Trois colonnes nullables : gtiObserved (W/m²), gtiObservedSource (openmeteo-satellite-state), gtiObservedAt (observedGtiTime tel que publié). Rattachées au pas [T − 1 h, T) : même convention que gti prévu (§14.3), Open-Meteo horodatant une moyenne horaire à la fin de son heure. L'observation arrive 1 à 4 h après l'écriture du pas : elle complète la ligne existante par un UPDATE ciblé — ces trois colonnes seulement, seulement si gtiObserved IS NULL. Seule exception à « une ligne n'est jamais réécrite » ; elle ne crée jamais de ligne. Encore en tampon, elle entre par l'INSERT. Une table de forme etm67 exacte est migrée par ALTER TABLE ADD COLUMN dans une transaction : lignes conservées, nouvelles colonnes NULL. Le mécanisme « forme inattendue → refaite » ne vaut plus que pour les autres formes. Tests loadmodel : migration d'une base écrite par le code etm67 lui-même (fixture data/energy-forecast-etm67.sqlite), rattachement, retard, nuit à 0, valeur jamais remplacée, aucun autre champ touché. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
842 lines
42 KiB
C++
842 lines
42 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||
#include "forecastlearninglog.h"
|
||
#include "forecaststore.h"
|
||
|
||
#include <QDir>
|
||
#include <QFileInfo>
|
||
#include <QLoggingCategory>
|
||
#include <QSqlDatabase>
|
||
#include <QSqlError>
|
||
#include <QSqlQuery>
|
||
#include <QSqlRecord>
|
||
#include <QThread>
|
||
#include <QUuid>
|
||
#include <QtMath>
|
||
|
||
#include <limits>
|
||
|
||
Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)
|
||
|
||
namespace {
|
||
const QString Table = QStringLiteral("forecastLearning");
|
||
|
||
//! Les colonnes, dans l'ordre. Sert à CRÉER la table et à RECONNAÎTRE une table de forme
|
||
//! inattendue : celle-ci est refaite, jamais lue de travers.
|
||
const QStringList Colonnes = {
|
||
QStringLiteral("targetAt"), QStringLiteral("stepS"), QStringLiteral("siteId"),
|
||
QStringLiteral("planId"), QStringLiteral("gti"), QStringLiteral("tempC"), QStringLiteral("pW"),
|
||
QStringLiteral("corrections"), QStringLiteral("transportV"), QStringLiteral("kwp"),
|
||
QStringLiteral("performanceRatio"), QStringLiteral("tempCoeffPctPerK"), QStringLiteral("noctC"),
|
||
QStringLiteral("acLimitW"), QStringLiteral("issuedAt"), QStringLiteral("model"),
|
||
QStringLiteral("observedAt"), QStringLiteral("sitePlans"), QStringLiteral("sitePlansExpected"),
|
||
QStringLiteral("dayAheadAt"), QStringLiteral("contentIdDayAhead"),
|
||
QStringLiteral("contentIdHourAhead"), QStringLiteral("measuredW"),
|
||
QStringLiteral("measuredSource"), QStringLiteral("measuredMinutes"),
|
||
QStringLiteral("curtailed"), QStringLiteral("snow"), QStringLiteral("valid"),
|
||
QStringLiteral("writtenAt"),
|
||
// etm68 — ajoutées EN FIN par migration (ALTER TABLE ADD COLUMN, voir AjoutsEtm68).
|
||
QStringLiteral("gtiObserved"), QStringLiteral("gtiObservedSource"),
|
||
QStringLiteral("gtiObservedAt")
|
||
};
|
||
|
||
//! Les colonnes ajoutées après etm67, avec leur type. Toutes NULLABLES, sans défaut : une table
|
||
//! etm67 les reçoit par `ALTER TABLE … ADD COLUMN`, ses lignes restent, les nouvelles cases
|
||
//! valent NULL — « inconnu », exactement ce qu'elles étaient.
|
||
const QList<QPair<QString, QString>> AjoutsEtm68 = {
|
||
{QStringLiteral("gtiObserved"), QStringLiteral("REAL")},
|
||
{QStringLiteral("gtiObservedSource"), QStringLiteral("TEXT")},
|
||
{QStringLiteral("gtiObservedAt"), QStringLiteral("INTEGER")},
|
||
};
|
||
//! La forme EXACTE d'une table etm67 : la seule qu'on migre. Toute autre forme est refaite.
|
||
const QStringList ColonnesEtm67 = Colonnes.mid(0, Colonnes.count() - AjoutsEtm68.count());
|
||
|
||
const char *Creation =
|
||
"CREATE TABLE IF NOT EXISTS forecastLearning ("
|
||
// DÉBUT DU PAS, epoch s UTC — même sens que forecastPv.targetAt, pour la jointure.
|
||
" targetAt INTEGER NOT NULL,"
|
||
" stepS INTEGER NOT NULL,"
|
||
" siteId TEXT NOT NULL,"
|
||
" planId TEXT NOT NULL,"
|
||
// CE QUE LE PLAN PUBLIAIT pour ce pas, à sa dernière lecture. NULL = absent, JAMAIS 0 :
|
||
// en v1, gti et tempC n'existent pas.
|
||
" gti REAL,"
|
||
" tempC REAL,"
|
||
" pW REAL," // W MOYENS sur le pas, pas des Wh
|
||
" corrections TEXT NOT NULL," // ordre canonique, séparées par ','
|
||
" transportV INTEGER NOT NULL,"
|
||
// LA CONVERSION EN VIGUEUR — sans elle, `GTI × kWc × PR` n'est pas reconstituable dès qu'un
|
||
// paramètre change. tempCoeffPctPerK et noctC sont NULL en v1, qui ne les applique pas.
|
||
" kwp REAL,"
|
||
" performanceRatio REAL,"
|
||
" tempCoeffPctPerK REAL,"
|
||
" noctC REAL,"
|
||
" acLimitW INTEGER," // plafond du SITE, appliqué après la somme
|
||
// ATTRIBUTS de la charge utile lue — jamais une identité (§20).
|
||
" issuedAt INTEGER,"
|
||
" model TEXT,"
|
||
" observedAt INTEGER NOT NULL," // ms : dernière lecture de ce plan pour ce pas
|
||
" sitePlans INTEGER NOT NULL,"
|
||
" sitePlansExpected INTEGER NOT NULL,"
|
||
// RENVOIS vers forecastPv (targetAt, siteId, contentId) — la clé de contenu du §20.
|
||
" dayAheadAt INTEGER NOT NULL," // J−1 18:00 locale, epoch s
|
||
" contentIdDayAhead TEXT,"
|
||
" contentIdHourAhead TEXT,"
|
||
// CE QUE LE SITE A MESURÉ — production moyenne, W, positive quand on produit.
|
||
" measuredW REAL,"
|
||
" measuredSource TEXT,"
|
||
" measuredMinutes INTEGER NOT NULL,"
|
||
" curtailed INTEGER," // 1 détecté · 0 cherché, rien vu · NULL
|
||
" snow INTEGER," // chute de neige LUE sur le site · NULL
|
||
" valid INTEGER NOT NULL,"
|
||
" writtenAt INTEGER NOT NULL," // ms
|
||
// CE QUE LE SATELLITE A VU sur le plan — GTI observé, W/m². Arrive APRÈS l'écriture du pas :
|
||
// seules colonnes complétées plus tard (UPDATE ciblé, si NULL). NULL = inconnu, jamais 0.
|
||
" gtiObserved REAL,"
|
||
" gtiObservedSource TEXT,"
|
||
" gtiObservedAt INTEGER," // observedGtiTime tel que publié, epoch s
|
||
" PRIMARY KEY (targetAt, siteId, planId))";
|
||
|
||
QVariant nul(QMetaType::Type t) { return QVariant(QMetaType(t)); }
|
||
template<typename T> QVariant ouNul(const std::optional<T> &v, QMetaType::Type t)
|
||
{
|
||
return v ? QVariant::fromValue(*v) : nul(t);
|
||
}
|
||
QVariant ouNul(const std::optional<bool> &v)
|
||
{
|
||
return v ? QVariant(int(*v)) : nul(QMetaType::Int);
|
||
}
|
||
QVariant ouNul(const QString &s) { return s.isEmpty() ? nul(QMetaType::QString) : QVariant(s); }
|
||
|
||
std::optional<double> nombre(const QVariant &v)
|
||
{
|
||
bool ok = false;
|
||
const double d = v.toDouble(&ok);
|
||
return (ok && v.isValid() && !v.isNull()) ? std::optional<double>(d) : std::nullopt;
|
||
}
|
||
|
||
QList<std::optional<double>> serie(const QVariant &brut)
|
||
{
|
||
QList<std::optional<double>> s;
|
||
for (const QVariant &v : brut.toList())
|
||
s << nombre(v);
|
||
return s;
|
||
}
|
||
|
||
QString texteBool(const std::optional<bool> &b)
|
||
{
|
||
return b ? (*b ? QStringLiteral("oui") : QStringLiteral("non")) : QStringLiteral("inconnu");
|
||
}
|
||
}
|
||
|
||
// ============================================================================================
|
||
// LE FIL D'ÉCRITURE — sa propre connexion, jamais celle du cycle
|
||
// ============================================================================================
|
||
|
||
/*!
|
||
* \brief Écrit les lignes de \c forecastLearning, dans son propre fil et sa propre connexion.
|
||
* \note Interne à \c ForecastLearningLog. Une connexion SQLite ne se partage pas entre fils :
|
||
* celle-ci est ouverte, utilisée et fermée dans le fil d'écriture uniquement.
|
||
*/
|
||
class ForecastLearningWriter : public QObject
|
||
{
|
||
public:
|
||
ForecastLearningWriter(const QString &chemin, QAtomicInt *ecrites, QAtomicInt *lots,
|
||
QAtomicInt *complements, QAtomicInt *completes, QAtomicInt *valide)
|
||
: m_chemin(chemin), m_ecrites(ecrites), m_lots(lots), m_complements(complements)
|
||
, m_completes(completes), m_valide(valide) {}
|
||
|
||
void ouvrir()
|
||
{
|
||
QDir().mkpath(QFileInfo(m_chemin).absolutePath());
|
||
m_connexion = QStringLiteral("etm-forecast-learning-%1")
|
||
.arg(QUuid::createUuid().toString(QUuid::WithoutBraces));
|
||
m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connexion);
|
||
m_db.setDatabaseName(m_chemin);
|
||
// Le fil ATTEND un verrou tenu par l'historique des révisions plutôt que d'échouer ; il
|
||
// peut attendre, lui — c'est précisément pour ça qu'il n'est pas le fil du cycle.
|
||
m_db.setConnectOptions(QStringLiteral("QSQLITE_BUSY_TIMEOUT=10000"));
|
||
if (!m_db.open()) {
|
||
horsService(m_db.lastError().text());
|
||
return;
|
||
}
|
||
m_valide->storeRelease(assurerTable(/*verifierContenu=*/true) ? 1 : 0);
|
||
}
|
||
|
||
void fermer()
|
||
{
|
||
if (m_db.isOpen())
|
||
m_db.close();
|
||
m_db = QSqlDatabase();
|
||
if (!m_connexion.isEmpty())
|
||
QSqlDatabase::removeDatabase(m_connexion);
|
||
}
|
||
|
||
void ecrire(QList<ForecastLearningRow> lignes, qint64 nowS)
|
||
{
|
||
// Les lignes laissées par un verrou au lot précédent passent en tête : l'ordre d'arrivée
|
||
// n'a pas d'importance, la clé les distingue.
|
||
lignes = m_enAttente + lignes;
|
||
m_enAttente.clear();
|
||
if (m_valide->loadAcquire() && !lignes.isEmpty()) {
|
||
for (ForecastLearningRow &l : lignes) {
|
||
l.contentIdDayAhead = contenuEnVigueur(l.targetAt, l.siteId, l.dayAheadAt * 1000);
|
||
l.contentIdHourAhead = contenuEnVigueur(l.targetAt, l.siteId,
|
||
(l.targetAt - 3600) * 1000);
|
||
}
|
||
int inserees = 0;
|
||
QString erreur;
|
||
int code = inserer(lignes, &inserees, &erreur);
|
||
if (code == 5 || code == 6) {
|
||
// OCCUPÉE, pas abîmée : on réessaiera au prochain lot, rien n'est refait.
|
||
qCWarning(dcNymeaEnergy()) << "[Prévision] apprentissage : base occupée —"
|
||
<< lignes.count() << "ligne(s) remise(s) au lot suivant";
|
||
m_enAttente = lignes;
|
||
} else if (code != 0) {
|
||
// TOUT AUTRE ÉCHEC : la table est vérifiée — refaite si elle est abîmée, de forme
|
||
// inattendue ou disparue — et le lot rejoué UNE fois. Le service continue.
|
||
qCWarning(dcNymeaEnergy()) << "[Prévision] apprentissage : écriture refusée ("
|
||
<< erreur << ") — vérification de la table";
|
||
if (assurerTable(/*verifierContenu=*/true, /*refaireSiAbimee=*/true)
|
||
&& inserer(lignes, &inserees, &erreur) == 0) {
|
||
qCInfo(dcNymeaEnergy()) << "[Prévision] apprentissage : lot rejoué après réparation";
|
||
} else {
|
||
qCWarning(dcNymeaEnergy()) << "[Prévision] apprentissage :" << lignes.count()
|
||
<< "ligne(s) PERDUE(S) —" << erreur;
|
||
}
|
||
}
|
||
m_ecrites->fetchAndAddRelease(inserees);
|
||
journaliser(lignes, inserees);
|
||
purger(nowS);
|
||
}
|
||
m_lots->fetchAndAddRelease(1);
|
||
// Des GTI observés attendaient peut-être ces lignes (lot retenu par un verrou).
|
||
appliquerComplements();
|
||
}
|
||
|
||
/*!
|
||
* GTI observés arrivés APRÈS l'écriture de leur pas. Chacun complète SA ligne, s'il la
|
||
* trouve et si elle n'en a pas encore — et rien d'autre.
|
||
*/
|
||
void completer(const QList<ForecastLearningRow> &obs)
|
||
{
|
||
m_complementsEnAttente += obs;
|
||
appliquerComplements();
|
||
}
|
||
|
||
private:
|
||
void appliquerComplements()
|
||
{
|
||
if (m_complementsEnAttente.isEmpty())
|
||
return;
|
||
if (!m_valide->loadAcquire()) {
|
||
m_complements->fetchAndAddRelease(m_complementsEnAttente.count());
|
||
m_complementsEnAttente.clear();
|
||
return;
|
||
}
|
||
// Les lignes qu'ils complètent sont peut-être dans un lot retenu par un verrou : on les
|
||
// laisse passer d'abord, sinon l'UPDATE ne trouverait rien et l'observation serait perdue.
|
||
if (!m_enAttente.isEmpty())
|
||
return;
|
||
if (!m_db.transaction()) {
|
||
qCWarning(dcNymeaEnergy()) << "[Prévision] apprentissage : GTI observé remis à plus tard —"
|
||
<< m_db.lastError().text();
|
||
return;
|
||
}
|
||
QSqlQuery q(m_db);
|
||
// L'EXCEPTION ÉCRITE à « une ligne n'est jamais réécrite » (§31.7) : trois colonnes, et
|
||
// seulement quand la première est NULL. Une valeur écrite n'est jamais remplacée ; aucun
|
||
// autre champ — valid, writtenAt, la mesure — n'est touché.
|
||
q.prepare(QStringLiteral(
|
||
"UPDATE forecastLearning SET gtiObserved = ?, gtiObservedSource = ?, gtiObservedAt = ? "
|
||
"WHERE targetAt = ? AND siteId = ? AND planId = ? AND stepS = ? AND gtiObserved IS NULL"));
|
||
QList<int> effets;
|
||
for (const ForecastLearningRow &o : std::as_const(m_complementsEnAttente)) {
|
||
q.addBindValue(ouNul(o.gtiObserved, QMetaType::Double));
|
||
q.addBindValue(ouNul(o.gtiObservedSource));
|
||
q.addBindValue(ouNul(o.gtiObservedAt, QMetaType::LongLong));
|
||
q.addBindValue(o.targetAt);
|
||
q.addBindValue(o.siteId);
|
||
q.addBindValue(o.planId);
|
||
q.addBindValue(o.stepS);
|
||
if (!q.exec()) {
|
||
const QString erreur = q.lastError().text();
|
||
const int code = q.lastError().nativeErrorCode().toInt();
|
||
m_db.rollback();
|
||
if (code == 5 || code == 6)
|
||
return; // OCCUPÉE : on réessaiera au prochain lot
|
||
qCWarning(dcNymeaEnergy()) << "[Prévision] apprentissage :"
|
||
<< m_complementsEnAttente.count()
|
||
<< "GTI observé(s) PERDU(S) —" << erreur;
|
||
m_complements->fetchAndAddRelease(m_complementsEnAttente.count());
|
||
m_complementsEnAttente.clear();
|
||
return;
|
||
}
|
||
effets << (q.numRowsAffected() > 0 ? 1 : 0);
|
||
}
|
||
if (!m_db.commit()) {
|
||
m_db.rollback();
|
||
return;
|
||
}
|
||
for (int i = 0; i < m_complementsEnAttente.count(); ++i) {
|
||
const ForecastLearningRow &o = m_complementsEnAttente.at(i);
|
||
qCInfo(dcNymeaEnergy()).noquote()
|
||
<< QStringLiteral("[Prévision] apprentissage — GTI observé, pas %1 plan %2 : %3 W/m² "
|
||
"(publié pour %4) — %5")
|
||
.arg(QDateTime::fromSecsSinceEpoch(o.targetAt, QTimeZone::utc()).toString(Qt::ISODate),
|
||
o.planId.left(9))
|
||
.arg(o.gtiObserved ? *o.gtiObserved : 0.0, 0, 'f', 0)
|
||
.arg(QDateTime::fromSecsSinceEpoch(o.gtiObservedAt.value_or(0), QTimeZone::utc())
|
||
.toString(Qt::ISODate),
|
||
effets.at(i) ? QStringLiteral("ligne complétée")
|
||
: QStringLiteral("aucune ligne à compléter (absente, autre "
|
||
"pas, ou déjà renseignée)"));
|
||
m_completes->fetchAndAddRelease(effets.at(i));
|
||
}
|
||
m_complements->fetchAndAddRelease(m_complementsEnAttente.count());
|
||
m_complementsEnAttente.clear();
|
||
}
|
||
|
||
//! \return 0 si tout est écrit ; sinon le code SQLite natif de l'échec (≥ 1).
|
||
int inserer(const QList<ForecastLearningRow> &lignes, int *inserees, QString *erreur)
|
||
{
|
||
*inserees = 0;
|
||
if (!m_db.transaction()) {
|
||
*erreur = m_db.lastError().text();
|
||
return qMax(1, m_db.lastError().nativeErrorCode().toInt());
|
||
}
|
||
QSqlQuery q(m_db);
|
||
// Un pas ÉCOULÉ est un fait clos : une seconde écriture — un redémarrage qui revoit la
|
||
// même heure — ne réécrit pas la première, qui a vu le pas.
|
||
//
|
||
// `ON CONFLICT (clé) DO NOTHING`, et SURTOUT PAS `INSERT OR IGNORE` : celui-ci ignore AUSSI
|
||
// une violation de NOT NULL, et une ligne mal formée disparaîtrait sans une trace. Ici
|
||
// seul le doublon de clé est ignoré ; tout autre refus est une erreur, et se dit.
|
||
q.prepare(QStringLiteral("INSERT INTO forecastLearning (%1) VALUES (%2) "
|
||
"ON CONFLICT (targetAt, siteId, planId) DO NOTHING")
|
||
.arg(Colonnes.join(','),
|
||
QStringList(Colonnes.count(), QStringLiteral("?")).join(',')));
|
||
const qint64 ecrit = QDateTime::currentMSecsSinceEpoch();
|
||
for (const ForecastLearningRow &l : lignes) {
|
||
q.addBindValue(l.targetAt);
|
||
q.addBindValue(l.stepS);
|
||
q.addBindValue(l.siteId);
|
||
q.addBindValue(l.planId);
|
||
q.addBindValue(ouNul(l.gti, QMetaType::Double));
|
||
q.addBindValue(ouNul(l.tempC, QMetaType::Double));
|
||
q.addBindValue(ouNul(l.pW, QMetaType::Double));
|
||
// Une liste VIDE est une chaîne vide, pas NULL : « aucune correction » est une
|
||
// affirmation (la v1 n'a jamais corrigé), pas une absence.
|
||
const QString corrections = l.corrections.join(',');
|
||
q.addBindValue(corrections.isNull() ? QString("") : corrections);
|
||
q.addBindValue(l.transportVersion);
|
||
q.addBindValue(ouNul(l.kwp, QMetaType::Double));
|
||
q.addBindValue(ouNul(l.performanceRatio, QMetaType::Double));
|
||
q.addBindValue(ouNul(l.tempCoeffPctPerK, QMetaType::Double));
|
||
q.addBindValue(ouNul(l.noctC, QMetaType::Double));
|
||
q.addBindValue(ouNul(l.acLimitW, QMetaType::Int));
|
||
q.addBindValue(ouNul(l.issuedAt, QMetaType::LongLong));
|
||
q.addBindValue(ouNul(l.model));
|
||
q.addBindValue(l.observedAt);
|
||
q.addBindValue(l.sitePlans);
|
||
q.addBindValue(l.sitePlansExpected);
|
||
q.addBindValue(l.dayAheadAt);
|
||
q.addBindValue(ouNul(l.contentIdDayAhead));
|
||
q.addBindValue(ouNul(l.contentIdHourAhead));
|
||
q.addBindValue(ouNul(l.measuredW, QMetaType::Double));
|
||
q.addBindValue(ouNul(l.measuredSource));
|
||
q.addBindValue(l.measuredMinutes);
|
||
q.addBindValue(ouNul(l.curtailed));
|
||
q.addBindValue(ouNul(l.snow));
|
||
q.addBindValue(int(l.valid));
|
||
q.addBindValue(ecrit);
|
||
q.addBindValue(ouNul(l.gtiObserved, QMetaType::Double));
|
||
q.addBindValue(ouNul(l.gtiObservedSource));
|
||
q.addBindValue(ouNul(l.gtiObservedAt, QMetaType::LongLong));
|
||
if (!q.exec()) {
|
||
*erreur = q.lastError().text();
|
||
const int code = qMax(1, q.lastError().nativeErrorCode().toInt());
|
||
m_db.rollback();
|
||
*inserees = 0;
|
||
return code;
|
||
}
|
||
*inserees += q.numRowsAffected() > 0 ? 1 : 0;
|
||
}
|
||
if (!m_db.commit()) {
|
||
*erreur = m_db.lastError().text();
|
||
*inserees = 0;
|
||
return qMax(1, m_db.lastError().nativeErrorCode().toInt());
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/*!
|
||
* La révision du SITE en vigueur à l'instant \p instantMs pour le pas \p targetAt.
|
||
*
|
||
* « En vigueur » = la plus récemment APPARUE parmi celles encore VUES à cet instant
|
||
* (`firstSeenAt ≤ X ≤ lastSeenAt`) ; à défaut, la dernière vue avant X. Ce n'est pas la
|
||
* règle de \c ForecastStore::courbe() (`MAX(lastSeenAt)`), qui sert la courbe COURANTE : pour
|
||
* un instant PASSÉ, `MAX(lastSeenAt)` désignerait un contenu revu APRÈS X. La limite du
|
||
* §23.2 reste : \c lastSeenAt est mis à jour en place, donc un retour à un contenu antérieur
|
||
* rend une réponse approchée.
|
||
*/
|
||
QString contenuEnVigueur(qint64 targetAt, const QString &siteId, qint64 instantMs)
|
||
{
|
||
QSqlQuery q(m_db);
|
||
q.prepare(QStringLiteral(
|
||
"SELECT contentId FROM forecastPv "
|
||
"WHERE targetAt = ? AND siteId = ? AND firstSeenAt <= ? "
|
||
"ORDER BY (lastSeenAt >= ?) DESC, "
|
||
" CASE WHEN lastSeenAt >= ? THEN firstSeenAt ELSE lastSeenAt END DESC "
|
||
"LIMIT 1"));
|
||
q.addBindValue(targetAt);
|
||
q.addBindValue(siteId);
|
||
q.addBindValue(instantMs);
|
||
q.addBindValue(instantMs);
|
||
q.addBindValue(instantMs);
|
||
// Pas de forecastPv (magasin hors service) ou aucune révision : NULL, jamais deviné.
|
||
if (!q.exec() || !q.next())
|
||
return QString();
|
||
return q.value(0).toString();
|
||
}
|
||
|
||
/*!
|
||
* La table existe-t-elle, avec la forme attendue, et lisible ? Sinon elle est REFAITE — sauf
|
||
* la forme etm67 exacte, MIGRÉE en place (colonnes ajoutées, lignes conservées).
|
||
* \param verifierContenu Passe aussi un \c quick_check sur la table.
|
||
* \param refaireSiAbimee Refaire même quand la forme est la bonne (après un échec d'écriture
|
||
* que la vérification n'explique pas).
|
||
*/
|
||
bool assurerTable(bool verifierContenu, bool refaireSiAbimee = false)
|
||
{
|
||
QSqlQuery q(m_db);
|
||
QStringList trouvees;
|
||
if (!q.exec(QStringLiteral("PRAGMA table_info(forecastLearning)"))) {
|
||
// Le FICHIER n'est pas lisible comme une base : rien à refaire ici sans détruire
|
||
// aussi l'historique des révisions. Journalisé, et le service continue sans nous.
|
||
horsService(q.lastError().text());
|
||
return false;
|
||
}
|
||
while (q.next())
|
||
trouvees << q.value(1).toString();
|
||
|
||
// UNE TABLE etm67 — la forme exacte d'avant le GTI observé — est MIGRÉE EN PLACE, jamais
|
||
// refaite : ses lignes sont l'historique du banc. Les colonnes ajoutées sont nullables,
|
||
// sans défaut ; les lignes existantes y portent NULL (« inconnu »).
|
||
if (trouvees == ColonnesEtm67) {
|
||
bool ok = m_db.transaction();
|
||
for (const auto &c : AjoutsEtm68)
|
||
ok = ok && q.exec(QStringLiteral("ALTER TABLE forecastLearning ADD COLUMN %1 %2")
|
||
.arg(c.first, c.second));
|
||
ok = ok && m_db.commit();
|
||
if (ok) {
|
||
qCInfo(dcNymeaEnergy()) << "[Prévision] apprentissage : table etm67 migrée en place —"
|
||
<< AjoutsEtm68.count() << "colonne(s) ajoutée(s), lignes conservées";
|
||
trouvees = Colonnes;
|
||
} else {
|
||
// Un échec de migration ne REFAIT rien : la transaction est annulée, la table reste
|
||
// etm67 avec ses lignes, et l'enregistrement s'arrête jusqu'au prochain démarrage,
|
||
// qui retentera. Perdre l'historique pour une migration ratée serait l'inverse du but.
|
||
const QString erreur = q.lastError().text().isEmpty() ? m_db.lastError().text()
|
||
: q.lastError().text();
|
||
m_db.rollback();
|
||
qCWarning(dcNymeaEnergy()) << "[Prévision] apprentissage : migration etm67 refusée —"
|
||
<< erreur << "— table laissée telle quelle";
|
||
return false;
|
||
}
|
||
}
|
||
|
||
QString motif;
|
||
if (!trouvees.isEmpty() && trouvees != Colonnes)
|
||
motif = QStringLiteral("forme inattendue (%1 colonne(s))").arg(trouvees.count());
|
||
else if (!trouvees.isEmpty() && verifierContenu) {
|
||
QSqlQuery c(m_db);
|
||
if (!c.exec(QStringLiteral("PRAGMA quick_check(forecastLearning)")))
|
||
motif = QStringLiteral("vérification impossible : %1").arg(c.lastError().text());
|
||
else if (c.next() && c.value(0).toString() != QLatin1String("ok"))
|
||
motif = QStringLiteral("abîmée : %1").arg(c.value(0).toString());
|
||
}
|
||
if (refaireSiAbimee && trouvees.isEmpty())
|
||
qCWarning(dcNymeaEnergy()) << "[Prévision] apprentissage : table DISPARUE — recréée ;"
|
||
" le service continue";
|
||
if (motif.isEmpty() && refaireSiAbimee && !trouvees.isEmpty()) {
|
||
// La forme est bonne, le contenu passe la vérification, et l'écriture échoue
|
||
// quand même : on ne refait pas une table saine sur une hypothèse. On dit seulement.
|
||
return true;
|
||
}
|
||
if (!motif.isEmpty()) {
|
||
qCWarning(dcNymeaEnergy()).noquote()
|
||
<< QStringLiteral("[Prévision] apprentissage : table %1 — REFAITE, son contenu est "
|
||
"perdu ; le service continue").arg(motif);
|
||
if (!q.exec(QStringLiteral("DROP TABLE IF EXISTS forecastLearning"))) {
|
||
// Une table dont les PAGES sont abîmées ne se supprime pas : DROP parcourt son
|
||
// arbre, et c'est lui qui est cassé. On la met DE CÔTÉ par un renommage, qui ne
|
||
// touche que le schéma — elle reste dans le fichier, illisible et inerte, sous un
|
||
// nom daté — et une table neuve prend sa place.
|
||
const QString cote = QStringLiteral("forecastLearning_abimee_%1")
|
||
.arg(QDateTime::currentMSecsSinceEpoch());
|
||
const QString erreurDrop = q.lastError().text();
|
||
q.exec(QStringLiteral("DROP INDEX IF EXISTS idxForecastLearningPlan"));
|
||
if (!q.exec(QStringLiteral("ALTER TABLE forecastLearning RENAME TO %1").arg(cote))) {
|
||
horsService(QStringLiteral("%1 ; mise de côté impossible : %2")
|
||
.arg(erreurDrop, q.lastError().text()));
|
||
return false;
|
||
}
|
||
qCWarning(dcNymeaEnergy()).noquote()
|
||
<< QStringLiteral("[Prévision] apprentissage : suppression impossible (%1) — "
|
||
"table abîmée MISE DE CÔTÉ sous %2").arg(erreurDrop, cote);
|
||
}
|
||
}
|
||
if (!q.exec(QString::fromLatin1(Creation))
|
||
|| !q.exec(QStringLiteral("CREATE INDEX IF NOT EXISTS idxForecastLearningPlan "
|
||
"ON forecastLearning(planId, targetAt)"))) {
|
||
horsService(q.lastError().text());
|
||
return false;
|
||
}
|
||
m_valide->storeRelease(1);
|
||
return true;
|
||
}
|
||
|
||
void purger(qint64 nowS)
|
||
{
|
||
// 400 JOURS : un cycle saisonnier complet, et la même saison de l'an passé pour le
|
||
// comparer. La table croît d'une ligne par plan et par heure — ~9 600 lignes par plan.
|
||
QSqlQuery q(m_db);
|
||
q.prepare(QStringLiteral("DELETE FROM forecastLearning WHERE targetAt < ?"));
|
||
q.addBindValue(nowS - qint64(ForecastLearningLog::RetentionJours) * 86400);
|
||
if (!q.exec())
|
||
qCWarning(dcNymeaEnergy()) << "[Prévision] apprentissage : purge refusée :"
|
||
<< q.lastError().text();
|
||
else if (q.numRowsAffected() > 0)
|
||
qCInfo(dcNymeaEnergy()) << "[Prévision] apprentissage : purge —" << q.numRowsAffected()
|
||
<< "ligne(s) au-delà de" << ForecastLearningLog::RetentionJours
|
||
<< "jours";
|
||
}
|
||
|
||
void journaliser(const QList<ForecastLearningRow> &lignes, int inserees)
|
||
{
|
||
// L'ISSUE, pas seulement l'issue remarquable (règle 7-c) — et ce qui a été LU : la
|
||
// couverture de la mesure, l'écrêtage, la neige, les révisions trouvées.
|
||
int valides = 0;
|
||
for (const ForecastLearningRow &l : lignes)
|
||
valides += l.valid ? 1 : 0;
|
||
const ForecastLearningRow &l = lignes.first();
|
||
qCInfo(dcNymeaEnergy()).noquote()
|
||
<< QStringLiteral("[Prévision] apprentissage — pas %1 : %2 ligne(s) écrite(s) sur %3, "
|
||
"%4 valide(s) ; production %5 (%6 min) ; écrêtage %7 ; neige %8 ; "
|
||
"révision veille %9, H-1 %10")
|
||
.arg(QDateTime::fromSecsSinceEpoch(l.targetAt, QTimeZone::utc()).toString(Qt::ISODate))
|
||
.arg(inserees).arg(lignes.count()).arg(valides)
|
||
.arg(l.measuredW ? QStringLiteral("%1 W").arg(*l.measuredW, 0, 'f', 0)
|
||
: QStringLiteral("inconnue"))
|
||
.arg(l.measuredMinutes)
|
||
.arg(texteBool(l.curtailed), texteBool(l.snow))
|
||
.arg(l.contentIdDayAhead.isEmpty() ? QStringLiteral("aucune") : l.contentIdDayAhead.left(8),
|
||
l.contentIdHourAhead.isEmpty() ? QStringLiteral("aucune") : l.contentIdHourAhead.left(8));
|
||
}
|
||
|
||
void horsService(const QString &erreur)
|
||
{
|
||
m_valide->storeRelease(0);
|
||
if (m_horsServiceSignale)
|
||
return;
|
||
m_horsServiceSignale = true;
|
||
qCWarning(dcNymeaEnergy()) << "[Prévision] apprentissage HORS SERVICE — base illisible"
|
||
<< m_chemin << ":" << erreur
|
||
<< "— rien n'est enregistré, l'arbitrage n'en dépend pas";
|
||
}
|
||
|
||
QString m_chemin;
|
||
QString m_connexion;
|
||
QSqlDatabase m_db;
|
||
QAtomicInt *m_ecrites;
|
||
QAtomicInt *m_lots;
|
||
QAtomicInt *m_complements;
|
||
QAtomicInt *m_completes;
|
||
QAtomicInt *m_valide;
|
||
QList<ForecastLearningRow> m_enAttente;
|
||
QList<ForecastLearningRow> m_complementsEnAttente;
|
||
bool m_horsServiceSignale = false;
|
||
};
|
||
|
||
// ============================================================================================
|
||
// LE CÔTÉ CYCLE — copies en mémoire, aucune requête
|
||
// ============================================================================================
|
||
|
||
ForecastLearningLog::ForecastLearningLog(const QString &cheminFichier, const QTimeZone &fuseau,
|
||
QObject *parent)
|
||
: QObject(parent)
|
||
, m_fuseau(fuseau.isValid() ? fuseau : QTimeZone::systemTimeZone())
|
||
{
|
||
m_valide.storeRelease(1);
|
||
m_fil = new QThread(this);
|
||
m_fil->setObjectName(QStringLiteral("etm-forecast-learning"));
|
||
m_ecrivain = new ForecastLearningWriter(cheminFichier, &m_ecrites, &m_lots, &m_complements,
|
||
&m_completes, &m_valide);
|
||
m_ecrivain->moveToThread(m_fil);
|
||
m_fil->start(QThread::LowPriority);
|
||
ForecastLearningWriter *w = m_ecrivain;
|
||
QMetaObject::invokeMethod(w, [w]() { w->ouvrir(); }, Qt::QueuedConnection);
|
||
}
|
||
|
||
ForecastLearningLog::~ForecastLearningLog()
|
||
{
|
||
// Les lots en attente passent AVANT la fermeture : l'appel bloquant est rangé derrière eux.
|
||
ForecastLearningWriter *w = m_ecrivain;
|
||
QMetaObject::invokeMethod(w, [w]() { w->fermer(); delete w; }, Qt::BlockingQueuedConnection);
|
||
m_fil->quit();
|
||
m_fil->wait();
|
||
}
|
||
|
||
ForecastPlanObservation ForecastLearningLog::lirePlan(const QVariantMap &charge,
|
||
const QVariantMap ¶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;
|
||
}
|
||
}
|
||
}
|
||
// Le GTI observé AVANT finaliser() : un pas encore en tampon le reçoit dans sa ligne. Les
|
||
// autres compléments partent APRÈS le lot, pour que l'UPDATE trouve la ligne qu'il vise.
|
||
const QList<ForecastLearningRow> complements = siteId.isEmpty()
|
||
? QList<ForecastLearningRow>() : rattacherGtiObserve(siteId, plans);
|
||
finaliser(now);
|
||
if (!complements.isEmpty()) {
|
||
ForecastLearningWriter *w = m_ecrivain;
|
||
QMetaObject::invokeMethod(w, [w, complements]() { w->completer(complements); },
|
||
Qt::QueuedConnection);
|
||
}
|
||
}
|
||
|
||
QList<ForecastLearningRow> ForecastLearningLog::rattacherGtiObserve(
|
||
const QString &siteId, const QList<ForecastPlanObservation> &plans)
|
||
{
|
||
QList<ForecastLearningRow> complements;
|
||
for (const ForecastPlanObservation &p : plans) {
|
||
// observedGtiTime == 0 : rien publié (défaut de l'état). Ni 0 W/m², ni un instant inventé.
|
||
if (!p.gtiObserved || p.gtiObservedAt <= 0)
|
||
continue;
|
||
const QString cle = siteId + QLatin1Char('|') + p.planId;
|
||
if (m_gtiObserveVu.value(cle, 0) == p.gtiObservedAt)
|
||
continue; // déjà traité : l'état est relu à chaque cycle
|
||
m_gtiObserveVu.insert(cle, p.gtiObservedAt);
|
||
|
||
// LA CONVENTION DE `gti` PRÉVU (§14.3) : une valeur est rattachée au DÉBUT du pas qu'elle
|
||
// moyenne. Open-Meteo horodate une moyenne horaire à la FIN de son heure — la valeur à T
|
||
// vaut pour (T − 1 h, T] — donc le pas visé commence à T − 1 h.
|
||
const qint64 debut = p.gtiObservedAt - PasSatelliteS;
|
||
ForecastLearningRow o;
|
||
o.targetAt = debut;
|
||
o.stepS = PasSatelliteS; // une heure observée ne se répartit pas sur 4 quarts
|
||
o.siteId = siteId;
|
||
o.planId = p.planId;
|
||
o.gtiObserved = p.gtiObserved;
|
||
o.gtiObservedSource = QString::fromLatin1(SourceGtiObserve);
|
||
o.gtiObservedAt = p.gtiObservedAt;
|
||
|
||
auto t = m_tampon.find(Cle{siteId, debut});
|
||
if (t != m_tampon.end() && t->contains(p.planId)) {
|
||
ForecastLearningRow &l = (*t)[p.planId];
|
||
if (l.stepS == PasSatelliteS && !l.gtiObserved) {
|
||
l.gtiObserved = o.gtiObserved;
|
||
l.gtiObservedSource = o.gtiObservedSource;
|
||
l.gtiObservedAt = o.gtiObservedAt;
|
||
}
|
||
continue;
|
||
}
|
||
complements << o;
|
||
}
|
||
return complements;
|
||
}
|
||
|
||
void ForecastLearningLog::observerProduction(const QDateTime &finMinute, double productionW)
|
||
{
|
||
m_minutes.insert(finMinute.toUTC().toSecsSinceEpoch(), productionW);
|
||
}
|
||
|
||
void ForecastLearningLog::finaliser(const QDateTime &now)
|
||
{
|
||
const qint64 nowS = now.toUTC().toSecsSinceEpoch();
|
||
QList<ForecastLearningRow> lot;
|
||
|
||
if (!m_producteur && !m_tampon.isEmpty() && !m_sansProducteurSignale) {
|
||
m_sansProducteurSignale = true;
|
||
qCInfo(dcNymeaEnergy()) << "[Prévision] apprentissage : aucun producteur (smartmeterproducer)"
|
||
" — la production du site n'est pas mesurée, elle reste NULL";
|
||
} else if (m_producteur) {
|
||
m_sansProducteurSignale = false;
|
||
}
|
||
|
||
for (auto it = m_tampon.begin(); it != m_tampon.end();) {
|
||
QHash<QString, ForecastLearningRow> &parPlan = it.value();
|
||
// Les plans d'un même instant peuvent ne pas partager le pas : chacun s'écrit à SA fin.
|
||
QList<QString> prets;
|
||
for (auto p = parPlan.constBegin(); p != parPlan.constEnd(); ++p)
|
||
if (p.value().targetAt + p.value().stepS + GraceS <= nowS)
|
||
prets << p.key();
|
||
for (const QString &planId : std::as_const(prets)) {
|
||
ForecastLearningRow l = parPlan.value(planId);
|
||
const qint64 debut = l.targetAt;
|
||
const qint64 fin = l.targetAt + l.stepS;
|
||
int memePas = 0;
|
||
for (const ForecastLearningRow &autre : std::as_const(parPlan))
|
||
memePas += autre.stepS == l.stepS ? 1 : 0;
|
||
l.sitePlans = memePas;
|
||
l.sitePlansExpected = m_attendus.value(it.key());
|
||
l.dayAheadAt = instantVeille(QDateTime::fromSecsSinceEpoch(debut, QTimeZone::utc()),
|
||
m_fuseau).toSecsSinceEpoch();
|
||
|
||
// --- LA MESURE : la moyenne des minutes du pas, et combien il y en a eu -----------
|
||
// Un échantillon horodaté t couvre [t − 60 s, t) : ceux du pas sont dans ]début, fin].
|
||
double somme = 0, pic = 0;
|
||
int n = 0;
|
||
for (auto m = m_minutes.upperBound(debut); m != m_minutes.end() && m.key() <= fin; ++m) {
|
||
const double produitW = -m.value(); // nymea : production NÉGATIVE
|
||
somme += produitW;
|
||
pic = n == 0 ? produitW : qMax(pic, produitW);
|
||
++n;
|
||
}
|
||
l.measuredMinutes = n;
|
||
const int attendues = l.stepS / 60;
|
||
const bool couverte = m_producteur && attendues > 0
|
||
&& n >= qCeil(CouvertureMin * attendues);
|
||
if (couverte) {
|
||
l.measuredW = somme / n;
|
||
l.measuredSource = QString::fromLatin1(SourceBilan);
|
||
}
|
||
// --- L'ÉCRÊTAGE : ce que les données présentes permettent, et rien de plus --------
|
||
// Seul détecteur : le plafond AC du SITE, déclaré. Une minute à ≥ 98 % du plafond est
|
||
// une minute où l'onduleur bornait — la mesure y sous-estime le potentiel. Sans
|
||
// plafond déclaré, ou sans mesure, on ne cherche rien : NULL, pas « non écrêté ».
|
||
if (m_producteur && l.acLimitW && n > 0 && pic >= RapportPlafond * *l.acLimitW)
|
||
l.curtailed = true;
|
||
else if (couverte && l.acLimitW)
|
||
l.curtailed = false;
|
||
// --- LA NEIGE : la chute LUE sur le site pendant le pas — pas la couverture ---------
|
||
for (auto s = m_neige.lowerBound(debut); s != m_neige.end() && s.key() < fin; ++s)
|
||
l.snow = (l.snow && *l.snow) || s.value() > 0;
|
||
|
||
l.valid = l.measuredW && l.pW && l.curtailed != std::optional<bool>(true)
|
||
&& l.snow != std::optional<bool>(true)
|
||
&& l.sitePlansExpected > 0 && l.sitePlans == l.sitePlansExpected;
|
||
const QString filigrane = l.siteId + QLatin1Char('|') + l.planId;
|
||
m_ecritJusqua.insert(filigrane, qMax(m_ecritJusqua.value(filigrane, debut), debut));
|
||
lot << l;
|
||
}
|
||
for (const QString &planId : std::as_const(prets))
|
||
parPlan.remove(planId);
|
||
if (parPlan.isEmpty()) {
|
||
m_attendus.remove(it.key());
|
||
it = m_tampon.erase(it);
|
||
} else {
|
||
++it;
|
||
}
|
||
}
|
||
|
||
// Une journée et deux heures de minutes suffisent à tout pas encore en attente.
|
||
const qint64 borne = nowS - 26 * 3600;
|
||
while (!m_minutes.isEmpty() && m_minutes.firstKey() < borne)
|
||
m_minutes.erase(m_minutes.begin());
|
||
while (!m_neige.isEmpty() && m_neige.firstKey() < borne)
|
||
m_neige.erase(m_neige.begin());
|
||
|
||
if (lot.isEmpty())
|
||
return;
|
||
ForecastLearningWriter *w = m_ecrivain;
|
||
QMetaObject::invokeMethod(w, [w, lot, nowS]() { w->ecrire(lot, nowS); }, Qt::QueuedConnection);
|
||
}
|