fix(etm): ECS-306 — le budget décrémenté suit le palier réellement applicable

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) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-08-08 12:47:55 +02:00
parent 701759ddbb
commit 3846a2e29c
5 changed files with 112 additions and 11 deletions

View File

@ -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;
}

View File

@ -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)"));

View File

@ -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

View File

@ -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 <QDir>
@ -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<EnergyArbitrator *>(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<LoadConfigRelay>({ {rA.toString(), 2000} }), 300, 0, 1, LoadNeeds(), arb);
RelayRouter *l2 = new RelayRouter(tm, "ecs-2", "ECS secondaire",
QList<LoadConfigRelay>({ {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

View File

@ -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);