etm-powersync-energy-plugin.../tools/repair_energylogs.py
Patrick Schurig a0c02fbd34 feat(lot D): libellé jusqu'à l'adaptateur, relais comparés par table, paliers publiés
Les trois premiers constats de BRIEF_agent_plugin.md, dans l'ordre de ce qu'ils coûtent.

§2 — Renommer une charge n'atteignait jamais l'adaptateur. updateSoftConfig() ne
portait que priority et needs, donc un changement de libellé seul était compté
« inchangée » : l'adaptateur gardait son ancien nom et le journal contredisait
GetLoadConfig jusqu'à la prochaine reconstruction. `label` entre dans la signature et
dans le prédicat soft de l'arbitre, sans entrer dans sameHardware() — mise à jour EN
PLACE, pas reconstruction. La vraie prise était le Q_UNUSED de SgReadyAdapter, hérité
d'avant le lot B-bis : sans lui, renommer la PAC — le cas même qu'on allait tester —
n'aurait rien changé, et on aurait conclu que le correctif ne marchait pas.

§3 — relays[] n'est plus comparé par index. Un réordonnancement reconstruisait la
charge : contacts ouverts, verrous réarmés à froid. Comparer comme un ENSEMBLE aurait
été faux dans l'autre sens — à somme égale la table retient la première combinaison
rencontrée, donc échanger deux relais de même puissance change le contact qui sert ce
palier. sameHardware() compare la TABLE DÉRIVÉE : deux listes qui produisent les mêmes
paliers avec les mêmes contacts sont matériellement identiques, par construction.

§4 — mechanism.stagesW publie les paliers atteignables, en télémétrie et pas dans
GetLoadConfig. La télémétrie n'a aucun chemin d'écriture, donc « lecture seule » y est
vrai par construction ; GetLoadConfig aurait cassé l'aller-retour neutre dont l'app
dépend pour écrire — elle renvoie verbatim ce qu'elle a lu, le champ serait revenu
dans SetLoadConfig, et un refus en bloc aurait emporté toutes les charges.

deriveStages() est l'implémentation unique de la combinatoire, partagée par les trois
usages. C'était le fond de la demande du §4 : une seule règle, pas de divergence.

tests/auto/loadmodel : cible neuve sans serveur nymea, 2 ms. Elle a attrapé un défaut
de la correction elle-même avant le banc — operator== comparait les listes de ThingIds
et voyait une différence entre fermer {K1,K2} et fermer {K2,K1}.

tools/repair_energylogs.py : §1, et ce n'est PAS un correctif de ce plugin. La cause
est extérieure et déjà corrigée (SunSpec float32 aux mots permutés, d08cf50 daté
05:30:14 UTC — la seconde du premier échantillon empoisonné). La séquelle, elle, était
définitive : à 7,5e33 kWh le plus petit incrément représentable vaut 1,67e18, donc le
compteur n'était pas faux mais GELÉ, et rechargé tel quel à chaque démarrage.
Appliqué sur .75 : 62 726 anomalies → 0, accumulation reprise et vérifiée en direct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-26 10:07:22 +02:00

201 lines
8.6 KiB
Python

#!/usr/bin/env python3
"""Répare les cumuls d'énergie empoisonnés par une lecture de compteur absurde.
## Le défaut que ce script rattrape
`EnergyManagerImpl::updatePowerBalance()` accumule, sans aucun contrôle de plausibilité,
la différence des compteurs d'un Thing dans quatre totaux **persistés** :
m_totalAcquisition += newAcquisition - oldAcquisition; // idem Return, Production
Une seule lecture absurde suffit donc à empoisonner l'accumulateur **définitivement** :
rien ne redescend jamais, et à grande magnitude le compteur devient littéralement
**incapable de bouger**. À 7,5e33 kWh, le plus petit incrément représentable en double est
1,67e18 kWh — un delta réel de 0,001 kWh est absorbé sans laisser de trace :
>>> 7.5345e33 + 0.001 == 7.5345e33
True
Le compteur n'est pas seulement faux : il est **gelé**. Aucune purge côté client, aucun
redémarrage ne le débloque, parce que la valeur est rechargée au démarrage depuis le
journal (`latestLogEntry()`).
## Cas d'origine — banc `.75`, 2026-08-07
Le plugin SunSpec lisait les float32 Fronius avec leurs deux mots permutés. Corrigé par
`etm-powersync-plugins-modbus` `d08cf50` (« lire les float32 Fronius en big endian »),
daté **05:30:14 UTC** — soit la seconde même du premier échantillon empoisonné. Deux
cycles de lecture ont suffi : la puissance instantanée est revenue à 06:00, les cumuls
jamais.
## Ce que le script fait, et ce qu'il ne fait pas
- **Reporte** les quatre colonnes `total*` de `powerBalance`, et les deux de `thingPower`,
à leur dernière valeur SAINE, pour toutes les lignes ≥ seuil.
- **Annule** (NULL) les puissances instantanées absurdes — elles sont rares et isolées.
- **Ne touche pas** à `thingCache` : il porte les compteurs réels du Thing et il est sain.
C'est lui qui réarme les caches de delta au démarrage.
- **Ne reconstruit pas** l'énergie de la période empoisonnée : les relevés bruts du
compteur pour ces journées ne sont nulle part dans cette base. Cette énergie est
**perdue**, et le script l'assume par un palier plat plutôt que par une rampe inventée.
Le script est **rejouable** : il recalcule tout depuis le seuil, sans dépendre d'un état
antérieur. Le relancer sur une base déjà réparée ne change rien.
## Usage
python3 tools/repair_energylogs.py --check energylogs.sqlite
python3 tools/repair_energylogs.py --repair energylogs.sqlite --since 2026-08-07T05:30
Sur la box, la base appartient à root et nymead la tient ouverte :
sudo systemctl stop nymead
sudo cp /var/lib/nymea/energylogs.sqlite /var/lib/nymea/energylogs.sqlite.bak-$(date +%F)
sudo python3 repair_energylogs.py --repair /var/lib/nymea/energylogs.sqlite --since ...
sudo systemctl start nymead
Le redémarrage est **nécessaire** : les totaux vivent en mémoire et ne sont relus qu'au
démarrage du service.
"""
import argparse
import datetime
import sqlite3
import sys
PLAUSIBLE_POWER_W = 1e9 # 1 GW — au-delà, aucune installation domestique
PLAUSIBLE_TOTAL_KWH = 1e9 # 1 TWh — au-delà, aucun compteur de bâtiment
TOTALS = ["totalConsumption", "totalProduction", "totalAcquisition", "totalReturn"]
POWERS = ["consumption", "production", "acquisition", "storage"]
def ms(iso: str) -> int:
return int(datetime.datetime.fromisoformat(iso).replace(
tzinfo=datetime.UTC).timestamp() * 1000)
def human(t: int) -> str:
return datetime.datetime.fromtimestamp(t / 1000, datetime.UTC).strftime("%Y-%m-%d %H:%M")
def check(db: sqlite3.Connection) -> int:
"""Rend le nombre d'anomalies trouvées. 0 = base saine."""
bad = 0
print("── powerBalance ──")
for col in TOTALS:
n, mx = db.execute(
f"select count(*), max(abs({col})) from powerBalance where abs({col})>?",
(PLAUSIBLE_TOTAL_KWH,)).fetchone()
if n:
print(f" ⚠ {col:18} : {n:>6} ligne(s) invraisemblable(s), max {mx:.4e} kWh")
bad += n
for col in POWERS:
n, = db.execute(
f"select count(*) from powerBalance where abs({col})>?",
(PLAUSIBLE_POWER_W,)).fetchone()
if n:
print(f" ⚠ {col:18} : {n:>6} ligne(s) de puissance invraisemblable(s)")
bad += n
print("── thingPower ──")
for col in ("currentPower", "totalConsumption", "totalProduction"):
limit = PLAUSIBLE_POWER_W if col == "currentPower" else PLAUSIBLE_TOTAL_KWH
n, = db.execute(
f"select count(*) from thingPower where abs({col})>?", (limit,)).fetchone()
if n:
print(f" ⚠ {col:18} : {n:>6} ligne(s)")
bad += n
print("── monotonie des cumuls (sampleRate=15) ──")
for col in ("totalAcquisition", "totalReturn", "totalProduction"):
prev, breaks = None, 0
for (v,) in db.execute(
f"select {col} from powerBalance where sampleRate=15 order by timestamp"):
if prev is not None and v is not None and v < prev - 1e-6:
breaks += 1
prev = v if v is not None else prev
flag = "⚠" if breaks else " "
print(f" {flag} {col:18} : {breaks} rupture(s) de monotonie")
bad += breaks
print(f"\n{'BASE SAINE' if bad == 0 else f'{bad} ANOMALIE(S)'}")
return bad
def repair(db: sqlite3.Connection, cut: int) -> None:
print(f"Seuil : {human(cut)} UTC\n")
# ── 1. powerBalance : dernière ligne saine avant le seuil ────────────────
base = db.execute(
f"select {','.join(TOTALS)} from powerBalance "
"where timestamp < ? order by timestamp desc limit 1", (cut,)).fetchone()
if base is None:
sys.exit("Aucune ligne avant le seuil : rien à reporter, seuil probablement trop tôt.")
print("Dernières valeurs saines (powerBalance) :")
for name, v in zip(TOTALS, base):
print(f" {name:18} = {v:.4f}")
sets = ", ".join(f"{c}=?" for c in TOTALS)
n = db.execute(f"update powerBalance set {sets} where timestamp>=?",
(*base, cut)).rowcount
print(f"\n {n} ligne(s) powerBalance : cumuls reportés au palier sain")
# ── 2. puissances instantanées absurdes → NULL ───────────────────────────
for col in POWERS:
n = db.execute(
f"update powerBalance set {col}=NULL where abs({col})>?",
(PLAUSIBLE_POWER_W,)).rowcount
if n:
print(f" {n} ligne(s) : {col} invraisemblable → NULL")
# ── 3. thingPower, Thing par Thing ───────────────────────────────────────
for (thing_id,) in db.execute("select distinct thingId from thingPower"):
row = db.execute(
"select totalConsumption,totalProduction from thingPower "
"where thingId=? and timestamp<? order by timestamp desc limit 1",
(thing_id, cut)).fetchone()
if row is None:
continue
n = db.execute(
"update thingPower set totalConsumption=?, totalProduction=? "
"where thingId=? and timestamp>=?", (*row, thing_id, cut)).rowcount
print(f" {n} ligne(s) thingPower {thing_id[:10]} : "
f"cumuls reportés ({row[0]:.3f} / {row[1]:.3f})")
n = db.execute("update thingPower set currentPower=NULL where abs(currentPower)>?",
(PLAUSIBLE_POWER_W,)).rowcount
if n:
print(f" {n} ligne(s) thingPower : currentPower invraisemblable → NULL")
db.commit()
print("\n⚠ thingCache NON touché — il porte les compteurs réels du Thing, et c'est lui "
"qui réarme les caches de delta au démarrage.")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("database")
ap.add_argument("--check", action="store_true", help="diagnostic seul, aucune écriture")
ap.add_argument("--repair", action="store_true", help="écrit dans la base")
ap.add_argument("--since", metavar="ISO",
help="seuil UTC, ex. 2026-08-07T05:30 — requis avec --repair")
a = ap.parse_args()
if not (a.check or a.repair):
ap.error("choisir --check ou --repair")
if a.repair and not a.since:
ap.error("--repair exige --since : le seuil ne se devine pas")
db = sqlite3.connect(a.database)
if a.repair:
repair(db, ms(a.since))
print("\n── contrôle après réparation ──")
sys.exit(1 if check(db) else 0)
if __name__ == "__main__":
main()