From 4c6f6e04ed29f3386a29bc268e677fcd1b1fa870 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 9 Sep 2026 06:06:53 +0200 Subject: [PATCH] =?UTF-8?q?fix(pr=C3=A9vision=20PV):=20une=20cl=C3=A9=20de?= =?UTF-8?q?=20run=20inconnue=20se=20REFUSE,=20et=20ne=20se=20devine=20jama?= =?UTF-8?q?is?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suite à l'avis de l'agent openmeteo (2026-09-09). `issuedAt == 0` signifie CLÉ INCONNUE — le producteur le publie ainsi quand le repli best_match est actif, cette stratégie n'ayant pas d'identité de run unique. Le refus est désormais NOMMÉ (ISSUED_AT_UNKNOWN) et distinct de MALFORMED : la cause diffère et la conduite à tenir aussi ; les confondre ferait chercher un défaut de forme là où il n'y a simplement pas de run. Et l'heure de réception ne s'y substitue JAMAIS. Elle serait plausible et fausse — trop courte de toute la latence de publication — et une fois écrite, plus rien dans la donnée ne distinguerait la ligne devinée de la ligne datée. Même raisonnement que celui qui a fait refuser receivedAt comme clé, appliqué au cas dégradé. PURGE RECOMMANDÉE : vérifiée, et sans objet ici. L'avis demandait de ne conserver aucune ligne écrite avant openmeteo +etm12, au motif qu'une ligne saine et une ligne fausse sont indistinguables après coup — raisonnement juste. Mais la chronologie du banc le rend sans objet : le .so +etm13 a été posé à 06:01, notre première révision écrite à 06:03, et la concordance site/courbe est vraie (2026-09-09 02:00 des deux côtés). Purger détruirait une ligne saine. La consigne reste notée pour le jour où elle s'appliquera, avec le principe qui la fonde : une révision manquante est une lacune visible, une révision mal datée est un mensonge invisible. Et le défaut du producteur rejoint une famille déjà nommée ici : il n'était démontrable que sur la machine, par recoupement de DEUX états sur DEUX runs consécutifs. Un défaut dont les deux valeurs sont plausibles ne se voit jamais d'une seule source. loadmodel 32/32, ci-quality 4/4, build 0 erreur 0 warning. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p --- docs/DESIGN_PREVISION_PV.md | 58 +++++++++++++++++++++ energyplugin/etm/forecast/forecaststore.cpp | 14 ++++- tests/auto/loadmodel/testloadmodel.cpp | 42 +++++++++++++++ tests/auto/loadmodel/testloadmodel.h | 2 + 4 files changed, 115 insertions(+), 1 deletion(-) diff --git a/docs/DESIGN_PREVISION_PV.md b/docs/DESIGN_PREVISION_PV.md index f56db9f..dc68515 100644 --- a/docs/DESIGN_PREVISION_PV.md +++ b/docs/DESIGN_PREVISION_PV.md @@ -638,3 +638,61 @@ 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.)* + + +--- + +## 16. Avis du producteur — provenance faussée avant `openmeteo +etm12` (2026-09-09) + +Reçu de l'agent openmeteo : jusqu'à `+etm11` inclus, le champ `issuedAt` de la charge utile +portait le run **PRÉCÉDENT**. L'écart valait un intervalle de run (3 h AROME, 6 h ARPEGE), **jamais +aléatoire, toujours en retard**. Cause : la provenance venait d'un aller-retour `meta.json` +asynchrone qui atterrissait après la réponse de prévision, si bien que le plan lisait la valeur du +cycle précédent. + +### 16.1 Recommandation de purge — VÉRIFIÉE, et sans objet ici + +L'avis demandait de ne conserver aucune ligne écrite avant `+etm12`, au motif qu'une ligne saine +et une ligne fausse sont **indistinguables après coup**. Le raisonnement est juste. **Mais la +chronologie du banc le rend sans objet**, et c'est vérifié plutôt que supposé : + +| | | +|---|---| +| `.so` openmeteo `+etm13` posé | **06:01** | +| `[Prévision] RÉVISION` écrite | **06:03** | +| concordance à la lecture | site `2026-09-09 02:00` = courbe `02:00` — **vraie** | +| ligne stockée | `firstIssuedAt = 2026-09-09T00:00Z` (= 02:00 locale) | + +**La lecture n'a commencé qu'après le correctif.** La table ne contient aucune ligne suspecte, et +purger détruirait une ligne saine — c'est-à-dire échanger une lacune contre rien. + +> **La consigne « purge et laisse se reconstruire » reste la bonne**, et elle est notée pour le +> jour où elle s'appliquera. Le principe qui la fonde vaut d'être gardé tel quel : *une révision +> manquante est une lacune visible ; une révision mal datée est un mensonge invisible.* + +### 16.2 `issuedAt == 0` — refus NOMMÉ, et jamais deviné + +Repris et implémenté. Le motif est **distinct de `MALFORMED`** : la cause diffère (le repli +`best_match` n'a pas d'identité de run unique) et la conduite à tenir aussi. Les confondre ferait +chercher un défaut de forme là où la stratégie du producteur n'a simplement pas de run. + +Et **l'heure de réception ne s'y substitue jamais** : elle serait plausible et fausse — trop +courte de toute la latence de publication — et une fois écrite, plus rien dans la donnée ne +distinguerait la ligne devinée de la ligne datée. C'est le même raisonnement que celui qui a fait +refuser `receivedAt` comme clé, appliqué au cas dégradé. + +### 16.3 Ce que l'avis confirme sur la déduplication + +Celle du producteur porte sur la charge **entière**, `issuedAt` compris : elle attrape les cycles +répétés **à l'intérieur d'un même run** — la majorité — mais **pas** un run neuf aux valeurs +identiques. C'est exactement le partage déjà retenu au §15.2 : dédoublonner sur les `p` et faire +avancer la fraîcheur **reste à notre charge**, et nous sommes le dernier rempart. + +### 16.4 Le défaut n'était démontrable que sur la machine + +> *« Aucun test unitaire ne pouvait le montrer : il fallait deux runs consécutifs et la +> comparaison de deux états. »* + +À ranger avec les autres de la même famille : la latence de négociation de la V2C, le double +écrêtage de la borne, l'`update()` imbriqué. **Un défaut dont les deux valeurs sont plausibles ne +se voit que par recoupement de deux sources**, et aucune des deux ne suffit seule. diff --git a/energyplugin/etm/forecast/forecaststore.cpp b/energyplugin/etm/forecast/forecaststore.cpp index 4ab0566..485fad6 100644 --- a/energyplugin/etm/forecast/forecaststore.cpp +++ b/energyplugin/etm/forecast/forecaststore.cpp @@ -63,10 +63,22 @@ ForecastRevision ForecastStore::depuisTransport(const QVariantMap &charge, QStri 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()) { + if (t0 <= 0 || step <= 0 || p.isEmpty()) { refus = QStringLiteral("MALFORMED"); return r; } + // `issuedAt == 0` SIGNIFIE « CLÉ INCONNUE », et c'est un refus À PART — pas une charge utile + // mal formée. Le producteur le publie ainsi quand le repli `best_match` est actif : cette + // stratégie n'a pas d'identité de run unique, donc il n'y a rien à dater. + // + // ET SURTOUT : NE JAMAIS Y SUBSTITUER L'HEURE DE RÉCEPTION. Elle serait plausible et fausse + // — trop courte de toute la latence de publication — et une fois écrite, plus rien dans la + // donnée ne distinguerait la ligne devinée de la ligne datée. Une révision manquante est une + // lacune visible ; une révision mal datée est un mensonge invisible. + if (run <= 0) { + refus = QStringLiteral("ISSUED_AT_UNKNOWN"); + return r; + } r.issuedAt = QDateTime::fromSecsSinceEpoch(run, QTimeZone::utc()); r.model = charge.value(QStringLiteral("model")).toString(); diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index 37ea26c..e6903f6 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -1178,3 +1178,45 @@ void TestLoadModel::testFreshnessNeverDatesTheForecast() QCOMPARE(revs.count(), 1); QCOMPARE(revs.first().first, run06); // l'émission, pas la reconduction } + +void TestLoadModel::testAnUnknownRunKeyIsRefusedNotGuessed() +{ + // Avis du producteur (2026-09-09) : `issuedAt == 0` signifie CLÉ INCONNUE — notamment quand + // le repli `best_match` est actif, car cette stratégie n'a pas d'identité de run unique. + // + // Le refus doit être NOMMÉ, et distinct de « charge utile mal formée » : la cause diffère, + // et la conduite à tenir aussi. Surtout, l'heure de réception ne doit JAMAIS s'y substituer — + // elle serait plausible et fausse, et une fois écrite, plus rien dans la donnée ne + // distinguerait la ligne devinée de la ligne datée. + const qint64 t0 = QDateTime(QDate(2026, 9, 9), QTime(0, 0), QTimeZone::utc()).toSecsSinceEpoch(); + auto charge = [&](qint64 run) { + return QVariantMap{{"v", 1}, {"t0", t0}, {"step", 3600}, {"unit", "W"}, + {"convention", "mean over [t, t+step)"}, + {"model", "best_match"}, {"issuedAt", run}, + {"p", QVariantList{0, 100, 200}}}; + }; + QString refus; + + // 1. CLÉ INCONNUE — refus nommé, et AUCUN point : rien ne sera enregistré. + const ForecastRevision sansCle = ForecastStore::depuisTransport(charge(0), refus); + QCOMPARE(refus, QString("ISSUED_AT_UNKNOWN")); + QVERIFY(sansCle.points.isEmpty()); + QVERIFY2(!sansCle.issuedAt.isValid(), + "aucune heure ne doit être substituée à une clé inconnue"); + + // 2. Le motif est DISTINCT de MALFORMED : deux causes, deux conduites. Les confondre ferait + // chercher un défaut de forme là où la stratégie du producteur n'a simplement pas de run. + QVERIFY(refus != QLatin1String("MALFORMED")); + + // 3. CONTRE-ÉPREUVE — la même charge utile avec une clé valide passe. Sans elle, on ne + // saurait pas si le refus vise la clé ou tout le reste. + const ForecastRevision avecCle = ForecastStore::depuisTransport(charge(t0 + 6 * 3600), refus); + QVERIFY(refus.isEmpty()); + QCOMPARE(avecCle.points.count(), 3); + + // 4. ET LE MAGASIN LA REFUSE AUSSI, bout en bout : une révision sans clé ne s'écrit pas. + QTemporaryDir dir; + ForecastStore magasin(dir.filePath("forecast.sqlite")); + QVERIFY(magasin.isValid()); + QVERIFY2(!magasin.enregistrer(sansCle), "une révision sans clé de run ne s'enregistre pas"); +} diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index d529d04..c6b537f 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -95,6 +95,8 @@ private slots: void testRetentionKeepsBothEndsOfEachRevision(); //! La fraîcheur ne date JAMAIS la prévision, et elle n'est jamais absente. void testFreshnessNeverDatesTheForecast(); + //! Une clé de run inconnue se REFUSE — l'heure de réception ne s'y substitue jamais. + void testAnUnknownRunKeyIsRefusedNotGuessed(); //! La charge utile de transport se REFUSE plutôt que de s'interpréter au mieux. void testAnUnknownPayloadIsRefusedNotBestEffort(); };