// 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 #include 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 (ECS paliers + SG-Ready) 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é. QList nonEvLoads; for (const LoadContext &lc : ctx.loads) { if (lc.adapter == QStringLiteral("relay-stages") || 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(buildEcsStageAction(lc, remainingSurplusW)); } // 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::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 &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; } 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; }