feat(R3): draw.committedW — le registre des watts achetés, préalable à counts

L'app a validé le préalable sans réserve, et sa formulation de pourquoi counts
n'a pas été livré mérite d'être gardée : une identité dépendante du temps est
pire que pas d'identité. Vraie au premier cycle après un changement, fausse après
convergence, elle passe tous les tests — qui portent sur des cycles isolés — et
certifie un total faux chez les clients, avec le sceau d'une identité publiée.

draw.committedW est publié dans la MÊME TRAME que budget : les deux décrivent le
même cycle, et les lire à deux instants ferait comparer deux mondes.

SEUL, et c'est la deuxième demande, celle qui compte. authorisedW, remainingW,
binding, perPhaseBound sont ABSENTS, jamais à 0 — un authorisedW: 0 assorti d'un
binding par défaut afficherait un plafond de soutirage qui n'existe pas, avec sa
source. Un chiffre crédible et faux, c'est-à-dire la faute même que l'objet doit
rendre impossible.

budget.evReservedW n'a plus qu'UNE nature. Il recevait la part réseau
d'EV_GRID_START — une vraie tranche d'allocation — en plus de la correction A.
Déménagée dans draw.committedW. Sans ce déplacement, aucune définition du champ
n'aurait été vraie pour ses deux termes, et l'app aurait renommé son libellé sur
une définition à moitié fausse.

Sa définition est écrite, puisqu'elle affichait « Réservé au véhicule » : c'est
une CORRECTION du budget de surplus, pas une réservation ; elle tend vers 0 à
mesure que la mesure rattrape la commande, sans qu'aucune décision n'ait changé.
Le contrat dit : ne l'additionner à rien, il ne se réconcilie jamais. Le libellé
affirme deux choses fausses — que c'est réservé, et que ça concerne le véhicule
plutôt que la latence de sa mesure.

EV_GRID_START cesse d'être une exception : ses deux moitiés tombent dans des
compteurs de même nature. Le partage demeure — c'est un fait, une part est
achetée — mais il ne s'énonce plus comme une entorse.

Publication seule, aucune arithmétique de budget ne change : vérifié avant
d'écrire que la ligne alimentant evReservedW depuis la cascade n'avait aucun
retour dans le calcul.

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-30 08:58:11 +02:00
parent 4e5af02d01
commit 0638008a9f
8 changed files with 200 additions and 19 deletions

View File

@ -593,6 +593,54 @@ le cas normal.
> 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.
### `draw` — le registre des watts ACHETÉS
Publié **dans la même trame que `budget`** : les deux décrivent le même cycle, et les lire à deux
instants ferait comparer deux mondes.
```jsonc
"draw": { "committedW": 1400 } // watts achetés au réseau ce cycle
```
**Un seul champ, et c'est délibéré.** `authorisedW`, `remainingW`, `binding` et `perPhaseBound`
viendront avec le transport du §14a : **absents, jamais à `0`**. Un `authorisedW: 0` assorti d'un
`binding` par défaut ferait afficher un plafond de soutirage **qui n'existe pas, avec sa source**
— un chiffre crédible et faux, c'est-à-dire la faute même que cet objet doit rendre impossible.
> **Ce qu'il compte** : ce que le moteur a **décidé d'acheter** selon son modèle de financement,
> pas ce que le compteur verra passer. Pour une action du proxy — échéance, tarif dynamique —
> **toute** l'allocation est comptée comme achetée : l'échéance décide de charger qu'il y ait du
> soleil ou non, et aucun partage n'est calculé. Là où le moteur **sait** partager, il partage :
> `EV_GRID_START` ne verse ici que sa part réseau, sa part surplus allant dans
> `budget.allocatedW`.
>
> **Omis en mode dégradé**, comme `budget` : sans plan, il n'y a pas de soutirage décidé.
### ⚠️ `budget.evReservedW` n'est PAS un registre — et ne se réconcilie jamais
*(Définition demandée par l'app le 2026-08-30, qui l'affichait sous « Réservé au véhicule » — un
libellé qui affirme une réservation là où il y a une correction.)*
**`evReservedW` est une CORRECTION du budget de surplus.** Il vaut la somme des puissances
**commandées mais pas encore visibles au compteur** pour les charges servies au réseau. Il **tend
vers 0** à mesure que la mesure rattrape la commande, **sans qu'aucune décision n'ait changé** :
c'est un rattrapage de **latence**, pas un montant mis de côté.
| | `budget.allocatedW` | `budget.evReservedW` | `draw.committedW` |
|---|---|---|---|
| Nature | registre d'allocation (surplus) | **correction** de budget | registre des watts achetés |
| Réconciliable | oui | **non, et jamais** | oui |
| Varie sans décision | non | **oui** — décroît quand la mesure rattrape | non |
> **Ne l'additionnez à rien.** Le sommer avec des allocations compare deux natures, et le total
> obtenu serait **vrai au premier cycle après un changement, faux après convergence** — donc
> juste dans un test portant sur un cycle isolé, et faux chez le client. Une identité dépendante
> du temps est **pire que pas d'identité** : elle porte le sceau d'une identité publiée.
>
> **Depuis `1.15.2+etm31` il n'a plus qu'UNE nature.** Il recevait aussi la part réseau
> d'`EV_GRID_START` — une vraie tranche d'allocation — ce qui rendait toute définition du champ
> fausse pour l'un des deux termes. Cette part est passée dans `draw.committedW`.
### Les TROIS nombres d'une charge — décidé, appliqué, mesuré
*(Rendu explicite le 2026-08-30, après qu'une prémisse de la maquette app eut confondu les deux
@ -638,16 +686,16 @@ valait `"surplus"`, elle porte maintenant sur les **niveaux**. Un client qui la
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`.
> **`EV_GRID_START` n'est plus une exception depuis `+etm31`.** Ce motif partage **une**
> allocation entre deux registres, et les deux sont désormais des registres : `params.budgetW`
> va dans `budget.allocatedW`, `params.gridW` dans **`draw.committedW`** — et
> `budgetW + gridW == allocatedW` toujours. Sa contribution au surplus reste
> `decision.params.budgetW`, pas `targetW`.
>
> 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.
> Le partage lui-même ne disparaîtra pas — c'est un fait, une part est achetée et l'autre non.
> Ce qui disparaît est l'**exception** : les deux moitiés tombent maintenant dans des compteurs
> de même nature. `counts` (**R3**) publiera ce partage par niveau plutôt que de le laisser
> déduire du motif.
>
> `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

@ -774,7 +774,48 @@ correction, pas un compteur.
| `budget.evReservedW` | **cesse d'être une destination** — redevient une correction du budget, et le contrat le dira |
Avec cette forme, `Σ counts == targetW` tient **sans exception**, `EV_GRID_START` comprise
(900 → `allocatedW`, 480 → `draw.committedW`). Dites-nous, et nous l'écrivons dans la foulée.
(900 → `allocatedW`, 480 → `draw.committedW`).
### ✅ LIVRÉ — `draw.committedW`, seul, `1.15.2+etm31`
```jsonc
"budget": { … },
"draw": { "committedW": 480 } // même trame que budget
```
**Vos trois demandes sont tenues.**
**1. Par cycle, en watts, même trame que `budget`.** Publié dans le même instantané, jamais
séparément — sinon l'identité redeviendrait temporelle par un autre chemin.
**2. Seul.** `authorisedW`, `remainingW`, `binding`, `perPhaseBound` : **absents**, pas à zéro.
Votre argument est dans le code et dans le contrat, en toutes lettres — un `authorisedW: 0`
assorti d'un `binding` par défaut afficherait un plafond qui n'existe pas, avec sa source.
**3. La définition d'`evReservedW`**, et elle justifie de changer votre libellé :
> **`evReservedW` est une CORRECTION du budget de surplus, pas une réservation.** Somme des
> puissances **commandées mais pas encore visibles au compteur** pour les charges servies au
> réseau. Elle **tend vers 0** à mesure que la mesure rattrape la commande, **sans qu'aucune
> décision n'ait changé** : c'est un rattrapage de **latence**, pas un montant mis de côté.
« Réservé au véhicule » affirme donc deux choses fausses : que c'est réservé, et que ça concerne
le véhicule plutôt que la latence de sa mesure. Quelque chose comme « commandé, pas encore
mesuré » dirait ce que c'est — mais c'est votre écran.
> **Et il n'a plus qu'une nature depuis `+etm31`.** Il recevait aussi la part réseau
> d'`EV_GRID_START`, une vraie tranche d'allocation ; nous l'avons déplacée dans
> `draw.committedW`. Sans ce déménagement, aucune définition du champ n'aurait été vraie pour
> ses deux termes — et vous auriez renommé un libellé sur une définition à moitié fausse.
`EV_GRID_START` **cesse d'être une exception** : ses deux moitiés tombent désormais dans des
compteurs de **même nature** (900 → `budget.allocatedW`, 480 → `draw.committedW`). Le partage
demeure — c'est un fait — mais il ne s'énonce plus comme une entorse. **Votre sonde peut aller le
voir sur `.75` dès que nous aurons posé `+etm31`.**
**`counts` suit maintenant**, dans la forme arbitrée : clés opaques jamais recyclées, publié même
à zéro avec sa destination, et faisant foi pour la comptabilité — les `params` gardant la phrase
du motif.
> 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

@ -1023,6 +1023,19 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
budget.insert(QStringLiteral("remainingW"), plan.budget.remainingW);
snap.insert(QStringLiteral("budget"), budget);
// R3 — LE SOUTIRAGE, dans la MÊME TRAME que le budget. Les deux décrivent le même cycle ;
// les publier séparément ferait comparer deux instants, et l'identité de réconciliation
// redeviendrait temporelle par un autre chemin — ce que R3 existe pour empêcher.
//
// UN SEUL CHAMP, et c'est délibéré. authorisedW, remainingW, binding et perPhaseBound
// viendront avec le transport du §14a : absents, JAMAIS à 0. Un authorisedW: 0 assorti d'un
// binding par défaut ferait afficher un plafond de soutirage qui n'existe pas, avec sa
// source — un chiffre crédible et faux, c'est-à-dire la faute même que cet objet doit
// rendre impossible.
QVariantMap draw;
draw.insert(QStringLiteral("committedW"), plan.draw.committedW);
snap.insert(QStringLiteral("draw"), draw);
// On itère les ACTIONS, pas les tables d'adaptateurs : l'ordre du waterfall (rang 1 servi en
// premier) est porté par le slot, et QHash n'en garantit aucun. Un ordre instable ferait
// aussi diverger la comparaison de seuil d'un cycle à l'autre, pour rien.

View File

@ -88,6 +88,7 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
// (invariant 8). Les consignes EV de CE cycle ne sont pas encore visibles au
// compteur ; on réserve donc leur puissance commandée non encore mesurée.
double evReservedW = 0;
double drawCommittedW = 0; // R3 — watts ACHETÉS ce cycle
for (const LoadAction &la : slot.actions) {
if (la.kind != LoadAction::Setpoint || !la.chargingEnabled)
continue;
@ -99,6 +100,16 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
EvCharger *ev = evs.value(ThingId(la.loadId));
const double measuredW = ev ? ev->currentPower() : 0.0;
evReservedW += qMax(0.0, la.estimatedPowerW - measuredW);
// R3 — LE REGISTRE DES WATTS ACHETÉS. Distinct de la correction ci-dessus, et la
// distinction est la découverte du lot : evReservedW décroît à mesure que la mesure
// rattrape la commande, alors que ce qui a été ACHETÉ ne bouge pas. Confondre les deux
// donnerait une identité vraie au premier cycle et fausse après convergence — qui
// passerait tous les tests, puisqu'ils portent sur des cycles isolés.
//
// Toute l'allocation du proxy y entre : l'échéance décide de charger qu'il y ait du
// soleil ou non, et le moteur ne calcule aucun partage pour elle. Là où il SAIT
// partager, il partage — voir EV_GRID_START.
drawCommittedW += la.estimatedPowerW;
}
// Surplus net SIGNÉ : exportW − importW = −puissance compteur. Négatif en import →
// l'ECS déleste (budget < palier) au lieu de rester allumé sur le réseau. (Le maintien
@ -161,6 +172,8 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
// été alloué et ce qui a été recrédité. La recalculer ailleurs donnerait deux comptes à
// tenir d'accord, et le chiffre publié serait le premier à mentir.
PlanBudget compta;
PlanDraw draw; // registre des watts achetés — R3
draw.committedW = drawCommittedW; // ce que le proxy a déjà engagé au réseau
compta.surplusW = remainingSurplusW;
compta.evReservedW = evReservedW;
@ -208,7 +221,7 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
if (lc.adapter == QStringLiteral("sg-ready"))
slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta));
else
slot.actions.append(buildSetpointAction(lc, remainingSurplusW, compta)); // etmvariableload + relay-router + evcharger
slot.actions.append(buildSetpointAction(lc, remainingSurplusW, compta, draw)); // etmvariableload + relay-router + evcharger
// 3g-2 — c'est ICI que le jeu se constitue, au moment où la décision est prise. Le
// redéduire plus tard depuis les motifs ou le financement dériverait du vrai au
// premier motif ajouté (EV_GRID_START porte funding=Grid tout en venant du waterfall).
@ -225,6 +238,9 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
compta.remainingW = remainingSurplusW;
plan.budget = compta;
// R3 — le soutirage est publié DANS LA MÊME TRAME que le budget : les deux décrivent le
// même cycle, et les lire à deux instants ferait comparer deux mondes.
plan.draw = draw;
plan.waterfallEvIds = waterfallEvIds;
return plan;
}
@ -248,7 +264,7 @@ LoadAction RuleBasedScheduler::buildTimeRequirementAction(EvCharger *ev,
LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
double &remainingSurplusW,
PlanBudget &budget) const
PlanBudget &budget, PlanDraw &draw) const
{
// Recrédit anti-clignotement (Correction B / contrat rév. 2 §5) : la conso de DÉBUT de cycle
// est déjà soustraite de l'export mesuré → on la rend au budget local avant d'arrondir.
@ -351,8 +367,13 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
// réseau et se compte là où vivent déjà les puissances commandées non encore
// mesurées. Laisser le budget filer en négatif ferait porter le soutirage par
// la cascade, où il se lirait comme un manque de surplus chez les suivantes.
budget.allocatedW += budgetW;
budget.evReservedW += soutireW;
// R3 — le moteur SAIT partager ici, donc il partage : la part surplus entre
// dans le registre d'allocation, la part achetée dans le registre de soutirage.
// Elle allait dans evReservedW, qui est une CORRECTION : une tranche
// d'allocation y rendait toute définition du champ fausse pour l'un des deux
// termes, et interdisait de le nommer honnêtement à l'écran.
budget.allocatedW += budgetW;
draw.committedW += soutireW;
remainingSurplusW = 0;
return la;
}

View File

@ -89,8 +89,11 @@ private:
* qu'une charge consomme déjà — c'est-à-dire dans le cas normal.
* \return LoadAction kind=Setpoint, funding=Surplus, \c powerW = consigne, \c reason non vide.
*/
//! \param draw Registre des watts ACHETÉS (R3) — alimenté par EV_GRID_START, qui est le
//! seul endroit de la cascade où le moteur sache partager une allocation entre le
//! surplus et le réseau.
LoadAction buildSetpointAction(const LoadContext &lc, double &remainingSurplusW,
PlanBudget &budget) const;
PlanBudget &budget, PlanDraw &draw) const;
/*!
* \brief Construit un LoadAction "state" SG-Ready (PAC) par mapping SÉMANTIQUE du surplus.

View File

@ -50,9 +50,21 @@ struct PlanBudget {
//! Budget entrant du waterfall non-EV : surplus net signé (export − import) MOINS la
//! réservation EV du cycle. Négatif = soutirage réseau, le waterfall déleste.
double surplusW = 0;
//! Puissance réservée aux consignes EV de CE cycle, pas encore visible au compteur
//! (correction A). Publiée pour que \c surplusW reste rapprochable de la mesure brute :
//! sans elle, l'écart avec le compteur serait inexplicable côté client.
/*!
* \brief CORRECTION du budget de surplus — **jamais une réservation**, malgré son nom.
*
* Somme des puissances **commandées mais pas encore visibles au compteur** pour les charges
* servies au réseau (correction A). Elle **tend vers 0** à mesure que la mesure rattrape la
* commande, sans qu'aucune décision n'ait changé : c'est un rattrapage de **latence**, pas
* un montant mis de côté.
*
* \warning **Ce champ n'est pas réconciliable, et il ne le sera jamais** — ce n'est pas un
* registre. Additionner des allocations avec lui compare deux natures. Les watts
* **achetés** ont leur registre : \c PlanDraw::committedW.
* \note Depuis `1.15.2+etm31` il n'a plus qu'UNE nature. Il recevait aussi la part réseau
* d'`EV_GRID_START` — une vraie tranche d'allocation — ce qui rendait toute définition du
* champ fausse pour l'un des deux termes. Cette part est passée dans \c PlanDraw.
*/
double evReservedW = 0;
//! Somme des puissances ALLOUÉES aux charges non-EV par le waterfall de ce cycle — le
//! total, pas l'incrément. C'est la somme des \c estimatedPowerW des actions du créneau.
@ -64,6 +76,29 @@ struct PlanBudget {
double remainingW = 0;
};
/*!
* \brief Le SOUTIRAGE du cycle — registre des watts **achetés au réseau**.
*
* Publié à côté de \c budget, dans la **même trame** : les deux décrivent le même cycle, et les
* lire à deux instants ferait comparer deux mondes.
*
* \note **Ce que le moteur a décidé d'acheter selon son modèle de financement**, pas ce que le
* compteur verra passer. Pour une action du proxy — échéance, tarif dynamique — le modèle
* traite **toute** l'allocation comme achetée : l'échéance décide de charger qu'il y ait du
* soleil ou non, et aucun partage n'est calculé. Là où le moteur SAIT partager, il partage :
* `EV_GRID_START` ne verse ici que sa part réseau, sa part surplus allant dans
* \c PlanBudget::allocatedW.
* \note **Un seul champ pour l'instant, et c'est délibéré.** `authorisedW`, `remainingW`,
* `binding` et `perPhaseBound` viendront avec le transport du §14a — absents, **jamais à 0** :
* un `authorisedW: 0` assorti d'un `binding` par défaut ferait afficher un plafond de
* soutirage qui n'existe pas, avec sa source. Un chiffre crédible et faux, c'est-à-dire la
* faute même que cet objet doit rendre impossible.
*/
struct PlanDraw {
//! Watts achetés au réseau ce cycle. Toujours ≥ 0.
double committedW = 0;
};
/*!
* \brief Plan d'optimisation retourné par IScheduler::getPlan().
*
@ -80,6 +115,8 @@ struct Plan {
//! Comptabilité du budget du cycle — INTERNE, jamais sérialisée vers l'optimiseur socket
//! (même statut que \c LoadAction::funding). Alimente la télémétrie RPC.
PlanBudget budget;
//! Soutirage du cycle — registre des watts achetés (\c PlanDraw).
PlanDraw draw;
/*!
* \brief [3g-2] Bornes VE dont le waterfall a pris la décision ce cycle — INTERNE.
*

View File

@ -821,6 +821,12 @@ QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema()
schema.insert("o:timestamp", enumValueName(String)); // ISO 8601 UTC
schema.insert("degradedMode", enumValueName(Bool));
schema.insert("o:budget", budgetItem);
// R3 — registre des watts ACHETÉS, dans la même trame que « budget ». Un seul champ
// aujourd'hui : les quatre autres du §14a sont ABSENTS, jamais à 0 (voir buildTelemetry).
// Omis en mode dégradé, comme « budget » : sans plan, il n'y a pas de soutirage décidé.
QVariantMap drawItem;
drawItem.insert("committedW", enumValueName(Double));
schema.insert("o:draw", drawItem);
schema.insert("loads", QVariantList() << loadItem);
return schema;
}

View File

@ -5345,8 +5345,20 @@ void Simulation::testEvGridStartIsAnnouncedAndBounded()
// ---- La comptabilité du budget partage l'allocation ------------------------------------
const QVariantMap budget = snap.value("budget").toMap();
QCOMPARE(qRound(budget.value("allocatedW").toDouble()), 900); // la part de SURPLUS
QCOMPARE(qRound(budget.value("evReservedW").toDouble()), 480); // la part de RÉSEAU
QCOMPARE(qRound(budget.value("remainingW").toDouble()), 0);
// R3 — la part ACHETÉE a quitté evReservedW pour le registre du soutirage. evReservedW est
// une CORRECTION du budget (commandé non encore mesuré) : y verser une tranche
// d'allocation rendait toute définition du champ fausse pour l'un des deux termes, et
// interdisait de le nommer honnêtement à l'écran.
QCOMPARE(qRound(snap.value("draw").toMap().value("committedW").toDouble()), 480);
QCOMPARE(qRound(budget.value("evReservedW").toDouble()), 0);
// budgetW + gridW == allocatedW tient toujours, et se lit désormais SANS exception :
// la part surplus dans le registre d'allocation, la part achetée dans celui du soutirage.
QCOMPARE(qRound(budget.value("allocatedW").toDouble()
+ snap.value("draw").toMap().value("committedW").toDouble()),
qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()));
#endif
}