Patrick Schurig dd24cda445 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>
2026-10-13 10:21:18 +02:00

842 lines
42 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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"),
// 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.
" 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
// 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)); }
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 *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()
{
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);
// 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)
{
*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);
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());
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 — 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).
*/
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();
// 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());
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_complements;
QAtomicInt *m_completes;
QAtomicInt *m_valide;
QList<ForecastLearningRow> m_enAttente;
QList<ForecastLearningRow> m_complementsEnAttente;
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_complements,
&m_completes, &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;
}
}
}
// 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)
{
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);
}