test(decisions): comparateur étendu à purchasedWh (--achat) et liste des lignes (--lignes)

Le correctif c change une VALEUR PUBLIÉE (progress.purchasedWh), pas une consigne. --achat
retire en plus ce champ de chaque niveau et dit, par scénario et charge, l'ancienne et la
nouvelle valeur (ou « absent ») ; --lignes écrit chaque ligne qui change. Sans option, le
comportement d'etm70 est inchangé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-17 01:51:21 +02:00
parent c81c7b6225
commit c391d13312

View File

@ -2,6 +2,7 @@
# Comparateur « IDENTIQUE HORS MOTIFS » (correctifs moteur a et b, etm70).
#
# etm-powersync-compare-hors-motifs.py <attendu.ndjson[.xz]> <obtenu.ndjson[.xz]> [--cas <sortie.md>]
# [--achat] [--lignes <sortie.txt>]
#
# Un lot qui ne change QUE des motifs doit laisser tout le reste de la référence `decisions`
# intact à l'octet près. Ce script retire de chaque ligne, et SEULEMENT d'elle :
@ -9,10 +10,14 @@
# ligne de cycle, `plan.timeSlots[].actions[].reason` dans une ligne de plan direct ;
# - le manque chiffré d'une clôture non tenue (correctif b) : `params.missingWh` d'un motif
# ECO_FLOOR_MISSED porté par un niveau, et `progress.closedMissingWh` du même niveau.
# Avec --achat (correctif c, etm71), il retire EN PLUS `progress.purchasedWh` de chaque niveau :
# l'acheté se compte désormais sur l'énergie mesurée, et il est absent sans mesure. Un lot qui
# change cette VALEUR PUBLIÉE sans toucher aux consignes doit passer ce mode-là.
# Tout le reste — consignes, powerW, estimatedPowerW, états, relais, courants, budget, draw,
# counts, niveaux et leurs motifs, avancement — doit être IDENTIQUE. Code 0 si c'est le cas,
# 1 sinon (et le premier écart est montré). La liste des motifs qui changent est imprimée, ou
# écrite en Markdown avec --cas.
# écrite en Markdown avec --cas ; --lignes écrit le numéro et le contenu de chaque ligne qui
# change (attendu puis obtenu).
import json
import lzma
import sys
@ -34,22 +39,42 @@ def sans_manque(niveau, cle_motif):
prog.pop('closedMissingWh', None)
def normaliser(ligne):
"""Rend (ligne sans motifs, [(charge, motif retiré)])."""
ABSENT = '(absent)'
def sans_achat(niveau, charge, achats):
prog = niveau.get('progress')
if isinstance(prog, dict):
achats.append((charge, prog.pop('purchasedWh', ABSENT)))
def normaliser(ligne, achat=False):
"""Rend (ligne sans motifs, [(charge, motif retiré)], [(charge, purchasedWh retiré)])."""
d = json.loads(ligne)
retires = []
achats = []
if d.get('src') == 'cycle':
for ch in (d.get('telemetry') or {}).get('loads', []) or []:
retires.append((ch.get('loadId'), ch.pop('decision', None)))
for n in ch.get('levels', []) or []:
sans_manque(n, 'decision')
if achat:
sans_achat(n, ch.get('loadId'), achats)
elif d.get('src') == 'plan':
for s in (d.get('plan') or {}).get('timeSlots', []) or []:
for a in s.get('actions', []) or []:
retires.append((a.get('loadId'), a.pop('reason', None)))
for n in a.get('levels', []) or []:
sans_manque(n, 'reason')
return json.dumps(d, sort_keys=True, ensure_ascii=False), retires
if achat:
sans_achat(n, a.get('loadId'), achats)
return json.dumps(d, sort_keys=True, ensure_ascii=False), retires, achats
def achat_court(v):
if isinstance(v, (int, float)):
return f'{round(v, 2):g}'
return str(v)
def motif_court(m):
@ -68,6 +93,9 @@ def main():
return 2
attendu, obtenu = lire(sys.argv[1]), lire(sys.argv[2])
sortie_cas = sys.argv[sys.argv.index('--cas') + 1] if '--cas' in sys.argv else None
sortie_lignes = sys.argv[sys.argv.index('--lignes') + 1] if '--lignes' in sys.argv else None
achat = '--achat' in sys.argv
lignes_changees = []
if len(attendu) != len(obtenu):
print(f'NOMBRE DE LIGNES différent : {len(attendu)} attendues, {len(obtenu)} obtenues')
@ -79,10 +107,11 @@ def main():
if a == b:
brutes_identiques += 1
continue
na, ra = normaliser(a)
nb, rb = normaliser(b)
na, ra, aa = normaliser(a, achat)
nb, rb, ab = normaliser(b, achat)
lignes_changees.append((i, a, b))
if na != nb:
print(f'ÉCART HORS MOTIFS à la ligne {i} :')
print(f'ÉCART HORS MOTIFS{" ET HORS ACHAT" if achat else ""} à la ligne {i} :')
print('- ' + a[:3000])
print('+ ' + b[:3000])
return 1
@ -90,13 +119,23 @@ def main():
for (charge, ma), (_, mb) in zip(ra, rb):
if ma != mb:
cas[(fn, charge, motif_court(ma), motif_court(mb))] += 1
# L'acheté d'un niveau éco (--achat) : ancienne valeur → nouvelle, ou devenu absent.
for (charge, va), (_, vb) in zip(aa, ab):
if va != vb:
cas[(fn, charge, f'purchasedWh {achat_court(va)}', f'purchasedWh {achat_court(vb)}')] += 1
# Le manque d'un niveau change sans que le motif de tête change : à dire aussi.
if ra == rb:
if ra == rb and aa == ab:
cas[(fn, '(niveau)', 'missingWh', 'missingWh corrigé')] += 1
print(f'IDENTIQUE HORS MOTIFS : {len(attendu)} lignes, dont {brutes_identiques} identiques '
f'à l\'octet et {len(attendu) - brutes_identiques} qui ne diffèrent que par les motifs')
lignes = ['| scénario (fonction) | charge | ancien motif | nouveau motif | lignes |',
hors = 'HORS MOTIFS ET HORS ACHAT' if achat else 'HORS MOTIFS'
print(f'IDENTIQUE {hors} : {len(attendu)} lignes, dont {brutes_identiques} identiques '
f'à l\'octet et {len(attendu) - brutes_identiques} qui ne diffèrent que par '
f'{"les motifs ou purchasedWh" if achat else "les motifs"}')
if sortie_lignes:
with open(sortie_lignes, 'w', encoding='utf-8') as f:
for i, a, b in lignes_changees:
f.write(f'## ligne {i}\n- {a}\n+ {b}\n')
lignes = ['| scénario (fonction) | charge | avant | après | lignes |',
'|---|---|---|---|---|']
for (fn, charge, avant, apres), n in sorted(cas.items()):
lignes.append(f'| `{fn}` | `{charge}` | {avant} | {apres} | {n} |')