test(etm): restaurer testEcsRelayTopologies côté routeur + test config→RelayRouter (rév. 3)

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) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-06-28 13:00:21 +02:00
parent 88626cfd56
commit 4d2e457ed2
2 changed files with 159 additions and 7 deletions

View File

@ -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<EnergyArbitrator *>(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<LoadConfigRelay>({ {r.toString(), 2000} }), 0, 0, 1, LoadNeeds(), owner);
QCOMPARE(ecs->descriptor().declared.powerLevels, QList<int>({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<LoadConfigRelay>({ {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<int>({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<LoadConfigRelay>({ {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<int>({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<EnergyArbitrator *>(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()

View File

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