#!/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=?", (*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()