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
201 lines
8.6 KiB
Python
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()
|