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`. +