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:
Patrick Schurig 2026-10-17 12:12:25 +02:00
parent 1f6c7ec27a
commit 483bbcb027
12 changed files with 1519 additions and 2 deletions

View File

@ -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 &currentDateTime)
// 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 &currentDateTime)
// 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;

View File

@ -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*.

View File

@ -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 \

View 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());
}

View 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 &reglage() 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)

View File

@ -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);
}

View File

@ -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

View File

@ -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 &params)
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
}
JsonReply *NymeaEnergyJsonHandler::GetOptimizerTrace(const QVariantMap &params)
{
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 &params)
{
#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 &params)
{
Q_UNUSED(params)

View File

@ -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 &params);
//! \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 &params);
//! \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 &params);
//! \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 &params);

View File

@ -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)

View File

@ -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

View File

@ -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"