Patrick Schurig 4231453ea1 feat(prévision PV): la clé devient VRAIE, et la déduplication cesse de rejeter
Le canal est livré (openmeteo 1.15.2+etm11) : forecastCurveTransport, une
QVariantMap TYPÉE déclarée dans la ThingClass — au contrat, sans seconde couche
d'encodage. Mieux que la chaîne JSON envisagée : le §12.5 réclamait une version
dans la charge utile faute de schéma ; ici la charge utile a un schéma ET porte
sa version.

issuedAt VOYAGE DANS LA CHARGE UTILE. La clé de révision devient exacte, et tout
le §13 est levé : receivedAt, l'interdiction d'en calculer un horizon, la dette
meta.json. targetAt - issuedAt EST l'horizon. Le coût que je chiffrais disparaît
avec sa cause.

CE QUE J'AVAIS FAUX. Ma déduplication REJETAIT une révision aux valeurs
identiques. Avec une vraie heure de run, c'est faux : un run plus récent portant
les mêmes chiffres CONFIRME, il ne répète pas. Le rejeter rendrait indistincts
« le modèle a confirmé quatre fois » et « le modèle n'a rien dit depuis ce
matin ».

Le mécanisme change donc de nature et se simplifie : la CLÉ déduplique par
construction, et la comparaison de contenu ne fait plus que JOURNALISER (« le
run confirme le précédent »). Si issuedAt bougeait un jour sans que la prévision
bouge, cela se verrait dans le nombre de révisions — un défaut à la source,
qu'il faut voir et non masquer. Une déduplication de contenu l'aurait effacé.

La comparaison sur l'intersection reste, mais comme précaution : la fenêtre
couvre désormais les journées entières et les révisions sont alignées. Elle ne
dépend d'aucun comportement du voisin dont personne ne nous préviendrait.

model porte la provenance réelle, repli best_match compris. La colonne cesse
d'être nulle ; la règle de non-remplissage rétroactif reste.

L'INDEXATION EST LUE, PAS SUPPOSÉE. `convention` dit que p[i] est la moyenne sur
[t0 + i·step, t0 + (i+1)·step) : l'énergie vaut p[i] x step / 3600, sans
supposer que le pas vaut une heure. `unit` est VÉRIFIÉE — lire des Wh comme des
W donnerait une courbe plausible, fausse d'un facteur qui dépend du pas.

Lecture branchée dans le cycle, et c'est sans coût : un run déjà connu n'écrit
rien. Attendre une notification serait plus élégant et MOINS SÛR — un état
publié avant notre démarrage ne notifierait jamais, et la courbe resterait
invisible jusqu'au run suivant. L'état est cherché par son NOM, jamais par un
identifiant de ThingClass. Un refus est journalisé une fois par MOTIF, pas une
fois par cycle.

Deux tests : l'identité du run est celle de la révision (avec l'assertion
centrale — un run qui confirme EST conservé), et le refus de charge utile plutôt
que l'interprétation au mieux (version, unité, vide, tronquée, et un créneau
illisible qui reste un TROU).

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
2026-09-08 19:14:18 +02:00

324 lines
15 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,"
" issuedAt INTEGER NOT NULL,"
" forecastWh REAL NOT NULL,"
" model TEXT,"
" PRIMARY KEY (targetAt, issuedAt))"))
&& q.exec(QStringLiteral(
"CREATE INDEX IF NOT EXISTS idxForecastIssued ON forecastPv(issuedAt)"))
&& 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();
// --- LA CLÉ DÉDUPLIQUE ------------------------------------------------------------------
//
// Depuis que l'heure du run voyage dans la charge utile, l'identité du run EST l'identité de
// la révision : deux lectures du même run retombent sur les mêmes lignes. Il n'y a rien à
// comparer, et c'est tout le gain d'une clé VRAIE sur une clé approchée.
QSqlQuery deja(m_db);
deja.prepare(QStringLiteral("SELECT COUNT(*) FROM forecastPv WHERE issuedAt = ?"));
deja.addBindValue(run);
if (deja.exec() && deja.next() && deja.value(0).toInt() > 0) {
qCDebug(dcNymeaEnergy()) << "[Prévision] run déjà connu — rien à écrire";
return false;
}
// --- LE CONTENU OBSERVE, IL NE REJETTE PAS ----------------------------------------------
//
// Un run PLUS RÉCENT portant les mêmes chiffres est une information : le modèle n'a pas
// changé d'avis. IL CONFIRME, IL NE RÉPÈTE PAS. Le rejeter détruirait précisément ce que
// cette table conserve, et rendrait indistincts « le modèle a confirmé quatre fois » et
// « le modèle n'a rien dit depuis ce matin ».
//
// La comparaison porte sur l'INTERSECTION et non sur la charge utile. Le producteur publie
// aujourd'hui des journées ENTIÈRES — deux révisions du même jour couvrent donc exactement
// les mêmes créneaux — mais l'intersection reste correcte si cela changeait, et elle ne
// dépend d'aucun comportement du voisin dont personne ne nous préviendrait.
QSqlQuery derniere(m_db);
derniere.prepare(QStringLiteral("SELECT MAX(issuedAt) FROM forecastPv WHERE issuedAt < ?"));
derniere.addBindValue(run);
if (derniere.exec() && derniere.next() && !derniere.value(0).isNull()) {
QSqlQuery ref(m_db);
ref.prepare(QStringLiteral("SELECT targetAt, forecastWh FROM forecastPv WHERE issuedAt = ?"));
ref.addBindValue(derniere.value(0).toLongLong());
QHash<qint64, double> precedent;
if (ref.exec())
while (ref.next())
precedent.insert(ref.value(0).toLongLong(), ref.value(1).toDouble());
int communs = 0, differents = 0;
for (const ForecastPoint &p : revision.points) {
const auto it = precedent.constFind(p.targetAt.toUTC().toSecsSinceEpoch());
if (it == precedent.constEnd())
continue;
++communs;
if (qAbs(it.value() - p.wh) > EgaliteWh) ++differents;
}
if (communs > 0 && differents == 0)
qCInfo(dcNymeaEnergy()).noquote()
<< QStringLiteral("[Prévision] le run confirme le précédent — %1 créneau(x) "
"commun(s) inchangé(s)").arg(communs);
}
// --- é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, issuedAt, forecastWh, model) "
"VALUES (?, ?, ?, ?)"));
for (const ForecastPoint &p : revision.points) {
ins.addBindValue(p.targetAt.toUTC().toSecsSinceEpoch());
ins.addBindValue(run);
ins.addBindValue(p.wh);
// NULL, jamais une chaîne vide : une chaîne vide serait déjà une valeur. Et le producteur
// publie maintenant le modèle qui A RÉPONDU, repli compris — donc la colonne porte une
// provenance et non 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] run %1 enregistré — %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 FROM forecastPv f "
"WHERE targetAt BETWEEN ? AND ? AND issuedAt <= ? "
" AND issuedAt = (SELECT MAX(issuedAt) FROM forecastPv g "
" WHERE g.targetAt = f.targetAt AND g.issuedAt <= ?) "
"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 issuedAt, forecastWh FROM forecastPv WHERE targetAt = ? ORDER BY issuedAt"));
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 issuedAt NOT IN ("
" SELECT MIN(issuedAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt"
" UNION"
" SELECT MAX(issuedAt) 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;
}