La déduplication porte sur les VALEURS ; un run qui reconduit ne crée pas de ligne, il met à jour la fraîcheur. Le raisonnement qui tranche est celui de meta.json, appliqué une seconde fois : on ne stocke pas pour un consommateur qui n'existe pas. L'apprentissage n'existe pas, le consommateur d'aujourd'hui est un affichage, et un affichage ne lit pas une confirmation. Même déclencheur de réouverture, pour la même raison. UNE CONTRADICTION DANS LA CONSIGNE, ET COMMENT ELLE EST LEVÉE. Deux instructions incompatibles : « issuedAt est mis à jour sur la ligne existante » (= le dernier run) et « il devient l'horodatage du premier run, pas du dernier » (= le premier). Et l'avertissement joint — « quelqu'un lira prévu à 06Z sur une ligne que le run 12Z a confirmée » — ne décrit un danger QUE si l'instant n'est pas mis à jour. Aucune des deux n'a été choisie, parce qu'aucune ne suffit : un seul champ ne peut pas porter les deux faits sans mentir sur l'un. Ce serait exactement le piège receivedAt/issuedAt de la veille — deux instants voisins, un nom qui promet le mauvais — et le remède est le même : nommer ce que c'est. firstIssuedAt run qui a produit ces valeurs EN PREMIER (CLÉ, immuable) lastConfirmedAt dernier run qui les a RECONDUITES (mis à jour) La FRAÎCHEUR est lastConfirmedAt ; l'ANCIENNETÉ DE L'AVIS est firstIssuedAt. Une valeur émise à 06Z et reconduite à 12Z est fraîche ; une valeur émise à 06Z que personne n'a reconduite EST vieille de six heures. Les deux phrases sont vraies et ne parlent pas de la même chose. Les deux sont publiées au contrat : n'en publier qu'une obligerait le client à choisir laquelle il croit. firstIssuedAt ne bouge jamais — une clé qui se déplace change l'identité de la ligne sous elle. Les deux instants coïncident à l'ouverture et divergent à la première reconduction, ce que le test vérifie explicitement. DERNIER REMPART, PAS FILET : celle du producteur compare la charge utile entière, où issuedAt figure, donc elle ne mord jamais sur ce cas. Si celle-ci se trompe, personne ne rattrape — raison de plus pour qu'elle ne dépende d'aucun comportement du voisin : intersection des créneaux, et communs > 0 requis. loadmodel 30/30, 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
344 lines
16 KiB
C++
344 lines
16 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;
|
|
|
|
//! Version de la charge utile de TRANSPORT qu'on sait lire. Une autre est REFUSÉE, jamais
|
|
//! interprétée au mieux — cf. \c ForecastStore::depuisTransport().
|
|
constexpr int TransportVersion = 1;
|
|
}
|
|
|
|
QString ForecastStore::cheminParDefaut()
|
|
{
|
|
const QString env = QString(qgetenv("NYMEA_ENERGY_FORECAST_DB"));
|
|
if (!env.isEmpty())
|
|
return env;
|
|
// À CÔTÉ de energy-load-configuration.json, et surtout PAS dans energylogs.sqlite : cette
|
|
// base appartient à l'amont, qui en migre le schéma — et la clé y est un instant de mesure,
|
|
// quand la nôtre est une paire. Une prévision révisée n'a rien à faire dans la base du mesuré.
|
|
return NymeaSettings::storagePath() + "/energy-forecast.sqlite";
|
|
}
|
|
|
|
ForecastRevision ForecastStore::depuisTransport(const QVariantMap &charge, QString &refus)
|
|
{
|
|
ForecastRevision r;
|
|
refus.clear();
|
|
if (charge.isEmpty()) { refus = QStringLiteral("EMPTY"); return r; }
|
|
|
|
// LA VERSION D'ABORD, et le refus plutôt que l'interprétation au mieux. Une charge utile qui
|
|
// traverse une frontière de processus n'a plus de schéma : personne ne vérifie sa forme, et
|
|
// une lecture « au mieux » d'une version inconnue produirait une courbe fausse qui aurait
|
|
// l'air de marcher.
|
|
if (charge.value(QStringLiteral("v")).toInt() != TransportVersion) {
|
|
refus = QStringLiteral("VERSION_%1").arg(charge.value(QStringLiteral("v")).toString());
|
|
return r;
|
|
}
|
|
// L'unité et la convention sont LUES. Les supposer, c'est hériter d'un fait vrai sous des
|
|
// conditions qu'on ne saura pas reproduire le jour où elles changeront.
|
|
const QString unite = charge.value(QStringLiteral("unit")).toString();
|
|
if (unite != QLatin1String("W")) { refus = QStringLiteral("UNIT_%1").arg(unite); return r; }
|
|
|
|
const qint64 t0 = charge.value(QStringLiteral("t0")).toLongLong();
|
|
const int step = charge.value(QStringLiteral("step")).toInt();
|
|
const qint64 run = charge.value(QStringLiteral("issuedAt")).toLongLong();
|
|
const QVariantList p = charge.value(QStringLiteral("p")).toList();
|
|
if (t0 <= 0 || step <= 0 || run <= 0 || p.isEmpty()) {
|
|
refus = QStringLiteral("MALFORMED");
|
|
return r;
|
|
}
|
|
|
|
r.issuedAt = QDateTime::fromSecsSinceEpoch(run, QTimeZone::utc());
|
|
r.model = charge.value(QStringLiteral("model")).toString();
|
|
for (int i = 0; i < p.count(); ++i) {
|
|
bool ok = false;
|
|
const double w = p.at(i).toDouble(&ok);
|
|
// UN CRÉNEAU ILLISIBLE EST UN TROU, pas un zéro. Il ne crée simplement pas de point :
|
|
// la courbe en portera l'absence, et l'écran devra l'afficher comme telle.
|
|
if (!ok) continue;
|
|
ForecastPoint pt;
|
|
// `convention` : p[i] est la MOYENNE sur [t0 + i·step, t0 + (i+1)·step). L'énergie du
|
|
// créneau s'en déduit sans supposer que le pas vaut une heure — et le jour où il vaudra
|
|
// un quart d'heure, rien ici ne changera.
|
|
pt.targetAt = QDateTime::fromSecsSinceEpoch(t0 + qint64(i) * step, QTimeZone::utc());
|
|
pt.wh = w * step / 3600.0;
|
|
r.points.append(pt);
|
|
}
|
|
if (r.points.isEmpty())
|
|
refus = QStringLiteral("NO_POINT");
|
|
return r;
|
|
}
|
|
|
|
ForecastStore::ForecastStore(const QString &cheminFichier, QObject *parent)
|
|
: QObject(parent)
|
|
{
|
|
QDir().mkpath(QFileInfo(cheminFichier).absolutePath());
|
|
// Connexion NOMMÉE et unique : deux ForecastStore dans le même processus (production +
|
|
// test) partageraient sinon la connexion par défaut, et le second fermerait le premier.
|
|
m_connexion = QStringLiteral("etm-forecast-%1")
|
|
.arg(QUuid::createUuid().toString(QUuid::WithoutBraces));
|
|
m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connexion);
|
|
m_db.setDatabaseName(cheminFichier);
|
|
if (!m_db.open()) {
|
|
qCWarning(dcNymeaEnergy()) << "[Prévision] base illisible" << cheminFichier
|
|
<< ":" << m_db.lastError().text();
|
|
return;
|
|
}
|
|
m_valide = migrer();
|
|
}
|
|
|
|
ForecastStore::~ForecastStore()
|
|
{
|
|
if (m_db.isOpen())
|
|
m_db.close();
|
|
m_db = QSqlDatabase();
|
|
QSqlDatabase::removeDatabase(m_connexion);
|
|
}
|
|
|
|
bool ForecastStore::migrer()
|
|
{
|
|
QSqlQuery q(m_db);
|
|
// LA CLÉ EST 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,"
|
|
// LE RUN QUI A PRODUIT CES VALEURS EN PREMIER. C'est la clé : elle ne bouge jamais,
|
|
// sans quoi l'identité de la ligne changerait sous elle.
|
|
" firstIssuedAt INTEGER NOT NULL,"
|
|
// LE DERNIER RUN QUI LES A RECONDUITES, mis à jour en place. Deux instants et deux
|
|
// noms : un seul champ ne peut pas porter les deux faits sans mentir sur l'un.
|
|
// La FRAÎCHEUR est lastConfirmedAt ; l'ANCIENNETÉ DE L'AVIS est firstIssuedAt.
|
|
" lastConfirmedAt INTEGER NOT NULL,"
|
|
" forecastWh REAL NOT NULL,"
|
|
" model TEXT,"
|
|
" PRIMARY KEY (targetAt, firstIssuedAt))"))
|
|
&& q.exec(QStringLiteral(
|
|
"CREATE INDEX IF NOT EXISTS idxForecastIssued ON forecastPv(firstIssuedAt)"))
|
|
&& 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.issuedAt.isValid()) {
|
|
qCDebug(dcNymeaEnergy()) << "[Prévision] révision vide ou sans heure de run — ignorée";
|
|
return false;
|
|
}
|
|
const qint64 run = revision.issuedAt.toUTC().toSecsSinceEpoch();
|
|
|
|
// --- UNE RÉVISION N'EXISTE QUE SI LA PRÉVISION A CHANGÉ ---------------------------------
|
|
//
|
|
// La comparaison porte sur LES VALEURS, sur les créneaux communs — jamais sur la charge
|
|
// utile entière, qui contient `firstIssuedAt` et différerait donc à chaque run.
|
|
//
|
|
// ET C'EST LE DERNIER REMPART, PAS UN FILET : la déduplication du producteur compare la
|
|
// charge utile complète, où `firstIssuedAt` figure, donc elle ne mord jamais sur ce cas. Si
|
|
// celle-ci se trompe, personne ne rattrape.
|
|
QSqlQuery derniere(m_db);
|
|
derniere.prepare(QStringLiteral("SELECT MAX(firstIssuedAt) FROM forecastPv"));
|
|
if (derniere.exec() && derniere.next() && !derniere.value(0).isNull()) {
|
|
const qint64 refRun = derniere.value(0).toLongLong();
|
|
QSqlQuery ref(m_db);
|
|
ref.prepare(QStringLiteral(
|
|
"SELECT targetAt, forecastWh FROM forecastPv WHERE firstIssuedAt = ?"));
|
|
ref.addBindValue(refRun);
|
|
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.toUTC().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 créneau commun, il n'y a rien à comparer, et conclure
|
|
// « identique » ferait disparaître une prévision dont on ne sait rien.
|
|
if (identique && communs > 0) {
|
|
// LE RUN RECONDUIT — on ne crée pas de ligne, on met à jour la FRAÎCHEUR. Stocker la
|
|
// confirmation elle-même serait stocker pour un consommateur qui n'existe pas :
|
|
// celui d'aujourd'hui est un affichage, et un affichage ne lit pas une confirmation.
|
|
QSqlQuery maj(m_db);
|
|
maj.prepare(QStringLiteral(
|
|
"UPDATE forecastPv SET lastConfirmedAt = ? "
|
|
"WHERE firstIssuedAt = ? AND lastConfirmedAt < ?"));
|
|
maj.addBindValue(run);
|
|
maj.addBindValue(refRun);
|
|
maj.addBindValue(run);
|
|
if (!maj.exec())
|
|
qCWarning(dcNymeaEnergy()) << "[Prévision] reconduction refusée :"
|
|
<< maj.lastError().text();
|
|
else if (maj.numRowsAffected() > 0)
|
|
qCInfo(dcNymeaEnergy()).noquote()
|
|
<< QStringLiteral("[Prévision] run %1 RECONDUIT la prévision du %2 — "
|
|
"%3 créneau(x) inchangé(s), aucune révision")
|
|
.arg(revision.issuedAt.toUTC().toString(Qt::ISODate))
|
|
.arg(QDateTime::fromSecsSinceEpoch(refRun, QTimeZone::utc())
|
|
.toString(Qt::ISODate))
|
|
.arg(communs);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// --- écriture d'une VRAIE révision, 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, firstIssuedAt, lastConfirmedAt, forecastWh, model) VALUES (?, ?, ?, ?, ?)"));
|
|
for (const ForecastPoint &p : revision.points) {
|
|
ins.addBindValue(p.targetAt.toUTC().toSecsSinceEpoch());
|
|
ins.addBindValue(run);
|
|
ins.addBindValue(run); // à l'ouverture, les deux instants coïncident
|
|
ins.addBindValue(p.wh);
|
|
// NULL, jamais une chaîne vide : une chaîne vide serait déjà une valeur. Le producteur
|
|
// publie le modèle qui A RÉPONDU, repli compris — donc une provenance, pas une intention.
|
|
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 du run %1 — %2 créneau(x), modèle %3")
|
|
.arg(revision.issuedAt.toUTC().toString(Qt::ISODate))
|
|
.arg(revision.points.count())
|
|
.arg(revision.model.isEmpty() ? QStringLiteral("(inconnu)") : revision.model);
|
|
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, firstIssuedAt, lastConfirmedAt FROM forecastPv f "
|
|
"WHERE targetAt BETWEEN ? AND ? AND firstIssuedAt <= ? "
|
|
" AND firstIssuedAt = (SELECT MAX(firstIssuedAt) FROM forecastPv g "
|
|
" WHERE g.targetAt = f.targetAt AND g.firstIssuedAt <= ?) "
|
|
"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();
|
|
// LES DEUX INSTANTS, publiés ensemble. La FRAÎCHEUR est lastConfirmedAt ; l'ancienneté
|
|
// de l'avis est firstIssuedAt. N'en rendre qu'un ferait lire « prévu à 06Z » sur une
|
|
// ligne que le run 12Z vient de reconduire.
|
|
p.firstIssuedAt = QDateTime::fromSecsSinceEpoch(q.value(2).toLongLong(), QTimeZone::utc());
|
|
p.lastConfirmedAt = QDateTime::fromSecsSinceEpoch(q.value(3).toLongLong(), QTimeZone::utc());
|
|
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 firstIssuedAt, forecastWh FROM forecastPv WHERE targetAt = ? ORDER BY firstIssuedAt"));
|
|
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 firstIssuedAt NOT IN ("
|
|
" SELECT MIN(firstIssuedAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt"
|
|
" UNION"
|
|
" SELECT MAX(firstIssuedAt) 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;
|
|
}
|