// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync // // Lot « contrat OpenRPC » — suite de CONFORMITÉ d'un optimiseur quelconque. // // L'optimiseur à éprouver est lancé par la commande de $ETM_POWERSYNC_OPTIMIZER_CMD (défaut : // l'optimiseur de référence du dépôt, mode `normal`), et le VRAI moteur s'y branche : serveur // nymea, arbitre, `SocketScheduler`, `OptimizerClient`, adaptateurs, mocks de `simulation`. Rien // n'est simulé côté moteur ; c'est son client, son validateur et sa boucle de sécurité qui jugent. // Mode d'emploi : README.md à côté. // // Ce qui est vérifié, une fonction par point (un processus par fonction) : // 1. handshake accepté, client branché DIRECTEMENT sur l'optimiseur ; // 2. chaque GetPlan répondu sous le délai du moteur (10 s), aucun échec de requête ; // 3. la session entière (les deux sens) conforme au contrat OpenRPC EMBARQUÉ, règles // sémantiques comprises — jugée par le validateur publié ; // 4. chaque plan ACCEPTÉ : l'optimiseur commande, `GetPlan` sans `fallbackCode` ; // 5. la boucle de sécurité au-dessus : une consigne hors bornes est écrêtée par l'adaptateur, // une surcharge de phase est délestée par L4, quoi que dise l'optimiseur. // // Mode attendu ($ETM_POWERSYNC_CONFORMANCE_EXPECT, défaut `plan`) : `abstain` juge un optimiseur // qui s'abstient toujours (§7, réponse permise) — le point 4 exige alors une abstention à chaque // cycle, sans échec ni changement d'optimizerAlive, et le point 5 ne garde que L4 (rien à écrêter). // Pas de déduction depuis le handshake : `planning` et `capabilities.kinds` ne disent pas si // l'optimiseur planifie (README.md). // // Les points 2 à 5 passent par un RELAIS transparent (une ligne reçue = une ligne renvoyée) : il // capture la session pour le validateur, mesure les délais, et pour le point 5 seulement réécrit // la consigne d'un plan en 100 kW — l'épreuve doit tenir quel que soit l'optimiseur, y compris un // optimiseur qui n'en enverrait jamais. // // Banc : celui de `refoptimizer` (restreint) — deux charges `etmvariableload` (0–2 000 W ; // plancher 800, 3 000 W) et une PAC SG-Ready (P3 1 500 W, P4 3 000 W), compteur = −surplus + // Σ consommations, une charge consomme au cycle suivant ce qu'on lui a commandé. #include "simulationbase.h" #include "experience/energymanagermock.h" #include "../common/etm-powersync-fakeoptimizer.h" #include "../common/etm-powersync-contrat.h" #include "energysettings.h" #include "etm/energyarbitrator.h" #include "etm/scheduler/optimizerprotocol.h" #include "etm/scheduler/socketscheduler.h" #include #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; //! Ce que la suite attend de l'optimiseur : des plans appliqués, ou une abstention à chaque cycle. enum class Attendu { Plan, Abstention }; Attendu m_attendu = Attendu::Plan; EnergyArbitrator *m_arb = nullptr; Thing *m_compteur = nullptr; QString m_var1, m_var2; QUuid m_k1, m_k2; double m_surplusW = 0; QTemporaryDir m_dir; Optimiseur m_optimiseur; Relais *m_relais = nullptr; QString m_token; int m_jugees = 0; int m_requetes = 0; QStringList m_echecs; QStringList m_planRefuse; //!< cycles où l'optimiseur n'a pas commandé, avec le code de repli QStringList m_nonAbstenu; //!< mode abstain : cycles qui ne finissent pas en abstention QStringList m_verdicts; //!< chaque réponse jugée : « issue/code » }; void TestOptimizerConformance::monter() { // Le mode d'abord : un journal doit dire ce qui a été jugé, même si la suite échoue ensuite. const QString expect = variable("ETM_POWERSYNC_CONFORMANCE_EXPECT"); if (expect.isEmpty() || expect == QLatin1String("plan")) m_attendu = Attendu::Plan; else if (expect == QLatin1String("abstain")) m_attendu = Attendu::Abstention; else QFAIL(qUtf8Printable("ETM_POWERSYNC_CONFORMANCE_EXPECT inconnu : « " + expect + " » (plan | abstain)")); qInfo().noquote() << "[Conformité] mode attendu :" << (m_attendu == Attendu::Plan ? "plan" : "abstain") << (expect.isEmpty() ? "(défaut)" : "(ETM_POWERSYNC_CONFORMANCE_EXPECT)"); const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-conformance.json"; QFile::remove(cfgPath); qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); cleanupTestCase(); EnergySettings().remove("Optimizer/endpoint"); QFile::remove(OptimizerClient::defaultTokenPath()); for (const char *k : {"Optimizer/trace", "Optimizer/traceRetentionDays", "Optimizer/traceMaxBytesPerDay"}) EnergySettings().remove(k); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); m_arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(m_arb); QVERIFY(m_dir.isValid()); ThingManager *tm = NymeaCore::instance()->thingManager(); const QUuid meterId = addMeter(); m_experiencePlugin->energyManager()->setRootMeter(meterId); m_compteur = tm->findConfiguredThing(meterId); QVERIFY(m_compteur); m_compteur->setStateValue("connected", true); for (const char *p : {"currentPhaseA", "currentPhaseB", "currentPhaseC"}) m_compteur->setStateValue(p, 0); m_var1 = addEtmVariableLoad(27143).toString(); m_var2 = addEtmVariableLoad(27144).toString(); m_k1 = addPowerSwitch(0, 26761); m_k2 = addPowerSwitch(0, 26762); for (const QString &v : {m_var1, m_var2}) tm->findConfiguredThing(ThingId(v))->setStateValue("currentPowerW", 0); m_arb->setPhasePowerLimit(0); QVariantList l; l.append(QVariantMap{{"id", m_var1}, {"label", "Variable 1"}, {"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"maxPowerW", 2000}, {"priority", 1}, {"enabled", true}}); l.append(QVariantMap{{"id", m_var2}, {"label", "Variable 2"}, {"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"minPowerW", 800}, {"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}}); l.append(QVariantMap{ {"id", "pac"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"}, {"priority", 3}, {"enabled", true}, {"sgReady", QVariantMap{ {"minStateHoldS", 0}, {"states", QVariantList() << QVariantMap{{"state", 1}, {"relays", QVariantList{m_k1.toString()}}, {"estimatedPowerW", 0.0}} << QVariantMap{{"state", 2}, {"relays", QVariantList()}, {"estimatedPowerW", 0.0}} << QVariantMap{{"state", 3}, {"relays", QVariantList{m_k2.toString()}}, {"estimatedPowerW", 1500.0}} << QVariantMap{{"state", 4}, {"relays", QVariantList{m_k1.toString(), m_k2.toString()}}, {"estimatedPowerW", 3000.0}}}}}}); QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); // Traces : elles portent ce que le moteur a EXÉCUTÉ (clamped, shed) — le point 5 les lit. const QString d = m_dir.path() + QStringLiteral("/traces"); QDir().mkpath(d); m_arb->setOptimizerTraceDirectory(d); QCOMPARE(rpc("SetOptimizerTrace", {{"enabled", true}}).value("energyError").toString(), QString("EnergyErrorNoError")); // Token : celui que l'optimiseur attend, s'il en attend un. const QString fichier = variable("ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE"); if (!fichier.isEmpty()) { QFile f(fichier); QVERIFY2(f.open(QIODevice::ReadOnly), qUtf8Printable("ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE illisible : " + fichier)); m_token = QString::fromUtf8(f.readAll().trimmed()); QCOMPARE(rpc("SetOptimizerToken", {{"token", m_token}}).value("energyError").toString(), QString("EnergyErrorNoError")); } m_surplusW = 0; } void TestOptimizerConformance::brancher(bool parLeRelais) { const QString endpoint = variable("ETM_POWERSYNC_OPTIMIZER_ENDPOINT", QStringLiteral("unix://%1/optimiseur.sock").arg(m_dir.path())); QString erreur; QVERIFY2(m_optimiseur.demarrer(endpoint, &erreur), qUtf8Printable(erreur)); QString cible = endpoint; if (parLeRelais) { m_relais = new Relais(endpoint, m_dir.path() + QStringLiteral("/relais.sock"), m_token); QVERIFY2(m_relais->ecoute(), "le relais n'écoute pas"); cible = m_relais->endpoint(); } QCOMPARE(rpc("SetOptimizerEndpoint", {{"endpoint", cible}}).value("energyError").toString(), QString("EnergyErrorNoError")); SocketScheduler *s = m_arb->socketScheduler(); QVERIFY(s); connect(s, &SocketScheduler::reponseJugee, this, [this](const QString &issue, const QString &code) { ++m_jugees; m_verdicts << issue + QLatin1Char('/') + code; }); connect(s, &SocketScheduler::echecRequete, this, [this](const QString &code) { ++m_jugees; m_echecs << code; }); QVERIFY2(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }, 15000), qUtf8Printable(QStringLiteral("le moteur ne passe pas le handshake (lastErrorCode %1) :\n%2") .arg(s->client()->lastErrorCode(), m_optimiseur.sortie()))); } void TestOptimizerConformance::cycle(const QDateTime &t) { ThingManager *tm = NymeaCore::instance()->thingManager(); double consoW = 0; for (const QString &v : {m_var1, m_var2}) consoW += tm->findConfiguredThing(ThingId(v))->stateValue("currentPowerW").toDouble(); consoW += QHash{{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(); m_nonAbstenu.clear(); const bool vivantAuDepart = rpc("GetOptimizerStatus").value("optimizerAlive").toBool(); for (int i = 0; i < surplus.count(); ++i) { m_surplusW = surplus.at(i); cycle(t0.addSecs(30 * i)); if (QTest::currentTestFailed()) return; // Le premier cycle n'a pas encore de plan en cache (modèle cache) : il ne compte pas. if (i == 0) continue; const QVariantMap p = rpc("GetPlan"); const QString issue = QStringLiteral("cycle %1 (surplus %2 W) : %3, fallbackCode %4") .arg(i).arg(surplus.at(i)).arg(p.value("strategy").toString(), p.value("fallbackCode").toString()); if (!m_arb->socketScheduler()->optimiseurAuDernierCycle() || p.contains("fallbackCode")) m_planRefuse << issue; // §7 : une abstention rend le cycle au rule-based sans toucher à optimizerAlive. const bool vivant = rpc("GetOptimizerStatus").value("optimizerAlive").toBool(); if (m_arb->socketScheduler()->optimiseurAuDernierCycle() || p.value("strategy").toString() != QLatin1String("rules") || p.value("fallbackCode").toString() != QLatin1String("abstain") || vivant != vivantAuDepart) m_nonAbstenu << issue + QStringLiteral(", optimizerAlive %1 → %2").arg(vivantAuDepart ? "true" : "false", vivant ? "true" : "false"); } } int TestOptimizerConformance::etatPac() const { ThingManager *tm = NymeaCore::instance()->thingManager(); const bool k1 = tm->findConfiguredThing(m_k1)->stateValue("power").toBool(); const bool k2 = tm->findConfiguredThing(m_k2)->stateValue("power").toBool(); return k1 ? (k2 ? 4 : 1) : (k2 ? 3 : 2); } QVariantMap TestOptimizerConformance::etatApplique() const { ThingManager *tm = NymeaCore::instance()->thingManager(); return {{"var1", tm->findConfiguredThing(ThingId(m_var1))->stateValue("powerSetpoint").toDouble()}, {"var2", tm->findConfiguredThing(ThingId(m_var2))->stateValue("powerSetpoint").toDouble()}, {"pac", etatPac()}}; } QVariantMap TestOptimizerConformance::rpc(const QString &methode, const QVariantMap ¶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))); if (m_attendu == Attendu::Abstention) { if (QTest::currentTestFailed()) return; // Chaque cycle rendu au rule-based PAR UNE ABSTENTION : ni plan appliqué, ni autre repli, // ni échec compté (§7). Une réponse jugée autrement qu'`abstention/abstain` fait échouer. QVERIFY2(m_nonAbstenu.isEmpty(), qUtf8Printable(QStringLiteral("cycles non abstenus :\n") + m_nonAbstenu.join('\n'))); QVERIFY2(m_echecs.isEmpty(), qUtf8Printable("requêtes en échec : " + m_echecs.join(", "))); QVERIFY2(!m_verdicts.isEmpty() && m_verdicts.count(QStringLiteral("abstention/abstain")) == m_verdicts.size(), qUtf8Printable("réponses jugées : " + m_verdicts.join(", "))); QCOMPARE(m_arb->socketScheduler()->vivacite().echecsConsecutifs(), 0); QCOMPARE(rpc("GetOptimizerStatus").value("activeStrategy").toString(), QString("rules")); return; } QVERIFY2(m_planRefuse.isEmpty(), qUtf8Printable(QStringLiteral("plans non appliqués :\n") + m_planRefuse.join('\n'))); QCOMPARE(rpc("GetOptimizerStatus").value("activeStrategy").toString(), QString("optimizer")); } // --- 5 --------------------------------------------------------------------------------------------- void TestOptimizerConformance::testTheSafetyLoopStaysOnTop() { monter(); if (QTest::currentTestFailed()) return; m_arb->setPhasePowerLimit(25); brancher(true); if (QTest::currentTestFailed()) return; const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0)); m_surplusW = 9000; for (int k = 0; k < 3; ++k) cycle(t0.addSecs(30 * k)); // a) Hors bornes : chaque consigne d'un plan reçu devient 100 kW. L'adaptateur l'écrête à la // puissance déclarée de la charge, et le moteur le dit (`clamped` dans la trace). // En mode abstain, aucun plan donc aucune consigne : les mêmes cycles passent, sans épreuve. const bool abstention = m_attendu == Attendu::Abstention; m_relais->horsBornes = !abstention; for (int k = 3; k < 6; ++k) cycle(t0.addSecs(30 * k)); m_relais->horsBornes = false; QList l; if (!abstention) { QVERIFY2(m_relais->reecrites > 0, "l'optimiseur n'a émis aucune consigne setpoint : rien à écrêter"); const QVariantMap e = etatApplique(); QVERIFY2(e.value("var1").toDouble() <= 2000 && e.value("var2").toDouble() <= 3000, qUtf8Printable(cle(e))); QVERIFY(waitFor([&] { l = traces(); return l.count() >= 6; }, 5000)); bool ecretee = false; for (const QVariant &x : l.at(5).value("executed").toList()) ecretee |= x.toMap().value("clamped").toBool() && x.toMap().value("powerW").toDouble() <= 3000; QVERIFY2(ecretee && l.at(5).value("strategy").toString() == QLatin1String("optimizer"), qUtf8Printable(cle(l.at(5)))); } // b) L4 : phase A à 32 A sous un disjoncteur de 25 A. Quoi que commande l'optimiseur, L4 déleste. const QVariantMap avant = etatApplique(); m_compteur->setStateValue("currentPhaseA", 32); m_surplusW = -2000; cycle(t0.addSecs(30 * 6)); const QVariantMap apres = etatApplique(); QVERIFY(waitFor([&] { l = traces(); return l.count() >= 7; }, 5000)); bool delestee = false; for (const QVariant &x : l.at(6).value("executed").toList()) delestee |= x.toMap().value("shed").toBool(); QVERIFY2(delestee || apres.value("var1").toDouble() + apres.value("var2").toDouble() < avant.value("var1").toDouble() + avant.value("var2").toDouble(), qUtf8Printable(cle(l.at(6)))); if (abstention) { // L4 sous le rule-based, sans consigne de l'optimiseur. Le `shed` est exigé : à −2 000 W, le // rule-based baisse déjà les consignes de lui-même, la baisse seule ne prouverait pas L4. QVERIFY2(delestee, qUtf8Printable("L4 n'a rien délesté : " + cle(l.at(6)))); qInfo().noquote() << "[Conformité] L4 vérifié sous" << l.at(6).value("strategy").toString() << "(shed dans la trace)"; QSKIP("aucune consigne à écrêter : l'optimiseur s'abstient"); } } QTEST_MAIN(TestOptimizerConformance) #include "testconformance.moc"