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>
310 lines
16 KiB
C++
310 lines
16 KiB
C++
// 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;
|
||
};
|