test: suite etm-powersync-optimizer-conformance pour un optimiseur quelconque

Lance l'optimiseur de $ETM_POWERSYNC_OPTIMIZER_CMD (défaut : la référence,
mode normal) et y branche le vrai moteur : handshake direct accepté, GetPlan
répondus sous 10 s, session entière conforme au contrat embarqué (règles
sémantiques comprises), chaque plan appliqué sans fallbackCode, consigne hors
bornes écrêtée et surcharge délestée par L4. Relais transparent pour capturer
la session. README : la lancer contre un autre optimiseur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-17 21:46:53 +02:00
parent 2f191f4bec
commit fc41c4ff40
5 changed files with 800 additions and 5 deletions

View File

@ -1,2 +1,2 @@
TEMPLATE = subdirs
SUBDIRS += charging decisions loadmodel optimizerclient protocolschema refoptimizer simulation socketscheduler spotmarket
SUBDIRS += charging conformance decisions loadmodel optimizerclient protocolschema refoptimizer simulation socketscheduler spotmarket

View File

@ -0,0 +1,58 @@
# `etm-powersync-optimizer-conformance` — conformité d'un optimiseur quelconque
Suite de test du moteur (Qt Test, harnais nymea du dépôt). Elle lance **votre** optimiseur, branche
dessus le **vrai** moteur — serveur nymea, arbitre, `SocketScheduler`, `OptimizerClient`,
adaptateurs, sur les mocks de la suite `simulation` — et vérifie, une fonction par point :
| Fonction | Ce qui est vérifié |
|---|---|
| `testHandshakeIsAccepted` | le client du moteur, **directement** sur l'optimiseur, passe le handshake (§3) : majeure 1, aucun code d'erreur |
| `testGetPlanIsAnsweredInTime` | 8 cycles : chaque `GetPlan` répondu sous le délai du moteur (10 s), aucun échec de requête ; latence max affichée |
| `testTheSessionMatchesTheContract` | la session entière (les deux sens) est conforme au contrat OpenRPC **embarqué** dans le moteur, règles sémantiques comprises (`tools/etm-powersync-schema-check --semantic`) |
| `testEveryPlanIsAccepted` | à chaque cycle, le plan est **appliqué** : l'optimiseur commande, `GetPlan` sans `fallbackCode` |
| `testTheSafetyLoopStaysOnTop` | la boucle de sécurité reste au-dessus : une consigne hors bornes (réécrite en 100 kW par le relais de test) est **écrêtée** par l'adaptateur (`clamped`), une surcharge de phase est **délestée** par L4 |
Les fonctions 2 à 5 passent par un relais transparent (une ligne reçue = une ligne renvoyée) qui
capture la session et mesure les délais ; la 5 est la seule où il réécrit une réponse. Banc : 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) ; surplus de −1 000 à 9 000 W.
## Lancer contre un autre optimiseur
Construire le moteur avec ses tests (`qmake6 …/etm-powersync-energy-plugin-etm.pro CONFIG+=build_tests
&& make -j4`), puis, **un processus par fonction** (le harnais nymea ne rend pas sa mémoire entre deux
tests, `AGENTS.md`) :
```sh
cd <build>/tests/auto/conformance
export NYMEA_PLUGINS_PATH=<build>/tests/mocks/plugins/energymocks:/usr/lib/x86_64-linux-gnu/nymea/plugins
export ETM_POWERSYNC_OPTIMIZER_CMD='exec /chemin/de/mon-optimiseur --listen {endpoint}'
for fn in testHandshakeIsAccepted testGetPlanIsAnsweredInTime testTheSessionMatchesTheContract \
testEveryPlanIsAccepted testTheSafetyLoopStaysOnTop; do
systemd-run --user --scope -q -p MemoryMax=2G \
bwrap --dev-bind / / --unshare-net --tmpfs /tmp --bind "$PWD/../../.." "$PWD/../../.." \
--bind <sources> <sources> --chdir "$PWD" ./target_wrapper.sh ./etm-powersync-optimizer-conformance "$fn"
done
```
`bwrap --unshare-net` est recommandé : le harnais ouvre des ports fixes, et l'optimiseur que lance la
commande vit dans le même réseau privé (la boucle locale y fonctionne, `tcp://127.0.0.1:…` compris).
| Variable | Défaut | Sens |
|---|---|---|
| `ETM_POWERSYNC_OPTIMIZER_CMD` | l'optimiseur de référence du dépôt, mode `normal` | Commande qui démarre le **serveur** (`/bin/sh -c`, dans son propre groupe de processus, arrêtée par SIGTERM puis SIGKILL). `{endpoint}` y est remplacé par l'endpoint où écouter, aussi passé dans `ETM_POWERSYNC_OPTIMIZER_ENDPOINT`. `-` : ne rien lancer, l'optimiseur écoute déjà |
| `ETM_POWERSYNC_OPTIMIZER_ENDPOINT` | `unix://<répertoire temporaire du test>/optimiseur.sock` | Où l'optimiseur écoute (`unix:///…` ou `tcp://hôte:port`) |
| `ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE` | aucun | Fichier du token que l'optimiseur attend ; le moteur l'envoie au handshake (16 caractères au moins) |
| `ETM_POWERSYNC_OPTIMIZER_START_S` | `15` | Délai pour que l'endpoint accepte une connexion |
| `ETM_POWERSYNC_SOURCE_DIR` | sources du build | Racine où trouver le validateur et l'optimiseur de référence |
L'attente de démarrage ouvre une connexion de sonde, refermée sans handshake : un serveur conforme
la tolère. Un échec dit ce qui manque — code d'erreur du client, écart au contrat (chemin, mot-clé),
cycles non appliqués avec leur `fallbackCode`, latences.
## Ce qu'elle ne vérifie pas
La **qualité** des plans (aucune comparaison au rule-based : c'est `refoptimizer`, pour la référence
seulement), les bornes VE (aucune borne au banc), `UpdateLearned` (ignoré par le moteur 1.1), le
comportement sous défaut de transport. Le contrat : `docs/etm-powersync-optimizer-protocol.openrpc.json`
(prose : `docs/OPTIMIZER_PROTOCOL.md`, annexe G).

View File

@ -0,0 +1,44 @@
# Lot « contrat OpenRPC » — suite de CONFORMITÉ d'un optimiseur QUELCONQUE : le vrai moteur
# (serveur nymea, arbitre, SocketScheduler, OptimizerClient, adaptateurs, mocks de `simulation`)
# branché sur l'optimiseur que lance $ETM_POWERSYNC_OPTIMIZER_CMD. Mode d'emploi : README.md.
#
# Suite À PART, comme `refoptimizer` : elle ne touche pas la référence `decisions`.
include(../common/common.pri)
include(../../../energyplugin/energyplugin.pri)
include(../../mocks/spotmarketprovider/spotmarketdataprovidermock.pri)
CONFIG += testcase
TARGET = etm-powersync-optimizer-conformance
DEFINES += ENERGY_SIMULATION
# Racine des sources : l'optimiseur de référence (commande par défaut) et le validateur. Surchargée
# à l'exécution par $ETM_POWERSYNC_SOURCE_DIR (arbre mutant, arbre archivé).
DEFINES += ETM_POWERSYNC_SOURCE_DIR=\\\"$$clean_path($$PWD/../../..)\\\"
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/simulationbase.h \
$$PWD/../common/etm-powersync-fakeoptimizer.h \
$$PWD/../common/etm-powersync-contrat.h
SOURCES += \
$$SIM/experience/energyexperienceenergymock.cpp \
$$SIM/experience/energyjsonhandler.cpp \
$$SIM/experience/energylogger.cpp \
$$SIM/experience/energymanagermock.cpp \
$$SIM/simulationbase.cpp \
testconformance.cpp
target.path = $$[QT_INSTALL_PREFIX]/bin
INSTALLS += target

View File

@ -0,0 +1,656 @@
// 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.
//
// 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;
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
};
void TestOptimizerConformance::monter()
{
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 &, const QString &) { ++m_jugees; });
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();
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");
if (!m_arb->socketScheduler()->optimiseurAuDernierCycle() || p.contains("fallbackCode"))
m_planRefuse << QStringLiteral("cycle %1 (surplus %2 W) : %3, fallbackCode %4")
.arg(i).arg(surplus.at(i)).arg(p.value("strategy").toString(),
p.value("fallbackCode").toString());
}
}
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)));
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).
m_relais->horsBornes = true;
for (int k = 3; k < 6; ++k)
cycle(t0.addSecs(30 * k));
m_relais->horsBornes = false;
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)));
QList<QVariantMap> l;
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))));
}
QTEST_MAIN(TestOptimizerConformance)
#include "testconformance.moc"

View File

@ -1,9 +1,9 @@
# tools/
Outils de développement et d'exploitation du plugin. Ce fichier ne décrit que
`etm-powersync-trace-replay` ; l'optimiseur de référence a le sien
(`etm-powersync-ref-optimizer/README.md`), les autres scripts portent leur mode d'emploi en
en-tête.
Outils de développement et d'exploitation du plugin. Ce fichier décrit
`etm-powersync-trace-replay` et les deux outils du contrat du protocole optimiseur ;
l'optimiseur de référence a le sien (`etm-powersync-ref-optimizer/README.md`), les autres scripts
portent leur mode d'emploi en en-tête.
Noms d'optimiseur : `powersync-optimizer` = exemple de référence GPL (ce dépôt) ; `etm-optimizer` = optimiseur ETM (propriétaire, hors de ce dépôt). L'optimiseur de
référence s'annonce `powersync-optimizer` au handshake ; son fichier garde le préfixe des artefacts.
@ -48,3 +48,40 @@ Les traces contiennent des mesures du site : elles **restent sur le site**. L'ex
Test : `python3 -I tests/tools/etm-powersync-trace-replay-test.py` (serveur de test minimal qui
renvoie les réponses enregistrées — ce n'est pas l'optimiseur de référence).
## Le contrat du protocole optimiseur — `etm-powersync-schema-check`, `etm-powersync-gen-protocol-doc`
Python 3, bibliothèque standard seule, aucun accès réseau. Le contrat :
`docs/etm-powersync-optimizer-protocol.openrpc.json` (OpenRPC 1.3), servi par le moteur
(`NymeaEnergy.GetOptimizerProtocol`).
```sh
# un message, une session NDJSON (les deux sens), des traces de décision
./tools/etm-powersync-schema-check --method GetPlan --params requete.json
./tools/etm-powersync-schema-check --method GetPlan --result plan.json --request requete.json --semantic
./tools/etm-powersync-schema-check --ndjson session.ndjson --semantic
./tools/etm-powersync-schema-check --trace etm-powersync-optimizer-trace-2026-10-17.ndjson --semantic
./tools/etm-powersync-schema-check --subset # le sous-ensemble de JSON Schema géré
# la prose du protocole, régénérée depuis le contrat ; --check : code 1 si pas à jour (ci-quality 5/5)
./tools/etm-powersync-gen-protocol-doc [--check]
# les listes fermées du contrat, régénérées depuis le CODE
ETM_POWERSYNC_CODE_LISTS=/tmp/listes.json <build>/tests/auto/protocolschema/etm-powersync-protocolschema \
testClosedListsAreGeneratedFromTheCode
./tools/etm-powersync-gen-protocol-doc --enums-from /tmp/listes.json
```
- **Sous-ensemble géré** : `$ref` local, `type`, `enum`, `const`, `properties`, `required`,
`additionalProperties`, `propertyNames`, `items`, `minItems`, `maxItems`, `minimum`, `maximum`,
`exclusiveMinimum`, `minLength`, `maxLength`, `pattern`, `oneOf`, `anyOf`, `allOf`. Tout autre
mot-clé est une erreur de schéma (code 2), jamais ignoré.
- **`--semantic`** applique `x-etm-semantic-rules` : ce que le validateur du moteur refuse en plus de
la forme, dans son ordre, sous le code qu'il publie (`fallbackCode`).
- **Code de sortie** : 0 conforme ; 1 écart de forme ou refus sémantique ; 2 erreur d'usage ou de
schéma.
- Conformité d'un optimiseur de bout en bout, avec le vrai moteur : suite
`etm-powersync-optimizer-conformance` (`tests/auto/conformance/README.md`).
Tests : `python3 -I tests/tools/etm-powersync-schema-check-test.py` et
`python3 -I tests/tools/etm-powersync-gen-protocol-doc-test.py`.