From 4e7826ff8a3593f608281fd8ef7f043a23c500bc Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 9 Sep 2026 08:21:10 +0200 Subject: [PATCH] =?UTF-8?q?fix(pr=C3=A9vision=20PV):=20le=20RPC=20ne=20ren?= =?UTF-8?q?dait=20plus=20rien,=20et=20la=20courbe=20courante=20=C3=A9tait?= =?UTF-8?q?=20la=20mauvaise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux défauts de moi, révélés en quatre minutes par un plan de test que le producteur a ajouté puis retiré pour une raison sans rapport. 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é, donc sans returns la charge utile est éliminée en silence. Aucun test ne couvre la surface RPC : 31 unitaires, la simulation et ci-quality passaient pendant que la méthode ne rendait plus rien. Ce n'est pas un défaut de logique mais de DÉCLARATION, et rien ici ne les regarde. 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 ; son firstSeenAt n'a pas bougé — c'est le principe du champ — mais le tri était fait dessus. 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. Ce qui a cours est ce qu'on a vu EN DERNIER. Le tri passe sur lastSeenAt ; firstSeenAt dit quand un contenu est apparu, pas s'il a cours. Limite écrite plutôt que découverte : lastSeenAt est mis à jour en place, donc une lecture historique traversant un RETOUR à un contenu antérieur est approchée. L'exactitude demanderait une table d'épisodes — différée. Et une correction sur mon relevé précédent : « huit fenêtres par jour, jusqu'à une heure chacune » est DÉDUIT du code, pas mesuré — le producteur le dit lui-même. Ce qui est certain est que la dérive était monotone et sans rappel. L'écrire comme une mesure en ferait la prémisse de quelqu'un. Fenêtre à écarter de toute comparaison prévu/réalisé : 08:07 → 08:11:36. loadmodel 31/31, charging 48/48, spotmarket 32/32, simulation 69/69, 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 | 64 ++++++++++++++++++++- energyplugin/etm/forecast/forecaststore.cpp | 21 +++++-- energyplugin/nymeaenergyjsonhandler.cpp | 12 ++++ tests/auto/loadmodel/testloadmodel.cpp | 32 +++++++++++ tests/auto/loadmodel/testloadmodel.h | 2 + 5 files changed, 124 insertions(+), 7 deletions(-) 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();