feat(prévision PV): la clé devient VRAIE, et la déduplication cesse de rejeter

Le canal est livré (openmeteo 1.15.2+etm11) : forecastCurveTransport, une
QVariantMap TYPÉE déclarée dans la ThingClass — au contrat, sans seconde couche
d'encodage. Mieux que la chaîne JSON envisagée : le §12.5 réclamait une version
dans la charge utile faute de schéma ; ici la charge utile a un schéma ET porte
sa version.

issuedAt VOYAGE DANS LA CHARGE UTILE. La clé de révision devient exacte, et tout
le §13 est levé : receivedAt, l'interdiction d'en calculer un horizon, la dette
meta.json. targetAt - issuedAt EST l'horizon. Le coût que je chiffrais disparaît
avec sa cause.

CE QUE J'AVAIS FAUX. Ma déduplication REJETAIT une révision aux valeurs
identiques. Avec une vraie heure de run, c'est faux : un run plus récent portant
les mêmes chiffres CONFIRME, il ne répète pas. Le rejeter rendrait indistincts
« le modèle a confirmé quatre fois » et « le modèle n'a rien dit depuis ce
matin ».

Le mécanisme change donc de nature et se simplifie : la CLÉ déduplique par
construction, et la comparaison de contenu ne fait plus que JOURNALISER (« le
run confirme le précédent »). Si issuedAt bougeait un jour 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. Une déduplication de contenu l'aurait effacé.

La comparaison sur l'intersection reste, mais comme précaution : la fenêtre
couvre désormais les journées entières et les révisions sont alignées. Elle ne
dépend d'aucun comportement du voisin dont personne ne nous préviendrait.

model porte la provenance réelle, repli best_match compris. La colonne cesse
d'être nulle ; la règle de non-remplissage rétroactif reste.

L'INDEXATION EST LUE, PAS SUPPOSÉE. `convention` dit que p[i] est la moyenne sur
[t0 + i·step, t0 + (i+1)·step) : l'énergie vaut p[i] x step / 3600, sans
supposer que le pas vaut une heure. `unit` est VÉRIFIÉE — lire des Wh comme des
W donnerait une courbe plausible, fausse d'un facteur qui dépend du pas.

Lecture branchée dans le cycle, et c'est sans coût : un run déjà connu n'écrit
rien. Attendre une notification serait plus élégant et MOINS SÛR — un état
publié avant notre démarrage ne notifierait jamais, et la courbe resterait
invisible jusqu'au run suivant. L'état est cherché par son NOM, jamais par un
identifiant de ThingClass. Un refus est journalisé une fois par MOTIF, pas une
fois par cycle.

Deux tests : l'identité du run est celle de la révision (avec l'assertion
centrale — un run qui confirme EST conservé), et le refus de charge utile plutôt
que l'interprétation au mieux (version, unité, vide, tronquée, et un créneau
illisible qui reste un TROU).

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:14:18 +02:00
parent 3ab9d22160
commit 4231453ea1
7 changed files with 377 additions and 113 deletions

View File

@ -469,3 +469,75 @@ Donc : la colonne `model` existe et reste **NULLE** tant que la correction n'est
`NULL` qui dit « on ne sait pas » est honnête ; une valeur qui dit « ce qu'on avait demandé » est
fausse d'une façon parfaitement crédible. **Et aucun remplissage rétroactif** : les lignes écrites
avant la correction resteront nulles, parce qu'elles le sont.
---
## 14. Le canal est livré — 2026-09-08, `openmeteo 1.15.2+etm11`
État `forecastCurveTransport`, **`QVariantMap` typée déclarée dans la ThingClass** — donc au
contrat, sans seconde couche d'encodage, et lisible à la sonde. Meilleur que la chaîne JSON
envisagée : le §12.5 demandait un numéro de version *dans* la charge utile faute de schéma ; ici
la charge utile en a un **et** porte sa version.
### 14.1 Trois de mes réserves tombent, et la première m'oblige à me corriger
**`issuedAt` voyage dans la charge utile.** La clé de révision devient **exacte**. Tout le §13 —
`receivedAt`, l'interdiction d'en calculer un horizon, la dette `meta.json` — **est levé** :
`targetAt - issuedAt` **est** l'horizon, et il peut servir à corréler l'erreur à l'âge de la
prévision. Le coût que je chiffrais disparaît avec sa cause.
**La fenêtre couvre les journées entières**, 48 créneaux alignés. Deux révisions du même jour
couvrent exactement les mêmes créneaux. Ma comparaison sur l'intersection **reste juste et
devient une précaution** plutôt qu'une nécessité — je la garde pour la raison du §13.3 : elle ne
dépend d'aucun comportement du voisin dont personne ne nous préviendrait.
**`model` porte la provenance réelle**, repli `best_match` compris et vérifié en provoquant la
panne. La colonne cesse d'être nulle. **La règle de non-remplissage rétroactif reste** : les
lignes écrites avant ne sauront jamais quel modèle a répondu, et elles doivent le dire.
### 14.2 CE QUE J'AVAIS FAUX — la déduplication ne doit pas REJETER
Ma première version rejetait une révision dont les valeurs coïncidaient avec la précédente.
**Avec une vraie heure de run, c'est faux.**
> **Un run plus récent portant les mêmes chiffres CONFIRME, il ne répète pas.** Le modèle n'a pas
> changé d'avis — c'est une information, et la rejeter rendrait indistincts « le modèle a
> confirmé quatre fois » et « le modèle n'a rien dit depuis ce matin ».
Le mécanisme change donc de nature, et il se simplifie :
| | avant (clé approchée) | après (clé vraie) |
|---|---|---|
| **déduplique** | la comparaison de contenu | **la clé**, par construction |
| **le contenu** | rejetait | **journalise seulement** — « le run confirme le précédent » |
Et si `issuedAt` bougeait un jour 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. Une déduplication de
contenu l'aurait effacé — c'est le mode d'érosion du masque, appliqué à une donnée.
### 14.3 L'indexation est LUE, pas supposée
`convention` voyage dans la charge utile : `p[i]` est la **moyenne sur `[t0 + i·step,
t0 + (i+1)·step)`**. L'énergie du créneau vaut donc `p[i] × step / 3600` — **sans supposer que le
pas vaut une heure**. Le jour où il vaudra un quart d'heure, rien ici ne changera.
`unit` est **vérifiée** et non supposée : lire des Wh comme des W donnerait une courbe plausible,
fausse d'un facteur qui dépend du pas — exactement le genre de nombre que personne ne rattrape.
### 14.4 Ce qui est branché
`EnergyArbitrator::lireCourbePrevision()`, à chaque cycle, **et c'est sans coût** : un run déjà
connu n'écrit rien, la clé s'en charge. Attendre une notification serait plus élégant et **moins
sûr** — un état publié avant notre démarrage ne nous notifierait jamais, et la courbe resterait
invisible jusqu'au run suivant.
L'état est cherché par **le nom de l'état**, jamais par un identifiant de ThingClass : le
producteur peut renommer sa classe ou en ajouter une seconde sans que cela nous concerne. Ce qui
fait contrat, c'est le nom déclaré dans son JSON.
Un refus est journalisé **une fois par motif**, pas une fois par cycle : une charge utile refusée
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.

View File

@ -419,6 +419,11 @@ void EnergyArbitrator::update(const QDateTime &currentDateTime)
// rallume pas non plus : la marge est encore négative, et l'étape 3 la borne.
shedWaterfallForOverload(currentDateTime);
// La prévision se lit AVANT le contexte, et elle n'y entre pas : ce lot est un AFFICHAGE.
// Elle ne pondère aucune échéance et ne déplace aucun plan — le jour où l'étalement éco la
// lira, ce sera un lot à part, avec sa propre décision.
lireCourbePrevision();
SurplusContext ctx = buildContext(currentDateTime);
// Les dérogations vivent ENTRE la construction et la décision : elles se terminent sur ce
// que le contexte rapporte, et les survivantes y sont marquées avant que le plan ne soit
@ -2239,6 +2244,40 @@ void EnergyArbitrator::buildDegradedTelemetry(const QDateTime &now)
m_telemetry = snap;
}
void EnergyArbitrator::lireCourbePrevision()
{
if (!m_forecastStore || !m_forecastStore->isValid() || !m_tm)
return;
// On cherche l'état par son NOM, pas la ThingClass par son identifiant : le producteur peut
// renommer sa classe ou en ajouter une seconde sans que cela nous concerne. Ce qui fait
// contrat, c'est le nom de l'état — il est déclaré dans le JSON du plugin.
for (Thing *thing : m_tm->configuredThings()) {
if (!thing->thingClass().hasStateType("forecastCurveTransport"))
continue;
const QVariantMap charge = thing->stateValue("forecastCurveTransport").toMap();
if (charge.isEmpty())
continue; // rien publié encore : une ABSENCE, pas une erreur
QString refus;
const ForecastRevision rev = ForecastStore::depuisTransport(charge, refus);
if (!refus.isEmpty()) {
// UNE FOIS PAR MOTIF, pas une fois par cycle : une charge utile refusée le reste,
// et répéter l'avertissement chaque minute noierait le journal — donc le signal.
if (m_forecastRefusVu.value(thing->id().toString()) != refus) {
m_forecastRefusVu.insert(thing->id().toString(), refus);
qCWarning(dcNymeaEnergy()) << "[Prévision] charge utile REFUSÉE de"
<< thing->name() << ":" << refus
<< "— aucune courbe enregistrée (jamais une lecture "
"partielle qui aurait l'air de marcher)";
}
continue;
}
m_forecastRefusVu.remove(thing->id().toString());
m_forecastStore->enregistrer(rev); // le run déjà connu rend faux, sans bruit
}
}
void EnergyArbitrator::attacherFinsDerogation(QVariantMap &snap) const
{
// \rule{LM-1500-f} — FINS DE DÉROGATION, publiées au NIVEAU DE LA TRAME, pas dans loads[].

View File

@ -782,6 +782,16 @@ private:
//! déjà plus, et l'événement disparaîtrait quand il explique le plus de choses.
void attacherFinsDerogation(QVariantMap &snap) const;
/*!
* \brief Lit la courbe publiée par le plugin météo et l'enregistre si le run est neuf.
*
* \note Appelée à chaque cycle, et c'est SANS COÛT : l'identité du run déduplique par la
* clé, donc une lecture qui ne rapporte rien de neuf n'écrit rien. Attendre une
* notification serait plus élégant et moins sûr — un état publié avant notre démarrage ne
* nous notifierait jamais, et la courbe resterait invisible jusqu'au prochain run.
*/
void lireCourbePrevision();
/*!
@ -853,6 +863,8 @@ private:
QHash<QString, ILoadAdapter *> m_loadAdapters;
//! Historique bitemporel des prévisions PV. Possédé ; purgé une fois par jour.
ForecastStore *m_forecastStore = nullptr;
//! Dernier motif de refus vu par Thing — pour n'avertir qu'au CHANGEMENT de motif.
QHash<QString, QString> m_forecastRefusVu;
QTimer *m_forecastPurge = nullptr;
//! loadId → config AYANT SERVI à construire l'adaptateur. Permet le rebuild incrémental
//! (ECS-412) : on ne reconstruit que si le matériel a changé.

View File

@ -23,6 +23,10 @@ constexpr int RetentionPleineJours = 7;
//! Tolérance de comparaison entre deux valeurs d'énergie (Wh). Deux flottants issus du même
//! calcul sont égaux au bit près ; ce seuil couvre un ré-encodage JSON, pas une révision.
constexpr double EgaliteWh = 0.001;
//! Version de la charge utile de TRANSPORT qu'on sait lire. Une autre est REFUSÉE, jamais
//! interprétée au mieux — cf. \c ForecastStore::depuisTransport().
constexpr int TransportVersion = 1;
}
QString ForecastStore::cheminParDefaut()
@ -36,6 +40,55 @@ QString ForecastStore::cheminParDefaut()
return NymeaSettings::storagePath() + "/energy-forecast.sqlite";
}
ForecastRevision ForecastStore::depuisTransport(const QVariantMap &charge, QString &refus)
{
ForecastRevision r;
refus.clear();
if (charge.isEmpty()) { refus = QStringLiteral("EMPTY"); return r; }
// LA VERSION D'ABORD, et le refus plutôt que l'interprétation au mieux. Une charge utile qui
// traverse une frontière de processus n'a plus de schéma : personne ne vérifie sa forme, et
// une lecture « au mieux » d'une version inconnue produirait une courbe fausse qui aurait
// l'air de marcher.
if (charge.value(QStringLiteral("v")).toInt() != TransportVersion) {
refus = QStringLiteral("VERSION_%1").arg(charge.value(QStringLiteral("v")).toString());
return r;
}
// L'unité et la convention sont LUES. Les supposer, c'est hériter d'un fait vrai sous des
// conditions qu'on ne saura pas reproduire le jour où elles changeront.
const QString unite = charge.value(QStringLiteral("unit")).toString();
if (unite != QLatin1String("W")) { refus = QStringLiteral("UNIT_%1").arg(unite); return r; }
const qint64 t0 = charge.value(QStringLiteral("t0")).toLongLong();
const int step = charge.value(QStringLiteral("step")).toInt();
const qint64 run = charge.value(QStringLiteral("issuedAt")).toLongLong();
const QVariantList p = charge.value(QStringLiteral("p")).toList();
if (t0 <= 0 || step <= 0 || run <= 0 || p.isEmpty()) {
refus = QStringLiteral("MALFORMED");
return r;
}
r.issuedAt = QDateTime::fromSecsSinceEpoch(run, QTimeZone::utc());
r.model = charge.value(QStringLiteral("model")).toString();
for (int i = 0; i < p.count(); ++i) {
bool ok = false;
const double w = p.at(i).toDouble(&ok);
// UN CRÉNEAU ILLISIBLE EST UN TROU, pas un zéro. Il ne crée simplement pas de point :
// la courbe en portera l'absence, et l'écran devra l'afficher comme telle.
if (!ok) continue;
ForecastPoint pt;
// `convention` : p[i] est la MOYENNE sur [t0 + i·step, t0 + (i+1)·step). L'énergie du
// créneau s'en déduit sans supposer que le pas vaut une heure — et le jour où il vaudra
// un quart d'heure, rien ici ne changera.
pt.targetAt = QDateTime::fromSecsSinceEpoch(t0 + qint64(i) * step, QTimeZone::utc());
pt.wh = w * step / 3600.0;
r.points.append(pt);
}
if (r.points.isEmpty())
refus = QStringLiteral("NO_POINT");
return r;
}
ForecastStore::ForecastStore(const QString &cheminFichier, QObject *parent)
: QObject(parent)
{
@ -74,12 +127,12 @@ bool ForecastStore::migrer()
q.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS forecastPv ("
" targetAt INTEGER NOT NULL,"
" receivedAt INTEGER NOT NULL,"
" issuedAt INTEGER NOT NULL,"
" forecastWh REAL NOT NULL,"
" model TEXT,"
" PRIMARY KEY (targetAt, receivedAt))"))
" PRIMARY KEY (targetAt, issuedAt))"))
&& q.exec(QStringLiteral(
"CREATE INDEX IF NOT EXISTS idxForecastReceived ON forecastPv(receivedAt)"))
"CREATE INDEX IF NOT EXISTS idxForecastIssued ON forecastPv(issuedAt)"))
&& q.exec(QStringLiteral("PRAGMA user_version = %1").arg(SchemaVersion));
if (!ok)
qCWarning(dcNymeaEnergy()) << "[Prévision] migration échouée :" << q.lastError().text();
@ -92,50 +145,59 @@ bool ForecastStore::enregistrer(const ForecastRevision &revision)
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.receivedAt.isValid()) {
qCDebug(dcNymeaEnergy()) << "[Prévision] révision vide ou sans instant — non enregistrée";
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();
// --- LA CLÉ DÉDUPLIQUE ------------------------------------------------------------------
//
// 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;
}
// --- DÉDUPLICATION SUR L'INTERSECTION -------------------------------------------------
// --- LE CONTENU OBSERVE, IL NE REJETTE PAS ----------------------------------------------
//
// Deux révisions sont la MÊME si, sur leurs heures COMMUNES, toutes les valeurs coïncident.
// Comparer les charges utiles entières serait faux dès que la fenêtre glisse : la courbe de
// 11 h a un point de moins que celle de 10 h, et on verrait une révision toutes les heures —
// exactement ce que la déduplication existe pour éviter.
// 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.
QSqlQuery derniere(m_db);
derniere.prepare(QStringLiteral(
"SELECT MAX(receivedAt) FROM forecastPv WHERE receivedAt < ?"));
derniere.addBindValue(revision.receivedAt.toSecsSinceEpoch() + 1);
derniere.prepare(QStringLiteral("SELECT MAX(issuedAt) FROM forecastPv WHERE issuedAt < ?"));
derniere.addBindValue(run);
if (derniere.exec() && derniere.next() && !derniere.value(0).isNull()) {
const qint64 refRecu = derniere.value(0).toLongLong();
QSqlQuery ref(m_db);
ref.prepare(QStringLiteral(
"SELECT targetAt, forecastWh FROM forecastPv WHERE receivedAt = ?"));
ref.addBindValue(refRecu);
ref.prepare(QStringLiteral("SELECT targetAt, forecastWh FROM forecastPv WHERE issuedAt = ?"));
ref.addBindValue(derniere.value(0).toLongLong());
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;
int communs = 0, differents = 0;
for (const ForecastPoint &p : revision.points) {
const auto it = precedent.constFind(p.targetAt.toSecsSinceEpoch());
const auto it = precedent.constFind(p.targetAt.toUTC().toSecsSinceEpoch());
if (it == precedent.constEnd())
continue; // hors intersection : ne départage rien
continue;
++communs;
if (qAbs(it.value() - p.wh) > EgaliteWh) { identique = false; break; }
}
// `communs > 0` est REQUIS : sans heure commune, il n'y a rien à comparer, et conclure
// « identique » ferait disparaître une révision dont on ne sait rien. Le cas se produit
// au passage de minuit, quand la fenêtre a entièrement tourné.
if (identique && communs > 0) {
qCDebug(dcNymeaEnergy()).noquote()
<< QStringLiteral("[Prévision] même courbe sur %1 heure(s) commune(s) — "
"pas une révision").arg(communs);
return false;
if (qAbs(it.value() - p.wh) > EgaliteWh) ++differents;
}
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 ------------------------------------------------------
@ -143,15 +205,15 @@ bool ForecastStore::enregistrer(const ForecastRevision &revision)
qCWarning(dcNymeaEnergy()) << "[Prévision] transaction refusée :" << m_db.lastError().text();
QSqlQuery ins(m_db);
ins.prepare(QStringLiteral(
"INSERT OR REPLACE INTO forecastPv (targetAt, receivedAt, forecastWh, model) "
"INSERT OR REPLACE INTO forecastPv (targetAt, issuedAt, forecastWh, model) "
"VALUES (?, ?, ?, ?)"));
const qint64 recu = revision.receivedAt.toUTC().toSecsSinceEpoch();
for (const ForecastPoint &p : revision.points) {
ins.addBindValue(p.targetAt.toUTC().toSecsSinceEpoch());
ins.addBindValue(recu);
ins.addBindValue(run);
ins.addBindValue(p.wh);
// MODEL À NULL quand le producteur publie une INTENTION et non une provenance. Une
// chaîne vide serait déjà une valeur ; NULL dit « on ne sait pas », ce qui est vrai.
// 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.
ins.addBindValue(revision.model.isEmpty() ? QVariant(QMetaType(QMetaType::QString))
: QVariant(revision.model));
if (!ins.exec()) {
@ -165,9 +227,10 @@ bool ForecastStore::enregistrer(const ForecastRevision &revision)
return false;
}
qCInfo(dcNymeaEnergy()).noquote()
<< QStringLiteral("[Prévision] révision enregistrée — %1 point(s), reçue %2")
<< QStringLiteral("[Prévision] run %1 enregistré — %2 créneau(x), modèle %3")
.arg(revision.issuedAt.toUTC().toString(Qt::ISODate))
.arg(revision.points.count())
.arg(revision.receivedAt.toUTC().toString(Qt::ISODate));
.arg(revision.model.isEmpty() ? QStringLiteral("(inconnu)") : revision.model);
return true;
}
@ -185,9 +248,9 @@ QList<ForecastPoint> ForecastStore::courbe(const QDateTime &de, const QDateTime
QSqlQuery q(m_db);
q.prepare(QStringLiteral(
"SELECT targetAt, forecastWh FROM forecastPv f "
"WHERE targetAt BETWEEN ? AND ? AND receivedAt <= ? "
" AND receivedAt = (SELECT MAX(receivedAt) FROM forecastPv g "
" WHERE g.targetAt = f.targetAt AND g.receivedAt <= ?) "
"WHERE targetAt BETWEEN ? AND ? AND issuedAt <= ? "
" AND issuedAt = (SELECT MAX(issuedAt) FROM forecastPv g "
" WHERE g.targetAt = f.targetAt AND g.issuedAt <= ?) "
"ORDER BY targetAt"));
const qint64 limite = borne.toUTC().toSecsSinceEpoch();
q.addBindValue(de.toUTC().toSecsSinceEpoch());
@ -217,7 +280,7 @@ QList<QPair<QDateTime, double>> ForecastStore::revisions(const QDateTime &target
return sortie;
QSqlQuery q(m_db);
q.prepare(QStringLiteral(
"SELECT receivedAt, forecastWh FROM forecastPv WHERE targetAt = ? ORDER BY receivedAt"));
"SELECT issuedAt, forecastWh FROM forecastPv WHERE targetAt = ? ORDER BY issuedAt"));
q.addBindValue(targetAt.toUTC().toSecsSinceEpoch());
if (!q.exec())
return sortie;
@ -242,10 +305,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 receivedAt NOT IN ("
" SELECT MIN(receivedAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt"
"DELETE FROM forecastPv WHERE targetAt < ? AND issuedAt NOT IN ("
" SELECT MIN(issuedAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt"
" UNION"
" SELECT MAX(receivedAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt)"));
" SELECT MAX(issuedAt) 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

@ -7,6 +7,7 @@
#include <QObject>
#include <QSqlDatabase>
#include <QString>
#include <QVariantMap>
/*!
* \file forecaststore.h
@ -49,34 +50,31 @@ struct ForecastPoint {
*/
struct ForecastRevision {
/*!
* \brief Instant de RÉCEPTION, UTC — et **surtout pas** l'heure d'initialisation du run.
* \brief Instant d'INITIALISATION DU RUN, UTC — le vrai, pas l'heure de réception.
*
* \warning **AUCUN HORIZON NE SE CALCULE DEPUIS CE CHAMP.** Entre l'initialisation d'un run
* et sa mise à disposition s'écoule la latence de publication du modèle : des **heures**
* pour un modèle global. `targetAt - receivedAt` est donc trop court de toute cette
* latence, **systématiquement et dans le même sens** — une prévision qu'on croirait « à
* H−1 » peut être le run de H−5.
* 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 Pourquoi ce champ s'appelle ainsi
* Le nom porte la protection. `issuedAt` promettrait l'heure du run ; quelqu'un écrirait
* `targetAt - issuedAt` et obtiendrait un nombre **parfaitement plausible**. L'heure du
* run n'est pas dans la réponse de prévision de l'API — il faudrait une requête de plus,
* qu'aucun consommateur actuel ne justifie (cf. \c DESIGN_PREVISION_PV.md §13.4).
*
* \note Ce que la déduplication sauve : les **bornes** des révisions — combien, et laquelle
* s'applique. Ce qu'elle ne sauve pas : leur **âge**. « Quelle prévision » est juste ;
* « quel horizon » est faux.
* \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.
*/
QDateTime receivedAt;
QDateTime issuedAt;
//! Les points de la courbe. Une révision **vide n'est pas enregistrée** : voir \c enregistrer().
QList<ForecastPoint> points;
/*!
* \brief Modèle ayant répondu — **vide tant que le producteur publie une INTENTION**.
* \brief Modèle ayant RÉPONDU — provenance réelle, repli compris.
*
* Le plugin météo publie aujourd'hui le modèle *configuré*, pas celui qui a répondu. Le
* stocker comme provenance enregistrerait une intention — et une intention rangée dans une
* colonne de provenance **devient** une provenance au premier lecteur. Vide dit « on ne
* sait pas », ce qui est vrai.
* Le producteur publie désormais celui qui a répondu et non celui qui était configuré, repli
* `best_match` inclus, vérifié en provoquant la panne. La colonne peut donc porter une
* valeur.
*
* \note **Aucun remplissage rétroactif.** Les lignes écrites avant cette correction ne
* sauront jamais quel modèle a répondu, et elles doivent le dire : leur `model` reste
* NULL. Le combler après coup y rangerait une supposition sous un nom de provenance.
*/
QString model;
};
@ -109,25 +107,48 @@ public:
//! \return Le chemin, surchargeable par \c NYMEA_ENERGY_FORECAST_DB (tests).
static QString cheminParDefaut();
/*!
* \brief Convertit la charge utile de transport en révision.
* \param charge Carte typée publiée par le producteur météo (\c forecastCurveTransport).
* \param[out] refus Motif d'échec, vide si la lecture a réussi. Un CODE, pas une phrase.
* \return La révision ; invalide (\c points vide) si la charge utile est refusée.
*
* \par Le REFUS, jamais l'interprétation au mieux
* La charge utile traverse une frontière de processus : aucun compilateur ne vérifie sa
* forme. Une version inconnue produit **aucune courbe** et un motif nommé — jamais une
* lecture partielle qui aurait l'air de marcher. Sans cela, le symptôme d'un changement de
* forme serait « courbe vide », **indistinguable d'une nuit**.
*
* \par L'indexation est LUE, pas supposée
* \c convention voyage dans la charge utile : \c p[i] est la moyenne sur
* `[t0 + i·step, t0 + (i+1)·step)`. On n'a donc ni à la redécouvrir, ni à faire confiance
* à un document qui pourrait se périmer sans que le code s'en aperçoive.
*/
static ForecastRevision depuisTransport(const QVariantMap &charge, QString &refus);
//! \brief La base est-elle utilisable ? \return Faux si l'ouverture ou la migration a échoué.
bool isValid() const { return m_valide; }
/*!
* \brief Enregistre une révision, si c'en est une.
* \param revision Révision reçue.
* \return Vrai si elle a été écrite ; **faux si elle a été reconnue identique** à la
* dernière connue, ou si elle était vide, ou en cas d'échec.
* \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 comparaison porte sur l'INTERSECTION, pas sur la charge utile
* Si la fenêtre publiée **glisse** — seulement les heures à venir — la charge utile de
* 11 h a un point de moins que celle de 10 h, et une égalité de charge utile verrait une
* révision **toutes les heures**, ce que la déduplication existe précisément pour éviter.
* Deux révisions sont donc les mêmes si, **sur leurs heures communes**, toutes les valeurs
* coïncident.
* \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.
*
* Ce choix ne dépend pas de la forme que le producteur publie aujourd'hui : il est correct
* que la fenêtre glisse ou non, et il ne peut donc pas être invalidé par un changement
* chez le voisin dont personne ne nous préviendrait.
* \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 ».
*
* 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.
*/
bool enregistrer(const ForecastRevision &revision);
@ -148,7 +169,7 @@ public:
* \brief Toutes les révisions connues pour une heure visée, de la plus ancienne à la plus
* récente. Sert à lire l'historique d'une révision, jamais à décider.
* \param targetAt Heure visée (UTC).
* \return Paires (instant de réception, énergie). Vide si rien n'est connu.
* \return Paires (instant d'émission du run, énergie). Vide si rien n'est connu.
*/
QList<QPair<QDateTime, double>> revisions(const QDateTime &targetAt) const;

View File

@ -932,51 +932,54 @@ ForecastRevision revision(const QDateTime &recue, const QDateTime &depuis,
const QList<double> &wh)
{
ForecastRevision r;
r.receivedAt = recue;
r.issuedAt = recue;
for (int i = 0; i < wh.count(); ++i)
r.points.append({ depuis.addSecs(i * 3600), wh.at(i) });
return r;
}
}
void TestLoadModel::testARevisionIsJudgedOnTheOverlapNotThePayload()
void TestLoadModel::testTheRunIdentityIsTheRevisionIdentity()
{
QTemporaryDir dir;
ForecastStore magasin(dir.filePath("forecast.sqlite"));
QVERIFY(magasin.isValid());
const QDateTime h10 = QDateTime(QDate(2026, 9, 9), QTime(10, 0), QTimeZone::utc());
const QDateTime t10 = h10; // la fenêtre commence à l'heure courante
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 t0 = QDateTime(QDate(2026, 9, 9), QTime(10, 0), QTimeZone::utc());
// 1. Première révision : elle s'enregistre, il n'y a rien à quoi la comparer.
QVERIFY(magasin.enregistrer(revision(h10, t10, {100, 200, 300, 400})));
// 1. Premier run : il s'enregistre.
QVERIFY(magasin.enregistrer(revision(run08, t0, {100, 200, 300, 400})));
// 2. LA MÊME COURBE RELUE — pas une révision. C'est le cas nominal : les modèles se
// rafraîchissent au mieux toutes les heures, donc la plupart des lectures rendent
// l'identique.
QVERIFY2(!magasin.enregistrer(revision(h10.addSecs(600), t10, {100, 200, 300, 400})),
"une courbe identique n'est pas une révision");
// 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})));
QCOMPARE(magasin.revisions(t0).count(), 1);
// 3. LE CAS QUE `==` RATERAIT — la fenêtre a GLISSÉ d'une heure : un point de moins, et les
// valeurs communes inchangées. La charge utile DIFFÈRE, la prévision NON. Sans la
// comparaison sur l'intersection, on compterait une révision toutes les heures — ce que
// la déduplication existe précisément pour éviter.
QVERIFY2(!magasin.enregistrer(revision(h10.addSecs(3600), t10.addSecs(3600), {200, 300, 400})),
"fenêtre glissée, valeurs communes inchangées : ce n'est pas une révision");
// 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é");
QCOMPARE(magasin.revisions(t0).count(), 2);
// 4. CONTRE-ÉPREUVE — une seule valeur commune change, et c'est une révision. Sans elle, on
// ne saurait pas si la déduplication distingue quoi que ce soit ou refuse tout.
QVERIFY2(magasin.enregistrer(revision(h10.addSecs(7200), t10.addSecs(3600), {200, 999, 400})),
"une valeur commune différente EST une révision");
// 5. AUCUNE HEURE COMMUNE — c'est une révision. Le cas se produit au passage de minuit,
// quand la fenêtre a entièrement tourné : conclure « identique » sans intersection
// ferait disparaître une prévision dont on ne sait rien.
QVERIFY(magasin.enregistrer(revision(h10.addSecs(10800), t10.addDays(3), {10, 20})));
// 6. Une révision VIDE n'en est pas une : l'absence est un trou, pas une courbe de zéros.
ForecastRevision vide; vide.receivedAt = h10.addSecs(14400);
// 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.
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
}
void TestLoadModel::testTheCurveIsReproducibleAndKeepsItsHoles()
@ -1027,7 +1030,7 @@ void TestLoadModel::testRetentionKeepsBothEndsOfEachRevision()
const QDateTime cible = QDateTime(QDate(2026, 9, 1), QTime(12, 0), QTimeZone::utc());
for (int i = 0; i < 4; ++i) {
ForecastRevision r;
r.receivedAt = cible.addSecs(-(4 - i) * 3600);
r.issuedAt = cible.addSecs(-(4 - i) * 3600);
r.points.append({ cible, 1000.0 + i });
QVERIFY(magasin.enregistrer(r));
}
@ -1050,10 +1053,63 @@ void TestLoadModel::testRetentionKeepsBothEndsOfEachRevision()
const QDateTime recente = QDateTime::currentDateTimeUtc().addSecs(3600);
for (int i = 0; i < 3; ++i) {
ForecastRevision r;
r.receivedAt = QDateTime::currentDateTimeUtc().addSecs(i * 60);
r.issuedAt = QDateTime::currentDateTimeUtc().addSecs(i * 60);
r.points.append({ recente, 500.0 + i });
QVERIFY(magasin.enregistrer(r));
}
magasin.purger();
QCOMPARE(magasin.revisions(recente).count(), 3);
}
void TestLoadModel::testAnUnknownPayloadIsRefusedNotBestEffort()
{
// 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) {
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}};
};
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.
const ForecastRevision ok = ForecastStore::depuisTransport(
charge(1, "W", 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"));
// 2. VERSION INCONNUE — refusée, et le motif la NOMME. Pas de courbe partielle.
QVERIFY(ForecastStore::depuisTransport(charge(2, "W", 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)
.points.isEmpty());
QCOMPARE(refus, QString("UNIT_Wh"));
// 4. CHARGE UTILE VIDE ou tronquée — refus nommé, jamais une courbe de zéros.
QVERIFY(ForecastStore::depuisTransport(QVariantMap(), refus).points.isEmpty());
QCOMPARE(refus, QString("EMPTY"));
QVERIFY(ForecastStore::depuisTransport(charge(1, "W", 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.
const ForecastRevision troue = ForecastStore::depuisTransport(
charge(1, "W", QVariantList{100, QString("bruit"), 300}), refus);
QVERIFY(refus.isEmpty());
QCOMPARE(troue.points.count(), 2);
}

View File

@ -86,13 +86,14 @@ private slots:
void testReportingDayBoundaryIsPublishedNotDeducible();
void testDrawMarginComparesHeadroomsNotCaps();
//! Une révision identique SUR SES HEURES COMMUNES n'en est pas une — et c'est le cas qu'une
//! égalité de charge utile raterait dès que la fenêtre glisse.
void testARevisionIsJudgedOnTheOverlapNotThePayload();
//! L'identité du RUN est celle de la révision — et un run qui confirme n'est pas un doublon.
void testTheRunIdentityIsTheRevisionIdentity();
//! 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.
void testRetentionKeepsBothEndsOfEachRevision();
//! La charge utile de transport se REFUSE plutôt que de s'interpréter au mieux.
void testAnUnknownPayloadIsRefusedNotBestEffort();
};
#endif // TESTLOADMODEL_H