# OPTIMIZER_PROTOCOL — v1.1 Contrat entre le moteur d'énergie **etm-powersync-energy-plugin-etm** (couche A, GPL-3.0) et un **optimiseur** externe (couche B : l'optimiseur ETM, ou toute implémentation tierce). Ce document fait autorité. Il sert trois usages : 1. Interface interne entre l'arbitrage central et la stratégie par défaut (rule-based). 2. Spécification du protocole socket pour un optimiseur en process séparé. 3. Documentation publique : n'importe qui peut implémenter son propre optimiseur. Statut : **v1.1** — figé à la fin de la beta. Versionné en semver (`protocolVersion`). > ### La forme est un contrat MACHINE > > La **forme** des messages — champs, types, obligatoire, valeurs permises, et ce que le > validateur du moteur refuse en plus — est écrite dans > **`docs/etm-powersync-optimizer-protocol.openrpc.json`** (OpenRPC 1.3). Il fait foi sur la forme ; > ce document fait foi sur le **sens**. Le moteur l'embarque et le sert > (`NymeaEnergy.GetOptimizerProtocol`, `INTERFACE.md`) ; les deux côtés sont testés contre lui > (annexe G). Les tableaux entre balises `BEGIN GENERATED` / `END GENERATED` ci-dessous sont > **produits depuis le contrat** par `tools/etm-powersync-gen-protocol-doc` (`--check` en CI) : > ne pas les éditer à la main. Les exemples JSON, eux, restent illustratifs. > ### Ce document ne décrit PAS la frontière JSON-RPC de l'app > > Le protocole ci-dessous relie le **moteur** à un **optimiseur** (couche A ↔ couche B), sur > un socket dédié, en NDJSON. Il ne dit rien du namespace que consomme l'application > Flutter, et n'a jamais eu vocation à le dire. > > **La frontière app ↔ box, c'est `NymeaEnergy.*`**, documentée dans `INTERFACE.md` et faisant > autorité via `energyplugin/nymeaenergyjsonhandler.cpp` + `JSONRPC.Introspect` sur une box. > > Cette note existe parce que l'erreur a coûté une session : un brief a envoyé l'agent app > chercher ici le namespace `LoadConfig`, qui n'y est pas et n'y sera pas. > > | | transport | qui parle à qui | contrat | > |---|---|---|---| > | **Ce document** | socket NDJSON dédié | moteur ↔ optimiseur | `GetPlan(SurplusContext)` → plan par créneaux | > | `INTERFACE.md` | WebSocket nymea, port 4444 | app ↔ box | `NymeaEnergy.*` | --- ## 0. Changelog ### 1.1 (2026-10) Additif sur le fil : toutes les additions sont des **champs optionnels nouveaux** ; un optimiseur 1.0 reste conforme (champs inconnus ignorés, §9). **Correction de brouillon (pas une rupture)** : le kind `stage` et l'adaptateur `relay-stages` décrits en 1.0 n'ont jamais existé dans le moteur livré (retirés du code en rév. 3, avant toute implémentation socket). Aucun consommateur déployé n'en dépend ; ils sont retirés du document sans changement de majeure. **Correction avant publication (2026-10-14)** : `price.source` `hphc` devient `tou` (zones horaires saisies, N zones et plusieurs plages par zone — HP/HC en est un cas) ; `hphc` n'a jamais été émis. `baseload.quality` est ajouté (optionnel). **Correction avant publication (2026-10-13)** : `conversion` n'admet plus `fallback`. La valeur avait été écrite sans définition ; **aucun émetteur ne l'a jamais produite**. Seule `physics` est émise ; une prévision dégradée se lit dans `corrections`. - §5.2 **Prévisions explicites** (`forecast`) : grille temporelle déclarée, PV, consommation de base, température, prix complet. Remplace le renvoi à `CONTRACT_A_openmeteo.md`. - §5.3 **Groupes** (`group`) : clé de tri à deux niveaux. `priority` reste le rang effectif. - §5.3 **Domaine** (`domain`) et noms réels des mécanismes (`adapter`). - §5.3 **Besoins enrichis** : `energyTargets`, `comfort`, `source` de chaque besoin. - §5.3 **Mesure par charge** : `measurement {source, powerW}` (aligné sur `INTERFACE.md`). - §5.3 Champs de verrou `minState`/`maxState` (SG-Ready) documentés. - §5.1 **Déclencheur** de la requête (`trigger`), `meter.source`. - §3 `capabilities`. - §6 **`validUntil`**, `expected`, `reasonCode` + `reasonParams`, `diagnostics` ; `force` interdit sur le fil. - Annexe D **Traces et rejeu** ; Annexe E **Optimiseur de référence** ; Annexe F **Champs réservés** (batterie). - **Contrat machine (2026-10-17, sans changement de fil)** : la forme des messages devient le document OpenRPC `docs/etm-powersync-optimizer-protocol.openrpc.json`, servi par le moteur, tableaux de ce document générés depuis lui ; annexe G. Les exemples du §5 ont été réalignés sur ce que le moteur émet réellement (champs jamais émis signalés comme tels). ### 1.0 (2026-06) — version initiale - 2026-08-09 (`92d0bef`) : `available`, `lockMinPowerW`, `lockMaxPowerW` documentés (ECS-410). - 2026-08-25 (`afaa8c2`) : encadré « ce document ne décrit pas la frontière de l'app ». --- ## 1. Philosophie - **Le plugin ne fait pas confiance à l'optimiseur.** Tout plan reçu est écrêté par les bornes de sécurité du plugin (limites par charge, protection de surcharge, anti-flapping). Un optimiseur bugué ou malveillant ne peut produire que des choix sous-optimaux *dans des limites sûres* — jamais un état dangereux. - **La boucle de sécurité n'est pas négociable.** Couches L0 à L4 (`docs/SAFETY.md`). `verifyOverloadProtection()` (temps réel, sur `powerBalanceChanged`) s'exécute indépendamment et prime sur toute consigne ; son extension au waterfall (`shedWaterfallForOverload()`) revendique les charges délestées, que le dispatch saute. - **Le système fonctionne sans optimiseur.** Optimiseur absent, mort, abstentionniste ou plan expiré → repli automatique sur la stratégie rule-based interne (GPL). - **Toute décision est expliquée.** Chaque action porte un `reason` lisible (français), non vide. C'est une exigence du contrat, pas une option. - **Le plugin seul commande le matériel.** Temporisations et anti-battement sont tenus par le plugin en toutes circonstances ; l'optimiseur les connaît pour planifier, il ne les fait pas respecter. - **Zéro cloud.** Rien dans ce protocole n'exige ni ne provoque de connexion sortante du site (`AGENTS.md` invariant 10). ## 2. Topologie et transport - **Le plugin est le CLIENT. L'optimiseur est le SERVEUR.** Le plugin se connecte à un endpoint configuré et demande des plans. La box n'ouvre aucun port. - Endpoint configurable : - `unix:///run/powersync/optimizer.sock` — défaut. Optimiseur local. Droits : socket possédé par `root:powersync`, mode `0660`. - `tcp://:` — 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 → réponse `error` (code `-32001` recommandé ; le moteur traite **toute** réponse `error` au handshake comme un refus, `handshake-refused`) puis connexion fermée. Recommandé pour tout endpoint `tcp://`. **Forme** (E-T2-8) : chaîne de **16 caractères au moins** et de 4 096 octets au plus, imprimable, sans blanc ni caractère de contrôle (UTF-8 admis). Le plugin refuse d'en poser un plus court, et ne se connecte pas avec un fichier de token plus court (ni sans token) : il masque toute occurrence du token dans ce que l'optimiseur renvoie, et un token court corromprait un identifiant qui le contiendrait. - `capabilities` (1.1, optionnel) : affiché en mode installateur. **S'il est présent**, le plugin rejette toute action dont le `kind` n'y est pas annoncé. - Pas de `locale` : la locale nymea est par connexion app (`AGENTS.md` invariant 12) ; la traduction vers l'app passe par `reasonCode` (§6). - Handshake invalide ou timeout (5 s) → repli, nouvelle tentative au backoff. Invalide : `result` non objet, `protocolVersion` absent ou vide, `capabilities` présent mais non objet (`invalid-handshake`) ; majeure différente (`major-mismatch`). - `name` : optionnel, informatif, affiché en mode installateur ; jamais contrôlé. **`Handshake` — params** | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `protocolVersion` | `ProtocolVersion` | oui | `"1.1"` | Version du moteur ; seule la majeure est comparée. | | `engine` | string | oui | motif `^etm-powersync-energy-plugin-etm/` | Moteur et sa version. | | `token` | string | non | ≥ 16 car. | Secret partagé (§3, E-T2-8) : 16 caractères au moins, 4 096 octets au plus (borne en octets non exprimable ici : contrôlée par le moteur), imprimable, sans blanc ni caractère de contrôle, UTF-8 admis. Envoyé seulement s'il est configuré ; jamais journalisé. | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). **`HandshakeResult`** | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `protocolVersion` | string | oui | ≥ 1 car. ; motif `^[0-9]+(\.[0-9]+)*$` | Version semver de l'optimiseur ; même MAJEURE que le moteur exigée (règle major-mismatch). | | `name` | string | non | | Nom affiché (mode installateur), non contrôlé par le moteur (E-T7-3). | | `planning` | boolean | non | | Vrai si l'optimiseur produit des plans multi-créneaux. Informatif. | | `capabilities` | `Capabilities` | non | | | Champs non déclarés : ignorés (§9). **`Capabilities`** — Capacités annoncées (§3, 1.1, optionnel). Affichées en mode installateur, réduites aux clés et valeurs connues (capacitesPubliees) ; un champ inconnu est ignoré (§9). | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `maxHorizonH` | number | non | ≥ 0 | Horizon maximal planifié, en heures. | | `resolutionS` | number | non | ≥ 0 | Pas des créneaux, en secondes. | | `kinds` | [string] | non | `setpoint` · `state` · `constraint` | PRÉSENT, il restreint les kinds acceptés (règle kind-not-announced). | | `usesForecast` | [string] | non | `pv` · `baseload` · `price` · `temperature` | | | `reasonCodes` | boolean | non | | L'optimiseur émet des reasonCode du catalogue. | Champs non déclarés : ignorés (§9). ## 4. Cycle et heartbeat - Le plugin appelle `GetPlan` à chaque cycle d'optimisation (~1/min) **et** sur événement significatif — voir `trigger`, §5.1. - **Le heartbeat est implicite** : une réponse valide à `GetPlan` = optimiseur vivant. Deux **requêtes** consécutives en échec (timeout par requête : 10 s, erreur, réponse illisible, plan invalide) → `optimizerAlive = false`, repli rule-based, notification `NymeaEnergy.OptimizerStatusChanged`. Le compte est **par requête, pas par cycle** : une requête remplacée par une plus récente avant sa réponse n'est pas un échec. - L'optimiseur peut être lent à *calculer* (EOS : minutes) — il doit alors répondre vite avec son **dernier plan en cache** ou `abstain`, et recalculer en tâche de fond. Une requête n'attend jamais un calcul long. Le rythme de recalcul est libre (typiquement chaque quart d'heure, et sur `trigger` ≠ `cycle`). ## 5. Requête : `GetPlan(SurplusContext)` ### 5.1 En-tête et état instantané ```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:00+02:00","label":"ROUGE","priceCtkWh":75.6}] } }} ``` - `trigger` (1.1) : `cycle` | `load-change` (branchement VE, changement d'état) | `intent` (intention client) | `config` (modification installateur) | `forecast` (nouvelle prévision). Un optimiseur peut s'en servir pour lancer un recalcul anticipé ; il répond quand même immédiatement (§4). - `meter.source` (1.1) : identité du compteur racine désigné (`Energy.SetRootMeter`), unique. - `meter.exportW` est la mesure **brute** (`AGENTS.md` règle 8) : jamais le net après pilotage. - `pv.currentW` (1.1) : production instantanée du site en W, **positive**, telle que l'additionne l'`EnergyManager` de nymea (`|currentPowerProduction|`). Publiée dès qu'au moins un Thing `smartmeterproducer` est configuré — onduleur communicant (`solarinverter`, qui l'étend) **ou** compteur de production posé sur un onduleur non communicant. Sans producteur : `null`, jamais 0 (0 W ne distinguerait pas la nuit de l'absence de PV). **Un seul producteur déclaré par source PV** : nymea additionne tous les producteurs, et un onduleur communicant doublé d'un compteur de production sur la même chaîne compterait la même énergie deux fois. - `site.gridCapW` : réservé (signaux réseau type §14a EnWG). `null` en France. - `tariff` (1.0) reste émis pour compatibilité ; un optimiseur 1.1 lit `forecast.price`. Aucun écrivain aujourd'hui (E3) : le moteur 1.1 émet `tariff: null`. **`GetPlan` — params** | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `timestamp` | string | oui | instant avec décalage | Instant du cycle — le « maintenant » du validateur. | | `trigger` | string | oui | `cycle` · `forecast` · `config` · `load-change` · `intent` | Cause de la requête (§5.1). GEN : SocketScheduler::knownTriggers() — du moins au plus significatif. | | `site` | `Site` | oui | | | | `meter` | `Meter` | oui | | | | `pv` | `Pv` | oui | | | | `battery` | `Battery` | oui | | | | `loads` | [`Load`] | oui | | Charges arbitrées, ⊆ GetLoadConfig. | | `forecast` | `Forecast` \| null | oui | | null sans instantané de prévision. | | `tariff` | `Tariff` \| null | oui | | | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). **`Site`** | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `contractedPowerW` | number \| null | oui | | Puissance souscrite (W) ; null si inconnue (E1), jamais 0. | | `gridCapW` | null | oui | | RÉSERVÉ (signaux réseau type §14a EnWG) : toujours null en 1.1. | | `phaseLimitA` | [number] \| null | oui | | Limite par phase en A ; null si inconnue. | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). **`Meter`** | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `source` | string \| null | oui | | ThingId du compteur racine désigné ; null si inconnu. | | `importW` | number | oui | ≥ 0 | Puissance importée (W). | | `exportW` | number | oui | ≥ 0 | Puissance exportée (W), mesure BRUTE (AGENTS.md règle 8) — jamais le net après pilotage. | | `perPhaseA` | [number] | oui | | Courant par phase (A). | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). **`Pv`** | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `currentW` | number \| null | oui | ≥ 0 | Production instantanée (W), positive ; null sans producteur (jamais 0). | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). **`BatteryAbsent`** — Aucun stockage déclaré : rien d'autre n'est émis. | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `present` | boolean | oui | `false` | | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). **`BatteryPresent`** | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `present` | boolean | oui | `true` | | | `socPercent` | number | oui | | | | `powerW` | number | oui | | Positif = charge, négatif = décharge. | | `capacityWh` | number \| null | oui | | null si inconnue. | | `maxChargeW` | number \| null | oui | | null si inconnue. | | `maxDischargeW` | number \| null | oui | | null si inconnue. | | `reserveSocPercent` | integer \| null | oui | | Réserve de secours (%) ; null si inconnue. | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). **`Tariff`** — Tarif 1.0, conservé pour compatibilité ; un optimiseur 1.1 lit forecast.price. Aucun écrivain aujourd'hui (E3) : null en pratique. | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `provider` | string \| null | oui | | | | `current` | `TariffEntry` | oui | | | | `next` | [`TariffNextEntry`] | oui | | | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). **`TariffEntry`** | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `label` | string | oui | | | | `priceCtkWh` | number | oui | | | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). **`TariffNextEntry`** | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `label` | string | oui | | | | `priceCtkWh` | number | oui | | | | `from` | string \| null | oui | instant avec décalage | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). ### 5.2 Prévisions (`forecast`) Toutes les séries partagent **une seule grille temporelle**. Les tableaux sont alignés index par index ; `null` dans une case = valeur inconnue — **jamais 0, jamais inventée** (`docs/DESIGN_PREVISION_PV.md` §8.4). ```json "forecast":{ "start":"2026-10-12T14:00:00+02:00", "resolutionS":3600, "steps":34, "pv":{ "weatherModel":"meteofrance_seamless", "conversion":"physics", "corrections":["temperature","ac-limit"], "issuedAt":null, "contentChangedAt":"2026-10-12T12:07:00+02:00", "acLimitW":8000, "p50W":[ ... ], "perField":[ {"id":"","corrections":["temperature"]} ] }, "baseload":{"model":"none","p50W":[null, ...]}, "weather":null, "price":{ "currency":"EUR","importCtkWh":[ ... ],"exportCtkWh":[ ... ], "includes":["energy","grid","taxes"],"source":"tou", "knownUntil":"2026-10-13T22:00:00Z" } } ``` Règles : - **Grille** : `resolutionS` et `start` font foi — un consommateur ne suppose jamais le pas (`DESIGN_PREVISION_PV.md` §14.3). En 1.1 le producteur publie au pas **horaire** (`3600`) ; le pas de 15 min sera introduit avec les prix day-ahead, sans changement de protocole. Horizon : jusqu'à la fin du lendemain, 48 h max. - **Instants** : absolus. Les horodatages portent leur décalage ; aucune case n'est indexée par une heure murale (`DESIGN_PREVISION_PV.md` §8.1 — jours de 23 h et 25 h). - **`pv`** : puissance AC moyenne sur `[t, t+pas)`, sommée sur les champs puis plafonnée à `acLimitW`. - **C'est un plafond, pas une espérance**, tant que la conversion ignore l'ombrage et que l'encrassement n'est porté que par un facteur fixe : le biais est systématique et optimiste (`DESIGN_PREVISION_PV.md` §3, §21.2). - `corrections` (1.1) : corrections physiques appliquées — `temperature` (température de module), `ac-limit` (plafond onduleur). Liste vide = conversion brute `GTI × kWc × PR`. - `p10W`/`p90W` : optionnels, **absents** tant que `conversion` = `physics`. **Ils ne sont JAMAIS dérivés de la dispersion d'ensemble** : elle est anticorrélée à l'erreur de conversion (`DESIGN_PREVISION_PV.md` §4). - `weatherModel` : modèle météo **qui a réellement répondu** (repli `best_match` compris), `null` si inconnu — jamais le modèle configuré. - `conversion` : `physics` (seule valeur émise par le plugin GPL). Le champ reste pour des valeurs futures ; un consommateur ignore une valeur qu'il ne connaît pas plutôt que de la deviner. Une prévision **dégradée** (température absente, par exemple) ne se lit **pas** ici : elle se lit dans `corrections` (`[]` = aucune correction). Le plugin GPL **n'émet jamais `hybrid`** : la correction apprise vit hors GPL (`DESIGN_PREVISION_PV.md` §5). Un optimiseur qui corrige la prévision le déclare dans `diagnostics.inputsUsed.pv` (§6). - `issuedAt` : instant du run s'il est connu, sinon `null`. **Ce n'est pas l'âge de chaque case** (`DESIGN_PREVISION_PV.md` §20-21). `contentChangedAt` : instant d'apparition du contenu courant — un producteur figé ne doit pas se lire comme une prévision stable (§26.2). - `perField` : optionnel, affichage et diagnostic. Convention d'orientation (Open-Meteo, PVGIS) : `tiltDeg` 0 = horizontal, 90 = vertical ; `azimuthDeg` 0 = sud, −90 = est, +90 = ouest, ±180 = nord. - `corrections` (1.1) : corrections déclarées **par ce plan** (jamais `ac-limit`, qui est une correction de site). Le `corrections` de `pv` est l'**intersection** de celles des plans, plus `ac-limit` si un plafond est configuré : une divergence entre plans se lit **ici**, dans les données — aucun booléen ne la résume. - **`baseload`** : consommation **hors charges pilotées**. `model` : `profile` | `learned` | `none` ; `none` + tableau de `null` autorisé (installation neuve). Le plugin la calcule depuis le compteur racine moins les charges, chacune selon `measurement.source` (§5.3) ; une charge en `none` est comptée à sa puissance **commandée**, et la qualité de l'estimation le dit. - `profile` (plugin GPL) : médiane par créneau local et par type de jour (ouvré / week-end) sur 28 jours, mesurée sur les échantillons d'une minute du bilan — jamais ses cumuls ; `none` sous 7 jours couverts. Forme et définition : `INTERFACE.md`, `GetForecastSnapshot`. - `quality` (1.1, optionnel) : codes et nombres — jours couverts, minutes comptées à la commande, minutes négatives, et par charge sa source et `constantMeasurement`. - **`price`** : **prix complet payé** — `includes` liste ce qui est inclus. Un prix n'est **pas une prévision** : au-delà de `knownUntil`, `null`. Sources : `fixed`, `tou`, `tempo`, `day-ahead`. `exportCtkWh` : tarif de revente ou 0. - `fixed` / `tou` : tarif **saisi** (une zone sur 24 h / zones horaires en heure locale) ; connu sur tout l'horizon, `knownUntil` = fin de la grille. Un créneau à cheval sur deux zones reçoit la **moyenne pondérée par la durée** — le coût exact d'une puissance constante sur le créneau, seule valeur cohérente avec les autres séries de la grille. - `forecast` peut être `null`. L'optimiseur ne consomme **que** ce que le moteur lui transmet (décision du 2026-10-16) : il n'appelle ni service météo ni fournisseur de tarif, et le plugin, lui, ne sort pas pour les obtenir. - **Ce que le moteur 1.1 n'émet pas** (décrit ici pour la suite, absent du contrat, donc refusé s'il apparaissait sans changement de contrat) : `pv.provider` (E25), `pv.p10W`/`p90W`, `perField[].label`/`peakW`/`tiltDeg`/`azimuthDeg`/`p50W` (seuls `id` et `corrections` sont émis), `weather.temperatureC` (`weather` vaut toujours `null`), `baseload.model: learned`, `price.source` `tempo`/`day-ahead` (seuls `fixed` et `tou`, tarif saisi). **`Forecast`** — Prévisions sur UNE grille (§5.2) ; tableaux alignés index par index. | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `start` | string | oui | instant avec décalage | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. | | `resolutionS` | integer | oui | ≥ 1 | Pas de la grille (s) — fait foi, jamais supposé. | | `steps` | integer | oui | ≥ 0 | Nombre de cases ; toutes les séries ont cette longueur. | | `pv` | `PvForecast` \| null | oui | | null sans courbe (site non désigné, charge utile refusée, canal muet). | | `baseload` | `Baseload` | oui | | | | `weather` | null | oui | | Aucune source de température en 1.1 : toujours null. | | `price` | `Price` \| null | oui | | null sans tarif saisi. | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). **`PvForecast`** | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `p50W` | [number \| null] | oui | | Puissance AC moyenne sur [t, t+pas), sommée sur les plans puis plafonnée à acLimitW ; null = case inconnue. Un plafond, pas une espérance (§5.2). | | `conversion` | string | oui | `physics` | Seule valeur émise par le moteur GPL ; un consommateur ignore une valeur inconnue. | | `corrections` | [string] | oui | `temperature` · `ac-limit` | [] = conversion brute. | | `weatherModel` | string \| null | oui | | Modèle qui a RÉPONDU ; null si inconnu. | | `issuedAt` | string \| null | oui | instant avec décalage | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. | | `contentChangedAt` | string \| null | oui | instant avec décalage | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. | | `acLimitW` | integer | non | | Plafond AC du site (W), s'il est configuré. | | `perField` | [`PvField`] | non | | | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). **`PvField`** | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `id` | string | oui | | ThingId du plan. | | `corrections` | [string] | oui | `temperature` · `ac-limit` | | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). **`Baseload`** | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `model` | string | oui | `profile` · `none` | Le moteur GPL émet profile ou none (learned n'est pas émis). | | `p50W` | [number \| null] | oui | | | | `quality` | `BaseloadQuality` | non | | | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). **`BaseloadQuality`** | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `windowDays` | integer | oui | | | | `minDays` | integer | oui | | | | `resolutionS` | integer | oui | | | | `daysCovered` | object | oui | | | | `daysCovered.workday` | integer | oui | | | | `daysCovered.weekend` | integer | oui | | | | `minutes` | integer | oui | | | | `commandedMinutes` | integer | oui | | | | `negativeMinutes` | integer | oui | | | | `incompleteMinutes` | integer | oui | | | | `computedAt` | string | non | instant avec décalage | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. | | `loads` | [`BaseloadLoadQuality`] | oui | | | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). **`BaseloadLoadQuality`** | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `loadId` | string | oui | | | | `source` | string | oui | `meter` · `device` · `none` | | | `minutesBySource` | object | oui | | | | `constantMeasurement` | boolean | non | | | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). **`Price`** | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `currency` | string | oui | `"EUR"` | | | `importCtkWh` | [number \| null] | oui | | | | `exportCtkWh` | [number \| null] | oui | | | | `includes` | [string] | oui | `energy` · `grid` · `taxes` | | | `source` | string | oui | `fixed` · `tou` | Tarif SAISI : une zone (fixed) ou des zones horaires (tou). tempo et day-ahead ne sont pas émis en 1.1. | | `knownUntil` | string | oui | instant avec décalage | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). ### 5.3 Charges (`loads[]`) ```json "loads":[ { "id":"chauffe-eau","adapter":"relay-router","label":"Chauffe-eau","domain":"ecs", "priority":2, "declared":{"minPowerW":0,"maxPowerW":2400}, "learned":{"dailyEnergyWh":6400,"confidence":0.35}, "telemetry":{"currentPowerW":1200,"available":true,"lockMinPowerW":1200,"lockMaxPowerW":-1, "measurement":{"source":"meter","powerW":1180}}, "needs":{ "dailyDeadline":"18:00","minEnergyWhPerDay":4000, "energyTargets":[{"by":"2026-06-07T18:00:00+02:00","minWh":4000,"source":"installer"}] }, "limits":{"minOnS":300,"minOffS":300} }, { "id":"ev-1","adapter":"evcharger","label":"Borne garage","domain":"ev", "declared":{"minA":6,"maxA":16,"phases":3}, "telemetry":{"currentPowerW":4100,"available":true,"pluggedIn":true,"charging":true,"sessionWh":5200, "measurement":{"source":"device","powerW":4100}}, "needs":{"targetSocPercent":80,"deadline":"2026-06-08T07:00:00+02:00"}, "priority":1, "limits":{"chargingEnabledLockS":300,"currentChangeLockS":60} }, { "id":"pac-terrain","adapter":"sg-ready","label":"PAC","domain":"heating", "declared":{"states":[1,2,3,4],"estimatedPowerW":{"3":1800,"4":2600}}, "telemetry":{"currentPowerW":0,"available":true,"state":2,"minState":2,"maxState":4, "measurement":{"source":"none"}}, "priority":3, "limits":{"minStateHoldS":900} } ] ``` Notes de schéma : - `loads[].declared` : saisi par l'installateur (plaque signalétique, câblage). Fiable. - `loads[].defaults` : valeurs approximatives par catégorie. Point de départ. **Non émis en 1.1.** - `loads[].learned` : estimations de l'optimiseur, **renvoyées** au plugin pour persistance et affichage, avec `confidence` (0–1). Le rule-based les ignore. - `loads[].priority` : **rang effectif** (1 = premier servi). Voir annexe C. Un optimiseur qui ne lit que `priority` reste correct. - `loads[].limits` : verrous anti-flapping. Le plugin LES APPLIQUE quoi qu'il arrive ; ils sont transmis pour que l'optimiseur planifie intelligemment, pas pour qu'il les fasse respecter. **Mécanisme et domaine** - `adapter` — mécanisme d'actionnement, valeurs **du code** (`LoadConfig::adapter`) : `relay-router` (combinaison de relais, consigne en watts), `etmvariableload` (consigne continue en watts), `sg-ready`, `evcharger` ; `battery` réservé (annexe F). - `domain` — intention déclarée par l'installateur, **jamais déduite du mécanisme**, **non lue par l'arbitre** : `ev` | `ecs` | `heating` | `hvac` | `battery`, ou absent. - Une PAC qui assure aussi l'ECS sans ballon séparé = **une seule charge** (`heating`). - Un décideur ne doit **jamais** raisonner sur `adapter` pour savoir ce que mesure une charge : c'est `measurement.source` qui le dit (LM-1106). **Groupes (optionnel)** - `group` = `{groupRank, rankInGroup}` : **clé de tri**, jamais détenteur de budget (`specs/spec_loadmodel.md` LM-500). `priority` est le tri total résultant, calculé par le plugin. Absent tant que les groupes ne sont pas implémentés (rang global unique). **Besoins (`needs`)** - `energyTargets` : énergie minimale à avoir délivrée à une échéance. Forme générique (ECS, VE converti en Wh par le plugin). Plusieurs cibles possibles. - `comfort` : `ecoMinC` doit **toujours** être tenu ; `comfortC` seulement sur surplus (`specs/spec_loadmodel.md` §10, LM-1010). **Non émis en 1.1** (aucune sonde de température n'entre dans le contexte). - `source` de chaque besoin : `installer` | `user-intent` | `learned`. Une intention client prime sur un besoin appris. Le moteur 1.1 émet `installer` (obligation quotidienne) et `user-intent` (dérogation à cible d'énergie) ; `learned` est réservé. - Champs 1.0 (`dailyDeadline`, `minEnergyWhPerDay`, `targetSocPercent`, `deadline`) restent émis ; un optimiseur 1.1 préfère `energyTargets`. **VE** - La priorité porte sur la **borne**, pas sur le véhicule. - `telemetry.vehicle` (optionnel) : `{"socPercent":54,"source":"myskoda","capacityWh":77000}` quand une intégration véhicule existe ; absent sinon — **non émis en 1.1** (aucune intégration). - Une borne **configurée dont l'appareil manque** n'a ni `declared`, ni `limits`, ni `pluggedIn`/`charging` : ses zéros diraient « inconnu ». `declared.phases` porte les phases **visées** ce cycle, pas celles de la plaque (E26). ### `loads[].telemetry` — champs ETM Ils changent la lecture du contexte : un optimiseur qui les ignore prendra de mauvaises décisions. | Champ | Type | Sens | |---|---|---| | `available` | bool | **Gouverne l'ALLOCATION, pas la COMPTABILITÉ.** Faux ⇒ la charge ne doit recevoir **aucune** consigne neuve — mais elle **continue d'être comptée** pour ce qu'elle consomme via `currentPowerW`. Sauter une charge indisponible sans la compter offrirait aux suivantes un budget qui n'existe pas. | | `lockMinPowerW` | W | Plancher imposé **maintenant** par un verrou anti-rebond : puissance déjà engagée que l'adaptateur ne peut pas couper. Le budget DOIT être décrémenté de cette valeur **même si elle dépasse le budget disponible**. | | `lockMaxPowerW` | W | Plafond imposé **maintenant** par un verrou : au-delà, l'adaptateur refusera de démarrer. **Négatif ⇒ aucun plafond** (et non « plafond nul »). | | `minState` / `maxState` | int | SG-Ready : fenêtre d'états autorisée **maintenant** par `minStateHold`. Gel total (`minState == maxState == state`) tant que le verrou court. | | `measurement` | objet | `{source, powerW}` — `source` : `meter` (compteur dédié désigné) \| `device` (l'appareil piloté mesure) \| `none` (rien ne mesure : état positif, pas une erreur). **Une puissance déclarée n'est jamais une mesure.** Sémantique : `INTERFACE.md`, `specs/spec_loadmodel.md` LM-1105/1106. | Une charge en défaut d'écriture est **figée** : `available = false` et `lockMinPowerW == lockMaxPowerW ==` la puissance crue engagée. Le plancher n'est jamais supérieur au plafond. Ces champs sont exprimés **en watts**, jamais en index de palier : aucun identifiant de relais ni index de combinaison ne franchit cette frontière. Une charge arbitrée (§5.3) — la forme dépend du mécanisme (adapter). **`LoadWatts`** — Charge pilotée en watts : combinaison de relais (relay-router) ou consigne continue (etmvariableload). | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `id` | string | oui | ≥ 1 car. | Identifiant de la charge — clé des actions. | | `label` | string | oui | | Nom lisible. | | `priority` | integer | oui | | Rang effectif (1 = premier servi). | | `domain` | string | non | `ecs` · `ev` · `heating` · `hvac` · `battery` | Intention déclarée par l'installateur, jamais déduite du mécanisme, non lue par l'arbitre (§5.3). GEN : LoadConfig::knownDomains(). | | `learned` | `LoadLearned` | non | | | | `needs` | `LoadNeeds` | non | | | | `adapter` | string | oui | `relay-router` · `etmvariableload` | | | `declared` | object | oui | | | | `declared.minPowerW` | number | oui | | 0 = aucun plancher. | | `declared.maxPowerW` | number | oui | | | | `telemetry` | object | oui | | | | `telemetry.currentPowerW` | number | oui | | Puissance courante (W) telle que l'adaptateur la publie. | | `telemetry.available` | boolean | oui | | Gouverne l'ALLOCATION, pas la COMPTABILITÉ (§5.3). | | `telemetry.measurement` | `Measurement` | non | | Mesure par charge (§5.3, LM-1105/1106) : décider sur source, jamais sur adapter. | | `telemetry.lockMinPowerW` | number | oui | | Plancher imposé MAINTENANT par un verrou. | | `telemetry.lockMaxPowerW` | number | oui | | Plafond imposé maintenant ; négatif = aucun plafond. | | `limits` | object | oui | | | | `limits.minOnS` | integer | oui | | | | `limits.minOffS` | integer | oui | | | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). **`LoadEvCharger`** — Borne. Une borne CONFIGURÉE dont l'appareil manque n'a ni declared, ni limits, ni pluggedIn/charging : ses zéros diraient « inconnu ». | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `id` | string | oui | ≥ 1 car. | Identifiant de la charge — clé des actions. | | `label` | string | oui | | Nom lisible. | | `priority` | integer | oui | | Rang effectif (1 = premier servi). | | `domain` | string | non | `ecs` · `ev` · `heating` · `hvac` · `battery` | Intention déclarée par l'installateur, jamais déduite du mécanisme, non lue par l'arbitre (§5.3). GEN : LoadConfig::knownDomains(). | | `learned` | `LoadLearned` | non | | | | `needs` | `LoadNeeds` | non | | | | `adapter` | string | oui | `"evcharger"` | | | `declared` | object | non | | | | `declared.minA` | number | oui | | | | `declared.maxA` | number | oui | | | | `declared.phases` | integer | oui | | Phases VISÉES ce cycle, pas celles de la plaque (E26). | | `telemetry` | object | oui | | | | `telemetry.currentPowerW` | number | oui | | Puissance courante (W) telle que l'adaptateur la publie. | | `telemetry.available` | boolean | oui | | Gouverne l'ALLOCATION, pas la COMPTABILITÉ (§5.3). | | `telemetry.measurement` | `Measurement` | non | | Mesure par charge (§5.3, LM-1105/1106) : décider sur source, jamais sur adapter. | | `telemetry.pluggedIn` | boolean | non | | | | `telemetry.charging` | boolean | non | | | | `telemetry.sessionWh` | number | non | | Absent si la borne ne compte pas sa session (LM-1009 C1). | | `limits` | object | non | | | | `limits.chargingEnabledLockS` | integer | oui | | | | `limits.currentChangeLockS` | integer | oui | | | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). **`LoadSgReady`** — PAC SG-Ready : 4 états normés, kind state. | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `id` | string | oui | ≥ 1 car. | Identifiant de la charge — clé des actions. | | `label` | string | oui | | Nom lisible. | | `priority` | integer | oui | | Rang effectif (1 = premier servi). | | `domain` | string | non | `ecs` · `ev` · `heating` · `hvac` · `battery` | Intention déclarée par l'installateur, jamais déduite du mécanisme, non lue par l'arbitre (§5.3). GEN : LoadConfig::knownDomains(). | | `learned` | `LoadLearned` | non | | | | `needs` | `LoadNeeds` | non | | | | `adapter` | string | oui | `"sg-ready"` | | | `declared` | object | oui | | | | `declared.states` | [integer] | oui | ≥ 1 ; ≤ 4 | | | `declared.estimatedPowerW` | object | oui | | | | `telemetry` | object | oui | | | | `telemetry.currentPowerW` | number | oui | | Puissance courante (W) telle que l'adaptateur la publie. | | `telemetry.available` | boolean | oui | | Gouverne l'ALLOCATION, pas la COMPTABILITÉ (§5.3). | | `telemetry.measurement` | `Measurement` | non | | Mesure par charge (§5.3, LM-1105/1106) : décider sur source, jamais sur adapter. | | `telemetry.state` | integer | oui | ≥ 0 ; ≤ 4 | État commandé (1-4) ; 0 = non applicable. | | `telemetry.minState` | integer | oui | | Fenêtre d'états autorisée MAINTENANT par minStateHold. | | `telemetry.maxState` | integer | oui | | | | `limits` | object | oui | | | | `limits.minStateHoldS` | integer | oui | | | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). Mesure par charge (§5.3, LM-1105/1106) : décider sur source, jamais sur adapter. **`Measurement, forme 1`** — Rien ne mesure : état positif, pas une erreur. | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `source` | string | oui | `"none"` | | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). **`Measurement, forme 2`** | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `source` | string | oui | `meter` · `device` | | | `powerW` | number | oui | | | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). **`LoadNeeds`** — Besoins (§5.3). Champs 1.0 émis tels quels quand ils sont déclarés ; un optimiseur 1.1 préfère energyTargets. | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `dailyDeadline` | string | non | motif `^[0-9]{2}:[0-9]{2}$` | Heure murale locale HH:MM (1.0). | | `minEnergyWhPerDay` | integer | non | > 0 | | | `targetSocPercent` | integer | non | > 0 | | | `deadline` | string | non | instant avec décalage | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. | | `energyTargets` | [`EnergyTarget`] | non | | | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). **`EnergyTarget`** | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `by` | string | oui | instant avec décalage | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. | | `minWh` | number | oui | > 0 | Énergie minimale à avoir délivrée à l'échéance. | | `source` | string | oui | `installer` · `user-intent` | learned n'est pas émis en 1.1. | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). **`LoadLearned`** — Absent tant qu'aucun profil n'a été reçu (E12). | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `dailyEnergyWh` | number | oui | | | | `confidence` | number | oui | ≥ 0 ; ≤ 1 | | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). ## 6. Réponse : plan par créneaux ```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. Côté moteur (plan publié vers l'app, traces), ce plan porte `strategy: "rules"`, la même valeur que `activeStrategy` ; le `strategy` d'un optimiseur reste une chaîne libre, informative. - **Le contournement de verrou (`force`) n'existe pas sur le fil.** Il est réservé au repli de sécurité interne (L2) ; un champ `force` reçu du socket est ignoré. - **`validUntil`** : au-delà, le plan est **expiré** → repli rule-based jusqu'au prochain plan valide. Absent = plan valable tant qu'un créneau couvre `now`. - **`expected`** (optionnel, par créneau) : trajectoire prévue. Affichage et mesure d'écart plan/réalité ; **jamais utilisé pour commander**. - **`diagnostics`** (optionnel) : `status` `optimal` | `feasible` | `best-so-far` (calcul interrompu, meilleur plan trouvé) | `infeasible-relaxed` (besoins non tenus nommés dans les `reason`) ; `computeMs`, `gapPercent`, `solver` affichés en mode installateur et enregistrés dans les traces. `inputsUsed.pv` peut valoir `hybrid` si l'optimiseur a appliqué sa propre correction apprise. **Explications et langue** - Sur le socket, `reason` (texte, français) est **obligatoire** : il sert aux traces et au mode installateur. - `reasonCode` + `reasonParams` (optionnels) : code stable du catalogue `DecisionCode` (`etm/types/decisionreason.h`, rendu documenté dans `INTERFACE.md`) et ses paramètres. - **Vers l'app, seul `{reasonCode, reasonParams}` est transmis** — jamais le texte libre (`AGENTS.md` invariant 12). Une action sans `reasonCode` connu du catalogue est transmise sous le code générique `OPTIMIZER_DECISION`, sans texte. ### Types d'action (`kind`) | `kind` | Mécanismes | Champs | Exemple | |---|---|---|---| | `setpoint` | evcharger, relay-router, etmvariableload | `currentA` (borne) ou `powerW` | borne à 10 A ; chauffe-eau à 1200 W | | `state` | sg-ready | `state` (1=verrouillage, 2=normal, 3=recommandation, 4=forcé) | PAC en état 3 | | `constraint` | — (réservé batterie, annexe F) | `charge`/`discharge` : `allow`\|`forbid` | — | La frontière est en **watts** : `relay-router` choisit lui-même la combinaison de relais, `etmvariableload` reçoit une consigne continue. Aucun index de palier, aucun pourcentage. Ce que le contrat déclare de la réponse (une réponse peut porter d'autres champs : ils sont ignorés, §9). Les codes émissibles et leurs seules clés de paramètres transmises : **`Abstention`** — « Pas de plan maintenant » (§7) : repli pour ce cycle, pas un échec. | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `abstain` | boolean | oui | `true` | | | `reason` | string | non | | | Champs non déclarés : ignorés (§9). **`Plan`** — Plan par créneaux (§6). | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `abstain` | boolean | non | `false` | | | `planId` | string | non | | | | `strategy` | string | non | | Chaîne libre, informative. | | `computedAt` | string | non | instant avec décalage | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. | | `validUntil` | string \| null | non | instant avec décalage | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. | | `slots` | [`Slot`] | oui | | [] = abstention (empty-plan). Seul le créneau couvrant maintenant est exécuté. | | `diagnostics` | `Diagnostics` | non | | | Champs non déclarés : ignorés (§9). **`Slot`** | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `from` | string | oui | instant avec décalage | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. | | `to` | string | oui | instant avec décalage | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. | | `actions` | [`Action`] \| null | non | | Absent ou null = aucune action. | | `expected` | `Expected` | non | | | Champs non déclarés : ignorés (§9). **`SetpointAction`** | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `loadId` | string | oui | ≥ 1 car. | Charge visée : connue du contexte (règle unknown-load), une fois par créneau (duplicate-load). | | `reason` | string | oui | ≥ 1 car. ; motif `\S` | Texte français, OBLIGATOIRE et non vide (règle missing-reason). Traces et journal seulement, jamais vers l'app. | | `reasonCode` | string | non | `SURPLUS_SETPOINT` · `SURPLUS_INSUFFICIENT` · `BELOW_MIN_POWER` · `EV_DEADLINE` · `SG_FORCED` · `SG_RECOMMENDED` · `SG_NORMAL` · `ECO_FLOOR_GRID` · `PRICE_SLOT` · `OPTIMIZER_DECISION` | Codes de motif qu'un optimiseur peut émettre (§6, liste blanche). GEN : DecisionCode::optimizerEmittableCodes(). Clés de paramètres admises par code : x-etm-reason-params. | | `reasonParams` | object \| null | non | | Paramètres du motif : valeurs nombre, booléen ou code [A-Za-z]+ ; clés admises par code : x-etm-reason-params. Toute autre clé est SUPPRIMÉE avant l'app (filtrée, pas refusée). | | `force` | quelconque | non | | INTERDIT sur le fil (§6) : ignoré, signalé une fois. | | `kind` | string | oui | `"setpoint"` | | | `powerW` | number | non | ≥ 0 | Consigne en watts (relay-router, etmvariableload ; ou borne). | | `currentA` | number | non | ≥ 0 | Borne : courant (0 = suspendre). | | `phases` | enum | non | `1` · `3` | Borne : phases visées ; absent = inchangé. | Champs non déclarés : ignorés (§9). Au moins un de : `powerW`, `currentA`. **`StateAction`** | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `loadId` | string | oui | ≥ 1 car. | Charge visée : connue du contexte (règle unknown-load), une fois par créneau (duplicate-load). | | `reason` | string | oui | ≥ 1 car. ; motif `\S` | Texte français, OBLIGATOIRE et non vide (règle missing-reason). Traces et journal seulement, jamais vers l'app. | | `reasonCode` | string | non | `SURPLUS_SETPOINT` · `SURPLUS_INSUFFICIENT` · `BELOW_MIN_POWER` · `EV_DEADLINE` · `SG_FORCED` · `SG_RECOMMENDED` · `SG_NORMAL` · `ECO_FLOOR_GRID` · `PRICE_SLOT` · `OPTIMIZER_DECISION` | Codes de motif qu'un optimiseur peut émettre (§6, liste blanche). GEN : DecisionCode::optimizerEmittableCodes(). Clés de paramètres admises par code : x-etm-reason-params. | | `reasonParams` | object \| null | non | | Paramètres du motif : valeurs nombre, booléen ou code [A-Za-z]+ ; clés admises par code : x-etm-reason-params. Toute autre clé est SUPPRIMÉE avant l'app (filtrée, pas refusée). | | `force` | quelconque | non | | INTERDIT sur le fil (§6) : ignoré, signalé une fois. | | `kind` | string | oui | `"state"` | | | `state` | integer | oui | ≥ 1 ; ≤ 4 | 1 = verrouillage, 2 = normal, 3 = recommandation, 4 = forcé. | Champs non déclarés : ignorés (§9). **`ConstraintAction`** — RÉSERVÉ (annexe F) : forme décrite, toujours refusée (kind-mismatch). | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `loadId` | string | oui | ≥ 1 car. | Charge visée : connue du contexte (règle unknown-load), une fois par créneau (duplicate-load). | | `reason` | string | oui | ≥ 1 car. ; motif `\S` | Texte français, OBLIGATOIRE et non vide (règle missing-reason). Traces et journal seulement, jamais vers l'app. | | `reasonCode` | string | non | `SURPLUS_SETPOINT` · `SURPLUS_INSUFFICIENT` · `BELOW_MIN_POWER` · `EV_DEADLINE` · `SG_FORCED` · `SG_RECOMMENDED` · `SG_NORMAL` · `ECO_FLOOR_GRID` · `PRICE_SLOT` · `OPTIMIZER_DECISION` | Codes de motif qu'un optimiseur peut émettre (§6, liste blanche). GEN : DecisionCode::optimizerEmittableCodes(). Clés de paramètres admises par code : x-etm-reason-params. | | `reasonParams` | object \| null | non | | Paramètres du motif : valeurs nombre, booléen ou code [A-Za-z]+ ; clés admises par code : x-etm-reason-params. Toute autre clé est SUPPRIMÉE avant l'app (filtrée, pas refusée). | | `force` | quelconque | non | | INTERDIT sur le fil (§6) : ignoré, signalé une fois. | | `kind` | string | oui | `"constraint"` | | | `charge` | enum | non | `allow` · `forbid` | | | `discharge` | enum | non | `allow` · `forbid` | | Champs non déclarés : ignorés (§9). `ReasonParams` : object \| null — . Paramètres du motif : valeurs nombre, booléen ou code [A-Za-z]+ ; clés admises par code : x-etm-reason-params. Toute autre clé est SUPPRIMÉE avant l'app (filtrée, pas refusée). **`Expected`** — Trajectoire prévue : affichage et mesure d'écart, JAMAIS pour commander. | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `gridImportW` | number | non | | | | `gridExportW` | number | non | | | | `batterySocPercent` | quelconque | non | | RÉSERVÉ (annexe F) : ignoré. | Champs non déclarés : ignorés (§9). **`Diagnostics`** — Informatif : mode installateur et traces. | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `solver` | string | non | | | | `status` | string | non | `optimal` · `feasible` · `best-so-far` · `infeasible-relaxed` | | | `gapPercent` | number | non | | | | `computeMs` | number | non | ≥ 0 | | | `inputsUsed` | object | non | | | | `inputsUsed.pv` | string | non | `physics` · `hybrid` · `none` | | Champs non déclarés : ignorés (§9). | `reasonCode` | Clés de `reasonParams` admises | |---|---| | `SURPLUS_SETPOINT` | `budgetW`, `setpointW`, `stepped` | | `SURPLUS_INSUFFICIENT` | `budgetW` | | `BELOW_MIN_POWER` | `budgetW`, `minPowerW`, `state` | | `EV_DEADLINE` | — | | `SG_FORCED` | `budgetW`, `estimatedW` | | `SG_RECOMMENDED` | `budgetW`, `estimatedW` | | `SG_NORMAL` | `budgetW` | | `ECO_FLOOR_GRID` | `gridW`, `atMaximum`, `remainingWh`, `targetWh`, `remainingS`, `regime`, `minAlwaysW`, `boundBy` | | `PRICE_SLOT` | `priceCtkWh`, `setpointW` | | `OPTIMIZER_DECISION` | — | ## 7. Abstention et repli L'optimiseur peut répondre : ```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` est **inchangé** (une abstention ne ressuscite pas un optimiseur mort, E-T4-1). | Situation | optimizerAlive | Stratégie active | |---|---|---| | Réponse plan valide | true | optimiseur | | `abstain` | inchangé | rules (ce cycle) | | Plan expiré (`validUntil` dépassé) | inchangé | rules jusqu'au prochain plan valide | | Timeout / erreur / plan invalide sur **deux requêtes consécutives** | false | rules | | Socket absent / fermé | false | rules | | `optimizerExpected=false` (aucun endpoint configuré) | false | rules | | Mode dégradé L2 (compteur muet > 90 s) | inchangé | **aucune** — planification suspendue, repli de sécurité | Retour à `optimizerAlive = true` au premier plan valide. Les échecs se comptent **par requête** : une abstention ou un plan déjà expiré à la réception interrompent la série ; une requête remplacée par une plus récente n'est pas un échec. Avant le premier plan valide, `optimizerAlive` vaut `false`. Un plan invalide remplace le cache (l'ancien plan valide n'est plus appliqué) et un socket fermé l'abandonne (E-T3-9). **Ce que le validateur refuse en plus de la forme.** Le JSON Schema du contrat dit la forme ; le moteur juge ensuite chaque réponse sur le contexte de SA requête, dans cet ordre, et le premier contrôle qui échoue donne son code (`fallbackCode` vers l'app, `lastErrorCode` pour un refus) : Ce que le moteur refuse EN PLUS de la forme, dans l'ordre de ses contrôles (OptimizerPlan::valider, OptimizerClient::handleHandshakeReply). name = code du verdict. issue : abstention (repli ce cycle, pas un échec), expired, invalid (plan refusé en entier, compté comme échec), connection-refused (handshake). needs : ce qu'il faut connaître en plus de la réponse (la requête, le handshake). | Règle | Méthode | Issue | `fallbackCode` | Échec compté | Il faut aussi | Le schéma la voit aussi | Ce qui est refusé | |---|---|---|---|---|---|---|---| | `abstain` | GetPlan | abstention | `abstain` | non | — | non | result.abstain vaut true. | | `malformed-plan` | GetPlan | invalid | `malformed-plan` | oui | — | oui | slots absent ou non liste ; un créneau qui n'est pas un objet ; actions non liste. | | `empty-plan` | GetPlan | abstention | `empty-plan` | non | — | non | slots est une liste vide. | | `bad-time` | GetPlan | invalid | `bad-time` | oui | — | oui | from/to illisibles ou sans décalage, ou from ≥ to ; validUntil illisible ou sans décalage. | | `slots-unordered` | GetPlan | invalid | `slots-unordered` | oui | — | non | un créneau commence avant la fin du précédent. | | `malformed-action` | GetPlan | invalid | `malformed-action` | oui | — | oui | action qui n'est pas un objet, ou sans loadId (chaîne non vide). | | `unknown-load` | GetPlan | invalid | `unknown-load` | oui | request | non | loadId absent des loads[] de la requête. | | `unknown-kind` | GetPlan | invalid | `unknown-kind` | oui | — | oui | kind hors ActionKind. | | `kind-mismatch` | GetPlan | invalid | `kind-mismatch` | oui | request | non | kind différent de celui du mécanisme de la charge (setpoint : evcharger, relay-router, etmvariableload ; state : sg-ready ; constraint : aucun). | | `kind-not-announced` | GetPlan | invalid | `kind-not-announced` | oui | handshake | non | capabilities.kinds annoncé au handshake et kind absent de la liste. | | `missing-reason` | GetPlan | invalid | `missing-reason` | oui | — | oui | reason absent, non textuel, ou vide une fois les caractères de contrôle retirés. | | `bad-value` | GetPlan | invalid | `bad-value` | oui | request | oui | state absent ou hors 1-4 ; borne : currentA/powerW absents ou négatifs, phases hors {1, 3} ; charge en watts : powerW absent ou négatif. | | `duplicate-load` | GetPlan | invalid | `duplicate-load` | oui | — | non | deux actions pour la même charge dans un créneau. | | `expired` | GetPlan | expired | `expired` | non | request | non | validUntil ≤ timestamp de la requête. | | `no-current-slot` | GetPlan | abstention | `no-current-slot` | non | request | non | aucun créneau ne couvre le timestamp de la requête. | | `incomplete-slot` | GetPlan | invalid | `incomplete-slot` | oui | request | non | une charge available de la requête n'a pas d'action dans le créneau courant (décision Q1). | | `handshake-refused` | Handshake | connection-refused | `no-plan` | non | — | non | réponse error (token absent ou différent : -32001 recommandé) → connexion fermée, nouvelle tentative au backoff. | | `invalid-handshake` | Handshake | connection-refused | `no-plan` | non | — | oui | result non objet, protocolVersion absent ou vide, capabilities présent et non objet. | | `major-mismatch` | Handshake | connection-refused | `no-plan` | non | — | non | MAJEURE de protocolVersion (ce qui précède le premier point) différente de celle du moteur. | L'état exposé à l'app (`optimizerExpected`, `optimizerAlive`, `activeStrategy` ∈ `optimizer` | `rules` | `none`) reflète cet état en continu ; chaque transition émet `NymeaEnergy.OptimizerStatusChanged` (`INTERFACE.md`). Il n'y a pas de `tier` : l'app déduit ce qu'elle affiche de ces trois champs. ## 8. Apprentissage et transparence - L'optimiseur peut renvoyer ses estimations (`learned` + `confidence`) dans une notification `UpdateLearned` (sans id, fire-and-forget) ; le plugin les persiste et les retransmet dans les contextes suivants. **État 1.1 du moteur** : reçue, journalisée une fois, ignorée — la persistance est un lot ultérieur (`INTERFACE.md`). - Tant que `confidence < 0.7` (seuil indicatif), l'app peut afficher un badge « profil en apprentissage ». - Les données d'apprentissage restent **locales**. Rien ne quitte le site. **`UpdateLearned` — params** | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `loads` | [object] | non | | Non normé en 1.1 : intention §8 (learned + confidence par charge). | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). - L'état des modèles de prévision (`forecast.pv.conversion`, `forecast.pv.corrections`, `forecast.baseload.model`) est exposé dans l'app au même titre que `confidence`. ## 9. Versionnement - `protocolVersion` en semver. Même majeure = compatible. - Champs inconnus dans `params`/`result` : **ignorés silencieusement**. - Changements incompatibles (renommage, sémantique) = majeure + entrée changelog §0. --- ## Annexe A — Session minimale ``` → {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.1","engine":"etm-powersync-energy-plugin-etm/1.15.2+etm70"}} ← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.1","name":"mon-optimiseur","planning":false}} → {"jsonrpc":"2.0","id":2,"method":"GetPlan","params":{ ...SurplusContext... }} ← {"jsonrpc":"2.0","id":2,"result":{"planId":"x","strategy":"custom","slots":[{ "from":"...","to":"...","actions":[...] }]}} ``` Un optimiseur conforme minimal tient en ~50 lignes de Python : répondre au handshake, renvoyer un plan à un créneau (ou `abstain`). ## Annexe B — Exemple : brancher Akkudoktor-EOS EOS (github.com/Akkudoktor-EOS/EOS) est un optimiseur-planificateur HTTP (Python, algorithme génétique, horizon 48 h). Intégration par un **pont** (~200 lignes) tournant où l'on veut sur le LAN : ``` plugin (client) ──tcp://nas:7600──► eos-bridge ──HTTP──► serveur EOS ``` Le pont : (1) répond au Handshake ; (2) traduit `SurplusContext` → payload EOS — `forecast` fournit PV, consommation de base et prix sur grille commune ; (3) traduit le plan EOS → `slots[]`/actions ; (4) sert le plan en cache et recalcule en arrière-plan ; (5) répond `abstain` pendant un recalcul initial. Les bornes de sécurité du plugin s'appliquent intégralement : un plan EOS aberrant est écrêté, pas exécuté aveuglément. ## Annexe C — Priorités : modèle utilisateur - **Liste ordonnée** (drag-and-drop dans l'app) : l'ordre de service du surplus. Le rule-based l'exécute en cascade (waterfall) ; un planificateur s'en sert comme préférence quand tout ne peut pas être servi. - **Groupes** : déplacer une famille entière sans renuméroter (`spec_loadmodel.md` §5). Un groupe trie, il ne reçoit pas de budget. - **Besoins par charge** : échéances, minimums, cible, `energyTargets`, `comfort`. - **Intentions client** : deviennent des besoins `source: "user-intent"` — l'optimiseur ré-arbitre avec, il n'est pas contourné. - **Promotions conditionnelles** : une échéance qui approche ou un signal tarifaire peut faire passer une charge devant — toujours expliqué. Pas de poids, pas de pourcentages : un ordre, des besoins, des exceptions expliquées. ## Annexe D — Traces et rejeu But : comparer des optimiseurs **sur les mêmes entrées** et rejouer un incident. - Option plugin `optimizerTrace` (défaut : **désactivée** ; à activer sur banc), réglée par `NymeaEnergy.SetOptimizerTrace` (`INTERFACE.md`, « Traces de décision »). - Fichiers NDJSON locaux `etm-powersync-optimizer-trace-AAAA-MM-JJ.ndjson`, rotation à minuit heure locale, rétention configurable (défaut 14 jours), plafond par fichier journalier (défaut 64 Mio), sous le répertoire de stockage du moteur (`NymeaSettings::storagePath()`). Écriture asynchrone : une trace en échec ne bloque rien et ne change aucune décision. - Une ligne par cycle, **y compris sous le rule-based** (sa réponse est alors le plan du rule-based, `strategy: "rules"`) et en mode dégradé L2 (`strategy: "none"`, sans requête) : ```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} ``` - `request` : les params `GetPlan` envoyés (ou qui l'auraient été) ; `response` : ce qui a répondu à **cette** requête — `result` brut, ou `error.code` (`no-response` si elle a été remplacée sans réponse) ; `executed` : actions appliquées au cycle ; `latencyMs` : optimiseur seulement. La réponse à la requête N sert au cycle N+1 : la ligne N l'attend. - Jamais le token (il ne voyage que dans le handshake ; recopié par une réponse, il est masqué). - **Rejeu** : `tools/etm-powersync-trace-replay` relit `request`, l'envoie à un optimiseur candidat (`unix://` par défaut, `tcp://` accepté), écrit les réponses à côté de la trace et résume : taux d'abstention, `computeMs` médian et max, cycles dont les actions du créneau courant diffèrent du plan enregistré. - Les traces restent sur le site. Export manuel par l'installateur uniquement. ## Annexe E — Optimiseur de référence `tools/etm-powersync-ref-optimizer/` : serveur Python minimal (bibliothèque standard seule) conforme 1.1, `unix:///run/powersync/optimizer.sock` par défaut, `tcp://` accepté ; répondant un plan à un créneau qui reproduit le waterfall rule-based. Usages : test de bout en bout du `SocketScheduler` (suite `etm-powersync-refoptimizer`) ; point de comparaison du banc ; exemple communautaire. Mode d'emploi et modes d'injection de défauts : son `README.md`. Il s'annonce **`powersync-optimizer`** au handshake (`name`). `powersync-optimizer` = exemple de référence GPL (ce dépôt) ; `etm-optimizer` = optimiseur ETM (propriétaire, hors de ce dépôt). Le nom n'est pas contrôlé par le moteur (§3) : il n'est qu'affiché. - Il ne lit **que** le contexte `GetPlan` (aucune connexion sortante, aucun fichier hors le token). - Il reproduit la **passe confort** : tri (rang, identifiant), surplus net signé, recrédit de la consommation courante, réserve de stockage, plancher, plafond, verrous, mapping SG-Ready avec hystérésis. Il ne reproduit pas ce que le contexte 1.1 ne porte pas : passe éco (avancement livré), dérogations, autorisation de soutirage, paliers d'un `relay-router`, refus de commande, proxy VE. - Il n'émet que les codes `reasonCode` qu'un optimiseur peut émettre, avec leurs seules clés documentées ; un code du rule-based hors de cette liste (verrou, réserve…) part en `OPTIMIZER_DECISION`, le mécanisme nommé dans `reason`. ## Annexe F — Champs réservés (non normatifs) Ces champs sont **réservés** : aucun porteur n'existe dans le moteur (pas de `BatteryAdapter`, lot 3f). Un optimiseur ne doit pas les émettre ; le plugin les ignore. Ils deviendront normatifs avec le lot qui les implémente. - Adaptateur `battery` ; kind `constraint` (`charge`/`discharge` : `allow`|`forbid`) ; `setpoint` batterie avec `source:"grid"` (charge réseau). - `loads[].socTarget` `{percent, by, source}` : cible de SOC comme participant à la priorisation. - `loads[].policies` : `noDischargeWhileEvCharging`, `holdChargeForDomains`, `gridChargeAllowed`. - `expected.batterySocPercent`. ## Annexe G — Contrat machine, validateur et conformité - **Le contrat** : `docs/etm-powersync-optimizer-protocol.openrpc.json` (OpenRPC 1.3). Méthodes `Handshake`, `GetPlan`, `UpdateLearned` ; forme des messages en JSON Schema (`components.schemas`) ; ce qu'OpenRPC ne sait pas dire en extensions `x-etm-…` : `x-etm-transport` (unix/tcp, NDJSON, rôles, délais), `x-etm-direction` (sens d'une méthode), `x-etm-fallback-machine` (§7), `x-etm-semantic-rules` (ce que le validateur refuse en plus, avec `fallbackCode`), `x-etm-tolerances` (ce que le moteur accepte au-delà), `x-etm-reason-params`, `x-etm-generated` (listes fermées régénérées depuis le code). Forme côté moteur (requêtes) **fermée** ; côté optimiseur (réponses) **ouverte** (§9). - **Servi** par le moteur qui tourne : `NymeaEnergy.GetOptimizerProtocol` (`INTERFACE.md`). Pas de `rpc.discover` sur le socket : l'optimiseur se déclare au handshake, point. - **Validateur** : `tools/etm-powersync-schema-check` (Python, bibliothèque standard) — un message, une session NDJSON (les deux sens) ou des traces (annexe D) ; `--semantic` applique les règles. - **Conformité d'un optimiseur quelconque** : suite `etm-powersync-optimizer-conformance` (`tests/auto/conformance/README.md`) — le vrai client du moteur branché sur l'optimiseur que lance `ETM_POWERSYNC_OPTIMIZER_CMD`. - **Prose générée** : `tools/etm-powersync-gen-protocol-doc` (`--check` dans `tools/ci-quality.sh`).