docs(optimiseur): versions allemande et anglaise de la section et de « Comment le moteur décide »

Prose traduite pour Présentation, Protocole, Contrat, Écrire un
optimiseur et la page du moteur ; ancres de titres identiques dans les
trois langues. Les blocs générés sont identiques d'une langue à l'autre.
Les exemples gardent un motif en français, comme l'exige le contrat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-18 13:09:09 +02:00
parent 209e2d441b
commit 41d59de971
11 changed files with 2328 additions and 1 deletions

View File

@ -0,0 +1,87 @@
# Wie die Engine entscheidet
<span class="badge community">COMMUNITY</span>
Die Energie-Engine der Box steuert Ihre Verbraucher selbstständig: Wallboxen,
Warmwasserbereiter, SG-Ready-Wärmepumpe, Relais. Sie ist frei (GPL-3.0) und entscheidet in
der **Community**-Version. Diese Seite beschreibt ihre Logik für Installateure und
Integratoren; die folgenden Seiten erläutern die einzelnen Funktionen.
## Zwei Schleifen, zwei Prioritäten
Die Engine trennt Sicherheit und Verteilung. Es sind zwei getrennte Schleifen, mit
unterschiedlichem Takt und unterschiedlicher Priorität.
- **Die Sicherheitsschleife, in Echtzeit.** Bei jeder Änderung der gemessenen Bilanz liest
sie den Strom jeder Phase am Netzanschlusszähler. Überschreitet eine Phase die
eingestellte Grenze, regelt sie die Verbraucher sofort ab ([Lastabwurf](delestage.md))
und gibt wieder frei, sobald genug Reserve da ist. Sie wartet nicht auf den Zyklus und
hat Vorrang vor allem anderen.
- **Der Verteilungszyklus, etwa einmal pro Minute.** Er verteilt die verfügbare Leistung
auf die Verbraucher. Nur hier greift eine Strategie ein: die eingebauten Regeln oder,
falls angeschlossen, ein [Optimierer](../optimiseur/index.md).
Antwortet der Netzanschlusszähler nicht mehr, setzt die Engine die Verteilung aus und geht
in den Sicherheits-Rückfall: ohne Messung keine Entscheidung.
## Die Regeln, in ihrer Reihenfolge
In jedem Zyklus durchläuft die Engine dieselben Schritte, in derselben Reihenfolge.
1. **Messen.** Ausgangspunkt ist der Messwert des Netzanschlusszählers. Die Engine rechnet
hinzu, was die von ihr gesteuerten Verbraucher bereits aufnehmen: Ein laufender
Verbraucher wird nicht gegen sich selbst gerechnet.
2. **Berücksichtigen, was festliegt.** Ein gesperrter Verbraucher (Mindestlauf- oder
Mindeststillstandszeit, Wartezeit zwischen zwei Stromänderungen) behält seine
zugesagte Leistung, und sie wird vom Budget abgezogen. Ein nicht verfügbarer
Verbraucher erhält keinen Sollwert, sein Verbrauch wird aber weiter gezählt.
3. **Bedarfe mit Frist bedienen.** Eine tägliche Pflicht (etwa eine Mindestenergie für den
Warmwasserbereiter bis zu einer bestimmten Uhrzeit) oder eine einmalige Anforderung aus
der App kann, wenn die Frist naht und der Überschuss nicht gereicht hat, den Rest aus
dem Netz beziehen — sofern der Installateur das erlaubt hat.
4. **Den Überschuss kaskadiert verteilen.** Die Verbraucher werden in der von Ihnen
festgelegten **Priorität** bedient (1 = zuerst). Jeder erhält, was vom Budget übrig
ist, innerhalb seiner Grenzen, und das Budget wird entsprechend verringert. Es gibt
**ein einziges** Budget: Ohne es sähen drei Manager jeweils „2 kW Überschuss“ und
zögen zusammen 6.
5. **Abschalten, was nicht starten kann.** Unterhalb seiner Mindestleistung bleibt ein
Verbraucher aus, statt halb zu laufen.
### Die Grenzen jedes Verbrauchers
Jeder Verbraucher hat vom Installateur eingetragene Grenzen, die **jede** Entscheidung
einhält, gleich welche Strategie:
- eine **Unter-** und eine **Obergrenze** der Leistung (bzw. des Stroms bei einer Wallbox);
- **Sperren gegen Takten**: Mindestlauf- und Mindeststillstandszeit, Wartezeit zwischen
zwei Änderungen;
- bei einer SG-Ready-Wärmepumpe: der Zustand wird nach der geschätzten Leistung jedes
Zustands gewählt, mit einer Mindesthaltedauer.
## Der Grund jeder Entscheidung
Die Engine trifft keine Entscheidung, ohne ihren Grund zu nennen. Jede Entscheidung trägt
einen nicht leeren **Grund** (`decisionReason`): einen stabilen Code mit Parametern, den
die App in Ihrer Sprache anzeigt — „Überschuss reicht nicht“, „unter der
Mindestleistung“, „Ladefrist“… Die Liste der Codes ist mit dem
[Optimierer-Protokoll](../optimiseur/protocole.md#reponse) veröffentlicht.
## Die Messung, nie das Ergebnis der Steuerung
Der Überschuss wird aus der **Rohmessung** berechnet: Erzeugung und Zähler, so wie sie
gemessen werden. Nie aus der Nettobilanz nach der Steuerung: Die Engine würde sich selbst
lesen und schwingen.
## Ohne Internet
All das läuft auf der Box. Ohne Internet steuert die Engine, wendet die Sicherheit an und
antwortet der App in Ihrem lokalen Netz. Nur die [PV-Prognose](prevision-pv.md) setzt
aus — und sie dient in der Community-Version nicht zur Entscheidung, ebenso wenig wie der
[eingegebene Tarif](../tarifs.md): Beide werden angezeigt und gespeichert, aber von den
Regeln nie verwendet.
## Und ein Optimierer?
Ein Optimierer kann anhand der Prognosen einen Plan über mehrere Stunden vorschlagen. Er
ersetzt den Verteilungsschritt, **nie** die Sicherheitsschleife oder die Grenzen. Siehe
Abschnitt [Optimierer](../optimiseur/index.md).

View File

@ -0,0 +1,82 @@
# How the engine decides
<span class="badge community">COMMUNITY</span>
The box's energy engine drives your loads on its own: EV chargers, water heater,
SG-Ready heat pump, relays. It is free software (GPL-3.0) and it is what decides in the
**Community** edition. This page describes its logic for installers and integrators; the
following pages detail each feature.
## Two loops, two priorities
The engine keeps safety apart from allocation. They are two separate loops, running at
different rates and with different priorities.
- **The safety loop, in real time.** On every change in the measured balance, it reads the
current of each phase at the grid meter. If a phase exceeds the configured limit, it
cuts loads back immediately ([load shedding](delestage.md)), then releases them once
there is headroom again. It does not wait for the cycle and overrides everything else.
- **The allocation cycle, about once a minute.** It shares the available power between
the loads. This is the only place a strategy acts: the built-in rules, or an
[optimiser](../optimiseur/index.md) if one is connected.
If the grid meter stops answering, the engine suspends allocation and falls back to safety
mode: no measurement, no decision.
## The rules, in order
On every cycle, the engine runs the same steps, in the same order.
1. **Measure.** The starting point is what the grid meter measures. The engine adds back
what the loads it drives are already drawing: a running load is not counted against
itself.
2. **Account for what is locked.** A load under a lock (minimum on or off time, delay
between two current changes) keeps the power it has committed, and that power is taken
out of the budget. An unavailable load receives no setpoint, but what it draws is still
counted.
3. **Serve needs with a deadline.** A daily obligation (for example a minimum energy for
the water heater before a given time) or a one-off request made from the app may, as
the deadline nears and the surplus has not been enough, take the remainder from the
grid — if the installer has allowed it.
4. **Cascade the surplus.** Loads are served in the **priority** order you set (1 = served
first). Each receives what is left of the budget, within its bounds, and the budget is
reduced accordingly. There is **one** budget: without it, three managers would each see
"2 kW of surplus" and draw 6 between them.
5. **Stop what cannot start.** Below its minimum power, a load stays off rather than
running at half.
### The bounds of each load
Each load carries bounds entered by the installer, which **every** decision respects,
whatever the strategy:
- a power **floor** and **ceiling** (or current, for an EV charger);
- **anti-flapping locks**: minimum on and off time, delay between two changes;
- for an SG-Ready heat pump: the state is chosen from the estimated power of each state,
with a minimum hold time.
## The reason for every decision
The engine takes no decision without stating why. Every decision carries a non-empty
**reason** (`decisionReason`): a stable code and its parameters, which the app displays in
your language — "surplus insufficient", "below minimum power", "charging deadline"… The
list of codes is published with the [optimiser protocol](../optimiseur/protocole.md#reponse).
## The measurement, never the result of control
Surplus is computed from the **raw measurement**: production and meter, as measured. Never
from the net balance after control: the engine would be reading itself, and oscillate.
## Without internet
All of the above runs on the box. Without internet, the engine keeps driving the loads,
enforces safety and answers the app on your local network. Only the
[PV forecast](prevision-pv.md) stops — and it is not used for decisions in the Community
edition, any more than the [entered tariff](../tarifs.md): both are displayed and
recorded, never used by the rules.
## What about an optimiser?
An optimiser can propose a plan over several hours, based on the forecasts. It replaces
the allocation step, **never** the safety loop or the bounds. See the
[Optimizer](../optimiseur/index.md) section.

View File

@ -0,0 +1,55 @@
# Vertrag
Der **maschinenlesbare Vertrag** beschreibt die Form jeder Nachricht des
[Protokolls](protocole.md): ein [OpenRPC](https://spec.open-rpc.org/)-Dokument, dessen
Nachrichten in JSON Schema beschrieben sind, ergänzt um `x-etm-…`-Erweiterungen für das,
was OpenRPC nicht ausdrücken kann — Transport, Rückfall-Automat, benannte Prüfregeln,
Toleranzen, geschlossene Listen.
Die Engine bringt ihn mit, und ihre Tests wie die Konformitäts-Testsuite prüfen ihn auf
beiden Seiten. Die Tabellen der Seite [Protokoll](protocole.md) werden daraus erzeugt.
## Den Vertrag herunterladen {#telecharger}
[:material-download: `etm-powersync-optimizer-protocol.openrpc.json` herunterladen](etm-powersync-optimizer-protocol.openrpc.json){ .md-button }
Die Website liefert die Datei **unverändert**, Byte für Byte, aus dem Standard-Branch des
Engine-Repositorys. Ihr Steckbrief:
<!-- 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 -->
## Der Vertrag Ihrer Box: `NymeaEnergy.GetOptimizerProtocol` {#getoptimizerprotocol}
Der hier veröffentlichte Vertrag folgt der Entwicklung der Engine; Ihre Box kann eine
ältere Version tragen. Um **den** Vertrag der installierten Engine zu erfahren, fragen Sie
die Box: Die JSON-RPC-Methode `NymeaEnergy.GetOptimizerProtocol` liefert den, den sie
mitbringt. Dieser ist für einen an diese Box angebundenen Optimierer maßgeblich.
Die Methode ist in
[`INTERFACE.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/INTERFACE.md)
im Engine-Repository beschrieben, zusammen mit den übrigen `NymeaEnergy.*`-Methoden des
Optimierers (Verbindungspunkt, gemeinsames Geheimnis, Zustand). Die Verbindung zur API
beschreibt die Seite [JSON-RPC-API](../integrations/jsonrpc-api.md).
## Nachrichten prüfen {#valider}
Das Engine-Repository enthält einen Validator, `tools/etm-powersync-schema-check` (Python,
Standardbibliothek): Er prüft eine Nachricht, eine vollständig aufgezeichnete Sitzung oder
Aufzeichnungen gegen den Vertrag und wendet auf Wunsch die benannten Prüfregeln an.

View File

@ -0,0 +1,54 @@
# Contract
The **machine-readable contract** describes the shape of every message of the
[protocol](protocole.md): an [OpenRPC](https://spec.open-rpc.org/) document whose messages
are written in JSON Schema, with `x-etm-…` extensions for what OpenRPC cannot express —
transport, fallback state machine, named validation rules, tolerances, closed lists.
The engine embeds it, and both its own tests and the conformance suite check it on both
sides. The tables on the [Protocol](protocole.md) page are generated from it.
## Download the contract {#telecharger}
[:material-download: Download `etm-powersync-optimizer-protocol.openrpc.json`](etm-powersync-optimizer-protocol.openrpc.json){ .md-button }
The site serves the file **as is**, byte for byte, from the default branch of the engine
repository. Its record:
<!-- 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 -->
## Your box's contract: `NymeaEnergy.GetOptimizerProtocol` {#getoptimizerprotocol}
The contract published here follows the engine's development; your box may carry an
earlier version. To get **the** contract of the installed engine, ask the box: the
JSON-RPC method `NymeaEnergy.GetOptimizerProtocol` returns the one it embeds. That is the
one that is authoritative for an optimiser connected to that box.
The method is described in
[`INTERFACE.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/INTERFACE.md),
in the engine repository, along with the other optimiser `NymeaEnergy.*` methods
(endpoint, shared secret, status). Connecting to the API is described on the
[JSON-RPC API](../integrations/jsonrpc-api.md) page.
## Validating messages {#valider}
The engine repository provides a validator, `tools/etm-powersync-schema-check` (Python,
standard library): it checks a single message, a complete recorded session or traces
against the contract, and applies the named validation rules on request.

View File

@ -0,0 +1,102 @@
# Einen Optimierer schreiben
Ein Optimierer ist ein **Server**, der das [Protokoll](protocole.md) spricht: Er antwortet
auf den Handshake und dann auf jedes `GetPlan` mit einem Plan aus einem oder mehreren
Zeitfenstern oder mit einer Enthaltung. Die Sprache ist frei. Ein minimaler Optimierer
passt in rund fünfzig Zeilen Python.
Minimale Sitzung, zur Veranschaulichung (die genaue Form steht im [Vertrag](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":"mein-optimierer","planning":false}}
→ {"jsonrpc":"2.0","id":2,"method":"GetPlan","params":{ …Kontext… }}
← {"jsonrpc":"2.0","id":2,"result":{"abstain":true,"reason":"premier calcul en cours"}}
```
Drei Punkte, die man nicht übersehen sollte:
- **eine Aktion je verfügbarem Verbraucher** im aktuellen Zeitfenster, jede mit ihrem
**Grund** als Text (auf Französisch): sonst wird der ganze Plan abgelehnt;
- **schnell antworten**: den zuletzt zwischengespeicherten Plan liefern oder sich
enthalten, und im Hintergrund rechnen;
- **nichts anderswo holen**: Alles, was der Optimierer braucht, Prognosen inbegriffen,
steht im Kontext.
## Der Referenz-Optimierer {#reference}
Das Engine-Repository enthält einen freien Referenz-Optimierer (GPL-3.0) in Python, nur
mit der Standardbibliothek:
[`tools/etm-powersync-ref-optimizer/`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/tree/main/tools/etm-powersync-ref-optimizer).
Er antwortet mit einem Plan aus einem Zeitfenster, der die Kaskade der Engine-Regeln
nachbildet, und liest dabei nur den Kontext. Beim Handshake meldet er sich als
`powersync-optimizer`.
Er ist Ausgangspunkt für einen eigenen Optimierer und Vergleichsmaßstab: Er lauscht
standardmäßig am lokalen Socket oder an `tcp://` mit einem gemeinsamen Geheimnis, das aus
einer Datei gelesen wird. Er kann auch Fehler einspeisen (Enthaltung, Langsamkeit,
ungültiger Plan, andere Hauptversion…), um den Rückfall der Engine zu testen.
Anleitung: [seine `README.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/tools/etm-powersync-ref-optimizer/README.md).
## Die Engine an den eigenen Optimierer anbinden {#brancher}
Drei JSON-RPC-Methoden der Box, dem Administrator vorbehalten, beschrieben in
[`INTERFACE.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/INTERFACE.md):
- `NymeaEnergy.SetOptimizerEndpoint` — wohin verbinden (`unix:///…` oder `tcp://host:port`);
- `NymeaEnergy.SetOptimizerToken` — das gemeinsame Geheimnis, dasselbe wie beim Optimierer;
- `NymeaEnergy.GetOptimizerStatus` — Zustand der Verbindung: Optimierer lebt, aktive
Strategie, gemeldeter Name, letzter Fehlercode.
Über `tcp://` ohne Verschlüsselung läuft das Geheimnis im Klartext durch das lokale Netz.
## Die Konformitäts-Testsuite {#conformite}
Die Suite `etm-powersync-optimizer-conformance` startet **Ihren** Optimierer und schließt
die **echte** Engine daran an, mit simulierten Geräten. Sie prüft, eine Funktion je Punkt:
1. der Handshake wird angenommen;
2. jedes `GetPlan` wird innerhalb der Frist der Engine beantwortet;
3. die gesamte Sitzung, in beide Richtungen, entspricht dem von der Engine mitgebrachten
Vertrag, Prüfregeln inbegriffen;
4. jeder Plan wird angewendet: Der Optimierer steuert;
5. die Sicherheitsschleife bleibt obenauf: Ein Sollwert außerhalb der Grenzen wird
gekappt, eine Phasenüberlast wird abgeworfen.
Die **Qualität** der Pläne bewertet sie nicht.
### Gegen den eigenen Optimierer ausführen
Die Engine mit ihren Tests bauen, dann den Befehl angeben, der Ihren Server startet;
`{endpoint}` wird darin durch die Adresse ersetzt, an der gelauscht werden soll:
```sh
export ETM_POWERSYNC_OPTIMIZER_CMD='exec /pfad/zu/mein-optimierer --listen {endpoint}'
export ETM_POWERSYNC_CONFORMANCE_EXPECT=plan # oder abstain
```
Jede Funktion läuft in einem eigenen Prozess. Der vollständige Befehl (Netzisolierung
empfohlen), die übrigen Variablen (gemeinsames Geheimnis, Startfrist…) und das Lesen der
Fehlschläge stehen in der
[`README.md` der Suite](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/tests/auto/conformance/README.md).
### Erwarteter Modus: `plan` oder `abstain` {#mode-attendu}
`ETM_POWERSYNC_CONFORMANCE_EXPECT` sagt der Suite, was Ihr Optimierer tut:
- **`plan`** (Standard): Er steuert. Jeder Zyklus muss seinen Plan anwenden.
- **`abstain`**: Er enthält sich in jedem Zyklus — das ist erlaubt und konform. Die Suite
prüft dann, dass die Engine in jedem Zyklus nach ihren Regeln steuert, ohne Fehler zu
zählen, und dass die Sicherheit weiter greift; die Prüfung der Kappung entfällt, da es
keinen Sollwert zu kappen gibt.
Jeder andere Wert lässt die Suite scheitern. **Der Modus wird nicht aus dem Handshake
abgeleitet**: `planning` und `capabilities` beschreiben die Form der Pläne, nicht, ob
welche erzeugt werden. Der Modus wird angegeben, nicht erraten.
## Auf denselben Eingaben vergleichen {#traces}
Die Engine kann auf der Box jeden Zyklus aufzeichnen (standardmäßig aus,
`NymeaEnergy.SetOptimizerTrace`). Das Werkzeug `tools/etm-powersync-trace-replay` spielt
diese Aufzeichnungen einem Kandidaten-Optimierer vor und fasst die Abweichungen zum
aufgezeichneten Plan zusammen. Die Aufzeichnungen bleiben vor Ort.

View File

@ -0,0 +1,99 @@
# Writing an optimizer
An optimiser is a **server** that speaks the [protocol](protocole.md): it answers the
handshake, then every `GetPlan` with a plan of one or more slots, or with an abstention.
Any language will do. A minimal optimiser fits in about fifty lines of Python.
Minimal session, for illustration (the exact shape is in the [contract](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":"my-optimiser","planning":false}}
→ {"jsonrpc":"2.0","id":2,"method":"GetPlan","params":{ …context… }}
← {"jsonrpc":"2.0","id":2,"result":{"abstain":true,"reason":"premier calcul en cours"}}
```
Three things not to miss:
- **one action per available load** in the current slot, each with its **reason** as
text: otherwise the whole plan is rejected (the reason text is in French);
- **answer quickly**: serve the last cached plan, or abstain, and compute in the
background;
- **fetch nothing elsewhere**: everything the optimiser needs, forecasts included, is in
the context.
## The reference optimiser {#reference}
The engine repository contains a free reference optimiser (GPL-3.0), in Python, standard
library only:
[`tools/etm-powersync-ref-optimizer/`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/tree/main/tools/etm-powersync-ref-optimizer).
It answers a one-slot plan that reproduces the cascade of the engine's rules, reading only
the context. At handshake it announces itself as `powersync-optimizer`.
It is a starting point for writing your own, and a point of comparison: it listens on the
local socket by default, or on `tcp://` with a shared secret read from a file. It can also
inject faults (abstention, slowness, invalid plan, different major version…) to test the
engine's fallback. Instructions:
[its `README.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/tools/etm-powersync-ref-optimizer/README.md).
## Pointing the engine at your optimiser {#brancher}
Three JSON-RPC methods of the box, reserved to the administrator, described in
[`INTERFACE.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/INTERFACE.md):
- `NymeaEnergy.SetOptimizerEndpoint` — where to connect (`unix:///…` or `tcp://host:port`);
- `NymeaEnergy.SetOptimizerToken` — the shared secret, the same as the optimiser's;
- `NymeaEnergy.GetOptimizerStatus` — connection status: optimiser alive, active strategy,
announced name, last error code.
Over `tcp://` without encryption, the secret travels in clear text on the local network.
## The conformance suite {#conformite}
The `etm-powersync-optimizer-conformance` suite starts **your** optimiser and connects the
**real** engine to it, on simulated devices. It checks, one function per point:
1. the handshake is accepted;
2. every `GetPlan` is answered within the engine's timeout;
3. the whole session, both ways, conforms to the contract embedded in the engine,
validation rules included;
4. every plan is applied: the optimiser is in control;
5. the safety loop stays on top: an out-of-bounds setpoint is clamped, a phase overload is
shed.
It does not judge the **quality** of the plans.
### Running it against your optimiser
Build the engine with its tests, then give the command that starts your server;
`{endpoint}` in it is replaced by the address to listen on:
```sh
export ETM_POWERSYNC_OPTIMIZER_CMD='exec /path/to/my-optimiser --listen {endpoint}'
export ETM_POWERSYNC_CONFORMANCE_EXPECT=plan # or abstain
```
Each function runs in its own process. The full command (network isolation recommended),
the other variables (shared secret, start-up delay…) and how to read failures are in the
[suite's `README.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/tests/auto/conformance/README.md).
### Expected mode: `plan` or `abstain` {#mode-attendu}
`ETM_POWERSYNC_CONFORMANCE_EXPECT` tells the suite what your optimiser does:
- **`plan`** (default): it is in control. Every cycle must apply its plan.
- **`abstain`**: it abstains on every cycle — that is allowed, and conformant. The suite
then checks that the engine drives by its rules on every cycle, without counting a
failure, and that safety still holds; the clamping check is skipped, as there is no
setpoint to clamp.
Any other value makes the suite fail. **The mode is not inferred from the handshake**:
`planning` and `capabilities` describe the shape of plans, not whether any are produced.
The mode is given, not guessed.
## Comparing on the same inputs {#traces}
The engine can record, on the box, a trace of every cycle (off by default,
`NymeaEnergy.SetOptimizerTrace`). The `tools/etm-powersync-trace-replay` tool replays these
traces against a candidate optimiser and summarises the differences from the recorded plan.
Traces stay on site.

View File

@ -17,7 +17,7 @@ Session minimale, à titre d'illustration (la forme exacte est dans le [contrat]
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é ;
**motif** en texte, en français : 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

View File

@ -0,0 +1,69 @@
# Optimierer
<span class="badge testing">IN ARBEIT</span>
Das Protokoll zwischen Engine und Optimierer ist veröffentlicht (Version 1.1). Die
Anbindung eines Optimierers wird auf unserer Testanlage erprobt; die Box braucht keinen,
um zu funktionieren.
## Wozu ein Optimierer dient
Die [Regeln der Engine](../fonctionnalites/moteur.md) entscheiden über die Gegenwart: den
Überschuss dieser Minute, verteilt nach Ihren Prioritäten. Ein Optimierer schaut weiter
voraus. Aus den Prognosen, die ihm die Engine übergibt — PV-Erzeugung, Grundverbrauch,
eingegebener Tarif —, schlägt er einen **Plan in Zeitfenstern** über mehrere Stunden vor:
den Warmwasserbereiter in die erwartete Erzeugungsspitze legen, Reserve für das Laden am
Abend lassen usw.
## Der Optimierer berät, die Engine entscheidet
- Die Engine führt nur das **aktuelle Zeitfenster** des Plans aus. Der Rest wird als
Prognose angezeigt und im nächsten Zyklus neu angefragt.
- Jeder Sollwert wird durch die Grenzen des Verbrauchers (Unter-, Obergrenze, Sperren
gegen Takten) und durch die **Sicherheitsschleife** in Echtzeit **gedeckelt**; diese
bleibt bei der Engine und hat Vorrang vor allem. Ein Optimierer kann keine Sperre
umgehen.
- Jede Aktion muss ihren **Grund** tragen. Eine Aktion ohne Grund wird verworfen.
- Ein fehlerhafter Plan, oder einer, der einen unbekannten Verbraucher anspricht, wird
vollständig abgelehnt.
Ein fehlerhafter Optimierer kann also schlimmstenfalls eine schlechte Verteilung
vorschlagen — begrenzt durch die Engine.
## Die Box funktioniert ohne Optimierer
Optimierer fehlt, schweigt, enthält sich, Plan abgelaufen oder abgelehnt: Die Engine kehrt
für den Zyklus zu ihren **Regeln** (`rules`) zurück, ohne Unterbrechung der Steuerung. Die
App zeigt jederzeit, welche Strategie steuert. Die Community-Version funktioniert
vollständig so.
## Seine Daten kommen nur von der Engine
- Die Engine verbindet sich mit dem Optimierer, nie umgekehrt: Die Box öffnet keinen Port.
- Der Optimierer läuft auf der Box (lokaler Socket) oder auf einem Rechner in Ihrem lokalen
Netz (TCP). Im lokalen Netz wird ein gemeinsames Geheimnis (Token) empfohlen.
- Er erhält **nur** den Kontext, den die Engine sendet, Prognosen inbegriffen: Er ruft
weder einen Wetterdienst noch einen Tarifanbieter auf und öffnet keine Verbindung nach
außen.
- Was er lernt, bleibt vor Ort.
## Wer einen Optimierer schreiben kann
Jeder. Das Protokoll ist offen und durch einen [maschinenlesbaren Vertrag](contrat.md)
unter GPL-3.0 beschrieben. Ein freier **Referenz-Optimierer** und eine
**Konformitäts-Testsuite** erlauben es, einen eigenen zu schreiben und gegen die echte
Engine zu prüfen — siehe [Einen Optimierer schreiben](ecrire.md).
## Der ETM-Optimierer
ETM-Schurig bietet einen eigenen, proprietären Optimierer an, enthalten in der Pro-Edition.
Er wird über dasselbe Protokoll angebunden, mit denselben Grenzen: Er berät, die Engine
entscheidet. Beim Handshake meldet er sich als `etm-optimizer`; der Referenz-Optimierer
meldet sich als `powersync-optimizer`.
## In diesem Abschnitt
- [Protokoll](protocole.md) — Transport, Handshake, Kontext, Plan, Rückfall,
Prüfregeln.
- [Vertrag](contrat.md) — die OpenRPC-Datei, die für die Form der Nachrichten maßgeblich ist.
- [Einen Optimierer schreiben](ecrire.md) — Referenz-Optimierer und Konformitäts-Testsuite.

View File

@ -0,0 +1,63 @@
# Optimizer
<span class="badge testing">IN PROGRESS</span>
The protocol between the engine and an optimiser is published (version 1.1). Connecting
an optimiser is being tested on our test installation; the box does not need one to work.
## What an optimiser is for
The [engine's rules](../fonctionnalites/moteur.md) decide on the present: this minute's
surplus, shared according to your priorities. An optimiser looks further ahead. From the
forecasts the engine hands it — PV production, base consumption, entered tariff — it
proposes a **plan in time slots** over several hours: shift the water heater to the
expected production peak, keep headroom for evening charging, and so on.
## The optimiser advises, the engine decides
- The engine only executes the **current slot** of the plan. The rest is shown as a
forecast, and requested again on the next cycle.
- Every setpoint is **capped** by the load's bounds (floor, ceiling, anti-flapping locks)
and by the real-time **safety loop**, which stays with the engine and overrides
everything. An optimiser cannot bypass a lock.
- Every action must carry its **reason**. An action without one is rejected.
- A malformed plan, or one that targets an unknown load, is rejected as a whole.
At worst, a faulty optimiser can therefore only propose a poor allocation — bounded by the
engine.
## The box works without an optimiser
Optimiser missing, silent, abstaining, plan expired or rejected: the engine returns to its
**rules** (`rules`) for that cycle, with no interruption of control. The app shows at all
times which strategy is in charge. The Community edition works entirely this way.
## Its data come from the engine only
- The engine connects to the optimiser, never the other way round: the box opens no port.
- The optimiser runs on the box (local socket) or on a machine in your local network
(TCP). On the local network, a shared secret (token) is recommended.
- It receives **only** the context the engine sends, forecasts included: it calls neither
a weather service nor a tariff provider, and opens no connection to the outside.
- What it learns stays on site.
## Who can write an optimiser
Anyone. The protocol is open and described by a [machine-readable contract](contrat.md)
published under GPL-3.0. A free **reference optimiser** and a **conformance suite** let you
write your own and check it against the real engine — see
[Writing an optimizer](ecrire.md).
## The ETM optimiser
ETM-Schurig offers its own proprietary optimiser, included in the Pro edition. It connects
through this same protocol, within the same limits: it advises, the engine decides. At
handshake it announces itself as `etm-optimizer`; the reference optimiser announces itself
as `powersync-optimizer`.
## In this section
- [Protocol](protocole.md) — transport, handshake, context, plan, fallback, validation
rules.
- [Contract](contrat.md) — the OpenRPC file that is authoritative on the shape of messages.
- [Writing an optimizer](ecrire.md) — reference optimiser and conformance suite.

View File

@ -0,0 +1,860 @@
# Protokoll
Das Protokoll verbindet die **Energie-Engine** der Box mit einem **Optimierer**, über einen
eigenen Kanal. Es ist nicht die API der App: Diese läuft über die
[JSON-RPC-API](../integrations/jsonrpc-api.md).
!!! info "Was maßgeblich ist"
- **Die Form** der Nachrichten (Felder, Typen, zulässige Werte, was die Engine
ablehnt) legt der [maschinenlesbare Vertrag](contrat.md) fest. Die Tabellen dieser
Seite werden bei jeder Veröffentlichung daraus **erzeugt**: Rahmen auf Englisch,
Beschreibungen unverändert aus dem Vertrag, auf Französisch. Sie sind in allen drei
Sprachen identisch.
- **Die Bedeutung** ist ausführlich, auf Französisch, in
[`OPTIMIZER_PROTOCOL.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/docs/OPTIMIZER_PROTOCOL.md)
im Repository der Engine beschrieben. Diese Seite fasst das Wesentliche zusammen; bei
Abweichungen gelten die Dokumente der Engine.
## Transport und Rollen {#transport}
- Der Optimierer ist der **Server**, die Engine der **Client**: Die Engine verbindet sich
und stellt die Fragen. Die Box öffnet keinen Port.
- Zwei Transporte, dieselben Nachrichten: `unix://` (lokaler Socket, Standard) und
`tcp://` (Rechner im lokalen Netz).
- JSON-RPC 2.0, eine Nachricht pro Zeile (NDJSON), UTF-8.
- Bricht die Verbindung ab, verbindet sich die Engine mit wachsender Wartezeit neu und
steuert in der Zwischenzeit nach ihren Regeln.
<!-- 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}
Bei jeder Verbindung sendet die Engine `Handshake` mit ihrer Protokollversion und, falls
konfiguriert, dem gemeinsamen Geheimnis. Der Optimierer antwortet mit seiner Version und,
wenn er möchte, seinem Namen und seinen Fähigkeiten. Andere **Hauptversion**, abgelehntes
Geheimnis oder unlesbare Antwort: Die Verbindung wird abgelehnt, und die Engine steuert
nach ihren Regeln. Kündigt der Optimierer die Aktionsarten (`kinds`) an, die er sendet,
lehnt die Engine danach jede Aktion einer anderen Art ab.
<!-- 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 -->
## Der Kontext: `GetPlan` {#contexte}
In jedem Zyklus und bei Ereignissen (Fahrzeug angesteckt, neue Prognose, geänderte
Konfiguration…) sendet die Engine `GetPlan` mit einem **Kontext**: Zustand der Anlage und
des Netzanschlusszählers, Erzeugung, Speicher, die verteilten Verbraucher mit ihren
Grenzen, Sperren und Bedarfen, Prognosen auf einem einzigen Zeitraster.
Einige Leseregeln, die Entscheidungen verändern:
- der Zähler wird als **Rohmessung** übertragen, nie als Bilanz nach der Steuerung;
- ein unbekannter Wert ist `null`, **nie 0**;
- ein nicht verfügbarer Verbraucher (`available: false`) darf keinen Sollwert erhalten,
verbraucht aber: Er wird weiter gezählt;
- laufende Sperren (`lockMinPowerW`, `lockMaxPowerW`) sagen, was der Verbraucher **jetzt**
kann; die Engine wendet sie in jedem Fall an;
- was ein Verbraucher misst, steht in `measurement.source`, nie in seinem Mechanismus
(`adapter`); eine angegebene Leistung ist nie eine Messung.
Die von der Engine gesendete Form ist **geschlossen**: Sie sendet kein Feld außerhalb des
Vertrags.
<!-- 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 -->
## Die Antwort: ein Plan oder eine Enthaltung {#reponse}
Der Optimierer antwortet **schnell** — mit seinem zuletzt zwischengespeicherten Plan, wenn
er lange rechnet — und rechnet im Hintergrund neu. Er antwortet entweder:
- mit einem **Plan in Zeitfenstern**: Jedes Fenster enthält eine Aktion je verfügbarem
Verbraucher (Sollwert in Watt oder Ampere, oder SG-Ready-Zustand), jede mit ihrem Grund;
nur das aktuelle Fenster wird ausgeführt, und `validUntil` begrenzt die Gültigkeit des
Plans;
- mit einer **Enthaltung** (`abstain: true`): „jetzt kein Plan“. Die Engine steuert für
diesen Zyklus nach ihren Regeln, ohne den Optimierer als ausgefallen zu betrachten.
Der Grund einer Aktion hat zwei Formen: einen freien Text auf Französisch,
**verpflichtend**, für die Aufzeichnungen; und optional einen Code aus dem Katalog
(`reasonCode`) mit seinen Parametern. An die App wird **nur der Code** übertragen, und die
App übersetzt ihn. Ein unbekannter Code geht als `OPTIMIZER_DECISION` hinaus.
Die von der Engine empfangene Form ist **offen**: Ein unbekanntes Feld wird ignoriert.
<!-- 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 -->
### Zulässige Grund-Codes {#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}
Benachrichtigung vom Optimierer an die Engine, ohne Antwort, um mitzuteilen, was er über
die Verbraucher gelernt hat. In Version 1.1 der Engine wird sie empfangen und protokolliert,
aber noch nicht ausgewertet.
<!-- 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 -->
## Rückfall-Automat {#repli}
Es gibt keinen eigenen Herzschlag: Eine gültige Antwort auf `GetPlan` bedeutet, dass der
Optimierer lebt. Fehler werden je Anfrage gezählt; nach mehreren aufeinanderfolgenden
Fehlern gilt der Optimierer als abwesend, und die Engine steuert bis zum nächsten gültigen
Plan nach ihren Regeln. Die App sieht diesen Zustand laufend (`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 -->
## Prüfregeln {#regles}
Das JSON Schema des Vertrags beschreibt die Form. Danach prüft die Engine jede Antwort
gegen den Kontext **ihrer** Anfrage, Regel für Regel, in der Reihenfolge unten; die erste,
die scheitert, liefert ihren Code (`fallbackCode`, in der App sichtbar).
<!-- 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 -->
### Toleranzen {#tolerances}
Was die Engine über den Vertrag hinaus akzeptiert. Ein konformer Optimierer nutzt das nicht.
<!-- 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 -->
## Geschlossene Listen {#listes-fermees}
Zulässige Werte, aus dem Code der Engine neu erzeugt.
<!-- 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

@ -0,0 +1,856 @@
# Protocol
The protocol links the box's energy **engine** to an **optimiser**, over a dedicated
channel. It is not the app's API: the app goes through the
[JSON-RPC API](../integrations/jsonrpc-api.md).
!!! info "What is authoritative"
- **The shape** of messages (fields, types, allowed values, what the engine rejects) is
set by the [machine-readable contract](contrat.md). The tables on this page are
**generated** from it on every publication: framing in English, descriptions taken
as-is from the contract, in French. They are identical in all three languages.
- **The meaning** is described in detail, in French, in
[`OPTIMIZER_PROTOCOL.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/docs/OPTIMIZER_PROTOCOL.md),
in the engine repository. This page gives the essentials; where they differ, the
engine's documents prevail.
## Transport and roles {#transport}
- The optimiser is the **server**, the engine is the **client**: the engine connects and
asks the questions. The box opens no port.
- Two transports, same messages: `unix://` (local socket, default) and `tcp://` (machine
on the local network).
- JSON-RPC 2.0, one message per line (NDJSON), UTF-8.
- If the connection drops, the engine reconnects with a growing delay and drives the loads
by its rules in the meantime.
<!-- 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}
On every connection, the engine sends `Handshake` with its protocol version and, if
configured, the shared secret. The optimiser answers with its version and, if it wishes,
its name and capabilities. A different **major** version, a refused secret or an
unreadable answer: the connection is refused, and the engine drives by its rules. If the
optimiser announces the action kinds (`kinds`) it emits, the engine then rejects any action
of another kind.
<!-- 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 -->
## The context: `GetPlan` {#contexte}
On every cycle, and on events (vehicle plugged in, new forecast, configuration
change…), the engine sends `GetPlan` with a **context**: state of the site and of the grid
meter, production, battery, the arbitrated loads with their bounds, locks and needs, and
forecasts on a single time grid.
A few reading rules that change decisions:
- the meter is sent as a **raw measurement**, never as the balance after control;
- an unknown value is `null`, **never 0**;
- an unavailable load (`available: false`) must receive no setpoint, but it consumes: it
is still counted;
- current locks (`lockMinPowerW`, `lockMaxPowerW`) say what the load can do **now**; the
engine enforces them regardless;
- what a load measures is read from `measurement.source`, never from its mechanism
(`adapter`); a declared power is never a measurement.
The shape sent by the engine is **closed**: it emits no field outside the contract.
<!-- 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 -->
## The answer: a plan or an abstention {#reponse}
The optimiser answers **quickly** — with its last cached plan if it computes for long —
and recomputes in the background. It answers either:
- a **plan in time slots**: each slot carries one action per available load (setpoint in
watts or amps, or SG-Ready state), each with its reason; only the current slot is
executed, and `validUntil` bounds the plan's lifetime;
- an **abstention** (`abstain: true`): "no plan right now". The engine drives by its rules
for that cycle, without treating the optimiser as failed.
An action's reason has two forms: a free text in French, **mandatory**, for the traces;
and, optionally, a code from the catalogue (`reasonCode`) with its parameters. Only **the
code** is passed on to the app, which translates it. An unknown code goes out as
`OPTIMIZER_DECISION`.
The shape received by the engine is **open**: an unknown field is ignored.
<!-- 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 -->
### Emittable reason codes {#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 sent by the optimiser to the engine, with no answer, to pass on what it has
learned about the loads. In version 1.1 of the engine, it is received and logged, but not
yet used.
<!-- 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 -->
## Fallback state machine {#repli}
There is no separate heartbeat: a valid answer to `GetPlan` means the optimiser is alive.
Failures are counted per request; after several consecutive failures, the optimiser is
declared absent and the engine drives by its rules until the next valid plan. The app sees
this state continuously (`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 -->
## Validation rules {#regles}
The contract's JSON Schema states the shape. The engine then judges each answer against
the context of **its** request, rule by rule, in the order below; the first that fails
gives its code (`fallbackCode`, visible in the app).
<!-- 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 -->
### Tolerances {#tolerances}
What the engine accepts beyond the contract. A conformant optimiser does not rely on it.
<!-- 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 -->
## Closed lists {#listes-fermees}
Allowed values, regenerated from the engine's code.
<!-- 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 -->