Patrick Schurig 61db6cfd66 test(conformance): mode attendu plan | abstain, un optimiseur qui s'abstient est jugé conforme
S'abstenir est une réponse permise (§7). Avec ETM_POWERSYNC_CONFORMANCE_EXPECT=abstain,
testEveryPlanIsAccepted exige une abstention à chaque cycle (rules, fallbackCode abstain,
verdict abstention/abstain, aucun échec, optimizerAlive inchangé) ; testTheSafetyLoopStaysOnTop
vérifie L4 sous le rule-based (shed exigé) puis se termine en QSKIP, rien à écrêter. Mode `plan`
(défaut) inchangé. Le mode retenu est affiché en tête de chaque fonction.

Pas de déduction depuis le handshake : `planning` est informatif (multi-créneaux ou non) et
`kinds` absent veut dire « tout admis » ; la référence en mode abstain annonce des kinds, le mode
protocol-1.0 (planning false, sans capabilities) planifie. README.

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

715 lines
32 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
//
// Lot « contrat OpenRPC » — suite de CONFORMITÉ d'un optimiseur quelconque.
//
// L'optimiseur à éprouver est lancé par la commande de $ETM_POWERSYNC_OPTIMIZER_CMD (défaut :
// l'optimiseur de référence du dépôt, mode `normal`), et le VRAI moteur s'y branche : serveur
// nymea, arbitre, `SocketScheduler`, `OptimizerClient`, adaptateurs, mocks de `simulation`. Rien
// n'est simulé côté moteur ; c'est son client, son validateur et sa boucle de sécurité qui jugent.
// Mode d'emploi : README.md à côté.
//
// Ce qui est vérifié, une fonction par point (un processus par fonction) :
// 1. handshake accepté, client branché DIRECTEMENT sur l'optimiseur ;
// 2. chaque GetPlan répondu sous le délai du moteur (10 s), aucun échec de requête ;
// 3. la session entière (les deux sens) conforme au contrat OpenRPC EMBARQUÉ, règles
// sémantiques comprises — jugée par le validateur publié ;
// 4. chaque plan ACCEPTÉ : l'optimiseur commande, `GetPlan` sans `fallbackCode` ;
// 5. la boucle de sécurité au-dessus : une consigne hors bornes est écrêtée par l'adaptateur,
// une surcharge de phase est délestée par L4, quoi que dise l'optimiseur.
//
// Mode attendu ($ETM_POWERSYNC_CONFORMANCE_EXPECT, défaut `plan`) : `abstain` juge un optimiseur
// qui s'abstient toujours (§7, réponse permise) — le point 4 exige alors une abstention à chaque
// cycle, sans échec ni changement d'optimizerAlive, et le point 5 ne garde que L4 (rien à écrêter).
// Pas de déduction depuis le handshake : `planning` et `capabilities.kinds` ne disent pas si
// l'optimiseur planifie (README.md).
//
// Les points 2 à 5 passent par un RELAIS transparent (une ligne reçue = une ligne renvoyée) : il
// capture la session pour le validateur, mesure les délais, et pour le point 5 seulement réécrit
// la consigne d'un plan en 100 kW — l'épreuve doit tenir quel que soit l'optimiseur, y compris un
// optimiseur qui n'en enverrait jamais.
//
// Banc : celui de `refoptimizer` (restreint) — deux charges `etmvariableload` (0–2 000 W ;
// plancher 800, 3 000 W) et une PAC SG-Ready (P3 1 500 W, P4 3 000 W), compteur = −surplus +
// Σ consommations, une charge consomme au cycle suivant ce qu'on lui a commandé.
#include "simulationbase.h"
#include "experience/energymanagermock.h"
#include "../common/etm-powersync-fakeoptimizer.h"
#include "../common/etm-powersync-contrat.h"
#include "energysettings.h"
#include "etm/energyarbitrator.h"
#include "etm/scheduler/optimizerprotocol.h"
#include "etm/scheduler/socketscheduler.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QLocalServer>
#include <QLocalSocket>
#include <QProcess>
#include <QTcpSocket>
#include <QTemporaryDir>
#include <csignal>
#include <unistd.h>
using namespace nymeaserver;
namespace {
QString variable(const char *nom, const QString &defaut = QString())
{
const QByteArray v = qgetenv(nom);
return v.isNull() ? defaut : QString::fromLocal8Bit(v);
}
//! Un socket vers \p endpoint (unix:// ou tcp://), non connecté ; nullptr si illisible.
QIODevice *socketVers(const QString &endpoint, QObject *parent)
{
const OptimizerEndpoint e = OptimizerEndpoint::parse(endpoint);
if (e.kind == OptimizerEndpoint::Unix) {
auto *s = new QLocalSocket(parent);
s->connectToServer(e.path);
return s;
}
if (e.kind == OptimizerEndpoint::Tcp) {
auto *s = new QTcpSocket(parent);
s->connectToHost(e.host, e.port);
return s;
}
return nullptr;
}
bool connecte(QIODevice *s)
{
if (auto *l = qobject_cast<QLocalSocket *>(s))
return l->state() == QLocalSocket::ConnectedState;
if (auto *t = qobject_cast<QTcpSocket *>(s))
return t->state() == QAbstractSocket::ConnectedState;
return false;
}
/*!
* L'optimiseur à éprouver, en sous-processus, dans son propre GROUPE de processus : la commande
* passe par `/bin/sh -c`, et l'arrêt doit atteindre ce qu'elle a lancé, pas seulement le shell.
*/
class Optimiseur
{
public:
~Optimiseur() { arreter(); }
static QString commandeParDefaut()
{
return QStringLiteral("exec python3 -I -B '%1/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer' "
"--listen {endpoint}").arg(Contrat::racineSources());
}
bool demarrer(const QString &endpoint, QString *erreur)
{
m_endpoint = endpoint;
const QString cmd = variable("ETM_POWERSYNC_OPTIMIZER_CMD", commandeParDefaut());
if (cmd == QLatin1String("-"))
return attendreEcoute(erreur); // déjà lancé par l'appelant
m_p = new QProcess;
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
env.insert(QStringLiteral("ETM_POWERSYNC_OPTIMIZER_ENDPOINT"), endpoint);
env.insert(QStringLiteral("LC_ALL"), QStringLiteral("C.UTF-8"));
m_p->setProcessEnvironment(env);
m_p->setProcessChannelMode(QProcess::MergedChannels);
m_p->setChildProcessModifier([] { ::setpgid(0, 0); });
m_p->start(QStringLiteral("/bin/sh"), {QStringLiteral("-c"), QString(cmd).replace(QStringLiteral("{endpoint}"), endpoint)});
if (!m_p->waitForStarted(5000)) {
*erreur = QStringLiteral("la commande ne démarre pas : ") + m_p->errorString();
return false;
}
return attendreEcoute(erreur);
}
void arreter()
{
if (!m_p)
return;
if (m_p->state() != QProcess::NotRunning) {
::kill(-pid_t(m_p->processId()), SIGTERM);
if (!m_p->waitForFinished(3000)) {
::kill(-pid_t(m_p->processId()), SIGKILL);
m_p->waitForFinished(3000);
}
}
m_sortie += m_p->readAll();
delete m_p;
m_p = nullptr;
}
QString sortie() { if (m_p) m_sortie += m_p->readAll(); return QString::fromUtf8(m_sortie).right(2000); }
private:
bool attendreEcoute(QString *erreur)
{
const int delaiS = variable("ETM_POWERSYNC_OPTIMIZER_START_S", QStringLiteral("15")).toInt();
QElapsedTimer t;
t.start();
while (t.elapsed() < delaiS * 1000) {
QObject proprietaire;
QIODevice *s = socketVers(m_endpoint, &proprietaire);
if (!s) {
*erreur = QStringLiteral("endpoint illisible : ") + m_endpoint;
return false;
}
waitFor([s] { return connecte(s); }, 200);
if (connecte(s))
return true; // la sonde se referme sans handshake : un serveur conforme le tolère
if (m_p && m_p->state() == QProcess::NotRunning) {
*erreur = QStringLiteral("l'optimiseur s'est arrêté au démarrage (code %1) : %2")
.arg(m_p->exitCode()).arg(sortie());
return false;
}
settle(100);
}
*erreur = QStringLiteral("rien n'écoute sur %1 après %2 s : %3").arg(m_endpoint).arg(delaiS).arg(sortie());
return false;
}
QProcess *m_p = nullptr;
QByteArray m_sortie;
QString m_endpoint;
};
/*!
* Relais transparent moteur ↔ optimiseur : chaque ligne passe telle quelle, et entre dans la
* session capturée (le token, s'il y en a un, y devient des astérisques). \c horsBornes réécrit
* la consigne des actions `setpoint` des plans reçus — le point 5, et lui seul.
*/
class Relais : public QObject
{
public:
Relais(const QString &amont, const QString &chemin, const QString &token)
: m_amont(amont), m_token(token)
{
QLocalServer::removeServer(chemin);
connect(&m_serveur, &QLocalServer::newConnection, this, [this] {
while (QLocalSocket *s = m_serveur.nextPendingConnection())
adopter(s);
});
m_ok = m_serveur.listen(chemin);
}
bool ecoute() const { return m_ok; }
QString endpoint() const { return QStringLiteral("unix://") + m_serveur.fullServerName(); }
QList<QJsonObject> session; //!< Les deux sens, dans l'ordre.
QList<double> latencesMs; //!< GetPlan : envoi → réponse, en ms.
bool horsBornes = false;
int reecrites = 0;
private:
void adopter(QLocalSocket *moteur)
{
QIODevice *opt = socketVers(m_amont, moteur);
if (!opt) {
moteur->abort();
return;
}
auto *tamponMoteur = new QByteArray;
auto *tamponOpt = new QByteArray;
auto *enAttente = new QByteArray; // lignes du moteur reçues avant la connexion amont
connect(moteur, &QObject::destroyed, this, [=] { delete tamponMoteur; delete tamponOpt; delete enAttente; });
auto versOpt = [=](const QByteArray &ligne) {
if (connecte(opt))
opt->write(ligne + '\n');
else
enAttente->append(ligne + '\n');
};
auto vider = [=] {
if (!enAttente->isEmpty() && connecte(opt)) {
opt->write(*enAttente);
enAttente->clear();
}
};
if (auto *l = qobject_cast<QLocalSocket *>(opt))
connect(l, &QLocalSocket::connected, this, vider);
else if (auto *t = qobject_cast<QTcpSocket *>(opt))
connect(t, &QTcpSocket::connected, this, vider);
connect(moteur, &QIODevice::readyRead, this, [=] {
tamponMoteur->append(moteur->readAll());
int nl;
while ((nl = tamponMoteur->indexOf('\n')) >= 0) {
const QByteArray ligne = tamponMoteur->left(nl);
tamponMoteur->remove(0, nl + 1);
const QJsonObject m = QJsonDocument::fromJson(ligne).object();
noter(m);
if (m.value("method").toString() == QLatin1String("GetPlan")) {
m_planEnVol = m.value("id").toVariant().toLongLong();
m_chrono.start();
}
versOpt(ligne);
}
});
connect(opt, &QIODevice::readyRead, this, [=] {
tamponOpt->append(opt->readAll());
int nl;
while ((nl = tamponOpt->indexOf('\n')) >= 0) {
QByteArray ligne = tamponOpt->left(nl);
tamponOpt->remove(0, nl + 1);
QJsonObject m = QJsonDocument::fromJson(ligne).object();
noter(m); // l'ORIGINAL : c'est l'optimiseur qu'on juge, pas la réécriture
if (m_planEnVol && m.value("id").toVariant().toLongLong() == m_planEnVol) {
latencesMs << m_chrono.nsecsElapsed() / 1e6;
m_planEnVol = 0;
if (horsBornes && m.value("result").isObject()) {
reecrire(m);
ligne = QJsonDocument(m).toJson(QJsonDocument::Compact);
}
}
moteur->write(ligne + '\n');
}
});
auto couper = [=] {
if (auto *l = qobject_cast<QLocalSocket *>(opt)) l->abort();
else if (auto *t = qobject_cast<QTcpSocket *>(opt)) t->abort();
moteur->abort();
};
connect(moteur, &QLocalSocket::disconnected, this, couper);
if (auto *l = qobject_cast<QLocalSocket *>(opt))
connect(l, &QLocalSocket::disconnected, this, couper);
else if (auto *t = qobject_cast<QTcpSocket *>(opt))
connect(t, &QTcpSocket::disconnected, this, couper);
}
void reecrire(QJsonObject &m)
{
QJsonObject r = m.value("result").toObject();
QJsonArray creneaux = r.value("slots").toArray();
for (int i = 0; i < creneaux.size(); ++i) {
QJsonObject s = creneaux.at(i).toObject();
QJsonArray actions = s.value("actions").toArray();
for (int j = 0; j < actions.size(); ++j) {
QJsonObject a = actions.at(j).toObject();
if (a.value("kind").toString() != QLatin1String("setpoint"))
continue;
if (a.contains("currentA"))
a.insert("currentA", 500);
a.insert("powerW", 100000);
actions[j] = a;
++reecrites;
}
s.insert("actions", actions);
creneaux[i] = s;
}
r.insert("slots", creneaux);
m.insert("result", r);
}
void noter(QJsonObject m)
{
if (!m_token.isEmpty()) {
QByteArray l = QJsonDocument(m).toJson(QJsonDocument::Compact);
l.replace(m_token.toUtf8(), QByteArray(m_token.toUtf8().size(), '*'));
m = QJsonDocument::fromJson(l).object();
}
session << m;
}
QLocalServer m_serveur;
QString m_amont;
QString m_token;
bool m_ok = false;
qint64 m_planEnVol = 0;
QElapsedTimer m_chrono;
};
QString cle(const QVariantMap &v)
{
return QString::fromUtf8(QJsonDocument::fromVariant(v).toJson(QJsonDocument::Compact));
}
} // namespace
class TestOptimizerConformance : public SimulationBase
{
Q_OBJECT
private slots:
void testHandshakeIsAccepted();
void testGetPlanIsAnsweredInTime();
void testTheSessionMatchesTheContract();
void testEveryPlanIsAccepted();
void testTheSafetyLoopStaysOnTop();
private:
void monter();
//! Lance l'optimiseur et y branche le moteur (directement, ou par le relais).
void brancher(bool parLeRelais);
void cycle(const QDateTime &t);
//! Une série de cycles qui parcourt le banc : import, demi-charge, PAC en 3, puis en 4.
void parcours(const QDateTime &t0);
QVariantMap etatApplique() const;
int etatPac() const;
QVariantMap rpc(const QString &methode, const QVariantMap &params = QVariantMap());
QList<QVariantMap> traces() const;
//! Ce que la suite attend de l'optimiseur : des plans appliqués, ou une abstention à chaque cycle.
enum class Attendu { Plan, Abstention };
Attendu m_attendu = Attendu::Plan;
EnergyArbitrator *m_arb = nullptr;
Thing *m_compteur = nullptr;
QString m_var1, m_var2;
QUuid m_k1, m_k2;
double m_surplusW = 0;
QTemporaryDir m_dir;
Optimiseur m_optimiseur;
Relais *m_relais = nullptr;
QString m_token;
int m_jugees = 0;
int m_requetes = 0;
QStringList m_echecs;
QStringList m_planRefuse; //!< cycles où l'optimiseur n'a pas commandé, avec le code de repli
QStringList m_nonAbstenu; //!< mode abstain : cycles qui ne finissent pas en abstention
QStringList m_verdicts; //!< chaque réponse jugée : « issue/code »
};
void TestOptimizerConformance::monter()
{
// Le mode d'abord : un journal doit dire ce qui a été jugé, même si la suite échoue ensuite.
const QString expect = variable("ETM_POWERSYNC_CONFORMANCE_EXPECT");
if (expect.isEmpty() || expect == QLatin1String("plan"))
m_attendu = Attendu::Plan;
else if (expect == QLatin1String("abstain"))
m_attendu = Attendu::Abstention;
else
QFAIL(qUtf8Printable("ETM_POWERSYNC_CONFORMANCE_EXPECT inconnu : « " + expect + " » (plan | abstain)"));
qInfo().noquote() << "[Conformité] mode attendu :"
<< (m_attendu == Attendu::Plan ? "plan" : "abstain")
<< (expect.isEmpty() ? "(défaut)" : "(ETM_POWERSYNC_CONFORMANCE_EXPECT)");
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-conformance.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
cleanupTestCase();
EnergySettings().remove("Optimizer/endpoint");
QFile::remove(OptimizerClient::defaultTokenPath());
for (const char *k : {"Optimizer/trace", "Optimizer/traceRetentionDays", "Optimizer/traceMaxBytesPerDay"})
EnergySettings().remove(k);
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
m_arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(m_arb);
QVERIFY(m_dir.isValid());
ThingManager *tm = NymeaCore::instance()->thingManager();
const QUuid meterId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(meterId);
m_compteur = tm->findConfiguredThing(meterId);
QVERIFY(m_compteur);
m_compteur->setStateValue("connected", true);
for (const char *p : {"currentPhaseA", "currentPhaseB", "currentPhaseC"})
m_compteur->setStateValue(p, 0);
m_var1 = addEtmVariableLoad(27143).toString();
m_var2 = addEtmVariableLoad(27144).toString();
m_k1 = addPowerSwitch(0, 26761);
m_k2 = addPowerSwitch(0, 26762);
for (const QString &v : {m_var1, m_var2})
tm->findConfiguredThing(ThingId(v))->setStateValue("currentPowerW", 0);
m_arb->setPhasePowerLimit(0);
QVariantList l;
l.append(QVariantMap{{"id", m_var1}, {"label", "Variable 1"}, {"adapter", "etmvariableload"},
{"mode", "dynamic"}, {"maxPowerW", 2000}, {"priority", 1}, {"enabled", true}});
l.append(QVariantMap{{"id", m_var2}, {"label", "Variable 2"}, {"adapter", "etmvariableload"},
{"mode", "dynamic"}, {"minPowerW", 800}, {"maxPowerW", 3000}, {"priority", 2},
{"enabled", true}});
l.append(QVariantMap{
{"id", "pac"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"}, {"priority", 3},
{"enabled", true},
{"sgReady", QVariantMap{
{"minStateHoldS", 0},
{"states", QVariantList()
<< QVariantMap{{"state", 1}, {"relays", QVariantList{m_k1.toString()}}, {"estimatedPowerW", 0.0}}
<< QVariantMap{{"state", 2}, {"relays", QVariantList()}, {"estimatedPowerW", 0.0}}
<< QVariantMap{{"state", 3}, {"relays", QVariantList{m_k2.toString()}}, {"estimatedPowerW", 1500.0}}
<< QVariantMap{{"state", 4}, {"relays", QVariantList{m_k1.toString(), m_k2.toString()}},
{"estimatedPowerW", 3000.0}}}}}});
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
// Traces : elles portent ce que le moteur a EXÉCUTÉ (clamped, shed) — le point 5 les lit.
const QString d = m_dir.path() + QStringLiteral("/traces");
QDir().mkpath(d);
m_arb->setOptimizerTraceDirectory(d);
QCOMPARE(rpc("SetOptimizerTrace", {{"enabled", true}}).value("energyError").toString(), QString("EnergyErrorNoError"));
// Token : celui que l'optimiseur attend, s'il en attend un.
const QString fichier = variable("ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE");
if (!fichier.isEmpty()) {
QFile f(fichier);
QVERIFY2(f.open(QIODevice::ReadOnly), qUtf8Printable("ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE illisible : " + fichier));
m_token = QString::fromUtf8(f.readAll().trimmed());
QCOMPARE(rpc("SetOptimizerToken", {{"token", m_token}}).value("energyError").toString(), QString("EnergyErrorNoError"));
}
m_surplusW = 0;
}
void TestOptimizerConformance::brancher(bool parLeRelais)
{
const QString endpoint = variable("ETM_POWERSYNC_OPTIMIZER_ENDPOINT",
QStringLiteral("unix://%1/optimiseur.sock").arg(m_dir.path()));
QString erreur;
QVERIFY2(m_optimiseur.demarrer(endpoint, &erreur), qUtf8Printable(erreur));
QString cible = endpoint;
if (parLeRelais) {
m_relais = new Relais(endpoint, m_dir.path() + QStringLiteral("/relais.sock"), m_token);
QVERIFY2(m_relais->ecoute(), "le relais n'écoute pas");
cible = m_relais->endpoint();
}
QCOMPARE(rpc("SetOptimizerEndpoint", {{"endpoint", cible}}).value("energyError").toString(), QString("EnergyErrorNoError"));
SocketScheduler *s = m_arb->socketScheduler();
QVERIFY(s);
connect(s, &SocketScheduler::reponseJugee, this, [this](const QString &issue, const QString &code) {
++m_jugees;
m_verdicts << issue + QLatin1Char('/') + code;
});
connect(s, &SocketScheduler::echecRequete, this, [this](const QString &code) { ++m_jugees; m_echecs << code; });
QVERIFY2(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }, 15000),
qUtf8Printable(QStringLiteral("le moteur ne passe pas le handshake (lastErrorCode %1) :\n%2")
.arg(s->client()->lastErrorCode(), m_optimiseur.sortie())));
}
void TestOptimizerConformance::cycle(const QDateTime &t)
{
ThingManager *tm = NymeaCore::instance()->thingManager();
double consoW = 0;
for (const QString &v : {m_var1, m_var2})
consoW += tm->findConfiguredThing(ThingId(v))->stateValue("currentPowerW").toDouble();
consoW += QHash<int, double>{{3, 1500.0}, {4, 3000.0}}.value(etatPac(), 0.0);
m_compteur->setStateValue("currentPower", -m_surplusW + consoW);
SocketScheduler *s = m_arb->socketScheduler();
const bool envoi = s && s->client()->state() == OptimizerClient::Ready;
const int avant = m_jugees;
m_arb->recordMeterUpdate(t);
m_arb->simulationCallUpdate(t);
if (envoi) {
++m_requetes;
// 15 s : au-delà des 10 s du moteur, qui conclut lui-même au délai dépassé (echecRequete).
QVERIFY2(waitFor([&] { return m_jugees > avant; }, 15000), "réponse jamais jugée par le moteur");
}
settle(30);
for (const QString &v : {m_var1, m_var2}) {
Thing *th = tm->findConfiguredThing(ThingId(v));
th->setStateValue("currentPowerW", th->stateValue("powerSetpoint").toDouble());
}
}
void TestOptimizerConformance::parcours(const QDateTime &t0)
{
const QList<double> surplus{-1000, 1500, 2600, 4200, 6600, 9000, 3000, -500};
m_planRefuse.clear();
m_nonAbstenu.clear();
const bool vivantAuDepart = rpc("GetOptimizerStatus").value("optimizerAlive").toBool();
for (int i = 0; i < surplus.count(); ++i) {
m_surplusW = surplus.at(i);
cycle(t0.addSecs(30 * i));
if (QTest::currentTestFailed())
return;
// Le premier cycle n'a pas encore de plan en cache (modèle cache) : il ne compte pas.
if (i == 0)
continue;
const QVariantMap p = rpc("GetPlan");
const QString issue = QStringLiteral("cycle %1 (surplus %2 W) : %3, fallbackCode %4")
.arg(i).arg(surplus.at(i)).arg(p.value("strategy").toString(),
p.value("fallbackCode").toString());
if (!m_arb->socketScheduler()->optimiseurAuDernierCycle() || p.contains("fallbackCode"))
m_planRefuse << issue;
// §7 : une abstention rend le cycle au rule-based sans toucher à optimizerAlive.
const bool vivant = rpc("GetOptimizerStatus").value("optimizerAlive").toBool();
if (m_arb->socketScheduler()->optimiseurAuDernierCycle() || p.value("strategy").toString() != QLatin1String("rules")
|| p.value("fallbackCode").toString() != QLatin1String("abstain") || vivant != vivantAuDepart)
m_nonAbstenu << issue + QStringLiteral(", optimizerAlive %1 → %2").arg(vivantAuDepart ? "true" : "false", vivant ? "true" : "false");
}
}
int TestOptimizerConformance::etatPac() const
{
ThingManager *tm = NymeaCore::instance()->thingManager();
const bool k1 = tm->findConfiguredThing(m_k1)->stateValue("power").toBool();
const bool k2 = tm->findConfiguredThing(m_k2)->stateValue("power").toBool();
return k1 ? (k2 ? 4 : 1) : (k2 ? 3 : 2);
}
QVariantMap TestOptimizerConformance::etatApplique() const
{
ThingManager *tm = NymeaCore::instance()->thingManager();
return {{"var1", tm->findConfiguredThing(ThingId(m_var1))->stateValue("powerSetpoint").toDouble()},
{"var2", tm->findConfiguredThing(ThingId(m_var2))->stateValue("powerSetpoint").toDouble()},
{"pac", etatPac()}};
}
QVariantMap TestOptimizerConformance::rpc(const QString &methode, const QVariantMap &params)
{
const QVariantMap r = injectAndWait("NymeaEnergy." + methode, params).toMap();
[&] { QCOMPARE(r.value("status").toString(), QString("success")); }();
return r.value("params").toMap();
}
QList<QVariantMap> TestOptimizerConformance::traces() const
{
QList<QVariantMap> l;
const QDir d(m_dir.path() + QStringLiteral("/traces"));
for (const QString &nom : d.entryList({"etm-powersync-optimizer-trace-*.ndjson"}, QDir::Files, QDir::Name)) {
QFile f(d.filePath(nom));
if (f.open(QIODevice::ReadOnly))
for (const QByteArray &ligne : f.readAll().split('\n'))
if (!ligne.trimmed().isEmpty())
l << QJsonDocument::fromJson(ligne).toVariant().toMap();
}
return l;
}
// --- 1 ---------------------------------------------------------------------------------------------
void TestOptimizerConformance::testHandshakeIsAccepted()
{
// Le client du moteur, DIRECTEMENT sur l'optimiseur (sans relais) : connexion, handshake,
// majeure acceptée ; ce que l'installateur verra (nom, version, capacités) est publié.
monter();
if (QTest::currentTestFailed())
return;
brancher(false);
if (QTest::currentTestFailed())
return;
const QVariantMap st = rpc("GetOptimizerStatus");
QVERIFY2(st.value("protocolVersion").toString().section('.', 0, 0) == QLatin1String("1"), qUtf8Printable(cle(st)));
QVERIFY2(!st.contains("lastErrorCode"), qUtf8Printable(cle(st)));
qInfo().noquote() << "[Conformité] optimiseur :" << st.value("name").toString() << "protocole"
<< st.value("protocolVersion").toString() << "capacités" << cle(st.value("capabilities").toMap());
}
// --- 2 ---------------------------------------------------------------------------------------------
void TestOptimizerConformance::testGetPlanIsAnsweredInTime()
{
monter();
if (QTest::currentTestFailed())
return;
brancher(true);
if (QTest::currentTestFailed())
return;
parcours(utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0)));
QVERIFY2(m_echecs.isEmpty(), qUtf8Printable("requêtes en échec : " + m_echecs.join(", ")));
QCOMPARE(m_relais->latencesMs.size(), m_requetes);
double max = 0;
for (double l : m_relais->latencesMs)
max = qMax(max, l);
qInfo() << "[Conformité]" << m_requetes << "GetPlan, latence max" << max << "ms";
QVERIFY2(max < OptimizerClient::kRequestTimeoutMs,
qUtf8Printable(QStringLiteral("latence max %1 ms ≥ %2 ms").arg(max, 0, 'f', 1).arg(OptimizerClient::kRequestTimeoutMs)));
}
// --- 3 ---------------------------------------------------------------------------------------------
void TestOptimizerConformance::testTheSessionMatchesTheContract()
{
monter();
if (QTest::currentTestFailed())
return;
brancher(true);
if (QTest::currentTestFailed())
return;
parcours(utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0)));
QTemporaryDir dir;
QVERIFY(dir.isValid());
const Contrat::Verdict v = Contrat::juger(m_relais->session,
Contrat::ecrireSchema(dir, OptimizerProtocol::schemaDocumentBytes()),
{QStringLiteral("--semantic")});
QVERIFY2(v.code == 0, qUtf8Printable(QStringLiteral("session NON CONFORME au contrat :\n") + v.sortie.right(4000)));
QVERIFY2(v.compte("Handshake.result") >= 1 && v.compte("GetPlan.result") >= m_requetes - 1,
qUtf8Printable(v.sortie.right(1000)));
}
// --- 4 ---------------------------------------------------------------------------------------------
void TestOptimizerConformance::testEveryPlanIsAccepted()
{
monter();
if (QTest::currentTestFailed())
return;
brancher(true);
if (QTest::currentTestFailed())
return;
parcours(utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0)));
if (m_attendu == Attendu::Abstention) {
if (QTest::currentTestFailed())
return;
// Chaque cycle rendu au rule-based PAR UNE ABSTENTION : ni plan appliqué, ni autre repli,
// ni échec compté (§7). Une réponse jugée autrement qu'`abstention/abstain` fait échouer.
QVERIFY2(m_nonAbstenu.isEmpty(), qUtf8Printable(QStringLiteral("cycles non abstenus :\n") + m_nonAbstenu.join('\n')));
QVERIFY2(m_echecs.isEmpty(), qUtf8Printable("requêtes en échec : " + m_echecs.join(", ")));
QVERIFY2(!m_verdicts.isEmpty() && m_verdicts.count(QStringLiteral("abstention/abstain")) == m_verdicts.size(),
qUtf8Printable("réponses jugées : " + m_verdicts.join(", ")));
QCOMPARE(m_arb->socketScheduler()->vivacite().echecsConsecutifs(), 0);
QCOMPARE(rpc("GetOptimizerStatus").value("activeStrategy").toString(), QString("rules"));
return;
}
QVERIFY2(m_planRefuse.isEmpty(), qUtf8Printable(QStringLiteral("plans non appliqués :\n") + m_planRefuse.join('\n')));
QCOMPARE(rpc("GetOptimizerStatus").value("activeStrategy").toString(), QString("optimizer"));
}
// --- 5 ---------------------------------------------------------------------------------------------
void TestOptimizerConformance::testTheSafetyLoopStaysOnTop()
{
monter();
if (QTest::currentTestFailed())
return;
m_arb->setPhasePowerLimit(25);
brancher(true);
if (QTest::currentTestFailed())
return;
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0));
m_surplusW = 9000;
for (int k = 0; k < 3; ++k)
cycle(t0.addSecs(30 * k));
// a) Hors bornes : chaque consigne d'un plan reçu devient 100 kW. L'adaptateur l'écrête à la
// puissance déclarée de la charge, et le moteur le dit (`clamped` dans la trace).
// En mode abstain, aucun plan donc aucune consigne : les mêmes cycles passent, sans épreuve.
const bool abstention = m_attendu == Attendu::Abstention;
m_relais->horsBornes = !abstention;
for (int k = 3; k < 6; ++k)
cycle(t0.addSecs(30 * k));
m_relais->horsBornes = false;
QList<QVariantMap> l;
if (!abstention) {
QVERIFY2(m_relais->reecrites > 0, "l'optimiseur n'a émis aucune consigne setpoint : rien à écrêter");
const QVariantMap e = etatApplique();
QVERIFY2(e.value("var1").toDouble() <= 2000 && e.value("var2").toDouble() <= 3000, qUtf8Printable(cle(e)));
QVERIFY(waitFor([&] { l = traces(); return l.count() >= 6; }, 5000));
bool ecretee = false;
for (const QVariant &x : l.at(5).value("executed").toList())
ecretee |= x.toMap().value("clamped").toBool() && x.toMap().value("powerW").toDouble() <= 3000;
QVERIFY2(ecretee && l.at(5).value("strategy").toString() == QLatin1String("optimizer"),
qUtf8Printable(cle(l.at(5))));
}
// b) L4 : phase A à 32 A sous un disjoncteur de 25 A. Quoi que commande l'optimiseur, L4 déleste.
const QVariantMap avant = etatApplique();
m_compteur->setStateValue("currentPhaseA", 32);
m_surplusW = -2000;
cycle(t0.addSecs(30 * 6));
const QVariantMap apres = etatApplique();
QVERIFY(waitFor([&] { l = traces(); return l.count() >= 7; }, 5000));
bool delestee = false;
for (const QVariant &x : l.at(6).value("executed").toList())
delestee |= x.toMap().value("shed").toBool();
QVERIFY2(delestee || apres.value("var1").toDouble() + apres.value("var2").toDouble()
< avant.value("var1").toDouble() + avant.value("var2").toDouble(),
qUtf8Printable(cle(l.at(6))));
if (abstention) {
// L4 sous le rule-based, sans consigne de l'optimiseur. Le `shed` est exigé : à −2 000 W, le
// rule-based baisse déjà les consignes de lui-même, la baisse seule ne prouverait pas L4.
QVERIFY2(delestee, qUtf8Printable("L4 n'a rien délesté : " + cle(l.at(6))));
qInfo().noquote() << "[Conformité] L4 vérifié sous" << l.at(6).value("strategy").toString() << "(shed dans la trace)";
QSKIP("aucune consigne à écrêter : l'optimiseur s'abstient");
}
}
QTEST_MAIN(TestOptimizerConformance)
#include "testconformance.moc"