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 d1666143f7
commit cd010dd28c
5 changed files with 146 additions and 22 deletions

View File

@ -528,7 +528,9 @@ mécanisme, et **motif de décision sous forme de code**.
{
"loadId": "ecs-chauffe-eau",
"allocatedW": 3500, // ce que le waterfall a ALLOUÉ (pas la mesure)
"funding": "surplus", // o: — « surplus » ou « grid », voir plus bas
"levels": [ // une entrée par passe RÉELLEMENT parcourue
{ "level": "comfort", "targetW": 3500, "funding": "surplus" }
],
"available": true,
"faultCode": "WRITE_FAILED", // o: — absent = aucun défaut
"lock": { "kind": "minOff", "remainingS": 42 }, // o: — absent = aucun verrou actif
@ -562,12 +564,58 @@ le cas normal.
> `allocatedW` **des charges financées au surplus** retombe sur `budget.allocatedW` — ce qui
> n'était pas vrai avant.
>
> **`funding` (`o:`, depuis 3g-1)** — `"surplus"` ou `"grid"`. Il existe parce que les bornes
> peuvent être dans `loads[]` pour deux raisons différentes : la **cascade** (surplus, comptée
> dans `budget.allocatedW`) ou le **proxy** — échéance de départ, tarif dynamique — qui soutire
> au réseau et dont la puissance vit dans `budget.evReservedW`. Sommer `allocatedW` sans
> distinguer les deux donne un écart qu'aucun client ne peut interpréter : défaut, ou borne
> servie au réseau ? **Omis en mode dégradé** : sans plan, il n'y a pas de financement.
**`levels[]`** — **une entrée par passe RÉELLEMENT parcourue**, et c'est là que vit le
**financement**. *(R2 de la maquette des deux passes, 2026-08-29.)*
| Clé | Sens |
|---|---|
| `level` | `"eco"` ou `"comfort"` — quel BESOIN cette entrée sert |
| `targetW` | ce que le niveau a **DÉCIDÉ** (R4) — à ne pas confondre avec `mechanism.*`, qui dit ce qui a été **appliqué** |
| `funding` | `"surplus"` ou `"grid"` — l'origine des watts de CE niveau |
> **Aujourd'hui il y a exactement UN 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"` à zéro : **l'absence est l'information**, et un zéro forgé la
> détruirait.
>
> **`levels[]` entièrement absent** = box antérieure à `+etm30`, ou **mode dégradé** — sans
> plan, il n'y a pas de financement.
> ⚠️ **`funding` N'EXISTE PLUS au niveau de la charge**, et il ne revient pas en résumé. La
> raison n'est pas la propreté : 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 — « 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.
>
> **Pourquoi `decision` reste, lui, au niveau de la charge** : parce qu'on sait dire de quel
> niveau il parle (`decision.level`, R7). On conserve un champ hérité **quand on peut en dire
> quelque chose de vrai**, jamais par symétrie.
### L'identité de réconciliation — écrite ici, jamais reconstruite
```
Σ sur toutes les charges de loads[]
Σ sur tous les niveaux dont funding == "surplus"
niveau.targetW == budget.allocatedW
```
Elle a changé de forme avec R2 : elle portait sur `allocatedW` des charges dont le `funding`
valait `"surplus"`, elle porte maintenant sur les **niveaux**. Un client qui la reconstruirait
depuis l'ancienne forme divergerait **au premier cas mixte** — c'est-à-dire au premier cycle du
§10.
> **L'exception, une seule, et elle est publiée** : `EV_GRID_START`. Ce motif partage **une**
> allocation entre **deux** compteurs — `params.budgetW` va dans `budget.allocatedW`,
> `params.gridW` dans `budget.evReservedW`, et `budgetW + gridW == allocatedW` toujours. Le
> niveau porte alors `funding: "grid"` : pour l'identité ci-dessus, sa contribution au surplus
> n'est **pas** `targetW` mais `decision.params.budgetW`.
>
> C'est le seul endroit où un watt publié ne tombe pas dans un compteur unique, et c'est
> précisément ce que **R3** demande de généraliser — un mapping `(niveau, financement) →
> compteur` publié, plutôt qu'une exception à connaître. Tant que R3 n'est pas livrée,
> **l'exception est ici**, pas dans la tête du lecteur.
>
> `budget.evReservedW` **subsiste**, mais restreint : il ne compte plus que les bornes servies
> par un financement **réseau** (échéance de départ, spot market), qui restent décidées en

View File

@ -598,10 +598,37 @@ avez déjà les deux nombres aujourd'hui** — ce sont leurs noms qui étaient i
fiche technique. Votre lecture du point de rupture est confirmée : une charge refusée **après**
lui pour cause de palier ne le déplace pas.
### Ce que nous prenons en premier
### R2 est LIVRÉE — `1.15.2+etm30`
**R2**, pour votre raison : c'est la seule des huit qui soit un changement de **forme** d'un
champ existant. Les sept autres attendent sans se renchérir.
`funding` vit dans `levels[]`. **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.
```jsonc
"allocatedW": 3500,
"levels": [ { "level": "comfort", "targetW": 3500, "funding": "surplus" } ]
```
**`funding` a DISPARU du niveau de la charge, sans résumé.** 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. Le motif `decision`, lui,
reste — parce qu'on saura dire de quel niveau il parle (R7). **On garde un champ hérité quand on
peut en dire quelque chose de vrai, jamais par symétrie.**
**Et l'identité de réconciliation est écrite dans `INTERFACE.md`**, pour que vous n'ayez pas à la
reconstruire :
```
Σ charges Σ niveaux dont funding == "surplus" → niveau.targetW == budget.allocatedW
```
Avec **l'unique exception, publiée** : `EV_GRID_START` partage une allocation entre deux
compteurs, et sa contribution au surplus est `decision.params.budgetW`, pas `targetW`. C'est ce
que **R3** généralisera ; d'ici là l'exception est dans le contrat, pas dans la tête du lecteur.
> `levels[]` **absent** = box antérieure à `+etm30`, **ou mode dégradé** — sans plan, il n'y a
> pas de financement. Votre repli sur `allocatedW`/`decision` reste donc juste, et il couvre les
> deux cas.
> Et deux fils antérieurs de votre brief sont **clos**, au cas où ils ne vous seraient pas
> parvenus : l'**entrelacement des domaines** est tranché — grouper à l'affichage, **jamais à

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