107 Commits

Author SHA1 Message Date
06e728415f docs: protocole du relevé du 2026-09-08, écrit avant la mesure
Même raison que le critère de tri du §6 : un protocole rédigé après coup se plie à
ce qu'on a vu.

TROIS OBSERVATIONS. (1) progress.regime de la V2C — première bascule observable sur
du réel, et la dérogation en dépend entièrement puisque sa fin measurementLost se
déclenche sur ce signal. Les trois issues possibles sont écrites d'avance, et
AUCUNE n'est un échec : chacune décide de ce qu'on peut promettre. (2)
command.divergent — les premières traces qui passent le critère du §6, maintenant
qu'une borne charge vraiment et qu'un compteur suit ; regarder d'abord les épisodes
COURTS, ce sont eux qui décident si l'étape 4 vaut le risque de rejouer une
commande. (3) Min+PV sur la borne, où le tirage est dix fois celui du chauffe-eau.

Et le biais de comptage est rappelé : contrairement à « error limit reached », les
lignes [Divergence] DÉBUT/FIN sont émises aux transitions seulement — c'est le
comptage juste.

CE QUI INVALIDERAIT LE RELEVÉ est écrit aussi : tout redémarrage de nymead pendant
la fenêtre repose la base d'avancement. Ne rien déployer pendant l'observation.
2026-09-08 06:22:01 +02:00
ee67b21c2d docs(dérogation): une cible qui cesse d'être mesurable termine la dérogation
LA QUESTION QUE NI L'UN NI L'AUTRE N'AVAIT POSÉE : que fait la dérogation ENTRE le
figement de sessionEnergy et l'échéance de durée ? Elle achète au maximum sur une
mesure morte, et sur un lien qui perd 40 % de ses trames « au bout » peut vouloir
dire des heures.

POSITION RETENUE : une dérogation dont la cible cesse d'être mesurable CESSE. Elle
n'attend pas sa durée. C'est R6 transposée — là-bas « servie oui, tenue jamais » ;
ici « active oui, vérifiable non, donc on s'arrête ». Une dérogation est une
commande qui ACHÈTE, et la maintenir sans savoir où elle en est, c'est acheter à
l'aveugle. L'instrument existe déjà : measurement.source et le régime de LM-1009,
qui le disent au cycle même.

QUATRE FINS, et le motif dit laquelle a mordu : targetReached, unplugged,
measurementLost, durationElapsed. Les quatre appellent des gestes différents — et
une dérogation qui se terminerait sans dire laquelle laisserait l'utilisateur
relancer un boost que le lien empêchera encore.

L'ASYMÉTRIE, reprise du plugin V2C. La confirmation de débranchement existe
(k_absencesPourClore polls) et porte une propriété qu'il faut garder : SEULS LES
POLLS RÉUSSIS font avancer le compteur d'absences. Une coupure réseau le gèle au
lieu de le faire progresser — un lien mort ne peut donc pas fabriquer un faux
débranchement.

Et les deux erreurs ne coûtent pas la même chose : terminer à tort rend la main,
continuer à tort achète au maximum sur une mesure morte. D'où la règle, gravée dans
AGENTS.md : un mécanisme qui achète s'arrête dans le doute, et ses seuils de
confirmation sont asymétriques par conception — prompts à conclure la fin, lents à
conclure la poursuite. C'est l'inverse du réglage qu'on choisirait pour un mode.

La durée devient le FILET DU FILET : la perte de mesure attrape le cas où
l'instrument se tait et se signale ; la durée attrape le cas où même le régime ne
se signale pas. Deux filets parce que le premier repose lui-même sur un instrument.
2026-09-08 06:15:34 +02:00
60babd5af7 docs(dérogation): la cible se dit en ÉNERGIE, et la fin par SOC disparaît du design
Correction de Patrick, et elle supprime un manque au lieu de le documenter.

PAS DE COMMUNICATION VÉHICULE, et il n'y en aura pas : aucun plugin constructeur
prévu, et une intégration cloud Skoda/VW serait contraire au 100 % local, fragile,
rafraîchie à la minute au mieux.

Mais l'énergie est la BONNE grandeur indépendamment de cette contrainte. « 20 kWh
puis tu t'arrêtes » demande sessionEnergy, que le plugin V2C construit depuis le
2026-08-29. « Jusqu'à 80 % » demanderait la capacité, l'état de santé et le
rendement — trois inconnues pour dire la même chose en moins sûr. C'est exactement
ce que LM-1009 a établi pour l'obligation éco : on mesure de l'énergie parce que
c'est ce qu'on sait mesurer.

La fin par SOC ne figure donc plus dans le design, et son absence est écrite comme
une DÉCISION, pas comme un manque — sinon quelqu'un l'ouvrirait comme un lot.

ET sessionEnergy A UN SILENCE CONNU, qui justifie le filet mieux qu'un cas d'école.
LM-1012-d, mesuré sur la V2C le 2026-08-30 : à la perte du lien, currentPower tombe
à 0 et les booléens à false, mais sessionEnergy RESTE À SA DERNIÈRE VALEUR. Une
dérogation « 20 kWh puis stop » dont le compteur se fige à 12 kWh n'atteindra
jamais sa cible et achèterait au maximum indéfiniment. C'est le comportement
documenté de la borne installée, pas une hypothèse.

Conséquence heureuse : les trois fins du VE — débranchement, cible en énergie,
durée — sont toutes exerçables sans véhicule communicant. Ce lot est éprouvable sur
le banc réel, là où une fin par SoC ne l'aurait jamais été.
2026-09-08 06:09:16 +02:00
3c60c83427 docs: le boost est une DÉROGATION, pas un mode — design et règle
CADRAGE DE PATRICK, et il vaut au-delà du VE. Un mode se choisit une fois et vaut
jusqu'au prochain choix ; une dérogation répond à un besoin ponctuel et s'éteint
avec lui. Un boost encore actif après la fin est un mode qu'on a oublié de
quitter ; un boost qui rend la main est une dérogation terminée.

Conséquence de conception : ni un ChargingMode de plus, ni une demande dans needs.
Une dérogation se superpose au mode courant et s'efface. Elle n'a ni cible
d'énergie ni échéance — les deux supposent un besoin qui dure.

CE QUI LA BORNE : la sûreté seule, DRAW_CAP et le délestage, qui deviennent la
seule limite donc une limite obligatoire. Elle prend la tête de la passe éco mais
ne surplombe pas la sûreté : si DRAW_CAP mord, elle est servie la première DANS CE
QUI RESTE — sans quoi on retomberait sur le second chemin d'achat refusé pour le
minimum.

LE RANG. Le boost passe devant, y compris devant une charge mieux rangée. LM-1002
dit que le rang est ce que l'INSTALLATEUR décide ; elle ne dit rien d'une action
ponctuelle de l'utilisateur. Prendre la tête passe par une porte qu'elle ne ferme
pas.

DEUX PRÉCAUTIONS. Le mode précédent se MÉMORISE : retomber sur Normal sortirait la
borne de l'arbitrage, l'inverse du but — même famille que le repli d'échéance qui
réécrivait EcoWithMinCurrent en Eco. Et « terminé » se définit PAR MÉCANISME, ce
que le moteur ne peut pas deviner : c'est un ajout à ILoadAdapter, au même titre
que commandSettleS().

LA RÈGLE, gravée dans AGENTS.md : une dérogation qui ne sait pas dire quand elle
finit ne devrait pas exister. Sur un chauffe-eau sans sonde, « jusqu'à ce que ce
soit chaud » n'est pas mesurable — la fin doit être une durée, explicitement, et se
dire À LA SAISIE. Même raisonnement que progressMeasurable : l'écran de résultat
parle trop tard.

ET LA DURÉE EST UN FILET, jamais une option. Toute dérogation en porte une, même
avec une meilleure condition de fin, parce que cette condition repose sur un
INSTRUMENT et qu'un instrument peut se taire. Une cible qui ne remonte plus, une
sonde qui tombe : la dérogation ne finit jamais, en commandant le maximum. C'est
l'instrument dont le silence ressemble à un résultat, appliqué à une commande qui
achète. Donc jamais « jusqu'à annulation ».
2026-09-07 16:41:47 +02:00
86aab086a2 docs(min-réseau): le plancher se dérive, et la piste monophasée ne tient pas
DEUX APPORTS DU BRIEF APP f2bd07d, intégrés au design.

§9 — LE PLANCHER SE DÉRIVE, et ça corrige mon étape D. Pour une borne, la valeur
n'a besoin d'aucun champ : maxChargingCurrent.minValue × phaseCount × 230 est déjà
sur la box, dans la classe du Thing. Écrire cette valeur dans needs.minPowerWAlways
à l'appairage, comme je le proposais, créerait une COPIE QUI SE PÉRIME — un
recâblage change le réglage phaseCount et la valeur stockée resterait fausse sans
que rien ne le dise. C'est « une valeur dérivable est illégitime quand on la
publie », appliqué au stockage.

Le champ garde sa raison d'être pour les charges en watts, qui n'ont pas de
plancher matériel à dériver. Étape D révisée : le mode marque que la charge veut
un minimum, la passe éco dérive la valeur au cycle. Une seule source, jamais
périmée.

§10 — LA PISTE MONOPHASÉE EST TRANCHÉE, et par la carte Modbus elle-même. Il
n'existe AUCUN registre de phases sur la Trydan : la campagne de portage a exploré
0x0BC2-0x0BD5 et 0x177A-0x1783, et le plugin le documente. La borne ne sait pas
dire son câblage, donc n'offre rien pour le changer. phaseCount est un RÉGLAGE,
déclaration de l'installateur recopiée dans un état.

Et le détourner serait dangereux : déclarer 1 phase sur une borne câblée en 3
ferait budgéter 1 380 W et commander 6 A SUR TROIS PHASES — 4 140 W tirés. La
borne démarrerait, au mauvais courant, sans erreur. Le réglage décrit le câblage,
il n'est pas un bouton de réglage du plancher.

Ce qui reste vrai : une borne CÂBLÉE en monophasé a un plancher de 1 380 W et se
sert bien mieux sur une installation à petit surplus. C'est une décision de
câblage à l'installation, à dire au dimensionnement — pas un réglage logiciel.
2026-09-07 10:56:52 +02:00
e1079760e7 docs: measuredSince dérive au changement de fuseau, et l'inertie saine
DÉFAUT MESURÉ, non corrigé. Bascule Europe/Paris → Europe/London à 13:37:26Z,
sans redémarrage de nymead :

  periodStartedAt : 1788645600 → 1788649200  (+3600 s)  CORRECT, la journée suit
  measuredSince   : 1788645607 → 1788649207  (+3600 s)  FAUX, un instant absolu
                                                        ne bouge pas

Cause : `m_baselinePosee = now` conserve le QDateTime en spécification
Qt::LocalTime, qui garde des composantes d'horloge murale et résout son époque au
moment de la CONVERSION. Après bascule, la même lecture « 00:00:07 » rend une
époque différente d'une heure.

Le champ posé vendredi pour rendre une couture lisible est lui-même déplacé par la
couture. Correctif : `m_baselinePosee = now.toUTC()`, une ligne — un instant
absolu se stocke en UTC, là où periodStartedAt doit rester construit dans le
fuseau courant puisque c'est une frontière locale. Les deux champs n'ont pas la
même nature et le stockage doit le refléter. Non appliqué : le déploiement
redémarrerait nymead et reposerait la baseline pendant la fenêtre de capture de
l'app.

EV_MIN_GRID NE SERA PAS VÉRIFIABLE SUR MACHINE (DESIGN_MIN_RESEAU §8), et c'est
écrit avant plutôt que découvert en mesurant : la voiture est débranchée,
pluggedIn retombe, la borne sort de loads[] — vérifié, il ne reste que chauffe-eau
et pac-terrain. Le lot tiendra sur ses tests et sa contre-épreuve. Trois
préconditions cumulatives pour lever la réserve, aucune à notre main aujourd'hui.

L'INERTIE SAINE, distinguée (AGENTS.md). Tout ce qui précède traite une
contre-épreuve inerte comme un signal d'alarme ; il existe un cas où elle est
correcte — le mécanisme tire sa valeur du CONTEXTE, pas du champ perturbé. Le
plancher du minimum réseau vient de la borne, pas du champ déclaré.

Le test qui sépare : « qu'est-ce qui déciderait, si ce n'est pas ça ? » Inertie
malsaine, on ne sait pas nommer l'autre source. Inertie saine, on la nomme ET on
peut la perturber elle. D'où la conséquence pratique : une inertie saine n'est
acceptable que si la contre-épreuve est DÉPLACÉE sur la vraie source, jamais
abandonnée — sinon c'est refermer l'identité sur elle-même une fois de plus.
2026-09-06 15:41:06 +02:00
80a19a19dd docs: le mot est « minimum réseau » — ni garanti ni prioritaire
TON ARGUMENT REMPLACE LE MIEN. Ce n'est pas la modulation : l'ECS multi-paliers
module aussi. Ce qui sépare les deux cas, c'est qu'ON NE SAIT PAS COMBIEN DE TEMPS
LA VOITURE RESTE BRANCHÉE. Une obligation en énergie suppose une échéance, qui
donne son dénominateur à la cadence ; une borne n'en a aucune tant que personne
n'en déclare. « 4 140 W tant qu'elle est là » est la seule formulation qui ne
suppose rien. Un chauffe-eau a une échéance naturelle et une capacité finie :
l'énergie y est la bonne grandeur.

LE RANG EST CELUI DE L'INSTALLATEUR. Un minimum déclassé par une charge mieux
rangée est le comportement voulu — LM-1002. Le contredire par un mécanisme serait
le doublon qu'on refuse partout. Nuance vérifiée dans le code : la hiérarchie
éco/confort SURPLOMBE le rang (un confort de rang 1 passe après un éco de rang 3),
donc un minimum passe avant tout le confort, et le rang le départage face à un
autre besoin éco.

DRAW_CAP ET LA RÉSERVE BATTERIE PRIMENT. Un besoin, pas une exception aux
plafonds.

LE MOT. Les deux candidats échouent au même test que celui qu'on corrige — un nom
qui promet autre chose que ce que le code fait.

  garanti     : promet une certitude que rien ne tient.
  prioritaire : vrai face à TOUT le confort, faux face à un autre éco mieux rangé
                — donc vrai en régime normal et faux exactement dans le cas
                d'échec. Un mot qui ne se trompe que dans le cas intéressant ne
                sera jamais relu. Et il contredirait LM-1002 dans le libellé, en
                laissant croire que le mode réordonne.

RETENU : on nomme le FINANCEMENT, seule propriété qui tienne toujours et vraie
différence avec Eco — Eco dépense le surplus, ce mode ACHÈTE le complément.

  · libellé du mode : « Surplus + minimum réseau »
  · champ           : needs.minPowerWAlways, grammaire de son frère
                      minEnergyWhPerDay — les deux déclarent une demande, aucun
                      ne promet une livraison
  · bannis partout, commentaires compris : « garanti », « prioritaire »

Ce qui dit le résultat, c'est le motif : ECO_FLOOR_GRID chiffre l'achat, DRAW_CAP
nomme le plafond qui a empêché. Le nom déclare l'intention, le motif rapporte les
faits.

Document renommé DESIGN_MIN_RESEAU.md, et le mot purgé de son propre texte — y
compris des invariants, où « la garantie » s'était réintroduite quatre fois.
2026-09-06 15:13:39 +02:00
b5efcc6357 docs: design du minimum garanti — option 2, avec le renommage
Design avant code, comme pour le délestage et le §10. Rien d'implémenté.

LE CONSTAT EST DOUBLE. Le mode n'a jamais eu de minimum garanti — mort par
réécriture chez l'amont, puis mort par exclusion depuis 3g-2 (DESIGN_3g §5). Et
son nom promet l'inverse : `MinCurrent` désigne L'EXIGENCE DE LA BORNE, les 6 A
sous lesquels elle refuse de charger, pas une garantie du mode. Trois modes
annoncés, deux implémentés, et celui qui manque est celui que l'utilisateur
croyait utiliser — le défaut n'est pas qu'une fonction manque, c'est qu'elle est
annoncée.

POURQUOI MAINTENANT, sans attendre le prix. Rachat à 0,01 € : charger 30 % au
réseau en journée revient à 0,073 €/kWh contre 0,180 € la nuit. Sur la majorité du
parc neuf, aucun arbitrage n'est nécessaire pour savoir que c'est le bon choix. Le
prix ne devient décisif que sur les anciens contrats à 0,1296 €, où l'écart tombe
à deux centimes et s'inverse sous 62 % de couverture solaire. L'option 3 reste
l'horizon ; elle choisira S'IL FAUT garantir, pas COMMENT.

CONDITION 1 — machinerie du §10 : passe éco, borné par DrawCap, funding Grid,
motif qui dit combien est acheté. Exclut explicitement EV_GRID_START, dont les
trois bornes dérivent du surplus et qui achète hors du registre d'autorisation.

CONDITION 2 — un champ de plus DANS needs, frère de minEnergyWhPerDay, pas une
structure parallèle : `guaranteedPowerW`. À ne pas confondre avec minPowerW
(LM-1201) : le premier borne une consigne SI on sert, le second en crée une
TOUJOURS. La passe éco rend max(cadenceObligation, guaranteedPowerW), avec quatre
invariants — la garantie ne se tient jamais (donc watts retombe sur elle quand
l'obligation est tenue, pas sur zéro) ; progress ne décrit que l'obligation et est
ABSENT sur une garantie seule ; le motif dit lequel des deux mord ; une garantie
non honorée se dit par DRAW_CAP qui la nomme.

CONDITION 3 — le fil ne peut pas être renommé sans casser les clients déployés.
Ce qui se corrige est la DESCRIPTION au contrat, d'où l'app tire ses libellés.
ChargingModeNormal n'est pas « recharge immédiate au maximum » : c'est MANUEL, la
borne sort de l'arbitrage et le moteur ne commande rien — seule la protection de
surcharge la touche encore, ce qui doit rester et doit être dit.

QUATRE ÉTAPES, à comportement constant d'abord : corriger le contrat (aucun code),
porter et publier le champ sans s'en servir, la passe éco le prend en compte, puis
le mode s'y branche.

ET CE QU'ON NE SAURA PAS PROUVER AU BANC : 2 kWc contre un plancher de 4 140 W,
donc une garantie toujours financée au réseau et jamais le cas mixte. Le
chauffe-eau l'exercera dès qu'il aura un compteur réel — quatrième nomination de
la même précondition.
2026-09-06 14:51:35 +02:00
fd516daea5 docs(3g §5): Min+PV — le mode a un minimum garanti, qui n'a jamais tourné
Établi, aucun correctif posé.

1. ChargingModeEcoWithMinCurrent A un comportement de minimum garanti : branche 4
   d'adjustEvChargers(), 6 A inconditionnels, sans condition de budget ni
   autorisation de soutirage. Ce n'est pas un EcoCharging avec un seuil.

2. Mais ce chemin n'a JAMAIS tourné, deux fois pour deux raisons. Chez l'amont, le
   repli d'échéance réécrivait le mode en ChargingModeEco à chaque cycle : branche
   morte, réglage détruit en silence. Corrigé côté ETM. Puis depuis 3g-2, le
   crochet evCommandedByWaterfall() exclut du proxy toute borne que le waterfall
   commande — la branche redevient inatteignable, pour une raison nouvelle. Mort
   par réécriture, puis mort par exclusion, sans que rien ne le signale.

3. Et sur cette installation il n'aurait rien changé : plancher 4 140 W contre
   2 000 W de crête. La voiture aurait chargé entièrement au réseau, en
   permanence, sans qu'aucun mécanisme ne le compte.

4. EV_GRID_START ne peut pas porter un minimum permanent. Ses trois bornes
   dérivent toutes du surplus — budgetW > 0, seuil proportionnel, consommation de
   tout le budget — et un minimum permanent en est indépendant par définition. Les
   lever, c'est créer le second chemin. Et EV_GRID_START achète HORS du registre
   d'autorisation de soutirage, là où la passe éco achète sous DrawCap : deux
   mécanismes dont un seul est borné, et c'est le second qui ferait disjoncter.

5. La différence de nature demeure : l'obligation éco est une énergie avec
   échéance, Min+PV une puissance permanente. On peut approcher la seconde par la
   première, mais sous régime mesuré le plancher retombe à zéro dès la cible
   atteinte — ce n'est pas un minimum permanent.
2026-09-06 14:41:28 +02:00
9659518916 docs: verdict de la fenêtre du week-end — rien d'exploitable, et pourquoi
La V2C est restée HORS WATERFALL du samedi 18:00 au dimanche 11:46, 1 066 cycles
portant « Borne V2C Trydan hors waterfall (mode manuel ou aucun véhicule
branché) » et « has no car assigned. Ignoring... ». Elle n'a produit aucune trace
sur la période visée, et ce n'était pas un battement de lien.

La voiture est devenue visible à 11:46 dimanche, pas samedi soir. Huit cycles
utiles ont suivi, 11:47 à 12:08, sur le PV simulé à 9 kWc — démarrages en
tolérance d'acquisition puis consignes de surplus. Puis plus rien : le banc est
passé d'un simulateur 9 kWc à une installation réelle de 2 kWc, et 2 kWc ne peut
pas alimenter le plancher de 4 140 W d'une borne triphasée à 6 A.

Bilan : V2C aucune trace, chauffe-eau et pac-terrain écartées par le critère du §6
(compteurs constants, 1 500 et 800 W puis 0). C'est un résultat, pas un échec — il
dit que la fenêtre a été ouverte sur un banc qui ne pouvait pas la remplir, et il
nomme les deux raisons.

Précondition pour la prochaine : un compteur réel sur une charge pilotée, et une
borne effectivement dans le waterfall — vérifié au journal AVANT d'ouvrir.
2026-09-06 13:23:57 +02:00
4259faee5b docs(terrain): §11 a été validé contre des valeurs tapées, jamais mesurées
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.
2026-09-06 12:47:50 +02:00
322cf27e0f docs: crête PV à 6 kWc, voiture branchée, et la convention de signe du réseau
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.
2026-09-06 12:11:48 +02:00
f956e0f049 docs(inventaire): le sixième manque est nommé deux fois, donc prioritaire
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.
2026-09-06 08:54:35 +02:00
bf8d890c8e docs: conditions de la fenêtre d'observation du week-end
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.
2026-09-06 08:20:33 +02:00
217f20b58f docs: le critère de tri de l'étape 4, écrit AVANT les données
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.
2026-09-05 16:54:38 +02:00
85ba2a5d3f docs(upstream): la journalisation ne vole pas de temps au cycle — mesuré, pas déduit
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.
2026-09-05 10:25:48 +02:00
9eef60fc35 docs(upstream): SIGSEGV symbolisé — l'hypothèse tombe, c'est un pointeur NUL
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.
2026-09-05 09:33:17 +02:00
88c96c2264 ops(banc): .75 passe en Europe/Paris, et la couture est datée
`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.
2026-09-05 08:54:42 +02:00
119cffc745 test(mock): les deux derniers manques — compteur sans énergie, sonde thermique
`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.
2026-09-05 08:52:24 +02:00
be556ef207 docs: quatrième mode d'érosion, et la troisième nature d'heure murale
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à.
2026-09-05 07:39:15 +02:00
39588760a3 spec(LM-1005-b): le fuseau d'une plage tarifaire, en précondition du lot tarifaire
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.
2026-09-04 16:39:20 +02:00
18e9c3abe4 docs: ouvrir le lot fuseau, et graver les deux masques empilés
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.
2026-09-04 07:52:28 +02:00
Patrick Schurig
04852c518a test(mock): le compteur intègre enfin, et purchasedWh gagne un second usage
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
2026-09-04 06:19:08 +02:00
Patrick Schurig
c88ba6ad45 [upstream-fix] rootmeter: la tension est locale à chaque phase
`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
2026-09-04 06:02:17 +02:00
Patrick Schurig
f3cddedee0 test(mock): la tension par phase, et LM-1210-a enfin exercée
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
2026-09-03 07:50:56 +02:00
Patrick Schurig
08558540ed audit: la question passée à toute la suite — ce qu'on croyait vérifié
« 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
2026-09-03 06:58:42 +02:00
Patrick Schurig
cdd3bc88f3 test(mock): le relais sec, et le cas que le banc ne savait pas produire
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
2026-09-02 08:00:51 +02:00
Patrick Schurig
ccf05cea87 feat(R6): la mesurabilité se déclare à la saisie, et l'inventaire du mock
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
2026-09-01 17:27:56 +02:00
Patrick Schurig
3eb59e86f9 docs+test(commande perdue §3): le recrédit est un prêt consenti contre l'obéissance
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
2026-09-01 11:58:06 +02:00
Patrick Schurig
4d73ff2482 docs: §10 déployé sur .75, et le design de la commande perdue
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
2026-09-01 07:12:05 +02:00
Patrick Schurig
24d8f55997 docs(amont): le dbgsym peut être posé sans risque, et sans toucher au banc
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é.
2026-09-01 07:05:52 +02:00
Patrick Schurig
a8f8734fc2 feat(§10 étape C): l'avancement est une différence, et le régime se publie
`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
2026-08-31 07:53:15 +02:00
Patrick Schurig
0d2b6728ae [upstream-fix] un verdict de test ne doit pas dépendre de l'heure
Six appels à `update(QDateTime::currentDateTime())` manquaient la garde
ENERGY_SIMULATION que leurs voisins ont (l.124, l.288) :
setPhasePowerLimit, setAcquisitionTolerance, et trois dans
onActionExecuted — qui partent sur toute action manuelle appliquée à une
borne.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-31 00:10:55 +02:00
Patrick Schurig
b2f19443fc refactor(§10 étape A1): le choix du motif devient une fonction pure
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
2026-08-30 22:58:54 +02:00
Patrick Schurig
2a584de913 docs: les quatre décisions du §10, et un troisième mode d'érosion
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
2026-08-30 22:38:21 +02:00
Patrick Schurig
f2a96a0193 docs(relevé app): trois constats à traiter après le §10, et sessionEnergy tranché
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.
2026-08-30 22:37:27 +02:00
Patrick Schurig
2d615e6bcc docs(design §10): réconcilié avec le délestage livré, et plan en quatre étapes
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
2026-08-30 22:34:44 +02:00
Patrick Schurig
b7a7f22ca3 feat(délestage §4): L4 déleste le waterfall, et dit ce qu'il a franchi
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
2026-08-30 15:25:20 +02:00
Patrick Schurig
d6dc9dcd4e fix(ECS-415): un refus doit se distinguer d'une application, par le retour
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
2026-08-30 13:47:36 +02:00
Patrick Schurig
b0c96972de docs(LM-1210-a): le masque de phases est un report daté, pas une position
Vivre avec le pessimisme de la pire phase est le bon arbitrage pour une
couche de sécurité — il coûte du rendement, jamais de la sécurité. Mais
l'échéance se rapproche par les deux bouts, et l'écrire comme une position
de principe serait faux.

Côté matériel, l'information existe déjà pour une borne : la V2C publie
currentL1/L2/L3 depuis le 2026-08-28. Côté modèle, le §14a distinguera par
phase et ne pourra pas se contenter d'un nombre.

Le jour venu, houseDrawMargin() retiendra la phase de la charge au lieu de
la pire : un seul appel change, parce que l'étape 2 compare des MARGES et
non des plafonds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-30 13:05:36 +02:00
Patrick Schurig
b274989166 feat(délestage §3): la cascade tient dans deux ressources, et DRAW_CAP naît
Le surplus dit ce qu'on peut dépenser sans acheter ; l'autorisation de
soutirage dit ce que le branchement supporte. Une charge doit tenir dans
les deux. Jusqu'ici seules les bornes VE étaient bornées par la seconde —
une charge de waterfall pouvait donc créer un dépassement que seule la
protection L4 rattrapait, après coup. C'est ce qui change.

`SurplusContext::drawMargin` est une valeur de CYCLE, résolue une fois par
`houseDrawMargin()` et jamais relue : deux lectures à deux instants
feraient décider sur deux mondes. Chaque charge servie l'entame.

Le motif porte les quatre grandeurs gravées au §6.3 : la marge (SIGNÉE),
l'ampleur du dépassement, la source, et la phase qui borne. La phase n'est
publiée que s'il y en a une — un plafond total n'en a pas, et en annoncer
une enverrait mesurer au hasard. Précédence LM-1011 appliquée telle quelle :
DRAW_CAP seulement à autorisation NULLE, sinon BELOW_MIN_POWER.

Les bornes VE ne sont pas écrêtées par la marge de la maison, et ce n'est
pas un oubli : `evPhaseAllowanceW()` connaît LES PHASES QU'ELLES OCCUPENT.
Leur appliquer en plus la pire phase les priverait d'une phase saine parce
qu'une autre sature. Entre deux bornes justes on garde la mieux informée,
pas la plus sévère. Elles décrémentent la marge comme les autres.

LIMITE DE MODÈLE assumée (LM-1210-a) : `LoadDeclared::phases` est un nombre,
pas un masque — le moteur ignore sur quelle phase une charge est câblée. La
pire phase est donc retenue. Deviner, dans une couche de sécurité, se paie
au disjoncteur. Le pessimisme est borné en pratique et coûte du rendement,
jamais de la sécurité ; le lever demande un masque déclaré, donc config,
interface et migration. Documenté dans la spec et le design, à trancher.

Suite complète : simulation 52/52, charging 48/48 identique à la référence
ligne à ligne — les bornes ne bougent pas, c'est l'invariant du lot —
loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement. Contre-épreuve :
bornage débranché, la charge est servie 1000 W alors que l'autorisation est
déjà dépassée de 460 W.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-30 12:29:38 +02:00
Patrick Schurig
13d24e10df [upstream-fix] rootmeter: lire l'état qu'on vient de tester (phases B et C)
Les branches des phases B et C testaient `currentPhaseB`/`currentPhaseC`
— des ampères — puis lisaient `currentPowerPhaseB`/`currentPowerPhaseC`
divisés par 230 — des watts. La phase A, elle, lit bien l'état qu'elle
teste. L'asymétrie n'était pas cosmétique : un compteur publiant les
courants par phase SANS les puissances par phase entrait dans la première
branche, y lisait 0, et se voyait accorder une place libre égale au plafond
entier. La protection de surcharge était AVEUGLE sur B et C, sans rien
signaler.

Le correctif change le comportement, et dans le bon sens : des bornes
seront écrêtées qui ne l'étaient pas, parce que la protection redevient
effective. Sur une triphasée déséquilibrée, le disjoncteur était jusqu'ici
le seul rempart — L0 employé comme régulateur au lieu de dernier recours.

Aucun test de ce dépôt ne pouvait le trouver : le mock pose currentPhaseB =
currentPowerPhaseB / 230, donc les deux états y sont cohérents par
construction et lire l'un ou l'autre y donne le même nombre. Le test
désaccorde les états à la main, ce qu'aucun chemin normal du mock ne
permet ; sans le correctif il annonce 25 A de place libre sur une phase qui
en tire 20.

L'angle mort est nommé comme une CLASSE dans SAFETY.md, et non comme un
incident : l'installation de référence n'exerce pas le cas, une suite verte
ne dit alors rien sur lui, et `stateValue()` d'un état absent rend 0 —
valeur qui, dans un calcul de marge, se confond avec « rien ne consomme ».

Un défaut voisin de la même fonction est consigné sans être corrigé :
`voltage` est déclarée hors des trois blocs et mutée par celui de la phase
A. Il aura son propre correctif isolé.

Fait avant l'étape 3 délibérément : c'est la couche de sécurité, c'est le
chemin qu'on étend, et brancher la marge dessus l'aurait rendu plus
difficile à isoler.

Suite complète : simulation 51/51, 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
2026-08-30 12:11:43 +02:00
Patrick Schurig
b29489bdb5 feat(délestage §2): le disjoncteur passe par la résolution, au watt près
`phasePowerLimit` devient un `DrawCap{Breaker, perPhaseW}` et
`evPhaseAllowanceW()` traverse `resolveDrawMargin()` — la mécanique du
délestage est branchée, et rien ne bouge. C'est le but : à comportement
constant, tout écart observé ensuite est un défaut, pas une évolution.

L'égalité est obtenue par CONSTRUCTION. `phaseDrawW()` inverse
`calculateAllowanceAmpere()` phase par phase plutôt que de relire les états
du compteur : les lectures amont ne sont pas symétriques entre phases, et
les recopier ici en aurait fait deux versions vouées à diverger.

Deux déplacements sans changement de valeur : l'écrêtage `qMax(0, …)`
descend chez le consommateur (la résolution rend une marge SIGNÉE, dont
l'étape 4 a besoin pour dire de combien délester), et le facteur 230 remonte
dans le plafond, qui s'exprime en watts comme les autres.

Vérification : quatre suites diffées ligne à ligne contre une référence
capturée avant l'édition — charging 48/48 et simulation 50/50 identiques,
loadmodel 21/21, spotmarket 32/32. Contre-épreuve : à 1 V près la suite ne
discrimine pas (consignes en ampères entiers), donc cet essai ne prouve
rien ; à un facteur dix, 7 échecs. Le chemin est vivant.

Au passage, une `\note` du .h affirmait qu'on rend à la borne ce qu'elle
tire déjà. Le code ne l'a jamais fait, et c'est délibérément de sécurité :
sur une phase en dépassement la marge est négative, et c'est ce signe qui
ramène la borne à son minimum.

Décisions 6.1 à 6.3 gravées dans le design, et défaut amont consigné dans
SAFETY.md — non corrigé sciemment, il appartient à un [upstream-fix] isolé.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-30 11:58:23 +02:00
Patrick Schurig
0908943da8 docs(design): l'extension du délestage au waterfall — la précondition du §10
Écrit avant tout code de liaison, comme le workflow l'exige pour une couche de
sécurité. Il dit sous quelle forme, dans quel ordre, et nomme trois points à
trancher.

L'écart mesuré : verifyOverloadProtection() parcourt m_evChargers et rien
d'autre ; le waterfall ne connaît le plafond que par evPhaseAllowanceW(), et
uniquement pour les bornes. Les charges pilotées et la PAC n'ont aucun plafond —
invisible tant que la cascade ne dépense que du surplus, puisqu'on ne peut pas
dépasser ce qu'on n'achète pas. Le §10 change cela.

LE POINT TECHNIQUE DU LOT, et il n'est pas évident : on ne compare pas les
plafonds — ils n'ont pas la même forme — on compare LES MARGES qu'ils laissent.
Un plafond par phase donne sa marge sur la phase la plus chargée, un plafond
total la sienne sur le soutirage mesuré ; les deux sont des watts, donc
comparables. Le plus petit mord, et on retient sa source. Aucune conversion, donc
aucune hypothèse d'équilibre entre phases — et la marge est exactement ce que la
cascade doit connaître pour ne pas dépasser : le même nombre borne et explique.

Deux endroits, deux rôles, et ce n'est pas une redondance : la cascade empêche de
CRÉER le dépassement, la protection L4 rattrape ce qu'on n'a pas décidé — une
plaque de cuisson, un sèche-linge. L4 garde le dernier mot.

Quatre étapes dont DEUX À COMPORTEMENT CONSTANT (le type, puis la source Breaker
branchée à résultat identique au watt près). Dans un lot de sécurité, ce qui coûte
n'est pas d'écrire le comportement, c'est de prouver qu'on n'a rien cassé.

Trois décisions demandées, avec recommandation : la marge tient-elle compte du
commandé non encore mesuré (recommandé NON — une hypothèse fausse y coupe du
courant) ; que fait le délestage d'une charge sous minOn (recommandé : bypass pour
Breaker et GridOperator, PAS pour EcoSelfImposed — un plafond qu'on s'impose ne
justifie pas d'abîmer un compresseur, et c'est la première conséquence concrète
du fait que la source soit un paramètre) ; et ce que porte le motif DRAW_CAP.

Et ce que le lot ne fait pas : aucun transport §14a, aucun changement du repli L2
— le plafond auto-imposé est respecté par construction dès lors que le repli ne
dépense pas, et l'exigence dure appartient au §14a, hors du moteur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-30 11:24:52 +02:00
Patrick Schurig
1732f1a640 feat(R1,R7): decision.level, et l'absence d'un niveau distinguée de son zéro
R7 — decision.level sur le motif hérité, présent exactement quand levels[] l'est.
Sans lui chaque client réinvente sa règle de fusion et deux écrans affichent deux
motifs pour le même cycle. Un seul niveau aujourd'hui, donc la question ne se pose
pas : c'est la raison d'ajouter le champ maintenant plutôt qu'avec le §10 — même
argument que R2 sur le coût de déplacement.

R1 — l'absence et le zéro épinglés dans les deux sens. Niveau absent = passe non
parcourue ; niveau à targetW 0 = passe parcourue, rien à prendre. Publier l'un
sans l'autre les rendrait indistinguables. Aujourd'hui la passe unique est
toujours parcourue dès qu'une charge est arbitrée, donc c'est le MODE DÉGRADÉ qui
porte le cas « aucune passe » — levels[] absent, planification suspendue.

Vérifié échouant dans les deux sens : un niveau à zéro omis, et un decision.level
publié sans levels[].

LM-1014 — un client teste la PRÉSENCE d'un champ, jamais une version. La règle
vient de l'agent app, qui n'a pas retiré son exception EV_GRID_START mais l'a
déplacée sur la bonne frontière : elle survit là où la charge utile n'a pas de
counts. Ce qui rend une capacité détectable est la charge utile, pas un numéro —
une box se rétrograde, un paquet se reconstruit, une branche se déploie hors
séquence. Portée aussi en tête d'INTERFACE.md, avec sa conséquence : les versions
y datent les changements, elles ne sont pas des conditions à tester.

R5, R6, R8 NE SONT PAS REPOUSSÉES — elles sont sans objet, et je l'ai vérifié
plutôt que supposé : ECO_FLOOR_MET, ECO_FLOOR_GRID et DRAW_CAP n'existent pas
dans le catalogue. Écrire la garde de R6 aujourd'hui poserait un invariant sur un
code que rien n'émet, c'est-à-dire un test toujours vert.

simulation 35/35, charging 17/17, loadmodel 20/20, spotmarket 7/7, amd64 0/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-30 11:05:36 +02:00
Patrick Schurig
ed877d86d2 docs: la trace du partage EV_GRID_START, verbatim, pour l'agent app
Il ne peut pas produire ce régime sur le banc : pluggedIn est en lecture seule,
aucune action RPC ne l'écrit, et sans assignedCarId la borne reste hors loads[].
Le partage est une propriété du CODE, pas du matériel — le banc n'ajouterait que
la confirmation que le binaire arm64 fait pareil.

La charge utile de testEvGridStartIsAnnouncedAndBounded est donc reproduite telle
quelle dans le brief, avec son critère vérifiable ligne à ligne :

  params.budgetW 900 → counts["budget.allocatedW"] 900 → budget.allocatedW 900
  params.gridW   480 → counts["draw.committedW"]   480 → draw.committedW   480
  Σ counts = 1380 == targetW == allocatedW

Les deux moitiés sont dans les deux compteurs, et les deux compteurs sont de même
nature. budget.evReservedW vaut 0 : il ne reçoit plus de tranche d'allocation, il
est redevenu la correction de latence qu'il a toujours été.

La seconde borne de la trace montre l'autre moitié de la règle — targetW: 0 avec
counts PRÉSENT et sa destination à zéro. C'est le cas le plus fréquent, celui
qu'un counts vide aurait perdu.

+etm32 est posé sur .75 et l'identité s'y vérifie sur des données réelles : Σ
counts[budget.allocatedW] = 1700 = budget.allocatedW. Le régime EV_GRID_START n'y
est pas reproductible et ne sera pas fabriqué — counts en serait la seule preuve,
et il l'est déjà dans la trace.

L'instrumentation qui a produit la trace était temporaire et a été retirée ;
l'arbre est identique à avant. Elle n'aurait rien affiché sans
QT_LOGGING_RULES="*.debug=true" — les règles par défaut suppriment qDebug sans
catégorie, ce qui a d'abord donné une sortie vide et un test qui passait.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-30 10:25:34 +02:00
Patrick Schurig
abcf80a5d3 feat(R3): counts — chaque watt dans un compteur, et GetChargingInfos filtre
counts est publié par niveau : { destination → watts }, Σ counts == targetW
toujours. Clés opaques et jamais recyclées, publié même à une seule destination
et même à zéro — un counts vide satisferait l'identité en perdant la destination,
sur le cas le plus fréquent.

L'identité ne passe plus par funding : elle somme des COMPTEURS, plus des
origines. EV_GRID_START y entre sans exception. funding et counts ne se dérivent
pas l'un de l'autre — l'un dit d'où viennent les watts, l'autre où ils sont
comptés.

LA GARANTIE EST UNE MACHINE. Sept sites construisent une LoadAction ; vérifier
qu'ils renseignent tous counts en RELISANT le code se referait à chaque site
ajouté. testCountsSumToTarget relit la charge utile — toutes les charges, trois
régimes dont l'import où tout vaut 0 — et constate le résultat.

ET LA CONTRE-ÉPREUVE A TROUVÉ UN TROU DANS LE TEST LUI-MÊME. Amputer le partage
d'EV_GRID_START le laissait PASSER : la borne, auto-provisionnée en queue par
LM-1209, ne voyait jamais de budget, et le régime n'était pas exercé. Corrigé —
rang 1, et EV_GRID_START en PREMIER régime, parce qu'une fois la borne en charge
son recrédit porte le budget bien au-dessus du plancher et le démarrage sous
tolérance ne peut plus se produire. Plus une assertion qui EXIGE que le régime
ait été exercé, sinon le test ne prouve rien du partage.

Deux fois dans ce lot, la contre-épreuve a corrigé le test plutôt que le code :
la première fois elle avait échoué au MONTAGE (NYMEA_PLUGINS_PATH omis) et ne
prouvait rien non plus. Un garde-fou qu'on n'a pas vu échouer POUR LA BONNE
RAISON ne vaut pas mieux qu'un garde-fou qu'on n'a pas vu échouer.

GetChargingInfos filtre sur evChargerId. Le paramètre était déclaré au schéma,
documenté « for all or a single EV charger », et Q_UNUSED : accepté, sans effet,
sans erreur. Des trois issues c'était la pire — un paramètre absent se voit, un
paramètre refusé se voit, un paramètre ignoré ne se voit pas. Identifiant inconnu
→ liste vide, jamais une erreur, et la liste vide est non ambiguë puisque toute
borne configurée a une entrée.

Balayage du namespace : sur 20 méthodes, 9 déclarent des paramètres et
GetChargingInfos était la seule à en ignorer un. Mon premier balayage avait
conclu « aucune méthode ne déclare de paramètre » — regex fausse ; refait en
croisant, pour chaque méthode, les params.insert de sa déclaration avec les
params.value de son corps.

Le garde-fou doxygen de ci-quality a par ailleurs attrapé ma propre insertion :
le bloc CountKey s'était glissé entre le commentaire de LoadAction et la
structure, qui perdait sa documentation.

simulation 34/34, charging 17/17, loadmodel 20/20, spotmarket 7/7, amd64 0/0,
doxygen 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-30 10:14:19 +02:00
Patrick Schurig
0638008a9f feat(R3): draw.committedW — le registre des watts achetés, préalable à counts
L'app a validé le préalable sans réserve, et sa formulation de pourquoi counts
n'a pas été livré mérite d'être gardée : une identité dépendante du temps est
pire que pas d'identité. Vraie au premier cycle après un changement, fausse après
convergence, elle passe tous les tests — qui portent sur des cycles isolés — et
certifie un total faux chez les clients, avec le sceau d'une identité publiée.

draw.committedW est publié dans la MÊME TRAME que budget : les deux décrivent le
même cycle, et les lire à deux instants ferait comparer deux mondes.

SEUL, et c'est la deuxième demande, celle qui compte. authorisedW, remainingW,
binding, perPhaseBound sont ABSENTS, jamais à 0 — un authorisedW: 0 assorti d'un
binding par défaut afficherait un plafond de soutirage qui n'existe pas, avec sa
source. Un chiffre crédible et faux, c'est-à-dire la faute même que l'objet doit
rendre impossible.

budget.evReservedW n'a plus qu'UNE nature. Il recevait la part réseau
d'EV_GRID_START — une vraie tranche d'allocation — en plus de la correction A.
Déménagée dans draw.committedW. Sans ce déplacement, aucune définition du champ
n'aurait été vraie pour ses deux termes, et l'app aurait renommé son libellé sur
une définition à moitié fausse.

Sa définition est écrite, puisqu'elle affichait « Réservé au véhicule » : c'est
une CORRECTION du budget de surplus, pas une réservation ; elle tend vers 0 à
mesure que la mesure rattrape la commande, sans qu'aucune décision n'ait changé.
Le contrat dit : ne l'additionner à rien, il ne se réconcilie jamais. Le libellé
affirme deux choses fausses — que c'est réservé, et que ça concerne le véhicule
plutôt que la latence de sa mesure.

EV_GRID_START cesse d'être une exception : ses deux moitiés tombent dans des
compteurs de même nature. Le partage demeure — c'est un fait, une part est
achetée — mais il ne s'énonce plus comme une entorse.

Publication seule, aucune arithmétique de budget ne change : vérifié avant
d'écrire que la ligne alimentant evReservedW depuis la cascade n'avait aucun
retour dans le calcul.

simulation 33/33, charging 16/16, loadmodel 20/20, amd64 0/0, doxygen 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-30 08:58:11 +02:00
Patrick Schurig
4e5af02d01 docs: LM-1012/LM-1013, et R3 est bloquée par une destination qui n'en est pas une
LM-1013 — « grid » l'emporte dès qu'un watt est acheté, écrit comme RÈGLE et non
comme approximation. L'argument de l'agent app emporte la décision : tant qu'on
la décrit comme approximative, quelqu'un voudra la rendre exacte — part
dominante, seuil de majorité — et sur 900/480 le libellé basculerait d'un cycle à
l'autre sans qu'aucune décision n'ait changé. Déterministe, indépendante des
proportions, alignée sur le fait actionnable, et vraie sans décision nouvelle pour
le plancher éco du §10.

Pas de « mixed » publié, et c'est l'app qui l'a prouvé par son propre code : tout
client qui teste funding == "grid" a une branche par défaut, et cette branche est
« surplus ». Une troisième valeur y tomberait SANS ERREUR et rangerait au soleil
un niveau dont une part est achetée.

LM-1012 — on publie ce qui ne se dérive pas, on dérive ce qui se dérive. La ligne
est de l'app et elle vaut au-delà du cas : une valeur dérivée LOCALE est légitime
PARCE QU'elle est dérivable ; publiée, elle est illégitime POUR LA MÊME RAISON.
Elle explique après coup trois refus pris séparément — l'index d'ordre de
service, le « mixed », et stagesW qui est publié précisément parce que la
combinatoire n'est PAS dérivable côté client. Symétrique : rankOrigin est publié
parce que rien dans un rang ne dit qui l'a posé.

Ses trois demandes : clés opaques jamais réutilisées (engagement pris, même règle
que les numéros de règle retirés) ; counts publié même à zéro avec sa destination
(confirmé) ; et counts fait foi pour la COMPTABILITÉ, decision.params pour la
PHRASE du motif (sa lecture est la bonne).

MAIS R3 NE PEUT PAS ÊTRE ÉCRITE TELLE QUELLE, et c'est le résultat de la journée.
budget.evReservedW n'est pas un registre d'allocation : c'est un terme de
correction du budget, et il mélange la correction A — commandé non encore mesuré,
calculé AVANT la cascade — avec la part réseau d'EV_GRID_START, qui est une vraie
tranche d'allocation. Une borne servie au réseau qui tire déjà ce qu'on lui a
commandé y contribue 0 alors que son targetW vaut 7 000 W : Σ counts == targetW
serait vrai au premier cycle et faux ensuite. Ça passerait les tests et
dériverait en production.

Ce que R3 a mis au jour : les watts achetés n'ont AUCUN registre aujourd'hui.
draw.committedW devient un préalable à R3, pas une suite — petit, et sans effet
sur l'arithmétique, la ligne 355 étant de la publication seule. Ce qui change est
le SENS d'evReservedW, qui redevient une correction. Proposé à l'app, en attente :
c'est son identité de réconciliation.

Aucun code : counts n'est pas livré sur une base fausse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-30 08:29:24 +02:00
Patrick Schurig
a305a0c4ee docs: la sauvegarde se PROUVE, et counts sera toujours publié
AGENTS.md — « Sauvegarder l'historique quand la forge est injoignable », avec les
trois commandes et la seule qui compte : cloner À BLANC depuis la machine de
destination. Un bundle copié ailleurs n'est pas une sauvegarde ; un bundle cloné
à blanc depuis ailleurs en est une, et l'étape 3 est celle qu'on saute — dix
secondes, et c'est la seule qui distingue « restaurable depuis une autre
machine » de « présent sur une autre machine ». Avec ce qu'elle ne remplace pas :
un bundle est une photo, pas un remote.

R3 envoyée à l'app, avec la réponse à la question de forme : counts sera publié
TOUJOURS, même à une seule destination. L'omettre obligerait l'app à connaître la
destination par défaut, donc à la déduire, et lui donnerait deux chemins de
lecture dont un exercé sur presque toutes les charges. Surtout, l'identité
deviendrait conditionnelle — une identité à branche est une identité qu'on peut
se tromper à écrire, en silence.

C'est la règle qui a payé toute la semaine, et elle est citée comme telle : une
absence ne doit jamais vouloir dire « le défaut s'applique ». measurement.source
"none" est publié plutôt que la clé retirée ; rankOrigin absent n'affirme rien ;
levels[] absent dit « box antérieure », pas « un niveau implicite ». Le silence
ne porte que l'ignorance.

ET UNE QUESTION QUE counts FAIT APPARAÎTRE : R2 a déplacé le problème d'un cran
sans l'éliminer pour EV_GRID_START, dont le niveau est financé en partie au
surplus et en partie au réseau. funding: "grid" y est déjà une approximation — au
niveau, comme il l'était à la charge. Les deux champs ne dérivent pas l'un de
l'autre (origine contre registre), garder les deux est justifié À CONDITION QUE
CE SOIT DIT, sinon quelqu'un supprimera un jour celui qui a l'air redondant.
L'arbitrage sur le cas mixte est renvoyé à l'app, avec notre penchant motivé.

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