Patrick Schurig ff94377267 feat(prévision PV): table forecastLearning — un pas écoulé, un plan, une ligne
Brief 3 T2. Nouvelle table dans energy-forecast.sqlite, clé (targetAt, siteId,
planId) : le pas écoulé est un fait clos, jamais réécrit (ON CONFLICT DO NOTHING,
pas INSERT OR IGNORE qui avalerait un NOT NULL). Les révisions restent dans
forecastPv ; la table y renvoie par la clé de contenu du §20 (révision en vigueur
à J−1 18:00 locale et à H−1).

gti/tempC et paramètres v2 NULL en v1, jamais 0. Production moyenne depuis les
échantillons d'une minute du bilan, NULL sous 90 % de couverture ou sans
producteur. Écrêtage détecté seulement contre le plafond AC déclaré ; neige =
chute lue sur le site. Écriture sur un fil dédié ; rétention 400 jours ; table
abîmée refaite ou mise de côté, le service continue.

Tests loadmodel : 5 fonctions, 13 contre-épreuves vérifiées échouant.

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

231 lines
13 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.
};
/*!
* \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.
};
/*!
* \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";
/*!
* \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.
*/
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 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);
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;
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_valide;
};