// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #include "forecaststore.h" #include #include #include #include #include #include #include #include Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy) namespace { //! Version du schéma. À NOUS, pas à l'amont — c'est tout l'intérêt d'un fichier séparé. constexpr int SchemaVersion = 1; //! Fenêtre de révisions PLEINES, en jours. Au-delà, on n'garde que les deux bouts. constexpr int RetentionPleineJours = 7; //! Tolérance de comparaison entre deux valeurs d'énergie (Wh). Deux flottants issus du même //! calcul sont égaux au bit près ; ce seuil couvre un ré-encodage JSON, pas une révision. constexpr double EgaliteWh = 0.001; } QString ForecastStore::cheminParDefaut() { const QString env = QString(qgetenv("NYMEA_ENERGY_FORECAST_DB")); if (!env.isEmpty()) return env; // À CÔTÉ de energy-load-configuration.json, et surtout PAS dans energylogs.sqlite : cette // base appartient à l'amont, qui en migre le schéma — et la clé y est un instant de mesure, // quand la nôtre est une paire. Une prévision révisée n'a rien à faire dans la base du mesuré. return NymeaSettings::storagePath() + "/energy-forecast.sqlite"; } ForecastStore::ForecastStore(const QString &cheminFichier, QObject *parent) : QObject(parent) { QDir().mkpath(QFileInfo(cheminFichier).absolutePath()); // Connexion NOMMÉE et unique : deux ForecastStore dans le même processus (production + // test) partageraient sinon la connexion par défaut, et le second fermerait le premier. m_connexion = QStringLiteral("etm-forecast-%1") .arg(QUuid::createUuid().toString(QUuid::WithoutBraces)); m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connexion); m_db.setDatabaseName(cheminFichier); if (!m_db.open()) { qCWarning(dcNymeaEnergy()) << "[Prévision] base illisible" << cheminFichier << ":" << m_db.lastError().text(); return; } m_valide = migrer(); } ForecastStore::~ForecastStore() { if (m_db.isOpen()) m_db.close(); m_db = QSqlDatabase(); QSqlDatabase::removeDatabase(m_connexion); } bool ForecastStore::migrer() { QSqlQuery q(m_db); // LA CLÉ EST LA PAIRE, et c'est tout le lot. Avec targetAt seul, chaque rafraîchissement // écraserait le précédent — on perdrait les révisions, donc la comparaison prévu/réalisé. // // Les deux instants sont des EPOCHS UTC (secondes). Jamais une chaîne d'heure locale : un // « 02:00 » local existe deux fois en octobre et pas du tout en mars. const bool ok = q.exec(QStringLiteral( "CREATE TABLE IF NOT EXISTS forecastPv (" " targetAt INTEGER NOT NULL," " receivedAt INTEGER NOT NULL," " forecastWh REAL NOT NULL," " model TEXT," " PRIMARY KEY (targetAt, receivedAt))")) && q.exec(QStringLiteral( "CREATE INDEX IF NOT EXISTS idxForecastReceived ON forecastPv(receivedAt)")) && q.exec(QStringLiteral("PRAGMA user_version = %1").arg(SchemaVersion)); if (!ok) qCWarning(dcNymeaEnergy()) << "[Prévision] migration échouée :" << q.lastError().text(); return ok; } bool ForecastStore::enregistrer(const ForecastRevision &revision) { if (!m_valide) return false; // UNE RÉVISION VIDE N'EST PAS UNE RÉVISION. Pas de réseau, API en erreur, plan absent : // l'absence est un TROU, et l'écrire en courbe de zéros se lirait « on prévoit une nuit ». if (revision.points.isEmpty() || !revision.receivedAt.isValid()) { qCDebug(dcNymeaEnergy()) << "[Prévision] révision vide ou sans instant — non enregistrée"; return false; } // --- DÉDUPLICATION SUR L'INTERSECTION ------------------------------------------------- // // Deux révisions sont la MÊME si, sur leurs heures COMMUNES, toutes les valeurs coïncident. // Comparer les charges utiles entières serait faux dès que la fenêtre glisse : la courbe de // 11 h a un point de moins que celle de 10 h, et on verrait une révision toutes les heures — // exactement ce que la déduplication existe pour éviter. QSqlQuery derniere(m_db); derniere.prepare(QStringLiteral( "SELECT MAX(receivedAt) FROM forecastPv WHERE receivedAt < ?")); derniere.addBindValue(revision.receivedAt.toSecsSinceEpoch() + 1); if (derniere.exec() && derniere.next() && !derniere.value(0).isNull()) { const qint64 refRecu = derniere.value(0).toLongLong(); QSqlQuery ref(m_db); ref.prepare(QStringLiteral( "SELECT targetAt, forecastWh FROM forecastPv WHERE receivedAt = ?")); ref.addBindValue(refRecu); QHash precedent; if (ref.exec()) while (ref.next()) precedent.insert(ref.value(0).toLongLong(), ref.value(1).toDouble()); int communs = 0; bool identique = true; for (const ForecastPoint &p : revision.points) { const auto it = precedent.constFind(p.targetAt.toSecsSinceEpoch()); if (it == precedent.constEnd()) continue; // hors intersection : ne départage rien ++communs; if (qAbs(it.value() - p.wh) > EgaliteWh) { identique = false; break; } } // `communs > 0` est REQUIS : sans heure commune, il n'y a rien à comparer, et conclure // « identique » ferait disparaître une révision dont on ne sait rien. Le cas se produit // au passage de minuit, quand la fenêtre a entièrement tourné. if (identique && communs > 0) { qCDebug(dcNymeaEnergy()).noquote() << QStringLiteral("[Prévision] même courbe sur %1 heure(s) commune(s) — " "pas une révision").arg(communs); return false; } } // --- écriture, en une transaction ------------------------------------------------------ if (!m_db.transaction()) qCWarning(dcNymeaEnergy()) << "[Prévision] transaction refusée :" << m_db.lastError().text(); QSqlQuery ins(m_db); ins.prepare(QStringLiteral( "INSERT OR REPLACE INTO forecastPv (targetAt, receivedAt, forecastWh, model) " "VALUES (?, ?, ?, ?)")); const qint64 recu = revision.receivedAt.toUTC().toSecsSinceEpoch(); for (const ForecastPoint &p : revision.points) { ins.addBindValue(p.targetAt.toUTC().toSecsSinceEpoch()); ins.addBindValue(recu); ins.addBindValue(p.wh); // MODEL À NULL quand le producteur publie une INTENTION et non une provenance. Une // chaîne vide serait déjà une valeur ; NULL dit « on ne sait pas », ce qui est vrai. ins.addBindValue(revision.model.isEmpty() ? QVariant(QMetaType(QMetaType::QString)) : QVariant(revision.model)); if (!ins.exec()) { qCWarning(dcNymeaEnergy()) << "[Prévision] écriture refusée :" << ins.lastError().text(); m_db.rollback(); return false; } } if (!m_db.commit()) { qCWarning(dcNymeaEnergy()) << "[Prévision] commit refusé :" << m_db.lastError().text(); return false; } qCInfo(dcNymeaEnergy()).noquote() << QStringLiteral("[Prévision] révision enregistrée — %1 point(s), reçue %2") .arg(revision.points.count()) .arg(revision.receivedAt.toUTC().toString(Qt::ISODate)); return true; } QList ForecastStore::courbe(const QDateTime &de, const QDateTime &a, const QDateTime &connuA) const { QList points; if (!m_valide) return points; const QDateTime borne = connuA.isValid() ? connuA : QDateTime::currentDateTimeUtc(); // Pour chaque heure visée, la DERNIÈRE révision connue à l'instant demandé. `connuA` rend la // lecture reproductible : « ce qu'on prévoyait à 8 h » se demande en le fixant à 8 h, et // deux lectures du même passé donnent le même résultat. QSqlQuery q(m_db); q.prepare(QStringLiteral( "SELECT targetAt, forecastWh FROM forecastPv f " "WHERE targetAt BETWEEN ? AND ? AND receivedAt <= ? " " AND receivedAt = (SELECT MAX(receivedAt) FROM forecastPv g " " WHERE g.targetAt = f.targetAt AND g.receivedAt <= ?) " "ORDER BY targetAt")); const qint64 limite = borne.toUTC().toSecsSinceEpoch(); q.addBindValue(de.toUTC().toSecsSinceEpoch()); q.addBindValue(a.toUTC().toSecsSinceEpoch()); q.addBindValue(limite); q.addBindValue(limite); if (!q.exec()) { qCWarning(dcNymeaEnergy()) << "[Prévision] lecture refusée :" << q.lastError().text(); return points; } // Les heures sans prévision sont ABSENTES de la liste. On ne comble pas : un trou dans une // courbe demande un effort de rendu, un zéro n'en demande aucun — et c'est pour ça qu'on // écrit des zéros. Ici, non. while (q.next()) { ForecastPoint p; p.targetAt = QDateTime::fromSecsSinceEpoch(q.value(0).toLongLong(), QTimeZone::utc()); p.wh = q.value(1).toDouble(); points.append(p); } return points; } QList> ForecastStore::revisions(const QDateTime &targetAt) const { QList> sortie; if (!m_valide) return sortie; QSqlQuery q(m_db); q.prepare(QStringLiteral( "SELECT receivedAt, forecastWh FROM forecastPv WHERE targetAt = ? ORDER BY receivedAt")); q.addBindValue(targetAt.toUTC().toSecsSinceEpoch()); if (!q.exec()) return sortie; while (q.next()) sortie.append({ QDateTime::fromSecsSinceEpoch(q.value(0).toLongLong(), QTimeZone::utc()), q.value(1).toDouble() }); return sortie; } int ForecastStore::purger(const QDateTime &maintenant) { if (!m_valide) return 0; const QDateTime ref = maintenant.isValid() ? maintenant : QDateTime::currentDateTimeUtc(); const qint64 limite = ref.toUTC().addDays(-RetentionPleineJours).toSecsSinceEpoch(); // AU-DELÀ DE LA FENÊTRE PLEINE : on ne garde que les DEUX BOUTS par heure visée — la // première révision (horizon le plus long) et la dernière (meilleure estimation). // // Les deux, et pas une. Elles répondent aux deux seules questions qui se posent : « la // veille valait-elle quelque chose ? » et « que pouvait-on savoir de mieux ? ». N'en garder // qu'une détruirait la révision, c'est-à-dire l'objet de cette table. QSqlQuery q(m_db); q.prepare(QStringLiteral( "DELETE FROM forecastPv WHERE targetAt < ? AND receivedAt NOT IN (" " SELECT MIN(receivedAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt" " UNION" " SELECT MAX(receivedAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt)")); q.addBindValue(limite); if (!q.exec()) { qCWarning(dcNymeaEnergy()) << "[Prévision] purge refusée :" << q.lastError().text(); return 0; } const int n = q.numRowsAffected(); if (n > 0) qCInfo(dcNymeaEnergy()) << "[Prévision] purge :" << n << "révision(s) intermédiaire(s) retirée(s) au-delà de" << RetentionPleineJours << "jours"; return n; }