etm-powersync-energy-plugin.../docs/DESIGN_DELESTAGE.md
Patrick Schurig b29489bdb5 feat(délestage §2): le disjoncteur passe par la résolution, au watt près
`phasePowerLimit` devient un `DrawCap{Breaker, perPhaseW}` et
`evPhaseAllowanceW()` traverse `resolveDrawMargin()` — la mécanique du
délestage est branchée, et rien ne bouge. C'est le but : à comportement
constant, tout écart observé ensuite est un défaut, pas une évolution.

L'égalité est obtenue par CONSTRUCTION. `phaseDrawW()` inverse
`calculateAllowanceAmpere()` phase par phase plutôt que de relire les états
du compteur : les lectures amont ne sont pas symétriques entre phases, et
les recopier ici en aurait fait deux versions vouées à diverger.

Deux déplacements sans changement de valeur : l'écrêtage `qMax(0, …)`
descend chez le consommateur (la résolution rend une marge SIGNÉE, dont
l'étape 4 a besoin pour dire de combien délester), et le facteur 230 remonte
dans le plafond, qui s'exprime en watts comme les autres.

Vérification : quatre suites diffées ligne à ligne contre une référence
capturée avant l'édition — charging 48/48 et simulation 50/50 identiques,
loadmodel 21/21, spotmarket 32/32. Contre-épreuve : à 1 V près la suite ne
discrimine pas (consignes en ampères entiers), donc cet essai ne prouve
rien ; à un facteur dix, 7 échecs. Le chemin est vivant.

Au passage, une `\note` du .h affirmait qu'on rend à la borne ce qu'elle
tire déjà. Le code ne l'a jamais fait, et c'est délibérément de sécurité :
sur une phase en dépassement la marge est négative, et c'est ce signe qui
ramène la borne à son minimum.

Décisions 6.1 à 6.3 gravées dans le design, et défaut amont consigné dans
SAFETY.md — non corrigé sciemment, il appartient à un [upstream-fix] isolé.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-30 11:58:23 +02:00

11 KiB
Raw Blame History

DESIGN — l'extension du délestage au waterfall

Statut : conception, à valider avant tout code de liaison (AGENTS.md, workflow obligatoire). Précondition de : §10 (LM-1006, couplage 1). Le §10 ne part pas sans elle. Contrainte de forme : specs/spec_loadmodel.md LM-1006-1, écrite le 2026-08-27 « pour que la porte reste ouverte ». Elle se referme ici.

Ce document ne demande pas si le délestage doit s'étendre. Il dit sous quelle forme, dans quel ordre, et nomme ce qui demande une décision — §6.


1. L'écart, mesuré

Aujourd'hui
Qui est délesté verifyOverloadProtection() parcourt m_evChargers et rien d'autre
Ce que le waterfall sait du plafond evPhaseAllowanceW() — et uniquement pour les bornes, comme plafond déclaré d'une borne
Charges pilotées, PAC aucun plafond : leur maxPowerW vient de la configuration, la cascade n'est bornée que par le surplus

Tant que la cascade ne dépense que du surplus, l'absence de plafond ne se voit pas : on ne peut pas dépasser ce qu'on n'achète pas. Le §10 change cela — la passe éco décide d'acheter, et plusieurs planchers qui s'ouvrent le même matin sans soleil s'additionnent sur le disjoncteur de branchement.


2. Le mécanisme : UN plafond, TROIS sources

LM-1006-1 l'impose et c'est le cœur du lot : la source est un paramètre, jamais une hypothèse enfouie dans le nom de la fonction.

struct DrawCap {
    Source  source;      // Breaker | GridOperator | EcoSelfImposed
    double  perPhaseW;   // < 0 = ce plafond ne borne pas par phase
    double  totalW;      // < 0 = ce plafond ne borne pas au total
};

Les deux formes coexistent et ne se convertissent JAMAIS (LM-1006-1). Un plafond par phase n'est pas un tiers d'un plafond total : une maison peut être à 20 A sur une phase et 2 A sur les deux autres.

Source Forme Durée Origine
Breaker par phase permanent phasePowerLimit, réglage d'installation
GridOperator total temporaire, sur ordre §14a — le transport n'existe pas
EcoSelfImposed total le temps de la passe 1 §10 — la passe n'existe pas

Deux des trois sources n'ont pas encore d'émetteur, et c'est voulu. Le mécanisme se conçoit pour trois parce que l'écrire pour une le rendrait irréversible : « cela tient dans une signature aujourd'hui ; ce serait une refonte dans deux ans » (LM-1006-1).


3. Comment on compare sans convertir

C'est le point technique du lot, et il n'est pas évident. On ne compare pas les plafonds — ils n'ont pas la même forme. On compare les MARGES qu'ils laissent.

marge(cap) = combien de watts SUPPLÉMENTAIRES ce plafond autorise, ici et maintenant
  • pour un plafond par phase : la marge est celle de la phase la plus chargée, ramenée en watts — c'est déjà ce que fait RootMeter::calculateAllowanceAmpere() ;
  • pour un plafond total : la marge est totalW − soutirage mesuré.

La marge est un nombre de watts dans les deux cas : elle se compare. Le plafond qui laisse la plus petite marge MORD, et c'est sa source qu'on retient.

Ce que cela préserve : aucune conversion, donc aucune hypothèse d'équilibre entre phases. Un plafond par phase reste évalué phase par phase jusqu'au bout ; il ne devient jamais un « total équivalent ».

Et ce que ça donne gratuitement : la marge est exactement ce que la cascade doit connaître pour ne pas dépasser. Le même nombre sert à borner et à expliquer.


4. Où le plafond mord — deux endroits, deux rôles

Il faut les distinguer, sinon on écrit deux fois la même chose ou on en oublie une.

Quand Ce qu'il fait
Dans la cascade (L3, décision) à chaque cycle, avant de commander borne l'allocation : on ne décide pas d'acheter au-delà de la marge
Dans la protection (L4, réaction) sur powerBalanceChanged, après la mesure déleste ce qui dépasse déjà, dans l'ordre inverse du rang

Les deux sont nécessaires, et ce n'est pas une redondance : la première empêche de créer le dépassement, la seconde rattrape ce qu'on n'a pas décidé — une plaque de cuisson, un sèche-linge, tout ce que le HEMS ne pilote pas. La couche L4 garde le dernier mot (AGENTS.md, MODÈLE DE SÉCURITÉ), et ce lot ne change pas cela.

L'ordre de coupe est l'inverse du rang : la charge servie en dernier est délestée en premier. C'est le seul ordre qui respecte le classement de l'installateur — l'inverse reviendrait à le contredire au moment où il compte le plus.


5. Ordre de travail — quatre étapes, dont deux à comportement constant

Étape 1 — le TYPE et sa résolution. DrawCap, la marge, « le plus contraignant l'emporte et on sait lequel ». Testable isolément (tests/auto/loadmodel, sans serveur nymea). Comportement inchangé : rien ne le consomme encore.

Étape 2 — la SOURCE Breaker branchée, à comportement CONSTANT. ✅ FAITE (2026-08-30). phasePowerLimit devient un DrawCap{Breaker, perPhaseW} et evPhaseAllowanceW() passe par resolveDrawMargin(), via drawCaps() / phaseDrawW() / evDrawMargin().

Comment l'égalité au watt est obtenue. phaseDrawW() inverse calculateAllowanceAmpere() phase par phase (tirage = (limite − marge) × 230) au lieu de relire les états du compteur. Ce détour n'est pas de la coquetterie : les lectures amont ne sont pas symétriques entre phases (voir SAFETY.md, « défaut amont connu »), et les recopier ici en aurait fait deux versions vouées à diverger. L'inversion rend l'égalité vraie par construction, quelles que soient ces lectures — y compris si le défaut amont est corrigé plus tard.

Deux choses ont changé de place, sans changer de valeur. L'écrêtage qMax(0, …) descend dans evPhaseAllowanceW() : la résolution, elle, rend une marge signée, parce que l'étape 4 a besoin de l'ampleur du dépassement (décision 6.3). Et le facteur 230 remonte dans le plafond, qui s'exprime désormais en watts comme tous les autres.

Vérification. Quatre suites relancées, verdicts diffés ligne à ligne contre une référence capturée avant l'édition : charging 48/48 identique, simulation 50/50 identique, loadmodel 21/21, spotmarket 32/32.

Contre-épreuve, en deux temps. Une sortie identique s'obtiendrait aussi si l'ancien chemin survivait et que le nouveau était mort. Perturber le plafond de 1 V (230 → 229) ne fait rien bouger : la suite ne discrimine pas à ce grain, les consignes étant en ampères entiers. Ce premier essai ne prouve donc rien, et il faut le dire. C'est la perturbation d'un facteur dix (230 → 23) qui tranche : 7 échecs dans testEcoMode, testEcoModePhaseSwitching et testBatteryLevelConsideration. Le chemin est vivant, et c'est bien lui qui décide.

À retenir pour la suite : la sensibilité de charging à la limite de phase est de l'ordre de l'ampère, pas du watt. « Identique au watt près » y est donc vérifié comme une identité de résultats, pas de grandeurs intermédiaires — pour celles-ci, c'est l'inversion qui répond.

Étape 3 — la cascade est bornée par la marge. Le waterfall cesse d'allouer au-delà. C'est ici que le comportement change pour les charges non-EV, et c'est ici que le motif DRAW_CAP naît (R8 : il publie sa source).

Étape 4 — la protection L4 déleste le waterfall. verifyOverloadProtection() cesse d'être réservée aux bornes ; ordre inverse du rang, force = true, verrous bypassés comme le repli L2.

Les deux premières ne changent rien et rendent les deux suivantes petites. C'est la même stratégie que le §10 : ce qui coûte, dans un lot de sécurité, n'est pas d'écrire le comportement — c'est de prouver qu'on n'a rien cassé en chemin.


6. CE QUI A ÉTÉ TRANCHÉ (2026-08-30)

6.1 — La marge tient-elle compte du commandé non encore mesuré ? → NON. C'était la correction A transposée au soutirage. Le motif du refus est plus fort que celui de la recommandation initiale : le commandé-non-encore-mesuré, aujourd'hui, c'est budget.evReservedW — et on vient d'établir (LM-1011, PlanDraw) que ce champ mélange deux natures et n'est pas le registre qu'il paraît être. Bâtir une décision de sécurité sur le seul champ dont on sait qu'il n'est pas ce qu'il annonce, ce serait construire sur du sable. La cascade borne donc sur la marge MESURÉE. L'écart d'un cycle est absorbé par la marge que le disjoncteur garde par construction (allowancePerPhase -= 1, côté proxy).

6.2 — Le délestage L4 bypasse-t-il minOn ? → OUI, pour Breaker et GridOperator seulement. Le disjoncteur ne connaît pas le court-cycling, et un ordre du gestionnaire de réseau ne se négocie pas. NON pour EcoSelfImposed : un plafond qu'on s'impose ne justifie pas d'abîmer un compresseur — on renonce au plancher éco plutôt qu'au matériel. C'est la première conséquence concrète du fait que la source soit un paramètre et non une constante.

Précaution attachée à 6.2 — le bypass doit SE DIRE dans le motif. Un verrou franchi en silence, c'est un compresseur qui court-cycle sans que personne ne sache pourquoi. Le motif porte donc un marqueur explicite du franchissement : sans lui, la trace montrerait une charge coupée avant son minOn et rien n'expliquerait l'écart à la règle, qui passerait pour un défaut de l'implémentation du verrou. C'est la même doctrine que partout ailleurs ici — publier le régime, ne jamais le laisser déduire.

6.3 — Ce que porte le motif DRAW_CAP (naissance à l'étape 3) :

Champ Pourquoi il y est
la marge de combien on manquait — c'est la grandeur qui a décidé
la source ce qu'on a le droit d'en faire diffère selon elle (cf. 6.2) ; exigence R8
l'ampleur du dépassement « on frôle » et « on dépasse de 1200 W » ne se traitent pas pareil, et la marge seule, écrêtée, ne les distingue plus
la phase qui borne sans elle, un installateur devant une triphasée déséquilibrée ne sait pas où aller mesurer
le minOn franchi, le cas échéant conséquence directe de la précaution ci-dessus

Le dépassement est disponible parce que DrawMargin::watts n'est pas écrêté à zéro : l'écrêtage appartient au consommateur (une borne ne prend jamais une puissance négative), la résolution, elle, dit la vérité (-1200 veut dire quelque chose que 0 a perdu).


7. Ce que ce lot ne fait PAS

  • Il ne crée aucun transport §14a. GridOperator sera une source qu'aucun émetteur ne remplit ; c'est le but — la porte reste ouverte, elle ne s'ouvre pas.
  • Il ne touche pas au repli L2. LM-1006-1, propriété 2 : le plafond auto-imposé est respecté par construction dès lors que le repli ne dépense pas. L'exigence dure — tenir une borne imposée par un tiers alors que le moteur ne décide plus — appartient au §14a et vivra hors du moteur. Les confondre ferait hériter à ce lot d'une contrainte qui ne le concerne pas.
  • Il ne déplace pas la frontière de sécurité. L4 garde le dernier mot, L0 reste physique.