- Add ManualSlotConfig type with weekly repeating support and auto-expiry - Add ManualStrategy (strategyId="manual"): applies user-defined allocations exactly; expired slots logged and skipped; inflexible/critical loads always applied as safety fallback; decisionReason never empty - Extend SchedulerSettings with manualSlots persistence section (INI array) - Extend SchedulerManager with setManualSlot/removeManualSlot/clearManualSlots methods; hydrates ManualStrategy from settings on registerStrategy() - Add JSON-RPC v11 methods: GetManualSlots, SetManualSlot, RemoveManualSlot, ClearManualSlots + ManualSlotActivated push notification - Register ManualStrategy in energypluginnymea.cpp::init() (no feature flag) - Add 5 unit tests: basicSlot, noConfig_fallback, expiredSlot, repeatingSlot, persistence (JSON round-trip) - Update doc.md section 11 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
440 lines
14 KiB
C++
440 lines
14 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 "schedulingstrategies/manualstrategy.h"
|
|
#include "schedulersettings.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)
|
|
{
|
|
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<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();
|
|
|
|
// Hydrate ManualStrategy with persisted slots as soon as it is registered
|
|
if (strategy->strategyId() == QLatin1String("manual") && m_settings) {
|
|
ManualStrategy *ms = qobject_cast<ManualStrategy *>(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<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;
|
|
}
|
|
|
|
// --- Manual slot management ---
|
|
|
|
QList<ManualSlotConfig> 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<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";
|
|
}
|
|
|
|
ManualStrategy *SchedulerManager::findManualStrategy() const
|
|
{
|
|
foreach (ISchedulingStrategy *s, m_strategies) {
|
|
if (s->strategyId() == QLatin1String("manual"))
|
|
return qobject_cast<ManualStrategy *>(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<int>(qMin(msUntilNext, static_cast<qint64>(INT_MAX))));
|
|
qCDebug(dcNymeaEnergy()) << "SchedulerManager: next slot execution in"
|
|
<< msUntilNext / 60000 << "min at" << next;
|
|
}
|
|
}
|
|
}
|