# OPTIMIZER_PROTOCOL — v1.2 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.2** — 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.2 (2026-10) Mineure. Norme les points que le premier optimiseur réel a trouvés ouverts : erreurs renvoyées par l'optimiseur, fermeture après un refus, token non exigé, sens d'`UpdateLearned`, longueur de ligne, lots, propriétaire de `/run/powersync`. **Côté moteur, seule la version annoncée change** : il n'émet aucun message nouveau et juge les réponses exactement comme en 1.1. **Compatibilité.** Même majeure : un moteur 1.2 accepte un optimiseur 1.0 ou 1.1, et un optimiseur 1.2 accepte un moteur 1.0 ou 1.1 (seule la majeure est comparée, §3). Les règles nouvelles portent sur ce que fait l'optimiseur dans des situations que le moteur ne produit pas — contexte invalide, `GetPlan` avant handshake, autre majeure, lot — et sur une qu'il peut produire, la ligne de plus de 1 Mio (§2, écart connu). Un optimiseur 1.1 qui y répond autrement continue de fonctionner avec ce moteur ; il n'est pas conforme 1.2 sur ces points, et s'aligne avant de s'annoncer 1.2. - §7 **Contexte `GetPlan` invalide** : l'optimiseur répond une **erreur `-32602`**, pas une abstention ; `error.data` liste les chemins fautifs et la nature de chaque problème, jamais les valeurs. Le moteur la compte comme un échec (règle des échecs consécutifs inchangée). - §3, §7 **Codes d'erreur de l'optimiseur normés** : majeure incompatible au handshake → `-32602` puis fermeture ; `GetPlan` avant un handshake réussi → `-32600`, connexion gardée ; token faux ou absent → `-32001` (inchangé). Toute autre erreur : un code JSON-RPC 2.0 standard. - §3 **Après un handshake refusé, les deux côtés ferment** : l'optimiseur après avoir envoyé l'erreur, le moteur à sa réception. - §3 **Token envoyé alors que l'optimiseur n'en exige pas : accepté**, sans erreur. - §8 **`UpdateLearned`** : sens optimiseur → moteur uniquement ; reçu par un optimiseur, notification ignorée, requête avec `id` → `-32601`. Sa **forme reste non normée en 1.2**. - §2 **`maxLineBytes` (1 Mio) dans les deux sens** ; ligne trop longue reçue par l'optimiseur → `-32600`, connexion gardée. - §2 **Lots JSON-RPC interdits** dans les deux sens : un message par ligne ; un lot reçu → `-32600`. - §2 **`/run/powersync` appartient au paquet de l'optimiseur** ; le moteur ne le crée ni ne le suppose. - Contrat machine : `methods[].errors` complétés, `x-etm-message-errors` (nouveau), `x-etm-transport` (`maxLineBytesScope`, `batches`, `runtimeDirectory`) ; tableau des erreurs du §7 généré depuis lui. ### 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`. **Le répertoire `/run/powersync` appartient au paquet de l'optimiseur** (1.2) : `RuntimeDirectory=powersync` de son unité systemd, groupe `powersync`. Le moteur ne le crée ni ne le suppose : sans optimiseur, il n'en a pas besoin — et il ne se connecte à rien tant qu'aucun endpoint n'est configuré (§7, `optimizerExpected=false`). - `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.** - **Pas de lot, dans les deux sens** (1.2). Chaque ligne porte un seul objet JSON-RPC ; un lot (`[…]`) est interdit en émission. Reçu par l'optimiseur : erreur `-32600` à `id: null`, aucun message du lot n'est traité, connexion conservée. Reçu par le moteur : ligne écartée (`invalid-json` : ce n'est pas un objet), comptée, connexion conservée ; une réponse contenue dans le lot n'est pas lue, et la requête qu'elle visait finit en délai dépassé (§4). - **Longueur de ligne : `maxLineBytes` = 1 Mio (1 048 576 octets, `\n` non compté), dans les deux sens** (1.2). Reçue trop longue par l'optimiseur : jetée sans être gardée en mémoire, erreur `-32600` à `id: null`, connexion conservée. Reçue par le moteur : ligne écartée (`line-too-long`), comptée, connexion conservée ; la requête en vol continue d'attendre — si sa réponse était cette ligne, elle finit en délai dépassé (10 s, échec compté, §4). > **Écart connu du moteur (1.2)** : il n'applique pas encore la borne à ce qu'il **émet**. > Rien ne borne la taille d'un contexte `GetPlan` (un `label` de charge très long suffit) ; une > telle requête part sur une seule ligne de plus de 1 Mio, un optimiseur conforme la refuse, et > la requête finit en délai dépassé. Éprouvé par `testAContextOverTheLineLimitIsStillSent` > (suite `optimizerclient`, échec attendu tant que l'écart vit). - 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.2", "engine":"etm-powersync-energy-plugin-etm/", "token":"" }} ``` Réponse attendue : ```json {"jsonrpc":"2.0","id":1,"result":{ "protocolVersion":"1.2", "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, **des deux côtés**. Majeure différente dans la réponse → le plugin refuse, log explicite, repli rule-based. Majeure différente dans la requête → l'optimiseur refuse (1.2) : erreur `-32602`, puis fermeture. Une autre mineure est acceptée des deux côtés. - `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é optimiseur et absent ou différent dans la requête → réponse `error` **`-32001`** (normé en 1.2 ; « recommandé » en 1.1) puis connexion fermée. **Un token envoyé alors que l'optimiseur n'en exige pas est accepté**, sans erreur (1.2). 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é. - **Refus et fermeture** (1.2). Un handshake refusé — token (`-32001`) ou majeure (`-32602`) — est fermé **des deux côtés** : l'optimiseur ferme après avoir envoyé l'erreur, le moteur à sa réception. Le moteur traite **toute** réponse `error` au handshake comme un refus, quel que soit son code (`handshake-refused`), et retente au backoff (§2). - **`GetPlan` avant un handshake réussi** (1.2) : l'optimiseur répond `-32600` et **garde** la connexion — le client peut encore envoyer `Handshake`. Le moteur n'en envoie jamais : il n'appelle `GetPlan` qu'une fois le handshake accepté. - Les codes d'erreur de l'optimiseur sont rassemblés au §7, « Erreurs renvoyées par l'optimiseur ». **`Handshake` — params** | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| | `protocolVersion` | `ProtocolVersion` | oui | `"1.2"` | Version du moteur ; seule la majeure est comparée, des deux côtés (une autre majeure : l'optimiseur répond -32602 et ferme). | | `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. - Les codes qui décrivent ce que le **moteur** constate ne sont pas émissibles (liste blanche ci-dessous). `ECO_FLOOR_BELOW_STAGE` en est un : une obligation dont la cadence tombe sous le premier palier de la charge, donc rien d'acheté. L'optimiseur ne reçoit pas les paliers (`powerLevels` n'est pas émis, §5.3) et ne peut pas le savoir ; le moteur le dit. ### 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). **Erreurs renvoyées par l'optimiseur** (1.2). Un optimiseur répond une erreur JSON-RPC, jamais une abstention, à ce qu'il ne peut pas traiter. En particulier : - **Contexte `GetPlan` invalide → `-32602`**, pas `abstain`. `error.data` liste les problèmes : pour chacun, le **chemin** du champ fautif et la **nature** du problème (obligatoire absent, type faux, valeur hors domaine…) — **jamais la valeur reçue**. Un contexte invalide est un défaut du moteur ; une abstention le cacherait. Le moteur compte cette erreur comme un **échec** de la requête (`rpc-error`), selon la règle des deux échecs consécutifs ci-dessus, inchangée. - Les codes sont ceux de JSON-RPC 2.0, plus `-32001` (token refusé), seul code propre au protocole. Une situation que le tableau ne nomme pas prend le code standard qui lui correspond (`-32603` erreur interne, par exemple). | Code | Méthode | `message` | Connexion après l'erreur | Quand | |---|---|---|---|---| | `-32001` | Handshake | token refusé | fermée | Token configuré côté optimiseur, et absent ou différent dans le Handshake. Un token envoyé alors que l'optimiseur n'en exige pas est ACCEPTÉ. | | `-32602` | Handshake | majeure incompatible | fermée | Majeure de params.protocolVersion différente de celle de l'optimiseur (1.2). | | `-32600` | GetPlan | handshake requis | conservée | GetPlan reçu avant un Handshake réussi sur cette connexion (1.2). Le moteur n'en envoie jamais : il attend d'être prêt. | | `-32602` | GetPlan | contexte invalide | conservée | Contexte GetPlan non conforme au contrat (1.2). error.data liste les problèmes : pour chacun, le chemin du champ fautif et la nature du problème, JAMAIS la valeur reçue. Une erreur et non une abstention : un contexte invalide est un défaut du moteur, une abstention le cacherait. | | `-32603` | GetPlan | erreur interne | conservée | Code JSON-RPC 2.0 standard pour toute autre panne de l'optimiseur. | | `-32700` | toute | erreur d'analyse | conservée | Ligne qui n'est pas du JSON (ou pas de l'UTF-8). Réponse à id null. Code standard JSON-RPC 2.0. | | `-32600` | toute | lot interdit | conservée | Ligne qui est un lot JSON-RPC ([…]) (1.2). Une seule réponse, à id null ; aucun message du lot n'est traité. | | `-32600` | toute | ligne trop longue | conservée | Ligne de plus de maxLineBytes octets, '\n' non compté (1.2). Jetée sans être analysée ni gardée en mémoire ; réponse à id null. | | `-32601` | toute | méthode inconnue | conservée | Requête (avec id) d'une méthode que l'optimiseur ne sert pas — dont UpdateLearned, qui ne va que de l'optimiseur au moteur (1.2). Une notification (sans id) : ignorée, sans réponse. | Ce que l'OPTIMISEUR répond à un message qu'il ne peut pas traiter, quelle que soit la méthode (1.2). Connexion conservée dans tous les cas. Le moteur n'émet que des objets, un par ligne, des méthodes Handshake et GetPlan : il n'en provoque aucune, sauf la ligne trop longue (écart connu du moteur 1.2 : il ne borne pas encore ce qu'il émet, §2). Reçue par le moteur, une réponse error à id null est une ligne écartée (missing-id), connexion conservée. Toute autre erreur : un code JSON-RPC 2.0 standard. Ce que fait le moteur d'une réponse error : - **Handshake** : Toute réponse error au Handshake, quel que soit son code : handshake-refused, le moteur ferme, nouvelle tentative au backoff. - **GetPlan** : Toute réponse error à GetPlan, quel que soit son code : échec de la requête (rpc-error), compté par la règle des échecs consécutifs (§7). **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, quel que soit son code (token refusé : -32001 ; majeure incompatible : -32602, Handshake.errors) → le moteur ferme, 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 du moteur** : reçue, journalisée une fois, ignorée — la persistance est un lot ultérieur (`INTERFACE.md`). - **Sens unique : de l'optimiseur vers le moteur** (1.2). Le moteur n'en envoie jamais. Reçue par un optimiseur : en notification (sans `id`), ignorée sans réponse ; en requête (avec `id`), erreur `-32601` (méthode inconnue). - **La forme d'`UpdateLearned` n'est pas normée en 1.2.** Le tableau ci-dessous dit ce que le contrat admet, pas ce qu'un moteur en fera : la forme sera fixée avec la première stratégie qui apprend. - 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.2 : 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. **Les deux côtés comparent la majeure** et elle seule (§3) : le moteur refuse un optimiseur d'une autre majeure, et l'optimiseur un moteur d'une autre majeure (`-32602`, 1.2). Toute mineure est acceptée. - Une mineure n'ajoute que des champs optionnels, ou des règles sur des situations que l'autre côté conforme ne produit pas (1.2 en est un exemple, §0). Elle se lit comme une liste de choses à aligner avant de s'annoncer de cette mineure, pas comme une rupture. - 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.2","engine":"etm-powersync-energy-plugin-etm/1.15.2+etm71"}} ← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.2","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":[...] }]}} ``` Ce que l'optimiseur répond à ce que le moteur n'envoie jamais (1.2, §2, §3, §7) — c'est ce qu'éprouve la suite de conformité, par une connexion de sonde : ``` → {"jsonrpc":"2.0","id":1,"method":"GetPlan","params":{}} (avant le handshake) ← {"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"handshake requis"}} connexion gardée → [{"jsonrpc":"2.0","id":2,"method":"UpdateLearned","params":{}}] (un lot) ← {"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"lot interdit"}} connexion gardée → {"jsonrpc":"2.0","id":3,"method":"Handshake","params":{"protocolVersion":"2.0","engine":"…"}} ← {"jsonrpc":"2.0","id":3,"error":{"code":-32602,"message":"majeure incompatible"}} puis fermeture ``` Un optimiseur conforme minimal tient en ~50 lignes de Python : répondre au handshake, renvoyer un plan à un créneau (ou `abstain`), et les erreurs du §7. ## 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.2, `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, longueur de ligne, lots, répertoire du socket), `x-etm-direction` (sens d'une méthode), `methods[].errors` et `x-etm-message-errors` (erreurs de l'optimiseur, 1.2), `x-etm-errors-engine` (ce que le moteur fait d'une erreur), `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`. Depuis 1.2, elle éprouve aussi, par une connexion de sonde, les erreurs du §7 que le moteur ne provoque pas (annexe A). - **Prose générée** : `tools/etm-powersync-gen-protocol-doc` (`--check` dans `tools/ci-quality.sh`).