Patrick Schurig 92d0bef4ac fix(etm): ECS-410 — échec d'écriture relais, échelle bornée et défaut collant
writeRelay() jetait le ThingActionInfo* : aucun acquittement, aucun retour
arrière, available codé en dur à true, et m_currentStage mis à jour comme si tout
avait réussi — on annonçait une puissance non appliquée.

MODÈLE ASYNCHRONE. executeAction est asynchrone et update() ne doit jamais
attendre (AGENTS règle 5). « Attendre le résultat » ne veut donc pas dire bloquer
le cycle : l'écriture est émise, l'adaptateur retient combien d'acquittements il
attend (m_pending), le verdict tombe quand le compteur retombe à zéro, et la
conséquence est traitée au cycle suivant. Motif repris tel quel d'EvCharger
(evcharger.cpp:293-299), y compris `this` en contexte de connexion — si
l'adaptateur meurt, les callbacks sont coupés proprement.

INDÉTERMINATION. Pendant une transition, telemetry() annonce
max(m_stagePrev, m_stageTarget) : on ne sait pas ce qui est fermé, on annonce donc
la plus haute des deux puissances possibles. Même direction qu'ECS-411 (relais
injoignable supposé fermé) — ne jamais annoncer moins que ce qui peut être
appliqué. Sous-estimer fait sur-allouer les charges suivantes ; surestimer ne fait
que retarder une montée.

ÉCHELLE BORNÉE à trois barreaux, une tentative chacun, aucune boucle : cible →
retour arrière → arrêt total → défaut. Le retour arrière est asynchrone au même
titre et passe par le même compteur.

DÉFAUT COLLANT, pas clignotant. m_faulted est un verrou posé une seule fois, sans
délai ni expiration : available ne peut pas osciller d'un cycle à l'autre. Seul
NymeaEnergy.ClearLoadFault le lève — acte délibéré et journalisé de l'opérateur.
La reconstruction le lève aussi, mais par construction : un adaptateur neuf n'a
pas d'historique. À la levée, l'état matériel est RELU (ECS-411), pas supposé.

CANAL OUVERT. LoadContext n'avait AUCUN champ available : le publier aurait été
décoratif. Ajouté à LoadContextTelemetry, avec sa sémantique écrite noir sur
blanc — il gouverne l'allocation, PAS la comptabilité. Une charge en défaut ne
reçoit rien mais reste comptée : une puissance qu'on ne sait plus couper est de la
conso fixe, au même titre que la base de la maison. Le figeage est porté par
lockMin == lockMax == puissance crue engagée, jamais un plafond nul sous un
plancher non nul. Le même canal servira ECS-601.

OPTIMIZER_PROTOCOL.md mis à jour dans le MÊME lot, comme l'exige le §11 de la
spec : available, lockMinPowerW et lockMaxPowerW documentés avec leur sémantique.

clearFault() est PURE VIRTUELLE sur ILoadAdapter : les trois autres adaptateurs la
déclarent sans effet plutôt que d'hériter d'un défaut vide. Leur généralisation est
portée par ECS-414.

Test testEcsPartialFailure : cas nominal sans défaut, puis échelle complète via un
relais introuvable — défaut atteint, relais valide bien ramené à l'ouverture par la
tentative d'arrêt total, available faux, plancher == plafond == puissance comptée,
plus aucune commande une heure plus tard, puis levée délibérée.

Build amd64 0 erreur. Simulation : 16/16.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:32:51 +02:00

3359 lines
154 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/adapters/relayrouter.h"
#include "../../../energyplugin/etm/types/loadconfig.h"
#include "../../../energyplugin/etm/config/loadconfigstore.h"
#include "../../../energyplugin/etm/ratios/energyratioscalculator.h"
#include "../../../energyplugin/etm/scheduler/rulebasedscheduler.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);
// ===================== Cas RELAIS (rév. 3) — certification du trou T2 fermé =====================
// Compteur muet → le RelayRouter coupe TOUS ses relais, force=true (bypass minOn). Sans ce
// volet, le repli L2 du routeur n'est pas certifié : des relais ECS pourraient rester allumés.
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 rA = addPowerSwitch(1000, 26661);
QUuid rB = addPowerSwitch(1500, 26662);
Thing *relayA = tm2->findConfiguredThing(rA);
Thing *relayB = tm2->findConfiguredThing(rB);
QVERIFY(relayA && relayB);
// RelayRouter : paliers DÉRIVÉS [0, 1000, 1500, 2500] ; minOn=300 s (pour prouver le bypass force).
RelayRouter *router = new RelayRouter(
tm2, "ecs-relais-repli", "ECS relais (repli)",
QList<LoadConfigRelay>({ {rA.toString(), 1000}, {rB.toString(), 1500} }),
300, 0, 1, LoadNeeds(), arb2);
arb2->registerRelayRouter(router);
auto cycle2 = [&](const QDateTime &now){ arb2->simulationCallUpdate(now); QCoreApplication::processEvents(); };
// Surplus 2500 → palier 2500 (A+B) : les DEUX relais ON.
arb2->recordMeterUpdate(t0);
m2->setStateValue("currentPower", -2500); cycle2(t0);
QCOMPARE(qRound(router->currentSetpointW()), 2500);
QCOMPARE(relayA->stateValue("power").toBool(), true);
QCOMPARE(relayB->stateValue("power").toBool(), true);
QVERIFY(!arb2->degradedMode());
// Compteur muet > 90 s → mode dégradé : TOUS les relais OFF, force=true (bypass minOn 300).
arb2->evaluateMeterFreshness(t0.addSecs(91));
QCoreApplication::processEvents();
QVERIFY(arb2->degradedMode());
QCOMPARE(qRound(router->currentSetpointW()), 0);
QCOMPARE(relayA->stateValue("power").toBool(), false); // trou T2 fermé : relais coupé
QCOMPARE(relayB->stateValue("power").toBool(), false);
// STABILITÉ : muet, faux surplus piège → les relais RESTENT OFF (planif suspendue).
m2->setStateValue("currentPower", -3000);
foreach (int dt, QList<int>({92, 200})) {
cycle2(t0.addSecs(dt));
QVERIFY2(arb2->degradedMode(), "degradedMode doit rester actif pendant le silence");
QCOMPARE(relayA->stateValue("power").toBool(), false);
QCOMPARE(relayB->stateValue("power").toBool(), false);
}
#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 : les DEUX formes rév. 3 (mutuellement exclusives) doivent être acceptées
// par le MÊME schéma SET — sinon nymea rejette une forme avant le handler (bug objectRef
// strict de T4, doublé). Une etmvariableload fixed (thing réel) + une dynamic + une relay-router.
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}};
loadConfigs << QVariantMap{ // ← rév. 3 : relay-router (relays[])
{"id", "ecs-relais"}, {"label", "ECS relais"}, {"adapter", "relay-router"},
{"mode", "fixed"}, {"priority", 3}, {"enabled", true},
{"relays", QVariantList()
<< QVariantMap{{"thingId", "{aaaaaaaa-1111-2222-3333-444444444444}"}, {"powerW", 1000}}
<< QVariantMap{{"thingId", "{bbbbbbbb-1111-2222-3333-444444444444}"}, {"powerW", 1500}}},
{"minOnS", 60}, {"minOffS", 60}};
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", loadConfigs}});
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError"));
// 3. GetLoadConfig → round-trip des 3 entrées (les 2 formes), champs préservés (pack/unpack).
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
const QVariantList got = resp.toMap().value("params").toMap().value("loadConfigs").toList();
QCOMPARE(got.size(), 3);
QVariantMap fixedGot, relayGot;
for (const QVariant &v : got) {
const QVariantMap m = v.toMap();
if (m.value("id").toString() == ecsId.toString()) fixedGot = m;
if (m.value("adapter").toString() == "relay-router") relayGot = m;
}
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"));
// Forme relay-router : relays[] + minOnS round-trip (jamais piloté ici — RelayRouter en étape 4).
QVERIFY(!relayGot.isEmpty());
QVariantList relays = relayGot.value("relays").toList();
QCOMPARE(relays.size(), 2);
QCOMPARE(relays.last().toMap().value("powerW").toInt(), 1500);
QCOMPARE(relayGot.value("minOnS").toInt(), 60);
// 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. Validation CONDITIONNELLE — rejet en bloc (energyError InvalidParameter) :
// (a) etmvariableload fixed sans 0 dans powerLevels ; (b) relay-router avec relays[] vide.
QVariantList badA;
badA << 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", badA}});
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorInvalidParameter"));
QVariantList badB;
badB << QVariantMap{
{"id", "ecs-vide"}, {"label", "ECS sans relais"}, {"adapter", "relay-router"},
{"mode", "fixed"}, {"priority", 1}, {"enabled", true},
{"relays", QVariantList()}}; // relays[] vide → invalide
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", badB}});
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorInvalidParameter"));
// Aucun rejet n'a écrasé : GetLoadConfig retourne toujours les 3 valides.
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
QCOMPARE(resp.toMap().value("params").toMap().value("loadConfigs").toList().size(), 3);
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()
{
#ifndef ETM_ARBITRATOR
QSKIP("testEcsRelayTopologies nécessite ETM_ARBITRATOR.");
#else
// [rév. 3] La combinatoire watts→relais vit DANS le RelayRouter (couche routeur, frontière
// déplacée). On teste directement applyAction(Setpoint W) : paliers DÉRIVÉS, arrondi à la
// combinaison ≤ setpoint, off-before-on non-cascadé, et DÉDUPLICATION des niveaux.
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
auto sp = [&](double w) {
LoadAction a;
a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus;
a.powerW = w; a.reason = QStringLiteral("test topo");
return a;
};
auto freshSetup = [&](ThingManager *&tm, QObject *&owner) {
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
tm = NymeaCore::instance()->thingManager();
owner = arb;
};
// ===================== Topologie 1 : 1 relais (dégénéré [0, 2000]) =====================
{
ThingManager *tm; QObject *owner; freshSetup(tm, owner);
QUuid r = addPowerSwitch(2000, 26661);
Thing *relay = tm->findConfiguredThing(r);
QVERIFY(relay);
RelayRouter *ecs = new RelayRouter(tm, "ecs-1", "ECS 1 relais",
QList<LoadConfigRelay>({ {r.toString(), 2000} }), 0, 0, 1, LoadNeeds(), owner);
QCOMPARE(ecs->descriptor().declared.powerLevels, QList<int>({0, 2000}));
ecs->applyAction(sp(2500), t0); // 2500 → palier 2000
QCOMPARE(qRound(ecs->currentSetpointW()), 2000);
QCOMPARE(relay->stateValue("power").toBool(), true);
ecs->applyAction(sp(1000), t0.addSecs(1)); // 1000 < 2000 → 0
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
QCOMPARE(relay->stateValue("power").toBool(), false);
}
// ============= Topologie 2 : 3 relais 500/1000/2000 (NON-CASCADÉ, off-before-on) =============
{
ThingManager *tm; QObject *owner; freshSetup(tm, owner);
QUuid r500 = addPowerSwitch(500, 26661);
QUuid r1000 = addPowerSwitch(1000, 26662);
QUuid r2000 = addPowerSwitch(2000, 26663);
Thing *t500 = tm->findConfiguredThing(r500);
Thing *t1000 = tm->findConfiguredThing(r1000);
Thing *t2000 = tm->findConfiguredThing(r2000);
QVERIFY(t500 && t1000 && t2000);
RelayRouter *ecs = new RelayRouter(tm, "ecs-3", "ECS 3 relais",
QList<LoadConfigRelay>({ {r500.toString(), 500}, {r1000.toString(), 1000}, {r2000.toString(), 2000} }),
0, 0, 1, LoadNeeds(), owner);
// 8 niveaux dérivés (2^3 combinaisons toutes distinctes).
QCOMPARE(ecs->descriptor().declared.powerLevels, QList<int>({0, 500, 1000, 1500, 2000, 2500, 3000, 3500}));
// 1700 → palier 1500 = {r500, r1000} (r2000 OFF).
ecs->applyAction(sp(1700), t0);
QCOMPARE(qRound(ecs->currentSetpointW()), 1500);
QCOMPARE(t500->stateValue("power").toBool(), true);
QCOMPARE(t1000->stateValue("power").toBool(), true);
QCOMPARE(t2000->stateValue("power").toBool(), false);
// Transition NON-CASCADÉE 1500 → 2000 = {r2000} SEUL : commute 3 relais (off-before-on).
ecs->applyAction(sp(2000), t0.addSecs(1));
QCOMPARE(qRound(ecs->currentSetpointW()), 2000);
QCOMPARE(t500->stateValue("power").toBool(), false);
QCOMPARE(t1000->stateValue("power").toBool(), false);
QCOMPARE(t2000->stateValue("power").toBool(), true);
}
// ============= Topologie 3 : DÉDUPLICATION (deux relais identiques 1000 W) =============
{
ThingManager *tm; QObject *owner; freshSetup(tm, owner);
QUuid rA = addPowerSwitch(1000, 26661);
QUuid rB = addPowerSwitch(1000, 26662);
Thing *tA = tm->findConfiguredThing(rA);
Thing *tB = tm->findConfiguredThing(rB);
QVERIFY(tA && tB);
RelayRouter *ecs = new RelayRouter(tm, "ecs-dup", "ECS dédup",
QList<LoadConfigRelay>({ {rA.toString(), 1000}, {rB.toString(), 1000} }), 0, 0, 1, LoadNeeds(), owner);
// 4 combinaisons MAIS deux donnent 1000 W → FUSIONNÉES : 3 niveaux, pas 4.
QCOMPARE(ecs->descriptor().declared.powerLevels, QList<int>({0, 1000, 2000}));
// 1200 → palier 1000 = UN SEUL relais (le premier de la combinaison dédupliquée).
ecs->applyAction(sp(1200), t0);
QCOMPARE(qRound(ecs->currentSetpointW()), 1000);
QCOMPARE(tA->stateValue("power").toBool(), true);
QCOMPARE(tB->stateValue("power").toBool(), false);
// 2200 → palier 2000 = les deux.
ecs->applyAction(sp(2200), t0.addSecs(1));
QCOMPARE(qRound(ecs->currentSetpointW()), 2000);
QCOMPARE(tA->stateValue("power").toBool(), true);
QCOMPARE(tB->stateValue("power").toBool(), true);
}
#endif
}
void Simulation::testEnergyRatiosAlignment()
{
#ifndef ETM_ARBITRATOR
QSKIP("ETM_ARBITRATOR désactivé — ratios canoniques non compilés.");
#else
// Vecteurs joués 1:1 contre la sémantique du seam interim app
// (EnergyRatiosInterim.compute) : seed, normal, clamp-bas, den≤0→n/a,
// non-monotone (Δ<0)→reseed, nouveau jour local→reseed.
EnergyRatiosCalculator calc;
auto naAuto = [](const EnergyRatiosCalculator::Ratios &r) { QVERIFY(!r.autoconsommationValid); };
auto naAuton = [](const EnergyRatiosCalculator::Ratios &r) { QVERIFY(!r.autonomieValid); };
auto eqAuto = [](const EnergyRatiosCalculator::Ratios &r, double v) {
QVERIFY(r.autoconsommationValid); QVERIFY(qAbs(r.autoconsommation - v) < 1e-3);
};
auto eqAuton = [](const EnergyRatiosCalculator::Ratios &r, double v) {
QVERIFY(r.autonomieValid); QVERIFY(qAbs(r.autonomie - v) < 1e-3);
};
const QDateTime d29_00(QDate(2026, 6, 29), QTime(0, 0));
const QDateTime d29_06(QDate(2026, 6, 29), QTime(6, 0));
const QDateTime d29_08(QDate(2026, 6, 29), QTime(8, 0));
const QDateTime d29_10(QDate(2026, 6, 29), QTime(10, 0));
const QDateTime d30_02(QDate(2026, 6, 30), QTime(2, 0));
const QDateTime d30_10(QDate(2026, 6, 30), QTime(10, 0));
// A — 1er appel : seed baseline → deltas nuls → den≤0 → n/a (les deux).
EnergyRatiosCalculator::Ratios a = calc.compute(1000, 200, 3000, 2200, d29_00);
naAuto(a); naAuton(a);
// B — même jour, croissance monotone : auto=(500-100)/500=80%, autonomie=(1000-500)/1000=50%.
EnergyRatiosCalculator::Ratios b = calc.compute(1500, 300, 4000, 2700, d29_06);
eqAuto(b, 80.0); eqAuton(b, 50.0);
// C — clamp bas : dReturn(700) > dProd(600) → num<0 → auto borné à 0.0 ;
// autonomie=(1200-900)/1200=25%.
EnergyRatiosCalculator::Ratios c = calc.compute(1600, 900, 4200, 3100, d29_08);
eqAuto(c, 0.0); eqAuton(c, 25.0);
// D — compteur non monotone (production 900 < base 1000) → reseed → n/a.
EnergyRatiosCalculator::Ratios d = calc.compute(900, 900, 4200, 3100, d29_10);
naAuto(d); naAuton(d);
// E — nouveau jour local → reseed → n/a (même si cumuls croissants).
EnergyRatiosCalculator::Ratios e = calc.compute(2000, 500, 6000, 4000, d30_02);
naAuto(e); naAuton(e);
// F — même jour (30) : auto=(600-200)/600≈66.667%, autonomie=(1000-500)/1000=50%.
EnergyRatiosCalculator::Ratios f = calc.compute(2600, 700, 7000, 4500, d30_10);
eqAuto(f, 66.6667); eqAuton(f, 50.0);
#endif
}
void Simulation::testLoadConfigRelayRouter()
{
#ifndef ETM_ARBITRATOR
QSKIP("testLoadConfigRelayRouter nécessite ETM_ARBITRATOR.");
#else
// Chaîne COMPLÈTE rév. 3 : SetConfigs(relays[]) → store.changed → rebuild construit un
// RelayRouter → cycle surplus → arrondi scheduler → routeur → commutation relais → currentPowerW.
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arbitrator);
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 rA = addPowerSwitch(1000, 26661);
QUuid rB = addPowerSwitch(1500, 26662);
Thing *relayA = tm->findConfiguredThing(rA);
Thing *relayB = tm->findConfiguredThing(rB);
QVERIFY(relayA && relayB);
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-relayrouter.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", "ecs-relais"}, {"label", "ECS relais"}, {"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", 1}, {"enabled", true},
{"relays", QVariantList()
<< QVariantMap{{"thingId", rA.toString()}, {"powerW", 1000}}
<< QVariantMap{{"thingId", rB.toString()}, {"powerW", 1500}}},
{"minOnS", 0}, {"minOffS", 0}}));
QString err;
QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8()); // persiste + changed → rebuild → RelayRouter
// Surplus 2500 → paliers dérivés [0,1000,1500,2500] → palier 2500 (rA+rB) → les deux ON.
meter->setStateValue("currentPower", -2500);
arbitrator->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
QCoreApplication::processEvents();
QCOMPARE(relayA->stateValue("power").toBool(), true);
QCOMPARE(relayB->stateValue("power").toBool(), true);
// currentPowerW remonte (mock : relais ON → currentPower = nominal) → source du recrédit.
QCOMPARE(qRound(relayA->stateValue("currentPower").toDouble()), 1000);
QCOMPARE(qRound(relayB->stateValue("currentPower").toDouble()), 1500);
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
#endif
}
void Simulation::testEcsBudgetUnderLock()
{
#ifndef ETM_ARBITRATOR
QSKIP("testEcsBudgetUnderLock nécessite ETM_ARBITRATOR.");
#else
// [ECS-306] Sous verrou minOn, l'adaptateur maintient un palier au-dessus du budget.
// Le scheduler DOIT décrémenter le budget de ce palier réel — sinon la charge de
// priorité suivante reçoit un résidu surestimé et l'installation soutire au réseau.
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
ThingManager *tm = NymeaCore::instance()->thingManager();
QUuid rA = addPowerSwitch(2000, 26661); // charge 1, prioritaire, verrouillée minOn
QUuid rB = addPowerSwitch(1000, 26662); // charge 2, servie sur le résidu
Thing *tA = tm->findConfiguredThing(rA);
Thing *tB = tm->findConfiguredThing(rB);
QVERIFY(tA && tB);
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
RelayRouter *l1 = new RelayRouter(tm, "ecs-1", "ECS prioritaire",
QList<LoadConfigRelay>({ {rA.toString(), 2000} }), 300, 0, 1, LoadNeeds(), arb);
RelayRouter *l2 = new RelayRouter(tm, "ecs-2", "ECS secondaire",
QList<LoadConfigRelay>({ {rB.toString(), 1000} }), 0, 0, 2, LoadNeeds(), arb);
arb->registerRelayRouter(l1);
arb->registerRelayRouter(l2);
auto sp = [&](double w) {
LoadAction a; a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus;
a.powerW = w; a.reason = QStringLiteral("amorçage test"); return a;
};
// Amorçage : la charge 1 monte à 2000 W et arme son verrou minOn (300 s).
l1->applyAction(sp(2500), t0);
QCOMPARE(qRound(l1->currentSetpointW()), 2000);
// 60 s plus tard : minOn non écoulé → la fenêtre exposée impose un PLANCHER de 2000 W.
const QDateTime t1 = t0.addSecs(60);
LoadContext c1 = l1->toLoadContext(t1);
QCOMPARE(qRound(c1.telemetry.lockMinPowerW), 2000); // le canal ECS-306 existe…
QVERIFY(c1.telemetry.lockMaxPowerW >= c1.telemetry.lockMinPowerW);
// …et il est HONORÉ : avec un budget de 500 W, la charge 1 reste écrêtée à 2000 W et le
// résidu passé à la charge suivante est NÉGATIF, donc la charge 2 reste à 0.
RuleBasedScheduler sched(arb, nullptr);
SurplusContext ctx;
ctx.timestamp = t1;
ctx.meter.exportW = 500;
ctx.meter.importW = 0;
ctx.loads.append(c1);
ctx.loads.append(l2->toLoadContext(t1));
Plan plan = sched.getPlan(ctx);
Slot slot = plan.slotCovering(t1);
double a1 = -1, a2 = -1;
for (const LoadAction &a : slot.actions) {
if (a.loadId == "ecs-1") a1 = a.powerW;
if (a.loadId == "ecs-2") a2 = a.powerW;
}
QCOMPARE(qRound(a1), 2000); // maintenu par le verrou, au-dessus du budget
QCOMPARE(qRound(a2), 0); // le résidu tient compte des 2000 W engagés
QVERIFY2(!slot.actions.isEmpty(), "aucune action produite");
#endif
}
void Simulation::testEcsRestartRecovery()
{
#ifndef ETM_ARBITRATOR
QSKIP("testEcsRestartRecovery nécessite ETM_ARBITRATOR.");
#else
// [ECS-411] Des relais déjà fermés au démarrage → le palier courant est DÉDUIT, pas
// remis à 0. Sinon le moteur croit 0 W pendant que le ballon tire sa puissance.
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
ThingManager *tm = NymeaCore::instance()->thingManager();
QUuid r500 = addPowerSwitch(500, 26661);
QUuid r1000 = addPowerSwitch(1000, 26662);
QUuid r1500 = addPowerSwitch(1500, 26663);
Thing *t500 = tm->findConfiguredThing(r500);
Thing *t1000 = tm->findConfiguredThing(r1000);
Thing *t1500 = tm->findConfiguredThing(r1500);
QVERIFY(t500 && t1000 && t1500);
const QList<LoadConfigRelay> relays({ {r500.toString(), 500},
{r1000.toString(), 1000},
{r1500.toString(), 1500} });
// Cas 1 — tous ouverts : palier 0, comportement inchangé.
{
t500->setStateValue("power", false);
t1000->setStateValue("power", false);
t1500->setStateValue("power", false);
RelayRouter *r = new RelayRouter(tm, "ecs-off", "ECS éteint", relays, 0, 0, 1, LoadNeeds(), arb);
QCOMPARE(r->currentStage(), 0);
QCOMPARE(qRound(r->currentSetpointW()), 0);
}
// Cas 2 — R1000 fermé au démarrage : le routeur DOIT repartir à 1000 W, pas à 0.
{
t500->setStateValue("power", false);
t1000->setStateValue("power", true);
t1500->setStateValue("power", false);
RelayRouter *r = new RelayRouter(tm, "ecs-1000", "ECS repris", relays, 0, 0, 1, LoadNeeds(), arb);
QCOMPARE(qRound(r->currentSetpointW()), 1000);
}
// Cas 3 — R500 + R1500 fermés : 2000 W, combinaison à deux relais correctement reconnue.
{
t500->setStateValue("power", true);
t1000->setStateValue("power", false);
t1500->setStateValue("power", true);
RelayRouter *r = new RelayRouter(tm, "ecs-2000", "ECS repris 2000", relays, 0, 0, 1, LoadNeeds(), arb);
QCOMPARE(qRound(r->currentSetpointW()), 2000);
}
#endif
}
void Simulation::testEcsRebuildPreservesLock()
{
#ifndef ETM_ARBITRATOR
QSKIP("testEcsRebuildPreservesLock nécessite ETM_ARBITRATOR.");
#else
// [ECS-412] SetLoadConfig PENDANT une fenêtre de verrou active : un changement de rang
// ne doit pas détruire l'adaptateur, donc ne doit pas réarmer le verrou. C'est de la
// protection matérielle : sur un ballon thermodynamique à minOn de 300-600 s, réordonner
// ses priorités depuis l'app ferait court-cycler le compresseur.
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
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 rA = addPowerSwitch(2000, 26661);
Thing *tA = tm->findConfiguredThing(rA);
QVERIFY(tA);
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-rebuildlock.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
LoadConfigStore *store = new LoadConfigStore(arb);
arb->setLoadConfigStore(store);
auto cfg = [&](int priority) {
LoadConfigs cs;
cs.append(LoadConfig::fromMap(QVariantMap{
{"id", "ecs-verrou"}, {"label", "ECS verrouillé"}, {"adapter", "relay-router"},
{"mode", "fixed"}, {"priority", priority}, {"enabled", true},
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, {"powerW", 2000}}},
{"minOnS", 300}, {"minOffS", 0}}));
return cs;
};
QString err;
QVERIFY2(store->setConfigs(cfg(1), &err), err.toUtf8());
// Cycle 1 : surplus large → le relais se ferme, le verrou minOn s'arme.
meter->setStateValue("currentPower", -2500);
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
QCoreApplication::processEvents();
QCOMPARE(tA->stateValue("power").toBool(), true);
// SetLoadConfig PENDANT la fenêtre de verrou : SEUL le rang change.
QVERIFY2(store->setConfigs(cfg(2), &err), err.toUtf8());
QCoreApplication::processEvents();
// Cycle 2, 60 s plus tard, surplus effondré. Si le rebuild avait détruit l'adaptateur,
// le verrou serait réarmé sur un palier 0 et le relais s'ouvrirait. Il doit RESTER fermé :
// minOn court toujours depuis le cycle 1.
meter->setStateValue("currentPower", 100); // import : budget négatif
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 1, 0)));
QCoreApplication::processEvents();
QCOMPARE(tA->stateValue("power").toBool(), true);
// Au-delà de minOn, le délestage reprend normalement.
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 6, 0)));
QCoreApplication::processEvents();
QCOMPARE(tA->stateValue("power").toBool(), false);
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
#endif
}
void Simulation::testEcsColdStartLockExpires()
{
#ifndef ETM_ARBITRATOR
QSKIP("testEcsColdStartLockExpires nécessite ETM_ARBITRATOR.");
#else
// [ECS-412] L'armement du verrou au démarrage à froid doit être TRANSITOIRE.
//
// Défaut corrigé : un m_lastSwitch nul servait de sentinelle « elapsed = 0 » à CHAQUE
// cycle. Une charge démarrant au palier 0 avec minOffS > 0 ne pouvait donc jamais
// s'enclencher — donc jamais commuter, donc jamais valider m_lastSwitch : blocage
// circulaire. Constaté au banc le 2026-08-09, une charge sonde restant à 0 W sous
// 4 kW de surplus disponible. Aucun test ne combinait « palier 0 au départ » et
// « minOffS > 0 » : c'était exactement le trou.
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
ThingManager *tm = NymeaCore::instance()->thingManager();
QUuid rA = addPowerSwitch(1000, 26661);
Thing *tA = tm->findConfiguredThing(rA);
QVERIFY(tA);
tA->setStateValue("power", false); // départ RELAIS OUVERT → palier 0
const int minOff = 120;
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
RelayRouter *r = new RelayRouter(tm, "froid", "Charge à froid",
QList<LoadConfigRelay>({ {rA.toString(), 1000} }), 0, minOff, 1, LoadNeeds(), arb);
QCOMPARE(r->currentStage(), 0); // ECS-411 : rien de fermé → palier 0
auto sp = [&](double w, const QDateTime &at) {
LoadAction a; a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus;
a.powerW = w; a.reason = QStringLiteral("test démarrage à froid");
return r->applyAction(a, at);
};
// Budget LARGEMENT suffisant dès le premier cycle : seul le verrou peut retenir.
// Pendant minOff, la charge DOIT rester éteinte — c'est la protection voulue.
sp(5000, t0);
QCOMPARE(qRound(r->currentSetpointW()), 0);
QCOMPARE(tA->stateValue("power").toBool(), false);
sp(5000, t0.addSecs(minOff - 1)); // encore dans la fenêtre
QCOMPARE(qRound(r->currentSetpointW()), 0);
// …et APRÈS minOff, elle DOIT s'enclencher. C'est la moitié que le défaut supprimait :
// le verrou ne doit pas survivre à sa propre durée.
sp(5000, t0.addSecs(minOff + 1));
QCOMPARE(qRound(r->currentSetpointW()), 1000);
QCOMPARE(tA->stateValue("power").toBool(), true);
// La fenêtre exposée au scheduler (ECS-306) suit la même expiration : plafond nul
// pendant le verrou, plafond réel ensuite.
// Relais REMIS OUVERT avant construction : sans quoi ECS-411 déduirait un palier non
// nul et ce serait minOn, pas minOff, qui s'armerait — on ne testerait pas le cas visé.
tA->setStateValue("power", false);
RelayRouter *r2 = new RelayRouter(tm, "froid2", "Charge à froid 2",
QList<LoadConfigRelay>({ {rA.toString(), 1000} }), 0, minOff, 1, LoadNeeds(), arb);
QCOMPARE(r2->currentStage(), 0);
LoadContext c0 = r2->toLoadContext(t0);
QCOMPARE(qRound(c0.telemetry.lockMaxPowerW), 0); // armé au premier cycle
// Un premier applyAction estampille m_lastSwitch : à partir de là le verrou court.
LoadAction amorce;
amorce.kind = LoadAction::Setpoint;
amorce.funding = LoadAction::Surplus;
amorce.powerW = 0;
amorce.reason = QStringLiteral("amorçage");
r2->applyAction(amorce, t0);
LoadContext c1 = r2->toLoadContext(t0.addSecs(minOff + 1));
QCOMPARE(qRound(c1.telemetry.lockMaxPowerW), 1000); // expiré
#endif
}
void Simulation::testEcsPartialFailure()
{
#ifndef ETM_ARBITRATOR
QSKIP("testEcsPartialFailure nécessite ETM_ARBITRATOR.");
#else
// [ECS-410] Un relais introuvable fait échouer toute écriture le concernant. On exerce
// ainsi l'échelle complète : cible → retour arrière → arrêt total → défaut.
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
ThingManager *tm = NymeaCore::instance()->thingManager();
QUuid rOk = addPowerSwitch(1000, 26661);
Thing *tOk = tm->findConfiguredThing(rOk);
QVERIFY(tOk);
tOk->setStateValue("power", false);
const QString rKo = QStringLiteral("{deadbeef-0000-0000-0000-000000000000}"); // jamais configuré
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
auto sp = [&](double w) {
LoadAction a; a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus;
a.powerW = w; a.reason = QStringLiteral("test échec d'écriture"); return a;
};
// ECS-410 est ASYNCHRONE par construction (AGENTS règle 5 : jamais d'attente dans
// update()). Les acquittements arrivent par signal : sans faire tourner la boucle
// d'événements, l'échelle ne progresse pas d'un seul barreau.
auto laisserRetomber = [&]() { QTest::qWait(300); };
// --- Cas nominal : un seul relais VALIDE, aucune échelle déclenchée -------------------
{
RelayRouter *sain = new RelayRouter(tm, "sain", "Charge saine",
QList<LoadConfigRelay>({ {rOk.toString(), 1000} }), 0, 0, 1, LoadNeeds(), arb);
sain->applyAction(sp(1000), t0);
laisserRetomber();
QCOMPARE(qRound(sain->currentSetpointW()), 1000);
QCOMPARE(tOk->stateValue("power").toBool(), true);
QVERIFY2(!sain->faulted(), "une écriture réussie ne doit pas lever de défaut");
QCOMPARE(sain->telemetry().available, true);
tOk->setStateValue("power", false);
}
// --- Échelle complète : relais introuvable → défaut ----------------------------------
RelayRouter *r = new RelayRouter(tm, "ko", "Charge en panne",
QList<LoadConfigRelay>({ {rOk.toString(), 1000}, {rKo, 2000} }), 0, 0, 1, LoadNeeds(), arb);
QVERIFY(!r->faulted());
// Cible 3000 W = les DEUX relais. L'écriture sur rKo échoue → barreau 2 (retour arrière),
// qui échoue aussi → barreau 3 (arrêt total), qui échoue → défaut.
r->applyAction(sp(3000), t0);
laisserRetomber();
QVERIFY2(r->faulted(), "l'échelle ECS-410 doit aboutir au défaut");
// Le relais VALIDE a bien été ramené à l'ouverture par la tentative d'arrêt total :
// le barreau 3 n'est pas décoratif.
QCOMPARE(tOk->stateValue("power").toBool(), false);
// available bascule à faux — et ne clignote pas : il est COLLANT.
QCOMPARE(r->telemetry().available, false);
LoadContext c = r->toLoadContext(t0.addSecs(600));
QCOMPARE(c.telemetry.available, false);
// Charge FIGÉE : plancher == plafond. Un plafond nul sous un plancher non nul ferait
// dérailler le qBound du scheduler.
QCOMPARE(qRound(c.telemetry.lockMinPowerW), qRound(c.telemetry.lockMaxPowerW));
// …et la charge continue d'être COMPTÉE : elle sort de l'arbitrage, pas de la comptabilité.
QCOMPARE(qRound(c.telemetry.lockMinPowerW), qRound(c.telemetry.currentPowerW));
// Plus aucune commande n'est émise, même longtemps après : le défaut ne s'évapore pas.
tOk->setStateValue("power", false);
r->applyAction(sp(1000), t0.addSecs(3600));
laisserRetomber();
QCOMPARE(tOk->stateValue("power").toBool(), false);
QVERIFY(r->faulted());
// Levée DÉLIBÉRÉE, par l'opérateur — le seul chemin hors reconstruction.
r->clearFault();
QVERIFY(!r->faulted());
QCOMPARE(r->telemetry().available, true);
#endif
}
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)