diff --git a/energyplugin/etm/forecast/forecastlearninglog.cpp b/energyplugin/etm/forecast/forecastlearninglog.cpp index 1601003..b74c98c 100644 --- a/energyplugin/etm/forecast/forecastlearninglog.cpp +++ b/energyplugin/etm/forecast/forecastlearninglog.cpp @@ -34,9 +34,23 @@ const QStringList Colonnes = { QStringLiteral("contentIdHourAhead"), QStringLiteral("measuredW"), QStringLiteral("measuredSource"), QStringLiteral("measuredMinutes"), QStringLiteral("curtailed"), QStringLiteral("snow"), QStringLiteral("valid"), - QStringLiteral("writtenAt") + QStringLiteral("writtenAt"), + // etm68 — ajoutées EN FIN par migration (ALTER TABLE ADD COLUMN, voir AjoutsEtm68). + QStringLiteral("gtiObserved"), QStringLiteral("gtiObservedSource"), + QStringLiteral("gtiObservedAt") }; +//! Les colonnes ajoutées après etm67, avec leur type. Toutes NULLABLES, sans défaut : une table +//! etm67 les reçoit par `ALTER TABLE … ADD COLUMN`, ses lignes restent, les nouvelles cases +//! valent NULL — « inconnu », exactement ce qu'elles étaient. +const QList> AjoutsEtm68 = { + {QStringLiteral("gtiObserved"), QStringLiteral("REAL")}, + {QStringLiteral("gtiObservedSource"), QStringLiteral("TEXT")}, + {QStringLiteral("gtiObservedAt"), QStringLiteral("INTEGER")}, +}; +//! La forme EXACTE d'une table etm67 : la seule qu'on migre. Toute autre forme est refaite. +const QStringList ColonnesEtm67 = Colonnes.mid(0, Colonnes.count() - AjoutsEtm68.count()); + 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. @@ -76,6 +90,11 @@ const char *Creation = " snow INTEGER," // chute de neige LUE sur le site · NULL " valid INTEGER NOT NULL," " writtenAt INTEGER NOT NULL," // ms + // CE QUE LE SATELLITE A VU sur le plan — GTI observé, W/m². Arrive APRÈS l'écriture du pas : + // seules colonnes complétées plus tard (UPDATE ciblé, si NULL). NULL = inconnu, jamais 0. + " gtiObserved REAL," + " gtiObservedSource TEXT," + " gtiObservedAt INTEGER," // observedGtiTime tel que publié, epoch s " PRIMARY KEY (targetAt, siteId, planId))"; QVariant nul(QMetaType::Type t) { return QVariant(QMetaType(t)); } @@ -123,8 +142,9 @@ 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) {} + QAtomicInt *complements, QAtomicInt *completes, QAtomicInt *valide) + : m_chemin(chemin), m_ecrites(ecrites), m_lots(lots), m_complements(complements) + , m_completes(completes), m_valide(valide) {} void ouvrir() { @@ -190,9 +210,93 @@ public: purger(nowS); } m_lots->fetchAndAddRelease(1); + // Des GTI observés attendaient peut-être ces lignes (lot retenu par un verrou). + appliquerComplements(); + } + + /*! + * GTI observés arrivés APRÈS l'écriture de leur pas. Chacun complète SA ligne, s'il la + * trouve et si elle n'en a pas encore — et rien d'autre. + */ + void completer(const QList &obs) + { + m_complementsEnAttente += obs; + appliquerComplements(); } private: + void appliquerComplements() + { + if (m_complementsEnAttente.isEmpty()) + return; + if (!m_valide->loadAcquire()) { + m_complements->fetchAndAddRelease(m_complementsEnAttente.count()); + m_complementsEnAttente.clear(); + return; + } + // Les lignes qu'ils complètent sont peut-être dans un lot retenu par un verrou : on les + // laisse passer d'abord, sinon l'UPDATE ne trouverait rien et l'observation serait perdue. + if (!m_enAttente.isEmpty()) + return; + if (!m_db.transaction()) { + qCWarning(dcNymeaEnergy()) << "[Prévision] apprentissage : GTI observé remis à plus tard —" + << m_db.lastError().text(); + return; + } + QSqlQuery q(m_db); + // L'EXCEPTION ÉCRITE à « une ligne n'est jamais réécrite » (§31.7) : trois colonnes, et + // seulement quand la première est NULL. Une valeur écrite n'est jamais remplacée ; aucun + // autre champ — valid, writtenAt, la mesure — n'est touché. + q.prepare(QStringLiteral( + "UPDATE forecastLearning SET gtiObserved = ?, gtiObservedSource = ?, gtiObservedAt = ? " + "WHERE targetAt = ? AND siteId = ? AND planId = ? AND stepS = ? AND gtiObserved IS NULL")); + QList effets; + for (const ForecastLearningRow &o : std::as_const(m_complementsEnAttente)) { + q.addBindValue(ouNul(o.gtiObserved, QMetaType::Double)); + q.addBindValue(ouNul(o.gtiObservedSource)); + q.addBindValue(ouNul(o.gtiObservedAt, QMetaType::LongLong)); + q.addBindValue(o.targetAt); + q.addBindValue(o.siteId); + q.addBindValue(o.planId); + q.addBindValue(o.stepS); + if (!q.exec()) { + const QString erreur = q.lastError().text(); + const int code = q.lastError().nativeErrorCode().toInt(); + m_db.rollback(); + if (code == 5 || code == 6) + return; // OCCUPÉE : on réessaiera au prochain lot + qCWarning(dcNymeaEnergy()) << "[Prévision] apprentissage :" + << m_complementsEnAttente.count() + << "GTI observé(s) PERDU(S) —" << erreur; + m_complements->fetchAndAddRelease(m_complementsEnAttente.count()); + m_complementsEnAttente.clear(); + return; + } + effets << (q.numRowsAffected() > 0 ? 1 : 0); + } + if (!m_db.commit()) { + m_db.rollback(); + return; + } + for (int i = 0; i < m_complementsEnAttente.count(); ++i) { + const ForecastLearningRow &o = m_complementsEnAttente.at(i); + qCInfo(dcNymeaEnergy()).noquote() + << QStringLiteral("[Prévision] apprentissage — GTI observé, pas %1 plan %2 : %3 W/m² " + "(publié pour %4) — %5") + .arg(QDateTime::fromSecsSinceEpoch(o.targetAt, QTimeZone::utc()).toString(Qt::ISODate), + o.planId.left(9)) + .arg(o.gtiObserved ? *o.gtiObserved : 0.0, 0, 'f', 0) + .arg(QDateTime::fromSecsSinceEpoch(o.gtiObservedAt.value_or(0), QTimeZone::utc()) + .toString(Qt::ISODate), + effets.at(i) ? QStringLiteral("ligne complétée") + : QStringLiteral("aucune ligne à compléter (absente, autre " + "pas, ou déjà renseignée)")); + m_completes->fetchAndAddRelease(effets.at(i)); + } + m_complements->fetchAndAddRelease(m_complementsEnAttente.count()); + m_complementsEnAttente.clear(); + } + //! \return 0 si tout est écrit ; sinon le code SQLite natif de l'échec (≥ 1). int inserer(const QList &lignes, int *inserees, QString *erreur) { @@ -246,6 +350,9 @@ private: q.addBindValue(ouNul(l.snow)); q.addBindValue(int(l.valid)); q.addBindValue(ecrit); + q.addBindValue(ouNul(l.gtiObserved, QMetaType::Double)); + q.addBindValue(ouNul(l.gtiObservedSource)); + q.addBindValue(ouNul(l.gtiObservedAt, QMetaType::LongLong)); if (!q.exec()) { *erreur = q.lastError().text(); const int code = qMax(1, q.lastError().nativeErrorCode().toInt()); @@ -294,7 +401,8 @@ private: } /*! - * La table existe-t-elle, avec la forme attendue, et lisible ? Sinon elle est REFAITE. + * La table existe-t-elle, avec la forme attendue, et lisible ? Sinon elle est REFAITE — sauf + * la forme etm67 exacte, MIGRÉE en place (colonnes ajoutées, lignes conservées). * \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). @@ -312,6 +420,32 @@ private: while (q.next()) trouvees << q.value(1).toString(); + // UNE TABLE etm67 — la forme exacte d'avant le GTI observé — est MIGRÉE EN PLACE, jamais + // refaite : ses lignes sont l'historique du banc. Les colonnes ajoutées sont nullables, + // sans défaut ; les lignes existantes y portent NULL (« inconnu »). + if (trouvees == ColonnesEtm67) { + bool ok = m_db.transaction(); + for (const auto &c : AjoutsEtm68) + ok = ok && q.exec(QStringLiteral("ALTER TABLE forecastLearning ADD COLUMN %1 %2") + .arg(c.first, c.second)); + ok = ok && m_db.commit(); + if (ok) { + qCInfo(dcNymeaEnergy()) << "[Prévision] apprentissage : table etm67 migrée en place —" + << AjoutsEtm68.count() << "colonne(s) ajoutée(s), lignes conservées"; + trouvees = Colonnes; + } else { + // Un échec de migration ne REFAIT rien : la transaction est annulée, la table reste + // etm67 avec ses lignes, et l'enregistrement s'arrête jusqu'au prochain démarrage, + // qui retentera. Perdre l'historique pour une migration ratée serait l'inverse du but. + const QString erreur = q.lastError().text().isEmpty() ? m_db.lastError().text() + : q.lastError().text(); + m_db.rollback(); + qCWarning(dcNymeaEnergy()) << "[Prévision] apprentissage : migration etm67 refusée —" + << erreur << "— table laissée telle quelle"; + return false; + } + } + QString motif; if (!trouvees.isEmpty() && trouvees != Colonnes) motif = QStringLiteral("forme inattendue (%1 colonne(s))").arg(trouvees.count()); @@ -417,8 +551,11 @@ private: QSqlDatabase m_db; QAtomicInt *m_ecrites; QAtomicInt *m_lots; + QAtomicInt *m_complements; + QAtomicInt *m_completes; QAtomicInt *m_valide; QList m_enAttente; + QList m_complementsEnAttente; bool m_horsServiceSignale = false; }; @@ -434,7 +571,8 @@ ForecastLearningLog::ForecastLearningLog(const QString &cheminFichier, const QTi 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 = new ForecastLearningWriter(cheminFichier, &m_ecrites, &m_lots, &m_complements, + &m_completes, &m_valide); m_ecrivain->moveToThread(m_fil); m_fil->start(QThread::LowPriority); ForecastLearningWriter *w = m_ecrivain; @@ -552,7 +690,57 @@ void ForecastLearningLog::observer(const QDateTime &now, const QString &siteId, } } } + // Le GTI observé AVANT finaliser() : un pas encore en tampon le reçoit dans sa ligne. Les + // autres compléments partent APRÈS le lot, pour que l'UPDATE trouve la ligne qu'il vise. + const QList complements = siteId.isEmpty() + ? QList() : rattacherGtiObserve(siteId, plans); finaliser(now); + if (!complements.isEmpty()) { + ForecastLearningWriter *w = m_ecrivain; + QMetaObject::invokeMethod(w, [w, complements]() { w->completer(complements); }, + Qt::QueuedConnection); + } +} + +QList ForecastLearningLog::rattacherGtiObserve( + const QString &siteId, const QList &plans) +{ + QList complements; + for (const ForecastPlanObservation &p : plans) { + // observedGtiTime == 0 : rien publié (défaut de l'état). Ni 0 W/m², ni un instant inventé. + if (!p.gtiObserved || p.gtiObservedAt <= 0) + continue; + const QString cle = siteId + QLatin1Char('|') + p.planId; + if (m_gtiObserveVu.value(cle, 0) == p.gtiObservedAt) + continue; // déjà traité : l'état est relu à chaque cycle + m_gtiObserveVu.insert(cle, p.gtiObservedAt); + + // LA CONVENTION DE `gti` PRÉVU (§14.3) : une valeur est rattachée au DÉBUT du pas qu'elle + // moyenne. Open-Meteo horodate une moyenne horaire à la FIN de son heure — la valeur à T + // vaut pour (T − 1 h, T] — donc le pas visé commence à T − 1 h. + const qint64 debut = p.gtiObservedAt - PasSatelliteS; + ForecastLearningRow o; + o.targetAt = debut; + o.stepS = PasSatelliteS; // une heure observée ne se répartit pas sur 4 quarts + o.siteId = siteId; + o.planId = p.planId; + o.gtiObserved = p.gtiObserved; + o.gtiObservedSource = QString::fromLatin1(SourceGtiObserve); + o.gtiObservedAt = p.gtiObservedAt; + + auto t = m_tampon.find(Cle{siteId, debut}); + if (t != m_tampon.end() && t->contains(p.planId)) { + ForecastLearningRow &l = (*t)[p.planId]; + if (l.stepS == PasSatelliteS && !l.gtiObserved) { + l.gtiObserved = o.gtiObserved; + l.gtiObservedSource = o.gtiObservedSource; + l.gtiObservedAt = o.gtiObservedAt; + } + continue; + } + complements << o; + } + return complements; } void ForecastLearningLog::observerProduction(const QDateTime &finMinute, double productionW) diff --git a/energyplugin/etm/forecast/forecastlearninglog.h b/energyplugin/etm/forecast/forecastlearninglog.h index caba79c..0808617 100644 --- a/energyplugin/etm/forecast/forecastlearninglog.h +++ b/energyplugin/etm/forecast/forecastlearninglog.h @@ -54,6 +54,11 @@ struct ForecastPlanObservation { std::optional performanceRatio; //!< Idem. std::optional tempCoeffPctPerK; //!< v2 seulement — absent en v1, JAMAIS 0. std::optional noctC; //!< v2 seulement — absent en v1, JAMAIS 0. + //! GTI OBSERVÉ par satellite (état \c observedGti du Thing plan), W/m². Absent si + //! \c observedGtiTime vaut 0 ou si le Thing n'a pas ces états — JAMAIS 0 par défaut. + std::optional gtiObserved; + //! Instant \c observedGtiTime tel que publié (epoch s) = FIN de l'heure qu'il moyenne ; 0 = aucun. + qint64 gtiObservedAt = 0; }; /*! @@ -90,6 +95,9 @@ struct ForecastLearningRow { 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. + std::optional gtiObserved; //!< GTI observé (satellite) du pas, W/m² ; NULL si inconnu. + QString gtiObservedSource; //!< Code de sa source ; vide = NULL. + std::optional gtiObservedAt; //!< \c observedGtiTime tel que publié (s). }; /*! @@ -129,6 +137,10 @@ public: 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"; + //! Code de la source du GTI observé : l'état publié par le Thing plan openmeteo. + static constexpr const char *SourceGtiObserve = "openmeteo-satellite-state"; + //! Pas de la série satellite d'Open-Meteo : la valeur à \c T est la moyenne de `(T − 1 h, T]`. + static constexpr int PasSatelliteS = 3600; /*! * \brief Ouvre la table dans le fichier de la prévision, sur un fil d'écriture dédié. @@ -176,6 +188,13 @@ public: * \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. + * \note GTI OBSERVÉ (\c ForecastPlanObservation::gtiObserved) : rattaché au pas + * `[observedGtiTime − 3600, observedGtiTime)` — la convention de \c gti prévu (§14.3). + * Il arrive 1 à 4 h APRÈS l'écriture du pas : il complète alors la ligne existante, et + * SEULEMENT ses colonnes \c gtiObserved*, SEULEMENT si elles sont NULL — l'unique exception + * à « une ligne n'est jamais réécrite ». Une valeur écrite n'est jamais remplacée ; aucun + * autre champ, \c valid compris, n'en dépend. Un pas sans ligne, ou dont le pas n'est pas + * d'une heure, ne reçoit rien : l'observation ne crée jamais de ligne. */ void observer(const QDateTime &now, const QString &siteId, const QList &plans, int plansAttendus, @@ -193,6 +212,10 @@ public: 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 Lots de GTI observés traités (appliqués ou abandonnés). \return Compteur, lu sans verrou. + int complementsTraites() const { return m_complements.loadAcquire(); } + //! \brief Lignes dont le GTI observé a été COMPLÉTÉ en base. \return Compteur, lu sans verrou. + int gtiObservesCompletes() const { return m_completes.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; } @@ -205,6 +228,8 @@ private: } }; void finaliser(const QDateTime &now); + QList rattacherGtiObserve(const QString &siteId, + const QList &plans); QTimeZone m_fuseau; qint64 m_debutS = 0; // premier cycle : rien d'antérieur n'est écrit @@ -217,6 +242,9 @@ private: // jour : sans ce filigrane, un pas écrit serait repris au cycle suivant, réécrit pour rien // et rejournalisé chaque minute. QHash m_ecritJusqua; + // (site, plan) → dernier observedGtiTime TRAITÉ : l'état est relu à chaque cycle, on ne + // l'envoie qu'une fois par nouvelle valeur. + QHash m_gtiObserveVu; QMap m_minutes; // fin de minute (s) → production (W, nymea) QMap m_neige; // instant (s) → snowfall lu bool m_producteur = false; @@ -226,5 +254,7 @@ private: ForecastLearningWriter *m_ecrivain = nullptr; QAtomicInt m_ecrites; QAtomicInt m_lots; + QAtomicInt m_complements; + QAtomicInt m_completes; QAtomicInt m_valide; }; diff --git a/tests/auto/loadmodel/data/energy-forecast-etm67.sqlite b/tests/auto/loadmodel/data/energy-forecast-etm67.sqlite new file mode 100644 index 0000000..1c4270d Binary files /dev/null and b/tests/auto/loadmodel/data/energy-forecast-etm67.sqlite differ diff --git a/tests/auto/loadmodel/loadmodel.pro b/tests/auto/loadmodel/loadmodel.pro index bb77ff1..7ea2ef7 100644 --- a/tests/auto/loadmodel/loadmodel.pro +++ b/tests/auto/loadmodel/loadmodel.pro @@ -12,6 +12,9 @@ TARGET = nymea-energy-loadmodel HEADERS += testloadmodel.h SOURCES += testloadmodel.cpp +# Une base energy-forecast.sqlite ÉCRITE PAR etm67 (b7317f3) : la migration s'éprouve sur le +# vrai fichier, pas sur une imitation (testTheLearningLogMigratesAnEtm67TableInPlace). +RESOURCES += loadmodel.qrc target.path = $$[QT_INSTALL_PREFIX]/bin INSTALLS += target diff --git a/tests/auto/loadmodel/loadmodel.qrc b/tests/auto/loadmodel/loadmodel.qrc new file mode 100644 index 0000000..feee149 --- /dev/null +++ b/tests/auto/loadmodel/loadmodel.qrc @@ -0,0 +1,5 @@ + + + data/energy-forecast-etm67.sqlite + + diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index 1b5d7dd..d6b2d0f 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -1743,3 +1743,226 @@ void TestLoadModel::testTheLearningLogNeverBlocksTheCycle() QSqlDatabase::removeDatabase(nom); QTRY_COMPARE(log.lignesEcrites(), 1); } + +namespace { +//! LA TABLE etm67, TELLE QUE SQLite L'A ENREGISTRÉE dans `sqlite_master` — la chaîne exacte de +//! `Creation` à b7317f3 (1.15.2+etm67, déployée sur le banc). Elle sert à PROUVER que la base +//! de référence ci-dessous est bien une base etm67, pas une imitation qui lui ressemble. +const char *SchemaEtm67 = + "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))"; + +QStringList colonnesApprentissage(const QString &chemin) +{ + QStringList sortie; + const QString nom = QStringLiteral("colonnes-%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("PRAGMA table_info(forecastLearning)"))) + while (q.next()) + sortie << q.value(1).toString(); + db.close(); + } + } + QSqlDatabase::removeDatabase(nom); + return sortie; +} + +QStringList tables(const QString &chemin) +{ + QStringList sortie; + const QString nom = QStringLiteral("tables-%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 name, sql FROM sqlite_master WHERE type = 'table' " + "ORDER BY name"))) + while (q.next()) + sortie << q.value(0).toString() + QLatin1Char('\t') + q.value(1).toString(); + db.close(); + } + } + QSqlDatabase::removeDatabase(nom); + return sortie; +} +} + +void TestLoadModel::testTheLearningLogMigratesAnEtm67TableInPlace() +{ + // UNE BASE ÉCRITE PAR LE CODE etm67 LUI-MÊME (b7317f3, test de simulation du câblage), pas + // reconstituée à la main : c'est elle que le banc porte depuis 09:24 le 2026-10-13. + QTemporaryDir dir; + const QString chemin = dir.filePath("energy-forecast.sqlite"); + QVERIFY(QFile::copy(QStringLiteral(":/data/energy-forecast-etm67.sqlite"), chemin)); + QVERIFY(QFile::setPermissions(chemin, QFile::ReadOwner | QFile::WriteOwner)); + + const QStringList avantTables = tables(chemin); + QVERIFY2(avantTables.contains(QStringLiteral("forecastLearning\t") + QString::fromLatin1(SchemaEtm67)), + qPrintable(avantTables.join('\n'))); + const QList avant = lignesApprentissage(chemin); + QVERIFY2(avant.count() >= 2, "la base de référence doit porter des lignes etm67"); + QCOMPARE(avant.first().count(), 29); + + { + ForecastLearningLog log(chemin, Paris); + // L'ouverture migre : on attend les colonnes par `PRAGMA table_info`, dont la forme ne + // change pas. Un `SELECT *` préparé PENDANT l'ALTER fait planter le pilote Qt SQLite + // (29 colonnes à la préparation, 32 à la lecture) — vu une fois ; aucun lecteur de cette + // table n'existe hors des tests. + QTRY_VERIFY(colonnesApprentissage(chemin).contains(QStringLiteral("gtiObservedAt"))); + QVERIFY(log.isValid()); + + const QList apres = lignesApprentissage(chemin); + QCOMPARE(apres.count(), avant.count()); // AUCUNE ligne perdue + for (int i = 0; i < avant.count(); ++i) { + QCOMPARE(apres.at(i).count(), 32); + for (auto c = avant.at(i).constBegin(); c != avant.at(i).constEnd(); ++c) + QVERIFY2(apres.at(i).value(c.key()) == c.value(), qPrintable(c.key())); + for (const char *c : {"gtiObserved", "gtiObservedSource", "gtiObservedAt"}) + QVERIFY2(apres.at(i).value(c).isNull(), c); // inconnu, JAMAIS 0 + } + // Migrée, pas refaite ni mise de côté : une seule table forecastLearning, aucune autre. + int apprentissage = 0; + for (const QString &t : tables(chemin)) + apprentissage += t.startsWith(QLatin1String("forecastLearning")) ? 1 : 0; + QCOMPARE(apprentissage, 1); + + // Et la table migrée s'ÉCRIT : un pas neuf, observation comprise. + const QDateTime t0 = utc(2027, 1, 5, 10); + QList plans{planObserve("{p}", 1, t0, {800, 900}, {{"kwp", 2.0}})}; + log.observer(t0.addSecs(60), "{s}", plans, 1, std::nullopt, std::nullopt, true); + plans[0].gtiObserved = 310.0; + plans[0].gtiObservedAt = t0.addSecs(3600).toSecsSinceEpoch(); + // Le pas est encore en tampon à ce cycle : l'observation entre par l'INSERT. + log.observer(t0.addSecs(3600 + 150), "{s}", plans, 1, std::nullopt, std::nullopt, true); + QTRY_COMPARE(log.lotsTraites(), 1); + const QList neuve = + lignesApprentissage(chemin, QStringLiteral("targetAt = %1").arg(t0.toSecsSinceEpoch())); + QCOMPARE(neuve.count(), 1); + QCOMPARE(neuve.first().value("gtiObserved").toDouble(), 310.0); + } + // Un second démarrage sur la table migrée : forme attendue, rien à faire, rien de perdu. + { + ForecastLearningLog relance(chemin, Paris); + QTest::qWait(200); + QVERIFY(relance.isValid()); + QCOMPARE(lignesApprentissage(chemin).count(), avant.count() + 1); + } +} + +void TestLoadModel::testTheLearningLogAttachesTheObservedGtiToItsStep() +{ + QTemporaryDir dir; + const QString chemin = dir.filePath("energy-forecast.sqlite"); + const QString site = QStringLiteral("{s}"); + const QDateTime t0 = utc(2026, 10, 13, 9); // grille : 09, 10, 11, 12, 13 h + QList plans{ + planObserve("{p}", 2, t0, {300, 1200, 1500, 1400, 900}, {{"kwp", 2.0}})}; + auto observe = [&](double gti, const QDateTime &fin) { + plans[0].gtiObserved = gti; + plans[0].gtiObservedAt = fin.toSecsSinceEpoch(); + }; + auto ligne = [&](const QDateTime &debut) { + const QList l = + lignesApprentissage(chemin, QStringLiteral("targetAt = %1").arg(debut.toSecsSinceEpoch())); + return l.isEmpty() ? QVariantMap() : l.first(); + }; + auto cycle = [&](ForecastLearningLog &log, const QDateTime &n) { + log.observer(n, site, plans, 1, 3000, std::nullopt, true); + }; + + ForecastLearningLog log(chemin, Paris); + // 1. observedGtiTime == 0 — le défaut de l'état : RIEN, même si la valeur vaut 0. + observe(0.0, QDateTime::fromSecsSinceEpoch(0, QTimeZone::utc())); + cycle(log, utc(2026, 10, 13, 10, 5)); // premier cycle : le pas de 09 h est perdu + minutes(log, utc(2026, 10, 13, 10), utc(2026, 10, 13, 11), 1100); + cycle(log, utc(2026, 10, 13, 11, 3)); // écrit le pas de 10 h + QTRY_COMPARE(log.lotsTraites(), 1); + QTest::qWait(50); + QCOMPARE(log.complementsTraites(), 0); + const QVariantMap avant = ligne(utc(2026, 10, 13, 10)); + QVERIFY(!avant.isEmpty()); + for (const char *c : {"gtiObserved", "gtiObservedSource", "gtiObservedAt"}) + QVERIFY2(avant.value(c).isNull(), c); + QCOMPARE(avant.value("valid").toInt(), 1); + + // 2. LE RETARD : la valeur de 11:00 — moyenne de (10:00, 11:00] — arrive à 12:30, la ligne + // du pas de 10 h est écrite depuis 11:03. Elle la COMPLÈTE, elle et rien d'autre. + minutes(log, utc(2026, 10, 13, 11), utc(2026, 10, 13, 12), 1100); + observe(420.0, utc(2026, 10, 13, 11)); + cycle(log, utc(2026, 10, 13, 12, 30)); // écrit aussi le pas de 11 h + QTRY_COMPARE(log.lotsTraites(), 2); + QTRY_VERIFY2(!ligne(utc(2026, 10, 13, 10)).value("gtiObserved").isNull(), + "la valeur publiée pour 11:00 moyenne 10:00-11:00 : elle va au pas de 10 h"); + QCOMPARE(log.complementsTraites(), 1); + QCOMPARE(log.gtiObservesCompletes(), 1); + const QVariantMap apres = ligne(utc(2026, 10, 13, 10)); + QCOMPARE(apres.value("gtiObserved").toDouble(), 420.0); + QCOMPARE(apres.value("gtiObservedSource").toString(), QString("openmeteo-satellite-state")); + QCOMPARE(apres.value("gtiObservedAt").toLongLong(), utc(2026, 10, 13, 11).toSecsSinceEpoch()); + for (auto c = avant.constBegin(); c != avant.constEnd(); ++c) { + if (!c.key().startsWith(QLatin1String("gtiObserved"))) + QVERIFY2(apres.value(c.key()) == c.value(), + qPrintable(QStringLiteral("%1 réécrit par le complément").arg(c.key()))); + } + // Le pas de 11 h — celui que DÉSIGNERAIT l'instant publié sans le décalage — n'a rien reçu. + const QVariantMap onze = ligne(utc(2026, 10, 13, 11)); + QVERIFY(!onze.isEmpty()); + QVERIFY2(onze.value("gtiObserved").isNull(), "la valeur de 11:00 moyenne 10:00-11:00, pas 11:00-12:00"); + + // 3. L'état est relu à chaque cycle : la MÊME publication n'est pas renvoyée. + cycle(log, utc(2026, 10, 13, 12, 40)); + QTest::qWait(100); + QCOMPARE(log.complementsTraites(), 1); + + // 4. LA NUIT : 0 W/m² PUBLIÉ est une observation, pas une absence. + observe(0.0, utc(2026, 10, 13, 12)); + cycle(log, utc(2026, 10, 13, 12, 50)); + QTRY_COMPARE(log.complementsTraites(), 2); + QVERIFY(!ligne(utc(2026, 10, 13, 11)).value("gtiObserved").isNull()); + QCOMPARE(ligne(utc(2026, 10, 13, 11)).value("gtiObserved").toDouble(), 0.0); + + // 5. Une observation dont le pas n'a PAS de ligne (09 h, écoulé avant le premier cycle) : + // rien n'est créé. + observe(150.0, utc(2026, 10, 13, 10)); + cycle(log, utc(2026, 10, 13, 12, 55)); + QTRY_COMPARE(log.complementsTraites(), 3); + QCOMPARE(log.gtiObservesCompletes(), 2); + QVERIFY(ligne(utc(2026, 10, 13, 9)).isEmpty()); + + // 6. Le pas est ENCORE en tampon (fin 13:00, grâce jusqu'à 13:02) : l'observation entre dans + // la ligne avant qu'elle soit écrite — par l'INSERT, sans complément. + minutes(log, utc(2026, 10, 13, 12), utc(2026, 10, 13, 13), 1100); + observe(555.0, utc(2026, 10, 13, 13)); + cycle(log, utc(2026, 10, 13, 13, 1)); + cycle(log, utc(2026, 10, 13, 13, 3)); + QTRY_COMPARE(log.lotsTraites(), 3); + QCOMPARE(ligne(utc(2026, 10, 13, 12)).value("gtiObserved").toDouble(), 555.0); + QCOMPARE(log.complementsTraites(), 3); + + // 7. UNE VALEUR ÉCRITE N'EST JAMAIS REMPLACÉE — un redémarrage relit l'état mis en cache, + // revoit l'instant de 11:00 avec une autre valeur : la ligne garde la sienne. + { + ForecastLearningLog relance(chemin, Paris); + observe(999.0, utc(2026, 10, 13, 11)); + relance.observer(utc(2026, 10, 13, 13, 10), site, plans, 1, 3000, std::nullopt, true); + QTRY_COMPARE(relance.complementsTraites(), 1); + QCOMPARE(relance.gtiObservesCompletes(), 0); + } + QCOMPARE(ligne(utc(2026, 10, 13, 10)).value("gtiObserved").toDouble(), 420.0); + QCOMPARE(ligne(utc(2026, 10, 13, 10)).value("valid").toInt(), 1); // valid n'en dépend pas +} diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index df7fead..cdaebd9 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -109,6 +109,10 @@ private slots: void testTheLearningLogSurvivesADamagedTable(); //! L'écriture ne bloque jamais l'appelant, même base verrouillée. void testTheLearningLogNeverBlocksTheCycle(); + //! etm68 — une table etm67 RÉELLE est migrée en place : lignes conservées, colonnes NULL. + void testTheLearningLogMigratesAnEtm67TableInPlace(); + //! etm68 — le GTI observé va au pas qu'il moyenne, complète seulement ses colonnes, si NULL. + void testTheLearningLogAttachesTheObservedGtiToItsStep(); };