docs(releve): lot D — les trois correctifs éprouvés sur le banc

1.15.2+etm16 déployé sur .75 le 2026-08-26 09:11. Chaque critère porte sa nature :
machine, test, ou lecture.

§2 — le journal bascule de "chauffe-eau" à "Ballon 200 L" au cycle suivant l'écriture,
et le décompte de l'arbitre passe de « inchangée » à « 1 mise à jour EN PLACE ».

§3 — relais inversés (2000/1000/500), mêmes Things, mêmes puissances :
« 0 créée(s), 0 mise(s) à jour, 2 INCHANGÉE(S) ». Ni reconstruction ni mise à jour,
aucun état sûr, aucune commutation. Avant etm16 la même écriture donnait
« 1 créée(s), 1 retirée(s) » et ouvrait les contacts.

§4 — stagesW = [0,500,1000,1500,2000,2500,3000,3500] publié en télémétrie, maxStageW
étant son dernier élément. Publié LÀ et pas dans GetLoadConfig parce que la condition
« lecture seule ET rejetée à l'écriture » s'y contredit : l'app écrit en renvoyant
verbatim ce qu'elle a lu, donc elle aurait réémis le champ et le refus en bloc aurait
emporté toutes les charges.

§1 — cause datée à la seconde (d08cf50, 05:30:14 UTC) et déjà corrigée en amont ; la
séquelle, elle, était définitive par absorption flottante. Base du banc réparée :
62 726 anomalies → 0, accumulation reprise et mesurée en direct (+0,001953 kWh).
Deux corrections au brief : totalProduction n'était pas sain mais figé depuis 23 jours,
et les deux Things Fronius ont déraillé ensemble.

À remonter en amont : EnergyManagerImpl accumule sans garde-fou de plausibilité dans un
compteur persistant. N'importe quel plugin mal codé peut geler l'historique d'une box
client. Contribution nymea, pas correctif ETM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
This commit is contained in:
Patrick Schurig 2026-08-26 10:17:20 +02:00
parent a0c02fbd34
commit 5275ebae31

229
docs/RELEVE_LOTD.md Normal file
View File

@ -0,0 +1,229 @@
# RELEVÉ — lot D : les constats de l'agent app, dans l'ordre de ce qu'ils coûtent
**Entrée** : `BRIEF_agent_plugin.md` (sept constats, tous établis sur `.75` avec traces).
**Livré** : `1.15.2+etm16`, déployé sur `.75` le 2026-08-26 à 09:11.
**Portée** : §1 à §4. §5 à §7 restent à cadrer — voir la fin.
> Chaque critère porte sa nature : **machine** (observé sur `.75`), **test** (suite locale),
> ou **lecture** (source lue, non exercée). Un critère qui n'a pas été exercé le dit.
---
## §1 — Compteurs cumulés corrompus : la cause était ailleurs, et déjà corrigée
### Ce que la base a enregistré
Premier échantillon empoisonné : **2026-08-07 05:30 UTC**. Et à cet instant ce n'est pas le
cumul qui déraille — c'est la **puissance instantanée** :
```
08-07 05:15 P_acq = -1 955 W totalAcq = 5,01e+01 ← sain
08-07 05:30 P_acq = -9,81e+31 W totalAcq = 6,87e+30
08-07 05:45 P_acq = -5,39e+35 W totalAcq = 7,53e+33
08-07 06:00 P_acq = -6,7 W totalAcq = 7,53e+33 ← la puissance revient, le cumul reste
```
**Cause établie, à la seconde près.** `etm-powersync-plugins-modbus` `d08cf50` —
« fix(sunspec): lire les float32 Fronius en big endian » — est daté **2026-08-07 07:30:14
+0200, soit 05:30:14 UTC**. Son propre message décrit le symptôme : « un compteur important
2,7 kW rapporté comme exportant 107 MW ». Le banc tournait le plugin aux mots permutés
pendant la séance de diagnostic ; deux cycles de lecture ont suffi.
**Rien à corriger dans ce moteur pour l'origine** : elle l'est déjà (`+etm8`/`+etm9`).
### Pourquoi la séquelle était définitive
`EnergyManagerImpl::updatePowerBalance()` accumule sans aucun contrôle de plausibilité, et
le résultat est **persisté puis rechargé** à chaque démarrage (`latestLogEntry()`). À
7,5 × 10³³ kWh, le plus petit incrément représentable en double vaut **1,67 × 10¹⁸ kWh** :
```python
>>> 7.5345e33 + 0.001 == 7.5345e33
True
```
Le compteur n'était pas seulement faux, il était **gelé** — incapable de bouger, quoi qu'il
arrive. L'app avait posé un plafond de plausibilité en attendant, avec l'espoir qu'elle
« redeviendrait juste toute seule » : c'était faux. Sans purge, le plafond restait affiché
indéfiniment.
**Étendue** : 12 542 lignes sur 20 665 (**60,7 %**) portaient les totaux empoisonnés. Seules
**6 lignes** avaient une puissance instantanée aberrante — l'historique de puissance était
donc sain, et seules les colonnes `total*` étaient à réparer.
### Deux corrections au brief
- **`totalProduction` n'était pas « sain », il était figé** depuis le 2026-08-02 16:15, soit
23 jours. L'onduleur (`41ff6f36`) ne publie aucun total : son `thingCache` est à
`0.0 / 0.0`. La production a survécu à la corruption parce qu'elle vient d'un **autre
Thing**, qui ne contribue rien du tout. Le brief y lisait l'indice de chemins différents ;
l'explication est plus simple, et le défaut d'intégration SunSpec reste ouvert.
- **Les deux Things Fronius ont déraillé ensemble** (onduleur à 3,6e36 W, compteur à
5,4e35 W) — même connexion SunSpec, une seule salve.
### Réparation — **machine**
`tools/repair_energylogs.py`, appliqué sur `.75` le 2026-08-26 :
```
12 542 ligne(s) powerBalance : cumuls reportés au palier sain
6 / 5 / 6 ligne(s) : consumption / production / acquisition → NULL
12 542 ligne(s) thingPower : cumuls reportés, par Thing
11 ligne(s) thingPower : currentPower invraisemblable → NULL
── contrôle après réparation ── BASE SAINE (62 726 anomalies → 0)
```
`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.
**Accumulation reprise, vérifiée en direct** — c'est le point qui comptait :
```
totalAcquisition palier = 50,1286 → 50,134190 → 50,136143 Δ = +0,001953 kWh
totalConsumption palier = 6332,28 → 6332,285650 → 6332,287603 Δ = +0,001953 kWh
```
Des deltas de 2 Wh sont de nouveau encaissés. L'absorption flottante a disparu avec la
magnitude.
**Ce que le script n'invente pas** : l'énergie des 19 jours empoisonnés est perdue. Les
relevés bruts du compteur pour cette période ne sont nulle part dans la base. Un palier plat
l'assume ; une rampe l'aurait maquillée.
### À remonter en amont
`EnergyManagerImpl` n'a **aucun garde-fou de plausibilité** : il accumule n'importe quel
double venu d'un état de Thing dans un compteur persistant et irréversible. C'est de l'amont
nymea **non forké** (`1.15.2+202606191336~trixie1` sur le banc), donc une **contribution**,
pas un correctif ETM. Tant qu'il est là, **n'importe quel plugin mal codé peut geler
définitivement l'historique d'une box client**. À porter dans la doc d'exploitation.
---
## §2 — Renommer une charge atteint enfin l'adaptateur
**Correctif** : `label` entre dans `updateSoftConfig(label, priority, needs)` — interface,
quatre adaptateurs, et le prédicat `soft` de l'arbitre. Il **n'entre pas** dans
`sameHardware()` : c'est une mise à jour en place, pas une reconstruction.
**La vraie prise** : `SgReadyAdapter::updateSoftConfig()` ignorait tout (`Q_UNUSED`), hérité
d'avant le lot B-bis qui a rendu la PAC configurable. Sans le corriger, renommer la PAC — le
cas exact du banc, celui qu'on allait tester — n'aurait rien changé, et on aurait conclu que
le correctif ne marchait pas.
**Machine** — journal de `.75`, écriture de 09:12:45 :
```
09:12:00 [RelayRouter] "chauffe-eau" → consigne 1500 W … INCHANGÉ
09:13:00 [RelayRouter] "Ballon 200 L" → consigne 1000 W → palier 1000 W
09:12:45 [EnergyArbitrator] 0 créée(s), 1 mise(s) à jour EN PLACE, 1 inchangée(s)
```
Le décompte le dit aussi : avant `+etm16`, un renommage seul comptait « inchangée ».
---
## §3 — `relays[]` comparé par la table dérivée, pas par index
### La question posée, et sa réponse
L'ordre de déclaration **porte un sens, mais un seul** : `relayrouter.cpp` construit les
paliers de toutes les combinaisons et, **à somme égale, retient la première rencontrée**.
Donc :
- puissances **distinctes** (500/1000/2000, le cas du banc) → chaque somme a une combinaison
unique, **l'ordre est inerte** ;
- deux relais de **même puissance** → l'ordre décide **quel contact physique** sert ce palier.
Autre contacteur qui s'use, autre résistance qui chauffe.
Comparer comme un **ensemble** aurait donc masqué un vrai changement ; comparer **par index**
annonçait une reconstruction dans tous les autres cas.
**Décision** : comparer la **table dérivée** `(levels, mapping)`. Deux listes qui produisent
les mêmes paliers avec les mêmes contacts sont matériellement identiques — par construction,
pas par pari. `RelayRouter::deriveStages()` est l'**implémentation unique**, partagée par la
construction du routeur, la comparaison et la publication (§4).
### Le test a attrapé un défaut de la correction elle-même
Première version de `RelayStageTable::operator==` : comparaison des listes de ThingIds
élément par élément. Elle échouait sur le cas même que §3 corrige — le palier 1 500 W sort
`{K1,K2}` en ordre direct et `{K2,K1}` en ordre inverse. **Même geste électrique, deux
listes.** Les contacts d'un palier se comparent donc comme un **ensemble**, les paliers
eux-mêmes position par position.
**Machine** — écriture de 09:14:55, relais inversés (2000/1000/500), mêmes Things, mêmes
puissances :
```
09:14:55 [EnergyArbitrator] 0 créée(s), 0 mise(s) à jour, 2 INCHANGÉE(S), 0 retirée(s)
```
Ni reconstruction, ni même mise à jour : l'adaptateur est réutilisé tel quel. **Aucune ligne
« construit depuis config », aucun état sûr, aucun ECS-413, aucune commutation** entre 09:14
et 09:16 — le `RelayRouter` poursuit son cycle normal. Avant `+etm16`, la même écriture
donnait `1 créée(s), 1 retirée(s)` et ouvrait les contacts.
**Test** — `tests/auto/loadmodel`, 8/8, 2 ms, sans serveur nymea :
```
testStageTableIsDerivedFromSubsetSums testReorderingDistinctRelaysKeepsTheAdapter
testRelayOrderIsInertWhenPowersAreDistinct testSwappingTiedRelaysRebuilds
testRelayOrderDecidesWhichContactServesATiedStage testSoftFieldsNeverRebuild
```
---
## §4 — Paliers atteignables publiés, en lecture seule
**Machine** — `GetLoadTelemetry` sur `.75`, cycle `2026-08-26T08:12:00Z` :
```
chauffe-eau kind=relay stageW=1500 maxStageW=3500
stagesW = [0, 500, 1000, 1500, 2000, 2500, 3000, 3500]
```
Les huit paliers du banc, et `maxStageW` est bien le dernier élément — le contrôle croisé
que le client peut faire sur sa propre dérivation.
**Publié en télémétrie et PAS dans `GetLoadConfig`, à dessein.** La condition posée était
« lecture seule et rejetée à l'écriture ». Dans `GetLoadConfig` les deux se contredisent :
l'app écrit en **renvoyant verbatim ce qu'elle a lu** (aller-retour neutre, vérifié), donc
elle aurait réémis le champ publié, et un refus en bloc de `SetLoadConfig` aurait emporté
**toutes** les charges. La télémétrie n'a aucun chemin d'écriture : « lecture seule » y est
vrai par construction, sans rien casser.
---
## Ce sur quoi l'app s'appuie — contrôlé, rien de cassé
| Comportement | État après `+etm16` |
|---|---|
| `LoadConfigChanged` avant l'accusé de `SetLoadConfig` | intact (non modifié) |
| `GetLoadTelemetry` rend le dernier cycle publié, sans recalcul | intact — et observé : `timestamp` **absent** juste après le redémarrage, jusqu'au premier cycle |
| `enabled: false` passe par `applySafeState()` | intact (non modifié) |
| `JSONRPC.Introspect` décrit `SetLoadConfig` jusqu'au champ | intact ; `o:stagesW` ajouté au **schéma de télémétrie**, pas à celui de `SetLoadConfig` |
| `loads[] ⊆ GetLoadConfig` | intact |
| Aller-retour neutre `Get` → `Set` | **intact — et c'est ce qui a décidé où publier §4** |
---
## Modification d'API
`EnergyArbitrator::sameHardware()` passe **public**. Ce prédicat décide si une écriture coûte
un geste matériel — contacts ouverts, verrous réarmés à froid — ou une simple mise à jour en
place. C'est à ce titre un élément de contrat, que le client annonce à l'installateur avant
d'enregistrer, et il est éprouvé comme tel. Statique et sans état : l'exposer n'ouvre rien.
---
## §5 à §7 — à cadrer, pas faits
- **§5 `minCurrent`** — `ChargingModeEcoWithMinCurrent` existe dans l'énumération, aucun champ
de `ChargingInfo` ne porte le courant. Soit il vit ailleurs (paramètre du Thing borne), soit
il manque. **Tant que ce n'est pas tranché, l'app ne propose pas le réglage** : un champ qui
ne part nulle part est pire que pas de champ. Recoupe le lot E §3 (plancher).
- **§6 compteur par charge** — deux champs de même forme (`o:meterThingId`,
`o:sensorThingId`) : c'est l'objet du lot E §2.
- **§7 prédicat `typeAppareil`** — l'avertissement LM-200 attend son discriminant. `domain`
décrit ce qu'on arbitre, pas la machine.