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
40125d1026
commit
948ecd1a79
@ -124,8 +124,6 @@ LoadTelemetry RelayRouter::telemetry() const
|
|||||||
|
|
||||||
LoadContext RelayRouter::toLoadContext(const QDateTime &now) 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;
|
LoadContext ctx;
|
||||||
ctx.id = m_id;
|
ctx.id = m_id;
|
||||||
ctx.adapter = QStringLiteral("relay-router");
|
ctx.adapter = QStringLiteral("relay-router");
|
||||||
@ -135,8 +133,16 @@ LoadContext RelayRouter::toLoadContext(const QDateTime &now) const
|
|||||||
ctx.needs = m_needs;
|
ctx.needs = m_needs;
|
||||||
|
|
||||||
ctx.telemetry.currentPowerW = telemetry().currentPowerW;
|
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;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -204,6 +204,16 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
|
|||||||
setpointW = chosen;
|
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;
|
LoadAction la;
|
||||||
la.loadId = lc.id;
|
la.loadId = lc.id;
|
||||||
la.kind = LoadAction::Setpoint;
|
la.kind = LoadAction::Setpoint;
|
||||||
@ -211,7 +221,11 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
|
|||||||
la.powerW = setpointW;
|
la.powerW = setpointW;
|
||||||
la.estimatedPowerW = 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")
|
la.reason = QStringLiteral("Surplus PV %1 W — %2 consigne %3 W%4")
|
||||||
.arg(qRound(budgetW)).arg(lc.label).arg(qRound(setpointW))
|
.arg(qRound(budgetW)).arg(lc.label).arg(qRound(setpointW))
|
||||||
.arg(dynamic ? QString() : QStringLiteral(" (palier)"));
|
.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 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).
|
bool charging = false; //!< Charge effectivement en cours (courant non nul côté borne).
|
||||||
double sessionWh = 0; //!< Énergie chargée dans la session courante (Wh).
|
double sessionWh = 0; //!< Énergie chargée dans la session courante (Wh).
|
||||||
// --- etmvariableload (EV/ECS/routeur) : pas de fenêtre de verrou côté moteur —
|
// --- charges pilotées en watts : fenêtre de verrou (ECS-306) ---
|
||||||
// l'anti-rebond (minOn/minOff) vit dans le thing (contrat rév. 2 §3). Seul
|
//! Plancher (W) imposé MAINTENANT par le verrou \c minOn : puissance déjà engagée que
|
||||||
// currentPowerW (ci-dessus) sert au résidu de la règle d'arrondi (contrat §5).
|
//! 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 ---
|
// --- sg-ready ---
|
||||||
int state = 0; //!< État SG-Ready courant commandé (1-4) ; 0 = non applicable.
|
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
|
//! 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/types/loadconfig.h"
|
||||||
#include "../../../energyplugin/etm/config/loadconfigstore.h"
|
#include "../../../energyplugin/etm/config/loadconfigstore.h"
|
||||||
#include "../../../energyplugin/etm/ratios/energyratioscalculator.h"
|
#include "../../../energyplugin/etm/ratios/energyratioscalculator.h"
|
||||||
|
#include "../../../energyplugin/etm/scheduler/rulebasedscheduler.h"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
@ -868,6 +869,74 @@ void Simulation::testLoadConfigRelayRouter()
|
|||||||
#endif
|
#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()
|
void Simulation::testEcsRestartRecovery()
|
||||||
{
|
{
|
||||||
#ifndef ETM_ARBITRATOR
|
#ifndef ETM_ARBITRATOR
|
||||||
|
|||||||
@ -85,12 +85,16 @@ private slots:
|
|||||||
// (EnergyRatiosInterim.compute) — seed, normal, clamp, den≤0→n/a, non-monotone, nouveau jour.
|
// (EnergyRatiosInterim.compute) — seed, normal, clamp, den≤0→n/a, non-monotone, nouveau jour.
|
||||||
void testEnergyRatiosAlignment();
|
void testEnergyRatiosAlignment();
|
||||||
|
|
||||||
// [étape 1 / ECS-412] SetLoadConfig pendant une fenêtre de verrou active ne réarme pas
|
// [étape 1 / ECS-306] Le budget décrémenté par le scheduler correspond au palier
|
||||||
// le verrou : seul le matériel modifié est reconstruit. Couvre aussi l'armement à froid.
|
// RÉELLEMENT applicable sous verrou minOn — les charges suivantes ne reçoivent pas un
|
||||||
void testEcsRebuildPreservesLock();
|
// résidu surestimé.
|
||||||
|
void testEcsBudgetUnderLock();
|
||||||
// [étape 1 / ECS-411] Le palier courant est déduit de l'état réel des Things relais au
|
// [é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.
|
// démarrage, pas réinitialisé à 0.
|
||||||
void testEcsRestartRecovery();
|
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 printStates(Thing *thing);
|
||||||
void updateChargerMeter(Thing *thing);
|
void updateChargerMeter(Thing *thing);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user