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:
parent
ca73fd733b
commit
dd24cda445
@ -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)
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
BIN
tests/auto/loadmodel/data/energy-forecast-etm67.sqlite
Normal file
BIN
tests/auto/loadmodel/data/energy-forecast-etm67.sqlite
Normal file
Binary file not shown.
@ -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
|
||||
|
||||
5
tests/auto/loadmodel/loadmodel.qrc
Normal file
5
tests/auto/loadmodel/loadmodel.qrc
Normal file
@ -0,0 +1,5 @@
|
||||
<RCC>
|
||||
<qresource prefix="/">
|
||||
<file>data/energy-forecast-etm67.sqlite</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
};
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user