// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #pragma once #include #include #include #include #include #include #include #include #include #include #include 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> pW; //!< \c p du plan, en W moyens sur le pas. QList> gti; //!< GTI brut W/m² — vide en v1. QList> tempC; //!< \c temperature_2m °C — vide en v1. QStringList corrections; //!< Corrections du PLAN, ordre canonique. std::optional issuedAt; //!< Attribut ; absent si 0 (repli best_match). QString model; //!< Modèle ayant répondu ; vide = inconnu. std::optional kwp; //!< Paramètre du Thing plan, en vigueur. std::optional performanceRatio; //!< Idem. std::optional tempCoeffPctPerK; //!< v2 seulement — absent en v1, JAMAIS 0. std::optional 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 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 gti; //!< GTI du pas, W/m² ; NULL en v1. std::optional tempC; //!< \c temperature_2m du pas, °C ; NULL en v1. std::optional 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 kwp; //!< Crête du plan (Thing), en vigueur. std::optional performanceRatio; //!< PR du plan (Thing), en vigueur. std::optional tempCoeffPctPerK; //!< v2 seulement ; NULL en v1. std::optional noctC; //!< v2 seulement ; NULL en v1. std::optional acLimitW; //!< Plafond AC du SITE en vigueur. std::optional 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 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 curtailed; //!< 1 détecté ; 0 cherché, rien vu ; NULL non cherchable. std::optional snow; //!< Chute de neige lue sur le site ; NULL si non lue. bool valid = false; //!< Aucune raison CONNUE d'écarter la ligne. std::optional gtiObserved; //!< GTI observé (satellite) du pas, W/m² ; NULL si inconnu. QString gtiObservedSource; //!< Code de sa source ; vide = NULL. std::optional 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 ¶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. * \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 &plans, int plansAttendus, std::optional acLimitW, std::optional 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 rattacherGtiObserve(const QString &siteId, const QList &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> m_tampon; QMap 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 m_ecritJusqua; // (site, plan) → dernier observedGtiTime TRAITÉ : l'état est relu à chaque cycle, on ne // l'envoie qu'une fois par nouvelle valeur. QHash m_gtiObserveVu; QMap m_minutes; // fin de minute (s) → production (W, nymea) QMap 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; };