From 64a864bfaadcd28c01f3d215cb5676a51385f04b Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 17 Oct 2026 12:12:25 +0200 Subject: [PATCH] feat(tools): etm-powersync-trace-replay, rejeu des traces vers un optimiseur (brief 4 T6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python 3, bibliothèque standard seule. unix:// par défaut (unix:///run/powersync/optimizer.sock), tcp:// accepté ; token lu dans un fichier (--token-file), jamais en argument ni affiché. Réponses écrites à côté de la trace, jamais d'écrasement ; résumé : requêtes, taux d'abstention, computeMs médian et max, cycles non comparables, cycles dont les actions du créneau courant diffèrent du plan enregistré. --fail-on-divergence : code 1. Test (unittest) contre un serveur Unix minimal qui renvoie les réponses enregistrées (ce n'est pas l'optimiseur de référence) : trace synthétique et trois traces réelles du moteur → zéro divergence ; réponse modifiée → divergence vue, code 1 ; tcp://, refus d'écraser, endpoint absent. Co-Authored-By: Claude Opus 5.5 --- .../etm-powersync-trace-optimiseur.ndjson | 2 + .../data/etm-powersync-trace-regles.ndjson | 3 + .../data/etm-powersync-trace-repli.ndjson | 4 + .../tools/etm-powersync-trace-replay-test.py | 232 +++++++++++++ tools/README.md | 42 +++ tools/etm-powersync-trace-replay | 313 ++++++++++++++++++ 6 files changed, 596 insertions(+) create mode 100644 tests/tools/data/etm-powersync-trace-optimiseur.ndjson create mode 100644 tests/tools/data/etm-powersync-trace-regles.ndjson create mode 100644 tests/tools/data/etm-powersync-trace-repli.ndjson create mode 100644 tests/tools/etm-powersync-trace-replay-test.py create mode 100644 tools/README.md create mode 100755 tools/etm-powersync-trace-replay diff --git a/tests/tools/data/etm-powersync-trace-optimiseur.ndjson b/tests/tools/data/etm-powersync-trace-optimiseur.ndjson new file mode 100644 index 0000000..f8a0ad6 --- /dev/null +++ b/tests/tools/data/etm-powersync-trace-optimiseur.ndjson @@ -0,0 +1,2 @@ +{"executed":[{"clamped":false,"kind":"setpoint","loadId":"ecs","powerW":1000},{"clamped":false,"kind":"setpoint","loadId":"{86decdd5-de6d-414a-8389-c7c59e998309}","powerW":2000}],"latencyMs":1,"request":{"battery":{"present":false},"forecast":{"baseload":{"model":"none","p50W":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null],"quality":{"commandedMinutes":0,"computedAt":"2026-10-17T09:36:18Z","daysCovered":{"weekend":0,"workday":0},"incompleteMinutes":0,"loads":[],"minDays":7,"minutes":0,"negativeMinutes":0,"resolutionS":3600,"windowDays":28}},"price":null,"pv":null,"resolutionS":3600,"start":"2026-06-07T22:00:00Z","steps":48,"weather":null},"loads":[{"adapter":"etmvariableload","declared":{"maxPowerW":3000,"minPowerW":0},"id":"{86decdd5-de6d-414a-8389-c7c59e998309}","label":"Routeur","limits":{"minOffS":0,"minOnS":0},"priority":2,"telemetry":{"available":true,"currentPowerW":0,"lockMaxPowerW":-1,"lockMinPowerW":0,"measurement":{"powerW":0,"source":"device"}}},{"adapter":"relay-router","declared":{"maxPowerW":1000,"minPowerW":1000},"id":"ecs","label":"ECS","limits":{"minOffS":0,"minOnS":0},"priority":1,"telemetry":{"available":true,"currentPowerW":0,"lockMaxPowerW":1000,"lockMinPowerW":0,"measurement":{"powerW":0,"source":"device"}}}],"meter":{"exportW":3000,"importW":0,"perPhaseA":[0,0,0],"source":"{c3f0f4f0-7f1d-408d-a30d-b919a0b564f3}"},"pv":{"currentW":null},"site":{"contractedPowerW":null,"gridCapW":null,"phaseLimitA":null},"tariff":null,"timestamp":"2026-06-08T13:00:00Z","trigger":"cycle"},"response":{"result":{"diagnostics":{"computeMs":3,"status":"optimal"},"planId":"plan-test","slots":[{"actions":[{"kind":"setpoint","loadId":"ecs","powerW":1000,"reason":"Texte LIBRE de l'optimiseur, qui ne doit jamais sortir"},{"kind":"setpoint","loadId":"{86decdd5-de6d-414a-8389-c7c59e998309}","powerW":500,"reason":"Texte LIBRE de l'optimiseur, qui ne doit jamais sortir"}],"expected":{"gridImportW":0},"from":"2026-06-08T12:59:00Z","to":"2026-06-08T13:15:00Z"}],"strategy":"faux","validUntil":"2026-06-08T13:30:00Z"}},"strategy":"rules","t":"2026-06-08T15:00:00+02:00"} +{"executed":[{"clamped":false,"kind":"setpoint","loadId":"ecs","powerW":1000},{"clamped":false,"kind":"setpoint","loadId":"{86decdd5-de6d-414a-8389-c7c59e998309}","powerW":500}],"latencyMs":0,"request":{"battery":{"present":false},"forecast":{"baseload":{"model":"none","p50W":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null],"quality":{"commandedMinutes":0,"computedAt":"2026-10-17T09:36:18Z","daysCovered":{"weekend":0,"workday":0},"incompleteMinutes":0,"loads":[],"minDays":7,"minutes":0,"negativeMinutes":0,"resolutionS":3600,"windowDays":28}},"price":null,"pv":null,"resolutionS":3600,"start":"2026-06-07T22:00:00Z","steps":48,"weather":null},"loads":[{"adapter":"etmvariableload","declared":{"maxPowerW":3000,"minPowerW":0},"id":"{86decdd5-de6d-414a-8389-c7c59e998309}","label":"Routeur","limits":{"minOffS":0,"minOnS":0},"priority":2,"telemetry":{"available":true,"currentPowerW":0,"lockMaxPowerW":-1,"lockMinPowerW":0,"measurement":{"powerW":0,"source":"device"}}},{"adapter":"relay-router","declared":{"maxPowerW":1000,"minPowerW":1000},"id":"ecs","label":"ECS","limits":{"minOffS":0,"minOnS":0},"priority":1,"telemetry":{"available":true,"currentPowerW":1000,"lockMaxPowerW":1000,"lockMinPowerW":0,"measurement":{"powerW":1000,"source":"device"}}}],"meter":{"exportW":3000,"importW":0,"perPhaseA":[0,0,0],"source":"{c3f0f4f0-7f1d-408d-a30d-b919a0b564f3}"},"pv":{"currentW":null},"site":{"contractedPowerW":null,"gridCapW":null,"phaseLimitA":null},"tariff":null,"timestamp":"2026-06-08T13:01:00Z","trigger":"cycle"},"response":{"result":{"diagnostics":{"computeMs":3,"status":"optimal"},"planId":"plan-test","slots":[{"actions":[{"kind":"setpoint","loadId":"ecs","powerW":1000,"reason":"Texte LIBRE de l'optimiseur, qui ne doit jamais sortir"},{"kind":"setpoint","loadId":"{86decdd5-de6d-414a-8389-c7c59e998309}","powerW":500,"reason":"Texte LIBRE de l'optimiseur, qui ne doit jamais sortir"}],"expected":{"gridImportW":0},"from":"2026-06-08T13:00:00Z","to":"2026-06-08T13:16:00Z"}],"strategy":"faux","validUntil":"2026-06-08T13:31:00Z"}},"strategy":"optimizer","t":"2026-06-08T15:01:00+02:00"} diff --git a/tests/tools/data/etm-powersync-trace-regles.ndjson b/tests/tools/data/etm-powersync-trace-regles.ndjson new file mode 100644 index 0000000..d23d364 --- /dev/null +++ b/tests/tools/data/etm-powersync-trace-regles.ndjson @@ -0,0 +1,3 @@ +{"executed":[{"clamped":false,"kind":"setpoint","loadId":"ecs","powerW":1000},{"clamped":false,"kind":"setpoint","loadId":"{29e276f9-bbf5-403f-9107-182727dcb647}","powerW":2000}],"request":{"battery":{"present":false},"forecast":{"baseload":{"model":"none","p50W":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null],"quality":{"commandedMinutes":0,"computedAt":"2026-10-17T09:36:17Z","daysCovered":{"weekend":0,"workday":0},"incompleteMinutes":0,"loads":[],"minDays":7,"minutes":0,"negativeMinutes":0,"resolutionS":3600,"windowDays":28}},"price":null,"pv":null,"resolutionS":3600,"start":"2026-06-07T22:00:00Z","steps":48,"weather":null},"loads":[{"adapter":"etmvariableload","declared":{"maxPowerW":3000,"minPowerW":0},"id":"{29e276f9-bbf5-403f-9107-182727dcb647}","label":"Routeur","limits":{"minOffS":0,"minOnS":0},"priority":2,"telemetry":{"available":true,"currentPowerW":0,"lockMaxPowerW":-1,"lockMinPowerW":0,"measurement":{"powerW":0,"source":"device"}}},{"adapter":"relay-router","declared":{"maxPowerW":1000,"minPowerW":1000},"id":"ecs","label":"ECS","limits":{"minOffS":0,"minOnS":0},"priority":1,"telemetry":{"available":true,"currentPowerW":0,"lockMaxPowerW":1000,"lockMinPowerW":0,"measurement":{"powerW":1000,"source":"device"}}}],"meter":{"exportW":3000,"importW":0,"perPhaseA":[0,0,0],"source":"{de56ba36-d8b9-4ce4-baf6-7cc2707e1352}"},"pv":{"currentW":null},"site":{"contractedPowerW":null,"gridCapW":null,"phaseLimitA":null},"tariff":null,"timestamp":"2026-06-08T13:00:00Z","trigger":"cycle"},"response":{"result":{"computedAt":"2026-06-08T13:00:00Z","slots":[{"actions":[{"kind":"setpoint","loadId":"ecs","powerW":1000,"reasonCode":"SURPLUS_SETPOINT","reasonParams":{"budgetW":3000,"setpointW":1000,"stepped":true}},{"kind":"setpoint","loadId":"{29e276f9-bbf5-403f-9107-182727dcb647}","powerW":2000,"reasonCode":"SURPLUS_SETPOINT","reasonParams":{"budgetW":2000,"setpointW":2000,"stepped":false}}],"from":"2026-06-08T13:00:00Z","to":"2026-06-08T13:01:00Z"}],"strategy":"rules","validUntil":"2026-06-08T13:01:00Z"}},"strategy":"rules","t":"2026-06-08T15:00:00+02:00"} +{"executed":[{"clamped":false,"kind":"setpoint","loadId":"ecs","powerW":1000},{"clamped":false,"kind":"setpoint","loadId":"{29e276f9-bbf5-403f-9107-182727dcb647}","powerW":3000}],"request":{"battery":{"present":false},"forecast":{"baseload":{"model":"none","p50W":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null],"quality":{"commandedMinutes":0,"computedAt":"2026-10-17T09:36:17Z","daysCovered":{"weekend":0,"workday":0},"incompleteMinutes":0,"loads":[],"minDays":7,"minutes":0,"negativeMinutes":0,"resolutionS":3600,"windowDays":28}},"price":null,"pv":null,"resolutionS":3600,"start":"2026-06-07T22:00:00Z","steps":48,"weather":null},"loads":[{"adapter":"etmvariableload","declared":{"maxPowerW":3000,"minPowerW":0},"id":"{29e276f9-bbf5-403f-9107-182727dcb647}","label":"Routeur","limits":{"minOffS":0,"minOnS":0},"priority":2,"telemetry":{"available":true,"currentPowerW":0,"lockMaxPowerW":-1,"lockMinPowerW":0,"measurement":{"powerW":0,"source":"device"}}},{"adapter":"relay-router","declared":{"maxPowerW":1000,"minPowerW":1000},"id":"ecs","label":"ECS","limits":{"minOffS":0,"minOnS":0},"priority":1,"telemetry":{"available":true,"currentPowerW":1000,"lockMaxPowerW":1000,"lockMinPowerW":0,"measurement":{"powerW":1000,"source":"device"}}}],"meter":{"exportW":3000,"importW":0,"perPhaseA":[0,0,0],"source":"{de56ba36-d8b9-4ce4-baf6-7cc2707e1352}"},"pv":{"currentW":null},"site":{"contractedPowerW":null,"gridCapW":null,"phaseLimitA":null},"tariff":null,"timestamp":"2026-06-08T13:01:00Z","trigger":"cycle"},"response":{"result":{"computedAt":"2026-06-08T13:01:00Z","slots":[{"actions":[{"kind":"setpoint","loadId":"ecs","powerW":1000,"reasonCode":"SURPLUS_SETPOINT","reasonParams":{"budgetW":4000,"setpointW":1000,"stepped":true}},{"kind":"setpoint","loadId":"{29e276f9-bbf5-403f-9107-182727dcb647}","powerW":3000,"reasonCode":"SURPLUS_SETPOINT","reasonParams":{"budgetW":3000,"setpointW":3000,"stepped":false}}],"from":"2026-06-08T13:01:00Z","to":"2026-06-08T13:02:00Z"}],"strategy":"rules","validUntil":"2026-06-08T13:02:00Z"}},"strategy":"rules","t":"2026-06-08T15:01:00+02:00"} +{"executed":[],"strategy":"none","t":"2026-06-08T15:04:00+02:00"} diff --git a/tests/tools/data/etm-powersync-trace-repli.ndjson b/tests/tools/data/etm-powersync-trace-repli.ndjson new file mode 100644 index 0000000..6a7e886 --- /dev/null +++ b/tests/tools/data/etm-powersync-trace-repli.ndjson @@ -0,0 +1,4 @@ +{"executed":[{"clamped":false,"kind":"setpoint","loadId":"ecs","powerW":1000},{"clamped":false,"kind":"setpoint","loadId":"{15f393fa-d979-454c-8a0d-cfbfefa8b0df}","powerW":2000}],"latencyMs":1,"request":{"battery":{"present":false},"forecast":{"baseload":{"model":"none","p50W":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null]},"price":null,"pv":null,"resolutionS":3600,"start":"2026-06-07T22:00:00Z","steps":48,"weather":null},"loads":[{"adapter":"etmvariableload","declared":{"maxPowerW":3000,"minPowerW":0},"id":"{15f393fa-d979-454c-8a0d-cfbfefa8b0df}","label":"Routeur","limits":{"minOffS":0,"minOnS":0},"priority":2,"telemetry":{"available":true,"currentPowerW":0,"lockMaxPowerW":-1,"lockMinPowerW":0,"measurement":{"powerW":0,"source":"device"}}},{"adapter":"relay-router","declared":{"maxPowerW":1000,"minPowerW":1000},"id":"ecs","label":"ECS","limits":{"minOffS":0,"minOnS":0},"priority":1,"telemetry":{"available":true,"currentPowerW":0,"lockMaxPowerW":1000,"lockMinPowerW":0,"measurement":{"powerW":0,"source":"device"}}}],"meter":{"exportW":3000,"importW":0,"perPhaseA":[0,0,0],"source":"{33aa876f-a070-4de6-9b48-9378a80fd552}"},"pv":{"currentW":null},"site":{"contractedPowerW":null,"gridCapW":null,"phaseLimitA":null},"tariff":null,"timestamp":"2026-06-08T13:00:00Z","trigger":"cycle"},"response":{"result":{"abstain":true,"reason":"recalcul"}},"strategy":"rules","t":"2026-06-08T15:00:00+02:00"} +{"executed":[{"clamped":false,"kind":"setpoint","loadId":"ecs","powerW":1000},{"clamped":false,"kind":"setpoint","loadId":"{15f393fa-d979-454c-8a0d-cfbfefa8b0df}","powerW":3000}],"latencyMs":0,"request":{"battery":{"present":false},"forecast":{"baseload":{"model":"none","p50W":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null],"quality":{"commandedMinutes":0,"computedAt":"2026-10-17T09:36:19Z","daysCovered":{"weekend":0,"workday":0},"incompleteMinutes":0,"loads":[],"minDays":7,"minutes":0,"negativeMinutes":0,"resolutionS":3600,"windowDays":28}},"price":null,"pv":null,"resolutionS":3600,"start":"2026-06-07T22:00:00Z","steps":48,"weather":null},"loads":[{"adapter":"etmvariableload","declared":{"maxPowerW":3000,"minPowerW":0},"id":"{15f393fa-d979-454c-8a0d-cfbfefa8b0df}","label":"Routeur","limits":{"minOffS":0,"minOnS":0},"priority":2,"telemetry":{"available":true,"currentPowerW":0,"lockMaxPowerW":-1,"lockMinPowerW":0,"measurement":{"powerW":0,"source":"device"}}},{"adapter":"relay-router","declared":{"maxPowerW":1000,"minPowerW":1000},"id":"ecs","label":"ECS","limits":{"minOffS":0,"minOnS":0},"priority":1,"telemetry":{"available":true,"currentPowerW":1000,"lockMaxPowerW":1000,"lockMinPowerW":0,"measurement":{"powerW":1000,"source":"device"}}}],"meter":{"exportW":3000,"importW":0,"perPhaseA":[0,0,0],"source":"{33aa876f-a070-4de6-9b48-9378a80fd552}"},"pv":{"currentW":null},"site":{"contractedPowerW":null,"gridCapW":null,"phaseLimitA":null},"tariff":null,"timestamp":"2026-06-08T13:01:00Z","trigger":"cycle"},"response":{"error":{"code":"rpc-error"}},"strategy":"rules","t":"2026-06-08T15:01:00+02:00"} +{"executed":[{"clamped":false,"kind":"setpoint","loadId":"ecs","powerW":1000},{"clamped":false,"kind":"setpoint","loadId":"{15f393fa-d979-454c-8a0d-cfbfefa8b0df}","powerW":3000}],"request":{"battery":{"present":false},"forecast":{"baseload":{"model":"none","p50W":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null],"quality":{"commandedMinutes":0,"computedAt":"2026-10-17T09:36:19Z","daysCovered":{"weekend":0,"workday":0},"incompleteMinutes":0,"loads":[],"minDays":7,"minutes":0,"negativeMinutes":0,"resolutionS":3600,"windowDays":28}},"price":null,"pv":null,"resolutionS":3600,"start":"2026-06-07T22:00:00Z","steps":48,"weather":null},"loads":[{"adapter":"etmvariableload","declared":{"maxPowerW":3000,"minPowerW":0},"id":"{15f393fa-d979-454c-8a0d-cfbfefa8b0df}","label":"Routeur","limits":{"minOffS":0,"minOnS":0},"priority":2,"telemetry":{"available":true,"currentPowerW":0,"lockMaxPowerW":-1,"lockMinPowerW":0,"measurement":{"powerW":0,"source":"device"}}},{"adapter":"relay-router","declared":{"maxPowerW":1000,"minPowerW":1000},"id":"ecs","label":"ECS","limits":{"minOffS":0,"minOnS":0},"priority":1,"telemetry":{"available":true,"currentPowerW":1000,"lockMaxPowerW":1000,"lockMinPowerW":0,"measurement":{"powerW":1000,"source":"device"}}}],"meter":{"exportW":3000,"importW":0,"perPhaseA":[0,0,0],"source":"{33aa876f-a070-4de6-9b48-9378a80fd552}"},"pv":{"currentW":null},"site":{"contractedPowerW":null,"gridCapW":null,"phaseLimitA":null},"tariff":null,"timestamp":"2026-06-08T13:02:00Z","trigger":"cycle"},"response":{"error":{"code":"no-response"}},"strategy":"rules","t":"2026-06-08T15:02:00+02:00"} +{"executed":[{"clamped":false,"kind":"setpoint","loadId":"ecs","powerW":1000},{"clamped":false,"kind":"setpoint","loadId":"{15f393fa-d979-454c-8a0d-cfbfefa8b0df}","powerW":3000}],"request":{"battery":{"present":false},"forecast":{"baseload":{"model":"none","p50W":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null],"quality":{"commandedMinutes":0,"computedAt":"2026-10-17T09:36:19Z","daysCovered":{"weekend":0,"workday":0},"incompleteMinutes":0,"loads":[],"minDays":7,"minutes":0,"negativeMinutes":0,"resolutionS":3600,"windowDays":28}},"price":null,"pv":null,"resolutionS":3600,"start":"2026-06-07T22:00:00Z","steps":48,"weather":null},"loads":[{"adapter":"etmvariableload","declared":{"maxPowerW":3000,"minPowerW":0},"id":"{15f393fa-d979-454c-8a0d-cfbfefa8b0df}","label":"Routeur","limits":{"minOffS":0,"minOnS":0},"priority":2,"telemetry":{"available":true,"currentPowerW":0,"lockMaxPowerW":-1,"lockMinPowerW":0,"measurement":{"powerW":0,"source":"device"}}},{"adapter":"relay-router","declared":{"maxPowerW":1000,"minPowerW":1000},"id":"ecs","label":"ECS","limits":{"minOffS":0,"minOnS":0},"priority":1,"telemetry":{"available":true,"currentPowerW":1000,"lockMaxPowerW":1000,"lockMinPowerW":0,"measurement":{"powerW":1000,"source":"device"}}}],"meter":{"exportW":3000,"importW":0,"perPhaseA":[0,0,0],"source":"{33aa876f-a070-4de6-9b48-9378a80fd552}"},"pv":{"currentW":null},"site":{"contractedPowerW":null,"gridCapW":null,"phaseLimitA":null},"tariff":null,"timestamp":"2026-06-08T13:03:00Z","trigger":"cycle"},"response":{"error":{"code":"no-response"}},"strategy":"rules","t":"2026-06-08T15:03:00+02:00"} diff --git a/tests/tools/etm-powersync-trace-replay-test.py b/tests/tools/etm-powersync-trace-replay-test.py new file mode 100644 index 0000000..a1faa1b --- /dev/null +++ b/tests/tools/etm-powersync-trace-replay-test.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +"""Test de tools/etm-powersync-trace-replay (brief 4 T6). + + python3 -I tests/tools/etm-powersync-trace-replay-test.py + +Le serveur ci-dessous n'est PAS l'optimiseur de référence (T7) : il ne planifie rien, il RENVOIE +la réponse enregistrée dans la trace pour la requête reçue (reconnue par son `timestamp`), et +une abstention quand la réponse enregistrée est une erreur. Rejouer une trace contre lui doit +donc donner zéro divergence ; une réponse modifiée doit être vue. + +Les traces rejouées : une trace synthétique (toutes les formes de ligne), et les traces RÉELLES +du moteur sous tests/tools/data/ (exportées par la suite `socketscheduler`, +ETM_POWERSYNC_TRACE_EXPORT). +""" + +import glob +import json +import os +import socket +import subprocess +import sys +import tempfile +import threading +import unittest + +ICI = os.path.dirname(os.path.abspath(__file__)) +OUTIL = os.path.join(ICI, '..', '..', 'tools', 'etm-powersync-trace-replay') +DONNEES = os.path.join(ICI, 'data') +SECRET = 'T6-secret"de\\test-0123456789' + + +class ServeurEnregistre: + """Serveur NDJSON / JSON-RPC 2.0 minimal : handshake, puis GetPlan → réponse enregistrée.""" + + def __init__(self, trace, adresse, modifier=None): + self.reponses = {} + with open(trace, encoding='utf-8') as f: + for l in f: + if not l.strip(): + continue + d = json.loads(l) + req = d.get('request') + if isinstance(req, dict): + self.reponses[req.get('timestamp')] = d.get('response') + self.modifier = modifier # timestamp dont la réponse est altérée + self.tokens = [] + self.requetes = 0 + if isinstance(adresse, str): + self.ecoute = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + else: + self.ecoute = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.ecoute.bind(adresse) + self.ecoute.listen(1) + self.adresse = self.ecoute.getsockname() + self.fil = threading.Thread(target=self.servir, daemon=True) + self.fil.start() + + def repondre(self, req): + enregistree = self.reponses.get(req.get('timestamp')) + if not isinstance(enregistree, dict) or 'result' not in enregistree: + return {'abstain': True, 'reason': 'aucune réponse enregistrée'} + res = json.loads(json.dumps(enregistree['result'])) + if req.get('timestamp') == self.modifier: + for c in res.get('slots') or []: + for a in c.get('actions') or []: + a['powerW'] = float(a.get('powerW') or 0) + 250 + return res + + def servir(self): + conn, _ = self.ecoute.accept() + tampon = b'' + with conn: + while True: + morceau = conn.recv(65536) + if not morceau: + return + tampon += morceau + while b'\n' in tampon: + ligne, _, tampon = tampon.partition(b'\n') + m = json.loads(ligne) + if m.get('method') == 'Handshake': + self.tokens.append(m['params'].get('token')) + res = {'protocolVersion': '1.1', 'name': 'serveur-de-test', 'planning': False} + else: + self.requetes += 1 + res = self.repondre(m.get('params') or {}) + conn.sendall(json.dumps({'jsonrpc': '2.0', 'id': m['id'], 'result': res}).encode() + b'\n') + + def fermer(self): + self.ecoute.close() + + +def trace_synthetique(chemin): + """Une ligne de chaque forme : rule-based, optimiseur (2 créneaux), abstention, erreur + enregistrée (non comparable), L2 (sans requête).""" + def req(ts): + return {'timestamp': ts, 'trigger': 'cycle', 'loads': [{'id': 'ecs'}, {'id': 'pac'}]} + + def creneau(de, a, actions): + return {'from': de, 'to': a, 'actions': actions} + + lignes = [ + {'t': '2026-06-08T15:00:00+02:00', 'request': req('2026-06-08T13:00:00Z'), + 'response': {'result': {'strategy': 'rules', 'slots': [creneau( + '2026-06-08T13:00:00Z', '2026-06-08T13:01:00Z', + [{'loadId': 'ecs', 'kind': 'setpoint', 'powerW': 1000, 'reasonCode': 'SURPLUS_SETPOINT'}, + {'loadId': 'pac', 'kind': 'state', 'powerW': 0, 'state': 2, 'reasonCode': 'SGREADY_NORMAL'}])]}}, + 'executed': [], 'strategy': 'rules'}, + {'t': '2026-06-08T15:01:00+02:00', 'request': req('2026-06-08T13:01:00Z'), + 'response': {'result': {'planId': 'p', 'strategy': 'x', 'diagnostics': {'computeMs': 12}, + 'slots': [creneau('2026-06-08T12:00:00Z', '2026-06-08T13:00:00Z', []), + creneau('2026-06-08T13:00:00Z', '2026-06-08T14:00:00Z', + [{'loadId': 'ecs', 'kind': 'setpoint', 'powerW': 1500, + 'reason': 'texte libre'}])]}}, + 'executed': [], 'strategy': 'optimizer', 'latencyMs': 15}, + {'t': '2026-06-08T15:02:00+02:00', 'request': req('2026-06-08T13:02:00Z'), + 'response': {'result': {'abstain': True, 'reason': 'recalcul', 'diagnostics': {'computeMs': 40}}}, + 'executed': [], 'strategy': 'rules', 'latencyMs': 41}, + {'t': '2026-06-08T15:03:00+02:00', 'request': req('2026-06-08T13:03:00Z'), + 'response': {'error': {'code': 'no-response'}}, 'executed': [], 'strategy': 'rules'}, + {'t': '2026-06-08T15:04:00+02:00', 'executed': [], 'strategy': 'none'}, + ] + with open(chemin, 'w', encoding='utf-8') as f: + for l in lignes: + f.write(json.dumps(l, separators=(',', ':')) + '\n') + + +class TestTraceReplay(unittest.TestCase): + + def setUp(self): + self.dossier = tempfile.TemporaryDirectory() + self.d = self.dossier.name + self.trace = os.path.join(self.d, 'etm-powersync-optimizer-trace-2026-06-08.ndjson') + trace_synthetique(self.trace) + self.token = os.path.join(self.d, 'token') + with open(self.token, 'w') as f: + f.write(SECRET + '\n') + os.chmod(self.token, 0o600) + self.serveurs = [] + + def tearDown(self): + for s in self.serveurs: + s.fermer() + self.dossier.cleanup() + + def serveur(self, trace, modifier=None, tcp=False): + adresse = ('127.0.0.1', 0) if tcp else os.path.join(self.d, f'opt-{len(self.serveurs)}.sock') + s = ServeurEnregistre(trace, adresse, modifier) + self.serveurs.append(s) + endpoint = f'tcp://127.0.0.1:{s.adresse[1]}' if tcp else f'unix://{adresse}' + return s, endpoint + + def rejouer(self, *args): + p = subprocess.run([sys.executable, '-I', OUTIL, *args], capture_output=True, text=True, timeout=60) + return p.returncode, p.stdout, p.stderr + + def test_zero_divergence_sur_unix(self): + s, endpoint = self.serveur(self.trace) + code, out, err = self.rejouer(self.trace, '--endpoint', endpoint, '--token-file', self.token, + '--fail-on-divergence') + self.assertEqual(code, 0, out + err) + self.assertIn('requêtes rejouées : 4', out) + self.assertIn('abstentions : 2 (50.0 %)', out) # l'abstention, et la réponse à l'erreur + self.assertIn('computeMs : médian 26, max 40', out) + self.assertIn('cycles non comparables (réponse enregistrée en erreur, ou rejouée en erreur) : 1', out) + self.assertIn('cycles dont les actions diffèrent du plan enregistré : 0', out) + self.assertEqual(s.requetes, 4) # la ligne L2 n'est pas rejouée + # Le token voyage dans le handshake, et nulle part ailleurs. + self.assertEqual(s.tokens, [SECRET]) + sorties = glob.glob(os.path.join(self.d, '*.replay-*')) + self.assertEqual(len(sorties), 2, sorties) # réponses + résumé, à côté de la trace + for chemin in sorties: + with open(chemin, encoding='utf-8') as f: + self.assertNotIn('0123456789', f.read()) + self.assertNotIn('0123456789', out + err) + with open([x for x in sorties if x.endswith('.ndjson')][0], encoding='utf-8') as f: + reponses = [json.loads(l) for l in f] + self.assertEqual([r['line'] for r in reponses], [1, 2, 3, 4]) + self.assertFalse(any(r['divergent'] for r in reponses)) + + def test_divergence_detectee_et_code_non_nul(self): + s, endpoint = self.serveur(self.trace, modifier='2026-06-08T13:01:00Z') + code, out, err = self.rejouer(self.trace, '--endpoint', endpoint, '--fail-on-divergence') + self.assertEqual(code, 1, out + err) + self.assertIn('cycles dont les actions diffèrent du plan enregistré : 1', out) + self.assertIn('ligne 2 (2026-06-08T15:01:00+02:00) : ecs : 1500 W → 1750.0 W', out) + # Sans l'option : la divergence est dite, le code reste 0. + s2, endpoint2 = self.serveur(self.trace, modifier='2026-06-08T13:01:00Z') + code, out, _ = self.rejouer(self.trace, '--endpoint', endpoint2) + self.assertEqual(code, 0) + self.assertIn('diffèrent du plan enregistré : 1', out) + + def test_tcp_accepte(self): + _, endpoint = self.serveur(self.trace, tcp=True) + code, out, err = self.rejouer(self.trace, '--endpoint', endpoint, '--fail-on-divergence') + self.assertEqual(code, 0, out + err) + self.assertIn('diffèrent du plan enregistré : 0', out) + + def test_jamais_d_ecrasement(self): + _, endpoint = self.serveur(self.trace) + sortie = os.path.join(self.d, 'deja-la.ndjson') + with open(sortie, 'w') as f: + f.write('à garder\n') + code, _, err = self.rejouer(self.trace, '--endpoint', endpoint, '--out', sortie) + self.assertEqual(code, 2) + self.assertIn('refus d\'écraser', err) + with open(sortie) as f: + self.assertEqual(f.read(), 'à garder\n') + + def test_endpoint_absent(self): + code, _, err = self.rejouer(self.trace, '--endpoint', 'unix://' + os.path.join(self.d, 'absent.sock')) + self.assertEqual(code, 2) + self.assertIn('connexion impossible', err) + + def test_traces_reelles_du_moteur(self): + fichiers = sorted(glob.glob(os.path.join(DONNEES, 'etm-powersync-trace-*.ndjson'))) + self.assertTrue(fichiers, 'aucune trace réelle sous tests/tools/data') + for trace in fichiers: + with self.subTest(trace=os.path.basename(trace)): + _, endpoint = self.serveur(trace) + out_dir = tempfile.mkdtemp(dir=self.d) + code, out, err = self.rejouer(trace, '--endpoint', endpoint, '--fail-on-divergence', + '--out', os.path.join(out_dir, 'r.ndjson')) + self.assertEqual(code, 0, out + err) + self.assertIn('diffèrent du plan enregistré : 0', out) + self.assertNotIn('requêtes rejouées : 0', out) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..0a0d6d3 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,42 @@ +# tools/ + +Outils de développement et d'exploitation du plugin. Ce fichier ne décrit que +`etm-powersync-trace-replay` ; les autres scripts portent leur mode d'emploi en en-tête. + +## `etm-powersync-trace-replay` — rejouer des traces de décision + +Python 3, bibliothèque standard seule, GPL-3.0 comme le dépôt. + +Le moteur écrit, quand le réglage `optimizerTrace` est activé (`NymeaEnergy.SetOptimizerTrace`, +défaut : désactivé), une ligne NDJSON par cycle dans +`/etm-powersync-optimizer-trace-AAAA-MM-JJ.ndjson` (format : +`INTERFACE.md`, « Traces de décision » ; protocole : `docs/OPTIMIZER_PROTOCOL.md` annexe D). +L'outil renvoie chaque `request` d'un de ces fichiers à un optimiseur et compare ses actions au +plan enregistré. + +```sh +# optimiseur local (défaut : unix:///run/powersync/optimizer.sock) +./tools/etm-powersync-trace-replay etm-powersync-optimizer-trace-2026-10-17.ndjson + +# optimiseur sur le LAN, avec token, échec si une action diffère +./tools/etm-powersync-trace-replay trace.ndjson --endpoint tcp://nas:7600 \ + --token-file ~/.config/etm-optimizer.token --fail-on-divergence +``` + +- **Token** : seulement par `--token-file` (jamais en argument : il se verrait dans `ps`). + Il n'est ni affiché ni écrit. +- **Sorties** : `.replay-.ndjson` (une ligne par requête rejouée : réponse, + latence, écarts) et `….summary.txt`, à côté de la trace. Jamais d'écrasement (`--out` vers un + fichier existant est refusé). +- **Résumé** : requêtes rejouées, taux d'abstention, `computeMs` médian et max (s'il est fourni + dans `diagnostics`), cycles non comparables (réponse enregistrée en erreur), cycles dont les + actions du créneau courant diffèrent du plan enregistré, avec la liste. +- **Code de sortie** : 0 ; 1 avec `--fail-on-divergence` s'il y a divergence ; 2 sur erreur + (fichier, connexion, handshake refusé). +- Les lignes L2 (`strategy: "none"`) n'ont pas de requête : elles ne sont pas rejouées. + +Les traces contiennent des mesures du site : elles **restent sur le site**. L'export est manuel +(copie du fichier par l'installateur) ; l'outil ne parle qu'à l'endpoint donné. + +Test : `python3 -I tests/tools/etm-powersync-trace-replay-test.py` (serveur de test minimal qui +renvoie les réponses enregistrées — ce n'est pas l'optimiseur de référence). diff --git a/tools/etm-powersync-trace-replay b/tools/etm-powersync-trace-replay new file mode 100755 index 0000000..ea045d8 --- /dev/null +++ b/tools/etm-powersync-trace-replay @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +"""Rejoue un fichier de traces de décision vers un optimiseur (OPTIMIZER_PROTOCOL.md annexe D). + + etm-powersync-trace-replay TRACE.ndjson [--endpoint unix:///run/powersync/optimizer.sock] + [--token-file FICHIER] [--out SORTIE.ndjson] [--timeout S] + [--tolerance-w W] [--fail-on-divergence] + +Chaque ligne de TRACE qui porte une `request` est renvoyée, telle quelle, en `GetPlan` à +l'optimiseur (après un `Handshake`). Les réponses sont écrites dans un NOUVEAU fichier, à côté +de la trace (jamais d'écrasement), puis un résumé : requêtes, taux d'abstention, `computeMs` +médian et max, cycles dont les actions diffèrent du plan enregistré. + +« Actions du plan » : celles du créneau qui couvre l'instant de la requête (`timestamp`) — +charge, kind, puissance (à --tolerance-w près), état SG-Ready. Une abstention (`abstain`, aucun +créneau, aucun créneau courant) n'a pas d'actions. Une ligne dont la réponse enregistrée est une +erreur (`no-response`, `timeout`…) n'est pas comparable : comptée à part. + +Le token se lit dans un FICHIER (--token-file), jamais en argument : une ligne de commande se +voit dans `ps` et dans l'historique du shell. Il n'est jamais affiché. + +Les traces contiennent des mesures du site : elles restent sur le site. Ce script n'envoie rien +ailleurs qu'à l'endpoint donné. + +Code de sortie : 0 ; 1 s'il y a divergence ET --fail-on-divergence ; 2 sur erreur (fichier, +connexion, handshake refusé). +""" + +import argparse +import json +import os +import socket +import statistics +import sys +import time + +ENDPOINT_DEFAUT = 'unix:///run/powersync/optimizer.sock' +MAJEURE = '1' +VERSION = '1.1' + + +class Erreur(Exception): + """Erreur fatale : message pour l'utilisateur, code de sortie 2.""" + + +def ouvrir_endpoint(endpoint, timeout): + if endpoint.startswith('unix://'): + chemin = endpoint[len('unix://'):] + if not chemin.startswith('/'): + raise Erreur(f'endpoint unix sans chemin absolu : {endpoint}') + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + cible = chemin + elif endpoint.startswith('tcp://'): + reste = endpoint[len('tcp://'):].rstrip('/') + if reste.startswith('['): # IPv6 entre crochets + hote, _, port = reste[1:].partition(']:') + else: + hote, _, port = reste.rpartition(':') + if not hote or not port.isdigit(): + raise Erreur(f'endpoint tcp illisible (tcp://hôte:port attendu) : {endpoint}') + famille = socket.AF_INET6 if ':' in hote else socket.AF_INET + s = socket.socket(famille, socket.SOCK_STREAM) + cible = (hote, int(port)) + else: + raise Erreur(f'endpoint inconnu (unix:///chemin ou tcp://hôte:port) : {endpoint}') + s.settimeout(timeout) + try: + s.connect(cible) + except OSError as e: + s.close() + raise Erreur(f'connexion impossible à {endpoint} : {e.strerror or e}') + return s + + +class Client: + """JSON-RPC 2.0 sur NDJSON, une requête à la fois.""" + + def __init__(self, sock): + self.sock = sock + self.tampon = b'' + self.id = 0 + + def appeler(self, methode, params): + self.id += 1 + envoi = json.dumps({'jsonrpc': '2.0', 'id': self.id, 'method': methode, 'params': params}, + separators=(',', ':'), ensure_ascii=False).encode() + b'\n' + self.sock.sendall(envoi) + while True: + message = self.lire() + if isinstance(message, dict) and message.get('id') == self.id: + return message + # notification (UpdateLearned…) ou réponse étrangère : ignorée + + def lire(self): + while b'\n' not in self.tampon: + morceau = self.sock.recv(65536) + if not morceau: + raise ConnectionError('connexion fermée par l\'optimiseur') + self.tampon += morceau + ligne, _, self.tampon = self.tampon.partition(b'\n') + try: + return json.loads(ligne) + except ValueError: + return None + + +def lire_token(chemin): + if not chemin: + return None + try: + with open(chemin, 'rb') as f: + token = f.read(8192).strip() + except OSError as e: + raise Erreur(f'fichier de token illisible : {chemin} : {e.strerror}') + return token.decode('utf-8', 'replace') or None + + +def handshake(client, token): + params = {'protocolVersion': VERSION, 'engine': 'etm-powersync-trace-replay'} + if token: + params['token'] = token + r = client.appeler('Handshake', params) + if 'error' in r: + # Seul le CODE : un serveur peut recopier le token refusé dans son message. + raise Erreur(f'handshake refusé (erreur JSON-RPC {r["error"].get("code")})') + res = r.get('result') + if not isinstance(res, dict) or not isinstance(res.get('protocolVersion'), str): + raise Erreur('handshake : réponse sans protocolVersion') + if res['protocolVersion'].split('.')[0] != MAJEURE: + raise Erreur(f'handshake : optimiseur en {res["protocolVersion"][:32]}, majeure {MAJEURE} attendue') + return res + + +def instant(texte): + from datetime import datetime + if not isinstance(texte, str): + return None + try: + return datetime.fromisoformat(texte.replace('Z', '+00:00')) + except ValueError: + return None + + +def actions_courantes(reponse, maintenant): + """None si la réponse n'est pas comparable (erreur) ; [] pour une abstention ; sinon la + liste triée des actions du créneau couvrant `maintenant`.""" + if not isinstance(reponse, dict) or 'result' not in reponse: + return None + res = reponse['result'] + if not isinstance(res, dict) or res.get('abstain') is True: + return [] + for creneau in res.get('slots') or []: + debut, fin = instant(creneau.get('from')), instant(creneau.get('to')) + if maintenant is None or debut is None or fin is None or not (debut <= maintenant < fin): + continue + actions = [] + for a in creneau.get('actions') or []: + actions.append((str(a.get('loadId')), str(a.get('kind', 'setpoint')), + a.get('powerW'), a.get('state'))) + return sorted(actions, key=lambda x: (x[0], x[1])) + return [] + + +def differences(attendu, obtenu, tolerance): + """Liste lisible des écarts entre deux listes d'actions (vide = identiques).""" + if attendu == [] and obtenu == []: + return [] + if (attendu == []) != (obtenu == []): + return [('abstention' if attendu == [] else 'plan') + ' → ' + + ('abstention' if obtenu == [] else 'plan')] + da = {(a[0], a[1]): a for a in attendu} + do = {(o[0], o[1]): o for o in obtenu} + ecarts = [] + for cle in sorted(set(da) | set(do)): + a, o = da.get(cle), do.get(cle) + if a is None or o is None: + ecarts.append(f'{cle[0]} : {"absente" if a is None else "présente"} → ' + f'{"absente" if o is None else "présente"}') + continue + pa, po = a[2], o[2] + if (pa is None) != (po is None) or (pa is not None and abs(float(pa) - float(po)) > tolerance): + ecarts.append(f'{cle[0]} : {pa} W → {po} W') + if a[3] != o[3]: + ecarts.append(f'{cle[0]} : état {a[3]} → {o[3]}') + return ecarts + + +def sortie_libre(trace): + base = trace[:-len('.ndjson')] if trace.endswith('.ndjson') else trace + horodatage = time.strftime('%Y%m%dT%H%M%S') + # Deux rejeux dans la même seconde : suffixe, jamais d'écrasement (ni des réponses, ni du résumé). + for n in range(1, 1000): + nom = f'{base}.replay-{horodatage}' + (f'-{n}' if n > 1 else '') + if not os.path.exists(nom + '.ndjson') and not os.path.exists(nom + '.summary.txt'): + return nom + '.ndjson' + raise Erreur('aucun nom de sortie libre à côté de la trace') + + +def main(argv=None): + p = argparse.ArgumentParser(prog='etm-powersync-trace-replay', + description='Rejoue un fichier de traces de décision vers un optimiseur.') + p.add_argument('trace', help='fichier de traces (NDJSON, annexe D)') + p.add_argument('--endpoint', default=ENDPOINT_DEFAUT, + help=f'unix:///chemin ou tcp://hôte:port (défaut : {ENDPOINT_DEFAUT})') + p.add_argument('--token-file', help='fichier contenant le token (jamais en argument)') + p.add_argument('--out', help='fichier des réponses (défaut : à côté de la trace) ; jamais écrasé') + p.add_argument('--timeout', type=float, default=30.0, help='délai par requête, en s (défaut 30)') + p.add_argument('--tolerance-w', type=float, default=0.5, + help='écart de puissance toléré, en W (défaut 0,5)') + p.add_argument('--fail-on-divergence', action='store_true', + help='code de sortie 1 si une action diffère du plan enregistré') + args = p.parse_args(argv) + + try: + with open(args.trace, encoding='utf-8') as f: + lignes = [(n, l) for n, l in enumerate(f, 1) if l.strip()] + except OSError as e: + print(f'trace illisible : {args.trace} : {e.strerror}', file=sys.stderr) + return 2 + + try: + sortie = args.out or sortie_libre(args.trace) + except Erreur as e: + print(f'erreur : {e}', file=sys.stderr) + return 2 + resume_chemin = (sortie[:-len('.ndjson')] if sortie.endswith('.ndjson') else sortie) + '.summary.txt' + try: + token = lire_token(args.token_file) + sock = ouvrir_endpoint(args.endpoint, args.timeout) + client = Client(sock) + identite = handshake(client, token) + out = open(sortie, 'x', encoding='utf-8') # 'x' : jamais d'écrasement + except FileExistsError: + print(f'refus d\'écraser {sortie}', file=sys.stderr) + return 2 + except (Erreur, OSError, ConnectionError) as e: + print(f'erreur : {e}', file=sys.stderr) + return 2 + + requetes = abstentions = erreurs = illisibles = non_comparables = 0 + compute = [] + divergents = [] + with out: + for n, brute in lignes: + try: + ligne = json.loads(brute) + except ValueError: + illisibles += 1 + continue + requete = ligne.get('request') if isinstance(ligne, dict) else None + if not isinstance(requete, dict): + continue # cycle L2 (`strategy: none`) : rien à rejouer + requetes += 1 + t0 = time.monotonic() + try: + r = client.appeler('GetPlan', requete) + except (OSError, ConnectionError) as e: + print(f'erreur à la ligne {n} : {e}', file=sys.stderr) + return 2 + latence = round((time.monotonic() - t0) * 1000) + reponse = {'result': r['result']} if 'result' in r else \ + {'error': {'code': (r.get('error') or {}).get('code')}} + maintenant = instant(requete.get('timestamp')) + attendu = actions_courantes(ligne.get('response'), maintenant) + obtenu = actions_courantes(reponse, maintenant) + if obtenu is None: + erreurs += 1 + elif obtenu == []: + abstentions += 1 + res = reponse.get('result') + if isinstance(res, dict): + cms = (res.get('diagnostics') or {}).get('computeMs') + if isinstance(cms, (int, float)) and not isinstance(cms, bool): + compute.append(cms) + ecarts = None + if attendu is None or obtenu is None: + non_comparables += 1 + else: + ecarts = differences(attendu, obtenu, args.tolerance_w) + if ecarts: + divergents.append((n, ligne.get('t'), ecarts)) + out.write(json.dumps({'line': n, 't': ligne.get('t'), 'response': reponse, + 'latencyMs': latence, 'divergent': bool(ecarts), + 'differences': ecarts or []}, + separators=(',', ':'), ensure_ascii=False) + '\n') + sock.close() + + resume = [f'trace : {args.trace}', + f'optimiseur : {str(identite.get("name", "?"))[:64]} ({str(identite.get("protocolVersion"))[:32]})' + f' sur {args.endpoint}', + f'réponses : {sortie}', + f'requêtes rejouées : {requetes}' + (f' ({illisibles} ligne(s) illisible(s) ignorée(s))' if illisibles else ''), + f'abstentions : {abstentions} ({(100.0 * abstentions / requetes) if requetes else 0:.1f} %)', + f'erreurs : {erreurs}', + ('computeMs : médian ' + f'{statistics.median(compute):g}, max {max(compute):g} ' + f'({len(compute)} réponse(s) qui le donnent)') if compute else 'computeMs : non fourni', + f'cycles non comparables (réponse enregistrée en erreur, ou rejouée en erreur) : {non_comparables}', + f'cycles dont les actions diffèrent du plan enregistré : {len(divergents)}'] + for n, t, ecarts in divergents: + resume.append(f' ligne {n} ({t}) : ' + ' ; '.join(ecarts)) + texte = '\n'.join(resume) + '\n' + sys.stdout.write(texte) + try: + with open(resume_chemin, 'x', encoding='utf-8') as f: + f.write(texte) + except OSError as e: + print(f'résumé non écrit ({resume_chemin}) : {e.strerror}', file=sys.stderr) + return 1 if (divergents and args.fail_on_divergence) else 0 + + +if __name__ == '__main__': + sys.exit(main())