From 3846a2e29ca59eea7a0c2022c283593effe6d7d0 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 8 Aug 2026 12:47:55 +0200 Subject: [PATCH] =?UTF-8?q?fix(etm):=20ECS-306=20=E2=80=94=20le=20budget?= =?UTF-8?q?=20d=C3=A9cr=C3=A9ment=C3=A9=20suit=20le=20palier=20r=C3=A9elle?= =?UTF-8?q?ment=20applicable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Régression contre une décision documentée : AGENTS.md, section « Verrous minOn/minOff », énonce que le scheduler clampe et décrémente le budget au palier réel. Le canal qui le permettait — minStage/maxStage dans LoadContextTelemetry — a été retiré en rév. 2/3. L'écrêtage a continué d'avoir lieu dans l'adaptateur, mais son résultat ne revenait plus à l'arbitre : pendant toute la fenêtre minOn, les charges de priorité suivante recevaient un résidu SURESTIMÉ et l'installation soutirait au réseau. Règle visée : la 4 — « bornes par adaptateur écrêtent TOUTE sortie de stratégie ». Pas la 1 : il n'y a qu'un décideur, le défaut est un décalage de comptabilité entre décision et exécution. Le canal est restauré EN WATTS, pas en index de palier : lockMinPowerW / lockMaxPowerW dans LoadContextTelemetry, remplis par RelayRouter::toLoadContext() depuis lockWindow(), convertis via la table de paliers. La frontière rév. 3 tient — aucun identifiant de relais ni index de combinaison ne remonte. Un lockMaxPowerW négatif signifie « aucun plafond », et non « plafond nul ». buildSetpointAction() applique ces bornes AVANT de décrémenter le budget, donc le résidu passé aux charges suivantes tient compte de la puissance engagée — quitte à devenir négatif, ce qui est la réponse correcte. Le decisionReason distingue ce cas : « Verrou minOn — X maintenue à N W (puissance engagée, budget M W) ; le résidu en tient compte ». Corriger dans le cycle, pas au cycle suivant : remonter le palier appliqué après coup n'aurait rattrapé l'erreur qu'au tour d'après. Test : testEcsBudgetUnderLock — deux charges classées, la première verrouillée à 2000 W sous un budget de 500 W ; la seconde reste à 0 parce que le résidu tient compte des 2000 W engagés. Build amd64 0 erreur. Simulation : 14/14. Réf. specs/spec_ecs.md §3 ECS-306 (0.5.1). Co-Authored-By: Claude Opus 5 (1M context) --- energyplugin/etm/adapters/relayrouter.cpp | 14 ++-- .../etm/scheduler/rulebasedscheduler.cpp | 16 ++++- energyplugin/etm/types/surpluscontext.h | 14 +++- tests/auto/simulation/simulation.cpp | 69 +++++++++++++++++++ tests/auto/simulation/simulation.h | 10 ++- 5 files changed, 112 insertions(+), 11 deletions(-) diff --git a/energyplugin/etm/adapters/relayrouter.cpp b/energyplugin/etm/adapters/relayrouter.cpp index 1da7348..368f481 100644 --- a/energyplugin/etm/adapters/relayrouter.cpp +++ b/energyplugin/etm/adapters/relayrouter.cpp @@ -124,8 +124,6 @@ LoadTelemetry RelayRouter::telemetry() const LoadContext RelayRouter::toLoadContext(const QDateTime &now) const { - Q_UNUSED(now) // pas de fenêtre de verrou exposée : le verrou est INTERNE (frontière rév. 3). - LoadContext ctx; ctx.id = m_id; ctx.adapter = QStringLiteral("relay-router"); @@ -135,8 +133,16 @@ LoadContext RelayRouter::toLoadContext(const QDateTime &now) const ctx.needs = m_needs; ctx.telemetry.currentPowerW = telemetry().currentPowerW; - // Aucun stage/minStage/maxStage : ces champs n'existent plus (rév. 2) et le relais ne - // franchit pas la frontière. L'optimiseur ne voit que des watts. + + // ECS-306 — fenêtre de verrou exposée au scheduler, EN WATTS. Aucun index de palier ni + // identifiant de relais ne franchit la frontière (rév. 3) : on convertit les bornes + // internes en puissances via la table de paliers. Sans ce canal, le scheduler décrémente + // le budget d'une valeur que l'adaptateur va écrêter, et les charges suivantes reçoivent + // un résidu surestimé pendant toute la fenêtre minOn (AGENTS règle 4). + int minStage, maxStage; + lockWindow(now, minStage, maxStage); + ctx.telemetry.lockMinPowerW = m_levels.at(minStage); + ctx.telemetry.lockMaxPowerW = m_levels.at(maxStage); return ctx; } diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index a885867..4cfd53c 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -204,6 +204,16 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, setpointW = chosen; } + // ECS-306 — clamp lock-aware AVANT de décrémenter le budget (AGENTS règle 4 : les + // bornes de l'adaptateur écrêtent TOUTE sortie de stratégie, et leur résultat doit + // revenir à l'arbitre). Sans ceci, l'adaptateur remonte le palier de son côté et le + // scheduler décrémente une valeur plus basse : pendant toute la fenêtre minOn, les + // charges suivantes reçoivent un résidu surestimé → soutirage réseau. + if (lc.telemetry.lockMaxPowerW >= 0) + setpointW = qMin(setpointW, lc.telemetry.lockMaxPowerW); + if (setpointW < lc.telemetry.lockMinPowerW) + setpointW = lc.telemetry.lockMinPowerW; // puissance engagée, non-coupable + LoadAction la; la.loadId = lc.id; la.kind = LoadAction::Setpoint; @@ -211,7 +221,11 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, la.powerW = setpointW; la.estimatedPowerW = setpointW; - if (setpointW > 0) + if (setpointW > budgetW) + la.reason = QStringLiteral("Verrou minOn — %1 maintenue à %2 W (puissance engagée, " + "budget %3 W) ; le résidu en tient compte") + .arg(lc.label).arg(qRound(setpointW)).arg(qRound(budgetW)); + else if (setpointW > 0) la.reason = QStringLiteral("Surplus PV %1 W — %2 consigne %3 W%4") .arg(qRound(budgetW)).arg(lc.label).arg(qRound(setpointW)) .arg(dynamic ? QString() : QStringLiteral(" (palier)")); diff --git a/energyplugin/etm/types/surpluscontext.h b/energyplugin/etm/types/surpluscontext.h index a7a10a3..4e02b9e 100644 --- a/energyplugin/etm/types/surpluscontext.h +++ b/energyplugin/etm/types/surpluscontext.h @@ -59,9 +59,17 @@ struct LoadContextTelemetry { bool pluggedIn = false; //!< Véhicule branché. Une borne non branchée est ignorée du plan. bool charging = false; //!< Charge effectivement en cours (courant non nul côté borne). double sessionWh = 0; //!< Énergie chargée dans la session courante (Wh). - // --- etmvariableload (EV/ECS/routeur) : pas de fenêtre de verrou côté moteur — - // l'anti-rebond (minOn/minOff) vit dans le thing (contrat rév. 2 §3). Seul - // currentPowerW (ci-dessus) sert au résidu de la règle d'arrondi (contrat §5). + // --- charges pilotées en watts : fenêtre de verrou (ECS-306) --- + //! Plancher (W) imposé MAINTENANT par le verrou \c minOn : puissance déjà engagée que + //! l'adaptateur ne peut pas couper. Le scheduler DOIT décrémenter le budget de cette + //! valeur même si elle dépasse le budget disponible — sinon les charges de priorité + //! suivante reçoivent un résidu surestimé et l'installation soutire au réseau. + //! 0 = aucun plancher. + double lockMinPowerW = 0; + //! Plafond (W) imposé MAINTENANT par le verrou \c minOff : au-delà, l'adaptateur + //! refusera de redémarrer. **Négatif = aucun plafond** (et non « plafond nul »). + //! Exprimé en watts, jamais en index de palier : la frontière rév. 3 tient. + double lockMaxPowerW = -1; // --- sg-ready --- int state = 0; //!< État SG-Ready courant commandé (1-4) ; 0 = non applicable. //! Fenêtre d'états autorisée MAINTENANT par le verrou minStateHold (protection diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index d4d4810..ab85b4f 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -43,6 +43,7 @@ using namespace nymeaserver; #include "../../../energyplugin/etm/types/loadconfig.h" #include "../../../energyplugin/etm/config/loadconfigstore.h" #include "../../../energyplugin/etm/ratios/energyratioscalculator.h" +#include "../../../energyplugin/etm/scheduler/rulebasedscheduler.h" #endif #include @@ -868,6 +869,74 @@ void Simulation::testLoadConfigRelayRouter() #endif } +void Simulation::testEcsBudgetUnderLock() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testEcsBudgetUnderLock nécessite ETM_ARBITRATOR."); +#else + // [ECS-306] Sous verrou minOn, l'adaptateur maintient un palier au-dessus du budget. + // Le scheduler DOIT décrémenter le budget de ce palier réel — sinon la charge de + // priorité suivante reçoit un résidu surestimé et l'installation soutire au réseau. + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + ThingManager *tm = NymeaCore::instance()->thingManager(); + + QUuid rA = addPowerSwitch(2000, 26661); // charge 1, prioritaire, verrouillée minOn + QUuid rB = addPowerSwitch(1000, 26662); // charge 2, servie sur le résidu + Thing *tA = tm->findConfiguredThing(rA); + Thing *tB = tm->findConfiguredThing(rB); + QVERIFY(tA && tB); + + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + + RelayRouter *l1 = new RelayRouter(tm, "ecs-1", "ECS prioritaire", + QList({ {rA.toString(), 2000} }), 300, 0, 1, LoadNeeds(), arb); + RelayRouter *l2 = new RelayRouter(tm, "ecs-2", "ECS secondaire", + QList({ {rB.toString(), 1000} }), 0, 0, 2, LoadNeeds(), arb); + arb->registerRelayRouter(l1); + arb->registerRelayRouter(l2); + + auto sp = [&](double w) { + LoadAction a; a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus; + a.powerW = w; a.reason = QStringLiteral("amorçage test"); return a; + }; + + // Amorçage : la charge 1 monte à 2000 W et arme son verrou minOn (300 s). + l1->applyAction(sp(2500), t0); + QCOMPARE(qRound(l1->currentSetpointW()), 2000); + + // 60 s plus tard : minOn non écoulé → la fenêtre exposée impose un PLANCHER de 2000 W. + const QDateTime t1 = t0.addSecs(60); + LoadContext c1 = l1->toLoadContext(t1); + QCOMPARE(qRound(c1.telemetry.lockMinPowerW), 2000); // le canal ECS-306 existe… + QVERIFY(c1.telemetry.lockMaxPowerW >= c1.telemetry.lockMinPowerW); + + // …et il est HONORÉ : avec un budget de 500 W, la charge 1 reste écrêtée à 2000 W et le + // résidu passé à la charge suivante est NÉGATIF, donc la charge 2 reste à 0. + RuleBasedScheduler sched(arb, nullptr); + SurplusContext ctx; + ctx.timestamp = t1; + ctx.meter.exportW = 500; + ctx.meter.importW = 0; + ctx.loads.append(c1); + ctx.loads.append(l2->toLoadContext(t1)); + + Plan plan = sched.getPlan(ctx); + Slot slot = plan.slotCovering(t1); + double a1 = -1, a2 = -1; + for (const LoadAction &a : slot.actions) { + if (a.loadId == "ecs-1") a1 = a.powerW; + if (a.loadId == "ecs-2") a2 = a.powerW; + } + QCOMPARE(qRound(a1), 2000); // maintenu par le verrou, au-dessus du budget + QCOMPARE(qRound(a2), 0); // le résidu tient compte des 2000 W engagés + QVERIFY2(!slot.actions.isEmpty(), "aucune action produite"); +#endif +} + void Simulation::testEcsRestartRecovery() { #ifndef ETM_ARBITRATOR diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index cd1eed2..957a40c 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -85,12 +85,16 @@ private slots: // (EnergyRatiosInterim.compute) — seed, normal, clamp, den≤0→n/a, non-monotone, nouveau jour. void testEnergyRatiosAlignment(); - // [étape 1 / ECS-412] SetLoadConfig pendant une fenêtre de verrou active ne réarme pas - // le verrou : seul le matériel modifié est reconstruit. Couvre aussi l'armement à froid. - void testEcsRebuildPreservesLock(); + // [étape 1 / ECS-306] Le budget décrémenté par le scheduler correspond au palier + // RÉELLEMENT applicable sous verrou minOn — les charges suivantes ne reçoivent pas un + // résidu surestimé. + void testEcsBudgetUnderLock(); // [étape 1 / ECS-411] Le palier courant est déduit de l'état réel des Things relais au // démarrage, pas réinitialisé à 0. void testEcsRestartRecovery(); + // [étape 1 / ECS-412] SetLoadConfig pendant une fenêtre de verrou active ne réarme pas + // le verrou : seul le matériel modifié est reconstruit. Couvre aussi l'armement à froid. + void testEcsRebuildPreservesLock(); void printStates(Thing *thing); void updateChargerMeter(Thing *thing);