diff --git a/docs/DESIGN_PREVISION_PV.md b/docs/DESIGN_PREVISION_PV.md index d439d7c..57bacc5 100644 --- a/docs/DESIGN_PREVISION_PV.md +++ b/docs/DESIGN_PREVISION_PV.md @@ -541,3 +541,66 @@ le reste, et répéter l'avertissement chaque minute noierait le signal. **La prévision n'entre toujours pas dans le plan.** Elle est lue avant le contexte et n'y entre pas : ce lot est un affichage. + + +--- + +## 15. Version B — une révision n'existe que si la prévision a changé + +La déduplication porte sur **les valeurs**, et un run qui reconduit ne crée pas de ligne. + +**Le raisonnement qui tranche est celui de `meta.json`**, appliqué une seconde fois : *on ne +stocke pas pour un consommateur qui n'existe pas.* L'apprentissage n'existe pas ; le consommateur +d'aujourd'hui est un **affichage**, et un affichage ne lit pas une confirmation. Le déclencheur +de réouverture est le même : le jour où l'apprentissage corrélera l'erreur à l'horizon, il aura +peut-être besoin des confirmations — la décision se reprendra **avec un consommateur réel**. + +### 15.1 Une contradiction dans la consigne, et comment elle est levée + +Deux instructions incompatibles ont été données le même jour : + +| | | +|---|---| +| « `issuedAt` est **mis à jour** sur la ligne existante » | il vaut le **dernier** run | +| « il devient l'horodatage du **premier** run […] pas du dernier » | il vaut le **premier** | + +Et l'avertissement joint — *« quelqu'un lira “prévu à 06Z” sur une ligne que le run 12Z a +confirmée »* — ne décrit un danger **que si l'instant n'est pas mis à jour**. + +**Aucune des deux n'a été choisie**, parce qu'aucune ne suffit : **un seul champ ne peut pas +porter les deux faits sans mentir sur l'un.** Ce serait exactement le piège +`receivedAt`/`issuedAt` de la veille — deux instants voisins, un nom qui promet le mauvais — et +le remède est le même : **nommer ce que c'est**. + +```sql +targetAt INTEGER -- heure visée, epoch UTC +firstIssuedAt INTEGER -- run qui a produit ces valeurs EN PREMIER ← CLÉ, ne bouge jamais +lastConfirmedAt INTEGER -- dernier run qui les a RECONDUITES ← mis à jour en place +forecastWh REAL +model TEXT +PRIMARY KEY (targetAt, firstIssuedAt) +``` + +> **La FRAÎCHEUR est `lastConfirmedAt`. L'ANCIENNETÉ DE L'AVIS est `firstIssuedAt`.** +> +> Une valeur émise à 06Z et reconduite à 12Z est **fraîche**, pas vieille de six heures. Et une +> valeur émise à 06Z que personne n'a reconduite depuis **est** vieille de six heures. Les deux +> phrases sont vraies, elles ne parlent pas de la même chose, et un champ unique en effacerait +> une. + +`firstIssuedAt` **ne bouge jamais** : c'est la clé, et une clé qui se déplace change l'identité +de la ligne sous elle. Les deux instants **coïncident à l'ouverture** d'une révision et divergent +à la première reconduction — ce que le test vérifie explicitement. + +Les deux sont publiés au contrat, ensemble. N'en publier qu'un obligerait le client à choisir +lequel il croit. + +### 15.2 Cette déduplication est le DERNIER REMPART, pas un filet + +Celle du producteur compare la **charge utile entière**, où `issuedAt` figure : elle ne mord donc +**jamais** sur ce cas — un run neuf aux mêmes chiffres lui paraîtra toujours différent. + +**Si celle-ci se trompe, personne ne rattrape.** C'est une raison de plus de ne pas la laisser +dépendre d'un comportement du voisin : la comparaison porte sur l'**intersection** des créneaux, +correcte que la fenêtre glisse ou non, et `communs > 0` est requis — sans créneau commun il n'y a +rien à comparer, et conclure « identique » ferait disparaître une prévision dont on ne sait rien. diff --git a/energyplugin/etm/forecast/forecaststore.cpp b/energyplugin/etm/forecast/forecaststore.cpp index 582bd92..05ead06 100644 --- a/energyplugin/etm/forecast/forecaststore.cpp +++ b/energyplugin/etm/forecast/forecaststore.cpp @@ -126,13 +126,19 @@ bool ForecastStore::migrer() const bool ok = q.exec(QStringLiteral( "CREATE TABLE IF NOT EXISTS forecastPv (" - " targetAt INTEGER NOT NULL," - " issuedAt INTEGER NOT NULL," - " forecastWh REAL NOT NULL," - " model TEXT," - " PRIMARY KEY (targetAt, issuedAt))")) + " targetAt INTEGER NOT NULL," + // LE RUN QUI A PRODUIT CES VALEURS EN PREMIER. C'est la clé : elle ne bouge jamais, + // sans quoi l'identité de la ligne changerait sous elle. + " firstIssuedAt INTEGER NOT NULL," + // LE DERNIER RUN QUI LES A RECONDUITES, mis à jour en place. Deux instants et deux + // noms : un seul champ ne peut pas porter les deux faits sans mentir sur l'un. + // La FRAÎCHEUR est lastConfirmedAt ; l'ANCIENNETÉ DE L'AVIS est firstIssuedAt. + " lastConfirmedAt INTEGER NOT NULL," + " forecastWh REAL NOT NULL," + " model TEXT," + " PRIMARY KEY (targetAt, firstIssuedAt))")) && q.exec(QStringLiteral( - "CREATE INDEX IF NOT EXISTS idxForecastIssued ON forecastPv(issuedAt)")) + "CREATE INDEX IF NOT EXISTS idxForecastIssued ON forecastPv(firstIssuedAt)")) && q.exec(QStringLiteral("PRAGMA user_version = %1").arg(SchemaVersion)); if (!ok) qCWarning(dcNymeaEnergy()) << "[Prévision] migration échouée :" << q.lastError().text(); @@ -151,69 +157,78 @@ bool ForecastStore::enregistrer(const ForecastRevision &revision) } const qint64 run = revision.issuedAt.toUTC().toSecsSinceEpoch(); - // --- LA CLÉ DÉDUPLIQUE ------------------------------------------------------------------ + // --- UNE RÉVISION N'EXISTE QUE SI LA PRÉVISION A CHANGÉ --------------------------------- // - // Depuis que l'heure du run voyage dans la charge utile, l'identité du run EST l'identité de - // la révision : deux lectures du même run retombent sur les mêmes lignes. Il n'y a rien à - // comparer, et c'est tout le gain d'une clé VRAIE sur une clé approchée. - QSqlQuery deja(m_db); - deja.prepare(QStringLiteral("SELECT COUNT(*) FROM forecastPv WHERE issuedAt = ?")); - deja.addBindValue(run); - if (deja.exec() && deja.next() && deja.value(0).toInt() > 0) { - qCDebug(dcNymeaEnergy()) << "[Prévision] run déjà connu — rien à écrire"; - return false; - } - - // --- LE CONTENU OBSERVE, IL NE REJETTE PAS ---------------------------------------------- + // La comparaison porte sur LES VALEURS, sur les créneaux communs — jamais sur la charge + // utile entière, qui contient `firstIssuedAt` et différerait donc à chaque run. // - // Un run PLUS RÉCENT portant les mêmes chiffres est une information : le modèle n'a pas - // changé d'avis. IL CONFIRME, IL NE RÉPÈTE PAS. Le rejeter détruirait précisément ce que - // cette table conserve, et rendrait indistincts « le modèle a confirmé quatre fois » et - // « le modèle n'a rien dit depuis ce matin ». - // - // La comparaison porte sur l'INTERSECTION et non sur la charge utile. Le producteur publie - // aujourd'hui des journées ENTIÈRES — deux révisions du même jour couvrent donc exactement - // les mêmes créneaux — mais l'intersection reste correcte si cela changeait, et elle ne - // dépend d'aucun comportement du voisin dont personne ne nous préviendrait. + // ET C'EST LE DERNIER REMPART, PAS UN FILET : la déduplication du producteur compare la + // charge utile complète, où `firstIssuedAt` figure, donc elle ne mord jamais sur ce cas. Si + // celle-ci se trompe, personne ne rattrape. QSqlQuery derniere(m_db); - derniere.prepare(QStringLiteral("SELECT MAX(issuedAt) FROM forecastPv WHERE issuedAt < ?")); - derniere.addBindValue(run); + derniere.prepare(QStringLiteral("SELECT MAX(firstIssuedAt) FROM forecastPv")); if (derniere.exec() && derniere.next() && !derniere.value(0).isNull()) { + const qint64 refRun = derniere.value(0).toLongLong(); QSqlQuery ref(m_db); - ref.prepare(QStringLiteral("SELECT targetAt, forecastWh FROM forecastPv WHERE issuedAt = ?")); - ref.addBindValue(derniere.value(0).toLongLong()); + ref.prepare(QStringLiteral( + "SELECT targetAt, forecastWh FROM forecastPv WHERE firstIssuedAt = ?")); + ref.addBindValue(refRun); QHash precedent; if (ref.exec()) while (ref.next()) precedent.insert(ref.value(0).toLongLong(), ref.value(1).toDouble()); - int communs = 0, differents = 0; + + int communs = 0; + bool identique = true; for (const ForecastPoint &p : revision.points) { const auto it = precedent.constFind(p.targetAt.toUTC().toSecsSinceEpoch()); if (it == precedent.constEnd()) - continue; + continue; // hors intersection : ne départage rien ++communs; - if (qAbs(it.value() - p.wh) > EgaliteWh) ++differents; + if (qAbs(it.value() - p.wh) > EgaliteWh) { identique = false; break; } + } + // `communs > 0` est REQUIS : sans créneau commun, il n'y a rien à comparer, et conclure + // « identique » ferait disparaître une prévision dont on ne sait rien. + if (identique && communs > 0) { + // LE RUN RECONDUIT — on ne crée pas de ligne, on met à jour la FRAÎCHEUR. Stocker la + // confirmation elle-même serait stocker pour un consommateur qui n'existe pas : + // celui d'aujourd'hui est un affichage, et un affichage ne lit pas une confirmation. + QSqlQuery maj(m_db); + maj.prepare(QStringLiteral( + "UPDATE forecastPv SET lastConfirmedAt = ? " + "WHERE firstIssuedAt = ? AND lastConfirmedAt < ?")); + maj.addBindValue(run); + maj.addBindValue(refRun); + maj.addBindValue(run); + if (!maj.exec()) + qCWarning(dcNymeaEnergy()) << "[Prévision] reconduction refusée :" + << maj.lastError().text(); + else if (maj.numRowsAffected() > 0) + qCInfo(dcNymeaEnergy()).noquote() + << QStringLiteral("[Prévision] run %1 RECONDUIT la prévision du %2 — " + "%3 créneau(x) inchangé(s), aucune révision") + .arg(revision.issuedAt.toUTC().toString(Qt::ISODate)) + .arg(QDateTime::fromSecsSinceEpoch(refRun, QTimeZone::utc()) + .toString(Qt::ISODate)) + .arg(communs); + return false; } - if (communs > 0 && differents == 0) - qCInfo(dcNymeaEnergy()).noquote() - << QStringLiteral("[Prévision] le run confirme le précédent — %1 créneau(x) " - "commun(s) inchangé(s)").arg(communs); } - // --- écriture, en une transaction ------------------------------------------------------ + // --- écriture d'une VRAIE révision, en une transaction ---------------------------------- if (!m_db.transaction()) qCWarning(dcNymeaEnergy()) << "[Prévision] transaction refusée :" << m_db.lastError().text(); QSqlQuery ins(m_db); ins.prepare(QStringLiteral( - "INSERT OR REPLACE INTO forecastPv (targetAt, issuedAt, forecastWh, model) " - "VALUES (?, ?, ?, ?)")); + "INSERT OR REPLACE INTO forecastPv " + "(targetAt, firstIssuedAt, lastConfirmedAt, forecastWh, model) VALUES (?, ?, ?, ?, ?)")); for (const ForecastPoint &p : revision.points) { ins.addBindValue(p.targetAt.toUTC().toSecsSinceEpoch()); ins.addBindValue(run); + ins.addBindValue(run); // à l'ouverture, les deux instants coïncident ins.addBindValue(p.wh); - // NULL, jamais une chaîne vide : une chaîne vide serait déjà une valeur. Et le producteur - // publie maintenant le modèle qui A RÉPONDU, repli compris — donc la colonne porte une - // provenance et non une intention. + // NULL, jamais une chaîne vide : une chaîne vide serait déjà une valeur. Le producteur + // publie le modèle qui A RÉPONDU, repli compris — donc une provenance, pas une intention. ins.addBindValue(revision.model.isEmpty() ? QVariant(QMetaType(QMetaType::QString)) : QVariant(revision.model)); if (!ins.exec()) { @@ -227,7 +242,7 @@ bool ForecastStore::enregistrer(const ForecastRevision &revision) return false; } qCInfo(dcNymeaEnergy()).noquote() - << QStringLiteral("[Prévision] run %1 enregistré — %2 créneau(x), modèle %3") + << QStringLiteral("[Prévision] RÉVISION du run %1 — %2 créneau(x), modèle %3") .arg(revision.issuedAt.toUTC().toString(Qt::ISODate)) .arg(revision.points.count()) .arg(revision.model.isEmpty() ? QStringLiteral("(inconnu)") : revision.model); @@ -247,10 +262,10 @@ QList ForecastStore::courbe(const QDateTime &de, const QDateTime // deux lectures du même passé donnent le même résultat. QSqlQuery q(m_db); q.prepare(QStringLiteral( - "SELECT targetAt, forecastWh FROM forecastPv f " - "WHERE targetAt BETWEEN ? AND ? AND issuedAt <= ? " - " AND issuedAt = (SELECT MAX(issuedAt) FROM forecastPv g " - " WHERE g.targetAt = f.targetAt AND g.issuedAt <= ?) " + "SELECT targetAt, forecastWh, firstIssuedAt, lastConfirmedAt FROM forecastPv f " + "WHERE targetAt BETWEEN ? AND ? AND firstIssuedAt <= ? " + " AND firstIssuedAt = (SELECT MAX(firstIssuedAt) FROM forecastPv g " + " WHERE g.targetAt = f.targetAt AND g.firstIssuedAt <= ?) " "ORDER BY targetAt")); const qint64 limite = borne.toUTC().toSecsSinceEpoch(); q.addBindValue(de.toUTC().toSecsSinceEpoch()); @@ -268,6 +283,11 @@ QList ForecastStore::courbe(const QDateTime &de, const QDateTime ForecastPoint p; p.targetAt = QDateTime::fromSecsSinceEpoch(q.value(0).toLongLong(), QTimeZone::utc()); p.wh = q.value(1).toDouble(); + // LES DEUX INSTANTS, publiés ensemble. La FRAÎCHEUR est lastConfirmedAt ; l'ancienneté + // de l'avis est firstIssuedAt. N'en rendre qu'un ferait lire « prévu à 06Z » sur une + // ligne que le run 12Z vient de reconduire. + p.firstIssuedAt = QDateTime::fromSecsSinceEpoch(q.value(2).toLongLong(), QTimeZone::utc()); + p.lastConfirmedAt = QDateTime::fromSecsSinceEpoch(q.value(3).toLongLong(), QTimeZone::utc()); points.append(p); } return points; @@ -280,7 +300,7 @@ QList> ForecastStore::revisions(const QDateTime &target return sortie; QSqlQuery q(m_db); q.prepare(QStringLiteral( - "SELECT issuedAt, forecastWh FROM forecastPv WHERE targetAt = ? ORDER BY issuedAt")); + "SELECT firstIssuedAt, forecastWh FROM forecastPv WHERE targetAt = ? ORDER BY firstIssuedAt")); q.addBindValue(targetAt.toUTC().toSecsSinceEpoch()); if (!q.exec()) return sortie; @@ -305,10 +325,10 @@ int ForecastStore::purger(const QDateTime &maintenant) // qu'une détruirait la révision, c'est-à-dire l'objet de cette table. QSqlQuery q(m_db); q.prepare(QStringLiteral( - "DELETE FROM forecastPv WHERE targetAt < ? AND issuedAt NOT IN (" - " SELECT MIN(issuedAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt" + "DELETE FROM forecastPv WHERE targetAt < ? AND firstIssuedAt NOT IN (" + " SELECT MIN(firstIssuedAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt" " UNION" - " SELECT MAX(issuedAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt)")); + " SELECT MAX(firstIssuedAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt)")); q.addBindValue(limite); if (!q.exec()) { qCWarning(dcNymeaEnergy()) << "[Prévision] purge refusée :" << q.lastError().text(); diff --git a/energyplugin/etm/forecast/forecaststore.h b/energyplugin/etm/forecast/forecaststore.h index 3306044..d94b35c 100644 --- a/energyplugin/etm/forecast/forecaststore.h +++ b/energyplugin/etm/forecast/forecaststore.h @@ -43,6 +43,10 @@ struct ForecastPoint { QDateTime targetAt; //! Énergie prévue sur l'heure (Wh). Une ABSENCE ne s'écrit pas : elle ne crée pas de point. double wh = 0; + //! \brief Run qui a produit ces valeurs EN PREMIER. Renseigné à la lecture ; ignoré à l'écriture. + QDateTime firstIssuedAt; + //! \brief Dernier run qui les a RECONDUITES. Renseigné à la lecture ; ignoré à l'écriture. + QDateTime lastConfirmedAt; }; /*! @@ -55,12 +59,13 @@ struct ForecastRevision { * Il **voyage dans la charge utile** que publie le producteur (`issuedAt`), donc sans second * appel à l'API. C'est ce qui rend la clé de révision **exacte**, et non approchée. * - * \par Ce que cela change, et il faut le savoir pour ne pas hériter d'une vieille précaution - * Une première version de ce lot indexait sur l'instant de RÉCEPTION, faute de mieux, et - * interdisait d'en calculer un horizon : la latence de publication d'un modèle global se - * compte en heures, donc `targetAt - receivedAt` était trop court, systématiquement. - * **Cette réserve est levée** : avec l'heure du run, `targetAt - issuedAt` **est** - * l'horizon, et il peut servir à corréler l'erreur à l'âge de la prévision. + * \par Ce que cela change + * Une première version indexait sur l'instant de RÉCEPTION, faute de mieux, et interdisait + * d'en calculer un horizon : la latence de publication d'un modèle se compte en heures. + * **Cette réserve est levée** — l'heure du run est un horizon calculable. + * + * \note Ce champ porte l'instant du run QUI ARRIVE. Le magasin décide ensuite s'il ouvre + * une révision (\c firstIssuedAt) ou s'il en reconduit une (\c lastConfirmedAt). */ QDateTime issuedAt; //! Les points de la courbe. Une révision **vide n'est pas enregistrée** : voir \c enregistrer(). @@ -135,20 +140,35 @@ public: * \return Vrai si elle a été écrite ; **faux si ce run était déjà connu**, si elle était * vide, ou en cas d'échec. * - * \par LA CLÉ DÉDUPLIQUE, PAS LE CONTENU - * Depuis que l'heure du run voyage dans la charge utile, **l'identité du run EST l'identité - * de la révision**. Deux lectures du même run portent le même \c issuedAt et retombent sur - * la même ligne ; rien à comparer. + * \par UNE RÉVISION N'EXISTE QUE SI LA PRÉVISION A CHANGÉ + * La déduplication porte sur **les valeurs**, comparées sur les créneaux communs. Un run + * plus récent qui reconduit les mêmes chiffres **ne crée pas de ligne** : il met à jour + * \c lastConfirmedAt sur les lignes existantes. * - * \par Pourquoi le contenu ne doit SURTOUT PAS rejeter - * Un run **plus récent portant les mêmes chiffres** est une information : le modèle n'a pas - * changé d'avis. **Il confirme, il ne répète pas.** Le rejeter sur l'égalité des valeurs - * détruirait exactement ce que cette table existe pour conserver — et rendrait indistincts - * « le modèle a confirmé quatre fois » et « le modèle n'a rien dit depuis ce matin ». + * On ne stocke pas une confirmation pour un consommateur qui n'existe pas : le + * consommateur d'aujourd'hui est un **affichage**, et un affichage ne lit pas une + * confirmation. C'est le raisonnement qui a déjà écarté la requête `meta.json`, et il vaut + * ici pour la même raison. * - * La comparaison sur l'intersection subsiste donc, mais **journalisée seulement**, jamais - * bloquante. Si un jour \c issuedAt bougeait sans que la prévision bouge, cela se verrait - * dans le nombre de révisions — un défaut à la source, qu'il faut voir et non masquer. + * \par DEUX INSTANTS, DEUX NOMS — et c'est la leçon d'hier appliquée + * Un seul champ ne peut pas porter les deux faits sans mentir sur l'un : + * - \c firstIssuedAt : le run qui a produit ces valeurs **en premier**. C'est la CLÉ ; elle + * ne bouge jamais, sans quoi l'identité de la ligne changerait sous elle. + * - \c lastConfirmedAt : le dernier run qui les a **reconduites**. Mis à jour en place. + * + * Les garder confondus reproduirait exactement le piège `receivedAt`/`issuedAt` : deux + * instants voisins, un nom qui promet le mauvais. Quelqu'un lirait « prévu à 06Z » sur une + * ligne que le run 12Z vient de reconduire, et croirait la prévision vieille de six heures + * alors qu'elle est fraîche. **La fraîcheur est \c lastConfirmedAt ; l'ancienneté de + * l'avis est \c firstIssuedAt.** + * + * \note **Cette déduplication est le DERNIER REMPART, pas un filet.** Celle du producteur + * compare la charge utile entière, où \c issuedAt figure : elle ne mord donc jamais sur ce + * cas. Si celle-ci se trompe, personne ne rattrape. + * + * \note Ce que l'on ne conserve PAS : la LISTE des runs ayant confirmé. Le jour où un + * apprentissage corrélera l'erreur à l'horizon, il en aura peut-être besoin — la décision + * se reprendra alors **avec un consommateur réel**, comme pour `meta.json`. */ bool enregistrer(const ForecastRevision &revision); diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 5fa514c..dcfc881 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -314,15 +314,24 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana "are ABSENT from the list, never zero: a zero would read as 'we expect night'. " "'knownAt' makes the read reproducible - it returns the curve as it was known " "at that instant, which is how 'what did we forecast this morning' is asked. " - "'receivedAt' is when the box received the revision, NOT when the model run " - "was initialised: NO HORIZON MAY BE COMPUTED FROM IT - it is short by the " - "model's publication latency, which is hours."; + "A revision exists only when the forecast CHANGED: a later run carrying the " + "same numbers does not create one, it refreshes 'lastConfirmedAt'. TWO " + "INSTANTS, TWO NAMES - 'firstIssuedAt' is the run that first produced these " + "values (age of the OPINION, and the row's identity); 'lastConfirmedAt' is the " + "last run that reconducted them (FRESHNESS). Do not conflate them: a value " + "first issued at 06Z and reconducted at 12Z is fresh, not six hours old."; params.insert("from", enumValueName(String)); // ISO 8601 UTC params.insert("to", enumValueName(String)); // ISO 8601 UTC params.insert("o:knownAt", enumValueName(String)); // ISO 8601 UTC ; absent = maintenant QVariantMap pointItem; - pointItem.insert("targetAt", enumValueName(String)); // ISO 8601 UTC + pointItem.insert("targetAt", enumValueName(String)); // ISO 8601 UTC pointItem.insert("forecastWh", enumValueName(Double)); + // DEUX INSTANTS, DEUX NOMS. `firstIssuedAt` est le run qui a produit ces valeurs EN PREMIER + // — l'ancienneté de l'AVIS. `lastConfirmedAt` est le dernier run qui les a reconduites — la + // FRAÎCHEUR. Les confondre ferait lire « prévu à 06Z » sur une prévision que le run de 12Z + // vient de reconduire, et croire vieille de six heures une valeur qui est fraîche. + pointItem.insert("firstIssuedAt", enumValueName(String)); // ISO 8601 UTC + pointItem.insert("lastConfirmedAt", enumValueName(String)); // ISO 8601 UTC returns.insert("forecastPoints", QVariantList() << pointItem); registerMethod("GetPvForecast", description, params, returns, Types::PermissionScopeControlThings); @@ -1067,8 +1076,10 @@ JsonReply *NymeaEnergyJsonHandler::GetPvForecast(const QVariantMap ¶ms) // qui ne dit pas d'où il vient finit par être lu comme une mesure, et une prévision lue // comme une mesure est un mensonge que personne n'a écrit. points.append(QVariantMap{ - {"targetAt", p.targetAt.toUTC().toString(Qt::ISODate)}, - {"forecastWh", p.wh}}); + {"targetAt", p.targetAt.toUTC().toString(Qt::ISODate)}, + {"forecastWh", p.wh}, + {"firstIssuedAt", p.firstIssuedAt.toUTC().toString(Qt::ISODate)}, + {"lastConfirmedAt", p.lastConfirmedAt.toUTC().toString(Qt::ISODate)}}); } return createReply({{"forecastPoints", points}}); #else diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index cde1d1f..59701e7 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -933,53 +933,70 @@ ForecastRevision revision(const QDateTime &recue, const QDateTime &depuis, { ForecastRevision r; r.issuedAt = recue; - for (int i = 0; i < wh.count(); ++i) - r.points.append({ depuis.addSecs(i * 3600), wh.at(i) }); + for (int i = 0; i < wh.count(); ++i) { + // Les deux instants de LECTURE ne se renseignent pas à l'écriture : le magasin les pose. + ForecastPoint p; + p.targetAt = depuis.addSecs(i * 3600); + p.wh = wh.at(i); + r.points.append(p); + } return r; } } -void TestLoadModel::testTheRunIdentityIsTheRevisionIdentity() +void TestLoadModel::testARevisionExistsOnlyIfTheForecastChanged() { QTemporaryDir dir; ForecastStore magasin(dir.filePath("forecast.sqlite")); QVERIFY(magasin.isValid()); - const QDateTime run08 = QDateTime(QDate(2026, 9, 9), QTime(8, 0), QTimeZone::utc()); - const QDateTime run14 = QDateTime(QDate(2026, 9, 9), QTime(14, 0), QTimeZone::utc()); + const QDateTime run06 = QDateTime(QDate(2026, 9, 9), QTime(6, 0), QTimeZone::utc()); + const QDateTime run12 = QDateTime(QDate(2026, 9, 9), QTime(12, 0), QTimeZone::utc()); + const QDateTime run18 = QDateTime(QDate(2026, 9, 9), QTime(18, 0), QTimeZone::utc()); const QDateTime t0 = QDateTime(QDate(2026, 9, 9), QTime(10, 0), QTimeZone::utc()); - // 1. Premier run : il s'enregistre. - QVERIFY(magasin.enregistrer(revision(run08, t0, {100, 200, 300, 400}))); - - // 2. LE MÊME RUN, RELU — pas une révision, et c'est LA CLÉ qui le dit. Depuis que l'heure - // d'initialisation voyage dans la charge utile, l'identité du run EST celle de la - // révision : deux lectures retombent sur les mêmes lignes, sans rien comparer. - QVERIFY2(!magasin.enregistrer(revision(run08, t0, {100, 200, 300, 400})), - "le même run relu n'est pas une révision de plus"); - // Et même avec des valeurs DIFFÉRENTES : un run ne se réécrit pas. Si les chiffres d'un - // run donné changeaient, c'est le producteur qu'il faudrait regarder, pas cette table. - QVERIFY(!magasin.enregistrer(revision(run08, t0, {111, 222, 333, 444}))); + // 1. Premier run : une révision s'ouvre. + QVERIFY(magasin.enregistrer(revision(run06, t0, {100, 200, 300, 400}))); QCOMPARE(magasin.revisions(t0).count(), 1); - // 3. LE CŒUR DU LOT — un run PLUS RÉCENT portant LES MÊMES CHIFFRES **EST** une révision. - // Il confirme, il ne répète pas : le modèle n'a pas changé d'avis, et c'est une - // information. La rejeter rendrait indistincts « le modèle a confirmé quatre fois » et - // « le modèle n'a rien dit depuis ce matin ». - QVERIFY2(magasin.enregistrer(revision(run14, t0, {100, 200, 300, 400})), - "un run plus récent aux mêmes chiffres CONFIRME — il doit être conservé"); + // 2. LE RUN DE 12Z RECONDUIT LES MÊMES CHIFFRES — ce n'est PAS une révision. On ne stocke + // pas une confirmation pour un consommateur qui n'existe pas : celui d'aujourd'hui est un + // affichage, et un affichage ne lit pas une confirmation. Même raisonnement que celui qui + // a écarté la requête meta.json. + QVERIFY2(!magasin.enregistrer(revision(run12, t0, {100, 200, 300, 400})), + "un run qui reconduit les mêmes valeurs n'ouvre pas de révision"); + QCOMPARE(magasin.revisions(t0).count(), 1); + + // 3. MAIS LA FRAÎCHEUR A ÉTÉ MISE À JOUR, et c'est là que les DEUX instants se séparent. + // Les confondre ferait lire « prévu à 06Z » sur une ligne que le run 12Z vient de + // reconduire — la prévision paraîtrait vieille de six heures alors qu'elle est fraîche. + // C'est le piège receivedAt/issuedAt de la veille, déplacé d'un cran. + QList vue = magasin.courbe(t0, t0.addSecs(3 * 3600), run12.addSecs(60)); + QCOMPARE(vue.count(), 4); + QCOMPARE(vue.first().firstIssuedAt, run06); // ANCIENNETÉ DE L'AVIS + QCOMPARE(vue.first().lastConfirmedAt, run12); // FRAÎCHEUR + QVERIFY2(vue.first().firstIssuedAt != vue.first().lastConfirmedAt, + "les deux instants doivent pouvoir diverger — sinon l'un des deux ment"); + + // 4. UNE VALEUR QUI CHANGE OUVRE UNE RÉVISION, et les deux instants se rejoignent. + // Contre-épreuve du cas 2 : sans elle, on ne saurait pas si la déduplication distingue + // quoi que ce soit ou refuse tout. + QVERIFY(magasin.enregistrer(revision(run18, t0, {100, 999, 300, 400}))); QCOMPARE(magasin.revisions(t0).count(), 2); + vue = magasin.courbe(t0, t0.addSecs(3 * 3600), run18.addSecs(60)); + QCOMPARE(vue.first().firstIssuedAt, run18); + QCOMPARE(vue.first().lastConfirmedAt, run18); // à l'ouverture, ils coïncident - // 4. Une révision VIDE n'en est pas une : l'absence est un trou, pas une courbe de zéros. - ForecastRevision vide; vide.issuedAt = run14.addSecs(3600); - QVERIFY(!magasin.enregistrer(vide)); - - // 5. L'HORIZON EST CALCULABLE, et c'est ce que la vraie clé a rendu possible. Avec l'instant - // de RÉCEPTION on ne pouvait pas : la latence de publication d'un modèle se compte en - // heures, et l'horizon aurait été trop court, systématiquement. + // 5. LA CLÉ NE BOUGE PAS SOUS LA LIGNE. Une reconduction met à jour la fraîcheur et rien + // d'autre : la révision de 06Z reste identifiée par 06Z, sans quoi l'identité de la ligne + // changerait et l'historique deviendrait illisible. const QList> revs = magasin.revisions(t0); - QCOMPARE(revs.first().first.secsTo(t0), qint64(2 * 3600)); // run de 08:00 → cible 10:00 - QCOMPARE(revs.last().first.secsTo(t0), qint64(-4 * 3600)); // run de 14:00, cible dépassée + QCOMPARE(revs.first().first, run06); + QCOMPARE(revs.last().first, run18); + + // 6. Une révision VIDE n'en est pas une : l'absence est un trou, pas une courbe de zéros. + ForecastRevision vide; vide.issuedAt = run18.addSecs(3600); + QVERIFY(!magasin.enregistrer(vide)); } void TestLoadModel::testTheCurveIsReproducibleAndKeepsItsHoles() @@ -1031,7 +1048,10 @@ void TestLoadModel::testRetentionKeepsBothEndsOfEachRevision() for (int i = 0; i < 4; ++i) { ForecastRevision r; r.issuedAt = cible.addSecs(-(4 - i) * 3600); - r.points.append({ cible, 1000.0 + i }); + ForecastPoint pt; + pt.targetAt = cible; + pt.wh = 1000.0 + i; + r.points.append(pt); QVERIFY(magasin.enregistrer(r)); } QCOMPARE(magasin.revisions(cible).count(), 4); @@ -1054,7 +1074,10 @@ void TestLoadModel::testRetentionKeepsBothEndsOfEachRevision() for (int i = 0; i < 3; ++i) { ForecastRevision r; r.issuedAt = QDateTime::currentDateTimeUtc().addSecs(i * 60); - r.points.append({ recente, 500.0 + i }); + ForecastPoint pt; + pt.targetAt = recente; + pt.wh = 500.0 + i; + r.points.append(pt); QVERIFY(magasin.enregistrer(r)); } magasin.purger(); diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index d6300cf..f270a07 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -86,8 +86,9 @@ private slots: void testReportingDayBoundaryIsPublishedNotDeducible(); void testDrawMarginComparesHeadroomsNotCaps(); - //! L'identité du RUN est celle de la révision — et un run qui confirme n'est pas un doublon. - void testTheRunIdentityIsTheRevisionIdentity(); + //! Une révision n'existe que si la PRÉVISION a changé — un run qui reconduit met à jour la + //! fraîcheur, il n'ouvre pas de ligne. Et les deux instants ne se confondent pas. + void testARevisionExistsOnlyIfTheForecastChanged(); //! La courbe se relit telle qu'elle était connue à un instant, et ses trous restent des trous. void testTheCurveIsReproducibleAndKeepsItsHoles(); //! La rétention garde les DEUX bouts : n'en garder qu'un détruirait la révision.