Patrick Schurig 6b66d49f27 feat(prévision PV): lire forecastCurveTransport v1 ET v2, plafond AC de site (brief 3, T1)
Prérequis du déploiement d'openmeteo v2 (§28) :
- `depuisTransport()` lit v1 et v2. v2 exige `corrections` (liste) ; une
  correction inconnue refuse le plan (`CORRECTION_<nom>`), `ac-limit` compris,
  qui appartient au site. Autre version : `VERSION_<v>`, jamais devinée.
  `gti[]`/`tempC[]` ne sont pas lus ici (enregistrement : T2).
- `acLimitW`, paramètre de SITE porté par `Get/SetForecastSite` : optionnel,
  absent = aucun plafond (jamais 0), borné [100, 1 000 000] W, refusé sans
  site ; la désignation se remplace en entier. Persisté (`forecastAcLimitW`).
- `ForecastStore::sommerPlans()` : somme des plans du même run ET du même pas,
  puis écrêtage à `acLimitW × pas / 3600` APRÈS la somme. Fonction unique pour
  la table et le snapshot.
- `GetForecastSnapshot` publie `corrections` (intersection si les plans
  diffèrent, journalisé au changement ; `ac-limit` si plafond configuré),
  `conversion: "physics"` et `acLimitW`.

Aucune décision ne lit la prévision (§11) ; boucle de sécurité ni appelée ni
modifiée ; pas de `*Changed`.

Tests : loadmodel `testTransportV2IsReadAndAnUnknownCorrectionRefused`,
`testTheSiteCurveIsClippedAfterTheSum` (contre-épreuve faite : plafond
neutralisé → échec) ; simulation `testTheSiteAcLimitTravelsWithTheDesignation`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:59:27 +02:00

310 lines
16 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QDateTime>
#include <QList>
#include <QObject>
#include <QSqlDatabase>
#include <QString>
#include <QVariantMap>
#include <QStringList>
#include <optional>
/*!
* \file forecaststore.h
* \brief L'historique BITEMPOREL des prévisions de production PV.
*
* \par Ce que ce n'est pas
* Ce n'est **pas** « une valeur par heure ». 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 porte donc **« la valeur pour une heure,
* telle qu'on la connaissait à tel moment »**, et la clé est la **paire**.
*
* Avec la seule heure visée, chaque rafraîchissement écraserait le précédent : on perdrait les
* révisions, donc la comparaison prévu/réalisé — c'est-à-dire exactement ce que ce lot existe
* pour montrer.
*
* \par Le GPL n'apprend pas, il rend l'apprentissage POSSIBLE
* Comparer prévu et mesuré pour en tirer une correction est un apprentissage, donc hors de ce
* dépôt. Cette table est l'**interface** avec ce qui apprendra : elle conserve la matière sans
* la traiter. L'écraser à chaque révision la détruirait pour toujours, sans que rien ne le
* signale.
*
* \see docs/DESIGN_PREVISION_PV.md
*/
//! \brief Un point de la courbe : une énergie, pour une heure visée.
struct ForecastPoint {
/*!
* \brief Heure VISÉE — instant absolu, **UTC**. Jamais un libellé d'heure locale.
*
* Un « 02:00 » local existe **deux fois** le jour du passage à l'heure d'hiver et **pas du
* tout** au passage à l'heure d'été. Indexer par heure murale corromprait silencieusement
* la série deux fois par an, un jour où personne ne regarde.
*/
QDateTime targetAt;
//! Énergie prévue sur l'heure (Wh). Une ABSENCE ne s'écrit pas : elle ne crée pas de point.
double wh = 0;
/*!
* \brief Instant où ce CONTENU est apparu pour la première fois — l'ordre des révisions.
*
* Renseigné à la lecture ; ignoré à l'écriture. C'est un instant d'OBSERVATION, pas
* d'émission : depuis qu'on sait que \c issuedAt n'identifie pas le contenu, la seule chose
* qui ordonne les révisions est le moment où on les a vues.
*/
QDateTime firstSeenAt;
/*!
* \brief Dernière fois que ce contenu a été republié — la **fraîcheur**.
*
* \warning **JAMAIS une clé de révision.** \c firstSeenAt ordonne les révisions ;
* celui-ci dit seulement **jusqu'à quand elle a tenu**. Dater une prévision avec lui la
* ferait paraître née au moment où elle a été reconduite — et deux prévisions différentes
* reconduites par le même run porteraient la même date.
*
* \par Sur une ligne JAMAIS REPUBLIÉE, il vaut \c firstSeenAt — jamais absent
* Et ce n'est pas une copie déguisée en confirmation : le champ ne dit pas « quand
* quelqu'un a confirmé », il dit **« quel est le dernier run qui a affirmé ces valeurs »**.
* Le run émetteur les a affirmées. La réponse est donc vraie, et elle l'est uniformément.
*
* L'absence serait le mensonge : elle se lirait « on ne sait pas jusqu'à quand elle a
* tenu », alors qu'on le sait — au moins jusqu'à son émission. Et elle obligerait chaque
* client à écrire le repli `confirmedAt ?? issuedAt`, c'est-à-dire à refaire ce calcul-ci,
* ailleurs, une fois par client, avec une chance de le faire de travers.
*
* \invariant Toujours valide, et toujours `>= firstSeenAt`.
*/
QDateTime lastSeenAt;
};
/*!
* \brief Une révision : la courbe telle qu'on la connaissait à un instant.
*/
struct ForecastRevision {
/*!
* \brief Âge APPROCHÉ de la prévision — un **attribut**, jamais une clé. Peut être invalide.
*
* \warning **`issuedAt` N'IDENTIFIE PAS LE CONTENU**, et ce n'est pas un défaut du
* producteur : c'est ce que l'API permet. Mesuré le 2026-09-09 —
* - il vient de `meteofrance_arome_france_hd`, **le seul domaine AROME exposant un
* `meta.json`**, qui ne fournit **aucune** des valeurs de rayonnement de la courbe ;
* - le domaine qui les fournit (`meteofrance_arome_france`) n'expose **pas** de
* `meta.json` (HTTP 500) : il n'existe **aucune** identité de run pour la donnée qui
* forme la courbe ;
* - le serveur recompose les premières heures autrement que par le seul run, si bien
* qu'elles bougent **sans** que `issuedAt` bouge ;
* - `t0` est ancré sur le jour local de la REQUÊTE, pas sur le run ;
* - sous le repli `best_match`, il vaut **0** ;
* - deux plans d'un même site publient deux courbes **différentes** sous le **même**
* `issuedAt`.
*
* \note Il reste utile, et il est conservé : il répond correctement à « qu'est-ce qu'on
* prévoyait ce matin ». Il ne répond pas à « est-ce la même prévision ».
*
* \note **Invalide est une valeur acceptable.** Une charge utile sans âge est enregistrée
* quand même : refuser une prévision utilisable parce qu'un ATTRIBUT manque reviendrait à
* jeter la donnée pour la métadonnée.
*/
QDateTime issuedAt;
/*!
* \brief Le SITE dont cette courbe est la somme — partie de la clé.
*
* Recommandation du producteur : la clé porte l'identifiant du Thing, parce que deux plans
* publient des courbes DIFFÉRENTES sous le même \c issuedAt (tilt, azimut, kWc et PR entrent
* dans les valeurs). Ici la granularité est le SITE, puisqu'on somme ses plans — mais le
* besoin est le même : sans lui, changer de site désigné mêlerait deux historiques dans une
* table qui n'a aucun moyen de les distinguer.
*/
QString siteId;
//! Les points de la courbe. Une révision **vide n'est pas enregistrée** : voir \c enregistrer().
QList<ForecastPoint> points;
/*!
* \brief Modèle ayant RÉPONDU — provenance réelle, repli compris.
*
* Le producteur publie désormais celui qui a répondu et non celui qui était configuré, repli
* `best_match` inclus, vérifié en provoquant la panne. La colonne peut donc porter une
* valeur.
*
* \note **Aucun remplissage rétroactif.** Les lignes écrites avant cette correction ne
* sauront jamais quel modèle a répondu, et elles doivent le dire : leur `model` reste
* NULL. Le combler après coup y rangerait une supposition sous un nom de provenance.
*/
QString model;
/*!
* \brief Corrections PHYSIQUES appliquées à la courbe, ordre canonique
* (\c temperature, puis \c ac-limit). Vide = conversion brute `GTI × kWc × PR`.
*
* Sur un PLAN : lu dans la charge utile (v2), vide en v1 — la v1 n'a jamais corrigé.
* Sur le SITE : posé par \c sommerPlans(), \c ac-limit compris si un plafond est configuré.
*/
QStringList corrections;
//! Pas de la grille en secondes, LU dans la charge utile (\c step). 0 = inconnu.
int stepS = 0;
};
/*!
* \brief Historique bitemporel des prévisions, sur SQLite.
*
* \invariant Une révision identique à la précédente **sur ses heures communes** n'est pas
* enregistrée : ce n'est pas une révision, c'est la même relue.
* \invariant Une révision sans point n'est jamais enregistrée : l'absence est un **trou**,
* jamais une courbe de zéros.
* \invariant Tous les instants stockés sont des **epochs UTC**.
*/
class ForecastStore : public QObject
{
Q_OBJECT
public:
/*!
* \brief Ouvre (et crée au besoin) la base.
* \param cheminFichier Fichier SQLite. **Le nôtre**, jamais \c energylogs.sqlite : cette
* base-là appartient à l'amont, qui en migre le schéma — et surtout la clé y est un
* instant de mesure, quand la nôtre est une paire. Une prévision révisée n'a rien à faire
* dans la base du mesuré.
* \param parent Parent Qt.
*/
explicit ForecastStore(const QString &cheminFichier, QObject *parent = nullptr);
~ForecastStore() override;
//! \brief Chemin par défaut — à CÔTÉ de la configuration, jamais dans la base de l'amont.
//! \return Le chemin, surchargeable par \c NYMEA_ENERGY_FORECAST_DB (tests).
static QString cheminParDefaut();
/*!
* \brief Convertit la charge utile de transport en révision.
* \param charge Carte typée publiée par le producteur météo (\c forecastCurveTransport).
* \param[out] refus Motif d'échec, vide si la lecture a réussi. Un CODE, pas une phrase.
* \return La révision ; invalide (\c points vide) si la charge utile est refusée.
*
* \par Le REFUS, jamais l'interprétation au mieux
* La charge utile traverse une frontière de processus : aucun compilateur ne vérifie sa
* forme. Une version inconnue produit **aucune courbe** et un motif nommé — jamais une
* lecture partielle qui aurait l'air de marcher. Sans cela, le symptôme d'un changement de
* forme serait « courbe vide », **indistinguable d'une nuit**.
*
* \par Versions lues : 1 et 2
* v2 ajoute \c corrections (liste, obligatoire), \c gti[] et \c tempC[]. Motifs de refus :
* \c VERSION_x (autre version), \c UNIT_x, \c MALFORMED (dont \c corrections absente ou
* non-liste en v2), \c CORRECTION_x (correction inconnue — \c ac-limit compris : il
* appartient au site, pas au plan), \c NO_POINT, \c EMPTY.
*
* \par L'indexation est LUE, pas supposée
* \c convention voyage dans la charge utile : \c p[i] est la moyenne sur
* `[t0 + i·step, t0 + (i+1)·step)`. On n'a donc ni à la redécouvrir, ni à faire confiance
* à un document qui pourrait se périmer sans que le code s'en aperçoive.
*/
static ForecastRevision depuisTransport(const QVariantMap &charge, QString &refus);
/*!
* \brief La courbe du SITE : somme des plans, puis plafond AC (\c DESIGN_PREVISION_PV.md
* §17, §28 décision 2).
* \param plans Révisions des plans du site, déjà lues par \c depuisTransport().
* \param acLimitW Plafond onduleur du site, en W ; \c std::nullopt = aucun plafond.
* \param[out] correctionsMelangees Si non nul : vrai quand les plans ne déclarent pas les
* mêmes corrections (la courbe porte alors leur INTERSECTION).
* \return La révision du site (\c siteId vide, à poser par l'appelant) ; **sans point** si la
* somme n'a pas lieu.
*
* \par On ne somme que des plans du MÊME RUN et de la MÊME GRILLE
* Runs différents : une chimère, on attend la passe suivante (§17.2). Pas différents : la
* somme par instant mêlerait des énergies de périodes différentes, et le plafond
* (W × pas) n'aurait plus de période. Même réponse : rien, plutôt qu'un mélange.
*
* \par Le plafond s'applique APRÈS la somme
* L'écrêtage onduleur porte sur ce que l'onduleur reçoit de tous ses champs : un créneau
* vaut au plus `acLimitW × pas / 3600` Wh. Un créneau ABSENT reste absent — le plafond ne
* fabrique pas de point.
*
* \par Les corrections déclarées sont celles que TOUS les plans ont reçues
* Si les plans diffèrent, la courbe annonce la plus faible commune (intersection) : dire
* « corrigée en température » d'une somme dont un plan ne l'est pas serait faux. Puis
* \c ac-limit si un plafond est configuré — qu'il morde ou non sur ce run, la courbe est
* bornée par lui.
*/
static ForecastRevision sommerPlans(const QList<ForecastRevision> &plans,
std::optional<int> acLimitW,
bool *correctionsMelangees = nullptr);
//! \brief La base est-elle utilisable ? \return Faux si l'ouverture ou la migration a échoué.
bool isValid() const { return m_valide; }
/*!
* \brief Enregistre une révision, si c'en est une.
* \param revision Révision reçue.
* \return Vrai si elle a été écrite ; **faux si ce run était déjà connu**, si elle était
* vide, ou en cas d'échec.
*
* \par UNE RÉVISION N'EXISTE QUE SI LA PRÉVISION A CHANGÉ
* L'identité d'une révision est **le contenu lui-même** — une empreinte de la courbe
* entière. Une republication à l'identique **ne crée pas de ligne** : elle met à jour
* \c lastSeenAt. Un contenu neuf ouvre une révision, que \c issuedAt ait bougé ou non.
*
* C'est **plus fidèle** à la règle que la clé de run ne l'était : la règle a toujours dit
* « si la PRÉVISION a changé », et c'est la prévision qu'on regarde désormais.
*
* On ne stocke pas une confirmation pour un consommateur qui n'existe pas : le
* consommateur d'aujourd'hui est un **affichage**, et un affichage ne lit pas une
* confirmation. C'est le raisonnement qui a déjà écarté la requête `meta.json`, et il vaut
* ici pour la même raison.
*
* \par DEUX INSTANTS, DEUX NOMS
* - \c firstSeenAt : quand ce contenu est apparu. Il ORDONNE les révisions.
* - \c lastSeenAt : la dernière republication à l'identique. Mis à jour en place.
*
* Les garder confondus reproduirait le piège déjà payé deux fois : deux instants voisins,
* un nom qui promet le mauvais. **La FRAÎCHEUR est \c lastSeenAt ; l'APPARITION du
* contenu est \c firstSeenAt.**
*
* \note **Cette déduplication est le DERNIER REMPART, pas un filet.** Celle du producteur
* compare la charge utile entière : elle ne peut pas voir que deux charges utiles
* différentes portent la même courbe. Si celle-ci se trompe, personne ne rattrape.
*
* \note Ce que l'on ne conserve PAS : la LISTE des runs ayant confirmé. Le jour où un
* apprentissage corrélera l'erreur à l'horizon, il en aura peut-être besoin — la décision
* se reprendra alors **avec un consommateur réel**, comme pour `meta.json`.
*/
bool enregistrer(const ForecastRevision &revision);
/*!
* \brief La courbe COURANTE : pour chaque heure visée, la dernière révision connue.
* \param de Début de la fenêtre (inclus, UTC).
* \param a Fin de la fenêtre (inclus, UTC).
* \param connuA Ne considérer que les révisions reçues jusqu'à cet instant ; invalide =
* maintenant. C'est ce paramètre qui rend la lecture **reproductible** : « ce qu'on
* prévoyait à 8 h » se demande en le fixant à 8 h.
* \return Les points, ordonnés par heure visée. **Les heures sans prévision sont ABSENTES** —
* jamais un zéro, qui se lirait « on prévoit une nuit ».
*/
QList<ForecastPoint> courbe(const QDateTime &de, const QDateTime &a,
const QDateTime &connuA = QDateTime()) const;
/*!
* \brief Toutes les révisions connues pour une heure visée, de la plus ancienne à la plus
* récente. Sert à lire l'historique d'une révision, jamais à décider.
* \param targetAt Heure visée (UTC).
* \return Paires (instant d'émission du run, énergie). Vide si rien n'est connu.
*/
QList<QPair<QDateTime, double>> revisions(const QDateTime &targetAt) const;
/*!
* \brief Applique la rétention.
* \param maintenant Instant de référence ; invalide = maintenant.
* \return Nombre de lignes supprimées.
*
* \par Sept jours pleins, puis les DEUX BOUTS
* Au-delà de la fenêtre pleine, on ne garde par heure visée que la **première** révision
* (horizon le plus long) et la **dernière** (meilleure estimation). Les deux bouts
* conservent les deux seules questions qui se posent — *« la veille valait-elle quelque
* chose ? »* et *« que pouvait-on savoir de mieux ? »*. N'en garder qu'un détruirait la
* révision, c'est-à-dire l'objet même de cette table.
*/
int purger(const QDateTime &maintenant = QDateTime());
private:
bool migrer();
QSqlDatabase m_db;
QString m_connexion;
bool m_valide = false;
};