diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index b67e362..0407c96 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -36,6 +36,7 @@ using namespace nymeaserver; #ifdef ETM_ARBITRATOR #include "../../../energyplugin/etm/energyarbitrator.h" #include "../../../energyplugin/etm/adapters/ecsrelayadapter.h" +#include "../../../energyplugin/etm/adapters/sgreadyadapter.h" #endif #include @@ -222,6 +223,138 @@ void Simulation::testMeterSilentFallback() #endif } +void Simulation::testSgReadySurplus() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testSgReadySurplus nécessite ETM_ARBITRATOR."); +#else + // Encodage SG-Ready 2 bits (K1,K2) : 1=[K1] blocage · 2=[] normal · 3=[K2] reco · 4=[K1,K2] forcé. + // estimatedPowerW déclaré : P3=1500, P4=3000. Hystérésis état 4 : entrée P4×1,2=3600, sortie P4×1,0=3000. + const QHash pacPower({ {1, 0.0}, {2, 0.0}, {3, 1500.0}, {4, 3000.0} }); + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + + // ===================== Volets 1-3 : PAC seule ===================== + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + EnergyArbitrator *arbitrator = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY2(arbitrator, "smartChargingManager n'est pas un EnergyArbitrator"); + 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 k1 = addPowerSwitch(0, 26661); + QUuid k2 = addPowerSwitch(0, 26662); + Thing *relayK1 = tm->findConfiguredThing(k1); + Thing *relayK2 = tm->findConfiguredThing(k2); + QVERIFY(relayK1 && relayK2); + + SgReadyAdapter *pac = new SgReadyAdapter( + tm, "pac-test", "PAC test", + QHash>({ {1, {k1.toString()}}, {2, {}}, + {3, {k2.toString()}}, {4, {k1.toString(), k2.toString()}} }), + pacPower, 300, 1, arbitrator); + arbitrator->registerSgReadyAdapter(pac); + + auto setMeterW = [&](double signedW){ meter->setStateValue("currentPower", signedW); }; // <0 export + auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); }; + + // --- Volet 1 : montée d'états 2 → 3 → 4 (mapping sémantique) --- + setMeterW(-1000); cycle(t0); // budget 1000 < P3 → état 2 (normal) + QCOMPARE(pac->currentState(), 2); + setMeterW(-2000); cycle(t0); // budget 2000 ≥ P3 → état 3 (reco) + QCOMPARE(pac->currentState(), 3); + QCOMPARE(relayK2->stateValue("power").toBool(), true); + QCOMPARE(relayK1->stateValue("power").toBool(), false); + setMeterW(-2500); cycle(t0.addSecs(400)); // budget 2500+1500=4000 ≥ P4×1,2 → état 4 (hold écoulé) + QCOMPARE(pac->currentState(), 4); + QCOMPARE(relayK1->stateValue("power").toBool(), true); + QCOMPARE(relayK2->stateValue("power").toBool(), true); + + // --- Volet 2 : hystérésis 3↔4 (budget oscille dans la zone morte [P4×1,0 ; P4×1,2)) --- + // hold écoulé à chaque cycle (lastSwitch=T0+400) → c'est la ZONE MORTE qui tient l'état 4, pas le verrou. + setMeterW(-300); cycle(t0.addSecs(800)); // budget 300+3000=3300 ∈ [3000,3600) → reste 4 + QCOMPARE(pac->currentState(), 4); + setMeterW(-100); cycle(t0.addSecs(1200)); // budget 3100 → reste 4 + QCOMPARE(pac->currentState(), 4); + setMeterW(-500); cycle(t0.addSecs(1600)); // budget 3500 → reste 4 + QCOMPARE(pac->currentState(), 4); + // En-dessous de P4×1,0 → sort enfin de l'état 4 (vers 3). + setMeterW(200); cycle(t0.addSecs(2000)); // import 200 → budget -200+3000=2800 < 3000 → état 3 + QCOMPARE(pac->currentState(), 3); + + // --- Volet 3 : protection court-cycling (changement avant minStateHold → GELÉ) --- + // lastSwitch=T0+2000. À T0+2100 (elapsed 100 < hold 300) : surplus abondant mais GELÉ en 3. + setMeterW(-3000); cycle(t0.addSecs(2100)); + QCOMPARE(pac->currentState(), 3); // gelé malgré budget ≥ P4×1,2 (protection compresseur) + // À T0+2400 (elapsed 400 > hold) : MÊME surplus → bascule en 4. Seul le temps simulé a changé. + setMeterW(-3000); cycle(t0.addSecs(2400)); + QCOMPARE(pac->currentState(), 4); + + // ===================== Volet 4 : interaction budget PARTAGÉ ECS↔PAC ===================== + // Surplus 3000 W, ECS palier 1 = 2400 W, PAC P3 = 1500. Selon l'ordre de priorité, + // l'un se sert et l'autre voit le RELIQUAT → preuve du waterfall unifié (un seul budget). + // priority fixé à la création → on ré-initialise un arbitre frais par ordre testé. + auto runSharedBudget = [&](int ecsPrio, int pacPrio, int &ecsStageOut, int &pacStateOut) { + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + ThingManager *tm2 = NymeaCore::instance()->thingManager(); + + QUuid mId = addMeter(); + m_experiencePlugin->energyManager()->setRootMeter(mId); + Thing *m2 = tm2->findConfiguredThing(mId); + QVERIFY(m2); + m2->setStateValue("connected", true); + + QUuid ke = addPowerSwitch(0, 26663); // relais ECS + QUuid j1 = addPowerSwitch(0, 26661); // relais PAC K1 + QUuid j2 = addPowerSwitch(0, 26662); // relais PAC K2 + + // ECS : 1 palier à 2400 W, verrous à 0 (on teste le partage de budget, pas l'anti-rebond). + EcsRelayAdapter *ecs = new EcsRelayAdapter( + tm2, "ecs-wf", "ECS waterfall", + QList({0, 2400}), + QList>({ {}, {ke.toString()} }), + 0, 0, ecsPrio, arb); + arb->registerEcsAdapter(ecs); + + SgReadyAdapter *pacWf = new SgReadyAdapter( + tm2, "pac-wf", "PAC waterfall", + QHash>({ {1, {j1.toString()}}, {2, {}}, + {3, {j2.toString()}}, {4, {j1.toString(), j2.toString()}} }), + pacPower, 300, pacPrio, arb); + arb->registerSgReadyAdapter(pacWf); + + m2->setStateValue("currentPower", -3000); // export 3000 W + arb->simulationCallUpdate(t0); + QCoreApplication::processEvents(); + ecsStageOut = ecs->currentStage(); + pacStateOut = pacWf->currentState(); + }; + + int ecsStage = -1, pacState = -1; + + // ECS prioritaire (rang 1) : ECS se sert (2400) → reliquat 600 < P3 → PAC reste en NORMAL (2). + runSharedBudget(/*ecsPrio*/ 1, /*pacPrio*/ 2, ecsStage, pacState); + QCOMPARE(ecsStage, 1); + QCOMPARE(pacState, 2); + + // Priorités INVERSÉES — PAC prioritaire (rang 1) : PAC se sert (état 3, 1500) → reliquat + // 1500 < 2400 → l'ECS reste éteint (palier 0). L'ordre de service s'inverse. + runSharedBudget(/*ecsPrio*/ 2, /*pacPrio*/ 1, ecsStage, pacState); + QCOMPARE(ecsStage, 0); + QCOMPARE(pacState, 3); +#endif +} + void Simulation::run_data() { // Simulation infos diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 4ffdf0e..5827493 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -64,6 +64,10 @@ private slots: // planification suspendue (ECS reste 0 sur N cycles), reprise au retour du compteur. void testMeterSilentFallback(); + // SG-Ready (PAC) : montée d'états sur surplus, hystérésis 3↔4, protection court-cycling, + // et interaction budget PARTAGÉ ECS↔PAC (preuve du waterfall unifié 3e). + void testSgReadySurplus(); + void printStates(Thing *thing); void updateChargerMeter(Thing *thing);