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 }