docs(prévision PV): §31 — enregistrement pour l'apprentissage, écrêtage détectable ou non

Schéma et clé de forecastLearning, règles NULL, révision « en vigueur à X »,
tableau détectable / non détectable de l'écrêtage, neige, écriture et base
abîmée, limites. Inventaire du mock : pvPlane (paramètres) et solarInverter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-13 09:15:27 +02:00
parent 2b83c206e4
commit b1fdff197f
2 changed files with 122 additions and 0 deletions

View File

@ -1409,3 +1409,113 @@ restent l'état de leur date, et c'est ici qu'on lit ce qui les corrige.*
rien d'inventé) + `fault: FORECAST_DATE_UNREADABLE`, `faultParams.fields`. Rien n'est lu dans rien d'inventé) + `fault: FORECAST_DATE_UNREADABLE`, `faultParams.fields`. Rien n'est lu dans
la table. Le schéma déclaré change, de façon additive : `energyError` (requis, toujours rendu), la table. Le schéma déclaré change, de façon additive : `energyError` (requis, toujours rendu),
`o:fault`, `o:faultParams`. `forecastPoints` reste requis, donc `[]` en erreur. `o:fault`, `o:faultParams`. `forecastPoints` reste requis, donc `[]` en erreur.
## 31. L'enregistrement pour l'apprentissage futur — brief 3 T2 (2026-10-13)
*État du code. Le §5 tient : le GPL **n'apprend pas**, il conserve la matière. Rien de ce qui suit
n'entre dans une décision (§11) ; aucun RPC, aucune notification. Code :
`etm/forecast/forecastlearninglog.{h,cpp}`.*
### 31.1 La table, et pourquoi sa clé n'est pas celle de `forecastPv`
Même fichier (`energy-forecast.sqlite`, `NymeaSettings::storagePath()`), table à part :
```sql
CREATE TABLE forecastLearning (
targetAt, stepS, siteId, planId, -- PRIMARY KEY (targetAt, siteId, planId)
gti, tempC, pW, corrections, transportV, -- ce que le PLAN publiait pour ce pas
kwp, performanceRatio, tempCoeffPctPerK, noctC, acLimitW, -- la conversion EN VIGUEUR
issuedAt, model, observedAt, -- attributs de la charge utile lue (jamais une clé)
sitePlans, sitePlansExpected, -- la somme du site est-elle complète ?
dayAheadAt, contentIdDayAhead, contentIdHourAhead, -- renvois vers forecastPv
measuredW, measuredSource, measuredMinutes, -- ce que le SITE a mesuré
curtailed, snow, valid, writtenAt)
```
**`forecastPv` est l'axe des RÉVISIONS** : plusieurs contenus pour une heure, identifiés par leur
empreinte (§20). **Ici le pas est ÉCOULÉ** : il ne reste qu'un fait par plan, et il ne se révise
pas. La clé est donc `(targetAt, siteId, planId)`, et une ligne écrite n'est **jamais réécrite**
(`ON CONFLICT … DO NOTHING` — et non `INSERT OR IGNORE`, qui avalerait aussi une violation de
`NOT NULL`, en silence).
Les révisions ne sont pas recopiées : la table y **renvoie** par la clé de contenu du §20,
`(targetAt, siteId, contentId)`. Jamais par `issuedAt`, qui n'identifie pas la courbe (§20, §21).
`siteId` est dans la clé pour la raison du §20.2 : changer de site désigné ne mêle pas deux
historiques.
### 31.2 Ce que chaque groupe de colonnes affirme — et ce qui y est NULL
- **`pW`, `gti`, `tempC`** : la **dernière** lecture de la charge utile du plan pour ce pas
(`observedAt`). Pour un pas écoulé, c'est le run le plus récent — la meilleure estimation du ciel
de cette heure-là. En **v1**, `gti` et `tempC` sont **NULL**, jamais 0. Un `gti[]` ou `tempC[]`
de longueur différente de `p` est écarté en entier : l'alignement se ferait par devinette.
- **`kwp`, `performanceRatio`, `tempCoeffPctPerK`, `noctC`** : lus **sur le Thing plan**, par leur
nom (paramètres puis réglages) — la source unique du §28 décision 1, **jamais recopiée** côté
`ems`. `tempCoeffPctPerK`/`noctC` sont **NULL en v1** même si le Thing les porte : la v1 ne les a
pas appliqués. Ils sont aussi NULL tant qu'openmeteo ne les déclare pas (brief 2 non déployé).
`acLimitW` est le plafond **du site** en vigueur, appliqué après la somme (§28 décision 2).
- **`contentIdDayAhead`** : la révision du site en vigueur à **J−1 18:00 locale** (`dayAheadAt`,
stocké : le fuseau peut changer). « J−1 » est la veille du jour **local** où le pas commence ;
18:00 est un réglage, interprété dans le fuseau du système. **`contentIdHourAhead`** : en vigueur
à `targetAt − 1 h`. NULL quand aucune révision n'existait.
« En vigueur à X » = parmi les contenus apparus avant X et encore vus à X, le plus récemment
apparu ; à défaut, le dernier vu avant X. **Ce n'est pas la règle de `courbe()`**
(`MAX(lastSeenAt)`) : pour un X passé, celle-ci désigne un contenu revu *après* X — le cas du
§23.2. La limite du §23.2 demeure (`lastSeenAt` mis à jour en place).
- **`measuredW`** : moyenne, sur le pas, des échantillons **d'une minute** du bilan de puissance de
l'amont (`EnergyLogs::powerBalanceEntryAdded`, `SampleRate1Min` — chacun déjà une moyenne
pondérée dans le temps), signe retourné : **positif = produit**. `measuredSource` vaut
`power-balance-1min`. Publiée seulement si ≥ 90 % des minutes du pas sont arrivées
(`measuredMinutes` le dit) **et** si le site a au moins un Thing `smartmeterproducer` — sans lui,
la « production » du bilan est un **zéro de construction**, et elle reste NULL.
- **`valid`** : *aucune raison CONNUE d'écarter la ligne* — mesure publiée, `pW` présent,
`curtailed` ≠ 1, `snow` ≠ 1, et tous les plans du site présents pour ce pas
(`sitePlans == sitePlansExpected`, sans quoi la mesure du site se compare à une somme
partielle). Ce n'est **pas** un certificat : voir 31.3.
### 31.3 L'écrêtage — ce qui est détectable avec les données présentes, et ce qui ne l'est pas
`curtailed` vaut **1** (détecté), **0** (cherché, rien vu) ou **NULL** (rien ne permettait de
chercher). **0 ne veut jamais dire « certainement pas écrêté ».**
| cause | détectable ? | comment / pourquoi |
|---|---|---|
| **plafond AC du site** (`acLimitW` déclaré) | **OUI** | une minute du pas à ≥ 98 % du plafond ⇒ l'onduleur bornait, la mesure sous-estime le potentiel ⇒ 1. Mesure couverte, aucune minute au plafond ⇒ 0. |
| plafond onduleur **non déclaré** | **NON** | sans `acLimitW`, rien à quoi comparer : `curtailed` reste NULL. Un plateau « stable » ne suffit pas — un midi de ciel clair est aussi plat, et deviner le plafond serait inventer une détection. |
| **limitation d'injection** (zéro-injection, limite d'export) | **NON** | `ems` n'a aucun réglage de limite d'export, et aucune interface nymea ne la publie. Une injection à ~0 W se lit aussi bien comme une maison qui consomme tout. |
| **consigne onduleur** (limitation de puissance active, SunSpec 123 `WMaxLimPct`, Huawei…) | **NON** | les registres existent chez certains fabricants, mais **aucun Thing ne les expose** : `smartmeterproducer` ne porte que `currentPower` et `totalEnergyProduced`. Il faudrait un état publié par le plugin onduleur — à décider, pas à deviner. |
| délestage réseau / coupure onduleur | **NON** | indiscernable d'une production nulle. |
### 31.4 La neige
`snow` = une **chute** de neige **lue** sur le Thing site pendant le pas (état `snowfall`,
une valeur de **modèle** météo, > 0 ⇒ 1, = 0 ⇒ 0) ; NULL si le site n'a pas cet état. **La neige
qui COUVRE les panneaux après la chute n'est pas détectable** : rien ici ne la mesure, et la
déduire d'un écart prévu/mesuré serait précisément l'apprentissage qui n'a pas sa place dans le GPL.
### 31.5 Écriture, rétention, base abîmée
- **Jamais bloquante** : le cycle (`update()`, après `lireCourbePrevision()`) ne fait que copier en
mémoire ; une fois le pas fini (+ 120 s de grâce pour la dernière minute), un lot part vers un
**fil d'écriture** qui a **sa** connexion SQLite et attend lui-même un verrou (10 s). Éprouvé
base verrouillée : l'appel rend la main en moins de 50 ms.
- **Un pas écoulé avant le premier cycle n'est jamais écrit** : il n'a été ni lu ni mesuré par
nous. Un **filigrane** par (site, plan) empêche de reprendre un pas déjà écrit — la charge utile
garde les heures passées du jour (défaut trouvé par un test, corrigé avant commit).
- **Rétention 400 jours**, sur l'instant du cycle, à chaque lot.
- **Base abîmée** : table de forme inattendue ou qui échoue au `quick_check` ⇒ **refaite**, son
contenu perdu, journalisé. Si ses pages sont trop abîmées pour un `DROP`, elle est **mise de
côté** par renommage (`forecastLearning_abimee_<ms>`) et une table neuve prend sa place. Table
disparue entre deux lots ⇒ recréée, lot rejoué. Fichier qui n'est pas une base ⇒ enregistrement
**hors service**, journalisé une fois ; le service et l'arbitrage continuent. Le fichier n'est
**pas** recréé : il porte aussi `forecastPv`.
### 31.6 Ce qui n'est pas fait, et pourquoi
- **Aucune lecture RPC** : le brief ne la demande pas, et l'apprentissage lira la base. La forme de
la table est le contrat ; elle est ici.
- **Les paramètres sont lus au moment de la lecture de `p`, pas transportés avec lui.** Entre une
modification d'un paramètre sur le Thing et le recalcul du producteur, une ligne peut porter le
nouveau paramètre avec l'ancien `p`. La durée de cette fenêtre dépend du moment où le producteur
recalcule après une reconfiguration — **non vérifié** ici. La refermer demande que le producteur
publie ses paramètres **dans** la charge utile.

View File

@ -216,3 +216,15 @@ le critère aurait enfin quelque chose à trier.
**C'est la condition d'existence de l'étape 4**, pas un confort : décider de rejouer une commande **C'est la condition d'existence de l'étape 4**, pas un confort : décider de rejouer une commande
demande de savoir si des commandes se perdent, et cette question n'est pas posable sur un banc demande de savoir si des commandes se perdent, et cette question n'est pas posable sur un banc
qui ne mesure qu'une charge. qui ne mesure qu'une charge.
## 9. Prévision PV — `pvPlane` et `solarInverter` (2026-10-13)
| Classe | Porte | Ce qu'elle ouvre |
|---|---|---|
| `pvPlane` | état `forecastCurveTransport` ; paramètres `kwp`, `performanceRatio`, `tempCoeffPctPerK`, `noctC` *(paramètres ajoutés au brief 3 T2)* | une courbe de prévision par le RPC (D2) ; la lecture des paramètres de conversion **sur le Thing**, par leur nom |
| `solarInverter` | interface `smartmeterproducer` (`currentPower`, `totalEnergyProduced`) *(ajoutée au brief 3 T2)* | le seul producteur du mock : sans lui, « le site a-t-il un producteur ? » valait toujours non, et la production mesurée de l'enregistrement pour l'apprentissage n'était jamais publiée |
**Ce qui reste introuvable ici** : un Thing **site** portant `snowfall` (le compteur fait office de
site, il ne l'a pas — `snow` n'est éprouvé qu'en unitaire), et un plan **sans** les paramètres v2
(la classe les porte toujours — le cas « openmeteo d'avant le brief 2 » n'est éprouvé qu'en
unitaire, par `lirePlan()`).