On attendait la déduplication ; on a trouvé une écrasure. 06:03 RÉVISION du run 00Z — 48 créneaux 07:02 RÉVISION du run 00Z — 48 créneaux <- MÊME RUN Deux écritures pour la même identité de run à une heure d'écart : les valeurs avaient changé sous un issuedAt inchangé, et INSERT OR REPLACE les a remplacées sans rien dire. LA PREUVE A ÉTÉ DÉTRUITE PAR LE DÉFAUT LUI-MÊME. À 07:14 la table et l'état publié coïncidaient parfaitement — l'écrasure avait déjà aligné les deux, et la question « quels créneaux ont changé, les passés ou les futurs ? » est devenue impossible à poser. CORRECTION : un run est immuable. Des valeurs neuves sous une identité connue n'écrasent plus rien — écriture refusée, premières valeurs conservées, anomalie journalisée AVEC ce qui manquait pour décider : combien de créneaux, et le partage passé/futur. Ce partage est LE discriminant et il commande deux conduites opposées. Passés seulement : le producteur fusionne l'observé, légitime chez lui, et c'est NOTRE clé qui est trop courte — il lui faudrait une composante de contenu. Futurs : c'est un run qui n'identifie pas son contenu, donc un défaut à lui remonter. Et un cas neuf pour la famille des modes d'érosion : un défaut qui écrase la donnée qui le prouverait ne laisse pas de preuve, il laisse une COHÉRENCE. À 07:14 tout concordait — exactement ce qu'on aurait vu si tout allait bien. Au passage, le geste du motif s'adapte au nombre de candidats : « désignez-le » devant un seul, « choisissez-en un parmi les N » au-delà. Envoyer chercher une liste qui n'existe pas fait perdre le temps que l'aide prétend gagner, et c'est le pire endroit pour se tromper puisque celui qui la lit ne sait pas déjà. loadmodel 33/33, 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
404 lines
20 KiB
C++
404 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 <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 || p.isEmpty()) {
|
|
refus = QStringLiteral("MALFORMED");
|
|
return r;
|
|
}
|
|
// `issuedAt == 0` SIGNIFIE « CLÉ INCONNUE », et c'est un refus À PART — pas une charge utile
|
|
// mal formée. Le producteur le publie ainsi quand le repli `best_match` est actif : cette
|
|
// stratégie n'a pas d'identité de run unique, donc il n'y a rien à dater.
|
|
//
|
|
// ET SURTOUT : NE JAMAIS Y SUBSTITUER L'HEURE DE RÉCEPTION. Elle serait plausible et fausse
|
|
// — trop courte de toute la latence de publication — et une fois écrite, plus rien dans la
|
|
// donnée ne distinguerait la ligne devinée de la ligne datée. Une révision manquante est une
|
|
// lacune visible ; une révision mal datée est un mensonge invisible.
|
|
if (run <= 0) {
|
|
refus = QStringLiteral("ISSUED_AT_UNKNOWN");
|
|
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;
|
|
}
|
|
}
|
|
|
|
// --- UN RUN EST IMMUABLE ----------------------------------------------------------------
|
|
//
|
|
// Si ce run est DÉJÀ connu et que les valeurs diffèrent, le producteur a modifié une
|
|
// prévision qu'il avait publiée sous cette identité. Écraser serait détruire l'historique
|
|
// EN SILENCE — c'est ce que faisait la version précédente, et le 2026-09-09 à 07:02 elle a
|
|
// remplacé les valeurs de 06:03 sans laisser trace de ce qui avait bougé.
|
|
//
|
|
// On refuse donc, et on le DIT avec de quoi trancher plus tard : combien de créneaux
|
|
// diffèrent, et s'ils sont passés ou à venir. Des créneaux PASSÉS qui bougent seraient une
|
|
// fusion observé/prévu légitime chez le producteur — et imposeraient de changer notre clé.
|
|
// Des créneaux FUTURS qui bougent seraient un run qui n'identifie pas son contenu.
|
|
// Sans ce relevé, la question ne se pose même pas : la donnée qui la trancherait a disparu.
|
|
QSqlQuery connu(m_db);
|
|
connu.prepare(QStringLiteral(
|
|
"SELECT targetAt, forecastWh FROM forecastPv WHERE firstIssuedAt = ?"));
|
|
connu.addBindValue(run);
|
|
QHash<qint64, double> avant;
|
|
if (connu.exec())
|
|
while (connu.next())
|
|
avant.insert(connu.value(0).toLongLong(), connu.value(1).toDouble());
|
|
if (!avant.isEmpty()) {
|
|
const qint64 maintenant = QDateTime::currentSecsSinceEpoch();
|
|
int passes = 0, futurs = 0;
|
|
for (const ForecastPoint &p : revision.points) {
|
|
const qint64 t = p.targetAt.toUTC().toSecsSinceEpoch();
|
|
const auto it = avant.constFind(t);
|
|
if (it == avant.constEnd() || qAbs(it.value() - p.wh) <= EgaliteWh)
|
|
continue;
|
|
(t < maintenant ? passes : futurs)++;
|
|
}
|
|
if (passes + futurs > 0)
|
|
qCWarning(dcNymeaEnergy()).noquote()
|
|
<< QStringLiteral("[Prévision] RUN MUTÉ %1 — %2 créneau(x) modifiés (%3 passés, "
|
|
"%4 à venir) sous une identité de run INCHANGÉE. Écriture "
|
|
"REFUSÉE : les premières valeurs sont conservées.")
|
|
.arg(revision.issuedAt.toUTC().toString(Qt::ISODate))
|
|
.arg(passes + futurs).arg(passes).arg(futurs);
|
|
// Rien à écrire dans les deux cas : identique, la reconduction a déjà été traitée
|
|
// plus haut ; différent, le run est immuable et on ne détruit pas ce qu'on a.
|
|
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;
|
|
}
|