# Prévision de production PV — une série, et elle est révisée > ## ✅ TRANCHÉ le 2026-09-08 — `ems` n'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 `ems` appelle lui-même une API distante. C'est interdit ici**, et > l'interdiction est explicite à quatre endroits : > > - `AGENTS.md` invariant **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 `ems` appelle, 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. - **`suggestLogging` ne 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 »** : ```sql 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 optimiseur ETM 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é : 1. l'**ombrage**, 2. l'**encrassement**, 3. la **température de module**, 4. l'**écrêtage onduleur**, 5. 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 optimiseur ETM 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, optimiseur ETM 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 1. **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. 2. **Où se déclarent les plans PV** : un nouveau store à nous (ma proposition, §8.5), ou des champs sur le Thing onduleur ? 3. **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": , "points": [{ "t": , "wh": }] }` | `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.json` devient 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**. ```sql 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 est `firstIssuedAt`.** > > 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. ```sql 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 `issuedAt` ne 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. --- ## 22. La somme vérifiée — et ce qu'elle rend visible (2026-09-09, 08:08) Un second plan a été créé sur le site. **Le chemin de sommation a donc été exercé pour la première fois**, et il l'a été sur la machine : ``` 08:08:00 [Prévision] 2 plans sommés — 48 créneaux 08:08:00 RÉVISION 484220d7 — 48 créneaux, âge 2026-09-09T03:00:00Z 08:09:00 courbe inchangée — fraîcheur mise à jour 7eb8a7d4 vu 08:02:00 revu 08:07:00 48 créneaux 20,0 kWh ← un plan 484220d7 vu 08:08:00 revu 08:09:00 48 créneaux 36,0 kWh ← deux plans sommés ``` `20,0 + 16,0 = 36,0 kWh`, au dixième. Puis déduplication au cycle suivant. ### 22.1 Ma garde était juste, et son coût était plus grand que je ne le pensais L'hypothèse « les plans d'un site partagent un instant » était **fausse** : `due()` portait un jitter aléatoire par plan, si bien que deux plans divergeaient en quelques minutes. Ma garde « ne sommer que des plans du même run » aurait donc **bloqué la somme** à chaque publication de run — `issuedAt` venant d'un domaine à 3 h, cela fait **huit fenêtres par jour, jusqu'à une heure chacune**. Pas un cas rare : un cas régulier. Corrigé chez le producteur (`+etm14`) : le site pilote la prévision horaire de tous ses plans dans la même passe, et c'est désormais **au contrat**. L'hypothèse est vraie **par construction, pas par chance**. > **Et ma 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 > la prochaine passe ». 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. ### 22.2 CE QUE LA SOMME REND VISIBLE : une CONFIGURATION qui ressemble à une révision Le passage de 20,0 à 36,0 kWh est enregistré comme une **révision**. 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 : > *« L'historique doit 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. »* **Conséquence pour la comparaison prévu/réalisé** : comparer les 20 kWh d'avant 08:08 à une production postérieure comparerait **deux installations différentes**. Rien dans la table ne permet aujourd'hui 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** (leur nombre, ou l'empreinte de leurs identifiants) à côté de la courbe. Elle ne change pas la clé — le contenu suffit à identifier la révision — mais elle rend la **cause** lisible, et permet d'écarter d'une comparaison les révisions qui n'en sont pas. **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. Mêler deux inconnues. À reprendre quand les runs se seront succédé.