Ce qui ne dépend pas du canal, écrit maintenant. La lecture se branchera quand la forme du canal openmeteo sera connue ; le magasin est vide d'ici là, et une courbe vide EST la réponse honnête à « que prévoit-on ? » quand personne n'a rien publié. LA CLÉ EST LA PAIRE (targetAt, receivedAt). Avec targetAt seul, chaque rafraîchissement écraserait le précédent : on perdrait les révisions, donc la comparaison prévu/réalisé — ce que ce lot existe pour montrer. Le GPL n'apprend pas, il rend l'apprentissage possible : cette table est la matière, et l'écraser la détruirait pour toujours sans que rien ne le signale. receivedAt, JAMAIS issuedAt, et l'interdiction est écrite et non seulement nommée : 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. targetAt - receivedAt est donc trop court, systématiquement et dans le même sens. « Quelle prévision » est juste, « quel horizon » est faux. LA DÉDUPLICATION PORTE SUR L'INTERSECTION, pas sur la charge utile. Si la fenêtre glisse, la courbe de 11 h a un point de moins que celle de 10 h : une égalité de charge utile verrait une révision toutes les heures, exactement ce que la déduplication existe pour éviter. Et `communs > 0` est requis — sans heure commune il n'y a rien à comparer, et conclure « identique » ferait disparaître une révision dont on ne sait rien (cas du passage de minuit). 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, donc il ne peut pas être invalidé par un changement chez le voisin dont personne ne nous préviendrait. RÉTENTION : sept jours pleins, puis les DEUX BOUTS par heure visée — la première révision (horizon le plus long) et la dernière (meilleure estimation). Les deux, et pas une : elles répondent aux deux seules questions qui se posent, et n'en garder qu'une détruirait la révision. model reste NULL tant que le producteur publie une INTENTION et non une provenance. Une chaîne vide serait déjà une valeur ; NULL dit « on ne sait pas », ce qui est vrai. Aucun remplissage rétroactif à prévoir. GetPvForecast : un Get* SANS *Changed — amendement assumé à l'invariant 11, qui vise l'ÉTAT DE L'ARBITRE, celui qui bouge à chaque cycle et laisse un client déconnecté en retard. Une courbe historisée se lit sur demande, comme GetPowerBalanceLogs ; notifier chaque révision produirait un message par heure sur une donnée que personne n'attend en direct. `knownAt` rend la lecture reproductible : « ce qu'on prévoyait ce matin » se demande en le fixant. Base SÉPARÉE, jamais energylogs.sqlite : celle-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é. Trois tests unitaires, et le premier vise le cas qu'un == naïf raterait : une fenêtre glissée d'une heure, valeurs communes inchangées, charge utile différente — ce n'est PAS une révision. Avec ses contre-épreuves : une valeur commune modifiée EN EST une, et une fenêtre sans heure commune aussi. loadmodel 29/29, charging 48/48, spotmarket 32/32, simulation 69/69, 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
176 lines
8.3 KiB
C++
176 lines
8.3 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>
|
||
|
||
/*!
|
||
* \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<ForecastPoint> 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<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 de réception, é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;
|
||
};
|