307 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
a5b3d5fc16 docs(prévision PV): la somme vérifiée, et une configuration qui ressemble à une révision
Un second plan a été créé : le chemin de sommation a été exercé pour la première
fois, sur la machine. 20,0 + 16,0 = 36,0 kWh au dixième, « 2 plans sommés », puis
déduplication au cycle suivant.

MA GARDE ÉTAIT JUSTE, ET SON COÛT PLUS GRAND QUE PRÉVU. L'hypothèse « les plans
d'un site partagent un instant » était FAUSSE — un jitter par plan les faisait
diverger en minutes. Ma garde aurait donc bloqué la somme à chaque publication
de run : huit fenêtres par jour, jusqu'à une heure chacune. Corrigé chez le
producteur (+etm14), et désormais au contrat : l'hypothèse est vraie par
construction, pas par chance.

Et la garde reste utile APRÈS le correctif : elle devient l'instrument qui
l'observe. Si la boucle naturelle ne tenait pas, le journal dirait « plans de
runs différents — on attend ». Une garde qui vérifie la promesse sur laquelle
elle repose vaut mieux qu'une garde qu'on retire parce que la promesse a été
faite.

CE QUE LA SOMME REND VISIBLE : le passage de 20,0 à 36,0 kWh est enregistré
comme une RÉVISION, et il n'en est pas une — la prévision n'a pas changé,
l'INSTALLATION a changé. C'est le point du §8.5 annoncé au premier jour et
devenu réel : comparer les 20 kWh d'avant 08:08 à une production postérieure
comparerait deux installations différentes, et rien dans la table ne permet de
le voir. La révision est là, sa CAUSE ne l'est pas.

Proposition non implémentée : une colonne portant l'ensemble des plans, qui ne
change pas la clé mais rend la cause lisible. Pas fait maintenant et
délibérément — ce serait un schéma v3 une heure après la v2, sur un mécanisme
qui n'a qu'une matinée d'observation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-09 08:10:33 +02:00
Patrick Schurig
a8947579fa release: 1.15.2+etm64 — dégel de la courbe, clé de contenu, et la règle de fusion écrite
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-09 07:56:31 +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
2b7240a596 release: 1.15.2+etm63 — prévision PV, désignation du site, immuabilité du run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-09 07:24:16 +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
e0bfeb1788 fix(prévision PV): aucune adoption d'office — le motif rend le silence acceptable
Décision de Patrick : rien n'est jamais désigné automatiquement. Ma proposition
d'adopter le site unique est écartée, et sa raison porte plus loin que la mienne.

« Il n'y a rien à choisir » ne suffit pas, parce que LA DÉSIGNATION SE PERSISTE.
Le jour où un second site apparaît, elle devient un choix que personne n'a fait
et qui a l'air fait — plus rien ne distinguera « l'utilisateur a préféré
celui-ci » de « le moteur a pris le seul qu'il voyait en 2026 ».

Le précédent du rootmeter va dans ce sens (SetRootMeter exige une désignation
explicite, et une adoption d'office sur la seule présence d'une interface a déjà
coûté, cf. §14). Et la distinction avec l'auto-provisionnement des bornes
(+etm23) tient : celui-là ENREGISTRE ce que le moteur pilote déjà, il ne choisit
rien parmi plusieurs.

MISSING_FORECAST_SITE : la lecture s'arrête, mais elle le DIT — c'est ce qui
rend le silence acceptable, et l'écran peut nommer le geste au lieu d'afficher
un vide. Le rendu français donne le geste et pas seulement le manque : « Aucun
site de prévision désigné — choisissez-en un dans la configuration ». Au contrat
c'est un code, jamais une phrase.

Et il ne se publie QUE si un site existe. Aucun candidat : silence — annoncer
une désignation manquante à une installation qui n'a rien à désigner serait un
avertissement qu'aucun geste ne peut lever, donc du bruit permanent, et le bruit
finit par couvrir le signal.

PAS DE PARC, DONC PAS DE MIGRATION — mais une DATE. Aucun mécanisme n'est écrit,
délibérément : un mécanisme sans parc à migrer serait du code jamais exercé,
donc du code faux qu'on découvrirait le jour où il servirait. Si un parc existe
avant le 2026-12-31, la question se rouvre — et la réponse ne sera pas
l'adoption d'office pour autant : ce qui manquera est un chemin de configuration
à la mise en service.

forecastSiteChosen reste, sans plus armer d'adoption : il distingue une
installation jamais configurée d'une dont l'utilisateur a délibérément coupé la
prévision. Les deux se taisent de la même façon et ne veulent pas dire la même
chose.

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:49:46 +02:00
Patrick Schurig
17a222756b feat(prévision PV): migration — désigner d'office quand il n'y a rien à choisir
+etm62 lit sans désignation, +etm63 en exige une : une installation qui avait
commencé à remplir sa table s'arrêterait silencieusement. Règle proposée et
implémentée :

  0 candidat   rien, et RIEN au journal — cas normal d'une installation sans
               prévision
  1 candidat   DÉSIGNÉ D'OFFICE, persisté, une ligne au journal
  >= 2         rien de désigné, une ligne au journal, candidats publiés au
               contrat

LA DISTINCTION QUI LA FONDE. Le silence est la bonne réponse à « cette
installation n'a pas de prévision ». Il n'est PAS la bonne réponse à « cette
installation en avait une et nous venons de la lui retirer ». La première est
une configuration légitime, la seconde une régression que NOUS introduisons —
les confondre ferait passer notre propre rupture pour un cas normal, ce que la
règle du silence existe précisément pour ne pas couvrir.

POURQUOI LE DÉFAUT D'OFFICE NE DEVINE RIEN : avec un seul candidat, désigner
n'est pas CHOISIR — il n'y a rien à choisir. Dès deux, il y a un vrai choix et
le faire serait deviner : le moteur ne désigne rien, le dit une fois, et
GetForecastSite publie candidateSiteThingIds pour qu'un écran pose la question
sans second appel ni heuristique côté client.

« JAMAIS CONFIGURÉ » N'EST PAS « EXPLICITEMENT MIS À RIEN ». Un second réglage,
forecastSiteChosen, est posé par TOUT appel à SetForecastSite, identifiant vide
compris. Sans lui, un utilisateur ayant délibérément coupé la prévision la
verrait se rallumer au prochain démarrage, sa décision écrasée en silence.

Deux détails qui ont failli être faux : un site candidat se reconnaît à une
propriété OBSERVABLE (un enfant publie forecastCurveTransport), jamais à un nom
de ThingClass ; et la migration tourne au PREMIER CYCLE, pas au constructeur —
là, le ThingManager n'a pas chargé les Things, « aucun candidat » serait
toujours vrai, et le défaut ne se déclencherait jamais. Un mécanisme inerte qui
aurait l'air posé.

loadmodel 32/32, 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 06:34:02 +02:00
Patrick Schurig
2564ba1f16 feat(prévision PV): la prévision est une propriété du SITE — désignation globale et somme des plans
Désignation GLOBALE sur le modèle d'Energy.SetRootMeter — même nature : une
source unique pour toute l'installation, désignée une fois. Un forecastThingId
par charge supposerait que deux charges s'appuient sur des prévisions
différentes ; il n'y a qu'un ciel au-dessus de l'installation, et le champ dit
d'où vient la PRODUCTION, pas ce que la charge consomme.

GetForecastSite / SetForecastSite, persisté dans energy.conf.

PLUSIEURS PLANS : LA SOMME, et ce n'était pas un choix libre. La courbe est
superposée à la production MESURÉE, qui est le total. N'en retenir qu'un
donnerait une prévision systématiquement SOUS la mesure — qui se lirait comme
une installation qui sous-performe, soit le mode d'erreur déjà nommé au §3 pour
le biais optimiste, obtenu cette fois par le découpage.

UN DÉFAUT TROUVÉ EN ÉCRIVANT, ET IL ÉTAIT DÉJÀ LÀ. L'implémentation lisait tous
les Things portant forecastCurveTransport et les enregistrait l'un après
l'autre. Avec deux plans du même run, les deux révisions entrent en collision
sur la clé (targetAt, firstIssuedAt) : INSERT OR REPLACE garde LE DERNIER LU, en
silence. Ni somme ni choix — un tirage. Le banc n'a qu'un plan, donc rien ne
l'aurait montré.

ON NE SOMME QUE DES PLANS DU MÊME RUN. Un plan rafraîchi et l'autre pas
donneraient une chimère : une partie du toit à jour, l'autre périmée, publiées
comme le tout. Le moteur attend la passe suivante. Même règle si UN plan est
refusé — additionner le reste publierait une partie du toit sous le nom du tout.

L'ABSENCE DE DÉSIGNATION EST SILENCIEUSE. Rien retenu, rien journalisé : une
installation sans prévision est un cas normal, et un avertissement par cycle
ferait chercher une panne là où il n'y a qu'une fonction non configurée.
GetForecastSite OMET le champ plutôt que de rendre une chaîne vide.

Note de contrat : EnergyError de l'amont n'a pas de ThingNotFound, donc un
identifiant inconnu rend InvalidParameter et la description le dit. Inventer une
valeur d'énumération ferait diverger le contrat de celui de nymea-energy.

loadmodel 32/32, 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 06:17:58 +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
233832b251 release: 1.15.2+etm62 — la table de prévision PV et la lecture du canal
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-09 05:58:11 +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
3ab9d22160 docs(relevé): le contexte AVEC le décompte, et la prochaine question reformulée
Le fait le plus utile n'est pas le décompte, c'est son contexte : 10 437 W et
σ = 3065 W sur cinq heures, sans une seule divergence après 12:53. Deux épisodes
dans le vide ne diraient rien ; deux épisodes sur cinq heures d'activité réelle
avec une borne qui suit ses consignes à pleine puissance sont un signal. Un
compte faible sur une charge inerte et un compte faible sur une charge
sollicitée sont le même nombre et deux résultats opposés — donc les deux se
lisent ensemble, et le contexte passe DANS le décompte, pas après.

Et la réserve sur la résolution reformule la question de l'étape 4. Si des
commandes se perdaient brièvement, elles seraient TOUTES invisibles : un épisode
plus court qu'un cycle ne laisse aucune trace. La question n'est donc pas
« combien d'épisodes longs » mais « existe-t-il des épisodes courts qu'on ne
voit pas ».

Ce n'est pas la même mesure : elle demande une cadence fine sur une fenêtre
courte, pas une fenêtre longue à 60 s. Allonger l'observation actuelle n'y
répondra jamais, quel que soit le nombre d'heures ajoutées — l'instrument dont
le silence ressemble à un résultat, appliqué à un protocole. Et la fenêtre fine
se paie en cadence de boucle de commande, ce qui n'est pas un réglage
d'observation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-08 19:03:34 +02:00
Patrick Schurig
2fc227b6b7 docs(relevé): fenêtre du 2026-09-08 — 10 bruts, 2 retenus, et la décision reste ouverte
Observation de 5,8 h en Min+PV, rangée et non conclue.

10 épisodes bruts = 2 retenus + 8 écartés. Les deux retenus sont des latences de
démarrage, à 12:51 et 12:53, dans les trois minutes suivant la bascule. Aucun
ensuite — et l'après-midi n'a pas été calme : la V2C est montée à 10 437 W
(σ = 3065 W) SANS ouvrir une seule divergence. La borne a suivi ses consignes
pendant cinq heures.

Écartés : 1 transition de dérogation (bord de fermeture du boost, que la clause
écrite ce matin attrape correctement une fois testée sur le DÉBUT RÉEL et non
sur la ligne qui l'a révélé), et 7 pour compteur constant.

Deux réserves, écrites avec le décompte et non après :

- la résolution est de 60 s, pas de 15. Le cycle est déclenché par les entrées
  SampleRate1Min de nymea, donc tout épisode plus court qu'un cycle est
  invisible. La distribution est tronquée par le bas — non par un seuil, qui
  n'existe pas, mais par l'échantillonnage ;

- un seul instrument valide sur quatre. σ = 3065 W sur la V2C, 0,0 W sur les
  trois autres. Une conclusion sur « le moteur perd-il des commandes » tirée
  d'un seul instrument est une conclusion sur CETTE BORNE, pas sur le moteur.

Trois épisodes exploitables existent à ce jour — le 09:13 du 05/09 et les deux
d'aujourd'hui — et les trois pointent dans le même sens : latence de
négociation, pas commande perdue. Mais trois points sur une seule charge ne font
pas une série : la décision de l'étape 4 RESTE OUVERTE.

Ce qui la rendra posable est daté : de vrais SDM120 sur la PAC et l'ECS en fin
de semaine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-08 18:01:02 +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
Patrick Schurig
a2a12c1d9c docs(inventaire): le sixième manque sous sa forme réelle — un instrument valide sur quatre
Le §7 disait « des compteurs figés fabriquent de faux cas ». La fenêtre du
2026-09-08 (292 points par charge) donne le chiffre : σ = 1879 W sur la V2C,
0,0 W sur les trois autres.

Aucun compteur du banc ne suit la commande de la charge qu'il mesure, sauf un.

Ce n'est pas « des instruments imprécis » mais UN SEUL INSTRUMENT VALIDE SUR
QUATRE, et la conséquence change de nature : toute conclusion tirée de ce banc
sur l'OBÉISSANCE des charges est une conclusion sur cette borne, jamais sur le
moteur.

Le critère §6 rend le manque visible, il ne le comble pas — il écarte
correctement les trois compteurs constants, sans nommer personne, sur la seule
propriété observable de la donnée. Mais un tri qui garde un instrument sur
quatre a trié, il n'a pas mesuré.

De vrais SDM120 sur la PAC et l'ECS sont prévus en fin de semaine. C'est la
condition d'EXISTENCE de l'étape 4, pas un confort : décider de rejouer une
commande demande de savoir si des commandes se perdent, et la question n'est pas
posable sur un banc qui ne mesure qu'une charge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-08 17:09:16 +02:00
Patrick Schurig
4a0dfa273f docs(prévision PV): la clé de révision — déduplication, et receivedAt s'appelle receivedAt
Retenu : déduplication sur le contenu, pas de requête meta.json. Mais le coût
n'est pas un décalage de cycle — c'est un HORIZON FAUX DE PLUSIEURS HEURES.

Entre l'initialisation d'un run et sa mise à disposition il s'écoule la latence
de publication du modèle : des heures pour un modèle global. Donc
targetAt − receivedAt n'est pas l'horizon, il est trop court de toute cette
latence, systématiquement et dans le même sens. Une prévision qu'on croirait à
H−1 peut être le run de H−5.

Ce que la déduplication sauve : les BORNES des révisions — combien, et laquelle
s'applique. Ce qu'elle ne sauve pas : leur ÂGE. « Quelle prévision » est juste,
« quel horizon » est faux. Sans effet sur ce lot qui AFFICHE ; déterminant pour
l'apprentissage, dont l'objet est justement de corréler l'erreur à l'horizon.

Trois conditions :

1. La colonne s'appelle receivedAt, JAMAIS issuedAt. Un nom qui promet l'heure
   du run fabrique le calcul faux tout seul : quelqu'un écrira
   targetAt - issuedAt et obtiendra un nombre plausible. Et il faut l'écrire :
   aucun horizon ne se calcule depuis receivedAt.

2. La déduplication compare les points COMMUNS, pas la charge utile. Si la
   fenêtre glisse, un == naïf verrait une révision toutes les heures —
   exactement ce que la déduplication existe pour éviter. Je code la comparaison
   sur l'intersection dans les deux cas : elle est correcte pour les deux et ne
   dépend pas d'un comportement du voisin qui pourrait changer sans préavis.

3. La dette meta.json se rouvre AVEC son consommateur. La décision reposait sur
   « aucun consommateur ne le demande » et elle RESTE juste : un affichage n'a
   pas besoin de l'âge du run. Le jour où quelque chose corrèle l'erreur à
   l'horizon, meta.json devient nécessaire — et la rouvrir maintenant au nom
   d'un besoin supposé serait payer une requête par cycle pour un calcul que
   personne ne fait.

Volume confirmé : 24-48 points, 4-8 révisions/jour ⇒ ~200-400 lignes/jour au
lieu des 1150 estimées sur révision horaire.

Et la dette forecastModel : la colonne model reste NULLE tant que la correction
n'est pas livrée. Un NULL qui dit « on ne sait pas » est honnête ; une valeur
qui dit « ce qu'on avait demandé » est fausse d'une façon parfaitement crédible.
Aucun remplissage rétroactif.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-08 17:02:46 +02:00
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
002ecc639b docs(prévision PV): le canal — trois grandeurs mesurées, et le précédent ne couvre pas ce lot
Découpage tranché : ems n'appelle pas, l'invariant 10 reste. La distinction
retenue est plus fine que « zéro réseau » — une intégration qui lit une API perd
UNE ENTRÉE quand le lien tombe ; un experience-plugin qui appelle met LA LOGIQUE
DE DÉCISION sous dépendance distante. L'attribution CC BY 4.0 disparaît avec le
découpage au lieu d'être gérée.

LE PRÉCÉDENT CITÉ NE COUVRE PAS CE LOT. awattar/tempo/spothinta sont des
précédents d'intégrations qui APPELLENT. Le plugin awattar amont n'expose que
des SCALAIRES — currentMarketPrice, average, lowest, highest, rank, validUntil.
La série ne traverse jamais : l'amont a résolu le problème en appelant DEUX
FOIS. Il n'existe donc aucun canal éprouvé pour une série, parce que personne
n'en a construit.

ET LE MOTEUR APPELLE DÉJÀ : spotmarketdataproviderawattar.cpp, dans CE dépôt,
sort en HTTPS vers api.awattar.at et alimente planSpotMarketCharging() — une
DÉCISION. C'est ce que l'invariant 10 interdit, en service, hérité et jamais
choisi. Un invariant qui a une exception non déclarée n'est plus un invariant,
c'est une habitude. À trancher séparément.

Les trois grandeurs, sur le code et non la doc :
- taille : plafond dur à 1 MiB (jsonrpcserverimplementation.cpp:554, connexion
  larguée au-delà). Une courbe 48 h fait 2-5 ko — la taille n'est pas le sujet ;
- rediffusion : PAR RÉVISION, pas par cycle. thing.cpp dédoublonne
  (if (oldValue == newValue) return). La prémisse surestimait d'un facteur 60 ;
- nommage : forecastPvCurve, et le mot dit que c'est un canal.

Les deux vrais dangers ne sont pas la taille : « cached » réécrirait things.conf
toutes les heures sur une carte SD (cached: false obligatoire), et la
JOURNALISATION D'ÉTAT créerait une base de données par une case à cochée dans
l'app — chaque révision copiant le JSON entier dans energylogs.sqlite, sans que
personne l'ait décidé.

Et ce que je vois de plus : un JSON qui traverse n'a plus de schéma. Un numéro
de version DANS la charge utile, avec refus plutôt qu'interprétation au mieux —
sinon le symptôme d'un changement de forme est « courbe vide », indistinguable
d'une nuit. issuedAt vient aussi de la charge utile, jamais de l'instant de
réception : sinon un redémarrage réétiquette la série et la table bitemporelle
perd ce qu'elle existe pour retenir.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-08 16:49:22 +02:00
Patrick Schurig
91250f081f docs(prévision PV): BLOQUÉ — le découpage contredit l'invariant « ZÉRO cloud »
Le §1 de ce design fait appeler une API distante par ems. C'est interdit ici, et
l'interdiction est explicite à quatre endroits : AGENTS.md invariant 10 (« aucun
appel réseau sortant vers un service distant […] le moteur expose l'état, il ne
contacte personne », dont la seule exception nommée est un socket local/LAN) et
docs/SAFETY.md, trois fois.

Ce n'est pas une objection de style : « fonctionne sans internet » est un
argument produit, et le mode dégradé L2 est bâti sur l'idée que rien
d'essentiel ne dépend d'un lien externe.

Je n'avais PAS vu ce conflit en écrivant le design — il a été soulevé hors de ce
fil, avec le point d'attribution CC BY 4.0 qui va avec : l'obligation suit
l'appelant, donc si ems appelle, elle atterrit dans un dépôt où elle n'est
écrite nulle part et serait perdue en silence, dans un dépôt GPL publié.

Aucun code avant arbitrage. Deux issues, et ce n'est pas à l'agent de trancher :
soit l'invariant 10 est amendé avec sa raison écrite et l'attribution posée ici,
soit le découpage change et l'appel reste chez le plugin météo.

Trois décisions rendues, consignées :
- nouvelle table, et la raison est plus forte que la mienne — la CLÉ CHANGE DE
  NATURE, une prévision révisée n'a rien à faire dans la base du mesuré ;
- un Get* sans *Changed : l'invariant 11 vise l'ÉTAT VIVANT, pas l'historique.
  La distinction n'y était pas écrite ; l'appliquer mécaniquement aurait produit
  une notification par heure sur une donnée que personne n'attend en direct ;
- aucun effet sur l'arbitrage. Le jour où l'étalement éco lira la prévision, ce
  sera un lot à part — et le §10 est la seule place où elle mordrait, en pariant
  sur une courbe dont le §3 dit qu'elle est un plafond systématique.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-08 16:35:11 +02:00
Patrick Schurig
5316a6f723 docs(design): la prévision PV est une série RÉVISÉE — table bitemporelle
Nouveau lot cadré par Patrick. Le plugin openmeteo reste météo pure ; les plans
PV, la conversion, l'appel API et l'historique vivent ici.

LE POINT DE CONCEPTION : la table n'est pas « valeur par heure » mais « valeur
par heure, telle que prévue à tel moment ». La clé est la PAIRE
(targetAt, issuedAt). Avec targetAt seul, chaque rafraîchissement écrase le
précédent : on perd les révisions, donc la comparaison prévu/réalisé — ce qu'on
veut précisément montrer. Même distinction que measuredSince.

Conséquence de contrat que le cadrage n'énonçait pas : « prévu » ne veut rien
dire sans HORIZON. Trois lectures (courante, veille, H−1) répondent à trois
questions ; publier « prévu » sans dire laquelle laisse deux écrans montrer deux
courbes pour le même jour.

Repris de l'agent openmeteo, mesuré et non supposé : indexation rétrograde ;
cinq facteurs ignorés, TOUS optimistes — donc un biais signé, pas un bruit : la
conversion produit un PLAFOND, pas une estimation ; la dispersion d'ensemble est
ANTICORRÉLÉE au biais, donc un indicateur de confiance qui rassure le plus quand
il se trompe le plus — pire que pas d'indicateur, et l'affiner l'empire ; et la
correction prévu↔mesuré est un apprentissage, hors GPL.

Le GPL n'apprend pas, il rend l'apprentissage POSSIBLE : la table bitemporelle
EST l'interface avec Héos, et l'écraser à chaque révision détruirait la matière
pour toujours sans que rien ne le signale.

Cinq ajouts au cadrage : targetAt en epoch UTC jamais en heure murale (le piège
du 6 septembre appliqué à une série entière — vérification sur des jours de 23 h
et 25 h) ; rétention 7 jours pleins puis effondrement aux DEUX bouts, parce que
n'en garder qu'un détruit la révision ; stockage chez nous et non dans
energylogs.sqlite, que l'amont migre ; l'absence est un TROU, jamais un zéro ;
et l'historique doit retenir SOUS QUEL PLAN une prévision a été convertie, sinon
un recâblage rend la série antérieure incomparable en silence.

Trois choses manquent pour coder, dont la convention d'indexation AVEC ses
paramètres : reprendre une conclusion sans ses prémisses, c'est hériter d'un
fait vrai sous des conditions qu'on ne saura pas reproduire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-08 16:30:25 +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
0504543d87 docs(relevé): second essai — de 9 A à 20 A, le double écrêtage était bien la cause
Même borne, même durée, +etm61 : commandée 13 à 20 A au lieu de 9, mesurée
8,4 kW au lieu de 4,4, et 716 Wh livrés en 5 minutes contre 276 en 10. Le motif
dit maintenant « 13800 W achetés sur 13800 W possibles » et boundBy vaut
loadCeiling — le plafond suit le soutirage de la maison d'un cycle à l'autre,
parce que evPhaseAllowanceW() le recalcule sur la bonne grandeur.

Trois observations gardées, dont une pour l'étape 4 : la FERMETURE d'une
dérogation ouvre elle aussi une divergence (attendu 0 W, mesuré 8367 W), et
elle est plus trompeuse que celle d'ouverture. Une divergence qui naît dans le
même cycle qu'un DEROGATION_ENDED est une descente en cours, jamais une
commande perdue — sans quoi chaque boost compterait pour un incident.

La fin par débranchement reste non éprouvée : la voiture est revenue avant les
deux confirmations. Le mécanisme a en revanche montré ce qu'on lui demande —
une absence isolée n'a PAS terminé la dérogation (LM-1500-c).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-08 12:00: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
Patrick Schurig
106806d8ce fix(dérogation): setChargingInfo déclenche un update — pose après, restitution différée
setChargingInfo() appelle update() lui-même, et le lot le touche aux deux bouts.

À LA POSE, l'ordre décide d'une minute. L'écriture du mode vient désormais en
DERNIER, après l'insertion dans m_derogations : le cycle qu'elle déclenche
trouve la dérogation déjà posée et la sert immédiatement. L'ordre inverse la
lui faisait manquer d'un cycle — soixante secondes d'attente sur un mécanisme
dont la définition est « tout de suite ».

À LA FIN, la restitution est différée d'un tour de boucle. Les fins sont
évaluées DEPUIS update() ; y appeler setChargingInfo() imbriquait un update()
dans un update() — deux plans construits et deux dispatchs pour le même
instant, sur des charges qu'un seul des deux croirait avoir commandées.
update() n'a aucun verrou de ré-entrance : rien ne l'aurait signalé, et la
trace aurait montré une borne commandée deux fois sans cause lisible.

simulation 68/68, 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 10:52:04 +02:00
Patrick Schurig
1eae99143b feat(§15): le boost est une DÉROGATION — servie au maximum, et qui s'efface
Une dérogation n'est pas un mode. Un mode se choisit une fois et vaut jusqu'au
prochain choix ; une dérogation répond à un besoin ponctuel et s'éteint avec
lui. Un boost encore actif après la fin est un mode qu'on a oublié de quitter.

Elle se superpose au mode courant, sert la charge à son MAXIMUM en tête de
passe éco — devant toute autre charge, quel que soit son rang — financée au
réseau. Le rang classe ce que l'installateur décide (LM-1002) ; une dérogation
est une action ponctuelle de l'utilisateur, et passe par une porte que LM-1002
ne ferme pas.

Mais elle ne surplombe PAS la sûreté : DRAW_CAP la borne et le délestage la
reprend. Servie la première dans ce qui RESTE, jamais au-delà — sans quoi on
rouvrirait le second chemin d'achat refusé au minimum réseau (LM-1500-e).

LA DURÉE EST OBLIGATOIRE ET BORNÉE, refusée à la pose hors de [60 s, 6 h].
C'est la règle du lot : une dérogation qui ne sait pas dire quand elle finit ne
devrait pas exister (LM-1500). Elle reste armée même avec une cible, parce
qu'une cible repose sur un instrument et qu'un instrument peut se taire — à la
perte du lien, la V2C FIGE sessionEnergy au lieu de l'effacer (LM-1012-d) : une
dérogation « 20 kWh puis stop » dont le compteur reste à 12 kWh n'atteint
jamais sa cible.

Trois décisions prises en écrivant, absentes du design :

- Les fins se lisent dans le CONTEXTE DU CYCLE, pas dans les adaptateurs.
  evaluerDerogations() tourne entre buildContext() et getPlan(), sur ce que le
  scheduler va lire. Une seconde lecture donnerait une seconde vérité, et une
  dérogation pourrait finir sur une mesure que le plan ne voit pas. C'est aussi
  ce qui rend le débranchement gratuit : la borne est déjà filtrée du contexte,
  son absence EST le signal.

- measurementLost ne mord QUE s'il y a une cible (LM-1500-b). Sans cible, rien
  ne dépend de la mesure : couper là punirait la charge du silence d'un
  instrument dont elle n'a pas besoin. Et le critère est
  telemetry.sessionMeasurable, jamais progress.regime : `progress` est absent
  d'une charge sans obligation en énergie, c'est-à-dire exactement le cas du
  boost — le critère aurait été inerte.

- La base de la cible se capture au PREMIER CYCLE, pas à la pose : au moment où
  l'utilisateur appuie, aucun cycle n'a construit de contexte. Prendre 0
  compterait depuis le début de la session — une dérogation « 5 kWh » posée sur
  une session déjà à 6 kWh serait atteinte avant d'avoir commencé (LM-1012-b).

Le mode précédent se MÉMORISE, il ne se déduit pas (LM-1500-d). Revenir à un
défaut détruirait un réglage en silence, et sur une borne la sortirait de
l'arbitrage : une dérogation qui laisse l'installation moins pilotée qu'avant
elle. Une borne en manuel est basculée en éco pour la durée — seul cas où le
mode est écrit, et modeForce retient qu'on l'a fait.

La passe confort est SAUTÉE sous dérogation : la fusion des niveaux est une
addition, et la dérogation demande déjà le maximum. Conséquence comptable à
connaître : elle est comptée entièrement en achat alors que le surplus présent
la finance en partie — gridW la surestime au soleil. Approximation héritée du
§10, non aggravée, et la raison pour laquelle la première vérification au banc
se fait le matin.

La fin est un ÉVÉNEMENT et dit sa CAUSE (LM-1500-f), publiée dans
derogationEnded[] au NIVEAU DE LA TRAME : au débranchement la charge n'est déjà
plus dans loads[], et l'événement disparaîtrait quand il explique le plus de
choses. Cinq causes, cinq gestes. Le mode dégradé la porte aussi — c'est là que
measurementLost se produit. telemetrySignificantlyDiffers ne la filtre jamais :
un événement ne vit qu'un cycle.

Éprouvé par testDerogationTakesTheHeadButNotTheSafety, dont la tête de passe est
prouvée par la RARETÉ : 7 500 W d'autorisation pour 8 000 demandés, la borne de
rang 9 servie ENTIÈREMENT et l'ECS de rang 1 réduite aux 500 restants — l'inverse
exact de l'ordre des rangs. Contre-épreuve : la même borne sans dérogation
reçoit 0 W. Et à autorisation nulle, AUCUN niveau — pas un niveau à zéro.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-08 10:31:00 +02:00
06e728415f docs: protocole du relevé du 2026-09-08, écrit avant la mesure
Même raison que le critère de tri du §6 : un protocole rédigé après coup se plie à
ce qu'on a vu.

TROIS OBSERVATIONS. (1) progress.regime de la V2C — première bascule observable sur
du réel, et la dérogation en dépend entièrement puisque sa fin measurementLost se
déclenche sur ce signal. Les trois issues possibles sont écrites d'avance, et
AUCUNE n'est un échec : chacune décide de ce qu'on peut promettre. (2)
command.divergent — les premières traces qui passent le critère du §6, maintenant
qu'une borne charge vraiment et qu'un compteur suit ; regarder d'abord les épisodes
COURTS, ce sont eux qui décident si l'étape 4 vaut le risque de rejouer une
commande. (3) Min+PV sur la borne, où le tirage est dix fois celui du chauffe-eau.

Et le biais de comptage est rappelé : contrairement à « error limit reached », les
lignes [Divergence] DÉBUT/FIN sont émises aux transitions seulement — c'est le
comptage juste.

CE QUI INVALIDERAIT LE RELEVÉ est écrit aussi : tout redémarrage de nymead pendant
la fenêtre repose la base d'avancement. Ne rien déployer pendant l'observation.
2026-09-08 06:22:01 +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
60babd5af7 docs(dérogation): la cible se dit en ÉNERGIE, et la fin par SOC disparaît du design
Correction de Patrick, et elle supprime un manque au lieu de le documenter.

PAS DE COMMUNICATION VÉHICULE, et il n'y en aura pas : aucun plugin constructeur
prévu, et une intégration cloud Skoda/VW serait contraire au 100 % local, fragile,
rafraîchie à la minute au mieux.

Mais l'énergie est la BONNE grandeur indépendamment de cette contrainte. « 20 kWh
puis tu t'arrêtes » demande sessionEnergy, que le plugin V2C construit depuis le
2026-08-29. « Jusqu'à 80 % » demanderait la capacité, l'état de santé et le
rendement — trois inconnues pour dire la même chose en moins sûr. C'est exactement
ce que LM-1009 a établi pour l'obligation éco : on mesure de l'énergie parce que
c'est ce qu'on sait mesurer.

La fin par SOC ne figure donc plus dans le design, et son absence est écrite comme
une DÉCISION, pas comme un manque — sinon quelqu'un l'ouvrirait comme un lot.

ET sessionEnergy A UN SILENCE CONNU, qui justifie le filet mieux qu'un cas d'école.
LM-1012-d, mesuré sur la V2C le 2026-08-30 : à la perte du lien, currentPower tombe
à 0 et les booléens à false, mais sessionEnergy RESTE À SA DERNIÈRE VALEUR. Une
dérogation « 20 kWh puis stop » dont le compteur se fige à 12 kWh n'atteindra
jamais sa cible et achèterait au maximum indéfiniment. C'est le comportement
documenté de la borne installée, pas une hypothèse.

Conséquence heureuse : les trois fins du VE — débranchement, cible en énergie,
durée — sont toutes exerçables sans véhicule communicant. Ce lot est éprouvable sur
le banc réel, là où une fin par SoC ne l'aurait jamais été.
2026-09-08 06:09:16 +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
ab413dd86e release: 1.15.2+etm58 — recrédit corrigé (LM-1004) 2026-09-07 16:33:28 +02:00
421e0452cc fix(LM-1004): le recrédit appartient à la passe qui dépense du surplus
La règle disait « attribué à la PREMIÈRE passe où la charge apparaît », et le code
l'appliquait fidèlement. Mais la passe éco n'est pas bornée par le surplus : elle
l'est par l'autorisation de soutirage, et ne touche jamais remainingSurplusW. Lui
attribuer le recrédit revenait donc à ne le créditer NULLE PART.

Conséquence : une charge servie en éco ne récupérait jamais sa propre consommation,
et son tirage déprimait le budget dont elle avait besoin pour recevoir du surplus
par-dessus. Mesuré sur .75 : borne à 3 899 W, surplus publié −3 148 W ; sans elle
il valait +700 W. Elle se privait exactement du surplus qu'elle pouvait recevoir,
et « minimum + surplus » ne servait jamais que le minimum.

LE DÉFAUT PRÉEXISTAIT au lot d'aujourd'hui — le chauffe-eau servi en éco l'avait
déjà. Il était invisible parce que son compteur affiche 0 W : une charge qui ne
mesure rien ne peut pas se priver de quoi que ce soit. Il a fallu une charge qui
mesure, et qui tire quatre kilowatts, pour qu'il se voie. C'est une septième forme
de l'homogénéité du banc, et la première où c'est l'ABSENCE de mesure qui masque et
non l'uniformité.

Le paramètre recreditDejaAttribue et l'ensemble recreditAttribue sont retirés
plutôt que laissés inertes : un mécanisme vestigial se relit comme une garde.

Suites : loadmodel 26/26, simulation 67/67, charging 48/48, spotmarket 32/32.
2026-09-07 13:17:56 +02:00
049eaeeaa1 docs(contrat): ce que phasePowerLimit = 0 désactive, et ce qu'il ne désactive pas
La phrase « 0 signifie non configuré — tout le smart charging est désactivé » était
vraie de l'amont et FAUSSE depuis la transplantation du waterfall. Mesuré des
semaines durant sur .75 : la cascade de surplus, les paliers et les planchers éco
tournaient normalement avec la limite à 0.

Ce qui est réellement inerte à 0 : drawCaps() ne publie aucun plafond,
houseDrawMargin() rend « sans objet », DRAW_CAP n'est jamais émis, et
shedWaterfallForOverload() sort à sa première ligne faute de seuil.

Conséquence désormais consequente : à 0, le moteur peut engager du soutirage sans
borne — et il le fait depuis que les planchers éco achètent. La couche de
protection existe et n'a rien à protéger.

Et l'unité est rappelée en tête : AMPÈRES par phase, pas watts. J'ai failli
recommander « 7 360 W pour du 3×32 A », ce qui aurait posé un plafond de 7 360 A —
inerte, mais qui aurait eu l'air configuré. Le contrat le disait déjà plus bas ; il
le dit maintenant là où on lit la valeur.
2026-09-07 12:50:45 +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
86aab086a2 docs(min-réseau): le plancher se dérive, et la piste monophasée ne tient pas
DEUX APPORTS DU BRIEF APP f2bd07d, intégrés au design.

§9 — LE PLANCHER SE DÉRIVE, et ça corrige mon étape D. Pour une borne, la valeur
n'a besoin d'aucun champ : maxChargingCurrent.minValue × phaseCount × 230 est déjà
sur la box, dans la classe du Thing. Écrire cette valeur dans needs.minPowerWAlways
à l'appairage, comme je le proposais, créerait une COPIE QUI SE PÉRIME — un
recâblage change le réglage phaseCount et la valeur stockée resterait fausse sans
que rien ne le dise. C'est « une valeur dérivable est illégitime quand on la
publie », appliqué au stockage.

Le champ garde sa raison d'être pour les charges en watts, qui n'ont pas de
plancher matériel à dériver. Étape D révisée : le mode marque que la charge veut
un minimum, la passe éco dérive la valeur au cycle. Une seule source, jamais
périmée.

§10 — LA PISTE MONOPHASÉE EST TRANCHÉE, et par la carte Modbus elle-même. Il
n'existe AUCUN registre de phases sur la Trydan : la campagne de portage a exploré
0x0BC2-0x0BD5 et 0x177A-0x1783, et le plugin le documente. La borne ne sait pas
dire son câblage, donc n'offre rien pour le changer. phaseCount est un RÉGLAGE,
déclaration de l'installateur recopiée dans un état.

Et le détourner serait dangereux : déclarer 1 phase sur une borne câblée en 3
ferait budgéter 1 380 W et commander 6 A SUR TROIS PHASES — 4 140 W tirés. La
borne démarrerait, au mauvais courant, sans erreur. Le réglage décrit le câblage,
il n'est pas un bouton de réglage du plancher.

Ce qui reste vrai : une borne CÂBLÉE en monophasé a un plancher de 1 380 W et se
sert bien mieux sur une installation à petit surplus. C'est une décision de
câblage à l'installation, à dire au dimensionnement — pas un réglage logiciel.
2026-09-07 10:56:52 +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
80a19a19dd docs: le mot est « minimum réseau » — ni garanti ni prioritaire
TON ARGUMENT REMPLACE LE MIEN. Ce n'est pas la modulation : l'ECS multi-paliers
module aussi. Ce qui sépare les deux cas, c'est qu'ON NE SAIT PAS COMBIEN DE TEMPS
LA VOITURE RESTE BRANCHÉE. Une obligation en énergie suppose une échéance, qui
donne son dénominateur à la cadence ; une borne n'en a aucune tant que personne
n'en déclare. « 4 140 W tant qu'elle est là » est la seule formulation qui ne
suppose rien. Un chauffe-eau a une échéance naturelle et une capacité finie :
l'énergie y est la bonne grandeur.

LE RANG EST CELUI DE L'INSTALLATEUR. Un minimum déclassé par une charge mieux
rangée est le comportement voulu — LM-1002. Le contredire par un mécanisme serait
le doublon qu'on refuse partout. Nuance vérifiée dans le code : la hiérarchie
éco/confort SURPLOMBE le rang (un confort de rang 1 passe après un éco de rang 3),
donc un minimum passe avant tout le confort, et le rang le départage face à un
autre besoin éco.

DRAW_CAP ET LA RÉSERVE BATTERIE PRIMENT. Un besoin, pas une exception aux
plafonds.

LE MOT. Les deux candidats échouent au même test que celui qu'on corrige — un nom
qui promet autre chose que ce que le code fait.

  garanti     : promet une certitude que rien ne tient.
  prioritaire : vrai face à TOUT le confort, faux face à un autre éco mieux rangé
                — donc vrai en régime normal et faux exactement dans le cas
                d'échec. Un mot qui ne se trompe que dans le cas intéressant ne
                sera jamais relu. Et il contredirait LM-1002 dans le libellé, en
                laissant croire que le mode réordonne.

RETENU : on nomme le FINANCEMENT, seule propriété qui tienne toujours et vraie
différence avec Eco — Eco dépense le surplus, ce mode ACHÈTE le complément.

  · libellé du mode : « Surplus + minimum réseau »
  · champ           : needs.minPowerWAlways, grammaire de son frère
                      minEnergyWhPerDay — les deux déclarent une demande, aucun
                      ne promet une livraison
  · bannis partout, commentaires compris : « garanti », « prioritaire »

Ce qui dit le résultat, c'est le motif : ECO_FLOOR_GRID chiffre l'achat, DRAW_CAP
nomme le plafond qui a empêché. Le nom déclare l'intention, le motif rapporte les
faits.

Document renommé DESIGN_MIN_RESEAU.md, et le mot purgé de son propre texte — y
compris des invariants, où « la garantie » s'était réintroduite quatre fois.
2026-09-06 15:13:39 +02:00
b5efcc6357 docs: design du minimum garanti — option 2, avec le renommage
Design avant code, comme pour le délestage et le §10. Rien d'implémenté.

LE CONSTAT EST DOUBLE. Le mode n'a jamais eu de minimum garanti — mort par
réécriture chez l'amont, puis mort par exclusion depuis 3g-2 (DESIGN_3g §5). Et
son nom promet l'inverse : `MinCurrent` désigne L'EXIGENCE DE LA BORNE, les 6 A
sous lesquels elle refuse de charger, pas une garantie du mode. Trois modes
annoncés, deux implémentés, et celui qui manque est celui que l'utilisateur
croyait utiliser — le défaut n'est pas qu'une fonction manque, c'est qu'elle est
annoncée.

POURQUOI MAINTENANT, sans attendre le prix. Rachat à 0,01 € : charger 30 % au
réseau en journée revient à 0,073 €/kWh contre 0,180 € la nuit. Sur la majorité du
parc neuf, aucun arbitrage n'est nécessaire pour savoir que c'est le bon choix. Le
prix ne devient décisif que sur les anciens contrats à 0,1296 €, où l'écart tombe
à deux centimes et s'inverse sous 62 % de couverture solaire. L'option 3 reste
l'horizon ; elle choisira S'IL FAUT garantir, pas COMMENT.

CONDITION 1 — machinerie du §10 : passe éco, borné par DrawCap, funding Grid,
motif qui dit combien est acheté. Exclut explicitement EV_GRID_START, dont les
trois bornes dérivent du surplus et qui achète hors du registre d'autorisation.

CONDITION 2 — un champ de plus DANS needs, frère de minEnergyWhPerDay, pas une
structure parallèle : `guaranteedPowerW`. À ne pas confondre avec minPowerW
(LM-1201) : le premier borne une consigne SI on sert, le second en crée une
TOUJOURS. La passe éco rend max(cadenceObligation, guaranteedPowerW), avec quatre
invariants — la garantie ne se tient jamais (donc watts retombe sur elle quand
l'obligation est tenue, pas sur zéro) ; progress ne décrit que l'obligation et est
ABSENT sur une garantie seule ; le motif dit lequel des deux mord ; une garantie
non honorée se dit par DRAW_CAP qui la nomme.

CONDITION 3 — le fil ne peut pas être renommé sans casser les clients déployés.
Ce qui se corrige est la DESCRIPTION au contrat, d'où l'app tire ses libellés.
ChargingModeNormal n'est pas « recharge immédiate au maximum » : c'est MANUEL, la
borne sort de l'arbitrage et le moteur ne commande rien — seule la protection de
surcharge la touche encore, ce qui doit rester et doit être dit.

QUATRE ÉTAPES, à comportement constant d'abord : corriger le contrat (aucun code),
porter et publier le champ sans s'en servir, la passe éco le prend en compte, puis
le mode s'y branche.

ET CE QU'ON NE SAURA PAS PROUVER AU BANC : 2 kWc contre un plancher de 4 140 W,
donc une garantie toujours financée au réseau et jamais le cas mixte. Le
chauffe-eau l'exercera dès qu'il aura un compteur réel — quatrième nomination de
la même précondition.
2026-09-06 14:51:35 +02:00
fd516daea5 docs(3g §5): Min+PV — le mode a un minimum garanti, qui n'a jamais tourné
Établi, aucun correctif posé.

1. ChargingModeEcoWithMinCurrent A un comportement de minimum garanti : branche 4
   d'adjustEvChargers(), 6 A inconditionnels, sans condition de budget ni
   autorisation de soutirage. Ce n'est pas un EcoCharging avec un seuil.

2. Mais ce chemin n'a JAMAIS tourné, deux fois pour deux raisons. Chez l'amont, le
   repli d'échéance réécrivait le mode en ChargingModeEco à chaque cycle : branche
   morte, réglage détruit en silence. Corrigé côté ETM. Puis depuis 3g-2, le
   crochet evCommandedByWaterfall() exclut du proxy toute borne que le waterfall
   commande — la branche redevient inatteignable, pour une raison nouvelle. Mort
   par réécriture, puis mort par exclusion, sans que rien ne le signale.

3. Et sur cette installation il n'aurait rien changé : plancher 4 140 W contre
   2 000 W de crête. La voiture aurait chargé entièrement au réseau, en
   permanence, sans qu'aucun mécanisme ne le compte.

4. EV_GRID_START ne peut pas porter un minimum permanent. Ses trois bornes
   dérivent toutes du surplus — budgetW > 0, seuil proportionnel, consommation de
   tout le budget — et un minimum permanent en est indépendant par définition. Les
   lever, c'est créer le second chemin. Et EV_GRID_START achète HORS du registre
   d'autorisation de soutirage, là où la passe éco achète sous DrawCap : deux
   mécanismes dont un seul est borné, et c'est le second qui ferait disjoncter.

5. La différence de nature demeure : l'obligation éco est une énergie avec
   échéance, Min+PV une puissance permanente. On peut approcher la seconde par la
   première, mais sous régime mesuré le plancher retombe à zéro dès la cible
   atteinte — ce n'est pas un minimum permanent.
2026-09-06 14:41:28 +02:00
9659518916 docs: verdict de la fenêtre du week-end — rien d'exploitable, et pourquoi
La V2C est restée HORS WATERFALL du samedi 18:00 au dimanche 11:46, 1 066 cycles
portant « Borne V2C Trydan hors waterfall (mode manuel ou aucun véhicule
branché) » et « has no car assigned. Ignoring... ». Elle n'a produit aucune trace
sur la période visée, et ce n'était pas un battement de lien.

La voiture est devenue visible à 11:46 dimanche, pas samedi soir. Huit cycles
utiles ont suivi, 11:47 à 12:08, sur le PV simulé à 9 kWc — démarrages en
tolérance d'acquisition puis consignes de surplus. Puis plus rien : le banc est
passé d'un simulateur 9 kWc à une installation réelle de 2 kWc, et 2 kWc ne peut
pas alimenter le plancher de 4 140 W d'une borne triphasée à 6 A.

Bilan : V2C aucune trace, chauffe-eau et pac-terrain écartées par le critère du §6
(compteurs constants, 1 500 et 800 W puis 0). C'est un résultat, pas un échec — il
dit que la fenêtre a été ouverte sur un banc qui ne pouvait pas la remplir, et il
nomme les deux raisons.

Précondition pour la prochaine : un compteur réel sur une charge pilotée, et une
borne effectivement dans le waterfall — vérifié au journal AVANT d'ouvrir.
2026-09-06 13:23:57 +02:00
4259faee5b docs(terrain): §11 a été validé contre des valeurs tapées, jamais mesurées
ECS-Meter et PAC-Meter sont des smartMeterConsumer de generic-energy appairés à la
main : leur currentPower est SAISI, pas mesuré. 1 500 W et 800 W constants jusqu'au
2026-09-05, puis 0 depuis le passage de la box aux vrais compteurs Eastron, faute
de quelqu'un pour l'écrire.

Donc tout le §11 — compteur dédié par charge, sessionWh depuis le compteur,
measurement.source "meter" — a été éprouvé contre des valeurs tapées.

CE QUI EST PROUVÉ : la plomberie. meterThingId résolu, état lu, valeur remontée
dans measurement.powerW, sessionWh alimenté, source = meter. La chaîne existe et
transporte.

CE QUI NE L'EST PAS : la mesure. Qu'un compteur réel rattaché à une charge réelle
produise une valeur qui SUIT la commande — donc que l'écart commandé↔mesuré
signifie quelque chose.

La distinction n'est pas académique : c'est celle qui rend les traces de divergence
inexploitables sur ces deux charges. Un compteur constant produit un écart qui ne
parle que de sa propre constance.

Et ça explique ce que voit l'app : une consommation affichée sur des compteurs qui
ne mesurent rien. Elle est fidèle, la box aussi — la chaîne est juste sans source.

Troisième nomination du sixième manque du mock, par un troisième chemin : les
traces de divergence, puis la pose d'obligation, maintenant le passage au réel.
2026-09-06 12:47:50 +02:00
322cf27e0f docs: crête PV à 6 kWc, voiture branchée, et la convention de signe du réseau
TROIS FAITS du 2026-09-06, consignés parce que les traces de lundi en dépendent.

11:47:23 — crête PV du simulateur portée de 9 000 à 6 000 W (banc/cmd/peak,
retenu, courtier 192.168.1.131). Vérifié contre le modèle : écart moyen 0,3 W sur
216 échantillons de plein jour, contre 1 102 W pour l'hypothèse 9 000.

~12:00 — voiture branchée à la V2C. pluggedIn true, lien connected,
sessionEnergy 0,44 kWh. Aucun redémarrage de nymead dans les deux cas : ce ne sont
pas des coutures de baseline.

CE QUE 6 kWc NE CASSE PAS, contrairement à ce que j'avais craint. minPowerW vaut
4140 W (3 × 6 A × 230 V) et le surplus ne dépasse pas ~2 700 W à cette crête ; j'en
avais conclu que la borne ne démarrerait jamais. Faux : mesuré à midi simulé, elle
passe en EV_GRID_START dès 2 140 W de budget, puis en SURPLUS_SETPOINT. Le chemin
d'appoint réseau existe pour ce cas, et BELOW_MIN_POWER seul ne dit pas que la
borne est bloquée — il dit que CE budget-là ne suffit pas. Encore une conclusion
tirée d'un motif lu hors de sa trajectoire.

CONVENTION DE SIGNE, mesurée pour lever une ambiguïté d'écran.
currentPowerAcquisition est NÉGATIF en injection, POSITIF en soutirage — même
convention que banc/etat/grid. Dix échantillons appariés dont un passage par zéro
(−638 → +1939 W) : 10/10 de même signe. currentPowerProduction est négatif de même,
par la convention nymea « from a consumer perspective ».

Un client qui affiche « consommation réseau » sur un currentPowerAcquisition
négatif inverse le sens : la valeur dit que l'installation INJECTE. Le signe porte
la direction ; la valeur absolue seule ne la porte pas.
2026-09-06 12:11:48 +02:00
adf40532cb docs: le fuseau publié qualifie une horloge, et son avertissement est conditionnel
Deux points qui gouvernent la LECTURE de champs que ce dépôt publie — donc ils
vivent ici, même si l'écran qui les rendra vit ailleurs.

TIMEZONE QUALIFIE UNE HORLOGE, IL N'OFFRE PAS UN CHOIX. « 12:00 heure de la box
(Europe/Paris) » dit la chose ; « Fuseau : Europe/Paris » sous un champ d'échéance
laisse croire qu'on choisit le fuseau de CETTE échéance. La nuance n'est pas
cosmétique : le fuseau est modifiable, mais c'est un réglage SYSTÈME qui déplace
d'un coup toutes les heures murales de l'installation — échéances, frontière de la
journée de reporting, et demain les plages tarifaires. Il n'y en a qu'un, et il
vaut pour tout.

L'AVERTISSEMENT DE DIVERGENCE EST CONDITIONNEL, jamais permanent : il ne s'affiche
que si les deux fuseaux comparés diffèrent réellement. Affiché en permanence, il
cesse d'être lu — et il cesse de l'être le jour exact où il devient vrai.

C'est la règle de l'absence qui informe, déjà appliquée deux fois dans ce contrat :
progressUnmeasurableCause absent quand la charge est mesurable, phase absent dans
DRAW_CAP quand aucune phase ne mord. Un champ toujours présent ne porte aucune
information ; un avertissement toujours affiché non plus.
2026-09-06 09:12:31 +02:00
f956e0f049 docs(inventaire): le sixième manque est nommé deux fois, donc prioritaire
Un compteur qui SUIVE la commande de la charge qu'il mesure a été nommé par deux
chemins indépendants en deux jours : par les traces de divergence (quatorze
épisodes qui mesurent une consigne variable contre un compteur constant), et par
la pose d'une obligation éco (ce qui borne la fenêtre n'est pas la cible, c'est la
constance du compteur).

Deux lots différents, deux symptômes sans rapport apparent, une seule cause. Le
manque n° 4 avait été nommé deux fois de la même manière, et c'est ce qui lui avait
donné sa priorité — même règle appliquée ici.

Quand un manque se fait nommer deux fois sans qu'on l'ait cherché, il ne s'agit
plus d'un confort de test : il décide de ce que plusieurs lots pourront prouver.
2026-09-06 08:54:35 +02:00
bf8d890c8e docs: conditions de la fenêtre d'observation du week-end
Posé dimanche 2026-09-06 à 08:19 (Europe/Paris) : chauffe-eau, minEnergyWhPerDay
3000 → 2000, dailyDeadline inchangé à 12:00. Discipline habituelle — capture sur
disque avant écriture, 76 clés comparées, 1 divergence attendue, 0 imprévue — mais
PAS de restauration : la configuration reste en place tout le week-end.

Symboles de débogage laissés en place jusqu'à lundi, donc aucune couture
supplémentaire pendant la fenêtre. Les deux dernières restent 06:53:30Z (fuseau) et
09:22:57 (installation des symboles), toutes deux du 2026-09-05.

CE QUE L'ÉCRAN MONTRE À LA POSE, et pourquoi ce n'est pas un défaut. À 08:19 le
palier est ECO_FLOOR_MET avec deliveredWh = 30 475 Wh pour une cible de 2 000.
Arithmétiquement correct et sans intérêt : le compteur de l'ECS publie 1 500 W en
permanence, donc deliveredWh monte de 1,5 kWh/h que la charge soit commandée ou
non. La période a basculé hier à 12:00 ; vingt heures plus tard elle a « livré »
30 kWh.

LA FENÊTRE UTILE EST À 12:00 et dure ~80 minutes. À la bascule, la base est reprise
(LM-1013-c) : deliveredWh repart de 0 face à 2 000 Wh sur 24 h, soit une cadence de
83 W financée par le réseau si le surplus manque, que le compteur fixe comble en
2000/1500 = 80 min. Deux fenêtres sur le week-end — dimanche 12:00 et lundi 12:00.

ET C'EST LA CINQUIÈME FORME D'HOMOGÉNÉITÉ QUI FIXE CE PLAFOND. Sur un banc dont le
compteur ne suit pas la commande, on ne peut pas avoir à la fois une cible modeste
et un chemin d'achat longuement exercé : ce qui décide n'est pas la cible, c'est la
constance du compteur. Tenir l'achat en éveil tout le week-end demanderait de
dépasser 36 kWh/jour, c'est-à-dire acheter au plafond — précisément ce qu'on
voulait éviter. La contrainte est du banc, pas du moteur.
2026-09-06 08:20:33 +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