etm-powersync-energy-plugin.../docs/OPTIMIZER_PROTOCOL.md
Patrick Schurig 2f191f4bec docs(protocole): messages générés depuis le contrat, prose réalignée sur le code
32 blocs générés (§3, §5.1-§5.3, §6, §7, §8) ; annexe G (contrat, validateur,
conformité). Écarts prose/code corrigés, le contrat suivant le code : refus de
token par réponse error, optimizerAlive inchangé sur abstention, exemples du
§5 sans les champs jamais émis (provider, perField détaillé, weather, comfort,
defaults, group, nominalPowerW, volumeL) et avec ceux qui le sont (available,
currentPowerW), instants avec décalage, engine réel en annexe A ; §5.2 aligné
sur la décision du 2026-10-16 (l'optimiseur ne consomme que le contexte).
Contrat : listes régénérées depuis le code (ordre des clés de x-etm-reason-params).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:34:17 +02:00

74 KiB
Raw Blame History

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 : 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 : v1.1 — figé à la fin de la beta. Versionné en semver (protocolVersion).

La forme est un contrat MACHINE

La forme des messages — champs, types, obligatoire, valeurs permises, et ce que le validateur du moteur refuse en plus — est écrite dans docs/etm-powersync-optimizer-protocol.openrpc.json (OpenRPC 1.3). Il fait foi sur la forme ; ce document fait foi sur le sens. Le moteur l'embarque et le sert (NymeaEnergy.GetOptimizerProtocol, INTERFACE.md) ; les deux côtés sont testés contre lui (annexe G). Les tableaux entre balises BEGIN GENERATED / END GENERATED ci-dessous sont produits depuis le contrat par tools/etm-powersync-gen-protocol-doc (--check en CI) : ne pas les éditer à la main. Les exemples JSON, eux, restent illustratifs.

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.

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

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.

Correction avant publication (2026-10-14) : price.source hphc devient tou (zones horaires saisies, N zones et plusieurs plages par zone — HP/HC en est un cas) ; hphc n'a jamais été émis. baseload.quality est ajouté (optionnel).

Correction avant publication (2026-10-13) : conversion n'admet plus fallback. La valeur avait été écrite sans définition ; aucun émetteur ne l'a jamais produite. Seule physics est émise ; une prévision dégradée se lit dans corrections.

  • §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).
  • Contrat machine (2026-10-17, sans changement de fil) : la forme des messages devient le document OpenRPC docs/etm-powersync-optimizer-protocol.openrpc.json, servi par le moteur, tableaux de ce document générés depuis lui ; annexe G. Les exemples du §5 ont été réalignés sur ce que le moteur émet réellement (champs jamais émis signalés comme tels).

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

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. 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. 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.1",
  "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.1",
  "name":"etm-optimizer",
  "planning":true,
  "capabilities":{
    "maxHorizonH":48,
    "resolutionS":3600,
    "kinds":["setpoint","state","constraint"],
    "usesForecast":["pv","baseload","price","temperature"],
    "reasonCodes":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 → réponse error (code -32001 recommandé ; le moteur traite toute réponse error au handshake comme un refus, handshake-refused) puis connexion fermée. Recommandé pour tout endpoint tcp://. Forme (E-T2-8) : chaîne de 16 caractères au moins et de 4 096 octets au plus, imprimable, sans blanc ni caractère de contrôle (UTF-8 admis). Le plugin refuse d'en poser un plus court, et ne se connecte pas avec un fichier de token plus court (ni sans token) : il masque toute occurrence du token dans ce que l'optimiseur renvoie, et un token court corromprait un identifiant qui le contiendrait.
  • 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. Invalide : result non objet, protocolVersion absent ou vide, capabilities présent mais non objet (invalid-handshake) ; majeure différente (major-mismatch).
  • name : optionnel, informatif, affiché en mode installateur ; jamais contrôlé.

Handshake — params

Champ Type Obligatoire Valeurs Sens
protocolVersion ProtocolVersion oui "1.1" Version du moteur ; seule la majeure est comparée.
engine string oui motif ^etm-powersync-energy-plugin-etm/ Moteur et sa version.
token string non ≥ 16 car. Secret partagé (§3, E-T2-8) : 16 caractères au moins, 4 096 octets au plus (borne en octets non exprimable ici : contrôlée par le moteur), imprimable, sans blanc ni caractère de contrôle, UTF-8 admis. Envoyé seulement s'il est configuré ; jamais journalisé.

Champs non déclarés : refusés (forme émise par le moteur, fermée).

HandshakeResult

Champ Type Obligatoire Valeurs Sens
protocolVersion string oui ≥ 1 car. ; motif ^[0-9]+(\.[0-9]+)*$ Version semver de l'optimiseur ; même MAJEURE que le moteur exigée (règle major-mismatch).
name string non Nom affiché (mode installateur), non contrôlé par le moteur (E-T7-3).
planning boolean non Vrai si l'optimiseur produit des plans multi-créneaux. Informatif.
capabilities Capabilities non

Champs non déclarés : ignorés (§9).

Capabilities — Capacités annoncées (§3, 1.1, optionnel). Affichées en mode installateur, réduites aux clés et valeurs connues (capacitesPubliees) ; un champ inconnu est ignoré (§9).

Champ Type Obligatoire Valeurs Sens
maxHorizonH number non ≥ 0 Horizon maximal planifié, en heures.
resolutionS number non ≥ 0 Pas des créneaux, en secondes.
kinds [string] non setpoint · state · constraint PRÉSENT, il restreint les kinds acceptés (règle kind-not-announced).
usesForecast [string] non pv · baseload · price · temperature
reasonCodes boolean non L'optimiseur émet des reasonCode du catalogue.

Champs non déclarés : ignorés (§9).

4. Cycle et heartbeat

  • Le plugin appelle GetPlan à chaque cycle d'optimisation (~1/min) et sur événement significatif — voir trigger, §5.1.
  • Le heartbeat est implicite : une réponse valide à GetPlan = optimiseur vivant. Deux requêtes consécutives en échec (timeout par requête : 10 s, erreur, réponse illisible, plan invalide) → optimizerAlive = false, repli rule-based, notification NymeaEnergy.OptimizerStatusChanged. Le compte est par requête, pas par cycle : une requête remplacée par une plus récente avant sa réponse n'est pas un échec.
  • 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. 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é

{"jsonrpc":"2.0","id":42,"method":"GetPlan","params":{
  "timestamp":"2026-10-12T13:42:00+02:00",
  "trigger":"cycle",
  "site":{
    "contractedPowerW":12000,
    "gridCapW":null,
    "phaseLimitA":[63,63,63]
  },
  "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":[ "... §5.3 ..." ],
  "forecast":{ "... §5.2 ... ou null" },
  "tariff":{
    "provider":"tempo","current":{"label":"HP_BLEU","priceCtkWh":16.1},
    "next":[{"from":"2026-06-08T06:00:00+02:00","label":"ROUGE","priceCtkWh":75.6}]
  }
}}
  • 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.
  • pv.currentW (1.1) : production instantanée du site en W, positive, telle que l'additionne l'EnergyManager de nymea (|currentPowerProduction|). Publiée dès qu'au moins un Thing smartmeterproducer est configuré — onduleur communicant (solarinverter, qui l'étend) ou compteur de production posé sur un onduleur non communicant. Sans producteur : null, jamais 0 (0 W ne distinguerait pas la nuit de l'absence de PV). Un seul producteur déclaré par source PV : nymea additionne tous les producteurs, et un onduleur communicant doublé d'un compteur de production sur la même chaîne compterait la même énergie deux fois.
  • 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. Aucun écrivain aujourd'hui (E3) : le moteur 1.1 émet tariff: null.

GetPlan — params

Champ Type Obligatoire Valeurs Sens
timestamp string oui instant avec décalage Instant du cycle — le « maintenant » du validateur.
trigger string oui cycle · forecast · config · load-change · intent Cause de la requête (§5.1). GEN : SocketScheduler::knownTriggers() — du moins au plus significatif.
site Site oui
meter Meter oui
pv Pv oui
battery Battery oui
loads [Load] oui Charges arbitrées, ⊆ GetLoadConfig.
forecast Forecast | null oui null sans instantané de prévision.
tariff Tariff | null oui

Champs non déclarés : refusés (forme émise par le moteur, fermée).

Site

Champ Type Obligatoire Valeurs Sens
contractedPowerW number | null oui Puissance souscrite (W) ; null si inconnue (E1), jamais 0.
gridCapW null oui RÉSERVÉ (signaux réseau type §14a EnWG) : toujours null en 1.1.
phaseLimitA [number] | null oui Limite par phase en A ; null si inconnue.

Champs non déclarés : refusés (forme émise par le moteur, fermée).

Meter

Champ Type Obligatoire Valeurs Sens
source string | null oui ThingId du compteur racine désigné ; null si inconnu.
importW number oui ≥ 0 Puissance importée (W).
exportW number oui ≥ 0 Puissance exportée (W), mesure BRUTE (AGENTS.md règle 8) — jamais le net après pilotage.
perPhaseA [number] oui Courant par phase (A).

Champs non déclarés : refusés (forme émise par le moteur, fermée).

Pv

Champ Type Obligatoire Valeurs Sens
currentW number | null oui ≥ 0 Production instantanée (W), positive ; null sans producteur (jamais 0).

Champs non déclarés : refusés (forme émise par le moteur, fermée).

BatteryAbsent — Aucun stockage déclaré : rien d'autre n'est émis.

Champ Type Obligatoire Valeurs Sens
present boolean oui false

Champs non déclarés : refusés (forme émise par le moteur, fermée).

BatteryPresent

Champ Type Obligatoire Valeurs Sens
present boolean oui true
socPercent number oui
powerW number oui Positif = charge, négatif = décharge.
capacityWh number | null oui null si inconnue.
maxChargeW number | null oui null si inconnue.
maxDischargeW number | null oui null si inconnue.
reserveSocPercent integer | null oui Réserve de secours (%) ; null si inconnue.

Champs non déclarés : refusés (forme émise par le moteur, fermée).

Tariff — Tarif 1.0, conservé pour compatibilité ; un optimiseur 1.1 lit forecast.price. Aucun écrivain aujourd'hui (E3) : null en pratique.

Champ Type Obligatoire Valeurs Sens
provider string | null oui
current TariffEntry oui
next [TariffNextEntry] oui

Champs non déclarés : refusés (forme émise par le moteur, fermée).

TariffEntry

Champ Type Obligatoire Valeurs Sens
label string oui
priceCtkWh number oui

Champs non déclarés : refusés (forme émise par le moteur, fermée).

TariffNextEntry

Champ Type Obligatoire Valeurs Sens
label string oui
priceCtkWh number oui
from string | null oui instant avec décalage Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée.

Champs non déclarés : refusés (forme émise par le moteur, fermée).

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

"forecast":{
  "start":"2026-10-12T14:00:00+02:00",
  "resolutionS":3600,
  "steps":34,
  "pv":{
    "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>","corrections":["temperature"]}
    ]
  },
  "baseload":{"model":"none","p50W":[null, ...]},
  "weather":null,
  "price":{
    "currency":"EUR","importCtkWh":[ ... ],"exportCtkWh":[ ... ],
    "includes":["energy","grid","taxes"],"source":"tou",
    "knownUntil":"2026-10-13T22:00:00Z"
  }
}

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 (seule valeur émise par le plugin GPL). Le champ reste pour des valeurs futures ; un consommateur ignore une valeur qu'il ne connaît pas plutôt que de la deviner. Une prévision dégradée (température absente, par exemple) ne se lit pas ici : elle se lit dans corrections ([] = aucune correction). 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.
      • corrections (1.1) : corrections déclarées par ce plan (jamais ac-limit, qui est une correction de site). Le corrections de pv est l'intersection de celles des plans, plus ac-limit si un plafond est configuré : une divergence entre plans se lit ici, dans les données — aucun booléen ne la résume.
  • 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.
    • profile (plugin GPL) : médiane par créneau local et par type de jour (ouvré / week-end) sur 28 jours, mesurée sur les échantillons d'une minute du bilan — jamais ses cumuls ; none sous 7 jours couverts. Forme et définition : INTERFACE.md, GetForecastSnapshot.
    • quality (1.1, optionnel) : codes et nombres — jours couverts, minutes comptées à la commande, minutes négatives, et par charge sa source et constantMeasurement.
  • 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, tou, tempo, day-ahead. exportCtkWh : tarif de revente ou 0.
    • fixed / tou : tarif saisi (une zone sur 24 h / zones horaires en heure locale) ; connu sur tout l'horizon, knownUntil = fin de la grille. Un créneau à cheval sur deux zones reçoit la moyenne pondérée par la durée — le coût exact d'une puissance constante sur le créneau, seule valeur cohérente avec les autres séries de la grille.
  • forecast peut être null. L'optimiseur ne consomme que ce que le moteur lui transmet (décision du 2026-10-16) : il n'appelle ni service météo ni fournisseur de tarif, et le plugin, lui, ne sort pas pour les obtenir.
  • Ce que le moteur 1.1 n'émet pas (décrit ici pour la suite, absent du contrat, donc refusé s'il apparaissait sans changement de contrat) : pv.provider (E25), pv.p10W/p90W, perField[].label/peakW/tiltDeg/azimuthDeg/p50W (seuls id et corrections sont émis), weather.temperatureC (weather vaut toujours null), baseload.model: learned, price.source tempo/day-ahead (seuls fixed et tou, tarif saisi).

Forecast — Prévisions sur UNE grille (§5.2) ; tableaux alignés index par index.

Champ Type Obligatoire Valeurs Sens
start string oui instant avec décalage Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée.
resolutionS integer oui ≥ 1 Pas de la grille (s) — fait foi, jamais supposé.
steps integer oui ≥ 0 Nombre de cases ; toutes les séries ont cette longueur.
pv PvForecast | null oui null sans courbe (site non désigné, charge utile refusée, canal muet).
baseload Baseload oui
weather null oui Aucune source de température en 1.1 : toujours null.
price Price | null oui null sans tarif saisi.

Champs non déclarés : refusés (forme émise par le moteur, fermée).

PvForecast

Champ Type Obligatoire Valeurs Sens
p50W [number | null] oui Puissance AC moyenne sur [t, t+pas), sommée sur les plans puis plafonnée à acLimitW ; null = case inconnue. Un plafond, pas une espérance (§5.2).
conversion string oui physics Seule valeur émise par le moteur GPL ; un consommateur ignore une valeur inconnue.
corrections [string] oui temperature · ac-limit [] = conversion brute.
weatherModel string | null oui Modèle qui a RÉPONDU ; null si inconnu.
issuedAt string | null oui instant avec décalage Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée.
contentChangedAt string | null oui instant avec décalage Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée.
acLimitW integer non Plafond AC du site (W), s'il est configuré.
perField [PvField] non

Champs non déclarés : refusés (forme émise par le moteur, fermée).

PvField

Champ Type Obligatoire Valeurs Sens
id string oui ThingId du plan.
corrections [string] oui temperature · ac-limit

Champs non déclarés : refusés (forme émise par le moteur, fermée).

Baseload

Champ Type Obligatoire Valeurs Sens
model string oui profile · none Le moteur GPL émet profile ou none (learned n'est pas émis).
p50W [number | null] oui
quality BaseloadQuality non

Champs non déclarés : refusés (forme émise par le moteur, fermée).

BaseloadQuality

Champ Type Obligatoire Valeurs Sens
windowDays integer oui
minDays integer oui
resolutionS integer oui
daysCovered object oui
daysCovered.workday integer oui
daysCovered.weekend integer oui
minutes integer oui
commandedMinutes integer oui
negativeMinutes integer oui
incompleteMinutes integer oui
computedAt string non instant avec décalage Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée.
loads [BaseloadLoadQuality] oui

Champs non déclarés : refusés (forme émise par le moteur, fermée).

BaseloadLoadQuality

Champ Type Obligatoire Valeurs Sens
loadId string oui
source string oui meter · device · none
minutesBySource object oui
constantMeasurement boolean non

Champs non déclarés : refusés (forme émise par le moteur, fermée).

Price

Champ Type Obligatoire Valeurs Sens
currency string oui "EUR"
importCtkWh [number | null] oui
exportCtkWh [number | null] oui
includes [string] oui energy · grid · taxes
source string oui fixed · tou Tarif SAISI : une zone (fixed) ou des zones horaires (tou). tempo et day-ahead ne sont pas émis en 1.1.
knownUntil string oui instant avec décalage Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée.

Champs non déclarés : refusés (forme émise par le moteur, fermée).

5.3 Charges (loads[])

"loads":[
  {
    "id":"chauffe-eau","adapter":"relay-router","label":"Chauffe-eau","domain":"ecs",
    "priority":2,
    "declared":{"minPowerW":0,"maxPowerW":2400},
    "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"}]
    },
    "limits":{"minOnS":300,"minOffS":300}
  },
  {
    "id":"ev-1","adapter":"evcharger","label":"Borne garage","domain":"ev",
    "declared":{"minA":6,"maxA":16,"phases":3},
    "telemetry":{"currentPowerW":4100,"available":true,"pluggedIn":true,"charging":true,"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":{"currentPowerW":0,"available":true,"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. Non émis en 1.1.
  • 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 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.

Mécanisme et domaine

  • 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). Non émis en 1.1 (aucune sonde de température n'entre dans le contexte).
  • source de chaque besoin : installer | user-intent | learned. Une intention client prime sur un besoin appris. Le moteur 1.1 émet installer (obligation quotidienne) et user-intent (dérogation à cible d'énergie) ; learned est réservé.
  • 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 — non émis en 1.1 (aucune intégration).
  • Une borne configurée dont l'appareil manque n'a ni declared, ni limits, ni pluggedIn/charging : ses zéros diraient « inconnu ». declared.phases porte les phases visées ce cycle, pas celles de la plaque (E26).

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

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.

Une charge arbitrée (§5.3) — la forme dépend du mécanisme (adapter).

LoadWatts — Charge pilotée en watts : combinaison de relais (relay-router) ou consigne continue (etmvariableload).

Champ Type Obligatoire Valeurs Sens
id string oui ≥ 1 car. Identifiant de la charge — clé des actions.
label string oui Nom lisible.
priority integer oui Rang effectif (1 = premier servi).
domain string non ecs · ev · heating · hvac · battery Intention déclarée par l'installateur, jamais déduite du mécanisme, non lue par l'arbitre (§5.3). GEN : LoadConfig::knownDomains().
learned LoadLearned non
needs LoadNeeds non
adapter string oui relay-router · etmvariableload
declared object oui
declared.minPowerW number oui 0 = aucun plancher.
declared.maxPowerW number oui
telemetry object oui
telemetry.currentPowerW number oui Puissance courante (W) telle que l'adaptateur la publie.
telemetry.available boolean oui Gouverne l'ALLOCATION, pas la COMPTABILITÉ (§5.3).
telemetry.measurement Measurement non Mesure par charge (§5.3, LM-1105/1106) : décider sur source, jamais sur adapter.
telemetry.lockMinPowerW number oui Plancher imposé MAINTENANT par un verrou.
telemetry.lockMaxPowerW number oui Plafond imposé maintenant ; négatif = aucun plafond.
limits object oui
limits.minOnS integer oui
limits.minOffS integer oui

Champs non déclarés : refusés (forme émise par le moteur, fermée).

LoadEvCharger — Borne. Une borne CONFIGURÉE dont l'appareil manque n'a ni declared, ni limits, ni pluggedIn/charging : ses zéros diraient « inconnu ».

Champ Type Obligatoire Valeurs Sens
id string oui ≥ 1 car. Identifiant de la charge — clé des actions.
label string oui Nom lisible.
priority integer oui Rang effectif (1 = premier servi).
domain string non ecs · ev · heating · hvac · battery Intention déclarée par l'installateur, jamais déduite du mécanisme, non lue par l'arbitre (§5.3). GEN : LoadConfig::knownDomains().
learned LoadLearned non
needs LoadNeeds non
adapter string oui "evcharger"
declared object non
declared.minA number oui
declared.maxA number oui
declared.phases integer oui Phases VISÉES ce cycle, pas celles de la plaque (E26).
telemetry object oui
telemetry.currentPowerW number oui Puissance courante (W) telle que l'adaptateur la publie.
telemetry.available boolean oui Gouverne l'ALLOCATION, pas la COMPTABILITÉ (§5.3).
telemetry.measurement Measurement non Mesure par charge (§5.3, LM-1105/1106) : décider sur source, jamais sur adapter.
telemetry.pluggedIn boolean non
telemetry.charging boolean non
telemetry.sessionWh number non Absent si la borne ne compte pas sa session (LM-1009 C1).
limits object non
limits.chargingEnabledLockS integer oui
limits.currentChangeLockS integer oui

Champs non déclarés : refusés (forme émise par le moteur, fermée).

LoadSgReady — PAC SG-Ready : 4 états normés, kind state.

Champ Type Obligatoire Valeurs Sens
id string oui ≥ 1 car. Identifiant de la charge — clé des actions.
label string oui Nom lisible.
priority integer oui Rang effectif (1 = premier servi).
domain string non ecs · ev · heating · hvac · battery Intention déclarée par l'installateur, jamais déduite du mécanisme, non lue par l'arbitre (§5.3). GEN : LoadConfig::knownDomains().
learned LoadLearned non
needs LoadNeeds non
adapter string oui "sg-ready"
declared object oui
declared.states [integer] oui ≥ 1 ; ≤ 4
declared.estimatedPowerW object oui
telemetry object oui
telemetry.currentPowerW number oui Puissance courante (W) telle que l'adaptateur la publie.
telemetry.available boolean oui Gouverne l'ALLOCATION, pas la COMPTABILITÉ (§5.3).
telemetry.measurement Measurement non Mesure par charge (§5.3, LM-1105/1106) : décider sur source, jamais sur adapter.
telemetry.state integer oui ≥ 0 ; ≤ 4 État commandé (1-4) ; 0 = non applicable.
telemetry.minState integer oui Fenêtre d'états autorisée MAINTENANT par minStateHold.
telemetry.maxState integer oui
limits object oui
limits.minStateHoldS integer oui

Champs non déclarés : refusés (forme émise par le moteur, fermée).

Mesure par charge (§5.3, LM-1105/1106) : décider sur source, jamais sur adapter.

Measurement, forme 1 — Rien ne mesure : état positif, pas une erreur.

Champ Type Obligatoire Valeurs Sens
source string oui "none"

Champs non déclarés : refusés (forme émise par le moteur, fermée).

Measurement, forme 2

Champ Type Obligatoire Valeurs Sens
source string oui meter · device
powerW number oui

Champs non déclarés : refusés (forme émise par le moteur, fermée).

LoadNeeds — Besoins (§5.3). Champs 1.0 émis tels quels quand ils sont déclarés ; un optimiseur 1.1 préfère energyTargets.

Champ Type Obligatoire Valeurs Sens
dailyDeadline string non motif ^[0-9]{2}:[0-9]{2}$ Heure murale locale HH:MM (1.0).
minEnergyWhPerDay integer non > 0
targetSocPercent integer non > 0
deadline string non instant avec décalage Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée.
energyTargets [EnergyTarget] non

Champs non déclarés : refusés (forme émise par le moteur, fermée).

EnergyTarget

Champ Type Obligatoire Valeurs Sens
by string oui instant avec décalage Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée.
minWh number oui > 0 Énergie minimale à avoir délivrée à l'échéance.
source string oui installer · user-intent learned n'est pas émis en 1.1.

Champs non déclarés : refusés (forme émise par le moteur, fermée).

LoadLearned — Absent tant qu'aucun profil n'a été reçu (E12).

Champ Type Obligatoire Valeurs Sens
dailyEnergyWh number oui
confidence number oui ≥ 0 ; ≤ 1

Champs non déclarés : refusés (forme émise par le moteur, fermée).

6. Réponse : plan par créneaux

{"jsonrpc":"2.0","id":42,"result":{
  "planId":"2026-06-07T13:42:00Z-a1",
  "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)",
         "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"}}
}}

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 (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. Côté moteur (plan publié vers l'app, traces), ce plan porte strategy: "rules", la même valeur que activeStrategy ; le strategy d'un optimiseur reste une chaîne libre, informative.
  • 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 Mécanismes Champs Exemple
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 — (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.

Ce que le contrat déclare de la réponse (une réponse peut porter d'autres champs : ils sont ignorés, §9). Les codes émissibles et leurs seules clés de paramètres transmises :

Abstention — « Pas de plan maintenant » (§7) : repli pour ce cycle, pas un échec.

Champ Type Obligatoire Valeurs Sens
abstain boolean oui true
reason string non

Champs non déclarés : ignorés (§9).

Plan — Plan par créneaux (§6).

Champ Type Obligatoire Valeurs Sens
abstain boolean non false
planId string non
strategy string non Chaîne libre, informative.
computedAt string non instant avec décalage Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée.
validUntil string | null non instant avec décalage Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée.
slots [Slot] oui [] = abstention (empty-plan). Seul le créneau couvrant maintenant est exécuté.
diagnostics Diagnostics non

Champs non déclarés : ignorés (§9).

Slot

Champ Type Obligatoire Valeurs Sens
from string oui instant avec décalage Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée.
to string oui instant avec décalage Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée.
actions [Action] | null non Absent ou null = aucune action.
expected Expected non

Champs non déclarés : ignorés (§9).

SetpointAction

Champ Type Obligatoire Valeurs Sens
loadId string oui ≥ 1 car. Charge visée : connue du contexte (règle unknown-load), une fois par créneau (duplicate-load).
reason string oui ≥ 1 car. ; motif \S Texte français, OBLIGATOIRE et non vide (règle missing-reason). Traces et journal seulement, jamais vers l'app.
reasonCode string non SURPLUS_SETPOINT · SURPLUS_INSUFFICIENT · BELOW_MIN_POWER · EV_DEADLINE · SG_FORCED · SG_RECOMMENDED · SG_NORMAL · ECO_FLOOR_GRID · PRICE_SLOT · OPTIMIZER_DECISION Codes de motif qu'un optimiseur peut émettre (§6, liste blanche). GEN : DecisionCode::optimizerEmittableCodes(). Clés de paramètres admises par code : x-etm-reason-params.
reasonParams object | null non Paramètres du motif : valeurs nombre, booléen ou code [A-Za-z]+ ; clés admises par code : x-etm-reason-params. Toute autre clé est SUPPRIMÉE avant l'app (filtrée, pas refusée).
force quelconque non INTERDIT sur le fil (§6) : ignoré, signalé une fois.
kind string oui "setpoint"
powerW number non ≥ 0 Consigne en watts (relay-router, etmvariableload ; ou borne).
currentA number non ≥ 0 Borne : courant (0 = suspendre).
phases enum non 1 · 3 Borne : phases visées ; absent = inchangé.

Champs non déclarés : ignorés (§9). Au moins un de : powerW, currentA.

StateAction

Champ Type Obligatoire Valeurs Sens
loadId string oui ≥ 1 car. Charge visée : connue du contexte (règle unknown-load), une fois par créneau (duplicate-load).
reason string oui ≥ 1 car. ; motif \S Texte français, OBLIGATOIRE et non vide (règle missing-reason). Traces et journal seulement, jamais vers l'app.
reasonCode string non SURPLUS_SETPOINT · SURPLUS_INSUFFICIENT · BELOW_MIN_POWER · EV_DEADLINE · SG_FORCED · SG_RECOMMENDED · SG_NORMAL · ECO_FLOOR_GRID · PRICE_SLOT · OPTIMIZER_DECISION Codes de motif qu'un optimiseur peut émettre (§6, liste blanche). GEN : DecisionCode::optimizerEmittableCodes(). Clés de paramètres admises par code : x-etm-reason-params.
reasonParams object | null non Paramètres du motif : valeurs nombre, booléen ou code [A-Za-z]+ ; clés admises par code : x-etm-reason-params. Toute autre clé est SUPPRIMÉE avant l'app (filtrée, pas refusée).
force quelconque non INTERDIT sur le fil (§6) : ignoré, signalé une fois.
kind string oui "state"
state integer oui ≥ 1 ; ≤ 4 1 = verrouillage, 2 = normal, 3 = recommandation, 4 = forcé.

Champs non déclarés : ignorés (§9).

ConstraintAction — RÉSERVÉ (annexe F) : forme décrite, toujours refusée (kind-mismatch).

Champ Type Obligatoire Valeurs Sens
loadId string oui ≥ 1 car. Charge visée : connue du contexte (règle unknown-load), une fois par créneau (duplicate-load).
reason string oui ≥ 1 car. ; motif \S Texte français, OBLIGATOIRE et non vide (règle missing-reason). Traces et journal seulement, jamais vers l'app.
reasonCode string non SURPLUS_SETPOINT · SURPLUS_INSUFFICIENT · BELOW_MIN_POWER · EV_DEADLINE · SG_FORCED · SG_RECOMMENDED · SG_NORMAL · ECO_FLOOR_GRID · PRICE_SLOT · OPTIMIZER_DECISION Codes de motif qu'un optimiseur peut émettre (§6, liste blanche). GEN : DecisionCode::optimizerEmittableCodes(). Clés de paramètres admises par code : x-etm-reason-params.
reasonParams object | null non Paramètres du motif : valeurs nombre, booléen ou code [A-Za-z]+ ; clés admises par code : x-etm-reason-params. Toute autre clé est SUPPRIMÉE avant l'app (filtrée, pas refusée).
force quelconque non INTERDIT sur le fil (§6) : ignoré, signalé une fois.
kind string oui "constraint"
charge enum non allow · forbid
discharge enum non allow · forbid

Champs non déclarés : ignorés (§9).

ReasonParams : object | null — . Paramètres du motif : valeurs nombre, booléen ou code [A-Za-z]+ ; clés admises par code : x-etm-reason-params. Toute autre clé est SUPPRIMÉE avant l'app (filtrée, pas refusée).

Expected — Trajectoire prévue : affichage et mesure d'écart, JAMAIS pour commander.

Champ Type Obligatoire Valeurs Sens
gridImportW number non
gridExportW number non
batterySocPercent quelconque non RÉSERVÉ (annexe F) : ignoré.

Champs non déclarés : ignorés (§9).

Diagnostics — Informatif : mode installateur et traces.

Champ Type Obligatoire Valeurs Sens
solver string non
status string non optimal · feasible · best-so-far · infeasible-relaxed
gapPercent number non
computeMs number non ≥ 0
inputsUsed object non
inputsUsed.pv string non physics · hybrid · none

Champs non déclarés : ignorés (§9).

reasonCode Clés de reasonParams admises
SURPLUS_SETPOINT budgetW, setpointW, stepped
SURPLUS_INSUFFICIENT budgetW
BELOW_MIN_POWER budgetW, minPowerW, state
EV_DEADLINE —
SG_FORCED budgetW, estimatedW
SG_RECOMMENDED budgetW, estimatedW
SG_NORMAL budgetW
ECO_FLOOR_GRID gridW, atMaximum, remainingWh, targetWh, remainingS, regime, minAlwaysW, boundBy
PRICE_SLOT priceCtkWh, setpointW
OPTIMIZER_DECISION —

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 est inchangé (une abstention ne ressuscite pas un optimiseur mort, E-T4-1).

Situation optimizerAlive Stratégie active
Réponse plan valide true optimiseur
abstain inchangé rules (ce cycle)
Plan expiré (validUntil dépassé) inchangé rules jusqu'au prochain plan valide
Timeout / erreur / plan invalide sur deux requêtes consécutives false rules
Socket absent / fermé false rules
optimizerExpected=false (aucun endpoint configuré) false rules
Mode dégradé L2 (compteur muet > 90 s) inchangé aucune — planification suspendue, repli de sécurité

Retour à optimizerAlive = true au premier plan valide. Les échecs se comptent par requête : une abstention ou un plan déjà expiré à la réception interrompent la série ; une requête remplacée par une plus récente n'est pas un échec. Avant le premier plan valide, optimizerAlive vaut false. Un plan invalide remplace le cache (l'ancien plan valide n'est plus appliqué) et un socket fermé l'abandonne (E-T3-9).

Ce que le validateur refuse en plus de la forme. Le JSON Schema du contrat dit la forme ; le moteur juge ensuite chaque réponse sur le contexte de SA requête, dans cet ordre, et le premier contrôle qui échoue donne son code (fallbackCode vers l'app, lastErrorCode pour un refus) :

Ce que le moteur refuse EN PLUS de la forme, dans l'ordre de ses contrôles (OptimizerPlan::valider, OptimizerClient::handleHandshakeReply). name = code du verdict. issue : abstention (repli ce cycle, pas un échec), expired, invalid (plan refusé en entier, compté comme échec), connection-refused (handshake). needs : ce qu'il faut connaître en plus de la réponse (la requête, le handshake).

Règle Méthode Issue fallbackCode Échec compté Il faut aussi Le schéma la voit aussi Ce qui est refusé
abstain GetPlan abstention abstain non — non result.abstain vaut true.
malformed-plan GetPlan invalid malformed-plan oui — oui slots absent ou non liste ; un créneau qui n'est pas un objet ; actions non liste.
empty-plan GetPlan abstention empty-plan non — non slots est une liste vide.
bad-time GetPlan invalid bad-time oui — oui from/to illisibles ou sans décalage, ou from ≥ to ; validUntil illisible ou sans décalage.
slots-unordered GetPlan invalid slots-unordered oui — non un créneau commence avant la fin du précédent.
malformed-action GetPlan invalid malformed-action oui — oui action qui n'est pas un objet, ou sans loadId (chaîne non vide).
unknown-load GetPlan invalid unknown-load oui request non loadId absent des loads[] de la requête.
unknown-kind GetPlan invalid unknown-kind oui — oui kind hors ActionKind.
kind-mismatch GetPlan invalid kind-mismatch oui request non kind différent de celui du mécanisme de la charge (setpoint : evcharger, relay-router, etmvariableload ; state : sg-ready ; constraint : aucun).
kind-not-announced GetPlan invalid kind-not-announced oui handshake non capabilities.kinds annoncé au handshake et kind absent de la liste.
missing-reason GetPlan invalid missing-reason oui — oui reason absent, non textuel, ou vide une fois les caractères de contrôle retirés.
bad-value GetPlan invalid bad-value oui request oui state absent ou hors 1-4 ; borne : currentA/powerW absents ou négatifs, phases hors {1, 3} ; charge en watts : powerW absent ou négatif.
duplicate-load GetPlan invalid duplicate-load oui — non deux actions pour la même charge dans un créneau.
expired GetPlan expired expired non request non validUntil ≤ timestamp de la requête.
no-current-slot GetPlan abstention no-current-slot non request non aucun créneau ne couvre le timestamp de la requête.
incomplete-slot GetPlan invalid incomplete-slot oui request non une charge available de la requête n'a pas d'action dans le créneau courant (décision Q1).
handshake-refused Handshake connection-refused no-plan non — non réponse error (token absent ou différent : -32001 recommandé) → connexion fermée, nouvelle tentative au backoff.
invalid-handshake Handshake connection-refused no-plan non — oui result non objet, protocolVersion absent ou vide, capabilities présent et non objet.
major-mismatch Handshake connection-refused no-plan non — non MAJEURE de protocolVersion (ce qui précède le premier point) différente de celle du moteur.

L'état exposé à l'app (optimizerExpected, optimizerAlive, activeStrategy ∈ optimizer | rules | none) reflète cet état en continu ; chaque transition émet NymeaEnergy.OptimizerStatusChanged (INTERFACE.md). Il n'y a pas de tier : l'app déduit ce qu'elle affiche de ces trois champs.

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. État 1.1 du moteur : reçue, journalisée une fois, ignorée — la persistance est un lot ultérieur (INTERFACE.md).
  • 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.

UpdateLearned — params

Champ Type Obligatoire Valeurs Sens
loads [object] non Non normé en 1.1 : intention §8 (learned + confidence par charge).

Champs non déclarés : refusés (forme émise par le moteur, fermée).

  • 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.
  • Changements incompatibles (renommage, sémantique) = majeure + entrée changelog §0.

Annexe A — Session minimale

→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.1","engine":"etm-powersync-energy-plugin-etm/1.15.2+etm70"}}
← {"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).

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 — 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 est écrêté, pas exécuté aveuglément.

Annexe C — Priorités : modèle utilisateur

  • 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 ; à activer sur banc), réglée par NymeaEnergy.SetOptimizerTrace (INTERFACE.md, « Traces de décision »).
  • Fichiers NDJSON locaux etm-powersync-optimizer-trace-AAAA-MM-JJ.ndjson, rotation à minuit heure locale, rétention configurable (défaut 14 jours), plafond par fichier journalier (défaut 64 Mio), sous le répertoire de stockage du moteur (NymeaSettings::storagePath()). Écriture asynchrone : une trace en échec ne bloque rien et ne change aucune décision.
  • Une ligne par cycle, y compris sous le rule-based (sa réponse est alors le plan du rule-based, strategy: "rules") et en mode dégradé L2 (strategy: "none", sans requête) :
{"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}
  • request : les params GetPlan envoyés (ou qui l'auraient été) ; response : ce qui a répondu à cette requête — result brut, ou error.code (no-response si elle a été remplacée sans réponse) ; executed : actions appliquées au cycle ; latencyMs : optimiseur seulement. La réponse à la requête N sert au cycle N+1 : la ligne N l'attend.
  • Jamais le token (il ne voyage que dans le handshake ; recopié par une réponse, il est masqué).
  • Rejeu : tools/etm-powersync-trace-replay relit request, l'envoie à un optimiseur candidat (unix:// par défaut, tcp:// accepté), écrit les réponses à côté de la trace et résume : taux d'abstention, computeMs médian et max, cycles dont les actions du créneau courant diffèrent du plan enregistré.
  • Les traces restent sur le site. Export manuel par l'installateur uniquement.

Annexe E — Optimiseur de référence

tools/etm-powersync-ref-optimizer/ : serveur Python minimal (bibliothèque standard seule) conforme 1.1, unix:///run/powersync/optimizer.sock par défaut, tcp:// accepté ; répondant un plan à un créneau qui reproduit le waterfall rule-based. Usages : test de bout en bout du SocketScheduler (suite etm-powersync-refoptimizer) ; point de comparaison du banc ; exemple communautaire. Mode d'emploi et modes d'injection de défauts : son README.md.

Il s'annonce powersync-optimizer au handshake (name). powersync-optimizer = exemple de référence GPL (ce dépôt) ; etm-optimizer = optimiseur ETM (propriétaire, hors de ce dépôt). Le nom n'est pas contrôlé par le moteur (§3) : il n'est qu'affiché.

  • Il ne lit que le contexte GetPlan (aucune connexion sortante, aucun fichier hors le token).
  • Il reproduit la passe confort : tri (rang, identifiant), surplus net signé, recrédit de la consommation courante, réserve de stockage, plancher, plafond, verrous, mapping SG-Ready avec hystérésis. Il ne reproduit pas ce que le contexte 1.1 ne porte pas : passe éco (avancement livré), dérogations, autorisation de soutirage, paliers d'un relay-router, refus de commande, proxy VE.
  • Il n'émet que les codes reasonCode qu'un optimiseur peut émettre, avec leurs seules clés documentées ; un code du rule-based hors de cette liste (verrou, réserve…) part en OPTIMIZER_DECISION, le mécanisme nommé dans reason.

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.

Annexe G — Contrat machine, validateur et conformité

  • Le contrat : docs/etm-powersync-optimizer-protocol.openrpc.json (OpenRPC 1.3). Méthodes Handshake, GetPlan, UpdateLearned ; forme des messages en JSON Schema (components.schemas) ; ce qu'OpenRPC ne sait pas dire en extensions x-etm-… : x-etm-transport (unix/tcp, NDJSON, rôles, délais), x-etm-direction (sens d'une méthode), x-etm-fallback-machine (§7), x-etm-semantic-rules (ce que le validateur refuse en plus, avec fallbackCode), x-etm-tolerances (ce que le moteur accepte au-delà), x-etm-reason-params, x-etm-generated (listes fermées régénérées depuis le code). Forme côté moteur (requêtes) fermée ; côté optimiseur (réponses) ouverte (§9).
  • Servi par le moteur qui tourne : NymeaEnergy.GetOptimizerProtocol (INTERFACE.md). Pas de rpc.discover sur le socket : l'optimiseur se déclare au handshake, point.
  • Validateur : tools/etm-powersync-schema-check (Python, bibliothèque standard) — un message, une session NDJSON (les deux sens) ou des traces (annexe D) ; --semantic applique les règles.
  • Conformité d'un optimiseur quelconque : suite etm-powersync-optimizer-conformance (tests/auto/conformance/README.md) — le vrai client du moteur branché sur l'optimiseur que lance ETM_POWERSYNC_OPTIMIZER_CMD.
  • Prose générée : tools/etm-powersync-gen-protocol-doc (--check dans tools/ci-quality.sh).