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
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.
## 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
demande de savoir si des commandes se perdent, et cette question n'est pas posable sur un banc
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()`).