etm-powersync-energy-plugin.../docs/RELEVE_APP_20260831.md
Patrick Schurig 0d2b6728ae [upstream-fix] un verdict de test ne doit pas dépendre de l'heure
Six appels à `update(QDateTime::currentDateTime())` manquaient la garde
ENERGY_SIMULATION que leurs voisins ont (l.124, l.288) :
setPhasePowerLimit, setAcquisitionTolerance, et trois dans
onActionExecuted — qui partent sur toute action manuelle appliquée à une
borne.

Un cycle d'arbitrage complet partait donc à l'horloge MURALE au milieu
d'une suite qui injecte un temps simulé. Les deux horloges se mélangeaient
dans les verrous : l'armement paresseux (ECS-412) figeait m_lastSwitch sur
l'heure qu'il était, et un scénario daté en dur se déroulait « avant » son
propre armement — elapsed négatif, verrou jamais ouvert.

Ce n'est pas un défaut de production : il n'y a là-bas qu'une seule horloge.
C'est une faille de REPRODUCTIBILITÉ, et elle touchait tout test de
simulation réglant une limite de phase, une tolérance d'acquisition, ou
exécutant une action manuelle sur une borne.

PREUVE DU CORRECTIF : la garde rendue, testL4ShedsAPacAndSaysItForcedTheLock
repasse avec une DATE FIXE, trois fois sur trois — ce qu'il ne pouvait pas
faire. L'ancrage sur l'horloge réelle, posé la veille faute d'avoir la
cause, était un contournement ; il est retiré, et la date fixe revient parce
qu'elle rend les journaux reproductibles.

tools/check-simulation-clock.py, câblé en 4/4 de ci-quality.sh, et vérifié
mordant : garde retirée, il nomme la ligne exacte. Il a trouvé trois des six
déclencheurs dès son premier passage.

Pourquoi une vérification STATIQUE et pas un test : la suite ne peut pas
dire d'elle-même que son verdict dépend de l'heure, puisque c'est le verdict
qui ment. Le défaut n'a été trouvé que parce qu'il échouait cinq fois sur
cinq à code identique — une fenêtre de nuisance plus étroite ne basculerait
qu'une fois sur vingt, passerait pour une fragilité de temporisation, et
serait relancée jusqu'à ce qu'elle passe. C'est la famille de la semaine
sous une forme de plus : après l'instrument dont le silence ressemble à un
résultat, l'instrument dont le VERDICT ment selon l'heure.

Suite complète : simulation 54/54, charging 48/48 identique à la référence
ligne à ligne, loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement.
Aucun test ne dépendait de ces cycles.

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

133 lines
7.4 KiB
Markdown

# RELEVÉ — trois constats de l'agent app, à traiter APRÈS le §10
*(Remontés le 2026-08-31, mesurés sur `.75`. Rangés ici pour ne pas se perdre : ils ne passent
pas devant le §10, et aucun ne se déduit du code — les trois viennent de la machine.)*
---
## 1. Une commande perdue pendant une indisponibilité n'est JAMAIS réappliquée
**Mesuré.** La box journalise `Restoring manual values of false 6 A` trois secondes après une
coupure Modbus. La borne, elle, reste à `power: true`, **7 A** — ce que l'arbitre lui avait
commandé au cycle précédent. Le lien revient : **rien ne réapplique**. En mode manuel personne ne
commande, donc l'écart est **permanent**.
**Ce que ça coûte, chiffré :** 4,4 kW réellement tirés, `draw.committedW` à **0**, et un budget de
surplus de **2 349 W** qui ignore ces watts.
Une coupure d'**une seconde** suffit donc à laisser une borne à un courant que plus personne n'a
choisi, et le budget est faux tant que ça dure. Sur 18 kVA c'est invisible ; **sur 6 kVA c'est le
disjoncteur**.
**La question posée par l'app** était étroite — « une entrée en mode manuel devrait-elle
réappliquer ses valeurs à la reconnexion du Thing ? ». Elle est **élargie** : toute commande
perdue pendant une indisponibilité devrait être **réappliquée au retour, ou la divergence
signalée**.
**Et c'est calculable dès aujourd'hui.** On sait ce qu'on a commandé, la borne dit ce qu'elle
fait, et `measurement.source` (`LM-1105` / `LM-1106`) dit désormais **ce que vaut la
comparaison** — une divergence entre commandé et `device` n'a pas le même statut qu'une
divergence contre `none`.
> **Même famille que toute la semaine** : le lien revient, l'état a l'air sain, `available` est
> vrai, et personne ne sait que la commande n'a jamais atterri. C'est `ECS-111` et `ECS-415` sous
> une troisième forme — après la télémétrie et après la valeur de retour, voici **l'exécution**.
---
## 2. `chargingState` reste `ChargingStateIdle` pendant une charge RÉELLE
**Mesuré.** Lien vivant, borne publiant 4 372 → 4 397 W, courants qui bougent — et le champ reste
à `Idle`.
**Ce n'est donc PAS de la péremption**, et c'est ce qui rend le constat utile : personne ne
maintient le champ quand l'arbitre ne commande plus la borne.
**Un champ que plus personne ne maintient devrait être ABSENT, pas figé.** Figé, un écran affiche
« à l'arrêt » pendant une charge. C'est l'invariant 12 d'`AGENTS.md` — un champ absent vaut mieux
qu'un champ nul ou forgé — appliqué à un champ dont le *mainteneur* a disparu, et non la valeur.
---
## 3. `ChargingModeNormal` n'est pas un boost, et son nom le laisse croire
Il **restaure des « valeurs manuelles mémorisées »**, qui peuvent être `power: false`, `6 A`. Y
passer peut donc **arrêter une charge** alors que l'utilisateur croit demander la pleine
puissance. Le nom du mode et son effet ne coïncident pas.
**À documenter au minimum.** À corriger si l'on juge que le nom peut bouger — c'est une frontière
amont, et un renommage se paie côté clients.
---
## Et un point OUVERT qui se CLÔT : la V2C publie `sessionEnergy`
**Mesuré**, deux échantillons à deux heures d'écart sur charge réelle : l'écart entre
`sessionEnergy` et `chargeEnergy` est **constant au dix-millième** — 7,2044 dans les deux cas.
C'est l'accumulateur du plugin V2C qui porte les incréments d'**avant la dernière remise à
zéro**, et **ça ne se déduisait pas du code**.
**Conséquences, et elles touchent l'étape C du §10 :**
1. **`LM-1009 C1` reste vraie** pour les bornes qui n'ont pas l'état — mais **la V2C n'en fait
plus partie**. Le cas « réel dès aujourd'hui » qui motivait R6 change de porteur.
2. **`sessionEnergy` est l'accumulateur à lire** pour alimenter `LoadContextTelemetry::sessionWh`.
3. **⚠️ Il porte un OFFSET.** Sa valeur absolue n'est pas l'énergie de la session courante.
`deliveredWh` doit donc être une **différence depuis une base capturée**, jamais une lecture
directe — sinon toute obligation éco se croirait déjà tenue de 7,2 kWh au premier cycle. C'est
le genre d'erreur qui produit un nombre crédible et faux, et que rien ne signalerait.
L'app a corrigé sa maquette et son modèle en conséquence.
---
## ANNEXE — un piège de harnais, découvert en faisant l'étape A du §10
*(2026-08-30, en cherchant pourquoi un test vert la veille échouait déterministiquement.)*
`testL4ShedsAPacAndSaysItForcedTheLock` échouait **cinq fois sur cinq**, et la cause n'était ni
le code sous test ni une fragilité de temporisation : **un cycle d'arbitrage tourne à l'horloge
MURALE avant le premier cycle simulé.** Relevé par sonde : la PAC reçoit une action `SG_NORMAL`
estampillée `22:52:07` — l'heure qu'il était.
L'armement paresseux d'`ECS-412` fige alors `m_lastSwitch` sur cette heure. Un scénario daté en
dur (13:00 simulé) se déroule donc **« avant » son propre armement** : `elapsed` est négatif, le
verrou ne s'ouvre jamais, la PAC n'atteint pas l'état 4, et la prémisse du test s'effondre.
> **Le test passait ou échouait selon l'heure de la journée** — ce qui est pire que les deux, et
> ce qui explique aussi le `remainingS: 1288` observé plus tôt sans qu'on sache l'expliquer.
**Correctif retenu** : ancrer l'origine du scénario sur `QDateTime::currentDateTimeUtc()`. La
chronologie relative est tout ce dont il a besoin ; l'origine doit vivre dans la **même horloge
que ce qui le touche**. Le commentaire dit à quelle condition ce choix devient faux — le jour où
l'arbitrage ne peut plus tourner à l'horloge murale dans la suite, une date fixe redevient
préférable parce qu'elle rend les journaux reproductibles.
### RÉSOLU le 2026-08-31 — la cause, et six déclencheurs au lieu d'un
**`SmartChargingManager::setPhasePowerLimit()` appelait `update(QDateTime::currentDateTime())`
sans la garde `ENERGY_SIMULATION` que ses voisins ont** (`l.124`, `l.288`). Le test appelle
`SetPhasePowerLimit` : un cycle d'arbitrage complet partait donc à l'heure murale juste avant le
premier cycle simulé. `setAcquisitionTolerance()` avait le même défaut — et
`testCountsSumToTarget` l'appelle.
**Preuve du correctif, et elle est nette** : la garde rendue, le test repasse avec une **date
fixe**, trois fois sur trois — ce qu'il ne pouvait pas faire. L'ancrage sur l'horloge réelle
était un contournement ; il est retiré, et la date fixe revient parce qu'elle rend les journaux
reproductibles.
**La vérification statique a trouvé trois déclencheurs de plus** dès son premier passage :
`onActionExecuted()` en porte trois, qui partent sur toute action manuelle appliquée à une borne
(`power`, `maxChargingCurrent`, `desiredPhaseCount`). Six au total, tous gardés désormais.
> **Pourquoi une vérification STATIQUE, et pas un test.** La suite ne peut pas dire d'elle-même
> que son verdict dépend de l'heure : c'est le verdict lui-même qui ment. Et le défaut n'a été
> trouvé que parce qu'il échouait **cinq fois sur cinq** — un déclencheur dont la fenêtre de
> nuisance serait plus étroite ne basculerait qu'une fois sur vingt, passerait pour une
> fragilité de temporisation, et serait **relancé jusqu'à ce qu'il passe**. C'est la famille de
> la semaine sous une forme de plus : après l'instrument dont le silence ressemble à un
> résultat, l'instrument dont le **verdict** ment selon l'heure.
>
> `tools/check-simulation-clock.py`, câblé en 4/4 de `ci-quality.sh`, et **vérifié mordant** :
> garde retirée, il signale la ligne exacte.