Deux défauts trouvés par le premier essai au banc (2026-09-08, 11:05→11:16),
et le premier est celui que Patrick a vu en regardant la voiture.
LM-1500-h — UNE BORNE N'EST PAS ÉCRÊTÉE DEUX FOIS.
La tête de passe éco appliquait `margeRestante` au plafond d'une borne. Or
`margeRestante` est la marge d'UNE phase, et le plafond d'une borne triphasée
est une enveloppe sur TROIS : les comparer divise son allocation par trois.
Mesuré : marge de 6 210 W sur la phase la plus chargée, plafond de cycle à
13 800 W, borne servie à 6 210 — 9 A par phase au lieu de 27, 4 kW au lieu des
11 que la voiture pouvait prendre.
Le plafond d'une borne porte DÉJÀ l'autorisation de soutirage :
evPhaseAllowanceW() la calcule et la multiplie par le nombre de phases
occupées. La passe confort ne fait pas cette faute, et son commentaire dit
pourquoi depuis 3g-2 — « mieux vaut la borne mieux informée que la borne la
plus sévère ». Je l'ai réécrite quinze lignes plus haut sans la relire : une
règle notée à un seul endroit du code n'est pas une règle.
L'erreur allait dans le sens PRUDENT, donc rien ne la signalait — ni le
disjoncteur, ni le délestage, ni un test qui vérifie qu'on ne dépasse pas. Elle
ne se voyait qu'en regardant la voiture.
LM-1500-g — ÉCRIRE UN MODE PENDANT UNE DÉROGATION LA TERMINE.
Choisir un mode est durable ; une dérogation répond à un besoin ponctuel. Le
durable prime, et il prime tout de suite. Et SANS restituer le mode mémorisé,
qui écraserait celui que l'utilisateur vient de choisir.
Le cas vient du banc : le bouton « boost » de l'app écrit encore
ChargingModeNormal. Reçu pendant une dérogation, il fait restituer les valeurs
manuelles par l'amont — chargingEnabled à FAUX — et arme un verrou de 300 s,
pendant que la dérogation continue de commander 9 A. Deux commandeurs pour un
organe, et six minutes de dérogation servie sur le papier dans une borne
éteinte. Seul le journal de divergence le voyait, sans pouvoir en dire la cause.
Deux autres corrections du même essai :
- DEROGATION_ACTIVE et DEROGATION_ENDED sont rendus en français au journal ;
ils sortaient en « (motif inconnu …) ». Le repli 7-c a tenu — le code brut et
ses paramètres, jamais une chaîne vide — mais la ligne lue par l'utilisateur
était illisible.
- poseDerogation() prend l'instant en paramètre. Elle lisait l'horloge murale
quand le cycle lit l'horloge injectée : sans effet en production, où les deux
sont la même, mais sous test la fin par durée n'arrivait jamais. Trouvé par le
test, pas par la lecture.
Et `setChargingInfo() déclenche un cycle` est gravé dans AGENTS.md comme une
PROPRIÉTÉ : ce qui l'entoure a un ordre qui compte, dans les deux sens, et
ENERGY_SIMULATION supprime précisément l'appel — le chemin fautif n'existe pas
sous test.
simulation 69/69, loadmodel 26/26, charging 48/48, spotmarket 32/32,
ci-quality 4/4, build 0 erreur 0 warning.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Une dérogation n'est pas un mode. 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.
Elle se superpose au mode courant, sert la charge à son MAXIMUM en tête de
passe éco — devant toute autre charge, quel que soit son rang — financée au
réseau. Le rang classe ce que l'installateur décide (LM-1002) ; une dérogation
est une action ponctuelle de l'utilisateur, et passe par une porte que LM-1002
ne ferme pas.
Mais elle ne surplombe PAS la sûreté : DRAW_CAP la borne et le délestage la
reprend. Servie la première dans ce qui RESTE, jamais au-delà — sans quoi on
rouvrirait le second chemin d'achat refusé au minimum réseau (LM-1500-e).
LA DURÉE EST OBLIGATOIRE ET BORNÉE, refusée à la pose hors de [60 s, 6 h].
C'est la règle du lot : une dérogation qui ne sait pas dire quand elle finit ne
devrait pas exister (LM-1500). Elle reste armée même avec une cible, parce
qu'une cible repose sur un instrument et qu'un instrument peut se taire — à la
perte du lien, la V2C FIGE sessionEnergy au lieu de l'effacer (LM-1012-d) : une
dérogation « 20 kWh puis stop » dont le compteur reste à 12 kWh n'atteint
jamais sa cible.
Trois décisions prises en écrivant, absentes du design :
- Les fins se lisent dans le CONTEXTE DU CYCLE, pas dans les adaptateurs.
evaluerDerogations() tourne entre buildContext() et getPlan(), sur ce que le
scheduler va lire. Une seconde lecture donnerait une seconde vérité, et une
dérogation pourrait finir sur une mesure que le plan ne voit pas. C'est aussi
ce qui rend le débranchement gratuit : la borne est déjà filtrée du contexte,
son absence EST le signal.
- measurementLost ne mord QUE s'il y a une cible (LM-1500-b). Sans cible, rien
ne dépend de la mesure : couper là punirait la charge du silence d'un
instrument dont elle n'a pas besoin. Et le critère est
telemetry.sessionMeasurable, jamais progress.regime : `progress` est absent
d'une charge sans obligation en énergie, c'est-à-dire exactement le cas du
boost — le critère aurait été inerte.
- La base de la cible se capture au PREMIER CYCLE, pas à la pose : au moment où
l'utilisateur appuie, aucun cycle n'a construit de contexte. Prendre 0
compterait depuis le début de la session — une dérogation « 5 kWh » posée sur
une session déjà à 6 kWh serait atteinte avant d'avoir commencé (LM-1012-b).
Le mode précédent se MÉMORISE, il ne se déduit pas (LM-1500-d). Revenir à un
défaut détruirait un réglage en silence, et sur une borne la sortirait de
l'arbitrage : une dérogation qui laisse l'installation moins pilotée qu'avant
elle. Une borne en manuel est basculée en éco pour la durée — seul cas où le
mode est écrit, et modeForce retient qu'on l'a fait.
La passe confort est SAUTÉE sous dérogation : la fusion des niveaux est une
addition, et la dérogation demande déjà le maximum. Conséquence comptable à
connaître : elle est comptée entièrement en achat alors que le surplus présent
la finance en partie — gridW la surestime au soleil. Approximation héritée du
§10, non aggravée, et la raison pour laquelle la première vérification au banc
se fait le matin.
La fin est un ÉVÉNEMENT et dit sa CAUSE (LM-1500-f), publiée dans
derogationEnded[] au NIVEAU DE LA TRAME : au débranchement la charge n'est déjà
plus dans loads[], et l'événement disparaîtrait quand il explique le plus de
choses. Cinq causes, cinq gestes. Le mode dégradé la porte aussi — c'est là que
measurementLost se produit. telemetrySignificantlyDiffers ne la filtre jamais :
un événement ne vit qu'un cycle.
Éprouvé par testDerogationTakesTheHeadButNotTheSafety, dont la tête de passe est
prouvée par la RARETÉ : 7 500 W d'autorisation pour 8 000 demandés, la borne de
rang 9 servie ENTIÈREMENT et l'ECS de rang 1 réduite aux 500 restants — l'inverse
exact de l'ordre des rangs. Contre-épreuve : la même borne sans dérogation
reçoit 0 W. Et à autorisation nulle, AUCUN niveau — pas un niveau à zéro.
simulation 68/68, loadmodel 26/26, charging 48/48, spotmarket 32/32,
ci-quality 4/4, build 0 erreur 0 warning.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
La phrase « 0 signifie non configuré — tout le smart charging est désactivé » était
vraie de l'amont et FAUSSE depuis la transplantation du waterfall. Mesuré des
semaines durant sur .75 : la cascade de surplus, les paliers et les planchers éco
tournaient normalement avec la limite à 0.
Ce qui est réellement inerte à 0 : drawCaps() ne publie aucun plafond,
houseDrawMargin() rend « sans objet », DRAW_CAP n'est jamais émis, et
shedWaterfallForOverload() sort à sa première ligne faute de seuil.
Conséquence désormais consequente : à 0, le moteur peut engager du soutirage sans
borne — et il le fait depuis que les planchers éco achètent. La couche de
protection existe et n'a rien à protéger.
Et l'unité est rappelée en tête : AMPÈRES par phase, pas watts. J'ai failli
recommander « 7 360 W pour du 3×32 A », ce qui aurait posé un plafond de 7 360 A —
inerte, mais qui aurait eu l'air configuré. Le contrat le disait déjà plus bas ; il
le dit maintenant là où on lit la valeur.
LM-1013-b-vi. Le mode ChargingModeEcoWithMinCurrent promettait un courant minimum
depuis toujours sans qu'aucun chemin ne le serve : mort par réécriture chez
l'amont, puis mort par exclusion quand le waterfall a pris la borne.
LA LOI. computeEcoTarget() rend max(cadence de l'obligation, minimum). Le corps
historique est isolé dans calculerObligation() et une sortie anticipée rend
EXACTEMENT l'ancien résultat quand aucun minimum n'est demandé — ce n'est pas une
optimisation, c'est la garantie que l'ajout ne déplace rien pour les charges qui
n'en veulent pas. Les quatre suites le confirment.
LE MINIMUM NE SE TIENT JAMAIS. `tenue` ne décrit que l'obligation en énergie ;
quand elle est tenue, la cadence vaut 0 et la consigne retombe SUR LE MINIMUM, pas
sur zéro. C'est là, et seulement là, que la différence de nature entre une énergie
avec échéance et une puissance permanente se voit dans le code. L'ordonnanceur ne
publie donc ECO_FLOOR_MET que si watts vaut aussi 0 : sinon il couperait ce qu'il
doit continuer de servir.
DÉRIVÉ, JAMAIS STOCKÉ. EvAdapter calcule le minimum depuis evFloorW() à chaque
cycle quand le mode le demande. L'écrire en configuration à l'appairage créerait
une copie qui se périme au premier recâblage — phaseCount change, la copie reste
fausse en silence. Correction apportée par le brief app f2bd07d, et elle est
juste.
LE MOTIF DIT LEQUEL DES DEUX MORD. ECO_FLOOR_GRID gagne minAlwaysW et boundBy, et
OMET remainingWh/targetWh/remainingS/regime sur une charge à minimum seul : un
remainingS à 0 inventerait une échéance imminente et un remainingWh à 0 une
obligation tenue. progress est absent pour la même raison — il ne décrit que
l'obligation.
CONTRAT. Les trois descriptions de ChargingMode sont corrigées. Normal n'est pas
« recharge immédiate au maximum » : c'est MANUEL, le moteur ne commande rien, et
seule la protection de surcharge la touche encore — ce qui doit être dit, un mode
qui promet l'abstention et coupe quand même étant une surprise. Et les mots
« garanti » et « prioritaire » sont bannis à propos de ce mode : le premier promet
une certitude que rien ne tient, le second est vrai face au confort et faux face à
un autre besoin éco mieux rangé, donc faux exactement dans le cas d'échec.
TEST vu mordre : désarmé, minSeul.watts rend 0 au lieu de 4 140. Cinq points, dont
la contre-épreuve qui distingue « 4 140 vient du minimum » de « 4 140 vient d'un
reliquat d'obligation », et le cas 1 qui vérifie que rien ne bouge sans minimum.
Suites : loadmodel 26/26 (+1), simulation 67/67, charging 48/48, spotmarket 32/32.
NON DÉPLOYÉ — +etm57 attend le feu vert.
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.
Deux points qui gouvernent la LECTURE de champs que ce dépôt publie — donc ils
vivent ici, même si l'écran qui les rendra vit ailleurs.
TIMEZONE QUALIFIE UNE HORLOGE, IL N'OFFRE PAS UN CHOIX. « 12:00 heure de la box
(Europe/Paris) » dit la chose ; « Fuseau : Europe/Paris » sous un champ d'échéance
laisse croire qu'on choisit le fuseau de CETTE échéance. La nuance n'est pas
cosmétique : le fuseau est modifiable, mais c'est un réglage SYSTÈME qui déplace
d'un coup toutes les heures murales de l'installation — échéances, frontière de la
journée de reporting, et demain les plages tarifaires. Il n'y en a qu'un, et il
vaut pour tout.
L'AVERTISSEMENT DE DIVERGENCE EST CONDITIONNEL, jamais permanent : il ne s'affiche
que si les deux fuseaux comparés diffèrent réellement. Affiché en permanence, il
cesse d'être lu — et il cesse de l'être le jour exact où il devient vrai.
C'est la règle de l'absence qui informe, déjà appliquée deux fois dans ce contrat :
progressUnmeasurableCause absent quand la charge est mesurable, phase absent dans
DRAW_CAP quand aucune phase ne mord. Un champ toujours présent ne porte aucune
information ; un avertissement toujours affiché non plus.
TA CORRECTION EST RETENUE, et elle porte sur le point qui compte. `measuredSince`
n'a pas la même sémantique des deux côtés : sur une charge il répond « depuis
quand on MESURE » et borne une fenêtre dont `targetWh` donne l'autre bout ; sur
les ratios il répond « depuis quand on COMPTE », et la paire ne borne aucune
fenêtre. Transposer un nom n'est pas transposer une sémantique.
Conséquence gravée : `periodStartedAt` n'est PAS la borne basse des ratios —
c'est `measuredSince`. Présenter comme journaliers des chiffres qui ne comptent
que depuis le dernier redémarrage serait exactement le silence que la parade
prétendait fermer. Mesuré à l'instant sur .75 : les taux couvrent 1,97 h quand
l'écran dirait 9 h.
Et la séparation d'écrans est écrite là où elle se lit : `periodStartedAt` sert le
bilan journalier, `measuredSince` sert les ratios. Ils arrivent dans la même
trame, leurs noms se ressemblent, et c'est par là que l'erreur passe.
VÉRIFIÉ À LA SONDE PLUTÔT QUE DÉDUIT DU NOM, comme demandé — et il y avait de
quoi vérifier, puisque la justification même de publier était « le recalcul
serait faux les jours de changement d'heure ». Encore fallait-il que le nôtre soit
juste ces jours-là.
· en vrai sur .75 : `periodStartedAt` == minuit local, recalculé indépendamment ;
· en test : les époques viennent d'un ORACLE calculé hors de Qt, pas re-dérivé
avec les primitives du code testé — une identité qui se referme sur elle-même
ne vérifierait rien ;
· 2026-03-29 fait 23 h, 2026-10-25 fait 25 h, les 363 autres 24 h. Un client
posant « la journée fait 86 400 s » serait juste presque toujours, et faux
deux fois par an, dont une en plein hiver de chauffage.
TROIS FOIS mes propres contrôles se sont trompés en cherchant ces deux dates : un
balayage qui n'a rien trouvé, puis deux calculs rendant 24 h partout. Cause :
l'arithmétique entre deux datetimes du MÊME fuseau est naïve — elle rend la durée
d'horloge murale, jamais la durée réelle. Le contrôle qui le prouve passe donc par
les époques, jamais par une soustraction de dates. C'est la même leçon que celle
qu'on vient de graver : un instrument peut répondre « normal » parce qu'il ne
mesure pas ce qu'on croit.
Suites : loadmodel 24/24 (+1), simulation 67/67, charging 48/48, spotmarket 32/32.
Rien à déployer : le moteur est inchangé depuis +etm56.
DIVERGENCE JOURNALISÉE. `divergent` et `since` ne vivaient que dans la charge
utile de télémétrie et dans un QHash en mémoire — donc nulle part. Rien ne
comptait les épisodes : chaque redémarrage de nymead effaçait l'état, et le hash
est PURGÉ dès que l'écart cesse, si bien qu'une divergence qui s'ouvre et se
referme entre deux lectures ne laissait aucune trace. Un observateur externe
échantillonnant en continu aurait raté exactement les épisodes courts, qui sont
probablement les plus nombreux.
Conséquence : le critère « attendre que les traces parlent » attendait
indéfiniment. Le silence du journal était garanti par construction, pas par
l'absence du phénomène — l'instrument dont le silence ressemble à un résultat.
Deux `qCInfo`, aux deux transitions, avec la durée à la fermeture. Le journal de
.75 est persistant depuis le 2026-08-13 (200 Mo, un mois) : le comptage devient
trivial, et l'étape 4 pourra se décider sur des faits.
FRONTIÈRE DE PÉRIODE PUBLIÉE (LM-1005-b-iv). `System.GetTime` donne un NOM de
fuseau ; un décalage ne s'en déduit pas sans base de fuseaux, et embarquer une
base pour recalculer une frontière que le moteur connaît déjà resterait faux les
jours de changement d'heure, où la journée fait 23 ou 25 heures. Trois clients la
recalculeraient de trois façons, et la seule qui compte est celle qui a servi au
calcul. Même règle que `counts`.
C'est aussi la parade à la troisième nature d'heure murale : la période de
reporting suit l'installation, correctement, mais repose sur la prémisse que le
fuseau de la box SOIT celui de l'installation — et c'était le seul des trois cas
où personne ne vérifiait. `timeZone` rend la vérification possible.
DEUX CHAMPS, PAS UN (LM-1005-b-v), et c'est un ajout à la demande. La baseline se
repose à chaque redémarrage de nymead et à chaque recul de compteur, donc EN
PLEIN JOUR. Un jour où nymead a redémarré à 14 h, les ratios ne couvrent pas la
journée : ils couvrent depuis 14 h. Publier la seule frontière ferait écrire
« aujourd'hui » sur des chiffres qui ne parlent que de l'après-midi — un silence
de la même famille que celui qu'on ferme. `measuredSince` dit la portée réelle ;
quand les deux coïncident, le cas normal, il n'y a rien à dire de plus, et c'est
ce qui rend l'écart lisible le jour où il apparaît. Même paire, même raison que
`progress.measuredSince` / `targetWh`.
Les trois champs sont TOUJOURS présents, même quand les deux taux sont omis :
« la période a commencé à minuit et rien n'est encore mesurable » est une phrase
vraie et utile, là où une réponse vide ne se distingue pas d'une panne.
INVARIANT 11 au passage : le schéma du Get et celui du Changed étaient déclarés
DEUX fois. Ajouter trois champs à deux endroits est exactement la manière dont
ils divergent — d'où `energyRatiosSchema()`, déclaré une fois et servi aux deux,
comme `loadTelemetrySchema()`.
TEST vu mordre : remplacer `measuredSince` par la frontière — l'implémentation
naïve que la demande aurait pu produire — fait échouer l'assertion sur la repose
en plein jour (attendu 10:00, obtenu 00:00).
Suites : simulation 66/66, loadmodel 23/23, charging 48/48, spotmarket 32/32.
LM-1013-b-v. Tranché le 2026-09-01, jamais écrit — la décision a passé au second
plan quand sessionWh a été alimenté depuis le compteur de charge et que le régime
`unmeasurable` s'est réduit aux trois cas matériels. C'est la première classe
d'erreur d'AGENTS.md : une règle qui expire sans être exécutée. Elle n'était ni
dans le code, ni dans la spec, ni au contrat.
LE PROBLÈME. L'étalement `reste / temps restant` suppose que le NUMÉRATEUR bouge.
Sous `unmeasurable` il ne bouge pas : `remainingWh` est figé à la cible entière,
par contrat, parce qu'on ne sait pas ce qui a été livré et que le rebaser serait
affirmer sur ce qu'on ignore (LM-1009 C1). Un rapport dont seul le dénominateur
décroît ne suit plus un retard : IL SUIT L'HORLOGE. On achetait davantage parce
que le temps passait, jamais parce que quelque chose était en retard.
CHIFFRÉ, et le chiffre a été refait plutôt que repris. Obligation 4 kWh, plafond
7 kW, fenêtre d'un jour : l'ancienne loi achetait 19 010 Wh, soit ×4,75
l'obligation. Le facteur suit ln(T·P/E) + 1 — il grandit avec la fenêtre, ce qui
est le pire sens possible : plus on laisse de temps pour bien faire, plus on paie.
Une charge qu'on mesure mal coûtait ainsi structurellement plus cher qu'une charge
qu'on mesure, l'incitation exactement à l'envers.
(Le ×2,5 retenu au moment de trancher valait pour une fenêtre bien plus courte.
Sur la fenêtre quotidienne, qui est celle du modèle, c'est ×4,75. La conclusion ne
change pas ; le chiffre, si — et un chiffre faux dans une spec finit par servir de
prémisse à quelqu'un.)
LA LOI. Cible ÷ fenêtre de l'obligation, constante, et `atMaximum` jamais vrai. La
fenêtre est le JOUR parce que l'obligation est déclarée par jour — ce n'est pas un
réglage, c'est l'unité de la grandeur qu'on étale. Le total acheté sur la fenêtre
vaut alors EXACTEMENT la cible. On garde « viser l'obligation entière », le choix
qui sert ; on perd l'escalade, qui ne reposait sur rien.
CE QUE LE TEST TIENT, et il a été vu mordre — désarmé, il rend 667 W au lieu de
167 W à six heures de l'échéance :
· la cadence est identique à 6 h, 2 h, 1 h, 30 min et 5 min de l'échéance ;
· `atMaximum` reste faux jusqu'à la dernière minute ;
· l'intégrale sur les 1440 minutes de la fenêtre vaut la cible à 5 Wh près —
la propriété qui justifie la loi ne se lit sur aucune valeur isolée ;
· CONTRE-ÉPREUVE : même obligation, même instant, même numérateur (4000 non
livrés) sous `measured` — durcissement et point de non-retour intacts. Sans
elle, avoir supprimé l'étalement POUR TOUT LE MONDE passerait inaperçu.
AU CONTRAT. La loi de commande dépend désormais du régime, et un écran qui
suppose la même dans les deux se trompera sur la trajectoire — qui est ce qu'il
vend. INTERFACE.md porte la table des deux lois, et dit ce que l'écran ne peut
plus affirmer sous `unmeasurable` : ni que l'achat va s'intensifier, ni que
`atMaximum: false` veut dire que tout va bien — il veut dire qu'il n'y a pas
d'étalement du tout, donc rien qui puisse cesser.
Suites : loadmodel 23/23, simulation 66/66, charging 48/48, spotmarket 32/32.
totalEnergyConsumed n'était écrit par personne : il valait sa valeur par
défaut, indéfiniment. Un compteur figé est un compteur qui n'existe pas pour
tout ce qui l'intègre — deliveredWh restait à 0, remainingWh à 4 000, et
l'étalement adaptatif n'était jamais exercé. Le comportement se confondait
avec unmeasurable pour une raison entièrement différente et bénigne.
Quatrième occurrence d'un mock plus homogène que la réalité, après les six
états cohérents du rootmeter, les classes portant toutes une puissance, et la
triphasée équilibrée.
En export il ne recule pas — il ne bouge pas : un compteur de consommation
est monotone, et le faire reculer produirait un cas que le terrain ne produit
pas, celui-là même que la garde de sémantique traite comme une anomalie. Le
mock fabriquerait le défaut que le moteur est censé détecter.
LM-1014-b appliquée au mock, question posée et répondue : le cumul est
déclaré `cached`, donc il continue au redémarrage. Repartir de zéro ferait
reculer un cumul monotone.
Et purchasedWh gagne un second usage, sous `measured` cette fois. Mesuré sur
le banc : il accumule pendant que deliveredWh peut rester plat, et cette
divergence est un contrôle que l'écran n'aurait pas su fabriquer — de
l'acheté qui monte avec du livré plat signifie soit que la charge ne consomme
pas ce qu'on paie, soit que le compteur ne compte pas. C'est écrit au contrat
parce que le jour où les deux coïncideront, quelqu'un jugera le champ
redondant : c'est précisément quand ils divergent que la paire devient
informative, et un champ qui ne sert que dans l'anomalie n'a pas l'air utile
le reste du temps.
La borne basse du test de cumul est large, et délibérément : il ne contrôle
ni la cadence du minuteur ni l'alignement du premier tic, qui n'intègre rien.
Une borne serrée en ferait un test instable — qu'on relancerait jusqu'à ce
qu'il passe.
Suite : simulation 66/66, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
`voltage` était déclarée une seule fois hors des trois blocs et mutée par
l'un d'eux : une phase sans tension déclarée était calculée avec celle d'une
autre.
Et le défaut n'était pas celui qu'on croyait — le mesurer l'a montré.
L'interface energymeter rend currentPhaseA et voltagePhaseA OBLIGATOIRES, B
et C optionnels : la branche « puissance ÷ tension » de A est morte par
construction. La fuite n'allait donc pas de A vers B, comme la note d'origine
l'affirmait, mais de B vers C.
Démontré : B à 200 V, C à 4 000 W sans tension déclarée → 5,0 A de marge au
lieu de 7,6. Deux conditions, dont aucune n'était évidente avant d'écrire le
test : le masque doit couvrir B ET C — interroger C seule n'exécute pas le
bloc de B — et C doit être la phase la plus contrainte, sinon le qMin retient
B et masque l'écart. Mes deux premières versions du test passaient pour ces
deux raisons.
Le mock ne savait pas produire le cas : il a fallu meterPowerOnly, avec les
puissances et la tension de B mais sans les courants de B/C ni la tension de
C. Troisième fois qu'ouvrir le mock révèle un défaut plutôt que de seulement
débloquer un test.
draw.bindingPhase dit LAQUELLE des phases borne. perPhaseBound disait qu'une
phase borne, jamais laquelle : l'écran ne pouvait dire que « bridé » sans
dire par quoi. DRAW_CAP la porte aussi mais n'est émis que si une charge est
refusée — un cycle qui borde sans rien refuser laisserait l'écran muet.
Et dans AGENTS.md : une conclusion juste posée sur un raisonnement faux
survit jusqu'à ce qu'on bâtisse dessus, parce que la conclusion PROTÈGE le
raisonnement de l'examen. Un résultat faux se voit ; un résultat juste ferme
la question. Parade : quand une décision est écrite avec sa raison, la raison
mérite son propre contrôle — c'est elle que le lot suivant utilisera.
Suite : simulation 65/65, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Sur measured → unmeasurable → measured dans la même fenêtre, deliveredWh
repartait de zéro : la base était retirée puis reprise à la valeur courante,
donc l'énergie livrée pendant les tronçons mesurés antérieurs disparaissait.
L'écran sous-estimait, et — ce qui coûte — la passe éco rachetait le déjà
livré.
Ce n'est pas théorique : c'est le motif d'une V2C dont le lien Modbus bat.
Chaque perte et retour fait basculer le régime deux fois, et chaque
aller-retour effaçait ce qui précède. Contre-épreuve : tronçon jeté,
l'avancement retombe de 1500 Wh à 0.
On accumule par PÉRIODE, pas par tronçon — le tronçon est clos, pas
abandonné. Et la clôture se fait sur la dernière valeur lue TANT QUE la
mesure existait, jamais sur celle du cycle où le régime tombe : à cet instant
le Thing ne publie plus, et s'en servir fabriquerait une énergie.
Deux tranchages, épinglés. Le cumul de période repart de zéro à la bascule de
période, et les trous avec lui — ils comptaient les interruptions de la
période close. Et measuredSince garde le PREMIER instant mesuré, l'
intermittence se disant à part : redater à chaque retour effacerait
l'histoire au lieu de la raconter, ne rien dire ferait croire à une
continuité que le battement dément.
LM-1014-c : un cumul se teste aussi de part et d'autre d'un CHANGEMENT DE
RÉGIME, pas seulement de période. LM-1014-b ne disait rien de la bascule de
régime à l'intérieur d'une période — c'est exactement là que le défaut
vivait.
Le test a échoué deux fois avant d'être juste, et la seconde fois c'était le
test : mon horodatage « après échéance » visait la même échéance que le
premier, donc rien ne basculait.
Suite : simulation 62/62, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Le signal vit dans GetLoadConfig, en r:, et non dans la télémétrie.
`measurement` n'existe que pour les charges arbitrées ; or une charge qu'on
DÉCLARE est précisément celle qui n'y est pas encore. L'écran serait muet au
moment exact où il doit parler — celui de la saisie, avant qu'une obligation
n'achète à l'aveugle tous les jours.
Trois causes, trois gestes : noMeter se répare en une manipulation,
meterWithoutEnergy demande de remplacer le compteur, noSessionEnergy ne se
répare pas. Sans la cause, l'avertissement ne peut dire que « ça ne marchera
pas ».
Et meterThingId ne permet pas de le déduire : un compteur peut publier
currentPower sans totalEnergyConsumed — mesuré sur l'ECS du banc, reproduit
au test avec un powerSwitch désigné comme compteur. La puissance ne suffit
pas, il faut une énergie. Contre-épreuve : déduction par meterThingId seul,
le cas du banc passe pour mesurable.
INVENTAIRE À FROID DU MOCK. Trois fois en trois jours qu'un cas réel s'est
révélé inatteignable ; ce n'est plus un incident, c'est une propriété du banc
— il est plus homogène que la réalité, ayant été construit pour faire marcher
des scénarios et non pour représenter la diversité du parc.
Quatre cas restent structurellement introuvables, dont `source: none` pour
une charge SAINE — toutes les classes pilotables portent une puissance, si
bien qu'une charge non mesurée mais en bon état est impossible à fabriquer,
alors qu'un relais sec est banal sur le terrain. Et la branche `temperature`,
écrite et jamais exécutée faute d'une classe qui porte l'état.
La règle : avant d'écrire un test qui exerce une ABSENCE, vérifier que le
mock peut la produire. Une assertion sur un cas inatteignable ne passe pas,
elle NE S'EXÉCUTE PAS — ce qui est pire, car elle compte comme verte. Le
symptôme se reconnaît : la contre-épreuve ne mord pas.
Deux ajouts dans AGENTS.md. L'asymétrie des latences est une PROPRIÉTÉ, pas
une chance : le refus qui coûte de l'argent est celui de la descente, et
c'est le plus vite constaté — ce qui n'est vrai que parce que le seuil vient
du mécanisme. Et la question opératoire appliquée aux trois autres identités,
avec un résultat inégal qui est le but : celle de `draw` repose sur le fait
que le compteur voie toute la maison, ce que rien ne vérifie ;
Σ levels[].targetW == estimatedPowerW est interne au plan et ne dit rien du
matériel — il a fallu un champ hors de la formule pour relier les deux.
Suite : simulation 61/61, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Le recrédit rendait au budget la conso d'une charge sur l'hypothèse qu'on
pourra la lui reprendre. Contre un refus établi, les watts prêtés étaient
réalloués aux suivantes pendant que la charge continuait de les tirer :
4 000 W alloués pour 500 W de surplus réel. Suspension faite, la charge
suivante ne reçoit plus que les 500 W qui existent.
Le seuil vient du MÉCANISME, jamais d'une constante. Un écart récent est une
latence — une charge qui vient d'être commandée n'a pas atteint sa consigne,
et suspendre là ferait osciller ce qui va bien. Latences mesurées sur la
V2C : montée ~30 s, descente ~7 s. L'asymétrie joue dans le bon sens, le
refus qui coûte de l'argent étant celui de la descente. Défaut prudent à
60 s : l'oscillation d'une charge saine est un coût certain, le refus n'est
qu'un risque.
Et ça se dit — RECREDIT_SUSPENDED, avec depuis quand et combien de watts
retenus. Une suspension change l'allocation de toutes les charges suivantes ;
sans le motif, on chercherait un défaut de cascade là où une charge n'obéit
pas.
Et une cinquième classe dans AGENTS.md, la seule qu'aucun contrôle ne pouvait
attraper : une PRÉMISSE FAUSSE SOUS UNE ARITHMÉTIQUE EXACTE. Les quatre
autres se laissent prendre par un instrument — un champ qui ment, un retour
indiscernable de son contraire, un instrument muet, une assertion vacante.
Celle-ci n'a aucune trace : l'identité publiée tient exactement, que la
charge obéisse ou non.
La parade tient en une phrase : une identité qui tient ne prouve pas que le
modèle est juste, seulement qu'il est cohérent. Ce qu'une identité ne peut
jamais attraper, c'est une hypothèse sur le monde. D'où la question à poser à
chaque identité publiée : sur quoi repose-t-elle qui ne soit pas dans ses
propres termes ? Ici, sur l'obéissance — grandeur absente de la formule,
qu'il a fallu aller mesurer ailleurs pour la rendre vérifiable.
Suite : simulation 60/60, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement. Contre-épreuve :
suspension débranchée, le recrédit revient à 4 400 W.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Deux demandes de l'app, toutes deux fondées.
purchasedWh est publié même sous `unmeasurable`, et ce n'est pas une
entorse à la règle qui y interdit deliveredWh — c'en est le complément. Ce
qui est ACHETÉ est un fait connu sans mesure : c'est notre propre commande.
Ce qui est LIVRÉ est une observation qu'on ne peut pas faire. Sans lui, « ce
que ça coûte par jour » redeviendrait une estimation fabriquée à partir du
taux.
C'est un cumul sur la fenêtre d'obligation, donc LM-1014-b s'applique : remis
à zéro à la bascule, comme la base. Le premier cycle d'une période ne
contribue rien — il n'a pas de durée derrière lui, et en inventer une serait
fabriquer de l'énergie.
measuredSince ferme un piège d'affichage. Quand une obligation bascule de
unmeasurable à measured EN COURS de période, deliveredWh repart de 0 pendant
que targetWh reste la cible entière : une barre afficherait « 0,5 sur 4,0 »
après une bascule où 3 kWh ont pu être achetés. Rebaser targetWh serait pire
— on perdrait l'obligation réelle, et l'écran ne pourrait plus dire ce qui
reste dû. Le champ prend la seconde sortie : « mesuré depuis 09:14 », et
purchasedWh couvre l'avant. Les deux se répondent.
Et un piège de build noté dans AGENTS.md : changer la DISPOSITION d'une
structure dans un en-tête inclus transitivement laisse des objets compilés
contre l'ancienne, et le symptôme est un plantage dans un DESTRUCTEUR — qui
ressemble en tout point à un défaut mémoire du code qu'on vient d'écrire.
Avant de chercher le bug : make clean. Le plantage n'avait jamais existé.
Suite : simulation 59/59, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
`command` publie expectedW, observedW, divergent et since — seulement quand
une source mesure et qu'une commande est partie. Absent sous `none` : il n'y
a rien à comparer, et `divergent: false` affirmerait une concordance qu'on ne
peut pas constater.
L'asymétrie va dans les DEUX sens, et le contrat la dit maintenant en entier.
Sous `device`, une concordance ne prouve rien — plusieurs bornes renvoient
leur consigne en guise de puissance — mais une divergence dit que la charge
n'obéit pas. Sous `meter`, c'est l'inverse. Le test le démontre au lieu de
l'énoncer : la borne du mock dérive sa puissance de sa consigne, donc
expectedW == observedW par construction, et la divergence a dû être exercée
par un compteur explicite.
Un écart n'est pas un défaut, et le champ ne le qualifie pas. Véhicule qui
limite, câble en 16 A, mode manuel : le même nombre qu'une commande perdue.
La cause n'est pas dans les watts.
`since` est ce qui rend le champ utile — sans lui, une divergence permanente
ne se distingue pas d'un déphasage d'un cycle, et c'est toute la différence
entre le normal et le cas relevé.
Et une leçon dans AGENTS.md, appliquée à moi-même dans le même lot : une
contre-épreuve qui NE MORD PAS ne prouve pas que le code est bon, elle prouve
qu'on a perturbé au mauvais endroit. Mon assertion « sans mesure, pas de
command » était VACANTE — aucune charge du test n'avait source: none, la
boucle ne s'exécutait jamais, et la garde retirée le test passait toujours.
Toutes les classes du mock portent un état de puissance ; il a fallu une
charge au câblage manquant pour produire le cas. La contre-épreuve mord
désormais.
Suite : simulation 59/59, loadmodel 22/22, charging 48/48 identique à la
référence ligne à ligne, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
CONTRAT. Sous `unmeasurable`, remainingWh == targetWh : rien n'est
soustrait, parce qu'on ne sait rien de ce qui a été livré. 2 000 Wh restants
sur une cible de 2 000 Wh signifie « rien de mesurable », pas « rien livré » —
deux lectures qui commandent des phrases d'écran opposées, et l'écran ne peut
trancher que si le contrat le dit.
Soustraire l'intégration interne ferait franchir la frontière à une
estimation sous l'allure d'un fait : le chemin de carBatteryLevel, refusé en
LM-1009 D3. Et que `regime` voyage dans les mêmes params ne suffirait pas —
un client lisant remainingWh sans regarder regime obtiendrait un nombre
calculé, sans rien pour le lui dire.
Identité épinglée sous `measured` : deliveredWh + remainingWh == targetWh.
Troisième contrôle du même genre que Σ counts == targetW. Elle ne tient QUE
sous measured ; sous unmeasurable c'est remainingWh == targetWh qui prend le
relais, et c'est le régime qui dit laquelle appliquer.
Une honnêteté sur la portée de ce test : la contre-épreuve naïve — soustraire
deliveredWh sous unmeasurable — NE MORD PAS, puisque deliveredWh y vaut 0.
L'assertion est donc un corollaire de `delivered == false`, pas une garde
indépendante. La garde réelle est R6, vérifiée mordante au bon niveau :
publier l'estimation comme un fait fait sortir ECO_FLOOR_MET sous
unmeasurable, ce qui est exactement la violation que R6 interdit.
Commande perdue, étape 1 : l'EvAdapter retient enfin sa dernière commande.
Les adaptateurs en watts la gardaient déjà — l'écrêtage de verrou l'exige —
mais la borne, pilotée par le proxy amont, n'avait jamais eu à se relire.
C'est précisément là que le relevé porte : sans ce souvenir, l'écart entre ce
qu'on a commandé et ce que la borne fait n'est pas calculable. La commande
ÉCRÊTÉE est retenue, jamais la demande : comparer la demande ferait voir un
écart là où l'adaptateur a fait son travail. Publication seule, comportement
constant.
Suite : simulation 58/58, loadmodel 22/22, charging 48/48 identique à la
référence ligne à ligne, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Ce matin j'ai refusé de créer ce motif : il n'avait pas d'émetteur, et un
motif qui naît avant son émetteur est un invariant posé sur du code que rien
ne produit — un test toujours vert. La bascule de période l'a fait naître.
Une obligation abandonnée est désormais un fait que le moteur produit, le
journal le disait déjà, l'écran non.
Trois causes, trois gestes : drawCap envoie regarder l'abonnement,
loadCapacity la fiche technique de la charge, unmeasurable ce que la borne
publie — et unavailable s'y ajoute. Sans la cause, l'écran n'affiche qu'un
reproche sans recours.
missingWh est ABSENT sous unmeasurable, jamais 0 : on ne sait pas ce qui a
été livré, donc pas davantage ce qui manquait. Publier un chiffre
inventerait la grandeur dont l'absence est le problème.
R5 vérifiée contre la maquette plutôt que supposée conforme : la forme de
`progress` correspond exactement.
Un écart assumé et signalé : la maquette donnait ECO_FLOOR_GRID avec
deliveredWh ; le moteur publie remainingWh. deliveredWh vit déjà dans
`progress`, et le republier ferait deux sources pour un même fait. Surtout,
remainingWh n'est PAS dérivable sous unmeasurable — où deliveredWh est
absent — et c'est justement le cas où l'on achète.
R8 : le point de rupture se lit sur DRAW_CAP, jamais sur un solde tombé à
zéro. remainingW à 0 arrive aussi quand tout a été alloué normalement ; un
écran qui le guetterait signalerait une rupture inexistante et manquerait la
vraie. Écrit dans INTERFACE.md.
Suite : simulation 58/58, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Deux passes sur la même liste, même ordre ; ce qui change est la BORNE —
l'autorisation de soutirage pour l'éco, le reliquat de surplus pour le
confort. L'ordre lexicographique sur (niveau, rang) en découle : le rang
classe les charges, la hiérarchie éco/confort classe les besoins et surplombe
le rang.
Une seule commande par charge et par cycle : le niveau éco ne commande pas,
il est fusionné dans l'action de la passe confort. Émettre deux fois ferait
commuter un relais en passe 1 puis le recommuter en passe 2.
ECO_FLOOR_GRID rend lisible la TRAJECTOIRE. La cadence ne monte pas, elle
fait un mur — 333 W à six heures, le plafond dès seize minutes — et
« 1000 W achetés » seul ne permet pas de prévoir la facture. Avec le reste à
livrer et le temps restant, si. Le motif porte aussi le régime (sur quoi on
achète) et atMaximum (l'étalement a cessé).
R6 est armée à DEUX endroits : le calcul ne conclut jamais « tenue » sous un
régime non mesuré, et la passe refuse d'émettre ECO_FLOOR_MET dans ce cas.
Un invariant qui ne tient qu'à un seul endroit tient mal.
L'autorisation de soutirage borne l'éco : le plancher éco est une raison
d'acheter, pas un droit de faire disjoncter. Contre-épreuve : bornage retiré,
1000 W au lieu de 400. Et passe débranchée : un seul niveau au lieu de deux.
Recrédit attribué à la première passe où la charge apparaît, par champ
explicite — la discipline ne se teste pas, le champ si.
Mesuré avant d'être asserté, encore : aucun scénario existant ne portait
d'obligation, donc aucune référence. Et l'invariance reste utile là où elle a
un sens — aucune charge existante ne déclare d'obligation, donc charging
reste identique à la référence ligne à ligne dans une étape qui, par
ailleurs, change le comportement.
Suite : simulation 57/57, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
`authorisedW`, `remainingW`, `binding` et `perPhaseBound` étaient absents
parce qu'aucun plafond de soutirage n'existait. Le lot délestage en a créé
un : le refus visait la FORGE, pas la lecture, et son motif est tombé sans
que sa formulation ne bouge.
`committedW` est publié toujours ; les quatre autres seulement si un plafond
est en vigueur. Le zéro est une valeur quand la grandeur existe, l'absence
en est une quand elle n'existe pas.
`authorisedW` est SIGNÉ — négatif, la maison est déjà au-delà, et l'ampleur
du dépassement se lit. L'écrêter confondrait « pile à la limite » et « on
dépasse de 1200 W », la même perte que celle refusée dans la résolution.
L'identité authorisedW − committedW == remainingW tient toujours, et c'est
elle qui rend la charge utile vérifiable par un client.
Écrit EN TOUTES LETTRES dans le contrat : sous la réserve batterie,
budget.remainingW vaut 0 pendant que draw.remainingW ne le vaut pas, dans la
même trame. La réserve annule le surplus, jamais l'autorisation de
soutirage — contrainte physique du branchement qu'aucune règle de stockage
ne modifie. Les deux répondent à deux questions différentes : « puis-je
dépenser sans acheter ? » et « le branchement supporte-t-il que j'achète ? »
Le test d'absence est mort proprement. L'ancien épinglait « pas de
authorisedW » et a survécu à son motif ; le nouveau épingle la RÈGLE —
présent si le plafond existe, absent sinon — et nomme les deux évolutions
qui le périmeraient. Idem pour le test de la réserve, qui dit que sa
troisième assertion tombe le jour où l'on déciderait qu'une batterie basse
interdit d'acheter au réseau : une décision de modèle, pas une correction.
Suite : simulation 55/55, loadmodel 21/21, spotmarket 32/32, charging 48/48
identique à la référence ligne à ligne. Contre-épreuve : réserve annulant
aussi l'autorisation → 0 au lieu de 3450.
Signalé sans être expliqué : un plantage unique de charging (SIGSEGV) dans
un lot de fond, non reproductible en trois séquences. Le vidage le situe
dans Logger::log() de libnymea.so.1, atteint par un événement posté depuis
libnymea-core — aucune trame ETM dans la pile.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
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
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
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
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
TROIS choses, plus une sauvegarde.
1. LM-1011 — la précédence BELOW_MIN_POWER / DRAW_CAP est dans la SPEC. Je
l'avais écrite hier dans DESIGN_10.md : une note de conception se périme, la
spec fait autorité, et l'index ne voyait donc pas la règle. DESIGN_10 n'en
garde qu'un renvoi.
Autorisation résiduelle non nulle mais sous le plus petit palier →
BELOW_MIN_POWER ; DRAW_CAP réservé au résiduel nul. Ce n'est pas une règle
nouvelle : c'est celle de +etm19 transposée à une seconde ressource — le
surplus a déjà SURPLUS_INSUFFICIENT (« il n'y a rien ») contre BELOW_MIN_POWER
(« il y en a, mais pas au bon format »), et il aurait été absurde qu'une
ressource se lise autrement que l'autre. Ce qui décide n'est pas la véracité
mais le GESTE appelé : revoir le plafond, ou lire la fiche technique.
Conséquence de l'agent app retenue : le point de rupture de l'écran 2 est
ancré sur le DRAW_CAP, donc sur l'épuisement réel. Une charge refusée APRÈS
lui pour cause de palier ne le déplace pas — sans quoi deux charges
successives produiraient deux points de rupture concurrents dans un cycle.
2. LES TROIS NOMBRES au contrat — décidé, appliqué, mesuré. Ma correction sur
allocatedW a fait apparaître un troisième nombre que ni la maquette ni nous
n'avions nommé. INTERFACE.md porte le tableau ET ce que chaque écart signifie,
parce que c'est là qu'un écran se trompe de diagnostic : décidé ≠ appliqué est
une affaire de FORMAT, appliqué ≠ mesuré une affaire d'OBÉISSANCE — et cette
seconde lecture dépend encore de measurement.source.
3. R3 proposée à l'app avant d'être écrite : funding dit d'où viennent les watts,
il ne dit pas dans quel compteur ils tombent. Un niveau publierait sa
ventilation (counts), Σ counts == targetW toujours, et l'identité devient
calculable SANS exception — y compris quand draw.committedW arrivera.
Et la PRÉCONDITION du §10 est consignée : il ne part pas sans l'extension du
délestage au waterfall (LM-1006 couplage 1). La protection de surcharge ne
déleste aujourd'hui que les bornes ; livrer l'éco sans elle donnerait un plancher
capable de faire disjoncter une installation.
Aucun code. ci-quality 3/3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
funding était par CHARGE. Au §10 la même charge sera financée au réseau pour son
plancher éco et au surplus pour son confort DANS LE MÊME CYCLE : un financement
par charge y devient indécidable, et c'est lui qui porte l'identité de
réconciliation de l'app. Laissé à la charge, cette identité cesserait d'être
vérifiable sans que rien ne le signale.
Déplacé maintenant bien que le §10 ne soit pas écrit : seule des huit exigences
de la maquette à être un changement de FORME d'un champ existant, donc seule à se
renchérir à chaque écran qui s'y appuie.
UN SEUL NIVEAU aujourd'hui, et c'est « comfort ». La passe unique d'aujourd'hui
est celle qui, au §10, servira le confort depuis le surplus — la passe éco est la
nouveauté du lot, pas la passe existante rebaptisée. C'est ce qui rend le
déplacement faisable avant le lot : la forme est juste dès maintenant.
funding N'EST PAS conservé en résumé sur la charge, et c'est la première des deux
questions posées. Le cas mixte est justement celui qui le rendrait indécidable :
« mixte » serait un troisième code que personne n'a demandé, « grid si l'un
l'est » une convention que chaque client redériverait autrement. Deux endroits
pour un fait divergent.
Le motif `decision` reste, lui, au niveau de la charge — parce qu'on saura dire
de quel niveau il parle (R7). L'asymétrie est la règle : on conserve un champ
hérité QUAND ON PEUT EN DIRE QUELQUE CHOSE DE VRAI, jamais par symétrie.
L'IDENTITÉ DE RÉCONCILIATION est écrite dans INTERFACE.md, seconde question
posée. Somme sur les charges, somme sur les niveaux dont funding == "surplus", de
niveau.targetW == budget.allocatedW. Un client qui la reconstruirait depuis
l'ancienne forme divergerait au premier cas mixte.
Et son unique exception est publiée plutôt que sue : EV_GRID_START partage une
allocation entre deux compteurs, sa contribution au surplus est
decision.params.budgetW et non targetW. C'est ce que R3 généralisera ; d'ici là
l'exception vit dans le contrat, pas dans la tête du lecteur.
levels[] absent = box antérieure OU mode dégradé — le repli de l'app couvre les
deux. Rupture assumée pour un client lisant entry.funding : même arbitrage que
measuredW → measurement.
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
VERDICT SUR L'ÉCHO, sur une marque FRAÎCHE et sans rien déplacer avant : écho
verbatim inchangé -> EnergyErrorNoError, configuration identique, marque
survivante ; écho verbatim + un renommage ailleurs -> EnergyErrorNoError. Nos
deux relevés contradictoires s'expliquent : le premier essai suivait le
déplacement qui avait fait tomber la marque, l'app écrivait donc un "auto" que le
persisté ne portait plus — cas de l'écho périmé, refus correct.
sansRankOriginAuto() peut partir ; relire avant de réécrire le remplace.
CORRECTION LOURDE — le brief promettait à l'app l'inverse de la vérité.
« Supprimer l'entrée d'une borne dont le Thing existe encore la fait recréer au
cycle suivant » est FAUX : mesuré sur .75, entrée retirée, Thing vivant, rien
recréé en 140 s. syncAdapters() n'appelle provisionEvChargerConfig() que dans la
branche !m_adapters.contains(id), et l'adaptateur est construit depuis le Thing —
il survit à la suppression de l'entrée.
Ce que ça produit est le trou de 3g-2 rouvert par le côté client : la borne reste
ARBITRÉE mais ABSENTE de GetLoadConfig, à un rang de queue non réglable
(evPriority() → 1000). Il ne se referme qu'au redémarrage de nymead. Un correctif
est proposé — reprovisionner toute borne sans entrée, pas seulement à la
naissance de l'adaptateur — et NON écrit : c'est un changement de comportement,
la décision appartient à Patrick.
LM-1209-d — le rang est un ordre TOTAL, les domaines ne le partitionnent pas.
L'insertion en queue entrelace les domaines ; la contiguïté n'a jamais été un
invariant et rien dans le moteur n'en a besoin. L'exiger serait CONTRADICTOIRE
avec la raison même de LM-1209 : insérer une charge heating en respectant la
contiguïté décalerait toutes les ev suivantes, soit une détection qui réordonne
une installation qu'un humain a classée. Le même raisonnement vaut côté client :
grouper à l'affichage oui, à l'écriture non — et la note d'entrelacement ne
devrait pas être un avertissement, une liste entrelacée n'ayant rien
d'incohérent.
UN REFUS ARRIVE EN status: "success" — le verdict est dans params.energyError.
Porté en tête d'INTERFACE.md avec l'extrait qui va bien : une sonde qui teste
status annonce « accepté » sur une écriture rejetée, et une sonde qui se trompe
de champ produit un relevé qui a l'air d'un résultat. Même famille que le journal
lu par un sudo qui échoue en silence.
Banc rendu intact : 5 charges, rangs 1-5, marque "auto" en place au rang 5.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Les trois conditions, d'un bloc.
INSERTION EN QUEUE. provisionEvChargerConfig() posait rang min − 1, écrasé à 1 —
donc à égalité avec la charge que l'installateur avait posée là exprès. C'est
désormais 1 + max(rangs). Queue de LISTE et non de domaine : les domaines ne sont
pas contigus, donc « juste après le dernier ev » exigerait de pousser une charge
d'un autre domaine, ce que la condition 2 interdit. Et comme max(domaine) ≤
max(liste), la queue de liste est toujours au moins aussi reculée — elle coïncide
quand le domaine occupe la fin, et sinon place la nouvelle charge derrière tout
le monde plutôt que de l'intercaler devant une charge classée. Plus fidèle au
principe que la formulation d'origine.
UNICITÉ. validateRanks() refuse une écriture où deux charges portent le même
rang. Avec la mise en garde écrite noir sur blanc : l'égalité ne coûte pas le
déterminisme — le tri est total sur (rang, identifiant) depuis 3g-2 — elle coûte
la gouvernabilité. S'y tromper ferait chercher le défaut dans le tri.
ET LA RÈGLE GÉNÉRALE QUI EN SORT (LM-1209-a-bis) : une vérification qui ÉCARTE et
une vérification qui REFUSE ne peuvent pas être la même fonction. validateSet()
nomme ce qui rend l'ensemble dangereux et load() s'en sert pour écarter ; y
ajouter l'unicité de rang aurait fait perdre des charges au démarrage sur une
configuration héritée — disparues de la configuration ET de l'arbitrage, sans
applySafeState, soit ECS-413 rouvert par une autre porte. Les deux sont
séparées ; validateRanks() n'est appelée que par setConfigs(). Au chargement, un
doublon hérité est signalé bruyamment et CONSERVÉ.
o:rankOrigin. Énumération fermée "auto" | "user", absente par défaut. Une
énumération et non un booléen parce que le troisième état est le point :
`rankIsDefault: false` affirmerait que le rang a été choisi, alors que c'est ce
qu'on ignore d'une configuration héritée. L'absence doit porter le « on ne sait
pas » ; un booléen ne sait pas se taire. Même motif que measurement.source, et la
spec le nomme désormais comme un motif de conception plutôt qu'une décision au
cas par cas.
Et la précision demandée en écrivant : l'effacement porte sur le RANG, jamais sur
la réception d'une écriture. SetLoadConfig remplace en bloc, donc réordonner une
AUTRE charge renvoie forcément l'entrée de celle-ci — l'effacer à cette occasion
perdrait une marque encore vraie. L'omission ne lève rien ; pour lever sans
déplacer, un client envoie "user". Un "auto" fabriqué est refusé, l'aller-retour
verbatim reste licite.
Trois tests. testAutoProvisionedRankGoesToTheQueue vérifié échouant avec
l'insertion en tête — et il échoue en disant que la borne n'entre PAS en
configuration, les deux mécanismes se répondant : le rang libre par construction
et le refus d'écriture en filet. testRankOriginFollowsTheRankNotTheWrite couvre
le cas qui se serait perdu, un client naïf qui omet la clé en déplaçant une autre
charge. testRankUniquenessRefusesWriteButNeverDiscards couvre la moitié qui ne se
voit pas : qu'un chargement conserve ce qu'il aurait pu écarter.
amd64 0 erreur / 0 avertissement. simulation 31/31, charging 16/16, loadmodel
20/20, spotmarket 7/7.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Distinction remontée par l'agent app, qui a dû l'ajouter parce que le contrat ne
la portait pas — et confirmée sur le banc le jour même.
`none` est PUBLIÉ, donc il affirme « cette charge n'est pas mesurable ».
L'absence n'affirme rien : elle dit que la charge n'a pas participé à l'arbitrage
du cycle — mode manuel, aucun véhicule branché — ce qui est sans rapport avec sa
mesurabilité. Sans la distinction, une borne débranchée se verrait annoncer « le
compteur dédié est la seule voie » alors qu'elle publiera la sienne au prochain
cycle.
Même famille que LM-1009 D (un champ absent se voit, un champ qui s'efface se lit
comme un zéro), appliquée un cran plus haut : ici c'est l'ENTRÉE entière qui
s'efface, et la lire comme un `none` transformerait un silence en affirmation.
La garantie qui rend la règle utilisable est posée explicitement : `measurement`
est présent pour toute charge de loads[], et pour elle seule.
Constaté tel quel sur .75 en +etm26 : deux bornes pluggedIn:false absentes de
loads[], dont la V2C qui porte currentPower.
AGENTS.md consigne aussi l'état du déploiement et les trois vérifications de banc
masquées par la configuration de la box — chauffe-eau et pac-terrain portant un
meterThingId réel, elles publient `meter` et le chemin `device` n'est pas atteint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Signalé en relayant +etm26 : « masqué dès que la charge mesure, sauf SG-Ready »
formule la règle PAR MÉCANISME. La conclusion est bonne, la clé est fausse, et le
contrat ne l'interdisait pas explicitement.
L'exception SG-Ready est déjà DANS LA DONNÉE : un sg-ready publie `none`, donc
« masquer quand source == device » ne le masque pas, sans qu'aucun client ait à
connaître le mécanisme.
Et brancher sur `adapter` se trompe sur un cas qui n'a rien de théorique : un
relay-router à topologie MIXTE publie lui aussi `none` (LM-1105-a). Un client qui
déciderait au mécanisme masquerait le champ sur un ECS qui ne peut être mesuré
QUE par un compteur dédié. Un mécanisme est un mauvais indice de mesurabilité ;
la source EST la mesurabilité.
C'est LM-1106 pris par l'autre bout : le régime est publié POUR qu'il ne soit pas
déduit. Le redéduire du mécanisme côté client remettrait dans l'app la déduction
qu'on vient de retirer du moteur — et il faudrait la maintenir à chaque mécanisme
ajouté.
Aucun code, aucune sémantique déplacée : le contrat dit désormais ce qu'il
laissait deviner. Pas de release.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
LM-1105/LM-1106 implantés. measuredW disparaît, sans alias.
ILoadAdapter::deviceMeasurement() — PURE VIRTUELLE, comme runtimeView() et pour
la même raison : un mécanisme ajouté qui hériterait d'un « non mesurable » par
défaut sortirait de la mesure sans bruit, et son écran afficherait « pas
mesurable » avec l'autorité d'un constat. Le compilateur pose la question.
Le type porte une CAPACITÉ, pas une valeur nulle : `available` répond à « cette
charge est-elle mesurable », question posée à la ThingClass, jamais à la valeur
du moment. Un routeur au palier 0 mesure 0 W et reste mesurable ; sans ce
booléen les deux seraient indistinguables.
attachMeasurements() résout meter → device → none. Le compteur explicite garde la
priorité, et pas d'abord parce qu'il mesure mieux : un réglage sans effet est
pire que pas de réglage. La priorité porte sur la SOURCE, pas sur la valeur —
compteur déclaré injoignable, on retombe sur l'appareil, parce que se taire
perdrait une mesure qu'on a et que le silence se lirait « non mesurable ».
Sous `none`, powerW est OMIS. La puissance nominale reste lisible dans
mechanism.*, qui dit d'où elle vient ; la faire passer pour une mesure produirait
un écart commandé↔mesuré identiquement nul à tous les cycles — un « tout
concorde » permanent, corollaire LM-104.
DEUX AFFINEMENTS IMPOSÉS PAR L'IMPLANTATION, tous deux de modèle :
LM-1105-a — une mesure PARTIELLE n'est pas une mesure. Là où plusieurs Things
portent la charge, il faut qu'ils la portent TOUS : trois relais dont un seul
mesure annonceraient 500 W là où 1 500 W coulent, et l'écart enverrait chercher
une panne inexistante. À ne pas confondre avec LoadTelemetry::currentPowerW, qui
accepte « au moins un » et retombe sur le nominal — celui-là est le juge runtime
du budget et doit rendre un nombre à chaque cycle. Les fusionner rouvrirait la
correction B.
LM-1105-b — la règle présuppose que le Thing piloté est TRAVERSÉ par la
puissance. Un relais de relay-router commute la charge, donc sa puissance EST
celle de la charge ; un contact SG-Ready est un contact de SIGNAL — la PAC est
alimentée ailleurs et un contact qui mesurerait quelque chose mesurerait sa
propre bobine. sg-ready est donc `none` par CONSTRUCTION, même sur des relais qui
mesurent — c'est le cas du banc — et c'est le seul mécanisme pour lequel
meterThingId reste la seule voie.
RelayRouter : la capacité se juge sur la ThingClass de TOUS les relais déclarés,
la valeur se lit sur les relais FERMÉS. Jugée sur les relais actifs, la source
vaudrait `device` au palier 1 et `none` au palier 0 — un bloc de mesure qui
apparaît et disparaît au rythme du thermostat. VÉRIFIÉ ÉCHOUANT ainsi.
testMeasurementSourceIsPublished : les trois sources sans une ligne de config de
compteur, le palier 0 qui ne fait pas basculer la source, la PAC en `none` alors
que ses contacts mesurent, la disparition de measuredW, puis les deux règles de
priorité — le compteur explicite l'emporte (1 234 contre 2 900), un compteur
injoignable fait retomber sur l'appareil.
Ne touche PAS ce que l'arbitre lit : LM-1101 tient, LoadTelemetry::currentPowerW
est inchangé, et le recrédit anti-clignotement n'a jamais transité par
meterThingId.
amd64 0 erreur / 0 avertissement. Suites, un processus par test : simulation
30/30, charging 16/16, loadmodel 18/18, spotmarket 7/7.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Constat d'usage de l'agent app, retenu : pour afficher « mesuré » à côté de
« alloué », il fallait désigner une borne comme SON PROPRE compteur dans
meterThingId. Ce n'est devinable par personne, et une note d'écran qui explique
un réglage dit que le défaut est dans le modèle. LM-1105 : quand le Thing piloté
porte lui-même l'état de puissance, le moteur le lit sans configuration.
La règle porte sur le THING, pas sur le mécanisme — certains powerswitch portent
currentPower et RelayRouter sait déjà les sommer ; écrire la règle par mécanisme
y ajouterait la table à maintenir à la main que LM-600 refuse. Et le test se pose
sur la CLASSE, sur TOUS les Things déclarés : interroger les relais actifs ferait
osciller la réponse au palier 0, sur une installation dont rien n'a changé.
Le compteur explicite reste prioritaire — répondu oui, et la raison principale
n'est pas la précision mais qu'un réglage sans effet est pire que pas de réglage.
La priorité porte sur la SOURCE, pas sur la valeur : compteur déclaré injoignable
→ on retombe sur l'appareil, et la charge utile dit lequel a servi.
Le second point de l'agent app décide de la forme, et la vérification l'a rendu
plus tranchant qu'il n'était posé : le moteur porte DÉJÀ trois régimes sous un
seul nom et jette la distinction. evcharger rend une mesure ; relay-router rend
une mesure OU le palier nominal — le booléen `metered` est calculé puis jeté ;
sg-ready rend toujours une puissance DÉCLARÉE, jamais mesurée, délibérément.
Publier ces quatre lignes sous un même measuredW serait faux pour deux d'entre
elles, et un measuredW auto-rempli détruirait au passage la seule information que
le champ portait : sa présence signifiait « un compteur a été posé exprès ».
D'où LM-1106 : le régime se PUBLIE, il ne se déduit pas d'une valeur — C2 de
LM-1009 mot pour mot. measurement {source: meter|device|none, o:powerW}, powerW
absent sous none. `none` est un état POSITIF — C1 de LM-1009 : une capacité
manquante se dit — et c'est lui qui distingue « pas mesurable » de « compteur en
panne ». Sous none, la puissance nominale reste lisible dans mechanism.* : la
faire passer pour une mesure produirait un écart identiquement nul à tous les
cycles, un « tout concorde » permanent, corollaire LM-104.
Ce que la source change au diagnostic, et qui la rend utile plutôt que
décorative : device dit « la charge n'obéit pas » (commande et mesure sortent de
la même boîte) ; meter ouvre trois hypothèses — charge, compteur, câblage — dont
il ne tranche aucune ; none n'autorise rien. L'asymétrie va plus loin : sous
device une CONCORDANCE ne prouve rien, plusieurs wallbox renvoyant leur consigne
en guise de puissance, alors que sous meter elle est une preuve. device détecte
la désobéissance, meter est le seul à pouvoir détecter un mensonge — et c'est ce
qui donne à LM-1104 sa raison d'être : meterThingId reste AUTORISÉ sur une charge
qui publie déjà, à masquer par défaut, pas à interdire.
Ne touche PAS ce que l'arbitre lit : LM-1101 tient, et le recrédit
anti-clignotement continue de lire LoadContext::telemetry.currentPowerW, qui
vient de l'adaptateur et n'a jamais transité par meterThingId. Ce lot déplace ce
qui est PUBLIÉ, pas ce qui décide.
Aucun code. Le contrat bouge (measuredW → measurement), et le brief le dit à
l'app avec son coût : un écran.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Trouvé au banc le 2026-08-27, en exerçant 3g-2 (docs/RELEVE_3g2.md §3.1).
Une wallbox en Eco, voiture assignée, mais `pluggedIn: false`, s'est vu allouer
4 853 W qu'elle ne pouvait pas tirer — `currentPower: 0`, `charging: false`. La
PAC derrière elle n'a survécu que sur son propre recrédit. Trois cycles
consécutifs : 2 350 / 4 853 / 5 464 W retirés du budget et donnés à personne.
evParticipatesInWaterfall() reprenait le filtre d'entrée du proxy — voiture
assignée, mode non manuel — mais pas `pluggedIn`. Le proxy n'en avait pas
besoin : son allocation n'était pas une cascade, donc gaspiller ne privait
personne. En cascade, si. L'asymétrie était déjà visible dans le code : la
boucle EV du scheduler, qui sert les financements réseau, teste
`!ev->pluggedIn()` depuis toujours ; seul le chemin waterfall ne le faisait pas.
C'est le défaut du relevé 3g-1 §3.4 par une autre porte : là le plancher
mentait, ici c'est le filtre d'entrée. Défaut de 3g-1, révélé par 3g-2 parce
que la décision atteint enfin le matériel — et corrigé à part du lot, comme les
quatre précédents.
Hors arbitrage, et non « servie à 0 avec un motif » : même famille que les deux
autres exclusions (DESIGN_3g §4). Une borne débranchée ne consomme rien, sa
consommation nulle est déjà au compteur, et lui donner un rang la ferait
apparaître dans la cascade pour n'y rien faire. Le journal l'annonce comme les
deux autres cas.
testUnpluggedChargerTakesNoBudget, VÉRIFIÉ ÉCHOUANT SANS LE CORRECTIF : la
borne débranchée disparaît du contexte et la charge suivante reçoit les 3 000 W
entiers ; la seconde moitié rebranche le véhicule pour prouver que c'est bien
`pluggedIn` qui décide, et non une exclusion fortuite.
Suite complète : simulation 28/28, charging 16/16, loadmodel 16/16,
spotmarket 7/7. Build amd64 0/0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
Le relevé machine du 2026-08-26 l'énonçait sans détour : « tant que ceci n'est
pas traité, la transplantation ne commande rien ». L'arbitre décidait,
publiait un motif juste, et adjustEvChargers() commandait autre chose une
milliseconde plus tard. Ce lot ferme les trois points que le banc a ouverts.
1. UNE CHARGE, UN COMMANDEUR
Les actions EV du waterfall sont enfin DISPATCHÉES — elles ne l'étaient pas du
tout : m_loadAdapters ne contient pas les bornes, et applyActionsToAdapters()
s'arrêtait là. Et les bornes ainsi commandées sortent du chemin proxy par deux
crochets virtuels posés dans l'amont, interrogés à deux moments du cycle où la
réponse n'est pas la même : evSurplusPlannedByWaterfall() avant le plan (à qui
la borne appartient-elle), evCommandedByWaterfall() après (qu'a-t-il commandé).
Sans cette distinction, une borne servie par l'échéance ne serait plus
commandée du tout.
Le jeu des bornes commandées est NOMMÉ par le scheduler (Plan::waterfallEvIds).
Le redéduire ailleurs — « motif différent d'EV_DEADLINE », « financement au
surplus » — dériverait du vrai au premier motif ajouté, et EV_GRID_START porte
justement funding=Grid tout en venant du waterfall. Il est vidé à l'entrée du
mode dégradé : le waterfall ne commandant plus, il ne peut plus revendiquer.
chargingState, que posait adjustEvChargers(), est repris par l'arbitre pour
les bornes qui n'y passent plus — sinon l'app afficherait « en charge » une
borne que l'arbitre vient d'éteindre.
2. evFloorW() REND L'EXIGENCE RÉELLE, ET LA TOLÉRANCE DEVIENT VISIBLE
Le plancher valait `min × acquisitionTolerance × 230 × phases` : 2 070 W
annoncés pour une borne qui en exige 4 140. Le waterfall allouait 2 828 W,
l'adaptateur refusait d'enclencher sous 6 A, et 2 828 W étaient retirés du
budget pour personne. Ce sont deux grandeurs différentes : le plancher est ce
que la borne EXIGE, la tolérance est la part qu'on accepte de ne pas couvrir.
La tolérance n'est pas supprimée pour autant — un réglage documenté (défaut
0,5) qui devient inerte sans le dire est un silence ambigu, et tout le parc
verrait son VE démarrer deux fois plus tard sans explication. Elle devient un
seuil de démarrage EXPLICITE : motif EV_GRID_START {budgetW, floorW, gridW},
funding=Grid, et une comptabilité qui se partage — budgetW dans
budget.allocatedW, gridW dans budget.evReservedW, budgetW + gridW ==
allocatedW.
C'est une brèche assumée dans le financement réseau que DESIGN_3g §3.1 déférait
à 3f. Elle est bornée par construction, pas par discipline : surplus réel exigé
(budgetW > 0), tout le budget restant consommé donc AU PLUS UNE borne par
cycle, et le plafond de la protection de surcharge s'applique avant. Écrite en
toutes lettres dans DESIGN_3g §3.1-bis.
3. LE RANG D'UNE BORNE EST CONFIGURABLE
Il vit là où vivent tous les rangs : LoadConfig.priority (LM-1206/LM-1207). Pas
de second système de priorité — un classement propre aux bornes ne saurait pas
exprimer « VE1 > ECS > VE2 », et deux classements qui ne peuvent pas
s'interclasser sont un défaut, pas deux fonctionnalités. ChargingInfo ne porte
donc aucun champ de rang.
Toute borne détectée reçoit d'office une entrée « evcharger », portant le rang
et RIEN d'autre : ses limites viennent du Thing et changent avec la voiture
branchée. Création journalisée, une ligne par borne, à la création seulement —
une écriture que personne n'a demandée doit laisser une trace. Le tri devient
TOTAL : à rang égal, l'identifiant départage, donc laquelle démarre est
reproductible même avant tout réglage.
L'entrée SURVIT à la disparition de son Thing : borne remplacée, ThingId
changé, appareil déposé. Elle reste publiée available: false / faultCode:
THING_MISSING / motif LOAD_UNAVAILABLE, et ne retient aucun budget. La
supprimer d'office rendrait « borne remplacée » indistinguable de « borne
jamais configurée ».
CORRIGE UNE RUPTURE D'INVARIANT DE 3g-1, non signalée à l'époque : en faisant
entrer les bornes dans loads[], 3g-1 publiait des charges qui ne figuraient
dans AUCUNE GetLoadConfig — la charge fantôme que le lot B-bis avait
supprimée, réapparue par l'autre bout. L'app s'appuie sur cet invariant pour
résoudre libellé, domaine et rang. L'auto-provisionnement le rétablit par
construction.
ÉPROUVÉ
- testEvChargerRankDecidesWhoCharges : deux bornes, un budget qui ne paie
qu'un plancher, la seconde refusée avec BELOW_MIN_POWER {budgetW: 620,
minPowerW: 1380}, et l'inversion du rang change la borne servie. C'est la
moitié ARBITRAGE de LM-1204-b. Couvre aussi l'aller-retour neutre par la
frontière RPC et l'inclusion loads[] ⊆ GetLoadConfig.
- testEvGridStartIsAnnouncedAndBounded : le motif chiffre le soutirage, la
comptabilité se réconcilie, une seule borne soutire par cycle.
- testEvChargerConfigOutlivesItsThing : l'entrée survit, publiée en défaut.
- testEvChargerConfigIsRankOnly / RefusesPayloads / ClaimsItsThing.
Deux points de contrôle de `run` bougent, dans un seul scénario et par une
cause unique : la conversion watts→ampères se fait désormais après
l'allocation, et l'arrondi tombe parfois de l'autre côté. 14 des 16 scénarios
sont identiques au bit près, et ce qui compte est inchangé dans les deux —
la voiture est à 100 % à l'échéance.
Suite complète : simulation 27/27, charging 16/16, loadmodel 16/16,
spotmarket 7/7. Build amd64 0/0.
RIEN N'A TOURNÉ SUR MACHINE. docs/RECAP_3g2.md dit ce qui reste à éprouver au
banc — dont LM-1204-b avec deux bornes réelles, la Terra AC étant vérifiée
absente du réseau (ARP INCOMPLETE depuis le banc lui-même).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
LM-1203-b dit « le budget est ANNULÉ, pas réduit », et le motif publié dit
« aucune charge n'est servie ». Le code disait autre chose : la réserve ne
mordait que sur les AUGMENTATIONS.
Le recrédit anti-clignotement (correction B) rend à chaque charge sa
consommation de début de cycle avant d'arrondir. Il le faisait aussi sous la
réserve — donc toute charge déjà en marche gardait exactement sa puissance,
indéfiniment, pendant que la télémétrie annonçait qu'aucune n'était servie.
Le recrédit suppose que le budget est une MESURE dont la consommation de la
charge a déjà été retranchée. Sous la réserve, le budget n'est pas une mesure
mais une DÉCISION de retenir : recréditer dedans rend exactement ce qu'on
venait de confisquer. Le recrédit est donc supprimé dans ce seul cas, pour les
charges en watts comme pour les états SG-Ready — sans quoi une PAC en état 3
ou 4 y resterait elle aussi indéfiniment.
Conséquence sur le motif : ce que la réserve retient à une charge a DEUX
termes, et il faut les deux. Le surplus annulé, le même pour toutes, et sa
propre consommation, que le recrédit lui aurait rendue. Sans le second, une
charge en marche coupée par la réserve sur un surplus nul publiait « surplus
insuffisant » — vrai sur le surplus, faux sur la cause : sans la réserve, elle
aurait continué de tourner.
Le défaut ne s'est vu qu'en exécutant réellement les décisions du waterfall :
batteryLevelConsiderationStopCharging échouait, la borne restant allumée là
où l'amont la coupe.
Suite charging vert (dont les deux tests de réserve), simulation `run` 16/16,
testEcsSurplusPV / testSgReadySurplus / testLoadTelemetryRpc verts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
Troisième défaut latent de 3g-1, et le seul des trois qui touche la sécurité.
Corrigé à part du lot 3g-2, pour pouvoir être relu ou rétroporté seul.
L'écrêtage d'une consigne EV par la limite de phase vivait dans
planSurplusCharging() : `newValue = qMin(newRawValue, allowanceOnRootMeter)`.
En transplantant la décision de surplus dans le waterfall, 3g-1 a laissé cet
écrêtage derrière lui. Sans conséquence tant que rien n'était exécuté — mais
la décision du waterfall est censée l'être, et alors plus rien ne borne
l'allocation avant qu'elle ne parte au matériel. Le seul filet restant serait
verifyOverloadProtection(), APRÈS coup.
Le plafond redevient donc une borne DÉCLARÉE de l'adaptateur
(LoadContext.declared.maxPowerW), ce qui le place là où la règle 4 d'AGENTS le
veut : le waterfall ne peut pas allouer au-delà, au lieu d'être rattrapé.
Deux points de sécurité tenus explicitement :
- Le plafond ne descend JAMAIS sous le plancher de la borne. C'est la règle
de l'amont reprise mot pour mot (`if (newValueInt <= minValue) newValueInt
= minValue`) et la répartition des rôles qu'elle porte : la limite de
phase RABAISSE la borne à son minimum, elle ne la coupe pas. Couper est le
geste de la couche L4, qui tourne en temps réel sur powerBalanceChanged et
garde le dernier mot après le dispatch. Écrêter jusqu'à 0 ici ferait
décider la coupure par la stratégie.
- L'allowance est la place LIBRE, sans rendre à la borne ce qu'elle tire
déjà. Le retour serait plus juste en régime établi, mais ferait disparaître
la réponse à la surcharge : sur une phase en dépassement, la place libre
est NÉGATIVE, et c'est ce signe qui ramène la borne à son minimum.
Nouveau motif PHASE_LIMIT {limitW, requiredW} : quand la place libre tombe
sous ce que la borne exige, c'est la limite de phase qui interdit, pas le
budget. Dire « budget sous le plancher » enverrait attendre le soleil là où
il faut regarder l'abonnement et les autres consommateurs — deux causes, deux
gestes (règle 7-b).
Suite simulation `run` 16/16, testOverloadProtectionEcoMode vert.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
Deux écarts entre le contrat écrit et le code, dans le même type.
Le premier accompagne le correctif de la description publiée : INTERFACE.md
répétait la promesse fausse à trois endroits, dont une note d'intégration qui
la formulait en toutes lettres (« seuls les champs présents sont appliqués »).
La table des défauts obtenus est désormais donnée, ainsi que le geste correct.
Le second n'avait pas de correctif de code parce que le code a raison :
`repeatDays` était documenté « 0=Lun … 6=Dim » alors que setChargingInfo()
refuse tout ce qui sort de [1, 7] — un client qui suit la documentation reçoit
EnergyErrorInvalidParameter sur le lundi. Les bornes de `targetPercentage`
sont documentées au passage, pour la même raison.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
buildTelemetry() n'interrogeait que la table des charges de configuration et sautait l'EV
avec un commentaire devenu faux (« décidé en amont du waterfall »). Le journal montrait les
décisions des deux bornes ; GetLoadTelemetry n'en montrait aucune. Le changement de contrat
annoncé par +etm21 n'était donc pas livré — et LM-1204-b était inéprouvable, puisque son
discriminant est justement le motif publié.
Nouveau champ loads[].funding (« surplus » / « grid ») : dès lors que les bornes sont dans
loads[], deux d'entre elles peuvent y être pour des raisons différentes, l'une comptée dans
budget.allocatedW, l'autre dans budget.evReservedW. Sans lui, sommer allocatedW donne un
écart ininterprétable.
Constat non corrigé, écrit au contrat : le rang d'une borne n'est pas configurable
(priority = 100 en dur) et std::sort n'est pas stable — laquelle des deux bornes est servie
n'est ni réglable ni garanti reproductible.
Aussi : « batterie à 50 %% » — %% n'est pas une échappée pour QString::arg. Vu au banc.
Et retrait du code mort buildMinCurrentAction/buildIdleAction, seuls porteurs de EV_ECO_MIN
et EV_IDLE depuis +etm21.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
La transplantation. Les bornes partagent le budget, décrémentent la cascade et portent
un rang qui a enfin un sens — il était décoratif depuis le début.
Le défaut corrigé n'était pas théorique : planSurplusCharging() calculait l'allocation
une fois avant la boucle et ne la décrémentait jamais. Chaque borne recevait la totalité
du surplus. Avec 4 kW et deux bornes, ce n'était pas « 2 kW à chacune » mais « 4 kW à
chacune », et le rattrapage venait du compteur au cycle suivant, pas de l'arbitrage.
Trois motifs EV disparaissent — des suppressions, pas des remplacements. EV_ECO_MIN en
particulier : le courant minimum était la même idée que le plancher du §12, écrite une
seconde fois pour un seul mécanisme.
Le nombre de phases est décidé AVANT l'allocation et réutilisé à l'exécution, parce que
c'est lui qui fixe le plancher — 6 A valent 1,4 kW en mono et 4,1 kW en tri. C'était le
piège le plus silencieux du lot.
evReservedW est restreint au financement réseau plutôt que supprimé : ce qui reste
décidé en amont doit rester réservé, ce qui passe par la cascade ne le doit plus.
Et une correction attrapée à la première observation en vrai de BATTERY_RESERVE : il
était publié avec withheldW à 0 sur un surplus négatif, donc il disait « une règle vous
bloque » là où il n'y avait simplement rien.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
Le vrai sujet n'était pas le banc mais le défaut d'usine. Une fois la règle batterie
dans le budget, un seuil à 0,9 gèle toute l'installation sous 90 % de SOC — ni ECS, ni
PAC, ni borne — sans erreur et sans panne. Personne ne s'en apercevait tant que la règle
ne touchait que le VE.
Le réglage confondait une CIBLE et une RÉSERVE. Une cible est ce qu'on aimerait
atteindre, une réserve ce sous quoi on ne descend pas ; la règle « sous ce niveau, rien
d'autre n'est servi » a une sémantique de réserve et était défaultée comme une cible.
0,2 protège l'autonomie du soir sans confisquer la journée, et un installateur qui veut
« la batterie d'abord » monte la valeur délibérément — maintenant que l'écran l'expose.
BATTERY_RESERVE publie le surplus RETENU, pas seulement le SOC : « 3 200 W retenus » se
comprend là où « batterie à 47 % » se subit.
LM-1203-b inscrit la nuance qui ne se déduit pas du code : sous la réserve le budget est
annulé, pas réduit.
Vérifié au passage, comme demandé : evReservedW devra filtrer sur funding == Grid après
la seconde moitié — spot market et échéance restent au proxy, minCurrent et idle
disparaissent. Il reste correct tel quel tant que le VE n'est pas dans la cascade.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
Audit demandé par Patrick : un motif qui décrit la cause générique là où le mécanisme
connaît la cause précise. Deux branches en portaient le défaut, et une troisième le
porte ailleurs.
Branche à paliers : un budget de 300 W devant un premier palier de 500 W publiait
SURPLUS_INSUFFICIENT. Ce n'est pas un manque de surplus, c'est une granularité.
Branche sg-ready : le code publié était SG_NORMAL — correct sur le fond, donc l'app n'a
jamais été trompée. C'est le RENDU FRANÇAIS qui disait « Surplus insuffisant (933 W) »
devant un état 3 à 1 500 W, et c'est l'installateur qui lit journalctl pendant une mise
en service qui l'était. Le paramètre state nomme désormais l'état hors d'atteinte :
dire « sous le plancher » sans dire lequel remplacerait une phrase vague par une autre.
La distinction conservée est celle que Patrick a posée : budget ≤ 0 laisse
SURPLUS_INSUFFICIENT / SG_NORMAL — il n'y a rien. Budget positif sous le premier palier
donne BELOW_MIN_POWER — il y en a, mais pas au bon format.
Troisième cas, non corrigé ici : EV_IDLE dit « pas de surplus » là où la borne a un
plancher de 6 A. Son site est le proxy amont, que 3g remplace ; l'y corriger
maintenant reviendrait à écrire deux fois la même règle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
Le domaine d'une consigne devient {0} ∪ [minPowerW, maxPowerW], un ensemble discontinu.
LM-1203 est la règle qui fait le travail : sous le plancher, allouer 0 et TRANSMETTRE le
budget à la charge suivante.
Constat en lisant le waterfall : il satisfaisait déjà LM-1203 pour les charges à
paliers — le plus haut palier ≤ budget vaut 0 quand le budget est sous le premier, et
le résidu cascade. Le trou n'était que dans la branche continue, où
qBound(0, budget, max) acceptait n'importe quelle miette. Le correctif est donc plus
petit que la spec ne le laissait craindre, et localisé.
Le plancher est déclaré pour etmvariableload dynamic et dérivé partout ailleurs — plus
petit palier non nul chez relay-router, plus petite estimation non nulle chez sg-ready.
SetLoadConfig refuse de le déclarer là où il se dérive, et refuse aussi minPowerW >
maxPowerW : le domaine serait réduit à {0}, la charge ne démarrerait jamais, et rien
dans le journal ne dirait pourquoi.
Motif BELOW_MIN_POWER distinct de SURPLUS_INSUFFICIENT : les confondre enverrait
chercher du soleil là où il faut lire une fiche technique.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
Vérifié sur .75 après déploiement de +etm17 : GetLoadConfig rend meterThingId='' et
sensorThingId='' sur les deux charges du banc, alors que le fichier persisté n'en porte
aucune trace. C'est la sérialisation par méta-objet, exactement comme domain — et c'est
ce qui rend l'aller-retour Get → Set neutre, donc ce sur quoi l'app s'appuie pour
écrire. Un client traite '' et l'absence de la même façon.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
Deux champs optionnels de même forme sur LoadConfig — o:meterThingId, o:sensorThingId —
et leur publication en télémétrie. Rien d'autre : l'arbitre ne s'en sert pas pour
décider, et le §12 comme le §10 restent à faire.
« Aucun effet sur les décisions » est vrai par CONSTRUCTION, pas par discipline : les
adaptateurs ne voient jamais ces Things. attachMeasurements() les résout au moment de
publier, dans les deux chemins de télémétrie — y compris le mode dégradé, où un
exploitant veut précisément voir ce que la charge fait pendant que l'arbitre ne décide
plus rien.
measuredW est publié À CÔTÉ de allocatedW et jamais fondu dedans : l'un est ce que le
waterfall a alloué, l'autre ce qu'un compteur a vu. Les confondre rendrait la somme des
allocations irréconciliable avec budget.allocatedW — et injecter une mesure en retard
sur la commande réintroduirait l'oscillation que le recrédit a supprimée.
claimedThingIds() les exclut, et l'exclusion est maintenant commentée sur place : LM-201
vaut pour ce qui commande, ceux-ci mesurent. Sans ce commentaire, la symétrie avec
relays[] invitait à les ajouter — ce qui ferait refuser un compteur divisionnaire
partagé ou une sonde de pièce commune.
spec 0.2.1 : la précondition de livraison du §10 (extension du délestage au waterfall,
même lot ou avant, jamais après) est écrite comme CONDITION et non comme conseil — la
protection de surcharge ne déleste que les bornes, et des planchers éco qui
s'additionnent un matin sans soleil sur un branchement 6 kVA feraient disjoncter
l'installation. Et LM-1008 tranche la cible de confort : saisie en degrés, frontière de
l'arbitre en énergie, conversion à la couche domaine — et aucune conversion nécessaire
aujourd'hui, un comparateur suffit, c'est l'arrêt qui compte et pas le dosage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
Le bloc d'enregistrement codé en dur d'energypluginnymea.cpp est supprimé, avec
registerSgReadyAdapter(), la table m_sgReadyAdapters et claimedRelays(). Il n'existe plus
aucun chemin d'enregistrement en dur : m_loadAdapters est la table UNIQUE des charges
arbitrées, et toutes ses entrées viennent de LoadConfigStore.
Contrat : loads[] de GetLoadTelemetry est désormais INCLUS dans GetLoadConfig, par
CONSTRUCTION et non par discipline. La règle de détection publiée à +etm14 — « un loadId sans
équivalent dans GetLoadConfig est une charge non configurable » — est retirée d'INTERFACE.md :
elle n'avait qu'un porteur, pac-terrain, qui vient de disparaître. Aucun configurable:false
n'est ajouté, il n'aurait plus rien à signaler. L'inclusion ne vaut que dans ce sens : une
charge enabled:false est configurée sans être publiée.
SÉCURITÉ — repli L2 d'une PAC de configuration, trou ouvert par LM-300 et refermé ici. Le
repli envoyait un Setpoint(0) à toute la table et un State(2) à une table séparée réservée à
la PAC en dur ; une PAC construite depuis la CONFIG vivant dans la première recevait donc une
action que SgReadyAdapter rejette d'entrée, et rien d'autre — elle restait en état 4 sous
compteur muet quand SAFETY.md §L2 exige l'état 2. Le motif DEGRADED_L2 s'affichait
correctement : seule l'écriture manquait. Le repli lit maintenant supportedKinds (LM-301-b).
ECS-411/-411-b au SG-Ready : l'état de départ est relu sur les contacts, avec relevé contact
par contact et l'issue — y compris quand l'issue est l'état neutre. ECS-412 au minStateHold :
armement paresseux au démarrage à froid, jamais permanent.
Harnais de test : JournalCapture se chaînait sur elle-même (récursion infinie à croissance
quadratique dès la deuxième capture vivante, donc OOM) ; tm/owner non initialisés sur trois
sites de freshSetup().
Vérifié sur .75 avant publication — docs/RELEVE_LOTBbis.md. Suites : simulation 85,
charging 74, spotmarket 44, zéro échec, un processus par test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
Constat d'origine, rejoué et daté au banc : 90 s d'écoute, arbitre actif, ~2 kW exportés
→ ZÉRO notification NymeaEnergy.*. L'état runtime de l'arbitre (allocation, budget, verrous,
paliers, disponibilité, motifs) ne franchissait aucune frontière RPC : il vivait exclusivement
dans qCInfo(dcNymeaEnergy). Expliquer les décisions au client n'avait donc aucun transport.
Après déploiement, même geste : 13 notifications en 480 s.
Aucune phrase composée par la box. La locale nymea est PAR CONNEXION (JSONRPC.Hello o:locale),
et NymeaEnergyJsonHandler est un JsonHandler sans pluginId — donc hors du catalogue de
traduction : rien ne rattraperait une phrase française émise là. La box transporte un CODE et
des PARAMÈTRES ; la phrase est fabriquée par le client.
LoadAction::reason devient DecisionReason{code, params}. La ligne française du journal est
RENDUE depuis ce couple par renderFr(), source unique — jamais maintenue en parallèle. Les 19
codes sont EXTRAITS des sites existants, pas inventés ; les motifs d'ECS-309 et ECS-309-b sont
repris mot pour mot. Prouvé par comparaison de traces (1726 lignes de journal identiques en
simulation), par une table d'or qui échoue au premier code sans phrase attendue, et au banc sur
7 motifs distincts. Le libellé de la charge n'entre pas dans la charge utile : il est substitué
au RENDU, par l'appelant qui le connaît.
Les verrous sortent en SECONDES RESTANTES, pas en watts. Ceci n'enfreint pas ECS-306 : cette
exigence interdit qu'un index de PALIER franchisse la frontière d'ALLOCATION ; une valeur
d'AFFICHAGE sortante est un autre chemin. Le mécanisme sort en union discriminée
(relay|variable|sgReady), toutes clés internes optionnelles au schéma (LM-302).
ILoadAdapter::runtimeView() est PURE VIRTUELLE à dessein — mais la pure virtuelle force à
RÉPONDRE, pas à répondre quelque chose. testRuntimeViewIsSubstantial() est le second verrou :
les quatre adaptateurs, amenés dans un état non trivial, doivent rendre les valeurs runtime
réelles, les verrous en secondes ET leur expiration, et les codes de défaut. Deux mutants
« vue vide » introduits puis retirés : le test échoue sur chacun.
budget publié : surplusW, evReservedW, recreditedW, allocatedW, remainingW, avec l'identité
surplus + recrédit − alloué == restant. recreditedW n'est pas décoratif : sans lui l'identité
serait fausse DÈS QU'UNE CHARGE CONSOMME, et le client chercherait un écart inexplicable. Le
banc l'a montré aussitôt — surplusW −250 W avec recreditedW 3000 W — rendant lisible l'artefact
de banc documenté dans AGENTS.md (relais sans mesure + rootmeter aveugle).
Champs omis, jamais nuls : timestamp absent = aucun cycle depuis le démarrage ; budget absent =
mode dégradé, planification suspendue. Publier un zéro se lirait « exécuté, rien à allouer ».
Cadence : changement qualitatif ou variation d'allocation > 50 W, plus un battement de 60 s.
C'est le battement qui rend un arbitre FIGÉ détectable — les notifications continuent tandis que
le timestamp cesse d'avancer. Observé 3 fois au banc. Volume mesuré : ≈ 1,7 Mo/jour, un client.
GetChargingSchedules rend enfin degradedMode : il était POUSSÉ en notification seule, donc
illisible par un client connecté après la bascule (famille ECS-411). Règles absolues 11 et 12
ajoutées à AGENTS.md.
LoadConfig : champ optionnel domain (ecs·ev·heating·hvac·battery), PURE métadonnée d'intention
que l'arbitre ne lit pas. Absente ou vide = non classée. Forme exacte découverte au banc et
corrigée dans la doc ET le test : GetLoadConfig sérialise via le méta-objet, donc la propriété
sort TOUJOURS, à "" — comme sgReady:{} ou powerLevels:[]. C'est le corollaire LM-302.
Règle 7-c, deux silences fermés : les retours idempotents du RelayRouter et du SgReadyAdapter
étaient MUETS — « consigne reçue, palier déjà bon » ne se distinguait pas de « l'action n'est
jamais parvenue à l'adaptateur », et c'est le cas ordinaire qu'on vient vérifier.
⚠️ Porté au contrat : loads[] n'est PAS en correspondance 1:1 avec GetLoadConfig. « pac-terrain »
est la PAC SG-Ready CODÉE EN DUR d'energypluginnymea.cpp (K1/K2 = Relay4/Relay5, {3:1500,
4:3000}) — arbitrée et publiée, mais inconfigurable : ni rang, ni domain, ni enabled:false.
INTERFACE.md porte la règle de détection pour l'app. Son sort reste À TRANCHER (RELEVE_LOTB §8-bis b).
Déploiement : la recette de TEST_TERRAIN.md §b portait un glob « dpkg -i /tmp/…_*_arm64.deb »,
de la même famille que celui qui a rétrogradé .75 de +etm12 à +etm9. Remplacée par
etm-powersync-deploy/deploy.sh (archivage avant dépôt, installation par nom, refus de
rétrogradation, contrôle de version après coup).
Vérifié sur .75 (1.15.2+etm13 puis +etm14) : critères 1, 2, 4, 5, 6, 7, 8 du brief. Critère 3
(épisode dégradé) vérifié en simulation seulement — le déclencher demande de faire taire un
simulateur SunSpec partagé, hors périmètre. Relevé complet, critère par critère, avec ce qui n'a
PAS été vérifié sur la machine et pourquoi : docs/RELEVE_LOTB.md.
Suites : simulation 38/38, charging 46/46, spotmarket 32/32, y compris en build DEBUG où les
Q_ASSERT_X du validateur JSON-RPC sont actifs (le schéma déclaré correspond donc à la charge
utile émise, retours ET notifications). Cross-arm64 : 0 avertissement. Doxygen : 10, inchangé.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MfvSskkYrrFriYcj4683D