// 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 #include #include #include 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 telemetrieParId(const QVariantMap &snap) { QHash 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(); 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 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 m_requetes; //! Ce que le faux optimiseur répond à un GetPlan ; vide = il ne répond pas. std::function 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(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().isEmpty()); QVERIFY(m_arb->findChildren().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().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().count(), 1); EnergySettings().remove("Optimizer/endpoint"); m_arb->relireReglageOptimiseur(); QVERIFY(!m_arb->socketScheduler()); QVERIFY(m_arb->findChildren().isEmpty()); } void TestSocketScheduler::testValidPlanIsAppliedOnTheNextCycle_data() { QTest::addColumn("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 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("cas"); QTest::addColumn("codeJuge"); QTest::addColumn("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 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 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é } QTEST_MAIN(TestSocketScheduler) #include "testsocketscheduler.moc"