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>
476 lines
22 KiB
C++
476 lines
22 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
|
|
|
/* Décisions de référence (brief 4 T0.5).
|
|
*
|
|
* Chaque cycle que les scénarios de `simulation` font tourner passe par ici (voir le `--wrap` de
|
|
* decisions.pro) : le VRAI cycle s'exécute, puis on sérialise une ligne NDJSON dans le fichier
|
|
* nommé par ETM_DECISIONS_OUT. Sans cette variable, rien n'est écrit et la suite tourne comme
|
|
* `simulation`.
|
|
*
|
|
* Ce qu'une ligne porte, et pourquoi c'est la décision :
|
|
* - `telemetry` : l'instantané que l'arbitre vient de bâtir (loadTelemetry()). Ses `loads[]`
|
|
* sont les actions du créneau, DANS L'ORDRE du créneau et après fusion du délestage :
|
|
* motif {code, params, level}, allocatedW (= estimatedPowerW), niveaux (targetW, funding,
|
|
* counts, motif, avancement), plus ce que l'adaptateur a réellement posé (mechanism, lock,
|
|
* command.expectedW) ; `budget` et `draw` du plan ; `degradedMode` ;
|
|
* - `proxy` : les ChargingAction que le plan a laissées au proxy amont (scheduledActions()) ;
|
|
* - `things` : l'état de chaque Thing après le cycle — c'est là que finit une consigne
|
|
* (courant, phases, relais, setpoint, état SG-Ready), qu'elle vienne du waterfall ou du proxy.
|
|
*
|
|
* Une partie des scénarios n'a pas d'arbitre : ils construisent un SurplusContext et
|
|
* interrogent un RuleBasedScheduler local. Ces appels-là sont interceptés aussi (second `--wrap`)
|
|
* et leur Plan est sérialisé EN ENTIER, champ par champ (kind, powerW, état, courant, phases,
|
|
* niveaux, avancement, force…). Un getPlan() fait pendant un cycle n'est pas réécrit : le cycle
|
|
* le décrit déjà.
|
|
*
|
|
* Ce qui est EXCLU, et seulement cela :
|
|
* - les horodatages muraux : `command.since` (ancré sur QDateTime::currentDateTime()) et les
|
|
* compteurs `totalEnergy*` des Things, que le plugin mock intègre sur l'horloge murale ;
|
|
* - toute identité tirée au hasard par exécution : les ThingId deviennent `@1`, `@2`… dans
|
|
* l'ordre de première apparition dans la télémétrie ou le plan de LA LIGNE ; `planId` (Plan
|
|
* direct) n'est pas sérialisé, et l'arbitre ne le publie pas.
|
|
* Deux exécutions du même code doivent donner le même fichier à l'octet près : c'est ainsi que
|
|
* cette liste a été établie (2026-10-16), et c'est ainsi qu'on vérifiera un ajout.
|
|
*
|
|
* L'horloge murale du processus est figée à son démarrage (ETM_DECISIONS_EPOCH, posé par le
|
|
* lanceur) : voir `clock_gettime` plus bas.
|
|
*
|
|
* Le contrôle ne doit pas pouvoir disparaître en silence (AGENTS.md) : un cycle sur un
|
|
* gestionnaire qui n'est pas un EnergyArbitrator s'écrit `"arbitre": false`, et le lanceur
|
|
* refuse un fichier où aucun cycle n'a été enregistré.
|
|
*/
|
|
|
|
#include <QtTest>
|
|
#include <QDir>
|
|
#include <QFile>
|
|
#include <QFileInfo>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QRegularExpression>
|
|
|
|
#include <nymeacore.h>
|
|
#include <integrations/thingmanager.h>
|
|
|
|
#include "../../../energyplugin/smartchargingmanager.h"
|
|
#include "../../../energyplugin/evcharger.h"
|
|
#include "../../../energyplugin/etm/energyarbitrator.h"
|
|
|
|
#include "../../../energyplugin/etm/scheduler/rulebasedscheduler.h"
|
|
|
|
#include <dlfcn.h>
|
|
#include <time.h>
|
|
|
|
/* L'HORLOGE MURALE FIGÉE AU DÉMARRAGE. Des scénarios bâtissent leurs dates sur
|
|
* QDateTime::currentDateTimeUtc() (testTheLearningLogIsFedByTheCycle : l'heure courante, plus
|
|
* trois jours) : sans ceci, la référence ne vaudrait que dans l'heure UTC où elle a été générée
|
|
* — constaté le 2026-10-16, une heure plus tard, sur un code inchangé.
|
|
*
|
|
* Avec ETM_DECISIONS_EPOCH=<secondes Unix>, CLOCK_REALTIME part de cet instant au premier appel
|
|
* du processus, puis avance au rythme réel. Les horloges monotones (QTimer, QElapsedTimer) ne
|
|
* sont pas touchées. L'exécutable définit `clock_gettime` : Qt et la libstdc++, qui l'appellent
|
|
* par la PLT, résolvent vers celle-ci avant celle de la libc. */
|
|
extern "C" int clock_gettime(clockid_t horloge, struct timespec *ts) noexcept
|
|
{
|
|
using Reelle = int (*)(clockid_t, struct timespec *);
|
|
static const Reelle reelle = reinterpret_cast<Reelle>(dlsym(RTLD_NEXT, "clock_gettime"));
|
|
const int r = reelle(horloge, ts);
|
|
if (r != 0 || (horloge != CLOCK_REALTIME && horloge != CLOCK_REALTIME_COARSE))
|
|
return r;
|
|
static const char *const epoque = getenv("ETM_DECISIONS_EPOCH");
|
|
if (!epoque)
|
|
return r;
|
|
// Initialisation d'une statique locale : une seule fois, au premier appel, et sûre entre fils.
|
|
static const long long decalage = atoll(epoque) - static_cast<long long>(ts->tv_sec);
|
|
ts->tv_sec += decalage;
|
|
return r;
|
|
}
|
|
|
|
#define REEL_CYCLE __real__ZN20SmartChargingManager20simulationCallUpdateERK9QDateTime
|
|
#define ENVEL_CYCLE __wrap__ZN20SmartChargingManager20simulationCallUpdateERK9QDateTime
|
|
#define REEL_PLAN __real__ZN18RuleBasedScheduler7getPlanERK14SurplusContext
|
|
#define ENVEL_PLAN __wrap__ZN18RuleBasedScheduler7getPlanERK14SurplusContext
|
|
|
|
// extern "C" ne change que le nom, pas la convention : `this` passe en premier argument, et le
|
|
// Plan rendu par valeur passe par le pointeur de retour caché, comme pour la fonction membre.
|
|
extern "C" void REEL_CYCLE(SmartChargingManager *self, const QDateTime &dateTime);
|
|
extern "C" Plan REEL_PLAN(RuleBasedScheduler *self, const SurplusContext &ctx);
|
|
|
|
namespace {
|
|
|
|
class Enregistreur
|
|
{
|
|
public:
|
|
static Enregistreur &instance() { static Enregistreur e; return e; }
|
|
|
|
//! Profondeur de cycle : un getPlan() appelé PENDANT un cycle appartient au cycle, et il est
|
|
//! déjà décrit par sa télémétrie. Seuls les appels directs des scénarios sont enregistrés
|
|
//! comme plans — ce qui rend l'enregistrement indépendant de la façon dont l'arbitre
|
|
//! atteint son planificateur (appel direct ou virtuel).
|
|
int dansCycle = 0;
|
|
|
|
void noterCycle(SmartChargingManager *scm, const QDateTime &dateTime)
|
|
{
|
|
if (!m_fichier.isOpen())
|
|
return;
|
|
QVariantMap ligne = entete(QStringLiteral("cycle"));
|
|
ligne.insert(QStringLiteral("t"), dateTime.toUTC().toString(Qt::ISODateWithMs));
|
|
|
|
// L'ORDRE de traitement fixe l'ordre des alias : les charges du créneau d'abord, dans
|
|
// l'ordre du créneau, puis le proxy, puis les Things.
|
|
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(scm);
|
|
ligne.insert(QStringLiteral("arbitre"), arb != nullptr);
|
|
if (arb) {
|
|
ligne.insert(QStringLiteral("telemetry"), anonymiser(sansMural(arb->loadTelemetry())));
|
|
ligne.insert(QStringLiteral("proxy"), trieParRendu(anonymiser(proxy(arb), false)));
|
|
}
|
|
ligne.insert(QStringLiteral("things"), trieParRendu(anonymiser(things(), false)));
|
|
ecrire(ligne);
|
|
}
|
|
|
|
void noterPlan(const SurplusContext &ctx, const Plan &plan)
|
|
{
|
|
if (!m_fichier.isOpen() || dansCycle > 0)
|
|
return;
|
|
QVariantMap ligne = entete(QStringLiteral("plan"));
|
|
ligne.insert(QStringLiteral("t"), ctx.timestamp.toUTC().toString(Qt::ISODateWithMs));
|
|
ligne.insert(QStringLiteral("plan"), anonymiser(versCarte(plan)));
|
|
ecrire(ligne);
|
|
}
|
|
|
|
private:
|
|
QVariantMap entete(const QString &source)
|
|
{
|
|
const QString fn = QString::fromLatin1(QTest::currentTestFunction());
|
|
const QString tag = QString::fromLatin1(QTest::currentDataTag());
|
|
if (fn != m_fn) {
|
|
m_fn = fn;
|
|
m_n = 0;
|
|
}
|
|
// Les alias sont propres à LA LIGNE : une charge y est nommée par son rang d'apparition
|
|
// dans le créneau. Une identité suivie d'un cycle à l'autre ne tient pas : deux bornes
|
|
// identiques reçoivent leur rang provisionné dans l'ordre d'un QHash, si bien que
|
|
// LAQUELLE charge change d'une exécution à l'autre, pour des décisions symétriques
|
|
// (constaté le 2026-10-16 sur testEvChargerRankDecidesWhoCharges).
|
|
m_alias.clear();
|
|
QVariantMap ligne;
|
|
ligne.insert(QStringLiteral("fn"), fn);
|
|
if (!tag.isEmpty())
|
|
ligne.insert(QStringLiteral("tag"), tag);
|
|
ligne.insert(QStringLiteral("n"), m_n++);
|
|
ligne.insert(QStringLiteral("src"), source);
|
|
return ligne;
|
|
}
|
|
|
|
void ecrire(const QVariantMap &ligne)
|
|
{
|
|
m_fichier.write(QJsonDocument::fromVariant(ligne).toJson(QJsonDocument::Compact));
|
|
m_fichier.write("\n");
|
|
m_fichier.flush();
|
|
}
|
|
|
|
static QString date(const QDateTime &d)
|
|
{
|
|
return d.isValid() ? d.toUTC().toString(Qt::ISODateWithMs) : QString();
|
|
}
|
|
|
|
static QVariantMap comptes(const QMap<QString, double> &c)
|
|
{
|
|
QVariantMap r;
|
|
for (auto it = c.constBegin(); it != c.constEnd(); ++it)
|
|
r.insert(it.key(), it.value());
|
|
return r;
|
|
}
|
|
|
|
//! Le Plan EN ENTIER, champ par champ — sauf `planId`, identité tirée au hasard.
|
|
static QVariantMap versCarte(const Plan &plan)
|
|
{
|
|
QVariantList creneaux;
|
|
for (const Slot &s : plan.timeSlots) {
|
|
QVariantList actions;
|
|
for (const LoadAction &a : s.actions) {
|
|
QVariantList niveaux;
|
|
for (const LoadAction::Level &n : a.levels) {
|
|
QVariantMap niveau{
|
|
{QStringLiteral("level"), n.level},
|
|
{QStringLiteral("targetW"), n.targetW},
|
|
{QStringLiteral("funding"), int(n.funding)},
|
|
{QStringLiteral("counts"), comptes(n.counts)},
|
|
{QStringLiteral("reason"), n.reason.toMap()},
|
|
{QStringLiteral("hasProgress"), n.hasProgress}};
|
|
if (n.hasProgress) {
|
|
const EcoProgress &p = n.progress;
|
|
QVariantMap progres{
|
|
{QStringLiteral("regime"), p.regime},
|
|
{QStringLiteral("delivered"), p.delivered},
|
|
{QStringLiteral("deliveredWh"), p.deliveredWh},
|
|
{QStringLiteral("targetWh"), p.targetWh},
|
|
{QStringLiteral("measuredSince"), date(p.measuredSince)},
|
|
{QStringLiteral("measuredGaps"), p.measuredGaps},
|
|
{QStringLiteral("closedMissed"), p.closedMissed},
|
|
{QStringLiteral("closedMissingWh"), p.closedMissingWh},
|
|
{QStringLiteral("closedKnown"), p.closedKnown},
|
|
{QStringLiteral("closedCause"), p.closedCause}};
|
|
// Comme la RPC : l'acheté n'existe que mesuré (correctif c). Absent sinon.
|
|
if (p.purchased)
|
|
progres.insert(QStringLiteral("purchasedWh"), p.purchasedWh);
|
|
niveau.insert(QStringLiteral("progress"), progres);
|
|
}
|
|
niveaux.append(niveau);
|
|
}
|
|
actions.append(QVariantMap{
|
|
{QStringLiteral("loadId"), a.loadId},
|
|
{QStringLiteral("kind"), int(a.kind)},
|
|
{QStringLiteral("funding"), int(a.funding)},
|
|
{QStringLiteral("chargingEnabled"), a.chargingEnabled},
|
|
{QStringLiteral("currentA"), a.currentA},
|
|
{QStringLiteral("phaseCount"), a.phaseCount},
|
|
{QStringLiteral("powerW"), a.powerW},
|
|
{QStringLiteral("source"), int(a.source)},
|
|
{QStringLiteral("state"), a.state},
|
|
{QStringLiteral("charge"), int(a.charge)},
|
|
{QStringLiteral("discharge"), int(a.discharge)},
|
|
{QStringLiteral("reason"), a.reason.toMap()},
|
|
{QStringLiteral("estimatedPowerW"), a.estimatedPowerW},
|
|
{QStringLiteral("counts"), comptes(a.counts)},
|
|
{QStringLiteral("levels"), niveaux},
|
|
{QStringLiteral("force"), a.force}});
|
|
}
|
|
creneaux.append(QVariantMap{{QStringLiteral("from"), date(s.from)},
|
|
{QStringLiteral("to"), date(s.to)},
|
|
{QStringLiteral("actions"), actions}});
|
|
}
|
|
QStringList bornes(plan.waterfallEvIds.constBegin(), plan.waterfallEvIds.constEnd());
|
|
bornes.sort();
|
|
return QVariantMap{
|
|
{QStringLiteral("strategy"), plan.strategy},
|
|
{QStringLiteral("timeSlots"), creneaux},
|
|
{QStringLiteral("budget"), QVariantMap{
|
|
{QStringLiteral("surplusW"), plan.budget.surplusW},
|
|
{QStringLiteral("evReservedW"), plan.budget.evReservedW},
|
|
{QStringLiteral("allocatedW"), plan.budget.allocatedW},
|
|
{QStringLiteral("recreditedW"), plan.budget.recreditedW},
|
|
{QStringLiteral("remainingW"), plan.budget.remainingW}}},
|
|
{QStringLiteral("draw"), QVariantMap{
|
|
{QStringLiteral("committedW"), plan.draw.committedW},
|
|
{QStringLiteral("capBorne"), plan.draw.capBorne},
|
|
{QStringLiteral("authorisedW"), plan.draw.authorisedW},
|
|
{QStringLiteral("remainingW"), plan.draw.remainingW},
|
|
{QStringLiteral("bindingCode"), plan.draw.bindingCode},
|
|
{QStringLiteral("bindingPhase"), plan.draw.bindingPhase},
|
|
{QStringLiteral("perPhaseBound"), plan.draw.perPhaseBound}}},
|
|
{QStringLiteral("waterfallEvIds"), bornes}};
|
|
}
|
|
|
|
private:
|
|
Enregistreur()
|
|
{
|
|
const QString chemin = QString::fromLocal8Bit(qgetenv("ETM_DECISIONS_OUT"));
|
|
if (chemin.isEmpty())
|
|
return;
|
|
m_fichier.setFileName(chemin);
|
|
if (!m_fichier.open(QIODevice::WriteOnly | QIODevice::Append))
|
|
qFatal("decisionrecorder : impossible d'ouvrir %s", qPrintable(chemin));
|
|
}
|
|
|
|
//! Retire les horodatages muraux. La liste est FERMÉE : un champ mural nouveau fera
|
|
//! diverger deux exécutions du même code, et c'est le lanceur (`stability`) qui le dira.
|
|
static QVariant sansMural(const QVariant &v)
|
|
{
|
|
if (v.typeId() == QMetaType::QVariantMap) {
|
|
QVariantMap m = v.toMap();
|
|
if (m.contains(QStringLiteral("command"))) {
|
|
QVariantMap c = m.value(QStringLiteral("command")).toMap();
|
|
c.remove(QStringLiteral("since"));
|
|
m.insert(QStringLiteral("command"), c);
|
|
}
|
|
for (auto it = m.begin(); it != m.end(); ++it)
|
|
it.value() = sansMural(it.value());
|
|
return m;
|
|
}
|
|
if (v.typeId() == QMetaType::QVariantList) {
|
|
QVariantList l = v.toList();
|
|
for (QVariant &e : l)
|
|
e = sansMural(e);
|
|
return l;
|
|
}
|
|
return v;
|
|
}
|
|
|
|
//! N'ATTRIBUENT un alias que la télémétrie et le plan, dont l'ordre (celui du créneau) est
|
|
//! déterministe. Le proxy et les Things sont rangés par QHash : deux bornes homonymes y
|
|
//! apparaissent dans un ordre tiré au hasard, et un alias attribué là changerait d'une
|
|
//! exécution à l'autre. Un ThingId qu'ils sont seuls à citer s'écrit donc `@?`.
|
|
QString alias(const QString &s, bool attribuer)
|
|
{
|
|
static const QRegularExpression uuid(
|
|
QStringLiteral("\\{?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\\}?"));
|
|
QString r;
|
|
qsizetype dernier = 0;
|
|
auto it = uuid.globalMatch(s);
|
|
while (it.hasNext()) {
|
|
const QRegularExpressionMatch m = it.next();
|
|
QString cle = m.captured(0).toLower();
|
|
cle.remove(QLatin1Char('{')).remove(QLatin1Char('}'));
|
|
if (attribuer && !m_alias.contains(cle))
|
|
m_alias.insert(cle, QStringLiteral("@%1").arg(m_alias.size() + 1));
|
|
r += s.mid(dernier, m.capturedStart(0) - dernier)
|
|
+ m_alias.value(cle, QStringLiteral("@?"));
|
|
dernier = m.capturedEnd(0);
|
|
}
|
|
return dernier == 0 ? s : r + s.mid(dernier);
|
|
}
|
|
|
|
QVariant anonymiser(const QVariant &v, bool attribuer = true)
|
|
{
|
|
switch (v.typeId()) {
|
|
case QMetaType::QVariantMap: {
|
|
// Valeurs d'abord, dans l'ordre des clés d'origine — puis les clés elles-mêmes.
|
|
const QVariantMap m = v.toMap();
|
|
QList<QPair<QString, QVariant>> paires;
|
|
for (auto it = m.constBegin(); it != m.constEnd(); ++it)
|
|
paires.append({it.key(), anonymiser(it.value(), attribuer)});
|
|
QVariantMap r;
|
|
for (const auto &p : paires)
|
|
r.insert(alias(p.first, attribuer), p.second);
|
|
return r;
|
|
}
|
|
case QMetaType::QVariantList: {
|
|
QVariantList l = v.toList();
|
|
for (QVariant &e : l)
|
|
e = anonymiser(e, attribuer);
|
|
return l;
|
|
}
|
|
case QMetaType::QString:
|
|
case QMetaType::QUuid:
|
|
case QMetaType::QByteArray:
|
|
return alias(v.toString(), attribuer);
|
|
default:
|
|
return v;
|
|
}
|
|
}
|
|
|
|
//! Range une liste par son rendu JSON, APRÈS anonymisation : l'ordre d'un QHash ne doit
|
|
//! rien décider.
|
|
static QVariant trieParRendu(const QVariant &v)
|
|
{
|
|
QList<QPair<QByteArray, QVariant>> liste;
|
|
for (const QVariant &e : v.toList())
|
|
liste.append({QJsonDocument::fromVariant(e).toJson(QJsonDocument::Compact), e});
|
|
std::sort(liste.begin(), liste.end(),
|
|
[](const auto &a, const auto &b) { return a.first < b.first; });
|
|
QVariantList r;
|
|
for (const auto &p : liste)
|
|
r.append(p.second);
|
|
return r;
|
|
}
|
|
|
|
static QVariant proxy(EnergyArbitrator *arb)
|
|
{
|
|
QVariantList bornes;
|
|
const auto &actions = arb->scheduledActions();
|
|
for (auto it = actions.constBegin(); it != actions.constEnd(); ++it) {
|
|
QVariantMap parEmetteur;
|
|
for (auto a = it.value().constBegin(); a != it.value().constEnd(); ++a) {
|
|
parEmetteur.insert(ChargingAction::issuerToString(a.key()), QVariantMap{
|
|
{QStringLiteral("enabled"), QVariant(a.value().chargingEnabled())},
|
|
{QStringLiteral("currentA"), QVariant(a.value().maxChargingCurrent())},
|
|
{QStringLiteral("phases"), QVariant(a.value().desiredPhaseCount())},
|
|
{QStringLiteral("force"), QVariant(a.value().force())}});
|
|
}
|
|
Thing *t = it.key()->thing();
|
|
QVariantMap borne{{QStringLiteral("thing"), t ? t->id().toString() : QString()},
|
|
{QStringLiteral("actions"), parEmetteur}};
|
|
borne.insert(QStringLiteral("name"), t ? t->name() : QString());
|
|
bornes.append(borne);
|
|
}
|
|
return bornes;
|
|
}
|
|
|
|
static QVariant things()
|
|
{
|
|
// Les compteurs d'énergie CUMULÉS sont exclus : le plugin mock les intègre sur l'horloge
|
|
// MURALE (mesuré le 2026-10-16 : `totalEnergyConsumed` diffère entre deux exécutions du
|
|
// même code). Ce sont des entrées de l'arbitre, pas des décisions ; ce qu'il en tire
|
|
// (avancement éco) est publié par la télémétrie, qui reste comparée.
|
|
QVariantList liste;
|
|
for (Thing *t : nymeaserver::NymeaCore::instance()->thingManager()->configuredThings()) {
|
|
QVariantMap etats;
|
|
for (const State &s : t->states()) {
|
|
const StateType st = t->thingClass().stateTypes().findById(s.stateTypeId());
|
|
if (st.name().startsWith(QLatin1String("totalEnergy")))
|
|
continue;
|
|
etats.insert(st.name(), s.value());
|
|
}
|
|
liste.append(QVariantMap{{QStringLiteral("class"), t->thingClass().name()},
|
|
{QStringLiteral("name"), t->name()},
|
|
{QStringLiteral("states"), etats}});
|
|
}
|
|
return liste;
|
|
}
|
|
|
|
QFile m_fichier;
|
|
QString m_fn;
|
|
int m_n = 0;
|
|
QHash<QString, QString> m_alias;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
/* Brief 4 T6 — la preuve « traces ACTIVÉES » : avec ETM_DECISIONS_TRACE=<fichier>, les traces de
|
|
* décision de l'arbitre sont activées AVANT chaque cycle (un scénario peut recréer l'arbitre), et
|
|
* <fichier> reçoit après le cycle « <lignes> <octets> » des traces présentes sur disque — la
|
|
* preuve qu'elles ont bien été écrites (l'écrivain est asynchrone : le compte suit avec un cycle
|
|
* de retard au plus). La ligne de décision n'en dit rien : elle doit rester IDENTIQUE. */
|
|
static void tracesDemandees(SmartChargingManager *self, bool apres)
|
|
{
|
|
static const QString compte = QString::fromLocal8Bit(qgetenv("ETM_DECISIONS_TRACE"));
|
|
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(self);
|
|
if (compte.isEmpty() || !arb)
|
|
return;
|
|
if (!apres) {
|
|
if (!arb->optimizerTracer())
|
|
arb->setOptimizerTrace(true, QVariant(), QVariant());
|
|
return;
|
|
}
|
|
// Compte INCRÉMENTAL : seuls les octets apparus depuis le cycle précédent sont lus (un
|
|
// scénario fait ~20 000 cycles ; tout relire à chaque cycle serait quadratique).
|
|
static QHash<QString, qint64> lus;
|
|
static qint64 lignes = 0, octets = 0;
|
|
const QDir dossier(arb->optimizerTrace().value(QStringLiteral("directory")).toString());
|
|
for (const QFileInfo &fi : dossier.entryInfoList({QStringLiteral("etm-powersync-optimizer-trace-*.ndjson")},
|
|
QDir::Files)) {
|
|
qint64 &deja = lus[fi.filePath()];
|
|
if (fi.size() <= deja)
|
|
continue;
|
|
QFile f(fi.filePath());
|
|
if (!f.open(QIODevice::ReadOnly) || !f.seek(deja))
|
|
continue;
|
|
const QByteArray b = f.readAll();
|
|
lignes += b.count('\n');
|
|
octets += b.size();
|
|
deja += b.size();
|
|
}
|
|
QFile sortie(compte);
|
|
if (sortie.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
|
sortie.write(QByteArray::number(lignes) + ' ' + QByteArray::number(octets) + '\n');
|
|
}
|
|
|
|
extern "C" void ENVEL_CYCLE(SmartChargingManager *self, const QDateTime &dateTime)
|
|
{
|
|
Enregistreur &e = Enregistreur::instance();
|
|
tracesDemandees(self, false);
|
|
++e.dansCycle;
|
|
REEL_CYCLE(self, dateTime);
|
|
--e.dansCycle;
|
|
e.noterCycle(self, dateTime);
|
|
tracesDemandees(self, true);
|
|
}
|
|
|
|
extern "C" Plan ENVEL_PLAN(RuleBasedScheduler *self, const SurplusContext &ctx)
|
|
{
|
|
Plan plan = REEL_PLAN(self, ctx);
|
|
Enregistreur::instance().noterPlan(ctx, plan);
|
|
return plan;
|
|
}
|