feat(prévision PV): GetForecastSnapshot publie les corrections par plan (perField, D2)

Décision de Patrick du 2026-10-13. Le `corrections` du site reste
l'intersection (+ `ac-limit` si plafond) ; le détail se lit désormais en
donnée : `perField`, une entrée par plan sommé, `{id, corrections}`,
triée par id, présente seulement avec une courbe. Pas de booléen
« divergent ». Le journal garde son message.

- EnergyArbitrator::forecastSnapshot() : paramètre de sortie optionnel
  `parPlan` (ForecastPlanLu) ;
- handler : `o:perField` déclaré et servi ;
- mock : classe `pvPlane` (état forecastCurveTransport) + addPvPlane() ;
  premier test qui fait passer une COURBE par le RPC
  (testTheSnapshotPublishesEachPlanesCorrections : divergence,
  concordance, plafond, refus). Contre-épreuve : parPlan non rempli →
  le test échoue (0 entrée au lieu de 2) ;
- OPTIMIZER_PROTOCOL §5.2, INTERFACE.md, design §30.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-13 06:48:11 +02:00
parent 5468192cf8
commit 96148b9153
11 changed files with 211 additions and 6 deletions

View File

@ -1389,3 +1389,10 @@ restent l'état de leur date, et c'est ici qu'on lit ce qui les corrige.*
Une prévision **dégradée** (température absente) se lit dans `corrections: []`, pas dans
`conversion`. Correction **avant publication** (`OPTIMIZER_PROTOCOL.md` §0, 1.1) : aucun
émetteur n'a jamais produit `fallback`.
2. **Corrections PAR PLAN, en donnée.** Le §29 publiait l'intersection des corrections et ne
disait la divergence qu'au journal. `GetForecastSnapshot` porte désormais `perField` : une
entrée par plan sommé, `{id, corrections}`. Le `corrections` du site reste l'intersection
(+ `ac-limit` si plafond). **Pas de booléen « divergent »** : la divergence se lit dans les
données. `OPTIMIZER_PROTOCOL.md` §5.2 ajoute `corrections` aux entrées de `perField`. Le
journal garde son message. Le mock porte une classe `pvPlane` : pour la première fois, un
test fait passer une **courbe** par le RPC (trou n° 8 du rapport T1, refermé pour le snapshot).

View File

@ -211,7 +211,8 @@ index par index ; `null` dans une case = valeur inconnue — **jamais 0, jamais
"acLimitW":8000,
"p50W":[ ... ],
"perField":[
{"id":"<thingId plan>","label":"Toit sud","peakW":6000,"tiltDeg":35,"azimuthDeg":0,"p50W":[ ... ]}
{"id":"<thingId plan>","label":"Toit sud","peakW":6000,"tiltDeg":35,"azimuthDeg":0,
"corrections":["temperature"],"p50W":[ ... ]}
]
},
"baseload":{"model":"none","p50W":[null, ...]},
@ -256,6 +257,10 @@ Règles :
- `perField` : optionnel, affichage et diagnostic. Convention d'orientation (Open-Meteo,
PVGIS) : `tiltDeg` 0 = horizontal, 90 = vertical ; `azimuthDeg` 0 = sud, −90 = est,
+90 = ouest, ±180 = nord.
- `corrections` (1.1) : corrections déclarées **par ce plan** (jamais `ac-limit`, qui est
une correction de site). Le `corrections` de `pv` est l'**intersection** de celles des
plans, plus `ac-limit` si un plafond est configuré : une divergence entre plans se lit
**ici**, dans les données — aucun booléen ne la résume.
- **`baseload`** : consommation **hors charges pilotées**. `model` : `profile` | `learned` |
`none` ; `none` + tableau de `null` autorisé (installation neuve). Le plugin la calcule
depuis le compteur racine moins les charges, chacune selon `measurement.source` (§5.3) ;

View File

@ -2348,10 +2348,13 @@ bool EnergyArbitrator::setForecastSiteId(const QString &siteId, std::optional<in
return true;
}
ForecastRevision EnergyArbitrator::forecastSnapshot(QString &fault, QVariantMap &faultParams) const
ForecastRevision EnergyArbitrator::forecastSnapshot(QString &fault, QVariantMap &faultParams,
QList<ForecastPlanLu> *parPlan) const
{
fault.clear();
faultParams.clear();
if (parPlan)
parPlan->clear();
ForecastRevision vide;
if (!m_tm)
return vide;
@ -2369,6 +2372,7 @@ ForecastRevision EnergyArbitrator::forecastSnapshot(QString &fault, QVariantMap
// LA COURBE VIENT DU CANAL, pas de la table. C'est toute la frontière.
QList<ForecastRevision> plans;
QList<ForecastPlanLu> lus;
QString refus;
for (Thing *thing : m_tm->configuredThings()) {
if (thing->parentId().toString() != m_forecastSiteId
@ -2381,6 +2385,7 @@ ForecastRevision EnergyArbitrator::forecastSnapshot(QString &fault, QVariantMap
const ForecastRevision rev = ForecastStore::depuisTransport(charge, r);
if (!r.isEmpty()) { refus = r; continue; }
plans.append(rev);
lus.append({thing->id().toString(), rev.corrections});
}
// UNE CHARGE UTILE REFUSÉE N'EST PAS UNE PRÉVISION NULLE, et le motif existe pour que
@ -2400,6 +2405,15 @@ ForecastRevision EnergyArbitrator::forecastSnapshot(QString &fault, QVariantMap
if (site.points.isEmpty())
return vide;
site.siteId = m_forecastSiteId;
// CHAQUE PLAN SOMMÉ, avec ses corrections — rendu seulement avec une courbe. La courbe du
// site n'annonce que l'intersection ; sans le détail, « un plan sans température » et
// « aucun plan corrigé » se liraient pareil. Trié, pour qu'un instantané se compare.
if (parPlan) {
std::sort(lus.begin(), lus.end(), [](const ForecastPlanLu &a, const ForecastPlanLu &b) {
return a.planThingId < b.planThingId;
});
*parPlan = lus;
}
return site;
}

View File

@ -119,10 +119,19 @@ public:
*/
QStringList forecastSiteCandidates() const;
//! Un plan SOMMÉ, tel qu'il a été lu : son Thing et les corrections qu'il déclare.
struct ForecastPlanLu {
QString planThingId;
QStringList corrections; //!< ordre canonique ; vide = conversion brute
};
/*!
* \brief La prévision COURANTE, lue DIRECTEMENT sur le canal — l'entrée, pas la trace.
* \param[out] fault Code de motif, vide si tout va bien.
* \param[out] faultParams Paramètres du motif.
* \param[out] parPlan Si non nul : un élément par plan SOMMÉ, trié par identifiant. Rempli
* seulement quand une courbe est rendue — vide sinon. C'est là que se lit une divergence
* de corrections entre plans : la courbe du site n'en porte que l'intersection (§30.2).
* \return La révision telle que le producteur la publie ; \c points vide si rien n'est
* lisible. **Une absence, jamais une courbe de zéros.**
*
@ -133,7 +142,8 @@ public:
* parce qu'elle est nette maintenant ; dans six semaines, le premier consommateur prendrait
* \c GetPvForecast sans y penser.
*/
ForecastRevision forecastSnapshot(QString &fault, QVariantMap &faultParams) const;
ForecastRevision forecastSnapshot(QString &fault, QVariantMap &faultParams,
QList<ForecastPlanLu> *parPlan = nullptr) const;
/*!
* \brief Construit l'arbitre central. Mêmes dépendances que \c SmartChargingManager amont,

View File

@ -395,7 +395,9 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
"With a curve come 'corrections' (physical corrections applied, canonical "
"order: 'temperature' by the provider, 'ac-limit' when the site has an AC "
"limit; empty = raw GTI x kWp x PR; when planes disagree, only the corrections "
"ALL of them received), 'conversion' ('physics' - this engine has no other) and "
"ALL of them received), 'perField' (one entry per summed plane: its thing 'id' "
"and the 'corrections' it declared - where a divergence between planes is "
"read), 'conversion' ('physics' - this engine has no other) and "
"'acLimitW' when an AC limit is configured: the curve is the sum of the planes "
"CLIPPED to it.";
QVariantMap snapPoint;
@ -407,6 +409,10 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
returns.insert("o:model", enumValueName(String));
returns.insert("o:forecastPoints", QVariantList() << snapPoint);
returns.insert("o:corrections", QVariantList() << enumValueName(String));
QVariantMap snapPlan;
snapPlan.insert("id", enumValueName(Uuid)); // Thing plan
snapPlan.insert("corrections", QVariantList() << enumValueName(String));
returns.insert("o:perField", QVariantList() << snapPlan);
returns.insert("o:conversion", enumValueName(String)); // physics, seule valeur émise
returns.insert("o:acLimitW", enumValueName(Uint));
returns.insert("o:fault", enumValueName(String));
@ -1243,7 +1249,8 @@ JsonReply *NymeaEnergyJsonHandler::GetForecastSnapshot(const QVariantMap &params
return createReply(ret);
QString fault;
QVariantMap faultParams;
const ForecastRevision rev = arb->forecastSnapshot(fault, faultParams);
QList<EnergyArbitrator::ForecastPlanLu> parPlan;
const ForecastRevision rev = arb->forecastSnapshot(fault, faultParams, &parPlan);
if (!fault.isEmpty()) {
ret.insert(QStringLiteral("fault"), fault);
if (!faultParams.isEmpty())
@ -1281,6 +1288,13 @@ JsonReply *NymeaEnergyJsonHandler::GetForecastSnapshot(const QVariantMap &params
// Ce qui a été fait à la courbe, publié AVEC elle (OPTIMIZER_PROTOCOL §5.2) : une liste vide
// est une affirmation — conversion brute — pas une absence.
ret.insert(QStringLiteral("corrections"), QVariant(rev.corrections).toList());
// PAR PLAN, en DONNÉE : le `corrections` du site est l'intersection, et une divergence entre
// plans se lit ici — pas dans un booléen qui résumerait ce que les données disent déjà.
QVariantList plansLus;
for (const EnergyArbitrator::ForecastPlanLu &p : parPlan)
plansLus.append(QVariantMap{{QStringLiteral("id"), p.planThingId},
{QStringLiteral("corrections"), QVariant(p.corrections).toList()}});
ret.insert(QStringLiteral("perField"), plansLus);
// `physics` : le moteur GPL n'a pas d'autre conversion. Jamais `hybrid` (correction apprise,
// hors GPL, §5). Une prévision dégradée se lit dans `corrections`, pas ici (§30.1).
ret.insert(QStringLiteral("conversion"), QStringLiteral("physics"));

View File

@ -517,6 +517,17 @@ QUuid EnergyTestBase::addMeterNoEnergy(quint16 port)
return response.toMap().value("params").toMap().value("thingId").toUuid();
}
QUuid EnergyTestBase::addPvPlane(const QString &nom)
{
QVariantMap params;
params.insert("thingClassId", mockPvPlaneThingClassId.toString());
params.insert("name", nom);
QVariant response = injectAndWait("Integrations.AddThing", params);
verifyThingError(response);
return response.toMap().value("params").toMap().value("thingId").toUuid();
}
QUuid EnergyTestBase::addThermalProbe(quint16 port)
{
QVariantMap portParam;

View File

@ -59,6 +59,7 @@ static QUuid mockPowerSwitchThingClassId = QUuid("841f8905-d1d7-4053-909f-01123b
//! puissance, si bien qu'aucun test ne pouvait distinguer « non mesurée » de « en défaut »
//! (docs/INVENTAIRE_MOCK.md).
static QUuid mockDryRelayThingClassId = QUuid("5b2c7e41-9a06-4d3f-b8c1-6e0f2a7d4b39");
static QUuid mockPvPlaneThingClassId = QUuid("4e9b2f71-8c3a-4d56-a1e0-7f2b9c6d3e18");
static QUuid mockMeterNoEnergyThingClassId = QUuid("50dacc7b-7142-4716-8f59-97d6ee81f39c");
static QUuid mockThermalProbeThingClassId = QUuid("37e61ea3-e4b2-4d3e-bc82-66e350c29f66");
@ -141,6 +142,14 @@ public:
QUuid addThermalProbe(quint16 port = 26681);
//! Mock thing implémentant l'interface etmvariableload (states currentPowerW / powerSetpoint).
QUuid addEtmVariableLoad(quint16 port = 27001);
/*!
* \brief Ajoute un PLAN PV : un Thing qui porte l'état \c forecastCurveTransport.
* \param nom Nom du Thing.
* \return L'identifiant du Thing créé.
* \note Le plan naît SANS parent : le test le range sous un site (\c Thing::setParentId) et
* écrit sa charge utile. Sans lui, aucune courbe de prévision n'était productible.
*/
QUuid addPvPlane(const QString &nom = QStringLiteral("PV plane"));
void removeDevices();
QVariant removeDevice(const QUuid &thingId);

View File

@ -61,6 +61,7 @@ using namespace nymeaserver;
#include <QFileInfo>
#include <QHash>
#include <QScopeGuard>
#include <QtMath>
#include <QtGlobal>
#include <QProcess>
@ -7211,6 +7212,111 @@ void Simulation::testTheSiteAcLimitTravelsWithTheDesignation()
#endif
}
void Simulation::testTheSnapshotPublishesEachPlanesCorrections()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
// Décision du 2026-10-13 (§30.2) — les corrections PAR PLAN, en donnée. Le `corrections` du
// site est l'intersection : sans le détail, « un plan sans température » et « aucun plan
// corrigé » se liraient pareil. Pas de booléen « divergent » : la divergence se LIT.
//
// Premier test qui fait passer une COURBE par le RPC : le mock porte désormais une classe
// `pvPlane` (canal `forecastCurveTransport`), rangée ici sous un site par `setParentId`.
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
ThingManager *tm = NymeaCore::instance()->thingManager();
const QUuid site = addMeter(); // n'importe quel Thing configuré tient lieu de site
const QUuid est = addPvPlane(QStringLiteral("Toit est"));
const QUuid ouest = addPvPlane(QStringLiteral("Toit ouest"));
Thing *tEst = tm->findConfiguredThing(ThingId(est));
Thing *tOuest = tm->findConfiguredThing(ThingId(ouest));
QVERIFY(tEst && tOuest && !site.isNull());
tEst->setParentId(ThingId(site));
tOuest->setParentId(ThingId(site));
// Détacher les plans quoi qu'il arrive : un enfant restant ferait échouer le nettoyage.
const auto detacher = qScopeGuard([&]() {
tEst->setParentId(ThingId());
tOuest->setParentId(ThingId());
arb->setForecastSiteId(QString());
});
// L'identifiant AVEC accolades, tel que `candidateSiteThingIds` le rend. ⚠️ Sans accolades,
// la désignation est acceptée mais le snapshot reste muet : `forecastSnapshot()` compare des
// CHAÎNES (défaut préexistant, signalé dans le rapport des décisions du 2026-10-13).
const QString siteId = ThingId(site).toString();
const qint64 t0 = QDateTime(QDate(2026, 10, 13), QTime(10, 0), QTimeZone::utc()).toSecsSinceEpoch();
auto charge = [&](const QVariantList &corrections, const QVariantList &p) {
return QVariantMap{{"v", 2}, {"unit", "W"}, {"convention", "mean over [t, t+step)"},
{"t0", t0}, {"step", 3600}, {"issuedAt", t0 - 3600},
{"model", "meteofrance_seamless"}, {"p", p},
{"corrections", corrections}};
};
auto snapshot = [&]() {
return injectAndWait("NymeaEnergy.GetForecastSnapshot").toMap().value("params").toMap();
};
// `perField` rendu sous forme { id → corrections }, l'ordre étant vérifié à part.
auto parPlan = [](const QVariantMap &snap) {
QMap<QUuid, QStringList> m;
for (const QVariant &e : snap.value("perField").toList())
m.insert(e.toMap().value("id").toUuid(), e.toMap().value("corrections").toStringList());
return m;
};
// 1. Plans DIVERGENTS : l'un corrigé en température, l'autre brut. Plafond 2000 W.
tEst->setStateValue("forecastCurveTransport", charge({"temperature"}, {1500, 1200}));
tOuest->setStateValue("forecastCurveTransport", charge({}, {1200, 300}));
QVariantMap r = injectAndWait("NymeaEnergy.SetForecastSite",
{{"forecastSiteThingId", siteId}, {"acLimitW", 2000}}).toMap();
QCOMPARE(r.value("params").toMap().value("energyError").toString(), QStringLiteral("EnergyErrorNoError"));
QVariantMap snap = snapshot();
QVERIFY2(snap.contains("forecastPoints"), qUtf8Printable(QJsonDocument::fromVariant(snap).toJson()));
const QVariantList pts = snap.value("forecastPoints").toList();
QCOMPARE(pts.count(), 2);
QCOMPARE(pts.at(0).toMap().value("forecastWh").toDouble(), 2000.0); // 2700 écrêté
QCOMPARE(pts.at(1).toMap().value("forecastWh").toDouble(), 1500.0);
// Le site : l'intersection, plus le plafond. La température N'Y EST PAS — un plan ne l'a pas.
QCOMPARE(snap.value("corrections").toStringList(), QStringList{"ac-limit"});
QCOMPARE(snap.value("conversion").toString(), QStringLiteral("physics"));
QCOMPARE(snap.value("acLimitW").toInt(), 2000);
// Le détail : une entrée par plan sommé, et la divergence se lit dans les données.
QMap<QUuid, QStringList> plans = parPlan(snap);
QCOMPARE(plans.count(), 2);
QCOMPARE(plans.value(est), QStringList{"temperature"});
QCOMPARE(plans.value(ouest), QStringList{});
// ac-limit est une correction du SITE : elle n'apparaît dans aucune entrée de plan.
for (const QStringList &c : plans)
QVERIFY(!c.contains("ac-limit"));
// Trié par identifiant : un instantané se compare.
const QVariantList brut = snap.value("perField").toList();
QVERIFY(brut.at(0).toMap().value("id").toUuid().toString()
< brut.at(1).toMap().value("id").toUuid().toString());
// 2. Plans CONCORDANTS, sans plafond : la température remonte au site, `ac-limit` disparaît,
// et `acLimitW` est omis — jamais 0.
tOuest->setStateValue("forecastCurveTransport", charge({"temperature"}, {1200, 300}));
r = injectAndWait("NymeaEnergy.SetForecastSite", {{"forecastSiteThingId", siteId}}).toMap();
QCOMPARE(r.value("params").toMap().value("energyError").toString(), QStringLiteral("EnergyErrorNoError"));
snap = snapshot();
QCOMPARE(snap.value("corrections").toStringList(), QStringList{"temperature"});
QVERIFY(!snap.contains("acLimitW"));
QCOMPARE(snap.value("forecastPoints").toList().at(0).toMap().value("forecastWh").toDouble(), 2700.0);
plans = parPlan(snap);
QCOMPARE(plans.value(est), QStringList{"temperature"});
QCOMPARE(plans.value(ouest), QStringList{"temperature"});
// 3. Un plan REFUSÉ : ni courbe, ni détail par plan — le motif seul.
tOuest->setStateValue("forecastCurveTransport", charge({"inconnue"}, {1200, 300}));
snap = snapshot();
QCOMPARE(snap.value("fault").toString(), QString::fromLatin1(DecisionCode::ForecastPayloadRefused));
QVERIFY(!snap.contains("forecastPoints"));
QVERIFY(!snap.contains("perField"));
#endif
}
void Simulation::testEveryGetterReturnsWhatItDeclares()
{
// LE TROU QUE LE 2026-09-09 A OUVERT.

View File

@ -213,6 +213,7 @@ private slots:
void testSpotMarketIsOutOfTheBuild();
//! Brief 3 T1 — acLimitW voyage avec la désignation de site, absent = aucun plafond.
void testTheSiteAcLimitTravelsWithTheDesignation();
void testTheSnapshotPublishesEachPlanesCorrections();
//! TOUTE méthode Get* du namespace rend ce que sa déclaration promet.
void testEveryGetterReturnsWhatItDeclares();
//! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro.

View File

@ -439,6 +439,14 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info)
info->finish(Thing::ThingErrorNoError);
return;
} else if (thing->thingClassId() == pvPlaneThingClassId) {
// PLAN PV — un canal `forecastCurveTransport`, rien d'autre. Le test le RANGE sous un
// site (`Thing::setParentId`) et y écrit la charge utile lui-même : sans cette classe,
// aucun test ne pouvait produire une courbe de prévision, et le snapshot n'était exercé
// que dans ses cas vides.
info->finish(Thing::ThingErrorNoError);
return;
} else if (thing->thingClassId() == powerSwitchThingClassId) {
EnergyMockController *controller = new EnergyMockController(thing, this);
ParamType paramType = thing->thingClass().paramTypes().findByName("port");

View File

@ -1211,8 +1211,28 @@
"writable": true
}
]
},
{
"name": "pvPlane",
"displayName": "Mocked PV plane (canal forecastCurveTransport)",
"id": "4e9b2f71-8c3a-4d56-a1e0-7f2b9c6d3e18",
"createMethods": [
"user"
],
"paramTypes": [
],
"stateTypes": [
{
"id": "6a1c3e85-2d7f-4b90-9e14-0c8a5f3b7d22",
"name": "forecastCurveTransport",
"displayName": "Forecast curve — transport channel, not a value",
"type": "QVariantMap",
"defaultValue": {},
"cached": false
}
]
}
]
}
]
}
}