fix(prévision PV): un RUN est immuable — écrasure silencieuse trouvée sur le banc

On attendait la déduplication ; on a trouvé une écrasure.

06:03 RÉVISION du run 00Z — 48 créneaux
07:02 RÉVISION du run 00Z — 48 créneaux    <- MÊME RUN

Deux écritures pour la même identité de run à une heure d'écart : les valeurs
avaient changé sous un issuedAt inchangé, et INSERT OR REPLACE les a remplacées
sans rien dire.

LA PREUVE A ÉTÉ DÉTRUITE PAR LE DÉFAUT LUI-MÊME. À 07:14 la table et l'état
publié coïncidaient parfaitement — l'écrasure avait déjà aligné les deux, et la
question « quels créneaux ont changé, les passés ou les futurs ? » est devenue
impossible à poser.

CORRECTION : un run est immuable. Des valeurs neuves sous une identité connue
n'écrasent plus rien — écriture refusée, premières valeurs conservées, anomalie
journalisée AVEC ce qui manquait pour décider : combien de créneaux, et le
partage passé/futur.

Ce partage est LE discriminant et il commande deux conduites opposées. Passés
seulement : le producteur fusionne l'observé, légitime chez lui, et c'est NOTRE
clé qui est trop courte — il lui faudrait une composante de contenu. Futurs :
c'est un run qui n'identifie pas son contenu, donc un défaut à lui remonter.

Et un cas neuf pour la famille des modes d'érosion : un défaut qui écrase la
donnée qui le prouverait ne laisse pas de preuve, il laisse une COHÉRENCE. À
07:14 tout concordait — exactement ce qu'on aurait vu si tout allait bien.

Au passage, le geste du motif s'adapte au nombre de candidats : « désignez-le »
devant un seul, « choisissez-en un parmi les N » au-delà. Envoyer chercher une
liste qui n'existe pas fait perdre le temps que l'aide prétend gagner, et c'est
le pire endroit pour se tromper puisque celui qui la lit ne sait pas déjà.

loadmodel 33/33, 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:
Patrick Schurig 2026-09-09 07:23:42 +02:00
parent c55f1ec73a
commit 2984d1ba87
7 changed files with 152 additions and 9 deletions

View File

@ -824,3 +824,56 @@ un futur mécanisme qui les confondrait rallumerait une fonction que quelqu'un a
Le signalement tourne **au premier cycle, pas au constructeur**. Au constructeur, le
`ThingManager` n'a pas encore chargé les Things : « aucun candidat » y serait toujours vrai, et
l'avertissement **ne se déclencherait jamais** — un mécanisme inerte qui aurait l'air posé.
---
## 19. Un RUN est immuable — le défaut que la mesure a trouvé (2026-09-09)
**Ce que le banc a montré, et qui n'était pas ce qu'on cherchait.** On attendait la
déduplication ; on a trouvé une écrasure.
```
06:03 [Prévision] RÉVISION du run 2026-09-09T00:00:00Z — 48 créneaux
07:02 [Prévision] RÉVISION du run 2026-09-09T00:00:00Z — 48 créneaux ← MÊME RUN
```
Deux écritures pour **la même identité de run**, une heure d'écart. Les valeurs avaient donc
changé sous un `issuedAt` inchangé — et `INSERT OR REPLACE` les a remplacées **sans rien dire**.
> **La preuve de ce qui avait bougé a été détruite par le défaut lui-même.** À 07:14, la table
> et l'état publié coïncidaient parfaitement : la comparaison ne pouvait plus rien montrer,
> puisque l'écrasure avait déjà aligné les deux. La question « quels créneaux ont changé, les
> passés ou les futurs ? » est devenue impossible à poser.
### 19.1 La correction : refuser, et relever ce qu'il fallait pour trancher
Un run **est immuable**. Des valeurs neuves sous une identité connue **n'écrasent plus rien** :
l'écriture est refusée, les premières valeurs sont conservées, et l'anomalie est journalisée avec
**ce qui manquait pour décider** :
```
[Prévision] RUN MUTÉ 2026-09-09T00:00:00Z — 7 créneau(x) modifiés (7 passés, 0 à venir)
sous une identité de run INCHANGÉE. Écriture REFUSÉE.
```
Le partage passé/futur est **le** discriminant, et il commande deux conduites opposées :
| ce qui bouge | lecture | conduite |
|---|---|---|
| **passés seulement** | le producteur fusionne l'**observé** dans la courbe — légitime chez lui | notre clé est trop courte : il lui faut une composante de contenu |
| **futurs** | un run qui **n'identifie pas son contenu** | c'est un défaut à remonter au producteur |
Sans ce relevé, la question ne se pose même pas — la donnée qui la trancherait a disparu.
### 19.2 Ce que l'épisode dit de la méthode
C'est la troisième fois cette semaine qu'un défaut **ne se voit que sur la machine**, et la
première où il **efface sa propre trace**. La famille s'agrandit d'un cas :
> **Un défaut qui écrase la donnée qui le prouverait ne laisse pas de preuve — il laisse une
> cohérence.** À 07:14, tout concordait. C'est exactement ce qu'on aurait vu si tout allait bien.
Et il rappelle pourquoi l'ordre demandé était le bon : observer **avant** de redéployer. Un
redémarrage aurait relu l'état, réécrit la ligne, et rendu les deux hypothèses — « la
déduplication marche » et « le redémarrage a tout relu » — définitivement indistinguables.

View File

@ -2291,12 +2291,14 @@ void EnergyArbitrator::signalerSiteManquant()
// SILENCE S'IL N'Y A RIEN À DÉSIGNER. Une installation sans plugin météo appairé n'a rien à
// choisir : lui annoncer qu'il manque une désignation serait un avertissement qu'on ne peut
// pas satisfaire, donc du bruit permanent.
if (forecastSiteCandidates().isEmpty())
const QStringList candidats = forecastSiteCandidates();
if (candidats.isEmpty())
return;
m_forecastSiteSignale = true;
qCInfo(dcNymeaEnergy()).noquote()
<< QStringLiteral("[Prévision] %1 — la lecture reste arrêtée (motif %2)")
.arg(renderFr({ DecisionCode::MissingForecastSite, {} }))
.arg(renderFr({ DecisionCode::MissingForecastSite,
{{QStringLiteral("candidates"), candidats.count()}} }))
.arg(QLatin1String(DecisionCode::MissingForecastSite));
}

View File

@ -227,6 +227,48 @@ bool ForecastStore::enregistrer(const ForecastRevision &revision)
}
}
// --- UN RUN EST IMMUABLE ----------------------------------------------------------------
//
// Si ce run est DÉJÀ connu et que les valeurs diffèrent, le producteur a modifié une
// prévision qu'il avait publiée sous cette identité. Écraser serait détruire l'historique
// EN SILENCE — c'est ce que faisait la version précédente, et le 2026-09-09 à 07:02 elle a
// remplacé les valeurs de 06:03 sans laisser trace de ce qui avait bougé.
//
// On refuse donc, et on le DIT avec de quoi trancher plus tard : combien de créneaux
// diffèrent, et s'ils sont passés ou à venir. Des créneaux PASSÉS qui bougent seraient une
// fusion observé/prévu légitime chez le producteur — et imposeraient de changer notre clé.
// Des créneaux FUTURS qui bougent seraient un run qui n'identifie pas son contenu.
// Sans ce relevé, la question ne se pose même pas : la donnée qui la trancherait a disparu.
QSqlQuery connu(m_db);
connu.prepare(QStringLiteral(
"SELECT targetAt, forecastWh FROM forecastPv WHERE firstIssuedAt = ?"));
connu.addBindValue(run);
QHash<qint64, double> avant;
if (connu.exec())
while (connu.next())
avant.insert(connu.value(0).toLongLong(), connu.value(1).toDouble());
if (!avant.isEmpty()) {
const qint64 maintenant = QDateTime::currentSecsSinceEpoch();
int passes = 0, futurs = 0;
for (const ForecastPoint &p : revision.points) {
const qint64 t = p.targetAt.toUTC().toSecsSinceEpoch();
const auto it = avant.constFind(t);
if (it == avant.constEnd() || qAbs(it.value() - p.wh) <= EgaliteWh)
continue;
(t < maintenant ? passes : futurs)++;
}
if (passes + futurs > 0)
qCWarning(dcNymeaEnergy()).noquote()
<< QStringLiteral("[Prévision] RUN MUTÉ %1 — %2 créneau(x) modifiés (%3 passés, "
"%4 à venir) sous une identité de run INCHANGÉE. Écriture "
"REFUSÉE : les premières valeurs sont conservées.")
.arg(revision.issuedAt.toUTC().toString(Qt::ISODate))
.arg(passes + futurs).arg(passes).arg(futurs);
// Rien à écrire dans les deux cas : identique, la reconduction a déjà été traitée
// plus haut ; différent, le run est immuable et on ne détruit pas ce qu'on a.
return false;
}
// --- écriture d'une VRAIE révision, en une transaction ----------------------------------
if (!m_db.transaction())
qCWarning(dcNymeaEnergy()) << "[Prévision] transaction refusée :" << m_db.lastError().text();

View File

@ -212,12 +212,17 @@ QString renderFr(const DecisionReason &reason, const QString &label)
return QStringLiteral("Dérogation terminée sur %1 — %2").arg(label).arg(pourquoi);
}
if (c == QLatin1String(DecisionCode::MissingForecastSite))
// LE GESTE, pas seulement le manque. « Il en manque une » laisse l'utilisateur devant un
// constat ; « choisissez-en un dans la configuration » lui donne la marche à suivre.
// Même exigence que les trois causes d'ECO_FLOOR_MISSED : trois causes, trois gestes.
return QStringLiteral("Aucun site de prévision désigné — choisissez-en un dans la "
"configuration");
if (c == QLatin1String(DecisionCode::MissingForecastSite)) {
// LE GESTE, pas seulement le manque — et LE BON GESTE. « Choisissez-en un » devant un
// seul candidat envoie chercher une liste qui n'existe pas ; celui qui lit l'aide ne
// sait pas déjà, donc un geste faux lui fait perdre le temps qu'elle prétend gagner.
const int n = p.value(QStringLiteral("candidates")).toInt();
if (n == 1)
return QStringLiteral("Aucun site de prévision désigné — désignez-le dans la "
"configuration");
return QStringLiteral("Aucun site de prévision désigné — choisissez-en un parmi les %1 "
"disponibles dans la configuration").arg(n);
}
// --- Sécurité et cycle de vie ------------------------------------------
if (c == QLatin1String(DecisionCode::DegradedL2))

View File

@ -282,7 +282,8 @@ constexpr const char *DerogationEnded = "DEROGATION_ENDED";
/*!
* \brief Des plans de prévision existent, mais AUCUN SITE n'est désigné — la lecture est arrêtée.
* params : aucun.
* params : \c candidates — NOMBRE de sites désignables. Il change le GESTE : devant un seul,
* « choisissez-en un » enverrait chercher une liste qui n'existe pas.
*
* \note **Publié seulement si un site existe.** Une installation sans plugin météo appairé n'a
* rien à désigner : lui annoncer qu'il manque une désignation serait un avertissement qu'on ne

View File

@ -1220,3 +1220,41 @@ void TestLoadModel::testAnUnknownRunKeyIsRefusedNotGuessed()
QVERIFY(magasin.isValid());
QVERIFY2(!magasin.enregistrer(sansCle), "une révision sans clé de run ne s'enregistre pas");
}
void TestLoadModel::testARunIsImmutable()
{
// Constaté sur .75 le 2026-09-09 : à 07:02, une seconde écriture pour le run 00Z, dont les
// valeurs différaient de celles de 06:03. L'implémentation d'alors faisait INSERT OR REPLACE
// et les a remplacées EN SILENCE — la preuve de ce qui avait bougé a été détruite par le
// défaut lui-même, et la question « passé ou futur ? » est devenue impossible à poser.
QTemporaryDir dir;
ForecastStore magasin(dir.filePath("forecast.sqlite"));
QVERIFY(magasin.isValid());
const QDateTime run = QDateTime(QDate(2026, 9, 9), QTime(0, 0), QTimeZone::utc());
const QDateTime t0 = QDateTime(QDate(2026, 9, 9), QTime(10, 0), QTimeZone::utc());
QVERIFY(magasin.enregistrer(revision(run, t0, {100, 200, 300})));
// 1. LE MÊME RUN AVEC D'AUTRES VALEURS N'ÉCRASE RIEN. Le producteur a modifié une prévision
// qu'il avait publiée sous cette identité : c'est une anomalie à SIGNALER, pas à absorber.
QVERIFY2(!magasin.enregistrer(revision(run, t0, {100, 999, 300})),
"un run est immuable — une écriture divergente doit être refusée");
// 2. ET LES PREMIÈRES VALEURS SONT INTACTES. C'est tout l'enjeu : détruire l'historique
// supprime la donnée qui permettrait de trancher plus tard.
const QList<ForecastPoint> vue = magasin.courbe(t0, t0.addSecs(2 * 3600), run.addSecs(60));
QCOMPARE(vue.count(), 3);
QCOMPARE(qRound(vue.at(1).wh), 200); // et non 999
QCOMPARE(magasin.revisions(t0).count(), 1);
// 3. CONTRE-ÉPREUVE — un run DIFFÉRENT aux mêmes valeurs reconduit (pas de ligne neuve),
// et un run différent aux valeurs neuves ouvre bien une révision. Sans ces deux lignes,
// on ne saurait pas si le refus vise la mutation ou toute écriture.
const QDateTime run06 = run.addSecs(6 * 3600);
QVERIFY(!magasin.enregistrer(revision(run06, t0, {100, 200, 300}))); // reconduction
QCOMPARE(magasin.revisions(t0).count(), 1);
const QDateTime run12 = run.addSecs(12 * 3600);
QVERIFY(magasin.enregistrer(revision(run12, t0, {100, 888, 300}))); // vraie révision
QCOMPARE(magasin.revisions(t0).count(), 2);
}

View File

@ -97,6 +97,8 @@ private slots:
void testFreshnessNeverDatesTheForecast();
//! Une clé de run inconnue se REFUSE — l'heure de réception ne s'y substitue jamais.
void testAnUnknownRunKeyIsRefusedNotGuessed();
//! Un RUN est immuable : des valeurs neuves sous une identité connue n'écrasent rien.
void testARunIsImmutable();
//! La charge utile de transport se REFUSE plutôt que de s'interpréter au mieux.
void testAnUnknownPayloadIsRefusedNotBestEffort();
};