ECS-Meter et PAC-Meter sont des smartMeterConsumer de generic-energy appairés à la
main : leur currentPower est SAISI, pas mesuré. 1 500 W et 800 W constants jusqu'au
2026-09-05, puis 0 depuis le passage de la box aux vrais compteurs Eastron, faute
de quelqu'un pour l'écrire.
Donc tout le §11 — compteur dédié par charge, sessionWh depuis le compteur,
measurement.source "meter" — a été éprouvé contre des valeurs tapées.
CE QUI EST PROUVÉ : la plomberie. meterThingId résolu, état lu, valeur remontée
dans measurement.powerW, sessionWh alimenté, source = meter. La chaîne existe et
transporte.
CE QUI NE L'EST PAS : la mesure. Qu'un compteur réel rattaché à une charge réelle
produise une valeur qui SUIT la commande — donc que l'écart commandé↔mesuré
signifie quelque chose.
La distinction n'est pas académique : c'est celle qui rend les traces de divergence
inexploitables sur ces deux charges. Un compteur constant produit un écart qui ne
parle que de sa propre constance.
Et ça explique ce que voit l'app : une consommation affichée sur des compteurs qui
ne mesurent rien. Elle est fidèle, la box aussi — la chaîne est juste sans source.
Troisième nomination du sixième manque du mock, par un troisième chemin : les
traces de divergence, puis la pose d'obligation, maintenant le passage au réel.
TROIS FAITS du 2026-09-06, consignés parce que les traces de lundi en dépendent.
11:47:23 — crête PV du simulateur portée de 9 000 à 6 000 W (banc/cmd/peak,
retenu, courtier 192.168.1.131). Vérifié contre le modèle : écart moyen 0,3 W sur
216 échantillons de plein jour, contre 1 102 W pour l'hypothèse 9 000.
~12:00 — voiture branchée à la V2C. pluggedIn true, lien connected,
sessionEnergy 0,44 kWh. Aucun redémarrage de nymead dans les deux cas : ce ne sont
pas des coutures de baseline.
CE QUE 6 kWc NE CASSE PAS, contrairement à ce que j'avais craint. minPowerW vaut
4140 W (3 × 6 A × 230 V) et le surplus ne dépasse pas ~2 700 W à cette crête ; j'en
avais conclu que la borne ne démarrerait jamais. Faux : mesuré à midi simulé, elle
passe en EV_GRID_START dès 2 140 W de budget, puis en SURPLUS_SETPOINT. Le chemin
d'appoint réseau existe pour ce cas, et BELOW_MIN_POWER seul ne dit pas que la
borne est bloquée — il dit que CE budget-là ne suffit pas. Encore une conclusion
tirée d'un motif lu hors de sa trajectoire.
CONVENTION DE SIGNE, mesurée pour lever une ambiguïté d'écran.
currentPowerAcquisition est NÉGATIF en injection, POSITIF en soutirage — même
convention que banc/etat/grid. Dix échantillons appariés dont un passage par zéro
(−638 → +1939 W) : 10/10 de même signe. currentPowerProduction est négatif de même,
par la convention nymea « from a consumer perspective ».
Un client qui affiche « consommation réseau » sur un currentPowerAcquisition
négatif inverse le sens : la valeur dit que l'installation INJECTE. Le signe porte
la direction ; la valeur absolue seule ne la porte pas.
Deux points qui gouvernent la LECTURE de champs que ce dépôt publie — donc ils
vivent ici, même si l'écran qui les rendra vit ailleurs.
TIMEZONE QUALIFIE UNE HORLOGE, IL N'OFFRE PAS UN CHOIX. « 12:00 heure de la box
(Europe/Paris) » dit la chose ; « Fuseau : Europe/Paris » sous un champ d'échéance
laisse croire qu'on choisit le fuseau de CETTE échéance. La nuance n'est pas
cosmétique : le fuseau est modifiable, mais c'est un réglage SYSTÈME qui déplace
d'un coup toutes les heures murales de l'installation — échéances, frontière de la
journée de reporting, et demain les plages tarifaires. Il n'y en a qu'un, et il
vaut pour tout.
L'AVERTISSEMENT DE DIVERGENCE EST CONDITIONNEL, jamais permanent : il ne s'affiche
que si les deux fuseaux comparés diffèrent réellement. Affiché en permanence, il
cesse d'être lu — et il cesse de l'être le jour exact où il devient vrai.
C'est la règle de l'absence qui informe, déjà appliquée deux fois dans ce contrat :
progressUnmeasurableCause absent quand la charge est mesurable, phase absent dans
DRAW_CAP quand aucune phase ne mord. Un champ toujours présent ne porte aucune
information ; un avertissement toujours affiché non plus.
Un compteur qui SUIVE la commande de la charge qu'il mesure a été nommé par deux
chemins indépendants en deux jours : par les traces de divergence (quatorze
épisodes qui mesurent une consigne variable contre un compteur constant), et par
la pose d'une obligation éco (ce qui borne la fenêtre n'est pas la cible, c'est la
constance du compteur).
Deux lots différents, deux symptômes sans rapport apparent, une seule cause. Le
manque n° 4 avait été nommé deux fois de la même manière, et c'est ce qui lui avait
donné sa priorité — même règle appliquée ici.
Quand un manque se fait nommer deux fois sans qu'on l'ait cherché, il ne s'agit
plus d'un confort de test : il décide de ce que plusieurs lots pourront prouver.
Posé dimanche 2026-09-06 à 08:19 (Europe/Paris) : chauffe-eau, minEnergyWhPerDay
3000 → 2000, dailyDeadline inchangé à 12:00. Discipline habituelle — capture sur
disque avant écriture, 76 clés comparées, 1 divergence attendue, 0 imprévue — mais
PAS de restauration : la configuration reste en place tout le week-end.
Symboles de débogage laissés en place jusqu'à lundi, donc aucune couture
supplémentaire pendant la fenêtre. Les deux dernières restent 06:53:30Z (fuseau) et
09:22:57 (installation des symboles), toutes deux du 2026-09-05.
CE QUE L'ÉCRAN MONTRE À LA POSE, et pourquoi ce n'est pas un défaut. À 08:19 le
palier est ECO_FLOOR_MET avec deliveredWh = 30 475 Wh pour une cible de 2 000.
Arithmétiquement correct et sans intérêt : le compteur de l'ECS publie 1 500 W en
permanence, donc deliveredWh monte de 1,5 kWh/h que la charge soit commandée ou
non. La période a basculé hier à 12:00 ; vingt heures plus tard elle a « livré »
30 kWh.
LA FENÊTRE UTILE EST À 12:00 et dure ~80 minutes. À la bascule, la base est reprise
(LM-1013-c) : deliveredWh repart de 0 face à 2 000 Wh sur 24 h, soit une cadence de
83 W financée par le réseau si le surplus manque, que le compteur fixe comble en
2000/1500 = 80 min. Deux fenêtres sur le week-end — dimanche 12:00 et lundi 12:00.
ET C'EST LA CINQUIÈME FORME D'HOMOGÉNÉITÉ QUI FIXE CE PLAFOND. Sur un banc dont le
compteur ne suit pas la commande, on ne peut pas avoir à la fois une cible modeste
et un chemin d'achat longuement exercé : ce qui décide n'est pas la cible, c'est la
constance du compteur. Tenir l'achat en éveil tout le week-end demanderait de
dépasser 36 kWh/jour, c'est-à-dire acheter au plafond — précisément ce qu'on
voulait éviter. La contrainte est du banc, pas du moteur.
CRITÈRE DE TRI (DESIGN_COMMANDE_PERDUE.md §6), daté et poussé avant l'ouverture de
la fenêtre d'observation. Un critère rédigé après coup se plie aux résultats sans
jamais mentir, et sans qu'on puisse le prouver. S'il doit changer, ce sera un
commit visible avec sa raison.
Deux conditions, toutes deux nécessaires : `measurement.source` != `none`, ET le
compteur doit avoir VARIÉ sur la fenêtre. Un compteur d'écart-type nul n'est pas
un compteur, c'est une constante déguisée, et tout écart mesuré contre lui est un
artefact.
PAS de tri par nom de charge. Écarter chauffe-eau et pac-terrain nommément serait
fragile deux fois : ça se périme au premier renommage, et ça n'attrape pas la
troisième charge à compteur fixe qu'on ajoutera sans y penser. Le critère porte sur
une propriété observable de la donnée.
LES FAUX POSITIFS DE LA V2C sont d'une autre nature, et il faut les séparer avant
de compter. Elle a un vrai compteur, donc elle passe le critère — mais chaque perte
de lien produit une divergence qui n'est pas une commande perdue : c'est une mesure
absente. « Elle n'obéit pas » et « on ne sait pas si elle obéit » sont deux
affirmations que l'écart seul confond.
C'est la distinction `none` contre l'absence, appliquée à un écart. Et le pire cas
est documenté : à la perte du lien la V2C publie currentPower = 0 (LM-1012-d), donc
le zéro est PUBLIÉ, pas absent, et rien dans l'objet `command` ne le distingue d'un
zéro réel. Il produit la divergence maximale — le faux positif qui ressemble trait
pour trait au cas cherché. Conséquence portée au lot : `command` devrait dire que
son observedW n'est pas fiable, comme progress.regime le fait pour l'avancement.
Le compte des écartés sera publié aussi : un tri dont on ne dit pas ce qu'il a jeté
n'est pas un tri, c'est une sélection.
CINQUIÈME FORME D'HOMOGÉNÉITÉ DU BANC (AGENTS.md + INVENTAIRE_MOCK.md), et elle
inverse le mécanisme. Les quatre précédentes MASQUENT un cas ; celle-ci en FABRIQUE
un faux. Un cas masqué se signale par un test qui ne mord pas ; un cas fabriqué se
signale par des données qui rassurent.
14 épisodes en 24 h, de quoi croire le phénomène fréquent et dimensionner un lot
dessus. L'instrument fonctionne, la donnée est réelle, le compte est juste — seule
la question à laquelle ces nombres répondent n'est pas celle qu'on croyait poser,
et un nombre n'annonce jamais de quelle question il est la réponse.
Manque noté au mock : aucun compteur dont la valeur SUIVE la commande de la charge
qu'il mesure. Tant que c'est le cas, aucun test ne peut exercer une charge qui
obéit, donc aucun ne peut distinguer l'obéissance de la désobéissance.
Le bloc voisin traite la conclusion juste posée sur un raisonnement faux. Celui-ci
est l'inverse, et il se lit encore moins bien : chaque prémisse vérifiable et
vraie, conclusion fausse quand même.
Le verrou de logEvent() existe, l'I/O SQLite existe, elle est bien dans le thread
appelant. Et pourtant la conclusion tombe : qCInfo ne passe pas par logEvent(), et
sur la cible le moteur de journal est Influx, qui empile et poste en asynchrone.
Le chemin coûteux existe et n'est jamais emprunté par ce qui était accusé de
l'emprunter.
RÈGLE : un chemin coûteux ne coûte que s'il est PARCOURU. La lecture établit le
mécanisme, jamais la conséquence ; passer de l'un à l'autre demande la fréquence,
et la fréquence se mesure. 2,1 ms par cycle de 60 s, soit 0,0035 %.
ET LA MESURE AUSSI PEUT PORTER À CÔTÉ. Le premier proxy, en Python, donnait 11 µs
par ligne ; la vraie valeur est 70. L'écart n'est pas du bruit : le proxy mesurait
l'écriture alors que le coût est dans le formatage — deux segments du même chemin,
et il tenait le mauvais. Une mesure ne vaut que si elle couvre le chemin entier,
dans les conditions réelles : banc Qt, vraie bibliothèque, vrai matériel.
Le bon résidu quand une piste tombe : le mécanisme reste documenté avec son
chiffre. Le jour où quelqu'un branchera logEvent() sur un signal fréquent, la
mesure d'aujourd'hui dira à quoi comparer. Une hypothèse écartée par la mesure
laisse une borne derrière elle ; une hypothèse jamais posée ne laisse rien.
L'hypothèse était que chaque qCInfo bloquait le thread de décision sur une
écriture SQLite sous verrou. Mesurée sur la cible, elle ne tient pas — et deux de
ses prémisses étaient fausses.
CE QUI ÉTAIT FAUX. (1) `qCInfo` ne passe PAS par le LogEngine : il va au
gestionnaire de messages, qui écrit sur stdout. Le chemin `Logger::log()` →
`logEvent()` est celui des états et actions journalisés, déclenché par des
changements d'état. Deux chemins, deux coûts. (2) Le LogEngine de la cible est
LogEngineInfluxDB : `logEvent()` empile dans m_writeQueue et poste en asynchrone.
Rien n'y bloque le thread appelant. Le .sqlite du rapport était celui du harnais
de test, pas de la box.
MESURÉ sur Pi Zero 2 W, banc croisé arm64 lié à libnymea, gestionnaire de nymea
installé, stdout sur la socket journald — les conditions de nymead :
· catégorie active, vers journald : 69,4 µs / ligne
· catégorie active, vers /dev/null : 61,9 µs / ligne
· catégorie éteinte : 0,0045 µs / ligne
L'I/O n'est pas le coût : 8 µs sur 70. Le reste est du formatage. Et le cycle
d'arbitrage tourne à 60 s exactement pour ~30 lignes — soit 2,1 ms par cycle,
0,0035 %. Il faudrait 143 lignes par seconde soutenues pour atteindre 1 %.
Donc : ce n'était pas un argument pour le rapport, et l'y mettre sans mesurer
l'aurait affaibli. Une première estimation en Python donnait 11 µs/ligne — six
fois trop bas, parce qu'elle ne mesurait que l'écriture et pas le formatage. Le
banc en Qt, lié à la vraie bibliothèque, était le seul moyen d'avoir le chiffre.
MAIS UN DÉFAUT RÉEL EN SORT, petit et chiffré. nymeaLogMessageHandler construit
`messageString` — dont un QDateTime::toString — POUR CHAQUE MESSAGE, alors qu'elle
ne sert que si un fichier journal est ouvert. Mesuré isolément : 28,0 µs, soit
40 % du coût d'une ligne. Et nymead tourne sans fichier journal dans le service
livré (`ExecStart=/usr/bin/nymead -n`, aucun descripteur .log ouvert) : ces 28 µs
sont jetés à chaque message, sur toute installation en configuration par défaut.
Correctif d'une accolade : construire messageString sous `if (s_logFile.isOpen())`.
Ajouté en troisième demande du rapport, explicitement indépendante du plantage.
L'hypothèse « durée de vie du Logger vis-à-vis des événements postés » est FAUSSE.
Ce n'est pas un usage après libération : c'est un déréférencement de nullptr, et
la garde censée l'empêcher interroge un ensemble différent de celui qu'elle
protège. Correctif d'une ligne.
ÉTABLI, avec les symboles 1.15.2 (identifiants de build vérifiés) :
· #0 libnymea.so.1 + 0x14ea24 → Logger::log(...), logger.cpp:55
· #1 libnymea-core.so.1+0x1edae6 → la lambda de ThingManagerImplementation::
executeAction connectée à finished
· rdi = 0x0 au signal. Dans l'ABI SysV AMD64, rdi porte le premier argument,
donc `this`. Logger::log a été appelée sur un pointeur NUL — un usage après
libération donnerait une adresse quelconque, pas zéro.
LE MÉCANISME. La lambda fait `m_actionLoggers.value(clé)->log(...)`, et
QHash::value() rend nullptr sur une clé absente. Sa garde est
`thing->loggedActionTypeIds().contains(...)` — la liste DU THING, pas la TABLE
des journaliseurs. Au retrait d'un Thing, unregisterActionLogger vide la table
pendant que la liste du Thing reste intacte. La connexion prend `this` (le
ThingManager) pour contexte, donc un `finished` déjà posté survit au retrait :
garde vraie, table vide, nullptr déréférencé.
C'est notre défaut récurrent, dans du code amont : UN GARDE-FOU QUI INTERROGE LE
MAUVAIS OBJET. Il a l'air de protéger l'accès qui suit et n'a aucun rapport avec
lui — d'où son invisibilité à la relecture.
Et ce n'est pas limité aux tests : toute suppression de Thing pendant qu'une
action est en vol ouvre la même fenêtre en production, qu'une action longue
élargit.
SYMBOLISÉ SANS RIEN INSTALLER, et sans privilèges : `apt-get download` puis
`dpkg-deb -x`, et `addr2line` sur les fichiers extraits. Noté dans le rapport,
parce que c'est aussi la voie qui ne touche pas la machine. gdb, lui, ne peut pas
produire de pile ici : le binaire de test et le plugin ont été reconstruits depuis
le plantage, donc leurs identifiants ne correspondent plus au vidage. La
résolution par offset dans les bibliothèques reste valide, et suffit.
RÉSERVE ÉCRITE : les symboles sont ceux de 1.15.2, mais le code cité est lu dans
l'arbre 1.16.0, seule source locale. Les deux symboles s'y retrouvent à
l'identique ; la formulation exacte du correctif reste à vérifier sur la branche.
LEÇON GRAVÉE (AGENTS.md, à côté de la règle des identités) : entre deux instants
du MÊME fuseau, la soustraction rend la durée d'horloge murale, jamais la durée
réelle — donc un contrôle qui cherche les journées de 23 ou 25 h avec cette
arithmétique rend 24 h partout et conclut qu'elles n'existent pas. Trois de mes
contrôles s'y sont trompés dans ce lot, sur la question même que le champ existe
pour régler.
La parade générale est la vraie leçon : l'attendu d'un test ne se calcule pas avec
les primitives du code testé. L'oracle du test de frontière vient de hors Qt ; le
re-dériver avec QDateTime aurait refermé l'identité sur elle-même — la faute que
la règle voisine traque, retrouvée un cran plus bas, non plus dans l'assertion
mais dans la fabrication de sa valeur attendue.
TA CORRECTION EST RETENUE, et elle porte sur le point qui compte. `measuredSince`
n'a pas la même sémantique des deux côtés : sur une charge il répond « depuis
quand on MESURE » et borne une fenêtre dont `targetWh` donne l'autre bout ; sur
les ratios il répond « depuis quand on COMPTE », et la paire ne borne aucune
fenêtre. Transposer un nom n'est pas transposer une sémantique.
Conséquence gravée : `periodStartedAt` n'est PAS la borne basse des ratios —
c'est `measuredSince`. Présenter comme journaliers des chiffres qui ne comptent
que depuis le dernier redémarrage serait exactement le silence que la parade
prétendait fermer. Mesuré à l'instant sur .75 : les taux couvrent 1,97 h quand
l'écran dirait 9 h.
Et la séparation d'écrans est écrite là où elle se lit : `periodStartedAt` sert le
bilan journalier, `measuredSince` sert les ratios. Ils arrivent dans la même
trame, leurs noms se ressemblent, et c'est par là que l'erreur passe.
VÉRIFIÉ À LA SONDE PLUTÔT QUE DÉDUIT DU NOM, comme demandé — et il y avait de
quoi vérifier, puisque la justification même de publier était « le recalcul
serait faux les jours de changement d'heure ». Encore fallait-il que le nôtre soit
juste ces jours-là.
· en vrai sur .75 : `periodStartedAt` == minuit local, recalculé indépendamment ;
· en test : les époques viennent d'un ORACLE calculé hors de Qt, pas re-dérivé
avec les primitives du code testé — une identité qui se referme sur elle-même
ne vérifierait rien ;
· 2026-03-29 fait 23 h, 2026-10-25 fait 25 h, les 363 autres 24 h. Un client
posant « la journée fait 86 400 s » serait juste presque toujours, et faux
deux fois par an, dont une en plein hiver de chauffage.
TROIS FOIS mes propres contrôles se sont trompés en cherchant ces deux dates : un
balayage qui n'a rien trouvé, puis deux calculs rendant 24 h partout. Cause :
l'arithmétique entre deux datetimes du MÊME fuseau est naïve — elle rend la durée
d'horloge murale, jamais la durée réelle. Le contrôle qui le prouve passe donc par
les époques, jamais par une soustraction de dates. C'est la même leçon que celle
qu'on vient de graver : un instrument peut répondre « normal » parce qu'il ne
mesure pas ce qu'on croit.
Suites : loadmodel 24/24 (+1), simulation 67/67, charging 48/48, spotmarket 32/32.
Rien à déployer : le moteur est inchangé depuis +etm56.
`Europe/London` sur un banc français était une erreur de configuration. La
correction a été différée tant que le décalage n'était pas visible : déplacer les
frontières d'une heure sans rien qui le dise aurait rendu l'historique illisible
de part et d'autre, sans qu'aucun lecteur ne puisse savoir où passait la couture.
`periodStartedAt`, posé hier, a levé cette réserve.
COUTURE : 2026-09-05T06:53:30Z. Avant, journée de reporting de 23:00 à 23:00 UTC ;
après, de 22:00 à 22:00. Toute comparaison jour à jour qui franchit cet instant
compare deux découpages différents.
Méthode : `Configuration.SetTimeZone` en JSON-RPC. `System.SetTimeZone` N'EXISTE
PAS en 1.15.2 — malgré la note de dépréciation de `Configuration.SetTimeZone` qui
dit d'utiliser celui-là. Suivre la note aurait donné « No such method ».
CONFIRMÉ EN VRAI : le moteur a suivi SANS redémarrage de nymead. Ce n'était
jusqu'ici mesuré qu'en conteneur avec un programme Qt6 ; `GetEnergyRatios.timeZone`
et `System.GetTime.timeZone` sont passés à Europe/Paris au cycle suivant, et
`periodStartedAt` a reculé de 23:00 à 22:00 UTC.
ET LA PAIRE A SERVI AU MOMENT EXACT OÙ ELLE SERVAIT. `measuredSince` n'a pas bougé
(05:04:29 UTC) : la frontière publiée a changé, la portée réelle de la mesure non.
Sans le second champ, le bilan du jour aurait paru couvrir une heure de plus qu'il
ne couvre — un décalage introduit par la correction elle-même, le lendemain du
jour où on a posé de quoi le voir.
`meterNoEnergy` remplace le `powerSwitch` détourné en instrument de mesure. Le
détour marchait — le moteur ne teste que `hasStateType("totalEnergyConsumed")` —
mais il modélisait un montage que personne ne fait, et faisait tenir DEUX rôles à
la même Thing dans le même scénario : relais d'une charge et compteur d'une autre.
La classe ne déclare PAS `energymeter` : cette interface EXIGE `totalEnergyConsumed`
et `totalEnergyProduced`. Promettre une interface qu'on ne tient pas ferait mentir
le mock exactement là où il doit être littéral. `smartmeter` est la base sans état
obligatoire, et c'est précisément ce qu'est ce matériel.
`thermalProbe` ouvre le volet thermique. La branche « sonde présente » de
attachMeasurements() était écrite depuis toujours et n'avait JAMAIS été parcourue :
aucune classe du mock ne portait l'état `temperature`. C'était la seule entrée du
volet thermique, et rien ne l'exerçait.
CE QUE L'OUVERTURE A RÉVÉLÉ, comme les trois précédentes. `setupThing()` du mock
n'avait AUCUNE sortie par défaut : une classe sans branche ne rencontrait jamais
d'`info->finish()`. Le setup ne échouait pas — il ne se terminait jamais. Le
symptôme est un Thing éternellement « en cours de configuration » et un test qui
expire ailleurs, très loin de la cause. Même mécanisme que le contrôleur
déréférencé pour toute classe : une hypothèse du mock que son homogénéité
protégeait. Une classe oubliée se dit maintenant, en qCWarning + SetupFailed.
UNE GARDE A MORDU sur son auteur le jour même. La première version du test
thermique lisait `loads[]` sans compteur racine — donc une liste vide, et trois
assertions qui ne se seraient jamais exécutées. `QCOMPARE(parId.count(), 3)` l'a
attrapé. C'est la parade écrite dans INVENTAIRE_MOCK.md, appliquée à elle-même.
RELEVÉ, NON CORRIGÉ : une sonde DÉSIGNÉE mais incapable produit la même charge
utile qu'aucune sonde du tout (`temperatureC` absent), alors que le geste de
réparation diffère — désigner une sonde, ou en changer. Silence de la famille
`progressUnmeasurableCause` ; le fermer demande un catalogue de causes thermiques
qui n'existe pas encore.
Suites : simulation 67/67 (+1), loadmodel 23/23, charging 48/48, spotmarket 32/32.
DIVERGENCE JOURNALISÉE. `divergent` et `since` ne vivaient que dans la charge
utile de télémétrie et dans un QHash en mémoire — donc nulle part. Rien ne
comptait les épisodes : chaque redémarrage de nymead effaçait l'état, et le hash
est PURGÉ dès que l'écart cesse, si bien qu'une divergence qui s'ouvre et se
referme entre deux lectures ne laissait aucune trace. Un observateur externe
échantillonnant en continu aurait raté exactement les épisodes courts, qui sont
probablement les plus nombreux.
Conséquence : le critère « attendre que les traces parlent » attendait
indéfiniment. Le silence du journal était garanti par construction, pas par
l'absence du phénomène — l'instrument dont le silence ressemble à un résultat.
Deux `qCInfo`, aux deux transitions, avec la durée à la fermeture. Le journal de
.75 est persistant depuis le 2026-08-13 (200 Mo, un mois) : le comptage devient
trivial, et l'étape 4 pourra se décider sur des faits.
FRONTIÈRE DE PÉRIODE PUBLIÉE (LM-1005-b-iv). `System.GetTime` donne un NOM de
fuseau ; un décalage ne s'en déduit pas sans base de fuseaux, et embarquer une
base pour recalculer une frontière que le moteur connaît déjà resterait faux les
jours de changement d'heure, où la journée fait 23 ou 25 heures. Trois clients la
recalculeraient de trois façons, et la seule qui compte est celle qui a servi au
calcul. Même règle que `counts`.
C'est aussi la parade à la troisième nature d'heure murale : la période de
reporting suit l'installation, correctement, mais repose sur la prémisse que le
fuseau de la box SOIT celui de l'installation — et c'était le seul des trois cas
où personne ne vérifiait. `timeZone` rend la vérification possible.
DEUX CHAMPS, PAS UN (LM-1005-b-v), et c'est un ajout à la demande. La baseline se
repose à chaque redémarrage de nymead et à chaque recul de compteur, donc EN
PLEIN JOUR. Un jour où nymead a redémarré à 14 h, les ratios ne couvrent pas la
journée : ils couvrent depuis 14 h. Publier la seule frontière ferait écrire
« aujourd'hui » sur des chiffres qui ne parlent que de l'après-midi — un silence
de la même famille que celui qu'on ferme. `measuredSince` dit la portée réelle ;
quand les deux coïncident, le cas normal, il n'y a rien à dire de plus, et c'est
ce qui rend l'écart lisible le jour où il apparaît. Même paire, même raison que
`progress.measuredSince` / `targetWh`.
Les trois champs sont TOUJOURS présents, même quand les deux taux sont omis :
« la période a commencé à minuit et rien n'est encore mesurable » est une phrase
vraie et utile, là où une réponse vide ne se distingue pas d'une panne.
INVARIANT 11 au passage : le schéma du Get et celui du Changed étaient déclarés
DEUX fois. Ajouter trois champs à deux endroits est exactement la manière dont
ils divergent — d'où `energyRatiosSchema()`, déclaré une fois et servi aux deux,
comme `loadTelemetrySchema()`.
TEST vu mordre : remplacer `measuredSince` par la frontière — l'implémentation
naïve que la demande aurait pu produire — fait échouer l'assertion sur la repose
en plein jour (attendu 10:00, obtenu 00:00).
Suites : simulation 66/66, loadmodel 23/23, charging 48/48, spotmarket 32/32.
QUATRIÈME MODE D'ÉROSION — l'inverse du troisième. La règle immortelle survit
parce que sa garde la protège ; celle-ci meurt parce que le CHEMIN qui y menait
s'est raréfié, et c'est un CORRECTIF qui l'a raréfié.
Le taux constant (LM-1013-b-v) a été décidé le 2026-09-01 avec son chiffrage.
Puis sessionWh a été alimenté depuis le compteur de charge et `unmeasurable` est
passé de « la V2C, tous les jours » à trois cas matériels rares. La décision n'a
jamais été écrite, et plus personne ne passait devant pour s'en apercevoir.
Ce qui rend la classe traître : le correctif est un PROGRÈS. On ne se méfie pas
de ce qui améliore les choses. Réduire un régime dégradé à ses cas irréductibles
est exactement ce qu'il faut faire — et c'est ce qui a fait disparaître la
décision de la vue. Parade : quand un lot RÉDUIT la fréquence d'un chemin, lister
ce qui vivait dessus et n'a pas été fait. Une décision non écrite ne se périme pas
au rythme de sa pertinence mais au rythme de sa VISIBILITÉ, et les deux viennent
de se découpler.
UN CHIFFRE FAUX DANS UNE SPEC finit par servir de prémisse. Variante numérique du
raisonnement faux sous une conclusion juste — et elle se transmet plus facilement,
parce qu'un chiffre se cite et se recopie sans ses conditions. Le ×2,5 aurait
voyagé avec « 4 kWh » et sans « fenêtre de 2,5 h ». Règle : un chiffre porte les
conditions qui le produisent, ou il se refait avant d'être écrit ; et quand il
change, la spec garde les deux valeurs et la raison de l'écart.
« LENT PAR CONSTRUCTION » ET « FIGÉ » ONT LA MÊME FORME. Règle d'investigation,
pas un fait sur selfConsumptionRate — lui va bien : sur un cycle de surplus
complet il a parcouru 53,4 → 98,4 en 54 valeurs distinctes. La leçon n'est PAS
« observer plus longtemps » : ça aurait marché ici et ça échoue sur une grandeur
à cadence horaire. Le discriminant est la NATURE de la grandeur — instantanée,
cumulée sur une période, événementielle — et elle se lit dans le contrat, pas dans
le signal. Sans cette phrase, toute observation est trop courte ou trop longue
sans qu'on puisse le savoir.
TROISIÈME NATURE D'HEURE MURALE : la période de REPORTING. GetEnergyRatios dérive
ses Δ depuis minuit local. Elle suit l'installation, ce qui est le bon
comportement — mais elle repose sur la même prémisse non vérifiée que l'échéance
client : que le fuseau de la box SOIT celui de l'installation. Sur .75, le bilan
journalier de l'app se remet à zéro à 01:00 heure française, et rien ne le dit.
Ce que la troisième ajoute : la prémisse « la box est bien réglée » ne porte pas
seulement une saisie, elle porte des GRANDEURS PUBLIÉES. Une échéance mal
fuseau-horée se corrige d'un réglage ; un bilan journalier découpé au mauvais
endroit a déjà été lu, affiché, et peut-être facturé. La colonne « qui le
vérifie » de DESIGN_FUSEAU.md répond « personne » pour celle-là.
L'agent app pousse la distinction un cran plus loin, et elle a raison : ce n'est
pas la même donnée avec deux fuseaux, ce sont DEUX DONNÉES DIFFÉRENTES. Ça change
ce qu'il faut persister, pas seulement ce qu'il faut afficher.
Une heure murale nue est CORRECTE pour une échéance client. Si la box change de
fuseau, « 07:00 » doit se réinterpréter : le client veut son eau chaude à 7 h du
matin CHEZ LUI. Réinterpréter est le bon comportement, pas une tolérance.
La même heure nue est un défaut MUET pour une plage tarifaire. La fenêtre est
définie ailleurs, par le fournisseur ; elle ne bouge pas parce que la box a bougé.
Une donnée qui se réinterprète alors que son référentiel ne bouge pas devient
fausse SANS QUE RIEN DANS LA DONNÉE NE PERMETTE DE S'EN APERCEVOIR — et la facture
est le seul endroit où ça se verrait, un mois plus tard.
D'où LM-1005-b-i : le fuseau de référence vit dans le CHAMP, pas dans une
convention d'appel. Une convention se perd au premier import, au premier
changement de fournisseur, au premier agent qui n'a pas lu le paragraphe.
LM-1005-b-ii : et il se PUBLIE. Ne pas laisser un client déduire Europe/Paris —
un fournisseur allemand définit ses plages en Europe/Berlin. Déduire, ce serait
choisir une horloge à la place de quelqu'un, exactement ce que la décision « ni
imposer, ni refuser : déclarer » refuse. Le moteur publie le fuseau de référence
avec la configuration tarifaire, comme il publiera celui de l'installation avec la
sienne.
LM-1005-b-iii : DEUX avertissements distincts, jamais fusionnés en une seule
comparaison. L'échéance compare installation ≠ appareil ; la plage tarifaire
compare fournisseur ≠ installation. Seul le second peut faire acheter au mauvais
moment, et seul le second est indevinable par un installateur — il ne se voit ni
sur la box ni sur le téléphone, seulement en rapprochant le contrat. Les fondre en
un « les fuseaux diffèrent » ferait disparaître celui qui compte derrière celui
qui se corrige tout seul.
LM-1013-b-v. Tranché le 2026-09-01, jamais écrit — la décision a passé au second
plan quand sessionWh a été alimenté depuis le compteur de charge et que le régime
`unmeasurable` s'est réduit aux trois cas matériels. C'est la première classe
d'erreur d'AGENTS.md : une règle qui expire sans être exécutée. Elle n'était ni
dans le code, ni dans la spec, ni au contrat.
LE PROBLÈME. L'étalement `reste / temps restant` suppose que le NUMÉRATEUR bouge.
Sous `unmeasurable` il ne bouge pas : `remainingWh` est figé à la cible entière,
par contrat, parce qu'on ne sait pas ce qui a été livré et que le rebaser serait
affirmer sur ce qu'on ignore (LM-1009 C1). Un rapport dont seul le dénominateur
décroît ne suit plus un retard : IL SUIT L'HORLOGE. On achetait davantage parce
que le temps passait, jamais parce que quelque chose était en retard.
CHIFFRÉ, et le chiffre a été refait plutôt que repris. Obligation 4 kWh, plafond
7 kW, fenêtre d'un jour : l'ancienne loi achetait 19 010 Wh, soit ×4,75
l'obligation. Le facteur suit ln(T·P/E) + 1 — il grandit avec la fenêtre, ce qui
est le pire sens possible : plus on laisse de temps pour bien faire, plus on paie.
Une charge qu'on mesure mal coûtait ainsi structurellement plus cher qu'une charge
qu'on mesure, l'incitation exactement à l'envers.
(Le ×2,5 retenu au moment de trancher valait pour une fenêtre bien plus courte.
Sur la fenêtre quotidienne, qui est celle du modèle, c'est ×4,75. La conclusion ne
change pas ; le chiffre, si — et un chiffre faux dans une spec finit par servir de
prémisse à quelqu'un.)
LA LOI. Cible ÷ fenêtre de l'obligation, constante, et `atMaximum` jamais vrai. La
fenêtre est le JOUR parce que l'obligation est déclarée par jour — ce n'est pas un
réglage, c'est l'unité de la grandeur qu'on étale. Le total acheté sur la fenêtre
vaut alors EXACTEMENT la cible. On garde « viser l'obligation entière », le choix
qui sert ; on perd l'escalade, qui ne reposait sur rien.
CE QUE LE TEST TIENT, et il a été vu mordre — désarmé, il rend 667 W au lieu de
167 W à six heures de l'échéance :
· la cadence est identique à 6 h, 2 h, 1 h, 30 min et 5 min de l'échéance ;
· `atMaximum` reste faux jusqu'à la dernière minute ;
· l'intégrale sur les 1440 minutes de la fenêtre vaut la cible à 5 Wh près —
la propriété qui justifie la loi ne se lit sur aucune valeur isolée ;
· CONTRE-ÉPREUVE : même obligation, même instant, même numérateur (4000 non
livrés) sous `measured` — durcissement et point de non-retour intacts. Sans
elle, avoir supprimé l'étalement POUR TOUT LE MONDE passerait inaperçu.
AU CONTRAT. La loi de commande dépend désormais du régime, et un écran qui
suppose la même dans les deux se trompera sur la trajectoire — qui est ce qu'il
vend. INTERFACE.md porte la table des deux lois, et dit ce que l'écran ne peut
plus affirmer sous `unmeasurable` : ni que l'achat va s'intensifier, ni que
`atMaximum: false` veut dire que tout va bien — il veut dire qu'il n'y a pas
d'étalement du tout, donc rien qui puisse cesser.
Suites : loadmodel 23/23, simulation 66/66, charging 48/48, spotmarket 32/32.
La distinction des deux natures d'heure murale déménage du lot qui l'a découverte
vers le lot qu'elle contraint. Rangée dans DESIGN_FUSEAU.md, c'était une note ;
rangée à l'entrée du lot tarifaire, c'est une condition d'entrée — au même titre
que l'extension du délestage l'était pour le §10.
LM-1005-b, posée juste sous LM-1005, là où la spec nomme déjà la frontière que
l'arbitre franchit en devenant acheteur : « à quel prix, à quelle heure, sous quel
signal tarifaire ». C'est exactement là que le risque naît, et pas un jour avant —
tant que l'arbitre distribue un surplus, il n'a aucune heure à respecter.
· `dailyDeadline` (habitude du client) → fuseau de l'installation
· heures creuses, Tempo, EPEX (calendrier réglementé) → fuseau où le tarif est DÉFINI
Une plage d'heures creuses française est définie en Europe/Paris par le
fournisseur, pas « à l'heure locale de la box ». Les confondre ne décale pas un
affichage : ça achète au mauvais moment, et le tarif ne s'est pas décalé. La
facture est le seul endroit où ça se verrait, un mois trop tard.
Le marché de gros amont travaille en instants absolus, donc il est déjà à l'abri.
Le risque apparaît à la première plage écrite en heures murales.
DESIGN_FUSEAU.md ne garde que l'établissement des faits et renvoie ici pour la
règle opposable — pas de règle en double, un seul domicile.
Note de forme, qui a coûté une passe : l'index des règles lit `l.strip()`, donc une
définition placée dans une citation `>` n'est JAMAIS reconnue — elle sort en
« citée et jamais définie ». L'énoncé vit hors de la citation, le bloc d'appui
dedans.
LOT FUSEAU (docs/DESIGN_FUSEAU.md) — design avant code, comme pour le délestage
et le §10. Deux questions étaient posées ; les deux sont fermées par mesure.
1. Le fuseau vient-il de la box ou de nymea ? Des deux, et c'est le MÊME.
`EnergyArbitrator::update()` prend `QDateTime::currentDateTime()`
(energyarbitrator.cpp:329), `resolveDeadline()` hérite via `now.timeZone()`,
et nymea ne fait que relayer `QTimeZone::systemTimeZoneId()`. Aucun réglage
nymea ne peut dire autre chose que timedatectl : box et moteur ne peuvent pas
diverger entre eux.
2. Mesuré dans le conteneur, avec un programme Qt6 pendant qu'on bascule
/etc/localtime sous lui : un processus VIVANT suit le changement, sans
redémarrage. Ce n'était pas acquis — la mise en cache du fuseau par processus
est le comportement classique de la libc, et si Qt l'avait fait, corriger un
fuseau n'aurait rien corrigé jusqu'au redéploiement suivant.
La vraie divergence est ailleurs, et elle est double. Entre l'INSTALLATION et le
CLIENT d'abord : `dailyDeadline` est une heure murale SANS fuseau, saisie sur un
téléphone qui a le sien, lue dans celui de la box — silencieuse des deux côtés,
chacun ayant raison dans son référentiel. Un fuseau implicite est une absence
qui signifie « le défaut s'applique », ce qu'on s'interdit partout ailleurs.
Et entre l'installation et le CALENDRIER RÉGLEMENTÉ, qui est le point neuf : une
plage d'heures creuses française est définie en Europe/Paris par le fournisseur,
pas « à l'heure locale de la box ». Deux natures d'heure murale, deux sources de
fuseau — l'habitude du client suit l'installation, le tarif suit le texte qui le
définit. Les confondre ne décale pas l'affichage : ça achète au mauvais moment.
DÉCISION : ni imposer, ni refuser — déclarer. Imposer serait affirmer un fuseau
qu'on ne sait pas vérifier. Refuser de fonctionner déplacerait un problème
d'affichage vers la sécurité, alors que L0..L4 raisonnent en durées et ne
dépendent d'aucun fuseau. Déclarer est la doctrine maison appliquée telle quelle.
DEUX MASQUES EMPILÉS (AGENTS.md, quatrième occurrence de la même famille). Le
mock plus homogène que la réalité avait une suite : l'environnement de
vérification plus indulgent que la réalité, deux fois dans la même chaîne de
build. Un artefact périmé couvrant un lien cassé, et le build vert couvrant les
deux. Un répertoire de travail accumule de l'état qui répond à la place du
dépôt — « est-ce que ça compile ? » n'a pas de valeur sans dire DEPUIS QUOI.
Seule la reconstruction dans un répertoire vierge les révèle, et le md5 identique
au binaire en service est la façon de fermer : il ne dit pas « ça compile », il
dit que le build reproductible produit exactement ce qui tourne.
Le 2026-09-04, l'étalement éco restait inobservable sur .75 alors que le moteur
était à jour (+etm53, posé la veille). La grandeur manquante — le cumul du
compteur ECS — appartient à nymea-plugin-generic-energy, jamais reconstruit
depuis le 2026-06-19.
La consigne de vérification en vigueur aurait RASSURÉ : `dpkg -l | grep
powersync-energy` répond « +etm53, tout va bien ». Elle interroge le paquet de
la fonction, et aurait imputé au moteur le silence d'un paquet qu'on ne lui
avait pas livré.
C'est un cran plus fin que le piège du 2026-09-03 : là il fallait vérifier la
version avant de conclure, ici il faut vérifier la version du BON paquet — et
savoir lequel suppose de demander à dpkg qui possède la ThingClass.
La ligne enregistrée interroge les deux encodages : une chaîne QStringLiteral
est stockée en UTF-16 et reste invisible à un `strings` nu — c'est ce qui avait
failli faire conclure à l'absence de champs pourtant publiés.
Corollaire noté au même endroit : le verrou anti-rétrogradation de deploy.sh
compare la version du MÊME nom de paquet. Un paquet ETM qui renomme celui qu'il
remplace se présente en « première installation » — aucune version comparée. La
même faille une couche plus bas : un garde-fou qui interroge le mauvais objet.
totalEnergyConsumed n'était écrit par personne : il valait sa valeur par
défaut, indéfiniment. Un compteur figé est un compteur qui n'existe pas pour
tout ce qui l'intègre — deliveredWh restait à 0, remainingWh à 4 000, et
l'étalement adaptatif n'était jamais exercé. Le comportement se confondait
avec unmeasurable pour une raison entièrement différente et bénigne.
Quatrième occurrence d'un mock plus homogène que la réalité, après les six
états cohérents du rootmeter, les classes portant toutes une puissance, et la
triphasée équilibrée.
En export il ne recule pas — il ne bouge pas : un compteur de consommation
est monotone, et le faire reculer produirait un cas que le terrain ne produit
pas, celui-là même que la garde de sémantique traite comme une anomalie. Le
mock fabriquerait le défaut que le moteur est censé détecter.
LM-1014-b appliquée au mock, question posée et répondue : le cumul est
déclaré `cached`, donc il continue au redémarrage. Repartir de zéro ferait
reculer un cumul monotone.
Et purchasedWh gagne un second usage, sous `measured` cette fois. Mesuré sur
le banc : il accumule pendant que deliveredWh peut rester plat, et cette
divergence est un contrôle que l'écran n'aurait pas su fabriquer — de
l'acheté qui monte avec du livré plat signifie soit que la charge ne consomme
pas ce qu'on paie, soit que le compteur ne compte pas. C'est écrit au contrat
parce que le jour où les deux coïncideront, quelqu'un jugera le champ
redondant : c'est précisément quand ils divergent que la paire devient
informative, et un champ qui ne sert que dans l'anomalie n'a pas l'air utile
le reste du temps.
La borne basse du test de cumul est large, et délibérément : il ne contrôle
ni la cadence du minuteur ni l'alignement du premier tic, qui n'intègre rien.
Une borne serrée en ferait un test instable — qu'on relancerait jusqu'à ce
qu'il passe.
Suite : simulation 66/66, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
`voltage` était déclarée une seule fois hors des trois blocs et mutée par
l'un d'eux : une phase sans tension déclarée était calculée avec celle d'une
autre.
Et le défaut n'était pas celui qu'on croyait — le mesurer l'a montré.
L'interface energymeter rend currentPhaseA et voltagePhaseA OBLIGATOIRES, B
et C optionnels : la branche « puissance ÷ tension » de A est morte par
construction. La fuite n'allait donc pas de A vers B, comme la note d'origine
l'affirmait, mais de B vers C.
Démontré : B à 200 V, C à 4 000 W sans tension déclarée → 5,0 A de marge au
lieu de 7,6. Deux conditions, dont aucune n'était évidente avant d'écrire le
test : le masque doit couvrir B ET C — interroger C seule n'exécute pas le
bloc de B — et C doit être la phase la plus contrainte, sinon le qMin retient
B et masque l'écart. Mes deux premières versions du test passaient pour ces
deux raisons.
Le mock ne savait pas produire le cas : il a fallu meterPowerOnly, avec les
puissances et la tension de B mais sans les courants de B/C ni la tension de
C. Troisième fois qu'ouvrir le mock révèle un défaut plutôt que de seulement
débloquer un test.
draw.bindingPhase dit LAQUELLE des phases borne. perPhaseBound disait qu'une
phase borne, jamais laquelle : l'écran ne pouvait dire que « bridé » sans
dire par quoi. DRAW_CAP la porte aussi mais n'est émis que si une charge est
refusée — un cycle qui borde sans rien refuser laisserait l'écran muet.
Et dans AGENTS.md : une conclusion juste posée sur un raisonnement faux
survit jusqu'à ce qu'on bâtisse dessus, parce que la conclusion PROTÈGE le
raisonnement de l'examen. Un résultat faux se voit ; un résultat juste ferme
la question. Parade : quand une décision est écrite avec sa raison, la raison
mérite son propre contrôle — c'est elle que le lot suivant utilisera.
Suite : simulation 65/65, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Le même geste — un bloc glissé entre un commentaire et sa déclaration, si
bien que la voisine hérite de \param qu'elle n'a pas — a été commis quatre
fois en une semaine, à attention soutenue, et rattrapé quatre fois par le
même contrôle.
Un outil qui attrape quatre fois la même erreur chez quelqu'un d'attentif ne
signale pas un défaut d'attention : il signale que le geste est piégeux. Le
contrôle rattrape après coup ; celui-ci agit à l'écriture.
inserer-doc.py localise la déclaration, REMONTE au-dessus du bloc de
documentation qui lui est déjà attaché, et insère là — le bloc existant reste
collé à sa propre déclaration. Éprouvé sur le cas réel : il a détecté la doc
de qualifierRefus, prévenu qu'il insérait au-dessus d'elle, et l'a laissée
attachée. L'erreur devient inexprimable au lieu d'être rattrapée.
La règle générale est dans AGENTS.md : compter les récidives d'un même
contrôle. Une fois est une inattention, quatre fois est une conception à
revoir — et la question n'est plus « comment mieux faire attention ? » mais
« comment rendre l'erreur impossible à commettre ? ».
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Le mock posait currentPhaseX = currentPowerPhaseX / 230 : les deux familles
d'états étaient cohérentes par construction, donc ni triphasée déséquilibrée
ni tension ≠ 230 V n'étaient fabricables. Les paramètres sont optionnels —
sans eux le comportement d'origine tient, et aucun scénario existant ne bouge.
LM-1210-a est enfin exercée : le choix de la pire phase faute de masque était
écrit, raisonné, et jamais vérifié. Phase B saturée à 30 A sous un plafond de
25 pendant que A et C exportent, et la maison exporte GLOBALEMENT — la marge
retenue est celle de B, négative, et non celle du total qui rassurerait.
Et l'ouverture a corrigé une ERREUR DE RAISONNEMENT, pas seulement comblé un
trou. Mon premier test attendait qu'une phase en export donne une marge
supérieure au plafond : c'est ce que le design affirmait. Il a échoué —
calculateAllowanceAmpere() part du plafond et ne fait que des qMin, donc la
marge y est écrêtée. Physiquement juste, mais la phrase était fausse. La
conclusion tenait, la raison non, et rien ne pouvait le révéler tant que le
déséquilibre était infabricable. Corrigée dans la spec et dans le code.
C'est la deuxième fois qu'ajouter de la diversité au mock trouve un défaut
plutôt que de seulement débloquer un test — après dryRelay, qui avait révélé
le déréférencement du contrôleur.
Et une note à côté de la garde du compteur : l'app dépend de `connected` pour
REFUSER D'AFFICHER, jamais pour COMPTER, et elle a raison. Si connected ment,
l'écran dit « hors ligne » à tort et rien n'est mal compté. Une comptabilité
n'a pas droit à cette tolérance — même donnée non fiable, deux usages, deux
verdicts. S'abstenir sur un doute est sûr, accumuler sur un doute ne l'est
pas. Quelqu'un lirait l'asymétrie comme une incohérence sans cette ligne.
Suite : simulation 64/64, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Le premier dit ce que le MATÉRIEL permet, le second ce que le MOTEUR fait.
Seul l'EvAdapter remplissait sessionMeasurable, donc sur toute charge non-VE
le premier disait oui et le second non — et l'avertissement de saisie de
l'app, qui se déclenche sur progressMeasurable, restait muet exactement sur
les charges achetées à l'aveugle. sessionWh est alimenté depuis le compteur
désigné.
L'avertissement de l'app est arrivé AVANT que j'écrive la garde, et il était
décisif. Un compteur qui disparaît peut publier 0 au lieu de se taire : la
V2C a forgé un zéro sur currentPower et ses booléens, mais laissé
sessionEnergy à sa dernière valeur. La politique dépend du plugin — donc
inutilisable comme garde.
Et le point à vérifier avait une réponse nette : il n'existe AUCUN signal de
joignabilité fiable. setupComplete parle du setup et non du lien ; connected
est maintenu par le plugin, la V2C basculant après trois échecs quand un
compteur générique peut ne jamais basculer. La garde ne pouvait pas reposer
là-dessus.
Elle porte donc sur la SÉMANTIQUE du compteur, seule chose que le moteur
sache sans parier : sur un compteur de session un recul est une session
neuve, sur un compteur de vie c'est une anomalie — on gèle, on ne recapture
jamais. Sans elle, le scénario chiffré sort tel quel : base reprise à 0
pendant l'absence, retour avec 10 kWh réels, deliveredWh à 10 000 en un
cycle, obligation déclarée tenue sans rien de livré. Et R6 ne l'attrape pas :
elle n'interdit ECO_FLOOR_MET que sous unmeasurable, alors que ce chemin est
MESURÉ — c'est le seul par lequel un faux « tenue » peut sortir, et c'est
celui qu'ouvrait l'alimentation depuis un compteur.
audit-controles.py dit maintenant en tête qu'il ne tranche pas et ne doit
jamais trancher : neuf des dix identités relevées contrôlaient réellement, et
un verdict automatique aurait supprimé neuf contrôles utiles.
AGENTS.md : la diversité du mock n'est pas un confort de test, c'est une
borne supérieure sur ce que la suite peut prouver — elle se fixe en écrivant
les classes, pas les tests.
Quatrième occurrence du piège d'insertion doxygen, rattrapée par le même
contrôle. Comme annoncé.
Suite : simulation 63/63, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
« Qu'est-ce que ce contrôle aurait attrapé s'il avait échoué ? » passée à la
suite existante. Le résultat n'est pas une liste de tests à supprimer.
Contrôles qui ne contrôlent rien : les assertions conditionnelles dans une
boucle sont à zéro — la seule qui existait a été corrigée avant-hier. Sur dix
identités auto-référentielles relevées, UNE SEULE est vaine :
authorisedW − committedW == remainingW relisait une affectation d'une ligne.
Elle est retirée AVEC SA RAISON à sa place, et avec la distinction d'avec
l'identité du budget — qui croise remainingSurplusW filé le long de la
cascade et allocatedW accumulé en trois sites distincts, donc attrape un site
qui décrémenterait sans accumuler. Supprimer sans dire pourquoi aurait invité
quelqu'un à la remettre.
La moitié qui compte : l'index annonçait 47 règles sans porteur. Il mesurait
la CITATION, pas la couverture — douze étaient couvertes par un test qui ne
les nommait pas. Citées par un test : 22 → 40. Les liens sont maintenant
vérifiables par la machine au lieu de reposer sur la mémoire de qui a écrit
quoi ; c'est la doctrine du dépôt appliquée à elle-même.
Sur les 31 restantes, 23 relèvent de familles non implantées — écrire leur
test poserait un invariant sur du code que rien n'émet. Six sont du cadrage
mal classé. Et UNE manque vraiment : LM-1210-a, la pire phase faute de
masque, qu'aucun test n'exerce parce que le mock ne sait pas produire une
triphasée déséquilibrée. Même angle mort que l'inventaire, nommé deux fois.
tools/audit-controles.py pérennise les deux motifs. Il ne remplace pas la
question, il la pose là où elle se pose souvent — et il ne juge pas : la
lecture tranche, l'écart entre les dix relevées et la seule vaine le montre.
Le résultat le plus utile n'est pas le défaut trouvé, c'est le chiffre
corrigé. On croyait 47 règles non couvertes ; il y en avait 31, dont 23
légitimes et une seule qui manque. Savoir lesquelles change ce qu'on
surveille.
Suite : simulation 62/62, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Sur measured → unmeasurable → measured dans la même fenêtre, deliveredWh
repartait de zéro : la base était retirée puis reprise à la valeur courante,
donc l'énergie livrée pendant les tronçons mesurés antérieurs disparaissait.
L'écran sous-estimait, et — ce qui coûte — la passe éco rachetait le déjà
livré.
Ce n'est pas théorique : c'est le motif d'une V2C dont le lien Modbus bat.
Chaque perte et retour fait basculer le régime deux fois, et chaque
aller-retour effaçait ce qui précède. Contre-épreuve : tronçon jeté,
l'avancement retombe de 1500 Wh à 0.
On accumule par PÉRIODE, pas par tronçon — le tronçon est clos, pas
abandonné. Et la clôture se fait sur la dernière valeur lue TANT QUE la
mesure existait, jamais sur celle du cycle où le régime tombe : à cet instant
le Thing ne publie plus, et s'en servir fabriquerait une énergie.
Deux tranchages, épinglés. Le cumul de période repart de zéro à la bascule de
période, et les trous avec lui — ils comptaient les interruptions de la
période close. Et measuredSince garde le PREMIER instant mesuré, l'
intermittence se disant à part : redater à chaque retour effacerait
l'histoire au lieu de la raconter, ne rien dire ferait croire à une
continuité que le battement dément.
LM-1014-c : un cumul se teste aussi de part et d'autre d'un CHANGEMENT DE
RÉGIME, pas seulement de période. LM-1014-b ne disait rien de la bascule de
régime à l'intérieur d'une période — c'est exactement là que le défaut
vivait.
Le test a échoué deux fois avant d'être juste, et la seconde fois c'était le
test : mon horodatage « après échéance » visait la même échéance que le
premier, donc rien ne basculait.
Suite : simulation 62/62, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
La classe `dryRelay` commute et ne mesure pas. Elle ouvre le cas que
l'inventaire à froid avait identifié comme structurellement introuvable : une
charge SAINE et NON MESURÉE. Jusqu'ici `measurement.source: none` n'était
atteignable qu'avec un Thing manquant — ce qui traîne `available: false` et
mélange deux affirmations distinctes, « rien ne mesure cette charge » et
« cette charge est en panne ». Un relais sec est banal sur le terrain, et
parfaitement sain.
Le test vérifie ce que rien ne pouvait vérifier avant : deux charges, même
source: none, available opposés, faultCode sur la seule en défaut. Il
n'aurait pas pu s'écrire hier.
Et l'ajout a révélé que le mock avait SA PROPRE prémisse fausse :
executeAction() déréférençait le contrôleur HTTP de chaque Thing sans
vérifier, supposant que toute classe en possède un. Un relais sec n'en a pas
besoin. Le plantage fut immédiat et net ; l'hypothèse, elle, était invisible
tant qu'aucune classe ne la contredisait — l'homogénéité du mock la
protégeait, exactement comme elle masquait les cas qu'il ne savait pas
produire. Ajouter de la diversité au banc n'a pas seulement ouvert un test :
ça a trouvé un défaut.
AGENTS.md reçoit la règle générale sur les identités : une identité dont les
deux termes viennent de la même source ne vérifie rien. Elle est vraie par
construction et rassure là où rien ne protège — la question n'est pas
« tient-elle ? » mais « de quoi parle-t-elle vraiment ? ». Et c'est le même
défaut que la contre-épreuve qui ne mord pas, vu de l'autre côté : perturber
une grandeur qui ne pouvait rien changer, et vérifier une identité qui se
referme sur elle-même, sont deux gestes de vérification qui ne touchent rien.
Le réflexe commun : demander ce que ce contrôle aurait attrapé s'il avait
échoué. S'il n'y a pas de réponse, il ne contrôle rien.
Suite : simulation 62/62, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Le signal vit dans GetLoadConfig, en r:, et non dans la télémétrie.
`measurement` n'existe que pour les charges arbitrées ; or une charge qu'on
DÉCLARE est précisément celle qui n'y est pas encore. L'écran serait muet au
moment exact où il doit parler — celui de la saisie, avant qu'une obligation
n'achète à l'aveugle tous les jours.
Trois causes, trois gestes : noMeter se répare en une manipulation,
meterWithoutEnergy demande de remplacer le compteur, noSessionEnergy ne se
répare pas. Sans la cause, l'avertissement ne peut dire que « ça ne marchera
pas ».
Et meterThingId ne permet pas de le déduire : un compteur peut publier
currentPower sans totalEnergyConsumed — mesuré sur l'ECS du banc, reproduit
au test avec un powerSwitch désigné comme compteur. La puissance ne suffit
pas, il faut une énergie. Contre-épreuve : déduction par meterThingId seul,
le cas du banc passe pour mesurable.
INVENTAIRE À FROID DU MOCK. Trois fois en trois jours qu'un cas réel s'est
révélé inatteignable ; ce n'est plus un incident, c'est une propriété du banc
— il est plus homogène que la réalité, ayant été construit pour faire marcher
des scénarios et non pour représenter la diversité du parc.
Quatre cas restent structurellement introuvables, dont `source: none` pour
une charge SAINE — toutes les classes pilotables portent une puissance, si
bien qu'une charge non mesurée mais en bon état est impossible à fabriquer,
alors qu'un relais sec est banal sur le terrain. Et la branche `temperature`,
écrite et jamais exécutée faute d'une classe qui porte l'état.
La règle : avant d'écrire un test qui exerce une ABSENCE, vérifier que le
mock peut la produire. Une assertion sur un cas inatteignable ne passe pas,
elle NE S'EXÉCUTE PAS — ce qui est pire, car elle compte comme verte. Le
symptôme se reconnaît : la contre-épreuve ne mord pas.
Deux ajouts dans AGENTS.md. L'asymétrie des latences est une PROPRIÉTÉ, pas
une chance : le refus qui coûte de l'argent est celui de la descente, et
c'est le plus vite constaté — ce qui n'est vrai que parce que le seuil vient
du mécanisme. Et la question opératoire appliquée aux trois autres identités,
avec un résultat inégal qui est le but : celle de `draw` repose sur le fait
que le compteur voie toute la maison, ce que rien ne vérifie ;
Σ levels[].targetW == estimatedPowerW est interne au plan et ne dit rien du
matériel — il a fallu un champ hors de la formule pour relier les deux.
Suite : simulation 61/61, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Le recrédit rendait au budget la conso d'une charge sur l'hypothèse qu'on
pourra la lui reprendre. Contre un refus établi, les watts prêtés étaient
réalloués aux suivantes pendant que la charge continuait de les tirer :
4 000 W alloués pour 500 W de surplus réel. Suspension faite, la charge
suivante ne reçoit plus que les 500 W qui existent.
Le seuil vient du MÉCANISME, jamais d'une constante. Un écart récent est une
latence — une charge qui vient d'être commandée n'a pas atteint sa consigne,
et suspendre là ferait osciller ce qui va bien. Latences mesurées sur la
V2C : montée ~30 s, descente ~7 s. L'asymétrie joue dans le bon sens, le
refus qui coûte de l'argent étant celui de la descente. Défaut prudent à
60 s : l'oscillation d'une charge saine est un coût certain, le refus n'est
qu'un risque.
Et ça se dit — RECREDIT_SUSPENDED, avec depuis quand et combien de watts
retenus. Une suspension change l'allocation de toutes les charges suivantes ;
sans le motif, on chercherait un défaut de cascade là où une charge n'obéit
pas.
Et une cinquième classe dans AGENTS.md, la seule qu'aucun contrôle ne pouvait
attraper : une PRÉMISSE FAUSSE SOUS UNE ARITHMÉTIQUE EXACTE. Les quatre
autres se laissent prendre par un instrument — un champ qui ment, un retour
indiscernable de son contraire, un instrument muet, une assertion vacante.
Celle-ci n'a aucune trace : l'identité publiée tient exactement, que la
charge obéisse ou non.
La parade tient en une phrase : une identité qui tient ne prouve pas que le
modèle est juste, seulement qu'il est cohérent. Ce qu'une identité ne peut
jamais attraper, c'est une hypothèse sur le monde. D'où la question à poser à
chaque identité publiée : sur quoi repose-t-elle qui ne soit pas dans ses
propres termes ? Ici, sur l'obéissance — grandeur absente de la formule,
qu'il a fallu aller mesurer ailleurs pour la rendre vérifiable.
Suite : simulation 60/60, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement. Contre-épreuve :
suspension débranchée, le recrédit revient à 4 400 W.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Mesuré AVANT d'implanter, et la mesure a corrigé le diagnostic.
Le constat disait « un budget de surplus qui ignore ces watts ». Ce n'est pas
ce qui se passe : quand une charge consomme et OBÉIT, la comptabilité est
juste — sa conso est recréditée puis réallouée à elle-même, et les suivantes
ne reçoivent que le surplus réel. Export 500 W, A à 4 400 W obéissante, B
reçoit 500 W. Corriger cela aurait été corriger un non-défaut.
Le vrai défaut est plus grave, et l'hypothèse est écrite dans le code :
« le budget est une mesure dont la conso de la charge a déjà été retranchée »,
c'est-à-dire « je peux rendre ces watts parce que je peux les reprendre ».
C'est un prêt, et rien ne vérifie que l'emprunteur rembourse.
Mesuré, le cas où la promesse est engagée : A commandée à 0 par son plancher,
ses 4 400 W redistribués à B. Si A obéit, tout est juste. Si A n'obéit pas —
commande perdue, mode manuel, véhicule qui limite — la maison tire 8 400 W
pour 4 900 de production : 3 500 W importés que personne n'a décidés.
Et l'identité surplus + recrédit − alloué == restant TIENT dans les deux cas.
C'est ce qui distingue un prêt d'une erreur : la comptabilité est juste,
c'est l'hypothèse sur laquelle elle repose qui n'est pas vérifiée — donc
aucun contrôle de cohérence ne pouvait l'attraper.
Le test épingle le comportement ACTUEL, correct SOUS HYPOTHÈSE, et dit à
quelle condition il devient faux : le jour où le recrédit sera conditionné à
l'obéissance constatée, c'est l'allocation de B qu'il faudra revoir, pas le
test. Écrit pour qu'on ne le « corrige » pas par réflexe.
Ce que le design proposait — retrancher la conso subie du budget — est SANS
OBJET : elle en est déjà retranchée par le compteur. La note est conservée
pour que personne ne la réimplante en la croyant oubliée.
Suite : simulation 60/60, loadmodel 22/22, charging 48/48 identique à la
référence, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Deux demandes de l'app, toutes deux fondées.
purchasedWh est publié même sous `unmeasurable`, et ce n'est pas une
entorse à la règle qui y interdit deliveredWh — c'en est le complément. Ce
qui est ACHETÉ est un fait connu sans mesure : c'est notre propre commande.
Ce qui est LIVRÉ est une observation qu'on ne peut pas faire. Sans lui, « ce
que ça coûte par jour » redeviendrait une estimation fabriquée à partir du
taux.
C'est un cumul sur la fenêtre d'obligation, donc LM-1014-b s'applique : remis
à zéro à la bascule, comme la base. Le premier cycle d'une période ne
contribue rien — il n'a pas de durée derrière lui, et en inventer une serait
fabriquer de l'énergie.
measuredSince ferme un piège d'affichage. Quand une obligation bascule de
unmeasurable à measured EN COURS de période, deliveredWh repart de 0 pendant
que targetWh reste la cible entière : une barre afficherait « 0,5 sur 4,0 »
après une bascule où 3 kWh ont pu être achetés. Rebaser targetWh serait pire
— on perdrait l'obligation réelle, et l'écran ne pourrait plus dire ce qui
reste dû. Le champ prend la seconde sortie : « mesuré depuis 09:14 », et
purchasedWh couvre l'avant. Les deux se répondent.
Et un piège de build noté dans AGENTS.md : changer la DISPOSITION d'une
structure dans un en-tête inclus transitivement laisse des objets compilés
contre l'ancienne, et le symptôme est un plantage dans un DESTRUCTEUR — qui
ressemble en tout point à un défaut mémoire du code qu'on vient d'écrire.
Avant de chercher le bug : make clean. Le plantage n'avait jamais existé.
Suite : simulation 59/59, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
`command` publie expectedW, observedW, divergent et since — seulement quand
une source mesure et qu'une commande est partie. Absent sous `none` : il n'y
a rien à comparer, et `divergent: false` affirmerait une concordance qu'on ne
peut pas constater.
L'asymétrie va dans les DEUX sens, et le contrat la dit maintenant en entier.
Sous `device`, une concordance ne prouve rien — plusieurs bornes renvoient
leur consigne en guise de puissance — mais une divergence dit que la charge
n'obéit pas. Sous `meter`, c'est l'inverse. Le test le démontre au lieu de
l'énoncer : la borne du mock dérive sa puissance de sa consigne, donc
expectedW == observedW par construction, et la divergence a dû être exercée
par un compteur explicite.
Un écart n'est pas un défaut, et le champ ne le qualifie pas. Véhicule qui
limite, câble en 16 A, mode manuel : le même nombre qu'une commande perdue.
La cause n'est pas dans les watts.
`since` est ce qui rend le champ utile — sans lui, une divergence permanente
ne se distingue pas d'un déphasage d'un cycle, et c'est toute la différence
entre le normal et le cas relevé.
Et une leçon dans AGENTS.md, appliquée à moi-même dans le même lot : une
contre-épreuve qui NE MORD PAS ne prouve pas que le code est bon, elle prouve
qu'on a perturbé au mauvais endroit. Mon assertion « sans mesure, pas de
command » était VACANTE — aucune charge du test n'avait source: none, la
boucle ne s'exécutait jamais, et la garde retirée le test passait toujours.
Toutes les classes du mock portent un état de puissance ; il a fallu une
charge au câblage manquant pour produire le cas. La contre-épreuve mord
désormais.
Suite : simulation 59/59, loadmodel 22/22, charging 48/48 identique à la
référence ligne à ligne, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
CONTRAT. Sous `unmeasurable`, remainingWh == targetWh : rien n'est
soustrait, parce qu'on ne sait rien de ce qui a été livré. 2 000 Wh restants
sur une cible de 2 000 Wh signifie « rien de mesurable », pas « rien livré » —
deux lectures qui commandent des phrases d'écran opposées, et l'écran ne peut
trancher que si le contrat le dit.
Soustraire l'intégration interne ferait franchir la frontière à une
estimation sous l'allure d'un fait : le chemin de carBatteryLevel, refusé en
LM-1009 D3. Et que `regime` voyage dans les mêmes params ne suffirait pas —
un client lisant remainingWh sans regarder regime obtiendrait un nombre
calculé, sans rien pour le lui dire.
Identité épinglée sous `measured` : deliveredWh + remainingWh == targetWh.
Troisième contrôle du même genre que Σ counts == targetW. Elle ne tient QUE
sous measured ; sous unmeasurable c'est remainingWh == targetWh qui prend le
relais, et c'est le régime qui dit laquelle appliquer.
Une honnêteté sur la portée de ce test : la contre-épreuve naïve — soustraire
deliveredWh sous unmeasurable — NE MORD PAS, puisque deliveredWh y vaut 0.
L'assertion est donc un corollaire de `delivered == false`, pas une garde
indépendante. La garde réelle est R6, vérifiée mordante au bon niveau :
publier l'estimation comme un fait fait sortir ECO_FLOOR_MET sous
unmeasurable, ce qui est exactement la violation que R6 interdit.
Commande perdue, étape 1 : l'EvAdapter retient enfin sa dernière commande.
Les adaptateurs en watts la gardaient déjà — l'écrêtage de verrou l'exige —
mais la borne, pilotée par le proxy amont, n'avait jamais eu à se relire.
C'est précisément là que le relevé porte : sans ce souvenir, l'écart entre ce
qu'on a commandé et ce que la borne fait n'est pas calculable. La commande
ÉCRÊTÉE est retenue, jamais la demande : comparer la demande ferait voir un
écart là où l'adaptateur a fait son travail. Publication seule, comportement
constant.
Suite : simulation 58/58, loadmodel 22/22, charging 48/48 identique à la
référence ligne à ligne, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Déploiement vérifié SUR MACHINE et non déduit du script : version relue,
nymead actif, arbitre initialisé, et les quatre motifs du §10 présents dans
le binaire posé.
Piège de vérification noté au passage : authorisedW, perPhaseBound et
binding sont des QStringLiteral, donc stockés en UTF-16 et invisibles à
`strings` par défaut. Un premier relevé les donnait absents — j'ai failli
les rapporter manquants. `strings -el` les trouve. Vérifier une chaîne dans
un binaire demande de savoir comment elle y est encodée.
Et le design du premier constat de l'app, ouvert plutôt qu'attaqué au code.
Trois choses qu'il établit, toutes mesurées :
Le titre est trop étroit. « Réappliquer la commande de l'arbitre » ne décrit
pas le cas : en mode manuel l'arbitre NE COMMANDE PAS. Ce qui s'est passé est
qu'une écriture du proxy a échoué pendant l'indisponibilité, que personne ne
l'a rejouée, et que l'organe est resté sur une consigne dont plus aucun
commandeur ne se réclame.
La pièce qui manque est là où le constat porte : l'EvAdapter ne garde PAS sa
dernière consigne, là où les adaptateurs en watts la gardent. Ils en ont
besoin pour l'écrêtage de verrou ; la borne était pilotée par le proxy, qui
n'a jamais eu à se relire.
Le vrai coût n'est pas l'affichage. draw.committedW à 0 pendant que 4,4 kW
sont tirés veut dire que le budget de surplus est calculé sans ces watts :
le moteur alloue de l'énergie qui n'existe pas. Le délestage, lui, protège —
il agit sur la marge MESURÉE, qui les inclut. C'est le budget qui ment.
Quatre décisions posées, dont la principale : signaler toujours, réappliquer
jamais telle quelle. Rejouer une consigne vieille de plusieurs minutes, c'est
décider avec des données mortes — ce que le mode dégradé L2 refuse déjà de
faire. Ce qui manque n'est pas le rejeu, c'est que l'écart soit DIT tant
qu'il dure, et il dure indéfiniment quand personne ne reprend la main.
Ordre de travail : mémoire de la commande, puis écart publié sans décider
dessus, puis comptabilité corrigée, et la reprise en main EN DERNIER — c'est
la seule étape qui commande du matériel, et on saura alors combien de fois
le cas se produit vraiment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Vérifié plutôt que supposé. La version EXACTE qui a planté est encore au
dépôt nymea — indispensable, car des symboles d%sune autre version ne
résolvent pas un offset, ils le résolvent FAUX.
Trois points qui rendent l%sopération sûre : la version exacte existe ;
l%sépinglage évite la cascade (sans lui apt prendrait la 1.16.0 et mettrait à
jour toute la pile de développement) ; et ces paquets sont amd64, Section
debug, n%sinstallant que /usr/lib/debug — le banc est arm64 et ne peut pas
être affecté.
Ce matin j'ai refusé de créer ce motif : il n'avait pas d'émetteur, et un
motif qui naît avant son émetteur est un invariant posé sur du code que rien
ne produit — un test toujours vert. La bascule de période l'a fait naître.
Une obligation abandonnée est désormais un fait que le moteur produit, le
journal le disait déjà, l'écran non.
Trois causes, trois gestes : drawCap envoie regarder l'abonnement,
loadCapacity la fiche technique de la charge, unmeasurable ce que la borne
publie — et unavailable s'y ajoute. Sans la cause, l'écran n'affiche qu'un
reproche sans recours.
missingWh est ABSENT sous unmeasurable, jamais 0 : on ne sait pas ce qui a
été livré, donc pas davantage ce qui manquait. Publier un chiffre
inventerait la grandeur dont l'absence est le problème.
R5 vérifiée contre la maquette plutôt que supposée conforme : la forme de
`progress` correspond exactement.
Un écart assumé et signalé : la maquette donnait ECO_FLOOR_GRID avec
deliveredWh ; le moteur publie remainingWh. deliveredWh vit déjà dans
`progress`, et le republier ferait deux sources pour un même fait. Surtout,
remainingWh n'est PAS dérivable sous unmeasurable — où deliveredWh est
absent — et c'est justement le cas où l'on achète.
R8 : le point de rupture se lit sur DRAW_CAP, jamais sur un solde tombé à
zéro. remainingW à 0 arrive aussi quand tout a été alloué normalement ; un
écran qui le guetterait signalerait une rupture inexistante et manquerait la
vraie. Écrit dans INTERFACE.md.
Suite : simulation 58/58, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Deux passes sur la même liste, même ordre ; ce qui change est la BORNE —
l'autorisation de soutirage pour l'éco, le reliquat de surplus pour le
confort. L'ordre lexicographique sur (niveau, rang) en découle : le rang
classe les charges, la hiérarchie éco/confort classe les besoins et surplombe
le rang.
Une seule commande par charge et par cycle : le niveau éco ne commande pas,
il est fusionné dans l'action de la passe confort. Émettre deux fois ferait
commuter un relais en passe 1 puis le recommuter en passe 2.
ECO_FLOOR_GRID rend lisible la TRAJECTOIRE. La cadence ne monte pas, elle
fait un mur — 333 W à six heures, le plafond dès seize minutes — et
« 1000 W achetés » seul ne permet pas de prévoir la facture. Avec le reste à
livrer et le temps restant, si. Le motif porte aussi le régime (sur quoi on
achète) et atMaximum (l'étalement a cessé).
R6 est armée à DEUX endroits : le calcul ne conclut jamais « tenue » sous un
régime non mesuré, et la passe refuse d'émettre ECO_FLOOR_MET dans ce cas.
Un invariant qui ne tient qu'à un seul endroit tient mal.
L'autorisation de soutirage borne l'éco : le plancher éco est une raison
d'acheter, pas un droit de faire disjoncter. Contre-épreuve : bornage retiré,
1000 W au lieu de 400. Et passe débranchée : un seul niveau au lieu de deux.
Recrédit attribué à la première passe où la charge apparaît, par champ
explicite — la discipline ne se teste pas, le champ si.
Mesuré avant d'être asserté, encore : aucun scénario existant ne portait
d'obligation, donc aucune référence. Et l'invariance reste utile là où elle a
un sens — aucune charge existante ne déclare d'obligation, donc charging
reste identique à la référence ligne à ligne dans une étape qui, par
ailleurs, change le comportement.
Suite : simulation 57/57, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
La frontière FABRIQUAIT le niveau à partir de l'action : elle ne pouvait donc
en produire qu'un, puisqu'il n'y avait qu'un endroit pour le former. Le plan
les porte désormais (`LoadAction::Level`), et la télémétrie les traduit au
lieu de les décider — une frontière qui invente une structure finit par
diverger de ce que le moteur a réellement décidé.
Comportement CONSTANT : un seul niveau « comfort » aujourd'hui, et les quatre
suites sont vertes avec charging identique à la référence ligne à ligne.
Le test qui exige un `levels[]` non vide a trouvé un site d'émission oublié
— SG-Ready passe par un autre chemin que les consignes en watts. D'où un
POINT UNIQUE de pose (`poserNiveauConfort`) : recopier la pose à chaque site
la ferait diverger au premier site ajouté, et l'invariant
Σ levels[].targetW == estimatedPowerW tomberait en silence, une charge sans
niveau publiant simplement une liste vide.
ECO_FLOOR_GRID rend lisible la TRAJECTOIRE, pas l'instant. La cadence de
l'étalement ne monte pas, elle fait un mur : 333 W à six heures de
l'échéance, le plafond dès seize minutes. Un client qui lit « 333 W achetés »
ne peut pas deviner qu'il paiera dix fois plus dans cinq heures si le soleil
ne vient pas. Le motif porte donc gridW, remainingWh, targetWh, remainingS,
le régime, et atMaximum — les deux derniers disant respectivement sur quoi on
achète et si l'étalement a cessé.
ECO_FLOOR_MET est INTERDIT sous `unmeasurable` (R6) : dire « tenue » suppose
de savoir ce qui a été livré. Une échéance qui cesse d'être mesurable et
continue de s'afficher comme tenue est pire qu'une échéance absente — elle
rassure.
LM-1014-b généralise les deux défauts de cumul de ces deux jours : toute
grandeur cumulée rapportée à une période doit dire ce qu'elle fait au
changement de période. La question à poser une fois, à l'écriture : que
vaut-elle à la seconde qui suit la bascule ? Ne pas choisir revient à
choisir « elle continue », presque toujours faux et toujours silencieux.
Suite : loadmodel 22/22, simulation 56/56, charging 48/48 identique,
spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
`computeEcoTarget()` convertit une obligation en énergie en une cible en
watts : cadence = reste / temps restant, recalculée à chaque cycle. Une
cadence figée au départ ne rattrape pas — après une heure sans surplus elle
reste à la cadence initiale et manque l'échéance en ayant l'air de
travailler.
Mesurer d'abord était nécessaire, pas rituel : aucun scénario existant ne
portait d'obligation éco, donc aucune référence. Les valeurs ont été
relevées par sonde avant toute assertion — et c'est le relevé qui a trouvé
un défaut que la lecture du code ne montrait pas.
LA BASCULE DE PÉRIODE (LM-1013-c). À l'instant de l'échéance quotidienne,
resolveDeadline() vise la prochaine occurrence : une nouvelle obligation
s'ouvre. Sans reprise de la base d'avancement, le lendemain hérite de
l'énergie de la veille et se croit tenu sans avoir rien reçu. La sonde
affichait 83 W et 86400 s restantes à 07:00 pile, ce qui n'avait aucun sens
tant qu'on ne cherchait pas la période. Contre-épreuve : 1800 Wh portés au
crédit du jour suivant.
Limite assumée et écrite : l'obligation précédente, si elle n'était pas
tenue, est abandonnée SANS motif dédié. Le dire demande un ECO_FLOOR_MISSED
que rien n'émet ; le journal le dit, l'écran non.
Le durcissement est relevé point par point — 333 W à six heures, 2 kW à une
heure, le plafond dès seize minutes — parce que c'est la courbe que le motif
devra rendre lisible, pas une valeur isolée. Point de non-retour nommé
plutôt que subi : un seuil se discute, une asymptote non.
R6 armée dans le calcul : sous `unmeasurable`, jamais « tenue ». On vise
l'obligation entière, le choix qui sert plutôt que celui qui suppose acquis.
Suite : loadmodel 22/22, simulation 56/56, charging 48/48 identique à la
référence ligne à ligne, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
`sessionEnergy` est l'ACCUMULATEUR du plugin V2C, pas un compteur remis à
zéro pour le moteur : l'écart avec `chargeEnergy` est constant au
dix-millième (7,2044 kWh sur deux échantillons à deux heures d'écart), parce
qu'il porte les incréments d'avant la dernière remise à zéro de la borne.
Le lire directement ferait croire toute obligation éco tenue de 7,2 kWh dès
le premier cycle — un nombre crédible et faux que rien ne signalerait.
Contre-épreuve : lecture directe, le test annonce 7204.
La base se prend à l'ouverture de l'OBLIGATION, jamais au démarrage de
nymead : sinon un redémarrage en pleine session réinitialise l'avancement
sur une valeur déjà accumulée, et la recharge de nuit relue au matin repart
de zéro. Elle est RECAPTURÉE sur un recul franc de l'accumulateur — une
nouvelle session — mais pas sur un recul infime, qui est du bruit de lecture
et coûterait l'avancement acquis à chaque frémissement du compteur.
Ce que le moteur ne refait PAS : la détection de remise à zéro de la BORNE
vit dans le plugin, qui interroge à 30 s. Le moteur travaille à la minute et
raterait des cycles entiers. Lire comment le plugin l'avait écrite a évité
d'ajouter une seconde accumulation, moins fine, en concurrence de la
première.
Trois maillons manquants raccordés, tous déclarés et alimentés par personne :
EvCharger n'exposait pas sessionEnergy ; LoadContextTelemetry::sessionWh
n'était rempli nulle part ; et toLoadContext() ABANDONNAIT les besoins
déclarés, si bien que LoadContext::needs n'était rempli par aucun adaptateur.
L'obligation éco n'existait qu'en configuration.
Mock : sessionEnergy ajouté à chargerPhaseSwitching UNIQUEMENT — `charger`
reste sans, ce qui donne le cas unmeasurable nativement. La suite porte les
deux régimes, comme le banc.
Le régime est publié alors qu'aucune décision ne s'y appuie encore, et c'est
délibéré : cela sépare publier le régime de décider avec, comme measurement
l'a été de l'arbitrage, et rend R6 testable le jour où le motif naîtra au
lieu d'un invariant posé sur du code que rien n'émet.
Suite : simulation 56/56, charging 48/48 identique à la référence ligne à
ligne, loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
`authorisedW`, `remainingW`, `binding` et `perPhaseBound` étaient absents
parce qu'aucun plafond de soutirage n'existait. Le lot délestage en a créé
un : le refus visait la FORGE, pas la lecture, et son motif est tombé sans
que sa formulation ne bouge.
`committedW` est publié toujours ; les quatre autres seulement si un plafond
est en vigueur. Le zéro est une valeur quand la grandeur existe, l'absence
en est une quand elle n'existe pas.
`authorisedW` est SIGNÉ — négatif, la maison est déjà au-delà, et l'ampleur
du dépassement se lit. L'écrêter confondrait « pile à la limite » et « on
dépasse de 1200 W », la même perte que celle refusée dans la résolution.
L'identité authorisedW − committedW == remainingW tient toujours, et c'est
elle qui rend la charge utile vérifiable par un client.
Écrit EN TOUTES LETTRES dans le contrat : sous la réserve batterie,
budget.remainingW vaut 0 pendant que draw.remainingW ne le vaut pas, dans la
même trame. La réserve annule le surplus, jamais l'autorisation de
soutirage — contrainte physique du branchement qu'aucune règle de stockage
ne modifie. Les deux répondent à deux questions différentes : « puis-je
dépenser sans acheter ? » et « le branchement supporte-t-il que j'achète ? »
Le test d'absence est mort proprement. L'ancien épinglait « pas de
authorisedW » et a survécu à son motif ; le nouveau épingle la RÈGLE —
présent si le plafond existe, absent sinon — et nomme les deux évolutions
qui le périmeraient. Idem pour le test de la réserve, qui dit que sa
troisième assertion tombe le jour où l'on déciderait qu'une batterie basse
interdit d'acheter au réseau : une décision de modèle, pas une correction.
Suite : simulation 55/55, loadmodel 21/21, spotmarket 32/32, charging 48/48
identique à la référence ligne à ligne. Contre-épreuve : réserve annulant
aussi l'autorisation → 0 au lieu de 3450.
Signalé sans être expliqué : un plantage unique de charging (SIGSEGV) dans
un lot de fond, non reproductible en trois séquences. Le vidage le situe
dans Logger::log() de libnymea.so.1, atteint par un événement posté depuis
libnymea-core — aucune trame ETM dans la pile.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
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
Tant que décider et émettre étaient le même geste, une seconde passe était
littéralement inexprimable : chaque décision produisait immédiatement une
action, et le budget se décrémentait dans le même mouvement.
`decideSetpoint()` rend désormais une CIBLE ; `emitSetpointAction()` en forme
la commande et le motif.
La contrainte du lot est tenue et vérifiée sur le fichier produit : vouluW
est capturé AVANT l'écrêtage de verrou (ECS-309), l'écrêtage précède le
décrément du budget (ECS-306), et les deux restent dans la phase de
DÉCISION. Un déplacement d'un cran publierait des motifs faux sans qu'aucun
nombre ne bouge.
Le remaniement est fait par ALIAS DE RÉFÉRENCE — `LoadAction &la = t.action`
— précisément pour que les vingt lignes d'affectation ne soient pas
retouchées. Ce qui n'est pas réécrit ne peut pas dériver. Et le typage a
servi de garde-fou : `SetpointTarget` n'étant pas `LoadAction`, un `return`
converti au mauvais endroit n'aurait pas compilé.
Les trois sorties anticipées (PHASE_LIMIT, EV_GRID_START, LOAD_UNAVAILABLE)
portent déjà leur motif : la cause est connue au moment où la décision
s'arrête, il n'y a rien à choisir.
Suite complète : simulation 54/54, charging 48/48 identique à la référence
ligne à ligne, loadmodel 21/21, spotmarket 32/32.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Préalable au reste, et il n'ajoute aucune fonction. La chaîne de sélection
du motif vivait au milieu de l'émission : il n'y avait qu'un motif possible
parce qu'il n'y avait qu'un endroit pour le former. Chaque passe devra
choisir SON motif sur SES grandeurs (R7 — le motif dit de quel niveau il
parle), donc la chaîne sort avant que la seconde passe n'arrive.
`MotifEntrees` regroupe exactement ce dont le choix dépend : ce qui n'y
figure pas ne peut pas influencer le motif, et c'est vérifiable à l'œil.
Comportement CONSTANT, et vérifié comme tel : la fonction extraite est
IDENTIQUE TOKEN POUR TOKEN à la chaîne d'origine, comparée par
normalisation. L'ordre des branches est ce qui porte les précédences
(LM-1011 : BELOW_MIN_POWER prime sur DRAW_CAP tant qu'il reste de
l'autorisation) — le réordonner changerait le motif publié sans qu'aucun
nombre ne bouge.
Deux erreurs attrapées en chemin, toutes deux par relecture :
- la substitution avait préfixé les CLÉS de charge utile ("budgetW" →
"e.budgetW"), soit un changement de contrat silencieux ;
- un `else if` transformé en `if` : sans effet ici puisque chaque branche
retourne, mais une divergence gratuite dans une fonction dont l'ordre
des branches est la spécification.
Et un piège de HARNAIS, sans rapport avec l'étape mais qui la bloquait :
testL4ShedsAPacAndSaysItForcedTheLock échouait 5 fois sur 5 alors qu'il
passait la veille, à code identique. Ni le code sous test, ni une fragilité
de temporisation — un cycle d'arbitrage tourne à l'horloge MURALE avant le
premier cycle simulé (sonde : action SG_NORMAL estampillée « 22:52:07 »).
L'armement paresseux d'ECS-412 fige m_lastSwitch sur cette heure, et un
scénario daté en dur se déroule « avant » son propre armement : elapsed
négatif, verrou jamais ouvert. Le test passait ou échouait SELON L'HEURE
QU'IL EST, ce qui est pire que les deux, et c'est aussi l'explication du
remainingS: 1288 observé plus tôt sans qu'on sache le lire.
Corrigé en ancrant l'origine du scénario sur l'horloge réelle : la
chronologie relative suffit, l'origine doit vivre dans la même horloge que
ce qui touche le système. Le commentaire dit à quelle condition ce choix
devient faux, conformément au troisième mode d'érosion.
Reste ouvert et consigné : pourquoi un cycle complet s'exécute à l'horloge
murale dans une suite compilée avec ENERGY_SIMULATION, où les minuteurs sont
explicitement exclus. Pas un défaut de production — une seule horloge y
existe — mais une faille de reproductibilité pour tout test manipulant un
Thing.
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.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
11.1 étalement uniforme, avec les trois choses qui s'écrivent avec lui :
recalculé à chaque cycle (une cadence figée ne rattrape pas et manque
l'échéance en ayant l'air de travailler) ; il se durcit à l'approche —
2 kWh font 2 kW à une heure et 12 kW à dix minutes, donc la fenêtre d'achat
massif arrive d'un coup et c'est le MOTIF qui doit le dire ; et un point de
non-retour explicite, parce qu'un seuil nommé se discute là où une asymptote
se subit.
11.2 oui aux quatre champs, et la subtilité entre dans le CONTRAT : sous la
réserve batterie, draw.remainingW reste non nul pendant que budget.remainingW
est à zéro. LM-1203-b vue depuis la frontière — la réserve annule le surplus,
jamais l'autorisation de soutirage.
11.3 dissoute, 11.4 par champ explicite.
Et un troisième mode d'érosion dans AGENTS.md, symétrique des deux autres :
non pas une règle qui se périme faute d'être exécutable, mais une règle rendue
IMMORTELLE par sa propre garde. draw.authorisedW a été refusé pour un motif
juste, épinglé par un test, puis le motif est tombé — et la formulation a
survécu, protégée par un test qui ne savait pas que le monde avait changé.
Parade déjà pratiquée ici : quand un test fige une ABSENCE, son commentaire
écrit la condition de levée. « Absent tant que rien ne le mesure » se relit et
se lève ; « ne doit pas exister » se relit et s'obéit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Rangés pour ne pas se perdre — aucun ne passe 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%s est jamais réappliquée.
Coupure Modbus, la box restaure « false 6 A », la borne reste à 7 A : 4,4 kW
tirés, draw.committedW à 0, budget de 2 349 W qui les ignore. Une seconde de
coupure suffit. Sur 18 kVA invisible, sur 6 kVA c%s est le disjoncteur.
Même famille qu%sECS-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%sexécution.
2. chargingState figé à Idle pendant une charge réelle — pas de la péremption,
personne ne maintient le champ. Un champ sans mainteneur doit être ABSENT,
pas figé, sinon un écran dit « à l%sarrêt » pendant une charge.
3. ChargingModeNormal n%est pas un boost : il restaure des valeurs mémorisées
qui peuvent être false/6 A. Le nom et l%seffet ne coïncident pas.
Et un point ouvert qui se clôt : la V2C publie sessionEnergy. PIÈGE consigné
dans le design §10 étape C — l%saccumulateur porte un OFFSET constant (7,2044
au dix-millième sur deux échantillons), donc deliveredWh doit être une
différence depuis une base capturée. Une lecture directe ferait croire toute
obligation éco tenue de 7,2 kWh au premier cycle.
Ce design précédait le lot délestage. Trois de ses sections décrivaient du
code désormais livré (le plafond comme objet, le délestage, DRAW_CAP), et
une proposait une forme depuis refusée. Le lire tel quel faisait
reconstruire l'existant.
La correction qui compte : PlanDraw n'a qu'un champ. L'absence
d'authorisedW/remainingW a été défendue en prose puis épinglée par un test,
au motif qu'un authorisedW forgé afficherait un plafond qui n'existe pas.
Ce motif est TOMBÉ — le plafond existe maintenant, et le champ serait une
lecture. C'est une décision à reprendre, pas une règle à appliquer : une
décision juste dont le motif a disparu et dont seule la formulation survit.
Plan de travail réécrit sur la stratégie qui a marché au délestage : trois
étapes à comportement constant, une seule qui change.
A — le split décider/émettre, sans ajouter de fonction. Aujourd'hui
buildSetpointAction() décide, écrête, comptabilise et motive dans le
même geste, et décrémente le budget en émettant : une seconde passe y
est littéralement inexprimable. Piège nommé : l'arrondi et l'écrêtage
de verrou doivent migrer SANS changer d'ordre relatif, ECS-306 et
ECS-309 en dépendent — les déplacer ferait publier des motifs faux
sans qu'aucun nombre ne bouge.
B — draw complété (binding, perPhaseBound, authorisedW, remainingW),
publication seule.
C — l'avancement branché EN LECTURE SEULE : sessionWh alimenté, progress
publié avec son régime. C'est là que R5 devient vraie et que R6
devient testable AVANT d'être nécessaire — on sépare publier le
régime de décider avec, comme measurement l'a été de l'arbitrage.
D — la passe éco, seule étape qui change le comportement.
Quatre décisions posées, dont la centrale : comment une obligation en
ÉNERGIE devient une cible en WATTS. minEnergyWhPerDay est transporté et lu
par aucune décision ; sessionWh est déclaré et rempli nulle part. Les deux
moitiés de l'obligation éco sont de la plomberie inerte.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
La protection de surcharge ne connaissait que les bornes VE : une PAC ou un
routeur pouvaient tenir un dépassement sans que rien ne les redescende. Elle
déleste désormais les charges du waterfall, de la dernière servie à la
première — le même ordre total que la cascade, lu à l'envers, pour que
délester ne puisse pas contredire l'ordre dans lequel on a servi.
Délester n'est pas toujours retirer des watts. Une PAC ne se module pas : on
la fait redescendre d'ÉTAT, vers 2 et jamais 1. C'est le cas que le bornage
de l'étape 3 ne pouvait pas traiter — il empêche d'allouer, il ne retire
rien à personne.
Le franchissement de verrou se CONSTATE : on tente d'abord sans forcer, et
`minOnBypassed` n'est marqué que si le verrou a réellement refusé. Le
déduire d'un verrou simplement ACTIF serait faux — un verrou peut être armé
et permettre malgré tout le geste demandé. Le marqueur annoncerait alors un
court-cycle qui n'a pas eu lieu, et le premier compresseur réellement
maltraité passerait inaperçu au milieu des fausses alertes. Ce constat
n'était pas possible avant ECS-415.
On ne force que pour un plafond SUBI. Comportement observé, non postulé : la
contre-épreuve « forçage interdit » — qui est le comportement d'un plafond
EcoSelfImposed — laisse la PAC en état 4.
Un seul commandeur par organe : les charges délestées sont revendiquées pour
le cycle et le dispatch les saute, sinon la consigne du plan rallumerait ce
que la sécurité vient de couper.
La fenêtre d'exécution est étroite — après syncAdapters(), avant le dispatch
— et les deux placements écartés sont consignés au design §6bis parce qu'ils
se représenteront.
Ce lot a été repris après un arrêt : deux versions antérieures du test
passaient sans rien prouver, leurs prémisses étant supposées. Les trois
cycles sont désormais MESURÉS puis assertés, l'armement à froid compris.
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.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Deux ajouts qui sortent d'ECS-415, à côté de la règle des garde-fous.
Le versant CONTRAT : un contrat qu'aucun appelant n'exerce n'est pas un
contrat tenu, c'est un contrat non testé. Le garde-fou dit « une règle que
rien ne rejoue se périme » ; le contrat dit « une garantie que personne ne
consomme n'a jamais été vérifiée ». D'où la conduite : avant d'exploiter une
valeur de retour qu'aucun code n'exploitait, l'épingler d'abord par un test.
Le fait de méthode : la règle d'ECS-415 était déjà écrite dans le
commentaire de la branche VOISINE, à trois lignes de la branche fautive, et
elle ne s'était pas propagée. Une règle écrite à un endroit ne se propage
pas toute seule — c'est ce qui justifie de balayer les autres implantations
du même contrat plutôt que de corriger localement. Corollaire de relecture :
la proximité d'un bon commentaire n'est pas une preuve que le code voisin le
respecte.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
SgReadyAdapter::applyAction() renvoyait, quand le verrou minStateHold
refusait la transition, l'action DEMANDÉE telle quelle. Un appelant qui
relisait `state` y retrouvait l'état qu'il venait de demander et concluait
au succès — alors que rien n'avait été écrit et que les contacts n'avaient
pas bougé.
Ce n'est pas un défaut du délestage, c'est un défaut de contrat. N'importe
quelle descente d'état pouvait échouer en silence, et `available` restait
vrai : la charge avait l'air saine pendant que la PAC tirait 3 kW. Même
famille qu'ECS-111 — une issue indiscernable de son contraire — transposée
au retour d'une commande au lieu de la télémétrie.
Toute sortie décrit désormais l'état RÉELLEMENT tenu, `estimatedPowerW`
compris. L'idempotence n'y échappe pas : demander « état 4, 0 W » à une PAC
qui en tire 3000 doit rendre 3000, pas le zéro de l'appelant — sinon il lit
son enveloppe, pas une réponse. Même correction sur la branche « motif
vide » du RelayRouter et de l'EtmVariableLoadAdapter.
Le défaut était LATENT : aucun appelant ne lisait ce retour, le dispatch
l'ignore. Le premier à s'y fier fut le délestage L4 en cours d'écriture, qui
a publié 3 kW rendus par une PAC restée en état 4. Un contrat qu'aucun
appelant n'exerce n'est pas un contrat tenu, c'est un contrat non testé.
Deux pistes écartées, mesurées et non supposées. m_currentState n'était pas
en cause : il est écrit synchroniquement, sans attendre d'acquittement.
ECS-411-b non plus : la relecture des contacts répond à une divergence entre
état interne et matériel, et il n'y en avait aucune — l'état interne était
juste, c'est le retour qui mentait.
Les trois pièges rencontrés à l'étape 4 sont consignés dans
DESIGN_DELESTAGE §6bis, les deux placements écartés compris. L'étape 4
elle-même reste non livrée.
Suite complète : simulation 53/53, charging 48/48, loadmodel 21/21,
spotmarket 32/32, doxygen 0 avertissement. Contre-épreuve : sur l'ancien
retour, refuse.state vaut 2 — l'état demandé — au lieu de 4.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p