// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #pragma once #include #include #include #include #include /*! * \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 Une révision : la courbe telle qu'on la connaissait à un instant. */ struct ForecastRevision { /*! * \brief Instant de RÉCEPTION, UTC — et **surtout pas** l'heure d'initialisation du run. * * \warning **AUCUN HORIZON NE SE CALCULE DEPUIS CE CHAMP.** Entre l'initialisation d'un run * et sa mise à disposition s'écoule la latence de publication du modèle : des **heures** * pour un modèle global. `targetAt - receivedAt` est donc trop court de toute cette * latence, **systématiquement et dans le même sens** — une prévision qu'on croirait « à * H−1 » peut être le run de H−5. * * \par Pourquoi ce champ s'appelle ainsi * Le nom porte la protection. `issuedAt` promettrait l'heure du run ; quelqu'un écrirait * `targetAt - issuedAt` et obtiendrait un nombre **parfaitement plausible**. L'heure du * run n'est pas dans la réponse de prévision de l'API — il faudrait une requête de plus, * qu'aucun consommateur actuel ne justifie (cf. \c DESIGN_PREVISION_PV.md §13.4). * * \note Ce que la déduplication sauve : les **bornes** des révisions — combien, et laquelle * s'applique. Ce qu'elle ne sauve pas : leur **âge**. « Quelle prévision » est juste ; * « quel horizon » est faux. */ QDateTime receivedAt; //! Les points de la courbe. Une révision **vide n'est pas enregistrée** : voir \c enregistrer(). QList points; /*! * \brief Modèle ayant répondu — **vide tant que le producteur publie une INTENTION**. * * Le plugin météo publie aujourd'hui le modèle *configuré*, pas celui qui a répondu. Le * stocker comme provenance enregistrerait une intention — et une intention rangée dans une * colonne de provenance **devient** une provenance au premier lecteur. Vide dit « on ne * sait pas », ce qui est vrai. */ 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 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 elle a été reconnue identique** à la * dernière connue, ou si elle était vide, ou en cas d'échec. * * \par La comparaison porte sur l'INTERSECTION, pas sur la charge utile * Si la fenêtre publiée **glisse** — seulement les heures à venir — la charge utile de * 11 h a un point de moins que celle de 10 h, et une égalité de charge utile verrait une * révision **toutes les heures**, ce que la déduplication existe précisément pour éviter. * Deux révisions sont donc les mêmes si, **sur leurs heures communes**, toutes les valeurs * coïncident. * * Ce choix ne dépend pas de la forme que le producteur publie aujourd'hui : il est correct * que la fenêtre glisse ou non, et il ne peut donc pas être invalidé par un changement * chez le voisin dont personne ne nous préviendrait. */ 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 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 de réception, énergie). Vide si rien n'est connu. */ QList> 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; };