Patrick Schurig be401dec40 fix(dérogation): une borne n'est pas écrêtée deux fois, et un mode écrit la termine
Deux défauts trouvés par le premier essai au banc (2026-09-08, 11:05→11:16),
et le premier est celui que Patrick a vu en regardant la voiture.

LM-1500-h — UNE BORNE N'EST PAS ÉCRÊTÉE DEUX FOIS.

La tête de passe éco appliquait `margeRestante` au plafond d'une borne. Or
`margeRestante` est la marge d'UNE phase, et le plafond d'une borne triphasée
est une enveloppe sur TROIS : les comparer divise son allocation par trois.
Mesuré : marge de 6 210 W sur la phase la plus chargée, plafond de cycle à
13 800 W, borne servie à 6 210 — 9 A par phase au lieu de 27, 4 kW au lieu des
11 que la voiture pouvait prendre.

Le plafond d'une borne porte DÉJÀ l'autorisation de soutirage :
evPhaseAllowanceW() la calcule et la multiplie par le nombre de phases
occupées. La passe confort ne fait pas cette faute, et son commentaire dit
pourquoi depuis 3g-2 — « mieux vaut la borne mieux informée que la borne la
plus sévère ». Je l'ai réécrite quinze lignes plus haut sans la relire : une
règle notée à un seul endroit du code n'est pas une règle.

L'erreur allait dans le sens PRUDENT, donc rien ne la signalait — ni le
disjoncteur, ni le délestage, ni un test qui vérifie qu'on ne dépasse pas. Elle
ne se voyait qu'en regardant la voiture.

LM-1500-g — ÉCRIRE UN MODE PENDANT UNE DÉROGATION LA TERMINE.

Choisir un mode est durable ; une dérogation répond à un besoin ponctuel. Le
durable prime, et il prime tout de suite. Et SANS restituer le mode mémorisé,
qui écraserait celui que l'utilisateur vient de choisir.

Le cas vient du banc : le bouton « boost » de l'app écrit encore
ChargingModeNormal. Reçu pendant une dérogation, il fait restituer les valeurs
manuelles par l'amont — chargingEnabled à FAUX — et arme un verrou de 300 s,
pendant que la dérogation continue de commander 9 A. Deux commandeurs pour un
organe, et six minutes de dérogation servie sur le papier dans une borne
éteinte. Seul le journal de divergence le voyait, sans pouvoir en dire la cause.

Deux autres corrections du même essai :

- DEROGATION_ACTIVE et DEROGATION_ENDED sont rendus en français au journal ;
  ils sortaient en « (motif inconnu …) ». Le repli 7-c a tenu — le code brut et
  ses paramètres, jamais une chaîne vide — mais la ligne lue par l'utilisateur
  était illisible.
- poseDerogation() prend l'instant en paramètre. Elle lisait l'horloge murale
  quand le cycle lit l'horloge injectée : sans effet en production, où les deux
  sont la même, mais sous test la fin par durée n'arrivait jamais. Trouvé par le
  test, pas par la lecture.

Et `setChargingInfo() déclenche un cycle` est gravé dans AGENTS.md comme une
PROPRIÉTÉ : ce qui l'entoure a un ordre qui compte, dans les deux sens, et
ENERGY_SIMULATION supprime précisément l'appel — le chemin fautif n'existe pas
sous test.

simulation 69/69, loadmodel 26/26, charging 48/48, spotmarket 32/32,
ci-quality 4/4, build 0 erreur 0 warning.

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

2398 lines
148 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.

# SPEC — Modèle de charges (domaine × mécanisme)
Version : 0.2.3
Dépôt : `etm-powersync-energy-plugin-etm`
Statut : **intention de conception figée.** §3 (schéma persisté) est **en cours
d'implémentation** depuis le 2026-08-09 : `relay-router`, `etmvariableload` et
`sg-ready` sont trois charges utiles discriminées par `adapter`. Le reste du
document reste une intention.
> **0.2.3** — LM-1203-b : sous la réserve de stockage, le budget est annulé et non réduit ;
> avec son corollaire — une réserve réglée haut gèle toute l'installation sans rien signaler —
> et les deux exigences qui l'accompagnent (motif dédié, défaut d'usine ramené de 0,9 à 0,2).
> **0.2.2** — LM-1204-b : le scénario d'acceptation de 3g, avec son piège de simulateur et le
> motif qui le déjoue. Ajouté quand le banc a reçu une seconde borne — et **il reste
> inexerçable tant que 3g n'est pas fait**, ce qui est précisément ce qu'il mesure.
> **0.2.1** — spec relue et validée (Patrick, 2026-08-26). Deux décisions inscrites :
> l'extension du délestage au waterfall devient une **précondition de livraison** du §10 —
> écrite comme condition et non comme conseil — et la cible de confort est tranchée
> (LM-1008) : saisie en degrés, frontière de l'arbitre en énergie, conversion à la couche
> domaine — et aucune conversion nécessaire aujourd'hui, un comparateur suffit. Ordre
> d'implémentation retenu : §11, puis §12, puis §10.
> **0.2.0** — trois manques du modèle spécifiés, **avant** implémentation : le besoin à deux
> niveaux (§10, LM-1000+), le compteur et la sonde par charge (§11, LM-1100+), le plancher de
> modulation (§12, LM-1200+). Écrits maintenant parce qu'écrits tard ils se heurteraient à des
> structures déjà figées. **Aucun code ne suit ce document** — l'ordre d'implémentation
> recommandé est en §13, et il n'est pas celui dans lequel les trois ont été demandés.
> **0.1.4** — LM-201 et LM-202 ajoutés (§2). **LM-201 est la règle qui manquait** : une
> charge existe quand il y a un **canal de commande distinct pour un service**. Un canal, une
> charge. Elle repose la question du domaine, que 0.1.3 posait mal — le domaine ne décrit pas la
> **machine**, il décrit **ce qu'on arbitre**. Elle explique du même coup la contrainte
> d'exclusivité de `validateSet()`, qui cesse d'être une limite technique subie.
> **LM-202** : les thermostats et les zones ne sont pas des charges.
> **0.1.3** — LM-200 ajouté (§2) : toutes les cases de la matrice ne se valent pas.
> Certaines sont *non prévues*, d'autres sont **interdites par la physique** — le cas de la
> PAC, qui ne se pilote qu'en `SgReady` ou en communicant. Précise LM-101, qui était trop
> permissif lu seul. Aucune décision de conception renversée ; une contrainte qui existait
> dans le matériel et pas dans le document.
> **0.1.2** — LM-104 ajouté (§1) : « supposer conservateur pour annoncer, tenter
> systématiquement pour agir ». Principe dégagé des tests d'ECS-411 et ECS-414,
> inscrit ici parce qu'il vaut pour tout mécanisme et non pour l'ECS seul.
> §3 : le mécanisme `sg-ready` a sa charge utile et cesse d'être une intention.
> **0.1.1** — passe de vérification contre le code. Quatre précisions :
> préséance des verrous (LM-303), portée de l'union discriminée (LM-302),
> citation de règle (LM-102), `EvAdapter` inscrit au renommage (LM-601).
> Aucune décision de conception modifiée.
> **Ce document ne déclenche aucun travail.** L'ordre de `specs/spec_ecs.md` §2
> reste seul en vigueur, et l'étape 1 reste bloquée par ses arbitrages §13-1 et
> §13-2. Une seule chose ici est urgente : la **forme du schéma persisté** (§3),
> parce que c'est la seule partie coûteuse à rattraper une fois que le banc et la
> beta auront écrit des données. Le reste s'implémente au fil des besoins.
---
## §1 — Deux couches
**LM-100** — Le modèle de charges se découpe en deux couches indépendantes :
- **Couche domaine** — `Ecs`, `Hvac`, `Ev`, `SmartHome`. Détient le modèle de la
charge (état thermique, échéance de départ, cycle), en déduit la demande et
`available`. **Ne parle à aucun matériel.**
- **Couche mécanisme** — `Relay`, `Variable`, `SgReady`, `ModbusSetpoint`.
Traduit une enveloppe en watts vers le matériel et tient les verrous.
**Écrite une seule fois, partagée par tous les domaines.**
**LM-101** — Une charge configurée choisit **un domaine et un mécanisme**. Le
domaine ne détermine pas le mécanisme et réciproquement — mais il en **exclut**
certains, cf. **LM-200**. L'indépendance des deux couches porte sur le *couplage
de conception*, pas sur la liberté de combiner : elle dit qu'aucune des deux
n'a besoin de connaître l'autre pour être écrite, jamais que toute paire est
réalisable.
**LM-102** — Aucune des deux couches ne répartit de budget. La couche domaine se
retire de l'allocation via `available` ; elle ne la négocie pas. Règles absolues
1 et 2 d'`AGENTS.md` inchangées.
**LM-104 — Supposer conservateur pour annoncer, tenter systématiquement pour agir.**
Un adaptateur qui ne peut pas LIRE l'état d'un organe DOIT le supposer dans l'état
le plus consommateur (contact fermé, palier engagé) quand il **annonce** — dans
`telemetry()`, `toLoadContext()` ou la déduction de son état courant au démarrage.
Le même adaptateur DOIT néanmoins **émettre l'écriture** vers cet organe quand il
agit, sans jamais la court-circuiter au motif que l'état supposé coïncide déjà avec
la cible.
Les deux moitiés sont indissociables, et c'est l'omission de la seconde qui est
piégeuse : supposer « fermé » puis en déduire « donc rien à écrire » transforme une
hypothèse de prudence en **masquage de panne** — l'organe reste dans son état réel,
inconnu, et aucun verdict d'échec ne se déclenche. La prudence porte sur ce qu'on
DIT de l'installation, jamais sur ce qu'on lui ENVOIE.
Portée : tout mécanisme, présent et futur. `Relay` (déduction de palier au
démarrage), `SgReady` (relevé du motif de contacts réellement fermés), et par
construction `ModbusSetpoint` dès qu'un registre devient illisible. Origine :
deux défauts symétriques trouvés par les tests d'ECS-411 puis d'ECS-414 —
d'abord l'hypothèse inverse (« injoignable donc ouvert »), puis son corollaire
(« supposé fermé donc jamais écrit »).
**Justification.** Un nommage par domaine seul (`EcsAdapter`, `HvacAdapter`)
obligerait à écrire l'encodage SG-Ready deux fois (ECS et HVAC), la combinatoire
de relais deux fois (ECS et EV en prise commandée), et le transport Modbus deux
fois (HVAC et EV). Un nommage par mécanisme seul perdrait les comportements
propres au domaine, qui sont réels : stockage différable pour l'ECS, confort
immédiat pour le HVAC, échéance de départ pour l'EV, cycle non modulable pour le
SmartHome.
---
## §2 — Matrice
| Domaine | Mécanismes attendus |
|---|---|
| `Ecs` | `Relay` (résistance à paliers) · `SgReady` (ballon thermodynamique) · `Variable` (triac) |
| `Hvac` | `SgReady` (PAC) · `ModbusSetpoint` (PAC, clim, VMC) |
| `Ev` | `Relay` (prise commandée : GreenUp, Witty) · `ModbusSetpoint` (borne) |
| `SmartHome` | `Relay` — voir §7, sémantique différente |
**LM-200 — Une case vide se lit de deux façons, et il faut les distinguer.** La
plupart sont *non prévues* : rien ne les interdit, elles n'ont simplement pas
d'usage identifié. D'autres sont **impossibles**, parce que le mécanisme ne sait
pas commander cet organe-là. Les confondre revient à traiter une contrainte
physique comme une lacune de feuille de route.
**Le cas dur, et le seul identifié à ce jour : la PAC.** Une pompe à chaleur se
pilote de **deux** façons, et de deux seulement :
| Voie | Mécanisme | Ce qui circule |
|---|---|---|
| **SG-Ready** | `SgReady` | deux contacts **secs** vers l'entrée de signalisation — 4 états normés |
| **Communicante** | mécanisme *communicant*, via un plugin nymea | mode, consignes, limite de puissance — le modèle **propre** de la machine |
**Ni `Relay`, ni `Variable`.** Ce ne sont pas des cases à remplir plus tard :
- **`Relay` couperait l'ALIMENTATION du compresseur.** Un `RelayRouter` étage de la
puissance réelle ; appliqué à une PAC, il lui retire son autorité sur son propre
cycle — dégivrage, retour d'huile, temporisation de redémarrage — et transforme
chaque décision d'arbitrage en court-cycling. Le verrou `minOn/minOff` limite la
casse, il ne rend pas le geste légitime.
- **`Variable` suppose un cadran en watts.** Un triac module une résistance ; un
compresseur n'est pas gradable. Une PAC communicante peut certes accepter une
*limite* de puissance, mais elle l'interprète selon son propre modèle et ne
s'engage sur aucune valeur instantanée — ce n'est pas la sémantique d'enveloppe
que `Variable` promet à l'optimiseur, et cette promesse alimente le budget.
**⚠️ Le piège de lecture : « ma PAC est pilotée par deux relais, donc `Relay` ».**
Non. Les contacts SG-Ready **sont** des relais, et au banc `.75` ce sont bien deux
things `power` (K1/K2). Mais ils **signalent**, ils ne **portent** pas la charge.
C'est exactement ce qui sépare les deux mécanismes : `Relay` commute de la
puissance, `SgReady` commute du sens. Voir des relais dans une configuration ne
dit donc rien du mécanisme qui convient.
**Ce que ça implique pour le mécanisme communicant, qui n'existe pas encore.** Il
est appelé `ModbusSetpoint` dans ce document : **le nom est trop étroit.** Ce qui
le définit n'est pas Modbus — c'est qu'un **thing nymea** porte le modèle de la
machine et que le moteur lui parle dans ce modèle-là. Le transport (Modbus
RTU/TCP, IP propriétaire, passerelle cloud) est une affaire de plugin
d'intégration, invisible d'ici. À renommer quand il sera implémenté ; à ne pas
confondre avec `Variable` d'ici là, malgré la ressemblance de surface d'une
« consigne » à écrire.
**Rien n'empêche aujourd'hui d'exprimer une case interdite, et c'est assumé.**
`domain` est une métadonnée d'intention, **optionnelle**, que l'arbitre ne lit
pas : une charge `domain: "hvac"` + `adapter: "relay-router"` est acceptée par
`isValid()`. **Décision prise (2026-08-25) : pas de refus.** Un refus ferait
cesser `o:domain` d'être une métadonnée — donc changerait le contrat annoncé à
l'app — et resterait **contournable par omission**, le domaine étant facultatif.
Un contrôle qu'on esquive en effaçant un champ protège mal. La sanction retenue
est un **avertissement au journal** à la construction de l'adaptateur.
**Cet avertissement n'a pas encore de prédicat, et ne peut donc pas être écrit
aujourd'hui.** LM-201 en dit la raison : le domaine décrit **ce qu'on arbitre**,
pas la machine — il ne peut pas révéler qu'il y a un compresseur derrière. Keyé
sur le domaine seul, l'avertissement crierait sur des installations correctes :
| Configuration | Verdict réel |
|---|---|
| `heating` + `relay-router` | **légitime** — chauffage électrique résistif sur relais |
| `hvac` + `relay-router` | **légitime** — VMC, ventilateur sur contact |
| `hvac` + `etmvariableload` | **légitime** — ventilation à vitesse variable |
| `heating`/`hvac` + relais, mais PAC derrière | ce qu'on veut signaler — **indistinguable des trois lignes ci-dessus** |
Le discriminant qui le porterait est **`typeAppareil`** (§3, LM-300), dont
l'énumération reste ouverte (§8-1) — c'est sa valeur *thermodynamique* qui dirait
« compresseur ». **L'avertissement est donc spécifié ici et implémenté avec
`typeAppareil`, pas avant.** Un avertissement qui crie au loup sur des
installations correctes abîme la seule surface de diagnostic dont dispose le banc.
---
**LM-201 — UN CANAL DE COMMANDE, UNE CHARGE.** Une charge existe quand il y a un
**canal de commande distinct pour un service**. C'est la règle qui fonde toutes
les autres, et elle repose la question du domaine : *« quel domaine pour une
PAC ? »* est **mal posée**. Une PAC air/eau fait chauffage, rafraîchissement et
ECS — le domaine ne décrit donc pas la **machine**, il décrit **ce qu'on
arbitre**.
Conséquences, toutes vérifiables sur une installation :
| Installation | Charges | Pourquoi |
|---|---|---|
| PAC chauffage + ECS, **sans ballon séparé** | **UNE** charge, domaine `heating` | un seul canal SG-Ready commande la machine entière |
| PAC + **ballon séparé** | **DEUX** charges (`heating`, `ecs`) | le ballon a son **propre** canal |
| PAC qui fait **aussi du froid** | **UNE** charge | même machine, même canal, même rang |
- **Pas de charge ECS sur une PAC sans ballon séparé.** Le SG-Ready commande la
machine entière ; la répartition entre chauffage et ECS appartient à la
**régulation de la PAC**. Il n'y a rien à arbitrer indépendamment — en déclarer
deux reviendrait à promettre un arbitrage qu'aucun canal ne peut exécuter.
- **Le froid ne crée pas de charge.** Ce qui change entre l'été et l'hiver est la
**saison**, pas la configuration. Une configuration qui devrait être réécrite
deux fois par an serait le signe qu'on a modélisé la machine et non le canal.
- **La contrainte d'exclusivité de `validateSet()` n'est pas une limite technique
subie — c'est le modèle.** Deux charges ne peuvent pas revendiquer les mêmes
contacts (LM-302-b, ECS-110-b) parce que **deux charges sur un canal unique se
contrediraient** : elles recevraient deux consignes pour un seul organe, et le
budget compterait la même puissance deux fois (règle absolue 1). Le refus
existait avant la règle ; la règle dit enfin pourquoi il est juste.
**LM-202 — Un thermostat de zone n'est PAS une charge.** Un thermostat ouvre une
**vanne** ; il ne porte pas la consommation, qui reste à la machine. Les déclarer
comme charges produirait des entrées à **0 W** dans le waterfall pendant que la
PAC est comptée ailleurs — des charges qui n'ont rien à recevoir, sur un canal
qui ne consomme rien.
Zones et thermostats sont de la **configuration de confort** : ils décrivent **où
va la chaleur**, pas **combien on en achète**. Ils vivent dans leur écran et leur
namespace — **pas dans `LoadConfig`**. C'est la même frontière que LM-102 trace
entre la couche domaine et l'allocation : décrire un besoin n'est pas négocier un
budget.
---
## §3 — Schéma de configuration (à figer)
**LM-300** — `LoadConfig` est une **union discriminée** par le mécanisme :
```
LoadConfig {
id, nom, enabled
domaine : Ecs | Hvac | Ev | SmartHome
typeAppareil : affine le domaine (résistif | thermodynamique | …)
groupe, prioritéGroupe, prioritéMembre
mécanisme : Relay | Variable | SgReady | ModbusSetpoint ← discriminant
chargeUtile : LoadConfigRelay | LoadConfigVariable | …
verrous : minOnS, minOffS
facettes[] : voir §4
}
```
**État d'implémentation (2026-08-09).** Trois charges utiles existent, discriminées
par le champ `adapter` du schéma persisté — `relay-router`, `etmvariableload`,
`sg-ready`. Le domaine, le groupe et les facettes ne sont pas encore portés : ce
qui est figé, c'est la **forme**, celle qui coûte cher à rattraper.
```
adapter = "sg-ready" → sgReady {
states[] { state : 1..4, relays[] : thingId, estimatedPowerW }
minStateHoldS
}
```
`estimatedPowerW` porte volontairement ce nom : c'est une **estimation**, jamais un
engagement — une PAC ne consomme pas la même chose à −5 °C et à +12 °C. Le champ
sert à ordonner et à budgéter, la mesure reste la source de vérité (ECS-500).
L'état 2 est **obligatoire** : c'est le plancher de repli du mode dégradé L2 et de
la désactivation (ECS-413/414). Une configuration qui ne peut pas l'exprimer est
refusée, pas construite.
**LM-301** — Ajouter un mécanisme DOIT se limiter à un type de charge utile et
une branche de fabrique. Rien d'autre ne bouge. **Vérifié à l'usage** : l'ajout de
`sg-ready` a touché exactement une charge utile, une branche de `isValid()`, une
branche de fabrique dans `rebuildLoadAdapters()`, et un champ optionnel au schéma
RPC. Aucun code d'arbitrage n'a bougé.
**LM-301-b — La limite de LM-301, trouvée au lot B-bis (2026-08-25).** « Une charge utile, une
branche de fabrique » vaut pour **construire** un mécanisme, pas pour l'**alimenter en actions**.
Quand `sg-ready` a rejoint la table commune des charges arbitrées, un code d'arbitrage a bel et
bien dû bouger : le repli du mode dégradé L2 fabriquait un `Setpoint(0)` pour **toute** la table,
en présumant du kind. Une PAC de configuration recevait donc une action que `SgReadyAdapter`
rejette d'entrée — et rien d'autre : elle restait en **état 4 (forcé)** sous compteur muet, quand
`SAFETY.md` §L2 exige l'état 2. Le défaut ne se voyait pas dans la télémétrie, où le motif
`DEGRADED_L2` s'affichait correctement ; seule l'**écriture** manquait.
**Règle qui en sort** : tout code qui **fabrique** une action pour une charge quelconque DOIT
lire `descriptor().supportedKinds` et se refuser explicitement s'il n'y trouve rien de connu —
jamais présumer du kind à partir de la table où la charge se trouve. C'est le pendant, côté
actions, de ce que LM-302-b dit côté validation : ce qui est vrai « pour toutes les charges »
n'appartient à aucune charge utile.
**LM-302-b — Ce qu'une charge utile ne peut pas valider.** L'exclusivité d'un Thing est
une propriété de l'**ensemble** des charges, pas d'une charge : aucune charge utile ne
peut la vérifier, quelle que soit sa rigueur. Elle vit donc au niveau du store
(`validateSet()`), et tout mécanisme futur DOIT déclarer les Things qu'il revendique via
`LoadConfig::claimedThingIds()` — c'est le seul point à étendre, et l'oublier rend le
mécanisme invisible à la vérification. Voir ECS-110-b.
**LM-302 — Charges utiles auto-validantes, et portée de l'« inexprimable ».**
Chaque charge utile valide la sienne. Les états invalides doivent être inexprimables
dans les **types C++** et le **schéma persisté** ; à la frontière **JSON-RPC**, la
validation est **à l'exécution**, par contrainte nymea.
**Corollaire vérifié à l'usage (2026-08-09).** `GetLoadConfig` sérialise via le
méta-objet, donc **toutes** les propriétés déclarées apparaissent dans la sortie — y
compris la charge utile **vide** d'un autre mécanisme. Un schéma qui exige une clé
interne dès que la charge utile est présente rend donc impossible l'aller-retour
lire-puis-réécrire, c'est-à-dire le geste ordinaire de l'application, et le rejet
survient **avant** le handler.
**Règle.** Toutes les clés internes d'une charge utile optionnelle DOIVENT être
déclarées optionnelles (`o:`) au schéma RPC. L'exigence réelle vit dans `isValid()`,
jamais dans le schéma. Les clés internes d'une charge utile optionnelle contraignent
les charges des **autres** mécanismes — c'est le côté par lequel le mur se touche.
**Obligation permanente.** Tout mécanisme ajouté DOIT être accompagné d'un test
d'aller-retour `GetLoadConfig` → `SetLoadConfig` **inchangé**, vérifié échouant sans
le correctif. Sans cette obligation, le prochain mécanisme rejouera la scène.
**LM-303 — Verrous.** `typeAppareil` fournit des **défauts** (résistif ≈ 60 s,
thermodynamique ≈ 300 s), l'installateur peut les modifier, et c'est la valeur
**stockée** qui fait foi. Jamais de temporisation codée en dur.
**Préséance.** Les verrous déclarés au niveau de la **charge** s'appliquent par
défaut. Une charge utile PEUT en porter de plus fins — par relais, par registre —
et ceux-là **priment** sur le défaut de charge, pour le seul actionneur qu'ils
désignent.
Cette règle laisse `spec_ecs.md` §13-3 (ECS-307, grain des temporisations par
relais) entièrement ouvert : ajouter plus tard un champ optionnel dans une charge
utile est rétro-compatible, quelle que soit la réponse. Le schéma persisté n'a
donc pas à être « rendu tolérant » — c'est la préséance qui devait être écrite,
et elle l'est ici.
---
## §4 — Facettes : une machine, plusieurs fonctions
**LM-400** — Une machine physique est **une seule charge** : un rang, un jeu de
verrous, une ligne de budget. Une PAC assurant chauffage et ECS n'est **jamais**
déclarée deux fois.
**Justification.** Deux déclarations produiraient une double allocation du
surplus pour une seule consommation, deux rangs pour un seul comportement, et
deux jeux de verrous sur un seul compresseur — la protection anti court-cycling
tomberait précisément là où elle coûte le plus cher.
**LM-401** — Une charge PEUT porter **plusieurs facettes** au-dessus d'un
actionnement unique. Une PAC mixte porte une facette ECS (consigne, température
ballon) et une facette chauffage, avec un seul mécanisme.
**LM-402** — Les facettes servent à la lecture, au suivi et au calcul
d'`available`. Elles **ne portent pas de rang**. Une charge à deux facettes ne
peut pas être classée « après les chauffe-eau mais avant la clim » : si cette
granularité est nécessaire, il faut deux machines, pas deux entrées.
**LM-403 — Le mode est une sortie, pas une entrée.** SG-Ready applique un niveau
d'encouragement à la machine entière ; le choix de la fonction appartient au
régulateur interne. Le HEMS **observe** le mode courant, il ne le sélectionne
pas. Aucune conception ne DOIT supposer un aiguillage.
Ce que le mode apporte : `available` devient honnête. Machine en mode chauffage
→ la facette ECS n'absorbera rien quoi qu'on encourage → le waterfall passe au
suivant au lieu de le deviner.
**LM-404 — Biais par consigne : option avancée, hors périmètre pour l'instant.**
Monter `hotWaterSetpointTemperature` place le ballon sous consigne et la machine
bascule d'elle-même. C'est indirect, lent, et surtout **persistant** : une
consigne écrite le reste après un plantage ou un redémarrage, et elle écrase un
réglage d'installateur. Toute implémentation future DOIT comporter des bornes
dures, une politique de restauration et un chien de garde. À ne rouvrir que si le
terrain le justifie.
**LM-405 — Décomposition côté ems, pas côté plugin.** Le découpage en facettes
DOIT se faire dans l'ems au-dessus des états plats du thing, et non en forkant
les plugins constructeurs. Chaque constructeur expose une structure différente ;
l'abstraction doit vivre là où elle est indépendante du constructeur, sous peine
de la refaire N fois et de gérer deux formes du même concept.
**LM-406** — Si l'installation n'a pas de chauffe-eau distinct, il n'y a pas de
charge ECS : la facette ECS s'accroche à la charge PAC.
---
## §5 — Groupes et double rang
**LM-500** — Un groupe est une **clé de tri**, jamais un détenteur de budget. Le
waterfall trie sur le couple `(prioritéGroupe, prioritéMembre)` et reste
inchangé. Un groupe qui reçoit une allocation puis la redistribue est un second
décideur (règle absolue 1).
**LM-501** — Classer un ECS, une PAC et une clim les uns par rapport aux autres
**fonctionne déjà** avec la liste plate triée par `priority`. Les groupes ne sont
nécessaires qu'à l'échelle : plusieurs chauffe-eau, plusieurs bornes, plusieurs
zones, quand on veut déplacer une famille entière sans renuméroter.
**LM-502 — Plafond de groupe : optionnel.** Justifié quand les membres partagent
une limite électrique réelle (bornes sur un même câble ou un même abonnement).
Sans objet pour un groupe thermique, dont les membres sont sur des circuits
distincts. C'est le seul point où la boucle du waterfall change vraiment : elle
doit suivre l'engagement cumulé du groupe.
**LM-503** — Deux notions à **ne pas** confondre avec un rang :
- **Exclusion mutuelle** (clim et PAC en opposition dans une même zone) : une
contrainte entre appareils.
- **Équité interne** (trois chauffe-eau à rang égal, dont le troisième resterait
froid tout l'été) : une politique de rotation dans le tri.
**LM-504** — « HVAC » couvre chauffage, ventilation et climatisation ; y ranger
l'eau chaude sanitaire est un abus. Libellé recommandé côté installateur :
« Thermique » ou « Chauffage & eau chaude ».
---
## §6 — Nommage
**LM-600** — Les classes d'actionnement portent le nom du **mécanisme**, jamais
du domaine.
**LM-601** — Un renommage de cohérence est souhaitable. Trois classes sont
concernées, avec des échéances distinctes :
| Classe | Défaut | Débloqué après |
|---|---|---|
| `RelayRouter` | Ne suit pas la convention `*Adapter` de ses voisins | étape 3 de `spec_ecs.md` |
| `EtmVariableLoadAdapter` | Préfixe `Etm` redondant dans un dépôt ETM | étape 3 de `spec_ecs.md` |
| `EvAdapter` | **Nommé d'après un domaine** (§2 range `Ev` en domaine) | **3g** |
`EvAdapter` n'est pas une classe de domaine égarée dans la couche mécanisme :
c'est un **adaptateur de mécanisme mal nommé**. Il parle à l'interface
`evcharger` de nymea — un mécanisme au même titre que `SgReady`. Le correctif est
un renom, pas un redécoupage.
Aucun de ces renommages ne se fait avant son échéance. Pour `RelayRouter`,
renommer juste avant la restructuration de `m_relayMapping` double le bruit dans
l'historique ; pour `EvAdapter`, le câblage lui-même change en 3g (l'adaptateur
n'est aujourd'hui pas dispatché), et renommer une classe dont le branchement va
bouger produit le même bruit pour rien.
`BatteryAdapter` n'entre pas dans ce tableau : LM-700 le traite hors moule.
---
## §7 — Cas hors moule
**LM-700 — Batterie.** Une batterie n'est pas une charge avec un rang : elle est
aussi une **source**, capable de financer les autres charges (grid-funding, 3f).
Elle intervient à un autre moment de la cascade et ne se modélise pas comme un
consommateur classé. Onduleur hybride et AC-coupling sont bien deux mécanismes.
**LM-701 — SmartHome.** Un cycle de lave-vaisselle ne se descend pas à 0 W en
cours de route : c'est un engagement pris au démarrage, pas un verrou `minOn`.
La sémantique d'action n'est pas une enveloppe en watts mais un **décalage
temporel** — démarrer maintenant ou plus tard. À traiter comme un type d'action
distinct, pas comme une variante.
---
## §8 — Points ouverts
1. `typeAppareil` — quelle énumération exacte, et jusqu'où elle affine le
domaine. **A désormais un consommateur nommé** : c'est lui, et non `domain`,
qui porterait l'avertissement de LM-200 (valeur *thermodynamique* = il y a un
compresseur). Tant qu'il n'existe pas, l'avertissement n'a pas de prédicat.
2. Représentation persistée des facettes (§4) : imbriquées dans la charge, ou
table séparée référençant la charge.
3. Sémantique d'action du SmartHome (LM-701) : à spécifier avant toute
implémentation, elle ne se déduit pas du reste.
4. Rotation d'équité (LM-503) : politique à définir si le besoin se confirme.
5. **Autorisation de soutirage (LM-1005).** Quel objet exactement — un booléen d'horloge, une
fenêtre tarifaire, un coût plafond ? Le rule-based et le MILP n'en attendent pas la même
forme, et la choisir trop tôt contraindrait le second.
6. **Seuil et fenêtre du diagnostic de mesure (LM-1102).** « Trois cycles » est un point de
départ. La valeur juste dépend de l'inertie de la charge et de la latence du compteur — donc
des données que LM-1103 doit d'abord constituer.
7. ~~**Cible de confort : en énergie ou en grandeur physique ?**~~ **TRANCHÉ** (2026-08-26) —
voir LM-1008.
8. **`heating` vs `hvac` — ce que ces deux codes désignent.** Question **ouverte, et
volontairement non tranchée**. Elle ne se saura vraiment qu'en dessinant la **PAC
communicante** (Alpha Innotec, Aquarea), où les consignes ECS et chauffage sont enfin
**séparées** — donc où le canal de commande cesse d'être unique, et où LM-201 rend un
verdict différent. D'ici là, `o:domain` **garde son rôle actuel** : grouper l'ordre de
service, **sans force normative**. **Ne construire aucune règle dessus** — l'avertissement
de LM-200 attend `typeAppareil` précisément pour cette raison.
*(La question « le domaine doit-il devenir un discriminant de validation ? » est, elle,
tranchée : non — avertissement, pas refus. Voir LM-200.)*
---
## §9 — Ce que ce document ne change pas
- L'ordre de `specs/spec_ecs.md` §2 et ses arbitrages bloquants.
- Les règles absolues 1 à 10 d'`AGENTS.md`.
- Le découpage en vigueur : le scheduler alloue, `RelayRouter` traduit.
- Rien dans le code aujourd'hui.
> Les §10 à §13 qui suivent sont **postérieurs** à ces deux sections de clôture : ils ont été
> ajoutés en 0.2.0 sans renuméroter le document, pour ne pas casser les renvois existants vers
> « §8-1 ». Les points ouverts qu'ils soulèvent ont été ajoutés au §8.
---
## §10 — Besoin à deux niveaux : éco et confort
> **Statut : intention de conception.** Rien de ceci n'est implémenté. Le modèle actuel n'a
> qu'un seul niveau de besoin, et il est implicite : *tout ce que le surplus permet*.
**LM-1000 — Une charge thermique n'a pas un besoin binaire.** Elle en a deux, de natures
différentes :
- **Éco** — un **plancher à atteindre quoi qu'il arrive**. Un ballon qui n'a pas vu 50 °C de
la journée doit les voir, au réseau s'il le faut. C'est une **obligation d'énergie sur un
horizon**, pas une préférence d'allocation.
- **Confort** — un **bonus**, servi seulement quand le surplus abonde. Monter le même ballon
à 60 °C parce qu'il y a du soleil. Ne rien servir n'est pas un échec.
Les deux ne se distinguent pas par leur quantité mais par ce qui arrive **quand on ne les
sert pas** : rien pour le confort, une obligation reportée pour l'éco.
**LM-1001 — L'éco n'est pas un champ nouveau : c'est `needs` promu.** `LoadConfig` porte déjà
`needs { dailyDeadline, minEnergyWhPerDay }`. C'est **exactement** la forme d'un plancher éco :
une énergie, une échéance. Ce qui manque n'est pas la déclaration du plancher — c'est la
**cible de confort** et la **place des deux dans le waterfall**.
> N'ajouter un `ecoNeeds` séparé serait dupliquer `needs` sous un autre nom, et il faudrait
> ensuite expliquer lequel des deux fait foi.
**LM-1002 — Deux passes sur la MÊME liste ordonnée. Pas de second rang.**
```
passe 1 — ÉCO : parcourir les charges par rang croissant, servir leur plancher éco
passe 2 — CONFORT : reparcourir la MÊME liste, dans le MÊME ordre, avec le reliquat
```
Le rang garde un sens unique et un seul — l'ordre de service que l'installateur a posé. La
hiérarchie éco/confort le **surplombe** : elle dit *quel besoin* est servi avant, jamais
*quelle charge* passe devant.
**Ce que cela vaut, formellement** : un ordre lexicographique sur `(niveau, rang)`, où
`niveau ∈ {éco, confort}`. C'est un ordre **total**, dérivé du rang existant — pas un second
système de priorité.
> **Rejeté explicitement** : une paire `prioritéEco` / `prioritéConfort` par charge. Elle
> permettrait de contredire le rang, elle divergerait de lui à la première modification, et
> elle obligerait l'installateur à tenir deux classements cohérents dans sa tête.
**LM-1003 — Deux passes, mais UNE SEULE commande par charge et par cycle.** Les passes
calculent des **cibles**, elles ne commandent pas. La consigne appliquée à une charge est la
**somme** de ce que les deux passes lui ont attribué, arrondie une seule fois au mécanisme
(palier de relais, état SG-Ready, consigne continue), puis émise une seule fois.
C'est la règle qui empêche le défaut le plus probable de cette conception : un relais commandé
en passe 1 puis recommandé en passe 2, donc deux commutations dans le même cycle — du
court-cycling fabriqué par l'arbitre lui-même.
**LM-1004 — Le recrédit anti-clignotement appartient à la passe qui DÉPENSE DU SURPLUS.** La
correction B rend à chaque charge sa consommation de début de cycle avant d'arrondir. Avec deux
passes, ce crédit ne doit être compté qu'une fois : compté deux fois, il fabriquerait du budget
qui n'existe pas.
> **Formulation corrigée le 2026-09-07.** La règle disait « à la **première** passe où la charge
> apparaît ». C'était faux, et le code l'appliquait fidèlement : la passe éco n'est pas bornée
> par le surplus mais par l'**autorisation de soutirage**, et ne touche jamais
> `remainingSurplusW`. Lui « attribuer » le recrédit revenait donc à ne le créditer **nulle
> part** — une charge servie en éco ne récupérait jamais sa propre consommation.
>
> **Conséquence mesurée** : borne à 3 899 W, surplus publié **−3 148 W** ; sans elle il valait
> **+700 W**. Elle se privait exactement du surplus qu'elle pouvait recevoir, et « minimum +
> surplus » ne servait jamais que le minimum.
>
> Le défaut existait depuis le §10 pour le chauffe-eau servi en éco. Il était **invisible parce
> que son compteur affiche 0 W** : une charge qui ne mesure rien ne peut pas se priver de quoi
> que ce soit. Il a fallu une charge qui mesure — et qui tire quatre kilowatts — pour qu'il se
> voie. Septième forme de l'homogénéité du banc, et la première où c'est l'ABSENCE de mesure
> qui masque, non l'uniformité.
**LM-1005 — La frontière que ce modèle franchit, et il faut la nommer.**
Jusqu'ici l'arbitre **distribue un surplus**. Un plancher éco non atteint au coucher du soleil
signifie **soutirer au réseau** — donc décider d'**acheter**. L'arbitre cesse d'être un
répartiteur pour devenir un acheteur, et cela ouvre trois questions qu'il n'avait jamais eu à
poser : à quel **prix**, à quelle **heure**, sous quel **signal tarifaire** (heures creuses,
Tempo).
- **Le rule-based franchira cette frontière grossièrement**, et c'est acceptable : « plancher
non atteint à 22 h → chauffer ». Une règle d'horloge, pas une optimisation.
- **Le MILP la franchira vraiment** : c'est précisément le problème qu'il sait résoudre —
placer une énergie obligatoire dans une fenêtre, au coût minimal, sous contraintes.
**LM-1005-b — Une plage tarifaire ne prend pas son fuseau du système : elle porte celui où le
tarif est défini.**
> **PRÉCONDITION DE LIVRAISON DU LOT TARIFAIRE — pas une recommandation.** Aucune fenêtre
> tarifaire ne part sans que ce fuseau soit explicite dans la donnée qui la porte — pas dans un
> commentaire, pas dans une convention d'appel : dans le champ.
>
> Il y a DEUX natures d'heure murale dans ce moteur, et elles ne prennent pas leur fuseau au
> même endroit :
>
> | | nature | fuseau qui fait foi |
> |---|---|---|
> | `dailyDeadline`, « avant 7 h » | **habitude du client** | celui de l'installation |
> | heures creuses, Tempo, EPEX | **calendrier réglementé** | celui où le tarif est **défini** |
> | taux journaliers (« minuit local ») | **période de reporting** | celui de l'installation |
>
> La troisième a été trouvée en cherchant autre chose (2026-09-04) : `GetEnergyRatios` calcule
> ses Δ **depuis minuit local**. Elle suit l'installation, ce qui est CORRECT — mais seulement si
> le fuseau de la box est celui de l'installation. Sur `.75`, en `Europe/London`, le bilan
> journalier de l'app se remet à zéro à **01:00 heure française**. Le comportement du moteur est
> juste ; c'est la prémisse qui est fausse, et rien ne la vérifie.
>
> Une plage d'heures creuses française est définie en `Europe/Paris` par le fournisseur, pas
> « à l'heure locale de la box ». Les confondre ne décale pas un affichage : **ça achète au
> mauvais moment, et le tarif, lui, ne s'est pas décalé.** La facture est le seul endroit où
> ça se verrait, et elle arrive un mois trop tard.
>
> Le risque naît ICI et nulle part avant. Tant que l'arbitre distribue un surplus, il n'a pas
> d'heure à respecter ; c'est en devenant **acheteur** qu'il en acquiert une. Aujourd'hui,
> toute heure murale hériterait silencieusement du fuseau système : correct pour l'habitude du
> client **si** la box est bien réglée, faux pour le tarif **dès qu'elle ne l'est pas**.
>
> Le cas n'est pas théorique : au 2026-09-04, le banc `.75` tourne en `Europe/London`, et une
> obligation déclarée à `"12:00"` s'y résout à 13:00 heure française. Établi et mesuré dans
> `docs/DESIGN_FUSEAU.md` — il n'existe qu'UN fuseau, celui du système, et un `nymead` vivant
> suit son changement sans redémarrage.
**LM-1005-b-i — une heure murale nue est CORRECTE pour une échéance client, et FAUTIVE pour une
plage tarifaire.** *(Distinction poussée un cran plus loin par l'agent app, 2026-09-04.)*
Ce n'est pas la même donnée avec deux fuseaux : ce sont deux données différentes.
- **Échéance client** — `"07:00"` nu est le bon codage. Si la box change de fuseau, l'habitude
doit se réinterpréter : le client veut toujours son eau chaude à 7 h **du matin chez lui**.
Réinterpréter est ici le comportement CORRECT, pas une tolérance.
- **Plage tarifaire** — `"22:00"` nu est un défaut MUET. La fenêtre est définie **ailleurs**,
par le fournisseur ; elle ne bouge pas parce que la box a bougé. Une donnée qui se
réinterprète alors que son référentiel, lui, ne bouge pas, devient fausse **sans que rien
dans la donnée ne permette de s'en apercevoir**.
Conséquence : **le fuseau de référence vit dans le CHAMP, pas dans une convention d'appel.** Une
convention se perd au premier import, au premier changement de fournisseur, au premier agent qui
n'a pas lu ce paragraphe. Sans ça, le piège du 2026-09-04 revient à l'identique sur un champ où
il coûte de l'argent au lieu d'une phrase d'écran.
**LM-1005-b-ii — le fuseau de référence se PUBLIE, il ne se déduit pas.** Ne pas laisser un
client supposer `Europe/Paris` : un fournisseur allemand définit ses plages en `Europe/Berlin`.
Un client qui déduirait choisirait une horloge **à la place de quelqu'un** — exactement ce que
la décision « ni imposer, ni refuser : déclarer » refuse. Le moteur publie donc le fuseau de
référence avec la configuration tarifaire, comme il publiera celui de l'installation avec la
sienne.
**LM-1005-b-iii — DEUX avertissements distincts, à ne jamais fusionner en une comparaison.**
| avertissement | compare | conséquence si ignoré |
|---|---|---|
| échéance client | **installation ≠ appareil** | l'obligation tombe à la mauvaise heure |
| plage tarifaire | **fournisseur ≠ installation** | on **achète au mauvais moment** |
Seul le second peut coûter de l'argent, et seul le second est **indevinable par un installateur**
— il ne se voit ni sur la box, ni sur le téléphone, seulement en rapprochant le contrat du
fournisseur. Les fusionner en un unique « les fuseaux diffèrent » ferait disparaître celui des
deux qui compte derrière celui qui se corrige tout seul.
**Et les deux sont CONDITIONNELS.** Un avertissement ne s'affiche que si les deux fuseaux
comparés diffèrent réellement. Affiché en permanence, il cesse d'être lu — et il cesse de l'être
**le jour exact où il devient vrai**. C'est la règle de l'absence qui informe, la même qui rend
`progressUnmeasurableCause` absent sur une charge mesurable et `phase` absent dans `DRAW_CAP`
quand aucune phase ne mord.
**Corollaire de formulation** : le fuseau publié qualifie une HORLOGE, il n'offre pas un choix.
« 12:00 heure de la box (Europe/Paris) » dit la chose ; « Fuseau : Europe/Paris » sous un champ
d'échéance laisse croire qu'on choisit le fuseau de CETTE échéance. Il n'y en a qu'un, c'est un
réglage système, et il déplace d'un coup toutes les heures murales de l'installation.
**LM-1005-b-iv — la frontière d'une période de reporting se PUBLIE, elle ne se recalcule pas.**
*(Demandé par l'agent app, posé le 2026-09-05.)*
`GetEnergyRatios` publie `periodStartedAt` (epoch), `measuredSince` (epoch) et `timeZone` (IANA).
**Pourquoi publier plutôt que laisser déduire.** `System.GetTime` donne un NOM de fuseau ; un
décalage ne s'en déduit pas sans base de fuseaux. Embarquer une base pour recalculer une frontière
que le moteur connaît déjà serait le mauvais geste — et le recalcul resterait **faux les jours de
changement d'heure**, où la journée fait 23 ou 25 heures. Trois clients la recalculeraient de
trois façons, et la seule qui compte est celle qui a servi au calcul. **Même règle que `counts`.**
**Et c'est la PARADE à la troisième nature.** La période de reporting suit l'installation, ce qui
est correct — mais repose sur la prémisse que le fuseau de la box soit celui de l'installation, et
c'était le seul des trois cas où **personne ne vérifiait**. Publier la frontière et son fuseau est
exactement ce qui rend la vérification possible.
**LM-1005-b-v — `periodStartedAt` et `measuredSince` ne bornent PAS la même chose, et ils
arrivent dans la même trame.** `measuredSince` est la borne basse des RATIOS — c'est depuis là
qu'on compte. `periodStartedAt` est la frontière de la JOURNÉE, et sert le bilan journalier, qui
est un autre écran. **`periodStartedAt` n'est pas la borne basse des ratios**, et les présenter
comme journaliers alors qu'ils ne comptent que depuis le dernier redémarrage serait exactement le
silence que ce champ prétend fermer.
> L'analogie avec `progress.measuredSince` s'arrête là, et c'est par là que l'erreur passe —
> commise une fois, à la conception même de ce champ. Sur une charge, `measuredSince` répond
> « depuis quand on MESURE » et borne une fenêtre dont `targetWh` donne l'autre bout. Ici il
> répond « depuis quand on COMPTE », et la paire ne borne aucune fenêtre : les deux dates parlent
> de deux objets différents. **Transposer un nom, ce n'est pas transposer une sémantique.**
**LM-1005-b-v-bis — pourquoi la frontière ne se recalcule pas, en trois nombres.** Une journée
locale ne fait pas toujours 24 heures. Vérifié, avec un oracle calculé hors de Qt puis gravé en
test : **2026-03-29 fait 23 h, 2026-10-25 fait 25 h**, et les 363 autres jours 24 h. Un client qui
poserait « la journée fait 86 400 s » serait donc juste presque toujours — et faux deux fois par
an, dont une en plein hiver de chauffage. C'est l'erreur silencieuse par excellence : elle ne se
voit pas à l'écran, elle se voit sur la facture, un mois plus tard.
Et la raison de fond demeure : `System.GetTime` publie un NOM de fuseau. Un décalage ne s'en déduit
pas sans base de fuseaux, et embarquer une base pour recalculer ce que le moteur sait déjà est le
mauvais geste.
**LM-1005-b-vi — la baseline se repose en plein jour, et ça se dit.** Elle est reprise à chaque
redémarrage de nymead et à chaque recul de compteur. Un jour où nymead a redémarré à 14 h, les
ratios ne couvrent pas la journée : ils couvrent depuis 14 h. Sans `measuredSince`, l'écran écrit
« aujourd'hui » sur des chiffres qui ne parlent que de l'après-midi.
**Conséquence de conception** : la passe 1 n'est **pas** bornée par le surplus. Elle est bornée
par une **autorisation de soutirage** — un objet distinct, que le rule-based rendra binaire et
que le MILP rendra continu. La passe 2, elle, reste strictement bornée par le reliquat de
surplus. **Ne pas confondre les deux bornes** : c'est ce qui distingue un plancher d'un bonus.
**LM-1006 — Deux couplages à traiter en même temps, sinon la conception est fausse.**
1. **Le raccordement.** Si tous les planchers éco se servent au réseau le même matin sans
soleil, ils s'additionnent sur le disjoncteur de branchement. La passe 1 doit donc être
bornée par la **limite de raccordement** — aujourd'hui `phasePowerLimit`, dont la protection
de surcharge ne déleste **que les bornes de recharge**. Éco et délestage général sont le
même lot ; les séparer livrerait un plancher capable de faire disjoncter une installation.
2. **L'ordre de service apparent.** Un plancher éco de rang 4 passe devant un confort de
rang 1. C'est voulu, et c'est **contre-intuitif** pour l'installateur qui vient de poser son
ordre. L'app doit le montrer — deux passes visibles, pas un classement unique qui aurait
l'air désobéi. Sans cela, le premier retour terrain sera « l'ordre n'est pas respecté ».
**LM-1006-1 — Le délestage n'est pas « la protection de surcharge » : c'est l'application d'un
PLAFOND DE SOUTIRAGE, quelle qu'en soit la source.** *(Note de conception, Patrick, 2026-08-27.
Ne déclenche aucun travail — elle existe pour que la porte reste ouverte.)*
Le §14a EnWG (Allemagne) permettra au gestionnaire de réseau d'imposer un **plafond de puissance
au point de livraison**, que l'EMS répartit ensuite librement sur les appareils. C'est le même
mécanisme que le délestage exigé par LM-1006 (couplage 1), avec une **source de commande
différente**.
Il y a donc **trois sources pour un seul mécanisme** :
| Source | Nature du plafond | Durée |
|---|---|---|
| **Disjoncteur de branchement** | physique — `phasePowerLimit` | permanent |
| **§14a EnWG** | externe, **sur ordre** du gestionnaire de réseau | temporaire |
| **§10 éco** (LM-1005) | implicite — celui qu'on **s'impose** en décidant de soutirer | le temps de la passe 1 |
**Ce qui est demandé, et c'est tout** : quand le délestage général sera écrit, ne pas l'écrire
comme *« la protection de surcharge »* mais comme *« appliquer un plafond de soutirage »*. La
source est un paramètre, pas une hypothèse enfouie dans le nom de la fonction. **Cela tient dans
une signature aujourd'hui ; ce serait une refonte dans deux ans.**
Deux propriétés sont vraies des **trois** sources. Les tenir dès l'écriture ne coûte rien ; les
rattraper après coûte le mécanisme entier.
1. **Le plafond le plus contraignant l'emporte — et on doit pouvoir dire LEQUEL a mordu.**
C'est exactement l'exigence qui sépare `BELOW_MIN_POWER` de `SURPLUS_INSUFFICIENT` (§12), et
elle se transpose telle quelle : un client bridé doit lire **« gestionnaire de réseau »** et
non « protection de surcharge ». **Deux causes, deux gestes** — l'une se subit et se
renseigne auprès du fournisseur, l'autre s'attaque en délestant la maison ou en revoyant
l'abonnement. Un motif qui les confond envoie diagnostiquer le mauvais problème, avec
l'autorité d'une explication (règle 7-b d'`AGENTS.md`).
> Conséquence pratique : le plafond retenu et sa **source** appartiennent au motif publié,
> pas au seul journal. Le catalogue de motifs est fermé et testé sur le TEXTE — c'est le bon
> endroit pour que cette distinction ne puisse pas se perdre.
2. **Le plafond survit à la panne du HEMS** — mais **pas de la même façon selon la source**, et
les confondre ferait hériter au §10 d'une exigence qui ne le concerne pas encore.
*(Distinction posée par Patrick, 2026-08-27.)*
| Source | Ce que « survivre » veut dire | Qui doit le tenir |
|---|---|---|
| Disjoncteur | rien à faire — il coupe physiquement | **L0**, hors logiciel |
| **Plafond éco (§10)** | **rien à faire non plus** | le repli L2 **ne dépense pas** : un plafond qu'on s'impose à soi-même est respecté par construction dès lors qu'on cesse d'acheter |
| **§14a EnWG** | le plafond doit **continuer d'être appliqué** alors que le moteur ne décide plus, et la coupure totale est interdite | **hors du moteur** — le repli L2 n'en est pas capable, et n'a pas à l'être |
**Conséquence pour le §10 : aucune.** Son plafond est **auto-imposé**, donc un repli qui
n'initie rien (`docs/SAFETY.md` §L2) le respecte sans code supplémentaire. L'exigence dure —
tenir une borne imposée par un tiers, sans moteur — appartient au §14a, dont le transport
n'existe pas encore, et elle vivra **hors du moteur**. C'est ce que cette note réserve, et
c'est tout ce qu'elle réserve.
**Ce que cette note ne demande PAS** : aucun champ de configuration, aucune entrée au modèle de
charges, aucun code. Le §14a n'a pas de transport ici (il arrivera par un canal qui n'existe pas
encore), et le §10 n'est pas parti. C'est une **contrainte de forme sur du code non écrit**.
---
**LM-1008 — La cible de confort : les deux unités, mais pas au même endroit.** *(Décision
Patrick, 2026-08-26.)*
- **La frontière de l'arbitre reste en ÉNERGIE.** Rien ne change à ce que le moteur reçoit et
distribue : des watts et des Wh.
- **La cible se SAISIT en degrés**, parce que c'est ainsi que l'installateur et le client la
pensent. Un ballon se règle à 60 °C, pas à 2 400 Wh.
- **La conversion appartient à la couche domaine**, jamais à l'arbitre. C'est LM-100 appliqué
tel quel : le domaine détient le modèle de la machine, l'arbitrage ne connaît que des watts.
**Et aujourd'hui, aucune conversion n'est nécessaire.** Sans modèle thermique, il suffit de
savoir si le besoin de confort **existe encore** :
```
sonde < consigne de confort → la charge est demandeuse de son bonus
sonde ≥ consigne de confort → elle ne l'est plus, la passe 2 la saute
```
**C'est l'ARRÊT qui compte, pas le dosage.** Un bonus qu'on sert tant qu'il est utile et qu'on
cesse de servir quand il ne l'est plus se contente d'un comparateur — et un comparateur ne
demande aucun modèle.
Le **dosage en Wh** viendra avec le modèle thermique, que l'historique du §11 permettra
précisément d'apprendre. **Une raison de plus de faire le §11 en premier** : il ne prépare pas
seulement les seuils, il prépare la seule chose qui rendra le dosage possible.
**LM-1009 — Une échéance ne vaut que ce que son avancement se mesure.** *(Question remontée par
l'agent app, 2026-08-27. Note de conception : ne déclenche aucun travail, mais elle CONDITIONNE
la forme du §10.)*
**Le constat de l'app, et il est exact.** Sur `.75`, aucune classe `evcharger` ne déclare d'état
de charge et aucune ne porte d'interface véhicule — vérifié sur les cinq classes présentes
(`terraAcTcp`, `terraAcRtu`, `wallbox` ×2, `wallboxNoMeter`) : `batteryLevel` absent partout.
Les bornes publient `pluggedIn`, `charging`, `maxChargingCurrent`, `phaseCount` et, pour quatre
d'entre elles, `sessionEnergy`. Jamais un pourcentage.
**Ce que le code fait aujourd'hui, et c'est plus troublant que « il manque une mesure ».**
`targetPercentage` est comparé à `carBatteryLevel`, lu sur le Thing **voiture**
(`smartchargingmanager.cpp:628`). Et ce `batteryLevel`, quand la voiture est une voiture
*manuelle* — le seul cas où le mécanisme s'active, il exige un `batteryLevel` **inscriptible** —
est **écrit par le moteur lui-même** : `updateManualSoCsWithMeter()` intègre la puissance de la
BORNE, applique un facteur de pertes, divise par une `capacity` déclarée, et **réinjecte le
résultat dans l'état du Thing** par une Action.
Autrement dit : le moteur estime un pourcentage à partir de trois grandeurs dont **aucune n'est
mesurée** — le SoC de départ saisi par l'utilisateur, la capacité déclarée, un facteur de pertes
constant — puis compare la cible à sa propre estimation. **La boucle se referme sur elle-même**,
et rien, à aucun moment, ne lit l'état réel d'un véhicule.
> Le piège est là, et il touche l'app en premier : l'estimation est écrite dans `batteryLevel`,
> donc elle sort par la frontière RPC **indistinguable d'une mesure**. Un écran qui affiche
> « 62 % » affiche une intégration, pas une lecture. Si le §10 conserve ce chemin, ce champ doit
> être marqué comme ESTIMÉ là où il est publié — sans quoi l'app ment de bonne foi.
**Et pendant ce temps, la grandeur qui EST mesurée n'est lue par personne.** `sessionEnergy` —
l'énergie livrée depuis le branchement, comptée par la borne — est publiée par quatre des cinq
classes du banc. Le contrat porte déjà son emplacement : `LoadContextTelemetry::sessionWh`
(`surpluscontext.h:77`). **Il n'est rempli nulle part**, et `EvCharger` n'expose même pas
d'accesseur. Champ déclaré, jamais alimenté.
### Réponse à la première question : OUI, et le modèle a déjà la bonne forme
Un avancement utilisable se reconstruit sans communication véhicule, et les trois pièces
existent déjà :
| Ce qu'il faut | Ce qui existe | État |
|---|---|---|
| une grandeur mesurée | `sessionEnergy` de la borne | publiée, **jamais lue** |
| un emplacement au contrat | `LoadContextTelemetry::sessionWh` | déclaré, **jamais rempli** |
| une cible dans la même unité | `needs { minEnergyWhPerDay }` (LM-1001) | déclaré, **jamais lu par le scheduler** |
**C'est la forme que le §10 doit retenir** : une obligation exprimée en **énergie**, mesurée par
l'énergie livrée. Elle est vérifiable de bout en bout, et elle est déjà commune à toutes les
charges — un ballon et une borne portent le même `needs`, ce qui est exactement ce que LM-1001
annonçait.
**Quatre réserves, et chacune devient un défaut si on l'oublie :**
1. **Livré ≠ stocké.** `sessionEnergy` est comptée à la borne ; les pertes de charge font que le
véhicule en reçoit moins. Ce n'est pas un défaut de la mesure — c'est le bon côté du compteur
pour une **obligation d'énergie** : celui qu'on paie. Mais la promesse doit se formuler
« livrer X kWh », jamais « mettre X kWh dans la batterie ».
2. **`sessionEnergy` est une grandeur de SESSION** : elle se remet à zéro au branchement. Ce
n'est une réserve que si l'on vise une obligation *quotidienne* — et la décision D2 ci-dessous
ne la vise pas. Pour une obligation *par session*, la remise à zéro n'est pas un obstacle
contourné : c'est **exactement la bonne sémantique**, et le problème disparaît au lieu d'être
traité.
3. **Elle ne répondra JAMAIS à « la voiture sera-t-elle pleine ? »** Aucune énergie livrée ne dit
où en est une batterie dont on ne connaît ni l'état de départ ni la capacité réelle. Une cible
en énergie est vérifiable ; une cible en pourcentage ne l'est pas. Ce sont **deux objets
différents**, et le §10 ne doit pas les confondre sous le mot « échéance ».
### Réponse à la seconde question : OUI, et elle tient MÊME avec la première
Répondre « oui » à la première ne dispense pas d'écrire la seconde, parce que `targetPercentage`
**ne disparaît pas** : c'est un champ amont, c'est ce que l'app affiche, et c'est ce que
l'utilisateur saisit. Deux objets, donc deux régimes de vérité, et le motif publié doit les
distinguer :
| Objet | Vérifiable ? | Ce que le motif a le droit de dire |
|---|---|---|
| obligation en **énergie** (`needs`) | **oui**, par `sessionEnergy` | « X kWh livrés sur les Y demandés » — et l'atteinte peut être annoncée |
| cible en **pourcentage** (`targetPercentage`) | **non**, sans communication véhicule | l'intention déclarée, et **l'avancement comme inconnu ou estimé** — jamais comme tenu |
**Règle à tenir dès l'écriture** : *un motif n'affirme pas ce que le moteur ne mesure pas.* Une
échéance qu'on ne peut pas vérifier ne doit jamais se présenter comme tenue, ni comme en voie de
l'être. C'est la même exigence que celle qui sépare `BELOW_MIN_POWER` de `SURPLUS_INSUFFICIENT`
(§12) et que LM-1006-1 pose pour la source d'un plafond : **deux causes, deux phrases**, parce
qu'elles n'appellent pas le même geste. Ici le geste diffère radicalement — « attends, ça
charge » contre « je ne peux pas savoir où tu en es ».
### DÉCISIONS *(Patrick, 2026-08-27)*
**D1 — L'avancement s'affiche en ÉNERGIE LIVRÉE, jamais en pourcentage.** « 8,4 kWh livrés depuis
le branchement » est vérifiable ; « 62 % » suppose une capacité que personne ne connaît et **se
lira comme une lecture du véhicule**. Le pourcentage garde sa place dans la **saisie de
l'intention** — l'utilisateur pense sa voiture en pourcentage — mais **jamais dans l'affichage de
l'avancement**.
**D2 — Obligation par SESSION d'abord.** Le §10 lit `sessionEnergy` et s'arrête là. Trois raisons
qui vont dans le même sens : elle ne demande que de lire un champ **déjà publié** ; la remise à
zéro au branchement cesse d'être une réserve (voir ci-dessus) ; et c'est **l'usage réel** — on
branche le soir pour partir le matin. L'accumulation inter-sessions est un **lot à part**,
dépendant de l'historique du §11, et `minEnergyWhPerDay` ne sera honoré qu'avec lui.
**D3 — Tant que le chemin `carBatteryLevel` survit, il est ÉTIQUETÉ à la publication.** Une valeur
que le moteur a lui-même intégrée ne franchit pas la frontière RPC **sous le même nom qu'une
mesure**. Le précédent de nommage existe déjà dans ce document : `estimatedPowerW` porte
volontairement son préfixe (§3), parce qu'un nom doit porter le statut épistémique de ce qu'il
transporte. C'est aussi le refus opposé au `currentPowerStorage` à zéro du relevé 3g-1 : **mieux
vaut « estimé », ou rien, qu'un chiffre crédible et faux** — et c'est le corollaire de forme
d'`AGENTS.md` invariant 12, « un champ absent vaut mieux qu'un champ nul ou forgé ».
### CONDITIONS DE LIVRAISON — pas des recommandations
Elles s'écrivent avec le mécanisme, pas après. Ce sont les deux façons dont ce §10 peut mentir
sans erreur visible.
**C1 — Une borne qui ne publie pas `sessionEnergy` DÉGRADE VISIBLEMENT.** Mesuré sur le banc :
`wallboxNoMeter` et l'une des deux classes `wallbox` n'ont pas cet état. Sur ces bornes,
l'obligation n'est **pas mesurable** — et une échéance qui cesse silencieusement de l'être est
**pire qu'une échéance absente**, parce qu'elle continue de s'afficher comme si elle tenait. Le
moteur doit le dire, l'app doit le montrer, et la dégradation ne doit jamais se lire comme un
avancement nul. Même geste que `hasPowerMeter()`, déjà testé avant toute estimation de SoC.
**C2 — On doit pouvoir dire LEQUEL des deux régimes s'applique — dans le motif comme à l'écran.**
Un avancement mesuré et un avancement estimé ne se distinguent par aucune de leurs valeurs : les
deux sont un nombre qui monte. Si le régime ne se lit pas, le second se fait passer pour le
premier, et c'est précisément le défaut que cette note décrit. Le régime est donc une donnée
publiée, au même titre que la source d'un plafond en LM-1006-1 — **deux causes, deux phrases**.
---
**LM-1010 — Le §10 est DÉBLOQUÉ : la maquette des deux passes fait contrat.** *(Réponse de
l'agent app au §9 du brief, 2026-08-29. `etm-powersync-app/docs/mockups/`
`deux_passes_eco_confort_mockup.html` **fait foi** ; huit exigences argumentées et la charge
utile complète.)*
Les trois questions de 8.5 sont tranchées, et **l'argument décisif n'est ni le nôtre ni celui du
design** :
1. **La liste reste ordonnée par RANG** — non pas d'abord parce que le rang doit rester lisible,
mais parce que **l'ordre de service n'est PAS une propriété de la configuration** : il dépend
des planchers déjà atteints, donc il change d'un cycle à l'autre sans que personne ne touche à
rien. Une liste de configuration ne peut pas avoir une colonne vertébrale qui bouge sous le
doigt qui vient d'y glisser une charge. Trier par ordre de service ne rendrait pas le rang
illisible — cela rendrait la **liste instable**, ce qui est pire.
2. **Une entrée par charge, deux lignes dans la carte.** La commande est le **parent**, les
niveaux sa **décomposition**. Règle générale : *on dédouble là où l'objet affiché est la
SÉQUENCE, jamais là où l'objet est la CHARGE.* D'où un second écran, en lecture seule,
« ordre de service du cycle » — c'est lui qui montre le rang 4 éco devant le rang 1 confort,
rangs visibles, et qui désamorce « l'ordre n'est pas respecté ».
3. **Éco servi / confort non = deux ÉTATS NOMMÉS**, jamais une fraction. Affiché comme une
fraction, le régime **nominal** se lirait comme une dégradation permanente.
**Ce que le moteur doit publier** : `levels[]`, un élément par passe **réellement parcourue** —
rien de retiré. Les quatre exigences qui coûteraient cher à rattraper :
| | | |
|---|---|---|
| **R2** | `funding` descend au **niveau** | **la plus urgente** : seule des huit à être un *changement de forme* d'un champ existant. Au §10 la même charge est financée au réseau pour son éco et au surplus pour son confort **dans le même cycle** — un `funding` par charge devient indécidable, et c'est lui qui porte l'identité de réconciliation |
| **R3** | chaque watt tombe dans **exactement un** compteur, et le mapping est **publié** | trois destinations coexisteront : `budget.allocatedW`, `budget.evReservedW`, `draw.committedW`. Leçon d'`EV_GRID_START`, qui partage une allocation entre deux compteurs |
| **R4** | **cible ≠ commande**, et l'arrondi **ne se répartit pas** | toute clé de répartition serait une fiction dont l'app hériterait comme d'un fait. *Réponse du moteur sur ce que les compteurs comptent : `docs/DESIGN_10.md`* |
| **R6** | `ECO_FLOOR_MET` **interdit** sous régime `unmeasurable` | servie oui, tenue jamais (LM-1009 C1). Cas réel **dès aujourd'hui** : la V2C ne publie pas `sessionEnergy` |
**Et ce qu'il ne faut PAS fabriquer** — trois refus, tous du même genre : ne pas publier ce qui
se déduit.
- **Aucun index d'ordre de service.** La séquence se dérive d'un tri lexicographique sur
`(niveau, priority)`. Un index publié serait une **seconde source de vérité pour une donnée
déductible**, et divergerait un jour.
- **Aucun second rang**, sous quelque nom que ce soit. La maquette n'a de sens que si LM-1002
tient.
- **Aucun pourcentage d'avancement**, même indicatif.
> La maquette **vaut comme contrat, pas comme feuille de route** : elle fixe la forme de ce qui
> sera publié, pas une date. Et côté app, rien ne commence avant que `levels[]` soit en ligne.
**LM-1210 — la cascade tient dans DEUX ressources, et les bornes VE gardent la leur.**
*(Étape 3 du délestage, 2026-08-30.)*
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. Jusqu'ici seules les bornes VE
étaient bornées par la seconde — une charge de waterfall pouvait donc créer un dépassement que
seule la protection L4 rattrapait, **après coup**.
La marge est une valeur de **cycle** : `SurplusContext::drawMargin`, résolue une fois, jamais
relue. Chaque charge servie l'entame de ce qu'elle a été autorisée à prendre — **toutes**, bornes
comprises. Ne décompter que les charges qu'on borne offrirait aux suivantes une marge déjà
dépensée.
**Les bornes VE ne sont pas bornées par la marge de la maison**, et ce n'est pas un oubli : leur
plafond de cycle est déjà écrêté par `evPhaseAllowanceW()`, **qui connaît les phases qu'elles
occupent**. Leur appliquer en plus la pire phase de la maison les priverait d'une phase saine
parce qu'une autre sature. Entre deux bornes justes, on garde **la mieux informée**, pas la plus
sévère.
**LM-1210-a — la marge retenue est celle de la PIRE phase, faute de savoir laquelle.**
`LoadDeclared::phases` est un **nombre**, pas un masque : le moteur ignore sur quelle phase une
charge est câblée. Une monophasée de phase inconnue peut tomber sur n'importe laquelle, donc sur
la pire. Supposer mieux serait deviner, et deviner dans une couche de sécurité se paie au
disjoncteur.
Le pessimisme est **borné en pratique** : une phase qui exporte est **au maximum de la marge** —
laquelle est écrêtée au plafond, l'export ne permettant pas de tirer plus que le disjoncteur — et
ne borne donc jamais. *(Formulation corrigée le 2026-09-03 : le texte disait « une marge plus
grande que le plafond », ce qui est faux. La conclusion tenait, la raison non.)* Il ne mord que sur une triphasée réellement déséquilibrée — et il
coûte alors du **rendement, jamais de la sécurité**. Lever la limite demande un masque de phases
déclaré, donc config, interface et migration : hors de ce lot.
> **Report daté, pas position de principe (2026-08-30).** Le masque **finira par s'ouvrir**, et
> l'échéance se rapproche par les deux bouts. Côté matériel, l'information EXISTE déjà pour une
> borne : la V2C publie `currentL1` / `currentL2` / `currentL3` depuis le **2026-08-28**. Côté
> modèle, le **§14a** distinguera par phase — il ne pourra pas se contenter d'un nombre.
>
> Ce qui est décidé ici n'est donc pas « le moteur ignorera toujours les phases », mais « il les
> ignore tant que le champ n'est pas déclaré, et il se comporte alors de la manière la plus
> défensive ». Le jour où le masque arrive, `houseDrawMargin()` cesse de retenir la pire phase
> pour retenir **celle de la charge** — un seul appel change, et le pessimisme disparaît sans
> qu'aucune règle ne bouge. C'est précisément ce que l'étape 2 a préparé en comparant des
> **marges** et non des plafonds.
**LM-1211 — le délestage L4 s'étend au waterfall, en ordre inverse du rang, et dit ce qu'il a
franchi.** *(Étape 4 du délestage, 2026-08-30.)*
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.
**LM-1211-a — un seul commandeur par organe.** Les charges délestées sont **revendiquées** pour
le cycle : le dispatch les saute. Sans cela, la consigne du plan rallumerait ce que la sécurité
vient de couper — règle absolue 1, appliquée ici comme `m_waterfallEvIds` l'applique aux bornes.
**LM-1211-b — le franchissement de verrou se CONSTATE, il ne se présume pas.** Le délestage tente
d'abord **sans forcer**, et ne marque `minOnBypassed` que si le verrou a réellement refusé.
Déduire le franchissement 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`. Tant qu'un refus renvoyait l'action
> **demandée**, il se lisait comme un succès : le délestage ne forçait jamais et publiait des
> watts que personne n'avait rendus.
**LM-1211-c — on ne force que pour un plafond SUBI.** `Breaker` et `GridOperator` autorisent le
franchissement ; `EcoSelfImposed` **jamais** — on renonce au plancher éco plutôt qu'au matériel.
Comportement observé, non postulé : forçage interdit, la PAC de test **reste en état 4**.
**LM-1211-d — 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 (`ECS-413`). C'est le cas que le bornage de la
cascade (`LM-1210`) ne pouvait pas traiter — il empêche d'allouer, il ne retire rien à personne.
**LM-1012-b — l'avancement est une DIFFÉRENCE depuis une base, jamais une lecture directe.**
*(§10 étape C, 2026-08-31. Mesuré sur le banc, et ça ne se déduisait pas du code.)*
`sessionEnergy` est l'**accumulateur du plugin**, pas un compteur remis à zéro pour le moteur.
Côté V2C, l'écart avec `chargeEnergy` est constant au dix-millième — 7,2044 kWh sur deux
échantillons à deux heures d'écart — parce qu'il porte les incréments d'avant la dernière remise
à zéro de la borne. **Le lire directement ferait croire toute obligation éco tenue de 7,2 kWh dès
le premier cycle** : un nombre crédible et faux, que rien ne signalerait.
**LM-1012-b-i — la base se prend à l'ouverture de l'OBLIGATION, jamais au démarrage de nymead.**
La prendre au démarrage réinitialiserait l'avancement d'une session en cours sur une valeur déjà
accumulée : une recharge de nuit relue au matin repartirait de zéro.
**LM-1012-b-ii — un recul FRANC de l'accumulateur recapture la base.** Le plugin le remet à la
valeur brute à chaque nouvelle session ; sans recapture, la différence deviendrait négative. Un
recul **infime** est du bruit de lecture et ne conclut à rien — la base reste, sinon l'avancement
acquis se perdrait à chaque frémissement du compteur.
> **Ce que le moteur ne refait PAS.** La détection de remise à zéro de la BORNE vit dans le
> plugin, qui interroge à 30 s. Le moteur travaille à la minute et raterait des cycles entiers ;
> il ne détecte donc que le recul de l'**accumulateur lui-même**. Regarder comment le plugin l'a
> fait avant de le refaire est ce qui a évité d'écrire une seconde accumulation, moins fine, en
> concurrence de la première.
**LM-1012-c — le RÉGIME d'avancement se publie, porté par le niveau (R5).**
`progress.regime` ∈ `measured` \| `estimated` \| `unmeasurable`. Sous `unmeasurable`,
`deliveredWh` est **absent, jamais 0** : une dégradation ne doit pas se lire comme un avancement
nul, sans quoi l'échéance continue de s'afficher comme si elle tenait (`LM-1009` C1).
Le régime est publié **alors qu'aucune décision ne s'y appuie encore**, et c'est délibéré : cela
sépare **publier le régime** de **décider avec**, exactement comme `measurement` a été séparé de
l'arbitrage. C'est ce qui rendra **R6** — `ECO_FLOOR_MET` interdit sous `unmeasurable` — testable
le jour où le motif naîtra, au lieu d'un invariant posé sur un code que rien n'émet.
**LM-1014-b — toute grandeur CUMULÉE rapportée à une période doit dire ce qu'elle fait au
CHANGEMENT de période.** *(Généralisée le 2026-08-31, après deux occurrences en deux jours.)*
| Grandeur | Ce qui n'était pas dit | Ce que ça produisait |
|---|---|---|
| `sessionEnergy` | qu'elle porte un **offset** des incréments d'avant la dernière RAZ de la borne | toute obligation crue tenue de 7,2 kWh au premier cycle |
| l'obligation quotidienne | que la base d'avancement **ne se reprenait pas** à l'échéance | le lendemain héritant de l'énergie de la veille, tenu sans rien avoir reçu |
Ce sont deux cas de la même famille, et aucun des deux ne se voyait en lisant le code : les deux
produisent un nombre **crédible et faux**, que rien ne signale — pas une erreur, pas une absence,
un chiffre plausible au mauvais endroit.
> **La question à poser à chaque cumul, une fois, à l'écriture :**
> **« Que vaut-il à la seconde qui suit la bascule ? »**
>
> Trois réponses possibles, et il faut choisir explicitement : il **repart de zéro**, il
> **continue**, ou il est **repris sur une base**. Ne pas choisir revient à choisir « il
> continue » — qui est presque toujours faux pour une grandeur périodique, et toujours
> silencieux.
Le corollaire de test s'écrit tout seul : **un cumul se teste de part et d'autre de sa bascule**,
jamais seulement en régime établi. Les deux défauts ci-dessus ont survécu à des suites vertes
parce qu'aucun scénario ne traversait la frontière.
**LM-1014-c — un cumul se teste aussi de part et d'autre d'un CHANGEMENT DE RÉGIME, pas
seulement de période.** *(Corollaire de `LM-1014-b`, trouvé par lecture le 2026-09-02.)*
`LM-1014-b` dit ce qu'un cumul devient à la bascule de **période**. Elle ne disait rien de la
bascule de **régime à l'intérieur d'une période** — et c'est là que le défaut vivait.
Sur `measured → unmeasurable → measured` dans la même fenêtre, la base d'avancement était retirée
puis **reprise à la valeur courante** : l'énergie livrée pendant les tronçons mesurés antérieurs
disparaissait. Deux conséquences, et la seconde coûte :
1. l'écran **sous-estime** ;
2. la passe éco **rachète ce qui a déjà été livré**.
**Ce n'est pas théorique** : c'est le motif exact d'une V2C dont le lien Modbus bat — chaque perte
et retour fait basculer le régime **deux fois**, et chaque aller-retour effaçait ce qui précède.
**La correction : accumuler par PÉRIODE, pas par tronçon.** En quittant `measured`, on ajoute
`dernierSessionWh − base` à un cumul de période au lieu de le jeter. Le tronçon est **clos**, pas
abandonné.
> **Et il faut clôturer sur la DERNIÈRE valeur lue tant que la mesure existait**, jamais sur celle
> du cycle où le régime tombe : à cet instant le Thing ne publie plus, et s'en servir fabriquerait
> une énergie.
**LM-1014-c-i — le cumul de période repart de zéro à la bascule de période**, comme tout ce qui
lui est rapporté, et **les trous avec lui** : ils comptaient les interruptions de la période
close.
**LM-1014-c-ii — `measuredSince` garde le PREMIER instant mesuré, et l'intermittence se dit à
part** (`measuredGaps`). Redater à chaque retour effacerait l'histoire au lieu de la raconter ;
ne rien dire ferait croire à une continuité que le battement dément. L'écran peut alors écrire
« mesuré **par intermittence** depuis 09:14 », qui est la phrase vraie.
**LM-1012-d — un accumulateur ne parie JAMAIS sur ce qu'un plugin fait de ses états en tombant.**
*(2026-09-03, avertissement de l'app intégré AVANT l'écriture.)*
Un compteur qui disparaît peut **publier 0 au lieu de se taire**. Mesuré sur la V2C le
2026-08-30 : `currentPower` 4 396 → 0 et les booléens à `false` à la perte du lien — mais
`sessionEnergy` **resté à sa dernière valeur**. **La politique dépend du plugin, ce qui la rend
inutilisable comme garde.**
**Et il n'existe aucun signal de joignabilité sur lequel s'appuyer** : `setupComplete` parle du
setup et non du lien ; `connected` est maintenu par le plugin — la V2C bascule après trois échecs,
un compteur Modbus peut avoir un autre seuil, un compteur générique peut ne jamais basculer.
**La garde porte donc sur ce que le moteur sait sans parier : la SÉMANTIQUE du compteur.**
| Source | Un recul signifie | Geste |
|---|---|---|
| **session** (`sessionEnergy`) | une nouvelle session s'est ouverte | recapturer la base |
| **vie** (`totalEnergyConsumed`) | la valeur n'est pas fiable | **geler**, ne jamais recapturer |
> **Ce que cela évite, chiffré.** Base reprise à 0 pendant l'absence, retour du compteur avec ses
> 10 kWh réels, et `deliveredWh` saute à 10 000 en un cycle : **l'obligation se déclare tenue sans
> que rien n'ait été livré**. Et `R6` ne s'y oppose pas — elle n'interdit `ECO_FLOOR_MET` que sous
> `unmeasurable`. **Le chemin mesuré est le seul par lequel un faux « tenue » peut sortir**, et
> c'est précisément celui qu'ouvre l'alimentation de `sessionWh` depuis un compteur.
**LM-1013-b — une obligation en ÉNERGIE devient une cible en WATTS par étalement uniforme.**
*(§10 étape D, tranché le 2026-08-31.)*
`cadence = reste / temps restant`, et rien de plus savant. « Au plus tard » serait optimal mais
suppose une **prévision** de production, qui n'existe pas dans ce modèle. Même raison qui a fait
de la cible de confort un comparateur plutôt qu'un dosage (`LM-1008`) : ce qui manque n'est pas
la formule, c'est le modèle qui la rendrait honnête.
**LM-1013-b-i — 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**. Les deux termes du rapport bougent.
**LM-1013-b-ii — le durcissement est mécanique, et le motif doit le DIRE.** Relevé : 2 kWh
restants font **333 W à six heures** de l'échéance, **2 kW à une heure**, et **le plafond dès
seize minutes**. La fenêtre où l'achat devient massif n'arrive pas progressivement, elle arrive
**d'un coup**. Le motif publie donc la cadence ET le temps restant — sans eux, un client voit sa
facture grimper à six heures du matin sans cause lisible et cherche une panne.
**LM-1013-b-iii — un POINT DE NON-RETOUR nommé.** Sous un seuil de temps restant, on cesse
d'étaler et on prend le maximum. Sans lui la cadence tend vers l'infini sans que rien ne décide :
**un seuil se discute, une asymptote se subit.**
**LM-1013-b-iv — l'heure quotidienne vise la PROCHAINE occurrence.** Lue à 23:00, une limite de
07:00 vise le lendemain. La lire comme dépassée ferait franchir le point de non-retour toutes les
nuits, et acheter à pleine puissance chaque soir sans qu'aucune échéance ne l'exige.
**LM-1013-b-vi — un MINIMUM est une puissance, l'obligation une énergie ; le minimum ne se tient
jamais.** *(« Surplus + minimum réseau », tranché le 2026-09-07.)*
Une obligation en énergie **suppose une échéance** : c'est elle qui donne son dénominateur à la
cadence. **Une borne n'en a aucune** — on ne sait pas combien de temps la voiture reste branchée.
« 6 A tant qu'elle est là » est la seule formulation qui ne suppose rien. Un chauffe-eau, lui, a
une échéance naturelle et une capacité finie : l'énergie y est la bonne grandeur.
`computeEcoTarget()` rend donc **`max(cadence de l'obligation, minimum)`**, et quatre invariants
en découlent :
1. **Le minimum ne se « tient » JAMAIS.** `tenue` ne décrit que l'obligation. Quand celle-ci est
tenue, la cadence vaut 0 et la consigne **retombe sur le minimum, pas sur zéro** — c'est là,
et seulement là, que la différence de nature se voit dans le code.
2. **`progress` ne décrit que l'obligation**, et il est **absent** sur une charge à minimum seul.
3. **Le motif dit lequel des deux mord** (`boundBy`), et nomme le minimum (`minAlwaysW`). Les
deux produisent le même nombre sans dire qui a décidé.
4. **Un minimum non servi se dit** — rang, plafond de soutirage ou réserve batterie peuvent
l'empêcher, et `DRAW_CAP` le nomme. Le champ **déclare une demande**, il ne promet aucune
livraison : les mots « garanti » et « prioritaire » sont bannis à son sujet.
**Pour une borne, la valeur se DÉRIVE** (`maxChargingCurrent.minValue × phaseCount × 230 V`) à
chaque cycle, jamais stockée : une copie écrite à l'appairage se périmerait au premier recâblage.
**LM-1013-b-v — sous `unmeasurable`, la loi est un TAUX CONSTANT, pas l'étalement.**
*(Tranché le 2026-09-01 ; écrit et livré le 2026-09-04.)*
L'étalement `reste / temps restant` suppose que le **numérateur bouge**. Sous ce régime il ne
bouge pas : `remainingWh` est figé à la cible entière, par contrat — on ne sait pas ce qui a été
livré, et le rebaser serait affirmer sur ce qu'on ignore (`LM-1009` C1).
**Un rapport dont seul le dénominateur décroît ne suit plus un retard : il suit l'horloge.** On
achèterait davantage *parce que le temps passe*, jamais parce que quelque chose est en retard —
et l'escalade n'aurait aucun fait derrière elle.
**Chiffré.** Obligation 4 kWh, plafond 7 kW, fenêtre d'un jour : l'ancienne loi achète
**19 010 Wh**, soit **×4,75** l'obligation. Le facteur suit `ln(T·P/E) + 1` — il **grandit avec
la fenêtre**, ce qui est le pire sens possible : plus on laisse de temps pour bien faire, plus on
paie. *(Le ×2,5 retenu au moment de trancher valait pour une fenêtre bien plus courte ; sur la
fenêtre quotidienne, qui est celle du modèle, c'est ×4,75. La conclusion ne change pas — le
chiffre, si.)*
Une charge qu'on mesure mal coûterait ainsi structurellement plus cher qu'une charge qu'on
mesure : l'incitation exactement à l'envers.
La loi : **cible ÷ fenêtre de l'obligation, constante**, et `atMaximum` **jamais vrai**. La
fenêtre est le **jour**, parce que l'obligation est déclarée par jour (`minEnergyWhPerDay`) — ce
n'est pas un réglage, c'est l'unité de la grandeur qu'on étale. Le total acheté sur la fenêtre
vaut alors **exactement la cible**, ni plus ni moins.
On garde « viser l'obligation entière », qui est le choix qui **sert** plutôt que celui qui
suppose acquis. On perd l'escalade, qui ne reposait sur rien.
> `LM-1013-b-i` (recalculé à chaque cycle), `LM-1013-b-ii` (durcissement) et `LM-1013-b-iii`
> (point de non-retour) ne valent donc QUE sous un régime mesuré. Les trois décrivent le
> comportement d'un rapport dont les deux termes bougent ; sous `unmeasurable`, un seul bouge, et
> ce qu'ils décrivent n'existe pas.
**LM-1013-c — la BASCULE DE PÉRIODE reprend la base d'avancement.** *(Trouvé en mesurant, et
invisible à la lecture du code.)* À l'instant de l'échéance quotidienne, une nouvelle période
d'obligation s'ouvre. Sans reprise de la base, **le lendemain hérite de l'énergie de la veille et
se croit tenu sans avoir rien reçu** — contre-épreuve : 1800 Wh portés au crédit du jour suivant.
> **Limite assumée** : l'obligation précédente, si elle n'était pas tenue, est abandonnée **sans
> motif dédié**. Le dire demanderait un `ECO_FLOOR_MISSED` que rien n'émet encore ; le journal le
> dit, l'écran non. À reprendre quand le catalogue s'ouvrira.
**LM-1011 — `BELOW_MIN_POWER` prime sur `DRAW_CAP` dès qu'il reste de l'autorisation.**
*(Précédence tranchée par Patrick le 2026-08-29, sur une question de l'agent app. Écrite ICI,
une fois, pour qu'aucun côté de la frontière ne l'établisse pour son compte.)*
Autorisation résiduelle **non nulle mais inférieure au plus petit palier** du mécanisme :
**`BELOW_MIN_POWER`**. `DRAW_CAP` reste réservé à l'autorisation résiduelle **nulle**.
Les deux motifs sont vrais, mais pas au même niveau. `DRAW_CAP` dit « **il n'y a plus
d'autorisation** » — or il en reste, simplement **pas au bon format**.
**Ce n'est pas une règle nouvelle : c'est celle de `+etm19`, transposée à une seconde
ressource.** Le surplus l'a déjà : `SURPLUS_INSUFFICIENT` dit « il n'y a rien »,
`BELOW_MIN_POWER` dit « il y en a, mais pas au bon format ». Le soutirage hérite du même couple,
et il aurait été absurde qu'une ressource se lise autrement que l'autre.
**Ce qui décide n'est pas la véracité, c'est le GESTE que le motif appelle** — règle 7-b : un
motif faux envoie diagnostiquer le mauvais problème, avec l'autorité d'une explication.
| Motif | Ce qu'il dit | Le geste utile |
|---|---|---|
| `DRAW_CAP` | l'autorisation est **épuisée** | revoir le plafond, ou attendre |
| `BELOW_MIN_POWER` | il en reste, **la charge ne sait pas la prendre** | lire la fiche technique du mécanisme |
> **Conséquence retenue de l'agent app** : le **point de rupture** de l'écran « ordre de service
> du cycle » est **ancré sur le `DRAW_CAP`**, donc sur l'épuisement réel. Une charge refusée
> **après** lui pour cause de palier porte `BELOW_MIN_POWER` et **ne déplace pas** ce point :
> elle est en aval de la coupure, elle n'est pas la coupure. Sans cette précédence, deux charges
> successives auraient produit deux points de rupture concurrents dans le même cycle.
**LM-1012 — On PUBLIE ce qui ne se dérive pas ; on DÉRIVE ce qui se dérive.** *(Ligne tracée par
l'agent app le 2026-08-30. Elle vaut bien au-delà du §10, et elle explique après coup plusieurs
refus qu'on avait pris un par un.)*
La légitimité d'une valeur **dérivée** ne dépend pas de son utilité, mais de **l'endroit où elle
vit** :
- **dérivée, locale, jamais transportée** → légitime, et **précisément parce qu'**elle est
dérivable : chaque lecteur la recalcule, elle ne peut pas mentir.
- **dérivée et PUBLIÉE** → illégitime, **pour la même raison** : elle devient une seconde source
de vérité pour un fait qui en a déjà une, et les deux divergeront.
C'est ce qui distingue un `"mixed"` calculé côté client — parfaitement légitime, il se relit à
chaque affichage — d'un `"mixed"` publié par le moteur, qui serait une valeur à maintenir en
accord avec une ventilation qui, elle, fait foi.
> **Elle couvre, après coup, trois refus déjà pris séparément** : l'**index d'ordre de service**
> (dérivable d'un tri sur `(niveau, priority)`), le **`"mixed"` de `funding`** (dérivable de
> `counts`), et le **`stagesW`** publié — qui, lui, est publié *parce que* la table de paliers
> n'est PAS dérivable côté client, la combinatoire vivant dans l'adaptateur.
>
> **Et son symétrique** : `rankOrigin` est publié parce qu'il **ne se dérive pas** — rien dans un
> rang ne dit qui l'a posé.
**LM-1013 — `grid` l'emporte dès qu'un seul watt est acheté.** *(Règle posée par l'agent app le
2026-08-30, retenue telle quelle : c'est une RÈGLE, pas une approximation, et la nuance décide.)*
Le financement d'un niveau vaut `"grid"` **dès qu'une part, si petite soit-elle, est achetée au
réseau** ; `"surplus"` seulement si la totalité vient du surplus.
**Pourquoi ce n'est pas « une approximation qu'on assume »** : tant qu'on la décrit ainsi,
quelqu'un voudra un jour la rendre *exacte* — part dominante, seuil de majorité. Sur un niveau à
900 W de surplus et 480 W de réseau, un tel libellé **basculerait d'un cycle à l'autre au gré des
proportions**, sans qu'aucune décision n'ait changé. La règle « un watt acheté suffit » est
**déterministe, indépendante des proportions**, alignée sur le fait actionnable — *de l'argent
a-t-il été dépensé ?* — et elle vaudra **sans décision nouvelle** pour le plancher éco du §10.
> **Et il n'y aura pas de troisième valeur `"mixed"`.** L'argument est celui de l'app, et il est
> décisif : **tout client qui teste `funding == "grid"` a une branche par défaut, et cette
> branche est « surplus »**. Une valeur inconnue y tomberait **sans erreur**, et rangerait au
> soleil un niveau dont une part est achetée — c'est la panne de l'omission, avec un champ
> présent en plus pour faire croire le contraire. Le client qui veut la nuance la **dérive** de
> `counts` (LM-1012).
**LM-1014 — Un client teste la PRÉSENCE d'un champ, jamais une version.** *(Règle posée par
l'agent app le 2026-08-30. Elle généralise LM-1007 et lui donne son mécanisme.)*
Ce qui rend une capacité détectable est **la charge utile**, jamais un numéro de version : un
champ présent décrit ce que la box **sait faire**, un numéro décrit ce qu'on **croit** qu'elle
sait faire. La première frontière est stable, la seconde ne l'est pas — une box se rétrograde, un
paquet se reconstruit, une branche se déploie hors séquence.
> **L'exemple qui a produit la règle** : l'agent app n'a pas *retiré* son exception
> `EV_GRID_START` quand le partage est devenu publié, il l'a **déplacée sur la bonne frontière**
> — elle survit là où la charge utile n'a pas de `counts`, c'est-à-dire sur les installations
> encore en `+etm2x`, où le partage est **réel mais non publié**. Une exception conditionnée à
> une version aurait été fausse au premier déploiement hors séquence.
**Conséquence pour tout ce qu'on ajoute** : chaque champ neuf doit être détectable par sa
présence, et **chaque absence doit porter un sens écrit**. Le contrat le tient déjà pour
`levels[]`, `measurement.source`, `draw`, `rankOrigin` et `decision.level` — et c'est pourquoi
aucun de ces champs n'est jamais publié « à zéro pour compléter » : un zéro forgé détruit
précisément l'information que l'absence portait.
**LM-1007 — Compatibilité descendante, et elle est gratuite.** Une charge qui ne déclare
**aucun** besoin éco ne participe qu'à la passe 2 — soit exactement le comportement
d'aujourd'hui. Le modèle à deux niveaux **dégrade proprement** vers le modèle actuel, ce qui
permet de l'implémenter sans migration de configuration.
---
## §11 — Compteur et sonde par charge
> **Statut : intention de conception.** Deux champs optionnels, de **même forme**, et dont
> l'arbitre ne se sert **pas pour décider** dans ce lot.
**LM-1100 — Deux champs, une même règle.**
```
LoadConfig {
…
o:meterThingId : ThingId — mesure de CETTE charge
o:sensorThingId : ThingId — grandeur physique du service rendu (température)
}
```
**Sélection par interface nymea, jamais par plugin.** Le compteur se choisit parmi les Things
portant `energymeter` / `smartmeter` ; la sonde parmi celles portant l'interface de température.
Nommer un plugin dans le modèle le rendrait faux au prochain plugin qui rend le même service —
et obligerait à maintenir une table à la main, ce que LM-600 refuse déjà pour les mécanismes.
> ⚠️ **`meterThingId` n'est plus la SEULE source de mesure — voir LM-1105.** Il l'a été, et cela
> obligeait l'installateur à désigner une borne comme son propre compteur pour qu'elle affiche
> ce qu'elle mesure déjà. Le champ garde tout son sens ; il cesse d'être une corvée de
> configuration là où le moteur peut lire l'appareil directement.
**LM-1101 — Le compteur sert à VÉRIFIER, jamais à décider.** Commandé 3 000 W, mesuré 0 W sur
trois cycles consécutifs : la charge ne fait pas ce qu'on lui demande, et c'est **la seule
chose** que la mesure autorise à conclure.
**Ne PAS injecter la mesure dans le budget.** Deux raisons, et chacune suffit :
1. **Elle est en retard sur la commande** — scrutation Modbus, moyennage interne du compteur.
L'injecter brute réintroduirait exactement l'oscillation que le recrédit de la correction B
a supprimée : commander, mesurer trop bas, recommander, dépasser, corriger.
2. **La charge est déjà comptée dans le compteur racine.** Le compteur de charge est une
**décomposition** de ce que le racine mesure, pas une source à additionner. L'ajouter
compterait la même énergie deux fois.
**LM-1102 — Un écart de mesure n'est pas un défaut d'écriture.** `WRITE_FAILED` dit « l'échelle
de commande est épuisée » ; un écart de mesure dit « la commande est partie, l'effet n'est pas
là ». Deux causes, deux gestes chez le client : l'une envoie au bus, l'autre au disjoncteur
divisionnaire ou à la résistance.
**L'écart NE DOIT PAS poser `available: false`.** Sortir une charge de l'arbitrage sur un doute
de **mesure** — alors que la commande, elle, est peut-être partie — coûterait le service sur un
soupçon. Il se publie comme **diagnostic**, à côté de `faultCode` et sans le remplacer.
> Le seuil et le nombre de cycles sont des **paramètres**, pas des constantes : un compteur
> lent et une charge à forte inertie produiraient sinon un diagnostic permanent. Trois cycles
> est un point de départ, pas une valeur figée.
> **Et l'écart ne CONCLUT PAS la même chose selon la source de la mesure** — LM-1106. Un écart
> calculé sur une valeur qui n'est pas une mesure ne conclut, lui, rien du tout.
**LM-1103 — La sonde est en lecture seule, et son vrai usage est différé.** Publiée en
télémétrie, **aucun effet** sur les décisions. Deux usages, dont le second est le plus
important :
1. **Diagnostic de mise en service** — l'installateur voit la température monter pendant qu'il
commande, ou ne pas monter.
2. **Constituer l'historique** — température, puissance, heure. **Sans lui, les seuils éco et
confort du §10 ne pourront jamais être réglés autrement qu'au doigt mouillé.** Un ballon ne
révèle sa constante de temps qu'observé, et une PAC ne révèle sa courbe qu'à travers une
saison. C'est la raison pour laquelle ce champ mérite d'exister **avant** d'avoir un
consommateur : le jour où le §10 s'implémente, il faut déjà des données.
**LM-1104 — Ni le compteur ni la sonde ne sont exclusifs, contrairement aux relais.** LM-201
(« un canal de commande, une charge ») ne s'applique pas ici : ces Things ne **commandent**
rien, ils mesurent. Deux charges peuvent légitimement partager une sonde de pièce, et un
compteur de tableau divisionnaire peut couvrir plusieurs charges.
**Conséquence directe** : `validateSet()` ne doit **pas** inclure `meterThingId` ni
`sensorThingId` dans son contrôle d'unicité des Things. Les y ajouter par symétrie avec
`relays[]` refuserait des configurations correctes.
**LM-1105 — Le moteur lit l'appareil qu'il pilote. `meterThingId` cesse d'être la seule
source.** *(Constat d'usage de l'agent app, 2026-08-28 ; proposition retenue.)*
Pour qu'une charge publie ce qu'elle consomme, il fallait la désigner **comme son propre
compteur** dans `meterThingId`. Ce n'est devinable par personne : un installateur voit un bloc
« compteur dédié » sur une borne qui publie déjà `currentPower`, et n'a aucune raison d'y
remettre la borne elle-même. **C'est de la configuration demandée pour compenser ce que le
moteur peut déduire** — et une configuration qu'on documente en note d'écran est le signe que le
défaut est dans le modèle, pas dans l'app.
**La règle** : quand le Thing piloté **porte lui-même l'état de puissance**, le moteur le lit,
sans configuration. `meterThingId` ne reste nécessaire que là où rien ne mesure.
> **La règle porte sur le THING, pas sur le mécanisme.** « Les bornes mesurent, les relais
> non » est vrai en pratique et faux en droit : certains `powerswitch` portent `currentPower`,
> et `RelayRouter` sait déjà les sommer. Écrire la règle par mécanisme y ajouterait la table à
> maintenir à la main que LM-600 refuse. Le test est une question posée à la **ThingClass**.
>
> **Et il se pose sur la CLASSE, jamais sur la valeur ni sur l'instant.** Un `relay-router` au
> palier 0 n'a aucun relais actif : interroger les relais *actifs* ferait osciller la réponse
> d'un cycle à l'autre, sur une installation dont rien n'a changé. La question est « ces Things
> portent-ils l'état ? », posée sur **tous** les Things déclarés de la charge.
>
> *Vérifié : jugée sur les relais actifs, la source publiée vaut `device` au palier 1 et `none`
> au palier 0 — un écran de mesure qui apparaît et disparaît au rythme du thermostat
> (`testMeasurementSourceIsPublished`, contre-épreuve).*
**LM-1105-a — Une mesure PARTIELLE n'est pas une mesure.** *(Affinement imposé par
l'implantation, 2026-08-28.)*
Là où plusieurs Things portent la charge — les relais d'un `relay-router` —, il faut qu'ils la
portent **tous**. « Au moins un » produirait une somme **sous-évaluée** présentée comme la mesure
de la charge : une topologie de trois relais dont un seul mesure annoncerait 500 W là où
1 500 W coulent, et l'écart commandé↔mesuré enverrait chercher une panne qui n'existe pas
(LM-1102). Une topologie mixte est donc `none` — et c'est un des cas où `meterThingId` garde
tout son sens.
> ⚠️ **À ne pas confondre avec `LoadTelemetry::currentPowerW`**, qui accepte « au moins un » et
> retombe sur le palier **nominal** sinon. Les deux ne répondent pas à la même question :
> celui-là est le **juge runtime du budget**, il doit rendre un nombre à chaque cycle parce que
> le recrédit anti-clignotement en dépend. Celui-ci ne vaut que ce qu'un appareil a réellement
> mesuré, ou rien. **Les fusionner rouvrirait la correction B.**
**LM-1105-b — La règle présuppose que le Thing piloté est TRAVERSÉ par la puissance.**
*(Trouvé en implantant, et c'est la limite exacte de LM-1105.)*
« Le moteur lit l'appareil qu'il pilote » n'est vrai que si l'appareil piloté est **dans le
chemin de puissance** de la charge. Un relais de `relay-router` commute la charge : sa puissance
**est** celle de la charge. Un contact **SG-Ready** est un contact de **SIGNAL** — la PAC est
alimentée ailleurs, son courant ne traverse jamais K1/K2, et un contact qui mesurerait quelque
chose mesurerait **sa propre bobine**.
**`sg-ready` est donc `none` par CONSTRUCTION**, et non par accident de matériel : même montée
sur des relais qui mesurent, la charge reste non mesurable par son organe de commande. C'est le
seul mécanisme pour lequel `meterThingId` demeure la **seule** voie vers une mesure — et cela
doit se lire à l'écran plutôt que se deviner.
**Le compteur explicite reste PRIORITAIRE, et ce n'est pas d'abord une question de précision.**
Un compteur divisionnaire posé exprès mesure mieux qu'un appareil qui se déclare — c'est vrai,
et ce n'est pas la raison principale. La raison principale est qu'**un réglage qui n'a pas
d'effet est pire que pas de réglage** : si le moteur préférait l'appareil au compteur que
l'installateur a pris la peine de désigner, le champ deviendrait décoratif, et le prochain à le
lire ne saurait pas s'il sert.
> **La priorité porte sur la SOURCE, pas sur la valeur.** `meterThingId` renseigné mais Thing
> absent, ou Thing sans l'état : on retombe sur l'appareil, et **ce qui est publié dit lequel a
> servi** (LM-1106). Ne rien publier au motif que le compteur déclaré est injoignable
> perdrait une mesure qu'on a — et le silence se lirait « charge non mesurable », ce qui serait
> faux.
**Ce que LM-1105 ne change PAS** : rien de ce que l'**arbitre** lit. LM-1101 tient mot pour
mot — la mesure vérifie, elle ne budgète pas — et le recrédit anti-clignotement continue de lire
`LoadContext::telemetry.currentPowerW`, qui vient de l'adaptateur et n'a jamais transité par
`meterThingId`. Ce lot déplace **ce qui est publié**, pas ce qui décide. En particulier, il ne
rouvre pas la correction B ni l'artefact du banc `.75` (`AGENTS.md`, PLAN 3C).
**LM-1106 — Ce qui est publié n'est pas un nombre : c'est un couple {valeur, source}.**
*(Second point soulevé par l'agent app, et c'est lui qui décide de la forme.)*
Le moteur porte **déjà** trois régimes sous un seul nom, et il jette la distinction :
| Adaptateur | Ce que vaut `telemetry().currentPowerW` | Régime |
|---|---|---|
| `evcharger` | `EvCharger::currentPower()` | **mesuré par l'appareil** |
| `etmvariableload` | l'état `currentPowerW` du Thing s'il existe ; sinon la consigne en vol ; sinon 0 | **mesuré**, ou **commandé** |
| `relay-router` | somme des relais qui portent `currentPower` ; **sinon le palier NOMINAL** | mesuré ou **nominal** — et le booléen `metered` est calculé, puis jeté |
| `sg-ready` | `estimatedPowerW[état]` | **déclaré**, jamais mesuré, délibérément (invariant 8) |
Publier ces quatre lignes sous le même `measuredW` serait faux pour deux d'entre elles. Et
publier un `measuredW` **automatiquement rempli** détruirait au passage la seule information que
le champ portait jusqu'ici : sa présence signifiait « un compteur a été posé exprès ».
**Donc le régime se PUBLIE, il ne se déduit pas d'une valeur.** C'est C2 de LM-1009, mot pour
mot, appliqué à la mesure — et pour la même raison qu'en LM-1009 : *un champ absent se voit ; un
champ qui s'efface se lit comme un zéro.*
```
o:measurement {
source : "meter" | "device" | "none",
o:powerW: double // absent si source == "none"
}
```
- **`meter`** — un Thing tiers déclaré par `meterThingId`.
- **`device`** — l'appareil piloté publie sa propre puissance.
- **`none`** — **rien ne mesure cette charge.** État à part entière, et positif : c'est C1 de
LM-1009 appliqué ici — une capacité manquante **se dit**. Sans lui, l'app ne peut pas
distinguer « pas mesurable » de « compteur en panne », et continue d'afficher un bloc de
mesure vide sur un `sg-ready` qui n'en aura jamais.
> **Et `none` ne se confond pas avec l'ABSENCE de la charge.** *(Distinction remontée par
> l'agent app, 2026-08-28.)* `none` est **publié**, donc il AFFIRME. L'absence n'affirme rien :
> elle dit que la charge n'a pas participé à l'arbitrage du cycle — mode manuel, aucun véhicule
> branché — ce qui est sans rapport avec sa mesurabilité. Une borne débranchée est absente de
> `loads[]` et publiera `device` dès qu'on y branchera une voiture ; lui annoncer « le compteur
> dédié est la seule voie » serait faux.
>
> C'est la même famille que LM-1009 D — *un champ absent se voit ; un champ qui s'efface se lit
> comme un zéro* — appliquée un cran plus haut : ici c'est l'ENTRÉE entière qui s'efface, et la
> lire comme un `none` transformerait un silence en affirmation. La garantie qui rend la règle
> utilisable : **`measurement` est présent pour toute charge de `loads[]`, et pour elle seule.**
> **Sous `none`, aucune valeur n'est publiée sous ce nom.** La puissance *nominale* existe et
> reste lisible — `mechanism.stageW`, `mechanism.state`, `mechanism.estimatedPowerW` la portent
> déjà, et disent d'où elle vient. La faire passer pour une mesure produirait un écart
> commande↔mesure **identiquement nul, à tous les cycles, pour toujours** : un « tout
> concorde » permanent, c'est-à-dire le pire des diagnostics. C'est le corollaire LM-104 —
> l'hypothèse de prudence devient un masquage de panne.
**Ce que la source change au DIAGNOSTIC** (LM-1102), et c'est ce qui la rend utile plutôt que
décorative :
| Source | Ce qu'un écart commandé↔mesuré autorise à conclure |
|---|---|
| `device` | **La charge n'obéit pas.** Commande et mesure sortent de la même boîte : l'écart parle de la charge et d'elle seule — véhicule qui refuse le courant, thermostat ouvert, plafond interne. |
| `meter` | **La charge, le compteur, ou le câblage.** Trois hypothèses, l'écart n'en tranche aucune seul. C'est le seul cas où « le compteur ne voit pas cette charge » est sur la table — le défaut ouvert du banc, compteur racine aveugle aux résistances ECS. |
| `none` | **Rien.** Aucun écart n'est calculable, et il ne faut surtout pas en fabriquer un. |
> **L'asymétrie va plus loin que « le compteur mesure mieux », et elle donne à LM-1104 sa
> raison d'être.** Sous `device`, une **concordance** ne prouve pas grand-chose : plusieurs
> wallbox renvoient leur consigne en guise de puissance, et commandé == mesuré n'y est qu'une
> tautologie. Sous `meter`, une concordance est une **preuve**. Autrement dit : `device`
> détecte la **désobéissance**, `meter` est le seul à pouvoir détecter un **mensonge**. C'est
> pourquoi `meterThingId` doit rester **autorisé** sur une charge qui publie déjà sa puissance
> — LM-1104 ne devient pas caduc, il gagne un usage.
**Conséquence pour l'app, et elle est de présentation, pas de modèle** : le champ « compteur
dédié » se **masque par défaut** là où `source` vaut `device`, et se propose en réglage avancé.
Il ne doit pas être *interdit* : ce serait fermer le cas ci-dessus, et casser le compteur de
tableau divisionnaire couvrant plusieurs charges (LM-1104).
> **La règle de présentation se lit sur `source`, JAMAIS sur `adapter`.** La tentation est
> d'écrire « masqué sauf pour `sg-ready` », puisque LM-1105-b fait de ce mécanisme une
> exception. Mais l'exception est déjà **dans la donnée** : un `sg-ready` publie `none`, donc la
> règle « masquer quand `source == "device"` » ne le masque pas — sans qu'aucun client ait à
> connaître le mécanisme.
>
> Et brancher sur `adapter` se **trompe** sur un cas qui n'a rien de théorique : un
> `relay-router` à topologie **mixte** publie lui aussi `none` (LM-1105-a). Un client qui ne
> déciderait qu'au mécanisme masquerait le champ sur un ECS qui, précisément, ne peut être
> mesuré que par un compteur dédié. Un mécanisme est un mauvais indice de mesurabilité ; la
> source EST la mesurabilité.
>
> C'est LM-1106 pris par l'autre bout — le régime est publié **pour** qu'il ne soit pas déduit,
> et le déduire du mécanisme côté client remettrait dans l'app la déduction qu'on vient de
> retirer du moteur.
---
## §12 — Le plancher de modulation
> **Statut : intention de conception.** C'est le **préalable à l'étape 3g** — la
> transplantation du VE dans le waterfall.
**LM-1200 — Le modèle n'exprime aujourd'hui qu'une enveloppe proportionnelle.** Une charge
modulable est décrite par `maxPowerW`, et l'arbitre suppose qu'elle accepte n'importe quoi
entre 0 et ce plafond. **C'est faux pour la plupart du matériel réel** :
- une borne de recharge **ne charge pas sous 6 A** — sous ce seuil elle ne démarre pas, ou
démarre et s'arrête ;
- les **paliers SG-Ready sont un plancher déguisé** : l'état 3 consomme ce qu'il consomme, il
n'existe pas d'état « 3 à moitié » ;
- un routeur de relais a le même plancher implicite : son plus petit palier non nul.
**LM-1201 — Spécifier : rien, ou au moins X.** Une charge modulable porte un minimum non nul :
```
adapter = "etmvariableload" → { mode: "dynamic", maxPowerW, o:minPowerW }
```
Le domaine de la consigne cesse d'être `[0, maxPowerW]` pour devenir
**`{0} ∪ [minPowerW, maxPowerW]`** — un ensemble discontinu. C'est la seule façon de dire ce
que le matériel fait vraiment.
**LM-1202 — Le plancher existe pour les trois mécanismes ; il n'est déclaré que pour un.**
| Mécanisme | Plancher | Origine |
|---|---|---|
| `etmvariableload` | `minPowerW` | **déclaré** — rien ne permet de le deviner |
| `relay-router` | plus petit palier non nul | **dérivé** de `relays[]` |
| `sg-ready` | `estimatedPowerW` de l'état 3 | **dérivé** de la table d'états |
Ne pas déclarer ce qui se dérive : c'est la règle que le lot D vient d'appliquer aux paliers
(`stagesW`), et pour la même raison — deux sources d'une même vérité divergent.
**LM-1203 — Un budget inférieur au plancher ne doit pas être retenu en otage.** Règle
d'allocation :
```
si budget_disponible < plancher(charge) → allouer 0, et TRANSMETTRE le budget à la suivante
```
Sans cette règle, une charge de rang 1 au plancher élevé bloquerait tout le waterfall en aval
en gardant un budget qu'elle ne peut pas utiliser.
**LM-1204 — Pourquoi c'est le préalable à 3g, et pas un raffinement.**
Deux bornes, 2 kW de surplus, plancher de 1,38 kW chacune (6 A monophasé) :
- **répartition égale** : 1 kW à chacune → **les deux sont sous leur plancher, rien ne charge,
et les 2 kW partent au réseau** ;
- **waterfall** : 1,38 kW à la première, le reliquat de 0,62 kW à la seconde qui n'atteint pas
son plancher et reçoit donc 0 → **une voiture charge**.
Le proxy actuel, qui décide du VE **en amont** du waterfall, ne peut pas produire le second
comportement : il n'a pas de notion de reliquat à passer. **C'est le plancher qui rend la
transplantation nécessaire, pas l'inverse.**
**LM-1203-b — Sous la réserve de stockage, le budget est ANNULÉ, pas réduit.** *(Décision
Patrick, 2026-08-26 ; ne se déduit pas du code.)*
Quand le niveau de la batterie passe sous sa réserve, le waterfall ne rabote pas le budget :
il le met à **zéro**. Servir « un peu » cumulerait deux inconvénients sans produire le
moindre avantage — cela retarderait la recharge du stockage **et** ne démarrerait
probablement personne, puisque les planchers du §12 mordent d'abord. Un budget résiduel de
300 W devant un premier palier de 500 W ne sert personne ; il ne fait que priver la batterie.
> **La réserve n'annule QUE le surplus — pas une autorisation de SOUTIRAGE.** *(Décision
> Patrick, 2026-08-27, à l'ouverture du §10.)* La passe éco du §10 ne dépense pas de surplus :
> elle dépense une autorisation d'acheter au réseau. La réserve ne la touche donc pas. Deux
> raisons, et chacune suffit : **une obligation qui cède à la réserve cesse d'être une
> obligation** — un ballon qui n'a pas vu 50 °C ne les verrait jamais parce que la batterie de
> la maison est basse ; et la réserve garde tout 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.
**Corollaire, et c'est le piège** : une installation dont la réserve est réglée haut ne pilote
plus RIEN sous ce niveau — ni ECS, ni PAC, ni borne. Cela ne produit ni erreur ni panne, donc
rien ne le signale. D'où deux exigences qui accompagnent la règle :
1. le motif **`BATTERY_RESERVE`** publie le surplus **retenu**, le SOC et le seuil — la cause
se lit en une ligne au lieu de se chercher ;
2. le **défaut d'usine est une réserve, pas une cible**. `0,9` confondait les deux : une cible
est ce qu'on aimerait atteindre, une réserve ce sous quoi on ne descend pas. Abaissé à
`0,2` en 3g-1.
**LM-1204-b — Le scénario d'acceptation de 3g, à monter au banc.** *(Défini avec Patrick,
2026-08-26, quand le banc a reçu une seconde borne.)*
> **MOITIÉ ARBITRAGE FRANCHIE (3g-2)** — `testEvChargerRankDecidesWhoCharges` : deux bornes, un
> budget qui ne paie qu'un plancher, la seconde refusée avec `BELOW_MIN_POWER {budgetW: 620,
> minPowerW: 1380}`, et l'inversion du rang change la borne servie. **La moitié MATÉRIELLE reste
> ouverte** : elle exige deux bornes RÉELLES, et la Terra AC ne répond toujours pas
> (`docs/RECAP_3g2.md` §1). Ce qu'une wallbox simulée ne prouvera pas est écrit plus bas — et
> c'est précisément ce qui manque encore.
>
> **Il n'était PAS exerçable avant 3g**, et c'est le point : aujourd'hui `nonEvLoads` filtre
> `etmvariableload | relay-router | sg-ready`, l'EV est décidé **en proxy avant** le waterfall,
> les deux bornes ne partagent donc aucun budget avec le reste et **leur rang n'a aucun sens**.
> Ce scénario est le critère qui dira que 3g est fait.
1. **Budget insuffisant pour deux planchers.** Surplus ≈ 4 kW, deux bornes à ≈ 1,4 kW de
plancher (6 A monophasé). **Attendu** : la borne de rang supérieur servie à fond, la seconde
au reliquat — et **0 si le reliquat est sous son plancher**, jamais une part symbolique.
**Contre-exemple à ne surtout pas produire** : 2 kW à chacune, les deux sous leur plancher,
rien ne charge et le surplus part au réseau.
2. **Inversion de rang entre les deux bornes**, et la charge servie doit changer. C'est le
pendant EV de l'inversion ECS/SG-Ready déjà éprouvée.
**Le piège de simulateur, et comment le déjouer.** Une wallbox simulée acceptera peut-être une
consigne sous son plancher là où du matériel réel refuserait de démarrer. **Le plancher doit
être appliqué par l'ARBITRE, jamais constaté par le matériel** — sinon le test ne prouve que
l'obéissance du simulateur.
Le discriminant est le **motif publié**, et il est décisif parce que seul le scheduler peut
l'émettre :
| ce qu'on lit | ce que ça prouve |
|---|---|
| `BELOW_MIN_POWER {budgetW, minPowerW}` | **l'arbitre a refusé** — aucune commande n'est partie |
| `SURPLUS_SETPOINT` avec `setpointW < minPowerW` | l'arbitre a laissé passer : le plancher n'est **pas** appliqué |
| borne à 0 W sans motif de plancher | c'est le **matériel** qui a refusé — le test ne prouve rien |
**Ce qu'une wallbox simulée ne prouvera pas**, et qu'il ne faut pas déclarer vérifié : la
négociation de courant réelle, le délai d'établissement de la charge, et le comportement d'un
véhicule qui refuse.
> ⚠️ **À lire avec LM-1009 (§10)** : la machinerie existe, mais son AVANCEMENT ne se mesure pas.
> `targetPercentage` est comparé à un pourcentage que le moteur a lui-même estimé, et la seule
> grandeur réellement mesurée — `sessionEnergy` — n'est lue nulle part. Une échéance en énergie
> est vérifiable ; une échéance en pourcentage ne l'est pas.
**LM-1205 — La machinerie d'échéance existe déjà.** `needs { dailyDeadline, minEnergyWhPerDay }`
est ce qui avait motivé le proxy — la crainte que le waterfall ne sache pas exprimer « cette
voiture doit être chargée à 7 h ». Il sait : c'est le même champ que le plancher éco du §10
(LM-1001). Une fois le VE dans le waterfall, il hérite du modèle de besoin commun.
> **LM-1206 et LM-1207 sont IMPLÉMENTÉS (3g-2).** Le rang d'une borne est un champ de
> `LoadConfig`, créé d'office à la détection, modifiable à chaud par `SetLoadConfig`. Aucun champ
> de rang n'a été ajouté à `ChargingInfo`, et il n'y en aura pas. Forme exacte de l'entrée :
> `INTERFACE.md`, « le mécanisme `evcharger` ».
**LM-1206 — La priorité porte sur la BORNE, pas sur la voiture. Décision arrêtée.** Il n'existe
pas de communication véhicule aujourd'hui : rien ne permet de savoir *quelle* voiture est
branchée, ni de lui attribuer un rang. Le rang appartient donc au **point de charge**, comme il
appartient à un chauffe-eau.
`assignedCarId` existe déjà dans `ChargingInfo` : le jour où la communication véhicule arrive,
la migration du rang de la borne vers le véhicule reste possible **sans casser l'acquis**.
**LM-1208 — Une charge sortie de la DÉCISION n'est jamais sortie de la COMPTABILITÉ.**
*(Règle posée par l'agent app, 2026-08-28, sur un constat de banc. La règle est retenue ; le
mécanisme qui l'accompagnait était inexact — voir l'encadré.)*
Quel que soit le motif d'exclusion — mode manuel, véhicule inconnu, borne débranchée — **la
consommation réelle de la charge reste dans le bilan du budget**. Une charge qu'on cesse de
piloter ne cesse pas de consommer.
> **Cette règle est DÉJÀ tenue, et par construction** : le budget part de la mesure **brute** du
> compteur (invariant 8 d'`AGENTS.md`), donc la consommation d'une charge exclue y est déjà
> déduite — sans que personne ait à la réserver. Vérifié au banc le 2026-08-28 : la V2C tirant
> 3,9 kW hors arbitrage, le compteur est passé de −2 915 W (export) à +3 531 W (import) et
> l'arbitre a publié « Surplus insuffisant (−3 531 W) — chauffe-eau à 0 W ». **L'ECS n'a pas été
> servi par-dessus.** L'exclusion est conservatrice : la charge exclue retire du budget aux
> autres, et personne ne le lui rend.
>
> **La règle garde pourtant un porteur, étroit et réel** : une charge exclue du contexte mais
> commandée par le **proxy** ne reçoit aucun `evReservedW` — sa puissance commandée et pas
> encore mesurée n'est réservée par personne (correction A ne couvre que les actions du
> scheduler). Le trou est transitoire — un cycle, jusqu'à ce que le compteur rattrape — et il ne
> mord pas aujourd'hui, le mode manuel faisant sauter la borne aussi côté proxy. **Il mordra au
> §10**, où la passe éco décide d'acheter : acheter en croyant disposer d'une puissance déjà
> engagée est exactement le défaut que la règle nomme.
**LM-1208-b — « Pas de voiture assignée » cesse d'être un critère d'exclusion.** *(Décision
demandée, argument de l'agent app retenu.)*
Trois raisons, et la troisième est décisive :
1. **Le cas de l'invité le rend intenable.** Une voiture qui n'est pas celle du propriétaire ne
sera jamais déclarée, et la borne cesserait d'être arbitrée **précisément quand elle
consomme**.
2. **Le critère n'est pas vérifiable.** L'assignation est déclarée à la main ; rien ne dit ce qui
est réellement branché. Un critère d'arbitrage qui repose sur une déclaration invérifiable
n'arbitre pas, il devine.
3. **Il CONTREDIT LM-1206.** Le rang et le plancher appartiennent à la **borne**, pas au
véhicule. Exiger un véhicule connu pour arbitrer la borne réintroduit exactement la
dépendance que LM-1206 a écartée.
**Ce qu'on perd sans voiture assignée est une FONCTION, pas l'arbitrage** : l'échéance et la
cible d'énergie deviennent indisponibles — dégradation, pas exclusion. C'est la même distinction
que C1 de LM-1009 : une capacité manquante se dit, elle ne fait pas disparaître la charge.
> **Ce qui RESTE un critère d'exclusion, et pourquoi** :
> — **mode manuel** (`ChargingModeNormal`) : c'est une intention explicite de l'utilisateur —
> il pilote, l'arbitre s'abstient. Vérifiable, et voulu.
> — **aucun véhicule branché** (`pluggedIn`) : la borne ne peut rien consommer de ce qu'on lui
> allouerait (mesuré, `docs/RELEVE_3g2.md` §3.1).
>
> ⚠️ **Conséquence d'exploitation à connaître** : le mode par défaut d'une borne fraîchement
> détectée est `ChargingModeNormal`. **Une borne neuve n'est donc jamais arbitrée tant que
> personne n'a choisi un mode éco** — ce qui est correct par construction, mais doit être visible
> à la mise en service, sans quoi « la borne ne charge pas toute seule » se lira comme un défaut.
**LM-1208-c — Sans voiture, le nombre de phases vient de la BORNE.** *(Conséquence de
LM-1208-b trouvée à la mise en œuvre, 2026-08-28. Elle n'est pas un détail d'implantation : elle
décide du budget.)*
Retirer le critère ne suffit pas, et ce qui manque ne se voit pas. Le proxy amont **ne prépare
pas** une borne sans voiture — `prepareInformation()` la filtre en entrée, et c'est correct :
sans véhicule il n'a ni SoC, ni capacité, ni échéance à planifier. La borne entre donc dans le
waterfall **sans `ChargingProcessInfo`**, c'est-à-dire sans la règle de bascule 1↔3 phases que
LM-1206 avait pris soin de ne pas redériver.
Or l'accesseur rend un objet **par défaut**, pas une absence — et ses valeurs par défaut sont
*plausibles* : une phase, pas de bascule. Une borne triphasée serait donc annoncée monophasée,
et **le budget divisé par trois** : plafond à 3 680 W au lieu de 11 040, courant commandé à 16 A
au lieu de 9, et 11 kW tirés d'une enveloppe qui en comptait 3,7. C'est le défaut de 3g-1
(« viser 3 phases sur une combinaison qui n'en permet qu'une ») pris exactement à l'envers.
**La règle** : sans `ChargingProcessInfo`, on **ne bascule pas** — basculer suppose de savoir ce
que la voiture accepte — et on retient `phaseCount` de la **borne**, qui est ce qu'elle utilisera
réellement. C'est l'hypothèse conservatrice au sens de LM-104 : elle **surestime** la
consommation de la borne plutôt que de la sous-estimer.
> **Ce que ce cas apprend au-delà des bornes** : un accesseur qui rend un défaut là où il
> faudrait dire « absent » est un masquage de panne (corollaire LM-104), et il est d'autant plus
> dangereux que le défaut est crédible. La question « cette borne a-t-elle été préparée ? » doit
> être POSABLE, pas déduite d'une valeur.
**LM-1209 — L'auto-provisionnement REMPLIT UN TROU, il ne décide pas.** *(Décision Patrick,
2026-08-28. La règle est générale : elle porte sur toute création d'entrée par le moteur, pas
sur les seules bornes.)*
Une charge détectée par le moteur reçoit une entrée de configuration parce que **sans entrée
elle serait arbitrée sans rang réglable** — c'est le trou que l'auto-provisionnement referme. Il
ne s'ensuit rien sur sa PLACE : **le rang est un choix de l'installateur**, et une détection ne
réordonne pas ce qu'un humain a décidé.
**Donc : insertion en QUEUE, jamais en tête.** Une borne détectée un mois après la mise en
service passerait sinon devant l'ECS que quelqu'un a réglé exprès — l'ancienneté d'un réglage
n'est pas une raison de le défaire, et la nouveauté d'une détection n'est pas une raison de la
servir en premier.
> **Cela ne contredit PAS l'ordre par défaut « le VE d'abord »**, et c'est la distinction qui
> manquait à la question : ce défaut-là décrit une **mise en service initiale**, où personne n'a
> encore rien classé. Un **ajout ultérieur** est une situation différente — il existe un
> classement, et il fait autorité. Deux moments, deux règles, et les confondre revient à laisser
> le matériel réordonner l'installation.
**Ce que « en queue » veut dire exactement : `1 + max(rangs existants)`.** Formulé d'abord comme
« en queue de son DOMAINE », il faut le préciser, parce que les domaines ne sont pas des blocs
contigus : rien n'empêche `ev` d'occuper les rangs 2 et 3 pendant qu'`ecs` tient 1 et 4. Insérer
« juste après le dernier `ev` » exigerait alors de pousser l'ECS du rang 4 — ce que LM-1209-b
interdit.
> **Et la queue de LISTE est ici plus fidèle au principe que la queue de domaine**, ce qui règle
> la tension au lieu de l'arbitrer : `max(domaine) ≤ max(liste)` toujours, donc la queue de liste
> est **toujours au moins aussi reculée**. Quand le domaine occupe déjà la fin de la liste — le
> cas ordinaire d'un domaine qui ne grandit que par détection — les deux coïncident. Quand ils
> divergent, la queue de liste place la nouvelle charge **derrière tout le monde** au lieu de
> l'intercaler devant une charge classée : c'est exactement « remplir un trou sans décider ».
**LM-1209-a — L'égalité de rang doit être IMPOSSIBLE, et le contrat doit la garantir plutôt que
la supposer.**
`1 + max` produit un rang libre par construction, y compris sur une configuration héritée qui
contiendrait déjà des doublons. Mais la garantie ne peut pas reposer sur le seul chemin
d'insertion : une écriture cliente doit être **refusée** si elle pose deux charges au même rang.
> ⚠️ **Ce que l'égalité coûte n'est PAS l'indéterminisme** — cette lecture est périmée depuis
> 3g-2, et s'y fier ferait chercher le défaut au mauvais endroit. Le waterfall trie sur le
> couple **`(rang, identifiant)`** (`rulebasedscheduler.cpp`), un ordre **total et reproductible**
> ajouté précisément parce que deux bornes à égalité rendaient irreproductible laquelle
> démarrait. Trois charges au rang 1 sont donc servies dans un ordre **stable** — mais **arbitraire
> et non réglable** : il obéit à des identifiants, c'est-à-dire à personne. C'est cela que
> l'unicité corrige, et c'est une exigence de gouvernabilité, pas de déterminisme.
**LM-1209-a-bis — Une vérification qui ÉCARTE et une vérification qui REFUSE ne peuvent pas
être la même fonction.** *(Règle générale, posée le 2026-08-28 sur un piège trouvé en
implantant LM-1209-a. Elle vaut au-delà des rangs.)*
Le moteur porte deux familles de contrôles, et leur seul geste correct est **opposé** :
| Famille | Ce qu'elle nomme | Geste correct | Où |
|---|---|---|---|
| **Sûreté d'ensemble** | l'ensemble est **dangereux** — deux charges commandent le même organe, deux commandes contradictoires | **ÉCARTER** l'entrée fautive et continuer | chargement **et** écriture |
| **Bonne forme** | l'ensemble est **mal formé** — deux charges au même rang | **REFUSER** l'écriture entière | écriture **seulement** |
**Les fusionner convertit une règle de contrat en perte de configuration silencieuse.**
`LoadConfigStore::load()` applique la vérification d'ensemble en écartant les entrées fautives —
« tolérance ascendante », et c'est le bon geste pour un conflit d'organe : une charge absente ne
commande rien, deux adaptateurs sur un relais se contrediraient. Y ajouter l'unicité de rang
ferait qu'une configuration héritée à rangs doublés **perdrait des charges au démarrage** :
disparues de la configuration *et* de l'arbitrage, **sans `applySafeState`** — donc laissées dans
leur dernier état commandé, exactement le trou d'ECS-413 rouvert par une autre porte. Et sur la
foi d'une règle resserrée *après* que la configuration a été écrite.
**Le comportement de MIGRATION qui en découle** : au chargement, un doublon hérité est **signalé
bruyamment et CONSERVÉ** ; la première écriture cliente le répare. Rien n'est perdu entre-temps,
et l'installation continue de fonctionner dans l'ordre stable — quoique non réglable — qu'elle
avait déjà.
> **Le test à écrire est celui de la moitié qui ne se voit pas.** Qu'une écriture mal formée soit
> refusée se remarque tout de suite ; qu'un chargement conserve ce qu'il aurait pu écarter ne se
> remarque jamais — sauf le jour où une box perd trois charges au redémarrage.
**LM-1209-b — Insérer ne renumérote personne.** Aucun autre rang ne bouge. C'est le pendant
exact de la suppression côté app, qui laisse les rangs en place : **un geste de configuration ne
doit pas devenir un geste d'arbitrage.** Renuméroter pour « faire de la place » modifierait
l'ordre de service de charges auxquelles personne n'a touché — et le ferait sans qu'aucune
décision d'exploitation ait été prise.
**LM-1209-e — Le rattrapage est CYCLIQUE : la condition est « cette charge n'a pas d'entrée »,
jamais « l'adaptateur vient de naître ».** *(Défaut trouvé sur `.75` le 2026-08-29, corrigé le
même jour.)*
Le provisionnement vivait dans la branche « l'adaptateur vient d'être créé ». Or **l'adaptateur
est construit depuis le Thing** : il survit à la suppression de l'entrée de configuration. Une
entrée supprimée depuis l'app n'était donc **jamais recréée** — mesuré, rien en 140 s.
**Ce que cela produisait est le trou de 3g-2, rouvert par le côté client** : la charge restait
**arbitrée** mais **absente de `GetLoadConfig`**, à un rang de queue non réglable. Elle
consommait du budget sans que personne puisse la classer, et rien ne le disait. Le seul
rattrapage était un redémarrage du service.
> **La leçon dépasse ce défaut** : un rattrapage dont la condition est un **événement**
> (« à la naissance de… ») ne rattrape rien, puisqu'il ne se rejoue pas. Une condition d'**état**
> (« cette charge n'a pas d'entrée ») se réévalue, donc elle répare. C'est la même distinction
> qu'entre ECS-411 — relire l'état réel des relais — et l'hypothèse de départ qu'il a remplacée.
**Une entrée recréée revient en `rankOrigin: "auto"`**, et c'est juste : personne ne l'a classée,
son rang est de nouveau un défaut. Le rang que l'installateur avait posé est **perdu avec
l'entrée** — c'est le prix de la suppression, et c'est précisément pourquoi la marque doit se
voir : la charge réapparaît **en queue, avec son badge**, plutôt que de revenir en silence à une
place que personne n'a choisie.
> ⚠️ **Corollaire d'exploitation** : supprimer l'entrée d'une charge dont le Thing vit n'est
> **pas** un moyen de la retirer de l'arbitrage — elle revient au cycle suivant, déclassée.
> `enabled: false` est la voie, et lui conserve son rang.
>
> **Et le REFUS de provisionnement ne s'annonce qu'une fois par charge.** Le rattrapage étant
> cyclique, répéter l'avertissement en produirait un par minute et pour toujours — la règle est
> déjà posée pour les charges sans décision (`buildTelemetry()`) : avertir à chaque cycle noie
> le vrai signal. Le drapeau tombe au premier succès, donc un défaut qui se résout se redit s'il
> revient.
**LM-1209-d — Le rang est un ordre TOTAL ; les domaines ne le partitionnent pas.** *(Point
ouvert soulevé par l'agent app, tranché le 2026-08-29.)*
L'insertion en queue entrelace les domaines — `ecs, ev, ev, heating, ev`. Faut-il que
l'insertion respecte la **contiguïté** d'un domaine, ou que le regroupement automatique côté
client cesse d'être automatique ? **La seconde.**
**La contiguïté n'a jamais été un invariant, et rien dans le moteur n'en a besoin** : le
waterfall trie sur le **rang seul**, et `domain` est une métadonnée d'intention que l'arbitre ne
lit pas (LM-1101 · §3). La contiguïté est une convention d'**affichage**, inventée par le client.
**Et l'exiger du moteur serait CONTRADICTOIRE avec la raison même de LM-1209**, pas seulement
avec sa condition « aucun autre rang ne bouge ». Insérer une charge `heating` en respectant la
contiguïté obligerait à décaler toutes les charges `ev` situées après — c'est-à-dire **une
détection qui réordonne une installation qu'un humain a classée**, exactement ce que LM-1209
interdit. On ne peut pas invoquer LM-1209 pour la queue et l'enfreindre pour le regroupement.
> **Le même raisonnement s'applique au CLIENT, et c'est le vrai point.** Un regroupement par
> domaine appliqué **à l'enregistrement** est aussi une machine qui réordonne ce qu'un humain a
> décidé — au pire moment, et sans que personne l'ait demandé. Il défait l'entrelacement
> délibéré (« l'ECS après la borne 1 mais avant la borne 2 » est un arbitrage légitime, et le
> §10 en produira d'autres), et il remonterait au rang 3 une borne que le moteur vient de ranger
> en queue.
>
> **Grouper à l'AFFICHAGE : oui. Grouper à l'ÉCRITURE : non.** Si le regroupement a de la
> valeur, il doit être un **geste explicite** de l'installateur — visible, réversible —, jamais
> une normalisation au moment de sauver.
>
> **Conséquence sur la note d'entrelacement** : elle ne doit pas s'énoncer comme un
> avertissement. Une liste entrelacée n'a rien d'incohérent ; l'annoncer comme un défaut pousse
> à « réparer » un ordre que personne n'a cassé. Au plus une **proposition**.
**LM-1209-c — Une entrée créée d'office DOIT se dire.** Une charge qui prend une place dans
l'ordre de service sans que rien ne le signale est un **changement d'arbitrage silencieux**. Le
journal l'annonce déjà ; cela ne suffit pas — l'app ne lit pas le journal, et c'est elle qui
montre le classement. La charge utile doit porter de quoi distinguer **« ce rang est un défaut »**
de **« ce rang est un choix »**.
C'est la même exigence que C2 de LM-1009 et que LM-1106 : **le régime se publie, il ne se déduit
pas.** Un client ne peut pas deviner qu'un rang n'a jamais été choisi — rien dans le nombre ne le
dit.
**La forme retenue : `o:rankOrigin`, énumération fermée `"auto" | "user"`, absente par défaut.**
- **`"auto"`** — l'entrée a été créée d'office et **son rang n'a jamais été choisi**. Seul le
moteur l'écrit.
- **`"user"`** — le rang est **assumé**. C'est la voie par laquelle un client lève la marque
**sans déplacer la charge** : « j'ai vu ce défaut et il me va ».
- **absente — rien n'est affirmé.** Ni « choisi », ni « par défaut ».
> **Une énumération et non un booléen, et la raison est le troisième état.** `rankIsDefault:
> false` *affirmerait* que le rang a été choisi — or c'est précisément ce qu'on ignore d'une
> configuration héritée. **L'absence doit porter le « on ne sait pas »**, et un booléen ne sait
> pas se taire. C'est le même motif de conception que `measurement.source` (§11), et il mérite
> d'être nommé comme tel plutôt que redécidé au cas par cas : **publier le régime, et ne jamais
> laisser un silence passer pour une affirmation.**
**Trois règles de transition, et la deuxième est celle qu'on se serait trompé à écrire** :
1. **Rang CHANGÉ pour cet id → la marque est effacée.** Quelqu'un a classé la charge. Effacée
vers le **vide**, pas vers `"user"` : le moteur constate un déplacement, il ne sait pas si un
humain ou un script l'a voulu — ne rien affirmer est le minimum honnête.
2. **Rang INCHANGÉ → la marque est CONSERVÉE, même si le client l'omet.** `SetLoadConfig`
remplace en bloc : réordonner une **autre** charge renvoie forcément l'entrée de celle-ci, et
l'effacer à cette occasion perdrait une marque encore vraie — la charge n'a toujours été
classée par personne. **L'effacement porte sur le RANG, jamais sur la réception d'une
écriture.**
3. **`"auto"` reçu là où le persisté ne l'a pas → refus.** Un client ne fabrique pas une
affirmation sur un geste du moteur. L'aller-retour **verbatim** d'une entrée qui la porte déjà
reste licite — sans cette tolérance, la neutralité `Get` → `Set` serait rompue et tout client
renvoyant ce qu'il a lu se verrait refusé.
**LM-1207 — Ne PAS ajouter de `priority` à `ChargingInfo`.** Ce serait un **second système de
priorité**, et il serait immédiatement insuffisant : il ne saurait pas exprimer
**« VE1 > ECS > VE2 »**, puisqu'il ne classerait que les bornes entre elles. Deux classements
séparés qui ne peuvent pas s'interclasser ne sont pas deux fonctionnalités, c'est un défaut.
Le rang du VE doit vivre **là où vivent tous les rangs** : dans `LoadConfig.priority`. C'est
une conséquence directe de 3g, et une raison de plus de ne pas le différer.
---
## §13 — Ordre d'implémentation recommandé
**Ce n'est pas l'ordre dans lequel les trois manques ont été énoncés**, et la raison tient à ce
que chacun coûte et rapporte.
**1. §11 — compteur et sonde.** *Le moins cher, et il conditionne le reste.*
Purement **additif** : deux champs optionnels, aucune décision d'arbitrage modifiée, aucune
migration de configuration. Le risque de régression est proche de zéro.
Mais surtout : **la sonde constitue l'historique sans lequel les seuils du §10 ne pourront pas
être réglés.** Un ballon révèle sa constante de temps en quelques jours, une PAC en une saison.
Chaque semaine où ce champ n'existe pas est une semaine de données qu'on n'aura jamais.
Le différer ne coûte pas du temps de développement — il coûte du **temps calendaire**.
**2. §12 — le plancher.** *Contenu, et il débloque 3g.*
Le plancher est **dérivable** pour deux mécanismes sur trois : seul `etmvariableload` demande un
champ. Le vrai travail est la règle d'allocation LM-1203 (« sous le plancher → 0, et on
transmet »), qui est locale au waterfall et se teste sans matériel.
Il débloque 3g, qui débloque à son tour l'unification du rang (LM-1207). Trois choses attendent
derrière une seule.
**3. §10 — éco / confort.** *Le plus gros, et le seul qui franchisse une frontière.*
À faire en dernier, pour trois raisons :
- il **change le waterfall lui-même**, alors que les deux précédents l'entourent ;
- il fait de l'arbitre un **acheteur**, ce qui ouvre le prix, l'heure et le signal tarifaire ;
- **il est indissociable du délestage général** (LM-1006-1, désormais **défini** — et il est à
écrire comme un plafond de soutirage à trois sources, pas comme la protection de surcharge),
qui n'existe pas encore : la
protection de surcharge ne déleste que les bornes. Livrer les planchers éco sans lui, c'est
livrer un mécanisme capable de faire disjoncter une installation un matin sans soleil.
> **PRÉCONDITION DE LIVRAISON — pas une recommandation.** Le §10 **ne part pas** sans
> l'extension du délestage au waterfall. Même lot, ou avant. **Jamais après.**
>
> Aujourd'hui la protection de surcharge ne déleste **que les bornes de recharge**
> (`m_evChargers` dans `SmartChargingManager::verifyOverloadProtection`). Des planchers éco qui
> s'additionnent un matin sans soleil sur un branchement 6 kVA feraient **disjoncter
> l'installation** — pas dégrader le confort : couper le courant, chez un client.
>
> Écrit comme condition et non comme conseil parce que c'est exactement le genre de dette
> qu'on ne rembourse jamais : elle ne se manifeste qu'aux conditions extrêmes, donc « ça marche
> chez tout le monde » jusqu'au jour où ça ne marche chez personne. *(Décision Patrick,
> 2026-08-26.)*
**Ce qui peut se faire en parallèle, sans rien attendre** : la publication en télémétrie de la
sonde et du diagnostic de mesure (§11), puisqu'elle n'a aucun effet sur les décisions.
---
---
## §14 — Le stockage est-il une charge arbitrable ?
> **Statut : réponse de conception. Ne déclenche aucun travail** — elle dit sous quelle forme
> l'étape **3f** (déjà déférée : *« BatteryAdapter — constraints + charge réseau plafonnée +
> waterfall grid-funding »*) devra se faire, et lève les quatre points que l'agent app a nommés.
**La demande, telle que Patrick l'a formulée** : *« si je veux que l'énergie aille d'abord dans
l'eau chaude, et seulement si l'eau est chaude je commence à charger la batterie, je fais
comment ? »*
**Réponse courte : aujourd'hui il ne peut pas, et la réponse est OUI — le stockage doit devenir
une charge du waterfall.** Ce n'est pas une porte à ouvrir : elle est déjà encadrée, et depuis le
début.
---
### LM-1400 — Les quatre constats sont exacts, vérifiés indépendamment
| Constat | Vérifié |
|---|---|
| `batteryLevelConsideration` donne la priorité à la BATTERIE, pas aux charges | oui — `rulebasedscheduler.cpp:125-139`, sous le seuil le budget est **annulé** |
| La batterie n'est dans aucun waterfall | oui — aucune entrée `evcharger`/`battery` dans `GetLoadConfig` ; quatre charges sur `.75` |
| Le moteur ne la commande nulle part | oui — `enableCharging`, `enableDischarging`, `chargingRate`, `dischargingRate`, `gridCharging` : **0 occurrence** dans `energyplugin/` |
| Le levier existe | oui — la classe `sunspecStorage` du banc déclare **les cinq actions**, et l'énumération `domain` accepte déjà `battery` sans que rien n'en fasse quoi que ce soit |
**Et le contrat l'attendait.** `OPTIMIZER_PROTOCOL.md` décrit déjà la batterie comme une charge de
`loads[]` (`"adapter":"battery"`), avec **deux kinds** dont un lui est réservé :
| kind | ce qu'il exprime | déjà déclaré |
|---|---|---|
| `setpoint` + `source: "grid"` | charger la batterie, éventuellement **depuis le réseau** | `LoadAction::Source { Solar, GridSource }` |
| `constraint` | `charge`/`discharge` : `allow` \| `forbid` | `LoadAction::Permission { Allow, Forbid }` |
`SurplusBattery` porte déjà `reserveSocPercent`, `maxChargeW`, `maxDischargeW` — trois champs
qu'aucun code ne lit aujourd'hui. **Le modèle a été conçu pour ça et n'a jamais été câblé.**
`domain: "battery"` accepté sans effet n'est donc pas une question effleurée : c'est le reliquat
visible d'une place réservée.
---
### LM-1401 — (a) Le double commandeur : la réponse est MESURABLE, pas à deviner
C'est la bonne question, et c'est la leçon de 3g-2 correctement appliquée : **avant d'ajouter un
commandeur, savoir qui commande déjà**. Mais ici, contrairement aux bornes, **le détecteur existe
déjà et il est publié**.
Le plugin SunSpec **écrit, relit, et compare** (`integrationpluginsunspec.cpp:703-726`), et son
commentaire nomme exactement le cas redouté :
> *« An inverter may accept a write and then ignore it without raising a Modbus exception —
> control not enabled on the device, grid charging not allowed, an IP restriction, or **another
> controller overwriting the setpoints**. An unverified setpoint is an unapplied setpoint. »*
Le résultat sort dans l'état **`controlHealthy`**, déjà déclaré par la classe du banc.
**Conséquence pour 3f, et elle est forte** : la question « l'onduleur obéit-il ? » ne se tranche
pas sur le papier — **elle s'observe**. Trois exigences en découlent :
1. **Mesurer avant de commander.** Une consigne d'essai, `controlHealthy` relu. Si l'onduleur
re-décide, il le dira. C'est exactement ce qui manquait aux bornes avant 3g-2, et qui a coûté
un lot entier.
2. **`controlHealthy == false` doit sortir de l'arbitrage**, comme `available == false` le fait
déjà pour une charge en défaut (ECS-410) : une batterie dont les consignes ne prennent pas
n'est pas pilotable, et lui allouer du budget serait le trou de `pluggedIn` à nouveau.
3. **Le motif doit le dire.** Une batterie non servie parce que l'onduleur reprend la main est un
diagnostic d'installation, pas un manque de surplus. Deux causes, deux gestes.
---
### LM-1402 — (b) `batteryLevelConsideration` cesse d'être un rang et redevient une RÉSERVE
Les deux mécanismes ne sont pas concurrents **par nature** — ils le sont par accident
d'implémentation. Ce sont deux objets différents que le code actuel confond :
| | ce que c'est | ce que le code en fait aujourd'hui |
|---|---|---|
| **rang** (`priority`) | l'ordre de service : qui reçoit le surplus en premier | n'existe pas pour la batterie |
| **réserve** (`batteryLevelConsideration`) | un plancher : sous ce niveau, on protège le stockage | **agit comme un rang absolu** — annule tout le budget des autres |
**Position : quand la batterie devient une charge rangée, le seuil PERD son rôle de priorité et
ne garde que son rôle de plancher.** « La batterie d'abord » s'exprime alors par le rang — et
« l'eau chaude d'abord » aussi, dans la même liste, par le même geste.
Le précédent existe et il va dans ce sens : le défaut d'usine est passé de `0,9` à `0,2` en
3g-1, précisément parce que **`0,9` confondait une cible et une réserve** (LM-1203-b). Ce lot
finit ce mouvement.
> **Ne pas garder les deux rôles.** Deux réglages dont l'un défait l'autre en silence est
> exactement ce que LM-1207 refuse pour les priorités : deux classements qui ne s'interclassent
> pas ne sont pas deux fonctionnalités, c'est un défaut. Et l'écran ne saurait pas le présenter.
---
### LM-1403 — (c) La sûreté : deux gestes très différents sous un seul mot
« Le rang peut-il vider une batterie ? » — la question se scinde, et la scission désamorce la
moitié de l'inquiétude :
| Geste | Effet sur le SOC | Dangereux ? |
|---|---|---|
| **Ne pas la charger** (servir l'ECS d'abord) | le SOC **ne monte pas** | **non** — réversible, le soleil revient. C'est la demande de Patrick, et elle ne vide rien |
| **La décharger pour servir une charge** | le SOC **descend** | **oui** — et c'est le seul cas à border |
Or le second est **déjà refusé aujourd'hui**, sans rapport avec le rang
(`rulebasedscheduler.cpp:152-156`) :
```cpp
if (ctx.battery.powerW < 0) {
// Décharge : ne pas vider la batterie pour alimenter une charge pilotée, quel que
// soit le niveau. Ce qu'elle fournit n'est pas du surplus.
remainingSurplusW += ctx.battery.powerW; // powerW < 0 → retranche
}
```
**Position** : le rang gouverne uniquement l'**ordre de charge**. Trois planchers restent
intouchables et ne se règlent pas par glissement — `batteryCritical`, `reserveSocPercent` (déjà
au contrat, jamais lu), et l'interdiction ci-dessus. **Un rang ne fait jamais descendre un SOC ;
il décide seulement de ce qui le fait monter.** Le glissement demandé est donc sûr par
construction, et c'est ce qui permet de l'offrir sans écran d'avertissement.
---
### LM-1404 — (d) Le signe : l'inquiétude tombe, et pour une raison précise
La crainte est légitime après `EV_GRID_START`, mais elle ne s'applique pas ici. **Charger une
batterie, c'est consommer** — au compteur, c'est indistinguable d'un chauffe-eau qui chauffe.
| | `allocatedW` veut dire | signe au bilan |
|---|---|---|
| ECS | consommer 2 000 W | **−** au surplus |
| Borne | consommer 2 000 W | **−** au surplus |
| **Batterie en charge** | **stocker** 2 000 W | **−** au surplus — **identique** |
`allocatedW` d'une batterie qui charge se réconcilie donc avec `budget.allocatedW` **sans aucun
traitement particulier** : c'est une charge comme les autres, et le waterfall n'a pas à savoir ce
qu'elle fait de l'énergie.
**Le sens opposé n'apparaît qu'à la DÉCHARGE — et une décharge n'est pas une allocation.** C'est
une *source*, elle appartient au revenu du budget, pas à sa dépense. Le code le traite déjà ainsi
(LM-1403). **Les mélanger serait le vrai défaut** : une décharge comptée en `allocatedW` négatif
rendrait faux l'invariant publié `surplus + recrédit − alloué == restant`.
> **Règle qui en sort** : `allocatedW` d'une batterie est **toujours ≥ 0**. Ce qu'elle rend au
> réseau ou à la maison n'est jamais une allocation.
---
### LM-1405 — Ce qui reste à décider, et que cette note ne tranche pas
- **La charge réseau plafonnée** (`setpoint` + `source: "grid"`) : acheter pour stocker est un
franchissement de frontière, le même que le §10. Il doit suivre les mêmes règles — plafond de
soutirage de LM-1006-1 compris. **Hors de ce qui répond à la demande de Patrick** : servir
l'ECS avant la batterie ne demande aucun achat.
- **L'ordre relatif par défaut.** Une installation existante a aujourd'hui « batterie d'abord »
par le seuil. Lui donner un rang change son comportement : quel rang par défaut, et
faut-il migrer les installations en service ? Même famille que le rang par défaut des bornes,
et même risque — un ordre qui n'est pas un choix mais un artefact (`RELEVE_3g2.md` §3.2).
- **La forme de l'entrée `LoadConfig`.** `adapter: "battery"`, sur le modèle de `evcharger`
(rang seul, limites lues sur le Thing) — à confirmer à l'écriture de 3f.
### LM-1406 — Ce qu'il ne faut PAS faire en attendant
**Une règle `Rules.*` coupant `enableCharging` fonctionnerait, et elle est à écarter.** L'agent
app l'a écartée de lui-même, pour la bonne raison : ce serait un **second commandeur sans
arbitre** — précisément le défaut que 3g-2 vient de supprimer sur les bornes, réintroduit par la
porte de l'app. Et il serait pire ici : sur les bornes, le second commandeur était au moins
*dans* le moteur, donc observable au journal.
**« Pas réglable aujourd'hui » est la bonne réponse à donner à l'utilisateur**, et elle vaut
mieux qu'un réglage dont personne ne garantit l'effet.
---
## §15 — Les dérogations
*Ouvert et implémenté le 2026-09-08. Design complet : `docs/DESIGN_DEROGATION.md`.*
### LM-1500 — Une dérogation qui ne sait pas dire QUAND elle finit ne devrait pas exister
C'est la règle du lot, et elle vaut au-delà du VE.
Une dérogation **achète**. Le mécanisme qui la termine doit donc être connu **avant** de la
poser, jamais découvert en cours de route : « jusqu'à annulation » n'est pas une fin, c'est un
mode déguisé, et un mode qu'on a oublié de quitter continue d'acheter indéfiniment.
Conséquence exécutoire : la **durée est obligatoire et bornée** (`[60 s, 6 h]`), refusée à la
pose. C'est le seul endroit où l'on peut encore la faire respecter.
### LM-1500-a — La durée est le FILET, et il reste armé même quand il y a mieux
Une dérogation porte une échéance de durée **même lorsqu'elle a une meilleure condition de fin**,
parce que cette condition-là repose sur un **instrument**, et qu'un instrument peut se taire.
Mesuré sur la V2C (`LM-1012-d`) : à la perte du lien Modbus, `sessionEnergy` **reste à sa
dernière valeur** au lieu de disparaître. Une dérogation « 20 kWh puis stop » dont le compteur se
fige à 12 kWh n'atteint jamais sa cible — et sans filet, elle achèterait jusqu'à ce que quelqu'un
s'en aperçoive.
**La durée s'éprouve partout**, sans matériel et sans lien : une raison de plus d'en faire le
filet plutôt qu'un recours.
### LM-1500-b — La fin par MESURE ne mord que s'il y a une cible
« Une dérogation dont la **cible** cesse d'être mesurable doit cesser » — R6 transposée : active
oui, vérifiable non, donc on s'arrête. Acheter au maximum sans savoir où l'on en est, c'est
acheter à l'aveugle.
Mais **sans cible, rien ne dépend de la mesure** : le filet de durée suffit, et couper là
punirait la charge du silence d'un instrument dont elle n'a pas besoin.
Le critère se lit sur `telemetry.sessionMeasurable`, **jamais** sur `progress.regime` : `progress`
est absent d'une charge sans obligation en énergie — c'est-à-dire exactement le cas du boost, où
le critère aurait été **inerte**.
### LM-1500-c — Les seuils de confirmation sont ASYMÉTRIQUES
Les deux erreurs ne coûtent pas la même chose. **Terminer à tort rend la main** : la charge
repasse à son mode mémorisé, l'utilisateur relance. **Continuer à tort achète au maximum.**
Le moteur est donc **prompt à conclure la fin, lent à conclure la poursuite** — deux cycles
suffisent. Et les compteurs se remettent à zéro dès que le signal se dément : un clignotement ne
doit pas s'accumuler d'un épisode à l'autre.
### LM-1500-d — Le mode précédent se MÉMORISE, il ne se déduit pas
Revenir à un **défaut** détruirait un réglage en silence. Sur une borne, retomber sur `Normal`
la sortirait de l'arbitrage : une dérogation qui laisserait l'installation **moins pilotée
qu'avant elle**, l'inverse exact de son but.
Même famille que le repli d'échéance qui réécrivait `EcoWithMinCurrent` en `Eco`.
### LM-1500-e — Elle prend la TÊTE de la passe éco ; elle ne surplombe pas la sûreté
Le rang classe ce que l'**installateur** décide (`LM-1002`) ; une dérogation est une **action
ponctuelle de l'utilisateur**, et passe par une porte que `LM-1002` ne ferme pas. Un boost servi
après une autre charge contredirait « tout de suite », qui est sa définition.
Mais `DRAW_CAP` la borne et le délestage la reprend : elle est servie la première **dans ce qui
reste**, jamais au-delà. Sans cette limite on ouvrirait un **second chemin d'achat** — celui-là
même qui a été refusé au minimum réseau (`LM-1013-b-vi`, `DESIGN_MIN_RESEAU.md` §3).
### LM-1500-f — La fin est un ÉVÉNEMENT, et elle dit sa CAUSE
Publiée le seul cycle où elle survient, au **niveau de la trame** et non dans `loads[]` : au
débranchement, la charge n'y est déjà plus, et l'événement disparaîtrait exactement quand il
explique le plus de choses.
Les quatre causes appellent des **gestes différents** — cible atteinte (rien à faire), mesure
perdue (regarder le lien), débranchement, durée écoulée. Une fin qui ne dirait pas laquelle a
mordu laisserait l'utilisateur relancer une dérogation que le lien empêchera encore. La cause
`cancelled` s'ajoute aux quatre : c'est une fin **par décision**, et la distinguer est le but.
### LM-1500-g — Écrire un MODE pendant une dérogation la termine, et sans restituer
Choisir un mode est une décision **durable** ; une dérogation répond à un besoin **ponctuel**.
Le durable prime, et il prime **tout de suite**.
**Et sans restituer le mode mémorisé** : celui qu'on restituerait écraserait celui que
l'utilisateur vient de choisir. On lui reprendrait sa décision au nom de la nôtre, un tour de
boucle plus tard, sans qu'il puisse le voir.
**Ce n'est pas une subtilité, c'est une collision de commandeurs.** Constaté au banc le
2026-09-08 : le bouton « boost » de l'app écrit encore `ChargingModeNormal`. Reçu pendant une
dérogation, il fait restituer les valeurs manuelles par l'amont — `chargingEnabled` à **faux** —
et **arme un verrou de 300 s**, pendant que la dérogation continue de commander 9 A. Deux
commandeurs pour un organe, et six minutes de dérogation servie **sur le papier** dans une borne
éteinte. Seul le journal de divergence le voyait, et il ne pouvait pas en dire la cause.
### LM-1500-h — Une BORNE n'est pas écrêtée deux fois
Le plafond de cycle d'une borne (`declared.maxPowerW`) porte **déjà** l'autorisation de
soutirage : `evPhaseAllowanceW()` la calcule et la **multiplie par le nombre de phases occupées**.
Lui appliquer en plus `margeRestante`, c'est comparer une enveloppe **triphasée** à la marge
**d'une seule phase** — et diviser son allocation par trois.
Mesuré au banc le 2026-09-08 : marge de 6 210 W sur la phase la plus chargée, borne triphasée
dont le plafond du cycle valait 13 800 W. Le double écrêtage l'a servie à 6 210 W, soit **9 A par
phase au lieu de 27** — **4 kW au lieu des 11** que la voiture pouvait prendre.
**Sûr, mais faux.** L'erreur va dans le sens prudent, donc rien ne la signale : ni le
disjoncteur, ni le délestage, ni un test qui vérifie qu'on ne dépasse pas. Elle ne se voit qu'en
comparant ce qui est servi à ce qui aurait dû l'être — c'est-à-dire en regardant la voiture.
La passe confort l'avait déjà écrit (« mieux vaut la borne mieux informée que la borne la plus
sévère ») ; la tête de passe éco l'a réappris à ses dépens. **Une règle notée à un seul endroit
du code n'est pas une règle.**