feat(traces): traces de décision NDJSON, une ligne par cycle (brief 4 T6, annexe D)
Réglage optimizerTrace (energy.conf Optimizer/trace, …RetentionDays, …MaxBytesPerDay), défaut DÉSACTIVÉ : ni objet, ni fil, ni fichier. NymeaEnergy.GetOptimizerTrace / SetOptimizerTrace, PermissionScopeAdmin comme SetOptimizerEndpoint, type contrôlé par le moteur, appliqué à chaud. Une ligne par cycle — rule-based, optimiseur, et L2 (strategy none, sans requête) : t (heure locale), request (params GetPlan envoyés, ou qui l'auraient été), response (ce qui a répondu à CETTE requête : result brut, error.code, no-response ; sinon le plan du rule-based en codes), executed (appliqué, clamped, shed), strategy, latencyMs. La ligne N attend la réponse à la requête N (elle sert au cycle N+1). Toutes les E/S sur un fil dédié (OptimizerTraceWriter) : rotation à minuit heure locale, purge au-delà de la rétention (14 j), plafond par fichier journalier (64 Mio, mesuré : 5 Ko/ligne rule-based, 39 Ko sous un optimiseur à 48 créneaux, 1 cycle/min), file bornée à 256 lignes. Échec d'écriture et plafond dits une fois. update() ne fait que poster. Le token n'est jamais dans une ligne ; recopié par une réponse d'optimiseur, il est masqué. Preuves : decisions IDENTIQUE traces désactivées ET activées (21 132 lignes de trace écrites, ETM_DECISIONS_TRACES=1). socketscheduler 33 fonctions, 114/0 (+10 fonctions T6). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1f6c7ec27a
commit
483bbcb027
@ -15,6 +15,7 @@
|
|||||||
#include "scheduler/socketscheduler.h"
|
#include "scheduler/socketscheduler.h"
|
||||||
#include "scheduler/optimizerstatus.h"
|
#include "scheduler/optimizerstatus.h"
|
||||||
#include "scheduler/publishedplan.h"
|
#include "scheduler/publishedplan.h"
|
||||||
|
#include "scheduler/optimizertrace.h"
|
||||||
#include "types/decisionreason.h"
|
#include "types/decisionreason.h"
|
||||||
#include "types/surpluscontext.h"
|
#include "types/surpluscontext.h"
|
||||||
#include "types/plan.h"
|
#include "types/plan.h"
|
||||||
@ -28,6 +29,8 @@
|
|||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
|
#include <nymeasettings.h>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
//! Période du watchdog L2 (SAFETY.md §L2) : tick indépendant des signaux compteur.
|
//! 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é.
|
// Brief 4 T3 — la stratégie optimiseur n'existe que si un endpoint est configuré.
|
||||||
relireReglageOptimiseur();
|
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
|
// 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à à
|
// 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,
|
// 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ù
|
// 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.
|
// 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;
|
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).
|
// 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.
|
// Rien ici ne commande : c'est de la publication, après tout le reste.
|
||||||
noterPlanDuCycle(plan, slot, currentDateTime);
|
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 {
|
namespace {
|
||||||
@ -2889,6 +2898,12 @@ void EnergyArbitrator::relireReglageOptimiseur()
|
|||||||
[s]() { s->noterDeclencheur(QStringLiteral("load-change")); });
|
[s]() { s->noterDeclencheur(QStringLiteral("load-change")); });
|
||||||
}
|
}
|
||||||
connect(s, &SocketScheduler::etatChange, this, &EnergyArbitrator::suivreStatutOptimiseur);
|
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"
|
qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur] stratégie optimiseur active vers"
|
||||||
<< endpoint.toString()
|
<< endpoint.toString()
|
||||||
<< "— rule-based en ombre à chaque cycle, repli sur lui";
|
<< "— rule-based en ombre à chaque cycle, repli sur lui";
|
||||||
@ -3004,6 +3019,136 @@ void EnergyArbitrator::noterPlanDuCycle(const Plan &plan, const Slot &slot, cons
|
|||||||
suivreStatutOptimiseur();
|
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 EnergyArbitrator::contexteFil(const SurplusContext &ctx) const
|
||||||
{
|
{
|
||||||
OptimizerProtocol::ContexteFil fil;
|
OptimizerProtocol::ContexteFil fil;
|
||||||
|
|||||||
@ -33,6 +33,7 @@ class ILoadAdapter;
|
|||||||
class LoadConfigStore;
|
class LoadConfigStore;
|
||||||
class RuleBasedScheduler;
|
class RuleBasedScheduler;
|
||||||
class SocketScheduler;
|
class SocketScheduler;
|
||||||
|
class OptimizerTracer;
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief Arbitre central ETM — remplace SmartChargingManager::update() (ETM_ARBITRATOR).
|
* \brief Arbitre central ETM — remplace SmartChargingManager::update() (ETM_ARBITRATOR).
|
||||||
@ -317,6 +318,43 @@ public:
|
|||||||
return m_planPublie.isEmpty() ? QVariantMap{{QStringLiteral("inForce"), false}} : m_planPublie;
|
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) :
|
* \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,
|
* 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);
|
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.
|
* \brief Vrai si deux instantanés diffèrent assez pour mériter une notification.
|
||||||
* \param a Dernier instantané publié. \param b Instantané courant.
|
* \param a Dernier instantané publié. \param b Instantané courant.
|
||||||
@ -1178,6 +1228,10 @@ private:
|
|||||||
QString m_statutOptimiseurEmis;
|
QString m_statutOptimiseurEmis;
|
||||||
//! Plan en vigueur publié au dernier cycle (\c publishedPlan()) ; vide sans plan.
|
//! Plan en vigueur publié au dernier cycle (\c publishedPlan()) ; vide sans plan.
|
||||||
QVariantMap m_planPublie;
|
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é.
|
//! Gestionnaire d'énergie (production PV instantanée du fil optimiseur). Non possédé.
|
||||||
EnergyManager *m_em = nullptr;
|
EnergyManager *m_em = nullptr;
|
||||||
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
|
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
|
||||||
|
|||||||
@ -22,6 +22,7 @@ HEADERS += \
|
|||||||
$$PWD/scheduler/optimizerplan.h \
|
$$PWD/scheduler/optimizerplan.h \
|
||||||
$$PWD/scheduler/socketscheduler.h \
|
$$PWD/scheduler/socketscheduler.h \
|
||||||
$$PWD/scheduler/optimizerstatus.h \
|
$$PWD/scheduler/optimizerstatus.h \
|
||||||
|
$$PWD/scheduler/optimizertrace.h \
|
||||||
$$PWD/scheduler/publishedplan.h \
|
$$PWD/scheduler/publishedplan.h \
|
||||||
$$PWD/ratios/energyratioscalculator.h \
|
$$PWD/ratios/energyratioscalculator.h \
|
||||||
$$PWD/energyarbitrator.h \
|
$$PWD/energyarbitrator.h \
|
||||||
@ -45,6 +46,7 @@ SOURCES += \
|
|||||||
$$PWD/scheduler/optimizerplan.cpp \
|
$$PWD/scheduler/optimizerplan.cpp \
|
||||||
$$PWD/scheduler/socketscheduler.cpp \
|
$$PWD/scheduler/socketscheduler.cpp \
|
||||||
$$PWD/scheduler/optimizerstatus.cpp \
|
$$PWD/scheduler/optimizerstatus.cpp \
|
||||||
|
$$PWD/scheduler/optimizertrace.cpp \
|
||||||
$$PWD/scheduler/publishedplan.cpp \
|
$$PWD/scheduler/publishedplan.cpp \
|
||||||
$$PWD/energyarbitrator.cpp \
|
$$PWD/energyarbitrator.cpp \
|
||||||
$$PWD/spotmarketbuild.cpp \
|
$$PWD/spotmarketbuild.cpp \
|
||||||
|
|||||||
396
energyplugin/etm/scheduler/optimizertrace.cpp
Normal file
396
energyplugin/etm/scheduler/optimizertrace.cpp
Normal file
@ -0,0 +1,396 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||||
|
|
||||||
|
#include "optimizertrace.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QLoggingCategory>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
|
||||||
|
#include <nymeasettings.h>
|
||||||
|
|
||||||
|
#include "energysettings.h"
|
||||||
|
|
||||||
|
Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)
|
||||||
|
|
||||||
|
namespace OptimizerTrace {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QString iso(const QDateTime &t) { return t.toUTC().toString(Qt::ISODate); }
|
||||||
|
|
||||||
|
QString nomKind(LoadAction::Kind k)
|
||||||
|
{
|
||||||
|
switch (k) {
|
||||||
|
case LoadAction::Setpoint: return QStringLiteral("setpoint");
|
||||||
|
case LoadAction::State: return QStringLiteral("state");
|
||||||
|
case LoadAction::Constraint: return QStringLiteral("constraint");
|
||||||
|
}
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Une action DÉCIDÉE, au format d'une action de plan (§6) — le motif en code, jamais en phrase.
|
||||||
|
QVariantMap actionDecidee(const LoadAction &a)
|
||||||
|
{
|
||||||
|
QVariantMap m;
|
||||||
|
m.insert(QStringLiteral("loadId"), a.loadId);
|
||||||
|
m.insert(QStringLiteral("kind"), nomKind(a.kind));
|
||||||
|
m.insert(QStringLiteral("powerW"), qMax(0.0, a.estimatedPowerW));
|
||||||
|
if (a.kind == LoadAction::State)
|
||||||
|
m.insert(QStringLiteral("state"), a.state);
|
||||||
|
m.insert(QStringLiteral("reasonCode"), a.reason.code);
|
||||||
|
if (!a.reason.params.isEmpty())
|
||||||
|
m.insert(QStringLiteral("reasonParams"), a.reason.params);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString CleActive = QStringLiteral("Optimizer/trace");
|
||||||
|
const QString CleRetention = QStringLiteral("Optimizer/traceRetentionDays");
|
||||||
|
const QString ClePlafond = QStringLiteral("Optimizer/traceMaxBytesPerDay");
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
QString nomFichier(const QDate &jour)
|
||||||
|
{
|
||||||
|
return QLatin1String(PrefixeFichier) + jour.toString(QStringLiteral("yyyy-MM-dd"))
|
||||||
|
+ QLatin1String(SuffixeFichier);
|
||||||
|
}
|
||||||
|
|
||||||
|
QDate dateDuFichier(const QString &nom)
|
||||||
|
{
|
||||||
|
static const QRegularExpression forme(QLatin1String("^") + QRegularExpression::escape(QLatin1String(PrefixeFichier))
|
||||||
|
+ QStringLiteral("(\\d{4}-\\d{2}-\\d{2})")
|
||||||
|
+ QRegularExpression::escape(QLatin1String(SuffixeFichier))
|
||||||
|
+ QStringLiteral("$"));
|
||||||
|
const QRegularExpressionMatch m = forme.match(nom);
|
||||||
|
return m.hasMatch() ? QDate::fromString(m.captured(1), QStringLiteral("yyyy-MM-dd")) : QDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString horodatage(const QDateTime &t)
|
||||||
|
{
|
||||||
|
const QDateTime local = t.toLocalTime();
|
||||||
|
return local.toOffsetFromUtc(local.offsetFromUtc()).toString(Qt::ISODate);
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantMap reponseRegles(const Plan &plan, const QDateTime &now)
|
||||||
|
{
|
||||||
|
QVariantMap r;
|
||||||
|
r.insert(QStringLiteral("strategy"), QStringLiteral("rules"));
|
||||||
|
r.insert(QStringLiteral("computedAt"), iso(now));
|
||||||
|
QVariantList creneaux;
|
||||||
|
QDateTime fin;
|
||||||
|
for (const Slot &s : plan.timeSlots) {
|
||||||
|
QVariantList actions;
|
||||||
|
for (const LoadAction &a : s.actions)
|
||||||
|
actions.append(actionDecidee(a));
|
||||||
|
creneaux.append(QVariantMap{{QStringLiteral("from"), iso(s.from.isValid() ? s.from : now)},
|
||||||
|
{QStringLiteral("to"), iso(s.to.isValid() ? s.to : now)},
|
||||||
|
{QStringLiteral("actions"), actions}});
|
||||||
|
if (s.to.isValid() && (!fin.isValid() || s.to > fin))
|
||||||
|
fin = s.to;
|
||||||
|
}
|
||||||
|
if (fin.isValid())
|
||||||
|
r.insert(QStringLiteral("validUntil"), iso(fin));
|
||||||
|
r.insert(QStringLiteral("slots"), creneaux);
|
||||||
|
return QVariantMap{{QStringLiteral("result"), r}};
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantList executees(const Slot &slot, const QHash<QString, LoadAction> &appliquees,
|
||||||
|
const QSet<QString> &delestees)
|
||||||
|
{
|
||||||
|
// Même lecture que le plan publié : ce que l'adaptateur a RENDU ; une charge délestée par L4
|
||||||
|
// ou non dispatchée n'a pas de retour, son action du créneau (fusionnée) fait foi.
|
||||||
|
QVariantList l;
|
||||||
|
for (const LoadAction &a : slot.actions) {
|
||||||
|
const auto r = appliquees.constFind(a.loadId);
|
||||||
|
const LoadAction &faite = r != appliquees.constEnd() ? *r : a;
|
||||||
|
QVariantMap m;
|
||||||
|
m.insert(QStringLiteral("loadId"), a.loadId);
|
||||||
|
m.insert(QStringLiteral("kind"), nomKind(a.kind));
|
||||||
|
m.insert(QStringLiteral("powerW"), qMax(0.0, faite.estimatedPowerW));
|
||||||
|
if (a.kind == LoadAction::State)
|
||||||
|
m.insert(QStringLiteral("state"), faite.state);
|
||||||
|
m.insert(QStringLiteral("clamped"), a.clamped);
|
||||||
|
if (delestees.contains(a.loadId))
|
||||||
|
m.insert(QStringLiteral("shed"), true);
|
||||||
|
l.append(m);
|
||||||
|
}
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray masquerToken(const QByteArray &ligne, const QByteArray &token, int *masques)
|
||||||
|
{
|
||||||
|
if (masques)
|
||||||
|
*masques = 0;
|
||||||
|
if (token.isEmpty())
|
||||||
|
return ligne;
|
||||||
|
// La forme que QJsonDocument donnerait au token dans une chaîne : ["…"] sans ses bornes.
|
||||||
|
const QByteArray tableau = QJsonDocument(QJsonArray{QString::fromUtf8(token)}).toJson(QJsonDocument::Compact);
|
||||||
|
const QByteArray echappe = tableau.mid(2, tableau.size() - 4);
|
||||||
|
if (echappe.isEmpty() || !ligne.contains(echappe))
|
||||||
|
return ligne;
|
||||||
|
QByteArray out = ligne;
|
||||||
|
const int n = out.count(echappe);
|
||||||
|
out.replace(echappe, QByteArrayLiteral("[token masqué]"));
|
||||||
|
if (masques)
|
||||||
|
*masques = n;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
Reglage Reglage::lire()
|
||||||
|
{
|
||||||
|
EnergySettings s;
|
||||||
|
Reglage r;
|
||||||
|
r.active = s.value(CleActive, false).toBool();
|
||||||
|
bool ok = false;
|
||||||
|
const int jours = s.value(CleRetention, RetentionDefautJours).toInt(&ok);
|
||||||
|
if (ok && jours >= RetentionMinJours && jours <= RetentionMaxJours)
|
||||||
|
r.retentionJours = jours;
|
||||||
|
const qint64 plafond = s.value(ClePlafond, PlafondDefautOctets).toLongLong(&ok);
|
||||||
|
if (ok && plafond >= PlafondMinOctets && plafond <= PlafondMaxOctets)
|
||||||
|
r.plafondOctetsJour = plafond;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Reglage::operator==(const Reglage &o) const
|
||||||
|
{
|
||||||
|
return active == o.active && retentionJours == o.retentionJours
|
||||||
|
&& plafondOctetsJour == o.plafondOctetsJour && dossier == o.dossier
|
||||||
|
&& cheminToken == o.cheminToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace OptimizerTrace
|
||||||
|
|
||||||
|
// --- Écrivain : son propre fil, toutes les E/S ------------------------------------------------
|
||||||
|
|
||||||
|
OptimizerTraceWriter::OptimizerTraceWriter(std::shared_ptr<std::atomic<int>> enAttente)
|
||||||
|
: m_enAttente(std::move(enAttente))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void OptimizerTraceWriter::configurer(const OptimizerTrace::Reglage &r)
|
||||||
|
{
|
||||||
|
const bool autreDossier = r.dossier != m_reglage.dossier;
|
||||||
|
m_reglage = r;
|
||||||
|
if (autreDossier) {
|
||||||
|
m_fichier.close();
|
||||||
|
m_jour = QDate();
|
||||||
|
}
|
||||||
|
// Un plafond relevé vaut tout de suite, le jour même.
|
||||||
|
m_plafondAtteint = QDate();
|
||||||
|
if (m_jour.isValid())
|
||||||
|
purger(m_jour);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OptimizerTraceWriter::ecrire(const QVariantMap &ligne, const QDate &jour)
|
||||||
|
{
|
||||||
|
// La ligne quitte la file quoi qu'il arrive ici : écrite, refusée par le plafond, ou perdue
|
||||||
|
// sur un échec.
|
||||||
|
struct Decompte {
|
||||||
|
std::atomic<int> *n;
|
||||||
|
~Decompte() { n->fetch_sub(1); }
|
||||||
|
} decompte{m_enAttente.get()};
|
||||||
|
|
||||||
|
int masques = 0;
|
||||||
|
const QByteArray l = OptimizerTrace::masquerToken(
|
||||||
|
QJsonDocument(QJsonObject::fromVariantMap(ligne)).toJson(QJsonDocument::Compact),
|
||||||
|
token(), &masques)
|
||||||
|
+ '\n';
|
||||||
|
if (masques > 0 && !m_masqueSignale) {
|
||||||
|
// La requête ne porte jamais le token : seule la réponse BRUTE d'un optimiseur peut le
|
||||||
|
// recopier. On dit le fait, jamais la valeur.
|
||||||
|
m_masqueSignale = true;
|
||||||
|
qCWarning(dcNymeaEnergy()) << "[Trace] la réponse de l'optimiseur recopie le token — masqué dans"
|
||||||
|
" la trace (signalé une fois par session)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jour != m_jour)
|
||||||
|
ouvrir(jour);
|
||||||
|
if (m_plafondAtteint == jour)
|
||||||
|
return;
|
||||||
|
if (!m_fichier.isOpen()) {
|
||||||
|
ouvrir(jour); // un échec précédent : on retente à chaque ligne, sans le redire
|
||||||
|
if (!m_fichier.isOpen())
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m_taille + l.size() > m_reglage.plafondOctetsJour) {
|
||||||
|
m_plafondAtteint = jour;
|
||||||
|
m_fichier.close();
|
||||||
|
qCWarning(dcNymeaEnergy()).noquote()
|
||||||
|
<< QStringLiteral("[Trace] plafond journalier atteint (%1 octets, réglage %2) — plus aucune"
|
||||||
|
" ligne jusqu'à demain ; décisions inchangées")
|
||||||
|
.arg(m_taille).arg(m_reglage.plafondOctetsJour);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const qint64 n = m_fichier.write(l);
|
||||||
|
if (n != l.size() || !m_fichier.flush()) {
|
||||||
|
echec(m_fichier.fileName() + QStringLiteral(" : ") + m_fichier.errorString());
|
||||||
|
m_fichier.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_taille += n;
|
||||||
|
if (m_echecSignale) {
|
||||||
|
m_echecSignale = false;
|
||||||
|
qCInfo(dcNymeaEnergy()).noquote() << "[Trace] écriture rétablie :" << m_fichier.fileName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void OptimizerTraceWriter::ouvrir(const QDate &jour)
|
||||||
|
{
|
||||||
|
m_fichier.close();
|
||||||
|
if (jour != m_jour)
|
||||||
|
purger(jour);
|
||||||
|
m_jour = jour;
|
||||||
|
m_taille = 0;
|
||||||
|
const QString dossier = m_reglage.dossier.isEmpty() ? NymeaSettings::storagePath() : m_reglage.dossier;
|
||||||
|
m_fichier.setFileName(QDir(dossier).filePath(OptimizerTrace::nomFichier(jour)));
|
||||||
|
if (!m_fichier.open(QIODevice::WriteOnly | QIODevice::Append)) {
|
||||||
|
echec(m_fichier.fileName() + QStringLiteral(" : ") + m_fichier.errorString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_taille = m_fichier.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
void OptimizerTraceWriter::purger(const QDate &aujourdhui)
|
||||||
|
{
|
||||||
|
const QString dossier = m_reglage.dossier.isEmpty() ? NymeaSettings::storagePath() : m_reglage.dossier;
|
||||||
|
QDir d(dossier);
|
||||||
|
// Rétention de N jours, celui du jour compris : on garde [aujourd'hui − N + 1, aujourd'hui].
|
||||||
|
const QDate limite = aujourdhui.addDays(-m_reglage.retentionJours);
|
||||||
|
int supprimes = 0;
|
||||||
|
const QStringList noms = d.entryList({QLatin1String(OptimizerTrace::PrefixeFichier) + QLatin1Char('*')
|
||||||
|
+ QLatin1String(OptimizerTrace::SuffixeFichier)},
|
||||||
|
QDir::Files);
|
||||||
|
for (const QString &nom : noms) {
|
||||||
|
const QDate date = OptimizerTrace::dateDuFichier(nom);
|
||||||
|
if (date.isValid() && date <= limite && d.remove(nom))
|
||||||
|
++supprimes;
|
||||||
|
}
|
||||||
|
if (supprimes > 0)
|
||||||
|
qCInfo(dcNymeaEnergy()) << "[Trace]" << supprimes << "fichier(s) de plus de"
|
||||||
|
<< m_reglage.retentionJours << "jour(s) supprimé(s)";
|
||||||
|
}
|
||||||
|
|
||||||
|
void OptimizerTraceWriter::echec(const QString &detail)
|
||||||
|
{
|
||||||
|
if (m_echecSignale)
|
||||||
|
return;
|
||||||
|
m_echecSignale = true;
|
||||||
|
qCWarning(dcNymeaEnergy()).noquote() << "[Trace] écriture impossible :" << detail
|
||||||
|
<< "— traces suspendues jusqu'à la prochaine écriture réussie"
|
||||||
|
" (signalé une fois) ; décisions inchangées";
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray OptimizerTraceWriter::token()
|
||||||
|
{
|
||||||
|
if (m_reglage.cheminToken.isEmpty())
|
||||||
|
return QByteArray();
|
||||||
|
const QFileInfo fi(m_reglage.cheminToken);
|
||||||
|
if (!fi.exists()) {
|
||||||
|
m_token.clear();
|
||||||
|
m_tokenTaille = -1;
|
||||||
|
return m_token;
|
||||||
|
}
|
||||||
|
if (fi.lastModified() != m_tokenLuA || fi.size() != m_tokenTaille) {
|
||||||
|
QFile f(m_reglage.cheminToken);
|
||||||
|
m_token = f.open(QIODevice::ReadOnly) ? f.read(8192).trimmed() : QByteArray();
|
||||||
|
m_tokenLuA = fi.lastModified();
|
||||||
|
m_tokenTaille = fi.size();
|
||||||
|
}
|
||||||
|
return m_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Traceur : fil de l'arbitre, jamais d'E/S ------------------------------------------------
|
||||||
|
|
||||||
|
OptimizerTracer::OptimizerTracer(const OptimizerTrace::Reglage &r, QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_enAttente(std::make_shared<std::atomic<int>>(0))
|
||||||
|
{
|
||||||
|
qRegisterMetaType<OptimizerTrace::Reglage>();
|
||||||
|
m_ecrivain = new OptimizerTraceWriter(m_enAttente);
|
||||||
|
m_ecrivain->moveToThread(&m_fil);
|
||||||
|
connect(&m_fil, &QThread::finished, m_ecrivain, &QObject::deleteLater);
|
||||||
|
connect(this, &OptimizerTracer::versEcrivain, m_ecrivain, &OptimizerTraceWriter::ecrire);
|
||||||
|
connect(this, &OptimizerTracer::reglageVersEcrivain, m_ecrivain, &OptimizerTraceWriter::configurer);
|
||||||
|
m_fil.setObjectName(QStringLiteral("etm-powersync-trace"));
|
||||||
|
m_fil.start(QThread::LowestPriority);
|
||||||
|
configurer(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
OptimizerTracer::~OptimizerTracer()
|
||||||
|
{
|
||||||
|
terminerEnAttente();
|
||||||
|
// L'arrêt passe DERRIÈRE les lignes déjà postées : elles sont écrites, puis le fil s'arrête.
|
||||||
|
// Jamais appelé depuis update() — à la désactivation (RPC) ou à l'arrêt du plugin.
|
||||||
|
QThread *fil = &m_fil;
|
||||||
|
QMetaObject::invokeMethod(m_ecrivain, [fil] { fil->quit(); }, Qt::QueuedConnection);
|
||||||
|
m_fil.wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
void OptimizerTracer::configurer(const OptimizerTrace::Reglage &r)
|
||||||
|
{
|
||||||
|
m_reglage = r;
|
||||||
|
emit reglageVersEcrivain(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OptimizerTracer::noterCycle(const QVariantMap &ligne, const QDateTime &t, quint64 idRequete)
|
||||||
|
{
|
||||||
|
terminerEnAttente();
|
||||||
|
if (idRequete == 0) {
|
||||||
|
poster(ligne, t);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_ligneEnAttente = ligne;
|
||||||
|
m_tEnAttente = t;
|
||||||
|
m_idEnAttente = idRequete;
|
||||||
|
}
|
||||||
|
|
||||||
|
void OptimizerTracer::noterReponse(quint64 id, const QVariantMap &reponse, qint64 latenceMs)
|
||||||
|
{
|
||||||
|
if (id == 0 || id != m_idEnAttente)
|
||||||
|
return;
|
||||||
|
QVariantMap l = m_ligneEnAttente;
|
||||||
|
l.insert(QStringLiteral("response"), reponse);
|
||||||
|
l.insert(QStringLiteral("latencyMs"), latenceMs);
|
||||||
|
m_idEnAttente = 0;
|
||||||
|
m_ligneEnAttente.clear();
|
||||||
|
poster(l, m_tEnAttente);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OptimizerTracer::terminerEnAttente()
|
||||||
|
{
|
||||||
|
if (m_idEnAttente == 0)
|
||||||
|
return;
|
||||||
|
QVariantMap l = m_ligneEnAttente;
|
||||||
|
l.insert(QStringLiteral("response"),
|
||||||
|
QVariantMap{{QStringLiteral("error"),
|
||||||
|
QVariantMap{{QStringLiteral("code"), QLatin1String(OptimizerTrace::SansReponse)}}}});
|
||||||
|
m_idEnAttente = 0;
|
||||||
|
m_ligneEnAttente.clear();
|
||||||
|
poster(l, m_tEnAttente);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OptimizerTracer::poster(const QVariantMap &ligne, const QDateTime &t)
|
||||||
|
{
|
||||||
|
if (m_enAttente->load() >= OptimizerTrace::FileMaxLignes) {
|
||||||
|
++m_abandonnees;
|
||||||
|
if (!m_fileSignalee) {
|
||||||
|
m_fileSignalee = true;
|
||||||
|
qCWarning(dcNymeaEnergy()) << "[Trace] l'écrivain ne suit plus (" << OptimizerTrace::FileMaxLignes
|
||||||
|
<< "lignes en attente) — lignes abandonnées jusqu'à ce qu'il rattrape ;"
|
||||||
|
" décisions inchangées";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m_fileSignalee) {
|
||||||
|
m_fileSignalee = false;
|
||||||
|
qCInfo(dcNymeaEnergy()) << "[Trace] l'écrivain a rattrapé —" << m_abandonnees
|
||||||
|
<< "ligne(s) abandonnée(s) depuis le démarrage des traces";
|
||||||
|
}
|
||||||
|
m_enAttente->fetch_add(1);
|
||||||
|
emit versEcrivain(ligne, t.toLocalTime().date());
|
||||||
|
}
|
||||||
244
energyplugin/etm/scheduler/optimizertrace.h
Normal file
244
energyplugin/etm/scheduler/optimizertrace.h
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QDate>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QString>
|
||||||
|
#include <QThread>
|
||||||
|
#include <QVariantMap>
|
||||||
|
#include <atomic>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include "../types/plan.h"
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* \file optimizertrace.h
|
||||||
|
* \brief Traces de décision (OPTIMIZER_PROTOCOL.md annexe D, brief 4 T6) : une ligne NDJSON par
|
||||||
|
* cycle, dans un fichier par jour LOCAL sous \c NymeaSettings::storagePath().
|
||||||
|
*
|
||||||
|
* Trois étages, chacun avec sa règle :
|
||||||
|
* - \c OptimizerTrace (fonctions PURES) : ce qu'une ligne contient ;
|
||||||
|
* - \c OptimizerTracer (fil de l'arbitre) : assemble la ligne d'un cycle, attend la réponse à SA
|
||||||
|
* requête, la confie à l'écrivain — sans jamais toucher au disque ni attendre ;
|
||||||
|
* - \c OptimizerTraceWriter (fil dédié) : toutes les E/S — rotation, purge, plafond, échecs.
|
||||||
|
*
|
||||||
|
* \par Ce qu'une ligne porte (annexe D)
|
||||||
|
* \c t (instant du cycle, heure locale avec décalage), \c request (les paramètres \c GetPlan
|
||||||
|
* envoyés, ou ceux qui l'auraient été), \c response (ce qui a répondu à CETTE requête),
|
||||||
|
* \c executed (actions appliquées au cycle), \c strategy (\c optimizer | \c rules | \c none),
|
||||||
|
* \c latencyMs (optimiseur seulement). Jamais le token : il ne voyage que dans le handshake,
|
||||||
|
* et l'écrivain masque en plus toute occurrence que la réponse brute d'un optimiseur porterait.
|
||||||
|
*
|
||||||
|
* \invariant Les traces restent sur le site (invariant 10 du moteur) : aucun envoi, export manuel.
|
||||||
|
* \invariant Rien ici ne commande : une trace activée ou non, en échec ou non, ne change aucune
|
||||||
|
* décision (preuve \c decisions traces activées).
|
||||||
|
*/
|
||||||
|
namespace OptimizerTrace {
|
||||||
|
|
||||||
|
//! Préfixe des fichiers : \c etm-powersync-optimizer-trace-AAAA-MM-JJ.ndjson.
|
||||||
|
constexpr const char *PrefixeFichier = "etm-powersync-optimizer-trace-";
|
||||||
|
//! Suffixe des fichiers.
|
||||||
|
constexpr const char *SuffixeFichier = ".ndjson";
|
||||||
|
|
||||||
|
//! Rétention par défaut (jours, fichier du jour compris) — annexe D.
|
||||||
|
constexpr int RetentionDefautJours = 14;
|
||||||
|
//! Rétention minimale acceptée (jours).
|
||||||
|
constexpr int RetentionMinJours = 1;
|
||||||
|
//! Rétention maximale acceptée (jours).
|
||||||
|
constexpr int RetentionMaxJours = 365;
|
||||||
|
//! Plafond par défaut d'un fichier journalier (octets). Mesuré (brief 4 T6, \c testTraceLineSize) :
|
||||||
|
//! ~5 Ko par ligne rule-based, ~39 Ko sous un optimiseur qui rend 48 créneaux × 4 actions ; à
|
||||||
|
//! 1 cycle/min (banc), 7,6 et 56 Mo par jour. 64 Mio tient la journée d'optimiseur avec ~15 % de
|
||||||
|
//! marge ; borne totale, à 14 jours : 896 Mio.
|
||||||
|
constexpr qint64 PlafondDefautOctets = 64ll * 1024 * 1024;
|
||||||
|
//! Plafond journalier minimal accepté (octets).
|
||||||
|
constexpr qint64 PlafondMinOctets = 1024ll * 1024;
|
||||||
|
//! Plafond journalier maximal accepté (octets).
|
||||||
|
constexpr qint64 PlafondMaxOctets = 1024ll * 1024 * 1024;
|
||||||
|
//! Lignes confiées à l'écrivain et pas encore écrites, au-delà desquelles une ligne est
|
||||||
|
//! abandonnée plutôt que de laisser la mémoire croître derrière un support qui n'écrit plus.
|
||||||
|
constexpr int FileMaxLignes = 256;
|
||||||
|
|
||||||
|
//! Code de \c response quand la requête n'a eu aucune réponse avant la suivante (remplacée).
|
||||||
|
constexpr const char *SansReponse = "no-response";
|
||||||
|
|
||||||
|
/*! \brief \return Le nom du fichier du jour \p jour (date LOCALE). \param jour Jour local. */
|
||||||
|
QString nomFichier(const QDate &jour);
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* \brief Lit la date d'un nom de fichier de trace.
|
||||||
|
* \param nom Nom de fichier, sans chemin. \return La date, invalide si \p nom n'en est pas un.
|
||||||
|
*/
|
||||||
|
QDate dateDuFichier(const QString &nom);
|
||||||
|
|
||||||
|
/*! \brief \param t Instant. \return \p t à l'heure LOCALE, décalage explicite (\c 2026-10-12T13:42:00+02:00). */
|
||||||
|
QString horodatage(const QDateTime &t);
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* \brief Le plan du rule-based comme RÉPONSE à la requête du cycle (format \c GetPlan §6) :
|
||||||
|
* \c strategy \c rules, \c computedAt, \c validUntil, créneaux et actions DÉCIDÉES
|
||||||
|
* (\c powerW, \c state, \c reasonCode, \c reasonParams — des codes, jamais de texte).
|
||||||
|
* \param plan Plan rendu par la stratégie. \param now Instant du cycle.
|
||||||
|
* \return \c {"result": {...}}.
|
||||||
|
*/
|
||||||
|
QVariantMap reponseRegles(const Plan &plan, const QDateTime &now);
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* \brief Les actions APPLIQUÉES au cycle : \c loadId, \c kind, \c powerW, \c state (SG-Ready),
|
||||||
|
* \c clamped, et \c shed quand L4 a délesté la charge.
|
||||||
|
* \param slot Créneau dispatché, délestage L4 fusionné. \param appliquees Retours
|
||||||
|
* d'\c applyAction(). \param delestees Charges revendiquées par L4.
|
||||||
|
* \return La liste, dans l'ordre du créneau.
|
||||||
|
*/
|
||||||
|
QVariantList executees(const Slot &slot, const QHash<QString, LoadAction> &appliquees,
|
||||||
|
const QSet<QString> &delestees);
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* \brief Remplace, dans une ligne déjà sérialisée, chaque occurrence du token par
|
||||||
|
* \c "[token masqué]".
|
||||||
|
* \param ligne Ligne JSON. \param token Le secret, brut ; vide = rien à faire.
|
||||||
|
* \param[out] masques Nombre de remplacements (peut être \c nullptr).
|
||||||
|
* \return La ligne masquée.
|
||||||
|
* \note Le token est cherché sous sa forme ÉCHAPPÉE JSON (\c " et \c \\ ) : c'est ainsi qu'il
|
||||||
|
* apparaîtrait dans la ligne.
|
||||||
|
*/
|
||||||
|
QByteArray masquerToken(const QByteArray &ligne, const QByteArray &token, int *masques = nullptr);
|
||||||
|
|
||||||
|
/*! \brief Réglage des traces (\c Optimizer/trace* dans \c energy.conf). */
|
||||||
|
struct Reglage {
|
||||||
|
bool active = false; //!< \c Optimizer/trace — défaut : désactivé.
|
||||||
|
int retentionJours = RetentionDefautJours; //!< \c Optimizer/traceRetentionDays.
|
||||||
|
qint64 plafondOctetsJour = PlafondDefautOctets; //!< \c Optimizer/traceMaxBytesPerDay.
|
||||||
|
QString dossier; //!< Répertoire ; vide = \c storagePath().
|
||||||
|
QString cheminToken; //!< Fichier du token, pour le masquage.
|
||||||
|
|
||||||
|
/*! \brief Lu dans \c energy.conf ; une valeur hors bornes reprend son défaut. \return Le réglage. */
|
||||||
|
static Reglage lire();
|
||||||
|
//! \return Vrai si les deux réglages produisent les mêmes fichiers. \param o Autre réglage.
|
||||||
|
bool operator==(const Reglage &o) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace OptimizerTrace
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* \brief L'écrivain des traces — vit sur son PROPRE fil : toutes les E/S disque sont ici.
|
||||||
|
*
|
||||||
|
* - **Rotation** : le fichier est choisi par la date LOCALE de la ligne (\c jour), donc par
|
||||||
|
* l'horloge du cycle — c'est elle qu'un test injecte.
|
||||||
|
* - **Purge** : à chaque changement de jour et à chaque réglage, les fichiers de plus de
|
||||||
|
* \c retentionJours jours (celui du jour compris) sont supprimés.
|
||||||
|
* - **Plafond** : un fichier qui atteindrait \c plafondOctetsJour n'est plus écrit jusqu'au
|
||||||
|
* lendemain ; dit UNE fois.
|
||||||
|
* - **Échec** (répertoire absent, en lecture seule, support plein) : dit UNE fois, puis plus
|
||||||
|
* rien jusqu'à la première écriture réussie, qui est dite aussi. Jamais d'exception, jamais
|
||||||
|
* d'attente du côté de l'arbitre.
|
||||||
|
*/
|
||||||
|
class OptimizerTraceWriter : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
/*! \brief \param enAttente Compteur partagé avec le traceur : lignes confiées, pas encore traitées. */
|
||||||
|
explicit OptimizerTraceWriter(std::shared_ptr<std::atomic<int>> enAttente);
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
/*! \brief Applique un réglage (dossier, rétention, plafond, token) et purge. \param r Réglage. */
|
||||||
|
void configurer(const OptimizerTrace::Reglage &r);
|
||||||
|
/*!
|
||||||
|
* \brief Sérialise et écrit une ligne dans le fichier de \p jour.
|
||||||
|
* \param ligne Contenu de la ligne. \param jour Jour LOCAL de la ligne.
|
||||||
|
*/
|
||||||
|
void ecrire(const QVariantMap &ligne, const QDate &jour);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void ouvrir(const QDate &jour);
|
||||||
|
void purger(const QDate &aujourdhui);
|
||||||
|
void echec(const QString &detail);
|
||||||
|
QByteArray token();
|
||||||
|
|
||||||
|
std::shared_ptr<std::atomic<int>> m_enAttente;
|
||||||
|
OptimizerTrace::Reglage m_reglage;
|
||||||
|
QFile m_fichier;
|
||||||
|
QDate m_jour; // jour du fichier ouvert (ou tenté)
|
||||||
|
qint64 m_taille = 0;
|
||||||
|
QDate m_plafondAtteint; // jour dont le plafond est atteint : plus rien ce jour-là
|
||||||
|
bool m_echecSignale = false;
|
||||||
|
bool m_masqueSignale = false;
|
||||||
|
QByteArray m_token;
|
||||||
|
QDateTime m_tokenLuA; // date de modification du fichier lu
|
||||||
|
qint64 m_tokenTaille = -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* \brief Le traceur, côté arbitre : une ligne par cycle, confiée à l'écrivain.
|
||||||
|
*
|
||||||
|
* N'existe que si \c Optimizer/trace est vrai : désactivé, l'arbitre ne construit ni cet objet
|
||||||
|
* ni son fil, et aucun fichier n'est créé.
|
||||||
|
*
|
||||||
|
* **Appariement requête → réponse.** La réponse à la requête du cycle N arrive APRÈS le cycle
|
||||||
|
* (elle sert au cycle N+1, modèle cache). La ligne N attend donc sa réponse : elle part à la
|
||||||
|
* réponse, ou au cycle suivant avec \c {"error": {"code": "no-response"}} si la requête a été
|
||||||
|
* remplacée sans réponse. Une ligne sans requête envoyée (pas d'endpoint, client non connecté)
|
||||||
|
* porte la réponse du rule-based et part tout de suite ; une ligne L2 aussi.
|
||||||
|
*
|
||||||
|
* \invariant Aucune E/S disque, aucune attente : \c noterCycle() et \c noterReponse() ne font
|
||||||
|
* que poster vers le fil de l'écrivain.
|
||||||
|
*/
|
||||||
|
class OptimizerTracer : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
/*! \brief Démarre le fil de l'écrivain. \param r Réglage. \param parent Propriétaire Qt. */
|
||||||
|
explicit OptimizerTracer(const OptimizerTrace::Reglage &r, QObject *parent = nullptr);
|
||||||
|
/*! \brief Termine la ligne en attente, vide la file, arrête le fil. */
|
||||||
|
~OptimizerTracer() override;
|
||||||
|
|
||||||
|
/*! \brief Change rétention, plafond ou dossier, à chaud. \param r Réglage. */
|
||||||
|
void configurer(const OptimizerTrace::Reglage &r);
|
||||||
|
/*! \brief \return Le réglage appliqué. */
|
||||||
|
const OptimizerTrace::Reglage ®lage() const { return m_reglage; }
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* \brief Ligne d'un cycle.
|
||||||
|
* \param ligne \c t, \c request, \c response (si connue), \c executed, \c strategy.
|
||||||
|
* \param t Instant du cycle (choisit le fichier).
|
||||||
|
* \param idRequete Identifiant de la requête envoyée ce cycle ; 0 = aucune (la ligne part).
|
||||||
|
*/
|
||||||
|
void noterCycle(const QVariantMap &ligne, const QDateTime &t, quint64 idRequete);
|
||||||
|
/*!
|
||||||
|
* \brief Réponse (ou échec) à une requête.
|
||||||
|
* \param id Identifiant de la requête. \param reponse \c {"result": …} ou \c {"error": {"code": …}}.
|
||||||
|
* \param latenceMs Délai envoi → réponse.
|
||||||
|
*/
|
||||||
|
void noterReponse(quint64 id, const QVariantMap &reponse, qint64 latenceMs);
|
||||||
|
|
||||||
|
/*! \brief \return Lignes abandonnées parce que la file de l'écrivain était pleine. */
|
||||||
|
int lignesAbandonnees() const { return m_abandonnees; }
|
||||||
|
|
||||||
|
signals:
|
||||||
|
//! \cond INTERNE
|
||||||
|
void versEcrivain(const QVariantMap &ligne, const QDate &jour);
|
||||||
|
void reglageVersEcrivain(const OptimizerTrace::Reglage &r);
|
||||||
|
//! \endcond
|
||||||
|
|
||||||
|
private:
|
||||||
|
void poster(const QVariantMap &ligne, const QDateTime &t);
|
||||||
|
void terminerEnAttente();
|
||||||
|
|
||||||
|
OptimizerTrace::Reglage m_reglage;
|
||||||
|
QThread m_fil;
|
||||||
|
OptimizerTraceWriter *m_ecrivain = nullptr; // vit sur m_fil, détruit à sa fin
|
||||||
|
std::shared_ptr<std::atomic<int>> m_enAttente;
|
||||||
|
QVariantMap m_ligneEnAttente;
|
||||||
|
QDateTime m_tEnAttente;
|
||||||
|
quint64 m_idEnAttente = 0;
|
||||||
|
int m_abandonnees = 0;
|
||||||
|
bool m_fileSignalee = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
Q_DECLARE_METATYPE(OptimizerTrace::Reglage)
|
||||||
@ -48,6 +48,8 @@ Plan SocketScheduler::getPlan(const SurplusContext &ctx)
|
|||||||
// L'OMBRE D'ABORD, et toujours : ses effets de bord (proxy VE, comptabilité éco, réserve)
|
// 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.
|
// sont dus à chaque cycle, quelle que soit la stratégie qui commandera.
|
||||||
const Plan ombre = m_ombre->getPlan(ctx);
|
const Plan ombre = m_ombre->getPlan(ctx);
|
||||||
|
m_idDuCycle = 0;
|
||||||
|
m_requeteDuCycle.clear();
|
||||||
envoyer(ctx);
|
envoyer(ctx);
|
||||||
|
|
||||||
if (!m_aCache) {
|
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
|
return; // le déclencheur attend la prochaine requête qui partira vraiment
|
||||||
OptimizerProtocol::ContexteFil fil = m_fil ? m_fil(ctx) : OptimizerProtocol::ContexteFil();
|
OptimizerProtocol::ContexteFil fil = m_fil ? m_fil(ctx) : OptimizerProtocol::ContexteFil();
|
||||||
fil.trigger = m_declencheur;
|
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)
|
if (id == 0)
|
||||||
return;
|
return;
|
||||||
m_idRequete = id;
|
m_idRequete = id;
|
||||||
m_ctxRequete = ctx;
|
m_ctxRequete = ctx;
|
||||||
|
m_chronoRequete.start();
|
||||||
|
m_idDuCycle = id;
|
||||||
|
m_requeteDuCycle = params;
|
||||||
m_declencheur = QStringLiteral("cycle");
|
m_declencheur = QStringLiteral("cycle");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,6 +135,7 @@ void SocketScheduler::onPlanRecu(quint64 id, const QVariantMap &result)
|
|||||||
{
|
{
|
||||||
if (id != m_idRequete)
|
if (id != m_idRequete)
|
||||||
return;
|
return;
|
||||||
|
emit reponseTracee(id, QVariantMap{{QStringLiteral("result"), result}}, m_chronoRequete.elapsed());
|
||||||
const OptimizerPlan::Verdict v = OptimizerPlan::valider(result, m_ctxRequete,
|
const OptimizerPlan::Verdict v = OptimizerPlan::valider(result, m_ctxRequete,
|
||||||
m_client->capabilities(),
|
m_client->capabilities(),
|
||||||
m_client->hasCapabilities());
|
m_client->hasCapabilities());
|
||||||
@ -194,6 +201,8 @@ void SocketScheduler::onPlanEchoue(quint64 id, const QString &code)
|
|||||||
return;
|
return;
|
||||||
// Le client a déjà journalisé la cause. Le plan en cache, s'il y en a un, reste valable
|
// 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).
|
// 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);
|
emit echecRequete(code);
|
||||||
noterVivacite(OptimizerStatus::Vivacite::Evenement::Echec, code);
|
noterVivacite(OptimizerStatus::Vivacite::Evenement::Echec, code);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <QElapsedTimer>
|
||||||
#include <QHash>
|
#include <QHash>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QSet>
|
#include <QSet>
|
||||||
@ -113,6 +114,12 @@ public:
|
|||||||
/*! \brief \return La vivacité de l'optimiseur (§7) : vivant, échecs, dernier code, dernier plan. */
|
/*! \brief \return La vivacité de l'optimiseur (§7) : vivant, échecs, dernier code, dernier plan. */
|
||||||
const OptimizerStatus::Vivacite &vivacite() const { return m_vivacite; }
|
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:
|
signals:
|
||||||
/*!
|
/*!
|
||||||
* \brief Une réponse \c GetPlan a été jugée — accroche du compteur d'échecs §7 (T4).
|
* \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). */
|
* (\c timeout, \c disconnected, \c rpc-error, \c invalid-response). */
|
||||||
void echecRequete(const QString &code);
|
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
|
/*! \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. */
|
* été substitué. \param code Motif du repli, vide si optimiseur. */
|
||||||
void strategieDuCycle(bool optimiseur, const QString &code);
|
void strategieDuCycle(bool optimiseur, const QString &code);
|
||||||
@ -152,6 +167,9 @@ private:
|
|||||||
QString m_declencheur = QStringLiteral("cycle");
|
QString m_declencheur = QStringLiteral("cycle");
|
||||||
quint64 m_idRequete = 0;
|
quint64 m_idRequete = 0;
|
||||||
SurplusContext m_ctxRequete; // contexte de la requête en vol : juge sa réponse
|
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;
|
bool m_aCache = false;
|
||||||
QVariantMap m_cache; // dernier `result` valide à sa réception, brut
|
QVariantMap m_cache; // dernier `result` valide à sa réception, brut
|
||||||
|
|||||||
@ -593,6 +593,34 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
|||||||
returns.insert("o:fault", enumValueName(String));
|
returns.insert("o:fault", enumValueName(String));
|
||||||
registerMethod("SetOptimizerToken", description, params, returns, Types::PermissionScopeAdmin);
|
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;
|
QVariantMap planAction;
|
||||||
planAction.insert("loadId", enumValueName(String));
|
planAction.insert("loadId", enumValueName(String));
|
||||||
planAction.insert("kind", enumValueName(String)); // setpoint | state
|
planAction.insert("kind", enumValueName(String)); // setpoint | state
|
||||||
@ -1636,6 +1664,33 @@ JsonReply *NymeaEnergyJsonHandler::SetOptimizerToken(const QVariantMap ¶ms)
|
|||||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
|
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JsonReply *NymeaEnergyJsonHandler::GetOptimizerTrace(const QVariantMap ¶ms)
|
||||||
|
{
|
||||||
|
Q_UNUSED(params)
|
||||||
|
#ifdef ETM_ARBITRATOR
|
||||||
|
if (EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager))
|
||||||
|
return createReply(arb->optimizerTrace());
|
||||||
|
#endif
|
||||||
|
return createReply({{"enabled", false}, {"retentionDays", 0}, {"maxBytesPerDay", 0}, {"directory", ""}});
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonReply *NymeaEnergyJsonHandler::SetOptimizerTrace(const QVariantMap ¶ms)
|
||||||
|
{
|
||||||
|
#ifdef ETM_ARBITRATOR
|
||||||
|
if (EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager)) {
|
||||||
|
QString fault;
|
||||||
|
if (arb->setOptimizerTrace(params.value("enabled"), params.value("retentionDays"),
|
||||||
|
params.value("maxBytesPerDay"), &fault))
|
||||||
|
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}});
|
||||||
|
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)},
|
||||||
|
{"fault", fault}});
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
Q_UNUSED(params)
|
||||||
|
#endif
|
||||||
|
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
|
||||||
|
}
|
||||||
|
|
||||||
JsonReply *NymeaEnergyJsonHandler::GetPlan(const QVariantMap ¶ms)
|
JsonReply *NymeaEnergyJsonHandler::GetPlan(const QVariantMap ¶ms)
|
||||||
{
|
{
|
||||||
Q_UNUSED(params)
|
Q_UNUSED(params)
|
||||||
|
|||||||
@ -152,6 +152,13 @@ public:
|
|||||||
//! \brief [ETM] Pose le token de l'optimiseur — ÉCRITURE SEULE (Q2). \param params \c token (vide = retrait).
|
//! \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.
|
//! \return \c energyError ; sur refus \c fault \c token-refused. Le token n'est jamais rendu.
|
||||||
Q_INVOKABLE JsonReply *SetOptimizerToken(const QVariantMap ¶ms);
|
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.
|
//! \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.
|
//! \return \c EnergyArbitrator::publishedPlan() ; \c {inForce: false} sans plan en vigueur.
|
||||||
Q_INVOKABLE JsonReply *GetPlan(const QVariantMap ¶ms);
|
Q_INVOKABLE JsonReply *GetPlan(const QVariantMap ¶ms);
|
||||||
|
|||||||
@ -42,7 +42,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
#include <QtTest>
|
#include <QtTest>
|
||||||
|
#include <QDir>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QRegularExpression>
|
#include <QRegularExpression>
|
||||||
@ -415,13 +417,54 @@ private:
|
|||||||
|
|
||||||
} // namespace
|
} // 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)
|
extern "C" void ENVEL_CYCLE(SmartChargingManager *self, const QDateTime &dateTime)
|
||||||
{
|
{
|
||||||
Enregistreur &e = Enregistreur::instance();
|
Enregistreur &e = Enregistreur::instance();
|
||||||
|
tracesDemandees(self, false);
|
||||||
++e.dansCycle;
|
++e.dansCycle;
|
||||||
REEL_CYCLE(self, dateTime);
|
REEL_CYCLE(self, dateTime);
|
||||||
--e.dansCycle;
|
--e.dansCycle;
|
||||||
e.noterCycle(self, dateTime);
|
e.noterCycle(self, dateTime);
|
||||||
|
tracesDemandees(self, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
extern "C" Plan ENVEL_PLAN(RuleBasedScheduler *self, const SurplusContext &ctx)
|
extern "C" Plan ENVEL_PLAN(RuleBasedScheduler *self, const SurplusContext &ctx)
|
||||||
|
|||||||
@ -6,6 +6,10 @@
|
|||||||
# run-decisions.sh <build> check enregistre puis compare à la référence
|
# run-decisions.sh <build> check enregistre puis compare à la référence
|
||||||
# (code 1 au premier écart, et il est montré)
|
# (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
|
# <build> : arbre construit depuis le .pro racine avec CONFIG+=build_tests (le binaire
|
||||||
# `nymea-energy-decisions` y est sous tests/auto/decisions/).
|
# `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.
|
# isolation, le résultat dépendrait de l'historique de la machine, pas du code.
|
||||||
lancer() { # $1 = fonction, $2 = fichier NDJSON du test
|
lancer() { # $1 = fonction, $2 = fichier NDJSON du test
|
||||||
rm -f "$2"
|
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 \
|
systemd-run --user --scope -q -p MemoryMax=2G -p MemorySwapMax=0 \
|
||||||
bwrap --dev-bind / / --unshare-net --tmpfs /tmp --bind "$BUILD" "$BUILD" --bind "$TRAVAIL" "$TRAVAIL" \
|
bwrap --dev-bind / / --unshare-net --tmpfs /tmp --bind "$BUILD" "$BUILD" --bind "$TRAVAIL" "$TRAVAIL" \
|
||||||
--chdir "$BIN_DIR" \
|
--chdir "$BIN_DIR" \
|
||||||
@ -83,6 +89,11 @@ done
|
|||||||
# écart massif — mais on le dit ici en clair.
|
# é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; }
|
[ "$total" -gt 0 ] || { echo "AUCUN cycle enregistré — l'enregistreur n'est pas branché" >&2; exit 2; }
|
||||||
echo "fonctions=${#FONCTIONS[@]} cycles=$total" >&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
|
[ "$MODE" = record ] && exit 0
|
||||||
|
|
||||||
|
|||||||
@ -15,15 +15,20 @@
|
|||||||
#include "etm/scheduler/socketscheduler.h"
|
#include "etm/scheduler/socketscheduler.h"
|
||||||
#include "etm/scheduler/optimizerstatus.h"
|
#include "etm/scheduler/optimizerstatus.h"
|
||||||
#include "etm/scheduler/publishedplan.h"
|
#include "etm/scheduler/publishedplan.h"
|
||||||
|
#include "etm/scheduler/optimizertrace.h"
|
||||||
#include "etm/types/decisionreason.h"
|
#include "etm/types/decisionreason.h"
|
||||||
|
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
|
#include <QJsonArray>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QLoggingCategory>
|
#include <QLoggingCategory>
|
||||||
#include <QSignalSpy>
|
#include <QSignalSpy>
|
||||||
#include <QTemporaryDir>
|
#include <QTemporaryDir>
|
||||||
|
|
||||||
#include <experiences/experiencemanager.h>
|
#include <experiences/experiencemanager.h>
|
||||||
|
#include <nymeasettings.h>
|
||||||
|
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
using namespace nymeaserver;
|
using namespace nymeaserver;
|
||||||
|
|
||||||
@ -117,6 +122,17 @@ private slots:
|
|||||||
void testGetPlanPublishesThePlanInForce();
|
void testGetPlanPublishesThePlanInForce();
|
||||||
void testGetPlanShowsTheAppliedPowerUnderALock();
|
void testGetPlanShowsTheAppliedPowerUnderALock();
|
||||||
void testUpdateLearnedIsLoggedOnceAndIgnored();
|
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:
|
private:
|
||||||
//! Banc commun ; \p avecOptimiseur faux = rien n'est configuré.
|
//! Banc commun ; \p avecOptimiseur faux = rien n'est configuré.
|
||||||
@ -153,6 +169,17 @@ private:
|
|||||||
void amenerAOptimiseur(const QDateTime &t0, int validiteS = 1800);
|
void amenerAOptimiseur(const QDateTime &t0, int validiteS = 1800);
|
||||||
//! Triplets publiés par les notifications \c OptimizerStatusChanged de l'arbitre.
|
//! Triplets publiés par les notifications \c OptimizerStatusChanged de l'arbitre.
|
||||||
static QStringList triplets(const QSignalSpy &spy);
|
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
|
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.
|
// fonction précédente du même processus a pu les poser.
|
||||||
EnergySettings().remove("Optimizer/endpoint");
|
EnergySettings().remove("Optimizer/endpoint");
|
||||||
QFile::remove(OptimizerClient::defaultTokenPath());
|
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";
|
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||||
initTestCase();
|
initTestCase();
|
||||||
m_arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
m_arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||||
@ -493,6 +523,7 @@ void TestSocketScheduler::testRefusedPlanFallsBackToRules()
|
|||||||
void TestSocketScheduler::testOutOfBoundsActionIsClamped()
|
void TestSocketScheduler::testOutOfBoundsActionIsClamped()
|
||||||
{
|
{
|
||||||
monter();
|
monter();
|
||||||
|
const QString d = activerTraces();
|
||||||
m_repondre = [this](const QVariantMap &req) {
|
m_repondre = [this](const QVariantMap &req) {
|
||||||
return plan(req, {action("ecs", {{"powerW", 2500}}), action(m_var, {{"powerW", 0}})});
|
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.
|
// Le même écrêtage au cycle suivant ne se répète pas en clair.
|
||||||
cycle(t0.addSecs(120), -3000);
|
cycle(t0.addSecs(120), -3000);
|
||||||
QCOMPARE(j.compte("ÉCRÊTÉE"), 1);
|
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()
|
void TestSocketScheduler::testUnavailableLoadKeepsTheShadowAction()
|
||||||
@ -1386,5 +1432,492 @@ void TestSocketScheduler::testUpdateLearnedIsLoggedOnceAndIgnored()
|
|||||||
QVERIFY(!req.contains("4321") && !req.contains("learned"));
|
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)
|
QTEST_MAIN(TestSocketScheduler)
|
||||||
#include "testsocketscheduler.moc"
|
#include "testsocketscheduler.moc"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user