Brief 3 T4. Une minute = consommation du site (échantillon d'une minute du bilan, jamais ses cumuls) moins chaque charge pilotée selon sa source ; sous none, sa puissance commandée, et la minute le garde. Deux tables dédiées dans energy-forecast.sqlite (baseloadMinute, baseloadMinuteLoad), fil d'écriture propre, lots de 5 min, lecture par journée, rétention 35 jours. forecastPv et forecastLearning ne sont pas touchées. Profil : médiane par case locale et type de jour (ouvré/week-end) sur 28 jours ; none sous 7 jours couverts à 90 %. Qualité : minutes comptées à la commande, négatives, écartées, et par charge source et constantMeasurement (la constante déguisée du banc). Tests loadmodel : soustraction par source, profil synthétique (médiane, jours de 23/25 h), seuil des 7 jours, fichier etm67 réel intact, jamais bloquant, redémarrage, rétention, fichier illisible. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
557 lines
23 KiB
C++
557 lines
23 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||
#include "baseloadlog.h"
|
||
|
||
#include <QDir>
|
||
#include <QFileInfo>
|
||
#include <QLoggingCategory>
|
||
#include <QMap>
|
||
#include <QMutexLocker>
|
||
#include <QSqlDatabase>
|
||
#include <QSqlError>
|
||
#include <QSqlQuery>
|
||
#include <QThread>
|
||
#include <QUuid>
|
||
#include <QtMath>
|
||
|
||
#include <algorithm>
|
||
|
||
Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)
|
||
|
||
namespace {
|
||
//! Les colonnes, dans l'ordre : elles servent à CRÉER et à RECONNAÎTRE une table.
|
||
const QStringList ColonnesMinute = {
|
||
QStringLiteral("at"), QStringLiteral("siteW"), QStringLiteral("loadsW"), QStringLiteral("baseW"),
|
||
QStringLiteral("commandedLoads"), QStringLiteral("writtenAt")
|
||
};
|
||
const QStringList ColonnesCharge = {
|
||
QStringLiteral("at"), QStringLiteral("loadId"), QStringLiteral("source"), QStringLiteral("powerW")
|
||
};
|
||
|
||
const char *CreationMinute =
|
||
"CREATE TABLE IF NOT EXISTS baseloadMinute ("
|
||
// FIN de la minute, epoch s UTC — l'horodatage de l'échantillon du bilan, tel quel.
|
||
" at INTEGER NOT NULL PRIMARY KEY,"
|
||
// Consommation du SITE, W moyens : import − export + production − stockage (bilan 1 min).
|
||
" siteW REAL NOT NULL,"
|
||
// Σ des charges pilotées retenues ; NULL si l'une n'avait pas de puissance connue.
|
||
" loadsW REAL,"
|
||
// siteW − loadsW ; NULL avec loadsW. Une valeur NÉGATIVE est gardée : elle est un constat.
|
||
" baseW REAL,"
|
||
" commandedLoads INTEGER NOT NULL," // charges comptées à leur COMMANDE (none)
|
||
" writtenAt INTEGER NOT NULL)"; // ms
|
||
const char *CreationCharge =
|
||
"CREATE TABLE IF NOT EXISTS baseloadMinuteLoad ("
|
||
" at INTEGER NOT NULL,"
|
||
" loadId TEXT NOT NULL,"
|
||
" source TEXT NOT NULL," // meter | device | none
|
||
" powerW REAL," // mesuré, ou commandé sous none ; NULL inconnu
|
||
" PRIMARY KEY (at, loadId))";
|
||
|
||
QVariant nul(QMetaType::Type t) { return QVariant(QMetaType(t)); }
|
||
|
||
std::optional<double> mediane(QList<double> v)
|
||
{
|
||
if (v.isEmpty())
|
||
return std::nullopt;
|
||
std::sort(v.begin(), v.end());
|
||
const int n = v.count();
|
||
return n % 2 ? v.at(n / 2) : (v.at(n / 2 - 1) + v.at(n / 2)) / 2.0;
|
||
}
|
||
|
||
//! Type de jour : 0 ouvré (lundi-vendredi), 1 week-end. Les jours fériés ne sont pas connus.
|
||
int typeDeJour(const QDate &d) { return d.dayOfWeek() >= 6 ? 1 : 0; }
|
||
}
|
||
|
||
// ============================================================================================
|
||
// La minute, et le profil — logique PURE
|
||
// ============================================================================================
|
||
|
||
std::optional<double> BaseloadMinute::baseW() const
|
||
{
|
||
double somme = 0;
|
||
for (const BaseloadLoadPart &c : charges) {
|
||
if (!c.powerW)
|
||
return std::nullopt; // une commande inconnue : on ne compte pas 0
|
||
somme += *c.powerW;
|
||
}
|
||
return siteW - somme;
|
||
}
|
||
|
||
int BaseloadMinute::chargesCommandees() const
|
||
{
|
||
int n = 0;
|
||
for (const BaseloadLoadPart &c : charges)
|
||
n += c.source == QLatin1String("none") ? 1 : 0;
|
||
return n;
|
||
}
|
||
|
||
BaseloadProfile BaseloadLog::calculerProfil(const QList<BaseloadMinute> &minutes, const QDateTime &now,
|
||
const QTimeZone &fuseau, int resolutionS)
|
||
{
|
||
BaseloadProfile p;
|
||
p.calcule = true;
|
||
p.calculeA = now.toUTC();
|
||
p.resolutionS = resolutionS;
|
||
const int cases = resolutionS > 0 ? 86400 / resolutionS : 0;
|
||
p.medianes[0] = QVector<std::optional<double>>(cases);
|
||
p.medianes[1] = QVector<std::optional<double>>(cases);
|
||
|
||
const qint64 finS = now.toSecsSinceEpoch();
|
||
const qint64 debutS = finS - qint64(BaseloadProfile::FenetreJours) * 86400;
|
||
|
||
// (jour local, case) → somme et nombre de minutes ; jour local → minutes connues.
|
||
struct Cumul { double somme = 0; int n = 0; };
|
||
QMap<QPair<QDate, int>, Cumul> parCase;
|
||
QMap<QDate, int> parJour;
|
||
struct ParCharge { QString derniereSource; qint64 derniere = 0; QHash<QString, int> parSource;
|
||
double min = 0, max = 0; int mesurees = 0; };
|
||
QMap<QString, ParCharge> charges;
|
||
|
||
for (const BaseloadMinute &m : minutes) {
|
||
if (m.finMinute <= debutS || m.finMinute > finS)
|
||
continue;
|
||
// La minute couvre [fin − 60 s, fin) : elle se range sous l'heure murale de son DÉBUT.
|
||
const QDateTime locale = QDateTime::fromSecsSinceEpoch(m.finMinute - 60, QTimeZone::utc())
|
||
.toTimeZone(fuseau);
|
||
for (const BaseloadLoadPart &c : m.charges) {
|
||
ParCharge &q = charges[c.loadId];
|
||
q.parSource[c.source] += 1;
|
||
if (m.finMinute >= q.derniere) {
|
||
q.derniere = m.finMinute;
|
||
q.derniereSource = c.source;
|
||
}
|
||
if (c.source != QLatin1String("none") && c.powerW) {
|
||
q.min = q.mesurees == 0 ? *c.powerW : qMin(q.min, *c.powerW);
|
||
q.max = q.mesurees == 0 ? *c.powerW : qMax(q.max, *c.powerW);
|
||
++q.mesurees;
|
||
}
|
||
}
|
||
const std::optional<double> b = m.baseW();
|
||
if (!b) {
|
||
++p.minutesIncompletes;
|
||
continue;
|
||
}
|
||
++p.minutes;
|
||
if (m.chargesCommandees() > 0)
|
||
++p.minutesEstimees;
|
||
if (*b < 0)
|
||
++p.minutesNegatives;
|
||
if (cases == 0)
|
||
continue;
|
||
const int c = (locale.time().msecsSinceStartOfDay() / 1000) / resolutionS;
|
||
Cumul &cu = parCase[qMakePair(locale.date(), c)];
|
||
cu.somme += *b;
|
||
++cu.n;
|
||
parJour[locale.date()] += 1;
|
||
}
|
||
|
||
// UN JOUR COMPTE s'il est couvert à 90 % de SES minutes — 1 380, 1 440 ou 1 500 selon
|
||
// l'heure d'été : la durée du jour local se lit en instants, jamais en heures murales.
|
||
for (auto it = parJour.constBegin(); it != parJour.constEnd(); ++it) {
|
||
const QDateTime d0(it.key(), QTime(0, 0), fuseau);
|
||
const QDateTime d1(it.key().addDays(1), QTime(0, 0), fuseau);
|
||
const qint64 attendues = (d1.toSecsSinceEpoch() - d0.toSecsSinceEpoch()) / 60;
|
||
if (it.value() >= qCeil(BaseloadProfile::CouvertureMin * attendues))
|
||
(typeDeJour(it.key()) == 0 ? p.joursOuvres : p.joursWeekEnd) += 1;
|
||
}
|
||
|
||
// LA MÉDIANE, par type de jour et par case, des moyennes de case couvertes à 90 %.
|
||
QVector<QList<double>> valeurs[2] = {QVector<QList<double>>(cases), QVector<QList<double>>(cases)};
|
||
const int attenduesParCase = resolutionS / 60;
|
||
for (auto it = parCase.constBegin(); it != parCase.constEnd(); ++it) {
|
||
// Au jour de 25 h, la case répétée reçoit deux heures de minutes : sa moyenne les mêle,
|
||
// sa couverture est jugée sur une case normale. Au jour de 23 h, la case qui manque
|
||
// n'existe pas — elle ne compte pour rien.
|
||
if (it.value().n < qCeil(BaseloadProfile::CouvertureMin * attenduesParCase))
|
||
continue;
|
||
valeurs[typeDeJour(it.key().first)][it.key().second] << it.value().somme / it.value().n;
|
||
}
|
||
const bool assez = p.joursOuvres + p.joursWeekEnd >= BaseloadProfile::JoursMin;
|
||
p.model = assez ? QStringLiteral("profile") : QStringLiteral("none");
|
||
if (assez) {
|
||
for (int t = 0; t < 2; ++t)
|
||
for (int c = 0; c < cases; ++c)
|
||
if (valeurs[t][c].count() >= BaseloadProfile::ValeursMinParCase)
|
||
p.medianes[t][c] = mediane(valeurs[t][c]);
|
||
}
|
||
|
||
for (auto it = charges.constBegin(); it != charges.constEnd(); ++it) {
|
||
BaseloadLoadQuality q;
|
||
q.loadId = it.key();
|
||
q.source = it.value().derniereSource;
|
||
q.minutesParSource = it.value().parSource;
|
||
if (it.value().mesurees >= BaseloadProfile::MinutesMesureesMin)
|
||
q.constante = (it.value().max - it.value().min) < BaseloadProfile::ConstanteW;
|
||
p.charges << q;
|
||
}
|
||
return p;
|
||
}
|
||
|
||
QList<std::optional<double>> BaseloadProfile::serie(const QDateTime &start, int stepS, int steps,
|
||
const QTimeZone &fuseau) const
|
||
{
|
||
QList<std::optional<double>> s;
|
||
for (int i = 0; i < steps; ++i)
|
||
s << std::nullopt;
|
||
if (model != QLatin1String("profile") || stepS != resolutionS || resolutionS <= 0)
|
||
return s;
|
||
for (int i = 0; i < steps; ++i) {
|
||
const QDateTime locale = start.addSecs(qint64(i) * stepS).toTimeZone(fuseau);
|
||
const int c = (locale.time().msecsSinceStartOfDay() / 1000) / resolutionS;
|
||
const QVector<std::optional<double>> &m = medianes[typeDeJour(locale.date())];
|
||
if (c >= 0 && c < m.count())
|
||
s[i] = m.at(c);
|
||
}
|
||
return s;
|
||
}
|
||
|
||
QVariantMap BaseloadProfile::qualiteVariant() const
|
||
{
|
||
QVariantMap q;
|
||
q.insert(QStringLiteral("windowDays"), FenetreJours);
|
||
q.insert(QStringLiteral("minDays"), JoursMin);
|
||
q.insert(QStringLiteral("resolutionS"), resolutionS);
|
||
q.insert(QStringLiteral("daysCovered"), QVariantMap{{QStringLiteral("workday"), joursOuvres},
|
||
{QStringLiteral("weekend"), joursWeekEnd}});
|
||
q.insert(QStringLiteral("minutes"), minutes);
|
||
q.insert(QStringLiteral("commandedMinutes"), minutesEstimees);
|
||
q.insert(QStringLiteral("negativeMinutes"), minutesNegatives);
|
||
q.insert(QStringLiteral("incompleteMinutes"), minutesIncompletes);
|
||
if (calculeA.isValid())
|
||
q.insert(QStringLiteral("computedAt"), calculeA.toUTC().toString(Qt::ISODate));
|
||
QVariantList cs;
|
||
for (const BaseloadLoadQuality &c : charges) {
|
||
QVariantMap m;
|
||
m.insert(QStringLiteral("loadId"), c.loadId);
|
||
m.insert(QStringLiteral("source"), c.source);
|
||
QVariantMap parSource;
|
||
for (auto it = c.minutesParSource.constBegin(); it != c.minutesParSource.constEnd(); ++it)
|
||
parSource.insert(it.key(), it.value());
|
||
m.insert(QStringLiteral("minutesBySource"), parSource);
|
||
// ABSENT faute de minutes mesurées pour se prononcer — jamais « false » par défaut.
|
||
if (c.constante)
|
||
m.insert(QStringLiteral("constantMeasurement"), *c.constante);
|
||
cs << m;
|
||
}
|
||
q.insert(QStringLiteral("loads"), cs);
|
||
return q;
|
||
}
|
||
|
||
// ============================================================================================
|
||
// LE FIL D'ÉCRITURE — sa propre connexion
|
||
// ============================================================================================
|
||
|
||
/*!
|
||
* \brief Écrit les minutes et recalcule le profil, dans son propre fil et sa propre connexion.
|
||
* \note Interne à \c BaseloadLog.
|
||
*/
|
||
class BaseloadWriter : public QObject
|
||
{
|
||
public:
|
||
BaseloadWriter(const QString &chemin, BaseloadLog *log, const QTimeZone &fuseau)
|
||
: m_chemin(chemin), m_log(log), m_fuseau(fuseau) {}
|
||
|
||
//! Minutes tenues en mémoire avant écriture : une transaction toutes les cinq minutes au
|
||
//! lieu d'une par minute — moins de verrous tenus sur le fichier que le cycle partage.
|
||
static constexpr int LotMinutes = 5;
|
||
|
||
void ouvrir()
|
||
{
|
||
QDir().mkpath(QFileInfo(m_chemin).absolutePath());
|
||
m_connexion = QStringLiteral("etm-baseload-%1").arg(QUuid::createUuid().toString(QUuid::WithoutBraces));
|
||
m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connexion);
|
||
m_db.setDatabaseName(m_chemin);
|
||
m_db.setConnectOptions(QStringLiteral("QSQLITE_BUSY_TIMEOUT=10000"));
|
||
if (!m_db.open()) {
|
||
horsService(m_db.lastError().text());
|
||
return;
|
||
}
|
||
const bool ok = assurerTable(QStringLiteral("baseloadMinute"), ColonnesMinute, CreationMinute)
|
||
&& assurerTable(QStringLiteral("baseloadMinuteLoad"), ColonnesCharge, CreationCharge);
|
||
m_log->m_valide.storeRelease(ok ? 1 : 0);
|
||
// UN PREMIER PROFIL dès l'ouverture : après un redémarrage, les 28 jours sont en base, et
|
||
// les attendre un quart d'heure laisserait le snapshot dire « none » sans raison.
|
||
if (ok)
|
||
calculer(QDateTime::currentDateTimeUtc(), 0);
|
||
}
|
||
|
||
void fermer()
|
||
{
|
||
vider();
|
||
if (m_db.isOpen())
|
||
m_db.close();
|
||
m_db = QSqlDatabase();
|
||
if (!m_connexion.isEmpty())
|
||
QSqlDatabase::removeDatabase(m_connexion);
|
||
}
|
||
|
||
void ajouter(const BaseloadMinute &m)
|
||
{
|
||
m_lot << m;
|
||
if (m_lot.count() >= LotMinutes)
|
||
vider();
|
||
// Le recalcul suit le temps des MINUTES, pas l'horloge murale : un test qui injecte une
|
||
// journée en une seconde la voit recalculée comme le banc la verrait.
|
||
if (m_dernierCalcul == 0 || m.finMinute - m_dernierCalcul >= BaseloadLog::RecalculS)
|
||
calculer(QDateTime::fromSecsSinceEpoch(m.finMinute, QTimeZone::utc()), 0);
|
||
}
|
||
|
||
void calculer(const QDateTime &now, int resolutionS)
|
||
{
|
||
vider();
|
||
if (resolutionS > 0)
|
||
m_resolution = resolutionS;
|
||
m_dernierCalcul = now.toSecsSinceEpoch();
|
||
if (!m_log->m_valide.loadAcquire()) {
|
||
m_log->m_calcules.fetchAndAddRelease(1);
|
||
return;
|
||
}
|
||
purger(now.toSecsSinceEpoch());
|
||
const qint64 fin = now.toSecsSinceEpoch();
|
||
const qint64 debut = fin - qint64(BaseloadProfile::FenetreJours) * 86400;
|
||
const QList<BaseloadMinute> minutes = lire(debut, fin);
|
||
const QTimeZone fuseau = m_fuseau.isValid() ? m_fuseau : QTimeZone::systemTimeZone();
|
||
const BaseloadProfile p = BaseloadLog::calculerProfil(minutes, now, fuseau, m_resolution);
|
||
m_log->publierProfil(p);
|
||
m_log->m_calcules.fetchAndAddRelease(1);
|
||
// 7-c — l'ISSUE à chaque calcul, et ce qui a été LU : combien de minutes, de quelle nature.
|
||
qCInfo(dcNymeaEnergy()).noquote()
|
||
<< QStringLiteral("[Base] profil %1 — %2 jour(s) ouvré(s) + %3 de week-end couverts "
|
||
"(seuil %4) ; %5 minute(s) connues dont %6 à la commande, %7 négative(s), "
|
||
"%8 écartée(s)")
|
||
.arg(p.model).arg(p.joursOuvres).arg(p.joursWeekEnd).arg(BaseloadProfile::JoursMin)
|
||
.arg(p.minutes).arg(p.minutesEstimees).arg(p.minutesNegatives)
|
||
.arg(p.minutesIncompletes);
|
||
}
|
||
|
||
private:
|
||
void vider()
|
||
{
|
||
if (m_lot.isEmpty() || !m_log->m_valide.loadAcquire()) {
|
||
m_lot.clear();
|
||
return;
|
||
}
|
||
if (!m_db.transaction()) {
|
||
qCWarning(dcNymeaEnergy()) << "[Base] écriture remise à plus tard —" << m_db.lastError().text();
|
||
return; // le lot reste en mémoire, on réessaiera
|
||
}
|
||
QSqlQuery qm(m_db), qc(m_db);
|
||
// `ON CONFLICT … DO NOTHING`, jamais `INSERT OR IGNORE` : celui-ci avalerait aussi une
|
||
// violation de NOT NULL, et une minute mal formée disparaîtrait sans trace (leçon T2).
|
||
qm.prepare(QStringLiteral("INSERT INTO baseloadMinute (%1) VALUES (?,?,?,?,?,?) "
|
||
"ON CONFLICT (at) DO NOTHING").arg(ColonnesMinute.join(',')));
|
||
qc.prepare(QStringLiteral("INSERT INTO baseloadMinuteLoad (%1) VALUES (?,?,?,?) "
|
||
"ON CONFLICT (at, loadId) DO NOTHING").arg(ColonnesCharge.join(',')));
|
||
const qint64 ecrit = QDateTime::currentMSecsSinceEpoch();
|
||
int inserees = 0;
|
||
for (const BaseloadMinute &m : std::as_const(m_lot)) {
|
||
const std::optional<double> b = m.baseW();
|
||
double charges = 0;
|
||
for (const BaseloadLoadPart &c : m.charges)
|
||
charges += c.powerW.value_or(0);
|
||
qm.addBindValue(m.finMinute);
|
||
qm.addBindValue(m.siteW);
|
||
qm.addBindValue(b ? QVariant(charges) : nul(QMetaType::Double));
|
||
qm.addBindValue(b ? QVariant(*b) : nul(QMetaType::Double));
|
||
qm.addBindValue(m.chargesCommandees());
|
||
qm.addBindValue(ecrit);
|
||
bool ok = qm.exec();
|
||
inserees += ok && qm.numRowsAffected() > 0 ? 1 : 0;
|
||
for (const BaseloadLoadPart &c : m.charges) {
|
||
if (!ok)
|
||
break;
|
||
qc.addBindValue(m.finMinute);
|
||
qc.addBindValue(c.loadId);
|
||
qc.addBindValue(c.source);
|
||
qc.addBindValue(c.powerW ? QVariant(*c.powerW) : nul(QMetaType::Double));
|
||
ok = qc.exec();
|
||
}
|
||
if (!ok) {
|
||
const QSqlError e = qm.lastError().isValid() ? qm.lastError() : qc.lastError();
|
||
m_db.rollback();
|
||
if (e.nativeErrorCode().toInt() == 5 || e.nativeErrorCode().toInt() == 6) {
|
||
qCWarning(dcNymeaEnergy()) << "[Base] base occupée —" << m_lot.count()
|
||
<< "minute(s) remise(s) au lot suivant";
|
||
return;
|
||
}
|
||
qCWarning(dcNymeaEnergy()) << "[Base]" << m_lot.count() << "minute(s) PERDUE(S) —"
|
||
<< e.text();
|
||
m_lot.clear();
|
||
return;
|
||
}
|
||
}
|
||
if (!m_db.commit()) {
|
||
m_db.rollback();
|
||
return;
|
||
}
|
||
m_log->m_ecrites.fetchAndAddRelease(inserees);
|
||
m_lot.clear();
|
||
}
|
||
|
||
/*!
|
||
* La fenêtre, lue PAR JOURNÉE : chaque requête est courte et rend le verrou du fichier
|
||
* entre deux. Une lecture de 28 jours d'un bloc tiendrait le verrou partagé assez longtemps
|
||
* pour faire attendre l'historique des révisions — qui écrit, lui, depuis le cycle.
|
||
*/
|
||
QList<BaseloadMinute> lire(qint64 debut, qint64 fin)
|
||
{
|
||
QList<BaseloadMinute> sortie;
|
||
QHash<qint64, int> index;
|
||
for (qint64 a = debut; a < fin; a += 86400) {
|
||
const qint64 b = qMin(fin, a + 86400);
|
||
QSqlQuery q(m_db);
|
||
q.prepare(QStringLiteral("SELECT at, siteW FROM baseloadMinute WHERE at > ? AND at <= ? ORDER BY at"));
|
||
q.addBindValue(a);
|
||
q.addBindValue(b);
|
||
if (q.exec()) {
|
||
while (q.next()) {
|
||
BaseloadMinute m;
|
||
m.finMinute = q.value(0).toLongLong();
|
||
m.siteW = q.value(1).toDouble();
|
||
index.insert(m.finMinute, sortie.count());
|
||
sortie << m;
|
||
}
|
||
}
|
||
q.finish();
|
||
QSqlQuery c(m_db);
|
||
c.prepare(QStringLiteral("SELECT at, loadId, source, powerW FROM baseloadMinuteLoad "
|
||
"WHERE at > ? AND at <= ?"));
|
||
c.addBindValue(a);
|
||
c.addBindValue(b);
|
||
if (c.exec()) {
|
||
while (c.next()) {
|
||
const int i = index.value(c.value(0).toLongLong(), -1);
|
||
if (i < 0)
|
||
continue;
|
||
BaseloadLoadPart part;
|
||
part.loadId = c.value(1).toString();
|
||
part.source = c.value(2).toString();
|
||
if (!c.value(3).isNull())
|
||
part.powerW = c.value(3).toDouble();
|
||
sortie[i].charges << part;
|
||
}
|
||
}
|
||
}
|
||
return sortie;
|
||
}
|
||
|
||
void purger(qint64 nowS)
|
||
{
|
||
const qint64 borne = nowS - qint64(BaseloadLog::RetentionJours) * 86400;
|
||
QSqlQuery q(m_db);
|
||
for (const char *table : {"baseloadMinute", "baseloadMinuteLoad"}) {
|
||
q.prepare(QStringLiteral("DELETE FROM %1 WHERE at <= ?").arg(QLatin1String(table)));
|
||
q.addBindValue(borne);
|
||
if (!q.exec())
|
||
qCWarning(dcNymeaEnergy()) << "[Base] purge refusée :" << q.lastError().text();
|
||
}
|
||
}
|
||
|
||
/*!
|
||
* La table existe-t-elle, avec la forme attendue ? Sinon elle est REFAITE — ce sont NOS
|
||
* tables, nées avec ce lot : aucune version antérieure n'en a écrit, il n'y a pas d'historique
|
||
* à migrer. Le jour où une colonne s'ajoutera, ce sera une migration en place, comme etm68.
|
||
* Les autres tables du fichier ne sont jamais touchées.
|
||
*/
|
||
bool assurerTable(const QString &nom, const QStringList &colonnes, const char *creation)
|
||
{
|
||
QSqlQuery q(m_db);
|
||
if (!q.exec(QStringLiteral("PRAGMA table_info(%1)").arg(nom))) {
|
||
horsService(q.lastError().text());
|
||
return false;
|
||
}
|
||
QStringList trouvees;
|
||
while (q.next())
|
||
trouvees << q.value(1).toString();
|
||
if (!trouvees.isEmpty() && trouvees != colonnes) {
|
||
qCWarning(dcNymeaEnergy()).noquote()
|
||
<< QStringLiteral("[Base] table %1 de forme inattendue (%2 colonne(s)) — REFAITE, "
|
||
"son contenu est perdu ; le service continue").arg(nom).arg(trouvees.count());
|
||
if (!q.exec(QStringLiteral("DROP TABLE IF EXISTS %1").arg(nom))) {
|
||
const QString cote = QStringLiteral("%1_abimee_%2").arg(nom).arg(QDateTime::currentMSecsSinceEpoch());
|
||
if (!q.exec(QStringLiteral("ALTER TABLE %1 RENAME TO %2").arg(nom, cote))) {
|
||
horsService(q.lastError().text());
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
if (!q.exec(QString::fromLatin1(creation))) {
|
||
horsService(q.lastError().text());
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
void horsService(const QString &erreur)
|
||
{
|
||
m_log->m_valide.storeRelease(0);
|
||
if (m_horsServiceSignale)
|
||
return;
|
||
m_horsServiceSignale = true;
|
||
qCWarning(dcNymeaEnergy()) << "[Base] consommation de base HORS SERVICE — base illisible"
|
||
<< m_chemin << ":" << erreur
|
||
<< "— rien n'est enregistré, l'arbitrage n'en dépend pas";
|
||
}
|
||
|
||
QString m_chemin;
|
||
BaseloadLog *m_log;
|
||
QTimeZone m_fuseau;
|
||
QString m_connexion;
|
||
QSqlDatabase m_db;
|
||
QList<BaseloadMinute> m_lot;
|
||
qint64 m_dernierCalcul = 0;
|
||
int m_resolution = BaseloadLog::ResolutionParDefautS;
|
||
bool m_horsServiceSignale = false;
|
||
};
|
||
|
||
// ============================================================================================
|
||
// LE CÔTÉ APPELANT — copies, aucune requête
|
||
// ============================================================================================
|
||
|
||
BaseloadLog::BaseloadLog(const QString &cheminFichier, const QTimeZone &fuseau, QObject *parent)
|
||
: QObject(parent)
|
||
{
|
||
m_valide.storeRelease(1);
|
||
m_fil = new QThread(this);
|
||
m_fil->setObjectName(QStringLiteral("etm-baseload"));
|
||
m_ecrivain = new BaseloadWriter(cheminFichier, this, fuseau);
|
||
m_ecrivain->moveToThread(m_fil);
|
||
m_fil->start(QThread::LowPriority);
|
||
BaseloadWriter *w = m_ecrivain;
|
||
QMetaObject::invokeMethod(w, [w]() { w->ouvrir(); }, Qt::QueuedConnection);
|
||
}
|
||
|
||
BaseloadLog::~BaseloadLog()
|
||
{
|
||
BaseloadWriter *w = m_ecrivain;
|
||
QMetaObject::invokeMethod(w, [w]() { w->fermer(); delete w; }, Qt::BlockingQueuedConnection);
|
||
m_fil->quit();
|
||
m_fil->wait();
|
||
}
|
||
|
||
void BaseloadLog::observerMinute(const BaseloadMinute &minute)
|
||
{
|
||
BaseloadWriter *w = m_ecrivain;
|
||
QMetaObject::invokeMethod(w, [w, minute]() { w->ajouter(minute); }, Qt::QueuedConnection);
|
||
}
|
||
|
||
void BaseloadLog::recalculer(const QDateTime &now, int resolutionS)
|
||
{
|
||
BaseloadWriter *w = m_ecrivain;
|
||
QMetaObject::invokeMethod(w, [w, now, resolutionS]() { w->calculer(now, resolutionS); },
|
||
Qt::QueuedConnection);
|
||
}
|
||
|
||
BaseloadProfile BaseloadLog::profil() const
|
||
{
|
||
QMutexLocker l(&m_verrou);
|
||
return m_profil;
|
||
}
|
||
|
||
void BaseloadLog::publierProfil(const BaseloadProfile &p)
|
||
{
|
||
QMutexLocker l(&m_verrou);
|
||
m_profil = p;
|
||
}
|