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