From f20b08a3a204d664d63753a29113e9298f654b25 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 27 Oct 2026 12:28:42 +0100 Subject: [PATCH] =?UTF-8?q?test=20:=20reproductions=20du=20lot=20d=C3=A9le?= =?UTF-8?q?stage=20triphas=C3=A9=20(rouges=20sur=201.15.2+etm76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - testOverloadLeavesTheChargerItsPhases (3 cas) et testAnUnassignedCarTakesItsPhasesAway : une borne triphasée sans voiture, ou dont la voiture monophasée est désassignée, comptée sur une phase — 3 680 W alloués et attendus pour 16 A × 3. - testOverloadReactsToTheTotalWhenPhasesLag : des courants par phase en retard sur le total retardent la protection. - testPhaseSafetyMargin : la marge de 10 % (décision du 2026-10-27). - testTheDrawCapHoldsAgainstAnOptimizerAtMaximum (refoptimizer) : sous un optimiseur qui demande le maximum, ce que la protection a retiré revient au cycle suivant. - Témoins verts sur etm76 : testSheddingOpensTheRelaysUnderMinOn (le délestage franchit minOn et ouvre les relais) et testCommandDivergenceSeesAPhaseCountError (×3 vu en régime établi). - Optimiseur de référence : mode over-limit (toute consigne en watts à 50 kW). Co-Authored-By: Claude Opus 5.5 --- tests/auto/refoptimizer/testrefoptimizer.cpp | 110 +++++ tests/auto/simulation/simulation.cpp | 462 ++++++++++++++++++ tests/auto/simulation/simulation.h | 29 ++ .../tools/etm-powersync-schema-check-test.py | 2 + tools/etm-powersync-ref-optimizer/README.md | 3 +- .../etm-powersync-ref-optimizer | 9 +- 6 files changed, 613 insertions(+), 2 deletions(-) diff --git a/tests/auto/refoptimizer/testrefoptimizer.cpp b/tests/auto/refoptimizer/testrefoptimizer.cpp index 30bfe7a..f52abe5 100644 --- a/tests/auto/refoptimizer/testrefoptimizer.cpp +++ b/tests/auto/refoptimizer/testrefoptimizer.cpp @@ -223,6 +223,7 @@ private slots: void testAProtocol10OptimizerIsAccepted(); void testTracesReplayWithZeroDivergence(); void testSafetyWinsOverTheOptimizer(); + void testTheDrawCapHoldsAgainstAnOptimizerAtMaximum(); void testAnEchoedTokenLeaksNowhereEndToEnd(); void cleanupTestCase(); @@ -936,6 +937,115 @@ void TestRefOptimizer::testSafetyWinsOverTheOptimizer() QCOMPARE(triplet(), QString("attendu|vivant|optimizer")); } +void TestRefOptimizer::testTheDrawCapHoldsAgainstAnOptimizerAtMaximum() +{ + // DÉFAUTS 4 et 5 — relevés le 2026-10-27 sur une installation d'essai, sous un optimiseur qui + // demande le MAXIMUM à tout. La protection déleste ; au cycle suivant, le compteur est revenu + // juste sous le seuil, le délestage se relâche, l'optimiseur redemande tout, et la surcharge + // reprend une minute. Aucune mémoire entre la protection et le plan suivant : la marge que le + // compteur laisse n'était opposée à AUCUNE consigne d'optimiseur — seulement à la cascade du + // rule-based. Ici la borne (triphasée, sans voiture) et la charge variable 2 jouent le rôle de + // la borne et du chauffe-eau de l'essai. + monter(Banc::Restreint); + if (QTest::currentTestFailed()) + return; + ThingManager *tm = NymeaCore::instance()->thingManager(); + const QUuid borneId = addCharger("ABC", 16, 26818); + Thing *borne = tm->findConfiguredThing(borneId); + QVERIFY(borne); + for (const char *e : {"connected", "pluggedIn"}) + borne->setStateValue(e, true); + borne->setStateValue("power", false); + borne->setStateValue("maxChargingCurrent", 6); + QCOMPARE(injectAndWait("NymeaEnergy.SetChargingInfo", + QVariantMap{{"chargingInfo", QVariantMap{{"evChargerId", borneId}, + {"chargingMode", "ChargingModeEco"}}}}) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + m_arb->setPhasePowerLimit(25); + RefOptimizer ref; + brancher(ref, Transport::Unix, {"--mode", "over-limit"}); + if (QTest::currentTestFailed()) + return; + + // Un pas : le compteur est POSÉ (total et phases), la réponse de l'optimiseur attendue, puis + // les charges variables consomment leur consigne. La borne, elle, mesure ce qu'elle tient. + QDateTime t = utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0)); + const auto pas = [&](double totalW, double phaseA, int dtS = 60) { + t = t.addSecs(dtS); + m_compteur->setStateValue("currentPower", totalW); + for (const char *ph : {"A", "B", "C"}) { + m_compteur->setStateValue(QStringLiteral("currentPhase%1").arg(ph), phaseA); + m_compteur->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), phaseA * 230); + } + const int avant = m_jugees; + m_arb->recordMeterUpdate(t); + m_arb->simulationCallUpdate(t); + QVERIFY2(waitFor([&] { return m_jugees > avant; }, 15000), "réponse de l'optimiseur jamais jugée"); + settle(30); + for (const QString &v : {m_var1, m_var2}) { + Thing *th = tm->findConfiguredThing(ThingId(v)); + th->setStateValue("currentPowerW", th->stateValue("powerSetpoint").toDouble()); + } + }; + const auto var2W = [&]() { + return tm->findConfiguredThing(ThingId(m_var2))->stateValue("powerSetpoint").toDouble(); + }; + const auto borneA = [&]() { + return borne->stateValue("power").toBool() ? borne->stateValue("maxChargingCurrent").toInt() : 0; + }; + const auto decision = [&](const QString &id) { + for (const QVariant &v : m_arb->loadTelemetry().value("loads").toList()) + if (v.toMap().value("loadId").toString() == id) + return v.toMap().value("decision").toMap(); + return QVariantMap(); + }; + + // 1. Export : l'optimiseur demande 50 kW à tout, les adaptateurs écrêtent au matériel. + for (int k = 0; k < 3; ++k) + pas(-20000, -20); + QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); + QCOMPARE(borneA(), 16); + QCOMPARE(var2W(), 3000.0); + + // 2. LA CHARGE EN WATTS (le chauffe-eau de l'essai). 40 A par phase : la protection déleste + // la cascade en ordre inverse du rang, la PAC puis la charge 2. + pas(40 * 230 * 3, 40); + const double var2Apres = var2W(); + QVERIFY2(var2Apres < 3000, qUtf8Printable(QString::number(var2Apres))); + + // 3. Le compteur revient JUSTE sous le seuil : 22 A par phase (seuil 22,5 A, 25 A − 10 %). + // La marge ne laisse que 0,5 A par phase : ce qui a été retiré ne peut pas revenir. + pas(22 * 230 * 3, 22); + QVERIFY2(var2W() <= var2Apres + 0.5 * 230 + 1, + qUtf8Printable(QStringLiteral("la charge 2 est remontée de %1 à %2 W").arg(var2Apres).arg(var2W()))); + // DÉFAUT 5 — la consigne écrêtée par la sécurité porte le motif de la sécurité, pas celui que + // l'optimiseur avait donné à sa demande. + QCOMPARE(decision(m_var2).value("code").toString(), QString(DecisionCode::DrawCap)); + + // 4. La place revient : tout ce que l'optimiseur demande tient sous le seuil, il est servi. + // Six minutes : le verrou de réenclenchement de la borne, coupée en 2, est expiré. + pas(-20000, -20, 360); + pas(-20000, -20, 360); + QCOMPARE(borneA(), 16); + QCOMPARE(var2W(), 3000.0); + + // 5. LA BORNE. 32 A par phase : la protection la rabaisse sans la couper. + pas(32 * 230 * 3, 32); + const int borneApres = borneA(); + QVERIFY2(borneApres > 0 && borneApres < 16, qUtf8Printable(QString::number(borneApres))); + + // 6. Juste sous le seuil : la borne ne remonte pas au-delà de ce que la marge laisse. + pas(22 * 230 * 3, 22); + QVERIFY2(borneA() <= borneApres, + qUtf8Printable(QStringLiteral("la borne est remontée de %1 à %2 A").arg(borneApres).arg(borneA()))); + + // 7. La place revient : la borne retrouve ses 16 A. + pas(-20000, -20); + pas(-20000, -20); + QCOMPARE(borneA(), 16); +} + void TestRefOptimizer::testAnEchoedTokenLeaksNowhereEndToEnd() { // Correctif A, de bout en bout : l'optimiseur de référence, en `echo-token`, recopie le token diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index a811613..6907d2f 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -13129,3 +13129,465 @@ void Simulation::testSheddingLogKeepsTheLastEpisodes() qUtf8Printable(QJsonDocument::fromVariant(v).toJson(QJsonDocument::Compact))); #endif } + +// ============================================================================================ +// Lot délestage triphasé (2026-10-27). Essai de surcharge sur une installation d'essai : borne +// triphasée comptée en monophasé après un délestage, protection lente, chauffe-eau délesté qui ne +// coupe pas, délestage relâché sur une surcharge qui revient. Chaque test de ce bloc a été vu +// ROUGE sur 1.15.2+etm76, sauf celui du chauffe-eau, qui établit que le moteur n'y est pour rien. +// ============================================================================================ + +Simulation::BancTriphase Simulation::preparerBancTriphase(const QString &nom, const QString &phases, bool bascule) +{ + BancTriphase b; + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-triphase-" + nom + ".json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + b.arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + if (!b.arb) + return b; + ThingManager *tm = NymeaCore::instance()->thingManager(); + const QUuid meterId = addMeter(26811); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + b.compteur = tm->findConfiguredThing(meterId); + b.compteur->setStateValue("connected", true); + + const QUuid borneId = bascule ? addChargerWithPhaseCountSwitching(phases, 16, 26812) + : addCharger(phases, 16, 26812); + b.borneId = borneId.toString(); + b.borne = tm->findConfiguredThing(borneId); + armerBorne(b.borne); + if (bascule && phases == QLatin1String("ABC")) { + // Le mock d'une borne à bascule démarre en monophasé (desiredPhaseCount = 1). + b.borne->setStateValue("desiredPhaseCount", 3); + b.borne->setStateValue("usedPhases", QStringLiteral("ABC")); + b.borne->setStateValue("phaseCount", 3); + } + // AUCUNE voiture assignée : c'est le cas relevé, et celui que le manager ne prépare pas. + const QVariantMap info{{"evChargerId", borneId}, {"chargingMode", "ChargingModeEco"}}; + injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})); + // Aucun démarrage sous tolérance : la lecture des puissances reste celle du budget. + injectAndWait("NymeaEnergy.SetAcquisitionTolerance", QVariantMap({{"acquisitionTolerance", 1.0}})); + injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 30}})); + return b; +} + +void Simulation::testOverloadLeavesTheChargerItsPhases_data() +{ + QTest::addColumn("phases"); + QTest::addColumn("bascule"); + QTest::addColumn("phasesAttendues"); + // Le cas relevé : une borne triphasée qui ne sait PAS changer de phases. + QTest::newRow("triphasée, sans bascule") << "ABC" << false << 3; + QTest::newRow("triphasée, à bascule") << "ABC" << true << 3; + QTest::newRow("monophasée") << "A" << false << 1; +} + +void Simulation::testOverloadLeavesTheChargerItsPhases() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // DÉFAUT 1. Borne triphasée 16 A sans voiture assignée, limite de phase 30 A. Avant le + // délestage : 11 040 W alloués, 16 A × 3, 11 040 W mesurés. Après : 3 680 W alloués et + // attendus, 16 A commandés, 11 040 W mesurés, jusqu'à la fin de l'essai. La borne était + // devenue monophasée dans les comptes du moteur, et nulle part ailleurs. + // + // Ce qui est tenu est une CONCORDANCE entre trois sources qui ne se recopient pas : ce que la + // cascade alloue, ce que le moteur dit avoir commandé (`command.expectedW`), et ce que la + // borne publie (`mechanism.currentA` × 230 × `mechanism.phaseCount`). + QFETCH(QString, phases); + QFETCH(bool, bascule); + QFETCH(int, phasesAttendues); + BancTriphase b = preparerBancTriphase(QStringLiteral("phases-%1-%2").arg(phases).arg(bascule), + phases, bascule); + QVERIFY(b.arb && b.compteur && b.borne); + QCOMPARE(b.borne->stateValue("phaseCount").toInt(), phasesAttendues); + + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(10, 0)); + QVariantMap snap; + const auto cycle = [&](double maisonA) { + a = a.addSecs(360); // au-delà des verrous de la borne + b.compteur->setStateValue("currentPower", compteurVoitLaBorne(b.compteur, b.borne, maisonA)); + b.arb->recordMeterUpdate(a); + b.arb->simulationCallUpdate(a); + QCoreApplication::processEvents(); + snap = b.arb->loadTelemetry(); + }; + const auto entree = [&]() { return telemetrieParId(snap).value(b.borneId); }; + const int pleineW = 16 * 230 * phasesAttendues; + const auto concordance = [&](const char *quand) { + const QVariantMap e = entree(); + const QVariantMap meca = e.value("mechanism").toMap(); + const int courant = meca.value("currentA").toInt(); + const QString ctx = QStringLiteral("%1 — %2").arg(QString::fromUtf8(quand), + QString::fromUtf8(QJsonDocument::fromVariant(e).toJson(QJsonDocument::Compact))); + QVERIFY2(meca.value("phaseCount").toInt() == phasesAttendues, qUtf8Printable(ctx)); + QVERIFY2(qRound(e.value("allocatedW").toDouble()) == courant * 230 * phasesAttendues, + qUtf8Printable(ctx)); + QVERIFY2(qRound(e.value("command").toMap().value("expectedW").toDouble()) + == courant * 230 * phasesAttendues, qUtf8Printable(ctx)); + }; + + // 0. TÉMOIN — la maison exporte 25 A par phase : la borne prend tout ce qu'elle peut. + cycle(-25); + cycle(-25); + QCOMPARE(consigneA(b.borne), 16); + QCOMPARE(qRound(entree().value("allocatedW").toDouble()), pleineW); + concordance("avant le délestage"); + if (QTest::currentTestFailed()) + return; + + // 1. SURCHARGE — 17 A de maison s'ajoutent aux 16 A de la borne : 33 A pour 30. La protection + // rabaisse la borne. Sur 1.15.2+etm76, ce geste laissait dans la table du manager un + // ChargingProcessInfo PAR DÉFAUT (1 phase, pas de bascule) pour une borne qu'il ne prépare + // pas, et `evTargetPhases()` le prenait pour une préparation de ce cycle. + cycle(17); + QVERIFY2(consigneA(b.borne) < 16, qUtf8Printable(QString::number(consigneA(b.borne)))); + + // 2. RETOUR — la maison exporte de nouveau : la borne retrouve sa pleine puissance, et ses + // phases dans les comptes. + cycle(-25); + cycle(-25); + cycle(-25); + QCOMPARE(consigneA(b.borne), 16); + concordance("après le délestage"); + QCOMPARE(qRound(entree().value("allocatedW").toDouble()), pleineW); + QCOMPARE(entree().value("command").toMap().value("divergent").toBool(), false); +#endif +} + +void Simulation::testAnUnassignedCarTakesItsPhasesAway() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // DÉFAUT 1, second chemin vers la même erreur, sans aucune surcharge. Une voiture MONOPHASÉE + // est assignée à une borne triphasée sans bascule : le manager prépare la borne et retient + // min(3, 1) = 1 phase, à juste titre. Puis la voiture est désassignée — un invité branche une + // triphasée. Le manager cesse de préparer la borne ; son ChargingProcessInfo de la veille, lui, + // reste dans la table, et « 1 phase » se lisait comme une préparation de ce cycle. + BancTriphase b = preparerBancTriphase(QStringLiteral("voiture-mono"), QStringLiteral("ABC"), false); + QVERIFY(b.arb && b.compteur && b.borne); + ThingManager *tm = NymeaCore::instance()->thingManager(); + const QUuid voitureId = addCar(26813); + Thing *voiture = tm->findConfiguredThing(voitureId); + QVERIFY(voiture); + voiture->setStateValue("batteryLevel", 30); + voiture->setStateValue("capacity", 60); + voiture->setStateValue("minChargingCurrent", 6); + voiture->setStateValue("phaseCount", 1); + QVariantMap info{{"evChargerId", b.borneId}, {"assignedCarId", voitureId}, + {"chargingMode", "ChargingModeEco"}, {"targetPercentage", 100}}; + QCOMPARE(injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(10, 0)); + QVariantMap snap; + const auto cycle = [&](double maisonA) { + a = a.addSecs(360); + b.compteur->setStateValue("currentPower", compteurVoitLaBorne(b.compteur, b.borne, maisonA)); + b.arb->recordMeterUpdate(a); + b.arb->simulationCallUpdate(a); + QCoreApplication::processEvents(); + snap = b.arb->loadTelemetry(); + }; + const auto entree = [&]() { return telemetrieParId(snap).value(b.borneId); }; + + // La voiture monophasée : la borne est comptée sur UNE phase. C'est juste, et c'est le témoin. + cycle(-25); + cycle(-25); + QCOMPARE(qRound(entree().value("allocatedW").toDouble()), 16 * 230); + + // L'invité : plus de voiture assignée. La borne tire sur ses trois phases. + info.remove("assignedCarId"); + QCOMPARE(injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + cycle(-25); + cycle(-25); + const QVariantMap e = entree(); + QVERIFY2(qRound(e.value("allocatedW").toDouble()) + == e.value("mechanism").toMap().value("currentA").toInt() * 230 * 3, + qUtf8Printable(QJsonDocument::fromVariant(e).toJson(QJsonDocument::Compact))); + QCOMPARE(qRound(e.value("allocatedW").toDouble()), 16 * 230 * 3); +#endif +} + +void Simulation::testCommandDivergenceSeesAPhaseCountError() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // `command.divergent` est le filet qui aurait dû signaler le défaut 1 : 3 680 W attendus, + // 11 040 W mesurés. Ici l'erreur de phases est posée par le MATÉRIEL, pas par le moteur : une + // borne déclarée monophasée tire 16 A sur trois phases. Un écart de ce facteur doit se lire + // divergent dès le cycle suivant — c'est une descente non suivie, jugée sur 7 s. + BancTriphase b = preparerBancTriphase(QStringLiteral("divergence-x3"), QStringLiteral("A"), false); + QVERIFY(b.arb && b.compteur && b.borne); + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(10, 0)); + QVariantMap snap; + bool ment = false; + const auto cycle = [&](double maisonA) { + a = a.addSecs(360); + if (ment) { + // La borne tire sur TROIS phases ce qu'elle publie comme monophasé. + for (const char *ph : {"A", "B", "C"}) { + b.borne->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 16.0); + b.borne->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 16.0 * 230); + } + b.borne->setStateValue("currentPower", 16.0 * 230 * 3); + } + b.compteur->setStateValue("currentPower", compteurVoitLaBorne(b.compteur, b.borne, maisonA)); + b.arb->recordMeterUpdate(a); + b.arb->simulationCallUpdate(a); + QCoreApplication::processEvents(); + snap = b.arb->loadTelemetry(); + }; + const auto commande = [&]() { + return telemetrieParId(snap).value(b.borneId).value("command").toMap(); + }; + + cycle(-25); + cycle(-25); + QCOMPARE(consigneA(b.borne), 16); + QCOMPARE(commande().value("divergent").toBool(), false); // témoin : concordance + + ment = true; + cycle(-25); + cycle(-25); + const QVariantMap c = commande(); + QVERIFY2(c.value("divergent").toBool(), + qUtf8Printable(QJsonDocument::fromVariant(c).toJson(QJsonDocument::Compact))); + QCOMPARE(qRound(c.value("expectedW").toDouble()), 16 * 230); +#endif +} + +void Simulation::testOverloadReactsToTheTotalWhenPhasesLag() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // DÉFAUT 2. Sur l'installation d'essai, la maison est montée à 22,3 kW importés — 32,3 A par + // phase — à 11:08:10, et le délestage ne s'est ouvert qu'à 11:09:55. Les courants PAR PHASE + // que le moteur recevait étaient en retard sur le total : 29,1 A à 11:09, 30,4 A à 11:10, pour + // 32,3 A réels. La protection jugeait sur eux seuls et attendait qu'ils franchissent la limite. + // + // Or le total suffit à prouver la surcharge : la phase la plus chargée porte AU MOINS le tiers + // du total importé. Ici, 26 A lus sur chaque phase pour 22 277 W au total : 32,3 A en moyenne, + // au-delà de la limite, quoi que disent les phases. + BancTriphase b = preparerBancTriphase(QStringLiteral("phases-en-retard"), QStringLiteral("ABC"), false); + QVERIFY(b.arb && b.compteur && b.borne); + ThingManager *tm = NymeaCore::instance()->thingManager(); + const QUuid r1 = addPowerSwitch(1000, 26814); + const QUuid r2 = addPowerSwitch(2000, 26815); + QVERIFY(tm->findConfiguredThing(r1) && tm->findConfiguredThing(r2)); + QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap() + .value("params").toMap().value("loadConfigs").toList(); + for (int i = 0; i < l.count(); ++i) { + QVariantMap m = l.at(i).toMap(); + if (m.value("id").toString() == b.borneId) m.insert("priority", 1); + l[i] = m; + } + l.append(QVariantMap{{"id", "ecs-retard"}, {"label", "Chauffe-eau"}, {"adapter", "relay-router"}, + {"mode", "fixed"}, {"priority", 2}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0}, + {"relays", QVariantList{QVariantMap{{"thingId", r1.toString()}, {"powerW", 1000}}, + QVariantMap{{"thingId", r2.toString()}, {"powerW", 2000}}}}}); + QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(10, 0)); + QVariantMap snap; + const auto lancer = [&]() { + b.arb->recordMeterUpdate(a); + b.arb->simulationCallUpdate(a); + QTest::qWait(20); + QCoreApplication::processEvents(); + snap = b.arb->loadTelemetry(); + }; + const auto ecsW = [&]() { + return qRound(telemetrieParId(snap).value("ecs-retard").value("mechanism").toMap() + .value("stageW").toDouble()); + }; + // Le surplus sert les deux : la borne à 16 A × 3, le chauffe-eau à 3 kW sur la phase A. + for (int k = 0; k < 3; ++k) { + a = a.addSecs(360); + double totalW = compteurVoitLaBorne(b.compteur, b.borne, -25); + const double ecsA = (k == 0 ? 0 : ecsW()) / 230.0; + b.compteur->setStateValue("currentPhaseA", b.compteur->stateValue("currentPhaseA").toDouble() + ecsA); + b.compteur->setStateValue("currentPowerPhaseA", b.compteur->stateValue("currentPhaseA").toDouble() * 230); + totalW += ecsA * 230; + b.compteur->setStateValue("currentPower", totalW); + lancer(); + } + QCOMPARE(consigneA(b.borne), 16); + QCOMPARE(ecsW(), 3000); + + // La surcharge arrive : le TOTAL la porte (22 277 W, 32,3 A en moyenne), les PHASES pas + // encore (26 A lus sur chacune, sous la limite de 30 A comme sous le seuil de 27 A). + a = a.addSecs(60); + for (const char *ph : {"A", "B", "C"}) { + b.compteur->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 26.0); + b.compteur->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 26.0 * 230); + } + b.compteur->setStateValue("currentPower", 22277.0); + lancer(); + // Ce sont les GESTES DE LA PROTECTION qui sont jugés, au journal des délestages : sous le + // rule-based, l'import coupe aussi la cascade faute de surplus, et la consigne finale ne dirait + // pas qui a agi. La protection a rabaissé la borne et délesté le chauffe-eau, sur le dépassement + // que le total PROUVE : (32,3 − 27) × 230 ≈ 1 215 W par phase. + const QVariantList evts = evenementsDelestage(injectAndWait("NymeaEnergy.GetSheddingLog")); + QHash parCharge; + for (const QVariant &v : evts) + parCharge.insert(v.toMap().value("loadId").toString(), v.toMap()); + const QString journal = QString::fromUtf8(QJsonDocument::fromVariant(evts).toJson(QJsonDocument::Compact)); + QVERIFY2(parCharge.contains(b.borneId), qUtf8Printable(journal)); + QVERIFY2(parCharge.contains(QStringLiteral("ecs-retard")), qUtf8Printable(journal)); + QVERIFY2(parCharge.value(b.borneId).value("after").toMap().value("mechanism").toMap() + .value("currentA").toInt() <= 10, qUtf8Printable(journal)); + QVERIFY2(qAbs(parCharge.value("ecs-retard").value("overshootW").toInt() - 1215) <= 5, qUtf8Printable(journal)); +#endif +} + +void Simulation::testSheddingOpensTheRelaysUnderMinOn() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // DÉFAUT 3 — à PROUVER, pas à corriger d'office. Sur l'installation d'essai, le chauffe-eau + // (1 + 2 kW, minOn 60 s, minOff 60 s) a été délesté à 2 000 puis 1 000 W, le journal disait + // « after stageW 1000 », et la mesure est restée à 3 000 W plus de deux minutes, `lock: minOn` + // affiché. Deux questions : la protection franchit-elle le verrou de marche minimale, et la + // commande des relais part-elle ? Ce test répond aux deux sur les relais eux-mêmes. + BancTriphase b = preparerBancTriphase(QStringLiteral("ecs-minon"), QStringLiteral("A"), false); + QVERIFY(b.arb && b.compteur); + b.borne->setStateValue("pluggedIn", false); // la borne hors jeu : seul le chauffe-eau compte + ThingManager *tm = NymeaCore::instance()->thingManager(); + const QUuid r1 = addPowerSwitch(1000, 26816); + const QUuid r2 = addPowerSwitch(2000, 26817); + Thing *relais1 = tm->findConfiguredThing(r1); + Thing *relais2 = tm->findConfiguredThing(r2); + QVERIFY(relais1 && relais2); + QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap() + .value("params").toMap().value("loadConfigs").toList(); + l.append(QVariantMap{{"id", "ecs-minon"}, {"label", "Chauffe-eau"}, {"adapter", "relay-router"}, + {"mode", "fixed"}, {"priority", 2}, {"enabled", true}, {"minOnS", 60}, {"minOffS", 60}, + {"relays", QVariantList{QVariantMap{{"thingId", r1.toString()}, {"powerW", 1000}}, + QVariantMap{{"thingId", r2.toString()}, {"powerW", 2000}}}}}); + QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + + QVariantMap snap; + const auto cycle = [&](const QDateTime &at, double phaseA, double totalW) { + poserPhaseA(b.compteur, phaseA); + b.compteur->setStateValue("currentPower", totalW); + b.arb->recordMeterUpdate(at); + b.arb->simulationCallUpdate(at); + QTest::qWait(20); + QCoreApplication::processEvents(); + snap = b.arb->loadTelemetry(); + return telemetrieParId(snap).value("ecs-minon"); + }; + const auto fermesW = [&]() { + return (relais1->stateValue("power").toBool() ? 1000 : 0) + + (relais2->stateValue("power").toBool() ? 2000 : 0); + }; + + // Armement à froid (ECS-412), puis le surplus enclenche les deux relais. + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(10, 0)); + cycle(t0, -20, -6000); + QVariantMap e = cycle(t0.addSecs(120), -20, -6000); + QCOMPARE(qRound(e.value("mechanism").toMap().value("stageW").toDouble()), 3000); + QCOMPARE(fermesW(), 3000); + + // 20 s plus tard, minOn armé : 33 A sur la phase A. La protection doit franchir le verrou. + e = cycle(t0.addSecs(140), 33, 33 * 230 + 4 * 230); + const QVariantMap motif = e.value("decision").toMap(); + QCOMPARE(motif.value("code").toString(), QString(DecisionCode::DrawCap)); + QVERIFY2(motif.value("params").toMap().value("minOnBypassed").toBool(), + qUtf8Printable(QJsonDocument::fromVariant(motif).toJson(QJsonDocument::Compact))); + const int palierW = qRound(e.value("mechanism").toMap().value("stageW").toDouble()); + QVERIFY2(palierW < 3000, qUtf8Printable(QString::number(palierW))); + // …et la commande est PARTIE : les relais disent le palier publié. + QCOMPARE(fermesW(), palierW); + + // La surcharge dure : le cycle suivant continue de délester, sur les relais aussi. + e = cycle(t0.addSecs(160), 33, 33 * 230 + 4 * 230); + QCOMPARE(fermesW(), qRound(e.value("mechanism").toMap().value("stageW").toDouble())); +#endif +} + +void Simulation::testPhaseSafetyMargin() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // DÉCISION DE PATRICK (2026-10-27) — le moteur vise 90 % de la limite de phase : 27 A pour + // 30 A. Pour le plafond qu'il déclare et alloue, et pour le seuil où la protection agit. + // Réglable, persistée avec la limite, publiée en lecture. + BancTriphase b = preparerBancTriphase(QStringLiteral("marge"), QStringLiteral("ABC"), false); + QVERIFY(b.arb && b.compteur && b.borne); + const auto lire = [&]() { + return injectAndWait("NymeaEnergy.GetPhasePowerLimit").toMap().value("params").toMap(); + }; + const auto ecrire = [&](const QVariantMap &p) { + return injectAndWait("NymeaEnergy.SetPhasePowerLimit", p).toMap().value("params").toMap() + .value("energyError").toString(); + }; + + // 1. Lecture : 10 % par défaut, et le seuil qui en résulte. + QVariantMap g = lire(); + QCOMPARE(g.value("phasePowerLimit").toUInt(), 30u); + QCOMPARE(g.value("phaseSafetyMarginPercent").toUInt(), 10u); + QCOMPARE(g.value("effectivePhaseLimit").toDouble(), 27.0); + + // 2. La protection agit à 90 % : 28 A par phase, sous la limite de 30, au-dessus du seuil. + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(10, 0)); + const auto cycle = [&](double maisonA) { + a = a.addSecs(360); + b.compteur->setStateValue("currentPower", compteurVoitLaBorne(b.compteur, b.borne, maisonA)); + b.arb->recordMeterUpdate(a); + b.arb->simulationCallUpdate(a); + QCoreApplication::processEvents(); + }; + cycle(-25); + cycle(-25); + QCOMPARE(consigneA(b.borne), 16); + cycle(12); // 12 + 16 = 28 A + QVERIFY2(consigneA(b.borne) < 16, qUtf8Printable(QString::number(consigneA(b.borne)))); + // Le geste est jugé sur le SEUIL : 28 − 27 = 1 A de trop, la borne perd 1 A, pas 0. + QCOMPARE(consigneA(b.borne), 15); + + // 3. Écriture : la marge seule ne bouge pas la limite ; notifiée, persistée. + QSignalSpy notif(m_mockTcpServer, &MockTcpServer::outgoingData); + QCOMPARE(ecrire({{"phasePowerLimit", 30}, {"phaseSafetyMarginPercent", 15}}), QString("EnergyErrorNoError")); + g = lire(); + QCOMPARE(g.value("phaseSafetyMarginPercent").toUInt(), 15u); + QCOMPARE(g.value("effectivePhaseLimit").toDouble(), 25.5); + QCOMPARE(EnergySettings().value("phaseSafetyMarginPercent").toUInt(), 15u); + const QVariantList notifs = checkNotifications(notif, "NymeaEnergy.PhasePowerLimitChanged"); + QVERIFY(!notifs.isEmpty()); + QCOMPARE(notifs.last().toMap().value("params").toMap().value("phaseSafetyMarginPercent").toUInt(), 15u); + QCOMPARE(notifs.last().toMap().value("params").toMap().value("effectivePhaseLimit").toDouble(), 25.5); + + // 4. Bornes : 0 et 25 acceptés, au-delà refusé et sans effet. + QCOMPARE(ecrire({{"phasePowerLimit", 30}, {"phaseSafetyMarginPercent", 0}}), QString("EnergyErrorNoError")); + QCOMPARE(lire().value("effectivePhaseLimit").toDouble(), 30.0); + QCOMPARE(ecrire({{"phasePowerLimit", 30}, {"phaseSafetyMarginPercent", 25}}), QString("EnergyErrorNoError")); + QCOMPARE(ecrire({{"phasePowerLimit", 30}, {"phaseSafetyMarginPercent", 26}}), + QString("EnergyErrorInvalidParameter")); + QCOMPARE(lire().value("phaseSafetyMarginPercent").toUInt(), 25u); + // Sans la marge, l'écriture de la limite garde la marge réglée. + QCOMPARE(ecrire({{"phasePowerLimit", 32}}), QString("EnergyErrorNoError")); + QCOMPARE(lire().value("phaseSafetyMarginPercent").toUInt(), 25u); + QCOMPARE(lire().value("effectivePhaseLimit").toDouble(), 24.0); + QCOMPARE(ecrire({{"phasePowerLimit", 30}, {"phaseSafetyMarginPercent", 10}}), QString("EnergyErrorNoError")); +#endif +} diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 874a70f..8b97b44 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -81,6 +81,18 @@ private: //! Un cycle à l'instant \p a, compteur à \p compteurW (import > 0) ; rend la trame publiée. QVariantMap cycleHc(ScenarioHc &s, const QDateTime &a, double compteurW); + // ── Lot délestage triphasé — une borne SANS voiture assignée, limite de phase 30 A ──────── + struct BancTriphase { + EnergyArbitrator *arb = nullptr; + Thing *compteur = nullptr; + Thing *borne = nullptr; + QString borneId; + }; + //! Serveur neuf, compteur, borne 16 A « Éco » branchée et sans voiture (le cas de l'invité, + //! LM-1208-b). \p phases : câblage du mock ; \p bascule : la borne porte `desiredPhaseCount` + //! (mise en triphasé si elle est câblée sur trois). + BancTriphase preparerBancTriphase(const QString &nom, const QString &phases, bool bascule); + // ── Recrédit — une borne mesurée par un compteur dédié, cycle par cycle ────────────────── //! Ce que la télémétrie dit de la borne à un cycle, et ce qu'elle a reçu. struct CycleBorne { @@ -385,6 +397,23 @@ private slots: //! N + 1 épisodes : le plus ancien sort, les identifiants ne sont jamais réutilisés. void testSheddingLogKeepsTheLastEpisodes(); + // ── Lot délestage triphasé (2026-10-27) ───────────────────────────────── + //! Après une surcharge, la borne garde ses phases dans les comptes : allocation, commande et + //! matériel disent le même nombre de phases — triphasée sans bascule, à bascule, monophasée. + void testOverloadLeavesTheChargerItsPhases_data(); + void testOverloadLeavesTheChargerItsPhases(); + //! Une voiture monophasée désassignée ne laisse pas « 1 phase » à une borne triphasée. + void testAnUnassignedCarTakesItsPhasesAway(); + //! Une borne qui tire sur trois phases ce que le moteur croit commandé sur une : divergente. + void testCommandDivergenceSeesAPhaseCountError(); + //! Des courants par phase en retard sur le total ne retardent pas la protection. + void testOverloadReactsToTheTotalWhenPhasesLag(); + //! Délesté sous son minOn, le chauffe-eau franchit le verrou ET ses relais s'ouvrent. + void testSheddingOpensTheRelaysUnderMinOn(); + //! La marge de sécurité : 10 % par défaut, réglable et bornée, persistée, et c'est à 90 % + //! de la limite que la protection agit. + void testPhaseSafetyMargin(); + void printStates(Thing *thing); void updateChargerMeter(Thing *thing); diff --git a/tests/tools/etm-powersync-schema-check-test.py b/tests/tools/etm-powersync-schema-check-test.py index 2a42582..2764e2c 100644 --- a/tests/tools/etm-powersync-schema-check-test.py +++ b/tests/tools/etm-powersync-schema-check-test.py @@ -65,6 +65,8 @@ TABLE_MODES = { 'old-data': ('—', 'valide'), 'echo-value': ('—', 'valide'), 'major-first': ('—', 'valide'), + # Une consigne hors des bornes est un plan VALIDE : c'est au moteur de l'écrêter, pas au contrat. + 'over-limit': ('—', 'valide'), } # Une valeur que nul chemin conforme ne peut contenir (tiret, deux-points) : si elle apparaît dans diff --git a/tools/etm-powersync-ref-optimizer/README.md b/tools/etm-powersync-ref-optimizer/README.md index 880a655..6fdb995 100644 --- a/tools/etm-powersync-ref-optimizer/README.md +++ b/tools/etm-powersync-ref-optimizer/README.md @@ -83,7 +83,8 @@ Le rejeu compare, cycle par cycle, les actions du créneau courant au plan enreg l'accepte, le contrat OpenRPC le refuse — contre-épreuve de la suite de conformité) · `old-data` (contexte invalide : `error.data` dans la forme d'avant 1.3, `{"problemes": [phrases]}`) · `echo-value` (contexte invalide : `error.data` recopie la valeur reçue) · `major-first` (la majeure -jugée avant le token). +jugée avant le token) · `over-limit` (toute consigne en watts portée à 50 kW, au-delà des bornes +de la charge et du branchement : le moteur doit l'écrêter). `--announce VERSION` : la version annoncée au handshake, et dont la majeure est exigée du moteur (défaut `1.3`). Avec `--announce 1.2`, il se présente en optimiseur 1.2 : la suite de conformité diff --git a/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer b/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer index 1227ef2..7b33caf 100755 --- a/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer +++ b/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer @@ -117,7 +117,7 @@ PROBLEMES_MAX = 50 # §7, 1.3 : au-delà, les 50 premiers et `trun MODES = ['normal', 'abstain', 'slow', 'invalid', 'expired', 'wrong-major', 'drop', 'unknown-load', 'forbidden-kind', 'missing-load', 'echo-token', 'protocol-1.0', 'deviate', 'wrong-type', - 'old-data', 'echo-value', 'major-first'] + 'old-data', 'echo-value', 'major-first', 'over-limit'] # Catalogue (energyplugin/etm/types/decisionreason.cpp) : codes qu'un optimiseur peut émettre et # leurs clés de paramètres documentées (INTERFACE.md). Le test d'intégration les confronte au @@ -389,6 +389,13 @@ def plan(ctx, mode, horizon_s, token): if a['loadId'] in disponibles: del actions[i] break + elif mode == 'over-limit': + # Un optimiseur qui demande le MAXIMUM à tout ce qui se règle en watts, au-delà des bornes + # de la charge et du branchement. Le moteur doit l'écrêter (invariant 2), jamais le poser. + for a in actions: + if a.get('kind') == 'setpoint': + a['powerW'] = 50000.0 + a['reason'] = 'mode over-limit (test) : 50 kW demandés' elif mode == 'echo-token' and token: for a in actions: a['reason'] = f'{a["reason"]} — écho {token}'