Patrick Schurig 7184fe4e1d feat(etm): config charge pilotée persistée (Get/SetLoadConfig) + repli L2
Pont moteur↔app : déclaration des charges etmvariableload persistée et éditable,
construction des adaptateurs à chaud depuis la config (remplace le registre en dur).

- LoadConfig / LoadConfigNeeds (Q_GADGET typés, introspectables) — forme mot pour mot
  du LoadDescriptor §4 (jonction inter-repos avec l'app).
- LoadConfigStore : persistance atomique /var/lib/nymea/energy-load-configuration.json,
  validation en bloc, signal changed().
- Handler NymeaEnergy.GetLoadConfig / SetLoadConfig (+ notif LoadConfigChanged). GET typé
  objectRef<LoadConfig> ; SET schéma inline o: (powerLevels/needs conditionnels §4).
- EnergyArbitrator : setLoadConfigStore + rebuildEtmVariableLoadAdapters (enabled==true
  seulement, §9) ; repli L2 = setPowerSetpoint(0) force=true sur tout etmvariableload
  (ferme le trou sécurité ouvert en T2).
- Tests simulation : migration ECS→etmvariableload (arrondi fixed/dynamic, recrédit,
  délestage, round-trip powerSetpoint), budget partagé etmvariableload↔PAC, watchdog L2
  réactivé, persistance + construction depuis config + injection RPC end-to-end.

PAC SG-Ready reste hors config (§8, gelée). Les 5 tâches du brief sont couvertes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 09:51:38 +02:00

2713 lines
124 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 "simulation.h"
#include <hardware/electricity.h>
#include <servers/mocktcpserver.h>
#include <experiences/experiencemanager.h>
#include <experiences/experienceplugin.h>
using namespace nymeaserver;
#include "../../../energyplugin/smartchargingmanager.h"
#include "../../mocks/spotmarketprovider/spotmarketdataprovidermock.h"
#ifdef ETM_ARBITRATOR
#include "../../../energyplugin/etm/energyarbitrator.h"
#include "../../../energyplugin/etm/adapters/sgreadyadapter.h"
// [T3] ecsrelayadapter.h retiré : l'ECS est piloté en watts via EtmVariableLoadAdapter
// (interface etmvariableload, kind Setpoint). La combinatoire relais vit dans le thing.
#include "../../../energyplugin/etm/adapters/etmvariableloadadapter.h"
#include "../../../energyplugin/etm/types/loadconfig.h"
#include "../../../energyplugin/etm/config/loadconfigstore.h"
#endif
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QHash>
#include <QtMath>
#include <QtGlobal>
#include <QProcess>
#include <QDateTime>
#include <QSignalSpy>
#include <QProcessEnvironment>
#include <QCoreApplication>
#include <nymeacore.h>
#include "simulationtestpoint.h"
void Simulation::testEcsSurplusPV()
{
#ifndef ETM_ARBITRATOR
QSKIP("testEcsSurplusPV nécessite ETM_ARBITRATOR.");
#else
// [T3] ECS piloté en WATTS via l'interface etmvariableload (contrat rév. 2 §3/§5) : l'arbitre
// arrondit le surplus au powerLevels déclaré (fixed) ou clampe à maxPowerW (dynamic), écrit
// powerSetpoint, et recrédite currentPowerW (début de cycle) pour l'anti-clignotement. La
// combinatoire matérielle (relais/triac) vit dans le thing — invisible côté moteur.
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
// =================== FIXED : paliers déclarés [0, 1200, 2400] ===================
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY2(arbitrator, "smartChargingManager n'est pas un EnergyArbitrator (ETM_ARBITRATOR requis)");
ThingManager *thingManager = NymeaCore::instance()->thingManager();
QUuid meterThingId = addMeter();
QVERIFY(!meterThingId.isNull());
m_experiencePlugin->energyManager()->setRootMeter(meterThingId);
Thing *meterThing = thingManager->findConfiguredThing(meterThingId);
QVERIFY(meterThing);
meterThing->setStateValue("connected", true);
QUuid ecsId = addEtmVariableLoad(27001);
QVERIFY(!ecsId.isNull());
Thing *ecsThing = thingManager->findConfiguredThing(ecsId);
QVERIFY(ecsThing);
EtmVariableLoadAdapter *ecs = new EtmVariableLoadAdapter(
thingManager, ecsId.toString(), "ECS variable",
QList<int>({0, 1200, 2400}), 2400, 1, LoadNeeds(), arbitrator);
arbitrator->registerEtmVariableLoadAdapter(ecs);
auto setMeterW = [&](double signedW){ meterThing->setStateValue("currentPower", signedW); }; // <0 = export
auto setLoadW = [&](double w){ ecsThing->setStateValue("currentPowerW", w); }; // mesure réelle simulée
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
// --- Cascade montante (la charge ne tire encore rien : currentPowerW = 0) ---
setLoadW(0);
setMeterW(-1000); cycle(t0); // budget 1000 < 1200 → 0 W
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
setMeterW(-1500); cycle(t0); // budget 1500 → palier 1200
QCOMPARE(qRound(ecs->currentSetpointW()), 1200);
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 1200); // round-trip interface
setMeterW(-2500); cycle(t0); // budget 2500 → palier 2400
QCOMPARE(qRound(ecs->currentSetpointW()), 2400);
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 2400);
// --- Anti-clignotement (recrédit) : la charge tire 2400, PV 2500 → export net 100.
// budget = 100 + 2400 (recrédit currentPowerW début de cycle) = 2500 → RESTE 2400.
// Sans recrédit : 100 → 0 → oscillation. C'est précisément le test du recrédit. ---
setLoadW(2400);
setMeterW(-100); cycle(t0);
QCOMPARE(qRound(ecs->currentSetpointW()), 2400);
// --- Délestage sur import : charge tire 2400, import 600 → budget = -600 + 2400 = 1800 → 1200. ---
setMeterW(600); cycle(t0);
QCOMPARE(qRound(ecs->currentSetpointW()), 1200);
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 1200);
// =================== DYNAMIC : modulation continue, maxPowerW 3000, sans powerLevels ===================
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb2 = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb2);
ThingManager *tm2 = NymeaCore::instance()->thingManager();
QUuid mId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(mId);
Thing *m2 = tm2->findConfiguredThing(mId);
QVERIFY(m2);
m2->setStateValue("connected", true);
QUuid rId = addEtmVariableLoad(27002);
Thing *rThing = tm2->findConfiguredThing(rId);
QVERIFY(rThing);
rThing->setStateValue("currentPowerW", 0);
EtmVariableLoadAdapter *router = new EtmVariableLoadAdapter(
tm2, rId.toString(), "Routeur PV", QList<int>(), 3000, 1, LoadNeeds(), arb2); // powerLevels vide → dynamic
arb2->registerEtmVariableLoadAdapter(router);
m2->setStateValue("currentPower", -2000); arb2->simulationCallUpdate(t0); QCoreApplication::processEvents();
QCOMPARE(qRound(router->currentSetpointW()), 2000); // clamp(2000, 0, 3000)
m2->setStateValue("currentPower", -4000); arb2->simulationCallUpdate(t0); QCoreApplication::processEvents();
QCOMPARE(qRound(router->currentSetpointW()), 3000); // plafonné à maxPowerW
m2->setStateValue("currentPower", 500); arb2->simulationCallUpdate(t0); QCoreApplication::processEvents();
QCOMPARE(qRound(router->currentSetpointW()), 0); // import → 0
#endif
}
void Simulation::testMeterSilentFallback()
{
#ifndef ETM_ARBITRATOR
QSKIP("testMeterSilentFallback nécessite ETM_ARBITRATOR.");
#else
// [T4] Repli L2 charge pilotée recâblé : compteur muet >90 s → setPowerSetpoint(0) force=true
// sur l'etmvariableload, planif suspendue (reste 0 sur N cycles), reprise au retour compteur.
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY2(arbitrator, "smartChargingManager n'est pas un EnergyArbitrator (ETM_ARBITRATOR requis)");
ThingManager *thingManager = NymeaCore::instance()->thingManager();
QUuid meterThingId = addMeter();
QVERIFY(!meterThingId.isNull());
m_experiencePlugin->energyManager()->setRootMeter(meterThingId);
Thing *meterThing = thingManager->findConfiguredThing(meterThingId);
QVERIFY(meterThing);
meterThing->setStateValue("connected", true);
QUuid ecsId = addEtmVariableLoad(27010);
QVERIFY(!ecsId.isNull());
Thing *ecsThing = thingManager->findConfiguredThing(ecsId);
QVERIFY(ecsThing);
EtmVariableLoadAdapter *ecs = new EtmVariableLoadAdapter(
thingManager, ecsId.toString(), "ECS repli",
QList<int>({0, 2400}), 2400, 1, LoadNeeds(), arbitrator);
arbitrator->registerEtmVariableLoadAdapter(ecs);
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
auto setMeterW = [&](double signedW){ meterThing->setStateValue("currentPower", signedW); };
auto setLoadW = [&](double w){ ecsThing->setStateValue("currentPowerW", w); };
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
// ECS servi sur surplus, compteur frais à T0.
arbitrator->recordMeterUpdate(t0);
setLoadW(0);
setMeterW(-2500); cycle(t0); // budget 2500 → 2400
QCOMPARE(qRound(ecs->currentSetpointW()), 2400);
QVERIFY(!arbitrator->degradedMode());
// Compteur muet > 90 s → mode dégradé : setPowerSetpoint(0) force=true.
setLoadW(2400); // la charge tirait
arbitrator->evaluateMeterFreshness(t0.addSecs(91));
QCoreApplication::processEvents();
QVERIFY(arbitrator->degradedMode());
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 0);
// STABILITÉ : muet, plusieurs cycles, faux surplus piège → l'ECS RESTE à 0 (planif suspendue).
setMeterW(-3000);
foreach (int dt, QList<int>({92, 120, 200, 280})) {
cycle(t0.addSecs(dt));
QVERIFY2(arbitrator->degradedMode(), "degradedMode doit rester actif pendant le silence");
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
}
// REPRISE : compteur reparle → degradedMode retombe → recalcul (pas de restauration d'ancienne consigne).
arbitrator->recordMeterUpdate(t0.addSecs(300));
QVERIFY(!arbitrator->degradedMode());
setLoadW(0);
setMeterW(-1000); cycle(t0.addSecs(301)); // 1000 < 2400 → reste 0
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
setMeterW(-2500); cycle(t0.addSecs(302)); // surplus suffisant → 2400
QCOMPARE(qRound(ecs->currentSetpointW()), 2400);
#endif
}
void Simulation::testLoadConfigPersistence()
{
#ifndef ETM_ARBITRATOR
QSKIP("testLoadConfigPersistence nécessite ETM_ARBITRATOR.");
#else
// Persistance §4 : SetConfigs → fichier écrit → relecture par un store neuf → round-trip
// mot pour mot (id/label/mode/powerLevels/maxPowerW/priority/enabled/needs).
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-persist.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
const QVariantMap entry{
{"id", "{11111111-2222-3333-4444-555555555555}"},
{"label", "Chauffe-eau"},
{"adapter", "etmvariableload"},
{"mode", "fixed"},
{"powerLevels", QVariantList() << 0 << 600 << 1200},
{"maxPowerW", 1200},
{"priority", 2},
{"enabled", true},
{"needs", QVariantMap{{"dailyDeadline", "06:00"}, {"minEnergyWhPerDay", 4000}}}
};
LoadConfigs cfgs;
cfgs.append(LoadConfig::fromMap(entry));
{
LoadConfigStore store1;
QString err;
QVERIFY2(store1.setConfigs(cfgs, &err), err.toUtf8());
}
QVERIFY(QFileInfo::exists(cfgPath));
LoadConfigStore store2; // relit le fichier
QCOMPARE(store2.configs().count(), 1);
const LoadConfig r = store2.configs().first();
QCOMPARE(r.label(), QString("Chauffe-eau"));
QCOMPARE(r.mode(), QString("fixed"));
QCOMPARE(r.powerLevelsInt(), QList<int>({0, 600, 1200}));
QCOMPARE(r.maxPowerW(), 1200);
QCOMPARE(r.priority(), 2);
QVERIFY(r.enabled());
QCOMPARE(r.needs().dailyDeadline(), QString("06:00"));
QCOMPARE(r.needs().minEnergyWhPerDay(), 4000);
// Validation : une config fixed sans 0 dans powerLevels est rejetée EN BLOC (rien persisté).
QVariantMap badEntry = entry;
badEntry["powerLevels"] = QVariantList() << 600 << 1200; // pas de 0
LoadConfigs bad;
bad.append(LoadConfig::fromMap(badEntry));
LoadConfigStore store3;
QString err2;
QVERIFY(!store3.setConfigs(bad, &err2));
QVERIFY(!err2.isEmpty());
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
#endif
}
void Simulation::testLoadConfigBuildsAdapters()
{
#ifndef ETM_ARBITRATOR
QSKIP("testLoadConfigBuildsAdapters nécessite ETM_ARBITRATOR.");
#else
// SetConfigs → store.changed → l'arbitre (re)construit les adaptateurs etmvariableload.
// enabled==true servi par le surplus ; enabled==false JAMAIS piloté (exclu, contrat §9).
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arbitrator);
ThingManager *thingManager = NymeaCore::instance()->thingManager();
QUuid meterId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(meterId);
Thing *meter = thingManager->findConfiguredThing(meterId);
QVERIFY(meter);
meter->setStateValue("connected", true);
QUuid id1 = addEtmVariableLoad(27020); // enabled
QUuid id2 = addEtmVariableLoad(27021); // disabled
Thing *t1 = thingManager->findConfiguredThing(id1);
Thing *t2 = thingManager->findConfiguredThing(id2);
QVERIFY(t1 && t2);
t1->setStateValue("currentPowerW", 0);
t2->setStateValue("currentPowerW", 0);
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-build.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
LoadConfigStore *store = new LoadConfigStore(arbitrator);
arbitrator->setLoadConfigStore(store);
LoadConfigs cfgs;
cfgs.append(LoadConfig::fromMap(QVariantMap{
{"id", id1.toString()}, {"label", "ECS actif"}, {"adapter", "etmvariableload"},
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 2400}, {"maxPowerW", 2400},
{"priority", 1}, {"enabled", true}}));
cfgs.append(LoadConfig::fromMap(QVariantMap{
{"id", id2.toString()}, {"label", "ECS désactivé"}, {"adapter", "etmvariableload"},
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 1200}, {"maxPowerW", 1200},
{"priority", 2}, {"enabled", false}}));
QString err;
QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8()); // persiste + changed → rebuild
// Surplus large (5000 W) : assez pour servir les DEUX si elles étaient actives.
meter->setStateValue("currentPower", -5000);
arbitrator->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
QCoreApplication::processEvents();
QCOMPARE(qRound(t1->stateValue("powerSetpoint").toDouble()), 2400); // enabled → servi
QCOMPARE(qRound(t2->stateValue("powerSetpoint").toDouble()), 0); // disabled → jamais piloté
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
#endif
}
void Simulation::testLoadConfigRpc()
{
#ifndef ETM_ARBITRATOR
QSKIP("testLoadConfigRpc nécessite ETM_ARBITRATOR.");
#else
// End-to-end JSON-RPC (la couche que l'app consomme) : GetLoadConfig vide → SetLoadConfig
// (fixed réelle + dynamic) → energyError NoError → GetLoadConfig round-trip → effet RÉEL sur
// le thing (RPC → store → changed → arbitre reconstruit → adaptateur → powerSetpoint) →
// rejet d'une config invalide.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-rpc.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); // avant initTestCase → store sur ce chemin
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arbitrator);
ThingManager *thingManager = NymeaCore::instance()->thingManager();
QUuid meterId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(meterId);
Thing *meter = thingManager->findConfiguredThing(meterId);
QVERIFY(meter);
meter->setStateValue("connected", true);
QUuid ecsId = addEtmVariableLoad(27030); // thing réel piloté par la config fixed
Thing *ecsThing = thingManager->findConfiguredThing(ecsId);
QVERIFY(ecsThing);
ecsThing->setStateValue("currentPowerW", 0);
// 1. GetLoadConfig initial → vide.
QVariant resp = injectAndWait("NymeaEnergy.GetLoadConfig");
QVERIFY(resp.toMap().value("params").toMap().value("loadConfigs").toList().isEmpty());
// 2. SetLoadConfig : une fixed (thing réel) + une dynamic.
QVariantList loadConfigs;
loadConfigs << QVariantMap{
{"id", ecsId.toString()}, {"label", "Chauffe-eau"}, {"adapter", "etmvariableload"},
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 1200 << 2400}, {"maxPowerW", 2400},
{"priority", 2}, {"enabled", true},
{"needs", QVariantMap{{"dailyDeadline", "06:00"}, {"minEnergyWhPerDay", 4000}}}};
loadConfigs << QVariantMap{
{"id", "{b033b212-1adb-4df0-ba2b-8fa477de52a2}"}, {"label", "Routeur PV"},
{"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"maxPowerW", 3000},
{"priority", 1}, {"enabled", true}};
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", loadConfigs}});
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError"));
// 3. GetLoadConfig → round-trip des 2 entrées, champs §4 préservés (via pack/unpack Q_GADGET).
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
const QVariantList got = resp.toMap().value("params").toMap().value("loadConfigs").toList();
QCOMPARE(got.size(), 2);
QVariantMap fixedGot;
for (const QVariant &v : got)
if (v.toMap().value("id").toString() == ecsId.toString()) fixedGot = v.toMap();
QVERIFY(!fixedGot.isEmpty());
QCOMPARE(fixedGot.value("mode").toString(), QString("fixed"));
QCOMPARE(fixedGot.value("label").toString(), QString("Chauffe-eau"));
QVariantList levels = fixedGot.value("powerLevels").toList();
QCOMPARE(levels.size(), 3);
QCOMPARE(levels.last().toInt(), 2400);
QCOMPARE(fixedGot.value("needs").toMap().value("dailyDeadline").toString(), QString("06:00"));
// 4. Fichier persisté.
QVERIFY(QFileInfo::exists(cfgPath));
// 5. EFFET RÉEL : le Set RPC a reconstruit les adaptateurs → un cycle surplus pilote le thing.
// Cascade du waterfall unifié construit DEPUIS la config RPC : surplus 6000 → routeur dynamic
// (rang 1) prend 3000 (clamp maxPowerW), reliquat 3000 → ECS fixed (rang 2) prend le palier 2400.
meter->setStateValue("currentPower", -6000);
arbitrator->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
QCoreApplication::processEvents();
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 2400); // reliquat 3000 → palier 2400
// 6. Config invalide (fixed sans 0) → rejet en bloc, energyError InvalidParameter.
QVariantList bad;
bad << QVariantMap{
{"id", "{cccccccc-2222-3333-4444-555555555555}"}, {"label", "Bancal"}, {"adapter", "etmvariableload"},
{"mode", "fixed"}, {"powerLevels", QVariantList() << 1200 << 2400}, {"maxPowerW", 2400},
{"priority", 1}, {"enabled", true}};
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", bad}});
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorInvalidParameter"));
// Le rejet n'a rien écrasé : GetLoadConfig retourne toujours les 2 valides.
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
QCOMPARE(resp.toMap().value("params").toMap().value("loadConfigs").toList().size(), 2);
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
#endif
}
void Simulation::testSgReadySurplus()
{
#ifndef ETM_ARBITRATOR
QSKIP("testSgReadySurplus nécessite ETM_ARBITRATOR.");
#else
// Encodage SG-Ready 2 bits (K1,K2) : 1=[K1] blocage · 2=[] normal · 3=[K2] reco · 4=[K1,K2] forcé.
// estimatedPowerW déclaré : P3=1500, P4=3000. Hystérésis état 4 : entrée P4×1,2=3600, sortie P4×1,0=3000.
const QHash<int, double> pacPower({ {1, 0.0}, {2, 0.0}, {3, 1500.0}, {4, 3000.0} });
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
// ===================== Volets 1-3 : PAC seule =====================
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY2(arbitrator, "smartChargingManager n'est pas un EnergyArbitrator");
ThingManager *tm = NymeaCore::instance()->thingManager();
QUuid meterId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(meterId);
Thing *meter = tm->findConfiguredThing(meterId);
QVERIFY(meter);
meter->setStateValue("connected", true);
QUuid k1 = addPowerSwitch(0, 26661);
QUuid k2 = addPowerSwitch(0, 26662);
Thing *relayK1 = tm->findConfiguredThing(k1);
Thing *relayK2 = tm->findConfiguredThing(k2);
QVERIFY(relayK1 && relayK2);
SgReadyAdapter *pac = new SgReadyAdapter(
tm, "pac-test", "PAC test",
QHash<int, QList<QString>>({ {1, {k1.toString()}}, {2, {}},
{3, {k2.toString()}}, {4, {k1.toString(), k2.toString()}} }),
pacPower, 300, 1, arbitrator);
arbitrator->registerSgReadyAdapter(pac);
auto setMeterW = [&](double signedW){ meter->setStateValue("currentPower", signedW); }; // <0 export
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
// --- Volet 1 : montée d'états 2 → 3 → 4 (mapping sémantique) ---
setMeterW(-1000); cycle(t0); // budget 1000 < P3 → état 2 (normal)
QCOMPARE(pac->currentState(), 2);
setMeterW(-2000); cycle(t0); // budget 2000 ≥ P3 → état 3 (reco)
QCOMPARE(pac->currentState(), 3);
QCOMPARE(relayK2->stateValue("power").toBool(), true);
QCOMPARE(relayK1->stateValue("power").toBool(), false);
setMeterW(-2500); cycle(t0.addSecs(400)); // budget 2500+1500=4000 ≥ P4×1,2 → état 4 (hold écoulé)
QCOMPARE(pac->currentState(), 4);
QCOMPARE(relayK1->stateValue("power").toBool(), true);
QCOMPARE(relayK2->stateValue("power").toBool(), true);
// --- Volet 2 : hystérésis 3↔4 (budget oscille dans la zone morte [P4×1,0 ; P4×1,2)) ---
// hold écoulé à chaque cycle (lastSwitch=T0+400) → c'est la ZONE MORTE qui tient l'état 4, pas le verrou.
setMeterW(-300); cycle(t0.addSecs(800)); // budget 300+3000=3300 ∈ [3000,3600) → reste 4
QCOMPARE(pac->currentState(), 4);
setMeterW(-100); cycle(t0.addSecs(1200)); // budget 3100 → reste 4
QCOMPARE(pac->currentState(), 4);
setMeterW(-500); cycle(t0.addSecs(1600)); // budget 3500 → reste 4
QCOMPARE(pac->currentState(), 4);
// En-dessous de P4×1,0 → sort enfin de l'état 4 (vers 3).
setMeterW(200); cycle(t0.addSecs(2000)); // import 200 → budget -200+3000=2800 < 3000 → état 3
QCOMPARE(pac->currentState(), 3);
// --- Volet 3 : protection court-cycling (changement avant minStateHold → GELÉ) ---
// lastSwitch=T0+2000. À T0+2100 (elapsed 100 < hold 300) : surplus abondant mais GELÉ en 3.
setMeterW(-3000); cycle(t0.addSecs(2100));
QCOMPARE(pac->currentState(), 3); // gelé malgré budget ≥ P4×1,2 (protection compresseur)
// À T0+2400 (elapsed 400 > hold) : MÊME surplus → bascule en 4. Seul le temps simulé a changé.
setMeterW(-3000); cycle(t0.addSecs(2400));
QCOMPARE(pac->currentState(), 4);
// ===================== Volet 4 : budget PARTAGÉ ECS(etmvariableload)↔PAC =====================
// [T3] Surplus 3000 W ; ECS palier 2400 W (etmvariableload, kind Setpoint), PAC P3 = 1500.
// Selon l'ordre de priorité, l'un se sert et l'autre voit le RELIQUAT → waterfall unifié
// (un seul budget cascade à travers etmvariableload ET sg-ready, triés par priorité).
auto runSharedBudget = [&](int ecsPrio, int pacPrio, double &ecsSetpointOut, int &pacStateOut) {
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
ThingManager *tm2 = NymeaCore::instance()->thingManager();
QUuid mId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(mId);
Thing *m2 = tm2->findConfiguredThing(mId);
QVERIFY(m2);
m2->setStateValue("connected", true);
QUuid eId = addEtmVariableLoad(27003); // ECS etmvariableload
QUuid j1 = addPowerSwitch(0, 26661); // relais PAC K1
QUuid j2 = addPowerSwitch(0, 26662); // relais PAC K2
Thing *eThing = tm2->findConfiguredThing(eId);
QVERIFY(eThing);
eThing->setStateValue("currentPowerW", 0);
// ECS : 1 palier à 2400 W (fixed). La charge ne tire encore rien (currentPowerW=0).
EtmVariableLoadAdapter *ecsWf = new EtmVariableLoadAdapter(
tm2, eId.toString(), "ECS waterfall", QList<int>({0, 2400}), 2400, ecsPrio, LoadNeeds(), arb);
arb->registerEtmVariableLoadAdapter(ecsWf);
SgReadyAdapter *pacWf = new SgReadyAdapter(
tm2, "pac-wf", "PAC waterfall",
QHash<int, QList<QString>>({ {1, {j1.toString()}}, {2, {}},
{3, {j2.toString()}}, {4, {j1.toString(), j2.toString()}} }),
pacPower, 300, pacPrio, arb);
arb->registerSgReadyAdapter(pacWf);
m2->setStateValue("currentPower", -3000); // export 3000 W
arb->simulationCallUpdate(t0);
QCoreApplication::processEvents();
ecsSetpointOut = ecsWf->currentSetpointW();
pacStateOut = pacWf->currentState();
};
double ecsSetpoint = -1;
int pacState = -1;
// ECS prioritaire (rang 1) : ECS se sert (2400) → reliquat 600 < P3 → PAC reste NORMAL (2).
runSharedBudget(/*ecsPrio*/ 1, /*pacPrio*/ 2, ecsSetpoint, pacState);
QCOMPARE(qRound(ecsSetpoint), 2400);
QCOMPARE(pacState, 2);
// Priorités INVERSÉES — PAC prioritaire (rang 1) : PAC se sert (état 3, 1500) → reliquat
// 1500 < 2400 → l'ECS reste à 0 W. L'ordre de service s'inverse (même budget unique).
runSharedBudget(/*ecsPrio*/ 2, /*pacPrio*/ 1, ecsSetpoint, pacState);
QCOMPARE(qRound(ecsSetpoint), 0);
QCOMPARE(pacState, 3);
#endif
}
void Simulation::testEcsRelayTopologies()
{
// [T2] DÉSACTIVÉ — les topologies relais (1 relais, 3 relais non-cascadé, mapping
// powerLevels↔combinaisons) sont désormais la responsabilité du THING etmvariableload, plus
// de l'energymanager (contrat rév. 2 §1/§3 : la combinatoire matérielle vit dans le thing).
// Côté moteur, seul l'arrondi au powerLevels (Setpoint W) sera testé — TODO T3.
QSKIP("TODO T3 — la combinatoire relais part dans le thing ; le moteur ne teste que l'arrondi W.");
}
void Simulation::run_data()
{
// Simulation infos
QTest::addColumn<QString>("simulationName");
QTest::addColumn<QString>("simulationTitle");
QTest::addColumn<QString>("databaseName");
QTest::addColumn<QDateTime>("simulationStart");
QTest::addColumn<ChargerPlugEvents>("plugEvents");
QTest::addColumn<int>("simulationHours");
QTest::addColumn<EnergyLogs::SampleRate>("sampleRate");
QTest::addColumn<double>("productionScaling");
QTest::addColumn<int>("detailsStepStart");
QTest::addColumn<int>("detailsStepStop");
QTest::addColumn<DetailsStepList>("detailsStepList");
// Houshold info
QTest::addColumn<int>("phasePowerLimit");
QTest::addColumn<bool>("spotMarketEnabled");
QTest::addColumn<QString>("spotMarketResourceData");
QTest::addColumn<double>("acquisitionTolerance");
QTest::addColumn<double>("batteryLevelConsideration");
// ChargingInfo
QTest::addColumn<double>("targetPercentage");
QTest::addColumn<QDateTime>("targetDateTime");
QTest::addColumn<QString>("chargingMode");
QTest::addColumn<int>("carBatteryLevel");
QTest::addColumn<int>("dailySpotMarketPercentage");
// Car information and states
QTest::addColumn<int>("carCapacity");
QTest::addColumn<int>("carMinChargingCurrent");
QTest::addColumn<int>("carPhaseCount");
// Energy storage information and states
QTest::addColumn<bool>("energyStorageAvailable");
QTest::addColumn<int>("energyStorageCapacity");
QTest::addColumn<double>("energyStorageMaxChargingPower");
QTest::addColumn<double>("energyStorageMaxDischargingPower");
QTest::addColumn<double>("energyStorageInitialBatteyLevel");
// Charger initial states
QTest::addColumn<bool>("chargerConnected");
QTest::addColumn<bool>("chargerPower");
QTest::addColumn<QString>("chargerPhases");
QTest::addColumn<bool>("canSwitchPhaseCount");
QTest::addColumn<int>("chargerMaxChargingCurrent");
QTest::addColumn<int>("chargerMaxChargingCurrentMaxValue");
QTest::addColumn<SimulationIterationTest>("iterationTest");
bool runAllSimulations = true;
bool runSpotmarketSimulation = runAllSimulations;
bool run1PhaseSimulations = runAllSimulations;
bool run2PhaseSimulations = runAllSimulations;
bool run3PhaseSimulations = runAllSimulations;
bool runPhaseSwitchingSimulations = runAllSimulations;
// Simulations
if (runSpotmarketSimulation)
QTest::newRow("Spotmarket only")
/* Simulation info */
<< "simulation-spotmarket-only-1-phase-16A" // simulationName
<< "Simulation (1 phase, charger 16A max, only spot market)" // simulationTitle
<< ":/databases/2022-08-12-kostal-energylogs.sqlite" // databaseName
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(0,0,0)) // simulationStart (UTC)
<< ChargerPlugEvents( { ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(7,0,0)), false),
ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(17,30,0)), true),
ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 15), QTime(7,0,0)), false),
ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 15), QTime(17,0,0)), true)
}) // pluggedInTime (UTC)
<< 48 // simulationHours
<< EnergyLogs::SampleRate1Min
<< 0.0 // productionScaling
<< 0 // detailsStepStart
<< 0 // detailsStepStop
<< DetailsStepList() // detailsStepList
/* Houshold info */
<< 32 // phase limit (A)
<< true // spotMarketEnabled
<< ":/resources/dataset-1.json" // spotMarketResourceData
<< 0.5 // acquisitionTolerance
<< 0.9 // batteryLevelConsideration
/* Charging Info */
<< 100.0 // targetPercentage
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 15), QTime(22,0,0)) // targetDateTime
<< "ChargingModeEco" // chargingMode
<< 20 // carBatteryLevel
<< 20 // dailySpotMarketPercentage
/* Car settings */
<< 50 // carCapacity
<< 6 // carMinChargingCurrent
<< 1 // carPhaseCount
/* Energy storage */
<< false // energyStorageAvailable
<< 0 // energyStorageCapacity
<< 0.0 // energyStorageMaxChargingPower
<< 0.0 // energyStorageMaxDischargingPower
<< 50.0 // energyStorageInitialBatteyLevel
<< true // chargerConnected
<< false // chargerPower
<< "ABC" // chargerPhases
<< false // canSwitchPhaseCount
<< 6 // chargerMaxChargingCurrent
<< 16 //chargerMaxChargingCurrentMaxValue
<< SimulationIterationTest ( {
{
0, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 20)
}
},
{
250, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
}
},
{
500, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
}
},
{
1440, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40) // Should have charged 20% in one day
}
},
{
2800, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
}
},
{
2880, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 60) // Should have charged 20% in one day
}
}
} );
if (runSpotmarketSimulation)
QTest::newRow("Spotmarket and PV")
/* Simulation info */
<< "simulation-spotmarket-and-pv-1-phase-16A" // simulationName
<< "Simulation (1 phase, charger 16A max, spot market and PV)" // simulationTitle
<< ":/databases/2022-08-12-kostal-energylogs.sqlite" // databaseName
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(0,0,0)) // simulationStart (UTC)
<< ChargerPlugEvents()
<< 48 // simulationHours
<< EnergyLogs::SampleRate1Min
<< 0.35 // productionScaling
<< 0 // detailsStepStart
<< 0 // detailsStepStop
<< DetailsStepList() // detailsStepList
/* Houshold info */
<< 32 // phase limit (A)
<< true // spotMarketEnabled
<< ":/resources/dataset-1.json" // spotMarketResourceData
<< 0.5 // acquisitionTolerance
<< 0.9 // batteryLevelConsideration
/* Charging Info */
<< 100.0 // targetPercentage
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 15), QTime(22,0,0)) // targetDateTime
<< "ChargingModeEco" // chargingMode
<< 20 // carBatteryLevel
<< 20 // dailySpotMarketPercentage
/* Car settings */
<< 50 // carCapacity
<< 6 // carMinChargingCurrent
<< 1 // carPhaseCount
/* Energy storage */
<< false // energyStorageAvailable
<< 0 // energyStorageCapacity
<< 0.0 // energyStorageMaxChargingPower
<< 0.0 // energyStorageMaxDischargingPower
<< 50.0 // energyStorageInitialBatteyLevel
<< true // chargerConnected
<< false // chargerPower
<< "ABC" // chargerPhases
<< false // canSwitchPhaseCount
<< 6 // chargerMaxChargingCurrent
<< 16 //chargerMaxChargingCurrentMaxValue
<< SimulationIterationTest ( {
{
0, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 20)
}
},
{
250, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
}
},
{
500, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
}
},
{
580, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
}
},
{
750, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
}
},
{
780, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
}
},
{
810, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
}
},
{
1400, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
}
},
{
2000, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 46) // Should be at least 20% more than the day before...
}
},
{
2250, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
}
},
{
2750, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
}
}
} );
if (runSpotmarketSimulation)
QTest::newRow("Spotmarket with target time")
/* Simulation info */
<< "simulation-spotmarket-only-with-targettime-1-phase-16A" // simulationName
<< "Simulation (1 phase, charger 16A max, only spot market with target time)" // simulationTitle
<< ":/databases/2022-08-12-kostal-energylogs.sqlite" // databaseName
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(0,0,0)) // simulationStart (UTC)
<< ChargerPlugEvents() // Car plug events
<< 48 // simulationHours
<< EnergyLogs::SampleRate1Min
<< 0.0 // productionScaling
<< 0 // detailsStepStart
<< 0 // detailsStepStop
<< DetailsStepList() // detailsStepList
/* Houshold info */
<< 32 // phase limit (A)
<< true // spotMarketEnabled
<< ":/resources/dataset-1.json" // spotMarketResourceData
<< 0.5 // acquisitionTolerance
<< 0.9 // batteryLevelConsideration
/* Charging Info */
<< 100.0 // targetPercentage
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 15), QTime(22,0,0)) // targetDateTime
<< "ChargingModeEcoWithTargetTime" // chargingMode
<< 20 // carBatteryLevel
<< 0 // dailySpotMarketPercentage
/* Car settings */
<< 50 // carCapacity
<< 6 // carMinChargingCurrent
<< 1 // carPhaseCount
/* Energy storage */
<< false // energyStorageAvailable
<< 0 // energyStorageCapacity
<< 0.0 // energyStorageMaxChargingPower
<< 0.0 // energyStorageMaxDischargingPower
<< 50.0 // energyStorageInitialBatteyLevel
<< true // chargerConnected
<< false // chargerPower
<< "ABC" // chargerPhases
<< false // canSwitchPhaseCount
<< 6 // chargerMaxChargingCurrent
<< 16 //chargerMaxChargingCurrentMaxValue
<< SimulationIterationTest ( {
{
0, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 20)
}
},
{
700, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
}
},
{
1200, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
}
},
{
1400, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
}
},
{
1550, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
}
},
{
1700, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
}
},
{
2760, { // 22:00
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
}
}
} );
if (runSpotmarketSimulation)
QTest::newRow("Spotmarket only with target time")
/* Simulation info */
<< "simulation-spotmarket-only-with-targettime-1-day-1-phase-16A" // simulationName
<< "Simulation (1 phase, charger 16A max, only spot market with target time single day)" // simulationTitle
<< ":/databases/2022-08-12-kostal-energylogs.sqlite" // databaseName
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(0, 0, 0)) // simulationStart (UTC)
<< ChargerPlugEvents()
<< 32 // simulationHours
<< EnergyLogs::SampleRate1Min
<< 0.0 // productionScaling
<< 0 // detailsStepStart
<< 0 // detailsStepStop
<< DetailsStepList() // detailsStepList
/* Houshold info */
<< 32 // phase limit (A)
<< true // spotMarketEnabled
<< ":/resources/dataset-1.json" // spotMarketResourceData
<< 0.5 // acquisitionTolerance
<< 0.9 // batteryLevelConsideration
/* Charging Info */
<< 100.0 // targetPercentage
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 15), QTime(07, 0, 0)) // targetDateTime
<< "ChargingModeEcoWithTargetTime" // chargingMode
<< 50 // carBatteryLevel
<< 0 // dailySpotMarketPercentage
/* Car settings */
<< 50 // carCapacity
<< 6 // carMinChargingCurrent
<< 1 // carPhaseCount
/* Energy storage */
<< false // energyStorageAvailable
<< 0 // energyStorageCapacity
<< 0.0 // energyStorageMaxChargingPower
<< 0.0 // energyStorageMaxDischargingPower
<< 50.0 // energyStorageInitialBatteyLevel
<< true // chargerConnected
<< false // chargerPower
<< "ABC" // chargerPhases
<< false // canSwitchPhaseCount
<< 6 // chargerMaxChargingCurrent
<< 16 //chargerMaxChargingCurrentMaxValue
<< SimulationIterationTest ( {
{
0, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 50)
}
},
{
250, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 61)
}
},
{
500, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
}
},
{
1400, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
}
}
});
if (run1PhaseSimulations)
QTest::newRow("Default")
/* Simulation info */
<< "simulation-1-phase-32A" // simulationName
<< "Simulation (1 phase, charger 32A max, target 22:00 100%" // simulationTitle
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)) // simulationStart (UTC)
<< ChargerPlugEvents() // pluggedInTime (UTC)
<< 18 // simulationHours
<< EnergyLogs::SampleRate1Min
<< 20.0 // productionScaling
<< 0 // detailsStepStart
<< 0 // detailsStepStop
<< DetailsStepList() // detailsStepList
/* Houshold info */
<< 32 // phase limit (A)
<< false // spotMarketEnabled
<< ":/resources/dataset-1.json" // spotMarketResourceData
<< 0.5 // acquisitionTolerance
<< 0.9 // batteryLevelConsideration
/* Charging Info */
<< 100.0 // targetPercentage
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
<< "ChargingModeEcoWithTargetTime" // chargingMode
<< 40 // carBatteryLevel
<< 0 // dailySpotMarketPercentage
/* Car settings */
<< 50 // carCapacity
<< 6 // carMinChargingCurrent
<< 1 // carPhaseCount
/* Energy storage */
<< false // energyStorageAvailable
<< 0 // energyStorageCapacity
<< 0.0 // energyStorageMaxChargingPower
<< 0.0 // energyStorageMaxDischargingPower
<< 50.0 // energyStorageInitialBatteyLevel
<< true // chargerConnected
<< false // chargerPower
<< "ABC" // chargerPhases
<< false // canSwitchPhaseCount
<< 6 // chargerMaxChargingCurrent
<< 32 //chargerMaxChargingCurrentMaxValue
<< SimulationIterationTest ( {
{
0, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
}
},
{
163, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
}
},
{
170, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 9),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
}
},
{
444, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 7) ,
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
}
},
{
520, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6) ,
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 80)
}
},
{
700, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6) ,
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 85)
}
},
{
900, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 30) ,
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 88)
}
},
{
960, { // 22:00
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 30) ,
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
}
}
} );
if (run1PhaseSimulations)
QTest::newRow("Default")
/* Simulation info */
<< "simulation-1-phase-16A" // simulationName
<< "Simulation (1 phase, charger 16A max, target 22:00 100%)" // simulationTitle
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(0,0,0)) // simulationStart (UTC)
<< ChargerPlugEvents( { ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)), true) }) // pluggedInTime (UTC)
<< 24 // simulationHours
<< EnergyLogs::SampleRate1Min
<< 20.0 // productionScaling
<< 0 // detailsStepStart
<< 0 // detailsStepStop
<< DetailsStepList() // detailsStepList
/* Houshold info */
<< 32 // phase limit (A)
<< false // spotMarketEnabled
<< ":/resources/dataset-1.json" // spotMarketResourceData
<< 0.5 // acquisitionTolerance
<< 0.9 // batteryLevelConsideration
/* Charging Info */
<< 100.0 // targetPercentage
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
<< "ChargingModeEcoWithTargetTime" // chargingMode
<< 50 // carBatteryLevel
<< 0 // dailySpotMarketPercentage
/* Car settings */
<< 50 // carCapacity
<< 6 // carMinChargingCurrent
<< 1 // carPhaseCount
/* Energy storage */
<< false // energyStorageAvailable
<< 0 // energyStorageCapacity
<< 0.0 // energyStorageMaxChargingPower
<< 0.0 // energyStorageMaxDischargingPower
<< 50.0 // energyStorageInitialBatteyLevel
<< true // chargerConnected
<< false // chargerPower
<< "ABC" // chargerPhases
<< false // canSwitchPhaseCount
<< 6 // chargerMaxChargingCurrent
<< 16 //chargerMaxChargingCurrentMaxValue
<< SimulationIterationTest ( {
{
0, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 50)
}
},
{
452, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 50)
}
},
{
467, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 9),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 51)
}
},
{
750, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
}
},
{
830, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
}
},
{
841, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
}
},
{
1000, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
}
},
{
1300, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
}
},
{
1440, { // 22:00
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
}
}
} );
if (run2PhaseSimulations)
QTest::newRow("Kostal")
/* Simulation info */
<< "simulation-kostal-2-phase-16A" // simulationName
<< "Simulation (2 phase, charger 16A max, target 22:00 100%)" // simulationTitle
<< ":/databases/2022-08-12-kostal-energylogs.sqlite" // databaseName
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(0,0,0)) // simulationStart (UTC)
<< ChargerPlugEvents( { ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(8,0,0)), true) }) // pluggedInTime (UTC)
<< 24 // simulationHours
<< EnergyLogs::SampleRate1Min
<< 1.0 // productionScaling
<< 0 // detailsStepStart
<< 0 // detailsStepStop
<< DetailsStepList() // detailsStepList
/* Houshold info */
<< 32 // phase limit (A)
<< false // spotMarketEnabled
<< ":/resources/dataset-1.json" // spotMarketResourceData
<< 0.5 // acquisitionTolerance
<< 0.9 // batteryLevelConsideration
/* Charging Info */
<< 100.0 // targetPercentage
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(22,0,0)) // targetDateTime
<< "ChargingModeEcoWithTargetTime" // chargingMode
<< 40 // carBatteryLevel
<< 0 // dailySpotMarketPercentage
/* Car settings */
<< 50 // carCapacity
<< 6 // carMinChargingCurrent
<< 2 // carPhaseCount
/* Energy storage */
<< false // energyStorageAvailable
<< 0 // energyStorageCapacity
<< 0.0 // energyStorageMaxChargingPower
<< 0.0 // energyStorageMaxDischargingPower
<< 50.0 // energyStorageInitialBatteyLevel
<< true // chargerConnected
<< false // chargerPower
<< "ABC" // chargerPhases
<< false // canSwitchPhaseCount
<< 6 // chargerMaxChargingCurrent
<< 16 //chargerMaxChargingCurrentMaxValue
<< SimulationIterationTest ( {
{
0, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
}
},
{
550, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
}
},
{
600, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 9),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 48)
}
},
{
820, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
}
},
{
823, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
}
},
{
847, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 80)
}
},
{
1050, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
}
}
} );
if (run2PhaseSimulations)
QTest::newRow("Kostal")
/* Simulation info */
<< "simulation-kostal-2-phase-16A-away-2-hours" // simulationName
<< "Simulation (2 phase, charger 16A max, target 22:00 100%)" // simulationTitle
<< ":/databases/2022-08-12-kostal-energylogs.sqlite" // databaseName
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(0,0,0)) // simulationStart (UTC)
<< ChargerPlugEvents( {
ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(12,0,0)), false),
ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(14,00,0)), true, 10)
}) // pluggedInTime (UTC)
<< 24 // simulationHours
<< EnergyLogs::SampleRate1Min
<< 1.0 // productionScaling
<< 0 // detailsStepStart
<< 0 // detailsStepStop
<< DetailsStepList() // detailsStepList
/* Houshold info */
<< 32 // phase limit (A)
<< false // spotMarketEnabled
<< ":/resources/dataset-1.json" // spotMarketResourceData
<< 0.5 // acquisitionTolerance
<< 0.9 // batteryLevelConsideration
/* Charging Info */
<< 100.0 // targetPercentage
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(22,0,0)) // targetDateTime
<< "ChargingModeEcoWithTargetTime" // chargingMode
<< 40 // carBatteryLevel
<< 0 // dailySpotMarketPercentage
/* Car settings */
<< 50 // carCapacity
<< 6 // carMinChargingCurrent
<< 2 // carPhaseCount
/* Energy storage */
<< false // energyStorageAvailable
<< 0 // energyStorageCapacity
<< 0.0 // energyStorageMaxChargingPower
<< 0.0 // energyStorageMaxDischargingPower
<< 50.0 // energyStorageInitialBatteyLevel
<< true // chargerConnected
<< false // chargerPower
<< "ABC" // chargerPhases
<< false // canSwitchPhaseCount
<< 6 // chargerMaxChargingCurrent
<< 16 //chargerMaxChargingCurrentMaxValue
<< SimulationIterationTest ( {
{
0, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
}
},
{
550, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
}
},
{
600, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 9),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 48)
}
},
{
1300, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
}
},
{
1440, { // 22:00
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
}
}
} );
if (run2PhaseSimulations)
QTest::newRow("Default")
/* Simulation info */
<< "simulation-2-phase-16A" // simulationName
<< "Simulation (2 phase, charger 16A max, target 22:00 100%)" // simulationTitle
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)) // simulationStart (UTC)
<< ChargerPlugEvents() // pluggedInTime (UTC)
<< 18 // simulationHours
<< EnergyLogs::SampleRate1Min
<< 20.0 // productionScaling
<< 0 // detailsStepStart
<< 0 // detailsStepStop
<< DetailsStepList() // detailsStepList
/* Houshold info */
<< 32 // phase limit (A)
<< false // spotMarketEnabled
<< ":/resources/dataset-1.json" // spotMarketResourceData
<< 0.5 // acquisitionTolerance
<< 0.9 // batteryLevelConsideration
/* Charging Info */
<< 100.0 // targetPercentage
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
<< "ChargingModeEcoWithTargetTime" // chargingMode
<< 40 // carBatteryLevel
<< 0 // dailySpotMarketPercentage
/* Car settings */
<< 50 // carCapacity
<< 6 // carMinChargingCurrent
<< 2 // carPhaseCount
/* Energy storage */
<< false // energyStorageAvailable
<< 0 // energyStorageCapacity
<< 0.0 // energyStorageMaxChargingPower
<< 0.0 // energyStorageMaxDischargingPower
<< 50.0 // energyStorageInitialBatteyLevel
<< true // chargerConnected
<< false // chargerPower
<< "ABC" // chargerPhases
<< false // canSwitchPhaseCount
<< 6 // chargerMaxChargingCurrent
<< 16 //chargerMaxChargingCurrentMaxValue
<< SimulationIterationTest ( {
{
0, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
}
},
{
850, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
}
},
{
900, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
}
},
{
960, { // 22:00
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
}
}
} );
if (run2PhaseSimulations)
QTest::newRow("Default")
/* Simulation info */
<< "simulation-2-phase-32A" // simulationName
<< "Simulation (2 phase, charger 32A max, target 22:00 100%)" // simulationTitle
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)) // simulationStart (UTC)
<< ChargerPlugEvents() // pluggedInTime (UTC)
<< 18 // simulationHours
<< EnergyLogs::SampleRate1Min
<< 20.0 // productionScaling
<< 0 // detailsStepStart
<< 0 // detailsStepStop
<< DetailsStepList() // detailsStepList
/* Houshold info */
<< 32 // phase limit (A)
<< false // spotMarketEnabled
<< ":/resources/dataset-1.json" // spotMarketResourceData
<< 0.5 // acquisitionTolerance
<< 0.9 // batteryLevelConsideration
/* Charging Info */
<< 100.0 // targetPercentage
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
<< "ChargingModeEcoWithTargetTime" // chargingMode
<< 40 // carBatteryLevel
<< 0 // dailySpotMarketPercentage
/* Car settings */
<< 50 // carCapacity
<< 6 // carMinChargingCurrent
<< 2 // carPhaseCount
/* Energy storage */
<< false // energyStorageAvailable
<< 0 // energyStorageCapacity
<< 0.0 // energyStorageMaxChargingPower
<< 0.0 // energyStorageMaxDischargingPower
<< 50.0 // energyStorageInitialBatteyLevel
<< true // chargerConnected
<< false // chargerPower
<< "ABC" // chargerPhases
<< false // canSwitchPhaseCount
<< 6 // chargerMaxChargingCurrent
<< 32 //chargerMaxChargingCurrentMaxValue
<< SimulationIterationTest ( {
{
0, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
}
},
{
100, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
}
},
{
300, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 9),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 61)
}
},
{
470, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
}
},
{
586, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
}
},
{
800, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
}
},
{
950, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 30),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 99)
}
},
{
960, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
}
}
} );
if (run3PhaseSimulations)
QTest::newRow("Default")
/* Simulation info */
<< "simulation-3-phase-16A" // simulationName
<< "Simulation (3 phase, charger 16A max, target 22:00 100%)" // simulationTitle
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)) // simulationStart (UTC)
<< ChargerPlugEvents() // pluggedInTime (UTC)
<< 18 // simulationHours
<< EnergyLogs::SampleRate1Min
<< 40.0 // productionScaling
<< 0 // detailsStepStart
<< 0 // detailsStepStop
<< DetailsStepList({200, 300, 400, 500}) // detailsStepList
/* Houshold info */
<< 32 // phase limit (A)
<< false // spotMarketEnabled
<< ":/resources/dataset-1.json" // spotMarketResourceData
<< 0.5 // acquisitionTolerance
<< 0.9 // batteryLevelConsideration
/* Charging Info */
<< 100.0 // targetPercentage
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
<< "ChargingModeEcoWithTargetTime" // chargingMode
<< 40 // carBatteryLevel
<< 0 // dailySpotMarketPercentage
/* Car settings */
<< 50 // carCapacity
<< 6 // carMinChargingCurrent
<< 3 // carPhaseCount
/* Energy storage */
<< false // energyStorageAvailable
<< 0 // energyStorageCapacity
<< 0.0 // energyStorageMaxChargingPower
<< 0.0 // energyStorageMaxDischargingPower
<< 50.0 // energyStorageInitialBatteyLevel
<< true // chargerConnected
<< false // chargerPower
<< "ABC" // chargerPhases
<< false // canSwitchPhaseCount
<< 6 // chargerMaxChargingCurrent
<< 16 //chargerMaxChargingCurrentMaxValue
<< SimulationIterationTest ( {
{
0, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
}
},
{
200, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 11),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 57)
}
},
{
300, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 13),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 83)
}
},
{
400, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 11),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
}
},
{
700, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
}
}
} );
if (run3PhaseSimulations)
QTest::newRow("Default")
/* Simulation info */
<< "simulation-3-phase-32A" // simulationName
<< "Simulation (3 phase, charger 32A max, target 22:00 100%)" // simulationTitle
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)) // simulationStart (UTC)
<< ChargerPlugEvents() // pluggedInTime (UTC)
<< 18 // simulationHours
<< EnergyLogs::SampleRate1Min
<< 20.0 // productionScaling
<< 0 // detailsStepStart
<< 0 // detailsStepStop
<< DetailsStepList({ 480, 500, 600, 950, 960 }) // detailsStepList
/* Houshold info */
<< 32 // phase limit (A)
<< false // spotMarketEnabled
<< ":/resources/dataset-1.json" // spotMarketResourceData
<< 0.5 // acquisitionTolerance
<< 0.9 // batteryLevelConsideration
/* Charging Info */
<< 100.0 // targetPercentage
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
<< "ChargingModeEcoWithTargetTime" // chargingMode
<< 40 // carBatteryLevel
<< 0 // dailySpotMarketPercentage
/* Car settings */
<< 50 // carCapacity
<< 6 // carMinChargingCurrent
<< 3 // carPhaseCount
/* Energy storage */
<< false // energyStorageAvailable
<< 0 // energyStorageCapacity
<< 0.0 // energyStorageMaxChargingPower
<< 0.0 // energyStorageMaxDischargingPower
<< 50.0 // energyStorageInitialBatteyLevel
<< true // chargerConnected
<< false // chargerPower
<< "ABC" // chargerPhases
<< false // canSwitchPhaseCount
<< 6 // chargerMaxChargingCurrent
<< 32 //chargerMaxChargingCurrentMaxValue
<< SimulationIterationTest ( {
{
100, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
}
},
{
200, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
}
},
{
300, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
}
},
{
400, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
}
},
{
480, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
}
},
{
500, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 82)
}
},
{
600, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
}
},
{
950, {
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 98) // Finish 10 min early
}
},
{
960, { // 22:00
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
}
}
} );
if (runPhaseSwitchingSimulations)
QTest::newRow("Default")
/* Simulation info */
<< "simulation-phase-switching-16A" // simulationName
<< "Simulation (phase switching, charger 16A max, surplus only)" // simulationTitle
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)) // simulationStart (UTC)
<< ChargerPlugEvents() // pluggedInTime (UTC)
<< 18 // simulationHours
<< EnergyLogs::SampleRate1Min
<< 45.0 // productionScaling
<< 0 // detailsStepStart
<< 0 // detailsStepStop
<< DetailsStepList({80, 150, 310, 400, 470}) // detailsStepList
/* Houshold info */
<< 32 // phase limit (A)
<< false // spotMarketEnabled
<< ":/resources/dataset-1.json" // spotMarketResourceData
<< 0.5 // acquisitionTolerance
<< 0.9 // batteryLevelConsideration
/* Charging Info */
<< 100.0 // targetPercentage
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
<< "ChargingModeEco" // chargingMode
<< 40 // carBatteryLevel
<< 0 // dailySpotMarketPercentage
/* Car settings */
<< 50 // carCapacity
<< 6 // carMinChargingCurrent
<< 3 // carPhaseCount
/* Energy storage */
<< false // energyStorageAvailable
<< 0 // energyStorageCapacity
<< 0.0 // energyStorageMaxChargingPower
<< 0.0 // energyStorageMaxDischargingPower
<< 50.0 // energyStorageInitialBatteyLevel
<< true // chargerConnected
<< false // chargerPower
<< "ABC" // chargerPhases
<< true // canSwitchPhaseCount
<< 6 // chargerMaxChargingCurrent
<< 16 //chargerMaxChargingCurrentMaxValue
<< SimulationIterationTest ( {
{
0, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
}
},
{
80, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
}
},
{
150, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 10),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 50)
}
},
{
310, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 91)
}
},
{
400, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 13),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
}
},
{
470, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 9),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
}
},
} );
if (runPhaseSwitchingSimulations)
QTest::newRow("Default")
/* Simulation info */
<< "simulation-energy-storage-phase-switching" // simulationName
<< "Simulation energy storage, phase switching" // simulationTitle
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(0,0,0)) // simulationStart (UTC)
<< ChargerPlugEvents() // pluggedInTime (UTC)
<< 36 // simulationHours
<< EnergyLogs::SampleRate1Min
<< 45.0 // productionScaling
<< 0 // detailsStepStart
<< 10 // detailsStepStop
<< DetailsStepList({}) // detailsStepList
/* Houshold info */
<< 32 // phase limit (A)
<< false // spotMarketEnabled
<< ":/resources/dataset-1.json" // spotMarketResourceData
<< 0.5 // acquisitionTolerance
<< 0.9 // batteryLevelConsideration
/* Charging Info */
<< 100.0 // targetPercentage
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
<< "ChargingModeEco" // chargingMode
<< 40 // carBatteryLevel
<< 0 // dailySpotMarketPercentage
/* Car settings */
<< 50 // carCapacity
<< 6 // carMinChargingCurrent
<< 3 // carPhaseCount
/* Energy storage */
<< true // energyStorageAvailable
<< 12 // energyStorageCapacity
<< 5000.0 // energyStorageMaxChargingPower
<< 5000.0 // energyStorageMaxDischargingPower
<< 10.0 // energyStorageInitialBatteyLevel
<< true // chargerConnected
<< false // chargerPower
<< "ABC" // chargerPhases
<< true // canSwitchPhaseCount
<< 6 // chargerMaxChargingCurrent
<< 16 //chargerMaxChargingCurrentMaxValue
<< SimulationIterationTest ( {
{
0, {
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
}
}
} );
}
void Simulation::run()
{
QFETCH(QString, simulationName);
QFETCH(QString, simulationTitle);
QFETCH(QString, databaseName);
QFETCH(QDateTime, simulationStart);
QFETCH(ChargerPlugEvents, plugEvents);
QFETCH(int, simulationHours);
QFETCH(EnergyLogs::SampleRate, sampleRate);
QFETCH(double, productionScaling);
QFETCH(int, detailsStepStart);
QFETCH(int, detailsStepStop);
QFETCH(DetailsStepList, detailsStepList);
QFETCH(int, phasePowerLimit);
QFETCH(bool, spotMarketEnabled);
QFETCH(QString, spotMarketResourceData);
QFETCH(double, acquisitionTolerance);
QFETCH(double, batteryLevelConsideration);
QFETCH(double, targetPercentage);
QFETCH(QDateTime, targetDateTime);
QFETCH(QString, chargingMode);
QFETCH(int, carBatteryLevel);
QFETCH(int, dailySpotMarketPercentage);
QFETCH(int, carCapacity);
QFETCH(int, carMinChargingCurrent);
QFETCH(int, carPhaseCount);
QFETCH(bool, energyStorageAvailable);
QFETCH(int, energyStorageCapacity);
QFETCH(double, energyStorageMaxChargingPower);
QFETCH(double, energyStorageMaxDischargingPower);
QFETCH(double, energyStorageInitialBatteyLevel);
QFETCH(bool, chargerConnected);
QFETCH(bool, chargerPower);
QFETCH(QString, chargerPhases);
QFETCH(bool, canSwitchPhaseCount);
QFETCH(int, chargerMaxChargingCurrent);
QFETCH(int, chargerMaxChargingCurrentMaxValue);
QFETCH(SimulationIterationTest, iterationTest);
QStringList loggingDefaultList = {
"*.debug=false",
"Application.debug=true",
"LogEngine.info=false",
"Simulation.debug=true",
"Experiences.debug=false",
"NymeaEnergy.debug=false",
"EnergyMocks.debug=false",
"DBus.warning=false",
};
QString loggingRulesDefault = loggingDefaultList.join("\n");
QStringList loggingDetailsList = {
"*.debug=false",
"Application.debug=true",
"LogEngine.info=false",
"Simulation.debug=true",
"Experiences.debug=false",
"NymeaEnergy.debug=true",
"EnergyMocks.debug=false",
"DBus.warning=false",
};
QString loggingRulesDetails = loggingDetailsList.join("\n");
QStringList availableChargingModes;
availableChargingModes << "ChargingModeNormal";
availableChargingModes << "ChargingModeEco";
availableChargingModes << "ChargingModeEcoWithTargetTime";
QVERIFY2(availableChargingModes.contains(chargingMode), "Unknown charging mode passed to the simulation. Please compair the list with the ChargingMode enum.");
if (canSwitchPhaseCount)
QVERIFY2(chargerPhases == "ABC", "If the charger supports phase count switching all 3 phases must be connected.");
cleanupTestCase();
m_energyLogDbFilePath = databaseName;
initTestCase(loggingRulesDefault);
// Print simulation init details
QLoggingCategory::setFilterRules(loggingRulesDefault);
QVariant response; QVariantMap params;
QNetworkReply *reply = nullptr;
QSignalSpy packetSpy(m_mockTcpServer, &MockTcpServer::outgoingData);;
Electricity::Phases chargerPhasesConverted = Electricity::convertPhasesFromString(chargerPhases);
// Set phase power limit
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
// Add mock spotmarket provider
SpotMarketManager *spotMarketManager = m_experiencePlugin->spotMarketManager();
SpotMarketDataProviderMock *mockProvider = new SpotMarketDataProviderMock(nullptr, this);
QVERIFY(mockProvider->prepareResourceData(spotMarketResourceData, simulationStart.toUTC()));
QVERIFY(spotMarketManager->registerProvider(mockProvider));
QVERIFY(spotMarketManager->changeProvider(mockProvider->providerId()));
// Enabke/disable spot market
response = injectAndWait("NymeaEnergy.SetSpotMarketConfiguration", QVariantMap({ {"enabled", spotMarketEnabled }, {"providerId", mockProvider->providerId()} }));
QCOMPARE(response.toMap().value("params").toMap().value("energyError").toString(), "EnergyErrorNoError");
QCOMPARE(m_experiencePlugin->spotMarketManager()->enabled(), spotMarketEnabled);
// Set initial acquisition tolerance
params.clear(); response.clear();
params.insert("acquisitionTolerance", acquisitionTolerance);
response = injectAndWait("NymeaEnergy.SetAcquisitionTolerance", params);
verifyEnergyError(response);
// Set battery level consideration
params.clear(); response.clear();
params.insert("batteryLevelConsideration", batteryLevelConsideration);
response = injectAndWait("NymeaEnergy.SetBatteryLevelConsideration", params);
verifyEnergyError(response);
// Add mock meter
QUuid meterThingId = addMeter();
QVERIFY2(!meterThingId.isNull(), "Did not receive valid ThingId");
if (packetSpy.count() == 0) packetSpy.wait();
checkNotification(packetSpy, "Integrations.ThingAdded");
packetSpy.clear();
// Set it as root meter
m_experiencePlugin->energyManager()->setRootMeter(meterThingId);
// Make sure this is our root meter now
response = injectAndWait("Energy.GetRootMeter");
QCOMPARE(response.toMap().value("params").toMap().value("rootMeterThingId").toUuid(), meterThingId);
packetSpy.clear();
// Add the charger
QUuid evChargerId;
if (canSwitchPhaseCount) {
evChargerId = addChargerWithPhaseCountSwitching(chargerPhases, chargerMaxChargingCurrentMaxValue);
} else {
evChargerId = addCharger(chargerPhases, chargerMaxChargingCurrentMaxValue);
}
QVERIFY2(!evChargerId.isNull(), "Did not receive valid ThingId");
if (packetSpy.count() == 0) packetSpy.wait();
checkNotification(packetSpy, "Integrations.ThingAdded");
// Add the car
QUuid carThingId = addCar();
QVERIFY2(!carThingId.isNull(), "Did not receive valid ThingId");
if (packetSpy.count() == 0) packetSpy.wait();
checkNotification(packetSpy, "Integrations.ThingAdded");
// Add energy storage if available
QUuid energyStorageThingId;
if (energyStorageAvailable) {
energyStorageThingId = addEnergyStorage(energyStorageCapacity, energyStorageMaxChargingPower, energyStorageMaxDischargingPower);
QVERIFY2(!energyStorageThingId.isNull(), "Did not receive valid ThingId");
if (packetSpy.count() == 0) packetSpy.wait();
checkNotification(packetSpy, "Integrations.ThingAdded");
}
// ==============================================================================
// Set states of car and charger
Thing *carThing = NymeaCore::instance()->thingManager()->findConfiguredThing(carThingId);
QVERIFY2(carThing != nullptr, "Failed to find car thing");
carThing->setSettingValue(carThing->thingClass().settingsTypes().findByName("phaseCount").id(), carPhaseCount);
carThing->setSettingValue(carThing->thingClass().settingsTypes().findByName("capacity").id(), carCapacity);
carThing->setSettingValue(carThing->thingClass().settingsTypes().findByName("minChargingCurrent").id(), carMinChargingCurrent);
carThing->setStateValue("batteryLevel", carBatteryLevel);
Thing *chargerThing = NymeaCore::instance()->thingManager()->findConfiguredThing(evChargerId);
QVERIFY2(chargerThing != nullptr, "Failed to find charger thing");
chargerThing->setStateValue("connected", chargerConnected);
chargerThing->setStateValue("power", chargerPower);
chargerThing->setStateValue("maxChargingCurrent", chargerMaxChargingCurrent);
chargerThing->setStateValue("maxChargingCurrentMaxValue", chargerMaxChargingCurrentMaxValue);
chargerThing->setStateValue("pluggedIn", true); // Initially always plugged in, the rest can be handeld using the plug events
// This will update all internal states not set directly
updateChargerMeter(chargerThing);
Thing *meterThing = NymeaCore::instance()->thingManager()->findConfiguredThing(meterThingId);
QVERIFY2(meterThing != nullptr, "Failed to find meter thing");
meterThing->setStateValue("connected", true);
// Energy storage states
Thing *energyStorageThing = nullptr;
if (energyStorageAvailable) {
energyStorageThing = NymeaCore::instance()->thingManager()->findConfiguredThing(energyStorageThingId);
energyStorageThing->setStateValue("currentPower", 0);
energyStorageThing->setStateValue("batteryLevel", energyStorageInitialBatteyLevel);
energyStorageThing->setProperty("preciseBatteryLevel", energyStorageInitialBatteyLevel * 1.0); // For precise runtime calculations
printStates(energyStorageThing);
}
// printStates(chargerThing);
// printStates(carThing);
// printStates(meterThing);
// Set charging info with our charger and car, this should trigger the evaluation
QVariantMap chargingInfoMap;
chargingInfoMap.insert("evChargerId", evChargerId);
chargingInfoMap.insert("assignedCarId", carThingId);
chargingInfoMap.insert("chargingMode", chargingMode);
chargingInfoMap.insert("endDateTime", targetDateTime.toMSecsSinceEpoch() / 1000);
chargingInfoMap.insert("targetPercentage", targetPercentage);
chargingInfoMap.insert("spotMarketChargingEnabled", spotMarketEnabled);
chargingInfoMap.insert("dailySpotMarketPercentage", dailySpotMarketPercentage);
response = injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", chargingInfoMap}}));
QCOMPARE(response.toMap().value("status").toString(), QString("success"));
QCOMPARE(response.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError"));
uint effectivePhaseCount;
if (canSwitchPhaseCount) {
effectivePhaseCount = chargerThing->stateValue("phaseCount").toUInt();
} else {
effectivePhaseCount = qMin((uint)carPhaseCount, Electricity::getPhaseCount(chargerPhasesConverted));
}
QString usedPhases;
if (effectivePhaseCount >= 1)
usedPhases.append("A");
if (effectivePhaseCount >= 2)
usedPhases.append("B");
if (effectivePhaseCount >= 3)
usedPhases.append("C");
chargerThing->setStateValue("usedPhases", usedPhases);
// Note: we want to have the limit negative, so we see better where the limit is and why the chaging stopped
double aquisitionToleranceLimit = -(effectivePhaseCount * carMinChargingCurrent * 230.0 * acquisitionTolerance);
// Simulation output
QDir simulationBaseDir = QDir(QDir::currentPath() + QDir::separator() + "simulations");
QDir workspaceDir = QDir(simulationBaseDir.absolutePath() + QDir::separator() + "workspace");
if (!workspaceDir.exists()) {
//QVERIFY2(outputDir.removeRecursively(), "Failed to cleanup output dir");
QVERIFY2(workspaceDir.mkpath(workspaceDir.path()), "Failed to create results dir");
}
QDir outputDir = QDir(workspaceDir.absolutePath() + QDir::separator() + simulationName);
if (!outputDir.exists()) {
// QVERIFY2(outputDir.removeRecursively(), "Failed to cleanup output dir");
QVERIFY2(outputDir.mkpath(outputDir.path()), "Failed to create output dir");
}
QDir resultsDir = QDir(simulationBaseDir.absolutePath() + QDir::separator() + "results");
if (!resultsDir.exists()) {
//QVERIFY2(outputDir.removeRecursively(), "Failed to cleanup output dir");
QVERIFY2(resultsDir.mkpath(resultsDir.path()), "Failed to create results dir");
}
QFile gnuplotLogFile(outputDir.path() + QDir::separator() + "simulation.csv");
QVERIFY2(gnuplotLogFile.open(QIODevice::ReadWrite | QIODevice::Truncate), QString("Failed to open logfile for gnuplot" + gnuplotLogFile.fileName() + ": " + gnuplotLogFile.errorString()).toUtf8());
QTextStream gnuplotLogFileStream(&gnuplotLogFile);
QFile gnuplotOriginalLogFile(outputDir.path() + QDir::separator() + "original.csv");
QVERIFY2(gnuplotOriginalLogFile.open(QIODevice::ReadWrite | QIODevice::Truncate), "Failed to open logfile for gnuplot");
QTextStream gnuplotOriginalLogFileStream(&gnuplotOriginalLogFile);
// ==============================================================================
// All set up, lets start simulating
QDateTime simulationEnd = simulationStart.addSecs(simulationHours * 3600);
PowerBalanceLogEntries powerBalanceLogs = m_experiencePlugin->energyManager()->logs()->powerBalanceLogs(sampleRate, simulationStart, simulationEnd);
qCDebug(dcSimulation()) << "Simulation start" << simulationStart;
qCDebug(dcSimulation()) << "Simulation end" << simulationEnd;
qCDebug(dcSimulation()) << "Loaded" << powerBalanceLogs.count() << "log entries for that day";
// Simulation helpers
int simulationProgress = 0;
// Disable init details
QLoggingCategory::setFilterRules(loggingRulesDefault);
// Run the simulation
for (int i = 0; i < powerBalanceLogs.count(); i++) {
bool debugEnabled = (i >= detailsStepStart && i <= detailsStepStop) || detailsStepList.contains(i);
const PowerBalanceLogEntry entry = powerBalanceLogs.at(i);
QDateTime currentDateTime = entry.timestamp().toUTC();
// Calculate progress
if (debugEnabled) {
qCDebug(dcSimulation()) << "###############################################################################################################";
qCDebug(dcSimulation()) << "Step" << i << ":" << currentDateTime.toUTC().toString("yyyy.MM.dd hh:mm");
}
effectivePhaseCount = chargerThing->stateValue("phaseCount").toUInt();
usedPhases = chargerThing->stateValue("usedPhases").toString();
// Update mocked spotmarket data provider
mockProvider->setCurrentDataTime(currentDateTime.toUTC());
// Print simulation progress
double simulationProgressPrecise = i * 100 / powerBalanceLogs.count();
if (simulationProgress != qRound(simulationProgressPrecise)) {
simulationProgress = qRound(simulationProgressPrecise);
if (simulationProgress % 10 == 0) {
qCDebug(dcSimulation()) << simulationName << currentDateTime.toUTC().toString("hh:mm") << simulationProgress << "% (" << i << ")";
}
}
// Enable logs in the interesting simulation steps
if (debugEnabled) {
QLoggingCategory::setFilterRules(loggingRulesDetails);
} else {
QLoggingCategory::setFilterRules(loggingRulesDefault);
}
// Get the current situation befor running the simulation step
double totalProduction = entry.production() * productionScaling;
double totalProductionDifference = totalProduction - entry.production();
double scaledCurrentPower = entry.acquisition() + totalProductionDifference;
//qCDebug(dcSimulation()) << "Scale production using" << productionScaling << entry.production() << "-->" << totalProduction << totalProductionDifference;
//qCDebug(dcSimulation()) << "Scale aquisition using" << entry.acquisition() << "-->" << scaledCurrentPower;
// Distribute the load before battery and charger on 3 phases
double phaseLoad = scaledCurrentPower / 3;
// -------------------- Charger
// Handle plug events
foreach(const ChargerPlugEvent &plugEvent, plugEvents) {
if (plugEvent.dateTime.date() == currentDateTime.toUTC().date() &&
plugEvent.dateTime.time().hour() == currentDateTime.toUTC().time().hour() &&
plugEvent.dateTime.time().minute() == currentDateTime.toUTC().time().minute()) {
// Plug event
chargerThing->setStateValue("pluggedIn", plugEvent.pluggedIn);
if (plugEvent.percentageUsed != 0) {
double batteryLevel = carThing->stateValue("batteryLevel").toDouble();
batteryLevel -= plugEvent.percentageUsed;
if (batteryLevel < 0) {
batteryLevel = 0;
}
qCDebug(dcSimulation()) << "-->" << currentDateTime.toUTC().toString("hh:mm") << "New car battery level" << batteryLevel << "(old:" << carThing->stateValue("batteryLevel").toDouble() << ")";
carThing->setStateValue("batteryLevel", batteryLevel);
}
qCDebug(dcSimulation()) << "-->" << currentDateTime.toUTC().toString("hh:mm") << "Car has been" << (plugEvent.pluggedIn ? "plugged in" : "unplugged");
}
}
// Let the charger set all power, voltage etc....
updateChargerMeter(chargerThing);
double chargerCurrentPower = chargerThing->stateValue("currentPower").toDouble();
double totalConsumption = chargerCurrentPower + entry.consumption();
// Totals before battery
double totalCurrentPower = totalConsumption + totalProduction;
// All producers and all consumers have been summed up, charge / discharge the battery and create a final total balance
// -------------------- Energy storage
double energyStorageCurrentPower = 0;
uint energyStorageBatteryLevel = 0;
// All consumers should have what they get, put the rest into or from the storage within limits
if (energyStorageAvailable) {
// Calculate the new battery level depending on the previouse step.
double esCurrentPower = energyStorageThing->stateValue("currentPower").toDouble();
double esBatteryLevel = energyStorageThing->property("preciseBatteryLevel").toDouble();
double esCapacity = energyStorageThing->stateValue("capacity").toDouble();
// Let's caclulate the new percentage depending on the rate of the last minute...
// We charged/discharged the last minute with energyStorageCurrentPower W
double energyChargedDischargedkWh = esCurrentPower * 60 / 60 / 60 / 1000;
double addedPercentage = energyChargedDischargedkWh * 100.0 / esCapacity;
double newBatteryLevel = esBatteryLevel += addedPercentage;
if (totalCurrentPower < 0 && newBatteryLevel < 100) {
energyStorageCurrentPower = qMin(energyStorageMaxChargingPower, -totalCurrentPower);
energyStorageBatteryLevel = newBatteryLevel;
energyStorageThing->setProperty("preciseBatteryLevel", newBatteryLevel);
setEnergyStorageStates(energyStorageBatteryLevel, energyStorageCurrentPower);
} else if (totalCurrentPower > 0 && newBatteryLevel > 0) {
energyStorageCurrentPower = - qMin(energyStorageMaxDischargingPower, totalCurrentPower);
energyStorageBatteryLevel = newBatteryLevel;
energyStorageThing->setProperty("preciseBatteryLevel", newBatteryLevel);
setEnergyStorageStates(energyStorageBatteryLevel, energyStorageCurrentPower);
} else {
energyStorageBatteryLevel = newBatteryLevel;
energyStorageThing->setProperty("preciseBatteryLevel", newBatteryLevel);
setEnergyStorageStates(energyStorageBatteryLevel, energyStorageCurrentPower);
}
//qCDebug(dcSimulation()) << "Energy storage charged with" << energyStorageCurrentPower << "W" << addedPercentage << "% added to total" << energyStorageBatteryLevel << "%";
}
// -------------------- Meter
totalCurrentPower += energyStorageCurrentPower;
phaseLoad += energyStorageCurrentPower / 3;
// Add the charger power in the aproperiate phase
QVariantMap phases = QVariantMap({ {"A", phaseLoad + chargerThing->stateValue("currentPowerPhaseA").toDouble()},
{"B", phaseLoad + chargerThing->stateValue("currentPowerPhaseB").toDouble()},
{"C", phaseLoad + chargerThing->stateValue("currentPowerPhaseC").toDouble()} });
reply = setMeterStates(phases, true);
QSignalSpy setMeterStatesReplySpy(reply, &QNetworkReply::finished);
if (setMeterStatesReplySpy.count() == 0) setMeterStatesReplySpy.wait();
QCOMPARE(reply->error(), QNetworkReply::NoError);
// -------------------- Run charging manager update
// Set charger information and pass them to the logic
ThingPowerLogEntry chargerPowerEntry(currentDateTime.toUTC(), chargerThing->id(), chargerThing->stateValue("currentPower").toDouble(), 0, 0);
m_experiencePlugin->smartChargingManager()->simulationCallUpdateManualSoCsWithMeter(sampleRate, chargerPowerEntry);
// Update smart charging manager with the current root meter and charger situation
m_experiencePlugin->smartChargingManager()->simulationCallUpdate(currentDateTime.toUTC());
// Fetch information after simulation iteration
double carBatteryPercentage = carThing->stateValue("batteryLevel").toDouble();
int maxChargingCurrent = chargerThing->stateValue("maxChargingCurrent").toInt();
chargerPower = chargerThing->stateValue("power").toBool();
chargerCurrentPower = chargerThing->stateValue("currentPower").toDouble();
if (debugEnabled | iterationTest.contains(i)) {
qCDebug(dcSimulation()) << "Step" << i;
qCDebug(dcSimulation()) << "- Total power:" << totalCurrentPower << "Production:" << totalProduction << "Consumption:" << totalConsumption;
if (energyStorageAvailable) {
qCDebug(dcSimulation()) << "- Energy storage:" << energyStorageCurrentPower << energyStorageThing->property("preciseBatteryLevel").toDouble() << "%";
}
qCDebug(dcSimulation()) << "- Meter phases: A:" << meterThing->stateValue("currentPowerPhaseA").toDouble() << "W | B:"
<< meterThing->stateValue("currentPowerPhaseB").toDouble() << "W | C:" << meterThing->stateValue("currentPowerPhaseC").toDouble() << "W";
qCDebug(dcSimulation()) << "- Charger:" << chargerCurrentPower << "[W] (" << maxChargingCurrent << "[A]" << (chargerCurrentPower ? "On)" : "Off )") << effectivePhaseCount << usedPhases;
qCDebug(dcSimulation()) << "- Charger phases: A:" << chargerThing->stateValue("currentPowerPhaseA").toDouble() << "W | B:"
<< chargerThing->stateValue("currentPowerPhaseB").toDouble() << "W | C:" << chargerThing->stateValue("currentPowerPhaseC").toDouble() << "W";
qCDebug(dcSimulation()) << "- Car battery:" << carBatteryPercentage;
qCDebug(dcSimulation()) << "--------------------------------";
// printStates(meterThing);
// printStates(chargerThing);
// printStates(carThing);
}
// Verify test points
foreach(const SimulationTestPoint &testPoint, iterationTest.value(i)) {
switch(testPoint.testType()) {
case SimulationTestPoint::TestTypeCharging:
QVERIFY2(chargerPower == testPoint.expectedValue().toBool(),
qPrintable(QString("Simulation: %1 - %2 Step: %3 expected \"%4\" from the testpoint but is actually \"%5\"")
.arg(simulationName)
.arg(simulationTitle)
.arg(i)
.arg(testPoint.expectedValue().toBool() ? "true" : "false")
.arg(chargerPower ? "true" : "false")));
break;
case SimulationTestPoint::TestTypeMaxChargingCurrent:
QVERIFY2(maxChargingCurrent == testPoint.expectedValue().toInt(),
qPrintable(QString("Simulation: %1 - %2 Step: %3 expected \"%4\" from the testpoint but is actually \"%5\"")
.arg(simulationName)
.arg(simulationTitle)
.arg(i)
.arg(testPoint.expectedValue().toInt())
.arg(maxChargingCurrent)));
break;
case SimulationTestPoint::TestTypeStateOfCharge:
QVERIFY2(qFuzzyCompare(carBatteryPercentage, testPoint.expectedValue().toDouble()),
qPrintable(QString("Simulation: %1 - %2 Step: %3 expected \"%4\" from the testpoint but is actually \"%5\"")
.arg(simulationName)
.arg(simulationTitle)
.arg(i)
.arg(testPoint.expectedValue().toDouble())
.arg(carBatteryPercentage)));
break;
}
}
// -------------------- Data logging
// Log the simulation data
gnuplotLogFileStream << currentDateTime.toMSecsSinceEpoch() / 1000 << ", " << // 1
totalCurrentPower << ", " << // 2
totalProduction << ", " << // 3
totalConsumption << ", " << // 4
chargerCurrentPower << ", " << // 5
maxChargingCurrent << ", " << // 6
(chargerPower ? "1" : "0") << ", " << // 7
chargerThing->state("maxChargingCurrent").minValue().toDouble() * 230 * effectivePhaseCount << ", " << // 8
chargerThing->state("maxChargingCurrent").maxValue().toDouble() * 230 * effectivePhaseCount << ", " << // 9
phasePowerLimit * 230 * effectivePhaseCount << ", " << // 10
carBatteryPercentage << ", " << // 11
i << ", " << // 12
aquisitionToleranceLimit << ", " << // 13
(chargerThing->stateValue("pluggedIn").toBool() ? 10 : 0 ) << ", "; // 14
if (spotMarketEnabled) {
const ScoreEntries weightedEntries = spotMarketManager->weightedScoreEntries(currentDateTime.date());
const ScoreEntry currentScore = weightedEntries.getScoreEntry(currentDateTime.toUTC());
QVERIFY(!currentScore.isNull());
gnuplotLogFileStream << currentScore.weighting() * 100 << ", "; // 15
gnuplotLogFileStream << currentScore.value() / 10.0 << ", "; // 16 Price
} else {
gnuplotLogFileStream << 0 << ", "; // 15
gnuplotLogFileStream << 0 << ", "; // 16
}
if (energyStorageAvailable) {
gnuplotLogFileStream << energyStorageCurrentPower << ", "; // 17
gnuplotLogFileStream << energyStorageBatteryLevel << ", "; // 18
} else {
gnuplotLogFileStream << 0 << ", "; // 17
gnuplotLogFileStream << 0 << ", "; // 18
}
gnuplotLogFileStream << "\n";
// Log Unchanged for raw data analysis
gnuplotOriginalLogFileStream << currentDateTime.toMSecsSinceEpoch() / 1000 << ", " << // 1
scaledCurrentPower << ", " << // 2
totalProduction << ", " << // 3
entry.consumption() << ", " << // 4
phasePowerLimit * 230 * effectivePhaseCount << ", " << // 5
i << ", " << // 6
"\n";
}
gnuplotLogFile.close();
gnuplotOriginalLogFile.close();
// Draw original data
QStringList scriptLines;
QStringList plotLines;
// Plot with: 1h = 200 px
int height = 800;
int width = simulationHours * 200;
QString originalImageName = simulationName + "-00-original.png";
QString simulationImageName = simulationName + "-01.png";
scriptLines.append("set term png size " + QString::number(width) + "," + QString::number(height));
scriptLines.append("set output '" + originalImageName + "'");
scriptLines.append("set datafile separator ','");
scriptLines.append(plotOriginalData(powerBalanceLogs.count()));
if (spotMarketEnabled) {
scriptLines.append("set term png size " + QString::number(width) + "," + QString::number(height * 2));
scriptLines.append("set output '" + simulationImageName + "'");
scriptLines.append("set datafile separator ','");
scriptLines.append("set multiplot layout 2,1");
scriptLines.append("set size 1,0.8");
scriptLines.append("set origin 0,0.2");
scriptLines.append(plotSimulation(simulationTitle, powerBalanceLogs.count()));
scriptLines.append("set size 1,0.2");
scriptLines.append("set origin 0,0");
scriptLines.append(plotSpotMarketData(powerBalanceLogs.count()));
scriptLines.append("unset multiplot");
} else {
scriptLines.append("set term png size " + QString::number(width) + "," + QString::number(height));
scriptLines.append("set output '" + simulationImageName + "'");
scriptLines.append("set datafile separator ','");
scriptLines.append(plotSimulation(simulationTitle, powerBalanceLogs.count()));
}
// Write the gnuplot script
QFile gnuplotScript(outputDir.path() + QDir::separator() + "script.gnuplot");
QVERIFY2(gnuplotScript.open(QIODevice::ReadWrite | QIODevice::Truncate),
QString("Failed to open script file for gnuplot" + gnuplotScript.fileName() + ": " + gnuplotScript.errorString()).toUtf8());
QTextStream scriptStream(&gnuplotScript);
foreach (const QString &line, scriptLines)
scriptStream << line << "\n";
gnuplotScript.close();
// Write the executable gnuplot script
scriptLines.clear();
scriptLines.append("set terminal wxt 1 persist");
scriptLines.append("set datafile separator ','");
if (spotMarketEnabled) {
scriptLines.append("set multiplot layout 2,1");
scriptLines.append("set size 1,0.8");
scriptLines.append("set origin 0,0.2");
scriptLines.append(plotSimulation(simulationTitle, powerBalanceLogs.count()));
scriptLines.append("set size 1,0.2");
scriptLines.append("set origin 0,0");
scriptLines.append(plotSpotMarketData(powerBalanceLogs.count()));
scriptLines.append("unset multiplot");
} else {
scriptLines.append(plotSimulation(simulationTitle, powerBalanceLogs.count()));
}
QString executableScriptName = simulationName + ".gnuplot";
QFile executableGnuplotScript(outputDir.path() + QDir::separator() + executableScriptName);
QVERIFY2(executableGnuplotScript.open(QIODevice::ReadWrite | QIODevice::Truncate),
QString("Failed to open logfile for gnuplot" + executableGnuplotScript.fileName() + ": " + executableGnuplotScript.errorString()).toUtf8());
QTextStream executableScriptStream(&executableGnuplotScript);
foreach (const QString &line, scriptLines)
executableScriptStream << line << "\n";
executableGnuplotScript.close();
QProcess gnuplotProcess;
//gnuplotProcess.setEnvironment(QProcessEnvironment::systemEnvironment().toStringList());
gnuplotProcess.setProcessChannelMode(QProcess::MergedChannels);
gnuplotProcess.setWorkingDirectory(outputDir.path());
gnuplotProcess.start("gnuplot", { "-c", "script.gnuplot"});
gnuplotProcess.waitForFinished();
qCDebug(dcSimulation()) << "gnuplot finished" << gnuplotProcess.arguments() << gnuplotProcess.workingDirectory() << gnuplotProcess.exitCode() << gnuplotProcess.exitStatus();
if (gnuplotProcess.exitCode() != 0) {
qCDebug(dcSimulation()) << "error plotting data:\n" << qUtf8Printable(gnuplotProcess.readAll());
QVERIFY2(false, "plot process finished with error");
}
// Copy resulting images to the simulations
QFile::copy(outputDir.path() + QDir::separator() + originalImageName, resultsDir.path() + QDir::separator() + originalImageName);
QFile::copy(outputDir.path() + QDir::separator() + simulationImageName, resultsDir.path() + QDir::separator() + simulationImageName);
}
void Simulation::printStates(Thing *thing)
{
qCDebug(dcSimulation()) << "Thing states for" << thing->name();
foreach (const StateType &stateType, thing->thingClass().stateTypes()) {
qCDebug(dcSimulation()) << "-->" << stateType.name() << thing->stateValue(stateType.id());
}
}
void Simulation::updateChargerMeter(Thing *thing)
{
Action updateChargerAction(thing->thingClass().actionTypes().findByName("update").id(), thing->id());
NymeaCore::instance()->thingManager()->executeAction(updateChargerAction);
}
QStringList Simulation::plotOriginalData(int powerBalanceCount)
{
QStringList scriptLines;
scriptLines.append("set title 'Original energy data'");
scriptLines.append("set grid");
scriptLines.append("set timefmt '%s'");
scriptLines.append("set xdata time");
scriptLines.append("set xtics 3600");
scriptLines.append("set format x '%H:%M'");
scriptLines.append("set xlabel 'Time'");
scriptLines.append("set ylabel '[W]'");
scriptLines.append("set style fill transparent solid 0.3");
scriptLines.append("set x2tics 100");
scriptLines.append("set xtics nomirror");
scriptLines.append("set x2label 'iterations'");
scriptLines.append("set x2range [0:" + QString::number(powerBalanceCount) + "]");
QStringList plotLines;
plotLines.append("'original.csv' using 1:3 with boxes lt rgb '#7F75C23A' title 'Production'");
plotLines.append("'original.csv' using 1:4 with boxes lt rgb '#7F3590F3' title 'Consumption'");
plotLines.append("'original.csv' using 1:5 with line lt rgb 'orange' title 'House limit'");
plotLines.append("'original.csv' using 1:2 with line lt rgb 'red' title 'Meter'");
scriptLines.append("plot \\\n" + plotLines.join(", \\\n"));
scriptLines.append("");
return scriptLines;
}
QStringList Simulation::plotSimulation(const QString &title, int powerBalanceCount)
{
QStringList scriptLines;
scriptLines.append("set title '" + title + "'");
scriptLines.append("set grid");
scriptLines.append("set timefmt '%s'");
scriptLines.append("set xdata time");
scriptLines.append("set format x '%H:%M'");
scriptLines.append("set xtics 3600");
scriptLines.append("set xlabel 'time'");
scriptLines.append("set ylabel '[W]'");
scriptLines.append("set x2tics 100");
scriptLines.append("set xtics nomirror");
scriptLines.append("set x2label 'iterations'");
scriptLines.append("set x2range [0:" + QString::number(powerBalanceCount) + "]");
scriptLines.append("set style fill transparent solid 0.3");
scriptLines.append("set y2tics 10");
scriptLines.append("set ytics nomirror");
scriptLines.append("set y2label '[\%]'");
scriptLines.append("set y2range [0:100]");
QStringList plotLines;
plotLines.append("'simulation.csv' using 1:9 title 'Charger range' w filledcurves x1 lc rgb '#fff0f0f0'");
plotLines.append("'simulation.csv' using 1:8 notitle w filledcurves x1 lc rgb '#ffffffff'");
plotLines.append("'simulation.csv' using 1:3 with boxes lt rgb '#0A75C23A' title 'Production'");
plotLines.append("'simulation.csv' using 1:4 with boxes lt rgb '#7F3590F3' title 'Consumption'");
plotLines.append("'simulation.csv' using 1:17 with boxes lt rgb '#7FA020F0' title 'Energy storage'");
plotLines.append("'simulation.csv' using 1:5 with boxes lt rgb '#7FF3DE8A' title 'Charger'");
// plotLines.append("'simulation.csv' using 1:9 with line lt rgb '#EABC01' title 'Charger max'");
// plotLines.append("'simulation.csv' using 1:8 with line lt rgb '#F6CAAF' title 'Charger min'");
plotLines.append("'simulation.csv' using 1:13 with line lt rgb 'green' title 'Acquisition Limit'");
plotLines.append("'simulation.csv' using 1:11 with line lt rgb 'black' axes x1y2 title 'Battery [\%]'");
plotLines.append("'simulation.csv' using 1:18 with line lt rgb 'purple ' axes x1y2 title 'Energy storage [\%]'");
plotLines.append("'simulation.csv' using 1:14 with line lt rgb 'purple' axes x1y2 title 'Car plugged in into charger'");
plotLines.append("'simulation.csv' using 1:10 with line lt rgb 'orange' title 'House limit'");
plotLines.append("'simulation.csv' using 1:2 with line lt rgb 'red' title 'Meter'");
scriptLines.append("plot \\\n" + plotLines.join(", \\\n"));
scriptLines.append("");
return scriptLines;
}
QStringList Simulation::plotSpotMarketData(int powerBalanceCount)
{
QStringList scriptLines;
scriptLines.append("set title 'Spot maket data'");
scriptLines.append("set grid");
scriptLines.append("set timefmt '%s'");
scriptLines.append("set xdata time");
scriptLines.append("set format x '%H:%M'");
scriptLines.append("set xtics 3600");
scriptLines.append("set xlabel 'Time'");
scriptLines.append("set ylabel 'Price [Cent/kWh]'");
scriptLines.append("set x2tics 100");
scriptLines.append("set xtics nomirror");
scriptLines.append("set x2label 'iterations'");
scriptLines.append("set x2range [0:" + QString::number(powerBalanceCount) + "]");
scriptLines.append("set y2tics");
scriptLines.append("set ytics nomirror");
scriptLines.append("set y2label '[\%]'");
scriptLines.append("set y2range [0:100]");
QStringList plotLines;
plotLines.append("'simulation.csv' using 1:15 with boxes fs solid lt rgb '#fff0f0f0' axes x1y2 title 'Spotmarket scoring [%]'");
plotLines.append("'simulation.csv' using 1:16 with line lt rgb 'black' axes x1y1 title 'Price [Cent/kWh]'");
scriptLines.append("plot \\\n" + plotLines.join(", \\\n"));
scriptLines.append("");
return scriptLines;
}
QTEST_MAIN(Simulation)