diff --git a/energyplugin/energyplugin.pri b/energyplugin/energyplugin.pri index e3f265d..24de5be 100644 --- a/energyplugin/energyplugin.pri +++ b/energyplugin/energyplugin.pri @@ -35,6 +35,7 @@ HEADERS += \ $$PWD/nymeaenergyjsonhandler.h \ $$PWD/rootmeter.h \ $$PWD/smartchargingmanager.h \ + $$PWD/etm/forecast/forecaststore.h \ $$PWD/spotmarket/spotmarketdataprovider.h \ $$PWD/spotmarket/spotmarketdataproviderawattar.h \ $$PWD/spotmarket/spotmarketmanager.h \ @@ -55,6 +56,7 @@ SOURCES += \ $$PWD/nymeaenergyjsonhandler.cpp \ $$PWD/rootmeter.cpp \ $$PWD/smartchargingmanager.cpp \ + $$PWD/etm/forecast/forecaststore.cpp \ $$PWD/spotmarket/spotmarketdataprovider.cpp \ $$PWD/spotmarket/spotmarketdataproviderawattar.cpp \ $$PWD/spotmarket/spotmarketmanager.cpp \ diff --git a/energyplugin/energyplugin.pro b/energyplugin/energyplugin.pro index 375880d..66996dd 100644 --- a/energyplugin/energyplugin.pro +++ b/energyplugin/energyplugin.pro @@ -5,7 +5,7 @@ CONFIG += plugin link_pkgconfig PKGCONFIG += nymea nymea-energy -QT *= network +QT *= network sql include(energyplugin.pri) diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index a94345f..36a1de6 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -70,6 +70,19 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm, m_telemetryHeartbeat->setInterval(TelemetryHeartbeatPeriodMs); connect(m_telemetryHeartbeat, &QTimer::timeout, this, &EnergyArbitrator::publishTelemetryHeartbeat); m_telemetryHeartbeat->start(); + + // L'historique des prévisions PV. Créé ici parce que sa DURÉE DE VIE est celle de l'arbitre + // — pas parce qu'il décide : il n'entre dans aucun plan, ne pondère aucune échéance. + // La purge tourne une fois par jour ; une table qui ne dit pas quand elle purge finit par + // le décider toute seule un jour de disque plein. + m_forecastStore = new ForecastStore(ForecastStore::cheminParDefaut(), this); + if (m_forecastStore->isValid()) { + m_forecastPurge = new QTimer(this); + m_forecastPurge->setInterval(24 * 3600 * 1000); + connect(m_forecastPurge, &QTimer::timeout, this, + [this]() { m_forecastStore->purger(); }); + m_forecastPurge->start(); + } #else Q_UNUSED(em) #endif diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index e2e9c84..eebb2b9 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -11,6 +11,7 @@ #include "types/loadconfig.h" #include "types/drawcap.h" #include "types/derogation.h" +#include "forecast/forecaststore.h" #include "types/ecoprogress.h" #include "types/ecotarget.h" @@ -69,6 +70,14 @@ public: //! \brief Les dérogations en cours. \return Liste, vide dans le cas normal. QList derogations() const { return m_derogations.values(); } + /*! + * \brief L'historique des prévisions PV. \return Le magasin, jamais nul. + * \note **Vide tant qu'aucun canal ne l'alimente** — et une courbe vide est la réponse + * HONNÊTE à « que prévoit-on ? » quand personne n'a rien publié. Ce n'est pas un état + * transitoire à masquer : c'est le même « absence plutôt que zéro » que partout ici. + */ + ForecastStore *forecastStore() const { return m_forecastStore; } + /*! * \brief Construit l'arbitre central. Mêmes dépendances que \c SmartChargingManager amont, * dont il hérite (justification : AGENTS.md, « 3b-iii »). @@ -842,6 +851,9 @@ private: //! codée en dur ; elle a disparu avec elle (lot B-bis), et avec elle la possibilité qu'une //! charge soit arbitrée sans être configurable. QHash m_loadAdapters; + //! Historique bitemporel des prévisions PV. Possédé ; purgé une fois par jour. + ForecastStore *m_forecastStore = nullptr; + QTimer *m_forecastPurge = nullptr; //! loadId → config AYANT SERVI à construire l'adaptateur. Permet le rebuild incrémental //! (ECS-412) : on ne reconstruit que si le matériel a changé. QHash m_builtFrom; diff --git a/energyplugin/etm/forecast/forecaststore.cpp b/energyplugin/etm/forecast/forecaststore.cpp new file mode 100644 index 0000000..15fbbd3 --- /dev/null +++ b/energyplugin/etm/forecast/forecaststore.cpp @@ -0,0 +1,260 @@ +// 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; +} diff --git a/energyplugin/etm/forecast/forecaststore.h b/energyplugin/etm/forecast/forecaststore.h new file mode 100644 index 0000000..f748cae --- /dev/null +++ b/energyplugin/etm/forecast/forecaststore.h @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include +#include +#include +#include + +/*! + * \file forecaststore.h + * \brief L'historique BITEMPOREL des prévisions de production PV. + * + * \par Ce que ce n'est pas + * Ce n'est **pas** « une valeur par heure ». Une prévision est **révisée** : celle de 8 h pour + * 15 h n'est pas celle de 14 h pour 15 h. La table porte donc **« la valeur pour une heure, + * telle qu'on la connaissait à tel moment »**, et la clé est la **paire**. + * + * Avec la seule heure visée, chaque rafraîchissement écraserait le précédent : on perdrait les + * révisions, donc la comparaison prévu/réalisé — c'est-à-dire exactement ce que ce lot existe + * pour montrer. + * + * \par Le GPL n'apprend pas, il rend l'apprentissage POSSIBLE + * Comparer prévu et mesuré pour en tirer une correction est un apprentissage, donc hors de ce + * dépôt. Cette table est l'**interface** avec ce qui apprendra : elle conserve la matière sans + * la traiter. L'écraser à chaque révision la détruirait pour toujours, sans que rien ne le + * signale. + * + * \see docs/DESIGN_PREVISION_PV.md + */ + +//! \brief Un point de la courbe : une énergie, pour une heure visée. +struct ForecastPoint { + /*! + * \brief Heure VISÉE — instant absolu, **UTC**. Jamais un libellé d'heure locale. + * + * Un « 02:00 » local existe **deux fois** le jour du passage à l'heure d'hiver et **pas du + * tout** au passage à l'heure d'été. Indexer par heure murale corromprait silencieusement + * la série deux fois par an, un jour où personne ne regarde. + */ + QDateTime targetAt; + //! Énergie prévue sur l'heure (Wh). Une ABSENCE ne s'écrit pas : elle ne crée pas de point. + double wh = 0; +}; + +/*! + * \brief Une révision : la courbe telle qu'on la connaissait à un instant. + */ +struct ForecastRevision { + /*! + * \brief Instant de RÉCEPTION, UTC — et **surtout pas** l'heure d'initialisation du run. + * + * \warning **AUCUN HORIZON NE SE CALCULE DEPUIS CE CHAMP.** Entre l'initialisation d'un run + * et sa mise à disposition s'écoule la latence de publication du modèle : des **heures** + * pour un modèle global. `targetAt - receivedAt` est donc trop court de toute cette + * latence, **systématiquement et dans le même sens** — une prévision qu'on croirait « à + * H−1 » peut être le run de H−5. + * + * \par Pourquoi ce champ s'appelle ainsi + * Le nom porte la protection. `issuedAt` promettrait l'heure du run ; quelqu'un écrirait + * `targetAt - issuedAt` et obtiendrait un nombre **parfaitement plausible**. L'heure du + * run n'est pas dans la réponse de prévision de l'API — il faudrait une requête de plus, + * qu'aucun consommateur actuel ne justifie (cf. \c DESIGN_PREVISION_PV.md §13.4). + * + * \note Ce que la déduplication sauve : les **bornes** des révisions — combien, et laquelle + * s'applique. Ce qu'elle ne sauve pas : leur **âge**. « Quelle prévision » est juste ; + * « quel horizon » est faux. + */ + QDateTime receivedAt; + //! Les points de la courbe. Une révision **vide n'est pas enregistrée** : voir \c enregistrer(). + QList points; + /*! + * \brief Modèle ayant répondu — **vide tant que le producteur publie une INTENTION**. + * + * Le plugin météo publie aujourd'hui le modèle *configuré*, pas celui qui a répondu. Le + * stocker comme provenance enregistrerait une intention — et une intention rangée dans une + * colonne de provenance **devient** une provenance au premier lecteur. Vide dit « on ne + * sait pas », ce qui est vrai. + */ + QString model; +}; + +/*! + * \brief Historique bitemporel des prévisions, sur SQLite. + * + * \invariant Une révision identique à la précédente **sur ses heures communes** n'est pas + * enregistrée : ce n'est pas une révision, c'est la même relue. + * \invariant Une révision sans point n'est jamais enregistrée : l'absence est un **trou**, + * jamais une courbe de zéros. + * \invariant Tous les instants stockés sont des **epochs UTC**. + */ +class ForecastStore : public QObject +{ + Q_OBJECT +public: + /*! + * \brief Ouvre (et crée au besoin) la base. + * \param cheminFichier Fichier SQLite. **Le nôtre**, jamais \c energylogs.sqlite : cette + * base-là appartient à l'amont, qui en migre le schéma — et surtout 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é. + * \param parent Parent Qt. + */ + explicit ForecastStore(const QString &cheminFichier, QObject *parent = nullptr); + ~ForecastStore() override; + + //! \brief Chemin par défaut — à CÔTÉ de la configuration, jamais dans la base de l'amont. + //! \return Le chemin, surchargeable par \c NYMEA_ENERGY_FORECAST_DB (tests). + static QString cheminParDefaut(); + + //! \brief La base est-elle utilisable ? \return Faux si l'ouverture ou la migration a échoué. + bool isValid() const { return m_valide; } + + /*! + * \brief Enregistre une révision, si c'en est une. + * \param revision Révision reçue. + * \return Vrai si elle a été écrite ; **faux si elle a été reconnue identique** à la + * dernière connue, ou si elle était vide, ou en cas d'échec. + * + * \par La comparaison porte sur l'INTERSECTION, pas sur la charge utile + * Si la fenêtre publiée **glisse** — seulement les heures à venir — la charge utile de + * 11 h a un point de moins que celle de 10 h, et une égalité de charge utile verrait une + * révision **toutes les heures**, ce que la déduplication existe précisément pour éviter. + * Deux révisions sont donc les mêmes si, **sur leurs heures communes**, toutes les valeurs + * coïncident. + * + * Ce choix ne dépend pas de la forme que le producteur publie aujourd'hui : il est correct + * que la fenêtre glisse ou non, et il ne peut donc pas être invalidé par un changement + * chez le voisin dont personne ne nous préviendrait. + */ + bool enregistrer(const ForecastRevision &revision); + + /*! + * \brief La courbe COURANTE : pour chaque heure visée, la dernière révision connue. + * \param de Début de la fenêtre (inclus, UTC). + * \param a Fin de la fenêtre (inclus, UTC). + * \param connuA Ne considérer que les révisions reçues jusqu'à cet instant ; invalide = + * maintenant. C'est ce paramètre qui rend la lecture **reproductible** : « ce qu'on + * prévoyait à 8 h » se demande en le fixant à 8 h. + * \return Les points, ordonnés par heure visée. **Les heures sans prévision sont ABSENTES** — + * jamais un zéro, qui se lirait « on prévoit une nuit ». + */ + QList courbe(const QDateTime &de, const QDateTime &a, + const QDateTime &connuA = QDateTime()) const; + + /*! + * \brief Toutes les révisions connues pour une heure visée, de la plus ancienne à la plus + * récente. Sert à lire l'historique d'une révision, jamais à décider. + * \param targetAt Heure visée (UTC). + * \return Paires (instant de réception, énergie). Vide si rien n'est connu. + */ + QList> revisions(const QDateTime &targetAt) const; + + /*! + * \brief Applique la rétention. + * \param maintenant Instant de référence ; invalide = maintenant. + * \return Nombre de lignes supprimées. + * + * \par Sept jours pleins, puis les DEUX BOUTS + * Au-delà de la fenêtre pleine, on ne garde par heure visée que la **première** révision + * (horizon le plus long) et la **dernière** (meilleure estimation). Les deux bouts + * conservent les deux seules questions qui se posent — *« la veille valait-elle quelque + * chose ? »* et *« que pouvait-on savoir de mieux ? »*. N'en garder qu'un détruirait la + * révision, c'est-à-dire l'objet même de cette table. + */ + int purger(const QDateTime &maintenant = QDateTime()); + +private: + bool migrer(); + + QSqlDatabase m_db; + QString m_connexion; + bool m_valide = false; +}; diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 2ed5ca5..5fa514c 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -25,6 +25,7 @@ #include "nymeaenergyjsonhandler.h" #ifdef ETM_ARBITRATOR #include "etm/energyarbitrator.h" +#include "etm/forecast/forecaststore.h" #endif #include "types/charginginfo.h" #include "smartchargingmanager.h" @@ -298,6 +299,33 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana returns = loadTelemetrySchema(); registerMethod("GetLoadTelemetry", description, params, returns, Types::PermissionScopeControlThings); + // --- Prévision PV ------------------------------------------------------------------------ + // + // UN `Get*`, ET PAS DE `*Changed` — amendement assumé à l'invariant 11, qui vise l'ÉTAT DE + // L'ARBITRE : celui-là bouge à chaque cycle, et un client déconnecté s'en retrouverait en + // retard sans pouvoir se resynchroniser. Une courbe HISTORISÉE n'a pas ce problème : elle se + // lit sur demande, comme GetPowerBalanceLogs. Notifier chaque révision produirait un message + // par heure sur une donnée que personne n'attend en direct. + params.clear(); returns.clear(); + description = "Read the stored PV production FORECAST for a window. This is a forecast, " + "NEVER a measurement: the conversion ignores shading, soiling, module " + "temperature and inverter clipping - all optimistic - so the curve is a " + "CEILING, systematically above what will be produced. Hours with no forecast " + "are ABSENT from the list, never zero: a zero would read as 'we expect night'. " + "'knownAt' makes the read reproducible - it returns the curve as it was known " + "at that instant, which is how 'what did we forecast this morning' is asked. " + "'receivedAt' is when the box received the revision, NOT when the model run " + "was initialised: NO HORIZON MAY BE COMPUTED FROM IT - it is short by the " + "model's publication latency, which is hours."; + params.insert("from", enumValueName(String)); // ISO 8601 UTC + params.insert("to", enumValueName(String)); // ISO 8601 UTC + params.insert("o:knownAt", enumValueName(String)); // ISO 8601 UTC ; absent = maintenant + QVariantMap pointItem; + pointItem.insert("targetAt", enumValueName(String)); // ISO 8601 UTC + pointItem.insert("forecastWh", enumValueName(Double)); + returns.insert("forecastPoints", QVariantList() << pointItem); + registerMethod("GetPvForecast", description, params, returns, Types::PermissionScopeControlThings); + // --- Dérogations ------------------------------------------------------------------------- // // Une dérogation n'est PAS un mode : elle ne passe donc pas par SetChargingInfo, qui écrit @@ -1019,6 +1047,36 @@ JsonReply *NymeaEnergyJsonHandler::GetLoadTelemetry(const QVariantMap ¶ms) return createReply(currentLoadTelemetry()); } +JsonReply *NymeaEnergyJsonHandler::GetPvForecast(const QVariantMap ¶ms) +{ +#ifdef ETM_ARBITRATOR + EnergyArbitrator *arb = dynamic_cast(m_smartChargingManager); + ForecastStore *magasin = arb ? arb->forecastStore() : nullptr; + if (!magasin || !magasin->isValid()) + return createReply({{"forecastPoints", QVariantList()}}); + + const QDateTime de = QDateTime::fromString(params.value("from").toString(), Qt::ISODate); + const QDateTime a = QDateTime::fromString(params.value("to").toString(), Qt::ISODate); + QDateTime connuA; + if (params.contains("knownAt")) + connuA = QDateTime::fromString(params.value("knownAt").toString(), Qt::ISODate); + + QVariantList points; + for (const ForecastPoint &p : magasin->courbe(de, a, connuA)) { + // Le nom porte la protection : `forecastWh`, jamais `wh` ni `productionWh`. Un nombre + // qui ne dit pas d'où il vient finit par être lu comme une mesure, et une prévision lue + // comme une mesure est un mensonge que personne n'a écrit. + points.append(QVariantMap{ + {"targetAt", p.targetAt.toUTC().toString(Qt::ISODate)}, + {"forecastWh", p.wh}}); + } + return createReply({{"forecastPoints", points}}); +#else + Q_UNUSED(params) + return createReply({{"forecastPoints", QVariantList()}}); +#endif +} + JsonReply *NymeaEnergyJsonHandler::SetLoadDerogation(const QVariantMap ¶ms) { #ifdef ETM_ARBITRATOR diff --git a/energyplugin/nymeaenergyjsonhandler.h b/energyplugin/nymeaenergyjsonhandler.h index 9340720..6f64ec1 100644 --- a/energyplugin/nymeaenergyjsonhandler.h +++ b/energyplugin/nymeaenergyjsonhandler.h @@ -119,6 +119,10 @@ public: //! \brief Pose une dérogation (servie au maximum, financée au réseau, en tête de passe éco). //! \param params \c loadId, \c durationS (obligatoire, borné), \c targetWh (optionnel). //! \return \c energyError — \c InvalidParameter si la charge est inconnue ou la durée hors bornes. + //! \brief Lit la courbe de prévision PV stockée. \param params \c from, \c to, \c o:knownAt. + //! \return \c forecastPoints — les heures SANS prévision sont absentes, jamais à zéro. + //! \note Un \c Get* SANS \c *Changed : l'invariant 11 vise l'état vivant, pas l'historique. + Q_INVOKABLE JsonReply *GetPvForecast(const QVariantMap ¶ms); Q_INVOKABLE JsonReply *SetLoadDerogation(const QVariantMap ¶ms); //! \brief Lève une dérogation avant son terme et rend le mode mémorisé. //! \param params \c loadId. \return \c energyError — \c InvalidParameter si aucune n'était en cours. diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index 4a27ce3..fad4e02 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -1,5 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later +#include "etm/forecast/forecaststore.h" +#include #include "testloadmodel.h" #include "etm/adapters/relayrouter.h" @@ -923,3 +925,135 @@ void TestLoadModel::testDrawMarginComparesHeadroomsNotCaps() QCOMPARE(drawCapSourceCode(DrawCapSource::GridOperator), QStringLiteral("gridOperator")); QCOMPARE(drawCapSourceCode(DrawCapSource::EcoSelfImposed), QStringLiteral("selfImposed")); } + +namespace { +//! Fabrique une révision : heures consécutives à partir de \a depuis, une valeur par heure. +ForecastRevision revision(const QDateTime &recue, const QDateTime &depuis, + const QList &wh) +{ + ForecastRevision r; + r.receivedAt = recue; + for (int i = 0; i < wh.count(); ++i) + r.points.append({ depuis.addSecs(i * 3600), wh.at(i) }); + return r; +} +} + +void TestLoadModel::testARevisionIsJudgedOnTheOverlapNotThePayload() +{ + QTemporaryDir dir; + ForecastStore magasin(dir.filePath("forecast.sqlite")); + QVERIFY(magasin.isValid()); + + const QDateTime h10 = QDateTime(QDate(2026, 9, 9), QTime(10, 0), QTimeZone::utc()); + const QDateTime t10 = h10; // la fenêtre commence à l'heure courante + + // 1. Première révision : elle s'enregistre, il n'y a rien à quoi la comparer. + QVERIFY(magasin.enregistrer(revision(h10, t10, {100, 200, 300, 400}))); + + // 2. LA MÊME COURBE RELUE — pas une révision. C'est le cas nominal : les modèles se + // rafraîchissent au mieux toutes les heures, donc la plupart des lectures rendent + // l'identique. + QVERIFY2(!magasin.enregistrer(revision(h10.addSecs(600), t10, {100, 200, 300, 400})), + "une courbe identique n'est pas une révision"); + + // 3. LE CAS QUE `==` RATERAIT — la fenêtre a GLISSÉ d'une heure : un point de moins, et les + // valeurs communes inchangées. La charge utile DIFFÈRE, la prévision NON. Sans la + // comparaison sur l'intersection, on compterait une révision toutes les heures — ce que + // la déduplication existe précisément pour éviter. + QVERIFY2(!magasin.enregistrer(revision(h10.addSecs(3600), t10.addSecs(3600), {200, 300, 400})), + "fenêtre glissée, valeurs communes inchangées : ce n'est pas une révision"); + + // 4. CONTRE-ÉPREUVE — une seule valeur commune change, et c'est une révision. Sans elle, on + // ne saurait pas si la déduplication distingue quoi que ce soit ou refuse tout. + QVERIFY2(magasin.enregistrer(revision(h10.addSecs(7200), t10.addSecs(3600), {200, 999, 400})), + "une valeur commune différente EST une révision"); + + // 5. AUCUNE HEURE COMMUNE — c'est une révision. Le cas se produit au passage de minuit, + // quand la fenêtre a entièrement tourné : conclure « identique » sans intersection + // ferait disparaître une prévision dont on ne sait rien. + QVERIFY(magasin.enregistrer(revision(h10.addSecs(10800), t10.addDays(3), {10, 20}))); + + // 6. Une révision VIDE n'en est pas une : l'absence est un trou, pas une courbe de zéros. + ForecastRevision vide; vide.receivedAt = h10.addSecs(14400); + QVERIFY(!magasin.enregistrer(vide)); +} + +void TestLoadModel::testTheCurveIsReproducibleAndKeepsItsHoles() +{ + QTemporaryDir dir; + ForecastStore magasin(dir.filePath("forecast.sqlite")); + QVERIFY(magasin.isValid()); + + const QDateTime t0 = QDateTime(QDate(2026, 9, 9), QTime(12, 0), QTimeZone::utc()); + const QDateTime r08 = QDateTime(QDate(2026, 9, 9), QTime(8, 0), QTimeZone::utc()); + const QDateTime r14 = QDateTime(QDate(2026, 9, 9), QTime(14, 0), QTimeZone::utc()); + + QVERIFY(magasin.enregistrer(revision(r08, t0, {1000, 1100, 1200}))); // « à 8 h » + QVERIFY(magasin.enregistrer(revision(r14, t0, {700, 800, 900}))); // révisé « à 14 h » + + // « CE QU'ON PRÉVOYAIT À 8 H » est une question à part entière, et elle se pose en fixant + // l'instant de connaissance. Sans ce paramètre, la comparaison prévu/réalisé n'aurait pas + // d'horizon nommé, et deux écrans montreraient deux courbes pour le même jour. + const QList vue08 = magasin.courbe(t0, t0.addSecs(7200), r08.addSecs(60)); + QCOMPARE(vue08.count(), 3); + QCOMPARE(qRound(vue08.at(0).wh), 1000); + + // La même fenêtre, connue plus tard : la révision de 14 h l'emporte. + const QList vue14 = magasin.courbe(t0, t0.addSecs(7200), r14.addSecs(60)); + QCOMPARE(vue14.count(), 3); + QCOMPARE(qRound(vue14.at(0).wh), 700); + + // Les DEUX révisions coexistent pour la même heure visée — c'est tout l'objet de la table. + // Avec `targetAt` seul pour clé, la seconde aurait écrasé la première. + QCOMPARE(magasin.revisions(t0).count(), 2); + + // LES TROUS RESTENT DES TROUS. On demande six heures, trois sont connues : la courbe en rend + // trois, jamais six dont trois à zéro — un zéro se lirait « on prévoit une nuit ». + const QList large = magasin.courbe(t0, t0.addSecs(6 * 3600), r14.addSecs(60)); + QCOMPARE(large.count(), 3); + + // Et une fenêtre entièrement inconnue rend une liste VIDE, pas une courbe de zéros. + QVERIFY(magasin.courbe(t0.addDays(5), t0.addDays(5).addSecs(7200), r14.addSecs(60)).isEmpty()); +} + +void TestLoadModel::testRetentionKeepsBothEndsOfEachRevision() +{ + QTemporaryDir dir; + ForecastStore magasin(dir.filePath("forecast.sqlite")); + QVERIFY(magasin.isValid()); + + // Une heure visée ancienne, quatre révisions successives. + const QDateTime cible = QDateTime(QDate(2026, 9, 1), QTime(12, 0), QTimeZone::utc()); + for (int i = 0; i < 4; ++i) { + ForecastRevision r; + r.receivedAt = cible.addSecs(-(4 - i) * 3600); + r.points.append({ cible, 1000.0 + i }); + QVERIFY(magasin.enregistrer(r)); + } + QCOMPARE(magasin.revisions(cible).count(), 4); + + // Purge prise BIEN APRÈS : la fenêtre pleine de sept jours est dépassée. + const int retires = magasin.purger(cible.addDays(30)); + QCOMPARE(retires, 2); // les deux intermédiaires + + // LES DEUX BOUTS SURVIVENT, et c'est le cœur de la règle : la première répond à « la veille + // valait-elle quelque chose ? », la dernière à « que pouvait-on savoir de mieux ? ». + // N'en garder qu'une détruirait la révision, c'est-à-dire l'objet de la table. + const QList> restant = magasin.revisions(cible); + QCOMPARE(restant.count(), 2); + QCOMPARE(qRound(restant.first().second), 1000); // horizon le plus long + QCOMPARE(qRound(restant.last().second), 1003); // meilleure estimation + + // CONTRE-ÉPREUVE — une heure visée RÉCENTE garde toutes ses révisions. Sans elle, une purge + // qui viderait tout passerait pour une purge correcte. + const QDateTime recente = QDateTime::currentDateTimeUtc().addSecs(3600); + for (int i = 0; i < 3; ++i) { + ForecastRevision r; + r.receivedAt = QDateTime::currentDateTimeUtc().addSecs(i * 60); + r.points.append({ recente, 500.0 + i }); + QVERIFY(magasin.enregistrer(r)); + } + magasin.purger(); + QCOMPARE(magasin.revisions(recente).count(), 3); +} diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index c8f4165..4c5ed24 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -85,6 +85,14 @@ private slots: void testStoredInstantsSurviveATimeZoneChange(); void testReportingDayBoundaryIsPublishedNotDeducible(); void testDrawMarginComparesHeadroomsNotCaps(); + + //! Une révision identique SUR SES HEURES COMMUNES n'en est pas une — et c'est le cas qu'une + //! égalité de charge utile raterait dès que la fenêtre glisse. + void testARevisionIsJudgedOnTheOverlapNotThePayload(); + //! La courbe se relit telle qu'elle était connue à un instant, et ses trous restent des trous. + void testTheCurveIsReproducibleAndKeepsItsHoles(); + //! La rétention garde les DEUX bouts : n'en garder qu'un détruirait la révision. + void testRetentionKeepsBothEndsOfEachRevision(); }; #endif // TESTLOADMODEL_H