test(décisions): référence au bit près des scénarios de simulation (brief 4 T0.5)
Preuve « sans endpoint = identique au bit près », posée AVANT tout refactoring. `nymea-energy-decisions` est la suite `simulation` compilée à l'identique, plus un enregistreur branché à l'édition de liens (`--wrap`) : aucune ligne de la suite ni du moteur n'est modifiée. Chaque cycle (`simulationCallUpdate`) écrit une ligne NDJSON : télémétrie de l'arbitre (actions du créneau dans l'ordre, motifs, niveaux, budget, soutirage, consigne posée), actions laissées au proxy, état des Things. Les scénarios qui interrogent un `RuleBasedScheduler` local voient leur `Plan` sérialisé champ par champ. Exclus : `command.since` et les compteurs `totalEnergy*` du mock (horloge murale), les ThingId (alias par rang dans le créneau, à chaque ligne : deux bornes identiques reçoivent leur rang provisionné dans l'ordre d'un QHash). `planId` n'est pas lu. Référence générée sur 83483cc (1.15.2+etm69) : 66 fonctions, 23 673 cycles + 36 plans directs, deux exécutions identiques à l'octet. Chaque processus a un /tmp privé : libnymea range l'état de test sous /tmp/nymea-test, partagé et cumulatif (348 bornes orphelines trouvées dans energy-load-configuration.json). tests/auto/decisions/run-decisions.sh <build> check Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
83483ccf71
commit
d4b7d4dced
@ -1,2 +1,2 @@
|
||||
TEMPLATE = subdirs
|
||||
SUBDIRS += charging loadmodel simulation spotmarket
|
||||
SUBDIRS += charging decisions loadmodel simulation spotmarket
|
||||
|
||||
398
tests/auto/decisions/decisionrecorder.cpp
Normal file
398
tests/auto/decisions/decisionrecorder.cpp
Normal file
@ -0,0 +1,398 @@
|
||||
// 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.
|
||||
*
|
||||
* 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"
|
||||
|
||||
#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;
|
||||
}
|
||||
50
tests/auto/decisions/decisions.pro
Normal file
50
tests/auto/decisions/decisions.pro
Normal file
@ -0,0 +1,50 @@
|
||||
# Décisions de référence (brief 4 T0.5) — la preuve « sans endpoint = identique au bit près ».
|
||||
#
|
||||
# Ce binaire est la suite `simulation`, compilée À L'IDENTIQUE (mêmes sources, même
|
||||
# ENERGY_SIMULATION), plus un enregistreur. Aucune ligne de la suite n'est modifiée : le point
|
||||
# d'écoute est posé à l'ÉDITION DE LIENS. `--wrap` redirige chaque appel de
|
||||
# SmartChargingManager::simulationCallUpdate() fait par les scénarios vers
|
||||
# decisionrecorder.cpp, qui appelle le vrai cycle puis sérialise ce qu'il a décidé.
|
||||
#
|
||||
# Lancer : tests/auto/decisions/run-decisions.sh (voir son en-tête).
|
||||
|
||||
include(../common/common.pri)
|
||||
include(../../../energyplugin/energyplugin.pri)
|
||||
include(../../mocks/spotmarketprovider/spotmarketdataprovidermock.pri)
|
||||
|
||||
CONFIG += testcase
|
||||
|
||||
TARGET = nymea-energy-decisions
|
||||
|
||||
DEFINES += ENERGY_SIMULATION
|
||||
|
||||
SIM = $$PWD/../simulation
|
||||
INCLUDEPATH += $$SIM
|
||||
|
||||
RESOURCES += \
|
||||
$$SIM/simulationresources.qrc
|
||||
|
||||
HEADERS += \
|
||||
$$SIM/experience/energyexperienceenergymock.h \
|
||||
$$SIM/experience/energyjsonhandler.h \
|
||||
$$SIM/experience/energylogger.h \
|
||||
$$SIM/experience/energymanagermock.h \
|
||||
$$SIM/simulation.h \
|
||||
$$SIM/simulationbase.h \
|
||||
$$SIM/simulationtestpoint.h
|
||||
|
||||
SOURCES += \
|
||||
$$SIM/experience/energyexperienceenergymock.cpp \
|
||||
$$SIM/experience/energyjsonhandler.cpp \
|
||||
$$SIM/experience/energylogger.cpp \
|
||||
$$SIM/experience/energymanagermock.cpp \
|
||||
$$SIM/simulation.cpp \
|
||||
$$SIM/simulationbase.cpp \
|
||||
$$SIM/simulationtestpoint.cpp \
|
||||
decisionrecorder.cpp
|
||||
|
||||
# Noms mangés Itanium : SmartChargingManager::simulationCallUpdate(const QDateTime &) (chaque
|
||||
# cycle des scénarios) et RuleBasedScheduler::getPlan(const SurplusContext &) (les scénarios qui
|
||||
# interrogent le planificateur directement, sans arbitre).
|
||||
QMAKE_LFLAGS += -Wl,--wrap=_ZN20SmartChargingManager20simulationCallUpdateERK9QDateTime
|
||||
QMAKE_LFLAGS += -Wl,--wrap=_ZN18RuleBasedScheduler7getPlanERK14SurplusContext
|
||||
BIN
tests/auto/decisions/reference/decisions.ndjson.xz
Normal file
BIN
tests/auto/decisions/reference/decisions.ndjson.xz
Normal file
Binary file not shown.
90
tests/auto/decisions/run-decisions.sh
Executable file
90
tests/auto/decisions/run-decisions.sh
Executable file
@ -0,0 +1,90 @@
|
||||
#!/bin/bash
|
||||
# Décisions de référence (brief 4 T0.5) — rejoue les scénarios de `simulation` et compare, cycle
|
||||
# par cycle, ce que l'arbitre a décidé au fichier de référence commité.
|
||||
#
|
||||
# run-decisions.sh <build> record <sortie.ndjson> enregistre seulement
|
||||
# run-decisions.sh <build> check enregistre puis compare à la référence
|
||||
# (code 1 au premier écart, et il est montré)
|
||||
#
|
||||
# <build> : arbre construit depuis le .pro racine avec CONFIG+=build_tests (le binaire
|
||||
# `nymea-energy-decisions` y est sous tests/auto/decisions/).
|
||||
#
|
||||
# AGENTS.md : UN PROCESSUS PAR TEST, chacun sous MemoryMax=2G. Les tests ne tournent jamais en
|
||||
# parallèle (le harnais nymea ouvre des ports fixes). Un test qui sort en erreur est rejoué UNE
|
||||
# fois — instabilité connue du port 4847 — et le rejeu est annoncé, jamais masqué.
|
||||
set -u
|
||||
|
||||
ICI="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REFERENCE="$ICI/reference/decisions.ndjson.xz"
|
||||
|
||||
BUILD="$(cd "${1:?build}" && pwd)"
|
||||
MODE="${2:?record|check}"
|
||||
BIN_DIR="$BUILD/tests/auto/decisions"
|
||||
BIN="$BIN_DIR/nymea-energy-decisions"
|
||||
[ -x "$BIN" ] || { echo "binaire absent : $BIN" >&2; exit 2; }
|
||||
|
||||
case "$MODE" in
|
||||
record) SORTIE="${3:?sortie}" ;;
|
||||
check) SORTIE="$(mktemp --suffix=.ndjson)" ;;
|
||||
*) echo "mode inconnu : $MODE" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
TRAVAIL="$(mktemp -d)"
|
||||
trap 'rm -rf "$TRAVAIL"' EXIT
|
||||
|
||||
export NYMEA_PLUGINS_PATH="$BUILD/tests/mocks/plugins/energymocks:/usr/lib/x86_64-linux-gnu/nymea/plugins"
|
||||
|
||||
# UN /tmp PRIVÉ ET VIDE PAR PROCESSUS. libnymea range l'état du serveur de test sous
|
||||
# `/tmp/nymea-test`, en dur : configuration des charges, energy.conf, magasin de prévision. Ce
|
||||
# répertoire est partagé par toutes les exécutions de la machine et il s'accumule — mesuré le
|
||||
# 2026-10-16, un energy-load-configuration.json de 348 bornes orphelines laissé là le 14. Sans
|
||||
# isolation, le résultat dépendrait de l'historique de la machine, pas du code.
|
||||
lancer() { # $1 = fonction, $2 = fichier NDJSON du test
|
||||
rm -f "$2"
|
||||
( cd "$BIN_DIR" && ETM_DECISIONS_OUT="$2" \
|
||||
systemd-run --user --scope -q -p MemoryMax=2G -p MemorySwapMax=0 \
|
||||
bwrap --dev-bind / / --tmpfs /tmp --bind "$BUILD" "$BUILD" --bind "$TRAVAIL" "$TRAVAIL" \
|
||||
--chdir "$BIN_DIR" \
|
||||
./target_wrapper.sh ./nymea-energy-decisions "$1" > "$TRAVAIL/$1.log" 2>&1 )
|
||||
}
|
||||
|
||||
mapfile -t FONCTIONS < <(cd "$BIN_DIR" && ./nymea-energy-decisions -functions 2>/dev/null \
|
||||
| sed -n 's/^\([A-Za-z_][A-Za-z0-9_]*\)()$/\1/p')
|
||||
[ "${#FONCTIONS[@]}" -gt 0 ] || { echo "aucune fonction listée" >&2; exit 2; }
|
||||
|
||||
: > "$SORTIE"
|
||||
total=0
|
||||
for f in "${FONCTIONS[@]}"; do
|
||||
out="$TRAVAIL/$f.ndjson"
|
||||
lancer "$f" "$out"; code=$?
|
||||
if [ "$code" -ne 0 ]; then
|
||||
echo "REJEU $f (code $code) — $(grep -m1 -E 'FAIL!' "$TRAVAIL/$f.log" | cut -c1-160)" >&2
|
||||
lancer "$f" "$out"; code=$?
|
||||
fi
|
||||
n=0; [ -f "$out" ] && { n=$(wc -l < "$out"); cat "$out" >> "$SORTIE"; }
|
||||
total=$((total + n))
|
||||
resume=$(grep -m1 '^Totals:' "$TRAVAIL/$f.log")
|
||||
printf '{"fn":"%s","fin":true,"exit":%d,"cycles":%d}\n' "$f" "$code" "$n" >> "$SORTIE"
|
||||
printf '%-48s exit=%d cycles=%-6d %s\n' "$f" "$code" "$n" "$resume" >&2
|
||||
done
|
||||
|
||||
# Le contrôle ne doit pas pouvoir disparaître : un enregistreur débranché (wrap perdu, binaire
|
||||
# reconstruit sans lui) produirait un fichier sans cycle, que la comparaison verrait comme un
|
||||
# écart massif — mais on le dit ici en clair.
|
||||
[ "$total" -gt 0 ] || { echo "AUCUN cycle enregistré — l'enregistreur n'est pas branché" >&2; exit 2; }
|
||||
echo "fonctions=${#FONCTIONS[@]} cycles=$total" >&2
|
||||
|
||||
[ "$MODE" = record ] && exit 0
|
||||
|
||||
ATTENDU="$TRAVAIL/attendu.ndjson"
|
||||
xz -dc "$REFERENCE" > "$ATTENDU" || exit 2
|
||||
if cmp -s "$ATTENDU" "$SORTIE"; then
|
||||
echo "IDENTIQUE à la référence ($(wc -l < "$ATTENDU") lignes)" >&2
|
||||
rm -f "$SORTIE"
|
||||
exit 0
|
||||
fi
|
||||
echo "ÉCART avec la référence — premières lignes divergentes (- attendu, + obtenu) :" >&2
|
||||
diff "$ATTENDU" "$SORTIE" | head -c 6000 >&2
|
||||
echo >&2
|
||||
echo "fichier obtenu conservé : $SORTIE" >&2
|
||||
exit 1
|
||||
Loading…
x
Reference in New Issue
Block a user