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
261 lines
12 KiB
C++
261 lines
12 KiB
C++
// 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;
|
|
}
|