fix(tools): trace-replay compare une action state sur son état seul (brief 4 T7)

Le plan du rule-based tracé porte la puissance ESTIMÉE d'une action state ; un optimiseur ne
la donne pas (§6 ne la définit pas). Le rejeu d'une trace rule-based contre l'optimiseur de
référence voyait donc une divergence sur chaque PAC. Seuls l'état (state) et la puissance
d'une consigne (setpoint) sont comparés. Test unitaire ajouté (7 / 0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-17 13:16:43 +02:00
parent 6fcce1a470
commit f42dc709e3
2 changed files with 37 additions and 3 deletions

View File

@ -228,5 +228,35 @@ class TestTraceReplay(unittest.TestCase):
self.assertNotIn('requêtes rejouées : 0', out)
class TestComparaison(unittest.TestCase):
"""La comparaison seule (fonctions de l'outil, sans serveur)."""
@classmethod
def setUpClass(cls):
from importlib.machinery import SourceFileLoader
cls.outil = SourceFileLoader('trace_replay', OUTIL).load_module()
def test_action_state_comparee_sur_son_etat_seul(self):
# Brief 4 T7 : le plan du rule-based porte la puissance ESTIMÉE d'une action `state`,
# l'optimiseur de référence ne la donne pas (le §6 ne la définit pas) — ce n'est pas un écart.
o = self.outil
maintenant = o.instant('2026-06-08T13:00:00Z')
creneau = {'from': '2026-06-08T13:00:00Z', 'to': '2026-06-08T13:01:00Z'}
regles = {'result': {'slots': [dict(creneau, actions=[
{'loadId': 'pac', 'kind': 'state', 'state': 3, 'powerW': 1500},
{'loadId': 'ecs', 'kind': 'setpoint', 'powerW': 1000}])]}}
optimiseur = {'result': {'slots': [dict(creneau, actions=[
{'loadId': 'pac', 'kind': 'state', 'state': 3},
{'loadId': 'ecs', 'kind': 'setpoint', 'powerW': 1000.0}])]}}
a = o.actions_courantes(regles, maintenant)
self.assertEqual(o.differences(a, o.actions_courantes(optimiseur, maintenant), 0.5), [])
optimiseur['result']['slots'][0]['actions'][0]['state'] = 4
self.assertEqual(o.differences(a, o.actions_courantes(optimiseur, maintenant), 0.5), ['pac : état 3 → 4'])
optimiseur['result']['slots'][0]['actions'][0]['state'] = 3
optimiseur['result']['slots'][0]['actions'][1]['powerW'] = 1200
self.assertEqual(o.differences(a, o.actions_courantes(optimiseur, maintenant), 0.5),
['ecs : 1000 W → 1200 W'])
if __name__ == '__main__':
unittest.main(verbosity=2)

View File

@ -13,7 +13,10 @@ de la trace (jamais d'écrasement), puis un résumé : requêtes, taux d'abstent
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
charge, kind, puissance d'une consigne (`setpoint`, à --tolerance-w près), état d'une action
`state` (SG-Ready). La puissance d'une action `state` n'est pas comparée : le protocole ne la
définit pas (§6), et le plan du rule-based la porte (puissance estimée) quand un optimiseur ne la
donne pas. 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.
@ -156,8 +159,9 @@ def actions_courantes(reponse, maintenant):
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')))
kind = str(a.get('kind', 'setpoint'))
actions.append((str(a.get('loadId')), kind,
None if kind == 'state' else a.get('powerW'), a.get('state')))
return sorted(actions, key=lambda x: (x[0], x[1]))
return []