feat(tools): etm-powersync-trace-replay, rejeu des traces vers un optimiseur (brief 4 T6)
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 <noreply@anthropic.com>
This commit is contained in:
parent
483bbcb027
commit
64a864bfaa
2
tests/tools/data/etm-powersync-trace-optimiseur.ndjson
Normal file
2
tests/tools/data/etm-powersync-trace-optimiseur.ndjson
Normal file
@ -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"}
|
||||
3
tests/tools/data/etm-powersync-trace-regles.ndjson
Normal file
3
tests/tools/data/etm-powersync-trace-regles.ndjson
Normal file
@ -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"}
|
||||
4
tests/tools/data/etm-powersync-trace-repli.ndjson
Normal file
4
tests/tools/data/etm-powersync-trace-repli.ndjson
Normal file
@ -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"}
|
||||
232
tests/tools/etm-powersync-trace-replay-test.py
Normal file
232
tests/tools/etm-powersync-trace-replay-test.py
Normal file
@ -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)
|
||||
42
tools/README.md
Normal file
42
tools/README.md
Normal file
@ -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
|
||||
`<storagePath>/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** : `<trace>.replay-<horodatage>.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).
|
||||
313
tools/etm-powersync-trace-replay
Executable file
313
tools/etm-powersync-trace-replay
Executable file
@ -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())
|
||||
Loading…
x
Reference in New Issue
Block a user