341 lines
14 KiB
C++
341 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 "testscheduler.h"
|
||
#include <QSignalSpy>
|
||
#include <algorithm>
|
||
|
||
// Typical winter day electricity prices (EUR/kWh), hours 00:00–23:00
|
||
// Cheap night: 00–06, morning peak: 07–09, midday dip: 10–14, evening peak: 16–20
|
||
const double TestScheduler::winterPrices[24] = {
|
||
0.05, 0.05, 0.04, 0.04, 0.04, 0.05, // 00–05 cheap night
|
||
0.12, 0.16, 0.14, 0.11, 0.09, 0.08, // 06–11 morning peak → midday dip
|
||
0.07, 0.07, 0.08, 0.09, 0.14, 0.16, // 12–17 midday → evening peak
|
||
0.18, 0.17, 0.15, 0.12, 0.10, 0.08 // 18–23 evening peak → falling
|
||
};
|
||
|
||
TestScheduler::TestScheduler(QObject *parent)
|
||
: QObject(parent)
|
||
{
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helper: build 24h winter forecast starting at 'start' (hourly slots)
|
||
// ---------------------------------------------------------------------------
|
||
QList<EnergyTimeSlot> TestScheduler::buildWinterForecast(const QDateTime &start) const
|
||
{
|
||
// Solar bell curve peaking at noon (~4 kW, 0 during night)
|
||
static const double solarW[24] = {
|
||
0, 0, 0, 0, 0, 0, // 00–05: no sun
|
||
200, 600, 1200, 2400, 3600, 4000, // 06–11: rising
|
||
4200, 4000, 3600, 2800, 1600, 600, // 12–17: peak then falling
|
||
0, 0, 0, 0, 0, 0 // 18–23: no sun
|
||
};
|
||
static const double baseW = 1200.0; // constant base load
|
||
|
||
QList<EnergyTimeSlot> forecast;
|
||
for (int h = 0; h < 24; ++h) {
|
||
EnergyTimeSlot slot;
|
||
slot.start = start.addSecs(h * 3600);
|
||
slot.end = start.addSecs((h + 1) * 3600);
|
||
slot.solarForecastW = solarW[h];
|
||
slot.baseConsumptionW = baseW;
|
||
slot.electricityPrice = winterPrices[h];
|
||
slot.electricitySellPrice = 0.04;
|
||
forecast.append(slot);
|
||
}
|
||
return forecast;
|
||
}
|
||
|
||
bool TestScheduler::allActiveSlotsHaveReason(const QList<EnergyTimeSlot> &timeline) const
|
||
{
|
||
foreach (const EnergyTimeSlot &slot, timeline) {
|
||
// An "active" slot is one that has any non-zero allocation
|
||
bool active = slot.allocatedToEV > 0
|
||
|| slot.allocatedToHP > 0
|
||
|| slot.allocatedToDHW > 0
|
||
|| qAbs(slot.allocatedToBattery) > 0;
|
||
if (active && slot.decisionReason.isEmpty())
|
||
return false;
|
||
// All slots (even passive ones) must have a reason — enforced by fillMissingReasons
|
||
if (slot.decisionReason.isEmpty())
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
int TestScheduler::countSlotsInCheapestN(const QList<EnergyTimeSlot> &timeline,
|
||
const QDateTime &deadline,
|
||
int n) const
|
||
{
|
||
// Find all eligible slots before deadline
|
||
QList<double> prices;
|
||
foreach (const EnergyTimeSlot &slot, timeline) {
|
||
if (slot.start < deadline && slot.electricityPrice > 0)
|
||
prices.append(slot.electricityPrice);
|
||
}
|
||
std::sort(prices.begin(), prices.end());
|
||
if (n > prices.size())
|
||
n = prices.size();
|
||
double threshold = (n > 0) ? prices.at(n - 1) : 999.0;
|
||
|
||
int charged = 0;
|
||
foreach (const EnergyTimeSlot &slot, timeline) {
|
||
if (slot.allocatedToEV > 0 && slot.start < deadline) {
|
||
if (slot.electricityPrice <= threshold)
|
||
charged++;
|
||
}
|
||
}
|
||
return charged;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test 1 — RuleBasedStrategy, typical winter day
|
||
// ---------------------------------------------------------------------------
|
||
void TestScheduler::testRuleBasedStrategy_winterDay()
|
||
{
|
||
// Scenario: EV plugged in at 18:00, deadline next day 07:00, SOC 40%, target 80%
|
||
// → Charging should be scheduled in cheap night slots 00:00–06:00 and/or 23:00
|
||
|
||
// Start the forecast at "today 18:00 UTC"
|
||
QDateTime base = QDateTime(QDate::currentDate(), QTime(18, 0, 0), Qt::UTC);
|
||
QList<EnergyTimeSlot> forecast = buildWinterForecast(base);
|
||
|
||
// EV load: Shiftable, SmartCharging, 7400W (typical 32A/1ph), SOC 40→80%
|
||
FlexibleLoad evLoad;
|
||
evLoad.thingId = ThingId::createThingId();
|
||
evLoad.displayName = QStringLiteral("Test EV");
|
||
evLoad.type = LoadType::Shiftable;
|
||
evLoad.source = LoadSource::SmartCharging;
|
||
evLoad.minPowerW = 1400;
|
||
evLoad.maxPowerW = 7400;
|
||
evLoad.currentValue = 40;
|
||
evLoad.targetValue = 80;
|
||
evLoad.priority = 0.7;
|
||
evLoad.deadline = base.addSecs(13 * 3600); // 07:00 next day
|
||
|
||
SchedulerConfig config;
|
||
config.chargePriceThreshold = 0.08;
|
||
config.solarSurplusThresholdW = 200.0;
|
||
|
||
RuleBasedStrategy strategy;
|
||
QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {evLoad}, config);
|
||
|
||
// Verify: 24 slots returned
|
||
QCOMPARE(result.size(), 24);
|
||
|
||
// Verify: all slots have a non-empty decisionReason
|
||
QVERIFY2(allActiveSlotsHaveReason(result),
|
||
"Some active slots are missing a decisionReason");
|
||
|
||
// Verify: EV is scheduled somewhere
|
||
int evChargingSlots = 0;
|
||
foreach (const EnergyTimeSlot &slot, result)
|
||
if (slot.allocatedToEV > 0)
|
||
evChargingSlots++;
|
||
QVERIFY2(evChargingSlots > 0, "EV was never scheduled for charging");
|
||
|
||
// Verify: at least half of charged slots fall within the cheapest hours before deadline
|
||
int cheapSlots = countSlotsInCheapestN(result, evLoad.deadline, 6);
|
||
QVERIFY2(cheapSlots >= evChargingSlots / 2,
|
||
QString("Only %1/%2 EV slots fall in the 6 cheapest hours")
|
||
.arg(cheapSlots).arg(evChargingSlots).toUtf8());
|
||
|
||
// Verify: decisionReason for EV slots is not generic fallback
|
||
foreach (const EnergyTimeSlot &slot, result) {
|
||
if (slot.allocatedToEV > 0) {
|
||
QVERIFY2(!slot.decisionReason.isEmpty(), "EV charging slot has no reason");
|
||
QVERIFY2(!slot.decisionRules.isEmpty(), "EV charging slot has no rules");
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test 2 — Manual override is respected
|
||
// ---------------------------------------------------------------------------
|
||
void TestScheduler::testManualOverrideRespected()
|
||
{
|
||
QDateTime base = QDateTime(QDate::currentDate(), QTime(18, 0, 0), Qt::UTC);
|
||
QList<EnergyTimeSlot> forecast = buildWinterForecast(base);
|
||
|
||
// Override slot at 23:00 (index 5 from 18h = hour 5)
|
||
QDateTime overrideStart = base.addSecs(5 * 3600); // 23:00
|
||
for (int i = 0; i < forecast.size(); ++i) {
|
||
if (forecast.at(i).start == overrideStart) {
|
||
forecast[i].manualOverride = true;
|
||
forecast[i].overrideReason = QStringLiteral("Départ annulé — ne pas charger ce soir");
|
||
forecast[i].allocatedToEV = 0;
|
||
forecast[i].decisionReason = QStringLiteral("Décision manuelle : Départ annulé");
|
||
forecast[i].decisionRules = QStringList() << QStringLiteral("ManualOverride");
|
||
break;
|
||
}
|
||
}
|
||
|
||
FlexibleLoad evLoad;
|
||
evLoad.thingId = ThingId::createThingId();
|
||
evLoad.displayName = QStringLiteral("Test EV");
|
||
evLoad.type = LoadType::Shiftable;
|
||
evLoad.source = LoadSource::SmartCharging;
|
||
evLoad.minPowerW = 1400;
|
||
evLoad.maxPowerW = 7400;
|
||
evLoad.currentValue = 40;
|
||
evLoad.targetValue = 80;
|
||
evLoad.priority = 0.7;
|
||
evLoad.deadline = base.addSecs(13 * 3600);
|
||
|
||
SchedulerConfig config;
|
||
config.chargePriceThreshold = 0.08;
|
||
|
||
RuleBasedStrategy strategy;
|
||
QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {evLoad}, config);
|
||
|
||
// Verify: overridden slot is unchanged
|
||
foreach (const EnergyTimeSlot &slot, result) {
|
||
if (slot.start == overrideStart) {
|
||
QVERIFY2(slot.manualOverride, "Manual override flag was cleared by Scheduler");
|
||
QCOMPARE(slot.allocatedToEV, 0.0);
|
||
QVERIFY2(slot.decisionRules.contains("ManualOverride"),
|
||
"ManualOverride rule was removed");
|
||
}
|
||
}
|
||
|
||
// Verify: other slots are still computed (EV is charged elsewhere)
|
||
int evChargingSlots = 0;
|
||
foreach (const EnergyTimeSlot &slot, result) {
|
||
if (slot.start != overrideStart && slot.allocatedToEV > 0)
|
||
evChargingSlots++;
|
||
}
|
||
QVERIFY2(evChargingSlots > 0,
|
||
"No EV charging scheduled in non-overridden slots");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test 3 — Fallback when all prices = 0 (no tariff data)
|
||
// ---------------------------------------------------------------------------
|
||
void TestScheduler::testFallbackEmptyTariffData()
|
||
{
|
||
QDateTime base = QDateTime(QDate::currentDate(), QTime(0, 0, 0), Qt::UTC);
|
||
|
||
// Build forecast with all prices = 0 (TariffManager returned nothing)
|
||
QList<EnergyTimeSlot> forecast = buildWinterForecast(base);
|
||
for (int i = 0; i < forecast.size(); ++i)
|
||
forecast[i].electricityPrice = 0.0;
|
||
|
||
// Critical HP load (inflexible, priority ≥ 0.9)
|
||
FlexibleLoad criticalHP;
|
||
criticalHP.thingId = ThingId::createThingId();
|
||
criticalHP.displayName = QStringLiteral("Critical Heating");
|
||
criticalHP.type = LoadType::Inflexible;
|
||
criticalHP.source = LoadSource::HeatPump;
|
||
criticalHP.minPowerW = 2000;
|
||
criticalHP.maxPowerW = 2000;
|
||
criticalHP.currentPowerW= 2000;
|
||
criticalHP.priority = 0.95;
|
||
|
||
SchedulerConfig config;
|
||
config.chargePriceThreshold = 0.08; // No slot qualifies (all prices = 0)
|
||
|
||
RuleBasedStrategy strategy;
|
||
QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {criticalHP}, config);
|
||
|
||
// Verify: plan is non-empty
|
||
QCOMPARE(result.size(), 24);
|
||
|
||
// Verify: critical HP is allocated in every slot
|
||
foreach (const EnergyTimeSlot &slot, result) {
|
||
QVERIFY2(slot.allocatedToHP >= criticalHP.currentPowerW,
|
||
QString("Critical HP not allocated in slot %1")
|
||
.arg(slot.start.toString("hh:mm")).toUtf8());
|
||
}
|
||
|
||
// Verify: all slots have a reason (no crashes, no empty reasons)
|
||
QVERIFY2(allActiveSlotsHaveReason(result),
|
||
"Some slots are missing decisionReason when tariff data is empty");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test 4 — Hot strategy swap (RuleBasedStrategy → AIStrategy stub)
|
||
// ---------------------------------------------------------------------------
|
||
void TestScheduler::testHotStrategySwap()
|
||
{
|
||
QDateTime base = QDateTime(QDate::currentDate(), QTime(0, 0, 0), Qt::UTC);
|
||
QList<EnergyTimeSlot> forecast = buildWinterForecast(base);
|
||
|
||
FlexibleLoad evLoad;
|
||
evLoad.thingId = ThingId::createThingId();
|
||
evLoad.displayName = QStringLiteral("Test EV");
|
||
evLoad.type = LoadType::Shiftable;
|
||
evLoad.source = LoadSource::SmartCharging;
|
||
evLoad.minPowerW = 1400;
|
||
evLoad.maxPowerW = 7400;
|
||
evLoad.currentValue = 40;
|
||
evLoad.targetValue = 80;
|
||
evLoad.priority = 0.7;
|
||
evLoad.deadline = base.addSecs(13 * 3600);
|
||
|
||
SchedulerConfig config;
|
||
config.chargePriceThreshold = 0.08;
|
||
|
||
// Step 1: compute with RuleBasedStrategy
|
||
RuleBasedStrategy ruleStrategy;
|
||
QList<EnergyTimeSlot> ruleResult = ruleStrategy.computeSchedule(forecast, {evLoad}, config);
|
||
QCOMPARE(ruleResult.size(), 24);
|
||
|
||
// Step 2: swap to AIStrategy (stub)
|
||
AIStrategy aiStrategy;
|
||
QVERIFY2(!aiStrategy.isAvailable(), "AI strategy should report unavailable");
|
||
|
||
QSignalSpy unavailableSpy(&aiStrategy, &ISchedulingStrategy::strategyUnavailable);
|
||
QList<EnergyTimeSlot> aiResult = aiStrategy.computeSchedule(forecast, {evLoad}, config);
|
||
|
||
// Verify: AI returns a plan of correct size (no crash)
|
||
QCOMPARE(aiResult.size(), 24);
|
||
|
||
// Verify: AI emitted strategyUnavailable signal
|
||
QVERIFY2(unavailableSpy.count() > 0, "AIStrategy did not emit strategyUnavailable");
|
||
|
||
// Verify: every AI slot has a non-empty decisionReason
|
||
foreach (const EnergyTimeSlot &slot, aiResult) {
|
||
QVERIFY2(!slot.decisionReason.isEmpty(),
|
||
"AI stub returned slot without decisionReason");
|
||
}
|
||
|
||
// Verify: AI slot reason contains "model" keyword (fallback message)
|
||
const EnergyTimeSlot &firstSlot = aiResult.first();
|
||
QVERIFY2(firstSlot.decisionReason.contains("IA", Qt::CaseInsensitive)
|
||
|| firstSlot.decisionReason.contains("AI", Qt::CaseInsensitive)
|
||
|| firstSlot.decisionReason.contains("modèle", Qt::CaseInsensitive)
|
||
|| firstSlot.decisionReason.contains("model", Qt::CaseInsensitive),
|
||
"AI stub reason does not mention model/fallback");
|
||
|
||
// Verify: AI result has AIModelNotLoaded rule
|
||
QVERIFY2(firstSlot.decisionRules.contains("AIModelNotLoaded"),
|
||
"AI stub missing AIModelNotLoaded rule tag");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test runner
|
||
// ---------------------------------------------------------------------------
|
||
QTEST_MAIN(TestScheduler)
|