diff --git a/docs/OPTIMIZER_PROTOCOL.md b/docs/OPTIMIZER_PROTOCOL.md index 0317ff8..9667d68 100644 --- a/docs/OPTIMIZER_PROTOCOL.md +++ b/docs/OPTIMIZER_PROTOCOL.md @@ -1,16 +1,16 @@ -# OPTIMIZER_PROTOCOL — v1 (draft) +# 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 : optimiseur ETM, ou toute implémentation tierce). +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 : **draft v1** — figé à la fin de la beta. Versionné en semver (`protocolVersion`). +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 +> ### 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 @@ -20,13 +20,43 @@ Statut : **draft v1** — figé à la fin de la beta. Versionné en semver (`pro > autorité via `energyplugin/nymeaenergyjsonhandler.cpp` + `JSONRPC.Introspect` sur une box. > > Cette note existe parce que l'erreur a coûté une session : un brief a envoyé l'agent app -> chercher ici le namespace `LoadConfig`, qui n'y est pas et n'y sera pas. Les deux -> protocoles portent tous deux du JSON-RPC — c'est tout ce qu'ils ont en commun. +> 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.*` — 20 méthodes, 14 notifications | +> | `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 ». --- @@ -36,19 +66,26 @@ Statut : **draft v1** — figé à la fin de la beta. Versionné en semver (`pro bornes de sécurité du plugin (limites par charge, protection de surcharge, anti-flapping). Un optimiseur bugué ou malveillant ne peut produire que des choix sous-optimaux *dans des limites sûres* — jamais un état dangereux. -- **La boucle de sécurité n'est pas négociable.** `verifyOverloadProtection()` (temps réel, - sur `powerBalanceChanged`) s'exécute indépendamment et prime sur toute consigne. -- **Le système fonctionne sans optimiseur.** Optimiseur absent, mort ou abstentionniste → - repli automatique sur la stratégie rule-based interne (GPL). La maison reste pilotée. +- **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 (optimiseur ETM). + - `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). @@ -63,7 +100,7 @@ Statut : **draft v1** — figé à la fin de la beta. Versionné en semver (`pro ```json {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{ - "protocolVersion":"1.0", + "protocolVersion":"1.1", "engine":"etm-powersync-energy-plugin-etm/", "token":"" }} @@ -73,9 +110,16 @@ Réponse attendue : ```json {"jsonrpc":"2.0","id":1,"result":{ - "protocolVersion":"1.0", + "protocolVersion":"1.1", "name":"etm-optimizer", - "planning":true + "planning":true, + "capabilities":{ + "maxHorizonH":48, + "resolutionS":3600, + "kinds":["setpoint","state","constraint"], + "usesForecast":["pv","baseload","price","temperature"], + "reasonCodes":true + } }} ``` @@ -85,68 +129,47 @@ Réponse attendue : 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 (charge branchée/débranchée, changement de config). + é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 `Ems.CapabilitiesChanged`. + `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. + 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-06-07T13:42:00+02:00", + "timestamp":"2026-10-12T13:42:00+02:00", + "trigger":"cycle", "site":{ "contractedPowerW":12000, "gridCapW":null, "phaseLimitA":[63,63,63] }, - "meter":{"importW":0,"exportW":2310,"perPhaseA":[3.2,4.1,2.8]}, + "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":[ - { - "id":"ecs-1","adapter":"relay-stages","label":"Chauffe-eau", - "declared":{"nominalPowerW":2400,"stages":[0,1200,2400]}, - "defaults":{"dailyEnergyWh":7000}, - "learned":{"dailyEnergyWh":6400,"confidence":0.35}, - "telemetry":{"state":1,"currentPowerW":1200,"lastSwitch":"2026-06-07T13:10:00+02:00"}, - "needs":{"dailyDeadline":"18:00","minEnergyWhPerDay":4000}, - "priority":2, - "limits":{"minOnS":300,"minOffS":300} - }, - { - "id":"ev-1","adapter":"evcharger","label":"Borne garage", - "declared":{"minA":6,"maxA":16,"phases":3}, - "telemetry":{"pluggedIn":true,"charging":true,"currentPowerW":4100,"sessionWh":5200}, - "needs":{"targetSocPercent":80,"deadline":"2026-06-08T07:00:00+02:00"}, - "priority":1, - "limits":{"chargingEnabledLockS":300,"currentChangeLockS":60} - }, - { - "id":"pac-1","adapter":"sg-ready","label":"PAC", - "declared":{"states":[1,2,3,4],"estimatedPowerW":{"3":1800,"4":2600}}, - "telemetry":{"state":2}, - "priority":3, - "limits":{"minStateHoldS":900} - }, - { - "id":"bat-1","adapter":"battery","label":"Batterie Fronius", - "priority":4 - } - ], - "forecast":{ "...":"objet Contract A (openmeteo) transmis tel quel, ou null" }, + "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}] @@ -154,65 +177,216 @@ Réponse attendue : }} ``` +- `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 dans la liste ordonnée définie par le client (1 = premier - servi). Voir annexe C. +- `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. -- `site.gridCapW` : réservé (signaux réseau type §14a EnWG). `null` en France v1. -- `forecast` : contenu défini par `CONTRACT_A_openmeteo.md`. Peut être `null` - (l'optimiseur peut sourcer ses propres prévisions — il est libre). -### `loads[].telemetry` — champs ETM non encore normalisés +**Mécanisme et domaine** -Trois champs ajoutés par l'implémentation ETM et **absents des exemples ci-dessus**. -Ils sont documentés ici parce qu'ils changent la lecture du contexte, et qu'une -implémentation d'optimiseur qui les ignore prendra de mauvaises décisions. +- `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`. Un optimiseur qui se contenterait de « sauter » les charges indisponibles sauterait aussi leur comptabilité, et offrirait aux charges suivantes un budget qui n'existe pas. | -| `lockMinPowerW` | W | Plancher imposé **maintenant** par un verrou anti-rebond : puissance déjà engagée que l'adaptateur ne peut pas couper. Le budget DOIT être décrémenté de cette valeur **même si elle dépasse le budget disponible** — sinon les charges de rang suivant reçoivent un résidu surestimé et l'installation soutire au réseau. | +| `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 — un tel couple ferait dérailler tout écrêtage. +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. +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-optimizer-predict", + "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)"}, - {"loadId":"ecs-1","kind":"stage","stage":1, - "reason":"Reliquat 0,9 kW → ECS palier 1 (besoin estimé 6,4 kWh/j, en apprentissage)"}, - {"loadId":"bat-1","kind":"constraint","charge":"allow","discharge":"forbid", - "reason":"Jour ROUGE demain : décharge bloquée, on préserve le SoC"} - ] - }, - { - "from":"2026-06-07T22:00:00+02:00","to":"2026-06-08T06:00:00+02:00", - "actions":[ - {"loadId":"bat-1","kind":"setpoint","powerW":3000,"source":"grid", - "reason":"Charge réseau HC avant jour ROUGE (plafond 3 kW : abonnement 12 kVA)"} - ] + "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"}} }} ``` @@ -221,20 +395,41 @@ Règles d'exécution côté plugin : (affichage app : « prévision programmée ») et sera re-demandé au cycle suivant. - Plan vide ou sans créneau courant = abstention (voir §7). - Toute action est **écrêtée** : bornes min/max de l'adaptateur, verrous temporels, - protection de surcharge. Une action écrêtée est exécutée au plus proche possible - et loggée (`clamped`). -- `reason` absent ou vide → action **rejetée**, log d'avertissement. (Exigence - decisionReason.) + 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` | Charges | Champs | Exemple | +| `kind` | Mécanismes | Champs | Exemple | |---|---|---|---| -| `setpoint` | evcharger, battery | `currentA` ou `powerW` (+ `source:"grid"` pour charge réseau batterie) | borne à 10 A ; batterie +3000 W depuis réseau | -| `stage` | relay-stages (ECS…) | `stage` (entier, parmi `declared.stages`) | ECS palier 1 (1200 W) | +| `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` | battery (v1) | `charge`/`discharge` : `allow`\|`forbid` | décharge interdite | +| `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 @@ -246,18 +441,17 @@ L'optimiseur peut répondre : Sémantique : « je n'ai pas de plan à proposer maintenant ». Le plugin bascule sur la stratégie rule-based **pour ce cycle**, sans considérer l'optimiseur comme mort -(`optimizerAlive` reste `true`). C'est le comportement attendu pendant un recalcul, -un démarrage à froid, ou une absence de données. - -Tableau de repli complet : +(`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. @@ -267,33 +461,31 @@ L'objet capabilities exposé à l'app (`tier`, `optimizerExpected`, `optimizerAl - L'optimiseur peut renvoyer ses estimations (`learned` + `confidence`) dans une notification `UpdateLearned` (sans id, fire-and-forget) ; le plugin les persiste et les retransmet dans les contextes suivants. -- Tant que `confidence < 0.7` (seuil indicatif), les `reason` concernés mentionnent - l'apprentissage (« besoin estimé …, en apprentissage ») et l'app peut afficher un - badge « profil en apprentissage » sur la charge. -- Les données d'apprentissage restent **locales** (buffer circulaire, ~2 mois, - écrasement). Rien ne quitte le site. C'est un engagement produit, pas un détail. +- 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** (tolérance - ascendante). Ajouts mineurs = nouveaux champs optionnels. -- Changements incompatibles (renommage, sémantique) = majeure + entrée changelog - dans ce fichier. +- 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.0","engine":"etm/0.9"}} -← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.0","name":"mon-optimiseur","planning":false}} +→ {"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`). C'est le point d'entrée communautaire. +renvoyer un plan à un créneau (ou `abstain`). ## Annexe B — Exemple : brancher Akkudoktor-EOS @@ -305,10 +497,9 @@ où l'on veut sur le LAN : plugin (client) ──tcp://nas:7600──► eos-bridge ──HTTP──► serveur EOS ``` -Le pont : (1) répond au Handshake ; (2) traduit `SurplusContext` → payload EOS -(séries horaires PV/conso/prix — il peut compléter avec les prévisions Akkudoktor) ; -(3) traduit le plan EOS → `slots[]`/`LoadAction` ; (4) sert le plan en cache et -recalcule en arrière-plan (EOS est lent, le protocole exige des réponses rapides) ; +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 @@ -316,13 +507,54 @@ est écrêté, pas exécuté aveuglément. ## Annexe C — Priorités : modèle utilisateur -- **Liste ordonnée globale** (drag-and-drop dans l'app) : l'ordre de service du - surplus. Le rule-based l'exécute en cascade (waterfall) ; un planificateur s'en - sert comme préférence quand tout ne peut pas être servi. -- **Besoins par charge** (fiche équipement) : échéances (`deadline`, - `dailyDeadline`), minimums (`minEnergyWhPerDay`), cible (`targetSocPercent`). -- **Promotions conditionnelles** : une échéance qui approche ou un signal tarifaire - (Tempo ROUGE) peut faire passer une charge devant la liste — toujours expliqué - dans `reason`. +- **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`.