feat(traces): traces de décision NDJSON, une ligne par cycle (brief 4 T6, annexe D)
Réglage optimizerTrace (energy.conf Optimizer/trace, …RetentionDays, …MaxBytesPerDay), défaut DÉSACTIVÉ : ni objet, ni fil, ni fichier. NymeaEnergy.GetOptimizerTrace / SetOptimizerTrace, PermissionScopeAdmin comme SetOptimizerEndpoint, type contrôlé par le moteur, appliqué à chaud. Une ligne par cycle — rule-based, optimiseur, et L2 (strategy none, sans requête) : t (heure locale), request (params GetPlan envoyés, ou qui l'auraient été), response (ce qui a répondu à CETTE requête : result brut, error.code, no-response ; sinon le plan du rule-based en codes), executed (appliqué, clamped, shed), strategy, latencyMs. La ligne N attend la réponse à la requête N (elle sert au cycle N+1). Toutes les E/S sur un fil dédié (OptimizerTraceWriter) : rotation à minuit heure locale, purge au-delà de la rétention (14 j), plafond par fichier journalier (64 Mio, mesuré : 5 Ko/ligne rule-based, 39 Ko sous un optimiseur à 48 créneaux, 1 cycle/min), file bornée à 256 lignes. Échec d'écriture et plafond dits une fois. update() ne fait que poster. Le token n'est jamais dans une ligne ; recopié par une réponse d'optimiseur, il est masqué. Preuves : decisions IDENTIQUE traces désactivées ET activées (21 132 lignes de trace écrites, ETM_DECISIONS_TRACES=1). socketscheduler 33 fonctions, 114/0 (+10 fonctions T6). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1f6c7ec27a
commit
483bbcb027
@ -15,6 +15,7 @@
|
||||
#include "scheduler/socketscheduler.h"
|
||||
#include "scheduler/optimizerstatus.h"
|
||||
#include "scheduler/publishedplan.h"
|
||||
#include "scheduler/optimizertrace.h"
|
||||
#include "types/decisionreason.h"
|
||||
#include "types/surpluscontext.h"
|
||||
#include "types/plan.h"
|
||||
@ -28,6 +29,8 @@
|
||||
#include <QTimer>
|
||||
#include <QJsonDocument>
|
||||
#include <QFileInfo>
|
||||
#include <nymeasettings.h>
|
||||
#include <cmath>
|
||||
|
||||
namespace {
|
||||
//! Période du watchdog L2 (SAFETY.md §L2) : tick indépendant des signaux compteur.
|
||||
@ -132,6 +135,8 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm,
|
||||
|
||||
// Brief 4 T3 — la stratégie optimiseur n'existe que si un endpoint est configuré.
|
||||
relireReglageOptimiseur();
|
||||
// Brief 4 T6 — les traces n'existent que si elles sont activées (défaut : non).
|
||||
relireReglageTrace();
|
||||
|
||||
// Brief 4 T4 — §7 : le mode dégradé L2 l'emporte sur tout (activeStrategy = none). La boucle
|
||||
// de sécurité n'est ni appelée ni modifiée : on écoute la notification qu'elle émet déjà à
|
||||
@ -454,6 +459,8 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
||||
// posé des consignes à la transition ; si elles ne suffisent pas à tenir le plafond,
|
||||
// c'est encore à L4 de trancher. Aucune télémétrie n'est publiée dans cet état, d'où
|
||||
// le slot local — il ne sert qu'à recevoir les actions, personne ne le lira.
|
||||
// Brief 4 T6 — une ligne de trace « none », sans requête : un poste, rien d'autre.
|
||||
tracerCycleDegrade(currentDateTime);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -545,6 +552,8 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
||||
// Brief 4 T4 — le plan en vigueur (GetPlan) et la stratégie qui a commandé ce cycle (§7).
|
||||
// Rien ici ne commande : c'est de la publication, après tout le reste.
|
||||
noterPlanDuCycle(plan, slot, currentDateTime);
|
||||
// Brief 4 T6 — la ligne de trace du cycle (annexe D) : confiée au fil de l'écrivain.
|
||||
tracerCycle(ctx, plan, slot, currentDateTime);
|
||||
}
|
||||
|
||||
namespace {
|
||||
@ -2889,6 +2898,12 @@ void EnergyArbitrator::relireReglageOptimiseur()
|
||||
[s]() { s->noterDeclencheur(QStringLiteral("load-change")); });
|
||||
}
|
||||
connect(s, &SocketScheduler::etatChange, this, &EnergyArbitrator::suivreStatutOptimiseur);
|
||||
// Brief 4 T6 — la réponse à la requête d'un cycle complète la ligne de trace de ce cycle.
|
||||
connect(s, &SocketScheduler::reponseTracee, this,
|
||||
[this](quint64 id, const QVariantMap &reponse, qint64 latenceMs) {
|
||||
if (m_trace)
|
||||
m_trace->noterReponse(id, reponse, latenceMs);
|
||||
});
|
||||
qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur] stratégie optimiseur active vers"
|
||||
<< endpoint.toString()
|
||||
<< "— rule-based en ombre à chaque cycle, repli sur lui";
|
||||
@ -3004,6 +3019,136 @@ void EnergyArbitrator::noterPlanDuCycle(const Plan &plan, const Slot &slot, cons
|
||||
suivreStatutOptimiseur();
|
||||
}
|
||||
|
||||
void EnergyArbitrator::relireReglageTrace()
|
||||
{
|
||||
OptimizerTrace::Reglage r = OptimizerTrace::Reglage::lire();
|
||||
r.dossier = m_dossierTrace;
|
||||
r.cheminToken = OptimizerClient::defaultTokenPath();
|
||||
if (!r.active) {
|
||||
if (m_trace) {
|
||||
delete m_trace; // termine la ligne en attente, vide la file, arrête le fil
|
||||
m_trace = nullptr;
|
||||
qCInfo(dcNymeaEnergy()) << "[Trace] traces de décision désactivées";
|
||||
}
|
||||
return; // désactivées : aucun objet, aucun fil, aucun fichier
|
||||
}
|
||||
if (m_trace) {
|
||||
if (!(m_trace->reglage() == r)) {
|
||||
m_trace->configurer(r);
|
||||
qCInfo(dcNymeaEnergy()) << "[Trace] réglage appliqué : rétention" << r.retentionJours
|
||||
<< "jour(s), plafond" << r.plafondOctetsJour << "octets par jour";
|
||||
}
|
||||
return;
|
||||
}
|
||||
m_trace = new OptimizerTracer(r, this);
|
||||
qCInfo(dcNymeaEnergy()).noquote()
|
||||
<< QStringLiteral("[Trace] traces de décision ACTIVÉES : %1/%2AAAA-MM-JJ%3, rétention %4 jour(s), "
|
||||
"plafond %5 octets par jour — elles restent sur le site")
|
||||
.arg(r.dossier.isEmpty() ? NymeaSettings::storagePath() : r.dossier,
|
||||
QLatin1String(OptimizerTrace::PrefixeFichier), QLatin1String(OptimizerTrace::SuffixeFichier))
|
||||
.arg(r.retentionJours).arg(r.plafondOctetsJour);
|
||||
}
|
||||
|
||||
QVariantMap EnergyArbitrator::optimizerTrace() const
|
||||
{
|
||||
const OptimizerTrace::Reglage r = OptimizerTrace::Reglage::lire();
|
||||
return {{QStringLiteral("enabled"), r.active},
|
||||
{QStringLiteral("retentionDays"), r.retentionJours},
|
||||
{QStringLiteral("maxBytesPerDay"), r.plafondOctetsJour},
|
||||
{QStringLiteral("directory"), m_dossierTrace.isEmpty() ? NymeaSettings::storagePath() : m_dossierTrace}};
|
||||
}
|
||||
|
||||
bool EnergyArbitrator::setOptimizerTrace(const QVariant &enabled, const QVariant &retentionDays,
|
||||
const QVariant &maxBytesPerDay, QString *code)
|
||||
{
|
||||
// Le TYPE est contrôlé ici : sous Qt 6, le validateur nymea ne le fait plus (brief 4 T4, M25).
|
||||
auto entier = [](const QVariant &v, qint64 *n) {
|
||||
const int t = v.userType();
|
||||
if (t != QMetaType::Int && t != QMetaType::UInt && t != QMetaType::LongLong
|
||||
&& t != QMetaType::ULongLong && t != QMetaType::Double)
|
||||
return false;
|
||||
const double d = v.toDouble();
|
||||
if (d != std::floor(d))
|
||||
return false;
|
||||
*n = qint64(d);
|
||||
return true;
|
||||
};
|
||||
qint64 jours = 0, plafond = 0;
|
||||
const bool okActive = !enabled.isValid() || enabled.userType() == QMetaType::Bool;
|
||||
const bool okJours = !retentionDays.isValid()
|
||||
|| (entier(retentionDays, &jours) && jours >= OptimizerTrace::RetentionMinJours
|
||||
&& jours <= OptimizerTrace::RetentionMaxJours);
|
||||
const bool okPlafond = !maxBytesPerDay.isValid()
|
||||
|| (entier(maxBytesPerDay, &plafond) && plafond >= OptimizerTrace::PlafondMinOctets
|
||||
&& plafond <= OptimizerTrace::PlafondMaxOctets);
|
||||
if (!okActive || !okJours || !okPlafond) {
|
||||
qCWarning(dcNymeaEnergy()) << "[Trace] réglage REFUSÉ : enabled booléen, retentionDays entier de"
|
||||
<< OptimizerTrace::RetentionMinJours << "à" << OptimizerTrace::RetentionMaxJours
|
||||
<< ", maxBytesPerDay entier de" << OptimizerTrace::PlafondMinOctets << "à"
|
||||
<< OptimizerTrace::PlafondMaxOctets << "— réglage inchangé";
|
||||
if (code)
|
||||
*code = QStringLiteral("invalid-trace-setting");
|
||||
return false;
|
||||
}
|
||||
EnergySettings reglages;
|
||||
if (enabled.isValid())
|
||||
reglages.setValue(QStringLiteral("Optimizer/trace"), enabled.toBool());
|
||||
if (retentionDays.isValid())
|
||||
reglages.setValue(QStringLiteral("Optimizer/traceRetentionDays"), int(jours));
|
||||
if (maxBytesPerDay.isValid())
|
||||
reglages.setValue(QStringLiteral("Optimizer/traceMaxBytesPerDay"), plafond);
|
||||
reglages.sync();
|
||||
relireReglageTrace();
|
||||
return true;
|
||||
}
|
||||
|
||||
void EnergyArbitrator::setOptimizerTraceDirectory(const QString &dossier)
|
||||
{
|
||||
m_dossierTrace = dossier;
|
||||
relireReglageTrace();
|
||||
}
|
||||
|
||||
void EnergyArbitrator::tracerCycle(const SurplusContext &ctx, const Plan &plan, const Slot &slot,
|
||||
const QDateTime &now)
|
||||
{
|
||||
if (!m_trace)
|
||||
return;
|
||||
QVariantMap ligne;
|
||||
ligne.insert(QStringLiteral("t"), OptimizerTrace::horodatage(now));
|
||||
const bool optimiseur = plan.strategy == QLatin1String(OptimizerPlan::StrategieOptimiseur);
|
||||
const quint64 id = m_socketScheduler ? m_socketScheduler->idRequeteDuCycle() : 0;
|
||||
if (id != 0) {
|
||||
// La requête RÉELLEMENT envoyée ; sa réponse complétera la ligne (ou « no-response »).
|
||||
ligne.insert(QStringLiteral("request"), m_socketScheduler->requeteDuCycle());
|
||||
} else {
|
||||
// Aucune n'est partie (pas d'endpoint, client non connecté) : celle qui serait partie,
|
||||
// même fonction, même fil — et c'est le rule-based qui y a répondu.
|
||||
OptimizerProtocol::ContexteFil fil = contexteFil(ctx);
|
||||
fil.trigger = m_socketScheduler ? m_socketScheduler->declencheurEnAttente() : QStringLiteral("cycle");
|
||||
ligne.insert(QStringLiteral("request"), OptimizerProtocol::getPlanParams(ctx, fil));
|
||||
ligne.insert(QStringLiteral("response"),
|
||||
optimiseur ? QVariantMap{{QStringLiteral("error"),
|
||||
QVariantMap{{QStringLiteral("code"), QStringLiteral("not-sent")}}}}
|
||||
: OptimizerTrace::reponseRegles(plan, now));
|
||||
}
|
||||
ligne.insert(QStringLiteral("executed"),
|
||||
OptimizerTrace::executees(slot, m_actionsAppliquees,
|
||||
QSet<QString>(m_shedActions.keyBegin(), m_shedActions.keyEnd())));
|
||||
ligne.insert(QStringLiteral("strategy"), QLatin1String(optimiseur ? OptimizerStatus::StrategieOptimiseur
|
||||
: OptimizerStatus::StrategieRegles));
|
||||
m_trace->noterCycle(ligne, now, id);
|
||||
}
|
||||
|
||||
void EnergyArbitrator::tracerCycleDegrade(const QDateTime &now)
|
||||
{
|
||||
if (!m_trace)
|
||||
return;
|
||||
m_trace->noterCycle(QVariantMap{{QStringLiteral("t"), OptimizerTrace::horodatage(now)},
|
||||
{QStringLiteral("executed"), QVariantList()},
|
||||
{QStringLiteral("strategy"), QLatin1String(OptimizerStatus::StrategieAucune)}},
|
||||
now, 0);
|
||||
}
|
||||
|
||||
OptimizerProtocol::ContexteFil EnergyArbitrator::contexteFil(const SurplusContext &ctx) const
|
||||
{
|
||||
OptimizerProtocol::ContexteFil fil;
|
||||
|
||||
@ -33,6 +33,7 @@ class ILoadAdapter;
|
||||
class LoadConfigStore;
|
||||
class RuleBasedScheduler;
|
||||
class SocketScheduler;
|
||||
class OptimizerTracer;
|
||||
|
||||
/*!
|
||||
* \brief Arbitre central ETM — remplace SmartChargingManager::update() (ETM_ARBITRATOR).
|
||||
@ -317,6 +318,43 @@ public:
|
||||
return m_planPublie.isEmpty() ? QVariantMap{{QStringLiteral("inForce"), false}} : m_planPublie;
|
||||
}
|
||||
|
||||
/*!
|
||||
* \brief (Re)lit le réglage des traces (\c Optimizer/trace, \c …RetentionDays,
|
||||
* \c …MaxBytesPerDay dans \c energy.conf) et l'applique à chaud — brief 4 T6, annexe D.
|
||||
*
|
||||
* Désactivé (défaut) : AUCUN traceur ni fil n'existe, aucun fichier n'est créé. Activé : un
|
||||
* \c OptimizerTracer écrit une ligne par cycle (planifié ou L2) sous \c storagePath().
|
||||
* \note Appelée par le constructeur et par \c setOptimizerTrace() (RPC).
|
||||
*/
|
||||
void relireReglageTrace();
|
||||
|
||||
/*!
|
||||
* \brief Le réglage des traces — \c NymeaEnergy.GetOptimizerTrace.
|
||||
* \return \c enabled, \c retentionDays, \c maxBytesPerDay, \c directory.
|
||||
*/
|
||||
QVariantMap optimizerTrace() const;
|
||||
|
||||
/*!
|
||||
* \brief Change le réglage des traces, le persiste et l'applique à chaud —
|
||||
* \c NymeaEnergy.SetOptimizerTrace.
|
||||
* \param enabled Activer ; invalide = inchangé. \param retentionDays Jours gardés (1–365) ;
|
||||
* invalide = inchangé. \param maxBytesPerDay Plafond d'un fichier journalier (1 Mio – 1 Gio) ;
|
||||
* invalide = inchangé.
|
||||
* \param[out] code \c invalid-trace-setting si une valeur est hors bornes ; rien n'est alors modifié.
|
||||
* \return Vrai si le réglage est posé.
|
||||
*/
|
||||
bool setOptimizerTrace(const QVariant &enabled, const QVariant &retentionDays,
|
||||
const QVariant &maxBytesPerDay, QString *code = nullptr);
|
||||
|
||||
/*! \brief \return Le traceur, ou \c nullptr si les traces sont désactivées. */
|
||||
OptimizerTracer *optimizerTracer() const { return m_trace; }
|
||||
|
||||
/*!
|
||||
* \brief Répertoire des traces — tests et diagnostic seulement ; vide (défaut) =
|
||||
* \c NymeaSettings::storagePath(). Appliqué à chaud. \param dossier Répertoire.
|
||||
*/
|
||||
void setOptimizerTraceDirectory(const QString &dossier);
|
||||
|
||||
/*!
|
||||
* \brief Ce que la requête \c GetPlan porte en plus du contexte (OPTIMIZER_PROTOCOL §5) :
|
||||
* compteur racine, PV instantanée, limite de phase, réserve, domaine et mesure par charge,
|
||||
@ -1142,6 +1180,18 @@ private:
|
||||
*/
|
||||
void noterPlanDuCycle(const Plan &plan, const Slot &slot, const QDateTime &now);
|
||||
|
||||
/*!
|
||||
* \brief Fin de cycle planifié : confie la ligne du cycle au traceur (annexe D). Sans effet si
|
||||
* les traces sont désactivées. Ne fait que poster : aucune E/S disque, aucune attente.
|
||||
* \param ctx Contexte du cycle (la requête qui serait partie, s'il n'en est parti aucune).
|
||||
* \param plan Plan du cycle (la réponse du rule-based). \param slot Créneau dispatché,
|
||||
* délestage L4 fusionné. \param now Instant du cycle.
|
||||
*/
|
||||
void tracerCycle(const SurplusContext &ctx, const Plan &plan, const Slot &slot, const QDateTime &now);
|
||||
|
||||
/*! \brief Cycle en mode dégradé L2 : ligne \c strategy \c none, sans requête. \param now Instant. */
|
||||
void tracerCycleDegrade(const QDateTime &now);
|
||||
|
||||
/*!
|
||||
* \brief Vrai si deux instantanés diffèrent assez pour mériter une notification.
|
||||
* \param a Dernier instantané publié. \param b Instantané courant.
|
||||
@ -1178,6 +1228,10 @@ private:
|
||||
QString m_statutOptimiseurEmis;
|
||||
//! Plan en vigueur publié au dernier cycle (\c publishedPlan()) ; vide sans plan.
|
||||
QVariantMap m_planPublie;
|
||||
//! Traceur (annexe D) ; \c nullptr quand les traces sont désactivées. Possédé.
|
||||
OptimizerTracer *m_trace = nullptr;
|
||||
//! Répertoire des traces imposé (tests) ; vide = \c storagePath().
|
||||
QString m_dossierTrace;
|
||||
//! Gestionnaire d'énergie (production PV instantanée du fil optimiseur). Non possédé.
|
||||
EnergyManager *m_em = nullptr;
|
||||
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
|
||||
|
||||
@ -22,6 +22,7 @@ HEADERS += \
|
||||
$$PWD/scheduler/optimizerplan.h \
|
||||
$$PWD/scheduler/socketscheduler.h \
|
||||
$$PWD/scheduler/optimizerstatus.h \
|
||||
$$PWD/scheduler/optimizertrace.h \
|
||||
$$PWD/scheduler/publishedplan.h \
|
||||
$$PWD/ratios/energyratioscalculator.h \
|
||||
$$PWD/energyarbitrator.h \
|
||||
@ -45,6 +46,7 @@ SOURCES += \
|
||||
$$PWD/scheduler/optimizerplan.cpp \
|
||||
$$PWD/scheduler/socketscheduler.cpp \
|
||||
$$PWD/scheduler/optimizerstatus.cpp \
|
||||
$$PWD/scheduler/optimizertrace.cpp \
|
||||
$$PWD/scheduler/publishedplan.cpp \
|
||||
$$PWD/energyarbitrator.cpp \
|
||||
$$PWD/spotmarketbuild.cpp \
|
||||
|
||||
396
energyplugin/etm/scheduler/optimizertrace.cpp
Normal file
396
energyplugin/etm/scheduler/optimizertrace.cpp
Normal file
@ -0,0 +1,396 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
|
||||
#include "optimizertrace.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QLoggingCategory>
|
||||
#include <QRegularExpression>
|
||||
|
||||
#include <nymeasettings.h>
|
||||
|
||||
#include "energysettings.h"
|
||||
|
||||
Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)
|
||||
|
||||
namespace OptimizerTrace {
|
||||
|
||||
namespace {
|
||||
|
||||
QString iso(const QDateTime &t) { return t.toUTC().toString(Qt::ISODate); }
|
||||
|
||||
QString nomKind(LoadAction::Kind k)
|
||||
{
|
||||
switch (k) {
|
||||
case LoadAction::Setpoint: return QStringLiteral("setpoint");
|
||||
case LoadAction::State: return QStringLiteral("state");
|
||||
case LoadAction::Constraint: return QStringLiteral("constraint");
|
||||
}
|
||||
return QString();
|
||||
}
|
||||
|
||||
// Une action DÉCIDÉE, au format d'une action de plan (§6) — le motif en code, jamais en phrase.
|
||||
QVariantMap actionDecidee(const LoadAction &a)
|
||||
{
|
||||
QVariantMap m;
|
||||
m.insert(QStringLiteral("loadId"), a.loadId);
|
||||
m.insert(QStringLiteral("kind"), nomKind(a.kind));
|
||||
m.insert(QStringLiteral("powerW"), qMax(0.0, a.estimatedPowerW));
|
||||
if (a.kind == LoadAction::State)
|
||||
m.insert(QStringLiteral("state"), a.state);
|
||||
m.insert(QStringLiteral("reasonCode"), a.reason.code);
|
||||
if (!a.reason.params.isEmpty())
|
||||
m.insert(QStringLiteral("reasonParams"), a.reason.params);
|
||||
return m;
|
||||
}
|
||||
|
||||
const QString CleActive = QStringLiteral("Optimizer/trace");
|
||||
const QString CleRetention = QStringLiteral("Optimizer/traceRetentionDays");
|
||||
const QString ClePlafond = QStringLiteral("Optimizer/traceMaxBytesPerDay");
|
||||
|
||||
} // namespace
|
||||
|
||||
QString nomFichier(const QDate &jour)
|
||||
{
|
||||
return QLatin1String(PrefixeFichier) + jour.toString(QStringLiteral("yyyy-MM-dd"))
|
||||
+ QLatin1String(SuffixeFichier);
|
||||
}
|
||||
|
||||
QDate dateDuFichier(const QString &nom)
|
||||
{
|
||||
static const QRegularExpression forme(QLatin1String("^") + QRegularExpression::escape(QLatin1String(PrefixeFichier))
|
||||
+ QStringLiteral("(\\d{4}-\\d{2}-\\d{2})")
|
||||
+ QRegularExpression::escape(QLatin1String(SuffixeFichier))
|
||||
+ QStringLiteral("$"));
|
||||
const QRegularExpressionMatch m = forme.match(nom);
|
||||
return m.hasMatch() ? QDate::fromString(m.captured(1), QStringLiteral("yyyy-MM-dd")) : QDate();
|
||||
}
|
||||
|
||||
QString horodatage(const QDateTime &t)
|
||||
{
|
||||
const QDateTime local = t.toLocalTime();
|
||||
return local.toOffsetFromUtc(local.offsetFromUtc()).toString(Qt::ISODate);
|
||||
}
|
||||
|
||||
QVariantMap reponseRegles(const Plan &plan, const QDateTime &now)
|
||||
{
|
||||
QVariantMap r;
|
||||
r.insert(QStringLiteral("strategy"), QStringLiteral("rules"));
|
||||
r.insert(QStringLiteral("computedAt"), iso(now));
|
||||
QVariantList creneaux;
|
||||
QDateTime fin;
|
||||
for (const Slot &s : plan.timeSlots) {
|
||||
QVariantList actions;
|
||||
for (const LoadAction &a : s.actions)
|
||||
actions.append(actionDecidee(a));
|
||||
creneaux.append(QVariantMap{{QStringLiteral("from"), iso(s.from.isValid() ? s.from : now)},
|
||||
{QStringLiteral("to"), iso(s.to.isValid() ? s.to : now)},
|
||||
{QStringLiteral("actions"), actions}});
|
||||
if (s.to.isValid() && (!fin.isValid() || s.to > fin))
|
||||
fin = s.to;
|
||||
}
|
||||
if (fin.isValid())
|
||||
r.insert(QStringLiteral("validUntil"), iso(fin));
|
||||
r.insert(QStringLiteral("slots"), creneaux);
|
||||
return QVariantMap{{QStringLiteral("result"), r}};
|
||||
}
|
||||
|
||||
QVariantList executees(const Slot &slot, const QHash<QString, LoadAction> &appliquees,
|
||||
const QSet<QString> &delestees)
|
||||
{
|
||||
// Même lecture que le plan publié : ce que l'adaptateur a RENDU ; une charge délestée par L4
|
||||
// ou non dispatchée n'a pas de retour, son action du créneau (fusionnée) fait foi.
|
||||
QVariantList l;
|
||||
for (const LoadAction &a : slot.actions) {
|
||||
const auto r = appliquees.constFind(a.loadId);
|
||||
const LoadAction &faite = r != appliquees.constEnd() ? *r : a;
|
||||
QVariantMap m;
|
||||
m.insert(QStringLiteral("loadId"), a.loadId);
|
||||
m.insert(QStringLiteral("kind"), nomKind(a.kind));
|
||||
m.insert(QStringLiteral("powerW"), qMax(0.0, faite.estimatedPowerW));
|
||||
if (a.kind == LoadAction::State)
|
||||
m.insert(QStringLiteral("state"), faite.state);
|
||||
m.insert(QStringLiteral("clamped"), a.clamped);
|
||||
if (delestees.contains(a.loadId))
|
||||
m.insert(QStringLiteral("shed"), true);
|
||||
l.append(m);
|
||||
}
|
||||
return l;
|
||||
}
|
||||
|
||||
QByteArray masquerToken(const QByteArray &ligne, const QByteArray &token, int *masques)
|
||||
{
|
||||
if (masques)
|
||||
*masques = 0;
|
||||
if (token.isEmpty())
|
||||
return ligne;
|
||||
// La forme que QJsonDocument donnerait au token dans une chaîne : ["…"] sans ses bornes.
|
||||
const QByteArray tableau = QJsonDocument(QJsonArray{QString::fromUtf8(token)}).toJson(QJsonDocument::Compact);
|
||||
const QByteArray echappe = tableau.mid(2, tableau.size() - 4);
|
||||
if (echappe.isEmpty() || !ligne.contains(echappe))
|
||||
return ligne;
|
||||
QByteArray out = ligne;
|
||||
const int n = out.count(echappe);
|
||||
out.replace(echappe, QByteArrayLiteral("[token masqué]"));
|
||||
if (masques)
|
||||
*masques = n;
|
||||
return out;
|
||||
}
|
||||
|
||||
Reglage Reglage::lire()
|
||||
{
|
||||
EnergySettings s;
|
||||
Reglage r;
|
||||
r.active = s.value(CleActive, false).toBool();
|
||||
bool ok = false;
|
||||
const int jours = s.value(CleRetention, RetentionDefautJours).toInt(&ok);
|
||||
if (ok && jours >= RetentionMinJours && jours <= RetentionMaxJours)
|
||||
r.retentionJours = jours;
|
||||
const qint64 plafond = s.value(ClePlafond, PlafondDefautOctets).toLongLong(&ok);
|
||||
if (ok && plafond >= PlafondMinOctets && plafond <= PlafondMaxOctets)
|
||||
r.plafondOctetsJour = plafond;
|
||||
return r;
|
||||
}
|
||||
|
||||
bool Reglage::operator==(const Reglage &o) const
|
||||
{
|
||||
return active == o.active && retentionJours == o.retentionJours
|
||||
&& plafondOctetsJour == o.plafondOctetsJour && dossier == o.dossier
|
||||
&& cheminToken == o.cheminToken;
|
||||
}
|
||||
|
||||
} // namespace OptimizerTrace
|
||||
|
||||
// --- Écrivain : son propre fil, toutes les E/S ------------------------------------------------
|
||||
|
||||
OptimizerTraceWriter::OptimizerTraceWriter(std::shared_ptr<std::atomic<int>> enAttente)
|
||||
: m_enAttente(std::move(enAttente))
|
||||
{
|
||||
}
|
||||
|
||||
void OptimizerTraceWriter::configurer(const OptimizerTrace::Reglage &r)
|
||||
{
|
||||
const bool autreDossier = r.dossier != m_reglage.dossier;
|
||||
m_reglage = r;
|
||||
if (autreDossier) {
|
||||
m_fichier.close();
|
||||
m_jour = QDate();
|
||||
}
|
||||
// Un plafond relevé vaut tout de suite, le jour même.
|
||||
m_plafondAtteint = QDate();
|
||||
if (m_jour.isValid())
|
||||
purger(m_jour);
|
||||
}
|
||||
|
||||
void OptimizerTraceWriter::ecrire(const QVariantMap &ligne, const QDate &jour)
|
||||
{
|
||||
// La ligne quitte la file quoi qu'il arrive ici : écrite, refusée par le plafond, ou perdue
|
||||
// sur un échec.
|
||||
struct Decompte {
|
||||
std::atomic<int> *n;
|
||||
~Decompte() { n->fetch_sub(1); }
|
||||
} decompte{m_enAttente.get()};
|
||||
|
||||
int masques = 0;
|
||||
const QByteArray l = OptimizerTrace::masquerToken(
|
||||
QJsonDocument(QJsonObject::fromVariantMap(ligne)).toJson(QJsonDocument::Compact),
|
||||
token(), &masques)
|
||||
+ '\n';
|
||||
if (masques > 0 && !m_masqueSignale) {
|
||||
// La requête ne porte jamais le token : seule la réponse BRUTE d'un optimiseur peut le
|
||||
// recopier. On dit le fait, jamais la valeur.
|
||||
m_masqueSignale = true;
|
||||
qCWarning(dcNymeaEnergy()) << "[Trace] la réponse de l'optimiseur recopie le token — masqué dans"
|
||||
" la trace (signalé une fois par session)";
|
||||
}
|
||||
|
||||
if (jour != m_jour)
|
||||
ouvrir(jour);
|
||||
if (m_plafondAtteint == jour)
|
||||
return;
|
||||
if (!m_fichier.isOpen()) {
|
||||
ouvrir(jour); // un échec précédent : on retente à chaque ligne, sans le redire
|
||||
if (!m_fichier.isOpen())
|
||||
return;
|
||||
}
|
||||
if (m_taille + l.size() > m_reglage.plafondOctetsJour) {
|
||||
m_plafondAtteint = jour;
|
||||
m_fichier.close();
|
||||
qCWarning(dcNymeaEnergy()).noquote()
|
||||
<< QStringLiteral("[Trace] plafond journalier atteint (%1 octets, réglage %2) — plus aucune"
|
||||
" ligne jusqu'à demain ; décisions inchangées")
|
||||
.arg(m_taille).arg(m_reglage.plafondOctetsJour);
|
||||
return;
|
||||
}
|
||||
const qint64 n = m_fichier.write(l);
|
||||
if (n != l.size() || !m_fichier.flush()) {
|
||||
echec(m_fichier.fileName() + QStringLiteral(" : ") + m_fichier.errorString());
|
||||
m_fichier.close();
|
||||
return;
|
||||
}
|
||||
m_taille += n;
|
||||
if (m_echecSignale) {
|
||||
m_echecSignale = false;
|
||||
qCInfo(dcNymeaEnergy()).noquote() << "[Trace] écriture rétablie :" << m_fichier.fileName();
|
||||
}
|
||||
}
|
||||
|
||||
void OptimizerTraceWriter::ouvrir(const QDate &jour)
|
||||
{
|
||||
m_fichier.close();
|
||||
if (jour != m_jour)
|
||||
purger(jour);
|
||||
m_jour = jour;
|
||||
m_taille = 0;
|
||||
const QString dossier = m_reglage.dossier.isEmpty() ? NymeaSettings::storagePath() : m_reglage.dossier;
|
||||
m_fichier.setFileName(QDir(dossier).filePath(OptimizerTrace::nomFichier(jour)));
|
||||
if (!m_fichier.open(QIODevice::WriteOnly | QIODevice::Append)) {
|
||||
echec(m_fichier.fileName() + QStringLiteral(" : ") + m_fichier.errorString());
|
||||
return;
|
||||
}
|
||||
m_taille = m_fichier.size();
|
||||
}
|
||||
|
||||
void OptimizerTraceWriter::purger(const QDate &aujourdhui)
|
||||
{
|
||||
const QString dossier = m_reglage.dossier.isEmpty() ? NymeaSettings::storagePath() : m_reglage.dossier;
|
||||
QDir d(dossier);
|
||||
// Rétention de N jours, celui du jour compris : on garde [aujourd'hui − N + 1, aujourd'hui].
|
||||
const QDate limite = aujourdhui.addDays(-m_reglage.retentionJours);
|
||||
int supprimes = 0;
|
||||
const QStringList noms = d.entryList({QLatin1String(OptimizerTrace::PrefixeFichier) + QLatin1Char('*')
|
||||
+ QLatin1String(OptimizerTrace::SuffixeFichier)},
|
||||
QDir::Files);
|
||||
for (const QString &nom : noms) {
|
||||
const QDate date = OptimizerTrace::dateDuFichier(nom);
|
||||
if (date.isValid() && date <= limite && d.remove(nom))
|
||||
++supprimes;
|
||||
}
|
||||
if (supprimes > 0)
|
||||
qCInfo(dcNymeaEnergy()) << "[Trace]" << supprimes << "fichier(s) de plus de"
|
||||
<< m_reglage.retentionJours << "jour(s) supprimé(s)";
|
||||
}
|
||||
|
||||
void OptimizerTraceWriter::echec(const QString &detail)
|
||||
{
|
||||
if (m_echecSignale)
|
||||
return;
|
||||
m_echecSignale = true;
|
||||
qCWarning(dcNymeaEnergy()).noquote() << "[Trace] écriture impossible :" << detail
|
||||
<< "— traces suspendues jusqu'à la prochaine écriture réussie"
|
||||
" (signalé une fois) ; décisions inchangées";
|
||||
}
|
||||
|
||||
QByteArray OptimizerTraceWriter::token()
|
||||
{
|
||||
if (m_reglage.cheminToken.isEmpty())
|
||||
return QByteArray();
|
||||
const QFileInfo fi(m_reglage.cheminToken);
|
||||
if (!fi.exists()) {
|
||||
m_token.clear();
|
||||
m_tokenTaille = -1;
|
||||
return m_token;
|
||||
}
|
||||
if (fi.lastModified() != m_tokenLuA || fi.size() != m_tokenTaille) {
|
||||
QFile f(m_reglage.cheminToken);
|
||||
m_token = f.open(QIODevice::ReadOnly) ? f.read(8192).trimmed() : QByteArray();
|
||||
m_tokenLuA = fi.lastModified();
|
||||
m_tokenTaille = fi.size();
|
||||
}
|
||||
return m_token;
|
||||
}
|
||||
|
||||
// --- Traceur : fil de l'arbitre, jamais d'E/S ------------------------------------------------
|
||||
|
||||
OptimizerTracer::OptimizerTracer(const OptimizerTrace::Reglage &r, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_enAttente(std::make_shared<std::atomic<int>>(0))
|
||||
{
|
||||
qRegisterMetaType<OptimizerTrace::Reglage>();
|
||||
m_ecrivain = new OptimizerTraceWriter(m_enAttente);
|
||||
m_ecrivain->moveToThread(&m_fil);
|
||||
connect(&m_fil, &QThread::finished, m_ecrivain, &QObject::deleteLater);
|
||||
connect(this, &OptimizerTracer::versEcrivain, m_ecrivain, &OptimizerTraceWriter::ecrire);
|
||||
connect(this, &OptimizerTracer::reglageVersEcrivain, m_ecrivain, &OptimizerTraceWriter::configurer);
|
||||
m_fil.setObjectName(QStringLiteral("etm-powersync-trace"));
|
||||
m_fil.start(QThread::LowestPriority);
|
||||
configurer(r);
|
||||
}
|
||||
|
||||
OptimizerTracer::~OptimizerTracer()
|
||||
{
|
||||
terminerEnAttente();
|
||||
// L'arrêt passe DERRIÈRE les lignes déjà postées : elles sont écrites, puis le fil s'arrête.
|
||||
// Jamais appelé depuis update() — à la désactivation (RPC) ou à l'arrêt du plugin.
|
||||
QThread *fil = &m_fil;
|
||||
QMetaObject::invokeMethod(m_ecrivain, [fil] { fil->quit(); }, Qt::QueuedConnection);
|
||||
m_fil.wait();
|
||||
}
|
||||
|
||||
void OptimizerTracer::configurer(const OptimizerTrace::Reglage &r)
|
||||
{
|
||||
m_reglage = r;
|
||||
emit reglageVersEcrivain(r);
|
||||
}
|
||||
|
||||
void OptimizerTracer::noterCycle(const QVariantMap &ligne, const QDateTime &t, quint64 idRequete)
|
||||
{
|
||||
terminerEnAttente();
|
||||
if (idRequete == 0) {
|
||||
poster(ligne, t);
|
||||
return;
|
||||
}
|
||||
m_ligneEnAttente = ligne;
|
||||
m_tEnAttente = t;
|
||||
m_idEnAttente = idRequete;
|
||||
}
|
||||
|
||||
void OptimizerTracer::noterReponse(quint64 id, const QVariantMap &reponse, qint64 latenceMs)
|
||||
{
|
||||
if (id == 0 || id != m_idEnAttente)
|
||||
return;
|
||||
QVariantMap l = m_ligneEnAttente;
|
||||
l.insert(QStringLiteral("response"), reponse);
|
||||
l.insert(QStringLiteral("latencyMs"), latenceMs);
|
||||
m_idEnAttente = 0;
|
||||
m_ligneEnAttente.clear();
|
||||
poster(l, m_tEnAttente);
|
||||
}
|
||||
|
||||
void OptimizerTracer::terminerEnAttente()
|
||||
{
|
||||
if (m_idEnAttente == 0)
|
||||
return;
|
||||
QVariantMap l = m_ligneEnAttente;
|
||||
l.insert(QStringLiteral("response"),
|
||||
QVariantMap{{QStringLiteral("error"),
|
||||
QVariantMap{{QStringLiteral("code"), QLatin1String(OptimizerTrace::SansReponse)}}}});
|
||||
m_idEnAttente = 0;
|
||||
m_ligneEnAttente.clear();
|
||||
poster(l, m_tEnAttente);
|
||||
}
|
||||
|
||||
void OptimizerTracer::poster(const QVariantMap &ligne, const QDateTime &t)
|
||||
{
|
||||
if (m_enAttente->load() >= OptimizerTrace::FileMaxLignes) {
|
||||
++m_abandonnees;
|
||||
if (!m_fileSignalee) {
|
||||
m_fileSignalee = true;
|
||||
qCWarning(dcNymeaEnergy()) << "[Trace] l'écrivain ne suit plus (" << OptimizerTrace::FileMaxLignes
|
||||
<< "lignes en attente) — lignes abandonnées jusqu'à ce qu'il rattrape ;"
|
||||
" décisions inchangées";
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (m_fileSignalee) {
|
||||
m_fileSignalee = false;
|
||||
qCInfo(dcNymeaEnergy()) << "[Trace] l'écrivain a rattrapé —" << m_abandonnees
|
||||
<< "ligne(s) abandonnée(s) depuis le démarrage des traces";
|
||||
}
|
||||
m_enAttente->fetch_add(1);
|
||||
emit versEcrivain(ligne, t.toLocalTime().date());
|
||||
}
|
||||
244
energyplugin/etm/scheduler/optimizertrace.h
Normal file
244
energyplugin/etm/scheduler/optimizertrace.h
Normal file
@ -0,0 +1,244 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#pragma once
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QDate>
|
||||
#include <QDateTime>
|
||||
#include <QFile>
|
||||
#include <QHash>
|
||||
#include <QObject>
|
||||
#include <QSet>
|
||||
#include <QString>
|
||||
#include <QThread>
|
||||
#include <QVariantMap>
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
|
||||
#include "../types/plan.h"
|
||||
|
||||
/*!
|
||||
* \file optimizertrace.h
|
||||
* \brief Traces de décision (OPTIMIZER_PROTOCOL.md annexe D, brief 4 T6) : une ligne NDJSON par
|
||||
* cycle, dans un fichier par jour LOCAL sous \c NymeaSettings::storagePath().
|
||||
*
|
||||
* Trois étages, chacun avec sa règle :
|
||||
* - \c OptimizerTrace (fonctions PURES) : ce qu'une ligne contient ;
|
||||
* - \c OptimizerTracer (fil de l'arbitre) : assemble la ligne d'un cycle, attend la réponse à SA
|
||||
* requête, la confie à l'écrivain — sans jamais toucher au disque ni attendre ;
|
||||
* - \c OptimizerTraceWriter (fil dédié) : toutes les E/S — rotation, purge, plafond, échecs.
|
||||
*
|
||||
* \par Ce qu'une ligne porte (annexe D)
|
||||
* \c t (instant du cycle, heure locale avec décalage), \c request (les paramètres \c GetPlan
|
||||
* envoyés, ou ceux qui l'auraient été), \c response (ce qui a répondu à CETTE requête),
|
||||
* \c executed (actions appliquées au cycle), \c strategy (\c optimizer | \c rules | \c none),
|
||||
* \c latencyMs (optimiseur seulement). Jamais le token : il ne voyage que dans le handshake,
|
||||
* et l'écrivain masque en plus toute occurrence que la réponse brute d'un optimiseur porterait.
|
||||
*
|
||||
* \invariant Les traces restent sur le site (invariant 10 du moteur) : aucun envoi, export manuel.
|
||||
* \invariant Rien ici ne commande : une trace activée ou non, en échec ou non, ne change aucune
|
||||
* décision (preuve \c decisions traces activées).
|
||||
*/
|
||||
namespace OptimizerTrace {
|
||||
|
||||
//! Préfixe des fichiers : \c etm-powersync-optimizer-trace-AAAA-MM-JJ.ndjson.
|
||||
constexpr const char *PrefixeFichier = "etm-powersync-optimizer-trace-";
|
||||
//! Suffixe des fichiers.
|
||||
constexpr const char *SuffixeFichier = ".ndjson";
|
||||
|
||||
//! Rétention par défaut (jours, fichier du jour compris) — annexe D.
|
||||
constexpr int RetentionDefautJours = 14;
|
||||
//! Rétention minimale acceptée (jours).
|
||||
constexpr int RetentionMinJours = 1;
|
||||
//! Rétention maximale acceptée (jours).
|
||||
constexpr int RetentionMaxJours = 365;
|
||||
//! Plafond par défaut d'un fichier journalier (octets). Mesuré (brief 4 T6, \c testTraceLineSize) :
|
||||
//! ~5 Ko par ligne rule-based, ~39 Ko sous un optimiseur qui rend 48 créneaux × 4 actions ; à
|
||||
//! 1 cycle/min (banc), 7,6 et 56 Mo par jour. 64 Mio tient la journée d'optimiseur avec ~15 % de
|
||||
//! marge ; borne totale, à 14 jours : 896 Mio.
|
||||
constexpr qint64 PlafondDefautOctets = 64ll * 1024 * 1024;
|
||||
//! Plafond journalier minimal accepté (octets).
|
||||
constexpr qint64 PlafondMinOctets = 1024ll * 1024;
|
||||
//! Plafond journalier maximal accepté (octets).
|
||||
constexpr qint64 PlafondMaxOctets = 1024ll * 1024 * 1024;
|
||||
//! Lignes confiées à l'écrivain et pas encore écrites, au-delà desquelles une ligne est
|
||||
//! abandonnée plutôt que de laisser la mémoire croître derrière un support qui n'écrit plus.
|
||||
constexpr int FileMaxLignes = 256;
|
||||
|
||||
//! Code de \c response quand la requête n'a eu aucune réponse avant la suivante (remplacée).
|
||||
constexpr const char *SansReponse = "no-response";
|
||||
|
||||
/*! \brief \return Le nom du fichier du jour \p jour (date LOCALE). \param jour Jour local. */
|
||||
QString nomFichier(const QDate &jour);
|
||||
|
||||
/*!
|
||||
* \brief Lit la date d'un nom de fichier de trace.
|
||||
* \param nom Nom de fichier, sans chemin. \return La date, invalide si \p nom n'en est pas un.
|
||||
*/
|
||||
QDate dateDuFichier(const QString &nom);
|
||||
|
||||
/*! \brief \param t Instant. \return \p t à l'heure LOCALE, décalage explicite (\c 2026-10-12T13:42:00+02:00). */
|
||||
QString horodatage(const QDateTime &t);
|
||||
|
||||
/*!
|
||||
* \brief Le plan du rule-based comme RÉPONSE à la requête du cycle (format \c GetPlan §6) :
|
||||
* \c strategy \c rules, \c computedAt, \c validUntil, créneaux et actions DÉCIDÉES
|
||||
* (\c powerW, \c state, \c reasonCode, \c reasonParams — des codes, jamais de texte).
|
||||
* \param plan Plan rendu par la stratégie. \param now Instant du cycle.
|
||||
* \return \c {"result": {...}}.
|
||||
*/
|
||||
QVariantMap reponseRegles(const Plan &plan, const QDateTime &now);
|
||||
|
||||
/*!
|
||||
* \brief Les actions APPLIQUÉES au cycle : \c loadId, \c kind, \c powerW, \c state (SG-Ready),
|
||||
* \c clamped, et \c shed quand L4 a délesté la charge.
|
||||
* \param slot Créneau dispatché, délestage L4 fusionné. \param appliquees Retours
|
||||
* d'\c applyAction(). \param delestees Charges revendiquées par L4.
|
||||
* \return La liste, dans l'ordre du créneau.
|
||||
*/
|
||||
QVariantList executees(const Slot &slot, const QHash<QString, LoadAction> &appliquees,
|
||||
const QSet<QString> &delestees);
|
||||
|
||||
/*!
|
||||
* \brief Remplace, dans une ligne déjà sérialisée, chaque occurrence du token par
|
||||
* \c "[token masqué]".
|
||||
* \param ligne Ligne JSON. \param token Le secret, brut ; vide = rien à faire.
|
||||
* \param[out] masques Nombre de remplacements (peut être \c nullptr).
|
||||
* \return La ligne masquée.
|
||||
* \note Le token est cherché sous sa forme ÉCHAPPÉE JSON (\c " et \c \\ ) : c'est ainsi qu'il
|
||||
* apparaîtrait dans la ligne.
|
||||
*/
|
||||
QByteArray masquerToken(const QByteArray &ligne, const QByteArray &token, int *masques = nullptr);
|
||||
|
||||
/*! \brief Réglage des traces (\c Optimizer/trace* dans \c energy.conf). */
|
||||
struct Reglage {
|
||||
bool active = false; //!< \c Optimizer/trace — défaut : désactivé.
|
||||
int retentionJours = RetentionDefautJours; //!< \c Optimizer/traceRetentionDays.
|
||||
qint64 plafondOctetsJour = PlafondDefautOctets; //!< \c Optimizer/traceMaxBytesPerDay.
|
||||
QString dossier; //!< Répertoire ; vide = \c storagePath().
|
||||
QString cheminToken; //!< Fichier du token, pour le masquage.
|
||||
|
||||
/*! \brief Lu dans \c energy.conf ; une valeur hors bornes reprend son défaut. \return Le réglage. */
|
||||
static Reglage lire();
|
||||
//! \return Vrai si les deux réglages produisent les mêmes fichiers. \param o Autre réglage.
|
||||
bool operator==(const Reglage &o) const;
|
||||
};
|
||||
|
||||
} // namespace OptimizerTrace
|
||||
|
||||
/*!
|
||||
* \brief L'écrivain des traces — vit sur son PROPRE fil : toutes les E/S disque sont ici.
|
||||
*
|
||||
* - **Rotation** : le fichier est choisi par la date LOCALE de la ligne (\c jour), donc par
|
||||
* l'horloge du cycle — c'est elle qu'un test injecte.
|
||||
* - **Purge** : à chaque changement de jour et à chaque réglage, les fichiers de plus de
|
||||
* \c retentionJours jours (celui du jour compris) sont supprimés.
|
||||
* - **Plafond** : un fichier qui atteindrait \c plafondOctetsJour n'est plus écrit jusqu'au
|
||||
* lendemain ; dit UNE fois.
|
||||
* - **Échec** (répertoire absent, en lecture seule, support plein) : dit UNE fois, puis plus
|
||||
* rien jusqu'à la première écriture réussie, qui est dite aussi. Jamais d'exception, jamais
|
||||
* d'attente du côté de l'arbitre.
|
||||
*/
|
||||
class OptimizerTraceWriter : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
/*! \brief \param enAttente Compteur partagé avec le traceur : lignes confiées, pas encore traitées. */
|
||||
explicit OptimizerTraceWriter(std::shared_ptr<std::atomic<int>> enAttente);
|
||||
|
||||
public slots:
|
||||
/*! \brief Applique un réglage (dossier, rétention, plafond, token) et purge. \param r Réglage. */
|
||||
void configurer(const OptimizerTrace::Reglage &r);
|
||||
/*!
|
||||
* \brief Sérialise et écrit une ligne dans le fichier de \p jour.
|
||||
* \param ligne Contenu de la ligne. \param jour Jour LOCAL de la ligne.
|
||||
*/
|
||||
void ecrire(const QVariantMap &ligne, const QDate &jour);
|
||||
|
||||
private:
|
||||
void ouvrir(const QDate &jour);
|
||||
void purger(const QDate &aujourdhui);
|
||||
void echec(const QString &detail);
|
||||
QByteArray token();
|
||||
|
||||
std::shared_ptr<std::atomic<int>> m_enAttente;
|
||||
OptimizerTrace::Reglage m_reglage;
|
||||
QFile m_fichier;
|
||||
QDate m_jour; // jour du fichier ouvert (ou tenté)
|
||||
qint64 m_taille = 0;
|
||||
QDate m_plafondAtteint; // jour dont le plafond est atteint : plus rien ce jour-là
|
||||
bool m_echecSignale = false;
|
||||
bool m_masqueSignale = false;
|
||||
QByteArray m_token;
|
||||
QDateTime m_tokenLuA; // date de modification du fichier lu
|
||||
qint64 m_tokenTaille = -1;
|
||||
};
|
||||
|
||||
/*!
|
||||
* \brief Le traceur, côté arbitre : une ligne par cycle, confiée à l'écrivain.
|
||||
*
|
||||
* N'existe que si \c Optimizer/trace est vrai : désactivé, l'arbitre ne construit ni cet objet
|
||||
* ni son fil, et aucun fichier n'est créé.
|
||||
*
|
||||
* **Appariement requête → réponse.** La réponse à la requête du cycle N arrive APRÈS le cycle
|
||||
* (elle sert au cycle N+1, modèle cache). La ligne N attend donc sa réponse : elle part à la
|
||||
* réponse, ou au cycle suivant avec \c {"error": {"code": "no-response"}} si la requête a été
|
||||
* remplacée sans réponse. Une ligne sans requête envoyée (pas d'endpoint, client non connecté)
|
||||
* porte la réponse du rule-based et part tout de suite ; une ligne L2 aussi.
|
||||
*
|
||||
* \invariant Aucune E/S disque, aucune attente : \c noterCycle() et \c noterReponse() ne font
|
||||
* que poster vers le fil de l'écrivain.
|
||||
*/
|
||||
class OptimizerTracer : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
/*! \brief Démarre le fil de l'écrivain. \param r Réglage. \param parent Propriétaire Qt. */
|
||||
explicit OptimizerTracer(const OptimizerTrace::Reglage &r, QObject *parent = nullptr);
|
||||
/*! \brief Termine la ligne en attente, vide la file, arrête le fil. */
|
||||
~OptimizerTracer() override;
|
||||
|
||||
/*! \brief Change rétention, plafond ou dossier, à chaud. \param r Réglage. */
|
||||
void configurer(const OptimizerTrace::Reglage &r);
|
||||
/*! \brief \return Le réglage appliqué. */
|
||||
const OptimizerTrace::Reglage ®lage() const { return m_reglage; }
|
||||
|
||||
/*!
|
||||
* \brief Ligne d'un cycle.
|
||||
* \param ligne \c t, \c request, \c response (si connue), \c executed, \c strategy.
|
||||
* \param t Instant du cycle (choisit le fichier).
|
||||
* \param idRequete Identifiant de la requête envoyée ce cycle ; 0 = aucune (la ligne part).
|
||||
*/
|
||||
void noterCycle(const QVariantMap &ligne, const QDateTime &t, quint64 idRequete);
|
||||
/*!
|
||||
* \brief Réponse (ou échec) à une requête.
|
||||
* \param id Identifiant de la requête. \param reponse \c {"result": …} ou \c {"error": {"code": …}}.
|
||||
* \param latenceMs Délai envoi → réponse.
|
||||
*/
|
||||
void noterReponse(quint64 id, const QVariantMap &reponse, qint64 latenceMs);
|
||||
|
||||
/*! \brief \return Lignes abandonnées parce que la file de l'écrivain était pleine. */
|
||||
int lignesAbandonnees() const { return m_abandonnees; }
|
||||
|
||||
signals:
|
||||
//! \cond INTERNE
|
||||
void versEcrivain(const QVariantMap &ligne, const QDate &jour);
|
||||
void reglageVersEcrivain(const OptimizerTrace::Reglage &r);
|
||||
//! \endcond
|
||||
|
||||
private:
|
||||
void poster(const QVariantMap &ligne, const QDateTime &t);
|
||||
void terminerEnAttente();
|
||||
|
||||
OptimizerTrace::Reglage m_reglage;
|
||||
QThread m_fil;
|
||||
OptimizerTraceWriter *m_ecrivain = nullptr; // vit sur m_fil, détruit à sa fin
|
||||
std::shared_ptr<std::atomic<int>> m_enAttente;
|
||||
QVariantMap m_ligneEnAttente;
|
||||
QDateTime m_tEnAttente;
|
||||
quint64 m_idEnAttente = 0;
|
||||
int m_abandonnees = 0;
|
||||
bool m_fileSignalee = false;
|
||||
};
|
||||
|
||||
Q_DECLARE_METATYPE(OptimizerTrace::Reglage)
|
||||
@ -48,6 +48,8 @@ Plan SocketScheduler::getPlan(const SurplusContext &ctx)
|
||||
// L'OMBRE D'ABORD, et toujours : ses effets de bord (proxy VE, comptabilité éco, réserve)
|
||||
// sont dus à chaque cycle, quelle que soit la stratégie qui commandera.
|
||||
const Plan ombre = m_ombre->getPlan(ctx);
|
||||
m_idDuCycle = 0;
|
||||
m_requeteDuCycle.clear();
|
||||
envoyer(ctx);
|
||||
|
||||
if (!m_aCache) {
|
||||
@ -117,11 +119,15 @@ void SocketScheduler::envoyer(const SurplusContext &ctx)
|
||||
return; // le déclencheur attend la prochaine requête qui partira vraiment
|
||||
OptimizerProtocol::ContexteFil fil = m_fil ? m_fil(ctx) : OptimizerProtocol::ContexteFil();
|
||||
fil.trigger = m_declencheur;
|
||||
const quint64 id = m_client->requestPlan(OptimizerProtocol::getPlanParams(ctx, fil));
|
||||
const QVariantMap params = OptimizerProtocol::getPlanParams(ctx, fil);
|
||||
const quint64 id = m_client->requestPlan(params);
|
||||
if (id == 0)
|
||||
return;
|
||||
m_idRequete = id;
|
||||
m_ctxRequete = ctx;
|
||||
m_chronoRequete.start();
|
||||
m_idDuCycle = id;
|
||||
m_requeteDuCycle = params;
|
||||
m_declencheur = QStringLiteral("cycle");
|
||||
}
|
||||
|
||||
@ -129,6 +135,7 @@ void SocketScheduler::onPlanRecu(quint64 id, const QVariantMap &result)
|
||||
{
|
||||
if (id != m_idRequete)
|
||||
return;
|
||||
emit reponseTracee(id, QVariantMap{{QStringLiteral("result"), result}}, m_chronoRequete.elapsed());
|
||||
const OptimizerPlan::Verdict v = OptimizerPlan::valider(result, m_ctxRequete,
|
||||
m_client->capabilities(),
|
||||
m_client->hasCapabilities());
|
||||
@ -194,6 +201,8 @@ void SocketScheduler::onPlanEchoue(quint64 id, const QString &code)
|
||||
return;
|
||||
// Le client a déjà journalisé la cause. Le plan en cache, s'il y en a un, reste valable
|
||||
// jusqu'à son échéance : un échec ISOLÉ n'est pas un optimiseur mort. Le second l'est (§7).
|
||||
emit reponseTracee(id, QVariantMap{{QStringLiteral("error"), QVariantMap{{QStringLiteral("code"), code}}}},
|
||||
m_chronoRequete.elapsed());
|
||||
emit echecRequete(code);
|
||||
noterVivacite(OptimizerStatus::Vivacite::Evenement::Echec, code);
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#pragma once
|
||||
|
||||
#include <QElapsedTimer>
|
||||
#include <QHash>
|
||||
#include <QObject>
|
||||
#include <QSet>
|
||||
@ -113,6 +114,12 @@ public:
|
||||
/*! \brief \return La vivacité de l'optimiseur (§7) : vivant, échecs, dernier code, dernier plan. */
|
||||
const OptimizerStatus::Vivacite &vivacite() const { return m_vivacite; }
|
||||
|
||||
/*! \brief \return Identifiant de la requête envoyée au DERNIER \c getPlan(), 0 si aucune n'est
|
||||
* partie (client non connecté) — traces, brief 4 T6. */
|
||||
quint64 idRequeteDuCycle() const { return m_idDuCycle; }
|
||||
/*! \brief \return Les paramètres \c GetPlan envoyés au dernier \c getPlan() (vide si aucun). */
|
||||
QVariantMap requeteDuCycle() const { return m_requeteDuCycle; }
|
||||
|
||||
signals:
|
||||
/*!
|
||||
* \brief Une réponse \c GetPlan a été jugée — accroche du compteur d'échecs §7 (T4).
|
||||
@ -125,6 +132,14 @@ signals:
|
||||
* (\c timeout, \c disconnected, \c rpc-error, \c invalid-response). */
|
||||
void echecRequete(const QString &code);
|
||||
|
||||
/*!
|
||||
* \brief Ce qui a répondu à la requête \p id, pour les traces (brief 4 T6).
|
||||
* \param id Identifiant de la requête. \param reponse \c {"result": …} BRUT (texte libre
|
||||
* compris : il ne va qu'aux traces, qui restent sur le site) ou \c {"error": {"code": …}}.
|
||||
* \param latenceMs Délai envoi → réponse.
|
||||
*/
|
||||
void reponseTracee(quint64 id, const QVariantMap &reponse, qint64 latenceMs);
|
||||
|
||||
/*! \brief Stratégie retenue au cycle. \param optimiseur Vrai si le plan de l'optimiseur a
|
||||
* été substitué. \param code Motif du repli, vide si optimiseur. */
|
||||
void strategieDuCycle(bool optimiseur, const QString &code);
|
||||
@ -152,6 +167,9 @@ private:
|
||||
QString m_declencheur = QStringLiteral("cycle");
|
||||
quint64 m_idRequete = 0;
|
||||
SurplusContext m_ctxRequete; // contexte de la requête en vol : juge sa réponse
|
||||
QElapsedTimer m_chronoRequete; // latence de la requête en vol (traces)
|
||||
quint64 m_idDuCycle = 0; // requête partie au dernier getPlan() (traces)
|
||||
QVariantMap m_requeteDuCycle;
|
||||
|
||||
bool m_aCache = false;
|
||||
QVariantMap m_cache; // dernier `result` valide à sa réception, brut
|
||||
|
||||
@ -593,6 +593,34 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
||||
returns.insert("o:fault", enumValueName(String));
|
||||
registerMethod("SetOptimizerToken", description, params, returns, Types::PermissionScopeAdmin);
|
||||
|
||||
// Traces de décision (brief 4 T6, OPTIMIZER_PROTOCOL annexe D). Admin, comme l'endpoint : les
|
||||
// traces enregistrent les MESURES du site, chaque minute, et le réglage décide de leur volume
|
||||
// sur la carte SD. Lecture comprise : le répertoire est un chemin du système.
|
||||
params.clear(); returns.clear();
|
||||
description = "Decision traces (OPTIMIZER_PROTOCOL annex D): one NDJSON line per cycle, in a "
|
||||
"daily file (local time) etm-powersync-optimizer-trace-YYYY-MM-DD.ndjson under "
|
||||
"directory. They contain site measurements and stay on the box: export is manual "
|
||||
"only. enabled (default false), retentionDays (default 14, today included), "
|
||||
"maxBytesPerDay (cap of one daily file; beyond it nothing more is written until "
|
||||
"the next day).";
|
||||
returns.insert("enabled", enumValueName(Bool));
|
||||
returns.insert("retentionDays", enumValueName(Int));
|
||||
returns.insert("maxBytesPerDay", enumValueName(Int));
|
||||
returns.insert("directory", enumValueName(String));
|
||||
registerMethod("GetOptimizerTrace", description, params, returns, Types::PermissionScopeAdmin);
|
||||
|
||||
params.clear(); returns.clear();
|
||||
description = "Change the decision-trace setting, persisted and applied immediately. Each key is "
|
||||
"optional; an absent key is unchanged. enabled: bool. retentionDays: integer 1-365. "
|
||||
"maxBytesPerDay: integer 1048576-1073741824. On refusal: EnergyErrorInvalidParameter, "
|
||||
"fault invalid-trace-setting, and nothing is changed.";
|
||||
params.insert("o:enabled", enumValueName(Bool));
|
||||
params.insert("o:retentionDays", enumValueName(Int));
|
||||
params.insert("o:maxBytesPerDay", enumValueName(Int));
|
||||
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
|
||||
returns.insert("o:fault", enumValueName(String));
|
||||
registerMethod("SetOptimizerTrace", description, params, returns, Types::PermissionScopeAdmin);
|
||||
|
||||
QVariantMap planAction;
|
||||
planAction.insert("loadId", enumValueName(String));
|
||||
planAction.insert("kind", enumValueName(String)); // setpoint | state
|
||||
@ -1636,6 +1664,33 @@ JsonReply *NymeaEnergyJsonHandler::SetOptimizerToken(const QVariantMap ¶ms)
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
|
||||
}
|
||||
|
||||
JsonReply *NymeaEnergyJsonHandler::GetOptimizerTrace(const QVariantMap ¶ms)
|
||||
{
|
||||
Q_UNUSED(params)
|
||||
#ifdef ETM_ARBITRATOR
|
||||
if (EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager))
|
||||
return createReply(arb->optimizerTrace());
|
||||
#endif
|
||||
return createReply({{"enabled", false}, {"retentionDays", 0}, {"maxBytesPerDay", 0}, {"directory", ""}});
|
||||
}
|
||||
|
||||
JsonReply *NymeaEnergyJsonHandler::SetOptimizerTrace(const QVariantMap ¶ms)
|
||||
{
|
||||
#ifdef ETM_ARBITRATOR
|
||||
if (EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager)) {
|
||||
QString fault;
|
||||
if (arb->setOptimizerTrace(params.value("enabled"), params.value("retentionDays"),
|
||||
params.value("maxBytesPerDay"), &fault))
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}});
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)},
|
||||
{"fault", fault}});
|
||||
}
|
||||
#else
|
||||
Q_UNUSED(params)
|
||||
#endif
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
|
||||
}
|
||||
|
||||
JsonReply *NymeaEnergyJsonHandler::GetPlan(const QVariantMap ¶ms)
|
||||
{
|
||||
Q_UNUSED(params)
|
||||
|
||||
@ -152,6 +152,13 @@ public:
|
||||
//! \brief [ETM] Pose le token de l'optimiseur — ÉCRITURE SEULE (Q2). \param params \c token (vide = retrait).
|
||||
//! \return \c energyError ; sur refus \c fault \c token-refused. Le token n'est jamais rendu.
|
||||
Q_INVOKABLE JsonReply *SetOptimizerToken(const QVariantMap ¶ms);
|
||||
//! \brief [ETM] Réglage des traces de décision (brief 4 T6, annexe D). \param params Aucun.
|
||||
//! \return \c EnergyArbitrator::optimizerTrace().
|
||||
Q_INVOKABLE JsonReply *GetOptimizerTrace(const QVariantMap ¶ms);
|
||||
//! \brief [ETM] Change le réglage des traces, à chaud. \param params \c o:enabled,
|
||||
//! \c o:retentionDays, \c o:maxBytesPerDay. \return \c energyError ; sur refus \c fault
|
||||
//! \c invalid-trace-setting — rien n'est modifié.
|
||||
Q_INVOKABLE JsonReply *SetOptimizerTrace(const QVariantMap ¶ms);
|
||||
//! \brief [ETM] Le plan en vigueur au dernier cycle (exigence app 2). \param params Aucun.
|
||||
//! \return \c EnergyArbitrator::publishedPlan() ; \c {inForce: false} sans plan en vigueur.
|
||||
Q_INVOKABLE JsonReply *GetPlan(const QVariantMap ¶ms);
|
||||
|
||||
@ -42,7 +42,9 @@
|
||||
*/
|
||||
|
||||
#include <QtTest>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QRegularExpression>
|
||||
@ -415,13 +417,54 @@ private:
|
||||
|
||||
} // namespace
|
||||
|
||||
/* Brief 4 T6 — la preuve « traces ACTIVÉES » : avec ETM_DECISIONS_TRACE=<fichier>, les traces de
|
||||
* décision de l'arbitre sont activées AVANT chaque cycle (un scénario peut recréer l'arbitre), et
|
||||
* <fichier> reçoit après le cycle « <lignes> <octets> » des traces présentes sur disque — la
|
||||
* preuve qu'elles ont bien été écrites (l'écrivain est asynchrone : le compte suit avec un cycle
|
||||
* de retard au plus). La ligne de décision n'en dit rien : elle doit rester IDENTIQUE. */
|
||||
static void tracesDemandees(SmartChargingManager *self, bool apres)
|
||||
{
|
||||
static const QString compte = QString::fromLocal8Bit(qgetenv("ETM_DECISIONS_TRACE"));
|
||||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(self);
|
||||
if (compte.isEmpty() || !arb)
|
||||
return;
|
||||
if (!apres) {
|
||||
if (!arb->optimizerTracer())
|
||||
arb->setOptimizerTrace(true, QVariant(), QVariant());
|
||||
return;
|
||||
}
|
||||
// Compte INCRÉMENTAL : seuls les octets apparus depuis le cycle précédent sont lus (un
|
||||
// scénario fait ~20 000 cycles ; tout relire à chaque cycle serait quadratique).
|
||||
static QHash<QString, qint64> lus;
|
||||
static qint64 lignes = 0, octets = 0;
|
||||
const QDir dossier(arb->optimizerTrace().value(QStringLiteral("directory")).toString());
|
||||
for (const QFileInfo &fi : dossier.entryInfoList({QStringLiteral("etm-powersync-optimizer-trace-*.ndjson")},
|
||||
QDir::Files)) {
|
||||
qint64 &deja = lus[fi.filePath()];
|
||||
if (fi.size() <= deja)
|
||||
continue;
|
||||
QFile f(fi.filePath());
|
||||
if (!f.open(QIODevice::ReadOnly) || !f.seek(deja))
|
||||
continue;
|
||||
const QByteArray b = f.readAll();
|
||||
lignes += b.count('\n');
|
||||
octets += b.size();
|
||||
deja += b.size();
|
||||
}
|
||||
QFile sortie(compte);
|
||||
if (sortie.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
||||
sortie.write(QByteArray::number(lignes) + ' ' + QByteArray::number(octets) + '\n');
|
||||
}
|
||||
|
||||
extern "C" void ENVEL_CYCLE(SmartChargingManager *self, const QDateTime &dateTime)
|
||||
{
|
||||
Enregistreur &e = Enregistreur::instance();
|
||||
tracesDemandees(self, false);
|
||||
++e.dansCycle;
|
||||
REEL_CYCLE(self, dateTime);
|
||||
--e.dansCycle;
|
||||
e.noterCycle(self, dateTime);
|
||||
tracesDemandees(self, true);
|
||||
}
|
||||
|
||||
extern "C" Plan ENVEL_PLAN(RuleBasedScheduler *self, const SurplusContext &ctx)
|
||||
|
||||
@ -6,6 +6,10 @@
|
||||
# run-decisions.sh <build> check enregistre puis compare à la référence
|
||||
# (code 1 au premier écart, et il est montré)
|
||||
#
|
||||
# ETM_DECISIONS_TRACES=1 (brief 4 T6) : mêmes scénarios, TRACES DE DÉCISION ACTIVÉES. La référence
|
||||
# ne change pas — c'est la preuve que la trace n'influe sur aucune décision ; le total des lignes
|
||||
# de trace écrites est annoncé à la fin (il doit être non nul).
|
||||
#
|
||||
# <build> : arbre construit depuis le .pro racine avec CONFIG+=build_tests (le binaire
|
||||
# `nymea-energy-decisions` y est sous tests/auto/decisions/).
|
||||
#
|
||||
@ -51,7 +55,9 @@ export NYMEA_PLUGINS_PATH="$BUILD/tests/mocks/plugins/energymocks:/usr/lib/x86_6
|
||||
# isolation, le résultat dépendrait de l'historique de la machine, pas du code.
|
||||
lancer() { # $1 = fonction, $2 = fichier NDJSON du test
|
||||
rm -f "$2"
|
||||
( cd "$BIN_DIR" && ETM_DECISIONS_OUT="$2" ETM_DECISIONS_EPOCH="$EPOQUE" \
|
||||
local trace=""
|
||||
[ "${ETM_DECISIONS_TRACES:-0}" = 1 ] && trace="$TRAVAIL/$1.trace"
|
||||
( cd "$BIN_DIR" && ETM_DECISIONS_OUT="$2" ETM_DECISIONS_EPOCH="$EPOQUE" ETM_DECISIONS_TRACE="$trace" \
|
||||
systemd-run --user --scope -q -p MemoryMax=2G -p MemorySwapMax=0 \
|
||||
bwrap --dev-bind / / --unshare-net --tmpfs /tmp --bind "$BUILD" "$BUILD" --bind "$TRAVAIL" "$TRAVAIL" \
|
||||
--chdir "$BIN_DIR" \
|
||||
@ -83,6 +89,11 @@ done
|
||||
# écart massif — mais on le dit ici en clair.
|
||||
[ "$total" -gt 0 ] || { echo "AUCUN cycle enregistré — l'enregistreur n'est pas branché" >&2; exit 2; }
|
||||
echo "fonctions=${#FONCTIONS[@]} cycles=$total" >&2
|
||||
if [ "${ETM_DECISIONS_TRACES:-0}" = 1 ]; then
|
||||
read -r tl to < <(cat "$TRAVAIL"/*.trace 2>/dev/null | awk '{l+=$1; o+=$2} END {print l+0, o+0}')
|
||||
echo "traces activées : $tl ligne(s) de trace, $to octet(s) écrits" >&2
|
||||
[ "$tl" -gt 0 ] || { echo "AUCUNE ligne de trace — les traces ne sont pas branchées" >&2; exit 2; }
|
||||
fi
|
||||
|
||||
[ "$MODE" = record ] && exit 0
|
||||
|
||||
|
||||
@ -15,15 +15,20 @@
|
||||
#include "etm/scheduler/socketscheduler.h"
|
||||
#include "etm/scheduler/optimizerstatus.h"
|
||||
#include "etm/scheduler/publishedplan.h"
|
||||
#include "etm/scheduler/optimizertrace.h"
|
||||
#include "etm/types/decisionreason.h"
|
||||
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QLoggingCategory>
|
||||
#include <QSignalSpy>
|
||||
#include <QTemporaryDir>
|
||||
|
||||
#include <experiences/experiencemanager.h>
|
||||
#include <nymeasettings.h>
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
using namespace nymeaserver;
|
||||
|
||||
@ -117,6 +122,17 @@ private slots:
|
||||
void testGetPlanPublishesThePlanInForce();
|
||||
void testGetPlanShowsTheAppliedPowerUnderALock();
|
||||
void testUpdateLearnedIsLoggedOnceAndIgnored();
|
||||
// Brief 4 T6 — traces de décision (annexe D)
|
||||
void testTraceIsOffByDefault();
|
||||
void testTraceSettingByRpc();
|
||||
void testTraceLineUnderRulesAndInL2();
|
||||
void testTraceLineUnderTheOptimizer();
|
||||
void testTraceAbstentionFailureAndNoResponse();
|
||||
void testTraceRotatesAtLocalMidnightAndPurges();
|
||||
void testTraceCapStopsUntilTheNextDay();
|
||||
void testTraceWriteFailureChangesNoDecision();
|
||||
void testTokenIsInNoTrace();
|
||||
void testTraceLineSize();
|
||||
|
||||
private:
|
||||
//! Banc commun ; \p avecOptimiseur faux = rien n'est configuré.
|
||||
@ -153,6 +169,17 @@ private:
|
||||
void amenerAOptimiseur(const QDateTime &t0, int validiteS = 1800);
|
||||
//! Triplets publiés par les notifications \c OptimizerStatusChanged de l'arbitre.
|
||||
static QStringList triplets(const QSignalSpy &spy);
|
||||
//! T6 : active les traces dans un répertoire temporaire propre ; rend son chemin.
|
||||
QString activerTraces();
|
||||
//! T6 : les lignes de trace de \p dossier, tous fichiers, dans l'ordre ; attend d'en avoir
|
||||
//! au moins \p n (l'écrivain est asynchrone).
|
||||
static QList<QVariantMap> traces(const QString &dossier, int n = 0);
|
||||
//! T6 : les fichiers de trace de \p dossier, triés.
|
||||
static QStringList fichiersTrace(const QString &dossier);
|
||||
//! T6 : copie les traces de \p dossier sous \c $ETM_POWERSYNC_TRACE_EXPORT (s'il est posé),
|
||||
//! préfixées de \p nom — la matière du test de \c tools/etm-powersync-trace-replay.
|
||||
static void exporterTraces(const QString &dossier, const QString &nom);
|
||||
QTemporaryDir m_dossierTraces;
|
||||
};
|
||||
|
||||
QString TestSocketScheduler::triplet() const
|
||||
@ -208,6 +235,9 @@ void TestSocketScheduler::monter(bool avecOptimiseur, FakeOptimizer::Transport t
|
||||
// fonction précédente du même processus a pu les poser.
|
||||
EnergySettings().remove("Optimizer/endpoint");
|
||||
QFile::remove(OptimizerClient::defaultTokenPath());
|
||||
// T6 : ni traces ni réglage de traces hérités d'une fonction précédente.
|
||||
for (const char *cle : {"Optimizer/trace", "Optimizer/traceRetentionDays", "Optimizer/traceMaxBytesPerDay"})
|
||||
EnergySettings().remove(cle);
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
m_arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
@ -493,6 +523,7 @@ void TestSocketScheduler::testRefusedPlanFallsBackToRules()
|
||||
void TestSocketScheduler::testOutOfBoundsActionIsClamped()
|
||||
{
|
||||
monter();
|
||||
const QString d = activerTraces();
|
||||
m_repondre = [this](const QVariantMap &req) {
|
||||
return plan(req, {action("ecs", {{"powerW", 2500}}), action(m_var, {{"powerW", 0}})});
|
||||
};
|
||||
@ -509,6 +540,21 @@ void TestSocketScheduler::testOutOfBoundsActionIsClamped()
|
||||
// Le même écrêtage au cycle suivant ne se répète pas en clair.
|
||||
cycle(t0.addSecs(120), -3000);
|
||||
QCOMPARE(j.compte("ÉCRÊTÉE"), 1);
|
||||
// T6 : la trace dit l'APPLIQUÉ et le marque `clamped` ; la réponse brute garde la demande.
|
||||
const QVariantMap ligne = traces(d, 2).at(1);
|
||||
QCOMPARE(ligne.value("strategy").toString(), QString("optimizer"));
|
||||
bool vu = false;
|
||||
for (const QVariant &v : ligne.value("executed").toList()) {
|
||||
if (v.toMap().value("loadId").toString() != "ecs")
|
||||
continue;
|
||||
vu = true;
|
||||
QCOMPARE(qRound(v.toMap().value("powerW").toDouble()), 1000);
|
||||
QCOMPARE(v.toMap().value("clamped").toBool(), true);
|
||||
}
|
||||
QVERIFY(vu);
|
||||
const QVariantList demandees = ligne.value("response").toMap().value("result").toMap().value("slots")
|
||||
.toList().first().toMap().value("actions").toList();
|
||||
QCOMPARE(qRound(demandees.first().toMap().value("powerW").toDouble()), 2500);
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testUnavailableLoadKeepsTheShadowAction()
|
||||
@ -1386,5 +1432,492 @@ void TestSocketScheduler::testUpdateLearnedIsLoggedOnceAndIgnored()
|
||||
QVERIFY(!req.contains("4321") && !req.contains("learned"));
|
||||
}
|
||||
|
||||
// --- Brief 4 T6 : traces de décision (OPTIMIZER_PROTOCOL annexe D) ----------------------------
|
||||
|
||||
QString TestSocketScheduler::activerTraces()
|
||||
{
|
||||
static int n = 0;
|
||||
const QString d = m_dossierTraces.path() + QStringLiteral("/traces-%1").arg(++n);
|
||||
QDir().mkpath(d);
|
||||
m_arb->setOptimizerTraceDirectory(d);
|
||||
const QVariantMap r = rpc("SetOptimizerTrace", {{"enabled", true}});
|
||||
[&] { QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorNoError")); }();
|
||||
[&] { QVERIFY(m_arb->optimizerTracer()); }();
|
||||
return d;
|
||||
}
|
||||
|
||||
QStringList TestSocketScheduler::fichiersTrace(const QString &dossier)
|
||||
{
|
||||
return QDir(dossier).entryList({"etm-powersync-optimizer-trace-*.ndjson"}, QDir::Files, QDir::Name);
|
||||
}
|
||||
|
||||
void TestSocketScheduler::exporterTraces(const QString &dossier, const QString &nom)
|
||||
{
|
||||
const QString vers = qEnvironmentVariable("ETM_POWERSYNC_TRACE_EXPORT");
|
||||
if (vers.isEmpty())
|
||||
return;
|
||||
QDir().mkpath(vers);
|
||||
for (const QString &f : fichiersTrace(dossier)) {
|
||||
QFile::remove(QDir(vers).filePath(nom + "-" + f));
|
||||
QFile::copy(QDir(dossier).filePath(f), QDir(vers).filePath(nom + "-" + f));
|
||||
}
|
||||
}
|
||||
|
||||
QList<QVariantMap> TestSocketScheduler::traces(const QString &dossier, int n)
|
||||
{
|
||||
QList<QVariantMap> lignes;
|
||||
auto lire = [&] {
|
||||
lignes.clear();
|
||||
for (const QString &nom : fichiersTrace(dossier)) {
|
||||
QFile f(QDir(dossier).filePath(nom));
|
||||
if (!f.open(QIODevice::ReadOnly))
|
||||
continue;
|
||||
for (const QByteArray &l : f.readAll().split('\n'))
|
||||
if (!l.isEmpty())
|
||||
lignes.append(QJsonDocument::fromJson(l).object().toVariantMap());
|
||||
}
|
||||
return lignes.count() >= n;
|
||||
};
|
||||
waitFor(lire, 3000);
|
||||
return lignes;
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testTraceIsOffByDefault()
|
||||
{
|
||||
// Défaut : DÉSACTIVÉ. Ni traceur, ni fil, ni fichier, ni ligne de journal.
|
||||
monter(false);
|
||||
QVERIFY(!m_arb->optimizerTracer());
|
||||
QVERIFY(m_arb->findChildren<OptimizerTracer *>().isEmpty());
|
||||
const QVariantMap g = rpc("GetOptimizerTrace");
|
||||
QCOMPARE(g.value("enabled").toBool(), false);
|
||||
QCOMPARE(g.value("retentionDays").toInt(), 14);
|
||||
QCOMPARE(g.value("maxBytesPerDay").toLongLong(), OptimizerTrace::PlafondDefautOctets);
|
||||
QCOMPARE(g.value("directory").toString(), NymeaSettings::storagePath());
|
||||
Journal j;
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
cycle(t0, -3000);
|
||||
cycle(t0.addSecs(60), -3000);
|
||||
settle(200);
|
||||
QVERIFY(fichiersTrace(NymeaSettings::storagePath()).isEmpty());
|
||||
QVERIFY(!j.texte().contains("[Trace]"));
|
||||
QVERIFY(j.texte().contains("[Arbitre]")); // le journal coulait bien
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testTraceSettingByRpc()
|
||||
{
|
||||
monter(false);
|
||||
// Refus : une valeur hors bornes ou du mauvais type ne change RIEN, pas même les autres clés
|
||||
// de l'appel (le type est contrôlé par le moteur : le validateur Qt 6 ne le fait pas).
|
||||
const QList<QVariantMap> refus{
|
||||
{{"retentionDays", 0}}, {{"retentionDays", 366}}, {{"retentionDays", 1.5}},
|
||||
{{"retentionDays", "14"}}, {{"maxBytesPerDay", 1000}}, {{"maxBytesPerDay", 2147483647}},
|
||||
{{"enabled", "yes"}}, {{"enabled", true}, {"retentionDays", 0}}};
|
||||
for (const QVariantMap &p : refus) {
|
||||
const QVariantMap r = rpc("SetOptimizerTrace", p);
|
||||
QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorInvalidParameter"));
|
||||
QCOMPARE(r.value("fault").toString(), QString("invalid-trace-setting"));
|
||||
QVERIFY(!m_arb->optimizerTracer());
|
||||
QCOMPARE(rpc("GetOptimizerTrace").value("retentionDays").toInt(), 14);
|
||||
QVERIFY(!EnergySettings().contains("Optimizer/trace"));
|
||||
}
|
||||
// Pose : persistée dans energy.conf, appliquée à chaud.
|
||||
const QString d = m_dossierTraces.path() + "/reglage";
|
||||
QDir().mkpath(d);
|
||||
m_arb->setOptimizerTraceDirectory(d);
|
||||
QVariantMap r = rpc("SetOptimizerTrace", {{"enabled", true}, {"retentionDays", 3}, {"maxBytesPerDay", 2097152}});
|
||||
QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorNoError"));
|
||||
OptimizerTracer *tr = m_arb->optimizerTracer();
|
||||
QVERIFY(tr);
|
||||
QCOMPARE(tr->reglage().retentionJours, 3);
|
||||
QCOMPARE(tr->reglage().plafondOctetsJour, qint64(2097152));
|
||||
QCOMPARE(EnergySettings().value("Optimizer/trace").toBool(), true);
|
||||
QCOMPARE(EnergySettings().value("Optimizer/traceRetentionDays").toInt(), 3);
|
||||
QCOMPARE(EnergySettings().value("Optimizer/traceMaxBytesPerDay").toLongLong(), 2097152ll);
|
||||
QCOMPARE(QJsonObject::fromVariantMap(rpc("GetOptimizerTrace")),
|
||||
QJsonObject::fromVariantMap({{"enabled", true}, {"retentionDays", 3},
|
||||
{"maxBytesPerDay", 2097152}, {"directory", d}}));
|
||||
// Une clé seule : les autres restent, le traceur n'est pas reconstruit.
|
||||
rpc("SetOptimizerTrace", {{"retentionDays", 7}});
|
||||
QCOMPARE(m_arb->optimizerTracer(), tr);
|
||||
QCOMPARE(tr->reglage().retentionJours, 7);
|
||||
QCOMPARE(tr->reglage().plafondOctetsJour, qint64(2097152));
|
||||
// Désactivation : le traceur disparaît, la ligne de réglage reste lisible.
|
||||
rpc("SetOptimizerTrace", {{"enabled", false}});
|
||||
QVERIFY(!m_arb->optimizerTracer());
|
||||
QCOMPARE(rpc("GetOptimizerTrace").value("enabled").toBool(), false);
|
||||
QCOMPARE(rpc("GetOptimizerTrace").value("retentionDays").toInt(), 7);
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testTraceLineUnderRulesAndInL2()
|
||||
{
|
||||
// Sans endpoint : la requête qui SERAIT partie, et le plan du rule-based comme réponse.
|
||||
monter(false);
|
||||
const QString d = activerTraces();
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
cycle(t0, -3000);
|
||||
cycle(t0.addSecs(60), -3000);
|
||||
QList<QVariantMap> l = traces(d, 2);
|
||||
QCOMPARE(l.count(), 2);
|
||||
const QVariantMap a = l.last();
|
||||
QStringList cles = a.keys();
|
||||
cles.sort();
|
||||
QCOMPARE(cles, (QStringList{"executed", "request", "response", "strategy", "t"}));
|
||||
QCOMPARE(a.value("t").toString(), OptimizerTrace::horodatage(t0.addSecs(60)));
|
||||
QCOMPARE(QDateTime::fromString(a.value("t").toString(), Qt::ISODate), t0.addSecs(60));
|
||||
// L'heure LOCALE, décalage explicite (annexe D) — pas l'UTC.
|
||||
QCOMPARE(QDateTime::fromString(a.value("t").toString(), Qt::ISODate).offsetFromUtc(),
|
||||
t0.toLocalTime().offsetFromUtc());
|
||||
QVERIFY(!a.value("t").toString().endsWith('Z'));
|
||||
QCOMPARE(a.value("strategy").toString(), QString("rules"));
|
||||
|
||||
const QVariantMap req = a.value("request").toMap();
|
||||
QCOMPARE(req.value("trigger").toString(), QString("cycle"));
|
||||
QCOMPARE(req.value("meter").toMap().value("exportW").toDouble(), 3000.0);
|
||||
QVERIFY(req.contains("forecast") && req.contains("timestamp"));
|
||||
QStringList ids;
|
||||
for (const QVariant &c : req.value("loads").toList())
|
||||
ids << c.toMap().value("id").toString();
|
||||
QVERIFY(ids.contains("ecs") && ids.contains(m_var));
|
||||
|
||||
const QVariantMap rep = a.value("response").toMap().value("result").toMap();
|
||||
QCOMPARE(rep.value("strategy").toString(), QString("rules"));
|
||||
QCOMPARE(rep.value("slots").toList().count(), 1);
|
||||
const QHash<QString, QVariantMap> tele = telemetrie();
|
||||
for (const QVariant &v : rep.value("slots").toList().first().toMap().value("actions").toList()) {
|
||||
const QVariantMap act = v.toMap();
|
||||
QVERIFY(!act.value("reasonCode").toString().isEmpty());
|
||||
QVERIFY(!act.contains("reason")); // des codes, jamais de texte
|
||||
}
|
||||
QCOMPARE(a.value("executed").toList().count(), tele.count());
|
||||
for (const QVariant &v : a.value("executed").toList()) {
|
||||
const QVariantMap e = v.toMap();
|
||||
QVERIFY2(tele.contains(e.value("loadId").toString()), qUtf8Printable(e.value("loadId").toString()));
|
||||
QCOMPARE(e.value("kind").toString(), QString("setpoint"));
|
||||
QCOMPARE(qRound(e.value("powerW").toDouble()),
|
||||
qRound(tele.value(e.value("loadId").toString()).value("allocatedW").toDouble()));
|
||||
QCOMPARE(e.value("clamped").toBool(), false);
|
||||
}
|
||||
QVERIFY(!a.contains("latencyMs"));
|
||||
|
||||
// L2 : une ligne `none`, SANS requête ni réponse, rien d'exécuté par la planification.
|
||||
m_arb->evaluateMeterFreshness(t0.addSecs(200));
|
||||
QVERIFY(m_arb->degradedMode());
|
||||
m_arb->simulationCallUpdate(t0.addSecs(240));
|
||||
l = traces(d, 3);
|
||||
QCOMPARE(l.count(), 3);
|
||||
QCOMPARE(l.last(), (QVariantMap{{"t", OptimizerTrace::horodatage(t0.addSecs(240))},
|
||||
{"executed", QVariantList()}, {"strategy", "none"}}));
|
||||
exporterTraces(d, "regles");
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testTraceLineUnderTheOptimizer()
|
||||
{
|
||||
monter();
|
||||
const QString d = activerTraces();
|
||||
m_repondre = planValide();
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
cycle(t0, -3000);
|
||||
cycle(t0.addSecs(60), -3000);
|
||||
const QList<QVariantMap> l = traces(d, 2);
|
||||
QCOMPARE(l.count(), 2);
|
||||
QCOMPARE(m_requetes.count(), 2);
|
||||
for (int i = 0; i < 2; ++i) {
|
||||
// La requête RÉELLEMENT envoyée, et la réponse BRUTE à CETTE requête, avec sa latence.
|
||||
QCOMPARE(QJsonObject::fromVariantMap(l.at(i).value("request").toMap()),
|
||||
QJsonObject::fromVariantMap(m_requetes.at(i)));
|
||||
QCOMPARE(QJsonObject::fromVariantMap(l.at(i).value("response").toMap().value("result").toMap()),
|
||||
QJsonObject::fromVariantMap(planValide()(m_requetes.at(i))));
|
||||
QVERIFY(l.at(i).value("latencyMs").toLongLong() >= 0);
|
||||
// Le texte libre de l'optimiseur n'est QUE dans la réponse brute.
|
||||
QVariantMap sansReponse = l.at(i);
|
||||
sansReponse.remove("response");
|
||||
QVERIFY(!QJsonDocument::fromVariant(sansReponse).toJson().contains("LIBRE"));
|
||||
}
|
||||
// Cycle 1 : rien en cache, le rule-based a commandé ; cycle 2 : le plan reçu au cycle 1.
|
||||
QCOMPARE(l.at(0).value("strategy").toString(), QString("rules"));
|
||||
QCOMPARE(l.at(1).value("strategy").toString(), QString("optimizer"));
|
||||
QHash<QString, double> puissances;
|
||||
for (const QVariant &v : l.at(1).value("executed").toList()) {
|
||||
puissances.insert(v.toMap().value("loadId").toString(), v.toMap().value("powerW").toDouble());
|
||||
QCOMPARE(v.toMap().value("clamped").toBool(), false);
|
||||
}
|
||||
QCOMPARE(puissances.value("ecs"), 1000.0);
|
||||
QCOMPARE(puissances.value(m_var), 500.0);
|
||||
exporterTraces(d, "optimiseur");
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testTraceAbstentionFailureAndNoResponse()
|
||||
{
|
||||
monter();
|
||||
const QString d = activerTraces();
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
m_repondre = [](const QVariantMap &) { return QVariantMap{{"abstain", true}, {"reason", "recalcul"}}; };
|
||||
cycle(t0, -3000);
|
||||
m_repondre = [](const QVariantMap &) { return QVariantMap{{"__erreur", true}}; };
|
||||
cycle(t0.addSecs(60), -3000);
|
||||
m_repondre = nullptr; // ne répond plus : la requête sera remplacée sans réponse
|
||||
cycle(t0.addSecs(120), -3000);
|
||||
cycle(t0.addSecs(180), -3000);
|
||||
const QList<QVariantMap> l = traces(d, 3);
|
||||
QCOMPARE(l.count(), 3);
|
||||
QCOMPARE(l.at(0).value("response").toMap(),
|
||||
(QVariantMap{{"result", QVariantMap{{"abstain", true}, {"reason", "recalcul"}}}}));
|
||||
QCOMPARE(l.at(1).value("response").toMap(),
|
||||
(QVariantMap{{"error", QVariantMap{{"code", "rpc-error"}}}}));
|
||||
QVERIFY(l.at(1).contains("latencyMs"));
|
||||
QCOMPARE(l.at(2).value("response").toMap(),
|
||||
(QVariantMap{{"error", QVariantMap{{"code", "no-response"}}}}));
|
||||
QVERIFY(!l.at(2).contains("latencyMs"));
|
||||
for (const QVariantMap &x : l) {
|
||||
QCOMPARE(x.value("strategy").toString(), QString("rules"));
|
||||
QVERIFY(x.contains("request") && !x.value("executed").toList().isEmpty());
|
||||
}
|
||||
// La 4e ligne attend encore sa réponse : elle part au cycle suivant, ou quand l'arbitre
|
||||
// abandonne les traces (désactivation) — jamais perdue en silence.
|
||||
rpc("SetOptimizerTrace", {{"enabled", false}});
|
||||
QCOMPARE(traces(d, 4).last().value("response").toMap(),
|
||||
(QVariantMap{{"error", QVariantMap{{"code", "no-response"}}}}));
|
||||
exporterTraces(d, "repli");
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testTraceRotatesAtLocalMidnightAndPurges()
|
||||
{
|
||||
monter(false);
|
||||
const QString d = activerTraces();
|
||||
const QDate jour(2026, 6, 8);
|
||||
auto poser = [&](const QString &nom) {
|
||||
QFile f(QDir(d).filePath(nom));
|
||||
QVERIFY(f.open(QIODevice::WriteOnly));
|
||||
f.write("{}\n");
|
||||
};
|
||||
poser(OptimizerTrace::nomFichier(jour.addDays(-14))); // hors rétention : purgé
|
||||
poser(OptimizerTrace::nomFichier(jour.addDays(-13))); // dans la rétention (14 jours, ce jour compris)
|
||||
poser("etm-powersync-optimizer-trace-pas-une-date.ndjson");
|
||||
poser("autre.ndjson");
|
||||
Journal j;
|
||||
// 23:59 puis 00:00:30 HEURE LOCALE : la rotation suit le jour local du cycle.
|
||||
cycle(QDateTime(jour, QTime(23, 59, 0)), -3000);
|
||||
traces(d, 3);
|
||||
QCOMPARE(fichiersTrace(d), (QStringList{OptimizerTrace::nomFichier(jour.addDays(-13)),
|
||||
OptimizerTrace::nomFichier(jour),
|
||||
"etm-powersync-optimizer-trace-pas-une-date.ndjson"}));
|
||||
cycle(QDateTime(jour.addDays(1), QTime(0, 0, 30)), -3000);
|
||||
traces(d, 4);
|
||||
QCOMPARE(fichiersTrace(d), (QStringList{OptimizerTrace::nomFichier(jour),
|
||||
OptimizerTrace::nomFichier(jour.addDays(1)),
|
||||
"etm-powersync-optimizer-trace-pas-une-date.ndjson"}));
|
||||
QVERIFY(QFile::exists(QDir(d).filePath("autre.ndjson")));
|
||||
QFile f(QDir(d).filePath(OptimizerTrace::nomFichier(jour.addDays(1))));
|
||||
QVERIFY(f.open(QIODevice::ReadOnly));
|
||||
const QVariantMap ligne = QJsonDocument::fromJson(f.readLine()).object().toVariantMap();
|
||||
QCOMPARE(QDateTime::fromString(ligne.value("t").toString(), Qt::ISODate).toLocalTime().date(), jour.addDays(1));
|
||||
// Rétention réglée à chaud : 1 jour = le fichier du jour seul.
|
||||
rpc("SetOptimizerTrace", {{"retentionDays", 1}});
|
||||
QVERIFY(waitFor([&] { return !fichiersTrace(d).contains(OptimizerTrace::nomFichier(jour)); }));
|
||||
QCOMPARE(j.compte("[Trace]"), j.compte("[Trace] 1 fichier(s)") + j.compte("[Trace] réglage appliqué"));
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testTraceCapStopsUntilTheNextDay()
|
||||
{
|
||||
monter(false);
|
||||
const QString d = activerTraces();
|
||||
rpc("SetOptimizerTrace", {{"maxBytesPerDay", OptimizerTrace::PlafondMinOctets}});
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
const QString chemin = QDir(d).filePath(OptimizerTrace::nomFichier(t0.toLocalTime().date()));
|
||||
{
|
||||
QFile f(chemin);
|
||||
QVERIFY(f.open(QIODevice::WriteOnly));
|
||||
f.write(QByteArray(OptimizerTrace::PlafondMinOctets - 100, 'x') + '\n');
|
||||
}
|
||||
const qint64 avant = QFileInfo(chemin).size();
|
||||
Journal j;
|
||||
for (int i = 0; i < 4; ++i)
|
||||
cycle(t0.addSecs(60 * i), -3000);
|
||||
settle(300);
|
||||
QCOMPARE(QFileInfo(chemin).size(), avant); // plus rien ce jour-là
|
||||
QCOMPARE(j.compte("plafond journalier atteint"), 1);
|
||||
// Le lendemain : de nouveau écrit.
|
||||
cycle(t0.addDays(1), -3000);
|
||||
const QString demain = QDir(d).filePath(OptimizerTrace::nomFichier(t0.addDays(1).toLocalTime().date()));
|
||||
QVERIFY(waitFor([&] { return QFileInfo(demain).size() > 0; }));
|
||||
QCOMPARE(j.compte("plafond journalier atteint"), 1);
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testTraceWriteFailureChangesNoDecision()
|
||||
{
|
||||
if (::geteuid() == 0)
|
||||
QSKIP("root écrit dans un répertoire en lecture seule");
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
const QList<double> compteur{-3000, -1500, -500, -2200};
|
||||
// Ce que décide chaque cycle, par RÔLE de charge (l'identifiant du routeur change d'un banc à
|
||||
// l'autre) : puissance allouée et code du motif.
|
||||
auto releve = [&] {
|
||||
QStringList r;
|
||||
const QHash<QString, QVariantMap> t = telemetrie();
|
||||
for (const QString &id : {QStringLiteral("ecs"), m_var})
|
||||
r << QStringLiteral("%1:%2").arg(qRound(t.value(id).value("allocatedW").toDouble()))
|
||||
.arg(t.value(id).value("decision").toMap().value("code").toString());
|
||||
return r.join('|');
|
||||
};
|
||||
monter(false);
|
||||
QStringList reference;
|
||||
for (int i = 0; i < compteur.count(); ++i) {
|
||||
cycle(t0.addSecs(60 * i), compteur.at(i));
|
||||
reference << releve();
|
||||
}
|
||||
|
||||
monter(false);
|
||||
const QString d = activerTraces();
|
||||
QVERIFY(QFile::setPermissions(d, QFileDevice::ReadOwner | QFileDevice::ExeOwner));
|
||||
Journal j;
|
||||
QStringList obtenu;
|
||||
for (int i = 0; i < compteur.count(); ++i) {
|
||||
cycle(t0.addSecs(60 * i), compteur.at(i));
|
||||
obtenu << releve();
|
||||
}
|
||||
settle(300);
|
||||
QCOMPARE(obtenu, reference);
|
||||
QVERIFY(fichiersTrace(d).isEmpty());
|
||||
QCOMPARE(j.compte("[Trace] écriture impossible"), 1);
|
||||
// Le répertoire redevient inscriptible : l'écriture reprend, et c'est dit une fois.
|
||||
QVERIFY(QFile::setPermissions(d, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner));
|
||||
cycle(t0.addSecs(600), -3000);
|
||||
QCOMPARE(traces(d, 1).count(), 1);
|
||||
QCOMPARE(j.compte("[Trace] écriture rétablie"), 1);
|
||||
QCOMPARE(j.compte("[Trace] écriture impossible"), 1);
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testTokenIsInNoTrace()
|
||||
{
|
||||
monter();
|
||||
// Un secret avec les deux caractères que JSON échappe : le masquage doit le reconnaître écrit.
|
||||
const QString secret = QStringLiteral("T6-secret\"qui\\ne-sort-pas-0123456789");
|
||||
QCOMPARE(rpc("SetOptimizerToken", {{"token", secret}}).value("energyError").toString(),
|
||||
QString("EnergyErrorNoError"));
|
||||
const QString d = activerTraces();
|
||||
// Un optimiseur qui RECOPIE le token dans sa réponse (motif, identifiant de plan).
|
||||
m_repondre = [this, secret](const QVariantMap &req) {
|
||||
QVariantMap p = plan(req, {action("ecs", {{"powerW", 1000}, {"reason", "écho " + secret}}),
|
||||
action(m_var, {{"powerW", 500}})});
|
||||
p.insert("planId", secret);
|
||||
return p;
|
||||
};
|
||||
Journal j;
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
cycle(t0, -3000);
|
||||
cycle(t0.addSecs(60), -3000);
|
||||
QCOMPARE(traces(d, 2).count(), 2);
|
||||
QByteArray tout;
|
||||
for (const QString &nom : fichiersTrace(d)) {
|
||||
QFile f(QDir(d).filePath(nom));
|
||||
QVERIFY(f.open(QIODevice::ReadOnly));
|
||||
tout += f.readAll();
|
||||
}
|
||||
const QByteArray echappe = QJsonDocument(QJsonArray{secret}).toJson(QJsonDocument::Compact).mid(2).chopped(2);
|
||||
QVERIFY(!tout.contains(secret.toUtf8()));
|
||||
QVERIFY(!tout.contains(echappe));
|
||||
QVERIFY(!tout.contains("0123456789")); // pas même un morceau
|
||||
QCOMPARE(tout.count("[token masqué]"), 4); // 2 réponses × (motif + planId)
|
||||
QCOMPARE(j.compte("recopie le token"), 1);
|
||||
// La requête ne l'a jamais porté : il ne voyage que dans le handshake.
|
||||
for (const QVariantMap &r : std::as_const(m_requetes))
|
||||
QVERIFY(!QJsonDocument::fromVariant(r).toJson().contains("0123456789"));
|
||||
QFile::remove(OptimizerClient::defaultTokenPath());
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testTraceLineSize()
|
||||
{
|
||||
// MESURE (brief 4 T6, banc Pi Zero 2 W sur carte SD) : la taille d'une ligne au CONTEXTE
|
||||
// COMPLET — la requête réelle d'un cycle, sa prévision portée à 48 créneaux horaires (PV par
|
||||
// plan, base, météo, prix import/export), quatre charges — sous le rule-based et sous un
|
||||
// optimiseur qui rend 48 créneaux. Sérialisée comme l'écrit l'écrivain (JSON compact).
|
||||
monter();
|
||||
m_repondre = planValide();
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
cycle(t0, -3000);
|
||||
QVariantMap req = m_requetes.first();
|
||||
auto serie = [](double base, double pas) {
|
||||
QVariantList l;
|
||||
for (int i = 0; i < 48; ++i)
|
||||
l << qRound((base + pas * ((i * 7) % 13)) * 10) / 10.0;
|
||||
return l;
|
||||
};
|
||||
QVariantList plans;
|
||||
for (int k = 0; k < 2; ++k)
|
||||
plans << QVariantMap{{"id", QUuid::createUuid().toString()}, {"label", k ? "Toit ouest" : "Toit sud"},
|
||||
{"peakW", 4500}, {"tiltDeg", 35}, {"azimuthDeg", k ? 90 : 0},
|
||||
{"corrections", QVariantList{"temperature"}}, {"p50W", serie(1200, 310)}};
|
||||
req.insert("forecast", QVariantMap{
|
||||
{"start", t0.toString(Qt::ISODate)}, {"resolutionS", 3600}, {"steps", 48},
|
||||
{"pv", QVariantMap{{"provider", "open-meteo"}, {"weatherModel", "meteofrance_seamless"},
|
||||
{"conversion", "physics"}, {"corrections", QVariantList{"temperature", "ac-limit"}},
|
||||
{"issuedAt", t0.toString(Qt::ISODate)}, {"contentChangedAt", t0.toString(Qt::ISODate)},
|
||||
{"acLimitW", 8000}, {"p50W", serie(2400, 620)}, {"perField", plans}}},
|
||||
{"baseload", QVariantMap{{"model", "profile"}, {"p50W", serie(380, 45)},
|
||||
{"quality", QVariantMap{{"windowDays", 28}, {"minutes", 28800}}}}},
|
||||
{"weather", QVariantMap{{"temperatureC", serie(8, 1.3)}}},
|
||||
{"price", QVariantMap{{"currency", "EUR"}, {"importCtkWh", serie(18.5, 2.1)},
|
||||
{"exportCtkWh", serie(4.2, 0.6)}, {"includes", QVariantList{"energy", "grid", "taxes"}},
|
||||
{"source", "tempo"}, {"knownUntil", t0.addDays(1).toString(Qt::ISODate)}}}});
|
||||
QVariantList charges = req.value("loads").toList();
|
||||
for (int k = 0; charges.count() < 4; ++k) {
|
||||
QVariantMap c = charges.at(k).toMap();
|
||||
c.insert("id", QUuid::createUuid().toString());
|
||||
charges << c;
|
||||
}
|
||||
req.insert("loads", charges);
|
||||
|
||||
QVariantList executees, actionsRegles;
|
||||
for (const QVariant &c : charges) {
|
||||
const QString id = c.toMap().value("id").toString();
|
||||
executees << QVariantMap{{"loadId", id}, {"kind", "setpoint"}, {"powerW", 1234.5}, {"clamped", false}};
|
||||
actionsRegles << QVariantMap{{"loadId", id}, {"kind", "setpoint"}, {"powerW", 1234.5},
|
||||
{"reasonCode", "SURPLUS_SETPOINT"},
|
||||
{"reasonParams", QVariantMap{{"budgetW", 3000}, {"setpointW", 1234.5}}}};
|
||||
}
|
||||
QVariantList creneaux;
|
||||
for (int i = 0; i < 48; ++i) {
|
||||
QVariantList actions;
|
||||
for (const QVariant &c : charges)
|
||||
actions << QVariantMap{{"loadId", c.toMap().value("id")}, {"kind", "setpoint"}, {"powerW", 1500},
|
||||
{"reasonCode", "SURPLUS_SETPOINT"}, {"reason", "surplus prévu sur ce créneau"}};
|
||||
creneaux << QVariantMap{{"from", t0.addSecs(3600 * i).toString(Qt::ISODate)},
|
||||
{"to", t0.addSecs(3600 * (i + 1)).toString(Qt::ISODate)},
|
||||
{"actions", actions}, {"expected", QVariantMap{{"gridImportW", 120}, {"gridExportW", 0}}}};
|
||||
}
|
||||
auto taille = [](const QVariantMap &l) {
|
||||
return QJsonDocument(QJsonObject::fromVariantMap(l)).toJson(QJsonDocument::Compact).size() + 1;
|
||||
};
|
||||
const QVariantMap regles{{"t", OptimizerTrace::horodatage(t0)}, {"request", req},
|
||||
{"response", QVariantMap{{"result", QVariantMap{
|
||||
{"strategy", "rules"}, {"computedAt", t0.toString(Qt::ISODate)},
|
||||
{"validUntil", t0.addSecs(60).toString(Qt::ISODate)},
|
||||
{"slots", QVariantList{QVariantMap{{"from", t0.toString(Qt::ISODate)},
|
||||
{"to", t0.addSecs(60).toString(Qt::ISODate)},
|
||||
{"actions", actionsRegles}}}}}}}},
|
||||
{"executed", executees}, {"strategy", "rules"}};
|
||||
QVariantMap optimiseur = regles;
|
||||
optimiseur.insert("response", QVariantMap{{"result", QVariantMap{
|
||||
{"planId", QUuid::createUuid().toString()}, {"strategy", "etm-optimizer"},
|
||||
{"validUntil", t0.addSecs(1800).toString(Qt::ISODate)}, {"slots", creneaux},
|
||||
{"diagnostics", QVariantMap{{"status", "optimal"}, {"computeMs", 412}, {"solver", "highs"}}}}}});
|
||||
optimiseur.insert("strategy", "optimizer");
|
||||
optimiseur.insert("latencyMs", 431);
|
||||
const int sansPrevision = taille(QVariantMap{{"request", m_requetes.first()}});
|
||||
const int r = taille(regles), o = taille(optimiseur);
|
||||
qInfo().noquote() << QStringLiteral("[mesure] requête réelle du banc (2 charges, sans prévision) : %1 o").arg(sansPrevision);
|
||||
qInfo().noquote() << QStringLiteral("[mesure] ligne rule-based, 4 charges, prévision 48 créneaux : %1 o — %2 o/jour à 1 cycle/min")
|
||||
.arg(r).arg(qint64(r) * 1440);
|
||||
qInfo().noquote() << QStringLiteral("[mesure] ligne optimiseur, plan de 48 créneaux × 4 actions : %1 o — %2 o/jour")
|
||||
.arg(o).arg(qint64(o) * 1440);
|
||||
// Le plafond par défaut doit tenir une journée PLEINE d'optimiseur au rythme mesuré au banc
|
||||
// (1 cycle/min), avec 15 % de marge pour les cycles déclenchés par un événement ; et une
|
||||
// journée rule-based quatre fois.
|
||||
QVERIFY2(qint64(o) * 1440 * 115 / 100 <= OptimizerTrace::PlafondDefautOctets,
|
||||
qPrintable(QStringLiteral("%1 o par ligne d'optimiseur").arg(o)));
|
||||
QVERIFY2(qint64(r) * 1440 * 4 <= OptimizerTrace::PlafondDefautOctets,
|
||||
qPrintable(QStringLiteral("%1 o par ligne rule-based").arg(r)));
|
||||
}
|
||||
|
||||
QTEST_MAIN(TestSocketScheduler)
|
||||
#include "testsocketscheduler.moc"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user