diff --git a/docs/DESIGN_PREVISION_PV.md b/docs/DESIGN_PREVISION_PV.md index 6c7c013..0c56311 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(); };