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>
This commit is contained in:
parent
e3816ee031
commit
66c81086a9
@ -51,6 +51,7 @@ HEADERS += \
|
||||
$$PWD/smartchargingmanager.h \
|
||||
$$PWD/etm/forecast/forecaststore.h \
|
||||
$$PWD/etm/forecast/forecastlearninglog.h \
|
||||
$$PWD/etm/forecast/baseloadlog.h \
|
||||
$$PWD/spotmarket/spotmarketdataprovider.h \
|
||||
$$PWD/spotmarket/spotmarketmanager.h \
|
||||
$$PWD/types/chargingaction.h \
|
||||
@ -72,6 +73,7 @@ SOURCES += \
|
||||
$$PWD/smartchargingmanager.cpp \
|
||||
$$PWD/etm/forecast/forecaststore.cpp \
|
||||
$$PWD/etm/forecast/forecastlearninglog.cpp \
|
||||
$$PWD/etm/forecast/baseloadlog.cpp \
|
||||
$$PWD/spotmarket/spotmarketdataprovider.cpp \
|
||||
$$PWD/spotmarket/spotmarketmanager.cpp \
|
||||
$$PWD/types/chargingaction.cpp \
|
||||
|
||||
556
energyplugin/etm/forecast/baseloadlog.cpp
Normal file
556
energyplugin/etm/forecast/baseloadlog.cpp
Normal file
@ -0,0 +1,556 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#include "baseloadlog.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QLoggingCategory>
|
||||
#include <QMap>
|
||||
#include <QMutexLocker>
|
||||
#include <QSqlDatabase>
|
||||
#include <QSqlError>
|
||||
#include <QSqlQuery>
|
||||
#include <QThread>
|
||||
#include <QUuid>
|
||||
#include <QtMath>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)
|
||||
|
||||
namespace {
|
||||
//! Les colonnes, dans l'ordre : elles servent à CRÉER et à RECONNAÎTRE une table.
|
||||
const QStringList ColonnesMinute = {
|
||||
QStringLiteral("at"), QStringLiteral("siteW"), QStringLiteral("loadsW"), QStringLiteral("baseW"),
|
||||
QStringLiteral("commandedLoads"), QStringLiteral("writtenAt")
|
||||
};
|
||||
const QStringList ColonnesCharge = {
|
||||
QStringLiteral("at"), QStringLiteral("loadId"), QStringLiteral("source"), QStringLiteral("powerW")
|
||||
};
|
||||
|
||||
const char *CreationMinute =
|
||||
"CREATE TABLE IF NOT EXISTS baseloadMinute ("
|
||||
// FIN de la minute, epoch s UTC — l'horodatage de l'échantillon du bilan, tel quel.
|
||||
" at INTEGER NOT NULL PRIMARY KEY,"
|
||||
// Consommation du SITE, W moyens : import − export + production − stockage (bilan 1 min).
|
||||
" siteW REAL NOT NULL,"
|
||||
// Σ des charges pilotées retenues ; NULL si l'une n'avait pas de puissance connue.
|
||||
" loadsW REAL,"
|
||||
// siteW − loadsW ; NULL avec loadsW. Une valeur NÉGATIVE est gardée : elle est un constat.
|
||||
" baseW REAL,"
|
||||
" commandedLoads INTEGER NOT NULL," // charges comptées à leur COMMANDE (none)
|
||||
" writtenAt INTEGER NOT NULL)"; // ms
|
||||
const char *CreationCharge =
|
||||
"CREATE TABLE IF NOT EXISTS baseloadMinuteLoad ("
|
||||
" at INTEGER NOT NULL,"
|
||||
" loadId TEXT NOT NULL,"
|
||||
" source TEXT NOT NULL," // meter | device | none
|
||||
" powerW REAL," // mesuré, ou commandé sous none ; NULL inconnu
|
||||
" PRIMARY KEY (at, loadId))";
|
||||
|
||||
QVariant nul(QMetaType::Type t) { return QVariant(QMetaType(t)); }
|
||||
|
||||
std::optional<double> mediane(QList<double> v)
|
||||
{
|
||||
if (v.isEmpty())
|
||||
return std::nullopt;
|
||||
std::sort(v.begin(), v.end());
|
||||
const int n = v.count();
|
||||
return n % 2 ? v.at(n / 2) : (v.at(n / 2 - 1) + v.at(n / 2)) / 2.0;
|
||||
}
|
||||
|
||||
//! Type de jour : 0 ouvré (lundi-vendredi), 1 week-end. Les jours fériés ne sont pas connus.
|
||||
int typeDeJour(const QDate &d) { return d.dayOfWeek() >= 6 ? 1 : 0; }
|
||||
}
|
||||
|
||||
// ============================================================================================
|
||||
// La minute, et le profil — logique PURE
|
||||
// ============================================================================================
|
||||
|
||||
std::optional<double> BaseloadMinute::baseW() const
|
||||
{
|
||||
double somme = 0;
|
||||
for (const BaseloadLoadPart &c : charges) {
|
||||
if (!c.powerW)
|
||||
return std::nullopt; // une commande inconnue : on ne compte pas 0
|
||||
somme += *c.powerW;
|
||||
}
|
||||
return siteW - somme;
|
||||
}
|
||||
|
||||
int BaseloadMinute::chargesCommandees() const
|
||||
{
|
||||
int n = 0;
|
||||
for (const BaseloadLoadPart &c : charges)
|
||||
n += c.source == QLatin1String("none") ? 1 : 0;
|
||||
return n;
|
||||
}
|
||||
|
||||
BaseloadProfile BaseloadLog::calculerProfil(const QList<BaseloadMinute> &minutes, const QDateTime &now,
|
||||
const QTimeZone &fuseau, int resolutionS)
|
||||
{
|
||||
BaseloadProfile p;
|
||||
p.calcule = true;
|
||||
p.calculeA = now.toUTC();
|
||||
p.resolutionS = resolutionS;
|
||||
const int cases = resolutionS > 0 ? 86400 / resolutionS : 0;
|
||||
p.medianes[0] = QVector<std::optional<double>>(cases);
|
||||
p.medianes[1] = QVector<std::optional<double>>(cases);
|
||||
|
||||
const qint64 finS = now.toSecsSinceEpoch();
|
||||
const qint64 debutS = finS - qint64(BaseloadProfile::FenetreJours) * 86400;
|
||||
|
||||
// (jour local, case) → somme et nombre de minutes ; jour local → minutes connues.
|
||||
struct Cumul { double somme = 0; int n = 0; };
|
||||
QMap<QPair<QDate, int>, Cumul> parCase;
|
||||
QMap<QDate, int> parJour;
|
||||
struct ParCharge { QString derniereSource; qint64 derniere = 0; QHash<QString, int> parSource;
|
||||
double min = 0, max = 0; int mesurees = 0; };
|
||||
QMap<QString, ParCharge> charges;
|
||||
|
||||
for (const BaseloadMinute &m : minutes) {
|
||||
if (m.finMinute <= debutS || m.finMinute > finS)
|
||||
continue;
|
||||
// La minute couvre [fin − 60 s, fin) : elle se range sous l'heure murale de son DÉBUT.
|
||||
const QDateTime locale = QDateTime::fromSecsSinceEpoch(m.finMinute - 60, QTimeZone::utc())
|
||||
.toTimeZone(fuseau);
|
||||
for (const BaseloadLoadPart &c : m.charges) {
|
||||
ParCharge &q = charges[c.loadId];
|
||||
q.parSource[c.source] += 1;
|
||||
if (m.finMinute >= q.derniere) {
|
||||
q.derniere = m.finMinute;
|
||||
q.derniereSource = c.source;
|
||||
}
|
||||
if (c.source != QLatin1String("none") && c.powerW) {
|
||||
q.min = q.mesurees == 0 ? *c.powerW : qMin(q.min, *c.powerW);
|
||||
q.max = q.mesurees == 0 ? *c.powerW : qMax(q.max, *c.powerW);
|
||||
++q.mesurees;
|
||||
}
|
||||
}
|
||||
const std::optional<double> b = m.baseW();
|
||||
if (!b) {
|
||||
++p.minutesIncompletes;
|
||||
continue;
|
||||
}
|
||||
++p.minutes;
|
||||
if (m.chargesCommandees() > 0)
|
||||
++p.minutesEstimees;
|
||||
if (*b < 0)
|
||||
++p.minutesNegatives;
|
||||
if (cases == 0)
|
||||
continue;
|
||||
const int c = (locale.time().msecsSinceStartOfDay() / 1000) / resolutionS;
|
||||
Cumul &cu = parCase[qMakePair(locale.date(), c)];
|
||||
cu.somme += *b;
|
||||
++cu.n;
|
||||
parJour[locale.date()] += 1;
|
||||
}
|
||||
|
||||
// UN JOUR COMPTE s'il est couvert à 90 % de SES minutes — 1 380, 1 440 ou 1 500 selon
|
||||
// l'heure d'été : la durée du jour local se lit en instants, jamais en heures murales.
|
||||
for (auto it = parJour.constBegin(); it != parJour.constEnd(); ++it) {
|
||||
const QDateTime d0(it.key(), QTime(0, 0), fuseau);
|
||||
const QDateTime d1(it.key().addDays(1), QTime(0, 0), fuseau);
|
||||
const qint64 attendues = (d1.toSecsSinceEpoch() - d0.toSecsSinceEpoch()) / 60;
|
||||
if (it.value() >= qCeil(BaseloadProfile::CouvertureMin * attendues))
|
||||
(typeDeJour(it.key()) == 0 ? p.joursOuvres : p.joursWeekEnd) += 1;
|
||||
}
|
||||
|
||||
// LA MÉDIANE, par type de jour et par case, des moyennes de case couvertes à 90 %.
|
||||
QVector<QList<double>> valeurs[2] = {QVector<QList<double>>(cases), QVector<QList<double>>(cases)};
|
||||
const int attenduesParCase = resolutionS / 60;
|
||||
for (auto it = parCase.constBegin(); it != parCase.constEnd(); ++it) {
|
||||
// Au jour de 25 h, la case répétée reçoit deux heures de minutes : sa moyenne les mêle,
|
||||
// sa couverture est jugée sur une case normale. Au jour de 23 h, la case qui manque
|
||||
// n'existe pas — elle ne compte pour rien.
|
||||
if (it.value().n < qCeil(BaseloadProfile::CouvertureMin * attenduesParCase))
|
||||
continue;
|
||||
valeurs[typeDeJour(it.key().first)][it.key().second] << it.value().somme / it.value().n;
|
||||
}
|
||||
const bool assez = p.joursOuvres + p.joursWeekEnd >= BaseloadProfile::JoursMin;
|
||||
p.model = assez ? QStringLiteral("profile") : QStringLiteral("none");
|
||||
if (assez) {
|
||||
for (int t = 0; t < 2; ++t)
|
||||
for (int c = 0; c < cases; ++c)
|
||||
if (valeurs[t][c].count() >= BaseloadProfile::ValeursMinParCase)
|
||||
p.medianes[t][c] = mediane(valeurs[t][c]);
|
||||
}
|
||||
|
||||
for (auto it = charges.constBegin(); it != charges.constEnd(); ++it) {
|
||||
BaseloadLoadQuality q;
|
||||
q.loadId = it.key();
|
||||
q.source = it.value().derniereSource;
|
||||
q.minutesParSource = it.value().parSource;
|
||||
if (it.value().mesurees >= BaseloadProfile::MinutesMesureesMin)
|
||||
q.constante = (it.value().max - it.value().min) < BaseloadProfile::ConstanteW;
|
||||
p.charges << q;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
QList<std::optional<double>> BaseloadProfile::serie(const QDateTime &start, int stepS, int steps,
|
||||
const QTimeZone &fuseau) const
|
||||
{
|
||||
QList<std::optional<double>> s;
|
||||
for (int i = 0; i < steps; ++i)
|
||||
s << std::nullopt;
|
||||
if (model != QLatin1String("profile") || stepS != resolutionS || resolutionS <= 0)
|
||||
return s;
|
||||
for (int i = 0; i < steps; ++i) {
|
||||
const QDateTime locale = start.addSecs(qint64(i) * stepS).toTimeZone(fuseau);
|
||||
const int c = (locale.time().msecsSinceStartOfDay() / 1000) / resolutionS;
|
||||
const QVector<std::optional<double>> &m = medianes[typeDeJour(locale.date())];
|
||||
if (c >= 0 && c < m.count())
|
||||
s[i] = m.at(c);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
QVariantMap BaseloadProfile::qualiteVariant() const
|
||||
{
|
||||
QVariantMap q;
|
||||
q.insert(QStringLiteral("windowDays"), FenetreJours);
|
||||
q.insert(QStringLiteral("minDays"), JoursMin);
|
||||
q.insert(QStringLiteral("resolutionS"), resolutionS);
|
||||
q.insert(QStringLiteral("daysCovered"), QVariantMap{{QStringLiteral("workday"), joursOuvres},
|
||||
{QStringLiteral("weekend"), joursWeekEnd}});
|
||||
q.insert(QStringLiteral("minutes"), minutes);
|
||||
q.insert(QStringLiteral("commandedMinutes"), minutesEstimees);
|
||||
q.insert(QStringLiteral("negativeMinutes"), minutesNegatives);
|
||||
q.insert(QStringLiteral("incompleteMinutes"), minutesIncompletes);
|
||||
if (calculeA.isValid())
|
||||
q.insert(QStringLiteral("computedAt"), calculeA.toUTC().toString(Qt::ISODate));
|
||||
QVariantList cs;
|
||||
for (const BaseloadLoadQuality &c : charges) {
|
||||
QVariantMap m;
|
||||
m.insert(QStringLiteral("loadId"), c.loadId);
|
||||
m.insert(QStringLiteral("source"), c.source);
|
||||
QVariantMap parSource;
|
||||
for (auto it = c.minutesParSource.constBegin(); it != c.minutesParSource.constEnd(); ++it)
|
||||
parSource.insert(it.key(), it.value());
|
||||
m.insert(QStringLiteral("minutesBySource"), parSource);
|
||||
// ABSENT faute de minutes mesurées pour se prononcer — jamais « false » par défaut.
|
||||
if (c.constante)
|
||||
m.insert(QStringLiteral("constantMeasurement"), *c.constante);
|
||||
cs << m;
|
||||
}
|
||||
q.insert(QStringLiteral("loads"), cs);
|
||||
return q;
|
||||
}
|
||||
|
||||
// ============================================================================================
|
||||
// LE FIL D'ÉCRITURE — sa propre connexion
|
||||
// ============================================================================================
|
||||
|
||||
/*!
|
||||
* \brief Écrit les minutes et recalcule le profil, dans son propre fil et sa propre connexion.
|
||||
* \note Interne à \c BaseloadLog.
|
||||
*/
|
||||
class BaseloadWriter : public QObject
|
||||
{
|
||||
public:
|
||||
BaseloadWriter(const QString &chemin, BaseloadLog *log, const QTimeZone &fuseau)
|
||||
: m_chemin(chemin), m_log(log), m_fuseau(fuseau) {}
|
||||
|
||||
//! Minutes tenues en mémoire avant écriture : une transaction toutes les cinq minutes au
|
||||
//! lieu d'une par minute — moins de verrous tenus sur le fichier que le cycle partage.
|
||||
static constexpr int LotMinutes = 5;
|
||||
|
||||
void ouvrir()
|
||||
{
|
||||
QDir().mkpath(QFileInfo(m_chemin).absolutePath());
|
||||
m_connexion = QStringLiteral("etm-baseload-%1").arg(QUuid::createUuid().toString(QUuid::WithoutBraces));
|
||||
m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connexion);
|
||||
m_db.setDatabaseName(m_chemin);
|
||||
m_db.setConnectOptions(QStringLiteral("QSQLITE_BUSY_TIMEOUT=10000"));
|
||||
if (!m_db.open()) {
|
||||
horsService(m_db.lastError().text());
|
||||
return;
|
||||
}
|
||||
const bool ok = assurerTable(QStringLiteral("baseloadMinute"), ColonnesMinute, CreationMinute)
|
||||
&& assurerTable(QStringLiteral("baseloadMinuteLoad"), ColonnesCharge, CreationCharge);
|
||||
m_log->m_valide.storeRelease(ok ? 1 : 0);
|
||||
// UN PREMIER PROFIL dès l'ouverture : après un redémarrage, les 28 jours sont en base, et
|
||||
// les attendre un quart d'heure laisserait le snapshot dire « none » sans raison.
|
||||
if (ok)
|
||||
calculer(QDateTime::currentDateTimeUtc(), 0);
|
||||
}
|
||||
|
||||
void fermer()
|
||||
{
|
||||
vider();
|
||||
if (m_db.isOpen())
|
||||
m_db.close();
|
||||
m_db = QSqlDatabase();
|
||||
if (!m_connexion.isEmpty())
|
||||
QSqlDatabase::removeDatabase(m_connexion);
|
||||
}
|
||||
|
||||
void ajouter(const BaseloadMinute &m)
|
||||
{
|
||||
m_lot << m;
|
||||
if (m_lot.count() >= LotMinutes)
|
||||
vider();
|
||||
// Le recalcul suit le temps des MINUTES, pas l'horloge murale : un test qui injecte une
|
||||
// journée en une seconde la voit recalculée comme le banc la verrait.
|
||||
if (m_dernierCalcul == 0 || m.finMinute - m_dernierCalcul >= BaseloadLog::RecalculS)
|
||||
calculer(QDateTime::fromSecsSinceEpoch(m.finMinute, QTimeZone::utc()), 0);
|
||||
}
|
||||
|
||||
void calculer(const QDateTime &now, int resolutionS)
|
||||
{
|
||||
vider();
|
||||
if (resolutionS > 0)
|
||||
m_resolution = resolutionS;
|
||||
m_dernierCalcul = now.toSecsSinceEpoch();
|
||||
if (!m_log->m_valide.loadAcquire()) {
|
||||
m_log->m_calcules.fetchAndAddRelease(1);
|
||||
return;
|
||||
}
|
||||
purger(now.toSecsSinceEpoch());
|
||||
const qint64 fin = now.toSecsSinceEpoch();
|
||||
const qint64 debut = fin - qint64(BaseloadProfile::FenetreJours) * 86400;
|
||||
const QList<BaseloadMinute> minutes = lire(debut, fin);
|
||||
const QTimeZone fuseau = m_fuseau.isValid() ? m_fuseau : QTimeZone::systemTimeZone();
|
||||
const BaseloadProfile p = BaseloadLog::calculerProfil(minutes, now, fuseau, m_resolution);
|
||||
m_log->publierProfil(p);
|
||||
m_log->m_calcules.fetchAndAddRelease(1);
|
||||
// 7-c — l'ISSUE à chaque calcul, et ce qui a été LU : combien de minutes, de quelle nature.
|
||||
qCInfo(dcNymeaEnergy()).noquote()
|
||||
<< QStringLiteral("[Base] profil %1 — %2 jour(s) ouvré(s) + %3 de week-end couverts "
|
||||
"(seuil %4) ; %5 minute(s) connues dont %6 à la commande, %7 négative(s), "
|
||||
"%8 écartée(s)")
|
||||
.arg(p.model).arg(p.joursOuvres).arg(p.joursWeekEnd).arg(BaseloadProfile::JoursMin)
|
||||
.arg(p.minutes).arg(p.minutesEstimees).arg(p.minutesNegatives)
|
||||
.arg(p.minutesIncompletes);
|
||||
}
|
||||
|
||||
private:
|
||||
void vider()
|
||||
{
|
||||
if (m_lot.isEmpty() || !m_log->m_valide.loadAcquire()) {
|
||||
m_lot.clear();
|
||||
return;
|
||||
}
|
||||
if (!m_db.transaction()) {
|
||||
qCWarning(dcNymeaEnergy()) << "[Base] écriture remise à plus tard —" << m_db.lastError().text();
|
||||
return; // le lot reste en mémoire, on réessaiera
|
||||
}
|
||||
QSqlQuery qm(m_db), qc(m_db);
|
||||
// `ON CONFLICT … DO NOTHING`, jamais `INSERT OR IGNORE` : celui-ci avalerait aussi une
|
||||
// violation de NOT NULL, et une minute mal formée disparaîtrait sans trace (leçon T2).
|
||||
qm.prepare(QStringLiteral("INSERT INTO baseloadMinute (%1) VALUES (?,?,?,?,?,?) "
|
||||
"ON CONFLICT (at) DO NOTHING").arg(ColonnesMinute.join(',')));
|
||||
qc.prepare(QStringLiteral("INSERT INTO baseloadMinuteLoad (%1) VALUES (?,?,?,?) "
|
||||
"ON CONFLICT (at, loadId) DO NOTHING").arg(ColonnesCharge.join(',')));
|
||||
const qint64 ecrit = QDateTime::currentMSecsSinceEpoch();
|
||||
int inserees = 0;
|
||||
for (const BaseloadMinute &m : std::as_const(m_lot)) {
|
||||
const std::optional<double> b = m.baseW();
|
||||
double charges = 0;
|
||||
for (const BaseloadLoadPart &c : m.charges)
|
||||
charges += c.powerW.value_or(0);
|
||||
qm.addBindValue(m.finMinute);
|
||||
qm.addBindValue(m.siteW);
|
||||
qm.addBindValue(b ? QVariant(charges) : nul(QMetaType::Double));
|
||||
qm.addBindValue(b ? QVariant(*b) : nul(QMetaType::Double));
|
||||
qm.addBindValue(m.chargesCommandees());
|
||||
qm.addBindValue(ecrit);
|
||||
bool ok = qm.exec();
|
||||
inserees += ok && qm.numRowsAffected() > 0 ? 1 : 0;
|
||||
for (const BaseloadLoadPart &c : m.charges) {
|
||||
if (!ok)
|
||||
break;
|
||||
qc.addBindValue(m.finMinute);
|
||||
qc.addBindValue(c.loadId);
|
||||
qc.addBindValue(c.source);
|
||||
qc.addBindValue(c.powerW ? QVariant(*c.powerW) : nul(QMetaType::Double));
|
||||
ok = qc.exec();
|
||||
}
|
||||
if (!ok) {
|
||||
const QSqlError e = qm.lastError().isValid() ? qm.lastError() : qc.lastError();
|
||||
m_db.rollback();
|
||||
if (e.nativeErrorCode().toInt() == 5 || e.nativeErrorCode().toInt() == 6) {
|
||||
qCWarning(dcNymeaEnergy()) << "[Base] base occupée —" << m_lot.count()
|
||||
<< "minute(s) remise(s) au lot suivant";
|
||||
return;
|
||||
}
|
||||
qCWarning(dcNymeaEnergy()) << "[Base]" << m_lot.count() << "minute(s) PERDUE(S) —"
|
||||
<< e.text();
|
||||
m_lot.clear();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!m_db.commit()) {
|
||||
m_db.rollback();
|
||||
return;
|
||||
}
|
||||
m_log->m_ecrites.fetchAndAddRelease(inserees);
|
||||
m_lot.clear();
|
||||
}
|
||||
|
||||
/*!
|
||||
* La fenêtre, lue PAR JOURNÉE : chaque requête est courte et rend le verrou du fichier
|
||||
* entre deux. Une lecture de 28 jours d'un bloc tiendrait le verrou partagé assez longtemps
|
||||
* pour faire attendre l'historique des révisions — qui écrit, lui, depuis le cycle.
|
||||
*/
|
||||
QList<BaseloadMinute> lire(qint64 debut, qint64 fin)
|
||||
{
|
||||
QList<BaseloadMinute> sortie;
|
||||
QHash<qint64, int> index;
|
||||
for (qint64 a = debut; a < fin; a += 86400) {
|
||||
const qint64 b = qMin(fin, a + 86400);
|
||||
QSqlQuery q(m_db);
|
||||
q.prepare(QStringLiteral("SELECT at, siteW FROM baseloadMinute WHERE at > ? AND at <= ? ORDER BY at"));
|
||||
q.addBindValue(a);
|
||||
q.addBindValue(b);
|
||||
if (q.exec()) {
|
||||
while (q.next()) {
|
||||
BaseloadMinute m;
|
||||
m.finMinute = q.value(0).toLongLong();
|
||||
m.siteW = q.value(1).toDouble();
|
||||
index.insert(m.finMinute, sortie.count());
|
||||
sortie << m;
|
||||
}
|
||||
}
|
||||
q.finish();
|
||||
QSqlQuery c(m_db);
|
||||
c.prepare(QStringLiteral("SELECT at, loadId, source, powerW FROM baseloadMinuteLoad "
|
||||
"WHERE at > ? AND at <= ?"));
|
||||
c.addBindValue(a);
|
||||
c.addBindValue(b);
|
||||
if (c.exec()) {
|
||||
while (c.next()) {
|
||||
const int i = index.value(c.value(0).toLongLong(), -1);
|
||||
if (i < 0)
|
||||
continue;
|
||||
BaseloadLoadPart part;
|
||||
part.loadId = c.value(1).toString();
|
||||
part.source = c.value(2).toString();
|
||||
if (!c.value(3).isNull())
|
||||
part.powerW = c.value(3).toDouble();
|
||||
sortie[i].charges << part;
|
||||
}
|
||||
}
|
||||
}
|
||||
return sortie;
|
||||
}
|
||||
|
||||
void purger(qint64 nowS)
|
||||
{
|
||||
const qint64 borne = nowS - qint64(BaseloadLog::RetentionJours) * 86400;
|
||||
QSqlQuery q(m_db);
|
||||
for (const char *table : {"baseloadMinute", "baseloadMinuteLoad"}) {
|
||||
q.prepare(QStringLiteral("DELETE FROM %1 WHERE at <= ?").arg(QLatin1String(table)));
|
||||
q.addBindValue(borne);
|
||||
if (!q.exec())
|
||||
qCWarning(dcNymeaEnergy()) << "[Base] purge refusée :" << q.lastError().text();
|
||||
}
|
||||
}
|
||||
|
||||
/*!
|
||||
* La table existe-t-elle, avec la forme attendue ? Sinon elle est REFAITE — ce sont NOS
|
||||
* tables, nées avec ce lot : aucune version antérieure n'en a écrit, il n'y a pas d'historique
|
||||
* à migrer. Le jour où une colonne s'ajoutera, ce sera une migration en place, comme etm68.
|
||||
* Les autres tables du fichier ne sont jamais touchées.
|
||||
*/
|
||||
bool assurerTable(const QString &nom, const QStringList &colonnes, const char *creation)
|
||||
{
|
||||
QSqlQuery q(m_db);
|
||||
if (!q.exec(QStringLiteral("PRAGMA table_info(%1)").arg(nom))) {
|
||||
horsService(q.lastError().text());
|
||||
return false;
|
||||
}
|
||||
QStringList trouvees;
|
||||
while (q.next())
|
||||
trouvees << q.value(1).toString();
|
||||
if (!trouvees.isEmpty() && trouvees != colonnes) {
|
||||
qCWarning(dcNymeaEnergy()).noquote()
|
||||
<< QStringLiteral("[Base] table %1 de forme inattendue (%2 colonne(s)) — REFAITE, "
|
||||
"son contenu est perdu ; le service continue").arg(nom).arg(trouvees.count());
|
||||
if (!q.exec(QStringLiteral("DROP TABLE IF EXISTS %1").arg(nom))) {
|
||||
const QString cote = QStringLiteral("%1_abimee_%2").arg(nom).arg(QDateTime::currentMSecsSinceEpoch());
|
||||
if (!q.exec(QStringLiteral("ALTER TABLE %1 RENAME TO %2").arg(nom, cote))) {
|
||||
horsService(q.lastError().text());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!q.exec(QString::fromLatin1(creation))) {
|
||||
horsService(q.lastError().text());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void horsService(const QString &erreur)
|
||||
{
|
||||
m_log->m_valide.storeRelease(0);
|
||||
if (m_horsServiceSignale)
|
||||
return;
|
||||
m_horsServiceSignale = true;
|
||||
qCWarning(dcNymeaEnergy()) << "[Base] consommation de base HORS SERVICE — base illisible"
|
||||
<< m_chemin << ":" << erreur
|
||||
<< "— rien n'est enregistré, l'arbitrage n'en dépend pas";
|
||||
}
|
||||
|
||||
QString m_chemin;
|
||||
BaseloadLog *m_log;
|
||||
QTimeZone m_fuseau;
|
||||
QString m_connexion;
|
||||
QSqlDatabase m_db;
|
||||
QList<BaseloadMinute> m_lot;
|
||||
qint64 m_dernierCalcul = 0;
|
||||
int m_resolution = BaseloadLog::ResolutionParDefautS;
|
||||
bool m_horsServiceSignale = false;
|
||||
};
|
||||
|
||||
// ============================================================================================
|
||||
// LE CÔTÉ APPELANT — copies, aucune requête
|
||||
// ============================================================================================
|
||||
|
||||
BaseloadLog::BaseloadLog(const QString &cheminFichier, const QTimeZone &fuseau, QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
m_valide.storeRelease(1);
|
||||
m_fil = new QThread(this);
|
||||
m_fil->setObjectName(QStringLiteral("etm-baseload"));
|
||||
m_ecrivain = new BaseloadWriter(cheminFichier, this, fuseau);
|
||||
m_ecrivain->moveToThread(m_fil);
|
||||
m_fil->start(QThread::LowPriority);
|
||||
BaseloadWriter *w = m_ecrivain;
|
||||
QMetaObject::invokeMethod(w, [w]() { w->ouvrir(); }, Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
BaseloadLog::~BaseloadLog()
|
||||
{
|
||||
BaseloadWriter *w = m_ecrivain;
|
||||
QMetaObject::invokeMethod(w, [w]() { w->fermer(); delete w; }, Qt::BlockingQueuedConnection);
|
||||
m_fil->quit();
|
||||
m_fil->wait();
|
||||
}
|
||||
|
||||
void BaseloadLog::observerMinute(const BaseloadMinute &minute)
|
||||
{
|
||||
BaseloadWriter *w = m_ecrivain;
|
||||
QMetaObject::invokeMethod(w, [w, minute]() { w->ajouter(minute); }, Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
void BaseloadLog::recalculer(const QDateTime &now, int resolutionS)
|
||||
{
|
||||
BaseloadWriter *w = m_ecrivain;
|
||||
QMetaObject::invokeMethod(w, [w, now, resolutionS]() { w->calculer(now, resolutionS); },
|
||||
Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
BaseloadProfile BaseloadLog::profil() const
|
||||
{
|
||||
QMutexLocker l(&m_verrou);
|
||||
return m_profil;
|
||||
}
|
||||
|
||||
void BaseloadLog::publierProfil(const BaseloadProfile &p)
|
||||
{
|
||||
QMutexLocker l(&m_verrou);
|
||||
m_profil = p;
|
||||
}
|
||||
204
energyplugin/etm/forecast/baseloadlog.h
Normal file
204
energyplugin/etm/forecast/baseloadlog.h
Normal file
@ -0,0 +1,204 @@
|
||||
// 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;
|
||||
};
|
||||
@ -19,6 +19,7 @@
|
||||
#include "etm/config/loadconfigstore.h"
|
||||
#include "etm/ratios/energyratioscalculator.h"
|
||||
#include "etm/tariff/tariffconfig.h"
|
||||
#include "etm/forecast/baseloadlog.h"
|
||||
|
||||
#include <QTest>
|
||||
#include <QDir>
|
||||
@ -2168,3 +2169,295 @@ void TestLoadModel::testAStraddlingSlotGetsTheTimeWeightedMean()
|
||||
QCOMPARE(quarts, (QList<double>{15.0, 15.0, 25.0, 25.0}));
|
||||
}
|
||||
|
||||
// ============================================================================================
|
||||
// Brief 3 T4 — la consommation de base
|
||||
// ============================================================================================
|
||||
|
||||
namespace {
|
||||
//! La base SYNTHÉTIQUE : ouvré 300 + 10·h W, week-end 500 + 10·h W, h = heure MURALE locale.
|
||||
double baseSynthetique(const QDateTime &locale)
|
||||
{
|
||||
return (locale.date().dayOfWeek() >= 6 ? 500.0 : 300.0) + 10.0 * locale.time().hour();
|
||||
}
|
||||
|
||||
/*!
|
||||
* Des minutes `]debut, fin]`, chacune avec TROIS charges de trois sources :
|
||||
* - `ecs` sans mesure (none) : 1 000 W COMMANDÉS de 12:00 à 14:00 locales, 0 sinon ;
|
||||
* - `pac` sur un compteur à valeur FIXE (meter, 800 W) — la constante déguisée du banc ;
|
||||
* - `borne` mesurée par l'appareil (device), 7 000 W de 18:00 à 20:00.
|
||||
* Le site voit base + charges : retrancher selon la source doit rendre la base.
|
||||
*/
|
||||
QList<BaseloadMinute> minutesSynthetiques(qint64 debut, qint64 fin, const QTimeZone &fuseau)
|
||||
{
|
||||
QList<BaseloadMinute> out;
|
||||
for (qint64 f = debut + 60; f <= fin; f += 60) {
|
||||
const QDateTime locale = QDateTime::fromSecsSinceEpoch(f - 60, QTimeZone::utc()).toTimeZone(fuseau);
|
||||
const int h = locale.time().hour();
|
||||
BaseloadMinute m;
|
||||
m.finMinute = f;
|
||||
const double ecs = (h >= 12 && h < 14) ? 1000.0 : 0.0;
|
||||
const double borne = (h >= 18 && h < 20) ? 7000.0 : 0.0;
|
||||
m.charges << BaseloadLoadPart{QStringLiteral("borne"), QStringLiteral("device"), borne}
|
||||
<< BaseloadLoadPart{QStringLiteral("ecs"), QStringLiteral("none"), ecs}
|
||||
<< BaseloadLoadPart{QStringLiteral("pac"), QStringLiteral("meter"), 800.0};
|
||||
m.siteW = baseSynthetique(locale) + ecs + borne + 800.0;
|
||||
out << m;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
qint64 minuitLocal(int a, int mo, int j, const QTimeZone &fuseau)
|
||||
{
|
||||
return QDateTime(QDate(a, mo, j), QTime(0, 0), fuseau).toSecsSinceEpoch();
|
||||
}
|
||||
}
|
||||
|
||||
void TestLoadModel::testTheBaseloadMinuteSubtractsEachLoadBySource()
|
||||
{
|
||||
BaseloadMinute m;
|
||||
m.siteW = 2500;
|
||||
m.charges << BaseloadLoadPart{"ecs", "none", 1000.0}
|
||||
<< BaseloadLoadPart{"pac", "meter", 800.0}
|
||||
<< BaseloadLoadPart{"borne", "device", 0.0};
|
||||
QCOMPARE(m.baseW().value_or(-1), 700.0);
|
||||
QCOMPARE(m.chargesCommandees(), 1);
|
||||
|
||||
// NÉGATIVE, rendue telle quelle : le compteur racine voit moins que les charges. L'écrêter
|
||||
// à 0 cacherait exactement ce que la qualité doit montrer.
|
||||
m.siteW = 1500;
|
||||
QCOMPARE(m.baseW().value_or(1e9), -300.0);
|
||||
|
||||
// Une commande INCONNUE : la base l'est aussi — jamais comptée à 0.
|
||||
m.charges[0].powerW.reset();
|
||||
QVERIFY(!m.baseW().has_value());
|
||||
|
||||
// Aucune charge pilotée : la base EST la consommation du site.
|
||||
BaseloadMinute seule;
|
||||
seule.siteW = 420;
|
||||
QCOMPARE(seule.baseW().value_or(-1), 420.0);
|
||||
QCOMPARE(seule.chargesCommandees(), 0);
|
||||
}
|
||||
|
||||
void TestLoadModel::testTheBaseloadProfileIsTheMedianPerSlotAndDayType()
|
||||
{
|
||||
const QTimeZone paris("Europe/Paris");
|
||||
QVERIFY(paris.isValid());
|
||||
// Fenêtre de 28 jours finissant au minuit local du mardi 2026-11-10 : elle CONTIENT le jour
|
||||
// de 25 h (dimanche 2026-10-25). Données dès le 1er octobre — la fenêtre trie.
|
||||
const qint64 fin = minuitLocal(2026, 11, 10, paris);
|
||||
QList<BaseloadMinute> minutes = minutesSynthetiques(minuitLocal(2026, 10, 1, paris), fin, paris);
|
||||
|
||||
// Les ACCIDENTS que la médiane doit absorber, et que la qualité doit compter :
|
||||
// - un mercredi entier à +3 000 W (une journée hors norme) ;
|
||||
// - une heure où le compteur racine ne voit RIEN (site 0 W : base négative) ;
|
||||
// - 30 minutes où la commande de l'ECS est INCONNUE (minute écartée).
|
||||
const qint64 mer0 = minuitLocal(2026, 10, 21, paris), mer1 = minuitLocal(2026, 10, 22, paris);
|
||||
const qint64 aveugle = minuitLocal(2026, 10, 27, paris) + 10 * 3600;
|
||||
const qint64 inconnu = minuitLocal(2026, 10, 28, paris) + 15 * 3600;
|
||||
int negatives = 0, incompletes = 0;
|
||||
for (BaseloadMinute &m : minutes) {
|
||||
if (m.finMinute > mer0 && m.finMinute <= mer1)
|
||||
m.siteW += 3000;
|
||||
if (m.finMinute > aveugle && m.finMinute <= aveugle + 3600) {
|
||||
m.siteW = 0;
|
||||
++negatives;
|
||||
}
|
||||
if (m.finMinute > inconnu && m.finMinute <= inconnu + 1800) {
|
||||
m.charges[1].powerW.reset();
|
||||
++incompletes;
|
||||
}
|
||||
}
|
||||
|
||||
const BaseloadProfile p = BaseloadLog::calculerProfil(
|
||||
minutes, QDateTime::fromSecsSinceEpoch(fin, QTimeZone::utc()), paris, 3600);
|
||||
QCOMPARE(p.model, QStringLiteral("profile"));
|
||||
QCOMPARE(p.resolutionS, 3600);
|
||||
// 28 jours locaux, du mar. 13 oct. (23 h de données dans la fenêtre, 96 % : compté) au
|
||||
// lun. 9 nov. — 20 ouvrés, 8 de week-end. Le dimanche de 25 h compte sur SES 1 500 minutes.
|
||||
QCOMPARE(p.joursOuvres, 20);
|
||||
QCOMPARE(p.joursWeekEnd, 8);
|
||||
QCOMPARE(p.minutesNegatives, negatives);
|
||||
QCOMPARE(p.minutesIncompletes, incompletes);
|
||||
// L'ECS est sous `none` à CHAQUE minute : toutes les minutes connues sont des estimations.
|
||||
QCOMPARE(p.minutesEstimees, p.minutes);
|
||||
|
||||
// LA QUALITÉ PAR CHARGE : la source, le décompte, et la constante déguisée.
|
||||
QHash<QString, BaseloadLoadQuality> q;
|
||||
for (const BaseloadLoadQuality &c : p.charges)
|
||||
q.insert(c.loadId, c);
|
||||
QCOMPARE(q.value("pac").source, QStringLiteral("meter"));
|
||||
QCOMPARE(q.value("pac").constante, std::optional<bool>(true)); // 800 W fixes : le banc
|
||||
QCOMPARE(q.value("borne").constante, std::optional<bool>(false)); // 0 puis 7 000 : vivante
|
||||
QVERIFY2(!q.value("ecs").constante.has_value(), "aucune minute mesurée : rien à dire, pas « false »");
|
||||
QCOMPARE(q.value("ecs").source, QStringLiteral("none"));
|
||||
QCOMPARE(q.value("ecs").minutesParSource.value("none"), p.minutes + p.minutesIncompletes);
|
||||
const QVariantMap qv = p.qualiteVariant();
|
||||
QCOMPARE(qv.value("daysCovered").toMap().value("workday").toInt(), 20);
|
||||
QCOMPARE(qv.value("commandedMinutes").toInt(), p.minutes);
|
||||
QVERIFY(!qv.value("loads").toList().isEmpty());
|
||||
|
||||
// LES VALEURS : la base synthétique EXACTE, malgré le mercredi hors norme et l'heure
|
||||
// aveugle — c'est ce que la médiane achète. Les attendus sont des CONSTANTES.
|
||||
QList<std::optional<double>> attendu;
|
||||
for (int h = 0; h < 24; ++h)
|
||||
attendu << 300.0 + 10 * h;
|
||||
QCOMPARE(p.serie(QDateTime::fromSecsSinceEpoch(fin, QTimeZone::utc()), 3600, 24, paris), attendu);
|
||||
|
||||
// JOURS DE 25 H ET DE 23 H (deux dimanches) — heures murales tirées de l'oracle Python :
|
||||
// 2026-10-25 : 0, 1, 2, 2, 3 … 23 ; 2026-03-29 : 0, 1, 3 … 23.
|
||||
QList<std::optional<double>> automne, printemps;
|
||||
for (int h : {0, 1, 2, 2})
|
||||
automne << 500.0 + 10 * h;
|
||||
for (int h = 3; h < 24; ++h)
|
||||
automne << 500.0 + 10 * h;
|
||||
for (int h : {0, 1})
|
||||
printemps << 500.0 + 10 * h;
|
||||
for (int h = 3; h < 24; ++h)
|
||||
printemps << 500.0 + 10 * h;
|
||||
QCOMPARE(p.serie(QDateTime::fromSecsSinceEpoch(1792879200, QTimeZone::utc()), 3600, 25, paris), automne);
|
||||
QCOMPARE(p.serie(QDateTime::fromSecsSinceEpoch(1774738800, QTimeZone::utc()), 3600, 23, paris), printemps);
|
||||
|
||||
// Une grille d'une AUTRE résolution ne lit pas un profil horaire : tout absent.
|
||||
for (const std::optional<double> &v : p.serie(QDateTime::fromSecsSinceEpoch(fin, QTimeZone::utc()), 900, 8, paris))
|
||||
QVERIFY(!v.has_value());
|
||||
}
|
||||
|
||||
void TestLoadModel::testTheBaseloadIsNoneUnderSevenDays()
|
||||
{
|
||||
const QTimeZone paris("Europe/Paris");
|
||||
const qint64 fin = minuitLocal(2026, 11, 10, paris);
|
||||
const QDateTime now = QDateTime::fromSecsSinceEpoch(fin, QTimeZone::utc());
|
||||
|
||||
// SIX jours pleins : none, et la série est entièrement ABSENTE — jamais un profil maigre.
|
||||
BaseloadProfile p = BaseloadLog::calculerProfil(
|
||||
minutesSynthetiques(minuitLocal(2026, 11, 4, paris), fin, paris), now, paris, 3600);
|
||||
QCOMPARE(p.model, QStringLiteral("none"));
|
||||
QCOMPARE(p.joursOuvres + p.joursWeekEnd, 6);
|
||||
for (const std::optional<double> &v : p.serie(now, 3600, 24, paris))
|
||||
QVERIFY(!v.has_value());
|
||||
QCOMPARE(p.qualiteVariant().value("minDays").toInt(), 7);
|
||||
|
||||
// Six jours pleins + un SEPTIÈME couvert à 85 % : toujours none (seuil 90 %).
|
||||
QList<BaseloadMinute> m = minutesSynthetiques(minuitLocal(2026, 11, 3, paris), fin, paris);
|
||||
const qint64 j7 = minuitLocal(2026, 11, 3, paris);
|
||||
m.erase(std::remove_if(m.begin(), m.end(), [&](const BaseloadMinute &x) {
|
||||
return x.finMinute > j7 && x.finMinute <= j7 + 216 * 60; // 216 min = 15 % de 1 440
|
||||
}), m.end());
|
||||
p = BaseloadLog::calculerProfil(m, now, paris, 3600);
|
||||
QCOMPARE(p.joursOuvres + p.joursWeekEnd, 6);
|
||||
QCOMPARE(p.model, QStringLiteral("none"));
|
||||
|
||||
// SEPT jours pleins : profile.
|
||||
p = BaseloadLog::calculerProfil(minutesSynthetiques(j7, fin, paris), now, paris, 3600);
|
||||
QCOMPARE(p.joursOuvres + p.joursWeekEnd, 7);
|
||||
QCOMPARE(p.model, QStringLiteral("profile"));
|
||||
QCOMPARE(p.serie(now, 3600, 1, paris).first().value_or(-1), 300.0);
|
||||
}
|
||||
|
||||
void TestLoadModel::testTheBaseloadLogWritesWithoutTouchingTheOtherTables()
|
||||
{
|
||||
// Le VRAI fichier etm67 (forecastLearning, 4 lignes) : nos tables s'y ajoutent, et la sienne
|
||||
// ne bouge pas — ni forme, ni lignes. C'est l'engagement envers les données du banc (T3).
|
||||
QTemporaryDir dir;
|
||||
const QString chemin = dir.filePath(QStringLiteral("energy-forecast.sqlite"));
|
||||
QVERIFY(QFile::copy(QStringLiteral(":/data/energy-forecast-etm67.sqlite"), chemin));
|
||||
QFile::setPermissions(chemin, QFile::ReadOwner | QFile::WriteOwner);
|
||||
auto lire = [&](const QString &sql) {
|
||||
QVariantList r;
|
||||
const QString nom = QUuid::createUuid().toString();
|
||||
{
|
||||
QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), nom);
|
||||
db.setDatabaseName(chemin);
|
||||
if (db.open()) {
|
||||
QSqlQuery q(db);
|
||||
if (q.exec(sql))
|
||||
while (q.next())
|
||||
r << q.value(0);
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
QSqlDatabase::removeDatabase(nom);
|
||||
return r;
|
||||
};
|
||||
const QVariantList schemaAvant = lire("SELECT sql FROM sqlite_master WHERE name = 'forecastLearning'");
|
||||
const QVariantList lignesAvant = lire("SELECT COUNT(*) FROM forecastLearning");
|
||||
QCOMPARE(lignesAvant.value(0).toInt(), 4);
|
||||
|
||||
const QTimeZone paris("Europe/Paris");
|
||||
QDateTime maintenant = QDateTime::currentDateTimeUtc();
|
||||
maintenant.setTime(QTime(maintenant.time().hour(), maintenant.time().minute()));
|
||||
const qint64 fin = maintenant.toSecsSinceEpoch();
|
||||
const QList<BaseloadMinute> huitJours = minutesSynthetiques(fin - 8 * 86400, fin, paris);
|
||||
{
|
||||
BaseloadLog log(chemin, paris);
|
||||
QTRY_VERIFY(log.profilsCalcules() >= 1); // le premier calcul, à l'ouverture
|
||||
QVERIFY(log.isValid());
|
||||
QCOMPARE(log.profil().model, QStringLiteral("none"));
|
||||
|
||||
// JAMAIS BLOQUANT, même fichier verrouillé en écriture par une autre connexion.
|
||||
const QString nom = QUuid::createUuid().toString();
|
||||
{
|
||||
QSqlDatabase verrou = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), nom);
|
||||
verrou.setDatabaseName(chemin);
|
||||
QVERIFY(verrou.open());
|
||||
QSqlQuery q(verrou);
|
||||
QVERIFY(q.exec(QStringLiteral("BEGIN EXCLUSIVE")));
|
||||
QElapsedTimer chrono;
|
||||
chrono.start();
|
||||
for (int i = 0; i < 20; ++i)
|
||||
log.observerMinute(huitJours.at(i));
|
||||
QVERIFY2(chrono.elapsed() < 50, qPrintable(QString::number(chrono.elapsed())));
|
||||
QTest::qWait(200);
|
||||
QVERIFY(q.exec(QStringLiteral("COMMIT")));
|
||||
verrou.close();
|
||||
}
|
||||
QSqlDatabase::removeDatabase(nom);
|
||||
|
||||
for (int i = 20; i < huitJours.count(); ++i)
|
||||
log.observerMinute(huitJours.at(i));
|
||||
const int calcules = log.profilsCalcules();
|
||||
log.recalculer(maintenant);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(log.profilsCalcules() > calcules, 20000);
|
||||
QCOMPARE(log.minutesEcrites(), huitJours.count());
|
||||
QCOMPARE(log.profil().model, QStringLiteral("profile"));
|
||||
}
|
||||
// NOS tables : une ligne par minute, une par minute et par charge.
|
||||
QCOMPARE(lire("SELECT COUNT(*) FROM baseloadMinute").value(0).toInt(), huitJours.count());
|
||||
QCOMPARE(lire("SELECT COUNT(*) FROM baseloadMinuteLoad").value(0).toInt(), 3 * huitJours.count());
|
||||
QCOMPARE(lire("SELECT COUNT(*) FROM baseloadMinuteLoad WHERE source = 'none'").value(0).toInt(),
|
||||
huitJours.count());
|
||||
// LA SIENNE : intacte.
|
||||
QCOMPARE(lire("SELECT sql FROM sqlite_master WHERE name = 'forecastLearning'"), schemaAvant);
|
||||
QCOMPARE(lire("SELECT COUNT(*) FROM forecastLearning"), lignesAvant);
|
||||
|
||||
// REDÉMARRAGE : le profil est recalculé à l'ouverture, depuis la base — pas après 15 min.
|
||||
{
|
||||
BaseloadLog relance(chemin, paris);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(relance.profilsCalcules() >= 1, 20000);
|
||||
QCOMPARE(relance.profil().model, QStringLiteral("profile"));
|
||||
QCOMPARE(relance.profil().serie(QDateTime::fromSecsSinceEpoch(fin, QTimeZone::utc()), 3600, 1, paris).size(), 1);
|
||||
|
||||
// RÉTENTION : une minute de plus de 35 jours disparaît au calcul suivant.
|
||||
BaseloadMinute vieille;
|
||||
vieille.finMinute = fin - 36 * 86400;
|
||||
vieille.siteW = 100;
|
||||
relance.observerMinute(vieille);
|
||||
const int c = relance.profilsCalcules();
|
||||
relance.recalculer(maintenant);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(relance.profilsCalcules() > c, 20000);
|
||||
}
|
||||
QCOMPARE(lire(QStringLiteral("SELECT COUNT(*) FROM baseloadMinute WHERE at <= %1").arg(fin - 35 * 86400))
|
||||
.value(0).toInt(), 0);
|
||||
|
||||
// FICHIER QUI N'EST PAS UNE BASE : hors service, et l'appelant continue.
|
||||
const QString faux = dir.filePath(QStringLiteral("pas-une-base.sqlite"));
|
||||
{
|
||||
QFile f(faux);
|
||||
QVERIFY(f.open(QIODevice::WriteOnly));
|
||||
f.write(QByteArray(4096, 'x'));
|
||||
}
|
||||
BaseloadLog horsService(faux, paris);
|
||||
QTRY_VERIFY(horsService.profilsCalcules() >= 0 && !horsService.isValid());
|
||||
horsService.observerMinute(huitJours.first());
|
||||
QVERIFY(!horsService.profil().calcule);
|
||||
}
|
||||
|
||||
@ -122,6 +122,16 @@ private slots:
|
||||
//! Un créneau à cheval sur deux zones reçoit la moyenne pondérée par la durée.
|
||||
void testAStraddlingSlotGetsTheTimeWeightedMean();
|
||||
|
||||
// ── Brief 3 T4 — consommation de base ──────────────────────────────────
|
||||
//! La minute retranche chaque charge selon sa source ; commande inconnue → minute écartée.
|
||||
void testTheBaseloadMinuteSubtractsEachLoadBySource();
|
||||
//! Profil = médiane par créneau local et type de jour sur 28 jours ; qualité dite ; 23 h / 25 h.
|
||||
void testTheBaseloadProfileIsTheMedianPerSlotAndDayType();
|
||||
//! Sous 7 jours couverts (à 90 %) : `none`, et rien d'inventé.
|
||||
void testTheBaseloadIsNoneUnderSevenDays();
|
||||
//! Tables dédiées, écriture asynchrone, recalcul, rétention — sans toucher forecastLearning.
|
||||
void testTheBaseloadLogWritesWithoutTouchingTheOtherTables();
|
||||
|
||||
};
|
||||
|
||||
#endif // TESTLOADMODEL_H
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user