fix(scheduler): ECS-309 — le motif nomme le verrou, pas le budget
Quand un verrou produit le palier appliqué, le decisionReason doit nommer ce verrou. Le chemin minOff ne le faisait pas : il tombait dans la branche par défaut et publiait « Surplus insuffisant (7706 W) » alors que 7706 W étaient précisément disponibles. Constaté au banc le 2026-08-09, pendant la fenêtre d'armement à froid d'ECS-412. La décision était juste ; le motif disait l'inverse de la cause. La règle 7 d'AGENTS.md exige un motif non vide ; ECS-309 l'étend de la présence à la fidélité. Un motif faux est pire qu'un motif absent : il envoie diagnostiquer le mauvais problème, et il le fait avec l'autorité d'une explication. Le scheduler mémorise ce que la stratégie voulait AVANT écrêtage par les bornes de verrou — sans cette mémoire, « le budget ne payait pas » et « le verrou a refusé » aboutissent à la même consigne et deviennent indiscernables. Le motif minOff cite le budget DISPONIBLE, précisément pour couper court à l'explication budgétaire. Le test porte sur le MÉCANISME annoncé, pas sur la non-vacuité de la chaîne : les trois causes doivent produire trois textes distincts, et le cas minOff est vérifié échouant sans le correctif — il reproduit alors le motif exact du banc. Le cas minOn exige un soutirage pour être atteint : le recrédit anti-clignotement rend au budget la puissance déjà engagée, si bien qu'en export une charge peut toujours s'offrir son propre palier. Suite complète : 112 tests, 0 échec. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3c81f76da5
commit
38e490e432
@ -232,6 +232,11 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
|
||||
// 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.
|
||||
// ECS-309 — ce que la stratégie voulait, AVANT que les bornes de verrou n'écrêtent.
|
||||
// Sans cette mémoire, le motif ne peut pas distinguer « le budget ne payait pas » de
|
||||
// « le verrou a refusé » : les deux finissent à la même consigne.
|
||||
const double vouluW = setpointW;
|
||||
|
||||
if (lc.telemetry.lockMaxPowerW >= 0)
|
||||
setpointW = qMin(setpointW, lc.telemetry.lockMaxPowerW);
|
||||
if (setpointW < lc.telemetry.lockMinPowerW)
|
||||
@ -244,10 +249,25 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
|
||||
la.powerW = setpointW;
|
||||
la.estimatedPowerW = setpointW;
|
||||
|
||||
// ECS-309 — le motif nomme le MÉCANISME qui a produit l'issue, jamais un mécanisme
|
||||
// plausible. La règle 7 d'AGENTS.md exige un motif non vide ; celle-ci exige qu'il soit
|
||||
// vrai. Un motif faux envoie diagnostiquer le mauvais problème, avec l'autorité d'une
|
||||
// explication — constaté au banc le 2026-08-09 : palier 0 avec 7706 W disponibles, et
|
||||
// « Surplus insuffisant (7706 W) » publié.
|
||||
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 < vouluW)
|
||||
// Le budget payait vouluW ; c'est la fenêtre minOff qui interdit de monter. Le motif
|
||||
// cite le budget DISPONIBLE, précisément pour couper court à l'explication budgétaire.
|
||||
la.reason = setpointW <= 0
|
||||
? QStringLiteral("Verrou minOff — %1 ne peut pas s'enclencher avant la fin de la "
|
||||
"fenêtre (budget %2 W disponible, palier %3 W refusé)")
|
||||
.arg(lc.label).arg(qRound(budgetW)).arg(qRound(vouluW))
|
||||
: QStringLiteral("Verrou minOff — %1 plafonnée à %2 W avant la fin de la fenêtre "
|
||||
"(budget %3 W disponible, palier %4 W refusé)")
|
||||
.arg(lc.label).arg(qRound(setpointW)).arg(qRound(budgetW)).arg(qRound(vouluW));
|
||||
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))
|
||||
|
||||
@ -1141,6 +1141,57 @@ void Simulation::testEcsColdStartLockExpires()
|
||||
LoadContext c0 = r2->toLoadContext(t0);
|
||||
QCOMPARE(qRound(c0.telemetry.lockMaxPowerW), 0); // armé au premier cycle
|
||||
|
||||
// [ECS-309] Le MOTIF doit nommer le mécanisme réel. Ce test porte sur ce que le texte
|
||||
// dit, pas sur sa non-vacuité : c'est exactement ce qu'un « motif non vide » laissait
|
||||
// passer. Défaut constaté au banc le 2026-08-09 — palier plafonné à 0 avec 7706 W de
|
||||
// surplus, et « Surplus insuffisant (7706 W) » publié. La décision était juste, le
|
||||
// motif accusait le budget alors que la cause était l'armement minOff.
|
||||
RuleBasedScheduler sched(arb);
|
||||
auto motifPour = [&](RelayRouter *routeur, double surplusW, const QDateTime &at) {
|
||||
SurplusContext ctx;
|
||||
ctx.timestamp = at;
|
||||
// Surplus net SIGNÉ : positif = export, négatif = soutirage réseau.
|
||||
ctx.meter.exportW = surplusW > 0 ? surplusW : 0.0;
|
||||
ctx.meter.importW = surplusW < 0 ? -surplusW : 0.0;
|
||||
ctx.loads.append(routeur->toLoadContext(at));
|
||||
const Plan plan = sched.getPlan(ctx);
|
||||
for (const LoadAction &a : plan.timeSlots.first().actions)
|
||||
if (a.loadId == routeur->descriptor().id)
|
||||
return a.reason;
|
||||
return QString();
|
||||
};
|
||||
|
||||
// 1. Verrou minOff : budget PLÉTHORIQUE, palier refusé. Le motif nomme minOff et ne
|
||||
// met pas l'issue sur le compte du budget.
|
||||
const QString mOff = motifPour(r2, 7706, t0);
|
||||
QVERIFY2(mOff.contains("minOff"), qUtf8Printable("motif: " + mOff));
|
||||
QVERIFY2(!mOff.contains("insuffisant", Qt::CaseInsensitive), qUtf8Printable("motif: " + mOff));
|
||||
|
||||
// 2. Budget RÉELLEMENT insuffisant, hors verrou : là, le budget est la bonne explication.
|
||||
tA->setStateValue("power", false);
|
||||
RelayRouter *r3 = new RelayRouter(tm, "froid3", "Charge à froid 3",
|
||||
QList<LoadConfigRelay>({ {rA.toString(), 1000} }), 0, 0, 1, LoadNeeds(), arb);
|
||||
const QString mBudget = motifPour(r3, 200, t0); // 200 W < palier 1000 W
|
||||
QVERIFY2(mBudget.contains("insuffisant", Qt::CaseInsensitive), qUtf8Printable("motif: " + mBudget));
|
||||
QVERIFY2(!mBudget.contains("Verrou"), qUtf8Printable("motif: " + mBudget));
|
||||
|
||||
// 3. Verrou minOn : palier maintenu AU-DESSUS du budget. Troisième cas, troisième texte.
|
||||
// Il faut un SOUTIRAGE pour l'atteindre : le recrédit anti-clignotement (correction B)
|
||||
// rend au budget la puissance déjà engagée, si bien qu'en export une charge peut
|
||||
// toujours s'offrir son propre palier. C'est quand le compteur importe que le palier
|
||||
// tenu par minOn dépasse le budget — et c'est précisément le cas d'ECS-306.
|
||||
RelayRouter *r4 = new RelayRouter(tm, "chaud", "Charge chaude",
|
||||
QList<LoadConfigRelay>({ {rA.toString(), 1000} }), 600, 0, 1, LoadNeeds(), arb);
|
||||
LoadAction montee; montee.kind = LoadAction::Setpoint; montee.funding = LoadAction::Surplus;
|
||||
montee.powerW = 1000; montee.reason = QStringLiteral("amorçage minOn");
|
||||
r4->applyAction(montee, t0);
|
||||
QCOMPARE(qRound(r4->currentSetpointW()), 1000);
|
||||
const QString mOn = motifPour(r4, -400, t0.addSecs(10)); // import 400 W → budget 600 < 1000
|
||||
QVERIFY2(mOn.contains("minOn"), qUtf8Printable("motif: " + mOn));
|
||||
|
||||
// Les trois motifs sont distincts : c'est la lisibilité qu'ECS-309 exige.
|
||||
QVERIFY(mOff != mBudget && mBudget != mOn && mOff != mOn);
|
||||
|
||||
// Un premier applyAction estampille m_lastSwitch : à partir de là le verrou court.
|
||||
LoadAction amorce;
|
||||
amorce.kind = LoadAction::Setpoint;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user