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 a9c688489b
commit 0991cedb8e
6 changed files with 101 additions and 9 deletions

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
}