feat(R2): le financement descend au niveau — funding vit dans levels[]

funding était par CHARGE. Au §10 la même charge sera financée au réseau pour son
plancher éco et au surplus pour son confort DANS LE MÊME CYCLE : un financement
par charge y devient indécidable, et c'est lui qui porte l'identité de
réconciliation de l'app. Laissé à la charge, cette identité cesserait d'être
vérifiable sans que rien ne le signale.

Déplacé maintenant bien que le §10 ne soit pas écrit : seule des huit exigences
de la maquette à être un changement de FORME d'un champ existant, donc seule à se
renchérir à chaque écran qui s'y appuie.

UN SEUL NIVEAU aujourd'hui, et c'est « comfort ». La passe unique d'aujourd'hui
est celle qui, au §10, servira le confort depuis le surplus — la passe éco est la
nouveauté du lot, pas la passe existante rebaptisée. C'est ce qui rend le
déplacement faisable avant le lot : la forme est juste dès maintenant.

funding N'EST PAS conservé en résumé sur la charge, et c'est la première des deux
questions posées. Le cas mixte est justement celui qui le rendrait indécidable :
« mixte » serait un troisième code que personne n'a demandé, « grid si l'un
l'est » une convention que chaque client redériverait autrement. Deux endroits
pour un fait divergent.

Le motif `decision` reste, lui, au niveau de la charge — parce qu'on saura dire
de quel niveau il parle (R7). L'asymétrie est la règle : on conserve un champ
hérité QUAND ON PEUT EN DIRE QUELQUE CHOSE DE VRAI, jamais par symétrie.

L'IDENTITÉ DE RÉCONCILIATION est écrite dans INTERFACE.md, seconde question
posée. Somme sur les charges, somme sur les niveaux dont funding == "surplus", de
niveau.targetW == budget.allocatedW. Un client qui la reconstruirait depuis
l'ancienne forme divergerait au premier cas mixte.

Et son unique exception est publiée plutôt que sue : EV_GRID_START partage une
allocation entre deux compteurs, sa contribution au surplus est
decision.params.budgetW et non targetW. C'est ce que R3 généralisera ; d'ici là
l'exception vit dans le contrat, pas dans la tête du lecteur.

levels[] absent = box antérieure OU mode dégradé — le repli de l'app couvre les
deux. Rupture assumée pour un client lisant entry.funding : même arbitrage que
measuredW → measurement.

simulation 33/33, charging 16/16, loadmodel 20/20, amd64 0/0, doxygen 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
This commit is contained in:
Patrick Schurig 2026-08-29 16:57:39 +02:00
parent 459ba9b009
commit 260534d648
3 changed files with 61 additions and 12 deletions

View File

@ -1048,7 +1048,12 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
QVariantMap orphan;
orphan.insert(QStringLiteral("loadId"), action.loadId);
orphan.insert(QStringLiteral("allocatedW"), 0);
orphan.insert(QStringLiteral("funding"), QStringLiteral("surplus"));
// R2 — le financement vit dans levels[], plus sur la charge. Une charge dont
// l'appareil manque n'a rien décidé : un seul niveau, à 0 W.
orphan.insert(QStringLiteral("levels"), QVariantList()
<< QVariantMap{{QStringLiteral("level"), QStringLiteral("comfort")},
{QStringLiteral("targetW"), 0.0},
{QStringLiteral("funding"), QStringLiteral("surplus")}});
orphan.insert(QStringLiteral("available"), false);
orphan.insert(QStringLiteral("decision"), action.reason.toMap());
orphan.insert(QStringLiteral("faultCode"), QStringLiteral("THING_MISSING"));
@ -1067,15 +1072,36 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
// vit dans la charge utile de mécanisme ; les confondre rendrait la somme des
// allocations irréconciliable avec budget.allocatedW.
entry.insert(QStringLiteral("allocatedW"), action.estimatedPowerW);
// Le FINANCEMENT, sans quoi la charge utile n'est pas réconciliable. Seules les actions
// financées au SURPLUS ont décrémenté la cascade et sont comptées dans
// budget.allocatedW ; celles financées au RÉSEAU — échéance et spot market, restées au
// proxy — portent une puissance qui vit dans budget.evReservedW. Sans ce champ, un
// client qui somme loads[].allocatedW trouve un écart et n'a aucun moyen de savoir
// s'il regarde un bug ou une borne servie au réseau.
entry.insert(QStringLiteral("funding"),
action.funding == LoadAction::Grid ? QStringLiteral("grid")
: QStringLiteral("surplus"));
// --- R2 : LE FINANCEMENT DESCEND AU NIVEAU -------------------------------------
//
// Il était par CHARGE. Au §10 la même charge sera financée au réseau pour son plancher
// éco et au surplus pour son confort DANS LE MÊME CYCLE : un financement par charge y
// devient indécidable, et c'est lui qui porte l'identité de réconciliation de l'app
// (« somme des parts financées au surplus == budget.allocatedW »). Laissé à la charge,
// cette identité cesserait d'être vérifiable sans que rien ne le signale.
//
// Déplacé MAINTENANT bien que le §10 ne soit pas écrit : c'est la seule des huit
// exigences de la maquette qui soit un changement de FORME d'un champ existant, donc la
// seule qui se renchérisse à chaque écran qui s'y appuie.
//
// Aujourd'hui UN SEUL niveau, et c'est « comfort » : la passe unique d'aujourd'hui est
// celle qui, au §10, servira le confort depuis le surplus. La passe éco est la
// nouveauté du lot, pas la passe existante rebaptisée. Une charge sans obligation éco
// n'aura jamais d'entrée « eco » — l'absence est l'information (R1).
//
// « funding » n'est PAS conservé en résumé sur la charge : voir INTERFACE.md. Le cas
// mixte est justement celui qui le rend indécidable, et deux endroits pour un fait
// divergent. Le motif « decision », lui, RESTE — parce qu'on saura dire de quel niveau
// il parle (R7), là où un financement de charge ne pourrait rien dire de vrai.
QVariantMap niveau;
niveau.insert(QStringLiteral("level"), QStringLiteral("comfort"));
// R4 — le niveau publie ce qu'il a DÉCIDÉ. C'est la même valeur qu'allocatedW tant
// qu'il n'y a qu'un niveau ; elle s'en séparera quand il y en aura deux.
niveau.insert(QStringLiteral("targetW"), action.estimatedPowerW);
niveau.insert(QStringLiteral("funding"),
action.funding == LoadAction::Grid ? QStringLiteral("grid")
: QStringLiteral("surplus"));
entry.insert(QStringLiteral("levels"), QVariantList() << niveau);
entry.insert(QStringLiteral("available"), adapter->telemetry().available);
entry.insert(QStringLiteral("decision"), action.reason.toMap());

View File

@ -757,6 +757,16 @@ QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema()
measurementItem.insert("source", enumValueName(String)); // meter | device | none
measurementItem.insert("o:powerW", enumValueName(Double));
// §10 / R1-R2-R4 — un élément par passe RÉELLEMENT parcourue. Aujourd'hui il y en a un
// seul, « comfort » : la passe unique d'aujourd'hui est celle qui servira le confort depuis
// le surplus. Une charge sans obligation éco n'aura jamais d'entrée « eco » à zéro —
// l'absence est l'information. « targetW » est ce que le niveau a DÉCIDÉ (R4), à ne pas
// confondre avec la charge utile de mécanisme, qui dit ce qui a été APPLIQUÉ.
QVariantMap levelItem;
levelItem.insert("level", enumValueName(String)); // eco | comfort
levelItem.insert("targetW", enumValueName(Double));
levelItem.insert("funding", enumValueName(String)); // surplus | grid
QVariantMap loadItem;
loadItem.insert("loadId", enumValueName(String));
// ALLOCATION du waterfall (ce qui a décrémenté le budget), pas la mesure : la mesure vit
@ -769,7 +779,11 @@ QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema()
// au réseau et dont la puissance vit dans budget.evReservedW. Sans ce champ, un client qui
// somme allocatedW trouve un écart sans pouvoir dire s'il regarde un défaut ou une borne
// servie au réseau. Omis en mode dégradé : sans plan, il n'y a pas de financement.
loadItem.insert("o:funding", enumValueName(String));
// R2 — le financement descend au NIVEAU (levels[]). Il n'est PAS conservé en résumé sur la
// charge : au §10 la même charge sera financée au réseau pour son éco et au surplus pour
// son confort dans le même cycle, et un résumé n'aurait alors aucune valeur vraie à porter.
// Deux endroits pour un fait divergent.
loadItem.insert("levels", QVariantList() << levelItem);
loadItem.insert("available", enumValueName(Bool));
loadItem.insert("o:faultCode", enumValueName(String)); // absent = aucun défaut
// §11 (LM-1101/1103) — mesures de la charge, publiées à côté de l'allocation et JAMAIS

View File

@ -5323,7 +5323,16 @@ void Simulation::testEvGridStartIsAnnouncedAndBounded()
QCOMPARE(qRound(pA.value("budgetW").toDouble() + pA.value("gridW").toDouble()), 1380);
// Le financement DIT la vérité sur l'origine : une part vient du réseau.
QCOMPARE(tele.value(borneAId.toString()).value("funding").toString(), QString("grid"));
// R2 — le financement vit dans levels[], plus sur la charge. Un seul niveau aujourd'hui.
const QVariantList nivA = tele.value(borneAId.toString()).value("levels").toList();
QCOMPARE(nivA.count(), 1);
QCOMPARE(nivA.first().toMap().value("level").toString(), QString("comfort"));
QCOMPARE(nivA.first().toMap().value("funding").toString(), QString("grid"));
// Et le niveau publie ce qu'il a DÉCIDÉ : tant qu'il n'y en a qu'un, c'est allocatedW.
QCOMPARE(qRound(nivA.first().toMap().value("targetW").toDouble()),
qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()));
QVERIFY2(!tele.value(borneAId.toString()).contains("funding"),
"funding ne doit plus être publié au niveau de la CHARGE");
// ---- Borné par construction : UNE borne par cycle --------------------------------------
// Le démarrage consomme TOUT le budget restant ; la suivante trouve zéro et repart sur son