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:
Patrick Schurig 2026-10-17 12:12:25 +02:00
parent 483bbcb027
commit 64a864bfaa
6 changed files with 596 additions and 0 deletions

View 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"}

View 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"}

View 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"}

View 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
View 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
View 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())