etm-powersync-energy-plugin.../docs/OPTIMIZER_PROTOCOL.md

292 lines
13 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).
## 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.