Patrick Schurig b274989166 feat(délestage §3): la cascade tient dans deux ressources, et DRAW_CAP naît
Le surplus dit ce qu'on peut dépenser sans acheter ; l'autorisation de
soutirage dit ce que le branchement supporte. Une charge doit tenir dans
les deux. Jusqu'ici seules les bornes VE étaient bornées par la seconde —
une charge de waterfall pouvait donc créer un dépassement que seule la
protection L4 rattrapait, après coup. C'est ce qui change.

`SurplusContext::drawMargin` est une valeur de CYCLE, résolue une fois par
`houseDrawMargin()` et jamais relue : deux lectures à deux instants
feraient décider sur deux mondes. Chaque charge servie l'entame.

Le motif porte les quatre grandeurs gravées au §6.3 : la marge (SIGNÉE),
l'ampleur du dépassement, la source, et la phase qui borne. La phase n'est
publiée que s'il y en a une — un plafond total n'en a pas, et en annoncer
une enverrait mesurer au hasard. Précédence LM-1011 appliquée telle quelle :
DRAW_CAP seulement à autorisation NULLE, sinon BELOW_MIN_POWER.

Les bornes VE ne sont pas écrêtées par la marge de la maison, et ce n'est
pas un oubli : `evPhaseAllowanceW()` connaît LES PHASES QU'ELLES OCCUPENT.
Leur appliquer en plus la pire phase les priverait d'une phase saine parce
qu'une autre sature. Entre deux bornes justes on garde la mieux informée,
pas la plus sévère. Elles décrémentent la marge comme les autres.

LIMITE DE MODÈLE assumée (LM-1210-a) : `LoadDeclared::phases` est un nombre,
pas un masque — le moteur ignore sur quelle phase une charge est câblée. La
pire phase est donc retenue. Deviner, dans une couche de sécurité, se paie
au disjoncteur. Le pessimisme est borné en pratique et coûte du rendement,
jamais de la sécurité ; le lever demande un masque déclaré, donc config,
interface et migration. Documenté dans la spec et le design, à trancher.

Suite complète : simulation 52/52, charging 48/48 identique à la référence
ligne à ligne — les bornes ne bougent pas, c'est l'invariant du lot —
loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement. Contre-épreuve :
bornage débranché, la charge est servie 1000 W alors que l'autorisation est
déjà dépassée de 460 W.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-30 12:29:38 +02:00

6585 lines
333 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

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

// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "simulation.h"
#include <hardware/electricity.h>
#include <servers/mocktcpserver.h>
#include <experiences/experiencemanager.h>
#include <experiences/experienceplugin.h>
using namespace nymeaserver;
#include "../../../energyplugin/smartchargingmanager.h"
#include "../../mocks/spotmarketprovider/spotmarketdataprovidermock.h"
#ifdef ETM_ARBITRATOR
#include "../../../energyplugin/etm/energyarbitrator.h"
#include "../../../energyplugin/etm/adapters/sgreadyadapter.h"
// [T3] ecsrelayadapter.h retiré : l'ECS est piloté en watts via EtmVariableLoadAdapter
// (interface etmvariableload, kind Setpoint). La combinatoire relais vit dans le thing.
#include "../../../energyplugin/etm/adapters/etmvariableloadadapter.h"
#include "../../../energyplugin/etm/adapters/relayrouter.h"
#include "../../../energyplugin/etm/adapters/evadapter.h"
#include "../../../energyplugin/evcharger.h"
#include "../../../energyplugin/rootmeter.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"
#include "../../../energyplugin/etm/types/decisionreason.h"
#endif
#include <functional>
#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"
namespace {
#ifdef ETM_ARBITRATOR
/*! \brief Charge utile de configuration d'une PAC SG-Ready 2 bits (K1/K2), forme §4.
*
* Depuis le lot B-bis il n'existe plus d'enregistrement codé en dur : une PAC se déclare comme
* n'importe quelle autre charge. Ce fabricant évite d'étaler le même littéral dans chaque test
* — et rend visible qu'il n'y a QU'UNE façon de déclarer une PAC.
*/
QVariantMap sgReadyConfigMap(const QString &id, const QString &label,
const QUuid &k1, const QUuid &k2,
double p3, double p4, int minStateHoldS, int priority)
{
return QVariantMap{
{"id", id}, {"label", label}, {"adapter", "sg-ready"}, {"mode", "fixed"},
{"priority", priority}, {"enabled", true},
{"sgReady", QVariantMap{
{"minStateHoldS", minStateHoldS},
{"states", QVariantList()
<< QVariantMap{{"state", 1}, {"relays", QVariantList() << k1.toString()},
{"estimatedPowerW", 0.0}}
<< QVariantMap{{"state", 2}, {"relays", QVariantList()},
{"estimatedPowerW", 0.0}}
<< QVariantMap{{"state", 3}, {"relays", QVariantList() << k2.toString()},
{"estimatedPowerW", p3}}
<< QVariantMap{{"state", 4},
{"relays", QVariantList() << k1.toString() << k2.toString()},
{"estimatedPowerW", p4}}}}}};
}
/*! \brief Capture les messages Qt le temps d'une portée, pour asserter sur le TEXTE publié.
*
* AGENTS.md est explicite : les tests des règles 7-b à 7-d portent sur le TEXTE, jamais sur la
* non-vacuité d'une chaîne — « un test motif non vide n'aurait rien vu, dans aucun de ces cas ».
* Un relevé de reprise (ECS-411-b) relève de la même exigence : ce qui compte est qu'il DISE
* ce qui a été lu, pas qu'il existe.
*/
class JournalCapture
{
public:
/*! \brief Ouvre une capture. Plusieurs peuvent être vivantes en même temps.
*
* Le handler n'est installé QU'UNE FOIS, et chaîné au handler d'ORIGINE mémorisé à cette
* occasion. Le réinstaller à chaque construction serait un piège : \c qInstallMessageHandler
* rend le handler courant, qui est alors \c collecte() elle-même — la capture se chaînerait
* sur elle-même, chaque message se rappellerait indéfiniment en concaténant dans
* \c m_texte, et le processus mourrait en OOM. Défaut constaté le 2026-08-25 : la suite
* entière atteignait 7-8 Gio et emportait la session graphique, alors qu'un seul test —
* celui-ci, le seul à ouvrir DEUX captures — en était responsable.
*/
JournalCapture()
{
if (s_pile.isEmpty())
s_origine = qInstallMessageHandler(&JournalCapture::collecte);
s_pile.append(this);
}
//! Referme la capture ; le handler d'origine n'est rendu qu'à la dernière.
~JournalCapture()
{
s_pile.removeAll(this);
if (s_pile.isEmpty())
qInstallMessageHandler(s_origine);
}
bool contains(const QString &fragment) const { return m_texte.contains(fragment); }
bool contient(QtMsgType type) const { return m_types.contains(type); }
QString texte() const { return m_texte; }
private:
static void collecte(QtMsgType type, const QMessageLogContext &ctx, const QString &msg)
{
// Toutes les captures vivantes reçoivent le message — c'est ce que le chaînage visait.
for (JournalCapture *c : s_pile) {
c->m_texte += msg + QLatin1Char('\n');
c->m_types.insert(type);
}
if (s_origine)
s_origine(type, ctx, msg); // le message reste visible dans la sortie de test
}
QString m_texte;
QSet<QtMsgType> m_types;
static QList<JournalCapture *> s_pile;
static QtMessageHandler s_origine;
};
QList<JournalCapture *> JournalCapture::s_pile;
QtMessageHandler JournalCapture::s_origine = nullptr;
//! \brief Lit une grandeur entière du mécanisme d'une charge, par la frontière PUBLIQUE.
//! \return La valeur, ou -1 si la charge n'est pas publiée — un -1 fait échouer l'assertion
//! appelante, ce qu'un 0 silencieux ne ferait pas.
int mechanismInt(EnergyArbitrator *arb, const QString &loadId, const char *cle)
{
for (const QVariant &v : arb->loadTelemetry().value(QStringLiteral("loads")).toList())
if (v.toMap().value(QStringLiteral("loadId")).toString() == loadId)
return v.toMap().value(QStringLiteral("mechanism")).toMap()
.value(QLatin1String(cle)).toInt();
return -1;
}
#endif
}
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, /* minPowerW */ 0, 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, /* minPowerW */ 0, 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, /* minPowerW */ 0, 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);
// [lot B-bis] La PAC est une charge de CONFIGURATION comme les autres — plus aucun
// adaptateur codé en dur, donc plus d'enregistrement direct : le test emprunte le chemin
// de production (SetConfigs → changed → rebuild → SgReadyAdapter).
const QString cfgPacPath = QDir::tempPath() + "/etm-loadcfg-sgready.json";
QFile::remove(cfgPacPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPacPath.toUtf8());
LoadConfigStore *storePac = new LoadConfigStore(arbitrator);
arbitrator->setLoadConfigStore(storePac);
LoadConfigs cfgsPac;
cfgsPac.append(LoadConfig::fromMap(sgReadyConfigMap("pac-test", "PAC test", k1, k2,
/*p3*/ 1500.0, /*p4*/ 3000.0,
/*minStateHoldS*/ 300, /*priority*/ 1)));
QString errPac;
QVERIFY2(storePac->setConfigs(cfgsPac, &errPac), errPac.toUtf8());
auto setMeterW = [&](double signedW){ meter->setStateValue("currentPower", signedW); }; // <0 export
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
// L'état de la PAC se lit désormais par la frontière publique — celle-là même que l'app
// consomme. Le test gagne au passage la vérification que la télémétrie suit l'arbitrage.
auto etatPac = [&](){ return mechanismInt(arbitrator, "pac-test", "state"); };
// --- Volet 0 : ARMEMENT À FROID du verrou (ECS-412 appliqué au minStateHold) ---
// Un adaptateur neuf ne sait PAS quand la PAC a commuté pour la dernière fois : un
// m_lastSwitch nul vaut « commutation venant d'avoir lieu », pas « jamais commuté ». Sans
// cela, un redémarrage de nymead pendant que le compresseur tourne autoriserait une coupure
// immédiate — du court-cycling provoqué par un redémarrage de service, exactement ce que
// minStateHold existe pour empêcher.
setMeterW(-3000); cycle(t0); // budget largement suffisant pour l'état 4…
QCOMPARE(etatPac(), 2); // …mais GELÉ par l'armement à froid
// L'armement est PARESSEUX : il expire. C'est le défaut d'ECS-412 qu'on refuse de refaire —
// un armement PERMANENT gelait toute charge à froid, sans issue (pas d'enclenchement → pas
// de commutation → pas d'estampille → blocage circulaire). L'estampille est posée au
// PREMIER cycle reçu, refusé ou non, si bien que le compte à rebours court dès maintenant.
const QDateTime tA = t0.addSecs(301); // > minStateHoldS (300) : le verrou a expiré
// --- Volet 1 : montée d'états 2 → 3 → 4 (mapping sémantique) ---
setMeterW(-1000); cycle(tA); // budget 1000 < P3 → état 2 (normal)
QCOMPARE(etatPac(), 2);
setMeterW(-2000); cycle(tA); // budget 2000 ≥ P3 → état 3 (reco)
QCOMPARE(etatPac(), 3);
QCOMPARE(relayK2->stateValue("power").toBool(), true);
QCOMPARE(relayK1->stateValue("power").toBool(), false);
setMeterW(-2500); cycle(tA.addSecs(400)); // budget 2500+1500=4000 ≥ P4×1,2 → état 4 (hold écoulé)
QCOMPARE(etatPac(), 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=tA+400) → c'est la ZONE MORTE qui tient l'état 4, pas le verrou.
setMeterW(-300); cycle(tA.addSecs(800)); // budget 300+3000=3300 ∈ [3000,3600) → reste 4
QCOMPARE(etatPac(), 4);
setMeterW(-100); cycle(tA.addSecs(1200)); // budget 3100 → reste 4
QCOMPARE(etatPac(), 4);
setMeterW(-500); cycle(tA.addSecs(1600)); // budget 3500 → reste 4
QCOMPARE(etatPac(), 4);
// En-dessous de P4×1,0 → sort enfin de l'état 4 (vers 3).
setMeterW(200); cycle(tA.addSecs(2000)); // import 200 → budget -200+3000=2800 < 3000 → état 3
QCOMPARE(etatPac(), 3);
// --- Volet 3 : protection court-cycling (changement avant minStateHold → GELÉ) ---
// lastSwitch=tA+2000. À tA+2100 (elapsed 100 < hold 300) : surplus abondant mais GELÉ en 3.
setMeterW(-3000); cycle(tA.addSecs(2100));
QCOMPARE(etatPac(), 3); // gelé malgré budget ≥ P4×1,2 (protection compresseur)
// À tA+2400 (elapsed 400 > hold) : MÊME surplus → bascule en 4. Seul le temps simulé a changé.
setMeterW(-3000); cycle(tA.addSecs(2400));
QCOMPARE(etatPac(), 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);
// LES DEUX charges viennent de la CONFIGURATION (lot B-bis) : c'est le chemin de
// production, et le seul depuis que la PAC n'a plus d'exemplaire codé en dur. Les
// mélanger — un adaptateur enregistré en direct + un store — ne marche pas, et c'est
// voulu : rebuildLoadAdapters() met en état sûr puis détruit tout ce que la config ne
// référence pas (ECS-413). Une charge « en dur » disparaîtrait au premier SetLoadConfig.
const QString cfgWfPath = QDir::tempPath() + "/etm-loadcfg-waterfall.json";
QFile::remove(cfgWfPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgWfPath.toUtf8());
LoadConfigStore *storeWf = new LoadConfigStore(arb);
arb->setLoadConfigStore(storeWf);
LoadConfigs cfgsWf;
// ECS : 1 palier à 2400 W (fixed). La charge ne tire encore rien (currentPowerW=0).
cfgsWf.append(LoadConfig::fromMap(QVariantMap{
{"id", eId.toString()}, {"label", "ECS waterfall"}, {"adapter", "etmvariableload"},
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 2400}, {"maxPowerW", 2400},
{"priority", ecsPrio}, {"enabled", true}}));
cfgsWf.append(LoadConfig::fromMap(sgReadyConfigMap("pac-wf", "PAC waterfall", j1, j2,
1500.0, 3000.0, 300, pacPrio)));
QString errWf;
QVERIFY2(storeWf->setConfigs(cfgsWf, &errWf), errWf.toUtf8());
m2->setStateValue("currentPower", -3000); // export 3000 W
// Cycle d'AMORÇAGE : il désarme le verrou à froid de la PAC (ECS-412) sans rien
// décider d'intéressant. La mesure se fait au cycle suivant, verrou expiré — sinon on
// mesurerait la protection compresseur au lieu de l'ordre de service.
arb->simulationCallUpdate(t0);
QCoreApplication::processEvents();
arb->simulationCallUpdate(t0.addSecs(301));
QCoreApplication::processEvents();
ecsSetpointOut = mechanismInt(arb, eId.toString(), "setpointW");
pacStateOut = mechanismInt(arb, "pac-wf", "state");
};
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;
// Le motif est un CODE : ce test ne porte pas sur le texte, seulement sur la
// non-vacuité exigée par la règle 7 (une action au code vide est rejetée).
a.powerW = w; a.reason = { DecisionCode::SurplusSetpoint };
return a;
};
// Les deux sorties sont initialisées par l'APPELANT et vérifiées APRÈS l'appel : un
// QVERIFY en échec sort de CE lambda, pas du test, et laisserait sinon des pointeurs
// non initialisés déréférencés juste après — comportement indéfini au lieu d'un échec
// de test lisible.
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 = nullptr; QObject *owner = nullptr;
freshSetup(tm, owner);
QVERIFY(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 = nullptr; QObject *owner = nullptr;
freshSetup(tm, owner);
QVERIFY(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 = nullptr; QObject *owner = nullptr;
freshSetup(tm, owner);
QVERIFY(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 = { DecisionCode::SurplusSetpoint }; 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
}
// [ECS-411-b] Collecteur de messages, pour asserter sur ce que le journal DIT. Sans lui,
// on ne pourrait tester que le palier déduit — or le défaut visé est précisément qu'un
// palier juste était annoncé par un silence.
static QStringList s_journal;
static QtMessageHandler s_handlerPrecedent = nullptr;
static void collecterJournal(QtMsgType t, const QMessageLogContext &c, const QString &m)
{
s_journal << m;
if (s_handlerPrecedent)
s_handlerPrecedent(t, c, m);
}
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é.
// [ECS-411-b] Et la reprise DOIT s'annoncer quand même. Jusqu'au 2026-08-13 la
// correspondance exacte ne journalisait que si le palier était non nul (`if (i > 0)`),
// si bien qu'au palier 0 — le cas de l'armement minOff, celui qui compte — le journal
// était muet. Un silence ne distingue pas « relais lus, tous ouverts » de « reprise
// non exécutée ». Le test porte sur le TEXTE, comme pour ECS-309.
{
t500->setStateValue("power", false);
t1000->setStateValue("power", false);
t1500->setStateValue("power", false);
s_journal.clear();
s_handlerPrecedent = qInstallMessageHandler(collecterJournal);
RelayRouter *r = new RelayRouter(tm, "ecs-off", "ECS éteint", relays, 0, 0, 1, LoadNeeds(), arb);
qInstallMessageHandler(s_handlerPrecedent);
QCOMPARE(r->currentStage(), 0);
QCOMPARE(qRound(r->currentSetpointW()), 0);
const QString repr = s_journal.filter(QStringLiteral("reprise au démarrage")).value(0);
QVERIFY2(!repr.isEmpty(), "ECS-411-b : aucune reprise annoncée au palier 0");
QVERIFY2(repr.contains(QStringLiteral("palier 0 W")), qUtf8Printable(repr));
// Ce qui a été LU, pas seulement ce qui en est déduit : les trois relais nommés,
// chacun avec sa puissance et son état. C'est la seconde moitié d'ECS-411-b.
QVERIFY2(repr.contains(QStringLiteral("500W)=ouvert")), qUtf8Printable(repr));
QVERIFY2(repr.contains(QStringLiteral("1000W)=ouvert")), qUtf8Printable(repr));
QVERIFY2(repr.contains(QStringLiteral("1500W)=ouvert")), qUtf8Printable(repr));
QVERIFY2(repr.contains(QStringLiteral("somme lue 0 W")), qUtf8Printable(repr));
QVERIFY2(!repr.contains(QStringLiteral("INJOIGNABLE")), qUtf8Printable(repr));
}
// Cas 1-bis — [ECS-411-b] un relais INJOIGNABLE, supposé fermé. Le palier annoncé
// peut être le même qu'un cas nominal ; c'est le relevé qui doit lever le doute, et
// le niveau qui doit passer en avertissement. Sans ça, « palier repris : 0 » couvre
// deux situations appelant des gestes opposés.
{
t500->setStateValue("power", false);
t1000->setStateValue("power", false);
t1500->setStateValue("power", false);
// Un thingId qui ne correspond à aucun Thing configuré : branche « absent ».
const QList<LoadConfigRelay> avecFantome({
{r500.toString(), 500},
{QUuid("{deadbeef-0000-4000-8000-000000000001}").toString(), 1000} });
s_journal.clear();
s_handlerPrecedent = qInstallMessageHandler(collecterJournal);
RelayRouter *rf = new RelayRouter(tm, "ecs-fantome", "ECS relais absent",
avecFantome, 0, 0, 1, LoadNeeds(), arb);
qInstallMessageHandler(s_handlerPrecedent);
Q_UNUSED(rf)
const QString repr = s_journal.filter(QStringLiteral("reprise au démarrage")).value(0);
QVERIFY2(!repr.isEmpty(), "ECS-411-b : aucune reprise annoncée avec relais absent");
QVERIFY2(repr.contains(QStringLiteral("INJOIGNABLE")), qUtf8Printable(repr));
QVERIFY2(repr.contains(QStringLiteral("supposé fermé")), qUtf8Printable(repr));
QVERIFY2(repr.contains(QStringLiteral("INCERTAIN")), qUtf8Printable(repr));
// Le relais lisible reste annoncé comme lu, et non noyé dans l'incertitude.
QVERIFY2(repr.contains(QStringLiteral("500W)=ouvert")), qUtf8Printable(repr));
}
// Cas 1-ter — [ECS-411-b] le relais dont l'encodage SOLO a été évincé par la
// déduplication. Avec 500/1000/1500, le palier 1500 est encodé {500,1000} (masque 3,
// rencontré avant le masque 4), donc R1500 n'apparaît seul dans AUCUN encodage. Sa
// nominale était alors redéduite à 0, et un démarrage sur ce seul relais donnait
// sumW = 0 → palier 0 annoncé pendant que 1500 W circulaient. C'est l'annonce en
// sous-estimation qu'ECS-411 interdit, et le silence au palier 0 la couvrait.
// Trouvé par le relevé d'ECS-411-b, qui affichait « (0W) » pour ce relais.
{
t500->setStateValue("power", false);
t1000->setStateValue("power", false);
t1500->setStateValue("power", true); // SEUL R1500 fermé
s_journal.clear();
s_handlerPrecedent = qInstallMessageHandler(collecterJournal);
RelayRouter *r = new RelayRouter(tm, "ecs-dedup", "ECS dédup", relays, 0, 0, 1, LoadNeeds(), arb);
qInstallMessageHandler(s_handlerPrecedent);
QCOMPARE(qRound(r->currentSetpointW()), 1500); // JAMAIS moins que ce qui circule
const QString repr = s_journal.filter(QStringLiteral("reprise au démarrage")).value(0);
QVERIFY2(!repr.isEmpty(), "ECS-411-b : aucune reprise annoncée");
QVERIFY2(repr.contains(QStringLiteral("palier 1500 W")), qUtf8Printable(repr));
QVERIFY2(repr.contains(QStringLiteral("1500W)=fermé")), qUtf8Printable(repr));
QVERIFY2(repr.contains(QStringLiteral("somme lue 1500 W")), qUtf8Printable(repr));
}
// 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 = { DecisionCode::SurplusSetpoint };
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)
// La ligne du JOURNAL, RENDUE depuis le code — c'est elle que les assertions
// ci-dessous inspectent. Le test porte donc toujours sur le TEXTE PUBLIÉ, et il
// prouve en même temps que la refonte {code, params} ne l'a pas déplacé.
return renderFr(a.reason, routeur->descriptor().label);
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
// §12 / f134048 — ce cas n'est plus « surplus insuffisant » mais BELOW_MIN_POWER : il y a
// un budget, il n'est simplement pas au bon FORMAT. Le motif doit donc nommer le palier —
// la distinction envoie l'installateur attendre le soleil ou relire son câblage, pas la
// même chose. Ce qui reste inchangé, et qui est l'objet du test : ce n'est pas le verrou.
QVERIFY2(mBudget.contains("palier", 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 = { DecisionCode::SurplusSetpoint };
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 = { DecisionCode::SurplusSetpoint };
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 = { DecisionCode::SurplusSetpoint }; 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.
// Enregistré auprès de l'arbitre : c'est LUI qui porte l'issue (règle 7-c), donc la
// levée passe par le chemin RPC réel et non par un appel direct à l'adaptateur.
arb->registerRelayRouter(r);
// [Règle 7-c] L'issue DOIT être journalisée par l'ARBITRE, et distinguer trois cas :
// défaut levé, aucun défaut à lever, adaptateur introuvable. C'est le seul levier de
// reprise à distance : sans issue lisible, un opérateur ne sait pas si son geste a agi,
// relance, puis conclut que le système est cassé. Le test porte sur le TEXTE publié.
QVERIFY(r->faulted());
s_journal.clear();
s_handlerPrecedent = qInstallMessageHandler(collecterJournal);
const bool leve = arb->clearLoadFault(QStringLiteral("ko"));
qInstallMessageHandler(s_handlerPrecedent);
QVERIFY(leve);
QVERIFY(!r->faulted());
// [ECS-111, 2026-08-29] La charge reste INDISPONIBLE, et c'est la bonne réponse : ce
// routeur déclare `rKo`, un relais JAMAIS configuré. ClearLoadFault lève le défaut
// d'ÉCRITURE — c'est sa promesse, et `!r->faulted()` juste au-dessus la vérifie — mais il
// ne fait pas exister un Thing. Annoncer `available: true` ici serait vrai pendant un
// cycle et faux au suivant, la première écriture rouvrant le défaut.
//
// Ce test se servait d'un relais inexistant comme RACCOURCI pour forcer un échec
// d'écriture. Le raccourci a désormais un sens propre — câblage incomplet — et les deux
// causes se distinguent : le défaut collant se lève, le câblage manquant non.
QCOMPARE(r->telemetry().available, false);
QCOMPARE(r->runtimeView(t0).faultCode, QStringLiteral("THING_MISSING"));
const QString issue1 = s_journal.filter(QStringLiteral("ClearLoadFault")).join(QStringLiteral(" | "));
QVERIFY2(issue1.contains(QStringLiteral("défaut LEVÉ")), qUtf8Printable(issue1));
// Second appel, charge SAINE : « aucun défaut à lever » doit se lire, et ne pas se
// confondre avec le premier cas. Le RPC réussit — il n'y a pas d'erreur — mais l'issue
// est différente, et c'est précisément la distinction que l'opérateur ne pouvait pas
// faire avant.
s_journal.clear();
s_handlerPrecedent = qInstallMessageHandler(collecterJournal);
const bool leve2 = arb->clearLoadFault(QStringLiteral("ko"));
qInstallMessageHandler(s_handlerPrecedent);
QVERIFY2(leve2, "la charge existe : l'appel aboutit, même sans défaut à lever");
const QString issue2 = s_journal.filter(QStringLiteral("ClearLoadFault")).join(QStringLiteral(" | "));
QVERIFY2(issue2.contains(QStringLiteral("AUCUN défaut")), qUtf8Printable(issue2));
QVERIFY2(!issue2.contains(QStringLiteral("défaut LEVÉ")), qUtf8Printable(issue2));
// Troisième cas : adaptateur introuvable. Ni l'un ni l'autre des deux précédents.
s_journal.clear();
s_handlerPrecedent = qInstallMessageHandler(collecterJournal);
const bool leve3 = arb->clearLoadFault(QStringLiteral("charge-qui-n-existe-pas"));
qInstallMessageHandler(s_handlerPrecedent);
QVERIFY2(!leve3, "un identifiant inconnu ne doit pas réussir");
const QString issue3 = s_journal.filter(QStringLiteral("ClearLoadFault")).join(QStringLiteral(" | "));
QVERIFY2(issue3.contains(QStringLiteral("introuvable")), qUtf8Printable(issue3));
// Les trois issues sont distinctes : c'est ce que la règle 7-c exige.
QVERIFY(issue1 != issue2 && issue2 != issue3 && issue1 != issue3);
#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 = { DecisionCode::SgRecommended }; 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.
tK1->setStateValue("power", false); // AVANT construction : c'est ce que la reprise lira
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);
// ECS-411 appliqué au SG-Ready : l'état de départ est RELU, pas supposé. K1 est lu ouvert,
// K2 est INJOIGNABLE donc supposé fermé (LM-104) — le motif {K2} est exactement l'encodage
// de l'état 1 sur ce câblage. L'adaptateur démarre donc en 1, et non en 2 « par défaut ».
QCOMPARE(pac->currentState(), 1);
// ECS-412 — armement à froid : la première commande est REFUSÉE (minStateHold 900 s), et
// c'est ce refus qui pose l'estampille. Le compte à rebours court donc dès maintenant :
// l'armement est paresseux, pas permanent.
pac->applyAction(st(4), t0);
QCOMPARE(pac->currentState(), 1);
pac->applyAction(st(4), t0.addSecs(901)); // verrou expiré → l'échelle d'échec démarre
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(961));
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(8100));
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, {
// [ETM 3g-2] 7 A → 6 A. La borne est ARRÊTÉE à ce pas (ligne
// suivante) : la valeur relue est la dernière consigne émise avant
// la coupure, et elle diffère d'un ampère parce que la conversion
// watts→ampères se fait désormais APRÈS l'allocation du waterfall,
// au lieu d'être calculée nativement en ampères par le proxy.
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6) ,
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, {
// [ETM 3g-2] 88 % → 87 %, et 30 A → 6 A. Le CHEMIN diffère d'un
// ampère par-ci par-là (arrondi de la conversion, cf. pas 444) et
// l'écart s'accumule sur 900 pas de rétroaction. Ce qui compte est
// au pas 960, et c'est inchangé : la voiture est à 100 % à
// l'échéance. Les 14 autres scénarios sont identiques au bit près.
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 87)
}
},
{
960, { // 22:00
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, {
// [ETM 3g-2] La borne ne charge PAS encore à ce pas, et c'est le
// mécanisme d'échéance qui fonctionne : à 89 % avec une heure
// devant elle, la projection dit qu'il reste du temps, donc rien
// n'est forcé. Le forçage intervient plus tard, et le pas 960
// vérifie qu'il a suffi — voiture à 100 % à l'échéance.
// Le proxy forçait dès ce pas parce que sa trajectoire de la
// journée laissait la voiture un peu moins chargée.
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 89)
}
},
{
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::testDecisionReasonRendering()
{
#ifndef ETM_ARBITRATOR
QSKIP("testDecisionReasonRendering nécessite ETM_ARBITRATOR.");
#else
// [Lot B §1 / critère 6] « Journal INCHANGÉ ».
//
// Depuis la refonte, LoadAction::reason porte un CODE et des PARAMÈTRES ; la ligne
// française est RENDUE par renderFr(). Le risque de la manœuvre est là et nulle part
// ailleurs : une phrase reformulée « au passage ». Les motifs d'ECS-309 et ECS-309-b sont
// le fruit de corrections coûteuses au banc — les réécrire enverrait à nouveau
// diagnostiquer le mauvais problème, avec l'autorité d'une explication.
//
// Ce tableau est donc la copie MOT POUR MOT des QStringLiteral d'avant la refonte. Il se
// relit contre `git show HEAD:energyplugin/etm/scheduler/rulebasedscheduler.cpp`. Un test
// « motif non vide » n'aurait rien vu ici — c'est précisément la leçon d'ECS-309.
struct Cas { DecisionReason reason; QString label; QString attendu; };
const QList<Cas> table = {
// --- EV (proxy amont) : aucune charge nommée, donc aucun libellé ---
{ { DecisionCode::EvSurplus }, QString(),
QStringLiteral("Surplus PV disponible — recharge solaire") },
{ { DecisionCode::EvSpotMarket }, QString(),
QStringLiteral("Tarif aWATTar favorable — recharge heure creuse") },
{ { DecisionCode::EvDeadline }, QString(),
QStringLiteral("Deadline VE approchante — recharge prioritaire") },
{ { DecisionCode::EvEcoMin }, QString(),
QStringLiteral("Aucun surplus — courant minimum maintenu (mode EcoMin)") },
{ { DecisionCode::EvIdle }, QString(),
QStringLiteral("Aucun surplus disponible — recharge suspendue") },
// --- Charges pilotées en watts ---
{ { DecisionCode::LoadUnavailable, {{"frozenW", 1500}} }, QStringLiteral("Chauffe-eau"),
QStringLiteral("Chauffe-eau INDISPONIBLE — aucune commande émise ; 1500 W toujours "
"comptés au budget (levée par ClearLoadFault)") },
{ { DecisionCode::LockMinOn, {{"appliedW", 2000}, {"budgetW", 600}} },
QStringLiteral("ECS prioritaire"),
QStringLiteral("Verrou minOn — ECS prioritaire maintenue à 2000 W (puissance engagée, "
"budget 600 W) ; le résidu en tient compte") },
{ { DecisionCode::LockMinOff, {{"budgetW", 7706}, {"requestedW", 1000}} },
QStringLiteral("Charge à froid"),
QStringLiteral("Verrou minOff — Charge à froid ne peut pas s'enclencher avant la fin "
"de la fenêtre (budget 7706 W disponible, palier 1000 W refusé)") },
{ { DecisionCode::LockMinOffCapped,
{{"appliedW", 1000}, {"budgetW", 4000}, {"requestedW", 2500}} },
QStringLiteral("ECS relais"),
QStringLiteral("Verrou minOff — ECS relais plafonnée à 1000 W avant la fin de la "
"fenêtre (budget 4000 W disponible, palier 2500 W refusé)") },
{ { DecisionCode::SurplusSetpoint,
{{"budgetW", 3000}, {"setpointW", 2400}, {"stepped", true}} },
QStringLiteral("Chauffe-eau"),
QStringLiteral("Surplus PV 3000 W — Chauffe-eau consigne 2400 W (palier)") },
{ { DecisionCode::SurplusSetpoint,
{{"budgetW", 3000}, {"setpointW", 3000}, {"stepped", false}} },
QStringLiteral("Routeur PV"),
QStringLiteral("Surplus PV 3000 W — Routeur PV consigne 3000 W") },
{ { DecisionCode::SurplusInsufficient, {{"budgetW", -389}} },
QStringLiteral("Charge à froid 3"),
QStringLiteral("Surplus insuffisant (-389 W) — Charge à froid 3 à 0 W") },
// --- SG-Ready ---
{ { DecisionCode::LockMinStateHold, {{"state", 3}} }, QStringLiteral("PAC test"),
QStringLiteral("Verrou minStateHold — PAC test maintenue état 3 (court-cycling PAC)") },
{ { DecisionCode::SgForced, {{"budgetW", 4000}, {"estimatedW", 3000}} },
QStringLiteral("PAC test"),
QStringLiteral("Surplus abondant 4000 W — PAC test forcée (état 4, ~3000 W)") },
{ { DecisionCode::SgRecommended, {{"budgetW", 2000}, {"estimatedW", 1500}} },
QStringLiteral("PAC test"),
QStringLiteral("Surplus PV 2000 W — PAC test recommandée (état 3, ~1500 W)") },
// §12 / f134048 — SG_NORMAL ne sort plus QUE pour un budget nul ou négatif : le cas
// « budget présent mais sous l'état 3 » est passé à BELOW_MIN_POWER. La phrase dit
// donc « aucun surplus », là où « insuffisant » couvrait aussi, et à tort, un budget
// de 933 W devant un état à 1 500 W.
{ { DecisionCode::SgNormal, {{"budgetW", 1000}} }, QStringLiteral("PAC test"),
QStringLiteral("Aucun surplus à allouer (1000 W) — PAC test en normal (état 2, non pilotée)") },
// 3g-1 — les deux motifs sur lesquels le banc s'appuie pour distinguer « il n'y a
// rien » de « il y en a, mais pas au bon format » ou « une règle le retient ». Leur
// TEXTE n'était pas éprouvé : ils ont rejoint le catalogue sans ligne ici.
{ { DecisionCode::BatteryReserve,
{{"withheldW", 3200}, {"socPercent", 35}, {"reservePercent", 80}} },
QStringLiteral("Chauffe-eau"),
QStringLiteral("3200 W de surplus retenus — batterie à 35 %, sous la réserve de 80 % ; "
"aucune charge n'est servie") },
{ { DecisionCode::BelowMinPower, {{"budgetW", 1096}, {"minPowerW", 2070}} },
QStringLiteral("Borne du garage"),
QStringLiteral("Budget 1096 W sous le premier palier de Borne du garage (2070 W) — 0 W, "
"le budget passe à la charge suivante") },
// La variante à ÉTATS nomme l'état hors d'atteinte : sans lui, une phrase vague en
// remplacerait une autre (note de DecisionCode::BelowMinPower).
{ { DecisionCode::BelowMinPower, {{"budgetW", 933}, {"minPowerW", 1500}, {"state", 3}} },
QStringLiteral("PAC test"),
QStringLiteral("Budget 933 W sous l'état 3 de PAC test (1500 W estimés) — état 2, "
"le budget passe à la charge suivante") },
// 3g-2 — le démarrage sous tolérance est une décision d'ACHETER : la phrase doit
// chiffrer ce qui est soutiré, sans quoi elle se lit comme une erreur d'allocation.
{ { DecisionCode::EvGridStart,
{{"budgetW", 2200}, {"floorW", 4140}, {"gridW", 1940}} },
QStringLiteral("Borne du garage"),
QStringLiteral("Démarrage de Borne du garage : plancher 4140 W, dont 1940 W SOUTIRÉS au "
"réseau (2200 W de surplus disponibles, tolérance d'acquisition)") },
// 3g-2 — et quand c'est la limite de phase qui interdit, elle est NOMMÉE : le geste
// attendu n'est pas d'attendre le soleil, c'est de regarder l'abonnement.
{ { DecisionCode::PhaseLimit, {{"limitW", 1200}, {"requiredW", 4140}} },
QStringLiteral("Borne du garage"),
QStringLiteral("Limite de phase : 1200 W disponibles pour Borne du garage, sous les "
"4140 W qu'elle exige — aucun démarrage possible ce cycle") },
// --- Sécurité et cycle de vie ---
{ { DecisionCode::DegradedL2 }, QString(),
QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)") },
{ { DecisionCode::SafeStateRelay }, QString(),
QStringLiteral("Charge désactivée — mise en état sûr avant retrait (ECS-413)") },
{ { DecisionCode::SafeStateSetpoint }, QString(),
QStringLiteral("Charge désactivée — mise en état sûr (0 W) avant retrait (ECS-413)") },
{ { DecisionCode::SafeStateSgReady }, QString(),
QStringLiteral("Charge désactivée — mise en état sûr (état 2) avant retrait (ECS-413)") },
};
QSet<QString> codesCouverts;
for (const Cas &c : table) {
const QString rendu = renderFr(c.reason, c.label);
QVERIFY2(rendu == c.attendu,
qUtf8Printable(QStringLiteral("code %1\n rendu : %2\n attendu : %3")
.arg(c.reason.code, rendu, c.attendu)));
codesCouverts.insert(c.reason.code);
}
// Le catalogue est FERMÉ : tout code ajouté sans ligne de tableau doit faire échouer ce
// test, sinon la garantie « le journal est rendu depuis le code » se perd au premier oubli.
const QStringList catalogue = {
DecisionCode::EvSurplus, DecisionCode::EvSpotMarket, DecisionCode::EvDeadline,
DecisionCode::EvEcoMin, DecisionCode::EvIdle, DecisionCode::LoadUnavailable,
DecisionCode::LockMinOn, DecisionCode::LockMinOff, DecisionCode::LockMinOffCapped,
DecisionCode::SurplusSetpoint, DecisionCode::SurplusInsufficient,
DecisionCode::LockMinStateHold, DecisionCode::SgForced, DecisionCode::SgRecommended,
DecisionCode::SgNormal, DecisionCode::DegradedL2, DecisionCode::SafeStateRelay,
DecisionCode::SafeStateSetpoint, DecisionCode::SafeStateSgReady,
// 3g-1 : ajoutés au catalogue sans être ajoutés ICI — la clôture avait dérivé, donc
// la garantie « tout code a une ligne de rendu éprouvée » ne tenait plus.
DecisionCode::BatteryReserve, DecisionCode::BelowMinPower,
// 3g-2
DecisionCode::EvGridStart, DecisionCode::PhaseLimit };
for (const QString &code : catalogue)
QVERIFY2(codesCouverts.contains(code),
qUtf8Printable(QStringLiteral("code %1 sans ligne de rendu attendue").arg(code)));
// Repli d'un code HORS catalogue : jamais une chaîne vide. Un rendu vide ferait passer un
// motif présent-mais-inconnu pour un motif absent — le silence ambigu de la règle 7-c.
const QString inconnu = renderFr(DecisionReason("CODE_QUI_NEXISTE_PAS", {{"x", 1}}),
QStringLiteral("Charge"));
QVERIFY(!inconnu.isEmpty());
QVERIFY2(inconnu.contains("CODE_QUI_NEXISTE_PAS"), qUtf8Printable(inconnu));
QVERIFY(!renderFr(DecisionReason()).isEmpty()); // motif absent : dit, pas tu
#endif
}
void Simulation::testLoadTelemetryRpc()
{
#ifndef ETM_ARBITRATOR
QSKIP("testLoadTelemetryRpc nécessite ETM_ARBITRATOR.");
#else
// [Lot B §2] La frontière de télémétrie, de bout en bout : c'est la couche que l'app
// consomme. Le constat qui a motivé ce lot était que l'état runtime de l'arbitre ne
// franchissait AUCUNE frontière RPC — 90 s d'écoute, arbitre actif, zéro notification.
//
// Couvre les critères d'acceptation 1 à 5 : notification non nulle et cohérente,
// snapshot ≡ notification, reprise à froid du mode dégradé, arbitre figé détectable,
// zéro langue naturelle dans la charge utile.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-telemetry.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
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();
// Le harnais de simulation n'abonne PAS le client aux notifications (il charge son
// experience mock APRÈS initTestCase, donc EnergyTestBase saute l'abonnement). Or ce test
// porte précisément sur les notifications : sans cet appel, il « passerait » en observant
// le silence qu'il est censé faire échouer.
enableNotifications({"NymeaEnergy"});
QUuid meterId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(meterId);
Thing *meter = tm->findConfiguredThing(meterId);
QVERIFY(meter);
meter->setStateValue("connected", true);
// ---- 0. AVANT tout cycle : « pas encore d'arbitrage » se DIT, il ne se devine pas ------
// timestamp ABSENT (omis, jamais nul) : forger un instant se lirait « cycle exécuté, rien à
// allouer », soit exactement le mensonge que ce champ existe pour empêcher.
QVariant resp = injectAndWait("NymeaEnergy.GetLoadTelemetry");
QVariantMap vide = resp.toMap().value("params").toMap();
QVERIFY2(!vide.contains("timestamp"), "timestamp forgé avant le premier cycle");
QVERIFY(vide.contains("degradedMode"));
QCOMPARE(vide.value("degradedMode").toBool(), false);
QVERIFY(vide.value("loads").toList().isEmpty());
// ---- Trois mécanismes, un seul budget : l'union discriminée doit sortir ENTIÈRE --------
QUuid rA = addPowerSwitch(1000, 26661); // relay-router (1 relais, minOn 300 s)
QUuid k1 = addPowerSwitch(0, 26662); // sg-ready K1
QUuid k2 = addPowerSwitch(0, 26663); // sg-ready K2
QUuid varId = addEtmVariableLoad(27040); // etmvariableload dynamic
Thing *relayA = tm->findConfiguredThing(rA);
Thing *varThing = tm->findConfiguredThing(varId);
QVERIFY(relayA && varThing);
QVERIFY(tm->findConfiguredThing(k1) && tm->findConfiguredThing(k2));
varThing->setStateValue("currentPowerW", 0);
LoadConfigStore *store = new LoadConfigStore(arb);
arb->setLoadConfigStore(store);
LoadConfigs cfgs;
cfgs.append(LoadConfig::fromMap(QVariantMap{
{"id", varId.toString()}, {"label", "Routeur PV"}, {"adapter", "etmvariableload"},
{"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 1}, {"enabled", true},
{"domain", "ecs"}}));
cfgs.append(LoadConfig::fromMap(QVariantMap{
{"id", "pac-telemetrie"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"},
{"priority", 2}, {"enabled", true}, {"domain", "hvac"},
{"sgReady", QVariantMap{
{"minStateHoldS", 300},
{"states", QVariantList()
<< QVariantMap{{"state", 1}, {"relays", QVariantList() << k1.toString()},
{"estimatedPowerW", 0.0}}
<< QVariantMap{{"state", 2}, {"relays", QVariantList()},
{"estimatedPowerW", 0.0}}
<< QVariantMap{{"state", 3}, {"relays", QVariantList() << k2.toString()},
{"estimatedPowerW", 800.0}}
<< QVariantMap{{"state", 4},
{"relays", QVariantList() << k1.toString() << k2.toString()},
{"estimatedPowerW", 1600.0}}}}}}));
cfgs.append(LoadConfig::fromMap(QVariantMap{
{"id", "ecs-relais-telemetrie"}, {"label", "ECS relais"}, {"adapter", "relay-router"},
{"mode", "fixed"}, {"priority", 3}, {"enabled", true}, {"domain", "ecs"},
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, {"powerW", 1000}}},
{"minOnS", 300}, {"minOffS", 0}}));
QString err;
QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8());
// ---- 1. Le CONSTAT rejoué : un cycle d'arbitrage produit-il une notification ? ---------
QSignalSpy notificationSpy(m_mockTcpServer, &MockTcpServer::outgoingData);
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
meter->setStateValue("currentPower", -6000); // 6000 W exportés
arb->simulationCallUpdate(t0);
QCoreApplication::processEvents();
QVariantList notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged");
QVERIFY2(!notifs.isEmpty(), "aucune notification LoadTelemetryChanged — c'est le constat "
"d'origine, arbitre actif et frontière RPC muette");
QVariantMap premier = notifs.last().toMap().value("params").toMap();
// PREMIER cycle : la PAC est GELÉE par l'armement à froid du minStateHold (ECS-412). Ce
// n'est pas un détail de test — c'est ce que l'app verra après CHAQUE redémarrage de
// nymead, pendant la durée configurée. La télémétrie doit donc le dire, et le dire en
// clair : un verrou nommé, des secondes restantes, et un motif qui accuse le verrou et
// non le budget.
{
QVariantMap parIdC1;
for (const QVariant &v : premier.value("loads").toList())
parIdC1.insert(v.toMap().value("loadId").toString(), v.toMap());
const QVariantMap pacC1 = parIdC1.value("pac-telemetrie").toMap();
QCOMPARE(pacC1.value("mechanism").toMap().value("state").toInt(), 2);
QCOMPARE(qRound(pacC1.value("allocatedW").toDouble()), 0);
QCOMPARE(pacC1.value("decision").toMap().value("code").toString(),
QString(DecisionCode::LockMinStateHold));
QCOMPARE(pacC1.value("lock").toMap().value("kind").toString(), QString("minStateHold"));
QCOMPARE(pacC1.value("lock").toMap().value("remainingS").toInt(), 300);
// Le budget du cycle en tient compte : la PAC n'a rien reçu.
QCOMPARE(qRound(premier.value("budget").toMap().value("allocatedW").toDouble()), 4000);
// Le relais, lui, vient de commuter : sa fenêtre minOn est pleine.
const QVariantMap relaisC1 = parIdC1.value("ecs-relais-telemetrie").toMap();
QCOMPARE(relaisC1.value("lock").toMap().value("kind").toString(), QString("minOn"));
QCOMPARE(relaisC1.value("lock").toMap().value("remainingS").toInt(), 300);
}
// ---- Le verrou EXPIRE, et la PAC prend sa part ------------------------------------------
notificationSpy.clear();
const QDateTime t1 = t0.addSecs(301); // > minStateHoldS (300)
arb->simulationCallUpdate(t1);
QCoreApplication::processEvents();
notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged");
QVERIFY2(!notifs.isEmpty(), "le déblocage de la PAC change l'allocation : il doit notifier");
QVariantMap notif = notifs.last().toMap().value("params").toMap();
// Cohérence avec la cascade : Routeur PV (rang 1) prend 3000 (clamp maxPowerW), reliquat
// 3000 → PAC (rang 2) forcée état 4 (~1600), reliquat 1400 → ECS relais (rang 3) 1000.
QCOMPARE(notif.value("degradedMode").toBool(), false);
QVERIFY(notif.contains("timestamp"));
QCOMPARE(notif.value("timestamp").toString(), t1.toUTC().toString(Qt::ISODate));
const QVariantMap budget = notif.value("budget").toMap();
QCOMPARE(qRound(budget.value("surplusW").toDouble()), 6000);
QCOMPARE(qRound(budget.value("evReservedW").toDouble()), 0);
QCOMPARE(qRound(budget.value("allocatedW").toDouble()), 5600);
QCOMPARE(qRound(budget.value("remainingW").toDouble()), 1400);
// ---- L'ABSENCE de `draw` se défend, parce que rien ne casse quand on la comble -----------
//
// `draw` ne porte qu'un champ : `committedW`. Les quatre autres du §14a — authorisedW,
// remainingW, binding, perPhaseBound — sont ABSENTS, jamais à 0. Le contrat le dit, mais un
// contrat ne fait échouer aucune compilation : le jour où quelqu'un ajoutera `authorisedW:
// 0` « pour compléter l'objet », six mois plus tard, rien ne le retiendra — et l'app
// affichera un plafond de soutirage qui n'existe pas, avec sa source, faute d'avoir un
// moyen de distinguer « pas de plafond » de « plafond nul ».
//
// Ce test est ce moyen. S'il casse, ce n'est pas lui qu'il faut corriger : c'est le champ
// ajouté qui doit arriver AVEC son transport §14a et une valeur vraie, pas à zéro.
const QVariantMap draw = notif.value("draw").toMap();
QCOMPARE(draw.keys(), QStringList{QStringLiteral("committedW")});
// Le relais tire déjà ses 1000 W depuis le cycle précédent : ils lui sont RECRÉDITÉS avant
// arrondi (anti-clignotement). Sans ce terme publié, l'identité serait fausse ici même.
QCOMPARE(qRound(budget.value("recreditedW").toDouble()), 1000);
// L'invariant publié : surplus + recrédit − alloué == restant. Le recrédit y figure parce
// que sans lui l'identité serait FAUSSE dès qu'une charge consomme déjà — c'est-à-dire dans
// le cas normal — et le client chercherait un écart inexplicable.
QCOMPARE(qRound(budget.value("surplusW").toDouble() + budget.value("recreditedW").toDouble()
- budget.value("allocatedW").toDouble()),
qRound(budget.value("remainingW").toDouble()));
const QVariantList loads = notif.value("loads").toList();
QCOMPARE(loads.size(), 3); // l'EV n'y est pas : décidé en amont
QVariantMap parId;
double sommeAllouee = 0;
for (const QVariant &v : loads) {
const QVariantMap e = v.toMap();
parId.insert(e.value("loadId").toString(), e);
sommeAllouee += e.value("allocatedW").toDouble();
}
// La somme des allocations RETOMBE sur budget.allocatedW : sans quoi un client qui
// additionne les lignes obtiendrait un total en désaccord avec l'en-tête.
QCOMPARE(qRound(sommeAllouee), qRound(budget.value("allocatedW").toDouble()));
const QVariantMap eVar = parId.value(varId.toString()).toMap();
const QVariantMap ePac = parId.value("pac-telemetrie").toMap();
const QVariantMap eRelay = parId.value("ecs-relais-telemetrie").toMap();
QVERIFY(!eVar.isEmpty() && !ePac.isEmpty() && !eRelay.isEmpty());
// Union discriminée de mécanisme : chaque variante sort avec SA charge utile, et seulement
// la sienne (LM-300/302 appliqués au sens sortant).
QCOMPARE(eVar.value("mechanism").toMap().value("kind").toString(), QString("variable"));
QCOMPARE(qRound(eVar.value("mechanism").toMap().value("setpointW").toDouble()), 3000);
QCOMPARE(qRound(eVar.value("mechanism").toMap().value("percent").toDouble()), 100);
QVERIFY(!eVar.value("mechanism").toMap().contains("state")); // charge utile d'un AUTRE mécanisme
QVERIFY(!eVar.value("mechanism").toMap().contains("stageW"));
QCOMPARE(ePac.value("mechanism").toMap().value("kind").toString(), QString("sgReady"));
QCOMPARE(ePac.value("mechanism").toMap().value("state").toInt(), 4);
QVERIFY(!ePac.value("mechanism").toMap().contains("stageW"));
QCOMPARE(eRelay.value("mechanism").toMap().value("kind").toString(), QString("relay"));
QCOMPARE(qRound(eRelay.value("mechanism").toMap().value("stageW").toDouble()), 1000);
QVERIFY(!eRelay.value("mechanism").toMap().contains("percent"));
// Motifs : des CODES, avec leurs paramètres numériques. Jamais une phrase.
QCOMPARE(eVar.value("decision").toMap().value("code").toString(),
QString(DecisionCode::SurplusSetpoint));
QCOMPARE(eVar.value("decision").toMap().value("params").toMap().value("stepped").toBool(), false);
QCOMPARE(ePac.value("decision").toMap().value("code").toString(), QString(DecisionCode::SgForced));
QCOMPARE(eRelay.value("decision").toMap().value("code").toString(),
QString(DecisionCode::SurplusSetpoint));
QCOMPARE(eRelay.value("decision").toMap().value("params").toMap().value("stepped").toBool(), true);
// Verrous en SECONDES restantes, pas en watts. Les deux charges viennent de commuter à t0,
// donc leur fenêtre est pleine. Le routeur PV n'a AUCUN verrou côté moteur : la clé est
// absente, ce qui est la vérité — et non un verrou à zéro, qui se lirait « fenêtre expirée ».
// La PAC vient de commuter en 4 : sa fenêtre repart pleine.
QCOMPARE(ePac.value("lock").toMap().value("kind").toString(), QString("minStateHold"));
QCOMPARE(ePac.value("lock").toMap().value("remainingS").toInt(), 300);
// Le relais, lui, a commuté au cycle PRÉCÉDENT (t0) : 301 s plus tard son minOn de 300 s a
// EXPIRÉ, et la clé disparaît. Un verrou qui ne s'efface pas serait un verrou permanent —
// le défaut d'ECS-412 —, et l'app afficherait une charge éternellement bloquée.
QVERIFY2(!eRelay.contains("lock"), "le verrou minOn du relais aurait dû expirer");
QVERIFY(!eVar.contains("lock"));
// Disponibilité, et absence de code de défaut sur une installation saine.
for (const QVariant &v : loads) {
QCOMPARE(v.toMap().value("available").toBool(), true);
QVERIFY(!v.toMap().contains("faultCode"));
}
// ---- 2. Snapshot ≡ notification --------------------------------------------------------
// Le Get ne recalcule pas : il rend l'objet publié. Un recalcul donnerait des verrous
// rafraîchis sous un timestamp périmé — un état qui n'a jamais existé.
resp = injectAndWait("NymeaEnergy.GetLoadTelemetry");
QCOMPARE(resp.toMap().value("params").toMap(), notif);
// ---- 5. ZÉRO langue naturelle dans la charge utile -------------------------------------
// Contrôle MÉCANIQUE : toute chaîne émise est ASCII et sans espace. Une phrase française en
// porterait forcément (accents et/ou espaces). Le libellé de la charge, lui, n'entre pas
// dans la charge utile : il est substitué au RENDU, côté journal.
std::function<void(const QVariant &, const QString &)> verifierCodes =
[&](const QVariant &v, const QString &chemin) {
if (v.userType() == QMetaType::QVariantMap) {
const QVariantMap m = v.toMap();
for (auto it = m.constBegin(); it != m.constEnd(); ++it)
verifierCodes(it.value(), chemin + '.' + it.key());
} else if (v.userType() == QMetaType::QVariantList) {
const QVariantList l = v.toList();
for (int i = 0; i < l.size(); ++i)
verifierCodes(l.at(i), QStringLiteral("%1[%2]").arg(chemin).arg(i));
} else if (v.userType() == QMetaType::QString) {
const QString s = v.toString();
for (const QChar &c : s)
QVERIFY2(c.unicode() >= 32 && c.unicode() < 127,
qUtf8Printable(QStringLiteral("%1 : caractère non ASCII dans « %2 »")
.arg(chemin, s)));
QVERIFY2(!s.contains(' '),
qUtf8Printable(QStringLiteral("%1 : espace dans « %2 » — une valeur "
"émise doit être un CODE, pas une phrase")
.arg(chemin, s)));
}
};
verifierCodes(notif, QStringLiteral("telemetry"));
// ---- 4. Arbitre FIGÉ détectable --------------------------------------------------------
// Aucun cycle nouveau : le battement republie l'instantané tel quel. Le client reçoit donc
// un état, et son timestamp N'AVANCE PAS — c'est la preuve de non-fraîcheur. Sans
// battement, « installation stable » et « moteur arrêté » sont le même silence.
notificationSpy.clear();
arb->publishTelemetryHeartbeat();
QCoreApplication::processEvents();
notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged");
QVERIFY2(!notifs.isEmpty(), "aucun battement de cœur");
const QVariantMap battement = notifs.last().toMap().value("params").toMap();
QCOMPARE(battement.value("timestamp").toString(), notif.value("timestamp").toString());
// ---- Le SEUIL : une oscillation de quelques watts ne réveille personne ------------------
// Le routeur est déjà à son plafond ; un surplus voisin ne change ni allocation ni code de
// motif. Sans seuil, chaque cycle notifierait pour rien sur une cible RPi Zero 2W.
notificationSpy.clear();
meter->setStateValue("currentPower", -6010); // +10 W, sous le seuil
arb->simulationCallUpdate(t1.addSecs(60));
QCoreApplication::processEvents();
QVERIFY2(checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged").isEmpty(),
"une variation de 10 W a notifié — le seuil ne mord pas");
// …mais le Get, lui, a bien avancé : l'état INTERNE est à jour même sans notification.
resp = injectAndWait("NymeaEnergy.GetLoadTelemetry");
const QVariantMap c2 = resp.toMap().value("params").toMap();
QCOMPARE(c2.value("timestamp").toString(), t1.addSecs(60).toUTC().toString(Qt::ISODate));
// Deuxième cycle : les charges consomment, donc le RECRÉDIT n'est plus nul — et c'est
// exactement le cas où l'identité serait fausse sans lui. L'allocation TOTALE, elle, n'a
// pas bougé : c'est pour cela que le seuil ne mord pas.
const QVariantMap b2 = c2.value("budget").toMap();
QVERIFY2(qRound(b2.value("recreditedW").toDouble()) > 0, "recrédit nul alors que les charges consomment");
QCOMPARE(qRound(b2.value("allocatedW").toDouble()), 5600);
QCOMPARE(qRound(b2.value("surplusW").toDouble() + b2.value("recreditedW").toDouble()
- b2.value("allocatedW").toDouble()),
qRound(b2.value("remainingW").toDouble()));
// ---- 3. Reprise à froid du mode dégradé ------------------------------------------------
// Un client qui se connecte APRÈS la bascule doit la lire, par GetLoadTelemetry ET par
// GetChargingSchedules. C'est la famille ECS-411 : une notification seule laisse le
// nouveau venu dans un silence ambigu.
notificationSpy.clear();
arb->recordMeterUpdate(t1.addSecs(60));
arb->evaluateMeterFreshness(t1.addSecs(60 + 91));
QCoreApplication::processEvents();
QVERIFY(arb->degradedMode());
notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged");
QVERIFY2(!notifs.isEmpty(), "bascule en mode dégradé sans notification de télémétrie");
const QVariantMap dgr = notifs.last().toMap().value("params").toMap();
QCOMPARE(dgr.value("degradedMode").toBool(), true);
// budget ABSENT : la planification est suspendue, il n'y a pas de budget pour ce cycle.
// Publier des zéros se lirait « arbitrage exécuté, rien à allouer » — l'inverse du vrai.
QVERIFY2(!dgr.contains("budget"), "budget publié en mode dégradé (planification suspendue)");
// Et `draw` avec lui, pour la même raison : sans plan, il n'y a pas de soutirage DÉCIDÉ.
// Un `committedW: 0` s'y lirait « ce cycle n'a rien acheté », alors qu'il n'y a pas eu de
// cycle du tout.
QVERIFY2(!dgr.contains("draw"), "draw publié en mode dégradé (aucun soutirage décidé)");
QVERIFY(!dgr.value("loads").toList().isEmpty());
QVariantMap dgrParId;
for (const QVariant &v : dgr.value("loads").toList()) {
QCOMPARE(v.toMap().value("decision").toMap().value("code").toString(),
QString(DecisionCode::DegradedL2));
QCOMPARE(qRound(v.toMap().value("allocatedW").toDouble()), 0);
dgrParId.insert(v.toMap().value("loadId").toString(), v.toMap());
}
// TROU DE SÉCURITÉ REFERMÉ (lot B-bis). Le repli L2 envoyait un Setpoint(0) à toute la
// table des charges pilotées et un State(2) à une table SÉPARÉE, réservée à la PAC codée en
// dur. Une PAC de CONFIGURATION vit dans la première : elle recevait donc un Setpoint, que
// SgReadyAdapter rejette d'entrée — et rien d'autre. Sous compteur muet elle restait en
// état 4 (forcé) indéfiniment, alors que SAFETY.md §L2 exige l'état 2.
//
// Le défaut était INVISIBLE dans la charge utile : le motif DEGRADED_L2 s'y affichait
// correctement, seule l'ÉCRITURE manquait. C'est pourquoi on vérifie ici les CONTACTS et
// le mécanisme publié, pas le motif.
QCOMPARE(dgrParId.value("pac-telemetrie").toMap()
.value("mechanism").toMap().value("state").toInt(), 2);
QCOMPARE(tm->findConfiguredThing(k1)->stateValue("power").toBool(), false);
QCOMPARE(tm->findConfiguredThing(k2)->stateValue("power").toBool(), false);
// Et la charge en watts est bien coupée elle aussi (le repli ne régresse pas de l'autre côté).
QCOMPARE(qRound(dgrParId.value("ecs-relais-telemetrie").toMap()
.value("mechanism").toMap().value("stageW").toDouble()), 0);
QCOMPARE(relayA->stateValue("power").toBool(), false);
verifierCodes(dgr, QStringLiteral("telemetry-degrade"));
// Le nouveau venu lit l'état par les DEUX portes.
resp = injectAndWait("NymeaEnergy.GetLoadTelemetry");
QCOMPARE(resp.toMap().value("params").toMap().value("degradedMode").toBool(), true);
resp = injectAndWait("NymeaEnergy.GetChargingSchedules");
QVERIFY2(resp.toMap().value("params").toMap().contains("degradedMode"),
"GetChargingSchedules ne rend toujours pas degradedMode (poussé en notification "
"seule : le défaut que ce lot corrige)");
QCOMPARE(resp.toMap().value("params").toMap().value("degradedMode").toBool(), true);
// Sortie du mode dégradé : symétrique, et publiée TOUT DE SUITE. La planification ne
// reprend qu'au cycle suivant ; sans cette republication, la télémétrie annoncerait encore
// degradedMode=true pendant une minute après la levée du repli.
notificationSpy.clear();
arb->recordMeterUpdate(t1.addSecs(300));
QCoreApplication::processEvents();
QVERIFY(!arb->degradedMode());
notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged");
QVERIFY2(!notifs.isEmpty(), "sortie du mode dégradé sans notification");
QCOMPARE(notifs.last().toMap().value("params").toMap().value("degradedMode").toBool(), false);
resp = injectAndWait("NymeaEnergy.GetChargingSchedules");
QCOMPARE(resp.toMap().value("params").toMap().value("degradedMode").toBool(), false);
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
#endif
}
void Simulation::testLoadConfigDomain()
{
#ifndef ETM_ARBITRATOR
QSKIP("testLoadConfigDomain nécessite ETM_ARBITRATOR.");
#else
// [Lot B §3] o:domain — PURE MÉTADONNÉE d'intention. Trois exigences, plus une quatrième
// qui n'est pas dans la liste mais qui est la raison d'être du champ : l'arbitrage ne
// change pas. Un champ « sans effet » se prouve, il ne se déclare pas.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-domain.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
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 ecsId = addEtmVariableLoad(27050);
Thing *ecsThing = tm->findConfiguredThing(ecsId);
QVERIFY(ecsThing);
ecsThing->setStateValue("currentPowerW", 0);
// 1. Charge AVEC domaine + charge SANS domaine : les deux acceptées.
QVariantList configs;
configs << QVariantMap{
{"id", ecsId.toString()}, {"label", "Chauffe-eau"}, {"adapter", "etmvariableload"},
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 2400}, {"maxPowerW", 2400},
{"priority", 1}, {"enabled", true}, {"domain", "ecs"}};
configs << QVariantMap{ // aucune clé « domain » : non classée
{"id", "{cafe0001-0000-0000-0000-000000000001}"}, {"label", "Sans domaine"},
{"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"maxPowerW", 1000},
{"priority", 2}, {"enabled", true}};
QVariant resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", configs}});
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
// 2. ALLER-RETOUR lire → réécrire sans perte. C'est le geste ordinaire de l'app, et c'est
// le mur qu'a touché LM-302 : GetLoadConfig sérialise via le méta-objet, donc TOUTES les
// propriétés déclarées. Tout mécanisme ou champ ajouté embarque ce test.
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
const QVariantList lu = resp.toMap().value("params").toMap().value("loadConfigs").toList();
QCOMPARE(lu.size(), 2);
QVariantMap avec, sans;
for (const QVariant &v : lu) {
if (v.toMap().value("id").toString() == ecsId.toString()) avec = v.toMap();
else sans = v.toMap();
}
QCOMPARE(avec.value("domain").toString(), QString("ecs"));
// Forme EXACTE d'une charge non classée en sortie RPC : la clé est PRÉSENTE et VIDE.
// GetLoadConfig sérialise via le méta-objet, donc toutes les propriétés déclarées sortent,
// y compris vides — comme « sgReady: {} » ou « powerLevels: [] » ici même. C'est le
// corollaire LM-302, et c'est ce qui rend l'aller-retour Get → Set possible. L'assertion
// est exacte plutôt que permissive : une tolérance masquerait un changement de forme, or
// c'est la forme que l'app consomme. Vérifié identique au banc le 2026-08-25.
QVERIFY2(sans.contains("domain"), "la propriété domain doit sortir même vide (méta-objet)");
QCOMPARE(sans.value("domain").toString(), QString());
QVERIFY(sans.value("sgReady").toMap().isEmpty()); // même forme, pour comparaison
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", lu}});
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
for (const QVariant &v : resp.toMap().value("params").toMap().value("loadConfigs").toList())
if (v.toMap().value("id").toString() == ecsId.toString())
QCOMPARE(v.toMap().value("domain").toString(), QString("ecs")); // survit au tour
// 3. Valeur HORS énumération : rejet EN BLOC. Une valeur libre acceptée aujourd'hui
// deviendrait une valeur à supporter le jour où la couche domaine en fera son
// discriminant.
QVariantList mauvais = configs;
QVariantMap m0 = mauvais.first().toMap();
m0["domain"] = "chauffe-eau"; // libellé, pas un code de l'énumération
mauvais[0] = m0;
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", mauvais}});
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorInvalidParameter"));
// Rien n'a été écrasé : la config valide tient toujours.
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
QCOMPARE(resp.toMap().value("params").toMap().value("loadConfigs").toList().size(), 2);
// Toute la liste de l'énumération est acceptée — sinon « fermée » voudrait dire « arbitraire ».
for (const QString &d : LoadConfig::knownDomains()) {
QVariantList une;
une << QVariantMap{
{"id", ecsId.toString()}, {"label", "Chauffe-eau"}, {"adapter", "etmvariableload"},
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 2400}, {"maxPowerW", 2400},
{"priority", 1}, {"enabled", true}, {"domain", d}};
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", une}});
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
}
// 4. AUCUN effet sur l'arbitrage : la charge est servie exactement pareil, domaine ou non.
// Le champ enregistre une intention d'installateur ; il ne pilote rien.
meter->setStateValue("currentPower", -3000);
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
QCoreApplication::processEvents();
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 2400);
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
#endif
}
void Simulation::testRuntimeViewIsSubstantial()
{
#ifndef ETM_ARBITRATOR
QSKIP("testRuntimeViewIsSubstantial nécessite ETM_ARBITRATOR.");
#else
// [Lot B §2] La pure virtuelle `ILoadAdapter::runtimeView()` force chaque adaptateur à
// RÉPONDRE — elle ne l'oblige pas à répondre quelque chose. Une implémentation qui rendrait
// `LoadRuntimeView{}` compilerait tout aussi bien, passerait le dispatch, et sortirait de la
// télémétrie SANS BRUIT : le client verrait une charge sans mécanisme et sans verrou,
// indistinguable d'une charge libre. C'est exactement le silence ambigu que le garde-fou
// prétend empêcher, et le compilateur ne le voit pas.
//
// Ce test est le second verrou : pour CHAQUE adaptateur, la vue doit être SUBSTANTIELLE
// (mécanisme nommé, charge utile non vide) ET porter les valeurs RUNTIME réelles — pas des
// valeurs par défaut qui coïncideraient par hasard. On amène donc chaque adaptateur dans un
// état NON trivial avant de lire.
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);
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
// ---- 1. RelayRouter : palier appliqué en WATTS + verrou minOn en SECONDES --------------
QUuid rA = addPowerSwitch(1000, 26661);
QUuid rB = addPowerSwitch(1500, 26662);
QVERIFY(tm->findConfiguredThing(rA) && tm->findConfiguredThing(rB));
RelayRouter *routeur = new RelayRouter(tm, "rv-relais", "Routeur de relais",
QList<LoadConfigRelay>({ {rA.toString(), 1000}, {rB.toString(), 1500} }),
300, 0, 1, LoadNeeds(), arb);
LoadAction monte;
monte.kind = LoadAction::Setpoint; monte.funding = LoadAction::Surplus;
monte.powerW = 2500; monte.reason = { DecisionCode::SurplusSetpoint };
routeur->applyAction(monte, t0);
QCOMPARE(qRound(routeur->currentSetpointW()), 2500); // état NON trivial : palier haut
LoadRuntimeView vRelais = routeur->runtimeView(t0.addSecs(45));
QCOMPARE(vRelais.mechanismKind, QString("relay"));
// La charge utile porte le palier RÉELLEMENT appliqué, pas 0 ni le plafond.
QCOMPARE(qRound(vRelais.mechanism.value("stageW").toDouble()), 2500);
QCOMPARE(qRound(vRelais.mechanism.value("maxStageW").toDouble()), 2500);
// Verrou : minOn armé à t0, lu 45 s plus tard → 255 s restantes. Une vue vide donnerait 0,
// qui se lirait « fenêtre expirée » — l'inverse de la vérité.
QCOMPARE(vRelais.lockKind, QString("minOn"));
QCOMPARE(vRelais.lockRemainingS, 255);
QVERIFY(vRelais.faultCode.isEmpty()); // installation saine
// …et le verrou EXPIRE : sans cela, un « 255 » constant passerait le test ci-dessus.
QVERIFY(routeur->runtimeView(t0.addSecs(301)).lockKind.isEmpty());
// ---- 2. EtmVariableLoadAdapter : consigne, pourcentage dérivé, aucun verrou -----------
QUuid varId = addEtmVariableLoad(27060);
Thing *varThing = tm->findConfiguredThing(varId);
QVERIFY(varThing);
varThing->setStateValue("currentPowerW", 0);
EtmVariableLoadAdapter *continu = new EtmVariableLoadAdapter(
tm, varId.toString(), "Routeur PV", QList<int>(), 4000, /* minPowerW */ 0, 2, LoadNeeds(), arb);
LoadAction consigne;
consigne.kind = LoadAction::Setpoint; consigne.funding = LoadAction::Surplus;
consigne.powerW = 3000; consigne.reason = { DecisionCode::SurplusSetpoint };
continu->applyAction(consigne, t0);
QCOMPARE(qRound(continu->currentSetpointW()), 3000);
LoadRuntimeView vContinu = continu->runtimeView(t0);
QCOMPARE(vContinu.mechanismKind, QString("variable"));
QCOMPARE(qRound(vContinu.mechanism.value("setpointW").toDouble()), 3000);
QCOMPARE(qRound(vContinu.mechanism.value("maxPowerW").toDouble()), 4000);
// percent est DÉRIVÉ (3000/4000) : une valeur choisie pour qu'aucun défaut plausible —
// 0, 100, ou la consigne elle-même — ne puisse passer par accident.
QCOMPARE(vContinu.mechanism.value("percent").toDouble(), 75.0);
// Aucune fenêtre de verrou côté moteur : l'anti-rebond vit dans le thing. L'ABSENCE est ici
// la vérité, et elle est assertée — pas seulement tolérée.
QVERIFY(vContinu.lockKind.isEmpty());
QVERIFY(vContinu.faultCode.isEmpty());
// Le code de défaut n'est pas du code mort : un thing absent de la configuration le lève.
EtmVariableLoadAdapter *fantome = new EtmVariableLoadAdapter(
tm, "{deadbeef-0000-0000-0000-000000000042}", "Charge fantôme",
QList<int>(), 1000, /* minPowerW */ 0, 3, LoadNeeds(), arb);
QCOMPARE(fantome->runtimeView(t0).faultCode, QString("THING_MISSING"));
// ---- 3. SgReadyAdapter : état normé, estimation, verrou minStateHold ------------------
QUuid k1 = addPowerSwitch(0, 26663);
QUuid k2 = addPowerSwitch(0, 26664);
QVERIFY(tm->findConfiguredThing(k1) && tm->findConfiguredThing(k2));
SgReadyAdapter *pac = new SgReadyAdapter(
tm, "rv-pac", "PAC",
QHash<int, QList<QString>>({ {1, {k1.toString()}}, {2, {}},
{3, {k2.toString()}}, {4, {k1.toString(), k2.toString()}} }),
QHash<int, double>({ {1, 0.0}, {2, 0.0}, {3, 1500.0}, {4, 2600.0} }),
900, 4, arb);
LoadAction etat;
etat.kind = LoadAction::State; etat.funding = LoadAction::Surplus;
etat.state = 3; etat.reason = { DecisionCode::SgRecommended };
// ECS-412 — la première commande tombe dans l'armement à froid : REFUSÉE. C'est elle qui
// pose l'estampille, donc qui lance le compte à rebours (armement PARESSEUX, jamais
// permanent : c'est le blocage circulaire d'ECS-412 qu'on refuse de refaire).
pac->applyAction(etat, t0);
QCOMPARE(pac->currentState(), 2);
const QDateTime tPac = t0.addSecs(901); // verrou expiré
pac->applyAction(etat, tPac);
QCOMPARE(pac->currentState(), 3); // état NON trivial (le défaut est 2)
LoadRuntimeView vPac = pac->runtimeView(tPac.addSecs(120));
QCOMPARE(vPac.mechanismKind, QString("sgReady"));
QCOMPARE(vPac.mechanism.value("state").toInt(), 3);
QCOMPARE(qRound(vPac.mechanism.value("estimatedPowerW").toDouble()), 1500);
QCOMPARE(vPac.lockKind, QString("minStateHold"));
QCOMPARE(vPac.lockRemainingS, 780); // 900 − 120
QVERIFY(vPac.faultCode.isEmpty());
QVERIFY(pac->runtimeView(tPac.addSecs(901)).lockKind.isEmpty()); // le verrou expire
// ECS-110 — une PAC dont l'encodage ne permet pas l'état 2 n'est JAMAIS pilotable. Le taire
// la ferait passer pour une PAC au repos ; le code le dit.
SgReadyAdapter *bancale = new SgReadyAdapter(
tm, "rv-pac-bancale", "PAC sans état 2",
QHash<int, QList<QString>>({ {3, {k2.toString()}} }),
QHash<int, double>({ {3, 1500.0} }), 300, 5, arb);
QCOMPARE(bancale->runtimeView(t0).faultCode, QString("UNUSABLE_ENCODING"));
// ---- 4. EvAdapter : hors charge utile en beta, mais IMPLÉMENTÉ ------------------------
// Déclaré et écrit dès maintenant : en 3g, quand le VE rejoindra le waterfall unifié, il
// n'y aura rien à écrire ici. Une vue vide se découvrirait ce jour-là, en production.
// « ABC » et pas le défaut « All » : le mock n'accepte que A,B,C,AB,BC,AC,ABC.
QUuid chargerId = addCharger(QStringLiteral("ABC"));
QVERIFY(!chargerId.isNull());
arb->simulationCallUpdate(t0); // syncAdapters() crée les EvAdapter
QCoreApplication::processEvents();
EvCharger *borne = arb->registeredEvChargers().value(ThingId(chargerId.toString()));
QVERIFY2(borne, "la borne EV n'est pas enregistrée auprès de l'arbitre");
EvAdapter *ev = new EvAdapter(borne, arb);
LoadRuntimeView vEv = ev->runtimeView(t0);
QCOMPARE(vEv.mechanismKind, QString("evcharger"));
for (const char *cle : {"chargingEnabled", "pluggedIn", "currentA", "phaseCount"})
QVERIFY2(vEv.mechanism.contains(QLatin1String(cle)),
qUtf8Printable(QStringLiteral("clé « %1 » absente de la vue evcharger").arg(cle)));
// ---- 5. Le verrou générique : AUCUNE vue vide, pour AUCUN adaptateur ------------------
// C'est l'assertion qui échouerait si un adaptateur futur se contentait de « return {}; ».
// Elle porte sur la LISTE des adaptateurs, pas sur un cas particulier : ajouter un
// mécanisme sans l'ajouter ici laisse le trou ouvert, donc la liste est nommée en dur.
struct Cas { const char *nom; ILoadAdapter *a; };
const QList<Cas> tous = { {"relay-router", routeur}, {"etmvariableload", continu},
{"sg-ready", pac}, {"evcharger", ev} };
for (const Cas &c : tous) {
const LoadRuntimeView v = c.a->runtimeView(t0);
QVERIFY2(!v.mechanismKind.isEmpty(),
qUtf8Printable(QStringLiteral("%1 : mechanismKind VIDE — vue non implémentée").arg(c.nom)));
QVERIFY2(!v.mechanism.isEmpty(),
qUtf8Printable(QStringLiteral("%1 : charge utile de mécanisme VIDE").arg(c.nom)));
// Une charge utile qui ne porterait que « kind » serait une vue vide déguisée : le
// client n'y lirait rien de l'état réel. On exige au moins une grandeur.
QVERIFY2(v.mechanism.count() >= 2,
qUtf8Printable(QStringLiteral("%1 : charge utile réduite à un discriminant").arg(c.nom)));
}
#endif
}
void Simulation::testSgReadyRestartRecovery()
{
#ifndef ETM_ARBITRATOR
QSKIP("testSgReadyRestartRecovery nécessite ETM_ARBITRATOR.");
#else
// [lot B-bis / ECS-411 appliqué au mécanisme SG-Ready]
//
// Le SgReadyAdapter démarrait à l'état 2 « par défaut », quel que soit l'état réel des
// contacts. Tant que la PAC était codée en dur et construite une seule fois au démarrage du
// plugin, ça se voyait peu. Depuis qu'elle vient de la CONFIGURATION, elle est reconstruite
// à chaque changement de matériel — et l'hypothèse devient un mensonge répété.
//
// Deux dégâts, dont le second est le pire :
// 1. la télémétrie annonce l'état 2 (0 W alloué) pendant que la PAC tourne en forcé ;
// 2. LM-104 — applyAction(état 2) sur un adaptateur qui se CROIT en 2 est idempotent,
// donc MUET : les contacts restent en 4. L'hypothèse de prudence devient un
// MASQUAGE DE PANNE, ce que LM-104 interdit explicitement.
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);
QUuid k2 = addPowerSwitch(0, 26662);
Thing *tK1 = tm->findConfiguredThing(k1);
Thing *tK2 = tm->findConfiguredThing(k2);
QVERIFY(tK1 && tK2);
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
const QHash<int, QList<QString>> encodage({ {1, {k1.toString()}}, {2, {}},
{3, {k2.toString()}},
{4, {k1.toString(), k2.toString()}} });
const QHash<int, double> estim({ {1, 0.0}, {2, 0.0}, {3, 1500.0}, {4, 3000.0} });
// --- 1. Contacts en état 4 : l'adaptateur neuf le LIT, il ne suppose pas ---------------
tK1->setStateValue("power", true);
tK2->setStateValue("power", true);
JournalCapture j1;
SgReadyAdapter *pac4 = new SgReadyAdapter(tm, "pac-r4", "PAC reprise 4",
encodage, estim, 300, 1, arb);
QCOMPARE(pac4->currentState(), 4);
// ECS-411 : la télémétrie annonce la puissance ALLOUÉE de l'état lu, pas zéro. Sans la
// reprise, le waterfall croirait 3 kW libres qui circulent déjà.
QCOMPARE(qRound(pac4->telemetry().currentPowerW), 3000);
// ECS-411-b : le relevé publie ce qui a été LU, contact par contact, avec l'issue.
QVERIFY2(j1.contains("reprise au démarrage"), qUtf8Printable(j1.texte()));
QVERIFY2(j1.contains("état 4"), qUtf8Printable(j1.texte()));
QVERIFY2(j1.contains("fermé"), qUtf8Printable(j1.texte()));
QVERIFY2(j1.contains("correspondance exacte"), qUtf8Printable(j1.texte()));
// --- 2. LM-104 — le corollaire, qui est le vrai piège ----------------------------------
// Une remise à l'état 2 DOIT écrire. Sans la reprise, l'adaptateur se croirait déjà en 2,
// rendrait la main en idempotent, et les contacts resteraient fermés — panne masquée.
LoadAction versNeutre;
versNeutre.kind = LoadAction::State; versNeutre.funding = LoadAction::Surplus;
versNeutre.state = 2; versNeutre.force = true; // force : on teste l'écriture, pas le verrou
versNeutre.reason = { DecisionCode::SafeStateSgReady };
pac4->applyAction(versNeutre, t0);
QCoreApplication::processEvents();
QCOMPARE(tK1->stateValue("power").toBool(), false);
QCOMPARE(tK2->stateValue("power").toBool(), false);
QCOMPARE(pac4->currentState(), 2);
// --- 3. Contacts OUVERTS : l'issue est l'état neutre, et elle se dit quand même --------
// C'est le cas d'ECS-411-b : au palier/état NUL, l'ancien code se taisait, et « contacts
// lus, tous ouverts » ne se distinguait pas de « reprise non exécutée ». Or c'est
// précisément l'état qu'on vient vérifier après un démarrage.
tK1->setStateValue("power", false);
tK2->setStateValue("power", false);
JournalCapture j2;
SgReadyAdapter *pac2 = new SgReadyAdapter(tm, "pac-r2", "PAC reprise 2",
encodage, estim, 300, 1, arb);
QCOMPARE(pac2->currentState(), 2);
QVERIFY2(j2.contains("reprise au démarrage"), "l'issue NULLE doit se journaliser aussi");
QVERIFY2(j2.contains("état 2"), qUtf8Printable(j2.texte()));
QVERIFY2(j2.contains("ouvert"), qUtf8Printable(j2.texte()));
// --- 4. Contact INJOIGNABLE : supposé fermé, et la reprise passe en AVERTISSEMENT ------
// LM-104 — supposer conservateur pour ANNONCER. Le motif {K1 fermé, K2 absent supposé
// fermé} est l'encodage de l'état 4 : on annonce donc 3000 W, jamais 0.
tK1->setStateValue("power", true);
const QString k2absent = QStringLiteral("{deadbeef-2222-2222-2222-222222222222}");
JournalCapture j3;
SgReadyAdapter *pacKo = new SgReadyAdapter(tm, "pac-r-ko", "PAC contact absent",
QHash<int, QList<QString>>({ {1, {k1.toString()}}, {2, {}},
{3, {k2absent}}, {4, {k1.toString(), k2absent}} }),
estim, 300, 1, arb);
QCOMPARE(pacKo->currentState(), 4);
QVERIFY2(j3.contains("INJOIGNABLE"), qUtf8Printable(j3.texte()));
QVERIFY2(j3.contains("INCERTAIN"), qUtf8Printable(j3.texte()));
QVERIFY2(j3.contient(QtWarningMsg), "une reprise incertaine doit être un AVERTISSEMENT, "
"pas une information");
#endif
}
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)
namespace {
//! Prépare une borne du banc simulé : branchée, à l'arrêt, monophasée, 6-16 A.
void armerBorne(Thing *borne)
{
borne->setStateValue("connected", true);
borne->setStateValue("pluggedIn", true);
borne->setStateValue("power", false);
borne->setStateValue("charging", false);
borne->setStateValue("maxChargingCurrent", 6);
borne->setStateValue("currentPower", 0);
}
//! Les entrées de configuration, telles que l'APP les voit — par la frontière RPC, jamais par
//! un store privé : c'est la concordance des deux frontières qui est en jeu.
QHash<QString, QVariantMap> configsParId(const QVariant &getLoadConfigReply)
{
QHash<QString, QVariantMap> h;
const QVariantList l = getLoadConfigReply.toMap().value("params").toMap()
.value("loadConfigs").toList();
for (const QVariant &v : l)
h.insert(v.toMap().value("id").toString(), v.toMap());
return h;
}
//! Les entrées de télémétrie du dernier cycle, indexées par loadId.
QHash<QString, QVariantMap> telemetrieParId(const QVariantMap &snap)
{
QHash<QString, QVariantMap> h;
for (const QVariant &v : snap.value("loads").toList())
h.insert(v.toMap().value("loadId").toString(), v.toMap());
return h;
}
} // namespace
void Simulation::testEvChargerRankDecidesWhoCharges()
{
#ifndef ETM_ARBITRATOR
QSKIP("testEvChargerRankDecidesWhoCharges nécessite ETM_ARBITRATOR.");
#else
// [3g-2 · LM-1204-b, moitié arbitrage] Deux bornes, un budget qui ne paie qu'un seul
// plancher. Ce qui est éprouvé ici est ce qu'AUCUN matériel ne prouve : que l'ARBITRE
// choisit, qu'il le dit, et que son choix suit le rang configuré.
//
// Le discriminant est le MOTIF publié (spec_loadmodel §12) :
// BELOW_MIN_POWER → l'arbitre a refusé, aucune commande n'est partie ;
// une borne à 0 W sans motif de plancher → c'est le matériel, le test ne prouve rien.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-evrank.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
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);
// Tolérance d'acquisition à 1,0 : aucun démarrage sous tolérance ne vient brouiller la
// lecture du plancher. Ce mécanisme a son propre test.
QVERIFY(injectAndWait("NymeaEnergy.SetAcquisitionTolerance",
QVariantMap({{"acquisitionTolerance", 1.0}})).toMap()
.value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
// Deux bornes IDENTIQUES, plafonnées à 6 A : chacune prend exactement son plancher
// (1380 W) et pas un watt de plus. C'est ce qui rend le RELIQUAT visible — 2000 − 1380 =
// 620 W, sous le plancher de la seconde. Sans ce plafond, la première absorberait tout et
// la seconde publierait « pas de surplus », ce qui est vrai mais ne prouve pas le plancher.
QUuid borneAId = addCharger("A", 6, 26657);
QUuid borneBId = addCharger("A", 6, 26670);
QUuid voitureAId = addCar(26656);
QUuid voitureBId = addCar(26671);
QVERIFY(!borneAId.isNull() && !borneBId.isNull());
QVERIFY(!voitureAId.isNull() && !voitureBId.isNull());
Thing *borneA = tm->findConfiguredThing(borneAId);
Thing *borneB = tm->findConfiguredThing(borneBId);
QVERIFY(borneA && borneB);
armerBorne(borneA);
armerBorne(borneB);
for (const QUuid &v : {voitureAId, voitureBId}) {
Thing *voiture = tm->findConfiguredThing(v);
QVERIFY(voiture);
voiture->setStateValue("batteryLevel", 40);
voiture->setStateValue("capacity", 50);
voiture->setStateValue("minChargingCurrent", 6);
voiture->setStateValue("phaseCount", 1);
voiture->setStateValue("pluggedIn", true);
}
auto associer = [this](const QUuid &borne, const QUuid &voiture) {
QVariantMap info;
info.insert("evChargerId", borne);
info.insert("assignedCarId", voiture);
info.insert("chargingMode", "ChargingModeEco");
QVariant r = injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
QCOMPARE(r.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
};
associer(borneAId, voitureAId);
associer(borneBId, voitureBId);
// ---- 1. L'auto-provisionnement : les bornes ENTRENT dans la configuration --------------
// 3g-1 les arbitrait et les publiait sans qu'elles figurent dans GetLoadConfig — une charge
// publiée sans configuration est la charge fantôme que le lot B-bis avait supprimée.
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
meter->setStateValue("currentPower", 0); // aucun surplus : ce cycle ne sert qu'à
arb->simulationCallUpdate(t0); // faire entrer les bornes en configuration
QCoreApplication::processEvents();
QHash<QString, QVariantMap> cfgs = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
QVERIFY2(cfgs.contains(borneAId.toString()), "la borne A n'est pas entrée en configuration");
QVERIFY2(cfgs.contains(borneBId.toString()), "la borne B n'est pas entrée en configuration");
QCOMPARE(cfgs.value(borneAId.toString()).value("adapter").toString(), QStringLiteral("evcharger"));
QCOMPARE(cfgs.value(borneAId.toString()).value("domain").toString(), QStringLiteral("ev"));
QVERIFY(cfgs.value(borneAId.toString()).value("enabled").toBool());
// Aller-retour NEUTRE : relire la configuration et la réécrire mot pour mot ne change rien
// et ne reconstruit AUCUN adaptateur — l'adaptateur d'une borne vient du Thing, pas d'ici.
const QVariant avant = injectAndWait("NymeaEnergy.GetLoadConfig");
QVariant reecrit = injectAndWait("NymeaEnergy.SetLoadConfig",
QVariantMap({{"loadConfigs", avant.toMap().value("params").toMap().value("loadConfigs")}}));
QCOMPARE(reecrit.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
QCOMPARE(injectAndWait("NymeaEnergy.GetLoadConfig").toMap().value("params").toMap(),
avant.toMap().value("params").toMap());
// ---- 2. Le rang décide, et le MOTIF le dit --------------------------------------------
// Le réordonnancement passe par la MÊME frontière que l'app : GetLoadConfig, on change le
// rang, SetLoadConfig. Aucun raccourci par le store — c'est le chemin qui doit marcher.
auto poserRangs = [&](int rangA, int rangB) {
QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
.value("params").toMap().value("loadConfigs").toList();
for (int i = 0; i < l.count(); ++i) {
QVariantMap m = l.at(i).toMap();
if (m.value("id").toString() == borneAId.toString()) m.insert("priority", rangA);
if (m.value("id").toString() == borneBId.toString()) m.insert("priority", rangB);
l[i] = m;
}
QVariant r = injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}));
QCOMPARE(r.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
};
// Chaque cycle mesuré part de DEUX bornes à l'arrêt. Sans cela le recrédit
// anti-clignotement rendrait à celle qui charge déjà sa propre consommation — c'est le
// comportement voulu (le compteur l'inclut déjà), mais il rend le budget du cycle
// ambigu et le test ne mesurerait plus le rang.
auto cycle = [&](const QDateTime &t) {
for (Thing *b : {borneA, borneB}) {
b->setStateValue("power", false);
b->setStateValue("charging", false);
b->setStateValue("maxChargingCurrent", 6);
b->setStateValue("currentPower", 0);
}
meter->setStateValue("currentPower", -2000); // 2000 W exportés : UN seul plancher
arb->simulationCallUpdate(t);
QCoreApplication::processEvents();
return telemetrieParId(arb->loadTelemetry());
};
poserRangs(1, 2);
QHash<QString, QVariantMap> tele = cycle(t0.addSecs(60));
// Plancher : max(borne 6 A, voiture 6 A) × 230 V × 1 phase = 1380 W. Le budget de 2000 W
// en paie UN. La seconde ne reçoit pas « un peu » — elle reçoit 0 et son motif.
QCOMPARE(qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()), 1380);
QCOMPARE(qRound(tele.value(borneBId.toString()).value("allocatedW").toDouble()), 0);
// Le reliquat EXISTE (620 W) et il ne suffit pas : c'est bien le plancher qui refuse, pas
// l'absence de surplus. Les deux phrases envoient l'installateur vers des gestes opposés.
QCOMPARE(qRound(tele.value(borneBId.toString()).value("decision").toMap()
.value("params").toMap().value("budgetW").toDouble()), 620);
QCOMPARE(tele.value(borneBId.toString()).value("decision").toMap().value("code").toString(),
QString(DecisionCode::BelowMinPower));
QCOMPARE(qRound(tele.value(borneBId.toString()).value("decision").toMap()
.value("params").toMap().value("minPowerW").toDouble()), 1380);
// ---- 3. INVERSION du rang : la borne servie change ------------------------------------
// C'est le pendant EV de l'inversion ECS/SG-Ready déjà éprouvée, et la seule preuve que le
// rang est un RÉGLAGE et non un ordre de découverte.
poserRangs(2, 1);
tele = cycle(t0.addSecs(120));
QCOMPARE(qRound(tele.value(borneBId.toString()).value("allocatedW").toDouble()), 1380);
QCOMPARE(qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()), 0);
QCOMPARE(tele.value(borneAId.toString()).value("decision").toMap().value("code").toString(),
QString(DecisionCode::BelowMinPower));
// ---- 4. Une charge, UN commandeur -----------------------------------------------------
// La borne refusée doit être ÉTEINTE par la décision de l'arbitre. Avant 3g-2,
// adjustEvChargers() repassait derrière avec une consigne dérivée de sa propre logique :
// l'arbitre publiait un motif juste, et le matériel recevait autre chose.
QCOMPARE(borneA->stateValue("power").toBool(), false);
QCOMPARE(borneA->stateValue("charging").toBool(), false);
// Et la borne SERVIE l'est à la consigne du waterfall, pas à celle que le proxy aurait
// dérivée : 1380 W sur une phase, soit 6 A.
QCOMPARE(borneB->stateValue("power").toBool(), true);
QCOMPARE(borneB->stateValue("maxChargingCurrent").toInt(), 6);
// ---- 5. loads[] ⊆ GetLoadConfig, l'invariant que 3g-1 avait rompu ----------------------
QSet<QString> idsConfig;
for (const QString &id : configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")).keys())
idsConfig.insert(id);
for (auto it = tele.constBegin(); it != tele.constEnd(); ++it)
QVERIFY2(idsConfig.contains(it.key()),
qUtf8Printable(QStringLiteral("charge %1 publiée en télémétrie mais absente de "
"GetLoadConfig").arg(it.key())));
#endif
}
void Simulation::testEvGridStartIsAnnouncedAndBounded()
{
#ifndef ETM_ARBITRATOR
QSKIP("testEvGridStartIsAnnouncedAndBounded nécessite ETM_ARBITRATOR.");
#else
// [3g-2] `acquisitionTolerance` est un seuil de DÉCLENCHEMENT : la part du plancher qu'on
// accepte de ne pas couvrir par le surplus pour démarrer quand même. Le comportement
// existait chez le proxy, où il était invisible — confondu avec le plancher dans
// evFloorW(). Ici il est visible, chiffré, et borné.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-evgridstart.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
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);
injectAndWait("NymeaEnergy.SetAcquisitionTolerance", QVariantMap({{"acquisitionTolerance", 0.5}}));
QUuid borneAId = addCharger("A", 16, 26657);
QUuid borneBId = addCharger("A", 16, 26670);
QUuid voitureAId = addCar(26656);
QUuid voitureBId = addCar(26671);
Thing *borneA = tm->findConfiguredThing(borneAId);
Thing *borneB = tm->findConfiguredThing(borneBId);
QVERIFY(borneA && borneB);
armerBorne(borneA);
armerBorne(borneB);
for (const QUuid &v : {voitureAId, voitureBId}) {
Thing *voiture = tm->findConfiguredThing(v);
QVERIFY(voiture);
voiture->setStateValue("batteryLevel", 40);
voiture->setStateValue("capacity", 50);
voiture->setStateValue("minChargingCurrent", 6);
voiture->setStateValue("phaseCount", 1);
voiture->setStateValue("pluggedIn", true);
}
for (const QPair<QUuid, QUuid> &paire : QList<QPair<QUuid, QUuid>>{{borneAId, voitureAId},
{borneBId, voitureBId}}) {
QVariantMap info;
info.insert("evChargerId", paire.first);
info.insert("assignedCarId", paire.second);
info.insert("chargingMode", "ChargingModeEco");
injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
}
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
meter->setStateValue("currentPower", 0); // cycle d'entrée en configuration seulement
arb->simulationCallUpdate(t0);
QCoreApplication::processEvents();
QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
.value("params").toMap().value("loadConfigs").toList();
for (int i = 0; i < l.count(); ++i) {
QVariantMap m = l.at(i).toMap();
if (m.value("id").toString() == borneAId.toString()) m.insert("priority", 1);
if (m.value("id").toString() == borneBId.toString()) m.insert("priority", 2);
l[i] = m;
}
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
// Les deux bornes à l'arrêt : le budget du cycle est sans ambiguïté (cf. testEvChargerRank…).
for (Thing *b : {borneA, borneB}) {
b->setStateValue("power", false);
b->setStateValue("charging", false);
b->setStateValue("maxChargingCurrent", 6);
b->setStateValue("currentPower", 0);
}
// 900 W de surplus : sous le plancher de 1380 W, mais au-dessus de sa moitié. Le proxy
// aurait démarré ici sans le dire ; le waterfall démarre et le DIT.
meter->setStateValue("currentPower", -900);
arb->simulationCallUpdate(t0.addSecs(60));
QCoreApplication::processEvents();
const QVariantMap snap = arb->loadTelemetry();
QHash<QString, QVariantMap> tele = telemetrieParId(snap);
// ---- Le motif dit COMBIEN est soutiré, et pourquoi -------------------------------------
const QVariantMap decisionA = tele.value(borneAId.toString()).value("decision").toMap();
QCOMPARE(decisionA.value("code").toString(), QString(DecisionCode::EvGridStart));
const QVariantMap pA = decisionA.value("params").toMap();
QCOMPARE(qRound(pA.value("floorW").toDouble()), 1380);
QCOMPARE(qRound(pA.value("budgetW").toDouble()), 900);
QCOMPARE(qRound(pA.value("gridW").toDouble()), 480); // 1380 − 900, SOUTIRÉS
// budgetW + gridW == allocatedW : la comptabilité est vérifiable par le client seul.
QCOMPARE(qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()), 1380);
QCOMPARE(qRound(pA.value("budgetW").toDouble() + pA.value("gridW").toDouble()), 1380);
// Le financement DIT la vérité sur l'origine : une part vient du réseau.
// R2 — le financement vit dans levels[], plus sur la charge. Un seul niveau aujourd'hui.
const QVariantList nivA = tele.value(borneAId.toString()).value("levels").toList();
QCOMPARE(nivA.count(), 1);
QCOMPARE(nivA.first().toMap().value("level").toString(), QString("comfort"));
QCOMPARE(nivA.first().toMap().value("funding").toString(), QString("grid"));
// Et le niveau publie ce qu'il a DÉCIDÉ : tant qu'il n'y en a qu'un, c'est allocatedW.
QCOMPARE(qRound(nivA.first().toMap().value("targetW").toDouble()),
qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()));
QVERIFY2(!tele.value(borneAId.toString()).contains("funding"),
"funding ne doit plus être publié au niveau de la CHARGE");
// ---- Borné par construction : UNE borne par cycle --------------------------------------
// Le démarrage consomme TOUT le budget restant ; la suivante trouve zéro et repart sur son
// motif ordinaire. Deux bornes ne peuvent pas se mettre à soutirer le même cycle.
QCOMPARE(qRound(tele.value(borneBId.toString()).value("allocatedW").toDouble()), 0);
QVERIFY2(tele.value(borneBId.toString()).value("decision").toMap().value("code").toString()
!= QString(DecisionCode::EvGridStart),
"deux bornes ont soutiré au réseau dans le même cycle");
// ---- La comptabilité du budget partage l'allocation ------------------------------------
const QVariantMap budget = snap.value("budget").toMap();
QCOMPARE(qRound(budget.value("allocatedW").toDouble()), 900); // la part de SURPLUS
QCOMPARE(qRound(budget.value("remainingW").toDouble()), 0);
// R3 — la part ACHETÉE a quitté evReservedW pour le registre du soutirage. evReservedW est
// une CORRECTION du budget (commandé non encore mesuré) : y verser une tranche
// d'allocation rendait toute définition du champ fausse pour l'un des deux termes, et
// interdisait de le nommer honnêtement à l'écran.
QCOMPARE(qRound(snap.value("draw").toMap().value("committedW").toDouble()), 480);
QCOMPARE(qRound(budget.value("evReservedW").toDouble()), 0);
// budgetW + gridW == allocatedW tient toujours, et se lit désormais SANS exception :
// la part surplus dans le registre d'allocation, la part achetée dans celui du soutirage.
QCOMPARE(qRound(budget.value("allocatedW").toDouble()
+ snap.value("draw").toMap().value("committedW").toDouble()),
qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()));
#endif
}
void Simulation::testEvChargerConfigOutlivesItsThing()
{
#ifndef ETM_ARBITRATOR
QSKIP("testEvChargerConfigOutlivesItsThing nécessite ETM_ARBITRATOR.");
#else
// [3g-2] Borne remplacée, ThingId qui change, appareil déposé : l'entrée de configuration
// n'est PAS supprimée d'office — c'est une configuration, et l'installateur a pu la classer.
// Elle doit se lire comme une charge dont l'appareil manque, avec le motif qui va bien.
// Le cas symétrique du THING_MISSING éprouvé avec la sonde.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-evorphan.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
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);
meter->setStateValue("currentPower", -4000);
QUuid borneId = addCharger("A", 16, 26657);
QUuid voitureId = addCar(26656);
Thing *borne = tm->findConfiguredThing(borneId);
Thing *voiture = tm->findConfiguredThing(voitureId);
QVERIFY(borne && voiture);
armerBorne(borne);
voiture->setStateValue("batteryLevel", 40);
voiture->setStateValue("capacity", 50);
voiture->setStateValue("minChargingCurrent", 6);
voiture->setStateValue("phaseCount", 1);
voiture->setStateValue("pluggedIn", true);
QVariantMap info;
info.insert("evChargerId", borneId);
info.insert("assignedCarId", voitureId);
info.insert("chargingMode", "ChargingModeEco");
injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
arb->simulationCallUpdate(t0);
QCoreApplication::processEvents();
QVERIFY2(configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")).contains(borneId.toString()),
"la borne n'est pas entrée en configuration");
// ---- Le Thing disparaît ----------------------------------------------------------------
QVERIFY(removeDevice(borneId).toMap().value("params").toMap().value("thingError").toString()
== "ThingErrorNoError");
arb->simulationCallUpdate(t0.addSecs(60));
QCoreApplication::processEvents();
// L'entrée SURVIT : la supprimer effacerait un classement que l'installateur a posé, et le
// silence qui suivrait ne se distinguerait pas d'une borne jamais configurée.
QVERIFY2(configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")).contains(borneId.toString()),
"l'entrée de configuration a été supprimée avec le Thing");
// Et elle se LIT comme une charge sans appareil, à son rang, avec son motif.
const QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
QVERIFY2(tele.contains(borneId.toString()),
"la borne configurée sans appareil a disparu de la télémétrie");
const QVariantMap entree = tele.value(borneId.toString());
QCOMPARE(entree.value("available").toBool(), false);
QCOMPARE(entree.value("faultCode").toString(), QString("THING_MISSING"));
QCOMPARE(entree.value("decision").toMap().value("code").toString(),
QString(DecisionCode::LoadUnavailable));
QCOMPARE(qRound(entree.value("allocatedW").toDouble()), 0);
#endif
}
void Simulation::testUnpluggedChargerTakesNoBudget()
{
#ifndef ETM_ARBITRATOR
QSKIP("testUnpluggedChargerTakesNoBudget nécessite ETM_ARBITRATOR.");
#else
// [banc 2026-08-27, RELEVE_3g2 §3.1] Une borne en Eco, voiture assignée, mais AUCUN
// véhicule branché. Elle ne peut rien tirer de ce qu'on lui alloue — et pourtant elle
// était servie : 4 853 W retirés du budget et donnés à personne, la charge suivante ne
// survivant que sur son propre recrédit.
//
// Le proxy n'avait pas besoin de ce filtre : son allocation n'était pas une cascade, donc
// gaspiller ne privait personne. C'est la transplantation qui rend le test nécessaire.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-unplugged.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
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 borneId = addCharger("A", 16, 26657);
QUuid voitureId = addCar(26656);
QUuid routeurId = addEtmVariableLoad(27050);
Thing *borne = tm->findConfiguredThing(borneId);
Thing *voiture = tm->findConfiguredThing(voitureId);
Thing *routeur = tm->findConfiguredThing(routeurId);
QVERIFY(borne && voiture && routeur);
armerBorne(borne);
borne->setStateValue("pluggedIn", false); // ← le cas du banc : rien n'est branché
routeur->setStateValue("currentPowerW", 0);
voiture->setStateValue("batteryLevel", 40);
voiture->setStateValue("capacity", 50);
voiture->setStateValue("minChargingCurrent", 6);
voiture->setStateValue("phaseCount", 1);
QVariantMap info;
info.insert("evChargerId", borneId);
info.insert("assignedCarId", voitureId);
info.insert("chargingMode", "ChargingModeEco");
injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
meter->setStateValue("currentPower", 0);
arb->simulationCallUpdate(t0); // fait entrer la borne en configuration
QCoreApplication::processEvents();
// Une charge derrière la borne, au rang suivant : c'est elle qui doit recevoir le budget.
QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
.value("params").toMap().value("loadConfigs").toList();
for (int i = 0; i < l.count(); ++i) {
QVariantMap m = l.at(i).toMap();
if (m.value("id").toString() == borneId.toString()) m.insert("priority", 1);
l[i] = m;
}
l.append(QVariantMap{{"id", routeurId.toString()}, {"label", "Routeur PV"},
{"adapter", "etmvariableload"}, {"mode", "dynamic"},
{"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}});
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
// ---- Débranchée : elle ne doit PAS figurer dans le contexte ---------------------------
meter->setStateValue("currentPower", -3000);
arb->simulationCallUpdate(t0.addSecs(60));
QCoreApplication::processEvents();
QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
QVERIFY2(!tele.contains(borneId.toString()),
"une borne sans véhicule branché a été arbitrée");
// Et le budget est allé ENTIÈREMENT à la charge suivante. C'est la moitié du test qui
// compte : l'exclusion ne vaut que si elle ne coûte rien à personne.
QCOMPARE(qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble()), 3000);
QCOMPARE(qRound(arb->loadTelemetry().value("budget").toMap()
.value("allocatedW").toDouble()), 3000);
// ---- Branchée : elle redevient une charge du waterfall --------------------------------
// Sans cette seconde moitié, le test passerait aussi si la borne était exclue pour une
// raison quelconque — il ne prouverait pas que c'est `pluggedIn` qui décide.
borne->setStateValue("pluggedIn", true);
routeur->setStateValue("currentPowerW", 0);
arb->simulationCallUpdate(t0.addSecs(120));
QCoreApplication::processEvents();
tele = telemetrieParId(arb->loadTelemetry());
QVERIFY2(tele.contains(borneId.toString()),
"une borne avec véhicule branché doit entrer dans le waterfall");
QCOMPARE(qRound(tele.value(borneId.toString()).value("allocatedW").toDouble()), 3000);
QCOMPARE(qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble()), 0);
#endif
}
void Simulation::testChargerWithoutCarIsArbitrated()
{
#ifndef ETM_ARBITRATOR
QSKIP("testChargerWithoutCarIsArbitrated nécessite ETM_ARBITRATOR.");
#else
// [LM-1208-b · banc 2026-08-28] Le cas de l'INVITÉ. Une borne en Eco, un véhicule branché,
// et AUCUNE voiture assignée — parce qu'une voiture qui n'est pas celle du propriétaire ne
// sera jamais déclarée. Elle tirait 3,9 kW au banc et l'arbitre l'ignorait : le critère
// « pas de voiture assignée » la sortait du waterfall précisément quand elle consommait.
//
// Deux choses sont éprouvées ici, et la seconde est celle qui se serait perdue :
// 1. la borne EST arbitrée sans voiture assignée ;
// 2. elle l'est avec le nombre de phases de la BORNE. Le manager ne prépare pas une
// borne sans voiture (prepareInformation la filtre), donc son ChargingProcessInfo
// n'existe pas — et le lire par défaut annonce « 1 phase » pour une borne triphasée.
// Le défaut serait SILENCIEUX : plafond à 3 680 W au lieu de 11 040, courant commandé
// à 16 A au lieu de 9, et 11 kW tirés d'un budget qui en comptait 3,7.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-nocar.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
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);
// Tolérance d'acquisition à 1,0 : aucun démarrage sous tolérance ne vient brouiller la
// lecture du plancher — EV_GRID_START a son propre test.
QCOMPARE(injectAndWait("NymeaEnergy.SetAcquisitionTolerance",
QVariantMap({{"acquisitionTolerance", 1.0}})).toMap()
.value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
// La borne est TRIPHASÉE — c'est tout l'enjeu du point 2. Plancher 6 A × 230 V × 3
// = 4 140 W, plafond 16 A × 230 V × 3 = 11 040 W.
QUuid borneId = addCharger("ABC", 16, 26657);
QUuid routeurId = addEtmVariableLoad(27050);
Thing *borne = tm->findConfiguredThing(borneId);
Thing *routeur = tm->findConfiguredThing(routeurId);
QVERIFY(borne && routeur);
armerBorne(borne); // branchée, à l'arrêt, 6 A
QCOMPARE(borne->stateValue("phaseCount").toUInt(), 3u);
routeur->setStateValue("currentPowerW", 0);
// AUCUN assignedCarId — et aucune voiture n'est même ajoutée au système.
QVariantMap info;
info.insert("evChargerId", borneId);
info.insert("chargingMode", "ChargingModeEco");
QCOMPARE(injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
meter->setStateValue("currentPower", 0);
arb->simulationCallUpdate(t0); // fait entrer la borne en configuration
QCoreApplication::processEvents();
// Une charge derrière la borne : sans elle, « la borne prend tout » ne se distinguerait pas
// de « personne ne prend rien ».
QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
.value("params").toMap().value("loadConfigs").toList();
for (int i = 0; i < l.count(); ++i) {
QVariantMap m = l.at(i).toMap();
if (m.value("id").toString() == borneId.toString()) m.insert("priority", 1);
l[i] = m;
}
l.append(QVariantMap{{"id", routeurId.toString()}, {"label", "Routeur PV"},
{"adapter", "etmvariableload"}, {"mode", "dynamic"},
{"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}});
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
// ---- Sans voiture assignée : la borne est ARBITRÉE ------------------------------------
meter->setStateValue("currentPower", -6000);
arb->simulationCallUpdate(t0.addSecs(60));
QCoreApplication::processEvents();
QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
QVERIFY2(tele.contains(borneId.toString()),
"une borne sans voiture assignée a été exclue du waterfall (LM-1208-b)");
// Le budget entier lui revient : 6 000 W passent sous son plafond TRIPHASÉ (11 040 W).
// Annoncée monophasée, elle aurait plafonné à 3 680 W et laissé 2 320 W au routeur — c'est
// exactement la lecture qui distingue les deux.
QCOMPARE(qRound(tele.value(borneId.toString()).value("allocatedW").toDouble()), 6000);
QCOMPARE(qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble()), 0);
// Et le COURANT commandé le confirme au matériel, pas seulement dans la charge utile :
// 6 000 / (230 × 3) = 8,7 → 9 A. Sur une phase supposée, c'eût été 16 A écrêtés au plafond
// de la borne — soit 11 kW tirés d'un budget qui en comptait 3,7.
QCOMPARE(borne->stateValue("maxChargingCurrent").toUInt(), 9u);
// ---- Le mode manuel, lui, RESTE une exclusion ------------------------------------------
// Sans cette moitié, le test passerait aussi si toute borne était arbitrée sans condition :
// il ne prouverait pas que c'est le critère « voiture » qui a été retiré, et lui seul.
info.insert("chargingMode", "ChargingModeNormal");
QCOMPARE(injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
routeur->setStateValue("currentPowerW", 0);
arb->simulationCallUpdate(t0.addSecs(120));
QCoreApplication::processEvents();
tele = telemetrieParId(arb->loadTelemetry());
QVERIFY2(!tele.contains(borneId.toString()),
"une borne en mode manuel doit rester hors arbitrage");
QCOMPARE(qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble()), 3000);
#endif
}
void Simulation::testMeasurementSourceIsPublished()
{
#ifndef ETM_ARBITRATOR
QSKIP("testMeasurementSourceIsPublished nécessite ETM_ARBITRATOR.");
#else
// [§11 · LM-1105/LM-1106] Il fallait désigner une charge comme SON PROPRE compteur dans
// meterThingId pour qu'elle publie ce qu'elle mesure déjà. Le moteur lit désormais
// l'appareil qu'il pilote — et publie D'OÙ vient le nombre, parce que l'écart
// commandé↔mesuré ne conclut pas la même chose selon la source.
//
// Ce test porte les trois sources ET les deux règles de priorité. Sans la seconde moitié
// il passerait aussi si le compteur explicite avait été purement et simplement remplacé.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-measure.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
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);
// Trois mécanismes. Les relais du mock portent `currentPower` — c'est délibéré : le
// sg-ready doit répondre « non mesurable » MÊME LÀ, parce que ses contacts sont des
// contacts de SIGNAL et que le courant de la PAC ne les traverse pas.
QUuid rA = addPowerSwitch(1000, 26661); // relay-router, 1 relais
QUuid k1 = addPowerSwitch(0, 26662); // sg-ready K1
QUuid k2 = addPowerSwitch(0, 26663); // sg-ready K2
QUuid varId = addEtmVariableLoad(27041); // etmvariableload dynamic
Thing *varThing = tm->findConfiguredThing(varId);
QVERIFY(varThing && tm->findConfiguredThing(rA));
QVERIFY(tm->findConfiguredThing(k1) && tm->findConfiguredThing(k2));
varThing->setStateValue("currentPowerW", 0);
// Le compteur divisionnaire, posé EXPRÈS sur le routeur — il n'entrera en jeu qu'au 3.
QUuid divId = addMeter(26670);
Thing *div = tm->findConfiguredThing(divId);
QVERIFY(div);
div->setStateValue("connected", true);
div->setStateValue("currentPower", 1234);
LoadConfigStore *store = new LoadConfigStore(arb);
arb->setLoadConfigStore(store);
auto poserConfig = [&](const QString &meterThingId) {
LoadConfigs cfgs;
QVariantMap routeur{
{"id", varId.toString()}, {"label", "Routeur PV"}, {"adapter", "etmvariableload"},
{"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 1}, {"enabled", true}};
if (!meterThingId.isEmpty())
routeur.insert("meterThingId", meterThingId);
cfgs.append(LoadConfig::fromMap(routeur));
cfgs.append(LoadConfig::fromMap(sgReadyConfigMap("pac-mesure", "PAC", k1, k2,
800.0, 1600.0, 300, 2)));
cfgs.append(LoadConfig::fromMap(QVariantMap{
{"id", "ecs-mesure"}, {"label", "ECS relais"}, {"adapter", "relay-router"},
{"mode", "fixed"}, {"priority", 3}, {"enabled", true},
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()},
{"powerW", 1000}}},
{"minOnS", 0}, {"minOffS", 0}}));
QString err;
QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8());
};
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
// ---- 1. Les TROIS sources, sans une ligne de configuration de compteur ----------------
poserConfig(QString());
meter->setStateValue("currentPower", -6000);
arb->simulationCallUpdate(t0);
QCoreApplication::processEvents();
QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
// L'appareil piloté publie sa puissance : le moteur la lit, personne n'a rien désigné.
// 0 W est une MESURE, pas une absence — et la source ne doit pas basculer pour autant.
const QVariantMap mRouteur = tele.value(varId.toString()).value("measurement").toMap();
QCOMPARE(mRouteur.value("source").toString(), QString("device"));
QVERIFY2(mRouteur.contains("powerW"), "une charge mesurable doit publier sa valeur");
QCOMPARE(qRound(mRouteur.value("powerW").toDouble()), 0);
// LM-1105-a — le relay-router est servi : relais fermé, le mock rapporte 1 000 W. La mesure
// traverse bien le relais — c'est ce qui distingue un organe de COMMUTATION d'un contact
// de signal (voir la PAC juste dessous).
const QVariantMap mEcs = tele.value("ecs-mesure").value("measurement").toMap();
QCOMPARE(mEcs.value("source").toString(), QString("device"));
QCOMPARE(qRound(mEcs.value("powerW").toDouble()), 1000);
// LM-1105-b — la PAC : « none », ALORS QUE ses contacts portent currentPower. Un contact est
// un contact de signal — mesurer sa bobine ne mesure pas la PAC. C'est un état POSITIF,
// et powerW est OMIS : une nominale qui se ferait passer pour une mesure produirait un
// écart identiquement nul à tous les cycles.
const QVariantMap mPac = tele.value("pac-mesure").value("measurement").toMap();
QCOMPARE(mPac.value("source").toString(), QString("none"));
QVERIFY2(!mPac.contains("powerW"),
"une charge non mesurable a publié une puissance sous le nom d'une mesure");
// Et l'ancienne clé a bien DISPARU — deux noms pour une chose divergent.
QVERIFY2(!tele.value(varId.toString()).contains("measuredW"), "measuredW survit");
// ---- 1-bis. Palier 0 : la SOURCE ne bascule pas, la valeur seule tombe -----------------
// C'est la règle « la capacité se juge sur la CLASSE de tous les relais déclarés, jamais
// sur ceux qui sont fermés ». Jugée sur les relais fermés, elle répondrait « none » ici —
// et la source publiée basculerait à chaque extinction, sur une installation dont rien
// n'a changé. Il faut un budget NÉGATIF : à budget nul le recrédit anti-clignotement
// rendrait à l'ECS les 1 000 W qu'il consomme, et le relais resterait fermé.
meter->setStateValue("currentPower", 2000); // 2 000 W IMPORTÉS
arb->simulationCallUpdate(t0.addSecs(30));
QCoreApplication::processEvents();
tele = telemetrieParId(arb->loadTelemetry());
const QVariantMap mEcsEteint = tele.value("ecs-mesure").value("measurement").toMap();
QCOMPARE(mEcsEteint.value("source").toString(), QString("device"));
QCOMPARE(qRound(mEcsEteint.value("powerW").toDouble()), 0);
// ---- 2. La valeur SUIT l'appareil ------------------------------------------------------
meter->setStateValue("currentPower", -6000);
varThing->setStateValue("currentPowerW", 2900);
arb->simulationCallUpdate(t0.addSecs(60));
QCoreApplication::processEvents();
tele = telemetrieParId(arb->loadTelemetry());
QCOMPARE(tele.value(varId.toString()).value("measurement").toMap()
.value("source").toString(), QString("device"));
QCOMPARE(qRound(tele.value(varId.toString()).value("measurement").toMap()
.value("powerW").toDouble()), 2900);
// ---- 3. Le compteur EXPLICITE reste prioritaire ----------------------------------------
// Pas d'abord parce qu'il mesure mieux : parce qu'un réglage sans effet est pire que pas
// de réglage. 1234 W du compteur doivent l'emporter sur 2900 W déclarés par l'appareil.
poserConfig(divId.toString());
arb->simulationCallUpdate(t0.addSecs(120));
QCoreApplication::processEvents();
tele = telemetrieParId(arb->loadTelemetry());
const QVariantMap mAvecCompteur = tele.value(varId.toString()).value("measurement").toMap();
QCOMPARE(mAvecCompteur.value("source").toString(), QString("meter"));
QCOMPARE(qRound(mAvecCompteur.value("powerW").toDouble()), 1234);
// ---- 4. …mais la priorité porte sur la SOURCE, pas sur la valeur -----------------------
// Compteur déclaré et injoignable : on retombe sur l'appareil. Se taire perdrait une
// mesure qu'on a, et le silence se lirait « charge non mesurable » — ce qui serait faux.
poserConfig(QUuid::createUuid().toString());
arb->simulationCallUpdate(t0.addSecs(180));
QCoreApplication::processEvents();
tele = telemetrieParId(arb->loadTelemetry());
const QVariantMap mRepli = tele.value(varId.toString()).value("measurement").toMap();
QCOMPARE(mRepli.value("source").toString(), QString("device"));
QCOMPARE(qRound(mRepli.value("powerW").toDouble()), 2900);
#endif
}
void Simulation::testAutoProvisionedRankGoesToTheQueue()
{
#ifndef ETM_ARBITRATOR
QSKIP("testAutoProvisionedRankGoesToTheQueue nécessite ETM_ARBITRATOR.");
#else
// [LM-1209] L'auto-provisionnement REMPLIT UN TROU : sans entrée, une borne serait arbitrée
// sans rang réglable. Il ne s'ensuit rien sur sa PLACE. Une borne détectée un mois après la
// mise en service ne doit pas passer devant l'ECS que quelqu'un a réglé exprès — une
// détection ne réordonne pas ce qu'un humain a décidé.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-queue.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
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);
meter->setStateValue("currentPower", -4000);
// ---- Une installation DÉJÀ classée par un humain ---------------------------------------
QUuid rA = addPowerSwitch(1000, 26661);
QUuid varId = addEtmVariableLoad(27042);
QVERIFY(tm->findConfiguredThing(rA) && tm->findConfiguredThing(varId));
// Posées par la frontière RPC, comme l'app le ferait — c'est le magasin que le handler
// connaît, et c'est lui qui doit porter la marque de rang.
QVariantList l;
l.append(QVariantMap{{"id", "ecs-classe"}, {"label", "ECS classé"},
{"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", 1}, {"enabled", true}, {"domain", "ecs"},
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()},
{"powerW", 1000}}},
{"minOnS", 0}, {"minOffS", 0}});
l.append(QVariantMap{{"id", varId.toString()}, {"label", "Routeur PV"},
{"adapter", "etmvariableload"}, {"mode", "dynamic"},
{"maxPowerW", 3000}, {"priority", 2}, {"enabled", true},
{"domain", "ecs"}});
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
// ---- La borne arrive UN MOIS PLUS TARD -------------------------------------------------
QUuid borneId = addCharger("A", 16, 26657);
Thing *borne = tm->findConfiguredThing(borneId);
QVERIFY(borne);
armerBorne(borne);
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
arb->simulationCallUpdate(t0);
QCoreApplication::processEvents();
QHash<QString, QVariantMap> cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
QVERIFY2(cfg.contains(borneId.toString()), "la borne détectée n'est pas entrée en configuration");
// EN QUEUE : derrière tout le monde, pas en tête. C'est la correction du lot — le rang
// retombait à 1, à égalité avec l'ECS que quelqu'un avait posé là exprès.
QCOMPARE(cfg.value(borneId.toString()).value("priority").toInt(), 3);
// AUCUN autre rang n'a bougé : insérer est un geste de configuration, pas d'arbitrage.
QCOMPARE(cfg.value("ecs-classe").value("priority").toInt(), 1);
QCOMPARE(cfg.value(varId.toString()).value("priority").toInt(), 2);
// Et la création SE DIT : le journal ne suffit pas, l'app ne le lit pas et c'est elle qui
// montre le classement. « ce rang est un défaut, pas un choix. »
QCOMPARE(cfg.value(borneId.toString()).value("rankOrigin").toString(), QString("auto"));
// Les charges posées par un humain, elles, n'affirment rien — ni « choisi », ni « défaut ».
QVERIFY2(cfg.value("ecs-classe").value("rankOrigin").toString().isEmpty(),
"une charge posée par un humain ne doit rien affirmer sur l'origine de son rang");
// ---- Une SECONDE borne va encore derrière ----------------------------------------------
// Le rang produit doit rester LIBRE par construction : c'est ce qui rend l'égalité
// impossible par ce chemin, plutôt que de la refuser après coup.
QUuid borne2Id = addCharger("A", 16, 26670);
Thing *borne2 = tm->findConfiguredThing(borne2Id);
QVERIFY(borne2);
armerBorne(borne2);
arb->simulationCallUpdate(t0.addSecs(60));
QCoreApplication::processEvents();
cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
QCOMPARE(cfg.value(borne2Id.toString()).value("priority").toInt(), 4);
QCOMPARE(cfg.value(borneId.toString()).value("priority").toInt(), 3); // toujours pas bougé
QCOMPARE(cfg.value("ecs-classe").value("priority").toInt(), 1);
// Tous distincts : l'unicité est obtenue PAR CONSTRUCTION, pas par un refus tardif.
QSet<int> rangs;
for (auto it = cfg.constBegin(); it != cfg.constEnd(); ++it)
rangs.insert(it.value().value("priority").toInt());
QCOMPARE(rangs.count(), cfg.count());
#endif
}
void Simulation::testDeletedEvChargerConfigComesBack()
{
#ifndef ETM_ARBITRATOR
QSKIP("testDeletedEvChargerConfigComesBack nécessite ETM_ARBITRATOR.");
#else
// [LM-1209-e · mesuré sur `.75` le 2026-08-29] Le provisionnement vivait dans la branche
// « l'adaptateur vient de naître ». Or l'adaptateur est construit depuis le THING : il
// survit à la suppression de l'entrée de configuration. Une entrée supprimée depuis l'app
// n'était donc JAMAIS recréée — et la borne restait arbitrée, absente de GetLoadConfig, à
// un rang de queue non réglable. Le trou refermé en 3g-2, rouvert par le côté client.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-reprov.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
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);
meter->setStateValue("currentPower", -4000);
QUuid borneId = addCharger("A", 16, 26657);
Thing *borne = tm->findConfiguredThing(borneId);
QVERIFY(borne);
armerBorne(borne);
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
arb->simulationCallUpdate(t0);
QCoreApplication::processEvents();
QHash<QString, QVariantMap> cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
QVERIFY2(cfg.contains(borneId.toString()), "la borne n'est pas entrée en configuration");
QCOMPARE(cfg.value(borneId.toString()).value("rankOrigin").toString(), QString("auto"));
const int rangInitial = cfg.value(borneId.toString()).value("priority").toInt();
// ---- L'installateur classe la borne : la marque tombe -----------------------------------
QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
.value("params").toMap().value("loadConfigs").toList();
for (int i = 0; i < l.count(); ++i) {
QVariantMap m = l.at(i).toMap();
if (m.value("id").toString() == borneId.toString()) m.insert("priority", rangInitial + 3);
l[i] = m;
}
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
QVERIFY2(cfg.value(borneId.toString()).value("rankOrigin").toString().isEmpty(),
"un rang posé par un humain doit lever la marque");
// ---- …puis il SUPPRIME l'entrée, le Thing restant vivant --------------------------------
QVariantList sans;
for (const QVariant &v : injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
.value("params").toMap().value("loadConfigs").toList())
if (v.toMap().value("id").toString() != borneId.toString())
sans.append(v);
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", sans}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
QVERIFY2(!configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")).contains(borneId.toString()),
"l'entrée devait être retirée par l'écriture cliente");
// ---- Le cycle suivant la RATTRAPE -------------------------------------------------------
// Sans LM-1209-e, rien ne se passe : l'adaptateur existe déjà, donc le provisionnement
// n'est plus jamais tenté, et la borne reste arbitrée hors configuration.
arb->simulationCallUpdate(t0.addSecs(60));
QCoreApplication::processEvents();
cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
QVERIFY2(cfg.contains(borneId.toString()),
"une borne dont le Thing vit doit retrouver une entrée de configuration");
// Et elle revient EN QUEUE et en « auto » : personne ne l'a classée, son rang est de
// nouveau un défaut. Le rang que l'installateur avait posé est perdu avec l'entrée — c'est
// le prix de la suppression, et c'est pourquoi la marque doit se voir.
QCOMPARE(cfg.value(borneId.toString()).value("rankOrigin").toString(), QString("auto"));
int rangMax = 0;
for (auto it = cfg.constBegin(); it != cfg.constEnd(); ++it)
if (it.key() != borneId.toString())
rangMax = qMax(rangMax, it.value().value("priority").toInt());
QCOMPARE(cfg.value(borneId.toString()).value("priority").toInt(), qMax(1, rangMax + 1));
// ---- Et le rattrapage ne se REJOUE pas quand l'entrée existe ----------------------------
// Sans quoi il réécrirait la configuration à chaque cycle — donc un changed(), donc un
// rebuild d'adaptateurs, donc des verrous réarmés en boucle (ECS-412).
const QVariantMap avant = cfg.value(borneId.toString());
arb->simulationCallUpdate(t0.addSecs(120));
QCoreApplication::processEvents();
const QVariantMap apres = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"))
.value(borneId.toString());
QCOMPARE(apres, avant);
#endif
}
void Simulation::testVanishedRelayFreezesTheLoad()
{
#ifndef ETM_ARBITRATOR
QSKIP("testVanishedRelayFreezesTheLoad nécessite ETM_ARBITRATOR.");
#else
// [ECS-111] Le comportement était « non défini », et son effet dépassait l'affichage : le
// relais disparu était SAUTÉ, on retombait sur le nominal du PALIER ENTIER — part du relais
// fantôme comprise — et ce chiffre alimente le recrédit anti-clignotement, donc le BUDGET.
// L'arbitre se trompait sur le surplus pendant que `available` restait vrai.
//
// Ce test porte les deux moitiés, et c'est la seconde qui compte : ce que le fantôme
// laissait aux charges SUIVANTES.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-vanish.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
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); // ECS : un seul relais, 2000 W
QUuid varId = addEtmVariableLoad(27043); // la charge SUIVANTE, celle qui paie
Thing *routeur = tm->findConfiguredThing(varId);
QVERIFY(tm->findConfiguredThing(rA) && routeur);
routeur->setStateValue("currentPowerW", 0);
QVariantList l;
l.append(QVariantMap{{"id", "ecs-fantome"}, {"label", "ECS relais"},
{"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", 1}, {"enabled", true},
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()},
{"powerW", 2000}}},
{"minOnS", 0}, {"minOffS", 0}});
l.append(QVariantMap{{"id", varId.toString()}, {"label", "Routeur PV"},
{"adapter", "etmvariableload"}, {"mode", "dynamic"},
{"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}});
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
// ---- Régime sain : l'ECS prend son palier, le routeur prend le reste ------------------
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
meter->setStateValue("currentPower", -5000);
arb->simulationCallUpdate(t0);
QCoreApplication::processEvents();
QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
QCOMPARE(tele.value("ecs-fantome").value("available").toBool(), true);
QCOMPARE(qRound(tele.value("ecs-fantome").value("allocatedW").toDouble()), 2000);
const int routeurSain = qRound(tele.value(varId.toString()).value("allocatedW").toDouble());
QVERIFY2(routeurSain > 0, "le routeur doit être servi en régime sain");
// ---- Le relais DISPARAÎT du registre nymea --------------------------------------------
// Thing supprimé, ou plugin qui ne le voit plus : on ne sait pas dans quel état est le
// contact. C'est précisément pour ça qu'on ne peut pas « compter 0 W et servir le reste ».
QVERIFY(removeDevice(rA).toMap().value("params").toMap().value("thingError").toString()
== "ThingErrorNoError");
arb->simulationCallUpdate(t0.addSecs(60));
QCoreApplication::processEvents();
tele = telemetrieParId(arb->loadTelemetry());
// 1. La charge SORT DE L'ARBITRAGE, et elle le DIT.
const QVariantMap ecs = tele.value("ecs-fantome");
QCOMPARE(ecs.value("available").toBool(), false);
QCOMPARE(ecs.value("faultCode").toString(), QString("THING_MISSING"));
QCOMPARE(ecs.value("decision").toMap().value("code").toString(),
QString(DecisionCode::LoadUnavailable));
// 2. …mais PAS de la comptabilité : la puissance crue engagée reste allouée, parce qu'on
// ne sait plus la couper. C'est ECS-410, appliqué à une cause nouvelle.
QCOMPARE(qRound(ecs.value("allocatedW").toDouble()), 2000);
// 3. ET C'EST LA MOITIÉ QUI DÉCIDE : le fantôme est RETIRÉ du budget des suivantes au lieu
// de leur être recrédité. Avant le correctif, `currentPowerW` valait le nominal du
// palier entier et alimentait le recrédit — le routeur se voyait offrir un surplus qui
// n'existait pas. Ici il reçoit ce qui reste vraiment, pas davantage.
const int routeurFantome = qRound(tele.value(varId.toString()).value("allocatedW").toDouble());
QVERIFY2(routeurFantome <= routeurSain,
"la charge suivante ne doit pas recevoir PLUS quand un relais a disparu");
// 4. Et la mesure ne prétend rien : rien ne mesure une charge dont le câblage manque.
QCOMPARE(ecs.value("measurement").toMap().value("source").toString(), QString("none"));
QVERIFY2(!ecs.value("measurement").toMap().contains("powerW"),
"une charge au câblage incomplet ne doit pas publier de puissance mesurée");
#endif
}
/*!
* \brief [upstream-fix] La place libre par phase se calcule sur l'état QUI A ÉTÉ TESTÉ.
*
* \par Le défaut
* \c RootMeter::calculateAllowanceAmpere() testait \c currentPhaseB / \c currentPhaseC
* (ampères) puis lisait \c currentPowerPhaseB / \c currentPowerPhaseC divisés par 230
* (watts) — asymétrique avec la phase A, qui lit bien l'état qu'elle teste. Sur un compteur
* publiant les courants par phase SANS les puissances par phase, la lecture rendait 0 : la
* place libre valait le plafond entier et la protection de surcharge n'écrêtait **rien** sur
* B et C, sans rien signaler.
*
* \par Pourquoi aucun test existant ne pouvait le trouver
* Le mock pose \c currentPhaseB = \c currentPowerPhaseB / 230 : les deux états y sont
* **cohérents par construction**, donc lire l'un ou l'autre y donne le même nombre. C'est
* l'angle mort — l'installation de référence n'exerce pas le cas. Ce test le fabrique
* explicitement, en désaccordant les deux états, ce qu'aucun chemin normal du mock ne permet.
*
* \par Ce qui est épinglé
* Une phase B tirant 20 A avec une puissance par phase à zéro doit consommer de la marge.
* Avant le correctif : 25 A de place libre annoncés sur une phase qui en tire 20.
*/
void Simulation::testPhaseAllowanceReadsTheStateItTests()
{
ThingManager *thingManager = NymeaCore::instance()->thingManager();
QUuid meterThingId = addMeter();
QVERIFY(!meterThingId.isNull());
Thing *meterThing = thingManager->findConfiguredThing(meterThingId);
QVERIFY(meterThing);
meterThing->setStateValue("connected", true);
RootMeter *meter = new RootMeter(meterThing);
// Compteur qui publie les COURANTS par phase, mais pas les puissances par phase. On le
// fabrique en désaccordant les états à la main : le mock, lui, les tient toujours
// cohérents, et c'est précisément ce qui rendait le défaut invisible.
meterThing->setStateValue("currentPhaseA", 0);
meterThing->setStateValue("currentPhaseB", 20);
meterThing->setStateValue("currentPhaseC", 0);
meterThing->setStateValue("currentPowerPhaseA", 0);
meterThing->setStateValue("currentPowerPhaseB", 0);
meterThing->setStateValue("currentPowerPhaseC", 0);
const uint plafondA = 25;
// La phase B tire 20 A sous un plafond de 25 : il reste 5 A, pas 25.
QCOMPARE(meter->calculateAllowanceAmpere(Electricity::PhaseB, plafondA), 5.0);
// La phase A, elle, était déjà juste. On le vérifie pour que le test dise que les trois
// phases se comportent DÉSORMAIS pareil, et pas seulement que B a changé.
QCOMPARE(meter->calculateAllowanceAmpere(Electricity::PhaseA, plafondA), 25.0);
// Et la phase la plus chargée l'emporte quand on interroge les trois ensemble.
QCOMPARE(meter->calculateAllowanceAmpere(
Electricity::PhaseA | Electricity::PhaseB | Electricity::PhaseC, plafondA), 5.0);
}
/*!
* \brief [Étape 3] L'autorisation de SOUTIRAGE borne la cascade, et le motif dit laquelle.
*
* \par Ce qui change
* La protection de surcharge ne bornait jusqu'ici que les bornes VE, via le plafond de cycle
* d'`evPhaseAllowanceW()`. Une charge de waterfall pouvait donc créer un dépassement que seule
* la protection L4 rattrapait — après coup. Le waterfall tient désormais compte des DEUX
* ressources : le surplus dit ce qu'on peut dépenser sans acheter, l'autorisation dit ce que
* le branchement supporte.
*
* \par La précédence, qui est le point subtil
* \rule{LM-1011} : `DRAW_CAP` est réservé à l'autorisation résiduelle **nulle**. S'il en reste,
* fût-ce moins que le plus petit palier, le motif est `BELOW_MIN_POWER` — « il y en a, mais pas
* au bon format ». Les deux phrases envoient l'installateur vers des gestes opposés : revoir un
* abonnement, ou relire une fiche technique.
*
* \par Pourquoi le contexte est bâti à la main
* La marge est un champ du \c SurplusContext. La poser directement épingle la RÈGLE plutôt que
* la chaîne qui produit la marge — celle-ci a ses propres tests
* (`testDrawMarginComparesHeadroomsNotCaps`, `testPhaseAllowanceReadsTheStateItTests`). Un test
* qui dépendrait des deux échouerait pour deux raisons sans dire laquelle.
*/
void Simulation::testDrawCapBoundsTheCascade()
{
#ifndef ETM_ARBITRATOR
QSKIP("testDrawCapBoundsTheCascade nécessite ETM_ARBITRATOR.");
#else
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-drawcap.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
ThingManager *tm = NymeaCore::instance()->thingManager();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
QUuid rA = addPowerSwitch(1000, 26690);
QVERIFY(tm->findConfiguredThing(rA));
const QDateTime t0 = utcDateTime(QDate(2026, 8, 30), QTime(13, 0, 0));
RuleBasedScheduler sched(arb);
// Un relais de 1000 W : paliers {0, 1000}. Le cas *fixed* est le plus exigeant, parce que
// l'écrêtage à la marge doit RETOMBER sur un palier atteignable — sinon le mécanisme prend
// le palier au-dessus, c'est-à-dire exactement le dépassement qu'on vient de refuser.
RelayRouter *routeur = new RelayRouter(tm, "cap", "Charge sous plafond",
QList<LoadConfigRelay>({ {rA.toString(), 1000} }), 0, 0, 1, LoadNeeds(), arb);
auto decider = [&](double surplusW, const DrawMargin &marge) -> LoadAction {
SurplusContext ctx;
ctx.timestamp = t0;
ctx.meter.exportW = surplusW > 0 ? surplusW : 0.0;
ctx.meter.importW = surplusW < 0 ? -surplusW : 0.0;
ctx.drawMargin = marge;
ctx.loads.append(routeur->toLoadContext(t0));
const Plan plan = sched.getPlan(ctx);
for (const LoadAction &a : plan.timeSlots.first().actions)
if (a.loadId == routeur->descriptor().id)
return a;
return LoadAction();
};
DrawMargin aucun; // borne == false : personne ne borne
DrawMargin epuise; // autorisation NULLE, et même dépassée
epuise.borne = true; epuise.watts = -460; epuise.source = DrawCapSource::Breaker; epuise.phase = 0;
DrawMargin reste; // il RESTE 500 W, sous le palier de 1000
reste.borne = true; reste.watts = 500; reste.source = DrawCapSource::Breaker; reste.phase = 1;
DrawMargin large; // 5000 W : le plafond existe et ne mord pas
large.borne = true; large.watts = 5000; large.source = DrawCapSource::Breaker; large.phase = 0;
DrawMargin total; // plafond TOTAL : aucune phase coupable
total.borne = true; total.watts = -300; total.source = DrawCapSource::GridOperator; total.phase = -1;
// 1. CONTRE-ÉPREUVE — sans plafond, 5000 W de surplus servent le palier. C'est la ligne de
// base : si elle tombait, les suivantes ne prouveraient rien sur le plafond.
const LoadAction sansPlafond = decider(5000, aucun);
QCOMPARE(qRound(sansPlafond.estimatedPowerW), 1000);
QCOMPARE(sansPlafond.reason.code, QString(DecisionCode::SurplusSetpoint));
// 2. Un plafond LARGE ne change rien. Un plafond qui mord toujours serait un plafond qui
// ne borne pas : il coupe.
const LoadAction sousPlafondLarge = decider(5000, large);
QCOMPARE(qRound(sousPlafondLarge.estimatedPowerW), 1000);
QCOMPARE(sousPlafondLarge.reason.code, QString(DecisionCode::SurplusSetpoint));
// 3. Autorisation ÉPUISÉE : le surplus est pléthorique, et pourtant rien n'est servi. Le
// motif porte les quatre grandeurs gravées au §6.3 du design.
const LoadAction bloque = decider(5000, epuise);
QCOMPARE(qRound(bloque.estimatedPowerW), 0);
QCOMPARE(bloque.reason.code, QString(DecisionCode::DrawCap));
QCOMPARE(bloque.reason.params.value("marginW").toInt(), -460);
QCOMPARE(bloque.reason.params.value("overshootW").toInt(), 460);
QCOMPARE(bloque.reason.params.value("source").toString(), QString("connection"));
QCOMPARE(bloque.reason.params.value("phase").toString(), QString("A"));
// 4. \rule{LM-1011} — il RESTE 500 W d'autorisation, sous le palier de 1000. Ce n'est PAS
// DRAW_CAP : il y a de l'autorisation, elle n'est pas au bon format.
const LoadAction pasAuFormat = decider(5000, reste);
QCOMPARE(qRound(pasAuFormat.estimatedPowerW), 0);
QCOMPARE(pasAuFormat.reason.code, QString(DecisionCode::BelowMinPower));
QVERIFY2(pasAuFormat.reason.code != QString(DecisionCode::DrawCap),
"autorisation résiduelle non nulle : LM-1011 impose BELOW_MIN_POWER");
// 5. Plafond TOTAL : aucune phase n'est coupable, et le motif n'en invente pas une.
// Publier « A » par défaut serait indiscernable d'une vraie phase A, et enverrait
// mesurer au hasard.
const LoadAction plafondTotal = decider(5000, total);
QCOMPARE(plafondTotal.reason.code, QString(DecisionCode::DrawCap));
QCOMPARE(plafondTotal.reason.params.value("source").toString(), QString("gridOperator"));
QVERIFY2(!plafondTotal.reason.params.contains("phase"),
"un plafond total n'a pas de phase coupable — ne pas en publier une");
// 6. Le TEXTE publié nomme la ressource et la source. Règle 7-b : le motif nomme le
// mécanisme qui a produit l'issue. Dire « surplus » ici enverrait attendre le soleil
// devant 5000 W de surplus disponibles.
const QString texte = renderFr(bloque.reason, routeur->descriptor().label);
QVERIFY2(texte.contains("soutirage", Qt::CaseInsensitive), qUtf8Printable("motif: " + texte));
QVERIFY2(texte.contains("branchement", Qt::CaseInsensitive), qUtf8Printable("motif: " + texte));
QVERIFY2(!texte.contains("surplus", Qt::CaseInsensitive), qUtf8Printable("motif: " + texte));
#endif
}
void Simulation::testCountsSumToTarget()
{
#ifndef ETM_ARBITRATOR
QSKIP("testCountsSumToTarget nécessite ETM_ARBITRATOR.");
#else
// [R3] « Chaque watt publié tombe dans exactement un compteur, et le mapping est publié. »
//
// Sept endroits construisent une LoadAction, et chacun doit renseigner `counts`. Vérifier
// cela en RELISANT le code se referait à chaque site ajouté, et échouerait le jour où
// quelqu'un en ajoute un huitième. Ce test relit la CHARGE UTILE : il ne fait confiance à
// aucun site, il constate le résultat sur toutes les charges du cycle.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-counts.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
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);
QCOMPARE(injectAndWait("NymeaEnergy.SetAcquisitionTolerance",
QVariantMap({{"acquisitionTolerance", 0.5}})).toMap()
.value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
// Trois mécanismes + une borne : le plus de chemins de construction possible en un cycle.
QUuid rA = addPowerSwitch(1000, 26661);
QUuid k1 = addPowerSwitch(0, 26662);
QUuid k2 = addPowerSwitch(0, 26663);
QUuid varId = addEtmVariableLoad(27044);
QUuid borneId = addCharger("A", 16, 26657);
Thing *borne = tm->findConfiguredThing(borneId);
Thing *var = tm->findConfiguredThing(varId);
QVERIFY(borne && var && tm->findConfiguredThing(rA));
armerBorne(borne);
var->setStateValue("currentPowerW", 0);
QVariantMap info;
info.insert("evChargerId", borneId);
info.insert("chargingMode", "ChargingModeEco");
injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
meter->setStateValue("currentPower", 0);
arb->simulationCallUpdate(t0);
QCoreApplication::processEvents();
QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
.value("params").toMap().value("loadConfigs").toList();
// La borne au rang 1 — sinon, auto-provisionnée EN QUEUE (LM-1209), elle ne voit jamais de
// budget et le régime EV_GRID_START n'est pas exercé. C'est la contre-épreuve qui l'a
// montré : retirer la ventilation du partage laissait le test PASSER.
for (int i = 0; i < l.count(); ++i) {
QVariantMap m = l.at(i).toMap();
if (m.value("id").toString() == borneId.toString()) m.insert("priority", 1);
l[i] = m;
}
l.append(QVariantMap{{"id", varId.toString()}, {"label", "Routeur"},
{"adapter", "etmvariableload"}, {"mode", "dynamic"},
{"maxPowerW", 3000}, {"priority", 8}, {"enabled", true}});
l.append(QVariantMap{{"id", "ecs-counts"}, {"label", "ECS"}, {"adapter", "relay-router"},
{"mode", "fixed"}, {"priority", 9}, {"enabled", true},
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()},
{"powerW", 1000}}},
{"minOnS", 0}, {"minOffS", 0}});
l.append(sgReadyConfigMap("pac-counts", "PAC", k1, k2, 800.0, 1600.0, 0, 10));
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
// L'identité doit tenir dans TOUS les régimes, pas seulement quand tout est servi : un
// surplus large, un surplus qui force le démarrage sous tolérance, et un import qui
// déleste. Le troisième est celui où les allocations valent 0 — donc celui où un `counts`
// vide passerait inaperçu.
struct Regime { int compteurW; const char *quoi; bool attendEvGridStart; };
// EV_GRID_START EN PREMIER, et l'ordre est un fait du test, pas une préférence : dès qu'un
// cycle a fait charger la borne, son RECRÉDIT (correction B) porte le budget bien au-dessus
// du plancher, et le démarrage sous tolérance ne peut plus se produire. Placé en second, le
// régime n'était jamais exercé — la contre-épreuve l'a montré en laissant PASSER un partage
// amputé de moitié.
const QList<Regime> regimes = { {-900, "démarrage sous tolérance (EV_GRID_START)", true},
{-9000, "surplus large", false},
{2000, "import — délestage, allocations nulles", false} };
int cycle = 0;
for (const Regime &r : regimes) {
meter->setStateValue("currentPower", r.compteurW);
arb->simulationCallUpdate(t0.addSecs(60 * ++cycle));
QCoreApplication::processEvents();
const QVariantMap snap = arb->loadTelemetry();
const QVariantList charges = snap.value("loads").toList();
QVERIFY2(!charges.isEmpty(), r.quoi);
if (r.attendEvGridStart) {
bool vu = false;
for (const QVariant &v : charges)
if (v.toMap().value("decision").toMap().value("code").toString()
== QLatin1String(DecisionCode::EvGridStart))
vu = true;
QVERIFY2(vu, "le régime EV_GRID_START n'a PAS été exercé — le test ne prouverait "
"alors rien du partage entre les deux registres");
}
for (const QVariant &v : charges) {
const QVariantMap ch = v.toMap();
const QVariantList niveaux = ch.value("levels").toList();
QVERIFY2(!niveaux.isEmpty(),
qUtf8Printable(QStringLiteral("%1 : %2 sans levels[]")
.arg(r.quoi, ch.value("loadId").toString())));
for (const QVariant &nv : niveaux) {
const QVariantMap niveau = nv.toMap();
const QVariantMap counts = niveau.value("counts").toMap();
// 1. TOUJOURS présent, et jamais vide — même quand tout vaut 0. Un counts vide
// satisferait l'identité en perdant la destination, sur le cas le plus
// fréquent : c'est exactement la panne que R3 existe pour empêcher.
QVERIFY2(!counts.isEmpty(),
qUtf8Printable(QStringLiteral("%1 : %2 publie un counts VIDE")
.arg(r.quoi, ch.value("loadId").toString())));
// 2. Σ counts == targetW, à l'arrondi du watt près.
double somme = 0;
for (const QVariant &c : counts)
somme += c.toDouble();
QVERIFY2(qAbs(somme - niveau.value("targetW").toDouble()) < 1.0,
qUtf8Printable(QStringLiteral("%1 : %2 — Σ counts = %3, targetW = %4")
.arg(r.quoi, ch.value("loadId").toString())
.arg(somme).arg(niveau.value("targetW").toDouble())));
// 3. Les clés appartiennent au catalogue : une clé inventée ne se glisse pas
// dans la charge utile sans que quelqu'un l'ait décidée.
for (const QString &k : counts.keys())
QVERIFY2(k == QLatin1String(CountKey::BudgetAllocated)
|| k == QLatin1String(CountKey::DrawCommitted),
qUtf8Printable(QStringLiteral("clé de counts hors catalogue : %1").arg(k)));
}
}
// 4. Et la ventilation se RÉCONCILIE avec les compteurs du cycle — c'est l'identité que
// l'app calcule, relue ici sur la charge utile publiée.
double surplus = 0, achete = 0;
for (const QVariant &v : charges)
for (const QVariant &nv : v.toMap().value("levels").toList()) {
const QVariantMap c = nv.toMap().value("counts").toMap();
surplus += c.value(QString::fromLatin1(CountKey::BudgetAllocated)).toDouble();
achete += c.value(QString::fromLatin1(CountKey::DrawCommitted)).toDouble();
}
QVERIFY2(qAbs(surplus - snap.value("budget").toMap().value("allocatedW").toDouble()) < 1.0,
qUtf8Printable(QStringLiteral("%1 : Σ counts(surplus) = %2, budget.allocatedW = %3")
.arg(r.quoi).arg(surplus)
.arg(snap.value("budget").toMap().value("allocatedW").toDouble())));
QVERIFY2(qAbs(achete - snap.value("draw").toMap().value("committedW").toDouble()) < 1.0,
qUtf8Printable(QStringLiteral("%1 : Σ counts(acheté) = %2, draw.committedW = %3")
.arg(r.quoi).arg(achete)
.arg(snap.value("draw").toMap().value("committedW").toDouble())));
}
#endif
}
void Simulation::testLevelAbsenceAndZeroDifferInMeaning()
{
#ifndef ETM_ARBITRATOR
QSKIP("testLevelAbsenceAndZeroDifferInMeaning nécessite ETM_ARBITRATOR.");
#else
// [R1] « levels[] ne contient que les passes RÉELLEMENT parcourues. » La règle a deux
// moitiés qui se répondent, et publier l'une sans l'autre les rendrait indistinguables :
//
// niveau ABSENT → la passe n'a pas été parcourue ;
// niveau à 0 → la passe A ÉTÉ parcourue, elle n'avait rien à prendre.
//
// Un zéro forgé à la place d'une absence détruirait la première information ; une absence à
// la place d'un zéro détruirait la seconde. Aujourd'hui la passe unique est toujours
// parcourue quand la charge est arbitrée — c'est le MODE DÉGRADÉ qui porte le cas « aucune
// passe », la planification y étant suspendue.
//
// [R7] Et `decision.level` accompagne `levels[]` : les deux présents, ou les deux absents.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-levels.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
enableNotifications({"NymeaEnergy"});
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 varId = addEtmVariableLoad(27045);
Thing *var = tm->findConfiguredThing(varId);
QVERIFY(var);
var->setStateValue("currentPowerW", 0);
QVariantList l;
l.append(QVariantMap{{"id", varId.toString()}, {"label", "Routeur"},
{"adapter", "etmvariableload"}, {"mode", "dynamic"},
{"maxPowerW", 3000}, {"priority", 1}, {"enabled", true}});
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
// ---- 1. Passe parcourue AVEC budget : niveau publié, targetW > 0 -----------------------
meter->setStateValue("currentPower", -2000);
arb->recordMeterUpdate(t0);
arb->simulationCallUpdate(t0);
QCoreApplication::processEvents();
QVariantMap ch = telemetrieParId(arb->loadTelemetry()).value(varId.toString());
QCOMPARE(ch.value("levels").toList().count(), 1);
QVERIFY(qRound(ch.value("levels").toList().first().toMap().value("targetW").toDouble()) > 0);
QCOMPARE(ch.value("decision").toMap().value("level").toString(), QString("comfort"));
// ---- 2. Passe parcourue SANS budget : le niveau reste publié, à ZÉRO -------------------
// C'est la moitié qui compte. « Rien à prendre » n'est pas « pas de passe » : la charge a
// bien été examinée, le motif le dit, et l'écran doit pouvoir écrire « en attente » plutôt
// que de faire disparaître la ligne.
var->setStateValue("currentPowerW", 0);
meter->setStateValue("currentPower", 3000); // import franc
arb->recordMeterUpdate(t0.addSecs(60));
arb->simulationCallUpdate(t0.addSecs(60));
QCoreApplication::processEvents();
ch = telemetrieParId(arb->loadTelemetry()).value(varId.toString());
const QVariantList niv = ch.value("levels").toList();
QCOMPARE(niv.count(), 1);
QCOMPARE(qRound(niv.first().toMap().value("targetW").toDouble()), 0);
QVERIFY2(!niv.first().toMap().value("counts").toMap().isEmpty(),
"un niveau à zéro publie quand même sa destination");
QCOMPARE(ch.value("decision").toMap().value("level").toString(), QString("comfort"));
QVERIFY2(ch.value("decision").toMap().value("code").toString() != QString(),
"une passe parcourue sans budget porte un MOTIF, pas un silence");
// ---- 3. Aucune passe parcourue : levels[] ABSENT, et decision.level avec lui -----------
// Mode dégradé L2 : la planification est SUSPENDUE. Publier un niveau à zéro y mentirait —
// il n'y a pas eu de passe du tout, et « rien à prendre » supposerait qu'on ait regardé.
QSignalSpy spy(m_mockTcpServer, &MockTcpServer::outgoingData);
arb->evaluateMeterFreshness(t0.addSecs(60 + 95)); // compteur muet > 90 s
QCoreApplication::processEvents();
const QVariantList notifs = checkNotifications(spy, "NymeaEnergy.LoadTelemetryChanged");
QVERIFY2(!notifs.isEmpty(), "la bascule en mode dégradé doit notifier");
const QVariantMap dgr = notifs.last().toMap().value("params").toMap();
QCOMPARE(dgr.value("degradedMode").toBool(), true);
bool vuUneCharge = false;
for (const QVariant &v : dgr.value("loads").toList()) {
vuUneCharge = true;
const QVariantMap c = v.toMap();
QVERIFY2(!c.contains("levels"),
"levels[] publié en mode dégradé : aucune passe n'a pourtant été parcourue");
QVERIFY2(!c.value("decision").toMap().contains("level"),
"decision.level sans levels[] : le client devrait deviner à quoi il se rapporte");
}
QVERIFY2(vuUneCharge, "le mode dégradé doit publier les charges, sans leurs niveaux");
#endif
}