diff --git a/docs/DESIGN_PREVISION_PV.md b/docs/DESIGN_PREVISION_PV.md index 57bacc5..f56db9f 100644 --- a/docs/DESIGN_PREVISION_PV.md +++ b/docs/DESIGN_PREVISION_PV.md @@ -604,3 +604,37 @@ Celle du producteur compare la **charge utile entière**, où `issuedAt` figure 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. + + +### 15.3 Les deux gardes sur la fraîcheur — tranchées le 2026-09-09 + +**`lastConfirmedAt` n'est JAMAIS une clé de révision.** `firstIssuedAt` identifie la prévision ; +la fraîcheur dit seulement **jusqu'à quand elle a tenu**. Dater avec elle ferait paraître une +prévision née au moment où elle a été reconduite — et **deux prévisions différentes reconduites +par le même run porteraient la même date**, ce qui rendrait l'historique illisible. Écrit au +contrat, dans l'en-tête et dans un test, parce qu'une interdiction qui ne vit qu'à un endroit +n'est pas une interdiction. + +**Sur une ligne jamais reconduite, elle vaut `firstIssuedAt` — jamais absente.** Et la tranche +n'est pas arbitraire : elle vient de ce que le champ **répond**. + +> Il ne dit pas « quand quelqu'un a confirmé ». Il dit **« quel est le dernier run qui a AFFIRMÉ +> ces valeurs »**. Le run émetteur les a affirmées. La réponse est donc **vraie**, et elle l'est +> **uniformément** — il n'y a pas un cas « confirmation » et un cas « copie », il y a une seule +> question avec une seule sorte de réponse. + +L'ambiguïté que la consigne redoutait à juste titre ne se lève pas en choisissant une convention : +elle se lève en **définissant correctement le champ**. Une fois la définition posée, les deux cas +n'en font plus qu'un. + +Et l'absence serait ici le **mensonge**, à rebours de la règle habituelle de ce dépôt : elle se +lirait « on ne sait pas jusqu'à quand elle a tenu », alors qu'on le sait — au moins jusqu'à son +émission. Elle forcerait en outre chaque client à écrire `confirmedAt ?? issuedAt`, c'est-à-dire +à refaire **ce calcul-ci, ailleurs, une fois par client**, avec une chance de le faire de travers. + +**Invariant publié** : `lastConfirmedAt >= firstIssuedAt`, toujours. Une prévision ne peut pas +avoir tenu avant d'avoir été émise. + +*(Sur le nom : `lastConfirmedAt` et non `confirmedAt`. Le préfixe dit qu'il s'agit du **dernier** +d'une suite, là où `confirmedAt` se lirait comme l'instant d'un événement unique — et une ligne +reconduite quatre fois n'a pas « un » instant de confirmation.)* diff --git a/energyplugin/etm/forecast/forecaststore.cpp b/energyplugin/etm/forecast/forecaststore.cpp index 05ead06..4ab0566 100644 --- a/energyplugin/etm/forecast/forecaststore.cpp +++ b/energyplugin/etm/forecast/forecaststore.cpp @@ -225,7 +225,13 @@ bool ForecastStore::enregistrer(const ForecastRevision &revision) 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 + // À L'OUVERTURE, LES DEUX INSTANTS COÏNCIDENT — et ce n'est pas une copie de commodité. + // `lastConfirmedAt` répond à « quel est le dernier run qui a AFFIRMÉ ces valeurs ». Le + // run émetteur les a affirmées : la réponse est vraie, et elle l'est uniformément. + // Le laisser NUL se lirait « on ne sait pas jusqu'à quand elle a tenu », alors qu'on le + // sait — et forcerait chaque client à écrire le repli, ailleurs, avec une chance de le + // faire de travers. + ins.addBindValue(run); ins.addBindValue(p.wh); // 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. diff --git a/energyplugin/etm/forecast/forecaststore.h b/energyplugin/etm/forecast/forecaststore.h index d94b35c..f0f947d 100644 --- a/energyplugin/etm/forecast/forecaststore.h +++ b/energyplugin/etm/forecast/forecaststore.h @@ -43,9 +43,32 @@ 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. + /*! + * \brief Run qui a produit ces valeurs EN PREMIER — **l'identité de la prévision**. + * + * Renseigné à la lecture ; ignoré à l'écriture. C'est LUI qui date la prévision, et lui seul. + */ QDateTime firstIssuedAt; - //! \brief Dernier run qui les a RECONDUITES. Renseigné à la lecture ; ignoré à l'écriture. + /*! + * \brief Dernier run qui a AFFIRMÉ ces valeurs — la **fraîcheur**. + * + * \warning **JAMAIS une clé de révision.** \c firstIssuedAt identifie la prévision ; + * celui-ci dit seulement **jusqu'à quand elle a tenu**. Dater une prévision avec lui la + * ferait paraître née au moment où elle a été reconduite — et deux prévisions différentes + * reconduites par le même run porteraient la même date. + * + * \par Sur une ligne JAMAIS RECONDUITE, il vaut \c firstIssuedAt — jamais absent + * Et ce n'est pas une copie déguisée en confirmation : le champ ne dit pas « quand + * quelqu'un a confirmé », il dit **« quel est le dernier run qui a affirmé ces valeurs »**. + * Le run émetteur les a affirmées. La réponse est donc vraie, et elle l'est uniformément. + * + * L'absence serait le mensonge : elle se lirait « on ne sait pas jusqu'à quand elle a + * tenu », alors qu'on le sait — au moins jusqu'à son émission. Et elle obligerait chaque + * client à écrire le repli `confirmedAt ?? issuedAt`, c'est-à-dire à refaire ce calcul-ci, + * ailleurs, une fois par client, avec une chance de le faire de travers. + * + * \invariant Toujours valide, et toujours `>= firstIssuedAt`. + */ QDateTime lastConfirmedAt; }; diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index 59701e7..37ea26c 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -1136,3 +1136,45 @@ void TestLoadModel::testAnUnknownPayloadIsRefusedNotBestEffort() QVERIFY(refus.isEmpty()); QCOMPARE(troue.points.count(), 2); } + +void TestLoadModel::testFreshnessNeverDatesTheForecast() +{ + QTemporaryDir dir; + ForecastStore magasin(dir.filePath("forecast.sqlite")); + QVERIFY(magasin.isValid()); + + const QDateTime run06 = QDateTime(QDate(2026, 9, 9), QTime(6, 0), QTimeZone::utc()); + const QDateTime run10 = QDateTime(QDate(2026, 9, 9), QTime(10, 0), QTimeZone::utc()); + const QDateTime t0 = QDateTime(QDate(2026, 9, 9), QTime(14, 0), QTimeZone::utc()); + + QVERIFY(magasin.enregistrer(revision(run06, t0, {800, 900}))); + + // 1. SUR UNE LIGNE JAMAIS RECONDUITE, la fraîcheur vaut l'émission — jamais absente. + // Ce n'est pas une copie déguisée en confirmation : le champ répond à « quel est le + // dernier run qui a AFFIRMÉ ces valeurs », et le run émetteur les a affirmées. + // L'absence, elle, se lirait « on ne sait pas jusqu'à quand elle a tenu » — c'est faux, + // et chaque client devrait écrire le repli à sa façon. + QList vue = magasin.courbe(t0, t0.addSecs(3600), run06.addSecs(60)); + QCOMPARE(vue.count(), 2); + QVERIFY2(vue.first().lastConfirmedAt.isValid(), "la fraîcheur n'est JAMAIS absente"); + QCOMPARE(vue.first().lastConfirmedAt, run06); + QCOMPARE(vue.first().firstIssuedAt, run06); + + // 2. Après reconduction, elle avance — et l'identité, NON. + QVERIFY(!magasin.enregistrer(revision(run10, t0, {800, 900}))); + vue = magasin.courbe(t0, t0.addSecs(3600), run10.addSecs(60)); + QCOMPARE(vue.first().lastConfirmedAt, run10); // fraîcheur : avancée + QCOMPARE(vue.first().firstIssuedAt, run06); // identité : IMMUABLE + + // 3. L'INVARIANT qui rend le couple lisible : la fraîcheur ne précède jamais l'émission. + for (const ForecastPoint &p : vue) + QVERIFY2(p.lastConfirmedAt >= p.firstIssuedAt, + "une prévision ne peut pas avoir tenu AVANT d'avoir été émise"); + + // 4. LA FRAÎCHEUR NE DATE PAS LA PRÉVISION — c'est la garde que la règle demande. `revisions()` + // rend les instants d'ÉMISSION : deux prévisions différentes reconduites par le même run + // porteraient sinon la même date, et l'historique deviendrait illisible. + const QList> revs = magasin.revisions(t0); + QCOMPARE(revs.count(), 1); + QCOMPARE(revs.first().first, run06); // l'émission, pas la reconduction +} diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index f270a07..d529d04 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -93,6 +93,8 @@ private slots: void testTheCurveIsReproducibleAndKeepsItsHoles(); //! La rétention garde les DEUX bouts : n'en garder qu'un détruirait la révision. void testRetentionKeepsBothEndsOfEachRevision(); + //! La fraîcheur ne date JAMAIS la prévision, et elle n'est jamais absente. + void testFreshnessNeverDatesTheForecast(); //! La charge utile de transport se REFUSE plutôt que de s'interpréter au mieux. void testAnUnknownPayloadIsRefusedNotBestEffort(); };