diff --git a/docs/OPTIMIZER_PROTOCOL.md b/docs/OPTIMIZER_PROTOCOL.md index 221ba6d..b2fff25 100644 --- a/docs/OPTIMIZER_PROTOCOL.md +++ b/docs/OPTIMIZER_PROTOCOL.md @@ -10,6 +10,17 @@ Ce document fait autorité. Il sert trois usages : 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 @@ -61,6 +72,10 @@ valeur avait été écrite sans définition ; **aucun émetteur ne l'a jamais pr 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). @@ -135,8 +150,9 @@ Réponse attendue : log explicite, repli rule-based. - `planning` : `true` si l'optimiseur produit des plans multi-créneaux ; `false` s'il ne répond qu'au présent (plan à un créneau). Informationnel. -- `token` : si configuré côté plugin et absent/incorrect côté serveur → connexion fermée. - Recommandé pour tout endpoint `tcp://`. **Forme** (E-T2-8) : chaîne de **16 caractères au +- `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 @@ -145,7 +161,58 @@ Réponse attendue : 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. +- 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 @@ -185,7 +252,7 @@ Réponse attendue : "forecast":{ "... §5.2 ... ou null" }, "tariff":{ "provider":"tempo","current":{"label":"HP_BLEU","priceCtkWh":16.1}, - "next":[{"from":"2026-06-08T06:00","label":"ROUGE","priceCtkWh":75.6}] + "next":[{"from":"2026-06-08T06:00:00+02:00","label":"ROUGE","priceCtkWh":75.6}] } }} ``` @@ -204,7 +271,144 @@ Réponse attendue : 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`. +- `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`) @@ -218,7 +422,6 @@ index par index ; `null` dans une case = valeur inconnue — **jamais 0, jamais "resolutionS":3600, "steps":34, "pv":{ - "provider":"open-meteo", "weatherModel":"meteofrance_seamless", "conversion":"physics", "corrections":["temperature","ac-limit"], @@ -227,16 +430,15 @@ index par index ; `null` dans une case = valeur inconnue — **jamais 0, jamais "acLimitW":8000, "p50W":[ ... ], "perField":[ - {"id":"","label":"Toit sud","peakW":6000,"tiltDeg":35,"azimuthDeg":0, - "corrections":["temperature"],"p50W":[ ... ]} + {"id":"","corrections":["temperature"]} ] }, "baseload":{"model":"none","p50W":[null, ...]}, - "weather":{"temperatureC":[ ... ]}, + "weather":null, "price":{ "currency":"EUR","importCtkWh":[ ... ],"exportCtkWh":[ ... ], - "includes":["energy","grid","taxes"],"source":"tempo", - "knownUntil":"2026-10-13T06:00:00+02:00" + "includes":["energy","grid","taxes"],"source":"tou", + "knownUntil":"2026-10-13T22:00:00Z" } } ``` @@ -294,8 +496,140 @@ Règles : 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 reste libre de sourcer ses propres prévisions - (pont LAN, côté optimiseur), mais **le plugin, lui, ne sort pas** pour les obtenir. +- `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[]`) @@ -303,24 +637,21 @@ Règles : "loads":[ { "id":"chauffe-eau","adapter":"relay-router","label":"Chauffe-eau","domain":"ecs", - "group":{"groupRank":2,"rankInGroup":1}, "priority":2, - "declared":{"nominalPowerW":2400,"minPowerW":0,"maxPowerW":2400,"volumeL":200}, - "defaults":{"dailyEnergyWh":7000}, + "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"}], - "comfort":{"ecoMinC":50,"comfortC":60,"source":"installer"} + "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":{"pluggedIn":true,"charging":true,"currentPowerW":4100,"sessionWh":5200, + "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, @@ -329,7 +660,8 @@ Règles : { "id":"pac-terrain","adapter":"sg-ready","label":"PAC","domain":"heating", "declared":{"states":[1,2,3,4],"estimatedPowerW":{"3":1800,"4":2600}}, - "telemetry":{"state":2,"minState":2,"maxState":4,"measurement":{"source":"none"}}, + "telemetry":{"currentPowerW":0,"available":true,"state":2,"minState":2,"maxState":4, + "measurement":{"source":"none"}}, "priority":3, "limits":{"minStateHoldS":900} } @@ -338,7 +670,7 @@ Règles : Notes de schéma : - `loads[].declared` : saisi par l'installateur (plaque signalétique, câblage). Fiable. -- `loads[].defaults` : valeurs approximatives par catégorie. Point de départ. +- `loads[].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 @@ -369,9 +701,11 @@ Notes de schéma : - `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). + (`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. + 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`. @@ -379,7 +713,10 @@ Notes de schéma : - 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. + 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 @@ -401,6 +738,163 @@ 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 @@ -470,6 +964,162 @@ Règles d'exécution côté plugin : 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 : @@ -479,8 +1129,8 @@ L'optimiseur peut répondre : ``` Sémantique : « je n'ai pas de plan à proposer maintenant ». Le plugin bascule sur la -stratégie rule-based **pour ce cycle**, sans considérer l'optimiseur comme mort -(`optimizerAlive` reste `true`). +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 | |---|---|---| @@ -498,6 +1148,39 @@ requête remplacée par une plus récente n'est pas un échec. Avant le premier `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 @@ -512,6 +1195,19 @@ qu'elle affiche de ces trois champs. - 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`. @@ -526,7 +1222,7 @@ qu'elle affiche de ces trois champs. ## Annexe A — Session minimale ``` -→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.1","engine":"etm/0.9"}} +→ {"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":[...] }]}} @@ -632,3 +1328,22 @@ Ils deviendront normatifs avec le lot qui les implémente. - `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`). diff --git a/docs/etm-powersync-optimizer-protocol.openrpc.json b/docs/etm-powersync-optimizer-protocol.openrpc.json index ff2d764..801705b 100644 --- a/docs/etm-powersync-optimizer-protocol.openrpc.json +++ b/docs/etm-powersync-optimizer-protocol.openrpc.json @@ -2089,31 +2089,11 @@ ] }, "x-etm-reason-params": { - "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", @@ -2124,11 +2104,31 @@ "minAlwaysW", "boundBy" ], + "EV_DEADLINE": [], + "OPTIMIZER_DECISION": [], "PRICE_SLOT": [ "priceCtkWh", "setpointW" ], - "OPTIMIZER_DECISION": [] + "SG_FORCED": [ + "budgetW", + "estimatedW" + ], + "SG_NORMAL": [ + "budgetW" + ], + "SG_RECOMMENDED": [ + "budgetW", + "estimatedW" + ], + "SURPLUS_INSUFFICIENT": [ + "budgetW" + ], + "SURPLUS_SETPOINT": [ + "budgetW", + "setpointW", + "stepped" + ] }, "x-etm-generated": { "description": "Listes fermées RÉGÉNÉRÉES depuis le code (tools/etm-powersync-gen-protocol-doc --enums-from) ; la suite etm-powersync-protocolschema échoue si le code et le schéma divergent.",