diff --git a/docs/DESIGN_PREVISION_PV.md b/docs/DESIGN_PREVISION_PV.md index 9e98c77..627e2a9 100644 --- a/docs/DESIGN_PREVISION_PV.md +++ b/docs/DESIGN_PREVISION_PV.md @@ -877,3 +877,77 @@ première où il **efface sa propre trace**. La famille s'agrandit d'un cas : 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. + + +--- + +## 20. La clé devient LE CONTENU — `issuedAt` n'identifie pas la courbe (2026-09-09) + +**Réponse mesurée du producteur, et elle renverse le §13 comme le §19.** + +| fait établi | conséquence | +|---|---| +| `issuedAt` vient de `meteofrance_arome_france_hd`, **seul domaine AROME exposant un `meta.json`** — qui sert **0 valeur de rayonnement sur 72** | il vient d'un domaine qui **ne contribue rien** à la courbe | +| `meteofrance_arome_france`, qui fournit le rayonnement, n'expose **aucun** `meta.json` (HTTP 500) | il n'existe **aucune** identité de run pour la donnée qui forme `p` | +| à 07:30, `seamless` diverge d'`arome_france` de 08:00 à 13:00 (144 contre 356 ; 326 contre 530) et coïncide à partir de 14:00 | le serveur **recompose** les premières heures autrement que par le seul run | + +**Ce n'est donc pas un défaut chez le producteur : c'est ce que l'API permet.** Et `+etm63` +**gelait** la courbe — le seul endroit où le lot était **plus faux qu'avant**. + +### 20.1 La règle n'a pas bougé ; la clé, si + +> *« Une révision n'existe que si la PRÉVISION a changé. »* + +La règle est intacte. Ce qui était faux, c'est la **prémisse** : que le run identifie la +prévision. **Regarder le contenu lui-même y est plus fidèle que de regarder une étiquette +d'émission** — la formulation le disait déjà, et c'est la clé qui la rattrape. + +```sql +targetAt INTEGER -- frontière d'heure, en secondes +siteId TEXT -- \ +contentId TEXT -- > CLÉ : le site et l'empreinte de la courbe +firstSeenAt INTEGER -- apparition du contenu, EN MILLISECONDES — ordonne les révisions +lastSeenAt INTEGER -- dernière republication à l'identique — fraîcheur +issuedAt INTEGER -- ATTRIBUT, peut être NUL +model TEXT -- ATTRIBUT +PRIMARY KEY (targetAt, siteId, contentId) +``` + +**Migration v1 → v2 : la table est REFAITE, pas convertie.** Les lignes v1 sont clés sur un run +qui n'identifie pas leur contenu et ont pu être écrasées : leur donner une empreinte calculée sur +des valeurs dont on ignore l'origine serait **inventer une provenance**. La règle du producteur +s'applique — *une révision manquante est une lacune visible ; une révision mal datée est un +mensonge invisible.* + +### 20.2 Trois conséquences que la mesure impose + +**`issuedAt == 0` n'est PLUS un refus.** Il l'a été deux heures, tant qu'il servait de clé. +Devenu attribut, refuser une courbe exploitable parce qu'une **métadonnée** manque reviendrait à +**jeter la donnée pour son étiquette** — et `best_match` le met à zéro pour *toutes* ses courbes. +Il est stocké **NUL**, et aucun instant ne lui est substitué. + +**Le `siteId` entre dans la clé.** Deux plans publient des courbes **différentes** sous le +**même** `issuedAt` (tilt, azimut, kWc, PR entrent dans `p`). Ici la granularité est le site, +puisqu'on somme — mais le besoin est identique : sans lui, changer de site désigné **mêlerait +deux historiques** que rien ne distinguerait plus. La garde d'hier devient **nécessaire**. + +**`t0` est ancré sur le jour local de la REQUÊTE**, pas du run : à minuit, même run, fenêtre +décalée. C'est couvert sans rien ajouter — les `targetAt` changent, donc l'empreinte aussi. + +### 20.3 Un défaut trouvé par le test, et il n'était pas dans le test + +Deux révisions écrites **dans la même seconde** n'étaient pas ordonnables : `MAX(firstSeenAt)` en +rendait deux, et la courbe retournait des doublons. Les instants d'observation sont donc en +**millisecondes**, à la différence de `targetAt` qui est une frontière d'heure. + +> **Un instant d'observation doit distinguer deux observations, sinon il n'ordonne rien.** Sur la +> box, un cycle par minute l'aurait caché indéfiniment ; un test qui écrit deux fois d'affilée +> l'a sorti tout de suite. + +### 20.4 Ce qui reste une hypothèse, et doit le rester + +Le producteur **n'a pas établi pourquoi** le serveur recompose les premières heures — nowcast +satellite, mélange court terme, ou deux domaines sur des runs différents. **Il a mesuré que ça +diverge et sur quelles heures.** L'explication n'est pas acquise, et rien ici n'en dépend : la clé +de contenu est correcte quelle qu'en soit la cause. C'est précisément pourquoi elle est +préférable — elle n'a pas besoin qu'on comprenne le producteur pour être juste. diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 4cbe66a..dacd4d5 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -2384,6 +2384,7 @@ void EnergyArbitrator::lireCourbePrevision() return; ForecastRevision site; + site.siteId = m_forecastSiteId; site.issuedAt = run; site.model = plans.first().model; QMap somme; // trié par instant, et c'est ce qu'on veut publier diff --git a/energyplugin/etm/forecast/forecaststore.cpp b/energyplugin/etm/forecast/forecaststore.cpp index 0549e24..74ff769 100644 --- a/energyplugin/etm/forecast/forecaststore.cpp +++ b/energyplugin/etm/forecast/forecaststore.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -15,7 +16,8 @@ Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy) namespace { //! Version du schéma. À NOUS, pas à l'amont — c'est tout l'intérêt d'un fichier séparé. -constexpr int SchemaVersion = 1; +//! Version du schéma. 2 : la clé est le CONTENU, plus le run — `issuedAt` ne l'identifie pas. +constexpr int SchemaVersion = 2; //! Fenêtre de révisions PLEINES, en jours. Au-delà, on n'garde que les deux bouts. constexpr int RetentionPleineJours = 7; @@ -67,20 +69,18 @@ ForecastRevision ForecastStore::depuisTransport(const QVariantMap &charge, QStri refus = QStringLiteral("MALFORMED"); return r; } - // `issuedAt == 0` SIGNIFIE « CLÉ INCONNUE », et c'est un refus À PART — pas une charge utile - // mal formée. Le producteur le publie ainsi quand le repli `best_match` est actif : cette - // stratégie n'a pas d'identité de run unique, donc il n'y a rien à dater. + // `issuedAt == 0` SIGNIFIE « ÂGE INCONNU » — et ce n'est PAS un refus. // - // ET SURTOUT : NE JAMAIS Y SUBSTITUER L'HEURE DE RÉCEPTION. Elle serait plausible et fausse - // — trop courte de toute la latence de publication — et une fois écrite, plus rien dans la - // donnée ne distinguerait la ligne devinée de la ligne datée. Une révision manquante est une - // lacune visible ; une révision mal datée est un mensonge invisible. - if (run <= 0) { - refus = QStringLiteral("ISSUED_AT_UNKNOWN"); - return r; - } + // Il l'a été, tant que ce champ servait de clé. Depuis qu'il est mesuré qu'il n'identifie + // pas le contenu, il n'est plus qu'un ATTRIBUT : refuser une courbe exploitable parce qu'une + // métadonnée manque reviendrait à jeter la donnée pour son étiquette. Le repli `best_match` + // le met à zéro pour TOUTES ses courbes, qui sont pourtant parfaitement utilisables. + // + // ET AUCUN INSTANT NE LUI EST SUBSTITUÉ : ni la réception, ni t0. Un âge inconnu se dit par + // une ABSENCE, jamais par une valeur plausible qu'on ne pourrait plus distinguer d'une vraie. - r.issuedAt = QDateTime::fromSecsSinceEpoch(run, QTimeZone::utc()); + if (run > 0) + r.issuedAt = QDateTime::fromSecsSinceEpoch(run, QTimeZone::utc()); r.model = charge.value(QStringLiteral("model")).toString(); for (int i = 0; i < p.count(); ++i) { bool ok = false; @@ -130,27 +130,59 @@ ForecastStore::~ForecastStore() bool ForecastStore::migrer() { QSqlQuery q(m_db); - // LA CLÉ EST LA PAIRE, et c'est tout le lot. Avec targetAt seul, chaque rafraîchissement - // écraserait le précédent — on perdrait les révisions, donc la comparaison prévu/réalisé. + // LA CLÉ EST LE CONTENU. Elle l'a d'abord été le run — jusqu'à ce que le producteur + // établisse, mesures à l'appui, que `issuedAt` n'identifie pas la courbe. La règle, elle, + // n'a pas bougé : « une révision n'existe que si la PRÉVISION a changé ». Regarder la + // prévision elle-même y est plus fidèle que de regarder une étiquette d'émission. // // Les deux instants sont des EPOCHS UTC (secondes). Jamais une chaîne d'heure locale : un // « 02:00 » local existe deux fois en octobre et pas du tout en mars. + // MIGRATION v1 → v2 : la TABLE EST REFAITE, pas convertie. + // + // Les lignes de la v1 sont clés sur un run qui n'identifie pas leur contenu, et elles ont pu + // être écrasées par des rafraîchissements successifs (constaté sur `.75` le 2026-09-09). On + // ne peut donc pas savoir quel contenu chacune porte réellement. Les convertir leur donnerait + // une empreinte calculée sur des valeurs dont on ignore l'origine — une provenance inventée. + // + // C'est la règle déjà posée par le producteur, et elle vaut ici : une révision manquante est + // une lacune VISIBLE ; une révision mal datée est un mensonge INVISIBLE. La table se + // reconstruit en un cycle. + if (q.exec(QStringLiteral("PRAGMA user_version")) && q.next() + && q.value(0).toInt() > 0 && q.value(0).toInt() < SchemaVersion) { + qCWarning(dcNymeaEnergy()) << "[Prévision] schéma v" << q.value(0).toInt() + << "→ v" << SchemaVersion + << ": table REFAITE, l'historique v1 ne peut pas être daté" + " par contenu et n'est pas converti"; + q.exec(QStringLiteral("DROP TABLE IF EXISTS forecastPv")); + } + const bool ok = q.exec(QStringLiteral( "CREATE TABLE IF NOT EXISTS forecastPv (" - " targetAt INTEGER NOT NULL," - // LE RUN QUI A PRODUIT CES VALEURS EN PREMIER. C'est la clé : elle ne bouge jamais, - // sans quoi l'identité de la ligne changerait sous elle. - " firstIssuedAt INTEGER NOT NULL," - // LE DERNIER RUN QUI LES A RECONDUITES, mis à jour en place. Deux instants et deux - // noms : un seul champ ne peut pas porter les deux faits sans mentir sur l'un. - // La FRAÎCHEUR est lastConfirmedAt ; l'ANCIENNETÉ DE L'AVIS est firstIssuedAt. - " lastConfirmedAt INTEGER NOT NULL," - " forecastWh REAL NOT NULL," - " model TEXT," - " PRIMARY KEY (targetAt, firstIssuedAt))")) + " targetAt INTEGER NOT NULL," + // LE SITE dont la courbe est la somme. Sans lui, changer de site désigné mêlerait + // deux historiques que rien ne distinguerait plus. + " siteId TEXT NOT NULL," + // L'EMPREINTE DE LA COURBE ENTIÈRE — LA CLÉ. `issuedAt` ne peut pas la porter : il + // vient d'un domaine voisin qui ne fournit AUCUNE des valeurs de la courbe, et le + // serveur recompose les premières heures sans le faire bouger (mesuré 2026-09-09). + " contentId TEXT NOT NULL," + // QUAND CE CONTENU EST APPARU — l'ordre des révisions, faute d'identité d'émission. + // + // EN MILLISECONDES, à la différence de targetAt qui est une frontière d'heure. Deux + // révisions apparues dans la MÊME SECONDE seraient sinon inordonnables, et la + // « dernière connue » en rendrait deux : un instant d'observation doit distinguer + // deux observations, sinon il n'ordonne rien. + " firstSeenAt INTEGER NOT NULL," + // DERNIÈRE REPUBLICATION À L'IDENTIQUE — la fraîcheur, mise à jour en place. + " lastSeenAt INTEGER NOT NULL," + " forecastWh REAL NOT NULL," + // ATTRIBUTS, jamais la clé. `issuedAt` peut être NUL (repli best_match). + " issuedAt INTEGER," + " model TEXT," + " PRIMARY KEY (targetAt, siteId, contentId))")) && q.exec(QStringLiteral( - "CREATE INDEX IF NOT EXISTS idxForecastIssued ON forecastPv(firstIssuedAt)")) + "CREATE INDEX IF NOT EXISTS idxForecastSeen ON forecastPv(firstSeenAt)")) && q.exec(QStringLiteral("PRAGMA user_version = %1").arg(SchemaVersion)); if (!ok) qCWarning(dcNymeaEnergy()) << "[Prévision] migration échouée :" << q.lastError().text(); @@ -161,134 +193,77 @@ bool ForecastStore::enregistrer(const ForecastRevision &revision) { if (!m_valide) return false; - // UNE RÉVISION VIDE N'EST PAS UNE RÉVISION. Pas de réseau, API en erreur, plan absent : - // l'absence est un TROU, et l'écrire en courbe de zéros se lirait « on prévoit une nuit ». - if (revision.points.isEmpty() || !revision.issuedAt.isValid()) { - qCDebug(dcNymeaEnergy()) << "[Prévision] révision vide ou sans heure de run — ignorée"; - return false; - } - const qint64 run = revision.issuedAt.toUTC().toSecsSinceEpoch(); - - // --- UNE RÉVISION N'EXISTE QUE SI LA PRÉVISION A CHANGÉ --------------------------------- + // UNE RÉVISION VIDE N'EST PAS UNE RÉVISION. L'absence est un TROU, et l'écrire en courbe de + // zéros se lirait « on prévoit une nuit ». // - // La comparaison porte sur LES VALEURS, sur les créneaux communs — jamais sur la charge - // utile entière, qui contient `firstIssuedAt` et différerait donc à chaque run. - // - // ET C'EST LE DERNIER REMPART, PAS UN FILET : la déduplication du producteur compare la - // charge utile complète, où `firstIssuedAt` figure, donc elle ne mord jamais sur ce cas. Si - // celle-ci se trompe, personne ne rattrape. - QSqlQuery derniere(m_db); - derniere.prepare(QStringLiteral("SELECT MAX(firstIssuedAt) FROM forecastPv")); - if (derniere.exec() && derniere.next() && !derniere.value(0).isNull()) { - const qint64 refRun = derniere.value(0).toLongLong(); - QSqlQuery ref(m_db); - ref.prepare(QStringLiteral( - "SELECT targetAt, forecastWh FROM forecastPv WHERE firstIssuedAt = ?")); - ref.addBindValue(refRun); - QHash precedent; - if (ref.exec()) - while (ref.next()) - precedent.insert(ref.value(0).toLongLong(), ref.value(1).toDouble()); - - int communs = 0; - bool identique = true; - for (const ForecastPoint &p : revision.points) { - const auto it = precedent.constFind(p.targetAt.toUTC().toSecsSinceEpoch()); - if (it == precedent.constEnd()) - continue; // hors intersection : ne départage rien - ++communs; - if (qAbs(it.value() - p.wh) > EgaliteWh) { identique = false; break; } - } - // `communs > 0` est REQUIS : sans créneau commun, il n'y a rien à comparer, et conclure - // « identique » ferait disparaître une prévision dont on ne sait rien. - if (identique && communs > 0) { - // LE RUN RECONDUIT — on ne crée pas de ligne, on met à jour la FRAÎCHEUR. Stocker la - // confirmation elle-même serait stocker pour un consommateur qui n'existe pas : - // celui d'aujourd'hui est un affichage, et un affichage ne lit pas une confirmation. - QSqlQuery maj(m_db); - maj.prepare(QStringLiteral( - "UPDATE forecastPv SET lastConfirmedAt = ? " - "WHERE firstIssuedAt = ? AND lastConfirmedAt < ?")); - maj.addBindValue(run); - maj.addBindValue(refRun); - maj.addBindValue(run); - if (!maj.exec()) - qCWarning(dcNymeaEnergy()) << "[Prévision] reconduction refusée :" - << maj.lastError().text(); - else if (maj.numRowsAffected() > 0) - qCInfo(dcNymeaEnergy()).noquote() - << QStringLiteral("[Prévision] run %1 RECONDUIT la prévision du %2 — " - "%3 créneau(x) inchangé(s), aucune révision") - .arg(revision.issuedAt.toUTC().toString(Qt::ISODate)) - .arg(QDateTime::fromSecsSinceEpoch(refRun, QTimeZone::utc()) - .toString(Qt::ISODate)) - .arg(communs); - return false; - } - } - - // --- 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. + // `issuedAt` invalide N'EST PLUS un motif de refus : il est devenu un ATTRIBUT. Jeter une + // prévision utilisable parce qu'une métadonnée manque reviendrait à jeter la donnée pour son + // étiquette — et le repli `best_match`, qui la met à zéro, produit des courbes parfaitement + // exploitables. + if (revision.points.isEmpty()) { + qCDebug(dcNymeaEnergy()) << "[Prévision] révision vide — ignorée"; return false; } - // --- écriture d'une VRAIE révision, en une transaction ---------------------------------- + // --- L'EMPREINTE DU CONTENU, et c'est la clé -------------------------------------------- + // + // Calculée sur la courbe elle-même — instants et valeurs — donc indépendante de tout ce que + // le producteur raconte sur son origine. Deux charges utiles différentes portant la même + // courbe donnent la même empreinte, ce qu'aucune comparaison de charge utile ne peut voir. + QCryptographicHash h(QCryptographicHash::Sha1); + for (const ForecastPoint &p : revision.points) + h.addData(QStringLiteral("%1:%2;") + .arg(p.targetAt.toUTC().toSecsSinceEpoch()) + .arg(p.wh, 0, 'f', 3).toUtf8()); + const QString empreinte = QString::fromLatin1(h.result().toHex()); + const qint64 vu = QDateTime::currentMSecsSinceEpoch(); // ms : cf. le schéma + + // --- MÊME CONTENU : on ne crée rien, on rafraîchit --------------------------------------- + QSqlQuery deja(m_db); + deja.prepare(QStringLiteral( + "SELECT COUNT(*) FROM forecastPv WHERE contentId = ? AND siteId = ?")); + deja.addBindValue(empreinte); + deja.addBindValue(revision.siteId); + if (deja.exec() && deja.next() && deja.value(0).toInt() > 0) { + QSqlQuery maj(m_db); + maj.prepare(QStringLiteral( + "UPDATE forecastPv SET lastSeenAt = ? " + "WHERE contentId = ? AND siteId = ? AND lastSeenAt < ?")); + maj.addBindValue(vu); + maj.addBindValue(empreinte); + maj.addBindValue(revision.siteId); + maj.addBindValue(vu); + if (!maj.exec()) + qCWarning(dcNymeaEnergy()) << "[Prévision] rafraîchissement refusé :" + << maj.lastError().text(); + qCDebug(dcNymeaEnergy()) << "[Prévision] courbe inchangée — fraîcheur mise à jour"; + return false; + } + + // --- CONTENU NEUF : une révision, quoi qu'ait fait `issuedAt` ---------------------------- + // + // Y COMPRIS SOUS UN `issuedAt` INCHANGÉ. Le serveur recompose les premières heures sans + // toucher au run : refuser ces écritures GÈLERAIT la courbe sur les valeurs du premier + // cycle, et l'écran montrerait une prévision périmée sans le dire — un instrument qui + // continue d'indiquer une ancienne mesure, ce qui est pire que de ne rien montrer. if (!m_db.transaction()) qCWarning(dcNymeaEnergy()) << "[Prévision] transaction refusée :" << m_db.lastError().text(); QSqlQuery ins(m_db); ins.prepare(QStringLiteral( "INSERT OR REPLACE INTO forecastPv " - "(targetAt, firstIssuedAt, lastConfirmedAt, forecastWh, model) VALUES (?, ?, ?, ?, ?)")); + "(targetAt, siteId, contentId, firstSeenAt, lastSeenAt, forecastWh, issuedAt, model) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)")); + const bool ageConnu = revision.issuedAt.isValid(); for (const ForecastPoint &p : revision.points) { ins.addBindValue(p.targetAt.toUTC().toSecsSinceEpoch()); - ins.addBindValue(run); - // À L'OUVERTURE, LES DEUX INSTANTS COÏNCIDENT — et ce n'est pas une copie de commodité. - // `lastConfirmedAt` répond à « quel est le dernier run qui a AFFIRMÉ ces valeurs ». Le - // run émetteur les a affirmées : la réponse est vraie, et elle l'est uniformément. - // Le laisser NUL se lirait « on ne sait pas jusqu'à quand elle a tenu », alors qu'on le - // sait — et forcerait chaque client à écrire le repli, ailleurs, avec une chance de le - // faire de travers. - ins.addBindValue(run); + ins.addBindValue(revision.siteId); + ins.addBindValue(empreinte); + ins.addBindValue(vu); + ins.addBindValue(vu); // à l'apparition, les deux instants coïncident ins.addBindValue(p.wh); - // NULL, jamais une chaîne vide : une chaîne vide serait déjà une valeur. Le producteur - // publie le modèle qui A RÉPONDU, repli compris — donc une provenance, pas une intention. + // NUL plutôt qu'un zéro : « on ne connaît pas l'âge » n'est pas « âge zéro ». + ins.addBindValue(ageConnu ? QVariant(revision.issuedAt.toUTC().toSecsSinceEpoch()) + : QVariant(QMetaType(QMetaType::LongLong))); ins.addBindValue(revision.model.isEmpty() ? QVariant(QMetaType(QMetaType::QString)) : QVariant(revision.model)); if (!ins.exec()) { @@ -302,10 +277,12 @@ bool ForecastStore::enregistrer(const ForecastRevision &revision) return false; } qCInfo(dcNymeaEnergy()).noquote() - << QStringLiteral("[Prévision] RÉVISION du run %1 — %2 créneau(x), modèle %3") - .arg(revision.issuedAt.toUTC().toString(Qt::ISODate)) + << QStringLiteral("[Prévision] RÉVISION %1 — %2 créneau(x), modèle %3, âge %4") + .arg(empreinte.left(8)) .arg(revision.points.count()) - .arg(revision.model.isEmpty() ? QStringLiteral("(inconnu)") : revision.model); + .arg(revision.model.isEmpty() ? QStringLiteral("(inconnu)") : revision.model) + .arg(ageConnu ? revision.issuedAt.toUTC().toString(Qt::ISODate) + : QStringLiteral("(inconnu)")); return true; } @@ -322,12 +299,12 @@ QList ForecastStore::courbe(const QDateTime &de, const QDateTime // deux lectures du même passé donnent le même résultat. QSqlQuery q(m_db); q.prepare(QStringLiteral( - "SELECT targetAt, forecastWh, firstIssuedAt, lastConfirmedAt FROM forecastPv f " - "WHERE targetAt BETWEEN ? AND ? AND firstIssuedAt <= ? " - " AND firstIssuedAt = (SELECT MAX(firstIssuedAt) FROM forecastPv g " - " WHERE g.targetAt = f.targetAt AND g.firstIssuedAt <= ?) " + "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 <= ?) " "ORDER BY targetAt")); - const qint64 limite = borne.toUTC().toSecsSinceEpoch(); + const qint64 limite = borne.toUTC().toMSecsSinceEpoch(); // firstSeenAt est en ms q.addBindValue(de.toUTC().toSecsSinceEpoch()); q.addBindValue(a.toUTC().toSecsSinceEpoch()); q.addBindValue(limite); @@ -343,11 +320,11 @@ QList ForecastStore::courbe(const QDateTime &de, const QDateTime ForecastPoint p; p.targetAt = QDateTime::fromSecsSinceEpoch(q.value(0).toLongLong(), QTimeZone::utc()); p.wh = q.value(1).toDouble(); - // LES DEUX INSTANTS, publiés ensemble. La FRAÎCHEUR est lastConfirmedAt ; l'ancienneté - // de l'avis est firstIssuedAt. N'en rendre qu'un ferait lire « prévu à 06Z » sur une + // LES DEUX INSTANTS, publiés ensemble. La FRAÎCHEUR est lastSeenAt ; l'ancienneté + // de l'avis est firstSeenAt. N'en rendre qu'un ferait lire « prévu à 06Z » sur une // ligne que le run 12Z vient de reconduire. - p.firstIssuedAt = QDateTime::fromSecsSinceEpoch(q.value(2).toLongLong(), QTimeZone::utc()); - p.lastConfirmedAt = QDateTime::fromSecsSinceEpoch(q.value(3).toLongLong(), QTimeZone::utc()); + p.firstSeenAt = QDateTime::fromMSecsSinceEpoch(q.value(2).toLongLong(), QTimeZone::utc()); + p.lastSeenAt = QDateTime::fromMSecsSinceEpoch(q.value(3).toLongLong(), QTimeZone::utc()); points.append(p); } return points; @@ -360,12 +337,12 @@ QList> ForecastStore::revisions(const QDateTime &target return sortie; QSqlQuery q(m_db); q.prepare(QStringLiteral( - "SELECT firstIssuedAt, forecastWh FROM forecastPv WHERE targetAt = ? ORDER BY firstIssuedAt")); + "SELECT firstSeenAt, forecastWh FROM forecastPv WHERE targetAt = ? ORDER BY firstSeenAt")); q.addBindValue(targetAt.toUTC().toSecsSinceEpoch()); if (!q.exec()) return sortie; while (q.next()) - sortie.append({ QDateTime::fromSecsSinceEpoch(q.value(0).toLongLong(), QTimeZone::utc()), + sortie.append({ QDateTime::fromMSecsSinceEpoch(q.value(0).toLongLong(), QTimeZone::utc()), q.value(1).toDouble() }); return sortie; } @@ -385,10 +362,10 @@ int ForecastStore::purger(const QDateTime &maintenant) // qu'une détruirait la révision, c'est-à-dire l'objet de cette table. QSqlQuery q(m_db); q.prepare(QStringLiteral( - "DELETE FROM forecastPv WHERE targetAt < ? AND firstIssuedAt NOT IN (" - " SELECT MIN(firstIssuedAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt" + "DELETE FROM forecastPv WHERE targetAt < ? AND firstSeenAt NOT IN (" + " SELECT MIN(firstSeenAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt" " UNION" - " SELECT MAX(firstIssuedAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt)")); + " SELECT MAX(firstSeenAt) FROM forecastPv g WHERE g.targetAt = forecastPv.targetAt)")); q.addBindValue(limite); if (!q.exec()) { qCWarning(dcNymeaEnergy()) << "[Prévision] purge refusée :" << q.lastError().text(); diff --git a/energyplugin/etm/forecast/forecaststore.h b/energyplugin/etm/forecast/forecaststore.h index f0f947d..80da08e 100644 --- a/energyplugin/etm/forecast/forecaststore.h +++ b/energyplugin/etm/forecast/forecaststore.h @@ -44,20 +44,22 @@ struct ForecastPoint { //! Énergie prévue sur l'heure (Wh). Une ABSENCE ne s'écrit pas : elle ne crée pas de point. double wh = 0; /*! - * \brief Run qui a produit ces valeurs EN PREMIER — **l'identité de la prévision**. + * \brief Instant où ce CONTENU est apparu pour la première fois — l'ordre des révisions. * - * Renseigné à la lecture ; ignoré à l'écriture. C'est LUI qui date la prévision, et lui seul. + * Renseigné à la lecture ; ignoré à l'écriture. C'est un instant d'OBSERVATION, pas + * d'émission : depuis qu'on sait que \c issuedAt n'identifie pas le contenu, la seule chose + * qui ordonne les révisions est le moment où on les a vues. */ - QDateTime firstIssuedAt; + QDateTime firstSeenAt; /*! - * \brief Dernier run qui a AFFIRMÉ ces valeurs — la **fraîcheur**. + * \brief Dernière fois que ce contenu a été republié — la **fraîcheur**. * - * \warning **JAMAIS une clé de révision.** \c firstIssuedAt identifie la prévision ; + * \warning **JAMAIS une clé de révision.** \c firstSeenAt ordonne les révisions ; * celui-ci dit seulement **jusqu'à quand elle a tenu**. Dater une prévision avec lui la * ferait paraître née au moment où elle a été reconduite — et deux prévisions différentes * reconduites par le même run porteraient la même date. * - * \par Sur une ligne JAMAIS RECONDUITE, il vaut \c firstIssuedAt — jamais absent + * \par Sur une ligne JAMAIS REPUBLIÉE, il vaut \c firstSeenAt — jamais absent * Et ce n'est pas une copie déguisée en confirmation : le champ ne dit pas « quand * quelqu'un a confirmé », il dit **« quel est le dernier run qui a affirmé ces valeurs »**. * Le run émetteur les a affirmées. La réponse est donc vraie, et elle l'est uniformément. @@ -67,9 +69,9 @@ struct ForecastPoint { * client à écrire le repli `confirmedAt ?? issuedAt`, c'est-à-dire à refaire ce calcul-ci, * ailleurs, une fois par client, avec une chance de le faire de travers. * - * \invariant Toujours valide, et toujours `>= firstIssuedAt`. + * \invariant Toujours valide, et toujours `>= firstSeenAt`. */ - QDateTime lastConfirmedAt; + QDateTime lastSeenAt; }; /*! @@ -77,20 +79,40 @@ struct ForecastPoint { */ struct ForecastRevision { /*! - * \brief Instant d'INITIALISATION DU RUN, UTC — le vrai, pas l'heure de réception. + * \brief Âge APPROCHÉ de la prévision — un **attribut**, jamais une clé. Peut être invalide. * - * Il **voyage dans la charge utile** que publie le producteur (`issuedAt`), donc sans second - * appel à l'API. C'est ce qui rend la clé de révision **exacte**, et non approchée. + * \warning **`issuedAt` N'IDENTIFIE PAS LE CONTENU**, et ce n'est pas un défaut du + * producteur : c'est ce que l'API permet. Mesuré le 2026-09-09 — + * - il vient de `meteofrance_arome_france_hd`, **le seul domaine AROME exposant un + * `meta.json`**, qui ne fournit **aucune** des valeurs de rayonnement de la courbe ; + * - le domaine qui les fournit (`meteofrance_arome_france`) n'expose **pas** de + * `meta.json` (HTTP 500) : il n'existe **aucune** identité de run pour la donnée qui + * forme la courbe ; + * - le serveur recompose les premières heures autrement que par le seul run, si bien + * qu'elles bougent **sans** que `issuedAt` bouge ; + * - `t0` est ancré sur le jour local de la REQUÊTE, pas sur le run ; + * - sous le repli `best_match`, il vaut **0** ; + * - deux plans d'un même site publient deux courbes **différentes** sous le **même** + * `issuedAt`. * - * \par Ce que cela change - * Une première version indexait sur l'instant de RÉCEPTION, faute de mieux, et interdisait - * d'en calculer un horizon : la latence de publication d'un modèle se compte en heures. - * **Cette réserve est levée** — l'heure du run est un horizon calculable. + * \note Il reste utile, et il est conservé : il répond correctement à « qu'est-ce qu'on + * prévoyait ce matin ». Il ne répond pas à « est-ce la même prévision ». * - * \note Ce champ porte l'instant du run QUI ARRIVE. Le magasin décide ensuite s'il ouvre - * une révision (\c firstIssuedAt) ou s'il en reconduit une (\c lastConfirmedAt). + * \note **Invalide est une valeur acceptable.** Une charge utile sans âge est enregistrée + * quand même : refuser une prévision utilisable parce qu'un ATTRIBUT manque reviendrait à + * jeter la donnée pour la métadonnée. */ QDateTime issuedAt; + /*! + * \brief Le SITE dont cette courbe est la somme — partie de la clé. + * + * Recommandation du producteur : la clé porte l'identifiant du Thing, parce que deux plans + * publient des courbes DIFFÉRENTES sous le même \c issuedAt (tilt, azimut, kWc et PR entrent + * dans les valeurs). Ici la granularité est le SITE, puisqu'on somme ses plans — mais le + * besoin est le même : sans lui, changer de site désigné mêlerait deux historiques dans une + * table qui n'a aucun moyen de les distinguer. + */ + QString siteId; //! Les points de la courbe. Une révision **vide n'est pas enregistrée** : voir \c enregistrer(). QList points; /*! @@ -164,30 +186,29 @@ public: * vide, ou en cas d'échec. * * \par UNE RÉVISION N'EXISTE QUE SI LA PRÉVISION A CHANGÉ - * La déduplication porte sur **les valeurs**, comparées sur les créneaux communs. Un run - * plus récent qui reconduit les mêmes chiffres **ne crée pas de ligne** : il met à jour - * \c lastConfirmedAt sur les lignes existantes. + * L'identité d'une révision est **le contenu lui-même** — une empreinte de la courbe + * entière. Une republication à l'identique **ne crée pas de ligne** : elle met à jour + * \c lastSeenAt. Un contenu neuf ouvre une révision, que \c issuedAt ait bougé ou non. + * + * C'est **plus fidèle** à la règle que la clé de run ne l'était : la règle a toujours dit + * « si la PRÉVISION a changé », et c'est la prévision qu'on regarde désormais. * * On ne stocke pas une confirmation pour un consommateur qui n'existe pas : le * consommateur d'aujourd'hui est un **affichage**, et un affichage ne lit pas une * confirmation. C'est le raisonnement qui a déjà écarté la requête `meta.json`, et il vaut * ici pour la même raison. * - * \par DEUX INSTANTS, DEUX NOMS — et c'est la leçon d'hier appliquée - * Un seul champ ne peut pas porter les deux faits sans mentir sur l'un : - * - \c firstIssuedAt : le run qui a produit ces valeurs **en premier**. C'est la CLÉ ; elle - * ne bouge jamais, sans quoi l'identité de la ligne changerait sous elle. - * - \c lastConfirmedAt : le dernier run qui les a **reconduites**. Mis à jour en place. + * \par DEUX INSTANTS, DEUX NOMS + * - \c firstSeenAt : quand ce contenu est apparu. Il ORDONNE les révisions. + * - \c lastSeenAt : la dernière republication à l'identique. Mis à jour en place. * - * Les garder confondus reproduirait exactement le piège `receivedAt`/`issuedAt` : deux - * instants voisins, un nom qui promet le mauvais. Quelqu'un lirait « prévu à 06Z » sur une - * ligne que le run 12Z vient de reconduire, et croirait la prévision vieille de six heures - * alors qu'elle est fraîche. **La fraîcheur est \c lastConfirmedAt ; l'ancienneté de - * l'avis est \c firstIssuedAt.** + * Les garder confondus reproduirait le piège déjà payé deux fois : deux instants voisins, + * un nom qui promet le mauvais. **La FRAÎCHEUR est \c lastSeenAt ; l'APPARITION du + * contenu est \c firstSeenAt.** * * \note **Cette déduplication est le DERNIER REMPART, pas un filet.** Celle du producteur - * compare la charge utile entière, où \c issuedAt figure : elle ne mord donc jamais sur ce - * cas. Si celle-ci se trompe, personne ne rattrape. + * compare la charge utile entière : elle ne peut pas voir que deux charges utiles + * différentes portent la même courbe. Si celle-ci se trompe, personne ne rattrape. * * \note Ce que l'on ne conserve PAS : la LISTE des runs ayant confirmé. Le jour où un * apprentissage corrélera l'erreur à l'horizon, il en aura peut-être besoin — la décision diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index c81805b..ba3408f 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -312,28 +312,14 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana "NEVER a measurement: the conversion ignores shading, soiling, module " "temperature and inverter clipping - all optimistic - so the curve is a " "CEILING, systematically above what will be produced. Hours with no forecast " - "are ABSENT from the list, never zero: a zero would read as 'we expect night'. " - "'knownAt' makes the read reproducible - it returns the curve as it was known " - "at that instant, which is how 'what did we forecast this morning' is asked. " - "A revision exists only when the forecast CHANGED: a later run carrying the " - "same numbers does not create one, it refreshes 'lastConfirmedAt'. TWO " - "INSTANTS, TWO NAMES - 'firstIssuedAt' is the run that first produced these " - "values (age of the OPINION, and the row's identity); 'lastConfirmedAt' is the " - "last run that reconducted them (FRESHNESS). Do not conflate them: a value " - "first issued at 06Z and reconducted at 12Z is fresh, not six hours old."; - 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. `firstIssuedAt` est le run qui a produit ces valeurs EN PREMIER - // — l'ancienneté de l'AVIS. `lastConfirmedAt` est le dernier run qui les a reconduites — la - // FRAÎCHEUR. Les confondre ferait lire « prévu à 06Z » sur une prévision que le run de 12Z - // vient de reconduire, et croire vieille de six heures une valeur qui est fraîche. - pointItem.insert("firstIssuedAt", enumValueName(String)); // ISO 8601 UTC - pointItem.insert("lastConfirmedAt", enumValueName(String)); // ISO 8601 UTC - returns.insert("forecastPoints", QVariantList() << pointItem); + "are ABSENT, never zero: a zero would read as 'we expect night'. 'knownAt' " + "makes the read reproducible - it returns the curve as it was known at that " + "instant. A revision exists only when the FORECAST CHANGED; the provider's " + "run id does NOT identify the content (it comes from a domain that supplies " + "none of the curve's values), so revisions are keyed on the content itself. " + "'firstSeenAt' is when this content first appeared - it ORDERS revisions; " + "'lastSeenAt' is the last time it was republished unchanged - FRESHNESS. " + "Neither is an emission time, and no horizon may be computed from them."; registerMethod("GetPvForecast", description, params, returns, Types::PermissionScopeControlThings); // Désignation GLOBALE, sur le modèle d'Energy.SetRootMeter — même nature : une source unique @@ -1118,8 +1104,8 @@ JsonReply *NymeaEnergyJsonHandler::GetPvForecast(const QVariantMap ¶ms) points.append(QVariantMap{ {"targetAt", p.targetAt.toUTC().toString(Qt::ISODate)}, {"forecastWh", p.wh}, - {"firstIssuedAt", p.firstIssuedAt.toUTC().toString(Qt::ISODate)}, - {"lastConfirmedAt", p.lastConfirmedAt.toUTC().toString(Qt::ISODate)}}); + {"firstSeenAt", p.firstSeenAt.toUTC().toString(Qt::ISODate)}, + {"lastSeenAt", p.lastSeenAt.toUTC().toString(Qt::ISODate)}}); } return createReply({{"forecastPoints", points}}); #else diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index f84f7cd..b247567 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -927,14 +927,14 @@ void TestLoadModel::testDrawMarginComparesHeadroomsNotCaps() } namespace { -//! Fabrique une révision : heures consécutives à partir de \a depuis, une valeur par heure. -ForecastRevision revision(const QDateTime &recue, const QDateTime &depuis, - const QList &wh) +//! Fabrique une révision : créneaux consécutifs à partir de \a depuis, une valeur par créneau. +ForecastRevision revision(const QDateTime &age, const QDateTime &depuis, const QList &wh, + const QString &site = QStringLiteral("site-A")) { ForecastRevision r; - r.issuedAt = recue; + r.issuedAt = age; // ATTRIBUT, jamais la clé + r.siteId = site; for (int i = 0; i < wh.count(); ++i) { - // Les deux instants de LECTURE ne se renseignent pas à l'écriture : le magasin les pose. ForecastPoint p; p.targetAt = depuis.addSecs(i * 3600); p.wh = wh.at(i); @@ -944,58 +944,57 @@ ForecastRevision revision(const QDateTime &recue, const QDateTime &depuis, } } -void TestLoadModel::testARevisionExistsOnlyIfTheForecastChanged() +void TestLoadModel::testTheContentIsTheRevisionIdentity() { + // \rule{LM-1500} n'a pas bougé : « une révision n'existe que si la PRÉVISION a changé ». + // C'est la CLÉ qui a changé, parce que la prémisse a été mesurée fausse le 2026-09-09 : + // `issuedAt` vient d'un domaine qui ne fournit AUCUNE valeur de la courbe, et le serveur + // recompose les premières heures sans le faire bouger. Regarder le contenu est donc PLUS + // fidèle à la règle que regarder l'étiquette d'émission. QTemporaryDir dir; ForecastStore magasin(dir.filePath("forecast.sqlite")); QVERIFY(magasin.isValid()); - const QDateTime run06 = QDateTime(QDate(2026, 9, 9), QTime(6, 0), QTimeZone::utc()); - const QDateTime run12 = QDateTime(QDate(2026, 9, 9), QTime(12, 0), QTimeZone::utc()); - const QDateTime run18 = QDateTime(QDate(2026, 9, 9), QTime(18, 0), QTimeZone::utc()); + const QDateTime run00 = QDateTime(QDate(2026, 9, 9), QTime(0, 0), QTimeZone::utc()); const QDateTime t0 = QDateTime(QDate(2026, 9, 9), QTime(10, 0), QTimeZone::utc()); - // 1. Premier run : une révision s'ouvre. - QVERIFY(magasin.enregistrer(revision(run06, t0, {100, 200, 300, 400}))); + QVERIFY(magasin.enregistrer(revision(run00, t0, {100, 200, 300}))); QCOMPARE(magasin.revisions(t0).count(), 1); - // 2. LE RUN DE 12Z RECONDUIT LES MÊMES CHIFFRES — ce n'est PAS une révision. On ne stocke - // pas une confirmation pour un consommateur qui n'existe pas : celui d'aujourd'hui est un - // affichage, et un affichage ne lit pas une confirmation. Même raisonnement que celui qui - // a écarté la requête meta.json. - QVERIFY2(!magasin.enregistrer(revision(run12, t0, {100, 200, 300, 400})), - "un run qui reconduit les mêmes valeurs n'ouvre pas de révision"); + // 1. MÊME CONTENU republié — pas une révision, quoi qu'ait fait l'âge. La comparaison porte + // sur la courbe, pas sur la charge utile : deux charges utiles différentes portant la + // même courbe sont la même prévision, ce qu'aucune égalité de charge utile ne peut voir. + QVERIFY(!magasin.enregistrer(revision(run00.addSecs(6 * 3600), t0, {100, 200, 300}))); QCOMPARE(magasin.revisions(t0).count(), 1); - // 3. MAIS LA FRAÎCHEUR A ÉTÉ MISE À JOUR, et c'est là que les DEUX instants se séparent. - // Les confondre ferait lire « prévu à 06Z » sur une ligne que le run 12Z vient de - // reconduire — la prévision paraîtrait vieille de six heures alors qu'elle est fraîche. - // C'est le piège receivedAt/issuedAt de la veille, déplacé d'un cran. - QList vue = magasin.courbe(t0, t0.addSecs(3 * 3600), run12.addSecs(60)); - QCOMPARE(vue.count(), 4); - QCOMPARE(vue.first().firstIssuedAt, run06); // ANCIENNETÉ DE L'AVIS - QCOMPARE(vue.first().lastConfirmedAt, run12); // FRAÎCHEUR - QVERIFY2(vue.first().firstIssuedAt != vue.first().lastConfirmedAt, - "les deux instants doivent pouvoir diverger — sinon l'un des deux ment"); - - // 4. UNE VALEUR QUI CHANGE OUVRE UNE RÉVISION, et les deux instants se rejoignent. - // Contre-épreuve du cas 2 : sans elle, on ne saurait pas si la déduplication distingue - // quoi que ce soit ou refuse tout. - QVERIFY(magasin.enregistrer(revision(run18, t0, {100, 999, 300, 400}))); + // 2. LE CŒUR DE LA CORRECTION — un contenu NEUF sous un `issuedAt` INCHANGÉ **EST** une + // révision. La version précédente la refusait : elle gelait la courbe sur les valeurs du + // premier cycle, et l'écran aurait montré une prévision périmée sans le dire — un + // instrument qui continue d'indiquer une ancienne mesure, pire que pas d'instrument. + QTest::qWait(5); + QVERIFY2(magasin.enregistrer(revision(run00, t0, {100, 168, 300})), + "un contenu neuf sous le MÊME issuedAt est une révision — sinon la courbe gèle"); QCOMPARE(magasin.revisions(t0).count(), 2); - vue = magasin.courbe(t0, t0.addSecs(3 * 3600), run18.addSecs(60)); - QCOMPARE(vue.first().firstIssuedAt, run18); - QCOMPARE(vue.first().lastConfirmedAt, run18); // à l'ouverture, ils coïncident + const QList vue = magasin.courbe(t0, t0.addSecs(2 * 3600)); + QCOMPARE(qRound(vue.at(1).wh), 168); // la valeur FRAÎCHE, pas celle de 06:03 - // 5. LA CLÉ NE BOUGE PAS SOUS LA LIGNE. Une reconduction met à jour la fraîcheur et rien - // d'autre : la révision de 06Z reste identifiée par 06Z, sans quoi l'identité de la ligne - // changerait et l'historique deviendrait illisible. - const QList> revs = magasin.revisions(t0); - QCOMPARE(revs.first().first, run06); - QCOMPARE(revs.last().first, run18); + // 3. UN ÂGE INCONNU N'EST PLUS UN REFUS. `best_match` publie `issuedAt = 0` : jeter une + // prévision utilisable parce qu'un ATTRIBUT manque, ce serait jeter la donnée pour son + // étiquette. + QTest::qWait(5); + QVERIFY2(magasin.enregistrer(revision(QDateTime(), t0, {111, 222, 333})), + "une courbe sans âge connu reste enregistrable"); + QCOMPARE(magasin.revisions(t0).count(), 3); - // 6. Une révision VIDE n'en est pas une : l'absence est un trou, pas une courbe de zéros. - ForecastRevision vide; vide.issuedAt = run18.addSecs(3600); + // 4. DEUX SITES NE SE MÊLENT PAS. Sans le site dans la clé, changer de site désigné + // mêlerait deux historiques que rien ne distinguerait plus — et deux plans publient + // des courbes différentes sous le même issuedAt, donc le besoin est réel. + QTest::qWait(5); + QVERIFY(magasin.enregistrer(revision(run00, t0, {100, 200, 300}, QStringLiteral("site-B")))); + QCOMPARE(magasin.revisions(t0).count(), 4); // la même courbe, mais d'un AUTRE site + + // 5. Une révision VIDE n'en est pas une : l'absence est un trou, pas une courbe de zéros. + ForecastRevision vide; vide.issuedAt = run00; vide.siteId = QStringLiteral("site-A"); QVERIFY(!magasin.enregistrer(vide)); } @@ -1004,37 +1003,36 @@ void TestLoadModel::testTheCurveIsReproducibleAndKeepsItsHoles() QTemporaryDir dir; ForecastStore magasin(dir.filePath("forecast.sqlite")); QVERIFY(magasin.isValid()); + const QDateTime t0 = QDateTime(QDate(2026, 9, 9), QTime(12, 0), QTimeZone::utc()); - const QDateTime t0 = QDateTime(QDate(2026, 9, 9), QTime(12, 0), QTimeZone::utc()); - const QDateTime r08 = QDateTime(QDate(2026, 9, 9), QTime(8, 0), QTimeZone::utc()); - const QDateTime r14 = QDateTime(QDate(2026, 9, 9), QTime(14, 0), QTimeZone::utc()); + QVERIFY(magasin.enregistrer(revision(t0, t0, {1000, 1100, 1200}))); + const QDateTime avant = QDateTime::currentDateTimeUtc(); + QTest::qWait(5); // les instants d'OBSERVATION sont en ms + QVERIFY(magasin.enregistrer(revision(t0, t0, {700, 800, 900}))); - QVERIFY(magasin.enregistrer(revision(r08, t0, {1000, 1100, 1200}))); // « à 8 h » - QVERIFY(magasin.enregistrer(revision(r14, t0, {700, 800, 900}))); // révisé « à 14 h » + // « CE QU'ON PRÉVOYAIT À TEL MOMENT » se demande en fixant l'instant de connaissance. Sans + // ce paramètre, la comparaison prévu/réalisé n'aurait pas d'horizon nommé, et deux écrans + // montreraient deux courbes pour le même jour. + const QList tot = magasin.courbe(t0, t0.addSecs(7200), avant); + QCOMPARE(tot.count(), 3); + QCOMPARE(qRound(tot.first().wh), 1000); + const QList tard = magasin.courbe(t0, t0.addSecs(7200)); + QCOMPARE(qRound(tard.first().wh), 700); - // « CE QU'ON PRÉVOYAIT À 8 H » est une question à part entière, et elle se pose en fixant - // l'instant de connaissance. Sans ce paramètre, la comparaison prévu/réalisé n'aurait pas - // d'horizon nommé, et deux écrans montreraient deux courbes pour le même jour. - const QList vue08 = magasin.courbe(t0, t0.addSecs(7200), r08.addSecs(60)); - QCOMPARE(vue08.count(), 3); - QCOMPARE(qRound(vue08.at(0).wh), 1000); - - // La même fenêtre, connue plus tard : la révision de 14 h l'emporte. - const QList vue14 = magasin.courbe(t0, t0.addSecs(7200), r14.addSecs(60)); - QCOMPARE(vue14.count(), 3); - QCOMPARE(qRound(vue14.at(0).wh), 700); - - // Les DEUX révisions coexistent pour la même heure visée — c'est tout l'objet de la table. - // Avec `targetAt` seul pour clé, la seconde aurait écrasé la première. + // Les DEUX révisions coexistent pour le même créneau — c'est l'objet de la table. QCOMPARE(magasin.revisions(t0).count(), 2); - // LES TROUS RESTENT DES TROUS. On demande six heures, trois sont connues : la courbe en rend - // trois, jamais six dont trois à zéro — un zéro se lirait « on prévoit une nuit ». - const QList large = magasin.courbe(t0, t0.addSecs(6 * 3600), r14.addSecs(60)); - QCOMPARE(large.count(), 3); + // LES TROUS RESTENT DES TROUS : six heures demandées, trois connues, trois rendues. Jamais + // six dont trois à zéro — un zéro se lirait « on prévoit une nuit ». + QCOMPARE(magasin.courbe(t0, t0.addSecs(6 * 3600)).count(), 3); + QVERIFY(magasin.courbe(t0.addDays(5), t0.addDays(5).addSecs(7200)).isEmpty()); - // Et une fenêtre entièrement inconnue rend une liste VIDE, pas une courbe de zéros. - QVERIFY(magasin.courbe(t0.addDays(5), t0.addDays(5).addSecs(7200), r14.addSecs(60)).isEmpty()); + // La FRAÎCHEUR n'est jamais absente, et jamais antérieure à l'apparition. + for (const ForecastPoint &p : tard) { + QVERIFY(p.lastSeenAt.isValid()); + QVERIFY2(p.lastSeenAt >= p.firstSeenAt, + "une prévision ne peut pas avoir été revue avant d'être apparue"); + } } void TestLoadModel::testRetentionKeepsBothEndsOfEachRevision() @@ -1043,218 +1041,79 @@ void TestLoadModel::testRetentionKeepsBothEndsOfEachRevision() ForecastStore magasin(dir.filePath("forecast.sqlite")); QVERIFY(magasin.isValid()); - // Une heure visée ancienne, quatre révisions successives. const QDateTime cible = QDateTime(QDate(2026, 9, 1), QTime(12, 0), QTimeZone::utc()); for (int i = 0; i < 4; ++i) { - ForecastRevision r; - r.issuedAt = cible.addSecs(-(4 - i) * 3600); - ForecastPoint pt; - pt.targetAt = cible; - pt.wh = 1000.0 + i; - r.points.append(pt); - QVERIFY(magasin.enregistrer(r)); + QVERIFY(magasin.enregistrer(revision(cible, cible, {1000.0 + i}))); + QTest::qWait(5); // quatre apparitions distinctes } QCOMPARE(magasin.revisions(cible).count(), 4); - // Purge prise BIEN APRÈS : la fenêtre pleine de sept jours est dépassée. - const int retires = magasin.purger(cible.addDays(30)); - QCOMPARE(retires, 2); // les deux intermédiaires - - // LES DEUX BOUTS SURVIVENT, et c'est le cœur de la règle : la première répond à « la veille - // valait-elle quelque chose ? », la dernière à « que pouvait-on savoir de mieux ? ». - // N'en garder qu'une détruirait la révision, c'est-à-dire l'objet de la table. + // Purge bien au-delà de la fenêtre pleine : seuls les DEUX BOUTS survivent — la première + // apparition et la dernière. Les deux répondent aux deux seules questions qui se posent ; + // n'en garder qu'une détruirait la révision, c'est-à-dire l'objet de la table. + QCOMPARE(magasin.purger(cible.addDays(30)), 2); const QList> restant = magasin.revisions(cible); QCOMPARE(restant.count(), 2); - QCOMPARE(qRound(restant.first().second), 1000); // horizon le plus long - QCOMPARE(qRound(restant.last().second), 1003); // meilleure estimation + QCOMPARE(qRound(restant.first().second), 1000); + QCOMPARE(qRound(restant.last().second), 1003); - // CONTRE-ÉPREUVE — une heure visée RÉCENTE garde toutes ses révisions. Sans elle, une purge - // qui viderait tout passerait pour une purge correcte. - const QDateTime recente = QDateTime::currentDateTimeUtc().addSecs(3600); + // CONTRE-ÉPREUVE — un créneau RÉCENT garde toutes ses révisions. Sans elle, une purge qui + // viderait tout passerait pour une purge correcte. + const QDateTime recent = QDateTime::currentDateTimeUtc().addSecs(3600); for (int i = 0; i < 3; ++i) { - ForecastRevision r; - r.issuedAt = QDateTime::currentDateTimeUtc().addSecs(i * 60); - ForecastPoint pt; - pt.targetAt = recente; - pt.wh = 500.0 + i; - r.points.append(pt); - QVERIFY(magasin.enregistrer(r)); + QVERIFY(magasin.enregistrer(revision(recent, recent, {500.0 + i}))); + QTest::qWait(5); } magasin.purger(); - QCOMPARE(magasin.revisions(recente).count(), 3); + QCOMPARE(magasin.revisions(recent).count(), 3); } -void TestLoadModel::testAnUnknownPayloadIsRefusedNotBestEffort() +void TestLoadModel::testAPayloadIsRefusedOnFormNeverOnAMissingAttribute() { // La charge utile traverse une frontière de PROCESSUS : aucun compilateur ne vérifie sa - // forme. Une lecture « au mieux » d'une version inconnue produirait une courbe fausse qui - // aurait l'air de marcher, et le symptôme d'un changement de forme serait « courbe vide » — - // indistinguable d'une nuit. - const qint64 t0 = QDateTime(QDate(2026, 9, 9), QTime(0, 0), QTimeZone::utc()).toSecsSinceEpoch(); - const qint64 run = QDateTime(QDate(2026, 9, 9), QTime(6, 0), QTimeZone::utc()).toSecsSinceEpoch(); - auto charge = [&](int v, const QString &unite, const QVariantList &p) { + // forme. Mais la FORME et les ATTRIBUTS ne se refusent pas de la même façon — refuser sur un + // attribut manquant jetterait la donnée pour son étiquette. + const qint64 t0 = QDateTime(QDate(2026, 9, 9), QTime(0, 0), QTimeZone::utc()).toSecsSinceEpoch(); + auto charge = [&](int v, const QString &unite, qint64 age, const QVariantList &p) { return QVariantMap{{"v", v}, {"t0", t0}, {"step", 3600}, {"unit", unite}, - {"convention", "mean over [t, t+step)"}, {"regime", "forecast"}, - {"corrections", "none"}, {"model", "meteofrance_seamless"}, - {"issuedAt", run}, {"p", p}}; + {"convention", "mean over [t, t+step)"}, + {"model", "meteofrance_seamless"}, {"issuedAt", age}, {"p", p}}; }; QString refus; - // 1. NOMINAL — et la conversion suit la convention LUE, pas supposée : p[i] est une moyenne - // en W sur le créneau, donc l'énergie vaut w x step / 3600. + // 1. NOMINAL — la conversion suit la convention LUE : p[i] est une moyenne en W sur le + // créneau, donc l'énergie vaut w x step / 3600, sans supposer que le pas vaut une heure. const ForecastRevision ok = ForecastStore::depuisTransport( - charge(1, "W", QVariantList{0, 250, 1200}), refus); + charge(1, "W", t0, QVariantList{0, 250, 1200}), refus); QVERIFY2(refus.isEmpty(), qUtf8Printable(refus)); QCOMPARE(ok.points.count(), 3); - QCOMPARE(qRound(ok.points.at(1).wh), 250); // 250 W sur 3600 s - QCOMPARE(ok.points.at(1).targetAt.toSecsSinceEpoch(), t0 + 3600); // indexation rétrograde - QCOMPARE(ok.issuedAt.toSecsSinceEpoch(), run); - QCOMPARE(ok.model, QString("meteofrance_seamless")); + QCOMPARE(qRound(ok.points.at(1).wh), 250); + QCOMPARE(ok.points.at(1).targetAt.toSecsSinceEpoch(), t0 + 3600); - // 2. VERSION INCONNUE — refusée, et le motif la NOMME. Pas de courbe partielle. - QVERIFY(ForecastStore::depuisTransport(charge(2, "W", QVariantList{0, 250}), refus) + // 2. LA FORME se refuse, et le motif la NOMME. + QVERIFY(ForecastStore::depuisTransport(charge(2, "W", t0, QVariantList{0, 250}), refus) .points.isEmpty()); QCOMPARE(refus, QString("VERSION_2")); - - // 3. UNITÉ INATTENDUE — refusée aussi. Lire des Wh comme des W donnerait une courbe - // plausible et fausse d'un facteur qui dépend du pas : exactement le genre de nombre que - // personne ne rattrape. - QVERIFY(ForecastStore::depuisTransport(charge(1, "Wh", QVariantList{0, 250}), refus) + QVERIFY(ForecastStore::depuisTransport(charge(1, "Wh", t0, QVariantList{0, 250}), refus) .points.isEmpty()); - QCOMPARE(refus, QString("UNIT_Wh")); - - // 4. CHARGE UTILE VIDE ou tronquée — refus nommé, jamais une courbe de zéros. + QCOMPARE(refus, QString("UNIT_Wh")); // lire des Wh comme des W : plausible et faux QVERIFY(ForecastStore::depuisTransport(QVariantMap(), refus).points.isEmpty()); QCOMPARE(refus, QString("EMPTY")); - QVERIFY(ForecastStore::depuisTransport(charge(1, "W", QVariantList()), refus).points.isEmpty()); + QVERIFY(ForecastStore::depuisTransport(charge(1, "W", t0, QVariantList()), refus).points.isEmpty()); QCOMPARE(refus, QString("MALFORMED")); - // 5. UN CRÉNEAU ILLISIBLE EST UN TROU, pas un zéro : la courbe en porte deux au lieu de - // trois, et l'absence reste l'information. + // 3. UN ATTRIBUT MANQUANT NE SE REFUSE PAS. `best_match` publie `issuedAt = 0` : la courbe + // est parfaitement exploitable, seul son ÂGE est inconnu — et un âge inconnu se dit par + // une absence, jamais par un refus de la donnée qu'il accompagne. + const ForecastRevision sansAge = ForecastStore::depuisTransport( + charge(1, "W", 0, QVariantList{10, 20, 30}), refus); + QVERIFY2(refus.isEmpty(), "un âge inconnu ne refuse pas la courbe"); + QCOMPARE(sansAge.points.count(), 3); + QVERIFY2(!sansAge.issuedAt.isValid(), "et aucun instant n'est INVENTÉ à sa place"); + + // 4. UN CRÉNEAU ILLISIBLE EST UN TROU, pas un zéro. const ForecastRevision troue = ForecastStore::depuisTransport( - charge(1, "W", QVariantList{100, QString("bruit"), 300}), refus); + charge(1, "W", t0, QVariantList{100, QString("bruit"), 300}), refus); QVERIFY(refus.isEmpty()); QCOMPARE(troue.points.count(), 2); } - -void TestLoadModel::testFreshnessNeverDatesTheForecast() -{ - QTemporaryDir dir; - ForecastStore magasin(dir.filePath("forecast.sqlite")); - QVERIFY(magasin.isValid()); - - const QDateTime run06 = QDateTime(QDate(2026, 9, 9), QTime(6, 0), QTimeZone::utc()); - const QDateTime run10 = QDateTime(QDate(2026, 9, 9), QTime(10, 0), QTimeZone::utc()); - const QDateTime t0 = QDateTime(QDate(2026, 9, 9), QTime(14, 0), QTimeZone::utc()); - - QVERIFY(magasin.enregistrer(revision(run06, t0, {800, 900}))); - - // 1. SUR UNE LIGNE JAMAIS RECONDUITE, la fraîcheur vaut l'émission — jamais absente. - // Ce n'est pas une copie déguisée en confirmation : le champ répond à « quel est le - // dernier run qui a AFFIRMÉ ces valeurs », et le run émetteur les a affirmées. - // L'absence, elle, se lirait « on ne sait pas jusqu'à quand elle a tenu » — c'est faux, - // et chaque client devrait écrire le repli à sa façon. - QList vue = magasin.courbe(t0, t0.addSecs(3600), run06.addSecs(60)); - QCOMPARE(vue.count(), 2); - QVERIFY2(vue.first().lastConfirmedAt.isValid(), "la fraîcheur n'est JAMAIS absente"); - QCOMPARE(vue.first().lastConfirmedAt, run06); - QCOMPARE(vue.first().firstIssuedAt, run06); - - // 2. Après reconduction, elle avance — et l'identité, NON. - QVERIFY(!magasin.enregistrer(revision(run10, t0, {800, 900}))); - vue = magasin.courbe(t0, t0.addSecs(3600), run10.addSecs(60)); - QCOMPARE(vue.first().lastConfirmedAt, run10); // fraîcheur : avancée - QCOMPARE(vue.first().firstIssuedAt, run06); // identité : IMMUABLE - - // 3. L'INVARIANT qui rend le couple lisible : la fraîcheur ne précède jamais l'émission. - for (const ForecastPoint &p : vue) - QVERIFY2(p.lastConfirmedAt >= p.firstIssuedAt, - "une prévision ne peut pas avoir tenu AVANT d'avoir été émise"); - - // 4. LA FRAÎCHEUR NE DATE PAS LA PRÉVISION — c'est la garde que la règle demande. `revisions()` - // rend les instants d'ÉMISSION : deux prévisions différentes reconduites par le même run - // porteraient sinon la même date, et l'historique deviendrait illisible. - const QList> revs = magasin.revisions(t0); - QCOMPARE(revs.count(), 1); - QCOMPARE(revs.first().first, run06); // l'émission, pas la reconduction -} - -void TestLoadModel::testAnUnknownRunKeyIsRefusedNotGuessed() -{ - // Avis du producteur (2026-09-09) : `issuedAt == 0` signifie CLÉ INCONNUE — notamment quand - // le repli `best_match` est actif, car cette stratégie n'a pas d'identité de run unique. - // - // Le refus doit être NOMMÉ, et distinct de « charge utile mal formée » : la cause diffère, - // et la conduite à tenir aussi. Surtout, l'heure de réception ne doit JAMAIS s'y substituer — - // elle serait plausible et fausse, et une fois écrite, plus rien dans la donnée ne - // distinguerait la ligne devinée de la ligne datée. - const qint64 t0 = QDateTime(QDate(2026, 9, 9), QTime(0, 0), QTimeZone::utc()).toSecsSinceEpoch(); - auto charge = [&](qint64 run) { - return QVariantMap{{"v", 1}, {"t0", t0}, {"step", 3600}, {"unit", "W"}, - {"convention", "mean over [t, t+step)"}, - {"model", "best_match"}, {"issuedAt", run}, - {"p", QVariantList{0, 100, 200}}}; - }; - QString refus; - - // 1. CLÉ INCONNUE — refus nommé, et AUCUN point : rien ne sera enregistré. - const ForecastRevision sansCle = ForecastStore::depuisTransport(charge(0), refus); - QCOMPARE(refus, QString("ISSUED_AT_UNKNOWN")); - QVERIFY(sansCle.points.isEmpty()); - QVERIFY2(!sansCle.issuedAt.isValid(), - "aucune heure ne doit être substituée à une clé inconnue"); - - // 2. Le motif est DISTINCT de MALFORMED : deux causes, deux conduites. Les confondre ferait - // chercher un défaut de forme là où la stratégie du producteur n'a simplement pas de run. - QVERIFY(refus != QLatin1String("MALFORMED")); - - // 3. CONTRE-ÉPREUVE — la même charge utile avec une clé valide passe. Sans elle, on ne - // saurait pas si le refus vise la clé ou tout le reste. - const ForecastRevision avecCle = ForecastStore::depuisTransport(charge(t0 + 6 * 3600), refus); - QVERIFY(refus.isEmpty()); - QCOMPARE(avecCle.points.count(), 3); - - // 4. ET LE MAGASIN LA REFUSE AUSSI, bout en bout : une révision sans clé ne s'écrit pas. - QTemporaryDir dir; - ForecastStore magasin(dir.filePath("forecast.sqlite")); - 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 dfc377a..3fad2b8 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -86,21 +86,15 @@ private slots: void testReportingDayBoundaryIsPublishedNotDeducible(); void testDrawMarginComparesHeadroomsNotCaps(); - //! Une révision n'existe que si la PRÉVISION a changé — un run qui reconduit met à jour la - //! fraîcheur, il n'ouvre pas de ligne. Et les deux instants ne se confondent pas. - void testARevisionExistsOnlyIfTheForecastChanged(); - //! La courbe se relit telle qu'elle était connue à un instant, et ses trous restent des trous. + //! Une révision n'existe que si la PRÉVISION a changé — et le contenu seul en décide. + void testTheContentIsTheRevisionIdentity(); + //! La courbe se relit telle qu'elle était connue, et ses trous restent des trous. void testTheCurveIsReproducibleAndKeepsItsHoles(); //! La rétention garde les DEUX bouts : n'en garder qu'un détruirait la révision. void testRetentionKeepsBothEndsOfEachRevision(); - //! La fraîcheur ne date JAMAIS la prévision, et elle n'est jamais absente. - 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(); + //! La charge utile se REFUSE plutôt que de s'interpréter au mieux — mais pas pour un attribut. + void testAPayloadIsRefusedOnFormNeverOnAMissingAttribute(); + }; #endif // TESTLOADMODEL_H