Patrick Schurig 2a584de913 docs: les quatre décisions du §10, et un troisième mode d'érosion
11.1 étalement uniforme, avec les trois choses qui s'écrivent avec lui :
recalculé à chaque cycle (une cadence figée ne rattrape pas et manque
l'échéance en ayant l'air de travailler) ; il se durcit à l'approche —
2 kWh font 2 kW à une heure et 12 kW à dix minutes, donc la fenêtre d'achat
massif arrive d'un coup et c'est le MOTIF qui doit le dire ; et un point de
non-retour explicite, parce qu'un seuil nommé se discute là où une asymptote
se subit.

11.2 oui aux quatre champs, et la subtilité entre dans le CONTRAT : sous la
réserve batterie, draw.remainingW reste non nul pendant que budget.remainingW
est à zéro. LM-1203-b vue depuis la frontière — la réserve annule le surplus,
jamais l'autorisation de soutirage.

11.3 dissoute, 11.4 par champ explicite.

Et un troisième mode d'érosion dans AGENTS.md, symétrique des deux autres :
non pas une règle qui se périme faute d'être exécutable, mais une règle rendue
IMMORTELLE par sa propre garde. draw.authorisedW a été refusé pour un motif
juste, épinglé par un test, puis le motif est tombé — et la formulation a
survécu, protégée par un test qui ne savait pas que le monde avait changé.
Parade déjà pratiquée ici : quand un test fige une ABSENCE, son commentaire
écrit la condition de levée. « Absent tant que rien ne le mesure » se relit et
se lève ; « ne doit pas exister » se relit et s'obéit.

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

543 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
---
## 0. ⚠️ CE QUI A CHANGÉ DEPUIS L'ÉCRITURE DE CE DESIGN (2026-08-30)
Ce document a été écrit **avant** le lot délestage. Trois de ses sections décrivent désormais du
code livré, et une propose une forme qui a depuis été **refusée**. Lire les sections suivantes
sans cette mise à jour ferait reconstruire l'existant, ou pire, ressusciter un champ qu'un test
interdit.
| Section | État |
|---|---|
| **§2 — le plafond de soutirage devient un OBJET** | **LIVRÉ.** `DrawCap` / `DrawMargin` / `resolveDrawMargin()`, avec les trois sources et le départage par rang de contrainte. Voir `DESIGN_DELESTAGE.md`. |
| **§5 — le délestage, ce qu'il coupe et dans quel ordre** | **LIVRÉ** (`LM-1211`) : ordre inverse du rang, franchissement de verrou **constaté** et non présumé, réservé aux plafonds subis. |
| **§6 — les motifs** | `DRAW_CAP` **existe**, avec sa source, son ampleur et sa phase. `ECO_FLOOR_MET` / `ECO_FLOOR_GRID` restent à naître. |
| **§3 — la forme de `PlanDraw`** | **PARTIELLEMENT REFUSÉE**, voir ci-dessous. |
### La correction à faire sur §3 : `PlanDraw` n'a qu'un champ
Le §3 propose `PlanDraw { authorisedW, committedW, remainingW }`. Seul **`committedW`** a été
livré, et l'absence des deux autres a été **défendue en prose à trois endroits** puis **épinglée
par un test** (`testLoadTelemetryRpc` casse si on ajoute le champ). Le motif d'alors : *« un
`authorisedW` forgé afficherait un plafond de soutirage qui n'existe pas »*.
> **Mais ce motif est tombé.** Le plafond existe désormais — `ctx.drawMargin`, résolu une fois
> par cycle, avec sa source et sa phase. `authorisedW` ne serait plus une forge : il serait une
> lecture. **C'est donc une décision à reprendre, pas une règle à appliquer** (voir §11.2), et
> c'est un bon exemple de ce qu'`AGENTS.md` appelle une décision qui se périme : elle était
> juste, son motif a disparu, et seule sa formulation a survécu.
---
## 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<double> 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<DrawCap> 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<QString, LoadTarget> 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 — trois étapes à comportement CONSTANT, puis une seule qui change
*(Réécrit le 2026-08-30, après le lot délestage. La question de forme du §9 est tranchée : la
maquette `deux_passes_eco_confort_mockup.html` **fait contrat**, et `levels[]`, `funding` et
`counts` sont déjà livrés.)*
La stratégie du délestage a marché et se réemploie telle quelle : **ce qui coûte dans un lot de
ce genre n'est pas d'écrire le comportement, c'est de prouver qu'on n'a rien cassé en chemin.**
Trois étapes ne changent rien et rendent la quatrième petite.
### Étape A — le SPLIT décider / émettre, à comportement constant
C'est le gros du travail, et il n'ajoute aucune fonction. `getPlan()` se scinde en deux temps :
une phase qui calcule une **cible par charge**, une phase qui **émet une commande et une seule**.
Aujourd'hui `buildSetpointAction()` décide, écrête, comptabilise et motive dans le même geste, et
décrémente le budget en même temps qu'il émet — ce qui rend une seconde passe littéralement
inexprimable.
Tant qu'il n'y a qu'une passe, la cible confort est la seule, et la sortie doit être **identique
au watt près**. C'est ce qui rend l'étape vérifiable : `simulation` et `charging` ne doivent pas
bouger d'une ligne. Le filet est déjà là — les tests de motif portent sur le **texte publié**,
donc ils survivront au remaniement ou le condamneront.
> **Le piège à surveiller.** L'arrondi au mécanisme et l'écrêtage de verrou doivent migrer vers
> la phase d'émission **sans changer d'ordre relatif**. `ECS-306` (clamp lock-aware AVANT de
> décrémenter le budget) et `ECS-309` (le motif nomme le mécanisme réel, via `vouluW`) reposent
> tous deux sur cet ordre. Les déplacer d'un cran ferait publier des motifs faux sans qu'aucun
> nombre ne change — le genre de régression qu'une somme correcte masque.
### Étape B — `draw` complété, publication SEULE
`draw` ne publie que `committedW`. Le plafond existe maintenant : `binding` (la source qui
borne), `perPhaseBound`, `authorisedW` et `remainingW` deviennent des **lectures** et non des
forges. Aucune décision ne change ; c'est un ajout de charge utile, et il ferme la moitié de R8
qui restait ouverte au niveau du cycle.
Voir la décision **§11.2** — l'ajout suppose de revenir sur un refus dont le motif est tombé, et
de mettre à jour le test qui l'épingle.
### Étape C — l'AVANCEMENT mesuré, publié sans rien décider
`LoadContextTelemetry::sessionWh` est **déclaré et rempli nulle part** ; `EvCharger` n'expose
même pas d'accesseur vers `sessionEnergy`, que quatre des cinq classes du banc publient.
`minEnergyWhPerDay` est transporté par la config et le RPC, et **lu par aucune décision**. Les
deux moitiés de l'obligation éco sont donc de la plomberie inerte.
Cette étape les branche **en lecture seule** : `sessionWh` alimenté, `progress` publié dans
`levels[]` avec son `regime` ∈ `measured | estimated | unmeasurable`, `deliveredWh` **absent**
quand le régime est `unmeasurable` — jamais à zéro, sans quoi une dégradation se lirait comme un
avancement nul, ce que `LM-1009 C1` interdit nommément.
> **⚠️ `sessionEnergy` porte un OFFSET — mesuré le 2026-08-31.** Sur la V2C, l'écart entre
> `sessionEnergy` et `chargeEnergy` est constant au dix-millième (7,2044), parce que
> l'accumulateur du plugin porte les incréments d'avant la dernière remise à zéro. **La valeur
> absolue n'est donc pas l'énergie de la session courante.** `deliveredWh` doit être une
> **différence depuis une base capturée**, jamais une lecture directe — sans quoi toute
> obligation éco se croirait tenue de 7,2 kWh dès le premier cycle. Un nombre crédible et faux,
> que rien ne signalerait. Détail et mesures : `RELEVE_APP_20260831.md`.
>
> Corollaire : **la V2C n'est plus un cas `unmeasurable`.** `LM-1009 C1` reste vraie pour les
> bornes sans l'état, mais le porteur d'exemple change — R6 devra citer une autre classe.
> **C'est ici que R5 devient vraie, et R6 devient TESTABLE avant d'être nécessaire.** Le régime
> se publie sans qu'aucune décision ne s'y appuie : l'app peut afficher « non mesurable » sur la
> V2C dès cette étape, et la garde de R6 — `ECO_FLOOR_MET` interdit sous `unmeasurable` — aura
> son régime à interroger le jour où le motif naît. On sépare **publier le régime** de **décider
> avec**, exactement comme `measurement` l'a été de l'arbitrage.
### Étape D — la PASSE ÉCO naît, et c'est la seule étape qui change le comportement
Deux passes sur la même liste triée, `ECO_FLOOR_MET` et `ECO_FLOOR_GRID` au catalogue, R6 armée.
La passe 1 est bornée par l'**autorisation de soutirage**, la passe 2 par le **reliquat de
surplus**. Le délestage la rattrape si tous les planchers se servent au réseau le même matin —
c'est la précondition qui vient d'être levée.
---
## 11. CE QUI DEMANDE UNE DÉCISION AVANT L'ÉTAPE D
**11.1 — Comment une obligation en ÉNERGIE devient-elle une cible en WATTS pour ce cycle ?**
C'est la question centrale, et le design ne l'a jamais tranchée. `needs` porte
`minEnergyWhPerDay`, une `deadline` et un `targetSocPercent` — jamais un plancher en watts. La
passe éco doit donc convertir « il reste 900 Wh à livrer avant 7 h » en « prends 1400 W
maintenant ».
Trois formes possibles, et elles ne se valent pas :
| Forme | Ce qu'elle donne | Ce qu'elle coûte |
|---|---|---|
| **Tout ou rien** | la charge prend son plancher dès que l'obligation n'est pas tenue | achète au pire moment, et fait disjoncter à plusieurs |
| **Étalement uniforme** | `reste / temps restant` | simple, vérifiable, mais ignore le tarif et le soleil à venir |
| **Au plus tard** | ne rien acheter tant que le surplus peut encore suffire | optimal, mais suppose une **prévision** — hors modèle |
**TRANCHÉ (2026-08-31) : l'étalement uniforme**, pour la même raison que `LM-1008` a fait de la
cible de confort un **comparateur** plutôt qu'un dosage : ce qui manque n'est pas la formule,
c'est le modèle qui la rendrait honnête. Un étalement se lit, se teste, et ne prétend rien
prévoir. Le « au plus tard » viendra avec la prévision, pas avant.
**Trois choses s'écrivent AVEC lui, et aucune n'est un détail :**
**(a) Recalculé à CHAQUE cycle, jamais figé au départ.** Une cadence calculée une fois au
branchement ne rattrape pas : après une heure sans surplus, elle reste à la cadence initiale et
**manque l'échéance** en ayant l'air de travailler. L'étalement est un rapport entre ce qui
reste à livrer et le temps qui reste — les deux bougent, donc il se relit.
**(b) Il se DURCIT à l'approche, mécaniquement, et cela doit être DIT.** 2 kWh restants font 2 kW
à une heure de l'échéance et **12 kW à dix minutes**. La fenêtre où l'achat devient massif
n'arrive pas progressivement : elle arrive **d'un coup**. Sans que le motif le nomme, un client
verra sa facture grimper à 6 h du matin sans comprendre — et il aura raison de chercher une
panne. C'est donc le **motif publié** qui porte la cadence et le temps restant, pas seulement les
watts : la même doctrine que partout ici — **publier le régime, ne jamais le laisser déduire.**
**(c) Un POINT DE NON-RETOUR explicite**, comme la maquette batterie le demandait : passé une
heure limite, on achète ce qu'il faut. Sans lui, l'étalement peut théoriquement tendre vers
l'infini sans jamais décider — et un seuil nommé se discute, là où une asymptote se subit.
**11.2 — Publie-t-on `draw.authorisedW` et `remainingW` maintenant ?**
Le refus tenait à ce qu'ils n'existaient pas. Ils existent. Mais `remainingW` a une subtilité :
sous la réserve batterie, `LM-1203-b` annule le budget de surplus **sans annuler l'autorisation
de soutirage** — donc `draw.remainingW` reste non nul quand `budget.remainingW` est à zéro, et
c'est **juste**. Il faut le dire, sinon un client conclura à une incohérence.
**TRANCHÉ (2026-08-31) : oui aux quatre**, et le test qui les interdit se met à jour en même
temps — changement de contrat assumé, pas régression. Son nouveau commentaire dira **à quelle
condition il devient faux**, ce que l'ancien ne disait pas (cf. `AGENTS.md`, troisième mode
d'érosion).
**Et la subtilité s'écrit EN TOUTES LETTRES DANS LE CONTRAT, pas en note de bas de page :**
sous la réserve batterie, **`draw.remainingW` reste non nul pendant que `budget.remainingW` est à
zéro.** C'est `LM-1203-b` vue depuis la frontière — la réserve annule le **surplus**, jamais
l'**autorisation de soutirage**. Un client y verra une incohérence, et il aura raison de la
signaler si personne ne l'a dite.
**11.3 — La passe éco achète-t-elle au réseau par DÉFAUT ?**
`ECO_FLOOR_GRID` dit « ce plancher est servi en achetant ». Mais faut-il acheter dès que le
surplus manque, ou seulement quand l'échéance est menacée ? Avec l'étalement uniforme la question
se dissout : on achète ce que l'étalement réclame **et rien de plus**, donc jamais « par défaut »
— toujours parce que le temps restant l'exige. **TRANCHÉ : dissoute — pas de décision séparée**, elle
est absorbée par 11.1.
**11.4 — Que devient le recrédit anti-clignotement avec deux passes ?**
`LM-1004` fixe qu'il est attribué **une seule fois**. Reste à dire **où** : à la première passe
où la charge apparaît. Une charge à plancher éco le reçoit donc en passe 1, une charge sans
obligation en passe 2. **TRANCHÉ : un champ explicite dans la cible** (`recreditAttribue`)
plutôt qu'une discipline — la discipline ne se teste pas, le champ si.
---