powersync-energy-plugin-etm/energyplugin/schedulingstrategies/rulebasedstrategy.cpp

390 lines
16 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 "rulebasedstrategy.h"
#include <QLoggingCategory>
#include <algorithm>
#include <cmath>
Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)
RuleBasedStrategy::RuleBasedStrategy(QObject *parent)
: ISchedulingStrategy(parent)
{
}
QString RuleBasedStrategy::strategyId() const
{
return QStringLiteral("rule-based");
}
QString RuleBasedStrategy::displayName() const
{
return QStringLiteral("Règles déterministes");
}
QString RuleBasedStrategy::description() const
{
return QStringLiteral("Stratégie déterministe en 3 passes : charges critiques, "
"stockages (prix + solaire), charges flexibles (avant deadline).");
}
QList<EnergyTimeSlot> RuleBasedStrategy::computeSchedule(
const QList<EnergyTimeSlot> &forecast,
const QList<FlexibleLoad> &loads,
const SchedulerConfig &config)
{
// Start from a copy of the forecast so predictions are preserved
QList<EnergyTimeSlot> timeline = forecast;
// Clear previous allocations so we start fresh
for (int i = 0; i < timeline.size(); ++i) {
EnergyTimeSlot &slot = timeline[i];
if (slot.manualOverride)
continue; // Respect human decisions
slot.allocatedToEV = 0;
slot.allocatedToHP = 0;
slot.allocatedToDHW = 0;
slot.allocatedToBattery = 0;
slot.allocatedToFeedIn = 0;
slot.decisionReason.clear();
slot.decisionRules.clear();
}
passInflexibleLoads(timeline, loads);
passStorageLoads(timeline, loads, config);
passShiftableLoads(timeline, loads, config);
// Compute net grid power, cost, and self-sufficiency for every slot
for (int i = 0; i < timeline.size(); ++i)
computeSlotResults(timeline[i]);
fillMissingReasons(timeline);
return timeline;
}
QString RuleBasedStrategy::explainDecision(
const EnergyTimeSlot &slot,
const FlexibleLoad &load) const
{
if (slot.manualOverride)
return QStringLiteral("Décision manuelle : ") + slot.overrideReason;
switch (load.source) {
case LoadSource::SmartCharging:
if (slot.allocatedToEV > 0) {
if (slot.solarForecastW - slot.baseConsumptionW > 0)
return QString("Surplus solaire prévu +%1 kW — recharge VE gratuite")
.arg((slot.solarForecastW - slot.baseConsumptionW) / 1000.0, 0, 'f', 1);
return QString("Prix spot %1€/kWh (optimal avant deadline) recharge VE planifiée")
.arg(slot.electricityPrice, 0, 'f', 3);
}
return QStringLiteral("VE non chargé dans ce créneau");
case LoadSource::Battery:
if (slot.allocatedToBattery > 0)
return QString("Prix spot %1€/kWh (seuil %2€) — charge batterie")
.arg(slot.electricityPrice, 0, 'f', 3)
.arg(0.08, 0, 'f', 2);
if (slot.allocatedToBattery < 0)
return QStringLiteral("Pic de prix — décharge batterie pour réduire l'import réseau");
return QStringLiteral("Batterie en veille");
case LoadSource::HeatPump:
if (slot.allocatedToHP > 0)
return QString("Besoin chauffage %1 kW — PAC activée")
.arg(load.currentPowerW / 1000.0, 0, 'f', 1);
return QStringLiteral("PAC non requise dans ce créneau");
case LoadSource::DHW:
if (slot.allocatedToDHW > 0)
return QStringLiteral("Recharge ECS planifiée");
return QStringLiteral("ECS en veille");
default:
break;
}
return slot.decisionReason;
}
// ---------------------------------------------------------------------------
// Pass 1 — Inflexible / critical loads
// ---------------------------------------------------------------------------
void RuleBasedStrategy::passInflexibleLoads(
QList<EnergyTimeSlot> &timeline,
const QList<FlexibleLoad> &loads)
{
// Inflexible loads (e.g. base consumption) are already accounted for in
// baseConsumptionW. We iterate them here only to flag slots where critical
// loads are active, and potentially to add heating/DHW to HP/DHW allocations.
foreach (const FlexibleLoad &load, loads) {
if (load.type != LoadType::Inflexible)
continue;
for (int i = 0; i < timeline.size(); ++i) {
EnergyTimeSlot &slot = timeline[i];
if (slot.manualOverride)
continue;
// Critical HP loads go into heating allocation
if (load.source == LoadSource::HeatPump && load.priority >= 0.9) {
slot.allocatedToHP = qMax(slot.allocatedToHP, load.currentPowerW);
if (slot.decisionReason.isEmpty())
slot.decisionReason = QStringLiteral("Chauffage critique — PAC toujours active");
if (!slot.decisionRules.contains("CriticalHeating"))
slot.decisionRules.append("CriticalHeating");
}
// Critical DHW (légionellose)
if (load.source == LoadSource::DHW && load.priority >= 0.9) {
slot.allocatedToDHW = qMax(slot.allocatedToDHW, load.currentPowerW);
if (slot.decisionReason.isEmpty())
slot.decisionReason = QStringLiteral("ECS critique (sécurité légionellose)");
if (!slot.decisionRules.contains("CriticalDHW"))
slot.decisionRules.append("CriticalDHW");
}
}
}
}
// ---------------------------------------------------------------------------
// Pass 2 — Storage loads (battery, DHW tank with modulation)
// ---------------------------------------------------------------------------
void RuleBasedStrategy::passStorageLoads(
QList<EnergyTimeSlot> &timeline,
const QList<FlexibleLoad> &loads,
const SchedulerConfig &config)
{
foreach (const FlexibleLoad &load, loads) {
if (load.type != LoadType::Storage)
continue;
// Skip if already at target (SOC/temperature)
if (load.currentValue >= load.targetValue && load.targetValue > 0)
continue;
for (int i = 0; i < timeline.size(); ++i) {
EnergyTimeSlot &slot = timeline[i];
if (slot.manualOverride)
continue;
double surplus = slot.solarForecastW - slot.baseConsumptionW
- slot.allocatedToHP - slot.allocatedToDHW;
bool cheapPrice = slot.electricityPrice > 0
&& slot.electricityPrice < config.chargePriceThreshold;
bool solarSurplus = surplus > config.solarSurplusThresholdW;
if (!cheapPrice && !solarSurplus)
continue;
double powerToAllocate = qMin(load.maxPowerW, qMax(load.minPowerW, surplus > 0 ? surplus : load.maxPowerW));
if (load.source == LoadSource::Battery) {
slot.allocatedToBattery += powerToAllocate;
if (cheapPrice && !solarSurplus) {
slot.decisionReason = QString("Prix spot %1€/kWh (seuil %2€) — charge batterie")
.arg(slot.electricityPrice, 0, 'f', 3)
.arg(config.chargePriceThreshold, 0, 'f', 2);
slot.decisionRules.append("PriceBelow" + QString::number(config.chargePriceThreshold));
} else {
slot.decisionReason = QString("Surplus solaire +%1 kW — recharge batterie")
.arg(surplus / 1000.0, 0, 'f', 1);
slot.decisionRules.append("SolarSurplus");
}
} else if (load.source == LoadSource::DHW) {
slot.allocatedToDHW += powerToAllocate;
slot.decisionReason = QString("Surplus solaire +%1 kW — chauffe-eau solaire")
.arg(surplus / 1000.0, 0, 'f', 1);
slot.decisionRules.append("SolarSurplus");
}
}
}
}
// ---------------------------------------------------------------------------
// Pass 3 — Shiftable loads (EV, washing machine)
// ---------------------------------------------------------------------------
void RuleBasedStrategy::passShiftableLoads(
QList<EnergyTimeSlot> &timeline,
const QList<FlexibleLoad> &loads,
const SchedulerConfig &config)
{
foreach (const FlexibleLoad &load, loads) {
if (load.type != LoadType::Shiftable)
continue;
// Determine how many slots we need to reach targetValue from currentValue.
// For EV: assume kWh capacity is encoded in maxPowerW * (targetValue - currentValue)/100
// For a simple model: count how many 1h-equivalent slots at maxPowerW we need.
// Use a conservative estimate: plan for ~half the remaining need.
double remainingPct = qMax(0.0, load.targetValue - load.currentValue);
if (remainingPct <= 0)
continue;
// Slot duration in hours (assume slots are uniform)
double slotHours = 1.0;
if (!timeline.isEmpty()) {
qint64 dur = timeline.first().start.secsTo(timeline.first().end);
if (dur > 0)
slotHours = dur / 3600.0;
}
// Estimate slots needed: remainingPct% of capacity / (maxPowerW * slotHours)
// Capacity (Wh) = maxPowerW * (full charge duration in hours) — use targetValue as capacity hint
// Simplified: energyNeeded = remainingPct/100 * (load.maxPowerW * 4h) -- rough 4h estimate
double energyNeededWh = (remainingPct / 100.0) * load.maxPowerW * 4.0;
int slotsNeeded = qMax(1, static_cast<int>(std::ceil(energyNeededWh / (load.maxPowerW * slotHours))));
// Collect eligible slots before deadline
QList<int> eligibleIndices;
QDateTime deadline = load.deadline.isValid() ? load.deadline
: (timeline.isEmpty() ? QDateTime()
: timeline.last().end);
for (int i = 0; i < timeline.size(); ++i) {
const EnergyTimeSlot &slot = timeline.at(i);
if (slot.manualOverride)
continue;
if (deadline.isValid() && slot.start >= deadline)
continue;
// Only future slots (or current slot)
eligibleIndices.append(i);
}
// Sort eligible slots by composite score (cheapest + most solar first)
std::sort(eligibleIndices.begin(), eligibleIndices.end(),
[&](int a, int b) {
return slotScore(timeline.at(a), config) < slotScore(timeline.at(b), config);
});
// Pick the best slotsNeeded slots
int scheduled = 0;
foreach (int idx, eligibleIndices) {
if (scheduled >= slotsNeeded)
break;
EnergyTimeSlot &slot = timeline[idx];
double surplus = slot.solarForecastW - slot.baseConsumptionW
- slot.allocatedToHP - slot.allocatedToDHW - slot.allocatedToBattery;
bool hasSurplus = surplus > config.solarSurplusThresholdW;
bool cheapPrice = slot.electricityPrice > 0
&& slot.electricityPrice < config.chargePriceThreshold;
if (load.source == LoadSource::SmartCharging) {
slot.allocatedToEV = load.maxPowerW;
QStringList rules;
if (hasSurplus) {
rules.append("SolarSurplus");
slot.decisionReason = QString("Surplus solaire prévu +%1 kW — recharge VE gratuite")
.arg(surplus / 1000.0, 0, 'f', 1);
} else if (cheapPrice) {
rules.append("PriceBelow" + QString::number(config.chargePriceThreshold));
slot.decisionReason = QString("Prix spot %1€/kWh (seuil %2€) — recharge VE planifiée")
.arg(slot.electricityPrice, 0, 'f', 3)
.arg(config.chargePriceThreshold, 0, 'f', 2);
} else {
rules.append("EVDeadlineOk");
slot.decisionReason = QString("Créneau optimal avant deadline — recharge VE planifiée (%1€/kWh)")
.arg(slot.electricityPrice, 0, 'f', 3);
}
if (load.deadline.isValid())
rules.append("EVDeadlineOk");
slot.decisionRules.append(rules);
} else {
// Generic shiftable: allocate to EV slot as fallback
slot.allocatedToEV += load.maxPowerW;
slot.decisionReason = QString("Charge flexible planifiée (%1) avant deadline")
.arg(load.displayName);
slot.decisionRules.append("Shiftable");
}
scheduled++;
}
}
}
void RuleBasedStrategy::computeSlotResults(EnergyTimeSlot &slot) const
{
double totalLoad = slot.baseConsumptionW
+ slot.allocatedToEV
+ slot.allocatedToHP
+ slot.allocatedToDHW
+ qMax(0.0, slot.allocatedToBattery) // only charging
+ slot.allocatedToFeedIn;
double totalGeneration = slot.solarForecastW
+ qMax(0.0, -slot.allocatedToBattery); // battery discharge
slot.netGridPowerW = totalLoad - totalGeneration;
double slotHours = 1.0;
if (slot.start.isValid() && slot.end.isValid()) {
qint64 secs = slot.start.secsTo(slot.end);
if (secs > 0)
slotHours = secs / 3600.0;
}
double importKwh = qMax(0.0, slot.netGridPowerW) * slotHours / 1000.0;
double exportKwh = qMax(0.0, -slot.netGridPowerW) * slotHours / 1000.0;
slot.estimatedCostEUR = importKwh * slot.electricityPrice
- exportKwh * slot.electricitySellPrice;
if (totalLoad > 0)
slot.selfSufficiencyPct = qMin(100.0, totalGeneration / totalLoad * 100.0);
else
slot.selfSufficiencyPct = 100.0;
}
void RuleBasedStrategy::fillMissingReasons(QList<EnergyTimeSlot> &timeline) const
{
for (int i = 0; i < timeline.size(); ++i) {
EnergyTimeSlot &slot = timeline[i];
if (!slot.decisionReason.isEmpty())
continue;
// Build a generic reason from available data
double surplus = slot.solarForecastW - slot.baseConsumptionW;
if (surplus > 200)
slot.decisionReason = QString("Surplus solaire +%1 kW — aucune charge flexible disponible")
.arg(surplus / 1000.0, 0, 'f', 1);
else if (slot.electricityPrice > 0 && slot.electricityPrice < 0.05)
slot.decisionReason = QString("Prix très bas %1€/kWh — aucune charge flexible configurée")
.arg(slot.electricityPrice, 0, 'f', 3);
else
slot.decisionReason = QStringLiteral("Créneau passif — toutes les charges flexibles satisfaites");
}
}
double RuleBasedStrategy::slotScore(const EnergyTimeSlot &slot, const SchedulerConfig &config) const
{
// Lower score = better slot for scheduling
// Combine: low price (good), high solar surplus (good)
double priceScore = slot.electricityPrice; // lower is better
double solarScore = -(slot.solarForecastW - slot.baseConsumptionW)
/ qMax(1.0, config.solarSurplusThresholdW); // negative = better (higher surplus)
return priceScore + solarScore * 0.01; // price dominates, solar is tiebreaker
}