Patrick Schurig 66c81086a9 feat(base): BaseloadLog — minutes du bilan, profil médian, qualité dite
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>
2026-10-14 09:51:23 +02:00

205 lines
10 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 <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;
};