docs(optimiseur): section Optimiseur et partie technique en trois sections (fr)

Navigation : « EMS — moteur à règles » (pages de fonctionnalités, précédées
de « Comment le moteur décide »), « Optimiseur » (Présentation, Protocole,
Contrat, Écrire un optimiseur), « API » (ex-« Référence »). Aucun fichier
existant déplacé : les URL publiées restent valides.

Protocole : prose courte qui renvoie à OPTIMIZER_PROTOCOL.md du moteur
pour le sens ; tableaux, listes fermées et règles nommées générés depuis
le contrat. Contrat servi tel quel (copie octet pour octet).
reference.md, resté un bouchon, devient l'accueil de la section API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-18 13:06:04 +02:00
parent 8e37de4d24
commit 209e2d441b
10 changed files with 3393 additions and 12 deletions

View File

@ -0,0 +1,85 @@
# Comment le moteur décide
<span class="badge community">COMMUNITY</span>
Le moteur d'énergie de la box pilote seul vos charges : bornes de recharge, chauffe-eau,
pompe à chaleur SG-Ready, relais. Il est libre (GPL-3.0) et c'est lui qui décide dans la
version **Community**. Cette page décrit sa logique pour l'installateur et l'intégrateur ;
les pages suivantes détaillent chaque fonction.
## Deux boucles, deux priorités
Le moteur sépare la sécurité de la répartition. Ce sont deux boucles distinctes, qui ne
tournent pas au même rythme et n'ont pas la même priorité.
- **La boucle de sécurité, en temps réel.** À chaque variation du bilan mesuré, elle lit
le courant de chaque phase au compteur de tête. Si une phase dépasse la limite
configurée, elle réduit immédiatement les charges ([délestage](delestage.md)), puis
relâche quand la marge revient. Elle n'attend pas le cycle et prime sur tout le reste.
- **Le cycle de répartition, environ une fois par minute.** Il répartit la puissance
disponible entre les charges. C'est le seul endroit où une stratégie intervient : les
règles intégrées, ou un [optimiseur](../optimiseur/index.md) s'il est branché.
Si le compteur de tête ne répond plus, le moteur suspend la répartition et passe en repli
de sécurité : sans mesure, il ne décide pas.
## Les règles, dans l'ordre
À chaque cycle, le moteur applique les mêmes étapes, dans le même ordre.
1. **Mesurer.** Le point de départ est ce que mesure le compteur de tête. Le moteur y
rajoute ce que consomment déjà les charges qu'il pilote : une charge en marche n'est
pas comptée contre elle-même.
2. **Tenir compte de ce qui est figé.** Une charge sous verrou (durée minimale de marche
ou d'arrêt, délai entre deux changements de courant) garde sa puissance engagée, et
celle-ci est retirée du budget. Une charge indisponible ne reçoit aucune consigne, mais
ce qu'elle consomme reste compté.
3. **Servir les besoins à échéance.** Une obligation quotidienne (par exemple une énergie
minimale pour le chauffe-eau avant une heure donnée) ou une demande ponctuelle faite
depuis l'application peut, quand l'échéance approche et que le surplus n'a pas suffi,
prendre le complément sur le réseau — si l'installateur l'a autorisé.
4. **Répartir le surplus en cascade.** Les charges sont servies dans l'ordre de
**priorité** que vous avez fixé (1 = servie en premier). Chacune reçoit ce qui reste du
budget, dans ses bornes, et le budget est décrémenté d'autant. Le budget est **unique** :
sans lui, trois gestionnaires verraient chacun « 2 kW de surplus » et en tireraient 6.
5. **Arrêter ce qui ne peut pas démarrer.** Sous sa puissance minimale, une charge reste
arrêtée plutôt que de fonctionner à moitié.
### Les bornes de chaque charge
Chaque charge porte des bornes saisies par l'installateur, que **toute** décision
respecte, quelle que soit la stratégie :
- un **plancher** et un **plafond** de puissance (ou de courant, pour une borne) ;
- des **verrous anti-battement** : durée minimale de marche et d'arrêt, délai entre deux
changements ;
- pour une PAC SG-Ready : l'état choisi selon la puissance estimée de chaque état, avec
une durée minimale de maintien.
## Le motif de chaque décision
Le moteur ne prend aucune décision sans en dire la raison. Chaque décision porte un
**motif** (`decisionReason`) non vide : un code stable et ses paramètres, que
l'application affiche dans votre langue — « surplus insuffisant », « sous la puissance
minimale », « échéance de recharge »… La liste des codes est publiée avec le
[protocole optimiseur](../optimiseur/protocole.md#reponse).
## La mesure, jamais le résultat du pilotage
Le surplus est calculé sur la **mesure brute** : production et compteur, tels qu'ils
sont mesurés. Jamais sur le bilan net après pilotage : le moteur se lirait lui-même et
oscillerait.
## Sans internet
Tout ce qui précède tourne sur la box. Sans internet, le moteur pilote, applique la
sécurité et répond à l'application sur votre réseau local. Seule la
[prévision PV](prevision-pv.md) s'interrompt — et elle ne sert pas à décider dans la
version Community, pas plus que le [tarif saisi](../tarifs.md) : tous deux sont affichés
et enregistrés, jamais utilisés par les règles.
## Et un optimiseur ?
Un optimiseur peut proposer un plan sur plusieurs heures, à partir des prévisions. Il
remplace l'étape de répartition, **jamais** la boucle de sécurité ni les bornes. Voir la
section [Optimiseur](../optimiseur/index.md).

View File

@ -0,0 +1,54 @@
# Contrat
Le **contrat machine** décrit la forme de chaque message du [protocole](protocole.md) :
un document [OpenRPC](https://spec.open-rpc.org/) dont les messages sont en JSON Schema,
complété par des extensions `x-etm-…` pour ce qu'OpenRPC ne sait pas dire — transport,
machine de repli, règles de validation nommées, tolérances, listes fermées.
Le moteur l'embarque, et ses tests comme ceux de la suite de conformité le vérifient des
deux côtés. Les tableaux de la page [Protocole](protocole.md) en sont générés.
## Télécharger le contrat {#telecharger}
[:material-download: Télécharger `etm-powersync-optimizer-protocol.openrpc.json`](etm-powersync-optimizer-protocol.openrpc.json){ .md-button }
Le site sert le fichier **tel quel**, octet pour octet, depuis la branche par défaut du
dépôt du moteur. Sa fiche :
<!-- BEGIN GENERATED: __optimizer_contract__ -->
| | Contract |
|---|---|
| Title | etm-powersync — protocole moteur ↔ optimiseur |
| Protocol version | `1.1` |
| Format | OpenRPC `1.3.2`, JSON Schema in `components.schemas` |
| License | GPL-3.0-or-later |
| File | [`etm-powersync-optimizer-protocol.openrpc.json`](etm-powersync-optimizer-protocol.openrpc.json) |
| Size | 67215 bytes |
| SHA-256 | `29dfe5a3c94a06e94e95042c0ceefd4d6264fcca44265faa676a06fac1c07ded` |
| Methods | `Handshake` · `GetPlan` · `UpdateLearned` |
| Schemas | 47 |
| Semantic rules | 19 |
| Extensions (document) | `x-etm-fallback-machine` · `x-etm-generated` · `x-etm-reason-params` · `x-etm-semantic-rules` · `x-etm-tolerances` · `x-etm-transport` |
| Extensions (methods) | `x-etm-direction` · `x-etm-notification` · `x-etm-timeout-s` |
<!-- END GENERATED -->
## Le contrat de votre box : `NymeaEnergy.GetOptimizerProtocol` {#getoptimizerprotocol}
Le contrat publié ici suit le développement du moteur ; votre box peut porter une version
antérieure. Pour connaître **le** contrat du moteur installé, demandez-le à la box : la
méthode JSON-RPC `NymeaEnergy.GetOptimizerProtocol` rend celui qu'elle embarque. C'est
celui-là qui fait foi pour un optimiseur branché sur cette box.
La méthode est décrite dans
[`INTERFACE.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/INTERFACE.md),
dans le dépôt du moteur, avec les autres méthodes `NymeaEnergy.*` de l'optimiseur
(point de connexion, secret partagé, état). La connexion à l'API est décrite sur la page
[API JSON-RPC](../integrations/jsonrpc-api.md).
## Valider des messages {#valider}
Le dépôt du moteur fournit un validateur, `tools/etm-powersync-schema-check` (Python,
bibliothèque standard) : il juge un message, une session complète enregistrée ou des
traces contre le contrat, et applique sur demande les règles de validation nommées.

101
docs/optimiseur/ecrire.md Normal file
View File

@ -0,0 +1,101 @@
# Écrire un optimiseur
Un optimiseur est un **serveur** qui parle le [protocole](protocole.md) : il répond au
handshake, puis à chaque `GetPlan` par un plan à un ou plusieurs créneaux, ou par une
abstention. Le langage est libre. Un optimiseur minimal tient en une cinquantaine de
lignes de Python.
Session minimale, à titre d'illustration (la forme exacte est dans le [contrat](contrat.md)) :
```text
→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.1","engine":"etm-powersync-energy-plugin-etm/…"}}
← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.1","name":"mon-optimiseur","planning":false}}
→ {"jsonrpc":"2.0","id":2,"method":"GetPlan","params":{ …contexte… }}
← {"jsonrpc":"2.0","id":2,"result":{"abstain":true,"reason":"premier calcul en cours"}}
```
Trois points à ne pas manquer :
- **une action par charge disponible** dans le créneau en cours, chacune avec son
**motif** en texte : sinon le plan entier est refusé ;
- **répondre vite** : servir le dernier plan en cache, ou s'abstenir, et calculer en tâche
de fond ;
- **ne rien aller chercher ailleurs** : tout ce dont l'optimiseur a besoin, prévisions
comprises, est dans le contexte.
## L'optimiseur de référence {#reference}
Le dépôt du moteur contient un optimiseur de référence libre (GPL-3.0), en Python,
bibliothèque standard seule :
[`tools/etm-powersync-ref-optimizer/`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/tree/main/tools/etm-powersync-ref-optimizer).
Il répond un plan à un créneau qui reproduit la cascade des règles du moteur, en ne lisant
que le contexte. Il s'annonce `powersync-optimizer` au handshake.
C'est un point de départ pour écrire le sien, et un point de comparaison : il écoute sur
le socket local par défaut, ou sur `tcp://` avec un secret partagé lu dans un fichier. Il
sait aussi injecter des défauts (abstention, lenteur, plan invalide, majeure différente…)
pour tester le repli du moteur. Mode d'emploi :
[son `README.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/tools/etm-powersync-ref-optimizer/README.md).
## Brancher le moteur sur son optimiseur {#brancher}
Trois méthodes JSON-RPC de la box, réservées à l'administrateur, décrites dans
[`INTERFACE.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/INTERFACE.md) :
- `NymeaEnergy.SetOptimizerEndpoint` — où se connecter (`unix:///…` ou `tcp://hôte:port`) ;
- `NymeaEnergy.SetOptimizerToken` — le secret partagé, le même que celui de l'optimiseur ;
- `NymeaEnergy.GetOptimizerStatus` — l'état de la connexion : optimiseur vivant, stratégie
active, nom annoncé, dernier code d'erreur.
Sur `tcp://` sans chiffrement, le secret circule en clair sur le réseau local.
## La suite de conformité {#conformite}
La suite `etm-powersync-optimizer-conformance` lance **votre** optimiseur et branche dessus
le **vrai** moteur, sur des appareils simulés. Elle vérifie, une fonction par point :
1. le handshake est accepté ;
2. chaque `GetPlan` reçoit sa réponse dans le délai du moteur ;
3. la session entière, dans les deux sens, est conforme au contrat embarqué par le moteur,
règles de validation comprises ;
4. chaque plan est appliqué : c'est l'optimiseur qui commande ;
5. la boucle de sécurité reste au-dessus : une consigne hors bornes est écrêtée, une
surcharge de phase est délestée.
Elle ne juge pas la **qualité** des plans.
### La lancer contre son optimiseur
Construire le moteur avec ses tests, puis indiquer la commande qui démarre votre serveur ;
`{endpoint}` y est remplacé par l'adresse où écouter :
```sh
export ETM_POWERSYNC_OPTIMIZER_CMD='exec /chemin/de/mon-optimiseur --listen {endpoint}'
export ETM_POWERSYNC_CONFORMANCE_EXPECT=plan # ou abstain
```
Chaque fonction se lance dans son propre processus. La commande complète (isolement
réseau recommandé), les autres variables (secret partagé, délai de démarrage…) et la
lecture des échecs sont dans le
[`README.md` de la suite](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/tests/auto/conformance/README.md).
### Mode attendu : `plan` ou `abstain` {#mode-attendu}
`ETM_POWERSYNC_CONFORMANCE_EXPECT` dit à la suite ce que fait votre optimiseur :
- **`plan`** (par défaut) : il commande. Chaque cycle doit appliquer son plan.
- **`abstain`** : il s'abstient à chaque cycle — c'est permis, et conforme. La suite
vérifie alors que le moteur pilote par ses règles à chaque cycle, sans compter
d'échec, et que la sécurité tient toujours ; le contrôle d'écrêtage est sauté, faute de
consigne à écrêter.
Toute autre valeur fait échouer la suite. **Le mode ne se déduit pas du handshake** :
`planning` et `capabilities` décrivent la forme des plans, pas le fait d'en produire. Le
mode se donne, il ne se devine pas.
## Comparer sur les mêmes entrées {#traces}
Le moteur peut enregistrer, sur la box, une trace de chaque cycle (désactivée par défaut,
`NymeaEnergy.SetOptimizerTrace`). L'outil `tools/etm-powersync-trace-replay` rejoue ces
traces sur un optimiseur candidat et résume les écarts avec le plan enregistré. Les traces
restent sur le site.

File diff suppressed because it is too large Load Diff

66
docs/optimiseur/index.md Normal file
View File

@ -0,0 +1,66 @@
# Optimiseur
<span class="badge testing">EN COURS</span>
Le protocole entre le moteur et un optimiseur est publié (version 1.1). La connexion d'un
optimiseur est en test sur notre installation de test ; la box n'en a pas besoin pour
fonctionner.
## À quoi sert un optimiseur
Les [règles du moteur](../fonctionnalites/moteur.md) décident sur le présent : le surplus
de cette minute, réparti selon vos priorités. Un optimiseur regarde plus loin. À partir des
prévisions que le moteur lui transmet — production PV, consommation de base, tarif saisi —
il propose un **plan par créneaux** sur plusieurs heures : décaler le chauffe-eau vers le
pic de production prévu, garder de la marge pour la recharge du soir, etc.
## L'optimiseur conseille, le moteur décide
- Le moteur n'exécute que le **créneau en cours** du plan. Le reste est affiché comme
prévision, et redemandé au cycle suivant.
- Chaque consigne est **plafonnée** par les bornes de la charge (plancher, plafond,
verrous anti-battement) et par la **boucle de sécurité** en temps réel, qui reste au
moteur et prime sur tout. Un optimiseur ne peut pas contourner un verrou.
- Chaque action doit porter son **motif**. Une action sans motif est rejetée.
- Un plan mal formé, ou qui vise une charge inconnue, est refusé en entier.
Un optimiseur défaillant ne peut donc, au pire, que proposer une mauvaise répartition —
bornée par le moteur.
## La box fonctionne sans optimiseur
Optimiseur absent, muet, qui s'abstient, plan expiré ou refusé : le moteur revient à ses
**règles** (`rules`) pour le cycle, sans interruption du pilotage. L'application indique à
tout moment quelle stratégie pilote. La version Community fonctionne entièrement ainsi.
## Ses données ne viennent que du moteur
- C'est le moteur qui se connecte à l'optimiseur, jamais l'inverse : la box n'ouvre aucun
port.
- L'optimiseur tourne sur la box (socket local) ou sur une machine de votre réseau local
(TCP). Sur le réseau local, un secret partagé (token) est recommandé.
- Il ne reçoit **que** le contexte envoyé par le moteur, prévisions comprises : il
n'appelle ni service météo ni fournisseur de tarif, et n'ouvre aucune connexion vers
l'extérieur.
- Ce qu'il apprend reste sur le site.
## Qui peut écrire un optimiseur
Tout le monde. Le protocole est ouvert et décrit par un [contrat machine](contrat.md)
publié sous GPL-3.0. Un **optimiseur de référence** libre et une **suite de conformité**
permettent d'écrire le sien et de le vérifier contre le vrai moteur — voir
[Écrire un optimiseur](ecrire.md).
## L'optimiseur ETM
ETM-Schurig propose son propre optimiseur, propriétaire, inclus dans l'édition Pro. Il se
branche par ce même protocole, avec les mêmes limites : il conseille, le moteur décide. Au
handshake, il s'annonce `etm-optimizer` ; l'optimiseur de référence s'annonce
`powersync-optimizer`.
## Dans cette section
- [Protocole](protocole.md) — transport, handshake, contexte, plan, repli, règles de
validation.
- [Contrat](contrat.md) — le fichier OpenRPC qui fait foi sur la forme des messages.
- [Écrire un optimiseur](ecrire.md) — optimiseur de référence et suite de conformité.

View File

@ -0,0 +1,855 @@
# Protocole
Le protocole relie le **moteur** d'énergie de la box à un **optimiseur**, sur un canal
dédié. Ce n'est pas l'API de l'application : celle-ci passe par l'[API JSON-RPC](../integrations/jsonrpc-api.md).
!!! info "Ce qui fait foi"
- **La forme** des messages (champs, types, valeurs permises, ce que le moteur refuse)
est fixée par le [contrat machine](contrat.md). Les tableaux de cette page en sont
**générés** à chaque publication : cadre en anglais, descriptions reprises telles
quelles du contrat, en français. Ils sont identiques dans les trois langues.
- **Le sens** est décrit en détail, en français, dans
[`OPTIMIZER_PROTOCOL.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/docs/OPTIMIZER_PROTOCOL.md),
dans le dépôt du moteur. Cette page en donne l'essentiel ; en cas d'écart, les
documents du moteur l'emportent.
## Transport et rôles {#transport}
- L'optimiseur est le **serveur**, le moteur est le **client** : le moteur se connecte et
pose les questions. La box n'ouvre aucun port.
- Deux transports, mêmes messages : `unix://` (socket local, par défaut) et `tcp://`
(machine du réseau local).
- JSON-RPC 2.0, un message par ligne (NDJSON), UTF-8.
- Si la connexion tombe, le moteur se reconnecte avec un délai croissant et pilote par
ses règles en attendant.
<!-- BEGIN GENERATED: __optimizer_transport__ -->
| Property | Value (contract) |
|---|---|
| Protocol version | `1.1` |
| Application protocol | JSON-RPC `2.0` |
| Framing | `ndjson` (one message per line) |
| Encoding | `utf-8` |
| Endpoint schemes | `unix://` · `tcp://` |
| Default endpoint | `unix:///run/powersync/optimizer.sock` |
| Client | `engine` |
| Server | `optimizer` |
| Max line size | 1048576 bytes |
| Handshake timeout | 5 s |
| Request timeout | 10 s |
| Reconnect backoff | 1 s → 60 s, ×2 |
| Requests in flight | 1 |
| Request ids | entiers croissants sur toute la vie du client, à travers les reconnexions |
| Line errors | ligne non JSON, sans id, id jamais émis, ligne > maxLineBytes : comptée, connexion CONSERVÉE |
| Token masking | toute occurrence du token dans un message reçu est remplacée par « [token masqué] » dès l'analyse |
**Servers**
| Name | URL | Summary (contract) | Example values |
|---|---|---|---|
| `unix` | `unix:///run/powersync/optimizer.sock` | Défaut : optimiseur local, socket possédé par root:powersync, mode 0660. | |
| `tcp` | `tcp://{host}:{port}` | Optimiseur sur le LAN. Token recommandé ; il circule en clair sans TLS (E16). | `host` = `192.168.1.10`, `port` = `7400` |
**Methods**
| Method | Direction | Kind | Timeout | Summary (contract) |
|---|---|---|---|---|
| `Handshake` | `engine→optimizer` | request | 5 s | Ouverture de session, à chaque (re)connexion (§3). |
| `GetPlan` | `engine→optimizer` | request | 10 s | Demande d'un plan pour le contexte courant (§4-§6). |
| `UpdateLearned` | `optimizer→engine` | notification (no id) | — | Estimations apprises de l'optimiseur (§8) — notification, sans id. |
<!-- END GENERATED -->
## Handshake {#handshake}
À chaque connexion, le moteur envoie `Handshake` avec sa version du protocole et, s'il est
configuré, le secret partagé. L'optimiseur répond sa version et, s'il le souhaite, son nom
et ses capacités. Une **majeure** différente, un secret refusé ou une réponse illisible :
la connexion est refusée, et le moteur pilote par ses règles. Si l'optimiseur annonce les
types d'action (`kinds`) qu'il émet, le moteur refuse ensuite toute action d'un autre type.
<!-- BEGIN GENERATED: __optimizer_handshake__ -->
#### `Handshake` — params
Ouverture de session, à chaque (re)connexion (§3).
Envoyé par le moteur (client) dès la connexion. Délai : 5 s depuis le DÉBUT de la tentative ; au-delà, repli et nouvelle tentative au backoff. Le message porte le secret : il n'est jamais journalisé.
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `protocolVersion` | [`ProtocolVersion`](#schema-protocolversion) | yes | | Version du moteur ; seule la majeure est comparée. |
| `engine` | string | yes | pattern `^etm-powersync-energy-plugin-etm/` | Moteur et sa version. |
| `token` | [`HandshakeToken`](#schema-handshaketoken) | no | | |
**result** (`handshake`): [`HandshakeResult`](#schema-handshakeresult)
**Errors**
| Code | Message | Note (contract) |
|---|---|---|
| `-32001` | token refusé | Recommandé (optimiseur de référence). Toute réponse error est traitée pareil : handshake-refused, connexion fermée. |
#### `HandshakeToken` {#schema-handshaketoken}
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é.
Type: string — ≥ 16 char. ; pattern `^[^\s\x00-\x1f\x7f]+$`
#### `HandshakeResult` {#schema-handshakeresult}
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `protocolVersion` | string | yes | ≥ 1 char. ; pattern `^[0-9]+(\.[0-9]+)*$` | Version semver de l'optimiseur ; même MAJEURE que le moteur exigée (règle major-mismatch). |
| `name` | string | no | | Nom affiché (mode installateur), non contrôlé par le moteur (E-T7-3). |
| `planning` | boolean | no | | Vrai si l'optimiseur produit des plans multi-créneaux. Informatif. |
| `capabilities` | [`Capabilities`](#schema-capabilities) | no | | |
Undeclared fields: **ignored** (optimizer-emitted form, open).
#### `Capabilities` {#schema-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).
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `maxHorizonH` | number | no | ≥ 0 | Horizon maximal planifié, en heures. |
| `resolutionS` | number | no | ≥ 0 | Pas des créneaux, en secondes. |
| `kinds` | [[`ActionKind`](#schema-actionkind)] | no | | PRÉSENT, il restreint les kinds acceptés (règle kind-not-announced). |
| `usesForecast` | [string] | no | `pv` · `baseload` · `price` · `temperature` | |
| `reasonCodes` | boolean | no | | L'optimiseur émet des reasonCode du catalogue. |
Undeclared fields: **ignored** (optimizer-emitted form, open).
<!-- END GENERATED -->
## Le contexte : `GetPlan` {#contexte}
À chaque cycle, et sur événement (branchement d'un véhicule, nouvelle prévision,
modification de la configuration…), le moteur envoie `GetPlan` avec un **contexte** :
état du site et du compteur de tête, production, batterie, charges arbitrées avec leurs
bornes, verrous et besoins, prévisions sur une grille temporelle unique.
Quelques règles de lecture, qui changent les décisions :
- le compteur est transmis en **mesure brute**, jamais le bilan après pilotage ;
- une valeur inconnue vaut `null`, **jamais 0** ;
- une charge indisponible (`available: false`) ne doit recevoir aucune consigne, mais elle
consomme : elle reste comptée ;
- les verrous en cours (`lockMinPowerW`, `lockMaxPowerW`) disent ce que la charge peut
faire **maintenant** ; le moteur les applique quoi qu'il arrive ;
- ce que mesure une charge se lit dans `measurement.source`, jamais dans son mécanisme
(`adapter`) ; une puissance déclarée n'est jamais une mesure.
La forme envoyée par le moteur est **fermée** : il n'émet aucun champ hors du contrat.
<!-- BEGIN GENERATED: __optimizer_context__ -->
#### `GetPlan` — params
Demande d'un plan pour le contexte courant (§4-§6).
À chaque cycle (~1/min) et sur événement (trigger). Une requête en vol au plus : une plus récente remplace l'attente de la précédente (sa réponse est ignorée par id, ce n'est pas un échec). Délai : 10 s par requête. La réponse à la requête N sert au cycle N+1 (modèle cache). Le JSON Schema dit la FORME ; x-etm-semantic-rules dit ce que le validateur refuse en plus.
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `timestamp` | string | yes | instant with offset | Instant du cycle — le « maintenant » du validateur. |
| `trigger` | [`Trigger`](#schema-trigger) | yes | | |
| `site` | [`Site`](#schema-site) | yes | | |
| `meter` | [`Meter`](#schema-meter) | yes | | |
| `pv` | [`Pv`](#schema-pv) | yes | | |
| `battery` | [`Battery`](#schema-battery) | yes | | |
| `loads` | [[`Load`](#schema-load)] | yes | | Charges arbitrées, ⊆ GetLoadConfig. |
| `forecast` | [`Forecast`](#schema-forecast) \| null | yes | | null sans instantané de prévision. |
| `tariff` | [`Tariff`](#schema-tariff) \| null | yes | | |
**Errors**
| Code | Message | Note (contract) |
|---|---|---|
| `-32603` | erreur interne | Toute réponse error compte comme un échec de la requête (rpc-error, §7) ; le code n'est pas lu. |
#### `Site` {#schema-site}
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `contractedPowerW` | number \| null | yes | | Puissance souscrite (W) ; null si inconnue (E1), jamais 0. |
| `gridCapW` | null | yes | | RÉSERVÉ (signaux réseau type §14a EnWG) : toujours null en 1.1. |
| `phaseLimitA` | [number] \| null | yes | | Limite par phase en A ; null si inconnue. |
Undeclared fields: **rejected** (engine-emitted form, closed).
#### `Meter` {#schema-meter}
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `source` | string \| null | yes | | ThingId du compteur racine désigné ; null si inconnu. |
| `importW` | number | yes | ≥ 0 | Puissance importée (W). |
| `exportW` | number | yes | ≥ 0 | Puissance exportée (W), mesure BRUTE (AGENTS.md règle 8) — jamais le net après pilotage. |
| `perPhaseA` | [number] | yes | | Courant par phase (A). |
Undeclared fields: **rejected** (engine-emitted form, closed).
#### `Pv` {#schema-pv}
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `currentW` | number \| null | yes | ≥ 0 | Production instantanée (W), positive ; null sans producteur (jamais 0). |
Undeclared fields: **rejected** (engine-emitted form, closed).
#### `Battery` {#schema-battery}
One of: [`BatteryAbsent`](#schema-batteryabsent) · [`BatteryPresent`](#schema-batterypresent).
#### `Load` {#schema-load}
Une charge arbitrée (§5.3) — la forme dépend du mécanisme (adapter).
One of: [`LoadWatts`](#schema-loadwatts) · [`LoadEvCharger`](#schema-loadevcharger) · [`LoadSgReady`](#schema-loadsgready).
#### `Forecast` {#schema-forecast}
Prévisions sur UNE grille (§5.2) ; tableaux alignés index par index.
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `start` | string | yes | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
| `resolutionS` | integer | yes | ≥ 1 | Pas de la grille (s) — fait foi, jamais supposé. |
| `steps` | integer | yes | ≥ 0 | Nombre de cases ; toutes les séries ont cette longueur. |
| `pv` | [`PvForecast`](#schema-pvforecast) \| null | yes | | null sans courbe (site non désigné, charge utile refusée, canal muet). |
| `baseload` | [`Baseload`](#schema-baseload) | yes | | |
| `weather` | null | yes | | Aucune source de température en 1.1 : toujours null. |
| `price` | [`Price`](#schema-price) \| null | yes | | null sans tarif saisi. |
Undeclared fields: **rejected** (engine-emitted form, closed).
#### `Tariff` {#schema-tariff}
Tarif 1.0, conservé pour compatibilité ; un optimiseur 1.1 lit forecast.price. Aucun écrivain aujourd'hui (E3) : null en pratique.
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `provider` | string \| null | yes | | |
| `current` | [`TariffEntry`](#schema-tariffentry) | yes | | |
| `next` | [[`TariffNextEntry`](#schema-tariffnextentry)] | yes | | |
Undeclared fields: **rejected** (engine-emitted form, closed).
#### `BatteryAbsent` {#schema-batteryabsent}
Aucun stockage déclaré : rien d'autre n'est émis.
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `present` | boolean | yes | `false` | |
Undeclared fields: **rejected** (engine-emitted form, closed).
#### `BatteryPresent` {#schema-batterypresent}
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `present` | boolean | yes | `true` | |
| `socPercent` | number | yes | | |
| `powerW` | number | yes | | Positif = charge, négatif = décharge. |
| `capacityWh` | number \| null | yes | | null si inconnue. |
| `maxChargeW` | number \| null | yes | | null si inconnue. |
| `maxDischargeW` | number \| null | yes | | null si inconnue. |
| `reserveSocPercent` | integer \| null | yes | | Réserve de secours (%) ; null si inconnue. |
Undeclared fields: **rejected** (engine-emitted form, closed).
#### `LoadWatts` {#schema-loadwatts}
Charge pilotée en watts : combinaison de relais (relay-router) ou consigne continue (etmvariableload).
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `id` | string | yes | ≥ 1 char. | Identifiant de la charge — clé des actions. |
| `label` | string | yes | | Nom lisible. |
| `priority` | integer | yes | | Rang effectif (1 = premier servi). |
| `domain` | [`Domain`](#schema-domain) | no | | |
| `learned` | [`LoadLearned`](#schema-loadlearned) | no | | |
| `needs` | [`LoadNeeds`](#schema-loadneeds) | no | | |
| `adapter` | string | yes | `relay-router` · `etmvariableload` | |
| `declared` | object | yes | | |
| `declared.minPowerW` | number | yes | | 0 = aucun plancher. |
| `declared.maxPowerW` | number | yes | | |
| `telemetry` | object | yes | | |
| `telemetry.currentPowerW` | number | yes | | Puissance courante (W) telle que l'adaptateur la publie. |
| `telemetry.available` | boolean | yes | | Gouverne l'ALLOCATION, pas la COMPTABILITÉ (§5.3). |
| `telemetry.measurement` | [`Measurement`](#schema-measurement) | no | | |
| `telemetry.lockMinPowerW` | number | yes | | Plancher imposé MAINTENANT par un verrou. |
| `telemetry.lockMaxPowerW` | number | yes | | Plafond imposé maintenant ; négatif = aucun plafond. |
| `limits` | object | yes | | |
| `limits.minOnS` | integer | yes | | |
| `limits.minOffS` | integer | yes | | |
Undeclared fields: **rejected** (engine-emitted form, closed).
#### `LoadEvCharger` {#schema-loadevcharger}
Borne. Une borne CONFIGURÉE dont l'appareil manque n'a ni declared, ni limits, ni pluggedIn/charging : ses zéros diraient « inconnu ».
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `id` | string | yes | ≥ 1 char. | Identifiant de la charge — clé des actions. |
| `label` | string | yes | | Nom lisible. |
| `priority` | integer | yes | | Rang effectif (1 = premier servi). |
| `domain` | [`Domain`](#schema-domain) | no | | |
| `learned` | [`LoadLearned`](#schema-loadlearned) | no | | |
| `needs` | [`LoadNeeds`](#schema-loadneeds) | no | | |
| `adapter` | string | yes | `"evcharger"` | |
| `declared` | object | no | | |
| `declared.minA` | number | yes | | |
| `declared.maxA` | number | yes | | |
| `declared.phases` | integer | yes | | Phases VISÉES ce cycle, pas celles de la plaque (E26). |
| `telemetry` | object | yes | | |
| `telemetry.currentPowerW` | number | yes | | Puissance courante (W) telle que l'adaptateur la publie. |
| `telemetry.available` | boolean | yes | | Gouverne l'ALLOCATION, pas la COMPTABILITÉ (§5.3). |
| `telemetry.measurement` | [`Measurement`](#schema-measurement) | no | | |
| `telemetry.pluggedIn` | boolean | no | | |
| `telemetry.charging` | boolean | no | | |
| `telemetry.sessionWh` | number | no | | Absent si la borne ne compte pas sa session (LM-1009 C1). |
| `limits` | object | no | | |
| `limits.chargingEnabledLockS` | integer | yes | | |
| `limits.currentChangeLockS` | integer | yes | | |
Undeclared fields: **rejected** (engine-emitted form, closed).
#### `LoadSgReady` {#schema-loadsgready}
PAC SG-Ready : 4 états normés, kind state.
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `id` | string | yes | ≥ 1 char. | Identifiant de la charge — clé des actions. |
| `label` | string | yes | | Nom lisible. |
| `priority` | integer | yes | | Rang effectif (1 = premier servi). |
| `domain` | [`Domain`](#schema-domain) | no | | |
| `learned` | [`LoadLearned`](#schema-loadlearned) | no | | |
| `needs` | [`LoadNeeds`](#schema-loadneeds) | no | | |
| `adapter` | string | yes | `"sg-ready"` | |
| `declared` | object | yes | | |
| `declared.states` | [integer] | yes | ≥ 1 ; ≤ 4 | |
| `declared.estimatedPowerW` | map of number | yes | | |
| `telemetry` | object | yes | | |
| `telemetry.currentPowerW` | number | yes | | Puissance courante (W) telle que l'adaptateur la publie. |
| `telemetry.available` | boolean | yes | | Gouverne l'ALLOCATION, pas la COMPTABILITÉ (§5.3). |
| `telemetry.measurement` | [`Measurement`](#schema-measurement) | no | | |
| `telemetry.state` | integer | yes | ≥ 0 ; ≤ 4 | État commandé (1-4) ; 0 = non applicable. |
| `telemetry.minState` | integer | yes | | Fenêtre d'états autorisée MAINTENANT par minStateHold. |
| `telemetry.maxState` | integer | yes | | |
| `limits` | object | yes | | |
| `limits.minStateHoldS` | integer | yes | | |
Undeclared fields: **rejected** (engine-emitted form, closed).
#### `PvForecast` {#schema-pvforecast}
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `p50W` | [number \| null] | yes | | 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 | yes | `physics` | Seule valeur émise par le moteur GPL ; un consommateur ignore une valeur inconnue. |
| `corrections` | [[`Correction`](#schema-correction)] | yes | | [] = conversion brute. |
| `weatherModel` | string \| null | yes | | Modèle qui a RÉPONDU ; null si inconnu. |
| `issuedAt` | string \| null | yes | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
| `contentChangedAt` | string \| null | yes | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
| `acLimitW` | integer | no | | Plafond AC du site (W), s'il est configuré. |
| `perField` | [[`PvField`](#schema-pvfield)] | no | | |
Undeclared fields: **rejected** (engine-emitted form, closed).
#### `Baseload` {#schema-baseload}
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `model` | string | yes | `profile` · `none` | Le moteur GPL émet profile ou none (learned n'est pas émis). |
| `p50W` | [number \| null] | yes | | |
| `quality` | [`BaseloadQuality`](#schema-baseloadquality) | no | | |
Undeclared fields: **rejected** (engine-emitted form, closed).
#### `Price` {#schema-price}
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `currency` | string | yes | `"EUR"` | |
| `importCtkWh` | [number \| null] | yes | | |
| `exportCtkWh` | [number \| null] | yes | | |
| `includes` | [[`PriceInclude`](#schema-priceinclude)] | yes | | |
| `source` | string | yes | `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 | yes | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
Undeclared fields: **rejected** (engine-emitted form, closed).
#### `TariffEntry` {#schema-tariffentry}
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `label` | string | yes | | |
| `priceCtkWh` | number | yes | | |
Undeclared fields: **rejected** (engine-emitted form, closed).
#### `TariffNextEntry` {#schema-tariffnextentry}
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `label` | string | yes | | |
| `priceCtkWh` | number | yes | | |
| `from` | string \| null | yes | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
Undeclared fields: **rejected** (engine-emitted form, closed).
#### `LoadLearned` {#schema-loadlearned}
Absent tant qu'aucun profil n'a été reçu (E12).
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `dailyEnergyWh` | number | yes | | |
| `confidence` | number | yes | ≥ 0 ; ≤ 1 | |
Undeclared fields: **rejected** (engine-emitted form, closed).
#### `LoadNeeds` {#schema-loadneeds}
Besoins (§5.3). Champs 1.0 émis tels quels quand ils sont déclarés ; un optimiseur 1.1 préfère energyTargets.
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `dailyDeadline` | string | no | pattern `^[0-9]{2}:[0-9]{2}$` | Heure murale locale HH:MM (1.0). |
| `minEnergyWhPerDay` | integer | no | > 0 | |
| `targetSocPercent` | integer | no | > 0 | |
| `deadline` | string | no | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
| `energyTargets` | [[`EnergyTarget`](#schema-energytarget)] | no | ≥ 1 items | |
Undeclared fields: **rejected** (engine-emitted form, closed).
#### `Measurement` {#schema-measurement}
Mesure par charge (§5.3, LM-1105/1106) : décider sur source, jamais sur adapter.
**variant 1** — Rien ne mesure : état positif, pas une erreur.
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `source` | string | yes | `"none"` | |
Undeclared fields: **rejected** (engine-emitted form, closed).
**variant 2**
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `source` | string | yes | `meter` · `device` | |
| `powerW` | number | yes | | |
Undeclared fields: **rejected** (engine-emitted form, closed).
#### `PvField` {#schema-pvfield}
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `id` | string | yes | | ThingId du plan. |
| `corrections` | [[`Correction`](#schema-correction)] | yes | | |
Undeclared fields: **rejected** (engine-emitted form, closed).
#### `BaseloadQuality` {#schema-baseloadquality}
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `windowDays` | integer | yes | | |
| `minDays` | integer | yes | | |
| `resolutionS` | integer | yes | | |
| `daysCovered` | object | yes | | |
| `daysCovered.workday` | integer | yes | | |
| `daysCovered.weekend` | integer | yes | | |
| `minutes` | integer | yes | | |
| `commandedMinutes` | integer | yes | | |
| `negativeMinutes` | integer | yes | | |
| `incompleteMinutes` | integer | yes | | |
| `computedAt` | string | no | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
| `loads` | [[`BaseloadLoadQuality`](#schema-baseloadloadquality)] | yes | | |
Undeclared fields: **rejected** (engine-emitted form, closed).
#### `EnergyTarget` {#schema-energytarget}
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `by` | string | yes | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
| `minWh` | number | yes | > 0 | Énergie minimale à avoir délivrée à l'échéance. |
| `source` | string | yes | `installer` · `user-intent` | learned n'est pas émis en 1.1. |
Undeclared fields: **rejected** (engine-emitted form, closed).
#### `BaseloadLoadQuality` {#schema-baseloadloadquality}
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `loadId` | string | yes | | |
| `source` | string | yes | `meter` · `device` · `none` | |
| `minutesBySource` | map of integer | yes | keys `meter` · `device` · `none` | |
| `constantMeasurement` | boolean | no | | |
Undeclared fields: **rejected** (engine-emitted form, closed).
<!-- END GENERATED -->
## La réponse : un plan ou une abstention {#reponse}
L'optimiseur répond **vite** — avec son dernier plan en cache s'il calcule longtemps — et
recalcule en tâche de fond. Il répond soit :
- un **plan par créneaux** : chaque créneau porte une action par charge disponible
(consigne en watts ou en ampères, ou état SG-Ready), chacune avec son motif ; seul le
créneau en cours est exécuté, et `validUntil` borne la durée de vie du plan ;
- une **abstention** (`abstain: true`) : « pas de plan maintenant ». Le moteur pilote par
ses règles pour ce cycle, sans considérer l'optimiseur comme en panne.
Le motif d'une action a deux formes : un texte libre en français, **obligatoire**, pour
les traces ; et, facultatif, un code du catalogue (`reasonCode`) avec ses paramètres.
Vers l'application, **seul le code** est transmis, et l'application le traduit. Un code
inconnu part sous `OPTIMIZER_DECISION`.
La forme reçue par le moteur est **ouverte** : un champ inconnu est ignoré.
<!-- BEGIN GENERATED: __optimizer_plan__ -->
#### `GetPlan` — result
`plan`: [`GetPlanResult`](#schema-getplanresult)
**Errors**
| Code | Message | Note (contract) |
|---|---|---|
| `-32603` | erreur interne | Toute réponse error compte comme un échec de la requête (rpc-error, §7) ; le code n'est pas lu. |
#### `GetPlanResult` {#schema-getplanresult}
One of: [`Abstention`](#schema-abstention) · [`Plan`](#schema-plan).
#### `Abstention` {#schema-abstention}
« Pas de plan maintenant » (§7) : repli pour ce cycle, pas un échec.
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `abstain` | boolean | yes | `true` | |
| `reason` | string | no | | |
Undeclared fields: **ignored** (optimizer-emitted form, open).
#### `Plan` {#schema-plan}
Plan par créneaux (§6).
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `abstain` | boolean | no | `false` | |
| `planId` | string | no | | |
| `strategy` | string | no | | Chaîne libre, informative. |
| `computedAt` | string | no | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
| `validUntil` | string \| null | no | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
| `slots` | [[`Slot`](#schema-slot)] | yes | | [] = abstention (empty-plan). Seul le créneau couvrant maintenant est exécuté. |
| `diagnostics` | [`Diagnostics`](#schema-diagnostics) | no | | |
Undeclared fields: **ignored** (optimizer-emitted form, open).
#### `Slot` {#schema-slot}
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `from` | string | yes | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
| `to` | string | yes | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
| `actions` | [[`Action`](#schema-action)] \| null | no | | Absent ou null = aucune action. |
| `expected` | [`Expected`](#schema-expected) | no | | |
Undeclared fields: **ignored** (optimizer-emitted form, open).
#### `Diagnostics` {#schema-diagnostics}
Informatif : mode installateur et traces.
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `solver` | string | no | | |
| `status` | string | no | `optimal` · `feasible` · `best-so-far` · `infeasible-relaxed` | |
| `gapPercent` | number | no | | |
| `computeMs` | number | no | ≥ 0 | |
| `inputsUsed` | object | no | | |
| `inputsUsed.pv` | string | no | `physics` · `hybrid` · `none` | |
Undeclared fields: **ignored** (optimizer-emitted form, open).
#### `Action` {#schema-action}
One of: [`SetpointAction`](#schema-setpointaction) · [`StateAction`](#schema-stateaction) · [`ConstraintAction`](#schema-constraintaction).
#### `Expected` {#schema-expected}
Trajectoire prévue : affichage et mesure d'écart, JAMAIS pour commander.
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `gridImportW` | number | no | | |
| `gridExportW` | number | no | | |
| `batterySocPercent` | any | no | | RÉSERVÉ (annexe F) : ignoré. |
Undeclared fields: **ignored** (optimizer-emitted form, open).
#### `SetpointAction` {#schema-setpointaction}
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `loadId` | string | yes | ≥ 1 char. | Charge visée : connue du contexte (règle unknown-load), une fois par créneau (duplicate-load). |
| `reason` | string | yes | ≥ 1 char. ; pattern `\S` | Texte français, OBLIGATOIRE et non vide (règle missing-reason). Traces et journal seulement, jamais vers l'app. |
| `reasonCode` | [`ReasonCode`](#schema-reasoncode) | no | | |
| `reasonParams` | [`ReasonParams`](#schema-reasonparams) | no | | |
| `force` | any | no | | INTERDIT sur le fil (§6) : ignoré, signalé une fois. |
| `kind` | string | yes | `"setpoint"` | |
| `powerW` | number | no | ≥ 0 | Consigne en watts (relay-router, etmvariableload ; ou borne). |
| `currentA` | number | no | ≥ 0 | Borne : courant (0 = suspendre). |
| `phases` | enum | no | `1` · `3` | Borne : phases visées ; absent = inchangé. |
Undeclared fields: **ignored** (optimizer-emitted form, open).
At least one of: `powerW`, `currentA`.
#### `StateAction` {#schema-stateaction}
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `loadId` | string | yes | ≥ 1 char. | Charge visée : connue du contexte (règle unknown-load), une fois par créneau (duplicate-load). |
| `reason` | string | yes | ≥ 1 char. ; pattern `\S` | Texte français, OBLIGATOIRE et non vide (règle missing-reason). Traces et journal seulement, jamais vers l'app. |
| `reasonCode` | [`ReasonCode`](#schema-reasoncode) | no | | |
| `reasonParams` | [`ReasonParams`](#schema-reasonparams) | no | | |
| `force` | any | no | | INTERDIT sur le fil (§6) : ignoré, signalé une fois. |
| `kind` | string | yes | `"state"` | |
| `state` | integer | yes | ≥ 1 ; ≤ 4 | 1 = verrouillage, 2 = normal, 3 = recommandation, 4 = forcé. |
Undeclared fields: **ignored** (optimizer-emitted form, open).
#### `ConstraintAction` {#schema-constraintaction}
RÉSERVÉ (annexe F) : forme décrite, toujours refusée (kind-mismatch).
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `loadId` | string | yes | ≥ 1 char. | Charge visée : connue du contexte (règle unknown-load), une fois par créneau (duplicate-load). |
| `reason` | string | yes | ≥ 1 char. ; pattern `\S` | Texte français, OBLIGATOIRE et non vide (règle missing-reason). Traces et journal seulement, jamais vers l'app. |
| `reasonCode` | [`ReasonCode`](#schema-reasoncode) | no | | |
| `reasonParams` | [`ReasonParams`](#schema-reasonparams) | no | | |
| `force` | any | no | | INTERDIT sur le fil (§6) : ignoré, signalé une fois. |
| `kind` | string | yes | `"constraint"` | |
| `charge` | enum | no | `allow` · `forbid` | |
| `discharge` | enum | no | `allow` · `forbid` | |
Undeclared fields: **ignored** (optimizer-emitted form, open).
#### `ReasonParams` {#schema-reasonparams}
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).
Type: map of number \| boolean \| string \| null
<!-- END GENERATED -->
### Codes de motif émissibles {#codes-de-motif}
<!-- BEGIN GENERATED: __optimizer_reason_codes__ -->
| `reasonCode` | Accepted `reasonParams` keys |
|---|---|
| `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 -->
## `UpdateLearned` {#updatelearned}
Notification envoyée par l'optimiseur au moteur, sans réponse, pour transmettre ce qu'il a
appris des charges. Dans la version 1.1 du moteur, elle est reçue et journalisée, mais
pas encore exploitée.
<!-- BEGIN GENERATED: __optimizer_update_learned__ -->
#### `UpdateLearned` — params
Estimations apprises de l'optimiseur (§8) — notification, sans id.
Envoyée par l'OPTIMISEUR (sens inverse des deux autres), sans id, sans réponse. État 1.1 du moteur : reçue, journalisée une fois, IGNORÉE — rien n'est persisté, le contenu n'est pas lu. Forme non normée en 1.1.
| Field | Type | Required | Values | Description (contract, French) |
|---|---|---|---|---|
| `loads` | [object] | no | | Non normé en 1.1 : intention §8 (learned + confidence par charge). |
<!-- END GENERATED -->
## Machine de repli {#repli}
Il n'y a pas de battement de cœur séparé : une réponse valide à `GetPlan` signifie que
l'optimiseur est vivant. Les échecs se comptent par requête ; après plusieurs échecs
consécutifs, l'optimiseur est déclaré absent et le moteur pilote par ses règles jusqu'au
prochain plan valide. L'application voit cet état en continu (`optimizerAlive`,
`activeStrategy`).
<!-- BEGIN GENERATED: __optimizer_fallback__ -->
Machine de repli §7 (OptimizerStatus) : heartbeat implicite — une réponse valide à GetPlan = optimiseur vivant. Les échecs se comptent PAR REQUÊTE.
Consecutive failed requests before `optimizerAlive = false`: **2**.
| Situation (contract) | `optimizerAlive` | `activeStrategy` |
|---|---|---|
| réponse plan valide | `true` | `optimizer` |
| abstain, plan vide, aucun créneau courant | `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é / handshake refusé | `false` | `rules` |
| aucun endpoint configuré | `false` | `rules` |
| mode dégradé L2 (compteur muet > 90 s) | `inchangé` | `none` |
<!-- END GENERATED -->
## Règles de validation {#regles}
Le JSON Schema du contrat dit la forme. Le moteur juge ensuite chaque réponse sur le
contexte de **sa** requête, règle par règle, dans l'ordre ci-dessous ; la première qui
échoue donne son code (`fallbackCode`, visible dans l'application).
<!-- BEGIN GENERATED: __optimizer_rules__ -->
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).
| # | Rule | Method | Outcome | `fallbackCode` | `lastErrorCode` | Counts as failure | Also needs | Schema catches it too | What is rejected (contract) |
|---|---|---|---|---|---|---|---|---|---|
| 1 | `abstain` | `GetPlan` | `abstention` | `abstain` | — | no | — | no | result.abstain vaut true. |
| 2 | `malformed-plan` | `GetPlan` | `invalid` | `malformed-plan` | `malformed-plan` | yes | — | yes | slots absent ou non liste ; un créneau qui n'est pas un objet ; actions non liste. |
| 3 | `empty-plan` | `GetPlan` | `abstention` | `empty-plan` | — | no | — | no | slots est une liste vide. |
| 4 | `bad-time` | `GetPlan` | `invalid` | `bad-time` | `bad-time` | yes | — | yes | from/to illisibles ou sans décalage, ou from ≥ to ; validUntil illisible ou sans décalage. |
| 5 | `slots-unordered` | `GetPlan` | `invalid` | `slots-unordered` | `slots-unordered` | yes | — | no | un créneau commence avant la fin du précédent. |
| 6 | `malformed-action` | `GetPlan` | `invalid` | `malformed-action` | `malformed-action` | yes | — | yes | action qui n'est pas un objet, ou sans loadId (chaîne non vide). |
| 7 | `unknown-load` | `GetPlan` | `invalid` | `unknown-load` | `unknown-load` | yes | `request` | no | loadId absent des loads[] de la requête. |
| 8 | `unknown-kind` | `GetPlan` | `invalid` | `unknown-kind` | `unknown-kind` | yes | — | yes | kind hors ActionKind. |
| 9 | `kind-mismatch` | `GetPlan` | `invalid` | `kind-mismatch` | `kind-mismatch` | yes | `request` | no | kind différent de celui du mécanisme de la charge (setpoint : evcharger, relay-router, etmvariableload ; state : sg-ready ; constraint : aucun). |
| 10 | `kind-not-announced` | `GetPlan` | `invalid` | `kind-not-announced` | `kind-not-announced` | yes | `handshake` | no | capabilities.kinds annoncé au handshake et kind absent de la liste. |
| 11 | `missing-reason` | `GetPlan` | `invalid` | `missing-reason` | `missing-reason` | yes | — | yes | reason absent, non textuel, ou vide une fois les caractères de contrôle retirés. |
| 12 | `bad-value` | `GetPlan` | `invalid` | `bad-value` | `bad-value` | yes | `request` | yes | 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. |
| 13 | `duplicate-load` | `GetPlan` | `invalid` | `duplicate-load` | `duplicate-load` | yes | — | no | deux actions pour la même charge dans un créneau. |
| 14 | `expired` | `GetPlan` | `expired` | `expired` | — | no | `request` | no | validUntil ≤ timestamp de la requête. |
| 15 | `no-current-slot` | `GetPlan` | `abstention` | `no-current-slot` | — | no | `request` | no | aucun créneau ne couvre le timestamp de la requête. |
| 16 | `incomplete-slot` | `GetPlan` | `invalid` | `incomplete-slot` | `incomplete-slot` | yes | `request` | no | une charge available de la requête n'a pas d'action dans le créneau courant (décision Q1). |
| 17 | `handshake-refused` | `Handshake` | `connection-refused` | `no-plan` | `handshake-refused` | no | — | no | réponse error (token absent ou différent : -32001 recommandé) → connexion fermée, nouvelle tentative au backoff. |
| 18 | `invalid-handshake` | `Handshake` | `connection-refused` | `no-plan` | `invalid-handshake` | no | — | yes | result non objet, protocolVersion absent ou vide, capabilities présent et non objet. |
| 19 | `major-mismatch` | `Handshake` | `connection-refused` | `no-plan` | `major-mismatch` | no | — | no | MAJEURE de protocolVersion (ce qui précède le premier point) différente de celle du moteur. |
<!-- END GENERATED -->
### Tolérances {#tolerances}
Ce que le moteur accepte au-delà du contrat. Un optimiseur conforme ne s'en sert pas.
<!-- BEGIN GENERATED: __optimizer_tolerances__ -->
Ce que le moteur ACCEPTE au-delà du schéma — un optimiseur conforme ne s'en sert pas.
- reasonCode absent ou hors ReasonCode : action acceptée, motif transmis sous OPTIMIZER_DECISION sans paramètres.
- reasonParams : clé non admise pour le code (x-etm-reason-params) ou valeur refusée : clé supprimée, action acceptée.
- planId, strategy : lus comme chaînes quel que soit leur type ; computedAt illisible : ignoré.
- diagnostics, expected : non objets ignorés ; valeurs inconnues filtrées avant l'app.
- abstain : toute valeur « vraie » au sens de QVariant::toBool (true, chaîne non vide autre que « false »/« 0 », nombre non nul).
- champs inconnus (params et result) : ignorés (§9) ; force et champs réservés (annexe F) : ignorés et signalés une fois.
- state : 3.0 accepté comme 3 (entier au sens JSON Schema).
<!-- END GENERATED -->
## Listes fermées {#listes-fermees}
Valeurs permises, régénérées depuis le code du moteur.
<!-- BEGIN GENERATED: __optimizer_closed_lists__ -->
#### `ProtocolVersion` {#schema-protocolversion}
Version du protocole parlée par le moteur (GEN : OptimizerProtocol::ProtocolVersion).
`1.1`
Regenerated from the engine code: `OptimizerProtocol::ProtocolVersion`.
#### `Trigger` {#schema-trigger}
Cause de la requête (§5.1). GEN : SocketScheduler::knownTriggers() — du moins au plus significatif.
`cycle` · `forecast` · `config` · `load-change` · `intent`
Regenerated from the engine code: `SocketScheduler::knownTriggers()`.
#### `Domain` {#schema-domain}
Intention déclarée par l'installateur, jamais déduite du mécanisme, non lue par l'arbitre (§5.3). GEN : LoadConfig::knownDomains().
`ecs` · `ev` · `heating` · `hvac` · `battery`
Regenerated from the engine code: `LoadConfig::knownDomains()`.
#### `ActionKind` {#schema-actionkind}
Type d'action (§6). GEN : OptimizerPlan::kinds(). `constraint` est RÉSERVÉ (annexe F) : reconnu par le validateur, toujours refusé (règle kind-mismatch) faute de mécanisme porteur.
`setpoint` · `state` · `constraint`
Regenerated from the engine code: `OptimizerPlan::kinds()`.
#### `ReasonCode` {#schema-reasoncode}
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.
`SURPLUS_SETPOINT` · `SURPLUS_INSUFFICIENT` · `BELOW_MIN_POWER` · `EV_DEADLINE` · `SG_FORCED` · `SG_RECOMMENDED` · `SG_NORMAL` · `ECO_FLOOR_GRID` · `PRICE_SLOT` · `OPTIMIZER_DECISION`
Regenerated from the engine code: `DecisionCode::optimizerEmittableCodes()`.
#### `PriceInclude` {#schema-priceinclude}
Composante incluse dans le prix (§5.2). GEN : TariffConfig::includes().
`energy` · `grid` · `taxes`
Regenerated from the engine code: `TariffConfig::includes()`.
#### `FallbackCode` {#schema-fallbackcode}
Côté app (NymeaEnergy.GetPlan.fallbackCode, INTERFACE.md) : pourquoi l'optimiseur n'a pas commandé le cycle. Hors fil. GEN : SocketScheduler::knownFallbackCodes().
`no-plan` · `abstain` · `empty-plan` · `no-current-slot` · `expired` · `malformed-plan` · `bad-time` · `slots-unordered` · `malformed-action` · `unknown-load` · `unknown-kind` · `kind-mismatch` · `kind-not-announced` · `missing-reason` · `bad-value` · `duplicate-load` · `incomplete-slot` · `no-shadow-slot`
Regenerated from the engine code: `SocketScheduler::knownFallbackCodes()`.
#### `LastErrorCode` {#schema-lasterrorcode}
Côté app (NymeaEnergy.GetOptimizerStatus.lastErrorCode, INTERFACE.md) : dernier code d'erreur vu (connexion, requête, plan). Hors fil. GEN : SocketScheduler::knownLastErrorCodes().
`invalid-endpoint` · `token-too-short` · `connect-failed` · `closed-during-handshake` · `connection-lost` · `handshake-timeout` · `handshake-refused` · `invalid-handshake` · `major-mismatch` · `line-too-long` · `invalid-json` · `missing-id` · `unknown-id` · `timeout` · `rpc-error` · `invalid-response` · `disconnected` · `malformed-plan` · `bad-time` · `slots-unordered` · `malformed-action` · `unknown-load` · `unknown-kind` · `kind-mismatch` · `kind-not-announced` · `missing-reason` · `bad-value` · `duplicate-load` · `incomplete-slot`
Regenerated from the engine code: `SocketScheduler::knownLastErrorCodes()`.
#### `Correction` {#schema-correction}
`temperature` · `ac-limit`
<!-- END GENERATED -->

View File

@ -1,3 +1,11 @@
# Referenz
# API
> Stub — technische Referenz: Konfigurationsparameter, Modi, Glossar.
Die Box lässt sich über zwei lokale Schnittstellen in Ihrem Netz anbinden:
- die [JSON-RPC-API](integrations/jsonrpc-api.md) — Verbindung, Authentifizierung,
Konventionen — und ihre [vollständige Referenz](api/index.md), aus der Box erzeugt;
- [MQTT](integrations/mqtt.md).
!!! note "Und das Optimierer-Protokoll?"
Der Kanal zwischen Engine und Optimierer ist **keine** Integrations-API: Er hat ein
eigenes Protokoll, beschrieben im Abschnitt [Optimierer](optimiseur/protocole.md).

View File

@ -1,3 +1,11 @@
# Reference
# API
> Stub — technical reference: configuration parameters, modes, glossary.
The box integrates with your tools through two local interfaces, on your network:
- the [JSON-RPC API](integrations/jsonrpc-api.md) — connection, authentication,
conventions — and its [full reference](api/index.md), generated from the box;
- [MQTT](integrations/mqtt.md).
!!! note "What about the optimiser protocol?"
The channel between the engine and an optimiser is **not** an integration API: it
has its own protocol, described in the [Optimizer](optimiseur/protocole.md) section.

View File

@ -1,3 +1,11 @@
# Référence
# API
> Stub — référence technique : paramètres de configuration, modes, glossaire.
La box s'intègre à vos outils par deux interfaces locales, sur votre réseau :
- l'[API JSON-RPC](integrations/jsonrpc-api.md) — connexion, authentification,
conventions — et sa [référence complète](api/index.md), générée depuis la box ;
- [MQTT](integrations/mqtt.md).
!!! note "Et le protocole optimiseur ?"
Le canal entre le moteur et un optimiseur n'est **pas** une API d'intégration : il a
son propre protocole, décrit dans la section [Optimiseur](optimiseur/protocole.md).

View File

@ -85,8 +85,13 @@ plugins:
Accueil: Startseite
Installation: Installation
Appareils: Geräte
Fonctionnalités: Funktionen
Référence: Referenz
EMS — moteur à règles: EMS — Regelbasierte Engine
Comment le moteur décide: Wie die Engine entscheidet
Optimiseur: Optimierer
Protocole: Protokoll
Contrat: Vertrag
Écrire un optimiseur: Einen Optimierer schreiben
API: API
Aide: Hilfe
Compatibilité: Kompatibilität
Dépôt APT: APT-Repository
@ -123,8 +128,13 @@ plugins:
Accueil: Home
Installation: Installation
Appareils: Devices
Fonctionnalités: Features
Référence: Reference
EMS — moteur à règles: EMS — rule engine
Comment le moteur décide: How the engine decides
Optimiseur: Optimizer
Protocole: Protocol
Contrat: Contract
Écrire un optimiseur: Writing an optimizer
API: API
Aide: Help
Compatibilité: Compatibility
Dépôt APT: APT repository
@ -164,14 +174,22 @@ nav:
- Configuration: installation/configuration.md
- L'application: installation/application.md
- Appareils: appareils/
- Fonctionnalités:
# Partie technique : trois sections qui suivent l'architecture (2026-10-18).
# Les fichiers n'ont pas bougé : seules les étiquettes changent, les URL restent.
- EMS — moteur à règles:
- fonctionnalites/index.md
- Comment le moteur décide: fonctionnalites/moteur.md
- Surplus solaire: fonctionnalites/surplus-solaire.md
- Délestage: fonctionnalites/delestage.md
- Gestion batterie: fonctionnalites/gestion-batterie.md
- Prévision PV: fonctionnalites/prevision-pv.md
- Tarifs dynamiques: tarifs.md
- Référence:
- Optimiseur:
- optimiseur/index.md
- Protocole: optimiseur/protocole.md
- Contrat: optimiseur/contrat.md
- Écrire un optimiseur: optimiseur/ecrire.md
- API:
- reference.md
- API JsonRPC: integrations/jsonrpc-api.md
- Référence API: api/