# `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 /tests/auto/conformance export NYMEA_PLUGINS_PATH=/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 --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:///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).