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