docs(prévision): §32 consommation de base et prix ; protocole §5.2 (tou, quality)

Design §32 : grille, mesure à la minute, profil, qualité, tarif mural,
règle du créneau à cheval (moyenne pondérée) et ce qui reste pour e1.
Protocole 1.1, correction avant publication : price.source hphc → tou
(jamais émis), baseload.quality optionnel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-14 10:05:43 +02:00
parent bb34c6ed5e
commit 04fea6ffda
2 changed files with 95 additions and 1 deletions

View File

@ -1598,3 +1598,84 @@ S'y ajoutent les sondages en échec (pas de nouvel essai avant l'heure suivante)
redémarrages. La vraie valeur dépend du rythme réel de mise à jour du produit satellite, qui n'est
pas mesuré ici. Elle se lira sur le banc : la part des lignes de jour dont `gtiObserved` est NULL.
**Rien n'est comblé côté `ems`** : la correction est de publier la série, ce sera openmeteo etm16.
## 32. Consommation de base et prix sur la grille — brief 3 T4 (2026-10-14)
*État du code. Rien de ce qui précède n'est réécrit. Aucune décision ne lit ce qui suit (§11) :
`SurplusContext::tariff` n'est pas rempli, la base n'entre dans aucun contexte. Épinglé par
`testNoDecisionReadsBaseloadOrPrice` (même cycle, mêmes entrées, même plan, avec et sans tarif ni
profil). Contrat : `INTERFACE.md`, « Tarif saisi » et `GetForecastSnapshot`. Code :
`etm/tariff/tariffconfig.{h,cpp}`, `etm/forecast/baseloadlog.{h,cpp}`.*
### 32.1 La grille est celle de `pv` — et elle existe sans `pv`
`GetForecastSnapshot` publie désormais `start`, `resolutionS`, `steps` (protocole §5.2), et sur
cette grille `baseload` et `price`, alignés index par index. Avec une courbe, c'est **sa** grille
(premier → dernier créneau, trous compris). **Sans courbe, la grille par défaut du producteur**
(horaire, minuit local → minuit local du surlendemain) : un site non désigné ne doit pas faire
disparaître un tarif saisi. Le manque « le pas n'est publié par aucune méthode » est fermé pour
le snapshot.
### 32.2 La base — mesurée à la minute, jamais sur les cumuls
- **Source** : l'échantillon d'**une minute** du bilan (`powerBalanceEntryAdded`, `SampleRate1Min`),
ses **puissances moyennes** — `acquisition + |production| − storage`, la formule de l'amont pour
`consumption`, réécrite depuis ses termes. **Jamais** `totalConsumption`/`totalProduction`/
`totalAcquisition`/`totalReturn` : 63 117 kWh de production cumulée au bilan contre 4 914 kWh au
SDM120 (relevé du 2026-10-13). ⚠️ Le terme `− storage` n'est pas dans la formule du brief
(« import − export + production ») : sans batterie il vaut 0 ; avec, une charge de batterie
n'est pas une consommation.
- **Retranché** : chaque charge pilotée (tables de l'arbitre, **bornes comprises**) selon **sa**
`measurement.source` — la même fonction que la télémétrie (`mesurerCharge()`, extraite pour
qu'il n'y ait qu'une règle). Sous `none`, la **puissance commandée** (`puissanceCommandee()`,
extraite de l'écart commandé/mesuré : la même règle). Une commande inconnue rend la minute
**incomplète**, jamais comptée à 0.
- **Pas de minute** sans compteur racine (import = zéro de construction) ni en mode dégradé L2
(le compteur est muet).
- **Négatif gardé** : une base < 0 dit que le compteur racine voit moins que les charges — le
constat du banc (« ~1,5 kW de moins », `AGENTS.md`). L'écrêter cacherait ce que la qualité doit
montrer.
**Stockage** — deux tables dédiées dans `energy-forecast.sqlite` : `baseloadMinute (at, siteW,
loadsW, baseW, commandedLoads, writtenAt)` et `baseloadMinuteLoad (at, loadId, source, powerW)`.
`forecastPv` et `forecastLearning` ne sont **pas touchées** (éprouvé sur le fichier etm67 réel :
schéma et lignes identiques). Fil d'écriture dédié (sa connexion, `busy_timeout` 10 s), lots de
5 minutes, lecture de la fenêtre **par journée** pour ne jamais tenir le verrou du fichier
longtemps (le cycle y écrit les révisions). Rétention **35 jours** (fenêtre + une semaine). Forme
inattendue d'une de NOS tables ⇒ refaite (aucun historique antérieur n'existe) ; fichier illisible
⇒ hors service, journalisé une fois.
### 32.3 Le profil
Médiane, par case de la journée **locale** (largeur = pas de la grille) et par type de jour
(ouvré / week-end, fériés inconnus), des **moyennes de case** couvertes à ≥ 90 %, sur **28 jours**.
Une case sous **2** valeurs est `null`. **`none` sous 7 jours** couverts à ≥ 90 % de **leurs**
minutes (1 380 / 1 440 / 1 500). Un créneau de grille prend la case de l'heure murale de son
début (25 h : les deux « 02:00 » ont la même médiane ; 23 h : la case 02:00 n'est pas lue).
Recalcul sur le fil au plus tous les quarts d'heure (temps des minutes) et à l'ouverture ; le
snapshot lit la dernière copie, sous verrou bref, sans requête.
**La qualité dit ce que le modèle ne peut pas cacher** : minutes comptées à la **commande**,
minutes **négatives**, minutes **écartées**, et par charge sa source et `constantMeasurement`
(mesure qui n'a pas bougé d'un watt sur ≥ 60 minutes — la « constante déguisée » du §6 de
`DESIGN_COMMANDE_PERDUE.md`). Sur le banc, au 2026-10-14 : chauffe-eau `none` (compté à sa
commande), PAC sur `PAC-Meter` figé → `constantMeasurement: true`.
### 32.4 Le tarif — un réglage mural, rendu en instants
Saisi par l'installateur (décision du 2026-10-14) : **une** zone 00:00-24:00 (`fixed`) ou des zones
horaires (`tou`), chacune `{label, priceCtkWh, ranges[]}`, plusieurs plages par zone. Validation
**stricte** (`TariffConfig::fromVariant()`, la même pour le RPC et la relecture) : prix ≥ 0, heures
`"HH:MM"`, couverture des 1 440 minutes **sans trou ni chevauchement**, refus codé et localisé
(`TARIFF_REFUSED`, `cause` + index + minute). Persisté dans `energy.conf`.
**Créneau à cheval : moyenne pondérée par la durée — proposé et retenu.** Toutes les séries de la
grille sont des moyennes sur `[t, t + pas)` ; pour une puissance constante sur le créneau, le coût
est exactement `P × pas × moyenne pondérée`. C'est donc la seule valeur cohérente avec `pv` et
`baseload`. Le découpage au pas changerait la grille, qui est une pour toutes les séries. Calcul :
minute **absolue** par minute, chaque minute prend la zone de son heure murale — exact pour les
jours de 23 et 25 h (oracles Python `zoneinfo` dans les tests).
**Pour e1 (plus tard, sans être lu maintenant)** : `TariffConfig::zoneAt(instant, fuseau)` rend la
zone en vigueur ; « HC » n'a pas de code — le libellé est une donnée de l'installateur. Le lot e1
devra décider d'une règle (par exemple : la ou les zones au prix le plus bas), pas la deviner ici.

View File

@ -40,6 +40,10 @@ optimiseur 1.0 reste conforme (champs inconnus ignorés, §9).
rév. 3, avant toute implémentation socket). Aucun consommateur déployé n'en dépend ; ils
sont retirés du document sans changement de majeure.
**Correction avant publication (2026-10-14)** : `price.source` `hphc` devient `tou` (zones
horaires saisies, N zones et plusieurs plages par zone — HP/HC en est un cas) ; `hphc` n'a
jamais été émis. `baseload.quality` est ajouté (optionnel).
**Correction avant publication (2026-10-13)** : `conversion` n'admet plus `fallback`. La
valeur avait été écrite sans définition ; **aucun émetteur ne l'a jamais produite**. Seule
`physics` est émise ; une prévision dégradée se lit dans `corrections`.
@ -266,9 +270,18 @@ Règles :
depuis le compteur racine moins les charges, chacune selon `measurement.source` (§5.3) ;
une charge en `none` est comptée à sa puissance **commandée**, et la qualité de
l'estimation le dit.
- `profile` (plugin GPL) : médiane par créneau local et par type de jour (ouvré / week-end)
sur 28 jours, mesurée sur les échantillons d'une minute du bilan — jamais ses cumuls ;
`none` sous 7 jours couverts. Forme et définition : `INTERFACE.md`, `GetForecastSnapshot`.
- `quality` (1.1, optionnel) : codes et nombres — jours couverts, minutes comptées à la
commande, minutes négatives, et par charge sa source et `constantMeasurement`.
- **`price`** : **prix complet payé** — `includes` liste ce qui est inclus. Un prix n'est
**pas une prévision** : au-delà de `knownUntil`, `null`. Sources : `fixed`, `hphc`,
**pas une prévision** : au-delà de `knownUntil`, `null`. Sources : `fixed`, `tou`,
`tempo`, `day-ahead`. `exportCtkWh` : tarif de revente ou 0.
- `fixed` / `tou` : tarif **saisi** (une zone sur 24 h / zones horaires en heure locale) ;
connu sur tout l'horizon, `knownUntil` = fin de la grille. Un créneau à cheval sur deux
zones reçoit la **moyenne pondérée par la durée** — le coût exact d'une puissance
constante sur le créneau, seule valeur cohérente avec les autres séries de la grille.
- `forecast` peut être `null`. L'optimiseur reste libre de sourcer ses propres prévisions
(pont LAN, côté optimiseur), mais **le plugin, lui, ne sort pas** pour les obtenir.