test : reproductions du lot délestage triphasé (rouges sur 1.15.2+etm76)
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
2d06de0bc1
commit
f20b08a3a2
@ -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
|
||||
|
||||
@ -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<EnergyArbitrator *>(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<QString>("phases");
|
||||
QTest::addColumn<bool>("bascule");
|
||||
QTest::addColumn<int>("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<QString, QVariantMap> 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
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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é
|
||||
|
||||
@ -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}'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user