# DESIGN — §10 : éco / confort, et le délestage dans le même lot **Statut : conception, à valider avant tout code** (`AGENTS.md`, workflow obligatoire). **Entrée** : `specs/spec_loadmodel.md` §10 (LM-1000 → LM-1009) et §12. **Écrit après relecture de** `rulebasedscheduler.cpp` (`getPlan`, `buildSetpointAction`, `buildSgReadyStateAction`), `smartchargingmanager.cpp` (`verifyOverloadProtection`), et des deux notes qui contraignent la forme : **LM-1006-1** (le plafond de soutirage) et **LM-1009** (les deux régimes d'échéance). > **Ce document ne demande pas si le §10 doit se faire. Il dit sous quelle forme, et nomme les > points qui demandent une décision** — §8. **Quatre sont tranchés.** 2026-08-27 : la réserve > batterie n'annule pas l'autorisation de soutirage (8.1), et le repli dégradé ne concerne pas ce > lot (8.3). 2026-08-29 : le plafond §14a porte **les deux** formes — par phase et au total — > sans jamais convertir (8.2), et la cible de confort reste un **comparateur**, le dosage > attendant le modèle thermique du §11 (8.4). > > **Un seul reste ouvert, et il est chez l'app** : 8.5, l'ordre de service apparent. Question > posée le 2026-08-29 dans `docs/BRIEF_depuis_plugin.md` §9. --- ## 1. Pourquoi le délestage part AVEC, et pas après C'est la précondition écrite en §13 de la spec, et elle tient pour une raison arithmétique simple : **la passe éco n'est pas bornée par le surplus** (LM-1005). Elle est bornée par une autorisation de soutirage. Or aujourd'hui, la seule borne de soutirage qui existe dans le moteur — `phasePowerLimit` — ne déleste **que les bornes de recharge** (`verifyOverloadProtection`, boucle sur `m_evChargers`). Livrer les planchers éco sans le délestage général donnerait donc : un matin sans soleil, chaque charge se sert au réseau pour tenir son obligation, personne ne les additionne, et l'installation disjoncte. Ce n'est pas une dégradation de confort — c'est une coupure, chez un client. **Conséquence de conception, pas de calendrier** : la passe éco a besoin d'un objet « plafond » qui n'existe pas encore. Le §10 doit donc le créer. C'est le §2. --- ## 2. Le plafond de soutirage devient un OBJET (LM-1006-1) ### 2.1 Ce qu'il remplace Aujourd'hui, la limite de raccordement est consultée à deux endroits, sous deux formes, sans jamais être nommée : - `RootMeter::calculateAllowanceAmpere(phases, phasePowerLimit)` — la place libre **par phase**, interrogée par le proxy puis, depuis 3g-2, par `EnergyArbitrator::evPhaseAllowanceW()` ; - `verifyOverloadProtection()` — la réaction temps réel, qui déleste les bornes. Aucun des deux ne se demande *d'où vient* le plafond, parce qu'il n'y en a qu'un. Le §14a en ajoutera un second, le §10 lui-même un troisième. ### 2.2 La forme proposée ```cpp /*! Un plafond de soutirage, et ce qui l'a produit. */ struct DrawCap { enum Source { Connection, GridOperator, EcoSelfImposed }; Source source = Connection; double totalW = -1; //!< Plafond au POINT DE LIVRAISON. < 0 = pas de plafond total. QList perPhaseA; //!< Plafond PAR PHASE. Vide = pas de plafond par phase. QDateTime until; //!< Fin de validité ; invalide = permanent. }; /*! Le plafond EFFECTIF du cycle, et celui qui a mordu. */ struct DrawBudget { QList declared; //!< Tous les plafonds actifs, quelle que soit leur source. double effectiveW = -1; //!< Le plus contraignant, ramené en watts. DrawCap::Source binding = DrawCap::Connection; //!< QUI a mordu — publié, pas déduit. bool perPhaseBound = false; //!< Vrai si c'est une phase qui borne, pas le total. }; ``` **`binding` est le champ qui justifie tout l'objet.** Sans lui, un client bridé lit « protection de surcharge » là où la cause est le gestionnaire de réseau — deux causes, deux gestes (LM-1006-1, propriété 1). ### 2.3 Le piège : par phase, ou au total ? Les trois sources ne parlent pas la même langue, et c'est le seul vrai problème technique du §2. | Source | Forme naturelle | Peut-on la convertir ? | |---|---|---| | Disjoncteur de branchement | **par phase** (A) | pas en un total : 3 × 32 A ne vaut pas 22 kW répartissables | | §14a EnWG | **total** (W au point de livraison) | pas en par-phase : le gestionnaire ne dit rien de la répartition | | Plancher éco (§10) | **total** (W qu'on s'autorise) | idem | **Un plafond total ne protège pas d'une surcharge monophasée** : 4 kW tirés entièrement sur L1 passent un plafond total de 6 kW et font disjoncter un 16 A. **Et un plafond par phase ne sait pas exprimer le §14a.** **Décision de conception proposée** : `DrawBudget` porte **les deux**, les évalue **toutes les deux**, et `binding` dit laquelle a mordu la première. Aucune conversion, jamais — c'est la conversion qui perdrait l'information. `effectiveW` n'est qu'une commodité d'affichage, dérivée, et `perPhaseBound` dit s'il faut la lire avec précaution. ### 2.4 Où il se calcule Une fois par cycle, dans `buildContext()`, à côté de `ctx.meter` et `ctx.battery` — c'est déjà l'endroit qui lit le compteur et connaît `phasePowerLimit`. Il devient un champ de `SurplusContext` : ```cpp struct SurplusContext { // … existant … DrawBudget drawCap; //!< [§10] Plafond de soutirage du cycle, toutes sources confondues. }; ``` Publié en télémétrie au même titre que `budget` — c'est la contrepartie de `binding` : un client qui ne voit pas le plafond ne peut pas expliquer ce qu'il affiche. --- ## 3. Les deux passes, et ce qui borne chacune `LM-1002` fixe la structure : deux passes sur la **même** liste, triée une seule fois par rang. Ce qui change entre elles n'est pas l'ordre, c'est **la borne**. ``` ┌────────────── passe 1 : ÉCO ──────────────┐ liste triée par │ borne : DrawBudget (autorisation de │ rang (une seule) │ SOUTIRAGE — pas le surplus) │ rang 1 ─────────▶│ sert : le plancher `needs` non atteint │ rang 2 ─────────▶│ produit : une CIBLE, pas une commande │ rang 3 ─────────▶└───────────────────────────────────────────┘ │ │ ┌──────────── passe 2 : CONFORT ────────────┐ └──────────▶│ borne : le RELIQUAT de surplus │ (même ordre) │ sert : la cible de confort si demandeuse │ │ produit : une CIBLE, pas une commande │ └───────────────────────────────────────────┘ │ somme par charge, arrondie UNE fois │ UNE commande ``` **Ce que la passe 1 dépense n'est pas ce que la passe 2 dépense.** C'est le point que le code actuel ne sait pas exprimer : `getPlan()` n'a qu'un compteur, `remainingSurplusW`, et `buildSetpointAction()` le décrémente **et** émet l'action dans le même geste (`rulebasedscheduler.cpp:207-212`). Le §10 doit séparer les deux — voir §4. **Deux comptabilités, donc, et elles ne se mélangent pas** : ```cpp struct PlanBudget { // existant, inchangé dans sa sémantique double surplusW, evReservedW, allocatedW, recreditedW, remainingW; }; struct PlanDraw { // [§10] ce que la passe 1 a décidé d'ACHETER double authorisedW = 0; //!< Ce que le DrawBudget laissait prendre. double committedW = 0; //!< Ce que la passe 1 a réellement engagé au réseau. double remainingW = 0; //!< Reliquat d'autorisation, non dépensé. }; ``` > **Pourquoi deux structures et pas un champ de plus** : `PlanBudget` répond « d'où vient > l'énergie servie » ; `PlanDraw` répond « combien a-t-on décidé d'acheter ». Les fondre > rendrait l'invariant publié de `PlanBudget` (`surplus + recrédit − alloué == restant`) faux > ou incompréhensible, et c'est lui qui rend la charge utile vérifiable par un client. --- ## 4. Une seule commande par charge et par cycle (LM-1003) C'est la contrainte qui impose la refonte structurelle, et elle est petite à énoncer : **les passes calculent, elles ne commandent pas.** Découpage proposé de `getPlan()` : ```cpp // 1. contexte, budget de surplus, plafond de soutirage (inchangé + DrawBudget) // 2. tri UNE fois (inchangé) QHash cibles; // loadId → { ecoW, confortW, motifs… } // 3. passe 1 — éco, bornée par ctx.drawCap for (const LoadContext &lc : chargesTriees) cibles[lc.id].eco = planifierEco(lc, drawRestant, planDraw); // 4. passe 2 — confort, bornée par le reliquat de surplus for (const LoadContext &lc : chargesTriees) cibles[lc.id].confort = planifierConfort(lc, surplusRestant, planBudget); // 5. UNE émission par charge : somme, arrondi au mécanisme, motif for (const LoadContext &lc : chargesTriees) slot.actions.append(emettre(lc, cibles.value(lc.id))); ``` **L'arrondi au mécanisme se fait à l'étape 5, une seule fois.** C'est ce qui empêche le défaut que LM-1003 nomme : un relais commuté en passe 1 puis recommuté en passe 2, soit du court-cycling fabriqué par l'arbitre lui-même. **Le recrédit s'attribue à la première passe où la charge apparaît** (LM-1004) — donc dans `planifierEco()` si elle a un plancher, dans `planifierConfort()` sinon. Un champ `recreditAttribue` dans `LoadTarget` le rend vérifiable plutôt que discipliné. > **Ce que cette refonte casse, et qu'il faut prévoir** : `buildSetpointAction()` et > `buildSgReadyStateAction()` mélangent aujourd'hui décision, écrêtage de verrou, comptabilité > et motif. Ils doivent se scinder en « décider une cible » et « émettre ». C'est le gros du > travail du lot, et c'est du remaniement, pas de l'ajout. Les tests de motif existants > (`testDecisionReasonRendering`, `testEcsBudgetUnderLock`, `testEcsSurplusPV`) sont le filet : > ils portent sur le TEXTE publié, donc ils survivront au remaniement ou le condamneront. --- ## 5. Le délestage : ce qu'il coupe, et dans quel ordre **Le plafond borne le PLAN, il ne le corrige pas après coup.** C'est la leçon de 3g-2 : un plafond appliqué après l'allocation laisse le waterfall croire qu'il a servi. - **En passe 1**, chaque charge servie décrémente `planDraw.remainingW`. Quand l'autorisation est épuisée, les charges suivantes reçoivent 0 **et le motif nomme la source** (§6). - **L'ordre de délestage est l'inverse de l'ordre de service** : le rang décide qui est servi en premier, donc qui est coupé en dernier. Aucun second classement — c'est LM-1002 appliqué à l'envers, et c'est la seule lecture qui ne surprendra pas l'installateur. - **L4 ne bouge pas.** `verifyOverloadProtection()` garde le dernier mot en temps réel, sur la mesure, après le dispatch. Le §10 lui ajoute des charges à délester ; il ne lui retire pas son rôle et ne s'y substitue pas. **Sa position dans `update()` reste INTOUCHABLE** (AGENTS, modèle de sécurité). --- ## 6. Les motifs — ce que le §10 ajoute au catalogue Le catalogue est fermé et testé sur le texte ; toute clé ajoutée demande une ligne de rendu et une entrée de clôture (`testDecisionReasonRendering`). | Code | params | Quand | |---|---|---| | `DRAW_CAP` | `capW`, `source`, `requiredW`, `o:phase` | Le plafond de soutirage refuse, et **`source` dit lequel** : raccordement, gestionnaire de réseau, ou auto-imposé. `phase` présent si c'est une phase qui borne. **Réservé à l'autorisation résiduelle NULLE** — voir la précédence ci-dessous. | **PRÉCÉDENCE `DRAW_CAP` ↔ `BELOW_MIN_POWER`** — tranchée le 2026-08-29, et son domicile est `specs/spec_loadmodel.md` **LM-1011**, pas ici : une note de conception se périme, la spec fait autorité. En un mot : autorisation résiduelle **non nulle mais sous le plus petit palier** → `BELOW_MIN_POWER` ; `DRAW_CAP` réservé au résiduel **nul**. Le point de rupture de l'écran 2 est ancré sur le `DRAW_CAP`. **RÉPONSE À R4 — ce que les compteurs de budget comptent : le DÉCIDÉ.** *(Question de l'agent app, 2026-08-29.)* `budget.allocatedW` et ses voisins comptent **ce que la cascade a décidé**, jamais ce que le matériel a reçu. Ce n'est pas un choix de commodité : le budget est le **registre de la cascade**, et ce qui détermine ce que reçoit la charge suivante est la valeur décidée. L'arrondi, lui, vit **après** — dans l'adaptateur, au dispatch, et il est propre à chaque mécanisme. Compter le commandé obligerait à **rouvrir le budget après le dispatch**, c'est-à-dire à décider deux fois. > ⚠️ **Et cela corrige une prémisse de R4, qu'il vaut mieux corriger maintenant.** La maquette > écrit « `allocatedW` publie ce qui a été **commandé** ». **C'est faux aujourd'hui** : > `buildTelemetry()` publie `action.estimatedPowerW` du plan, et `applyActionsToAdapters()` > reçoit le slot **`const`** — rien n'est réécrit après le dispatch. Mesurable : > `testUnpluggedChargerTakesNoBudget` attend `allocatedW == 3000` sur une borne dont le courant > commandé donne 2 990 W. > > Donc **`Σ targetW == allocatedW` tiendra exactement**, par construction — les deux sont du > décidé. L'écart que l'écran veut nommer (« 1 400 W décidés → palier 1 500 W ») n'est **pas** > entre `targetW` et `allocatedW` : il est entre `allocatedW` (**décidé**) et la charge utile de > mécanisme (**appliqué**) — `mechanism.stageW`, `mechanism.setpointW`, `mechanism.currentA` — > **qui est déjà publiée aujourd'hui**. L'écran a déjà les deux nombres ; ce sont leurs noms qui > étaient inversés. | `ECO_FLOOR_GRID` | `deliveredWh`, `targetWh`, `gridW` | Une obligation éco servie **au réseau**. `gridW` chiffre l'achat, comme `EV_GRID_START` le fait déjà. | | `ECO_FLOOR_MET` | `deliveredWh`, `targetWh` | L'obligation est **atteinte** — et elle ne peut se dire atteinte que sur le régime ÉNERGIE (LM-1009). | | `COMFORT_SKIPPED` | `o:sensorValue`, `o:setpoint` | La charge n'est plus demandeuse de son bonus (comparateur de LM-1008). Distinct de « pas de budget ». | **Et un champ, pas un code** : le **régime** de l'avancement (mesuré / estimé / non mesurable), publié sur chaque charge portant une obligation. C'est la condition C2 de LM-1009, et elle ne peut pas vivre dans un code : les trois régimes coexistent dans le même cycle, sur des charges différentes. --- ## 7. Ce que LM-1009 impose à l'obligation éco Trois décisions déjà prises, rappelées ici parce qu'elles ferment des choix de conception : - **D1** — l'avancement se mesure et s'affiche en **énergie livrée**. La passe 1 compare donc des Wh à des Wh, jamais un pourcentage. - **D2** — **obligation par SESSION** d'abord. Pour une borne, la session est le branchement, et la source est `sessionEnergy`. Pour une charge thermique, la « session » est le cycle quotidien — et c'est là que l'analogie s'arrête : `minEnergyWhPerDay` **reste hors de ce lot**, faute d'accumulation inter-sessions (§11). - **C1** — une charge dont l'énergie livrée **n'est pas mesurable** dégrade **visiblement**. Le cas est réel dès demain : la V2C Trydan ne publie pas `sessionEnergy` (`docs/PREPA_BANC_20260828.md` §2.2). Le §10 doit donc prévoir ce régime **dès l'écriture**, pas comme un cas dégradé ajouté après. > **Conséquence directe** : une obligation éco sur une charge non mesurable est **déclarative, > pas vérifiable**. Elle peut être servie ; elle ne peut jamais être annoncée **tenue**. C'est > la même frontière que LM-1009 trace pour l'échéance VE. --- ## 8. LES POINTS À TRANCHER — deux le sont déjà **8.1 — La réserve batterie annule-t-elle aussi la passe ÉCO ?** *(le plus important)* LM-1203-b dit : sous la réserve, le budget est **annulé**. Mais ce budget est un budget de **surplus**, et la passe 1 ne dépense pas de surplus — elle dépense une autorisation de soutirage. - **Si la réserve annule aussi la passe 1** : une installation dont la batterie est basse n'atteindra **jamais** son plancher éco. Un ballon qui n'a pas vu 50 °C ne les verra pas, parce que la batterie de la maison est à 15 %. - **Si elle ne l'annule pas** : la maison achète au réseau pour un chauffe-eau pendant que sa batterie est basse — ce qui peut être exactement ce qu'on veut (l'obligation prime) ou une absurdité (on achète pendant qu'on manque). **TRANCHÉ (Patrick, 2026-08-27) : la réserve N'annule PAS la passe 1.** La réserve protège le **surplus** ; une obligation d'énergie sur échéance est d'une autre nature, et si elle cède à la réserve, elle cesse d'être une obligation. Et la réserve garde son sens là où elle en a un — empêcher de vider la batterie dans un chauffe-eau — ce qu'un soutirage réseau ne fait pas. Inscrit en LM-1203-b. **8.2 — Le plafond §14a est-il par phase, au total, ou les deux ?** Le §2.3 propose de porter les deux sans jamais convertir. Reste à confirmer que c'est bien ainsi que le gestionnaire de réseau l'exprimera — et le transport du §14a n'existe pas encore, donc la réponse peut attendre à condition que la **forme** ne se ferme pas maintenant. **8.3 — Le repli L2 doit-il TENIR le plafond ? TRANCHÉ, et la réponse retire le point du lot.** *(Patrick, 2026-08-27.)* Les deux cas ne se confondent pas : le plafond du §10 est **auto-imposé**, donc un repli qui **ne dépense pas** le respecte par construction — rien à écrire. L'exigence dure — tenir une borne imposée par un TIERS alors que le moteur ne décide plus — appartient au **§14a**, dont le transport n'existe pas, et elle vivra **hors du moteur**. Les confondre ferait hériter au §10 d'une exigence qui ne le concerne pas encore. LM-1006-1 est mis à jour en conséquence : **le §10 n'a rien à faire de ce côté.** **8.4 — La cible de confort : comparateur seul, ou dosage ?** LM-1008 tranche déjà : comparateur (`sonde < consigne` → demandeuse), le dosage attend le modèle thermique du §11. **À confirmer que ça reste vrai pour ce lot**, parce que c'est ce qui le rend faisable sans historique. **8.5 — Que fait l'app des deux passes ? POSÉE À L'AGENT APP le 2026-08-29**, et c'est le dernier point ouvert du lot. LM-1006, couplage 2 : un plancher éco de rang 4 passe devant un confort de rang 1 — voulu, et **contre-intuitif** pour l'installateur qui vient de poser son ordre. Sans écran qui montre les deux passes, le premier retour terrain sera « l'ordre n'est pas respecté ». Ce n'est pas du travail moteur, mais c'est une **condition de livraison**. La question est formulée en `docs/BRIEF_depuis_plugin.md` §9, avec les trois choses à trancher — sur quoi la liste est ordonnée, une entrée ou deux par charge, et ce qu'on voit d'une charge dont l'éco est servi et le confort non. **Le moteur ne répond pas à sa place** : ce qui lui est demandé est de dire quelle forme de publication rendrait l'écran possible, comme la maquette des mécanismes l'a fait pour les six écrans. La charge utile suivra. > ⚠️ **Tant qu'elle n'est pas tranchée, le §10 ne se code pas.** Ce n'est pas une dépendance > molle : la troisième question — une charge dont l'éco est servi et le confort non — n'a > **aucun équivalent** dans la télémétrie d'aujourd'hui, qui publie un motif et une allocation, > un seul de chaque. Commencer par le moteur reviendrait à inventer une charge utile puis à > demander à l'écran de s'en arranger, c'est-à-dire l'ordre inverse de celui qui a marché. --- ## 9. Ce qui n'est PAS dans ce lot - **`minEnergyWhPerDay`** — obligation quotidienne, exige l'accumulation inter-sessions (§11). Le lot livre l'obligation **par session**. - **Le transport du §14a** — aucun canal n'existe. Le lot livre la **forme** du plafond, pas sa source externe. - **Le prix, l'heure, le signal tarifaire** (LM-1005) — le rule-based franchit la frontière de l'achat **grossièrement** : « plancher non atteint à 22 h → chauffer ». Une règle d'horloge. L'optimisation appartient au MILP, donc à l'optimiseur socket, donc à 3d. - **Le dosage en Wh du confort** — voir 8.4. - **La conversion degrés → Wh** — couche domaine, jamais l'arbitre (LM-1008). --- ## 10. Ordre de travail proposé | Étape | Contenu | Éprouvable | |---|---|---| | **10-a** | `DrawCap` / `DrawBudget`, calculés dans `buildContext()`, publiés, **sans effet sur les décisions** | oui — télémétrie, aucun changement de comportement | | **10-b** | Délestage : le plafond borne le plan ; motif `DRAW_CAP` avec sa source ; l'ordre de coupe est l'inverse du rang | oui — simulation + banc | | **10-c** | Refonte décider/émettre (§4) : deux passes, une commande, recrédit unique — **à comportement CONSTANT**, passe 1 vide | oui — les 16 scénarios `run` doivent rester identiques au bit près | | **10-d** | Passe 1 réellement servie : obligation par session, régimes de LM-1009, motifs éco | oui — banc | | **10-e** | Passe 2 confort : comparateur de sonde | oui — banc, avec la sonde du §11 | **10-a et 10-c sont les deux étapes qui ne changent rien**, et ce sont les plus importantes : elles rendent le reste petit. 10-c en particulier doit être prouvée par l'**invariance** des scénarios enregistrés — si la refonte décider/émettre change un seul point de contrôle, c'est qu'elle a changé une décision, et il faut savoir laquelle avant d'aller plus loin.