Annexe D : défaut désactivé, nom des fichiers, rotation locale, plafond, appariement requête → réponse (la ligne N attend la réponse à la requête N), no-response, jamais le token ; le rejeu est fait par tools/etm-powersync-trace-replay (et non plus « hems-sim »). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
33 KiB
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 :
- Interface interne entre l'arbitrage central et la stratégie par défaut (rule-based).
- Spécification du protocole socket pour un optimiseur en process séparé.
- Documentation publique : n'importe qui peut implémenter son propre optimiseur.
Statut : v1.1 — 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 dansINTERFACE.mdet faisant autorité viaenergyplugin/nymeaenergyjsonhandler.cpp+JSONRPC.Introspectsur 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éneauxINTERFACE.mdWebSocket 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.priorityreste le rang effectif. - §5.3 Domaine (
domain) et noms réels des mécanismes (adapter). - §5.3 Besoins enrichis :
energyTargets,comfort,sourcede chaque besoin. - §5.3 Mesure par charge :
measurement {source, powerW}(aligné surINTERFACE.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;forceinterdit sur le fil. - Annexe D Traces et rejeu ; Annexe E Optimiseur de référence ; Annexe F Champs réservés (batterie).
1.0 (2026-06) — version initiale
- 2026-08-09 (
92d0bef) :available,lockMinPowerW,lockMaxPowerWdocumenté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, surpowerBalanceChanged) 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
reasonlisible (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.mdinvariant 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é parroot:powersync, mode0660.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:truesi l'optimiseur produit des plans multi-créneaux ;falses'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 endpointtcp://.capabilities(1.1, optionnel) : affiché en mode installateur. S'il est présent, le plugin rejette toute action dont lekindn'y est pas annoncé.- Pas de
locale: la locale nymea est par connexion app (AGENTS.mdinvariant 12) ; la traduction vers l'app passe parreasonCode(§6). - 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 — voirtrigger, §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, notificationNymeaEnergy.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 surtrigger≠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","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.exportWest la mesure brute (AGENTS.mdrè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'EnergyManagerde nymea (|currentPowerProduction|). Publiée dès qu'au moins un Thingsmartmeterproducerest 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).nullen France.tariff(1.0) reste émis pour compatibilité ; un optimiseur 1.1 litforecast.price.
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":{
"provider":"open-meteo",
"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>","label":"Toit sud","peakW":6000,"tiltDeg":35,"azimuthDeg":0,
"corrections":["temperature"],"p50W":[ ... ]}
]
},
"baseload":{"model":"none","p50W":[null, ...]},
"weather":{"temperatureC":[ ... ]},
"price":{
"currency":"EUR","importCtkWh":[ ... ],"exportCtkWh":[ ... ],
"includes":["energy","grid","taxes"],"source":"tempo",
"knownUntil":"2026-10-13T06:00:00+02:00"
}
}
Règles :
- Grille :
resolutionSetstartfont 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 bruteGTI × kWc × PR.p10W/p90W: optionnels, absents tant queconversion=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 (replibest_matchcompris),nullsi 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 danscorrections([]= aucune correction). Le plugin GPL n'émet jamaishybrid: la correction apprise vit hors GPL (DESIGN_PREVISION_PV.md§5). Un optimiseur qui corrige la prévision le déclare dansdiagnostics.inputsUsed.pv(§6).issuedAt: instant du run s'il est connu, sinonnull. 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) :tiltDeg0 = horizontal, 90 = vertical ;azimuthDeg0 = sud, −90 = est, +90 = ouest, ±180 = nord.corrections(1.1) : corrections déclarées par ce plan (jamaisac-limit, qui est une correction de site). Lecorrectionsdepvest l'intersection de celles des plans, plusac-limitsi un plafond est configuré : une divergence entre plans se lit ici, dans les données — aucun booléen ne la résume.
- 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 (
baseload: consommation hors charges pilotées.model:profile|learned|none;none+ tableau denullautorisé (installation neuve). Le plugin la calcule depuis le compteur racine moins les charges, chacune selonmeasurement.source(§5.3) ; une charge ennoneest 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 ;nonesous 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 etconstantMeasurement.
price: prix complet payé —includesliste ce qui est inclus. Un prix n'est pas une prévision : au-delà deknownUntil,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.
forecastpeut êtrenull. L'optimiseur reste libre de sourcer ses propres prévisions (pont LAN, côté optimiseur), mais le plugin, lui, ne sort pas pour les obtenir.
5.3 Charges (loads[])
"loads":[
{
"id":"chauffe-eau","adapter":"relay-router","label":"Chauffe-eau","domain":"ecs",
"group":{"groupRank":2,"rankInGroup":1},
"priority":2,
"declared":{"nominalPowerW":2400,"minPowerW":0,"maxPowerW":2400,"volumeL":200},
"defaults":{"dailyEnergyWh":7000},
"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"}],
"comfort":{"ecoMinC":50,"comfortC":60,"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":{"pluggedIn":true,"charging":true,"currentPowerW":4100,"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":{"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.loads[].learned: estimations de l'optimiseur, renvoyées au plugin pour persistance et affichage, avecconfidence(0–1). Le rule-based les ignore.loads[].priority: rang effectif (1 = premier servi). Voir annexe C. Un optimiseur qui ne lit quepriorityreste 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;batteryré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
adapterpour savoir ce que mesure une charge : c'estmeasurement.sourcequi le dit (LM-1106).
Groupes (optionnel)
group={groupRank, rankInGroup}: clé de tri, jamais détenteur de budget (specs/spec_loadmodel.mdLM-500).priorityest 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:ecoMinCdoit toujours être tenu ;comfortCseulement sur surplus (specs/spec_loadmodel.md§10, LM-1010).sourcede chaque besoin :installer|user-intent|learned. Une intention client prime sur un besoin appris.- Champs 1.0 (
dailyDeadline,minEnergyWhPerDay,targetSocPercent,deadline) restent émis ; un optimiseur 1.1 préfèreenergyTargets.
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.
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.
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
nowest 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). reasonabsent 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 queactiveStrategy; lestrategyd'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 champforcereç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 couvrenow.expected(optionnel, par créneau) : trajectoire prévue. Affichage et mesure d'écart plan/réalité ; jamais utilisé pour commander.diagnostics(optionnel) :statusoptimal|feasible|best-so-far(calcul interrompu, meilleur plan trouvé) |infeasible-relaxed(besoins non tenus nommés dans lesreason) ;computeMs,gapPercent,solveraffichés en mode installateur et enregistrés dans les traces.inputsUsed.pvpeut valoirhybridsi 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 catalogueDecisionCode(etm/types/decisionreason.h, rendu documenté dansINTERFACE.md) et ses paramètres.- Vers l'app, seul
{reasonCode, reasonParams}est transmis — jamais le texte libre (AGENTS.mdinvariant 12). Une action sansreasonCodeconnu du catalogue est transmise sous le code génériqueOPTIMIZER_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.
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).
| 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).
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 notificationUpdateLearned(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.
- 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 queconfidence.
9. Versionnement
protocolVersionen 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/0.9"}}
← {"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 parNymeaEnergy.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 paramsGetPlanenvoyés (ou qui l'auraient été) ;response: ce qui a répondu à cette requête —resultbrut, ouerror.code(no-responsesi 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-replayrelitrequest, 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,computeMsmé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/ref-optimizer/ : serveur Python minimal (bibliothèque standard seule) conforme 1.1,
répondant un plan à un créneau qui reproduit le waterfall rule-based. Usages : test de bout
en bout du SocketScheduler ; point de comparaison du banc ; exemple communautaire.
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; kindconstraint(charge/discharge:allow|forbid) ;setpointbatterie avecsource:"grid"(charge réseau). loads[].socTarget{percent, by, source}: cible de SOC comme participant à la priorisation.loads[].policies:noDischargeWhileEvCharging,holdChargeForDomains,gridChargeAllowed.expected.batterySocPercent.