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:
parent
1b807614d6
commit
139d686302
@ -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
|
`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
|
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.
|
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.
|
||||||
|
|||||||
@ -419,6 +419,11 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
|||||||
// rallume pas non plus : la marge est encore négative, et l'étape 3 la borne.
|
// rallume pas non plus : la marge est encore négative, et l'étape 3 la borne.
|
||||||
shedWaterfallForOverload(currentDateTime);
|
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);
|
SurplusContext ctx = buildContext(currentDateTime);
|
||||||
// Les dérogations vivent ENTRE la construction et la décision : elles se terminent sur ce
|
// 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
|
// 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;
|
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
|
void EnergyArbitrator::attacherFinsDerogation(QVariantMap &snap) const
|
||||||
{
|
{
|
||||||
// \rule{LM-1500-f} — FINS DE DÉROGATION, publiées au NIVEAU DE LA TRAME, pas dans loads[].
|
// \rule{LM-1500-f} — FINS DE DÉROGATION, publiées au NIVEAU DE LA TRAME, pas dans loads[].
|
||||||
|
|||||||
@ -782,6 +782,16 @@ private:
|
|||||||
//! déjà plus, et l'événement disparaîtrait quand il explique le plus de choses.
|
//! déjà plus, et l'événement disparaîtrait quand il explique le plus de choses.
|
||||||
void attacherFinsDerogation(QVariantMap &snap) const;
|
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;
|
QHash<QString, ILoadAdapter *> m_loadAdapters;
|
||||||
//! Historique bitemporel des prévisions PV. Possédé ; purgé une fois par jour.
|
//! Historique bitemporel des prévisions PV. Possédé ; purgé une fois par jour.
|
||||||
ForecastStore *m_forecastStore = nullptr;
|
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;
|
QTimer *m_forecastPurge = nullptr;
|
||||||
//! loadId → config AYANT SERVI à construire l'adaptateur. Permet le rebuild incrémental
|
//! loadId → config AYANT SERVI à construire l'adaptateur. Permet le rebuild incrémental
|
||||||
//! (ECS-412) : on ne reconstruit que si le matériel a changé.
|
//! (ECS-412) : on ne reconstruit que si le matériel a changé.
|
||||||
|
|||||||
@ -23,6 +23,10 @@ constexpr int RetentionPleineJours = 7;
|
|||||||
//! Tolérance de comparaison entre deux valeurs d'énergie (Wh). Deux flottants issus du même
|
//! 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.
|
//! calcul sont égaux au bit près ; ce seuil couvre un ré-encodage JSON, pas une révision.
|
||||||
constexpr double EgaliteWh = 0.001;
|
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()
|
QString ForecastStore::cheminParDefaut()
|
||||||
@ -36,6 +40,55 @@ QString ForecastStore::cheminParDefaut()
|
|||||||
return NymeaSettings::storagePath() + "/energy-forecast.sqlite";
|
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)
|
ForecastStore::ForecastStore(const QString &cheminFichier, QObject *parent)
|
||||||
: QObject(parent)
|
: QObject(parent)
|
||||||
{
|
{
|
||||||
@ -74,12 +127,12 @@ bool ForecastStore::migrer()
|
|||||||
q.exec(QStringLiteral(
|
q.exec(QStringLiteral(
|
||||||
"CREATE TABLE IF NOT EXISTS forecastPv ("
|
"CREATE TABLE IF NOT EXISTS forecastPv ("
|
||||||
" targetAt INTEGER NOT NULL,"
|
" targetAt INTEGER NOT NULL,"
|
||||||
" receivedAt INTEGER NOT NULL,"
|
" issuedAt INTEGER NOT NULL,"
|
||||||
" forecastWh REAL NOT NULL,"
|
" forecastWh REAL NOT NULL,"
|
||||||
" model TEXT,"
|
" model TEXT,"
|
||||||
" PRIMARY KEY (targetAt, receivedAt))"))
|
" PRIMARY KEY (targetAt, issuedAt))"))
|
||||||
&& q.exec(QStringLiteral(
|
&& 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));
|
&& q.exec(QStringLiteral("PRAGMA user_version = %1").arg(SchemaVersion));
|
||||||
if (!ok)
|
if (!ok)
|
||||||
qCWarning(dcNymeaEnergy()) << "[Prévision] migration échouée :" << q.lastError().text();
|
qCWarning(dcNymeaEnergy()) << "[Prévision] migration échouée :" << q.lastError().text();
|
||||||
@ -92,50 +145,59 @@ bool ForecastStore::enregistrer(const ForecastRevision &revision)
|
|||||||
return false;
|
return false;
|
||||||
// UNE RÉVISION VIDE N'EST PAS UNE RÉVISION. Pas de réseau, API en erreur, plan absent :
|
// 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 ».
|
// 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()) {
|
if (revision.points.isEmpty() || !revision.issuedAt.isValid()) {
|
||||||
qCDebug(dcNymeaEnergy()) << "[Prévision] révision vide ou sans instant — non enregistrée";
|
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;
|
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.
|
// Un run PLUS RÉCENT portant les mêmes chiffres est une information : le modèle n'a pas
|
||||||
// Comparer les charges utiles entières serait faux dès que la fenêtre glisse : la courbe de
|
// changé d'avis. IL CONFIRME, IL NE RÉPÈTE PAS. Le rejeter détruirait précisément ce que
|
||||||
// 11 h a un point de moins que celle de 10 h, et on verrait une révision toutes les heures —
|
// cette table conserve, et rendrait indistincts « le modèle a confirmé quatre fois » et
|
||||||
// exactement ce que la déduplication existe pour éviter.
|
// « 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);
|
QSqlQuery derniere(m_db);
|
||||||
derniere.prepare(QStringLiteral(
|
derniere.prepare(QStringLiteral("SELECT MAX(issuedAt) FROM forecastPv WHERE issuedAt < ?"));
|
||||||
"SELECT MAX(receivedAt) FROM forecastPv WHERE receivedAt < ?"));
|
derniere.addBindValue(run);
|
||||||
derniere.addBindValue(revision.receivedAt.toSecsSinceEpoch() + 1);
|
|
||||||
if (derniere.exec() && derniere.next() && !derniere.value(0).isNull()) {
|
if (derniere.exec() && derniere.next() && !derniere.value(0).isNull()) {
|
||||||
const qint64 refRecu = derniere.value(0).toLongLong();
|
|
||||||
QSqlQuery ref(m_db);
|
QSqlQuery ref(m_db);
|
||||||
ref.prepare(QStringLiteral(
|
ref.prepare(QStringLiteral("SELECT targetAt, forecastWh FROM forecastPv WHERE issuedAt = ?"));
|
||||||
"SELECT targetAt, forecastWh FROM forecastPv WHERE receivedAt = ?"));
|
ref.addBindValue(derniere.value(0).toLongLong());
|
||||||
ref.addBindValue(refRecu);
|
|
||||||
QHash<qint64, double> precedent;
|
QHash<qint64, double> precedent;
|
||||||
if (ref.exec())
|
if (ref.exec())
|
||||||
while (ref.next())
|
while (ref.next())
|
||||||
precedent.insert(ref.value(0).toLongLong(), ref.value(1).toDouble());
|
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) {
|
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())
|
if (it == precedent.constEnd())
|
||||||
continue; // hors intersection : ne départage rien
|
continue;
|
||||||
++communs;
|
++communs;
|
||||||
if (qAbs(it.value() - p.wh) > EgaliteWh) { identique = false; break; }
|
if (qAbs(it.value() - p.wh) > EgaliteWh) ++differents;
|
||||||
}
|
|
||||||
// `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 (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, en une transaction ------------------------------------------------------
|
||||||
@ -143,15 +205,15 @@ bool ForecastStore::enregistrer(const ForecastRevision &revision)
|
|||||||
qCWarning(dcNymeaEnergy()) << "[Prévision] transaction refusée :" << m_db.lastError().text();
|
qCWarning(dcNymeaEnergy()) << "[Prévision] transaction refusée :" << m_db.lastError().text();
|
||||||
QSqlQuery ins(m_db);
|
QSqlQuery ins(m_db);
|
||||||
ins.prepare(QStringLiteral(
|
ins.prepare(QStringLiteral(
|
||||||
"INSERT OR REPLACE INTO forecastPv (targetAt, receivedAt, forecastWh, model) "
|
"INSERT OR REPLACE INTO forecastPv (targetAt, issuedAt, forecastWh, model) "
|
||||||
"VALUES (?, ?, ?, ?)"));
|
"VALUES (?, ?, ?, ?)"));
|
||||||
const qint64 recu = revision.receivedAt.toUTC().toSecsSinceEpoch();
|
|
||||||
for (const ForecastPoint &p : revision.points) {
|
for (const ForecastPoint &p : revision.points) {
|
||||||
ins.addBindValue(p.targetAt.toUTC().toSecsSinceEpoch());
|
ins.addBindValue(p.targetAt.toUTC().toSecsSinceEpoch());
|
||||||
ins.addBindValue(recu);
|
ins.addBindValue(run);
|
||||||
ins.addBindValue(p.wh);
|
ins.addBindValue(p.wh);
|
||||||
// MODEL À NULL quand le producteur publie une INTENTION et non une provenance. Une
|
// NULL, jamais une chaîne vide : une chaîne vide serait déjà une valeur. Et le producteur
|
||||||
// chaîne vide serait déjà une valeur ; NULL dit « on ne sait pas », ce qui est vrai.
|
// 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))
|
ins.addBindValue(revision.model.isEmpty() ? QVariant(QMetaType(QMetaType::QString))
|
||||||
: QVariant(revision.model));
|
: QVariant(revision.model));
|
||||||
if (!ins.exec()) {
|
if (!ins.exec()) {
|
||||||
@ -165,9 +227,10 @@ bool ForecastStore::enregistrer(const ForecastRevision &revision)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
qCInfo(dcNymeaEnergy()).noquote()
|
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.points.count())
|
||||||
.arg(revision.receivedAt.toUTC().toString(Qt::ISODate));
|
.arg(revision.model.isEmpty() ? QStringLiteral("(inconnu)") : revision.model);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,9 +248,9 @@ QList<ForecastPoint> ForecastStore::courbe(const QDateTime &de, const QDateTime
|
|||||||
QSqlQuery q(m_db);
|
QSqlQuery q(m_db);
|
||||||
q.prepare(QStringLiteral(
|
q.prepare(QStringLiteral(
|
||||||
"SELECT targetAt, forecastWh FROM forecastPv f "
|
"SELECT targetAt, forecastWh FROM forecastPv f "
|
||||||
"WHERE targetAt BETWEEN ? AND ? AND receivedAt <= ? "
|
"WHERE targetAt BETWEEN ? AND ? AND issuedAt <= ? "
|
||||||
" AND receivedAt = (SELECT MAX(receivedAt) FROM forecastPv g "
|
" AND issuedAt = (SELECT MAX(issuedAt) FROM forecastPv g "
|
||||||
" WHERE g.targetAt = f.targetAt AND g.receivedAt <= ?) "
|
" WHERE g.targetAt = f.targetAt AND g.issuedAt <= ?) "
|
||||||
"ORDER BY targetAt"));
|
"ORDER BY targetAt"));
|
||||||
const qint64 limite = borne.toUTC().toSecsSinceEpoch();
|
const qint64 limite = borne.toUTC().toSecsSinceEpoch();
|
||||||
q.addBindValue(de.toUTC().toSecsSinceEpoch());
|
q.addBindValue(de.toUTC().toSecsSinceEpoch());
|
||||||
@ -217,7 +280,7 @@ QList<QPair<QDateTime, double>> ForecastStore::revisions(const QDateTime &target
|
|||||||
return sortie;
|
return sortie;
|
||||||
QSqlQuery q(m_db);
|
QSqlQuery q(m_db);
|
||||||
q.prepare(QStringLiteral(
|
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());
|
q.addBindValue(targetAt.toUTC().toSecsSinceEpoch());
|
||||||
if (!q.exec())
|
if (!q.exec())
|
||||||
return sortie;
|
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.
|
// qu'une détruirait la révision, c'est-à-dire l'objet de cette table.
|
||||||
QSqlQuery q(m_db);
|
QSqlQuery q(m_db);
|
||||||
q.prepare(QStringLiteral(
|
q.prepare(QStringLiteral(
|
||||||
"DELETE FROM forecastPv WHERE targetAt < ? AND receivedAt NOT IN ("
|
"DELETE FROM forecastPv WHERE targetAt < ? AND issuedAt NOT IN ("
|
||||||
" SELECT MIN(receivedAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt"
|
" SELECT MIN(issuedAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt"
|
||||||
" UNION"
|
" 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);
|
q.addBindValue(limite);
|
||||||
if (!q.exec()) {
|
if (!q.exec()) {
|
||||||
qCWarning(dcNymeaEnergy()) << "[Prévision] purge refusée :" << q.lastError().text();
|
qCWarning(dcNymeaEnergy()) << "[Prévision] purge refusée :" << q.lastError().text();
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QSqlDatabase>
|
#include <QSqlDatabase>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QVariantMap>
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \file forecaststore.h
|
* \file forecaststore.h
|
||||||
@ -49,34 +50,31 @@ struct ForecastPoint {
|
|||||||
*/
|
*/
|
||||||
struct ForecastRevision {
|
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
|
* Il **voyage dans la charge utile** que publie le producteur (`issuedAt`), donc sans second
|
||||||
* et sa mise à disposition s'écoule la latence de publication du modèle : des **heures**
|
* appel à l'API. C'est ce qui rend la clé de révision **exacte**, et non approchée.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* \par Pourquoi ce champ s'appelle ainsi
|
* \par Ce que cela change, et il faut le savoir pour ne pas hériter d'une vieille précaution
|
||||||
* Le nom porte la protection. `issuedAt` promettrait l'heure du run ; quelqu'un écrirait
|
* Une première version de ce lot indexait sur l'instant de RÉCEPTION, faute de mieux, et
|
||||||
* `targetAt - issuedAt` et obtiendrait un nombre **parfaitement plausible**. L'heure du
|
* interdisait d'en calculer un horizon : la latence de publication d'un modèle global se
|
||||||
* run n'est pas dans la réponse de prévision de l'API — il faudrait une requête de plus,
|
* compte en heures, donc `targetAt - receivedAt` était trop court, systématiquement.
|
||||||
* qu'aucun consommateur actuel ne justifie (cf. \c DESIGN_PREVISION_PV.md §13.4).
|
* **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.
|
||||||
* \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.
|
|
||||||
*/
|
*/
|
||||||
QDateTime receivedAt;
|
QDateTime issuedAt;
|
||||||
//! Les points de la courbe. Une révision **vide n'est pas enregistrée** : voir \c enregistrer().
|
//! Les points de la courbe. Une révision **vide n'est pas enregistrée** : voir \c enregistrer().
|
||||||
QList<ForecastPoint> points;
|
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
|
* Le producteur publie désormais celui qui a répondu et non celui qui était configuré, repli
|
||||||
* stocker comme provenance enregistrerait une intention — et une intention rangée dans une
|
* `best_match` inclus, vérifié en provoquant la panne. La colonne peut donc porter une
|
||||||
* colonne de provenance **devient** une provenance au premier lecteur. Vide dit « on ne
|
* valeur.
|
||||||
* sait pas », ce qui est vrai.
|
*
|
||||||
|
* \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;
|
QString model;
|
||||||
};
|
};
|
||||||
@ -109,25 +107,48 @@ public:
|
|||||||
//! \return Le chemin, surchargeable par \c NYMEA_ENERGY_FORECAST_DB (tests).
|
//! \return Le chemin, surchargeable par \c NYMEA_ENERGY_FORECAST_DB (tests).
|
||||||
static QString cheminParDefaut();
|
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é.
|
//! \brief La base est-elle utilisable ? \return Faux si l'ouverture ou la migration a échoué.
|
||||||
bool isValid() const { return m_valide; }
|
bool isValid() const { return m_valide; }
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief Enregistre une révision, si c'en est une.
|
* \brief Enregistre une révision, si c'en est une.
|
||||||
* \param revision Révision reçue.
|
* \param revision Révision reçue.
|
||||||
* \return Vrai si elle a été écrite ; **faux si elle a été reconnue identique** à la
|
* \return Vrai si elle a été écrite ; **faux si ce run était déjà connu**, si elle était
|
||||||
* dernière connue, ou si elle était vide, ou en cas d'échec.
|
* vide, ou en cas d'échec.
|
||||||
*
|
*
|
||||||
* \par La comparaison porte sur l'INTERSECTION, pas sur la charge utile
|
* \par LA CLÉ DÉDUPLIQUE, PAS LE CONTENU
|
||||||
* Si la fenêtre publiée **glisse** — seulement les heures à venir — la charge utile de
|
* Depuis que l'heure du run voyage dans la charge utile, **l'identité du run EST l'identité
|
||||||
* 11 h a un point de moins que celle de 10 h, et une égalité de charge utile verrait une
|
* de la révision**. Deux lectures du même run portent le même \c issuedAt et retombent sur
|
||||||
* révision **toutes les heures**, ce que la déduplication existe précisément pour éviter.
|
* la même ligne ; rien à comparer.
|
||||||
* Deux révisions sont donc les mêmes si, **sur leurs heures communes**, toutes les valeurs
|
|
||||||
* coïncident.
|
|
||||||
*
|
*
|
||||||
* Ce choix ne dépend pas de la forme que le producteur publie aujourd'hui : il est correct
|
* \par Pourquoi le contenu ne doit SURTOUT PAS rejeter
|
||||||
* que la fenêtre glisse ou non, et il ne peut donc pas être invalidé par un changement
|
* Un run **plus récent portant les mêmes chiffres** est une information : le modèle n'a pas
|
||||||
* chez le voisin dont personne ne nous préviendrait.
|
* 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);
|
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
|
* \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.
|
* récente. Sert à lire l'historique d'une révision, jamais à décider.
|
||||||
* \param targetAt Heure visée (UTC).
|
* \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;
|
QList<QPair<QDateTime, double>> revisions(const QDateTime &targetAt) const;
|
||||||
|
|
||||||
|
|||||||
@ -932,51 +932,54 @@ ForecastRevision revision(const QDateTime &recue, const QDateTime &depuis,
|
|||||||
const QList<double> &wh)
|
const QList<double> &wh)
|
||||||
{
|
{
|
||||||
ForecastRevision r;
|
ForecastRevision r;
|
||||||
r.receivedAt = recue;
|
r.issuedAt = recue;
|
||||||
for (int i = 0; i < wh.count(); ++i)
|
for (int i = 0; i < wh.count(); ++i)
|
||||||
r.points.append({ depuis.addSecs(i * 3600), wh.at(i) });
|
r.points.append({ depuis.addSecs(i * 3600), wh.at(i) });
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void TestLoadModel::testARevisionIsJudgedOnTheOverlapNotThePayload()
|
void TestLoadModel::testTheRunIdentityIsTheRevisionIdentity()
|
||||||
{
|
{
|
||||||
QTemporaryDir dir;
|
QTemporaryDir dir;
|
||||||
ForecastStore magasin(dir.filePath("forecast.sqlite"));
|
ForecastStore magasin(dir.filePath("forecast.sqlite"));
|
||||||
QVERIFY(magasin.isValid());
|
QVERIFY(magasin.isValid());
|
||||||
|
|
||||||
const QDateTime h10 = QDateTime(QDate(2026, 9, 9), QTime(10, 0), QTimeZone::utc());
|
const QDateTime run08 = QDateTime(QDate(2026, 9, 9), QTime(8, 0), QTimeZone::utc());
|
||||||
const QDateTime t10 = h10; // la fenêtre commence à l'heure courante
|
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.
|
// 1. Premier run : il s'enregistre.
|
||||||
QVERIFY(magasin.enregistrer(revision(h10, t10, {100, 200, 300, 400})));
|
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
|
// 2. LE MÊME RUN, RELU — pas une révision, et c'est LA CLÉ qui le dit. Depuis que l'heure
|
||||||
// rafraîchissent au mieux toutes les heures, donc la plupart des lectures rendent
|
// d'initialisation voyage dans la charge utile, l'identité du run EST celle de la
|
||||||
// l'identique.
|
// révision : deux lectures retombent sur les mêmes lignes, sans rien comparer.
|
||||||
QVERIFY2(!magasin.enregistrer(revision(h10.addSecs(600), t10, {100, 200, 300, 400})),
|
QVERIFY2(!magasin.enregistrer(revision(run08, t0, {100, 200, 300, 400})),
|
||||||
"une courbe identique n'est pas une révision");
|
"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
|
// 3. LE CŒUR DU LOT — un run PLUS RÉCENT portant LES MÊMES CHIFFRES **EST** une révision.
|
||||||
// valeurs communes inchangées. La charge utile DIFFÈRE, la prévision NON. Sans la
|
// Il confirme, il ne répète pas : le modèle n'a pas changé d'avis, et c'est une
|
||||||
// comparaison sur l'intersection, on compterait une révision toutes les heures — ce que
|
// information. La rejeter rendrait indistincts « le modèle a confirmé quatre fois » et
|
||||||
// la déduplication existe précisément pour éviter.
|
// « le modèle n'a rien dit depuis ce matin ».
|
||||||
QVERIFY2(!magasin.enregistrer(revision(h10.addSecs(3600), t10.addSecs(3600), {200, 300, 400})),
|
QVERIFY2(magasin.enregistrer(revision(run14, t0, {100, 200, 300, 400})),
|
||||||
"fenêtre glissée, valeurs communes inchangées : ce n'est pas une révision");
|
"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
|
// 4. Une révision VIDE n'en est pas une : l'absence est un trou, pas une courbe de zéros.
|
||||||
// ne saurait pas si la déduplication distingue quoi que ce soit ou refuse tout.
|
ForecastRevision vide; vide.issuedAt = run14.addSecs(3600);
|
||||||
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);
|
|
||||||
QVERIFY(!magasin.enregistrer(vide));
|
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()
|
void TestLoadModel::testTheCurveIsReproducibleAndKeepsItsHoles()
|
||||||
@ -1027,7 +1030,7 @@ void TestLoadModel::testRetentionKeepsBothEndsOfEachRevision()
|
|||||||
const QDateTime cible = QDateTime(QDate(2026, 9, 1), QTime(12, 0), QTimeZone::utc());
|
const QDateTime cible = QDateTime(QDate(2026, 9, 1), QTime(12, 0), QTimeZone::utc());
|
||||||
for (int i = 0; i < 4; ++i) {
|
for (int i = 0; i < 4; ++i) {
|
||||||
ForecastRevision r;
|
ForecastRevision r;
|
||||||
r.receivedAt = cible.addSecs(-(4 - i) * 3600);
|
r.issuedAt = cible.addSecs(-(4 - i) * 3600);
|
||||||
r.points.append({ cible, 1000.0 + i });
|
r.points.append({ cible, 1000.0 + i });
|
||||||
QVERIFY(magasin.enregistrer(r));
|
QVERIFY(magasin.enregistrer(r));
|
||||||
}
|
}
|
||||||
@ -1050,10 +1053,63 @@ void TestLoadModel::testRetentionKeepsBothEndsOfEachRevision()
|
|||||||
const QDateTime recente = QDateTime::currentDateTimeUtc().addSecs(3600);
|
const QDateTime recente = QDateTime::currentDateTimeUtc().addSecs(3600);
|
||||||
for (int i = 0; i < 3; ++i) {
|
for (int i = 0; i < 3; ++i) {
|
||||||
ForecastRevision r;
|
ForecastRevision r;
|
||||||
r.receivedAt = QDateTime::currentDateTimeUtc().addSecs(i * 60);
|
r.issuedAt = QDateTime::currentDateTimeUtc().addSecs(i * 60);
|
||||||
r.points.append({ recente, 500.0 + i });
|
r.points.append({ recente, 500.0 + i });
|
||||||
QVERIFY(magasin.enregistrer(r));
|
QVERIFY(magasin.enregistrer(r));
|
||||||
}
|
}
|
||||||
magasin.purger();
|
magasin.purger();
|
||||||
QCOMPARE(magasin.revisions(recente).count(), 3);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@ -86,13 +86,14 @@ private slots:
|
|||||||
void testReportingDayBoundaryIsPublishedNotDeducible();
|
void testReportingDayBoundaryIsPublishedNotDeducible();
|
||||||
void testDrawMarginComparesHeadroomsNotCaps();
|
void testDrawMarginComparesHeadroomsNotCaps();
|
||||||
|
|
||||||
//! Une révision identique SUR SES HEURES COMMUNES n'en est pas une — et c'est le cas qu'une
|
//! L'identité du RUN est celle de la révision — et un run qui confirme n'est pas un doublon.
|
||||||
//! égalité de charge utile raterait dès que la fenêtre glisse.
|
void testTheRunIdentityIsTheRevisionIdentity();
|
||||||
void testARevisionIsJudgedOnTheOverlapNotThePayload();
|
|
||||||
//! La courbe se relit telle qu'elle était connue à un instant, et ses trous restent des trous.
|
//! La courbe se relit telle qu'elle était connue à un instant, et ses trous restent des trous.
|
||||||
void testTheCurveIsReproducibleAndKeepsItsHoles();
|
void testTheCurveIsReproducibleAndKeepsItsHoles();
|
||||||
//! La rétention garde les DEUX bouts : n'en garder qu'un détruirait la révision.
|
//! La rétention garde les DEUX bouts : n'en garder qu'un détruirait la révision.
|
||||||
void testRetentionKeepsBothEndsOfEachRevision();
|
void testRetentionKeepsBothEndsOfEachRevision();
|
||||||
|
//! La charge utile de transport se REFUSE plutôt que de s'interpréter au mieux.
|
||||||
|
void testAnUnknownPayloadIsRefusedNotBestEffort();
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // TESTLOADMODEL_H
|
#endif // TESTLOADMODEL_H
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user