diff --git a/docs/DESIGN_PREVISION_PV.md b/docs/DESIGN_PREVISION_PV.md index f9853fb..462c936 100644 --- a/docs/DESIGN_PREVISION_PV.md +++ b/docs/DESIGN_PREVISION_PV.md @@ -1028,8 +1028,14 @@ première fois**, et il l'a été sur la machine : L'hypothèse « les plans d'un site partagent un instant » était **fausse** : `due()` portait un jitter aléatoire par plan, si bien que deux plans divergeaient en quelques minutes. Ma garde « ne sommer que des plans du même run » aurait donc **bloqué la somme** à chaque publication de -run — `issuedAt` venant d'un domaine à 3 h, cela fait **huit fenêtres par jour, jusqu'à une heure -chacune**. Pas un cas rare : un cas régulier. +run — `issuedAt` venant d'un domaine à 3 h, cela ferait **huit fenêtres par jour**, d'une durée +que le producteur estime **jusqu'à une heure**. + +> **Ce chiffre est DÉDUIT du code, pas mesuré**, et le producteur le dit lui-même. Ce qui est +> certain, c'est que la dérive était **monotone et sans rappel** : rien ne réalignait jamais deux +> plans, donc l'écart ne pouvait que croître jusqu'à ce qu'un plan saute un run entier. La borne +> haute est un majorant, pas une observation — et l'écrire autrement en ferait la prémisse de +> quelqu'un. Corrigé chez le producteur (`+etm14`) : le site pilote la prévision horaire de tous ses plans dans la même passe, et c'est désormais **au contrat**. L'hypothèse est vraie **par construction, pas @@ -1062,3 +1068,57 @@ comparaison les révisions qui n'en sont pas. **Pas fait maintenant, et délibérément** : ce serait un schéma v3 une heure après la v2, sur un mécanisme qui n'a qu'une matinée d'observation. Mêler deux inconnues. À reprendre quand les runs se seront succédé. + + +--- + +## 23. Deux défauts de MOI, trouvés en quatre minutes par un plan de test + +Le second plan du §22 était une **fiction de test** du producteur — ajouté ~08:07, **retiré à +08:11:36** — créé pour vérifier sa propre garantie de cohérence. Il a fait bien plus que ça. + +### 23.1 Mon édition du contrat avait supprimé la déclaration du RPC + +`GetPvForecast` répondait **`{}`** — pas même une liste vide. En réécrivant sa `description` par +une substitution de texte, j'avais emporté les `params.insert(...)` et `returns.insert(...)` qui +la suivaient. nymea valide la réponse contre le schéma déclaré : sans `returns`, **la charge +utile est éliminée en silence**. + +> **Aucun test ne couvre la surface RPC.** Les 31 tests unitaires passaient, la simulation +> passait, `ci-quality` passait — et la méthode ne rendait plus rien. Ce n'est pas un défaut de +> logique, c'est un défaut de **déclaration**, et rien ici ne les regarde. + +### 23.2 La courbe COURANTE était la dernière APPARUE, pas la dernière VUE + +Le retrait du plan a fait **réapparaître un contenu déjà connu** — celui d'avant l'ajout. Son +`firstSeenAt` n'a pas bougé : c'est le principe même du champ. Mais le tri était fait dessus. + +**Résultat : quatre minutes après la disparition du plan fictif, le moteur servait encore la +courbe de 36 kWh** — une installation qui n'existe pas, à +80 % de la production mesurée. + +``` +7eb8a7d4 vu 08:02:00 revu 08:12:00 20,0 kWh ← le toit réel, revu en DERNIER +484220d7 vu 08:08:00 revu 08:11:00 36,0 kWh ← la fiction, apparue en dernier +``` + +> **Ce qui a cours est ce qu'on a vu EN DERNIER, pas ce qui est apparu en dernier.** Le tri passe +> sur `lastSeenAt`. `firstSeenAt` garde son rôle : il dit quand un contenu est apparu, **pas s'il +> a cours**. + +**Limite connue, écrite plutôt que découverte** : `lastSeenAt` est mis à jour **en place**, donc +il ne porte que sa valeur finale. Une lecture historique qui traverse un **retour** à un contenu +antérieur rend une réponse **approchée**. L'exactitude demanderait une table d'**épisodes** +d'observation — différée, comme la colonne d'ensemble de plans du §22.2. + +### 23.3 Ce que l'épisode dit + +Deux défauts, tous deux **de moi**, tous deux invisibles à la suite de tests, révélés en quatre +minutes par **un plan que quelqu'un d'autre a créé pour une raison sans rapport**. + +Le premier ne se voyait qu'en appelant le RPC ; le second qu'en **retirant** quelque chose — +geste qu'aucun test ne faisait, parce qu'aucun scénario n'y pensait. Le banc a produit un cas que +ni le producteur ni moi n'avions prévu : **ajouter puis retirer**. + +*(Fenêtre à écarter de toute comparaison prévu/réalisé : **08:07 → 08:11:36**, où la table décrit +une installation fictive. Les deux révisions qui l'encadrent sont des changements +d'INSTALLATION, pas de météo — exactement ce que la colonne différée du §22.2 servirait à dire.)* diff --git a/energyplugin/etm/forecast/forecaststore.cpp b/energyplugin/etm/forecast/forecaststore.cpp index 74ff769..751f973 100644 --- a/energyplugin/etm/forecast/forecaststore.cpp +++ b/energyplugin/etm/forecast/forecaststore.cpp @@ -294,15 +294,26 @@ QList ForecastStore::courbe(const QDateTime &de, const QDateTime return points; const QDateTime borne = connuA.isValid() ? connuA : QDateTime::currentDateTimeUtc(); - // Pour chaque heure visée, la DERNIÈRE révision connue à l'instant demandé. `connuA` rend la - // lecture reproductible : « ce qu'on prévoyait à 8 h » se demande en le fixant à 8 h, et - // deux lectures du même passé donnent le même résultat. + // COURANTE = LA DERNIÈRE VUE, pas la dernière APPARUE. La nuance a mordu le 2026-09-09 : + // un plan de test ajouté à 08:07 puis retiré à 08:11 a fait réapparaître un contenu DÉJÀ + // connu (celui d'avant l'ajout). Son `firstSeenAt` n'a pas bougé — c'est le principe même du + // champ — si bien qu'un tri sur `MAX(firstSeenAt)` continuait de servir la courbe de + // l'installation FICTIVE, quatre minutes après sa disparition. + // + // Ce qui est courant, c'est ce qu'on a vu EN DERNIER. `firstSeenAt` garde son rôle : il dit + // quand un contenu est apparu, pas s'il a cours. + // + // LIMITE CONNUE, et écrite plutôt que découverte : `lastSeenAt` est mis à jour EN PLACE, + // donc il ne porte que sa valeur FINALE. Une lecture historique (`connuA` dans le passé) qui + // traverse un RETOUR à un contenu antérieur rend donc une réponse approchée. L'exactitude + // demanderait une table d'ÉPISODES d'observation — un schéma de plus, différé tant que ce + // mécanisme n'a qu'une journée derrière lui. QSqlQuery q(m_db); q.prepare(QStringLiteral( "SELECT targetAt, forecastWh, firstSeenAt, lastSeenAt FROM forecastPv f " "WHERE targetAt BETWEEN ? AND ? AND firstSeenAt <= ? " - " AND firstSeenAt = (SELECT MAX(firstSeenAt) FROM forecastPv g " - " WHERE g.targetAt = f.targetAt AND g.firstSeenAt <= ?) " + " AND lastSeenAt = (SELECT MAX(lastSeenAt) FROM forecastPv g " + " WHERE g.targetAt = f.targetAt AND g.firstSeenAt <= ?) " "ORDER BY targetAt")); const qint64 limite = borne.toUTC().toMSecsSinceEpoch(); // firstSeenAt est en ms q.addBindValue(de.toUTC().toSecsSinceEpoch()); diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index b93fa9a..631a9e0 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -327,6 +327,18 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana "temperature and inverter clipping are as wrong at three hours as at " "thirty-six, and all in the optimistic direction. 'Nearer forecast is more " "reliable' must NOT be shown as 'nearer figure is more accurate'."; + 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("forecastWh", enumValueName(Double)); + // DEUX INSTANTS, DEUX NOMS. `firstSeenAt` est l'apparition du contenu — il ORDONNE les + // révisions. `lastSeenAt` est la dernière republication à l'identique — la FRAÎCHEUR. + // Aucun des deux n'est un instant d'ÉMISSION, et aucun horizon ne s'en calcule. + pointItem.insert("firstSeenAt", enumValueName(String)); // ISO 8601 UTC + pointItem.insert("lastSeenAt", enumValueName(String)); // ISO 8601 UTC + returns.insert("forecastPoints", QVariantList() << pointItem); registerMethod("GetPvForecast", description, params, returns, Types::PermissionScopeControlThings); // Désignation GLOBALE, sur le modèle d'Energy.SetRootMeter — même nature : une source unique diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index b247567..016d4b7 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -1117,3 +1117,35 @@ void TestLoadModel::testAPayloadIsRefusedOnFormNeverOnAMissingAttribute() QVERIFY(refus.isEmpty()); QCOMPARE(troue.points.count(), 2); } + +void TestLoadModel::testTheCurrentCurveIsTheLastSeenNotTheLastNew() +{ + // Cas mordu sur .75 le 2026-09-09 : un plan de test ajouté puis retiré fait RÉAPPARAÎTRE un + // contenu déjà connu. Son firstSeenAt ne bouge pas — c'est le principe du champ — si bien + // qu'un tri sur MAX(firstSeenAt) continuait de servir la courbe de l'installation FICTIVE + // quatre minutes après sa disparition. + QTemporaryDir dir; + ForecastStore magasin(dir.filePath("forecast.sqlite")); + QVERIFY(magasin.isValid()); + const QDateTime t0 = QDateTime(QDate(2026, 9, 9), QTime(10, 0), QTimeZone::utc()); + + QVERIFY(magasin.enregistrer(revision(t0, t0, {100, 200}))); // A — un plan + QTest::qWait(5); + QVERIFY(magasin.enregistrer(revision(t0, t0, {180, 360}))); // B — deux plans (fiction) + QTest::qWait(5); + QCOMPARE(qRound(magasin.courbe(t0, t0.addSecs(3600)).first().wh), 180); // B a cours + + // LE RETOUR À A : contenu déjà connu, donc AUCUNE ligne neuve — seule la fraîcheur avance. + QVERIFY2(!magasin.enregistrer(revision(t0, t0, {100, 200})), + "un contenu déjà connu ne crée pas de révision, même en revenant"); + QCOMPARE(magasin.revisions(t0).count(), 2); + + // ET C'EST A QUI A COURS, parce qu'il a été vu EN DERNIER. Avec un tri sur l'apparition, B + // resterait servi indéfiniment : une courbe qui ne décrit plus rien, sans que rien ne le dise. + const QList vue = magasin.courbe(t0, t0.addSecs(3600)); + QCOMPARE(vue.count(), 2); + QCOMPARE(qRound(vue.first().wh), 100); + // L'apparition de A reste la PREMIÈRE : le retour ne réécrit pas son identité. + QVERIFY2(vue.first().firstSeenAt < vue.first().lastSeenAt, + "revenir à un contenu avance sa fraîcheur, jamais son apparition"); +} diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index 3fad2b8..5010f6d 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -92,6 +92,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 courbe COURANTE est la dernière VUE, pas la dernière apparue. + void testTheCurrentCurveIsTheLastSeenNotTheLastNew(); //! La charge utile se REFUSE plutôt que de s'interpréter au mieux — mais pas pour un attribut. void testAPayloadIsRefusedOnFormNeverOnAMissingAttribute();