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 règle disait « attribué à la PREMIÈRE passe où la charge apparaît », et le code
l'appliquait fidèlement. Mais la passe éco n'est pas bornée par le surplus : elle
l'est par l'autorisation de soutirage, et ne touche jamais remainingSurplusW. Lui
attribuer le recrédit revenait donc à ne le créditer NULLE PART.
Conséquence : une charge servie en éco ne récupérait jamais sa propre consommation,
et son tirage déprimait le budget dont elle avait besoin pour recevoir du surplus
par-dessus. Mesuré sur .75 : borne à 3 899 W, surplus publié −3 148 W ; sans elle
il valait +700 W. Elle se privait exactement du surplus qu'elle pouvait recevoir,
et « minimum + surplus » ne servait jamais que le minimum.
LE DÉFAUT PRÉEXISTAIT au lot d'aujourd'hui — le chauffe-eau servi en éco l'avait
déjà. Il était invisible parce que son compteur affiche 0 W : une charge qui ne
mesure rien ne peut pas se priver de quoi que ce soit. Il a fallu une charge qui
mesure, et qui tire quatre kilowatts, pour qu'il se voie. C'est une septième forme
de l'homogénéité du banc, et la première où c'est l'ABSENCE de mesure qui masque et
non l'uniformité.
Le paramètre recreditDejaAttribue et l'ensemble recreditAttribue sont retirés
plutôt que laissés inertes : un mécanisme vestigial se relit comme une garde.
Suites : loadmodel 26/26, simulation 67/67, charging 48/48, spotmarket 32/32.
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.
`voltage` était déclarée une seule fois hors des trois blocs et mutée par
l'un d'eux : une phase sans tension déclarée était calculée avec celle d'une
autre.
Et le défaut n'était pas celui qu'on croyait — le mesurer l'a montré.
L'interface energymeter rend currentPhaseA et voltagePhaseA OBLIGATOIRES, B
et C optionnels : la branche « puissance ÷ tension » de A est morte par
construction. La fuite n'allait donc pas de A vers B, comme la note d'origine
l'affirmait, mais de B vers C.
Démontré : B à 200 V, C à 4 000 W sans tension déclarée → 5,0 A de marge au
lieu de 7,6. Deux conditions, dont aucune n'était évidente avant d'écrire le
test : le masque doit couvrir B ET C — interroger C seule n'exécute pas le
bloc de B — et C doit être la phase la plus contrainte, sinon le qMin retient
B et masque l'écart. Mes deux premières versions du test passaient pour ces
deux raisons.
Le mock ne savait pas produire le cas : il a fallu meterPowerOnly, avec les
puissances et la tension de B mais sans les courants de B/C ni la tension de
C. Troisième fois qu'ouvrir le mock révèle un défaut plutôt que de seulement
débloquer un test.
draw.bindingPhase dit LAQUELLE des phases borne. perPhaseBound disait qu'une
phase borne, jamais laquelle : l'écran ne pouvait dire que « bridé » sans
dire par quoi. DRAW_CAP la porte aussi mais n'est émis que si une charge est
refusée — un cycle qui borde sans rien refuser laisserait l'écran muet.
Et dans AGENTS.md : une conclusion juste posée sur un raisonnement faux
survit jusqu'à ce qu'on bâtisse dessus, parce que la conclusion PROTÈGE le
raisonnement de l'examen. Un résultat faux se voit ; un résultat juste ferme
la question. Parade : quand une décision est écrite avec sa raison, la raison
mérite son propre contrôle — c'est elle que le lot suivant utilisera.
Suite : simulation 65/65, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Le 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
Ce matin j'ai refusé de créer ce motif : il n'avait pas d'émetteur, et un
motif qui naît avant son émetteur est un invariant posé sur du code que rien
ne produit — un test toujours vert. La bascule de période l'a fait naître.
Une obligation abandonnée est désormais un fait que le moteur produit, le
journal le disait déjà, l'écran non.
Trois causes, trois gestes : drawCap envoie regarder l'abonnement,
loadCapacity la fiche technique de la charge, unmeasurable ce que la borne
publie — et unavailable s'y ajoute. Sans la cause, l'écran n'affiche qu'un
reproche sans recours.
missingWh est ABSENT sous unmeasurable, jamais 0 : on ne sait pas ce qui a
été livré, donc pas davantage ce qui manquait. Publier un chiffre
inventerait la grandeur dont l'absence est le problème.
R5 vérifiée contre la maquette plutôt que supposée conforme : la forme de
`progress` correspond exactement.
Un écart assumé et signalé : la maquette donnait ECO_FLOOR_GRID avec
deliveredWh ; le moteur publie remainingWh. deliveredWh vit déjà dans
`progress`, et le republier ferait deux sources pour un même fait. Surtout,
remainingWh n'est PAS dérivable sous unmeasurable — où deliveredWh est
absent — et c'est justement le cas où l'on achète.
R8 : le point de rupture se lit sur DRAW_CAP, jamais sur un solde tombé à
zéro. remainingW à 0 arrive aussi quand tout a été alloué normalement ; un
écran qui le guetterait signalerait une rupture inexistante et manquerait la
vraie. Écrit dans INTERFACE.md.
Suite : simulation 58/58, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Deux passes sur la même liste, même ordre ; ce qui change est la BORNE —
l'autorisation de soutirage pour l'éco, le reliquat de surplus pour le
confort. L'ordre lexicographique sur (niveau, rang) en découle : le rang
classe les charges, la hiérarchie éco/confort classe les besoins et surplombe
le rang.
Une seule commande par charge et par cycle : le niveau éco ne commande pas,
il est fusionné dans l'action de la passe confort. Émettre deux fois ferait
commuter un relais en passe 1 puis le recommuter en passe 2.
ECO_FLOOR_GRID rend lisible la TRAJECTOIRE. La cadence ne monte pas, elle
fait un mur — 333 W à six heures, le plafond dès seize minutes — et
« 1000 W achetés » seul ne permet pas de prévoir la facture. Avec le reste à
livrer et le temps restant, si. Le motif porte aussi le régime (sur quoi on
achète) et atMaximum (l'étalement a cessé).
R6 est armée à DEUX endroits : le calcul ne conclut jamais « tenue » sous un
régime non mesuré, et la passe refuse d'émettre ECO_FLOOR_MET dans ce cas.
Un invariant qui ne tient qu'à un seul endroit tient mal.
L'autorisation de soutirage borne l'éco : le plancher éco est une raison
d'acheter, pas un droit de faire disjoncter. Contre-épreuve : bornage retiré,
1000 W au lieu de 400. Et passe débranchée : un seul niveau au lieu de deux.
Recrédit attribué à la première passe où la charge apparaît, par champ
explicite — la discipline ne se teste pas, le champ si.
Mesuré avant d'être asserté, encore : aucun scénario existant ne portait
d'obligation, donc aucune référence. Et l'invariance reste utile là où elle a
un sens — aucune charge existante ne déclare d'obligation, donc charging
reste identique à la référence ligne à ligne dans une étape qui, par
ailleurs, change le comportement.
Suite : simulation 57/57, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
La frontière FABRIQUAIT le niveau à partir de l'action : elle ne pouvait donc
en produire qu'un, puisqu'il n'y avait qu'un endroit pour le former. Le plan
les porte désormais (`LoadAction::Level`), et la télémétrie les traduit au
lieu de les décider — une frontière qui invente une structure finit par
diverger de ce que le moteur a réellement décidé.
Comportement CONSTANT : un seul niveau « comfort » aujourd'hui, et les quatre
suites sont vertes avec charging identique à la référence ligne à ligne.
Le test qui exige un `levels[]` non vide a trouvé un site d'émission oublié
— SG-Ready passe par un autre chemin que les consignes en watts. D'où un
POINT UNIQUE de pose (`poserNiveauConfort`) : recopier la pose à chaque site
la ferait diverger au premier site ajouté, et l'invariant
Σ levels[].targetW == estimatedPowerW tomberait en silence, une charge sans
niveau publiant simplement une liste vide.
ECO_FLOOR_GRID rend lisible la TRAJECTOIRE, pas l'instant. La cadence de
l'étalement ne monte pas, elle fait un mur : 333 W à six heures de
l'échéance, le plafond dès seize minutes. Un client qui lit « 333 W achetés »
ne peut pas deviner qu'il paiera dix fois plus dans cinq heures si le soleil
ne vient pas. Le motif porte donc gridW, remainingWh, targetWh, remainingS,
le régime, et atMaximum — les deux derniers disant respectivement sur quoi on
achète et si l'étalement a cessé.
ECO_FLOOR_MET est INTERDIT sous `unmeasurable` (R6) : dire « tenue » suppose
de savoir ce qui a été livré. Une échéance qui cesse d'être mesurable et
continue de s'afficher comme tenue est pire qu'une échéance absente — elle
rassure.
LM-1014-b généralise les deux défauts de cumul de ces deux jours : toute
grandeur cumulée rapportée à une période doit dire ce qu'elle fait au
changement de période. La question à poser une fois, à l'écriture : que
vaut-elle à la seconde qui suit la bascule ? Ne pas choisir revient à
choisir « elle continue », presque toujours faux et toujours silencieux.
Suite : loadmodel 22/22, simulation 56/56, charging 48/48 identique,
spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
`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
Tant que décider et émettre étaient le même geste, une seconde passe était
littéralement inexprimable : chaque décision produisait immédiatement une
action, et le budget se décrémentait dans le même mouvement.
`decideSetpoint()` rend désormais une CIBLE ; `emitSetpointAction()` en forme
la commande et le motif.
La contrainte du lot est tenue et vérifiée sur le fichier produit : vouluW
est capturé AVANT l'écrêtage de verrou (ECS-309), l'écrêtage précède le
décrément du budget (ECS-306), et les deux restent dans la phase de
DÉCISION. Un déplacement d'un cran publierait des motifs faux sans qu'aucun
nombre ne bouge.
Le remaniement est fait par ALIAS DE RÉFÉRENCE — `LoadAction &la = t.action`
— précisément pour que les vingt lignes d'affectation ne soient pas
retouchées. Ce qui n'est pas réécrit ne peut pas dériver. Et le typage a
servi de garde-fou : `SetpointTarget` n'étant pas `LoadAction`, un `return`
converti au mauvais endroit n'aurait pas compilé.
Les trois sorties anticipées (PHASE_LIMIT, EV_GRID_START, LOAD_UNAVAILABLE)
portent déjà leur motif : la cause est connue au moment où la décision
s'arrête, il n'y a rien à choisir.
Suite complète : simulation 54/54, charging 48/48 identique à la référence
ligne à ligne, loadmodel 21/21, spotmarket 32/32.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Préalable au reste, et il n'ajoute aucune fonction. La chaîne de sélection
du motif vivait au milieu de l'émission : il n'y avait qu'un motif possible
parce qu'il n'y avait qu'un endroit pour le former. Chaque passe devra
choisir SON motif sur SES grandeurs (R7 — le motif dit de quel niveau il
parle), donc la chaîne sort avant que la seconde passe n'arrive.
`MotifEntrees` regroupe exactement ce dont le choix dépend : ce qui n'y
figure pas ne peut pas influencer le motif, et c'est vérifiable à l'œil.
Comportement CONSTANT, et vérifié comme tel : la fonction extraite est
IDENTIQUE TOKEN POUR TOKEN à la chaîne d'origine, comparée par
normalisation. L'ordre des branches est ce qui porte les précédences
(LM-1011 : BELOW_MIN_POWER prime sur DRAW_CAP tant qu'il reste de
l'autorisation) — le réordonner changerait le motif publié sans qu'aucun
nombre ne bouge.
Deux erreurs attrapées en chemin, toutes deux par relecture :
- la substitution avait préfixé les CLÉS de charge utile ("budgetW" →
"e.budgetW"), soit un changement de contrat silencieux ;
- un `else if` transformé en `if` : sans effet ici puisque chaque branche
retourne, mais une divergence gratuite dans une fonction dont l'ordre
des branches est la spécification.
Et un piège de HARNAIS, sans rapport avec l'étape mais qui la bloquait :
testL4ShedsAPacAndSaysItForcedTheLock échouait 5 fois sur 5 alors qu'il
passait la veille, à code identique. Ni le code sous test, ni une fragilité
de temporisation — un cycle d'arbitrage tourne à l'horloge MURALE avant le
premier cycle simulé (sonde : action SG_NORMAL estampillée « 22:52:07 »).
L'armement paresseux d'ECS-412 fige m_lastSwitch sur cette heure, et un
scénario daté en dur se déroule « avant » son propre armement : elapsed
négatif, verrou jamais ouvert. Le test passait ou échouait SELON L'HEURE
QU'IL EST, ce qui est pire que les deux, et c'est aussi l'explication du
remainingS: 1288 observé plus tôt sans qu'on sache le lire.
Corrigé en ancrant l'origine du scénario sur l'horloge réelle : la
chronologie relative suffit, l'origine doit vivre dans la même horloge que
ce qui touche le système. Le commentaire dit à quelle condition ce choix
devient faux, conformément au troisième mode d'érosion.
Reste ouvert et consigné : pourquoi un cycle complet s'exécute à l'horloge
murale dans une suite compilée avec ENERGY_SIMULATION, où les minuteurs sont
explicitement exclus. Pas un défaut de production — une seule horloge y
existe — mais une faille de reproductibilité pour tout test manipulant un
Thing.
Suite complète : simulation 54/54, charging 48/48 identique à la référence
ligne à ligne, loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
La protection de surcharge ne connaissait que les bornes VE : une PAC ou un
routeur pouvaient tenir un dépassement sans que rien ne les redescende. Elle
déleste désormais les charges du waterfall, de la dernière servie à la
première — le même ordre total que la cascade, lu à l'envers, pour que
délester ne puisse pas contredire l'ordre dans lequel on a servi.
Délester n'est pas toujours retirer des watts. Une PAC ne se module pas : on
la fait redescendre d'ÉTAT, vers 2 et jamais 1. C'est le cas que le bornage
de l'étape 3 ne pouvait pas traiter — il empêche d'allouer, il ne retire
rien à personne.
Le franchissement de verrou se CONSTATE : on tente d'abord sans forcer, et
`minOnBypassed` n'est marqué que si le verrou a réellement refusé. Le
déduire d'un verrou simplement ACTIF serait faux — un verrou peut être armé
et permettre malgré tout le geste demandé. Le marqueur annoncerait alors un
court-cycle qui n'a pas eu lieu, et le premier compresseur réellement
maltraité passerait inaperçu au milieu des fausses alertes. Ce constat
n'était pas possible avant ECS-415.
On ne force que pour un plafond SUBI. Comportement observé, non postulé : la
contre-épreuve « forçage interdit » — qui est le comportement d'un plafond
EcoSelfImposed — laisse la PAC en état 4.
Un seul commandeur par organe : les charges délestées sont revendiquées pour
le cycle et le dispatch les saute, sinon la consigne du plan rallumerait ce
que la sécurité vient de couper.
La fenêtre d'exécution est étroite — après syncAdapters(), avant le dispatch
— et les deux placements écartés sont consignés au design §6bis parce qu'ils
se représenteront.
Ce lot a été repris après un arrêt : deux versions antérieures du test
passaient sans rien prouver, leurs prémisses étant supposées. Les trois
cycles sont désormais MESURÉS puis assertés, l'armement à froid compris.
Suite complète : simulation 54/54, charging 48/48 identique à la référence
ligne à ligne, loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
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
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
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
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
Première moitié de 3g-1. La règle batterie vivait dans planSurplusCharging(), donc dans
le chemin VE — par accident d'histoire, pas par conception. Ce n'a jamais été une règle
« véhicule » : c'est une règle de BUDGET. Tant qu'elle y restait, une batterie sous sa
réserve empêchait de charger la voiture mais laissait le chauffe-eau se servir : deux
poids, deux mesures pour la même énergie.
buildContext() remplit désormais ctx.battery (champ qui existait, resté vide depuis
« déféré 3f »), et le waterfall applique les quatre comportements de l'amont :
sous la réserve le budget est ANNULÉ (et non réduit — servir « un peu » retarderait la
recharge sans démarrer personne, planchers du §12) ; batterie en charge sous le seuil,
tampon de 500 W ; en charge au-dessus, sa puissance rejoint le budget ; en décharge, sa
puissance est retranchée quel que soit le niveau, car ce qu'elle fournit n'est pas du
surplus.
⚠️ CHANGEMENT DE COMPORTEMENT assumé pour l'ECS et la PAC, qui n'ont jamais connu cette
règle. Une installation qui servait ses charges sous le seuil de réserve ne le fera plus.
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
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
Le critère était « consigne > budget ». Il est vrai dès que le budget passe sous zéro à
palier nul — 0 > -389 — et le motif annonçait alors « Verrou minOn — chauffe-eau maintenue
à 0 W (puissance engagée, budget -389 W) » : un verrou qui ne mordait pas, sur une
puissance engagée nulle, quand le soutirage était la seule cause.
Constaté au banc le 2026-08-13 sur +etm10, sept fois en quatre heures, toujours au cycle
suivant un palier 0. Le défaut est antérieur à ECS-309 : celui-ci a ajouté la branche
minOff après la branche minOn sans voir que la première capturait déjà un cas étranger.
Corriger une branche ne dit rien de ses voisines.
Le critère compare désormais la consigne appliquée à la consigne VOULUE avant écrêtage —
la mémoire qu'ECS-309 avait justement introduite. Relevée par le verrou → minOn ; rabaissée
→ minOff ; inchangée → le budget. Les deux branches se lisent sur le même axe, et le motif
nomme un mécanisme parce qu'il a agi, non parce qu'il aurait pu.
Ce n'était pas qu'un défaut de lisibilité. La branche minOn étant évaluée en premier, elle
masquait le motif d'armement à froid d'ECS-412 chaque fois que le budget était négatif au
redémarrage — c'est-à-dire au fond du creux, précisément l'instant où il faut redémarrer
pour observer minOff. La campagne du volet 2 aurait mesuré le mauvais verrou.
Le test reproduit le cas du banc : palier 0, budget négatif, aucun verrou actif. Sans le
correctif il produit le texte exact relevé sur la machine. Suite complète : 112 tests
(34 simulation + 46 charging + 32 spotmarket), 0 échec.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Quand un verrou produit le palier appliqué, le decisionReason doit nommer ce verrou. Le
chemin minOff ne le faisait pas : il tombait dans la branche par défaut et publiait
« Surplus insuffisant (7706 W) » alors que 7706 W étaient précisément disponibles.
Constaté au banc le 2026-08-09, pendant la fenêtre d'armement à froid d'ECS-412. La
décision était juste ; le motif disait l'inverse de la cause.
La règle 7 d'AGENTS.md exige un motif non vide ; ECS-309 l'étend de la présence à la
fidélité. Un motif faux est pire qu'un motif absent : il envoie diagnostiquer le mauvais
problème, et il le fait avec l'autorité d'une explication.
Le scheduler mémorise ce que la stratégie voulait AVANT écrêtage par les bornes de
verrou — sans cette mémoire, « le budget ne payait pas » et « le verrou a refusé »
aboutissent à la même consigne et deviennent indiscernables. Le motif minOff cite le
budget DISPONIBLE, précisément pour couper court à l'explication budgétaire.
Le test porte sur le MÉCANISME annoncé, pas sur la non-vacuité de la chaîne : les trois
causes doivent produire trois textes distincts, et le cas minOff est vérifié échouant
sans le correctif — il reproduit alors le motif exact du banc. Le cas minOn exige un
soutirage pour être atteint : le recrédit anti-clignotement rend au budget la puissance
déjà engagée, si bien qu'en export une charge peut toujours s'offrir son propre palier.
Suite complète : 112 tests, 0 échec.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
writeRelay() jetait le ThingActionInfo* : aucun acquittement, aucun retour
arrière, available codé en dur à true, et m_currentStage mis à jour comme si tout
avait réussi — on annonçait une puissance non appliquée.
MODÈLE ASYNCHRONE. executeAction est asynchrone et update() ne doit jamais
attendre (AGENTS règle 5). « Attendre le résultat » ne veut donc pas dire bloquer
le cycle : l'écriture est émise, l'adaptateur retient combien d'acquittements il
attend (m_pending), le verdict tombe quand le compteur retombe à zéro, et la
conséquence est traitée au cycle suivant. Motif repris tel quel d'EvCharger
(evcharger.cpp:293-299), y compris `this` en contexte de connexion — si
l'adaptateur meurt, les callbacks sont coupés proprement.
INDÉTERMINATION. Pendant une transition, telemetry() annonce
max(m_stagePrev, m_stageTarget) : on ne sait pas ce qui est fermé, on annonce donc
la plus haute des deux puissances possibles. Même direction qu'ECS-411 (relais
injoignable supposé fermé) — ne jamais annoncer moins que ce qui peut être
appliqué. Sous-estimer fait sur-allouer les charges suivantes ; surestimer ne fait
que retarder une montée.
ÉCHELLE BORNÉE à trois barreaux, une tentative chacun, aucune boucle : cible →
retour arrière → arrêt total → défaut. Le retour arrière est asynchrone au même
titre et passe par le même compteur.
DÉFAUT COLLANT, pas clignotant. m_faulted est un verrou posé une seule fois, sans
délai ni expiration : available ne peut pas osciller d'un cycle à l'autre. Seul
NymeaEnergy.ClearLoadFault le lève — acte délibéré et journalisé de l'opérateur.
La reconstruction le lève aussi, mais par construction : un adaptateur neuf n'a
pas d'historique. À la levée, l'état matériel est RELU (ECS-411), pas supposé.
CANAL OUVERT. LoadContext n'avait AUCUN champ available : le publier aurait été
décoratif. Ajouté à LoadContextTelemetry, avec sa sémantique écrite noir sur
blanc — il gouverne l'allocation, PAS la comptabilité. Une charge en défaut ne
reçoit rien mais reste comptée : une puissance qu'on ne sait plus couper est de la
conso fixe, au même titre que la base de la maison. Le figeage est porté par
lockMin == lockMax == puissance crue engagée, jamais un plafond nul sous un
plancher non nul. Le même canal servira ECS-601.
OPTIMIZER_PROTOCOL.md mis à jour dans le MÊME lot, comme l'exige le §11 de la
spec : available, lockMinPowerW et lockMaxPowerW documentés avec leur sémantique.
clearFault() est PURE VIRTUELLE sur ILoadAdapter : les trois autres adaptateurs la
déclarent sans effet plutôt que d'hériter d'un défaut vide. Leur généralisation est
portée par ECS-414.
Test testEcsPartialFailure : cas nominal sans défaut, puis échelle complète via un
relais introuvable — défaut atteint, relais valide bien ramené à l'ouverture par la
tentative d'arrêt total, available faux, plancher == plafond == puissance comptée,
plus aucune commande une heure plus tard, puis levée délibérée.
Build amd64 0 erreur. Simulation : 16/16.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Régression contre une décision documentée : AGENTS.md, section « Verrous
minOn/minOff », énonce que le scheduler clampe et décrémente le budget au palier
réel. Le canal qui le permettait — minStage/maxStage dans LoadContextTelemetry —
a été retiré en rév. 2/3. L'écrêtage a continué d'avoir lieu dans l'adaptateur,
mais son résultat ne revenait plus à l'arbitre : pendant toute la fenêtre minOn,
les charges de priorité suivante recevaient un résidu SURESTIMÉ et
l'installation soutirait au réseau.
Règle visée : la 4 — « bornes par adaptateur écrêtent TOUTE sortie de
stratégie ». Pas la 1 : il n'y a qu'un décideur, le défaut est un décalage de
comptabilité entre décision et exécution.
Le canal est restauré EN WATTS, pas en index de palier : lockMinPowerW /
lockMaxPowerW dans LoadContextTelemetry, remplis par RelayRouter::toLoadContext()
depuis lockWindow(), convertis via la table de paliers. La frontière rév. 3 tient
— aucun identifiant de relais ni index de combinaison ne remonte. Un
lockMaxPowerW négatif signifie « aucun plafond », et non « plafond nul ».
buildSetpointAction() applique ces bornes AVANT de décrémenter le budget, donc le
résidu passé aux charges suivantes tient compte de la puissance engagée — quitte
à devenir négatif, ce qui est la réponse correcte. Le decisionReason distingue ce
cas : « Verrou minOn — X maintenue à N W (puissance engagée, budget M W) ; le
résidu en tient compte ».
Corriger dans le cycle, pas au cycle suivant : remonter le palier appliqué après
coup n'aurait rattrapé l'erreur qu'au tour d'après.
Test : testEcsBudgetUnderLock — deux charges classées, la première verrouillée à
2000 W sous un budget de 500 W ; la seconde reste à 0 parce que le résidu tient
compte des 2000 W engagés.
Build amd64 0 erreur. Simulation : 14/14.
Réf. specs/spec_ecs.md §3 ECS-306 (0.5.1).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
L'arbitre construit et pilote les routeurs ; le repli L2 couvre les DEUX types ENSEMBLE
(atomique : pas d'intermédiaire avec charge active sans repli).
- Map unique m_loadAdapters (QHash<QString, ILoadAdapter*>) : RelayRouter + EtmVariableLoadAdapter
cohabitent par polymorphisme. registerRelayRouter() ajouté.
- rebuildLoadAdapters() : la distinction de TYPE vit ICI — RelayRouter si relays[], sinon
EtmVariableLoadAdapter. Au-dessus tout est ILoadAdapter (Setpoint W).
- Dispatch Setpoint AGNOSTIQUE au type : routage par loadId, le polymorphisme absorbe (pas de
if(RelayRouter)). Le scheduler distingue Setpoint (etmvariableload + relay-router) vs State
(sg-ready) — nature de l'action, pas classe concrète.
- Repli L2 : applyDegradedMode boucle sur TOUS les ILoadAdapter → Setpoint(0) force=true. Pour
le RelayRouter, coupe TOUS les relais (bypass minOn). Ferme le trou T2 pour le routeur.
- testMeterSilentFallback étendu au cas RELAIS : compteur muet → 2 relais OFF force=true,
restent OFF sur N cycles. Certifie la sécurité relais.
Build prod 0/0 ; suite (config/L2/migrés) verte. Étape qui rouvre le déploiement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RuleBasedScheduler : buildSetpointAction (contrat rév. 2 §5) — fixed = arrondi au plus
haut powerLevels ≤ budget ; dynamic = clamp(budget, 0, maxPowerW) ; recrédit currentPowerW
de DÉBUT de cycle (invariant 8) ; résidu cascadé. Waterfall unifié : etmvariableload
(Setpoint W) + sg-ready (State) triés par priorité, un seul budget.
EnergyArbitrator : registerEtmVariableLoadAdapter, inclusion dans buildContext, dispatch
Setpoint par loadId (l'EV reste au proxy amont).
Infra de test : ThingClass mock etmVariableLoad (currentPowerW read / powerSetpoint write)
+ handler executeAction + helper addEtmVariableLoad — valide l'implémentabilité de
l'interface avant le thing réel. Les scénarios simulation migrés arrivent en T4.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bug : exportW clampé à max(0,-p) AVANT recrédit → sur-crédit en import (ECS
restait allumé sur le réseau, ne délestait jamais). Fix : surplus net SIGNÉ
(exportW - importW). Régime export inchangé.
Le délestage strict est borné par minOn/minOff (protection compresseur, pas confort) :
l'adaptateur expose minStage/maxStage (fenêtre de verrou évaluée au temps de cycle),
le scheduler clampe bestStage et décrémente au palier réel → budget correct pour les
charges suivantes (puissance verrouillée = engagée non-coupable).
Seam de temps unifié : now=ctx.timestamp partagé par toLoadContext()/applyAction() ;
lockWindow() est l'unique calcul, lockActive() en dérive (décision==exécution).
Interface ILoadAdapter étendue (now) + contrat "temps=paramètre, jamais l'horloge"
documenté pour les futurs adaptateurs. EvAdapter aligné. Build 0 erreur / 0 warning.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Corrections A (déduction EV unique) et B (anti-clignotement) intégrées.
Tri priorité ascendant (rang 1 = premier servi, OPTIMIZER_PROTOCOL §5/annexe C) —
corrige l'inversion du PLAN 3C et 3 doc-comments (plan.h, loaddescriptor.h,
ecsrelayadapter.h). Build 0 erreur / 0 warning.
telemetry() ECS : currentPowerW MESURÉE si au moins un relais expose "currentPower"
(thermostat coupé → 0, pas de fantôme), DÉCLARÉE en repli seulement sans comptage.
Dette evadapter.cpp priority=100 (ancienne convention) inscrite en 3g.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>