La protection de surcharge ne connaissait que les bornes VE : une PAC ou un routeur pouvaient tenir un dépassement sans que rien ne les redescende. Elle déleste désormais les charges du waterfall, de la dernière servie à la première — le même ordre total que la cascade, lu à l'envers, pour que délester ne puisse pas contredire l'ordre dans lequel on a servi. Délester n'est pas toujours retirer des watts. Une PAC ne se module pas : on la fait redescendre d'ÉTAT, vers 2 et jamais 1. C'est le cas que le bornage de l'étape 3 ne pouvait pas traiter — il empêche d'allouer, il ne retire rien à personne. Le franchissement de verrou se CONSTATE : on tente d'abord sans forcer, et `minOnBypassed` n'est marqué que si le verrou a réellement refusé. Le déduire d'un verrou simplement ACTIF serait faux — un verrou peut être armé et permettre malgré tout le geste demandé. Le marqueur annoncerait alors un court-cycle qui n'a pas eu lieu, et le premier compresseur réellement maltraité passerait inaperçu au milieu des fausses alertes. Ce constat n'était pas possible avant ECS-415. On ne force que pour un plafond SUBI. Comportement observé, non postulé : la contre-épreuve « forçage interdit » — qui est le comportement d'un plafond EcoSelfImposed — laisse la PAC en état 4. Un seul commandeur par organe : les charges délestées sont revendiquées pour le cycle et le dispatch les saute, sinon la consigne du plan rallumerait ce que la sécurité vient de couper. La fenêtre d'exécution est étroite — après syncAdapters(), avant le dispatch — et les deux placements écartés sont consignés au design §6bis parce qu'ils se représenteront. Ce lot a été repris après un arrêt : deux versions antérieures du test passaient sans rien prouver, leurs prémisses étant supposées. Les trois cycles sont désormais MESURÉS puis assertés, l'armement à froid compris. Suite complète : simulation 54/54, charging 48/48 identique à la référence ligne à ligne, loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
18 KiB
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. ✅ FAITE (2026-08-30). SurplusContext porte
une drawMargin de cycle, résolue une fois par houseDrawMargin(). La cascade la consomme
comme elle consomme le surplus, et DRAW_CAP naît avec sa source (R8), son ampleur et sa
phase.
Deux ressources, pas une. Le surplus dit ce qu'on peut dépenser sans acheter ; l'autorisation
de soutirage dit ce que le branchement supporte. Une charge doit tenir dans les deux. La
précédence suit LM-1011, déjà gravée : DRAW_CAP uniquement quand l'autorisation
résiduelle est nulle ; s'il en reste, fût-ce moins que le plus petit palier, c'est
BELOW_MIN_POWER. Le soutirage hérite exactement du couple que le surplus avait déjà.
Les bornes VE en sont exclues, et c'est délibéré. Leur plafond de cycle est déjà écrêté par
evPhaseAllowanceW(), qui connaît les phases qu'elles occupent. Leur appliquer en plus la
marge de la maison — la pire phase — les priverait d'une phase saine parce qu'une autre sature.
Mieux vaut la borne mieux informée que la borne la plus sévère. La marge est en revanche
décrémentée par toutes les charges servies, bornes comprises : elles consomment la même
autorisation, et ne décompter que celles qu'on borne offrirait aux suivantes une marge déjà
dépensée.
⚠️ Une limite du MODÈLE, pas de l'implantation — à trancher
LoadDeclared::phasesest un nombre, jamais un masque : le moteur sait qu'une charge est mono ou triphasée, jamais sur quelle phase elle est câblée.houseDrawMargin()retient donc la pire phase, seul choix défendable : une monophasée de phase inconnue peut tomber sur n'importe laquelle, et deviner, dans une couche de sécurité, se paie au disjoncteur.Le pessimisme est borné en pratique — une phase qui exporte a une marge plus grande que le plafond et ne borne personne ; seule une phase réellement proche de sa limite mord. Mais sur une triphasée franchement déséquilibrée, une charge câblée sur une phase saine sera refusée à cause d'une autre. C'est une perte économique, jamais une perte de sécurité, et elle est réversible.
La levée demande un champ déclaré (masque de phases par charge) — donc config, interface app et migration. Cela dépasse ce lot.
Tranché le 2026-08-30 : on vit avec le pessimisme, et c'est le bon arbitrage pour une couche de sécurité — il coûte du rendement, jamais de la sécurité. Mais c'est un report daté, pas une position : la V2C publie
currentL1/L2/L3depuis le 2026-08-28, donc l'information existe déjà côté matériel pour une borne, et le §14a distinguera par phase. Le jour venu,houseDrawMargin()retiendra la phase de la charge au lieu de la pire — un seul appel change, parce que l'étape 2 compare des marges et non des plafonds.
Étape 4 — la protection L4 déleste le waterfall. ✅ FAITE (2026-08-30). La protection ne se
limite plus aux bornes VE : shedWaterfallForOverload() déleste les charges du waterfall en
ordre inverse du rang, et le motif DRAW_CAP porte alors shed, shedW et le
franchissement de verrou.
Où elle s'exécute, et pourquoi cette fenêtre est étroite. Après syncAdapters(), avant tout
dispatch. Les deux autres placements ont été essayés et échouent — voir §6bis, qui les
consigne. Les charges délestées sont revendiquées : le dispatch les saute, un seul commandeur
par organe (règle absolue 1), exactement comme m_waterfallEvIds protège les bornes.
Le franchissement se CONSTATE. Le délestage tente d'abord sans forcer ; il ne pose
minOnBypassed que si le verrou a réellement refusé. Le déduire d'un verrou simplement actif
serait faux — un verrou peut être armé et permettre malgré tout le geste demandé. Le marqueur
annoncerait alors un court-cycle qui n'a pas eu lieu, et le premier compresseur réellement
maltraité passerait inaperçu au milieu des fausses alertes.
Ce constat n'était pas possible avant. Tant que applyAction() renvoyait, sur refus, l'action
demandée (\rule{ECS-415}), un refus se lisait comme un succès : le délestage n'a jamais
forcé, et publiait 3 kW rendus par une PAC restée en état 4. La correction du contrat était donc
la précondition, et non un détour.
Délester n'est pas toujours retirer des watts. Une PAC ne se module pas : on la fait
redescendre d'état, vers 2 (ECS-413), jamais 1. C'est le cas où la décision 6.2 mord, et le
test le montre dans les deux sens — forçage autorisé, la PAC redescend ; forçage interdit (le
comportement d'un plafond EcoSelfImposed), elle reste à 4. On renonce au plancher plutôt
qu'au matériel, et ce n'est pas une intention : c'est un comportement observé.
Ce qui n'est PAS couvert, et le rester tant qu'il n'y a pas d'émetteur.
GridOperatoretEcoSelfImposedn'ont aucune source qui les produise. La branche qui les distingue est écrite et son effet est démontré par la contre-épreuve ci-dessus, mais aucun test de bout en bout ne peut les exercer — en écrire un poserait un invariant sur un code que rien n'émet, c'est-à-dire un test toujours vert. Ils viendront avec le §14a.SG-Ready décrémente la marge sans être borné par elle au niveau de la cascade (étape 3) : son choix est un état, pas des watts. C'est précisément pourquoi le délestage était nécessaire — et c'est désormais lui qui couvre le cas.
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
minOnet 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).
6bis. TROIS PIÈGES RENCONTRÉS À L'ÉTAPE 4 — à garder tels quels
L'étape 4 a été arrêtée avant livraison : son propre test l'a prise à publier un délestage de 3 kW pendant que les contacts de la PAC restaient fermés. Trois hypothèses successives se sont révélées fausses. Les deux premières sont des pièges de placement qui se représenteront ; la troisième était la bonne, et elle a donné ECS-415.
Piège 1 — après le dispatch, on lit un état périmé. Dans un même update() aucune boucle
d'événements ne tourne. Un adaptateur qui vient d'être commandé rapporte encore son état
précédent. Le délestage demandait alors un état que l'adaptateur croyait déjà tenir, se le
voyait accorder comme un non-geste, et se créditait des watts que personne n'avait rendus.
« Le dernier mot » ne veut pas dire « le dernier appel du cycle » : cela veut dire « sur un
état posé ».
Piège 2 — avant syncAdapters(), l'écriture part avec l'adaptateur. La reconstruction
incrémentale (ECS-412) remplace l'objet, et la commande en cours disparaît avec lui. Les
contacts restaient fermés pendant qu'on publiait un délestage. La fenêtre correcte est
étroite : après syncAdapters(), avant tout dispatch.
Piège 3 (la vraie cause) — un refus qui se lisait comme un succès. Le verrou refusait bien
la descente, mais applyAction() renvoyait l'action demandée, donc state == 2, donc
« cible atteinte ». Voir ECS-415 : le retour décrit désormais l'état réellement tenu.
Ce que ces trois pièges ont en commun. Aucun n'était visible dans le code : chacun se déduisait d'un ordre d'exécution ou d'une valeur de retour, et les trois produisaient le même symptôme — un délestage annoncé sans effet. Ce sont les sondes, pas la relecture, qui les ont départagés. Une hypothèse plausible et non mesurée en a coûté deux.
7. Ce que ce lot ne fait PAS
- Il ne crée aucun transport §14a.
GridOperatorsera 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.