From 4d2e457ed2462d83bf5956db9c90ef6ba86b2ff3 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 28 Jun 2026 13:00:21 +0200 Subject: [PATCH] =?UTF-8?q?test(etm):=20restaurer=20testEcsRelayTopologies?= =?UTF-8?q?=20c=C3=B4t=C3=A9=20routeur=20+=20test=20config=E2=86=92RelayRo?= =?UTF-8?q?uter=20(r=C3=A9v.=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testEcsRelayTopologies réécrit pour le RelayRouter (la combinatoire vit maintenant dans le routeur, plus dans le thing) : - 1 relais (dégénéré [0, 2000]) ; - 3 relais 500/1000/2000 NON-CASCADÉ : paliers dérivés [0,500,..,3500], arrondi watts→combo, transition 1500→2000={r2000} seul (off-before-on, commute 3 relais) ; - DÉDUPLICATION : deux relais identiques 1000 W → niveaux fusionnés [0,1000,2000] (3, pas 4), le palier 1000 n'utilise qu'un relais — prouve que la dérivation dédoublonne. testLoadConfigRelayRouter (nouveau) : chaîne COMPLÈTE SetConfigs(relays[]) → store.changed → rebuild → RelayRouter → cycle surplus → commutation relais → currentPowerW (pas les pièces isolées). Build prod 0/0 ; suite complète verte (8/8 tests à 3 passed). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/auto/simulation/simulation.cpp | 160 ++++++++++++++++++++++++++- tests/auto/simulation/simulation.h | 6 +- 2 files changed, 159 insertions(+), 7 deletions(-) diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 5773200..195a3d6 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -657,11 +657,161 @@ void Simulation::testSgReadySurplus() void Simulation::testEcsRelayTopologies() { - // [T2] DÉSACTIVÉ — les topologies relais (1 relais, 3 relais non-cascadé, mapping - // powerLevels↔combinaisons) sont désormais la responsabilité du THING etmvariableload, plus - // de l'energymanager (contrat rév. 2 §1/§3 : la combinatoire matérielle vit dans le thing). - // Côté moteur, seul l'arrondi au powerLevels (Setpoint W) sera testé — TODO T3. - QSKIP("TODO T3 — la combinatoire relais part dans le thing ; le moteur ne teste que l'arrondi W."); +#ifndef ETM_ARBITRATOR + QSKIP("testEcsRelayTopologies nécessite ETM_ARBITRATOR."); +#else + // [rév. 3] La combinatoire watts→relais vit DANS le RelayRouter (couche routeur, frontière + // déplacée). On teste directement applyAction(Setpoint W) : paliers DÉRIVÉS, arrondi à la + // combinaison ≤ setpoint, off-before-on non-cascadé, et DÉDUPLICATION des niveaux. + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + auto sp = [&](double w) { + LoadAction a; + a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus; + a.powerW = w; a.reason = QStringLiteral("test topo"); + return a; + }; + auto freshSetup = [&](ThingManager *&tm, QObject *&owner) { + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + tm = NymeaCore::instance()->thingManager(); + owner = arb; + }; + + // ===================== Topologie 1 : 1 relais (dégénéré [0, 2000]) ===================== + { + ThingManager *tm; QObject *owner; freshSetup(tm, owner); + QUuid r = addPowerSwitch(2000, 26661); + Thing *relay = tm->findConfiguredThing(r); + QVERIFY(relay); + RelayRouter *ecs = new RelayRouter(tm, "ecs-1", "ECS 1 relais", + QList({ {r.toString(), 2000} }), 0, 0, 1, LoadNeeds(), owner); + QCOMPARE(ecs->descriptor().declared.powerLevels, QList({0, 2000})); + + ecs->applyAction(sp(2500), t0); // 2500 → palier 2000 + QCOMPARE(qRound(ecs->currentSetpointW()), 2000); + QCOMPARE(relay->stateValue("power").toBool(), true); + ecs->applyAction(sp(1000), t0.addSecs(1)); // 1000 < 2000 → 0 + QCOMPARE(qRound(ecs->currentSetpointW()), 0); + QCOMPARE(relay->stateValue("power").toBool(), false); + } + + // ============= Topologie 2 : 3 relais 500/1000/2000 (NON-CASCADÉ, off-before-on) ============= + { + ThingManager *tm; QObject *owner; freshSetup(tm, owner); + QUuid r500 = addPowerSwitch(500, 26661); + QUuid r1000 = addPowerSwitch(1000, 26662); + QUuid r2000 = addPowerSwitch(2000, 26663); + Thing *t500 = tm->findConfiguredThing(r500); + Thing *t1000 = tm->findConfiguredThing(r1000); + Thing *t2000 = tm->findConfiguredThing(r2000); + QVERIFY(t500 && t1000 && t2000); + RelayRouter *ecs = new RelayRouter(tm, "ecs-3", "ECS 3 relais", + QList({ {r500.toString(), 500}, {r1000.toString(), 1000}, {r2000.toString(), 2000} }), + 0, 0, 1, LoadNeeds(), owner); + // 8 niveaux dérivés (2^3 combinaisons toutes distinctes). + QCOMPARE(ecs->descriptor().declared.powerLevels, QList({0, 500, 1000, 1500, 2000, 2500, 3000, 3500})); + + // 1700 → palier 1500 = {r500, r1000} (r2000 OFF). + ecs->applyAction(sp(1700), t0); + QCOMPARE(qRound(ecs->currentSetpointW()), 1500); + QCOMPARE(t500->stateValue("power").toBool(), true); + QCOMPARE(t1000->stateValue("power").toBool(), true); + QCOMPARE(t2000->stateValue("power").toBool(), false); + + // Transition NON-CASCADÉE 1500 → 2000 = {r2000} SEUL : commute 3 relais (off-before-on). + ecs->applyAction(sp(2000), t0.addSecs(1)); + QCOMPARE(qRound(ecs->currentSetpointW()), 2000); + QCOMPARE(t500->stateValue("power").toBool(), false); + QCOMPARE(t1000->stateValue("power").toBool(), false); + QCOMPARE(t2000->stateValue("power").toBool(), true); + } + + // ============= Topologie 3 : DÉDUPLICATION (deux relais identiques 1000 W) ============= + { + ThingManager *tm; QObject *owner; freshSetup(tm, owner); + QUuid rA = addPowerSwitch(1000, 26661); + QUuid rB = addPowerSwitch(1000, 26662); + Thing *tA = tm->findConfiguredThing(rA); + Thing *tB = tm->findConfiguredThing(rB); + QVERIFY(tA && tB); + RelayRouter *ecs = new RelayRouter(tm, "ecs-dup", "ECS dédup", + QList({ {rA.toString(), 1000}, {rB.toString(), 1000} }), 0, 0, 1, LoadNeeds(), owner); + // 4 combinaisons MAIS deux donnent 1000 W → FUSIONNÉES : 3 niveaux, pas 4. + QCOMPARE(ecs->descriptor().declared.powerLevels, QList({0, 1000, 2000})); + + // 1200 → palier 1000 = UN SEUL relais (le premier de la combinaison dédupliquée). + ecs->applyAction(sp(1200), t0); + QCOMPARE(qRound(ecs->currentSetpointW()), 1000); + QCOMPARE(tA->stateValue("power").toBool(), true); + QCOMPARE(tB->stateValue("power").toBool(), false); + // 2200 → palier 2000 = les deux. + ecs->applyAction(sp(2200), t0.addSecs(1)); + QCOMPARE(qRound(ecs->currentSetpointW()), 2000); + QCOMPARE(tA->stateValue("power").toBool(), true); + QCOMPARE(tB->stateValue("power").toBool(), true); + } +#endif +} + +void Simulation::testLoadConfigRelayRouter() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testLoadConfigRelayRouter nécessite ETM_ARBITRATOR."); +#else + // Chaîne COMPLÈTE rév. 3 : SetConfigs(relays[]) → store.changed → rebuild construit un + // RelayRouter → cycle surplus → arrondi scheduler → routeur → commutation relais → currentPowerW. + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + EnergyArbitrator *arbitrator = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arbitrator); + ThingManager *tm = NymeaCore::instance()->thingManager(); + + QUuid meterId = addMeter(); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + Thing *meter = tm->findConfiguredThing(meterId); + QVERIFY(meter); + meter->setStateValue("connected", true); + + QUuid rA = addPowerSwitch(1000, 26661); + QUuid rB = addPowerSwitch(1500, 26662); + Thing *relayA = tm->findConfiguredThing(rA); + Thing *relayB = tm->findConfiguredThing(rB); + QVERIFY(relayA && relayB); + + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-relayrouter.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + LoadConfigStore *store = new LoadConfigStore(arbitrator); + arbitrator->setLoadConfigStore(store); + + LoadConfigs cfgs; + cfgs.append(LoadConfig::fromMap(QVariantMap{ + {"id", "ecs-relais"}, {"label", "ECS relais"}, {"adapter", "relay-router"}, {"mode", "fixed"}, + {"priority", 1}, {"enabled", true}, + {"relays", QVariantList() + << QVariantMap{{"thingId", rA.toString()}, {"powerW", 1000}} + << QVariantMap{{"thingId", rB.toString()}, {"powerW", 1500}}}, + {"minOnS", 0}, {"minOffS", 0}})); + QString err; + QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8()); // persiste + changed → rebuild → RelayRouter + + // Surplus 2500 → paliers dérivés [0,1000,1500,2500] → palier 2500 (rA+rB) → les deux ON. + meter->setStateValue("currentPower", -2500); + arbitrator->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0))); + QCoreApplication::processEvents(); + QCOMPARE(relayA->stateValue("power").toBool(), true); + QCOMPARE(relayB->stateValue("power").toBool(), true); + // currentPowerW remonte (mock : relais ON → currentPower = nominal) → source du recrédit. + QCOMPARE(qRound(relayA->stateValue("currentPower").toDouble()), 1000); + QCOMPARE(qRound(relayB->stateValue("currentPower").toDouble()), 1500); + + qunsetenv("NYMEA_ENERGY_LOAD_CONFIG"); + QFile::remove(cfgPath); +#endif } void Simulation::run_data() diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 64b5e2a..7534bcb 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -70,13 +70,15 @@ private slots: void testLoadConfigBuildsAdapters(); // [T4] Injection RPC end-to-end : NymeaEnergy.Get/SetLoadConfig (couche consommée par l'app). void testLoadConfigRpc(); + // [rév.3] Chaîne complète : SetConfigs(relays[]) → rebuild → RelayRouter → cycle → commutation relais. + void testLoadConfigRelayRouter(); // SG-Ready (PAC) : montée d'états sur surplus, hystérésis 3↔4, protection court-cycling, // + Volet 4 budget PARTAGÉ etmvariableload(ECS)↔PAC (waterfall unifié, inversion priorité). void testSgReadySurplus(); - // [T2] DÉSACTIVÉ (QSKIP) — la combinatoire relais part dans le thing etmvariableload ; - // le moteur ne testera plus que l'arrondi en watts. TODO T3. + // [rév.3] Combinatoire watts→relais DANS le RelayRouter : paliers dérivés, off-before-on, + // transition non-cascadée, et DÉDUPLICATION des niveaux (relais identiques fusionnés). void testEcsRelayTopologies(); void printStates(Thing *thing);