etm-powersync-energy-plugin.../docs/OPTIMIZER_PROTOCOL.md
Patrick Schurig 92d0bef4ac fix(etm): ECS-410 — échec d'écriture relais, échelle bornée et défaut collant
writeRelay() jetait le ThingActionInfo* : aucun acquittement, aucun retour
arrière, available codé en dur à true, et m_currentStage mis à jour comme si tout
avait réussi — on annonçait une puissance non appliquée.

MODÈLE ASYNCHRONE. executeAction est asynchrone et update() ne doit jamais
attendre (AGENTS règle 5). « Attendre le résultat » ne veut donc pas dire bloquer
le cycle : l'écriture est émise, l'adaptateur retient combien d'acquittements il
attend (m_pending), le verdict tombe quand le compteur retombe à zéro, et la
conséquence est traitée au cycle suivant. Motif repris tel quel d'EvCharger
(evcharger.cpp:293-299), y compris `this` en contexte de connexion — si
l'adaptateur meurt, les callbacks sont coupés proprement.

INDÉTERMINATION. Pendant une transition, telemetry() annonce
max(m_stagePrev, m_stageTarget) : on ne sait pas ce qui est fermé, on annonce donc
la plus haute des deux puissances possibles. Même direction qu'ECS-411 (relais
injoignable supposé fermé) — ne jamais annoncer moins que ce qui peut être
appliqué. Sous-estimer fait sur-allouer les charges suivantes ; surestimer ne fait
que retarder une montée.

ÉCHELLE BORNÉE à trois barreaux, une tentative chacun, aucune boucle : cible →
retour arrière → arrêt total → défaut. Le retour arrière est asynchrone au même
titre et passe par le même compteur.

DÉFAUT COLLANT, pas clignotant. m_faulted est un verrou posé une seule fois, sans
délai ni expiration : available ne peut pas osciller d'un cycle à l'autre. Seul
NymeaEnergy.ClearLoadFault le lève — acte délibéré et journalisé de l'opérateur.
La reconstruction le lève aussi, mais par construction : un adaptateur neuf n'a
pas d'historique. À la levée, l'état matériel est RELU (ECS-411), pas supposé.

CANAL OUVERT. LoadContext n'avait AUCUN champ available : le publier aurait été
décoratif. Ajouté à LoadContextTelemetry, avec sa sémantique écrite noir sur
blanc — il gouverne l'allocation, PAS la comptabilité. Une charge en défaut ne
reçoit rien mais reste comptée : une puissance qu'on ne sait plus couper est de la
conso fixe, au même titre que la base de la maison. Le figeage est porté par
lockMin == lockMax == puissance crue engagée, jamais un plafond nul sous un
plancher non nul. Le même canal servira ECS-601.

OPTIMIZER_PROTOCOL.md mis à jour dans le MÊME lot, comme l'exige le §11 de la
spec : available, lockMinPowerW et lockMaxPowerW documentés avec leur sémantique.

clearFault() est PURE VIRTUELLE sur ILoadAdapter : les trois autres adaptateurs la
déclarent sans effet plutôt que d'hériter d'un défaut vide. Leur généralisation est
portée par ECS-414.

Test testEcsPartialFailure : cas nominal sans défaut, puis échelle complète via un
relais introuvable — défaut atteint, relais valide bien ramené à l'ouverture par la
tentative d'arrêt total, available faux, plancher == plafond == puissance comptée,
plus aucune commande une heure plus tard, puis levée délibérée.

Build amd64 0 erreur. Simulation : 16/16.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:32:51 +02:00

311 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# OPTIMIZER_PROTOCOL — v1 (draft)
Contrat entre le moteur d'énergie **etm-powersync-energy-plugin-etm** (couche A, GPL-3.0)
et un **optimiseur** externe (couche B : Héos, 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`).
---
## 1. Philosophie
- **Le plugin ne fait pas confiance à l'optimiseur.** Tout plan reçu est écrêté par les
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.
- **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.
## 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 (Héos).
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).
- Protocole applicatif : **JSON-RPC 2.0, un message par ligne (NDJSON), UTF-8.**
- Le contrat est transport-agnostique : mêmes messages sur unix et tcp.
- Reconnexion : backoff exponentiel (1 s → 60 s max). Pendant la déconnexion :
stratégie de repli active, `optimizerAlive = false`.
## 3. Handshake
À chaque (re)connexion, le plugin envoie :
```json
{"jsonrpc":"2.0","id":1,"method":"Handshake","params":{
"protocolVersion":"1.0",
"engine":"etm-powersync-energy-plugin-etm/<version>",
"token":"<secret partagé, optionnel — requis si configuré côté plugin>"
}}
```
Réponse attendue :
```json
{"jsonrpc":"2.0","id":1,"result":{
"protocolVersion":"1.0",
"name":"heos",
"planning":true
}}
```
- `protocolVersion` : même **majeure** requise. Majeure différente → le plugin refuse,
log explicite, repli rule-based.
- `planning` : `true` si l'optimiseur produit des plans multi-créneaux ; `false` s'il
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://`.
- 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).
- **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`.
- 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.
## 5. Requête : `GetPlan(SurplusContext)`
```json
{"jsonrpc":"2.0","id":42,"method":"GetPlan","params":{
"timestamp":"2026-06-07T13:42:00+02:00",
"site":{
"contractedPowerW":12000,
"gridCapW":null,
"phaseLimitA":[63,63,63]
},
"meter":{"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" },
"tariff":{
"provider":"tempo","current":{"label":"HP_BLEU","priceCtkWh":16.1},
"next":[{"from":"2026-06-08T06:00","label":"ROUGE","priceCtkWh":75.6}]
}
}}
```
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` (01). 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[].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
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.
| 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. |
| `lockMaxPowerW` | W | Plafond imposé **maintenant** par un verrou : au-delà, l'adaptateur refusera de démarrer. **Négatif ⇒ aucun plafond** (et non « plafond nul »). |
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.
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":"heos-predict",
"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)"}
]
}
]
}}
```
Règles d'exécution côté plugin :
- **Seul le créneau couvrant `now` est exécuté.** Le reste du plan est informatif
(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.)
- Un rule-based interne répond au même contrat avec un plan à un seul créneau.
### Types d'action (`kind`)
| `kind` | Charges | 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) |
| `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 |
## 7. Abstention et repli
L'optimiseur peut répondre :
```json
{"jsonrpc":"2.0","id":42,"result":{"abstain":true,"reason":"recalcul en cours"}}
```
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 :
| Situation | optimizerAlive | Stratégie active |
|---|---|---|
| Réponse plan valide | true | optimiseur |
| `abstain` | true | rules (ce cycle) |
| Timeout / erreur / plan invalide ×2 | false | rules |
| Socket absent / fermé | false | rules |
| `optimizerExpected=false` (tier Community) | false | rules |
L'objet capabilities exposé à l'app (`tier`, `optimizerExpected`, `optimizerAlive`,
`activeStrategy`) reflète cet état en continu ; tout changement émet une notification.
## 8. Apprentissage et transparence
- 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.
## 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.
---
## 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":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.
## Annexe B — Exemple : brancher Akkudoktor-EOS
EOS (github.com/Akkudoktor-EOS/EOS) est un optimiseur-planificateur HTTP (Python,
algorithme génétique, horizon 48 h). Intégration par un **pont** (~200 lignes) tournant
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) ;
(5) répond `abstain` pendant un recalcul initial.
Les bornes de sécurité du plugin s'appliquent intégralement : un plan EOS aberrant
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`.
Pas de poids, pas de pourcentages : un ordre, des besoins, des exceptions expliquées.