test : une marche de consigne suivie fait suspendre le recrédit (reproduction)

Borne monophasée 6-16 A, 3 000 W de surplus constant. L'écart commande/mesure
est jugé après le dispatch, entre la consigne qui vient de partir et la mesure
de la précédente, puis consommé au cycle suivant par qualifierRefus() avant
d'être rejugé. Une marche suivie se lit donc comme un refus vieux d'un cycle,
au-delà des 30 s / 7 s de la borne : recrédit retenu, coupure (bloquée par le
verrou de 300 s, 6 A tirés pour 0 attendu), puis 7 A, puis 13 A, et ainsi de
suite. La borne tire 1 495 W en moyenne sur 3 000.

Trois rampes : consigne atteinte au pas suivant, 30 s / 7 s, 120 s. Les deux
premières donnent la même trace, cycle pour cycle.

Le test FIGE le défaut. Le correctif devra inverser les deux assertions
marquées « DÉFAUT ». Aucune ligne du moteur n'est modifiée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-25 16:46:18 +01:00
parent 9640ac73f2
commit 41ce86a634
2 changed files with 183 additions and 0 deletions

View File

@ -9113,6 +9113,187 @@ void Simulation::testRecreditIsALoanAgainstObedience()
#endif #endif
} }
/*!
* \brief [Reproduction] Une borne qui SUIT une marche de consigne voit son recrédit suspendu au
* cycle suivant, et la consigne oscille.
*
* Borne monophasée 6-16 A, production 3 300 W, maison 300 W, aucune autre charge : 3 000 W de
* surplus constant, que 13 A servent entièrement. Trois rampes de borne, mesure lue au cycle :
* consigne atteinte au pas suivant, latences déclarées par l'EvAdapter (30 s / 7 s), montée
* lente (120 s). La mesure qui sert à l'écart vient d'un compteur dédié que le test pose : la
* mesure propre du mock recopie sa consigne dès la commande, et ne montrerait aucun écart.
*
* \par Le mécanisme
* L'écart est jugé dans attachCommandDivergence(), APRÈS le dispatch : il compare la consigne
* qui vient de partir à la mesure de la précédente. Une marche de plus de 15 % s'y lit comme
* une divergence, et elle n'est réévaluée qu'au cycle suivant, après le plan. qualifierRefus()
* la trouve donc ouverte depuis un cycle — 60 s, au-delà des deux latences — et suspend le
* recrédit d'une borne qui a obéi.
*
* \warning Ce test FIGE le comportement de 1.15.2+etm75, défaut compris. Le correctif devra
* inverser les assertions marquées « DÉFAUT ».
*
* \note `refusedSinceS` n'est pas comparé : `since` est daté à l'horloge murale
* (m_lastTelemetryPublish) et comparé au temps de cycle, injecté ici. Seul le verdict compte,
* et il ne dépend que de « au moins un cycle », qui dépasse les deux seuils.
*/
void Simulation::testRecreditSuspendedAfterAnObeyedStep()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-marche.json";
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
constexpr double ProductionW = 3300;
constexpr double MaisonW = 300;
constexpr int Cycles = 20;
struct Cycle {
int mesureW = 0; //!< ce que la borne tire au début du cycle
QString motif;
int refusDepuisS = -1;
int retenuW = 0;
int surplusW = 0;
int recrediteW = 0;
int consigneA = 0; //!< ce que la borne a reçu ; 0 = coupée
int consigneW = 0;
int attenduW = 0;
bool divergent = false;
};
// monteeS / descenteS : temps que met la borne à rejoindre sa consigne. Le cycle dure 60 s.
auto rejouer = [&](int monteeS, int descenteS) -> QList<Cycle> {
QList<Cycle> trace;
QFile::remove(cfgPath);
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(27310);
m_experiencePlugin->energyManager()->setRootMeter(meterId);
const QUuid borneId = addCharger("A", 16, 27311);
const QUuid compteurId = addMeter(27312);
Thing *meter = tm->findConfiguredThing(meterId);
Thing *borne = tm->findConfiguredThing(borneId);
Thing *compteur = tm->findConfiguredThing(compteurId);
if (!arb || !meter || !borne || !compteur)
return trace;
meter->setStateValue("connected", true);
compteur->setStateValue("connected", true);
compteur->setStateValue("currentPower", 0);
armerBorne(borne);
QVariantMap info;
info.insert("evChargerId", borneId);
info.insert("chargingMode", "ChargingModeEco");
injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
// Cycle d'amorce SANS surplus : la borne reçoit son entrée de configuration et reste
// coupée. On lui désigne ensuite son compteur, avant la première marche.
// L'horloge murale sert de base : `since` y est daté (voir \note).
const QDateTime t0 = QDateTime::currentDateTimeUtc();
meter->setStateValue("currentPower", MaisonW);
arb->simulationCallUpdate(t0.addSecs(-60));
QCoreApplication::processEvents();
QVariantList majs;
for (const QVariant &v : injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
.value("params").toMap().value("loadConfigs").toList()) {
QVariantMap m = v.toMap();
if (m.value("id").toString() == borneId.toString())
m.insert("meterThingId", compteurId.toString());
majs.append(m);
}
injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", majs}}));
double tireW = 0;
double commandeW = 0;
for (int k = 0; k < Cycles; ++k) {
// La borne a eu un cycle pour rejoindre la consigne précédente ; le compteur racine
// voit ce qu'elle tire vraiment.
const int delaiS = commandeW > tireW ? monteeS : descenteS;
tireW = delaiS <= 60 ? commandeW : tireW + (commandeW - tireW) * 60.0 / delaiS;
borne->setStateValue("currentPower", qRound(tireW));
compteur->setStateValue("currentPower", qRound(tireW));
meter->setStateValue("currentPower", qRound(MaisonW + tireW - ProductionW));
arb->simulationCallUpdate(t0.addSecs(60 * k));
QCoreApplication::processEvents();
// Ce que la borne a REÇU, verrous compris — pas ce que le moteur a voulu.
const bool enMarche = borne->stateValue("power").toBool();
const int courantA = borne->stateValue("maxChargingCurrent").toInt();
commandeW = enMarche ? courantA * 230.0 : 0.0;
const QVariantMap snap = arb->loadTelemetry();
const QVariantMap e = telemetrieParId(snap).value(borneId.toString());
const QVariantMap params = e.value("decision").toMap().value("params").toMap();
const QVariantMap budget = snap.value("budget").toMap();
const QVariantMap cmd = e.value("command").toMap();
Cycle c;
c.mesureW = qRound(tireW);
c.motif = e.value("decision").toMap().value("code").toString();
c.refusDepuisS = params.value("refusedSinceS", -1).toInt();
c.retenuW = params.value("withheldW").toInt();
c.surplusW = qRound(budget.value("surplusW").toDouble());
c.recrediteW = qRound(budget.value("recreditedW").toDouble());
c.consigneA = enMarche ? courantA : 0;
c.consigneW = qRound(commandeW);
c.attenduW = qRound(cmd.value("expectedW").toDouble());
c.divergent = cmd.value("divergent").toBool();
trace.append(c);
qInfo().noquote() << QStringLiteral("[marche %1/%2 s] k=%3 mesuré %4 W | surplus %5 recrédit %6"
" | %7 retenu %8 | borne %9 A | attendu %10 W divergent %11")
.arg(monteeS).arg(descenteS).arg(k).arg(c.mesureW)
.arg(c.surplusW).arg(c.recrediteW).arg(c.motif).arg(c.retenuW)
.arg(c.consigneA).arg(c.attenduW)
.arg(c.divergent ? QStringLiteral("oui") : QStringLiteral("non"));
}
return trace;
};
const QList<Cycle> instant = rejouer(0, 0);
QCOMPARE(instant.size(), Cycles);
const QList<Cycle> latences = rejouer(30, 7);
QCOMPARE(latences.size(), Cycles);
const QList<Cycle> lente = rejouer(120, 7);
QCOMPARE(lente.size(), Cycles);
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
// 1. Échantillonné à 60 s, une borne qui rejoint sa consigne en 30 s ou en 7 s ne se distingue
// pas d'une borne instantanée : les deux traces sont identiques, cycle pour cycle.
for (int k = 0; k < Cycles; ++k) {
QCOMPARE(latences.at(k).motif, instant.at(k).motif);
QCOMPARE(latences.at(k).consigneA, instant.at(k).consigneA);
}
// 2. DÉFAUT — le recrédit est suspendu à des cycles où la borne tire exactement ce qu'elle
// a reçu au cycle précédent. Correct : aucune suspension, la borne a obéi partout.
int suspenduesObeie = 0;
for (int k = 1; k < Cycles; ++k) {
if (instant.at(k).mesureW == instant.at(k - 1).consigneW
&& instant.at(k).motif == QLatin1String(DecisionCode::RecreditSuspended)) {
++suspenduesObeie;
QCOMPARE(instant.at(k).retenuW, instant.at(k).mesureW); // toute sa conso retenue
QCOMPARE(instant.at(k).recrediteW, 0);
}
}
QVERIFY2(suspenduesObeie > 0, "DÉFAUT attendu : recrédit suspendu sur une borne qui a obéi");
// 3. DÉFAUT — la consigne ne se stabilise pas sous un surplus constant. Correct : 13 A dès
// le premier cycle, puis plus aucun changement.
QCOMPARE(instant.at(0).consigneA, 13);
int changements = 0;
for (int k = 1; k < Cycles; ++k)
if (instant.at(k).consigneA != instant.at(k - 1).consigneA)
++changements;
QVERIFY2(changements >= Cycles / 3,
qUtf8Printable(QStringLiteral("DÉFAUT attendu : consigne qui oscille, %1 changements")
.arg(changements)));
#endif
}
/*! /*!
* \brief [R6] La MESURABILITÉ de l'avancement se déclare à la SAISIE, avec sa cause. * \brief [R6] La MESURABILITÉ de l'avancement se déclare à la SAISIE, avec sa cause.
* *

View File

@ -240,6 +240,8 @@ private slots:
void testPhaseVoltageDoesNotLeakBetweenPhases(); void testPhaseVoltageDoesNotLeakBetweenPhases();
void testMeterEnergyActuallyAccumulates(); void testMeterEnergyActuallyAccumulates();
void testRecreditIsALoanAgainstObedience(); void testRecreditIsALoanAgainstObedience();
//! Reproduction — une marche de consigne suivie fait suspendre le recrédit au cycle suivant.
void testRecreditSuspendedAfterAnObeyedStep();
void testARefusedStateIsDistinguishableFromAnApplied(); void testARefusedStateIsDistinguishableFromAnApplied();
void testL4ShedsAPacAndSaysItForcedTheLock(); void testL4ShedsAPacAndSaysItForcedTheLock();
void testDerogationTakesTheHeadButNotTheSafety(); void testDerogationTakesTheHeadButNotTheSafety();