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:
Patrick Schurig 2026-10-27 12:28:42 +01:00
parent 2d06de0bc1
commit f20b08a3a2
6 changed files with 613 additions and 2 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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);

View File

@ -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

View File

@ -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é

View File

@ -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}'