Réglage `Optimizer/endpoint` (energy.conf), lu au démarrage par EnergyArbitrator::relireReglageOptimiseur() — lecture seule, la RPC viendra en T4. ABSENT : aucun SocketScheduler ni OptimizerClient n'existe, update() interroge le rule-based comme avant (decisions IDENTIQUE, 23 772 lignes). SocketScheduler (IScheduler) : à chaque cycle, le rule-based en OMBRE (effets de bord conservés), puis GetPlan envoyé sans attendre (getPlanParams + fil de l'arbitre : compteur racine, PV, phases, réserve, domaines, mesures, prévision réelle) ; si le plan en cache est valide pour CE cycle, ses actions remplacent celles de l'ombre (OptimizerPlan::substituer). Sinon rule-based : plan absent, invalide, expiré, abstention, connexion perdue. Un plan invalide vide le cache. Les actions passent par applyActionsToAdapters comme celles du rule-based ; apresDispatch() relit l'appliqué (Q3, Q4, clamped), avant la fusion L4. L4 et L2 inchangés : L4 garde le dernier mot, L2 suspend tout. Déclencheur réel (§5.1 trigger) : intent (ChargingInfo), load-change (Thing ajouté/retiré), config (SetLoadConfig, limites, réserve), forecast (révision nouvelle) ; le plus significatif l'emporte, consommé à l'envoi. Journal : le texte libre de l'optimiseur y va (assaini), jamais dans la télémétrie ; lignes en clair au changement seulement (stratégie, motifs, écrêtage). Version du moteur du handshake lue dans debian/changelog. Points d'accroche T4 : reponseJugee(), echecRequete(), strategieDuCycle(). INTERFACE.md : niveau unique « optimizer », budget/draw sous l'optimiseur, préséance L4 > indisponible/dégradé > optimiseur > éco|confort. Tests : suite etm-powersync-socketscheduler (12 fonctions, 43 passés) — faux optimiseur unix et tcp dans le processus, arbitre réel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
638 lines
30 KiB
C++
638 lines
30 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 "../common/etm-powersync-fakeoptimizer.h"
|
||
|
||
#include "energysettings.h"
|
||
#include "etm/energyarbitrator.h"
|
||
#include "etm/scheduler/socketscheduler.h"
|
||
#include "etm/types/decisionreason.h"
|
||
|
||
#include <QJsonDocument>
|
||
#include <QSignalSpy>
|
||
#include <QTemporaryDir>
|
||
|
||
#include <experiences/experiencemanager.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); }
|
||
|
||
private:
|
||
static void collecte(QtMsgType type, const QMessageLogContext &ctx, const QString &msg)
|
||
{
|
||
if (s_courant)
|
||
s_courant->m_texte += msg + QLatin1Char('\n');
|
||
if (s_origine)
|
||
s_origine(type, ctx, msg);
|
||
}
|
||
QString m_texte;
|
||
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();
|
||
|
||
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;
|
||
};
|
||
|
||
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();
|
||
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();
|
||
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_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.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);
|
||
});
|
||
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();
|
||
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);
|
||
}
|
||
|
||
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"));
|
||
}
|
||
|
||
QTEST_MAIN(TestSocketScheduler)
|
||
#include "testsocketscheduler.moc"
|