Annexe D : défaut désactivé, nom des fichiers, rotation locale, plafond, appariement requête → réponse (la ligne N attend la réponse à la requête N), no-response, jamais le token ; le rejeu est fait par tools/etm-powersync-trace-replay (et non plus « hems-sim »). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
616 lines
33 KiB
Markdown
616 lines
33 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`).
|
||
|
||
> ### 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).
|
||
|
||
### 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 → 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 — 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","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`.
|
||
|
||
### 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,
|
||
"corrections":["temperature"],"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` (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 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 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).
|
||
- `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`. 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.
|
||
|
||
## 6. Réponse : plan par créneaux
|
||
|
||
```json
|
||
{"jsonrpc":"2.0","id":42,"result":{
|
||
"planId":"2026-06-07T13:42:00Z-a1",
|
||
"strategy":"etm-predict",
|
||
"computedAt":"2026-06-07T13:41:12+02:00",
|
||
"validUntil":"2026-06-07T14:15:00+02:00",
|
||
"slots":[
|
||
{
|
||
"from":"2026-06-07T13:42:00+02:00","to":"2026-06-07T14:00:00+02:00",
|
||
"actions":[
|
||
{"loadId":"ev-1","kind":"setpoint","currentA":10,
|
||
"reason":"Surplus PV 2,3 kW → VE prioritaire (cible 80% à 07:00)",
|
||
"reasonCode":"SURPLUS_SETPOINT","reasonParams":{"surplusW":2300}},
|
||
{"loadId":"chauffe-eau","kind":"setpoint","powerW":1200,
|
||
"reason":"Reliquat 1,2 kW → chauffe-eau (4 kWh requis avant 18h)"}
|
||
],
|
||
"expected":{"gridImportW":0,"gridExportW":350}
|
||
}
|
||
],
|
||
"diagnostics":{"solver":"highs","status":"optimal","gapPercent":0.0,"computeMs":2140,
|
||
"inputsUsed":{"pv":"physics","baseload":"none","price":"tempo"}}
|
||
}}
|
||
```
|
||
|
||
Règles d'exécution côté plugin :
|
||
- **Seul le créneau couvrant `now` est exécuté.** Le reste du plan est informatif
|
||
(affichage app : « prévision programmée ») et sera re-demandé au cycle suivant.
|
||
- Plan vide ou sans créneau courant = abstention (voir §7).
|
||
- Toute action est **écrêtée** : bornes min/max de l'adaptateur, verrous temporels,
|
||
protection de surcharge (L4 et son extension au waterfall). Une action écrêtée est
|
||
exécutée au plus proche possible et loggée (`clamped`).
|
||
- `reason` absent ou vide → action **rejetée**, log d'avertissement.
|
||
- Un rule-based interne répond au même contrat avec un plan à un seul créneau. Côté moteur
|
||
(plan publié vers l'app, traces), ce plan porte `strategy: "rules"`, la même valeur que
|
||
`activeStrategy` ; le `strategy` d'un optimiseur reste une chaîne libre, informative.
|
||
- **Le contournement de verrou (`force`) n'existe pas sur le fil.** Il est réservé au repli
|
||
de sécurité interne (L2) ; un champ `force` reçu du socket est ignoré.
|
||
- **`validUntil`** : au-delà, le plan est **expiré** → repli rule-based jusqu'au prochain
|
||
plan valide. Absent = plan valable tant qu'un créneau couvre `now`.
|
||
- **`expected`** (optionnel, par créneau) : trajectoire prévue. Affichage et mesure d'écart
|
||
plan/réalité ; **jamais utilisé pour commander**.
|
||
- **`diagnostics`** (optionnel) : `status` `optimal` | `feasible` | `best-so-far` (calcul
|
||
interrompu, meilleur plan trouvé) | `infeasible-relaxed` (besoins non tenus nommés dans
|
||
les `reason`) ; `computeMs`, `gapPercent`, `solver` affichés en mode installateur et
|
||
enregistrés dans les traces. `inputsUsed.pv` peut valoir `hybrid` si l'optimiseur a
|
||
appliqué sa propre correction apprise.
|
||
|
||
**Explications et langue**
|
||
- Sur le socket, `reason` (texte, français) est **obligatoire** : il sert aux traces et au
|
||
mode installateur.
|
||
- `reasonCode` + `reasonParams` (optionnels) : code stable du catalogue `DecisionCode`
|
||
(`etm/types/decisionreason.h`, rendu documenté dans `INTERFACE.md`) et ses paramètres.
|
||
- **Vers l'app, seul `{reasonCode, reasonParams}` est transmis** — jamais le texte libre
|
||
(`AGENTS.md` invariant 12). Une action sans `reasonCode` connu du catalogue est
|
||
transmise sous le code générique `OPTIMIZER_DECISION`, sans texte.
|
||
|
||
### Types d'action (`kind`)
|
||
|
||
| `kind` | Mécanismes | Champs | Exemple |
|
||
|---|---|---|---|
|
||
| `setpoint` | evcharger, relay-router, etmvariableload | `currentA` (borne) ou `powerW` | borne à 10 A ; chauffe-eau à 1200 W |
|
||
| `state` | sg-ready | `state` (1=verrouillage, 2=normal, 3=recommandation, 4=forcé) | PAC en état 3 |
|
||
| `constraint` | — (réservé batterie, annexe F) | `charge`/`discharge` : `allow`\|`forbid` | — |
|
||
|
||
La frontière est en **watts** : `relay-router` choisit lui-même la combinaison de relais,
|
||
`etmvariableload` reçoit une consigne continue. Aucun index de palier, aucun pourcentage.
|
||
|
||
## 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` reste `true`).
|
||
|
||
| 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).
|
||
|
||
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.
|
||
- 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/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`).
|
||
|
||
## 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/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`.
|