test : un vrai refus reste vu, sur la borne et sur les autres charges

testARealRefusalIsStillSeen — borne figée sous une consigne à la hausse :
refus au cycle qui suit la commande, comme avant le correctif ; montée
qui progresse puis cale : latence tant qu'elle se rapproche, refus au
cycle où elle cale ; descente suivie de 500 W seulement : refus au cycle
suivant, la progression n'excuse jamais une descente.

testWattLoadsFollowedOrRefusedUnderAMeter — relais et consigne continue
sous compteur dédié : marche suivie dans les deux sens sans refus ; refus
réel vu au deuxième cycle (la latence par défaut de 60 s n'est pas
dépassée par un cycle de 60 s) ; sans compteur, rien ne change.

testSgReadyDivergenceIsPublishedOnly — sous compteur dédié, une PAC qui
monte en minutes n'est pas divergente, une PAC qui ne démarre pas l'est ;
son recrédit (puissance déclarée) n'en dépend pas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-25 19:31:05 +01:00
parent c4e517a8d9
commit e856c48084
2 changed files with 375 additions and 0 deletions

View File

@ -9324,6 +9324,375 @@ void Simulation::testRecreditSuspendedAfterAnObeyedStep()
#endif #endif
} }
/*!
* \brief Un VRAI refus reste vu, au cycle qui suit la commande — et la descente n'a pas d'excuse.
*
* Même banc que testRecreditSuspendedAfterAnObeyedStep(). Trois bornes qui n'obéissent pas :
* - **montée refusée** : la borne tient 7 A, la consigne passe à 13 A, le véhicule reste à 7 A —
* mesure figée. Refus au cycle qui suit la commande, c'est-à-dire au même cycle qu'avant le
* correctif ;
* - **montée qui progresse puis cale** : rampe vers 13 A, calée à 2 000 W. Tant qu'elle se
* rapproche, c'est une latence ; le cycle où elle cale, c'est un refus ;
* - **descente non suivie** : la production s'effondre, la consigne descend, la borne ne lâche
* que 500 W. Elle se « rapproche » de sa consigne, et c'est pourtant un refus, au cycle qui
* suit la commande de descente : la progression n'excuse jamais une descente.
*
* \note Les deux marches dont on lit le MOTIF tombent après les 300 s du verrou de marche/arrêt
* armé au démarrage. Avant, l'arrêt verrouillé engage le courant minimal (P2) et le motif
* publié est \c LOCK_MIN_ON, qui précède le refus ; le verdict, lui, est le même.
*/
void Simulation::testARealRefusalIsStillSeen()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
// ── 1. Montée refusée : 7 A suivis jusqu'à k=5, consigne de 13 A à k=5, figée à 7 A. ──
// La production retombe à k=6 : sans recrédit, le budget ne paie plus le plancher, et le
// motif qui l'explique est la suspension.
const QList<CycleBorne> figee = rejouerBorne("refus-montee",
[](int k, double commandeW, double tireW) { return k <= 5 ? commandeW : tireW; },
[](int k) { return k < 5 ? 1910.0 : (k == 5 ? 3300.0 : 2000.0); }, 7);
// ── 2. Montée qui progresse (la moitié de l'écart par cycle) puis cale à 2 000 W. ──────
const QList<CycleBorne> cale = rejouerBorne("refus-cale",
[](int, double commandeW, double tireW) {
const double rampe = commandeW > tireW ? tireW + (commandeW - tireW) * 0.5 : commandeW;
return qMin(rampe, 2000.0);
}, [](int) { return 3300.0; }, 5);
// ── 3. Descente non suivie : production 3 300 → 1 900 W à k=6, la borne ne lâche que 500 W.
const QList<CycleBorne> descente = rejouerBorne("refus-descente",
[](int k, double commandeW, double tireW) {
if (k <= 6)
return commandeW; // elle a obéi jusqu'à la commande de descente
return commandeW < tireW ? qMax(commandeW, tireW - 500.0) : commandeW;
}, [](int k) { return k >= 6 ? 1900.0 : 3300.0; }, 8);
QCOMPARE(figee.size(), 7);
QCOMPARE(cale.size(), 5);
QCOMPARE(descente.size(), 8);
// 1. 7 A suivis : aucune suspension. Commande de 13 A à k=5 ; à k=6 la borne tire toujours
// 1 610 W : refus AU CYCLE SUIVANT. Rien ne s'est rapproché, aucune latence ne l'excuse.
for (int k = 1; k <= 4; ++k) {
QCOMPARE(figee.at(k).consigneA, 7);
QVERIFY(figee.at(k).motif != QLatin1String(DecisionCode::RecreditSuspended));
}
QCOMPARE(figee.at(5).consigneA, 13);
QCOMPARE(figee.at(6).mesureW, 1610);
QCOMPARE(figee.at(6).motif, QString(DecisionCode::RecreditSuspended));
QCOMPARE(figee.at(6).retenuW, 1610);
QCOMPARE(figee.at(6).recrediteW, 0);
QVERIFY(figee.at(6).divergent);
// Daté à l'instant de la commande (k=5), en temps de cycle : un cycle, 60 s.
QCOMPARE(figee.at(6).refusDepuisS, 60);
// 2. 0 → 1 495 W à k=1 (rapprochée de 1 495 W) : latence, recrédit rendu, pas divergent.
// 1 495 → 2 000 W à k=2 (rapprochée de 505 W) : latence encore. 2 000 W à k=3 : calée, refus.
QCOMPARE(cale.at(0).consigneA, 13);
QCOMPARE(cale.at(1).mesureW, 1495);
QCOMPARE(cale.at(1).recrediteW, 1495);
QVERIFY(!cale.at(1).divergent);
QCOMPARE(cale.at(2).mesureW, 2000);
QCOMPARE(cale.at(2).recrediteW, 2000);
QVERIFY(!cale.at(2).divergent);
QCOMPARE(cale.at(3).mesureW, 2000);
QCOMPARE(cale.at(3).recrediteW, 0);
QVERIFY(cale.at(3).divergent);
// 3. Jusqu'à k=5, 13 A suivis. À k=6 la production tombe : la consigne descend. À k=7 la
// borne n'a lâché que 500 W — elle s'est rapprochée de sa consigne, et c'est un refus,
// AU CYCLE QUI SUIT la commande de descente : jugé sur la latence de descente (7 s), sans
// l'excuse de la progression.
QCOMPARE(descente.at(5).consigneA, 13);
QVERIFY2(descente.at(6).consigneA < 13, "la production tombe : la consigne doit descendre");
QCOMPARE(descente.at(7).mesureW, 2490);
QCOMPARE(descente.at(7).motif, QString(DecisionCode::RecreditSuspended));
QCOMPARE(descente.at(7).retenuW, 2490);
QCOMPARE(descente.at(7).recrediteW, 0);
QVERIFY(descente.at(7).divergent);
QCOMPARE(descente.at(7).refusDepuisS, 60);
#endif
}
/*!
* \brief Relais et consignes continues sous compteur dédié : marche suivie, refus réel, et sans
* compteur rien ne change.
*
* Même défaut que la borne avant le correctif : l'écart comparait la consigne qui venait de
* partir à la mesure de la précédente, et le verdict dépendait de la phase du battement de
* publication. Jugé au début du cycle, il ne dépend plus que de la charge :
* - **marche suivie** (le compteur voit la consigne au cycle suivant) : jamais de refus, dans
* les deux sens ;
* - **refus réel** (le compteur reste à 0) : latence au cycle qui suit la commande — le défaut
* de 60 s n'est pas dépassé par un cycle de 60 s — puis refus au suivant ;
* - **sans compteur** : relais sec, rien ne mesure, l'écart est absent ; consigne continue, la
* mesure est celle de l'appareil, qui est aussi sa commande — l'écart est publié, jamais
* divergent. Exactement comme avant.
*
* \note Pour une consigne continue, la « commande » est la puissance que l'appareil annonce
* appliquer (\c puissanceCommandee()). Le modèle le fait annoncer la consigne qu'il a reçue.
*/
void Simulation::testWattLoadsFollowedOrRefusedUnderAMeter()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
constexpr double MaisonW = 300;
struct Pas {
int consigneW = 0;
int recrediteW = 0;
QString motif;
int refusDepuisS = -1;
int retenuW = 0;
bool commande = false; //!< objet `command` publié
bool divergent = false;
};
// mecanisme : "relais" (relais sec 1 000 W) ou "variable" (consigne continue 0-2 000 W).
auto rejouer = [&](const QString &mecanisme, bool avecCompteur, bool obeit,
const std::function<double(int)> &productionW, int cycles) -> QList<Pas> {
QList<Pas> trace;
const QString nom = mecanisme + (avecCompteur ? "-compteur" : "-seul") + (obeit ? "-suivie" : "-refus");
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-" + nom + ".json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
ThingManager *tm = NymeaCore::instance()->thingManager();
const QUuid meterId = addMeter(27320);
m_experiencePlugin->energyManager()->setRootMeter(meterId);
const QUuid compteurId = addMeter(27321);
Thing *meter = tm->findConfiguredThing(meterId);
Thing *compteur = tm->findConfiguredThing(compteurId);
if (!arb || !meter || !compteur)
return trace;
meter->setStateValue("connected", true);
compteur->setStateValue("connected", true);
compteur->setStateValue("currentPower", 0);
QString loadId;
Thing *pilote = nullptr;
QVariantMap cfg;
if (mecanisme == QLatin1String("relais")) {
const QUuid r = addDryRelay(27322);
loadId = "ecs-relais";
cfg = {{"id", loadId}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", 1}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0},
{"relays", QVariantList() << QVariantMap{{"thingId", r.toString()}, {"powerW", 1000}}}};
} else {
const QUuid v = addEtmVariableLoad(27323);
pilote = tm->findConfiguredThing(v);
if (!pilote)
return trace;
pilote->setStateValue("currentPowerW", 0);
loadId = v.toString();
cfg = {{"id", loadId}, {"label", "Routeur PV"}, {"adapter", "etmvariableload"},
{"mode", "dynamic"}, {"maxPowerW", 2000}, {"priority", 1}, {"enabled", true}};
}
if (avecCompteur)
cfg.insert("meterThingId", compteurId.toString());
LoadConfigStore *store = new LoadConfigStore(arb);
arb->setLoadConfigStore(store);
LoadConfigs cfgs;
cfgs.append(LoadConfig::fromMap(cfg));
QString err;
if (!store->setConfigs(cfgs, &err))
return trace;
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
double commandeW = 0;
for (int k = 0; k < cycles; ++k) {
// Suivie : ce qu'on a commandé au cycle précédent coule. Refusée : rien ne coule.
const double tireW = obeit ? commandeW : 0.0;
compteur->setStateValue("currentPower", tireW);
if (pilote)
pilote->setStateValue("currentPowerW", commandeW); // l'appareil annonce sa consigne
meter->setStateValue("currentPower", MaisonW + tireW - productionW(k));
arb->simulationCallUpdate(t0.addSecs(60 * k));
QCoreApplication::processEvents();
const QVariantMap snap = arb->loadTelemetry();
const QVariantMap e = telemetrieParId(snap).value(loadId);
const QVariantMap mech = e.value("mechanism").toMap();
commandeW = mech.value(mecanisme == QLatin1String("relais") ? "stageW" : "setpointW").toDouble();
const QVariantMap params = e.value("decision").toMap().value("params").toMap();
Pas p;
p.consigneW = qRound(commandeW);
p.recrediteW = qRound(snap.value("budget").toMap().value("recreditedW").toDouble());
p.motif = e.value("decision").toMap().value("code").toString();
p.refusDepuisS = params.value("refusedSinceS", -1).toInt();
p.retenuW = params.value("withheldW").toInt();
p.commande = e.contains("command");
p.divergent = e.value("command").toMap().value("divergent").toBool();
trace.append(p);
qInfo().noquote() << QStringLiteral("[%1] k=%2 consigne %3 W | recrédit %4 | %5 retenu %6 | divergent %7")
.arg(nom).arg(k).arg(p.consigneW).arg(p.recrediteW).arg(p.motif)
.arg(p.retenuW).arg(p.divergent ? "oui" : "non");
}
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
return trace;
};
// Marche suivie : montée à k=0, descente à k=3, remontée à k=5.
auto paliers = [](int k) { return (k >= 3 && k < 5) ? 1000.0 : 1800.0; };
// Refus réel : de quoi commander au début, plus rien ensuite — le budget ne tient alors que
// par le recrédit, et sa suspension se lit dans le motif.
auto relaisRefus = [](int k) { return k < 2 ? 1800.0 : 1000.0; };
auto variableRefus = [](int k) { return k < 2 ? 2300.0 : 300.0; };
const QList<Pas> r1 = rejouer("relais", true, true, paliers, 7);
const QList<Pas> r2 = rejouer("relais", true, false, relaisRefus, 4);
const QList<Pas> r3 = rejouer("relais", false, false, relaisRefus, 4);
const QList<Pas> v1 = rejouer("variable", true, true, paliers, 7);
const QList<Pas> v2 = rejouer("variable", true, false, variableRefus, 4);
const QList<Pas> v3 = rejouer("variable", false, false, variableRefus, 4);
QCOMPARE(r1.size(), 7); QCOMPARE(r2.size(), 4); QCOMPARE(r3.size(), 4);
QCOMPARE(v1.size(), 7); QCOMPARE(v2.size(), 4); QCOMPARE(v3.size(), 4);
// ── Marche suivie : elle a bien eu lieu, dans les deux sens, et jamais de refus. ─────────
QCOMPARE(r1.at(0).consigneW, 1000);
QCOMPARE(r1.at(3).consigneW, 0);
QCOMPARE(r1.at(5).consigneW, 1000);
QVERIFY(v1.at(3).consigneW < v1.at(2).consigneW);
QVERIFY(v1.at(5).consigneW > v1.at(4).consigneW);
for (const QList<Pas> *t : {&r1, &v1}) {
for (int k = 1; k < t->size(); ++k) {
QVERIFY2(t->at(k).motif != QLatin1String(DecisionCode::RecreditSuspended),
qUtf8Printable(QStringLiteral("k=%1 : recrédit suspendu sur une charge qui obéit").arg(k)));
QVERIFY(t->at(k).commande);
QVERIFY2(!t->at(k).divergent, qUtf8Printable(QStringLiteral("k=%1 : divergent").arg(k)));
}
}
// ── Refus réel : latence au cycle qui suit la commande (60 s ne dépasse pas le défaut de
// 60 s), refus au suivant — recrédit suspendu, et le motif le dit. ─────────────────────
for (const QList<Pas> *t : {&r2, &v2}) {
QVERIFY(t->at(0).consigneW > 0);
QVERIFY(t->at(1).recrediteW > 0);
QVERIFY(!t->at(1).divergent);
QCOMPARE(t->at(2).recrediteW, 0);
QVERIFY(t->at(2).divergent);
QCOMPARE(t->at(2).motif, QString(DecisionCode::RecreditSuspended));
QCOMPARE(t->at(2).retenuW, t->at(0).consigneW);
// Daté à l'instant de la commande (k=0) : deux cycles.
QCOMPARE(t->at(2).refusDepuisS, 120);
}
// ── Sans compteur : rien ne change. Relais sec : rien ne mesure, aucun écart publié ;
// consigne continue : l'appareil mesure ce qu'il annonce, l'écart ne diverge jamais. ──
for (int k = 0; k < r3.size(); ++k) {
QVERIFY(!r3.at(k).commande);
QVERIFY(r3.at(k).motif != QLatin1String(DecisionCode::RecreditSuspended));
}
for (int k = 1; k < v3.size(); ++k) {
QVERIFY(v3.at(k).commande);
QVERIFY(!v3.at(k).divergent);
QVERIFY(v3.at(k).motif != QLatin1String(DecisionCode::RecreditSuspended));
}
#endif
}
/*!
* \brief SG-Ready sous compteur dédié : la divergence se PUBLIE, le recrédit n'en dépend pas.
*
* Le recrédit d'une PAC est la puissance DÉCLARÉE de son état (invariant 8), pas sa mesure : il
* ne consulte pas le refus, avant comme après le correctif. Ce qui change est ce que l'écart
* publié dit d'une PAC qui démarre :
* - **qui monte en puissance en plusieurs minutes** (1 000 W de plus par cycle) : en progression,
* donc une latence — jamais divergente ;
* - **qui ne démarre pas** (compteur figé à 0) : latence au cycle qui suit la commande (défaut de
* 60 s), divergente au suivant. Le recrédit, lui, reste de 3 000 W.
*/
void Simulation::testSgReadyDivergenceIsPublishedOnly()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
constexpr double MaisonW = 300;
struct Pas { int etat = 0; int recrediteW = 0; QString motif; bool divergent = false; bool commande = false; };
auto rejouer = [&](const QString &nom, const std::function<double(int)> &pacW, int cycles) -> QList<Pas> {
QList<Pas> trace;
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-" + nom + ".json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
ThingManager *tm = NymeaCore::instance()->thingManager();
const QUuid meterId = addMeter(27330);
m_experiencePlugin->energyManager()->setRootMeter(meterId);
const QUuid compteurId = addMeter(27331);
const QUuid k1 = addPowerSwitch(0, 27332);
const QUuid k2 = addPowerSwitch(0, 27333);
Thing *meter = tm->findConfiguredThing(meterId);
Thing *compteur = tm->findConfiguredThing(compteurId);
if (!arb || !meter || !compteur || !tm->findConfiguredThing(k1) || !tm->findConfiguredThing(k2))
return trace;
meter->setStateValue("connected", true);
compteur->setStateValue("connected", true);
compteur->setStateValue("currentPower", 0);
QVariantMap cfg = sgReadyConfigMap("pac", "PAC", k1, k2, 1500.0, 3000.0, 0, 1);
cfg.insert("meterThingId", compteurId.toString());
LoadConfigStore *store = new LoadConfigStore(arb);
arb->setLoadConfigStore(store);
LoadConfigs cfgs;
cfgs.append(LoadConfig::fromMap(cfg));
QString err;
if (!store->setConfigs(cfgs, &err))
return trace;
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
for (int k = 0; k < cycles; ++k) {
const double tireW = pacW(k);
compteur->setStateValue("currentPower", tireW);
meter->setStateValue("currentPower", MaisonW + tireW - 5000.0); // 4 700 W sans la PAC
arb->simulationCallUpdate(t0.addSecs(60 * k));
QCoreApplication::processEvents();
const QVariantMap snap = arb->loadTelemetry();
const QVariantMap e = telemetrieParId(snap).value("pac");
Pas p;
p.etat = e.value("mechanism").toMap().value("state").toInt();
p.recrediteW = qRound(snap.value("budget").toMap().value("recreditedW").toDouble());
p.motif = e.value("decision").toMap().value("code").toString();
p.commande = e.contains("command");
p.divergent = e.value("command").toMap().value("divergent").toBool();
trace.append(p);
qInfo().noquote() << QStringLiteral("[%1] k=%2 état %3 | PAC %4 W | recrédit %5 | %6 | divergent %7")
.arg(nom).arg(k).arg(p.etat).arg(qRound(tireW)).arg(p.recrediteW)
.arg(p.motif).arg(p.divergent ? "oui" : "non");
}
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
return trace;
};
const QList<Pas> monte = rejouer("pac-monte", [](int k) { return qMin(3000.0, 1000.0 * k); }, 5);
const QList<Pas> figee = rejouer("pac-figee", [](int) { return 0.0; }, 4);
QCOMPARE(monte.size(), 5);
QCOMPARE(figee.size(), 4);
for (const QList<Pas> *t : {&monte, &figee}) {
QCOMPARE(t->at(0).etat, 4); // 4 700 W de surplus : état forcé
for (int k = 1; k < t->size(); ++k) {
QCOMPARE(t->at(k).etat, 4);
QVERIFY(t->at(k).commande);
// Le recrédit est la puissance DÉCLARÉE de l'état 4, refus ou pas.
QCOMPARE(t->at(k).recrediteW, 3000);
QVERIFY(t->at(k).motif != QLatin1String(DecisionCode::RecreditSuspended));
}
}
// Une PAC qui monte en minutes est en progression : jamais divergente.
for (int k = 1; k < monte.size(); ++k)
QVERIFY2(!monte.at(k).divergent, qUtf8Printable(QStringLiteral("k=%1 : divergente").arg(k)));
// Une PAC qui ne démarre pas : latence au cycle qui suit la commande, divergente ensuite.
QVERIFY(!figee.at(1).divergent);
QVERIFY(figee.at(2).divergent);
QVERIFY(figee.at(3).divergent);
#endif
}
/*! /*!
* \brief P2 — un ARRÊT VERROUILLÉ engage le courant minimal : gelé au budget, et aucun faux refus. * \brief P2 — un ARRÊT VERROUILLÉ engage le courant minimal : gelé au budget, et aucun faux refus.
* *

View File

@ -269,6 +269,12 @@ private slots:
void testRecreditIsALoanAgainstObedience(); void testRecreditIsALoanAgainstObedience();
//! Une marche de consigne suivie ne fait plus suspendre le recrédit ; la consigne tient. //! Une marche de consigne suivie ne fait plus suspendre le recrédit ; la consigne tient.
void testRecreditSuspendedAfterAnObeyedStep(); void testRecreditSuspendedAfterAnObeyedStep();
//! Un vrai refus reste vu au cycle qui suit la commande ; la descente n'a pas d'excuse.
void testARealRefusalIsStillSeen();
//! Relais et consignes sous compteur dédié : marche suivie, refus réel, sans compteur.
void testWattLoadsFollowedOrRefusedUnderAMeter();
//! SG-Ready sous compteur dédié : la divergence se publie, le recrédit n'en dépend pas.
void testSgReadyDivergenceIsPublishedOnly();
//! P2 — un arrêt verrouillé engage le courant minimal : gelé au budget, aucun faux refus. //! P2 — un arrêt verrouillé engage le courant minimal : gelé au budget, aucun faux refus.
void testLockedStopFreezesTheMinimumAndIsNoRefusal(); void testLockedStopFreezesTheMinimumAndIsNoRefusal();
//! P2 — le verrou d'arrêt borne la somme éco + confort, il ne s'y ajoute pas. //! P2 — le verrou d'arrêt borne la somme éco + confort, il ne s'y ajoute pas.