Patrick Schurig dd24cda445 feat(prévision PV): forecastLearning porte le GTI observé — migration etm67 en place
Trois colonnes nullables : gtiObserved (W/m²), gtiObservedSource
(openmeteo-satellite-state), gtiObservedAt (observedGtiTime tel que publié).
Rattachées au pas [T − 1 h, T) : même convention que gti prévu (§14.3),
Open-Meteo horodatant une moyenne horaire à la fin de son heure.

L'observation arrive 1 à 4 h après l'écriture du pas : elle complète la ligne
existante par un UPDATE ciblé — ces trois colonnes seulement, seulement si
gtiObserved IS NULL. Seule exception à « une ligne n'est jamais réécrite » ;
elle ne crée jamais de ligne. Encore en tampon, elle entre par l'INSERT.

Une table de forme etm67 exacte est migrée par ALTER TABLE ADD COLUMN dans une
transaction : lignes conservées, nouvelles colonnes NULL. Le mécanisme « forme
inattendue → refaite » ne vaut plus que pour les autres formes.

Tests loadmodel : migration d'une base écrite par le code etm67 lui-même
(fixture data/energy-forecast-etm67.sqlite), rattachement, retard, nuit à 0,
valeur jamais remplacée, aucun autre champ touché.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:21:18 +02:00

261 lines
15 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QAtomicInt>
#include <QDateTime>
#include <QHash>
#include <QList>
#include <QMap>
#include <QObject>
#include <QString>
#include <QStringList>
#include <QTimeZone>
#include <QVariantMap>
#include <optional>
class QThread;
class ForecastLearningWriter;
/*!
* \file forecastlearninglog.h
* \brief L'enregistrement pour l'APPRENTISSAGE FUTUR — brief 3, T2 (\c DESIGN_PREVISION_PV.md §31).
*
* \par Le GPL n'apprend pas ; il rend l'apprentissage POSSIBLE (§5)
* Une ligne par **pas écoulé** de la grille du producteur et par **plan** : ce que le plan
* publiait pour ce pas (\c p, et en v2 \c gti / \c tempC), les paramètres de conversion EN
* VIGUEUR, les révisions du site en vigueur à la veille et à H−1 (par leur \c contentId), et ce
* que le site a mesuré. Rien n'est rapproché ici : le rapprochement est le produit.
*
* \par Aucun effet sur l'arbitrage (§11)
* Rien de ce module n'entre dans le contexte ni dans le plan. Il reçoit des COPIES, en fin de
* lecture de la prévision, et n'a aucun accès à ce que le cycle décide.
*/
/*!
* \brief Ce qu'un plan publiait, lu à un cycle — la matière d'une ligne.
*
* Les séries sont indexées comme \c p : l'élément \c i vaut pour `[t0 + i·step, t0 + (i+1)·step)`.
* Un élément ABSENT (\c std::nullopt) est un trou — jamais un zéro.
*/
struct ForecastPlanObservation {
QString planId; //!< ThingId du plan, forme \c {uuid}.
int transportVersion = 0; //!< \c v de la charge utile (1 ou 2).
qint64 t0 = 0; //!< Début de la grille, epoch s UTC.
int stepS = 0; //!< Pas, en secondes, LU.
QList<std::optional<double>> pW; //!< \c p du plan, en W moyens sur le pas.
QList<std::optional<double>> gti; //!< GTI brut W/m² — vide en v1.
QList<std::optional<double>> tempC; //!< \c temperature_2m °C — vide en v1.
QStringList corrections; //!< Corrections du PLAN, ordre canonique.
std::optional<qint64> issuedAt; //!< Attribut ; absent si 0 (repli best_match).
QString model; //!< Modèle ayant répondu ; vide = inconnu.
std::optional<double> kwp; //!< Paramètre du Thing plan, en vigueur.
std::optional<double> performanceRatio; //!< Idem.
std::optional<double> tempCoeffPctPerK; //!< v2 seulement — absent en v1, JAMAIS 0.
std::optional<double> noctC; //!< v2 seulement — absent en v1, JAMAIS 0.
//! GTI OBSERVÉ par satellite (état \c observedGti du Thing plan), W/m². Absent si
//! \c observedGtiTime vaut 0 ou si le Thing n'a pas ces états — JAMAIS 0 par défaut.
std::optional<double> gtiObserved;
//! Instant \c observedGtiTime tel que publié (epoch s) = FIN de l'heure qu'il moyenne ; 0 = aucun.
qint64 gtiObservedAt = 0;
};
/*!
* \brief Une ligne de la table \c forecastLearning, telle qu'elle sera écrite.
* \note Exposée pour les tests et pour la documentation du schéma ; l'écriture passe toujours
* par \c ForecastLearningLog::observer().
*/
struct ForecastLearningRow {
qint64 targetAt = 0; //!< Début du pas, epoch s UTC.
int stepS = 0; //!< Durée du pas, s.
QString siteId; //!< Site désigné à l'observation.
QString planId; //!< Plan.
std::optional<double> gti; //!< GTI du pas, W/m² ; NULL en v1.
std::optional<double> tempC; //!< \c temperature_2m du pas, °C ; NULL en v1.
std::optional<double> pW; //!< \c p du plan pour ce pas, W moyens.
QStringList corrections; //!< Corrections du plan.
int transportVersion = 0; //!< Version de la charge utile lue.
std::optional<double> kwp; //!< Crête du plan (Thing), en vigueur.
std::optional<double> performanceRatio; //!< PR du plan (Thing), en vigueur.
std::optional<double> tempCoeffPctPerK; //!< v2 seulement ; NULL en v1.
std::optional<double> noctC; //!< v2 seulement ; NULL en v1.
std::optional<int> acLimitW; //!< Plafond AC du SITE en vigueur.
std::optional<qint64> issuedAt; //!< Attribut de la charge utile (s).
QString model; //!< Modèle ayant répondu.
qint64 observedAt = 0; //!< Dernière lecture de ce plan pour ce pas (ms).
int sitePlans = 0; //!< Plans du site ayant une valeur pour ce pas.
int sitePlansExpected = 0; //!< Plans portant un canal sous le site.
qint64 dayAheadAt = 0; //!< J−1 18:00 locale, epoch s.
QString contentIdDayAhead; //!< Révision du site en vigueur à \c dayAheadAt ; vide = NULL.
QString contentIdHourAhead; //!< Révision du site en vigueur à `targetAt − 1 h` ; vide = NULL.
std::optional<double> measuredW; //!< Production moyenne du site, W (> 0 = produit).
QString measuredSource; //!< Code de la source ; vide = NULL.
int measuredMinutes = 0; //!< Échantillons d'une minute reçus sur le pas.
std::optional<bool> curtailed; //!< 1 détecté ; 0 cherché, rien vu ; NULL non cherchable.
std::optional<bool> snow; //!< Chute de neige lue sur le site ; NULL si non lue.
bool valid = false; //!< Aucune raison CONNUE d'écarter la ligne.
std::optional<double> gtiObserved; //!< GTI observé (satellite) du pas, W/m² ; NULL si inconnu.
QString gtiObservedSource; //!< Code de sa source ; vide = NULL.
std::optional<qint64> gtiObservedAt; //!< \c observedGtiTime tel que publié (s).
};
/*!
* \brief Enregistre, pour chaque pas écoulé et chaque plan, de quoi refaire et juger la
* conversion plus tard — sans jamais bloquer le cycle.
*
* \par La clé, et pourquoi ce n'est pas celle de \c forecastPv
* `PRIMARY KEY (targetAt, siteId, planId)`. \c forecastPv est l'axe des RÉVISIONS : plusieurs
* contenus pour une même heure, identifiés par leur empreinte (§20). Ici, le pas est ÉCOULÉ :
* il n'y a plus qu'un fait par plan, et il ne se révise pas — une ligne écrite n'est jamais
* réécrite (\c INSERT OR IGNORE). Les révisions restent dans \c forecastPv ; cette table y
* RENVOIE par \c (targetAt, siteId, contentId), la clé de contenu du §20, jamais par
* \c issuedAt, qui n'identifie pas la courbe. \c siteId est dans la clé pour la raison du
* §20.2 : sans lui, changer de site désigné mêlerait deux historiques.
*
* \par Asynchrone, jamais bloquant
* \c observer() ne fait que copier en mémoire et, une fois par pas, poster un lot à un fil
* d'écriture qui a SA connexion SQLite. Aucune requête n'est exécutée dans le fil appelant.
*
* \par Base abîmée
* Table illisible, de forme inattendue ou disparue : elle est REFAITE, l'incident journalisé,
* et le service continue. Fichier qui n'est pas une base : l'enregistrement s'arrête, journalisé
* une fois ; rien d'autre n'en dépend.
*/
class ForecastLearningLog : public QObject
{
Q_OBJECT
public:
//! Rétention de la table, en jours : un cycle saisonnier complet, plus une marge.
static constexpr int RetentionJours = 400;
//! Délai après la fin d'un pas avant de l'écrire : le dernier échantillon d'une minute,
//! horodaté à la fin du pas, arrive juste après.
static constexpr int GraceS = 120;
//! Part minimale des minutes reçues pour publier une production moyenne.
static constexpr double CouvertureMin = 0.9;
//! Une minute est ÉCRÊTÉE si sa production atteint ce rapport du plafond AC du site.
static constexpr double RapportPlafond = 0.98;
//! Code de la source de la production mesurée (jamais une phrase).
static constexpr const char *SourceBilan = "power-balance-1min";
//! Code de la source du GTI observé : l'état publié par le Thing plan openmeteo.
static constexpr const char *SourceGtiObserve = "openmeteo-satellite-state";
//! Pas de la série satellite d'Open-Meteo : la valeur à \c T est la moyenne de `(T − 1 h, T]`.
static constexpr int PasSatelliteS = 3600;
/*!
* \brief Ouvre la table dans le fichier de la prévision, sur un fil d'écriture dédié.
* \param cheminFichier Le MÊME fichier que \c ForecastStore (\c energy-forecast.sqlite).
* \param fuseau Fuseau qui fait foi pour « J−1 18:00 locale » ; invalide = fuseau du système.
* \param parent Parent Qt.
*/
explicit ForecastLearningLog(const QString &cheminFichier,
const QTimeZone &fuseau = QTimeZone(),
QObject *parent = nullptr);
//! \brief Vide les écritures en attente, puis ferme la base sur son fil.
~ForecastLearningLog() override;
/*!
* \brief Lit ce qu'un plan publie : la charge utile et les paramètres de son Thing.
* \param charge Charge utile \c forecastCurveTransport.
* \param parametres Paramètres du Thing plan, par NOM (\c kwp, \c performanceRatio,
* \c tempCoeffPctPerK, \c noctC) ; un nom absent vaut « non lisible ».
* \param[out] refus Motif de refus de la charge utile (codes de \c ForecastStore), vide sinon.
* \return L'observation ; \c pW vide si la charge utile est refusée.
* \note En v1, \c gti, \c tempC, \c tempCoeffPctPerK et \c noctC restent ABSENTS même si le
* Thing porte les paramètres : la v1 ne les a pas appliqués, et les écrire ferait croire le
* contraire. Un \c gti[] ou \c tempC[] de longueur différente de \c p est écarté en entier :
* l'alignement se ferait par devinette.
*/
static ForecastPlanObservation lirePlan(const QVariantMap &charge,
const QVariantMap &parametres, QString &refus);
/*!
* \brief « J−1 18:00 locale » pour un pas : 18:00 la VEILLE du jour local où il commence.
* \param targetAt Début du pas.
* \param fuseau Fuseau qui fait foi.
* \return L'instant absolu correspondant.
*/
static QDateTime instantVeille(const QDateTime &targetAt, const QTimeZone &fuseau);
/*!
* \brief Un cycle : retient ce que les plans publient, et écrit les pas écoulés.
* \param now Temps du cycle (\c ctx.timestamp — injectable).
* \param siteId Site désigné, forme canonique ; vide = rien à retenir.
* \param plans Plans du site dont la charge utile a été lue.
* \param plansAttendus Plans du site portant un canal, refusés compris.
* \param acLimitW Plafond AC du site en vigueur.
* \param chuteNeige Valeur de l'état \c snowfall du site ; absent si le site n'en a pas.
* \param producteurPresent Au moins un Thing \c smartmeterproducer : sans lui, la
* « production » du bilan est un zéro de construction, pas une mesure.
* \note Ne bloque jamais : copies en mémoire, puis un lot posté au fil d'écriture.
* \note GTI OBSERVÉ (\c ForecastPlanObservation::gtiObserved) : rattaché au pas
* `[observedGtiTime − 3600, observedGtiTime)` — la convention de \c gti prévu (§14.3).
* Il arrive 1 à 4 h APRÈS l'écriture du pas : il complète alors la ligne existante, et
* SEULEMENT ses colonnes \c gtiObserved*, SEULEMENT si elles sont NULL — l'unique exception
* à « une ligne n'est jamais réécrite ». Une valeur écrite n'est jamais remplacée ; aucun
* autre champ, \c valid compris, n'en dépend. Un pas sans ligne, ou dont le pas n'est pas
* d'une heure, ne reçoit rien : l'observation ne crée jamais de ligne.
*/
void observer(const QDateTime &now, const QString &siteId,
const QList<ForecastPlanObservation> &plans, int plansAttendus,
std::optional<int> acLimitW, std::optional<double> chuteNeige,
bool producteurPresent);
/*!
* \brief Un échantillon d'une minute du bilan de puissance du site.
* \param finMinute Horodatage de l'échantillon = FIN de la minute qu'il moyenne.
* \param productionW Production du bilan, convention nymea (NÉGATIVE quand on produit).
*/
void observerProduction(const QDateTime &finMinute, double productionW);
//! \brief Lignes effectivement insérées depuis l'ouverture. \return Compteur, lu sans verrou.
int lignesEcrites() const { return m_ecrites.loadAcquire(); }
//! \brief Lots traités par le fil d'écriture. \return Compteur, lu sans verrou.
int lotsTraites() const { return m_lots.loadAcquire(); }
//! \brief Lots de GTI observés traités (appliqués ou abandonnés). \return Compteur, lu sans verrou.
int complementsTraites() const { return m_complements.loadAcquire(); }
//! \brief Lignes dont le GTI observé a été COMPLÉTÉ en base. \return Compteur, lu sans verrou.
int gtiObservesCompletes() const { return m_completes.loadAcquire(); }
//! \brief La table est-elle utilisable ? \return Faux si le fichier n'est pas une base.
bool isValid() const { return m_valide.loadAcquire() != 0; }
private:
struct Cle {
QString siteId;
qint64 targetAt;
bool operator<(const Cle &o) const {
return targetAt != o.targetAt ? targetAt < o.targetAt : siteId < o.siteId;
}
};
void finaliser(const QDateTime &now);
QList<ForecastLearningRow> rattacherGtiObserve(const QString &siteId,
const QList<ForecastPlanObservation> &plans);
QTimeZone m_fuseau;
qint64 m_debutS = 0; // premier cycle : rien d'antérieur n'est écrit
// (site, pas) → plan → la DERNIÈRE lecture de ce plan pour ce pas, ligne partiellement
// remplie. Le tampon survit au glissement de la fenêtre du producteur : à minuit, le pas de
// 23 h quitte la charge utile avant d'avoir été écrit.
QMap<Cle, QHash<QString, ForecastLearningRow>> m_tampon;
QMap<Cle, int> m_attendus; // plans attendus pour (site, pas)
// (site, plan) → début du dernier pas ÉCRIT. La charge utile garde les heures passées du
// jour : sans ce filigrane, un pas écrit serait repris au cycle suivant, réécrit pour rien
// et rejournalisé chaque minute.
QHash<QString, qint64> m_ecritJusqua;
// (site, plan) → dernier observedGtiTime TRAITÉ : l'état est relu à chaque cycle, on ne
// l'envoie qu'une fois par nouvelle valeur.
QHash<QString, qint64> m_gtiObserveVu;
QMap<qint64, double> m_minutes; // fin de minute (s) → production (W, nymea)
QMap<qint64, double> m_neige; // instant (s) → snowfall lu
bool m_producteur = false;
bool m_sansProducteurSignale = false;
QThread *m_fil = nullptr;
ForecastLearningWriter *m_ecrivain = nullptr;
QAtomicInt m_ecrites;
QAtomicInt m_lots;
QAtomicInt m_complements;
QAtomicInt m_completes;
QAtomicInt m_valide;
};