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:
parent
701759ddbb
commit
3846a2e29c
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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)"));
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user