682 lines
28 KiB
C++
682 lines
28 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 <QDir>
|
||
#include <QFile>
|
||
#include <QCoreApplication>
|
||
#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);
|
||
// Index by actual UTC hour so solar peaks at noon and prices reflect time-of-day
|
||
int utcHour = slot.start.toUTC().time().hour();
|
||
slot.solarForecastW = solarW[utcHour];
|
||
slot.baseConsumptionW = baseW;
|
||
slot.electricityPrice = winterPrices[utcHour];
|
||
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 5 — ManualStrategy: slot configured → allocations applied exactly
|
||
// ---------------------------------------------------------------------------
|
||
void TestScheduler::testManualStrategy_basicSlot()
|
||
{
|
||
// Slot: today 22:00 UTC → tomorrow 06:00 UTC, ev=2000W, battery=1000W
|
||
QDate today = QDate::currentDate();
|
||
QDateTime slotStart = QDateTime(today, QTime(22, 0, 0), Qt::UTC);
|
||
QDateTime slotEnd = QDateTime(today.addDays(1), QTime(6, 0, 0), Qt::UTC);
|
||
|
||
ManualSlotConfig cfg;
|
||
cfg.start = slotStart;
|
||
cfg.end = slotEnd;
|
||
cfg.label = QStringLiteral("Recharge VE nuit");
|
||
cfg.repeating = false;
|
||
cfg.powerAllocations.insert(LoadSource::SmartCharging, 2000.0);
|
||
cfg.powerAllocations.insert(LoadSource::Battery, 1000.0);
|
||
|
||
ManualStrategy strategy;
|
||
strategy.setManualSlot(cfg);
|
||
|
||
// Build a 2-slot forecast covering 22:00 and 23:00
|
||
QList<EnergyTimeSlot> forecast;
|
||
for (int h = 0; h < 2; ++h) {
|
||
EnergyTimeSlot s;
|
||
s.start = slotStart.addSecs(h * 3600);
|
||
s.end = slotStart.addSecs((h + 1) * 3600);
|
||
forecast.append(s);
|
||
}
|
||
|
||
SchedulerConfig config;
|
||
QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {}, config);
|
||
|
||
QCOMPARE(result.size(), 2);
|
||
|
||
// Both slots fall within 22:00–06:00 → allocations must match exactly
|
||
foreach (const EnergyTimeSlot &slot, result) {
|
||
QCOMPARE(slot.allocatedToEV, 2000.0);
|
||
QCOMPARE(slot.allocatedToBattery, 1000.0);
|
||
QVERIFY2(!slot.decisionReason.isEmpty(), "decisionReason must not be empty");
|
||
QVERIFY2(slot.decisionReason.contains("manuel", Qt::CaseInsensitive) ||
|
||
slot.decisionReason.contains("Recharge VE nuit"),
|
||
"decisionReason should mention manual or label");
|
||
QVERIFY2(slot.decisionRules.contains("ManualSlot"),
|
||
"decisionRules must contain ManualSlot");
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test 6 — ManualStrategy: no config → fallback with safe reason
|
||
// ---------------------------------------------------------------------------
|
||
void TestScheduler::testManualStrategy_noConfig_fallback()
|
||
{
|
||
// No manual slot configured at all
|
||
ManualStrategy strategy;
|
||
|
||
QDateTime base = QDateTime(QDate::currentDate(), QTime(10, 0, 0), Qt::UTC);
|
||
|
||
QList<EnergyTimeSlot> forecast;
|
||
for (int h = 0; h < 3; ++h) {
|
||
EnergyTimeSlot s;
|
||
s.start = base.addSecs(h * 3600);
|
||
s.end = base.addSecs((h + 1) * 3600);
|
||
forecast.append(s);
|
||
}
|
||
|
||
SchedulerConfig config;
|
||
QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {}, config);
|
||
|
||
QCOMPARE(result.size(), 3);
|
||
foreach (const EnergyTimeSlot &slot, result) {
|
||
QCOMPARE(slot.allocatedToEV, 0.0);
|
||
QCOMPARE(slot.allocatedToBattery, 0.0);
|
||
QVERIFY2(!slot.decisionReason.isEmpty(),
|
||
"decisionReason must never be empty");
|
||
QVERIFY2(slot.decisionReason.contains("Aucune configuration",
|
||
Qt::CaseInsensitive) ||
|
||
slot.decisionRules.contains("ManualDefault"),
|
||
"Slot without config must report ManualDefault");
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test 7 — ManualStrategy: expired slot is ignored, reason set correctly
|
||
// ---------------------------------------------------------------------------
|
||
void TestScheduler::testManualStrategy_expiredSlot()
|
||
{
|
||
QDate today = QDate::currentDate();
|
||
QDateTime slotStart = QDateTime(today, QTime(14, 0, 0), Qt::UTC);
|
||
QDateTime slotEnd = QDateTime(today, QTime(16, 0, 0), Qt::UTC);
|
||
|
||
ManualSlotConfig cfg;
|
||
cfg.start = slotStart;
|
||
cfg.end = slotEnd;
|
||
cfg.label = QStringLiteral("Créneau dépassé");
|
||
cfg.repeating = false;
|
||
// Expiry = yesterday → already expired
|
||
cfg.expiresAt = QDateTime::currentDateTimeUtc().addDays(-1);
|
||
cfg.powerAllocations.insert(LoadSource::SmartCharging, 5000.0);
|
||
|
||
ManualStrategy strategy;
|
||
strategy.setManualSlot(cfg);
|
||
|
||
QList<EnergyTimeSlot> forecast;
|
||
EnergyTimeSlot s;
|
||
s.start = slotStart;
|
||
s.end = slotStart.addSecs(3600);
|
||
forecast.append(s);
|
||
|
||
SchedulerConfig config;
|
||
QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {}, config);
|
||
|
||
QCOMPARE(result.size(), 1);
|
||
const EnergyTimeSlot &slot = result.first();
|
||
|
||
// Expired slot: EV must NOT be charged
|
||
QCOMPARE(slot.allocatedToEV, 0.0);
|
||
|
||
// Reason must clearly state expiry
|
||
QVERIFY2(slot.decisionReason.contains("expiré", Qt::CaseInsensitive),
|
||
"decisionReason must mention expiry");
|
||
QVERIFY2(slot.decisionRules.contains("ExpiredSlot"),
|
||
"decisionRules must contain ExpiredSlot");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test 8 — ManualStrategy: repeating weekly slot applied to next recurrence
|
||
// ---------------------------------------------------------------------------
|
||
void TestScheduler::testManualStrategy_repeatingSlot()
|
||
{
|
||
// Find the most recent Monday as our anchor
|
||
QDate anchor = QDate::currentDate();
|
||
while (anchor.dayOfWeek() != 1) // Qt: 1 = Monday
|
||
anchor = anchor.addDays(-1);
|
||
|
||
ManualSlotConfig cfg;
|
||
cfg.start = QDateTime(anchor, QTime(22, 0, 0), Qt::UTC);
|
||
cfg.end = QDateTime(anchor.addDays(1), QTime(6, 0, 0), Qt::UTC); // overnight
|
||
cfg.label = QStringLiteral("Recharge hebdomadaire");
|
||
cfg.repeating = true;
|
||
// No expiresAt — repeats indefinitely
|
||
cfg.powerAllocations.insert(LoadSource::SmartCharging, 3000.0);
|
||
|
||
ManualStrategy strategy;
|
||
strategy.setManualSlot(cfg);
|
||
|
||
// Next Monday 23:00 — should match the repeating window Mon 22:00 → Tue 06:00
|
||
QDate nextMonday = anchor.addDays(7);
|
||
QDateTime testSlot = QDateTime(nextMonday, QTime(23, 0, 0), Qt::UTC);
|
||
|
||
QList<EnergyTimeSlot> forecast;
|
||
EnergyTimeSlot s;
|
||
s.start = testSlot;
|
||
s.end = testSlot.addSecs(3600);
|
||
forecast.append(s);
|
||
|
||
SchedulerConfig config;
|
||
QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {}, config);
|
||
|
||
QCOMPARE(result.size(), 1);
|
||
QVERIFY2(result.first().allocatedToEV > 0,
|
||
"Repeating slot must be applied to next weekly recurrence");
|
||
QCOMPARE(result.first().allocatedToEV, 3000.0);
|
||
QVERIFY2(result.first().decisionReason.contains("Recharge hebdomadaire"),
|
||
"decisionReason must contain the slot label");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test 9 — ManualStrategy: JSON round-trip (persistence simulation)
|
||
// ---------------------------------------------------------------------------
|
||
void TestScheduler::testManualStrategy_persistence()
|
||
{
|
||
QDate today = QDate::currentDate();
|
||
|
||
ManualSlotConfig original;
|
||
original.start = QDateTime(today, QTime(22, 0, 0), Qt::UTC);
|
||
original.end = QDateTime(today.addDays(1), QTime(6, 0, 0), Qt::UTC);
|
||
original.label = QStringLiteral("Recharge VE nuit");
|
||
original.repeating = false;
|
||
original.expiresAt = QDateTime::currentDateTimeUtc().addDays(7); // future
|
||
original.powerAllocations.insert(LoadSource::SmartCharging, 2000.0);
|
||
original.powerAllocations.insert(LoadSource::Battery, 1000.0);
|
||
|
||
// Serialize → deserialize
|
||
QVariantMap json = original.toJson();
|
||
ManualSlotConfig restored = ManualSlotConfig::fromJson(json);
|
||
|
||
QCOMPARE(restored.start, original.start);
|
||
QCOMPARE(restored.end, original.end);
|
||
QCOMPARE(restored.label, original.label);
|
||
QCOMPARE(restored.repeating, original.repeating);
|
||
QCOMPARE(restored.expiresAt, original.expiresAt);
|
||
QCOMPARE(restored.powerAllocations.value(LoadSource::SmartCharging), 2000.0);
|
||
QCOMPARE(restored.powerAllocations.value(LoadSource::Battery), 1000.0);
|
||
|
||
// Verify the restored config is functional: apply it in a new strategy instance
|
||
ManualStrategy strategy;
|
||
strategy.setManualSlot(restored);
|
||
|
||
QList<EnergyTimeSlot> forecast;
|
||
EnergyTimeSlot s;
|
||
s.start = original.start;
|
||
s.end = original.start.addSecs(3600);
|
||
forecast.append(s);
|
||
|
||
SchedulerConfig config;
|
||
QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {}, config);
|
||
|
||
QCOMPARE(result.size(), 1);
|
||
QCOMPARE(result.first().allocatedToEV, 2000.0);
|
||
QCOMPARE(result.first().allocatedToBattery, 1000.0);
|
||
QVERIFY2(result.first().decisionRules.contains("ManualSlot"),
|
||
"Restored slot must produce ManualSlot rule after round-trip");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test A — LoadAdapterRegistry::detectAdapterType (pure static, no ThingManager)
|
||
// ---------------------------------------------------------------------------
|
||
void TestScheduler::testDetectAdapterType()
|
||
{
|
||
QCOMPARE(LoadAdapterRegistry::detectAdapterType(LoadRole::EVCharger,
|
||
QStringList() << QStringLiteral("evcharger")),
|
||
QStringLiteral("evcharger"));
|
||
|
||
QCOMPARE(LoadAdapterRegistry::detectAdapterType(LoadRole::HeatPump,
|
||
QStringList() << QStringLiteral("heating")),
|
||
QStringLiteral("sgready"));
|
||
|
||
QCOMPARE(LoadAdapterRegistry::detectAdapterType(LoadRole::HeatPump,
|
||
QStringList() << QStringLiteral("relay")),
|
||
QStringLiteral("relay"));
|
||
|
||
QCOMPARE(LoadAdapterRegistry::detectAdapterType(LoadRole::DHW,
|
||
QStringList() << QStringLiteral("relay")),
|
||
QStringLiteral("relay"));
|
||
|
||
QCOMPARE(LoadAdapterRegistry::detectAdapterType(LoadRole::Battery,
|
||
QStringList() << QStringLiteral("energystorage")),
|
||
QStringLiteral("battery"));
|
||
|
||
QCOMPARE(LoadAdapterRegistry::detectAdapterType(LoadRole::SolarMeter,
|
||
QStringList() << QStringLiteral("energymeter")),
|
||
QStringLiteral("readonly"));
|
||
|
||
QCOMPARE(LoadAdapterRegistry::detectAdapterType(LoadRole::GridMeter,
|
||
QStringList() << QStringLiteral("energymeter")),
|
||
QStringLiteral("readonly"));
|
||
|
||
// Incompatible interface returns empty
|
||
QVERIFY(LoadAdapterRegistry::detectAdapterType(LoadRole::Battery,
|
||
QStringList() << QStringLiteral("relay")).isEmpty());
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test B — AdapterSettings round-trip
|
||
// ---------------------------------------------------------------------------
|
||
void TestScheduler::testAdapterSettings_roundTrip()
|
||
{
|
||
// Use a temp file to avoid polluting any production settings
|
||
const QString tmpPath = QDir::tempPath()
|
||
+ QStringLiteral("/nymea_adapter_test_%1.conf")
|
||
.arg(QCoreApplication::applicationPid());
|
||
|
||
// Override path via environment variable
|
||
qputenv("NYMEA_ADAPTER_SETTINGS", tmpPath.toUtf8());
|
||
|
||
{
|
||
AdapterSettings s;
|
||
QVariantMap params;
|
||
params.insert(QStringLiteral("thingId"), QStringLiteral("11111111-1111-1111-1111-111111111111"));
|
||
params.insert(QStringLiteral("adapterType"), QStringLiteral("relay"));
|
||
params.insert(QStringLiteral("nominalPowerW"), 2000.0);
|
||
params.insert(QStringLiteral("enabled"), true);
|
||
s.setAssignment(LoadRole::DHW, params);
|
||
} // destructor flushes (already saved by setAssignment)
|
||
|
||
{
|
||
AdapterSettings s2;
|
||
const QList<QVariantMap> loaded = s2.assignments();
|
||
QCOMPARE(loaded.size(), 1);
|
||
QCOMPARE(loaded.first().value(QStringLiteral("role")).toString(),
|
||
QStringLiteral("DHW"));
|
||
QCOMPARE(loaded.first().value(QStringLiteral("adapterType")).toString(),
|
||
QStringLiteral("relay"));
|
||
QCOMPARE(loaded.first().value(QStringLiteral("nominalPowerW")).toDouble(), 2000.0);
|
||
}
|
||
|
||
// Cleanup
|
||
QFile::remove(tmpPath);
|
||
qputenv("NYMEA_ADAPTER_SETTINGS", QByteArray());
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test C — SchedulerManager with null registry does not crash
|
||
// ---------------------------------------------------------------------------
|
||
void TestScheduler::testSchedulerManager_nullRegistry_noCrash()
|
||
{
|
||
// SchedulerManager(nullptr registry) must not crash on construction or forceRecompute().
|
||
// It will still compute a 24-slot timeline (stub forecast, default strategy).
|
||
SchedulerManager mgr(nullptr, nullptr, nullptr, nullptr);
|
||
// forceRecompute is called via singleShot in the constructor; give the event loop a tick
|
||
QCoreApplication::processEvents();
|
||
|
||
// The manager builds a 24-slot forecast even with all null managers.
|
||
// The registry being nullptr means applyCurrentSlot() is a no-op — verify no crash.
|
||
QVERIFY2(mgr.planHealth() == QLatin1String("ok") ||
|
||
mgr.planHealth() == QLatin1String("no_forecast") ||
|
||
mgr.planHealth() == QLatin1String("degraded"),
|
||
"planHealth() must return a valid string");
|
||
|
||
// forceRecompute() again — must not crash with null registry
|
||
mgr.forceRecompute();
|
||
QVERIFY(true); // reached here = no crash
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test D — LoadRole string serialization round-trip
|
||
// ---------------------------------------------------------------------------
|
||
void TestScheduler::testLoadRole_stringRoundTrip()
|
||
{
|
||
const QList<LoadRole> roles = {
|
||
LoadRole::EVCharger,
|
||
LoadRole::DHW,
|
||
LoadRole::HeatPump,
|
||
LoadRole::Battery,
|
||
LoadRole::SolarMeter,
|
||
LoadRole::GridMeter
|
||
};
|
||
foreach (LoadRole role, roles) {
|
||
const QString str = loadRoleToString(role);
|
||
const LoadRole restored = loadRoleFromString(str);
|
||
QVERIFY2(!str.isEmpty(), "loadRoleToString returned empty string");
|
||
QCOMPARE(restored, role);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test runner
|
||
// ---------------------------------------------------------------------------
|
||
QTEST_MAIN(TestScheduler)
|