19 Commits

Author SHA1 Message Date
Patrick Schurig
4e7826ff8a fix(prévision PV): le RPC ne rendait plus rien, et la courbe courante était la mauvaise
Deux défauts de moi, révélés en quatre minutes par un plan de test que le
producteur a ajouté puis retiré pour une raison sans rapport.

1. MON ÉDITION DU CONTRAT AVAIT SUPPRIMÉ LA DÉCLARATION DU RPC. GetPvForecast
répondait {} — pas même une liste vide. En réécrivant sa description par une
substitution de texte, j'avais emporté les params.insert et returns.insert qui
la suivaient ; nymea valide la réponse contre le schéma déclaré, donc sans
returns la charge utile est éliminée en silence.

Aucun test ne couvre la surface RPC : 31 unitaires, la simulation et ci-quality
passaient pendant que la méthode ne rendait plus rien. Ce n'est pas un défaut de
logique mais de DÉCLARATION, et rien ici ne les regarde.

2. LA COURBE COURANTE ÉTAIT LA DERNIÈRE APPARUE, PAS LA DERNIÈRE VUE. Le retrait
du plan a fait réapparaître un contenu déjà connu ; son firstSeenAt n'a pas bougé
— c'est le principe du champ — mais le tri était fait dessus. Quatre minutes
après la disparition du plan fictif, le moteur servait encore la courbe de
36 kWh : une installation qui n'existe pas, à +80 % de la production mesurée.

Ce qui a cours est ce qu'on a vu EN DERNIER. Le tri passe sur lastSeenAt ;
firstSeenAt dit quand un contenu est apparu, pas s'il a cours.

Limite écrite plutôt que découverte : lastSeenAt est mis à jour en place, donc
une lecture historique traversant un RETOUR à un contenu antérieur est
approchée. L'exactitude demanderait une table d'épisodes — différée.

Et une correction sur mon relevé précédent : « huit fenêtres par jour, jusqu'à
une heure chacune » est DÉDUIT du code, pas mesuré — le producteur le dit
lui-même. Ce qui est certain est que la dérive était monotone et sans rappel.
L'écrire comme une mesure en ferait la prémisse de quelqu'un.

Fenêtre à écarter de toute comparaison prévu/réalisé : 08:07 → 08:11:36.

loadmodel 31/31, charging 48/48, spotmarket 32/32, simulation 69/69,
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-09 08:21:10 +02:00
Patrick Schurig
ee6f1732bc fix(prévision PV): la clé devient LE CONTENU — issuedAt n'identifie pas la courbe
Réponse mesurée du producteur, et elle renverse le §13 comme le §19. +etm63
GELAIT la courbe : c'était le seul endroit où le lot était plus faux qu'avant.

Les faits établis : issuedAt vient de meteofrance_arome_france_hd, seul domaine
AROME exposant un meta.json — qui sert 0 valeur de rayonnement sur 72, donc ne
contribue RIEN à la courbe. Le domaine qui la fournit (arome_france) n'expose
aucun meta.json (HTTP 500) : il n'existe aucune identité de run pour la donnée
qui forme p. Et à 07:30, seamless diverge d'arome_france de 08:00 à 13:00 (144
contre 356, 326 contre 530) et coïncide à partir de 14:00 — le serveur
recompose les premières heures autrement que par le seul run.

Ce n'est pas un défaut du producteur : c'est ce que l'API permet.

LA RÈGLE N'A PAS BOUGÉ, LA CLÉ SI. « Une révision n'existe que si la PRÉVISION a
changé » reste intacte ; ce qui était faux, c'est la prémisse — que le run
identifie la prévision. Regarder le contenu y est plus fidèle que de regarder
une étiquette d'émission.

  PRIMARY KEY (targetAt, siteId, contentId)
  firstSeenAt / lastSeenAt : observation, en MILLISECONDES
  issuedAt / model         : attributs, issuedAt pouvant être NUL

Migration v1 -> v2 : table REFAITE, pas convertie. Les lignes v1 sont clés sur
un run qui n'identifie pas leur contenu et ont pu être écrasées ; leur donner
une empreinte calculée sur des valeurs dont on ignore l'origine serait inventer
une provenance.

TROIS CONSÉQUENCES IMPOSÉES PAR LA MESURE :
- issuedAt == 0 n'est PLUS un refus. Il l'a été deux heures, tant qu'il servait
  de clé. Devenu attribut, refuser une courbe exploitable parce qu'une
  métadonnée manque reviendrait à jeter la donnée pour son étiquette — et
  best_match le met à zéro pour toutes ses courbes. Stocké NUL, aucun instant
  substitué.
- siteId entre dans la clé : deux plans publient des courbes différentes sous le
  même issuedAt, donc la garde d'hier devient NÉCESSAIRE. Sans elle, changer de
  site désigné mêlerait deux historiques.
- t0 ancré sur le jour local de la requête est couvert sans rien ajouter : les
  targetAt changent, donc l'empreinte aussi.

ET UN DÉFAUT TROUVÉ PAR LE TEST, qui n'était pas dans le test : deux révisions
écrites dans la même seconde n'étaient pas ordonnables — MAX(firstSeenAt) en
rendait deux et la courbe retournait des doublons. Les instants d'observation
passent en millisecondes. Un instant d'observation doit distinguer deux
observations, sinon il n'ordonne rien ; un cycle par minute l'aurait caché
indéfiniment sur la box.

Le producteur n'a PAS établi pourquoi le serveur recompose — nowcast, mélange
court terme, ou deux domaines sur des runs différents. Il a mesuré QUE ça diverge
et SUR QUELLES HEURES. L'explication reste une hypothèse, et rien ici n'en
dépend : la clé de contenu est correcte quelle qu'en soit la cause.

loadmodel 30/30, charging 48/48, spotmarket 32/32, simulation 69/69,
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-09 07:54:45 +02:00
Patrick Schurig
4d6012693d fix(prévision PV): un RUN est immuable — écrasure silencieuse trouvée sur le banc
On attendait la déduplication ; on a trouvé une écrasure.

06:03 RÉVISION du run 00Z — 48 créneaux
07:02 RÉVISION du run 00Z — 48 créneaux    <- MÊME RUN

Deux écritures pour la même identité de run à une heure d'écart : les valeurs
avaient changé sous un issuedAt inchangé, et INSERT OR REPLACE les a remplacées
sans rien dire.

LA PREUVE A ÉTÉ DÉTRUITE PAR LE DÉFAUT LUI-MÊME. À 07:14 la table et l'état
publié coïncidaient parfaitement — l'écrasure avait déjà aligné les deux, et la
question « quels créneaux ont changé, les passés ou les futurs ? » est devenue
impossible à poser.

CORRECTION : un run est immuable. Des valeurs neuves sous une identité connue
n'écrasent plus rien — écriture refusée, premières valeurs conservées, anomalie
journalisée AVEC ce qui manquait pour décider : combien de créneaux, et le
partage passé/futur.

Ce partage est LE discriminant et il commande deux conduites opposées. Passés
seulement : le producteur fusionne l'observé, légitime chez lui, et c'est NOTRE
clé qui est trop courte — il lui faudrait une composante de contenu. Futurs :
c'est un run qui n'identifie pas son contenu, donc un défaut à lui remonter.

Et un cas neuf pour la famille des modes d'érosion : un défaut qui écrase la
donnée qui le prouverait ne laisse pas de preuve, il laisse une COHÉRENCE. À
07:14 tout concordait — exactement ce qu'on aurait vu si tout allait bien.

Au passage, le geste du motif s'adapte au nombre de candidats : « désignez-le »
devant un seul, « choisissez-en un parmi les N » au-delà. Envoyer chercher une
liste qui n'existe pas fait perdre le temps que l'aide prétend gagner, et c'est
le pire endroit pour se tromper puisque celui qui la lit ne sait pas déjà.

loadmodel 33/33, charging 48/48, spotmarket 32/32, simulation 69/69,
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-09 07:23:42 +02:00
Patrick Schurig
4c6f6e04ed fix(prévision PV): une clé de run inconnue se REFUSE, et ne se devine jamais
Suite à l'avis de l'agent openmeteo (2026-09-09).

`issuedAt == 0` signifie CLÉ INCONNUE — le producteur le publie ainsi quand le
repli best_match est actif, cette stratégie n'ayant pas d'identité de run
unique. Le refus est désormais NOMMÉ (ISSUED_AT_UNKNOWN) et distinct de
MALFORMED : la cause diffère et la conduite à tenir aussi ; les confondre ferait
chercher un défaut de forme là où il n'y a simplement pas de run.

Et l'heure de réception ne s'y substitue JAMAIS. Elle serait plausible et fausse
— trop courte de toute la latence de publication — et une fois écrite, plus rien
dans la donnée ne distinguerait la ligne devinée de la ligne datée. Même
raisonnement que celui qui a fait refuser receivedAt comme clé, appliqué au cas
dégradé.

PURGE RECOMMANDÉE : vérifiée, et sans objet ici. L'avis demandait de ne
conserver aucune ligne écrite avant openmeteo +etm12, au motif qu'une ligne
saine et une ligne fausse sont indistinguables après coup — raisonnement juste.
Mais la chronologie du banc le rend sans objet : le .so +etm13 a été posé à
06:01, notre première révision écrite à 06:03, et la concordance site/courbe est
vraie (2026-09-09 02:00 des deux côtés). Purger détruirait une ligne saine.

La consigne reste notée pour le jour où elle s'appliquera, avec le principe qui
la fonde : une révision manquante est une lacune visible, une révision mal datée
est un mensonge invisible.

Et le défaut du producteur rejoint une famille déjà nommée ici : il n'était
démontrable que sur la machine, par recoupement de DEUX états sur DEUX runs
consécutifs. Un défaut dont les deux valeurs sont plausibles ne se voit jamais
d'une seule source.

loadmodel 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-09 06:06:53 +02:00
Patrick Schurig
11635b8e6d feat(prévision PV): la fraîcheur ne date jamais la prévision, et elle n'est jamais absente
Deux gardes sur lastConfirmedAt, demandées et tranchées.

JAMAIS UNE CLÉ DE RÉVISION. firstIssuedAt identifie la prévision ; la fraîcheur
dit seulement jusqu'à quand elle a tenu. Dater avec elle ferait paraître une
prévision née au moment où elle a été reconduite — et deux prévisions
différentes reconduites par le même run porteraient la même date. Écrit dans
l'en-tête, au contrat ET dans un test : une interdiction qui ne vit qu'à un
endroit n'est pas une interdiction.

SUR UNE LIGNE JAMAIS RECONDUITE, ELLE VAUT firstIssuedAt — jamais absente. Et la
tranche vient de ce que le champ RÉPOND : il ne dit pas « quand quelqu'un a
confirmé » mais « quel est le dernier run qui a AFFIRMÉ ces valeurs ». Le run
émetteur les a affirmées. La réponse est vraie, et uniformément — il n'y a pas
un cas confirmation et un cas copie, il y a une seule question avec une seule
sorte de réponse.

L'ambiguïté redoutée ne se lève pas en choisissant une convention : elle se lève
en DÉFINISSANT correctement le champ. Une fois la définition posée, les deux cas
n'en font plus qu'un.

Et ici l'absence serait le mensonge, à rebours de la règle habituelle du dépôt :
elle se lirait « on ne sait pas jusqu'à quand elle a tenu » alors qu'on le sait,
et forcerait chaque client à écrire `confirmedAt ?? issuedAt` — ce calcul-ci,
ailleurs, une fois par client, avec une chance de le faire de travers.

Invariant publié : lastConfirmedAt >= firstIssuedAt, toujours. Une prévision ne
peut pas avoir tenu avant d'avoir été émise.

Sur le nom : lastConfirmedAt et non confirmedAt — le préfixe dit qu'il s'agit du
DERNIER d'une suite, là où confirmedAt se lirait comme l'instant d'un événement
unique, et une ligne reconduite quatre fois n'a pas « un » instant de
confirmation.

loadmodel 31/31, 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-09 05:56:18 +02:00
Patrick Schurig
da58d3906a feat(prévision PV): version B — une révision n'existe que si la prévision a changé
La déduplication porte sur les VALEURS ; un run qui reconduit ne crée pas de
ligne, il met à jour la fraîcheur.

Le raisonnement qui tranche est celui de meta.json, appliqué une seconde fois :
on ne stocke pas pour un consommateur qui n'existe pas. L'apprentissage n'existe
pas, le consommateur d'aujourd'hui est un affichage, et un affichage ne lit pas
une confirmation. Même déclencheur de réouverture, pour la même raison.

UNE CONTRADICTION DANS LA CONSIGNE, ET COMMENT ELLE EST LEVÉE. Deux instructions
incompatibles : « issuedAt est mis à jour sur la ligne existante » (= le dernier
run) et « il devient l'horodatage du premier run, pas du dernier » (= le
premier). Et l'avertissement joint — « quelqu'un lira prévu à 06Z sur une ligne
que le run 12Z a confirmée » — ne décrit un danger QUE si l'instant n'est pas
mis à jour.

Aucune des deux n'a été choisie, parce qu'aucune ne suffit : un seul champ ne
peut pas porter les deux faits sans mentir sur l'un. Ce serait exactement le
piège receivedAt/issuedAt de la veille — deux instants voisins, un nom qui
promet le mauvais — et le remède est le même : nommer ce que c'est.

  firstIssuedAt    run qui a produit ces valeurs EN PREMIER  (CLÉ, immuable)
  lastConfirmedAt  dernier run qui les a RECONDUITES         (mis à jour)

La FRAÎCHEUR est lastConfirmedAt ; l'ANCIENNETÉ DE L'AVIS est firstIssuedAt.
Une valeur émise à 06Z et reconduite à 12Z est fraîche ; une valeur émise à 06Z
que personne n'a reconduite EST vieille de six heures. Les deux phrases sont
vraies et ne parlent pas de la même chose. Les deux sont publiées au contrat :
n'en publier qu'une obligerait le client à choisir laquelle il croit.

firstIssuedAt ne bouge jamais — une clé qui se déplace change l'identité de la
ligne sous elle. Les deux instants coïncident à l'ouverture et divergent à la
première reconduction, ce que le test vérifie explicitement.

DERNIER REMPART, PAS FILET : celle du producteur compare la charge utile
entière, où issuedAt figure, donc elle ne mord jamais sur ce cas. Si celle-ci se
trompe, personne ne rattrape — raison de plus pour qu'elle ne dépende d'aucun
comportement du voisin : intersection des créneaux, et communs > 0 requis.

loadmodel 30/30, charging 48/48, spotmarket 32/32, simulation 69/69,
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 19:55:38 +02:00
Patrick Schurig
4231453ea1 feat(prévision PV): la clé devient VRAIE, et la déduplication cesse de rejeter
Le canal est livré (openmeteo 1.15.2+etm11) : forecastCurveTransport, une
QVariantMap TYPÉE déclarée dans la ThingClass — au contrat, sans seconde couche
d'encodage. Mieux que la chaîne JSON envisagée : le §12.5 réclamait une version
dans la charge utile faute de schéma ; ici la charge utile a un schéma ET porte
sa version.

issuedAt VOYAGE DANS LA CHARGE UTILE. La clé de révision devient exacte, et tout
le §13 est levé : receivedAt, l'interdiction d'en calculer un horizon, la dette
meta.json. targetAt - issuedAt EST l'horizon. Le coût que je chiffrais disparaît
avec sa cause.

CE QUE J'AVAIS FAUX. Ma déduplication REJETAIT une révision aux valeurs
identiques. Avec une vraie heure de run, c'est faux : un run plus récent portant
les mêmes chiffres CONFIRME, il ne répète pas. Le rejeter rendrait indistincts
« le modèle a confirmé quatre fois » et « le modèle n'a rien dit depuis ce
matin ».

Le mécanisme change donc de nature et se simplifie : la CLÉ déduplique par
construction, et la comparaison de contenu ne fait plus que JOURNALISER (« le
run confirme le précédent »). Si issuedAt bougeait un jour sans que la prévision
bouge, cela se verrait dans le nombre de révisions — un défaut à la source,
qu'il faut voir et non masquer. Une déduplication de contenu l'aurait effacé.

La comparaison sur l'intersection reste, mais comme précaution : la fenêtre
couvre désormais les journées entières et les révisions sont alignées. Elle ne
dépend d'aucun comportement du voisin dont personne ne nous préviendrait.

model porte la provenance réelle, repli best_match compris. La colonne cesse
d'être nulle ; la règle de non-remplissage rétroactif reste.

L'INDEXATION EST LUE, PAS SUPPOSÉE. `convention` dit que p[i] est la moyenne sur
[t0 + i·step, t0 + (i+1)·step) : l'énergie vaut p[i] x step / 3600, sans
supposer que le pas vaut une heure. `unit` est VÉRIFIÉE — lire des Wh comme des
W donnerait une courbe plausible, fausse d'un facteur qui dépend du pas.

Lecture branchée dans le cycle, et c'est sans coût : un run déjà connu n'écrit
rien. Attendre une notification serait plus élégant et MOINS SÛR — un état
publié avant notre démarrage ne notifierait jamais, et la courbe resterait
invisible jusqu'au run suivant. L'état est cherché par son NOM, jamais par un
identifiant de ThingClass. Un refus est journalisé une fois par MOTIF, pas une
fois par cycle.

Deux tests : l'identité du run est celle de la révision (avec l'assertion
centrale — un run qui confirme EST conservé), et le refus de charge utile plutôt
que l'interprétation au mieux (version, unité, vide, tronquée, et un créneau
illisible qui reste un TROU).

loadmodel 30/30, charging 48/48, spotmarket 32/32, simulation 69/69,
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 19:14:18 +02:00
Patrick Schurig
9dcaee8be1 feat(prévision PV): la table bitemporelle, la déduplication sur l'intersection, la rétention
Ce qui ne dépend pas du canal, écrit maintenant. La lecture se branchera quand
la forme du canal openmeteo sera connue ; le magasin est vide d'ici là, et une
courbe vide EST la réponse honnête à « que prévoit-on ? » quand personne n'a
rien publié.

LA CLÉ EST LA PAIRE (targetAt, receivedAt). Avec targetAt seul, chaque
rafraîchissement écraserait le précédent : on perdrait les révisions, donc la
comparaison prévu/réalisé — ce que ce lot existe pour montrer. Le GPL n'apprend
pas, il rend l'apprentissage possible : cette table est la matière, et l'écraser
la détruirait pour toujours sans que rien ne le signale.

receivedAt, JAMAIS issuedAt, et l'interdiction est écrite et non seulement
nommée : aucun horizon ne se calcule depuis ce champ. Entre l'initialisation
d'un run et sa mise à disposition s'écoule la latence de publication du modèle —
des heures. targetAt - receivedAt est donc trop court, systématiquement et dans
le même sens. « Quelle prévision » est juste, « quel horizon » est faux.

LA DÉDUPLICATION PORTE SUR L'INTERSECTION, pas sur la charge utile. Si la
fenêtre glisse, la courbe de 11 h a un point de moins que celle de 10 h : une
égalité de charge utile verrait une révision toutes les heures, exactement ce
que la déduplication existe pour éviter. Et `communs > 0` est requis — sans
heure commune il n'y a rien à comparer, et conclure « identique » ferait
disparaître une révision dont on ne sait rien (cas du passage de minuit).

Ce choix ne dépend pas de la forme que le producteur publie aujourd'hui : il est
correct que la fenêtre glisse ou non, donc il ne peut pas être invalidé par un
changement chez le voisin dont personne ne nous préviendrait.

RÉTENTION : sept jours pleins, puis les DEUX BOUTS par heure visée — la première
révision (horizon le plus long) et la dernière (meilleure estimation). Les deux,
et pas une : elles répondent aux deux seules questions qui se posent, et n'en
garder qu'une détruirait la révision.

model reste NULL tant que le producteur publie une INTENTION et non une
provenance. Une chaîne vide serait déjà une valeur ; NULL dit « on ne sait pas »,
ce qui est vrai. Aucun remplissage rétroactif à prévoir.

GetPvForecast : un Get* SANS *Changed — amendement assumé à l'invariant 11, qui
vise l'ÉTAT DE L'ARBITRE, celui qui bouge à chaque cycle et laisse un client
déconnecté en retard. Une courbe historisée se lit sur demande, comme
GetPowerBalanceLogs ; notifier chaque révision produirait un message par heure
sur une donnée que personne n'attend en direct. `knownAt` rend la lecture
reproductible : « ce qu'on prévoyait ce matin » se demande en le fixant.

Base SÉPARÉE, jamais energylogs.sqlite : celle-là appartient à l'amont qui en
migre le schéma, et surtout la clé y est un instant de mesure quand la nôtre est
une paire. Une prévision révisée n'a rien à faire dans la base du mesuré.

Trois tests unitaires, et le premier vise le cas qu'un == naïf raterait : une
fenêtre glissée d'une heure, valeurs communes inchangées, charge utile
différente — ce n'est PAS une révision. Avec ses contre-épreuves : une valeur
commune modifiée EN EST une, et une fenêtre sans heure commune aussi.

loadmodel 29/29, charging 48/48, spotmarket 32/32, simulation 69/69,
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 17:23:35 +02:00
e65dbb4a6a feat(§10): « Surplus + minimum réseau » — le mode sert enfin un minimum
LM-1013-b-vi. Le mode ChargingModeEcoWithMinCurrent promettait un courant minimum
depuis toujours sans qu'aucun chemin ne le serve : mort par réécriture chez
l'amont, puis mort par exclusion quand le waterfall a pris la borne.

LA LOI. computeEcoTarget() rend max(cadence de l'obligation, minimum). Le corps
historique est isolé dans calculerObligation() et une sortie anticipée rend
EXACTEMENT l'ancien résultat quand aucun minimum n'est demandé — ce n'est pas une
optimisation, c'est la garantie que l'ajout ne déplace rien pour les charges qui
n'en veulent pas. Les quatre suites le confirment.

LE MINIMUM NE SE TIENT JAMAIS. `tenue` ne décrit que l'obligation en énergie ;
quand elle est tenue, la cadence vaut 0 et la consigne retombe SUR LE MINIMUM, pas
sur zéro. C'est là, et seulement là, que la différence de nature entre une énergie
avec échéance et une puissance permanente se voit dans le code. L'ordonnanceur ne
publie donc ECO_FLOOR_MET que si watts vaut aussi 0 : sinon il couperait ce qu'il
doit continuer de servir.

DÉRIVÉ, JAMAIS STOCKÉ. EvAdapter calcule le minimum depuis evFloorW() à chaque
cycle quand le mode le demande. L'écrire en configuration à l'appairage créerait
une copie qui se périme au premier recâblage — phaseCount change, la copie reste
fausse en silence. Correction apportée par le brief app f2bd07d, et elle est
juste.

LE MOTIF DIT LEQUEL DES DEUX MORD. ECO_FLOOR_GRID gagne minAlwaysW et boundBy, et
OMET remainingWh/targetWh/remainingS/regime sur une charge à minimum seul : un
remainingS à 0 inventerait une échéance imminente et un remainingWh à 0 une
obligation tenue. progress est absent pour la même raison — il ne décrit que
l'obligation.

CONTRAT. Les trois descriptions de ChargingMode sont corrigées. Normal n'est pas
« recharge immédiate au maximum » : c'est MANUEL, le moteur ne commande rien, et
seule la protection de surcharge la touche encore — ce qui doit être dit, un mode
qui promet l'abstention et coupe quand même étant une surprise. Et les mots
« garanti » et « prioritaire » sont bannis à propos de ce mode : le premier promet
une certitude que rien ne tient, le second est vrai face au confort et faux face à
un autre besoin éco mieux rangé, donc faux exactement dans le cas d'échec.

TEST vu mordre : désarmé, minSeul.watts rend 0 au lieu de 4 140. Cinq points, dont
la contre-épreuve qui distingue « 4 140 vient du minimum » de « 4 140 vient d'un
reliquat d'obligation », et le cas 1 qui vérifie que rien ne bouge sans minimum.

Suites : loadmodel 26/26 (+1), simulation 67/67, charging 48/48, spotmarket 32/32.
NON DÉPLOYÉ — +etm57 attend le feu vert.
2026-09-07 11:20:08 +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
fb18a282e1 spec+test: les deux dates ne bornent pas la même chose, et la journée ne fait pas toujours 24 h
TA CORRECTION EST RETENUE, et elle porte sur le point qui compte. `measuredSince`
n'a pas la même sémantique des deux côtés : sur une charge il répond « depuis
quand on MESURE » et borne une fenêtre dont `targetWh` donne l'autre bout ; sur
les ratios il répond « depuis quand on COMPTE », et la paire ne borne aucune
fenêtre. Transposer un nom n'est pas transposer une sémantique.

Conséquence gravée : `periodStartedAt` n'est PAS la borne basse des ratios —
c'est `measuredSince`. Présenter comme journaliers des chiffres qui ne comptent
que depuis le dernier redémarrage serait exactement le silence que la parade
prétendait fermer. Mesuré à l'instant sur .75 : les taux couvrent 1,97 h quand
l'écran dirait 9 h.

Et la séparation d'écrans est écrite là où elle se lit : `periodStartedAt` sert le
bilan journalier, `measuredSince` sert les ratios. Ils arrivent dans la même
trame, leurs noms se ressemblent, et c'est par là que l'erreur passe.

VÉRIFIÉ À LA SONDE PLUTÔT QUE DÉDUIT DU NOM, comme demandé — et il y avait de
quoi vérifier, puisque la justification même de publier était « le recalcul
serait faux les jours de changement d'heure ». Encore fallait-il que le nôtre soit
juste ces jours-là.

  · en vrai sur .75 : `periodStartedAt` == minuit local, recalculé indépendamment ;
  · en test : les époques viennent d'un ORACLE calculé hors de Qt, pas re-dérivé
    avec les primitives du code testé — une identité qui se referme sur elle-même
    ne vérifierait rien ;
  · 2026-03-29 fait 23 h, 2026-10-25 fait 25 h, les 363 autres 24 h. Un client
    posant « la journée fait 86 400 s » serait juste presque toujours, et faux
    deux fois par an, dont une en plein hiver de chauffage.

TROIS FOIS mes propres contrôles se sont trompés en cherchant ces deux dates : un
balayage qui n'a rien trouvé, puis deux calculs rendant 24 h partout. Cause :
l'arithmétique entre deux datetimes du MÊME fuseau est naïve — elle rend la durée
d'horloge murale, jamais la durée réelle. Le contrôle qui le prouve passe donc par
les époques, jamais par une soustraction de dates. C'est la même leçon que celle
qu'on vient de graver : un instrument peut répondre « normal » parce qu'il ne
mesure pas ce qu'on croit.

Suites : loadmodel 24/24 (+1), simulation 67/67, charging 48/48, spotmarket 32/32.
Rien à déployer : le moteur est inchangé depuis +etm56.
2026-09-05 09:13:17 +02:00
7398b1dd24 feat(§10): sous unmeasurable, la passe éco commande à TAUX CONSTANT
LM-1013-b-v. Tranché le 2026-09-01, jamais écrit — la décision a passé au second
plan quand sessionWh a été alimenté depuis le compteur de charge et que le régime
`unmeasurable` s'est réduit aux trois cas matériels. C'est la première classe
d'erreur d'AGENTS.md : une règle qui expire sans être exécutée. Elle n'était ni
dans le code, ni dans la spec, ni au contrat.

LE PROBLÈME. L'étalement `reste / temps restant` suppose que le NUMÉRATEUR bouge.
Sous `unmeasurable` il ne bouge pas : `remainingWh` est figé à la cible entière,
par contrat, parce qu'on ne sait pas ce qui a été livré et que le rebaser serait
affirmer sur ce qu'on ignore (LM-1009 C1). Un rapport dont seul le dénominateur
décroît ne suit plus un retard : IL SUIT L'HORLOGE. On achetait davantage parce
que le temps passait, jamais parce que quelque chose était en retard.

CHIFFRÉ, et le chiffre a été refait plutôt que repris. Obligation 4 kWh, plafond
7 kW, fenêtre d'un jour : l'ancienne loi achetait 19 010 Wh, soit ×4,75
l'obligation. Le facteur suit ln(T·P/E) + 1 — il grandit avec la fenêtre, ce qui
est le pire sens possible : plus on laisse de temps pour bien faire, plus on paie.
Une charge qu'on mesure mal coûtait ainsi structurellement plus cher qu'une charge
qu'on mesure, l'incitation exactement à l'envers.

(Le ×2,5 retenu au moment de trancher valait pour une fenêtre bien plus courte.
Sur la fenêtre quotidienne, qui est celle du modèle, c'est ×4,75. La conclusion ne
change pas ; le chiffre, si — et un chiffre faux dans une spec finit par servir de
prémisse à quelqu'un.)

LA LOI. Cible ÷ fenêtre de l'obligation, constante, et `atMaximum` jamais vrai. La
fenêtre est le JOUR parce que l'obligation est déclarée par jour — ce n'est pas un
réglage, c'est l'unité de la grandeur qu'on étale. Le total acheté sur la fenêtre
vaut alors EXACTEMENT la cible. On garde « viser l'obligation entière », le choix
qui sert ; on perd l'escalade, qui ne reposait sur rien.

CE QUE LE TEST TIENT, et il a été vu mordre — désarmé, il rend 667 W au lieu de
167 W à six heures de l'échéance :
  · la cadence est identique à 6 h, 2 h, 1 h, 30 min et 5 min de l'échéance ;
  · `atMaximum` reste faux jusqu'à la dernière minute ;
  · l'intégrale sur les 1440 minutes de la fenêtre vaut la cible à 5 Wh près —
    la propriété qui justifie la loi ne se lit sur aucune valeur isolée ;
  · CONTRE-ÉPREUVE : même obligation, même instant, même numérateur (4000 non
    livrés) sous `measured` — durcissement et point de non-retour intacts. Sans
    elle, avoir supprimé l'étalement POUR TOUT LE MONDE passerait inaperçu.

AU CONTRAT. La loi de commande dépend désormais du régime, et un écran qui
suppose la même dans les deux se trompera sur la trajectoire — qui est ce qu'il
vend. INTERFACE.md porte la table des deux lois, et dit ce que l'écran ne peut
plus affirmer sous `unmeasurable` : ni que l'achat va s'intensifier, ni que
`atMaximum: false` veut dire que tout va bien — il veut dire qu'il n'y a pas
d'étalement du tout, donc rien qui puisse cesser.

Suites : loadmodel 23/23, simulation 66/66, charging 48/48, spotmarket 32/32.
2026-09-04 19:18:27 +02:00
Patrick Schurig
8d035b0c1a feat(§10 étape D, 1/2): l'étalement uniforme, mesuré avant d'être asserté
`computeEcoTarget()` convertit une obligation en énergie en une cible en
watts : cadence = reste / temps restant, recalculée à chaque cycle. Une
cadence figée au départ ne rattrape pas — après une heure sans surplus elle
reste à la cadence initiale et manque l'échéance en ayant l'air de
travailler.

Mesurer d'abord était nécessaire, pas rituel : aucun scénario existant ne
portait d'obligation éco, donc aucune référence. Les valeurs ont été
relevées par sonde avant toute assertion — et c'est le relevé qui a trouvé
un défaut que la lecture du code ne montrait pas.

LA BASCULE DE PÉRIODE (LM-1013-c). À l'instant de l'échéance quotidienne,
resolveDeadline() vise la prochaine occurrence : une nouvelle obligation
s'ouvre. Sans reprise de la base d'avancement, le lendemain hérite de
l'énergie de la veille et se croit tenu sans avoir rien reçu. La sonde
affichait 83 W et 86400 s restantes à 07:00 pile, ce qui n'avait aucun sens
tant qu'on ne cherchait pas la période. Contre-épreuve : 1800 Wh portés au
crédit du jour suivant.

Limite assumée et écrite : l'obligation précédente, si elle n'était pas
tenue, est abandonnée SANS motif dédié. Le dire demande un ECO_FLOOR_MISSED
que rien n'émet ; le journal le dit, l'écran non.

Le durcissement est relevé point par point — 333 W à six heures, 2 kW à une
heure, le plafond dès seize minutes — parce que c'est la courbe que le motif
devra rendre lisible, pas une valeur isolée. Point de non-retour nommé
plutôt que subi : un seuil se discute, une asymptote non.

R6 armée dans le calcul : sous `unmeasurable`, jamais « tenue ». On vise
l'obligation entière, le choix qui sert plutôt que celui qui suppose acquis.

Suite : loadmodel 22/22, simulation 56/56, 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-08-31 12:40:07 +02:00
Patrick Schurig
fa3c8d4755 feat(délestage 1/4): le plafond de soutirage et sa source — comportement inchangé
Étape 1 du design : le TYPE et sa résolution. Rien ne le consomme encore, et
c'est voulu — dans un lot de sécurité, ce qui coûte n'est pas d'écrire le
comportement, c'est de prouver qu'on n'a rien cassé en chemin.

DrawCap porte la source en PARAMÈTRE, jamais enfouie dans le nom d'une fonction :
Breaker (par phase, permanent), GridOperator (total, sur ordre — aucun émetteur,
le transport du §14a n'existe pas), EcoSelfImposed (total, le temps de la passe 1
— la passe n'existe pas non plus). Les deux sources sans émetteur sont déclarées
pour que la porte reste ouverte, pas parce qu'elle s'ouvre.

LE POINT TECHNIQUE : on ne compare pas les plafonds, on compare les MARGES. Un
plafond par phase donne sa marge sur la phase la plus chargée, un plafond total
la sienne sur le soutirage mesuré ; les deux sont des watts. Le plus petit mord,
et on retient sa source ET la phase qui borne — sans elle, un installateur devant
une maison triphasée déséquilibrée ne sait pas où mesurer.

La contre-épreuve de la conversion est éloquente : ramener un plafond par phase à
un « total équivalent » donne 9 300 W de marge là où il y en a 500. Dix-huit fois
trop, sur la couche qui décidera d'acheter. C'est exactement l'hypothèse
d'équilibre que LM-1006-1 interdit, et elle serait restée invisible jusqu'au jour
où elle coupe du courant.

Trois autres invariants, tous vérifiés échouant : aucun plafond déclaré rend une
marge SANS OBJET et non nulle — une marge nulle bornerait tout à 0 W et couperait
l'installation, c'est le « zéro forgé » appliqué à la sécurité ; une marge
NÉGATIVE conserve l'ampleur du dépassement, qui est ce que L4 doit délester ; et
le départage à marge égale est STABLE (Breaker > GridOperator > EcoSelfImposed),
sans quoi le motif publié changerait au gré de l'ordre du tableau, donc sans
qu'aucune décision n'ait changé.

Le design complet est dans docs/DESIGN_DELESTAGE.md, avec les trois décisions
qu'il demande — dont celle qui touche la sécurité : que fait le délestage d'une
charge sous verrou minOn, et la réponse diffère selon la source.

simulation 35/35, charging 17/17, loadmodel 21/21, spotmarket 7/7, amd64 0/0.
Comportement inchangé : aucune suite ne bouge d'une ligne.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-30 11:32:52 +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
baf267a963 feat(3g-2): une charge, un commandeur
Le relevé machine du 2026-08-26 l'énonçait sans détour : « tant que ceci n'est
pas traité, la transplantation ne commande rien ». L'arbitre décidait,
publiait un motif juste, et adjustEvChargers() commandait autre chose une
milliseconde plus tard. Ce lot ferme les trois points que le banc a ouverts.

1. UNE CHARGE, UN COMMANDEUR

Les actions EV du waterfall sont enfin DISPATCHÉES — elles ne l'étaient pas du
tout : m_loadAdapters ne contient pas les bornes, et applyActionsToAdapters()
s'arrêtait là. Et les bornes ainsi commandées sortent du chemin proxy par deux
crochets virtuels posés dans l'amont, interrogés à deux moments du cycle où la
réponse n'est pas la même : evSurplusPlannedByWaterfall() avant le plan (à qui
la borne appartient-elle), evCommandedByWaterfall() après (qu'a-t-il commandé).
Sans cette distinction, une borne servie par l'échéance ne serait plus
commandée du tout.

Le jeu des bornes commandées est NOMMÉ par le scheduler (Plan::waterfallEvIds).
Le redéduire ailleurs — « motif différent d'EV_DEADLINE », « financement au
surplus » — dériverait du vrai au premier motif ajouté, et EV_GRID_START porte
justement funding=Grid tout en venant du waterfall. Il est vidé à l'entrée du
mode dégradé : le waterfall ne commandant plus, il ne peut plus revendiquer.

chargingState, que posait adjustEvChargers(), est repris par l'arbitre pour
les bornes qui n'y passent plus — sinon l'app afficherait « en charge » une
borne que l'arbitre vient d'éteindre.

2. evFloorW() REND L'EXIGENCE RÉELLE, ET LA TOLÉRANCE DEVIENT VISIBLE

Le plancher valait `min × acquisitionTolerance × 230 × phases` : 2 070 W
annoncés pour une borne qui en exige 4 140. Le waterfall allouait 2 828 W,
l'adaptateur refusait d'enclencher sous 6 A, et 2 828 W étaient retirés du
budget pour personne. Ce sont deux grandeurs différentes : le plancher est ce
que la borne EXIGE, la tolérance est la part qu'on accepte de ne pas couvrir.

La tolérance n'est pas supprimée pour autant — un réglage documenté (défaut
0,5) qui devient inerte sans le dire est un silence ambigu, et tout le parc
verrait son VE démarrer deux fois plus tard sans explication. Elle devient un
seuil de démarrage EXPLICITE : motif EV_GRID_START {budgetW, floorW, gridW},
funding=Grid, et une comptabilité qui se partage — budgetW dans
budget.allocatedW, gridW dans budget.evReservedW, budgetW + gridW ==
allocatedW.

C'est une brèche assumée dans le financement réseau que DESIGN_3g §3.1 déférait
à 3f. Elle est bornée par construction, pas par discipline : surplus réel exigé
(budgetW > 0), tout le budget restant consommé donc AU PLUS UNE borne par
cycle, et le plafond de la protection de surcharge s'applique avant. Écrite en
toutes lettres dans DESIGN_3g §3.1-bis.

3. LE RANG D'UNE BORNE EST CONFIGURABLE

Il vit là où vivent tous les rangs : LoadConfig.priority (LM-1206/LM-1207). Pas
de second système de priorité — un classement propre aux bornes ne saurait pas
exprimer « VE1 > ECS > VE2 », et deux classements qui ne peuvent pas
s'interclasser sont un défaut, pas deux fonctionnalités. ChargingInfo ne porte
donc aucun champ de rang.

Toute borne détectée reçoit d'office une entrée « evcharger », portant le rang
et RIEN d'autre : ses limites viennent du Thing et changent avec la voiture
branchée. Création journalisée, une ligne par borne, à la création seulement —
une écriture que personne n'a demandée doit laisser une trace. Le tri devient
TOTAL : à rang égal, l'identifiant départage, donc laquelle démarre est
reproductible même avant tout réglage.

L'entrée SURVIT à la disparition de son Thing : borne remplacée, ThingId
changé, appareil déposé. Elle reste publiée available: false / faultCode:
THING_MISSING / motif LOAD_UNAVAILABLE, et ne retient aucun budget. La
supprimer d'office rendrait « borne remplacée » indistinguable de « borne
jamais configurée ».

CORRIGE UNE RUPTURE D'INVARIANT DE 3g-1, non signalée à l'époque : en faisant
entrer les bornes dans loads[], 3g-1 publiait des charges qui ne figuraient
dans AUCUNE GetLoadConfig — la charge fantôme que le lot B-bis avait
supprimée, réapparue par l'autre bout. L'app s'appuie sur cet invariant pour
résoudre libellé, domaine et rang. L'auto-provisionnement le rétablit par
construction.

ÉPROUVÉ

  - testEvChargerRankDecidesWhoCharges : deux bornes, un budget qui ne paie
    qu'un plancher, la seconde refusée avec BELOW_MIN_POWER {budgetW: 620,
    minPowerW: 1380}, et l'inversion du rang change la borne servie. C'est la
    moitié ARBITRAGE de LM-1204-b. Couvre aussi l'aller-retour neutre par la
    frontière RPC et l'inclusion loads[] ⊆ GetLoadConfig.
  - testEvGridStartIsAnnouncedAndBounded : le motif chiffre le soutirage, la
    comptabilité se réconcilie, une seule borne soutire par cycle.
  - testEvChargerConfigOutlivesItsThing : l'entrée survit, publiée en défaut.
  - testEvChargerConfigIsRankOnly / RefusesPayloads / ClaimsItsThing.

Deux points de contrôle de `run` bougent, dans un seul scénario et par une
cause unique : la conversion watts→ampères se fait désormais après
l'allocation, et l'arrondi tombe parfois de l'autre côté. 14 des 16 scénarios
sont identiques au bit près, et ce qui compte est inchangé dans les deux —
la voiture est à 100 % à l'échéance.

Suite complète : simulation 27/27, charging 16/16, loadmodel 16/16,
spotmarket 7/7. Build amd64 0/0.

RIEN N'A TOURNÉ SUR MACHINE. docs/RECAP_3g2.md dit ce qui reste à éprouver au
banc — dont LM-1204-b avec deux bornes réelles, la Terra AC étant vérifiée
absente du réseau (ARP INCOMPLETE depuis le banc lui-même).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
2026-08-27 10:43:25 +02:00
Patrick Schurig
639689baa9 feat(lot E §12): le plancher de modulation — rien, ou au moins X
Le domaine d'une consigne devient {0} ∪ [minPowerW, maxPowerW], un ensemble discontinu.
LM-1203 est la règle qui fait le travail : sous le plancher, allouer 0 et TRANSMETTRE le
budget à la charge suivante.

Constat en lisant le waterfall : il satisfaisait déjà LM-1203 pour les charges à
paliers — le plus haut palier ≤ budget vaut 0 quand le budget est sous le premier, et
le résidu cascade. Le trou n'était que dans la branche continue, où
qBound(0, budget, max) acceptait n'importe quelle miette. Le correctif est donc plus
petit que la spec ne le laissait craindre, et localisé.

Le plancher est déclaré pour etmvariableload dynamic et dérivé partout ailleurs — plus
petit palier non nul chez relay-router, plus petite estimation non nulle chez sg-ready.
SetLoadConfig refuse de le déclarer là où il se dérive, et refuse aussi minPowerW >
maxPowerW : le domaine serait réduit à {0}, la charge ne démarrerait jamais, et rien
dans le journal ne dirait pourquoi.

Motif BELOW_MIN_POWER distinct de SURPLUS_INSUFFICIENT : les confondre enverrait
chercher du soleil là où il faut lire une fiche technique.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-26 10:48:51 +02:00
Patrick Schurig
bbb3492c2c feat(lot E §11): compteur et sonde par charge, publiés et sans effet sur les décisions
Deux champs optionnels de même forme sur LoadConfig — o:meterThingId, o:sensorThingId —
et leur publication en télémétrie. Rien d'autre : l'arbitre ne s'en sert pas pour
décider, et le §12 comme le §10 restent à faire.

« Aucun effet sur les décisions » est vrai par CONSTRUCTION, pas par discipline : les
adaptateurs ne voient jamais ces Things. attachMeasurements() les résout au moment de
publier, dans les deux chemins de télémétrie — y compris le mode dégradé, où un
exploitant veut précisément voir ce que la charge fait pendant que l'arbitre ne décide
plus rien.

measuredW est publié À CÔTÉ de allocatedW et jamais fondu dedans : l'un est ce que le
waterfall a alloué, l'autre ce qu'un compteur a vu. Les confondre rendrait la somme des
allocations irréconciliable avec budget.allocatedW — et injecter une mesure en retard
sur la commande réintroduirait l'oscillation que le recrédit a supprimée.

claimedThingIds() les exclut, et l'exclusion est maintenant commentée sur place : LM-201
vaut pour ce qui commande, ceux-ci mesurent. Sans ce commentaire, la symétrie avec
relays[] invitait à les ajouter — ce qui ferait refuser un compteur divisionnaire
partagé ou une sonde de pièce commune.

spec 0.2.1 : la précondition de livraison du §10 (extension du délestage au waterfall,
même lot ou avant, jamais après) est écrite comme CONDITION et non comme conseil — la
protection de surcharge ne déleste que les bornes, et des planchers éco qui
s'additionnent un matin sans soleil sur un branchement 6 kVA feraient disjoncter
l'installation. Et LM-1008 tranche la cible de confort : saisie en degrés, frontière de
l'arbitre en énergie, conversion à la couche domaine — et aucune conversion nécessaire
aujourd'hui, un comparateur suffit, c'est l'arrêt qui compte et pas le dosage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-26 10:34:41 +02:00
Patrick Schurig
a0c02fbd34 feat(lot D): libellé jusqu'à l'adaptateur, relais comparés par table, paliers publiés
Les trois premiers constats de BRIEF_agent_plugin.md, dans l'ordre de ce qu'ils coûtent.

§2 — Renommer une charge n'atteignait jamais l'adaptateur. updateSoftConfig() ne
portait que priority et needs, donc un changement de libellé seul était compté
« inchangée » : l'adaptateur gardait son ancien nom et le journal contredisait
GetLoadConfig jusqu'à la prochaine reconstruction. `label` entre dans la signature et
dans le prédicat soft de l'arbitre, sans entrer dans sameHardware() — mise à jour EN
PLACE, pas reconstruction. La vraie prise était le Q_UNUSED de SgReadyAdapter, hérité
d'avant le lot B-bis : sans lui, renommer la PAC — le cas même qu'on allait tester —
n'aurait rien changé, et on aurait conclu que le correctif ne marchait pas.

§3 — relays[] n'est plus comparé par index. Un réordonnancement reconstruisait la
charge : contacts ouverts, verrous réarmés à froid. Comparer comme un ENSEMBLE aurait
été faux dans l'autre sens — à somme égale la table retient la première combinaison
rencontrée, donc échanger deux relais de même puissance change le contact qui sert ce
palier. sameHardware() compare la TABLE DÉRIVÉE : deux listes qui produisent les mêmes
paliers avec les mêmes contacts sont matériellement identiques, par construction.

§4 — mechanism.stagesW publie les paliers atteignables, en télémétrie et pas dans
GetLoadConfig. La télémétrie n'a aucun chemin d'écriture, donc « lecture seule » y est
vrai par construction ; GetLoadConfig aurait cassé l'aller-retour neutre dont l'app
dépend pour écrire — elle renvoie verbatim ce qu'elle a lu, le champ serait revenu
dans SetLoadConfig, et un refus en bloc aurait emporté toutes les charges.

deriveStages() est l'implémentation unique de la combinatoire, partagée par les trois
usages. C'était le fond de la demande du §4 : une seule règle, pas de divergence.

tests/auto/loadmodel : cible neuve sans serveur nymea, 2 ms. Elle a attrapé un défaut
de la correction elle-même avant le banc — operator== comparait les listes de ThingIds
et voyait une différence entre fermer {K1,K2} et fermer {K2,K1}.

tools/repair_energylogs.py : §1, et ce n'est PAS un correctif de ce plugin. La cause
est extérieure et déjà corrigée (SunSpec float32 aux mots permutés, d08cf50 daté
05:30:14 UTC — la seconde du premier échantillon empoisonné). La séquelle, elle, était
définitive : à 7,5e33 kWh le plus petit incrément représentable vaut 1,67e18, donc le
compteur n'était pas faux mais GELÉ, et rechargé tel quel à chaque démarrage.
Appliqué sur .75 : 62 726 anomalies → 0, accumulation reprise et vérifiée en direct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-26 10:07:22 +02:00