Patrick Schurig 3b63eec251 fix(3g-2): une borne sans voiture assignée reste arbitrée
LM-1208-b appliqué : `assignedCarId` sort du filtre d'entrée du waterfall.
Restent deux exclusions, et elles ne disent pas la même chose — le mode manuel
est une INTENTION (l'utilisateur pilote), l'absence de véhicule branché un
CONSTAT (rien à consommer de ce qu'on allouerait).

Le critère venait du proxy, où il a un sens : sans voiture, pas de SoC, pas de
capacité, pas d'échéance à planifier. Le waterfall n'a besoin d'aucune des trois
pour allouer du surplus. Il contredisait LM-1206 — le rang et le plancher
appartiennent à la borne, pas au véhicule — il n'était pas vérifiable, et il
excluait la borne d'un invité au moment précis où elle consomme. Ce qu'on perd
sans voiture est une FONCTION, échéance et cible d'énergie, pas l'arbitrage.

LM-1208-c, trouvé en implantant, et c'est lui qui aurait mordu : retirer le
critère ne suffit pas. Le proxy ne PRÉPARE pas une borne sans voiture —
prepareInformation() la filtre en entrée — donc elle entre dans le waterfall
sans ChargingProcessInfo. Or internalProcessInfo() rend un objet par DÉFAUT, et
ses valeurs sont plausibles : une phase, pas de bascule. Une borne triphasée
aurait été annoncée monophasée et le budget divisé par trois — plafond 3 680 W
au lieu de 11 040, 16 A commandés au lieu de 9, 11 kW tirés d'une enveloppe qui
en comptait 3,7. Le défaut de 3g-1 pris à l'envers, et silencieux.

D'où internalHasProcessInfo() : la question « cette borne a-t-elle été préparée
ce cycle » devient POSABLE au lieu d'être déduite d'une valeur crédible. Sans
ChargingProcessInfo on ne bascule pas — basculer suppose de savoir ce que la
voiture accepte — et on retient le phaseCount de la BORNE, hypothèse
conservatrice au sens de LM-104 : elle surestime la consommation.

Éprouvé par testChargerWithoutCarIsArbitrated, et VÉRIFIÉ ÉCHOUANT sans le
garde-fou : 3 680 W au lieu de 6 000 W alloués. Le test porte les deux moitiés —
la borne sans voiture est servie ET le mode manuel reste une exclusion, sans quoi
il passerait aussi si toute borne était arbitrée sans condition.

amd64 0 erreur / 0 avertissement. Suites, un processus par test : simulation
29/29 (dont run 16/16), charging 16/16, loadmodel 18/18, spotmarket 7/7.

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

79 KiB
Raw Blame History

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 s'attribue UNE fois. La correction B rend à chaque charge sa consommation de début de cycle avant d'arrondir. Avec deux passes, ce crédit doit être attribué à la première passe où la charge apparaît, jamais aux deux : compté deux fois, il fabriquerait du budget qui n'existe pas.

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.

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-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.

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.

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.


§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-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) :

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.