Quatre fonctions, une connexion de SONDE sur l'optimiseur (sans le moteur), pour ce que le client du moteur n'envoie jamais : - testGetPlanBeforeTheHandshakeIsRefused (Q3) : -32600 à son id, puis le handshake sur la même connexion est accepté (connexion gardée) ; - testARefusedHandshakeIsClosedByTheOptimizer (Q3, Q4) : autre majeure → -32602 puis l'optimiseur ferme ; avec un token attendu, token faux → -32001, fermé pareil ; - testABatchIsRefused (Q8) : un lot → une erreur -32600 à id null, son contenu non traité, connexion gardée ; - testALineOverTheLimitIsRefused (Q7) : maxLineBytes + 1 → -32600 à id null, une ligne d'exactement maxLineBytes est lue. Le traitement d'un message se constate par une réponse normée sans contexte : UpdateLearned avec id → -32601 (Q6). Réponses lues brutes : un lot renvoyé se verrait. README : tableau, boucle de lancement, ce qui n'est pas vérifié. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
100 lines
7.8 KiB
Markdown
100 lines
7.8 KiB
Markdown
# `etm-powersync-optimizer-conformance` — conformité d'un optimiseur quelconque
|
||
|
||
Suite de test du moteur (Qt Test, harnais nymea du dépôt). Elle lance **votre** optimiseur, branche
|
||
dessus le **vrai** moteur — serveur nymea, arbitre, `SocketScheduler`, `OptimizerClient`,
|
||
adaptateurs, sur les mocks de la suite `simulation` — et vérifie, une fonction par point :
|
||
|
||
| Fonction | Ce qui est vérifié |
|
||
|---|---|
|
||
| `testHandshakeIsAccepted` | le client du moteur, **directement** sur l'optimiseur, passe le handshake (§3) : majeure 1, aucun code d'erreur |
|
||
| `testGetPlanIsAnsweredInTime` | 8 cycles : chaque `GetPlan` répondu sous le délai du moteur (10 s), aucun échec de requête ; latence max affichée |
|
||
| `testTheSessionMatchesTheContract` | la session entière (les deux sens) est conforme au contrat OpenRPC **embarqué** dans le moteur, règles sémantiques comprises (`tools/etm-powersync-schema-check --semantic`) |
|
||
| `testEveryPlanIsAccepted` | à chaque cycle, le plan est **appliqué** : l'optimiseur commande, `GetPlan` sans `fallbackCode` |
|
||
| `testTheSafetyLoopStaysOnTop` | la boucle de sécurité reste au-dessus : une consigne hors bornes (réécrite en 100 kW par le relais de test) est **écrêtée** par l'adaptateur (`clamped`), une surcharge de phase est **délestée** par L4 |
|
||
| `testGetPlanBeforeTheHandshakeIsRefused` | protocole 1.2, §3 : `GetPlan` avant un handshake réussi → erreur `-32600` à son `id`, connexion **gardée** (le handshake qui suit est accepté) |
|
||
| `testARefusedHandshakeIsClosedByTheOptimizer` | protocole 1.2, §3 : handshake d'une autre majeure → `-32602`, puis l'optimiseur **ferme** ; si un token est attendu, token faux → `-32001`, fermé pareil |
|
||
| `testABatchIsRefused` | protocole 1.2, §2 : un lot `[…]` → **une** erreur `-32600` à `id: null`, rien du lot traité, connexion gardée |
|
||
| `testALineOverTheLimitIsRefused` | protocole 1.2, §2 : une ligne de `maxLineBytes` + 1 octets → `-32600` à `id: null`, connexion gardée ; une ligne d'exactement `maxLineBytes` est lue |
|
||
|
||
Ces deux dernières lignes valent pour le mode `plan` (défaut). Voir **Optimiseur qui s'abstient**.
|
||
|
||
Les fonctions 6 à 9 ne branchent pas le moteur : elles envoient, par une **connexion de sonde**, ce
|
||
que son client n'envoie jamais, et lisent la réponse brute (un lot renvoyé se verrait). Elles
|
||
utilisent des requêtes dont la réponse est normée sans contexte — `UpdateLearned` avec `id` →
|
||
`-32601` (§8) — pour constater qu'un message a été, ou n'a pas été, traité.
|
||
|
||
Les fonctions 2 à 5 passent par un relais transparent (une ligne reçue = une ligne renvoyée) qui
|
||
capture la session et mesure les délais ; la 5 est la seule où il réécrit une réponse. Banc : deux
|
||
charges `etmvariableload` (0–2 000 W ; plancher 800, 3 000 W) et une PAC SG-Ready (P3 1 500 W,
|
||
P4 3 000 W) ; surplus de −1 000 à 9 000 W.
|
||
|
||
## Lancer contre un autre optimiseur
|
||
|
||
Construire le moteur avec ses tests (`qmake6 …/etm-powersync-energy-plugin-etm.pro CONFIG+=build_tests
|
||
&& make -j4`), puis, **un processus par fonction** (le harnais nymea ne rend pas sa mémoire entre deux
|
||
tests, `AGENTS.md`) :
|
||
|
||
```sh
|
||
cd <build>/tests/auto/conformance
|
||
export NYMEA_PLUGINS_PATH=<build>/tests/mocks/plugins/energymocks:/usr/lib/x86_64-linux-gnu/nymea/plugins
|
||
export ETM_POWERSYNC_OPTIMIZER_CMD='exec /chemin/de/mon-optimiseur --listen {endpoint}'
|
||
for fn in testHandshakeIsAccepted testGetPlanIsAnsweredInTime testTheSessionMatchesTheContract \
|
||
testEveryPlanIsAccepted testTheSafetyLoopStaysOnTop testGetPlanBeforeTheHandshakeIsRefused \
|
||
testARefusedHandshakeIsClosedByTheOptimizer testABatchIsRefused testALineOverTheLimitIsRefused; do
|
||
systemd-run --user --scope -q -p MemoryMax=2G \
|
||
bwrap --dev-bind / / --unshare-net --tmpfs /tmp --bind "$PWD/../../.." "$PWD/../../.." \
|
||
--bind <sources> <sources> --chdir "$PWD" ./target_wrapper.sh ./etm-powersync-optimizer-conformance "$fn"
|
||
done
|
||
```
|
||
|
||
`bwrap --unshare-net` est recommandé : le harnais ouvre des ports fixes, et l'optimiseur que lance la
|
||
commande vit dans le même réseau privé (la boucle locale y fonctionne, `tcp://127.0.0.1:…` compris).
|
||
|
||
| Variable | Défaut | Sens |
|
||
|---|---|---|
|
||
| `ETM_POWERSYNC_OPTIMIZER_CMD` | l'optimiseur de référence du dépôt, mode `normal` | Commande qui démarre le **serveur** (`/bin/sh -c`, dans son propre groupe de processus, arrêtée par SIGTERM puis SIGKILL). `{endpoint}` y est remplacé par l'endpoint où écouter, aussi passé dans `ETM_POWERSYNC_OPTIMIZER_ENDPOINT`. `-` : ne rien lancer, l'optimiseur écoute déjà |
|
||
| `ETM_POWERSYNC_OPTIMIZER_ENDPOINT` | `unix://<répertoire temporaire du test>/optimiseur.sock` | Où l'optimiseur écoute (`unix:///…` ou `tcp://hôte:port`) |
|
||
| `ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE` | aucun | Fichier du token que l'optimiseur attend ; le moteur l'envoie au handshake (16 caractères au moins) |
|
||
| `ETM_POWERSYNC_OPTIMIZER_START_S` | `15` | Délai pour que l'endpoint accepte une connexion |
|
||
| `ETM_POWERSYNC_SOURCE_DIR` | sources du build | Racine où trouver le validateur et l'optimiseur de référence |
|
||
| `ETM_POWERSYNC_CONFORMANCE_EXPECT` | `plan` | Ce que la suite attend : `plan` (l'optimiseur commande) ou `abstain` (il s'abstient à chaque cycle). Toute autre valeur fait échouer |
|
||
|
||
L'attente de démarrage ouvre une connexion de sonde, refermée sans handshake : un serveur conforme
|
||
la tolère. Un échec dit ce qui manque — code d'erreur du client, écart au contrat (chemin, mot-clé),
|
||
cycles non appliqués avec leur `fallbackCode`, latences.
|
||
|
||
## Optimiseur qui s'abstient
|
||
|
||
S'abstenir est une réponse permise (`{"abstain": true}`, §7) : un optimiseur qui s'abstient
|
||
toujours est conforme. Avec `ETM_POWERSYNC_CONFORMANCE_EXPECT=abstain` :
|
||
|
||
- `testEveryPlanIsAccepted` exige que **chaque** cycle (hors le premier, sans réponse encore)
|
||
finisse en `strategy: rules` avec `fallbackCode: abstain`, que chaque réponse soit jugée
|
||
`abstention/abstain`, sans échec compté, et qu'`optimizerAlive` ne change pas. Un plan appliqué,
|
||
un autre `fallbackCode` ou un échec font échouer ;
|
||
- `testTheSafetyLoopStaysOnTop` vérifie toujours L4 sous le rule-based, sans consigne de
|
||
l'optimiseur : la trace doit porter `shed` (la seule baisse des consignes ne suffit pas, l'import
|
||
la produit déjà). Puis la fonction se termine en **SKIP** : aucune consigne à écrêter ;
|
||
- les autres fonctions sont inchangées.
|
||
|
||
À l'inverse, un optimiseur qui s'abstient en mode `plan` échoue à `testEveryPlanIsAccepted` et
|
||
`testTheSafetyLoopStaysOnTop`. Chaque fonction affiche en tête le mode retenu (`[Conformité] mode
|
||
attendu : …`).
|
||
|
||
**Le mode ne se déduit pas du handshake.** Le protocole n'a pas de capacité qui dise « je
|
||
planifie » : `planning` est informatif et distingue le plan multi-créneaux du plan à un créneau
|
||
(§3), et `capabilities.kinds` absent veut dire « tous les kinds admis », pas « aucun ». L'optimiseur
|
||
minimal de l'annexe A répond `planning: false`, sans `capabilities`, et planifie ; l'optimiseur de
|
||
référence en mode `abstain` annonce `kinds: ["setpoint", "state"]` et ne planifie jamais. Une règle
|
||
fondée sur ces champs jugerait faux dans les deux cas : le mode se donne, il ne se devine pas.
|
||
|
||
## Ce qu'elle ne vérifie pas
|
||
|
||
La **qualité** des plans (aucune comparaison au rule-based : c'est `refoptimizer`, pour la référence
|
||
seulement), les bornes VE (aucune borne au banc), `UpdateLearned` émis par l'optimiseur (ignoré par le moteur ; sa
|
||
forme n'est pas normée en 1.2), le token envoyé à un optimiseur qui n'en exige pas (§3, 1.2 : accepté ;
|
||
le moteur n'envoie de token que si l'installateur en a posé un), la réponse à un contexte `GetPlan`
|
||
invalide (§7, 1.2 : `-32602` ; la forme d'`error.data` n'est pas normée), le comportement sous défaut
|
||
de transport. Le contrat : `docs/etm-powersync-optimizer-protocol.openrpc.json`
|
||
(prose : `docs/OPTIMIZER_PROTOCOL.md`, annexe G).
|