// 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 #include #include #include #include #include #include #include "../../../energyplugin/smartchargingmanager.h" #include "../../../energyplugin/evcharger.h" #include "../../../energyplugin/etm/energyarbitrator.h" #include "../../../energyplugin/etm/scheduler/rulebasedscheduler.h" #include #include /* 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=, 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(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(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(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 &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> 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> 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 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; }