feat(prévision PV): version B — une révision n'existe que si la prévision a changé

La déduplication porte sur les VALEURS ; un run qui reconduit ne crée pas de
ligne, il met à jour la fraîcheur.

Le raisonnement qui tranche est celui de meta.json, appliqué une seconde fois :
on ne stocke pas pour un consommateur qui n'existe pas. L'apprentissage n'existe
pas, le consommateur d'aujourd'hui est un affichage, et un affichage ne lit pas
une confirmation. Même déclencheur de réouverture, pour la même raison.

UNE CONTRADICTION DANS LA CONSIGNE, ET COMMENT ELLE EST LEVÉE. Deux instructions
incompatibles : « issuedAt est mis à jour sur la ligne existante » (= le dernier
run) et « il devient l'horodatage du premier run, pas du dernier » (= le
premier). Et l'avertissement joint — « quelqu'un lira prévu à 06Z sur une ligne
que le run 12Z a confirmée » — ne décrit un danger QUE si l'instant n'est pas
mis à jour.

Aucune des deux n'a été choisie, parce qu'aucune ne suffit : un seul champ ne
peut pas porter les deux faits sans mentir sur l'un. Ce serait exactement le
piège receivedAt/issuedAt de la veille — deux instants voisins, un nom qui
promet le mauvais — et le remède est le même : nommer ce que c'est.

  firstIssuedAt    run qui a produit ces valeurs EN PREMIER  (CLÉ, immuable)
  lastConfirmedAt  dernier run qui les a RECONDUITES         (mis à jour)

La FRAÎCHEUR est lastConfirmedAt ; l'ANCIENNETÉ DE L'AVIS est firstIssuedAt.
Une valeur émise à 06Z et reconduite à 12Z est fraîche ; une valeur émise à 06Z
que personne n'a reconduite EST vieille de six heures. Les deux phrases sont
vraies et ne parlent pas de la même chose. Les deux sont publiées au contrat :
n'en publier qu'une obligerait le client à choisir laquelle il croit.

firstIssuedAt ne bouge jamais — une clé qui se déplace change l'identité de la
ligne sous elle. Les deux instants coïncident à l'ouverture et divergent à la
première reconduction, ce que le test vérifie explicitement.

DERNIER REMPART, PAS FILET : celle du producteur compare la charge utile
entière, où issuedAt figure, donc elle ne mord jamais sur ce cas. Si celle-ci se
trompe, personne ne rattrape — raison de plus pour qu'elle ne dépende d'aucun
comportement du voisin : intersection des créneaux, et communs > 0 requis.

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-08 19:55:38 +02:00
parent 4231453ea1
commit da58d3906a
6 changed files with 251 additions and 113 deletions

View File

@ -541,3 +541,66 @@ le reste, et répéter l'avertissement chaque minute noierait le signal.
**La prévision n'entre toujours pas dans le plan.** Elle est lue avant le contexte et n'y entre
pas : ce lot est un affichage.
---
## 15. Version B — une révision n'existe que si la prévision a changé
La déduplication porte sur **les valeurs**, et un run qui reconduit ne crée pas de ligne.
**Le raisonnement qui tranche est celui de `meta.json`**, appliqué une seconde fois : *on ne
stocke pas pour un consommateur qui n'existe pas.* L'apprentissage n'existe pas ; le consommateur
d'aujourd'hui est un **affichage**, et un affichage ne lit pas une confirmation. Le déclencheur
de réouverture est le même : le jour où l'apprentissage corrélera l'erreur à l'horizon, il aura
peut-être besoin des confirmations — la décision se reprendra **avec un consommateur réel**.
### 15.1 Une contradiction dans la consigne, et comment elle est levée
Deux instructions incompatibles ont été données le même jour :
| | |
|---|---|
| « `issuedAt` est **mis à jour** sur la ligne existante » | il vaut le **dernier** run |
| « il devient l'horodatage du **premier** run […] pas du dernier » | il vaut le **premier** |
Et l'avertissement joint — *« quelqu'un lira “prévu à 06Z” sur une ligne que le run 12Z a
confirmée »* — ne décrit un danger **que si l'instant n'est pas mis à jour**.
**Aucune des deux n'a été choisie**, parce qu'aucune ne suffit : **un seul champ ne peut pas
porter les deux faits sans mentir sur l'un.** Ce serait exactement le piège
`receivedAt`/`issuedAt` de la veille — deux instants voisins, un nom qui promet le mauvais — et
le remède est le même : **nommer ce que c'est**.
```sql
targetAt INTEGER -- heure visée, epoch UTC
firstIssuedAt INTEGER -- run qui a produit ces valeurs EN PREMIER ← CLÉ, ne bouge jamais
lastConfirmedAt INTEGER -- dernier run qui les a RECONDUITES ← mis à jour en place
forecastWh REAL
model TEXT
PRIMARY KEY (targetAt, firstIssuedAt)
```
> **La FRAÎCHEUR est `lastConfirmedAt`. L'ANCIENNETÉ DE L'AVIS est `firstIssuedAt`.**
>
> Une valeur émise à 06Z et reconduite à 12Z est **fraîche**, pas vieille de six heures. Et une
> valeur émise à 06Z que personne n'a reconduite depuis **est** vieille de six heures. Les deux
> phrases sont vraies, elles ne parlent pas de la même chose, et un champ unique en effacerait
> une.
`firstIssuedAt` **ne bouge jamais** : c'est la clé, et une clé qui se déplace change l'identité
de la ligne sous elle. Les deux instants **coïncident à l'ouverture** d'une révision et divergent
à la première reconduction — ce que le test vérifie explicitement.
Les deux sont publiés au contrat, ensemble. N'en publier qu'un obligerait le client à choisir
lequel il croit.
### 15.2 Cette déduplication est le DERNIER REMPART, pas un filet
Celle du producteur compare la **charge utile entière**, où `issuedAt` figure : elle ne mord donc
**jamais** sur ce cas — un run neuf aux mêmes chiffres lui paraîtra toujours différent.
**Si celle-ci se trompe, personne ne rattrape.** C'est une raison de plus de ne pas la laisser
dépendre d'un comportement du voisin : la comparaison porte sur l'**intersection** des créneaux,
correcte que la fenêtre glisse ou non, et `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.

View File

@ -126,13 +126,19 @@ bool ForecastStore::migrer()
const bool ok =
q.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS forecastPv ("
" targetAt INTEGER NOT NULL,"
" issuedAt INTEGER NOT NULL,"
" forecastWh REAL NOT NULL,"
" model TEXT,"
" PRIMARY KEY (targetAt, issuedAt))"))
" targetAt INTEGER NOT NULL,"
// LE RUN QUI A PRODUIT CES VALEURS EN PREMIER. C'est la clé : elle ne bouge jamais,
// sans quoi l'identité de la ligne changerait sous elle.
" firstIssuedAt INTEGER NOT NULL,"
// LE DERNIER RUN QUI LES A RECONDUITES, mis à jour en place. Deux instants et deux
// noms : un seul champ ne peut pas porter les deux faits sans mentir sur l'un.
// La FRAÎCHEUR est lastConfirmedAt ; l'ANCIENNETÉ DE L'AVIS est firstIssuedAt.
" lastConfirmedAt INTEGER NOT NULL,"
" forecastWh REAL NOT NULL,"
" model TEXT,"
" PRIMARY KEY (targetAt, firstIssuedAt))"))
&& q.exec(QStringLiteral(
"CREATE INDEX IF NOT EXISTS idxForecastIssued ON forecastPv(issuedAt)"))
"CREATE INDEX IF NOT EXISTS idxForecastIssued ON forecastPv(firstIssuedAt)"))
&& q.exec(QStringLiteral("PRAGMA user_version = %1").arg(SchemaVersion));
if (!ok)
qCWarning(dcNymeaEnergy()) << "[Prévision] migration échouée :" << q.lastError().text();
@ -151,69 +157,78 @@ bool ForecastStore::enregistrer(const ForecastRevision &revision)
}
const qint64 run = revision.issuedAt.toUTC().toSecsSinceEpoch();
// --- LA CLÉ DÉDUPLIQUE ------------------------------------------------------------------
// --- UNE RÉVISION N'EXISTE QUE SI LA PRÉVISION A CHANGÉ ---------------------------------
//
// Depuis que l'heure du run voyage dans la charge utile, l'identité du run EST l'identité de
// la révision : deux lectures du même run retombent sur les mêmes lignes. Il n'y a rien à
// comparer, et c'est tout le gain d'une clé VRAIE sur une clé approchée.
QSqlQuery deja(m_db);
deja.prepare(QStringLiteral("SELECT COUNT(*) FROM forecastPv WHERE issuedAt = ?"));
deja.addBindValue(run);
if (deja.exec() && deja.next() && deja.value(0).toInt() > 0) {
qCDebug(dcNymeaEnergy()) << "[Prévision] run déjà connu — rien à écrire";
return false;
}
// --- LE CONTENU OBSERVE, IL NE REJETTE PAS ----------------------------------------------
// 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.
//
// Un run PLUS RÉCENT portant les mêmes chiffres est une information : le modèle n'a pas
// changé d'avis. IL CONFIRME, IL NE RÉPÈTE PAS. Le rejeter détruirait précisément ce que
// cette table conserve, et rendrait indistincts « le modèle a confirmé quatre fois » et
// « le modèle n'a rien dit depuis ce matin ».
//
// La comparaison porte sur l'INTERSECTION et non sur la charge utile. Le producteur publie
// aujourd'hui des journées ENTIÈRES — deux révisions du même jour couvrent donc exactement
// les mêmes créneaux — mais l'intersection reste correcte si cela changeait, et elle ne
// dépend d'aucun comportement du voisin dont personne ne nous préviendrait.
// 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(issuedAt) FROM forecastPv WHERE issuedAt < ?"));
derniere.addBindValue(run);
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 issuedAt = ?"));
ref.addBindValue(derniere.value(0).toLongLong());
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, differents = 0;
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;
continue; // hors intersection : ne départage rien
++communs;
if (qAbs(it.value() - p.wh) > EgaliteWh) ++differents;
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;
}
if (communs > 0 && differents == 0)
qCInfo(dcNymeaEnergy()).noquote()
<< QStringLiteral("[Prévision] le run confirme le précédent — %1 créneau(x) "
"commun(s) inchangé(s)").arg(communs);
}
// --- écriture, en une transaction ------------------------------------------------------
// --- écriture d'une VRAIE révision, en une transaction ----------------------------------
if (!m_db.transaction())
qCWarning(dcNymeaEnergy()) << "[Prévision] transaction refusée :" << m_db.lastError().text();
QSqlQuery ins(m_db);
ins.prepare(QStringLiteral(
"INSERT OR REPLACE INTO forecastPv (targetAt, issuedAt, forecastWh, model) "
"VALUES (?, ?, ?, ?)"));
"INSERT OR REPLACE INTO forecastPv "
"(targetAt, firstIssuedAt, lastConfirmedAt, forecastWh, model) VALUES (?, ?, ?, ?, ?)"));
for (const ForecastPoint &p : revision.points) {
ins.addBindValue(p.targetAt.toUTC().toSecsSinceEpoch());
ins.addBindValue(run);
ins.addBindValue(run); // à l'ouverture, les deux instants coïncident
ins.addBindValue(p.wh);
// NULL, jamais une chaîne vide : une chaîne vide serait déjà une valeur. Et le producteur
// publie maintenant le modèle qui A RÉPONDU, repli compris — donc la colonne porte une
// provenance et non une intention.
// NULL, jamais une chaîne vide : une chaîne vide serait déjà une valeur. Le producteur
// publie le modèle qui A RÉPONDU, repli compris — donc une provenance, pas une intention.
ins.addBindValue(revision.model.isEmpty() ? QVariant(QMetaType(QMetaType::QString))
: QVariant(revision.model));
if (!ins.exec()) {
@ -227,7 +242,7 @@ bool ForecastStore::enregistrer(const ForecastRevision &revision)
return false;
}
qCInfo(dcNymeaEnergy()).noquote()
<< QStringLiteral("[Prévision] run %1 enregistré — %2 créneau(x), modèle %3")
<< QStringLiteral("[Prévision] RÉVISION du run %1 — %2 créneau(x), modèle %3")
.arg(revision.issuedAt.toUTC().toString(Qt::ISODate))
.arg(revision.points.count())
.arg(revision.model.isEmpty() ? QStringLiteral("(inconnu)") : revision.model);
@ -247,10 +262,10 @@ 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 FROM forecastPv f "
"WHERE targetAt BETWEEN ? AND ? AND issuedAt <= ? "
" AND issuedAt = (SELECT MAX(issuedAt) FROM forecastPv g "
" WHERE g.targetAt = f.targetAt AND g.issuedAt <= ?) "
"SELECT targetAt, forecastWh, firstIssuedAt, lastConfirmedAt FROM forecastPv f "
"WHERE targetAt BETWEEN ? AND ? AND firstIssuedAt <= ? "
" AND firstIssuedAt = (SELECT MAX(firstIssuedAt) FROM forecastPv g "
" WHERE g.targetAt = f.targetAt AND g.firstIssuedAt <= ?) "
"ORDER BY targetAt"));
const qint64 limite = borne.toUTC().toSecsSinceEpoch();
q.addBindValue(de.toUTC().toSecsSinceEpoch());
@ -268,6 +283,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
// ligne que le run 12Z vient de reconduire.
p.firstIssuedAt = QDateTime::fromSecsSinceEpoch(q.value(2).toLongLong(), QTimeZone::utc());
p.lastConfirmedAt = QDateTime::fromSecsSinceEpoch(q.value(3).toLongLong(), QTimeZone::utc());
points.append(p);
}
return points;
@ -280,7 +300,7 @@ QList<QPair<QDateTime, double>> ForecastStore::revisions(const QDateTime &target
return sortie;
QSqlQuery q(m_db);
q.prepare(QStringLiteral(
"SELECT issuedAt, forecastWh FROM forecastPv WHERE targetAt = ? ORDER BY issuedAt"));
"SELECT firstIssuedAt, forecastWh FROM forecastPv WHERE targetAt = ? ORDER BY firstIssuedAt"));
q.addBindValue(targetAt.toUTC().toSecsSinceEpoch());
if (!q.exec())
return sortie;
@ -305,10 +325,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 issuedAt NOT IN ("
" SELECT MIN(issuedAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt"
"DELETE FROM forecastPv WHERE targetAt < ? AND firstIssuedAt NOT IN ("
" SELECT MIN(firstIssuedAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt"
" UNION"
" SELECT MAX(issuedAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt)"));
" SELECT MAX(firstIssuedAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt)"));
q.addBindValue(limite);
if (!q.exec()) {
qCWarning(dcNymeaEnergy()) << "[Prévision] purge refusée :" << q.lastError().text();

View File

@ -43,6 +43,10 @@ struct ForecastPoint {
QDateTime targetAt;
//! É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. Renseigné à la lecture ; ignoré à l'écriture.
QDateTime firstIssuedAt;
//! \brief Dernier run qui les a RECONDUITES. Renseigné à la lecture ; ignoré à l'écriture.
QDateTime lastConfirmedAt;
};
/*!
@ -55,12 +59,13 @@ struct ForecastRevision {
* 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.
*
* \par Ce que cela change, et il faut le savoir pour ne pas hériter d'une vieille précaution
* Une première version de ce lot 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 global se
* compte en heures, donc `targetAt - receivedAt` était trop court, systématiquement.
* **Cette réserve est levée** : avec l'heure du run, `targetAt - issuedAt` **est**
* l'horizon, et il peut servir à corréler l'erreur à l'âge de la prévision.
* \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 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).
*/
QDateTime issuedAt;
//! Les points de la courbe. Une révision **vide n'est pas enregistrée** : voir \c enregistrer().
@ -135,20 +140,35 @@ public:
* \return Vrai si elle a été écrite ; **faux si ce run était déjà connu**, si elle était
* vide, ou en cas d'échec.
*
* \par LA CLÉ DÉDUPLIQUE, PAS LE CONTENU
* Depuis que l'heure du run voyage dans la charge utile, **l'identité du run EST l'identité
* de la révision**. Deux lectures du même run portent le même \c issuedAt et retombent sur
* la même ligne ; rien à comparer.
* \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.
*
* \par Pourquoi le contenu ne doit SURTOUT PAS rejeter
* Un run **plus récent portant les mêmes chiffres** est une information : le modèle n'a pas
* changé d'avis. **Il confirme, il ne répète pas.** Le rejeter sur l'égalité des valeurs
* détruirait exactement ce que cette table existe pour conserver — et rendrait indistincts
* « le modèle a confirmé quatre fois » et « le modèle n'a rien dit depuis ce matin ».
* 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.
*
* La comparaison sur l'intersection subsiste donc, mais **journalisée seulement**, jamais
* bloquante. Si un jour \c issuedAt bougeait sans que la prévision bouge, cela se verrait
* dans le nombre de révisions — un défaut à la source, qu'il faut voir et non masquer.
* \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.
*
* 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.**
*
* \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.
*
* \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
* se reprendra alors **avec un consommateur réel**, comme pour `meta.json`.
*/
bool enregistrer(const ForecastRevision &revision);

View File

@ -314,15 +314,24 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
"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. "
"'receivedAt' is when the box received the revision, NOT when the model run "
"was initialised: NO HORIZON MAY BE COMPUTED FROM IT - it is short by the "
"model's publication latency, which is hours.";
"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("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);
registerMethod("GetPvForecast", description, params, returns, Types::PermissionScopeControlThings);
@ -1067,8 +1076,10 @@ JsonReply *NymeaEnergyJsonHandler::GetPvForecast(const QVariantMap &params)
// qui ne dit pas d'où il vient finit par être lu comme une mesure, et une prévision lue
// comme une mesure est un mensonge que personne n'a écrit.
points.append(QVariantMap{
{"targetAt", p.targetAt.toUTC().toString(Qt::ISODate)},
{"forecastWh", p.wh}});
{"targetAt", p.targetAt.toUTC().toString(Qt::ISODate)},
{"forecastWh", p.wh},
{"firstIssuedAt", p.firstIssuedAt.toUTC().toString(Qt::ISODate)},
{"lastConfirmedAt", p.lastConfirmedAt.toUTC().toString(Qt::ISODate)}});
}
return createReply({{"forecastPoints", points}});
#else

View File

@ -933,53 +933,70 @@ ForecastRevision revision(const QDateTime &recue, const QDateTime &depuis,
{
ForecastRevision r;
r.issuedAt = recue;
for (int i = 0; i < wh.count(); ++i)
r.points.append({ depuis.addSecs(i * 3600), wh.at(i) });
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);
r.points.append(p);
}
return r;
}
}
void TestLoadModel::testTheRunIdentityIsTheRevisionIdentity()
void TestLoadModel::testARevisionExistsOnlyIfTheForecastChanged()
{
QTemporaryDir dir;
ForecastStore magasin(dir.filePath("forecast.sqlite"));
QVERIFY(magasin.isValid());
const QDateTime run08 = QDateTime(QDate(2026, 9, 9), QTime(8, 0), QTimeZone::utc());
const QDateTime run14 = QDateTime(QDate(2026, 9, 9), QTime(14, 0), QTimeZone::utc());
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 t0 = QDateTime(QDate(2026, 9, 9), QTime(10, 0), QTimeZone::utc());
// 1. Premier run : il s'enregistre.
QVERIFY(magasin.enregistrer(revision(run08, t0, {100, 200, 300, 400})));
// 2. LE MÊME RUN, RELU — pas une révision, et c'est LA CLÉ qui le dit. Depuis que l'heure
// d'initialisation voyage dans la charge utile, l'identité du run EST celle de la
// révision : deux lectures retombent sur les mêmes lignes, sans rien comparer.
QVERIFY2(!magasin.enregistrer(revision(run08, t0, {100, 200, 300, 400})),
"le même run relu n'est pas une révision de plus");
// Et même avec des valeurs DIFFÉRENTES : un run ne se réécrit pas. Si les chiffres d'un
// run donné changeaient, c'est le producteur qu'il faudrait regarder, pas cette table.
QVERIFY(!magasin.enregistrer(revision(run08, t0, {111, 222, 333, 444})));
// 1. Premier run : une révision s'ouvre.
QVERIFY(magasin.enregistrer(revision(run06, t0, {100, 200, 300, 400})));
QCOMPARE(magasin.revisions(t0).count(), 1);
// 3. LE CŒUR DU LOT — un run PLUS RÉCENT portant LES MÊMES CHIFFRES **EST** une révision.
// Il confirme, il ne répète pas : le modèle n'a pas changé d'avis, et c'est une
// information. La rejeter rendrait indistincts « le modèle a confirmé quatre fois » et
// « le modèle n'a rien dit depuis ce matin ».
QVERIFY2(magasin.enregistrer(revision(run14, t0, {100, 200, 300, 400})),
"un run plus récent aux mêmes chiffres CONFIRME — il doit être conservé");
// 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");
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})));
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
// 4. Une révision VIDE n'en est pas une : l'absence est un trou, pas une courbe de zéros.
ForecastRevision vide; vide.issuedAt = run14.addSecs(3600);
QVERIFY(!magasin.enregistrer(vide));
// 5. L'HORIZON EST CALCULABLE, et c'est ce que la vraie clé a rendu possible. Avec l'instant
// de RÉCEPTION on ne pouvait pas : la latence de publication d'un modèle se compte en
// heures, et l'horizon aurait été trop court, systématiquement.
// 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.secsTo(t0), qint64(2 * 3600)); // run de 08:00 → cible 10:00
QCOMPARE(revs.last().first.secsTo(t0), qint64(-4 * 3600)); // run de 14:00, cible dépassée
QCOMPARE(revs.first().first, run06);
QCOMPARE(revs.last().first, run18);
// 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);
QVERIFY(!magasin.enregistrer(vide));
}
void TestLoadModel::testTheCurveIsReproducibleAndKeepsItsHoles()
@ -1031,7 +1048,10 @@ void TestLoadModel::testRetentionKeepsBothEndsOfEachRevision()
for (int i = 0; i < 4; ++i) {
ForecastRevision r;
r.issuedAt = cible.addSecs(-(4 - i) * 3600);
r.points.append({ cible, 1000.0 + i });
ForecastPoint pt;
pt.targetAt = cible;
pt.wh = 1000.0 + i;
r.points.append(pt);
QVERIFY(magasin.enregistrer(r));
}
QCOMPARE(magasin.revisions(cible).count(), 4);
@ -1054,7 +1074,10 @@ void TestLoadModel::testRetentionKeepsBothEndsOfEachRevision()
for (int i = 0; i < 3; ++i) {
ForecastRevision r;
r.issuedAt = QDateTime::currentDateTimeUtc().addSecs(i * 60);
r.points.append({ recente, 500.0 + i });
ForecastPoint pt;
pt.targetAt = recente;
pt.wh = 500.0 + i;
r.points.append(pt);
QVERIFY(magasin.enregistrer(r));
}
magasin.purger();

View File

@ -86,8 +86,9 @@ private slots:
void testReportingDayBoundaryIsPublishedNotDeducible();
void testDrawMarginComparesHeadroomsNotCaps();
//! L'identité du RUN est celle de la révision — et un run qui confirme n'est pas un doublon.
void testTheRunIdentityIsTheRevisionIdentity();
//! 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.
void testTheCurveIsReproducibleAndKeepsItsHoles();
//! La rétention garde les DEUX bouts : n'en garder qu'un détruirait la révision.