diff --git a/specs/spec_loadmodel.md b/specs/spec_loadmodel.md index 954c1c3..1c6dc16 100644 --- a/specs/spec_loadmodel.md +++ b/specs/spec_loadmodel.md @@ -1094,3 +1094,176 @@ derrière une seule. 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.