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>
231 lines
13 KiB
C++
231 lines
13 KiB
C++
// 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 ¶metres, 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;
|
||
};
|