// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #include "energyarbitrator.h" #include "adapters/evadapter.h" #include "adapters/ecsrelayadapter.h" #include "scheduler/rulebasedscheduler.h" #include "types/surpluscontext.h" #include "types/plan.h" #include "../rootmeter.h" #include "../evcharger.h" #include "plugininfo.h" #include #include namespace { //! Période du watchdog L2 (SAFETY.md §L2) : tick indépendant des signaux compteur. constexpr int MeterWatchdogPeriodMs = 30 * 1000; // 30 s //! Seuil de silence compteur au-delà duquel le mode dégradé L2 est déclenché. constexpr int MeterSilenceThresholdS = 90; // 90 s } EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm, SpotMarketManager *sm, EnergyManagerConfiguration *conf, QObject *parent) : SmartChargingManager(em, tm, sm, conf, parent) , m_scheduler(new RuleBasedScheduler(this, this)) { // --- L2 : watchdog fraîcheur compteur (SAFETY.md §L2) --- // Fraîcheur picotée sur powerBalanceChanged (en plus de la connexion amont L4) ; // grâce au démarrage : on initialise le timestamp pour éviter un dégradé immédiat. m_lastMeterUpdate = QDateTime::currentDateTime(); connect(em, &EnergyManager::powerBalanceChanged, this, [this]() { m_lastMeterUpdate = QDateTime::currentDateTime(); if (m_degradedMode) { qCInfo(dcNymeaEnergy()) << "[Arbitre] Compteur de nouveau actif — sortie du mode dégradé L2."; m_degradedMode = false; emit chargingSchedulesChanged(); // pousse degradedMode=false (planif reprend au cycle suivant) } }); // QTimer (et non signal) : doit rester actif quand le compteur est muet. m_meterWatchdog = new QTimer(this); m_meterWatchdog->setInterval(MeterWatchdogPeriodMs); connect(m_meterWatchdog, &QTimer::timeout, this, &EnergyArbitrator::onMeterWatchdogTick); m_meterWatchdog->start(); qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] Arbitre ETM initialisé."; } void EnergyArbitrator::runSurplusPlanning(const QDateTime &now) { planSurplusCharging(now); } void EnergyArbitrator::runSpotMarketPlanning(const QDateTime &now) { planSpotMarketCharging(now); } const QHash &EnergyArbitrator::scheduledActions() const { return internalChargingActions(); } void EnergyArbitrator::doExecuteChargingAction(EvCharger *charger, const ChargingAction &action, const QDateTime &now) { executeChargingAction(charger, action, now); } const QHash &EnergyArbitrator::registeredEvChargers() const { return internalEvChargers(); } RootMeter *EnergyArbitrator::registeredRootMeter() const { return internalRootMeter(); } void EnergyArbitrator::registerEcsAdapter(EcsRelayAdapter *adapter) { const QString id = adapter->descriptor().id; if (m_ecsAdapters.contains(id)) { qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] EcsRelayAdapter déjà enregistré:" << id; return; } adapter->setParent(this); m_ecsAdapters[id] = adapter; qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] EcsRelayAdapter enregistré:" << adapter->descriptor().label; } void EnergyArbitrator::update(const QDateTime ¤tDateTime) { qCDebug(dcNymeaEnergy()) << "Updating smart charging"; // Ordre IDENTIQUE à SmartChargingManager::update() — INTERDIT de réordonner. // SCM : 1.updateManual 2.prepareInfo 3.verifyOverload 4.verifyRecovery // 5.planSpot 6.planSurplus 7.adjustEv // ETM : idem 1-4 ; insertions ETM entre 4 et 7 ; // planSpot + planSurplus appelés via m_scheduler->getPlan() (position 5-6). // 1-4 : préparation + sécurité (même ordre que l'amont) updateManualSoCsWithoutMeter(currentDateTime); prepareInformation(currentDateTime); verifyOverloadProtection(currentDateTime); verifyOverloadProtectionRecovery(currentDateTime); // Mode dégradé L2 : la sécurité (L4 ci-dessus) reste active, mais on SUSPEND la // planification et le dispatch. Replanifier sur le cache d'un compteur mort // rallumerait les charges que le watchdog vient de couper → oscillation. Les // consignes de repli (posées à la transition) tiennent jusqu'au retour du compteur. if (m_degradedMode) { qCDebug(dcNymeaEnergy()) << "[Arbitre] Mode dégradé L2 actif — planification suspendue."; return; } // ETM-only : sync adapters + proxy planification → log [Arbitre] // getPlan() appelle planSpotMarketCharging() + planSurplusCharging() (position 5-6 amont). syncAdapters(); SurplusContext ctx = buildContext(currentDateTime); Plan plan = m_scheduler->getPlan(ctx); Slot slot = plan.slotCovering(currentDateTime); for (const LoadAction &action : slot.actions) { qCInfo(dcNymeaEnergy()) << "[Arbitre]" << action.loadId << "→" << action.reason << "| activé:" << action.chargingEnabled << "| courant:" << action.currentA << "A" << "| phases:" << action.phaseCount << "| stratégie:" << plan.strategy; } // 7 : dispatch matériel (même position que l'amont — m_chargingActions rempli par getPlan()) applyActionsToAdapters(slot); // ECS (kind==Stage) → m_ecsAdapters adjustEvChargers(currentDateTime); // EV (kind==Setpoint) → proxy amont jusqu'à 3g } SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const { SurplusContext ctx; ctx.timestamp = now; // --- Compteur principal (AGENTS invariant 8 : mesure brute, aucune déduction) --- RootMeter *meter = internalRootMeter(); if (meter) { // currentPower() < 0 → export ; > 0 → import (convention amont SCM l.1141) const double p = meter->currentPower(); ctx.meter.importW = qMax(0.0, p); ctx.meter.exportW = qMax(0.0, -p); ctx.meter.perPhaseA = { meter->currentPhaseA(), meter->currentPhaseB(), meter->currentPhaseC() }; } // SurplusPv : interface inverter — déféré (remplissage prévu en 3d) // SurplusBattery : déféré 3f // --- loads[] : EV adapters --- for (auto it = m_adapters.constBegin(); it != m_adapters.constEnd(); ++it) ctx.loads.append(it.value()->toLoadContext()); // --- loads[] : ECS relay adapters --- for (auto it = m_ecsAdapters.constBegin(); it != m_ecsAdapters.constEnd(); ++it) ctx.loads.append(it.value()->toLoadContext()); return ctx; } void EnergyArbitrator::syncAdapters() { // Crée les adapters manquants for (auto it = internalEvChargers().constBegin(); it != internalEvChargers().constEnd(); ++it) { const QString id = it.key().toString(); if (!m_adapters.contains(id)) m_adapters[id] = new EvAdapter(it.value(), this); } // Supprime les adapters obsolètes for (const QString &id : m_adapters.keys()) { if (!internalEvChargers().contains(ThingId(id))) m_adapters.take(id)->deleteLater(); } } void EnergyArbitrator::applyActionsToAdapters(const Slot &slot) { for (const LoadAction &action : slot.actions) { // EV (Setpoint) : dispatché par adjustEvChargers() amont jusqu'à 3g. if (action.kind != LoadAction::Stage) continue; EcsRelayAdapter *adapter = m_ecsAdapters.value(action.loadId); if (!adapter) { qCWarning(dcNymeaEnergy()) << "[Arbitre] action Stage sans adaptateur ECS:" << action.loadId; continue; } // L'adaptateur applique, écrête et verrouille (anti-rebond) — il ne décide pas. adapter->applyAction(action); } } void EnergyArbitrator::onMeterWatchdogTick() { if (!m_lastMeterUpdate.isValid()) return; // Aucune mesure reçue (démarrage) — pas de dégradé (invariant root meter absent). const QDateTime now = QDateTime::currentDateTime(); const qint64 silentS = m_lastMeterUpdate.secsTo(now); if (silentS <= MeterSilenceThresholdS) return; if (m_degradedMode) return; // Déjà en repli — les consignes tiennent, pas de ré-émission (anti-oscillation). qCWarning(dcNymeaEnergy()) << "[Arbitre] Compteur muet depuis" << silentS << "s (>" << MeterSilenceThresholdS << "s) — mode dégradé L2."; applyDegradedMode(now); } void EnergyArbitrator::applyDegradedMode(const QDateTime &now) { m_degradedMode = true; emit chargingSchedulesChanged(); // pousse degradedMode=true (notification client L2) const QString reason = QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)"); // ECS : tous paliers coupés (stage 0), force=true → bypass des verrous anti-rebond. for (EcsRelayAdapter *adapter : m_ecsAdapters) { LoadAction la; la.loadId = adapter->descriptor().id; la.kind = LoadAction::Stage; la.stage = 0; la.force = true; la.reason = reason; adapter->applyAction(la); } // EV : repli CONSERVATEUR — n'initie aucune charge. On clampe seulement une charge // DÉJÀ en cours au courant minimum (force=true, bypass lock). Une borne branchée mais // non chargeante reste off (off volontaire possible : HC/spot à venir) ; débranchée → // aucune action. La garantie "jamais 0 A si branché" relève du failsafe L1 de la borne. for (auto it = internalEvChargers().constBegin(); it != internalEvChargers().constEnd(); ++it) { EvCharger *ev = it.value(); if (ev->available() && ev->charging()) ev->setMaxChargingCurrent(ev->maxChargingCurrentMinValue(), now, true); } // SG-Ready (état 1) / Batterie (aucune charge réseau) : repli ajouté avec leurs // adaptateurs (3e/3f). Le flag degradedMode + notification client arrivent en 3c-6. }