# 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`). > ### 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. - §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). ### 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://:` — optimiseur sur le LAN (NAS, serveur). Pour les implémentations communautaires (ex. pont Akkudoktor-EOS, annexe B). - Protocole applicatif : **JSON-RPC 2.0, un message par ligne (NDJSON), UTF-8.** - Le contrat est transport-agnostique : mêmes messages sur unix et tcp. - Reconnexion : backoff exponentiel (1 s → 60 s max). Pendant la déconnexion : stratégie de repli active, `optimizerAlive = false`. ## 3. Handshake À chaque (re)connexion, le plugin envoie : ```json {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{ "protocolVersion":"1.1", "engine":"etm-powersync-energy-plugin-etm/", "token":"" }} ``` Réponse attendue : ```json {"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 → connexion fermée. Recommandé pour tout endpoint `tcp://`. - `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. ## 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 cycles consécutifs sans réponse valide (timeout par requête : 10 s) → `optimizerAlive = false`, repli rule-based, notification de changement de capacités (namespace publié actuel : `NymeaEnergy`). - 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é ```json {"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":"","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.exportW` est la mesure **brute** (`AGENTS.md` règle 8) : jamais le net après pilotage. - `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`. ### 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). ```json "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":"","label":"Toit sud","peakW":6000,"tiltDeg":35,"azimuthDeg":0,"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** : `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` | `fallback`. 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. - **`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. - **`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`, `hphc`, `tempo`, `day-ahead`. `exportCtkWh` : tarif de revente ou 0. - `forecast` peut être `null`. 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[]`) ```json "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, 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). - `source` de 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è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. ### `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 ```json {"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. - **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. ## 7. Abstention et repli L'optimiseur peut répondre : ```json {"jsonrpc":"2.0","id":42,"result":{"abstain":true,"reason":"recalcul en cours"}} ``` Sémantique : « je n'ai pas de plan à proposer maintenant ». Le plugin bascule sur la stratégie rule-based **pour ce cycle**, sans considérer l'optimiseur comme mort (`optimizerAlive` reste `true`). | Situation | optimizerAlive | Stratégie active | |---|---|---| | Réponse plan valide | true | optimiseur | | `abstain` | true | rules (ce cycle) | | Plan expiré (`validUntil` dépassé) | true | rules jusqu'au prochain plan valide | | Timeout / erreur / plan invalide ×2 | false | rules | | Socket absent / fermé | false | rules | | `optimizerExpected=false` (tier Community) | false | rules | | Mode dégradé L2 (compteur muet > 90 s) | inchangé | **aucune** — planification suspendue, repli de sécurité | 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), 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 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/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 en production, activée sur banc). - Fichiers NDJSON locaux, rotation par jour, rétention configurable (défaut 14 jours), sous le répertoire de stockage du moteur (`NymeaSettings::storagePath()`). - Une ligne par cycle : ```json {"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} ``` - **Rejeu** : hems-sim relit `request`, l'envoie à un optimiseur candidat, compare `response` + `diagnostics`. - 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` ; 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`.