fix(optimiseur): pv.currentW publié dès qu'un smartmeterproducer existe, pas seulement un solarinverter

`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 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-17 01:51:21 +02:00
parent 6481515b64
commit c81c7b6225
5 changed files with 55 additions and 4 deletions

View File

@ -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`.

View File

@ -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())};

View File

@ -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<double> pvCurrentW;
//! Limite par phase en A (\c site.phaseLimitA) ; vide = celle du contexte, sinon \c null.
QList<double> phaseLimitA;

View File

@ -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();

View File

@ -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<EnergyManagerMock *>(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"