borne : retenir ce que la borne tient, et geler l'arrêt verrouillé (P2)

La borne retenait la consigne VOULUE par son adaptateur, et seulement
quand le waterfall la commandait. Deux faux refus en sortaient :
- une coupure refusée par le verrou de marche/arrêt (300 s) laissait la
  borne à 6 A pendant qu'on attendait 0 W ;
- une borne rabaissée par la protection L4, ou commandée par le proxy
  (échéance, spot), restait jugée contre la dernière consigne du
  waterfall.

La mémoire de commande est posée par EnergyArbitrator::
executeChargingAction(), point unique de tous les émetteurs, avec ce qui
a PASSÉ les verrous et l'instant de la commande.

Pendant que l'arrêt est verrouillé, la borne déclare lockMinPowerW =
courant minimal × 230 × phases, comme un relais sous minOn : le
waterfall gèle ces watts (LOCK_MIN_ON) au lieu de les prêter.

Le verrou borne la SOMME éco + confort : la part confort n'est relevée
que de ce que la passe éco ne couvre pas déjà. Sans cela, une borne
servie à 6 A en éco sous arrêt verrouillé recevait 12 A (trouvé par la
preuve decisions, aucun test ne le voyait).

Tests : testLockedStopFreezesTheMinimumAndIsNoRefusal,
testALockedStopDoesNotAddToTheEcoFloor, et une garde L4 dans
testOverloadProtectionReasonOnTheCharger.

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

View File

@ -51,8 +51,8 @@ LoadTelemetry EvAdapter::telemetry() const
LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
{
// Les verrous EV (enclenchement, changement de courant) vivent en aval : `now` ne sert qu'à
// savoir si l'instant tombe en heures creuses (lot e2).
// Les verrous EV (enclenchement, changement de courant) vivent en aval : `now` sert à savoir
// si l'instant tombe en heures creuses (lot e2), et si l'arrêt est verrouillé (P2, plus bas).
LoadContext ctx;
ctx.id = m_charger->thing()->id().toString();
ctx.adapter = QStringLiteral("evcharger");
@ -176,9 +176,20 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
ctx.telemetry.sessionMeasurable = m_charger->hasSessionEnergy();
ctx.telemetry.sessionWh = m_charger->sessionEnergyWh();
// Pas de fenêtre de verrou de palier côté EV : lockMaxPowerW négatif = aucun plafond
// (convention du contexte), et lockMinPowerW nul = rien d'engagé de non-coupable.
// Pas de plafond de verrou côté EV : lockMaxPowerW négatif = aucun plafond (convention du
// contexte).
ctx.telemetry.lockMaxPowerW = -1;
// P2 — l'ARRÊT VERROUILLÉ engage le courant minimal, comme un relais sous minOn. Une borne en
// marche dont le verrou de marche/arrêt (300 s par défaut) n'est pas expiré ne s'arrêtera pas,
// quoi qu'on lui commande : elle tirera son minimum. Le déclarer fait GELER ces watts par le
// waterfall ; ne pas le déclarer les laissait prêter aux suivantes, et la coupure que le plan
// croyait faire se lisait ensuite comme un refus de la borne — c'est le moteur qui la tenait.
if (m_charger->chargingEnabled() && m_charger->chargingEnabledLocked(now)) {
const uint phases = m_commandeValide && m_commandePhases > 0 ? m_commandePhases
: m_charger->phaseCount();
ctx.telemetry.lockMinPowerW = m_charger->maxChargingCurrentMinValue() * 230.0
* qMax(1u, phases);
}
return ctx;
}
@ -287,18 +298,10 @@ LoadAction EvAdapter::applyAction(const LoadAction &action, const QDateTime &now
m_parent->doExecuteChargingAction(m_charger, ca, now); // now = temps de cycle (injectable)
m_lastActionAt = now;
// §Commande perdue, étape 1 — MÉMOIRE de ce qu'on vient de commander. Les adaptateurs de
// charges en watts la gardent depuis toujours (ils en ont besoin pour l'écrêtage de
// verrou) ; la borne, pilotée par le proxy amont, n'a jamais eu à se relire. C'est
// exactement là que le constat du 2026-08-31 porte : sans ce souvenir, l'écart entre ce
// qu'on a commandé et ce que la borne fait n'est pas CALCULABLE.
//
// On retient la commande ÉCRÊTÉE, pas la demande : c'est elle qui est partie au matériel,
// et c'est donc elle que la borne devrait refléter. Comparer la demande ferait voir un
// écart là où l'adaptateur a simplement fait son travail.
m_commandeA = enclencher ? clampedA : 0;
m_commandePhases = phases;
m_commandeValide = true;
// §Commande perdue, étape 1 — MÉMOIRE de ce qu'on vient de commander. Elle n'est plus posée
// ici : EnergyArbitrator::executeChargingAction() la pose pour TOUT émetteur (ce chemin, le
// proxy, la protection L4), avec ce qui a passé les verrous de la borne (P2). Posée ici seul,
// elle restait périmée dès qu'un autre émetteur écrivait, et l'écart se lisait comme un refus.
LoadAction applied = action;
applied.currentA = clampedA;

View File

@ -46,9 +46,9 @@ public:
/*!
* \brief Construit l'entrée loads[] §5 du SurplusContext.
* \param now Temps de cycle (\c ctx.timestamp). L'EV n'a pas de verrou de palier (hors
* waterfall ECS/SG-Ready) ; l'instant ne sert qu'à savoir s'il tombe en heures creuses
* (lot e2) — la borne y vise sa pleine puissance, et ses phases s'y choisissent.
* \param now Temps de cycle (\c ctx.timestamp). Il sert à savoir s'il tombe en heures
* creuses (lot e2) — la borne y vise sa pleine puissance, et ses phases s'y choisissent —
* et si l'ARRÊT est verrouillé (voir la note P2).
* \return LoadContext incluant declared, limits, needs et télémétrie EV.
* \note **Lot e2** — dans les trois modes qui reportent en heures creuses, le minimum
* d'heures creuses (\c needs.offPeakMinPowerW) est le PLAFOND du cycle
@ -57,6 +57,9 @@ public:
* \note **Lot e2, suite** — dans ces mêmes conditions, la limite de phase est lue par
* \c EnergyArbitrator::evOffPeakAllowanceW() : la place libre plus le courant MESURÉ de la
* borne, moins 1 A par phase. Hors heures creuses, \c evPhaseAllowanceW(), inchangée.
* \note **P2** — borne en marche dont le verrou de marche/arrêt n'a pas expiré :
* \c telemetry.lockMinPowerW = courant minimal × 230 × phases. Elle ne s'arrêtera pas ce
* cycle, et le waterfall gèle ce qu'elle tirera, comme pour un relais sous \c minOn.
*/
LoadContext toLoadContext(const QDateTime &now) const override;
@ -139,8 +142,9 @@ public:
EvCharger *evCharger() const { return m_charger; }
/*!
* \brief Dernière commande envoyée au matériel par cet adaptateur.
* \param[out] ampere Courant commandé (A), 0 si la charge a été coupée.
* \brief Dernière commande tenue par la borne, quel qu'en soit l'émetteur, telle qu'elle a
* PASSÉ ses verrous (\c noterCommandeTenue()).
* \param[out] ampere Courant tenu (A), 0 si la charge a été coupée.
* \param[out] phases Nombre de phases commandé.
* \return Faux si AUCUNE commande n'est jamais partie — l'écart est alors sans objet, et
* c'est le cas normal d'une borne en mode manuel que l'arbitre ne pilote pas.
@ -148,9 +152,30 @@ public:
bool derniereCommande(uint &ampere, uint &phases) const
{ ampere = m_commandeA; phases = m_commandePhases; return m_commandeValide; }
//! \brief Instant de la dernière commande, à l'horloge de l'émetteur (temps de cycle pour le
//! waterfall). \return Invalide tant qu'aucune commande n'est partie.
QDateTime derniereCommandeAt() const { return m_commandeAt; }
/*!
* \brief Note la commande que la borne TIENT, quel qu'en soit l'émetteur (P2).
* \param active Marche après les verrous. \param courantA Courant après les verrous (A).
* \param phases Phases commandées. \param at Instant de la commande.
* \note Appelée par \c EnergyArbitrator::executeChargingAction(), le point unique par où
* passent le waterfall, le proxy et la protection L4 : posée par un seul d'entre eux, la
* mémoire se périmait dès qu'un autre écrivait.
*/
void noterCommandeTenue(bool active, uint courantA, uint phases, const QDateTime &at)
{
m_commandeA = active ? courantA : 0;
m_commandePhases = phases;
m_commandeValide = true;
m_commandeAt = at;
}
private:
/*!
* \brief Dernière commande RÉELLEMENT envoyée au matériel — écrêtée, pas demandée.
* \brief Dernière commande RÉELLEMENT tenue par le matériel — écrêtée et passée par les
* verrous de la borne, pas demandée.
*
* \par Pourquoi ce souvenir n'existait pas
* Les adaptateurs de charges en watts gardent leur consigne parce que l'écrêtage de verrou
@ -161,6 +186,9 @@ private:
*
* \note La commande ÉCRÊTÉE, jamais la demande : c'est elle qui est partie au matériel.
* Comparer la demande ferait voir un écart là où l'adaptateur a fait son travail.
* \note **P2** — et ce qui a PASSÉ les verrous de marche/arrêt et de courant : un arrêt
* refusé par le verrou de 300 s laisse la borne en marche à son courant minimal, et c'est
* ce courant qui est retenu, pas 0.
*/
uint m_commandeA = 0;
uint m_commandePhases = 0;
@ -168,6 +196,7 @@ private:
//! à zéro : sans ce booléen, une borne jamais pilotée paraîtrait divergente dès qu'elle
//! consomme, ce qui est le cas NORMAL du mode manuel.
bool m_commandeValide = false;
QDateTime m_commandeAt; //!< Instant de la dernière commande.
EvCharger *m_charger;
EnergyArbitrator *m_parent;

View File

@ -1093,6 +1093,17 @@ void EnergyArbitrator::executeChargingAction(EvCharger *evCharger, const Chargin
// touche ni à la consigne, ni à l'émetteur, ni aux verrous (règle 4).
SmartChargingManager::executeChargingAction(evCharger, chargingAction, currentDateTime);
// P2 — la borne retient ce qu'elle TIENT, quel que soit l'émetteur : c'est contre cette
// commande-là que sa mesure est jugée au cycle suivant. Le waterfall, le proxy et la
// protection L4 passent tous ici ; une coupure refusée par le verrou de 300 s y reste en
// marche à son courant, et une borne rabaissée par L4 y est à son nouveau courant.
if (EvAdapter *ev = m_adapters.value(evCharger->id().toString(), nullptr))
ev->noterCommandeTenue(activationPasse ? chargingAction.chargingEnabled() : avantActive,
courantPasse ? demandeA : avantA,
chargingAction.desiredPhaseCount() > 0
? chargingAction.desiredPhaseCount() : evCharger->phaseCount(),
currentDateTime);
if (!noterGesteBorne(evCharger, chargingAction.issuer(), avantActive, avantA,
activationPasse ? chargingAction.chargingEnabled() : avantActive,
courantPasse ? demandeA : avantA, currentDateTime))
@ -2577,13 +2588,19 @@ EnergyArbitrator::jugerCommande(const QString &loadId, const QDateTime &now) con
// lieu depuis. Sinon (repli L2, protection L4 hors cycle) l'instant est inconnu, et on prend
// le plus récent possible — une latence accordée à tort retarde un verdict d'un cycle, un
// refus prononcé à tort suspend le recrédit d'une charge saine.
// Une borne connaît l'instant de sa commande, quel qu'en soit l'émetteur (L4 écrit hors cycle).
QPair<double, QDateTime> &enVigueur = m_commandeEnVigueur[loadId];
if (!enVigueur.second.isValid()
|| qAbs(commandeW - enVigueur.first) > toleranceConcordance(enVigueur.first)) {
const EvAdapter *ev = m_adapters.value(loadId, nullptr);
const QDateTime instantBorne = ev ? ev->derniereCommandeAt() : QDateTime();
// Le dispatch d'un cycle porte le MÊME temps que le jugement fait au début de ce cycle :
// il le suit, d'où le « au moins aussi récent ».
const bool parDispatch = m_actionsAppliqueesAt.isValid() && m_actionsAppliqueesAt <= now
&& (!precedent.at.isValid() || m_actionsAppliqueesAt >= precedent.at);
if (instantBorne.isValid() && instantBorne <= now)
enVigueur = { commandeW, instantBorne };
else
enVigueur = { commandeW, parDispatch ? m_actionsAppliqueesAt : now };
}

View File

@ -613,6 +613,13 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
LoadContext lcConfort = lc;
if (plafondPartage.contains(lc.id))
lcConfort.declared.minPowerW = 0;
// Même règle pour la puissance ENGAGÉE par un verrou : elle borne la SOMME éco + confort,
// qui est la consigne. Relevée en entier sur la part confort, elle s'ajoutait à un achat
// éco qui la couvrait déjà : une borne tenue à 6 A par son verrou d'arrêt, servie à 6 A
// en éco, recevait 12 A.
if (niveauxEco.contains(lc.id))
lcConfort.telemetry.lockMinPowerW =
qMax(0.0, lc.telemetry.lockMinPowerW - niveauxEco.value(lc.id).targetW);
if (lc.adapter == QStringLiteral("sg-ready"))
slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta));
else

View File

@ -9324,6 +9324,125 @@ void Simulation::testRecreditSuspendedAfterAnObeyedStep()
#endif
}
/*!
* \brief P2 — un ARRÊT VERROUILLÉ engage le courant minimal : gelé au budget, et aucun faux refus.
*
* Borne à 13 A, démarrée à k=0 : son verrou de marche/arrêt (300 s) court jusqu'à k=5. À k=2 la
* production s'effondre, le plan voudrait couper. Avant le correctif, la coupure était
* commandée, refusée par le verrou, et la borne restait à 6 A pendant qu'on attendait 0 W : un
* refus fabriqué par le moteur lui-même au cycle suivant, et 1 380 W prêtés à la charge suivante
* pendant que la borne les tirait.
*
* Avec P2, la borne DÉCLARE ce que le verrou engage (\c lockMinPowerW = 6 A × 230 V) : le
* waterfall gèle ces watts (\c LOCK_MIN_ON), la consigne de routeur derrière n'en reçoit rien, la
* borne retient les 6 A qu'elle tient, et aucun refus n'est lu. À l'expiration du verrou,
* l'arrêt passe. La somme avec la passe éco : testALockedStopDoesNotAddToTheEcoFloor().
*/
void Simulation::testLockedStopFreezesTheMinimumAndIsNoRefusal()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
constexpr double MaisonW = 300;
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-arret-verrouille.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());
QVERIFY(arb);
ThingManager *tm = NymeaCore::instance()->thingManager();
const QUuid meterId = addMeter(27340);
m_experiencePlugin->energyManager()->setRootMeter(meterId);
const QUuid borneId = addCharger("A", 16, 27341);
const QUuid routeurId = addEtmVariableLoad(27342);
Thing *meter = tm->findConfiguredThing(meterId);
Thing *borne = tm->findConfiguredThing(borneId);
Thing *routeur = tm->findConfiguredThing(routeurId);
QVERIFY(meter && borne && routeur);
meter->setStateValue("connected", true);
routeur->setStateValue("currentPowerW", 0);
armerBorne(borne);
QVariantMap info;
info.insert("evChargerId", borneId);
info.insert("chargingMode", "ChargingModeEco");
injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
// Amorce : la borne reçoit son entrée de configuration (rang 1) ; le routeur PV passe derrière.
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
meter->setStateValue("currentPower", MaisonW);
arb->simulationCallUpdate(t0.addSecs(-60));
QCoreApplication::processEvents();
QVariantList cfgs = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
.value("params").toMap().value("loadConfigs").toList();
int rangMax = 0;
for (const QVariant &v : cfgs)
rangMax = qMax(rangMax, v.toMap().value("priority").toInt());
cfgs << QVariantMap{{"id", routeurId.toString()}, {"label", "Routeur PV"}, {"adapter", "etmvariableload"},
{"mode", "dynamic"}, {"maxPowerW", 2000}, {"priority", rangMax + 1}, {"enabled", true}};
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", cfgs}})).toMap()
.value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError"));
struct Pas { int borneA = 0; QString motif; int appliqueW = -1; int alloueBorneW = 0; int alloueRouteurW = 0;
bool divergent = false; };
QList<Pas> trace;
double borneW = 0, routeurW = 0;
for (int k = 0; k < 7; ++k) {
const double productionW = k < 2 ? 3300.0 : 600.0;
borne->setStateValue("currentPower", qRound(borneW));
routeur->setStateValue("currentPowerW", routeurW);
meter->setStateValue("currentPower", qRound(MaisonW + borneW + routeurW - productionW));
arb->simulationCallUpdate(t0.addSecs(60 * k));
QCoreApplication::processEvents();
const bool enMarche = borne->stateValue("power").toBool();
const int courantA = borne->stateValue("maxChargingCurrent").toInt();
borneW = enMarche ? courantA * 230.0 : 0.0;
routeurW = routeur->stateValue("powerSetpoint").toDouble();
const QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
const QVariantMap b = tele.value(borneId.toString());
Pas p;
p.borneA = enMarche ? courantA : 0;
p.motif = b.value("decision").toMap().value("code").toString();
p.appliqueW = b.value("decision").toMap().value("params").toMap().value("appliedW", -1).toInt();
p.alloueBorneW = qRound(b.value("allocatedW").toDouble());
p.alloueRouteurW = qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble());
p.divergent = b.value("command").toMap().value("divergent").toBool();
trace.append(p);
qInfo().noquote() << QStringLiteral("[arrêt verrouillé] k=%1 borne %2 A | %3 appliqué %4 | alloué borne %5 routeur %6 | divergent %7")
.arg(k).arg(p.borneA).arg(p.motif).arg(p.appliqueW).arg(p.alloueBorneW)
.arg(p.alloueRouteurW).arg(p.divergent ? "oui" : "non");
}
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
QCOMPARE(trace.at(0).borneA, 13);
QCOMPARE(trace.at(1).borneA, 13);
// k=2..4 : l'arrêt est verrouillé (démarrage à k=0, 300 s). La borne déclare ses 1 380 W
// engagés : le waterfall les gèle, le motif le dit, la borne reste à 6 A.
for (int k = 2; k <= 4; ++k) {
QCOMPARE(trace.at(k).motif, QString(DecisionCode::LockMinOn));
QCOMPARE(trace.at(k).appliqueW, 1380);
QCOMPARE(trace.at(k).alloueBorneW, 1380);
QCOMPARE(trace.at(k).borneA, 6);
// Les watts gelés ne sont PAS prêtés : le routeur derrière n'en reçoit rien.
QCOMPARE(trace.at(k).alloueRouteurW, 0);
}
// k=5 : verrou expiré, l'arrêt passe.
QCOMPARE(trace.at(5).borneA, 0);
QCOMPARE(trace.at(6).borneA, 0);
// Et à aucun cycle la borne n'est lue comme refusant : c'est le moteur qui la tenait.
for (int k = 0; k < trace.size(); ++k) {
QVERIFY2(trace.at(k).motif != QLatin1String(DecisionCode::RecreditSuspended),
qUtf8Printable(QStringLiteral("k=%1 : faux refus sous arrêt verrouillé").arg(k)));
QVERIFY2(!trace.at(k).divergent, qUtf8Printable(QStringLiteral("k=%1 : divergent").arg(k)));
}
#endif
}
/*!
* \brief [R6] La MESURABILITÉ de l'avancement se déclare à la SAISIE, avec sa cause.
*
@ -11250,6 +11369,36 @@ void Simulation::testWithoutOffPeakTheEngineIsEtm74()
#endif
}
/*!
* \brief P2 — le verrou d'arrêt borne la SOMME éco + confort, il ne s'y ajoute pas.
*
* « Éco + minimum » sans heures creuses : le minimum est acheté au réseau (6 A) par la passe
* éco, pendant que l'arrêt est verrouillé. Relevé en entier sur la part confort, le minimum
* engagé s'ajoutait à l'achat qui le couvrait déjà, et la borne recevait 12 A. Trouvé par la
* preuve `decisions`, aucun test ne le voyait : testWithoutOffPeakTheEngineIsEtm74() compare les
* trames entre elles, pas le courant.
*/
void Simulation::testALockedStopDoesNotAddToTheEcoFloor()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
FuseauDeTest fuseau("Europe/Paris");
ScenarioHc s = preparerScenarioHc(QStringLiteral("arret-eco"), BatterieHc::Aucune, QVariantList());
QVERIFY(s.arb && s.borne);
s.borne->setStateValue("pluggedIn", true);
for (int m = 0; m <= 3; ++m) {
const QVariantMap snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(16, m)),
600 + (m > 0 ? 1380 : 0));
const QVariantMap d = telemetrieParId(snap).value(s.borneId.toString())
.value("decision").toMap();
QCOMPARE(d.value("code").toString(), QString(DecisionCode::EcoFloorGrid));
QVERIFY(s.borne->stateValue("power").toBool());
QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 6);
}
#endif
}
void Simulation::testABatteryWithoutControlIsReadAsBefore()
{
#ifndef ETM_ARBITRATOR
@ -12256,6 +12405,10 @@ void Simulation::testOverloadProtectionReasonOnTheCharger()
QCOMPARE(e.value("after").toMap().value("powerW").toInt(), 0);
QCOMPARE(e.value("after").toMap().value("mechanism").toMap().value("chargingEnabled").toBool(), false);
QVERIFY(!e.contains("endedAt"));
// La protection écrit HORS du waterfall. La borne tient ce qu'elle lui a donné : ce n'est pas
// un refus. Jugée contre la dernière consigne du waterfall (16 A), elle se lisait refusante,
// et son recrédit était retiré.
QCOMPARE(entree().value("command").toMap().value("divergent").toBool(), false);
// 3. RETOUR — le four s'éteint. Le cycle reprend la borne : le motif se lève AU MÊME CYCLE.
// L'épisode, lui, n'est clos qu'après un cycle complet sans protection — daté de la reprise.
@ -12265,6 +12418,7 @@ void Simulation::testOverloadProtectionReasonOnTheCharger()
QCOMPARE(decision().value("code").toString(), QString(DecisionCode::EvOffPeakFullPower));
QVERIFY(entree().contains("levels"));
QVERIFY(!journal().first().toMap().contains("endedAt"));
QCOMPARE(entree().value("command").toMap().value("divergent").toBool(), false);
notifs.clear();
const int revisionAvant = snap.value("shedding").toMap().value("revision").toInt();

View File

@ -269,6 +269,10 @@ private slots:
void testRecreditIsALoanAgainstObedience();
//! Une marche de consigne suivie ne fait plus suspendre le recrédit ; la consigne tient.
void testRecreditSuspendedAfterAnObeyedStep();
//! P2 — un arrêt verrouillé engage le courant minimal : gelé au budget, aucun faux refus.
void testLockedStopFreezesTheMinimumAndIsNoRefusal();
//! P2 — le verrou d'arrêt borne la somme éco + confort, il ne s'y ajoute pas.
void testALockedStopDoesNotAddToTheEcoFloor();
void testARefusedStateIsDistinguishableFromAnApplied();
void testL4ShedsAPacAndSaysItForcedTheLock();
void testDerogationTakesTheHeadButNotTheSafety();