[3c-7b] testEcsSurplusPV : waterfall ECS + protection compresseur (seam de temps prouvé)

Test simulation autonome (arbitre frais via initTestCase) : 2 relais powerSwitch +
EcsRelayAdapter minOn=300. 4 régimes pilotés par le temps simulé :
cascade export 0→1→2 ; anti-clignotement (recrédit, hors verrou) ; import<minOn → RESTE
(protection compresseur) ; import>minOn → déleste. Seul le temps simulé change entre les
2 derniers → prouve le seam de temps unifié ET la protection.

Renommage ThingClass mockPowerSwitch→powerSwitch (collision symbole plugininfo vs
energytestbase dans le binaire simulation). Suite simulation : 17 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-06-09 21:52:14 +02:00
parent 3a8eb5da86
commit dde967da41
4 changed files with 102 additions and 3 deletions

View File

@ -33,6 +33,10 @@ using namespace nymeaserver;
#include "../../../energyplugin/smartchargingmanager.h"
#include "../../mocks/spotmarketprovider/spotmarketdataprovidermock.h"
#ifdef ETM_ARBITRATOR
#include "../../../energyplugin/etm/energyarbitrator.h"
#include "../../../energyplugin/etm/adapters/ecsrelayadapter.h"
#endif
#include <QHash>
#include <QtMath>
@ -41,11 +45,102 @@ using namespace nymeaserver;
#include <QDateTime>
#include <QSignalSpy>
#include <QProcessEnvironment>
#include <QCoreApplication>
#include <nymeacore.h>
#include "simulationtestpoint.h"
void Simulation::testEcsSurplusPV()
{
#ifndef ETM_ARBITRATOR
QSKIP("testEcsSurplusPV nécessite ETM_ARBITRATOR.");
#else
// Préambule harnais (comme run()) : DB + (ré)initialisation → expérience chargée et
// arbitre FRAIS (pas d'adaptateur ECS résiduel d'un test précédent).
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 (ETM_ARBITRATOR requis)");
ThingManager *thingManager = NymeaCore::instance()->thingManager();
// --- Root meter ---
QUuid meterThingId = addMeter();
QVERIFY2(!meterThingId.isNull(), "meter thingId invalide");
m_experiencePlugin->energyManager()->setRootMeter(meterThingId);
Thing *meterThing = thingManager->findConfiguredThing(meterThingId);
QVERIFY(meterThing);
meterThing->setStateValue("connected", true);
// --- Deux relais ECS : palier 1 = relayA (1200 W), palier 2 = A+B (2400 W) ---
QUuid relayAId = addPowerSwitch(1200, 26661);
QUuid relayBId = addPowerSwitch(1200, 26662);
QVERIFY(!relayAId.isNull() && !relayBId.isNull());
Thing *relayA = thingManager->findConfiguredThing(relayAId);
Thing *relayB = thingManager->findConfiguredThing(relayBId);
QVERIFY(relayA && relayB);
// minOn = 300 s (protection compresseur) ; minOff = 0 (pas de délai de redémarrage ici).
const int minOnS = 300;
EcsRelayAdapter *ecs = new EcsRelayAdapter(
thingManager, "ecs-surplus-test", "Chauffe-eau (test surplus)",
QList<int>({0, 1200, 2400}),
QList<QList<QString>>({ {}, {relayAId.toString()}, {relayAId.toString(), relayBId.toString()} }),
minOnS, 0, 1, arbitrator);
arbitrator->registerEcsAdapter(ecs);
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
// currentPower compteur : < 0 = export (surplus PV), > 0 = import (réseau).
auto setMeterW = [&](double signedW){ meterThing->setStateValue("currentPower", signedW); };
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
// ============ Régime 1 — cascade montante sur surplus (export) ============
setMeterW(-1000); cycle(t0); // 1000 W < palier 1 → éteint
QCOMPARE(ecs->currentStage(), 0);
QCOMPARE(relayA->stateValue("power").toBool(), false);
setMeterW(-1500); cycle(t0); // 1500 W → palier 1 (relayA)
QCOMPARE(ecs->currentStage(), 1);
QCOMPARE(relayA->stateValue("power").toBool(), true);
QCOMPARE(relayB->stateValue("power").toBool(), false);
setMeterW(-2500); cycle(t0.addSecs(1)); // 2500 W → palier 2 (montée autorisée même sous minOn)
QCOMPARE(ecs->currentStage(), 2);
QCOMPARE(relayA->stateValue("power").toBool(), true);
QCOMPARE(relayB->stateValue("power").toBool(), true);
// ============ Régime 2 — anti-clignotement (recrédit, hors verrou) ============
// T0+400 : minOn écoulé → plancher de verrou = 0. PV 2500, ECS tire 2400 → export net 100.
// budget = 100 + 2400 (recrédit conso) = 2500 → RESTE palier 2. Sans recrédit : 100 → éteint.
// Le plancher étant 0, c'est bien le RECRÉDIT qui maintient le palier, pas le verrou.
setMeterW(-100); cycle(t0.addSecs(400));
QCOMPARE(ecs->currentStage(), 2);
// Redescente propre au palier 1 (import partiel, minOn écoulé) pour préparer le régime 3.
setMeterW(600); cycle(t0.addSecs(401)); // import 600 → budget = -600 + 2400 = 1800 → palier 1
QCOMPARE(ecs->currentStage(), 1);
QCOMPARE(relayB->stateValue("power").toBool(), false);
// ============ Régime 3 — PROTECTION COMPRESSEUR : import < minOn → RESTE ============
// lastSwitch = T0+401. T0+501 (elapsed 100 < minOn 300) : PV 0, ECS tire 1200 → import 1200.
// budget = -1200 + 1200 = 0 → le scheduler voudrait palier 0, MAIS plancher de verrou = 1.
setMeterW(1200); cycle(t0.addSecs(501));
QCOMPARE(ecs->currentStage(), 1); // RESTE allumé — protection compresseur
QCOMPARE(relayA->stateValue("power").toBool(), true);
// ============ Régime 4 — minOn écoulé → DÉLESTE ============
// T0+801 (elapsed depuis T0+401 = 400 > minOn 300) : MÊME import 1200. Plancher = 0 → palier 0.
// Seul le TEMPS SIMULÉ a changé entre régime 3 et 4 : si le seam de temps était faux
// (horloge murale), la décision ne basculerait pas. Ce test prouve le seam ET la protection.
setMeterW(1200); cycle(t0.addSecs(801));
QCOMPARE(ecs->currentStage(), 0); // DÉLESTE
QCOMPARE(relayA->stateValue("power").toBool(), false);
#endif
}
void Simulation::run_data()
{
// Simulation infos

View File

@ -56,6 +56,10 @@ private slots:
void run_data();
void run();
// Waterfall ECS (EcsRelayAdapter) : cascade surplus, anti-clignotement (recrédit),
// et protection compresseur (import < minOn → RESTE ; import > minOn → déleste).
void testEcsSurplusPV();
void printStates(Thing *thing);
void updateChargerMeter(Thing *thing);

View File

@ -359,7 +359,7 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info)
return;
} else if (thing->thingClassId() == mockPowerSwitchThingClassId) {
} else if (thing->thingClassId() == powerSwitchThingClassId) {
EnergyMockController *controller = new EnergyMockController(thing, this);
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
quint16 port = thing->paramValue(paramType.id()).toUInt();
@ -499,7 +499,7 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info)
}
}
if (thing->thingClassId() == mockPowerSwitchThingClassId) {
if (thing->thingClassId() == powerSwitchThingClassId) {
if (actionType.name() == "power") {
bool power = action.paramValue(actionType.paramTypes().findByName("power").id()).toBool();
double nominal = thing->paramValue(thing->thingClass().paramTypes().findByName("nominalPower").id()).toDouble();

View File

@ -818,7 +818,7 @@
]
},
{
"name": "mockPowerSwitch",
"name": "powerSwitch",
"displayName": "Mocked Power Switch (relais ECS)",
"id": "841f8905-d1d7-4053-909f-01123b497747",
"createMethods": ["user"],