// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #include "forecastlearninglog.h" #include "forecaststore.h" #include #include #include #include #include #include #include #include #include #include #include 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> 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 QVariant ouNul(const std::optional &v, QMetaType::Type t) { return v ? QVariant::fromValue(*v) : nul(t); } QVariant ouNul(const std::optional &v) { return v ? QVariant(int(*v)) : nul(QMetaType::Int); } QVariant ouNul(const QString &s) { return s.isEmpty() ? nul(QMetaType::QString) : QVariant(s); } std::optional nombre(const QVariant &v) { bool ok = false; const double d = v.toDouble(&ok); return (ok && v.isValid() && !v.isNull()) ? std::optional(d) : std::nullopt; } QList> serie(const QVariant &brut) { QList> s; for (const QVariant &v : brut.toList()) s << nombre(v); return s; } QString texteBool(const std::optional &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 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 &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 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 &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 &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 m_enAttente; QList 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 ¶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> gti = serie(charge.value(QStringLiteral("gti"))); const QList> 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 &plans, int plansAttendus, std::optional acLimitW, std::optional 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::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 complements = siteId.isEmpty() ? QList() : rattacherGtiObserve(siteId, plans); finaliser(now); if (!complements.isEmpty()) { ForecastLearningWriter *w = m_ecrivain; QMetaObject::invokeMethod(w, [w, complements]() { w->completer(complements); }, Qt::QueuedConnection); } } QList ForecastLearningLog::rattacherGtiObserve( const QString &siteId, const QList &plans) { QList 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 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 &parPlan = it.value(); // Les plans d'un même instant peuvent ne pas partager le pas : chacun s'écrit à SA fin. QList 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(true) && l.snow != std::optional(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); }