fix(prévision PV): le RPC ne rendait plus rien, et la courbe courante était la mauvaise
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
This commit is contained in:
parent
a5b3d5fc16
commit
4e7826ff8a
@ -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.)*
|
||||
|
||||
@ -294,15 +294,26 @@ QList<ForecastPoint> 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());
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<ForecastPoint> 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");
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user