# 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 ferait **huit fenêtres par jour**, d'une durée que le producteur estime **jusqu'à une heure**. > **Ce chiffre est DÉDUIT du code, pas mesuré**, et le producteur le dit lui-même. Ce qui est > certain, c'est que la dérive était **monotone et sans rappel** : rien ne réalignait jamais deux > plans, donc l'écart ne pouvait que croître jusqu'à ce qu'un plan saute un run entier. La borne > haute est un majorant, pas une observation — et l'écrire autrement en ferait la prémisse de > quelqu'un. 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é. --- ## 23. Deux défauts de MOI, trouvés en quatre minutes par un plan de test Le second plan du §22 était une **fiction de test** du producteur — ajouté ~08:07, **retiré à 08:11:36** — créé pour vérifier sa propre garantie de cohérence. Il a fait bien plus que ça. ### 23.1 Mon édition du contrat avait supprimé la déclaration du RPC `GetPvForecast` répondait **`{}`** — pas même une liste vide. En réécrivant sa `description` par une substitution de texte, j'avais emporté les `params.insert(...)` et `returns.insert(...)` qui la suivaient. > **MÉCANISME CORRIGÉ le 2026-09-09** — ma première explication était fausse, et la > contre-épreuve l'a démolie. nymea **ne filtre pas** la charge utile : en retirant le > `returns`, la méthode répond toujours correctement (vérifié). Ce qui casse, c'est la > déclaration des **paramètres** : sans elle, l'appel est **rejeté à la validation** — > `Invalid params: Invalid key: from` — et **le handler n'est jamais atteint**. > > La différence n'est pas académique : un client qui ne lit que `params` voit une réponse vide et > **accuse le handler**, qui n'a rien fait. C'est le mode d'erreur « conclusion vraie sur un > raisonnement faux » — j'avais le bon coupable et le mauvais mécanisme, et rien ne l'aurait > montré sans une contre-épreuve qui rejoue le défaut. > **Aucun test ne couvre la surface RPC.** Les 31 tests unitaires passaient, la simulation > passait, `ci-quality` passait — et la méthode ne rendait plus rien. Ce n'est pas un défaut de > logique, c'est un défaut de **déclaration**, et rien ici ne les regarde. ### 23.2 La courbe COURANTE était la dernière APPARUE, pas la dernière VUE Le retrait du plan a fait **réapparaître un contenu déjà connu** — celui d'avant l'ajout. Son `firstSeenAt` n'a pas bougé : c'est le principe même du champ. Mais le tri était fait dessus. **Résultat : quatre minutes après la disparition du plan fictif, le moteur servait encore la courbe de 36 kWh** — une installation qui n'existe pas, à +80 % de la production mesurée. ``` 7eb8a7d4 vu 08:02:00 revu 08:12:00 20,0 kWh ← le toit réel, revu en DERNIER 484220d7 vu 08:08:00 revu 08:11:00 36,0 kWh ← la fiction, apparue en dernier ``` > **Ce qui a cours est ce qu'on a vu EN DERNIER, pas ce qui est apparu en dernier.** Le tri passe > sur `lastSeenAt`. `firstSeenAt` garde son rôle : il dit quand un contenu est apparu, **pas s'il > a cours**. **Limite connue, écrite plutôt que découverte** : `lastSeenAt` est mis à jour **en place**, donc il ne porte que sa valeur finale. Une lecture historique qui traverse un **retour** à un contenu antérieur rend une réponse **approchée**. L'exactitude demanderait une table d'**épisodes** d'observation — différée, comme la colonne d'ensemble de plans du §22.2. ### 23.3 Ce que l'épisode dit Deux défauts, tous deux **de moi**, tous deux invisibles à la suite de tests, révélés en quatre minutes par **un plan que quelqu'un d'autre a créé pour une raison sans rapport**. Le premier ne se voyait qu'en appelant le RPC ; le second qu'en **retirant** quelque chose — geste qu'aucun test ne faisait, parce qu'aucun scénario n'y pensait. Le banc a produit un cas que ni le producteur ni moi n'avions prévu : **ajouter puis retirer**. *(Fenêtre à écarter de toute comparaison prévu/réalisé : **08:07 → 08:11:36**, où la table décrit une installation fictive. Les deux révisions qui l'encadrent sont des changements d'INSTALLATION, pas de météo — exactement ce que la colonne différée du §22.2 servirait à dire.)* --- ## 24. Le balayage des getters — et sa première version était inerte `Simulation::testEveryGetterReturnsWhatItDeclares` appelle **toutes** les méthodes `NymeaEnergy.Get*` du namespace (14 aujourd'hui) et vérifie trois choses, dans cet ordre : 1. **le STATUT est `success`** — sinon la méthode est nommée avec son erreur ; 2. la charge utile n'est **pas vide** ; 3. chaque clé **requise** de la déclaration `returns` est présente. Les `Set*` sont exclus : ils **mutent**, et les appeler à l'aveugle serait pire que le trou. ### 24.1 La première version était une contre-épreuve inerte Elle disait : *« pour chaque clé déclarée, vérifier qu'elle est servie »*. En retirant la déclaration de `GetPvForecast`, elle **passait** — il n'y avait plus rien à vérifier. > **Le défaut désactivait son propre détecteur.** C'est exactement le mode d'érosion catalogué > dans `AGENTS.md`, rencontré ici dans le contrôle écrit *pour* fermer un trou. Le critère a donc été déplacé de la **déclaration** vers la **réponse** : *un getter qui ne rend rien est cassé, quelle que soit sa déclaration.* C'est l'affirmation qui tient debout sans elle. ### 24.2 Et la contre-épreuve a corrigé le diagnostic Rejouée avec le vrai défaut — `params` supprimés — elle mord et **nomme la cause** : ``` NymeaEnergy.GetPvForecast → statut « error » (Invalid params: Invalid key: from) ``` C'est ce qui a établi que le mécanisme n'était pas celui que j'avais écrit (§23.1). **Un contrôle écrit pour attraper un défaut a servi d'abord à comprendre ce défaut** — ce qu'aucune relecture n'avait fait, parce que le symptôme était compatible avec deux causes. ### 24.3 Ce que ce balayage ne couvre pas, et il faut le dire - **Les `Set*`** : non appelés. Le trou reste ouvert de leur côté, et le fermer demande un scénario par méthode, pas un balayage. - **La FORME des valeurs** : on vérifie qu'une clé est là, pas que son contenu est du bon type ni qu'il a du sens. `forecastPoints: []` passe — et c'est correct, une table vide est un état légitime. - ~~**Les paramètres OPTIONNELS**~~ — **fermé le 2026-09-09**. Le balayage fait un **second passage** avec les optionnels renseignés, et la contre-épreuve mord : retirer `o:knownAt` fait sortir `Invalid params: Invalid key: knownAt`. C'était une classe et non un cas — *ce qui n'est jamais exercé n'est pas « peu testé », c'est indistinguable d'absent*, alors qu'un client lit les optionnels dans l'introspection et les croit disponibles. --- ## 25. Alignement des plans — NON OBSERVÉ, et il faut le dire ainsi Le producteur confirme son correctif au cycle de 09:20. **Ce cycle ne prouvera rien de mon côté** : le plan de test a été retiré à 08:11:36, il n'en reste **qu'un**, et une garde qui compare deux plans ne s'exerce pas sur un seul. > **À noter comme NON OBSERVÉ, jamais comme validé.** Un silence de la garde signifie ici « rien > à comparer », pas « les plans sont alignés ». Les deux produisent exactement la même trace : > aucune ligne. C'est le mode d'érosion de l'instrument dont le silence ressemble à un résultat, dans sa forme la plus banale — et il aurait été facile de conclure de travers, puisque le résultat attendu et l'absence de mesure ont la même apparence. **Ce qu'il faudra pour l'observer** : deux plans réels et durables sur un site, ou une fenêtre d'observation pendant laquelle un second plan existe. Le seul relevé exploitable à ce jour reste celui de 08:08 — deux plans, même `issuedAt`, même `t0`, somme correcte — et il a été obtenu par le chemin **forcé**, pas par la boucle horaire naturelle. --- ## 26. `GetForecastSnapshot` — la table ou la décision, jamais les deux **Deux méthodes, deux usages, et la phrase qui les sépare tient au contrat :** > **`GetPvForecast` sert la TABLE. `GetForecastSnapshot` sert la DÉCISION.** > Un arbitre qui lit la table lirait **sa propre trace au lieu de son entrée** : il ne mesure > plus, il se relit. Le snapshot lit **le canal**, directement, sans persistance. Écrit maintenant parce que la distinction est nette maintenant — dans six semaines, le premier consommateur prendrait `GetPvForecast` sans y penser, et le piège se serait refermé sur lui. ### 26.1 Les deux absences, tranchées | état | réponse | |---|---| | aucun candidat, aucun site | **silence** — ni courbe, ni motif | | site désignable mais non désigné | `fault: MISSING_FORECAST_SITE` + `candidates` | | charge utile **refusée** (version, unité, forme) | `fault: FORECAST_PAYLOAD_REFUSED` + `cause` | | canal muet | ni courbe, ni motif | | plans de runs différents | ni courbe, ni motif — on ne somme pas un mélange | **Dans tous les cas, `forecastPoints` est ABSENT — jamais une liste de zéros.** *Une prévision manquante et une prévision nulle sont deux affirmations différentes*, et c'est le motif qui les sépare. Et **deux motifs, deux gestes** : configurer d'un côté, **regarder le producteur** de l'autre. Les confondre enverrait chercher un réglage là où c'est le plugin météo qui parle mal. ### 26.2 Dater le CONTENU, pas seulement la lecture `capturedAt` est **toujours** rendu : il date la **réponse**, et distingue « le moteur n'a pas répondu » de « le moteur a répondu, et il n'a rien ». Mais il ne suffit pas. **Un producteur FIGÉ se lirait comme une prévision stable** — l'instrument dont le silence ressemble à un résultat, sous sa forme **la plus coûteuse** : celui qui le lit décide d'**acheter**. D'où `contentChangedAt` : **quand le contenu courant est apparu**. Il vient du magasin — non pas comme entrée, mais parce qu'il **survit au redémarrage**. Le garder en mémoire l'aurait remis à zéro à chaque relance, c'est-à-dire qu'un producteur figé aurait **paru frais après le premier redémarrage venu** — la protection annulée par le geste le plus banal. Il est **omis** quand le magasin n'a jamais vu ce contenu, plutôt que deviné. ### 26.3 Un mécanisme entier était exclu du banc d'essai par une accolade En écrivant le test de frontière, `arb->forecastStore()` s'est révélé **nul**. Le magasin était créé à l'intérieur d'un `#ifndef ENERGY_SIMULATION` — par **accident de placement**, ajouté à la suite du battement de télémétrie qui, lui, est bien un déclencheur d'horloge murale. **Conséquence : toute la prévision était absente du binaire de test.** Aucune suite ne pouvait l'exercer à travers l'arbitre, et le balayage des getters voyait `GetPvForecast` répondre correctement… **par sa branche « magasin absent »**. > **Un mécanisme entier hors du banc d'essai, et tous les voyants au vert.** Le test qui l'a > trouvé ne le cherchait pas : il voulait vérifier une frontière, et il a buté sur l'absence de > ce qu'il devait franchir. Corrigé : le magasin est créé **hors garde** — il n'est pas un déclencheur d'horloge murale et rien ne justifiait qu'il y soit. **Sa purge, si**, et elle y reste. *(La suite de simulation passe de 69 à 71 tests, et deux d'entre eux exercent enfin du code qui tournait en production sans jamais être exécuté ici.)* --- ## 27. « 20 kWh » était vrai et illisible — une énergie sans sa période **Alerte reçue le 2026-09-09** : *20 kWh n'est pas plausible sur 2 kWc.* Un plan de 2 kWc plein sud à 50°, en Alsace, un 9 septembre, produit 8 à 12 kWh par beau temps. Le facteur deux est trop gros pour être du biais. **Le contrôle qui tranche, fait sans rien déployer** — la crête, comparée au plafond physique : ``` plan « Toiture sud 2 kWc » unit W step 3600 s 48 points CRÊTE p = 1780 W plafond physique 2 kWc = 2000 W ✓ sous le plafond 2026-09-09 : 6,72 kWh (couvert) 2026-09-10 : 13,41 kWh (dégagé) TOTAL : 20,13 kWh ``` **La conversion n'est pas en cause** : la crête reste sous les 2 000 W, donc `p[i] × step / 3600` est appliqué correctement. **Et la sommation non plus.** Le total de 20 kWh couvre **48 créneaux, c'est-à-dire DEUX JOURS** — 6,7 aujourd'hui, 13,4 demain. Chacun est dans la fourchette attendue. ### 27.1 Le défaut était dans mon RELEVÉ, pas dans la donnée J'ai écrit « 20 636 Wh sur 48 créneaux », puis « 20,0 kWh » tout court. La première mention portait sa période et personne ne pouvait la lire de travers ; **la seconde ne la portait plus**, et elle a produit exactement le doute qu'elle méritait. > **Une énergie sans sa période est un chiffre qui sera mal lu** — et il le sera par quelqu'un qui > connaît l'installation mieux que celui qui l'a écrit. C'est la famille des chiffres crédibles : > bon ordre de grandeur pour qui ne vérifie pas, faux du double pour qui vérifie. Le mal n'était pas dans le système : il était dans **la façon d'en rendre compte**. Un relevé est un instrument comme un autre, et celui-ci mesurait juste en affichant mal. **Corrigé** : l'échantillonnage ventile désormais **par jour** et publie la **crête**, qui est la grandeur comparable à une puissance déclarée. Deux nombres qu'on peut confronter à quelque chose, au lieu d'un total qu'on ne peut confronter qu'à soi-même. ### 27.2 Ce que la vérification confirme au passage Le `performanceRatio` à **1,00** est bien visible, et bien borné : 13,4 kWh un jour dégagé de septembre sur 2 kWc est **le haut de la fourchette**, pas au-delà. Il rend la prévision optimiste de l'ordre de 10 à 20 % — exactement ce qui avait été annoncé en le fixant, et il **s'ajoute** au plafond du §3 sans jamais l'expliquer. --- ## 28. Décisions du 2026-10-12 *Consignées telles que rendues (brief 1 du lot « protocole 1.1 »), à la suite du rapport du lot 0. Rien de ce qui précède n'est réécrit ; aucune n'est implémentée à ce jour — ce sont des décisions, pas un état du code.* 1. **Les paramètres physiques PAR CHAMP vivent sur le Thing plan openmeteo** — coefficient de température, NOCT — à côté de `tilt`, `azimuth`, `kwp` et `performanceRatio`. **C'est openmeteo qui applique la correction de température.** Une seule source de vérité pour l'orientation et la physique d'un champ : le doublon « Thing plan + store `ems` » relevé au lot 0 §1b est écarté. 2. **Le plafond AC est un paramètre DE SITE, côté `ems`**, appliqué **après la somme des plans**. L'écrêtage onduleur porte sur ce que l'onduleur reçoit de tous ses champs, pas sur chacun : il ne peut donc pas vivre sur un plan. 3. **openmeteo publie EN PLUS le GTI brut et `temperature_2m` horaire**, pour l'**enregistrement** — de quoi refaire ou contrôler la conversion plus tard sans dépendre de ce qu'elle a produit. 4. **Pas horaire conservé.** Le **15 min viendra avec les prix day-ahead**, sans changement de contrat : la grille est lue (`step`, §14.3), jamais supposée. 5. **§5 maintenu : aucune correction apprise dans le GPL.** `conversion` ∈ `physics` | `fallback` ; le plugin GPL n'émet jamais `hybrid`. Un optimiseur qui corrige la prévision le déclare de son côté (`OPTIMIZER_PROTOCOL.md` §6, `diagnostics.inputsUsed.pv`). > *Note du 2026-10-13 : `fallback` est **retiré** — `conversion` vaut `physics`, seule valeur > émise. Voir §30.1.* 6. **§11 maintenu : la prévision n'entre dans aucune décision sans un lot dédié**, avec sa propre décision écrite. Les décisions 1 à 4 améliorent la courbe ; elles ne la branchent sur rien. > **Ce que ces décisions déplacent, et qui n'est pas encore fait** : la correction de température > et la publication du GTI brut / `temperature_2m` sont du travail **openmeteo** (dépôt > `etm-powersync-plugins`) et un changement de forme du transport, qui passe à **`v: 2`** > (brief 2 ; règle : `v` est incrémenté à tout changement de forme). Tant que ce dépôt n'a pas > appris à lire la v2, `ForecastStore::depuisTransport()` la **refuse** (`VERSION_2`) : l'ordre de > livraison est imposé — le lecteur (`ems`, v1 et v2) est déployé **avant** le producteur. Le plafond AC de site est du travail **`ems`**. ## 29. `ems` lit la v2 et plafonne le site — brief 3 T1 (2026-10-12) *État du code, pas une décision nouvelle : la mise en œuvre des décisions 2 et 5 du §28, côté `ems`. Contrat : `INTERFACE.md`, section « Prévision PV ».* - **Le prérequis du §28 est levé** : `ForecastStore::depuisTransport()` lit **v1 et v2**. Une autre version reste refusée (`VERSION_`). En v2, `corrections` est une liste obligatoire ; une correction inconnue refuse le plan (`CORRECTION_`) au lieu d'être ignorée — `p` porterait un effet qu'on ne sait pas nommer. `ac-limit` annoncé par un **plan** est refusé au même titre : il appartient au site, et serait appliqué deux fois. - **Le plafond AC vit dans la désignation de site** (`Get/SetForecastSite`, `acLimitW`, absent = aucun plafond, jamais 0) et s'applique **après la somme**, dans une fonction unique — `ForecastStore::sommerPlans()` — que la table et le snapshot partagent. La somme exige désormais aussi la **même grille** (`step`) : sans elle, le plafond `W × pas` n'a plus de période. - **Plans aux corrections différentes** : la courbe annonce leur **intersection**. C'est la seule affirmation vraie sur une somme dont un plan n'est pas corrigé. - **La table enregistre la courbe plafonnée.** Conséquence : changer `acLimitW` y crée une révision qui est une **configuration**, exactement la famille du §22.2. L'enregistrement par plan et par pas (brief 3 T2) portera les paramètres en vigueur ; c'est lui qui rendra la cause lisible. - **`conversion` vaut toujours `physics`.** Aucun émetteur de `fallback` n'existe dans ce dépôt, et le dépôt ne définit nulle part ce qu'il désignerait — point laissé ouvert, pas deviné. > *Note du 2026-10-13 : point tranché, `fallback` est retiré (§30.1).* ## 30. Décisions du 2026-10-13 — après les rapports T0/T1 du brief 3 *Consignées telles que rendues par Patrick. Rien de ce qui précède n'est réécrit : les §28 et §29 restent l'état de leur date, et c'est ici qu'on lit ce qui les corrige.* 1. **`conversion` : `fallback` est retiré.** Le §28 décision 5 écrivait `physics` | `fallback` ; le §29 relevait que `fallback` n'était défini nulle part. La règle devient : `conversion` vaut `physics`, **seule valeur émise par le plugin GPL** ; le champ reste pour des valeurs futures. Une prévision **dégradée** (température absente) se lit dans `corrections: []`, pas dans `conversion`. Correction **avant publication** (`OPTIMIZER_PROTOCOL.md` §0, 1.1) : aucun émetteur n'a jamais produit `fallback`. 2. **Corrections PAR PLAN, en donnée.** Le §29 publiait l'intersection des corrections et ne disait la divergence qu'au journal. `GetForecastSnapshot` porte désormais `perField` : une entrée par plan sommé, `{id, corrections}`. Le `corrections` du site reste l'intersection (+ `ac-limit` si plafond). **Pas de booléen « divergent »** : la divergence se lit dans les données. `OPTIMIZER_PROTOCOL.md` §5.2 ajoute `corrections` aux entrées de `perField`. Le journal garde son message. Le mock porte une classe `pvPlane` : pour la première fois, un test fait passer une **courbe** par le RPC (trou n° 8 du rapport T1, refermé pour le snapshot). 3. **`acLimitW` dans `SetForecastSite` : absent = INCHANGÉ, `null` = EFFACER.** Le §29 remplaçait la désignation en entier, si bien qu'une app plus ancienne, ignorant le champ, aurait effacé le plafond à chaque enregistrement du site. `acLimitW` devient l'**exception explicite** au remplacement en entier. Bornes `[100, 1 000 000]` W validées. La validation nymea refuse `null` sur un `Uint` : le paramètre est déclaré `Variant` et sa forme contrôlée par le handler (`null` ou entier, rien d'autre). Choix d'implémentation, signalé : désigner **aucun** site retire aussi le plafond (invariant persisté « plafond ⇒ site »). 4. **Date illisible dans `GetPvForecast` = erreur explicite.** Elle devenait l'epoch 0 en silence, et un `to` illisible rendait une fenêtre vide, indistinguable de « rien d'enregistré ». `from`, `to`, `knownAt` illisibles → `energyError: EnergyErrorInvalidParameter` (énumération amont, rien d'inventé) + `fault: FORECAST_DATE_UNREADABLE`, `faultParams.fields`. Rien n'est lu dans la table. Le schéma déclaré change, de façon additive : `energyError` (requis, toujours rendu), `o:fault`, `o:faultParams`. `forecastPoints` reste requis, donc `[]` en erreur.