From ff9437726732f899a10ff2946fac898ffb5c7e9e Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 13 Oct 2026 09:15:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(pr=C3=A9vision=20PV):=20table=20forecastLe?= =?UTF-8?q?arning=20=E2=80=94=20un=20pas=20=C3=A9coul=C3=A9,=20un=20plan,?= =?UTF-8?q?=20une=20ligne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brief 3 T2. Nouvelle table dans energy-forecast.sqlite, clé (targetAt, siteId, planId) : le pas écoulé est un fait clos, jamais réécrit (ON CONFLICT DO NOTHING, pas INSERT OR IGNORE qui avalerait un NOT NULL). Les révisions restent dans forecastPv ; la table y renvoie par la clé de contenu du §20 (révision en vigueur à J−1 18:00 locale et à H−1). gti/tempC et paramètres v2 NULL en v1, jamais 0. Production moyenne depuis les échantillons d'une minute du bilan, NULL sous 90 % de couverture ou sans producteur. Écrêtage détecté seulement contre le plafond AC déclaré ; neige = chute lue sur le site. Écriture sur un fil dédié ; rétention 400 jours ; table abîmée refaite ou mise de côté, le service continue. Tests loadmodel : 5 fonctions, 13 contre-épreuves vérifiées échouant. Co-Authored-By: Claude Opus 5.5 --- energyplugin/energyplugin.pri | 2 + .../etm/forecast/forecastlearninglog.cpp | 653 ++++++++++++++++++ .../etm/forecast/forecastlearninglog.h | 230 ++++++ tests/auto/loadmodel/testloadmodel.cpp | 474 +++++++++++++ tests/auto/loadmodel/testloadmodel.h | 11 + 5 files changed, 1370 insertions(+) create mode 100644 energyplugin/etm/forecast/forecastlearninglog.cpp create mode 100644 energyplugin/etm/forecast/forecastlearninglog.h diff --git a/energyplugin/energyplugin.pri b/energyplugin/energyplugin.pri index 89b6066..d529a06 100644 --- a/energyplugin/energyplugin.pri +++ b/energyplugin/energyplugin.pri @@ -50,6 +50,7 @@ HEADERS += \ $$PWD/rootmeter.h \ $$PWD/smartchargingmanager.h \ $$PWD/etm/forecast/forecaststore.h \ + $$PWD/etm/forecast/forecastlearninglog.h \ $$PWD/spotmarket/spotmarketdataprovider.h \ $$PWD/spotmarket/spotmarketmanager.h \ $$PWD/types/chargingaction.h \ @@ -70,6 +71,7 @@ SOURCES += \ $$PWD/rootmeter.cpp \ $$PWD/smartchargingmanager.cpp \ $$PWD/etm/forecast/forecaststore.cpp \ + $$PWD/etm/forecast/forecastlearninglog.cpp \ $$PWD/spotmarket/spotmarketdataprovider.cpp \ $$PWD/spotmarket/spotmarketmanager.cpp \ $$PWD/types/chargingaction.cpp \ diff --git a/energyplugin/etm/forecast/forecastlearninglog.cpp b/energyplugin/etm/forecast/forecastlearninglog.cpp new file mode 100644 index 0000000..1601003 --- /dev/null +++ b/energyplugin/etm/forecast/forecastlearninglog.cpp @@ -0,0 +1,653 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#include "forecastlearninglog.h" +#include "forecaststore.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy) + +namespace { +const QString Table = QStringLiteral("forecastLearning"); + +//! Les colonnes, dans l'ordre. Sert à CRÉER la table et à RECONNAÎTRE une table de forme +//! inattendue : celle-ci est refaite, jamais lue de travers. +const QStringList Colonnes = { + QStringLiteral("targetAt"), QStringLiteral("stepS"), QStringLiteral("siteId"), + QStringLiteral("planId"), QStringLiteral("gti"), QStringLiteral("tempC"), QStringLiteral("pW"), + QStringLiteral("corrections"), QStringLiteral("transportV"), QStringLiteral("kwp"), + QStringLiteral("performanceRatio"), QStringLiteral("tempCoeffPctPerK"), QStringLiteral("noctC"), + QStringLiteral("acLimitW"), QStringLiteral("issuedAt"), QStringLiteral("model"), + QStringLiteral("observedAt"), QStringLiteral("sitePlans"), QStringLiteral("sitePlansExpected"), + QStringLiteral("dayAheadAt"), QStringLiteral("contentIdDayAhead"), + QStringLiteral("contentIdHourAhead"), QStringLiteral("measuredW"), + QStringLiteral("measuredSource"), QStringLiteral("measuredMinutes"), + QStringLiteral("curtailed"), QStringLiteral("snow"), QStringLiteral("valid"), + QStringLiteral("writtenAt") +}; + +const char *Creation = + "CREATE TABLE IF NOT EXISTS forecastLearning (" + // DÉBUT DU PAS, epoch s UTC — même sens que forecastPv.targetAt, pour la jointure. + " targetAt INTEGER NOT NULL," + " stepS INTEGER NOT NULL," + " siteId TEXT NOT NULL," + " planId TEXT NOT NULL," + // CE QUE LE PLAN PUBLIAIT pour ce pas, à sa dernière lecture. NULL = absent, JAMAIS 0 : + // en v1, gti et tempC n'existent pas. + " gti REAL," + " tempC REAL," + " pW REAL," // W MOYENS sur le pas, pas des Wh + " corrections TEXT NOT NULL," // ordre canonique, séparées par ',' + " transportV INTEGER NOT NULL," + // LA CONVERSION EN VIGUEUR — sans elle, `GTI × kWc × PR` n'est pas reconstituable dès qu'un + // paramètre change. tempCoeffPctPerK et noctC sont NULL en v1, qui ne les applique pas. + " kwp REAL," + " performanceRatio REAL," + " tempCoeffPctPerK REAL," + " noctC REAL," + " acLimitW INTEGER," // plafond du SITE, appliqué après la somme + // ATTRIBUTS de la charge utile lue — jamais une identité (§20). + " issuedAt INTEGER," + " model TEXT," + " observedAt INTEGER NOT NULL," // ms : dernière lecture de ce plan pour ce pas + " sitePlans INTEGER NOT NULL," + " sitePlansExpected INTEGER NOT NULL," + // RENVOIS vers forecastPv (targetAt, siteId, contentId) — la clé de contenu du §20. + " dayAheadAt INTEGER NOT NULL," // J−1 18:00 locale, epoch s + " contentIdDayAhead TEXT," + " contentIdHourAhead TEXT," + // CE QUE LE SITE A MESURÉ — production moyenne, W, positive quand on produit. + " measuredW REAL," + " measuredSource TEXT," + " measuredMinutes INTEGER NOT NULL," + " curtailed INTEGER," // 1 détecté · 0 cherché, rien vu · NULL + " snow INTEGER," // chute de neige LUE sur le site · NULL + " valid INTEGER NOT NULL," + " writtenAt INTEGER NOT NULL," // ms + " PRIMARY KEY (targetAt, siteId, planId))"; + +QVariant nul(QMetaType::Type t) { return QVariant(QMetaType(t)); } +template QVariant ouNul(const std::optional &v, QMetaType::Type t) +{ + return v ? QVariant::fromValue(*v) : nul(t); +} +QVariant ouNul(const std::optional &v) +{ + return v ? QVariant(int(*v)) : nul(QMetaType::Int); +} +QVariant ouNul(const QString &s) { return s.isEmpty() ? nul(QMetaType::QString) : QVariant(s); } + +std::optional nombre(const QVariant &v) +{ + bool ok = false; + const double d = v.toDouble(&ok); + return (ok && v.isValid() && !v.isNull()) ? std::optional(d) : std::nullopt; +} + +QList> serie(const QVariant &brut) +{ + QList> s; + for (const QVariant &v : brut.toList()) + s << nombre(v); + return s; +} + +QString texteBool(const std::optional &b) +{ + return b ? (*b ? QStringLiteral("oui") : QStringLiteral("non")) : QStringLiteral("inconnu"); +} +} + +// ============================================================================================ +// LE FIL D'ÉCRITURE — sa propre connexion, jamais celle du cycle +// ============================================================================================ + +/*! + * \brief Écrit les lignes de \c forecastLearning, dans son propre fil et sa propre connexion. + * \note Interne à \c ForecastLearningLog. Une connexion SQLite ne se partage pas entre fils : + * celle-ci est ouverte, utilisée et fermée dans le fil d'écriture uniquement. + */ +class ForecastLearningWriter : public QObject +{ +public: + ForecastLearningWriter(const QString &chemin, QAtomicInt *ecrites, QAtomicInt *lots, + QAtomicInt *valide) + : m_chemin(chemin), m_ecrites(ecrites), m_lots(lots), m_valide(valide) {} + + void ouvrir() + { + QDir().mkpath(QFileInfo(m_chemin).absolutePath()); + m_connexion = QStringLiteral("etm-forecast-learning-%1") + .arg(QUuid::createUuid().toString(QUuid::WithoutBraces)); + m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connexion); + m_db.setDatabaseName(m_chemin); + // Le fil ATTEND un verrou tenu par l'historique des révisions plutôt que d'échouer ; il + // peut attendre, lui — c'est précisément pour ça qu'il n'est pas le fil du cycle. + m_db.setConnectOptions(QStringLiteral("QSQLITE_BUSY_TIMEOUT=10000")); + if (!m_db.open()) { + horsService(m_db.lastError().text()); + return; + } + m_valide->storeRelease(assurerTable(/*verifierContenu=*/true) ? 1 : 0); + } + + void fermer() + { + if (m_db.isOpen()) + m_db.close(); + m_db = QSqlDatabase(); + if (!m_connexion.isEmpty()) + QSqlDatabase::removeDatabase(m_connexion); + } + + void ecrire(QList lignes, qint64 nowS) + { + // Les lignes laissées par un verrou au lot précédent passent en tête : l'ordre d'arrivée + // n'a pas d'importance, la clé les distingue. + lignes = m_enAttente + lignes; + m_enAttente.clear(); + if (m_valide->loadAcquire() && !lignes.isEmpty()) { + for (ForecastLearningRow &l : lignes) { + l.contentIdDayAhead = contenuEnVigueur(l.targetAt, l.siteId, l.dayAheadAt * 1000); + l.contentIdHourAhead = contenuEnVigueur(l.targetAt, l.siteId, + (l.targetAt - 3600) * 1000); + } + int inserees = 0; + QString erreur; + int code = inserer(lignes, &inserees, &erreur); + if (code == 5 || code == 6) { + // OCCUPÉE, pas abîmée : on réessaiera au prochain lot, rien n'est refait. + qCWarning(dcNymeaEnergy()) << "[Prévision] apprentissage : base occupée —" + << lignes.count() << "ligne(s) remise(s) au lot suivant"; + m_enAttente = lignes; + } else if (code != 0) { + // TOUT AUTRE ÉCHEC : la table est vérifiée — refaite si elle est abîmée, de forme + // inattendue ou disparue — et le lot rejoué UNE fois. Le service continue. + qCWarning(dcNymeaEnergy()) << "[Prévision] apprentissage : écriture refusée (" + << erreur << ") — vérification de la table"; + if (assurerTable(/*verifierContenu=*/true, /*refaireSiAbimee=*/true) + && inserer(lignes, &inserees, &erreur) == 0) { + qCInfo(dcNymeaEnergy()) << "[Prévision] apprentissage : lot rejoué après réparation"; + } else { + qCWarning(dcNymeaEnergy()) << "[Prévision] apprentissage :" << lignes.count() + << "ligne(s) PERDUE(S) —" << erreur; + } + } + m_ecrites->fetchAndAddRelease(inserees); + journaliser(lignes, inserees); + purger(nowS); + } + m_lots->fetchAndAddRelease(1); + } + +private: + //! \return 0 si tout est écrit ; sinon le code SQLite natif de l'échec (≥ 1). + int inserer(const QList &lignes, int *inserees, QString *erreur) + { + *inserees = 0; + if (!m_db.transaction()) { + *erreur = m_db.lastError().text(); + return qMax(1, m_db.lastError().nativeErrorCode().toInt()); + } + QSqlQuery q(m_db); + // Un pas ÉCOULÉ est un fait clos : une seconde écriture — un redémarrage qui revoit la + // même heure — ne réécrit pas la première, qui a vu le pas. + // + // `ON CONFLICT (clé) DO NOTHING`, et SURTOUT PAS `INSERT OR IGNORE` : celui-ci ignore AUSSI + // une violation de NOT NULL, et une ligne mal formée disparaîtrait sans une trace. Ici + // seul le doublon de clé est ignoré ; tout autre refus est une erreur, et se dit. + q.prepare(QStringLiteral("INSERT INTO forecastLearning (%1) VALUES (%2) " + "ON CONFLICT (targetAt, siteId, planId) DO NOTHING") + .arg(Colonnes.join(','), + QStringList(Colonnes.count(), QStringLiteral("?")).join(','))); + const qint64 ecrit = QDateTime::currentMSecsSinceEpoch(); + for (const ForecastLearningRow &l : lignes) { + q.addBindValue(l.targetAt); + q.addBindValue(l.stepS); + q.addBindValue(l.siteId); + q.addBindValue(l.planId); + q.addBindValue(ouNul(l.gti, QMetaType::Double)); + q.addBindValue(ouNul(l.tempC, QMetaType::Double)); + q.addBindValue(ouNul(l.pW, QMetaType::Double)); + // Une liste VIDE est une chaîne vide, pas NULL : « aucune correction » est une + // affirmation (la v1 n'a jamais corrigé), pas une absence. + const QString corrections = l.corrections.join(','); + q.addBindValue(corrections.isNull() ? QString("") : corrections); + q.addBindValue(l.transportVersion); + q.addBindValue(ouNul(l.kwp, QMetaType::Double)); + q.addBindValue(ouNul(l.performanceRatio, QMetaType::Double)); + q.addBindValue(ouNul(l.tempCoeffPctPerK, QMetaType::Double)); + q.addBindValue(ouNul(l.noctC, QMetaType::Double)); + q.addBindValue(ouNul(l.acLimitW, QMetaType::Int)); + q.addBindValue(ouNul(l.issuedAt, QMetaType::LongLong)); + q.addBindValue(ouNul(l.model)); + q.addBindValue(l.observedAt); + q.addBindValue(l.sitePlans); + q.addBindValue(l.sitePlansExpected); + q.addBindValue(l.dayAheadAt); + q.addBindValue(ouNul(l.contentIdDayAhead)); + q.addBindValue(ouNul(l.contentIdHourAhead)); + q.addBindValue(ouNul(l.measuredW, QMetaType::Double)); + q.addBindValue(ouNul(l.measuredSource)); + q.addBindValue(l.measuredMinutes); + q.addBindValue(ouNul(l.curtailed)); + q.addBindValue(ouNul(l.snow)); + q.addBindValue(int(l.valid)); + q.addBindValue(ecrit); + if (!q.exec()) { + *erreur = q.lastError().text(); + const int code = qMax(1, q.lastError().nativeErrorCode().toInt()); + m_db.rollback(); + *inserees = 0; + return code; + } + *inserees += q.numRowsAffected() > 0 ? 1 : 0; + } + if (!m_db.commit()) { + *erreur = m_db.lastError().text(); + *inserees = 0; + return qMax(1, m_db.lastError().nativeErrorCode().toInt()); + } + return 0; + } + + /*! + * La révision du SITE en vigueur à l'instant \p instantMs pour le pas \p targetAt. + * + * « En vigueur » = la plus récemment APPARUE parmi celles encore VUES à cet instant + * (`firstSeenAt ≤ X ≤ lastSeenAt`) ; à défaut, la dernière vue avant X. Ce n'est pas la + * règle de \c ForecastStore::courbe() (`MAX(lastSeenAt)`), qui sert la courbe COURANTE : pour + * un instant PASSÉ, `MAX(lastSeenAt)` désignerait un contenu revu APRÈS X. La limite du + * §23.2 reste : \c lastSeenAt est mis à jour en place, donc un retour à un contenu antérieur + * rend une réponse approchée. + */ + QString contenuEnVigueur(qint64 targetAt, const QString &siteId, qint64 instantMs) + { + QSqlQuery q(m_db); + q.prepare(QStringLiteral( + "SELECT contentId FROM forecastPv " + "WHERE targetAt = ? AND siteId = ? AND firstSeenAt <= ? " + "ORDER BY (lastSeenAt >= ?) DESC, " + " CASE WHEN lastSeenAt >= ? THEN firstSeenAt ELSE lastSeenAt END DESC " + "LIMIT 1")); + q.addBindValue(targetAt); + q.addBindValue(siteId); + q.addBindValue(instantMs); + q.addBindValue(instantMs); + q.addBindValue(instantMs); + // Pas de forecastPv (magasin hors service) ou aucune révision : NULL, jamais deviné. + if (!q.exec() || !q.next()) + return QString(); + return q.value(0).toString(); + } + + /*! + * La table existe-t-elle, avec la forme attendue, et lisible ? Sinon elle est REFAITE. + * \param verifierContenu Passe aussi un \c quick_check sur la table. + * \param refaireSiAbimee Refaire même quand la forme est la bonne (après un échec d'écriture + * que la vérification n'explique pas). + */ + bool assurerTable(bool verifierContenu, bool refaireSiAbimee = false) + { + QSqlQuery q(m_db); + QStringList trouvees; + if (!q.exec(QStringLiteral("PRAGMA table_info(forecastLearning)"))) { + // Le FICHIER n'est pas lisible comme une base : rien à refaire ici sans détruire + // aussi l'historique des révisions. Journalisé, et le service continue sans nous. + horsService(q.lastError().text()); + return false; + } + while (q.next()) + trouvees << q.value(1).toString(); + + QString motif; + if (!trouvees.isEmpty() && trouvees != Colonnes) + motif = QStringLiteral("forme inattendue (%1 colonne(s))").arg(trouvees.count()); + else if (!trouvees.isEmpty() && verifierContenu) { + QSqlQuery c(m_db); + if (!c.exec(QStringLiteral("PRAGMA quick_check(forecastLearning)"))) + motif = QStringLiteral("vérification impossible : %1").arg(c.lastError().text()); + else if (c.next() && c.value(0).toString() != QLatin1String("ok")) + motif = QStringLiteral("abîmée : %1").arg(c.value(0).toString()); + } + if (refaireSiAbimee && trouvees.isEmpty()) + qCWarning(dcNymeaEnergy()) << "[Prévision] apprentissage : table DISPARUE — recréée ;" + " le service continue"; + if (motif.isEmpty() && refaireSiAbimee && !trouvees.isEmpty()) { + // La forme est bonne, le contenu passe la vérification, et l'écriture échoue + // quand même : on ne refait pas une table saine sur une hypothèse. On dit seulement. + return true; + } + if (!motif.isEmpty()) { + qCWarning(dcNymeaEnergy()).noquote() + << QStringLiteral("[Prévision] apprentissage : table %1 — REFAITE, son contenu est " + "perdu ; le service continue").arg(motif); + if (!q.exec(QStringLiteral("DROP TABLE IF EXISTS forecastLearning"))) { + // Une table dont les PAGES sont abîmées ne se supprime pas : DROP parcourt son + // arbre, et c'est lui qui est cassé. On la met DE CÔTÉ par un renommage, qui ne + // touche que le schéma — elle reste dans le fichier, illisible et inerte, sous un + // nom daté — et une table neuve prend sa place. + const QString cote = QStringLiteral("forecastLearning_abimee_%1") + .arg(QDateTime::currentMSecsSinceEpoch()); + const QString erreurDrop = q.lastError().text(); + q.exec(QStringLiteral("DROP INDEX IF EXISTS idxForecastLearningPlan")); + if (!q.exec(QStringLiteral("ALTER TABLE forecastLearning RENAME TO %1").arg(cote))) { + horsService(QStringLiteral("%1 ; mise de côté impossible : %2") + .arg(erreurDrop, q.lastError().text())); + return false; + } + qCWarning(dcNymeaEnergy()).noquote() + << QStringLiteral("[Prévision] apprentissage : suppression impossible (%1) — " + "table abîmée MISE DE CÔTÉ sous %2").arg(erreurDrop, cote); + } + } + if (!q.exec(QString::fromLatin1(Creation)) + || !q.exec(QStringLiteral("CREATE INDEX IF NOT EXISTS idxForecastLearningPlan " + "ON forecastLearning(planId, targetAt)"))) { + horsService(q.lastError().text()); + return false; + } + m_valide->storeRelease(1); + return true; + } + + void purger(qint64 nowS) + { + // 400 JOURS : un cycle saisonnier complet, et la même saison de l'an passé pour le + // comparer. La table croît d'une ligne par plan et par heure — ~9 600 lignes par plan. + QSqlQuery q(m_db); + q.prepare(QStringLiteral("DELETE FROM forecastLearning WHERE targetAt < ?")); + q.addBindValue(nowS - qint64(ForecastLearningLog::RetentionJours) * 86400); + if (!q.exec()) + qCWarning(dcNymeaEnergy()) << "[Prévision] apprentissage : purge refusée :" + << q.lastError().text(); + else if (q.numRowsAffected() > 0) + qCInfo(dcNymeaEnergy()) << "[Prévision] apprentissage : purge —" << q.numRowsAffected() + << "ligne(s) au-delà de" << ForecastLearningLog::RetentionJours + << "jours"; + } + + void journaliser(const QList &lignes, int inserees) + { + // L'ISSUE, pas seulement l'issue remarquable (règle 7-c) — et ce qui a été LU : la + // couverture de la mesure, l'écrêtage, la neige, les révisions trouvées. + int valides = 0; + for (const ForecastLearningRow &l : lignes) + valides += l.valid ? 1 : 0; + const ForecastLearningRow &l = lignes.first(); + qCInfo(dcNymeaEnergy()).noquote() + << QStringLiteral("[Prévision] apprentissage — pas %1 : %2 ligne(s) écrite(s) sur %3, " + "%4 valide(s) ; production %5 (%6 min) ; écrêtage %7 ; neige %8 ; " + "révision veille %9, H-1 %10") + .arg(QDateTime::fromSecsSinceEpoch(l.targetAt, QTimeZone::utc()).toString(Qt::ISODate)) + .arg(inserees).arg(lignes.count()).arg(valides) + .arg(l.measuredW ? QStringLiteral("%1 W").arg(*l.measuredW, 0, 'f', 0) + : QStringLiteral("inconnue")) + .arg(l.measuredMinutes) + .arg(texteBool(l.curtailed), texteBool(l.snow)) + .arg(l.contentIdDayAhead.isEmpty() ? QStringLiteral("aucune") : l.contentIdDayAhead.left(8), + l.contentIdHourAhead.isEmpty() ? QStringLiteral("aucune") : l.contentIdHourAhead.left(8)); + } + + void horsService(const QString &erreur) + { + m_valide->storeRelease(0); + if (m_horsServiceSignale) + return; + m_horsServiceSignale = true; + qCWarning(dcNymeaEnergy()) << "[Prévision] apprentissage HORS SERVICE — base illisible" + << m_chemin << ":" << erreur + << "— rien n'est enregistré, l'arbitrage n'en dépend pas"; + } + + QString m_chemin; + QString m_connexion; + QSqlDatabase m_db; + QAtomicInt *m_ecrites; + QAtomicInt *m_lots; + QAtomicInt *m_valide; + QList m_enAttente; + bool m_horsServiceSignale = false; +}; + +// ============================================================================================ +// LE CÔTÉ CYCLE — copies en mémoire, aucune requête +// ============================================================================================ + +ForecastLearningLog::ForecastLearningLog(const QString &cheminFichier, const QTimeZone &fuseau, + QObject *parent) + : QObject(parent) + , m_fuseau(fuseau.isValid() ? fuseau : QTimeZone::systemTimeZone()) +{ + m_valide.storeRelease(1); + m_fil = new QThread(this); + m_fil->setObjectName(QStringLiteral("etm-forecast-learning")); + m_ecrivain = new ForecastLearningWriter(cheminFichier, &m_ecrites, &m_lots, &m_valide); + m_ecrivain->moveToThread(m_fil); + m_fil->start(QThread::LowPriority); + ForecastLearningWriter *w = m_ecrivain; + QMetaObject::invokeMethod(w, [w]() { w->ouvrir(); }, Qt::QueuedConnection); +} + +ForecastLearningLog::~ForecastLearningLog() +{ + // Les lots en attente passent AVANT la fermeture : l'appel bloquant est rangé derrière eux. + ForecastLearningWriter *w = m_ecrivain; + QMetaObject::invokeMethod(w, [w]() { w->fermer(); delete w; }, Qt::BlockingQueuedConnection); + m_fil->quit(); + m_fil->wait(); +} + +ForecastPlanObservation ForecastLearningLog::lirePlan(const QVariantMap &charge, + const QVariantMap ¶metres, QString &refus) +{ + ForecastPlanObservation o; + // LA MÊME VALIDATION que la courbe : une charge utile refusée pour la prévision l'est ici + // aussi. Deux lecteurs de la même frontière qui n'en refusent pas la même chose + // finiraient par enregistrer ce que l'autre a jugé illisible. + const ForecastRevision rev = ForecastStore::depuisTransport(charge, refus); + if (!refus.isEmpty()) + return o; + o.transportVersion = charge.value(QStringLiteral("v")).toInt(); + o.t0 = charge.value(QStringLiteral("t0")).toLongLong(); + o.stepS = rev.stepS; + o.corrections = rev.corrections; + o.model = rev.model; + if (rev.issuedAt.isValid()) + o.issuedAt = rev.issuedAt.toSecsSinceEpoch(); + o.pW = serie(charge.value(QStringLiteral("p"))); + + // gti[] et tempC[] — v2 seulement. Indexés comme p par CONTRAT ; une longueur différente + // rendrait l'alignement ambigu, et on ne devine pas : la série entière est écartée. + if (o.transportVersion >= 2) { + const QList> gti = serie(charge.value(QStringLiteral("gti"))); + const QList> tempC = serie(charge.value(QStringLiteral("tempC"))); + if (gti.count() == o.pW.count()) + o.gti = gti; + if (tempC.count() == o.pW.count()) + o.tempC = tempC; + } + // LES PARAMÈTRES DU THING PLAN — la seule source (§28 décision 1), lue, jamais dupliquée. + o.kwp = nombre(parametres.value(QStringLiteral("kwp"))); + o.performanceRatio = nombre(parametres.value(QStringLiteral("performanceRatio"))); + // En v1 la correction de température n'existe pas : ses paramètres, même posés sur le + // Thing, n'ont rien produit. Les écrire ferait croire qu'ils ont servi — ils restent NULL. + if (o.transportVersion >= 2) { + o.tempCoeffPctPerK = nombre(parametres.value(QStringLiteral("tempCoeffPctPerK"))); + o.noctC = nombre(parametres.value(QStringLiteral("noctC"))); + } + return o; +} + +QDateTime ForecastLearningLog::instantVeille(const QDateTime &targetAt, const QTimeZone &fuseau) +{ + // Un RÉGLAGE (« 18:00 ») s'interprète dans le fuseau qui fait foi ; le résultat est un + // INSTANT absolu. Le jour local est celui du DÉBUT du pas. + const QDate jour = targetAt.toTimeZone(fuseau).date(); + return QDateTime(jour.addDays(-1), QTime(18, 0), fuseau).toUTC(); +} + +void ForecastLearningLog::observer(const QDateTime &now, const QString &siteId, + const QList &plans, int plansAttendus, + std::optional acLimitW, std::optional chuteNeige, + bool producteurPresent) +{ + const qint64 nowS = now.toUTC().toSecsSinceEpoch(); + const qint64 nowMs = now.toUTC().toMSecsSinceEpoch(); + // Le PREMIER cycle borne ce qu'on écrit : un pas déjà écoulé avant qu'on regarde n'a été + // ni lu ni mesuré par nous. L'écrire avec la charge utile d'après ferait passer une + // relecture pour une observation. + if (m_debutS == 0) + m_debutS = nowS; + m_producteur = producteurPresent; + if (chuteNeige) + m_neige.insert(nowS, *chuteNeige); + + if (!siteId.isEmpty()) { + for (const ForecastPlanObservation &p : plans) { + if (p.stepS <= 0) + continue; + const qint64 dejaEcrit = m_ecritJusqua.value(siteId + QLatin1Char('|') + p.planId, + std::numeric_limits::min()); + for (int i = 0; i < p.pW.count(); ++i) { + const qint64 debut = p.t0 + qint64(i) * p.stepS; + if (debut > nowS) + break; // à venir : on le lira quand il aura commencé + if (debut + p.stepS <= m_debutS) + continue; // écoulé avant nous + if (debut <= dejaEcrit) + continue; // déjà écrit : un fait clos ne se relit pas + const Cle cle{siteId, debut}; + ForecastLearningRow &l = m_tampon[cle][p.planId]; + l.targetAt = debut; + l.stepS = p.stepS; + l.siteId = siteId; + l.planId = p.planId; + l.pW = p.pW.at(i); + l.gti = i < p.gti.count() ? p.gti.at(i) : std::nullopt; + l.tempC = i < p.tempC.count() ? p.tempC.at(i) : std::nullopt; + l.corrections = p.corrections; + l.transportVersion = p.transportVersion; + l.kwp = p.kwp; + l.performanceRatio = p.performanceRatio; + l.tempCoeffPctPerK = p.tempCoeffPctPerK; + l.noctC = p.noctC; + l.acLimitW = acLimitW; + l.issuedAt = p.issuedAt; + l.model = p.model; + l.observedAt = nowMs; + m_attendus[cle] = plansAttendus; + } + } + } + finaliser(now); +} + +void ForecastLearningLog::observerProduction(const QDateTime &finMinute, double productionW) +{ + m_minutes.insert(finMinute.toUTC().toSecsSinceEpoch(), productionW); +} + +void ForecastLearningLog::finaliser(const QDateTime &now) +{ + const qint64 nowS = now.toUTC().toSecsSinceEpoch(); + QList lot; + + if (!m_producteur && !m_tampon.isEmpty() && !m_sansProducteurSignale) { + m_sansProducteurSignale = true; + qCInfo(dcNymeaEnergy()) << "[Prévision] apprentissage : aucun producteur (smartmeterproducer)" + " — la production du site n'est pas mesurée, elle reste NULL"; + } else if (m_producteur) { + m_sansProducteurSignale = false; + } + + for (auto it = m_tampon.begin(); it != m_tampon.end();) { + QHash &parPlan = it.value(); + // Les plans d'un même instant peuvent ne pas partager le pas : chacun s'écrit à SA fin. + QList prets; + for (auto p = parPlan.constBegin(); p != parPlan.constEnd(); ++p) + if (p.value().targetAt + p.value().stepS + GraceS <= nowS) + prets << p.key(); + for (const QString &planId : std::as_const(prets)) { + ForecastLearningRow l = parPlan.value(planId); + const qint64 debut = l.targetAt; + const qint64 fin = l.targetAt + l.stepS; + int memePas = 0; + for (const ForecastLearningRow &autre : std::as_const(parPlan)) + memePas += autre.stepS == l.stepS ? 1 : 0; + l.sitePlans = memePas; + l.sitePlansExpected = m_attendus.value(it.key()); + l.dayAheadAt = instantVeille(QDateTime::fromSecsSinceEpoch(debut, QTimeZone::utc()), + m_fuseau).toSecsSinceEpoch(); + + // --- LA MESURE : la moyenne des minutes du pas, et combien il y en a eu ----------- + // Un échantillon horodaté t couvre [t − 60 s, t) : ceux du pas sont dans ]début, fin]. + double somme = 0, pic = 0; + int n = 0; + for (auto m = m_minutes.upperBound(debut); m != m_minutes.end() && m.key() <= fin; ++m) { + const double produitW = -m.value(); // nymea : production NÉGATIVE + somme += produitW; + pic = n == 0 ? produitW : qMax(pic, produitW); + ++n; + } + l.measuredMinutes = n; + const int attendues = l.stepS / 60; + const bool couverte = m_producteur && attendues > 0 + && n >= qCeil(CouvertureMin * attendues); + if (couverte) { + l.measuredW = somme / n; + l.measuredSource = QString::fromLatin1(SourceBilan); + } + // --- L'ÉCRÊTAGE : ce que les données présentes permettent, et rien de plus -------- + // Seul détecteur : le plafond AC du SITE, déclaré. Une minute à ≥ 98 % du plafond est + // une minute où l'onduleur bornait — la mesure y sous-estime le potentiel. Sans + // plafond déclaré, ou sans mesure, on ne cherche rien : NULL, pas « non écrêté ». + if (m_producteur && l.acLimitW && n > 0 && pic >= RapportPlafond * *l.acLimitW) + l.curtailed = true; + else if (couverte && l.acLimitW) + l.curtailed = false; + // --- LA NEIGE : la chute LUE sur le site pendant le pas — pas la couverture --------- + for (auto s = m_neige.lowerBound(debut); s != m_neige.end() && s.key() < fin; ++s) + l.snow = (l.snow && *l.snow) || s.value() > 0; + + l.valid = l.measuredW && l.pW && l.curtailed != std::optional(true) + && l.snow != std::optional(true) + && l.sitePlansExpected > 0 && l.sitePlans == l.sitePlansExpected; + const QString filigrane = l.siteId + QLatin1Char('|') + l.planId; + m_ecritJusqua.insert(filigrane, qMax(m_ecritJusqua.value(filigrane, debut), debut)); + lot << l; + } + for (const QString &planId : std::as_const(prets)) + parPlan.remove(planId); + if (parPlan.isEmpty()) { + m_attendus.remove(it.key()); + it = m_tampon.erase(it); + } else { + ++it; + } + } + + // Une journée et deux heures de minutes suffisent à tout pas encore en attente. + const qint64 borne = nowS - 26 * 3600; + while (!m_minutes.isEmpty() && m_minutes.firstKey() < borne) + m_minutes.erase(m_minutes.begin()); + while (!m_neige.isEmpty() && m_neige.firstKey() < borne) + m_neige.erase(m_neige.begin()); + + if (lot.isEmpty()) + return; + ForecastLearningWriter *w = m_ecrivain; + QMetaObject::invokeMethod(w, [w, lot, nowS]() { w->ecrire(lot, nowS); }, Qt::QueuedConnection); +} diff --git a/energyplugin/etm/forecast/forecastlearninglog.h b/energyplugin/etm/forecast/forecastlearninglog.h new file mode 100644 index 0000000..caba79c --- /dev/null +++ b/energyplugin/etm/forecast/forecastlearninglog.h @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +class QThread; +class ForecastLearningWriter; + +/*! + * \file forecastlearninglog.h + * \brief L'enregistrement pour l'APPRENTISSAGE FUTUR — brief 3, T2 (\c DESIGN_PREVISION_PV.md §31). + * + * \par Le GPL n'apprend pas ; il rend l'apprentissage POSSIBLE (§5) + * Une ligne par **pas écoulé** de la grille du producteur et par **plan** : ce que le plan + * publiait pour ce pas (\c p, et en v2 \c gti / \c tempC), les paramètres de conversion EN + * VIGUEUR, les révisions du site en vigueur à la veille et à H−1 (par leur \c contentId), et ce + * que le site a mesuré. Rien n'est rapproché ici : le rapprochement est le produit. + * + * \par Aucun effet sur l'arbitrage (§11) + * Rien de ce module n'entre dans le contexte ni dans le plan. Il reçoit des COPIES, en fin de + * lecture de la prévision, et n'a aucun accès à ce que le cycle décide. + */ + +/*! + * \brief Ce qu'un plan publiait, lu à un cycle — la matière d'une ligne. + * + * Les séries sont indexées comme \c p : l'élément \c i vaut pour `[t0 + i·step, t0 + (i+1)·step)`. + * Un élément ABSENT (\c std::nullopt) est un trou — jamais un zéro. + */ +struct ForecastPlanObservation { + QString planId; //!< ThingId du plan, forme \c {uuid}. + int transportVersion = 0; //!< \c v de la charge utile (1 ou 2). + qint64 t0 = 0; //!< Début de la grille, epoch s UTC. + int stepS = 0; //!< Pas, en secondes, LU. + QList> pW; //!< \c p du plan, en W moyens sur le pas. + QList> gti; //!< GTI brut W/m² — vide en v1. + QList> tempC; //!< \c temperature_2m °C — vide en v1. + QStringList corrections; //!< Corrections du PLAN, ordre canonique. + std::optional issuedAt; //!< Attribut ; absent si 0 (repli best_match). + QString model; //!< Modèle ayant répondu ; vide = inconnu. + std::optional kwp; //!< Paramètre du Thing plan, en vigueur. + std::optional performanceRatio; //!< Idem. + std::optional tempCoeffPctPerK; //!< v2 seulement — absent en v1, JAMAIS 0. + std::optional noctC; //!< v2 seulement — absent en v1, JAMAIS 0. +}; + +/*! + * \brief Une ligne de la table \c forecastLearning, telle qu'elle sera écrite. + * \note Exposée pour les tests et pour la documentation du schéma ; l'écriture passe toujours + * par \c ForecastLearningLog::observer(). + */ +struct ForecastLearningRow { + qint64 targetAt = 0; //!< Début du pas, epoch s UTC. + int stepS = 0; //!< Durée du pas, s. + QString siteId; //!< Site désigné à l'observation. + QString planId; //!< Plan. + std::optional gti; //!< GTI du pas, W/m² ; NULL en v1. + std::optional tempC; //!< \c temperature_2m du pas, °C ; NULL en v1. + std::optional pW; //!< \c p du plan pour ce pas, W moyens. + QStringList corrections; //!< Corrections du plan. + int transportVersion = 0; //!< Version de la charge utile lue. + std::optional kwp; //!< Crête du plan (Thing), en vigueur. + std::optional performanceRatio; //!< PR du plan (Thing), en vigueur. + std::optional tempCoeffPctPerK; //!< v2 seulement ; NULL en v1. + std::optional noctC; //!< v2 seulement ; NULL en v1. + std::optional acLimitW; //!< Plafond AC du SITE en vigueur. + std::optional issuedAt; //!< Attribut de la charge utile (s). + QString model; //!< Modèle ayant répondu. + qint64 observedAt = 0; //!< Dernière lecture de ce plan pour ce pas (ms). + int sitePlans = 0; //!< Plans du site ayant une valeur pour ce pas. + int sitePlansExpected = 0; //!< Plans portant un canal sous le site. + qint64 dayAheadAt = 0; //!< J−1 18:00 locale, epoch s. + QString contentIdDayAhead; //!< Révision du site en vigueur à \c dayAheadAt ; vide = NULL. + QString contentIdHourAhead; //!< Révision du site en vigueur à `targetAt − 1 h` ; vide = NULL. + std::optional measuredW; //!< Production moyenne du site, W (> 0 = produit). + QString measuredSource; //!< Code de la source ; vide = NULL. + int measuredMinutes = 0; //!< Échantillons d'une minute reçus sur le pas. + std::optional curtailed; //!< 1 détecté ; 0 cherché, rien vu ; NULL non cherchable. + std::optional snow; //!< Chute de neige lue sur le site ; NULL si non lue. + bool valid = false; //!< Aucune raison CONNUE d'écarter la ligne. +}; + +/*! + * \brief Enregistre, pour chaque pas écoulé et chaque plan, de quoi refaire et juger la + * conversion plus tard — sans jamais bloquer le cycle. + * + * \par La clé, et pourquoi ce n'est pas celle de \c forecastPv + * `PRIMARY KEY (targetAt, siteId, planId)`. \c forecastPv est l'axe des RÉVISIONS : plusieurs + * contenus pour une même heure, identifiés par leur empreinte (§20). Ici, le pas est ÉCOULÉ : + * il n'y a plus qu'un fait par plan, et il ne se révise pas — une ligne écrite n'est jamais + * réécrite (\c INSERT OR IGNORE). Les révisions restent dans \c forecastPv ; cette table y + * RENVOIE par \c (targetAt, siteId, contentId), la clé de contenu du §20, jamais par + * \c issuedAt, qui n'identifie pas la courbe. \c siteId est dans la clé pour la raison du + * §20.2 : sans lui, changer de site désigné mêlerait deux historiques. + * + * \par Asynchrone, jamais bloquant + * \c observer() ne fait que copier en mémoire et, une fois par pas, poster un lot à un fil + * d'écriture qui a SA connexion SQLite. Aucune requête n'est exécutée dans le fil appelant. + * + * \par Base abîmée + * Table illisible, de forme inattendue ou disparue : elle est REFAITE, l'incident journalisé, + * et le service continue. Fichier qui n'est pas une base : l'enregistrement s'arrête, journalisé + * une fois ; rien d'autre n'en dépend. + */ +class ForecastLearningLog : public QObject +{ + Q_OBJECT +public: + //! Rétention de la table, en jours : un cycle saisonnier complet, plus une marge. + static constexpr int RetentionJours = 400; + //! Délai après la fin d'un pas avant de l'écrire : le dernier échantillon d'une minute, + //! horodaté à la fin du pas, arrive juste après. + static constexpr int GraceS = 120; + //! Part minimale des minutes reçues pour publier une production moyenne. + static constexpr double CouvertureMin = 0.9; + //! Une minute est ÉCRÊTÉE si sa production atteint ce rapport du plafond AC du site. + static constexpr double RapportPlafond = 0.98; + //! Code de la source de la production mesurée (jamais une phrase). + static constexpr const char *SourceBilan = "power-balance-1min"; + + /*! + * \brief Ouvre la table dans le fichier de la prévision, sur un fil d'écriture dédié. + * \param cheminFichier Le MÊME fichier que \c ForecastStore (\c energy-forecast.sqlite). + * \param fuseau Fuseau qui fait foi pour « J−1 18:00 locale » ; invalide = fuseau du système. + * \param parent Parent Qt. + */ + explicit ForecastLearningLog(const QString &cheminFichier, + const QTimeZone &fuseau = QTimeZone(), + QObject *parent = nullptr); + //! \brief Vide les écritures en attente, puis ferme la base sur son fil. + ~ForecastLearningLog() override; + + /*! + * \brief Lit ce qu'un plan publie : la charge utile et les paramètres de son Thing. + * \param charge Charge utile \c forecastCurveTransport. + * \param parametres Paramètres du Thing plan, par NOM (\c kwp, \c performanceRatio, + * \c tempCoeffPctPerK, \c noctC) ; un nom absent vaut « non lisible ». + * \param[out] refus Motif de refus de la charge utile (codes de \c ForecastStore), vide sinon. + * \return L'observation ; \c pW vide si la charge utile est refusée. + * \note En v1, \c gti, \c tempC, \c tempCoeffPctPerK et \c noctC restent ABSENTS même si le + * Thing porte les paramètres : la v1 ne les a pas appliqués, et les écrire ferait croire le + * contraire. Un \c gti[] ou \c tempC[] de longueur différente de \c p est écarté en entier : + * l'alignement se ferait par devinette. + */ + static ForecastPlanObservation lirePlan(const QVariantMap &charge, + const QVariantMap ¶metres, QString &refus); + + /*! + * \brief « J−1 18:00 locale » pour un pas : 18:00 la VEILLE du jour local où il commence. + * \param targetAt Début du pas. + * \param fuseau Fuseau qui fait foi. + * \return L'instant absolu correspondant. + */ + static QDateTime instantVeille(const QDateTime &targetAt, const QTimeZone &fuseau); + + /*! + * \brief Un cycle : retient ce que les plans publient, et écrit les pas écoulés. + * \param now Temps du cycle (\c ctx.timestamp — injectable). + * \param siteId Site désigné, forme canonique ; vide = rien à retenir. + * \param plans Plans du site dont la charge utile a été lue. + * \param plansAttendus Plans du site portant un canal, refusés compris. + * \param acLimitW Plafond AC du site en vigueur. + * \param chuteNeige Valeur de l'état \c snowfall du site ; absent si le site n'en a pas. + * \param producteurPresent Au moins un Thing \c smartmeterproducer : sans lui, la + * « production » du bilan est un zéro de construction, pas une mesure. + * \note Ne bloque jamais : copies en mémoire, puis un lot posté au fil d'écriture. + */ + void observer(const QDateTime &now, const QString &siteId, + const QList &plans, int plansAttendus, + std::optional acLimitW, std::optional chuteNeige, + bool producteurPresent); + + /*! + * \brief Un échantillon d'une minute du bilan de puissance du site. + * \param finMinute Horodatage de l'échantillon = FIN de la minute qu'il moyenne. + * \param productionW Production du bilan, convention nymea (NÉGATIVE quand on produit). + */ + void observerProduction(const QDateTime &finMinute, double productionW); + + //! \brief Lignes effectivement insérées depuis l'ouverture. \return Compteur, lu sans verrou. + int lignesEcrites() const { return m_ecrites.loadAcquire(); } + //! \brief Lots traités par le fil d'écriture. \return Compteur, lu sans verrou. + int lotsTraites() const { return m_lots.loadAcquire(); } + //! \brief La table est-elle utilisable ? \return Faux si le fichier n'est pas une base. + bool isValid() const { return m_valide.loadAcquire() != 0; } + +private: + struct Cle { + QString siteId; + qint64 targetAt; + bool operator<(const Cle &o) const { + return targetAt != o.targetAt ? targetAt < o.targetAt : siteId < o.siteId; + } + }; + void finaliser(const QDateTime &now); + + QTimeZone m_fuseau; + qint64 m_debutS = 0; // premier cycle : rien d'antérieur n'est écrit + // (site, pas) → plan → la DERNIÈRE lecture de ce plan pour ce pas, ligne partiellement + // remplie. Le tampon survit au glissement de la fenêtre du producteur : à minuit, le pas de + // 23 h quitte la charge utile avant d'avoir été écrit. + QMap> m_tampon; + QMap m_attendus; // plans attendus pour (site, pas) + // (site, plan) → début du dernier pas ÉCRIT. La charge utile garde les heures passées du + // jour : sans ce filigrane, un pas écrit serait repris au cycle suivant, réécrit pour rien + // et rejournalisé chaque minute. + QHash m_ecritJusqua; + QMap m_minutes; // fin de minute (s) → production (W, nymea) + QMap m_neige; // instant (s) → snowfall lu + bool m_producteur = false; + bool m_sansProducteurSignale = false; + + QThread *m_fil = nullptr; + ForecastLearningWriter *m_ecrivain = nullptr; + QAtomicInt m_ecrites; + QAtomicInt m_lots; + QAtomicInt m_valide; +}; diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index 4469416..1b5d7dd 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -1,6 +1,13 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "etm/forecast/forecaststore.h" +#include "etm/forecast/forecastlearninglog.h" +#include +#include +#include +#include +#include +#include #include #include "testloadmodel.h" @@ -1269,3 +1276,470 @@ void TestLoadModel::testTheCurrentCurveIsTheLastSeenNotTheLastNew() QVERIFY2(vue.first().firstSeenAt < vue.first().lastSeenAt, "revenir à un contenu avance sa fraîcheur, jamais son apparition"); } + +// ============================================================================================ +// Brief 3 T2 — l'enregistrement pour l'apprentissage futur +// ============================================================================================ + +namespace { +const QTimeZone Paris("Europe/Paris"); + +QDateTime utc(int y, int mo, int d, int h, int mi = 0, int s = 0) +{ + return QDateTime(QDate(y, mo, d), QTime(h, mi, s), QTimeZone::utc()); +} + +//! Lit la table par une connexion À NOUS, pas celle du fil d'écriture : on vérifie ce qui est +//! sur le disque, pas ce que l'écrivain croit avoir écrit. +QList lignesApprentissage(const QString &chemin, const QString &where = QString()) +{ + QList sortie; + const QString nom = QStringLiteral("lecture-%1").arg(QUuid::createUuid().toString()); + { + QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), nom); + db.setDatabaseName(chemin); + if (db.open()) { + QSqlQuery q(db); + if (q.exec(QStringLiteral("SELECT * FROM forecastLearning %1 ORDER BY targetAt, planId") + .arg(where.isEmpty() ? QString() : QStringLiteral("WHERE ") + where))) { + while (q.next()) { + QVariantMap m; + const QSqlRecord r = q.record(); + for (int i = 0; i < r.count(); ++i) + m.insert(r.fieldName(i), q.value(i)); + sortie << m; + } + } + db.close(); + } + } + QSqlDatabase::removeDatabase(nom); + return sortie; +} + +bool executer(const QString &chemin, const QStringList &requetes) +{ + const QString nom = QStringLiteral("ecriture-%1").arg(QUuid::createUuid().toString()); + bool ok = true; + { + QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), nom); + db.setDatabaseName(chemin); + ok = db.open(); + QSqlQuery q(db); + for (const QString &r : requetes) + ok = ok && q.exec(r); + db.close(); + } + QSqlDatabase::removeDatabase(nom); + return ok; +} + +//! Une observation de plan, telle que l'arbitre la remet : charge utile + paramètres du Thing. +ForecastPlanObservation planObserve(const QString &id, int v, const QDateTime &t0, + const QVariantList &p, const QVariantMap ¶ms) +{ + QVariantMap c{{"v", v}, {"t0", t0.toSecsSinceEpoch()}, {"step", 3600}, {"unit", "W"}, + {"convention", "mean over [t, t+step)"}, {"model", "meteofrance_seamless"}, + {"issuedAt", t0.toSecsSinceEpoch() - 3600}, {"p", p}}; + if (v >= 2) { + c["corrections"] = QVariantList{"temperature"}; + QVariantList gti, temp; + for (int i = 0; i < p.count(); ++i) { gti << 100.0 * (i + 1); temp << 10.0 + i; } + temp[1] = QVariant(); // un trou : NULL, jamais 0 + c["gti"] = gti; c["tempC"] = temp; + } + QString refus; + ForecastPlanObservation o = ForecastLearningLog::lirePlan(c, params, refus); + o.planId = id; + return o; +} + +//! Injecte une minute de production par minute sur ]de, a] — convention nymea, NÉGATIVE. +void minutes(ForecastLearningLog &log, const QDateTime &de, const QDateTime &a, double produitW) +{ + for (QDateTime t = de.addSecs(60); t <= a; t = t.addSecs(60)) + log.observerProduction(t, -produitW); +} +} + +void TestLoadModel::testTheLearningLogWritesOneRowPerElapsedStepAndPlan() +{ + QTemporaryDir dir; + const QString chemin = dir.filePath("energy-forecast.sqlite"); + ForecastStore magasin(chemin); // même fichier, comme en production + QVERIFY(magasin.isValid()); + ForecastLearningLog log(chemin, Paris); + + const QDateTime t0 = utc(2026, 10, 13, 9); // grille : 09:00, 10:00, 11:00 + const QVariantMap params{{"kwp", 2.0}, {"performanceRatio", 0.9}, + {"tempCoeffPctPerK", -0.37}, {"noctC", 45.0}}; + const QList plans{ + planObserve("{plan-v2}", 2, t0, {300, 1200, 1500}, params), + planObserve("{plan-v1}", 1, t0, {250, 1000, 1400}, params), // MÊMES paramètres posés + }; + QCOMPARE(plans.at(0).pW.count(), 3); + QCOMPARE(plans.at(1).pW.count(), 3); + const QString site = QStringLiteral("{site}"); + + // Premier cycle à 10:05 : le pas de 09:00 est ÉCOULÉ AVANT qu'on regarde — jamais écrit. + log.observer(utc(2026, 10, 13, 10, 5), site, plans, 2, 3000, std::nullopt, true); + minutes(log, utc(2026, 10, 13, 10), utc(2026, 10, 13, 11), 1500); + log.observer(utc(2026, 10, 13, 10, 40), site, plans, 2, 3000, std::nullopt, true); + // 11:01 : le pas est fini, mais la grâce (dernière minute en route) ne l'est pas. + log.observer(utc(2026, 10, 13, 11, 1), site, plans, 2, 3000, std::nullopt, true); + QCOMPARE(log.lotsTraites(), 0); + log.observer(utc(2026, 10, 13, 11, 3), site, plans, 2, 3000, std::nullopt, true); + QTRY_COMPARE(log.lotsTraites(), 1); + QCOMPARE(log.lignesEcrites(), 2); + + const QList l = lignesApprentissage(chemin); + QCOMPARE(l.count(), 2); // UN pas, DEUX plans — rien d'autre + const QVariantMap v1 = l.at(0), v2 = l.at(1); // tri par planId : v1 < v2 + QCOMPARE(v1.value("planId").toString(), QString("{plan-v1}")); + QCOMPARE(v2.value("planId").toString(), QString("{plan-v2}")); + for (const QVariantMap &r : l) { + QCOMPARE(r.value("targetAt").toLongLong(), qint64(1791885600)); // oracle hors Qt + QCOMPARE(r.value("stepS").toInt(), 3600); + QCOMPARE(r.value("siteId").toString(), site); + QCOMPARE(r.value("kwp").toDouble(), 2.0); + QCOMPARE(r.value("performanceRatio").toDouble(), 0.9); + QCOMPARE(r.value("acLimitW").toInt(), 3000); + QCOMPARE(r.value("measuredW").toDouble(), 1500.0); // positive : le site PRODUIT + QCOMPARE(r.value("measuredSource").toString(), QString("power-balance-1min")); + QCOMPARE(r.value("measuredMinutes").toInt(), 60); + QCOMPARE(r.value("curtailed").toInt(), 0); // plafond déclaré, jamais atteint + QVERIFY2(r.value("snow").isNull(), "aucune lecture de neige : NULL, pas « pas de neige »"); + QCOMPARE(r.value("sitePlans").toInt(), 2); + QCOMPARE(r.value("sitePlansExpected").toInt(), 2); + QCOMPARE(r.value("valid").toInt(), 1); + // J−1 18:00 à PARIS pour un pas de 10:00Z le 13 : le 12 à 16:00Z (oracle hors Qt). + QCOMPARE(r.value("dayAheadAt").toLongLong(), qint64(1791820800)); + QCOMPARE(r.value("issuedAt").toLongLong(), t0.toSecsSinceEpoch() - 3600); + QCOMPARE(r.value("observedAt").toLongLong(), utc(2026, 10, 13, 11, 3).toMSecsSinceEpoch()); + } + // v2 : la valeur DU PAS (index 1), pas celle d'un voisin. + QCOMPARE(v2.value("pW").toDouble(), 1200.0); + QCOMPARE(v2.value("gti").toDouble(), 200.0); + QVERIFY2(v2.value("tempC").isNull(), "un tempC null dans la charge utile reste NULL"); + QCOMPARE(v2.value("corrections").toString(), QString("temperature")); + QCOMPARE(v2.value("transportV").toInt(), 2); + QCOMPARE(v2.value("tempCoeffPctPerK").toDouble(), -0.37); + QCOMPARE(v2.value("noctC").toDouble(), 45.0); + // v1 : gti, tempC et paramètres v2 NULL — JAMAIS 0 — alors que le Thing les porte. + QCOMPARE(v1.value("pW").toDouble(), 1000.0); + QCOMPARE(v1.value("transportV").toInt(), 1); + QCOMPARE(v1.value("corrections").toString(), QString()); + for (const char *c : {"gti", "tempC", "tempCoeffPctPerK", "noctC"}) + QVERIFY2(v1.value(c).isNull(), c); + + // Le pas de 11:00 n'est pas écoulé : rien. + QCOMPARE(lignesApprentissage(chemin, "targetAt = 1791889200").count(), 0); + + // Les cycles SUIVANTS revoient le pas de 10:00 dans la charge utile — elle garde les heures + // passées du jour. Un fait écrit ne se reprend pas : aucun lot, aucune ligne de journal par + // minute. (Trouvé par le test de la table abîmée : sans filigrane, chaque cycle le réécrivait.) + log.observer(utc(2026, 10, 13, 11, 10), site, plans, 2, 3000, std::nullopt, true); + log.observer(utc(2026, 10, 13, 11, 20), site, plans, 2, 3000, std::nullopt, true); + QTest::qWait(100); + QCOMPARE(log.lotsTraites(), 1); + + // Un SECOND enregistreur sur le même fichier — un redémarrage qui revoit la même heure — + // ne réécrit pas le fait clos : la première écriture, qui a vu le pas, l'emporte. + { + ForecastLearningLog relance(chemin, Paris); + QList autres = plans; + autres[0].pW[1] = 9999.0; + relance.observer(utc(2026, 10, 13, 10, 59), site, autres, 2, 3000, std::nullopt, true); + relance.observer(utc(2026, 10, 13, 11, 3), site, autres, 2, 3000, std::nullopt, true); + QTRY_COMPARE(relance.lotsTraites(), 1); + QCOMPARE(relance.lignesEcrites(), 0); + } + QCOMPARE(lignesApprentissage(chemin, "planId = '{plan-v2}'").first().value("pW").toDouble(), 1200.0); +} + +void TestLoadModel::testTheLearningLogReadsMeasureCurtailmentAndSnow() +{ + const QDateTime t0 = utc(2026, 10, 13, 10); + const QVariantMap params{{"kwp", 2.0}, {"performanceRatio", 0.9}}; + struct Cas { + const char *nom; + int minutesRecues; // minutes injectées à partir de 10:01 + double produitW; // production de chaque minute + double picW; // production de la minute 10:30 (le pic) + std::optional plafond; + std::optional neige; + bool producteur; + int plansVus, plansAttendus; + // attendus + bool mesure; QVariant curtailed; QVariant snow; int valid; + }; + const QList cas{ + // couverture complète, plafond non atteint : la référence + {"nominal", 60, 1200, 1200, 3000, 0.0, true, 1, 1, true, 0, 0, 1}, + // 30 minutes sur 60 : pas de moyenne, et l'écrêtage n'a pas pu être cherché + {"couverture partielle", 30, 1200, 1200, 3000, 0.0, true, 1, 1, false, {}, 0, 0}, + // 54 minutes sur 60 = 90 % : publiée, avec le compte + {"couverture au seuil", 54, 1200, 1200, 3000, 0.0, true, 1, 1, true, 0, 0, 1}, + // une minute à 98 % du plafond : l'onduleur bornait, la mesure sous-estime + {"écrêtage détecté", 60, 1200, 2941, 3000, 0.0, true, 1, 1, true, 1, 0, 0}, + // juste sous le seuil : rien de DÉTECTABLE + {"sous le seuil", 60, 1200, 2930, 3000, 0.0, true, 1, 1, true, 0, 0, 1}, + // sans plafond déclaré, rien n'est cherché : NULL, jamais « non écrêté » + {"sans plafond", 60, 1200, 2999, {}, 0.0, true, 1, 1, true, {}, 0, 1}, + // sans producteur, la « production » est un zéro de construction : NULL + {"sans producteur", 60, 1200, 1200, 3000, 0.0, false, 1, 1, false, {}, 0, 0}, + // une chute de neige lue pendant le pas + {"neige", 60, 1200, 1200, 3000, 0.4, true, 1, 1, true, 0, 1, 0}, + // le site n'a pas d'état snowfall : NULL + {"neige non lue", 60, 1200, 1200, 3000, {}, true, 1, 1, true, 0, {}, 1}, + // un plan du site refusé : la somme mesurée n'est pas comparable à ce plan seul + {"plan manquant", 60, 1200, 1200, 3000, 0.0, true, 1, 2, true, 0, 0, 0}, + }; + for (const Cas &c : cas) { + QTemporaryDir dir; + const QString chemin = dir.filePath("energy-forecast.sqlite"); + ForecastLearningLog log(chemin, Paris); + const QList plans{ + planObserve("{p}", 2, t0, {1000, 1000}, params)}; + log.observer(t0.addSecs(30), "{s}", plans, c.plansAttendus, c.plafond, c.neige, c.producteur); + for (int m = 1; m <= c.minutesRecues; ++m) + log.observerProduction(t0.addSecs(60 * m), -(m == 30 ? c.picW : c.produitW)); + log.observer(t0.addSecs(1800), "{s}", plans, c.plansAttendus, c.plafond, c.neige, c.producteur); + log.observer(t0.addSecs(3600 + 150), "{s}", plans, c.plansAttendus, c.plafond, std::nullopt, + c.producteur); + QTRY_COMPARE(log.lotsTraites(), 1); + const QList l = lignesApprentissage(chemin); + QVERIFY2(l.count() == 1, c.nom); + const QVariantMap r = l.first(); + QVERIFY2(r.value("measuredMinutes").toInt() == c.minutesRecues, c.nom); + QVERIFY2(r.value("measuredW").isNull() == !c.mesure, c.nom); + QVERIFY2(r.value("measuredSource").isNull() == !c.mesure, c.nom); + QVERIFY2(r.value("curtailed").isNull() == c.curtailed.isNull() + && (c.curtailed.isNull() || r.value("curtailed").toInt() == c.curtailed.toInt()), + qPrintable(QStringLiteral("%1 : curtailed %2").arg(c.nom, r.value("curtailed").toString()))); + QVERIFY2(r.value("snow").isNull() == c.snow.isNull() + && (c.snow.isNull() || r.value("snow").toInt() == c.snow.toInt()), c.nom); + QVERIFY2(r.value("valid").toInt() == c.valid, c.nom); + QVERIFY2(r.value("sitePlans").toInt() == c.plansVus, c.nom); + // Une v2 dont le Thing ne porte PAS encore tempCoeff/noct (openmeteo d'avant le brief 2) : + // NULL, jamais un défaut inventé ici. + QVERIFY2(r.value("tempCoeffPctPerK").isNull() && r.value("noctC").isNull(), c.nom); + QVERIFY2(r.value("kwp").toDouble() == 2.0, c.nom); + } +} + +void TestLoadModel::testTheLearningLogPointsAtTheRevisionsInForce() +{ + // La table RENVOIE aux révisions du site par leur CLÉ DE CONTENU (§20), jamais par + // `issuedAt`. Les révisions sont posées à la main, avec des instants d'observation choisis : + // c'est la règle « en vigueur à X » qu'on éprouve, pas l'horloge. + QTemporaryDir dir; + const QString chemin = dir.filePath("energy-forecast.sqlite"); + { ForecastStore magasin(chemin); QVERIFY(magasin.isValid()); } // crée forecastPv + const QString site = QStringLiteral("{site}"); + const qint64 pas10 = utc(2026, 10, 13, 10).toSecsSinceEpoch(); + const qint64 pas9 = utc(2026, 10, 13, 9).toSecsSinceEpoch(); + auto ms = [](const QDateTime &d) { return QString::number(d.toMSecsSinceEpoch()); }; + auto rev = [&](qint64 cible, const QString &id, const QDateTime &premier, const QDateTime &dernier, + const QString &s = QStringLiteral("{site}")) { + return QStringLiteral("INSERT INTO forecastPv (targetAt, siteId, contentId, firstSeenAt, " + "lastSeenAt, forecastWh) VALUES (%1, '%2', '%3', %4, %5, 1000)") + .arg(cible).arg(s, id, ms(premier), ms(dernier)); + }; + QVERIFY(executer(chemin, { + // Pas de 10:00. Veille = 12/10 16:00Z ; H−1 = 13/10 09:00Z. + // A : vue de 06:00 la veille, REVUE le 13 à 07:00 (un retour, cas du §23.2). + rev(pas10, "A", utc(2026, 10, 12, 6), utc(2026, 10, 13, 7)), + // B : apparue 14:30 la veille, vue jusqu'à 17:00 — c'est ELLE qui avait cours à 16:00. + rev(pas10, "B", utc(2026, 10, 12, 14, 30), utc(2026, 10, 12, 17)), + // C : apparue 08:30 le 13, vue jusqu'à 11:00 — en vigueur à 09:00. + rev(pas10, "C", utc(2026, 10, 13, 8, 30), utc(2026, 10, 13, 11)), + // Un AUTRE site, apparu plus tard : ne doit jamais être désigné. + rev(pas10, "Z", utc(2026, 10, 13, 8, 45), utc(2026, 10, 13, 11), "{autre}"), + // Pas de 09:00. H−1 = 08:00Z : AUCUNE révision n'était vue à cet instant (B finit à + // 07:00, C commence à 08:30) — la dernière vue avant, B, est celle qui avait cours. + rev(pas9, "B9", utc(2026, 10, 12, 14), utc(2026, 10, 13, 7)), + rev(pas9, "C9", utc(2026, 10, 13, 8, 30), utc(2026, 10, 13, 10)), + })); + + ForecastLearningLog log(chemin, Paris); + const QDateTime t0 = utc(2026, 10, 13, 9); + const QList plans{ + planObserve("{p}", 1, t0, {800, 900, 1000}, {{"kwp", 2.0}})}; + log.observer(utc(2026, 10, 13, 9, 30), site, plans, 1, std::nullopt, std::nullopt, true); + log.observer(utc(2026, 10, 13, 11, 5), site, plans, 1, std::nullopt, std::nullopt, true); + QTRY_COMPARE(log.lotsTraites(), 1); + const QList l = lignesApprentissage(chemin); + QCOMPARE(l.count(), 2); + QCOMPARE(l.at(0).value("targetAt").toLongLong(), pas9); + QCOMPARE(l.at(0).value("contentIdHourAhead").toString(), QString("B9")); + // Veille du pas de 09:00 = 12/10 16:00Z : seule B9 était apparue (14:00) et encore vue. + QCOMPARE(l.at(0).value("contentIdDayAhead").toString(), QString("B9")); + QCOMPARE(l.at(1).value("targetAt").toLongLong(), pas10); + // `MAX(lastSeenAt)` — la règle de la courbe COURANTE — aurait rendu A ici. + QCOMPARE(l.at(1).value("contentIdDayAhead").toString(), QString("B")); + QCOMPARE(l.at(1).value("contentIdHourAhead").toString(), QString("C")); + + // Aucune révision pour un pas : NULL — jamais un identifiant deviné. + QTemporaryDir vide; + const QString cheminVide = vide.filePath("energy-forecast.sqlite"); + ForecastLearningLog log2(cheminVide, Paris); + log2.observer(utc(2026, 10, 13, 9, 30), site, plans, 1, std::nullopt, std::nullopt, true); + log2.observer(utc(2026, 10, 13, 10, 5), site, plans, 1, std::nullopt, std::nullopt, true); + QTRY_COMPARE(log2.lotsTraites(), 1); + const QList l2 = lignesApprentissage(cheminVide); + QCOMPARE(l2.count(), 1); + QVERIFY(l2.first().value("contentIdDayAhead").isNull()); + QVERIFY(l2.first().value("contentIdHourAhead").isNull()); + + // « J−1 18:00 locale » autour des changements d'heure — oracles calculés hors de Qt. + QCOMPARE(ForecastLearningLog::instantVeille(utc(2026, 10, 12, 22, 30), Paris).toSecsSinceEpoch(), + qint64(1791820800)); // 00:30 locale le 13 : la veille est le 12, pas le 11 + QCOMPARE(ForecastLearningLog::instantVeille(utc(2026, 10, 25, 10), Paris).toSecsSinceEpoch(), + qint64(1792857600)); // jour de 25 h : la veille est encore en heure d'été + QCOMPARE(ForecastLearningLog::instantVeille(utc(2026, 10, 25, 23, 30), Paris).toSecsSinceEpoch(), + qint64(1792947600)); // le 26 local : 18:00 du 25, déjà en heure d'hiver + QCOMPARE(ForecastLearningLog::instantVeille(utc(2026, 3, 29, 10), Paris).toSecsSinceEpoch(), + qint64(1774717200)); // jour de 23 h : la veille est encore en heure d'hiver +} + +void TestLoadModel::testTheLearningLogSurvivesADamagedTable() +{ + const QDateTime t0 = utc(2026, 10, 13, 10); + const QList plans{ + planObserve("{p}", 1, t0, {800, 900, 1000}, {{"kwp", 2.0}})}; + auto pas = [&](ForecastLearningLog &log, int i) { + const QDateTime d = t0.addSecs(3600 * i); + log.observer(d.addSecs(60), "{s}", plans, 1, std::nullopt, std::nullopt, true); + log.observer(d.addSecs(3600 + 150), "{s}", plans, 1, std::nullopt, std::nullopt, true); + }; + + // 1. UNE TABLE DE FORME INATTENDUE — une version future, une écriture étrangère : REFAITE. + { + QTemporaryDir dir; + const QString chemin = dir.filePath("energy-forecast.sqlite"); + QVERIFY(executer(chemin, {"CREATE TABLE forecastLearning (x INTEGER, y TEXT)", + "INSERT INTO forecastLearning VALUES (1, 'étranger')"})); + ForecastLearningLog log(chemin, Paris); + pas(log, 0); + QTRY_COMPARE(log.lotsTraites(), 1); + QVERIFY(log.isValid()); + const QList l = lignesApprentissage(chemin); + QCOMPARE(l.count(), 1); + QVERIFY(l.first().contains("contentIdDayAhead")); // la forme attendue, refaite + + // 2. LA TABLE DISPARAÎT entre deux lots : recréée à l'écriture, lot REJOUÉ, rien perdu + // de ce lot-là — et le service n'a rien vu. + QVERIFY(executer(chemin, {"DROP TABLE forecastLearning"})); + pas(log, 1); + QTRY_COMPARE(log.lotsTraites(), 2); + QCOMPARE(lignesApprentissage(chemin).count(), 1); + QCOMPARE(log.lignesEcrites(), 2); + + // 3. RÉTENTION 400 jours — sur l'instant du CYCLE, pas sur l'horloge murale. + const qint64 vieux = t0.addDays(-401).toSecsSinceEpoch(); + const qint64 garde = t0.addDays(-399).toSecsSinceEpoch(); + QString ins = QStringLiteral( + "INSERT INTO forecastLearning (targetAt, stepS, siteId, planId, corrections, transportV, " + "observedAt, sitePlans, sitePlansExpected, dayAheadAt, measuredMinutes, valid, writtenAt) " + "VALUES (%1, 3600, '{s}', '{p}', '', 1, 0, 1, 1, 0, 0, 0, 0)"); + QVERIFY(executer(chemin, {ins.arg(vieux), ins.arg(garde)})); + pas(log, 2); + QTRY_COMPARE(log.lotsTraites(), 3); + QCOMPARE(lignesApprentissage(chemin, QStringLiteral("targetAt = %1").arg(vieux)).count(), 0); + QCOMPARE(lignesApprentissage(chemin, QStringLiteral("targetAt = %1").arg(garde)).count(), 1); + } + + // 4. UNE TABLE ABÎMÉE sur le disque : la vérification à l'ouverture la voit, elle est refaite. + { + QTemporaryDir dir; + const QString chemin = dir.filePath("energy-forecast.sqlite"); + QStringList remplir{"PRAGMA page_size = 4096", QString::fromLatin1( + "CREATE TABLE forecastLearning (targetAt INTEGER NOT NULL, stepS INTEGER NOT NULL, " + "siteId TEXT NOT NULL, planId TEXT NOT NULL, gti REAL, tempC REAL, pW REAL, " + "corrections TEXT NOT NULL, transportV INTEGER NOT NULL, kwp REAL, performanceRatio REAL, " + "tempCoeffPctPerK REAL, noctC REAL, acLimitW INTEGER, issuedAt INTEGER, model TEXT, " + "observedAt INTEGER NOT NULL, sitePlans INTEGER NOT NULL, sitePlansExpected INTEGER NOT NULL, " + "dayAheadAt INTEGER NOT NULL, contentIdDayAhead TEXT, contentIdHourAhead TEXT, " + "measuredW REAL, measuredSource TEXT, measuredMinutes INTEGER NOT NULL, curtailed INTEGER, " + "snow INTEGER, valid INTEGER NOT NULL, writtenAt INTEGER NOT NULL, " + "PRIMARY KEY (targetAt, siteId, planId))")}; + for (int i = 0; i < 400; ++i) + remplir << QStringLiteral( + "INSERT INTO forecastLearning (targetAt, stepS, siteId, planId, corrections, " + "transportV, observedAt, sitePlans, sitePlansExpected, dayAheadAt, measuredMinutes, " + "valid, writtenAt) VALUES (%1, 3600, '{s}', '{p%2}', '', 1, 0, 1, 1, 0, 0, 0, 0)") + .arg(i).arg(i); + QVERIFY(executer(chemin, remplir)); + // On écrase une page de DONNÉES de la table — pas l'en-tête du fichier, ni le schéma. + QFile f(chemin); + QVERIFY(f.open(QIODevice::ReadWrite)); + QVERIFY(f.size() >= 4 * 4096); + f.seek(3 * 4096); + f.write(QByteArray(4096, char(0x5a))); + f.close(); + + ForecastLearningLog log(chemin, Paris); + pas(log, 0); + QTRY_COMPARE(log.lotsTraites(), 1); + QVERIFY(log.isValid()); + QCOMPARE(log.lignesEcrites(), 1); + const QList l = lignesApprentissage(chemin); + QCOMPARE(l.count(), 1); // l'ancien contenu est perdu, et dit + QCOMPARE(l.first().value("planId").toString(), QString("{p}")); + } + + // 5. UN FICHIER QUI N'EST PAS UNE BASE : l'enregistrement s'arrête, rien ne plante, le + // cycle continue d'appeler sans effet. + { + QTemporaryDir dir; + const QString chemin = dir.filePath("energy-forecast.sqlite"); + QFile f(chemin); + QVERIFY(f.open(QIODevice::WriteOnly)); + f.write(QByteArray(8192, char(0x42))); + f.close(); + ForecastLearningLog log(chemin, Paris); + pas(log, 0); + QTRY_COMPARE(log.lotsTraites(), 1); + QVERIFY(!log.isValid()); + QCOMPARE(log.lignesEcrites(), 0); + } +} + +void TestLoadModel::testTheLearningLogNeverBlocksTheCycle() +{ + // La base est VERROUILLÉE en écriture par une autre connexion pendant que le cycle remet un + // lot. L'appel rend la main tout de suite ; la ligne arrive quand le verrou tombe. + QTemporaryDir dir; + const QString chemin = dir.filePath("energy-forecast.sqlite"); + const QDateTime t0 = utc(2026, 10, 13, 10); + const QList plans{ + planObserve("{p}", 1, t0, {800, 900}, {{"kwp", 2.0}})}; + ForecastLearningLog log(chemin, Paris); + log.observer(t0.addSecs(60), "{s}", plans, 1, std::nullopt, std::nullopt, true); + // Le fil a ouvert la base et créé la table : sinon le verrou ci-dessous gênerait l'ouverture, + // pas l'écriture, et le test mesurerait autre chose. + QTRY_VERIFY(executer(chemin, {"SELECT 1 FROM forecastLearning LIMIT 0"})); + + const QString nom = QStringLiteral("verrou"); + { + QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), nom); + db.setDatabaseName(chemin); + QVERIFY(db.open()); + QSqlQuery q(db); + QVERIFY(q.exec(QStringLiteral("BEGIN EXCLUSIVE"))); + + QElapsedTimer chrono; + chrono.start(); + log.observer(t0.addSecs(3600 + 150), "{s}", plans, 1, std::nullopt, std::nullopt, true); + const qint64 duree = chrono.elapsed(); + QVERIFY2(duree < 50, qPrintable(QStringLiteral("observer() a bloqué %1 ms").arg(duree))); + QTest::qWait(300); + QCOMPARE(log.lignesEcrites(), 0); // le fil attend le verrou, pas nous + + QVERIFY(q.exec(QStringLiteral("COMMIT"))); + db.close(); + } + QSqlDatabase::removeDatabase(nom); + QTRY_COMPARE(log.lignesEcrites(), 1); +} diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index 371ced1..df7fead 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -99,6 +99,17 @@ private slots: void testTransportV2IsReadAndAnUnknownCorrectionRefused(); void testTheSiteCurveIsClippedAfterTheSum(); + //! Brief 3 T2 — une ligne par pas ÉCOULÉ et par plan ; v1 laisse gti/tempC/paramètres v2 NULL. + void testTheLearningLogWritesOneRowPerElapsedStepAndPlan(); + //! Mesure, écrêtage, neige : LUS ou NULL, jamais devinés. + void testTheLearningLogReadsMeasureCurtailmentAndSnow(); + //! « J−1 18:00 locale » et H−1 : la révision du site en vigueur, par sa clé de contenu. + void testTheLearningLogPointsAtTheRevisionsInForce(); + //! Base abîmée : table refaite, journalisé, le service continue ; rétention 400 jours. + void testTheLearningLogSurvivesADamagedTable(); + //! L'écriture ne bloque jamais l'appelant, même base verrouillée. + void testTheLearningLogNeverBlocksTheCycle(); + }; #endif // TESTLOADMODEL_H