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:
parent
139e48a938
commit
ff94377267
@ -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 \
|
||||
|
||||
653
energyplugin/etm/forecast/forecastlearninglog.cpp
Normal file
653
energyplugin/etm/forecast/forecastlearninglog.cpp
Normal 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 ¶metres, 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);
|
||||
}
|
||||
230
energyplugin/etm/forecast/forecastlearninglog.h
Normal file
230
energyplugin/etm/forecast/forecastlearninglog.h
Normal 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 ¶metres, 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;
|
||||
};
|
||||
@ -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 ¶ms)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user