Prérequis du déploiement d'openmeteo v2 (§28) : - `depuisTransport()` lit v1 et v2. v2 exige `corrections` (liste) ; une correction inconnue refuse le plan (`CORRECTION_<nom>`), `ac-limit` compris, qui appartient au site. Autre version : `VERSION_<v>`, jamais devinée. `gti[]`/`tempC[]` ne sont pas lus ici (enregistrement : T2). - `acLimitW`, paramètre de SITE porté par `Get/SetForecastSite` : optionnel, absent = aucun plafond (jamais 0), borné [100, 1 000 000] W, refusé sans site ; la désignation se remplace en entier. Persisté (`forecastAcLimitW`). - `ForecastStore::sommerPlans()` : somme des plans du même run ET du même pas, puis écrêtage à `acLimitW × pas / 3600` APRÈS la somme. Fonction unique pour la table et le snapshot. - `GetForecastSnapshot` publie `corrections` (intersection si les plans diffèrent, journalisé au changement ; `ac-limit` si plafond configuré), `conversion: "physics"` et `acLimitW`. Aucune décision ne lit la prévision (§11) ; boucle de sécurité ni appelée ni modifiée ; pas de `*Changed`. Tests : loadmodel `testTransportV2IsReadAndAnUnknownCorrectionRefused`, `testTheSiteCurveIsClippedAfterTheSum` (contre-épreuve faite : plafond neutralisé → échec) ; simulation `testTheSiteAcLimitTravelsWithTheDesignation`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
494 lines
24 KiB
C++
494 lines
24 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 <QMap>
|
||
#include <QTimeZone>
|
||
#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;
|
||
|
||
//! Versions 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().
|
||
//! v1 : `p = GTI × kWc × PR`. v2 (brief 2) : mêmes champs, plus `corrections` (liste),
|
||
//! `gti[]`, `tempC[]` — `p` corrigé en température quand `corrections` le dit.
|
||
constexpr int TransportVersionMin = 1;
|
||
constexpr int TransportVersionMax = 2;
|
||
|
||
//! Corrections qu'un PRODUCTEUR peut déclarer. `ac-limit` n'en fait pas partie : le plafond est
|
||
//! un paramètre de SITE, appliqué ici après la somme (§28 décision 2). Un producteur qui
|
||
//! l'annoncerait l'aurait appliqué par plan — ce qui est faux — et on l'appliquerait deux fois.
|
||
const QStringList CorrectionsProducteur = { QStringLiteral("temperature") };
|
||
//! Ordre canonique des corrections publiées : une liste se compare.
|
||
const QStringList OrdreCorrections = { QStringLiteral("temperature"), QStringLiteral("ac-limit") };
|
||
|
||
QStringList ordonner(const QStringList &c)
|
||
{
|
||
QStringList r;
|
||
for (const QString &x : OrdreCorrections)
|
||
if (c.contains(x))
|
||
r << x;
|
||
return r;
|
||
}
|
||
}
|
||
|
||
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.
|
||
bool vLu = false;
|
||
const int v = charge.value(QStringLiteral("v")).toInt(&vLu);
|
||
if (!vLu || v < TransportVersionMin || v > TransportVersionMax) {
|
||
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;
|
||
}
|
||
// v2 : `corrections` est une LISTE, obligatoire. v1 n'en avait pas (chaîne "none") : sa
|
||
// conversion était brute par contrat, la liste est donc vide sans rien supposer.
|
||
//
|
||
// Une correction INCONNUE refuse la charge utile au lieu d'être ignorée : `p` porte alors un
|
||
// effet qu'on ne sait pas nommer, et l'annoncer sous une liste plus courte serait mentir sur
|
||
// la courbe. Même règle que la version : refuser, jamais interpréter au mieux.
|
||
if (v >= 2) {
|
||
const QVariant brut = charge.value(QStringLiteral("corrections"));
|
||
if (brut.userType() != QMetaType::QVariantList && brut.userType() != QMetaType::QStringList) {
|
||
refus = QStringLiteral("MALFORMED");
|
||
return r;
|
||
}
|
||
for (const QVariant &c : brut.toList()) {
|
||
const QString nom = c.toString();
|
||
if (!CorrectionsProducteur.contains(nom)) {
|
||
refus = QStringLiteral("CORRECTION_%1").arg(nom);
|
||
return r;
|
||
}
|
||
if (!r.corrections.contains(nom))
|
||
r.corrections << nom;
|
||
}
|
||
r.corrections = ordonner(r.corrections);
|
||
}
|
||
// `gti[]` et `tempC[]` (v2) ne sont PAS lus ici : la courbe n'en a pas besoin, et un défaut
|
||
// de leur forme ne doit pas faire perdre une prévision utilisable. Leur lecteur viendra avec
|
||
// l'enregistrement (brief 3, T2).
|
||
r.stepS = step;
|
||
// `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;
|
||
}
|
||
|
||
ForecastRevision ForecastStore::sommerPlans(const QList<ForecastRevision> &plans,
|
||
std::optional<int> acLimitW,
|
||
bool *correctionsMelangees)
|
||
{
|
||
ForecastRevision site;
|
||
if (correctionsMelangees)
|
||
*correctionsMelangees = false;
|
||
if (plans.isEmpty())
|
||
return site;
|
||
|
||
const QDateTime run = plans.first().issuedAt;
|
||
const int step = plans.first().stepS;
|
||
QStringList communes = plans.first().corrections;
|
||
bool melangees = false;
|
||
for (const ForecastRevision &r : plans) {
|
||
if (r.issuedAt != run || r.stepS != step)
|
||
return site; // une chimère ou deux grilles : rien plutôt qu'un mélange
|
||
if (r.corrections != plans.first().corrections)
|
||
melangees = true;
|
||
QStringList garde;
|
||
for (const QString &c : communes)
|
||
if (r.corrections.contains(c))
|
||
garde << c;
|
||
communes = garde;
|
||
}
|
||
|
||
site.issuedAt = run;
|
||
site.model = plans.first().model;
|
||
site.stepS = step;
|
||
QMap<qint64, double> somme; // trié par instant, et c'est ce qu'on veut publier
|
||
for (const ForecastRevision &r : plans)
|
||
for (const ForecastPoint &p : r.points)
|
||
somme[p.targetAt.toSecsSinceEpoch()] += p.wh;
|
||
|
||
// APRÈS la somme : l'onduleur plafonne ce qu'il reçoit de tous ses champs, pas chacun d'eux.
|
||
const std::optional<double> plafondWh =
|
||
(acLimitW && step > 0) ? std::optional<double>(double(*acLimitW) * step / 3600.0)
|
||
: std::nullopt;
|
||
for (auto it = somme.constBegin(); it != somme.constEnd(); ++it) {
|
||
ForecastPoint p;
|
||
p.targetAt = QDateTime::fromSecsSinceEpoch(it.key(), QTimeZone::utc());
|
||
p.wh = plafondWh ? qMin(it.value(), *plafondWh) : it.value();
|
||
site.points.append(p);
|
||
}
|
||
if (acLimitW)
|
||
communes << QStringLiteral("ac-limit");
|
||
site.corrections = ordonner(communes);
|
||
if (correctionsMelangees)
|
||
*correctionsMelangees = melangees;
|
||
return site;
|
||
}
|
||
|
||
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;
|
||
}
|