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:
Patrick Schurig 2026-09-09 07:54:45 +02:00
parent 2b7240a596
commit ee6f1732bc
7 changed files with 389 additions and 477 deletions

View File

@ -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.

View File

@ -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

View File

@ -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();

View File

@ -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

View File

@ -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 &params)
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

View File

@ -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);
}

View File

@ -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