La déduplication porte sur les VALEURS ; un run qui reconduit ne crée pas de ligne, il met à jour la fraîcheur. 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. Même déclencheur de réouverture, pour la même raison. UNE CONTRADICTION DANS LA CONSIGNE, ET COMMENT ELLE EST LEVÉE. Deux instructions incompatibles : « issuedAt est mis à jour sur la ligne existante » (= le dernier run) et « il devient l'horodatage du premier run, pas du dernier » (= 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. firstIssuedAt run qui a produit ces valeurs EN PREMIER (CLÉ, immuable) lastConfirmedAt dernier run qui les a RECONDUITES (mis à jour) La FRAÎCHEUR est lastConfirmedAt ; l'ANCIENNETÉ DE L'AVIS est firstIssuedAt. Une valeur émise à 06Z et reconduite à 12Z est fraîche ; une valeur émise à 06Z que personne n'a reconduite EST vieille de six heures. Les deux phrases sont vraies et ne parlent pas de la même chose. Les deux sont publiées au contrat : n'en publier qu'une obligerait le client à choisir laquelle il croit. firstIssuedAt ne bouge jamais — une clé qui se déplace change l'identité de la ligne sous elle. Les deux instants coïncident à l'ouverture et divergent à la première reconduction, ce que le test vérifie explicitement. DERNIER REMPART, PAS FILET : celle du producteur compare la charge utile entière, où issuedAt figure, donc elle ne mord jamais sur ce cas. Si celle-ci se trompe, personne ne rattrape — raison de plus pour qu'elle ne dépende d'aucun comportement du voisin : intersection des créneaux, et communs > 0 requis. loadmodel 30/30, charging 48/48, spotmarket 32/32, simulation 69/69, ci-quality 4/4, build 0 erreur 0 warning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
607 lines
32 KiB
Markdown
607 lines
32 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.
|