Patrick Schurig 5275ebae31 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
2026-08-26 10:17:20 +02:00

230 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.