etm-powersync-energy-plugin.../docs/DESIGN_PREVISION_PV.md
Patrick Schurig 002ecc639b docs(prévision PV): le canal — trois grandeurs mesurées, et le précédent ne couvre pas ce lot
Découpage tranché : ems n'appelle pas, l'invariant 10 reste. La distinction
retenue est plus fine que « zéro réseau » — une intégration qui lit une API perd
UNE ENTRÉE quand le lien tombe ; un experience-plugin qui appelle met LA LOGIQUE
DE DÉCISION sous dépendance distante. L'attribution CC BY 4.0 disparaît avec le
découpage au lieu d'être gérée.

LE PRÉCÉDENT CITÉ NE COUVRE PAS CE LOT. awattar/tempo/spothinta sont des
précédents d'intégrations qui APPELLENT. Le plugin awattar amont n'expose que
des SCALAIRES — currentMarketPrice, average, lowest, highest, rank, validUntil.
La série ne traverse jamais : l'amont a résolu le problème en appelant DEUX
FOIS. Il n'existe donc aucun canal éprouvé pour une série, parce que personne
n'en a construit.

ET LE MOTEUR APPELLE DÉJÀ : spotmarketdataproviderawattar.cpp, dans CE dépôt,
sort en HTTPS vers api.awattar.at et alimente planSpotMarketCharging() — une
DÉCISION. C'est ce que l'invariant 10 interdit, en service, hérité et jamais
choisi. Un invariant qui a une exception non déclarée n'est plus un invariant,
c'est une habitude. À trancher séparément.

Les trois grandeurs, sur le code et non la doc :
- taille : plafond dur à 1 MiB (jsonrpcserverimplementation.cpp:554, connexion
  larguée au-delà). Une courbe 48 h fait 2-5 ko — la taille n'est pas le sujet ;
- rediffusion : PAR RÉVISION, pas par cycle. thing.cpp dédoublonne
  (if (oldValue == newValue) return). La prémisse surestimait d'un facteur 60 ;
- nommage : forecastPvCurve, et le mot dit que c'est un canal.

Les deux vrais dangers ne sont pas la taille : « cached » réécrirait things.conf
toutes les heures sur une carte SD (cached: false obligatoire), et la
JOURNALISATION D'ÉTAT créerait une base de données par une case à cochée dans
l'app — chaque révision copiant le JSON entier dans energylogs.sqlite, sans que
personne l'ait décidé.

Et ce que je vois de plus : un JSON qui traverse n'a plus de schéma. Un numéro
de version DANS la charge utile, avec refus plutôt qu'interprétation au mieux —
sinon le symptôme d'un changement de forme est « courbe vide », indistinguable
d'une nuit. issuedAt vient aussi de la charge utile, jamais de l'instant de
réception : sinon un redémarrage réétiquette la série et la table bitemporelle
perd ce qu'elle existe pour retenir.

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

20 KiB
Raw Blame History

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 » :

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.