diff --git a/docs/DESIGN_PREVISION_PV.md b/docs/DESIGN_PREVISION_PV.md index 2790f47..d439d7c 100644 --- a/docs/DESIGN_PREVISION_PV.md +++ b/docs/DESIGN_PREVISION_PV.md @@ -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. diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 36a1de6..2b0946e 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -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. 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[]. diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index eebb2b9..45faea9 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -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 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 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é. diff --git a/energyplugin/etm/forecast/forecaststore.cpp b/energyplugin/etm/forecast/forecaststore.cpp index 15fbbd3..582bd92 100644 --- a/energyplugin/etm/forecast/forecaststore.cpp +++ b/energyplugin/etm/forecast/forecaststore.cpp @@ -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 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 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> 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(); diff --git a/energyplugin/etm/forecast/forecaststore.h b/energyplugin/etm/forecast/forecaststore.h index f748cae..3306044 100644 --- a/energyplugin/etm/forecast/forecaststore.h +++ b/energyplugin/etm/forecast/forecaststore.h @@ -7,6 +7,7 @@ #include #include #include +#include /*! * \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 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> revisions(const QDateTime &targetAt) const; diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index fad4e02..cde1d1f 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -932,51 +932,54 @@ ForecastRevision revision(const QDateTime &recue, const QDateTime &depuis, const QList &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> 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); +} diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index 4c5ed24..d6300cf 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -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