45 Commits

Author SHA1 Message Date
Patrick Schurig
b274989166 feat(délestage §3): la cascade tient dans deux ressources, et DRAW_CAP naît
Le surplus dit ce qu'on peut dépenser sans acheter ; l'autorisation de
soutirage dit ce que le branchement supporte. Une charge doit tenir dans
les deux. Jusqu'ici seules les bornes VE étaient bornées par la seconde —
une charge de waterfall pouvait donc créer un dépassement que seule la
protection L4 rattrapait, après coup. C'est ce qui change.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-30 12:29:38 +02:00
Patrick Schurig
b29489bdb5 feat(délestage §2): le disjoncteur passe par la résolution, au watt près
`phasePowerLimit` devient un `DrawCap{Breaker, perPhaseW}` et
`evPhaseAllowanceW()` traverse `resolveDrawMargin()` — la mécanique du
délestage est branchée, et rien ne bouge. C'est le but : à comportement
constant, tout écart observé ensuite est un défaut, pas une évolution.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-30 08:58:11 +02:00
Patrick Schurig
cd010dd28c feat(R2): le financement descend au niveau — funding vit dans levels[]
funding était par CHARGE. Au §10 la même charge sera financée au réseau pour son
plancher éco et au surplus pour son confort DANS LE MÊME CYCLE : un financement
par charge y devient indécidable, et c'est lui qui porte l'identité de
réconciliation de l'app. Laissé à la charge, cette identité cesserait d'être
vérifiable sans que rien ne le signale.

Déplacé maintenant bien que le §10 ne soit pas écrit : seule des huit exigences
de la maquette à être un changement de FORME d'un champ existant, donc seule à se
renchérir à chaque écran qui s'y appuie.

UN SEUL NIVEAU aujourd'hui, et c'est « comfort ». La passe unique d'aujourd'hui
est celle qui, au §10, servira le confort depuis le surplus — la passe éco est la
nouveauté du lot, pas la passe existante rebaptisée. C'est ce qui rend le
déplacement faisable avant le lot : la forme est juste dès maintenant.

funding N'EST PAS conservé en résumé sur la charge, et c'est la première des deux
questions posées. Le cas mixte est justement celui qui le rendrait indécidable :
« mixte » serait un troisième code que personne n'a demandé, « grid si l'un
l'est » une convention que chaque client redériverait autrement. Deux endroits
pour un fait divergent.

Le motif `decision` reste, lui, au niveau de la charge — parce qu'on saura dire
de quel niveau il parle (R7). L'asymétrie est la règle : on conserve un champ
hérité QUAND ON PEUT EN DIRE QUELQUE CHOSE DE VRAI, jamais par symétrie.

L'IDENTITÉ DE RÉCONCILIATION est écrite dans INTERFACE.md, seconde question
posée. Somme sur les charges, somme sur les niveaux dont funding == "surplus", de
niveau.targetW == budget.allocatedW. Un client qui la reconstruirait depuis
l'ancienne forme divergerait au premier cas mixte.

Et son unique exception est publiée plutôt que sue : EV_GRID_START partage une
allocation entre deux compteurs, sa contribution au surplus est
decision.params.budgetW et non targetW. C'est ce que R3 généralisera ; d'ici là
l'exception vit dans le contrat, pas dans la tête du lecteur.

levels[] absent = box antérieure OU mode dégradé — le repli de l'app couvre les
deux. Rupture assumée pour un client lisant entry.funding : même arbitrage que
measuredW → measurement.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-29 16:57:39 +02:00
Patrick Schurig
3ac61e9b66 docs(règles): les 14 en territoire livré, lues une par une
Sept étaient des trous de classification ou de citation, sept sont réelles.

TROIS CITATIONS AJOUTÉES, portées par du code depuis longtemps :
  LM-400    une machine physique est UNE charge → c'est l'exclusivité d'un Thing
            dans validateSet() qui l'empêche d'être déclarée deux fois
  LM-301-b  « une charge utile, une branche de fabrique » vaut pour CONSTRUIRE,
            pas pour ALIMENTER en actions → le repli L2 lit supportedKinds
  LM-600    les codes d'adapter portent le nom du MÉCANISME, jamais du domaine

QUATRE PASSÉES EN CADRAGE : ECS-004/005 (invariant tenu par ABSENCE — aucun
identifiant de relais ne franchit la frontière, il n'y a rien à pointer), LM-404
(hors périmètre déclaré), LM-501 (justification de NE PAS bâtir les groupes tant
que la liste plate suffit).

RESTENT SEPT, et ce sont de vraies attentes : ECS-111, ECS-302, ECS-305, ECS-307
(les trois dernières déjà listées en « ce qui reste à éprouver », §9 de
spec_ecs.md), LM-401/LM-402 (facettes, couche non bâtie), LM-502 (plafond de
groupe, conditionnel).

ECS-111 mérite d'être remonté : ce n'est pas une attente, c'est un défaut vivant,
et sa conséquence dépasse ce que la spec en dit. Détail dans le rendu de session.

Bilan de l'index après cette passe : 34 « spécifiée seule » (dont 27 en
territoire non construit), 26 cadrage, 24 citées par le code, 17 par un test, 12
vérifiées sur machine. Le nombre de départ était 70.

Aucun changement de comportement. ci-quality 3/3, build 0/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-29 12:57:53 +02:00
Patrick Schurig
d3dfe519d5 docs(règles): sept règles étaient implémentées et non citées
Premier usage réel de l'index, et il donne un résultat inattendu : sur les 70
règles « spécifiée seule », une part notable n'est PAS un trou d'implémentation
mais un trou de CITATION. Vérifié en cherchant le CONTENU dans le code plutôt que
l'identifiant :

  ECS-110-b      un Thing n'appartient qu'à une charge active  → validateSet()
  LM-302-b       l'exclusivité ne se valide pas dans une charge utile → idem
  LM-1209-a-bis  écarter et refuser ne sont pas la même fonction → validateRanks()
  ECS-410-b      l'issue de ClearLoadFault est journalisée par l'arbitre
  LM-303         les verrous appartiennent au mécanisme, pas au domaine
  LM-1208-c      sans voiture, le nombre de phases vient de la borne
  ECS-308        MaxRelays TRONQUE au lieu de refuser

Toutes portées par du code, aucune citée. L'index les comptait donc comme
spécifiées seules — il SOUS-DÉCLARE la couverture, exactement comme il peut la
surdéclarer. C'est la même limite que celle écrite en tête de la page générée
(« cité par n'est pas couvert par »), prise par l'autre bout : un identifiant
absent d'un commentaire ne dit rien de ce que le code fait.

Citations ajoutées avec \rule{}, l'alias posé hier — donc navigables du
commentaire vers la règle. Aucun changement de comportement : ce sont des
commentaires. 63 « spécifiée seule » au lieu de 70, 21 citées par le code au lieu
de 14.

ci-quality 3/3, build 0/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-29 12:44:12 +02:00
Patrick Schurig
2c46f8e798 fix(3g-2): une entrée de configuration supprimée est rattrapée au cycle suivant
LM-1209-e. Le provisionnement vivait dans la branche « l'adaptateur vient d'être
créé » de syncAdapters(). Or l'adaptateur est construit depuis le THING : il
survit à la suppression de l'entrée de configuration. Une entrée supprimée depuis
l'app n'était donc jamais recréée — mesuré sur .75, rien en 140 s.

Le trou de 3g-2 rouvert par le côté client : la borne restait ARBITRÉE mais
ABSENTE de GetLoadConfig, à un rang de queue non réglable. Elle consommait du
budget sans que personne puisse la classer, et rien ne le disait.

La condition devient un ÉTAT et non un ÉVÉNEMENT — « cette borne n'a pas
d'entrée » au lieu de « l'adaptateur vient de naître ». C'est la leçon qui dépasse
le défaut : un rattrapage dont la condition est un événement ne rattrape rien,
puisqu'il ne se rejoue pas. Même distinction qu'entre ECS-411, qui relit l'état
réel des relais, et l'hypothèse de départ qu'il a remplacée.

L'entrée recréée revient en rankOrigin "auto" — personne ne l'a classée, son rang
est de nouveau un défaut — et le rang posé par l'installateur est PERDU avec
l'entrée. Supprimer n'est donc pas un moyen de retirer une borne de l'arbitrage :
elle revient, déclassée, en queue et avec son badge. C'est exactement pourquoi la
marque doit se voir. enabled: false reste la voie et conserve le rang.

Le refus de provisionnement n'est annoncé qu'une fois par borne
(m_provisionRefuse) : le rattrapage étant cyclique, le répéter produirait un
avertissement par minute et pour toujours — la règle est déjà posée pour les
charges sans décision dans buildTelemetry(). Le drapeau tombe au premier succès.

testDeletedEvChargerConfigComesBack couvre les deux moitiés : le rattrapage, et
le cas négatif — il ne se rejoue PAS quand l'entrée existe, sans quoi il
réécrirait la configuration à chaque cycle, donc un changed(), donc un rebuild
d'adaptateurs, donc des verrous réarmés en boucle (ECS-412). Vérifié échouant
avec l'ancien comportement.

Le brief §6-C, qui promettait à l'app l'inverse de ce qui se passait sur le seul
geste client qui détruit de la configuration, est corrigé — et dit désormais ce
que la suppression coûte vraiment : le rang.

Passe aussi LM-1209-d (grouper à l'affichage, jamais à l'écriture) et
l'avertissement de sonde en tête d'INTERFACE.md.

amd64 0/0. simulation 32/32, charging 16/16, loadmodel 20/20, spotmarket 7/7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-29 11:08:08 +02:00
Patrick Schurig
8927b0033c feat(§12): LM-1209 — insertion en queue, rang unique, et la création se dit
Les trois conditions, d'un bloc.

INSERTION EN QUEUE. provisionEvChargerConfig() posait rang min − 1, écrasé à 1 —
donc à égalité avec la charge que l'installateur avait posée là exprès. C'est
désormais 1 + max(rangs). Queue de LISTE et non de domaine : les domaines ne sont
pas contigus, donc « juste après le dernier ev » exigerait de pousser une charge
d'un autre domaine, ce que la condition 2 interdit. Et comme max(domaine) ≤
max(liste), la queue de liste est toujours au moins aussi reculée — elle coïncide
quand le domaine occupe la fin, et sinon place la nouvelle charge derrière tout
le monde plutôt que de l'intercaler devant une charge classée. Plus fidèle au
principe que la formulation d'origine.

UNICITÉ. validateRanks() refuse une écriture où deux charges portent le même
rang. Avec la mise en garde écrite noir sur blanc : l'égalité ne coûte pas le
déterminisme — le tri est total sur (rang, identifiant) depuis 3g-2 — elle coûte
la gouvernabilité. S'y tromper ferait chercher le défaut dans le tri.

ET LA RÈGLE GÉNÉRALE QUI EN SORT (LM-1209-a-bis) : une vérification qui ÉCARTE et
une vérification qui REFUSE ne peuvent pas être la même fonction. validateSet()
nomme ce qui rend l'ensemble dangereux et load() s'en sert pour écarter ; y
ajouter l'unicité de rang aurait fait perdre des charges au démarrage sur une
configuration héritée — disparues de la configuration ET de l'arbitrage, sans
applySafeState, soit ECS-413 rouvert par une autre porte. Les deux sont
séparées ; validateRanks() n'est appelée que par setConfigs(). Au chargement, un
doublon hérité est signalé bruyamment et CONSERVÉ.

o:rankOrigin. Énumération fermée "auto" | "user", absente par défaut. Une
énumération et non un booléen parce que le troisième état est le point :
`rankIsDefault: false` affirmerait que le rang a été choisi, alors que c'est ce
qu'on ignore d'une configuration héritée. L'absence doit porter le « on ne sait
pas » ; un booléen ne sait pas se taire. Même motif que measurement.source, et la
spec le nomme désormais comme un motif de conception plutôt qu'une décision au
cas par cas.

Et la précision demandée en écrivant : l'effacement porte sur le RANG, jamais sur
la réception d'une écriture. SetLoadConfig remplace en bloc, donc réordonner une
AUTRE charge renvoie forcément l'entrée de celle-ci — l'effacer à cette occasion
perdrait une marque encore vraie. L'omission ne lève rien ; pour lever sans
déplacer, un client envoie "user". Un "auto" fabriqué est refusé, l'aller-retour
verbatim reste licite.

Trois tests. testAutoProvisionedRankGoesToTheQueue vérifié échouant avec
l'insertion en tête — et il échoue en disant que la borne n'entre PAS en
configuration, les deux mécanismes se répondant : le rang libre par construction
et le refus d'écriture en filet. testRankOriginFollowsTheRankNotTheWrite couvre
le cas qui se serait perdu, un client naïf qui omet la clé en déplaçant une autre
charge. testRankUniquenessRefusesWriteButNeverDiscards couvre la moitié qui ne se
voit pas : qu'un chargement conserve ce qu'il aurait pu écarter.

amd64 0 erreur / 0 avertissement. simulation 31/31, charging 16/16, loadmodel
20/20, spotmarket 7/7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-28 15:56:46 +02:00
Patrick Schurig
e392c1c7b8 feat(§11): la mesure d'une charge se lit sur l'appareil, et publie sa source
LM-1105/LM-1106 implantés. measuredW disparaît, sans alias.

ILoadAdapter::deviceMeasurement() — PURE VIRTUELLE, comme runtimeView() et pour
la même raison : un mécanisme ajouté qui hériterait d'un « non mesurable » par
défaut sortirait de la mesure sans bruit, et son écran afficherait « pas
mesurable » avec l'autorité d'un constat. Le compilateur pose la question.

Le type porte une CAPACITÉ, pas une valeur nulle : `available` répond à « cette
charge est-elle mesurable », question posée à la ThingClass, jamais à la valeur
du moment. Un routeur au palier 0 mesure 0 W et reste mesurable ; sans ce
booléen les deux seraient indistinguables.

attachMeasurements() résout meter → device → none. Le compteur explicite garde la
priorité, et pas d'abord parce qu'il mesure mieux : un réglage sans effet est
pire que pas de réglage. La priorité porte sur la SOURCE, pas sur la valeur —
compteur déclaré injoignable, on retombe sur l'appareil, parce que se taire
perdrait une mesure qu'on a et que le silence se lirait « non mesurable ».

Sous `none`, powerW est OMIS. La puissance nominale reste lisible dans
mechanism.*, qui dit d'où elle vient ; la faire passer pour une mesure produirait
un écart commandé↔mesuré identiquement nul à tous les cycles — un « tout
concorde » permanent, corollaire LM-104.

DEUX AFFINEMENTS IMPOSÉS PAR L'IMPLANTATION, tous deux de modèle :

LM-1105-a — une mesure PARTIELLE n'est pas une mesure. Là où plusieurs Things
portent la charge, il faut qu'ils la portent TOUS : trois relais dont un seul
mesure annonceraient 500 W là où 1 500 W coulent, et l'écart enverrait chercher
une panne inexistante. À ne pas confondre avec LoadTelemetry::currentPowerW, qui
accepte « au moins un » et retombe sur le nominal — celui-là est le juge runtime
du budget et doit rendre un nombre à chaque cycle. Les fusionner rouvrirait la
correction B.

LM-1105-b — la règle présuppose que le Thing piloté est TRAVERSÉ par la
puissance. Un relais de relay-router commute la charge, donc sa puissance EST
celle de la charge ; un contact SG-Ready est un contact de SIGNAL — la PAC est
alimentée ailleurs et un contact qui mesurerait quelque chose mesurerait sa
propre bobine. sg-ready est donc `none` par CONSTRUCTION, même sur des relais qui
mesurent — c'est le cas du banc — et c'est le seul mécanisme pour lequel
meterThingId reste la seule voie.

RelayRouter : la capacité se juge sur la ThingClass de TOUS les relais déclarés,
la valeur se lit sur les relais FERMÉS. Jugée sur les relais actifs, la source
vaudrait `device` au palier 1 et `none` au palier 0 — un bloc de mesure qui
apparaît et disparaît au rythme du thermostat. VÉRIFIÉ ÉCHOUANT ainsi.

testMeasurementSourceIsPublished : les trois sources sans une ligne de config de
compteur, le palier 0 qui ne fait pas basculer la source, la PAC en `none` alors
que ses contacts mesurent, la disparition de measuredW, puis les deux règles de
priorité — le compteur explicite l'emporte (1 234 contre 2 900), un compteur
injoignable fait retomber sur l'appareil.

Ne touche PAS ce que l'arbitre lit : LM-1101 tient, LoadTelemetry::currentPowerW
est inchangé, et le recrédit anti-clignotement n'a jamais transité par
meterThingId.

amd64 0 erreur / 0 avertissement. Suites, un processus par test : simulation
30/30, charging 16/16, loadmodel 18/18, spotmarket 7/7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-28 14:49:28 +02:00
Patrick Schurig
3b63eec251 fix(3g-2): une borne sans voiture assignée reste arbitrée
LM-1208-b appliqué : `assignedCarId` sort du filtre d'entrée du waterfall.
Restent deux exclusions, et elles ne disent pas la même chose — le mode manuel
est une INTENTION (l'utilisateur pilote), l'absence de véhicule branché un
CONSTAT (rien à consommer de ce qu'on allouerait).

Le critère venait du proxy, où il a un sens : sans voiture, pas de SoC, pas de
capacité, pas d'échéance à planifier. Le waterfall n'a besoin d'aucune des trois
pour allouer du surplus. Il contredisait LM-1206 — le rang et le plancher
appartiennent à la borne, pas au véhicule — il n'était pas vérifiable, et il
excluait la borne d'un invité au moment précis où elle consomme. Ce qu'on perd
sans voiture est une FONCTION, échéance et cible d'énergie, pas l'arbitrage.

LM-1208-c, trouvé en implantant, et c'est lui qui aurait mordu : retirer le
critère ne suffit pas. Le proxy ne PRÉPARE pas une borne sans voiture —
prepareInformation() la filtre en entrée — donc elle entre dans le waterfall
sans ChargingProcessInfo. Or internalProcessInfo() rend un objet par DÉFAUT, et
ses valeurs sont plausibles : une phase, pas de bascule. Une borne triphasée
aurait été annoncée monophasée et le budget divisé par trois — plafond 3 680 W
au lieu de 11 040, 16 A commandés au lieu de 9, 11 kW tirés d'une enveloppe qui
en comptait 3,7. Le défaut de 3g-1 pris à l'envers, et silencieux.

D'où internalHasProcessInfo() : la question « cette borne a-t-elle été préparée
ce cycle » devient POSABLE au lieu d'être déduite d'une valeur crédible. Sans
ChargingProcessInfo on ne bascule pas — basculer suppose de savoir ce que la
voiture accepte — et on retient le phaseCount de la BORNE, hypothèse
conservatrice au sens de LM-104 : elle surestime la consommation.

Éprouvé par testChargerWithoutCarIsArbitrated, et VÉRIFIÉ ÉCHOUANT sans le
garde-fou : 3 680 W au lieu de 6 000 W alloués. Le test porte les deux moitiés —
la borne sans voiture est servie ET le mode manuel reste une exclusion, sans quoi
il passerait aussi si toute borne était arbitrée sans condition.

amd64 0 erreur / 0 avertissement. Suites, un processus par test : simulation
29/29 (dont run 16/16), charging 16/16, loadmodel 18/18, spotmarket 7/7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-28 12:46:44 +02:00
Patrick Schurig
c2f08cb804 fix(3g-1): une borne sans véhicule branché est hors arbitrage
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
2026-08-27 12:52:21 +02:00
Patrick Schurig
baf267a963 feat(3g-2): une charge, un commandeur
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
2026-08-27 10:43:25 +02:00
Patrick Schurig
e5e20db9fd fix(3g-1): l'écrêtage par la limite de phase avait été perdu
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
2026-08-27 10:27:50 +02:00
Patrick Schurig
4f987e5b1d fix(3g-1): la bascule de phases se décide sur le courant POSSIBLE
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
2026-08-27 10:21:08 +02:00
Patrick Schurig
99ad9a04cb fix(3g-1): le nombre de phases visé se décide avec la voiture branchée
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
2026-08-27 10:16:41 +02:00
Patrick Schurig
aedcbf3cc2 fix(3g-1): les bornes étaient arbitrées mais ne franchissaient pas la frontière RPC
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
2026-08-26 18:31:34 +02:00
Patrick Schurig
1478d8f11c feat(3g-1): les bornes entrent dans le waterfall
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
2026-08-26 18:00:46 +02:00
Patrick Schurig
e634ed99de fix(arbitre): un filtre par interface qui ne trouve rien doit le DIRE
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
2026-08-26 11:57:24 +02:00
Patrick Schurig
36394cc351 feat(3g-1): la prise en compte du stockage monte dans le budget
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
2026-08-26 11:34:52 +02:00
Patrick Schurig
639689baa9 feat(lot E §12): le plancher de modulation — rien, ou au moins X
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
2026-08-26 10:48:51 +02:00
Patrick Schurig
bbb3492c2c feat(lot E §11): compteur et sonde par charge, publiés et sans effet sur les décisions
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
2026-08-26 10:34:41 +02:00
Patrick Schurig
a0c02fbd34 feat(lot D): libellé jusqu'à l'adaptateur, relais comparés par table, paliers publiés
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
2026-08-26 10:07:22 +02:00
Patrick Schurig
0765672e62 feat(config): lot B-bis — la PAC entre en configuration — 1.15.2+etm15
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
2026-08-25 18:35:04 +02:00
Patrick Schurig
74465375c4 feat(telemetry): lot B — frontière de télémétrie d'arbitrage + o:domain — 1.15.2+etm14
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
2026-08-25 13:18:10 +02:00
Patrick Schurig
cd1a0c83a5 fix: ECS-410-b — l'issue de ClearLoadFault, et l'asymétrie refus/succès (règles 7-c, 7-d)
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>
2026-08-13 20:27:53 +02:00
Patrick Schurig
25cde160ba feat(sg-ready): construction depuis la configuration + garde de double propriété
LM-301 vérifié à l'usage : brancher le mécanisme n'a touché qu'une branche de fabrique
dans rebuildLoadAdapters(), un champ optionnel au schéma RPC, et la comparaison
sameHardware() — la charge utile fait partie du « matériel », un état ré-encodé impose
une reconstruction. Aucun code d'arbitrage n'a bougé.

La PAC de configuration vit dans m_loadAdapters, la MÊME table que les autres charges.
Elle hérite donc d'ECS-412 (reconstruction incrémentale) et d'ECS-413 (état sûr à la
désactivation, qui vaut ici ÉTAT 2 et non « contacts ouverts ») sans code dédié. Le
dispatch des actions route par loadId d'abord, l'enregistrement en dur ne servant plus
que de repli.

Garde neuve, créée par cette configurabilité même : une PAC de config peut viser les
mêmes contacts que la PAC de banc câblée dans energypluginnymea.cpp. Deux adaptateurs
sur un contact, c'est deux commandes contradictoires et une charge comptée deux fois
dans le budget (règle absolue 1). La configuration fait autorité : l'exemplaire en dur
est retiré — sans mise en état sûr, les contacts ayant un propriétaire légitime.

testSgReadyFromConfig couvre les deux refus (sans état 2, charges utiles mélangées),
le round-trip par le store, le pilotage, et la désactivation vers l'état 2.
Suite complète : 111 tests, 0 échec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:34:39 +02:00
Patrick Schurig
dd28749b6b fix(etm): ECS-413 — désactiver une charge la laisse en état sûr
Constat de banc du 2026-08-09 : un SetLoadConfig posant enabled: false sur une
charge alors au palier 3500 W détruisait l'adaptateur en laissant les TROIS RELAIS
FERMÉS, juste avant une intervention de câblage. Plus personne ne les commandait ;
ils y seraient restés indéfiniment.

applySafeState(now) est ajouté à ILoadAdapter, PURE VIRTUELLE : l'état sûr est
propre à chaque adaptateur et une formulation « tout couper » serait fausse.
  - RelayRouter          : tous relais ouverts
  - EtmVariableLoadAdapter : consigne 0 W
  - SgReadyAdapter       : ÉTAT 2 (normal, mains off) — JAMAIS l'état 1. Bloquer
                           une PAC n'est pas la mettre en sécurité, c'est arrêter
                           le chauffage sans raison visible (SAFETY.md).
  - EvAdapter            : sans effet, il n'est pas construit depuis LoadConfig.

L'application passe par le chemin d'action NORMAL avec force = true, celui du
mode dégradé L2 : le mécanisme de contournement des verrous existait déjà.

ORDRE, et c'est le point qui dépendait d'ECS-410 : l'état sûr est appliqué AVANT
la destruction, et la destruction passe par deleteLater(). Les écritures d'ECS-410
sont asynchrones avec `this` en contexte de connexion — détruire immédiatement
couperait les acquittements en vol, et on ne saurait pas si la mise en sécurité a
abouti, précisément dans le cas où elle échoue.

PÉRIMÈTRE BORNÉ. Rien de tout cela à l'arrêt du plugin ni au redémarrage de
nymead : l'état doit y être CONSERVÉ, c'est ce qu'ECS-411 relit, et couper l'eau
chaude à chaque redémarrage de service serait une régression. La désactivation est
un acte délibéré de l'opérateur ; un redémarrage n'en est pas un. Les charges
CONSERVÉES par le rebuild incrémental ne passent pas par ce chemin.

Test testEcsDisableLeavesSafeState, avec son CAS NÉGATIF en premier : un rebuild
qui ne change que le rang ne coupe rien — sans lui, ECS-412 serait annulé et
chaque changement de priorité couperait la charge. Puis le cas positif :
désactivation, relais ouvert, et il le reste même sous surplus au cycle suivant.

Build amd64 0 erreur. Simulation : 17/17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:36:38 +02:00
Patrick Schurig
92d0bef4ac fix(etm): ECS-410 — échec d'écriture relais, échelle bornée et défaut collant
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>
2026-08-09 11:32:51 +02:00
Patrick Schurig
c9b8e63f89 fix(etm): ECS-412 — rebuild incrémental et armement du verrou à froid
Cause racine, pas symptôme. rebuildLoadAdapters() détruisait TOUS les adaptateurs
à chaque SetLoadConfig, réarmant leurs verrous. Sur un ballon thermodynamique à
minOn de 300-600 s, un client qui réordonne ses priorités depuis l'app pouvait
faire court-cycler son compresseur. C'est de la protection matérielle.

L'arbitre mémorise désormais la config ayant servi à construire chaque adaptateur
(m_builtFrom) et ne reconstruit que si le MATÉRIEL a changé — type, câblage,
paliers, plafond, verrous (sameHardware()). Un changement de rang ou de besoins
passe par updateSoftConfig(), en place : ni m_currentStage ni m_lastSwitch ne
bougent, aucun relais n'est réécrit. Le log distingue créées / mises à jour /
inchangées / retirées.

updateSoftConfig est PURE VIRTUELLE, sans implémentation par défaut. Un défaut
vide silencieux ferait qu'un futur adaptateur construit depuis LoadConfig
ignorerait sans bruit les changements de rang ; là, le compilateur force la
décision. EvAdapter et SgReadyAdapter la déclarent sans effet, avec le motif.

Démarrage à froid : après un redémarrage de nymead, m_lastSwitch est
irrécupérable. lockWindow() traite désormais un horodatage nul comme
« commutation venant d'avoir lieu » (elapsed = 0), donc verrou ARMÉ pour sa durée
configurée. L'écriture naturelle (`valid && elapsed < minOnS`) fait l'inverse et
laisserait une boucle de redémarrage court-circuiter la protection compresseur
quand elle est la plus nécessaire. Armer n'est pas verrouiller
inconditionnellement : avec une durée nulle, `0 < 0` est faux et le verrou reste
inactif — un premier jet qui forçait le verrou a fait tomber trois tests
existants, qui avaient raison.

Test : testEcsRebuildPreservesLock — SetLoadConfig pendant une fenêtre de verrou
active, le relais reste fermé ; le délestage reprend une fois minOn écoulé.

Build amd64 0 erreur. Simulation : 12/12.

Réf. specs/spec_ecs.md §3 ECS-412 (0.5.1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:42:56 +02:00
Patrick Schurig
88626cfd56 feat(etm): câblage RelayRouter + repli L2 unifié (rév. 3) — routeur vivant, sécurité fermée
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>
2026-06-28 12:40:28 +02:00
Patrick Schurig
7184fe4e1d feat(etm): config charge pilotée persistée (Get/SetLoadConfig) + repli L2
Pont moteur↔app : déclaration des charges etmvariableload persistée et éditable,
construction des adaptateurs à chaud depuis la config (remplace le registre en dur).

- LoadConfig / LoadConfigNeeds (Q_GADGET typés, introspectables) — forme mot pour mot
  du LoadDescriptor §4 (jonction inter-repos avec l'app).
- LoadConfigStore : persistance atomique /var/lib/nymea/energy-load-configuration.json,
  validation en bloc, signal changed().
- Handler NymeaEnergy.GetLoadConfig / SetLoadConfig (+ notif LoadConfigChanged). GET typé
  objectRef<LoadConfig> ; SET schéma inline o: (powerLevels/needs conditionnels §4).
- EnergyArbitrator : setLoadConfigStore + rebuildEtmVariableLoadAdapters (enabled==true
  seulement, §9) ; repli L2 = setPowerSetpoint(0) force=true sur tout etmvariableload
  (ferme le trou sécurité ouvert en T2).
- Tests simulation : migration ECS→etmvariableload (arrondi fixed/dynamic, recrédit,
  délestage, round-trip powerSetpoint), budget partagé etmvariableload↔PAC, watchdog L2
  réactivé, persistance + construction depuis config + injection RPC end-to-end.

PAC SG-Ready reste hors config (§8, gelée). Les 5 tâches du brief sont couvertes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 09:51:38 +02:00
Patrick Schurig
3cb79f5918 feat(etm): règle d'arrondi etmvariableload dans le waterfall + infra de test
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>
2026-06-28 09:47:58 +02:00
Patrick Schurig
51006745ed refactor(etm): retirer le kind Stage (ECS→Setpoint), chemin State PAC conservé
Contrat etmvariableload rév. 2 §6 : LoadAction garde DEUX kinds (Setpoint W + état
SG-Ready). On supprime UNIQUEMENT le kind Stage (index de palier ECS) ; l'ECS passera
en etmvariableload (Setpoint W) en T3.

- LoadAction : enum Kind { Setpoint, State, Constraint } (retrait Stage + champ stage).
- SurplusContext : retrait stage/minStage/maxStage de LoadContextTelemetry. state/minState/
  maxState (PAC) CONSERVÉS.
- LoadDescriptor : retrait du champ stages (relay-stages orphelin).
- RuleBasedScheduler : retrait buildEcsStageAction ; waterfall = sg-ready uniquement.
- EnergyArbitrator : retrait registerEcsAdapter / m_ecsAdapters / dispatch Stage /
  boucle contexte ECS / repli L2 ECS. Chemin State (PAC) intact.
- Suppression de EcsRelayAdapter ; retrait du registre en dur ECS (energypluginnymea).

NB : entre ce commit et T4 le repli L2 charge pilotée est absent (recâblé en T4) —
commit intermédiaire d'une branche feature, jamais déployé tel quel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 09:23:19 +02:00
Patrick Schurig
b06ac15714 [3e-4] arbitre : registerSgReadyAdapter + dispatch State + mode dégradé → état 2
registerSgReadyAdapter + m_sgReadyAdapters ; buildContext inclut les PAC ;
applyActionsToAdapters dispatche kind==State → m_sgReadyAdapters. Mode dégradé L2 :
SG-Ready → état 2 (NORMAL, mains off, force=true), JAMAIS état 1 (blocage). SAFETY.md
table L2 corrigée (état 2, pas 1). Build 0/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:25:09 +02:00
Patrick Schurig
54ba2296fa [3c-7c] testMeterSilentFallback + seam watchdog injectable
Extraction recordMeterUpdate(now)/evaluateMeterFreshness(now) : logique L2 injectable
(temps en paramètre), déclencheurs réels (QTimer + powerBalanceChanged) sous
#ifndef ENERGY_SIMULATION (pattern amont). onMeterWatchdogTick délègue.

testMeterSilentFallback : compteur muet >90s → dégradé (ECS off force=true, bypass
minOn) → STABILITÉ (ECS reste 0 sur 4 cycles, planif suspendue) → REPRISE (recalcul
depuis le surplus, pas de restauration d'ancienne consigne). Suite simulation 18/18,
charging 46/46, plugin prod 0/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:21:22 +02:00
Patrick Schurig
5d67dc943d [3c-3-fix] waterfall ECS : surplus net signé + clamp lock-aware (protection compresseur)
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>
2026-06-09 21:25:22 +02:00
Patrick Schurig
f71e0405b4 [3c-6] degradedMode() + notification ChargingSchedulesChanged + invariant zéro-cloud
virtual degradedMode() dans SmartChargingManager (base false, [ETM] additif),
override EnergyArbitrator. Champ o:degradedMode (additif) dans la notification
NymeaEnergy.ChargingSchedulesChanged, émise aussi aux transitions du mode dégradé
(planif suspendue → push du flag via emit chargingSchedulesChanged()).
INTERFACE.md : champ degradedMode documenté.

SAFETY.md : notification réconciliée (ChargingSchedulesChanged, pas EnergyManagerChanged)
+ limite "valeur figée non détectée". Correction ZÉRO CLOUD : suppression de la section
"Alertes externes" / mécanisme n8n, remplacée par une signalisation 100% locale
(notification nymea in-app + buzzer/relais via règle nymea, aucun canal réseau sortant).
Invariant 10 "ZÉRO cloud" gravé dans AGENTS.md.

Build 0 erreur / 0 warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:04:09 +02:00
Patrick Schurig
312a2484ae [3c-5] watchdog L2 : QTimer fraîcheur compteur + mode dégradé conservateur
QTimer 30s indépendant des signaux ; m_lastMeterUpdate picoté sur powerBalanceChanged.
Silence >90s → mode dégradé (appliqué à la TRANSITION uniquement) :
  - ECS palier 0 force=true ;
  - EV : clamp courant minimum SEULEMENT si déjà en charge (pas d'activation forcée ;
    "jamais 0 A si branché" relève du failsafe L1, pas du repli logiciel).
update() suspend la planification + le dispatch tant que m_degradedMode (sécurité L4
en position 3 reste active) → pas de rallumage sur le cache d'un compteur mort, pas
d'oscillation. Reprise au retour du compteur.

SAFETY.md §L2 : nuance maintenu/démarré + suspension planification. AGENTS.md morceau 7 :
exiger ECS reste à 0 sur plusieurs cycles. SG-Ready/Batterie déférés 3e/3f ;
flag degradedMode exposé en 3c-6. Build 0 erreur / 0 warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:43:28 +02:00
Patrick Schurig
0615e5f39d [3c-4] dispatch ECS : applyActionsToAdapters(Slot) dans update()
Itère slot.actions, dispatche les kind==Stage vers m_ecsAdapters (position 7,
avant adjustEvChargers). EV (Setpoint) reste sur le proxy amont jusqu'à 3g.
Build 0 erreur / 0 warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:26:49 +02:00
Patrick Schurig
7709057335 [wip] 3c morceaux 0-2 compilés + plan 3c validé dans AGENTS.md
Morceaux 0-2 implémentés et compilés (0 erreur / 0 warning) :
- M0 : LoadAction.force=false (bypass verrous anti-rebond sécurité)
- M1 : EcsRelayAdapter (.h+.cpp) — N paliers powerswitch, anti-rebond, etm.pri
- M2 : buildContext() — SurplusMeter brut, loads EV+ECS, registerEcsAdapter()

AGENTS.md : section PLAN 3C ajoutée avec corrections A+B intégrées.
Corrections A (déduction EV unique dans scheduler) et B (recrédit conso
propre anti-clignotement) documentées avant implémentation morceau 3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 13:34:42 +02:00
Patrick Schurig
5bb6da0e9f [3b-iv] ETM_ARBITRATOR actif — iso-fonctionnalité prouvée (simulation + tests charging, diff décisions zéro écart)
- energyplugin/energyplugin.pri : décommente DEFINES += ETM_ARBITRATOR (flip actif)
- energyplugin/etm/energyarbitrator.cpp : ajoute qCDebug "Updating smart charging" en tête
  de update() — comparabilité des logs avec l'amont garantie
- AGENTS.md : 3b → ✅ FAITE, chiffres de preuve, prochaine action 3c

Preuve iso-fonctionnalité :
- Simulation : 226 lignes décisions (Theoretically/Surplus/Current load) — diff = 0
- Tests charging : 57 lignes décisions — diff = 0 ; 46/46 PASS ref ET ETM
- [Arbitre] présents avec raisons françaises (idle, surplus PV, aWATTar, deadline)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:29:34 +02:00
Patrick Schurig
c3fedfe36b [3b] décision B + modèle sécurité (AGENTS + SAFETY.md) + Doxygen proxy/inactif
- AGENTS.md : nouvelle entrée "3b révisé — délégation EV à l'amont" (beta hybride
  assumée, ETM réel en 3c, transplantation EV en 3g) ; modèle sécurité L0-L4
  avec double déclenchement verifyOverloadProtection documenté (signal ligne 127 +
  appel cyclique ligne 313 SCM.cpp).
- docs/SAFETY.md : document normatif 5 couches + signalisation locale optionnelle ;
  Variante B confirmée pour le repli L2 (EV au minimum + notification nymea +
  risque 1,4 kW accepté) ; table défaillances/couches corrigée (L1 ne couvre pas
  compteur hors ligne).
- energyarbitrator.cpp update() : commentaire explicitant la correspondance exacte
  avec l'ordre SCM (1-4 parent, ETM entre 4 et 7, planSpot+planSurplus via getPlan).
- rulebasedscheduler.h : Doxygen getPlan() marqué "PROXY AMONT POUR L'EV (beta)".
- evadapter.h : Doxygen applyAction() marqué "Inactif jusqu'à 3g".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 07:41:12 +02:00
Patrick Schurig
5f49e4ca3c [3b-wip] EnergyArbitrator + RuleBasedScheduler + EvAdapter (dispatch amont, ETM_ARBITRATOR désactivé)
- EnergyArbitrator : public SmartChargingManager — raison documentée dans AGENTS.md §DÉCISIONS DE DESIGN
- SmartChargingManager : protected slots + virtual update() + 3 accesseurs inline [ETM]
- RuleBasedScheduler::getPlan() wraps planSurplusCharging/planSpotMarketCharging, annote chaque action d'un reason français
- EvAdapter : ILoadAdapter concret pour evcharger — applyAction() implémenté, NON appelé en 3b (dispatch via adjustEvChargers() amont, iso-fonctionnel)
- ETM_ARBITRATOR : commenté dans .pro — ne s'active qu'après preuve iso-fonctionnelle (3b-iv)
- Doxygen \brief + invariants + contrats sur toutes les classes/méthodes publiques etm/ (DoD §5)
- plan.h : timeSlots (pas slots, mot-clé Qt) ; commentaire JSON sérialisation "slots" OPTIMIZER_PROTOCOL §6
- .clangd : flags de repli Qt/nymea pour clangd via symlink ~/Schreibtisch/
- compile_commands.json gitignore (chemins absolus locaux)
- Build : 0 erreurs, 0 warnings — libnymea_energypluginnymea.so 914 KB

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 23:16:49 +02:00