Délestage visible : tests de simulation

- borne abaissée, coupée, puis reprise par le cycle : une entrée au journal,
  et une fermeture qui notifie sans autre cause (contre-épreuve : un cycle
  identique ne notifie rien) ;
- borne en mode manuel, hors de loads[] : l'épisode s'ouvre et se ferme par
  notification, et la reprise amont le referme ;
- relais et SG-Ready délestés pendant 20 minutes : une entrée par charge,
  avant et après dans le langage de chaque mécanisme ;
- 21 épisodes : le plus ancien sort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-25 11:05:53 +01:00
parent da6558fa8e
commit 1599acfba9
2 changed files with 490 additions and 0 deletions

View File

@ -11916,3 +11916,480 @@ void Simulation::testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases()
qUtf8Printable(trace)); qUtf8Printable(trace));
#endif #endif
} }
// ============================================================================================
// Lot délestage visible (2026-10-25) — la protection PUBLIE ce qu'elle fait, elle ne décide rien
// de plus. Question de Patrick : « Si le délestage coupe une charge, est-ce que le client voit une
// alerte quelque part ? » Avant ce lot : non.
// ============================================================================================
namespace {
//! Les événements du journal des délestages, par la frontière RPC — comme l'app les lit.
QVariantList evenementsDelestage(const QVariant &reponse)
{
return reponse.toMap().value("params").toMap().value("events").toList();
}
//! Un compteur aux trois phases posées à la main (A), les deux autres à 2 A.
void poserPhaseA(Thing *compteur, double amperesA)
{
compteur->setStateValue("currentPhaseA", amperesA);
compteur->setStateValue("currentPowerPhaseA", amperesA * 230);
for (const char *ph : {"B", "C"}) {
compteur->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 2.0);
compteur->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 460.0);
}
compteur->setStateValue("currentPower", (amperesA + 4) * 230);
}
} // namespace
void Simulation::testOverloadProtectionReasonOnTheCharger()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
// Une borne monophasée 16 A sur la phase A, en « éco avec minimum », servie à pleine puissance
// en heures creuses ; limite de branchement 25 A. Le compteur VOIT la borne. La protection de
// surcharge de l'amont la rabaisse, puis la coupe : avant ce lot, rien n'en sortait.
FuseauDeTest fuseau("Europe/Paris");
ScenarioHc s = preparerScenarioHc(QStringLiteral("protection"), BatterieHc::Aucune, hphcT4(),
BorneHc::Monophasee);
QVERIFY(s.arb && s.borne);
const QString borne = s.borneId.toString();
QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
s.borne->setStateValue("pluggedIn", true);
QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); // 23:00 à Paris : heures creuses
QVariantMap snap;
const auto cycle = [&](double maisonA) {
a = a.addSecs(360); // au-delà des verrous de la borne
snap = cycleHc(s, a, compteurVoitLaBorne(s.meter, s.borne, maisonA));
};
const auto entree = [&]() { return telemetrieParId(snap).value(borne); };
const auto decision = [&]() { return entree().value("decision").toMap(); };
const auto journal = [&]() { return evenementsDelestage(injectAndWait("NymeaEnergy.GetSheddingLog")); };
// 0. TÉMOIN — maison à 2 A : la borne à pleine puissance, aucun délestage. Le bloc `shedding`
// est ABSENT, et la télémétrie est exactement celle d'avant ce lot.
cycle(2);
cycle(2);
QCOMPARE(consigneA(s.borne), 16);
QCOMPARE(decision().value("code").toString(), QString(DecisionCode::EvOffPeakFullPower));
QVERIFY2(!snap.contains("shedding"), "aucun délestage : le bloc doit être absent, pas à zéro");
QVERIFY(journal().isEmpty());
// 1. RABAISSÉE — un four s'allume : 12 A de maison, 28 A sur la phase A pour 25. La
// protection rabaisse la borne du dépassement arrondi à l'ampère supérieur.
QSignalSpy notifs(s.arb, &EnergyArbitrator::loadTelemetryChanged);
cycle(12);
const int tenusA = consigneA(s.borne);
QVERIFY2(tenusA > 0 && tenusA < 16, qUtf8Printable(QString::number(tenusA)));
QCOMPARE(decision().value("code").toString(), QString(DecisionCode::OverloadThrottled));
QVariantMap p = decision().value("params").toMap();
QCOMPARE(p.value("fromA").toInt(), 16);
QCOMPARE(p.value("toA").toInt(), tenusA); // ce que la borne a réellement reçu
QCOMPARE(p.value("cut").toBool(), false);
QCOMPARE(p.value("phase").toString(), QStringLiteral("A"));
QCOMPARE(p.value("limitA").toInt(), 25);
QCOMPARE(p.value("overshootW").toInt(), 690); // (28 − 25) × 230
// Préséance L4 : l'action entière est celle de la protection — sans niveaux, et l'allocation
// dit ce qu'elle laisse à la borne.
QVERIFY(!entree().contains("levels"));
QVERIFY(!decision().contains("level"));
QCOMPARE(qRound(entree().value("allocatedW").toDouble()), tenusA * 230);
QCOMPARE(renderFr(DecisionReason(DecisionCode::OverloadThrottled, p), "Borne"),
QStringLiteral("Borne abaissée à %1 A : le branchement atteignait sa limite (phase A, "
"25 A), dépassement de 690 W").arg(tenusA));
// La notification est partie, et elle porte le résumé du journal.
QVERIFY(!notifs.isEmpty());
QVariantMap resume = notifs.last().first().toMap().value("shedding").toMap();
QCOMPARE(resume.value("open").toStringList(), QStringList{borne});
QVERIFY(resume.value("revision").toInt() > 0);
QVariantList evts = journal();
QCOMPARE(evts.size(), 1);
QVariantMap e = evts.first().toMap();
QCOMPARE(e.value("loadId").toString(), borne);
QVERIFY(!e.contains("endedAt"));
QCOMPARE(e.value("source").toString(), QStringLiteral("connection"));
QCOMPARE(e.value("phase").toString(), QStringLiteral("A"));
QCOMPARE(e.value("before").toMap().value("mechanism").toMap().value("kind").toString(),
QStringLiteral("evcharger"));
QCOMPARE(e.value("before").toMap().value("mechanism").toMap().value("currentA").toInt(), 16);
QCOMPARE(e.value("before").toMap().value("powerW").toInt(), 16 * 230);
QCOMPARE(e.value("after").toMap().value("mechanism").toMap().value("currentA").toInt(), tenusA);
QCOMPARE(e.value("cut").toBool(), false);
const QString debut = e.value("startedAt").toString();
// 2. COUPÉE — la maison monte à 22 A : même au plancher de 6 A, la phase dépasse. La
// protection coupe, et c'est TOUJOURS le même épisode.
cycle(22);
QCOMPARE(consigneA(s.borne), 0);
QCOMPARE(decision().value("code").toString(), QString(DecisionCode::OverloadThrottled));
p = decision().value("params").toMap();
QCOMPARE(p.value("cut").toBool(), true);
QCOMPARE(p.value("toA").toInt(), 0);
QCOMPARE(p.value("fromA").toInt(), 16);
QCOMPARE(qRound(entree().value("allocatedW").toDouble()), 0);
const QString coupee = renderFr(DecisionReason(DecisionCode::OverloadThrottled, p), "Borne");
QVERIFY2(coupee.startsWith(QStringLiteral("Borne coupée : le branchement atteignait sa limite "
"(phase A, 25 A)")), qUtf8Printable(coupee));
evts = journal();
QCOMPARE(evts.size(), 1);
e = evts.first().toMap();
QCOMPARE(e.value("startedAt").toString(), debut);
QCOMPARE(e.value("cut").toBool(), true);
QCOMPARE(e.value("after").toMap().value("powerW").toInt(), 0);
QCOMPARE(e.value("after").toMap().value("mechanism").toMap().value("chargingEnabled").toBool(), false);
QVERIFY(!e.contains("endedAt"));
// 3. RETOUR — le four s'éteint. Le cycle reprend la borne : le motif se lève AU MÊME CYCLE.
// L'épisode, lui, n'est clos qu'après un cycle complet sans protection — daté de la reprise.
cycle(2);
const QDateTime reprise = a;
QCOMPARE(consigneA(s.borne), 16);
QCOMPARE(decision().value("code").toString(), QString(DecisionCode::EvOffPeakFullPower));
QVERIFY(entree().contains("levels"));
QVERIFY(!journal().first().toMap().contains("endedAt"));
notifs.clear();
const int revisionAvant = snap.value("shedding").toMap().value("revision").toInt();
cycle(2);
evts = journal();
QCOMPARE(evts.size(), 1); // UN épisode, pas un par cycle
e = evts.first().toMap();
QCOMPARE(e.value("startedAt").toString(), debut);
QCOMPARE(e.value("endedAt").toString(), reprise.toUTC().toString(Qt::ISODate));
QVERIFY(snap.value("shedding").toMap().value("open").toStringList().isEmpty());
QCOMPARE(snap.value("shedding").toMap().value("revision").toInt(), revisionAvant + 1);
// 4. LA FERMETURE NOTIFIE, alors que rien d'autre ne change : même motif, même allocation.
QCOMPARE(notifs.size(), 1);
QVERIFY(notifs.first().first().toMap().value("shedding").toMap().value("open").toStringList().isEmpty());
// Contre-épreuve : le même cycle, rejoué, ne notifie RIEN. La notification précédente
// venait donc de la fermeture seule.
notifs.clear();
cycle(2);
QCOMPARE(notifs.size(), 0);
#endif
}
void Simulation::testOverloadProtectionOnAManualChargerIsLogged()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
// Une borne en mode MANUEL est hors de l'arbitrage, donc hors de loads[] : seule la protection
// de surcharge la touche encore, et elle peut la rabaisser la nuit sans que rien ne le dise.
// Ici le journal le dit, et la notification part même si rien d'autre ne change.
ScenarioHc s = preparerScenarioHc(QStringLiteral("manuel"), BatterieHc::Aucune, QVariantList(),
BorneHc::Monophasee);
QVERIFY(s.arb && s.borne);
const QString borne = s.borneId.toString();
QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
// Ce que l'usager a réglé à la main — c'est ce que la reprise lui rendra.
{
EnergySettings reglages;
reglages.beginGroup("ChargingInfos");
reglages.beginGroup(borne);
reglages.setValue("manualChargingEnabled", true);
reglages.setValue("manualMaxChargingCurrent", 16);
reglages.setValue("manualDesiredPhaseCount", 1);
reglages.endGroup();
reglages.endGroup();
}
s.borne->setStateValue("pluggedIn", true);
const QVariantMap info = injectAndWait("NymeaEnergy.GetChargingInfos").toMap().value("params").toMap()
.value("chargingInfos").toList().first().toMap();
QVariantMap manuel = info;
manuel.insert("chargingMode", "ChargingModeNormal");
QCOMPARE(injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", manuel}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
QCOMPARE(consigneA(s.borne), 16);
// L'HORLOGE MURALE, et elle seule : le passage en mode manuel restitue les valeurs de
// l'usager avec QDateTime::currentDateTime() (code amont), et arme les verrous de la borne sur
// cet instant. Un temps de test antérieur les tiendrait fermés, et la protection serait
// refusée à chaque geste. On part donc au-delà du verrou d'enclenchement (300 s).
QDateTime a = QDateTime::currentDateTime().addSecs(600);
QVariantMap snap;
const auto cycle = [&](double phaseA) {
a = a.addSecs(60);
poserPhaseA(s.meter, phaseA);
s.arb->recordMeterUpdate(a);
s.arb->simulationCallUpdate(a);
QCoreApplication::processEvents();
snap = s.arb->loadTelemetry();
};
const auto journal = [&]() { return evenementsDelestage(injectAndWait("NymeaEnergy.GetSheddingLog")); };
cycle(18); // 2 A de maison + 16 A de borne
QVERIFY(!telemetrieParId(snap).contains(borne)); // hors arbitrage : hors de loads[]
QVERIFY(!snap.contains("shedding"));
// 1. 28 A sur la phase A : la protection rabaisse la borne, deux fois dans l'appel (dans le
// cycle, puis sur powerBalanceChanged — le compteur n'a pas encore bougé).
QSignalSpy notifs(s.arb, &EnergyArbitrator::loadTelemetryChanged);
cycle(28);
const int tenusA = consigneA(s.borne);
QVERIFY2(tenusA > 0 && tenusA < 16, qUtf8Printable(QString::number(tenusA)));
QVERIFY(!telemetrieParId(snap).contains(borne)); // toujours hors de loads[]
QCOMPARE(snap.value("shedding").toMap().value("open").toStringList(), QStringList{borne});
// L'OUVERTURE NOTIFIE, et rien d'autre ne pouvait le faire : la borne n'est pas dans loads[].
QVERIFY(!notifs.isEmpty());
QCOMPARE(notifs.last().first().toMap().value("shedding").toMap().value("open").toStringList(),
QStringList{borne});
QVariantList evts = journal();
QCOMPARE(evts.size(), 1);
QVariantMap e = evts.first().toMap();
QCOMPARE(e.value("before").toMap().value("mechanism").toMap().value("currentA").toInt(), 16);
QCOMPARE(e.value("after").toMap().value("mechanism").toMap().value("currentA").toInt(), tenusA);
QCOMPARE(e.value("phase").toString(), QStringLiteral("A"));
QCOMPARE(e.value("overshootW").toInt(), 690);
QVERIFY(!e.contains("endedAt"));
// 2. La maison redescend : verifyOverloadProtectionRecovery() rend les 16 A manuels. La tenue
// est levée ; l'épisode se clôt au cycle complet suivant, daté de la reprise.
cycle(2 + tenusA);
const QDateTime reprise = a;
QCOMPARE(consigneA(s.borne), 16);
notifs.clear();
cycle(18);
e = journal().first().toMap();
QCOMPARE(e.value("endedAt").toString(), reprise.toUTC().toString(Qt::ISODate));
QVERIFY(snap.value("shedding").toMap().value("open").toStringList().isEmpty());
// LA FERMETURE NOTIFIE, seule cause possible : la borne n'est pas publiée dans loads[].
QCOMPARE(notifs.size(), 1);
notifs.clear();
cycle(18);
QCOMPARE(notifs.size(), 0); // contre-épreuve : rien ne change, rien ne part
QCOMPARE(journal().size(), 1);
#endif
}
void Simulation::testSheddingLogCoversEveryMechanism()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
// Deux mécanismes que L4 déleste de deux façons : un chauffe-eau à relais (palier en watts,
// coupé) et une PAC SG-Ready (état, rendue à 2 — jamais coupée). Le journal les enregistre
// sans connaître leur type : chacun dit « avant » et « après » dans la charge utile que son
// adaptateur publie. Puis le dépassement dure 20 minutes : UNE entrée par charge.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-journal-delestage.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
ThingManager *tm = NymeaCore::instance()->thingManager();
const QUuid meterId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(meterId);
Thing *meter = tm->findConfiguredThing(meterId);
QVERIFY(meter);
meter->setStateValue("connected", true);
const QUuid k1 = addPowerSwitch(0, 26694);
const QUuid k2 = addPowerSwitch(0, 26695);
const QUuid ecsRelais = addPowerSwitch(2000, 26696);
QVERIFY(tm->findConfiguredThing(k1) && tm->findConfiguredThing(k2) && tm->findConfiguredThing(ecsRelais));
LoadConfigStore *store = new LoadConfigStore(arb);
arb->setLoadConfigStore(store);
LoadConfigs cfgs;
cfgs.append(LoadConfig::fromMap(QVariantMap{
{"id", "pac-delestee"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"},
{"priority", 2}, {"enabled", true}, {"domain", "hvac"},
{"sgReady", QVariantMap{
{"minStateHoldS", 300},
{"states", QVariantList()
<< QVariantMap{{"state", 1}, {"relays", QVariantList() << k1.toString()}, {"estimatedPowerW", 0.0}}
<< QVariantMap{{"state", 2}, {"relays", QVariantList()}, {"estimatedPowerW", 0.0}}
<< QVariantMap{{"state", 3}, {"relays", QVariantList() << k2.toString()}, {"estimatedPowerW", 800.0}}
<< QVariantMap{{"state", 4}, {"relays", QVariantList() << k1.toString() << k2.toString()},
{"estimatedPowerW", 3000.0}}}}}}));
cfgs.append(LoadConfig::fromMap(QVariantMap{
{"id", "ecs-delestee"}, {"label", "Chauffe-eau"}, {"adapter", "relay-router"},
{"mode", "fixed"}, {"priority", 3}, {"enabled", true}, {"domain", "ecs"},
{"relays", QVariantList() << QVariantMap{{"thingId", ecsRelais.toString()}, {"powerW", 2000}}},
{"minOnS", 0}, {"minOffS", 0}}));
QString err;
QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8());
QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
QVariantMap snap;
const auto cycle = [&](const QDateTime &at, double phaseA, double totalW) {
meter->setStateValue("currentPhaseA", phaseA);
meter->setStateValue("currentPhaseB", 0);
meter->setStateValue("currentPhaseC", 0);
meter->setStateValue("currentPower", totalW);
arb->simulationCallUpdate(at);
QTest::qWait(20);
QCoreApplication::processEvents();
snap = arb->loadTelemetry();
return telemetrieParId(snap);
};
const auto journal = [&]() { return evenementsDelestage(injectAndWait("NymeaEnergy.GetSheddingLog")); };
const auto parCharge = [&]() {
QHash<QString, QVariantMap> h;
for (const QVariant &v : journal())
h.insert(v.toMap().value("loadId").toString(), v.toMap());
return h;
};
// Le surplus porte les deux charges : la PAC en état 4 (après son armement à froid), le
// chauffe-eau à 2000 W.
const QDateTime t0 = utcDateTime(QDate(2026, 8, 30), QTime(13, 0, 0));
cycle(t0, 0, -6000);
QHash<QString, QVariantMap> tele = cycle(t0.addSecs(3600), 0, -6000);
QCOMPARE(tele.value("pac-delestee").value("mechanism").toMap().value("state").toInt(), 4);
QCOMPARE(qRound(tele.value("ecs-delestee").value("mechanism").toMap().value("stageW").toDouble()), 2000);
QVERIFY(!snap.contains("shedding"));
// DÉPASSEMENT de 2 300 W sur la phase A (35 A pour 25) : L4 déleste en ordre inverse du rang,
// le chauffe-eau (rang 3, 2 000 W) puis la PAC (rang 2, 3 000 W).
QSignalSpy notifs(arb, &EnergyArbitrator::loadTelemetryChanged);
const QDateTime t2 = t0.addSecs(3610);
tele = cycle(t2, 35, 8050);
QCOMPARE(tele.value("ecs-delestee").value("decision").toMap().value("code").toString(), QString(DecisionCode::DrawCap));
QCOMPARE(tele.value("pac-delestee").value("decision").toMap().value("code").toString(), QString(DecisionCode::DrawCap));
QCOMPARE(snap.value("shedding").toMap().value("open").toStringList(),
(QStringList{"ecs-delestee", "pac-delestee"}));
QVERIFY(!notifs.isEmpty());
QCOMPARE(notifs.last().first().toMap().value("shedding").toMap().value("open").toStringList(),
(QStringList{"ecs-delestee", "pac-delestee"}));
QHash<QString, QVariantMap> j = parCharge();
QCOMPARE(j.size(), 2);
// Le chauffe-eau : un PALIER, en watts, COUPÉ.
const QVariantMap ecs = j.value("ecs-delestee");
QCOMPARE(ecs.value("before").toMap().value("mechanism").toMap().value("kind").toString(), QStringLiteral("relay"));
QCOMPARE(qRound(ecs.value("before").toMap().value("mechanism").toMap().value("stageW").toDouble()), 2000);
QCOMPARE(qRound(ecs.value("after").toMap().value("mechanism").toMap().value("stageW").toDouble()), 0);
QCOMPARE(ecs.value("before").toMap().value("powerW").toInt(), 2000);
QCOMPARE(ecs.value("after").toMap().value("powerW").toInt(), 0);
QCOMPARE(ecs.value("cut").toBool(), true);
// La PAC : un ÉTAT, de 4 à 2 — rendue à son thermostat, pas coupée (ECS-413).
const QVariantMap pac = j.value("pac-delestee");
QCOMPARE(pac.value("before").toMap().value("mechanism").toMap().value("kind").toString(), QStringLiteral("sgReady"));
QCOMPARE(pac.value("before").toMap().value("mechanism").toMap().value("state").toInt(), 4);
QCOMPARE(pac.value("after").toMap().value("mechanism").toMap().value("state").toInt(), 2);
QCOMPARE(pac.value("before").toMap().value("powerW").toInt(), 3000);
QCOMPARE(pac.value("cut").toBool(), false);
for (const QVariantMap &ev : {ecs, pac}) {
QCOMPARE(ev.value("source").toString(), QStringLiteral("connection"));
QCOMPARE(ev.value("phase").toString(), QStringLiteral("A"));
QCOMPARE(ev.value("overshootW").toInt(), 2300);
QCOMPARE(ev.value("startedAt").toString(), t2.toUTC().toString(Qt::ISODate));
QVERIFY(!ev.contains("endedAt"));
}
// LE DÉPASSEMENT DURE 20 MINUTES — un cycle par minute. Plus rien à délester (tout est à 0),
// et la cascade ne rallume rien sous un plafond épuisé : les deux épisodes restent OUVERTS,
// et il n'en naît aucun autre.
QDateTime t = t2;
for (int k = 0; k < 20; ++k) {
t = t.addSecs(60);
cycle(t, 35, 8050);
}
j = parCharge();
QCOMPARE(journal().size(), 2);
QVERIFY(!j.value("ecs-delestee").contains("endedAt"));
QVERIFY(!j.value("pac-delestee").contains("endedAt"));
// Le dépassement cesse : le plafond ne tient plus personne. Les deux épisodes se ferment à la
// fin de ce cycle, datés de lui — 21 minutes, une entrée chacun.
notifs.clear();
const QDateTime fin = t.addSecs(60);
cycle(fin, 0, -6000);
j = parCharge();
QCOMPARE(journal().size(), 2);
for (const QString &id : {QStringLiteral("ecs-delestee"), QStringLiteral("pac-delestee")}) {
QCOMPARE(j.value(id).value("startedAt").toString(), t2.toUTC().toString(Qt::ISODate));
QCOMPARE(j.value(id).value("endedAt").toString(), fin.toUTC().toString(Qt::ISODate));
}
QVERIFY(snap.value("shedding").toMap().value("open").toStringList().isEmpty());
QVERIFY(!notifs.isEmpty());
QVERIFY(notifs.last().first().toMap().value("shedding").toMap().value("open").toStringList().isEmpty());
#endif
}
void Simulation::testSheddingLogKeepsTheLastEpisodes()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
// Vingt et un épisodes sur un chauffe-eau : un cycle en dépassement, un cycle libre. Le journal
// en garde vingt ; le plus ancien sort, et aucun identifiant n'est réutilisé.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-journal-tournant.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
ThingManager *tm = NymeaCore::instance()->thingManager();
const QUuid meterId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(meterId);
Thing *meter = tm->findConfiguredThing(meterId);
QVERIFY(meter);
meter->setStateValue("connected", true);
const QUuid relais = addPowerSwitch(2000, 26697);
QVERIFY(tm->findConfiguredThing(relais));
LoadConfigStore *store = new LoadConfigStore(arb);
arb->setLoadConfigStore(store);
QString err;
QVERIFY2(store->setConfigs(LoadConfigs() << LoadConfig::fromMap(QVariantMap{
{"id", "ecs-tournant"}, {"label", "Chauffe-eau"}, {"adapter", "relay-router"},
{"mode", "fixed"}, {"priority", 1}, {"enabled", true},
{"relays", QVariantList() << QVariantMap{{"thingId", relais.toString()}, {"powerW", 2000}}},
{"minOnS", 0}, {"minOffS", 0}}), &err), err.toUtf8());
QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
QDateTime t = utcDateTime(QDate(2026, 8, 30), QTime(13, 0, 0));
const auto cycle = [&](double phaseA, double totalW) {
t = t.addSecs(60);
meter->setStateValue("currentPhaseA", phaseA);
meter->setStateValue("currentPhaseB", 0);
meter->setStateValue("currentPhaseC", 0);
meter->setStateValue("currentPower", totalW);
arb->simulationCallUpdate(t);
QCoreApplication::processEvents();
};
cycle(0, -6000); // le chauffe-eau s'enclenche sur le surplus
QStringList debuts;
for (int n = 0; n < SheddingLog::DefaultCapacity + 1; ++n) {
cycle(30, 6900); // 1 150 W de trop : délesté
debuts << t.toUTC().toString(Qt::ISODate);
cycle(0, -6000); // libre : rallumé, l'épisode se ferme
}
const QVariantMap reponse = injectAndWait("NymeaEnergy.GetSheddingLog").toMap().value("params").toMap();
const QVariantList evts = reponse.value("events").toList();
QCOMPARE(reponse.value("capacity").toInt(), SheddingLog::DefaultCapacity);
QCOMPARE(evts.size(), SheddingLog::DefaultCapacity);
// Le PREMIER épisode est sorti : le plus ancien gardé est le deuxième.
QCOMPARE(evts.first().toMap().value("id").toInt(), 2);
QCOMPARE(evts.first().toMap().value("startedAt").toString(), debuts.at(1));
QCOMPARE(evts.last().toMap().value("id").toInt(), SheddingLog::DefaultCapacity + 1);
QCOMPARE(evts.last().toMap().value("startedAt").toString(), debuts.last());
for (const QVariant &v : evts)
QVERIFY2(v.toMap().contains("endedAt"),
qUtf8Printable(QJsonDocument::fromVariant(v).toJson(QJsonDocument::Compact)));
#endif
}

View File

@ -333,6 +333,19 @@ private slots:
// relais fantôme comprise — et ce chiffre entrait dans le RECRÉDIT, donc dans le budget. // relais fantôme comprise — et ce chiffre entrait dans le RECRÉDIT, donc dans le budget.
void testVanishedRelayFreezesTheLoad(); void testVanishedRelayFreezesTheLoad();
// ── Lot délestage visible (2026-10-25) ──────────────────────────────────
//! La borne abaissée puis coupée par la protection de surcharge le DIT (OVERLOAD_THROTTLED),
//! le motif se lève au retour du cycle, et l'épisode fait UNE entrée du journal.
void testOverloadProtectionReasonOnTheCharger();
//! Une borne en mode manuel, hors de loads[] : l'épisode se lit au journal, s'ouvre et se
//! ferme par une notification qui n'a pas d'autre cause, et la reprise amont le referme.
void testOverloadProtectionOnAManualChargerIsLogged();
//! Deux mécanismes délestés par L4 (relais, SG-Ready) : un épisode de 20 min = une entrée
//! par charge, avant / après dans le langage de chaque mécanisme.
void testSheddingLogCoversEveryMechanism();
//! N + 1 épisodes : le plus ancien sort, les identifiants ne sont jamais réutilisés.
void testSheddingLogKeepsTheLastEpisodes();
void printStates(Thing *thing); void printStates(Thing *thing);
void updateChargerMeter(Thing *thing); void updateChargerMeter(Thing *thing);