Brief 3 T4. Une minute = consommation du site (échantillon d'une minute du bilan, jamais ses cumuls) moins chaque charge pilotée selon sa source ; sous none, sa puissance commandée, et la minute le garde. Deux tables dédiées dans energy-forecast.sqlite (baseloadMinute, baseloadMinuteLoad), fil d'écriture propre, lots de 5 min, lecture par journée, rétention 35 jours. forecastPv et forecastLearning ne sont pas touchées. Profil : médiane par case locale et type de jour (ouvré/week-end) sur 28 jours ; none sous 7 jours couverts à 90 %. Qualité : minutes comptées à la commande, négatives, écartées, et par charge source et constantMeasurement (la constante déguisée du banc). Tests loadmodel : soustraction par source, profil synthétique (médiane, jours de 23/25 h), seuil des 7 jours, fichier etm67 réel intact, jamais bloquant, redémarrage, rétention, fichier illisible. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
205 lines
10 KiB
C++
205 lines
10 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 <QMutex>
|
||
#include <QObject>
|
||
#include <QString>
|
||
#include <QTimeZone>
|
||
#include <QVariantMap>
|
||
#include <QVector>
|
||
|
||
#include <optional>
|
||
|
||
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<double> 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<BaseloadLoadPart> 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<double> 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<QString, int> 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<bool> 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<std::optional<double>> 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<BaseloadLoadQuality> 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<std::optional<double>> 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<BaseloadMinute> &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;
|
||
};
|