// 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 "rulebasedstrategy.h" #include #include #include 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 RuleBasedStrategy::computeSchedule( const QList &forecast, const QList &loads, const SchedulerConfig &config) { // Start from a copy of the forecast so predictions are preserved QList 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 &timeline, const QList &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 &timeline, const QList &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 &timeline, const QList &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(std::ceil(energyNeededWh / (load.maxPowerW * slotHours)))); // Collect eligible slots before deadline QList 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 &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 }