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:
Patrick Schurig 2026-10-14 09:51:23 +02:00
parent e3816ee031
commit 66c81086a9
5 changed files with 1065 additions and 0 deletions

View File

@ -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 \

View 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;
}

View 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;
};

View File

@ -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);
}

View File

@ -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