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
`1.15.2+etm23` construit arm64, posé sur `.75` par deploy.sh, exercé.
FRANCHI, journal et RPC à l'appui :
- Une charge, un commandeur. 9 lignes « Executing action Simulated wallbox »
sur la fenêtre, 9 AVANT « Adjusting chargers » (dispatch de l'arbitre), 0
après (proxy). 6 cycles sur 6 portent « is commanded by the ETM waterfall
this cycle. Not re-deciding here. » Le doublet du relevé 3g-1 §3.3 a
disparu, et quelqu'un commande — l'arbitre a bien allumé la borne à 10 A
sur sa propre décision.
- Le plancher publié est l'exigence réelle : 2 070 W sous +etm22, 4 140 W
sous +etm23, MÊME borne, MÊME journée. Et il suit le nombre de phases visé
d'un cycle à l'autre (4 140 en triphasé, 1 380 en monophasé).
- L'entrée evcharger est créée d'office, journalisée une fois par borne,
sans charge utile ; l'aller-retour GetLoadConfig → SetLoadConfig verbatim
est neutre et ne reconstruit aucun adaptateur ; loads[] ⊆ GetLoadConfig
est rétabli.
TROUVÉ, et c'est le point du passage machine :
- GRAVE — une borne SANS VÉHICULE BRANCHÉ absorbe le budget de la cascade.
4 853 W alloués à une wallbox `pluggedIn: false` qui tire 0 W, la PAC
derrière ne survivant que sur son propre recrédit. Trois cycles
consécutifs, 2 350 / 4 853 / 5 464 W donnés à personne. 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. evParticipatesInWaterfall() reprend le filtre 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. Défaut de 3g-1,
correction PROPOSÉE ET NON APPLIQUÉE, à traiter dans son propre commit.
- Mineur — le rang par défaut des bornes auto-provisionnées retombe à
ÉGALITÉ avec le rang 1 déjà occupé, au lieu de « en tête ». Le tri reste
total et reproductible, mais ce n'est pas ce qui était demandé.
NON OBSERVÉ, et pas fabriqué : EV_GRID_START (le budget est passé de 385 W à
2 350 W sans s'arrêter dans la fenêtre de tolérance), BATTERY_RESERVE avec un
surplus réel, LM-1204-b matériel (la Terra AC ne répond pas à l'ARP depuis le
banc lui-même), et le cas de transition — aucune borne n'était sous commande au
moment de la bascule.
Banc rendu à son état d'origine : ChargingInfos identiques champ par champ,
voiture de test retirée, wallbox éteinte. Les entrées evcharger restent — elles
seraient recréées au prochain démarrage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
Entrée de changelog du lot 3g-2, des quatre correctifs de 3g-1 qui
l'accompagnent et des deux [upstream-fix] du même passage.
Rien n'est construit ni déployé : le banc .75 tourne encore +etm22. La
construction arm64 et le déploiement restent à faire, et docs/RECAP_3g2.md §7
donne l'ordre de vérification au banc.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
Note de conception. Aucun code, aucun champ, aucune entrée au modèle de
charges : elle existe pour que la porte reste ouverte.
Le §14a EnWG permettra au gestionnaire de réseau d'imposer un plafond de
puissance au point de livraison, que l'EMS répartit ensuite librement sur les
appareils. C'est le même mécanisme que le délestage exigé par LM-1006, avec
une source de commande différente. Il y a donc trois sources pour un seul
mécanisme : le disjoncteur de branchement (physique, permanent), le §14a
(externe, sur ordre, temporaire), et le plancher éco du §10 (implicite, celui
qu'on s'impose en décidant de soutirer).
Ce que la note demande, et c'est tout : quand le délestage général sera écrit,
ne pas l'écrire comme « la protection de surcharge » mais comme « appliquer un
plafond de soutirage ». La source est un paramètre, pas une hypothèse enfouie
dans le nom de la fonction. Cela tient dans une signature aujourd'hui ; ce
serait une refonte dans deux ans.
Deux propriétés sont vraies des trois sources et ne coûtent rien à tenir dès
l'écriture :
- Le plafond le plus contraignant l'emporte, et on doit pouvoir dire LEQUEL
a mordu. C'est l'exigence qui sépare déjà BELOW_MIN_POWER de
SURPLUS_INSUFFICIENT : un client bridé doit lire « gestionnaire de réseau »
et non « protection de surcharge ». Deux causes, deux gestes — l'une se
subit, l'autre s'attaque en délestant ou en revoyant l'abonnement.
- Le plafond survit à la panne du HEMS. Vrai du disjoncteur par
construction ; ce sera une obligation légale pour le §14a, où la coupure
totale est interdite mais le plafond doit tenir. C'est une contrainte sur
degradedMode, et ce n'est pas celle qu'on aurait devinée : le repli L2
d'aujourd'hui est conçu pour NE RIEN INITIER, pas pour continuer à faire
respecter une borne qu'un tiers a imposée. Autant le concevoir là-dessus
maintenant que le découvrir en certification.
Referme au passage une référence pendante : LM-1006-1 était cité par §13 sans
avoir jamais été défini.
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
Quatrième défaut latent de 3g-1. Il vaut son propre message parce que c'est
une décision, et qu'elle s'est prise contre le test.
3g-1 convertit l'enveloppe du waterfall en ampères puis la passe à
`static_cast<uint>(qBound(...))` — donc une TRONCATURE. Cela paraît prudent
(« ne jamais dépasser l'enveloppe ») et coûte systématiquement un ampère :
230 W par phase, abandonnés à chaque cycle, toute la journée. Les scénarios de
simulation le chiffrent — huit d'entre eux sortaient un ampère plus bas, et
la voiture finissait la journée 1 % moins chargée.
L'arbitrage : l'enveloppe est une ALLOCATION, pas une limite. Elle sort d'une
mesure de compteur dont le bruit dépasse largement 115 W. Ce qui est une
limite — le courant maximal de la borne et la place laissée par la protection
de surcharge — n'est pas franchi pour autant : le plafond est déclaré à
l'enveloppe (commit précédent), donc respecté AVANT l'allocation, et le qBound
écrête au matériel.
C'est pourquoi le CODE a été réaligné sur l'amont plutôt que le test :
testOverloadProtectionEcoMode encode le comportement de l'arrondi, et la
troncature faisait basculer la protection de surcharge de « réduire au
minimum » à « couper ». Un ampère de conversion ne doit pas décider ça.
Et c'est aussi pourquoi la limite de phase n'est PAS réappliquée en ampères
entiers dans l'adaptateur : ce second écrêtage serait plus strict que celui de
l'amont (plancher au lieu d'arrondi) et coûterait de nouveau un ampère, pour
69 W de marge théorique que la couche L4 mesure réellement en temps réel.
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
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
Deuxième défaut latent de 3g-1, corrigé à part du lot 3g-2 pour la même
raison que le précédent.
EvAdapter passait `currentPower / 230` comme courant de référence à la règle
de bascule 1↔3 phases, au motif — écrit dans le commentaire — que
l'allocation n'est pas encore connue à cet instant. Mais la bascule ne se
décide pas sur ce que la borne TIRE : elle se décide sur ce qu'elle POURRAIT
tirer. C'est la formule du proxy, `currentPower/230 + allowance`, et c'est la
seule qui puisse répondre « redescends en monophasé, il n'y a plus que
800 W ».
Avec le seul courant courant, une borne passée en triphasé y restait quel que
soit l'effondrement du surplus — et son plancher publié restait à 4,1 kW,
donc plus rien ne pouvait la servir. Le surplus, lui, EST connu avant
l'allocation : c'est la mesure du compteur.
Le terme de stockage de planSurplusCharging() (tampon de 500 W, réinjection
au-dessus du seuil) n'entre volontairement pas dans cette référence : il la
déplace à la marge et n'a d'effet que sur le choix 1↔3, jamais sur
l'allocation — laquelle est faite par le waterfall, avec un budget qui tient
compte du stockage.
Suite simulation `run` 16/16, charging vert.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
Défaut de 3g-1, resté latent parce que sa sortie n'atteignait que la
télémétrie. Il est corrigé à part du lot 3g-2 pour pouvoir être relu,
contesté ou rétroporté sans lui.
EvAdapter interrogeait EvCharger::canSetPhaseCount(), qui dit seulement que
la borne PORTE l'état desiredPhaseCount. La vraie question — la borne ET la
voiture acceptent-elles 1 et 3 phases — est tranchée une fois par cycle par
prepareInformation(), qui en tire canSwitchPhaseCount et le couple
min/maxPossiblePhaseCount. Les deux prédicats ont divergé : là où le proxy
appliquait `qMin(carPhaseCount, getBestPhaseCount(...))`, 3g-1 n'a repris que
l'appel.
Conséquence : une borne dont la combinaison ne permet qu'une phase se voyait
viser 3 phases. Son plancher publié triplait (1,4 kW → 4,1 kW) sans que rien
ne puisse jamais le franchir — la borne restait éternellement « sous son
plancher », avec un motif juste et une cause fausse.
Second défaut, dans applyAction() : le nombre de phases demandé était borné
par m_charger->phaseCount(), qui est l'état COURANT de la borne. Une borne en
monophasé ne pouvait donc jamais être commandée en triphasé, et le plancher
publié pour trois phases n'aurait de toute façon pas pu être servi.
evTargetPhases() cesse d'être un passe-plat et lit les bornes du manager, via
un accesseur [ETM] read-only de plus (internalProcessInfo). L'arbitre ne
redérive rien : une seconde version de cette règle divergerait de la première
au premier ajustement, ce qui est exactement ce qui vient d'arriver.
Suite charging 16/16, simulation `run` 16/16, testLoadTelemetryRpc 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
La description publiée par l'introspection JSON-RPC annonçait « Only given
properties will be set, others will be untouched. » C'est faux : unpack()
construit un ChargingInfo NEUF depuis la charge utile, et setChargingInfo()
remplace le stocké en entier. Un champ writable omis retombe donc sur son
DÉFAUT — targetPercentage à 80, repeatDays vide, endDateTime absent,
assignedCarId nul — et l'appel réussit, EnergyErrorNoError. Rien ne signale
la perte.
Constaté sur le banc .75 le 2026-08-26 en restaurant la configuration : un
appel ne portant que chargingMode a porté targetPercentage de 0 à 80.
C'est la DESCRIPTION qui est corrigée, pas le comportement, et le choix
mérite d'être dit : ce .so est un drop-in de l'amont. Une sémantique de
fusion casserait le seul geste qui permet d'EFFACER un champ — retirer une
échéance, vider repeatDays — et changerait un contrat que les clients
existants suivent déjà. La description énumère désormais chaque défaut et
prescrit le geste correct : lire avec GetChargingInfos, modifier, renvoyer
l'objet complet.
Le test épingle le comportement annoncé, en trois temps : un objet complet,
un appel partiel qui aboutit et perd les champs omis, puis l'aller-retour
qui les préserve — y compris quand la charge utile relue porte
chargingState, propriété en lecture seule que la méthode doit ignorer sans
faillir. Il porte sur le COMPORTEMENT décrit, jamais sur la non-vacuité
d'une réponse.
Vérifié au passage : aucun autre Set* du namespace ne porte cette promesse
ni ce comportement. SetPhasePowerLimit, SetAcquisitionTolerance,
SetBatteryLevelConsideration et SetLockOnUnplug sont scalaires ;
SetSpotMarketConfiguration ne remet jamais un champ omis à son défaut ;
SetLoadConfig annonce déjà un remplacement d'ensemble.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
prepareInformation() repliait sur ChargingModeEco toute borne dont
nextEndTime() est invalide, dès lors que son mode n'était pas
ChargingModeNormal. Or ce repli n'a de sens que pour les modes qui EXIGENT
une échéance — c'est bien ce que font les deux autres sites du même repli
(planSpotMarketCharging, planSurplusCharging), qui testent explicitement
EcoWithTargetTime / EcoMinWithTargetTime. C'est ce site-ci qui a perdu la
garde en 7588473, quand les deux modes ETM ont été ajoutés.
Deux conséquences, toutes deux silencieuses :
- ChargingModeEcoWithMinCurrent était réécrit en ChargingModeEco au cycle
suivant, PERSISTÉ et notifié par chargingInfoChanged. Le mode « éco +
courant minimum garanti » ne survivait donc jamais à un cycle, et la
branche d'adjustEvChargers() qui l'applique était morte. Du point de vue
de l'app, un réglage posé par l'utilisateur disparaissait tout seul.
- ChargingModeEco était « replié » sur lui-même à CHAQUE cycle :
setChargingMode, storeChargingInfo (écriture disque) et une notification
chargingInfoChanged, sans qu'aucune valeur ne change. Un *Changed qui ne
change rien, à chaque tour, pour chaque borne en éco.
Trouvé en écrivant le test d'aller-retour de SetChargingInfo, qui échouait
sur la réécriture du mode.
Suite charging 16/16, simulation `run` 16/16.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
Le lot E §12 a ajouté le paramètre minPowerW au constructeur
d'EtmVariableLoadAdapter sans le répercuter sur les cinq sites d'appel de
tests/auto/simulation. La suite ne compile donc plus depuis ce commit —
autrement dit, personne ne l'a exécutée depuis. Les cinq sites passent
désormais minPowerW = 0 explicitement.
Deux attentes avaient rouillé pendant ce temps, et les deux portent sur du
TEXTE publié — ce qui est précisément ce que ces tests doivent surveiller :
- testEcsColdStartLockExpires attendait « insuffisant » là où f134048 a
délibérément fait sortir BELOW_MIN_POWER. Le cas est « il y a un budget,
il n'est pas au bon FORMAT » : le motif doit nommer le palier. Ce que le
test éprouve reste le même — ce n'est pas le verrou.
- testDecisionReasonRendering attendait « Surplus insuffisant » pour
SG_NORMAL, reformulé en « Aucun surplus à allouer » par le même commit,
parce que ce motif ne sort plus que pour un budget nul ou négatif.
Le catalogue de motifs avait dérivé au passage : BATTERY_RESERVE et
BELOW_MIN_POWER étaient entrés en 3g-1 sans ligne de rendu attendue ni entrée
dans la liste de clôture. Leur texte — celui sur lequel le banc s'appuie pour
distinguer « il n'y a rien » de « il y en a, mais pas au bon format » ou
« une règle le retient » — n'était donc éprouvé par rien. Refermé, avec les
deux formes de BELOW_MIN_POWER (avec et sans état).
Suite verte : simulation 24/24 fonctions, dont les 16 scénarios de `run`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
La transplantation ne commande rien tant que adjustEvChargers() n'est pas traité : l'arbitre
refuse, publie un motif juste, et le proxy commande ON à 6 A une ligne plus loin. La même
ligne part vers la Terra AC réelle et n'a rien fait UNIQUEMENT parce qu'elle est hors ligne.
Le plancher EV vaut la moitié du seuil de démarrage réel — acquisitionTolerance, qui est un
seuil de déclenchement, a été pris pour un plancher. 2828 W alloués, 4,1 A demandés, 6 A
exigés : allocation perdue.
La bascule 0,4 / 0,9 est faite dans les deux sens et la correction withheldW: 0 est vérifiée
sur machine. La moitié qui manque — la réserve retenant un surplus RÉEL — demande du soleil.
LM-1204-b n'est pas franchi : il exige deux bornes arbitrées, la Terra AC ne répond pas.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
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
Session arrêtée sur contexte saturé. Le paquet 1.15.2+etm21 est CONSTRUIT mais le banc
tourne encore etm20 : la transplantation n'a jamais tourné une seule fois, sur un banc
qui porte une Terra AC réelle. C'est la première chose à faire, et LM-1204-b avec.
Le RECAP porte la commande exacte de reprise, les cinq points à vérifier au
redéploiement, et ce qui reste ouvert — dont la bascule 0,4 / 0,9 devenue possible
depuis que SunSpec déclare energystorage, et jamais faite.
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
Un filterByInterface qui rend une liste vide est toujours ambigu entre « il n'y a pas de
batterie » et « il y en a une, mal déclarée ». Le second désactive une règle de sécurité
sans produire ni erreur ni panne — même mode de défaillance qu'un seuil de réserve mal
réglé : rien ne se signale.
Constaté sur le banc : le Thing « SunSpec Storage » déclare battery mais pas
energystorage. La réserve ne s'est donc JAMAIS appliquée — ni dans le waterfall que je
viens d'écrire, ni dans le proxy amont qui utilisait le même filtre depuis toujours, ni
dans le bilan de puissance de l'experience-plugin qui filtre pareil. GetPowerBalance rend
currentPowerStorage: 0 pendant que la batterie affiche 50 % et « Holding » : elle n'est
pas à zéro, elle est invisible.
L'arbitre émet maintenant un avertissement quand des appareils portent 'battery' sans
'energystorage'. Il ne corrige rien — la déclaration appartient au plugin — mais il rend
la cause lisible au lieu de la laisser deviner.
Brief émis vers etm-powersync-plugins-modbus : ce n'est pas une ligne de JSON, l'interface
exige currentPower et capacity que la classe ne fournit pas, et un currentPower de
batterie à zéro serait pire que son absence.
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
Livrable de phase avant tout code (workflow AGENTS.md). Écrit après lecture des
1 900 lignes de smartchargingmanager.cpp.
Le constat central est plus dur que le scénario ne le laissait croire.
planSurplusCharging calcule allowanceInAmpere UNE fois, avant la boucle, et ne le
décrémente jamais : chaque borne reçoit la TOTALITÉ du surplus, comme si elle était
seule. Avec 4 kW et deux bornes, le proxy ne produit pas « 2 kW à chacune » — il
produit « 4 kW à chacune ». Ce qui rattrape n'est pas l'arbitrage mais le compteur au
cycle suivant, puis la protection de surcharge. Le système converge par correction
d'erreur, pas par décision. Le critère LM-1204-b doit donc se lire : avant 3g les deux
bornes démarrent puis sont rattrapées ; après, une seule démarre, délibérément.
Trois choses ne se transportent pas, et deux demandent une décision non technique :
1. L'ÉCHÉANCE — sa machinerie (projectedEndTime, remainingPercentageToCharge) vient du
SoC de la voiture et n'a aucun équivalent ailleurs ; et le scheduler ne lit pas
encore needs (« grid funding dormant jusqu'à 3f »). Forcer pour tenir une échéance,
c'est soutirer, donc franchir la frontière du §10. Recommandation : la laisser au
proxy, qui maigrit sans disparaître.
2. LA BATTERIE — batteryLevelConsideration est calculé dans le chemin VE alors que
c'est une règle de BUDGET. La monter change le comportement de l'ECS et de la PAC,
qui ne l'ont jamais eue. Recommandation : la monter quand même, et l'assumer.
3. LE NOMBRE DE PHASES — se décide en ampères, le waterfall parle en watts : la
conversion descend dans l'adaptateur. L'ordre s'inverse, et le piège est silencieux :
une borne qui passe en triphasé change son plancher effectif (1,4 → 4,1 kW), donc le
plancher publié doit correspondre au nombre de phases VISÉ, pas au courant.
Nuance sur le plancher : minimalChargingCurrent vaut max(borne, voiture), il dépend
donc de la voiture branchée. C'est une valeur runtime, pas un réglage — l'écran ne doit
pas la présenter comme tel.
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
Défini avec Patrick quand le banc a reçu une seconde borne. Il n'est PAS exerçable
avant 3g, et c'est le point : nonEvLoads filtre etmvariableload | relay-router |
sg-ready, l'EV est décidé en proxy AVANT le waterfall, donc les deux bornes ne
partagent aucun budget et leur rang n'a aucun sens.
Le piège que Patrick signale est réel et il a une réponse observable : une wallbox
simulée peut obéir à une consigne sous son plancher là où du matériel refuserait. Le
discriminant est le MOTIF publié, que seul le scheduler peut émettre —
BELOW_MIN_POWER prouve que l'arbitre a refusé et qu'aucune commande n'est partie,
SURPLUS_SETPOINT avec setpointW < minPowerW prouve l'inverse, et une borne à 0 W sans
motif de plancher signifie que c'est le matériel qui a refusé, donc que le test ne
prouve rien.
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
Trois manques du modèle, spécifiés AVANT implémentation. Rien à coder tant que
Patrick n'a pas relu.
§10 — Besoin à deux niveaux (LM-1000+). L'éco n'est pas un champ nouveau : c'est
`needs {dailyDeadline, minEnergyWhPerDay}` promu au rang de plancher. Deux passes sur
la MÊME liste ordonnée, soit un ordre lexicographique sur (niveau, rang) — dérivé du
rang existant, donc pas un second système de priorité ; la paire
prioritéEco/prioritéConfort est rejetée explicitement. Deux règles que la conception
appelle et que le brief ne demandait pas : une seule commande par charge et par cycle
(sinon deux commutations dans le même cycle, du court-cycling fabriqué par l'arbitre),
et un recrédit anti-clignotement attribué une seule fois.
La frontière est nommée : la passe 1 n'est pas bornée par le surplus mais par une
AUTORISATION DE SOUTIRAGE, objet distinct que le rule-based rendra binaire et le MILP
continu. Et deux couplages sans lesquels la conception est fausse : la limite de
raccordement — les planchers éco s'additionnent sur le disjoncteur, et la protection
de surcharge ne déleste aujourd'hui QUE les bornes — et l'ordre de service apparent,
qu'un plancher de rang 4 passant devant un confort de rang 1 rend contre-intuitif.
§11 — Compteur et sonde (LM-1100+). Sélection par interface nymea, jamais par plugin.
Le compteur vérifie, il ne décide pas : ne pas l'injecter au budget, il est en retard
sur la commande et la charge est déjà comptée au compteur racine — décomposition, pas
source. Un écart de mesure ne pose PAS available:false : ce serait retirer le service
sur un doute de mesure. Et ni le compteur ni la sonde ne sont exclusifs, contrairement
aux relais : LM-201 ne s'applique pas à ce qui mesure, donc validateSet() ne doit pas
les inclure dans son contrôle d'unicité.
§12 — Le plancher (LM-1200+). Le domaine de la consigne devient {0} ∪ [min, max], un
ensemble discontinu. Déclaré pour etmvariableload seul ; dérivé pour les deux autres —
même règle qu'au lot D pour stagesW. La règle d'allocation qui compte : sous le
plancher, allouer 0 et TRANSMETTRE le budget, sinon une charge de rang 1 bloque tout
le waterfall en gardant un budget qu'elle ne peut pas utiliser. Décision arrêtée : la
priorité porte sur la borne, pas sur la voiture ; pas de priority dans ChargingInfo,
qui ne saurait pas exprimer « VE1 > ECS > VE2 ».
§13 — Ordre recommandé, et ce n'est PAS celui de l'énoncé : §11 d'abord (additif,
risque nul, et la sonde constitue l'historique sans lequel les seuils du §10 ne se
règleront jamais — le différer coûte du temps CALENDAIRE, pas du développement), puis
§12 (contenu, débloque 3g puis l'unification du rang), puis §10 en dernier — et son
délestage général dans le même lot, pas après.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
1.15.2+etm16 déployé sur .75 le 2026-08-26 09:11. Chaque critère porte sa nature :
machine, test, ou lecture.
§2 — le journal bascule de "chauffe-eau" à "Ballon 200 L" au cycle suivant l'écriture,
et le décompte de l'arbitre passe de « inchangée » à « 1 mise à jour EN PLACE ».
§3 — relais inversés (2000/1000/500), mêmes Things, mêmes puissances :
« 0 créée(s), 0 mise(s) à jour, 2 INCHANGÉE(S) ». Ni reconstruction ni mise à jour,
aucun état sûr, aucune commutation. Avant etm16 la même écriture donnait
« 1 créée(s), 1 retirée(s) » et ouvrait les contacts.
§4 — stagesW = [0,500,1000,1500,2000,2500,3000,3500] publié en télémétrie, maxStageW
étant son dernier élément. Publié LÀ et pas dans GetLoadConfig parce que la condition
« lecture seule ET rejetée à l'écriture » s'y contredit : l'app écrit en renvoyant
verbatim ce qu'elle a lu, donc elle aurait réémis le champ et le refus en bloc aurait
emporté toutes les charges.
§1 — cause datée à la seconde (d08cf50, 05:30:14 UTC) et déjà corrigée en amont ; la
séquelle, elle, était définitive par absorption flottante. Base du banc réparée :
62 726 anomalies → 0, accumulation reprise et mesurée en direct (+0,001953 kWh).
Deux corrections au brief : totalProduction n'était pas sain mais figé depuis 23 jours,
et les deux Things Fronius ont déraillé ensemble.
À remonter en amont : EnergyManagerImpl accumule sans garde-fou de plausibilité dans un
compteur persistant. N'importe quel plugin mal codé peut geler l'historique d'une box
client. Contribution nymea, pas correctif ETM.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
Les trois premiers constats de BRIEF_agent_plugin.md, dans l'ordre de ce qu'ils coûtent.
§2 — Renommer une charge n'atteignait jamais l'adaptateur. updateSoftConfig() ne
portait que priority et needs, donc un changement de libellé seul était compté
« inchangée » : l'adaptateur gardait son ancien nom et le journal contredisait
GetLoadConfig jusqu'à la prochaine reconstruction. `label` entre dans la signature et
dans le prédicat soft de l'arbitre, sans entrer dans sameHardware() — mise à jour EN
PLACE, pas reconstruction. La vraie prise était le Q_UNUSED de SgReadyAdapter, hérité
d'avant le lot B-bis : sans lui, renommer la PAC — le cas même qu'on allait tester —
n'aurait rien changé, et on aurait conclu que le correctif ne marchait pas.
§3 — relays[] n'est plus comparé par index. Un réordonnancement reconstruisait la
charge : contacts ouverts, verrous réarmés à froid. Comparer comme un ENSEMBLE aurait
été faux dans l'autre sens — à somme égale la table retient la première combinaison
rencontrée, donc échanger deux relais de même puissance change le contact qui sert ce
palier. sameHardware() compare la TABLE DÉRIVÉE : deux listes qui produisent les mêmes
paliers avec les mêmes contacts sont matériellement identiques, par construction.
§4 — mechanism.stagesW publie les paliers atteignables, en télémétrie et pas dans
GetLoadConfig. La télémétrie n'a aucun chemin d'écriture, donc « lecture seule » y est
vrai par construction ; GetLoadConfig aurait cassé l'aller-retour neutre dont l'app
dépend pour écrire — elle renvoie verbatim ce qu'elle a lu, le champ serait revenu
dans SetLoadConfig, et un refus en bloc aurait emporté toutes les charges.
deriveStages() est l'implémentation unique de la combinatoire, partagée par les trois
usages. C'était le fond de la demande du §4 : une seule règle, pas de divergence.
tests/auto/loadmodel : cible neuve sans serveur nymea, 2 ms. Elle a attrapé un défaut
de la correction elle-même avant le banc — operator== comparait les listes de ThingIds
et voyait une différence entre fermer {K1,K2} et fermer {K2,K1}.
tools/repair_energylogs.py : §1, et ce n'est PAS un correctif de ce plugin. La cause
est extérieure et déjà corrigée (SunSpec float32 aux mots permutés, d08cf50 daté
05:30:14 UTC — la seconde du premier échantillon empoisonné). La séquelle, elle, était
définitive : à 7,5e33 kWh le plus petit incrément représentable vaut 1,67e18, donc le
compteur n'était pas faux mais GELÉ, et rechargé tel quel à chaque démarrage.
Appliqué sur .75 : 62 726 anomalies → 0, accumulation reprise et vérifiée en direct.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
Émis après le lot C, tout constaté sur .75 (plugin 1.15.2+etm15), rien corrigé côté moteur.
L'app s'est arrangée pour ne pas mentir en attendant.
1. Les compteurs cumulés d'Energy.GetPowerBalance sont CORROMPUS : totalReturn 9,1e+33,
totalAcquisition 7,5e+33, totalConsumption -1,6e+33 — alors que totalProduction est sain
et que les états du Thing compteur sont tous plausibles (1429 / 87,9 kWh). Ce n'est donc
pas le compteur qui ment, c'est l'accumulation au-dessus.
2. Renommer une charge ne parvient jamais à l'adaptateur : updateSoftConfig() ne porte que
priority et needs, donc le journal de la box garde l'ancien libellé jusqu'à la prochaine
reconstruction — trompeur en mise en service.
3. relays[] est comparé PAR INDEX dans sameHardware() : un simple réordonnancement
reconstruit la charge (contacts ouverts, verrous réarmés). Question de conception ouverte —
le routeur dérive toutes les combinaisons, l'ordre ne change rien au routage.
4. Les paliers dérivés ne sont publiés nulle part (powerLevels vide, télémétrie limitée à
maxStageW/stageW). L'app les dérive pour l'affichage : deux implémentations de la même
combinatoire, et c'est l'app qui aura tort si elles divergent.
5. minCurrent n'existe dans aucun champ de ChargingInfo alors que ChargingModeEcoWithMinCurrent
est publié : le mode se choisit, son paramètre non.
6. Pas de compteur dédié par charge. L'app affiche le bloc grisé, dérivé du schéma — il
s'activera seul le jour où le champ existera.
7. L'avertissement LM-200 attend typeAppareil : keyé sur domain il crierait sur des
installations correctes.
Plus la liste de ce sur quoi l'app s'appuie désormais et qu'il ne faut pas casser.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
LM-201 est la règle qui manquait sous LM-200. Une charge existe quand il y a un canal de
commande DISTINCT pour un service. Elle repose la question du domaine, que 0.1.3 posait mal :
une PAC air/eau fait chauffage, rafraîchissement et ECS — le domaine ne décrit donc pas la
MACHINE, il décrit CE QU'ON ARBITRE.
Conséquences : PAC + ECS sans ballon séparé = UNE charge (heating), pas de charge ecs — le
SG-Ready commande la machine entière et la répartition appartient à sa régulation ; ballon
séparé = charge distincte, il a son propre canal ; PAC qui fait du froid = toujours une seule
charge, ce qui change est la saison, pas la configuration.
La règle explique l'exclusivité de validateSet() (LM-302-b / ECS-110-b) : deux charges sur un
canal unique se contrediraient, et le budget compterait la même puissance deux fois. Le refus
existait avant la règle ; la règle dit enfin pourquoi il est juste.
LM-202 : thermostats et zones ne sont pas des charges. Un thermostat ouvre une vanne, il ne
porte pas la consommation. Les déclarer produirait des entrées à 0 W dans le waterfall pendant
que la PAC est comptée ailleurs. Ils décrivent où va la chaleur, pas combien on en achète —
leur écran, leur namespace, jamais LoadConfig.
Enforcement LM-200 tranché : avertissement au journal, PAS de refus dans isValid(). Mais
l'avertissement n'a pas encore de prédicat — voir le rapport de session. Keyé sur domain seul
il crierait sur des installations correctes (heating + relay-router = chauffage résistif ;
hvac + relay-router = VMC). Son discriminant est typeAppareil (§8-1), valeur thermodynamique.
Spécifié ici, implémenté avec lui.
§8-5 : heating vs hvac reste OUVERT et ne se saura qu'en dessinant la PAC communicante, où les
consignes ECS et chauffage sont enfin séparées. D'ici là o:domain garde son rôle actuel,
sans force normative — ne construire aucune règle dessus.
INTERFACE_etmvariableload.md (canonique) : §4-bis et §4-ter ajoutés, côté app. C'est l'app qui
guide au moment du choix, et c'est le seul endroit fiable — elle sait quel appareil
l'installateur déclare, le moteur ne le sait pas.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
La PAC ne se pilote qu'en SgReady (contacts SECS de signalisation, 4 états normés) ou en
COMMUNICANT via un plugin nymea. Ni Relay ni Variable : le premier couperait l'alimentation
du compresseur et lui retirerait son autorité sur son propre cycle (dégivrage, retour
d'huile, temporisation) ; le second suppose un cadran en watts qu'un compresseur n'est pas.
LM-101 disait « le domaine ne détermine pas le mécanisme et réciproquement » — vrai du
couplage de CONCEPTION, faux lu comme une liberté de combiner. Précisé.
Piège nommé au passage : les contacts SG-Ready SONT des relais (K1/K2 au banc sont deux
things power), mais ils signalent au lieu de porter la charge. Voir des relais dans une
config ne dit rien du mécanisme qui convient.
Le mécanisme communicant, encore appelé ModbusSetpoint ici, porte un nom trop étroit : ce
qui le définit est qu'un thing nymea porte le modèle de la machine, pas le transport.
§8 point 5 : faire du domaine un discriminant de validation est une décision de contrat,
pas un correctif — trois options pesées, non tranchée.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
Ce document décrit le lien moteur ↔ optimiseur sur socket NDJSON. Il ne décrit pas la
frontière JSON-RPC de l'app, et un brief a pourtant envoyé l'agent app y chercher le
namespace de LoadConfig — qui n'y est pas et n'y sera pas. La confusion a coûté une
session : les deux protocoles portent du JSON-RPC, c'est leur seul point commun.
Note de périmètre en tête, avec le tableau des deux frontières et le renvoi vers
INTERFACE.md / nymeaenergyjsonhandler.cpp / JSONRPC.Introspect.
Relevé côté app, en corrigeant CLAUDE.md contre Introspect (plugin 1.15.2+etm15).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
Canal, pool, ajout seul, et le fait que le paquet posé sur le banc et le paquet publié
sont le même (.so sha256 identique, banc redéployé avec le paquet reconstruit).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
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
Ma rédaction se rassurait à tort : « +etm12 n'est pas perdu, .75 était réparable depuis le dépôt
APT à tout moment ». C'est vrai de cette version-là, et faux comme raisonnement.
.75 est le banc de BÊTA-TEST : on y pose un paquet AVANT de le publier. Sur le banc, un paquet
pas encore publié n'existe qu'à deux endroits — le répertoire de dépôt de la box et le pool
local de build. Ni au dépôt APT, ni chez un client, ni dans aucun git (un .deb ne se versionne
pas). L'écraser est IRRÉVERSIBLE, et c'est cela, plus que le ramassage multiple, que le verrou
d'archivage protège.
Le journal dpkg de la box, désormais cité, donne la chronologie qui le montre : +etm12 posé sur
le banc le 13 août à 19:48, publié au pool à 20:48 — une heure où il n'existait QUE sur le banc.
La plupart des paquets de banc ne quittent jamais cette fenêtre, puisqu'ils sont écartés : c'est
leur raison d'être. Le risque ne dépend pas de la chance qu'a eue une version particulière.
Le même journal donne la cascade du 25 août : quatre « upgrade » en onze secondes,
etm12→etm10→etm4→etm8→etm9. L'état final est celui du dernier configuré, pas du plus récent.
Renvoi ajouté vers les deux chemins de déploiement désormais écrits (BUILD-WORKFLOW.md §8,
DEPLOY.md §8) — le premier ne décrivait que le parc, laissant le chemin du banc s'improviser.
Documentation seule : docs/ n'est pas embarqué dans le .deb (vérifié : le paquet ne contient que
le .so, le changelog, le copyright et les .qm). 1.15.2+etm14 reste donc valide et fidèle à sa
source ; pas de nouvelle version.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MfvSskkYrrFriYcj4683D
L'agent app a besoin de savoir OÙ prendre le paquet : canal powersync-nightly, et non stable ni
testing, qui ne contiennent aucun powersync-energy-plugin-nymea.
Consigne au passage : reprepro a été invoqué avec --keepunreferencedfiles. Sans cette option il
aurait supprimé du pool le binaire d'+etm12, qui venait de perdre sa dernière référence — donc
il aurait CAUSÉ la perte que l'incident de rétrogradation décrivait à tort comme déjà survenue.
C'est la règle « une publication AJOUTE, elle n'enlève JAMAIS » de publish-to-repo.sh, appliquée
un cran plus haut, à l'inclusion.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MfvSskkYrrFriYcj4683D
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
ClearLoadFault est le SEUL levier de reprise à distance : ECS-410 pose un verrou collant que
seuls ce RPC ou une reconstruction lèvent. L'arbitre journalisait la demande de l'opérateur,
jamais son issue — si bien qu'appelé depuis l'étranger, on ne distinguait pas « le défaut a
été levé » de « il n'y en avait pas » de « l'appel n'a pas atteint l'adaptateur ». L'opérateur
relance trois fois et conclut que le système est cassé.
L'issue est écrite dans l'ARBITRE, pas dans chaque adaptateur : une ligne là couvre les quatre
d'un coup et ne peut pas être oubliée par un adaptateur futur ; répétée dans chacun, elle
manquerait le cinquième. Les trois cas sont distincts à la lecture.
ILoadAdapter::clearFault() rend un bool au lieu de void. Le type de retour force chaque
implémentation, présente et future, à répondre à la question « un défaut a-t-il réellement été
levé ». EvAdapter, qui n'a pas de mécanisme de défaut, le dit désormais explicitement plutôt
que par un corps vide.
Règle 7-d — un refus doit être au moins aussi visible que l'application correspondante. Le
refus par verrou minStateHold de SgReadyAdapter était en qCDebug quand l'application, dix
lignes plus bas, était en qCInfo. L'asymétrie est le défaut, pas le niveau : un resserrement
de la journalisation — et on vient d'en faire un pour tenir deux semaines — aurait fait
disparaître le refus AVANT le succès, ne laissant au journal que les décisions abouties. Or un
refus est plus informatif qu'une application réussie : il dit qu'une décision a été prise et
n'a pas été exécutée. Vérification faite sur les autres adaptateurs : c'était le seul cas. Les
qCDebug restants sont symétriques, et le marqueur L2 par cycle reste en debug à dessein, son
entrée étant en qCWarning et sa sortie en qCInfo.
Le retour idempotent du routeur reste DIFFÉRÉ, et ira en qCDebug : c'est le cas le plus
fréquent — plusieurs milliers de lignes par jour sur un budget ramené à 6,5 Mo — et son
ambiguïté est partiellement couverte, le scheduler journalisant sa décision et l'armement à
froid s'exécutant avant ce retour. Faible gain, coût réel : l'inverse des deux cas corrigés.
Le test porte sur le TEXTE des trois issues et vérifie qu'elles sont deux à deux distinctes.
Suite complète : 112 tests, 0 échec.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le smartmeter Modbus ECS est câblé et vérifié depuis le 2026-08-09 : d'où vient la mesure
n'est plus une décision à prendre, et exposer un second Eastron simulé reviendrait à simuler
ce qu'on possède en vrai.
L'ordre est posé : appairer les compteurs réels sur .75 (root-meter maison, producteur,
smartmeter ECS, PAC si prêt), puis retirer la vue SunSpec simulée du rôle de rootmeter — en
établissant d'abord ce que devient la configuration de l'arbitre quand il change, tout en
dépend — et vérifier que le compteur ECS n'est jamais désigné rootmeter.
Au 2026-08-13, things.conf ne déclare que la vue Fronius/SunSpec : aucun de ces compteurs
n'est appairé.
ECS-500 différé : le calibrage améliore la précision, pas l'interprétabilité.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
La correspondance exacte ne journalisait qu'au palier non nul (`if (i > 0)`). Au palier 0 —
le cas de l'armement minOff d'ECS-412, celui qu'on vient précisément observer — le journal
était muet, et « relais lus, tous ouverts » ne se distinguait pas de « reprise non
exécutée ». Constaté au banc le 2026-08-13 : ECS-411 fonctionnait, et son contrôle sur le
chemin réel était impossible depuis les traces.
Le relevé porte désormais l'état de CHAQUE relais — identifiant, puissance déclarée, et
fermé / ouvert / injoignable→supposé fermé — avec le palier retenu, la branche empruntée et
la somme lue. Sortie unique pour les trois branches. Un relais injoignable fait passer la
ligne en avertissement : le palier déduit est plausible, la lecture ne l'est pas, et
« palier repris : 0 » couvrait ces deux situations sans les distinguer.
Un défaut de fond a été trouvé PAR ce relevé, qui affichait « (0W) » pour le relais 1500 W.
La nominale par relais était redéduite des encodages solo, or la déduplication par puissance
peut en évincer un : avec 500/1000/1500, le palier 1500 est encodé {500,1000} (masque 3,
rencontré avant le masque 4) et le relais 1500 n'apparaît seul nulle part. Sa nominale valait
0, donc un démarrage sur ce seul relais donnait sumW = 0, aucune correspondance exacte, et un
palier 0 annoncé pendant que 1500 W circulaient — l'annonce en sous-estimation qu'ECS-411
existe pour interdire. Elle est prise à la déclaration (m_relayNominalW). Rendre la lecture
visible est ce qui a rendu le défaut trouvable : c'est le sens de la règle, pas un effet
secondaire.
Règle 7-c dans AGENTS.md : un silence ne doit jamais être ambigu. Journaliser l'issue, pas
seulement l'issue remarquable ; journaliser ce qui a été lu, pas seulement ce qui en est
déduit. Trois autres endroits signalés dans spec_ecs.md, non corrigés — le retour idempotent
du routeur, clearFault() sur charge saine, et le refus minStateHold en qCDebug quand
l'application est en qCInfo.
RELEVE_ECS306 §3.0, en tête du protocole : un état nymea n'émet changed() que si la valeur
bouge, donc une grandeur forcée constante déclenche le watchdog L2 en 90 s et fait mesurer le
watchdog au lieu de l'arbitrage. Constaté au banc en gelant le simulateur.
Les tests portent sur le TEXTE publié, via un collecteur de messages : un test « journal non
vide » n'aurait rien vu. Trois cas, tous vérifiés échouant sans les correctifs. Suite
complète : 112 tests, 0 échec.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Première génération réelle avec Graphviz (doxygen 1.9.8, la version de référence) : 14
avertissements, dont 8 introduits par moi cette session — \return et \param manquants sur
la charge utile sg-ready, claimedThingIds(), validateSet() et claimedRelays(). Corrigés.
Les 6 restants sont exactement relayrouter.h et energyarbitrator.h, les deux fichiers
différés jusqu'après l'étape 3 puisqu'ils vont être restructurés. Le résidu est donc
entièrement connu et daté.
CORRECTION — la note que j'avais écrite en tête du Doxyfile était fausse. Elle annonçait
que l'arête « EnergyArbitrator : public SmartChargingManager » n'apparaîtrait pas. Elle
apparaît : doxygen rend une classe de base non résolue en boîte simple. Ce qui manque est
derrière — la boîte est un cul-de-sac, sans membres, sans ancêtres, non cliquable (aucun
href dans le SVG). La conclusion ne change pas, ne pas élargir INPUT ; la description, si.
101 graphes SVG produits, 3,2 Mo de HTML, 1,8 Mo de XML.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
HAVE_DOT = YES, mais CALL_GRAPH et CALLER_GRAPH restent à NO, et pas pour une question
de coût : le moteur est signal-driven. powerBalanceChanged → verifyOverloadProtection()
est une connexion Qt, invisible à l'analyse statique ; un graphe d'appel montrerait
update() comme un point d'entrée orphelin et raterait le mécanisme principal de la
couche L4. Un schéma faux vaut moins que pas de schéma.
Ce que dot apporte réellement : héritage, collaboration, inclusions. SVG, lisible à toute
échelle et diffable, avec DOT_GRAPH_MAX_NODES = 60 pour éviter les pavés illisibles.
tools/gen-doc.sh : une seule commande, le Doxyfile épinglé. C'est la leçon du piège des
générateurs — 163 avertissements sans sortie contre 113 avec HTML+XML sur le même arbre.
Un chiffre de référence ne vaut que rattaché à une configuration exacte, et une commande
unique empêche qu'on régénère « à sa façon ». Le script signale aussi une version de
doxygen différente de la référence 1.9.8.
Avertissement ajouté en tête du Doxyfile, à côté de celui sur les générateurs : le graphe
d'héritage sera tronqué. EnergyArbitrator : public SmartChargingManager, mais l'amont est
hors INPUT. C'est voulu — élargir INPUT ferait exploser le compte d'avertissements. Sans
cette note, quelqu'un le « réparera ».
graphviz est documenté comme dépendance de la cible doc, pas du build : build-cross-arm64
compile, il ne dessine pas.
Hors de ce lot, comme convenu : \defgroup, carte \dot cliquable, WARN_AS_ERROR = YES,
hébergement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>