+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
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
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
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
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
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
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
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
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
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 optimiseur ETM, 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