Patrick Schurig e5c649e69a test(décisions): horloge murale figée au démarrage de chaque processus (brief 4 T0.5)
Constaté le 2026-10-16 : sur un code inchangé, la vérification échouait une heure après la
génération de la référence. testTheLearningLogIsFedByTheCycle date ses entrées sur
QDateTime::currentDateTimeUtc() (l'heure courante plus trois jours) : la référence ne valait
que dans l'heure UTC où elle avait été produite.

L'enregistreur définit clock_gettime : avec ETM_DECISIONS_EPOCH, CLOCK_REALTIME part de cet
instant au premier appel puis avance au rythme réel ; les horloges monotones (QTimer,
QElapsedTimer) ne sont pas touchées. Le lanceur pose 2026-10-16T05:00:00Z, l'heure où la
référence a été générée : la référence commitée n'est pas régénérée, elle est reproduite à
l'octet (md5 ea2c1db7…, 23 739 lignes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:18:09 +02:00

430 lines
20 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 <QFile>
#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;
niveau.insert(QStringLiteral("progress"), QVariantMap{
{QStringLiteral("regime"), p.regime},
{QStringLiteral("delivered"), p.delivered},
{QStringLiteral("deliveredWh"), p.deliveredWh},
{QStringLiteral("targetWh"), p.targetWh},
{QStringLiteral("purchasedWh"), p.purchasedWh},
{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}});
}
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
extern "C" void ENVEL_CYCLE(SmartChargingManager *self, const QDateTime &dateTime)
{
Enregistreur &e = Enregistreur::instance();
++e.dansCycle;
REEL_CYCLE(self, dateTime);
--e.dansCycle;
e.noterCycle(self, dateTime);
}
extern "C" Plan ENVEL_PLAN(RuleBasedScheduler *self, const SurplusContext &ctx)
{
Plan plan = REEL_PLAN(self, ctx);
Enregistreur::instance().noterPlan(ctx, plan);
return plan;
}