feat(prévision PV): la table bitemporelle, la déduplication sur l'intersection, la rétention
Ce qui ne dépend pas du canal, écrit maintenant. La lecture se branchera quand la forme du canal openmeteo sera connue ; le magasin est vide d'ici là, et une courbe vide EST la réponse honnête à « que prévoit-on ? » quand personne n'a rien publié. LA CLÉ EST LA PAIRE (targetAt, receivedAt). Avec targetAt seul, chaque rafraîchissement écraserait le précédent : on perdrait les révisions, donc la comparaison prévu/réalisé — ce que ce lot existe pour montrer. Le GPL n'apprend pas, il rend l'apprentissage possible : cette table est la matière, et l'écraser la détruirait pour toujours sans que rien ne le signale. receivedAt, JAMAIS issuedAt, et l'interdiction est écrite et non seulement nommée : 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. targetAt - receivedAt est donc trop court, systématiquement et dans le même sens. « Quelle prévision » est juste, « quel horizon » est faux. LA DÉDUPLICATION PORTE SUR L'INTERSECTION, pas sur la charge utile. Si la fenêtre glisse, la courbe de 11 h a un point de moins que celle de 10 h : une égalité de charge utile verrait une révision toutes les heures, exactement ce que la déduplication existe pour éviter. Et `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 (cas du passage de minuit). 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, donc il ne peut pas être invalidé par un changement chez le voisin dont personne ne nous préviendrait. RÉTENTION : sept jours pleins, puis 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, et n'en garder qu'une détruirait la révision. model reste NULL tant que 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. Aucun remplissage rétroactif à prévoir. GetPvForecast : un Get* SANS *Changed — amendement assumé à l'invariant 11, qui vise l'ÉTAT DE L'ARBITRE, celui qui bouge à chaque cycle et laisse un client déconnecté en retard. Une courbe historisée 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. `knownAt` rend la lecture reproductible : « ce qu'on prévoyait ce matin » se demande en le fixant. Base SÉPARÉE, jamais energylogs.sqlite : celle-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é. Trois tests unitaires, et le premier vise le cas qu'un == naïf raterait : une fenêtre glissée d'une heure, valeurs communes inchangées, charge utile différente — ce n'est PAS une révision. Avec ses contre-épreuves : une valeur commune modifiée EN EST une, et une fenêtre sans heure commune aussi. loadmodel 29/29, charging 48/48, spotmarket 32/32, simulation 69/69, ci-quality 4/4, build 0 erreur 0 warning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
This commit is contained in:
parent
a2a12c1d9c
commit
9dcaee8be1
@ -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 \
|
||||
|
||||
@ -5,7 +5,7 @@ CONFIG += plugin link_pkgconfig
|
||||
|
||||
PKGCONFIG += nymea nymea-energy
|
||||
|
||||
QT *= network
|
||||
QT *= network sql
|
||||
|
||||
include(energyplugin.pri)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<Derogation> 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<QString, ILoadAdapter *> 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<QString, LoadConfig> m_builtFrom;
|
||||
|
||||
260
energyplugin/etm/forecast/forecaststore.cpp
Normal file
260
energyplugin/etm/forecast/forecaststore.cpp
Normal file
@ -0,0 +1,260 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#include "forecaststore.h"
|
||||
|
||||
#include <QFileInfo>
|
||||
#include <QDir>
|
||||
#include <QSqlError>
|
||||
#include <QSqlQuery>
|
||||
#include <QUuid>
|
||||
#include <QVariant>
|
||||
#include <QLoggingCategory>
|
||||
#include <nymeasettings.h>
|
||||
|
||||
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<qint64, double> 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<ForecastPoint> ForecastStore::courbe(const QDateTime &de, const QDateTime &a,
|
||||
const QDateTime &connuA) const
|
||||
{
|
||||
QList<ForecastPoint> 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<QPair<QDateTime, double>> ForecastStore::revisions(const QDateTime &targetAt) const
|
||||
{
|
||||
QList<QPair<QDateTime, double>> 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;
|
||||
}
|
||||
175
energyplugin/etm/forecast/forecaststore.h
Normal file
175
energyplugin/etm/forecast/forecaststore.h
Normal file
@ -0,0 +1,175 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#pragma once
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QSqlDatabase>
|
||||
#include <QString>
|
||||
|
||||
/*!
|
||||
* \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<ForecastPoint> 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<ForecastPoint> 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<QPair<QDateTime, double>> 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;
|
||||
};
|
||||
@ -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<EnergyArbitrator *>(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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "etm/forecast/forecaststore.h"
|
||||
#include <QTemporaryDir>
|
||||
#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<double> &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<ForecastPoint> 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<ForecastPoint> 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<ForecastPoint> 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<QPair<QDateTime, double>> 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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user