Patrick Schurig 606b6d92d9 feat(prévision PV): version B — une révision n'existe que si la prévision a changé
La déduplication porte sur les VALEURS ; un run qui reconduit ne crée pas de
ligne, il met à jour la fraîcheur.

Le raisonnement qui tranche est celui de meta.json, appliqué une seconde fois :
on ne stocke pas pour un consommateur qui n'existe pas. L'apprentissage n'existe
pas, le consommateur d'aujourd'hui est un affichage, et un affichage ne lit pas
une confirmation. Même déclencheur de réouverture, pour la même raison.

UNE CONTRADICTION DANS LA CONSIGNE, ET COMMENT ELLE EST LEVÉE. Deux instructions
incompatibles : « issuedAt est mis à jour sur la ligne existante » (= le dernier
run) et « il devient l'horodatage du premier run, pas du dernier » (= le
premier). Et l'avertissement joint — « quelqu'un lira prévu à 06Z sur une ligne
que le run 12Z a confirmée » — ne décrit un danger QUE si l'instant n'est pas
mis à jour.

Aucune des deux n'a été choisie, parce qu'aucune ne suffit : un seul champ ne
peut pas porter les deux faits sans mentir sur l'un. Ce serait exactement le
piège receivedAt/issuedAt de la veille — deux instants voisins, un nom qui
promet le mauvais — et le remède est le même : nommer ce que c'est.

  firstIssuedAt    run qui a produit ces valeurs EN PREMIER  (CLÉ, immuable)
  lastConfirmedAt  dernier run qui les a RECONDUITES         (mis à jour)

La FRAÎCHEUR est lastConfirmedAt ; l'ANCIENNETÉ DE L'AVIS est firstIssuedAt.
Une valeur émise à 06Z et reconduite à 12Z est fraîche ; une valeur émise à 06Z
que personne n'a reconduite EST vieille de six heures. Les deux phrases sont
vraies et ne parlent pas de la même chose. Les deux sont publiées au contrat :
n'en publier qu'une obligerait le client à choisir laquelle il croit.

firstIssuedAt ne bouge jamais — une clé qui se déplace change l'identité de la
ligne sous elle. Les deux instants coïncident à l'ouverture et divergent à la
première reconduction, ce que le test vérifie explicitement.

DERNIER REMPART, PAS FILET : celle du producteur compare la charge utile
entière, où issuedAt figure, donc elle ne mord jamais sur ce cas. Si celle-ci se
trompe, personne ne rattrape — raison de plus pour qu'elle ne dépende d'aucun
comportement du voisin : intersection des créneaux, et communs > 0 requis.

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:55:38 +02:00

217 lines
11 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. Renseigné à la lecture ; ignoré à l'écriture.
QDateTime firstIssuedAt;
//! \brief Dernier run qui les a RECONDUITES. Renseigné à la lecture ; ignoré à l'écriture.
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;
};