From c81c7b622559f715c2678e1c8943522215a903e1 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 17 Oct 2026 01:51:21 +0200 Subject: [PATCH] =?UTF-8?q?fix(optimiseur):=20pv.currentW=20publi=C3=A9=20?= =?UTF-8?q?d=C3=A8s=20qu'un=20smartmeterproducer=20existe,=20pas=20seuleme?= =?UTF-8?q?nt=20un=20solarinverter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `solarinverter` étend `smartmeterproducer` dans libnymea. Un compteur de production posé sur un onduleur non communicant n'est que `smartmeterproducer` — c'est le PV du banc (sdm120Producer) : le filtre précédent y rendait `pv.currentW: null` alors que l'EnergyManager additionnait bien sa production. Même critère que la présence d'un producteur côté apprentissage. - OPTIMIZER_PROTOCOL.md §5.1 : `pv.currentW` documenté ; un seul producteur déclaré par source PV (nymea additionne les producteurs). - EnergyManagerMock::setCurrentPowerProduction() (test) : la production du mock valait toujours 0. - Test testPvIsReadFromAnyProducer : sans producteur → null ; smartmeterproducer seul → 2400. Co-Authored-By: Claude Opus 5.5 --- docs/OPTIMIZER_PROTOCOL.md | 7 ++++ energyplugin/etm/energyarbitrator.cpp | 10 ++++-- .../etm/scheduler/optimizerprotocol.h | 5 +-- .../simulation/experience/energymanagermock.h | 5 +++ .../socketscheduler/testsocketscheduler.cpp | 32 +++++++++++++++++++ 5 files changed, 55 insertions(+), 4 deletions(-) diff --git a/docs/OPTIMIZER_PROTOCOL.md b/docs/OPTIMIZER_PROTOCOL.md index 9c9e776..e1d1ff0 100644 --- a/docs/OPTIMIZER_PROTOCOL.md +++ b/docs/OPTIMIZER_PROTOCOL.md @@ -191,6 +191,13 @@ Réponse attendue : immédiatement (§4). - `meter.source` (1.1) : identité du compteur racine désigné (`Energy.SetRootMeter`), unique. - `meter.exportW` est la mesure **brute** (`AGENTS.md` règle 8) : jamais le net après pilotage. +- `pv.currentW` (1.1) : production instantanée du site en W, **positive**, telle que l'additionne + l'`EnergyManager` de nymea (`|currentPowerProduction|`). Publiée dès qu'au moins un Thing + `smartmeterproducer` est configuré — onduleur communicant (`solarinverter`, qui l'étend) **ou** + compteur de production posé sur un onduleur non communicant. Sans producteur : `null`, jamais + 0 (0 W ne distinguerait pas la nuit de l'absence de PV). **Un seul producteur déclaré par + source PV** : nymea additionne tous les producteurs, et un onduleur communicant doublé d'un + compteur de production sur la même chaîne compterait la même énergie deux fois. - `site.gridCapW` : réservé (signaux réseau type §14a EnWG). `null` en France. - `tariff` (1.0) reste émis pour compatibilité ; un optimiseur 1.1 lit `forecast.price`. diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 1e26668..7405a8a 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -2876,9 +2876,15 @@ OptimizerProtocol::ContexteFil EnergyArbitrator::contexteFil(const SurplusContex OptimizerProtocol::ContexteFil fil; if (m_em && m_em->rootMeter()) fil.meterSource = m_em->rootMeter()->id().toString(); - // La PV instantanée n'a de sens qu'avec un onduleur : sans lui, 0 W ne distinguerait pas la + // La PV instantanée n'a de sens qu'avec un PRODUCTEUR : sans lui, 0 W ne distinguerait pas la // nuit de l'absence de production — le champ reste alors inconnu (null). - if (m_em && m_tm && !m_tm->configuredThings().filterByInterface(QStringLiteral("solarinverter")).isEmpty()) + // + // `smartmeterproducer`, pas `solarinverter` : le second ÉTEND le premier dans libnymea, et un + // compteur de production posé sur un onduleur non communicant n'est que `smartmeterproducer` + // — c'est le PV du banc (sdm120Producer). Filtrer sur `solarinverter` y rendait `null` alors + // que l'EnergyManager additionnait bien sa production. Même critère que « le site a-t-il un + // producteur ? » de l'apprentissage (observerPourApprentissage()). + if (m_em && m_tm && !m_tm->configuredThings().filterByInterface(QStringLiteral("smartmeterproducer")).isEmpty()) fil.pvCurrentW = qAbs(m_em->currentPowerProduction()); if (phasePowerLimit() > 0) fil.phaseLimitA = {double(phasePowerLimit()), double(phasePowerLimit()), double(phasePowerLimit())}; diff --git a/energyplugin/etm/scheduler/optimizerprotocol.h b/energyplugin/etm/scheduler/optimizerprotocol.h index 2d64f76..be62c01 100644 --- a/energyplugin/etm/scheduler/optimizerprotocol.h +++ b/energyplugin/etm/scheduler/optimizerprotocol.h @@ -47,9 +47,10 @@ struct ContexteFil { QString trigger = QStringLiteral("cycle"); //! ThingId du compteur racine (\c meter.source) ; vide = inconnu → \c null. QString meterSource; - //! Production PV instantanée (\c pv.currentW) ; absente = inconnue → \c null. Le champ + //! Production PV instantanée (\c pv.currentW) ; absente = inconnue → \c null. Remplie dès + //! qu'un Thing \c smartmeterproducer existe (onduleur ou compteur de production). Le champ //! \c SurplusContext::pv n'est pas lu : il n'est jamais rempli, et son 0 ne distingue pas - //! « nuit » de « pas d'onduleur ». + //! « nuit » de « pas de producteur ». std::optional pvCurrentW; //! Limite par phase en A (\c site.phaseLimitA) ; vide = celle du contexte, sinon \c null. QList phaseLimitA; diff --git a/tests/auto/simulation/experience/energymanagermock.h b/tests/auto/simulation/experience/energymanagermock.h index 6d1c263..7803a40 100644 --- a/tests/auto/simulation/experience/energymanagermock.h +++ b/tests/auto/simulation/experience/energymanagermock.h @@ -54,6 +54,11 @@ public: EnergyLogs* logs() const override; + //! \brief Pose la production instantanée (W, négative = produite, convention nymea). + //! Le bilan de ce mock n'est jamais recalculé depuis les Things : sans ce réglage, la + //! production vaut 0 et aucun test ne peut distinguer « producteur à 0 W » de « mesuré ». + void setCurrentPowerProduction(double watts) { m_currentPowerProduction = watts; } + private: void updatePowerBalance(); diff --git a/tests/auto/socketscheduler/testsocketscheduler.cpp b/tests/auto/socketscheduler/testsocketscheduler.cpp index 12c2c73..d8d6fed 100644 --- a/tests/auto/socketscheduler/testsocketscheduler.cpp +++ b/tests/auto/socketscheduler/testsocketscheduler.cpp @@ -7,6 +7,7 @@ // à un relais de 1000 W (rang 1), un routeur etmvariableload de 3000 W (rang 2). #include "simulationbase.h" +#include "experience/energymanagermock.h" #include "../common/etm-powersync-fakeoptimizer.h" #include "energysettings.h" @@ -88,6 +89,7 @@ private slots: void testANewInvalidPlanReplacesTheCache(); void testConnectionLossDropsThePlan(); void testThePlanOfAnOptimizerBuysNothingEco(); + void testPvIsReadFromAnyProducer(); private: //! Banc commun ; \p avecOptimiseur faux = rien n'est configuré. @@ -698,6 +700,36 @@ void TestSocketScheduler::testThePlanOfAnOptimizerBuysNothingEco() QCOMPARE(p.value("purchasedWh").toDouble(), 0.0); // …et l'éco n'en a rien acheté } +void TestSocketScheduler::testPvIsReadFromAnyProducer() +{ + // `pv.currentW` se lit dès qu'un `smartmeterproducer` existe — pas seulement un + // `solarinverter`, qui l'ÉTEND dans libnymea. Le producteur du mock est exactement le cas + // du banc : un compteur de production (sdm120Producer) posé sur un onduleur non + // communicant, `smartmeterproducer` et rien d'autre. + monter(); + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + EnergyManagerMock *em = dynamic_cast(m_experiencePlugin->energyManager()); + QVERIFY(em); + em->setCurrentPowerProduction(-2400); // convention nymea : produite = négative + + // Sans producteur : inconnu (null), même si l'EnergyManager rend un chiffre — 0 W ne + // distinguerait pas la nuit de l'absence de PV. + cycle(t0, -3000); + QVariantMap pv = m_requetes.last().value("pv").toMap(); + QVERIFY(pv.contains("currentW")); + QVERIFY(pv.value("currentW").isNull()); + + const QUuid producteur = addSolarInverter(); + Thing *t = NymeaCore::instance()->thingManager()->findConfiguredThing(producteur); + QVERIFY(t); + QVERIFY2(t->thingClass().interfaces().contains("smartmeterproducer") + && !t->thingClass().interfaces().contains("solarinverter"), + "le cas éprouvé est un producteur qui N'EST PAS un onduleur"); + cycle(t0.addSecs(60), -3000); + pv = m_requetes.last().value("pv").toMap(); + QVERIFY2(!pv.value("currentW").isNull(), "un smartmeterproducer seul doit suffire"); + QCOMPARE(pv.value("currentW").toDouble(), 2400.0); +} QTEST_MAIN(TestSocketScheduler) #include "testsocketscheduler.moc"