Contre-épreuve T4 : sans le contrôle d'identifiant du traceur, aucun test ne cassait — le SocketScheduler écarte déjà les réponses obsolètes en amont. Ce test unitaire éprouve la seconde garde seule (autre requête, aucune requête, réponse en double). socketscheduler : 34 fonctions, 117 / 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1949 lines
100 KiB
C++
1949 lines
100 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||
//
|
||
// Brief 4 T3 — la stratégie optimiseur dans l'arbitre RÉEL : endpoint lu dans energy.conf, faux
|
||
// optimiseur du processus, cycles injectés (temps simulé pour l'arbitre ; seules les E/S de
|
||
// socket passent par la boucle d'événements). Chaque test monte son banc : un compteur, un ECS
|
||
// à un relais de 1000 W (rang 1), un routeur etmvariableload de 3000 W (rang 2).
|
||
|
||
#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/publishedplan.h"
|
||
#include "etm/scheduler/optimizertrace.h"
|
||
#include "etm/types/decisionreason.h"
|
||
|
||
#include <QFileInfo>
|
||
#include <QJsonArray>
|
||
#include <QJsonDocument>
|
||
#include <QLoggingCategory>
|
||
#include <QSignalSpy>
|
||
#include <QTemporaryDir>
|
||
|
||
#include <experiences/experiencemanager.h>
|
||
#include <nymeasettings.h>
|
||
|
||
#include <unistd.h>
|
||
|
||
using namespace nymeaserver;
|
||
|
||
namespace {
|
||
|
||
const QString kTexteLibre = QStringLiteral("Texte LIBRE de l'optimiseur, qui ne doit jamais sortir");
|
||
|
||
//! Capture du journal le temps d'une portée (même principe que la suite simulation).
|
||
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); }
|
||
//! Les lignes contenant \p fragment, préfixées de leur catégorie (« JsonRpcTraffic: … »).
|
||
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;
|
||
|
||
QHash<QString, QVariantMap> telemetrieParId(const QVariantMap &snap)
|
||
{
|
||
QHash<QString, QVariantMap> h;
|
||
for (const QVariant &v : snap.value("loads").toList())
|
||
h.insert(v.toMap().value("loadId").toString(), v.toMap());
|
||
return h;
|
||
}
|
||
|
||
QVariantMap action(const QString &id, const QVariantMap &champs)
|
||
{
|
||
QVariantMap a{{"loadId", id}, {"kind", "setpoint"}, {"reason", kTexteLibre}};
|
||
for (auto it = champs.constBegin(); it != champs.constEnd(); ++it)
|
||
a.insert(it.key(), it.value());
|
||
return a;
|
||
}
|
||
|
||
} // namespace
|
||
|
||
class TestSocketScheduler : public SimulationBase
|
||
{
|
||
Q_OBJECT
|
||
|
||
private slots:
|
||
void testNoEndpointNoObject();
|
||
void testValidPlanIsAppliedOnTheNextCycle_data();
|
||
void testValidPlanIsAppliedOnTheNextCycle();
|
||
void testRefusedPlanFallsBackToRules_data();
|
||
void testRefusedPlanFallsBackToRules();
|
||
void testOutOfBoundsActionIsClamped();
|
||
void testUnavailableLoadKeepsTheShadowAction();
|
||
void testReasonIsFilteredOnTheWayToTheApp();
|
||
void testL4ShedsALoadTheOptimizerCommands();
|
||
void testDegradedModeSuspendsPlanning();
|
||
void testBudgetAndDrawAreRecomputedFromTheApplied();
|
||
void testTheTriggerNamesTheCause();
|
||
void testANewInvalidPlanReplacesTheCache();
|
||
void testConnectionLossDropsThePlan();
|
||
void testThePlanOfAnOptimizerBuysNothingEco();
|
||
void testPvIsReadFromAnyProducer();
|
||
// Brief 4 T4 — machine de repli §7, capacités, réglage par RPC, plan publié
|
||
void testFallbackMachineIsPure();
|
||
void testStatusWithoutEndpoint();
|
||
void testStatusFollowsTheFallbackTable_data();
|
||
void testStatusFollowsTheFallbackTable();
|
||
void testEachTransitionNotifiesOnce();
|
||
void testEndpointIsSetByRpcAndAppliedHot();
|
||
void testTokenIsWriteOnlyAndNeverLeaks();
|
||
void testGetPlanPublishesThePlanInForce();
|
||
void testGetPlanShowsTheAppliedPowerUnderALock();
|
||
void testUpdateLearnedIsLoggedOnceAndIgnored();
|
||
// Brief 4 T6 — traces de décision (annexe D)
|
||
void testTraceIsOffByDefault();
|
||
void testTraceSettingByRpc();
|
||
void testTraceLineUnderRulesAndInL2();
|
||
void testTraceLineUnderTheOptimizer();
|
||
void testTraceAbstentionFailureAndNoResponse();
|
||
void testTraceRotatesAtLocalMidnightAndPurges();
|
||
void testTraceCapStopsUntilTheNextDay();
|
||
void testTraceWriteFailureChangesNoDecision();
|
||
void testTokenIsInNoTrace();
|
||
void testTraceLineSize();
|
||
void testTracerPairsTheResponseByRequestId();
|
||
|
||
private:
|
||
//! Banc commun ; \p avecOptimiseur faux = rien n'est configuré.
|
||
void monter(bool avecOptimiseur = true, FakeOptimizer::Transport transport = FakeOptimizer::Unix);
|
||
//! Un cycle à \p t, compteur à \p compteurW (négatif = export) ; attend le jugement de la
|
||
//! réponse si le faux optimiseur doit répondre.
|
||
void cycle(const QDateTime &t, double compteurW);
|
||
//! Plan d'un créneau couvrant [ts − 60 s, ts + 15 min[, valable 30 min, depuis la requête.
|
||
static QVariantMap plan(const QVariantMap &requete, const QVariantList &actions,
|
||
int validiteS = 1800);
|
||
QHash<QString, QVariantMap> telemetrie() const { return telemetrieParId(m_arb->loadTelemetry()); }
|
||
|
||
EnergyArbitrator *m_arb = nullptr;
|
||
Thing *m_compteur = nullptr;
|
||
QUuid m_relais;
|
||
QString m_var;
|
||
QTemporaryDir m_dir;
|
||
FakeOptimizer *m_serveur = nullptr;
|
||
QJsonObject m_handshake;
|
||
QList<QVariantMap> m_requetes;
|
||
//! Ce que le faux optimiseur répond à un GetPlan ; vide = il ne répond pas.
|
||
std::function<QVariantMap(const QVariantMap &)> m_repondre;
|
||
int m_jugees = 0;
|
||
QStringList m_codesJuges;
|
||
QStringList m_codesEchec; //!< echecRequete() reçus (T4)
|
||
QList<QJsonObject> m_handshakes; //!< params de chaque Handshake reçu (token)
|
||
//! Statut publié par l'arbitre, en trois mots : « attendu|vivant|stratégie ».
|
||
QString triplet() const;
|
||
//! Appel RPC ; rend `params` de la réponse.
|
||
QVariantMap rpc(const QString &methode, const QVariantMap ¶ms = QVariantMap());
|
||
//! Plan valide sur les deux charges (ECS 1000 W, routeur 500 W), valable \p validiteS.
|
||
std::function<QVariantMap(const QVariantMap &)> planValide(int validiteS = 1800);
|
||
//! Optimiseur monté, plan valide reçu puis appliqué : « attendu|vivant|optimizer » à t0 + 60 s.
|
||
void amenerAOptimiseur(const QDateTime &t0, int validiteS = 1800);
|
||
//! Triplets publiés par les notifications \c OptimizerStatusChanged de l'arbitre.
|
||
static QStringList triplets(const QSignalSpy &spy);
|
||
//! T6 : active les traces dans un répertoire temporaire propre ; rend son chemin.
|
||
QString activerTraces();
|
||
//! T6 : les lignes de trace de \p dossier, tous fichiers, dans l'ordre ; attend d'en avoir
|
||
//! au moins \p n (l'écrivain est asynchrone).
|
||
static QList<QVariantMap> traces(const QString &dossier, int n = 0);
|
||
//! T6 : les fichiers de trace de \p dossier, triés.
|
||
static QStringList fichiersTrace(const QString &dossier);
|
||
//! T6 : copie les traces de \p dossier sous \c $ETM_POWERSYNC_TRACE_EXPORT (s'il est posé),
|
||
//! préfixées de \p nom — la matière du test de \c tools/etm-powersync-trace-replay.
|
||
static void exporterTraces(const QString &dossier, const QString &nom);
|
||
QTemporaryDir m_dossierTraces;
|
||
};
|
||
|
||
QString TestSocketScheduler::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());
|
||
}
|
||
|
||
QVariantMap TestSocketScheduler::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();
|
||
}
|
||
|
||
std::function<QVariantMap(const QVariantMap &)> TestSocketScheduler::planValide(int validiteS)
|
||
{
|
||
return [this, validiteS](const QVariantMap &req) {
|
||
return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 500}})}, validiteS);
|
||
};
|
||
}
|
||
|
||
void TestSocketScheduler::amenerAOptimiseur(const QDateTime &t0, int validiteS)
|
||
{
|
||
m_repondre = planValide(validiteS);
|
||
cycle(t0, -3000);
|
||
cycle(t0.addSecs(60), -3000);
|
||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||
QCOMPARE(triplet(), QString("attendu|vivant|optimizer"));
|
||
}
|
||
|
||
QStringList TestSocketScheduler::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;
|
||
}
|
||
|
||
void TestSocketScheduler::monter(bool avecOptimiseur, FakeOptimizer::Transport transport)
|
||
{
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-socketscheduler.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
cleanupTestCase();
|
||
// T4 : chaque banc part sans endpoint ni token — l'arbitre les lit à sa construction, et une
|
||
// fonction précédente du même processus a pu les poser.
|
||
EnergySettings().remove("Optimizer/endpoint");
|
||
QFile::remove(OptimizerClient::defaultTokenPath());
|
||
// T6 : ni traces ni réglage de traces hérités d'une fonction précédente.
|
||
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);
|
||
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);
|
||
m_relais = addPowerSwitch(1000, 26661);
|
||
const QUuid varId = addEtmVariableLoad(27043);
|
||
m_var = varId.toString();
|
||
Thing *routeur = tm->findConfiguredThing(varId);
|
||
QVERIFY(tm->findConfiguredThing(m_relais) && routeur);
|
||
routeur->setStateValue("currentPowerW", 0);
|
||
|
||
QVariantList l;
|
||
l.append(QVariantMap{{"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", 1}, {"enabled", true},
|
||
{"relays", QVariantList() << QVariantMap{{"thingId", m_relais.toString()}, {"powerW", 1000}}},
|
||
{"minOnS", 0}, {"minOffS", 0}});
|
||
l.append(QVariantMap{{"id", m_var}, {"label", "Routeur"}, {"adapter", "etmvariableload"},
|
||
{"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}});
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
m_requetes.clear();
|
||
m_repondre = nullptr;
|
||
m_jugees = 0;
|
||
m_codesJuges.clear();
|
||
m_codesEchec.clear();
|
||
m_handshakes.clear();
|
||
if (!avecOptimiseur)
|
||
return;
|
||
|
||
QVERIFY(m_dir.isValid());
|
||
delete m_serveur;
|
||
m_serveur = new FakeOptimizer(transport, m_dir.path());
|
||
QVERIFY(m_serveur->listen());
|
||
if (m_handshake.isEmpty())
|
||
m_handshake = handshakeResult();
|
||
m_serveur->responder = [this](const QJsonObject &m) {
|
||
const qint64 id = m.value("id").toInteger();
|
||
if (m.value("method").toString() == QLatin1String("Handshake")) {
|
||
m_handshakes.append(m.value("params").toObject());
|
||
m_serveur->replyResult(id, m_handshake);
|
||
return true;
|
||
}
|
||
if (m.value("method").toString() == QLatin1String("GetPlan")) {
|
||
const QVariantMap params = m.value("params").toObject().toVariantMap();
|
||
m_requetes.append(params);
|
||
if (m_repondre) {
|
||
const QVariantMap r = m_repondre(params);
|
||
if (r.contains("__erreur")) // T4 : une erreur JSON-RPC, échec de la requête
|
||
m_serveur->sendJson(QJsonObject{{"jsonrpc", "2.0"}, {"id", id},
|
||
{"error", QJsonObject{{"code", -32000}, {"message", "panne"}}}});
|
||
else if (!r.isEmpty())
|
||
m_serveur->replyResult(id, QJsonObject::fromVariantMap(r));
|
||
}
|
||
return true;
|
||
}
|
||
return false;
|
||
};
|
||
EnergySettings().setValue("Optimizer/endpoint", m_serveur->endpoint());
|
||
m_arb->relireReglageOptimiseur();
|
||
SocketScheduler *s = m_arb->socketScheduler();
|
||
QVERIFY(s);
|
||
connect(s, &SocketScheduler::reponseJugee, this, [this](const QString &, const QString &code) {
|
||
++m_jugees;
|
||
m_codesJuges.append(code);
|
||
});
|
||
connect(s, &SocketScheduler::echecRequete, this, [this](const QString &code) {
|
||
++m_jugees; // une requête en échec est aussi « jugée » : le cycle n'attend pas plus
|
||
m_codesEchec.append(code);
|
||
});
|
||
QVERIFY(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }));
|
||
}
|
||
|
||
void TestSocketScheduler::cycle(const QDateTime &t, double compteurW)
|
||
{
|
||
m_compteur->setStateValue("currentPower", compteurW);
|
||
const int avant = m_jugees;
|
||
const int requetes = m_requetes.count();
|
||
SocketScheduler *s = m_arb->socketScheduler();
|
||
const bool envoi = s && s->client()->state() == OptimizerClient::Ready;
|
||
m_arb->recordMeterUpdate(t);
|
||
m_arb->simulationCallUpdate(t);
|
||
if (envoi)
|
||
QVERIFY2(waitFor([&] { return m_requetes.count() > requetes; }), "aucune requête GetPlan reçue");
|
||
if (envoi && m_repondre)
|
||
QVERIFY2(waitFor([&] { return m_jugees > avant; }), "réponse du faux optimiseur jamais jugée");
|
||
settle();
|
||
}
|
||
|
||
QVariantMap TestSocketScheduler::plan(const QVariantMap &requete, const QVariantList &actions, int validiteS)
|
||
{
|
||
const QDateTime ts = QDateTime::fromString(requete.value("timestamp").toString(), Qt::ISODate);
|
||
const QVariantMap creneau{{"from", ts.addSecs(-60).toString(Qt::ISODate)},
|
||
{"to", ts.addSecs(900).toString(Qt::ISODate)},
|
||
{"actions", actions}, {"expected", QVariantMap{{"gridImportW", 0}}}};
|
||
return {{"planId", "plan-test"}, {"strategy", "faux"},
|
||
{"validUntil", ts.addSecs(validiteS).toString(Qt::ISODate)},
|
||
{"slots", QVariantList{creneau}},
|
||
{"diagnostics", QVariantMap{{"status", "optimal"}, {"computeMs", 3}}}};
|
||
}
|
||
|
||
void TestSocketScheduler::testNoEndpointNoObject()
|
||
{
|
||
// Sans endpoint : AUCUN objet, aucune ligne, aucune connexion — la preuve structurelle de
|
||
// « identique au bit près ». La preuve comportementale est la référence `decisions`.
|
||
monter(false);
|
||
{
|
||
Journal j;
|
||
QVERIFY(!m_arb->socketScheduler());
|
||
QVERIFY(m_arb->findChildren<OptimizerClient *>().isEmpty());
|
||
QVERIFY(m_arb->findChildren<SocketScheduler *>().isEmpty());
|
||
m_arb->relireReglageOptimiseur();
|
||
QVERIFY(!m_arb->socketScheduler());
|
||
cycle(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)), -3000);
|
||
QVERIFY2(!j.texte().contains("[Optimiseur]"), qUtf8Printable(j.texte()));
|
||
QVERIFY(j.texte().contains("[Arbitre]")); // le journal coulait bien
|
||
}
|
||
// Endpoint illisible : dit UNE fois, rien n'est construit.
|
||
{
|
||
Journal j;
|
||
EnergySettings().setValue("Optimizer/endpoint", "http://optimiseur:80");
|
||
m_arb->relireReglageOptimiseur();
|
||
m_arb->relireReglageOptimiseur();
|
||
QVERIFY(!m_arb->socketScheduler());
|
||
QVERIFY(m_arb->findChildren<OptimizerClient *>().isEmpty());
|
||
QCOMPARE(j.compte("Optimizer/endpoint illisible"), 1);
|
||
}
|
||
// Revenu à « aucun endpoint » : de nouveau rien, et pas une ligne.
|
||
{
|
||
Journal j;
|
||
EnergySettings().remove("Optimizer/endpoint");
|
||
m_arb->relireReglageOptimiseur();
|
||
QVERIFY(!m_arb->socketScheduler());
|
||
QVERIFY2(!j.texte().contains("[Optimiseur]"), qUtf8Printable(j.texte()));
|
||
}
|
||
// Endpoint posé puis RETIRÉ : la stratégie et son client disparaissent avec lui.
|
||
QVERIFY(m_dir.isValid());
|
||
FakeOptimizer serveur(FakeOptimizer::Unix, m_dir.path());
|
||
QVERIFY(serveur.listen());
|
||
EnergySettings().setValue("Optimizer/endpoint", serveur.endpoint());
|
||
m_arb->relireReglageOptimiseur();
|
||
QVERIFY(m_arb->socketScheduler());
|
||
QCOMPARE(m_arb->findChildren<OptimizerClient *>().count(), 1);
|
||
EnergySettings().remove("Optimizer/endpoint");
|
||
m_arb->relireReglageOptimiseur();
|
||
QVERIFY(!m_arb->socketScheduler());
|
||
QVERIFY(m_arb->findChildren<OptimizerClient *>().isEmpty());
|
||
}
|
||
|
||
void TestSocketScheduler::testValidPlanIsAppliedOnTheNextCycle_data()
|
||
{
|
||
QTest::addColumn<int>("transport");
|
||
QTest::newRow("unix") << int(FakeOptimizer::Unix);
|
||
QTest::newRow("tcp") << int(FakeOptimizer::Tcp);
|
||
}
|
||
|
||
void TestSocketScheduler::testValidPlanIsAppliedOnTheNextCycle()
|
||
{
|
||
QFETCH(int, transport);
|
||
monter(true, FakeOptimizer::Transport(transport));
|
||
m_repondre = [this](const QVariantMap &req) {
|
||
return plan(req, {action("ecs", {{"powerW", 1000}, {"reasonCode", "SURPLUS_SETPOINT"},
|
||
{"reasonParams", QVariantMap{{"budgetW", 3000}, {"setpointW", 1000}}}}),
|
||
action(m_var, {{"powerW", 1800}, {"reasonCode", "PRICE_SLOT"},
|
||
{"reasonParams", QVariantMap{{"priceCtkWh", 16.96}}}})});
|
||
};
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
|
||
// Cycle 1 : la requête part, rien n'est en cache — le rule-based décide (modèle cache).
|
||
Journal j;
|
||
cycle(t0, -3000);
|
||
QCOMPARE(m_requetes.count(), 1);
|
||
const QVariantMap req = m_requetes.first();
|
||
QCOMPARE(req.value("trigger").toString(), QString("cycle"));
|
||
QCOMPARE(req.value("meter").toMap().value("exportW").toDouble(), 3000.0); // brut, règle 8
|
||
QStringList ids;
|
||
for (const QVariant &l : req.value("loads").toList())
|
||
ids << l.toMap().value("id").toString();
|
||
QVERIFY(ids.contains("ecs") && ids.contains(m_var));
|
||
// Le fil assemblé par l'arbitre, bout en bout (T1 l'avait laissé au premier appelant) : la
|
||
// prévision vient de l'instantané RÉEL (sans site désigné : grille, base et prix, `pv: null`),
|
||
// la mesure de chaque charge de mesurerCharge(), le compteur racine de l'EnergyManager.
|
||
const QVariantMap prevision = req.value("forecast").toMap();
|
||
QVERIFY2(prevision.contains("resolutionS") && prevision.contains("pv"),
|
||
QJsonDocument::fromVariant(req.value("forecast")).toJson().constData());
|
||
QVERIFY(prevision.value("pv").isNull());
|
||
QVERIFY(!req.value("meter").toMap().value("source").toString().isEmpty());
|
||
for (const QVariant &l : req.value("loads").toList())
|
||
QVERIFY2(l.toMap().value("telemetry").toMap().value("measurement").toMap().contains("source"),
|
||
qUtf8Printable(l.toMap().value("id").toString()));
|
||
QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||
QCOMPARE(m_arb->socketScheduler()->dernierCodeDeRepli(), QString("no-plan"));
|
||
const int routeurOmbre = qRound(telemetrie().value(m_var).value("allocatedW").toDouble());
|
||
QVERIFY2(routeurOmbre != 1800, "le scénario doit distinguer l'optimiseur de l'ombre");
|
||
QVERIFY(m_arb->socketScheduler()->aUnPlanEnCache());
|
||
|
||
// Cycle 2 : le plan reçu s'applique, par le dispatch commun.
|
||
cycle(t0.addSecs(60), -3000);
|
||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||
const QHash<QString, QVariantMap> tele = telemetrie();
|
||
const QVariantMap ecs = tele.value("ecs"), var = tele.value(m_var);
|
||
QCOMPARE(ecs.value("decision").toMap().value("code").toString(), QString(DecisionCode::SurplusSetpoint));
|
||
QCOMPARE(ecs.value("decision").toMap().value("level").toString(), QString("optimizer"));
|
||
QCOMPARE(qRound(ecs.value("allocatedW").toDouble()), 1000);
|
||
QCOMPARE(var.value("decision").toMap().value("code").toString(), QString(DecisionCode::PriceSlot));
|
||
QCOMPARE(var.value("decision").toMap().value("params").toMap().value("priceCtkWh").toDouble(), 16.96);
|
||
QCOMPARE(qRound(var.value("allocatedW").toDouble()), 1800);
|
||
QCOMPARE(var.value("levels").toList().count(), 1);
|
||
QCOMPARE(NymeaCore::instance()->thingManager()->findConfiguredThing(m_relais)->stateValue("power").toBool(), true);
|
||
// `expected` et `diagnostics` conservés — jamais pour commander.
|
||
QCOMPARE(m_arb->socketScheduler()->planApplique().diagnostics.value("status").toString(), QString("optimal"));
|
||
// Invariant 12 : le texte libre va au journal, JAMAIS dans la charge utile publiée.
|
||
const QByteArray publie = QJsonDocument::fromVariant(m_arb->loadTelemetry()).toJson();
|
||
QVERIFY(!publie.contains("LIBRE"));
|
||
QVERIFY2(j.texte().contains(kTexteLibre), "le texte de l'optimiseur doit être au journal");
|
||
QVERIFY(j.texte().contains("stratégie du cycle : optimiseur"));
|
||
}
|
||
|
||
void TestSocketScheduler::testRefusedPlanFallsBackToRules_data()
|
||
{
|
||
QTest::addColumn<QString>("cas");
|
||
QTest::addColumn<QString>("codeJuge");
|
||
QTest::addColumn<QString>("codeCycle");
|
||
QTest::newRow("incomplet (Q1)") << "incomplet" << "incomplete-slot" << "no-plan";
|
||
QTest::newRow("kind interdit") << "kind" << "kind-not-announced" << "no-plan";
|
||
QTest::newRow("loadId inconnu") << "inconnu" << "unknown-load" << "no-plan";
|
||
QTest::newRow("reason vide") << "reason" << "missing-reason" << "no-plan";
|
||
QTest::newRow("expiré à la réception") << "expire" << "expired" << "no-plan";
|
||
QTest::newRow("expiré à l'application") << "expire-tard" << "" << "expired";
|
||
QTest::newRow("abstention") << "abstention" << "abstain" << "no-plan";
|
||
}
|
||
|
||
void TestSocketScheduler::testRefusedPlanFallsBackToRules()
|
||
{
|
||
QFETCH(QString, cas);
|
||
QFETCH(QString, codeJuge);
|
||
QFETCH(QString, codeCycle);
|
||
m_handshake = handshakeResult();
|
||
if (cas == "kind") // l'optimiseur n'annonce que `state` : ses consignes en watts sont interdites
|
||
m_handshake.insert("capabilities", QJsonObject{{"kinds", QJsonArray{"state"}}});
|
||
monter();
|
||
m_repondre = [this, cas](const QVariantMap &req) -> QVariantMap {
|
||
QVariantMap ecs = action("ecs", {{"powerW", 1000}});
|
||
const QVariantMap var = action(m_var, {{"powerW", 1800}});
|
||
if (cas == "abstention")
|
||
return {{"abstain", true}, {"reason", "recalcul"}};
|
||
if (cas == "incomplet")
|
||
return plan(req, {ecs});
|
||
if (cas == "inconnu")
|
||
ecs["loadId"] = "fantome";
|
||
if (cas == "reason")
|
||
ecs["reason"] = "";
|
||
if (cas == "expire")
|
||
return plan(req, {ecs, var}, -1);
|
||
if (cas == "expire-tard")
|
||
return plan(req, {ecs, var}, 30); // valable à la réception, plus au cycle suivant
|
||
return plan(req, {ecs, var});
|
||
};
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
Journal j;
|
||
cycle(t0, -3000);
|
||
QCOMPARE(m_codesJuges, QStringList{codeJuge});
|
||
if (!codeJuge.isEmpty() && codeJuge != "abstain" && codeJuge != "expired")
|
||
QVERIFY(j.texte().contains("plan REFUSÉ")); // 7-d : le refus se voit
|
||
|
||
cycle(t0.addSecs(60), -3000);
|
||
QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||
QCOMPARE(m_arb->socketScheduler()->dernierCodeDeRepli(), codeCycle);
|
||
// Jamais appliqué EN PARTIE : aucune charge ne porte le niveau de l'optimiseur.
|
||
for (const QVariantMap &t : telemetrie())
|
||
QVERIFY2(t.value("decision").toMap().value("level").toString() != "optimizer",
|
||
qUtf8Printable(t.value("loadId").toString()));
|
||
}
|
||
|
||
void TestSocketScheduler::testOutOfBoundsActionIsClamped()
|
||
{
|
||
monter();
|
||
const QString d = activerTraces();
|
||
m_repondre = [this](const QVariantMap &req) {
|
||
return plan(req, {action("ecs", {{"powerW", 2500}}), action(m_var, {{"powerW", 0}})});
|
||
};
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
Journal j;
|
||
cycle(t0, -3000);
|
||
cycle(t0.addSecs(60), -3000);
|
||
// L'adaptateur écrête (un seul relais de 1000 W) : c'est l'APPLIQUÉ qui est publié.
|
||
const QVariantMap ecs = telemetrie().value("ecs");
|
||
QCOMPARE(ecs.value("decision").toMap().value("level").toString(), QString("optimizer"));
|
||
QCOMPARE(qRound(ecs.value("allocatedW").toDouble()), 1000);
|
||
QCOMPARE(qRound(ecs.value("levels").toList().first().toMap().value("targetW").toDouble()), 1000);
|
||
QCOMPARE(j.compte("ÉCRÊTÉE par l'adaptateur — demandé 2500 W , appliqué 1000 W"), 1);
|
||
// Le même écrêtage au cycle suivant ne se répète pas en clair.
|
||
cycle(t0.addSecs(120), -3000);
|
||
QCOMPARE(j.compte("ÉCRÊTÉE"), 1);
|
||
// T6 : la trace dit l'APPLIQUÉ et le marque `clamped` ; la réponse brute garde la demande.
|
||
const QVariantMap ligne = traces(d, 2).at(1);
|
||
QCOMPARE(ligne.value("strategy").toString(), QString("optimizer"));
|
||
bool vu = false;
|
||
for (const QVariant &v : ligne.value("executed").toList()) {
|
||
if (v.toMap().value("loadId").toString() != "ecs")
|
||
continue;
|
||
vu = true;
|
||
QCOMPARE(qRound(v.toMap().value("powerW").toDouble()), 1000);
|
||
QCOMPARE(v.toMap().value("clamped").toBool(), true);
|
||
}
|
||
QVERIFY(vu);
|
||
const QVariantList demandees = ligne.value("response").toMap().value("result").toMap().value("slots")
|
||
.toList().first().toMap().value("actions").toList();
|
||
QCOMPARE(qRound(demandees.first().toMap().value("powerW").toDouble()), 2500);
|
||
}
|
||
|
||
void TestSocketScheduler::testUnavailableLoadKeepsTheShadowAction()
|
||
{
|
||
monter();
|
||
// Le relais disparaît : l'ECS sort de l'arbitrage (LOAD_UNAVAILABLE, ECS-111).
|
||
QVERIFY(removeDevice(m_relais).toMap().value("params").toMap().value("thingError").toString()
|
||
== "ThingErrorNoError");
|
||
m_repondre = [this](const QVariantMap &req) {
|
||
return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 1200}})});
|
||
};
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
Journal j;
|
||
cycle(t0, -3000);
|
||
QCOMPARE(m_codesJuges, QStringList{QString()}); // valide : Q1 n'exige que les charges disponibles
|
||
cycle(t0.addSecs(60), -3000);
|
||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||
const QHash<QString, QVariantMap> tele = telemetrie();
|
||
QCOMPARE(tele.value("ecs").value("decision").toMap().value("code").toString(),
|
||
QString(DecisionCode::LoadUnavailable));
|
||
QCOMPARE(tele.value(m_var).value("decision").toMap().value("level").toString(), QString("optimizer"));
|
||
QCOMPARE(qRound(tele.value(m_var).value("allocatedW").toDouble()), 1200);
|
||
QVERIFY(j.texte().contains("ecs : charge INDISPONIBLE — action de l'optimiseur rejetée"));
|
||
// Q1 n'exige QUE les charges disponibles : un plan qui ne nomme pas l'ECS en panne est valide.
|
||
m_repondre = [this](const QVariantMap &req) { return plan(req, {action(m_var, {{"powerW", 1200}})}); };
|
||
cycle(t0.addSecs(120), -3000);
|
||
QCOMPARE(m_codesJuges.last(), QString());
|
||
cycle(t0.addSecs(180), -3000);
|
||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||
}
|
||
|
||
void TestSocketScheduler::testReasonIsFilteredOnTheWayToTheApp()
|
||
{
|
||
monter();
|
||
m_repondre = [this](const QVariantMap &req) {
|
||
return plan(req, {action("ecs", {{"powerW", 1000}, {"reasonCode", "PRICE_SLOT"},
|
||
{"reasonParams", QVariantMap{{"priceCtkWh", 12.5},
|
||
{"rang", 3}, // non documentée
|
||
{"setpointW", "mille watts"}}}}),
|
||
action(m_var, {{"powerW", 500}, {"reasonCode", "DEGRADED_L2"},
|
||
{"reasonParams", QVariantMap{{"x", 1}}}})});
|
||
};
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
Journal j;
|
||
cycle(t0, -3000);
|
||
cycle(t0.addSecs(60), -3000);
|
||
const QHash<QString, QVariantMap> tele = telemetrie();
|
||
const QVariantMap ecs = tele.value("ecs").value("decision").toMap();
|
||
QCOMPARE(ecs.value("code").toString(), QString(DecisionCode::PriceSlot));
|
||
QCOMPARE(ecs.value("params").toMap(), (QVariantMap{{"priceCtkWh", 12.5}}));
|
||
// Hors liste blanche : un optimiseur ne parle pas au nom de la sécurité.
|
||
const QVariantMap var = tele.value(m_var).value("decision").toMap();
|
||
QCOMPARE(var.value("code").toString(), QString(DecisionCode::OptimizerDecision));
|
||
QVERIFY(var.value("params").toMap().isEmpty());
|
||
// Une clé non documentée (même numérique) et une valeur texte : supprimées, et dites.
|
||
QVERIFY(j.texte().contains("paramètres supprimés : rang,setpointW"));
|
||
QVERIFY(j.texte().contains("reçu : DEGRADED_L2 → OPTIMIZER_DECISION"));
|
||
QVERIFY(!QJsonDocument::fromVariant(m_arb->loadTelemetry()).toJson().contains("mille watts"));
|
||
}
|
||
|
||
void TestSocketScheduler::testL4ShedsALoadTheOptimizerCommands()
|
||
{
|
||
monter();
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
m_repondre = [this](const QVariantMap &req) {
|
||
return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 0}})});
|
||
};
|
||
for (const char *p : {"currentPhaseA", "currentPhaseB", "currentPhaseC"})
|
||
m_compteur->setStateValue(p, 0);
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
cycle(t0, -3000);
|
||
cycle(t0.addSecs(60), -3000);
|
||
Thing *relais = NymeaCore::instance()->thingManager()->findConfiguredThing(m_relais);
|
||
QCOMPARE(relais->stateValue("power").toBool(), true); // l'optimiseur l'a enclenché
|
||
|
||
// Surcharge : L4 déleste, et il a le dernier mot sur le plan de l'optimiseur, toujours en cache.
|
||
m_compteur->setStateValue("currentPhaseA", 30);
|
||
cycle(t0.addSecs(70), 6900);
|
||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||
QCOMPARE(relais->stateValue("power").toBool(), false);
|
||
QCOMPARE(telemetrie().value("ecs").value("decision").toMap().value("code").toString(),
|
||
QString(DecisionCode::DrawCap));
|
||
}
|
||
|
||
void TestSocketScheduler::testDegradedModeSuspendsPlanning()
|
||
{
|
||
monter();
|
||
m_repondre = [this](const QVariantMap &req) {
|
||
return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 500}})});
|
||
};
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
cycle(t0, -3000);
|
||
cycle(t0.addSecs(60), -3000);
|
||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||
const int requetes = m_requetes.count();
|
||
|
||
// Compteur muet > 90 s : mode dégradé L2, la planification est suspendue — AUCUNE stratégie,
|
||
// ni requête ni application, quel que soit le plan en cache.
|
||
m_arb->evaluateMeterFreshness(t0.addSecs(200));
|
||
QVERIFY(m_arb->degradedMode());
|
||
m_arb->simulationCallUpdate(t0.addSecs(240));
|
||
settle(200);
|
||
QCOMPARE(m_requetes.count(), requetes);
|
||
QCOMPARE(NymeaCore::instance()->thingManager()->findConfiguredThing(m_relais)->stateValue("power").toBool(),
|
||
false); // le repli L2 a coupé l'ECS, et personne ne l'a rallumé
|
||
}
|
||
|
||
void TestSocketScheduler::testBudgetAndDrawAreRecomputedFromTheApplied()
|
||
{
|
||
monter();
|
||
m_repondre = [this](const QVariantMap &req) {
|
||
return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 1500}})});
|
||
};
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
cycle(t0, -500);
|
||
const QVariantMap drawOmbre = m_arb->loadTelemetry().value("draw").toMap();
|
||
cycle(t0.addSecs(60), -500);
|
||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||
|
||
const QVariantMap snap = m_arb->loadTelemetry();
|
||
const QVariantMap budget = snap.value("budget").toMap(), draw = snap.value("draw").toMap();
|
||
// Q4 — la mesure, puis ce qui a été APPLIQUÉ : 2500 W appliqués sur 500 W d'export.
|
||
QCOMPARE(budget.value("surplusW").toDouble(), 500.0);
|
||
QCOMPARE(budget.value("evReservedW").toDouble(), 0.0);
|
||
const double surplus = budget.value("surplusW").toDouble() + budget.value("recreditedW").toDouble();
|
||
QCOMPARE(qRound(surplus - budget.value("allocatedW").toDouble()), qRound(budget.value("remainingW").toDouble()));
|
||
QCOMPARE(qRound(budget.value("allocatedW").toDouble() + draw.value("committedW").toDouble()), 2500);
|
||
QCOMPARE(qRound(budget.value("allocatedW").toDouble()), qRound(qMin(surplus, 2500.0)));
|
||
QVERIFY(draw.value("committedW").toDouble() > 0);
|
||
QVERIFY2(qRound(drawOmbre.value("committedW").toDouble()) == 0, "l'ombre n'achetait rien");
|
||
// Les registres se réconcilient par charge : Σ counts == budget.allocatedW et draw.committedW.
|
||
double alloue = 0, achete = 0;
|
||
for (const QVariantMap &t : telemetrie()) {
|
||
for (const QVariant &n : t.value("levels").toList()) {
|
||
const QVariantMap c = n.toMap().value("counts").toMap();
|
||
alloue += c.value(CountKey::BudgetAllocated).toDouble();
|
||
achete += c.value(CountKey::DrawCommitted).toDouble();
|
||
}
|
||
// Q3 — financement dérivé de la mesure : ici l'export ne couvre pas, le reliquat est acheté.
|
||
QCOMPARE(t.value("levels").toList().count(), 1);
|
||
}
|
||
QCOMPARE(qRound(alloue), qRound(budget.value("allocatedW").toDouble()));
|
||
QCOMPARE(qRound(achete), qRound(draw.value("committedW").toDouble()));
|
||
QCOMPARE(telemetrie().value(m_var).value("levels").toList().first().toMap().value("funding").toString(),
|
||
QString("grid"));
|
||
}
|
||
|
||
void TestSocketScheduler::testTheTriggerNamesTheCause()
|
||
{
|
||
monter();
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
cycle(t0, -3000);
|
||
QCOMPARE(m_requetes.last().value("trigger").toString(), QString("cycle"));
|
||
|
||
// Une configuration réécrite (SetLoadConfig → reconstruction) : `config`.
|
||
const QVariantList cfg = injectAndWait("NymeaEnergy.GetLoadConfig").toMap().value("params").toMap()
|
||
.value("loadConfigs").toList();
|
||
QVERIFY(!cfg.isEmpty());
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", cfg}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
cycle(t0.addSecs(60), -3000);
|
||
QCOMPARE(m_requetes.last().value("trigger").toString(), QString("config"));
|
||
// Consommé : le cycle suivant redevient `cycle`.
|
||
cycle(t0.addSecs(120), -3000);
|
||
QCOMPARE(m_requetes.last().value("trigger").toString(), QString("cycle"));
|
||
|
||
// Un Thing ajouté : `load-change`.
|
||
addPowerSwitch(500, 26662);
|
||
cycle(t0.addSecs(180), -3000);
|
||
QCOMPARE(m_requetes.last().value("trigger").toString(), QString("load-change"));
|
||
|
||
// Plusieurs causes : la plus significative l'emporte.
|
||
m_arb->socketScheduler()->noterDeclencheur("forecast");
|
||
m_arb->socketScheduler()->noterDeclencheur("intent");
|
||
m_arb->socketScheduler()->noterDeclencheur("config");
|
||
cycle(t0.addSecs(240), -3000);
|
||
QCOMPARE(m_requetes.last().value("trigger").toString(), QString("intent"));
|
||
}
|
||
|
||
void TestSocketScheduler::testANewInvalidPlanReplacesTheCache()
|
||
{
|
||
// Un plan valide est en cache ; la réponse suivante est invalide. Elle ne s'applique pas, et
|
||
// l'ancien non plus : un optimiseur qui vient de répondre faux n'est pas cru sur sa réponse
|
||
// d'avant (§7 — le compteur d'échecs viendra en T4, le repli est immédiat).
|
||
monter();
|
||
m_repondre = [this](const QVariantMap &req) {
|
||
return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 500}})});
|
||
};
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
cycle(t0, -3000);
|
||
m_repondre = [this](const QVariantMap &req) { return plan(req, {action("ecs", {{"powerW", 1000}})}); };
|
||
cycle(t0.addSecs(60), -3000);
|
||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); // le premier plan, appliqué
|
||
QCOMPARE(m_codesJuges.last(), QString("incomplete-slot"));
|
||
cycle(t0.addSecs(120), -3000);
|
||
QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||
QCOMPARE(m_arb->socketScheduler()->dernierCodeDeRepli(), QString("no-plan"));
|
||
}
|
||
|
||
void TestSocketScheduler::testConnectionLossDropsThePlan()
|
||
{
|
||
// §7 « socket absent / fermé → rules » : un plan ne survit pas à la connexion qui l'a porté.
|
||
monter();
|
||
m_repondre = [this](const QVariantMap &req) {
|
||
return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 500}})});
|
||
};
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
cycle(t0, -3000);
|
||
cycle(t0.addSecs(60), -3000);
|
||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||
m_serveur->responder = nullptr; // plus de handshake : la connexion ne revient pas
|
||
m_serveur->abortClient();
|
||
SocketScheduler *s = m_arb->socketScheduler();
|
||
QVERIFY(waitFor([s] { return s->client()->state() != OptimizerClient::Ready; }));
|
||
QVERIFY(!s->aUnPlanEnCache());
|
||
cycle(t0.addSecs(120), -3000);
|
||
QVERIFY(!s->optimiseurAuDernierCycle());
|
||
QCOMPARE(s->dernierCodeDeRepli(), QString("no-plan"));
|
||
}
|
||
|
||
void TestSocketScheduler::testThePlanOfAnOptimizerBuysNothingEco()
|
||
{
|
||
// Correctif c, cas (vii) — sous un plan d'optimiseur, l'ombre rule-based tourne encore et
|
||
// calcule sa cible éco ; elle n'est pas exécutée. L'acheté se compte sur ce qui a été
|
||
// APPLIQUÉ, et une action d'optimiseur (niveau unique `optimizer`) n'a pas de part éco : rien
|
||
// n'est compté, même quand le compteur de la charge avance.
|
||
monter();
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
const QUuid compteurId = addMeter(26664);
|
||
Thing *compteur = tm->findConfiguredThing(compteurId);
|
||
QVERIFY(compteur);
|
||
compteur->setStateValue("connected", true);
|
||
compteur->setStateValue("currentPower", 0); // le test pose lui-même l'énergie
|
||
compteur->setStateValue("totalEnergyConsumed", 10.0);
|
||
auto ajouterWh = [&](double wh) {
|
||
compteur->setStateValue("totalEnergyConsumed",
|
||
compteur->stateValue("totalEnergyConsumed").toDouble() + wh / 1000.0);
|
||
};
|
||
// L'ECS reçoit une obligation (2 000 Wh d'ici 07:00, soit 1 000 W à 05:00 : son relais
|
||
// entier) et un compteur d'énergie. Le routeur, sans obligation, ne sert que Q1.
|
||
QVariantList l;
|
||
l.append(QVariantMap{{"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", 1}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0},
|
||
{"relays", QVariantList() << QVariantMap{{"thingId", m_relais.toString()}, {"powerW", 1000}}},
|
||
{"meterThingId", compteurId.toString()},
|
||
{"needs", QVariantMap{{"dailyDeadline", "07:00"}, {"minEnergyWhPerDay", 2000}}}});
|
||
l.append(QVariantMap{{"id", m_var}, {"label", "Routeur"}, {"adapter", "etmvariableload"},
|
||
{"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}});
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
m_repondre = [this](const QVariantMap &req) {
|
||
return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 0}})});
|
||
};
|
||
auto eco = [this]() {
|
||
for (const QVariant &n : telemetrie().value("ecs").value("levels").toList())
|
||
if (n.toMap().value("level").toString() == QLatin1String("eco"))
|
||
return n.toMap().value("progress").toMap();
|
||
return QVariantMap();
|
||
};
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(5, 0, 0));
|
||
cycle(t0, 2000); // rule-based : éco 1 000 W, relais fermé
|
||
QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||
QCOMPARE(eco().value("purchasedWh").toDouble(), 0.0);
|
||
cycle(t0.addSecs(60), 2000); // le plan de l'optimiseur s'applique
|
||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||
QCOMPARE(tm->findConfiguredThing(m_relais)->stateValue("power").toBool(), true);
|
||
|
||
// 250 Wh passent pendant que l'OPTIMISEUR commande ; puis il répond faux, et l'on revient
|
||
// aux règles — le seul moment où le niveau éco, donc l'acheté, se publie de nouveau.
|
||
ajouterWh(250);
|
||
m_repondre = [this](const QVariantMap &req) { return plan(req, {action("ecs", {{"powerW", 1000}})}); };
|
||
cycle(t0.addSecs(120), 2000); // plan d'avant appliqué ; le nouveau, refusé
|
||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||
cycle(t0.addSecs(180), 2000);
|
||
QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||
const QVariantMap p = eco();
|
||
QCOMPARE(p.value("regime").toString(), QString("measured"));
|
||
QCOMPARE(qRound(p.value("deliveredWh").toDouble()), 250); // l'énergie est bien arrivée…
|
||
QVERIFY2(p.contains("purchasedWh"), "sous measured, l'acheté est publié");
|
||
QCOMPARE(p.value("purchasedWh").toDouble(), 0.0); // …et l'éco n'en a rien acheté
|
||
}
|
||
|
||
void TestSocketScheduler::testPvIsReadFromAnyProducer()
|
||
{
|
||
// `pv.currentW` se lit dès qu'un `smartmeterproducer` existe — pas seulement un
|
||
// `solarinverter`, qui l'ÉTEND dans libnymea. Le producteur du mock est exactement le cas
|
||
// du banc : un compteur de production (sdm120Producer) posé sur un onduleur non
|
||
// communicant, `smartmeterproducer` et rien d'autre.
|
||
monter();
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
EnergyManagerMock *em = dynamic_cast<EnergyManagerMock *>(m_experiencePlugin->energyManager());
|
||
QVERIFY(em);
|
||
em->setCurrentPowerProduction(-2400); // convention nymea : produite = négative
|
||
|
||
// Sans producteur : inconnu (null), même si l'EnergyManager rend un chiffre — 0 W ne
|
||
// distinguerait pas la nuit de l'absence de PV.
|
||
cycle(t0, -3000);
|
||
QVariantMap pv = m_requetes.last().value("pv").toMap();
|
||
QVERIFY(pv.contains("currentW"));
|
||
QVERIFY(pv.value("currentW").isNull());
|
||
|
||
const QUuid producteur = addSolarInverter();
|
||
Thing *t = NymeaCore::instance()->thingManager()->findConfiguredThing(producteur);
|
||
QVERIFY(t);
|
||
QVERIFY2(t->thingClass().interfaces().contains("smartmeterproducer")
|
||
&& !t->thingClass().interfaces().contains("solarinverter"),
|
||
"le cas éprouvé est un producteur qui N'EST PAS un onduleur");
|
||
cycle(t0.addSecs(60), -3000);
|
||
pv = m_requetes.last().value("pv").toMap();
|
||
QVERIFY2(!pv.value("currentW").isNull(), "un smartmeterproducer seul doit suffire");
|
||
QCOMPARE(pv.value("currentW").toDouble(), 2400.0);
|
||
}
|
||
|
||
void TestSocketScheduler::testFallbackMachineIsPure()
|
||
{
|
||
using E = OptimizerStatus::Vivacite::Evenement;
|
||
OptimizerStatus::Vivacite v;
|
||
const QDateTime t = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
// Faux tant qu'aucun plan valide n'a été reçu : un optimiseur jamais entendu n'est pas vivant.
|
||
QVERIFY(!v.vivant());
|
||
QVERIFY(!v.noter(E::Echec, "timeout"));
|
||
QCOMPARE(v.echecsConsecutifs(), 1);
|
||
QVERIFY(v.noter(E::PlanValide, QString(), t));
|
||
QVERIFY(v.vivant());
|
||
QCOMPARE(v.echecsConsecutifs(), 0);
|
||
QCOMPARE(v.dernierPlanA(), t);
|
||
QCOMPARE(v.dernierCodeErreur(), QString("timeout")); // le dernier code reste lisible
|
||
// Abstention : vivant inchangé, et la série d'échecs est ROMPUE (« consécutifs »).
|
||
QVERIFY(!v.noter(E::Echec, "rpc-error"));
|
||
QVERIFY(v.vivant());
|
||
QVERIFY(!v.noter(E::Abstention));
|
||
QCOMPARE(v.echecsConsecutifs(), 0);
|
||
QVERIFY(!v.noter(E::Echec, "rpc-error"));
|
||
QVERIFY(v.vivant()); // un échec isolé ne tue pas
|
||
QVERIFY(v.noter(E::Echec, "incomplete-slot")); // le second, oui (E17 : par requête)
|
||
QVERIFY(!v.vivant());
|
||
QCOMPARE(v.echecsConsecutifs(), 2);
|
||
QCOMPARE(v.dernierCodeErreur(), QString("incomplete-slot"));
|
||
// Seul un plan valide rend la vie : une abstention dit qu'il répond, pas qu'il planifie.
|
||
QVERIFY(!v.noter(E::Abstention));
|
||
QVERIFY(!v.vivant());
|
||
QVERIFY(v.noter(E::PlanValide, QString(), t.addSecs(60)));
|
||
// Socket fermé : mort tout de suite, sans attendre deux requêtes.
|
||
QVERIFY(v.noter(E::ConnexionPerdue, "connection-lost"));
|
||
QVERIFY(!v.vivant());
|
||
QCOMPARE(v.dernierCodeErreur(), QString("connection-lost"));
|
||
|
||
// La colonne « stratégie active » : L2 d'abord, puis la vivacité.
|
||
QCOMPARE(OptimizerStatus::strategieActive(true, true, "optimizer"), QString("none"));
|
||
QCOMPARE(OptimizerStatus::strategieActive(true, false, "rules"), QString("none"));
|
||
QCOMPARE(OptimizerStatus::strategieActive(false, false, "optimizer"), QString("rules"));
|
||
QCOMPARE(OptimizerStatus::strategieActive(false, true, "optimizer"), QString("optimizer"));
|
||
QCOMPARE(OptimizerStatus::strategieActive(false, true, "rules"), QString("rules"));
|
||
QCOMPARE(OptimizerStatus::strategieActive(false, true, "none"), QString("none")); // L2 levé, pas encore replanifié
|
||
|
||
// Ce qu'un optimiseur écrit librement n'atteint pas l'app (mode installateur).
|
||
const QVariantMap cap = OptimizerStatus::capacitesPubliees(QVariantMap{
|
||
{"maxHorizonH", 48}, {"resolutionS", "3600"}, {"kinds", QVariantList{"setpoint", "fusee", 3}},
|
||
{"usesForecast", QVariantList{"pv", "astrologie"}}, {"reasonCodes", true}, {"libre", kTexteLibre}});
|
||
QCOMPARE(cap, (QVariantMap{{"maxHorizonH", 48.0}, {"kinds", QVariantList{"setpoint"}},
|
||
{"usesForecast", QVariantList{"pv"}}, {"reasonCodes", true}}));
|
||
QCOMPARE(OptimizerStatus::identite(QString("etm\x1b" "optimizer\n v2"), 64), QString("etm optimizer v2"));
|
||
QCOMPARE(OptimizerStatus::identite(QString(100, 'x'), 64).size(), 64);
|
||
QVERIFY(OptimizerStatus::identite(QString("\x07\x1b"), 64).isEmpty());
|
||
|
||
QCOMPARE(PublishedPlan::attenduPublie(QVariantMap{{"gridImportW", 0}, {"gridExportW", 350.5},
|
||
{"note", "texte"}, {"batterySocPercent", 40}}),
|
||
(QVariantMap{{"gridImportW", 0.0}, {"gridExportW", 350.5}}));
|
||
QCOMPARE(PublishedPlan::diagnosticsPublies(QVariantMap{
|
||
{"status", "optimal"}, {"solver", "highs"}, {"computeMs", 3},
|
||
{"inputsUsed", QVariantMap{{"pv", "hybrid"}, {"baseload", "none"}}}}),
|
||
(QVariantMap{{"status", "optimal"}, {"inputsUsed", QVariantMap{{"pv", "hybrid"}}}}));
|
||
QVERIFY(PublishedPlan::diagnosticsPublies(QVariantMap{
|
||
{"status", "presque"}, {"inputsUsed", QVariantMap{{"pv", kTexteLibre}}}}).isEmpty());
|
||
}
|
||
|
||
void TestSocketScheduler::testStatusWithoutEndpoint()
|
||
{
|
||
// §7, ligne « optimizerExpected = false » : rien n'est attendu, rien n'est vivant, le
|
||
// rule-based commande. Ce qui n'a pas de valeur est ABSENT, jamais 0.
|
||
monter(false);
|
||
enableNotifications({"NymeaEnergy"});
|
||
QSignalSpy paquets(m_mockTcpServer, &MockTcpServer::outgoingData);
|
||
QSignalSpy spy(m_arb, &EnergyArbitrator::optimizerStatusChanged);
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
Journal j;
|
||
QCOMPARE(rpc("GetPlan"), (QVariantMap{{"inForce", false}})); // aucun cycle encore
|
||
cycle(t0, -3000);
|
||
cycle(t0.addSecs(60), -3000);
|
||
const QVariantMap st = rpc("GetOptimizerStatus");
|
||
QCOMPARE(st, (QVariantMap{{"optimizerExpected", false}, {"optimizerAlive", false},
|
||
{"activeStrategy", "rules"}, {"tokenConfigured", false}}));
|
||
QCOMPARE(spy.count(), 0); // un cycle n'est pas une transition
|
||
|
||
// Le plan en vigueur : celui, à un créneau, du rule-based. Pas de fallbackCode : rien n'a été
|
||
// refusé, il n'y a personne d'autre — c'est ce qui distingue « pas d'optimiseur ».
|
||
const QVariantMap p = rpc("GetPlan");
|
||
QCOMPARE(p.value("inForce").toBool(), true);
|
||
QCOMPARE(p.value("strategy").toString(), QString("rules"));
|
||
QCOMPARE(p.value("computedAt").toString(), t0.addSecs(60).toUTC().toString(Qt::ISODate));
|
||
QVERIFY(p.contains("validUntil"));
|
||
QVERIFY(!p.contains("fallbackCode") && !p.contains("diagnostics"));
|
||
QCOMPARE(p.value("slots").toList().count(), 1);
|
||
QStringList ids;
|
||
for (const QVariant &a : p.value("slots").toList().first().toMap().value("actions").toList()) {
|
||
ids << a.toMap().value("loadId").toString();
|
||
QVERIFY(!a.toMap().value("reasonCode").toString().isEmpty());
|
||
QVERIFY(!a.toMap().contains("reason"));
|
||
}
|
||
QVERIFY2(ids.contains("ecs") && ids.contains(m_var), qUtf8Printable(ids.join(',')));
|
||
|
||
// L2 l'emporte sur tout, même sans optimiseur : `none`, UNE notification, plus de plan.
|
||
m_arb->evaluateMeterFreshness(t0.addSecs(200));
|
||
QCOMPARE(triplet(), QString("-|mort|none"));
|
||
m_arb->evaluateMeterFreshness(t0.addSecs(260));
|
||
QCOMPARE(triplets(spy), QStringList{"-|mort|none"});
|
||
QCOMPARE(rpc("GetPlan"), (QVariantMap{{"inForce", false}}));
|
||
// Sortie de L2 : la planification reprend au CYCLE, et la stratégie avec elle — d'ici là,
|
||
// rien n'a été planifié, `none` tient.
|
||
m_arb->recordMeterUpdate(t0.addSecs(290));
|
||
QVERIFY(!m_arb->degradedMode());
|
||
QCOMPARE(triplet(), QString("-|mort|none"));
|
||
cycle(t0.addSecs(300), -3000);
|
||
QCOMPARE(triplets(spy), (QStringList{"-|mort|none", "-|mort|rules"}));
|
||
QCOMPARE(rpc("GetPlan").value("strategy").toString(), QString("rules"));
|
||
// La notification JSON-RPC porte la charge utile du Get, une fois par transition.
|
||
const QVariantList notifs = checkNotifications(paquets, "NymeaEnergy.OptimizerStatusChanged");
|
||
QCOMPARE(notifs.count(), 2);
|
||
// Comparées en JSON : c'est la forme qui voyage (48 y revient en entier, pas en double).
|
||
QCOMPARE(QJsonObject::fromVariantMap(notifs.last().toMap().value("params").toMap()),
|
||
QJsonObject::fromVariantMap(m_arb->optimizerStatus()));
|
||
// Sans endpoint, le moteur ne dit rien de l'optimiseur.
|
||
QVERIFY2(!j.texte().contains("[Optimiseur]"), qUtf8Printable(j.texte()));
|
||
}
|
||
|
||
void TestSocketScheduler::testStatusFollowsTheFallbackTable_data()
|
||
{
|
||
QTest::addColumn<QString>("cas");
|
||
QTest::addColumn<QString>("attendu"); // triplet après l'événement
|
||
QTest::addColumn<int>("echecs"); // consecutiveFailures
|
||
QTest::addColumn<QString>("code"); // lastErrorCode, vide = absent
|
||
QTest::newRow("plan valide") << "valide" << "attendu|vivant|optimizer" << 0 << "";
|
||
QTest::newRow("abstention") << "abstention" << "attendu|vivant|rules" << 0 << "";
|
||
QTest::newRow("plan expiré") << "expire" << "attendu|vivant|rules" << 0 << "";
|
||
QTest::newRow("un échec") << "un-echec" << "attendu|vivant|optimizer" << 1 << "rpc-error";
|
||
QTest::newRow("deux échecs") << "deux-echecs" << "attendu|mort|rules" << 2 << "rpc-error";
|
||
QTest::newRow("plan invalide ×2") << "invalide" << "attendu|mort|rules" << 2 << "incomplete-slot";
|
||
QTest::newRow("requête remplacée") << "remplacee" << "attendu|vivant|optimizer" << 0 << "";
|
||
QTest::newRow("socket fermé") << "ferme" << "attendu|mort|rules" << 0 << "connection-lost";
|
||
QTest::newRow("mode dégradé L2") << "l2" << "attendu|vivant|none" << 0 << "";
|
||
}
|
||
|
||
void TestSocketScheduler::testStatusFollowsTheFallbackTable()
|
||
{
|
||
QFETCH(QString, cas);
|
||
QFETCH(QString, attendu);
|
||
QFETCH(int, echecs);
|
||
QFETCH(QString, code);
|
||
monter();
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
// Plan expiré : chaque plan vaut 90 s, et l'optimiseur se tait ensuite.
|
||
amenerAOptimiseur(t0, cas == "expire" ? 90 : 1800);
|
||
SocketScheduler *s = m_arb->socketScheduler();
|
||
|
||
if (cas == "abstention") {
|
||
m_repondre = [](const QVariantMap &) { return QVariantMap{{"abstain", true}, {"reason", "recalcul"}}; };
|
||
cycle(t0.addSecs(120), -3000);
|
||
cycle(t0.addSecs(180), -3000);
|
||
QCOMPARE(s->dernierCodeDeRepli(), QString("no-plan"));
|
||
} else if (cas == "expire") {
|
||
m_repondre = nullptr;
|
||
cycle(t0.addSecs(200), -3000); // le plan reçu à t0 + 60 valait jusqu'à t0 + 150
|
||
QCOMPARE(s->dernierCodeDeRepli(), QString("expired"));
|
||
} else if (cas == "un-echec" || cas == "deux-echecs") {
|
||
m_repondre = [](const QVariantMap &) { return QVariantMap{{"__erreur", true}}; };
|
||
cycle(t0.addSecs(120), -3000);
|
||
QVERIFY(s->optimiseurAuDernierCycle()); // un échec isolé : le plan en cache tient
|
||
if (cas == "deux-echecs") {
|
||
cycle(t0.addSecs(180), -3000);
|
||
QVERIFY(!s->aUnPlanEnCache()); // mort : le cache n'est plus cru
|
||
}
|
||
} else if (cas == "invalide") {
|
||
m_repondre = [this](const QVariantMap &req) { return plan(req, {action("ecs", {{"powerW", 1000}})}); };
|
||
cycle(t0.addSecs(120), -3000);
|
||
QCOMPARE(triplet(), QString("attendu|vivant|optimizer")); // un seul plan invalide
|
||
cycle(t0.addSecs(180), -3000);
|
||
} else if (cas == "remplacee") {
|
||
// Un optimiseur qui ne répond pas avant le cycle suivant : chaque requête est REMPLACÉE
|
||
// par la suivante. Ce n'est pas un échec (T2) — seul le délai de 10 s en ferait un.
|
||
m_repondre = nullptr;
|
||
cycle(t0.addSecs(120), -3000);
|
||
cycle(t0.addSecs(180), -3000);
|
||
cycle(t0.addSecs(240), -3000);
|
||
QVERIFY(s->optimiseurAuDernierCycle());
|
||
} else if (cas == "ferme") {
|
||
m_serveur->responder = nullptr;
|
||
m_serveur->abortClient();
|
||
QVERIFY(waitFor([s] { return s->client()->state() != OptimizerClient::Ready; }));
|
||
QVERIFY(!s->aUnPlanEnCache());
|
||
} else if (cas == "l2") {
|
||
m_arb->evaluateMeterFreshness(t0.addSecs(200));
|
||
QVERIFY(m_arb->degradedMode());
|
||
}
|
||
|
||
QCOMPARE(triplet(), attendu);
|
||
const QVariantMap st = rpc("GetOptimizerStatus");
|
||
QCOMPARE(QJsonObject::fromVariantMap(st), QJsonObject::fromVariantMap(m_arb->optimizerStatus()));
|
||
QCOMPARE(st.value("consecutiveFailures").toInt(), echecs);
|
||
QCOMPARE(st.value("lastErrorCode").toString(), code);
|
||
QCOMPARE(st.contains("lastErrorCode"), !code.isEmpty()); // inconnu = absent, jamais ""
|
||
// Identité du handshake, en mode installateur ; lastPlanAt = cycle de la requête du plan.
|
||
QCOMPARE(st.value("name").toString(), m_handshake.value("name").toString());
|
||
QCOMPARE(st.value("protocolVersion").toString(), m_handshake.value("protocolVersion").toString());
|
||
QVERIFY(st.value("lastPlanAt").toString().endsWith("Z"));
|
||
QCOMPARE(st.value("tokenConfigured").toBool(), false);
|
||
}
|
||
|
||
void TestSocketScheduler::testEachTransitionNotifiesOnce()
|
||
{
|
||
monter();
|
||
enableNotifications({"NymeaEnergy"});
|
||
QSignalSpy paquets(m_mockTcpServer, &MockTcpServer::outgoingData);
|
||
QSignalSpy spy(m_arb, &EnergyArbitrator::optimizerStatusChanged);
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
auto t = [&](int s) { return t0.addSecs(s); };
|
||
const auto abstention = [](const QVariantMap &) { return QVariantMap{{"abstain", true}, {"reason", "recalcul"}}; };
|
||
const auto erreur = [](const QVariantMap &) { return QVariantMap{{"__erreur", true}}; };
|
||
|
||
// Vivant : le plan valide rend la vie à sa RÉCEPTION, la stratégie suit au cycle qui l'applique.
|
||
m_repondre = planValide();
|
||
cycle(t(0), -3000);
|
||
cycle(t(60), -3000);
|
||
// Abstention RÉPÉTÉE : une seule transition vers rules.
|
||
m_repondre = abstention;
|
||
for (int s = 120; s <= 300; s += 60)
|
||
cycle(t(s), -3000);
|
||
m_repondre = planValide();
|
||
cycle(t(360), -3000);
|
||
cycle(t(420), -3000);
|
||
// Vivant → mort (deux requêtes en échec) → vivant.
|
||
m_repondre = erreur;
|
||
cycle(t(480), -3000);
|
||
cycle(t(540), -3000);
|
||
cycle(t(600), -3000); // un troisième échec : déjà mort, rien à dire
|
||
m_repondre = planValide(90);
|
||
cycle(t(660), -3000);
|
||
cycle(t(720), -3000);
|
||
// Plan expiré, puis nouveau plan.
|
||
m_repondre = nullptr;
|
||
cycle(t(840), -3000);
|
||
QCOMPARE(m_arb->socketScheduler()->dernierCodeDeRepli(), QString("expired"));
|
||
m_repondre = planValide();
|
||
cycle(t(900), -3000);
|
||
cycle(t(960), -3000);
|
||
// L2 pendant l'optimiseur, puis sortie : vivant inchangé, le plan en cache reprend.
|
||
m_arb->evaluateMeterFreshness(t(1160));
|
||
m_arb->evaluateMeterFreshness(t(1200));
|
||
cycle(t(1220), -3000);
|
||
|
||
const QStringList attendu{
|
||
"attendu|vivant|rules", // plan valide reçu, pas encore appliqué
|
||
"attendu|vivant|optimizer",
|
||
"attendu|vivant|rules", // abstention ×4 : une fois
|
||
"attendu|vivant|optimizer",
|
||
"attendu|mort|rules", // second échec consécutif
|
||
"attendu|vivant|rules", // premier plan valide
|
||
"attendu|vivant|optimizer",
|
||
"attendu|vivant|rules", // plan expiré
|
||
"attendu|vivant|optimizer", // nouveau plan
|
||
"attendu|vivant|none", // L2, prioritaire
|
||
"attendu|vivant|optimizer", // sortie de L2, premier cycle planifié
|
||
};
|
||
QCOMPARE(triplets(spy), attendu);
|
||
// Le transport : une notification JSON-RPC par transition, charge utile du Get.
|
||
const QVariantList notifs = checkNotifications(paquets, "NymeaEnergy.OptimizerStatusChanged");
|
||
QCOMPARE(notifs.count(), attendu.count());
|
||
// Comparées en JSON : c'est la forme qui voyage (48 y revient en entier, pas en double).
|
||
// `lastPlanAt` a avancé depuis (un plan de plus, sans transition) : c'est voulu, la
|
||
// notification ne suit que les transitions, le Get rend l'état du moment.
|
||
QJsonObject notifie = QJsonObject::fromVariantMap(notifs.last().toMap().value("params").toMap());
|
||
QJsonObject lu = QJsonObject::fromVariantMap(m_arb->optimizerStatus());
|
||
QVERIFY(notifie.contains("lastPlanAt") && lu.value("lastPlanAt") != notifie.value("lastPlanAt"));
|
||
notifie.remove("lastPlanAt");
|
||
lu.remove("lastPlanAt");
|
||
QCOMPARE(notifie, lu);
|
||
}
|
||
|
||
void TestSocketScheduler::testEndpointIsSetByRpcAndAppliedHot()
|
||
{
|
||
monter(false);
|
||
enableNotifications({"NymeaEnergy"});
|
||
QSignalSpy spy(m_arb, &EnergyArbitrator::optimizerStatusChanged);
|
||
QVERIFY(m_dir.isValid());
|
||
FakeOptimizer unixSrv(FakeOptimizer::Unix, m_dir.path());
|
||
FakeOptimizer tcpSrv(FakeOptimizer::Tcp, m_dir.path());
|
||
QVERIFY(unixSrv.listen() && tcpSrv.listen());
|
||
const QJsonObject hs = handshakeResult();
|
||
for (FakeOptimizer *f : {&unixSrv, &tcpSrv})
|
||
f->responder = [f, hs](const QJsonObject &m) {
|
||
if (m.value("method").toString() != QLatin1String("Handshake"))
|
||
return false;
|
||
f->replyResult(m.value("id").toInteger(), hs);
|
||
return true;
|
||
};
|
||
auto fixer = [this](const QString &e) { return rpc("SetOptimizerEndpoint", {{"endpoint", e}}); };
|
||
auto reglage = [] { return EnergySettings().value("Optimizer/endpoint"); };
|
||
|
||
// Illisible : refusé, DIT, et rien ne change.
|
||
Journal j;
|
||
QVariantMap r = fixer("http://optimiseur:80");
|
||
QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorInvalidParameter"));
|
||
QCOMPARE(r.value("fault").toString(), QString("invalid-endpoint"));
|
||
QVERIFY(!reglage().isValid());
|
||
QVERIFY(!m_arb->socketScheduler());
|
||
QCOMPARE(j.compte("endpoint REFUSÉ"), 1);
|
||
QCOMPARE(spy.count(), 0);
|
||
|
||
// unix:// : persisté, appliqué à chaud, le client se connecte.
|
||
r = fixer(unixSrv.endpoint());
|
||
QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorNoError"));
|
||
QCOMPARE(reglage().toString(), unixSrv.endpoint());
|
||
SocketScheduler *s = m_arb->socketScheduler();
|
||
QVERIFY(s);
|
||
QVERIFY(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }));
|
||
QCOMPARE(triplet(), QString("attendu|mort|rules"));
|
||
QCOMPARE(triplets(spy), QStringList{"attendu|mort|rules"});
|
||
QCOMPARE(rpc("GetOptimizerStatus").value("name").toString(), hs.value("name").toString());
|
||
|
||
// Un endpoint illisible pendant qu'un autre est posé : refusé, l'autre reste.
|
||
r = fixer("tcp://sans-port");
|
||
QCOMPARE(r.value("fault").toString(), QString("invalid-endpoint"));
|
||
QCOMPARE(m_arb->socketScheduler(), s);
|
||
QCOMPARE(reglage().toString(), unixSrv.endpoint());
|
||
|
||
// tcp:// : la stratégie est reconstruite vers le nouvel endpoint.
|
||
r = fixer(tcpSrv.endpoint());
|
||
QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorNoError"));
|
||
s = m_arb->socketScheduler();
|
||
QVERIFY(s);
|
||
QVERIFY(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }));
|
||
QCOMPARE(m_arb->findChildren<OptimizerClient *>().count(), 1);
|
||
QCOMPARE(tcpSrv.connections, 1);
|
||
|
||
// Vide : retiré du réglage, stratégie et client détruits, plus rien n'est attendu.
|
||
r = fixer("");
|
||
QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorNoError"));
|
||
QVERIFY(!reglage().isValid());
|
||
QVERIFY(!m_arb->socketScheduler());
|
||
QVERIFY(m_arb->findChildren<OptimizerClient *>().isEmpty());
|
||
QCOMPARE(rpc("GetOptimizerStatus"), (QVariantMap{{"optimizerExpected", false}, {"optimizerAlive", false},
|
||
{"activeStrategy", "rules"}, {"tokenConfigured", false}}));
|
||
QCOMPARE(triplets(spy), (QStringList{"attendu|mort|rules", "-|mort|rules"}));
|
||
}
|
||
|
||
void TestSocketScheduler::testTokenIsWriteOnlyAndNeverLeaks()
|
||
{
|
||
monter();
|
||
m_repondre = planValide();
|
||
const QString secret = QStringLiteral("T4-secret-qui-ne-sort-jamais-0123456789abcdef");
|
||
const QString chemin = OptimizerClient::defaultTokenPath();
|
||
QFile::remove(chemin);
|
||
QSignalSpy paquets(m_mockTcpServer, &MockTcpServer::outgoingData);
|
||
// TOUT le journal, tous niveaux, toutes catégories — nymea compris.
|
||
QLoggingCategory::setFilterRules(QStringLiteral("*.debug=true"));
|
||
Journal j;
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
|
||
QCOMPARE(rpc("GetOptimizerStatus").value("tokenConfigured").toBool(), false);
|
||
QVariantMap r = rpc("SetOptimizerToken", {{"token", secret}});
|
||
QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorNoError"));
|
||
QCOMPARE(r.keys(), QStringList{"energyError"});
|
||
QFile f(chemin);
|
||
QVERIFY(f.open(QIODevice::ReadOnly));
|
||
QCOMPARE(QString::fromUtf8(f.readAll()).trimmed(), secret);
|
||
f.close();
|
||
const QFileDevice::Permissions autres = QFileDevice::ExeOwner | QFileDevice::ReadGroup | QFileDevice::WriteGroup
|
||
| QFileDevice::ExeGroup | QFileDevice::ReadOther | QFileDevice::WriteOther | QFileDevice::ExeOther;
|
||
QVERIFY(QFile::permissions(chemin) & QFileDevice::ReadOwner);
|
||
QVERIFY(QFile::permissions(chemin) & QFileDevice::WriteOwner);
|
||
QCOMPARE(QFile::permissions(chemin) & autres, QFileDevice::Permissions()); // 0600
|
||
QCOMPARE(rpc("GetOptimizerStatus").value("tokenConfigured").toBool(), true);
|
||
QVERIFY(!EnergySettings().allKeys().join(' ').contains("token", Qt::CaseInsensitive));
|
||
|
||
// Le secret TRANSITE bien : à la reconnexion suivante, le handshake le porte.
|
||
const int handshakes = m_handshakes.count();
|
||
m_serveur->abortClient();
|
||
QVERIFY(waitFor([this, handshakes] { return m_handshakes.count() > handshakes; }, 8000));
|
||
QCOMPARE(m_handshakes.last().value("token").toString(), secret);
|
||
SocketScheduler *s = m_arb->socketScheduler();
|
||
QVERIFY(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }));
|
||
cycle(t0, -3000);
|
||
cycle(t0.addSecs(60), -3000);
|
||
QVERIFY(s->optimiseurAuDernierCycle());
|
||
rpc("GetPlan");
|
||
rpc("GetOptimizerStatus");
|
||
|
||
// Refus : pas une chaîne, un blanc — rien ne change, le secret reste.
|
||
r = injectAndWait("NymeaEnergy.SetOptimizerToken", {{"token", 31415926}}).toMap().value("params").toMap();
|
||
QCOMPARE(r.value("fault").toString(), QString("token-refused"));
|
||
r = rpc("SetOptimizerToken", {{"token", "avec blanc"}});
|
||
QCOMPARE(r.value("fault").toString(), QString("token-refused"));
|
||
QVERIFY(f.open(QIODevice::ReadOnly));
|
||
QCOMPARE(QString::fromUtf8(f.readAll()).trimmed(), secret);
|
||
f.close();
|
||
|
||
// Retrait : fichier supprimé, tokenConfigured faux.
|
||
QCOMPARE(rpc("SetOptimizerToken", {{"token", ""}}).value("energyError").toString(), QString("EnergyErrorNoError"));
|
||
QVERIFY(!QFile::exists(chemin));
|
||
QCOMPARE(rpc("GetOptimizerStatus").value("tokenConfigured").toBool(), false);
|
||
QVERIFY(j.texte().contains("token d'optimiseur posé") && j.texte().contains("token d'optimiseur retiré"));
|
||
QVERIFY(j.texte().contains("token REFUSÉ"));
|
||
|
||
// Aucune RÉPONSE ni notification ne contient le secret.
|
||
for (const QList<QVariant> &p : paquets)
|
||
QVERIFY2(!p.last().toByteArray().contains(secret.toUtf8()), p.last().toByteArray().left(200).constData());
|
||
QVERIFY(paquets.count() > 5); // le flux coulait
|
||
// Aucun journal du MOTEUR ne le contient. Il n'apparaît que dans la trace BRUTE des données
|
||
// entrantes de nymea (« Incoming data: », catégorie JsonRpcTraffic, niveau debug, éteinte par
|
||
// défaut) — hors de portée du plugin, comme pour le token d'authentification nymea lui-même.
|
||
auto horsTrafic = [&j](const QString &fragment) {
|
||
QStringList l;
|
||
for (const QString &x : j.lignes(fragment))
|
||
if (!x.startsWith(QLatin1String("JsonRpcTraffic: ")))
|
||
l << x.left(200);
|
||
return l;
|
||
};
|
||
QVERIFY2(horsTrafic(secret).isEmpty(), qUtf8Printable(horsTrafic(secret).join('\n')));
|
||
QVERIFY2(horsTrafic("31415926").isEmpty(), qUtf8Printable(horsTrafic("31415926").join('\n')));
|
||
QVERIFY2(!j.lignes(secret).isEmpty(),
|
||
"la trace JsonRpcTraffic de nymea ne recopie plus les données entrantes : retirer la mise "
|
||
"en garde d'INTERFACE.md (SetOptimizerToken)");
|
||
|
||
// Ce que nymea fait d'une clé EN TROP : le validateur refuse l'appel avant le handler et
|
||
// recopie les paramètres dans un AVERTISSEMENT (« Call params: », catégorie JsonRpc). Épinglé
|
||
// ici parce que c'est la seule fuite possible au journal par défaut, et qu'elle est hors du
|
||
// plugin : si ce test casse, nymea a cessé de le faire — retirer la mise en garde d'INTERFACE.md.
|
||
const QString autre = QStringLiteral("T4-autre-secret-cle-en-trop");
|
||
injectAndWait("NymeaEnergy.SetOptimizerToken", {{"token", autre}, {"cle", 1}});
|
||
QVERIFY(!j.lignes(autre).filter(QRegularExpression("^JsonRpc: ")).isEmpty());
|
||
QVERIFY(!QFile::exists(chemin)); // l'appel n'est jamais arrivé au handler
|
||
|
||
QLoggingCategory::setFilterRules(QStringLiteral(
|
||
"*.debug=false\nApplication.debug=true\nSimulation.debug=true\nExperiences.debug=true\n"
|
||
"NymeaEnergy.debug=true\nEnergyMocks.debug=true\nDBus.warning=false"));
|
||
}
|
||
|
||
void TestSocketScheduler::testGetPlanPublishesThePlanInForce()
|
||
{
|
||
monter();
|
||
m_repondre = [this](const QVariantMap &req) {
|
||
const QDateTime ts = QDateTime::fromString(req.value("timestamp").toString(), Qt::ISODate);
|
||
QVariantMap p = plan(req, {action("ecs", {{"powerW", 2500}, {"reasonCode", "SURPLUS_SETPOINT"},
|
||
{"reasonParams", QVariantMap{{"budgetW", 3000}, {"note", "texte libre"}}}}),
|
||
action(m_var, {{"powerW", 1800}})});
|
||
QVariantList creneaux = p.value("slots").toList();
|
||
QVariantMap c0 = creneaux.first().toMap();
|
||
c0.insert("expected", QVariantMap{{"gridImportW", 0}, {"gridExportW", 350}, {"commentaire", kTexteLibre}});
|
||
creneaux[0] = c0;
|
||
creneaux.append(QVariantMap{{"from", ts.addSecs(900).toString(Qt::ISODate)},
|
||
{"to", ts.addSecs(1800).toString(Qt::ISODate)},
|
||
{"actions", QVariantList{action("ecs", {{"powerW", 0}}),
|
||
action(m_var, {{"powerW", 3000}})}}});
|
||
p.insert("slots", creneaux);
|
||
p.insert("computedAt", ts.toString(Qt::ISODate));
|
||
p.insert("diagnostics", QVariantMap{{"status", "optimal"}, {"solver", "highs"}, {"computeMs", 3},
|
||
{"inputsUsed", QVariantMap{{"pv", "hybrid"}, {"baseload", "none"}}}});
|
||
return p;
|
||
};
|
||
auto parId = [](const QVariantMap &creneau) {
|
||
QHash<QString, QVariantMap> h;
|
||
for (const QVariant &a : creneau.value("actions").toList())
|
||
h.insert(a.toMap().value("loadId").toString(), a.toMap());
|
||
return h;
|
||
};
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
|
||
// Cycle 1 : un optimiseur est configuré mais n'a pas commandé — « pas de plan ce cycle ».
|
||
cycle(t0, -3000);
|
||
QVariantMap p = rpc("GetPlan");
|
||
QCOMPARE(p.value("strategy").toString(), QString("rules"));
|
||
QCOMPARE(p.value("fallbackCode").toString(), QString("no-plan"));
|
||
QCOMPARE(p.value("slots").toList().count(), 1);
|
||
|
||
// Cycle 2 : le plan de l'optimiseur EN VIGUEUR.
|
||
cycle(t0.addSecs(60), -3000);
|
||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||
p = rpc("GetPlan");
|
||
QCOMPARE(p.value("strategy").toString(), QString("optimizer"));
|
||
QVERIFY(!p.contains("fallbackCode"));
|
||
QCOMPARE(p.value("computedAt").toString(), t0.toUTC().toString(Qt::ISODate));
|
||
QCOMPARE(p.value("validUntil").toString(), t0.addSecs(1800).toUTC().toString(Qt::ISODate));
|
||
QCOMPARE(p.value("diagnostics").toMap(),
|
||
(QVariantMap{{"status", "optimal"}, {"inputsUsed", QVariantMap{{"pv", "hybrid"}}}}));
|
||
const QVariantList creneaux = p.value("slots").toList();
|
||
QCOMPARE(creneaux.count(), 2);
|
||
const QVariantMap c0 = creneaux.at(0).toMap(), c1 = creneaux.at(1).toMap();
|
||
QCOMPARE(c0.value("from").toString(), t0.addSecs(-60).toUTC().toString(Qt::ISODate));
|
||
QCOMPARE(c0.value("expected").toMap(), (QVariantMap{{"gridImportW", 0.0}, {"gridExportW", 350.0}}));
|
||
QVERIFY(!c1.contains("expected"));
|
||
// Créneau courant : la puissance APPLIQUÉE (un relais de 1000 W, 2500 demandés) ;
|
||
// créneau suivant : la puissance DEMANDÉE.
|
||
QCOMPARE(qRound(parId(c0).value("ecs").value("powerW").toDouble()), 1000);
|
||
QCOMPARE(qRound(parId(c0).value(m_var).value("powerW").toDouble()), 1800);
|
||
QCOMPARE(qRound(parId(c1).value("ecs").value("powerW").toDouble()), 0);
|
||
QCOMPARE(qRound(parId(c1).value(m_var).value("powerW").toDouble()), 3000);
|
||
// Motifs : codes et paramètres FILTRÉS seulement (invariant 12).
|
||
QCOMPARE(parId(c0).value("ecs").value("reasonCode").toString(), QString(DecisionCode::SurplusSetpoint));
|
||
QCOMPARE(parId(c0).value("ecs").value("reasonParams").toMap(), (QVariantMap{{"budgetW", 3000}}));
|
||
QCOMPARE(parId(c0).value(m_var).value("reasonCode").toString(), QString(DecisionCode::OptimizerDecision));
|
||
QCOMPARE(parId(c1).value(m_var).value("reasonCode").toString(), QString(DecisionCode::OptimizerDecision));
|
||
const QByteArray json = QJsonDocument::fromVariant(p).toJson();
|
||
for (const char *interdit : {"LIBRE", "texte libre", "highs", "plan-test", "\"faux\"", "\"reason\"", "commentaire"})
|
||
QVERIFY2(!json.contains(interdit), interdit);
|
||
|
||
// L2 : aucun plan en vigueur.
|
||
m_arb->evaluateMeterFreshness(t0.addSecs(260));
|
||
QCOMPARE(rpc("GetPlan"), (QVariantMap{{"inForce", false}}));
|
||
}
|
||
|
||
void TestSocketScheduler::testGetPlanShowsTheAppliedPowerUnderALock()
|
||
{
|
||
// Le créneau courant publie ce que l'adaptateur a APPLIQUÉ, pas ce que la stratégie a
|
||
// demandé. Sous l'optimiseur les deux coïncident déjà dans le créneau (finaliser(), T3) ;
|
||
// le cas qui les sépare est le rule-based face à un verrou interne à l'adaptateur.
|
||
monter(false);
|
||
QVariantList l;
|
||
l.append(QVariantMap{{"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", 1}, {"enabled", true}, {"minOnS", 600}, {"minOffS", 0},
|
||
{"relays", QVariantList() << QVariantMap{{"thingId", m_relais.toString()}, {"powerW", 1000}}}});
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
auto ecs = [this]() {
|
||
for (const QVariant &a : rpc("GetPlan").value("slots").toList().first().toMap().value("actions").toList())
|
||
if (a.toMap().value("loadId").toString() == QLatin1String("ecs"))
|
||
return a.toMap();
|
||
return QVariantMap();
|
||
};
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
cycle(t0, -3000);
|
||
QCOMPARE(qRound(ecs().value("powerW").toDouble()), 1000);
|
||
Thing *relais = NymeaCore::instance()->thingManager()->findConfiguredThing(m_relais);
|
||
QCOMPARE(relais->stateValue("power").toBool(), true);
|
||
// Import : la stratégie veut couper, le verrou minOn (600 s) tient le relais fermé.
|
||
cycle(t0.addSecs(60), 2500);
|
||
QCOMPARE(relais->stateValue("power").toBool(), true);
|
||
QCOMPARE(qRound(ecs().value("powerW").toDouble()), 1000);
|
||
}
|
||
|
||
void TestSocketScheduler::testUpdateLearnedIsLoggedOnceAndIgnored()
|
||
{
|
||
// §8 : reçu, journalisé UNE fois, ignoré — la persistance est un lot ultérieur.
|
||
monter();
|
||
amenerAOptimiseur(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
|
||
const QVariantMap avant = m_arb->optimizerStatus();
|
||
Journal j;
|
||
const QJsonObject n{{"jsonrpc", "2.0"}, {"method", "UpdateLearned"},
|
||
{"params", QJsonObject{{"learned", QJsonObject{{"ecsWhPerDay", 4321}}},
|
||
{"confidence", 0.5}, {"note", kTexteLibre}}}};
|
||
m_serveur->sendJson(n);
|
||
m_serveur->sendJson(n);
|
||
settle(200);
|
||
QCOMPARE(j.compte("UpdateLearned reçu et IGNORÉ"), 1);
|
||
QVERIFY(!j.texte().contains(kTexteLibre) && !j.texte().contains("4321")); // le contenu n'est pas écrit
|
||
QCOMPARE(m_arb->socketScheduler()->client()->state(), OptimizerClient::Ready);
|
||
QCOMPARE(m_arb->optimizerStatus(), avant);
|
||
// Ignoré : rien n'en revient dans la requête suivante.
|
||
cycle(utcDateTime(QDate(2026, 6, 8), QTime(13, 2, 0)), -3000);
|
||
const QByteArray req = QJsonDocument::fromVariant(m_requetes.last()).toJson();
|
||
QVERIFY(!req.contains("4321") && !req.contains("learned"));
|
||
}
|
||
|
||
// --- Brief 4 T6 : traces de décision (OPTIMIZER_PROTOCOL annexe D) ----------------------------
|
||
|
||
QString TestSocketScheduler::activerTraces()
|
||
{
|
||
static int n = 0;
|
||
const QString d = m_dossierTraces.path() + QStringLiteral("/traces-%1").arg(++n);
|
||
QDir().mkpath(d);
|
||
m_arb->setOptimizerTraceDirectory(d);
|
||
const QVariantMap r = rpc("SetOptimizerTrace", {{"enabled", true}});
|
||
[&] { QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorNoError")); }();
|
||
[&] { QVERIFY(m_arb->optimizerTracer()); }();
|
||
return d;
|
||
}
|
||
|
||
QStringList TestSocketScheduler::fichiersTrace(const QString &dossier)
|
||
{
|
||
return QDir(dossier).entryList({"etm-powersync-optimizer-trace-*.ndjson"}, QDir::Files, QDir::Name);
|
||
}
|
||
|
||
void TestSocketScheduler::exporterTraces(const QString &dossier, const QString &nom)
|
||
{
|
||
const QString vers = qEnvironmentVariable("ETM_POWERSYNC_TRACE_EXPORT");
|
||
if (vers.isEmpty())
|
||
return;
|
||
QDir().mkpath(vers);
|
||
for (const QString &f : fichiersTrace(dossier)) {
|
||
QFile::remove(QDir(vers).filePath(nom + "-" + f));
|
||
QFile::copy(QDir(dossier).filePath(f), QDir(vers).filePath(nom + "-" + f));
|
||
}
|
||
}
|
||
|
||
QList<QVariantMap> TestSocketScheduler::traces(const QString &dossier, int n)
|
||
{
|
||
QList<QVariantMap> lignes;
|
||
auto lire = [&] {
|
||
lignes.clear();
|
||
for (const QString &nom : fichiersTrace(dossier)) {
|
||
QFile f(QDir(dossier).filePath(nom));
|
||
if (!f.open(QIODevice::ReadOnly))
|
||
continue;
|
||
for (const QByteArray &l : f.readAll().split('\n'))
|
||
if (!l.isEmpty())
|
||
lignes.append(QJsonDocument::fromJson(l).object().toVariantMap());
|
||
}
|
||
return lignes.count() >= n;
|
||
};
|
||
waitFor(lire, 3000);
|
||
return lignes;
|
||
}
|
||
|
||
void TestSocketScheduler::testTraceIsOffByDefault()
|
||
{
|
||
// Défaut : DÉSACTIVÉ. Ni traceur, ni fil, ni fichier, ni ligne de journal.
|
||
monter(false);
|
||
QVERIFY(!m_arb->optimizerTracer());
|
||
QVERIFY(m_arb->findChildren<OptimizerTracer *>().isEmpty());
|
||
const QVariantMap g = rpc("GetOptimizerTrace");
|
||
QCOMPARE(g.value("enabled").toBool(), false);
|
||
QCOMPARE(g.value("retentionDays").toInt(), 14);
|
||
QCOMPARE(g.value("maxBytesPerDay").toLongLong(), OptimizerTrace::PlafondDefautOctets);
|
||
QCOMPARE(g.value("directory").toString(), NymeaSettings::storagePath());
|
||
Journal j;
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
cycle(t0, -3000);
|
||
cycle(t0.addSecs(60), -3000);
|
||
settle(200);
|
||
QVERIFY(fichiersTrace(NymeaSettings::storagePath()).isEmpty());
|
||
QVERIFY(!j.texte().contains("[Trace]"));
|
||
QVERIFY(j.texte().contains("[Arbitre]")); // le journal coulait bien
|
||
}
|
||
|
||
void TestSocketScheduler::testTraceSettingByRpc()
|
||
{
|
||
monter(false);
|
||
// Refus : une valeur hors bornes ou du mauvais type ne change RIEN, pas même les autres clés
|
||
// de l'appel (le type est contrôlé par le moteur : le validateur Qt 6 ne le fait pas).
|
||
const QList<QVariantMap> refus{
|
||
{{"retentionDays", 0}}, {{"retentionDays", 366}}, {{"retentionDays", 1.5}},
|
||
{{"retentionDays", "14"}}, {{"maxBytesPerDay", 1000}}, {{"maxBytesPerDay", 2147483647}},
|
||
{{"enabled", "yes"}}, {{"enabled", true}, {"retentionDays", 0}}};
|
||
for (const QVariantMap &p : refus) {
|
||
const QVariantMap r = rpc("SetOptimizerTrace", p);
|
||
QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorInvalidParameter"));
|
||
QCOMPARE(r.value("fault").toString(), QString("invalid-trace-setting"));
|
||
QVERIFY(!m_arb->optimizerTracer());
|
||
QCOMPARE(rpc("GetOptimizerTrace").value("retentionDays").toInt(), 14);
|
||
QVERIFY(!EnergySettings().contains("Optimizer/trace"));
|
||
}
|
||
// Pose : persistée dans energy.conf, appliquée à chaud.
|
||
const QString d = m_dossierTraces.path() + "/reglage";
|
||
QDir().mkpath(d);
|
||
m_arb->setOptimizerTraceDirectory(d);
|
||
QVariantMap r = rpc("SetOptimizerTrace", {{"enabled", true}, {"retentionDays", 3}, {"maxBytesPerDay", 2097152}});
|
||
QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorNoError"));
|
||
OptimizerTracer *tr = m_arb->optimizerTracer();
|
||
QVERIFY(tr);
|
||
QCOMPARE(tr->reglage().retentionJours, 3);
|
||
QCOMPARE(tr->reglage().plafondOctetsJour, qint64(2097152));
|
||
QCOMPARE(EnergySettings().value("Optimizer/trace").toBool(), true);
|
||
QCOMPARE(EnergySettings().value("Optimizer/traceRetentionDays").toInt(), 3);
|
||
QCOMPARE(EnergySettings().value("Optimizer/traceMaxBytesPerDay").toLongLong(), 2097152ll);
|
||
QCOMPARE(QJsonObject::fromVariantMap(rpc("GetOptimizerTrace")),
|
||
QJsonObject::fromVariantMap({{"enabled", true}, {"retentionDays", 3},
|
||
{"maxBytesPerDay", 2097152}, {"directory", d}}));
|
||
// Une clé seule : les autres restent, le traceur n'est pas reconstruit.
|
||
rpc("SetOptimizerTrace", {{"retentionDays", 7}});
|
||
QCOMPARE(m_arb->optimizerTracer(), tr);
|
||
QCOMPARE(tr->reglage().retentionJours, 7);
|
||
QCOMPARE(tr->reglage().plafondOctetsJour, qint64(2097152));
|
||
// Désactivation : le traceur disparaît, la ligne de réglage reste lisible.
|
||
rpc("SetOptimizerTrace", {{"enabled", false}});
|
||
QVERIFY(!m_arb->optimizerTracer());
|
||
QCOMPARE(rpc("GetOptimizerTrace").value("enabled").toBool(), false);
|
||
QCOMPARE(rpc("GetOptimizerTrace").value("retentionDays").toInt(), 7);
|
||
}
|
||
|
||
void TestSocketScheduler::testTraceLineUnderRulesAndInL2()
|
||
{
|
||
// Sans endpoint : la requête qui SERAIT partie, et le plan du rule-based comme réponse.
|
||
monter(false);
|
||
const QString d = activerTraces();
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
cycle(t0, -3000);
|
||
cycle(t0.addSecs(60), -3000);
|
||
QList<QVariantMap> l = traces(d, 2);
|
||
QCOMPARE(l.count(), 2);
|
||
const QVariantMap a = l.last();
|
||
QStringList cles = a.keys();
|
||
cles.sort();
|
||
QCOMPARE(cles, (QStringList{"executed", "request", "response", "strategy", "t"}));
|
||
QCOMPARE(a.value("t").toString(), OptimizerTrace::horodatage(t0.addSecs(60)));
|
||
QCOMPARE(QDateTime::fromString(a.value("t").toString(), Qt::ISODate), t0.addSecs(60));
|
||
// L'heure LOCALE, décalage explicite (annexe D) — pas l'UTC.
|
||
QCOMPARE(QDateTime::fromString(a.value("t").toString(), Qt::ISODate).offsetFromUtc(),
|
||
t0.toLocalTime().offsetFromUtc());
|
||
QVERIFY(!a.value("t").toString().endsWith('Z'));
|
||
QCOMPARE(a.value("strategy").toString(), QString("rules"));
|
||
|
||
const QVariantMap req = a.value("request").toMap();
|
||
QCOMPARE(req.value("trigger").toString(), QString("cycle"));
|
||
QCOMPARE(req.value("meter").toMap().value("exportW").toDouble(), 3000.0);
|
||
QVERIFY(req.contains("forecast") && req.contains("timestamp"));
|
||
QStringList ids;
|
||
for (const QVariant &c : req.value("loads").toList())
|
||
ids << c.toMap().value("id").toString();
|
||
QVERIFY(ids.contains("ecs") && ids.contains(m_var));
|
||
|
||
const QVariantMap rep = a.value("response").toMap().value("result").toMap();
|
||
QCOMPARE(rep.value("strategy").toString(), QString("rules"));
|
||
QCOMPARE(rep.value("slots").toList().count(), 1);
|
||
const QHash<QString, QVariantMap> tele = telemetrie();
|
||
for (const QVariant &v : rep.value("slots").toList().first().toMap().value("actions").toList()) {
|
||
const QVariantMap act = v.toMap();
|
||
QVERIFY(!act.value("reasonCode").toString().isEmpty());
|
||
QVERIFY(!act.contains("reason")); // des codes, jamais de texte
|
||
}
|
||
QCOMPARE(a.value("executed").toList().count(), tele.count());
|
||
for (const QVariant &v : a.value("executed").toList()) {
|
||
const QVariantMap e = v.toMap();
|
||
QVERIFY2(tele.contains(e.value("loadId").toString()), qUtf8Printable(e.value("loadId").toString()));
|
||
QCOMPARE(e.value("kind").toString(), QString("setpoint"));
|
||
QCOMPARE(qRound(e.value("powerW").toDouble()),
|
||
qRound(tele.value(e.value("loadId").toString()).value("allocatedW").toDouble()));
|
||
QCOMPARE(e.value("clamped").toBool(), false);
|
||
}
|
||
QVERIFY(!a.contains("latencyMs"));
|
||
|
||
// L2 : une ligne `none`, SANS requête ni réponse, rien d'exécuté par la planification.
|
||
m_arb->evaluateMeterFreshness(t0.addSecs(200));
|
||
QVERIFY(m_arb->degradedMode());
|
||
m_arb->simulationCallUpdate(t0.addSecs(240));
|
||
l = traces(d, 3);
|
||
QCOMPARE(l.count(), 3);
|
||
QCOMPARE(l.last(), (QVariantMap{{"t", OptimizerTrace::horodatage(t0.addSecs(240))},
|
||
{"executed", QVariantList()}, {"strategy", "none"}}));
|
||
exporterTraces(d, "regles");
|
||
}
|
||
|
||
void TestSocketScheduler::testTraceLineUnderTheOptimizer()
|
||
{
|
||
monter();
|
||
const QString d = activerTraces();
|
||
m_repondre = planValide();
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
cycle(t0, -3000);
|
||
cycle(t0.addSecs(60), -3000);
|
||
const QList<QVariantMap> l = traces(d, 2);
|
||
QCOMPARE(l.count(), 2);
|
||
QCOMPARE(m_requetes.count(), 2);
|
||
for (int i = 0; i < 2; ++i) {
|
||
// La requête RÉELLEMENT envoyée, et la réponse BRUTE à CETTE requête, avec sa latence.
|
||
QCOMPARE(QJsonObject::fromVariantMap(l.at(i).value("request").toMap()),
|
||
QJsonObject::fromVariantMap(m_requetes.at(i)));
|
||
QCOMPARE(QJsonObject::fromVariantMap(l.at(i).value("response").toMap().value("result").toMap()),
|
||
QJsonObject::fromVariantMap(planValide()(m_requetes.at(i))));
|
||
QVERIFY(l.at(i).value("latencyMs").toLongLong() >= 0);
|
||
// Le texte libre de l'optimiseur n'est QUE dans la réponse brute.
|
||
QVariantMap sansReponse = l.at(i);
|
||
sansReponse.remove("response");
|
||
QVERIFY(!QJsonDocument::fromVariant(sansReponse).toJson().contains("LIBRE"));
|
||
}
|
||
// Cycle 1 : rien en cache, le rule-based a commandé ; cycle 2 : le plan reçu au cycle 1.
|
||
QCOMPARE(l.at(0).value("strategy").toString(), QString("rules"));
|
||
QCOMPARE(l.at(1).value("strategy").toString(), QString("optimizer"));
|
||
QHash<QString, double> puissances;
|
||
for (const QVariant &v : l.at(1).value("executed").toList()) {
|
||
puissances.insert(v.toMap().value("loadId").toString(), v.toMap().value("powerW").toDouble());
|
||
QCOMPARE(v.toMap().value("clamped").toBool(), false);
|
||
}
|
||
QCOMPARE(puissances.value("ecs"), 1000.0);
|
||
QCOMPARE(puissances.value(m_var), 500.0);
|
||
exporterTraces(d, "optimiseur");
|
||
}
|
||
|
||
void TestSocketScheduler::testTraceAbstentionFailureAndNoResponse()
|
||
{
|
||
monter();
|
||
const QString d = activerTraces();
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
m_repondre = [](const QVariantMap &) { return QVariantMap{{"abstain", true}, {"reason", "recalcul"}}; };
|
||
cycle(t0, -3000);
|
||
m_repondre = [](const QVariantMap &) { return QVariantMap{{"__erreur", true}}; };
|
||
cycle(t0.addSecs(60), -3000);
|
||
m_repondre = nullptr; // ne répond plus : la requête sera remplacée sans réponse
|
||
cycle(t0.addSecs(120), -3000);
|
||
cycle(t0.addSecs(180), -3000);
|
||
const QList<QVariantMap> l = traces(d, 3);
|
||
QCOMPARE(l.count(), 3);
|
||
QCOMPARE(l.at(0).value("response").toMap(),
|
||
(QVariantMap{{"result", QVariantMap{{"abstain", true}, {"reason", "recalcul"}}}}));
|
||
QCOMPARE(l.at(1).value("response").toMap(),
|
||
(QVariantMap{{"error", QVariantMap{{"code", "rpc-error"}}}}));
|
||
QVERIFY(l.at(1).contains("latencyMs"));
|
||
QCOMPARE(l.at(2).value("response").toMap(),
|
||
(QVariantMap{{"error", QVariantMap{{"code", "no-response"}}}}));
|
||
QVERIFY(!l.at(2).contains("latencyMs"));
|
||
for (const QVariantMap &x : l) {
|
||
QCOMPARE(x.value("strategy").toString(), QString("rules"));
|
||
QVERIFY(x.contains("request") && !x.value("executed").toList().isEmpty());
|
||
}
|
||
// La 4e ligne attend encore sa réponse : elle part au cycle suivant, ou quand l'arbitre
|
||
// abandonne les traces (désactivation) — jamais perdue en silence.
|
||
rpc("SetOptimizerTrace", {{"enabled", false}});
|
||
QCOMPARE(traces(d, 4).last().value("response").toMap(),
|
||
(QVariantMap{{"error", QVariantMap{{"code", "no-response"}}}}));
|
||
exporterTraces(d, "repli");
|
||
}
|
||
|
||
void TestSocketScheduler::testTraceRotatesAtLocalMidnightAndPurges()
|
||
{
|
||
monter(false);
|
||
const QString d = activerTraces();
|
||
const QDate jour(2026, 6, 8);
|
||
auto poser = [&](const QString &nom) {
|
||
QFile f(QDir(d).filePath(nom));
|
||
QVERIFY(f.open(QIODevice::WriteOnly));
|
||
f.write("{}\n");
|
||
};
|
||
poser(OptimizerTrace::nomFichier(jour.addDays(-14))); // hors rétention : purgé
|
||
poser(OptimizerTrace::nomFichier(jour.addDays(-13))); // dans la rétention (14 jours, ce jour compris)
|
||
poser("etm-powersync-optimizer-trace-pas-une-date.ndjson");
|
||
poser("autre.ndjson");
|
||
Journal j;
|
||
// 23:59 puis 00:00:30 HEURE LOCALE : la rotation suit le jour local du cycle.
|
||
cycle(QDateTime(jour, QTime(23, 59, 0)), -3000);
|
||
traces(d, 3);
|
||
QCOMPARE(fichiersTrace(d), (QStringList{OptimizerTrace::nomFichier(jour.addDays(-13)),
|
||
OptimizerTrace::nomFichier(jour),
|
||
"etm-powersync-optimizer-trace-pas-une-date.ndjson"}));
|
||
cycle(QDateTime(jour.addDays(1), QTime(0, 0, 30)), -3000);
|
||
traces(d, 4);
|
||
QCOMPARE(fichiersTrace(d), (QStringList{OptimizerTrace::nomFichier(jour),
|
||
OptimizerTrace::nomFichier(jour.addDays(1)),
|
||
"etm-powersync-optimizer-trace-pas-une-date.ndjson"}));
|
||
QVERIFY(QFile::exists(QDir(d).filePath("autre.ndjson")));
|
||
QFile f(QDir(d).filePath(OptimizerTrace::nomFichier(jour.addDays(1))));
|
||
QVERIFY(f.open(QIODevice::ReadOnly));
|
||
const QVariantMap ligne = QJsonDocument::fromJson(f.readLine()).object().toVariantMap();
|
||
QCOMPARE(QDateTime::fromString(ligne.value("t").toString(), Qt::ISODate).toLocalTime().date(), jour.addDays(1));
|
||
// Rétention réglée à chaud : 1 jour = le fichier du jour seul.
|
||
rpc("SetOptimizerTrace", {{"retentionDays", 1}});
|
||
QVERIFY(waitFor([&] { return !fichiersTrace(d).contains(OptimizerTrace::nomFichier(jour)); }));
|
||
QCOMPARE(j.compte("[Trace]"), j.compte("[Trace] 1 fichier(s)") + j.compte("[Trace] réglage appliqué"));
|
||
}
|
||
|
||
void TestSocketScheduler::testTraceCapStopsUntilTheNextDay()
|
||
{
|
||
monter(false);
|
||
const QString d = activerTraces();
|
||
rpc("SetOptimizerTrace", {{"maxBytesPerDay", OptimizerTrace::PlafondMinOctets}});
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
const QString chemin = QDir(d).filePath(OptimizerTrace::nomFichier(t0.toLocalTime().date()));
|
||
{
|
||
QFile f(chemin);
|
||
QVERIFY(f.open(QIODevice::WriteOnly));
|
||
f.write(QByteArray(OptimizerTrace::PlafondMinOctets - 100, 'x') + '\n');
|
||
}
|
||
const qint64 avant = QFileInfo(chemin).size();
|
||
Journal j;
|
||
for (int i = 0; i < 4; ++i)
|
||
cycle(t0.addSecs(60 * i), -3000);
|
||
settle(300);
|
||
QCOMPARE(QFileInfo(chemin).size(), avant); // plus rien ce jour-là
|
||
QCOMPARE(j.compte("plafond journalier atteint"), 1);
|
||
// Le lendemain : de nouveau écrit.
|
||
cycle(t0.addDays(1), -3000);
|
||
const QString demain = QDir(d).filePath(OptimizerTrace::nomFichier(t0.addDays(1).toLocalTime().date()));
|
||
QVERIFY(waitFor([&] { return QFileInfo(demain).size() > 0; }));
|
||
QCOMPARE(j.compte("plafond journalier atteint"), 1);
|
||
}
|
||
|
||
void TestSocketScheduler::testTraceWriteFailureChangesNoDecision()
|
||
{
|
||
if (::geteuid() == 0)
|
||
QSKIP("root écrit dans un répertoire en lecture seule");
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
const QList<double> compteur{-3000, -1500, -500, -2200};
|
||
// Ce que décide chaque cycle, par RÔLE de charge (l'identifiant du routeur change d'un banc à
|
||
// l'autre) : puissance allouée et code du motif.
|
||
auto releve = [&] {
|
||
QStringList r;
|
||
const QHash<QString, QVariantMap> t = telemetrie();
|
||
for (const QString &id : {QStringLiteral("ecs"), m_var})
|
||
r << QStringLiteral("%1:%2").arg(qRound(t.value(id).value("allocatedW").toDouble()))
|
||
.arg(t.value(id).value("decision").toMap().value("code").toString());
|
||
return r.join('|');
|
||
};
|
||
monter(false);
|
||
QStringList reference;
|
||
for (int i = 0; i < compteur.count(); ++i) {
|
||
cycle(t0.addSecs(60 * i), compteur.at(i));
|
||
reference << releve();
|
||
}
|
||
|
||
monter(false);
|
||
const QString d = activerTraces();
|
||
QVERIFY(QFile::setPermissions(d, QFileDevice::ReadOwner | QFileDevice::ExeOwner));
|
||
Journal j;
|
||
QStringList obtenu;
|
||
for (int i = 0; i < compteur.count(); ++i) {
|
||
cycle(t0.addSecs(60 * i), compteur.at(i));
|
||
obtenu << releve();
|
||
}
|
||
settle(300);
|
||
QCOMPARE(obtenu, reference);
|
||
QVERIFY(fichiersTrace(d).isEmpty());
|
||
QCOMPARE(j.compte("[Trace] écriture impossible"), 1);
|
||
// Le répertoire redevient inscriptible : l'écriture reprend, et c'est dit une fois.
|
||
QVERIFY(QFile::setPermissions(d, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner));
|
||
cycle(t0.addSecs(600), -3000);
|
||
QCOMPARE(traces(d, 1).count(), 1);
|
||
QCOMPARE(j.compte("[Trace] écriture rétablie"), 1);
|
||
QCOMPARE(j.compte("[Trace] écriture impossible"), 1);
|
||
}
|
||
|
||
void TestSocketScheduler::testTokenIsInNoTrace()
|
||
{
|
||
monter();
|
||
// Un secret avec les deux caractères que JSON échappe : le masquage doit le reconnaître écrit.
|
||
const QString secret = QStringLiteral("T6-secret\"qui\\ne-sort-pas-0123456789");
|
||
QCOMPARE(rpc("SetOptimizerToken", {{"token", secret}}).value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
const QString d = activerTraces();
|
||
// Un optimiseur qui RECOPIE le token dans sa réponse (motif, identifiant de plan).
|
||
m_repondre = [this, secret](const QVariantMap &req) {
|
||
QVariantMap p = plan(req, {action("ecs", {{"powerW", 1000}, {"reason", "écho " + secret}}),
|
||
action(m_var, {{"powerW", 500}})});
|
||
p.insert("planId", secret);
|
||
return p;
|
||
};
|
||
Journal j;
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
cycle(t0, -3000);
|
||
cycle(t0.addSecs(60), -3000);
|
||
QCOMPARE(traces(d, 2).count(), 2);
|
||
QByteArray tout;
|
||
for (const QString &nom : fichiersTrace(d)) {
|
||
QFile f(QDir(d).filePath(nom));
|
||
QVERIFY(f.open(QIODevice::ReadOnly));
|
||
tout += f.readAll();
|
||
}
|
||
const QByteArray echappe = QJsonDocument(QJsonArray{secret}).toJson(QJsonDocument::Compact).mid(2).chopped(2);
|
||
QVERIFY(!tout.contains(secret.toUtf8()));
|
||
QVERIFY(!tout.contains(echappe));
|
||
QVERIFY(!tout.contains("0123456789")); // pas même un morceau
|
||
QCOMPARE(tout.count("[token masqué]"), 4); // 2 réponses × (motif + planId)
|
||
QCOMPARE(j.compte("recopie le token"), 1);
|
||
// La requête ne l'a jamais porté : il ne voyage que dans le handshake.
|
||
for (const QVariantMap &r : std::as_const(m_requetes))
|
||
QVERIFY(!QJsonDocument::fromVariant(r).toJson().contains("0123456789"));
|
||
QFile::remove(OptimizerClient::defaultTokenPath());
|
||
}
|
||
|
||
void TestSocketScheduler::testTraceLineSize()
|
||
{
|
||
// MESURE (brief 4 T6, banc Pi Zero 2 W sur carte SD) : la taille d'une ligne au CONTEXTE
|
||
// COMPLET — la requête réelle d'un cycle, sa prévision portée à 48 créneaux horaires (PV par
|
||
// plan, base, météo, prix import/export), quatre charges — sous le rule-based et sous un
|
||
// optimiseur qui rend 48 créneaux. Sérialisée comme l'écrit l'écrivain (JSON compact).
|
||
monter();
|
||
m_repondre = planValide();
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
cycle(t0, -3000);
|
||
QVariantMap req = m_requetes.first();
|
||
auto serie = [](double base, double pas) {
|
||
QVariantList l;
|
||
for (int i = 0; i < 48; ++i)
|
||
l << qRound((base + pas * ((i * 7) % 13)) * 10) / 10.0;
|
||
return l;
|
||
};
|
||
QVariantList plans;
|
||
for (int k = 0; k < 2; ++k)
|
||
plans << QVariantMap{{"id", QUuid::createUuid().toString()}, {"label", k ? "Toit ouest" : "Toit sud"},
|
||
{"peakW", 4500}, {"tiltDeg", 35}, {"azimuthDeg", k ? 90 : 0},
|
||
{"corrections", QVariantList{"temperature"}}, {"p50W", serie(1200, 310)}};
|
||
req.insert("forecast", QVariantMap{
|
||
{"start", t0.toString(Qt::ISODate)}, {"resolutionS", 3600}, {"steps", 48},
|
||
{"pv", QVariantMap{{"provider", "open-meteo"}, {"weatherModel", "meteofrance_seamless"},
|
||
{"conversion", "physics"}, {"corrections", QVariantList{"temperature", "ac-limit"}},
|
||
{"issuedAt", t0.toString(Qt::ISODate)}, {"contentChangedAt", t0.toString(Qt::ISODate)},
|
||
{"acLimitW", 8000}, {"p50W", serie(2400, 620)}, {"perField", plans}}},
|
||
{"baseload", QVariantMap{{"model", "profile"}, {"p50W", serie(380, 45)},
|
||
{"quality", QVariantMap{{"windowDays", 28}, {"minutes", 28800}}}}},
|
||
{"weather", QVariantMap{{"temperatureC", serie(8, 1.3)}}},
|
||
{"price", QVariantMap{{"currency", "EUR"}, {"importCtkWh", serie(18.5, 2.1)},
|
||
{"exportCtkWh", serie(4.2, 0.6)}, {"includes", QVariantList{"energy", "grid", "taxes"}},
|
||
{"source", "tempo"}, {"knownUntil", t0.addDays(1).toString(Qt::ISODate)}}}});
|
||
QVariantList charges = req.value("loads").toList();
|
||
for (int k = 0; charges.count() < 4; ++k) {
|
||
QVariantMap c = charges.at(k).toMap();
|
||
c.insert("id", QUuid::createUuid().toString());
|
||
charges << c;
|
||
}
|
||
req.insert("loads", charges);
|
||
|
||
QVariantList executees, actionsRegles;
|
||
for (const QVariant &c : charges) {
|
||
const QString id = c.toMap().value("id").toString();
|
||
executees << QVariantMap{{"loadId", id}, {"kind", "setpoint"}, {"powerW", 1234.5}, {"clamped", false}};
|
||
actionsRegles << QVariantMap{{"loadId", id}, {"kind", "setpoint"}, {"powerW", 1234.5},
|
||
{"reasonCode", "SURPLUS_SETPOINT"},
|
||
{"reasonParams", QVariantMap{{"budgetW", 3000}, {"setpointW", 1234.5}}}};
|
||
}
|
||
QVariantList creneaux;
|
||
for (int i = 0; i < 48; ++i) {
|
||
QVariantList actions;
|
||
for (const QVariant &c : charges)
|
||
actions << QVariantMap{{"loadId", c.toMap().value("id")}, {"kind", "setpoint"}, {"powerW", 1500},
|
||
{"reasonCode", "SURPLUS_SETPOINT"}, {"reason", "surplus prévu sur ce créneau"}};
|
||
creneaux << QVariantMap{{"from", t0.addSecs(3600 * i).toString(Qt::ISODate)},
|
||
{"to", t0.addSecs(3600 * (i + 1)).toString(Qt::ISODate)},
|
||
{"actions", actions}, {"expected", QVariantMap{{"gridImportW", 120}, {"gridExportW", 0}}}};
|
||
}
|
||
auto taille = [](const QVariantMap &l) {
|
||
return QJsonDocument(QJsonObject::fromVariantMap(l)).toJson(QJsonDocument::Compact).size() + 1;
|
||
};
|
||
const QVariantMap regles{{"t", OptimizerTrace::horodatage(t0)}, {"request", req},
|
||
{"response", QVariantMap{{"result", QVariantMap{
|
||
{"strategy", "rules"}, {"computedAt", t0.toString(Qt::ISODate)},
|
||
{"validUntil", t0.addSecs(60).toString(Qt::ISODate)},
|
||
{"slots", QVariantList{QVariantMap{{"from", t0.toString(Qt::ISODate)},
|
||
{"to", t0.addSecs(60).toString(Qt::ISODate)},
|
||
{"actions", actionsRegles}}}}}}}},
|
||
{"executed", executees}, {"strategy", "rules"}};
|
||
QVariantMap optimiseur = regles;
|
||
optimiseur.insert("response", QVariantMap{{"result", QVariantMap{
|
||
{"planId", QUuid::createUuid().toString()}, {"strategy", "etm-optimizer"},
|
||
{"validUntil", t0.addSecs(1800).toString(Qt::ISODate)}, {"slots", creneaux},
|
||
{"diagnostics", QVariantMap{{"status", "optimal"}, {"computeMs", 412}, {"solver", "highs"}}}}}});
|
||
optimiseur.insert("strategy", "optimizer");
|
||
optimiseur.insert("latencyMs", 431);
|
||
const int sansPrevision = taille(QVariantMap{{"request", m_requetes.first()}});
|
||
const int r = taille(regles), o = taille(optimiseur);
|
||
qInfo().noquote() << QStringLiteral("[mesure] requête réelle du banc (2 charges, sans prévision) : %1 o").arg(sansPrevision);
|
||
qInfo().noquote() << QStringLiteral("[mesure] ligne rule-based, 4 charges, prévision 48 créneaux : %1 o — %2 o/jour à 1 cycle/min")
|
||
.arg(r).arg(qint64(r) * 1440);
|
||
qInfo().noquote() << QStringLiteral("[mesure] ligne optimiseur, plan de 48 créneaux × 4 actions : %1 o — %2 o/jour")
|
||
.arg(o).arg(qint64(o) * 1440);
|
||
// Le plafond par défaut doit tenir une journée PLEINE d'optimiseur au rythme mesuré au banc
|
||
// (1 cycle/min), avec 15 % de marge pour les cycles déclenchés par un événement ; et une
|
||
// journée rule-based quatre fois.
|
||
QVERIFY2(qint64(o) * 1440 * 115 / 100 <= OptimizerTrace::PlafondDefautOctets,
|
||
qPrintable(QStringLiteral("%1 o par ligne d'optimiseur").arg(o)));
|
||
QVERIFY2(qint64(r) * 1440 * 4 <= OptimizerTrace::PlafondDefautOctets,
|
||
qPrintable(QStringLiteral("%1 o par ligne rule-based").arg(r)));
|
||
}
|
||
|
||
void TestSocketScheduler::testTracerPairsTheResponseByRequestId()
|
||
{
|
||
// Le traceur seul : une réponse ne complète QUE la ligne de SA requête. En service, le
|
||
// SocketScheduler écarte déjà les réponses obsolètes ; cette garde est la seconde.
|
||
const QString d = m_dossierTraces.path() + "/appariement";
|
||
QDir().mkpath(d);
|
||
OptimizerTrace::Reglage r;
|
||
r.active = true;
|
||
r.dossier = d;
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
{
|
||
OptimizerTracer tr(r);
|
||
tr.noterCycle({{"t", "a"}, {"strategy", "rules"}}, t0, 7);
|
||
tr.noterReponse(6, {{"result", QVariantMap{{"faux", true}}}}, 1); // une autre requête
|
||
tr.noterReponse(0, {{"result", QVariantMap{{"faux", true}}}}, 1); // aucune requête
|
||
tr.noterReponse(7, {{"result", QVariantMap{{"bon", true}}}}, 12);
|
||
tr.noterReponse(7, {{"result", QVariantMap{{"double", true}}}}, 13); // déjà complétée
|
||
} // le destructeur vide la file
|
||
const QList<QVariantMap> l = traces(d, 1);
|
||
QCOMPARE(l.count(), 1);
|
||
QCOMPARE(l.first().value("response").toMap(), (QVariantMap{{"result", QVariantMap{{"bon", true}}}}));
|
||
QCOMPARE(l.first().value("latencyMs").toInt(), 12);
|
||
}
|
||
|
||
QTEST_MAIN(TestSocketScheduler)
|
||
#include "testsocketscheduler.moc"
|