etm-powersync-energy-plugin.../docs/DESIGN_PREVISION_PV.md
Patrick Schurig 5316a6f723 docs(design): la prévision PV est une série RÉVISÉE — table bitemporelle
Nouveau lot cadré par Patrick. Le plugin openmeteo reste météo pure ; les plans
PV, la conversion, l'appel API et l'historique vivent ici.

LE POINT DE CONCEPTION : la table n'est pas « valeur par heure » mais « valeur
par heure, telle que prévue à tel moment ». La clé est la PAIRE
(targetAt, issuedAt). Avec targetAt seul, chaque rafraîchissement écrase le
précédent : on perd les révisions, donc la comparaison prévu/réalisé — ce qu'on
veut précisément montrer. Même distinction que measuredSince.

Conséquence de contrat que le cadrage n'énonçait pas : « prévu » ne veut rien
dire sans HORIZON. Trois lectures (courante, veille, H−1) répondent à trois
questions ; publier « prévu » sans dire laquelle laisse deux écrans montrer deux
courbes pour le même jour.

Repris de l'agent openmeteo, mesuré et non supposé : indexation rétrograde ;
cinq facteurs ignorés, TOUS optimistes — donc un biais signé, pas un bruit : la
conversion produit un PLAFOND, pas une estimation ; la dispersion d'ensemble est
ANTICORRÉLÉE au biais, donc un indicateur de confiance qui rassure le plus quand
il se trompe le plus — pire que pas d'indicateur, et l'affiner l'empire ; et la
correction prévu↔mesuré est un apprentissage, hors GPL.

Le GPL n'apprend pas, il rend l'apprentissage POSSIBLE : la table bitemporelle
EST l'interface avec Héos, et l'écraser à chaque révision détruirait la matière
pour toujours sans que rien ne le signale.

Cinq ajouts au cadrage : targetAt en epoch UTC jamais en heure murale (le piège
du 6 septembre appliqué à une série entière — vérification sur des jours de 23 h
et 25 h) ; rétention 7 jours pleins puis effondrement aux DEUX bouts, parce que
n'en garder qu'un détruit la révision ; stockage chez nous et non dans
energylogs.sqlite, que l'amont migre ; l'absence est un TROU, jamais un zéro ;
et l'historique doit retenir SOUS QUEL PLAN une prévision a été convertie, sinon
un recâblage rend la série antérieure incomparable en silence.

Trois choses manquent pour coder, dont la convention d'indexation AVEC ses
paramètres : reprendre une conclusion sans ses prémisses, c'est hériter d'un
fait vrai sous des conditions qu'on ne saura pas reproduire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-08 16:30:25 +02:00

220 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Prévision de production PV — une série, et elle est révisée
> 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.