// 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 "testscheduler.h"
#include
#include
// 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 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 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 &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 &timeline,
const QDateTime &deadline,
int n) const
{
// Find all eligible slots before deadline
QList 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 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 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 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 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 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 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 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 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 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)