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:
Patrick Schurig 2026-08-11 07:05:57 +02:00
parent 3c81f76da5
commit 38e490e432
2 changed files with 71 additions and 0 deletions

View File

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

View File

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