From 483bbcb027466a4dd23bc7e5a86a86c77d911899 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 17 Oct 2026 12:12:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(traces):=20traces=20de=20d=C3=A9cision=20N?= =?UTF-8?q?DJSON,=20une=20ligne=20par=20cycle=20(brief=204=20T6,=20annexe?= =?UTF-8?q?=20D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- energyplugin/etm/energyarbitrator.cpp | 145 +++++ energyplugin/etm/energyarbitrator.h | 54 ++ energyplugin/etm/etm.pri | 2 + energyplugin/etm/scheduler/optimizertrace.cpp | 396 +++++++++++++ energyplugin/etm/scheduler/optimizertrace.h | 244 ++++++++ .../etm/scheduler/socketscheduler.cpp | 11 +- energyplugin/etm/scheduler/socketscheduler.h | 18 + energyplugin/nymeaenergyjsonhandler.cpp | 55 ++ energyplugin/nymeaenergyjsonhandler.h | 7 + tests/auto/decisions/decisionrecorder.cpp | 43 ++ tests/auto/decisions/run-decisions.sh | 13 +- .../socketscheduler/testsocketscheduler.cpp | 533 ++++++++++++++++++ 12 files changed, 1519 insertions(+), 2 deletions(-) create mode 100644 energyplugin/etm/scheduler/optimizertrace.cpp create mode 100644 energyplugin/etm/scheduler/optimizertrace.h diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index ee9cec5..4c69651 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -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 #include #include +#include +#include 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(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; diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index f80640a..2eca7a6 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -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 m_adapters; //!< loadId (ThingId string) → EvAdapter*. diff --git a/energyplugin/etm/etm.pri b/energyplugin/etm/etm.pri index ceb5296..c444fbf 100644 --- a/energyplugin/etm/etm.pri +++ b/energyplugin/etm/etm.pri @@ -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 \ diff --git a/energyplugin/etm/scheduler/optimizertrace.cpp b/energyplugin/etm/scheduler/optimizertrace.cpp new file mode 100644 index 0000000..a58de1b --- /dev/null +++ b/energyplugin/etm/scheduler/optimizertrace.cpp @@ -0,0 +1,396 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync + +#include "optimizertrace.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#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 &appliquees, + const QSet &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> 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 *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>(0)) +{ + qRegisterMetaType(); + 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()); +} diff --git a/energyplugin/etm/scheduler/optimizertrace.h b/energyplugin/etm/scheduler/optimizertrace.h new file mode 100644 index 0000000..4ee59c5 --- /dev/null +++ b/energyplugin/etm/scheduler/optimizertrace.h @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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 &appliquees, + const QSet &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> 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> 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> 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) diff --git a/energyplugin/etm/scheduler/socketscheduler.cpp b/energyplugin/etm/scheduler/socketscheduler.cpp index d23bbe2..fcb268b 100644 --- a/energyplugin/etm/scheduler/socketscheduler.cpp +++ b/energyplugin/etm/scheduler/socketscheduler.cpp @@ -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); } diff --git a/energyplugin/etm/scheduler/socketscheduler.h b/energyplugin/etm/scheduler/socketscheduler.h index 8c08f52..498a5b4 100644 --- a/energyplugin/etm/scheduler/socketscheduler.h +++ b/energyplugin/etm/scheduler/socketscheduler.h @@ -2,6 +2,7 @@ // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #pragma once +#include #include #include #include @@ -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 diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 23b6005..151947d 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -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()); + 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(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(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) diff --git a/energyplugin/nymeaenergyjsonhandler.h b/energyplugin/nymeaenergyjsonhandler.h index 88da143..a9c085b 100644 --- a/energyplugin/nymeaenergyjsonhandler.h +++ b/energyplugin/nymeaenergyjsonhandler.h @@ -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); diff --git a/tests/auto/decisions/decisionrecorder.cpp b/tests/auto/decisions/decisionrecorder.cpp index 1a0315e..fdeb2de 100644 --- a/tests/auto/decisions/decisionrecorder.cpp +++ b/tests/auto/decisions/decisionrecorder.cpp @@ -42,7 +42,9 @@ */ #include +#include #include +#include #include #include #include @@ -415,13 +417,54 @@ private: } // namespace +/* Brief 4 T6 — la preuve « traces ACTIVÉES » : avec ETM_DECISIONS_TRACE=, les traces de + * décision de l'arbitre sont activées AVANT chaque cycle (un scénario peut recréer l'arbitre), et + * reçoit après le cycle « » 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(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 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) diff --git a/tests/auto/decisions/run-decisions.sh b/tests/auto/decisions/run-decisions.sh index 9876b09..91e25c0 100755 --- a/tests/auto/decisions/run-decisions.sh +++ b/tests/auto/decisions/run-decisions.sh @@ -6,6 +6,10 @@ # run-decisions.sh 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). +# # : 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 diff --git a/tests/auto/socketscheduler/testsocketscheduler.cpp b/tests/auto/socketscheduler/testsocketscheduler.cpp index e5bff4c..1453932 100644 --- a/tests/auto/socketscheduler/testsocketscheduler.cpp +++ b/tests/auto/socketscheduler/testsocketscheduler.cpp @@ -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 +#include #include #include #include #include #include +#include + +#include 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 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(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 TestSocketScheduler::traces(const QString &dossier, int n) +{ + QList 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().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 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 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 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 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 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 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 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 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"