# 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::phases` est 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/L3` depuis 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.** `GridOperator` et > `EcoSelfImposed` n'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 `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). --- ## 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.** `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.