[3e-5] testSgReadySurplus : montée + hystérésis + court-cycling + budget partagé ECS↔PAC

Test simulation autonome (mock powerSwitch 2 relais, encodage 2 bits). 4 volets :
(1) montée 2→3→4 ; (2) hystérésis 3↔4 (zone morte P4×1,0–1,2, budget oscillant →
reste 4) ; (3) court-cycling (gelé sous minStateHold, bascule au-delà via temps simulé) ;
(4) budget PARTAGÉ ECS↔PAC : ordre priorité → service inverse (preuve waterfall unifié 3e).
Suite simulation 19/19, 0 régression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-06-09 23:43:02 +02:00
parent b06ac15714
commit d8079e84e0
2 changed files with 137 additions and 0 deletions

View File

@ -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 <QHash>
@ -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<int, double> 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<EnergyArbitrator *>(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<int, QList<QString>>({ {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<EnergyArbitrator *>(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<int>({0, 2400}),
QList<QList<QString>>({ {}, {ke.toString()} }),
0, 0, ecsPrio, arb);
arb->registerEcsAdapter(ecs);
SgReadyAdapter *pacWf = new SgReadyAdapter(
tm2, "pac-wf", "PAC waterfall",
QHash<int, QList<QString>>({ {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

View File

@ -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);