// SPDX-License-Identifier: GPL-3.0-or-later /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * Copyright (C) 2013 - 2024, nymea GmbH * Copyright (C) 2024 - 2025, chargebyte austria GmbH * * This file is part of nymea-energy-plugin-nymea. * * nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * nymea-energy-plugin-nymea.s distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with nymea-energy-plugin-nymea. If not, see . * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ #include "schedulermanager.h" #include "schedulingstrategies/rulebasedstrategy.h" #include "schedulingstrategies/aistrategy.h" #include "schedulingstrategies/manualstrategy.h" #include "schedulersettings.h" #include "spotmarket/spotmarketmanager.h" #include Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy) SchedulerManager::SchedulerManager( SpotMarketManager *spotMarketManager, EnergyManager *energyManager, ThingManager *thingManager, QObject *parent) : QObject(parent), m_spotMarketManager(spotMarketManager), m_energyManager(energyManager), m_thingManager(thingManager) { m_settings = new SchedulerSettings(this); // Register built-in strategies RuleBasedStrategy *ruleStrategy = new RuleBasedStrategy(this); AIStrategy *aiStrategy = new AIStrategy(this); registerStrategy(ruleStrategy); registerStrategy(aiStrategy); // Default to rule-based m_activeStrategy = ruleStrategy; // Recompute timer — fires every recomputeIntervalMin connect(&m_recomputeTimer, &QTimer::timeout, this, &SchedulerManager::onRecomputeTimer); m_recomputeTimer.start(m_config.recomputeIntervalMin * 60 * 1000); // Slot execution timer connect(&m_slotTimer, &QTimer::timeout, this, &SchedulerManager::onSlotExecutionTimer); // Initial compute QTimer::singleShot(0, this, &SchedulerManager::forceRecompute); } // --- Strategy management --- void SchedulerManager::setStrategy(ISchedulingStrategy *strategy) { if (!strategy || !m_strategies.contains(strategy)) { qCWarning(dcNymeaEnergy()) << "SchedulerManager: unknown strategy, ignoring setStrategy()"; return; } if (m_activeStrategy == strategy) return; m_activeStrategy = strategy; qCDebug(dcNymeaEnergy()) << "SchedulerManager: strategy changed to" << strategy->strategyId(); emit strategyChanged(strategy->strategyId()); forceRecompute(); } ISchedulingStrategy *SchedulerManager::currentStrategy() const { return m_activeStrategy; } QList SchedulerManager::availableStrategies() const { return m_strategies; } void SchedulerManager::registerStrategy(ISchedulingStrategy *strategy) { if (!strategy) return; // Ensure no duplicate ids foreach (ISchedulingStrategy *s, m_strategies) { if (s->strategyId() == strategy->strategyId()) return; } strategy->setParent(this); m_strategies.append(strategy); qCDebug(dcNymeaEnergy()) << "SchedulerManager: registered strategy" << strategy->strategyId(); // Hydrate ManualStrategy with persisted slots as soon as it is registered if (strategy->strategyId() == QLatin1String("manual") && m_settings) { ManualStrategy *ms = qobject_cast(strategy); if (ms) { foreach (const ManualSlotConfig &cfg, m_settings->manualSlots()) ms->setManualSlot(cfg); qCDebug(dcNymeaEnergy()) << "SchedulerManager: loaded" << m_settings->manualSlots().size() << "manual slot(s) from settings"; } } } // --- Timeline access --- QList SchedulerManager::currentTimeline() const { return m_timeline; } QDateTime SchedulerManager::lastComputedAt() const { return m_lastComputedAt; } QDateTime SchedulerManager::nextRecomputeAt() const { return m_nextRecomputeAt; } QString SchedulerManager::planHealth() const { if (!m_activeStrategy || !m_activeStrategy->isAvailable()) return QStringLiteral("degraded"); if (m_timeline.isEmpty()) return QStringLiteral("no_forecast"); // Check if any slot has a missing decisionReason (should not happen) foreach (const EnergyTimeSlot &slot, m_timeline) { if (slot.decisionReason.isEmpty()) return QStringLiteral("degraded"); } return QStringLiteral("ok"); } // --- Configuration --- SchedulerConfig SchedulerManager::config() const { return m_config; } void SchedulerManager::setConfig(const SchedulerConfig &config) { m_config = config; // Restart recompute timer with new interval m_recomputeTimer.start(m_config.recomputeIntervalMin * 60 * 1000); emit configChanged(m_config); forceRecompute(); } // --- Flexible loads --- QList SchedulerManager::flexibleLoads() const { return m_loads; } void SchedulerManager::updateLoad(const FlexibleLoad &load) { for (int i = 0; i < m_loads.size(); ++i) { if (m_loads.at(i).thingId == load.thingId) { m_loads[i] = load; emit loadUpdated(load); return; } } m_loads.append(load); emit loadRegistered(load); } // --- Manual override --- void SchedulerManager::overrideSlot(const QDateTime &slotStart, LoadSource source, double powerW, const QString &reason) { for (int i = 0; i < m_timeline.size(); ++i) { EnergyTimeSlot &slot = m_timeline[i]; if (slot.start == slotStart) { slot.manualOverride = true; slot.overrideReason = reason; // Apply override to the relevant allocation switch (source) { case LoadSource::SmartCharging: slot.allocatedToEV = powerW; break; case LoadSource::HeatPump: slot.allocatedToHP = powerW; break; case LoadSource::DHW: slot.allocatedToDHW = powerW; break; case LoadSource::Battery: slot.allocatedToBattery = powerW; break; case LoadSource::FeedIn: slot.allocatedToFeedIn = powerW; break; case LoadSource::External: break; } slot.decisionReason = QStringLiteral("Décision manuelle : ") + reason; slot.decisionRules = QStringList() << QStringLiteral("ManualOverride"); qCDebug(dcNymeaEnergy()) << "SchedulerManager: override applied to slot" << slotStart << ":" << reason; emit timelineUpdated(m_timeline); return; } } qCWarning(dcNymeaEnergy()) << "SchedulerManager: no slot found for override at" << slotStart; } int SchedulerManager::activeOverridesCount() const { int count = 0; foreach (const EnergyTimeSlot &slot, m_timeline) { if (slot.manualOverride) count++; } return count; } // --- Manual slot management --- QList SchedulerManager::manualSlots() const { ManualStrategy *ms = findManualStrategy(); if (!ms) return {}; return ms->manualSlots(); } void SchedulerManager::setManualSlot(const ManualSlotConfig &config) { ManualStrategy *ms = findManualStrategy(); if (!ms) { qCWarning(dcNymeaEnergy()) << "SchedulerManager::setManualSlot: no ManualStrategy registered"; return; } ms->setManualSlot(config); if (m_settings) m_settings->setManualSlot(config); emit manualSlotsChanged(); forceRecompute(); } void SchedulerManager::removeManualSlot(const QDateTime &start) { ManualStrategy *ms = findManualStrategy(); if (!ms) return; ms->removeManualSlot(start); if (m_settings) m_settings->removeManualSlot(start); emit manualSlotsChanged(); forceRecompute(); } void SchedulerManager::clearManualSlots() { ManualStrategy *ms = findManualStrategy(); if (!ms) return; ms->clearAllManualSlots(); if (m_settings) m_settings->clearManualSlots(); emit manualSlotsChanged(); forceRecompute(); } // --- Force recompute --- void SchedulerManager::forceRecompute() { if (!m_activeStrategy) { qCWarning(dcNymeaEnergy()) << "SchedulerManager: no active strategy, skipping recompute"; return; } QList forecast = buildForecast(); QList loads = collectLoads(); // Preserve existing manual overrides QHash overrides; foreach (const EnergyTimeSlot &slot, m_timeline) { if (slot.manualOverride) overrides.insert(slot.start, slot); } // Re-inject overrides into the new forecast for (int i = 0; i < forecast.size(); ++i) { if (overrides.contains(forecast.at(i).start)) forecast[i] = overrides.value(forecast.at(i).start); } m_timeline = m_activeStrategy->computeSchedule(forecast, loads, m_config); m_lastComputedAt = QDateTime::currentDateTimeUtc(); m_nextRecomputeAt = m_lastComputedAt.addSecs(m_config.recomputeIntervalMin * 60); qCDebug(dcNymeaEnergy()) << "SchedulerManager: recomputed" << m_timeline.size() << "slots, health=" << planHealth(); // Apply current slot immediately QDateTime now = QDateTime::currentDateTimeUtc(); foreach (const EnergyTimeSlot &slot, m_timeline) { if (slot.isActive(now)) { applyCurrentSlot(slot); break; } } scheduleNextSlotTimer(); emit timelineUpdated(m_timeline); } // --- Private slots --- void SchedulerManager::onRecomputeTimer() { forceRecompute(); } void SchedulerManager::onSlotExecutionTimer() { QDateTime now = QDateTime::currentDateTimeUtc(); foreach (const EnergyTimeSlot &slot, m_timeline) { if (slot.isActive(now)) { qCDebug(dcNymeaEnergy()) << "SchedulerManager: executing slot" << slot.start << slot.decisionReason; applyCurrentSlot(slot); emit slotExecuted(slot, true); break; } } scheduleNextSlotTimer(); } // --- Private helpers --- QList SchedulerManager::buildForecast() const { // Stub PredictionProvider: build slots for the planning horizon (every 60 min). // All predictions are 0. TariffManager (SpotMarketManager) provides electricityPrice. QList forecast; QDateTime start = QDateTime::currentDateTimeUtc(); // Round down to the current hour start.setTime(QTime(start.time().hour(), 0, 0)); int horizon = m_config.planningHorizonHours; // Get spot price entries if available ScoreEntries scoreEntries; if (m_spotMarketManager && m_spotMarketManager->enabled() && m_spotMarketManager->currentProvider()) { scoreEntries = m_spotMarketManager->weightedScoreEntries(); } for (int h = 0; h < horizon; ++h) { EnergyTimeSlot slot; slot.start = start.addSecs(h * 3600); slot.end = start.addSecs((h + 1) * 3600); // Fill electricity price from spot market if available if (!scoreEntries.isEmpty()) { ScoreEntry entry = scoreEntries.getScoreEntry(slot.start); if (!entry.isNull()) slot.electricityPrice = entry.value() / 1000.0; // mEUR/kWh → EUR/kWh } // All other predictions are 0 (stub — replaced by PredictionManager later) forecast.append(slot); } qCDebug(dcNymeaEnergy()) << "SchedulerManager: built forecast of" << forecast.size() << "slots"; return forecast; } QList SchedulerManager::collectLoads() const { // Return the currently registered loads. // In Phase 1, loads are registered externally (by managers calling updateLoad()). return m_loads; } void SchedulerManager::applyCurrentSlot(const EnergyTimeSlot &slot) { // In Phase 1, we log the decision. Actual hardware commands are issued by // each specialist manager (SmartChargingManager, etc.) which listens to // the timelineUpdated() signal and reads the current slot. qCDebug(dcNymeaEnergy()) << "SchedulerManager: applying slot:" << slot.decisionReason << "EV=" << slot.allocatedToEV << "W" << "HP=" << slot.allocatedToHP << "W" << "DHW=" << slot.allocatedToDHW << "W" << "Bat=" << slot.allocatedToBattery << "W"; } ManualStrategy *SchedulerManager::findManualStrategy() const { foreach (ISchedulingStrategy *s, m_strategies) { if (s->strategyId() == QLatin1String("manual")) return qobject_cast(s); } return nullptr; } void SchedulerManager::scheduleNextSlotTimer() { m_slotTimer.stop(); QDateTime now = QDateTime::currentDateTimeUtc(); QDateTime next; foreach (const EnergyTimeSlot &slot, m_timeline) { if (slot.start > now) { next = slot.start; break; } } if (next.isValid()) { qint64 msUntilNext = now.msecsTo(next); if (msUntilNext > 0) { m_slotTimer.setSingleShot(true); m_slotTimer.start(static_cast(qMin(msUntilNext, static_cast(INT_MAX)))); qCDebug(dcNymeaEnergy()) << "SchedulerManager: next slot execution in" << msUntilNext / 60000 << "min at" << next; } } }