diff --git a/INTERFACE.md b/INTERFACE.md index 96bde4c..baf751e 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -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 diff --git a/docs/BRIEF_depuis_plugin.md b/docs/BRIEF_depuis_plugin.md index 2d8fa4e..83bcaa2 100644 --- a/docs/BRIEF_depuis_plugin.md +++ b/docs/BRIEF_depuis_plugin.md @@ -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 à diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 11c0ae1..f7dc789 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -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. diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index b448208..93b805b 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -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; } diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.h b/energyplugin/etm/scheduler/rulebasedscheduler.h index aa3d25a..6fbe6f5 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.h +++ b/energyplugin/etm/scheduler/rulebasedscheduler.h @@ -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. diff --git a/energyplugin/etm/types/plan.h b/energyplugin/etm/types/plan.h index a0f5ec3..2ae0cdd 100644 --- a/energyplugin/etm/types/plan.h +++ b/energyplugin/etm/types/plan.h @@ -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. * diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 1396594..659aab3 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -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; } diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 5f15928..4caaa0f 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -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 }