diff --git a/INTERFACE.md b/INTERFACE.md index 127601e..d1e24b3 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -645,13 +645,43 @@ Publié **dans la même trame que `budget`** : les deux décrivent le même cycl instants ferait comparer deux mondes. ```jsonc -"draw": { "committedW": 1400 } // watts achetés au réseau ce cycle +// SANS plafond de soutirage réglé — quatre champs ABSENTS, jamais à 0 : +"draw": { "committedW": 1400 } + +// AVEC un plafond en vigueur : +"draw": { "committedW": 1400, "authorisedW": 3450, "remainingW": 2050, + "binding": "connection", "perPhaseBound": true } ``` -**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. +**`committedW` est publié TOUJOURS** : le registre des watts achetés existe à chaque cycle, et +zéro y est une valeur. **Les quatre autres ne sont publiés QUE si un plafond est en vigueur.** +C'est la même règle des deux côtés — *le zéro est une valeur quand la grandeur existe, l'absence +en est une quand elle n'existe pas*. Un `authorisedW: 0` assorti d'un `binding` par défaut +afficherait un plafond de soutirage **qui n'existe pas, avec sa source**. + +| Champ | Ce qu'il dit | +|---|---| +| `authorisedW` | Autorisation du cycle, **SIGNÉE**. Négative ⇒ la maison est **déjà au-delà** du plafond, et l'ampleur du dépassement se lit directement. L'écrêter à zéro confondrait « pile à la limite » et « on dépasse de 1200 W ». | +| `remainingW` | `authorisedW − committedW`. **L'identité tient toujours** — c'est ce qui rend la charge utile vérifiable. Peut être négative : les bornes VE engagent du soutirage borné par LEUR phase, pas par la marge de la maison. | +| `binding` | Code opaque de la source qui borne : `connection` \| `gridOperator` \| `selfImposed`. Ce qu'on a le droit de faire d'un dépassement en dépend. | +| `perPhaseBound` | Le plafond qui borne est-il **par phase** ? Un plafond total n'a pas de phase coupable, et le dire évite d'envoyer mesurer au hasard sur une triphasée. | + +> ### ⚠️ `budget.remainingW == 0` et `draw.remainingW != 0` dans la MÊME trame : c'est NORMAL +> +> Sous la **réserve batterie**, le budget de surplus est **annulé** — pas réduit (`LM-1203-b` : +> servir « un peu » retarderait la recharge sans probablement démarrer personne). Mais la réserve +> n'annule **que le surplus**. L'**autorisation de soutirage** est une contrainte physique du +> branchement : aucune règle de stockage ne la modifie. +> +> Un client verra donc, légitimement, un budget à zéro à côté d'une autorisation intacte. **Ce +> n'est pas une incohérence**, et il aurait raison de la signaler si personne ne la lui avait +> dite — c'est pourquoi elle est écrite ici, et non en note de bas de page. Les deux grandeurs +> répondent à deux questions différentes : « puis-je dépenser sans acheter ? » et « le +> branchement supporte-t-il que j'achète ? » +> +> Épinglé par `testDrawAuthorisationSurvivesTheBatteryReserve`, qui dit aussi à quelle condition +> il devient faux : le jour où l'on déciderait qu'une batterie basse interdit d'acheter au +> réseau — une décision de modèle, pas une correction. > **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 — diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 9238eae..1fbf8c4 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,37 @@ +powersync-energy-plugin-nymea (1.15.2+etm39) trixie; urgency=medium + + * §10 étape B — `draw` publie l'AUTORISATION, et elle se lit au lieu d'être forgée. + `authorisedW`, `remainingW`, `binding` et `perPhaseBound` étaient absents parce qu'aucun + plafond de soutirage n'existait ; le lot délestage en a créé un. Le refus d'origine visait + la FORGE, pas la lecture — et son motif est tombé sans que sa formulation ne bouge. + * `committedW` publié TOUJOURS, les quatre autres SEULEMENT si un plafond est en vigueur. + Le zéro est une valeur quand la grandeur existe, l'absence en est une quand elle n'existe + pas — un `authorisedW: 0` avec un `binding` par défaut afficherait un plafond inexistant + avec sa source. + * `authorisedW` est SIGNÉ : négatif, la maison est déjà au-delà du plafond, et l'ampleur du + dépassement se lit. L'écrêter confondrait « pile à la limite » et « on dépasse de 1200 W ». + L'identité `authorisedW − committedW == remainingW` tient toujours ; `remainingW` peut être + négatif, et c'est un FAIT — les bornes VE engagent du soutirage borné par LEUR phase. + * ÉCRIT EN TOUTES LETTRES DANS LE CONTRAT, pas en note : sous la réserve batterie, + `budget.remainingW == 0` et `draw.remainingW != 0` dans la MÊME trame. La réserve annule le + surplus, jamais l'autorisation de soutirage — celle-ci est une contrainte physique du + branchement (LM-1203-b vue depuis la frontière). Un client aurait raison de signaler + l'incohérence si personne ne la lui avait dite. + * LE TEST D'ABSENCE EST MORT PROPREMENT, et son successeur dit à quelle condition il devient + faux — application directe du troisième mode d'érosion. L'ancien épinglait « pas de + authorisedW » et a survécu à son motif ; le nouveau épingle la RÈGLE (présent si le plafond + existe, absent sinon) et nomme les deux évolutions qui le périmeraient. + * Suite : simulation 55/55, loadmodel 21/21, spotmarket 32/32, charging 48/48 identique à la + référence ligne à ligne, doxygen 0 avertissement. Contre-épreuve : réserve annulant aussi + l'autorisation → 0 au lieu de 3450. + * NON EXPLIQUÉ, ET SIGNALÉ COMME TEL : un plantage unique de `charging` (SIGSEGV) pendant un + lot de fond, non reproductible en trois séquences. Le vidage le situe dans + `Logger::log()` de libnymea.so.1, atteint par un événement posté depuis libnymea-core — + AUCUNE trame ETM dans la pile. Hors de ce lot, mais consigné plutôt que relancé jusqu'à + disparaître. + + -- Patrick Schurig Mon, 31 Aug 2026 07:20:00 +0200 + powersync-energy-plugin-nymea (1.15.2+etm38) trixie; urgency=medium * §10 étapes A1 et A2 — décider et émettre se séparent, à comportement CONSTANT. La chaîne de diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 18979d5..1481be1 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -1307,13 +1307,24 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const // 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. + // `committedW` est publié TOUJOURS : le registre des watts achetés existe à chaque cycle, + // et zéro y est une valeur. Les quatre autres ne sont publiés QUE si un plafond est en + // vigueur — leur absence dit « aucun plafond n'existe », là où un `authorisedW: 0` assorti + // d'un `binding` par défaut afficherait un plafond de soutirage inexistant, avec sa source. + // Le zéro est une valeur quand la grandeur existe ; l'absence en est une quand elle n'existe + // pas, et c'est la même règle des deux côtés. + // + // §10 étape B — ces champs ne sont plus un pari sur le §14a : le plafond de branchement est + // résolu à chaque cycle depuis `phasePowerLimit`, donc ils se LISENT. Le refus de les + // publier tenait à leur inexistence ; l'inexistence a cessé. QVariantMap draw; draw.insert(QStringLiteral("committedW"), plan.draw.committedW); + if (plan.draw.capBorne) { + draw.insert(QStringLiteral("authorisedW"), plan.draw.authorisedW); + draw.insert(QStringLiteral("remainingW"), plan.draw.remainingW); + draw.insert(QStringLiteral("binding"), plan.draw.bindingCode); + draw.insert(QStringLiteral("perPhaseBound"), plan.draw.perPhaseBound); + } snap.insert(QStringLiteral("draw"), draw); // On itère les ACTIONS, pas les tables d'adaptateurs : l'ordre du waterfall (rang 1 servi en diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index e455823..54b6344 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -175,6 +175,16 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) PlanBudget compta; PlanDraw draw; // registre des watts achetés — R3 draw.committedW = drawCommittedW; // ce que le proxy a déjà engagé au réseau + + // §10 étape B — l'AUTORISATION du cycle, prise là où elle est résolue, et publiée telle + // quelle. Elle n'existe que si un plafond existe : sans lui, les quatre champs restent + // absents plutôt que nuls (LM-1013). + draw.capBorne = ctx.drawMargin.borne; + if (draw.capBorne) { + draw.authorisedW = ctx.drawMargin.watts; + draw.bindingCode = drawCapSourceCode(ctx.drawMargin.source); + draw.perPhaseBound = ctx.drawMargin.phase >= 0; + } compta.surplusW = remainingSurplusW; compta.evReservedW = evReservedW; @@ -252,6 +262,8 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) 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. + // L'identité se ferme ICI, au même endroit que celle du budget, et sur le même cycle. + draw.remainingW = draw.authorisedW - draw.committedW; plan.draw = draw; plan.waterfallEvIds = waterfallEvIds; return plan; diff --git a/energyplugin/etm/types/plan.h b/energyplugin/etm/types/plan.h index 2ae0cdd..b878dcb 100644 --- a/energyplugin/etm/types/plan.h +++ b/energyplugin/etm/types/plan.h @@ -97,6 +97,39 @@ struct PlanBudget { struct PlanDraw { //! Watts achetés au réseau ce cycle. Toujours ≥ 0. double committedW = 0; + + /*! + * \brief Un plafond de soutirage est-il en vigueur ? + * + * Faux ⇒ les quatre champs suivants sont **sans objet** et ne franchissent pas la frontière. + * Leur ABSENCE dit « aucun plafond n'existe » ; un zéro dirait « aucune autorisation », ce + * qui est l'affirmation contraire. C'est la distinction que \rule{LM-1013} impose, et elle + * décide ici du pilotage entier d'une installation sans limite réglée. + */ + bool capBorne = false; + + /*! + * \brief Autorisation de soutirage du cycle (W) — **SIGNÉE**. + * + * C'est \c DrawMargin::watts tel quel, et le signe porte l'information : négatif, la maison + * est **déjà au-delà** du plafond, et l'ampleur du dépassement se lit directement. L'écrêter + * à zéro confondrait « on est pile à la limite » avec « on dépasse de 1200 W » — la même + * perte que celle refusée dans la résolution elle-même. + */ + double authorisedW = 0; + + //! \brief \c authorisedW − \c committedW. Peut être négatif, et c'est alors un FAIT : + //! les bornes VE engagent du soutirage borné par LEUR phase, pas par la marge de la + //! maison (\rule{LM-1210}). L'identité tient toujours, c'est ce qui la rend vérifiable. + double remainingW = 0; + + //! \brief Source qui borne : \c connection | \c gridOperator | \c selfImposed. Vide si + //! aucun plafond. Ce qu'on a le droit de faire d'un dépassement en dépend (\rule{LM-1211-c}). + QString bindingCode; + + //! \brief Le plafond qui borne est-il exprimé PAR PHASE ? Un plafond total n'a pas de phase + //! coupable, et le dire évite d'envoyer mesurer au hasard sur une triphasée. + bool perPhaseBound = false; }; /*! diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 544be23..9e67983 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -4286,19 +4286,38 @@ void Simulation::testLoadTelemetryRpc() QCOMPARE(qRound(budget.value("evReservedW").toDouble()), 0); QCOMPARE(qRound(budget.value("allocatedW").toDouble()), 5600); QCOMPARE(qRound(budget.value("remainingW").toDouble()), 1400); - // ---- L'ABSENCE de `draw` se défend, parce que rien ne casse quand on la comble ----------- + // ---- `draw` : ce qui EXISTE se publie, ce qui n'existe pas s'ABSENTE ------------------- // - // `draw` ne porte qu'un champ : `committedW`. Les quatre autres du §14a — authorisedW, - // remainingW, binding, perPhaseBound — sont ABSENTS, jamais à 0. Le contrat le dit, mais un - // contrat ne fait échouer aucune compilation : le jour où quelqu'un ajoutera `authorisedW: - // 0` « pour compléter l'objet », six mois plus tard, rien ne le retiendra — et l'app - // affichera un plafond de soutirage qui n'existe pas, avec sa source, faute d'avoir un - // moyen de distinguer « pas de plafond » de « plafond nul ». + // Ce test REMPLACE celui qui épinglait l'absence d'`authorisedW`. Celui-là avait un motif + // juste — « un plafond de soutirage qui n'existe pas ne doit pas s'afficher » — et il a + // survécu à ce motif : le lot délestage a CRÉÉ le plafond, et le champ est devenu une + // lecture au lieu d'une forge. Sa garde, elle, ne savait pas que le monde avait changé. + // C'est le troisième mode d'érosion d'AGENTS.md, et la parade est ci-dessous. // - // Ce test est ce moyen. S'il casse, ce n'est pas lui qu'il faut corriger : c'est le champ - // ajouté qui doit arriver AVEC son transport §14a et une valeur vraie, pas à zéro. + // ⚠️ **CE TEST DEVIENT FAUX** le jour où l'une de ces deux choses arrive : + // · un plafond de soutirage devient publiable SANS être en vigueur — alors la condition + // de présence change, et c'est elle qu'il faut réécrire, pas les assertions ; + // · `committedW` cesse d'exister à chaque cycle — alors même lui devient conditionnel. + // Tant qu'aucune des deux n'est vraie, un échec ici signale une régression, pas un test + // périmé. const QVariantMap draw = notif.value("draw").toMap(); - QCOMPARE(draw.keys(), QStringList{QStringLiteral("committedW")}); + + // `committedW` est publié TOUJOURS : la grandeur existe à chaque cycle, et zéro en est une + // valeur. C'est la moitié « le zéro est une valeur quand la grandeur existe ». + QVERIFY2(draw.contains(QStringLiteral("committedW")), + "le registre des watts achetés existe à chaque cycle, même vide"); + + // Et l'autre moitié : ici AUCUN plafond n'est réglé (`phasePowerLimit` vaut 0, contrat + // amont : la protection est désactivée). Les quatre champs qui décrivent l'autorisation + // doivent donc être ABSENTS — pas à zéro. Un `authorisedW: 0` avec un `binding` par défaut + // dirait « aucune autorisation, imposée par le branchement » là où la vérité est « aucun + // plafond n'existe ». Les deux affirmations commandent des gestes opposés. + for (const QString &champ : {QStringLiteral("authorisedW"), QStringLiteral("remainingW"), + QStringLiteral("binding"), QStringLiteral("perPhaseBound")}) + QVERIFY2(!draw.contains(champ), + qUtf8Printable(QStringLiteral("sans plafond réglé, `%1` doit être ABSENT et " + "non nul — l'absence dit qu'aucun plafond " + "n'existe").arg(champ))); // Le relais tire déjà ses 1000 W depuis le cycle précédent : ils lui sont RECRÉDITÉS avant // arrondi (anti-clignotement). Sans ce terme publié, l'identité serait fausse ici même. @@ -6573,6 +6592,97 @@ void Simulation::testL4ShedsAPacAndSaysItForcedTheLock() #endif } +/*! + * \brief [§10 étape B] `draw` publie l'autorisation — et la RÉSERVE BATTERIE ne l'annule pas. + * + * \par Les quatre champs se publient parce qu'ils EXISTENT + * `authorisedW`, `remainingW`, `binding` et `perPhaseBound` étaient absents parce qu'aucun + * plafond de soutirage n'existait. Le lot délestage en a créé un ; ils se LISENT désormais, au + * lieu d'être forgés. Leur condition de présence est donc « un plafond est en vigueur », et non + * « le transport du §14a existe » — le refus d'origine visait la forge, pas la lecture. + * + * \par Ce que ce test épingle vraiment : \rule{LM-1203-b} vue depuis la frontière + * Sous la réserve batterie, le budget de surplus est **annulé** — pas réduit. Mais la réserve + * n'annule QUE le surplus : l'**autorisation de soutirage** est une contrainte physique du + * branchement, et aucune règle de stockage ne la modifie. Un client verra donc + * `budget.remainingW == 0` **et** `draw.remainingW != 0` dans la même trame, et il aura raison + * de le signaler si personne ne le lui a dit. C'est dit — ici, et dans `INTERFACE.md`. + * + * \warning **Ce test devient faux** le jour où la réserve batterie borne aussi le soutirage — + * c'est-à-dire le jour où l'on déciderait qu'une batterie basse interdit d'acheter au réseau. + * Ce serait une décision de modèle, pas une correction : c'est alors l'assertion 3 qu'il faut + * réécrire, pas le moteur. + */ +void Simulation::testDrawAuthorisationSurvivesTheBatteryReserve() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + RuleBasedScheduler sched(arb); + + const QDateTime t0 = utcDateTime(QDate(2026, 8, 31), QTime(13, 0, 0)); + + auto planifier = [&](bool batterieBasse, bool avecPlafond) { + SurplusContext ctx; + ctx.timestamp = t0; + ctx.meter.exportW = 5000; // 5 kW de surplus disponibles + if (avecPlafond) { + ctx.drawMargin.borne = true; + ctx.drawMargin.watts = 3450; // 25 A − 10 A tirés, sur 230 V + ctx.drawMargin.source = DrawCapSource::Breaker; + ctx.drawMargin.phase = 0; // phase A : plafond PAR PHASE + } + if (batterieBasse) { + ctx.battery.present = true; + ctx.battery.socPercent = 10; // sous le seuil par défaut (20 %) + } + return sched.getPlan(ctx); + }; + + // 1. SANS plafond réglé : les quatre champs restent SANS OBJET. `capBorne` est ce qui + // décide de leur présence à la frontière — et il ne se déduit pas d'une valeur nulle. + const Plan sansPlafond = planifier(false, false); + QCOMPARE(sansPlafond.draw.capBorne, false); + + // 2. AVEC plafond, hors réserve : l'autorisation se lit, rien n'est engagé, tout reste. + const Plan nominal = planifier(false, true); + QCOMPARE(nominal.draw.capBorne, true); + QCOMPARE(qRound(nominal.draw.authorisedW), 3450); + QCOMPARE(qRound(nominal.draw.committedW), 0); + QCOMPARE(qRound(nominal.draw.remainingW), 3450); + QCOMPARE(nominal.draw.bindingCode, QString("connection")); + QCOMPARE(nominal.draw.perPhaseBound, true); + // L'identité, qui est ce qui rend la charge utile vérifiable par un client. + QCOMPARE(qRound(nominal.draw.authorisedW - nominal.draw.committedW), + qRound(nominal.draw.remainingW)); + + // 3. SOUS LA RÉSERVE — le cœur du test. Le surplus est ANNULÉ, l'autorisation INTACTE. + const Plan sousReserve = planifier(true, true); + QCOMPARE(qRound(sousReserve.budget.remainingW), 0); // la réserve annule le surplus… + QCOMPARE(qRound(sousReserve.draw.remainingW), 3450); // …et ne touche pas au soutirage + QCOMPARE(sousReserve.draw.bindingCode, QString("connection")); + + // 4. Et un plafond TOTAL se distingue d'un plafond par phase. Sans cette distinction, un + // installateur devant une triphasée irait mesurer une phase que rien ne désigne. + SurplusContext ctxTotal; + ctxTotal.timestamp = t0; + ctxTotal.meter.exportW = 5000; + ctxTotal.drawMargin.borne = true; + ctxTotal.drawMargin.watts = 2000; + ctxTotal.drawMargin.source = DrawCapSource::GridOperator; + ctxTotal.drawMargin.phase = -1; // total : aucune phase coupable + const Plan total = sched.getPlan(ctxTotal); + QCOMPARE(total.draw.perPhaseBound, false); + QCOMPARE(total.draw.bindingCode, QString("gridOperator")); +#endif +} + void Simulation::testCountsSumToTarget() { #ifndef ETM_ARBITRATOR diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 1c8d577..f36459e 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -193,6 +193,7 @@ private slots: void testLevelAbsenceAndZeroDifferInMeaning(); void testPhaseAllowanceReadsTheStateItTests(); void testDrawCapBoundsTheCascade(); + void testDrawAuthorisationSurvivesTheBatteryReserve(); void testARefusedStateIsDistinguishableFromAnApplied(); void testL4ShedsAPacAndSaysItForcedTheLock();