From b1fdff197f8c1b4570c8abf84949004cf0eab8dd Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 13 Oct 2026 09:15:27 +0200 Subject: [PATCH] =?UTF-8?q?docs(pr=C3=A9vision=20PV):=20=C2=A731=20?= =?UTF-8?q?=E2=80=94=20enregistrement=20pour=20l'apprentissage,=20=C3=A9cr?= =?UTF-8?q?=C3=AAtage=20d=C3=A9tectable=20ou=20non?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/DESIGN_PREVISION_PV.md | 110 ++++++++++++++++++++++++++++++++++++ docs/INVENTAIRE_MOCK.md | 12 ++++ 2 files changed, 122 insertions(+) diff --git a/docs/DESIGN_PREVISION_PV.md b/docs/DESIGN_PREVISION_PV.md index aaecc53..145d630 100644 --- a/docs/DESIGN_PREVISION_PV.md +++ b/docs/DESIGN_PREVISION_PV.md @@ -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_`) 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. diff --git a/docs/INVENTAIRE_MOCK.md b/docs/INVENTAIRE_MOCK.md index 73b2ec4..eb2e26e 100644 --- a/docs/INVENTAIRE_MOCK.md +++ b/docs/INVENTAIRE_MOCK.md @@ -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()`).