diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 260ee51..a16be05 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -11916,3 +11916,480 @@ void Simulation::testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases() qUtf8Printable(trace)); #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(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 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 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 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(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 +} diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 2a0fe77..ac20465 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -333,6 +333,19 @@ private slots: // relais fantôme comprise — et ce chiffre entrait dans le RECRÉDIT, donc dans le budget. 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 updateChargerMeter(Thing *thing);