Patrick Schurig 11635b8e6d feat(prévision PV): la fraîcheur ne date jamais la prévision, et elle n'est jamais absente
Deux gardes sur lastConfirmedAt, demandées et tranchées.

JAMAIS UNE CLÉ DE RÉVISION. firstIssuedAt identifie la prévision ; la fraîcheur
dit seulement jusqu'à quand elle a tenu. Dater avec elle ferait paraître une
prévision née au moment où elle a été reconduite — et deux prévisions
différentes reconduites par le même run porteraient la même date. Écrit dans
l'en-tête, au contrat ET dans un test : une interdiction qui ne vit qu'à un
endroit n'est pas une interdiction.

SUR UNE LIGNE JAMAIS RECONDUITE, ELLE VAUT firstIssuedAt — jamais absente. Et la
tranche vient de ce que le champ RÉPOND : il ne dit pas « quand quelqu'un a
confirmé » mais « quel est le dernier run qui a AFFIRMÉ ces valeurs ». Le run
émetteur les a affirmées. La réponse est vraie, et uniformément — il n'y a pas
un cas confirmation et un cas copie, il y a une seule question avec une seule
sorte de réponse.

L'ambiguïté redoutée ne se lève pas en choisissant une convention : elle se lève
en DÉFINISSANT correctement le champ. Une fois la définition posée, les deux cas
n'en font plus qu'un.

Et ici l'absence serait le mensonge, à rebours de la règle habituelle du dépôt :
elle se lirait « on ne sait pas jusqu'à quand elle a tenu » alors qu'on le sait,
et forcerait chaque client à écrire `confirmedAt ?? issuedAt` — ce calcul-ci,
ailleurs, une fois par client, avec une chance de le faire de travers.

Invariant publié : lastConfirmedAt >= firstIssuedAt, toujours. Une prévision ne
peut pas avoir tenu avant d'avoir été émise.

Sur le nom : lastConfirmedAt et non confirmedAt — le préfixe dit qu'il s'agit du
DERNIER d'une suite, là où confirmedAt se lirait comme l'instant d'un événement
unique, et une ligne reconduite quatre fois n'a pas « un » instant de
confirmation.

loadmodel 31/31, charging 48/48, spotmarket 32/32, 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
2026-09-09 05:56:18 +02:00

350 lines
17 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);
// À L'OUVERTURE, LES DEUX INSTANTS COÏNCIDENT — et ce n'est pas une copie de commodité.
// `lastConfirmedAt` répond à « quel est le dernier run qui a AFFIRMÉ ces valeurs ». Le
// run émetteur les a affirmées : la réponse est vraie, et elle l'est uniformément.
// Le laisser NUL se lirait « on ne sait pas jusqu'à quand elle a tenu », alors qu'on le
// sait — et forcerait chaque client à écrire le repli, ailleurs, avec une chance de le
// faire de travers.
ins.addBindValue(run);
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;
}