Patrick Schurig 38e490e432 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>
2026-08-11 07:05:57 +02:00

347 lines
17 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "rulebasedscheduler.h"
#include "../energyarbitrator.h"
#include "../../evcharger.h"
#include "../../types/chargingaction.h"
#include "../../types/charginginfo.h"
#include "plugininfo.h"
#include <QUuid>
#include <algorithm>
RuleBasedScheduler::RuleBasedScheduler(EnergyArbitrator *arbitrator, QObject *parent)
: QObject(parent)
, m_arbitrator(arbitrator)
{
}
Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
{
// Planification (même logique que l'amont — écrit dans m_chargingActions)
m_arbitrator->runSpotMarketPlanning(ctx.timestamp);
m_arbitrator->runSurplusPlanning(ctx.timestamp);
Slot slot;
slot.from = ctx.timestamp;
slot.to = ctx.timestamp.addSecs(60);
const auto &cas = m_arbitrator->scheduledActions();
const auto &evs = m_arbitrator->registeredEvChargers();
// Même priorité que adjustEvChargers() — iso-fonctionnel 3b
for (auto it = evs.constBegin(); it != evs.constEnd(); ++it) {
EvCharger *ev = it.value();
if (!ev->available() || !ev->pluggedIn())
continue;
const ChargingActions &actions = cas.value(ev);
LoadAction la;
if (actions.value(ChargingAction::ChargingActionIssuerTimeRequirement).chargingEnabled()) {
la = buildTimeRequirementAction(
ev, actions.value(ChargingAction::ChargingActionIssuerTimeRequirement));
} else if (actions.value(ChargingAction::ChargingActionIssuerSurplusCharging).chargingEnabled()) {
const auto &ca = actions.value(ChargingAction::ChargingActionIssuerSurplusCharging);
la.loadId = ev->thing()->id().toString();
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Surplus;
la.chargingEnabled = true;
la.currentA = ca.maxChargingCurrent();
la.phaseCount = ca.desiredPhaseCount();
la.reason = QStringLiteral("Surplus PV disponible — recharge solaire");
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
} else if (actions.value(ChargingAction::ChargingActionIssuerSpotMarketCharging).chargingEnabled()) {
const auto &ca = actions.value(ChargingAction::ChargingActionIssuerSpotMarketCharging);
la.loadId = ev->thing()->id().toString();
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Grid;
la.chargingEnabled = true;
la.currentA = ca.maxChargingCurrent();
la.phaseCount = ca.desiredPhaseCount();
la.reason = QStringLiteral("Tarif aWATTar favorable — recharge heure creuse");
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
} else {
const ChargingInfo::ChargingMode mode =
m_arbitrator->chargingInfo(ev->id()).chargingMode();
if (mode == ChargingInfo::ChargingModeEcoWithMinCurrent
|| mode == ChargingInfo::ChargingModeEcoMinWithTargetTime) {
la = buildMinCurrentAction(ev);
} else {
la = buildIdleAction(ev);
}
}
slot.actions.append(la);
}
// ---- Waterfall ECS (charges non-EV à paliers) ---------------------------------
// Correction A — déduction EV unique : ctx.meter.exportW est la mesure BRUTE
// (invariant 8). Les consignes EV de CE cycle ne sont pas encore visibles au
// compteur ; on réserve donc leur puissance commandée non encore mesurée.
double evReservedW = 0;
for (const LoadAction &la : slot.actions) {
if (la.kind != LoadAction::Setpoint || !la.chargingEnabled)
continue;
EvCharger *ev = evs.value(ThingId(la.loadId));
const double measuredW = ev ? ev->currentPower() : 0.0;
evReservedW += qMax(0.0, la.estimatedPowerW - measuredW);
}
// Surplus net SIGNÉ : exportW importW = puissance compteur. Négatif en import →
// l'ECS déleste (budget < palier) au lieu de rester allumé sur le réseau. (Le maintien
// transitoire sous minOn est géré par le verrou de l'adaptateur, pas par le budget.)
double remainingSurplusW = (ctx.meter.exportW - ctx.meter.importW) - evReservedW;
// Charges pilotables non-EV triées par priorité ASCENDANTE : rang 1 = premier servi
// (OPTIMIZER_PROTOCOL §5 + annexe C — la priorité est un rang). Le budget de surplus est
// UNIQUE et cascade à travers TOUTES ces charges par priorité :
// - etmvariableload (ECS/routeur, kind Setpoint W → buildSetpointAction, règle d'arrondi §5) ;
// - sg-ready (PAC, kind State → buildSgReadyStateAction, mapping sémantique).
// Charges en WATTS (Setpoint) : etmvariableload (continu) ET relay-router (rév. 3, combinaison
// de relais dérivée côté routeur). Charge à ÉTATS : sg-ready (PAC). Le scheduler distingue
// Setpoint vs State (nature de l'action), PAS la classe concrète d'adaptateur (frontière rév. 3).
QList<LoadContext> nonEvLoads;
for (const LoadContext &lc : ctx.loads) {
if (lc.adapter == QStringLiteral("etmvariableload") || lc.adapter == QStringLiteral("relay-router")
|| lc.adapter == QStringLiteral("sg-ready"))
nonEvLoads.append(lc);
}
std::sort(nonEvLoads.begin(), nonEvLoads.end(),
[](const LoadContext &a, const LoadContext &b) { return a.priority < b.priority; });
for (const LoadContext &lc : nonEvLoads) {
if (lc.adapter == QStringLiteral("sg-ready"))
slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW));
else
slot.actions.append(buildSetpointAction(lc, remainingSurplusW)); // etmvariableload + relay-router
}
// Grid funding (ECS/PAC) : dormant jusqu'à 3f (waterfall réseau) — non implémenté ici.
Plan plan;
plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces);
plan.strategy = QStringLiteral("rule-based");
plan.timeSlots.append(slot);
return plan;
}
LoadAction RuleBasedScheduler::buildTimeRequirementAction(EvCharger *ev,
const ChargingAction &ca) const
{
// Le courant final est affiné par adjustEvChargers() (allowance root-meter).
// En 3b on log la valeur brute de la planification — iso-fonctionnel.
LoadAction la;
la.loadId = ev->thing()->id().toString();
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Grid;
la.chargingEnabled = true;
la.currentA = ca.maxChargingCurrent();
la.phaseCount = ca.desiredPhaseCount();
la.reason = QStringLiteral("Deadline VE approchante — recharge prioritaire");
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
return la;
}
LoadAction RuleBasedScheduler::buildMinCurrentAction(EvCharger *ev) const
{
const uint minA = qMax(EcoMinChargingCurrent, ev->maxChargingCurrentMinValue());
const uint phases = ev->phaseCount();
LoadAction la;
la.loadId = ev->thing()->id().toString();
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Surplus;
la.chargingEnabled = true;
la.currentA = minA;
la.phaseCount = phases;
la.reason = QStringLiteral("Aucun surplus — courant minimum maintenu (mode EcoMin)");
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
return la;
}
LoadAction RuleBasedScheduler::buildIdleAction(EvCharger *ev) const
{
LoadAction la;
la.loadId = ev->thing()->id().toString();
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Surplus;
la.chargingEnabled = false;
la.currentA = 0;
la.phaseCount = 0;
la.reason = QStringLiteral("Aucun surplus disponible — recharge suspendue");
la.estimatedPowerW = 0;
return la;
}
LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
double &remainingSurplusW) const
{
// Recrédit anti-clignotement (Correction B / contrat rév. 2 §5) : la conso de DÉBUT de cycle
// est déjà soustraite de l'export mesuré → on la rend au budget local avant d'arrondir.
// PAS de relecture post-setpoint (invariant 8 : aucune boucle de feedback).
const double budgetW = remainingSurplusW + lc.telemetry.currentPowerW;
const QList<int> &levels = lc.declared.powerLevels;
const bool dynamic = levels.isEmpty();
double setpointW;
if (dynamic) {
// Dynamic (routeur PV / triac) : modulation continue bornée au plafond déclaré.
const double maxW = lc.declared.maxPowerW > 0 ? lc.declared.maxPowerW : budgetW;
setpointW = qBound(0.0, budgetW, maxW);
} else {
// Fixed : plus haut palier déclaré ≤ budget (levels triés croissants, 0 inclus →
// garantit une consigne valide même budget ≤ 0). Granularité connue → zéro cycle de retard.
int chosen = 0;
for (int l : levels) {
if (l <= budgetW) chosen = l;
else break;
}
setpointW = chosen;
}
// ECS-410 — charge INDISPONIBLE : elle sort de l'ARBITRAGE, pas de la COMPTABILITÉ.
// Aucune allocation neuve, mais sa consommation reste retranchée du budget : une
// puissance qu'on ne sait plus couper est de la conso fixe, au même titre que la base de
// la maison. La retirer du bilan offrirait aux charges suivantes un budget qui n'existe
// pas — c'est le trou d'ECS-306 rouvert par une autre porte.
//
// Le figeage est porté par l'adaptateur : lockMin == lockMax == puissance crue engagée.
// Le clamp ci-dessous suffit donc à produire la bonne comptabilité, sans branche spéciale.
if (!lc.telemetry.available) {
const double gele = lc.telemetry.lockMinPowerW;
LoadAction la;
la.loadId = lc.id;
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Surplus;
la.powerW = gele;
la.estimatedPowerW = gele;
la.reason = QStringLiteral("%1 INDISPONIBLE — aucune commande émise ; %2 W toujours "
"comptés au budget (levée par ClearLoadFault)")
.arg(lc.label).arg(qRound(gele));
remainingSurplusW = budgetW - gele;
return la;
}
// 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.
// 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)
setpointW = lc.telemetry.lockMinPowerW; // puissance engagée, non-coupable
LoadAction la;
la.loadId = lc.id;
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Surplus;
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))
.arg(dynamic ? QString() : QStringLiteral(" (palier)"));
else
la.reason = QStringLiteral("Surplus insuffisant (%1 W) — %2 à 0 W")
.arg(qRound(budgetW)).arg(lc.label);
// Résidu : budget consigne engagée → charge suivante de la priorité (même cycle).
remainingSurplusW = budgetW - setpointW;
return la;
}
LoadAction RuleBasedScheduler::buildSgReadyStateAction(const LoadContext &lc,
double &remainingSurplusW) const
{
const int currentState = lc.telemetry.state;
const double p3 = lc.declared.estimatedPowerW.value(3, 0.0);
const double p4 = lc.declared.estimatedPowerW.value(4, 0.0);
// Recrédit (correction B) : la puissance ALLOUÉE de l'état courant (déclaré, 0 pour 1/2)
// revient au budget — comme l'ECS. Base : "quel surplus si la PAC n'était pas pilotée ?"
const double allocatedNowW = lc.declared.estimatedPowerW.value(currentState, 0.0);
const double budgetW = remainingSurplusW + allocatedNowW;
// Mapping SÉMANTIQUE (pas "le plus haut qui rentre") avec hystérésis d'état anti-oscillation :
// monter en 4 si budget ≥ P4×1,2 ; rester en 4 tant que budget ≥ P4×1,0 (zone morte) ;
// sinon recommandation (≥ P3) ; sinon normal (état 2, mains off).
// État 1 (effacement) jamais déclenché par le surplus seul — déféré (signal tarif/réseau).
constexpr double kForceMargin = 1.2; // hystérésis d'entrée en état 4
int targetState;
if (p4 > 0.0 && budgetW >= p4 * kForceMargin)
targetState = 4;
else if (currentState == 4 && p4 > 0.0 && budgetW >= p4) // zone morte : reste forcé
targetState = 4;
else if (p3 > 0.0 && budgetW >= p3)
targetState = 3;
else
targetState = 2;
// Clamp lock-aware (fenêtre minStateHold, calculée au MÊME ctx.timestamp).
const int loW = lc.telemetry.minState > 0 ? lc.telemetry.minState : 2;
const int hiW = lc.telemetry.maxState > 0 ? lc.telemetry.maxState : 2;
int bestState = qBound(qMin(loW, hiW), targetState, qMax(loW, hiW));
// Sécurité : ne jamais commander un état non déclaré (snap au plus haut déclaré ≤ cible).
if (!lc.declared.states.contains(bestState)) {
int snapped = lc.declared.states.isEmpty() ? 2 : lc.declared.states.first();
for (int s : lc.declared.states)
if (s <= bestState && s > snapped) snapped = s;
bestState = snapped;
}
LoadAction la;
la.loadId = lc.id;
la.kind = LoadAction::State;
la.funding = LoadAction::Surplus;
la.state = bestState;
la.estimatedPowerW = lc.declared.estimatedPowerW.value(bestState, 0.0);
if (bestState != targetState)
la.reason = QStringLiteral("Verrou minStateHold — %1 maintenue état %2 (court-cycling PAC)")
.arg(lc.label).arg(bestState);
else if (bestState == 4)
la.reason = QStringLiteral("Surplus abondant %1 W — %2 forcée (état 4, ~%3 W)")
.arg(qRound(budgetW)).arg(lc.label).arg(qRound(p4));
else if (bestState == 3)
la.reason = QStringLiteral("Surplus PV %1 W — %2 recommandée (état 3, ~%3 W)")
.arg(qRound(budgetW)).arg(lc.label).arg(qRound(p3));
else
la.reason = QStringLiteral("Surplus insuffisant (%1 W) — %2 en normal (état 2, non pilotée)")
.arg(qRound(budgetW)).arg(lc.label);
// Budget restant : on ne soustrait que la puissance ALLOUÉE (états 1/2 = 0).
remainingSurplusW = budgetW - la.estimatedPowerW;
return la;
}