Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
54 KiB
Prévision de production PV — une série, et elle est révisée
✅ TRANCHÉ le 2026-09-08 —
emsn'appelle pas. Voir §12.L'encadré ci-dessous est conservé comme historique de la décision.
⛔ (résolu) — le §1 contredisait un invariant du dépôt
Ce document propose que
emsappelle lui-même une API distante. C'est interdit ici, et l'interdiction est explicite à quatre endroits :
AGENTS.mdinvariant 10 : « ZÉRO cloud — aucun appel réseau sortant vers un service distant […] Le moteur expose l'état, il ne contacte personne. » La seule exception nommée est le client d'optimiseur sur socket local/LAN, jamais un service externe ;docs/SAFETY.md§« Signalisation locale (zéro cloud) », puis deux rappels : « Aucun canal sortant réseau », « Le système fonctionne SANS internet ».Ce n'est pas une objection de style : « fonctionne sans internet » est un argument produit (autoconsommation, local-first), et le mode dégradé L2 est construit sur l'idée que rien d'essentiel ne dépend d'un lien externe.
Et l'attribution CC BY 4.0 suit l'APPELANT. Si
emsappelle, l'obligation d'attribution d'Open-Meteo atterrit dans un dépôt où elle n'est écrite nulle part — donc elle serait perdue, en silence, dans un dépôt GPL publié. (Point soulevé hors de ce fil ; noté ici parce que c'est ce dépôt qui la porterait.)Aucun code ne s'écrit 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 dans ce dépôt ; soit le découpage change et l'appel reste chez le plugin météo, qui a déjà le droit de sortir.
Tout ce qui suit reste valable sous réserve de cet arbitrage : le §2 (table bitemporelle) et les §3 à §5 ne dépendent pas de qui appelle.
Ouvert le 2026-09-08. Design avant code, rien d'implémenté. Cadrage donné par Patrick. Les constats météo viennent de l'agent openmeteo et sont repris tels quels : ils sont mesurés, pas supposés.
1. La frontière, et pourquoi elle tombe là
| responsabilité | |
|---|---|
| plugin openmeteo | météo pure — irradiance, température, couverture. Rien de photovoltaïque |
| ems (ici) | les plans PV (crête, orientation, inclinaison), la conversion, l'appel API, et l'historique |
Le plugin météo ne connaît pas l'installation ; l'installation ne se déclare pas dans un plugin météo. Et c'est ici que vivent déjà la configuration persistée, le compteur de production et la frontière RPC — donc ici que la série peut être servie à l'app sans traverser une troisième maison.
Pourquoi ce n'est pas un état nymea
Le besoin n'est pas de décider, c'est d'afficher : la courbe prévue d'aujourd'hui et de J+1, superposée à la production réelle.
- Aucun état nymea ne porte une série. Un état est une valeur à un instant.
suggestLoggingne construit que du passé. Il enregistre ce qu'un état a valu. Une prévision porte sur des heures qui ne sont pas arrivées — il n'y a rien à enregistrer, il y a quelque chose à écrire d'avance.
C'est le même geste que le logging natif pour les compteurs, à ceci près qu'il regarde devant.
2. LE POINT DE CONCEPTION — la table est BITEMPORELLE
Une prévision est révisée. Celle de 8 h pour 15 h n'est pas celle de 14 h pour 15 h.
La table n'est donc pas « valeur par heure ». Elle est « valeur par heure, telle que prévue à tel moment » :
CREATE TABLE forecastPv (
targetAt INTEGER NOT NULL, -- epoch UTC de l'heure VISÉE
issuedAt INTEGER NOT NULL, -- epoch UTC de l'ÉMISSION de cette prévision
forecastWh REAL NOT NULL,
model TEXT,
PRIMARY KEY (targetAt, issuedAt) -- la CLÉ EST LA PAIRE
);
La clé est la paire, et c'est tout le lot. Avec targetAt seul, chaque rafraîchissement
écrase le précédent : on perd les révisions, donc la comparaison prévu/réalisé — c'est-à-dire
exactement ce qu'on veut montrer, et ce dont Héos aura besoin.
C'est la même distinction que
measuredSince: quand on a mesuré, et ce qu'on a mesuré. Deux dates, deux questions, et les confondre en perd une.
Conséquence de contrat : « prévu » ne veut rien dire sans HORIZON
Dès que les révisions coexistent, « la prévision pour 15 h » est ambiguë. Toute lecture doit nommer son horizon, et l'écran aussi :
| lecture | requête | ce qu'elle répond |
|---|---|---|
| courante | pour chaque targetAt, la ligne de issuedAt maximal ≤ maintenant |
« ce qu'on prévoit en ce moment » |
| veille | issuedAt le plus proche de J−1 à heure fixe |
« la prévision de la veille valait-elle quelque chose ? » |
| à H−1 | issuedAt le plus proche de targetAt − 1 h |
« la meilleure estimation possible » |
Les trois répondent à des questions différentes. Publier « prévu » sans dire laquelle laisse le client en choisir une au hasard, et deux écrans montrent deux courbes pour le même jour.
3. Ce que la conversion IGNORE — cinq facteurs, tous optimistes
Repris de l'agent openmeteo, mesuré :
- l'ombrage,
- l'encrassement,
- la température de module,
- l'écrêtage onduleur,
- les salissures.
Les cinq vont dans le même sens. Ce n'est donc pas un bruit qui se compense : c'est un biais systématique et signé. La conversion ne produit pas une estimation, elle produit un plafond.
À écrire dans le contrat, pas seulement ici. Une courbe présentée comme « production prévue » et systématiquement au-dessus du réel se lit comme une installation qui sous-performe. L'utilisateur cherchera une panne qui n'existe pas — et il aura raison de chercher, puisque l'écran lui aura affirmé une chose fausse.
4. La dispersion d'ensemble ne QUALIFIE PAS la production
Le réflexe serait de publier la dispersion des membres d'ensemble comme un indice de confiance. C'est exactement l'inverse qu'il faut faire.
Elle est anticorrélée au biais : un ciel clair et stable donne la dispersion la plus basse au moment précis où la perte thermique est la plus forte — donc où l'erreur de conversion est la plus grande.
Un indicateur de confiance anticorrélé à l'erreur est pire que pas d'indicateur. Il est le plus rassurant exactement quand il se trompe le plus. Il ne s'agit pas d'un indicateur imparfait qu'on affinerait : il pointe dans la mauvaise direction, et l'affiner l'empire.
Elle peut être stockée (elle décrit le ciel, et Héos en fera peut-être quelque chose). Elle ne doit jamais être publiée comme une qualité de la prévision de production.
(Famille des modes d'érosion d'AGENTS.md : l'instrument dont le silence ressemble à un
résultat. Ici, c'est l'instrument dont la confiance ressemble à une garantie.)
5. Ce qui est HORS GPL — et ce que le GPL doit publier pour que l'autre existe
Comparer prévu à mesuré pour en tirer une correction est un apprentissage, donc hors GPL.
Mais la conséquence est constructive, et c'est elle qui justifie la table bitemporelle :
Le GPL n'apprend pas ; il rend l'apprentissage POSSIBLE. La table
(targetAt, issuedAt)est l'interface entre les deux. Sans elle, Héos n'aurait pas la matière — et l'écraser à chaque révision détruirait cette matière pour toujours, sans que rien ne le signale.
Le GPL publie donc la série et le réalisé côte à côte, sans les rapprocher. Le rapprochement est le produit.
6. Cadence — une heure, et le raisonnement est celui du polling d'une borne
Les modèles se rafraîchissent au mieux toutes les heures. Interroger plus souvent brûle du quota sans rien apporter : ce n'est pas une donnée neuve, c'est la même donnée relue.
Le quota (10 000 appels/jour/IP) est très loin — et ce n'est pas l'argument. L'argument est le même que pour la borne : on ne polle pas plus vite que la chose n'a de nouveau à dire. Une cadence justifiée par « on a le droit » est une cadence qu'on ne sait pas défendre.
7. Le NOM porte la protection
Préfixe forecast partout, sans exception : forecastProductionW, forecastIssuedAt,
forecastHorizonS. Jamais productionW, jamais une valeur de measurement.source, jamais un
champ qu'une somme puisse mêler à du mesuré.
C'est la règle qui a coûté le plus cher ces trois semaines. Un nombre qui ne dit pas d'où il vient finit toujours par être lu comme une mesure — et une prévision lue comme une mesure est un mensonge que personne n'a écrit.
8. Ce que j'ajoute au cadrage
8.1 Une heure n'est pas une heure — le piège du 6 septembre, à nouveau
targetAt doit être un instant absolu en UTC, jamais un libellé d'heure locale. Un 2026-10-25 02:00 local existe deux fois et un 2026-03-29 02:00 n'existe pas : indexer par heure
murale corrompt silencieusement la série deux fois par an, et l'erreur se voit un jour où
personne ne regarde.
Concrètement : demander timezone=UTC à l'API, stocker des epochs, convertir au rendu
seulement. C'est la distinction déjà tranchée le 2026-09-07 — un réglage est une chaîne qu'on
interprète, un instant est absolu — appliquée à une série entière au lieu d'un champ.
Et la vérification qui mord : deux jours de 23 h et 25 h dans les données de test, avec la somme quotidienne comparée au nombre de lignes. Une série indexée en heure murale y perd ou double une heure, et rien d'autre ne le montre.
8.2 Rétention — la table grandit en heures × révisions
48 h d'horizon × 24 révisions/jour ≈ 1 150 lignes par jour, ~46 ko/jour, ~17 Mo/an. Tenable sur la cible, mais pas indéfiniment, et une table qui ne dit pas quand elle purge finit par le décider toute seule un jour de disque plein.
Proposition : 7 jours de révisions complètes, puis effondrement à deux lignes par heure visée — la première (horizon le plus long) et la dernière avant l'heure (meilleure estimation). Garder les deux bouts conserve les deux seules questions qui se posent : « la veille valait-elle quelque chose ? » et « que pouvait-on savoir de mieux ? ». N'en garder qu'une détruit la révision, c'est-à-dire l'objet du lot.
8.3 Où vit le stockage : chez nous, pas dans energylogs.sqlite
energylogs.sqlite appartient à l'amont, qui en migre le schéma. Y ajouter une table nous
exposerait à une migration qui ne nous connaît pas. Un fichier à nous, à côté de
energy-load-configuration.json, avec notre numéro de schéma.
8.4 L'absence n'est pas un zéro
Pas de réseau, API en erreur, plan PV non déclaré : aucune ligne. Jamais 0 Wh, qui se lirait
« on prévoit une nuit ». La courbe publiée porte des trous, et l'écran doit savoir les
afficher comme tels. Même règle que partout ici, et c'est la plus souvent oubliée quand la
donnée est une série : un trou dans une courbe demande un effort de rendu, un zéro n'en demande
aucun — et c'est pour ça qu'on écrit des zéros.
8.5 Les plans PV sont une CONFIGURATION, donc ils se versionnent
Crête, orientation, inclinaison sont saisis par l'installateur, comme les rangs de charge. Même
famille que LoadConfigStore : store persisté, RPC de lecture/écriture, validation au refus
et non à la correction silencieuse.
Et un plan qui change périme la conversion, pas la météo. L'historique doit donc retenir sous quel plan une prévision a été convertie — sinon un recâblage rend toute la série antérieure incomparable sans que rien ne le dise. C'est la même erreur que le plancher de borne qu'on aurait stocké au lieu de le dériver, déplacée dans le temps.
9. Ce qu'il me manque pour écrire le code
- La convention d'indexation, avec ses PARAMÈTRES. Elle est rétrograde et mesurée — je la
reprends. Mais une convention d'indexation dépend de la requête qui l'a produite (variables
demandées,
timezone, horaire vs quart-horaire). Reprendre la conclusion sans ses prémisses, c'est hériter d'un fait vrai sous des conditions qu'on ne saura pas reproduire. L'énoncé exact et les paramètres de l'appel mesuré, et je m'aligne dessus. - Où se déclarent les plans PV : un nouveau store à nous (ma proposition, §8.5), ou des champs sur le Thing onduleur ?
- La position du banc — latitude/longitude. Elles conditionnent l'appel, et une prévision convertie sur la mauvaise position est fausse d'une façon parfaitement crédible.
10. Étapes proposées, à comportement constant d'abord
A — le store des plans PV et son RPC. Rien n'appelle l'API ; la configuration existe et ne décide de rien. B — l'appel Open-Meteo et la table bitemporelle, sans publication. La série se remplit et s'observe au banc, y compris en travers d'un changement d'heure. C — la publication RPC de la courbe, avec son horizon nommé et ses trous. D — la superposition au réalisé, côte à côte et sans rapprochement.
Le pas D est celui qui touche à la frontière avec le produit, et il arrive quand la série est déjà vérifiable.
11. Décisions rendues le 2026-09-08
Question 1 — qui appelle l'API. Non tranchée, remontée. Voir l'encadré en tête. Les quatre décisions ci-dessous ne s'appliquent qu'une fois celle-là réglée.
Nouvelle table, pas energylogs.sqlite. Retenu, et la raison retenue est plus forte que
celle du §8.3 : ce n'est pas seulement que l'amont migre ce schéma, c'est que la clé change de
nature. energylogs est indexée par un instant de mesure ; la table de prévision est indexée
par une paire. Une prévision révisée n'a rien à faire dans la base du mesuré.
Un Get*, sans *Changed — amendement à l'invariant 11. L'invariant (« tout élément
runtime a un Get* ET un *Changed ») vise l'état de l'arbitre, qui bouge à chaque cycle
et dont un client déconnecté se retrouverait en retard sans pouvoir se resynchroniser. Une
courbe historisée n'a pas ce problème : elle se lit sur demande, comme GetPowerBalanceLogs.
Pas de notification.
À noter comme précédent : l'invariant 11 porte sur l'état vivant, pas sur l'historique. La distinction n'y était pas écrite, et 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 lot est un affichage. La prévision n'entre pas dans le plan, ne pondère pas l'étalement éco, ne déplace aucune échéance.
Le jour où l'étalement éco lira la prévision, ce sera un lot à part avec sa propre décision — et le §10 est aujourd'hui la seule place où elle mordrait : c'est la cadence d'obligation qui achèterait moins ce matin en pariant sur cet après-midi. Un pari, sur une courbe dont le §3 dit qu'elle est un plafond systématique. À ne pas glisser dans ce lot-ci.
12. Le découpage tranché, et le canal — 2026-09-08
ems n'appelle pas l'API. L'invariant 10 reste tel quel, et la raison est plus fine que
« zéro réseau » :
Un plugin d'intégration qui lit une API distante perd une entrée quand le lien tombe, comme un capteur perd la sienne. Un experience-plugin qui appelle le réseau, c'est la logique de décision qui se met à dépendre d'un service distant. L'invariant est écrit dans ce dépôt-ci pour cette raison, et il est juste là où il est.
L'attribution CC BY 4.0 reste donc chez openmeteo, où elle est déjà écrite : le problème disparaît avec le découpage au lieu d'être géré.
12.1 Le précédent cité ne couvre PAS ce lot — vérifié dans l'arbre amont
awattar, tempo, spothinta sont des précédents d'intégrations qui appellent une API.
Ils ne sont pas des précédents de série transmise au moteur. Le plugin awattar de
l'amont n'expose que des scalaires :
currentMarketPrice · averagePrice · lowestPrice · highestPrice · rank · validUntil
La série ne traverse jamais. L'amont a résolu le problème en appelant deux fois : notre
propre energyplugin/spotmarket/spotmarketdataproviderawattar.cpp refait la requête vers
https://api.awattar.at/v1/marketdata.
Il n'existe donc aucun canal éprouvé pour une série, parce que personne n'en a construit. Ce lot en ouvre un ; il n'en reprend pas un.
12.2 Conséquence à ne pas laisser sans suite : le moteur appelle DÉJÀ
SpotMarketDataProviderAwattar vit dans ce dépôt, sort en HTTPS vers api.awattar.at /
api.awattar.de, et son résultat alimente planSpotMarketCharging() — une décision, pas un
affichage.
C'est exactement ce que l'invariant 10 interdit, selon le raisonnement qui vient de le confirmer, et c'est en service. Hérité de l'amont, jamais choisi — mais un invariant qui a une exception non déclarée n'est plus un invariant, c'est une habitude. À trancher séparément : soit l'exception est écrite avec sa raison, soit le fournisseur spot descend chez une intégration.
12.3 Le canal : un état portant du JSON — les trois grandeurs, mesurées
| question | réponse | source |
|---|---|---|
| taille supportable | 1 MiB, plafond dur du transport | jsonrpcserverimplementation.cpp:554 — au-delà, la connexion est larguée |
| coût de rediffusion | par RÉVISION, pas par cycle | thing.cpp : if (oldValue == newValue) return; — nymea dédoublonne |
| nommage | préfixe forecast, et le mot dit que c'est un canal |
§7 |
Une courbe de 48 h fait 2 à 5 ko : trois ordres de grandeur sous le plafond. La taille n'est pas le sujet. Et réécrire la même valeur à chaque poll n'émet rien — le coût est d'une diffusion par heure, pas de soixante.
La prémisse « rediffusion à chaque cycle » surestime d'un facteur 60. Ce qui compte n'est pas la cadence du moteur, c'est celle des révisions.
12.4 Les deux vrais dangers, et aucun n'est la taille
a. cached — la réécriture du fichier de configuration. Un état cached est écrit sur
disque à chaque changement (thingmanagerimplementation.cpp → storeThingStates()). Un état
cached portant plusieurs kilo-octets de JSON réécrit things.conf toutes les heures, sur
une carte SD. "cached": false est obligatoire, comme l'état connected d'awattar.
b. La journalisation d'état — une base de données créée par une case à cocher. Si quelqu'un
active le logging sur cet état (Integrations.SetStateLogging, exposé dans l'app), chaque
révision écrit une copie complète du JSON dans energylogs.sqlite. Personne ne l'aura décidé,
rien ne le signalera, et la base grossira jusqu'à ce que le disque le dise.
C'est le mode d'érosion de l'instrument dont le silence ressemble à un résultat, retourné : un mécanisme qu'un tiers arme sans savoir ce qu'il arme. Le nom doit l'en dissuader, et le contrat doit dire que cet état ne se journalise pas.
12.5 Ce que je vois de plus : un JSON qui traverse n'a plus de schéma
Au moment où la courbe devient une chaîne, elle n'est plus typée. Si openmeteo change la
forme du contenu, ems casse à l'exécution, sans qu'aucune compilation ni aucun contrat ne
l'ait signalé — et le symptôme sera « la courbe est vide », c'est-à-dire indistinguable d'une nuit.
Un numéro de version DANS la charge utile, et le refus plutôt que l'interprétation au mieux. Une version inconnue produit aucune courbe et un avertissement nommé, jamais une lecture partielle qui aurait l'air de marcher.
C'est le versant charge utile de la règle déjà écrite au contrat : un contrat qu'aucun appelant n'exerce n'est pas un contrat. Ici, c'est un contrat qu'aucun compilateur ne vérifie — donc il faut que quelqu'un le vérifie à l'exécution, une fois, à l'entrée.
12.6 Nommage proposé
| état | forecastPvCurve — cached: false, non journalisable |
| charge utile | { "v": 1, "issuedAt": <epoch UTC>, "points": [{ "t": <epoch UTC>, "wh": <double> }] } |
v d'abord, issuedAt ensuite : les deux choses à lire avant de croire le reste. Et
issuedAt est dans la charge utile, pas déduit de l'instant de réception — sinon un
redémarrage de ems réétiquette toute la série avec l'heure du redémarrage, et la table
bitemporelle perd exactement ce qu'elle existe pour retenir.
13. La clé de révision — tranché le 2026-09-08
Retenu : la déduplication sur le contenu, et la colonne s'appelle receivedAt.
Pas de requête meta.json. Mais la décision n'est bonne qu'avec les trois conditions ci-dessous,
et la première est celle qui la rend acceptable.
13.1 Le coût réel n'est pas un décalage de cycle — c'est un horizon FAUX de plusieurs heures
receivedAt n'est pas « issuedAt à une minute près ». Entre l'initialisation d'un run et sa
mise à disposition, il s'écoule la latence de publication du modèle — des heures, pas des
minutes, pour un modèle global.
Conséquence directe : targetAt − receivedAt n'est pas l'horizon. Il est trop court de toute
la latence de publication, 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, et ce qu'elle ne sauve pas. Elle donne les bornes des révisions exactement : combien de révisions, et laquelle s'applique. Elle ne donne pas leur âge. « Quelle prévision » est juste ; « quel horizon » est faux.
C'est sans effet sur ce lot, qui affiche. C'est déterminant pour l'apprentissage, dont l'objet même est de corréler l'erreur à l'horizon.
13.2 Condition 1 — la colonne s'appelle receivedAt, jamais issuedAt
Un nom qui promet l'heure du run alors qu'il porte l'heure de réception fabrique le calcul faux
tout seul : quelqu'un écrira targetAt - issuedAt et obtiendra un nombre plausible. C'est la
classe qu'on paie le plus cher ici, et le remède est le même que pour forecast — le nom porte
la protection.
Et il faut l'écrire, pas seulement le nommer : aucun horizon ne se calcule depuis receivedAt.
13.3 Condition 2 — la déduplication compare les points COMMUNS, pas la charge utile
« Courbe identique à la précédente » ne suffit pas si la fenêtre glisse : si openmeteo ne
publie que les heures à venir, la charge utile de 11 h a un point de moins que celle de 10 h, et
un == sur la charge utile verrait une révision toutes les heures — exactement ce que la
déduplication existe pour éviter.
La règle : deux charges utiles sont la même révision si, sur leurs targetAt COMMUNS, toutes les
valeurs coïncident.
Question à trancher avec openmeteo, et elle décide de l'implémentation : la fenêtre publiée couvre-t-elle les journées entières (aujourd'hui 00 h → J+1 23 h, passé compris), ou seulement les heures à venir ? Dans le premier cas le jeu de points est stable dans la journée et un
==naïf suffirait — sauf au passage de minuit. Dans le seesecond, il faut la comparaison sur l'intersection. Je code la seconde dans les deux cas : elle est correcte pour les deux, et elle ne dépend pas d'un comportement qu'un changement chez le voisin pourrait retirer sans nous prévenir.
13.4 Condition 3 — la dette meta.json se rouvre AVEC son consommateur, pas avant
La décision de ne pas appeler meta.json reposait sur « aucun consommateur ne le demande ».
Elle reste juste : le consommateur d'aujourd'hui est un affichage, et un affichage n'a
pas besoin de l'âge du run — il a besoin de savoir ce qu'on prévoyait, ce que la déduplication
donne exactement.
Le consommateur qui en aura besoin est l'apprentissage, et il n'existe pas encore. La dette est donc notée, avec son déclencheur :
Le jour où quelque chose corrèle l'erreur à l'horizon,
meta.jsondevient nécessaire — et la décision se reprend avec un consommateur réel, ce qui est le raisonnement qui l'a rendue juste la première fois.
Ce qui serait fautif, c'est de la rouvrir maintenant au nom d'un besoin supposé : une requête par cycle payée aujourd'hui pour un calcul que personne ne fait.
13.5 Volume, confirmé
48 h d'horizon, 24 à 48 points selon l'heure. À 4–8 révisions par jour après déduplication : ~200 à 400 lignes par jour, contre les 1 150 estimées au §8.2 sur une révision horaire. La rétention du §8.2 devient largement confortable, et l'effondrement aux deux bouts peut attendre bien plus que 7 jours.
13.6 La dette forecastModel — je ne m'appuie pas dessus, et je n'écris rien de plausible
forecastModel publie le modèle configuré, pas celui qui a répondu. Le stocker comme
provenance enregistrerait une intention, et une intention rangée dans une colonne de
provenance devient une provenance au premier lecteur.
Donc : la colonne model existe et 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. Et aucun remplissage rétroactif : les lignes écrites
avant la correction resteront nulles, parce qu'elles le sont.
14. Le canal est livré — 2026-09-08, openmeteo 1.15.2+etm11
État forecastCurveTransport, QVariantMap typée déclarée dans la ThingClass — donc au
contrat, sans seconde couche d'encodage, et lisible à la sonde. Meilleur que la chaîne JSON
envisagée : le §12.5 demandait un numéro de version dans la charge utile faute de schéma ; ici
la charge utile en a un et porte sa version.
14.1 Trois de mes réserves tombent, et la première m'oblige à me corriger
issuedAt voyage dans la charge utile. La clé de révision devient exacte. Tout le §13 —
receivedAt, l'interdiction d'en calculer un horizon, la dette meta.json — est levé :
targetAt - issuedAt est l'horizon, et il peut servir à corréler l'erreur à l'âge de la
prévision. Le coût que je chiffrais disparaît avec sa cause.
La fenêtre couvre les journées entières, 48 créneaux alignés. Deux révisions du même jour couvrent exactement les mêmes créneaux. Ma comparaison sur l'intersection reste juste et devient une précaution plutôt qu'une nécessité — je la garde pour la raison du §13.3 : 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 et vérifié en provoquant la
panne. La colonne cesse d'être nulle. La règle de non-remplissage rétroactif reste : les
lignes écrites avant ne sauront jamais quel modèle a répondu, et elles doivent le dire.
14.2 CE QUE J'AVAIS FAUX — la déduplication ne doit pas REJETER
Ma première version rejetait une révision dont les valeurs coïncidaient avec la précédente. 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 modèle n'a pas changé d'avis — c'est une information, et la 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 il se simplifie :
| avant (clé approchée) | après (clé vraie) | |
|---|---|---|
| déduplique | la comparaison de contenu | la clé, par construction |
| le contenu | rejetait | journalise seulement — « le run confirme le précédent » |
Et 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é — c'est le mode d'érosion du masque, appliqué à une donnée.
14.3 L'indexation est LUE, pas supposée
convention voyage dans la charge utile : p[i] est la moyenne sur [t0 + i·step, t0 + (i+1)·step). L'énergie du créneau vaut donc p[i] × step / 3600 — sans supposer que le
pas vaut une heure. Le jour où il vaudra un quart d'heure, rien ici ne changera.
unit est vérifiée et non supposée : lire des Wh comme des W donnerait une courbe plausible,
fausse d'un facteur qui dépend du pas — exactement le genre de nombre que personne ne rattrape.
14.4 Ce qui est branché
EnergyArbitrator::lireCourbePrevision(), à chaque cycle, et c'est sans coût : un run déjà
connu n'écrit rien, la clé s'en charge. Attendre une notification serait plus élégant et moins
sûr — un état publié avant notre démarrage ne nous notifierait jamais, et la courbe resterait
invisible jusqu'au run suivant.
L'état est cherché par le nom de l'état, jamais par un identifiant de ThingClass : le producteur peut renommer sa classe ou en ajouter une seconde sans que cela nous concerne. Ce qui fait contrat, c'est le nom déclaré dans son JSON.
Un refus est journalisé une fois par motif, pas une fois par cycle : une charge utile refusée le reste, et répéter l'avertissement chaque minute noierait le signal.
La prévision n'entre toujours pas dans le plan. Elle est lue avant le contexte et n'y entre pas : ce lot est un affichage.
15. Version B — une révision n'existe que si la prévision a changé
La déduplication porte sur les valeurs, et un run qui reconduit ne crée pas de ligne.
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. Le déclencheur
de réouverture est le même : le jour où l'apprentissage corrélera l'erreur à l'horizon, il aura
peut-être besoin des confirmations — la décision se reprendra avec un consommateur réel.
15.1 Une contradiction dans la consigne, et comment elle est levée
Deux instructions incompatibles ont été données le même jour :
« issuedAt est mis à jour sur la ligne existante » |
il vaut le dernier run |
| « il devient l'horodatage du premier run […] pas du dernier » | il vaut 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.
targetAt INTEGER -- heure visée, epoch UTC
firstIssuedAt INTEGER -- run qui a produit ces valeurs EN PREMIER ← CLÉ, ne bouge jamais
lastConfirmedAt INTEGER -- dernier run qui les a RECONDUITES ← mis à jour en place
forecastWh REAL
model TEXT
PRIMARY KEY (targetAt, firstIssuedAt)
La FRAÎCHEUR est
lastConfirmedAt. L'ANCIENNETÉ DE L'AVIS estfirstIssuedAt.Une valeur émise à 06Z et reconduite à 12Z est fraîche, pas vieille de six heures. Et une valeur émise à 06Z que personne n'a reconduite depuis est vieille de six heures. Les deux phrases sont vraies, elles ne parlent pas de la même chose, et un champ unique en effacerait une.
firstIssuedAt ne bouge jamais : c'est la clé, et une clé qui se déplace change l'identité
de la ligne sous elle. Les deux instants coïncident à l'ouverture d'une révision et divergent
à la première reconduction — ce que le test vérifie explicitement.
Les deux sont publiés au contrat, ensemble. N'en publier qu'un obligerait le client à choisir lequel il croit.
15.2 Cette déduplication est le DERNIER REMPART, pas un filet
Celle du producteur compare la charge utile entière, où issuedAt figure : elle ne mord donc
jamais sur ce cas — un run neuf aux mêmes chiffres lui paraîtra toujours différent.
Si celle-ci se trompe, personne ne rattrape. C'est une raison de plus de ne pas la laisser
dépendre d'un comportement du voisin : la comparaison porte sur l'intersection des créneaux,
correcte que la fenêtre glisse ou non, et communs > 0 est requis — sans créneau commun il n'y a
rien à comparer, et conclure « identique » ferait disparaître une prévision dont on ne sait rien.
15.3 Les deux gardes sur la fraîcheur — tranchées le 2026-09-09
lastConfirmedAt n'est 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, ce qui rendrait l'historique illisible. Écrit au
contrat, dans l'en-tête et dans un test, parce qu'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
n'est pas arbitraire : elle vient de ce que le champ répond.
Il ne dit pas « quand quelqu'un a confirmé ». Il dit « quel est le dernier run qui a AFFIRMÉ ces valeurs ». Le run émetteur les a affirmées. La réponse est donc vraie, et elle l'est 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é que la consigne redoutait à juste titre 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 l'absence serait ici le mensonge, à rebours de la règle habituelle de ce dépôt : elle se
lirait « on ne sait pas jusqu'à quand elle a tenu », alors qu'on le sait — au moins jusqu'à son
émission. Elle forcerait en outre chaque client à écrire confirmedAt ?? issuedAt, c'est-à-dire
à refaire 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.)
16. Avis du producteur — provenance faussée avant openmeteo +etm12 (2026-09-09)
Reçu de l'agent openmeteo : jusqu'à +etm11 inclus, le champ issuedAt de la charge utile
portait le run PRÉCÉDENT. L'écart valait un intervalle de run (3 h AROME, 6 h ARPEGE), jamais
aléatoire, toujours en retard. Cause : la provenance venait d'un aller-retour meta.json
asynchrone qui atterrissait après la réponse de prévision, si bien que le plan lisait la valeur du
cycle précédent.
16.1 Recommandation de purge — VÉRIFIÉE, et sans objet ici
L'avis demandait de ne conserver aucune ligne écrite avant +etm12, au motif qu'une ligne saine
et une ligne fausse sont indistinguables après coup. Le raisonnement est juste. Mais la
chronologie du banc le rend sans objet, et c'est vérifié plutôt que supposé :
.so openmeteo +etm13 posé |
06:01 |
[Prévision] RÉVISION écrite |
06:03 |
| concordance à la lecture | site 2026-09-09 02:00 = courbe 02:00 — vraie |
| ligne stockée | firstIssuedAt = 2026-09-09T00:00Z (= 02:00 locale) |
La lecture n'a commencé qu'après le correctif. La table ne contient aucune ligne suspecte, et purger détruirait une ligne saine — c'est-à-dire échanger une lacune contre rien.
La consigne « purge et laisse se reconstruire » reste la bonne, et elle est notée pour le jour où elle s'appliquera. Le principe qui la fonde vaut d'être gardé tel quel : une révision manquante est une lacune visible ; une révision mal datée est un mensonge invisible.
16.2 issuedAt == 0 — refus NOMMÉ, et jamais deviné
Repris et implémenté. Le motif est distinct de MALFORMED : la cause diffère (le repli
best_match n'a pas d'identité de run unique) et la conduite à tenir aussi. Les confondre ferait
chercher un défaut de forme là où la stratégie du producteur n'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. C'est le même raisonnement que celui qui a fait
refuser receivedAt comme clé, appliqué au cas dégradé.
16.3 Ce que l'avis confirme sur la déduplication
Celle du producteur porte sur la charge entière, issuedAt compris : elle attrape les cycles
répétés à l'intérieur d'un même run — la majorité — mais pas un run neuf aux valeurs
identiques. C'est exactement le partage déjà retenu au §15.2 : dédoublonner sur les p et faire
avancer la fraîcheur reste à notre charge, et nous sommes le dernier rempart.
16.4 Le défaut n'était démontrable que sur la machine
« Aucun test unitaire ne pouvait le montrer : il fallait deux runs consécutifs et la comparaison de deux états. »
À ranger avec les autres de la même famille : la latence de négociation de la V2C, le double
écrêtage de la borne, l'update() imbriqué. Un défaut dont les deux valeurs sont plausibles ne
se voit que par recoupement de deux sources, et aucune des deux ne suffit seule.
17. La prévision est une propriété du SITE — désignation et somme (2026-09-09)
Une 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 o:forecastThingId par charge
supposerait que deux charges puissent s'appuyer 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. Ce serait offrir un réglage dont aucune valeur différenciée n'a de sens — donc
une occasion de se tromper sans contrepartie.
NymeaEnergy.GetForecastSite / SetForecastSite, persisté dans energy.conf.
17.1 Plusieurs plans : la SOMME, et ce n'était pas un choix libre
Une installation peut avoir deux orientations, est et ouest, donc deux Things de plan. La prévision du site est leur somme, et l'argument est celui de la comparaison :
La courbe est superposée à la production MESURÉE, qui est le total. N'en retenir qu'un plan donnerait une prévision systématiquement sous la mesure — qui se lirait comme une installation qui sous-performe. C'est exactement le mode d'erreur déjà nommé au §3 pour le biais optimiste de la conversion, obtenu cette fois par le découpage.
Un défaut trouvé en écrivant, et il était déjà là. L'implémentation précédente 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é.
17.2 On ne somme que des plans du MÊME RUN
Deux plans dont l'un a été 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 plutôt que d'écrire un mélange — le cas se referme seul, puisque le producteur rafraîchit tous ses plans en une passe.
Même règle si un plan est refusé (version, unité, clé inconnue) : la somme n'a pas lieu. Additionner ce qui reste publierait une partie du toit sous le nom du tout, et l'écart passerait pour une contre-performance.
17.3 L'absence de désignation est SILENCIEUSE
Aucun site désigné ⇒ on ne retient rien, et rien n'est journalisé. Une installation sans prévision est un cas normal, pas un défaut : un avertissement par cycle ferait chercher une panne là où il n'y a qu'une fonction non configurée.
Et GetForecastSite omet le champ plutôt que de rendre une chaîne vide — une chaîne vide se
lirait comme un identifiant qu'on n'a pas su rendre.
(Note de contrat : l'énumération EnergyError de l'amont ne porte pas de ThingNotFound. Un
identifiant inconnu rend donc InvalidParameter, et la description le dit. Inventer une valeur
d'énumération ferait diverger le contrat de celui que publie nymea-energy, et un client
recevant une valeur hors catalogue ne saurait pas la rendre.)
18. Aucune adoption d'office — le motif rend le silence acceptable
Décision du 2026-09-09 : rien n'est jamais désigné automatiquement. La proposition précédente — adopter le site unique — est écartée, et la raison porte plus loin qu'elle.
18.1 Pourquoi « il n'y a rien à choisir » ne suffit pas
Adopter le site unique paraissait sans risque : avec un seul candidat, désigner n'est pas choisir. Mais 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 — et 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 : Energy.SetRootMeter exige une désignation
explicite, et une adoption d'office sur la seule présence d'une interface a déjà coûté (§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. Ici, il y aurait
choix — fût-il unique aujourd'hui.
18.2 MISSING_FORECAST_SITE — ce qui rend le silence acceptable
La lecture s'arrête, mais elle le dit. L'écran peut donc nommer le geste qui manque au lieu d'afficher un vide.
Le motif dit QUOI FAIRE, pas seulement ce qui manque — rendu français :
« Aucun site de prévision désigné — choisissez-en un dans la configuration »
« Il en manque une » laisserait l'utilisateur devant un constat ; la phrase lui donne la marche à
suivre. Même exigence que les trois causes d'ECO_FLOOR_MISSED : trois causes, trois gestes.
Au contrat c'est un code (fault: "MISSING_FORECAST_SITE"), jamais une phrase — le client la
fabrique dans sa langue.
18.3 Il ne se publie QUE si un site existe
| état | conduite |
|---|---|
| aucun site candidat | silence — rien à désigner, et l'annoncer serait un avertissement qu'on ne peut pas satisfaire |
| site présent, aucun désigné | le motif, journalisé une fois, et publié dans GetForecastSite |
| site désigné | rien |
Un avertissement permanent qu'aucun geste ne peut lever est du bruit, et le bruit finit par couvrir le signal. C'est la même règle que le silence sur les bornes hors waterfall.
18.4 Pas de parc, donc pas de migration — mais une DATE
.75 est le seul déploiement, et sa table sera reconstruite en une journée. Aucun mécanisme de
migration n'est écrit, et c'est délibéré : un mécanisme sans parc à migrer serait du code
jamais exercé, donc du code faux qu'on découvrirait le jour où il servirait.
Date limite : 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 alors est un chemin de configuration à la mise en service, pas un défaut deviné au démarrage.
Écrit maintenant plutôt que repris dans six mois sans contexte.
18.5 « Jamais configuré » n'est pas « explicitement mis à rien »
forecastSiteChosen est posé par tout appel à SetForecastSite, identifiant vide compris.
Il ne sert plus à armer une adoption — il n'y en a plus — mais il reste utile : il distingue une
installation jamais configurée d'une installation dont l'utilisateur a délibérément coupé
la prévision. Les deux se taisent de la même façon ; elles ne veulent pas dire la même chose, et
un futur mécanisme qui les confondrait rallumerait une fonction que quelqu'un avait éteinte.
18.6 Un détail qui a failli être faux
Le signalement tourne au premier cycle, pas au constructeur. Au constructeur, le
ThingManager n'a pas encore chargé les Things : « aucun candidat » y serait toujours vrai, et
l'avertissement ne se déclencherait jamais — un mécanisme inerte qui aurait l'air posé.
19. Un RUN est immuable — le défaut que la mesure a trouvé (2026-09-09)
Ce que le banc a montré, et qui n'était pas ce qu'on cherchait. On attendait la déduplication ; on a trouvé une écrasure.
06:03 [Prévision] RÉVISION du run 2026-09-09T00:00:00Z — 48 créneaux
07:02 [Prévision] RÉVISION du run 2026-09-09T00:00:00Z — 48 créneaux ← MÊME RUN
Deux écritures pour la même identité de run, une heure d'écart. Les valeurs avaient donc
changé sous un issuedAt inchangé — et INSERT OR REPLACE les a remplacées sans rien dire.
La preuve de ce qui avait bougé a été détruite par le défaut lui-même. À 07:14, la table et l'état publié coïncidaient parfaitement : la comparaison ne pouvait plus rien montrer, puisque l'écrasure avait déjà aligné les deux. La question « quels créneaux ont changé, les passés ou les futurs ? » est devenue impossible à poser.
19.1 La correction : refuser, et relever ce qu'il fallait pour trancher
Un run est immuable. Des valeurs neuves sous une identité connue n'écrasent plus rien : l'écriture est refusée, les premières valeurs sont conservées, et l'anomalie est journalisée avec ce qui manquait pour décider :
[Prévision] RUN MUTÉ 2026-09-09T00:00:00Z — 7 créneau(x) modifiés (7 passés, 0 à venir)
sous une identité de run INCHANGÉE. Écriture REFUSÉE.
Le partage passé/futur est le discriminant, et il commande deux conduites opposées :
| ce qui bouge | lecture | conduite |
|---|---|---|
| passés seulement | le producteur fusionne l'observé dans la courbe — légitime chez lui | notre clé est trop courte : il lui faut une composante de contenu |
| futurs | un run qui n'identifie pas son contenu | c'est un défaut à remonter au producteur |
Sans ce relevé, la question ne se pose même pas — la donnée qui la trancherait a disparu.
19.2 Ce que l'épisode dit de la méthode
C'est la troisième fois cette semaine qu'un défaut ne se voit que sur la machine, et la première où il efface sa propre trace. La famille s'agrandit d'un cas :
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. C'est exactement ce qu'on aurait vu si tout allait bien.
Et il rappelle pourquoi l'ordre demandé était le bon : observer avant de redéployer. Un redémarrage aurait relu l'état, réécrit la ligne, et rendu les deux hypothèses — « la déduplication marche » et « le redémarrage a tout relu » — définitivement indistinguables.
20. La clé devient LE CONTENU — issuedAt n'identifie pas la courbe (2026-09-09)
Réponse mesurée du producteur, et elle renverse le §13 comme le §19.
| fait établi | conséquence |
|---|---|
issuedAt vient de meteofrance_arome_france_hd, seul domaine AROME exposant un meta.json — qui sert 0 valeur de rayonnement sur 72 |
il vient d'un domaine qui ne contribue rien à la courbe |
meteofrance_arome_france, qui fournit le rayonnement, n'expose aucun meta.json (HTTP 500) |
il n'existe aucune identité de run pour la donnée qui forme p |
à 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 donc pas un défaut chez le producteur : c'est ce que l'API permet. Et +etm63
gelait la courbe — le seul endroit où le lot était plus faux qu'avant.
20.1 La règle n'a pas bougé ; la clé, si
« Une révision n'existe que si la PRÉVISION a changé. »
La règle est intacte. Ce qui était faux, c'est la prémisse : que le run identifie la prévision. Regarder le contenu lui-même y est plus fidèle que de regarder une étiquette d'émission — la formulation le disait déjà, et c'est la clé qui la rattrape.
targetAt INTEGER -- frontière d'heure, en secondes
siteId TEXT -- \
contentId TEXT -- > CLÉ : le site et l'empreinte de la courbe
firstSeenAt INTEGER -- apparition du contenu, EN MILLISECONDES — ordonne les révisions
lastSeenAt INTEGER -- dernière republication à l'identique — fraîcheur
issuedAt INTEGER -- ATTRIBUT, peut être NUL
model TEXT -- ATTRIBUT
PRIMARY KEY (targetAt, siteId, contentId)
Migration v1 → v2 : la table est 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. La règle du producteur s'applique — une révision manquante est une lacune visible ; une révision mal datée est un mensonge invisible.
20.2 Trois conséquences que la mesure impose
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.
Il est stocké NUL, et aucun instant ne lui est substitué.
Le siteId entre dans la clé. Deux plans publient des courbes différentes sous le
même issuedAt (tilt, azimut, kWc, PR entrent dans p). Ici la granularité est le site,
puisqu'on somme — mais le besoin est identique : sans lui, changer de site désigné mêlerait
deux historiques que rien ne distinguerait plus. La garde d'hier devient nécessaire.
t0 est ancré sur le jour local de la REQUÊTE, pas du run : à minuit, même run, fenêtre
décalée. C'est couvert sans rien ajouter — les targetAt changent, donc l'empreinte aussi.
20.3 Un défaut trouvé par le test, et il 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 sont donc en
millisecondes, à la différence de targetAt qui est une frontière d'heure.
Un instant d'observation doit distinguer deux observations, sinon il n'ordonne rien. Sur la box, un cycle par minute l'aurait caché indéfiniment ; un test qui écrit deux fois d'affilée l'a sorti tout de suite.
20.4 Ce qui reste une hypothèse, et doit le rester
Le producteur n'a pas établi pourquoi le serveur recompose les premières heures — nowcast satellite, mélange court terme, ou deux domaines sur des runs différents. Il a mesuré que ça diverge et sur quelles heures. L'explication n'est pas acquise, et rien ici n'en dépend : la clé de contenu est correcte quelle qu'en soit la cause. C'est précisément pourquoi elle est préférable — elle n'a pas besoin qu'on comprenne le producteur pour être juste.
21. Pourquoi les créneaux proches bougent — la règle de fusion du seamless
Le producteur a établi le mécanisme, mesuré et non documenté par Open-Meteo :
seamless = w · arome_france_15min + (1 − w) · arome_france
Relevé le 2026-09-09 à 07:45, w déduit valeur par valeur : w = 1,00 de 08:00 à 10:00,
0,75 à 11:00, 0,50 à 12:00, 0,25 à 13:00, 0 à partir de 14:00. Rampe linéaire sur les
trois dernières heures de l'horizon du domaine 15 min.
Ce n'est pas un nowcast satellite — le produit satellite ne sert que le passé, vérifié. Les
premières heures égalent exactement arome_france_15min : c'est le même modèle, initialisé
plus récemment.
21.1 L'ÂGE EST LOCAL AU CRÉNEAU, pas global à la courbe
Le domaine 15 min se rafraîchit toutes les heures, l'horaire toutes les 3 h. Les ~5
premiers créneaux sont donc plus frais que le reste — et c'est l'unique raison pour laquelle
ils bougent sous un issuedAt stable.
Un seul
issuedAtne peut pas dater une courbe dont les parties n'ont pas le même âge.
Ce n'est pas une limite de notre modèle : c'est une propriété de la donnée. La clé de contenu s'en accommode sans rien savoir du mécanisme — c'est précisément pourquoi elle est préférable.
Conséquence à tenir : issuedAt est stocké comme attribut de la RÉVISION et n'est pas
publié par créneau. Il ne doit pas le devenir : le publier point par point affirmerait un âge
uniforme que la fusion dément. Si une indication de fraîcheur est un jour affichée, elle est
dégradée avec l'horizon, jamais uniforme.
21.2 L'avertissement qui compte pour l'écran
L'erreur météorologique décroît sur le proche. Le biais de CONVERSION, lui, ne bouge pas : ombrage, encrassement, température de module et écrêtage onduleur se trompent autant à trois heures qu'à trente-six, et tous dans le sens optimiste.
Un horizon proche améliore la météo, jamais la conversion. Il ne faut pas laisser « prévision proche plus fiable » se lire comme « chiffre proche plus juste ».
C'est le §3 sous un angle neuf, et le plus dangereux : le §3 disait qu'un plafond systématique se lit comme une contre-performance. Ici s'ajoute qu'une confiance croissante sur le proche donnerait à ce plafond l'apparence d'une mesure. Deux erreurs qui se composent — l'une fait douter de l'installation, l'autre fait croire au chiffre qui la fait douter.
Écrit dans la description de GetPvForecast, pas seulement ici : c'est un client qui construira
l'écran, et c'est le contrat qu'il lira.