Deux fonctions, par la connexion de sonde :
- testAnInvalidContextIsRefusedWithItsProblems : un GetPlan de contexte invalide
({"timestamp": "valeur-sonde:7Qz9"}) → -32602 à son id, connexion gardée ; la session de sonde
jugée par le validateur publié contre le contrat EMBARQUÉ (--responses-only) : forme exigée
d'un optimiseur qui s'annonce 1.3 ou plus, AVERTISSEMENT seulement pour un 1.2 ; le contrôle
doit avoir eu lieu (GetPlan.error.data compté) ; garde indépendante : la valeur reçue n'est
pas dans error.data, quelle que soit la version.
- testTheTokenIsCheckedBeforeTheMajor : token faux et autre majeure → -32001 puis fermeture ;
-32602 d'un optimiseur 1.2 averti ; SKIP sans token attendu.
Éprouvé contre l'optimiseur de référence : 1.3 conforme (11 verts + 1 SKIP sans token, 3/3 avec
token) ; old-data annoncé 1.3 refusé, annoncé 1.2 averti ; echo-value refusé en 1.3 comme en 1.2 ;
major-first refusé en 1.3, averti en 1.2 ; référence annoncée 1.2 verte sans avertissement.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
118 lines
10 KiB
Markdown
118 lines
10 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 |
|
||
| `testAnInvalidContextIsRefusedWithItsProblems` | protocole 1.3, §7 : un `GetPlan` dont le contexte est invalide (`{"timestamp": "valeur-sonde:7Qz9"}`) → `-32602` à son `id`, connexion **gardée** ; `error.data` jugé par le validateur publié contre le contrat embarqué (`--responses-only`) : forme `{"problems":[{path, problem}]}` **exigée** d'un optimiseur qui s'annonce 1.3 ou plus, **avertie** (`AVERTISSEMENT`, test vert) d'un optimiseur 1.2 ; la valeur reçue recopiée dans `error.data` est **refusée** quelle que soit la version (règle de 1.2) |
|
||
| `testTheTokenIsCheckedBeforeTheMajor` | protocole 1.3, §3 : token faux **et** autre majeure dans le même handshake → `-32001` (le token d'abord), puis l'optimiseur ferme. Exigé à partir de 1.3 ; `-32602` d'un optimiseur 1.2 est averti. **SKIP** sans `ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE` : sans token attendu, l'ordre ne s'éprouve pas |
|
||
| `testTheSessionMatchesTheContractOffUtc` | la fonction 3, le moteur sous un fuseau **non UTC** (`Europe/Berlin`) et cadencé en heure locale comme en production, à travers le passage à l'heure d'hiver (02:58 CEST → 02:01 CET) : aucun échec de requête, session conforme au contrat embarqué (tout instant porte son décalage, §5.2) |
|
||
|
||
Les lignes `testEveryPlanIsAccepted` et `testTheSafetyLoopStaysOnTop` valent pour le mode `plan` (défaut). Voir **Optimiseur qui s'abstient**.
|
||
|
||
Les fonctions 6 à 9, 11 et 12 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 et 10 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 \
|
||
testTheSessionMatchesTheContractOffUtc testAnInvalidContextIsRefusedWithItsProblems \
|
||
testTheTokenIsCheckedBeforeTheMajor; 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 |
|
||
|
||
**Un optimiseur 1.2 face aux règles 1.3.** Une mineure ajoute des règles (protocole §9) : la suite
|
||
lit la version que l'optimiseur annonce au handshake et n'exige la forme d'`error.data` et l'ordre
|
||
token puis majeure qu'à partir de 1.3. En deçà, l'écart est écrit en `AVERTISSEMENT` et la fonction
|
||
reste verte — l'optimiseur fonctionne avec ce moteur, il s'aligne avant de s'annoncer 1.3. Pour
|
||
éprouver l'ordre, donner un token (`ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE`, et le même fichier à
|
||
l'optimiseur) :
|
||
|
||
```sh
|
||
printf 'jeton-de-conformite-0123456789' > /chemin/token
|
||
export ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE=/chemin/token
|
||
export ETM_POWERSYNC_OPTIMIZER_CMD='exec /chemin/de/mon-optimiseur --listen {endpoint} --token-file /chemin/token'
|
||
```
|
||
|
||
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), l'exhaustivité de la validation du
|
||
contexte par l'optimiseur (un seul contexte invalide est envoyé ; il en suffit d'un problème pour
|
||
juger la forme), le comportement sous défaut de transport. Le contrat : `docs/etm-powersync-optimizer-protocol.openrpc.json`
|
||
(prose : `docs/OPTIMIZER_PROTOCOL.md`, annexe G).
|