// 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 #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é. //! Version du schéma. 2 : la clé est le CONTENU, plus le run — `issuedAt` ne l'identifie pas. constexpr int SchemaVersion = 2; //! 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; //! Version de la charge utile de TRANSPORT qu'on sait lire. Une autre est REFUSÉE, jamais //! interprétée au mieux — cf. \c ForecastStore::depuisTransport(). constexpr int TransportVersion = 1; } 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"; } ForecastRevision ForecastStore::depuisTransport(const QVariantMap &charge, QString &refus) { ForecastRevision r; refus.clear(); if (charge.isEmpty()) { refus = QStringLiteral("EMPTY"); return r; } // LA VERSION D'ABORD, et le refus plutôt que l'interprétation au mieux. Une charge utile qui // traverse une frontière de processus n'a plus de schéma : personne ne vérifie sa forme, et // une lecture « au mieux » d'une version inconnue produirait une courbe fausse qui aurait // l'air de marcher. if (charge.value(QStringLiteral("v")).toInt() != TransportVersion) { refus = QStringLiteral("VERSION_%1").arg(charge.value(QStringLiteral("v")).toString()); return r; } // L'unité et la convention sont LUES. Les supposer, c'est hériter d'un fait vrai sous des // conditions qu'on ne saura pas reproduire le jour où elles changeront. const QString unite = charge.value(QStringLiteral("unit")).toString(); if (unite != QLatin1String("W")) { refus = QStringLiteral("UNIT_%1").arg(unite); return r; } const qint64 t0 = charge.value(QStringLiteral("t0")).toLongLong(); const int step = charge.value(QStringLiteral("step")).toInt(); const qint64 run = charge.value(QStringLiteral("issuedAt")).toLongLong(); const QVariantList p = charge.value(QStringLiteral("p")).toList(); if (t0 <= 0 || step <= 0 || p.isEmpty()) { refus = QStringLiteral("MALFORMED"); return r; } // `issuedAt == 0` SIGNIFIE « ÂGE INCONNU » — et ce n'est PAS un refus. // // Il l'a été, tant que ce champ servait de clé. Depuis qu'il est mesuré qu'il n'identifie // pas le contenu, il n'est plus qu'un ATTRIBUT : refuser une courbe exploitable parce qu'une // métadonnée manque reviendrait à jeter la donnée pour son étiquette. Le repli `best_match` // le met à zéro pour TOUTES ses courbes, qui sont pourtant parfaitement utilisables. // // ET AUCUN INSTANT NE LUI EST SUBSTITUÉ : ni la réception, ni t0. Un âge inconnu se dit par // une ABSENCE, jamais par une valeur plausible qu'on ne pourrait plus distinguer d'une vraie. if (run > 0) r.issuedAt = QDateTime::fromSecsSinceEpoch(run, QTimeZone::utc()); r.model = charge.value(QStringLiteral("model")).toString(); for (int i = 0; i < p.count(); ++i) { bool ok = false; const double w = p.at(i).toDouble(&ok); // UN CRÉNEAU ILLISIBLE EST UN TROU, pas un zéro. Il ne crée simplement pas de point : // la courbe en portera l'absence, et l'écran devra l'afficher comme telle. if (!ok) continue; ForecastPoint pt; // `convention` : p[i] est la MOYENNE sur [t0 + i·step, t0 + (i+1)·step). L'énergie du // créneau s'en déduit sans supposer que le pas vaut une heure — et le jour où il vaudra // un quart d'heure, rien ici ne changera. pt.targetAt = QDateTime::fromSecsSinceEpoch(t0 + qint64(i) * step, QTimeZone::utc()); pt.wh = w * step / 3600.0; r.points.append(pt); } if (r.points.isEmpty()) refus = QStringLiteral("NO_POINT"); return r; } 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 LE CONTENU. Elle l'a d'abord été le run — jusqu'à ce que le producteur // établisse, mesures à l'appui, que `issuedAt` n'identifie pas la courbe. La règle, elle, // n'a pas bougé : « une révision n'existe que si la PRÉVISION a changé ». Regarder la // prévision elle-même y est plus fidèle que de regarder une étiquette d'émission. // // 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. // MIGRATION v1 → v2 : la TABLE EST REFAITE, pas convertie. // // Les lignes de la v1 sont clés sur un run qui n'identifie pas leur contenu, et elles ont pu // être écrasées par des rafraîchissements successifs (constaté sur `.75` le 2026-09-09). On // ne peut donc pas savoir quel contenu chacune porte réellement. Les convertir leur donnerait // une empreinte calculée sur des valeurs dont on ignore l'origine — une provenance inventée. // // C'est la règle déjà posée par le producteur, et elle vaut ici : une révision manquante est // une lacune VISIBLE ; une révision mal datée est un mensonge INVISIBLE. La table se // reconstruit en un cycle. if (q.exec(QStringLiteral("PRAGMA user_version")) && q.next() && q.value(0).toInt() > 0 && q.value(0).toInt() < SchemaVersion) { qCWarning(dcNymeaEnergy()) << "[Prévision] schéma v" << q.value(0).toInt() << "→ v" << SchemaVersion << ": table REFAITE, l'historique v1 ne peut pas être daté" " par contenu et n'est pas converti"; q.exec(QStringLiteral("DROP TABLE IF EXISTS forecastPv")); } const bool ok = q.exec(QStringLiteral( "CREATE TABLE IF NOT EXISTS forecastPv (" " targetAt INTEGER NOT NULL," // LE SITE dont la courbe est la somme. Sans lui, changer de site désigné mêlerait // deux historiques que rien ne distinguerait plus. " siteId TEXT NOT NULL," // L'EMPREINTE DE LA COURBE ENTIÈRE — LA CLÉ. `issuedAt` ne peut pas la porter : il // vient d'un domaine voisin qui ne fournit AUCUNE des valeurs de la courbe, et le // serveur recompose les premières heures sans le faire bouger (mesuré 2026-09-09). " contentId TEXT NOT NULL," // QUAND CE CONTENU EST APPARU — l'ordre des révisions, faute d'identité d'émission. // // EN MILLISECONDES, à la différence de targetAt qui est une frontière d'heure. Deux // révisions apparues dans la MÊME SECONDE seraient sinon inordonnables, et la // « dernière connue » en rendrait deux : un instant d'observation doit distinguer // deux observations, sinon il n'ordonne rien. " firstSeenAt INTEGER NOT NULL," // DERNIÈRE REPUBLICATION À L'IDENTIQUE — la fraîcheur, mise à jour en place. " lastSeenAt INTEGER NOT NULL," " forecastWh REAL NOT NULL," // ATTRIBUTS, jamais la clé. `issuedAt` peut être NUL (repli best_match). " issuedAt INTEGER," " model TEXT," " PRIMARY KEY (targetAt, siteId, contentId))")) && q.exec(QStringLiteral( "CREATE INDEX IF NOT EXISTS idxForecastSeen ON forecastPv(firstSeenAt)")) && 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. L'absence est un TROU, et l'écrire en courbe de // zéros se lirait « on prévoit une nuit ». // // `issuedAt` invalide N'EST PLUS un motif de refus : il est devenu un ATTRIBUT. Jeter une // prévision utilisable parce qu'une métadonnée manque reviendrait à jeter la donnée pour son // étiquette — et le repli `best_match`, qui la met à zéro, produit des courbes parfaitement // exploitables. if (revision.points.isEmpty()) { qCDebug(dcNymeaEnergy()) << "[Prévision] révision vide — ignorée"; return false; } // --- L'EMPREINTE DU CONTENU, et c'est la clé -------------------------------------------- // // Calculée sur la courbe elle-même — instants et valeurs — donc indépendante de tout ce que // le producteur raconte sur son origine. Deux charges utiles différentes portant la même // courbe donnent la même empreinte, ce qu'aucune comparaison de charge utile ne peut voir. QCryptographicHash h(QCryptographicHash::Sha1); for (const ForecastPoint &p : revision.points) h.addData(QStringLiteral("%1:%2;") .arg(p.targetAt.toUTC().toSecsSinceEpoch()) .arg(p.wh, 0, 'f', 3).toUtf8()); const QString empreinte = QString::fromLatin1(h.result().toHex()); const qint64 vu = QDateTime::currentMSecsSinceEpoch(); // ms : cf. le schéma // --- MÊME CONTENU : on ne crée rien, on rafraîchit --------------------------------------- QSqlQuery deja(m_db); deja.prepare(QStringLiteral( "SELECT COUNT(*) FROM forecastPv WHERE contentId = ? AND siteId = ?")); deja.addBindValue(empreinte); deja.addBindValue(revision.siteId); if (deja.exec() && deja.next() && deja.value(0).toInt() > 0) { QSqlQuery maj(m_db); maj.prepare(QStringLiteral( "UPDATE forecastPv SET lastSeenAt = ? " "WHERE contentId = ? AND siteId = ? AND lastSeenAt < ?")); maj.addBindValue(vu); maj.addBindValue(empreinte); maj.addBindValue(revision.siteId); maj.addBindValue(vu); if (!maj.exec()) qCWarning(dcNymeaEnergy()) << "[Prévision] rafraîchissement refusé :" << maj.lastError().text(); qCDebug(dcNymeaEnergy()) << "[Prévision] courbe inchangée — fraîcheur mise à jour"; return false; } // --- CONTENU NEUF : une révision, quoi qu'ait fait `issuedAt` ---------------------------- // // Y COMPRIS SOUS UN `issuedAt` INCHANGÉ. Le serveur recompose les premières heures sans // toucher au run : refuser ces écritures GÈLERAIT la courbe sur les valeurs du premier // cycle, et l'écran montrerait une prévision périmée sans le dire — un instrument qui // continue d'indiquer une ancienne mesure, ce qui est pire que de ne rien montrer. 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, siteId, contentId, firstSeenAt, lastSeenAt, forecastWh, issuedAt, model) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?)")); const bool ageConnu = revision.issuedAt.isValid(); for (const ForecastPoint &p : revision.points) { ins.addBindValue(p.targetAt.toUTC().toSecsSinceEpoch()); ins.addBindValue(revision.siteId); ins.addBindValue(empreinte); ins.addBindValue(vu); ins.addBindValue(vu); // à l'apparition, les deux instants coïncident ins.addBindValue(p.wh); // NUL plutôt qu'un zéro : « on ne connaît pas l'âge » n'est pas « âge zéro ». ins.addBindValue(ageConnu ? QVariant(revision.issuedAt.toUTC().toSecsSinceEpoch()) : QVariant(QMetaType(QMetaType::LongLong))); 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 %1 — %2 créneau(x), modèle %3, âge %4") .arg(empreinte.left(8)) .arg(revision.points.count()) .arg(revision.model.isEmpty() ? QStringLiteral("(inconnu)") : revision.model) .arg(ageConnu ? revision.issuedAt.toUTC().toString(Qt::ISODate) : QStringLiteral("(inconnu)")); 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(); // COURANTE = LA DERNIÈRE VUE, pas la dernière APPARUE. La nuance a mordu le 2026-09-09 : // un plan de test ajouté à 08:07 puis retiré à 08:11 a fait réapparaître un contenu DÉJÀ // connu (celui d'avant l'ajout). Son `firstSeenAt` n'a pas bougé — c'est le principe même du // champ — si bien qu'un tri sur `MAX(firstSeenAt)` continuait de servir la courbe de // l'installation FICTIVE, quatre minutes après sa disparition. // // Ce qui est courant, c'est ce qu'on a vu EN DERNIER. `firstSeenAt` garde son rôle : il dit // quand un contenu est apparu, pas s'il a cours. // // LIMITE CONNUE, et écrite plutôt que découverte : `lastSeenAt` est mis à jour EN PLACE, // donc il ne porte que sa valeur FINALE. Une lecture historique (`connuA` dans le passé) qui // traverse un RETOUR à un contenu antérieur rend donc une réponse approchée. L'exactitude // demanderait une table d'ÉPISODES d'observation — un schéma de plus, différé tant que ce // mécanisme n'a qu'une journée derrière lui. QSqlQuery q(m_db); q.prepare(QStringLiteral( "SELECT targetAt, forecastWh, firstSeenAt, lastSeenAt FROM forecastPv f " "WHERE targetAt BETWEEN ? AND ? AND firstSeenAt <= ? " " AND lastSeenAt = (SELECT MAX(lastSeenAt) FROM forecastPv g " " WHERE g.targetAt = f.targetAt AND g.firstSeenAt <= ?) " "ORDER BY targetAt")); const qint64 limite = borne.toUTC().toMSecsSinceEpoch(); // firstSeenAt est en ms 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(); // LES DEUX INSTANTS, publiés ensemble. La FRAÎCHEUR est lastSeenAt ; l'ancienneté // de l'avis est firstSeenAt. N'en rendre qu'un ferait lire « prévu à 06Z » sur une // ligne que le run 12Z vient de reconduire. p.firstSeenAt = QDateTime::fromMSecsSinceEpoch(q.value(2).toLongLong(), QTimeZone::utc()); p.lastSeenAt = QDateTime::fromMSecsSinceEpoch(q.value(3).toLongLong(), QTimeZone::utc()); 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 firstSeenAt, forecastWh FROM forecastPv WHERE targetAt = ? ORDER BY firstSeenAt")); q.addBindValue(targetAt.toUTC().toSecsSinceEpoch()); if (!q.exec()) return sortie; while (q.next()) sortie.append({ QDateTime::fromMSecsSinceEpoch(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 firstSeenAt NOT IN (" " SELECT MIN(firstSeenAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt" " UNION" " SELECT MAX(firstSeenAt) 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; }