Patrick Schurig 139d686302 feat(prévision PV): la clé devient VRAIE, et la déduplication cesse de rejeter
Le canal est livré (openmeteo 1.15.2+etm11) : forecastCurveTransport, une
QVariantMap TYPÉE déclarée dans la ThingClass — au contrat, sans seconde couche
d'encodage. Mieux que la chaîne JSON envisagée : le §12.5 réclamait une version
dans la charge utile faute de schéma ; ici la charge utile a un schéma ET porte
sa version.

issuedAt VOYAGE DANS LA CHARGE UTILE. La clé de révision devient exacte, et tout
le §13 est levé : receivedAt, l'interdiction d'en calculer un horizon, la dette
meta.json. targetAt - issuedAt EST l'horizon. Le coût que je chiffrais disparaît
avec sa cause.

CE QUE J'AVAIS FAUX. Ma déduplication REJETAIT une révision aux valeurs
identiques. Avec une vraie heure de run, c'est faux : un run plus récent portant
les mêmes chiffres CONFIRME, il ne répète pas. Le rejeter rendrait indistincts
« le modèle a confirmé quatre fois » et « le modèle n'a rien dit depuis ce
matin ».

Le mécanisme change donc de nature et se simplifie : la CLÉ déduplique par
construction, et la comparaison de contenu ne fait plus que JOURNALISER (« le
run confirme le précédent »). Si issuedAt bougeait un jour sans que la prévision
bouge, cela se verrait dans le nombre de révisions — un défaut à la source,
qu'il faut voir et non masquer. Une déduplication de contenu l'aurait effacé.

La comparaison sur l'intersection reste, mais comme précaution : la fenêtre
couvre désormais les journées entières et les révisions sont alignées. Elle ne
dépend d'aucun comportement du voisin dont personne ne nous préviendrait.

model porte la provenance réelle, repli best_match compris. La colonne cesse
d'être nulle ; la règle de non-remplissage rétroactif reste.

L'INDEXATION EST LUE, PAS SUPPOSÉE. `convention` dit que p[i] est la moyenne sur
[t0 + i·step, t0 + (i+1)·step) : l'énergie vaut p[i] x step / 3600, sans
supposer que le pas vaut une heure. `unit` est VÉRIFIÉE — lire des Wh comme des
W donnerait une courbe plausible, fausse d'un facteur qui dépend du pas.

Lecture branchée dans le cycle, et c'est sans coût : un run déjà connu n'écrit
rien. Attendre une notification serait plus élégant et MOINS SÛR — un état
publié avant notre démarrage ne notifierait jamais, et la courbe resterait
invisible jusqu'au run suivant. L'état est cherché par son NOM, jamais par un
identifiant de ThingClass. Un refus est journalisé une fois par MOTIF, pas une
fois par cycle.

Deux tests : l'identité du run est celle de la révision (avec l'assertion
centrale — un run qui confirme EST conservé), et le refus de charge utile plutôt
que l'interprétation au mieux (version, unité, vide, tronquée, et un créneau
illisible qui reste un TROU).

loadmodel 30/30, 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
2026-09-08 19:14:18 +02:00

197 lines
9.5 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 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, et il faut le savoir pour ne pas hériter d'une vieille précaution
* Une première version de ce lot 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 global se
* compte en heures, donc `targetAt - receivedAt` était trop court, systématiquement.
* **Cette réserve est levée** : avec l'heure du run, `targetAt - issuedAt` **est**
* l'horizon, et il peut servir à corréler l'erreur à l'âge de la prévision.
*/
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 LA CLÉ DÉDUPLIQUE, PAS LE CONTENU
* Depuis que l'heure du run voyage dans la charge utile, **l'identité du run EST l'identité
* de la révision**. Deux lectures du même run portent le même \c issuedAt et retombent sur
* la même ligne ; rien à comparer.
*
* \par Pourquoi le contenu ne doit SURTOUT PAS rejeter
* Un run **plus récent portant les mêmes chiffres** est une information : le modèle n'a pas
* changé d'avis. **Il confirme, il ne répète pas.** Le rejeter sur l'égalité des valeurs
* détruirait exactement ce que cette table existe pour conserver — et rendrait indistincts
* « le modèle a confirmé quatre fois » et « le modèle n'a rien dit depuis ce matin ».
*
* La comparaison sur l'intersection subsiste donc, mais **journalisée seulement**, jamais
* bloquante. Si un jour \c issuedAt bougeait sans que la prévision bouge, cela se verrait
* dans le nombre de révisions — un défaut à la source, qu'il faut voir et non masquer.
*/
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;
};