Ce dépôt est GPL et public : le nom commercial de l'optimiseur propriétaire n'y figure plus.
Remplacé par « l'optimiseur ETM » dans la prose et « etm-optimizer » là où l'on désigne le
composant (feuille de route, schéma). Sens inchangé. Aucun code touché.
`git grep -nIi 'h[ée]os'` : vide.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Constaté le 2026-10-16 : sur un code inchangé, la vérification échouait une heure après la
génération de la référence. testTheLearningLogIsFedByTheCycle date ses entrées sur
QDateTime::currentDateTimeUtc() (l'heure courante plus trois jours) : la référence ne valait
que dans l'heure UTC où elle avait été produite.
L'enregistreur définit clock_gettime : avec ETM_DECISIONS_EPOCH, CLOCK_REALTIME part de cet
instant au premier appel puis avance au rythme réel ; les horloges monotones (QTimer,
QElapsedTimer) ne sont pas touchées. Le lanceur pose 2026-10-16T05:00:00Z, l'heure où la
référence a été générée : la référence commitée n'est pas régénérée, elle est reproduite à
l'octet (md5 ea2c1db7…, 23 739 lignes).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EnergyArbitrator::instantanePrevision(now) rend la charge utile de
NymeaEnergy.GetForecastSnapshot — courbe, contentChangedAt, corrections par plan,
conversion, acLimitW, base et prix sur la grille, ou le motif qui les remplace. Le handler
n'ajoute que capturedAt, qui date la réponse. Le sérialiseur de l'optimiseur (T1) lira la
même fonction : un seul assemblage.
Code déplacé tel quel ; seuls changent les accès membres directs (m_forecastStore,
m_forecastAcLimitW, que les accesseurs rendaient tels quels) et une garde redondante
(`!rev.points.isEmpty()`, déjà garantie par le retour anticipé).
Neutre : référence de décisions IDENTIQUE ; suites inchangées (211/135/80/36+11, 0 échec) ;
aucun nouveau gabarit de journal >= info ; les 15 réponses GetForecastSnapshot des six
tests qui l'appellent sont identiques avant/après (trace ponctuelle non commitée, ThingId
et horodatages muraux neutralisés). contentChangedAt n'est rendu dans AUCUN test : son
déplacement n'est prouvé que par le texte.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
update() interroge désormais la stratégie par son interface. Sans endpoint d'optimiseur,
m_strategy pointe sur le rule-based : même objet, même appel. m_scheduler garde son type
concret — le rule-based restera le repli et le plan ombre d'une stratégie externe (T0 §0.1).
Aucun SocketScheduler.
Neutre : référence de décisions IDENTIQUE (23 739 lignes) ; suites inchangées
(simulation 211, loadmodel 135, charging 80, spotmarket 36 + 11 ignorés, 0 échec) ;
aucun nouveau gabarit de journal >= info.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Preuve « sans endpoint = identique au bit près », posée AVANT tout refactoring.
`nymea-energy-decisions` est la suite `simulation` compilée à l'identique, plus un
enregistreur branché à l'édition de liens (`--wrap`) : aucune ligne de la suite ni du
moteur n'est modifiée. Chaque cycle (`simulationCallUpdate`) écrit une ligne NDJSON :
télémétrie de l'arbitre (actions du créneau dans l'ordre, motifs, niveaux, budget, soutirage,
consigne posée), actions laissées au proxy, état des Things. Les scénarios qui interrogent
un `RuleBasedScheduler` local voient leur `Plan` sérialisé champ par champ.
Exclus : `command.since` et les compteurs `totalEnergy*` du mock (horloge murale), les
ThingId (alias par rang dans le créneau, à chaque ligne : deux bornes identiques
reçoivent leur rang provisionné dans l'ordre d'un QHash). `planId` n'est pas lu.
Référence générée sur 83483cc (1.15.2+etm69) : 66 fonctions, 23 673 cycles + 36 plans
directs, deux exécutions identiques à l'octet. Chaque processus a un /tmp privé : libnymea
range l'état de test sous /tmp/nymea-test, partagé et cumulatif (348 bornes orphelines
trouvées dans energy-load-configuration.json).
tests/auto/decisions/run-decisions.sh <build> check
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Design §32 : grille, mesure à la minute, profil, qualité, tarif mural,
règle du créneau à cheval (moyenne pondérée) et ce qui reste pour e1.
Protocole 1.1, correction avant publication : price.source hphc → tou
(jamais émis), baseload.quality optionnel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brief 3 T4, câblage.
- Get/SetTariff : tarif saisi persisté (energy.conf, clé tariff), à chaud ;
refus TARIFF_REFUSED {cause, zone, range, at, zones}, rien de modifié ;
timeZone de la box publié (les plages sont murales). Pas de *Changed.
- noterMinuteBilan() : minute du bilan (puissances moyennes, jamais les
cumuls) moins chaque charge pilotée, bornes comprises, selon sa source ;
none = puissance commandée. Rien sans compteur racine ni en mode dégradé.
mesurerCharge() et puissanceCommandee() extraits de la télémétrie : une
seule règle pour l'écran et pour la base.
- GetForecastSnapshot publie start/resolutionS/steps (grille de pv, ou
grille par défaut sans courbe), baseload {model, p50W, quality} et price
(null sans tarif ; créneau à cheval = moyenne pondérée).
- §11 : SurplusContext::tariff non rempli, aucune décision ne lit base ni
prix — épinglé par testNoDecisionReadsBaseloadOrPrice.
- INTERFACE.md : méthodes, paramètres, erreurs, unités, heure locale,
couverture 24 h, qualité de la base.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brief 3 T4. Une minute = consommation du site (échantillon d'une minute du
bilan, jamais ses cumuls) moins chaque charge pilotée selon sa source ; sous
none, sa puissance commandée, et la minute le garde. Deux tables dédiées
dans energy-forecast.sqlite (baseloadMinute, baseloadMinuteLoad), fil
d'écriture propre, lots de 5 min, lecture par journée, rétention 35 jours.
forecastPv et forecastLearning ne sont pas touchées.
Profil : médiane par case locale et type de jour (ouvré/week-end) sur
28 jours ; none sous 7 jours couverts à 90 %. Qualité : minutes comptées à
la commande, négatives, écartées, et par charge source et
constantMeasurement (la constante déguisée du banc).
Tests loadmodel : soustraction par source, profil synthétique (médiane,
jours de 23/25 h), seuil des 7 jours, fichier etm67 réel intact, jamais
bloquant, redémarrage, rétention, fichier illisible.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brief 3 T4. Une zone = {label, priceCtkWh, ranges[]}, plusieurs plages par
zone ; tarif unique = une zone 00:00-24:00 (source fixed), sinon tou.
Validation stricte et codée (prix ≥ 0, "HH:MM", 24 h sans trou ni
chevauchement, minute en cause rendue). importSeries() lit les plages en
heure murale, minute absolue par minute : jours de 23 h et 25 h exacts ; un
créneau à cheval reçoit la moyenne pondérée par la durée. Aucune décision ne
le lit (§11).
Tests loadmodel : validation, jours de 23/25 h et créneau à cheval sur
oracles Python zoneinfo.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Décision du 2026-10-14 : l'app trace prévu × (1 ± dispersion journalière d'ensemble),
libellée « fourchette météo ». Le §4 reste la règle du moteur et du protocole : la
dispersion ne qualifie pas la production, n'entre jamais dans p10W/p90W, et la bande
apprise la remplacera.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lus par leur nom, comme kwp. observedGtiTime == 0 ou états absents : rien.
Copies seulement (§11) : aucune décision ne lit ces valeurs, valid n'en
dépend pas.
Mock : pvPlane porte les états observedGti et observedGtiTime (noms
d'openmeteo). testTheLearningLogIsFedByTheCycle : le GTI observé publié pour
t0 + 1 h complète la ligne déjà écrite du pas t0, sur son plan seulement.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trois colonnes nullables : gtiObserved (W/m²), gtiObservedSource
(openmeteo-satellite-state), gtiObservedAt (observedGtiTime tel que publié).
Rattachées au pas [T − 1 h, T) : même convention que gti prévu (§14.3),
Open-Meteo horodatant une moyenne horaire à la fin de son heure.
L'observation arrive 1 à 4 h après l'écriture du pas : elle complète la ligne
existante par un UPDATE ciblé — ces trois colonnes seulement, seulement si
gtiObserved IS NULL. Seule exception à « une ligne n'est jamais réécrite » ;
elle ne crée jamais de ligne. Encore en tampon, elle entre par l'INSERT.
Une table de forme etm67 exacte est migrée par ALTER TABLE ADD COLUMN dans une
transaction : lignes conservées, nouvelles colonnes NULL. Le mécanisme « forme
inattendue → refaite » ne vaut plus que pour les autres formes.
Tests loadmodel : migration d'une base écrite par le code etm67 lui-même
(fixture data/energy-forecast-etm67.sqlite), rattachement, retard, nuit à 0,
valeur jamais remplacée, aucun autre champ touché.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Schéma et clé de forecastLearning, règles NULL, révision « en vigueur à X »,
tableau détectable / non détectable de l'écrêtage, neige, écriture et base
abîmée, limites. Inventaire du mock : pvPlane (paramètres) et solarInverter.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
observerPourApprentissage(now), après lireCourbePrevision() : copies seulement
(§11), aucune requête dans update(). Paramètres de conversion lus sur le Thing
plan par leur nom, jamais recopiés (§28 décision 1). Minutes du bilan branchées
sur EnergyLogs::powerBalanceEntryAdded(1 min), sous la garde de simulation.
Mock : pvPlane porte kwp/performanceRatio/tempCoeffPctPerK/noctC ; nouvelle
classe solarInverter (smartmeterproducer), seul producteur du mock.
Test : testTheLearningLogIsFedByTheCycle.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brief 3 T2. Nouvelle table dans energy-forecast.sqlite, clé (targetAt, siteId,
planId) : le pas écoulé est un fait clos, jamais réécrit (ON CONFLICT DO NOTHING,
pas INSERT OR IGNORE qui avalerait un NOT NULL). Les révisions restent dans
forecastPv ; la table y renvoie par la clé de contenu du §20 (révision en vigueur
à J−1 18:00 locale et à H−1).
gti/tempC et paramètres v2 NULL en v1, jamais 0. Production moyenne depuis les
échantillons d'une minute du bilan, NULL sous 90 % de couverture ou sans
producteur. Écrêtage détecté seulement contre le plafond AC déclaré ; neige =
chute lue sur le site. Écriture sur un fil dédié ; rétention 400 jours ; table
abîmée refaite ou mise de côté, le service continue.
Tests loadmodel : 5 fonctions, 13 contre-épreuves vérifiées échouant.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SetForecastSite stockait l'identifiant tel que reçu ; forecastSnapshot() et
lireCourbePrevision() le comparent en chaîne à parentId().toString(), qui a
des accolades. Un UUID envoyé sans elles était accepté, puis le snapshot
restait muet et la table vide.
Normalisation par ThingId(...).toString() (siteIdCanonique) à l'écriture
(setForecastSiteId) ET à la relecture des réglages (relireReglagesPrevision,
extraite du constructeur) : une valeur persistée sans accolades par une
version antérieure est relue correctement après mise à jour.
Test testTheForecastSiteIdIsMatchedWhateverItsBraces : forme à accolades,
sans accolades, majuscules (RPC), arbitre direct, valeur persistée relue,
plus une contre-épreuve sur un autre site. Contre-épreuves vérifiées : sans
la normalisation, le test échoue à la première forme sans accolades ; sans
la normalisation à la relecture seule, il échoue sur la valeur persistée.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Décision de Patrick du 2026-10-13. Constaté le 2026-10-12 : `from`,
`to` ou `knownAt` illisible valait l'epoch 0 en silence ; un `to`
illisible rendait une fenêtre vide, indistinguable de « rien
d'enregistré ».
Les dates sont validées avant toute lecture. Illisible (y compris "") →
`energyError: EnergyErrorInvalidParameter` (énumération amont),
`fault: FORECAST_DATE_UNREADABLE`, `faultParams.fields` (paramètres en
cause). Journal rendu par renderFr().
Schéma déclaré modifié, de façon additive : `energyError` (requis,
EnergyErrorNoError sur toute lecture), `o:fault`, `o:faultParams`.
`forecastPoints` reste requis, donc `[]` en erreur.
Test : testAnUnreadableForecastDateIsAnError (chaque date seule, les
trois ensemble, chaîne vide ; lectures valides sans motif). Le balayage
des getters vérifie `energyError` requis. Contre-épreuve : validation
neutralisée → échec (EnergyErrorNoError au lieu d'InvalidParameter).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Décision de Patrick du 2026-10-13 : une app plus ancienne qui ignore
`acLimitW` effaçait le plafond à chaque enregistrement du site.
`acLimitW` devient l'exception explicite au remplacement en entier :
absent = inchangé, null = effacer, entier = poser dans [100, 1 000 000] W
(bornes validées). Désigner aucun site retire aussi le plafond
(invariant « plafond ⇒ site »).
Vérifié dans le harnais (nymea 1.15.2) : la validation refuse `null` sur
un Uint (« Invalid UInt », status error). Le paramètre est donc déclaré
`Variant` — changement du schéma déclaré par rapport à T1 — et sa forme
est contrôlée dans le handler (null ou entier ; chaîne, fraction,
booléen refusés sans rien modifier).
Tests (testTheSiteAcLimitTravelsWithTheDesignation, réécrit) : absent
conserve (mémoire, RPC, disque), null efface (clé retirée, champ omis),
refus de forme et de bornes sans modification, 2500.0 accepté, retrait
du site. Contre-épreuve : handler revenu à « absent = efface » → échec à
l'étape « absent conserve ». Le test D2 passe désormais par null pour
effacer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Décision de Patrick du 2026-10-13. Le `corrections` du site reste
l'intersection (+ `ac-limit` si plafond) ; le détail se lit désormais en
donnée : `perField`, une entrée par plan sommé, `{id, corrections}`,
triée par id, présente seulement avec une courbe. Pas de booléen
« divergent ». Le journal garde son message.
- EnergyArbitrator::forecastSnapshot() : paramètre de sortie optionnel
`parPlan` (ForecastPlanLu) ;
- handler : `o:perField` déclaré et servi ;
- mock : classe `pvPlane` (état forecastCurveTransport) + addPvPlane() ;
premier test qui fait passer une COURBE par le RPC
(testTheSnapshotPublishesEachPlanesCorrections : divergence,
concordance, plafond, refus). Contre-épreuve : parPlan non rempli →
le test échoue (0 entrée au lieu de 2) ;
- OPTIMIZER_PROTOCOL §5.2, INTERFACE.md, design §30.2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Décision de Patrick du 2026-10-13. `fallback` avait été écrit sans
définition (protocole §5.2, design §28.5) et aucun émetteur ne l'a jamais
produit : correction avant publication, notée au changelog 1.1 du
protocole. Une prévision dégradée (température absente) se lit dans
`corrections`, pas dans `conversion`. Le champ reste pour des valeurs
futures.
- OPTIMIZER_PROTOCOL §5.2 et §0 (1.1) ;
- INTERFACE.md, GetForecastSnapshot ;
- design : notes datées en §28.5 et §29, nouveau §30.1 ;
- commentaires du handler (aucun changement de comportement).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Prérequis du déploiement d'openmeteo v2 (§28) :
- `depuisTransport()` lit v1 et v2. v2 exige `corrections` (liste) ; une
correction inconnue refuse le plan (`CORRECTION_<nom>`), `ac-limit` compris,
qui appartient au site. Autre version : `VERSION_<v>`, jamais devinée.
`gti[]`/`tempC[]` ne sont pas lus ici (enregistrement : T2).
- `acLimitW`, paramètre de SITE porté par `Get/SetForecastSite` : optionnel,
absent = aucun plafond (jamais 0), borné [100, 1 000 000] W, refusé sans
site ; la désignation se remplace en entier. Persisté (`forecastAcLimitW`).
- `ForecastStore::sommerPlans()` : somme des plans du même run ET du même pas,
puis écrêtage à `acLimitW × pas / 3600` APRÈS la somme. Fonction unique pour
la table et le snapshot.
- `GetForecastSnapshot` publie `corrections` (intersection si les plans
diffèrent, journalisé au changement ; `ac-limit` si plafond configuré),
`conversion: "physics"` et `acLimitW`.
Aucune décision ne lit la prévision (§11) ; boucle de sécurité ni appelée ni
modifiée ; pas de `*Changed`.
Tests : loadmodel `testTransportV2IsReadAndAnUnknownCorrectionRefused`,
`testTheSiteCurveIsClippedAfterTheSum` (contre-épreuve faite : plafond
neutralisé → échec) ; simulation `testTheSiteAcLimitTravelsWithTheDesignation`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Le fournisseur spot aWATTar était le seul flux sortant du moteur (invariant 10).
Il n'est plus compilé que sous `qmake CONFIG+=etm_spotmarket` ; la source reste
dans l'arbre pour la synchronisation amont.
Sans l'option (build livré) :
- `spotmarketdataproviderawattar.*` hors de SOURCES/HEADERS, enregistrement
sous `#ifdef ETM_SPOTMARKET` ;
- aucun `QNetworkAccessManager` : le plugin et l'expérience de simulation
passent par le même point, `SpotMarketBuild::creer()` ;
- `SetSpotMarketConfiguration` refuse l'activation avec
`EnergyErrorInvalidParameter` + `fault: SPOT_MARKET_NOT_IN_BUILD` (code,
pas de phrase). Désactiver reste accepté ;
- `planSpotMarketCharging()` est inerte : `enabled()` ne peut plus devenir vrai.
Tests :
- simulation `testSpotMarketIsOutOfTheBuild` ; contre-épreuve faite (un
QNetworkAccessManager réintroduit fait échouer le test) ;
- spotmarket `shippedBuildHasNoProvider`, sur le .so réel ; les 4 tests qui
lisent la liste aWATTar sont sautés hors option et passent avec l'option ;
- `testDecisionReasonRendering` : ligne de rendu du nouveau code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`setEnabled(true)` appelait `m_currentProvider->enable()` sans vérifier qu'un
fournisseur avait été retenu. Deux chemins y mènent :
- l'identifiant persisté (`SpotMarket/providerId`) ne correspond à aucun
fournisseur enregistré : `changeProvider()` échoue, le pointeur reste nul ;
- aucun fournisseur n'est enregistré : `keys().first()` sur une liste vide.
Le premier est atteignable en amont (fournisseur retiré entre deux versions,
réglage reporté). Le second le devient dès qu'aucun fournisseur n'est compilé.
Correctif isolé : l'activation est refusée en `qCWarning` et le gestionnaire
reste désactivé.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Le brief 2 fixe le transport à v: 2 ; la note de §28 l'écrivait au
conditionnel. Elle dit maintenant l'ordre de livraison qui en découle :
ems (lecteur v1 et v2) avant openmeteo v2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Consigne, sans réécrire le reste : paramètres physiques par champ
(coefficient de température, NOCT) sur le Thing plan openmeteo, qui
applique la correction de température ; plafond AC = paramètre de site
côté ems, appliqué après la somme des plans ; openmeteo publie en plus le
GTI brut et temperature_2m horaire ; pas horaire conservé, 15 min avec le
day-ahead ; §5 maintenu (conversion ∈ physics|fallback, rien d'appris dans
le GPL) ; §11 maintenu (aucune décision sans lot dédié).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Remplacement verbatim par la v1.1 figée (aucune réécriture). Additif sur le
fil : prévisions explicites (forecast), groupes, domaine, besoins enrichis,
measurement {source, powerW}, validUntil, reasonCode/reasonParams, force
interdit sur le fil. Le kind `stage` et l'adaptateur `relay-stages`, jamais
livrés, sont retirés comme correction de brouillon.
Références vérifiées contre le dépôt ; les écarts (chemins inexacts,
porteurs absents) sont consignés dans le rapport du brief 1, pas corrigés
dans le texte figé.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Alerte de Patrick : 20 kWh n'est pas plausible sur 2 kWc. Le contrôle qui
tranche, fait sans rien déployer — la crête comparée au plafond physique :
CRÊTE p = 1780 W plafond 2 kWc = 2000 W <- sous le plafond
2026-09-09 : 6,72 kWh (couvert)
2026-09-10 : 13,41 kWh (dégagé)
La conversion n'est pas en cause (la crête tient sous le plafond, donc
p x step/3600 est appliqué correctement), et la sommation non plus : le total
couvre 48 créneaux, c'est-à-dire DEUX JOURS. Chaque journée est dans la
fourchette attendue de 8 à 12 kWh.
LE DÉFAUT ÉTAIT DANS MON RELEVÉ, PAS DANS LA DONNÉE. J'ai écrit « 20 636 Wh sur
48 créneaux » — qui portait sa période — puis « 20,0 kWh » tout court, qui ne la
portait plus. La seconde a produit exactement le doute qu'elle méritait.
Une énergie sans sa période est un chiffre qui sera mal lu, et il le sera par
quelqu'un qui connaît l'installation mieux que celui qui l'a écrit. C'est la
famille des chiffres crédibles : bon ordre de grandeur pour qui ne vérifie pas,
faux du double pour qui vérifie. Le mal n'était pas dans le système mais dans la
façon d'en rendre compte — un relevé est un instrument comme un autre, et
celui-ci mesurait juste en affichant mal.
Corrigé : l'échantillonnage ventile par JOUR et publie la CRÊTE, seule grandeur
comparable à une puissance déclarée. Deux nombres confrontables à quelque chose,
au lieu d'un total qu'on ne peut confronter qu'à soi-même.
Et la vérification confirme le performanceRatio à 1,00 : 13,4 kWh un jour dégagé
de septembre sur 2 kWc est le HAUT de la fourchette, pas au-delà. Optimiste de
10 à 20 %, comme annoncé — il s'ajoute au plafond du §3 sans jamais l'expliquer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
GetPvForecast sert la TABLE ; GetForecastSnapshot sert la DÉCISION. Un arbitre
qui lit la table lirait sa propre trace au lieu de son entrée : il ne mesure
plus, il se relit. Le snapshot lit le canal, directement, sans persistance.
Écrit maintenant parce que la distinction est nette maintenant : dans six
semaines, le premier consommateur prendrait GetPvForecast sans y penser.
LES DEUX ABSENCES, TRANCHÉES. Aucun candidat : silence. Site désignable non
désigné : MISSING_FORECAST_SITE. Charge utile refusée : FORECAST_PAYLOAD_REFUSED
avec sa cause. Canal muet ou plans de runs différents : ni courbe ni motif.
Dans tous les cas forecastPoints est ABSENT, jamais une liste de zéros — une
prévision manquante et une prévision nulle sont deux affirmations différentes.
Deux motifs, deux gestes : configurer, ou regarder le producteur.
DATER LE CONTENU, PAS SEULEMENT LA LECTURE. capturedAt date la réponse et
distingue « pas répondu » de « répondu, et rien ». Mais un producteur FIGÉ se
lirait comme une prévision stable — le silence qui ressemble à un résultat, sous
sa forme la plus coûteuse puisque celui qui le lit décide d'ACHETER. D'où
contentChangedAt, qui vient du magasin non comme entrée mais parce qu'il SURVIT
AU REDÉMARRAGE : en mémoire, il se serait remis à zéro à chaque relance, et un
producteur figé aurait paru frais après le premier redémarrage venu. Omis quand
le magasin n'a jamais vu ce contenu, plutôt que deviné.
ET UN MÉCANISME ENTIER ÉTAIT EXCLU DU BANC D'ESSAI PAR UNE ACCOLADE. En écrivant
le test de frontière, forecastStore() s'est révélé NUL : le magasin était créé
dans un #ifndef ENERGY_SIMULATION, par accident de placement — ajouté à la suite
du battement de télémétrie, qui lui est bien un déclencheur d'horloge murale.
Toute la prévision était donc absente du binaire de test. Aucune suite ne
pouvait l'exercer à travers l'arbitre, et le balayage des getters voyait
GetPvForecast répondre correctement… par sa branche « magasin absent ». Un
mécanisme entier hors du banc, tous les voyants au vert. Le test qui l'a trouvé
ne le cherchait pas : il voulait vérifier une frontière et a buté sur l'absence
de ce qu'il devait franchir.
Le magasin sort de la garde — il n'est pas un déclencheur d'horloge murale. Sa
purge, si, et elle y reste.
loadmodel 31/31, charging 48/48, spotmarket 32/32, simulation 71/71,
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 CLASSES GRAVÉES DANS AGENTS.md.
1. LE CONTRÔLE QUI DISPARAÎT AVEC CE QU'IL SURVEILLE. Un balayage assis sur la
DÉCLARATION passait au vert quand la déclaration disparaissait : le défaut ne
trompait pas le détecteur, il le SUPPRIMAIT. Il occupe une place vide dans la
famille — la contre-épreuve qui ne mord pas est là mais ne peut pas échouer ;
l'identité qui se referme est vraie par construction ; la mesure qui porte à
côté vise une grandeur voisine ; celui-ci n'est PLUS LÀ, et son absence est
silencieuse.
Remède général : ancrer le critère sur ce qui SUBSISTE quand la référence
disparaît. Ici, la réponse plutôt que la déclaration. Et la seule façon de s'en
apercevoir est de REJOUER le défaut — la relecture donne toujours raison à qui a
écrit.
2. UN PARAMÈTRE OPTIONNEL QU'AUCUN TEST N'EXERCE EST UN PARAMÈTRE QU'ON PEUT
PERDRE. Aucun appel nominal ne l'utilise, donc sa disparition ne casse rien de
visible ; elle ne manquera qu'au client qui s'en sert, et le symptôme sera chez
lui. Ce qui n'est jamais exercé n'est pas « peu testé » : c'est indistinguable
d'absent, alors qu'un client lit les optionnels dans l'introspection et les
croit disponibles.
Fermé plutôt que noté : le balayage fait un SECOND PASSAGE avec les optionnels
renseignés. Contre-épreuve vérifiée — retirer o:knownAt fait sortir « Invalid
params: Invalid key: knownAt ».
AU CONTRAT : lire le STATUT avant la charge utile. Un appel refusé et un appel
qui ne rend rien se ressemblent et n'accusent pas le même — un client qui ne lit
que `params` conclut que le moteur est muet et accuse un code qui n'a pas tourné.
ET UNE NON-OBSERVATION, écrite comme telle : le cycle de 09:20 ne prouvera rien
de mon côté. Il ne reste qu'un plan depuis le retrait de 08:11:36, et une garde
qui compare deux plans ne s'exerce pas sur un seul. Un silence signifie « rien à
comparer », pas « les plans sont alignés » — les deux produisent la même trace,
aucune ligne. Le seul relevé exploitable reste celui de 08:08, obtenu par le
chemin forcé et non par la boucle horaire.
simulation 70/70, 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 trou nommé hier : la surface RPC n'est couverte par aucun test. Une méthode
qui ne répond plus, en silence, pendant que 31 unitaires, la simulation et
ci-quality passent.
Simulation::testEveryGetterReturnsWhatItDeclares appelle les 14 méthodes
NymeaEnergy.Get* et vérifie, dans cet ordre : le STATUT est success, la charge
utile n'est pas vide, et chaque clé requise de `returns` est servie. Les Set*
sont exclus — ils mutent, et les appeler à l'aveugle serait pire que le trou.
MA PREMIÈRE VERSION ÉTAIT UNE CONTRE-ÉPREUVE INERTE. Elle disait « pour chaque
clé déclarée, vérifier qu'elle est servie » : en retirant la déclaration de
GetPvForecast, elle PASSAIT — il n'y avait plus rien à vérifier. Le défaut
désactivait son propre détecteur, soit exactement le mode d'érosion catalogué
dans AGENTS.md, rencontré dans le contrôle écrit POUR fermer un trou.
Le critère est donc passé de la DÉCLARATION à la RÉPONSE : un getter qui ne rend
rien est cassé, quelle que soit sa déclaration. C'est l'affirmation qui tient
debout sans elle.
ET LA CONTRE-ÉPREUVE A CORRIGÉ MON DIAGNOSTIC. J'avais écrit que nymea élimine
la charge utile quand `returns` n'est pas déclaré. C'est FAUX, vérifié : la
méthode répond correctement sans returns. Ce qui casse, c'est la déclaration des
PARAMÈTRES — sans elle l'appel est rejeté à la validation (« Invalid params:
Invalid key: from ») et le handler n'est jamais atteint.
La différence n'est pas académique : un client qui ne lit que `params` voit une
réponse vide et accuse le handler, qui n'a rien fait. J'avais le bon coupable et
le mauvais mécanisme — conclusion vraie sur un raisonnement faux, et rien ne
l'aurait montré sans une contre-épreuve qui rejoue le défaut. Le contrôle regarde
donc le STATUT en premier, pour nommer le vrai coupable.
Ce que le balayage NE couvre pas, écrit plutôt que sous-entendu : les Set* (non
appelés), la FORME des valeurs (une clé présente ne dit rien de son contenu), et
les paramètres OPTIONNELS (o:knownAt n'est jamais exercé).
simulation 70/70, 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 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
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
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
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
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
+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