From 38e490e4321b89fd96f478cc182d7089874657d6 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 11 Aug 2026 07:05:57 +0200 Subject: [PATCH] =?UTF-8?q?fix(scheduler):=20ECS-309=20=E2=80=94=20le=20mo?= =?UTF-8?q?tif=20nomme=20le=20verrou,=20pas=20le=20budget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../etm/scheduler/rulebasedscheduler.cpp | 20 ++++++++ tests/auto/simulation/simulation.cpp | 51 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 090c52d..87b3186 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -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)) diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 26b73d4..690a207 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -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({ {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({ {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;