[3e+] ECS non-cascadé : applyRelayStage off-before-on + tests 1 et 3 relais

applyRelayStage faisait déjà du set-cible complet (delta correct, gère le non-cascadé) :
durcissement off-before-on (anti sur-puissance transitoire quand monter de palier éteint
des relais, ex. 3 résistances 500/1000/2000 : 1500→2000 commute 3 relais) + intention
documentée (comme SG-Ready).

testEcsRelayTopologies : ECS simple 1 relais (on/off) + ECS 3 relais non-cascadé
(transition 1500→2000 → set final r2000 SEUL, r500/r1000 coupés). Couvre les 2 topologies
du test terrain vendredi. Suite simulation 20/20, plugin prod 0/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-06-10 00:09:24 +02:00
parent 51760a7f61
commit dfdd9884d0
3 changed files with 119 additions and 9 deletions

View File

@ -172,7 +172,10 @@ bool EcsRelayAdapter::lockActive(int newStage, const QDateTime &now) const
void EcsRelayAdapter::applyRelayStage(int stage)
{
// Ensemble des relais ON pour le nouveau stage
// Set de relais CIBLE du palier (delta complet : chaque relais connu est amené à son
// état cible on/off, pas d'ajout incrémental). Gère les mappings NON-CASCADÉS où monter
// d'un palier éteint des relais (ex. 3 résistances 500/1000/2000 W : 1500=[r500,r1000]
// → 2000=[r2000] commute 3 relais).
const QSet<QString> wantOn = [&]() {
QSet<QString> s;
if (stage < m_relayMapping.size())
@ -181,28 +184,34 @@ void EcsRelayAdapter::applyRelayStage(int stage)
return s;
}();
// Union de tous les relais connus
QSet<QString> allRelays;
for (const auto &list : m_relayMapping)
for (const QString &id : list)
allRelays.insert(id);
for (const QString &thingId : allRelays) {
auto writeRelay = [&](const QString &thingId, bool on) {
Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId));
if (!relay) {
qCWarning(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
<< "— relais non trouvé:" << thingId;
continue;
return;
}
const bool targetOn = wantOn.contains(thingId);
StateType powerStateType = relay->thingClass().stateTypes().findByName("power");
if (!powerStateType.id().isNull()) {
Action powerAction(powerStateType.id(), relay->id(), Action::TriggeredByRule);
powerAction.setParams(ParamList() << Param(powerStateType.id(), targetOn));
powerAction.setParams(ParamList() << Param(powerStateType.id(), on));
m_thingManager->executeAction(powerAction);
} else {
// Fallback mock : setStateValue direct (Things sans actionType "power")
relay->setStateValue("power", targetOn);
relay->setStateValue("power", on); // repli mock
}
}
};
// Intention (résistif : transitoire inoffensif, mais portée comme SG-Ready) : COUPER
// d'abord les relais hors-cible, PUIS enclencher ceux de la cible → pas de sur-puissance
// transitoire (somme des deux paliers) sur une transition non-cascadée.
for (const QString &id : allRelays)
if (!wantOn.contains(id))
writeRelay(id, false);
for (const QString &id : wantOn)
writeRelay(id, true);
}

View File

@ -355,6 +355,103 @@ void Simulation::testSgReadySurplus()
#endif
}
void Simulation::testEcsRelayTopologies()
{
#ifndef ETM_ARBITRATOR
QSKIP("testEcsRelayTopologies nécessite ETM_ARBITRATOR.");
#else
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
// Setup commun : arbitre frais + root meter. Retourne meter + thingManager via réf.
auto freshSetup = [&](EnergyArbitrator *&arb, ThingManager *&tm, Thing *&meter) {
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
tm = NymeaCore::instance()->thingManager();
QUuid mId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(mId);
meter = tm->findConfiguredThing(mId);
QVERIFY(meter);
meter->setStateValue("connected", true);
};
// ===================== Topologie 1 : ECS simple (1 relais, [0, 2000]) =====================
{
EnergyArbitrator *arb; ThingManager *tm; Thing *meter;
freshSetup(arb, tm, meter);
QUuid r = addPowerSwitch(2000, 26661);
Thing *relay = tm->findConfiguredThing(r);
QVERIFY(relay);
EcsRelayAdapter *ecs = new EcsRelayAdapter(
tm, "ecs-1relay", "ECS simple",
QList<int>({0, 2000}),
QList<QList<QString>>({ {}, {r.toString()} }),
0, 0, 1, arb);
arb->registerEcsAdapter(ecs);
meter->setStateValue("currentPower", -2500); // surplus 2500 → palier 1
arb->simulationCallUpdate(t0); QCoreApplication::processEvents();
QCOMPARE(ecs->currentStage(), 1);
QCOMPARE(relay->stateValue("power").toBool(), true);
meter->setStateValue("currentPower", 1000); // import 1000 → palier 0 (off)
arb->simulationCallUpdate(t0.addSecs(1)); QCoreApplication::processEvents();
QCOMPARE(ecs->currentStage(), 0);
QCOMPARE(relay->stateValue("power").toBool(), false);
}
// ============= Topologie 2 : ECS 3 relais 500/1000/2000 W (mapping NON-CASCADÉ) =============
{
EnergyArbitrator *arb; ThingManager *tm; Thing *meter;
freshSetup(arb, tm, meter);
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);
// 8 niveaux binaires ; encodage bit0=r500, bit1=r1000, bit2=r2000.
EcsRelayAdapter *ecs = new EcsRelayAdapter(
tm, "ecs-3relay", "ECS 3 relais",
QList<int>({0, 500, 1000, 1500, 2000, 2500, 3000, 3500}),
QList<QList<QString>>({
{}, // 0
{r500.toString()}, // 500
{r1000.toString()}, // 1000
{r500.toString(), r1000.toString()}, // 1500
{r2000.toString()}, // 2000
{r500.toString(), r2000.toString()}, // 2500
{r1000.toString(), r2000.toString()}, // 3000
{r500.toString(), r1000.toString(), r2000.toString()}// 3500
}),
0, 0, 1, arb);
arb->registerEcsAdapter(ecs);
// Surplus 1700 → palier 1500 = [r500, r1000] (r2000 éteint).
meter->setStateValue("currentPower", -1700);
arb->simulationCallUpdate(t0); QCoreApplication::processEvents();
QCOMPARE(ecs->currentStage(), 3);
QCOMPARE(t500->stateValue("power").toBool(), true);
QCOMPARE(t1000->stateValue("power").toBool(), true);
QCOMPARE(t2000->stateValue("power").toBool(), false);
// Transition NON-CASCADÉE 1500 → 2000 : à stage 3 l'ECS mesure 1500 W (r500+r1000),
// export net 700 → budget 700+1500=2200 → palier 2000 = [r2000] SEUL.
// Vérifie le set FINAL : r500 OFF, r1000 OFF, r2000 ON (commutation de 3 relais).
meter->setStateValue("currentPower", -700);
arb->simulationCallUpdate(t0.addSecs(1)); QCoreApplication::processEvents();
QCOMPARE(ecs->currentStage(), 4);
QCOMPARE(t500->stateValue("power").toBool(), false);
QCOMPARE(t1000->stateValue("power").toBool(), false);
QCOMPARE(t2000->stateValue("power").toBool(), true);
}
#endif
}
void Simulation::run_data()
{
// Simulation infos

View File

@ -68,6 +68,10 @@ private slots:
// et interaction budget PARTAGÉ ECS↔PAC (preuve du waterfall unifié 3e).
void testSgReadySurplus();
// ECS topologies : 1 relais (simple) + 3 relais valeurs différentes (mapping NON-CASCADÉ,
// transition 1500→2000 qui commute 3 relais) — vérifie le set de relais final correct.
void testEcsRelayTopologies();
void printStates(Thing *thing);
void updateChargerMeter(Thing *thing);