Patrick Schurig 6b66d49f27 feat(prévision PV): lire forecastCurveTransport v1 ET v2, plafond AC de site (brief 3, T1)
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>
2026-10-12 12:59:27 +02:00

494 lines
24 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
}