Prose §6 seulement : la liste blanche et le contrat OpenRPC ne changent pas. L'optimiseur ne reçoit pas les paliers (powerLevels non émis, §5.3) ; seul le moteur sait qu'une consigne tombe sous le premier palier. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1354 lines
74 KiB
Markdown
1354 lines
74 KiB
Markdown
# OPTIMIZER_PROTOCOL — v1.1
|
||
|
||
Contrat entre le moteur d'énergie **etm-powersync-energy-plugin-etm** (couche A, GPL-3.0)
|
||
et un **optimiseur** externe (couche B : l'optimiseur ETM, ou toute implémentation tierce).
|
||
|
||
Ce document fait autorité. Il sert trois usages :
|
||
1. Interface interne entre l'arbitrage central et la stratégie par défaut (rule-based).
|
||
2. Spécification du protocole socket pour un optimiseur en process séparé.
|
||
3. Documentation publique : n'importe qui peut implémenter son propre optimiseur.
|
||
|
||
Statut : **v1.1** — figé à la fin de la beta. Versionné en semver (`protocolVersion`).
|
||
|
||
> ### La forme est un contrat MACHINE
|
||
>
|
||
> La **forme** des messages — champs, types, obligatoire, valeurs permises, et ce que le
|
||
> validateur du moteur refuse en plus — est écrite dans
|
||
> **`docs/etm-powersync-optimizer-protocol.openrpc.json`** (OpenRPC 1.3). Il fait foi sur la forme ;
|
||
> ce document fait foi sur le **sens**. Le moteur l'embarque et le sert
|
||
> (`NymeaEnergy.GetOptimizerProtocol`, `INTERFACE.md`) ; les deux côtés sont testés contre lui
|
||
> (annexe G). Les tableaux entre balises `BEGIN GENERATED` / `END GENERATED` ci-dessous sont
|
||
> **produits depuis le contrat** par `tools/etm-powersync-gen-protocol-doc` (`--check` en CI) :
|
||
> ne pas les éditer à la main. Les exemples JSON, eux, restent illustratifs.
|
||
|
||
> ### Ce document ne décrit PAS la frontière JSON-RPC de l'app
|
||
>
|
||
> Le protocole ci-dessous relie le **moteur** à un **optimiseur** (couche A ↔ couche B), sur
|
||
> un socket dédié, en NDJSON. Il ne dit rien du namespace que consomme l'application
|
||
> Flutter, et n'a jamais eu vocation à le dire.
|
||
>
|
||
> **La frontière app ↔ box, c'est `NymeaEnergy.*`**, documentée dans `INTERFACE.md` et faisant
|
||
> autorité via `energyplugin/nymeaenergyjsonhandler.cpp` + `JSONRPC.Introspect` sur une box.
|
||
>
|
||
> Cette note existe parce que l'erreur a coûté une session : un brief a envoyé l'agent app
|
||
> chercher ici le namespace `LoadConfig`, qui n'y est pas et n'y sera pas.
|
||
>
|
||
> | | transport | qui parle à qui | contrat |
|
||
> |---|---|---|---|
|
||
> | **Ce document** | socket NDJSON dédié | moteur ↔ optimiseur | `GetPlan(SurplusContext)` → plan par créneaux |
|
||
> | `INTERFACE.md` | WebSocket nymea, port 4444 | app ↔ box | `NymeaEnergy.*` |
|
||
|
||
---
|
||
|
||
## 0. Changelog
|
||
|
||
### 1.1 (2026-10)
|
||
Additif sur le fil : toutes les additions sont des **champs optionnels nouveaux** ; un
|
||
optimiseur 1.0 reste conforme (champs inconnus ignorés, §9).
|
||
|
||
**Correction de brouillon (pas une rupture)** : le kind `stage` et l'adaptateur
|
||
`relay-stages` décrits en 1.0 n'ont jamais existé dans le moteur livré (retirés du code en
|
||
rév. 3, avant toute implémentation socket). Aucun consommateur déployé n'en dépend ; ils
|
||
sont retirés du document sans changement de majeure.
|
||
|
||
**Correction avant publication (2026-10-14)** : `price.source` `hphc` devient `tou` (zones
|
||
horaires saisies, N zones et plusieurs plages par zone — HP/HC en est un cas) ; `hphc` n'a
|
||
jamais été émis. `baseload.quality` est ajouté (optionnel).
|
||
|
||
**Correction avant publication (2026-10-13)** : `conversion` n'admet plus `fallback`. La
|
||
valeur avait été écrite sans définition ; **aucun émetteur ne l'a jamais produite**. Seule
|
||
`physics` est émise ; une prévision dégradée se lit dans `corrections`.
|
||
|
||
- §5.2 **Prévisions explicites** (`forecast`) : grille temporelle déclarée, PV, consommation
|
||
de base, température, prix complet. Remplace le renvoi à `CONTRACT_A_openmeteo.md`.
|
||
- §5.3 **Groupes** (`group`) : clé de tri à deux niveaux. `priority` reste le rang effectif.
|
||
- §5.3 **Domaine** (`domain`) et noms réels des mécanismes (`adapter`).
|
||
- §5.3 **Besoins enrichis** : `energyTargets`, `comfort`, `source` de chaque besoin.
|
||
- §5.3 **Mesure par charge** : `measurement {source, powerW}` (aligné sur `INTERFACE.md`).
|
||
- §5.3 Champs de verrou `minState`/`maxState` (SG-Ready) documentés.
|
||
- §5.1 **Déclencheur** de la requête (`trigger`), `meter.source`.
|
||
- §3 `capabilities`.
|
||
- §6 **`validUntil`**, `expected`, `reasonCode` + `reasonParams`, `diagnostics` ; `force`
|
||
interdit sur le fil.
|
||
- Annexe D **Traces et rejeu** ; Annexe E **Optimiseur de référence** ;
|
||
Annexe F **Champs réservés** (batterie).
|
||
- **Contrat machine (2026-10-17, sans changement de fil)** : la forme des messages devient le
|
||
document OpenRPC `docs/etm-powersync-optimizer-protocol.openrpc.json`, servi par le moteur,
|
||
tableaux de ce document générés depuis lui ; annexe G. Les exemples du §5 ont été réalignés sur
|
||
ce que le moteur émet réellement (champs jamais émis signalés comme tels).
|
||
|
||
### 1.0 (2026-06) — version initiale
|
||
- 2026-08-09 (`92d0bef`) : `available`, `lockMinPowerW`, `lockMaxPowerW` documentés (ECS-410).
|
||
- 2026-08-25 (`afaa8c2`) : encadré « ce document ne décrit pas la frontière de l'app ».
|
||
|
||
---
|
||
|
||
## 1. Philosophie
|
||
|
||
- **Le plugin ne fait pas confiance à l'optimiseur.** Tout plan reçu est écrêté par les
|
||
bornes de sécurité du plugin (limites par charge, protection de surcharge, anti-flapping).
|
||
Un optimiseur bugué ou malveillant ne peut produire que des choix sous-optimaux *dans
|
||
des limites sûres* — jamais un état dangereux.
|
||
- **La boucle de sécurité n'est pas négociable.** Couches L0 à L4 (`docs/SAFETY.md`).
|
||
`verifyOverloadProtection()` (temps réel, sur `powerBalanceChanged`) s'exécute
|
||
indépendamment et prime sur toute consigne ; son extension au waterfall
|
||
(`shedWaterfallForOverload()`) revendique les charges délestées, que le dispatch saute.
|
||
- **Le système fonctionne sans optimiseur.** Optimiseur absent, mort, abstentionniste ou
|
||
plan expiré → repli automatique sur la stratégie rule-based interne (GPL).
|
||
- **Toute décision est expliquée.** Chaque action porte un `reason` lisible (français),
|
||
non vide. C'est une exigence du contrat, pas une option.
|
||
- **Le plugin seul commande le matériel.** Temporisations et anti-battement sont tenus par
|
||
le plugin en toutes circonstances ; l'optimiseur les connaît pour planifier, il ne les
|
||
fait pas respecter.
|
||
- **Zéro cloud.** Rien dans ce protocole n'exige ni ne provoque de connexion sortante du
|
||
site (`AGENTS.md` invariant 10).
|
||
|
||
## 2. Topologie et transport
|
||
|
||
- **Le plugin est le CLIENT. L'optimiseur est le SERVEUR.** Le plugin se connecte à un
|
||
endpoint configuré et demande des plans. La box n'ouvre aucun port.
|
||
- Endpoint configurable :
|
||
- `unix:///run/powersync/optimizer.sock` — défaut. Optimiseur local.
|
||
Droits : socket possédé par `root:powersync`, mode `0660`.
|
||
- `tcp://<host>:<port>` — optimiseur sur le LAN (NAS, serveur). Pour les
|
||
implémentations communautaires (ex. pont Akkudoktor-EOS, annexe B).
|
||
- Protocole applicatif : **JSON-RPC 2.0, un message par ligne (NDJSON), UTF-8.**
|
||
- Le contrat est transport-agnostique : mêmes messages sur unix et tcp.
|
||
- Reconnexion : backoff exponentiel (1 s → 60 s max). Pendant la déconnexion :
|
||
stratégie de repli active, `optimizerAlive = false`.
|
||
|
||
## 3. Handshake
|
||
|
||
À chaque (re)connexion, le plugin envoie :
|
||
|
||
```json
|
||
{"jsonrpc":"2.0","id":1,"method":"Handshake","params":{
|
||
"protocolVersion":"1.1",
|
||
"engine":"etm-powersync-energy-plugin-etm/<version>",
|
||
"token":"<secret partagé, optionnel — requis si configuré côté plugin>"
|
||
}}
|
||
```
|
||
|
||
Réponse attendue :
|
||
|
||
```json
|
||
{"jsonrpc":"2.0","id":1,"result":{
|
||
"protocolVersion":"1.1",
|
||
"name":"etm-optimizer",
|
||
"planning":true,
|
||
"capabilities":{
|
||
"maxHorizonH":48,
|
||
"resolutionS":3600,
|
||
"kinds":["setpoint","state","constraint"],
|
||
"usesForecast":["pv","baseload","price","temperature"],
|
||
"reasonCodes":true
|
||
}
|
||
}}
|
||
```
|
||
|
||
- `protocolVersion` : même **majeure** requise. Majeure différente → le plugin refuse,
|
||
log explicite, repli rule-based.
|
||
- `planning` : `true` si l'optimiseur produit des plans multi-créneaux ; `false` s'il
|
||
ne répond qu'au présent (plan à un créneau). Informationnel.
|
||
- `token` : si configuré côté plugin et absent/incorrect côté serveur → réponse `error`
|
||
(code `-32001` recommandé ; le moteur traite **toute** réponse `error` au handshake comme un
|
||
refus, `handshake-refused`) puis connexion fermée. Recommandé pour tout endpoint `tcp://`. **Forme** (E-T2-8) : chaîne de **16 caractères au
|
||
moins** et de 4 096 octets au plus, imprimable, sans blanc ni caractère de contrôle (UTF-8
|
||
admis). Le plugin refuse d'en poser un plus court, et ne se connecte pas avec un fichier de
|
||
token plus court (ni sans token) : il masque toute occurrence du token dans ce que l'optimiseur
|
||
renvoie, et un token court corromprait un identifiant qui le contiendrait.
|
||
- `capabilities` (1.1, optionnel) : affiché en mode installateur. **S'il est présent**, le
|
||
plugin rejette toute action dont le `kind` n'y est pas annoncé.
|
||
- Pas de `locale` : la locale nymea est par connexion app (`AGENTS.md` invariant 12) ; la
|
||
traduction vers l'app passe par `reasonCode` (§6).
|
||
- Handshake invalide ou timeout (5 s) → repli, nouvelle tentative au backoff. Invalide :
|
||
`result` non objet, `protocolVersion` absent ou vide, `capabilities` présent mais non objet
|
||
(`invalid-handshake`) ; majeure différente (`major-mismatch`).
|
||
- `name` : optionnel, informatif, affiché en mode installateur ; jamais contrôlé.
|
||
|
||
<!-- BEGIN GENERATED table:Handshake.params -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:HandshakeResult -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:Capabilities -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
## 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":"<thingId du compteur racine>","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`.
|
||
|
||
<!-- BEGIN GENERATED table:GetPlan.params -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:Site -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:Meter -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:Pv -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:Battery -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:Tariff -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:TariffEntry -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:TariffNextEntry -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
### 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":"<thingId plan>","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).
|
||
|
||
<!-- BEGIN GENERATED table:Forecast -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:PvForecast -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:PvField -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:Baseload -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:BaseloadQuality -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:BaseloadLoadQuality -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:Price -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
### 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.
|
||
|
||
<!-- BEGIN GENERATED table:Load -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:Measurement -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:LoadNeeds -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:EnergyTarget -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:LoadLearned -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
## 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 :
|
||
|
||
<!-- BEGIN GENERATED table:GetPlanResult -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:Slot -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:Action -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:ReasonParams -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:Expected -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED table:Diagnostics -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
<!-- BEGIN GENERATED reason-codes -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
| `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` | — |
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
## 7. Abstention et repli
|
||
|
||
L'optimiseur peut répondre :
|
||
|
||
```json
|
||
{"jsonrpc":"2.0","id":42,"result":{"abstain":true,"reason":"recalcul en cours"}}
|
||
```
|
||
|
||
Sémantique : « je n'ai pas de plan à proposer maintenant ». Le plugin bascule sur la
|
||
stratégie rule-based **pour ce cycle**, sans considérer l'optimiseur comme mort :
|
||
`optimizerAlive` est **inchangé** (une abstention ne ressuscite pas un optimiseur mort, E-T4-1).
|
||
|
||
| Situation | optimizerAlive | Stratégie active |
|
||
|---|---|---|
|
||
| Réponse plan valide | true | optimiseur |
|
||
| `abstain` | inchangé | rules (ce cycle) |
|
||
| Plan expiré (`validUntil` dépassé) | inchangé | rules jusqu'au prochain plan valide |
|
||
| Timeout / erreur / plan invalide sur **deux requêtes consécutives** | false | rules |
|
||
| Socket absent / fermé | false | rules |
|
||
| `optimizerExpected=false` (aucun endpoint configuré) | false | rules |
|
||
| Mode dégradé L2 (compteur muet > 90 s) | inchangé | **aucune** — planification suspendue, repli de sécurité |
|
||
|
||
Retour à `optimizerAlive = true` au premier plan valide. Les échecs se comptent **par
|
||
requête** : une abstention ou un plan déjà expiré à la réception interrompent la série ; une
|
||
requête remplacée par une plus récente n'est pas un échec. Avant le premier plan valide,
|
||
`optimizerAlive` vaut `false`. Un plan invalide remplace le cache (l'ancien plan valide n'est
|
||
plus appliqué) et un socket fermé l'abandonne (E-T3-9).
|
||
|
||
**Ce que le validateur refuse en plus de la forme.** Le JSON Schema du contrat dit la forme ; le
|
||
moteur juge ensuite chaque réponse sur le contexte de SA requête, dans cet ordre, et le premier
|
||
contrôle qui échoue donne son code (`fallbackCode` vers l'app, `lastErrorCode` pour un refus) :
|
||
|
||
<!-- BEGIN GENERATED rules -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
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. |
|
||
|
||
<!-- END GENERATED -->
|
||
|
||
L'état exposé à l'app (`optimizerExpected`, `optimizerAlive`, `activeStrategy` ∈
|
||
`optimizer` | `rules` | `none`) reflète cet état en continu ; chaque transition émet
|
||
`NymeaEnergy.OptimizerStatusChanged` (`INTERFACE.md`). Il n'y a pas de `tier` : l'app déduit ce
|
||
qu'elle affiche de ces trois champs.
|
||
|
||
## 8. Apprentissage et transparence
|
||
|
||
- L'optimiseur peut renvoyer ses estimations (`learned` + `confidence`) dans une
|
||
notification `UpdateLearned` (sans id, fire-and-forget) ; le plugin les persiste
|
||
et les retransmet dans les contextes suivants. **État 1.1 du moteur** : reçue, journalisée
|
||
une fois, ignorée — la persistance est un lot ultérieur (`INTERFACE.md`).
|
||
- Tant que `confidence < 0.7` (seuil indicatif), l'app peut afficher un badge
|
||
« profil en apprentissage ».
|
||
- Les données d'apprentissage restent **locales**. Rien ne quitte le site.
|
||
|
||
<!-- BEGIN GENERATED table:UpdateLearned.params -->
|
||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||
|
||
**`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).
|
||
|
||
<!-- END GENERATED -->
|
||
- L'état des modèles de prévision (`forecast.pv.conversion`, `forecast.pv.corrections`,
|
||
`forecast.baseload.model`) est exposé dans l'app au même titre que `confidence`.
|
||
|
||
## 9. Versionnement
|
||
|
||
- `protocolVersion` en semver. Même majeure = compatible.
|
||
- Champs inconnus dans `params`/`result` : **ignorés silencieusement**.
|
||
- Changements incompatibles (renommage, sémantique) = majeure + entrée changelog §0.
|
||
|
||
---
|
||
|
||
## Annexe A — Session minimale
|
||
|
||
```
|
||
→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.1","engine":"etm-powersync-energy-plugin-etm/1.15.2+etm70"}}
|
||
← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.1","name":"mon-optimiseur","planning":false}}
|
||
→ {"jsonrpc":"2.0","id":2,"method":"GetPlan","params":{ ...SurplusContext... }}
|
||
← {"jsonrpc":"2.0","id":2,"result":{"planId":"x","strategy":"custom","slots":[{ "from":"...","to":"...","actions":[...] }]}}
|
||
```
|
||
|
||
Un optimiseur conforme minimal tient en ~50 lignes de Python : répondre au handshake,
|
||
renvoyer un plan à un créneau (ou `abstain`).
|
||
|
||
## Annexe B — Exemple : brancher Akkudoktor-EOS
|
||
|
||
EOS (github.com/Akkudoktor-EOS/EOS) est un optimiseur-planificateur HTTP (Python,
|
||
algorithme génétique, horizon 48 h). Intégration par un **pont** (~200 lignes) tournant
|
||
où l'on veut sur le LAN :
|
||
|
||
```
|
||
plugin (client) ──tcp://nas:7600──► eos-bridge ──HTTP──► serveur EOS
|
||
```
|
||
|
||
Le pont : (1) répond au Handshake ; (2) traduit `SurplusContext` → payload EOS —
|
||
`forecast` fournit PV, consommation de base et prix sur grille commune ; (3) traduit le
|
||
plan EOS → `slots[]`/actions ; (4) sert le plan en cache et recalcule en arrière-plan ;
|
||
(5) répond `abstain` pendant un recalcul initial.
|
||
|
||
Les bornes de sécurité du plugin s'appliquent intégralement : un plan EOS aberrant
|
||
est écrêté, pas exécuté aveuglément.
|
||
|
||
## Annexe C — Priorités : modèle utilisateur
|
||
|
||
- **Liste ordonnée** (drag-and-drop dans l'app) : l'ordre de service du surplus. Le
|
||
rule-based l'exécute en cascade (waterfall) ; un planificateur s'en sert comme
|
||
préférence quand tout ne peut pas être servi.
|
||
- **Groupes** : déplacer une famille entière sans renuméroter (`spec_loadmodel.md` §5).
|
||
Un groupe trie, il ne reçoit pas de budget.
|
||
- **Besoins par charge** : échéances, minimums, cible, `energyTargets`, `comfort`.
|
||
- **Intentions client** : deviennent des besoins `source: "user-intent"` — l'optimiseur
|
||
ré-arbitre avec, il n'est pas contourné.
|
||
- **Promotions conditionnelles** : une échéance qui approche ou un signal tarifaire peut
|
||
faire passer une charge devant — toujours expliqué.
|
||
|
||
Pas de poids, pas de pourcentages : un ordre, des besoins, des exceptions expliquées.
|
||
|
||
## Annexe D — Traces et rejeu
|
||
|
||
But : comparer des optimiseurs **sur les mêmes entrées** et rejouer un incident.
|
||
|
||
- Option plugin `optimizerTrace` (défaut : **désactivée** ; à activer sur banc), réglée par
|
||
`NymeaEnergy.SetOptimizerTrace` (`INTERFACE.md`, « Traces de décision »).
|
||
- Fichiers NDJSON locaux `etm-powersync-optimizer-trace-AAAA-MM-JJ.ndjson`, rotation à minuit
|
||
heure locale, rétention configurable (défaut 14 jours), plafond par fichier journalier
|
||
(défaut 64 Mio), sous le répertoire de stockage du moteur (`NymeaSettings::storagePath()`).
|
||
Écriture asynchrone : une trace en échec ne bloque rien et ne change aucune décision.
|
||
- Une ligne par cycle, **y compris sous le rule-based** (sa réponse est alors le plan du
|
||
rule-based, `strategy: "rules"`) et en mode dégradé L2 (`strategy: "none"`, sans requête) :
|
||
|
||
```json
|
||
{"t":"2026-10-12T13:42:00+02:00","request":{ "...GetPlan params..." },
|
||
"response":{ "...result ou abstain..." },"executed":[{"loadId":"chauffe-eau","kind":"setpoint",
|
||
"powerW":1200,"clamped":false}],"strategy":"optimizer","latencyMs":12}
|
||
```
|
||
|
||
- `request` : les params `GetPlan` envoyés (ou qui l'auraient été) ; `response` : ce qui a
|
||
répondu à **cette** requête — `result` brut, ou `error.code` (`no-response` si elle a été
|
||
remplacée sans réponse) ; `executed` : actions appliquées au cycle ; `latencyMs` : optimiseur
|
||
seulement. La réponse à la requête N sert au cycle N+1 : la ligne N l'attend.
|
||
- Jamais le token (il ne voyage que dans le handshake ; recopié par une réponse, il est masqué).
|
||
- **Rejeu** : `tools/etm-powersync-trace-replay` relit `request`, l'envoie à un optimiseur
|
||
candidat (`unix://` par défaut, `tcp://` accepté), écrit les réponses à côté de la trace et
|
||
résume : taux d'abstention, `computeMs` médian et max, cycles dont les actions du créneau
|
||
courant diffèrent du plan enregistré.
|
||
- Les traces restent sur le site. Export manuel par l'installateur uniquement.
|
||
|
||
## Annexe E — Optimiseur de référence
|
||
|
||
`tools/etm-powersync-ref-optimizer/` : serveur Python minimal (bibliothèque standard seule)
|
||
conforme 1.1, `unix:///run/powersync/optimizer.sock` par défaut, `tcp://` accepté ; répondant un
|
||
plan à un créneau qui reproduit le waterfall rule-based. Usages : test de bout en bout du
|
||
`SocketScheduler` (suite `etm-powersync-refoptimizer`) ; point de comparaison du banc ; exemple
|
||
communautaire. Mode d'emploi et modes d'injection de défauts : son `README.md`.
|
||
|
||
Il s'annonce **`powersync-optimizer`** au handshake (`name`). `powersync-optimizer` = exemple de référence GPL (ce dépôt) ; `etm-optimizer` = optimiseur ETM (propriétaire, hors de ce dépôt).
|
||
Le nom n'est pas contrôlé par le moteur (§3) : il n'est qu'affiché.
|
||
|
||
- Il ne lit **que** le contexte `GetPlan` (aucune connexion sortante, aucun fichier hors le token).
|
||
- Il reproduit la **passe confort** : tri (rang, identifiant), surplus net signé, recrédit de la
|
||
consommation courante, réserve de stockage, plancher, plafond, verrous, mapping SG-Ready avec
|
||
hystérésis. Il ne reproduit pas ce que le contexte 1.1 ne porte pas : passe éco (avancement
|
||
livré), dérogations, autorisation de soutirage, paliers d'un `relay-router`, refus de commande,
|
||
proxy VE.
|
||
- Il n'émet que les codes `reasonCode` qu'un optimiseur peut émettre, avec leurs seules clés
|
||
documentées ; un code du rule-based hors de cette liste (verrou, réserve…) part en
|
||
`OPTIMIZER_DECISION`, le mécanisme nommé dans `reason`.
|
||
|
||
## Annexe F — Champs réservés (non normatifs)
|
||
|
||
Ces champs sont **réservés** : aucun porteur n'existe dans le moteur (pas de
|
||
`BatteryAdapter`, lot 3f). Un optimiseur ne doit pas les émettre ; le plugin les ignore.
|
||
Ils deviendront normatifs avec le lot qui les implémente.
|
||
|
||
- Adaptateur `battery` ; kind `constraint` (`charge`/`discharge` : `allow`|`forbid`) ;
|
||
`setpoint` batterie avec `source:"grid"` (charge réseau).
|
||
- `loads[].socTarget` `{percent, by, source}` : cible de SOC comme participant à la
|
||
priorisation.
|
||
- `loads[].policies` : `noDischargeWhileEvCharging`, `holdChargeForDomains`,
|
||
`gridChargeAllowed`.
|
||
- `expected.batterySocPercent`.
|
||
|
||
## Annexe G — Contrat machine, validateur et conformité
|
||
|
||
- **Le contrat** : `docs/etm-powersync-optimizer-protocol.openrpc.json` (OpenRPC 1.3). Méthodes
|
||
`Handshake`, `GetPlan`, `UpdateLearned` ; forme des messages en JSON Schema
|
||
(`components.schemas`) ; ce qu'OpenRPC ne sait pas dire en extensions `x-etm-…` :
|
||
`x-etm-transport` (unix/tcp, NDJSON, rôles, délais), `x-etm-direction` (sens d'une méthode),
|
||
`x-etm-fallback-machine` (§7), `x-etm-semantic-rules` (ce que le validateur refuse en plus, avec
|
||
`fallbackCode`), `x-etm-tolerances` (ce que le moteur accepte au-delà), `x-etm-reason-params`,
|
||
`x-etm-generated` (listes fermées régénérées depuis le code). Forme côté moteur (requêtes)
|
||
**fermée** ; côté optimiseur (réponses) **ouverte** (§9).
|
||
- **Servi** par le moteur qui tourne : `NymeaEnergy.GetOptimizerProtocol` (`INTERFACE.md`). Pas de
|
||
`rpc.discover` sur le socket : l'optimiseur se déclare au handshake, point.
|
||
- **Validateur** : `tools/etm-powersync-schema-check` (Python, bibliothèque standard) — un message,
|
||
une session NDJSON (les deux sens) ou des traces (annexe D) ; `--semantic` applique les règles.
|
||
- **Conformité d'un optimiseur quelconque** : suite `etm-powersync-optimizer-conformance`
|
||
(`tests/auto/conformance/README.md`) — le vrai client du moteur branché sur l'optimiseur que
|
||
lance `ETM_POWERSYNC_OPTIMIZER_CMD`.
|
||
- **Prose générée** : `tools/etm-powersync-gen-protocol-doc` (`--check` dans `tools/ci-quality.sh`).
|