Bug : exportW clampé à max(0,-p) AVANT recrédit → sur-crédit en import (ECS restait allumé sur le réseau, ne délestait jamais). Fix : surplus net SIGNÉ (exportW - importW). Régime export inchangé. Le délestage strict est borné par minOn/minOff (protection compresseur, pas confort) : l'adaptateur expose minStage/maxStage (fenêtre de verrou évaluée au temps de cycle), le scheduler clampe bestStage et décrémente au palier réel → budget correct pour les charges suivantes (puissance verrouillée = engagée non-coupable). Seam de temps unifié : now=ctx.timestamp partagé par toLoadContext()/applyAction() ; lockWindow() est l'unique calcul, lockActive() en dérive (décision==exécution). Interface ILoadAdapter étendue (now) + contrat "temps=paramètre, jamais l'horloge" documenté pour les futurs adaptateurs. EvAdapter aligné. Build 0 erreur / 0 warning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
220 lines
9.7 KiB
C++
220 lines
9.7 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 ECS triées par priorité ASCENDANTE : rang 1 = premier servi
|
||
// (OPTIMIZER_PROTOCOL §5 + annexe C — la priorité est un rang, pas un poids).
|
||
QList<LoadContext> ecsLoads;
|
||
for (const LoadContext &lc : ctx.loads) {
|
||
if (lc.adapter == QStringLiteral("relay-stages"))
|
||
ecsLoads.append(lc);
|
||
}
|
||
std::sort(ecsLoads.begin(), ecsLoads.end(),
|
||
[](const LoadContext &a, const LoadContext &b) { return a.priority < b.priority; });
|
||
|
||
for (const LoadContext &lc : ecsLoads)
|
||
slot.actions.append(buildEcsStageAction(lc, remainingSurplusW));
|
||
|
||
// Grid funding ECS : 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::buildEcsStageAction(const LoadContext &lc,
|
||
double &remainingSurplusW) const
|
||
{
|
||
// Correction B (anti-clignotement) : on recrédite la conso actuelle de l'ECS au budget.
|
||
// Sans ce recrédit, allumer un palier ferait chuter l'export mesuré → palier 0 → oscillation.
|
||
const double budgetChargeW = remainingSurplusW + lc.telemetry.currentPowerW;
|
||
|
||
// Palier déclaré le plus haut qui tient dans le budget. stages[0] == 0 par construction
|
||
// (Q_ASSERT EcsRelayAdapter).
|
||
const QList<int> &stages = lc.declared.stages;
|
||
const int topStage = stages.size() - 1;
|
||
int budgetStage = 0;
|
||
for (int i = 0; i < stages.size(); ++i) {
|
||
if (stages.at(i) <= budgetChargeW)
|
||
budgetStage = i;
|
||
}
|
||
|
||
// Verrou (minOn/minOff) : le palier choisi est borné par la fenêtre déclarée par
|
||
// l'adaptateur (calculée au MÊME ctx.timestamp). Une charge verrouillée ON garde son
|
||
// palier (puissance engagée non-coupable) ; à l'arrêt sous minOff elle ne redémarre pas.
|
||
const int lo = qBound(0, lc.telemetry.minStage, topStage);
|
||
const int hi = qBound(lo, lc.telemetry.maxStage, topStage);
|
||
const int bestStage = qBound(lo, budgetStage, hi);
|
||
|
||
LoadAction la;
|
||
la.loadId = lc.id;
|
||
la.kind = LoadAction::Stage;
|
||
la.funding = LoadAction::Surplus;
|
||
la.stage = bestStage;
|
||
la.estimatedPowerW = stages.at(bestStage);
|
||
|
||
if (bestStage > budgetStage)
|
||
// Maintenu au-dessus du budget : protection compresseur (minOn non écoulé).
|
||
la.reason = QStringLiteral("Verrou minOn — %1 maintenu palier %2 (%3 W, surplus %4 W)")
|
||
.arg(lc.label).arg(bestStage).arg(stages.at(bestStage)).arg(qRound(budgetChargeW));
|
||
else if (bestStage < budgetStage)
|
||
// Empêché de monter/redémarrer par minOff malgré le surplus.
|
||
la.reason = QStringLiteral("Verrou minOff — %1 maintenu palier %2 (redémarrage trop tôt)")
|
||
.arg(lc.label).arg(bestStage);
|
||
else if (bestStage > 0)
|
||
la.reason = QStringLiteral("Surplus PV %1 W — %2 palier %3 (%4 W)")
|
||
.arg(qRound(budgetChargeW)).arg(lc.label)
|
||
.arg(bestStage).arg(stages.at(bestStage));
|
||
else
|
||
la.reason = QStringLiteral("Surplus insuffisant (%1 W) — %2 éteint")
|
||
.arg(qRound(budgetChargeW)).arg(lc.label);
|
||
|
||
// Budget restant pour les charges ECS suivantes (rang supérieur / priorité plus basse).
|
||
remainingSurplusW = budgetChargeW - la.estimatedPowerW;
|
||
return la;
|
||
}
|