docs(protocole): OPTIMIZER_PROTOCOL passe en v1.1 figée
Remplacement verbatim par la v1.1 figée (aucune réécriture). Additif sur le
fil : prévisions explicites (forecast), groupes, domaine, besoins enrichis,
measurement {source, powerW}, validUntil, reasonCode/reasonParams, force
interdit sur le fil. Le kind `stage` et l'adaptateur `relay-stages`, jamais
livrés, sont retirés comme correction de brouillon.
Références vérifiées contre le dépôt ; les écarts (chemins inexacts,
porteurs absents) sont consignés dans le rapport du brief 1, pas corrigés
dans le texte figé.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a787a5c476
commit
c78dbfc318
@ -1,16 +1,16 @@
|
||||
# OPTIMIZER_PROTOCOL — v1 (draft)
|
||||
# OPTIMIZER_PROTOCOL — v1.1
|
||||
|
||||
Contrat entre le moteur d'énergie **etm-powersync-energy-plugin-etm** (couche A, GPL-3.0)
|
||||
et un **optimiseur** externe (couche B : optimiseur ETM, ou toute implémentation tierce).
|
||||
et un **optimiseur** externe (couche B : l'optimiseur ETM, ou toute implémentation tierce).
|
||||
|
||||
Ce document fait autorité. Il sert trois usages :
|
||||
1. Interface interne entre l'arbitrage central et la stratégie par défaut (rule-based).
|
||||
2. Spécification du protocole socket pour un optimiseur en process séparé.
|
||||
3. Documentation publique : n'importe qui peut implémenter son propre optimiseur.
|
||||
|
||||
Statut : **draft v1** — figé à la fin de la beta. Versionné en semver (`protocolVersion`).
|
||||
Statut : **v1.1** — figé à la fin de la beta. Versionné en semver (`protocolVersion`).
|
||||
|
||||
> ### ⚠️ Ce document ne décrit PAS la frontière JSON-RPC de l'app
|
||||
> ### Ce document ne décrit PAS la frontière JSON-RPC de l'app
|
||||
>
|
||||
> Le protocole ci-dessous relie le **moteur** à un **optimiseur** (couche A ↔ couche B), sur
|
||||
> un socket dédié, en NDJSON. Il ne dit rien du namespace que consomme l'application
|
||||
@ -20,13 +20,43 @@ Statut : **draft v1** — figé à la fin de la beta. Versionné en semver (`pro
|
||||
> autorité via `energyplugin/nymeaenergyjsonhandler.cpp` + `JSONRPC.Introspect` sur une box.
|
||||
>
|
||||
> Cette note existe parce que l'erreur a coûté une session : un brief a envoyé l'agent app
|
||||
> chercher ici le namespace `LoadConfig`, qui n'y est pas et n'y sera pas. Les deux
|
||||
> protocoles portent tous deux du JSON-RPC — c'est tout ce qu'ils ont en commun.
|
||||
> chercher ici le namespace `LoadConfig`, qui n'y est pas et n'y sera pas.
|
||||
>
|
||||
> | | transport | qui parle à qui | contrat |
|
||||
> |---|---|---|---|
|
||||
> | **Ce document** | socket NDJSON dédié | moteur ↔ optimiseur | `GetPlan(SurplusContext)` → plan par créneaux |
|
||||
> | `INTERFACE.md` | WebSocket nymea, port 4444 | app ↔ box | `NymeaEnergy.*` — 20 méthodes, 14 notifications |
|
||||
> | `INTERFACE.md` | WebSocket nymea, port 4444 | app ↔ box | `NymeaEnergy.*` |
|
||||
|
||||
---
|
||||
|
||||
## 0. Changelog
|
||||
|
||||
### 1.1 (2026-10)
|
||||
Additif sur le fil : toutes les additions sont des **champs optionnels nouveaux** ; un
|
||||
optimiseur 1.0 reste conforme (champs inconnus ignorés, §9).
|
||||
|
||||
**Correction de brouillon (pas une rupture)** : le kind `stage` et l'adaptateur
|
||||
`relay-stages` décrits en 1.0 n'ont jamais existé dans le moteur livré (retirés du code en
|
||||
rév. 3, avant toute implémentation socket). Aucun consommateur déployé n'en dépend ; ils
|
||||
sont retirés du document sans changement de majeure.
|
||||
|
||||
- §5.2 **Prévisions explicites** (`forecast`) : grille temporelle déclarée, PV, consommation
|
||||
de base, température, prix complet. Remplace le renvoi à `CONTRACT_A_openmeteo.md`.
|
||||
- §5.3 **Groupes** (`group`) : clé de tri à deux niveaux. `priority` reste le rang effectif.
|
||||
- §5.3 **Domaine** (`domain`) et noms réels des mécanismes (`adapter`).
|
||||
- §5.3 **Besoins enrichis** : `energyTargets`, `comfort`, `source` de chaque besoin.
|
||||
- §5.3 **Mesure par charge** : `measurement {source, powerW}` (aligné sur `INTERFACE.md`).
|
||||
- §5.3 Champs de verrou `minState`/`maxState` (SG-Ready) documentés.
|
||||
- §5.1 **Déclencheur** de la requête (`trigger`), `meter.source`.
|
||||
- §3 `capabilities`.
|
||||
- §6 **`validUntil`**, `expected`, `reasonCode` + `reasonParams`, `diagnostics` ; `force`
|
||||
interdit sur le fil.
|
||||
- Annexe D **Traces et rejeu** ; Annexe E **Optimiseur de référence** ;
|
||||
Annexe F **Champs réservés** (batterie).
|
||||
|
||||
### 1.0 (2026-06) — version initiale
|
||||
- 2026-08-09 (`92d0bef`) : `available`, `lockMinPowerW`, `lockMaxPowerW` documentés (ECS-410).
|
||||
- 2026-08-25 (`afaa8c2`) : encadré « ce document ne décrit pas la frontière de l'app ».
|
||||
|
||||
---
|
||||
|
||||
@ -36,19 +66,26 @@ Statut : **draft v1** — figé à la fin de la beta. Versionné en semver (`pro
|
||||
bornes de sécurité du plugin (limites par charge, protection de surcharge, anti-flapping).
|
||||
Un optimiseur bugué ou malveillant ne peut produire que des choix sous-optimaux *dans
|
||||
des limites sûres* — jamais un état dangereux.
|
||||
- **La boucle de sécurité n'est pas négociable.** `verifyOverloadProtection()` (temps réel,
|
||||
sur `powerBalanceChanged`) s'exécute indépendamment et prime sur toute consigne.
|
||||
- **Le système fonctionne sans optimiseur.** Optimiseur absent, mort ou abstentionniste →
|
||||
repli automatique sur la stratégie rule-based interne (GPL). La maison reste pilotée.
|
||||
- **La boucle de sécurité n'est pas négociable.** Couches L0 à L4 (`docs/SAFETY.md`).
|
||||
`verifyOverloadProtection()` (temps réel, sur `powerBalanceChanged`) s'exécute
|
||||
indépendamment et prime sur toute consigne ; son extension au waterfall
|
||||
(`shedWaterfallForOverload()`) revendique les charges délestées, que le dispatch saute.
|
||||
- **Le système fonctionne sans optimiseur.** Optimiseur absent, mort, abstentionniste ou
|
||||
plan expiré → repli automatique sur la stratégie rule-based interne (GPL).
|
||||
- **Toute décision est expliquée.** Chaque action porte un `reason` lisible (français),
|
||||
non vide. C'est une exigence du contrat, pas une option.
|
||||
- **Le plugin seul commande le matériel.** Temporisations et anti-battement sont tenus par
|
||||
le plugin en toutes circonstances ; l'optimiseur les connaît pour planifier, il ne les
|
||||
fait pas respecter.
|
||||
- **Zéro cloud.** Rien dans ce protocole n'exige ni ne provoque de connexion sortante du
|
||||
site (`AGENTS.md` invariant 10).
|
||||
|
||||
## 2. Topologie et transport
|
||||
|
||||
- **Le plugin est le CLIENT. L'optimiseur est le SERVEUR.** Le plugin se connecte à un
|
||||
endpoint configuré et demande des plans. La box n'ouvre aucun port.
|
||||
- Endpoint configurable :
|
||||
- `unix:///run/powersync/optimizer.sock` — défaut. Optimiseur local (optimiseur ETM).
|
||||
- `unix:///run/powersync/optimizer.sock` — défaut. Optimiseur local.
|
||||
Droits : socket possédé par `root:powersync`, mode `0660`.
|
||||
- `tcp://<host>:<port>` — optimiseur sur le LAN (NAS, serveur). Pour les
|
||||
implémentations communautaires (ex. pont Akkudoktor-EOS, annexe B).
|
||||
@ -63,7 +100,7 @@ Statut : **draft v1** — figé à la fin de la beta. Versionné en semver (`pro
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"method":"Handshake","params":{
|
||||
"protocolVersion":"1.0",
|
||||
"protocolVersion":"1.1",
|
||||
"engine":"etm-powersync-energy-plugin-etm/<version>",
|
||||
"token":"<secret partagé, optionnel — requis si configuré côté plugin>"
|
||||
}}
|
||||
@ -73,9 +110,16 @@ Réponse attendue :
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"result":{
|
||||
"protocolVersion":"1.0",
|
||||
"protocolVersion":"1.1",
|
||||
"name":"etm-optimizer",
|
||||
"planning":true
|
||||
"planning":true,
|
||||
"capabilities":{
|
||||
"maxHorizonH":48,
|
||||
"resolutionS":3600,
|
||||
"kinds":["setpoint","state","constraint"],
|
||||
"usesForecast":["pv","baseload","price","temperature"],
|
||||
"reasonCodes":true
|
||||
}
|
||||
}}
|
||||
```
|
||||
|
||||
@ -85,68 +129,47 @@ Réponse attendue :
|
||||
ne répond qu'au présent (plan à un créneau). Informationnel.
|
||||
- `token` : si configuré côté plugin et absent/incorrect côté serveur → connexion fermée.
|
||||
Recommandé pour tout endpoint `tcp://`.
|
||||
- `capabilities` (1.1, optionnel) : affiché en mode installateur. **S'il est présent**, le
|
||||
plugin rejette toute action dont le `kind` n'y est pas annoncé.
|
||||
- Pas de `locale` : la locale nymea est par connexion app (`AGENTS.md` invariant 12) ; la
|
||||
traduction vers l'app passe par `reasonCode` (§6).
|
||||
- Handshake invalide ou timeout (5 s) → repli, nouvelle tentative au backoff.
|
||||
|
||||
## 4. Cycle et heartbeat
|
||||
|
||||
- Le plugin appelle `GetPlan` à chaque cycle d'optimisation (~1/min) **et** sur
|
||||
événement significatif (charge branchée/débranchée, changement de config).
|
||||
événement significatif — voir `trigger`, §5.1.
|
||||
- **Le heartbeat est implicite** : une réponse valide à `GetPlan` = optimiseur vivant.
|
||||
Deux cycles consécutifs sans réponse valide (timeout par requête : 10 s) →
|
||||
`optimizerAlive = false`, repli rule-based, notification `Ems.CapabilitiesChanged`.
|
||||
`optimizerAlive = false`, repli rule-based, notification de changement de capacités
|
||||
(namespace publié actuel : `NymeaEnergy`).
|
||||
- L'optimiseur peut être lent à *calculer* (EOS : minutes) — il doit alors répondre
|
||||
vite avec son **dernier plan en cache** ou `abstain`, et recalculer en tâche de fond.
|
||||
Une requête n'attend jamais un calcul long.
|
||||
Une requête n'attend jamais un calcul long. Le rythme de recalcul est libre
|
||||
(typiquement chaque quart d'heure, et sur `trigger` ≠ `cycle`).
|
||||
|
||||
## 5. Requête : `GetPlan(SurplusContext)`
|
||||
|
||||
### 5.1 En-tête et état instantané
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":42,"method":"GetPlan","params":{
|
||||
"timestamp":"2026-06-07T13:42:00+02:00",
|
||||
"timestamp":"2026-10-12T13:42:00+02:00",
|
||||
"trigger":"cycle",
|
||||
"site":{
|
||||
"contractedPowerW":12000,
|
||||
"gridCapW":null,
|
||||
"phaseLimitA":[63,63,63]
|
||||
},
|
||||
"meter":{"importW":0,"exportW":2310,"perPhaseA":[3.2,4.1,2.8]},
|
||||
"meter":{"source":"<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":[
|
||||
{
|
||||
"id":"ecs-1","adapter":"relay-stages","label":"Chauffe-eau",
|
||||
"declared":{"nominalPowerW":2400,"stages":[0,1200,2400]},
|
||||
"defaults":{"dailyEnergyWh":7000},
|
||||
"learned":{"dailyEnergyWh":6400,"confidence":0.35},
|
||||
"telemetry":{"state":1,"currentPowerW":1200,"lastSwitch":"2026-06-07T13:10:00+02:00"},
|
||||
"needs":{"dailyDeadline":"18:00","minEnergyWhPerDay":4000},
|
||||
"priority":2,
|
||||
"limits":{"minOnS":300,"minOffS":300}
|
||||
},
|
||||
{
|
||||
"id":"ev-1","adapter":"evcharger","label":"Borne garage",
|
||||
"declared":{"minA":6,"maxA":16,"phases":3},
|
||||
"telemetry":{"pluggedIn":true,"charging":true,"currentPowerW":4100,"sessionWh":5200},
|
||||
"needs":{"targetSocPercent":80,"deadline":"2026-06-08T07:00:00+02:00"},
|
||||
"priority":1,
|
||||
"limits":{"chargingEnabledLockS":300,"currentChangeLockS":60}
|
||||
},
|
||||
{
|
||||
"id":"pac-1","adapter":"sg-ready","label":"PAC",
|
||||
"declared":{"states":[1,2,3,4],"estimatedPowerW":{"3":1800,"4":2600}},
|
||||
"telemetry":{"state":2},
|
||||
"priority":3,
|
||||
"limits":{"minStateHoldS":900}
|
||||
},
|
||||
{
|
||||
"id":"bat-1","adapter":"battery","label":"Batterie Fronius",
|
||||
"priority":4
|
||||
}
|
||||
],
|
||||
"forecast":{ "...":"objet Contract A (openmeteo) transmis tel quel, ou null" },
|
||||
"loads":[ "... §5.3 ..." ],
|
||||
"forecast":{ "... §5.2 ... ou null" },
|
||||
"tariff":{
|
||||
"provider":"tempo","current":{"label":"HP_BLEU","priceCtkWh":16.1},
|
||||
"next":[{"from":"2026-06-08T06:00","label":"ROUGE","priceCtkWh":75.6}]
|
||||
@ -154,65 +177,216 @@ Réponse attendue :
|
||||
}}
|
||||
```
|
||||
|
||||
- `trigger` (1.1) : `cycle` | `load-change` (branchement VE, changement d'état) | `intent`
|
||||
(intention client) | `config` (modification installateur) | `forecast` (nouvelle prévision).
|
||||
Un optimiseur peut s'en servir pour lancer un recalcul anticipé ; il répond quand même
|
||||
immédiatement (§4).
|
||||
- `meter.source` (1.1) : identité du compteur racine désigné (`Energy.SetRootMeter`), unique.
|
||||
- `meter.exportW` est la mesure **brute** (`AGENTS.md` règle 8) : jamais le net après pilotage.
|
||||
- `site.gridCapW` : réservé (signaux réseau type §14a EnWG). `null` en France.
|
||||
- `tariff` (1.0) reste émis pour compatibilité ; un optimiseur 1.1 lit `forecast.price`.
|
||||
|
||||
### 5.2 Prévisions (`forecast`)
|
||||
|
||||
Toutes les séries partagent **une seule grille temporelle**. Les tableaux sont alignés
|
||||
index par index ; `null` dans une case = valeur inconnue — **jamais 0, jamais inventée**
|
||||
(`docs/DESIGN_PREVISION_PV.md` §8.4).
|
||||
|
||||
```json
|
||||
"forecast":{
|
||||
"start":"2026-10-12T14:00:00+02:00",
|
||||
"resolutionS":3600,
|
||||
"steps":34,
|
||||
"pv":{
|
||||
"provider":"open-meteo",
|
||||
"weatherModel":"meteofrance_seamless",
|
||||
"conversion":"physics",
|
||||
"corrections":["temperature","ac-limit"],
|
||||
"issuedAt":null,
|
||||
"contentChangedAt":"2026-10-12T12:07:00+02:00",
|
||||
"acLimitW":8000,
|
||||
"p50W":[ ... ],
|
||||
"perField":[
|
||||
{"id":"<thingId plan>","label":"Toit sud","peakW":6000,"tiltDeg":35,"azimuthDeg":0,"p50W":[ ... ]}
|
||||
]
|
||||
},
|
||||
"baseload":{"model":"none","p50W":[null, ...]},
|
||||
"weather":{"temperatureC":[ ... ]},
|
||||
"price":{
|
||||
"currency":"EUR","importCtkWh":[ ... ],"exportCtkWh":[ ... ],
|
||||
"includes":["energy","grid","taxes"],"source":"tempo",
|
||||
"knownUntil":"2026-10-13T06:00:00+02:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Règles :
|
||||
|
||||
- **Grille** : `resolutionS` et `start` font foi — un consommateur ne suppose jamais le pas
|
||||
(`DESIGN_PREVISION_PV.md` §14.3). En 1.1 le producteur publie au pas **horaire**
|
||||
(`3600`) ; le pas de 15 min sera introduit avec les prix day-ahead, sans changement de
|
||||
protocole. Horizon : jusqu'à la fin du lendemain, 48 h max.
|
||||
- **Instants** : absolus. Les horodatages portent leur décalage ; aucune case n'est indexée
|
||||
par une heure murale (`DESIGN_PREVISION_PV.md` §8.1 — jours de 23 h et 25 h).
|
||||
- **`pv`** : puissance AC moyenne sur `[t, t+pas)`, sommée sur les champs puis plafonnée à
|
||||
`acLimitW`.
|
||||
- **C'est un plafond, pas une espérance**, tant que la conversion ignore l'ombrage et que
|
||||
l'encrassement n'est porté que par un facteur fixe : le biais est systématique et
|
||||
optimiste (`DESIGN_PREVISION_PV.md` §3, §21.2).
|
||||
- `corrections` (1.1) : corrections physiques appliquées — `temperature` (température de
|
||||
module), `ac-limit` (plafond onduleur). Liste vide = conversion brute `GTI × kWc × PR`.
|
||||
- `p10W`/`p90W` : optionnels, **absents** tant que `conversion` = `physics`. **Ils ne sont
|
||||
JAMAIS dérivés de la dispersion d'ensemble** : elle est anticorrélée à l'erreur de
|
||||
conversion (`DESIGN_PREVISION_PV.md` §4).
|
||||
- `weatherModel` : modèle météo **qui a réellement répondu** (repli `best_match` compris),
|
||||
`null` si inconnu — jamais le modèle configuré.
|
||||
- `conversion` : `physics` | `fallback`. Le plugin GPL **n'émet jamais `hybrid`** :
|
||||
la correction apprise vit hors GPL (`DESIGN_PREVISION_PV.md` §5). Un optimiseur qui
|
||||
corrige la prévision le déclare dans `diagnostics.inputsUsed.pv` (§6).
|
||||
- `issuedAt` : instant du run s'il est connu, sinon `null`. **Ce n'est pas l'âge de chaque
|
||||
case** (`DESIGN_PREVISION_PV.md` §20-21). `contentChangedAt` : instant d'apparition du
|
||||
contenu courant — un producteur figé ne doit pas se lire comme une prévision stable (§26.2).
|
||||
- `perField` : optionnel, affichage et diagnostic. Convention d'orientation (Open-Meteo,
|
||||
PVGIS) : `tiltDeg` 0 = horizontal, 90 = vertical ; `azimuthDeg` 0 = sud, −90 = est,
|
||||
+90 = ouest, ±180 = nord.
|
||||
- **`baseload`** : consommation **hors charges pilotées**. `model` : `profile` | `learned` |
|
||||
`none` ; `none` + tableau de `null` autorisé (installation neuve). Le plugin la calcule
|
||||
depuis le compteur racine moins les charges, chacune selon `measurement.source` (§5.3) ;
|
||||
une charge en `none` est comptée à sa puissance **commandée**, et la qualité de
|
||||
l'estimation le dit.
|
||||
- **`price`** : **prix complet payé** — `includes` liste ce qui est inclus. Un prix n'est
|
||||
**pas une prévision** : au-delà de `knownUntil`, `null`. Sources : `fixed`, `hphc`,
|
||||
`tempo`, `day-ahead`. `exportCtkWh` : tarif de revente ou 0.
|
||||
- `forecast` peut être `null`. L'optimiseur reste libre de sourcer ses propres prévisions
|
||||
(pont LAN, côté optimiseur), mais **le plugin, lui, ne sort pas** pour les obtenir.
|
||||
|
||||
### 5.3 Charges (`loads[]`)
|
||||
|
||||
```json
|
||||
"loads":[
|
||||
{
|
||||
"id":"chauffe-eau","adapter":"relay-router","label":"Chauffe-eau","domain":"ecs",
|
||||
"group":{"groupRank":2,"rankInGroup":1},
|
||||
"priority":2,
|
||||
"declared":{"nominalPowerW":2400,"minPowerW":0,"maxPowerW":2400,"volumeL":200},
|
||||
"defaults":{"dailyEnergyWh":7000},
|
||||
"learned":{"dailyEnergyWh":6400,"confidence":0.35},
|
||||
"telemetry":{"currentPowerW":1200,"available":true,"lockMinPowerW":1200,"lockMaxPowerW":-1,
|
||||
"measurement":{"source":"meter","powerW":1180}},
|
||||
"needs":{
|
||||
"dailyDeadline":"18:00","minEnergyWhPerDay":4000,
|
||||
"energyTargets":[{"by":"2026-06-07T18:00:00+02:00","minWh":4000,"source":"installer"}],
|
||||
"comfort":{"ecoMinC":50,"comfortC":60,"source":"installer"}
|
||||
},
|
||||
"limits":{"minOnS":300,"minOffS":300}
|
||||
},
|
||||
{
|
||||
"id":"ev-1","adapter":"evcharger","label":"Borne garage","domain":"ev",
|
||||
"declared":{"minA":6,"maxA":16,"phases":3},
|
||||
"telemetry":{"pluggedIn":true,"charging":true,"currentPowerW":4100,"sessionWh":5200,
|
||||
"measurement":{"source":"device","powerW":4100}},
|
||||
"needs":{"targetSocPercent":80,"deadline":"2026-06-08T07:00:00+02:00"},
|
||||
"priority":1,
|
||||
"limits":{"chargingEnabledLockS":300,"currentChangeLockS":60}
|
||||
},
|
||||
{
|
||||
"id":"pac-terrain","adapter":"sg-ready","label":"PAC","domain":"heating",
|
||||
"declared":{"states":[1,2,3,4],"estimatedPowerW":{"3":1800,"4":2600}},
|
||||
"telemetry":{"state":2,"minState":2,"maxState":4,"measurement":{"source":"none"}},
|
||||
"priority":3,
|
||||
"limits":{"minStateHoldS":900}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Notes de schéma :
|
||||
- `loads[].declared` : saisi par l'installateur (plaque signalétique, câblage). Fiable.
|
||||
- `loads[].defaults` : valeurs approximatives par catégorie. Point de départ.
|
||||
- `loads[].learned` : estimations de l'optimiseur, **renvoyées** au plugin pour
|
||||
persistance et affichage, avec `confidence` (0–1). Le rule-based les ignore.
|
||||
- `loads[].priority` : rang dans la liste ordonnée définie par le client (1 = premier
|
||||
servi). Voir annexe C.
|
||||
- `loads[].priority` : **rang effectif** (1 = premier servi). Voir annexe C. Un optimiseur
|
||||
qui ne lit que `priority` reste correct.
|
||||
- `loads[].limits` : verrous anti-flapping. Le plugin LES APPLIQUE quoi qu'il arrive ;
|
||||
ils sont transmis pour que l'optimiseur planifie intelligemment, pas pour qu'il
|
||||
les fasse respecter.
|
||||
- `site.gridCapW` : réservé (signaux réseau type §14a EnWG). `null` en France v1.
|
||||
- `forecast` : contenu défini par `CONTRACT_A_openmeteo.md`. Peut être `null`
|
||||
(l'optimiseur peut sourcer ses propres prévisions — il est libre).
|
||||
|
||||
### `loads[].telemetry` — champs ETM non encore normalisés
|
||||
**Mécanisme et domaine**
|
||||
|
||||
Trois champs ajoutés par l'implémentation ETM et **absents des exemples ci-dessus**.
|
||||
Ils sont documentés ici parce qu'ils changent la lecture du contexte, et qu'une
|
||||
implémentation d'optimiseur qui les ignore prendra de mauvaises décisions.
|
||||
- `adapter` — mécanisme d'actionnement, valeurs **du code** (`LoadConfig::adapter`) :
|
||||
`relay-router` (combinaison de relais, consigne en watts), `etmvariableload` (consigne
|
||||
continue en watts), `sg-ready`, `evcharger` ; `battery` réservé (annexe F).
|
||||
- `domain` — intention déclarée par l'installateur, **jamais déduite du mécanisme**,
|
||||
**non lue par l'arbitre** : `ev` | `ecs` | `heating` | `hvac` | `battery`, ou absent.
|
||||
- Une PAC qui assure aussi l'ECS sans ballon séparé = **une seule charge** (`heating`).
|
||||
- Un décideur ne doit **jamais** raisonner sur `adapter` pour savoir ce que mesure une
|
||||
charge : c'est `measurement.source` qui le dit (LM-1106).
|
||||
|
||||
**Groupes (optionnel)**
|
||||
|
||||
- `group` = `{groupRank, rankInGroup}` : **clé de tri**, jamais détenteur de budget
|
||||
(`specs/spec_loadmodel.md` LM-500). `priority` est le tri total résultant, calculé par le
|
||||
plugin. Absent tant que les groupes ne sont pas implémentés (rang global unique).
|
||||
|
||||
**Besoins (`needs`)**
|
||||
|
||||
- `energyTargets` : énergie minimale à avoir délivrée à une échéance. Forme générique
|
||||
(ECS, VE converti en Wh par le plugin). Plusieurs cibles possibles.
|
||||
- `comfort` : `ecoMinC` doit **toujours** être tenu ; `comfortC` seulement sur surplus
|
||||
(`specs/spec_loadmodel.md` §10, LM-1010).
|
||||
- `source` de chaque besoin : `installer` | `user-intent` | `learned`. Une intention client
|
||||
prime sur un besoin appris.
|
||||
- Champs 1.0 (`dailyDeadline`, `minEnergyWhPerDay`, `targetSocPercent`, `deadline`) restent
|
||||
émis ; un optimiseur 1.1 préfère `energyTargets`.
|
||||
|
||||
**VE**
|
||||
|
||||
- La priorité porte sur la **borne**, pas sur le véhicule.
|
||||
- `telemetry.vehicle` (optionnel) : `{"socPercent":54,"source":"myskoda","capacityWh":77000}`
|
||||
quand une intégration véhicule existe ; absent sinon.
|
||||
|
||||
### `loads[].telemetry` — champs ETM
|
||||
|
||||
Ils changent la lecture du contexte : un optimiseur qui les ignore prendra de mauvaises
|
||||
décisions.
|
||||
|
||||
| Champ | Type | Sens |
|
||||
|---|---|---|
|
||||
| `available` | bool | **Gouverne l'ALLOCATION, pas la COMPTABILITÉ.** Faux ⇒ la charge ne doit recevoir **aucune** consigne neuve — mais elle **continue d'être comptée** pour ce qu'elle consomme via `currentPowerW`. Un optimiseur qui se contenterait de « sauter » les charges indisponibles sauterait aussi leur comptabilité, et offrirait aux charges suivantes un budget qui n'existe pas. |
|
||||
| `lockMinPowerW` | W | Plancher imposé **maintenant** par un verrou anti-rebond : puissance déjà engagée que l'adaptateur ne peut pas couper. Le budget DOIT être décrémenté de cette valeur **même si elle dépasse le budget disponible** — sinon les charges de rang suivant reçoivent un résidu surestimé et l'installation soutire au réseau. |
|
||||
| `available` | bool | **Gouverne l'ALLOCATION, pas la COMPTABILITÉ.** Faux ⇒ la charge ne doit recevoir **aucune** consigne neuve — mais elle **continue d'être comptée** pour ce qu'elle consomme via `currentPowerW`. Sauter une charge indisponible sans la compter offrirait aux suivantes un budget qui n'existe pas. |
|
||||
| `lockMinPowerW` | W | Plancher imposé **maintenant** par un verrou anti-rebond : puissance déjà engagée que l'adaptateur ne peut pas couper. Le budget DOIT être décrémenté de cette valeur **même si elle dépasse le budget disponible**. |
|
||||
| `lockMaxPowerW` | W | Plafond imposé **maintenant** par un verrou : au-delà, l'adaptateur refusera de démarrer. **Négatif ⇒ aucun plafond** (et non « plafond nul »). |
|
||||
| `minState` / `maxState` | int | SG-Ready : fenêtre d'états autorisée **maintenant** par `minStateHold`. Gel total (`minState == maxState == state`) tant que le verrou court. |
|
||||
| `measurement` | objet | `{source, powerW}` — `source` : `meter` (compteur dédié désigné) \| `device` (l'appareil piloté mesure) \| `none` (rien ne mesure : état positif, pas une erreur). **Une puissance déclarée n'est jamais une mesure.** Sémantique : `INTERFACE.md`, `specs/spec_loadmodel.md` LM-1105/1106. |
|
||||
|
||||
Une charge en défaut d'écriture est **figée** : `available = false` et
|
||||
`lockMinPowerW == lockMaxPowerW ==` la puissance crue engagée. Le plancher n'est
|
||||
jamais supérieur au plafond — un tel couple ferait dérailler tout écrêtage.
|
||||
jamais supérieur au plafond.
|
||||
|
||||
Ces champs sont exprimés **en watts**, jamais en index de palier : aucun
|
||||
identifiant de relais ni index de combinaison ne franchit cette frontière.
|
||||
Ces champs sont exprimés **en watts**, jamais en index de palier : aucun identifiant de
|
||||
relais ni index de combinaison ne franchit cette frontière.
|
||||
|
||||
## 6. Réponse : plan par créneaux
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":42,"result":{
|
||||
"planId":"2026-06-07T13:42:00Z-a1",
|
||||
"strategy":"etm-optimizer-predict",
|
||||
"strategy":"etm-predict",
|
||||
"computedAt":"2026-06-07T13:41:12+02:00",
|
||||
"validUntil":"2026-06-07T14:15:00+02:00",
|
||||
"slots":[
|
||||
{
|
||||
"from":"2026-06-07T13:42:00+02:00","to":"2026-06-07T14:00:00+02:00",
|
||||
"actions":[
|
||||
{"loadId":"ev-1","kind":"setpoint","currentA":10,
|
||||
"reason":"Surplus PV 2,3 kW → VE prioritaire (cible 80% à 07:00)"},
|
||||
{"loadId":"ecs-1","kind":"stage","stage":1,
|
||||
"reason":"Reliquat 0,9 kW → ECS palier 1 (besoin estimé 6,4 kWh/j, en apprentissage)"},
|
||||
{"loadId":"bat-1","kind":"constraint","charge":"allow","discharge":"forbid",
|
||||
"reason":"Jour ROUGE demain : décharge bloquée, on préserve le SoC"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"from":"2026-06-07T22:00:00+02:00","to":"2026-06-08T06:00:00+02:00",
|
||||
"actions":[
|
||||
{"loadId":"bat-1","kind":"setpoint","powerW":3000,"source":"grid",
|
||||
"reason":"Charge réseau HC avant jour ROUGE (plafond 3 kW : abonnement 12 kVA)"}
|
||||
]
|
||||
"reason":"Surplus PV 2,3 kW → VE prioritaire (cible 80% à 07:00)",
|
||||
"reasonCode":"SURPLUS_SETPOINT","reasonParams":{"surplusW":2300}},
|
||||
{"loadId":"chauffe-eau","kind":"setpoint","powerW":1200,
|
||||
"reason":"Reliquat 1,2 kW → chauffe-eau (4 kWh requis avant 18h)"}
|
||||
],
|
||||
"expected":{"gridImportW":0,"gridExportW":350}
|
||||
}
|
||||
]
|
||||
],
|
||||
"diagnostics":{"solver":"highs","status":"optimal","gapPercent":0.0,"computeMs":2140,
|
||||
"inputsUsed":{"pv":"physics","baseload":"none","price":"tempo"}}
|
||||
}}
|
||||
```
|
||||
|
||||
@ -221,20 +395,41 @@ Règles d'exécution côté plugin :
|
||||
(affichage app : « prévision programmée ») et sera re-demandé au cycle suivant.
|
||||
- Plan vide ou sans créneau courant = abstention (voir §7).
|
||||
- Toute action est **écrêtée** : bornes min/max de l'adaptateur, verrous temporels,
|
||||
protection de surcharge. Une action écrêtée est exécutée au plus proche possible
|
||||
et loggée (`clamped`).
|
||||
- `reason` absent ou vide → action **rejetée**, log d'avertissement. (Exigence
|
||||
decisionReason.)
|
||||
protection de surcharge (L4 et son extension au waterfall). Une action écrêtée est
|
||||
exécutée au plus proche possible et loggée (`clamped`).
|
||||
- `reason` absent ou vide → action **rejetée**, log d'avertissement.
|
||||
- Un rule-based interne répond au même contrat avec un plan à un seul créneau.
|
||||
- **Le contournement de verrou (`force`) n'existe pas sur le fil.** Il est réservé au repli
|
||||
de sécurité interne (L2) ; un champ `force` reçu du socket est ignoré.
|
||||
- **`validUntil`** : au-delà, le plan est **expiré** → repli rule-based jusqu'au prochain
|
||||
plan valide. Absent = plan valable tant qu'un créneau couvre `now`.
|
||||
- **`expected`** (optionnel, par créneau) : trajectoire prévue. Affichage et mesure d'écart
|
||||
plan/réalité ; **jamais utilisé pour commander**.
|
||||
- **`diagnostics`** (optionnel) : `status` `optimal` | `feasible` | `best-so-far` (calcul
|
||||
interrompu, meilleur plan trouvé) | `infeasible-relaxed` (besoins non tenus nommés dans
|
||||
les `reason`) ; `computeMs`, `gapPercent`, `solver` affichés en mode installateur et
|
||||
enregistrés dans les traces. `inputsUsed.pv` peut valoir `hybrid` si l'optimiseur a
|
||||
appliqué sa propre correction apprise.
|
||||
|
||||
**Explications et langue**
|
||||
- Sur le socket, `reason` (texte, français) est **obligatoire** : il sert aux traces et au
|
||||
mode installateur.
|
||||
- `reasonCode` + `reasonParams` (optionnels) : code stable du catalogue `DecisionCode`
|
||||
(`etm/types/decisionreason.h`, rendu documenté dans `INTERFACE.md`) et ses paramètres.
|
||||
- **Vers l'app, seul `{reasonCode, reasonParams}` est transmis** — jamais le texte libre
|
||||
(`AGENTS.md` invariant 12). Une action sans `reasonCode` connu du catalogue est
|
||||
transmise sous le code générique `OPTIMIZER_DECISION`, sans texte.
|
||||
|
||||
### Types d'action (`kind`)
|
||||
|
||||
| `kind` | Charges | Champs | Exemple |
|
||||
| `kind` | Mécanismes | Champs | Exemple |
|
||||
|---|---|---|---|
|
||||
| `setpoint` | evcharger, battery | `currentA` ou `powerW` (+ `source:"grid"` pour charge réseau batterie) | borne à 10 A ; batterie +3000 W depuis réseau |
|
||||
| `stage` | relay-stages (ECS…) | `stage` (entier, parmi `declared.stages`) | ECS palier 1 (1200 W) |
|
||||
| `setpoint` | evcharger, relay-router, etmvariableload | `currentA` (borne) ou `powerW` | borne à 10 A ; chauffe-eau à 1200 W |
|
||||
| `state` | sg-ready | `state` (1=verrouillage, 2=normal, 3=recommandation, 4=forcé) | PAC en état 3 |
|
||||
| `constraint` | battery (v1) | `charge`/`discharge` : `allow`\|`forbid` | décharge interdite |
|
||||
| `constraint` | — (réservé batterie, annexe F) | `charge`/`discharge` : `allow`\|`forbid` | — |
|
||||
|
||||
La frontière est en **watts** : `relay-router` choisit lui-même la combinaison de relais,
|
||||
`etmvariableload` reçoit une consigne continue. Aucun index de palier, aucun pourcentage.
|
||||
|
||||
## 7. Abstention et repli
|
||||
|
||||
@ -246,18 +441,17 @@ L'optimiseur peut répondre :
|
||||
|
||||
Sémantique : « je n'ai pas de plan à proposer maintenant ». Le plugin bascule sur la
|
||||
stratégie rule-based **pour ce cycle**, sans considérer l'optimiseur comme mort
|
||||
(`optimizerAlive` reste `true`). C'est le comportement attendu pendant un recalcul,
|
||||
un démarrage à froid, ou une absence de données.
|
||||
|
||||
Tableau de repli complet :
|
||||
(`optimizerAlive` reste `true`).
|
||||
|
||||
| Situation | optimizerAlive | Stratégie active |
|
||||
|---|---|---|
|
||||
| Réponse plan valide | true | optimiseur |
|
||||
| `abstain` | true | rules (ce cycle) |
|
||||
| Plan expiré (`validUntil` dépassé) | true | rules jusqu'au prochain plan valide |
|
||||
| Timeout / erreur / plan invalide ×2 | false | rules |
|
||||
| Socket absent / fermé | false | rules |
|
||||
| `optimizerExpected=false` (tier Community) | false | rules |
|
||||
| Mode dégradé L2 (compteur muet > 90 s) | inchangé | **aucune** — planification suspendue, repli de sécurité |
|
||||
|
||||
L'objet capabilities exposé à l'app (`tier`, `optimizerExpected`, `optimizerAlive`,
|
||||
`activeStrategy`) reflète cet état en continu ; tout changement émet une notification.
|
||||
@ -267,33 +461,31 @@ L'objet capabilities exposé à l'app (`tier`, `optimizerExpected`, `optimizerAl
|
||||
- L'optimiseur peut renvoyer ses estimations (`learned` + `confidence`) dans une
|
||||
notification `UpdateLearned` (sans id, fire-and-forget) ; le plugin les persiste
|
||||
et les retransmet dans les contextes suivants.
|
||||
- Tant que `confidence < 0.7` (seuil indicatif), les `reason` concernés mentionnent
|
||||
l'apprentissage (« besoin estimé …, en apprentissage ») et l'app peut afficher un
|
||||
badge « profil en apprentissage » sur la charge.
|
||||
- Les données d'apprentissage restent **locales** (buffer circulaire, ~2 mois,
|
||||
écrasement). Rien ne quitte le site. C'est un engagement produit, pas un détail.
|
||||
- Tant que `confidence < 0.7` (seuil indicatif), l'app peut afficher un badge
|
||||
« profil en apprentissage ».
|
||||
- Les données d'apprentissage restent **locales**. Rien ne quitte le site.
|
||||
- L'état des modèles de prévision (`forecast.pv.conversion`, `forecast.pv.corrections`,
|
||||
`forecast.baseload.model`) est exposé dans l'app au même titre que `confidence`.
|
||||
|
||||
## 9. Versionnement
|
||||
|
||||
- `protocolVersion` en semver. Même majeure = compatible.
|
||||
- Champs inconnus dans `params`/`result` : **ignorés silencieusement** (tolérance
|
||||
ascendante). Ajouts mineurs = nouveaux champs optionnels.
|
||||
- Changements incompatibles (renommage, sémantique) = majeure + entrée changelog
|
||||
dans ce fichier.
|
||||
- Champs inconnus dans `params`/`result` : **ignorés silencieusement**.
|
||||
- Changements incompatibles (renommage, sémantique) = majeure + entrée changelog §0.
|
||||
|
||||
---
|
||||
|
||||
## Annexe A — Session minimale
|
||||
|
||||
```
|
||||
→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.0","engine":"etm/0.9"}}
|
||||
← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.0","name":"mon-optimiseur","planning":false}}
|
||||
→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.1","engine":"etm/0.9"}}
|
||||
← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.1","name":"mon-optimiseur","planning":false}}
|
||||
→ {"jsonrpc":"2.0","id":2,"method":"GetPlan","params":{ ...SurplusContext... }}
|
||||
← {"jsonrpc":"2.0","id":2,"result":{"planId":"x","strategy":"custom","slots":[{ "from":"...","to":"...","actions":[...] }]}}
|
||||
```
|
||||
|
||||
Un optimiseur conforme minimal tient en ~50 lignes de Python : répondre au handshake,
|
||||
renvoyer un plan à un créneau (ou `abstain`). C'est le point d'entrée communautaire.
|
||||
renvoyer un plan à un créneau (ou `abstain`).
|
||||
|
||||
## Annexe B — Exemple : brancher Akkudoktor-EOS
|
||||
|
||||
@ -305,10 +497,9 @@ où l'on veut sur le LAN :
|
||||
plugin (client) ──tcp://nas:7600──► eos-bridge ──HTTP──► serveur EOS
|
||||
```
|
||||
|
||||
Le pont : (1) répond au Handshake ; (2) traduit `SurplusContext` → payload EOS
|
||||
(séries horaires PV/conso/prix — il peut compléter avec les prévisions Akkudoktor) ;
|
||||
(3) traduit le plan EOS → `slots[]`/`LoadAction` ; (4) sert le plan en cache et
|
||||
recalcule en arrière-plan (EOS est lent, le protocole exige des réponses rapides) ;
|
||||
Le pont : (1) répond au Handshake ; (2) traduit `SurplusContext` → payload EOS —
|
||||
`forecast` fournit PV, consommation de base et prix sur grille commune ; (3) traduit le
|
||||
plan EOS → `slots[]`/actions ; (4) sert le plan en cache et recalcule en arrière-plan ;
|
||||
(5) répond `abstain` pendant un recalcul initial.
|
||||
|
||||
Les bornes de sécurité du plugin s'appliquent intégralement : un plan EOS aberrant
|
||||
@ -316,13 +507,54 @@ est écrêté, pas exécuté aveuglément.
|
||||
|
||||
## Annexe C — Priorités : modèle utilisateur
|
||||
|
||||
- **Liste ordonnée globale** (drag-and-drop dans l'app) : l'ordre de service du
|
||||
surplus. Le rule-based l'exécute en cascade (waterfall) ; un planificateur s'en
|
||||
sert comme préférence quand tout ne peut pas être servi.
|
||||
- **Besoins par charge** (fiche équipement) : échéances (`deadline`,
|
||||
`dailyDeadline`), minimums (`minEnergyWhPerDay`), cible (`targetSocPercent`).
|
||||
- **Promotions conditionnelles** : une échéance qui approche ou un signal tarifaire
|
||||
(Tempo ROUGE) peut faire passer une charge devant la liste — toujours expliqué
|
||||
dans `reason`.
|
||||
- **Liste ordonnée** (drag-and-drop dans l'app) : l'ordre de service du surplus. Le
|
||||
rule-based l'exécute en cascade (waterfall) ; un planificateur s'en sert comme
|
||||
préférence quand tout ne peut pas être servi.
|
||||
- **Groupes** : déplacer une famille entière sans renuméroter (`spec_loadmodel.md` §5).
|
||||
Un groupe trie, il ne reçoit pas de budget.
|
||||
- **Besoins par charge** : échéances, minimums, cible, `energyTargets`, `comfort`.
|
||||
- **Intentions client** : deviennent des besoins `source: "user-intent"` — l'optimiseur
|
||||
ré-arbitre avec, il n'est pas contourné.
|
||||
- **Promotions conditionnelles** : une échéance qui approche ou un signal tarifaire peut
|
||||
faire passer une charge devant — toujours expliqué.
|
||||
|
||||
Pas de poids, pas de pourcentages : un ordre, des besoins, des exceptions expliquées.
|
||||
|
||||
## Annexe D — Traces et rejeu
|
||||
|
||||
But : comparer des optimiseurs **sur les mêmes entrées** et rejouer un incident.
|
||||
|
||||
- Option plugin `optimizerTrace` (défaut : désactivée en production, activée sur banc).
|
||||
- Fichiers NDJSON locaux, rotation par jour, rétention configurable (défaut 14 jours), sous
|
||||
le répertoire de stockage du moteur (`NymeaSettings::storagePath()`).
|
||||
- Une ligne par cycle :
|
||||
|
||||
```json
|
||||
{"t":"2026-10-12T13:42:00+02:00","request":{ "...GetPlan params..." },
|
||||
"response":{ "...result ou abstain..." },"executed":[{"loadId":"chauffe-eau","kind":"setpoint",
|
||||
"powerW":1200,"clamped":false}],"strategy":"optimizer","latencyMs":12}
|
||||
```
|
||||
|
||||
- **Rejeu** : hems-sim relit `request`, l'envoie à un optimiseur candidat, compare
|
||||
`response` + `diagnostics`.
|
||||
- Les traces restent sur le site. Export manuel par l'installateur uniquement.
|
||||
|
||||
## Annexe E — Optimiseur de référence
|
||||
|
||||
`tools/ref-optimizer/` : serveur Python minimal (bibliothèque standard seule) conforme 1.1,
|
||||
répondant un plan à un créneau qui reproduit le waterfall rule-based. Usages : test de bout
|
||||
en bout du `SocketScheduler` ; point de comparaison du banc ; exemple communautaire.
|
||||
|
||||
## Annexe F — Champs réservés (non normatifs)
|
||||
|
||||
Ces champs sont **réservés** : aucun porteur n'existe dans le moteur (pas de
|
||||
`BatteryAdapter`, lot 3f). Un optimiseur ne doit pas les émettre ; le plugin les ignore.
|
||||
Ils deviendront normatifs avec le lot qui les implémente.
|
||||
|
||||
- Adaptateur `battery` ; kind `constraint` (`charge`/`discharge` : `allow`|`forbid`) ;
|
||||
`setpoint` batterie avec `source:"grid"` (charge réseau).
|
||||
- `loads[].socTarget` `{percent, by, source}` : cible de SOC comme participant à la
|
||||
priorisation.
|
||||
- `loads[].policies` : `noDischargeWhileEvCharging`, `holdChargeForDomains`,
|
||||
`gridChargeAllowed`.
|
||||
- `expected.batterySocPercent`.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user