feat(prévision PV): table forecastLearning — un pas écoulé, un plan, une ligne

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 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-13 09:15:27 +02:00
parent 139e48a938
commit ff94377267
5 changed files with 1370 additions and 0 deletions

View File

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

View File

@ -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 <QDir>
#include <QFileInfo>
#include <QLoggingCategory>
#include <QSqlDatabase>
#include <QSqlError>
#include <QSqlQuery>
#include <QSqlRecord>
#include <QThread>
#include <QUuid>
#include <QtMath>
#include <limits>
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<typename T> QVariant ouNul(const std::optional<T> &v, QMetaType::Type t)
{
return v ? QVariant::fromValue(*v) : nul(t);
}
QVariant ouNul(const std::optional<bool> &v)
{
return v ? QVariant(int(*v)) : nul(QMetaType::Int);
}
QVariant ouNul(const QString &s) { return s.isEmpty() ? nul(QMetaType::QString) : QVariant(s); }
std::optional<double> nombre(const QVariant &v)
{
bool ok = false;
const double d = v.toDouble(&ok);
return (ok && v.isValid() && !v.isNull()) ? std::optional<double>(d) : std::nullopt;
}
QList<std::optional<double>> serie(const QVariant &brut)
{
QList<std::optional<double>> s;
for (const QVariant &v : brut.toList())
s << nombre(v);
return s;
}
QString texteBool(const std::optional<bool> &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<ForecastLearningRow> 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<ForecastLearningRow> &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<ForecastLearningRow> &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<ForecastLearningRow> 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 &parametres, 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<std::optional<double>> gti = serie(charge.value(QStringLiteral("gti")));
const QList<std::optional<double>> 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<ForecastPlanObservation> &plans, int plansAttendus,
std::optional<int> acLimitW, std::optional<double> 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<qint64>::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<ForecastLearningRow> 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<QString, ForecastLearningRow> &parPlan = it.value();
// Les plans d'un même instant peuvent ne pas partager le pas : chacun s'écrit à SA fin.
QList<QString> 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<bool>(true)
&& l.snow != std::optional<bool>(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);
}

View File

@ -0,0 +1,230 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QAtomicInt>
#include <QDateTime>
#include <QHash>
#include <QList>
#include <QMap>
#include <QObject>
#include <QString>
#include <QStringList>
#include <QTimeZone>
#include <QVariantMap>
#include <optional>
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<std::optional<double>> pW; //!< \c p du plan, en W moyens sur le pas.
QList<std::optional<double>> gti; //!< GTI brut W/m² — vide en v1.
QList<std::optional<double>> tempC; //!< \c temperature_2m °C — vide en v1.
QStringList corrections; //!< Corrections du PLAN, ordre canonique.
std::optional<qint64> issuedAt; //!< Attribut ; absent si 0 (repli best_match).
QString model; //!< Modèle ayant répondu ; vide = inconnu.
std::optional<double> kwp; //!< Paramètre du Thing plan, en vigueur.
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.
};
/*!
* \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<double> gti; //!< GTI du pas, W/m² ; NULL en v1.
std::optional<double> tempC; //!< \c temperature_2m du pas, °C ; NULL en v1.
std::optional<double> 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<double> kwp; //!< Crête du plan (Thing), en vigueur.
std::optional<double> performanceRatio; //!< PR du plan (Thing), en vigueur.
std::optional<double> tempCoeffPctPerK; //!< v2 seulement ; NULL en v1.
std::optional<double> noctC; //!< v2 seulement ; NULL en v1.
std::optional<int> acLimitW; //!< Plafond AC du SITE en vigueur.
std::optional<qint64> 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<double> 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<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.
};
/*!
* \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 &parametres, 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<ForecastPlanObservation> &plans, int plansAttendus,
std::optional<int> acLimitW, std::optional<double> 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<Cle, QHash<QString, ForecastLearningRow>> m_tampon;
QMap<Cle, int> 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<QString, qint64> m_ecritJusqua;
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;
bool m_sansProducteurSignale = false;
QThread *m_fil = nullptr;
ForecastLearningWriter *m_ecrivain = nullptr;
QAtomicInt m_ecrites;
QAtomicInt m_lots;
QAtomicInt m_valide;
};

View File

@ -1,6 +1,13 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "etm/forecast/forecaststore.h"
#include "etm/forecast/forecastlearninglog.h"
#include <QElapsedTimer>
#include <QFileInfo>
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QSqlRecord>
#include <QUuid>
#include <QTemporaryDir>
#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<QVariantMap> lignesApprentissage(const QString &chemin, const QString &where = QString())
{
QList<QVariantMap> 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 &params)
{
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<ForecastPlanObservation> 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<QVariantMap> 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<ForecastPlanObservation> 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<int> plafond;
std::optional<double> neige;
bool producteur;
int plansVus, plansAttendus;
// attendus
bool mesure; QVariant curtailed; QVariant snow; int valid;
};
const QList<Cas> 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<ForecastPlanObservation> 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<QVariantMap> 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<ForecastPlanObservation> 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<QVariantMap> 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<QVariantMap> 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<ForecastPlanObservation> 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<QVariantMap> 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<QVariantMap> 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<ForecastPlanObservation> 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);
}

View File

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