Patrick Schurig ee6f1732bc fix(prévision PV): la clé devient LE CONTENU — issuedAt n'identifie pas la courbe
Réponse mesurée du producteur, et elle renverse le §13 comme le §19. +etm63
GELAIT la courbe : c'était le seul endroit où le lot était plus faux qu'avant.

Les faits établis : issuedAt vient de meteofrance_arome_france_hd, seul domaine
AROME exposant un meta.json — qui sert 0 valeur de rayonnement sur 72, donc ne
contribue RIEN à la courbe. Le domaine qui la fournit (arome_france) n'expose
aucun meta.json (HTTP 500) : il n'existe aucune identité de run pour la donnée
qui forme p. Et à 07:30, seamless diverge d'arome_france de 08:00 à 13:00 (144
contre 356, 326 contre 530) et coïncide à partir de 14:00 — le serveur
recompose les premières heures autrement que par le seul run.

Ce n'est pas un défaut du producteur : c'est ce que l'API permet.

LA RÈGLE N'A PAS BOUGÉ, LA CLÉ SI. « Une révision n'existe que si la PRÉVISION a
changé » reste intacte ; ce qui était faux, c'est la prémisse — que le run
identifie la prévision. Regarder le contenu y est plus fidèle que de regarder
une étiquette d'émission.

  PRIMARY KEY (targetAt, siteId, contentId)
  firstSeenAt / lastSeenAt : observation, en MILLISECONDES
  issuedAt / model         : attributs, issuedAt pouvant être NUL

Migration v1 -> v2 : table REFAITE, pas convertie. Les lignes v1 sont clés sur
un run qui n'identifie pas leur contenu et ont pu être écrasées ; leur donner
une empreinte calculée sur des valeurs dont on ignore l'origine serait inventer
une provenance.

TROIS CONSÉQUENCES IMPOSÉES PAR LA MESURE :
- issuedAt == 0 n'est PLUS un refus. Il l'a été deux heures, tant qu'il servait
  de clé. Devenu attribut, refuser une courbe exploitable parce qu'une
  métadonnée manque reviendrait à jeter la donnée pour son étiquette — et
  best_match le met à zéro pour toutes ses courbes. Stocké NUL, aucun instant
  substitué.
- siteId entre dans la clé : deux plans publient des courbes différentes sous le
  même issuedAt, donc la garde d'hier devient NÉCESSAIRE. Sans elle, changer de
  site désigné mêlerait deux historiques.
- t0 ancré sur le jour local de la requête est couvert sans rien ajouter : les
  targetAt changent, donc l'empreinte aussi.

ET UN DÉFAUT TROUVÉ PAR LE TEST, qui n'était pas dans le test : deux révisions
écrites dans la même seconde n'étaient pas ordonnables — MAX(firstSeenAt) en
rendait deux et la courbe retournait des doublons. Les instants d'observation
passent en millisecondes. Un instant d'observation doit distinguer deux
observations, sinon il n'ordonne rien ; un cycle par minute l'aurait caché
indéfiniment sur la box.

Le producteur n'a PAS établi pourquoi le serveur recompose — nowcast, mélange
court terme, ou deux domaines sur des runs différents. Il a mesuré QUE ça diverge
et SUR QUELLES HEURES. L'explication reste une hypothèse, et rien ici n'en
dépend : la clé de contenu est correcte quelle qu'en soit la cause.

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-09 07:54:45 +02:00

381 lines
19 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();
// 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, firstSeenAt, lastSeenAt FROM forecastPv f "
"WHERE targetAt BETWEEN ? AND ? AND firstSeenAt <= ? "
" AND firstSeenAt = (SELECT MAX(firstSeenAt) 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;
}