Le critère était « consigne > budget ». Il est vrai dès que le budget passe sous zéro à palier nul — 0 > -389 — et le motif annonçait alors « Verrou minOn — chauffe-eau maintenue à 0 W (puissance engagée, budget -389 W) » : un verrou qui ne mordait pas, sur une puissance engagée nulle, quand le soutirage était la seule cause. Constaté au banc le 2026-08-13 sur +etm10, sept fois en quatre heures, toujours au cycle suivant un palier 0. Le défaut est antérieur à ECS-309 : celui-ci a ajouté la branche minOff après la branche minOn sans voir que la première capturait déjà un cas étranger. Corriger une branche ne dit rien de ses voisines. Le critère compare désormais la consigne appliquée à la consigne VOULUE avant écrêtage — la mémoire qu'ECS-309 avait justement introduite. Relevée par le verrou → minOn ; rabaissée → minOff ; inchangée → le budget. Les deux branches se lisent sur le même axe, et le motif nomme un mécanisme parce qu'il a agi, non parce qu'il aurait pu. Ce n'était pas qu'un défaut de lisibilité. La branche minOn étant évaluée en premier, elle masquait le motif d'armement à froid d'ECS-412 chaque fois que le budget était négatif au redémarrage — c'est-à-dire au fond du creux, précisément l'instant où il faut redémarrer pour observer minOff. La campagne du volet 2 aurait mesuré le mauvais verrou. Le test reproduit le cas du banc : palier 0, budget négatif, aucun verrou actif. Sans le correctif il produit le texte exact relevé sur la machine. Suite complète : 112 tests (34 simulation + 46 charging + 32 spotmarket), 0 échec. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3788 lines
176 KiB
C++
3788 lines
176 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
|
||
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||
*
|
||
* Copyright (C) 2013 - 2024, nymea GmbH
|
||
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
|
||
*
|
||
* This file is part of nymea-energy-plugin-nymea.
|
||
*
|
||
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
|
||
* it under the terms of the GNU General Public License as published by
|
||
* the Free Software Foundation, either version 3 of the License, or
|
||
* (at your option) any later version.
|
||
*
|
||
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
|
||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
* GNU General Public License for more details.
|
||
*
|
||
* You should have received a copy of the GNU General Public License
|
||
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
|
||
*
|
||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||
|
||
#include "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);
|
||
|
||
// 3bis. VA-ET-VIENT : réinjecter tel quel ce que GET vient de rendre. C'est le geste réel
|
||
// de l'app — lire, modifier un champ, réécrire — et il échouait : GET sérialise via le
|
||
// méta-objet, donc TOUTES les propriétés déclarées, y compris un « sgReady » VIDE sur une
|
||
// charge qui n'est pas SG-Ready. Le schéma SET exigeait « states » dès que la clé était
|
||
// présente, et nymea rejetait AVANT le handler. Constaté au banc le 2026-08-10, sur la
|
||
// première tentative réelle de SetLoadConfig — aucun test ne faisait ce va-et-vient.
|
||
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", got}});
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError"));
|
||
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("loadConfigs").toList().size(), 3);
|
||
|
||
// 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
|
||
|
||
// [ECS-309] Le MOTIF doit nommer le mécanisme réel. Ce test porte sur ce que le texte
|
||
// dit, pas sur sa non-vacuité : c'est exactement ce qu'un « motif non vide » laissait
|
||
// passer. Défaut constaté au banc le 2026-08-09 — palier plafonné à 0 avec 7706 W de
|
||
// surplus, et « Surplus insuffisant (7706 W) » publié. La décision était juste, le
|
||
// motif accusait le budget alors que la cause était l'armement minOff.
|
||
RuleBasedScheduler sched(arb);
|
||
auto motifPour = [&](RelayRouter *routeur, double surplusW, const QDateTime &at) {
|
||
SurplusContext ctx;
|
||
ctx.timestamp = at;
|
||
// Surplus net SIGNÉ : positif = export, négatif = soutirage réseau.
|
||
ctx.meter.exportW = surplusW > 0 ? surplusW : 0.0;
|
||
ctx.meter.importW = surplusW < 0 ? -surplusW : 0.0;
|
||
ctx.loads.append(routeur->toLoadContext(at));
|
||
const Plan plan = sched.getPlan(ctx);
|
||
for (const LoadAction &a : plan.timeSlots.first().actions)
|
||
if (a.loadId == routeur->descriptor().id)
|
||
return a.reason;
|
||
return QString();
|
||
};
|
||
|
||
// 1. Verrou minOff : budget PLÉTHORIQUE, palier refusé. Le motif nomme minOff et ne
|
||
// met pas l'issue sur le compte du budget.
|
||
const QString mOff = motifPour(r2, 7706, t0);
|
||
QVERIFY2(mOff.contains("minOff"), qUtf8Printable("motif: " + mOff));
|
||
QVERIFY2(!mOff.contains("insuffisant", Qt::CaseInsensitive), qUtf8Printable("motif: " + mOff));
|
||
|
||
// 2. Budget RÉELLEMENT insuffisant, hors verrou : là, le budget est la bonne explication.
|
||
tA->setStateValue("power", false);
|
||
RelayRouter *r3 = new RelayRouter(tm, "froid3", "Charge à froid 3",
|
||
QList<LoadConfigRelay>({ {rA.toString(), 1000} }), 0, 0, 1, LoadNeeds(), arb);
|
||
const QString mBudget = motifPour(r3, 200, t0); // 200 W < palier 1000 W
|
||
QVERIFY2(mBudget.contains("insuffisant", Qt::CaseInsensitive), qUtf8Printable("motif: " + mBudget));
|
||
QVERIFY2(!mBudget.contains("Verrou"), qUtf8Printable("motif: " + mBudget));
|
||
|
||
// 3. Verrou minOn : palier maintenu AU-DESSUS du budget. Troisième cas, troisième texte.
|
||
// Il faut un SOUTIRAGE pour l'atteindre : le recrédit anti-clignotement (correction B)
|
||
// rend au budget la puissance déjà engagée, si bien qu'en export une charge peut
|
||
// toujours s'offrir son propre palier. C'est quand le compteur importe que le palier
|
||
// tenu par minOn dépasse le budget — et c'est précisément le cas d'ECS-306.
|
||
RelayRouter *r4 = new RelayRouter(tm, "chaud", "Charge chaude",
|
||
QList<LoadConfigRelay>({ {rA.toString(), 1000} }), 600, 0, 1, LoadNeeds(), arb);
|
||
LoadAction montee; montee.kind = LoadAction::Setpoint; montee.funding = LoadAction::Surplus;
|
||
montee.powerW = 1000; montee.reason = QStringLiteral("amorçage minOn");
|
||
r4->applyAction(montee, t0);
|
||
QCOMPARE(qRound(r4->currentSetpointW()), 1000);
|
||
const QString mOn = motifPour(r4, -400, t0.addSecs(10)); // import 400 W → budget 600 < 1000
|
||
QVERIFY2(mOn.contains("minOn"), qUtf8Printable("motif: " + mOn));
|
||
|
||
// 4. [ECS-309-b] Palier 0 et budget NÉGATIF, sans aucun verrou actif. Le soutirage est
|
||
// la seule cause ; nommer minOn ici est un mensonge à double titre — aucun verrou ne
|
||
// mord, et « puissance engagée » désigne 0 W. Le critère « setpointW > budgetW »
|
||
// l'affirmait pourtant, 0 > −389 étant vrai. Constaté au banc le 2026-08-13, sept
|
||
// fois en quatre heures, toujours au cycle suivant un palier 0.
|
||
// Sans le correctif ce cas produit le texte exact du banc :
|
||
// « Verrou minOn — ... maintenue à 0 W (puissance engagée, budget -389 W) ».
|
||
const QString mSoutirage = motifPour(r3, -389, t0);
|
||
QVERIFY2(mSoutirage.contains("insuffisant", Qt::CaseInsensitive),
|
||
qUtf8Printable("motif: " + mSoutirage));
|
||
QVERIFY2(!mSoutirage.contains("Verrou"), qUtf8Printable("motif: " + mSoutirage));
|
||
QVERIFY2(!mSoutirage.contains("puissance engagée"), qUtf8Printable("motif: " + mSoutirage));
|
||
QVERIFY2(mSoutirage.contains("-389"), qUtf8Printable("motif: " + mSoutirage));
|
||
|
||
// Les trois motifs sont distincts : c'est la lisibilité qu'ECS-309 exige.
|
||
QVERIFY(mOff != mBudget && mBudget != mOn && mOff != mOn);
|
||
|
||
// 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::testEcsDisableLeavesSafeState()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testEcsDisableLeavesSafeState nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [ECS-413] Constat de banc du 2026-08-09 : un SetLoadConfig posant enabled: false sur une
|
||
// charge au palier 3500 W a détruit l'adaptateur en laissant les TROIS relais fermés,
|
||
// juste avant une intervention de câblage.
|
||
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-safestate.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
LoadConfigStore *store = new LoadConfigStore(arb);
|
||
arb->setLoadConfigStore(store);
|
||
|
||
auto cfg = [&](bool enabled, int priority) {
|
||
LoadConfigs cs;
|
||
cs.append(LoadConfig::fromMap(QVariantMap{
|
||
{"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", priority}, {"enabled", enabled},
|
||
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, {"powerW", 2000}}},
|
||
{"minOnS", 0}, {"minOffS", 0}}));
|
||
return cs;
|
||
};
|
||
|
||
QString err;
|
||
QVERIFY2(store->setConfigs(cfg(true, 1), &err), err.toUtf8());
|
||
|
||
// Surplus large → le relais se ferme.
|
||
meter->setStateValue("currentPower", -2500);
|
||
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
|
||
QCoreApplication::processEvents();
|
||
QCOMPARE(tA->stateValue("power").toBool(), true);
|
||
|
||
// --- CAS NÉGATIF, obligatoire : rebuild SANS désactivation ---------------------------
|
||
// Seul le rang change. ECS-412 conserve l'adaptateur, donc AUCUNE mise en sécurité ne
|
||
// doit être déclenchée : sans ce cas, chaque changement de priorité couperait la charge.
|
||
QVERIFY2(store->setConfigs(cfg(true, 2), &err), err.toUtf8());
|
||
QTest::qWait(300);
|
||
QCOMPARE(tA->stateValue("power").toBool(), true);
|
||
|
||
// --- CAS POSITIF : désactivation ------------------------------------------------------
|
||
QVERIFY2(store->setConfigs(cfg(false, 2), &err), err.toUtf8());
|
||
QTest::qWait(300);
|
||
QCOMPARE(tA->stateValue("power").toBool(), false); // état sûr appliqué AVANT destruction
|
||
|
||
// Et il le reste : plus personne ne pilote, rien ne doit le refermer même sous surplus.
|
||
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 5, 0)));
|
||
QTest::qWait(300);
|
||
QCOMPARE(tA->stateValue("power").toBool(), false);
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testSgReadyPartialFailure()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testSgReadyPartialFailure nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [ECS-414] Même échelle qu'ECS-410, mais le PLANCHER d'une PAC est l'ÉTAT 2 — jamais
|
||
// « contacts ouverts », qui est une COMMANDE et peut valoir le BLOCAGE selon l'encodage.
|
||
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 k1 = addPowerSwitch(0, 26661);
|
||
Thing *tK1 = tm->findConfiguredThing(k1);
|
||
QVERIFY(tK1);
|
||
const QString k2ko = QStringLiteral("{deadbeef-1111-1111-1111-111111111111}"); // jamais configuré
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
auto st = [&](int s) {
|
||
LoadAction a; a.kind = LoadAction::State; a.funding = LoadAction::Surplus;
|
||
a.state = s; a.reason = QStringLiteral("test échec SG-Ready"); return a;
|
||
};
|
||
auto laisserRetomber = [&]() { QTest::qWait(300); };
|
||
|
||
// --- Cas nominal : encodage entièrement valide -----------------------------------------
|
||
{
|
||
SgReadyAdapter *sain = new SgReadyAdapter(tm, "pac-ok", "PAC saine",
|
||
QHash<int, QList<QString>>({ {1,{k1.toString()}}, {2,{}}, {3,{}}, {4,{k1.toString()}} }),
|
||
QHash<int, double>({ {1,0.0}, {2,0.0}, {3,1500.0}, {4,3000.0} }), 0, 2, arb);
|
||
sain->applyAction(st(4), t0);
|
||
laisserRetomber();
|
||
QCOMPARE(sain->currentState(), 4);
|
||
QVERIFY2(!sain->faulted(), "une écriture réussie ne doit pas lever de défaut");
|
||
QCOMPARE(sain->telemetry().available, true);
|
||
}
|
||
|
||
// --- Échelle complète, et surtout : QUEL est le plancher ------------------------------
|
||
// Encodage : état 1 = K2 seul (BLOCAGE), état 3 = K1 seul, état 4 = K1+K2.
|
||
// K2 est injoignable. Un contact qu'on ne peut jamais confirmer rend TOUT état
|
||
// inconfirmable : l'échelle va donc jusqu'au défaut, et c'est l'issue honnête.
|
||
//
|
||
// Ce que ce test verrouille n'est pas le défaut lui-même, c'est le PLANCHER : le repli
|
||
// ramène K1 à l'OUVERTURE, c'est-à-dire l'état 2 sur cet encodage. Un plancher « contacts
|
||
// ouverts par principe » aurait pu, sur un autre câblage, valoir l'état 1 — le BLOCAGE.
|
||
SgReadyAdapter *pac = new SgReadyAdapter(tm, "pac-ko", "PAC contact HS",
|
||
QHash<int, QList<QString>>({ {1,{k2ko}}, {2,{}}, {3,{k1.toString()}}, {4,{k1.toString(), k2ko}} }),
|
||
QHash<int, double>({ {1,0.0}, {2,0.0}, {3,1500.0}, {4,3000.0} }), 900, 2, arb);
|
||
tK1->setStateValue("power", false);
|
||
|
||
pac->applyAction(st(4), t0);
|
||
laisserRetomber();
|
||
|
||
QVERIFY2(pac->faulted(), "un contact jamais confirmable doit mener au défaut");
|
||
|
||
// Le repli a bien COMMANDÉ K1 : il est ouvert, donc l'encodage de l'état 2 a été visé.
|
||
// Le verrou minStateHoldS de 900 s ne l'a pas retardé — le repli écrit les contacts
|
||
// directement, équivalent d'un force = true, comme le repli L2. Attendre un quart d'heure
|
||
// pour sortir d'un état non voulu ne serait pas défendable.
|
||
QCOMPARE(tK1->stateValue("power").toBool(), false);
|
||
QCOMPARE(pac->telemetry().available, false);
|
||
|
||
LoadContext c = pac->toLoadContext(t0.addSecs(60));
|
||
QCOMPARE(c.telemetry.available, false);
|
||
// Charge FIGÉE : plancher == plafond.
|
||
QCOMPARE(c.telemetry.minState, c.telemetry.maxState);
|
||
|
||
// Plus aucune commande, même bien après le verrou.
|
||
pac->applyAction(st(3), t0.addSecs(7200));
|
||
laisserRetomber();
|
||
QCOMPARE(tK1->stateValue("power").toBool(), false);
|
||
QVERIFY(pac->faulted());
|
||
|
||
// Levée délibérée : l'état est RELU depuis les contacts, pas supposé.
|
||
pac->clearFault();
|
||
QVERIFY(!pac->faulted());
|
||
QCOMPARE(pac->telemetry().available, true);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testSgReadyFromConfig()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testSgReadyFromConfig nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [LM-300] Première implémentation réelle de spec_loadmodel.md : union discriminée par
|
||
// MÉCANISME, une charge utile par mécanisme, mutuellement exclusives.
|
||
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 k1 = addPowerSwitch(0, 26661);
|
||
QUuid k2 = addPowerSwitch(0, 26662);
|
||
Thing *tK1 = tm->findConfiguredThing(k1);
|
||
Thing *tK2 = tm->findConfiguredThing(k2);
|
||
QVERIFY(tK1 && tK2);
|
||
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-sgready.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
LoadConfigStore *store = new LoadConfigStore(arb);
|
||
arb->setLoadConfigStore(store);
|
||
|
||
auto etat = [](int s, const QVariantList &relays, double w) {
|
||
return QVariantMap{{"state", s}, {"relays", relays}, {"estimatedPowerW", w}};
|
||
};
|
||
auto pacCfg = [&](const QVariantList &etats) {
|
||
return QVariantMap{
|
||
{"id", "pac"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"},
|
||
{"priority", 1}, {"enabled", true},
|
||
{"sgReady", QVariantMap{{"states", etats}, {"minStateHoldS", 0}}}};
|
||
};
|
||
const QVariantList etatsOk = QVariantList()
|
||
<< etat(1, QVariantList() << k1.toString(), 0)
|
||
<< etat(2, QVariantList(), 0)
|
||
<< etat(3, QVariantList() << k2.toString(), 1500)
|
||
<< etat(4, QVariantList() << k1.toString() << k2.toString(), 3000);
|
||
|
||
QString err;
|
||
|
||
// --- REFUS 1 : pas d'état 2 → la charge ne peut pas être mise en sécurité (ECS-110) ----
|
||
{
|
||
QVariantList sansDeux = QVariantList()
|
||
<< etat(3, QVariantList() << k2.toString(), 1500)
|
||
<< etat(4, QVariantList() << k1.toString() << k2.toString(), 3000);
|
||
LoadConfigs cs; cs.append(LoadConfig::fromMap(pacCfg(sansDeux)));
|
||
QVERIFY2(!store->setConfigs(cs, &err), "une PAC sans état 2 DOIT être refusée");
|
||
QVERIFY2(err.contains("état 2"), err.toUtf8());
|
||
}
|
||
|
||
// --- REFUS 2 : charges utiles mélangées → états invalides inexprimables (LM-302) -------
|
||
{
|
||
QVariantMap m = pacCfg(etatsOk);
|
||
m.insert("relays", QVariantList() << QVariantMap{{"thingId", k1.toString()}, {"powerW", 500}});
|
||
LoadConfigs cs; cs.append(LoadConfig::fromMap(m));
|
||
QVERIFY2(!store->setConfigs(cs, &err), "relays[] avec sg-ready DOIT être refusé");
|
||
}
|
||
|
||
// --- ACCEPTÉ : configuration complète, round-trip par le store -------------------------
|
||
LoadConfigs cs; cs.append(LoadConfig::fromMap(pacCfg(etatsOk)));
|
||
QVERIFY2(store->setConfigs(cs, &err), err.toUtf8());
|
||
const LoadConfigSgReady relu = store->configs().first().sgReadyPayload();
|
||
QCOMPARE(relu.states.size(), 4);
|
||
QVERIFY(relu.hasState(2));
|
||
// Le nom du champ dit ce qu'il est : une ESTIMATION, pas un engagement.
|
||
QCOMPARE(qRound(relu.states.at(3).estimatedPowerW), 3000);
|
||
|
||
// --- La PAC configurée est pilotée comme n'importe quelle charge ------------------------
|
||
meter->setStateValue("currentPower", -4000); // surplus large → état 4 attendu
|
||
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
|
||
QTest::qWait(300);
|
||
QCOMPARE(tK1->stateValue("power").toBool(), true);
|
||
QCOMPARE(tK2->stateValue("power").toBool(), true);
|
||
|
||
// --- Désactivation : état sûr = ÉTAT 2, pas « contacts ouverts par principe » -----------
|
||
QVariantMap off = pacCfg(etatsOk); off.insert("enabled", false);
|
||
LoadConfigs cs2; cs2.append(LoadConfig::fromMap(off));
|
||
QVERIFY2(store->setConfigs(cs2, &err), err.toUtf8());
|
||
QTest::qWait(300);
|
||
// État 2 = motif vide sur cet encodage → les deux contacts ouverts.
|
||
QCOMPARE(tK1->stateValue("power").toBool(), false);
|
||
QCOMPARE(tK2->stateValue("power").toBool(), false);
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testThingOwnershipIsExclusive()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testThingOwnershipIsExclusive nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [ECS-110 étendu] isValid() s'arrête au bord d'une charge. Le conflit ENTRE charges —
|
||
// même défaut que la PAC en dur, mais entre deux charges de configuration — ne peut se
|
||
// voir qu'au niveau de l'ensemble.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
const QString K1 = "{beaf92e1-0000-4b84-9ce4-e42364863801}";
|
||
const QString K2 = "{bf236e64-0000-4bf1-82a6-4ede03de7502}";
|
||
const QString VAR = "{11111111-0000-4000-8000-000000000003}";
|
||
|
||
auto sgReady = [&](const QString &id, bool enabled, const QStringList &relaisEtat3) {
|
||
QVariantList etats;
|
||
etats << QVariantMap{{"state", 1}, {"relays", QVariantList()}, {"estimatedPowerW", 0}};
|
||
etats << QVariantMap{{"state", 2}, {"relays", QVariantList()}, {"estimatedPowerW", 0}};
|
||
QVariantList r3;
|
||
for (const QString &r : relaisEtat3) r3 << r;
|
||
etats << QVariantMap{{"state", 3}, {"relays", r3}, {"estimatedPowerW", 1500}};
|
||
return LoadConfig::fromMap(QVariantMap{
|
||
{"id", id}, {"label", "PAC " + id}, {"adapter", "sg-ready"}, {"mode", "fixed"},
|
||
{"priority", 2}, {"enabled", enabled},
|
||
{"sgReady", QVariantMap{{"states", etats}, {"minStateHoldS", 0}}}});
|
||
};
|
||
auto routeur = [&](const QString &id, bool enabled, const QStringList &relais) {
|
||
QVariantList rs;
|
||
for (const QString &r : relais)
|
||
rs << QVariantMap{{"thingId", r}, {"powerW", 500}};
|
||
return LoadConfig::fromMap(QVariantMap{
|
||
{"id", id}, {"label", "ECS " + id}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", 1}, {"enabled", enabled}, {"relays", rs}});
|
||
};
|
||
auto variable = [&](const QString &thingId, bool enabled) {
|
||
return LoadConfig::fromMap(QVariantMap{
|
||
{"id", thingId}, {"label", "Charge variable"}, {"adapter", "etmvariableload"},
|
||
{"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 3}, {"enabled", enabled}});
|
||
};
|
||
|
||
QString err;
|
||
LoadConfigs cs;
|
||
|
||
// --- Le cas général : K1 appartient au routeur ET à la PAC ---------------------------
|
||
cs = LoadConfigs() << routeur("ecs", true, {K1, K2}) << sgReady("pac", true, {K1});
|
||
QVERIFY2(!LoadConfigStore::validateSet(cs, &err),
|
||
"un Thing partagé entre deux charges actives DOIT être refusé");
|
||
QVERIFY2(err.contains("deux charges actives"), err.toUtf8());
|
||
|
||
// --- Même conflit avec etmvariableload, dont l'id EST le ThingId piloté ---------------
|
||
cs = LoadConfigs() << routeur("ecs", true, {VAR}) << variable(VAR, true);
|
||
QVERIFY2(!LoadConfigStore::validateSet(cs, &err), "conflit relay-router ↔ etmvariableload");
|
||
|
||
// --- Formes d'écriture différentes du même uuid : le conflit reste visible ------------
|
||
QString sansAccolades = K1; sansAccolades.remove('{').remove('}');
|
||
cs = LoadConfigs() << routeur("ecs", true, {K1}) << sgReady("pac", true, {sansAccolades.toUpper()});
|
||
QVERIFY2(!LoadConfigStore::validateSet(cs, &err),
|
||
"la normalisation doit voir « {uuid} » et « UUID » comme le même Thing");
|
||
|
||
// --- Identifiants de charge en double : la seconde serait perdue en silence -----------
|
||
cs = LoadConfigs() << routeur("meme-id", true, {K1}) << sgReady("meme-id", true, {K2});
|
||
QVERIFY2(!LoadConfigStore::validateSet(cs, &err), "identifiants dupliqués");
|
||
|
||
// --- Doublons À L'INTÉRIEUR d'une charge ----------------------------------------------
|
||
QVERIFY2(!routeur("ecs", true, {K1, K1}).isValid(&err), "deux étages sur le même Thing");
|
||
QVERIFY2(!sgReady("pac", true, {K2, K2}).isValid(&err), "deux fois le même relais dans un état");
|
||
// Mais un relais présent dans PLUSIEURS états reste légitime (état 4 = état 1 + état 3).
|
||
{
|
||
QVariantList etats;
|
||
etats << QVariantMap{{"state", 1}, {"relays", QVariantList() << K1}, {"estimatedPowerW", 0}};
|
||
etats << QVariantMap{{"state", 2}, {"relays", QVariantList()}, {"estimatedPowerW", 0}};
|
||
etats << QVariantMap{{"state", 4}, {"relays", QVariantList() << K1 << K2}, {"estimatedPowerW", 3000}};
|
||
const LoadConfig pac = LoadConfig::fromMap(QVariantMap{
|
||
{"id", "pac"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"},
|
||
{"priority", 2}, {"enabled", true},
|
||
{"sgReady", QVariantMap{{"states", etats}, {"minStateHoldS", 0}}}});
|
||
QVERIFY2(pac.isValid(&err), err.toUtf8());
|
||
}
|
||
|
||
// --- Une charge DÉSACTIVÉE ne revendique rien : préparer un remplacement reste permis --
|
||
cs = LoadConfigs() << routeur("ecs", true, {K1, K2}) << sgReady("pac-futur", false, {K1});
|
||
QVERIFY2(LoadConfigStore::validateSet(cs, &err), err.toUtf8());
|
||
// …mais l'activer ensuite repasse par la vérification et échoue.
|
||
cs = LoadConfigs() << routeur("ecs", true, {K1, K2}) << sgReady("pac-futur", true, {K1});
|
||
QVERIFY(!LoadConfigStore::validateSet(cs, &err));
|
||
|
||
// --- Le refus remonte bien par setConfigs (et rien n'est persisté) ---------------------
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-exclusive.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
LoadConfigStore store;
|
||
QVERIFY2(store.setConfigs(LoadConfigs() << routeur("ecs", true, {K1, K2}), &err), err.toUtf8());
|
||
cs = LoadConfigs() << routeur("ecs", true, {K1, K2}) << sgReady("pac", true, {K2});
|
||
QVERIFY(!store.setConfigs(cs, &err));
|
||
QCOMPARE(store.configs().count(), 1); // rollback : l'ancienne config tient
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#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)
|