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
This commit is contained in:
parent
2b7240a596
commit
ee6f1732bc
@ -877,3 +877,77 @@ première où il **efface sa propre trace**. La famille s'agrandit d'un cas :
|
||||
Et il rappelle pourquoi l'ordre demandé était le bon : observer **avant** de redéployer. Un
|
||||
redémarrage aurait relu l'état, réécrit la ligne, et rendu les deux hypothèses — « la
|
||||
déduplication marche » et « le redémarrage a tout relu » — définitivement indistinguables.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 20. La clé devient LE CONTENU — `issuedAt` n'identifie pas la courbe (2026-09-09)
|
||||
|
||||
**Réponse mesurée du producteur, et elle renverse le §13 comme le §19.**
|
||||
|
||||
| fait établi | conséquence |
|
||||
|---|---|
|
||||
| `issuedAt` vient de `meteofrance_arome_france_hd`, **seul domaine AROME exposant un `meta.json`** — qui sert **0 valeur de rayonnement sur 72** | il vient d'un domaine qui **ne contribue rien** à la courbe |
|
||||
| `meteofrance_arome_france`, qui fournit le rayonnement, n'expose **aucun** `meta.json` (HTTP 500) | il n'existe **aucune** identité de run pour la donnée qui forme `p` |
|
||||
| à 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 donc pas un défaut chez le producteur : c'est ce que l'API permet.** Et `+etm63`
|
||||
**gelait** la courbe — le seul endroit où le lot était **plus faux qu'avant**.
|
||||
|
||||
### 20.1 La règle n'a pas bougé ; la clé, si
|
||||
|
||||
> *« Une révision n'existe que si la PRÉVISION a changé. »*
|
||||
|
||||
La règle est intacte. Ce qui était faux, c'est la **prémisse** : que le run identifie la
|
||||
prévision. **Regarder le contenu lui-même y est plus fidèle que de regarder une étiquette
|
||||
d'émission** — la formulation le disait déjà, et c'est la clé qui la rattrape.
|
||||
|
||||
```sql
|
||||
targetAt INTEGER -- frontière d'heure, en secondes
|
||||
siteId TEXT -- \
|
||||
contentId TEXT -- > CLÉ : le site et l'empreinte de la courbe
|
||||
firstSeenAt INTEGER -- apparition du contenu, EN MILLISECONDES — ordonne les révisions
|
||||
lastSeenAt INTEGER -- dernière republication à l'identique — fraîcheur
|
||||
issuedAt INTEGER -- ATTRIBUT, peut être NUL
|
||||
model TEXT -- ATTRIBUT
|
||||
PRIMARY KEY (targetAt, siteId, contentId)
|
||||
```
|
||||
|
||||
**Migration v1 → v2 : la table est 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**. La règle du producteur
|
||||
s'applique — *une révision manquante est une lacune visible ; une révision mal datée est un
|
||||
mensonge invisible.*
|
||||
|
||||
### 20.2 Trois conséquences que la mesure impose
|
||||
|
||||
**`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.
|
||||
Il est stocké **NUL**, et aucun instant ne lui est substitué.
|
||||
|
||||
**Le `siteId` entre dans la clé.** Deux plans publient des courbes **différentes** sous le
|
||||
**même** `issuedAt` (tilt, azimut, kWc, PR entrent dans `p`). Ici la granularité est le site,
|
||||
puisqu'on somme — mais le besoin est identique : sans lui, changer de site désigné **mêlerait
|
||||
deux historiques** que rien ne distinguerait plus. La garde d'hier devient **nécessaire**.
|
||||
|
||||
**`t0` est ancré sur le jour local de la REQUÊTE**, pas du run : à minuit, même run, fenêtre
|
||||
décalée. C'est couvert sans rien ajouter — les `targetAt` changent, donc l'empreinte aussi.
|
||||
|
||||
### 20.3 Un défaut trouvé par le test, et il 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 sont donc en
|
||||
**millisecondes**, à la différence de `targetAt` qui est une frontière d'heure.
|
||||
|
||||
> **Un instant d'observation doit distinguer deux observations, sinon il n'ordonne rien.** Sur la
|
||||
> box, un cycle par minute l'aurait caché indéfiniment ; un test qui écrit deux fois d'affilée
|
||||
> l'a sorti tout de suite.
|
||||
|
||||
### 20.4 Ce qui reste une hypothèse, et doit le rester
|
||||
|
||||
Le producteur **n'a pas établi pourquoi** le serveur recompose les premières heures — nowcast
|
||||
satellite, mélange court terme, ou deux domaines sur des runs différents. **Il a mesuré que ça
|
||||
diverge et sur quelles heures.** L'explication n'est pas acquise, et rien ici n'en dépend : la clé
|
||||
de contenu est correcte quelle qu'en soit la cause. C'est précisément pourquoi elle est
|
||||
préférable — elle n'a pas besoin qu'on comprenne le producteur pour être juste.
|
||||
|
||||
@ -2384,6 +2384,7 @@ void EnergyArbitrator::lireCourbePrevision()
|
||||
return;
|
||||
|
||||
ForecastRevision site;
|
||||
site.siteId = m_forecastSiteId;
|
||||
site.issuedAt = run;
|
||||
site.model = plans.first().model;
|
||||
QMap<qint64, double> somme; // trié par instant, et c'est ce qu'on veut publier
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
#include <QSqlError>
|
||||
#include <QSqlQuery>
|
||||
#include <QUuid>
|
||||
#include <QCryptographicHash>
|
||||
#include <QVariant>
|
||||
#include <QLoggingCategory>
|
||||
#include <nymeasettings.h>
|
||||
@ -15,7 +16,8 @@ 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;
|
||||
//! 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;
|
||||
@ -67,20 +69,18 @@ ForecastRevision ForecastStore::depuisTransport(const QVariantMap &charge, QStri
|
||||
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.
|
||||
// `issuedAt == 0` SIGNIFIE « ÂGE INCONNU » — et ce n'est PAS un refus.
|
||||
//
|
||||
// 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;
|
||||
}
|
||||
// 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.
|
||||
|
||||
r.issuedAt = QDateTime::fromSecsSinceEpoch(run, QTimeZone::utc());
|
||||
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;
|
||||
@ -130,27 +130,59 @@ ForecastStore::~ForecastStore()
|
||||
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é.
|
||||
// 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 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))"))
|
||||
" 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 idxForecastIssued ON forecastPv(firstIssuedAt)"))
|
||||
"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();
|
||||
@ -161,134 +193,77 @@ 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É ---------------------------------
|
||||
// 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 ».
|
||||
//
|
||||
// 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.
|
||||
// `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;
|
||||
}
|
||||
|
||||
// --- écriture d'une VRAIE révision, en une transaction ----------------------------------
|
||||
// --- 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, firstIssuedAt, lastConfirmedAt, forecastWh, model) VALUES (?, ?, ?, ?, ?)"));
|
||||
"(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(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(revision.siteId);
|
||||
ins.addBindValue(empreinte);
|
||||
ins.addBindValue(vu);
|
||||
ins.addBindValue(vu); // à l'apparition, les deux instants coïncident
|
||||
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.
|
||||
// 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()) {
|
||||
@ -302,10 +277,12 @@ bool ForecastStore::enregistrer(const ForecastRevision &revision)
|
||||
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))
|
||||
<< 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(revision.model.isEmpty() ? QStringLiteral("(inconnu)") : revision.model)
|
||||
.arg(ageConnu ? revision.issuedAt.toUTC().toString(Qt::ISODate)
|
||||
: QStringLiteral("(inconnu)"));
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -322,12 +299,12 @@ QList<ForecastPoint> ForecastStore::courbe(const QDateTime &de, const QDateTime
|
||||
// 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 <= ?) "
|
||||
"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().toSecsSinceEpoch();
|
||||
const qint64 limite = borne.toUTC().toMSecsSinceEpoch(); // firstSeenAt est en ms
|
||||
q.addBindValue(de.toUTC().toSecsSinceEpoch());
|
||||
q.addBindValue(a.toUTC().toSecsSinceEpoch());
|
||||
q.addBindValue(limite);
|
||||
@ -343,11 +320,11 @@ QList<ForecastPoint> ForecastStore::courbe(const QDateTime &de, const QDateTime
|
||||
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
|
||||
// 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.firstIssuedAt = QDateTime::fromSecsSinceEpoch(q.value(2).toLongLong(), QTimeZone::utc());
|
||||
p.lastConfirmedAt = QDateTime::fromSecsSinceEpoch(q.value(3).toLongLong(), QTimeZone::utc());
|
||||
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;
|
||||
@ -360,12 +337,12 @@ QList<QPair<QDateTime, double>> ForecastStore::revisions(const QDateTime &target
|
||||
return sortie;
|
||||
QSqlQuery q(m_db);
|
||||
q.prepare(QStringLiteral(
|
||||
"SELECT firstIssuedAt, forecastWh FROM forecastPv WHERE targetAt = ? ORDER BY firstIssuedAt"));
|
||||
"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::fromSecsSinceEpoch(q.value(0).toLongLong(), QTimeZone::utc()),
|
||||
sortie.append({ QDateTime::fromMSecsSinceEpoch(q.value(0).toLongLong(), QTimeZone::utc()),
|
||||
q.value(1).toDouble() });
|
||||
return sortie;
|
||||
}
|
||||
@ -385,10 +362,10 @@ int ForecastStore::purger(const QDateTime &maintenant)
|
||||
// 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"
|
||||
"DELETE FROM forecastPv WHERE targetAt < ? AND firstSeenAt NOT IN ("
|
||||
" SELECT MIN(firstSeenAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt"
|
||||
" UNION"
|
||||
" SELECT MAX(firstIssuedAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt)"));
|
||||
" 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();
|
||||
|
||||
@ -44,20 +44,22 @@ struct ForecastPoint {
|
||||
//! Énergie prévue sur l'heure (Wh). Une ABSENCE ne s'écrit pas : elle ne crée pas de point.
|
||||
double wh = 0;
|
||||
/*!
|
||||
* \brief Run qui a produit ces valeurs EN PREMIER — **l'identité de la prévision**.
|
||||
* \brief Instant où ce CONTENU est apparu pour la première fois — l'ordre des révisions.
|
||||
*
|
||||
* Renseigné à la lecture ; ignoré à l'écriture. C'est LUI qui date la prévision, et lui seul.
|
||||
* Renseigné à la lecture ; ignoré à l'écriture. C'est un instant d'OBSERVATION, pas
|
||||
* d'émission : depuis qu'on sait que \c issuedAt n'identifie pas le contenu, la seule chose
|
||||
* qui ordonne les révisions est le moment où on les a vues.
|
||||
*/
|
||||
QDateTime firstIssuedAt;
|
||||
QDateTime firstSeenAt;
|
||||
/*!
|
||||
* \brief Dernier run qui a AFFIRMÉ ces valeurs — la **fraîcheur**.
|
||||
* \brief Dernière fois que ce contenu a été republié — la **fraîcheur**.
|
||||
*
|
||||
* \warning **JAMAIS une clé de révision.** \c firstIssuedAt identifie la prévision ;
|
||||
* \warning **JAMAIS une clé de révision.** \c firstSeenAt ordonne les révisions ;
|
||||
* celui-ci dit seulement **jusqu'à quand elle a tenu**. Dater une prévision avec lui la
|
||||
* ferait paraître née au moment où elle a été reconduite — et deux prévisions différentes
|
||||
* reconduites par le même run porteraient la même date.
|
||||
*
|
||||
* \par Sur une ligne JAMAIS RECONDUITE, il vaut \c firstIssuedAt — jamais absent
|
||||
* \par Sur une ligne JAMAIS REPUBLIÉE, il vaut \c firstSeenAt — jamais absent
|
||||
* Et ce n'est pas une copie déguisée en confirmation : le champ ne dit pas « quand
|
||||
* quelqu'un a confirmé », il dit **« quel est le dernier run qui a affirmé ces valeurs »**.
|
||||
* Le run émetteur les a affirmées. La réponse est donc vraie, et elle l'est uniformément.
|
||||
@ -67,9 +69,9 @@ struct ForecastPoint {
|
||||
* client à écrire le repli `confirmedAt ?? issuedAt`, c'est-à-dire à refaire ce calcul-ci,
|
||||
* ailleurs, une fois par client, avec une chance de le faire de travers.
|
||||
*
|
||||
* \invariant Toujours valide, et toujours `>= firstIssuedAt`.
|
||||
* \invariant Toujours valide, et toujours `>= firstSeenAt`.
|
||||
*/
|
||||
QDateTime lastConfirmedAt;
|
||||
QDateTime lastSeenAt;
|
||||
};
|
||||
|
||||
/*!
|
||||
@ -77,20 +79,40 @@ struct ForecastPoint {
|
||||
*/
|
||||
struct ForecastRevision {
|
||||
/*!
|
||||
* \brief Instant d'INITIALISATION DU RUN, UTC — le vrai, pas l'heure de réception.
|
||||
* \brief Âge APPROCHÉ de la prévision — un **attribut**, jamais une clé. Peut être invalide.
|
||||
*
|
||||
* Il **voyage dans la charge utile** que publie le producteur (`issuedAt`), donc sans second
|
||||
* appel à l'API. C'est ce qui rend la clé de révision **exacte**, et non approchée.
|
||||
* \warning **`issuedAt` N'IDENTIFIE PAS LE CONTENU**, et ce n'est pas un défaut du
|
||||
* producteur : c'est ce que l'API permet. Mesuré le 2026-09-09 —
|
||||
* - il vient de `meteofrance_arome_france_hd`, **le seul domaine AROME exposant un
|
||||
* `meta.json`**, qui ne fournit **aucune** des valeurs de rayonnement de la courbe ;
|
||||
* - le domaine qui les fournit (`meteofrance_arome_france`) n'expose **pas** de
|
||||
* `meta.json` (HTTP 500) : il n'existe **aucune** identité de run pour la donnée qui
|
||||
* forme la courbe ;
|
||||
* - le serveur recompose les premières heures autrement que par le seul run, si bien
|
||||
* qu'elles bougent **sans** que `issuedAt` bouge ;
|
||||
* - `t0` est ancré sur le jour local de la REQUÊTE, pas sur le run ;
|
||||
* - sous le repli `best_match`, il vaut **0** ;
|
||||
* - deux plans d'un même site publient deux courbes **différentes** sous le **même**
|
||||
* `issuedAt`.
|
||||
*
|
||||
* \par Ce que cela change
|
||||
* Une première version indexait sur l'instant de RÉCEPTION, faute de mieux, et interdisait
|
||||
* d'en calculer un horizon : la latence de publication d'un modèle se compte en heures.
|
||||
* **Cette réserve est levée** — l'heure du run est un horizon calculable.
|
||||
* \note Il reste utile, et il est conservé : il répond correctement à « qu'est-ce qu'on
|
||||
* prévoyait ce matin ». Il ne répond pas à « est-ce la même prévision ».
|
||||
*
|
||||
* \note Ce champ porte l'instant du run QUI ARRIVE. Le magasin décide ensuite s'il ouvre
|
||||
* une révision (\c firstIssuedAt) ou s'il en reconduit une (\c lastConfirmedAt).
|
||||
* \note **Invalide est une valeur acceptable.** Une charge utile sans âge est enregistrée
|
||||
* quand même : refuser une prévision utilisable parce qu'un ATTRIBUT manque reviendrait à
|
||||
* jeter la donnée pour la métadonnée.
|
||||
*/
|
||||
QDateTime issuedAt;
|
||||
/*!
|
||||
* \brief Le SITE dont cette courbe est la somme — partie de la clé.
|
||||
*
|
||||
* Recommandation du producteur : la clé porte l'identifiant du Thing, parce que deux plans
|
||||
* publient des courbes DIFFÉRENTES sous le même \c issuedAt (tilt, azimut, kWc et PR entrent
|
||||
* dans les valeurs). Ici la granularité est le SITE, puisqu'on somme ses plans — mais le
|
||||
* besoin est le même : sans lui, changer de site désigné mêlerait deux historiques dans une
|
||||
* table qui n'a aucun moyen de les distinguer.
|
||||
*/
|
||||
QString siteId;
|
||||
//! Les points de la courbe. Une révision **vide n'est pas enregistrée** : voir \c enregistrer().
|
||||
QList<ForecastPoint> points;
|
||||
/*!
|
||||
@ -164,30 +186,29 @@ public:
|
||||
* vide, ou en cas d'échec.
|
||||
*
|
||||
* \par UNE RÉVISION N'EXISTE QUE SI LA PRÉVISION A CHANGÉ
|
||||
* La déduplication porte sur **les valeurs**, comparées sur les créneaux communs. Un run
|
||||
* plus récent qui reconduit les mêmes chiffres **ne crée pas de ligne** : il met à jour
|
||||
* \c lastConfirmedAt sur les lignes existantes.
|
||||
* L'identité d'une révision est **le contenu lui-même** — une empreinte de la courbe
|
||||
* entière. Une republication à l'identique **ne crée pas de ligne** : elle met à jour
|
||||
* \c lastSeenAt. Un contenu neuf ouvre une révision, que \c issuedAt ait bougé ou non.
|
||||
*
|
||||
* C'est **plus fidèle** à la règle que la clé de run ne l'était : la règle a toujours dit
|
||||
* « si la PRÉVISION a changé », et c'est la prévision qu'on regarde désormais.
|
||||
*
|
||||
* On ne stocke pas une confirmation pour un consommateur qui n'existe pas : le
|
||||
* consommateur d'aujourd'hui est un **affichage**, et un affichage ne lit pas une
|
||||
* confirmation. C'est le raisonnement qui a déjà écarté la requête `meta.json`, et il vaut
|
||||
* ici pour la même raison.
|
||||
*
|
||||
* \par DEUX INSTANTS, DEUX NOMS — et c'est la leçon d'hier appliquée
|
||||
* Un seul champ ne peut pas porter les deux faits sans mentir sur l'un :
|
||||
* - \c firstIssuedAt : 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.
|
||||
* - \c lastConfirmedAt : le dernier run qui les a **reconduites**. Mis à jour en place.
|
||||
* \par DEUX INSTANTS, DEUX NOMS
|
||||
* - \c firstSeenAt : quand ce contenu est apparu. Il ORDONNE les révisions.
|
||||
* - \c lastSeenAt : la dernière republication à l'identique. Mis à jour en place.
|
||||
*
|
||||
* Les garder confondus reproduirait exactement le piège `receivedAt`/`issuedAt` : deux
|
||||
* instants voisins, un nom qui promet le mauvais. Quelqu'un lirait « prévu à 06Z » sur une
|
||||
* ligne que le run 12Z vient de reconduire, et croirait la prévision vieille de six heures
|
||||
* alors qu'elle est fraîche. **La fraîcheur est \c lastConfirmedAt ; l'ancienneté de
|
||||
* l'avis est \c firstIssuedAt.**
|
||||
* Les garder confondus reproduirait le piège déjà payé deux fois : deux instants voisins,
|
||||
* un nom qui promet le mauvais. **La FRAÎCHEUR est \c lastSeenAt ; l'APPARITION du
|
||||
* contenu est \c firstSeenAt.**
|
||||
*
|
||||
* \note **Cette déduplication est le DERNIER REMPART, pas un filet.** Celle du producteur
|
||||
* compare la charge utile entière, où \c issuedAt figure : elle ne mord donc jamais sur ce
|
||||
* cas. Si celle-ci se trompe, personne ne rattrape.
|
||||
* compare la charge utile entière : elle ne peut pas voir que deux charges utiles
|
||||
* différentes portent la même courbe. Si celle-ci se trompe, personne ne rattrape.
|
||||
*
|
||||
* \note Ce que l'on ne conserve PAS : la LISTE des runs ayant confirmé. Le jour où un
|
||||
* apprentissage corrélera l'erreur à l'horizon, il en aura peut-être besoin — la décision
|
||||
|
||||
@ -312,28 +312,14 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
||||
"NEVER a measurement: the conversion ignores shading, soiling, module "
|
||||
"temperature and inverter clipping - all optimistic - so the curve is a "
|
||||
"CEILING, systematically above what will be produced. Hours with no forecast "
|
||||
"are ABSENT from the list, never zero: a zero would read as 'we expect night'. "
|
||||
"'knownAt' makes the read reproducible - it returns the curve as it was known "
|
||||
"at that instant, which is how 'what did we forecast this morning' is asked. "
|
||||
"A revision exists only when the forecast CHANGED: a later run carrying the "
|
||||
"same numbers does not create one, it refreshes 'lastConfirmedAt'. TWO "
|
||||
"INSTANTS, TWO NAMES - 'firstIssuedAt' is the run that first produced these "
|
||||
"values (age of the OPINION, and the row's identity); 'lastConfirmedAt' is the "
|
||||
"last run that reconducted them (FRESHNESS). Do not conflate them: a value "
|
||||
"first issued at 06Z and reconducted at 12Z is fresh, not six hours old.";
|
||||
params.insert("from", enumValueName(String)); // ISO 8601 UTC
|
||||
params.insert("to", enumValueName(String)); // ISO 8601 UTC
|
||||
params.insert("o:knownAt", enumValueName(String)); // ISO 8601 UTC ; absent = maintenant
|
||||
QVariantMap pointItem;
|
||||
pointItem.insert("targetAt", enumValueName(String)); // ISO 8601 UTC
|
||||
pointItem.insert("forecastWh", enumValueName(Double));
|
||||
// DEUX INSTANTS, DEUX NOMS. `firstIssuedAt` est le run qui a produit ces valeurs EN PREMIER
|
||||
// — l'ancienneté de l'AVIS. `lastConfirmedAt` est le dernier run qui les a reconduites — la
|
||||
// FRAÎCHEUR. Les confondre ferait lire « prévu à 06Z » sur une prévision que le run de 12Z
|
||||
// vient de reconduire, et croire vieille de six heures une valeur qui est fraîche.
|
||||
pointItem.insert("firstIssuedAt", enumValueName(String)); // ISO 8601 UTC
|
||||
pointItem.insert("lastConfirmedAt", enumValueName(String)); // ISO 8601 UTC
|
||||
returns.insert("forecastPoints", QVariantList() << pointItem);
|
||||
"are ABSENT, never zero: a zero would read as 'we expect night'. 'knownAt' "
|
||||
"makes the read reproducible - it returns the curve as it was known at that "
|
||||
"instant. A revision exists only when the FORECAST CHANGED; the provider's "
|
||||
"run id does NOT identify the content (it comes from a domain that supplies "
|
||||
"none of the curve's values), so revisions are keyed on the content itself. "
|
||||
"'firstSeenAt' is when this content first appeared - it ORDERS revisions; "
|
||||
"'lastSeenAt' is the last time it was republished unchanged - FRESHNESS. "
|
||||
"Neither is an emission time, and no horizon may be computed from them.";
|
||||
registerMethod("GetPvForecast", description, params, returns, Types::PermissionScopeControlThings);
|
||||
|
||||
// Désignation GLOBALE, sur le modèle d'Energy.SetRootMeter — même nature : une source unique
|
||||
@ -1118,8 +1104,8 @@ JsonReply *NymeaEnergyJsonHandler::GetPvForecast(const QVariantMap ¶ms)
|
||||
points.append(QVariantMap{
|
||||
{"targetAt", p.targetAt.toUTC().toString(Qt::ISODate)},
|
||||
{"forecastWh", p.wh},
|
||||
{"firstIssuedAt", p.firstIssuedAt.toUTC().toString(Qt::ISODate)},
|
||||
{"lastConfirmedAt", p.lastConfirmedAt.toUTC().toString(Qt::ISODate)}});
|
||||
{"firstSeenAt", p.firstSeenAt.toUTC().toString(Qt::ISODate)},
|
||||
{"lastSeenAt", p.lastSeenAt.toUTC().toString(Qt::ISODate)}});
|
||||
}
|
||||
return createReply({{"forecastPoints", points}});
|
||||
#else
|
||||
|
||||
@ -927,14 +927,14 @@ void TestLoadModel::testDrawMarginComparesHeadroomsNotCaps()
|
||||
}
|
||||
|
||||
namespace {
|
||||
//! Fabrique une révision : heures consécutives à partir de \a depuis, une valeur par heure.
|
||||
ForecastRevision revision(const QDateTime &recue, const QDateTime &depuis,
|
||||
const QList<double> &wh)
|
||||
//! Fabrique une révision : créneaux consécutifs à partir de \a depuis, une valeur par créneau.
|
||||
ForecastRevision revision(const QDateTime &age, const QDateTime &depuis, const QList<double> &wh,
|
||||
const QString &site = QStringLiteral("site-A"))
|
||||
{
|
||||
ForecastRevision r;
|
||||
r.issuedAt = recue;
|
||||
r.issuedAt = age; // ATTRIBUT, jamais la clé
|
||||
r.siteId = site;
|
||||
for (int i = 0; i < wh.count(); ++i) {
|
||||
// Les deux instants de LECTURE ne se renseignent pas à l'écriture : le magasin les pose.
|
||||
ForecastPoint p;
|
||||
p.targetAt = depuis.addSecs(i * 3600);
|
||||
p.wh = wh.at(i);
|
||||
@ -944,58 +944,57 @@ ForecastRevision revision(const QDateTime &recue, const QDateTime &depuis,
|
||||
}
|
||||
}
|
||||
|
||||
void TestLoadModel::testARevisionExistsOnlyIfTheForecastChanged()
|
||||
void TestLoadModel::testTheContentIsTheRevisionIdentity()
|
||||
{
|
||||
// \rule{LM-1500} n'a pas bougé : « une révision n'existe que si la PRÉVISION a changé ».
|
||||
// C'est la CLÉ qui a changé, parce que la prémisse a été mesurée fausse le 2026-09-09 :
|
||||
// `issuedAt` vient d'un domaine qui ne fournit AUCUNE valeur de la courbe, et le serveur
|
||||
// recompose les premières heures sans le faire bouger. Regarder le contenu est donc PLUS
|
||||
// fidèle à la règle que regarder l'étiquette d'émission.
|
||||
QTemporaryDir dir;
|
||||
ForecastStore magasin(dir.filePath("forecast.sqlite"));
|
||||
QVERIFY(magasin.isValid());
|
||||
|
||||
const QDateTime run06 = QDateTime(QDate(2026, 9, 9), QTime(6, 0), QTimeZone::utc());
|
||||
const QDateTime run12 = QDateTime(QDate(2026, 9, 9), QTime(12, 0), QTimeZone::utc());
|
||||
const QDateTime run18 = QDateTime(QDate(2026, 9, 9), QTime(18, 0), QTimeZone::utc());
|
||||
const QDateTime run00 = QDateTime(QDate(2026, 9, 9), QTime(0, 0), QTimeZone::utc());
|
||||
const QDateTime t0 = QDateTime(QDate(2026, 9, 9), QTime(10, 0), QTimeZone::utc());
|
||||
|
||||
// 1. Premier run : une révision s'ouvre.
|
||||
QVERIFY(magasin.enregistrer(revision(run06, t0, {100, 200, 300, 400})));
|
||||
QVERIFY(magasin.enregistrer(revision(run00, t0, {100, 200, 300})));
|
||||
QCOMPARE(magasin.revisions(t0).count(), 1);
|
||||
|
||||
// 2. LE RUN DE 12Z RECONDUIT LES MÊMES CHIFFRES — ce n'est PAS une révision. On ne stocke
|
||||
// pas une confirmation pour un consommateur qui n'existe pas : celui d'aujourd'hui est un
|
||||
// affichage, et un affichage ne lit pas une confirmation. Même raisonnement que celui qui
|
||||
// a écarté la requête meta.json.
|
||||
QVERIFY2(!magasin.enregistrer(revision(run12, t0, {100, 200, 300, 400})),
|
||||
"un run qui reconduit les mêmes valeurs n'ouvre pas de révision");
|
||||
// 1. MÊME CONTENU republié — pas une révision, quoi qu'ait fait l'âge. La comparaison porte
|
||||
// sur la courbe, pas sur la charge utile : deux charges utiles différentes portant la
|
||||
// même courbe sont la même prévision, ce qu'aucune égalité de charge utile ne peut voir.
|
||||
QVERIFY(!magasin.enregistrer(revision(run00.addSecs(6 * 3600), t0, {100, 200, 300})));
|
||||
QCOMPARE(magasin.revisions(t0).count(), 1);
|
||||
|
||||
// 3. MAIS LA FRAÎCHEUR A ÉTÉ MISE À JOUR, et c'est là que les DEUX instants se séparent.
|
||||
// Les confondre ferait lire « prévu à 06Z » sur une ligne que le run 12Z vient de
|
||||
// reconduire — la prévision paraîtrait vieille de six heures alors qu'elle est fraîche.
|
||||
// C'est le piège receivedAt/issuedAt de la veille, déplacé d'un cran.
|
||||
QList<ForecastPoint> vue = magasin.courbe(t0, t0.addSecs(3 * 3600), run12.addSecs(60));
|
||||
QCOMPARE(vue.count(), 4);
|
||||
QCOMPARE(vue.first().firstIssuedAt, run06); // ANCIENNETÉ DE L'AVIS
|
||||
QCOMPARE(vue.first().lastConfirmedAt, run12); // FRAÎCHEUR
|
||||
QVERIFY2(vue.first().firstIssuedAt != vue.first().lastConfirmedAt,
|
||||
"les deux instants doivent pouvoir diverger — sinon l'un des deux ment");
|
||||
|
||||
// 4. UNE VALEUR QUI CHANGE OUVRE UNE RÉVISION, et les deux instants se rejoignent.
|
||||
// Contre-épreuve du cas 2 : sans elle, on ne saurait pas si la déduplication distingue
|
||||
// quoi que ce soit ou refuse tout.
|
||||
QVERIFY(magasin.enregistrer(revision(run18, t0, {100, 999, 300, 400})));
|
||||
// 2. LE CŒUR DE LA CORRECTION — un contenu NEUF sous un `issuedAt` INCHANGÉ **EST** une
|
||||
// révision. La version précédente la refusait : elle gelait la courbe sur les valeurs du
|
||||
// premier cycle, et l'écran aurait montré une prévision périmée sans le dire — un
|
||||
// instrument qui continue d'indiquer une ancienne mesure, pire que pas d'instrument.
|
||||
QTest::qWait(5);
|
||||
QVERIFY2(magasin.enregistrer(revision(run00, t0, {100, 168, 300})),
|
||||
"un contenu neuf sous le MÊME issuedAt est une révision — sinon la courbe gèle");
|
||||
QCOMPARE(magasin.revisions(t0).count(), 2);
|
||||
vue = magasin.courbe(t0, t0.addSecs(3 * 3600), run18.addSecs(60));
|
||||
QCOMPARE(vue.first().firstIssuedAt, run18);
|
||||
QCOMPARE(vue.first().lastConfirmedAt, run18); // à l'ouverture, ils coïncident
|
||||
const QList<ForecastPoint> vue = magasin.courbe(t0, t0.addSecs(2 * 3600));
|
||||
QCOMPARE(qRound(vue.at(1).wh), 168); // la valeur FRAÎCHE, pas celle de 06:03
|
||||
|
||||
// 5. LA CLÉ NE BOUGE PAS SOUS LA LIGNE. Une reconduction met à jour la fraîcheur et rien
|
||||
// d'autre : la révision de 06Z reste identifiée par 06Z, sans quoi l'identité de la ligne
|
||||
// changerait et l'historique deviendrait illisible.
|
||||
const QList<QPair<QDateTime, double>> revs = magasin.revisions(t0);
|
||||
QCOMPARE(revs.first().first, run06);
|
||||
QCOMPARE(revs.last().first, run18);
|
||||
// 3. UN ÂGE INCONNU N'EST PLUS UN REFUS. `best_match` publie `issuedAt = 0` : jeter une
|
||||
// prévision utilisable parce qu'un ATTRIBUT manque, ce serait jeter la donnée pour son
|
||||
// étiquette.
|
||||
QTest::qWait(5);
|
||||
QVERIFY2(magasin.enregistrer(revision(QDateTime(), t0, {111, 222, 333})),
|
||||
"une courbe sans âge connu reste enregistrable");
|
||||
QCOMPARE(magasin.revisions(t0).count(), 3);
|
||||
|
||||
// 6. Une révision VIDE n'en est pas une : l'absence est un trou, pas une courbe de zéros.
|
||||
ForecastRevision vide; vide.issuedAt = run18.addSecs(3600);
|
||||
// 4. DEUX SITES NE SE MÊLENT PAS. Sans le site dans la clé, changer de site désigné
|
||||
// mêlerait deux historiques que rien ne distinguerait plus — et deux plans publient
|
||||
// des courbes différentes sous le même issuedAt, donc le besoin est réel.
|
||||
QTest::qWait(5);
|
||||
QVERIFY(magasin.enregistrer(revision(run00, t0, {100, 200, 300}, QStringLiteral("site-B"))));
|
||||
QCOMPARE(magasin.revisions(t0).count(), 4); // la même courbe, mais d'un AUTRE site
|
||||
|
||||
// 5. Une révision VIDE n'en est pas une : l'absence est un trou, pas une courbe de zéros.
|
||||
ForecastRevision vide; vide.issuedAt = run00; vide.siteId = QStringLiteral("site-A");
|
||||
QVERIFY(!magasin.enregistrer(vide));
|
||||
}
|
||||
|
||||
@ -1004,37 +1003,36 @@ void TestLoadModel::testTheCurveIsReproducibleAndKeepsItsHoles()
|
||||
QTemporaryDir dir;
|
||||
ForecastStore magasin(dir.filePath("forecast.sqlite"));
|
||||
QVERIFY(magasin.isValid());
|
||||
const QDateTime t0 = QDateTime(QDate(2026, 9, 9), QTime(12, 0), QTimeZone::utc());
|
||||
|
||||
const QDateTime t0 = QDateTime(QDate(2026, 9, 9), QTime(12, 0), QTimeZone::utc());
|
||||
const QDateTime r08 = QDateTime(QDate(2026, 9, 9), QTime(8, 0), QTimeZone::utc());
|
||||
const QDateTime r14 = QDateTime(QDate(2026, 9, 9), QTime(14, 0), QTimeZone::utc());
|
||||
QVERIFY(magasin.enregistrer(revision(t0, t0, {1000, 1100, 1200})));
|
||||
const QDateTime avant = QDateTime::currentDateTimeUtc();
|
||||
QTest::qWait(5); // les instants d'OBSERVATION sont en ms
|
||||
QVERIFY(magasin.enregistrer(revision(t0, t0, {700, 800, 900})));
|
||||
|
||||
QVERIFY(magasin.enregistrer(revision(r08, t0, {1000, 1100, 1200}))); // « à 8 h »
|
||||
QVERIFY(magasin.enregistrer(revision(r14, t0, {700, 800, 900}))); // révisé « à 14 h »
|
||||
// « CE QU'ON PRÉVOYAIT À TEL MOMENT » se demande en fixant l'instant de connaissance. Sans
|
||||
// ce paramètre, la comparaison prévu/réalisé n'aurait pas d'horizon nommé, et deux écrans
|
||||
// montreraient deux courbes pour le même jour.
|
||||
const QList<ForecastPoint> tot = magasin.courbe(t0, t0.addSecs(7200), avant);
|
||||
QCOMPARE(tot.count(), 3);
|
||||
QCOMPARE(qRound(tot.first().wh), 1000);
|
||||
const QList<ForecastPoint> tard = magasin.courbe(t0, t0.addSecs(7200));
|
||||
QCOMPARE(qRound(tard.first().wh), 700);
|
||||
|
||||
// « CE QU'ON PRÉVOYAIT À 8 H » est une question à part entière, et elle se pose en fixant
|
||||
// l'instant de connaissance. Sans ce paramètre, la comparaison prévu/réalisé n'aurait pas
|
||||
// d'horizon nommé, et deux écrans montreraient deux courbes pour le même jour.
|
||||
const QList<ForecastPoint> vue08 = magasin.courbe(t0, t0.addSecs(7200), r08.addSecs(60));
|
||||
QCOMPARE(vue08.count(), 3);
|
||||
QCOMPARE(qRound(vue08.at(0).wh), 1000);
|
||||
|
||||
// La même fenêtre, connue plus tard : la révision de 14 h l'emporte.
|
||||
const QList<ForecastPoint> vue14 = magasin.courbe(t0, t0.addSecs(7200), r14.addSecs(60));
|
||||
QCOMPARE(vue14.count(), 3);
|
||||
QCOMPARE(qRound(vue14.at(0).wh), 700);
|
||||
|
||||
// Les DEUX révisions coexistent pour la même heure visée — c'est tout l'objet de la table.
|
||||
// Avec `targetAt` seul pour clé, la seconde aurait écrasé la première.
|
||||
// Les DEUX révisions coexistent pour le même créneau — c'est l'objet de la table.
|
||||
QCOMPARE(magasin.revisions(t0).count(), 2);
|
||||
|
||||
// LES TROUS RESTENT DES TROUS. On demande six heures, trois sont connues : la courbe en rend
|
||||
// trois, jamais six dont trois à zéro — un zéro se lirait « on prévoit une nuit ».
|
||||
const QList<ForecastPoint> large = magasin.courbe(t0, t0.addSecs(6 * 3600), r14.addSecs(60));
|
||||
QCOMPARE(large.count(), 3);
|
||||
// LES TROUS RESTENT DES TROUS : six heures demandées, trois connues, trois rendues. Jamais
|
||||
// six dont trois à zéro — un zéro se lirait « on prévoit une nuit ».
|
||||
QCOMPARE(magasin.courbe(t0, t0.addSecs(6 * 3600)).count(), 3);
|
||||
QVERIFY(magasin.courbe(t0.addDays(5), t0.addDays(5).addSecs(7200)).isEmpty());
|
||||
|
||||
// Et une fenêtre entièrement inconnue rend une liste VIDE, pas une courbe de zéros.
|
||||
QVERIFY(magasin.courbe(t0.addDays(5), t0.addDays(5).addSecs(7200), r14.addSecs(60)).isEmpty());
|
||||
// La FRAÎCHEUR n'est jamais absente, et jamais antérieure à l'apparition.
|
||||
for (const ForecastPoint &p : tard) {
|
||||
QVERIFY(p.lastSeenAt.isValid());
|
||||
QVERIFY2(p.lastSeenAt >= p.firstSeenAt,
|
||||
"une prévision ne peut pas avoir été revue avant d'être apparue");
|
||||
}
|
||||
}
|
||||
|
||||
void TestLoadModel::testRetentionKeepsBothEndsOfEachRevision()
|
||||
@ -1043,218 +1041,79 @@ void TestLoadModel::testRetentionKeepsBothEndsOfEachRevision()
|
||||
ForecastStore magasin(dir.filePath("forecast.sqlite"));
|
||||
QVERIFY(magasin.isValid());
|
||||
|
||||
// Une heure visée ancienne, quatre révisions successives.
|
||||
const QDateTime cible = QDateTime(QDate(2026, 9, 1), QTime(12, 0), QTimeZone::utc());
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
ForecastRevision r;
|
||||
r.issuedAt = cible.addSecs(-(4 - i) * 3600);
|
||||
ForecastPoint pt;
|
||||
pt.targetAt = cible;
|
||||
pt.wh = 1000.0 + i;
|
||||
r.points.append(pt);
|
||||
QVERIFY(magasin.enregistrer(r));
|
||||
QVERIFY(magasin.enregistrer(revision(cible, cible, {1000.0 + i})));
|
||||
QTest::qWait(5); // quatre apparitions distinctes
|
||||
}
|
||||
QCOMPARE(magasin.revisions(cible).count(), 4);
|
||||
|
||||
// Purge prise BIEN APRÈS : la fenêtre pleine de sept jours est dépassée.
|
||||
const int retires = magasin.purger(cible.addDays(30));
|
||||
QCOMPARE(retires, 2); // les deux intermédiaires
|
||||
|
||||
// LES DEUX BOUTS SURVIVENT, et c'est le cœur de la règle : la première répond à « la veille
|
||||
// valait-elle quelque chose ? », la dernière à « que pouvait-on savoir de mieux ? ».
|
||||
// N'en garder qu'une détruirait la révision, c'est-à-dire l'objet de la table.
|
||||
// Purge bien au-delà de la fenêtre pleine : seuls les DEUX BOUTS survivent — la première
|
||||
// apparition et la dernière. Les deux répondent aux deux seules questions qui se posent ;
|
||||
// n'en garder qu'une détruirait la révision, c'est-à-dire l'objet de la table.
|
||||
QCOMPARE(magasin.purger(cible.addDays(30)), 2);
|
||||
const QList<QPair<QDateTime, double>> restant = magasin.revisions(cible);
|
||||
QCOMPARE(restant.count(), 2);
|
||||
QCOMPARE(qRound(restant.first().second), 1000); // horizon le plus long
|
||||
QCOMPARE(qRound(restant.last().second), 1003); // meilleure estimation
|
||||
QCOMPARE(qRound(restant.first().second), 1000);
|
||||
QCOMPARE(qRound(restant.last().second), 1003);
|
||||
|
||||
// CONTRE-ÉPREUVE — une heure visée RÉCENTE garde toutes ses révisions. Sans elle, une purge
|
||||
// qui viderait tout passerait pour une purge correcte.
|
||||
const QDateTime recente = QDateTime::currentDateTimeUtc().addSecs(3600);
|
||||
// CONTRE-ÉPREUVE — un créneau RÉCENT garde toutes ses révisions. Sans elle, une purge qui
|
||||
// viderait tout passerait pour une purge correcte.
|
||||
const QDateTime recent = QDateTime::currentDateTimeUtc().addSecs(3600);
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
ForecastRevision r;
|
||||
r.issuedAt = QDateTime::currentDateTimeUtc().addSecs(i * 60);
|
||||
ForecastPoint pt;
|
||||
pt.targetAt = recente;
|
||||
pt.wh = 500.0 + i;
|
||||
r.points.append(pt);
|
||||
QVERIFY(magasin.enregistrer(r));
|
||||
QVERIFY(magasin.enregistrer(revision(recent, recent, {500.0 + i})));
|
||||
QTest::qWait(5);
|
||||
}
|
||||
magasin.purger();
|
||||
QCOMPARE(magasin.revisions(recente).count(), 3);
|
||||
QCOMPARE(magasin.revisions(recent).count(), 3);
|
||||
}
|
||||
|
||||
void TestLoadModel::testAnUnknownPayloadIsRefusedNotBestEffort()
|
||||
void TestLoadModel::testAPayloadIsRefusedOnFormNeverOnAMissingAttribute()
|
||||
{
|
||||
// La charge utile traverse une frontière de PROCESSUS : aucun compilateur ne vérifie sa
|
||||
// forme. Une lecture « au mieux » d'une version inconnue produirait une courbe fausse qui
|
||||
// aurait l'air de marcher, et le symptôme d'un changement de forme serait « courbe vide » —
|
||||
// indistinguable d'une nuit.
|
||||
const qint64 t0 = QDateTime(QDate(2026, 9, 9), QTime(0, 0), QTimeZone::utc()).toSecsSinceEpoch();
|
||||
const qint64 run = QDateTime(QDate(2026, 9, 9), QTime(6, 0), QTimeZone::utc()).toSecsSinceEpoch();
|
||||
auto charge = [&](int v, const QString &unite, const QVariantList &p) {
|
||||
// forme. Mais la FORME et les ATTRIBUTS ne se refusent pas de la même façon — refuser sur un
|
||||
// attribut manquant jetterait la donnée pour son étiquette.
|
||||
const qint64 t0 = QDateTime(QDate(2026, 9, 9), QTime(0, 0), QTimeZone::utc()).toSecsSinceEpoch();
|
||||
auto charge = [&](int v, const QString &unite, qint64 age, const QVariantList &p) {
|
||||
return QVariantMap{{"v", v}, {"t0", t0}, {"step", 3600}, {"unit", unite},
|
||||
{"convention", "mean over [t, t+step)"}, {"regime", "forecast"},
|
||||
{"corrections", "none"}, {"model", "meteofrance_seamless"},
|
||||
{"issuedAt", run}, {"p", p}};
|
||||
{"convention", "mean over [t, t+step)"},
|
||||
{"model", "meteofrance_seamless"}, {"issuedAt", age}, {"p", p}};
|
||||
};
|
||||
QString refus;
|
||||
|
||||
// 1. NOMINAL — et la conversion suit la convention LUE, pas supposée : p[i] est une moyenne
|
||||
// en W sur le créneau, donc l'énergie vaut w x step / 3600.
|
||||
// 1. NOMINAL — la conversion suit la convention LUE : p[i] est une moyenne en W sur le
|
||||
// créneau, donc l'énergie vaut w x step / 3600, sans supposer que le pas vaut une heure.
|
||||
const ForecastRevision ok = ForecastStore::depuisTransport(
|
||||
charge(1, "W", QVariantList{0, 250, 1200}), refus);
|
||||
charge(1, "W", t0, QVariantList{0, 250, 1200}), refus);
|
||||
QVERIFY2(refus.isEmpty(), qUtf8Printable(refus));
|
||||
QCOMPARE(ok.points.count(), 3);
|
||||
QCOMPARE(qRound(ok.points.at(1).wh), 250); // 250 W sur 3600 s
|
||||
QCOMPARE(ok.points.at(1).targetAt.toSecsSinceEpoch(), t0 + 3600); // indexation rétrograde
|
||||
QCOMPARE(ok.issuedAt.toSecsSinceEpoch(), run);
|
||||
QCOMPARE(ok.model, QString("meteofrance_seamless"));
|
||||
QCOMPARE(qRound(ok.points.at(1).wh), 250);
|
||||
QCOMPARE(ok.points.at(1).targetAt.toSecsSinceEpoch(), t0 + 3600);
|
||||
|
||||
// 2. VERSION INCONNUE — refusée, et le motif la NOMME. Pas de courbe partielle.
|
||||
QVERIFY(ForecastStore::depuisTransport(charge(2, "W", QVariantList{0, 250}), refus)
|
||||
// 2. LA FORME se refuse, et le motif la NOMME.
|
||||
QVERIFY(ForecastStore::depuisTransport(charge(2, "W", t0, QVariantList{0, 250}), refus)
|
||||
.points.isEmpty());
|
||||
QCOMPARE(refus, QString("VERSION_2"));
|
||||
|
||||
// 3. UNITÉ INATTENDUE — refusée aussi. Lire des Wh comme des W donnerait une courbe
|
||||
// plausible et fausse d'un facteur qui dépend du pas : exactement le genre de nombre que
|
||||
// personne ne rattrape.
|
||||
QVERIFY(ForecastStore::depuisTransport(charge(1, "Wh", QVariantList{0, 250}), refus)
|
||||
QVERIFY(ForecastStore::depuisTransport(charge(1, "Wh", t0, QVariantList{0, 250}), refus)
|
||||
.points.isEmpty());
|
||||
QCOMPARE(refus, QString("UNIT_Wh"));
|
||||
|
||||
// 4. CHARGE UTILE VIDE ou tronquée — refus nommé, jamais une courbe de zéros.
|
||||
QCOMPARE(refus, QString("UNIT_Wh")); // lire des Wh comme des W : plausible et faux
|
||||
QVERIFY(ForecastStore::depuisTransport(QVariantMap(), refus).points.isEmpty());
|
||||
QCOMPARE(refus, QString("EMPTY"));
|
||||
QVERIFY(ForecastStore::depuisTransport(charge(1, "W", QVariantList()), refus).points.isEmpty());
|
||||
QVERIFY(ForecastStore::depuisTransport(charge(1, "W", t0, QVariantList()), refus).points.isEmpty());
|
||||
QCOMPARE(refus, QString("MALFORMED"));
|
||||
|
||||
// 5. UN CRÉNEAU ILLISIBLE EST UN TROU, pas un zéro : la courbe en porte deux au lieu de
|
||||
// trois, et l'absence reste l'information.
|
||||
// 3. UN ATTRIBUT MANQUANT NE SE REFUSE PAS. `best_match` publie `issuedAt = 0` : la courbe
|
||||
// est parfaitement exploitable, seul son ÂGE est inconnu — et un âge inconnu se dit par
|
||||
// une absence, jamais par un refus de la donnée qu'il accompagne.
|
||||
const ForecastRevision sansAge = ForecastStore::depuisTransport(
|
||||
charge(1, "W", 0, QVariantList{10, 20, 30}), refus);
|
||||
QVERIFY2(refus.isEmpty(), "un âge inconnu ne refuse pas la courbe");
|
||||
QCOMPARE(sansAge.points.count(), 3);
|
||||
QVERIFY2(!sansAge.issuedAt.isValid(), "et aucun instant n'est INVENTÉ à sa place");
|
||||
|
||||
// 4. UN CRÉNEAU ILLISIBLE EST UN TROU, pas un zéro.
|
||||
const ForecastRevision troue = ForecastStore::depuisTransport(
|
||||
charge(1, "W", QVariantList{100, QString("bruit"), 300}), refus);
|
||||
charge(1, "W", t0, QVariantList{100, QString("bruit"), 300}), refus);
|
||||
QVERIFY(refus.isEmpty());
|
||||
QCOMPARE(troue.points.count(), 2);
|
||||
}
|
||||
|
||||
void TestLoadModel::testFreshnessNeverDatesTheForecast()
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ForecastStore magasin(dir.filePath("forecast.sqlite"));
|
||||
QVERIFY(magasin.isValid());
|
||||
|
||||
const QDateTime run06 = QDateTime(QDate(2026, 9, 9), QTime(6, 0), QTimeZone::utc());
|
||||
const QDateTime run10 = QDateTime(QDate(2026, 9, 9), QTime(10, 0), QTimeZone::utc());
|
||||
const QDateTime t0 = QDateTime(QDate(2026, 9, 9), QTime(14, 0), QTimeZone::utc());
|
||||
|
||||
QVERIFY(magasin.enregistrer(revision(run06, t0, {800, 900})));
|
||||
|
||||
// 1. SUR UNE LIGNE JAMAIS RECONDUITE, la fraîcheur vaut l'émission — jamais absente.
|
||||
// Ce n'est pas une copie déguisée en confirmation : le champ répond à « quel est le
|
||||
// dernier run qui a AFFIRMÉ ces valeurs », et le run émetteur les a affirmées.
|
||||
// L'absence, elle, se lirait « on ne sait pas jusqu'à quand elle a tenu » — c'est faux,
|
||||
// et chaque client devrait écrire le repli à sa façon.
|
||||
QList<ForecastPoint> vue = magasin.courbe(t0, t0.addSecs(3600), run06.addSecs(60));
|
||||
QCOMPARE(vue.count(), 2);
|
||||
QVERIFY2(vue.first().lastConfirmedAt.isValid(), "la fraîcheur n'est JAMAIS absente");
|
||||
QCOMPARE(vue.first().lastConfirmedAt, run06);
|
||||
QCOMPARE(vue.first().firstIssuedAt, run06);
|
||||
|
||||
// 2. Après reconduction, elle avance — et l'identité, NON.
|
||||
QVERIFY(!magasin.enregistrer(revision(run10, t0, {800, 900})));
|
||||
vue = magasin.courbe(t0, t0.addSecs(3600), run10.addSecs(60));
|
||||
QCOMPARE(vue.first().lastConfirmedAt, run10); // fraîcheur : avancée
|
||||
QCOMPARE(vue.first().firstIssuedAt, run06); // identité : IMMUABLE
|
||||
|
||||
// 3. L'INVARIANT qui rend le couple lisible : la fraîcheur ne précède jamais l'émission.
|
||||
for (const ForecastPoint &p : vue)
|
||||
QVERIFY2(p.lastConfirmedAt >= p.firstIssuedAt,
|
||||
"une prévision ne peut pas avoir tenu AVANT d'avoir été émise");
|
||||
|
||||
// 4. LA FRAÎCHEUR NE DATE PAS LA PRÉVISION — c'est la garde que la règle demande. `revisions()`
|
||||
// rend les instants d'ÉMISSION : deux prévisions différentes reconduites par le même run
|
||||
// porteraient sinon la même date, et l'historique deviendrait illisible.
|
||||
const QList<QPair<QDateTime, double>> revs = magasin.revisions(t0);
|
||||
QCOMPARE(revs.count(), 1);
|
||||
QCOMPARE(revs.first().first, run06); // l'émission, pas la reconduction
|
||||
}
|
||||
|
||||
void TestLoadModel::testAnUnknownRunKeyIsRefusedNotGuessed()
|
||||
{
|
||||
// Avis du producteur (2026-09-09) : `issuedAt == 0` signifie CLÉ INCONNUE — notamment quand
|
||||
// le repli `best_match` est actif, car cette stratégie n'a pas d'identité de run unique.
|
||||
//
|
||||
// Le refus doit être NOMMÉ, et distinct de « charge utile mal formée » : la cause diffère,
|
||||
// et la conduite à tenir aussi. Surtout, l'heure de réception ne doit JAMAIS s'y substituer —
|
||||
// elle serait plausible et fausse, et une fois écrite, plus rien dans la donnée ne
|
||||
// distinguerait la ligne devinée de la ligne datée.
|
||||
const qint64 t0 = QDateTime(QDate(2026, 9, 9), QTime(0, 0), QTimeZone::utc()).toSecsSinceEpoch();
|
||||
auto charge = [&](qint64 run) {
|
||||
return QVariantMap{{"v", 1}, {"t0", t0}, {"step", 3600}, {"unit", "W"},
|
||||
{"convention", "mean over [t, t+step)"},
|
||||
{"model", "best_match"}, {"issuedAt", run},
|
||||
{"p", QVariantList{0, 100, 200}}};
|
||||
};
|
||||
QString refus;
|
||||
|
||||
// 1. CLÉ INCONNUE — refus nommé, et AUCUN point : rien ne sera enregistré.
|
||||
const ForecastRevision sansCle = ForecastStore::depuisTransport(charge(0), refus);
|
||||
QCOMPARE(refus, QString("ISSUED_AT_UNKNOWN"));
|
||||
QVERIFY(sansCle.points.isEmpty());
|
||||
QVERIFY2(!sansCle.issuedAt.isValid(),
|
||||
"aucune heure ne doit être substituée à une clé inconnue");
|
||||
|
||||
// 2. Le motif est DISTINCT de MALFORMED : deux causes, deux conduites. Les confondre ferait
|
||||
// chercher un défaut de forme là où la stratégie du producteur n'a simplement pas de run.
|
||||
QVERIFY(refus != QLatin1String("MALFORMED"));
|
||||
|
||||
// 3. CONTRE-ÉPREUVE — la même charge utile avec une clé valide passe. Sans elle, on ne
|
||||
// saurait pas si le refus vise la clé ou tout le reste.
|
||||
const ForecastRevision avecCle = ForecastStore::depuisTransport(charge(t0 + 6 * 3600), refus);
|
||||
QVERIFY(refus.isEmpty());
|
||||
QCOMPARE(avecCle.points.count(), 3);
|
||||
|
||||
// 4. ET LE MAGASIN LA REFUSE AUSSI, bout en bout : une révision sans clé ne s'écrit pas.
|
||||
QTemporaryDir dir;
|
||||
ForecastStore magasin(dir.filePath("forecast.sqlite"));
|
||||
QVERIFY(magasin.isValid());
|
||||
QVERIFY2(!magasin.enregistrer(sansCle), "une révision sans clé de run ne s'enregistre pas");
|
||||
}
|
||||
|
||||
void TestLoadModel::testARunIsImmutable()
|
||||
{
|
||||
// Constaté sur .75 le 2026-09-09 : à 07:02, une seconde écriture pour le run 00Z, dont les
|
||||
// valeurs différaient de celles de 06:03. L'implémentation d'alors faisait INSERT OR REPLACE
|
||||
// et les a remplacées EN SILENCE — la preuve de ce qui avait bougé a été détruite par le
|
||||
// défaut lui-même, et la question « passé ou futur ? » est devenue impossible à poser.
|
||||
QTemporaryDir dir;
|
||||
ForecastStore magasin(dir.filePath("forecast.sqlite"));
|
||||
QVERIFY(magasin.isValid());
|
||||
|
||||
const QDateTime run = QDateTime(QDate(2026, 9, 9), QTime(0, 0), QTimeZone::utc());
|
||||
const QDateTime t0 = QDateTime(QDate(2026, 9, 9), QTime(10, 0), QTimeZone::utc());
|
||||
|
||||
QVERIFY(magasin.enregistrer(revision(run, t0, {100, 200, 300})));
|
||||
|
||||
// 1. LE MÊME RUN AVEC D'AUTRES VALEURS N'ÉCRASE RIEN. Le producteur a modifié une prévision
|
||||
// qu'il avait publiée sous cette identité : c'est une anomalie à SIGNALER, pas à absorber.
|
||||
QVERIFY2(!magasin.enregistrer(revision(run, t0, {100, 999, 300})),
|
||||
"un run est immuable — une écriture divergente doit être refusée");
|
||||
|
||||
// 2. ET LES PREMIÈRES VALEURS SONT INTACTES. C'est tout l'enjeu : détruire l'historique
|
||||
// supprime la donnée qui permettrait de trancher plus tard.
|
||||
const QList<ForecastPoint> vue = magasin.courbe(t0, t0.addSecs(2 * 3600), run.addSecs(60));
|
||||
QCOMPARE(vue.count(), 3);
|
||||
QCOMPARE(qRound(vue.at(1).wh), 200); // et non 999
|
||||
QCOMPARE(magasin.revisions(t0).count(), 1);
|
||||
|
||||
// 3. CONTRE-ÉPREUVE — un run DIFFÉRENT aux mêmes valeurs reconduit (pas de ligne neuve),
|
||||
// et un run différent aux valeurs neuves ouvre bien une révision. Sans ces deux lignes,
|
||||
// on ne saurait pas si le refus vise la mutation ou toute écriture.
|
||||
const QDateTime run06 = run.addSecs(6 * 3600);
|
||||
QVERIFY(!magasin.enregistrer(revision(run06, t0, {100, 200, 300}))); // reconduction
|
||||
QCOMPARE(magasin.revisions(t0).count(), 1);
|
||||
const QDateTime run12 = run.addSecs(12 * 3600);
|
||||
QVERIFY(magasin.enregistrer(revision(run12, t0, {100, 888, 300}))); // vraie révision
|
||||
QCOMPARE(magasin.revisions(t0).count(), 2);
|
||||
}
|
||||
|
||||
@ -86,21 +86,15 @@ private slots:
|
||||
void testReportingDayBoundaryIsPublishedNotDeducible();
|
||||
void testDrawMarginComparesHeadroomsNotCaps();
|
||||
|
||||
//! Une révision n'existe que si la PRÉVISION a changé — un run qui reconduit met à jour la
|
||||
//! fraîcheur, il n'ouvre pas de ligne. Et les deux instants ne se confondent pas.
|
||||
void testARevisionExistsOnlyIfTheForecastChanged();
|
||||
//! La courbe se relit telle qu'elle était connue à un instant, et ses trous restent des trous.
|
||||
//! Une révision n'existe que si la PRÉVISION a changé — et le contenu seul en décide.
|
||||
void testTheContentIsTheRevisionIdentity();
|
||||
//! La courbe se relit telle qu'elle était connue, et ses trous restent des trous.
|
||||
void testTheCurveIsReproducibleAndKeepsItsHoles();
|
||||
//! La rétention garde les DEUX bouts : n'en garder qu'un détruirait la révision.
|
||||
void testRetentionKeepsBothEndsOfEachRevision();
|
||||
//! La fraîcheur ne date JAMAIS la prévision, et elle n'est jamais absente.
|
||||
void testFreshnessNeverDatesTheForecast();
|
||||
//! Une clé de run inconnue se REFUSE — l'heure de réception ne s'y substitue jamais.
|
||||
void testAnUnknownRunKeyIsRefusedNotGuessed();
|
||||
//! Un RUN est immuable : des valeurs neuves sous une identité connue n'écrasent rien.
|
||||
void testARunIsImmutable();
|
||||
//! La charge utile de transport se REFUSE plutôt que de s'interpréter au mieux.
|
||||
void testAnUnknownPayloadIsRefusedNotBestEffort();
|
||||
//! La charge utile se REFUSE plutôt que de s'interpréter au mieux — mais pas pour un attribut.
|
||||
void testAPayloadIsRefusedOnFormNeverOnAMissingAttribute();
|
||||
|
||||
};
|
||||
|
||||
#endif // TESTLOADMODEL_H
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user