docs(spec §14): oui, le stockage devient une charge rangée du waterfall

Question de l'agent app, relayant Patrick : « si je veux que l'énergie aille
d'abord dans l'eau chaude, et seulement ensuite dans la batterie, je fais
comment ? » Réponse : aujourd'hui il ne peut pas, et la réponse est OUI.

Les quatre constats de l'app sont exacts, revérifiés indépendamment. Et le
contrat attendait déjà cette charge : OPTIMIZER_PROTOCOL décrit
adapter:"battery" dans loads[], LoadAction porte le kind Constraint avec
Permission{Allow,Forbid} et Source{Solar,GridSource}, et SurplusBattery déclare
reserveSocPercent / maxChargeW / maxDischargeW — trois champs qu'aucun code ne
lit. domain:"battery" accepté sans effet n'est donc pas une question effleurée,
c'est le reliquat visible d'une place réservée. La note dit sous quelle forme 3f
la remplit.

Les quatre points, tranchés :

(a) Le double commandeur ne se devine pas : il se MESURE. Le plugin SunSpec
    écrit, relit et compare, et son commentaire nomme le cas exact — « another
    controller overwriting the setpoints ». Le résultat sort dans controlHealthy,
    déjà déclaré par la classe du banc. Trois exigences en découlent, dont
    celle-ci : controlHealthy == false doit SORTIR de l'arbitrage comme
    available == false le fait déjà, sinon c'est le trou de pluggedIn à nouveau.

(b) batteryLevelConsideration et le rang ne sont pas concurrents par nature —
    ils le sont par accident d'implémentation. Le seuil agit aujourd'hui comme
    un rang absolu ; quand la batterie sera rangée, il perd ce rôle et ne garde
    que celui de plancher. Le mouvement a commencé en 3g-1 avec le défaut passé
    de 0,9 à 0,2, précisément parce que 0,9 confondait cible et réserve.

(c) La sûreté : « ne pas charger » et « décharger » sont deux gestes que le mot
    unique cachait. Le premier ne vide rien et c'est la demande de Patrick ; le
    second est déjà refusé par le budget, quel que soit le niveau. Un rang ne
    fait jamais descendre un SOC, il décide de ce qui le fait monter.

(d) Le signe : l'inquiétude tombe. Charger une batterie, c'est consommer — au
    compteur c'est indistinguable d'un chauffe-eau, donc allocatedW se
    réconcilie sans traitement particulier. 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. Règle : allocatedW d'une batterie est
    toujours ≥ 0.

La règle Rules.* écartée par l'app l'est pour la bonne raison, et la note le
confirme : ce serait un second commandeur sans arbitre, pire que celui de 3g-2
puisqu'il vivrait hors du moteur, donc hors du journal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
This commit is contained in:
Patrick Schurig 2026-08-27 20:04:41 +02:00
parent f244729515
commit 5fb15d521b

View File

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