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>
304 lines
14 KiB
C++
304 lines
14 KiB
C++
// 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-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;
|
||
la.loadId = lc.id;
|
||
la.kind = LoadAction::Setpoint;
|
||
la.funding = LoadAction::Surplus;
|
||
la.powerW = setpointW;
|
||
la.estimatedPowerW = setpointW;
|
||
|
||
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")
|
||
.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;
|
||
}
|