diff --git a/docs/DESIGN_PREVISION_PV.md b/docs/DESIGN_PREVISION_PV.md index a8b99c8..c155beb 100644 --- a/docs/DESIGN_PREVISION_PV.md +++ b/docs/DESIGN_PREVISION_PV.md @@ -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. diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 82f1f79..4cbe66a 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -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)); } diff --git a/energyplugin/etm/forecast/forecaststore.cpp b/energyplugin/etm/forecast/forecaststore.cpp index 485fad6..0549e24 100644 --- a/energyplugin/etm/forecast/forecaststore.cpp +++ b/energyplugin/etm/forecast/forecaststore.cpp @@ -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 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(); diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index 42dbb85..0988dfe 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -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)) diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index d44d895..25fe1ce 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -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 diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index e6903f6..f84f7cd 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -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 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); +} diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index c6b537f..dfc377a 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -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(); };