Un second plan a été créé : le chemin de sommation a été exercé pour la première fois, sur la machine. 20,0 + 16,0 = 36,0 kWh au dixième, « 2 plans sommés », puis déduplication au cycle suivant. MA GARDE ÉTAIT JUSTE, ET SON COÛT PLUS GRAND QUE PRÉVU. L'hypothèse « les plans d'un site partagent un instant » était FAUSSE — un jitter par plan les faisait diverger en minutes. Ma garde aurait donc bloqué la somme à chaque publication de run : huit fenêtres par jour, jusqu'à une heure chacune. Corrigé chez le producteur (+etm14), et désormais au contrat : l'hypothèse est vraie par construction, pas par chance. Et la garde reste utile APRÈS le correctif : elle devient l'instrument qui l'observe. Si la boucle naturelle ne tenait pas, le journal dirait « plans de runs différents — on attend ». Une garde qui vérifie la promesse sur laquelle elle repose vaut mieux qu'une garde qu'on retire parce que la promesse a été faite. CE QUE LA SOMME REND VISIBLE : le passage de 20,0 à 36,0 kWh est enregistré comme une RÉVISION, et il n'en est pas une — la prévision n'a pas changé, l'INSTALLATION a changé. C'est le point du §8.5 annoncé au premier jour et devenu réel : comparer les 20 kWh d'avant 08:08 à une production postérieure comparerait deux installations différentes, et rien dans la table ne permet de le voir. La révision est là, sa CAUSE ne l'est pas. Proposition non implémentée : une colonne portant l'ensemble des plans, qui ne change pas la clé mais rend la cause lisible. Pas fait maintenant et délibérément — ce serait un schéma v3 une heure après la v2, sur un mécanisme qui n'a qu'une matinée d'observation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
1065 lines
57 KiB
Markdown
1065 lines
57 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 Héos aura besoin.
|
||
|
||
> C'est la même distinction que `measuredSince` : **quand on a mesuré**, et **ce qu'on a mesuré**.
|
||
> Deux dates, deux questions, et les confondre en perd une.
|
||
|
||
### Conséquence de contrat : « prévu » ne veut rien dire sans HORIZON
|
||
|
||
Dès que les révisions coexistent, « la prévision pour 15 h » est ambiguë. Toute lecture doit
|
||
nommer son horizon, et l'écran aussi :
|
||
|
||
| lecture | requête | ce qu'elle répond |
|
||
|---|---|---|
|
||
| **courante** | pour chaque `targetAt`, la ligne de `issuedAt` maximal ≤ maintenant | « ce qu'on prévoit en ce moment » |
|
||
| **veille** | `issuedAt` le plus proche de J−1 à heure fixe | « la prévision de la veille valait-elle quelque chose ? » |
|
||
| **à H−1** | `issuedAt` le plus proche de `targetAt − 1 h` | « la meilleure estimation possible » |
|
||
|
||
Les trois répondent à des questions différentes. Publier « prévu » sans dire laquelle laisse le
|
||
client en choisir une au hasard, et deux écrans montrent deux courbes pour le même jour.
|
||
|
||
## 3. Ce que la conversion IGNORE — cinq facteurs, tous optimistes
|
||
|
||
Repris de l'agent openmeteo, mesuré :
|
||
|
||
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 Héos en fera peut-être quelque chose). Elle
|
||
ne doit **jamais être publiée comme une qualité de la prévision de production**.
|
||
|
||
*(Famille des modes d'érosion d'`AGENTS.md` : l'instrument dont le silence ressemble à un
|
||
résultat. Ici, c'est l'instrument dont la confiance ressemble à une garantie.)*
|
||
|
||
## 5. Ce qui est HORS GPL — et ce que le GPL doit publier pour que l'autre existe
|
||
|
||
**Comparer prévu à mesuré pour en tirer une correction est un apprentissage**, donc hors GPL.
|
||
|
||
Mais la conséquence est constructive, et c'est elle qui justifie la table bitemporelle :
|
||
|
||
> **Le GPL n'apprend pas ; il rend l'apprentissage POSSIBLE.** La table `(targetAt, issuedAt)`
|
||
> est l'interface entre les deux. Sans elle, Héos n'aurait pas la matière — et l'écraser à chaque
|
||
> révision détruirait cette matière pour toujours, sans que rien ne le signale.
|
||
|
||
Le GPL publie donc la série et le réalisé côte à côte, **sans les rapprocher**. Le rapprochement
|
||
est le produit.
|
||
|
||
## 6. Cadence — une heure, et le raisonnement est celui du polling d'une borne
|
||
|
||
Les modèles se rafraîchissent **au mieux toutes les heures**. Interroger plus souvent brûle du
|
||
quota sans rien apporter : ce n'est pas une donnée neuve, c'est la même donnée relue.
|
||
|
||
Le quota (10 000 appels/jour/IP) est très loin — **et ce n'est pas l'argument**. L'argument est
|
||
le même que pour la borne : *on ne polle pas plus vite que la chose n'a de nouveau à dire*. Une
|
||
cadence justifiée par « on a le droit » est une cadence qu'on ne sait pas défendre.
|
||
|
||
## 7. Le NOM porte la protection
|
||
|
||
**Préfixe `forecast` partout**, sans exception : `forecastProductionW`, `forecastIssuedAt`,
|
||
`forecastHorizonS`. Jamais `productionW`, jamais une valeur de `measurement.source`, jamais un
|
||
champ qu'une somme puisse mêler à du mesuré.
|
||
|
||
> **C'est la règle qui a coûté le plus cher ces trois semaines.** Un nombre qui ne dit pas d'où
|
||
> il vient finit toujours par être lu comme une mesure — et une prévision lue comme une mesure
|
||
> est un mensonge que personne n'a écrit.
|
||
|
||
---
|
||
|
||
## 8. Ce que j'ajoute au cadrage
|
||
|
||
### 8.1 Une heure n'est pas une heure — le piège du 6 septembre, à nouveau
|
||
|
||
`targetAt` doit être un **instant absolu en UTC**, jamais un libellé d'heure locale. Un `2026-10-25
|
||
02:00` local existe **deux fois** et un `2026-03-29 02:00` n'existe **pas** : indexer par heure
|
||
murale corrompt silencieusement la série deux fois par an, et l'erreur se voit un jour où
|
||
personne ne regarde.
|
||
|
||
Concrètement : demander `timezone=UTC` à l'API, stocker des epochs, **convertir au rendu
|
||
seulement**. C'est la distinction déjà tranchée le 2026-09-07 — *un réglage est une chaîne qu'on
|
||
interprète, un instant est absolu* — appliquée à une série entière au lieu d'un champ.
|
||
|
||
**Et la vérification qui mord** : deux jours de 23 h et 25 h dans les données de test, avec la
|
||
somme quotidienne comparée au nombre de lignes. Une série indexée en heure murale y perd ou
|
||
double une heure, et rien d'autre ne le montre.
|
||
|
||
### 8.2 Rétention — la table grandit en heures × révisions
|
||
|
||
48 h d'horizon × 24 révisions/jour ≈ **1 150 lignes par jour**, ~46 ko/jour, ~17 Mo/an. Tenable
|
||
sur la cible, **mais pas indéfiniment**, et une table qui ne dit pas quand elle purge finit par
|
||
le décider toute seule un jour de disque plein.
|
||
|
||
Proposition : **7 jours de révisions complètes**, puis effondrement à **deux lignes par heure
|
||
visée** — la **première** (horizon le plus long) et la **dernière avant l'heure** (meilleure
|
||
estimation). Garder les deux bouts conserve les deux seules questions qui se posent : *« la
|
||
veille valait-elle quelque chose ? »* et *« que pouvait-on savoir de mieux ? »*. N'en garder
|
||
qu'une détruit la révision, c'est-à-dire l'objet du lot.
|
||
|
||
### 8.3 Où vit le stockage : chez nous, pas dans `energylogs.sqlite`
|
||
|
||
`energylogs.sqlite` appartient à l'amont, qui en migre le schéma. Y ajouter une table nous
|
||
exposerait à une migration qui ne nous connaît pas. Un fichier à nous, à côté de
|
||
`energy-load-configuration.json`, avec **notre** numéro de schéma.
|
||
|
||
### 8.4 L'absence n'est pas un zéro
|
||
|
||
Pas de réseau, API en erreur, plan PV non déclaré : **aucune ligne**. Jamais `0 Wh`, qui se lirait
|
||
« on prévoit une nuit ». La courbe publiée porte des **trous**, et l'écran doit savoir les
|
||
afficher comme tels. Même règle que partout ici, et c'est la plus souvent oubliée quand la
|
||
donnée est une série : un trou dans une courbe demande un effort de rendu, un zéro n'en demande
|
||
aucun — et c'est pour ça qu'on écrit des zéros.
|
||
|
||
### 8.5 Les plans PV sont une CONFIGURATION, donc ils se versionnent
|
||
|
||
Crête, orientation, inclinaison sont saisis par l'installateur, comme les rangs de charge. Même
|
||
famille que `LoadConfigStore` : store persisté, RPC de lecture/écriture, validation au **refus**
|
||
et non à la correction silencieuse.
|
||
|
||
**Et un plan qui change périme la conversion, pas la météo.** L'historique doit donc retenir
|
||
*sous quel plan* une prévision a été convertie — sinon un recâblage rend toute la série
|
||
antérieure incomparable **sans que rien ne le dise**. C'est la même erreur que le plancher de
|
||
borne qu'on aurait stocké au lieu de le dériver, déplacée dans le temps.
|
||
|
||
---
|
||
|
||
## 9. Ce qu'il me manque pour écrire le code
|
||
|
||
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 fait **huit fenêtres par jour, jusqu'à une heure
|
||
chacune**. Pas un cas rare : un cas régulier.
|
||
|
||
Corrigé chez le producteur (`+etm14`) : le site pilote la prévision horaire de tous ses plans dans
|
||
la même passe, et c'est désormais **au contrat**. L'hypothèse est vraie **par construction, pas
|
||
par chance**.
|
||
|
||
> **Et ma garde reste utile après le correctif** — elle devient l'**instrument qui l'observe**.
|
||
> Si la boucle naturelle ne tenait pas, le journal dirait « plans de runs différents — on attend
|
||
> la prochaine passe ». Une garde qui vérifie la promesse sur laquelle elle repose vaut mieux
|
||
> qu'une garde qu'on retire parce que la promesse a été faite.
|
||
|
||
### 22.2 CE QUE LA SOMME REND VISIBLE : une CONFIGURATION qui ressemble à une révision
|
||
|
||
Le passage de 20,0 à 36,0 kWh est enregistré comme une **révision**. Il n'en est pas une : **la
|
||
prévision n'a pas changé, l'installation a changé.**
|
||
|
||
C'est le point du §8.5, annoncé au premier jour et devenu réel :
|
||
|
||
> *« L'historique doit retenir SOUS QUEL PLAN une prévision a été convertie — sinon un recâblage
|
||
> rend toute la série antérieure incomparable sans que rien ne le dise. »*
|
||
|
||
**Conséquence pour la comparaison prévu/réalisé** : comparer les 20 kWh d'avant 08:08 à une
|
||
production postérieure comparerait **deux installations différentes**. Rien dans la table ne
|
||
permet aujourd'hui de le voir — la révision est là, sa **cause** ne l'est pas.
|
||
|
||
**Proposition, non implémentée** : une colonne portant l'**ensemble des plans** (leur nombre, ou
|
||
l'empreinte de leurs identifiants) à côté de la courbe. Elle ne change pas la clé — le contenu
|
||
suffit à identifier la révision — mais elle rend la **cause** lisible, et permet d'écarter d'une
|
||
comparaison les révisions qui n'en sont pas.
|
||
|
||
**Pas fait maintenant, et délibérément** : ce serait un schéma v3 une heure après la v2, sur un
|
||
mécanisme qui n'a qu'une matinée d'observation. Mêler deux inconnues. À reprendre quand les runs
|
||
se seront succédé.
|