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

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


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 (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

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