pakutz79 253c5f487a feat: SchedulerManager v1 — build clean
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 22:02:01 +01:00

366 lines
12 KiB
C++

// 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 <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "schedulermanager.h"
#include "schedulingstrategies/rulebasedstrategy.h"
#include "schedulingstrategies/aistrategy.h"
#include "spotmarket/spotmarketmanager.h"
#include <QLoggingCategory>
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)
{
// 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<ISchedulingStrategy *> 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();
}
// --- Timeline access ---
QList<EnergyTimeSlot> 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<FlexibleLoad> 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;
}
// --- Force recompute ---
void SchedulerManager::forceRecompute()
{
if (!m_activeStrategy) {
qCWarning(dcNymeaEnergy()) << "SchedulerManager: no active strategy, skipping recompute";
return;
}
QList<EnergyTimeSlot> forecast = buildForecast();
QList<FlexibleLoad> loads = collectLoads();
// Preserve existing manual overrides
QHash<QDateTime, EnergyTimeSlot> 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<EnergyTimeSlot> SchedulerManager::buildForecast() const
{
// Stub PredictionProvider: build slots for the planning horizon (every 60 min).
// All predictions are 0. TariffManager (SpotMarketManager) provides electricityPrice.
QList<EnergyTimeSlot> 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<FlexibleLoad> 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";
}
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<int>(qMin(msUntilNext, static_cast<qint64>(INT_MAX))));
qCDebug(dcNymeaEnergy()) << "SchedulerManager: next slot execution in"
<< msUntilNext / 60000 << "min at" << next;
}
}
}