// 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 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 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 &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; } LoadAction la; la.loadId = lc.id; la.kind = LoadAction::Setpoint; la.funding = LoadAction::Surplus; la.powerW = setpointW; la.estimatedPowerW = setpointW; 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; }