feat(§10 étape B): draw publie l'autorisation, qui 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 visait la FORGE, pas la lecture, et son motif est tombé sans
que sa formulation ne bouge.

`committedW` est 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.

`authorisedW` est SIGNÉ — négatif, la maison est déjà au-delà, et l'ampleur
du dépassement se lit. L'écrêter confondrait « pile à la limite » et « on
dépasse de 1200 W », la même perte que celle refusée dans la résolution.
L'identité authorisedW − committedW == remainingW tient toujours, et c'est
elle qui rend la charge utile vérifiable par un client.

Écrit EN TOUTES LETTRES dans le contrat : sous la réserve batterie,
budget.remainingW vaut 0 pendant que draw.remainingW ne le vaut pas, dans la
même trame. La réserve annule le surplus, jamais l'autorisation de
soutirage — contrainte physique du branchement qu'aucune règle de stockage
ne modifie. Les deux répondent à deux questions différentes : « puis-je
dépenser sans acheter ? » et « le branchement supporte-t-il que j'achète ? »

Le test d'absence est mort proprement. 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. Idem pour le test de la réserve, qui dit que sa
troisième assertion tombe 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.

Suite : simulation 55/55, loadmodel 21/21, spotmarket 32/32, charging 48/48
identique à la référence ligne à ligne. Contre-épreuve : réserve annulant
aussi l'autorisation → 0 au lieu de 3450.

Signalé sans être expliqué : un plantage unique de charging (SIGSEGV) dans
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.

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-31 07:14:01 +02:00
parent 0d2b6728ae
commit f2ba9f4e30
7 changed files with 251 additions and 20 deletions

View File

@ -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 —

View File

@ -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 <etm.schurig@gmail.com> 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

View File

@ -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

View File

@ -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;

View File

@ -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;
};
/*!

View File

@ -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<EnergyArbitrator *>(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

View File

@ -193,6 +193,7 @@ private slots:
void testLevelAbsenceAndZeroDifferInMeaning();
void testPhaseAllowanceReadsTheStateItTests();
void testDrawCapBoundsTheCascade();
void testDrawAuthorisationSurvivesTheBatteryReserve();
void testARefusedStateIsDistinguishableFromAnApplied();
void testL4ShedsAPacAndSaysItForcedTheLock();