// 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 BaseloadWriter; /*! * \file baseloadlog.h * \brief La CONSOMMATION DE BASE — brief 3, T4 (\c DESIGN_PREVISION_PV.md §32). * * \par Ce qui est mesuré * Par minute : la consommation du site lue dans l'échantillon d'UNE MINUTE du bilan nymea * (`import − export + production − stockage`), moins chaque charge pilotée selon SA source de * mesure (\c measurement.source de la télémétrie). Une charge sans mesure (\c none) est comptée * à sa puissance COMMANDÉE — et chaque minute le dit. **Jamais les cumuls du bilan** * (\c totalConsumption…), incohérents sur le banc. * * \par Ce qui est publié * Un profil : médiane, par créneau de la journée LOCALE et par type de jour (ouvré / week-end), * des moyennes de créneau sur 28 jours. Sous 7 jours couverts : \c none, et rien d'inventé. * * \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, et ce qu'il * calcule n'est lu que par \c GetForecastSnapshot. */ //! \brief Une charge pilotée, à une minute : sa source et la puissance retenue. struct BaseloadLoadPart { QString loadId; //!< Identifiant de la charge (\c GetLoadConfig / ThingId d'une borne). QString source; //!< \c meter | \c device | \c none (même règle que la télémétrie). //! W retenus : MESURÉS sous \c meter/\c device, COMMANDÉS sous \c none ; absent si la commande //! est inconnue — la minute est alors incomplète, jamais comptée à 0. std::optional powerW; }; //! \brief Une minute du bilan, et les charges pilotées à cet instant. struct BaseloadMinute { qint64 finMinute = 0; //!< Horodatage de l'échantillon = FIN de la minute (epoch s UTC). double siteW = 0; //!< Consommation du site, W moyens sur la minute. QList charges; //!< Toutes les charges pilotées à cette minute, ordre stable. /*! * \brief La consommation de base de la minute. * \return `siteW − Σ charges` ; absent si une charge n'a pas de puissance connue. * \note Une valeur NÉGATIVE est rendue telle quelle : elle dit que le compteur racine voit * moins que la somme des charges (câblage, compteur aveugle, compteur de charge factice) — * l'écrêter à 0 cacherait exactement ce que la qualité doit montrer. */ std::optional baseW() const; //! \brief Nombre de charges comptées à leur COMMANDE (\c none). \return Le compte. int chargesCommandees() const; }; //! \brief Ce qu'on sait d'une charge sur la fenêtre — pour que la qualité le DISE. struct BaseloadLoadQuality { QString loadId; //!< Identifiant de la charge. QString source; //!< Source à la DERNIÈRE minute de la fenêtre. QHash minutesParSource; //!< meter / device / none → minutes. /*! * \brief La mesure n'a pas varié d'un watt sur la fenêtre (≥ 60 minutes mesurées). * Absent si trop peu de minutes mesurées pour le dire. * \note Ne tranche pas entre « compteur à valeur fixe » (constante déguisée — le cas du banc) * et « charge jamais en marche » : il signale, l'installateur juge. */ std::optional constante; }; /*! * \brief Le profil de consommation de base, tel que calculé — et sa qualité. */ struct BaseloadProfile { static constexpr int FenetreJours = 28; //!< Fenêtre du profil. static constexpr int JoursMin = 7; //!< Sous ce nombre de jours couverts : \c none. static constexpr double CouvertureMin = 0.9; //!< Part de minutes pour qu'un jour / créneau compte. static constexpr int ValeursMinParCase = 2; //!< Une médiane d'UNE valeur n'en est pas une. static constexpr double ConstanteW = 1.0; //!< Écart max/min sous lequel une mesure est « constante ». static constexpr int MinutesMesureesMin = 60; //!< Pour se prononcer sur « constante ». bool calcule = false; //!< Faux tant qu'aucun calcul n'a eu lieu (démarrage). QDateTime calculeA; //!< Instant du calcul (fin de la fenêtre). int resolutionS = 0; //!< Largeur d'un créneau du profil. QString model; //!< \c profile ou \c none. //! [0] jours ouvrés, [1] week-end : une médiane par créneau local, absente si trop peu de valeurs. QVector> medianes[2]; // --- La qualité ------------------------------------------------------------------------ int joursOuvres = 0; //!< Jours locaux ouvrés couverts à ≥ 90 %. int joursWeekEnd = 0; //!< Jours locaux de week-end couverts à ≥ 90 %. int minutes = 0; //!< Minutes de la fenêtre dont la base est connue. int minutesEstimees = 0; //!< … dont au moins une charge était comptée à sa COMMANDE. int minutesNegatives = 0; //!< … dont la base calculée est < 0. int minutesIncompletes = 0; //!< Minutes ÉCARTÉES : une commande inconnue. QList charges; //!< Une entrée par charge vue dans la fenêtre, triée. /*! * \brief Le profil sur une grille d'instants absolus. * \param start Début du premier créneau. \param stepS Pas de la grille. \param steps Créneaux. * \param fuseau Fuseau qui fait foi pour l'heure murale et le type de jour. * \return Une valeur par créneau, absente si inconnue ; tout absent sous \c none ou si la * grille n'a pas la résolution du profil. * \note Le créneau prend la case de l'heure murale de son DÉBUT : au jour de 25 h, les deux * « 02:00 » reçoivent la même médiane ; au jour de 23 h, la case 02:00 n'est jamais lue. */ QList> serie(const QDateTime &start, int stepS, int steps, const QTimeZone &fuseau) const; //! \brief La qualité, en codes et nombres — jamais une phrase. \return La carte publiée. QVariantMap qualiteVariant() const; }; /*! * \brief Enregistre les minutes de consommation de base et en tient le profil — sans jamais * bloquer le cycle ni un appel RPC. * * \par Deux tables dédiées, dans \c energy-forecast.sqlite * \c baseloadMinute (une ligne par minute) et \c baseloadMinuteLoad (une par minute et par * charge). Aucune autre table du fichier n'est touchée : \c forecastPv et \c forecastLearning * gardent leur forme et leurs lignes. * * \par Asynchrone * \c observerMinute() copie et poste ; le fil d'écriture (sa propre connexion) insère, purge, * et recalcule le profil au plus tous les quarts d'heure. \c profil() rend la DERNIÈRE copie * calculée, sous verrou bref, sans requête. */ class BaseloadLog : public QObject { Q_OBJECT public: //! Rétention des minutes, en jours : la fenêtre du profil, plus une semaine de marge. static constexpr int RetentionJours = 35; //! Recalcul au plus tous les … secondes de temps de MINUTE (pas d'horloge murale). static constexpr int RecalculS = 900; //! Résolution par défaut du profil : celle de la grille du producteur aujourd'hui. static constexpr int ResolutionParDefautS = 3600; /*! * \brief Ouvre les tables, sur un fil d'écriture dédié, et calcule un premier profil. * \param cheminFichier Le MÊME fichier que \c ForecastStore. * \param fuseau Fuseau qui fait foi ; invalide = fuseau du système, relu à chaque calcul. * \param parent Parent Qt. */ explicit BaseloadLog(const QString &cheminFichier, const QTimeZone &fuseau = QTimeZone(), QObject *parent = nullptr); //! \brief Vide les écritures en attente, puis ferme la base sur son fil. ~BaseloadLog() override; /*! * \brief Une minute de plus. Ne bloque jamais : copie, puis poste au fil d'écriture. * \param minute La minute, charges comprises. */ void observerMinute(const BaseloadMinute &minute); /*! * \brief Demande un recalcul du profil (fin de fenêtre \p now). Asynchrone. * \param now Fin de la fenêtre. \param resolutionS Largeur des créneaux ; 0 = inchangée. */ void recalculer(const QDateTime &now, int resolutionS = 0); //! \brief Le dernier profil calculé. \return Une copie ; \c calcule faux avant le premier calcul. BaseloadProfile profil() const; /*! * \brief Le calcul, PUR — sans base, testable seul. * \param minutes Les minutes disponibles (ordre indifférent). * \param now Fin de la fenêtre ; seules les minutes de `(now − 28 j, now]` comptent. * \param fuseau Fuseau qui fait foi. * \param resolutionS Largeur d'un créneau, en secondes (diviseur de 86 400). * \return Le profil et sa qualité. */ static BaseloadProfile calculerProfil(const QList &minutes, const QDateTime &now, const QTimeZone &fuseau, int resolutionS); //! \brief Minutes insérées depuis l'ouverture. \return Compteur, lu sans verrou. int minutesEcrites() const { return m_ecrites.loadAcquire(); } //! \brief Profils calculés depuis l'ouverture. \return Compteur, lu sans verrou. int profilsCalcules() const { return m_calcules.loadAcquire(); } //! \brief Les tables sont-elles utilisables ? \return Faux si le fichier n'est pas une base. bool isValid() const { return m_valide.loadAcquire() != 0; } private: friend class BaseloadWriter; void publierProfil(const BaseloadProfile &p); QThread *m_fil = nullptr; BaseloadWriter *m_ecrivain = nullptr; mutable QMutex m_verrou; BaseloadProfile m_profil; QAtomicInt m_ecrites; QAtomicInt m_calcules; QAtomicInt m_valide; };