Le brief 2 fixe le transport à v: 2 ; la note de §28 l'écrivait au conditionnel. Elle dit maintenant l'ordre de livraison qui en découle : ems (lecteur v1 et v2) avant openmeteo v2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1355 lines
73 KiB
Markdown
1355 lines
73 KiB
Markdown
# 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": <epoch UTC>, "points": [{ "t": <epoch UTC>, "wh": <double> }] }` |
|
||
|
||
`v` d'abord, `issuedAt` ensuite : les deux choses à lire **avant** de croire le reste. Et
|
||
`issuedAt` est **dans la charge utile**, pas déduit de l'instant de réception — sinon un
|
||
redémarrage de `ems` réétiquette toute la série avec l'heure du redémarrage, et la table
|
||
bitemporelle perd exactement ce qu'elle existe pour retenir.
|
||
|
||
|
||
---
|
||
|
||
## 13. La clé de révision — tranché le 2026-09-08
|
||
|
||
**Retenu : la déduplication sur le contenu, et la colonne s'appelle `receivedAt`.**
|
||
Pas de requête `meta.json`. Mais la décision n'est bonne qu'avec les trois conditions ci-dessous,
|
||
et la première est celle qui la rend acceptable.
|
||
|
||
### 13.1 Le coût réel n'est pas un décalage de cycle — c'est un horizon FAUX de plusieurs heures
|
||
|
||
`receivedAt` n'est pas « `issuedAt` à une minute près ». Entre l'initialisation d'un run et sa
|
||
mise à disposition, il s'écoule la **latence de publication du modèle** — des **heures**, pas des
|
||
minutes, pour un modèle global.
|
||
|
||
Conséquence directe : `targetAt − receivedAt` **n'est pas l'horizon**. Il est trop court de toute
|
||
la latence de publication, **systématiquement et dans le même sens**. Une prévision qu'on croirait
|
||
« à H−1 » peut être le run de H−5.
|
||
|
||
> **Ce que la déduplication sauve, et ce qu'elle ne sauve pas.** Elle donne les **bornes** des
|
||
> révisions exactement : *combien* de révisions, et *laquelle* s'applique. Elle ne donne pas leur
|
||
> **âge**. « Quelle prévision » est juste ; « quel horizon » est faux.
|
||
>
|
||
> C'est sans effet sur ce lot, qui **affiche**. C'est déterminant pour l'apprentissage, dont
|
||
> l'objet même est de corréler l'erreur à l'horizon.
|
||
|
||
### 13.2 Condition 1 — la colonne s'appelle `receivedAt`, jamais `issuedAt`
|
||
|
||
Un nom qui promet l'heure du run alors qu'il porte l'heure de réception fabrique le calcul faux
|
||
tout seul : quelqu'un écrira `targetAt - issuedAt` et obtiendra un nombre **plausible**. C'est la
|
||
classe qu'on paie le plus cher ici, et le remède est le même que pour `forecast` — **le nom porte
|
||
la protection.**
|
||
|
||
Et il faut l'écrire, pas seulement le nommer : **aucun horizon ne se calcule depuis `receivedAt`.**
|
||
|
||
### 13.3 Condition 2 — la déduplication compare les points COMMUNS, pas la charge utile
|
||
|
||
« Courbe identique à la précédente » ne suffit pas si la fenêtre **glisse** : si openmeteo ne
|
||
publie que les heures à venir, la charge utile de 11 h a un point de moins que celle de 10 h, et
|
||
un `==` sur la charge utile verrait une révision **toutes les heures** — exactement ce que la
|
||
déduplication existe pour éviter.
|
||
|
||
La règle : *deux charges utiles sont la même révision si, sur leurs `targetAt` COMMUNS, toutes les
|
||
valeurs coïncident.*
|
||
|
||
> **Question à trancher avec openmeteo, et elle décide de l'implémentation** : la fenêtre
|
||
> publiée couvre-t-elle les journées **entières** (aujourd'hui 00 h → J+1 23 h, passé compris),
|
||
> ou seulement **les heures à venir** ? Dans le premier cas le jeu de points est stable dans la
|
||
> journée et un `==` naïf suffirait — sauf au passage de minuit. Dans le seesecond, il faut la
|
||
> comparaison sur l'intersection. **Je code la seconde dans les deux cas** : elle est correcte
|
||
> pour les deux, et elle ne dépend pas d'un comportement qu'un changement chez le voisin pourrait
|
||
> retirer sans nous prévenir.
|
||
|
||
### 13.4 Condition 3 — la dette `meta.json` se rouvre AVEC son consommateur, pas avant
|
||
|
||
La décision de ne pas appeler `meta.json` reposait sur *« aucun consommateur ne le demande »*.
|
||
Elle **reste juste** : le consommateur d'aujourd'hui est un **affichage**, et un affichage n'a
|
||
pas besoin de l'âge du run — il a besoin de savoir *ce qu'on prévoyait*, ce que la déduplication
|
||
donne exactement.
|
||
|
||
Le consommateur qui en aura besoin est **l'apprentissage**, et il n'existe pas encore. La dette
|
||
est donc notée, avec son déclencheur :
|
||
|
||
> **Le jour où quelque chose corrèle l'erreur à l'horizon, `meta.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`).
|
||
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`**.
|