From fc41c4ff404f963a83d6a2e3881dbe241da9ee63 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 17 Oct 2026 21:46:53 +0200 Subject: [PATCH] test: suite etm-powersync-optimizer-conformance pour un optimiseur quelconque MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/auto/auto.pro | 2 +- tests/auto/conformance/README.md | 58 ++ tests/auto/conformance/conformance.pro | 44 ++ tests/auto/conformance/testconformance.cpp | 656 +++++++++++++++++++++ tools/README.md | 45 +- 5 files changed, 800 insertions(+), 5 deletions(-) create mode 100644 tests/auto/conformance/README.md create mode 100644 tests/auto/conformance/conformance.pro create mode 100644 tests/auto/conformance/testconformance.cpp diff --git a/tests/auto/auto.pro b/tests/auto/auto.pro index 9f9bb53..23d5a7a 100644 --- a/tests/auto/auto.pro +++ b/tests/auto/auto.pro @@ -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 diff --git a/tests/auto/conformance/README.md b/tests/auto/conformance/README.md new file mode 100644 index 0000000..d5dc2e4 --- /dev/null +++ b/tests/auto/conformance/README.md @@ -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 /tests/auto/conformance +export NYMEA_PLUGINS_PATH=/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 --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:///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). diff --git a/tests/auto/conformance/conformance.pro b/tests/auto/conformance/conformance.pro new file mode 100644 index 0000000..1c54477 --- /dev/null +++ b/tests/auto/conformance/conformance.pro @@ -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 diff --git a/tests/auto/conformance/testconformance.cpp b/tests/auto/conformance/testconformance.cpp new file mode 100644 index 0000000..97a86bb --- /dev/null +++ b/tests/auto/conformance/testconformance.cpp @@ -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 +#include +#include +#include +#include +#include +#include + +#include +#include + +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(s)) + return l->state() == QLocalSocket::ConnectedState; + if (auto *t = qobject_cast(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 session; //!< Les deux sens, dans l'ordre. + QList 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(opt)) + connect(l, &QLocalSocket::connected, this, vider); + else if (auto *t = qobject_cast(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(opt)) l->abort(); + else if (auto *t = qobject_cast(opt)) t->abort(); + moteur->abort(); + }; + connect(moteur, &QLocalSocket::disconnected, this, couper); + if (auto *l = qobject_cast(opt)) + connect(l, &QLocalSocket::disconnected, this, couper); + else if (auto *t = qobject_cast(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 ¶ms = QVariantMap()); + QList 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(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{{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 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 ¶ms) +{ + const QVariantMap r = injectAndWait("NymeaEnergy." + methode, params).toMap(); + [&] { QCOMPARE(r.value("status").toString(), QString("success")); }(); + return r.value("params").toMap(); +} + +QList TestOptimizerConformance::traces() const +{ + QList 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 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" diff --git a/tools/README.md b/tools/README.md index 9b09b24..a4ca463 100644 --- a/tools/README.md +++ b/tools/README.md @@ -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 /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`. +