Patrick Schurig 4e7826ff8a fix(prévision PV): le RPC ne rendait plus rien, et la courbe courante était la mauvaise
Deux défauts de moi, révélés en quatre minutes par un plan de test que le
producteur a ajouté puis retiré pour une raison sans rapport.

1. MON ÉDITION DU CONTRAT AVAIT SUPPRIMÉ LA DÉCLARATION DU RPC. GetPvForecast
répondait {} — pas même une liste vide. En réécrivant sa description par une
substitution de texte, j'avais emporté les params.insert et returns.insert qui
la suivaient ; nymea valide la réponse contre le schéma déclaré, donc sans
returns la charge utile est éliminée en silence.

Aucun test ne couvre la surface RPC : 31 unitaires, la simulation et ci-quality
passaient pendant que la méthode ne rendait plus rien. Ce n'est pas un défaut de
logique mais de DÉCLARATION, et rien ici ne les regarde.

2. LA COURBE COURANTE ÉTAIT LA DERNIÈRE APPARUE, PAS LA DERNIÈRE VUE. Le retrait
du plan a fait réapparaître un contenu déjà connu ; son firstSeenAt n'a pas bougé
— c'est le principe du champ — mais le tri était fait dessus. Quatre minutes
après la disparition du plan fictif, le moteur servait encore la courbe de
36 kWh : une installation qui n'existe pas, à +80 % de la production mesurée.

Ce qui a cours est ce qu'on a vu EN DERNIER. Le tri passe sur lastSeenAt ;
firstSeenAt dit quand un contenu est apparu, pas s'il a cours.

Limite écrite plutôt que découverte : lastSeenAt est mis à jour en place, donc
une lecture historique traversant un RETOUR à un contenu antérieur est
approchée. L'exactitude demanderait une table d'épisodes — différée.

Et une correction sur mon relevé précédent : « huit fenêtres par jour, jusqu'à
une heure chacune » est DÉDUIT du code, pas mesuré — le producteur le dit
lui-même. Ce qui est certain est que la dérive était monotone et sans rappel.
L'écrire comme une mesure en ferait la prémisse de quelqu'un.

Fenêtre à écarter de toute comparaison prévu/réalisé : 08:07 → 08:11:36.

loadmodel 31/31, 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-09 08:21:10 +02:00

392 lines
20 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 <QCryptographicHash>
#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é.
//! Version du schéma. 2 : la clé est le CONTENU, plus le run — `issuedAt` ne l'identifie pas.
constexpr int SchemaVersion = 2;
//! 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 || p.isEmpty()) {
refus = QStringLiteral("MALFORMED");
return r;
}
// `issuedAt == 0` SIGNIFIE « ÂGE INCONNU » — et ce n'est PAS un refus.
//
// Il l'a été, tant que ce champ servait de clé. Depuis qu'il est mesuré qu'il n'identifie
// pas le contenu, il n'est plus qu'un ATTRIBUT : refuser une courbe exploitable parce qu'une
// métadonnée manque reviendrait à jeter la donnée pour son étiquette. Le repli `best_match`
// le met à zéro pour TOUTES ses courbes, qui sont pourtant parfaitement utilisables.
//
// ET AUCUN INSTANT NE LUI EST SUBSTITUÉ : ni la réception, ni t0. Un âge inconnu se dit par
// une ABSENCE, jamais par une valeur plausible qu'on ne pourrait plus distinguer d'une vraie.
if (run > 0)
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 LE CONTENU. Elle l'a d'abord été le run — jusqu'à ce que le producteur
// établisse, mesures à l'appui, que `issuedAt` n'identifie pas la courbe. La règle, elle,
// n'a pas bougé : « une révision n'existe que si la PRÉVISION a changé ». Regarder la
// prévision elle-même y est plus fidèle que de regarder une étiquette d'émission.
//
// 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.
// MIGRATION v1 → v2 : la TABLE EST REFAITE, pas convertie.
//
// Les lignes de la v1 sont clés sur un run qui n'identifie pas leur contenu, et elles ont pu
// être écrasées par des rafraîchissements successifs (constaté sur `.75` le 2026-09-09). On
// ne peut donc pas savoir quel contenu chacune porte réellement. Les convertir leur donnerait
// une empreinte calculée sur des valeurs dont on ignore l'origine — une provenance inventée.
//
// C'est la règle déjà posée par le producteur, et elle vaut ici : une révision manquante est
// une lacune VISIBLE ; une révision mal datée est un mensonge INVISIBLE. La table se
// reconstruit en un cycle.
if (q.exec(QStringLiteral("PRAGMA user_version")) && q.next()
&& q.value(0).toInt() > 0 && q.value(0).toInt() < SchemaVersion) {
qCWarning(dcNymeaEnergy()) << "[Prévision] schéma v" << q.value(0).toInt()
<< "→ v" << SchemaVersion
<< ": table REFAITE, l'historique v1 ne peut pas être daté"
" par contenu et n'est pas converti";
q.exec(QStringLiteral("DROP TABLE IF EXISTS forecastPv"));
}
const bool ok =
q.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS forecastPv ("
" targetAt INTEGER NOT NULL,"
// LE SITE dont la courbe est la somme. Sans lui, changer de site désigné mêlerait
// deux historiques que rien ne distinguerait plus.
" siteId TEXT NOT NULL,"
// L'EMPREINTE DE LA COURBE ENTIÈRE — LA CLÉ. `issuedAt` ne peut pas la porter : il
// vient d'un domaine voisin qui ne fournit AUCUNE des valeurs de la courbe, et le
// serveur recompose les premières heures sans le faire bouger (mesuré 2026-09-09).
" contentId TEXT NOT NULL,"
// QUAND CE CONTENU EST APPARU — l'ordre des révisions, faute d'identité d'émission.
//
// EN MILLISECONDES, à la différence de targetAt qui est une frontière d'heure. Deux
// révisions apparues dans la MÊME SECONDE seraient sinon inordonnables, et la
// « dernière connue » en rendrait deux : un instant d'observation doit distinguer
// deux observations, sinon il n'ordonne rien.
" firstSeenAt INTEGER NOT NULL,"
// DERNIÈRE REPUBLICATION À L'IDENTIQUE — la fraîcheur, mise à jour en place.
" lastSeenAt INTEGER NOT NULL,"
" forecastWh REAL NOT NULL,"
// ATTRIBUTS, jamais la clé. `issuedAt` peut être NUL (repli best_match).
" issuedAt INTEGER,"
" model TEXT,"
" PRIMARY KEY (targetAt, siteId, contentId))"))
&& q.exec(QStringLiteral(
"CREATE INDEX IF NOT EXISTS idxForecastSeen ON forecastPv(firstSeenAt)"))
&& 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. L'absence est un TROU, et l'écrire en courbe de
// zéros se lirait « on prévoit une nuit ».
//
// `issuedAt` invalide N'EST PLUS un motif de refus : il est devenu un ATTRIBUT. Jeter une
// prévision utilisable parce qu'une métadonnée manque reviendrait à jeter la donnée pour son
// étiquette — et le repli `best_match`, qui la met à zéro, produit des courbes parfaitement
// exploitables.
if (revision.points.isEmpty()) {
qCDebug(dcNymeaEnergy()) << "[Prévision] révision vide — ignorée";
return false;
}
// --- L'EMPREINTE DU CONTENU, et c'est la clé --------------------------------------------
//
// Calculée sur la courbe elle-même — instants et valeurs — donc indépendante de tout ce que
// le producteur raconte sur son origine. Deux charges utiles différentes portant la même
// courbe donnent la même empreinte, ce qu'aucune comparaison de charge utile ne peut voir.
QCryptographicHash h(QCryptographicHash::Sha1);
for (const ForecastPoint &p : revision.points)
h.addData(QStringLiteral("%1:%2;")
.arg(p.targetAt.toUTC().toSecsSinceEpoch())
.arg(p.wh, 0, 'f', 3).toUtf8());
const QString empreinte = QString::fromLatin1(h.result().toHex());
const qint64 vu = QDateTime::currentMSecsSinceEpoch(); // ms : cf. le schéma
// --- MÊME CONTENU : on ne crée rien, on rafraîchit ---------------------------------------
QSqlQuery deja(m_db);
deja.prepare(QStringLiteral(
"SELECT COUNT(*) FROM forecastPv WHERE contentId = ? AND siteId = ?"));
deja.addBindValue(empreinte);
deja.addBindValue(revision.siteId);
if (deja.exec() && deja.next() && deja.value(0).toInt() > 0) {
QSqlQuery maj(m_db);
maj.prepare(QStringLiteral(
"UPDATE forecastPv SET lastSeenAt = ? "
"WHERE contentId = ? AND siteId = ? AND lastSeenAt < ?"));
maj.addBindValue(vu);
maj.addBindValue(empreinte);
maj.addBindValue(revision.siteId);
maj.addBindValue(vu);
if (!maj.exec())
qCWarning(dcNymeaEnergy()) << "[Prévision] rafraîchissement refusé :"
<< maj.lastError().text();
qCDebug(dcNymeaEnergy()) << "[Prévision] courbe inchangée — fraîcheur mise à jour";
return false;
}
// --- CONTENU NEUF : une révision, quoi qu'ait fait `issuedAt` ----------------------------
//
// Y COMPRIS SOUS UN `issuedAt` INCHANGÉ. Le serveur recompose les premières heures sans
// toucher au run : refuser ces écritures GÈLERAIT la courbe sur les valeurs du premier
// cycle, et l'écran montrerait une prévision périmée sans le dire — un instrument qui
// continue d'indiquer une ancienne mesure, ce qui est pire que de ne rien montrer.
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, siteId, contentId, firstSeenAt, lastSeenAt, forecastWh, issuedAt, model) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)"));
const bool ageConnu = revision.issuedAt.isValid();
for (const ForecastPoint &p : revision.points) {
ins.addBindValue(p.targetAt.toUTC().toSecsSinceEpoch());
ins.addBindValue(revision.siteId);
ins.addBindValue(empreinte);
ins.addBindValue(vu);
ins.addBindValue(vu); // à l'apparition, les deux instants coïncident
ins.addBindValue(p.wh);
// NUL plutôt qu'un zéro : « on ne connaît pas l'âge » n'est pas « âge zéro ».
ins.addBindValue(ageConnu ? QVariant(revision.issuedAt.toUTC().toSecsSinceEpoch())
: QVariant(QMetaType(QMetaType::LongLong)));
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 %1 — %2 créneau(x), modèle %3, âge %4")
.arg(empreinte.left(8))
.arg(revision.points.count())
.arg(revision.model.isEmpty() ? QStringLiteral("(inconnu)") : revision.model)
.arg(ageConnu ? revision.issuedAt.toUTC().toString(Qt::ISODate)
: QStringLiteral("(inconnu)"));
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();
// COURANTE = LA DERNIÈRE VUE, pas la dernière APPARUE. La nuance a mordu le 2026-09-09 :
// un plan de test ajouté à 08:07 puis retiré à 08:11 a fait réapparaître un contenu DÉJÀ
// connu (celui d'avant l'ajout). Son `firstSeenAt` n'a pas bougé — c'est le principe même du
// champ — si bien qu'un tri sur `MAX(firstSeenAt)` continuait de servir la courbe de
// l'installation FICTIVE, quatre minutes après sa disparition.
//
// Ce qui est courant, c'est ce qu'on a vu EN DERNIER. `firstSeenAt` garde son rôle : il dit
// quand un contenu est apparu, pas s'il a cours.
//
// LIMITE CONNUE, et écrite plutôt que découverte : `lastSeenAt` est mis à jour EN PLACE,
// donc il ne porte que sa valeur FINALE. Une lecture historique (`connuA` dans le passé) qui
// traverse un RETOUR à un contenu antérieur rend donc une réponse approchée. L'exactitude
// demanderait une table d'ÉPISODES d'observation — un schéma de plus, différé tant que ce
// mécanisme n'a qu'une journée derrière lui.
QSqlQuery q(m_db);
q.prepare(QStringLiteral(
"SELECT targetAt, forecastWh, firstSeenAt, lastSeenAt FROM forecastPv f "
"WHERE targetAt BETWEEN ? AND ? AND firstSeenAt <= ? "
" AND lastSeenAt = (SELECT MAX(lastSeenAt) FROM forecastPv g "
" WHERE g.targetAt = f.targetAt AND g.firstSeenAt <= ?) "
"ORDER BY targetAt"));
const qint64 limite = borne.toUTC().toMSecsSinceEpoch(); // firstSeenAt est en ms
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 lastSeenAt ; l'ancienneté
// de l'avis est firstSeenAt. N'en rendre qu'un ferait lire « prévu à 06Z » sur une
// ligne que le run 12Z vient de reconduire.
p.firstSeenAt = QDateTime::fromMSecsSinceEpoch(q.value(2).toLongLong(), QTimeZone::utc());
p.lastSeenAt = QDateTime::fromMSecsSinceEpoch(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 firstSeenAt, forecastWh FROM forecastPv WHERE targetAt = ? ORDER BY firstSeenAt"));
q.addBindValue(targetAt.toUTC().toSecsSinceEpoch());
if (!q.exec())
return sortie;
while (q.next())
sortie.append({ QDateTime::fromMSecsSinceEpoch(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 firstSeenAt NOT IN ("
" SELECT MIN(firstSeenAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt"
" UNION"
" SELECT MAX(firstSeenAt) 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;
}