81 Commits

Author SHA1 Message Date
Patrick Schurig
0b8264ab04 docs(AGENTS): une obligation qui change de maison avec l'appelant se perd
Classe de défaut, nommée en découpant le lot prévision PV. Certaines obligations
ne sont pas attachées à une DONNÉE mais à l'acte de l'aller chercher —
l'attribution CC BY 4.0 suit celui qui appelle. Déplacer l'appel déplace donc
l'obligation, et si le dépôt d'arrivée ne la porte pas, elle disparaît sans que
personne ne l'ait retirée.

Ce qui rend la classe dangereuse : le déplacement lui-même est invisible. On
discute d'un découpage de responsabilités sur des arguments d'architecture, et
l'obligation voyage en silence dans les bagages. Aucun test, aucune revue de
code, et le manquement se constate dans un dépôt PUBLIÉ.

La question à poser à chaque déplacement : cet acte porte-t-il une obligation,
et le dépôt d'arrivée l'écrit-il déjà ? Deux issues — écrite à l'arrivée, ou le
déplacement n'a pas lieu. Pas de troisième branche où l'on s'en souviendra.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-08 16:49:55 +02:00
Patrick Schurig
5dfd8b9047 docs: trois modes tranchés, et les transitions de dérogation sont écartées du tri
DÉCISION PRODUIT (Patrick, 2026-09-08, après validation du lot sur le banc) :
trois modes — Normal (manuel), PV, Min+PV — et le boost n'en est PAS un
quatrième. C'est la dérogation : un bouton d'action à côté du sélecteur, posé
par SetLoadDerogation et non par SetChargingInfo, avec sa durée obligatoire et
bornée [60 s, 6 h] que l'écran doit faire saisir. Gravé dans les RÉPONSES
FIGÉES : un mode se choisit et dure, une dérogation répond à un besoin ponctuel
et s'efface — les deux ne se rangent pas au même endroit.

CRITÈRE DE TRI DE L'ÉTAPE 4 — une clause de plus, écrite sur la mesure. Une
dérogation fait bouger la consigne de plusieurs kilowatts d'un coup, et CHAQUE
BORD ouvre une divergence : à l'ouverture la voiture négocie sa montée (13800
attendus, 3930 mesurés, convergence en 300 s), à la fermeture la borne n'a pas
encore décru (0 attendu, 8367 mesurés).

La fermeture est la plus trompeuse : immédiate, énorme, et trait pour trait
semblable à une commande ignorée. Le discriminant est temporel et déjà publié —
une divergence née dans le même cycle qu'un DEROGATION_ENDED, ou qu'un
DEROGATION_ACTIVE neuf, est une transition.

Sans cette clause, chaque boost compterait pour DEUX incidents, et l'étape 4 se
justifierait d'elle-même en comptant ses propres transitoires : le mécanisme du
§6.2 — un instrument qui fabrique la preuve dont il a besoin — déplacé du banc
vers la mesure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-08 12:09:03 +02:00
Patrick Schurig
be401dec40 fix(dérogation): une borne n'est pas écrêtée deux fois, et un mode écrit la termine
Deux défauts trouvés par le premier essai au banc (2026-09-08, 11:05→11:16),
et le premier est celui que Patrick a vu en regardant la voiture.

LM-1500-h — UNE BORNE N'EST PAS ÉCRÊTÉE DEUX FOIS.

La tête de passe éco appliquait `margeRestante` au plafond d'une borne. Or
`margeRestante` est la marge d'UNE phase, et le plafond d'une borne triphasée
est une enveloppe sur TROIS : les comparer divise son allocation par trois.
Mesuré : marge de 6 210 W sur la phase la plus chargée, plafond de cycle à
13 800 W, borne servie à 6 210 — 9 A par phase au lieu de 27, 4 kW au lieu des
11 que la voiture pouvait prendre.

Le plafond d'une borne porte DÉJÀ l'autorisation de soutirage :
evPhaseAllowanceW() la calcule et la multiplie par le nombre de phases
occupées. La passe confort ne fait pas cette faute, et son commentaire dit
pourquoi depuis 3g-2 — « mieux vaut la borne mieux informée que la borne la
plus sévère ». Je l'ai réécrite quinze lignes plus haut sans la relire : une
règle notée à un seul endroit du code n'est pas une règle.

L'erreur allait dans le sens PRUDENT, donc rien ne la signalait — ni le
disjoncteur, ni le délestage, ni un test qui vérifie qu'on ne dépasse pas. Elle
ne se voyait qu'en regardant la voiture.

LM-1500-g — ÉCRIRE UN MODE PENDANT UNE DÉROGATION LA TERMINE.

Choisir un mode est durable ; une dérogation répond à un besoin ponctuel. Le
durable prime, et il prime tout de suite. Et SANS restituer le mode mémorisé,
qui écraserait celui que l'utilisateur vient de choisir.

Le cas vient du banc : le bouton « boost » de l'app écrit encore
ChargingModeNormal. Reçu pendant une dérogation, il fait restituer les valeurs
manuelles par l'amont — chargingEnabled à FAUX — et arme un verrou de 300 s,
pendant que la dérogation continue de commander 9 A. Deux commandeurs pour un
organe, et six minutes de dérogation servie sur le papier dans une borne
éteinte. Seul le journal de divergence le voyait, sans pouvoir en dire la cause.

Deux autres corrections du même essai :

- DEROGATION_ACTIVE et DEROGATION_ENDED sont rendus en français au journal ;
  ils sortaient en « (motif inconnu …) ». Le repli 7-c a tenu — le code brut et
  ses paramètres, jamais une chaîne vide — mais la ligne lue par l'utilisateur
  était illisible.
- poseDerogation() prend l'instant en paramètre. Elle lisait l'horloge murale
  quand le cycle lit l'horloge injectée : sans effet en production, où les deux
  sont la même, mais sous test la fin par durée n'arrivait jamais. Trouvé par le
  test, pas par la lecture.

Et `setChargingInfo() déclenche un cycle` est gravé dans AGENTS.md comme une
PROPRIÉTÉ : ce qui l'entoure a un ordre qui compte, dans les deux sens, et
ENERGY_SIMULATION supprime précisément l'appel — le chemin fautif n'existe pas
sous test.

simulation 69/69, loadmodel 26/26, charging 48/48, spotmarket 32/32,
ci-quality 4/4, build 0 erreur 0 warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-08 11:43:39 +02:00
ee67b21c2d docs(dérogation): une cible qui cesse d'être mesurable termine la dérogation
LA QUESTION QUE NI L'UN NI L'AUTRE N'AVAIT POSÉE : que fait la dérogation ENTRE le
figement de sessionEnergy et l'échéance de durée ? Elle achète au maximum sur une
mesure morte, et sur un lien qui perd 40 % de ses trames « au bout » peut vouloir
dire des heures.

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

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

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

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

La durée devient le FILET DU FILET : la perte de mesure attrape le cas où
l'instrument se tait et se signale ; la durée attrape le cas où même le régime ne
se signale pas. Deux filets parce que le premier repose lui-même sur un instrument.
2026-09-08 06:15:34 +02:00
3c60c83427 docs: le boost est une DÉROGATION, pas un mode — design et règle
CADRAGE DE PATRICK, et il vaut au-delà du VE. Un mode se choisit une fois et vaut
jusqu'au prochain choix ; une dérogation répond à un besoin ponctuel et s'éteint
avec lui. Un boost encore actif après la fin est un mode qu'on a oublié de
quitter ; un boost qui rend la main est une dérogation terminée.

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

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

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

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

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

ET LA DURÉE EST UN FILET, jamais une option. Toute dérogation en porte une, même
avec une meilleure condition de fin, parce que cette condition repose sur un
INSTRUMENT et qu'un instrument peut se taire. Une cible qui ne remonte plus, une
sonde qui tombe : la dérogation ne finit jamais, en commandant le maximum. C'est
l'instrument dont le silence ressemble à un résultat, appliqué à une commande qui
achète. Donc jamais « jusqu'à annulation ».
2026-09-07 16:41:47 +02:00
89a5c06f7f fix: ancrer les quatre instants stockés à la capture, pas au rendu
DÉFAUT DE CLASSE, trouvé par l'audit demandé après la bascule de fuseau. Quatre
instants étaient stockés depuis QDateTime::currentDateTime(), donc en
spécification Qt::LocalTime : un tel objet ne garde que des composantes d'HORLOGE
MURALE et résout son époque au moment de la CONVERSION, avec le fuseau courant.

  m_baselinePosee        → GetEnergyRatios.measuredSince
  m_ecoMesureDepuis      → progress.measuredSince
  m_divergenceDepuis     → command.since
  m_lastTelemetryPublish → alimente le précédent

Mesuré sur la box le 2026-09-06, bascule Paris→London sans redémarrage :
measuredSince 1788645607 → 1788649207, +3600 s. Le champ posé pour rendre une
couture lisible était lui-même déplacé par la couture.

ET `.toUTC()` AU RENDU NE PROTÉGEAIT DE RIEN — c'est le piège, parce que les
quatre rendus l'appelaient déjà et avaient l'air corrects. Démontré par un banc Qt
avec bascule de /etc/localtime sous un objet déjà stocké :

  AVANT  stocké-local → 1788755728    stocké-UTC → 1788755728  (Europe/Paris)
  APRÈS  stocké-local → 1788759328    stocké-UTC → 1788755728  (Europe/London)

Correctif : `.toUTC()` À LA CAPTURE. periodStartedAt reste construit dans le
fuseau courant — c'est une frontière locale, elle DOIT bouger. Les deux champs
n'ont pas la même nature et le stockage le reflète maintenant.

TEST avec son oracle : l'ancrage tient sous re-rendu, la contre-épreuve reproduit
la dérive de +3600 s, et une quatrième assertion dit ce qui compte — le même
décalage est un DÉFAUT sur l'instant de pose et le COMPORTEMENT ATTENDU sur la
frontière. Le nombre ne dit pas lequel on regarde ; seule la nature du champ le
dit.

SIXIÈME FORME D'HOMOGÉNÉITÉ (AGENTS.md) : ce n'est plus le mock, c'est
L'ENVIRONNEMENT DE VÉRIFICATION. Box et téléphone en Europe/Paris pendant toute la
campagne : à décalage nul, un instant rendu en UTC, en heure box ou en heure
téléphone donne le MÊME TEXTE. Aucun défaut de conversion ne pouvait se voir, et
rien dans le code ne pouvait le signaler — la prémisse fausse n'est pas dans le
banc, elle est dans la coïncidence de deux réglages extérieurs.

D'où le geste : basculer le fuseau de temps en temps est un RÉVÉLATEUR, pas
seulement une manipulation de capture. Quinze minutes en Europe/London ont dénoncé
deux défauts, dont un qui avait traversé toute la campagne.

Suites : loadmodel 25/25 (+1), simulation 67/67, charging 48/48, spotmarket 32/32.
NON DÉPLOYÉ.
2026-09-07 06:44:05 +02:00
e1079760e7 docs: measuredSince dérive au changement de fuseau, et l'inertie saine
DÉFAUT MESURÉ, non corrigé. Bascule Europe/Paris → Europe/London à 13:37:26Z,
sans redémarrage de nymead :

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

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

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

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

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

Le test qui sépare : « qu'est-ce qui déciderait, si ce n'est pas ça ? » Inertie
malsaine, on ne sait pas nommer l'autre source. Inertie saine, on la nomme ET on
peut la perturber elle. D'où la conséquence pratique : une inertie saine n'est
acceptable que si la contre-épreuve est DÉPLACÉE sur la vraie source, jamais
abandonnée — sinon c'est refermer l'identité sur elle-même une fois de plus.
2026-09-06 15:41:06 +02:00
217f20b58f docs: le critère de tri de l'étape 4, écrit AVANT les données
CRITÈRE DE TRI (DESIGN_COMMANDE_PERDUE.md §6), daté et poussé avant l'ouverture de
la fenêtre d'observation. Un critère rédigé après coup se plie aux résultats sans
jamais mentir, et sans qu'on puisse le prouver. S'il doit changer, ce sera un
commit visible avec sa raison.

Deux conditions, toutes deux nécessaires : `measurement.source` != `none`, ET le
compteur doit avoir VARIÉ sur la fenêtre. Un compteur d'écart-type nul n'est pas
un compteur, c'est une constante déguisée, et tout écart mesuré contre lui est un
artefact.

PAS de tri par nom de charge. Écarter chauffe-eau et pac-terrain nommément serait
fragile deux fois : ça se périme au premier renommage, et ça n'attrape pas la
troisième charge à compteur fixe qu'on ajoutera sans y penser. Le critère porte sur
une propriété observable de la donnée.

LES FAUX POSITIFS DE LA V2C sont d'une autre nature, et il faut les séparer avant
de compter. Elle a un vrai compteur, donc elle passe le critère — mais chaque perte
de lien produit une divergence qui n'est pas une commande perdue : c'est une mesure
absente. « Elle n'obéit pas » et « on ne sait pas si elle obéit » sont deux
affirmations que l'écart seul confond.

C'est la distinction `none` contre l'absence, appliquée à un écart. Et le pire cas
est documenté : à la perte du lien la V2C publie currentPower = 0 (LM-1012-d), donc
le zéro est PUBLIÉ, pas absent, et rien dans l'objet `command` ne le distingue d'un
zéro réel. Il produit la divergence maximale — le faux positif qui ressemble trait
pour trait au cas cherché. Conséquence portée au lot : `command` devrait dire que
son observedW n'est pas fiable, comme progress.regime le fait pour l'avancement.

Le compte des écartés sera publié aussi : un tri dont on ne dit pas ce qu'il a jeté
n'est pas un tri, c'est une sélection.

CINQUIÈME FORME D'HOMOGÉNÉITÉ DU BANC (AGENTS.md + INVENTAIRE_MOCK.md), et elle
inverse le mécanisme. Les quatre précédentes MASQUENT un cas ; celle-ci en FABRIQUE
un faux. Un cas masqué se signale par un test qui ne mord pas ; un cas fabriqué se
signale par des données qui rassurent.

14 épisodes en 24 h, de quoi croire le phénomène fréquent et dimensionner un lot
dessus. L'instrument fonctionne, la donnée est réelle, le compte est juste — seule
la question à laquelle ces nombres répondent n'est pas celle qu'on croyait poser,
et un nombre n'annonce jamais de quelle question il est la réponse.

Manque noté au mock : aucun compteur dont la valeur SUIVE la commande de la charge
qu'il mesure. Tant que c'est le cas, aucun test ne peut exercer une charge qui
obéit, donc aucun ne peut distinguer l'obéissance de la désobéissance.
2026-09-05 16:54:38 +02:00
d08c82aabf docs(AGENTS): le miroir — toutes les prémisses vraies, la conclusion fausse
Le bloc voisin traite la conclusion juste posée sur un raisonnement faux. Celui-ci
est l'inverse, et il se lit encore moins bien : chaque prémisse vérifiable et
vraie, conclusion fausse quand même.

Le verrou de logEvent() existe, l'I/O SQLite existe, elle est bien dans le thread
appelant. Et pourtant la conclusion tombe : qCInfo ne passe pas par logEvent(), et
sur la cible le moteur de journal est Influx, qui empile et poste en asynchrone.
Le chemin coûteux existe et n'est jamais emprunté par ce qui était accusé de
l'emprunter.

RÈGLE : un chemin coûteux ne coûte que s'il est PARCOURU. La lecture établit le
mécanisme, jamais la conséquence ; passer de l'un à l'autre demande la fréquence,
et la fréquence se mesure. 2,1 ms par cycle de 60 s, soit 0,0035 %.

ET LA MESURE AUSSI PEUT PORTER À CÔTÉ. Le premier proxy, en Python, donnait 11 µs
par ligne ; la vraie valeur est 70. L'écart n'est pas du bruit : le proxy mesurait
l'écriture alors que le coût est dans le formatage — deux segments du même chemin,
et il tenait le mauvais. Une mesure ne vaut que si elle couvre le chemin entier,
dans les conditions réelles : banc Qt, vraie bibliothèque, vrai matériel.

Le bon résidu quand une piste tombe : le mécanisme reste documenté avec son
chiffre. Le jour où quelqu'un branchera logEvent() sur un signal fréquent, la
mesure d'aujourd'hui dira à quoi comparer. Une hypothèse écartée par la mesure
laisse une borne derrière elle ; une hypothèse jamais posée ne laisse rien.
2026-09-05 13:44:03 +02:00
9eef60fc35 docs(upstream): SIGSEGV symbolisé — l'hypothèse tombe, c'est un pointeur NUL
L'hypothèse « durée de vie du Logger vis-à-vis des événements postés » est FAUSSE.
Ce n'est pas un usage après libération : c'est un déréférencement de nullptr, et
la garde censée l'empêcher interroge un ensemble différent de celui qu'elle
protège. Correctif d'une ligne.

ÉTABLI, avec les symboles 1.15.2 (identifiants de build vérifiés) :
  · #0 libnymea.so.1 + 0x14ea24    → Logger::log(...), logger.cpp:55
  · #1 libnymea-core.so.1+0x1edae6 → la lambda de ThingManagerImplementation::
                                      executeAction connectée à finished
  · rdi = 0x0 au signal. Dans l'ABI SysV AMD64, rdi porte le premier argument,
    donc `this`. Logger::log a été appelée sur un pointeur NUL — un usage après
    libération donnerait une adresse quelconque, pas zéro.

LE MÉCANISME. La lambda fait `m_actionLoggers.value(clé)->log(...)`, et
QHash::value() rend nullptr sur une clé absente. Sa garde est
`thing->loggedActionTypeIds().contains(...)` — la liste DU THING, pas la TABLE
des journaliseurs. Au retrait d'un Thing, unregisterActionLogger vide la table
pendant que la liste du Thing reste intacte. La connexion prend `this` (le
ThingManager) pour contexte, donc un `finished` déjà posté survit au retrait :
garde vraie, table vide, nullptr déréférencé.

C'est notre défaut récurrent, dans du code amont : UN GARDE-FOU QUI INTERROGE LE
MAUVAIS OBJET. Il a l'air de protéger l'accès qui suit et n'a aucun rapport avec
lui — d'où son invisibilité à la relecture.

Et ce n'est pas limité aux tests : toute suppression de Thing pendant qu'une
action est en vol ouvre la même fenêtre en production, qu'une action longue
élargit.

SYMBOLISÉ SANS RIEN INSTALLER, et sans privilèges : `apt-get download` puis
`dpkg-deb -x`, et `addr2line` sur les fichiers extraits. Noté dans le rapport,
parce que c'est aussi la voie qui ne touche pas la machine. gdb, lui, ne peut pas
produire de pile ici : le binaire de test et le plugin ont été reconstruits depuis
le plantage, donc leurs identifiants ne correspondent plus au vidage. La
résolution par offset dans les bibliothèques reste valide, et suffit.

RÉSERVE ÉCRITE : les symboles sont ceux de 1.15.2, mais le code cité est lu dans
l'arbre 1.16.0, seule source locale. Les deux symboles s'y retrouvent à
l'identique ; la formulation exacte du correctif reste à vérifier sur la branche.

LEÇON GRAVÉE (AGENTS.md, à côté de la règle des identités) : entre deux instants
du MÊME fuseau, la soustraction rend la durée d'horloge murale, jamais la durée
réelle — donc un contrôle qui cherche les journées de 23 ou 25 h avec cette
arithmétique rend 24 h partout et conclut qu'elles n'existent pas. Trois de mes
contrôles s'y sont trompés dans ce lot, sur la question même que le champ existe
pour régler.

La parade générale est la vraie leçon : l'attendu d'un test ne se calcule pas avec
les primitives du code testé. L'oracle du test de frontière vient de hors Qt ; le
re-dériver avec QDateTime aurait refermé l'identité sur elle-même — la faute que
la règle voisine traque, retrouvée un cran plus bas, non plus dans l'assertion
mais dans la fabrication de sa valeur attendue.
2026-09-05 09:33:17 +02:00
be556ef207 docs: quatrième mode d'érosion, et la troisième nature d'heure murale
QUATRIÈME MODE D'ÉROSION — l'inverse du troisième. La règle immortelle survit
parce que sa garde la protège ; celle-ci meurt parce que le CHEMIN qui y menait
s'est raréfié, et c'est un CORRECTIF qui l'a raréfié.

Le taux constant (LM-1013-b-v) a été décidé le 2026-09-01 avec son chiffrage.
Puis sessionWh a été alimenté depuis le compteur de charge et `unmeasurable` est
passé de « la V2C, tous les jours » à trois cas matériels rares. La décision n'a
jamais été écrite, et plus personne ne passait devant pour s'en apercevoir.

Ce qui rend la classe traître : le correctif est un PROGRÈS. On ne se méfie pas
de ce qui améliore les choses. Réduire un régime dégradé à ses cas irréductibles
est exactement ce qu'il faut faire — et c'est ce qui a fait disparaître la
décision de la vue. Parade : quand un lot RÉDUIT la fréquence d'un chemin, lister
ce qui vivait dessus et n'a pas été fait. Une décision non écrite ne se périme pas
au rythme de sa pertinence mais au rythme de sa VISIBILITÉ, et les deux viennent
de se découpler.

UN CHIFFRE FAUX DANS UNE SPEC finit par servir de prémisse. Variante numérique du
raisonnement faux sous une conclusion juste — et elle se transmet plus facilement,
parce qu'un chiffre se cite et se recopie sans ses conditions. Le ×2,5 aurait
voyagé avec « 4 kWh » et sans « fenêtre de 2,5 h ». Règle : un chiffre porte les
conditions qui le produisent, ou il se refait avant d'être écrit ; et quand il
change, la spec garde les deux valeurs et la raison de l'écart.

« LENT PAR CONSTRUCTION » ET « FIGÉ » ONT LA MÊME FORME. Règle d'investigation,
pas un fait sur selfConsumptionRate — lui va bien : sur un cycle de surplus
complet il a parcouru 53,4 → 98,4 en 54 valeurs distinctes. La leçon n'est PAS
« observer plus longtemps » : ça aurait marché ici et ça échoue sur une grandeur
à cadence horaire. Le discriminant est la NATURE de la grandeur — instantanée,
cumulée sur une période, événementielle — et elle se lit dans le contrat, pas dans
le signal. Sans cette phrase, toute observation est trop courte ou trop longue
sans qu'on puisse le savoir.

TROISIÈME NATURE D'HEURE MURALE : la période de REPORTING. GetEnergyRatios dérive
ses Δ depuis minuit local. Elle suit l'installation, ce qui est le bon
comportement — mais elle repose sur la même prémisse non vérifiée que l'échéance
client : que le fuseau de la box SOIT celui de l'installation. Sur .75, le bilan
journalier de l'app se remet à zéro à 01:00 heure française, et rien ne le dit.

Ce que la troisième ajoute : la prémisse « la box est bien réglée » ne porte pas
seulement une saisie, elle porte des GRANDEURS PUBLIÉES. Une échéance mal
fuseau-horée se corrige d'un réglage ; un bilan journalier découpé au mauvais
endroit a déjà été lu, affiché, et peut-être facturé. La colonne « qui le
vérifie » de DESIGN_FUSEAU.md répond « personne » pour celle-là.
2026-09-05 07:39:15 +02:00
18e9c3abe4 docs: ouvrir le lot fuseau, et graver les deux masques empilés
LOT FUSEAU (docs/DESIGN_FUSEAU.md) — design avant code, comme pour le délestage
et le §10. Deux questions étaient posées ; les deux sont fermées par mesure.

1. Le fuseau vient-il de la box ou de nymea ? Des deux, et c'est le MÊME.
   `EnergyArbitrator::update()` prend `QDateTime::currentDateTime()`
   (energyarbitrator.cpp:329), `resolveDeadline()` hérite via `now.timeZone()`,
   et nymea ne fait que relayer `QTimeZone::systemTimeZoneId()`. Aucun réglage
   nymea ne peut dire autre chose que timedatectl : box et moteur ne peuvent pas
   diverger entre eux.

2. Mesuré dans le conteneur, avec un programme Qt6 pendant qu'on bascule
   /etc/localtime sous lui : un processus VIVANT suit le changement, sans
   redémarrage. Ce n'était pas acquis — la mise en cache du fuseau par processus
   est le comportement classique de la libc, et si Qt l'avait fait, corriger un
   fuseau n'aurait rien corrigé jusqu'au redéploiement suivant.

La vraie divergence est ailleurs, et elle est double. Entre l'INSTALLATION et le
CLIENT d'abord : `dailyDeadline` est une heure murale SANS fuseau, saisie sur un
téléphone qui a le sien, lue dans celui de la box — silencieuse des deux côtés,
chacun ayant raison dans son référentiel. Un fuseau implicite est une absence
qui signifie « le défaut s'applique », ce qu'on s'interdit partout ailleurs.

Et entre l'installation et le CALENDRIER RÉGLEMENTÉ, qui est le point neuf : une
plage d'heures creuses française est définie en Europe/Paris par le fournisseur,
pas « à l'heure locale de la box ». Deux natures d'heure murale, deux sources de
fuseau — l'habitude du client suit l'installation, le tarif suit le texte qui le
définit. Les confondre ne décale pas l'affichage : ça achète au mauvais moment.

DÉCISION : ni imposer, ni refuser — déclarer. Imposer serait affirmer un fuseau
qu'on ne sait pas vérifier. Refuser de fonctionner déplacerait un problème
d'affichage vers la sécurité, alors que L0..L4 raisonnent en durées et ne
dépendent d'aucun fuseau. Déclarer est la doctrine maison appliquée telle quelle.

DEUX MASQUES EMPILÉS (AGENTS.md, quatrième occurrence de la même famille). Le
mock plus homogène que la réalité avait une suite : l'environnement de
vérification plus indulgent que la réalité, deux fois dans la même chaîne de
build. Un artefact périmé couvrant un lien cassé, et le build vert couvrant les
deux. Un répertoire de travail accumule de l'état qui répond à la place du
dépôt — « est-ce que ça compile ? » n'a pas de valeur sans dire DEPUIS QUOI.
Seule la reconstruction dans un répertoire vierge les révèle, et le md5 identique
au binaire en service est la façon de fermer : il ne dit pas « ça compile », il
dit que le build reproductible produit exactement ce qui tourne.
2026-09-04 07:52:28 +02:00
bb12992513 docs(AGENTS): dater le paquet qui porte la grandeur, pas celui qui porte la fonction
Le 2026-09-04, l'étalement éco restait inobservable sur .75 alors que le moteur
était à jour (+etm53, posé la veille). La grandeur manquante — le cumul du
compteur ECS — appartient à nymea-plugin-generic-energy, jamais reconstruit
depuis le 2026-06-19.

La consigne de vérification en vigueur aurait RASSURÉ : `dpkg -l | grep
powersync-energy` répond « +etm53, tout va bien ». Elle interroge le paquet de
la fonction, et aurait imputé au moteur le silence d'un paquet qu'on ne lui
avait pas livré.

C'est un cran plus fin que le piège du 2026-09-03 : là il fallait vérifier la
version avant de conclure, ici il faut vérifier la version du BON paquet — et
savoir lequel suppose de demander à dpkg qui possède la ThingClass.

La ligne enregistrée interroge les deux encodages : une chaîne QStringLiteral
est stockée en UTF-16 et reste invisible à un `strings` nu — c'est ce qui avait
failli faire conclure à l'absence de champs pourtant publiés.

Corollaire noté au même endroit : le verrou anti-rétrogradation de deploy.sh
compare la version du MÊME nom de paquet. Un paquet ETM qui renomme celui qu'il
remplace se présente en « première installation » — aucune version comparée. La
même faille une couche plus bas : un garde-fou qui interroge le mauvais objet.
2026-09-04 07:13:43 +02:00
Patrick Schurig
c88ba6ad45 [upstream-fix] rootmeter: la tension est locale à chaque phase
`voltage` était déclarée une seule fois hors des trois blocs et mutée par
l'un d'eux : une phase sans tension déclarée était calculée avec celle d'une
autre.

Et le défaut n'était pas celui qu'on croyait — le mesurer l'a montré.
L'interface energymeter rend currentPhaseA et voltagePhaseA OBLIGATOIRES, B
et C optionnels : la branche « puissance ÷ tension » de A est morte par
construction. La fuite n'allait donc pas de A vers B, comme la note d'origine
l'affirmait, mais de B vers C.

Démontré : B à 200 V, C à 4 000 W sans tension déclarée → 5,0 A de marge au
lieu de 7,6. Deux conditions, dont aucune n'était évidente avant d'écrire le
test : le masque doit couvrir B ET C — interroger C seule n'exécute pas le
bloc de B — et C doit être la phase la plus contrainte, sinon le qMin retient
B et masque l'écart. Mes deux premières versions du test passaient pour ces
deux raisons.

Le mock ne savait pas produire le cas : il a fallu meterPowerOnly, avec les
puissances et la tension de B mais sans les courants de B/C ni la tension de
C. Troisième fois qu'ouvrir le mock révèle un défaut plutôt que de seulement
débloquer un test.

draw.bindingPhase dit LAQUELLE des phases borne. perPhaseBound disait qu'une
phase borne, jamais laquelle : l'écran ne pouvait dire que « bridé » sans
dire par quoi. DRAW_CAP la porte aussi mais n'est émis que si une charge est
refusée — un cycle qui borde sans rien refuser laisserait l'écran muet.

Et dans AGENTS.md : une conclusion juste posée sur un raisonnement faux
survit jusqu'à ce qu'on bâtisse dessus, parce que la conclusion PROTÈGE le
raisonnement de l'examen. Un résultat faux se voit ; un résultat juste ferme
la question. Parade : quand une décision est écrite avec sa raison, la raison
mérite son propre contrôle — c'est elle que le lot suivant utilisera.

Suite : simulation 65/65, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-04 06:02:17 +02:00
Patrick Schurig
6092663647 tools: un garde-fou à l'écriture pour le piège d'insertion doxygen
Le même geste — un bloc glissé entre un commentaire et sa déclaration, si
bien que la voisine hérite de \param qu'elle n'a pas — a été commis quatre
fois en une semaine, à attention soutenue, et rattrapé quatre fois par le
même contrôle.

Un outil qui attrape quatre fois la même erreur chez quelqu'un d'attentif ne
signale pas un défaut d'attention : il signale que le geste est piégeux. Le
contrôle rattrape après coup ; celui-ci agit à l'écriture.

inserer-doc.py localise la déclaration, REMONTE au-dessus du bloc de
documentation qui lui est déjà attaché, et insère là — le bloc existant reste
collé à sa propre déclaration. Éprouvé sur le cas réel : il a détecté la doc
de qualifierRefus, prévenu qu'il insérait au-dessus d'elle, et l'a laissée
attachée. L'erreur devient inexprimable au lieu d'être rattrapée.

La règle générale est dans AGENTS.md : compter les récidives d'un même
contrôle. Une fois est une inattention, quatre fois est une conception à
revoir — et la question n'est plus « comment mieux faire attention ? » mais
« comment rendre l'erreur impossible à commettre ? ».

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-03 07:54:24 +02:00
Patrick Schurig
9c5e3b146c fix: la contradiction progressMeasurable/regime, et la garde qui la rend sûre
Le premier dit ce que le MATÉRIEL permet, le second ce que le MOTEUR fait.
Seul l'EvAdapter remplissait sessionMeasurable, donc sur toute charge non-VE
le premier disait oui et le second non — et l'avertissement de saisie de
l'app, qui se déclenche sur progressMeasurable, restait muet exactement sur
les charges achetées à l'aveugle. sessionWh est alimenté depuis le compteur
désigné.

L'avertissement de l'app est arrivé AVANT que j'écrive la garde, et il était
décisif. Un compteur qui disparaît peut publier 0 au lieu de se taire : la
V2C a forgé un zéro sur currentPower et ses booléens, mais laissé
sessionEnergy à sa dernière valeur. La politique dépend du plugin — donc
inutilisable comme garde.

Et le point à vérifier avait une réponse nette : il n'existe AUCUN signal de
joignabilité fiable. setupComplete parle du setup et non du lien ; connected
est maintenu par le plugin, la V2C basculant après trois échecs quand un
compteur générique peut ne jamais basculer. La garde ne pouvait pas reposer
là-dessus.

Elle porte donc sur la SÉMANTIQUE du compteur, seule chose que le moteur
sache sans parier : sur un compteur de session un recul est une session
neuve, sur un compteur de vie c'est une anomalie — on gèle, on ne recapture
jamais. Sans elle, le scénario chiffré sort tel quel : base reprise à 0
pendant l'absence, retour avec 10 kWh réels, deliveredWh à 10 000 en un
cycle, obligation déclarée tenue sans rien de livré. Et R6 ne l'attrape pas :
elle n'interdit ECO_FLOOR_MET que sous unmeasurable, alors que ce chemin est
MESURÉ — c'est le seul par lequel un faux « tenue » peut sortir, et c'est
celui qu'ouvrait l'alimentation depuis un compteur.

audit-controles.py dit maintenant en tête qu'il ne tranche pas et ne doit
jamais trancher : neuf des dix identités relevées contrôlaient réellement, et
un verdict automatique aurait supprimé neuf contrôles utiles.

AGENTS.md : la diversité du mock n'est pas un confort de test, c'est une
borne supérieure sur ce que la suite peut prouver — elle se fixe en écrivant
les classes, pas les tests.

Quatrième occurrence du piège d'insertion doxygen, rattrapée par le même
contrôle. Comme annoncé.

Suite : simulation 63/63, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-03 07:21:13 +02:00
Patrick Schurig
cdd3bc88f3 test(mock): le relais sec, et le cas que le banc ne savait pas produire
La classe `dryRelay` commute et ne mesure pas. Elle ouvre le cas que
l'inventaire à froid avait identifié comme structurellement introuvable : une
charge SAINE et NON MESURÉE. Jusqu'ici `measurement.source: none` n'était
atteignable qu'avec un Thing manquant — ce qui traîne `available: false` et
mélange deux affirmations distinctes, « rien ne mesure cette charge » et
« cette charge est en panne ». Un relais sec est banal sur le terrain, et
parfaitement sain.

Le test vérifie ce que rien ne pouvait vérifier avant : deux charges, même
source: none, available opposés, faultCode sur la seule en défaut. Il
n'aurait pas pu s'écrire hier.

Et l'ajout a révélé que le mock avait SA PROPRE prémisse fausse :
executeAction() déréférençait le contrôleur HTTP de chaque Thing sans
vérifier, supposant que toute classe en possède un. Un relais sec n'en a pas
besoin. Le plantage fut immédiat et net ; l'hypothèse, elle, était invisible
tant qu'aucune classe ne la contredisait — l'homogénéité du mock la
protégeait, exactement comme elle masquait les cas qu'il ne savait pas
produire. Ajouter de la diversité au banc n'a pas seulement ouvert un test :
ça a trouvé un défaut.

AGENTS.md reçoit la règle générale sur les identités : une identité dont les
deux termes viennent de la même source ne vérifie rien. Elle est vraie par
construction et rassure là où rien ne protège — la question n'est pas
« tient-elle ? » mais « de quoi parle-t-elle vraiment ? ». Et c'est le même
défaut que la contre-épreuve qui ne mord pas, vu de l'autre côté : perturber
une grandeur qui ne pouvait rien changer, et vérifier une identité qui se
referme sur elle-même, sont deux gestes de vérification qui ne touchent rien.
Le réflexe commun : demander ce que ce contrôle aurait attrapé s'il avait
échoué. S'il n'y a pas de réponse, il ne contrôle rien.

Suite : simulation 62/62, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-02 08:00:51 +02:00
Patrick Schurig
ccf05cea87 feat(R6): la mesurabilité se déclare à la saisie, et l'inventaire du mock
Le signal vit dans GetLoadConfig, en r:, et non dans la télémétrie.
`measurement` n'existe que pour les charges arbitrées ; or une charge qu'on
DÉCLARE est précisément celle qui n'y est pas encore. L'écran serait muet au
moment exact où il doit parler — celui de la saisie, avant qu'une obligation
n'achète à l'aveugle tous les jours.

Trois causes, trois gestes : noMeter se répare en une manipulation,
meterWithoutEnergy demande de remplacer le compteur, noSessionEnergy ne se
répare pas. Sans la cause, l'avertissement ne peut dire que « ça ne marchera
pas ».

Et meterThingId ne permet pas de le déduire : un compteur peut publier
currentPower sans totalEnergyConsumed — mesuré sur l'ECS du banc, reproduit
au test avec un powerSwitch désigné comme compteur. La puissance ne suffit
pas, il faut une énergie. Contre-épreuve : déduction par meterThingId seul,
le cas du banc passe pour mesurable.

INVENTAIRE À FROID DU MOCK. Trois fois en trois jours qu'un cas réel s'est
révélé inatteignable ; ce n'est plus un incident, c'est une propriété du banc
— il est plus homogène que la réalité, ayant été construit pour faire marcher
des scénarios et non pour représenter la diversité du parc.

Quatre cas restent structurellement introuvables, dont `source: none` pour
une charge SAINE — toutes les classes pilotables portent une puissance, si
bien qu'une charge non mesurée mais en bon état est impossible à fabriquer,
alors qu'un relais sec est banal sur le terrain. Et la branche `temperature`,
écrite et jamais exécutée faute d'une classe qui porte l'état.

La règle : avant d'écrire un test qui exerce une ABSENCE, vérifier que le
mock peut la produire. Une assertion sur un cas inatteignable ne passe pas,
elle NE S'EXÉCUTE PAS — ce qui est pire, car elle compte comme verte. Le
symptôme se reconnaît : la contre-épreuve ne mord pas.

Deux ajouts dans AGENTS.md. L'asymétrie des latences est une PROPRIÉTÉ, pas
une chance : le refus qui coûte de l'argent est celui de la descente, et
c'est le plus vite constaté — ce qui n'est vrai que parce que le seuil vient
du mécanisme. Et la question opératoire appliquée aux trois autres identités,
avec un résultat inégal qui est le but : celle de `draw` repose sur le fait
que le compteur voie toute la maison, ce que rien ne vérifie ;
Σ levels[].targetW == estimatedPowerW est interne au plan et ne dit rien du
matériel — il a fallu un champ hors de la formule pour relier les deux.

Suite : simulation 61/61, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-01 17:27:56 +02:00
Patrick Schurig
2c4d04a53f feat: le recrédit est conditionné à l'obéissance constatée
Le recrédit rendait au budget la conso d'une charge sur l'hypothèse qu'on
pourra la lui reprendre. Contre un refus établi, les watts prêtés étaient
réalloués aux suivantes pendant que la charge continuait de les tirer :
4 000 W alloués pour 500 W de surplus réel. Suspension faite, la charge
suivante ne reçoit plus que les 500 W qui existent.

Le seuil vient du MÉCANISME, jamais d'une constante. Un écart récent est une
latence — une charge qui vient d'être commandée n'a pas atteint sa consigne,
et suspendre là ferait osciller ce qui va bien. Latences mesurées sur la
V2C : montée ~30 s, descente ~7 s. L'asymétrie joue dans le bon sens, le
refus qui coûte de l'argent étant celui de la descente. Défaut prudent à
60 s : l'oscillation d'une charge saine est un coût certain, le refus n'est
qu'un risque.

Et ça se dit — RECREDIT_SUSPENDED, avec depuis quand et combien de watts
retenus. Une suspension change l'allocation de toutes les charges suivantes ;
sans le motif, on chercherait un défaut de cascade là où une charge n'obéit
pas.

Et une cinquième classe dans AGENTS.md, la seule qu'aucun contrôle ne pouvait
attraper : une PRÉMISSE FAUSSE SOUS UNE ARITHMÉTIQUE EXACTE. Les quatre
autres se laissent prendre par un instrument — un champ qui ment, un retour
indiscernable de son contraire, un instrument muet, une assertion vacante.
Celle-ci n'a aucune trace : l'identité publiée tient exactement, que la
charge obéisse ou non.

La parade tient en une phrase : une identité qui tient ne prouve pas que le
modèle est juste, seulement qu'il est cohérent. Ce qu'une identité ne peut
jamais attraper, c'est une hypothèse sur le monde. D'où la question à poser à
chaque identité publiée : sur quoi repose-t-elle qui ne soit pas dans ses
propres termes ? Ici, sur l'obéissance — grandeur absente de la formule,
qu'il a fallu aller mesurer ailleurs pour la rendre vérifiable.

Suite : simulation 60/60, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement. Contre-épreuve :
suspension débranchée, le recrédit revient à 4 400 W.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-01 16:55:47 +02:00
Patrick Schurig
770cef13f3 feat: purchasedWh dans tous les régimes, et measuredSince
Deux demandes de l'app, toutes deux fondées.

purchasedWh est publié même sous `unmeasurable`, et ce n'est pas une
entorse à la règle qui y interdit deliveredWh — c'en est le complément. Ce
qui est ACHETÉ est un fait connu sans mesure : c'est notre propre commande.
Ce qui est LIVRÉ est une observation qu'on ne peut pas faire. Sans lui, « ce
que ça coûte par jour » redeviendrait une estimation fabriquée à partir du
taux.

C'est un cumul sur la fenêtre d'obligation, donc LM-1014-b s'applique : remis
à zéro à la bascule, comme la base. Le premier cycle d'une période ne
contribue rien — il n'a pas de durée derrière lui, et en inventer une serait
fabriquer de l'énergie.

measuredSince ferme un piège d'affichage. Quand une obligation bascule de
unmeasurable à measured EN COURS de période, deliveredWh repart de 0 pendant
que targetWh reste la cible entière : une barre afficherait « 0,5 sur 4,0 »
après une bascule où 3 kWh ont pu être achetés. Rebaser targetWh serait pire
— on perdrait l'obligation réelle, et l'écran ne pourrait plus dire ce qui
reste dû. Le champ prend la seconde sortie : « mesuré depuis 09:14 », et
purchasedWh couvre l'avant. Les deux se répondent.

Et un piège de build noté dans AGENTS.md : changer la DISPOSITION d'une
structure dans un en-tête inclus transitivement laisse des objets compilés
contre l'ancienne, et le symptôme est un plantage dans un DESTRUCTEUR — qui
ressemble en tout point à un défaut mémoire du code qu'on vient d'écrire.
Avant de chercher le bug : make clean. Le plantage n'avait jamais existé.

Suite : simulation 59/59, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-01 11:48:11 +02:00
Patrick Schurig
19b20d930f feat(commande perdue §2): l'écart se publie, gouverné par la source
`command` publie expectedW, observedW, divergent et since — seulement quand
une source mesure et qu'une commande est partie. Absent sous `none` : il n'y
a rien à comparer, et `divergent: false` affirmerait une concordance qu'on ne
peut pas constater.

L'asymétrie va dans les DEUX sens, et le contrat la dit maintenant en entier.
Sous `device`, une concordance ne prouve rien — plusieurs bornes renvoient
leur consigne en guise de puissance — mais une divergence dit que la charge
n'obéit pas. Sous `meter`, c'est l'inverse. Le test le démontre au lieu de
l'énoncer : la borne du mock dérive sa puissance de sa consigne, donc
expectedW == observedW par construction, et la divergence a dû être exercée
par un compteur explicite.

Un écart n'est pas un défaut, et le champ ne le qualifie pas. Véhicule qui
limite, câble en 16 A, mode manuel : le même nombre qu'une commande perdue.
La cause n'est pas dans les watts.

`since` est ce qui rend le champ utile — sans lui, une divergence permanente
ne se distingue pas d'un déphasage d'un cycle, et c'est toute la différence
entre le normal et le cas relevé.

Et une leçon dans AGENTS.md, appliquée à moi-même dans le même lot : une
contre-épreuve qui NE MORD PAS ne prouve pas que le code est bon, elle prouve
qu'on a perturbé au mauvais endroit. Mon assertion « sans mesure, pas de
command » était VACANTE — aucune charge du test n'avait source: none, la
boucle ne s'exécutait jamais, et la garde retirée le test passait toujours.
Toutes les classes du mock portent un état de puissance ; il a fallu une
charge au câblage manquant pour produire le cas. La contre-épreuve mord
désormais.

Suite : simulation 59/59, loadmodel 22/22, charging 48/48 identique à la
référence ligne à ligne, spotmarket 32/32, doxygen 0 avertissement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-01 07:57:40 +02:00
Patrick Schurig
2a584de913 docs: les quatre décisions du §10, et un troisième mode d'érosion
11.1 étalement uniforme, avec les trois choses qui s'écrivent avec lui :
recalculé à chaque cycle (une cadence figée ne rattrape pas et manque
l'échéance en ayant l'air de travailler) ; il se durcit à l'approche —
2 kWh font 2 kW à une heure et 12 kW à dix minutes, donc la fenêtre d'achat
massif arrive d'un coup et c'est le MOTIF qui doit le dire ; et un point de
non-retour explicite, parce qu'un seuil nommé se discute là où une asymptote
se subit.

11.2 oui aux quatre champs, et la subtilité entre dans le CONTRAT : sous la
réserve batterie, draw.remainingW reste non nul pendant que budget.remainingW
est à zéro. LM-1203-b vue depuis la frontière — la réserve annule le surplus,
jamais l'autorisation de soutirage.

11.3 dissoute, 11.4 par champ explicite.

Et un troisième mode d'érosion dans AGENTS.md, symétrique des deux autres :
non pas une règle qui se périme faute d'être exécutable, mais une règle rendue
IMMORTELLE par sa propre garde. draw.authorisedW a été refusé pour un motif
juste, épinglé par un test, puis le motif est tombé — et la formulation a
survécu, protégée par un test qui ne savait pas que le monde avait changé.
Parade déjà pratiquée ici : quand un test fige une ABSENCE, son commentaire
écrit la condition de levée. « Absent tant que rien ne le mesure » se relit et
se lève ; « ne doit pas exister » se relit et s'obéit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-30 22:38:21 +02:00
Patrick Schurig
eba874f735 docs(AGENTS): le versant contrat des garde-fous, et une règle ne se propage pas
Deux ajouts qui sortent d'ECS-415, à côté de la règle des garde-fous.

Le versant CONTRAT : un contrat qu'aucun appelant n'exerce n'est pas un
contrat tenu, c'est un contrat non testé. Le garde-fou dit « une règle que
rien ne rejoue se périme » ; le contrat dit « une garantie que personne ne
consomme n'a jamais été vérifiée ». D'où la conduite : avant d'exploiter une
valeur de retour qu'aucun code n'exploitait, l'épingler d'abord par un test.

Le fait de méthode : la règle d'ECS-415 était déjà écrite dans le
commentaire de la branche VOISINE, à trois lignes de la branche fautive, et
elle ne s'était pas propagée. Une règle écrite à un endroit ne se propage
pas toute seule — c'est ce qui justifie de balayer les autres implantations
du même contrat plutôt que de corriger localement. Corollaire de relecture :
la proximité d'un bon commentaire n'est pas une preuve que le code voisin le
respecte.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-30 10:25:34 +02:00
Patrick Schurig
c58ab9663a docs: la route de push réelle, et ce qui distingue ce qui tient de ce qui s'érode
CARTOGRAPHIE DES REMOTES CORRIGÉE, et elle était fausse sur deux points dont un
bloquait tout push. `gitea-lan` = 192.168.1.113 est la SEULE route SSH — le port
22 de git.etm-powersync.fr est filtré — et le même Gitea sert les deux noms :
origin (HTTPS, qui n'authentifie pas) et origin-ssh désignent LE MÊME dépôt.

Surtout : « gitea-lan = miroir public GPL, ne jamais y pousser » était faux. Le
remote etm-public pointe sur powersync-energy-plugin-etm.git — sans le préfixe
etm- — et ce dépôt N'EXISTE PAS : Gitea répond « Cannot find repository ». Un
push y aurait échoué, pas publié. La prudence était donc fondée sur une
cartographie inexacte, et elle interdisait la seule route disponible.

Reste vrai : la publication du miroir public est un geste manuel de Patrick. Et
la vérification d'ascendance avant push est consignée comme obligatoire — c'est
ce qui distingue une avance rapide d'un écrasement.

61 commits poussés vers origin-ssh, ascendance vérifiée avant et relecture après :
plus rien en local seulement.

ET LA LEÇON DE LA SEMAINE, en section propre : la différence entre ce qui tient
et ce qui s'érode n'a jamais été la qualité de l'argument, c'est de savoir s'il
existe une machine qui le rejoue. Six cas de la semaine en regard — la table
d'état qui déclarait absents trois mécanismes testés, la table de traçabilité qui
nommait huit tests inexistants, la promesse de recréation jamais exercée,
ECS-111 décrit et jamais corrigé, l'absence de draw.authorisedW défendue en prose
à trois endroits — contre ce qu'un générateur, un test ou un refus RPC a tenu.

Avec son corollaire, éprouvé lui aussi : un garde-fou qu'on n'a jamais vu échouer
ne protège rien. Tous les tests d'invariant de la semaine ont été vérifiés
échouant avant d'être déclarés prêts.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-30 08:18:27 +02:00
Patrick Schurig
b6a6c7eebc docs: LM-1011 dans la spec, les trois nombres au contrat, R3 proposée
TROIS choses, plus une sauvegarde.

1. LM-1011 — la précédence BELOW_MIN_POWER / DRAW_CAP est dans la SPEC. Je
   l'avais écrite hier dans DESIGN_10.md : une note de conception se périme, la
   spec fait autorité, et l'index ne voyait donc pas la règle. DESIGN_10 n'en
   garde qu'un renvoi.

   Autorisation résiduelle non nulle mais sous le plus petit palier →
   BELOW_MIN_POWER ; DRAW_CAP réservé au résiduel nul. Ce n'est pas une règle
   nouvelle : c'est celle de +etm19 transposée à une seconde ressource — le
   surplus a déjà SURPLUS_INSUFFICIENT (« il n'y a rien ») contre BELOW_MIN_POWER
   (« il y en a, mais pas au bon format »), et il aurait été absurde qu'une
   ressource se lise autrement que l'autre. Ce qui décide n'est pas la véracité
   mais le GESTE appelé : revoir le plafond, ou lire la fiche technique.

   Conséquence de l'agent app retenue : le point de rupture de l'écran 2 est
   ancré sur le DRAW_CAP, donc sur l'épuisement réel. Une charge refusée APRÈS
   lui pour cause de palier ne le déplace pas — sans quoi deux charges
   successives produiraient deux points de rupture concurrents dans un cycle.

2. LES TROIS NOMBRES au contrat — décidé, appliqué, mesuré. Ma correction sur
   allocatedW a fait apparaître un troisième nombre que ni la maquette ni nous
   n'avions nommé. INTERFACE.md porte le tableau ET ce que chaque écart signifie,
   parce que c'est là qu'un écran se trompe de diagnostic : décidé ≠ appliqué est
   une affaire de FORMAT, appliqué ≠ mesuré une affaire d'OBÉISSANCE — et cette
   seconde lecture dépend encore de measurement.source.

3. R3 proposée à l'app avant d'être écrite : funding dit d'où viennent les watts,
   il ne dit pas dans quel compteur ils tombent. Un niveau publierait sa
   ventilation (counts), Σ counts == targetW toujours, et l'identité devient
   calculable SANS exception — y compris quand draw.committedW arrivera.

Et la PRÉCONDITION du §10 est consignée : il ne part pas sans l'extension du
délestage au waterfall (LM-1006 couplage 1). La protection de surcharge ne
déleste aujourd'hui que les bornes ; livrer l'éco sans elle donnerait un plancher
capable de faire disjoncter une installation.

Aucun code. ci-quality 3/3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-30 08:12:26 +02:00
Patrick Schurig
d1666143f7 docs(§10): débloqué — la maquette des deux passes fait contrat
L'agent app a répondu au §9. Décisions inscrites en LM-1010, précédence des
motifs et réponse à R4 en DESIGN_10.md, réponse à l'app en brief §10.

SON ARGUMENT SUR LA LISTE EST MEILLEUR QUE LE NÔTRE, et il est repris tel quel :
nous disions « le rang doit rester lisible », il dit que l'ORDRE DE SERVICE N'EST
PAS UNE PROPRIÉTÉ DE LA CONFIGURATION — il change d'un cycle à l'autre selon les
planchers atteints. Trier par lui ne rendrait pas le rang illisible, ça rendrait
la LISTE instable, ce qui est pire. Une liste de configuration ne peut pas avoir
une colonne vertébrale qui bouge sous le doigt qui vient d'y glisser une charge.

Les deux autres réponses : une entrée par charge et deux lignes dans la carte —
on dédouble là où l'objet affiché est la SÉQUENCE, jamais là où il est la CHARGE ;
et éco servi / confort non en deux ÉTATS NOMMÉS, jamais une fraction, sans quoi
le régime nominal se lirait comme une dégradation permanente.

PRÉCÉDENCE DRAW_CAP ↔ BELOW_MIN_POWER, tranchée et écrite ICI plutôt que de
chaque côté de la frontière. Autorisation résiduelle non nulle mais sous le plus
petit palier : BELOW_MIN_POWER. DRAW_CAP dit « il n'y a plus d'autorisation », or
il en reste — pas au bon format. C'est la distinction de +etm19 entre
SURPLUS_INSUFFICIENT et BELOW_MIN_POWER, transposée telle quelle au soutirage :
pas une règle nouvelle, la même appliquée à une seconde ressource. Ce qui la
décide est le GESTE qu'elle appelle — revoir le plafond, ou lire la fiche
technique.

RÉPONSE À R4 : les compteurs de budget comptent le DÉCIDÉ. Le budget est le
registre de la cascade, et ce qui détermine ce que reçoit la charge suivante est
la valeur décidée. L'arrondi vit après, dans l'adaptateur, propre à chaque
mécanisme — compter le commandé obligerait à rouvrir le budget après le dispatch,
c'est-à-dire à décider deux fois.

ET UNE PRÉMISSE DE R4 EST FAUSSE, mieux vaut le dire maintenant : la maquette
pose que allocatedW publie « ce qui a été commandé ». buildTelemetry() publie le
PLAN, et applyActionsToAdapters() reçoit le slot const — rien n'est réécrit après
le dispatch. Mesurable : testUnpluggedChargerTakesNoBudget attend allocatedW ==
3000 sur une borne dont le courant commandé donne 2 990 W. Donc Σ targetW ==
allocatedW tiendra exactement, et l'écart que l'écran veut nommer est entre
allocatedW (décidé) et la charge utile de MÉCANISME (appliqué) — déjà publiée.
L'app a les deux nombres ; leurs noms étaient inversés.

PROCHAINE ACTION DU §10 : R2, funding descend au niveau. Seule des huit à être un
changement de forme d'un champ existant ; les sept autres attendent sans se
renchérir.

Aucun code. ci-quality 3/3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-29 15:46:41 +02:00
Patrick Schurig
2a7bb1cce9 fix(ECS-111): un relais disparu fige la charge au lieu de publier un fantôme
Le comportement était « non défini » depuis l'écriture de la spec. telemetry()
SAUTAIT un relais qui ne résout plus : ou bien plus aucun relais actif ne
mesurait et l'on retombait sur le nominal du PALIER ENTIER — part du fantôme
comprise — ou bien des survivants mesuraient et l'on publiait une somme
sous-évaluée.

Et ce n'était pas de l'affichage : telemetry().currentPowerW alimente le recrédit
anti-clignotement, donc le BUDGET. L'arbitre se trompait sur le surplus
disponible pendant qu'available restait vrai. Un mensonge qui décide, pas un
mensonge qui s'affiche.

Option (a), la seule cohérente avec la famille : available faux, faultCode
THING_MISSING, traitement ECS-410 — sortie de l'ARBITRAGE, maintien dans la
COMPTABILITÉ, figée à la puissance crue engagée. La puissance est donc RETIRÉE du
budget des suivantes au lieu de leur être recréditée. Aucune somme partielle.

(b) — compter 0 W et servir le reste — est écartée : un relais absent du registre
est un Thing supprimé ou un plugin qui ne le voit plus, on ne sait pas dans quel
état est le contact, et servir le reste reviendrait à supposer.

Jugé sur TOUS les relais déclarés, jamais sur ceux du palier courant — sinon la
disponibilité basculerait au rythme du thermostat. Même règle que
deviceMeasurement(), qui traitait DÉJÀ le cas correctement : aligner l'ancien sur
le neuf, pas inventer une politique.

THING_MISSING prime sur WRITE_FAILED : un relais absent EXPLIQUE les écritures
qui échouent, et annoncer WRITE_FAILED enverrait l'opérateur au bus quand la
cause est dans la configuration. D'où le corollaire, écrit dans la doc de
clearFault : ClearLoadFault lève le défaut d'ÉCRITURE, il ne fait pas exister un
Thing.

testEcsPartialFailure a dû être ajusté et c'est instructif : il utilisait un
relais JAMAIS CONFIGURÉ comme raccourci pour forcer un échec d'écriture. Le
raccourci a maintenant un sens propre, et le test distingue les deux causes — le
défaut collant se lève, le câblage manquant non.

Trouvé ni par un test ni par le banc, mais par la LECTURE d'une règle que l'index
a rendue visible. Consigné comme tel dans spec_ecs.md et AGENTS.md : c'est le
premier défaut réel exhumé par le chantier de documentation.

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 13:27:25 +02:00
Patrick Schurig
909cfc5c1e docs(§10): 8.2 et 8.4 tranchés, 8.5 posée à l'agent app
Quatre des cinq points du §10 sont désormais tranchés. 8.2 : le plafond §14a
porte les deux formes — par phase et au total — sans jamais convertir. 8.4 : la
cible de confort reste un comparateur, le dosage attendant le modèle thermique du
§11. Confirmations, rien de rouvert.

8.5 reste, et il ne se tranche pas ici : c'est l'ordre de service APPARENT. Un
plancher éco de rang 4 passe devant un confort de rang 1 — voulu, lexicographique
sur (niveau, rang), et contre-intuitif pour l'installateur qui vient de poser son
ordre. La question est POSÉE à l'agent app dans le brief §9, pas répondue.

Trois choses lui sont données pour décider : sur quoi la liste est ordonnée — par
rang, et l'ordre affiché n'est plus l'ordre de service, ce qu'il faut dire ; ou
par ordre de service, et le rang cesse d'être lisible. Une entrée par passe ou
une entrée à deux états. Et ce qu'on voit d'une charge dont l'éco est servi et le
confort non — le cas le plus fréquent, et le seul qui n'ait AUCUN équivalent
aujourd'hui : la télémétrie publie un motif et une allocation, un seul de chaque.

Ce qui lui est demandé est de dire quelle FORME DE PUBLICATION rendrait l'écran
possible, comme la maquette des mécanismes l'a fait pour les six écrans — pas de
câbler quoi que ce soit, le §10 n'étant pas implémenté. Deux garde-fous joints
pour que la réponse ne se ferme pas sur un détail : une charge reçoit une SEULE
commande par cycle quelles que soient les passes (LM-1003), et le rang reste UN —
pas de prioritéEco/prioritéConfort, rejet explicite de LM-1002.

Et la conséquence est écrite des deux côtés : le §10 ne se code pas avant cette
réponse. Commencer par le moteur reviendrait à inventer une charge utile puis à
demander à l'écran de s'en arranger, c'est-à-dire l'ordre inverse de celui qui a
marché.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-29 12:50:01 +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
1490183ac5 docs: l'écho verbatim passe sur marque fraîche — et deux corrections mesurées
VERDICT SUR L'ÉCHO, sur une marque FRAÎCHE et sans rien déplacer avant : écho
verbatim inchangé -> EnergyErrorNoError, configuration identique, marque
survivante ; écho verbatim + un renommage ailleurs -> EnergyErrorNoError. Nos
deux relevés contradictoires s'expliquent : le premier essai suivait le
déplacement qui avait fait tomber la marque, l'app écrivait donc un "auto" que le
persisté ne portait plus — cas de l'écho périmé, refus correct.
sansRankOriginAuto() peut partir ; relire avant de réécrire le remplace.

CORRECTION LOURDE — le brief promettait à l'app l'inverse de la vérité.
« Supprimer l'entrée d'une borne dont le Thing existe encore la fait recréer au
cycle suivant » est FAUX : mesuré sur .75, entrée retirée, Thing vivant, rien
recréé en 140 s. syncAdapters() n'appelle provisionEvChargerConfig() que dans la
branche !m_adapters.contains(id), et l'adaptateur est construit depuis le Thing —
il survit à la suppression de l'entrée.

Ce que ça produit est le trou de 3g-2 rouvert par le côté client : la borne reste
ARBITRÉE mais ABSENTE de GetLoadConfig, à un rang de queue non réglable
(evPriority() → 1000). Il ne se referme qu'au redémarrage de nymead. Un correctif
est proposé — reprovisionner toute borne sans entrée, pas seulement à la
naissance de l'adaptateur — et NON écrit : c'est un changement de comportement,
la décision appartient à Patrick.

LM-1209-d — le rang est un ordre TOTAL, les domaines ne le partitionnent pas.
L'insertion en queue entrelace les domaines ; la contiguïté n'a jamais été un
invariant et rien dans le moteur n'en a besoin. L'exiger serait CONTRADICTOIRE
avec la raison même de LM-1209 : insérer une charge heating en respectant la
contiguïté décalerait toutes les ev suivantes, soit une détection qui réordonne
une installation qu'un humain a classée. Le même raisonnement vaut côté client :
grouper à l'affichage oui, à l'écriture non — et la note d'entrelacement ne
devrait pas être un avertissement, une liste entrelacée n'ayant rien
d'incohérent.

UN REFUS ARRIVE EN status: "success" — le verdict est dans params.energyError.
Porté en tête d'INTERFACE.md avec l'extrait qui va bien : une sonde qui teste
status annonce « accepté » sur une écriture rejetée, et une sonde qui se trompe
de champ produit un relevé qui a l'air d'un résultat. Même famille que le journal
lu par un sudo qui échoue en silence.

Banc rendu intact : 5 charges, rangs 1-5, marque "auto" en place au rang 5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-29 10:48:47 +02:00
Patrick Schurig
c20d55d6f7 docs(banc): l'écho verbatim d'un « auto » passe — vérifié sur une vraie entrée
Une seconde wallbox appairée après +etm27 a donné la première entrée
rankOrigin="auto" en configuration. Insertion en queue vérifiée sur machine :
rang 5, aucun autre rang modifié, ligne de journal complète.

LE CAS QUI DÉCIDAIT LA DEMANDE DU BRIEF EST DÉJÀ TOLÉRÉ, et par conception :
reconcileRankOrigin() n'accepte "auto" que si le persisté le porte déjà — c'est
l'exception écrite à la règle 3, sans laquelle la neutralité Get→Set serait
rompue. Mesuré : écho verbatim intégral -> EnergyErrorNoError ; écho verbatim
AVEC un renommage ailleurs -> EnergyErrorNoError, marque survivante, rangs
inchangés. Un renommage n'est donc jamais bloqué par une borne
auto-provisionnée, et le filtre de l'app est inoffensif mais inutile.

CE QUI MORD VRAIMENT est ailleurs, et c'est mesuré aussi : l'ÉCHO PÉRIMÉ.
Déplacer l'entrée fait tomber la marque ; renvoyer ensuite un payload mis en
cache AVANT ce déplacement, qui porte encore "auto", est rejeté. C'est correct —
ce payload affirme quelque chose qui n'est plus vrai — et le remède est de relire
avant de réécrire. Le vrai piège est un cache de configuration qui survit à une
écriture, pas le champ.

COÛT DE L'ESSAI, consigné : le banc ne porte plus d'entrée "auto". Borner le
refus demandait de déplacer l'entrée puis de la remettre, ce qui a consommé la
marque — elle tombe sur un changement de rang, quelle qu'en soit la main. Pour en
retrouver une, retirer l'entrée et laisser l'auto-provisionnement la recréer : au
rang 6, pas 5. On ne peut pas avoir à la fois le rang choisi et la marque.

Aucun code : la tolérance demandée existait déjà.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-29 10:36:18 +02:00
Patrick Schurig
bbe88c9bd6 docs(banc): lire le journal sans sudo, et ce qu'une absence prouve
Un `sudo journalctl` par ssh ÉCHOUE — pas de TTY pour le mot de passe — et
l'échec part sur stderr : stdout est vide, le grep en aval ne trouve rien, la
commande rend 0. Un relevé conduit ainsi ne dit rien et en a l'air.

C'est arrivé sur mes propres relevés des 28 et 29 : deux recherches « la ligne X
apparaît-elle ? » n'avaient RIEN LU. Et l'explication que j'avais donnée à
l'absence — « cette ligne est en qCDebug, le niveau n'est pas actif » — était
fausse : les lignes `D | NymeaEnergy:` sortent bel et bien sur .75. Une cause
plausible avancée pour un silence qu'on n'a pas mesuré, c'est le corollaire
LM-104 appliqué à l'instrument de mesure.

L'accès qui marche est écrit : `journalctl -u nymead`, SANS sudo, l'utilisateur
etm étant dans le groupe adm. Vérifié sur .75.

Et la règle qui va avec : une absence ne s'affirme que dans un flux dont on a
prouvé qu'il coule — citer une ligne qu'on a LUE dans la même fenêtre, puis dire
ce qui n'y est pas. RELEVE_LOTD.md est donné en modèle : il cite la ligne
« 0 créée(s), 0 mise(s) à jour, 2 INCHANGÉE(S) » qu'il a lue, PUIS conclut à
l'absence d'ECS-413 et de commutation. L'absence y porte parce que la présence
l'accompagne.

AUDIT DES RELEVÉS. Repris sur ce critère : aucun ne repose sur un journal vide.
RELEVE_LOTBbis §267 parle d'un dry-run reprepro, pas du journal. Le seul défaut
était dans la PROCÉDURE — TEST_TERRAIN.md prescrivait `sudo journalctl` dans son
helper logs(), celui qu'un opérateur lance pendant un essai, alors que sa ligne
51 était déjà juste. Le document se contredisait ; corrigé.

Consigné aussi : +etm27 posé sur .75 et ce qui y a été vérifié — avec les deux
vérifications qui n'y sont PAS observables et qu'il ne faut pas fabriquer. La
config du banc porte les rangs 1-2-3-4, réparée à la main hier soir ; la
migration se rencontrera sur une box cliente non migrée.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-29 10:01:42 +02:00
Patrick Schurig
b552a1745b docs(règles): dix numéros retirés, deux tables supprimées, l'index câblé à Doxygen
Étapes 1 et 3 du chantier de documentation.

DIX NUMÉROS RETIRÉS — ECS-100/101/102/300/301/304/400/402/403/404. Ils
n'existaient QUE comme une étiquette de trois mots dans une table de statut :
aucun énoncé, nulle part. Leur reconstruire un texte depuis l'étiquette et le
code aurait été inventer une exigence en croyant la restituer, et une règle
inventée qui porte un numéro fait autorité pour toujours. Si le texte d'origine
n'existe nulle part, la règle n'existe pas ; ce que le code fait reste vrai, il
n'a simplement plus de règle à citer.

Retirés, pas effacés : tools/rules-retired.txt les porte, l'index les publie avec
leur date, leur motif et les endroits qui les citent encore. Un identifiant
croisé dans un vieux relevé renvoie donc à quelque chose — RELEVE_ECS306.md cite
toujours ECS-404, et c'est bien. Un numéro retiré n'est JAMAIS réattribué : le
générateur refuse une définition qui en reprendrait un, une même référence ne
doit pas désigner deux exigences selon la date de lecture.

Les citations VIVANTES de ces numéros sont retirées (spec_ecs §2/§4/§5,
AGENTS.md) — les phrases tiennent sans elles. Les citations HISTORIQUES restent.

DEUX TABLES SUPPRIMÉES, pas corrigées. §1 déclarait ECS-410/411/412 « absents »
alors que les trois étaient implémentées et testées. §9 « Traçabilité » nommait
HUIT tests qui n'ont jamais été écrits. Son défaut n'était pas d'être périmée
mais de mélanger un CONSTAT et une INTENTION sous la même colonne — « ECS-306 →
testEcsBudgetUnderLock » est un fait, « ECS-303 → banc » est un projet ; réunis,
le second garantit que l'ensemble devient faux. Les deux sont séparés : le
constat est généré, l'intention devient « Ce qui reste à éprouver », avec pour
chaque exigence POURQUOI elle n'est pas éprouvée.

DOXYGEN (étape 3). doc-generated/RULES.md entre en INPUT — ses ancres {#LM-1105}
rendent \rule{} résoluble — et l'alias \rule{1} est défini. Les specs restent
HORS périmètre : les y ajouter ferait entrer deux fois le même énoncé et
doublerait les ancres. Les 250 citations existantes ne sont pas converties : le
générateur les voit déjà, et \ref sert l'autre sens — du commentaire vers la
règle. Éprouvé sur un cas réel, \rule{LM-1209-c} résout sans avertissement.

Piège d'ordonnancement corrigé au passage : gen-rules.py écrit DANS $SORTIE, donc
l'appeler avant `rm -rf "$SORTIE"` l'effaçait aussitôt — doxygen signalait
« RULES.md is not a readable file ». L'index se produit après le nettoyage. Son
code de retour ne stoppe pas la génération (un index incomplet reste utile à
lire) mais est répercuté à la fin.

Avertissements doxygen : 30 → 26, dont 3 étaient les miens — rankOrigin(),
setRankOrigin() et knownRankOrigins() n'étaient pas documentés (DoD 5).

L'index sort désormais RC=0 : 111 règles, aucun orphelin, aucun domicile ambigu.
69 spécifiées seules, 16 citées par un test, 14 par le code, 12 vérifiées sur
machine. Build 0/0, loadmodel 20/20.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-29 09:36:18 +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
9c71c10009 docs(banc): meter qui l'emporte sur .75 est voulu — ne pas le défaire pour voir
Ma note de déploiement présentait les trois vérifications non observables comme
« masquées », ce qui se lit comme un angle mort à ouvrir. C'en est l'inverse :
le chauffe-eau et la PAC ont leurs compteurs dédiés depuis le 27, `meter` qui
l'emporte EST le comportement voulu, et les deux chemins masqués sont déjà
éprouvés en simulation et vérifiés échouant.

Le risque de la formulation d'origine était concret : qu'un prochain passage
retire ces meterThingId pour « mieux voir », échangeant une preuve contre une
observation et laissant la box dans un état que personne n'a voulu. La consigne
est donc explicite.

Corollaire de « à guetter au banc, jamais à fabriquer », posé à côté : le banc
sert les cas qu'on ne sait pas fabriquer, pas à rejouer ce qu'on a démontré.

Et une note de méthode, parce que l'erreur se refait : les trois attentes étaient
fausses pour avoir raisonné PAR MÉCANISME là où la CONFIGURATION décide — la même
erreur que LM-1106 corrige côté client, et elle se commet aussi bien en lisant un
banc qu'en écrivant une app.

Docs seuls, pas de release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-28 15:24:47 +02:00
Patrick Schurig
3876eb6bdd docs(contrat): une charge absente de la télémétrie n'est pas « none »
Distinction remontée par l'agent app, qui a dû l'ajouter parce que le contrat ne
la portait pas — et confirmée sur le banc le jour même.

`none` est PUBLIÉ, donc il affirme « cette charge n'est pas mesurable ».
L'absence n'affirme rien : elle dit que la charge n'a pas participé à l'arbitrage
du cycle — mode manuel, aucun véhicule branché — ce qui est sans rapport avec sa
mesurabilité. Sans la distinction, une borne débranchée se verrait annoncer « le
compteur dédié est la seule voie » alors qu'elle publiera la sienne au prochain
cycle.

Même famille que LM-1009 D (un champ absent se voit, un champ qui s'efface se lit
comme un zéro), appliquée un cran plus haut : ici c'est l'ENTRÉE entière qui
s'efface, et la lire comme un `none` transformerait un silence en affirmation.

La garantie qui rend la règle utilisable est posée explicitement : `measurement`
est présent pour toute charge de loads[], et pour elle seule.

Constaté tel quel sur .75 en +etm26 : deux bornes pluggedIn:false absentes de
loads[], dont la V2C qui porte currentPower.

AGENTS.md consigne aussi l'état du déploiement et les trois vérifications de banc
masquées par la configuration de la box — chauffe-eau et pac-terrain portant un
meterThingId réel, elles publient `meter` et le chemin `device` n'est pas atteint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-28 15:21:33 +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
fcba98159f docs(spec §11): la mesure d'une charge se lit sur l'appareil, et dit sa source
Constat d'usage de l'agent app, retenu : pour afficher « mesuré » à côté de
« alloué », il fallait désigner une borne comme SON PROPRE compteur dans
meterThingId. Ce n'est devinable par personne, et une note d'écran qui explique
un réglage dit que le défaut est dans le modèle. LM-1105 : quand le Thing piloté
porte lui-même l'état de puissance, le moteur le lit sans configuration.

La règle porte sur le THING, pas sur le mécanisme — certains powerswitch portent
currentPower et RelayRouter sait déjà les sommer ; écrire la règle par mécanisme
y ajouterait la table à maintenir à la main que LM-600 refuse. Et le test se pose
sur la CLASSE, sur TOUS les Things déclarés : interroger les relais actifs ferait
osciller la réponse au palier 0, sur une installation dont rien n'a changé.

Le compteur explicite reste prioritaire — répondu oui, et la raison principale
n'est pas la précision mais qu'un réglage sans effet est pire que pas de réglage.
La priorité porte sur la SOURCE, pas sur la valeur : compteur déclaré injoignable
→ on retombe sur l'appareil, et la charge utile dit lequel a servi.

Le second point de l'agent app décide de la forme, et la vérification l'a rendu
plus tranchant qu'il n'était posé : le moteur porte DÉJÀ trois régimes sous un
seul nom et jette la distinction. evcharger rend une mesure ; relay-router rend
une mesure OU le palier nominal — le booléen `metered` est calculé puis jeté ;
sg-ready rend toujours une puissance DÉCLARÉE, jamais mesurée, délibérément.
Publier ces quatre lignes sous un même measuredW serait faux pour deux d'entre
elles, et un measuredW auto-rempli détruirait au passage la seule information que
le champ portait : sa présence signifiait « un compteur a été posé exprès ».

D'où LM-1106 : le régime se PUBLIE, il ne se déduit pas d'une valeur — C2 de
LM-1009 mot pour mot. measurement {source: meter|device|none, o:powerW}, powerW
absent sous none. `none` est un état POSITIF — C1 de LM-1009 : une capacité
manquante se dit — et c'est lui qui distingue « pas mesurable » de « compteur en
panne ». Sous none, la puissance nominale reste lisible dans mechanism.* : la
faire passer pour une mesure produirait un écart identiquement nul à tous les
cycles, un « tout concorde » permanent, corollaire LM-104.

Ce que la source change au diagnostic, et qui la rend utile plutôt que
décorative : device dit « la charge n'obéit pas » (commande et mesure sortent de
la même boîte) ; meter ouvre trois hypothèses — charge, compteur, câblage — dont
il ne tranche aucune ; none n'autorise rien. L'asymétrie va plus loin : sous
device une CONCORDANCE ne prouve rien, plusieurs wallbox renvoyant leur consigne
en guise de puissance, alors que sous meter elle est une preuve. device détecte
la désobéissance, meter est le seul à pouvoir détecter un mensonge — et c'est ce
qui donne à LM-1104 sa raison d'être : meterThingId reste AUTORISÉ sur une charge
qui publie déjà, à masquer par défaut, pas à interdire.

Ne touche PAS ce que l'arbitre lit : LM-1101 tient, et le recrédit
anti-clignotement continue de lire LoadContext::telemetry.currentPowerW, qui
vient de l'adaptateur et n'a jamais transité par meterThingId. Ce lot déplace ce
qui est PUBLIÉ, pas ce qui décide.

Aucun code. Le contrat bouge (measuredW → measurement), et le brief le dit à
l'app avec son coût : un écran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-28 14:28:46 +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
05310cf561 docs(spec): LM-1208 — sortir de la décision n'est pas sortir de la comptabilité
Quatre constats de l'agent app, vérifiés un par un sur le banc.

A — la CONCLUSION est retenue, le MÉCANISME ne l'est pas.

« Pas de voiture assignée » cesse d'être un critère d'exclusion (LM-1208-b).
Les trois arguments tiennent, et le troisième est décisif : le critère
contredit LM-1206, qui donne le rang et le plancher à la borne et non au
véhicule. S'y ajoutent le cas de l'invité — la borne cesserait d'être arbitrée
au moment précis où elle consomme — et l'invérifiabilité d'une assignation
déclarée à la main.

Mais le budget n'était pas faussé, et c'est le journal du banc qui le montre :
pendant que la V2C tirait, le compteur est passé de −2 915 W (export) à
+3 531 W (import) et l'arbitre a publié « Surplus insuffisant (−3 531 W) —
chauffe-eau à 0 W ». L'ECS n'a pas été servi par-dessus. Le budget part de la
mesure BRUTE (invariant 8) : la consommation d'une charge exclue y est déjà
déduite. L'exclusion est conservatrice.

La règle générale est néanmoins écrite, parce qu'elle a un porteur étroit et
réel : une charge exclue du contexte mais commandée par le PROXY ne reçoit
aucun evReservedW. Transitoire, sans effet aujourd'hui — mais elle mordra au
§10, où la passe éco décide d'acheter.

Et un écart de MESURE, distinct et non tranché, est signalé : le compteur
racine lit ~1,5 kW de moins que la somme des charges mesurées. Même famille que
son aveuglement documenté aux résistances ECS. Ce n'est pas un problème
d'arbitrage, mais un budget juste suppose un compteur qui voit tout.

B — même cause, mais le mode manuel n'est pas en cause : il reste une exclusion
délibérée. Le vrai point est que le mode par défaut d'une borne détectée EST
ChargingModeNormal — une borne neuve n'est donc jamais arbitrée tant que
personne n'a choisi un mode éco. Correct, mais invisible à la mise en service.

C — l'entrée résiduelle se supprime déjà depuis l'app : SetLoadConfig remplace
l'ensemble, et l'auto-provisionnement ne recrée que les bornes dont le Thing
existe. Le geste demandé existe ; la limite à connaître est qu'une entrée dont
le Thing vit encore sera recréée — pour celle-là, c'est enabled: false.

D — instruction reçue, aucun cas particulier V2C dans le moteur, C1 maintenue.
Leur distinction est plus fine que ce que le brief anticipait : un champ absent
se voit, un champ qui s'efface se lit comme un zéro. Troisième régime, et c'est
pourquoi C2 exige que le régime soit publié plutôt que déduit d'une valeur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
2026-08-28 11:55:32 +02:00
Patrick Schurig
804a9c585d docs(spec §14): oui, le stockage devient une charge rangée du waterfall
Question de l'agent app, relayant Patrick : « si je veux que l'énergie aille
d'abord dans l'eau chaude, et seulement ensuite dans la batterie, je fais
comment ? » Réponse : aujourd'hui il ne peut pas, et la réponse est OUI.

Les quatre constats de l'app sont exacts, revérifiés indépendamment. Et le
contrat attendait déjà cette charge : OPTIMIZER_PROTOCOL décrit
adapter:"battery" dans loads[], LoadAction porte le kind Constraint avec
Permission{Allow,Forbid} et Source{Solar,GridSource}, et SurplusBattery déclare
reserveSocPercent / maxChargeW / maxDischargeW — trois champs qu'aucun code ne
lit. domain:"battery" accepté sans effet n'est donc pas une question effleurée,
c'est le reliquat visible d'une place réservée. La note dit sous quelle forme 3f
la remplit.

Les quatre points, tranchés :

(a) Le double commandeur ne se devine pas : il se MESURE. Le plugin SunSpec
    écrit, relit et compare, et son commentaire nomme le cas exact — « another
    controller overwriting the setpoints ». Le résultat sort dans controlHealthy,
    déjà déclaré par la classe du banc. Trois exigences en découlent, dont
    celle-ci : controlHealthy == false doit SORTIR de l'arbitrage comme
    available == false le fait déjà, sinon c'est le trou de pluggedIn à nouveau.

(b) batteryLevelConsideration et le rang ne sont pas concurrents par nature —
    ils le sont par accident d'implémentation. Le seuil agit aujourd'hui comme
    un rang absolu ; quand la batterie sera rangée, il perd ce rôle et ne garde
    que celui de plancher. Le mouvement a commencé en 3g-1 avec le défaut passé
    de 0,9 à 0,2, précisément parce que 0,9 confondait cible et réserve.

(c) La sûreté : « ne pas charger » et « décharger » sont deux gestes que le mot
    unique cachait. Le premier ne vide rien et c'est la demande de Patrick ; le
    second est déjà refusé par le budget, quel que soit le niveau. Un rang ne
    fait jamais descendre un SOC, il décide de ce qui le fait monter.

(d) Le signe : l'inquiétude tombe. Charger une batterie, c'est consommer — au
    compteur c'est indistinguable d'un chauffe-eau, donc allocatedW se
    réconcilie sans traitement particulier. Le sens opposé n'apparaît qu'à la
    décharge, et une décharge n'est pas une allocation : c'est une source, elle
    appartient au revenu du budget. Règle : allocatedW d'une batterie est
    toujours ≥ 0.

La règle Rules.* écartée par l'app l'est pour la bonne raison, et la note le
confirme : ce serait un second commandeur sans arbitre, pire que celui de 3g-2
puisqu'il vivrait hors du moteur, donc hors du journal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
2026-08-27 20:04:41 +02:00
Patrick Schurig
16feb61149 docs(design): §10 ouvert — éco/confort avec le délestage, et la prépa du banc V2C
Deux livrables, aucun code.

DESIGN_10.md — le §10 sous la contrainte des deux notes.

Le délestage part AVEC pour une raison arithmétique, pas de calendrier : la
passe éco n'est pas bornée par le surplus mais par une autorisation de
soutirage, et cet objet n'existe pas encore. Le lot doit donc le créer.

LM-1006-1 devient une structure : DrawCap {source, totalW, perPhaseA, until} et
DrawBudget qui publie `binding` — QUI a mordu. Le piège technique est nommé et
tranché : les trois sources ne parlent pas la même langue (le disjoncteur est
par phase, le §14a et l'éco sont des totaux), un plafond total ne protège pas
d'une surcharge monophasée, un plafond par phase n'exprime pas le §14a. La
proposition est de porter les deux et de ne JAMAIS convertir — c'est la
conversion qui perdrait l'information.

LM-1003 impose la vraie refonte : les passes calculent des cibles, une seule
commande sort par charge et par cycle, arrondie une fois au mécanisme. Cela
scinde buildSetpointAction(), qui mélange aujourd'hui décision, écrêtage,
comptabilité et motif. Deux comptabilités distinctes, PlanBudget (d'où vient
l'énergie) et PlanDraw (combien on a décidé d'acheter) — les fondre rendrait
faux l'invariant publié de la première.

Cinq points à trancher, dont trois ne sont pas techniques. Le premier est le
plus lourd : la réserve batterie annule le budget de SURPLUS, mais la passe éco
dépense une autorisation de SOUTIRAGE. Si la réserve l'annule aussi, un plancher
éco ne sera jamais atteint batterie basse ; si elle ne l'annule pas, la maison
achète pendant qu'elle manque. Recommandation donnée, décision d'exploitation.

L'ordre de travail met en tête les deux étapes qui NE CHANGENT RIEN — publier le
plafond sans effet, puis la refonte décider/émettre à comportement constant,
prouvée par l'invariance des 16 scénarios enregistrés. Si un seul point de
contrôle bouge, c'est qu'une décision a changé, et il faut savoir laquelle.

PREPA_BANC_20260828.md — deux blocages silencieux trouvés la veille.

1. La ThingClass trydan ne déclare PAS phaseCount, et aucun registre Modbus V2C
   ne le porte. EvCharger::phaseCount() retombe sur 1 : sur une Trydan
   triphasée, le plancher publié vaudrait 1 380 W au lieu de 4 140, et la
   conversion W→A donnerait une consigne 3× trop haute. C'est exactement la
   famille de défaut que 3g-2 vient de fermer, rouverte côté plugin. Correctif
   proposé : saisie à l'appairage, comme la voiture simulée le fait déjà.
2. sessionEnergy n'est délibérément pas exposée — le brief du plugin l'interdit,
   evcc ayant retiré son ChargeRater pour cette borne (firmware peu fiable).
   Donc « sessionEnergy sur matériel réel » n'est PAS franchissable demain. En
   échange, demain permet la validation qui lèverait l'interdit, et exerce pour
   de vrai la condition C1 de LM-1009 sur du matériel.

Plus : le plugin V2C n'est pas installé sur le banc, la Skoda n'a aucune
intégration (donc voiture manuelle, avec les quatre réglages à saisir), et
l'IP de test .127 est libre.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
2026-08-27 15:06:37 +02:00
Patrick Schurig
d4ad01324f docs(spec §10): LM-1009 — les trois arbitrages tranchés, et deux conditions de livraison
Décisions de Patrick, écrites dans la spec et portées à l'agent app qui avait
remonté la question.

D1 — l'avancement s'affiche en ÉNERGIE LIVRÉE, jamais en pourcentage. « 8,4 kWh
livrés depuis le branchement » est vérifiable ; « 62 % » suppose une capacité
que personne ne connaît et se lit comme une lecture du véhicule. Le pourcentage
reste dans la saisie de l'intention, il sort de l'affichage de l'avancement.

D2 — obligation par SESSION d'abord. Elle ne demande que de lire un champ déjà
publié, c'est l'usage réel (on branche le soir pour partir le matin), et surtout
la remise à zéro de sessionEnergy au branchement CESSE d'être une réserve : pour
une obligation de session, c'est la bonne sémantique. Le texte est réécrit en
conséquence — le problème disparaît au lieu d'être contourné. L'obligation
quotidienne devient un lot à part, dépendant de l'historique du §11.

D3 — tant que le chemin carBatteryLevel survit, il est étiqueté à la
publication. Une valeur que le moteur a lui-même intégrée ne franchit pas la
frontière RPC sous le même nom qu'une mesure. Précédent de nommage déjà dans ce
document (estimatedPowerW), et même refus que le currentPowerStorage à zéro du
relevé 3g-1 : mieux vaut « estimé », ou rien, qu'un chiffre crédible et faux.

Les deux réserves deviennent des CONDITIONS DE LIVRAISON, pas des notes — ce
sont les deux façons dont le §10 peut mentir sans erreur visible :

  C1 — une borne sans sessionEnergy dégrade VISIBLEMENT. Mesuré : wallboxNoMeter
  et l'une des deux classes wallbox ne l'ont pas. Une échéance qui cesse
  silencieusement d'être mesurable est pire qu'une échéance absente, parce
  qu'elle continue de s'afficher. Et « pas mesurable » n'est pas « rien livré ».
  C2 — on doit pouvoir dire LEQUEL des deux régimes s'applique, dans le motif
  comme à l'écran. Un avancement mesuré et un avancement estimé ne se
  distinguent par aucune de leurs valeurs : les deux sont un nombre qui monte.

La distinction des deux régimes est conservée telle quelle : une obligation en
énergie peut annoncer son atteinte, une cible en pourcentage publie une
intention et un avancement estimé — jamais « tenue ».

Le brief app gagne un cinquième point : c'est celui qui change un écran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
2026-08-27 14:49:55 +02:00
Patrick Schurig
2385933b6a docs(spec §10): LM-1009 — une échéance ne vaut que ce que son avancement se mesure
Question remontée par l'agent app. Note de conception, aucun code : elle
conditionne la forme du §10, elle ne la code pas.

Le constat de l'app est exact — vérifié sur les cinq classes evcharger du banc,
aucune ne déclare batteryLevel ni ne porte d'interface véhicule. Mais le code
dit quelque chose de plus troublant que « il manque une mesure » :

targetPercentage est comparé à carBatteryLevel, lu sur le Thing voiture. Et ce
batteryLevel, dans le seul cas où le mécanisme s'active — une voiture manuelle,
dont l'état est INSCRIPTIBLE — est écrit par le moteur lui-même :
updateManualSoCsWithMeter() intègre la puissance de la borne, applique un
facteur de pertes, divise par une capacité déclarée, et réinjecte le résultat
dans l'état du Thing. Le moteur compare donc sa cible à sa propre estimation,
bâtie sur trois grandeurs dont aucune n'est mesurée. La boucle se referme sur
elle-même. Et l'estimation sort par la frontière RPC indistinguable d'une
mesure — c'est l'app qui ment ensuite, de bonne foi.

Pendant ce temps, la seule grandeur réellement mesurée n'est lue par personne :
sessionEnergy est publiée par quatre des cinq classes, LoadContextTelemetry
porte déjà son emplacement (sessionWh), et le champ n'est rempli nulle part.

Réponse à la première question : OUI, et les trois pièces existent déjà —
sessionEnergy pour mesurer, sessionWh pour la transporter, needs.minEnergyWhPerDay
pour exprimer la cible. C'est la forme que le §10 doit retenir : une obligation
en ÉNERGIE, vérifiable de bout en bout sans communication véhicule. Avec quatre
réserves écrites, dont deux mordent tout de suite : sessionEnergy se remet à
zéro au branchement (donc inutilisable telle quelle pour un minEnergyWhPerDay),
et toutes les bornes ne la publient pas (donc la dégradation doit être VISIBLE).

Réponse à la seconde : OUI, et elle tient MÊME avec la première — parce que
targetPercentage ne disparaît pas. Deux objets, deux régimes de vérité : une
obligation en énergie peut annoncer son atteinte, une cible en pourcentage
publie une intention et un avancement inconnu ou estimé, jamais « tenue ». Même
exigence que BELOW_MIN_POWER contre SURPLUS_INSUFFICIENT : deux causes, deux
phrases, parce qu'elles n'appellent pas le même geste.

Deux arbitrages sont laissés ouverts, dont un de lot : obligation par session
(faisable en lisant un champ déjà publié) ou obligation quotidienne (qui exige
l'historique du §11).

LM-1205 (§12) renvoie désormais vers cette note : la machinerie d'échéance
existe, son avancement ne se mesure pas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
2026-08-27 14:43:40 +02:00
Patrick Schurig
d57a0184f3 docs(brief): ce que 3g-2 change pour l'app
Quatre points, émis vers l'agent app.

Le deuxième est le plus utile pour lui : la mise en garde « pour une borne,
allocatedW est la décision de l'arbitre et non l'état de la borne » TOMBE.
L'arbitre commande seul depuis +etm23 — 9 commandes sur la fenêtre du banc, 9
par lui, 0 par le proxy. allocationIsCommand rend true partout, les branches
conditionnelles peuvent disparaître.

Avec une réserve qu'il faut lui laisser : l'allocation reste ce qui a été
COMMANDÉ, pas ce que la borne TIRE. Ce qu'il peut enfin affirmer, c'est que
personne d'autre ne commande. L'écart commande/réalité se lit dans la charge
utile `mechanism`, pas dans allocatedW.

Les trois autres : loads[] ⊆ GetLoadConfig rétabli (2 → 4 entrées sur le banc,
avec la forme d'une entrée evcharger et la neutralité de l'aller-retour) ;
EV_GRID_START {budgetW, floorW, gridW} avec sa comptabilité partagée — clé à
prévoir, mais PAS ENCORE VUE sur machine, donc pas d'écran à bâtir autour ;
et le rang de borne, qui se classe au glisser-déposer sur une seule liste.

La réserve sur le rang par défaut est signalée explicitement parce qu'elle le
concerne : avant tout réglage, l'ordre affiché est un artefact, pas un choix.
À ne pas présenter comme un réglage tant que le point n'est pas tranché.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
2026-08-27 13:15:10 +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
93b257172c docs(relevé): 3g-2 sur machine — le doublet a disparu, et le banc a trouvé un trou
`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
2026-08-27 12:44:19 +02:00