diff --git a/energyplugin/etm/adapters/evadapter.cpp b/energyplugin/etm/adapters/evadapter.cpp index bf2a0e9..0c3a2ef 100644 --- a/energyplugin/etm/adapters/evadapter.cpp +++ b/energyplugin/etm/adapters/evadapter.cpp @@ -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; diff --git a/energyplugin/etm/adapters/evadapter.h b/energyplugin/etm/adapters/evadapter.h index c80a3fe..361824d 100644 --- a/energyplugin/etm/adapters/evadapter.h +++ b/energyplugin/etm/adapters/evadapter.h @@ -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 &ere, 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; diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 971dad0..89b4fb8 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -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,14 +2588,20 @@ 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 &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); - enVigueur = { commandeW, parDispatch ? m_actionsAppliqueesAt : now }; + if (instantBorne.isValid() && instantBorne <= now) + enVigueur = { commandeW, instantBorne }; + else + enVigueur = { commandeW, parDispatch ? m_actionsAppliqueesAt : now }; } JugementCommande j; diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index bf1ca82..807dcf8 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -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 diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index a5a84a3..60ff950 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -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(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 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 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(); diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index e4ad194..a4f9f64 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -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();