From 66c81086a9764ccaa976a4ccbfd9372455d401a7 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 14 Oct 2026 09:51:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(base):=20BaseloadLog=20=E2=80=94=20minutes?= =?UTF-8?q?=20du=20bilan,=20profil=20m=C3=A9dian,=20qualit=C3=A9=20dite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- energyplugin/energyplugin.pri | 2 + energyplugin/etm/forecast/baseloadlog.cpp | 556 ++++++++++++++++++++++ energyplugin/etm/forecast/baseloadlog.h | 204 ++++++++ tests/auto/loadmodel/testloadmodel.cpp | 293 ++++++++++++ tests/auto/loadmodel/testloadmodel.h | 10 + 5 files changed, 1065 insertions(+) create mode 100644 energyplugin/etm/forecast/baseloadlog.cpp create mode 100644 energyplugin/etm/forecast/baseloadlog.h diff --git a/energyplugin/energyplugin.pri b/energyplugin/energyplugin.pri index d529a06..75ff94a 100644 --- a/energyplugin/energyplugin.pri +++ b/energyplugin/energyplugin.pri @@ -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 \ diff --git a/energyplugin/etm/forecast/baseloadlog.cpp b/energyplugin/etm/forecast/baseloadlog.cpp new file mode 100644 index 0000000..8906025 --- /dev/null +++ b/energyplugin/etm/forecast/baseloadlog.cpp @@ -0,0 +1,556 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#include "baseloadlog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +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 mediane(QList 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 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 &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>(cases); + p.medianes[1] = QVector>(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, Cumul> parCase; + QMap parJour; + struct ParCharge { QString derniereSource; qint64 derniere = 0; QHash parSource; + double min = 0, max = 0; int mesurees = 0; }; + QMap 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 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> valeurs[2] = {QVector>(cases), QVector>(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> BaseloadProfile::serie(const QDateTime &start, int stepS, int steps, + const QTimeZone &fuseau) const +{ + QList> 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> &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 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 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 lire(qint64 debut, qint64 fin) + { + QList sortie; + QHash 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 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; +} diff --git a/energyplugin/etm/forecast/baseloadlog.h b/energyplugin/etm/forecast/baseloadlog.h new file mode 100644 index 0000000..bc7dbc2 --- /dev/null +++ b/energyplugin/etm/forecast/baseloadlog.h @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +class QThread; +class BaseloadWriter; + +/*! + * \file baseloadlog.h + * \brief La CONSOMMATION DE BASE — brief 3, T4 (\c DESIGN_PREVISION_PV.md §32). + * + * \par Ce qui est mesuré + * Par minute : la consommation du site lue dans l'échantillon d'UNE MINUTE du bilan nymea + * (`import − export + production − stockage`), moins chaque charge pilotée selon SA source de + * mesure (\c measurement.source de la télémétrie). Une charge sans mesure (\c none) est comptée + * à sa puissance COMMANDÉE — et chaque minute le dit. **Jamais les cumuls du bilan** + * (\c totalConsumption…), incohérents sur le banc. + * + * \par Ce qui est publié + * Un profil : médiane, par créneau de la journée LOCALE et par type de jour (ouvré / week-end), + * des moyennes de créneau sur 28 jours. Sous 7 jours couverts : \c none, et rien d'inventé. + * + * \par Aucun effet sur l'arbitrage (§11) + * Rien de ce module n'entre dans le contexte ni dans le plan : il reçoit des copies, et ce qu'il + * calcule n'est lu que par \c GetForecastSnapshot. + */ + +//! \brief Une charge pilotée, à une minute : sa source et la puissance retenue. +struct BaseloadLoadPart { + QString loadId; //!< Identifiant de la charge (\c GetLoadConfig / ThingId d'une borne). + QString source; //!< \c meter | \c device | \c none (même règle que la télémétrie). + //! W retenus : MESURÉS sous \c meter/\c device, COMMANDÉS sous \c none ; absent si la commande + //! est inconnue — la minute est alors incomplète, jamais comptée à 0. + std::optional powerW; +}; + +//! \brief Une minute du bilan, et les charges pilotées à cet instant. +struct BaseloadMinute { + qint64 finMinute = 0; //!< Horodatage de l'échantillon = FIN de la minute (epoch s UTC). + double siteW = 0; //!< Consommation du site, W moyens sur la minute. + QList charges; //!< Toutes les charges pilotées à cette minute, ordre stable. + /*! + * \brief La consommation de base de la minute. + * \return `siteW − Σ charges` ; absent si une charge n'a pas de puissance connue. + * \note Une valeur NÉGATIVE est rendue telle quelle : elle dit que le compteur racine voit + * moins que la somme des charges (câblage, compteur aveugle, compteur de charge factice) — + * l'écrêter à 0 cacherait exactement ce que la qualité doit montrer. + */ + std::optional baseW() const; + //! \brief Nombre de charges comptées à leur COMMANDE (\c none). \return Le compte. + int chargesCommandees() const; +}; + +//! \brief Ce qu'on sait d'une charge sur la fenêtre — pour que la qualité le DISE. +struct BaseloadLoadQuality { + QString loadId; //!< Identifiant de la charge. + QString source; //!< Source à la DERNIÈRE minute de la fenêtre. + QHash minutesParSource; //!< meter / device / none → minutes. + /*! + * \brief La mesure n'a pas varié d'un watt sur la fenêtre (≥ 60 minutes mesurées). + * Absent si trop peu de minutes mesurées pour le dire. + * \note Ne tranche pas entre « compteur à valeur fixe » (constante déguisée — le cas du banc) + * et « charge jamais en marche » : il signale, l'installateur juge. + */ + std::optional constante; +}; + +/*! + * \brief Le profil de consommation de base, tel que calculé — et sa qualité. + */ +struct BaseloadProfile { + static constexpr int FenetreJours = 28; //!< Fenêtre du profil. + static constexpr int JoursMin = 7; //!< Sous ce nombre de jours couverts : \c none. + static constexpr double CouvertureMin = 0.9; //!< Part de minutes pour qu'un jour / créneau compte. + static constexpr int ValeursMinParCase = 2; //!< Une médiane d'UNE valeur n'en est pas une. + static constexpr double ConstanteW = 1.0; //!< Écart max/min sous lequel une mesure est « constante ». + static constexpr int MinutesMesureesMin = 60; //!< Pour se prononcer sur « constante ». + + bool calcule = false; //!< Faux tant qu'aucun calcul n'a eu lieu (démarrage). + QDateTime calculeA; //!< Instant du calcul (fin de la fenêtre). + int resolutionS = 0; //!< Largeur d'un créneau du profil. + QString model; //!< \c profile ou \c none. + //! [0] jours ouvrés, [1] week-end : une médiane par créneau local, absente si trop peu de valeurs. + QVector> medianes[2]; + + // --- La qualité ------------------------------------------------------------------------ + int joursOuvres = 0; //!< Jours locaux ouvrés couverts à ≥ 90 %. + int joursWeekEnd = 0; //!< Jours locaux de week-end couverts à ≥ 90 %. + int minutes = 0; //!< Minutes de la fenêtre dont la base est connue. + int minutesEstimees = 0; //!< … dont au moins une charge était comptée à sa COMMANDE. + int minutesNegatives = 0; //!< … dont la base calculée est < 0. + int minutesIncompletes = 0; //!< Minutes ÉCARTÉES : une commande inconnue. + QList charges; //!< Une entrée par charge vue dans la fenêtre, triée. + + /*! + * \brief Le profil sur une grille d'instants absolus. + * \param start Début du premier créneau. \param stepS Pas de la grille. \param steps Créneaux. + * \param fuseau Fuseau qui fait foi pour l'heure murale et le type de jour. + * \return Une valeur par créneau, absente si inconnue ; tout absent sous \c none ou si la + * grille n'a pas la résolution du profil. + * \note Le créneau prend la case de l'heure murale de son DÉBUT : au jour de 25 h, les deux + * « 02:00 » reçoivent la même médiane ; au jour de 23 h, la case 02:00 n'est jamais lue. + */ + QList> serie(const QDateTime &start, int stepS, int steps, + const QTimeZone &fuseau) const; + + //! \brief La qualité, en codes et nombres — jamais une phrase. \return La carte publiée. + QVariantMap qualiteVariant() const; +}; + +/*! + * \brief Enregistre les minutes de consommation de base et en tient le profil — sans jamais + * bloquer le cycle ni un appel RPC. + * + * \par Deux tables dédiées, dans \c energy-forecast.sqlite + * \c baseloadMinute (une ligne par minute) et \c baseloadMinuteLoad (une par minute et par + * charge). Aucune autre table du fichier n'est touchée : \c forecastPv et \c forecastLearning + * gardent leur forme et leurs lignes. + * + * \par Asynchrone + * \c observerMinute() copie et poste ; le fil d'écriture (sa propre connexion) insère, purge, + * et recalcule le profil au plus tous les quarts d'heure. \c profil() rend la DERNIÈRE copie + * calculée, sous verrou bref, sans requête. + */ +class BaseloadLog : public QObject +{ + Q_OBJECT +public: + //! Rétention des minutes, en jours : la fenêtre du profil, plus une semaine de marge. + static constexpr int RetentionJours = 35; + //! Recalcul au plus tous les … secondes de temps de MINUTE (pas d'horloge murale). + static constexpr int RecalculS = 900; + //! Résolution par défaut du profil : celle de la grille du producteur aujourd'hui. + static constexpr int ResolutionParDefautS = 3600; + + /*! + * \brief Ouvre les tables, sur un fil d'écriture dédié, et calcule un premier profil. + * \param cheminFichier Le MÊME fichier que \c ForecastStore. + * \param fuseau Fuseau qui fait foi ; invalide = fuseau du système, relu à chaque calcul. + * \param parent Parent Qt. + */ + explicit BaseloadLog(const QString &cheminFichier, const QTimeZone &fuseau = QTimeZone(), + QObject *parent = nullptr); + //! \brief Vide les écritures en attente, puis ferme la base sur son fil. + ~BaseloadLog() override; + + /*! + * \brief Une minute de plus. Ne bloque jamais : copie, puis poste au fil d'écriture. + * \param minute La minute, charges comprises. + */ + void observerMinute(const BaseloadMinute &minute); + + /*! + * \brief Demande un recalcul du profil (fin de fenêtre \p now). Asynchrone. + * \param now Fin de la fenêtre. \param resolutionS Largeur des créneaux ; 0 = inchangée. + */ + void recalculer(const QDateTime &now, int resolutionS = 0); + + //! \brief Le dernier profil calculé. \return Une copie ; \c calcule faux avant le premier calcul. + BaseloadProfile profil() const; + + /*! + * \brief Le calcul, PUR — sans base, testable seul. + * \param minutes Les minutes disponibles (ordre indifférent). + * \param now Fin de la fenêtre ; seules les minutes de `(now − 28 j, now]` comptent. + * \param fuseau Fuseau qui fait foi. + * \param resolutionS Largeur d'un créneau, en secondes (diviseur de 86 400). + * \return Le profil et sa qualité. + */ + static BaseloadProfile calculerProfil(const QList &minutes, const QDateTime &now, + const QTimeZone &fuseau, int resolutionS); + + //! \brief Minutes insérées depuis l'ouverture. \return Compteur, lu sans verrou. + int minutesEcrites() const { return m_ecrites.loadAcquire(); } + //! \brief Profils calculés depuis l'ouverture. \return Compteur, lu sans verrou. + int profilsCalcules() const { return m_calcules.loadAcquire(); } + //! \brief Les tables sont-elles utilisables ? \return Faux si le fichier n'est pas une base. + bool isValid() const { return m_valide.loadAcquire() != 0; } + +private: + friend class BaseloadWriter; + void publierProfil(const BaseloadProfile &p); + + QThread *m_fil = nullptr; + BaseloadWriter *m_ecrivain = nullptr; + mutable QMutex m_verrou; + BaseloadProfile m_profil; + QAtomicInt m_ecrites; + QAtomicInt m_calcules; + QAtomicInt m_valide; +}; diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index 9200da3..471b5d7 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -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 #include @@ -2168,3 +2169,295 @@ void TestLoadModel::testAStraddlingSlotGetsTheTimeWeightedMean() QCOMPARE(quarts, (QList{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 minutesSynthetiques(qint64 debut, qint64 fin, const QTimeZone &fuseau) +{ + QList 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 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 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(true)); // 800 W fixes : le banc + QCOMPARE(q.value("borne").constante, std::optional(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> 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> 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 &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 &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 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 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); +} diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index d720f94..b842ec1 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -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