docs(protocole): OPTIMIZER_PROTOCOL passe en v1.1 figée

Remplacement verbatim par la v1.1 figée (aucune réécriture). Additif sur le
fil : prévisions explicites (forecast), groupes, domaine, besoins enrichis,
measurement {source, powerW}, validUntil, reasonCode/reasonParams, force
interdit sur le fil. Le kind `stage` et l'adaptateur `relay-stages`, jamais
livrés, sont retirés comme correction de brouillon.

Références vérifiées contre le dépôt ; les écarts (chemins inexacts,
porteurs absents) sont consignés dans le rapport du brief 1, pas corrigés
dans le texte figé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-12 08:18:07 +02:00
parent a787a5c476
commit c78dbfc318

View File

@ -1,16 +1,16 @@
# OPTIMIZER_PROTOCOL — v1 (draft)
# OPTIMIZER_PROTOCOL — v1.1
Contrat entre le moteur d'énergie **etm-powersync-energy-plugin-etm** (couche A, GPL-3.0)
et un **optimiseur** externe (couche B : optimiseur ETM, ou toute implémentation tierce).
et un **optimiseur** externe (couche B : l'optimiseur ETM, ou toute implémentation tierce).
Ce document fait autorité. Il sert trois usages :
1. Interface interne entre l'arbitrage central et la stratégie par défaut (rule-based).
2. Spécification du protocole socket pour un optimiseur en process séparé.
3. Documentation publique : n'importe qui peut implémenter son propre optimiseur.
Statut : **draft v1** — figé à la fin de la beta. Versionné en semver (`protocolVersion`).
Statut : **v1.1** — figé à la fin de la beta. Versionné en semver (`protocolVersion`).
> ### ⚠️ Ce document ne décrit PAS la frontière JSON-RPC de l'app
> ### Ce document ne décrit PAS la frontière JSON-RPC de l'app
>
> Le protocole ci-dessous relie le **moteur** à un **optimiseur** (couche A ↔ couche B), sur
> un socket dédié, en NDJSON. Il ne dit rien du namespace que consomme l'application
@ -20,13 +20,43 @@ Statut : **draft v1** — figé à la fin de la beta. Versionné en semver (`pro
> autorité via `energyplugin/nymeaenergyjsonhandler.cpp` + `JSONRPC.Introspect` sur une box.
>
> Cette note existe parce que l'erreur a coûté une session : un brief a envoyé l'agent app
> chercher ici le namespace `LoadConfig`, qui n'y est pas et n'y sera pas. Les deux
> protocoles portent tous deux du JSON-RPC — c'est tout ce qu'ils ont en commun.
> chercher ici le namespace `LoadConfig`, qui n'y est pas et n'y sera pas.
>
> | | transport | qui parle à qui | contrat |
> |---|---|---|---|
> | **Ce document** | socket NDJSON dédié | moteur ↔ optimiseur | `GetPlan(SurplusContext)` → plan par créneaux |
> | `INTERFACE.md` | WebSocket nymea, port 4444 | app ↔ box | `NymeaEnergy.*` — 20 méthodes, 14 notifications |
> | `INTERFACE.md` | WebSocket nymea, port 4444 | app ↔ box | `NymeaEnergy.*` |
---
## 0. Changelog
### 1.1 (2026-10)
Additif sur le fil : toutes les additions sont des **champs optionnels nouveaux** ; un
optimiseur 1.0 reste conforme (champs inconnus ignorés, §9).
**Correction de brouillon (pas une rupture)** : le kind `stage` et l'adaptateur
`relay-stages` décrits en 1.0 n'ont jamais existé dans le moteur livré (retirés du code en
rév. 3, avant toute implémentation socket). Aucun consommateur déployé n'en dépend ; ils
sont retirés du document sans changement de majeure.
- §5.2 **Prévisions explicites** (`forecast`) : grille temporelle déclarée, PV, consommation
de base, température, prix complet. Remplace le renvoi à `CONTRACT_A_openmeteo.md`.
- §5.3 **Groupes** (`group`) : clé de tri à deux niveaux. `priority` reste le rang effectif.
- §5.3 **Domaine** (`domain`) et noms réels des mécanismes (`adapter`).
- §5.3 **Besoins enrichis** : `energyTargets`, `comfort`, `source` de chaque besoin.
- §5.3 **Mesure par charge** : `measurement {source, powerW}` (aligné sur `INTERFACE.md`).
- §5.3 Champs de verrou `minState`/`maxState` (SG-Ready) documentés.
- §5.1 **Déclencheur** de la requête (`trigger`), `meter.source`.
- §3 `capabilities`.
- §6 **`validUntil`**, `expected`, `reasonCode` + `reasonParams`, `diagnostics` ; `force`
interdit sur le fil.
- Annexe D **Traces et rejeu** ; Annexe E **Optimiseur de référence** ;
Annexe F **Champs réservés** (batterie).
### 1.0 (2026-06) — version initiale
- 2026-08-09 (`92d0bef`) : `available`, `lockMinPowerW`, `lockMaxPowerW` documentés (ECS-410).
- 2026-08-25 (`afaa8c2`) : encadré « ce document ne décrit pas la frontière de l'app ».
---
@ -36,19 +66,26 @@ Statut : **draft v1** — figé à la fin de la beta. Versionné en semver (`pro
bornes de sécurité du plugin (limites par charge, protection de surcharge, anti-flapping).
Un optimiseur bugué ou malveillant ne peut produire que des choix sous-optimaux *dans
des limites sûres* — jamais un état dangereux.
- **La boucle de sécurité n'est pas négociable.** `verifyOverloadProtection()` (temps réel,
sur `powerBalanceChanged`) s'exécute indépendamment et prime sur toute consigne.
- **Le système fonctionne sans optimiseur.** Optimiseur absent, mort ou abstentionniste →
repli automatique sur la stratégie rule-based interne (GPL). La maison reste pilotée.
- **La boucle de sécurité n'est pas négociable.** Couches L0 à L4 (`docs/SAFETY.md`).
`verifyOverloadProtection()` (temps réel, sur `powerBalanceChanged`) s'exécute
indépendamment et prime sur toute consigne ; son extension au waterfall
(`shedWaterfallForOverload()`) revendique les charges délestées, que le dispatch saute.
- **Le système fonctionne sans optimiseur.** Optimiseur absent, mort, abstentionniste ou
plan expiré → repli automatique sur la stratégie rule-based interne (GPL).
- **Toute décision est expliquée.** Chaque action porte un `reason` lisible (français),
non vide. C'est une exigence du contrat, pas une option.
- **Le plugin seul commande le matériel.** Temporisations et anti-battement sont tenus par
le plugin en toutes circonstances ; l'optimiseur les connaît pour planifier, il ne les
fait pas respecter.
- **Zéro cloud.** Rien dans ce protocole n'exige ni ne provoque de connexion sortante du
site (`AGENTS.md` invariant 10).
## 2. Topologie et transport
- **Le plugin est le CLIENT. L'optimiseur est le SERVEUR.** Le plugin se connecte à un
endpoint configuré et demande des plans. La box n'ouvre aucun port.
- Endpoint configurable :
- `unix:///run/powersync/optimizer.sock` — défaut. Optimiseur local (optimiseur ETM).
- `unix:///run/powersync/optimizer.sock` — défaut. Optimiseur local.
Droits : socket possédé par `root:powersync`, mode `0660`.
- `tcp://<host>:<port>` — optimiseur sur le LAN (NAS, serveur). Pour les
implémentations communautaires (ex. pont Akkudoktor-EOS, annexe B).
@ -63,7 +100,7 @@ Statut : **draft v1** — figé à la fin de la beta. Versionné en semver (`pro
```json
{"jsonrpc":"2.0","id":1,"method":"Handshake","params":{
"protocolVersion":"1.0",
"protocolVersion":"1.1",
"engine":"etm-powersync-energy-plugin-etm/<version>",
"token":"<secret partagé, optionnel — requis si configuré côté plugin>"
}}
@ -73,9 +110,16 @@ Réponse attendue :
```json
{"jsonrpc":"2.0","id":1,"result":{
"protocolVersion":"1.0",
"protocolVersion":"1.1",
"name":"etm-optimizer",
"planning":true
"planning":true,
"capabilities":{
"maxHorizonH":48,
"resolutionS":3600,
"kinds":["setpoint","state","constraint"],
"usesForecast":["pv","baseload","price","temperature"],
"reasonCodes":true
}
}}
```
@ -85,68 +129,47 @@ Réponse attendue :
ne répond qu'au présent (plan à un créneau). Informationnel.
- `token` : si configuré côté plugin et absent/incorrect côté serveur → connexion fermée.
Recommandé pour tout endpoint `tcp://`.
- `capabilities` (1.1, optionnel) : affiché en mode installateur. **S'il est présent**, le
plugin rejette toute action dont le `kind` n'y est pas annoncé.
- Pas de `locale` : la locale nymea est par connexion app (`AGENTS.md` invariant 12) ; la
traduction vers l'app passe par `reasonCode` (§6).
- Handshake invalide ou timeout (5 s) → repli, nouvelle tentative au backoff.
## 4. Cycle et heartbeat
- Le plugin appelle `GetPlan` à chaque cycle d'optimisation (~1/min) **et** sur
événement significatif (charge branchée/débranchée, changement de config).
événement significatif — voir `trigger`, §5.1.
- **Le heartbeat est implicite** : une réponse valide à `GetPlan` = optimiseur vivant.
Deux cycles consécutifs sans réponse valide (timeout par requête : 10 s) →
`optimizerAlive = false`, repli rule-based, notification `Ems.CapabilitiesChanged`.
`optimizerAlive = false`, repli rule-based, notification de changement de capacités
(namespace publié actuel : `NymeaEnergy`).
- L'optimiseur peut être lent à *calculer* (EOS : minutes) — il doit alors répondre
vite avec son **dernier plan en cache** ou `abstain`, et recalculer en tâche de fond.
Une requête n'attend jamais un calcul long.
Une requête n'attend jamais un calcul long. Le rythme de recalcul est libre
(typiquement chaque quart d'heure, et sur `trigger` ≠ `cycle`).
## 5. Requête : `GetPlan(SurplusContext)`
### 5.1 En-tête et état instantané
```json
{"jsonrpc":"2.0","id":42,"method":"GetPlan","params":{
"timestamp":"2026-06-07T13:42:00+02:00",
"timestamp":"2026-10-12T13:42:00+02:00",
"trigger":"cycle",
"site":{
"contractedPowerW":12000,
"gridCapW":null,
"phaseLimitA":[63,63,63]
},
"meter":{"importW":0,"exportW":2310,"perPhaseA":[3.2,4.1,2.8]},
"meter":{"source":"<thingId du compteur racine>","importW":0,"exportW":2310,"perPhaseA":[3.2,4.1,2.8]},
"pv":{"currentW":3400},
"battery":{
"present":true,"socPercent":62,"powerW":-800,
"capacityWh":10000,"reserveSocPercent":15,
"maxChargeW":5000,"maxDischargeW":5000
},
"loads":[
{
"id":"ecs-1","adapter":"relay-stages","label":"Chauffe-eau",
"declared":{"nominalPowerW":2400,"stages":[0,1200,2400]},
"defaults":{"dailyEnergyWh":7000},
"learned":{"dailyEnergyWh":6400,"confidence":0.35},
"telemetry":{"state":1,"currentPowerW":1200,"lastSwitch":"2026-06-07T13:10:00+02:00"},
"needs":{"dailyDeadline":"18:00","minEnergyWhPerDay":4000},
"priority":2,
"limits":{"minOnS":300,"minOffS":300}
},
{
"id":"ev-1","adapter":"evcharger","label":"Borne garage",
"declared":{"minA":6,"maxA":16,"phases":3},
"telemetry":{"pluggedIn":true,"charging":true,"currentPowerW":4100,"sessionWh":5200},
"needs":{"targetSocPercent":80,"deadline":"2026-06-08T07:00:00+02:00"},
"priority":1,
"limits":{"chargingEnabledLockS":300,"currentChangeLockS":60}
},
{
"id":"pac-1","adapter":"sg-ready","label":"PAC",
"declared":{"states":[1,2,3,4],"estimatedPowerW":{"3":1800,"4":2600}},
"telemetry":{"state":2},
"priority":3,
"limits":{"minStateHoldS":900}
},
{
"id":"bat-1","adapter":"battery","label":"Batterie Fronius",
"priority":4
}
],
"forecast":{ "...":"objet Contract A (openmeteo) transmis tel quel, ou null" },
"loads":[ "... §5.3 ..." ],
"forecast":{ "... §5.2 ... ou null" },
"tariff":{
"provider":"tempo","current":{"label":"HP_BLEU","priceCtkWh":16.1},
"next":[{"from":"2026-06-08T06:00","label":"ROUGE","priceCtkWh":75.6}]
@ -154,65 +177,216 @@ Réponse attendue :
}}
```
- `trigger` (1.1) : `cycle` | `load-change` (branchement VE, changement d'état) | `intent`
(intention client) | `config` (modification installateur) | `forecast` (nouvelle prévision).
Un optimiseur peut s'en servir pour lancer un recalcul anticipé ; il répond quand même
immédiatement (§4).
- `meter.source` (1.1) : identité du compteur racine désigné (`Energy.SetRootMeter`), unique.
- `meter.exportW` est la mesure **brute** (`AGENTS.md` règle 8) : jamais le net après pilotage.
- `site.gridCapW` : réservé (signaux réseau type §14a EnWG). `null` en France.
- `tariff` (1.0) reste émis pour compatibilité ; un optimiseur 1.1 lit `forecast.price`.
### 5.2 Prévisions (`forecast`)
Toutes les séries partagent **une seule grille temporelle**. Les tableaux sont alignés
index par index ; `null` dans une case = valeur inconnue — **jamais 0, jamais inventée**
(`docs/DESIGN_PREVISION_PV.md` §8.4).
```json
"forecast":{
"start":"2026-10-12T14:00:00+02:00",
"resolutionS":3600,
"steps":34,
"pv":{
"provider":"open-meteo",
"weatherModel":"meteofrance_seamless",
"conversion":"physics",
"corrections":["temperature","ac-limit"],
"issuedAt":null,
"contentChangedAt":"2026-10-12T12:07:00+02:00",
"acLimitW":8000,
"p50W":[ ... ],
"perField":[
{"id":"<thingId plan>","label":"Toit sud","peakW":6000,"tiltDeg":35,"azimuthDeg":0,"p50W":[ ... ]}
]
},
"baseload":{"model":"none","p50W":[null, ...]},
"weather":{"temperatureC":[ ... ]},
"price":{
"currency":"EUR","importCtkWh":[ ... ],"exportCtkWh":[ ... ],
"includes":["energy","grid","taxes"],"source":"tempo",
"knownUntil":"2026-10-13T06:00:00+02:00"
}
}
```
Règles :
- **Grille** : `resolutionS` et `start` font foi — un consommateur ne suppose jamais le pas
(`DESIGN_PREVISION_PV.md` §14.3). En 1.1 le producteur publie au pas **horaire**
(`3600`) ; le pas de 15 min sera introduit avec les prix day-ahead, sans changement de
protocole. Horizon : jusqu'à la fin du lendemain, 48 h max.
- **Instants** : absolus. Les horodatages portent leur décalage ; aucune case n'est indexée
par une heure murale (`DESIGN_PREVISION_PV.md` §8.1 — jours de 23 h et 25 h).
- **`pv`** : puissance AC moyenne sur `[t, t+pas)`, sommée sur les champs puis plafonnée à
`acLimitW`.
- **C'est un plafond, pas une espérance**, tant que la conversion ignore l'ombrage et que
l'encrassement n'est porté que par un facteur fixe : le biais est systématique et
optimiste (`DESIGN_PREVISION_PV.md` §3, §21.2).
- `corrections` (1.1) : corrections physiques appliquées — `temperature` (température de
module), `ac-limit` (plafond onduleur). Liste vide = conversion brute `GTI × kWc × PR`.
- `p10W`/`p90W` : optionnels, **absents** tant que `conversion` = `physics`. **Ils ne sont
JAMAIS dérivés de la dispersion d'ensemble** : elle est anticorrélée à l'erreur de
conversion (`DESIGN_PREVISION_PV.md` §4).
- `weatherModel` : modèle météo **qui a réellement répondu** (repli `best_match` compris),
`null` si inconnu — jamais le modèle configuré.
- `conversion` : `physics` | `fallback`. Le plugin GPL **n'émet jamais `hybrid`** :
la correction apprise vit hors GPL (`DESIGN_PREVISION_PV.md` §5). Un optimiseur qui
corrige la prévision le déclare dans `diagnostics.inputsUsed.pv` (§6).
- `issuedAt` : instant du run s'il est connu, sinon `null`. **Ce n'est pas l'âge de chaque
case** (`DESIGN_PREVISION_PV.md` §20-21). `contentChangedAt` : instant d'apparition du
contenu courant — un producteur figé ne doit pas se lire comme une prévision stable (§26.2).
- `perField` : optionnel, affichage et diagnostic. Convention d'orientation (Open-Meteo,
PVGIS) : `tiltDeg` 0 = horizontal, 90 = vertical ; `azimuthDeg` 0 = sud, −90 = est,
+90 = ouest, ±180 = nord.
- **`baseload`** : consommation **hors charges pilotées**. `model` : `profile` | `learned` |
`none` ; `none` + tableau de `null` autorisé (installation neuve). Le plugin la calcule
depuis le compteur racine moins les charges, chacune selon `measurement.source` (§5.3) ;
une charge en `none` est comptée à sa puissance **commandée**, et la qualité de
l'estimation le dit.
- **`price`** : **prix complet payé** — `includes` liste ce qui est inclus. Un prix n'est
**pas une prévision** : au-delà de `knownUntil`, `null`. Sources : `fixed`, `hphc`,
`tempo`, `day-ahead`. `exportCtkWh` : tarif de revente ou 0.
- `forecast` peut être `null`. L'optimiseur reste libre de sourcer ses propres prévisions
(pont LAN, côté optimiseur), mais **le plugin, lui, ne sort pas** pour les obtenir.
### 5.3 Charges (`loads[]`)
```json
"loads":[
{
"id":"chauffe-eau","adapter":"relay-router","label":"Chauffe-eau","domain":"ecs",
"group":{"groupRank":2,"rankInGroup":1},
"priority":2,
"declared":{"nominalPowerW":2400,"minPowerW":0,"maxPowerW":2400,"volumeL":200},
"defaults":{"dailyEnergyWh":7000},
"learned":{"dailyEnergyWh":6400,"confidence":0.35},
"telemetry":{"currentPowerW":1200,"available":true,"lockMinPowerW":1200,"lockMaxPowerW":-1,
"measurement":{"source":"meter","powerW":1180}},
"needs":{
"dailyDeadline":"18:00","minEnergyWhPerDay":4000,
"energyTargets":[{"by":"2026-06-07T18:00:00+02:00","minWh":4000,"source":"installer"}],
"comfort":{"ecoMinC":50,"comfortC":60,"source":"installer"}
},
"limits":{"minOnS":300,"minOffS":300}
},
{
"id":"ev-1","adapter":"evcharger","label":"Borne garage","domain":"ev",
"declared":{"minA":6,"maxA":16,"phases":3},
"telemetry":{"pluggedIn":true,"charging":true,"currentPowerW":4100,"sessionWh":5200,
"measurement":{"source":"device","powerW":4100}},
"needs":{"targetSocPercent":80,"deadline":"2026-06-08T07:00:00+02:00"},
"priority":1,
"limits":{"chargingEnabledLockS":300,"currentChangeLockS":60}
},
{
"id":"pac-terrain","adapter":"sg-ready","label":"PAC","domain":"heating",
"declared":{"states":[1,2,3,4],"estimatedPowerW":{"3":1800,"4":2600}},
"telemetry":{"state":2,"minState":2,"maxState":4,"measurement":{"source":"none"}},
"priority":3,
"limits":{"minStateHoldS":900}
}
]
```
Notes de schéma :
- `loads[].declared` : saisi par l'installateur (plaque signalétique, câblage). Fiable.
- `loads[].defaults` : valeurs approximatives par catégorie. Point de départ.
- `loads[].learned` : estimations de l'optimiseur, **renvoyées** au plugin pour
persistance et affichage, avec `confidence` (0–1). Le rule-based les ignore.
- `loads[].priority` : rang dans la liste ordonnée définie par le client (1 = premier
servi). Voir annexe C.
- `loads[].priority` : **rang effectif** (1 = premier servi). Voir annexe C. Un optimiseur
qui ne lit que `priority` reste correct.
- `loads[].limits` : verrous anti-flapping. Le plugin LES APPLIQUE quoi qu'il arrive ;
ils sont transmis pour que l'optimiseur planifie intelligemment, pas pour qu'il
les fasse respecter.
- `site.gridCapW` : réservé (signaux réseau type §14a EnWG). `null` en France v1.
- `forecast` : contenu défini par `CONTRACT_A_openmeteo.md`. Peut être `null`
(l'optimiseur peut sourcer ses propres prévisions — il est libre).
### `loads[].telemetry` — champs ETM non encore normalisés
**Mécanisme et domaine**
Trois champs ajoutés par l'implémentation ETM et **absents des exemples ci-dessus**.
Ils sont documentés ici parce qu'ils changent la lecture du contexte, et qu'une
implémentation d'optimiseur qui les ignore prendra de mauvaises décisions.
- `adapter` — mécanisme d'actionnement, valeurs **du code** (`LoadConfig::adapter`) :
`relay-router` (combinaison de relais, consigne en watts), `etmvariableload` (consigne
continue en watts), `sg-ready`, `evcharger` ; `battery` réservé (annexe F).
- `domain` — intention déclarée par l'installateur, **jamais déduite du mécanisme**,
**non lue par l'arbitre** : `ev` | `ecs` | `heating` | `hvac` | `battery`, ou absent.
- Une PAC qui assure aussi l'ECS sans ballon séparé = **une seule charge** (`heating`).
- Un décideur ne doit **jamais** raisonner sur `adapter` pour savoir ce que mesure une
charge : c'est `measurement.source` qui le dit (LM-1106).
**Groupes (optionnel)**
- `group` = `{groupRank, rankInGroup}` : **clé de tri**, jamais détenteur de budget
(`specs/spec_loadmodel.md` LM-500). `priority` est le tri total résultant, calculé par le
plugin. Absent tant que les groupes ne sont pas implémentés (rang global unique).
**Besoins (`needs`)**
- `energyTargets` : énergie minimale à avoir délivrée à une échéance. Forme générique
(ECS, VE converti en Wh par le plugin). Plusieurs cibles possibles.
- `comfort` : `ecoMinC` doit **toujours** être tenu ; `comfortC` seulement sur surplus
(`specs/spec_loadmodel.md` §10, LM-1010).
- `source` de chaque besoin : `installer` | `user-intent` | `learned`. Une intention client
prime sur un besoin appris.
- Champs 1.0 (`dailyDeadline`, `minEnergyWhPerDay`, `targetSocPercent`, `deadline`) restent
émis ; un optimiseur 1.1 préfère `energyTargets`.
**VE**
- La priorité porte sur la **borne**, pas sur le véhicule.
- `telemetry.vehicle` (optionnel) : `{"socPercent":54,"source":"myskoda","capacityWh":77000}`
quand une intégration véhicule existe ; absent sinon.
### `loads[].telemetry` — champs ETM
Ils changent la lecture du contexte : un optimiseur qui les ignore prendra de mauvaises
décisions.
| Champ | Type | Sens |
|---|---|---|
| `available` | bool | **Gouverne l'ALLOCATION, pas la COMPTABILITÉ.** Faux ⇒ la charge ne doit recevoir **aucune** consigne neuve — mais elle **continue d'être comptée** pour ce qu'elle consomme via `currentPowerW`. Un optimiseur qui se contenterait de « sauter » les charges indisponibles sauterait aussi leur comptabilité, et offrirait aux charges suivantes un budget qui n'existe pas. |
| `lockMinPowerW` | W | Plancher imposé **maintenant** par un verrou anti-rebond : puissance déjà engagée que l'adaptateur ne peut pas couper. Le budget DOIT être décrémenté de cette valeur **même si elle dépasse le budget disponible** — sinon les charges de rang suivant reçoivent un résidu surestimé et l'installation soutire au réseau. |
| `available` | bool | **Gouverne l'ALLOCATION, pas la COMPTABILITÉ.** Faux ⇒ la charge ne doit recevoir **aucune** consigne neuve — mais elle **continue d'être comptée** pour ce qu'elle consomme via `currentPowerW`. Sauter une charge indisponible sans la compter offrirait aux suivantes un budget qui n'existe pas. |
| `lockMinPowerW` | W | Plancher imposé **maintenant** par un verrou anti-rebond : puissance déjà engagée que l'adaptateur ne peut pas couper. Le budget DOIT être décrémenté de cette valeur **même si elle dépasse le budget disponible**. |
| `lockMaxPowerW` | W | Plafond imposé **maintenant** par un verrou : au-delà, l'adaptateur refusera de démarrer. **Négatif ⇒ aucun plafond** (et non « plafond nul »). |
| `minState` / `maxState` | int | SG-Ready : fenêtre d'états autorisée **maintenant** par `minStateHold`. Gel total (`minState == maxState == state`) tant que le verrou court. |
| `measurement` | objet | `{source, powerW}` — `source` : `meter` (compteur dédié désigné) \| `device` (l'appareil piloté mesure) \| `none` (rien ne mesure : état positif, pas une erreur). **Une puissance déclarée n'est jamais une mesure.** Sémantique : `INTERFACE.md`, `specs/spec_loadmodel.md` LM-1105/1106. |
Une charge en défaut d'écriture est **figée** : `available = false` et
`lockMinPowerW == lockMaxPowerW ==` la puissance crue engagée. Le plancher n'est
jamais supérieur au plafond — un tel couple ferait dérailler tout écrêtage.
jamais supérieur au plafond.
Ces champs sont exprimés **en watts**, jamais en index de palier : aucun
identifiant de relais ni index de combinaison ne franchit cette frontière.
Ces champs sont exprimés **en watts**, jamais en index de palier : aucun identifiant de
relais ni index de combinaison ne franchit cette frontière.
## 6. Réponse : plan par créneaux
```json
{"jsonrpc":"2.0","id":42,"result":{
"planId":"2026-06-07T13:42:00Z-a1",
"strategy":"etm-optimizer-predict",
"strategy":"etm-predict",
"computedAt":"2026-06-07T13:41:12+02:00",
"validUntil":"2026-06-07T14:15:00+02:00",
"slots":[
{
"from":"2026-06-07T13:42:00+02:00","to":"2026-06-07T14:00:00+02:00",
"actions":[
{"loadId":"ev-1","kind":"setpoint","currentA":10,
"reason":"Surplus PV 2,3 kW → VE prioritaire (cible 80% à 07:00)"},
{"loadId":"ecs-1","kind":"stage","stage":1,
"reason":"Reliquat 0,9 kW → ECS palier 1 (besoin estimé 6,4 kWh/j, en apprentissage)"},
{"loadId":"bat-1","kind":"constraint","charge":"allow","discharge":"forbid",
"reason":"Jour ROUGE demain : décharge bloquée, on préserve le SoC"}
]
},
{
"from":"2026-06-07T22:00:00+02:00","to":"2026-06-08T06:00:00+02:00",
"actions":[
{"loadId":"bat-1","kind":"setpoint","powerW":3000,"source":"grid",
"reason":"Charge réseau HC avant jour ROUGE (plafond 3 kW : abonnement 12 kVA)"}
]
"reason":"Surplus PV 2,3 kW → VE prioritaire (cible 80% à 07:00)",
"reasonCode":"SURPLUS_SETPOINT","reasonParams":{"surplusW":2300}},
{"loadId":"chauffe-eau","kind":"setpoint","powerW":1200,
"reason":"Reliquat 1,2 kW → chauffe-eau (4 kWh requis avant 18h)"}
],
"expected":{"gridImportW":0,"gridExportW":350}
}
]
],
"diagnostics":{"solver":"highs","status":"optimal","gapPercent":0.0,"computeMs":2140,
"inputsUsed":{"pv":"physics","baseload":"none","price":"tempo"}}
}}
```
@ -221,20 +395,41 @@ Règles d'exécution côté plugin :
(affichage app : « prévision programmée ») et sera re-demandé au cycle suivant.
- Plan vide ou sans créneau courant = abstention (voir §7).
- Toute action est **écrêtée** : bornes min/max de l'adaptateur, verrous temporels,
protection de surcharge. Une action écrêtée est exécutée au plus proche possible
et loggée (`clamped`).
- `reason` absent ou vide → action **rejetée**, log d'avertissement. (Exigence
decisionReason.)
protection de surcharge (L4 et son extension au waterfall). Une action écrêtée est
exécutée au plus proche possible et loggée (`clamped`).
- `reason` absent ou vide → action **rejetée**, log d'avertissement.
- Un rule-based interne répond au même contrat avec un plan à un seul créneau.
- **Le contournement de verrou (`force`) n'existe pas sur le fil.** Il est réservé au repli
de sécurité interne (L2) ; un champ `force` reçu du socket est ignoré.
- **`validUntil`** : au-delà, le plan est **expiré** → repli rule-based jusqu'au prochain
plan valide. Absent = plan valable tant qu'un créneau couvre `now`.
- **`expected`** (optionnel, par créneau) : trajectoire prévue. Affichage et mesure d'écart
plan/réalité ; **jamais utilisé pour commander**.
- **`diagnostics`** (optionnel) : `status` `optimal` | `feasible` | `best-so-far` (calcul
interrompu, meilleur plan trouvé) | `infeasible-relaxed` (besoins non tenus nommés dans
les `reason`) ; `computeMs`, `gapPercent`, `solver` affichés en mode installateur et
enregistrés dans les traces. `inputsUsed.pv` peut valoir `hybrid` si l'optimiseur a
appliqué sa propre correction apprise.
**Explications et langue**
- Sur le socket, `reason` (texte, français) est **obligatoire** : il sert aux traces et au
mode installateur.
- `reasonCode` + `reasonParams` (optionnels) : code stable du catalogue `DecisionCode`
(`etm/types/decisionreason.h`, rendu documenté dans `INTERFACE.md`) et ses paramètres.
- **Vers l'app, seul `{reasonCode, reasonParams}` est transmis** — jamais le texte libre
(`AGENTS.md` invariant 12). Une action sans `reasonCode` connu du catalogue est
transmise sous le code générique `OPTIMIZER_DECISION`, sans texte.
### Types d'action (`kind`)
| `kind` | Charges | Champs | Exemple |
| `kind` | Mécanismes | Champs | Exemple |
|---|---|---|---|
| `setpoint` | evcharger, battery | `currentA` ou `powerW` (+ `source:"grid"` pour charge réseau batterie) | borne à 10 A ; batterie +3000 W depuis réseau |
| `stage` | relay-stages (ECS…) | `stage` (entier, parmi `declared.stages`) | ECS palier 1 (1200 W) |
| `setpoint` | evcharger, relay-router, etmvariableload | `currentA` (borne) ou `powerW` | borne à 10 A ; chauffe-eau à 1200 W |
| `state` | sg-ready | `state` (1=verrouillage, 2=normal, 3=recommandation, 4=forcé) | PAC en état 3 |
| `constraint` | battery (v1) | `charge`/`discharge` : `allow`\|`forbid` | décharge interdite |
| `constraint` | — (réservé batterie, annexe F) | `charge`/`discharge` : `allow`\|`forbid` | — |
La frontière est en **watts** : `relay-router` choisit lui-même la combinaison de relais,
`etmvariableload` reçoit une consigne continue. Aucun index de palier, aucun pourcentage.
## 7. Abstention et repli
@ -246,18 +441,17 @@ L'optimiseur peut répondre :
Sémantique : « je n'ai pas de plan à proposer maintenant ». Le plugin bascule sur la
stratégie rule-based **pour ce cycle**, sans considérer l'optimiseur comme mort
(`optimizerAlive` reste `true`). C'est le comportement attendu pendant un recalcul,
un démarrage à froid, ou une absence de données.
Tableau de repli complet :
(`optimizerAlive` reste `true`).
| Situation | optimizerAlive | Stratégie active |
|---|---|---|
| Réponse plan valide | true | optimiseur |
| `abstain` | true | rules (ce cycle) |
| Plan expiré (`validUntil` dépassé) | true | rules jusqu'au prochain plan valide |
| Timeout / erreur / plan invalide ×2 | false | rules |
| Socket absent / fermé | false | rules |
| `optimizerExpected=false` (tier Community) | false | rules |
| Mode dégradé L2 (compteur muet > 90 s) | inchangé | **aucune** — planification suspendue, repli de sécurité |
L'objet capabilities exposé à l'app (`tier`, `optimizerExpected`, `optimizerAlive`,
`activeStrategy`) reflète cet état en continu ; tout changement émet une notification.
@ -267,33 +461,31 @@ L'objet capabilities exposé à l'app (`tier`, `optimizerExpected`, `optimizerAl
- L'optimiseur peut renvoyer ses estimations (`learned` + `confidence`) dans une
notification `UpdateLearned` (sans id, fire-and-forget) ; le plugin les persiste
et les retransmet dans les contextes suivants.
- Tant que `confidence < 0.7` (seuil indicatif), les `reason` concernés mentionnent
l'apprentissage (« besoin estimé …, en apprentissage ») et l'app peut afficher un
badge « profil en apprentissage » sur la charge.
- Les données d'apprentissage restent **locales** (buffer circulaire, ~2 mois,
écrasement). Rien ne quitte le site. C'est un engagement produit, pas un détail.
- Tant que `confidence < 0.7` (seuil indicatif), l'app peut afficher un badge
« profil en apprentissage ».
- Les données d'apprentissage restent **locales**. Rien ne quitte le site.
- L'état des modèles de prévision (`forecast.pv.conversion`, `forecast.pv.corrections`,
`forecast.baseload.model`) est exposé dans l'app au même titre que `confidence`.
## 9. Versionnement
- `protocolVersion` en semver. Même majeure = compatible.
- Champs inconnus dans `params`/`result` : **ignorés silencieusement** (tolérance
ascendante). Ajouts mineurs = nouveaux champs optionnels.
- Changements incompatibles (renommage, sémantique) = majeure + entrée changelog
dans ce fichier.
- Champs inconnus dans `params`/`result` : **ignorés silencieusement**.
- Changements incompatibles (renommage, sémantique) = majeure + entrée changelog §0.
---
## Annexe A — Session minimale
```
→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.0","engine":"etm/0.9"}}
← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.0","name":"mon-optimiseur","planning":false}}
→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.1","engine":"etm/0.9"}}
← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.1","name":"mon-optimiseur","planning":false}}
→ {"jsonrpc":"2.0","id":2,"method":"GetPlan","params":{ ...SurplusContext... }}
← {"jsonrpc":"2.0","id":2,"result":{"planId":"x","strategy":"custom","slots":[{ "from":"...","to":"...","actions":[...] }]}}
```
Un optimiseur conforme minimal tient en ~50 lignes de Python : répondre au handshake,
renvoyer un plan à un créneau (ou `abstain`). C'est le point d'entrée communautaire.
renvoyer un plan à un créneau (ou `abstain`).
## Annexe B — Exemple : brancher Akkudoktor-EOS
@ -305,10 +497,9 @@ où l'on veut sur le LAN :
plugin (client) ──tcp://nas:7600──► eos-bridge ──HTTP──► serveur EOS
```
Le pont : (1) répond au Handshake ; (2) traduit `SurplusContext` → payload EOS
(séries horaires PV/conso/prix — il peut compléter avec les prévisions Akkudoktor) ;
(3) traduit le plan EOS → `slots[]`/`LoadAction` ; (4) sert le plan en cache et
recalcule en arrière-plan (EOS est lent, le protocole exige des réponses rapides) ;
Le pont : (1) répond au Handshake ; (2) traduit `SurplusContext` → payload EOS —
`forecast` fournit PV, consommation de base et prix sur grille commune ; (3) traduit le
plan EOS → `slots[]`/actions ; (4) sert le plan en cache et recalcule en arrière-plan ;
(5) répond `abstain` pendant un recalcul initial.
Les bornes de sécurité du plugin s'appliquent intégralement : un plan EOS aberrant
@ -316,13 +507,54 @@ est écrêté, pas exécuté aveuglément.
## Annexe C — Priorités : modèle utilisateur
- **Liste ordonnée globale** (drag-and-drop dans l'app) : l'ordre de service du
surplus. Le rule-based l'exécute en cascade (waterfall) ; un planificateur s'en
sert comme préférence quand tout ne peut pas être servi.
- **Besoins par charge** (fiche équipement) : échéances (`deadline`,
`dailyDeadline`), minimums (`minEnergyWhPerDay`), cible (`targetSocPercent`).
- **Promotions conditionnelles** : une échéance qui approche ou un signal tarifaire
(Tempo ROUGE) peut faire passer une charge devant la liste — toujours expliqué
dans `reason`.
- **Liste ordonnée** (drag-and-drop dans l'app) : l'ordre de service du surplus. Le
rule-based l'exécute en cascade (waterfall) ; un planificateur s'en sert comme
préférence quand tout ne peut pas être servi.
- **Groupes** : déplacer une famille entière sans renuméroter (`spec_loadmodel.md` §5).
Un groupe trie, il ne reçoit pas de budget.
- **Besoins par charge** : échéances, minimums, cible, `energyTargets`, `comfort`.
- **Intentions client** : deviennent des besoins `source: "user-intent"` — l'optimiseur
ré-arbitre avec, il n'est pas contourné.
- **Promotions conditionnelles** : une échéance qui approche ou un signal tarifaire peut
faire passer une charge devant — toujours expliqué.
Pas de poids, pas de pourcentages : un ordre, des besoins, des exceptions expliquées.
## Annexe D — Traces et rejeu
But : comparer des optimiseurs **sur les mêmes entrées** et rejouer un incident.
- Option plugin `optimizerTrace` (défaut : désactivée en production, activée sur banc).
- Fichiers NDJSON locaux, rotation par jour, rétention configurable (défaut 14 jours), sous
le répertoire de stockage du moteur (`NymeaSettings::storagePath()`).
- Une ligne par cycle :
```json
{"t":"2026-10-12T13:42:00+02:00","request":{ "...GetPlan params..." },
"response":{ "...result ou abstain..." },"executed":[{"loadId":"chauffe-eau","kind":"setpoint",
"powerW":1200,"clamped":false}],"strategy":"optimizer","latencyMs":12}
```
- **Rejeu** : hems-sim relit `request`, l'envoie à un optimiseur candidat, compare
`response` + `diagnostics`.
- Les traces restent sur le site. Export manuel par l'installateur uniquement.
## Annexe E — Optimiseur de référence
`tools/ref-optimizer/` : serveur Python minimal (bibliothèque standard seule) conforme 1.1,
répondant un plan à un créneau qui reproduit le waterfall rule-based. Usages : test de bout
en bout du `SocketScheduler` ; point de comparaison du banc ; exemple communautaire.
## Annexe F — Champs réservés (non normatifs)
Ces champs sont **réservés** : aucun porteur n'existe dans le moteur (pas de
`BatteryAdapter`, lot 3f). Un optimiseur ne doit pas les émettre ; le plugin les ignore.
Ils deviendront normatifs avec le lot qui les implémente.
- Adaptateur `battery` ; kind `constraint` (`charge`/`discharge` : `allow`|`forbid`) ;
`setpoint` batterie avec `source:"grid"` (charge réseau).
- `loads[].socTarget` `{percent, by, source}` : cible de SOC comme participant à la
priorisation.
- `loads[].policies` : `noDischargeWhileEvCharging`, `holdChargeForDomains`,
`gridChargeAllowed`.
- `expected.batterySocPercent`.