Patrick Schurig ae5e8639c4 feat: journaliser la divergence, et publier la frontière de période de reporting
DIVERGENCE JOURNALISÉE. `divergent` et `since` ne vivaient que dans la charge
utile de télémétrie et dans un QHash en mémoire — donc nulle part. Rien ne
comptait les épisodes : chaque redémarrage de nymead effaçait l'état, et le hash
est PURGÉ dès que l'écart cesse, si bien qu'une divergence qui s'ouvre et se
referme entre deux lectures ne laissait aucune trace. Un observateur externe
échantillonnant en continu aurait raté exactement les épisodes courts, qui sont
probablement les plus nombreux.

Conséquence : le critère « attendre que les traces parlent » attendait
indéfiniment. Le silence du journal était garanti par construction, pas par
l'absence du phénomène — l'instrument dont le silence ressemble à un résultat.
Deux `qCInfo`, aux deux transitions, avec la durée à la fermeture. Le journal de
.75 est persistant depuis le 2026-08-13 (200 Mo, un mois) : le comptage devient
trivial, et l'étape 4 pourra se décider sur des faits.

FRONTIÈRE DE PÉRIODE PUBLIÉE (LM-1005-b-iv). `System.GetTime` donne un NOM de
fuseau ; un décalage ne s'en déduit pas sans base de fuseaux, et embarquer une
base pour recalculer une frontière que le moteur connaît déjà resterait faux les
jours de changement d'heure, où la journée fait 23 ou 25 heures. Trois clients la
recalculeraient de trois façons, et la seule qui compte est celle qui a servi au
calcul. Même règle que `counts`.

C'est aussi la parade à la troisième nature d'heure murale : la période de
reporting suit l'installation, correctement, mais repose sur la prémisse que le
fuseau de la box SOIT celui de l'installation — et c'était le seul des trois cas
où personne ne vérifiait. `timeZone` rend la vérification possible.

DEUX CHAMPS, PAS UN (LM-1005-b-v), et c'est un ajout à la demande. La baseline se
repose à chaque redémarrage de nymead et à chaque recul de compteur, donc EN
PLEIN JOUR. Un jour où nymead a redémarré à 14 h, les ratios ne couvrent pas la
journée : ils couvrent depuis 14 h. Publier la seule frontière ferait écrire
« aujourd'hui » sur des chiffres qui ne parlent que de l'après-midi — un silence
de la même famille que celui qu'on ferme. `measuredSince` dit la portée réelle ;
quand les deux coïncident, le cas normal, il n'y a rien à dire de plus, et c'est
ce qui rend l'écart lisible le jour où il apparaît. Même paire, même raison que
`progress.measuredSince` / `targetWh`.

Les trois champs sont TOUJOURS présents, même quand les deux taux sont omis :
« la période a commencé à minuit et rien n'est encore mesurable » est une phrase
vraie et utile, là où une réponse vide ne se distingue pas d'une panne.

INVARIANT 11 au passage : le schéma du Get et celui du Changed étaient déclarés
DEUX fois. Ajouter trois champs à deux endroits est exactement la manière dont
ils divergent — d'où `energyRatiosSchema()`, déclaré une fois et servi aux deux,
comme `loadTelemetrySchema()`.

TEST vu mordre : remplacer `measuredSince` par la frontière — l'implémentation
naïve que la demande aurait pu produire — fait échouer l'assertion sur la repose
en plein jour (attendu 10:00, obtenu 00:00).

Suites : simulation 66/66, loadmodel 23/23, charging 48/48, spotmarket 32/32.
2026-09-05 08:01:16 +02:00

8160 lines
418 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);
// ── LA FRONTIÈRE DE PÉRIODE ET L'INSTANT DE MESURE, qui ne sont PAS le même instant ──
//
// Publier la seule frontière ferait écrire « aujourd'hui » sur des chiffres qui ne parlent
// pas de la journée : la baseline se repose à chaque redémarrage de nymead et à chaque
// recul de compteur, donc en plein jour. C'est la paire qui dit la vérité, pas le champ.
const QDateTime minuit29(QDate(2026, 6, 29), QTime(0, 0));
const QDateTime minuit30(QDate(2026, 6, 30), QTime(0, 0));
// Cas NORMAL — pose à minuit : les deux coïncident, et c'est ce qui rend l'écart lisible
// quand il apparaît.
QCOMPARE(a.debutPeriode, minuit29);
QCOMPARE(a.mesureDepuis, d29_00);
// Sans repose, l'instant de mesure NE BOUGE PAS : il date la baseline, pas le calcul.
QCOMPARE(b.debutPeriode, minuit29);
QCOMPARE(b.mesureDepuis, d29_00);
// REPOSE EN PLEIN JOUR (compteur non monotone à 10:00) — LES DEUX DIVERGENT. C'est le cas
// qui justifie la paire : la frontière dit toujours minuit, la mesure ne couvre que depuis
// 10 h, et un écran qui n'aurait que la frontière l'affirmerait à tort sur la journée.
QCOMPARE(d.debutPeriode, minuit29);
QCOMPARE(d.mesureDepuis, d29_10);
QVERIFY2(d.mesureDepuis > d.debutPeriode,
"une repose en plein jour doit se VOIR : sinon les ratios mentent sur leur portée");
// BASCULE DE JOUR — la frontière suit le nouveau jour, et la mesure date du premier calcul
// APRÈS minuit, jamais de minuit lui-même. Sur une box qui calcule en continu l'écart est
// de quelques secondes ; l'affirmer nul serait une approximation que rien n'oblige à faire.
QCOMPARE(e.debutPeriode, minuit30);
QCOMPARE(e.mesureDepuis, d30_02);
// Et la frontière ne rebouge pas dans la journée.
QCOMPARE(f.debutPeriode, minuit30);
QCOMPARE(f.mesureDepuis, d30_02);
#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);
// ---- `draw` : ce qui EXISTE se publie, ce qui n'existe pas s'ABSENTE -------------------
//
// Ce test REMPLACE celui qui épinglait l'absence d'`authorisedW`. Celui-là avait un motif
// juste — « un plafond de soutirage qui n'existe pas ne doit pas s'afficher » — et il a
// survécu à ce motif : le lot délestage a CRÉÉ le plafond, et le champ est devenu une
// lecture au lieu d'une forge. Sa garde, elle, ne savait pas que le monde avait changé.
// C'est le troisième mode d'érosion d'AGENTS.md, et la parade est ci-dessous.
//
// ⚠️ **CE TEST DEVIENT FAUX** le jour où l'une de ces deux choses arrive :
// · un plafond de soutirage devient publiable SANS être en vigueur — alors la condition
// de présence change, et c'est elle qu'il faut réécrire, pas les assertions ;
// · `committedW` cesse d'exister à chaque cycle — alors même lui devient conditionnel.
// Tant qu'aucune des deux n'est vraie, un échec ici signale une régression, pas un test
// périmé.
const QVariantMap draw = notif.value("draw").toMap();
// `committedW` est publié TOUJOURS : la grandeur existe à chaque cycle, et zéro en est une
// valeur. C'est la moitié « le zéro est une valeur quand la grandeur existe ».
QVERIFY2(draw.contains(QStringLiteral("committedW")),
"le registre des watts achetés existe à chaque cycle, même vide");
// Et l'autre moitié : ici AUCUN plafond n'est réglé (`phasePowerLimit` vaut 0, contrat
// amont : la protection est désactivée). Les quatre champs qui décrivent l'autorisation
// doivent donc être ABSENTS — pas à zéro. Un `authorisedW: 0` avec un `binding` par défaut
// dirait « aucune autorisation, imposée par le branchement » là où la vérité est « aucun
// plafond n'existe ». Les deux affirmations commandent des gestes opposés.
for (const QString &champ : {QStringLiteral("authorisedW"), QStringLiteral("remainingW"),
QStringLiteral("binding"), QStringLiteral("perPhaseBound")})
QVERIFY2(!draw.contains(champ),
qUtf8Printable(QStringLiteral("sans plafond réglé, `%1` doit être ABSENT et "
"non nul — l'absence dit qu'aucun plafond "
"n'existe").arg(champ)));
// 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
}
/*!
* \brief [ECS-415] Un refus doit se DISTINGUER d'une application, par la valeur de retour.
*
* \par Le défaut
* `SgReadyAdapter::applyAction()` renvoyait, en cas de refus par le verrou `minStateHold`,
* **l'action demandée telle quelle**. Un appelant qui relisait `state` y retrouvait l'état
* qu'il venait de demander, et concluait au succès — alors que rien n'avait été écrit et que
* les contacts n'avaient pas bougé. Deux autres sorties avaient le même travers (motif vide,
* état non déclaré), et les deux qui corrigeaient bien `state` (\c !m_usable, \c m_faulted)
* laissaient `estimatedPowerW` à la valeur de l'appelant.
*
* \par Pourquoi c'est plus grave qu'un délestage manqué
* N'IMPORTE QUELLE descente d'état pouvait échouer en silence, et `available` restait vrai :
* la charge avait l'air saine pendant que la PAC tirait 3 kW. C'est la famille d'ECS-111 —
* une issue indiscernable de son contraire — appliquée cette fois au retour d'une commande.
*
* \par Ce qui est épinglé
* Le refus et l'application se lisent DÉSORMAIS par le même test, et le retour décrit toujours
* l'état réellement tenu, puissance estimée comprise.
*/
void Simulation::testARefusedStateIsDistinguishableFromAnApplied()
{
#ifndef ETM_ARBITRATOR
QSKIP("testARefusedStateIsDistinguishableFromAnApplied nécessite ETM_ARBITRATOR.");
#else
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-415.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 k1 = addPowerSwitch(0, 26696);
QUuid k2 = addPowerSwitch(0, 26697);
Thing *tK1 = tm->findConfiguredThing(k1);
Thing *tK2 = tm->findConfiguredThing(k2);
QVERIFY(tK1 && tK2);
const QDateTime t0 = utcDateTime(QDate(2026, 8, 30), 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} });
// Contacts fermés : l'adaptateur neuf LIT l'état 4 (ECS-411-b). Le verrou minStateHold est
// armé à froid (ECS-412), donc toute descente est refusée ce cycle.
tK1->setStateValue("power", true);
tK2->setStateValue("power", true);
SgReadyAdapter *pac = new SgReadyAdapter(tm, "pac-415", "PAC refus lisible",
encodage, estim, 300, 1, arb);
QCOMPARE(pac->currentState(), 4);
LoadAction descente;
descente.loadId = "pac-415";
descente.kind = LoadAction::State;
descente.state = 2;
descente.estimatedPowerW = 0; // ce que l'APPELANT croit — c'est le piège
descente.reason = { DecisionCode::SgNormal, {{"budgetW", 0}} };
// --- 1. REFUSÉ par le verrou : le retour doit décrire l'état TENU, pas l'état demandé ---
const LoadAction refuse = pac->applyAction(descente, t0);
QCOMPARE(refuse.state, 4);
QCOMPARE(qRound(refuse.estimatedPowerW), 3000);
QCOMPARE(tK1->stateValue("power").toBool(), true);
QCOMPARE(tK2->stateValue("power").toBool(), true);
QCOMPARE(pac->currentState(), 4);
// --- 2. FORCÉ : le même appel, verrou franchi, doit se lire autrement -------------------
// C'est la moitié qui donne son sens à la première : si les deux retours étaient égaux,
// épingler la valeur du refus ne prouverait rien.
LoadAction forcee = descente;
forcee.force = true;
const LoadAction applique = pac->applyAction(forcee, t0);
QCOMPARE(applique.state, 2);
QCOMPARE(qRound(applique.estimatedPowerW), 0);
QCOMPARE(tK1->stateValue("power").toBool(), false);
QCOMPARE(tK2->stateValue("power").toBool(), false);
QCOMPARE(pac->currentState(), 2);
// --- 3. IDEMPOTENCE : l'état est bien celui demandé, mais la puissance vient de
// l'adaptateur. Un appelant qui remonte à 4 avec 0 W dans son enveloppe ne doit pas
// se voir rendre son propre zéro sur une PAC qui en tirerait 3000.
LoadAction remonte;
remonte.loadId = "pac-415";
remonte.kind = LoadAction::State;
remonte.state = 2; // état DÉJÀ tenu
remonte.estimatedPowerW = 12345; // valeur absurde de l'appelant
remonte.reason = { DecisionCode::SgNormal, {{"budgetW", 0}} };
const LoadAction idem = pac->applyAction(remonte, t0.addSecs(1));
QCOMPARE(idem.state, 2);
QCOMPARE(qRound(idem.estimatedPowerW), 0);
#endif
}
/*!
* \brief [Étape 4] L4 déleste une PAC en la faisant CHANGER D'ÉTAT, et dit qu'il a forcé.
*
* \par Règles couvertes
* \rule{LM-1211} (délestage en ordre inverse du rang), \rule{LM-1211-a} (un seul commandeur par
* organe), \rule{LM-1211-b} (le franchissement se CONSTATE), \rule{LM-1211-d} (délester peut
* être changer d'état).
*
* \par Le premier cas où délester ne veut pas dire retirer des watts
* Une PAC ne se module pas. Le bornage de la cascade (\rule{LM-1210}) ne peut rien contre une
* PAC déjà en état 4 : il empêche d'ALLOUER au-delà du plafond, il ne retire rien à personne.
* Seul le délestage peut la faire redescendre — et c'est un changement d'ÉTAT.
*
* \par C'est aussi le cas où le franchissement de verrou prend son sens
* `minStateHold` est le verrou qui s'oppose à cette descente. Le plan le constate et publie
* `LOCK_MIN_STATE_HOLD` ; le disjoncteur, lui, n'attend pas 300 secondes. L4 franchit, et **le
* dit** — un verrou franchi en silence, c'est un compresseur qui court-cycle sans que personne
* ne sache pourquoi.
*
* \par Les trois cycles sont tous NÉCESSAIRES, et chacun a été mesuré avant d'être asserté
* Deux versions antérieures de ce test passaient **sans rien prouver**, parce que leurs
* prémisses étaient supposées. Elles sont donc vérifiées ici, une par une :
* 1. l'armement à froid (ECS-412) **gèle la PAC à l'état 2** — on ne peut pas monter à 4 au
* premier cycle, et croire le contraire faisait tester une PAC qui n'a jamais chauffé ;
* 2. une fois le verrou expiré, le surplus la porte en état 4 et **réarme** le verrou ;
* 3. c'est seulement là que le dépassement rencontre un verrou actif.
*/
void Simulation::testL4ShedsAPacAndSaysItForcedTheLock()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-shed.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 k1 = addPowerSwitch(0, 26694);
QUuid k2 = addPowerSwitch(0, 26695);
Thing *relais1 = tm->findConfiguredThing(k1);
Thing *relais2 = tm->findConfiguredThing(k2);
QVERIFY(relais1 && relais2);
LoadConfigStore *store = new LoadConfigStore(arb);
arb->setLoadConfigStore(store);
const int minStateHoldS = 300;
LoadConfigs cfgs;
cfgs.append(LoadConfig::fromMap(QVariantMap{
{"id", "pac-delestee"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"},
{"priority", 2}, {"enabled", true}, {"domain", "hvac"},
{"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", 800.0}}
<< QVariantMap{{"state", 4},
{"relays", QVariantList() << k1.toString() << k2.toString()},
{"estimatedPowerW", 3000.0}}}}}}));
QString err;
QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8());
QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit",
QVariantMap({{"phasePowerLimit", 25}})).toMap()
.value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
auto cycle = [&](const QDateTime &at) {
arb->simulationCallUpdate(at);
QTest::qWait(50);
QCoreApplication::processEvents();
return telemetrieParId(arb->loadTelemetry()).value("pac-delestee");
};
// Date FIXE, et elle le redevient : `setPhasePowerLimit()` lançait un cycle d'arbitrage
// complet à l'horloge murale, faute de la garde ENERGY_SIMULATION que ses voisins ont.
// L'armement paresseux (ECS-412) figeait alors `m_lastSwitch` sur l'heure qu'il était, et
// ce scénario se déroulait « avant » son propre armement — il passait ou échouait SELON
// L'HEURE DE LA JOURNÉE. La garde rendue, une date fixe redevient préférable : elle rend
// les journaux reproductibles.
const QDateTime t0 = utcDateTime(QDate(2026, 8, 30), QTime(13, 0, 0));
meter->setStateValue("currentPhaseA", 0);
meter->setStateValue("currentPhaseB", 0);
meter->setStateValue("currentPhaseC", 0);
meter->setStateValue("currentPower", -6000);
QVariantMap pac = cycle(t0);
// PRÉMISSE 1 — l'armement à froid gèle la PAC à l'état 2. Le surplus est pléthorique et
// n'y change rien : c'est ECS-412, et c'est ce que l'app verra après chaque redémarrage.
QCOMPARE(pac.value("mechanism").toMap().value("state").toInt(), 2);
QCOMPARE(pac.value("decision").toMap().value("code").toString(),
QString(DecisionCode::LockMinStateHold));
const QDateTime t1 = t0.addSecs(3600); // largement au-delà de l'armement à froid
pac = cycle(t1);
// PRÉMISSE 2 — verrou expiré, le surplus porte la PAC en état 4, et le verrou SE RÉARME.
// Sans une PAC réellement à 3 kW, le délestage n'aurait rien à retirer.
QCOMPARE(pac.value("mechanism").toMap().value("state").toInt(), 4);
QCOMPARE(pac.value("decision").toMap().value("code").toString(), QString(DecisionCode::SgForced));
QCOMPARE(relais1->stateValue("power").toBool(), true);
QCOMPARE(relais2->stateValue("power").toBool(), true);
QCOMPARE(pac.value("lock").toMap().value("remainingS").toInt(), minStateHoldS);
const QDateTime t2 = t1.addSecs(10);
meter->setStateValue("currentPhaseA", 30);
meter->setStateValue("currentPower", 6900);
pac = cycle(t2);
// 1. LA PAC EST REDESCENDUE, et seul L4 pouvait le faire : à t2 le verrou court encore.
QCOMPARE(pac.value("mechanism").toMap().value("state").toInt(), 2);
QCOMPARE(relais1->stateValue("power").toBool(), false);
QCOMPARE(relais2->stateValue("power").toBool(), false);
// 2. Le motif publié est celui du DÉLESTAGE, pas celui du plan. La charge utile doit dire
// ce qui a été exécuté : le plan n'a pas commandé cette charge — L4 l'a REVENDIQUÉE et
// le dispatch l'a sautée. Publier LOCK_MIN_STATE_HOLD montrerait une PAC « maintenue »
// que le matériel vient justement de couper.
const QVariantMap decision = pac.value("decision").toMap();
QCOMPARE(decision.value("code").toString(), QString(DecisionCode::DrawCap));
const QVariantMap p = decision.value("params").toMap();
QCOMPARE(p.value("shed").toBool(), true);
QCOMPARE(p.value("source").toString(), QString("connection"));
QCOMPARE(p.value("phase").toString(), QString("A"));
QCOMPARE(p.value("overshootW").toInt(), 1150);
QCOMPARE(p.value("marginW").toInt(), -1150);
// Les watts RÉELLEMENT rendus : la puissance allouée de l'état quitté.
QCOMPARE(p.value("shedW").toInt(), 3000);
// 3. LE FRANCHISSEMENT SE DIT, et il a été CONSTATÉ, jamais présumé : le délestage tente
// d'abord SANS forcer et ne pose ce marqueur que si le verrou a réellement refusé.
// Le déduire d'un verrou simplement ACTIF serait faux — un verrou peut être armé et
// permettre malgré tout le geste demandé. Le marqueur annoncerait alors un court-cycle
// qui n'a pas eu lieu, et le premier compresseur réellement maltraité passerait
// inaperçu au milieu des fausses alertes.
//
// Ce constat n'est possible que depuis \rule{ECS-415} : tant que le refus renvoyait
// l'action DEMANDÉE, il se lisait comme un succès, et le délestage n'a jamais forcé —
// il publiait 3 kW rendus par une PAC restée en état 4.
QCOMPARE(p.value("minOnBypassed").toBool(), true);
// 4. Le TEXTE nomme la ressource et la source, jamais le surplus : ici il n'y en a pas, et
// accuser le surplus enverrait attendre le soleil devant un disjoncteur qui sature.
DecisionReason motif(DecisionCode::DrawCap, p);
const QString texte = renderFr(motif, "PAC");
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
}
/*!
* \brief [§10 étape B] `draw` publie l'autorisation — et la RÉSERVE BATTERIE ne l'annule pas.
*
* \par Les quatre champs se publient parce qu'ils EXISTENT
* `authorisedW`, `remainingW`, `binding` et `perPhaseBound` étaient absents parce qu'aucun
* plafond de soutirage n'existait. Le lot délestage en a créé un ; ils se LISENT désormais, au
* lieu d'être forgés. Leur condition de présence est donc « un plafond est en vigueur », et non
* « le transport du §14a existe » — le refus d'origine visait la forge, pas la lecture.
*
* \par Ce que ce test épingle vraiment : \rule{LM-1203-b} vue depuis la frontière
* Sous la réserve batterie, le budget de surplus est **annulé** — pas réduit. Mais la réserve
* n'annule QUE le surplus : l'**autorisation de soutirage** est une contrainte physique du
* branchement, et aucune règle de stockage ne la modifie. Un client verra donc
* `budget.remainingW == 0` **et** `draw.remainingW != 0` dans la même trame, et il aura raison
* de le signaler si personne ne le lui a dit. C'est dit — ici, et dans `INTERFACE.md`.
*
* \warning **Ce test devient faux** le jour où la réserve batterie borne aussi le soutirage —
* c'est-à-dire le jour où l'on déciderait qu'une batterie basse interdit d'acheter au réseau.
* Ce serait une décision de modèle, pas une correction : c'est alors l'assertion 3 qu'il faut
* réécrire, pas le moteur.
*/
void Simulation::testDrawAuthorisationSurvivesTheBatteryReserve()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
RuleBasedScheduler sched(arb);
const QDateTime t0 = utcDateTime(QDate(2026, 8, 31), QTime(13, 0, 0));
auto planifier = [&](bool batterieBasse, bool avecPlafond) {
SurplusContext ctx;
ctx.timestamp = t0;
ctx.meter.exportW = 5000; // 5 kW de surplus disponibles
if (avecPlafond) {
ctx.drawMargin.borne = true;
ctx.drawMargin.watts = 3450; // 25 A − 10 A tirés, sur 230 V
ctx.drawMargin.source = DrawCapSource::Breaker;
ctx.drawMargin.phase = 0; // phase A : plafond PAR PHASE
}
if (batterieBasse) {
ctx.battery.present = true;
ctx.battery.socPercent = 10; // sous le seuil par défaut (20 %)
}
return sched.getPlan(ctx);
};
// 1. SANS plafond réglé : les quatre champs restent SANS OBJET. `capBorne` est ce qui
// décide de leur présence à la frontière — et il ne se déduit pas d'une valeur nulle.
const Plan sansPlafond = planifier(false, false);
QCOMPARE(sansPlafond.draw.capBorne, false);
// 2. AVEC plafond, hors réserve : l'autorisation se lit, rien n'est engagé, tout reste.
const Plan nominal = planifier(false, true);
QCOMPARE(nominal.draw.capBorne, true);
QCOMPARE(qRound(nominal.draw.authorisedW), 3450);
QCOMPARE(qRound(nominal.draw.committedW), 0);
QCOMPARE(qRound(nominal.draw.remainingW), 3450);
QCOMPARE(nominal.draw.bindingCode, QString("connection"));
QCOMPARE(nominal.draw.perPhaseBound, true);
// L'identité `authorisedW − committedW == remainingW` N'EST PAS ASSERTÉE ICI, et c'est
// délibéré : le moteur l'établit sur UNE LIGNE (`rulebasedscheduler.cpp`, `draw.remainingW =
// draw.authorisedW - draw.committedW`). L'asserter reviendrait à relire cette affectation —
// les deux membres viennent de la même source, l'identité se referme sur elle-même, et le
// contrôle n'attraperait rien.
//
// Ce qui contrôle réellement, ce sont les TROIS attentes littérales ci-dessus : elles
// viennent du scénario, pas du moteur. Relevé au balayage du 2026-09-03.
//
// ⚠️ À ne pas confondre avec l'identité du BUDGET (`surplus + recrédit − alloué ==
// restant`), qui, elle, croise DEUX calculs indépendants — `remainingSurplusW` filé le long
// de la cascade contre `allocatedW` accumulé en trois sites distincts. Celle-là attrape un
// site qui décrémenterait sans accumuler, et mérite d'être assertée.
// 3. SOUS LA RÉSERVE — le cœur du test. Le surplus est ANNULÉ, l'autorisation INTACTE.
const Plan sousReserve = planifier(true, true);
QCOMPARE(qRound(sousReserve.budget.remainingW), 0); // la réserve annule le surplus…
QCOMPARE(qRound(sousReserve.draw.remainingW), 3450); // …et ne touche pas au soutirage
QCOMPARE(sousReserve.draw.bindingCode, QString("connection"));
// 4. Et un plafond TOTAL se distingue d'un plafond par phase. Sans cette distinction, un
// installateur devant une triphasée irait mesurer une phase que rien ne désigne.
SurplusContext ctxTotal;
ctxTotal.timestamp = t0;
ctxTotal.meter.exportW = 5000;
ctxTotal.drawMargin.borne = true;
ctxTotal.drawMargin.watts = 2000;
ctxTotal.drawMargin.source = DrawCapSource::GridOperator;
ctxTotal.drawMargin.phase = -1; // total : aucune phase coupable
const Plan total = sched.getPlan(ctxTotal);
QCOMPARE(total.draw.perPhaseBound, false);
QCOMPARE(total.draw.bindingCode, QString("gridOperator"));
#endif
}
/*!
* \brief [§10 étape C] L'avancement est une DIFFÉRENCE depuis une base, et le régime se publie.
*
* \par Règles couvertes
* \rule{LM-1012-b} (l'avancement est une différence), \rule{LM-1012-b-i} (base à l'ouverture de
* l'obligation), \rule{LM-1012-b-ii} (recapture sur recul franc), \rule{LM-1014-c} (le cumul
* survit au changement de RÉGIME), \rule{LM-1014-c-i} (remise à zéro à la période),
* \rule{LM-1014-c-ii} (\c measuredSince garde le premier instant).
*
* \par Le piège, mesuré sur le banc le 2026-08-31
* `sessionEnergy` est l'ACCUMULATEUR du plugin V2C, pas un compteur remis à zéro pour nous :
* l'écart avec `chargeEnergy` est constant au dix-millième (7,2044 kWh sur deux échantillons à
* deux heures d'écart), parce qu'il porte les incréments d'avant la dernière remise à zéro de la
* borne. **Le lire directement ferait croire toute obligation tenue de 7,2 kWh dès le premier
* cycle** — un nombre crédible et faux que rien ne signalerait.
*
* \par Règle couverte
* \rule{LM-1012-c} — le RÉGIME d'avancement se publie, porté par le niveau, et
* \c deliveredWh est absent sous \c unmeasurable plutôt que nul.
*
* \par Les deux questions que la base pose, et leurs réponses
* 1. **Quand est-elle prise ?** À l'ouverture de l'OBLIGATION, jamais au démarrage de nymead :
* sinon un redémarrage en pleine session réinitialise l'avancement sur une valeur déjà
* accumulée, et la recharge de nuit relue au matin repart de zéro.
* 2. **Que devient-elle si `sessionWh` recule ?** Le plugin remet son accumulateur à la valeur
* brute à chaque nouvelle session : la base est RECAPTURÉE, sinon la différence devient
* négative. On ne refait pas la détection de remise à zéro de la BORNE — elle vit dans le
* plugin, à 30 s, là où le moteur travaille à la minute et raterait des cycles entiers.
*
* \warning **Ce test devient faux** si `sessionEnergy` cesse d'être un accumulateur de plugin
* pour devenir un compteur remis à zéro par session : la recapture serait alors sans objet, et
* c'est l'assertion 4 qu'il faudrait retirer, pas le moteur.
*/
void Simulation::testEcoProgressBaseAndRegime()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
const QDateTime t0 = utcDateTime(QDate(2026, 8, 31), QTime(13, 0, 0));
LoadContext lc;
lc.id = "borne-eco";
lc.needs.minEnergyWhPerDay = 10000; // obligation : 10 kWh par jour
// 1. SANS obligation déclarée, il n'y a rien à publier — pas un avancement à zéro. Une
// charge sans obligation éco ne porte pas d'entrée « eco » : l'absence EST l'information.
LoadContext sansObligation = lc;
sansObligation.needs.minEnergyWhPerDay = 0;
QVERIFY2(arb->ecoProgress(sansObligation, t0).regime.isEmpty(),
"sans obligation, aucun régime : le niveau éco n'existe pas");
// 2. Borne qui ne COMPTE PAS : régime `unmeasurable`, et `deliveredWh` SANS OBJET.
// C1 de LM-1009 — une dégradation ne doit jamais se lire comme un avancement nul, sinon
// l'échéance continue de s'afficher comme si elle tenait.
LoadContext nonMesurable = lc;
nonMesurable.telemetry.sessionMeasurable = false;
nonMesurable.telemetry.sessionWh = 7204; // valeur présente mais SANS SIGNIFICATION
const EcoProgress sansMesure = arb->ecoProgress(nonMesurable, t0);
QCOMPARE(sansMesure.regime, QString(ProgressRegime::Unmeasurable));
QCOMPARE(sansMesure.delivered, false);
QVERIFY2(qFuzzyIsNull(sansMesure.deliveredWh),
"sous unmeasurable, deliveredWh n'est pas publié — et vaut donc 0 en interne");
// 3. LE PIÈGE LUI-MÊME. L'accumulateur vaut déjà 7204 Wh à l'ouverture de l'obligation.
// L'avancement doit être NUL, pas 7204 : la base est prise ICI.
LoadContext mesuree = lc;
mesuree.telemetry.sessionMeasurable = true;
mesuree.telemetry.sessionWh = 7204;
const EcoProgress ouverture = arb->ecoProgress(mesuree, t0);
QCOMPARE(ouverture.regime, QString(ProgressRegime::Measured));
QCOMPARE(ouverture.delivered, true);
QCOMPARE(qRound(ouverture.deliveredWh), 0);
QCOMPARE(qRound(ouverture.targetWh), 10000);
// …et l'avancement suit la DIFFÉRENCE, pas la valeur lue.
mesuree.telemetry.sessionWh = 9204;
QCOMPARE(qRound(arb->ecoProgress(mesuree, t0).deliveredWh), 2000);
// 4. RECUL FRANC : le plugin a remis son accumulateur à la valeur brute d'une nouvelle
// session. La base est recapturée — sans quoi la différence serait NÉGATIVE.
mesuree.telemetry.sessionWh = 120;
QCOMPARE(qRound(arb->ecoProgress(mesuree, t0).deliveredWh), 0);
mesuree.telemetry.sessionWh = 620;
QCOMPARE(qRound(arb->ecoProgress(mesuree, t0).deliveredWh), 500);
// 5. Recul INFIME : c'est du bruit de lecture, pas une nouvelle session. La base ne bouge
// pas — la recapturer ici perdrait l'avancement acquis à chaque frémissement du compteur.
mesuree.telemetry.sessionWh = 610;
const EcoProgress bruit = arb->ecoProgress(mesuree, t0);
QCOMPARE(qRound(bruit.deliveredWh), 490);
QVERIFY2(bruit.deliveredWh >= 0, "un avancement ne peut pas être négatif");
// 7. BASCULE DE PÉRIODE — le cas que la sonde a révélé, et qu'aucune lecture du code
// n'aurait montré. À l'instant de l'échéance quotidienne, une NOUVELLE période s'ouvre.
// Sans reprise de la base ici, le lendemain hériterait de l'énergie de la veille et se
// croirait tenu sans avoir rien reçu.
LoadContext quotidienne;
quotidienne.id = "borne-quotidienne";
quotidienne.needs.minEnergyWhPerDay = 2000;
quotidienne.needs.dailyDeadline = "07:00";
quotidienne.telemetry.sessionMeasurable = true;
const QDateTime veille = utcDateTime(QDate(2026, 8, 31), QTime(6, 0, 0)); // vise 07:00 J
const QDateTime apres = utcDateTime(QDate(2026, 8, 31), QTime(7, 30, 0)); // vise 07:00 J+1
quotidienne.telemetry.sessionWh = 1000;
QCOMPARE(qRound(arb->ecoProgress(quotidienne, veille).deliveredWh), 0); // base prise ici
quotidienne.telemetry.sessionWh = 2800;
QCOMPARE(qRound(arb->ecoProgress(quotidienne, veille).deliveredWh), 1800);
// L'échéance a basculé : la période est neuve, l'avancement repart de zéro — l'énergie de
// la veille n'est pas portée au crédit du jour suivant.
QCOMPARE(qRound(arb->ecoProgress(quotidienne, apres).deliveredWh), 0);
quotidienne.telemetry.sessionWh = 3300;
QCOMPARE(qRound(arb->ecoProgress(quotidienne, apres).deliveredWh), 500);
// 8. BASCULE DE RÉGIME DANS LA MÊME PÉRIODE — measured → unmeasurable → measured.
// C'est le motif d'une V2C dont le lien Modbus bat : chaque perte et retour fait
// basculer le régime DEUX fois. L'énergie livrée pendant les tronçons mesurés
// antérieurs ne doit pas disparaître — sinon l'écran sous-estime, et surtout la passe
// éco RACHÈTE ce qui a déjà été livré.
LoadContext bat;
bat.id = "borne-intermittente";
bat.needs.minEnergyWhPerDay = 4000;
bat.needs.dailyDeadline = "07:00";
bat.telemetry.sessionMeasurable = true;
bat.telemetry.sessionWh = 1000;
QCOMPARE(qRound(arb->ecoProgress(bat, t0).deliveredWh), 0); // base prise ici
bat.telemetry.sessionWh = 2500;
QCOMPARE(qRound(arb->ecoProgress(bat, t0).deliveredWh), 1500); // 1500 Wh livrés
// Le lien tombe : plus rien ne mesure.
bat.telemetry.sessionMeasurable = false;
QCOMPARE(arb->ecoProgress(bat, t0).regime, QString(ProgressRegime::Unmeasurable));
// Le lien revient, dans la MÊME période. Les 1500 Wh déjà livrés doivent SURVIVRE.
bat.telemetry.sessionMeasurable = true;
bat.telemetry.sessionWh = 2500;
QCOMPARE(qRound(arb->ecoProgress(bat, t0).deliveredWh), 1500);
bat.telemetry.sessionWh = 3000;
const EcoProgress reprise = arb->ecoProgress(bat, t0);
QCOMPARE(qRound(reprise.deliveredWh), 2000); // 1500 (tronçon clos) + 500 (en cours)
// TRANCHÉ — `measuredSince` garde le PREMIER instant mesuré, et l'intermittence se dit à
// part. Redater à chaque retour effacerait l'histoire au lieu de la raconter ; ne rien dire
// ferait croire à une continuité que le battement dément. L'écran peut alors écrire
// « mesuré PAR INTERMITTENCE depuis 09:14 », qui est la phrase vraie.
QCOMPARE(reprise.measuredSince, t0);
QCOMPARE(reprise.measuredGaps, 1);
// TRANCHÉ — à la BASCULE DE PÉRIODE, le cumul repart de zéro comme tout ce qui est rapporté
// à la période (LM-1014-b), et les trous avec lui : ils comptaient les interruptions de la
// période close, pas de la neuve.
// Au-delà de l'échéance visée depuis t0 (2026-08-31 13:00 → 2026-09-01 07:00) : c'est ce
// franchissement qui ouvre une période neuve. Un instant qui viserait la MÊME échéance ne
// ferait rien basculer — première version de ce test, qui échouait pour cette raison.
const QDateTime apresEcheance = utcDateTime(QDate(2026, 9, 1), QTime(8, 0, 0));
const EcoProgress neuve = arb->ecoProgress(bat, apresEcheance);
QCOMPARE(qRound(neuve.deliveredWh), 0);
QCOMPARE(neuve.measuredGaps, 0);
// 6. L'obligation retirée LIBÈRE la base : une nouvelle obligation repart d'un repère neuf,
// et n'hérite pas de l'avancement d'une obligation qui n'existe plus.
LoadContext retiree = mesuree;
retiree.needs.minEnergyWhPerDay = 0;
arb->ecoProgress(retiree, t0);
mesuree.telemetry.sessionWh = 5000;
QCOMPARE(qRound(arb->ecoProgress(mesuree, t0).deliveredWh), 0);
#endif
}
/*!
* \brief [§10 étape D] La passe ÉCO achète, et le motif dit la TRAJECTOIRE.
*
* \par Règles couvertes
* \rule{LM-1002} (deux passes sur la même liste ordonnée), \rule{LM-1007} (une charge sans
* obligation ne porte pas d'entrée « eco » — l'absence EST l'information), \rule{LM-1013-b}
* (l'étalement uniforme), \rule{LM-1210} (l'autorisation borne la cascade).
*
* \par Deux passes, une commande
* Même liste, même ordre ; ce qui change est la BORNE — l'autorisation de soutirage pour l'éco,
* le reliquat de surplus pour le confort. Les deux niveaux sont publiés, mais **une seule
* commande** part au matériel (\rule{LM-1003}) : émettre deux fois ferait commuter un relais en
* passe 1 puis le recommuter en passe 2, du court-cycling fabriqué par l'arbitre lui-même.
*
* \par Ce que le motif doit permettre, et pourquoi
* `ECO_FLOOR_GRID` est lu le matin où la facture monte. Les watts de l'instant n'y suffisent
* pas : la cadence fait un mur — 333 W à six heures de l'échéance, le plafond dès seize minutes.
* Un client qui lit « 1000 W achetés » ne peut pas deviner ce qu'il paiera dans deux heures ;
* avec le RESTE à livrer et le TEMPS restant, il le peut.
*/
void Simulation::testEcoPassBuysAndSaysTheTrajectory()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
RuleBasedScheduler sched(arb);
auto planifier = [&](const QDateTime &at, bool mesurable, double livreWh,
double margeW) -> LoadAction {
SurplusContext ctx;
ctx.timestamp = at;
ctx.meter.importW = 500; // la maison ACHÈTE : aucun surplus
ctx.drawMargin.borne = true;
ctx.drawMargin.watts = margeW;
ctx.drawMargin.source = DrawCapSource::Breaker;
ctx.drawMargin.phase = 0;
LoadContext ecs;
ecs.id = QStringLiteral("ecs-%1-%2").arg(mesurable).arg(qRound(margeW));
ecs.label = "ECS";
ecs.adapter = "etmvariableload";
ecs.priority = 1;
ecs.declared.maxPowerW = 3000;
ecs.needs.minEnergyWhPerDay = 2000;
ecs.needs.dailyDeadline = "07:00";
ecs.telemetry.sessionMeasurable = mesurable;
ecs.telemetry.sessionWh = livreWh;
ctx.loads.append(ecs);
const Plan plan = sched.getPlan(ctx);
return plan.timeSlots.first().actions.first();
};
const QDateTime deuxHeuresAvant = utcDateTime(QDate(2026, 8, 31), QTime(5, 0, 0));
// 1. DEUX NIVEAUX, et l'éco d'abord — l'ordre des passes, pas celui des rangs.
const LoadAction a = planifier(deuxHeuresAvant, true, 0, 5000);
QCOMPARE(a.levels.count(), 2);
QCOMPARE(a.levels.at(0).level, QString("eco"));
QCOMPARE(a.levels.at(1).level, QString("comfort"));
// 2. UNE SEULE COMMANDE, dont la puissance est la SOMME des niveaux. C'est l'invariant qui
// rend la charge utile vérifiable — et LM-1003 qui rend le matériel sûr.
QCOMPARE(qRound(a.estimatedPowerW), 1000); // 2 kWh en 2 h
double sommeNiveaux = 0;
for (const LoadAction::Level &n : a.levels)
sommeNiveaux += n.targetW;
QCOMPARE(qRound(sommeNiveaux), qRound(a.estimatedPowerW));
// 3. LE FINANCEMENT VIT DANS LE NIVEAU (R2). L'éco est acheté, le confort ne l'est pas —
// un `funding` par CHARGE serait indécidable ici, et c'est tout l'objet du changement.
QCOMPARE(a.levels.at(0).funding, LoadAction::Grid);
QCOMPARE(a.levels.at(1).funding, LoadAction::Surplus);
QCOMPARE(qRound(a.counts.value(CountKey::DrawCommitted)), 1000);
// 4. LA TRAJECTOIRE, et pas seulement l'instant. Chacun de ces champs répond à une question
// qu'un client se posera devant sa facture : combien, pour quoi, et jusqu'à quand.
const QVariantMap p = a.levels.at(0).reason.params;
QCOMPARE(a.levels.at(0).reason.code, QString(DecisionCode::EcoFloorGrid));
QCOMPARE(p.value("gridW").toInt(), 1000);
QCOMPARE(p.value("remainingWh").toInt(), 2000);
QCOMPARE(p.value("targetWh").toInt(), 2000);
QCOMPARE(p.value("remainingS").toInt(), 7200);
QCOMPARE(p.value("regime").toString(), QString("measured"));
QCOMPARE(p.value("atMaximum").toBool(), false);
// …et le TEXTE le dit, sans quoi le motif ne serait lisible que par un programme.
const QString texte = renderFr(a.levels.at(0).reason, "ECS");
QVERIFY2(texte.contains("2000 Wh"), qUtf8Printable("motif: " + texte));
QVERIFY2(texte.contains("2 h"), qUtf8Printable("motif: " + texte));
// 5. L'AUTORISATION DE SOUTIRAGE BORNE L'ÉCO. Le plancher éco est une raison d'acheter, pas
// un droit de faire disjoncter : marge à 400 W, on n'achète que 400.
const LoadAction borne = planifier(deuxHeuresAvant, true, 0, 400);
QCOMPARE(qRound(borne.levels.at(0).targetW), 400);
// 6. R6 — SOUS `unmeasurable`, JAMAIS « tenue ». Même avec 3 kWh au compteur de session, on
// ne sait pas ce qui a été livré POUR CETTE OBLIGATION : conclure serait affirmer sur ce
// qu'on ignore, et une échéance qui s'affiche comme tenue sans l'être rassure à tort.
const LoadAction inconnu = planifier(deuxHeuresAvant, false, 3000, 5000);
QCOMPARE(inconnu.levels.at(0).reason.code, QString(DecisionCode::EcoFloorGrid));
QVERIFY2(inconnu.levels.at(0).reason.code != QString(DecisionCode::EcoFloorMet),
"ECO_FLOOR_MET est interdit sous un régime non mesuré (R6)");
QCOMPARE(inconnu.levels.at(0).progress.regime, QString(ProgressRegime::Unmeasurable));
QVERIFY2(!inconnu.levels.at(0).progress.delivered,
"sous unmeasurable, deliveredWh n'a pas de sens et ne sera pas publié");
// 7. SOUS `unmeasurable`, `remainingWh` VAUT LA CIBLE INTACTE. Rien n'est soustrait, parce
// qu'on ne sait rien de ce qui a été livré. Soustraire une intégration interne ferait
// franchir la frontière à une ESTIMATION sous l'allure d'un fait — le chemin exact de
// `carBatteryLevel`, refusé en LM-1009 D3.
//
// Et que `regime` voyage dans les mêmes params ne suffirait pas : un client qui lit
// `remainingWh` sans regarder `regime` obtiendrait un nombre calculé, sans rien pour le
// lui dire. « 2000 Wh restants sur 2000 » doit signifier « rien de mesurable », pas
// « rien livré » — et l'écran ne peut trancher que si le contrat le dit.
const QVariantMap pu = inconnu.levels.at(0).reason.params;
QCOMPARE(pu.value("remainingWh").toInt(), pu.value("targetWh").toInt());
QCOMPARE(pu.value("remainingWh").toInt(), 2000);
// 8. IDENTITÉ SOUS `measured` — troisième contrôle vérifiable, au même titre que
// Σ counts == targetW et authorisedW − committedW == remainingW. C'est ce qui permet à
// un client de constater la cohérence au lieu de la supposer.
const LoadAction partiel = planifier(deuxHeuresAvant, true, 800, 5000);
const LoadAction suite = planifier(deuxHeuresAvant, true, 1400, 5000);
for (const LoadAction &act : {partiel, suite}) {
const QVariantMap pm = act.levels.at(0).reason.params;
QVERIFY2(act.levels.at(0).progress.delivered, "régime mesuré attendu");
QCOMPARE(qRound(act.levels.at(0).progress.deliveredWh) + pm.value("remainingWh").toInt(),
pm.value("targetWh").toInt());
}
#endif
}
/*!
* \brief [§10] Une obligation abandonnée est un FAIT produit — et un fait produit se dit.
*
* \par Ce motif n'existait pas le matin même
* Il n'avait pas d'ÉMETTEUR : le créer alors aurait posé un invariant sur un code que rien ne
* produit — un test toujours vert, donc un test qui ne protège rien. La bascule de période
* (\rule{LM-1013-c}) l'a fait naître. Le journal disait déjà l'abandon ; l'écran, non.
*
* \par Trois causes, trois gestes
* `drawCap` envoie regarder l'abonnement ou les autres consommateurs ; `loadCapacity` envoie
* lire la fiche technique de la charge ; `unmeasurable` envoie regarder ce que la borne publie.
* Sans la cause, l'écran n'affiche qu'un reproche sans recours.
*
* \warning **Ce test devient faux** si l'obligation cesse d'être bornée par l'autorisation de
* soutirage — alors `drawCap` ne serait plus une cause possible, et c'est le cas 1 qu'il
* faudrait réécrire, pas le moteur.
*/
void Simulation::testEcoFloorMissedSaysWhatWasLostAndWhy()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
RuleBasedScheduler sched(arb);
auto cycle = [&](const QString &id, const QDateTime &at, bool mesurable,
double livreWh, double margeW) -> LoadAction {
SurplusContext ctx;
ctx.timestamp = at;
ctx.meter.importW = 500;
ctx.drawMargin.borne = true;
ctx.drawMargin.watts = margeW;
ctx.drawMargin.source = DrawCapSource::Breaker;
ctx.drawMargin.phase = 0;
LoadContext ecs;
ecs.id = id;
ecs.label = "ECS";
ecs.adapter = "etmvariableload";
ecs.priority = 1;
ecs.declared.maxPowerW = 3000;
ecs.needs.minEnergyWhPerDay = 2000;
ecs.needs.dailyDeadline = "07:00";
ecs.telemetry.sessionMeasurable = mesurable;
ecs.telemetry.sessionWh = livreWh;
ctx.loads.append(ecs);
return sched.getPlan(ctx).timeSlots.first().actions.first();
};
const QDateTime avant = utcDateTime(QDate(2026, 8, 31), QTime(5, 0, 0));
const QDateTime apres = utcDateTime(QDate(2026, 8, 31), QTime(7, 30, 0));
// --- Cas 1 : MESURÉ, bridé par l'autorisation de soutirage ------------------------------
// Marge à 200 W alors que la cadence en demande 1000 : on note `drawCap`, et à la bascule
// c'est cette cause-là qui est publiée.
cycle("ecs-cap", avant, true, 0, 200);
cycle("ecs-cap", avant, true, 300, 200); // 300 Wh livrés sur 2000
const LoadAction close = cycle("ecs-cap", apres, true, 300, 200);
QCOMPARE(close.levels.at(0).reason.code, QString(DecisionCode::EcoFloorMissed));
const QVariantMap pc = close.levels.at(0).reason.params;
QCOMPARE(pc.value("cause").toString(), QString(MissedCause::DrawCap));
QCOMPARE(pc.value("missingWh").toInt(), 1700); // 2000 dus, 300 livrés
QCOMPARE(pc.value("targetWh").toInt(), 2000);
// Le TEXTE porte le manque ET le geste. « Non tenue » seul serait un reproche sans recours.
const QString tc = renderFr(close.levels.at(0).reason, "ECS");
QVERIFY2(tc.contains("1700"), qUtf8Printable("motif: " + tc));
QVERIFY2(tc.contains("soutirage"), qUtf8Printable("motif: " + tc));
// --- Cas 2 : NON MESURÉ -----------------------------------------------------------------
cycle("ecs-inc", avant, false, 0, 5000);
const LoadAction closeInc = cycle("ecs-inc", apres, false, 0, 5000);
QCOMPARE(closeInc.levels.at(0).reason.code, QString(DecisionCode::EcoFloorMissed));
const QVariantMap pi = closeInc.levels.at(0).reason.params;
QCOMPARE(pi.value("cause").toString(), QString(MissedCause::Unmeasurable));
// `missingWh` ABSENT, jamais 0. On ne sait pas ce qui a été livré, donc pas davantage ce
// qui manquait : un zéro se lirait « il ne manquait rien », là où la vérité est « on n'en
// sait rien ». Publier un chiffre inventerait la grandeur dont l'absence EST le problème.
QVERIFY2(!pi.contains("missingWh"),
"sous unmeasurable, le manque n'est pas chiffrable — pas de zéro forgé");
const QString ti = renderFr(closeInc.levels.at(0).reason, "ECS");
QVERIFY2(ti.contains("pas chiffrable"), qUtf8Printable("motif: " + ti));
// --- Cas 3 : obligation TENUE — aucune clôture manquée ----------------------------------
cycle("ecs-ok", avant, true, 0, 5000);
const LoadAction closeOk = cycle("ecs-ok", apres, true, 2500, 5000);
QVERIFY2(closeOk.levels.at(0).reason.code != QString(DecisionCode::EcoFloorMissed),
"une obligation tenue ne produit pas de motif de non-tenue");
#endif
}
/*!
* \brief [Commande perdue, étape 2] L'écart se PUBLIE — il ne se diagnostique pas.
*
* \par Ce que l'écart vaut dépend de la SOURCE, dans les deux sens
* | | concordance | divergence |
* |---|---|---|
* | `device` | **ne prouve rien** — plusieurs bornes renvoient leur consigne en guise de puissance | la charge n'obéit pas, et elle le dit elle-même |
* | `meter` | **preuve** — un tiers le constate | trois hypothèses : la charge, le compteur, le câblage |
*
* Ce test le DÉMONTRE au lieu de l'énoncer : la borne du mock dérive sa puissance de sa propre
* consigne, si bien que `expectedW == observedW` **par construction**. Une concordance obtenue
* ainsi n'est pas une preuve d'obéissance — c'est un écho.
*
* \par Un écart n'est pas un défaut
* Une borne qui n'obéit pas parce que le véhicule limite, parce que le câble est en 16 A, ou
* parce qu'elle est en mode manuel, produit le **même nombre** qu'une commande perdue. Le champ
* ne porte donc aucune cause : la cause n'est pas dans les watts.
*/
void Simulation::testCommandDivergenceIsPublishedNotDiagnosed()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-div.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", -8000); // 8 kW exportés
QUuid borneId = addCharger("A", 16, 26698);
Thing *borne = tm->findConfiguredThing(borneId);
QVERIFY(borne);
armerBorne(borne);
QVariantMap info;
info.insert("evChargerId", borneId);
info.insert("chargingMode", "ChargingModeEco");
injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
const QDateTime t0 = utcDateTime(QDate(2026, 9, 1), QTime(13, 0, 0));
arb->simulationCallUpdate(t0);
QCoreApplication::processEvents();
QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
const QVariantMap b = tele.value(borneId.toString());
// 1. SOUS `device`, LA CONCORDANCE NE PROUVE RIEN — et c'est mesurable ici même : la borne
// du mock dérive `currentPower` de sa propre consigne, donc les deux nombres coïncident
// PAR CONSTRUCTION. Le champ dit ce qu'il voit ; il ne dit pas que la borne obéit.
QCOMPARE(b.value("measurement").toMap().value("source").toString(), QString("device"));
const QVariantMap cmd = b.value("command").toMap();
QVERIFY2(!cmd.isEmpty(), "sous une source mesurante, l'écart doit être publié");
QCOMPARE(qRound(cmd.value("expectedW").toDouble()), qRound(cmd.value("observedW").toDouble()));
QCOMPARE(cmd.value("divergent").toBool(), false);
QVERIFY2(!cmd.contains("since"), "pas de divergence, donc pas de date de début");
// 2. DIVERGENCE — la borne annonce autre chose que ce qu'on lui a commandé. On publie les
// deux nombres, le verdict, et DEPUIS QUAND : sans la date, une divergence permanente ne
// se distingue pas d'un déphasage d'un cycle entre l'écriture et sa mesure, ce qui est
// toute la différence entre le normal et le cas relevé le 2026-08-31.
// On passe par un COMPTEUR EXPLICITE, et pas par la mesure de la borne : le mock recalcule
// `currentPower` depuis la consigne à chaque recommande, si bien qu'une divergence injectée
// là serait effacée avant d'être publiée — ce qui EST la démonstration du point 1, mais
// interdit d'exercer le point 2 par ce chemin. Sous `meter`, un tiers observe, et c'est
// aussi le seul régime où la divergence a valeur de preuve.
QUuid compteurBorne = addMeter(26699);
Thing *cb = tm->findConfiguredThing(compteurBorne);
QVERIFY(cb);
cb->setStateValue("connected", true);
cb->setStateValue("currentPower", 800); // le compteur voit 800 W…
QVariantList cfgs = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
.value("params").toMap().value("loadConfigs").toList();
QVariantList majs;
for (const QVariant &v : cfgs) {
QVariantMap m = v.toMap();
if (m.value("id").toString() == borneId.toString())
m.insert("meterThingId", compteurBorne.toString());
majs.append(m);
}
injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", majs}}));
arb->simulationCallUpdate(t0.addSecs(60));
QCoreApplication::processEvents();
const QVariantMap b2 = telemetrieParId(arb->loadTelemetry()).value(borneId.toString());
QCOMPARE(b2.value("measurement").toMap().value("source").toString(), QString("meter"));
const QVariantMap cmd2 = b2.value("command").toMap();
QCOMPARE(cmd2.value("divergent").toBool(), true);
QCOMPARE(qRound(cmd2.value("observedW").toDouble()), 800);
QVERIFY2(cmd2.contains("since"), "une divergence doit dire depuis quand elle dure");
// 3. Et le champ NE QUALIFIE PAS. Aucun code de cause, aucun défaut : véhicule qui limite,
// câble en 16 A ou commande perdue produisent le même nombre. Le publier comme un défaut
// ferait accuser une installation saine.
QVERIFY2(!cmd2.contains("cause") && !cmd2.contains("faultCode"),
"l'écart se publie, il ne se diagnostique pas — la cause n'est pas dans les watts");
// 4. SANS SOURCE MESURANTE, l'objet est ABSENT — pas `divergent: false`, qui affirmerait une
// concordance qu'on ne peut pas constater. Une charge de configuration sans compteur
// désigné ni état de puissance est exactement ce cas.
// Une charge SANS mesure est ajoutée exprès : sans elle, la boucle ci-dessous ne trouverait
// aucun cas et l'assertion serait VACANTE — elle passerait sans rien vérifier. C'est le
// piège de la contre-épreuve inerte, et il a été constaté ici même : la garde retirée, le
// test passait toujours.
// Le relais est VOLONTAIREMENT inexistant : toutes les classes du mock portent un état de
// puissance, donc aucune charge normale ne produit `source: none`. Une charge dont le
// câblage manque, si — et c'est un cas réel, pas un artifice (ECS-111).
const QString relaisSansMesure = "{deadbeef-0000-4000-8000-00000000c0de}";
LoadConfigStore *store = new LoadConfigStore(arb);
arb->setLoadConfigStore(store);
LoadConfigs sans;
sans.append(LoadConfig::fromMap(QVariantMap{
{"id", "ecs-sans-mesure"}, {"label", "ECS"}, {"adapter", "relay-router"},
{"mode", "fixed"}, {"priority", 5}, {"enabled", true}, {"domain", "ecs"},
{"relays", QVariantList() << QVariantMap{{"thingId", relaisSansMesure},
{"powerW", 1000}}}}));
QString err;
QVERIFY2(store->setConfigs(sans, &err), err.toUtf8());
arb->simulationCallUpdate(t0.addSecs(120));
QCoreApplication::processEvents();
int examinees = 0;
for (const QVariantMap &e : telemetrieParId(arb->loadTelemetry())) {
if (e.value("measurement").toMap().value("source").toString() != QLatin1String("none"))
continue;
++examinees;
QVERIFY2(!e.contains("command"),
"rien ne mesure : il n'y a rien à comparer, et l'objet doit être absent");
}
QVERIFY2(examinees > 0,
"aucune charge sans mesure dans ce cycle : l'assertion aurait été vacante");
#endif
}
/*!
* \brief [Commande perdue, étape 3] Le recrédit est un PRÊT consenti contre l'obéissance.
*
* \par Ce que la mesure a corrigé dans le diagnostic
* Le constat de l'app disait « un budget de surplus qui ignore ces watts ». Vérification faite,
* **ce n'est pas ce qui se passe** : quand une charge consomme et obéit, la comptabilité est
* juste — sa conso est recréditée puis réallouée à elle-même, et les suivantes ne reçoivent que
* le surplus réel. Corriger cela aurait été corriger un non-défaut.
*
* \par Le vrai défaut, et il est plus grave
* Le recrédit anti-clignotement repose sur une hypothèse écrite dans le code : *« le budget est
* une mesure dont la conso de la charge a déjà été retranchée »* — autrement dit **« je peux
* rendre ces watts parce que je peux les reprendre »**. C'est un **prêt consenti contre
* l'obéissance**, et rien ne vérifie que l'emprunteur rembourse.
*
* Ce test épingle le cas où la promesse est engagée : A est commandée à **0** et ses 4 400 W
* sont redistribués. Si A obéit, tout est juste. **Si A n'obéit pas** — commande perdue, mode
* manuel, véhicule qui limite — la maison tire 8 400 W pour 4 900 W de production, soit
* **3 500 W importés que personne n'a décidés**.
*
* \warning **Ce test épingle le comportement ACTUEL, qui est correct SOUS HYPOTHÈSE.** Il
* devient faux le jour où le recrédit sera conditionné à l'obéissance constatée
* (`command.divergent`) — et ce jour-là, c'est l'allocation de B qu'il faudra revoir, pas le
* test. La distinction est écrite ici pour qu'on ne le « corrige » pas par réflexe.
*/
void Simulation::testRecreditIsALoanAgainstObedience()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
RuleBasedScheduler sched(arb);
SurplusContext ctx;
ctx.timestamp = utcDateTime(QDate(2026, 9, 1), QTime(13, 0, 0));
ctx.meter.exportW = 500; // 500 W exportés SEULEMENT
LoadContext a;
a.id = "A-coupee"; a.label = "A"; a.adapter = "etmvariableload"; a.priority = 1;
a.declared.maxPowerW = 6000;
a.declared.minPowerW = 5000; // plancher AU-DESSUS du budget : A est coupée
a.telemetry.currentPowerW = 4400; // …mais elle consomme 4 400 W à cet instant
ctx.loads.append(a);
LoadContext b;
b.id = "B-suivante"; b.label = "B"; b.adapter = "etmvariableload"; b.priority = 2;
b.declared.maxPowerW = 4000;
ctx.loads.append(b);
const Plan plan = sched.getPlan(ctx);
QHash<QString, LoadAction> parId;
for (const LoadAction &act : plan.timeSlots.first().actions)
parId.insert(act.loadId, act);
// 1. A est coupée — son plancher n'est pas payable, et le budget PASSE à la suivante.
QCOMPARE(qRound(parId.value("A-coupee").estimatedPowerW), 0);
QCOMPARE(parId.value("A-coupee").reason.code, QString(DecisionCode::BelowMinPower));
// 2. B reçoit 4 000 W — c'est-à-dire la conso de A, PRÊTÉE d'avance sur sa coupure.
QCOMPARE(qRound(parId.value("B-suivante").estimatedPowerW), 4000);
// 3. LE PRÊT EST VISIBLE DANS LA COMPTABILITÉ, et c'est ce qui le rend vérifiable : 4 000 W
// alloués pour 500 W de surplus réel. L'écart est exactement le recrédit.
QCOMPARE(qRound(plan.budget.surplusW), 500);
QCOMPARE(qRound(plan.budget.recreditedW), 4400);
// 5. LE PRÊT EST REFUSÉ quand le refus est ÉTABLI. Même scénario, mais la charge A ne suit
// pas la commande depuis plus longtemps que la latence de son mécanisme : ses watts ne
// sont plus rendus au budget, et B ne reçoit donc plus ce qu'on ne peut pas reprendre.
ctx.loads[0].telemetry.commandRefused = true;
ctx.loads[0].telemetry.refusedSinceS = 120;
const Plan refus = sched.getPlan(ctx);
QHash<QString, LoadAction> parIdR;
for (const LoadAction &act : refus.timeSlots.first().actions)
parIdR.insert(act.loadId, act);
QCOMPARE(qRound(refus.budget.recreditedW), 0); // on ne prête plus
// B ne reçoit QUE le surplus réel : 500 W, et non les 4 000 W qu'on ne peut pas reprendre.
QCOMPARE(qRound(parIdR.value("B-suivante").estimatedPowerW), 500);
// 6. ET ÇA SE DIT. Une suspension change l'allocation de TOUTES les suivantes ; si personne
// ne sait pourquoi B reçoit moins, on chercherait un défaut de cascade là où il y a une
// charge qui n'obéit pas. Le motif porte la suspension ET sa cause chiffrée.
QCOMPARE(parIdR.value("A-coupee").reason.code, QString(DecisionCode::RecreditSuspended));
QCOMPARE(parIdR.value("A-coupee").reason.params.value("refusedSinceS").toInt(), 120);
QCOMPARE(parIdR.value("A-coupee").reason.params.value("withheldW").toInt(), 4400);
const QString txt = renderFr(parIdR.value("A-coupee").reason, "A");
QVERIFY2(txt.contains("4400"), qUtf8Printable("motif: " + txt));
QVERIFY2(txt.contains("reprendre"), qUtf8Printable("motif: " + txt));
// 7. UN ÉCART RÉCENT N'EST PAS UN REFUS. La latence du mécanisme protège une charge saine
// qui n'a pas encore atteint sa consigne — suspendre là ferait osciller ce qui va bien.
// C'est `qualifierRefus()` qui tranche, sur le seuil du mécanisme et non une constante.
ctx.loads[0].telemetry.commandRefused = false; // écart présent mais RÉCENT
ctx.loads[0].telemetry.refusedSinceS = 3;
const Plan latence = sched.getPlan(ctx);
QCOMPARE(qRound(latence.budget.recreditedW), 4400); // le prêt est maintenu
QCOMPARE(qRound(plan.budget.allocatedW), 4000);
QVERIFY2(plan.budget.allocatedW > plan.budget.surplusW,
"le recrédit prête d'avance ce que la coupure de A doit libérer");
// 4. L'identité de réconciliation TIENT malgré tout — surplus + recrédit − alloué == restant.
// C'est ce qui distingue un prêt d'une erreur : la comptabilité est juste, c'est
// l'hypothèse qu'elle repose dessus qui n'est pas vérifiée.
QCOMPARE(qRound(plan.budget.surplusW + plan.budget.recreditedW - plan.budget.allocatedW),
qRound(plan.budget.remainingW));
#endif
}
/*!
* \brief [R6] La MESURABILITÉ de l'avancement se déclare à la SAISIE, avec sa cause.
*
* \par Pourquoi ce signal ne peut pas vivre dans la télémétrie
* `measurement` n'existe que pour les charges de `loads[]`. Or une charge qu'on **déclare** est
* précisément celle qui n'y est pas encore — pas arbitrée, ou créée à l'instant. L'écran serait
* muet **au moment exact où il doit parler** : celui où l'installateur saisit une obligation qui
* achètera à l'aveugle tous les jours si rien ne la mesure.
*
* \par Trois causes, trois gestes
* `noMeter` se répare en une manipulation, `meterWithoutEnergy` demande de remplacer le
* compteur, `noSessionEnergy` ne se répare pas — c'est le matériel. Sans la cause, l'avertissement
* ne peut dire que « ça ne marchera pas ».
*
* \par Et `meterThingId` ne permet pas de le déduire
* Un compteur peut publier `currentPower` **sans** `totalEnergyConsumed` : mesuré le 2026-09-01
* sur l'ECS du banc, et reproduit ici avec un `powerSwitch` désigné comme compteur. Un client qui
* déduirait la mesurabilité de la présence du champ se tromperait sur ce cas précis.
*/
void Simulation::testProgressMeasurabilityIsDeclaredAtConfigTime()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-mes.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();
QUuid compteur = addMeter(26702); // meter : porte totalEnergyConsumed
QUuid r1 = addPowerSwitch(1000, 26703); // powerSwitch : power + currentPower SEULEMENT
QUuid r2 = addPowerSwitch(1000, 26704);
QUuid r3 = addPowerSwitch(1000, 26705);
QVERIFY(tm->findConfiguredThing(compteur) && tm->findConfiguredThing(r1));
// Écrit par la FRONTIÈRE RPC, comme l'app le ferait — c'est le magasin que le handler
// connaît. Un store créé à côté ne serait pas celui qu'il interroge : constaté ici même,
// GetLoadConfig rendait une liste VIDE et le test passait sans rien vérifier.
int rang = 0;
QVariantList aPoser;
auto relais = [&](const QString &id, const QUuid &thing, const QString &meter) {
++rang; // rangs UNIQUES : le store refuse deux charges de même rang
QVariantMap m{{"id", id}, {"label", id}, {"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", rang}, {"enabled", true}, {"domain", "ecs"},
{"relays", QVariantList() << QVariantMap{{"thingId", thing.toString()},
{"powerW", 1000}}}};
if (!meter.isEmpty()) m.insert("meterThingId", meter);
return m;
};
aPoser << relais("sans-compteur", r1, QString());
aPoser << relais("compteur-ok", r2, compteur.toString());
aPoser << relais("compteur-sans-energie", r3, r1.toString());
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig",
QVariantMap({{"loadConfigs", aPoser}})).toMap()
.value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
.value("params").toMap().value("loadConfigs").toList();
QHash<QString, QVariantMap> parId;
for (const QVariant &v : l) parId.insert(v.toMap().value("id").toString(), v.toMap());
// Garde contre l'assertion VACANTE : sans les trois entrées, tout ce qui suit passerait
// sans rien vérifier. Le piège a déjà été rencontré deux fois dans ce lot.
QCOMPARE(parId.count(), 3);
// 1. AUCUN COMPTEUR — réparable en une manipulation, et c'est la cause la plus fréquente.
QCOMPARE(parId.value("sans-compteur").value("progressMeasurable").toBool(), false);
QCOMPARE(parId.value("sans-compteur").value("progressUnmeasurableCause").toString(),
QString("noMeter"));
// 2. COMPTEUR AVEC ÉNERGIE — mesurable, et AUCUNE cause publiée : il n'y a rien à réparer.
QCOMPARE(parId.value("compteur-ok").value("progressMeasurable").toBool(), true);
QVERIFY2(!parId.value("compteur-ok").contains("progressUnmeasurableCause"),
"une charge mesurable ne porte pas de cause — l'absence est l'information");
// 3. COMPTEUR SANS ÉNERGIE — le cas qui interdit de déduire la mesurabilité de la seule
// présence de `meterThingId`. Le champ est là, le compteur existe, et pourtant rien ne
// cumule : la puissance ne suffit pas, il faut une énergie.
QCOMPARE(parId.value("compteur-sans-energie").value("progressMeasurable").toBool(), false);
QCOMPARE(parId.value("compteur-sans-energie").value("progressUnmeasurableCause").toString(),
QString("meterWithoutEnergy"));
QVERIFY2(!parId.value("compteur-sans-energie").value("meterThingId").toString().isEmpty(),
"le compteur EST désigné — c'est bien la déduction par meterThingId qui échoue");
#endif
}
/*!
* \brief [Inventaire du mock] « Saine mais NON MESURÉE » n'est pas « en défaut ».
*
* \par Le cas que le banc ne savait pas produire
* Toutes les classes pilotables du mock portaient une puissance. `measurement.source: none`
* n'était donc atteignable qu'avec un Thing **manquant** — ce qui traîne `available: false` avec
* lui et **mélange deux affirmations distinctes** : « rien ne mesure cette charge » et « cette
* charge est en panne ». Or un **relais sec** — il commute, il ne mesure pas — est banal sur le
* terrain, et parfaitement sain.
*
* Ce test n'aurait pas pu s'écrire avant la classe `dryRelay` : c'est l'inventaire à froid
* (`docs/INVENTAIRE_MOCK.md`) qui a rendu le manque visible, et le manque était structurel.
*
* \par Ce qu'il épingle
* Deux charges dans le même cycle, l'une saine sans mesure, l'autre au câblage manquant. Elles
* doivent se **distinguer** — mêmes `measurement.source`, `available` opposés.
*/
void Simulation::testHealthyButUnmeasuredDiffersFromFaulty()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-sec.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", -5000); // 5 kW exportés
// LE RELAIS SEC : il commute, il ne mesure pas. Il EXISTE, il est sain.
QUuid sec = addDryRelay(26681);
Thing *tSec = tm->findConfiguredThing(sec);
QVERIFY2(tSec, "le relais sec doit être instanciable");
QVERIFY2(!tSec->thingClass().hasStateType("currentPower"),
"un relais sec ne porte PAS de puissance — c'est tout l'objet de la classe");
QVariantList aPoser;
aPoser << QVariantMap{{"id", "ecs-relais-sec"}, {"label", "ECS relais sec"},
{"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", 1}, {"enabled", true}, {"domain", "ecs"},
{"relays", QVariantList() << QVariantMap{{"thingId", sec.toString()},
{"powerW", 1500}}}};
// …et une charge au CÂBLAGE MANQUANT, pour la comparaison.
aPoser << QVariantMap{{"id", "ecs-fantome"}, {"label", "ECS fantôme"},
{"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", 2}, {"enabled", true}, {"domain", "ecs"},
{"relays", QVariantList() << QVariantMap{
{"thingId", "{deadbeef-0000-4000-8000-00000000c0de}"},
{"powerW", 1500}}}};
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig",
QVariantMap({{"loadConfigs", aPoser}})).toMap()
.value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
arb->simulationCallUpdate(utcDateTime(QDate(2026, 9, 2), QTime(13, 0, 0)));
QCoreApplication::processEvents();
QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
const QVariantMap saine = tele.value("ecs-relais-sec");
const QVariantMap fantome = tele.value("ecs-fantome");
QVERIFY2(!saine.isEmpty() && !fantome.isEmpty(), "les deux charges doivent être publiées");
// 1. LES DEUX ne sont pas mesurées — même source, et c'est normal.
QCOMPARE(saine.value("measurement").toMap().value("source").toString(), QString("none"));
QCOMPARE(fantome.value("measurement").toMap().value("source").toString(), QString("none"));
// 2. MAIS ELLES SE DISTINGUENT, et c'est tout l'objet du lot : l'une est SAINE, l'autre en
// défaut. Avant la classe `dryRelay`, la première était impossible à fabriquer, et rien
// ne pouvait donc vérifier que le moteur les sépare.
QCOMPARE(saine.value("available").toBool(), true);
QCOMPARE(fantome.value("available").toBool(), false);
QCOMPARE(fantome.value("faultCode").toString(), QString("THING_MISSING"));
QVERIFY2(!saine.contains("faultCode"),
"une charge saine ne porte pas de code de défaut — l'absence est l'information");
// 3. Et aucune des deux ne publie `command` : rien ne mesure, donc rien à comparer. Sans le
// relais sec, cette assertion n'aurait porté que sur un cas en défaut.
QVERIFY2(!saine.contains("command"),
"sans mesure, l'écart commandé/mesuré est sans objet — même sur une charge saine");
#endif
}
/*!
* \brief Un compteur de VIE qui recule ne rebase JAMAIS l'avancement.
*
* \par Le chemin du faux « tenue », et il est le seul
* Un compteur qui disparaît peut publier **0 au lieu de se taire** — mesuré sur la V2C le
* 2026-08-30 : `currentPower` 4 396 → 0 et les booléens à `false` à la perte du lien. Si la base
* se reprenait sur ce zéro, le retour du compteur avec son cumul réel — 10 kWh sur l'ECS du banc
* — ferait sauter `deliveredWh` à 10 000 en un cycle : **l'obligation se déclarerait tenue sans
* que rien n'ait été livré**.
*
* Et `R6` ne s'y oppose pas : elle n'interdit `ECO_FLOOR_MET` que sous `unmeasurable`. Le chemin
* **mesuré** est donc le seul par lequel un faux « tenue » peut sortir — c'est-à-dire exactement
* celui qu'ouvre l'alimentation de `sessionWh` depuis un compteur.
*
* \par Pourquoi la garde porte sur la SÉMANTIQUE et non sur la joignabilité
* Il n'existe **aucun signal de joignabilité fiable** : `setupComplete` parle du setup et non du
* lien, et `connected` est maintenu par le plugin — la V2C bascule après trois échecs, un
* compteur générique peut ne jamais basculer. **Un accumulateur ne doit pas parier sur ce que
* chaque plugin fait de ses états en tombant** : la même V2C a forgé un zéro sur `currentPower`
* et laissé `sessionEnergy` à sa dernière valeur.
*
* Ce que le moteur sait sans parier : **un compteur de VIE ne recule pas.** Un recul y est une
* anomalie ; sur un compteur de SESSION, c'est une session neuve. Deux sources, deux gestes.
*/
void Simulation::testLifetimeCounterDropNeverRebasesTheProgress()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
const QDateTime t0 = utcDateTime(QDate(2026, 9, 3), QTime(13, 0, 0));
LoadContext ecs;
ecs.id = "ecs-compteur-vie";
ecs.needs.minEnergyWhPerDay = 4000;
ecs.needs.dailyDeadline = "07:00";
ecs.telemetry.sessionMeasurable = true;
ecs.telemetry.sessionCumulative = true; // totalEnergyConsumed : monotone par nature
ecs.telemetry.sessionWh = 9000; // le compteur en est à 9 kWh de vie
QCOMPARE(qRound(arb->ecoProgress(ecs, t0).deliveredWh), 0); // base prise à 9000
ecs.telemetry.sessionWh = 9500;
QCOMPARE(qRound(arb->ecoProgress(ecs, t0).deliveredWh), 500); // 500 Wh livrés
// LE LIEN TOMBE et le plugin publie ZÉRO au lieu de se taire.
ecs.telemetry.sessionWh = 0;
const EcoProgress pendant = arb->ecoProgress(ecs, t0);
// On GÈLE : la valeur ne vaut rien, donc on ne conclut rien. Pas d'avancement publié, pas
// de recapture — le tronçon reste ouvert sur sa dernière valeur saine.
QCOMPARE(pendant.regime, QString(ProgressRegime::Unmeasurable));
QVERIFY2(!pendant.delivered, "sur une valeur non fiable, on ne publie pas d'avancement");
// LE COMPTEUR REVIENT avec son cumul réel. C'est ici que le faux « tenue » sortirait si la
// base avait été reprise à 0 : 10 000 Wh d'un coup sur une obligation de 4 000.
ecs.telemetry.sessionWh = 10000;
const EcoProgress apresRetour = arb->ecoProgress(ecs, t0);
QCOMPARE(apresRetour.regime, QString(ProgressRegime::Measured));
// 10 000 − 9 000 (base intacte) = 1 000 Wh. PAS 10 000.
QCOMPARE(qRound(apresRetour.deliveredWh), 1000);
QVERIFY2(apresRetour.deliveredWh < ecs.needs.minEnergyWhPerDay,
"l'obligation ne doit PAS se déclarer tenue sur un retour de compteur");
// Et la distinction tient : sur un compteur de SESSION, le même recul EST une session neuve.
LoadContext borne;
borne.id = "borne-session";
borne.needs.minEnergyWhPerDay = 4000;
borne.needs.dailyDeadline = "07:00";
borne.telemetry.sessionMeasurable = true;
borne.telemetry.sessionCumulative = false; // sessionEnergy : remis à zéro par session
borne.telemetry.sessionWh = 9000;
QCOMPARE(qRound(arb->ecoProgress(borne, t0).deliveredWh), 0);
borne.telemetry.sessionWh = 120; // nouvelle session
const EcoProgress neuve = arb->ecoProgress(borne, t0);
QCOMPARE(neuve.regime, QString(ProgressRegime::Measured)); // pas d'anomalie : on recapture
QCOMPARE(qRound(neuve.deliveredWh), 0);
#endif
}
/*!
* \brief [\rule{LM-1210-a}] Sur une triphasée DÉSÉQUILIBRÉE, c'est la PIRE phase qui borne.
*
* \par Un choix écrit, raisonné, et jamais exercé — jusqu'ici
* `LoadDeclared::phases` est un NOMBRE, pas un masque : le moteur ignore sur quelle phase une
* charge est câblée. Il retient donc la pire, seul choix qui ne devine pas — deviner dans une
* couche de sécurité se paie au disjoncteur.
*
* Ce test n'existait pas parce que **le mock ne savait produire qu'une triphasée équilibrée** :
* il posait `currentPhaseX = currentPowerPhaseX / 230`, cohérent par construction. Le manque
* était nommé deux fois — par l'inventaire du mock et par le balayage des contrôles — avant
* d'être comblé.
*
* \par Ce qu'il épingle
* La maison EXPORTE globalement, et une seule phase sature. La marge doit être celle de cette
* phase-là, négative, et non celle du total qui paraîtrait confortable.
*/
void Simulation::testWorstPhaseBindsWhenPhasesAreUnbalanced()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
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.SetPhasePowerLimit",
QVariantMap({{"phasePowerLimit", 25}})).toMap()
.value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
// DÉSÉQUILIBRE : la phase B sature à 30 A sous un plafond de 25, pendant que A et C
// EXPORTENT. Impossible à écrire avant : le mock dérivait les courants des puissances.
meter->setStateValue("currentPhaseA", -10); // export
meter->setStateValue("currentPhaseB", 30); // SATURÉE
meter->setStateValue("currentPhaseC", -10); // export
meter->setStateValue("currentPower", -2300); // la maison exporte GLOBALEMENT
const DrawMargin marge = arb->houseDrawMargin();
QVERIFY2(marge.borne, "un plafond est réglé : la marge doit exister");
// 1. LA PIRE PHASE L'EMPORTE — et c'est bien B, pas le total. 25 A − 30 A = −5 A, soit
// −1150 W. Le total, lui, afficherait un confort trompeur : la maison exporte.
QCOMPARE(qRound(marge.watts), -1150);
QCOMPARE(marge.phase, 1); // phase B (0 = A, 1 = B, 2 = C)
// 1-bis. ET L'ÉCRAN DOIT POUVOIR NOMMER CETTE PHASE. `perPhaseBound` dit qu'une phase borne,
// jamais laquelle : l'écran ne pourrait dire que « bridé » sans dire par quoi, et
// enverrait mesurer au hasard sur trois phases dont deux vont bien. Le motif `DRAW_CAP`
// la porte aussi, mais il n'est émis que si une charge est REFUSÉE — un cycle qui borde
// sans rien refuser laisserait l'écran muet. Les deux endroits ne se recouvrent pas.
SurplusContext ctxPhase;
ctxPhase.timestamp = utcDateTime(QDate(2026, 9, 4), QTime(13, 0, 0));
ctxPhase.drawMargin = marge;
RuleBasedScheduler schedPhase(arb);
const Plan planPhase = schedPhase.getPlan(ctxPhase);
QCOMPARE(planPhase.draw.perPhaseBound, true);
QCOMPARE(planPhase.draw.bindingPhase, QString("B"));
// 2. ET LA MARGE EST NÉGATIVE alors que le compteur global est en EXPORT. C'est tout l'objet
// du choix : sans la lecture par phase, l'arbitre croirait disposer de 2 300 W à dépenser
// pendant qu'une phase est déjà au-delà de son disjoncteur.
QVERIFY2(meter->stateValue("currentPower").toDouble() < 0,
"la maison exporte : c'est le cas qui rend le pessimisme non trivial");
QVERIFY2(marge.watts < 0,
"la pire phase borne, même quand le total rassure");
// 3. Contre-jour : rééquilibrée, la même maison retrouve une marge confortable. Sans ce
// second point, le test ne distinguerait pas « la pire phase l'emporte » de « la marge
// est toujours négative ».
meter->setStateValue("currentPhaseB", -10);
const DrawMargin equilibree = arb->houseDrawMargin();
// La marge est ÉCRÊTÉE AU PLAFOND, et non augmentée par l'export : `calculateAllowanceAmpere`
// part de `phasePowerLimit` et ne fait que des `qMin`. Physiquement juste — l'export ne
// permet pas de tirer plus que le disjoncteur — mais ce n'est PAS ce que le design disait
// (« une phase qui exporte a une marge plus grande que le plafond »), et la phrase a été
// corrigée. La conclusion, elle, tient : une phase qui exporte est au maximum de la marge,
// donc elle ne borne jamais.
QCOMPARE(qRound(equilibree.watts), qRound(25.0 * 230.0));
QVERIFY2(equilibree.watts > 0, "rééquilibrée, la marge redevient positive");
#endif
}
/*!
* \brief [upstream-fix] La tension d'une phase ne doit pas servir à calculer une AUTRE phase.
*
* \par Le défaut
* `calculateAllowanceAmpere()` déclare `voltage` **une seule fois, hors des trois blocs**, et le
* bloc de la phase A la mute. Un compteur qui publie `voltagePhaseA` mais pas `voltagePhaseB`
* verrait donc la phase B calculée **avec la tension de A**.
*
* \par Pourquoi ce test n'existait pas
* Le mock posait `currentPhaseX = currentPowerPhaseX / 230` : tension implicite, identique
* partout, et le défaut invisible. C'est la **troisième fois** que l'ouverture du mock révèle un
* défaut plutôt que de seulement débloquer un test — après `dryRelay` et après la correction du
* raisonnement sur l'écrêtage de la marge.
*/
void Simulation::testPhaseVoltageDoesNotLeakBetweenPhases()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
ThingManager *tm = NymeaCore::instance()->thingManager();
QUuid meterId = addMeterPowerOnly(26691);
Thing *meter = tm->findConfiguredThing(meterId);
QVERIFY(meter);
meter->setStateValue("connected", true);
QVERIFY2(!meter->thingClass().hasStateType("currentPhaseC"),
"ce compteur ne publie PAS les courants de B et C — c'est ce qui rend la branche "
"« puissance ÷ tension » atteignable");
QVERIFY2(meter->thingClass().hasStateType("voltagePhaseB"),
"il publie la tension de B…");
QVERIFY2(!meter->thingClass().hasStateType("voltagePhaseC"),
"…mais PAS celle de C : c'est exactement le cas où la tension de B fuirait vers C");
RootMeter *rm = new RootMeter(meter);
// LE MASQUE DOIT COUVRIR B **ET** C — c'est la condition de la fuite : le bloc de B écrit
// `voltage`, celui de C le relit. Interroger C SEULE ne révèle rien, puisque le bloc de B ne
// s'exécute pas. (Première version de ce test, qui passait pour cette raison.)
//
// Et C doit être la phase la plus CONTRAINTE, sinon le `qMin` de la fonction retiendrait B
// et masquerait l'écart. B : 1000 W sous 200 V = 5 A, marge 20 A. C : 4000 W sous une
// tension INCONNUE — donc 230 V par défaut, 17,4 A, marge 7,6 A. Avec la fuite, C serait
// calculée sous les 200 V de B : 20 A tirés, marge 5 A.
meter->setStateValue("voltagePhaseB", 200);
meter->setStateValue("currentPowerPhaseB", 1000);
meter->setStateValue("currentPowerPhaseC", 4000);
const uint plafond = 25;
const double marge = rm->calculateAllowanceAmpere(
Electricity::PhaseB | Electricity::PhaseC, plafond);
// Sans fuite : min(20 ; 25 − 4000/230) = 7,6 A. Avec la fuite : min(20 ; 25 − 4000/200) = 5.
QCOMPARE(qRound(marge * 10), qRound((25.0 - 4000.0 / 230.0) * 10));
// Et l'ORDRE ne doit rien changer : le résultat de C ne doit pas dépendre de ce qu'on a
// demandé avant. C'est la propriété que la variable partagée détruisait.
QCOMPARE(qRound(rm->calculateAllowanceAmpere(Electricity::PhaseC, plafond) * 10),
qRound((25.0 - 4000.0 / 230.0) * 10));
#endif
}
/*!
* \brief Le compteur du mock INTÈGRE réellement — un cumul figé n'existe pas.
*
* \par Le blocage qu'il lève
* `totalEnergyConsumed` valait **10 kWh depuis le 1er septembre** : une constante. `deliveredWh`
* restait donc à 0, `remainingWh` à 4 000, et **l'étalement adaptatif n'était jamais exercé** —
* le comportement se confondait avec `unmeasurable` pour une raison entièrement différente et
* bénigne. Un compteur figé est un compteur qui n'existe pas pour tout ce qui l'intègre.
*
* \par \rule{LM-1014-b} appliquée au mock lui-même
* `totalEnergyConsumed` est déclaré **`cached`** : c'est un compteur de VIE, il **continue** au
* redémarrage et ne repart pas de zéro. Repartir de zéro ferait **reculer un cumul monotone**,
* ce que la garde de sémantique traite comme une anomalie — le mock produirait alors un cas que
* le terrain ne produit pas.
*/
void Simulation::testMeterEnergyActuallyAccumulates()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
ThingManager *tm = NymeaCore::instance()->thingManager();
QUuid meterId = addMeter(26693);
Thing *meter = tm->findConfiguredThing(meterId);
QVERIFY(meter);
meter->setStateValue("connected", true);
// Le compteur CONSOMME 3 600 W : un watt-heure par seconde, de quoi voir bouger le cumul.
meter->setStateValue("currentPower", 3600);
const double depart = meter->stateValue("totalEnergyConsumed").toDouble();
QTest::qWait(2500);
const double apres = meter->stateValue("totalEnergyConsumed").toDouble();
QVERIFY2(apres > depart,
qUtf8Printable(QStringLiteral("le cumul doit AVANCER : %1 → %2 kWh")
.arg(depart).arg(apres)));
// …et il avance dans un ORDRE DE GRANDEUR plausible : 3 600 W pendant ~2,5 s, soit ~2,5 Wh
// si toutes les secondes intègrent. La borne basse est LARGE et volontairement lâche : le
// test ne contrôle ni la cadence exacte du minuteur ni l'alignement du premier tic — qui
// n'intègre rien, faute de durée derrière lui. Une borne serrée en ferait un test instable,
// c'est-à-dire un test qu'on relancerait jusqu'à ce qu'il passe.
//
// Ce qui est réellement épinglé : il intègre, et il n'emballe pas. Le premier point est ce
// qui manquait ; le second est ce qui rendrait le mock inutilisable dans l'autre sens.
const double gagneWh = (apres - depart) * 1000.0;
QVERIFY2(gagneWh >= 0.5 && gagneWh < 10.0,
qUtf8Printable(QStringLiteral("ordre de grandeur plausible attendu (~2,5 Wh), "
"obtenu %1").arg(gagneWh)));
// EN EXPORT, il ne recule pas — il ne bouge pas. Un compteur de CONSOMMATION est monotone,
// et le faire reculer produirait un cas que le terrain ne produit pas.
meter->setStateValue("currentPower", -3000);
const double avantExport = meter->stateValue("totalEnergyConsumed").toDouble();
QTest::qWait(1500);
QCOMPARE(meter->stateValue("totalEnergyConsumed").toDouble(), avantExport);
#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
}