Deux gardes sur lastConfirmedAt, demandées et tranchées. JAMAIS UNE CLÉ DE RÉVISION. firstIssuedAt identifie la prévision ; la fraîcheur dit seulement jusqu'à quand elle a tenu. Dater avec elle ferait paraître une prévision 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. Écrit dans l'en-tête, au contrat ET dans un test : une interdiction qui ne vit qu'à un endroit n'est pas une interdiction. SUR UNE LIGNE JAMAIS RECONDUITE, ELLE VAUT firstIssuedAt — jamais absente. Et la tranche vient de ce que le champ RÉPOND : il ne dit pas « quand quelqu'un a confirmé » mais « quel est le dernier run qui a AFFIRMÉ ces valeurs ». Le run émetteur les a affirmées. La réponse est vraie, et uniformément — il n'y a pas un cas confirmation et un cas copie, il y a une seule question avec une seule sorte de réponse. L'ambiguïté redoutée ne se lève pas en choisissant une convention : elle se lève en DÉFINISSANT correctement le champ. Une fois la définition posée, les deux cas n'en font plus qu'un. Et ici l'absence serait le mensonge, à rebours de la règle habituelle du dépôt : elle se lirait « on ne sait pas jusqu'à quand elle a tenu » alors qu'on le sait, et forcerait chaque client à écrire `confirmedAt ?? issuedAt` — ce calcul-ci, ailleurs, une fois par client, avec une chance de le faire de travers. Invariant publié : lastConfirmedAt >= firstIssuedAt, toujours. Une prévision ne peut pas avoir tenu avant d'avoir été émise. Sur le nom : lastConfirmedAt et non confirmedAt — le préfixe dit qu'il s'agit du DERNIER d'une suite, là où confirmedAt se lirait comme l'instant d'un événement unique, et une ligne reconduite quatre fois n'a pas « un » instant de confirmation. loadmodel 31/31, charging 48/48, spotmarket 32/32, ci-quality 4/4, build 0 erreur 0 warning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
240 lines
12 KiB
C++
240 lines
12 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>
|
|
|
|
/*!
|
|
* \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 Run qui a produit ces valeurs EN PREMIER — **l'identité de la prévision**.
|
|
*
|
|
* Renseigné à la lecture ; ignoré à l'écriture. C'est LUI qui date la prévision, et lui seul.
|
|
*/
|
|
QDateTime firstIssuedAt;
|
|
/*!
|
|
* \brief Dernier run qui a AFFIRMÉ ces valeurs — la **fraîcheur**.
|
|
*
|
|
* \warning **JAMAIS une clé de révision.** \c firstIssuedAt identifie la prévision ;
|
|
* 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 RECONDUITE, il vaut \c firstIssuedAt — 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 `>= firstIssuedAt`.
|
|
*/
|
|
QDateTime lastConfirmedAt;
|
|
};
|
|
|
|
/*!
|
|
* \brief Une révision : la courbe telle qu'on la connaissait à un instant.
|
|
*/
|
|
struct ForecastRevision {
|
|
/*!
|
|
* \brief Instant d'INITIALISATION DU RUN, UTC — le vrai, pas l'heure de réception.
|
|
*
|
|
* Il **voyage dans la charge utile** que publie le producteur (`issuedAt`), donc sans second
|
|
* appel à l'API. C'est ce qui rend la clé de révision **exacte**, et non approchée.
|
|
*
|
|
* \par Ce que cela change
|
|
* Une première version indexait sur l'instant de RÉCEPTION, faute de mieux, et interdisait
|
|
* d'en calculer un horizon : la latence de publication d'un modèle se compte en heures.
|
|
* **Cette réserve est levée** — l'heure du run est un horizon calculable.
|
|
*
|
|
* \note Ce champ porte l'instant du run QUI ARRIVE. Le magasin décide ensuite s'il ouvre
|
|
* une révision (\c firstIssuedAt) ou s'il en reconduit une (\c lastConfirmedAt).
|
|
*/
|
|
QDateTime issuedAt;
|
|
//! 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 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 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 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É
|
|
* La déduplication porte sur **les valeurs**, comparées sur les créneaux communs. Un run
|
|
* plus récent qui reconduit les mêmes chiffres **ne crée pas de ligne** : il met à jour
|
|
* \c lastConfirmedAt sur les lignes existantes.
|
|
*
|
|
* 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 — et c'est la leçon d'hier appliquée
|
|
* Un seul champ ne peut pas porter les deux faits sans mentir sur l'un :
|
|
* - \c firstIssuedAt : le run qui a produit ces valeurs **en premier**. C'est la CLÉ ; elle
|
|
* ne bouge jamais, sans quoi l'identité de la ligne changerait sous elle.
|
|
* - \c lastConfirmedAt : le dernier run qui les a **reconduites**. Mis à jour en place.
|
|
*
|
|
* Les garder confondus reproduirait exactement le piège `receivedAt`/`issuedAt` : deux
|
|
* instants voisins, un nom qui promet le mauvais. Quelqu'un lirait « prévu à 06Z » sur une
|
|
* ligne que le run 12Z vient de reconduire, et croirait la prévision vieille de six heures
|
|
* alors qu'elle est fraîche. **La fraîcheur est \c lastConfirmedAt ; l'ancienneté de
|
|
* l'avis est \c firstIssuedAt.**
|
|
*
|
|
* \note **Cette déduplication est le DERNIER REMPART, pas un filet.** Celle du producteur
|
|
* compare la charge utile entière, où \c issuedAt figure : elle ne mord donc jamais sur ce
|
|
* cas. 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;
|
|
};
|