test(intégration): suite etm-powersync-refoptimizer, moteur réel + optimiseur de référence réel (brief 4 T7, §3)
Chaque test lance tools/etm-powersync-ref-optimizer en sous-processus (démarrage borné, échec en clair, jamais bloqué). - identité Q6 (banc restreint, passe confort seule) sur unix:// et tcp:// : ce que les mocks reçoivent au cycle i sous l'optimiseur == au cycle i-1 sous le rule-based seul, 36 cycles ; - chaque mode de défaut → la ligne du tableau §7, sur le banc complet, et le retour ; notifications une par transition ; slow : update() ≤ 1 ms ; - optimiseur 1.0 accepté ; traces rejouées sur un nouvel optimiseur : 0 divergence, puis mode deviate : divergence, code 1 ; L4 et L2 gagnent ; token recopié : nulle part ; - catalogue des codes émissibles identique à celui du moteur. 8 fonctions, 33 / 0, 54 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f42dc709e3
commit
644741b0ef
@ -1,2 +1,2 @@
|
||||
TEMPLATE = subdirs
|
||||
SUBDIRS += charging decisions loadmodel optimizerclient simulation socketscheduler spotmarket
|
||||
SUBDIRS += charging decisions loadmodel optimizerclient refoptimizer simulation socketscheduler spotmarket
|
||||
|
||||
43
tests/auto/refoptimizer/refoptimizer.pro
Normal file
43
tests/auto/refoptimizer/refoptimizer.pro
Normal file
@ -0,0 +1,43 @@
|
||||
# Brief 4 T7 — tests d'intégration de fin de lot (§3) : l'arbitre RÉEL, le serveur nymea et les
|
||||
# mocks de la suite `simulation`, et l'optimiseur de référence RÉEL
|
||||
# (tools/etm-powersync-ref-optimizer), lancé en sous-processus Python par chaque test.
|
||||
#
|
||||
# Suite À PART, comme `socketscheduler` : 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-refoptimizer
|
||||
|
||||
DEFINES += ENERGY_SIMULATION
|
||||
# Racine des sources : le test y trouve l'optimiseur de référence et l'outil de rejeu. 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
|
||||
|
||||
SOURCES += \
|
||||
$$SIM/experience/energyexperienceenergymock.cpp \
|
||||
$$SIM/experience/energyjsonhandler.cpp \
|
||||
$$SIM/experience/energylogger.cpp \
|
||||
$$SIM/experience/energymanagermock.cpp \
|
||||
$$SIM/simulationbase.cpp \
|
||||
testrefoptimizer.cpp
|
||||
|
||||
target.path = $$[QT_INSTALL_PREFIX]/bin
|
||||
INSTALLS += target
|
||||
946
tests/auto/refoptimizer/testrefoptimizer.cpp
Normal file
946
tests/auto/refoptimizer/testrefoptimizer.cpp
Normal file
@ -0,0 +1,946 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
//
|
||||
// Brief 4 T7 — tests d'intégration de fin de lot (§3). L'arbitre RÉEL (serveur nymea, mocks de
|
||||
// `simulation`, cycles injectés en temps simulé) parle à l'optimiseur de référence RÉEL,
|
||||
// tools/etm-powersync-ref-optimizer, lancé en sous-processus Python par chaque test, sur
|
||||
// `unix://` et sur `tcp://` (boucle locale du réseau privé du test).
|
||||
//
|
||||
// Deux bancs :
|
||||
// - RESTREINT (décision Q6) : deux charges etmvariableload et une PAC SG-Ready, la passe confort
|
||||
// seule — ni obligation éco, ni dérogation, ni stockage, ni plafond de soutirage. C'est le
|
||||
// périmètre où l'optimiseur de référence peut reproduire le rule-based depuis le seul contexte
|
||||
// GetPlan (voir l'en-tête de l'outil pour ce que le contexte 1.1 ne porte pas) ;
|
||||
// - COMPLET : le même, plus un chauffe-eau relay-router à deux relais sous obligation éco et un
|
||||
// plafond de soutirage. Les modes de défaut y sont joués EN ENTIER : ils ne dépendent pas des
|
||||
// décisions, et le repli rule-based y travaille sur tout ce qu'il sait faire.
|
||||
//
|
||||
// Physique du banc : chaque charge consomme au cycle suivant ce qu'on lui a commandé, et le
|
||||
// compteur en tient compte (compteur = −surplus + Σ consommations). C'est le régime où le budget
|
||||
// du rule-based vaut exactement le surplus, et où un plan calculé sur la requête N, appliqué au
|
||||
// cycle N + 1, est comparable à la décision du rule-based au cycle N (modèle cache, T3).
|
||||
|
||||
#include "simulationbase.h"
|
||||
#include "experience/energymanagermock.h"
|
||||
#include "../common/etm-powersync-fakeoptimizer.h"
|
||||
|
||||
#include "energysettings.h"
|
||||
#include "etm/energyarbitrator.h"
|
||||
#include "etm/scheduler/socketscheduler.h"
|
||||
#include "etm/scheduler/optimizerstatus.h"
|
||||
#include "etm/scheduler/optimizertrace.h"
|
||||
#include "etm/types/decisionreason.h"
|
||||
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QLoggingCategory>
|
||||
#include <QProcess>
|
||||
#include <QRandomGenerator>
|
||||
#include <QSignalSpy>
|
||||
#include <QTemporaryDir>
|
||||
|
||||
#include <experiences/experiencemanager.h>
|
||||
#include <nymeasettings.h>
|
||||
|
||||
using namespace nymeaserver;
|
||||
|
||||
namespace {
|
||||
|
||||
//! Capture du journal le temps d'une portée (même principe que `socketscheduler`).
|
||||
class Journal
|
||||
{
|
||||
public:
|
||||
Journal() { s_courant = this; s_origine = qInstallMessageHandler(&Journal::collecte); }
|
||||
~Journal() { qInstallMessageHandler(s_origine); s_courant = nullptr; }
|
||||
QString texte() const { return m_texte; }
|
||||
int compte(const QString &fragment) const { return m_texte.count(fragment); }
|
||||
QStringList lignes(const QString &fragment) const
|
||||
{
|
||||
QStringList l;
|
||||
for (const QString &x : m_lignes)
|
||||
if (x.contains(fragment))
|
||||
l << x;
|
||||
return l;
|
||||
}
|
||||
|
||||
private:
|
||||
static void collecte(QtMsgType type, const QMessageLogContext &ctx, const QString &msg)
|
||||
{
|
||||
if (s_courant) {
|
||||
s_courant->m_texte += msg + QLatin1Char('\n');
|
||||
s_courant->m_lignes << QString::fromLatin1(ctx.category ? ctx.category : "?") + QStringLiteral(": ") + msg;
|
||||
}
|
||||
if (s_origine)
|
||||
s_origine(type, ctx, msg);
|
||||
}
|
||||
QString m_texte;
|
||||
QStringList m_lignes;
|
||||
static Journal *s_courant;
|
||||
static QtMessageHandler s_origine;
|
||||
};
|
||||
Journal *Journal::s_courant = nullptr;
|
||||
QtMessageHandler Journal::s_origine = nullptr;
|
||||
|
||||
QString racineSources()
|
||||
{
|
||||
const QByteArray env = qgetenv("ETM_POWERSYNC_SOURCE_DIR");
|
||||
return env.isEmpty() ? QStringLiteral(ETM_POWERSYNC_SOURCE_DIR) : QString::fromLocal8Bit(env);
|
||||
}
|
||||
|
||||
/*!
|
||||
* L'optimiseur de référence en sous-processus. Rien ne bloque sans borne : le démarrage attend
|
||||
* la ligne « ÉCOUTE <endpoint> » au plus \c timeoutMs, puis échoue en disant pourquoi (sortie
|
||||
* d'erreur du processus) ; l'arrêt envoie SIGTERM, puis SIGKILL après 3 s.
|
||||
*/
|
||||
class RefOptimizer
|
||||
{
|
||||
public:
|
||||
~RefOptimizer() { arreter(); }
|
||||
|
||||
static QString script()
|
||||
{
|
||||
return racineSources() + QStringLiteral("/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer");
|
||||
}
|
||||
|
||||
bool demarrer(const QString &ecoute, const QStringList &args, QString *erreur,
|
||||
const QString &interpreteur = QStringLiteral("python3"), int timeoutMs = 10000)
|
||||
{
|
||||
arreter();
|
||||
m_sortie.clear();
|
||||
m_erreurs.clear();
|
||||
m_endpoint.clear();
|
||||
m_p = new QProcess;
|
||||
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
|
||||
env.insert(QStringLiteral("LC_ALL"), QStringLiteral("C.UTF-8"));
|
||||
m_p->setProcessEnvironment(env);
|
||||
m_p->setProgram(interpreteur);
|
||||
m_p->setArguments(QStringList{QStringLiteral("-I"), QStringLiteral("-B"), script(),
|
||||
QStringLiteral("--listen"), ecoute} + args);
|
||||
m_p->start();
|
||||
QElapsedTimer t;
|
||||
t.start();
|
||||
if (!m_p->waitForStarted(qMin(timeoutMs, 5000))) {
|
||||
*erreur = QStringLiteral("l'interpréteur « %1 » ne démarre pas : %2").arg(interpreteur, m_p->errorString());
|
||||
arreter();
|
||||
return false;
|
||||
}
|
||||
while (t.elapsed() < timeoutMs) {
|
||||
m_p->waitForReadyRead(50);
|
||||
m_sortie += m_p->readAllStandardOutput();
|
||||
m_erreurs += m_p->readAllStandardError();
|
||||
const int i = m_sortie.indexOf(QByteArrayLiteral("ÉCOUTE "));
|
||||
const int fin = i < 0 ? -1 : m_sortie.indexOf('\n', i);
|
||||
if (fin > 0) {
|
||||
m_endpoint = QString::fromUtf8(m_sortie.mid(i + int(qstrlen("ÉCOUTE ")), fin - i - int(qstrlen("ÉCOUTE "))));
|
||||
return true;
|
||||
}
|
||||
if (m_p->state() == QProcess::NotRunning) {
|
||||
m_erreurs += m_p->readAllStandardError();
|
||||
*erreur = QStringLiteral("l'optimiseur de référence s'est arrêté au démarrage (code %1) : %2")
|
||||
.arg(m_p->exitCode()).arg(QString::fromUtf8(m_erreurs).trimmed().right(600));
|
||||
arreter();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
*erreur = QStringLiteral("l'optimiseur de référence n'écoute pas après %1 ms : %2")
|
||||
.arg(timeoutMs).arg(QString::fromUtf8(m_erreurs).trimmed().right(600));
|
||||
arreter();
|
||||
return false;
|
||||
}
|
||||
|
||||
void arreter()
|
||||
{
|
||||
if (!m_p)
|
||||
return;
|
||||
if (m_p->state() != QProcess::NotRunning) {
|
||||
m_p->terminate();
|
||||
if (!m_p->waitForFinished(3000)) {
|
||||
m_p->kill();
|
||||
m_p->waitForFinished(3000);
|
||||
}
|
||||
}
|
||||
m_erreurs += m_p->readAllStandardError();
|
||||
delete m_p;
|
||||
m_p = nullptr;
|
||||
}
|
||||
|
||||
QString endpoint() const { return m_endpoint; }
|
||||
QString journal() { if (m_p) m_erreurs += m_p->readAllStandardError(); return QString::fromUtf8(m_erreurs); }
|
||||
|
||||
private:
|
||||
QProcess *m_p = nullptr;
|
||||
QByteArray m_sortie;
|
||||
QByteArray m_erreurs;
|
||||
QString m_endpoint;
|
||||
};
|
||||
|
||||
//! Un processus Python qui rejoue des traces : borné, code de sortie et sorties rendus.
|
||||
int lancerRejeu(const QStringList &args, QString *sortie, int timeoutMs = 60000)
|
||||
{
|
||||
QProcess p;
|
||||
p.setProgram(QStringLiteral("python3"));
|
||||
p.setArguments(QStringList{QStringLiteral("-I"), QStringLiteral("-B"),
|
||||
racineSources() + QStringLiteral("/tools/etm-powersync-trace-replay")} + args);
|
||||
p.start();
|
||||
if (!p.waitForStarted(5000)) {
|
||||
*sortie = p.errorString();
|
||||
return -1;
|
||||
}
|
||||
if (!p.waitForFinished(timeoutMs)) {
|
||||
p.kill();
|
||||
p.waitForFinished(3000);
|
||||
*sortie = QStringLiteral("rejeu toujours en cours après %1 ms — tué").arg(timeoutMs);
|
||||
return -2;
|
||||
}
|
||||
*sortie = QString::fromUtf8(p.readAllStandardOutput() + p.readAllStandardError());
|
||||
return p.exitCode();
|
||||
}
|
||||
|
||||
QString cleEtat(const QVariantMap &etat)
|
||||
{
|
||||
return QString::fromUtf8(QJsonDocument::fromVariant(etat).toJson(QJsonDocument::Compact));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class TestRefOptimizer : public SimulationBase
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
enum class Banc { Restreint, Complet };
|
||||
enum class Transport { Unix, Tcp };
|
||||
|
||||
private slots:
|
||||
void testTheCatalogueMatchesTheEngine();
|
||||
void testAPythonThatDoesNotStartFailsClearly();
|
||||
void testNormalReproducesTheRules_data();
|
||||
void testNormalReproducesTheRules();
|
||||
void testEachFaultModeGivesItsRow_data();
|
||||
void testEachFaultModeGivesItsRow();
|
||||
void testAProtocol10OptimizerIsAccepted();
|
||||
void testTracesReplayWithZeroDivergence();
|
||||
void testSafetyWinsOverTheOptimizer();
|
||||
void testAnEchoedTokenLeaksNowhereEndToEnd();
|
||||
|
||||
private:
|
||||
void monter(Banc banc);
|
||||
//! Démarre \p ref et y pointe le moteur par la RPC de production ; attend la connexion si
|
||||
//! \p attendrePret.
|
||||
void brancher(RefOptimizer &ref, Transport transport, const QStringList &args, bool attendrePret = true);
|
||||
//! Un cycle à \p t sous le surplus courant ; attend le jugement de la réponse si une requête
|
||||
//! part et que \p attendreReponse. Puis les charges se mettent à consommer leur consigne.
|
||||
void cycle(const QDateTime &t, bool attendreReponse = true);
|
||||
//! Ce que les MOCKS ont reçu : consigne de chaque charge en watts, état SG-Ready des contacts.
|
||||
QVariantMap etatApplique() const;
|
||||
int etatPac() const;
|
||||
//! « stratégie|vivant|échecs|dernier code » publiés par GetOptimizerStatus.
|
||||
QString ligneStatut();
|
||||
QString triplet() const;
|
||||
static QStringList triplets(const QSignalSpy &spy);
|
||||
QVariantMap rpc(const QString &methode, const QVariantMap ¶ms = QVariantMap());
|
||||
void ecrireMode(const QString &mode);
|
||||
QString activerTraces();
|
||||
QByteArray toutesLesTraces(const QString &dossier) const;
|
||||
static QList<QVariantMap> traces(const QString &dossier);
|
||||
|
||||
EnergyArbitrator *m_arb = nullptr;
|
||||
Thing *m_compteur = nullptr;
|
||||
QString m_var1, m_var2;
|
||||
QUuid m_k1, m_k2, m_k3, m_k4;
|
||||
Banc m_banc = Banc::Restreint;
|
||||
double m_surplusW = 0;
|
||||
QTemporaryDir m_dir;
|
||||
QString m_fichierMode;
|
||||
int m_jugees = 0;
|
||||
QStringList m_codesEchec;
|
||||
qint64 m_updateMaxMs = 0;
|
||||
QMetaObject::Connection m_cJugee, m_cEchec;
|
||||
};
|
||||
|
||||
// --- Banc --------------------------------------------------------------------------------------
|
||||
|
||||
void TestRefOptimizer::monter(Banc banc)
|
||||
{
|
||||
m_banc = banc;
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-refoptimizer.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
cleanupTestCase();
|
||||
EnergySettings().remove("Optimizer/endpoint");
|
||||
QFile::remove(OptimizerClient::defaultTokenPath());
|
||||
for (const char *cle : {"Optimizer/trace", "Optimizer/traceRetentionDays", "Optimizer/traceMaxBytesPerDay"})
|
||||
EnergySettings().remove(cle);
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
m_arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(m_arb);
|
||||
QVERIFY(m_dir.isValid());
|
||||
m_fichierMode = m_dir.path() + "/mode";
|
||||
QFile::remove(m_fichierMode);
|
||||
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(27043).toString();
|
||||
m_var2 = addEtmVariableLoad(27044).toString();
|
||||
m_k1 = addPowerSwitch(0, 26661);
|
||||
m_k2 = addPowerSwitch(0, 26662);
|
||||
for (const QString &v : {m_var1, m_var2}) {
|
||||
Thing *t = tm->findConfiguredThing(ThingId(v));
|
||||
QVERIFY(t);
|
||||
t->setStateValue("currentPowerW", 0);
|
||||
}
|
||||
|
||||
// Passe confort seule : ni besoin, ni dérogation, ni stockage, ni plafond (limite de phase 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}}}}}});
|
||||
if (banc == Banc::Complet) {
|
||||
// Le reste de ce que sait le rule-based : un chauffe-eau à paliers sous obligation éco, et
|
||||
// un plafond de soutirage (disjoncteur 25 A).
|
||||
m_k3 = addPowerSwitch(1000, 26663);
|
||||
m_k4 = addPowerSwitch(1500, 26664);
|
||||
l.append(QVariantMap{{"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||||
{"priority", 4}, {"enabled", true},
|
||||
{"relays", QVariantList{QVariantMap{{"thingId", m_k3.toString()}, {"powerW", 1000}},
|
||||
QVariantMap{{"thingId", m_k4.toString()}, {"powerW", 1500}}}},
|
||||
{"needs", QVariantMap{{"minEnergyWhPerDay", 3000}, {"dailyDeadline", "18:00"}}}});
|
||||
m_arb->setPhasePowerLimit(25);
|
||||
}
|
||||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||||
.toMap().value("params").toMap().value("energyError").toString(),
|
||||
QString("EnergyErrorNoError"));
|
||||
m_surplusW = 0;
|
||||
m_jugees = 0;
|
||||
m_codesEchec.clear();
|
||||
m_updateMaxMs = 0;
|
||||
}
|
||||
|
||||
void TestRefOptimizer::brancher(RefOptimizer &ref, Transport transport, const QStringList &args, bool attendrePret)
|
||||
{
|
||||
static int n = 0;
|
||||
const QString ecoute = transport == Transport::Unix
|
||||
? QStringLiteral("unix://%1/ref-%2.sock").arg(m_dir.path()).arg(++n)
|
||||
: QStringLiteral("tcp://127.0.0.1:0");
|
||||
QString erreur;
|
||||
QVERIFY2(ref.demarrer(ecoute, args, &erreur), qUtf8Printable(erreur));
|
||||
QCOMPARE(rpc("SetOptimizerEndpoint", {{"endpoint", ref.endpoint()}}).value("energyError").toString(),
|
||||
QString("EnergyErrorNoError"));
|
||||
SocketScheduler *s = m_arb->socketScheduler();
|
||||
QVERIFY(s);
|
||||
disconnect(m_cJugee);
|
||||
disconnect(m_cEchec);
|
||||
m_cJugee = connect(s, &SocketScheduler::reponseJugee, this, [this](const QString &, const QString &) { ++m_jugees; });
|
||||
m_cEchec = connect(s, &SocketScheduler::echecRequete, this, [this](const QString &code) {
|
||||
++m_jugees;
|
||||
m_codesEchec.append(code);
|
||||
});
|
||||
if (attendrePret)
|
||||
QVERIFY2(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }, 10000),
|
||||
qUtf8Printable("le moteur ne se connecte pas à l'optimiseur de référence :\n" + ref.journal()));
|
||||
}
|
||||
|
||||
void TestRefOptimizer::cycle(const QDateTime &t, bool attendreReponse)
|
||||
{
|
||||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||||
// Le compteur voit ce que les charges consomment DÉJÀ (consignes du cycle précédent).
|
||||
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);
|
||||
if (m_banc == Banc::Complet)
|
||||
for (const auto &[k, w] : {std::pair{m_k3, 1000.0}, std::pair{m_k4, 1500.0}})
|
||||
if (tm->findConfiguredThing(k)->stateValue("power").toBool())
|
||||
consoW += w;
|
||||
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);
|
||||
QElapsedTimer chrono;
|
||||
chrono.start();
|
||||
m_arb->simulationCallUpdate(t);
|
||||
m_updateMaxMs = qMax(m_updateMaxMs, chrono.elapsed());
|
||||
if (envoi && attendreReponse)
|
||||
QVERIFY2(waitFor([&] { return m_jugees > avant; }, 15000), "réponse de l'optimiseur de référence jamais jugée");
|
||||
settle(30);
|
||||
// Physique : chaque charge consomme désormais sa consigne.
|
||||
for (const QString &v : {m_var1, m_var2}) {
|
||||
Thing *th = tm->findConfiguredThing(ThingId(v));
|
||||
th->setStateValue("currentPowerW", th->stateValue("powerSetpoint").toDouble());
|
||||
}
|
||||
}
|
||||
|
||||
int TestRefOptimizer::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 TestRefOptimizer::etatApplique() const
|
||||
{
|
||||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||||
QVariantMap e;
|
||||
e.insert("var1", tm->findConfiguredThing(ThingId(m_var1))->stateValue("powerSetpoint").toDouble());
|
||||
e.insert("var2", tm->findConfiguredThing(ThingId(m_var2))->stateValue("powerSetpoint").toDouble());
|
||||
e.insert("pac", etatPac());
|
||||
if (m_banc == Banc::Complet)
|
||||
e.insert("ecs", tm->findConfiguredThing(m_k3)->stateValue("power").toBool() * 1000
|
||||
+ tm->findConfiguredThing(m_k4)->stateValue("power").toBool() * 1500);
|
||||
return e;
|
||||
}
|
||||
|
||||
QString TestRefOptimizer::ligneStatut()
|
||||
{
|
||||
const QVariantMap st = rpc("GetOptimizerStatus");
|
||||
return QStringLiteral("%1|%2|%3|%4").arg(st.value("activeStrategy").toString(),
|
||||
st.value("optimizerAlive").toBool() ? "vivant" : "mort",
|
||||
st.value("consecutiveFailures").toString(),
|
||||
st.value("lastErrorCode").toString());
|
||||
}
|
||||
|
||||
QString TestRefOptimizer::triplet() const
|
||||
{
|
||||
const QVariantMap st = m_arb->optimizerStatus();
|
||||
return QStringLiteral("%1|%2|%3").arg(st.value("optimizerExpected").toBool() ? "attendu" : "-",
|
||||
st.value("optimizerAlive").toBool() ? "vivant" : "mort",
|
||||
st.value("activeStrategy").toString());
|
||||
}
|
||||
|
||||
QStringList TestRefOptimizer::triplets(const QSignalSpy &spy)
|
||||
{
|
||||
QStringList l;
|
||||
for (const QList<QVariant> &args : spy) {
|
||||
const QVariantMap st = args.first().toMap();
|
||||
l << QStringLiteral("%1|%2|%3").arg(st.value("optimizerExpected").toBool() ? "attendu" : "-",
|
||||
st.value("optimizerAlive").toBool() ? "vivant" : "mort",
|
||||
st.value("activeStrategy").toString());
|
||||
}
|
||||
return l;
|
||||
}
|
||||
|
||||
QVariantMap TestRefOptimizer::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();
|
||||
}
|
||||
|
||||
void TestRefOptimizer::ecrireMode(const QString &mode)
|
||||
{
|
||||
QFile f(m_fichierMode);
|
||||
QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Truncate));
|
||||
f.write(mode.toUtf8());
|
||||
}
|
||||
|
||||
QString TestRefOptimizer::activerTraces()
|
||||
{
|
||||
const QString d = m_dir.path() + QStringLiteral("/traces");
|
||||
QDir().mkpath(d);
|
||||
m_arb->setOptimizerTraceDirectory(d);
|
||||
const QVariantMap r = rpc("SetOptimizerTrace", {{"enabled", true}});
|
||||
[&] { QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorNoError")); }();
|
||||
return d;
|
||||
}
|
||||
|
||||
QByteArray TestRefOptimizer::toutesLesTraces(const QString &dossier) const
|
||||
{
|
||||
QByteArray tout;
|
||||
for (const QString &nom : QDir(dossier).entryList({"etm-powersync-optimizer-trace-*.ndjson"}, QDir::Files, QDir::Name)) {
|
||||
QFile f(QDir(dossier).filePath(nom));
|
||||
if (f.open(QIODevice::ReadOnly))
|
||||
tout += f.readAll();
|
||||
}
|
||||
return tout;
|
||||
}
|
||||
|
||||
QList<QVariantMap> TestRefOptimizer::traces(const QString &dossier)
|
||||
{
|
||||
QList<QVariantMap> l;
|
||||
for (const QString &nom : QDir(dossier).entryList({"etm-powersync-optimizer-trace-*.ndjson"}, QDir::Files, QDir::Name)) {
|
||||
QFile f(QDir(dossier).filePath(nom));
|
||||
if (!f.open(QIODevice::ReadOnly))
|
||||
continue;
|
||||
for (const QByteArray &ligne : f.readAll().split('\n'))
|
||||
if (!ligne.trimmed().isEmpty())
|
||||
l << QJsonDocument::fromJson(ligne).toVariant().toMap();
|
||||
}
|
||||
return l;
|
||||
}
|
||||
|
||||
// --- Tests -------------------------------------------------------------------------------------
|
||||
|
||||
void TestRefOptimizer::testTheCatalogueMatchesTheEngine()
|
||||
{
|
||||
// L'optimiseur de référence porte sa copie des codes émissibles et de leurs clés : elle doit
|
||||
// être celle du moteur, sinon il émettrait des codes ou des clés que le moteur supprime.
|
||||
QProcess p;
|
||||
p.start("python3", {"-I", "-B", RefOptimizer::script(), "--list-codes"});
|
||||
QVERIFY2(p.waitForFinished(15000), "--list-codes ne rend pas la main");
|
||||
QCOMPARE(p.exitCode(), 0);
|
||||
const QVariantMap ref = QJsonDocument::fromJson(p.readAllStandardOutput()).toVariant().toMap();
|
||||
QVariantMap moteur;
|
||||
for (const QString &code : DecisionCode::optimizerEmittableCodes())
|
||||
moteur.insert(code, DecisionCode::optimizerParamKeys(code));
|
||||
QCOMPARE(cleEtat(ref), cleEtat(moteur));
|
||||
for (const QString &code : ref.keys())
|
||||
QVERIFY(DecisionCode::knownCodes().contains(code));
|
||||
}
|
||||
|
||||
void TestRefOptimizer::testAPythonThatDoesNotStartFailsClearly()
|
||||
{
|
||||
// Un sous-processus qui ne démarre pas fait échouer le test EN CLAIR et vite — jamais bloqué.
|
||||
RefOptimizer ref;
|
||||
QString erreur;
|
||||
QElapsedTimer t;
|
||||
t.start();
|
||||
QVERIFY(!ref.demarrer("unix:///repertoire/absent/optimizer.sock", {}, &erreur));
|
||||
QVERIFY2(erreur.contains("s'est arrêté au démarrage"), qUtf8Printable(erreur));
|
||||
QVERIFY2(erreur.contains("FileNotFoundError") || erreur.contains("No such file"), qUtf8Printable(erreur));
|
||||
QVERIFY(!ref.demarrer("tcp://127.0.0.1:0", {}, &erreur, "python3-absent-du-systeme"));
|
||||
QVERIFY2(erreur.contains("ne démarre pas"), qUtf8Printable(erreur));
|
||||
// Un processus qui démarre mais n'écoute jamais : borné par le délai, puis tué.
|
||||
QVERIFY(!ref.demarrer("tcp://127.0.0.1:0", {"--mode", "mode-inconnu"}, &erreur));
|
||||
QVERIFY2(erreur.contains("invalid choice"), qUtf8Printable(erreur));
|
||||
QVERIFY2(t.elapsed() < 20000, qPrintable(QString::number(t.elapsed())));
|
||||
}
|
||||
|
||||
void TestRefOptimizer::testNormalReproducesTheRules_data()
|
||||
{
|
||||
QTest::addColumn<int>("transport");
|
||||
QTest::newRow("unix") << int(Transport::Unix);
|
||||
QTest::newRow("tcp") << int(Transport::Tcp);
|
||||
}
|
||||
|
||||
void TestRefOptimizer::testNormalReproducesTheRules()
|
||||
{
|
||||
// Décision Q6 — identité sur le banc RESTREINT, avec UN CYCLE DE DÉCALAGE (modèle cache) :
|
||||
// ce que les mocks ont reçu au cycle i sous l'optimiseur == ce qu'ils ont reçu au cycle i − 1
|
||||
// sous le rule-based seul, sur la même suite de surplus. Comparé : la consigne en watts des
|
||||
// deux charges variables, l'état SG-Ready des contacts de la PAC, et qui a commandé. Non
|
||||
// comparé : les motifs (l'optimiseur parle sous son niveau `optimizer`, Q3) et la comptabilité
|
||||
// publiée (recalculée depuis l'appliqué, Q4).
|
||||
QFETCH(int, transport);
|
||||
monter(Banc::Restreint);
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
// Paliers de 4 cycles : import, demi-charge, plancher non atteint, état 3, état 4, la zone
|
||||
// morte de l'hystérésis (reste en 4), retour, et import.
|
||||
QList<double> surplus;
|
||||
for (double s : {-1000.0, 1500.0, 2600.0, 4200.0, 6600.0, 9000.0, 8200.0, 3000.0, -500.0})
|
||||
for (int k = 0; k < 4; ++k)
|
||||
surplus << s;
|
||||
const QDateTime tA = utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0));
|
||||
const QDateTime tB = tA.addSecs(3 * 3600);
|
||||
|
||||
// Rule-based seul (aucun endpoint).
|
||||
QList<QVariantMap> a;
|
||||
for (int i = 0; i < surplus.count(); ++i) {
|
||||
m_surplusW = surplus.at(i);
|
||||
cycle(tA.addSecs(30 * i));
|
||||
QVERIFY(!m_arb->socketScheduler());
|
||||
a << etatApplique();
|
||||
}
|
||||
// Le banc revient à l'état de départ (tout à 0, PAC en 2) avant de brancher l'optimiseur.
|
||||
m_surplusW = -1000;
|
||||
for (int k = 0; k < 3; ++k)
|
||||
cycle(tB.addSecs(-120 + 30 * k));
|
||||
QCOMPARE(cleEtat(etatApplique()), cleEtat(a.first()));
|
||||
|
||||
RefOptimizer ref;
|
||||
brancher(ref, Transport(transport), {});
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
QList<QVariantMap> b;
|
||||
QList<bool> optimiseur;
|
||||
for (int i = 0; i < surplus.count(); ++i) {
|
||||
m_surplusW = surplus.at(i);
|
||||
cycle(tB.addSecs(30 * i));
|
||||
b << etatApplique();
|
||||
optimiseur << m_arb->socketScheduler()->optimiseurAuDernierCycle();
|
||||
}
|
||||
|
||||
// Cycle 0 : aucun plan en cache, le rule-based décide — sur le même contexte que A[0].
|
||||
QVERIFY(!optimiseur.first());
|
||||
QCOMPARE(cleEtat(b.first()), cleEtat(a.first()));
|
||||
QStringList ecarts;
|
||||
for (int i = 1; i < surplus.count(); ++i) {
|
||||
if (!optimiseur.at(i))
|
||||
ecarts << QStringLiteral("cycle %1 : le rule-based a commandé").arg(i);
|
||||
if (cleEtat(b.at(i)) != cleEtat(a.at(i - 1)))
|
||||
ecarts << QStringLiteral("cycle %1 (surplus %2) : optimiseur %3, rule-based au cycle %4 %5")
|
||||
.arg(i).arg(surplus.at(i - 1)).arg(cleEtat(b.at(i))).arg(i - 1).arg(cleEtat(a.at(i - 1)));
|
||||
}
|
||||
QVERIFY2(ecarts.isEmpty(), qUtf8Printable(ecarts.join('\n')));
|
||||
// Régime stable : à la fin de chaque palier, les deux runs coïncident aussi SANS décalage.
|
||||
for (int i = 3; i < surplus.count(); i += 4) {
|
||||
QCOMPARE(cleEtat(a.at(i)), cleEtat(a.at(i - 1)));
|
||||
QCOMPARE(cleEtat(b.at(i)), cleEtat(a.at(i)));
|
||||
}
|
||||
// La suite a bien traversé les décisions qu'elle prétend éprouver.
|
||||
QSet<QString> vus;
|
||||
for (const QVariantMap &e : std::as_const(a))
|
||||
vus.insert(cleEtat(e));
|
||||
QVERIFY2(vus.count() >= 7, qPrintable(QString::number(vus.count())));
|
||||
QCOMPARE(a.at(23).value("pac").toInt(), 4); // 9000 W → forcé
|
||||
QCOMPARE(a.at(25).value("pac").toInt(), 4); // 8200 W : zone morte, reste en 4
|
||||
QCOMPARE(a.at(11).value("var2").toDouble(), 0.0); // 600 W sous le plancher de 800 W
|
||||
qInfo().noquote() << "[identité]" << surplus.count() << "cycles, update() max" << m_updateMaxMs << "ms";
|
||||
}
|
||||
|
||||
void TestRefOptimizer::testEachFaultModeGivesItsRow_data()
|
||||
{
|
||||
// Chaque ligne : le mode, s'il faut redémarrer l'optimiseur (handshake), puis la ligne
|
||||
// « stratégie|vivant|échecs|dernier code » après chacun des deux cycles en défaut et des deux
|
||||
// cycles revenus en `normal`, et les notifications OptimizerStatusChanged, une par transition.
|
||||
QTest::addColumn<QString>("mode");
|
||||
QTest::addColumn<bool>("redemarrer");
|
||||
QTest::addColumn<QStringList>("lignes");
|
||||
QTest::addColumn<QStringList>("notifications");
|
||||
|
||||
const QStringList abstention{"optimizer|vivant|0|", "rules|vivant|0|", "rules|vivant|0|", "optimizer|vivant|0|"};
|
||||
const QStringList unePause{"attendu|vivant|rules", "attendu|vivant|optimizer"};
|
||||
auto invalide = [](const QString &code) {
|
||||
return QStringList{"optimizer|vivant|1|" + code, "rules|mort|2|" + code, "rules|vivant|0|" + code,
|
||||
"optimizer|vivant|0|" + code};
|
||||
};
|
||||
const QStringList mortPuisVivant{"attendu|vivant|rules", "attendu|mort|rules", "attendu|vivant|rules",
|
||||
"attendu|vivant|optimizer"};
|
||||
QTest::newRow("abstain") << "abstain" << false << abstention << unePause;
|
||||
QTest::newRow("expired") << "expired" << false << abstention << unePause;
|
||||
QTest::newRow("invalid") << "invalid" << false << invalide("malformed-plan") << mortPuisVivant;
|
||||
QTest::newRow("unknown-load") << "unknown-load" << false << invalide("unknown-load") << mortPuisVivant;
|
||||
QTest::newRow("forbidden-kind") << "forbidden-kind" << false << invalide("kind-mismatch") << mortPuisVivant;
|
||||
QTest::newRow("missing-load") << "missing-load" << false << invalide("incomplete-slot") << mortPuisVivant;
|
||||
// slow (12 s > 10 s) : le premier délai dépassé garde le plan en cache, le second tue.
|
||||
QTest::newRow("slow") << "slow" << false
|
||||
<< QStringList{"optimizer|vivant|1|timeout", "rules|mort|2|timeout",
|
||||
"rules|vivant|0|timeout", "optimizer|vivant|0|timeout"}
|
||||
<< QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"};
|
||||
// drop : la connexion fermée tue tout de suite (§7 « socket fermé »), l'échec de la requête
|
||||
// en vol est compté (`disconnected`). Lu juste après le cycle, avant la reconnexion (1 s) ;
|
||||
// la TENTATIVE de reconnexion réécrit ensuite le dernier code avec celui du client
|
||||
// (`connection-lost`) — constaté ici, décrit au rapport T7.
|
||||
QTest::newRow("drop") << "drop" << false
|
||||
<< QStringList{"rules|mort|1|disconnected", "rules|mort|2|disconnected",
|
||||
"rules|vivant|0|connection-lost", "optimizer|vivant|0|connection-lost"}
|
||||
<< QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"};
|
||||
// wrong-major : au handshake suivant (optimiseur redémarré) ; aucune requête ne part.
|
||||
QTest::newRow("wrong-major") << "wrong-major" << true
|
||||
<< QStringList{"rules|mort|0|major-mismatch", "rules|mort|0|major-mismatch",
|
||||
"rules|vivant|0|major-mismatch", "optimizer|vivant|0|major-mismatch"}
|
||||
<< QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"};
|
||||
}
|
||||
|
||||
void TestRefOptimizer::testEachFaultModeGivesItsRow()
|
||||
{
|
||||
QFETCH(QString, mode);
|
||||
QFETCH(bool, redemarrer);
|
||||
QFETCH(QStringList, lignes);
|
||||
QFETCH(QStringList, notifications);
|
||||
monter(Banc::Complet);
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
ecrireMode("normal");
|
||||
RefOptimizer ref;
|
||||
const Transport transport = mode == "drop" || mode == "wrong-major" ? Transport::Tcp : Transport::Unix;
|
||||
brancher(ref, transport, {"--mode-file", m_fichierMode});
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
SocketScheduler *s = m_arb->socketScheduler();
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0));
|
||||
m_surplusW = 6600;
|
||||
cycle(t0);
|
||||
cycle(t0.addSecs(30));
|
||||
QCOMPARE(ligneStatut(), QString("optimizer|vivant|0|"));
|
||||
QCOMPARE(triplet(), QString("attendu|vivant|optimizer"));
|
||||
const QString endpoint = ref.endpoint();
|
||||
|
||||
QSignalSpy spy(m_arb, &EnergyArbitrator::optimizerStatusChanged);
|
||||
QStringList obtenu;
|
||||
ecrireMode(mode);
|
||||
if (redemarrer) {
|
||||
ref.arreter();
|
||||
QString erreur;
|
||||
QVERIFY2(ref.demarrer(endpoint, {"--mode-file", m_fichierMode}, &erreur), qUtf8Printable(erreur));
|
||||
QVERIFY2(waitFor([s] { return s->client()->lastErrorCode() == QLatin1String("major-mismatch"); }, 10000),
|
||||
qUtf8Printable(s->client()->lastErrorCode()));
|
||||
}
|
||||
m_updateMaxMs = 0;
|
||||
for (int k = 0; k < 4; ++k) {
|
||||
if (k == 2) {
|
||||
ecrireMode("normal");
|
||||
if (redemarrer) {
|
||||
ref.arreter();
|
||||
QString erreur;
|
||||
QVERIFY2(ref.demarrer(endpoint, {"--mode-file", m_fichierMode}, &erreur), qUtf8Printable(erreur));
|
||||
}
|
||||
}
|
||||
// Après une fermeture, le client se reconnecte au backoff (1 s, 2 s…) : on l'attend.
|
||||
if (k >= 1 && (mode == "drop" || (redemarrer && k >= 2)))
|
||||
QVERIFY2(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }, 15000),
|
||||
qUtf8Printable(s->client()->lastErrorCode()));
|
||||
const QDateTime t = t0.addSecs(60 + 30 * k);
|
||||
if (mode == "slow" && k < 2) {
|
||||
const int echecs = m_codesEchec.count();
|
||||
cycle(t, false);
|
||||
QVERIFY2(waitFor([&] { return m_codesEchec.count() > echecs; }, 15000), "aucun délai dépassé");
|
||||
QCOMPARE(m_codesEchec.last(), QString("timeout"));
|
||||
} else {
|
||||
cycle(t);
|
||||
}
|
||||
obtenu << ligneStatut();
|
||||
}
|
||||
QCOMPARE(obtenu, lignes);
|
||||
QCOMPARE(triplets(spy), notifications);
|
||||
// Le plan publié dit qu'aucun plan d'optimiseur ne commande, et pourquoi — au cycle repli.
|
||||
if (mode == "slow")
|
||||
QVERIFY2(m_updateMaxMs < 250, qPrintable(QStringLiteral("update() a duré %1 ms").arg(m_updateMaxMs)));
|
||||
qInfo().noquote() << "[mode]" << mode << obtenu.join(" ; ") << "— update() max" << m_updateMaxMs << "ms";
|
||||
}
|
||||
|
||||
void TestRefOptimizer::testAProtocol10OptimizerIsAccepted()
|
||||
{
|
||||
// Un optimiseur 1.0 — handshake sans capabilities, plan sans validUntil, sans code ni
|
||||
// paramètres de motif, sans diagnostics — reste accepté (§9, champs 1.1 optionnels).
|
||||
monter(Banc::Restreint);
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
RefOptimizer ref;
|
||||
brancher(ref, Transport::Unix, {"--mode", "protocol-1.0"});
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0));
|
||||
m_surplusW = 6600;
|
||||
for (int k = 0; k < 3; ++k)
|
||||
cycle(t0.addSecs(30 * k));
|
||||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||||
const QVariantMap st = rpc("GetOptimizerStatus");
|
||||
QCOMPARE(st.value("optimizerAlive").toBool(), true);
|
||||
QCOMPARE(st.value("protocolVersion").toString(), QString("1.0"));
|
||||
QVERIFY(!st.contains("capabilities"));
|
||||
QCOMPARE(cleEtat(etatApplique()), cleEtat(QVariantMap{{"var1", 2000.0}, {"var2", 3000.0}, {"pac", 3}}));
|
||||
// Sans reasonCode, le motif vers l'app est le code générique, sans paramètres.
|
||||
for (const QVariant &v : m_arb->loadTelemetry().value("loads").toList())
|
||||
QCOMPARE(v.toMap().value("decision").toMap().value("code").toString(), QString(DecisionCode::OptimizerDecision));
|
||||
const QVariantMap plan = rpc("GetPlan");
|
||||
QCOMPARE(plan.value("strategy").toString(), QString("optimizer"));
|
||||
}
|
||||
|
||||
void TestRefOptimizer::testTracesReplayWithZeroDivergence()
|
||||
{
|
||||
// Traces de bout en bout : des cycles sous le rule-based seul, puis sous l'optimiseur de
|
||||
// référence ; les traces sont rejouées par tools/etm-powersync-trace-replay sur un NOUVEL
|
||||
// optimiseur de référence → zéro divergence (les lignes du rule-based comprises : sur le banc
|
||||
// restreint, la référence décide comme lui, et sans décalage puisque chaque requête est
|
||||
// rejouée sur son propre contexte). Puis sur un optimiseur qui dévie → divergence, code 1.
|
||||
monter(Banc::Restreint);
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
const QString d = activerTraces();
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0));
|
||||
const QList<double> surplus{1500, 2600, 4200, 6600, 9000, 8200, 3000, -500};
|
||||
int i = 0;
|
||||
for (double s : surplus) {
|
||||
m_surplusW = s;
|
||||
cycle(t0.addSecs(30 * i++));
|
||||
}
|
||||
{
|
||||
RefOptimizer ref;
|
||||
brancher(ref, Transport::Unix, {});
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
for (double s : surplus) {
|
||||
m_surplusW = s;
|
||||
cycle(t0.addSecs(30 * i++));
|
||||
}
|
||||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||||
QCOMPARE(rpc("SetOptimizerTrace", {{"enabled", false}}).value("energyError").toString(),
|
||||
QString("EnergyErrorNoError")); // la ligne en attente est terminée et écrite
|
||||
}
|
||||
const QList<QVariantMap> lignes = traces(d);
|
||||
QCOMPARE(lignes.count(), 2 * surplus.count());
|
||||
int regles = 0, optimiseur = 0;
|
||||
for (const QVariantMap &l : lignes) {
|
||||
regles += l.value("strategy").toString() == "rules";
|
||||
optimiseur += l.value("strategy").toString() == "optimizer";
|
||||
}
|
||||
QCOMPARE(regles, surplus.count() + 1); // le premier cycle sous endpoint : pas de plan en cache
|
||||
QCOMPARE(optimiseur, surplus.count() - 1);
|
||||
const QStringList fichiers = QDir(d).entryList({"etm-powersync-optimizer-trace-*.ndjson"}, QDir::Files);
|
||||
QCOMPARE(fichiers.count(), 1);
|
||||
const QString trace = QDir(d).filePath(fichiers.first());
|
||||
|
||||
RefOptimizer neuf;
|
||||
QString erreur;
|
||||
QVERIFY2(neuf.demarrer(QStringLiteral("unix://%1/rejeu.sock").arg(m_dir.path()), {}, &erreur), qUtf8Printable(erreur));
|
||||
QString sortie;
|
||||
int code = lancerRejeu({trace, "--endpoint", neuf.endpoint(), "--fail-on-divergence"}, &sortie);
|
||||
QVERIFY2(code == 0, qUtf8Printable(QStringLiteral("code %1\n%2").arg(code).arg(sortie)));
|
||||
QVERIFY2(sortie.contains(QStringLiteral("requêtes rejouées : %1").arg(lignes.count())), qUtf8Printable(sortie));
|
||||
QVERIFY2(sortie.contains("cycles dont les actions diffèrent du plan enregistré : 0"), qUtf8Printable(sortie));
|
||||
QVERIFY2(sortie.contains("cycles non comparables (réponse enregistrée en erreur, ou rejouée en erreur) : 0"),
|
||||
qUtf8Printable(sortie));
|
||||
neuf.arreter();
|
||||
qInfo().noquote() << "[rejeu normal]" << sortie;
|
||||
|
||||
RefOptimizer devie;
|
||||
QVERIFY2(devie.demarrer(QStringLiteral("unix://%1/devie.sock").arg(m_dir.path()), {"--mode", "deviate"}, &erreur),
|
||||
qUtf8Printable(erreur));
|
||||
code = lancerRejeu({trace, "--endpoint", devie.endpoint(), "--fail-on-divergence"}, &sortie);
|
||||
QVERIFY2(code == 1, qUtf8Printable(QStringLiteral("code %1\n%2").arg(code).arg(sortie)));
|
||||
QVERIFY2(!sortie.contains("cycles dont les actions diffèrent du plan enregistré : 0\n"), qUtf8Printable(sortie));
|
||||
qInfo().noquote() << "[rejeu dévié]" << sortie;
|
||||
}
|
||||
|
||||
void TestRefOptimizer::testSafetyWinsOverTheOptimizer()
|
||||
{
|
||||
// L4 et L2 pendant que l'optimiseur de référence commande : la sécurité gagne, et `executed`
|
||||
// (trace) le montre.
|
||||
monter(Banc::Restreint);
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
m_arb->setPhasePowerLimit(25);
|
||||
const QString d = activerTraces();
|
||||
RefOptimizer ref;
|
||||
brancher(ref, Transport::Unix, {});
|
||||
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));
|
||||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||||
const QVariantMap avant = etatApplique();
|
||||
QCOMPARE(cleEtat(avant), cleEtat(QVariantMap{{"var1", 2000.0}, {"var2", 3000.0}, {"pac", 4}}));
|
||||
|
||||
// L4 — surcharge sur la phase A : l'optimiseur commande toujours le même plan, L4 déleste.
|
||||
m_compteur->setStateValue("currentPhaseA", 32);
|
||||
m_surplusW = -2000; // la maison importe : le plan en cache, lui, ne le sait pas encore
|
||||
cycle(t0.addSecs(90));
|
||||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||||
const QVariantMap apres = etatApplique();
|
||||
QVERIFY2(apres.value("var1").toDouble() + apres.value("var2").toDouble()
|
||||
< avant.value("var1").toDouble() + avant.value("var2").toDouble()
|
||||
|| apres.value("pac").toInt() < avant.value("pac").toInt(),
|
||||
qUtf8Printable(cleEtat(apres)));
|
||||
QList<QVariantMap> l = traces(d);
|
||||
QVERIFY(waitFor([&] { l = traces(d); return l.count() >= 4; }, 5000));
|
||||
const QVariantMap ligneL4 = l.at(3);
|
||||
QCOMPARE(ligneL4.value("strategy").toString(), QString("optimizer"));
|
||||
bool delestee = false;
|
||||
for (const QVariant &e : ligneL4.value("executed").toList())
|
||||
delestee |= e.toMap().value("shed").toBool();
|
||||
QVERIFY2(delestee, qUtf8Printable(QJsonDocument::fromVariant(ligneL4.value("executed")).toJson()));
|
||||
|
||||
// L2 — compteur muet > 90 s : plus aucune stratégie, consignes de repli, même avec un
|
||||
// optimiseur vivant et un plan en cache.
|
||||
m_compteur->setStateValue("currentPhaseA", 0);
|
||||
m_arb->evaluateMeterFreshness(t0.addSecs(300));
|
||||
QVERIFY(m_arb->degradedMode());
|
||||
m_arb->simulationCallUpdate(t0.addSecs(330));
|
||||
settle(200);
|
||||
QCOMPARE(rpc("GetOptimizerStatus").value("activeStrategy").toString(), QString("none"));
|
||||
QCOMPARE(cleEtat(etatApplique()), cleEtat(QVariantMap{{"var1", 0.0}, {"var2", 0.0}, {"pac", 2}}));
|
||||
QVERIFY(waitFor([&] { l = traces(d); return !l.isEmpty() && l.last().value("strategy").toString() == "none"; }, 5000));
|
||||
QCOMPARE(l.last().value("executed").toList().count(), 0);
|
||||
QVERIFY(!l.last().contains("request"));
|
||||
|
||||
// Le compteur reparle : l'optimiseur reprend la main.
|
||||
m_surplusW = 9000;
|
||||
for (int k = 0; k < 3; ++k)
|
||||
cycle(t0.addSecs(360 + 30 * k));
|
||||
QVERIFY(!m_arb->degradedMode());
|
||||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||||
QCOMPARE(triplet(), QString("attendu|vivant|optimizer"));
|
||||
}
|
||||
|
||||
void TestRefOptimizer::testAnEchoedTokenLeaksNowhereEndToEnd()
|
||||
{
|
||||
// Correctif A, de bout en bout : l'optimiseur de référence, en `echo-token`, recopie le token
|
||||
// (généré ici, avec " et \) dans `reason` et `planId`. Le secret ne sort par aucune ligne de
|
||||
// journal (hors la trace brute ENTRANTE de nymea, qui porte la pose du token), aucune trace,
|
||||
// aucune réponse ni notification RPC.
|
||||
monter(Banc::Restreint);
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
const QString secret = QStringLiteral("T7-%1\"bout\\en-bout").arg(QRandomGenerator::global()->generate64(), 0, 16);
|
||||
QCOMPARE(rpc("SetOptimizerToken", {{"token", secret}}).value("energyError").toString(), QString("EnergyErrorNoError"));
|
||||
const QString fichierToken = m_dir.path() + "/token";
|
||||
{
|
||||
QFile f(fichierToken);
|
||||
QVERIFY(f.open(QIODevice::WriteOnly));
|
||||
QVERIFY(f.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner));
|
||||
f.write(secret.toUtf8());
|
||||
}
|
||||
const QString d = activerTraces();
|
||||
QSignalSpy paquets(m_mockTcpServer, &MockTcpServer::outgoingData);
|
||||
QLoggingCategory::setFilterRules(QStringLiteral("*.debug=true"));
|
||||
Journal j;
|
||||
RefOptimizer ref;
|
||||
brancher(ref, Transport::Tcp, {"--mode", "echo-token", "--token-file", fichierToken});
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0));
|
||||
m_surplusW = 6600;
|
||||
for (int k = 0; k < 3; ++k)
|
||||
cycle(t0.addSecs(30 * k));
|
||||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); // le token était le bon
|
||||
rpc("GetOptimizerStatus");
|
||||
rpc("GetPlan");
|
||||
injectAndWait("NymeaEnergy.GetLoadTelemetry");
|
||||
QCOMPARE(rpc("SetOptimizerTrace", {{"enabled", false}}).value("energyError").toString(), QString("EnergyErrorNoError"));
|
||||
|
||||
const QByteArray echappe = QJsonDocument(QJsonArray{secret}).toJson(QJsonDocument::Compact).mid(2).chopped(2);
|
||||
const QStringList fragments{secret, QString::fromUtf8(echappe), secret.section('"', 0, 0)};
|
||||
QVERIFY2(j.texte().contains("[token masqué]"), "l'écho n'a pas été vu : le test ne prouverait rien");
|
||||
QCOMPARE(j.compte("recopie le token"), 1);
|
||||
for (const QString &f : fragments) {
|
||||
QStringList fuites;
|
||||
for (const QString &x : j.lignes(f))
|
||||
if (!x.startsWith(QLatin1String("JsonRpcTraffic: ")))
|
||||
fuites << x.left(300);
|
||||
QVERIFY2(fuites.isEmpty(), qUtf8Printable(fuites.join('\n')));
|
||||
}
|
||||
QVERIFY(paquets.count() >= 3);
|
||||
for (const QList<QVariant> &p : paquets)
|
||||
for (const QString &f : fragments)
|
||||
QVERIFY2(!p.last().toByteArray().contains(f.toUtf8()), p.last().toByteArray().left(300).constData());
|
||||
const QByteArray tout = toutesLesTraces(d);
|
||||
QVERIFY(tout.contains("[token masqué]"));
|
||||
for (const QString &f : fragments)
|
||||
QVERIFY2(!tout.contains(f.toUtf8()), qUtf8Printable(f));
|
||||
|
||||
QFile::remove(OptimizerClient::defaultTokenPath());
|
||||
QLoggingCategory::setFilterRules(QStringLiteral(
|
||||
"*.debug=false\nApplication.debug=true\nSimulation.debug=true\nExperiences.debug=true\n"
|
||||
"NymeaEnergy.debug=true\nEnergyMocks.debug=true\nDBus.warning=false"));
|
||||
}
|
||||
|
||||
QTEST_MAIN(TestRefOptimizer)
|
||||
#include "testrefoptimizer.moc"
|
||||
Loading…
x
Reference in New Issue
Block a user