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
7.4 KiB
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,
availableest vrai, et personne ne sait que la commande n'a jamais atterri. C'estECS-111etECS-415sous 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 :
LM-1009 C1reste 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.sessionEnergyest l'accumulateur à lire pour alimenterLoadContextTelemetry::sessionWh.- ⚠️ Il porte un OFFSET. Sa valeur absolue n'est pas l'énergie de la session courante.
deliveredWhdoit 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: 1288observé 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 deci-quality.sh, et vérifié mordant : garde retirée, il signale la ligne exacte.