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:
parent
da6558fa8e
commit
1599acfba9
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user