feat(prévision PV): forecastLearning porte le GTI observé — migration etm67 en place

Trois colonnes nullables : gtiObserved (W/m²), gtiObservedSource
(openmeteo-satellite-state), gtiObservedAt (observedGtiTime tel que publié).
Rattachées au pas [T − 1 h, T) : même convention que gti prévu (§14.3),
Open-Meteo horodatant une moyenne horaire à la fin de son heure.

L'observation arrive 1 à 4 h après l'écriture du pas : elle complète la ligne
existante par un UPDATE ciblé — ces trois colonnes seulement, seulement si
gtiObserved IS NULL. Seule exception à « une ligne n'est jamais réécrite » ;
elle ne crée jamais de ligne. Encore en tampon, elle entre par l'INSERT.

Une table de forme etm67 exacte est migrée par ALTER TABLE ADD COLUMN dans une
transaction : lignes conservées, nouvelles colonnes NULL. Le mécanisme « forme
inattendue → refaite » ne vaut plus que pour les autres formes.

Tests loadmodel : migration d'une base écrite par le code etm67 lui-même
(fixture data/energy-forecast-etm67.sqlite), rattachement, retard, nuit à 0,
valeur jamais remplacée, aucun autre champ touché.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-13 10:21:18 +02:00
parent ca73fd733b
commit dd24cda445
7 changed files with 458 additions and 5 deletions

View File

@ -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<QPair<QString, QString>> 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<ForecastLearningRow> &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<int> 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<ForecastLearningRow> &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<ForecastLearningRow> m_enAttente;
QList<ForecastLearningRow> 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<ForecastLearningRow> complements = siteId.isEmpty()
? QList<ForecastLearningRow>() : rattacherGtiObserve(siteId, plans);
finaliser(now);
if (!complements.isEmpty()) {
ForecastLearningWriter *w = m_ecrivain;
QMetaObject::invokeMethod(w, [w, complements]() { w->completer(complements); },
Qt::QueuedConnection);
}
}
QList<ForecastLearningRow> ForecastLearningLog::rattacherGtiObserve(
const QString &siteId, const QList<ForecastPlanObservation> &plans)
{
QList<ForecastLearningRow> 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)

View File

@ -54,6 +54,11 @@ struct ForecastPlanObservation {
std::optional<double> performanceRatio; //!< Idem.
std::optional<double> tempCoeffPctPerK; //!< v2 seulement — absent en v1, JAMAIS 0.
std::optional<double> 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<double> 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<bool> curtailed; //!< 1 détecté ; 0 cherché, rien vu ; NULL non cherchable.
std::optional<bool> snow; //!< Chute de neige lue sur le site ; NULL si non lue.
bool valid = false; //!< Aucune raison CONNUE d'écarter la ligne.
std::optional<double> gtiObserved; //!< GTI observé (satellite) du pas, W/m² ; NULL si inconnu.
QString gtiObservedSource; //!< Code de sa source ; vide = NULL.
std::optional<qint64> 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<ForecastPlanObservation> &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<ForecastLearningRow> rattacherGtiObserve(const QString &siteId,
const QList<ForecastPlanObservation> &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<QString, qint64> m_ecritJusqua;
// (site, plan) → dernier observedGtiTime TRAITÉ : l'état est relu à chaque cycle, on ne
// l'envoie qu'une fois par nouvelle valeur.
QHash<QString, qint64> m_gtiObserveVu;
QMap<qint64, double> m_minutes; // fin de minute (s) → production (W, nymea)
QMap<qint64, double> 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;
};

Binary file not shown.

View File

@ -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

View File

@ -0,0 +1,5 @@
<RCC>
<qresource prefix="/">
<file>data/energy-forecast-etm67.sqlite</file>
</qresource>
</RCC>

View File

@ -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<QVariantMap> 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<QVariantMap> 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<ForecastPlanObservation> 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<QVariantMap> 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<ForecastPlanObservation> 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<QVariantMap> 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
}

View File

@ -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();
};