etm-powersync-energy-plugin.../docs/OPTIMIZER_PROTOCOL.md
Patrick Schurig afaa8c2674 docs(protocole): dire ce que OPTIMIZER_PROTOCOL n'est pas
Ce document décrit le lien moteur ↔ optimiseur sur socket NDJSON. Il ne décrit pas la
frontière JSON-RPC de l'app, et un brief a pourtant envoyé l'agent app y chercher le
namespace de LoadConfig — qui n'y est pas et n'y sera pas. La confusion a coûté une
session : les deux protocoles portent du JSON-RPC, c'est leur seul point commun.

Note de périmètre en tête, avec le tableau des deux frontières et le renvoi vers
INTERFACE.md / nymeaenergyjsonhandler.cpp / JSONRPC.Introspect.

Relevé côté app, en corrigeant CLAUDE.md contre Introspect (plugin 1.15.2+etm15).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-25 21:28:24 +02:00

16 KiB
Raw Blame History

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

⚠️ 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 Flutter, et n'a jamais eu vocation à le dire.

La frontière app ↔ box, c'est NymeaEnergy.*, documentée dans INTERFACE.md et faisant 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.

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

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 :

{"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 :

{"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)

{"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 (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[].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

{"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 :

{"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.