R7 — decision.level sur le motif hérité, présent exactement quand levels[] l'est. Sans lui chaque client réinvente sa règle de fusion et deux écrans affichent deux motifs pour le même cycle. Un seul niveau aujourd'hui, donc la question ne se pose pas : c'est la raison d'ajouter le champ maintenant plutôt qu'avec le §10 — même argument que R2 sur le coût de déplacement. R1 — l'absence et le zéro épinglés dans les deux sens. Niveau absent = passe non parcourue ; niveau à targetW 0 = passe parcourue, rien à prendre. Publier l'un sans l'autre les rendrait indistinguables. Aujourd'hui la passe unique est toujours parcourue dès qu'une charge est arbitrée, donc c'est le MODE DÉGRADÉ qui porte le cas « aucune passe » — levels[] absent, planification suspendue. Vérifié échouant dans les deux sens : un niveau à zéro omis, et un decision.level publié sans levels[]. LM-1014 — un client teste la PRÉSENCE d'un champ, jamais une version. La règle vient de l'agent app, qui n'a pas retiré son exception EV_GRID_START mais l'a déplacée sur la bonne frontière : elle survit là où la charge utile n'a pas de counts. Ce qui rend une capacité détectable est la charge utile, pas un numéro — une box se rétrograde, un paquet se reconstruit, une branche se déploie hors séquence. Portée aussi en tête d'INTERFACE.md, avec sa conséquence : les versions y datent les changements, elles ne sont pas des conditions à tester. R5, R6, R8 NE SONT PAS REPOUSSÉES — elles sont sans objet, et je l'ai vérifié plutôt que supposé : ECO_FLOOR_MET, ECO_FLOOR_GRID et DRAW_CAP n'existent pas dans le catalogue. Écrire la garde de R6 aujourd'hui poserait un invariant sur un code que rien n'émet, c'est-à-dire un test toujours vert. simulation 35/35, charging 17/17, loadmodel 20/20, spotmarket 7/7, amd64 0/0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
6404 lines
324 KiB
C++
6404 lines
324 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
|
||
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||
*
|
||
* Copyright (C) 2013 - 2024, nymea GmbH
|
||
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
|
||
*
|
||
* This file is part of nymea-energy-plugin-nymea.
|
||
*
|
||
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
|
||
* it under the terms of the GNU General Public License as published by
|
||
* the Free Software Foundation, either version 3 of the License, or
|
||
* (at your option) any later version.
|
||
*
|
||
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
|
||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
* GNU General Public License for more details.
|
||
*
|
||
* You should have received a copy of the GNU General Public License
|
||
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
|
||
*
|
||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||
|
||
#include "simulation.h"
|
||
|
||
#include <hardware/electricity.h>
|
||
#include <servers/mocktcpserver.h>
|
||
#include <experiences/experiencemanager.h>
|
||
#include <experiences/experienceplugin.h>
|
||
|
||
using namespace nymeaserver;
|
||
|
||
#include "../../../energyplugin/smartchargingmanager.h"
|
||
#include "../../mocks/spotmarketprovider/spotmarketdataprovidermock.h"
|
||
#ifdef ETM_ARBITRATOR
|
||
#include "../../../energyplugin/etm/energyarbitrator.h"
|
||
#include "../../../energyplugin/etm/adapters/sgreadyadapter.h"
|
||
// [T3] ecsrelayadapter.h retiré : l'ECS est piloté en watts via EtmVariableLoadAdapter
|
||
// (interface etmvariableload, kind Setpoint). La combinatoire relais vit dans le thing.
|
||
#include "../../../energyplugin/etm/adapters/etmvariableloadadapter.h"
|
||
#include "../../../energyplugin/etm/adapters/relayrouter.h"
|
||
#include "../../../energyplugin/etm/adapters/evadapter.h"
|
||
#include "../../../energyplugin/evcharger.h"
|
||
#include "../../../energyplugin/etm/types/loadconfig.h"
|
||
#include "../../../energyplugin/etm/config/loadconfigstore.h"
|
||
#include "../../../energyplugin/etm/ratios/energyratioscalculator.h"
|
||
#include "../../../energyplugin/etm/scheduler/rulebasedscheduler.h"
|
||
#include "../../../energyplugin/etm/types/decisionreason.h"
|
||
#endif
|
||
|
||
#include <functional>
|
||
#include <QDir>
|
||
#include <QFile>
|
||
#include <QFileInfo>
|
||
|
||
#include <QHash>
|
||
#include <QtMath>
|
||
#include <QtGlobal>
|
||
#include <QProcess>
|
||
#include <QDateTime>
|
||
#include <QSignalSpy>
|
||
#include <QProcessEnvironment>
|
||
#include <QCoreApplication>
|
||
|
||
#include <nymeacore.h>
|
||
|
||
#include "simulationtestpoint.h"
|
||
|
||
namespace {
|
||
#ifdef ETM_ARBITRATOR
|
||
/*! \brief Charge utile de configuration d'une PAC SG-Ready 2 bits (K1/K2), forme §4.
|
||
*
|
||
* Depuis le lot B-bis il n'existe plus d'enregistrement codé en dur : une PAC se déclare comme
|
||
* n'importe quelle autre charge. Ce fabricant évite d'étaler le même littéral dans chaque test
|
||
* — et rend visible qu'il n'y a QU'UNE façon de déclarer une PAC.
|
||
*/
|
||
QVariantMap sgReadyConfigMap(const QString &id, const QString &label,
|
||
const QUuid &k1, const QUuid &k2,
|
||
double p3, double p4, int minStateHoldS, int priority)
|
||
{
|
||
return QVariantMap{
|
||
{"id", id}, {"label", label}, {"adapter", "sg-ready"}, {"mode", "fixed"},
|
||
{"priority", priority}, {"enabled", true},
|
||
{"sgReady", QVariantMap{
|
||
{"minStateHoldS", minStateHoldS},
|
||
{"states", QVariantList()
|
||
<< QVariantMap{{"state", 1}, {"relays", QVariantList() << k1.toString()},
|
||
{"estimatedPowerW", 0.0}}
|
||
<< QVariantMap{{"state", 2}, {"relays", QVariantList()},
|
||
{"estimatedPowerW", 0.0}}
|
||
<< QVariantMap{{"state", 3}, {"relays", QVariantList() << k2.toString()},
|
||
{"estimatedPowerW", p3}}
|
||
<< QVariantMap{{"state", 4},
|
||
{"relays", QVariantList() << k1.toString() << k2.toString()},
|
||
{"estimatedPowerW", p4}}}}}};
|
||
}
|
||
|
||
/*! \brief Capture les messages Qt le temps d'une portée, pour asserter sur le TEXTE publié.
|
||
*
|
||
* AGENTS.md est explicite : les tests des règles 7-b à 7-d portent sur le TEXTE, jamais sur la
|
||
* non-vacuité d'une chaîne — « un test motif non vide n'aurait rien vu, dans aucun de ces cas ».
|
||
* Un relevé de reprise (ECS-411-b) relève de la même exigence : ce qui compte est qu'il DISE
|
||
* ce qui a été lu, pas qu'il existe.
|
||
*/
|
||
class JournalCapture
|
||
{
|
||
public:
|
||
/*! \brief Ouvre une capture. Plusieurs peuvent être vivantes en même temps.
|
||
*
|
||
* Le handler n'est installé QU'UNE FOIS, et chaîné au handler d'ORIGINE mémorisé à cette
|
||
* occasion. Le réinstaller à chaque construction serait un piège : \c qInstallMessageHandler
|
||
* rend le handler courant, qui est alors \c collecte() elle-même — la capture se chaînerait
|
||
* sur elle-même, chaque message se rappellerait indéfiniment en concaténant dans
|
||
* \c m_texte, et le processus mourrait en OOM. Défaut constaté le 2026-08-25 : la suite
|
||
* entière atteignait 7-8 Gio et emportait la session graphique, alors qu'un seul test —
|
||
* celui-ci, le seul à ouvrir DEUX captures — en était responsable.
|
||
*/
|
||
JournalCapture()
|
||
{
|
||
if (s_pile.isEmpty())
|
||
s_origine = qInstallMessageHandler(&JournalCapture::collecte);
|
||
s_pile.append(this);
|
||
}
|
||
//! Referme la capture ; le handler d'origine n'est rendu qu'à la dernière.
|
||
~JournalCapture()
|
||
{
|
||
s_pile.removeAll(this);
|
||
if (s_pile.isEmpty())
|
||
qInstallMessageHandler(s_origine);
|
||
}
|
||
|
||
bool contains(const QString &fragment) const { return m_texte.contains(fragment); }
|
||
bool contient(QtMsgType type) const { return m_types.contains(type); }
|
||
QString texte() const { return m_texte; }
|
||
|
||
private:
|
||
static void collecte(QtMsgType type, const QMessageLogContext &ctx, const QString &msg)
|
||
{
|
||
// Toutes les captures vivantes reçoivent le message — c'est ce que le chaînage visait.
|
||
for (JournalCapture *c : s_pile) {
|
||
c->m_texte += msg + QLatin1Char('\n');
|
||
c->m_types.insert(type);
|
||
}
|
||
if (s_origine)
|
||
s_origine(type, ctx, msg); // le message reste visible dans la sortie de test
|
||
}
|
||
QString m_texte;
|
||
QSet<QtMsgType> m_types;
|
||
static QList<JournalCapture *> s_pile;
|
||
static QtMessageHandler s_origine;
|
||
};
|
||
QList<JournalCapture *> JournalCapture::s_pile;
|
||
QtMessageHandler JournalCapture::s_origine = nullptr;
|
||
|
||
//! \brief Lit une grandeur entière du mécanisme d'une charge, par la frontière PUBLIQUE.
|
||
//! \return La valeur, ou -1 si la charge n'est pas publiée — un -1 fait échouer l'assertion
|
||
//! appelante, ce qu'un 0 silencieux ne ferait pas.
|
||
int mechanismInt(EnergyArbitrator *arb, const QString &loadId, const char *cle)
|
||
{
|
||
for (const QVariant &v : arb->loadTelemetry().value(QStringLiteral("loads")).toList())
|
||
if (v.toMap().value(QStringLiteral("loadId")).toString() == loadId)
|
||
return v.toMap().value(QStringLiteral("mechanism")).toMap()
|
||
.value(QLatin1String(cle)).toInt();
|
||
return -1;
|
||
}
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testEcsSurplusPV()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testEcsSurplusPV nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [T3] ECS piloté en WATTS via l'interface etmvariableload (contrat rév. 2 §3/§5) : l'arbitre
|
||
// arrondit le surplus au powerLevels déclaré (fixed) ou clampe à maxPowerW (dynamic), écrit
|
||
// powerSetpoint, et recrédite currentPowerW (début de cycle) pour l'anti-clignotement. La
|
||
// combinatoire matérielle (relais/triac) vit dans le thing — invisible côté moteur.
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
|
||
// =================== FIXED : paliers déclarés [0, 1200, 2400] ===================
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY2(arbitrator, "smartChargingManager n'est pas un EnergyArbitrator (ETM_ARBITRATOR requis)");
|
||
ThingManager *thingManager = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterThingId = addMeter();
|
||
QVERIFY(!meterThingId.isNull());
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterThingId);
|
||
Thing *meterThing = thingManager->findConfiguredThing(meterThingId);
|
||
QVERIFY(meterThing);
|
||
meterThing->setStateValue("connected", true);
|
||
|
||
QUuid ecsId = addEtmVariableLoad(27001);
|
||
QVERIFY(!ecsId.isNull());
|
||
Thing *ecsThing = thingManager->findConfiguredThing(ecsId);
|
||
QVERIFY(ecsThing);
|
||
|
||
EtmVariableLoadAdapter *ecs = new EtmVariableLoadAdapter(
|
||
thingManager, ecsId.toString(), "ECS variable",
|
||
QList<int>({0, 1200, 2400}), 2400, /* minPowerW */ 0, 1, LoadNeeds(), arbitrator);
|
||
arbitrator->registerEtmVariableLoadAdapter(ecs);
|
||
|
||
auto setMeterW = [&](double signedW){ meterThing->setStateValue("currentPower", signedW); }; // <0 = export
|
||
auto setLoadW = [&](double w){ ecsThing->setStateValue("currentPowerW", w); }; // mesure réelle simulée
|
||
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
|
||
|
||
// --- Cascade montante (la charge ne tire encore rien : currentPowerW = 0) ---
|
||
setLoadW(0);
|
||
setMeterW(-1000); cycle(t0); // budget 1000 < 1200 → 0 W
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
|
||
setMeterW(-1500); cycle(t0); // budget 1500 → palier 1200
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 1200);
|
||
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 1200); // round-trip interface
|
||
setMeterW(-2500); cycle(t0); // budget 2500 → palier 2400
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 2400);
|
||
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 2400);
|
||
|
||
// --- Anti-clignotement (recrédit) : la charge tire 2400, PV 2500 → export net 100.
|
||
// budget = 100 + 2400 (recrédit currentPowerW début de cycle) = 2500 → RESTE 2400.
|
||
// Sans recrédit : 100 → 0 → oscillation. C'est précisément le test du recrédit. ---
|
||
setLoadW(2400);
|
||
setMeterW(-100); cycle(t0);
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 2400);
|
||
|
||
// --- Délestage sur import : charge tire 2400, import 600 → budget = -600 + 2400 = 1800 → 1200. ---
|
||
setMeterW(600); cycle(t0);
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 1200);
|
||
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 1200);
|
||
|
||
// =================== DYNAMIC : modulation continue, maxPowerW 3000, sans powerLevels ===================
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb2 = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb2);
|
||
ThingManager *tm2 = NymeaCore::instance()->thingManager();
|
||
QUuid mId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(mId);
|
||
Thing *m2 = tm2->findConfiguredThing(mId);
|
||
QVERIFY(m2);
|
||
m2->setStateValue("connected", true);
|
||
QUuid rId = addEtmVariableLoad(27002);
|
||
Thing *rThing = tm2->findConfiguredThing(rId);
|
||
QVERIFY(rThing);
|
||
rThing->setStateValue("currentPowerW", 0);
|
||
|
||
EtmVariableLoadAdapter *router = new EtmVariableLoadAdapter(
|
||
tm2, rId.toString(), "Routeur PV", QList<int>(), 3000, /* minPowerW */ 0, 1, LoadNeeds(), arb2); // powerLevels vide → dynamic
|
||
arb2->registerEtmVariableLoadAdapter(router);
|
||
|
||
m2->setStateValue("currentPower", -2000); arb2->simulationCallUpdate(t0); QCoreApplication::processEvents();
|
||
QCOMPARE(qRound(router->currentSetpointW()), 2000); // clamp(2000, 0, 3000)
|
||
m2->setStateValue("currentPower", -4000); arb2->simulationCallUpdate(t0); QCoreApplication::processEvents();
|
||
QCOMPARE(qRound(router->currentSetpointW()), 3000); // plafonné à maxPowerW
|
||
m2->setStateValue("currentPower", 500); arb2->simulationCallUpdate(t0); QCoreApplication::processEvents();
|
||
QCOMPARE(qRound(router->currentSetpointW()), 0); // import → 0
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testMeterSilentFallback()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testMeterSilentFallback nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [T4] Repli L2 charge pilotée recâblé : compteur muet >90 s → setPowerSetpoint(0) force=true
|
||
// sur l'etmvariableload, planif suspendue (reste 0 sur N cycles), reprise au retour compteur.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY2(arbitrator, "smartChargingManager n'est pas un EnergyArbitrator (ETM_ARBITRATOR requis)");
|
||
ThingManager *thingManager = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterThingId = addMeter();
|
||
QVERIFY(!meterThingId.isNull());
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterThingId);
|
||
Thing *meterThing = thingManager->findConfiguredThing(meterThingId);
|
||
QVERIFY(meterThing);
|
||
meterThing->setStateValue("connected", true);
|
||
|
||
QUuid ecsId = addEtmVariableLoad(27010);
|
||
QVERIFY(!ecsId.isNull());
|
||
Thing *ecsThing = thingManager->findConfiguredThing(ecsId);
|
||
QVERIFY(ecsThing);
|
||
|
||
EtmVariableLoadAdapter *ecs = new EtmVariableLoadAdapter(
|
||
thingManager, ecsId.toString(), "ECS repli",
|
||
QList<int>({0, 2400}), 2400, /* minPowerW */ 0, 1, LoadNeeds(), arbitrator);
|
||
arbitrator->registerEtmVariableLoadAdapter(ecs);
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
auto setMeterW = [&](double signedW){ meterThing->setStateValue("currentPower", signedW); };
|
||
auto setLoadW = [&](double w){ ecsThing->setStateValue("currentPowerW", w); };
|
||
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
|
||
|
||
// ECS servi sur surplus, compteur frais à T0.
|
||
arbitrator->recordMeterUpdate(t0);
|
||
setLoadW(0);
|
||
setMeterW(-2500); cycle(t0); // budget 2500 → 2400
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 2400);
|
||
QVERIFY(!arbitrator->degradedMode());
|
||
|
||
// Compteur muet > 90 s → mode dégradé : setPowerSetpoint(0) force=true.
|
||
setLoadW(2400); // la charge tirait
|
||
arbitrator->evaluateMeterFreshness(t0.addSecs(91));
|
||
QCoreApplication::processEvents();
|
||
QVERIFY(arbitrator->degradedMode());
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
|
||
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 0);
|
||
|
||
// STABILITÉ : muet, plusieurs cycles, faux surplus piège → l'ECS RESTE à 0 (planif suspendue).
|
||
setMeterW(-3000);
|
||
foreach (int dt, QList<int>({92, 120, 200, 280})) {
|
||
cycle(t0.addSecs(dt));
|
||
QVERIFY2(arbitrator->degradedMode(), "degradedMode doit rester actif pendant le silence");
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
|
||
}
|
||
|
||
// REPRISE : compteur reparle → degradedMode retombe → recalcul (pas de restauration d'ancienne consigne).
|
||
arbitrator->recordMeterUpdate(t0.addSecs(300));
|
||
QVERIFY(!arbitrator->degradedMode());
|
||
setLoadW(0);
|
||
setMeterW(-1000); cycle(t0.addSecs(301)); // 1000 < 2400 → reste 0
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
|
||
setMeterW(-2500); cycle(t0.addSecs(302)); // surplus suffisant → 2400
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 2400);
|
||
|
||
// ===================== Cas RELAIS (rév. 3) — certification du trou T2 fermé =====================
|
||
// Compteur muet → le RelayRouter coupe TOUS ses relais, force=true (bypass minOn). Sans ce
|
||
// volet, le repli L2 du routeur n'est pas certifié : des relais ECS pourraient rester allumés.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb2 = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb2);
|
||
ThingManager *tm2 = NymeaCore::instance()->thingManager();
|
||
QUuid mId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(mId);
|
||
Thing *m2 = tm2->findConfiguredThing(mId);
|
||
QVERIFY(m2);
|
||
m2->setStateValue("connected", true);
|
||
|
||
QUuid rA = addPowerSwitch(1000, 26661);
|
||
QUuid rB = addPowerSwitch(1500, 26662);
|
||
Thing *relayA = tm2->findConfiguredThing(rA);
|
||
Thing *relayB = tm2->findConfiguredThing(rB);
|
||
QVERIFY(relayA && relayB);
|
||
|
||
// RelayRouter : paliers DÉRIVÉS [0, 1000, 1500, 2500] ; minOn=300 s (pour prouver le bypass force).
|
||
RelayRouter *router = new RelayRouter(
|
||
tm2, "ecs-relais-repli", "ECS relais (repli)",
|
||
QList<LoadConfigRelay>({ {rA.toString(), 1000}, {rB.toString(), 1500} }),
|
||
300, 0, 1, LoadNeeds(), arb2);
|
||
arb2->registerRelayRouter(router);
|
||
|
||
auto cycle2 = [&](const QDateTime &now){ arb2->simulationCallUpdate(now); QCoreApplication::processEvents(); };
|
||
|
||
// Surplus 2500 → palier 2500 (A+B) : les DEUX relais ON.
|
||
arb2->recordMeterUpdate(t0);
|
||
m2->setStateValue("currentPower", -2500); cycle2(t0);
|
||
QCOMPARE(qRound(router->currentSetpointW()), 2500);
|
||
QCOMPARE(relayA->stateValue("power").toBool(), true);
|
||
QCOMPARE(relayB->stateValue("power").toBool(), true);
|
||
QVERIFY(!arb2->degradedMode());
|
||
|
||
// Compteur muet > 90 s → mode dégradé : TOUS les relais OFF, force=true (bypass minOn 300).
|
||
arb2->evaluateMeterFreshness(t0.addSecs(91));
|
||
QCoreApplication::processEvents();
|
||
QVERIFY(arb2->degradedMode());
|
||
QCOMPARE(qRound(router->currentSetpointW()), 0);
|
||
QCOMPARE(relayA->stateValue("power").toBool(), false); // trou T2 fermé : relais coupé
|
||
QCOMPARE(relayB->stateValue("power").toBool(), false);
|
||
|
||
// STABILITÉ : muet, faux surplus piège → les relais RESTENT OFF (planif suspendue).
|
||
m2->setStateValue("currentPower", -3000);
|
||
foreach (int dt, QList<int>({92, 200})) {
|
||
cycle2(t0.addSecs(dt));
|
||
QVERIFY2(arb2->degradedMode(), "degradedMode doit rester actif pendant le silence");
|
||
QCOMPARE(relayA->stateValue("power").toBool(), false);
|
||
QCOMPARE(relayB->stateValue("power").toBool(), false);
|
||
}
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testLoadConfigPersistence()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testLoadConfigPersistence nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// Persistance §4 : SetConfigs → fichier écrit → relecture par un store neuf → round-trip
|
||
// mot pour mot (id/label/mode/powerLevels/maxPowerW/priority/enabled/needs).
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-persist.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
const QVariantMap entry{
|
||
{"id", "{11111111-2222-3333-4444-555555555555}"},
|
||
{"label", "Chauffe-eau"},
|
||
{"adapter", "etmvariableload"},
|
||
{"mode", "fixed"},
|
||
{"powerLevels", QVariantList() << 0 << 600 << 1200},
|
||
{"maxPowerW", 1200},
|
||
{"priority", 2},
|
||
{"enabled", true},
|
||
{"needs", QVariantMap{{"dailyDeadline", "06:00"}, {"minEnergyWhPerDay", 4000}}}
|
||
};
|
||
LoadConfigs cfgs;
|
||
cfgs.append(LoadConfig::fromMap(entry));
|
||
|
||
{
|
||
LoadConfigStore store1;
|
||
QString err;
|
||
QVERIFY2(store1.setConfigs(cfgs, &err), err.toUtf8());
|
||
}
|
||
QVERIFY(QFileInfo::exists(cfgPath));
|
||
|
||
LoadConfigStore store2; // relit le fichier
|
||
QCOMPARE(store2.configs().count(), 1);
|
||
const LoadConfig r = store2.configs().first();
|
||
QCOMPARE(r.label(), QString("Chauffe-eau"));
|
||
QCOMPARE(r.mode(), QString("fixed"));
|
||
QCOMPARE(r.powerLevelsInt(), QList<int>({0, 600, 1200}));
|
||
QCOMPARE(r.maxPowerW(), 1200);
|
||
QCOMPARE(r.priority(), 2);
|
||
QVERIFY(r.enabled());
|
||
QCOMPARE(r.needs().dailyDeadline(), QString("06:00"));
|
||
QCOMPARE(r.needs().minEnergyWhPerDay(), 4000);
|
||
|
||
// Validation : une config fixed sans 0 dans powerLevels est rejetée EN BLOC (rien persisté).
|
||
QVariantMap badEntry = entry;
|
||
badEntry["powerLevels"] = QVariantList() << 600 << 1200; // pas de 0
|
||
LoadConfigs bad;
|
||
bad.append(LoadConfig::fromMap(badEntry));
|
||
LoadConfigStore store3;
|
||
QString err2;
|
||
QVERIFY(!store3.setConfigs(bad, &err2));
|
||
QVERIFY(!err2.isEmpty());
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testLoadConfigBuildsAdapters()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testLoadConfigBuildsAdapters nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// SetConfigs → store.changed → l'arbitre (re)construit les adaptateurs etmvariableload.
|
||
// enabled==true servi par le surplus ; enabled==false JAMAIS piloté (exclu, contrat §9).
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arbitrator);
|
||
ThingManager *thingManager = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = thingManager->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QUuid id1 = addEtmVariableLoad(27020); // enabled
|
||
QUuid id2 = addEtmVariableLoad(27021); // disabled
|
||
Thing *t1 = thingManager->findConfiguredThing(id1);
|
||
Thing *t2 = thingManager->findConfiguredThing(id2);
|
||
QVERIFY(t1 && t2);
|
||
t1->setStateValue("currentPowerW", 0);
|
||
t2->setStateValue("currentPowerW", 0);
|
||
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-build.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
LoadConfigStore *store = new LoadConfigStore(arbitrator);
|
||
arbitrator->setLoadConfigStore(store);
|
||
|
||
LoadConfigs cfgs;
|
||
cfgs.append(LoadConfig::fromMap(QVariantMap{
|
||
{"id", id1.toString()}, {"label", "ECS actif"}, {"adapter", "etmvariableload"},
|
||
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 2400}, {"maxPowerW", 2400},
|
||
{"priority", 1}, {"enabled", true}}));
|
||
cfgs.append(LoadConfig::fromMap(QVariantMap{
|
||
{"id", id2.toString()}, {"label", "ECS désactivé"}, {"adapter", "etmvariableload"},
|
||
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 1200}, {"maxPowerW", 1200},
|
||
{"priority", 2}, {"enabled", false}}));
|
||
QString err;
|
||
QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8()); // persiste + changed → rebuild
|
||
|
||
// Surplus large (5000 W) : assez pour servir les DEUX si elles étaient actives.
|
||
meter->setStateValue("currentPower", -5000);
|
||
arbitrator->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
|
||
QCoreApplication::processEvents();
|
||
|
||
QCOMPARE(qRound(t1->stateValue("powerSetpoint").toDouble()), 2400); // enabled → servi
|
||
QCOMPARE(qRound(t2->stateValue("powerSetpoint").toDouble()), 0); // disabled → jamais piloté
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testLoadConfigRpc()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testLoadConfigRpc nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// End-to-end JSON-RPC (la couche que l'app consomme) : GetLoadConfig vide → SetLoadConfig
|
||
// (fixed réelle + dynamic) → energyError NoError → GetLoadConfig round-trip → effet RÉEL sur
|
||
// le thing (RPC → store → changed → arbitre reconstruit → adaptateur → powerSetpoint) →
|
||
// rejet d'une config invalide.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-rpc.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); // avant initTestCase → store sur ce chemin
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arbitrator);
|
||
ThingManager *thingManager = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = thingManager->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QUuid ecsId = addEtmVariableLoad(27030); // thing réel piloté par la config fixed
|
||
Thing *ecsThing = thingManager->findConfiguredThing(ecsId);
|
||
QVERIFY(ecsThing);
|
||
ecsThing->setStateValue("currentPowerW", 0);
|
||
|
||
// 1. GetLoadConfig initial → vide.
|
||
QVariant resp = injectAndWait("NymeaEnergy.GetLoadConfig");
|
||
QVERIFY(resp.toMap().value("params").toMap().value("loadConfigs").toList().isEmpty());
|
||
|
||
// 2. SetLoadConfig : les DEUX formes rév. 3 (mutuellement exclusives) doivent être acceptées
|
||
// par le MÊME schéma SET — sinon nymea rejette une forme avant le handler (bug objectRef
|
||
// strict de T4, doublé). Une etmvariableload fixed (thing réel) + une dynamic + une relay-router.
|
||
QVariantList loadConfigs;
|
||
loadConfigs << QVariantMap{
|
||
{"id", ecsId.toString()}, {"label", "Chauffe-eau"}, {"adapter", "etmvariableload"},
|
||
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 1200 << 2400}, {"maxPowerW", 2400},
|
||
{"priority", 2}, {"enabled", true},
|
||
{"needs", QVariantMap{{"dailyDeadline", "06:00"}, {"minEnergyWhPerDay", 4000}}}};
|
||
loadConfigs << QVariantMap{
|
||
{"id", "{b033b212-1adb-4df0-ba2b-8fa477de52a2}"}, {"label", "Routeur PV"},
|
||
{"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"maxPowerW", 3000},
|
||
{"priority", 1}, {"enabled", true}};
|
||
loadConfigs << QVariantMap{ // ← rév. 3 : relay-router (relays[])
|
||
{"id", "ecs-relais"}, {"label", "ECS relais"}, {"adapter", "relay-router"},
|
||
{"mode", "fixed"}, {"priority", 3}, {"enabled", true},
|
||
{"relays", QVariantList()
|
||
<< QVariantMap{{"thingId", "{aaaaaaaa-1111-2222-3333-444444444444}"}, {"powerW", 1000}}
|
||
<< QVariantMap{{"thingId", "{bbbbbbbb-1111-2222-3333-444444444444}"}, {"powerW", 1500}}},
|
||
{"minOnS", 60}, {"minOffS", 60}};
|
||
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", loadConfigs}});
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError"));
|
||
|
||
// 3. GetLoadConfig → round-trip des 3 entrées (les 2 formes), champs préservés (pack/unpack).
|
||
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
|
||
const QVariantList got = resp.toMap().value("params").toMap().value("loadConfigs").toList();
|
||
QCOMPARE(got.size(), 3);
|
||
QVariantMap fixedGot, relayGot;
|
||
for (const QVariant &v : got) {
|
||
const QVariantMap m = v.toMap();
|
||
if (m.value("id").toString() == ecsId.toString()) fixedGot = m;
|
||
if (m.value("adapter").toString() == "relay-router") relayGot = m;
|
||
}
|
||
QVERIFY(!fixedGot.isEmpty());
|
||
QCOMPARE(fixedGot.value("mode").toString(), QString("fixed"));
|
||
QCOMPARE(fixedGot.value("label").toString(), QString("Chauffe-eau"));
|
||
QVariantList levels = fixedGot.value("powerLevels").toList();
|
||
QCOMPARE(levels.size(), 3);
|
||
QCOMPARE(levels.last().toInt(), 2400);
|
||
QCOMPARE(fixedGot.value("needs").toMap().value("dailyDeadline").toString(), QString("06:00"));
|
||
// Forme relay-router : relays[] + minOnS round-trip (jamais piloté ici — RelayRouter en étape 4).
|
||
QVERIFY(!relayGot.isEmpty());
|
||
QVariantList relays = relayGot.value("relays").toList();
|
||
QCOMPARE(relays.size(), 2);
|
||
QCOMPARE(relays.last().toMap().value("powerW").toInt(), 1500);
|
||
QCOMPARE(relayGot.value("minOnS").toInt(), 60);
|
||
|
||
// 3bis. VA-ET-VIENT : réinjecter tel quel ce que GET vient de rendre. C'est le geste réel
|
||
// de l'app — lire, modifier un champ, réécrire — et il échouait : GET sérialise via le
|
||
// méta-objet, donc TOUTES les propriétés déclarées, y compris un « sgReady » VIDE sur une
|
||
// charge qui n'est pas SG-Ready. Le schéma SET exigeait « states » dès que la clé était
|
||
// présente, et nymea rejetait AVANT le handler. Constaté au banc le 2026-08-10, sur la
|
||
// première tentative réelle de SetLoadConfig — aucun test ne faisait ce va-et-vient.
|
||
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", got}});
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError"));
|
||
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("loadConfigs").toList().size(), 3);
|
||
|
||
// 4. Fichier persisté.
|
||
QVERIFY(QFileInfo::exists(cfgPath));
|
||
|
||
// 5. EFFET RÉEL : le Set RPC a reconstruit les adaptateurs → un cycle surplus pilote le thing.
|
||
// Cascade du waterfall unifié construit DEPUIS la config RPC : surplus 6000 → routeur dynamic
|
||
// (rang 1) prend 3000 (clamp maxPowerW), reliquat 3000 → ECS fixed (rang 2) prend le palier 2400.
|
||
meter->setStateValue("currentPower", -6000);
|
||
arbitrator->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
|
||
QCoreApplication::processEvents();
|
||
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 2400); // reliquat 3000 → palier 2400
|
||
|
||
// 6. Validation CONDITIONNELLE — rejet en bloc (energyError InvalidParameter) :
|
||
// (a) etmvariableload fixed sans 0 dans powerLevels ; (b) relay-router avec relays[] vide.
|
||
QVariantList badA;
|
||
badA << QVariantMap{
|
||
{"id", "{cccccccc-2222-3333-4444-555555555555}"}, {"label", "Bancal"}, {"adapter", "etmvariableload"},
|
||
{"mode", "fixed"}, {"powerLevels", QVariantList() << 1200 << 2400}, {"maxPowerW", 2400},
|
||
{"priority", 1}, {"enabled", true}};
|
||
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", badA}});
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorInvalidParameter"));
|
||
|
||
QVariantList badB;
|
||
badB << QVariantMap{
|
||
{"id", "ecs-vide"}, {"label", "ECS sans relais"}, {"adapter", "relay-router"},
|
||
{"mode", "fixed"}, {"priority", 1}, {"enabled", true},
|
||
{"relays", QVariantList()}}; // relays[] vide → invalide
|
||
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", badB}});
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorInvalidParameter"));
|
||
|
||
// Aucun rejet n'a écrasé : GetLoadConfig retourne toujours les 3 valides.
|
||
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("loadConfigs").toList().size(), 3);
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testSgReadySurplus()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testSgReadySurplus nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// Encodage SG-Ready 2 bits (K1,K2) : 1=[K1] blocage · 2=[] normal · 3=[K2] reco · 4=[K1,K2] forcé.
|
||
// estimatedPowerW déclaré : P3=1500, P4=3000. Hystérésis état 4 : entrée P4×1,2=3600, sortie P4×1,0=3000.
|
||
const QHash<int, double> pacPower({ {1, 0.0}, {2, 0.0}, {3, 1500.0}, {4, 3000.0} });
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
|
||
// ===================== Volets 1-3 : PAC seule =====================
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY2(arbitrator, "smartChargingManager n'est pas un EnergyArbitrator");
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QUuid k1 = addPowerSwitch(0, 26661);
|
||
QUuid k2 = addPowerSwitch(0, 26662);
|
||
Thing *relayK1 = tm->findConfiguredThing(k1);
|
||
Thing *relayK2 = tm->findConfiguredThing(k2);
|
||
QVERIFY(relayK1 && relayK2);
|
||
|
||
// [lot B-bis] La PAC est une charge de CONFIGURATION comme les autres — plus aucun
|
||
// adaptateur codé en dur, donc plus d'enregistrement direct : le test emprunte le chemin
|
||
// de production (SetConfigs → changed → rebuild → SgReadyAdapter).
|
||
const QString cfgPacPath = QDir::tempPath() + "/etm-loadcfg-sgready.json";
|
||
QFile::remove(cfgPacPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPacPath.toUtf8());
|
||
LoadConfigStore *storePac = new LoadConfigStore(arbitrator);
|
||
arbitrator->setLoadConfigStore(storePac);
|
||
|
||
LoadConfigs cfgsPac;
|
||
cfgsPac.append(LoadConfig::fromMap(sgReadyConfigMap("pac-test", "PAC test", k1, k2,
|
||
/*p3*/ 1500.0, /*p4*/ 3000.0,
|
||
/*minStateHoldS*/ 300, /*priority*/ 1)));
|
||
QString errPac;
|
||
QVERIFY2(storePac->setConfigs(cfgsPac, &errPac), errPac.toUtf8());
|
||
|
||
auto setMeterW = [&](double signedW){ meter->setStateValue("currentPower", signedW); }; // <0 export
|
||
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
|
||
// L'état de la PAC se lit désormais par la frontière publique — celle-là même que l'app
|
||
// consomme. Le test gagne au passage la vérification que la télémétrie suit l'arbitrage.
|
||
auto etatPac = [&](){ return mechanismInt(arbitrator, "pac-test", "state"); };
|
||
|
||
// --- Volet 0 : ARMEMENT À FROID du verrou (ECS-412 appliqué au minStateHold) ---
|
||
// Un adaptateur neuf ne sait PAS quand la PAC a commuté pour la dernière fois : un
|
||
// m_lastSwitch nul vaut « commutation venant d'avoir lieu », pas « jamais commuté ». Sans
|
||
// cela, un redémarrage de nymead pendant que le compresseur tourne autoriserait une coupure
|
||
// immédiate — du court-cycling provoqué par un redémarrage de service, exactement ce que
|
||
// minStateHold existe pour empêcher.
|
||
setMeterW(-3000); cycle(t0); // budget largement suffisant pour l'état 4…
|
||
QCOMPARE(etatPac(), 2); // …mais GELÉ par l'armement à froid
|
||
// L'armement est PARESSEUX : il expire. C'est le défaut d'ECS-412 qu'on refuse de refaire —
|
||
// un armement PERMANENT gelait toute charge à froid, sans issue (pas d'enclenchement → pas
|
||
// de commutation → pas d'estampille → blocage circulaire). L'estampille est posée au
|
||
// PREMIER cycle reçu, refusé ou non, si bien que le compte à rebours court dès maintenant.
|
||
const QDateTime tA = t0.addSecs(301); // > minStateHoldS (300) : le verrou a expiré
|
||
|
||
// --- Volet 1 : montée d'états 2 → 3 → 4 (mapping sémantique) ---
|
||
setMeterW(-1000); cycle(tA); // budget 1000 < P3 → état 2 (normal)
|
||
QCOMPARE(etatPac(), 2);
|
||
setMeterW(-2000); cycle(tA); // budget 2000 ≥ P3 → état 3 (reco)
|
||
QCOMPARE(etatPac(), 3);
|
||
QCOMPARE(relayK2->stateValue("power").toBool(), true);
|
||
QCOMPARE(relayK1->stateValue("power").toBool(), false);
|
||
setMeterW(-2500); cycle(tA.addSecs(400)); // budget 2500+1500=4000 ≥ P4×1,2 → état 4 (hold écoulé)
|
||
QCOMPARE(etatPac(), 4);
|
||
QCOMPARE(relayK1->stateValue("power").toBool(), true);
|
||
QCOMPARE(relayK2->stateValue("power").toBool(), true);
|
||
|
||
// --- Volet 2 : hystérésis 3↔4 (budget oscille dans la zone morte [P4×1,0 ; P4×1,2)) ---
|
||
// hold écoulé à chaque cycle (lastSwitch=tA+400) → c'est la ZONE MORTE qui tient l'état 4, pas le verrou.
|
||
setMeterW(-300); cycle(tA.addSecs(800)); // budget 300+3000=3300 ∈ [3000,3600) → reste 4
|
||
QCOMPARE(etatPac(), 4);
|
||
setMeterW(-100); cycle(tA.addSecs(1200)); // budget 3100 → reste 4
|
||
QCOMPARE(etatPac(), 4);
|
||
setMeterW(-500); cycle(tA.addSecs(1600)); // budget 3500 → reste 4
|
||
QCOMPARE(etatPac(), 4);
|
||
// En-dessous de P4×1,0 → sort enfin de l'état 4 (vers 3).
|
||
setMeterW(200); cycle(tA.addSecs(2000)); // import 200 → budget -200+3000=2800 < 3000 → état 3
|
||
QCOMPARE(etatPac(), 3);
|
||
|
||
// --- Volet 3 : protection court-cycling (changement avant minStateHold → GELÉ) ---
|
||
// lastSwitch=tA+2000. À tA+2100 (elapsed 100 < hold 300) : surplus abondant mais GELÉ en 3.
|
||
setMeterW(-3000); cycle(tA.addSecs(2100));
|
||
QCOMPARE(etatPac(), 3); // gelé malgré budget ≥ P4×1,2 (protection compresseur)
|
||
// À tA+2400 (elapsed 400 > hold) : MÊME surplus → bascule en 4. Seul le temps simulé a changé.
|
||
setMeterW(-3000); cycle(tA.addSecs(2400));
|
||
QCOMPARE(etatPac(), 4);
|
||
|
||
// ===================== Volet 4 : budget PARTAGÉ ECS(etmvariableload)↔PAC =====================
|
||
// [T3] Surplus 3000 W ; ECS palier 2400 W (etmvariableload, kind Setpoint), PAC P3 = 1500.
|
||
// Selon l'ordre de priorité, l'un se sert et l'autre voit le RELIQUAT → waterfall unifié
|
||
// (un seul budget cascade à travers etmvariableload ET sg-ready, triés par priorité).
|
||
auto runSharedBudget = [&](int ecsPrio, int pacPrio, double &ecsSetpointOut, int &pacStateOut) {
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm2 = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid mId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(mId);
|
||
Thing *m2 = tm2->findConfiguredThing(mId);
|
||
QVERIFY(m2);
|
||
m2->setStateValue("connected", true);
|
||
|
||
QUuid eId = addEtmVariableLoad(27003); // ECS etmvariableload
|
||
QUuid j1 = addPowerSwitch(0, 26661); // relais PAC K1
|
||
QUuid j2 = addPowerSwitch(0, 26662); // relais PAC K2
|
||
Thing *eThing = tm2->findConfiguredThing(eId);
|
||
QVERIFY(eThing);
|
||
eThing->setStateValue("currentPowerW", 0);
|
||
|
||
// LES DEUX charges viennent de la CONFIGURATION (lot B-bis) : c'est le chemin de
|
||
// production, et le seul depuis que la PAC n'a plus d'exemplaire codé en dur. Les
|
||
// mélanger — un adaptateur enregistré en direct + un store — ne marche pas, et c'est
|
||
// voulu : rebuildLoadAdapters() met en état sûr puis détruit tout ce que la config ne
|
||
// référence pas (ECS-413). Une charge « en dur » disparaîtrait au premier SetLoadConfig.
|
||
const QString cfgWfPath = QDir::tempPath() + "/etm-loadcfg-waterfall.json";
|
||
QFile::remove(cfgWfPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgWfPath.toUtf8());
|
||
LoadConfigStore *storeWf = new LoadConfigStore(arb);
|
||
arb->setLoadConfigStore(storeWf);
|
||
|
||
LoadConfigs cfgsWf;
|
||
// ECS : 1 palier à 2400 W (fixed). La charge ne tire encore rien (currentPowerW=0).
|
||
cfgsWf.append(LoadConfig::fromMap(QVariantMap{
|
||
{"id", eId.toString()}, {"label", "ECS waterfall"}, {"adapter", "etmvariableload"},
|
||
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 2400}, {"maxPowerW", 2400},
|
||
{"priority", ecsPrio}, {"enabled", true}}));
|
||
cfgsWf.append(LoadConfig::fromMap(sgReadyConfigMap("pac-wf", "PAC waterfall", j1, j2,
|
||
1500.0, 3000.0, 300, pacPrio)));
|
||
QString errWf;
|
||
QVERIFY2(storeWf->setConfigs(cfgsWf, &errWf), errWf.toUtf8());
|
||
|
||
m2->setStateValue("currentPower", -3000); // export 3000 W
|
||
// Cycle d'AMORÇAGE : il désarme le verrou à froid de la PAC (ECS-412) sans rien
|
||
// décider d'intéressant. La mesure se fait au cycle suivant, verrou expiré — sinon on
|
||
// mesurerait la protection compresseur au lieu de l'ordre de service.
|
||
arb->simulationCallUpdate(t0);
|
||
QCoreApplication::processEvents();
|
||
arb->simulationCallUpdate(t0.addSecs(301));
|
||
QCoreApplication::processEvents();
|
||
ecsSetpointOut = mechanismInt(arb, eId.toString(), "setpointW");
|
||
pacStateOut = mechanismInt(arb, "pac-wf", "state");
|
||
};
|
||
|
||
double ecsSetpoint = -1;
|
||
int pacState = -1;
|
||
|
||
// ECS prioritaire (rang 1) : ECS se sert (2400) → reliquat 600 < P3 → PAC reste NORMAL (2).
|
||
runSharedBudget(/*ecsPrio*/ 1, /*pacPrio*/ 2, ecsSetpoint, pacState);
|
||
QCOMPARE(qRound(ecsSetpoint), 2400);
|
||
QCOMPARE(pacState, 2);
|
||
|
||
// Priorités INVERSÉES — PAC prioritaire (rang 1) : PAC se sert (état 3, 1500) → reliquat
|
||
// 1500 < 2400 → l'ECS reste à 0 W. L'ordre de service s'inverse (même budget unique).
|
||
runSharedBudget(/*ecsPrio*/ 2, /*pacPrio*/ 1, ecsSetpoint, pacState);
|
||
QCOMPARE(qRound(ecsSetpoint), 0);
|
||
QCOMPARE(pacState, 3);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testEcsRelayTopologies()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testEcsRelayTopologies nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [rév. 3] La combinatoire watts→relais vit DANS le RelayRouter (couche routeur, frontière
|
||
// déplacée). On teste directement applyAction(Setpoint W) : paliers DÉRIVÉS, arrondi à la
|
||
// combinaison ≤ setpoint, off-before-on non-cascadé, et DÉDUPLICATION des niveaux.
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
auto sp = [&](double w) {
|
||
LoadAction a;
|
||
a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus;
|
||
// Le motif est un CODE : ce test ne porte pas sur le texte, seulement sur la
|
||
// non-vacuité exigée par la règle 7 (une action au code vide est rejetée).
|
||
a.powerW = w; a.reason = { DecisionCode::SurplusSetpoint };
|
||
return a;
|
||
};
|
||
// Les deux sorties sont initialisées par l'APPELANT et vérifiées APRÈS l'appel : un
|
||
// QVERIFY en échec sort de CE lambda, pas du test, et laisserait sinon des pointeurs
|
||
// non initialisés déréférencés juste après — comportement indéfini au lieu d'un échec
|
||
// de test lisible.
|
||
auto freshSetup = [&](ThingManager *&tm, QObject *&owner) {
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
tm = NymeaCore::instance()->thingManager();
|
||
owner = arb;
|
||
};
|
||
|
||
// ===================== Topologie 1 : 1 relais (dégénéré [0, 2000]) =====================
|
||
{
|
||
ThingManager *tm = nullptr; QObject *owner = nullptr;
|
||
freshSetup(tm, owner);
|
||
QVERIFY(tm && owner);
|
||
QUuid r = addPowerSwitch(2000, 26661);
|
||
Thing *relay = tm->findConfiguredThing(r);
|
||
QVERIFY(relay);
|
||
RelayRouter *ecs = new RelayRouter(tm, "ecs-1", "ECS 1 relais",
|
||
QList<LoadConfigRelay>({ {r.toString(), 2000} }), 0, 0, 1, LoadNeeds(), owner);
|
||
QCOMPARE(ecs->descriptor().declared.powerLevels, QList<int>({0, 2000}));
|
||
|
||
ecs->applyAction(sp(2500), t0); // 2500 → palier 2000
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 2000);
|
||
QCOMPARE(relay->stateValue("power").toBool(), true);
|
||
ecs->applyAction(sp(1000), t0.addSecs(1)); // 1000 < 2000 → 0
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
|
||
QCOMPARE(relay->stateValue("power").toBool(), false);
|
||
}
|
||
|
||
// ============= Topologie 2 : 3 relais 500/1000/2000 (NON-CASCADÉ, off-before-on) =============
|
||
{
|
||
ThingManager *tm = nullptr; QObject *owner = nullptr;
|
||
freshSetup(tm, owner);
|
||
QVERIFY(tm && owner);
|
||
QUuid r500 = addPowerSwitch(500, 26661);
|
||
QUuid r1000 = addPowerSwitch(1000, 26662);
|
||
QUuid r2000 = addPowerSwitch(2000, 26663);
|
||
Thing *t500 = tm->findConfiguredThing(r500);
|
||
Thing *t1000 = tm->findConfiguredThing(r1000);
|
||
Thing *t2000 = tm->findConfiguredThing(r2000);
|
||
QVERIFY(t500 && t1000 && t2000);
|
||
RelayRouter *ecs = new RelayRouter(tm, "ecs-3", "ECS 3 relais",
|
||
QList<LoadConfigRelay>({ {r500.toString(), 500}, {r1000.toString(), 1000}, {r2000.toString(), 2000} }),
|
||
0, 0, 1, LoadNeeds(), owner);
|
||
// 8 niveaux dérivés (2^3 combinaisons toutes distinctes).
|
||
QCOMPARE(ecs->descriptor().declared.powerLevels, QList<int>({0, 500, 1000, 1500, 2000, 2500, 3000, 3500}));
|
||
|
||
// 1700 → palier 1500 = {r500, r1000} (r2000 OFF).
|
||
ecs->applyAction(sp(1700), t0);
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 1500);
|
||
QCOMPARE(t500->stateValue("power").toBool(), true);
|
||
QCOMPARE(t1000->stateValue("power").toBool(), true);
|
||
QCOMPARE(t2000->stateValue("power").toBool(), false);
|
||
|
||
// Transition NON-CASCADÉE 1500 → 2000 = {r2000} SEUL : commute 3 relais (off-before-on).
|
||
ecs->applyAction(sp(2000), t0.addSecs(1));
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 2000);
|
||
QCOMPARE(t500->stateValue("power").toBool(), false);
|
||
QCOMPARE(t1000->stateValue("power").toBool(), false);
|
||
QCOMPARE(t2000->stateValue("power").toBool(), true);
|
||
}
|
||
|
||
// ============= Topologie 3 : DÉDUPLICATION (deux relais identiques 1000 W) =============
|
||
{
|
||
ThingManager *tm = nullptr; QObject *owner = nullptr;
|
||
freshSetup(tm, owner);
|
||
QVERIFY(tm && owner);
|
||
QUuid rA = addPowerSwitch(1000, 26661);
|
||
QUuid rB = addPowerSwitch(1000, 26662);
|
||
Thing *tA = tm->findConfiguredThing(rA);
|
||
Thing *tB = tm->findConfiguredThing(rB);
|
||
QVERIFY(tA && tB);
|
||
RelayRouter *ecs = new RelayRouter(tm, "ecs-dup", "ECS dédup",
|
||
QList<LoadConfigRelay>({ {rA.toString(), 1000}, {rB.toString(), 1000} }), 0, 0, 1, LoadNeeds(), owner);
|
||
// 4 combinaisons MAIS deux donnent 1000 W → FUSIONNÉES : 3 niveaux, pas 4.
|
||
QCOMPARE(ecs->descriptor().declared.powerLevels, QList<int>({0, 1000, 2000}));
|
||
|
||
// 1200 → palier 1000 = UN SEUL relais (le premier de la combinaison dédupliquée).
|
||
ecs->applyAction(sp(1200), t0);
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 1000);
|
||
QCOMPARE(tA->stateValue("power").toBool(), true);
|
||
QCOMPARE(tB->stateValue("power").toBool(), false);
|
||
// 2200 → palier 2000 = les deux.
|
||
ecs->applyAction(sp(2200), t0.addSecs(1));
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 2000);
|
||
QCOMPARE(tA->stateValue("power").toBool(), true);
|
||
QCOMPARE(tB->stateValue("power").toBool(), true);
|
||
}
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testEnergyRatiosAlignment()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("ETM_ARBITRATOR désactivé — ratios canoniques non compilés.");
|
||
#else
|
||
// Vecteurs joués 1:1 contre la sémantique du seam interim app
|
||
// (EnergyRatiosInterim.compute) : seed, normal, clamp-bas, den≤0→n/a,
|
||
// non-monotone (Δ<0)→reseed, nouveau jour local→reseed.
|
||
EnergyRatiosCalculator calc;
|
||
|
||
auto naAuto = [](const EnergyRatiosCalculator::Ratios &r) { QVERIFY(!r.autoconsommationValid); };
|
||
auto naAuton = [](const EnergyRatiosCalculator::Ratios &r) { QVERIFY(!r.autonomieValid); };
|
||
auto eqAuto = [](const EnergyRatiosCalculator::Ratios &r, double v) {
|
||
QVERIFY(r.autoconsommationValid); QVERIFY(qAbs(r.autoconsommation - v) < 1e-3);
|
||
};
|
||
auto eqAuton = [](const EnergyRatiosCalculator::Ratios &r, double v) {
|
||
QVERIFY(r.autonomieValid); QVERIFY(qAbs(r.autonomie - v) < 1e-3);
|
||
};
|
||
|
||
const QDateTime d29_00(QDate(2026, 6, 29), QTime(0, 0));
|
||
const QDateTime d29_06(QDate(2026, 6, 29), QTime(6, 0));
|
||
const QDateTime d29_08(QDate(2026, 6, 29), QTime(8, 0));
|
||
const QDateTime d29_10(QDate(2026, 6, 29), QTime(10, 0));
|
||
const QDateTime d30_02(QDate(2026, 6, 30), QTime(2, 0));
|
||
const QDateTime d30_10(QDate(2026, 6, 30), QTime(10, 0));
|
||
|
||
// A — 1er appel : seed baseline → deltas nuls → den≤0 → n/a (les deux).
|
||
EnergyRatiosCalculator::Ratios a = calc.compute(1000, 200, 3000, 2200, d29_00);
|
||
naAuto(a); naAuton(a);
|
||
|
||
// B — même jour, croissance monotone : auto=(500-100)/500=80%, autonomie=(1000-500)/1000=50%.
|
||
EnergyRatiosCalculator::Ratios b = calc.compute(1500, 300, 4000, 2700, d29_06);
|
||
eqAuto(b, 80.0); eqAuton(b, 50.0);
|
||
|
||
// C — clamp bas : dReturn(700) > dProd(600) → num<0 → auto borné à 0.0 ;
|
||
// autonomie=(1200-900)/1200=25%.
|
||
EnergyRatiosCalculator::Ratios c = calc.compute(1600, 900, 4200, 3100, d29_08);
|
||
eqAuto(c, 0.0); eqAuton(c, 25.0);
|
||
|
||
// D — compteur non monotone (production 900 < base 1000) → reseed → n/a.
|
||
EnergyRatiosCalculator::Ratios d = calc.compute(900, 900, 4200, 3100, d29_10);
|
||
naAuto(d); naAuton(d);
|
||
|
||
// E — nouveau jour local → reseed → n/a (même si cumuls croissants).
|
||
EnergyRatiosCalculator::Ratios e = calc.compute(2000, 500, 6000, 4000, d30_02);
|
||
naAuto(e); naAuton(e);
|
||
|
||
// F — même jour (30) : auto=(600-200)/600≈66.667%, autonomie=(1000-500)/1000=50%.
|
||
EnergyRatiosCalculator::Ratios f = calc.compute(2600, 700, 7000, 4500, d30_10);
|
||
eqAuto(f, 66.6667); eqAuton(f, 50.0);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testLoadConfigRelayRouter()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testLoadConfigRelayRouter nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// Chaîne COMPLÈTE rév. 3 : SetConfigs(relays[]) → store.changed → rebuild construit un
|
||
// RelayRouter → cycle surplus → arrondi scheduler → routeur → commutation relais → currentPowerW.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arbitrator);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QUuid rA = addPowerSwitch(1000, 26661);
|
||
QUuid rB = addPowerSwitch(1500, 26662);
|
||
Thing *relayA = tm->findConfiguredThing(rA);
|
||
Thing *relayB = tm->findConfiguredThing(rB);
|
||
QVERIFY(relayA && relayB);
|
||
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-relayrouter.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
LoadConfigStore *store = new LoadConfigStore(arbitrator);
|
||
arbitrator->setLoadConfigStore(store);
|
||
|
||
LoadConfigs cfgs;
|
||
cfgs.append(LoadConfig::fromMap(QVariantMap{
|
||
{"id", "ecs-relais"}, {"label", "ECS relais"}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", 1}, {"enabled", true},
|
||
{"relays", QVariantList()
|
||
<< QVariantMap{{"thingId", rA.toString()}, {"powerW", 1000}}
|
||
<< QVariantMap{{"thingId", rB.toString()}, {"powerW", 1500}}},
|
||
{"minOnS", 0}, {"minOffS", 0}}));
|
||
QString err;
|
||
QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8()); // persiste + changed → rebuild → RelayRouter
|
||
|
||
// Surplus 2500 → paliers dérivés [0,1000,1500,2500] → palier 2500 (rA+rB) → les deux ON.
|
||
meter->setStateValue("currentPower", -2500);
|
||
arbitrator->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
|
||
QCoreApplication::processEvents();
|
||
QCOMPARE(relayA->stateValue("power").toBool(), true);
|
||
QCOMPARE(relayB->stateValue("power").toBool(), true);
|
||
// currentPowerW remonte (mock : relais ON → currentPower = nominal) → source du recrédit.
|
||
QCOMPARE(qRound(relayA->stateValue("currentPower").toDouble()), 1000);
|
||
QCOMPARE(qRound(relayB->stateValue("currentPower").toDouble()), 1500);
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testEcsBudgetUnderLock()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testEcsBudgetUnderLock nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [ECS-306] Sous verrou minOn, l'adaptateur maintient un palier au-dessus du budget.
|
||
// Le scheduler DOIT décrémenter le budget de ce palier réel — sinon la charge de
|
||
// priorité suivante reçoit un résidu surestimé et l'installation soutire au réseau.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid rA = addPowerSwitch(2000, 26661); // charge 1, prioritaire, verrouillée minOn
|
||
QUuid rB = addPowerSwitch(1000, 26662); // charge 2, servie sur le résidu
|
||
Thing *tA = tm->findConfiguredThing(rA);
|
||
Thing *tB = tm->findConfiguredThing(rB);
|
||
QVERIFY(tA && tB);
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
|
||
RelayRouter *l1 = new RelayRouter(tm, "ecs-1", "ECS prioritaire",
|
||
QList<LoadConfigRelay>({ {rA.toString(), 2000} }), 300, 0, 1, LoadNeeds(), arb);
|
||
RelayRouter *l2 = new RelayRouter(tm, "ecs-2", "ECS secondaire",
|
||
QList<LoadConfigRelay>({ {rB.toString(), 1000} }), 0, 0, 2, LoadNeeds(), arb);
|
||
arb->registerRelayRouter(l1);
|
||
arb->registerRelayRouter(l2);
|
||
|
||
auto sp = [&](double w) {
|
||
LoadAction a; a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus;
|
||
a.powerW = w; a.reason = { DecisionCode::SurplusSetpoint }; return a;
|
||
};
|
||
|
||
// Amorçage : la charge 1 monte à 2000 W et arme son verrou minOn (300 s).
|
||
l1->applyAction(sp(2500), t0);
|
||
QCOMPARE(qRound(l1->currentSetpointW()), 2000);
|
||
|
||
// 60 s plus tard : minOn non écoulé → la fenêtre exposée impose un PLANCHER de 2000 W.
|
||
const QDateTime t1 = t0.addSecs(60);
|
||
LoadContext c1 = l1->toLoadContext(t1);
|
||
QCOMPARE(qRound(c1.telemetry.lockMinPowerW), 2000); // le canal ECS-306 existe…
|
||
QVERIFY(c1.telemetry.lockMaxPowerW >= c1.telemetry.lockMinPowerW);
|
||
|
||
// …et il est HONORÉ : avec un budget de 500 W, la charge 1 reste écrêtée à 2000 W et le
|
||
// résidu passé à la charge suivante est NÉGATIF, donc la charge 2 reste à 0.
|
||
RuleBasedScheduler sched(arb, nullptr);
|
||
SurplusContext ctx;
|
||
ctx.timestamp = t1;
|
||
ctx.meter.exportW = 500;
|
||
ctx.meter.importW = 0;
|
||
ctx.loads.append(c1);
|
||
ctx.loads.append(l2->toLoadContext(t1));
|
||
|
||
Plan plan = sched.getPlan(ctx);
|
||
Slot slot = plan.slotCovering(t1);
|
||
double a1 = -1, a2 = -1;
|
||
for (const LoadAction &a : slot.actions) {
|
||
if (a.loadId == "ecs-1") a1 = a.powerW;
|
||
if (a.loadId == "ecs-2") a2 = a.powerW;
|
||
}
|
||
QCOMPARE(qRound(a1), 2000); // maintenu par le verrou, au-dessus du budget
|
||
QCOMPARE(qRound(a2), 0); // le résidu tient compte des 2000 W engagés
|
||
QVERIFY2(!slot.actions.isEmpty(), "aucune action produite");
|
||
#endif
|
||
}
|
||
|
||
// [ECS-411-b] Collecteur de messages, pour asserter sur ce que le journal DIT. Sans lui,
|
||
// on ne pourrait tester que le palier déduit — or le défaut visé est précisément qu'un
|
||
// palier juste était annoncé par un silence.
|
||
static QStringList s_journal;
|
||
static QtMessageHandler s_handlerPrecedent = nullptr;
|
||
static void collecterJournal(QtMsgType t, const QMessageLogContext &c, const QString &m)
|
||
{
|
||
s_journal << m;
|
||
if (s_handlerPrecedent)
|
||
s_handlerPrecedent(t, c, m);
|
||
}
|
||
|
||
void Simulation::testEcsRestartRecovery()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testEcsRestartRecovery nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [ECS-411] Des relais déjà fermés au démarrage → le palier courant est DÉDUIT, pas
|
||
// remis à 0. Sinon le moteur croit 0 W pendant que le ballon tire sa puissance.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid r500 = addPowerSwitch(500, 26661);
|
||
QUuid r1000 = addPowerSwitch(1000, 26662);
|
||
QUuid r1500 = addPowerSwitch(1500, 26663);
|
||
Thing *t500 = tm->findConfiguredThing(r500);
|
||
Thing *t1000 = tm->findConfiguredThing(r1000);
|
||
Thing *t1500 = tm->findConfiguredThing(r1500);
|
||
QVERIFY(t500 && t1000 && t1500);
|
||
|
||
const QList<LoadConfigRelay> relays({ {r500.toString(), 500},
|
||
{r1000.toString(), 1000},
|
||
{r1500.toString(), 1500} });
|
||
|
||
// Cas 1 — tous ouverts : palier 0, comportement inchangé.
|
||
// [ECS-411-b] Et la reprise DOIT s'annoncer quand même. Jusqu'au 2026-08-13 la
|
||
// correspondance exacte ne journalisait que si le palier était non nul (`if (i > 0)`),
|
||
// si bien qu'au palier 0 — le cas de l'armement minOff, celui qui compte — le journal
|
||
// était muet. Un silence ne distingue pas « relais lus, tous ouverts » de « reprise
|
||
// non exécutée ». Le test porte sur le TEXTE, comme pour ECS-309.
|
||
{
|
||
t500->setStateValue("power", false);
|
||
t1000->setStateValue("power", false);
|
||
t1500->setStateValue("power", false);
|
||
s_journal.clear();
|
||
s_handlerPrecedent = qInstallMessageHandler(collecterJournal);
|
||
RelayRouter *r = new RelayRouter(tm, "ecs-off", "ECS éteint", relays, 0, 0, 1, LoadNeeds(), arb);
|
||
qInstallMessageHandler(s_handlerPrecedent);
|
||
QCOMPARE(r->currentStage(), 0);
|
||
QCOMPARE(qRound(r->currentSetpointW()), 0);
|
||
|
||
const QString repr = s_journal.filter(QStringLiteral("reprise au démarrage")).value(0);
|
||
QVERIFY2(!repr.isEmpty(), "ECS-411-b : aucune reprise annoncée au palier 0");
|
||
QVERIFY2(repr.contains(QStringLiteral("palier 0 W")), qUtf8Printable(repr));
|
||
// Ce qui a été LU, pas seulement ce qui en est déduit : les trois relais nommés,
|
||
// chacun avec sa puissance et son état. C'est la seconde moitié d'ECS-411-b.
|
||
QVERIFY2(repr.contains(QStringLiteral("500W)=ouvert")), qUtf8Printable(repr));
|
||
QVERIFY2(repr.contains(QStringLiteral("1000W)=ouvert")), qUtf8Printable(repr));
|
||
QVERIFY2(repr.contains(QStringLiteral("1500W)=ouvert")), qUtf8Printable(repr));
|
||
QVERIFY2(repr.contains(QStringLiteral("somme lue 0 W")), qUtf8Printable(repr));
|
||
QVERIFY2(!repr.contains(QStringLiteral("INJOIGNABLE")), qUtf8Printable(repr));
|
||
}
|
||
|
||
// Cas 1-bis — [ECS-411-b] un relais INJOIGNABLE, supposé fermé. Le palier annoncé
|
||
// peut être le même qu'un cas nominal ; c'est le relevé qui doit lever le doute, et
|
||
// le niveau qui doit passer en avertissement. Sans ça, « palier repris : 0 » couvre
|
||
// deux situations appelant des gestes opposés.
|
||
{
|
||
t500->setStateValue("power", false);
|
||
t1000->setStateValue("power", false);
|
||
t1500->setStateValue("power", false);
|
||
// Un thingId qui ne correspond à aucun Thing configuré : branche « absent ».
|
||
const QList<LoadConfigRelay> avecFantome({
|
||
{r500.toString(), 500},
|
||
{QUuid("{deadbeef-0000-4000-8000-000000000001}").toString(), 1000} });
|
||
s_journal.clear();
|
||
s_handlerPrecedent = qInstallMessageHandler(collecterJournal);
|
||
RelayRouter *rf = new RelayRouter(tm, "ecs-fantome", "ECS relais absent",
|
||
avecFantome, 0, 0, 1, LoadNeeds(), arb);
|
||
qInstallMessageHandler(s_handlerPrecedent);
|
||
Q_UNUSED(rf)
|
||
|
||
const QString repr = s_journal.filter(QStringLiteral("reprise au démarrage")).value(0);
|
||
QVERIFY2(!repr.isEmpty(), "ECS-411-b : aucune reprise annoncée avec relais absent");
|
||
QVERIFY2(repr.contains(QStringLiteral("INJOIGNABLE")), qUtf8Printable(repr));
|
||
QVERIFY2(repr.contains(QStringLiteral("supposé fermé")), qUtf8Printable(repr));
|
||
QVERIFY2(repr.contains(QStringLiteral("INCERTAIN")), qUtf8Printable(repr));
|
||
// Le relais lisible reste annoncé comme lu, et non noyé dans l'incertitude.
|
||
QVERIFY2(repr.contains(QStringLiteral("500W)=ouvert")), qUtf8Printable(repr));
|
||
}
|
||
|
||
// Cas 1-ter — [ECS-411-b] le relais dont l'encodage SOLO a été évincé par la
|
||
// déduplication. Avec 500/1000/1500, le palier 1500 est encodé {500,1000} (masque 3,
|
||
// rencontré avant le masque 4), donc R1500 n'apparaît seul dans AUCUN encodage. Sa
|
||
// nominale était alors redéduite à 0, et un démarrage sur ce seul relais donnait
|
||
// sumW = 0 → palier 0 annoncé pendant que 1500 W circulaient. C'est l'annonce en
|
||
// sous-estimation qu'ECS-411 interdit, et le silence au palier 0 la couvrait.
|
||
// Trouvé par le relevé d'ECS-411-b, qui affichait « (0W) » pour ce relais.
|
||
{
|
||
t500->setStateValue("power", false);
|
||
t1000->setStateValue("power", false);
|
||
t1500->setStateValue("power", true); // SEUL R1500 fermé
|
||
s_journal.clear();
|
||
s_handlerPrecedent = qInstallMessageHandler(collecterJournal);
|
||
RelayRouter *r = new RelayRouter(tm, "ecs-dedup", "ECS dédup", relays, 0, 0, 1, LoadNeeds(), arb);
|
||
qInstallMessageHandler(s_handlerPrecedent);
|
||
|
||
QCOMPARE(qRound(r->currentSetpointW()), 1500); // JAMAIS moins que ce qui circule
|
||
const QString repr = s_journal.filter(QStringLiteral("reprise au démarrage")).value(0);
|
||
QVERIFY2(!repr.isEmpty(), "ECS-411-b : aucune reprise annoncée");
|
||
QVERIFY2(repr.contains(QStringLiteral("palier 1500 W")), qUtf8Printable(repr));
|
||
QVERIFY2(repr.contains(QStringLiteral("1500W)=fermé")), qUtf8Printable(repr));
|
||
QVERIFY2(repr.contains(QStringLiteral("somme lue 1500 W")), qUtf8Printable(repr));
|
||
}
|
||
|
||
// Cas 2 — R1000 fermé au démarrage : le routeur DOIT repartir à 1000 W, pas à 0.
|
||
{
|
||
t500->setStateValue("power", false);
|
||
t1000->setStateValue("power", true);
|
||
t1500->setStateValue("power", false);
|
||
RelayRouter *r = new RelayRouter(tm, "ecs-1000", "ECS repris", relays, 0, 0, 1, LoadNeeds(), arb);
|
||
QCOMPARE(qRound(r->currentSetpointW()), 1000);
|
||
}
|
||
|
||
// Cas 3 — R500 + R1500 fermés : 2000 W, combinaison à deux relais correctement reconnue.
|
||
{
|
||
t500->setStateValue("power", true);
|
||
t1000->setStateValue("power", false);
|
||
t1500->setStateValue("power", true);
|
||
RelayRouter *r = new RelayRouter(tm, "ecs-2000", "ECS repris 2000", relays, 0, 0, 1, LoadNeeds(), arb);
|
||
QCOMPARE(qRound(r->currentSetpointW()), 2000);
|
||
}
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testEcsRebuildPreservesLock()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testEcsRebuildPreservesLock nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [ECS-412] SetLoadConfig PENDANT une fenêtre de verrou active : un changement de rang
|
||
// ne doit pas détruire l'adaptateur, donc ne doit pas réarmer le verrou. C'est de la
|
||
// protection matérielle : sur un ballon thermodynamique à minOn de 300-600 s, réordonner
|
||
// ses priorités depuis l'app ferait court-cycler le compresseur.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QUuid rA = addPowerSwitch(2000, 26661);
|
||
Thing *tA = tm->findConfiguredThing(rA);
|
||
QVERIFY(tA);
|
||
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-rebuildlock.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
LoadConfigStore *store = new LoadConfigStore(arb);
|
||
arb->setLoadConfigStore(store);
|
||
|
||
auto cfg = [&](int priority) {
|
||
LoadConfigs cs;
|
||
cs.append(LoadConfig::fromMap(QVariantMap{
|
||
{"id", "ecs-verrou"}, {"label", "ECS verrouillé"}, {"adapter", "relay-router"},
|
||
{"mode", "fixed"}, {"priority", priority}, {"enabled", true},
|
||
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, {"powerW", 2000}}},
|
||
{"minOnS", 300}, {"minOffS", 0}}));
|
||
return cs;
|
||
};
|
||
|
||
QString err;
|
||
QVERIFY2(store->setConfigs(cfg(1), &err), err.toUtf8());
|
||
|
||
// Cycle 1 : surplus large → le relais se ferme, le verrou minOn s'arme.
|
||
meter->setStateValue("currentPower", -2500);
|
||
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
|
||
QCoreApplication::processEvents();
|
||
QCOMPARE(tA->stateValue("power").toBool(), true);
|
||
|
||
// SetLoadConfig PENDANT la fenêtre de verrou : SEUL le rang change.
|
||
QVERIFY2(store->setConfigs(cfg(2), &err), err.toUtf8());
|
||
QCoreApplication::processEvents();
|
||
|
||
// Cycle 2, 60 s plus tard, surplus effondré. Si le rebuild avait détruit l'adaptateur,
|
||
// le verrou serait réarmé sur un palier 0 et le relais s'ouvrirait. Il doit RESTER fermé :
|
||
// minOn court toujours depuis le cycle 1.
|
||
meter->setStateValue("currentPower", 100); // import : budget négatif
|
||
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 1, 0)));
|
||
QCoreApplication::processEvents();
|
||
QCOMPARE(tA->stateValue("power").toBool(), true);
|
||
|
||
// Au-delà de minOn, le délestage reprend normalement.
|
||
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 6, 0)));
|
||
QCoreApplication::processEvents();
|
||
QCOMPARE(tA->stateValue("power").toBool(), false);
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testEcsColdStartLockExpires()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testEcsColdStartLockExpires nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [ECS-412] L'armement du verrou au démarrage à froid doit être TRANSITOIRE.
|
||
//
|
||
// Défaut corrigé : un m_lastSwitch nul servait de sentinelle « elapsed = 0 » à CHAQUE
|
||
// cycle. Une charge démarrant au palier 0 avec minOffS > 0 ne pouvait donc jamais
|
||
// s'enclencher — donc jamais commuter, donc jamais valider m_lastSwitch : blocage
|
||
// circulaire. Constaté au banc le 2026-08-09, une charge sonde restant à 0 W sous
|
||
// 4 kW de surplus disponible. Aucun test ne combinait « palier 0 au départ » et
|
||
// « minOffS > 0 » : c'était exactement le trou.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid rA = addPowerSwitch(1000, 26661);
|
||
Thing *tA = tm->findConfiguredThing(rA);
|
||
QVERIFY(tA);
|
||
tA->setStateValue("power", false); // départ RELAIS OUVERT → palier 0
|
||
|
||
const int minOff = 120;
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
|
||
RelayRouter *r = new RelayRouter(tm, "froid", "Charge à froid",
|
||
QList<LoadConfigRelay>({ {rA.toString(), 1000} }), 0, minOff, 1, LoadNeeds(), arb);
|
||
QCOMPARE(r->currentStage(), 0); // ECS-411 : rien de fermé → palier 0
|
||
|
||
auto sp = [&](double w, const QDateTime &at) {
|
||
LoadAction a; a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus;
|
||
a.powerW = w; a.reason = { DecisionCode::SurplusSetpoint };
|
||
return r->applyAction(a, at);
|
||
};
|
||
|
||
// Budget LARGEMENT suffisant dès le premier cycle : seul le verrou peut retenir.
|
||
// Pendant minOff, la charge DOIT rester éteinte — c'est la protection voulue.
|
||
sp(5000, t0);
|
||
QCOMPARE(qRound(r->currentSetpointW()), 0);
|
||
QCOMPARE(tA->stateValue("power").toBool(), false);
|
||
|
||
sp(5000, t0.addSecs(minOff - 1)); // encore dans la fenêtre
|
||
QCOMPARE(qRound(r->currentSetpointW()), 0);
|
||
|
||
// …et APRÈS minOff, elle DOIT s'enclencher. C'est la moitié que le défaut supprimait :
|
||
// le verrou ne doit pas survivre à sa propre durée.
|
||
sp(5000, t0.addSecs(minOff + 1));
|
||
QCOMPARE(qRound(r->currentSetpointW()), 1000);
|
||
QCOMPARE(tA->stateValue("power").toBool(), true);
|
||
|
||
// La fenêtre exposée au scheduler (ECS-306) suit la même expiration : plafond nul
|
||
// pendant le verrou, plafond réel ensuite.
|
||
// Relais REMIS OUVERT avant construction : sans quoi ECS-411 déduirait un palier non
|
||
// nul et ce serait minOn, pas minOff, qui s'armerait — on ne testerait pas le cas visé.
|
||
tA->setStateValue("power", false);
|
||
RelayRouter *r2 = new RelayRouter(tm, "froid2", "Charge à froid 2",
|
||
QList<LoadConfigRelay>({ {rA.toString(), 1000} }), 0, minOff, 1, LoadNeeds(), arb);
|
||
QCOMPARE(r2->currentStage(), 0);
|
||
LoadContext c0 = r2->toLoadContext(t0);
|
||
QCOMPARE(qRound(c0.telemetry.lockMaxPowerW), 0); // armé au premier cycle
|
||
|
||
// [ECS-309] Le MOTIF doit nommer le mécanisme réel. Ce test porte sur ce que le texte
|
||
// dit, pas sur sa non-vacuité : c'est exactement ce qu'un « motif non vide » laissait
|
||
// passer. Défaut constaté au banc le 2026-08-09 — palier plafonné à 0 avec 7706 W de
|
||
// surplus, et « Surplus insuffisant (7706 W) » publié. La décision était juste, le
|
||
// motif accusait le budget alors que la cause était l'armement minOff.
|
||
RuleBasedScheduler sched(arb);
|
||
auto motifPour = [&](RelayRouter *routeur, double surplusW, const QDateTime &at) {
|
||
SurplusContext ctx;
|
||
ctx.timestamp = at;
|
||
// Surplus net SIGNÉ : positif = export, négatif = soutirage réseau.
|
||
ctx.meter.exportW = surplusW > 0 ? surplusW : 0.0;
|
||
ctx.meter.importW = surplusW < 0 ? -surplusW : 0.0;
|
||
ctx.loads.append(routeur->toLoadContext(at));
|
||
const Plan plan = sched.getPlan(ctx);
|
||
for (const LoadAction &a : plan.timeSlots.first().actions)
|
||
if (a.loadId == routeur->descriptor().id)
|
||
// La ligne du JOURNAL, RENDUE depuis le code — c'est elle que les assertions
|
||
// ci-dessous inspectent. Le test porte donc toujours sur le TEXTE PUBLIÉ, et il
|
||
// prouve en même temps que la refonte {code, params} ne l'a pas déplacé.
|
||
return renderFr(a.reason, routeur->descriptor().label);
|
||
return QString();
|
||
};
|
||
|
||
// 1. Verrou minOff : budget PLÉTHORIQUE, palier refusé. Le motif nomme minOff et ne
|
||
// met pas l'issue sur le compte du budget.
|
||
const QString mOff = motifPour(r2, 7706, t0);
|
||
QVERIFY2(mOff.contains("minOff"), qUtf8Printable("motif: " + mOff));
|
||
QVERIFY2(!mOff.contains("insuffisant", Qt::CaseInsensitive), qUtf8Printable("motif: " + mOff));
|
||
|
||
// 2. Budget RÉELLEMENT insuffisant, hors verrou : là, le budget est la bonne explication.
|
||
tA->setStateValue("power", false);
|
||
RelayRouter *r3 = new RelayRouter(tm, "froid3", "Charge à froid 3",
|
||
QList<LoadConfigRelay>({ {rA.toString(), 1000} }), 0, 0, 1, LoadNeeds(), arb);
|
||
const QString mBudget = motifPour(r3, 200, t0); // 200 W < palier 1000 W
|
||
// §12 / f134048 — ce cas n'est plus « surplus insuffisant » mais BELOW_MIN_POWER : il y a
|
||
// un budget, il n'est simplement pas au bon FORMAT. Le motif doit donc nommer le palier —
|
||
// la distinction envoie l'installateur attendre le soleil ou relire son câblage, pas la
|
||
// même chose. Ce qui reste inchangé, et qui est l'objet du test : ce n'est pas le verrou.
|
||
QVERIFY2(mBudget.contains("palier", Qt::CaseInsensitive), qUtf8Printable("motif: " + mBudget));
|
||
QVERIFY2(!mBudget.contains("Verrou"), qUtf8Printable("motif: " + mBudget));
|
||
|
||
// 3. Verrou minOn : palier maintenu AU-DESSUS du budget. Troisième cas, troisième texte.
|
||
// Il faut un SOUTIRAGE pour l'atteindre : le recrédit anti-clignotement (correction B)
|
||
// rend au budget la puissance déjà engagée, si bien qu'en export une charge peut
|
||
// toujours s'offrir son propre palier. C'est quand le compteur importe que le palier
|
||
// tenu par minOn dépasse le budget — et c'est précisément le cas d'ECS-306.
|
||
RelayRouter *r4 = new RelayRouter(tm, "chaud", "Charge chaude",
|
||
QList<LoadConfigRelay>({ {rA.toString(), 1000} }), 600, 0, 1, LoadNeeds(), arb);
|
||
LoadAction montee; montee.kind = LoadAction::Setpoint; montee.funding = LoadAction::Surplus;
|
||
montee.powerW = 1000; montee.reason = { DecisionCode::SurplusSetpoint };
|
||
r4->applyAction(montee, t0);
|
||
QCOMPARE(qRound(r4->currentSetpointW()), 1000);
|
||
const QString mOn = motifPour(r4, -400, t0.addSecs(10)); // import 400 W → budget 600 < 1000
|
||
QVERIFY2(mOn.contains("minOn"), qUtf8Printable("motif: " + mOn));
|
||
|
||
// 4. [ECS-309-b] Palier 0 et budget NÉGATIF, sans aucun verrou actif. Le soutirage est
|
||
// la seule cause ; nommer minOn ici est un mensonge à double titre — aucun verrou ne
|
||
// mord, et « puissance engagée » désigne 0 W. Le critère « setpointW > budgetW »
|
||
// l'affirmait pourtant, 0 > −389 étant vrai. Constaté au banc le 2026-08-13, sept
|
||
// fois en quatre heures, toujours au cycle suivant un palier 0.
|
||
// Sans le correctif ce cas produit le texte exact du banc :
|
||
// « Verrou minOn — ... maintenue à 0 W (puissance engagée, budget -389 W) ».
|
||
const QString mSoutirage = motifPour(r3, -389, t0);
|
||
QVERIFY2(mSoutirage.contains("insuffisant", Qt::CaseInsensitive),
|
||
qUtf8Printable("motif: " + mSoutirage));
|
||
QVERIFY2(!mSoutirage.contains("Verrou"), qUtf8Printable("motif: " + mSoutirage));
|
||
QVERIFY2(!mSoutirage.contains("puissance engagée"), qUtf8Printable("motif: " + mSoutirage));
|
||
QVERIFY2(mSoutirage.contains("-389"), qUtf8Printable("motif: " + mSoutirage));
|
||
|
||
// Les trois motifs sont distincts : c'est la lisibilité qu'ECS-309 exige.
|
||
QVERIFY(mOff != mBudget && mBudget != mOn && mOff != mOn);
|
||
|
||
// Un premier applyAction estampille m_lastSwitch : à partir de là le verrou court.
|
||
LoadAction amorce;
|
||
amorce.kind = LoadAction::Setpoint;
|
||
amorce.funding = LoadAction::Surplus;
|
||
amorce.powerW = 0;
|
||
amorce.reason = { DecisionCode::SurplusSetpoint };
|
||
r2->applyAction(amorce, t0);
|
||
|
||
LoadContext c1 = r2->toLoadContext(t0.addSecs(minOff + 1));
|
||
QCOMPARE(qRound(c1.telemetry.lockMaxPowerW), 1000); // expiré
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testEcsPartialFailure()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testEcsPartialFailure nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [ECS-410] Un relais introuvable fait échouer toute écriture le concernant. On exerce
|
||
// ainsi l'échelle complète : cible → retour arrière → arrêt total → défaut.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid rOk = addPowerSwitch(1000, 26661);
|
||
Thing *tOk = tm->findConfiguredThing(rOk);
|
||
QVERIFY(tOk);
|
||
tOk->setStateValue("power", false);
|
||
const QString rKo = QStringLiteral("{deadbeef-0000-0000-0000-000000000000}"); // jamais configuré
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
auto sp = [&](double w) {
|
||
LoadAction a; a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus;
|
||
a.powerW = w; a.reason = { DecisionCode::SurplusSetpoint }; return a;
|
||
};
|
||
// ECS-410 est ASYNCHRONE par construction (AGENTS règle 5 : jamais d'attente dans
|
||
// update()). Les acquittements arrivent par signal : sans faire tourner la boucle
|
||
// d'événements, l'échelle ne progresse pas d'un seul barreau.
|
||
auto laisserRetomber = [&]() { QTest::qWait(300); };
|
||
|
||
// --- Cas nominal : un seul relais VALIDE, aucune échelle déclenchée -------------------
|
||
{
|
||
RelayRouter *sain = new RelayRouter(tm, "sain", "Charge saine",
|
||
QList<LoadConfigRelay>({ {rOk.toString(), 1000} }), 0, 0, 1, LoadNeeds(), arb);
|
||
sain->applyAction(sp(1000), t0);
|
||
laisserRetomber();
|
||
QCOMPARE(qRound(sain->currentSetpointW()), 1000);
|
||
QCOMPARE(tOk->stateValue("power").toBool(), true);
|
||
QVERIFY2(!sain->faulted(), "une écriture réussie ne doit pas lever de défaut");
|
||
QCOMPARE(sain->telemetry().available, true);
|
||
tOk->setStateValue("power", false);
|
||
}
|
||
|
||
// --- Échelle complète : relais introuvable → défaut ----------------------------------
|
||
RelayRouter *r = new RelayRouter(tm, "ko", "Charge en panne",
|
||
QList<LoadConfigRelay>({ {rOk.toString(), 1000}, {rKo, 2000} }), 0, 0, 1, LoadNeeds(), arb);
|
||
QVERIFY(!r->faulted());
|
||
|
||
// Cible 3000 W = les DEUX relais. L'écriture sur rKo échoue → barreau 2 (retour arrière),
|
||
// qui échoue aussi → barreau 3 (arrêt total), qui échoue → défaut.
|
||
r->applyAction(sp(3000), t0);
|
||
laisserRetomber();
|
||
QVERIFY2(r->faulted(), "l'échelle ECS-410 doit aboutir au défaut");
|
||
|
||
// Le relais VALIDE a bien été ramené à l'ouverture par la tentative d'arrêt total :
|
||
// le barreau 3 n'est pas décoratif.
|
||
QCOMPARE(tOk->stateValue("power").toBool(), false);
|
||
|
||
// available bascule à faux — et ne clignote pas : il est COLLANT.
|
||
QCOMPARE(r->telemetry().available, false);
|
||
LoadContext c = r->toLoadContext(t0.addSecs(600));
|
||
QCOMPARE(c.telemetry.available, false);
|
||
// Charge FIGÉE : plancher == plafond. Un plafond nul sous un plancher non nul ferait
|
||
// dérailler le qBound du scheduler.
|
||
QCOMPARE(qRound(c.telemetry.lockMinPowerW), qRound(c.telemetry.lockMaxPowerW));
|
||
|
||
// …et la charge continue d'être COMPTÉE : elle sort de l'arbitrage, pas de la comptabilité.
|
||
QCOMPARE(qRound(c.telemetry.lockMinPowerW), qRound(c.telemetry.currentPowerW));
|
||
|
||
// Plus aucune commande n'est émise, même longtemps après : le défaut ne s'évapore pas.
|
||
tOk->setStateValue("power", false);
|
||
r->applyAction(sp(1000), t0.addSecs(3600));
|
||
laisserRetomber();
|
||
QCOMPARE(tOk->stateValue("power").toBool(), false);
|
||
QVERIFY(r->faulted());
|
||
|
||
// Levée DÉLIBÉRÉE, par l'opérateur — le seul chemin hors reconstruction.
|
||
// Enregistré auprès de l'arbitre : c'est LUI qui porte l'issue (règle 7-c), donc la
|
||
// levée passe par le chemin RPC réel et non par un appel direct à l'adaptateur.
|
||
arb->registerRelayRouter(r);
|
||
|
||
// [Règle 7-c] L'issue DOIT être journalisée par l'ARBITRE, et distinguer trois cas :
|
||
// défaut levé, aucun défaut à lever, adaptateur introuvable. C'est le seul levier de
|
||
// reprise à distance : sans issue lisible, un opérateur ne sait pas si son geste a agi,
|
||
// relance, puis conclut que le système est cassé. Le test porte sur le TEXTE publié.
|
||
QVERIFY(r->faulted());
|
||
s_journal.clear();
|
||
s_handlerPrecedent = qInstallMessageHandler(collecterJournal);
|
||
const bool leve = arb->clearLoadFault(QStringLiteral("ko"));
|
||
qInstallMessageHandler(s_handlerPrecedent);
|
||
QVERIFY(leve);
|
||
QVERIFY(!r->faulted());
|
||
|
||
// [ECS-111, 2026-08-29] La charge reste INDISPONIBLE, et c'est la bonne réponse : ce
|
||
// routeur déclare `rKo`, un relais JAMAIS configuré. ClearLoadFault lève le défaut
|
||
// d'ÉCRITURE — c'est sa promesse, et `!r->faulted()` juste au-dessus la vérifie — mais il
|
||
// ne fait pas exister un Thing. Annoncer `available: true` ici serait vrai pendant un
|
||
// cycle et faux au suivant, la première écriture rouvrant le défaut.
|
||
//
|
||
// Ce test se servait d'un relais inexistant comme RACCOURCI pour forcer un échec
|
||
// d'écriture. Le raccourci a désormais un sens propre — câblage incomplet — et les deux
|
||
// causes se distinguent : le défaut collant se lève, le câblage manquant non.
|
||
QCOMPARE(r->telemetry().available, false);
|
||
QCOMPARE(r->runtimeView(t0).faultCode, QStringLiteral("THING_MISSING"));
|
||
|
||
const QString issue1 = s_journal.filter(QStringLiteral("ClearLoadFault")).join(QStringLiteral(" | "));
|
||
QVERIFY2(issue1.contains(QStringLiteral("défaut LEVÉ")), qUtf8Printable(issue1));
|
||
|
||
// Second appel, charge SAINE : « aucun défaut à lever » doit se lire, et ne pas se
|
||
// confondre avec le premier cas. Le RPC réussit — il n'y a pas d'erreur — mais l'issue
|
||
// est différente, et c'est précisément la distinction que l'opérateur ne pouvait pas
|
||
// faire avant.
|
||
s_journal.clear();
|
||
s_handlerPrecedent = qInstallMessageHandler(collecterJournal);
|
||
const bool leve2 = arb->clearLoadFault(QStringLiteral("ko"));
|
||
qInstallMessageHandler(s_handlerPrecedent);
|
||
QVERIFY2(leve2, "la charge existe : l'appel aboutit, même sans défaut à lever");
|
||
|
||
const QString issue2 = s_journal.filter(QStringLiteral("ClearLoadFault")).join(QStringLiteral(" | "));
|
||
QVERIFY2(issue2.contains(QStringLiteral("AUCUN défaut")), qUtf8Printable(issue2));
|
||
QVERIFY2(!issue2.contains(QStringLiteral("défaut LEVÉ")), qUtf8Printable(issue2));
|
||
|
||
// Troisième cas : adaptateur introuvable. Ni l'un ni l'autre des deux précédents.
|
||
s_journal.clear();
|
||
s_handlerPrecedent = qInstallMessageHandler(collecterJournal);
|
||
const bool leve3 = arb->clearLoadFault(QStringLiteral("charge-qui-n-existe-pas"));
|
||
qInstallMessageHandler(s_handlerPrecedent);
|
||
QVERIFY2(!leve3, "un identifiant inconnu ne doit pas réussir");
|
||
|
||
const QString issue3 = s_journal.filter(QStringLiteral("ClearLoadFault")).join(QStringLiteral(" | "));
|
||
QVERIFY2(issue3.contains(QStringLiteral("introuvable")), qUtf8Printable(issue3));
|
||
|
||
// Les trois issues sont distinctes : c'est ce que la règle 7-c exige.
|
||
QVERIFY(issue1 != issue2 && issue2 != issue3 && issue1 != issue3);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testEcsDisableLeavesSafeState()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testEcsDisableLeavesSafeState nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [ECS-413] Constat de banc du 2026-08-09 : un SetLoadConfig posant enabled: false sur une
|
||
// charge au palier 3500 W a détruit l'adaptateur en laissant les TROIS relais fermés,
|
||
// juste avant une intervention de câblage.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QUuid rA = addPowerSwitch(2000, 26661);
|
||
Thing *tA = tm->findConfiguredThing(rA);
|
||
QVERIFY(tA);
|
||
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-safestate.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
LoadConfigStore *store = new LoadConfigStore(arb);
|
||
arb->setLoadConfigStore(store);
|
||
|
||
auto cfg = [&](bool enabled, int priority) {
|
||
LoadConfigs cs;
|
||
cs.append(LoadConfig::fromMap(QVariantMap{
|
||
{"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", priority}, {"enabled", enabled},
|
||
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, {"powerW", 2000}}},
|
||
{"minOnS", 0}, {"minOffS", 0}}));
|
||
return cs;
|
||
};
|
||
|
||
QString err;
|
||
QVERIFY2(store->setConfigs(cfg(true, 1), &err), err.toUtf8());
|
||
|
||
// Surplus large → le relais se ferme.
|
||
meter->setStateValue("currentPower", -2500);
|
||
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
|
||
QCoreApplication::processEvents();
|
||
QCOMPARE(tA->stateValue("power").toBool(), true);
|
||
|
||
// --- CAS NÉGATIF, obligatoire : rebuild SANS désactivation ---------------------------
|
||
// Seul le rang change. ECS-412 conserve l'adaptateur, donc AUCUNE mise en sécurité ne
|
||
// doit être déclenchée : sans ce cas, chaque changement de priorité couperait la charge.
|
||
QVERIFY2(store->setConfigs(cfg(true, 2), &err), err.toUtf8());
|
||
QTest::qWait(300);
|
||
QCOMPARE(tA->stateValue("power").toBool(), true);
|
||
|
||
// --- CAS POSITIF : désactivation ------------------------------------------------------
|
||
QVERIFY2(store->setConfigs(cfg(false, 2), &err), err.toUtf8());
|
||
QTest::qWait(300);
|
||
QCOMPARE(tA->stateValue("power").toBool(), false); // état sûr appliqué AVANT destruction
|
||
|
||
// Et il le reste : plus personne ne pilote, rien ne doit le refermer même sous surplus.
|
||
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 5, 0)));
|
||
QTest::qWait(300);
|
||
QCOMPARE(tA->stateValue("power").toBool(), false);
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testSgReadyPartialFailure()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testSgReadyPartialFailure nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [ECS-414] Même échelle qu'ECS-410, mais le PLANCHER d'une PAC est l'ÉTAT 2 — jamais
|
||
// « contacts ouverts », qui est une COMMANDE et peut valoir le BLOCAGE selon l'encodage.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid k1 = addPowerSwitch(0, 26661);
|
||
Thing *tK1 = tm->findConfiguredThing(k1);
|
||
QVERIFY(tK1);
|
||
const QString k2ko = QStringLiteral("{deadbeef-1111-1111-1111-111111111111}"); // jamais configuré
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
auto st = [&](int s) {
|
||
LoadAction a; a.kind = LoadAction::State; a.funding = LoadAction::Surplus;
|
||
a.state = s; a.reason = { DecisionCode::SgRecommended }; return a;
|
||
};
|
||
auto laisserRetomber = [&]() { QTest::qWait(300); };
|
||
|
||
// --- Cas nominal : encodage entièrement valide -----------------------------------------
|
||
{
|
||
SgReadyAdapter *sain = new SgReadyAdapter(tm, "pac-ok", "PAC saine",
|
||
QHash<int, QList<QString>>({ {1,{k1.toString()}}, {2,{}}, {3,{}}, {4,{k1.toString()}} }),
|
||
QHash<int, double>({ {1,0.0}, {2,0.0}, {3,1500.0}, {4,3000.0} }), 0, 2, arb);
|
||
sain->applyAction(st(4), t0);
|
||
laisserRetomber();
|
||
QCOMPARE(sain->currentState(), 4);
|
||
QVERIFY2(!sain->faulted(), "une écriture réussie ne doit pas lever de défaut");
|
||
QCOMPARE(sain->telemetry().available, true);
|
||
}
|
||
|
||
// --- Échelle complète, et surtout : QUEL est le plancher ------------------------------
|
||
// Encodage : état 1 = K2 seul (BLOCAGE), état 3 = K1 seul, état 4 = K1+K2.
|
||
// K2 est injoignable. Un contact qu'on ne peut jamais confirmer rend TOUT état
|
||
// inconfirmable : l'échelle va donc jusqu'au défaut, et c'est l'issue honnête.
|
||
//
|
||
// Ce que ce test verrouille n'est pas le défaut lui-même, c'est le PLANCHER : le repli
|
||
// ramène K1 à l'OUVERTURE, c'est-à-dire l'état 2 sur cet encodage. Un plancher « contacts
|
||
// ouverts par principe » aurait pu, sur un autre câblage, valoir l'état 1 — le BLOCAGE.
|
||
tK1->setStateValue("power", false); // AVANT construction : c'est ce que la reprise lira
|
||
SgReadyAdapter *pac = new SgReadyAdapter(tm, "pac-ko", "PAC contact HS",
|
||
QHash<int, QList<QString>>({ {1,{k2ko}}, {2,{}}, {3,{k1.toString()}}, {4,{k1.toString(), k2ko}} }),
|
||
QHash<int, double>({ {1,0.0}, {2,0.0}, {3,1500.0}, {4,3000.0} }), 900, 2, arb);
|
||
|
||
// ECS-411 appliqué au SG-Ready : l'état de départ est RELU, pas supposé. K1 est lu ouvert,
|
||
// K2 est INJOIGNABLE donc supposé fermé (LM-104) — le motif {K2} est exactement l'encodage
|
||
// de l'état 1 sur ce câblage. L'adaptateur démarre donc en 1, et non en 2 « par défaut ».
|
||
QCOMPARE(pac->currentState(), 1);
|
||
|
||
// ECS-412 — armement à froid : la première commande est REFUSÉE (minStateHold 900 s), et
|
||
// c'est ce refus qui pose l'estampille. Le compte à rebours court donc dès maintenant :
|
||
// l'armement est paresseux, pas permanent.
|
||
pac->applyAction(st(4), t0);
|
||
QCOMPARE(pac->currentState(), 1);
|
||
|
||
pac->applyAction(st(4), t0.addSecs(901)); // verrou expiré → l'échelle d'échec démarre
|
||
laisserRetomber();
|
||
|
||
QVERIFY2(pac->faulted(), "un contact jamais confirmable doit mener au défaut");
|
||
|
||
// Le repli a bien COMMANDÉ K1 : il est ouvert, donc l'encodage de l'état 2 a été visé.
|
||
// Le verrou minStateHoldS de 900 s ne l'a pas retardé — le repli écrit les contacts
|
||
// directement, équivalent d'un force = true, comme le repli L2. Attendre un quart d'heure
|
||
// pour sortir d'un état non voulu ne serait pas défendable.
|
||
QCOMPARE(tK1->stateValue("power").toBool(), false);
|
||
QCOMPARE(pac->telemetry().available, false);
|
||
|
||
LoadContext c = pac->toLoadContext(t0.addSecs(961));
|
||
QCOMPARE(c.telemetry.available, false);
|
||
// Charge FIGÉE : plancher == plafond.
|
||
QCOMPARE(c.telemetry.minState, c.telemetry.maxState);
|
||
|
||
// Plus aucune commande, même bien après le verrou.
|
||
pac->applyAction(st(3), t0.addSecs(8100));
|
||
laisserRetomber();
|
||
QCOMPARE(tK1->stateValue("power").toBool(), false);
|
||
QVERIFY(pac->faulted());
|
||
|
||
// Levée délibérée : l'état est RELU depuis les contacts, pas supposé.
|
||
pac->clearFault();
|
||
QVERIFY(!pac->faulted());
|
||
QCOMPARE(pac->telemetry().available, true);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testSgReadyFromConfig()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testSgReadyFromConfig nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [LM-300] Première implémentation réelle de spec_loadmodel.md : union discriminée par
|
||
// MÉCANISME, une charge utile par mécanisme, mutuellement exclusives.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QUuid k1 = addPowerSwitch(0, 26661);
|
||
QUuid k2 = addPowerSwitch(0, 26662);
|
||
Thing *tK1 = tm->findConfiguredThing(k1);
|
||
Thing *tK2 = tm->findConfiguredThing(k2);
|
||
QVERIFY(tK1 && tK2);
|
||
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-sgready.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
LoadConfigStore *store = new LoadConfigStore(arb);
|
||
arb->setLoadConfigStore(store);
|
||
|
||
auto etat = [](int s, const QVariantList &relays, double w) {
|
||
return QVariantMap{{"state", s}, {"relays", relays}, {"estimatedPowerW", w}};
|
||
};
|
||
auto pacCfg = [&](const QVariantList &etats) {
|
||
return QVariantMap{
|
||
{"id", "pac"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"},
|
||
{"priority", 1}, {"enabled", true},
|
||
{"sgReady", QVariantMap{{"states", etats}, {"minStateHoldS", 0}}}};
|
||
};
|
||
const QVariantList etatsOk = QVariantList()
|
||
<< etat(1, QVariantList() << k1.toString(), 0)
|
||
<< etat(2, QVariantList(), 0)
|
||
<< etat(3, QVariantList() << k2.toString(), 1500)
|
||
<< etat(4, QVariantList() << k1.toString() << k2.toString(), 3000);
|
||
|
||
QString err;
|
||
|
||
// --- REFUS 1 : pas d'état 2 → la charge ne peut pas être mise en sécurité (ECS-110) ----
|
||
{
|
||
QVariantList sansDeux = QVariantList()
|
||
<< etat(3, QVariantList() << k2.toString(), 1500)
|
||
<< etat(4, QVariantList() << k1.toString() << k2.toString(), 3000);
|
||
LoadConfigs cs; cs.append(LoadConfig::fromMap(pacCfg(sansDeux)));
|
||
QVERIFY2(!store->setConfigs(cs, &err), "une PAC sans état 2 DOIT être refusée");
|
||
QVERIFY2(err.contains("état 2"), err.toUtf8());
|
||
}
|
||
|
||
// --- REFUS 2 : charges utiles mélangées → états invalides inexprimables (LM-302) -------
|
||
{
|
||
QVariantMap m = pacCfg(etatsOk);
|
||
m.insert("relays", QVariantList() << QVariantMap{{"thingId", k1.toString()}, {"powerW", 500}});
|
||
LoadConfigs cs; cs.append(LoadConfig::fromMap(m));
|
||
QVERIFY2(!store->setConfigs(cs, &err), "relays[] avec sg-ready DOIT être refusé");
|
||
}
|
||
|
||
// --- ACCEPTÉ : configuration complète, round-trip par le store -------------------------
|
||
LoadConfigs cs; cs.append(LoadConfig::fromMap(pacCfg(etatsOk)));
|
||
QVERIFY2(store->setConfigs(cs, &err), err.toUtf8());
|
||
const LoadConfigSgReady relu = store->configs().first().sgReadyPayload();
|
||
QCOMPARE(relu.states.size(), 4);
|
||
QVERIFY(relu.hasState(2));
|
||
// Le nom du champ dit ce qu'il est : une ESTIMATION, pas un engagement.
|
||
QCOMPARE(qRound(relu.states.at(3).estimatedPowerW), 3000);
|
||
|
||
// --- La PAC configurée est pilotée comme n'importe quelle charge ------------------------
|
||
meter->setStateValue("currentPower", -4000); // surplus large → état 4 attendu
|
||
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
|
||
QTest::qWait(300);
|
||
QCOMPARE(tK1->stateValue("power").toBool(), true);
|
||
QCOMPARE(tK2->stateValue("power").toBool(), true);
|
||
|
||
// --- Désactivation : état sûr = ÉTAT 2, pas « contacts ouverts par principe » -----------
|
||
QVariantMap off = pacCfg(etatsOk); off.insert("enabled", false);
|
||
LoadConfigs cs2; cs2.append(LoadConfig::fromMap(off));
|
||
QVERIFY2(store->setConfigs(cs2, &err), err.toUtf8());
|
||
QTest::qWait(300);
|
||
// État 2 = motif vide sur cet encodage → les deux contacts ouverts.
|
||
QCOMPARE(tK1->stateValue("power").toBool(), false);
|
||
QCOMPARE(tK2->stateValue("power").toBool(), false);
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testThingOwnershipIsExclusive()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testThingOwnershipIsExclusive nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [ECS-110 étendu] isValid() s'arrête au bord d'une charge. Le conflit ENTRE charges —
|
||
// même défaut que la PAC en dur, mais entre deux charges de configuration — ne peut se
|
||
// voir qu'au niveau de l'ensemble.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
const QString K1 = "{beaf92e1-0000-4b84-9ce4-e42364863801}";
|
||
const QString K2 = "{bf236e64-0000-4bf1-82a6-4ede03de7502}";
|
||
const QString VAR = "{11111111-0000-4000-8000-000000000003}";
|
||
|
||
auto sgReady = [&](const QString &id, bool enabled, const QStringList &relaisEtat3) {
|
||
QVariantList etats;
|
||
etats << QVariantMap{{"state", 1}, {"relays", QVariantList()}, {"estimatedPowerW", 0}};
|
||
etats << QVariantMap{{"state", 2}, {"relays", QVariantList()}, {"estimatedPowerW", 0}};
|
||
QVariantList r3;
|
||
for (const QString &r : relaisEtat3) r3 << r;
|
||
etats << QVariantMap{{"state", 3}, {"relays", r3}, {"estimatedPowerW", 1500}};
|
||
return LoadConfig::fromMap(QVariantMap{
|
||
{"id", id}, {"label", "PAC " + id}, {"adapter", "sg-ready"}, {"mode", "fixed"},
|
||
{"priority", 2}, {"enabled", enabled},
|
||
{"sgReady", QVariantMap{{"states", etats}, {"minStateHoldS", 0}}}});
|
||
};
|
||
auto routeur = [&](const QString &id, bool enabled, const QStringList &relais) {
|
||
QVariantList rs;
|
||
for (const QString &r : relais)
|
||
rs << QVariantMap{{"thingId", r}, {"powerW", 500}};
|
||
return LoadConfig::fromMap(QVariantMap{
|
||
{"id", id}, {"label", "ECS " + id}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", 1}, {"enabled", enabled}, {"relays", rs}});
|
||
};
|
||
auto variable = [&](const QString &thingId, bool enabled) {
|
||
return LoadConfig::fromMap(QVariantMap{
|
||
{"id", thingId}, {"label", "Charge variable"}, {"adapter", "etmvariableload"},
|
||
{"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 3}, {"enabled", enabled}});
|
||
};
|
||
|
||
QString err;
|
||
LoadConfigs cs;
|
||
|
||
// --- Le cas général : K1 appartient au routeur ET à la PAC ---------------------------
|
||
cs = LoadConfigs() << routeur("ecs", true, {K1, K2}) << sgReady("pac", true, {K1});
|
||
QVERIFY2(!LoadConfigStore::validateSet(cs, &err),
|
||
"un Thing partagé entre deux charges actives DOIT être refusé");
|
||
QVERIFY2(err.contains("deux charges actives"), err.toUtf8());
|
||
|
||
// --- Même conflit avec etmvariableload, dont l'id EST le ThingId piloté ---------------
|
||
cs = LoadConfigs() << routeur("ecs", true, {VAR}) << variable(VAR, true);
|
||
QVERIFY2(!LoadConfigStore::validateSet(cs, &err), "conflit relay-router ↔ etmvariableload");
|
||
|
||
// --- Formes d'écriture différentes du même uuid : le conflit reste visible ------------
|
||
QString sansAccolades = K1; sansAccolades.remove('{').remove('}');
|
||
cs = LoadConfigs() << routeur("ecs", true, {K1}) << sgReady("pac", true, {sansAccolades.toUpper()});
|
||
QVERIFY2(!LoadConfigStore::validateSet(cs, &err),
|
||
"la normalisation doit voir « {uuid} » et « UUID » comme le même Thing");
|
||
|
||
// --- Identifiants de charge en double : la seconde serait perdue en silence -----------
|
||
cs = LoadConfigs() << routeur("meme-id", true, {K1}) << sgReady("meme-id", true, {K2});
|
||
QVERIFY2(!LoadConfigStore::validateSet(cs, &err), "identifiants dupliqués");
|
||
|
||
// --- Doublons À L'INTÉRIEUR d'une charge ----------------------------------------------
|
||
QVERIFY2(!routeur("ecs", true, {K1, K1}).isValid(&err), "deux étages sur le même Thing");
|
||
QVERIFY2(!sgReady("pac", true, {K2, K2}).isValid(&err), "deux fois le même relais dans un état");
|
||
// Mais un relais présent dans PLUSIEURS états reste légitime (état 4 = état 1 + état 3).
|
||
{
|
||
QVariantList etats;
|
||
etats << QVariantMap{{"state", 1}, {"relays", QVariantList() << K1}, {"estimatedPowerW", 0}};
|
||
etats << QVariantMap{{"state", 2}, {"relays", QVariantList()}, {"estimatedPowerW", 0}};
|
||
etats << QVariantMap{{"state", 4}, {"relays", QVariantList() << K1 << K2}, {"estimatedPowerW", 3000}};
|
||
const LoadConfig pac = LoadConfig::fromMap(QVariantMap{
|
||
{"id", "pac"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"},
|
||
{"priority", 2}, {"enabled", true},
|
||
{"sgReady", QVariantMap{{"states", etats}, {"minStateHoldS", 0}}}});
|
||
QVERIFY2(pac.isValid(&err), err.toUtf8());
|
||
}
|
||
|
||
// --- Une charge DÉSACTIVÉE ne revendique rien : préparer un remplacement reste permis --
|
||
cs = LoadConfigs() << routeur("ecs", true, {K1, K2}) << sgReady("pac-futur", false, {K1});
|
||
QVERIFY2(LoadConfigStore::validateSet(cs, &err), err.toUtf8());
|
||
// …mais l'activer ensuite repasse par la vérification et échoue.
|
||
cs = LoadConfigs() << routeur("ecs", true, {K1, K2}) << sgReady("pac-futur", true, {K1});
|
||
QVERIFY(!LoadConfigStore::validateSet(cs, &err));
|
||
|
||
// --- Le refus remonte bien par setConfigs (et rien n'est persisté) ---------------------
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-exclusive.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
LoadConfigStore store;
|
||
QVERIFY2(store.setConfigs(LoadConfigs() << routeur("ecs", true, {K1, K2}), &err), err.toUtf8());
|
||
cs = LoadConfigs() << routeur("ecs", true, {K1, K2}) << sgReady("pac", true, {K2});
|
||
QVERIFY(!store.setConfigs(cs, &err));
|
||
QCOMPARE(store.configs().count(), 1); // rollback : l'ancienne config tient
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::run_data()
|
||
{
|
||
// Simulation infos
|
||
QTest::addColumn<QString>("simulationName");
|
||
QTest::addColumn<QString>("simulationTitle");
|
||
QTest::addColumn<QString>("databaseName");
|
||
QTest::addColumn<QDateTime>("simulationStart");
|
||
QTest::addColumn<ChargerPlugEvents>("plugEvents");
|
||
QTest::addColumn<int>("simulationHours");
|
||
QTest::addColumn<EnergyLogs::SampleRate>("sampleRate");
|
||
QTest::addColumn<double>("productionScaling");
|
||
QTest::addColumn<int>("detailsStepStart");
|
||
QTest::addColumn<int>("detailsStepStop");
|
||
QTest::addColumn<DetailsStepList>("detailsStepList");
|
||
|
||
// Houshold info
|
||
QTest::addColumn<int>("phasePowerLimit");
|
||
QTest::addColumn<bool>("spotMarketEnabled");
|
||
QTest::addColumn<QString>("spotMarketResourceData");
|
||
QTest::addColumn<double>("acquisitionTolerance");
|
||
QTest::addColumn<double>("batteryLevelConsideration");
|
||
|
||
// ChargingInfo
|
||
QTest::addColumn<double>("targetPercentage");
|
||
QTest::addColumn<QDateTime>("targetDateTime");
|
||
QTest::addColumn<QString>("chargingMode");
|
||
QTest::addColumn<int>("carBatteryLevel");
|
||
QTest::addColumn<int>("dailySpotMarketPercentage");
|
||
|
||
// Car information and states
|
||
QTest::addColumn<int>("carCapacity");
|
||
QTest::addColumn<int>("carMinChargingCurrent");
|
||
QTest::addColumn<int>("carPhaseCount");
|
||
|
||
// Energy storage information and states
|
||
QTest::addColumn<bool>("energyStorageAvailable");
|
||
QTest::addColumn<int>("energyStorageCapacity");
|
||
QTest::addColumn<double>("energyStorageMaxChargingPower");
|
||
QTest::addColumn<double>("energyStorageMaxDischargingPower");
|
||
QTest::addColumn<double>("energyStorageInitialBatteyLevel");
|
||
|
||
// Charger initial states
|
||
QTest::addColumn<bool>("chargerConnected");
|
||
QTest::addColumn<bool>("chargerPower");
|
||
QTest::addColumn<QString>("chargerPhases");
|
||
QTest::addColumn<bool>("canSwitchPhaseCount");
|
||
QTest::addColumn<int>("chargerMaxChargingCurrent");
|
||
QTest::addColumn<int>("chargerMaxChargingCurrentMaxValue");
|
||
|
||
QTest::addColumn<SimulationIterationTest>("iterationTest");
|
||
|
||
bool runAllSimulations = true;
|
||
|
||
bool runSpotmarketSimulation = runAllSimulations;
|
||
bool run1PhaseSimulations = runAllSimulations;
|
||
bool run2PhaseSimulations = runAllSimulations;
|
||
bool run3PhaseSimulations = runAllSimulations;
|
||
bool runPhaseSwitchingSimulations = runAllSimulations;
|
||
|
||
// Simulations
|
||
if (runSpotmarketSimulation)
|
||
QTest::newRow("Spotmarket only")
|
||
|
||
/* Simulation info */
|
||
|
||
<< "simulation-spotmarket-only-1-phase-16A" // simulationName
|
||
<< "Simulation (1 phase, charger 16A max, only spot market)" // simulationTitle
|
||
<< ":/databases/2022-08-12-kostal-energylogs.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(0,0,0)) // simulationStart (UTC)
|
||
|
||
<< ChargerPlugEvents( { ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(7,0,0)), false),
|
||
ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(17,30,0)), true),
|
||
ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 15), QTime(7,0,0)), false),
|
||
ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 15), QTime(17,0,0)), true)
|
||
}) // pluggedInTime (UTC)
|
||
|
||
<< 48 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 0.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList() // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< true // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 15), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEco" // chargingMode
|
||
<< 20 // carBatteryLevel
|
||
<< 20 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 1 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 16 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 20)
|
||
}
|
||
},
|
||
{
|
||
250, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
}
|
||
},
|
||
{
|
||
500, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
}
|
||
},
|
||
{
|
||
1440, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40) // Should have charged 20% in one day
|
||
}
|
||
},
|
||
{
|
||
2800, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
}
|
||
},
|
||
{
|
||
2880, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 60) // Should have charged 20% in one day
|
||
}
|
||
}
|
||
} );
|
||
|
||
if (runSpotmarketSimulation)
|
||
QTest::newRow("Spotmarket and PV")
|
||
|
||
/* Simulation info */
|
||
|
||
<< "simulation-spotmarket-and-pv-1-phase-16A" // simulationName
|
||
<< "Simulation (1 phase, charger 16A max, spot market and PV)" // simulationTitle
|
||
<< ":/databases/2022-08-12-kostal-energylogs.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(0,0,0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents()
|
||
|
||
<< 48 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 0.35 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList() // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< true // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 15), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEco" // chargingMode
|
||
<< 20 // carBatteryLevel
|
||
<< 20 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 1 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 16 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 20)
|
||
}
|
||
},
|
||
{
|
||
250, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
500, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
580, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
750, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
780, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
810, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
1400, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
2000, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 46) // Should be at least 20% more than the day before...
|
||
}
|
||
},
|
||
{
|
||
2250, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
}
|
||
},
|
||
{
|
||
2750, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
}
|
||
}
|
||
} );
|
||
|
||
if (runSpotmarketSimulation)
|
||
QTest::newRow("Spotmarket with target time")
|
||
|
||
/* Simulation info */
|
||
|
||
<< "simulation-spotmarket-only-with-targettime-1-phase-16A" // simulationName
|
||
<< "Simulation (1 phase, charger 16A max, only spot market with target time)" // simulationTitle
|
||
<< ":/databases/2022-08-12-kostal-energylogs.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(0,0,0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents() // Car plug events
|
||
|
||
<< 48 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 0.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList() // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< true // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 15), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEcoWithTargetTime" // chargingMode
|
||
<< 20 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 1 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 16 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 20)
|
||
}
|
||
},
|
||
{
|
||
700, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
1200, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
1400, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
1550, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
1700, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
2760, { // 22:00
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
}
|
||
} );
|
||
|
||
|
||
if (runSpotmarketSimulation)
|
||
QTest::newRow("Spotmarket only with target time")
|
||
|
||
/* Simulation info */
|
||
|
||
<< "simulation-spotmarket-only-with-targettime-1-day-1-phase-16A" // simulationName
|
||
<< "Simulation (1 phase, charger 16A max, only spot market with target time single day)" // simulationTitle
|
||
<< ":/databases/2022-08-12-kostal-energylogs.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(0, 0, 0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents()
|
||
|
||
<< 32 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 0.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList() // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< true // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 15), QTime(07, 0, 0)) // targetDateTime
|
||
<< "ChargingModeEcoWithTargetTime" // chargingMode
|
||
<< 50 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 1 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 16 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 50)
|
||
}
|
||
},
|
||
{
|
||
250, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 61)
|
||
}
|
||
},
|
||
{
|
||
500, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
}
|
||
},
|
||
{
|
||
1400, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
}
|
||
}
|
||
});
|
||
|
||
if (run1PhaseSimulations)
|
||
QTest::newRow("Default")
|
||
/* Simulation info */
|
||
<< "simulation-1-phase-32A" // simulationName
|
||
<< "Simulation (1 phase, charger 32A max, target 22:00 100%" // simulationTitle
|
||
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents() // pluggedInTime (UTC)
|
||
<< 18 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 20.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList() // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< false // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEcoWithTargetTime" // chargingMode
|
||
<< 40 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 1 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 32 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
|
||
}
|
||
},
|
||
{
|
||
163, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
170, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 9),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
444, {
|
||
// [ETM 3g-2] 7 A → 6 A. La borne est ARRÊTÉE à ce pas (ligne
|
||
// suivante) : la valeur relue est la dernière consigne émise avant
|
||
// la coupure, et elle diffère d'un ampère parce que la conversion
|
||
// watts→ampères se fait désormais APRÈS l'allocation du waterfall,
|
||
// au lieu d'être calculée nativement en ampères par le proxy.
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6) ,
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
520, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6) ,
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 80)
|
||
}
|
||
},
|
||
{
|
||
700, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6) ,
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 85)
|
||
}
|
||
},
|
||
{
|
||
900, {
|
||
// [ETM 3g-2] 88 % → 87 %, et 30 A → 6 A. Le CHEMIN diffère d'un
|
||
// ampère par-ci par-là (arrondi de la conversion, cf. pas 444) et
|
||
// l'écart s'accumule sur 900 pas de rétroaction. Ce qui compte est
|
||
// au pas 960, et c'est inchangé : la voiture est à 100 % à
|
||
// l'échéance. Les 14 autres scénarios sont identiques au bit près.
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 87)
|
||
}
|
||
},
|
||
{
|
||
960, { // 22:00
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
}
|
||
} );
|
||
|
||
if (run1PhaseSimulations)
|
||
QTest::newRow("Default")
|
||
/* Simulation info */
|
||
<< "simulation-1-phase-16A" // simulationName
|
||
<< "Simulation (1 phase, charger 16A max, target 22:00 100%)" // simulationTitle
|
||
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(0,0,0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents( { ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)), true) }) // pluggedInTime (UTC)
|
||
<< 24 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 20.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList() // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< false // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEcoWithTargetTime" // chargingMode
|
||
<< 50 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 1 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 16 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 50)
|
||
}
|
||
},
|
||
{
|
||
452, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 50)
|
||
}
|
||
},
|
||
{
|
||
467, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 9),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 51)
|
||
}
|
||
},
|
||
{
|
||
750, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
830, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
841, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
1000, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
1300, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
1440, { // 22:00
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
}
|
||
} );
|
||
|
||
if (run2PhaseSimulations)
|
||
QTest::newRow("Kostal")
|
||
/* Simulation info */
|
||
<< "simulation-kostal-2-phase-16A" // simulationName
|
||
<< "Simulation (2 phase, charger 16A max, target 22:00 100%)" // simulationTitle
|
||
<< ":/databases/2022-08-12-kostal-energylogs.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(0,0,0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents( { ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(8,0,0)), true) }) // pluggedInTime (UTC)
|
||
<< 24 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 1.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList() // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< false // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEcoWithTargetTime" // chargingMode
|
||
<< 40 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 2 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 16 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
|
||
}
|
||
},
|
||
{
|
||
550, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
600, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 9),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 48)
|
||
}
|
||
},
|
||
{
|
||
820, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
823, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
847, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 80)
|
||
}
|
||
},
|
||
{
|
||
1050, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
}
|
||
} );
|
||
|
||
if (run2PhaseSimulations)
|
||
QTest::newRow("Kostal")
|
||
/* Simulation info */
|
||
<< "simulation-kostal-2-phase-16A-away-2-hours" // simulationName
|
||
<< "Simulation (2 phase, charger 16A max, target 22:00 100%)" // simulationTitle
|
||
<< ":/databases/2022-08-12-kostal-energylogs.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(0,0,0)) // simulationStart (UTC)
|
||
|
||
<< ChargerPlugEvents( {
|
||
ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(12,0,0)), false),
|
||
ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(14,00,0)), true, 10)
|
||
}) // pluggedInTime (UTC)
|
||
<< 24 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 1.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList() // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< false // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEcoWithTargetTime" // chargingMode
|
||
<< 40 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 2 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 16 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
|
||
}
|
||
},
|
||
{
|
||
550, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
600, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 9),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 48)
|
||
}
|
||
},
|
||
{
|
||
1300, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
1440, { // 22:00
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
}
|
||
} );
|
||
|
||
if (run2PhaseSimulations)
|
||
QTest::newRow("Default")
|
||
/* Simulation info */
|
||
<< "simulation-2-phase-16A" // simulationName
|
||
<< "Simulation (2 phase, charger 16A max, target 22:00 100%)" // simulationTitle
|
||
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents() // pluggedInTime (UTC)
|
||
<< 18 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 20.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList() // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< false // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEcoWithTargetTime" // chargingMode
|
||
<< 40 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 2 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 16 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
|
||
}
|
||
},
|
||
{
|
||
850, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
900, {
|
||
// [ETM 3g-2] La borne ne charge PAS encore à ce pas, et c'est le
|
||
// mécanisme d'échéance qui fonctionne : à 89 % avec une heure
|
||
// devant elle, la projection dit qu'il reste du temps, donc rien
|
||
// n'est forcé. Le forçage intervient plus tard, et le pas 960
|
||
// vérifie qu'il a suffi — voiture à 100 % à l'échéance.
|
||
// Le proxy forçait dès ce pas parce que sa trajectoire de la
|
||
// journée laissait la voiture un peu moins chargée.
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 89)
|
||
}
|
||
},
|
||
{
|
||
960, { // 22:00
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
}
|
||
} );
|
||
|
||
if (run2PhaseSimulations)
|
||
QTest::newRow("Default")
|
||
/* Simulation info */
|
||
<< "simulation-2-phase-32A" // simulationName
|
||
<< "Simulation (2 phase, charger 32A max, target 22:00 100%)" // simulationTitle
|
||
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents() // pluggedInTime (UTC)
|
||
<< 18 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 20.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList() // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< false // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEcoWithTargetTime" // chargingMode
|
||
<< 40 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 2 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 32 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
|
||
}
|
||
},
|
||
{
|
||
100, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
300, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 9),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 61)
|
||
}
|
||
},
|
||
{
|
||
470, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
586, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
800, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
950, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 30),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 99)
|
||
}
|
||
},
|
||
{
|
||
960, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
}
|
||
} );
|
||
|
||
if (run3PhaseSimulations)
|
||
QTest::newRow("Default")
|
||
/* Simulation info */
|
||
<< "simulation-3-phase-16A" // simulationName
|
||
<< "Simulation (3 phase, charger 16A max, target 22:00 100%)" // simulationTitle
|
||
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents() // pluggedInTime (UTC)
|
||
<< 18 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 40.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList({200, 300, 400, 500}) // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< false // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEcoWithTargetTime" // chargingMode
|
||
<< 40 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 3 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 16 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
|
||
}
|
||
},
|
||
{
|
||
200, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 11),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 57)
|
||
}
|
||
},
|
||
{
|
||
300, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 13),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 83)
|
||
}
|
||
},
|
||
{
|
||
400, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 11),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
},
|
||
{
|
||
700, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
}
|
||
} );
|
||
|
||
|
||
if (run3PhaseSimulations)
|
||
QTest::newRow("Default")
|
||
/* Simulation info */
|
||
<< "simulation-3-phase-32A" // simulationName
|
||
<< "Simulation (3 phase, charger 32A max, target 22:00 100%)" // simulationTitle
|
||
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents() // pluggedInTime (UTC)
|
||
<< 18 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 20.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList({ 480, 500, 600, 950, 960 }) // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< false // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEcoWithTargetTime" // chargingMode
|
||
<< 40 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 3 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 32 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
100, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
|
||
}
|
||
},
|
||
{
|
||
200, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
300, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
400, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
480, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
500, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 82)
|
||
}
|
||
},
|
||
{
|
||
600, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
950, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 98) // Finish 10 min early
|
||
}
|
||
},
|
||
{
|
||
960, { // 22:00
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
}
|
||
} );
|
||
|
||
if (runPhaseSwitchingSimulations)
|
||
QTest::newRow("Default")
|
||
/* Simulation info */
|
||
<< "simulation-phase-switching-16A" // simulationName
|
||
<< "Simulation (phase switching, charger 16A max, surplus only)" // simulationTitle
|
||
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents() // pluggedInTime (UTC)
|
||
<< 18 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 45.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList({80, 150, 310, 400, 470}) // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< false // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEco" // chargingMode
|
||
<< 40 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 3 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< true // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 16 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
|
||
}
|
||
},
|
||
{
|
||
80, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
|
||
}
|
||
},
|
||
{
|
||
150, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 10),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 50)
|
||
}
|
||
},
|
||
{
|
||
310, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 91)
|
||
}
|
||
},
|
||
{
|
||
400, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 13),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
},
|
||
{
|
||
470, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 9),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
},
|
||
} );
|
||
|
||
if (runPhaseSwitchingSimulations)
|
||
QTest::newRow("Default")
|
||
/* Simulation info */
|
||
<< "simulation-energy-storage-phase-switching" // simulationName
|
||
<< "Simulation energy storage, phase switching" // simulationTitle
|
||
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(0,0,0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents() // pluggedInTime (UTC)
|
||
<< 36 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 45.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 10 // detailsStepStop
|
||
<< DetailsStepList({}) // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< false // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEco" // chargingMode
|
||
<< 40 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 3 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< true // energyStorageAvailable
|
||
<< 12 // energyStorageCapacity
|
||
<< 5000.0 // energyStorageMaxChargingPower
|
||
<< 5000.0 // energyStorageMaxDischargingPower
|
||
<< 10.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< true // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 16 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
|
||
}
|
||
}
|
||
} );
|
||
}
|
||
|
||
void Simulation::run()
|
||
{
|
||
QFETCH(QString, simulationName);
|
||
QFETCH(QString, simulationTitle);
|
||
QFETCH(QString, databaseName);
|
||
QFETCH(QDateTime, simulationStart);
|
||
QFETCH(ChargerPlugEvents, plugEvents);
|
||
QFETCH(int, simulationHours);
|
||
QFETCH(EnergyLogs::SampleRate, sampleRate);
|
||
QFETCH(double, productionScaling);
|
||
QFETCH(int, detailsStepStart);
|
||
QFETCH(int, detailsStepStop);
|
||
QFETCH(DetailsStepList, detailsStepList);
|
||
|
||
QFETCH(int, phasePowerLimit);
|
||
QFETCH(bool, spotMarketEnabled);
|
||
QFETCH(QString, spotMarketResourceData);
|
||
QFETCH(double, acquisitionTolerance);
|
||
QFETCH(double, batteryLevelConsideration);
|
||
|
||
QFETCH(double, targetPercentage);
|
||
QFETCH(QDateTime, targetDateTime);
|
||
QFETCH(QString, chargingMode);
|
||
QFETCH(int, carBatteryLevel);
|
||
QFETCH(int, dailySpotMarketPercentage);
|
||
|
||
QFETCH(int, carCapacity);
|
||
QFETCH(int, carMinChargingCurrent);
|
||
QFETCH(int, carPhaseCount);
|
||
|
||
QFETCH(bool, energyStorageAvailable);
|
||
QFETCH(int, energyStorageCapacity);
|
||
QFETCH(double, energyStorageMaxChargingPower);
|
||
QFETCH(double, energyStorageMaxDischargingPower);
|
||
QFETCH(double, energyStorageInitialBatteyLevel);
|
||
|
||
QFETCH(bool, chargerConnected);
|
||
QFETCH(bool, chargerPower);
|
||
QFETCH(QString, chargerPhases);
|
||
QFETCH(bool, canSwitchPhaseCount);
|
||
QFETCH(int, chargerMaxChargingCurrent);
|
||
QFETCH(int, chargerMaxChargingCurrentMaxValue);
|
||
|
||
QFETCH(SimulationIterationTest, iterationTest);
|
||
|
||
|
||
QStringList loggingDefaultList = {
|
||
"*.debug=false",
|
||
"Application.debug=true",
|
||
"LogEngine.info=false",
|
||
"Simulation.debug=true",
|
||
"Experiences.debug=false",
|
||
"NymeaEnergy.debug=false",
|
||
"EnergyMocks.debug=false",
|
||
"DBus.warning=false",
|
||
};
|
||
QString loggingRulesDefault = loggingDefaultList.join("\n");
|
||
|
||
QStringList loggingDetailsList = {
|
||
"*.debug=false",
|
||
"Application.debug=true",
|
||
"LogEngine.info=false",
|
||
"Simulation.debug=true",
|
||
"Experiences.debug=false",
|
||
"NymeaEnergy.debug=true",
|
||
"EnergyMocks.debug=false",
|
||
"DBus.warning=false",
|
||
};
|
||
QString loggingRulesDetails = loggingDetailsList.join("\n");
|
||
|
||
QStringList availableChargingModes;
|
||
availableChargingModes << "ChargingModeNormal";
|
||
availableChargingModes << "ChargingModeEco";
|
||
availableChargingModes << "ChargingModeEcoWithTargetTime";
|
||
QVERIFY2(availableChargingModes.contains(chargingMode), "Unknown charging mode passed to the simulation. Please compair the list with the ChargingMode enum.");
|
||
|
||
if (canSwitchPhaseCount)
|
||
QVERIFY2(chargerPhases == "ABC", "If the charger supports phase count switching all 3 phases must be connected.");
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = databaseName;
|
||
initTestCase(loggingRulesDefault);
|
||
|
||
// Print simulation init details
|
||
QLoggingCategory::setFilterRules(loggingRulesDefault);
|
||
|
||
QVariant response; QVariantMap params;
|
||
QNetworkReply *reply = nullptr;
|
||
QSignalSpy packetSpy(m_mockTcpServer, &MockTcpServer::outgoingData);;
|
||
|
||
Electricity::Phases chargerPhasesConverted = Electricity::convertPhasesFromString(chargerPhases);
|
||
|
||
// Set phase power limit
|
||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
|
||
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
|
||
|
||
// Add mock spotmarket provider
|
||
SpotMarketManager *spotMarketManager = m_experiencePlugin->spotMarketManager();
|
||
SpotMarketDataProviderMock *mockProvider = new SpotMarketDataProviderMock(nullptr, this);
|
||
QVERIFY(mockProvider->prepareResourceData(spotMarketResourceData, simulationStart.toUTC()));
|
||
QVERIFY(spotMarketManager->registerProvider(mockProvider));
|
||
QVERIFY(spotMarketManager->changeProvider(mockProvider->providerId()));
|
||
|
||
// Enabke/disable spot market
|
||
response = injectAndWait("NymeaEnergy.SetSpotMarketConfiguration", QVariantMap({ {"enabled", spotMarketEnabled }, {"providerId", mockProvider->providerId()} }));
|
||
QCOMPARE(response.toMap().value("params").toMap().value("energyError").toString(), "EnergyErrorNoError");
|
||
QCOMPARE(m_experiencePlugin->spotMarketManager()->enabled(), spotMarketEnabled);
|
||
|
||
// Set initial acquisition tolerance
|
||
params.clear(); response.clear();
|
||
params.insert("acquisitionTolerance", acquisitionTolerance);
|
||
response = injectAndWait("NymeaEnergy.SetAcquisitionTolerance", params);
|
||
verifyEnergyError(response);
|
||
|
||
// Set battery level consideration
|
||
params.clear(); response.clear();
|
||
params.insert("batteryLevelConsideration", batteryLevelConsideration);
|
||
response = injectAndWait("NymeaEnergy.SetBatteryLevelConsideration", params);
|
||
verifyEnergyError(response);
|
||
|
||
// Add mock meter
|
||
QUuid meterThingId = addMeter();
|
||
QVERIFY2(!meterThingId.isNull(), "Did not receive valid ThingId");
|
||
if (packetSpy.count() == 0) packetSpy.wait();
|
||
checkNotification(packetSpy, "Integrations.ThingAdded");
|
||
packetSpy.clear();
|
||
|
||
// Set it as root meter
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterThingId);
|
||
|
||
// Make sure this is our root meter now
|
||
response = injectAndWait("Energy.GetRootMeter");
|
||
QCOMPARE(response.toMap().value("params").toMap().value("rootMeterThingId").toUuid(), meterThingId);
|
||
packetSpy.clear();
|
||
|
||
// Add the charger
|
||
QUuid evChargerId;
|
||
if (canSwitchPhaseCount) {
|
||
evChargerId = addChargerWithPhaseCountSwitching(chargerPhases, chargerMaxChargingCurrentMaxValue);
|
||
} else {
|
||
evChargerId = addCharger(chargerPhases, chargerMaxChargingCurrentMaxValue);
|
||
}
|
||
|
||
QVERIFY2(!evChargerId.isNull(), "Did not receive valid ThingId");
|
||
if (packetSpy.count() == 0) packetSpy.wait();
|
||
checkNotification(packetSpy, "Integrations.ThingAdded");
|
||
|
||
// Add the car
|
||
QUuid carThingId = addCar();
|
||
QVERIFY2(!carThingId.isNull(), "Did not receive valid ThingId");
|
||
if (packetSpy.count() == 0) packetSpy.wait();
|
||
checkNotification(packetSpy, "Integrations.ThingAdded");
|
||
|
||
// Add energy storage if available
|
||
QUuid energyStorageThingId;
|
||
if (energyStorageAvailable) {
|
||
energyStorageThingId = addEnergyStorage(energyStorageCapacity, energyStorageMaxChargingPower, energyStorageMaxDischargingPower);
|
||
QVERIFY2(!energyStorageThingId.isNull(), "Did not receive valid ThingId");
|
||
if (packetSpy.count() == 0) packetSpy.wait();
|
||
checkNotification(packetSpy, "Integrations.ThingAdded");
|
||
}
|
||
|
||
// ==============================================================================
|
||
// Set states of car and charger
|
||
|
||
Thing *carThing = NymeaCore::instance()->thingManager()->findConfiguredThing(carThingId);
|
||
QVERIFY2(carThing != nullptr, "Failed to find car thing");
|
||
carThing->setSettingValue(carThing->thingClass().settingsTypes().findByName("phaseCount").id(), carPhaseCount);
|
||
carThing->setSettingValue(carThing->thingClass().settingsTypes().findByName("capacity").id(), carCapacity);
|
||
carThing->setSettingValue(carThing->thingClass().settingsTypes().findByName("minChargingCurrent").id(), carMinChargingCurrent);
|
||
carThing->setStateValue("batteryLevel", carBatteryLevel);
|
||
|
||
Thing *chargerThing = NymeaCore::instance()->thingManager()->findConfiguredThing(evChargerId);
|
||
QVERIFY2(chargerThing != nullptr, "Failed to find charger thing");
|
||
chargerThing->setStateValue("connected", chargerConnected);
|
||
chargerThing->setStateValue("power", chargerPower);
|
||
chargerThing->setStateValue("maxChargingCurrent", chargerMaxChargingCurrent);
|
||
chargerThing->setStateValue("maxChargingCurrentMaxValue", chargerMaxChargingCurrentMaxValue);
|
||
chargerThing->setStateValue("pluggedIn", true); // Initially always plugged in, the rest can be handeld using the plug events
|
||
|
||
// This will update all internal states not set directly
|
||
updateChargerMeter(chargerThing);
|
||
|
||
Thing *meterThing = NymeaCore::instance()->thingManager()->findConfiguredThing(meterThingId);
|
||
QVERIFY2(meterThing != nullptr, "Failed to find meter thing");
|
||
meterThing->setStateValue("connected", true);
|
||
|
||
// Energy storage states
|
||
Thing *energyStorageThing = nullptr;
|
||
if (energyStorageAvailable) {
|
||
energyStorageThing = NymeaCore::instance()->thingManager()->findConfiguredThing(energyStorageThingId);
|
||
energyStorageThing->setStateValue("currentPower", 0);
|
||
energyStorageThing->setStateValue("batteryLevel", energyStorageInitialBatteyLevel);
|
||
energyStorageThing->setProperty("preciseBatteryLevel", energyStorageInitialBatteyLevel * 1.0); // For precise runtime calculations
|
||
printStates(energyStorageThing);
|
||
}
|
||
|
||
// printStates(chargerThing);
|
||
// printStates(carThing);
|
||
// printStates(meterThing);
|
||
|
||
// Set charging info with our charger and car, this should trigger the evaluation
|
||
QVariantMap chargingInfoMap;
|
||
chargingInfoMap.insert("evChargerId", evChargerId);
|
||
chargingInfoMap.insert("assignedCarId", carThingId);
|
||
chargingInfoMap.insert("chargingMode", chargingMode);
|
||
chargingInfoMap.insert("endDateTime", targetDateTime.toMSecsSinceEpoch() / 1000);
|
||
chargingInfoMap.insert("targetPercentage", targetPercentage);
|
||
chargingInfoMap.insert("spotMarketChargingEnabled", spotMarketEnabled);
|
||
chargingInfoMap.insert("dailySpotMarketPercentage", dailySpotMarketPercentage);
|
||
response = injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", chargingInfoMap}}));
|
||
QCOMPARE(response.toMap().value("status").toString(), QString("success"));
|
||
QCOMPARE(response.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError"));
|
||
|
||
uint effectivePhaseCount;
|
||
if (canSwitchPhaseCount) {
|
||
effectivePhaseCount = chargerThing->stateValue("phaseCount").toUInt();
|
||
} else {
|
||
effectivePhaseCount = qMin((uint)carPhaseCount, Electricity::getPhaseCount(chargerPhasesConverted));
|
||
}
|
||
|
||
QString usedPhases;
|
||
if (effectivePhaseCount >= 1)
|
||
usedPhases.append("A");
|
||
|
||
if (effectivePhaseCount >= 2)
|
||
usedPhases.append("B");
|
||
|
||
if (effectivePhaseCount >= 3)
|
||
usedPhases.append("C");
|
||
|
||
chargerThing->setStateValue("usedPhases", usedPhases);
|
||
|
||
|
||
// Note: we want to have the limit negative, so we see better where the limit is and why the chaging stopped
|
||
double aquisitionToleranceLimit = -(effectivePhaseCount * carMinChargingCurrent * 230.0 * acquisitionTolerance);
|
||
|
||
// Simulation output
|
||
QDir simulationBaseDir = QDir(QDir::currentPath() + QDir::separator() + "simulations");
|
||
|
||
QDir workspaceDir = QDir(simulationBaseDir.absolutePath() + QDir::separator() + "workspace");
|
||
if (!workspaceDir.exists()) {
|
||
//QVERIFY2(outputDir.removeRecursively(), "Failed to cleanup output dir");
|
||
QVERIFY2(workspaceDir.mkpath(workspaceDir.path()), "Failed to create results dir");
|
||
}
|
||
|
||
QDir outputDir = QDir(workspaceDir.absolutePath() + QDir::separator() + simulationName);
|
||
if (!outputDir.exists()) {
|
||
// QVERIFY2(outputDir.removeRecursively(), "Failed to cleanup output dir");
|
||
QVERIFY2(outputDir.mkpath(outputDir.path()), "Failed to create output dir");
|
||
}
|
||
|
||
QDir resultsDir = QDir(simulationBaseDir.absolutePath() + QDir::separator() + "results");
|
||
if (!resultsDir.exists()) {
|
||
//QVERIFY2(outputDir.removeRecursively(), "Failed to cleanup output dir");
|
||
QVERIFY2(resultsDir.mkpath(resultsDir.path()), "Failed to create results dir");
|
||
}
|
||
|
||
QFile gnuplotLogFile(outputDir.path() + QDir::separator() + "simulation.csv");
|
||
QVERIFY2(gnuplotLogFile.open(QIODevice::ReadWrite | QIODevice::Truncate), QString("Failed to open logfile for gnuplot" + gnuplotLogFile.fileName() + ": " + gnuplotLogFile.errorString()).toUtf8());
|
||
QTextStream gnuplotLogFileStream(&gnuplotLogFile);
|
||
|
||
QFile gnuplotOriginalLogFile(outputDir.path() + QDir::separator() + "original.csv");
|
||
QVERIFY2(gnuplotOriginalLogFile.open(QIODevice::ReadWrite | QIODevice::Truncate), "Failed to open logfile for gnuplot");
|
||
QTextStream gnuplotOriginalLogFileStream(&gnuplotOriginalLogFile);
|
||
|
||
|
||
// ==============================================================================
|
||
// All set up, lets start simulating
|
||
|
||
QDateTime simulationEnd = simulationStart.addSecs(simulationHours * 3600);
|
||
|
||
PowerBalanceLogEntries powerBalanceLogs = m_experiencePlugin->energyManager()->logs()->powerBalanceLogs(sampleRate, simulationStart, simulationEnd);
|
||
qCDebug(dcSimulation()) << "Simulation start" << simulationStart;
|
||
qCDebug(dcSimulation()) << "Simulation end" << simulationEnd;
|
||
qCDebug(dcSimulation()) << "Loaded" << powerBalanceLogs.count() << "log entries for that day";
|
||
|
||
// Simulation helpers
|
||
int simulationProgress = 0;
|
||
|
||
// Disable init details
|
||
QLoggingCategory::setFilterRules(loggingRulesDefault);
|
||
|
||
// Run the simulation
|
||
for (int i = 0; i < powerBalanceLogs.count(); i++) {
|
||
|
||
bool debugEnabled = (i >= detailsStepStart && i <= detailsStepStop) || detailsStepList.contains(i);
|
||
const PowerBalanceLogEntry entry = powerBalanceLogs.at(i);
|
||
QDateTime currentDateTime = entry.timestamp().toUTC();
|
||
|
||
// Calculate progress
|
||
if (debugEnabled) {
|
||
qCDebug(dcSimulation()) << "###############################################################################################################";
|
||
qCDebug(dcSimulation()) << "Step" << i << ":" << currentDateTime.toUTC().toString("yyyy.MM.dd hh:mm");
|
||
}
|
||
|
||
effectivePhaseCount = chargerThing->stateValue("phaseCount").toUInt();
|
||
usedPhases = chargerThing->stateValue("usedPhases").toString();
|
||
|
||
// Update mocked spotmarket data provider
|
||
mockProvider->setCurrentDataTime(currentDateTime.toUTC());
|
||
|
||
// Print simulation progress
|
||
double simulationProgressPrecise = i * 100 / powerBalanceLogs.count();
|
||
if (simulationProgress != qRound(simulationProgressPrecise)) {
|
||
simulationProgress = qRound(simulationProgressPrecise);
|
||
if (simulationProgress % 10 == 0) {
|
||
qCDebug(dcSimulation()) << simulationName << currentDateTime.toUTC().toString("hh:mm") << simulationProgress << "% (" << i << ")";
|
||
}
|
||
}
|
||
|
||
// Enable logs in the interesting simulation steps
|
||
if (debugEnabled) {
|
||
QLoggingCategory::setFilterRules(loggingRulesDetails);
|
||
} else {
|
||
QLoggingCategory::setFilterRules(loggingRulesDefault);
|
||
}
|
||
|
||
// Get the current situation befor running the simulation step
|
||
double totalProduction = entry.production() * productionScaling;
|
||
double totalProductionDifference = totalProduction - entry.production();
|
||
double scaledCurrentPower = entry.acquisition() + totalProductionDifference;
|
||
//qCDebug(dcSimulation()) << "Scale production using" << productionScaling << entry.production() << "-->" << totalProduction << totalProductionDifference;
|
||
//qCDebug(dcSimulation()) << "Scale aquisition using" << entry.acquisition() << "-->" << scaledCurrentPower;
|
||
|
||
// Distribute the load before battery and charger on 3 phases
|
||
double phaseLoad = scaledCurrentPower / 3;
|
||
|
||
// -------------------- Charger
|
||
|
||
// Handle plug events
|
||
foreach(const ChargerPlugEvent &plugEvent, plugEvents) {
|
||
if (plugEvent.dateTime.date() == currentDateTime.toUTC().date() &&
|
||
plugEvent.dateTime.time().hour() == currentDateTime.toUTC().time().hour() &&
|
||
plugEvent.dateTime.time().minute() == currentDateTime.toUTC().time().minute()) {
|
||
|
||
// Plug event
|
||
chargerThing->setStateValue("pluggedIn", plugEvent.pluggedIn);
|
||
if (plugEvent.percentageUsed != 0) {
|
||
double batteryLevel = carThing->stateValue("batteryLevel").toDouble();
|
||
batteryLevel -= plugEvent.percentageUsed;
|
||
if (batteryLevel < 0) {
|
||
batteryLevel = 0;
|
||
}
|
||
qCDebug(dcSimulation()) << "-->" << currentDateTime.toUTC().toString("hh:mm") << "New car battery level" << batteryLevel << "(old:" << carThing->stateValue("batteryLevel").toDouble() << ")";
|
||
carThing->setStateValue("batteryLevel", batteryLevel);
|
||
}
|
||
qCDebug(dcSimulation()) << "-->" << currentDateTime.toUTC().toString("hh:mm") << "Car has been" << (plugEvent.pluggedIn ? "plugged in" : "unplugged");
|
||
}
|
||
}
|
||
|
||
// Let the charger set all power, voltage etc....
|
||
updateChargerMeter(chargerThing);
|
||
double chargerCurrentPower = chargerThing->stateValue("currentPower").toDouble();
|
||
|
||
double totalConsumption = chargerCurrentPower + entry.consumption();
|
||
|
||
// Totals before battery
|
||
double totalCurrentPower = totalConsumption + totalProduction;
|
||
|
||
// All producers and all consumers have been summed up, charge / discharge the battery and create a final total balance
|
||
|
||
// -------------------- Energy storage
|
||
|
||
double energyStorageCurrentPower = 0;
|
||
uint energyStorageBatteryLevel = 0;
|
||
|
||
// All consumers should have what they get, put the rest into or from the storage within limits
|
||
|
||
if (energyStorageAvailable) {
|
||
|
||
// Calculate the new battery level depending on the previouse step.
|
||
|
||
double esCurrentPower = energyStorageThing->stateValue("currentPower").toDouble();
|
||
double esBatteryLevel = energyStorageThing->property("preciseBatteryLevel").toDouble();
|
||
double esCapacity = energyStorageThing->stateValue("capacity").toDouble();
|
||
|
||
// Let's caclulate the new percentage depending on the rate of the last minute...
|
||
|
||
// We charged/discharged the last minute with energyStorageCurrentPower W
|
||
double energyChargedDischargedkWh = esCurrentPower * 60 / 60 / 60 / 1000;
|
||
double addedPercentage = energyChargedDischargedkWh * 100.0 / esCapacity;
|
||
double newBatteryLevel = esBatteryLevel += addedPercentage;
|
||
|
||
if (totalCurrentPower < 0 && newBatteryLevel < 100) {
|
||
energyStorageCurrentPower = qMin(energyStorageMaxChargingPower, -totalCurrentPower);
|
||
energyStorageBatteryLevel = newBatteryLevel;
|
||
energyStorageThing->setProperty("preciseBatteryLevel", newBatteryLevel);
|
||
setEnergyStorageStates(energyStorageBatteryLevel, energyStorageCurrentPower);
|
||
} else if (totalCurrentPower > 0 && newBatteryLevel > 0) {
|
||
energyStorageCurrentPower = - qMin(energyStorageMaxDischargingPower, totalCurrentPower);
|
||
energyStorageBatteryLevel = newBatteryLevel;
|
||
energyStorageThing->setProperty("preciseBatteryLevel", newBatteryLevel);
|
||
setEnergyStorageStates(energyStorageBatteryLevel, energyStorageCurrentPower);
|
||
} else {
|
||
energyStorageBatteryLevel = newBatteryLevel;
|
||
energyStorageThing->setProperty("preciseBatteryLevel", newBatteryLevel);
|
||
setEnergyStorageStates(energyStorageBatteryLevel, energyStorageCurrentPower);
|
||
}
|
||
|
||
//qCDebug(dcSimulation()) << "Energy storage charged with" << energyStorageCurrentPower << "W" << addedPercentage << "% added to total" << energyStorageBatteryLevel << "%";
|
||
}
|
||
|
||
// -------------------- Meter
|
||
|
||
totalCurrentPower += energyStorageCurrentPower;
|
||
phaseLoad += energyStorageCurrentPower / 3;
|
||
|
||
// Add the charger power in the aproperiate phase
|
||
QVariantMap phases = QVariantMap({ {"A", phaseLoad + chargerThing->stateValue("currentPowerPhaseA").toDouble()},
|
||
{"B", phaseLoad + chargerThing->stateValue("currentPowerPhaseB").toDouble()},
|
||
{"C", phaseLoad + chargerThing->stateValue("currentPowerPhaseC").toDouble()} });
|
||
|
||
reply = setMeterStates(phases, true);
|
||
QSignalSpy setMeterStatesReplySpy(reply, &QNetworkReply::finished);
|
||
if (setMeterStatesReplySpy.count() == 0) setMeterStatesReplySpy.wait();
|
||
QCOMPARE(reply->error(), QNetworkReply::NoError);
|
||
|
||
// -------------------- Run charging manager update
|
||
|
||
// Set charger information and pass them to the logic
|
||
ThingPowerLogEntry chargerPowerEntry(currentDateTime.toUTC(), chargerThing->id(), chargerThing->stateValue("currentPower").toDouble(), 0, 0);
|
||
m_experiencePlugin->smartChargingManager()->simulationCallUpdateManualSoCsWithMeter(sampleRate, chargerPowerEntry);
|
||
|
||
// Update smart charging manager with the current root meter and charger situation
|
||
m_experiencePlugin->smartChargingManager()->simulationCallUpdate(currentDateTime.toUTC());
|
||
|
||
// Fetch information after simulation iteration
|
||
double carBatteryPercentage = carThing->stateValue("batteryLevel").toDouble();
|
||
int maxChargingCurrent = chargerThing->stateValue("maxChargingCurrent").toInt();
|
||
chargerPower = chargerThing->stateValue("power").toBool();
|
||
chargerCurrentPower = chargerThing->stateValue("currentPower").toDouble();
|
||
|
||
|
||
if (debugEnabled | iterationTest.contains(i)) {
|
||
qCDebug(dcSimulation()) << "Step" << i;
|
||
qCDebug(dcSimulation()) << "- Total power:" << totalCurrentPower << "Production:" << totalProduction << "Consumption:" << totalConsumption;
|
||
if (energyStorageAvailable) {
|
||
qCDebug(dcSimulation()) << "- Energy storage:" << energyStorageCurrentPower << energyStorageThing->property("preciseBatteryLevel").toDouble() << "%";
|
||
}
|
||
qCDebug(dcSimulation()) << "- Meter phases: A:" << meterThing->stateValue("currentPowerPhaseA").toDouble() << "W | B:"
|
||
<< meterThing->stateValue("currentPowerPhaseB").toDouble() << "W | C:" << meterThing->stateValue("currentPowerPhaseC").toDouble() << "W";
|
||
qCDebug(dcSimulation()) << "- Charger:" << chargerCurrentPower << "[W] (" << maxChargingCurrent << "[A]" << (chargerCurrentPower ? "On)" : "Off )") << effectivePhaseCount << usedPhases;
|
||
qCDebug(dcSimulation()) << "- Charger phases: A:" << chargerThing->stateValue("currentPowerPhaseA").toDouble() << "W | B:"
|
||
<< chargerThing->stateValue("currentPowerPhaseB").toDouble() << "W | C:" << chargerThing->stateValue("currentPowerPhaseC").toDouble() << "W";
|
||
qCDebug(dcSimulation()) << "- Car battery:" << carBatteryPercentage;
|
||
qCDebug(dcSimulation()) << "--------------------------------";
|
||
|
||
// printStates(meterThing);
|
||
// printStates(chargerThing);
|
||
// printStates(carThing);
|
||
|
||
}
|
||
|
||
// Verify test points
|
||
foreach(const SimulationTestPoint &testPoint, iterationTest.value(i)) {
|
||
switch(testPoint.testType()) {
|
||
case SimulationTestPoint::TestTypeCharging:
|
||
QVERIFY2(chargerPower == testPoint.expectedValue().toBool(),
|
||
qPrintable(QString("Simulation: %1 - %2 Step: %3 expected \"%4\" from the testpoint but is actually \"%5\"")
|
||
.arg(simulationName)
|
||
.arg(simulationTitle)
|
||
.arg(i)
|
||
.arg(testPoint.expectedValue().toBool() ? "true" : "false")
|
||
.arg(chargerPower ? "true" : "false")));
|
||
|
||
break;
|
||
case SimulationTestPoint::TestTypeMaxChargingCurrent:
|
||
QVERIFY2(maxChargingCurrent == testPoint.expectedValue().toInt(),
|
||
qPrintable(QString("Simulation: %1 - %2 Step: %3 expected \"%4\" from the testpoint but is actually \"%5\"")
|
||
.arg(simulationName)
|
||
.arg(simulationTitle)
|
||
.arg(i)
|
||
.arg(testPoint.expectedValue().toInt())
|
||
.arg(maxChargingCurrent)));
|
||
|
||
break;
|
||
case SimulationTestPoint::TestTypeStateOfCharge:
|
||
QVERIFY2(qFuzzyCompare(carBatteryPercentage, testPoint.expectedValue().toDouble()),
|
||
qPrintable(QString("Simulation: %1 - %2 Step: %3 expected \"%4\" from the testpoint but is actually \"%5\"")
|
||
.arg(simulationName)
|
||
.arg(simulationTitle)
|
||
.arg(i)
|
||
.arg(testPoint.expectedValue().toDouble())
|
||
.arg(carBatteryPercentage)));
|
||
|
||
break;
|
||
}
|
||
}
|
||
|
||
// -------------------- Data logging
|
||
|
||
// Log the simulation data
|
||
gnuplotLogFileStream << currentDateTime.toMSecsSinceEpoch() / 1000 << ", " << // 1
|
||
totalCurrentPower << ", " << // 2
|
||
totalProduction << ", " << // 3
|
||
totalConsumption << ", " << // 4
|
||
chargerCurrentPower << ", " << // 5
|
||
maxChargingCurrent << ", " << // 6
|
||
(chargerPower ? "1" : "0") << ", " << // 7
|
||
chargerThing->state("maxChargingCurrent").minValue().toDouble() * 230 * effectivePhaseCount << ", " << // 8
|
||
chargerThing->state("maxChargingCurrent").maxValue().toDouble() * 230 * effectivePhaseCount << ", " << // 9
|
||
phasePowerLimit * 230 * effectivePhaseCount << ", " << // 10
|
||
carBatteryPercentage << ", " << // 11
|
||
i << ", " << // 12
|
||
aquisitionToleranceLimit << ", " << // 13
|
||
(chargerThing->stateValue("pluggedIn").toBool() ? 10 : 0 ) << ", "; // 14
|
||
if (spotMarketEnabled) {
|
||
const ScoreEntries weightedEntries = spotMarketManager->weightedScoreEntries(currentDateTime.date());
|
||
const ScoreEntry currentScore = weightedEntries.getScoreEntry(currentDateTime.toUTC());
|
||
QVERIFY(!currentScore.isNull());
|
||
gnuplotLogFileStream << currentScore.weighting() * 100 << ", "; // 15
|
||
gnuplotLogFileStream << currentScore.value() / 10.0 << ", "; // 16 Price
|
||
} else {
|
||
gnuplotLogFileStream << 0 << ", "; // 15
|
||
gnuplotLogFileStream << 0 << ", "; // 16
|
||
}
|
||
if (energyStorageAvailable) {
|
||
gnuplotLogFileStream << energyStorageCurrentPower << ", "; // 17
|
||
gnuplotLogFileStream << energyStorageBatteryLevel << ", "; // 18
|
||
} else {
|
||
gnuplotLogFileStream << 0 << ", "; // 17
|
||
gnuplotLogFileStream << 0 << ", "; // 18
|
||
}
|
||
gnuplotLogFileStream << "\n";
|
||
|
||
|
||
|
||
// Log Unchanged for raw data analysis
|
||
gnuplotOriginalLogFileStream << currentDateTime.toMSecsSinceEpoch() / 1000 << ", " << // 1
|
||
scaledCurrentPower << ", " << // 2
|
||
totalProduction << ", " << // 3
|
||
entry.consumption() << ", " << // 4
|
||
phasePowerLimit * 230 * effectivePhaseCount << ", " << // 5
|
||
i << ", " << // 6
|
||
"\n";
|
||
}
|
||
|
||
gnuplotLogFile.close();
|
||
gnuplotOriginalLogFile.close();
|
||
|
||
// Draw original data
|
||
QStringList scriptLines;
|
||
QStringList plotLines;
|
||
|
||
// Plot with: 1h = 200 px
|
||
int height = 800;
|
||
int width = simulationHours * 200;
|
||
|
||
QString originalImageName = simulationName + "-00-original.png";
|
||
QString simulationImageName = simulationName + "-01.png";
|
||
|
||
scriptLines.append("set term png size " + QString::number(width) + "," + QString::number(height));
|
||
scriptLines.append("set output '" + originalImageName + "'");
|
||
scriptLines.append("set datafile separator ','");
|
||
scriptLines.append(plotOriginalData(powerBalanceLogs.count()));
|
||
|
||
if (spotMarketEnabled) {
|
||
scriptLines.append("set term png size " + QString::number(width) + "," + QString::number(height * 2));
|
||
scriptLines.append("set output '" + simulationImageName + "'");
|
||
scriptLines.append("set datafile separator ','");
|
||
|
||
scriptLines.append("set multiplot layout 2,1");
|
||
|
||
scriptLines.append("set size 1,0.8");
|
||
scriptLines.append("set origin 0,0.2");
|
||
scriptLines.append(plotSimulation(simulationTitle, powerBalanceLogs.count()));
|
||
|
||
scriptLines.append("set size 1,0.2");
|
||
scriptLines.append("set origin 0,0");
|
||
scriptLines.append(plotSpotMarketData(powerBalanceLogs.count()));
|
||
|
||
scriptLines.append("unset multiplot");
|
||
|
||
} else {
|
||
scriptLines.append("set term png size " + QString::number(width) + "," + QString::number(height));
|
||
scriptLines.append("set output '" + simulationImageName + "'");
|
||
scriptLines.append("set datafile separator ','");
|
||
scriptLines.append(plotSimulation(simulationTitle, powerBalanceLogs.count()));
|
||
}
|
||
|
||
// Write the gnuplot script
|
||
QFile gnuplotScript(outputDir.path() + QDir::separator() + "script.gnuplot");
|
||
QVERIFY2(gnuplotScript.open(QIODevice::ReadWrite | QIODevice::Truncate),
|
||
QString("Failed to open script file for gnuplot" + gnuplotScript.fileName() + ": " + gnuplotScript.errorString()).toUtf8());
|
||
QTextStream scriptStream(&gnuplotScript);
|
||
foreach (const QString &line, scriptLines)
|
||
scriptStream << line << "\n";
|
||
|
||
gnuplotScript.close();
|
||
|
||
|
||
// Write the executable gnuplot script
|
||
|
||
scriptLines.clear();
|
||
|
||
scriptLines.append("set terminal wxt 1 persist");
|
||
scriptLines.append("set datafile separator ','");
|
||
|
||
if (spotMarketEnabled) {
|
||
|
||
scriptLines.append("set multiplot layout 2,1");
|
||
|
||
scriptLines.append("set size 1,0.8");
|
||
scriptLines.append("set origin 0,0.2");
|
||
scriptLines.append(plotSimulation(simulationTitle, powerBalanceLogs.count()));
|
||
|
||
scriptLines.append("set size 1,0.2");
|
||
scriptLines.append("set origin 0,0");
|
||
scriptLines.append(plotSpotMarketData(powerBalanceLogs.count()));
|
||
|
||
scriptLines.append("unset multiplot");
|
||
|
||
} else {
|
||
scriptLines.append(plotSimulation(simulationTitle, powerBalanceLogs.count()));
|
||
}
|
||
|
||
QString executableScriptName = simulationName + ".gnuplot";
|
||
QFile executableGnuplotScript(outputDir.path() + QDir::separator() + executableScriptName);
|
||
QVERIFY2(executableGnuplotScript.open(QIODevice::ReadWrite | QIODevice::Truncate),
|
||
QString("Failed to open logfile for gnuplot" + executableGnuplotScript.fileName() + ": " + executableGnuplotScript.errorString()).toUtf8());
|
||
QTextStream executableScriptStream(&executableGnuplotScript);
|
||
foreach (const QString &line, scriptLines)
|
||
executableScriptStream << line << "\n";
|
||
|
||
executableGnuplotScript.close();
|
||
|
||
|
||
|
||
QProcess gnuplotProcess;
|
||
//gnuplotProcess.setEnvironment(QProcessEnvironment::systemEnvironment().toStringList());
|
||
gnuplotProcess.setProcessChannelMode(QProcess::MergedChannels);
|
||
gnuplotProcess.setWorkingDirectory(outputDir.path());
|
||
gnuplotProcess.start("gnuplot", { "-c", "script.gnuplot"});
|
||
gnuplotProcess.waitForFinished();
|
||
qCDebug(dcSimulation()) << "gnuplot finished" << gnuplotProcess.arguments() << gnuplotProcess.workingDirectory() << gnuplotProcess.exitCode() << gnuplotProcess.exitStatus();
|
||
if (gnuplotProcess.exitCode() != 0) {
|
||
qCDebug(dcSimulation()) << "error plotting data:\n" << qUtf8Printable(gnuplotProcess.readAll());
|
||
QVERIFY2(false, "plot process finished with error");
|
||
}
|
||
|
||
// Copy resulting images to the simulations
|
||
QFile::copy(outputDir.path() + QDir::separator() + originalImageName, resultsDir.path() + QDir::separator() + originalImageName);
|
||
QFile::copy(outputDir.path() + QDir::separator() + simulationImageName, resultsDir.path() + QDir::separator() + simulationImageName);
|
||
}
|
||
|
||
void Simulation::testDecisionReasonRendering()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testDecisionReasonRendering nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [Lot B §1 / critère 6] « Journal INCHANGÉ ».
|
||
//
|
||
// Depuis la refonte, LoadAction::reason porte un CODE et des PARAMÈTRES ; la ligne
|
||
// française est RENDUE par renderFr(). Le risque de la manœuvre est là et nulle part
|
||
// ailleurs : une phrase reformulée « au passage ». Les motifs d'ECS-309 et ECS-309-b sont
|
||
// le fruit de corrections coûteuses au banc — les réécrire enverrait à nouveau
|
||
// diagnostiquer le mauvais problème, avec l'autorité d'une explication.
|
||
//
|
||
// Ce tableau est donc la copie MOT POUR MOT des QStringLiteral d'avant la refonte. Il se
|
||
// relit contre `git show HEAD:energyplugin/etm/scheduler/rulebasedscheduler.cpp`. Un test
|
||
// « motif non vide » n'aurait rien vu ici — c'est précisément la leçon d'ECS-309.
|
||
struct Cas { DecisionReason reason; QString label; QString attendu; };
|
||
const QList<Cas> table = {
|
||
// --- EV (proxy amont) : aucune charge nommée, donc aucun libellé ---
|
||
{ { DecisionCode::EvSurplus }, QString(),
|
||
QStringLiteral("Surplus PV disponible — recharge solaire") },
|
||
{ { DecisionCode::EvSpotMarket }, QString(),
|
||
QStringLiteral("Tarif aWATTar favorable — recharge heure creuse") },
|
||
{ { DecisionCode::EvDeadline }, QString(),
|
||
QStringLiteral("Deadline VE approchante — recharge prioritaire") },
|
||
{ { DecisionCode::EvEcoMin }, QString(),
|
||
QStringLiteral("Aucun surplus — courant minimum maintenu (mode EcoMin)") },
|
||
{ { DecisionCode::EvIdle }, QString(),
|
||
QStringLiteral("Aucun surplus disponible — recharge suspendue") },
|
||
|
||
// --- Charges pilotées en watts ---
|
||
{ { DecisionCode::LoadUnavailable, {{"frozenW", 1500}} }, QStringLiteral("Chauffe-eau"),
|
||
QStringLiteral("Chauffe-eau INDISPONIBLE — aucune commande émise ; 1500 W toujours "
|
||
"comptés au budget (levée par ClearLoadFault)") },
|
||
{ { DecisionCode::LockMinOn, {{"appliedW", 2000}, {"budgetW", 600}} },
|
||
QStringLiteral("ECS prioritaire"),
|
||
QStringLiteral("Verrou minOn — ECS prioritaire maintenue à 2000 W (puissance engagée, "
|
||
"budget 600 W) ; le résidu en tient compte") },
|
||
{ { DecisionCode::LockMinOff, {{"budgetW", 7706}, {"requestedW", 1000}} },
|
||
QStringLiteral("Charge à froid"),
|
||
QStringLiteral("Verrou minOff — Charge à froid ne peut pas s'enclencher avant la fin "
|
||
"de la fenêtre (budget 7706 W disponible, palier 1000 W refusé)") },
|
||
{ { DecisionCode::LockMinOffCapped,
|
||
{{"appliedW", 1000}, {"budgetW", 4000}, {"requestedW", 2500}} },
|
||
QStringLiteral("ECS relais"),
|
||
QStringLiteral("Verrou minOff — ECS relais plafonnée à 1000 W avant la fin de la "
|
||
"fenêtre (budget 4000 W disponible, palier 2500 W refusé)") },
|
||
{ { DecisionCode::SurplusSetpoint,
|
||
{{"budgetW", 3000}, {"setpointW", 2400}, {"stepped", true}} },
|
||
QStringLiteral("Chauffe-eau"),
|
||
QStringLiteral("Surplus PV 3000 W — Chauffe-eau consigne 2400 W (palier)") },
|
||
{ { DecisionCode::SurplusSetpoint,
|
||
{{"budgetW", 3000}, {"setpointW", 3000}, {"stepped", false}} },
|
||
QStringLiteral("Routeur PV"),
|
||
QStringLiteral("Surplus PV 3000 W — Routeur PV consigne 3000 W") },
|
||
{ { DecisionCode::SurplusInsufficient, {{"budgetW", -389}} },
|
||
QStringLiteral("Charge à froid 3"),
|
||
QStringLiteral("Surplus insuffisant (-389 W) — Charge à froid 3 à 0 W") },
|
||
|
||
// --- SG-Ready ---
|
||
{ { DecisionCode::LockMinStateHold, {{"state", 3}} }, QStringLiteral("PAC test"),
|
||
QStringLiteral("Verrou minStateHold — PAC test maintenue état 3 (court-cycling PAC)") },
|
||
{ { DecisionCode::SgForced, {{"budgetW", 4000}, {"estimatedW", 3000}} },
|
||
QStringLiteral("PAC test"),
|
||
QStringLiteral("Surplus abondant 4000 W — PAC test forcée (état 4, ~3000 W)") },
|
||
{ { DecisionCode::SgRecommended, {{"budgetW", 2000}, {"estimatedW", 1500}} },
|
||
QStringLiteral("PAC test"),
|
||
QStringLiteral("Surplus PV 2000 W — PAC test recommandée (état 3, ~1500 W)") },
|
||
// §12 / f134048 — SG_NORMAL ne sort plus QUE pour un budget nul ou négatif : le cas
|
||
// « budget présent mais sous l'état 3 » est passé à BELOW_MIN_POWER. La phrase dit
|
||
// donc « aucun surplus », là où « insuffisant » couvrait aussi, et à tort, un budget
|
||
// de 933 W devant un état à 1 500 W.
|
||
{ { DecisionCode::SgNormal, {{"budgetW", 1000}} }, QStringLiteral("PAC test"),
|
||
QStringLiteral("Aucun surplus à allouer (1000 W) — PAC test en normal (état 2, non pilotée)") },
|
||
|
||
// 3g-1 — les deux motifs sur lesquels le banc s'appuie pour distinguer « il n'y a
|
||
// rien » de « il y en a, mais pas au bon format » ou « une règle le retient ». Leur
|
||
// TEXTE n'était pas éprouvé : ils ont rejoint le catalogue sans ligne ici.
|
||
{ { DecisionCode::BatteryReserve,
|
||
{{"withheldW", 3200}, {"socPercent", 35}, {"reservePercent", 80}} },
|
||
QStringLiteral("Chauffe-eau"),
|
||
QStringLiteral("3200 W de surplus retenus — batterie à 35 %, sous la réserve de 80 % ; "
|
||
"aucune charge n'est servie") },
|
||
{ { DecisionCode::BelowMinPower, {{"budgetW", 1096}, {"minPowerW", 2070}} },
|
||
QStringLiteral("Borne du garage"),
|
||
QStringLiteral("Budget 1096 W sous le premier palier de Borne du garage (2070 W) — 0 W, "
|
||
"le budget passe à la charge suivante") },
|
||
// La variante à ÉTATS nomme l'état hors d'atteinte : sans lui, une phrase vague en
|
||
// remplacerait une autre (note de DecisionCode::BelowMinPower).
|
||
{ { DecisionCode::BelowMinPower, {{"budgetW", 933}, {"minPowerW", 1500}, {"state", 3}} },
|
||
QStringLiteral("PAC test"),
|
||
QStringLiteral("Budget 933 W sous l'état 3 de PAC test (1500 W estimés) — état 2, "
|
||
"le budget passe à la charge suivante") },
|
||
|
||
// 3g-2 — le démarrage sous tolérance est une décision d'ACHETER : la phrase doit
|
||
// chiffrer ce qui est soutiré, sans quoi elle se lit comme une erreur d'allocation.
|
||
{ { DecisionCode::EvGridStart,
|
||
{{"budgetW", 2200}, {"floorW", 4140}, {"gridW", 1940}} },
|
||
QStringLiteral("Borne du garage"),
|
||
QStringLiteral("Démarrage de Borne du garage : plancher 4140 W, dont 1940 W SOUTIRÉS au "
|
||
"réseau (2200 W de surplus disponibles, tolérance d'acquisition)") },
|
||
// 3g-2 — et quand c'est la limite de phase qui interdit, elle est NOMMÉE : le geste
|
||
// attendu n'est pas d'attendre le soleil, c'est de regarder l'abonnement.
|
||
{ { DecisionCode::PhaseLimit, {{"limitW", 1200}, {"requiredW", 4140}} },
|
||
QStringLiteral("Borne du garage"),
|
||
QStringLiteral("Limite de phase : 1200 W disponibles pour Borne du garage, sous les "
|
||
"4140 W qu'elle exige — aucun démarrage possible ce cycle") },
|
||
|
||
// --- Sécurité et cycle de vie ---
|
||
{ { DecisionCode::DegradedL2 }, QString(),
|
||
QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)") },
|
||
{ { DecisionCode::SafeStateRelay }, QString(),
|
||
QStringLiteral("Charge désactivée — mise en état sûr avant retrait (ECS-413)") },
|
||
{ { DecisionCode::SafeStateSetpoint }, QString(),
|
||
QStringLiteral("Charge désactivée — mise en état sûr (0 W) avant retrait (ECS-413)") },
|
||
{ { DecisionCode::SafeStateSgReady }, QString(),
|
||
QStringLiteral("Charge désactivée — mise en état sûr (état 2) avant retrait (ECS-413)") },
|
||
};
|
||
|
||
QSet<QString> codesCouverts;
|
||
for (const Cas &c : table) {
|
||
const QString rendu = renderFr(c.reason, c.label);
|
||
QVERIFY2(rendu == c.attendu,
|
||
qUtf8Printable(QStringLiteral("code %1\n rendu : %2\n attendu : %3")
|
||
.arg(c.reason.code, rendu, c.attendu)));
|
||
codesCouverts.insert(c.reason.code);
|
||
}
|
||
|
||
// Le catalogue est FERMÉ : tout code ajouté sans ligne de tableau doit faire échouer ce
|
||
// test, sinon la garantie « le journal est rendu depuis le code » se perd au premier oubli.
|
||
const QStringList catalogue = {
|
||
DecisionCode::EvSurplus, DecisionCode::EvSpotMarket, DecisionCode::EvDeadline,
|
||
DecisionCode::EvEcoMin, DecisionCode::EvIdle, DecisionCode::LoadUnavailable,
|
||
DecisionCode::LockMinOn, DecisionCode::LockMinOff, DecisionCode::LockMinOffCapped,
|
||
DecisionCode::SurplusSetpoint, DecisionCode::SurplusInsufficient,
|
||
DecisionCode::LockMinStateHold, DecisionCode::SgForced, DecisionCode::SgRecommended,
|
||
DecisionCode::SgNormal, DecisionCode::DegradedL2, DecisionCode::SafeStateRelay,
|
||
DecisionCode::SafeStateSetpoint, DecisionCode::SafeStateSgReady,
|
||
// 3g-1 : ajoutés au catalogue sans être ajoutés ICI — la clôture avait dérivé, donc
|
||
// la garantie « tout code a une ligne de rendu éprouvée » ne tenait plus.
|
||
DecisionCode::BatteryReserve, DecisionCode::BelowMinPower,
|
||
// 3g-2
|
||
DecisionCode::EvGridStart, DecisionCode::PhaseLimit };
|
||
for (const QString &code : catalogue)
|
||
QVERIFY2(codesCouverts.contains(code),
|
||
qUtf8Printable(QStringLiteral("code %1 sans ligne de rendu attendue").arg(code)));
|
||
|
||
// Repli d'un code HORS catalogue : jamais une chaîne vide. Un rendu vide ferait passer un
|
||
// motif présent-mais-inconnu pour un motif absent — le silence ambigu de la règle 7-c.
|
||
const QString inconnu = renderFr(DecisionReason("CODE_QUI_NEXISTE_PAS", {{"x", 1}}),
|
||
QStringLiteral("Charge"));
|
||
QVERIFY(!inconnu.isEmpty());
|
||
QVERIFY2(inconnu.contains("CODE_QUI_NEXISTE_PAS"), qUtf8Printable(inconnu));
|
||
QVERIFY(!renderFr(DecisionReason()).isEmpty()); // motif absent : dit, pas tu
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testLoadTelemetryRpc()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testLoadTelemetryRpc nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [Lot B §2] La frontière de télémétrie, de bout en bout : c'est la couche que l'app
|
||
// consomme. Le constat qui a motivé ce lot était que l'état runtime de l'arbitre ne
|
||
// franchissait AUCUNE frontière RPC — 90 s d'écoute, arbitre actif, zéro notification.
|
||
//
|
||
// Couvre les critères d'acceptation 1 à 5 : notification non nulle et cohérente,
|
||
// snapshot ≡ notification, reprise à froid du mode dégradé, arbitre figé détectable,
|
||
// zéro langue naturelle dans la charge utile.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-telemetry.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
// Le harnais de simulation n'abonne PAS le client aux notifications (il charge son
|
||
// experience mock APRÈS initTestCase, donc EnergyTestBase saute l'abonnement). Or ce test
|
||
// porte précisément sur les notifications : sans cet appel, il « passerait » en observant
|
||
// le silence qu'il est censé faire échouer.
|
||
enableNotifications({"NymeaEnergy"});
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
// ---- 0. AVANT tout cycle : « pas encore d'arbitrage » se DIT, il ne se devine pas ------
|
||
// timestamp ABSENT (omis, jamais nul) : forger un instant se lirait « cycle exécuté, rien à
|
||
// allouer », soit exactement le mensonge que ce champ existe pour empêcher.
|
||
QVariant resp = injectAndWait("NymeaEnergy.GetLoadTelemetry");
|
||
QVariantMap vide = resp.toMap().value("params").toMap();
|
||
QVERIFY2(!vide.contains("timestamp"), "timestamp forgé avant le premier cycle");
|
||
QVERIFY(vide.contains("degradedMode"));
|
||
QCOMPARE(vide.value("degradedMode").toBool(), false);
|
||
QVERIFY(vide.value("loads").toList().isEmpty());
|
||
|
||
// ---- Trois mécanismes, un seul budget : l'union discriminée doit sortir ENTIÈRE --------
|
||
QUuid rA = addPowerSwitch(1000, 26661); // relay-router (1 relais, minOn 300 s)
|
||
QUuid k1 = addPowerSwitch(0, 26662); // sg-ready K1
|
||
QUuid k2 = addPowerSwitch(0, 26663); // sg-ready K2
|
||
QUuid varId = addEtmVariableLoad(27040); // etmvariableload dynamic
|
||
Thing *relayA = tm->findConfiguredThing(rA);
|
||
Thing *varThing = tm->findConfiguredThing(varId);
|
||
QVERIFY(relayA && varThing);
|
||
QVERIFY(tm->findConfiguredThing(k1) && tm->findConfiguredThing(k2));
|
||
varThing->setStateValue("currentPowerW", 0);
|
||
|
||
LoadConfigStore *store = new LoadConfigStore(arb);
|
||
arb->setLoadConfigStore(store);
|
||
|
||
LoadConfigs cfgs;
|
||
cfgs.append(LoadConfig::fromMap(QVariantMap{
|
||
{"id", varId.toString()}, {"label", "Routeur PV"}, {"adapter", "etmvariableload"},
|
||
{"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 1}, {"enabled", true},
|
||
{"domain", "ecs"}}));
|
||
cfgs.append(LoadConfig::fromMap(QVariantMap{
|
||
{"id", "pac-telemetrie"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"},
|
||
{"priority", 2}, {"enabled", true}, {"domain", "hvac"},
|
||
{"sgReady", QVariantMap{
|
||
{"minStateHoldS", 300},
|
||
{"states", QVariantList()
|
||
<< QVariantMap{{"state", 1}, {"relays", QVariantList() << k1.toString()},
|
||
{"estimatedPowerW", 0.0}}
|
||
<< QVariantMap{{"state", 2}, {"relays", QVariantList()},
|
||
{"estimatedPowerW", 0.0}}
|
||
<< QVariantMap{{"state", 3}, {"relays", QVariantList() << k2.toString()},
|
||
{"estimatedPowerW", 800.0}}
|
||
<< QVariantMap{{"state", 4},
|
||
{"relays", QVariantList() << k1.toString() << k2.toString()},
|
||
{"estimatedPowerW", 1600.0}}}}}}));
|
||
cfgs.append(LoadConfig::fromMap(QVariantMap{
|
||
{"id", "ecs-relais-telemetrie"}, {"label", "ECS relais"}, {"adapter", "relay-router"},
|
||
{"mode", "fixed"}, {"priority", 3}, {"enabled", true}, {"domain", "ecs"},
|
||
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, {"powerW", 1000}}},
|
||
{"minOnS", 300}, {"minOffS", 0}}));
|
||
QString err;
|
||
QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8());
|
||
|
||
// ---- 1. Le CONSTAT rejoué : un cycle d'arbitrage produit-il une notification ? ---------
|
||
QSignalSpy notificationSpy(m_mockTcpServer, &MockTcpServer::outgoingData);
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
meter->setStateValue("currentPower", -6000); // 6000 W exportés
|
||
arb->simulationCallUpdate(t0);
|
||
QCoreApplication::processEvents();
|
||
|
||
QVariantList notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged");
|
||
QVERIFY2(!notifs.isEmpty(), "aucune notification LoadTelemetryChanged — c'est le constat "
|
||
"d'origine, arbitre actif et frontière RPC muette");
|
||
QVariantMap premier = notifs.last().toMap().value("params").toMap();
|
||
|
||
// PREMIER cycle : la PAC est GELÉE par l'armement à froid du minStateHold (ECS-412). Ce
|
||
// n'est pas un détail de test — c'est ce que l'app verra après CHAQUE redémarrage de
|
||
// nymead, pendant la durée configurée. La télémétrie doit donc le dire, et le dire en
|
||
// clair : un verrou nommé, des secondes restantes, et un motif qui accuse le verrou et
|
||
// non le budget.
|
||
{
|
||
QVariantMap parIdC1;
|
||
for (const QVariant &v : premier.value("loads").toList())
|
||
parIdC1.insert(v.toMap().value("loadId").toString(), v.toMap());
|
||
const QVariantMap pacC1 = parIdC1.value("pac-telemetrie").toMap();
|
||
QCOMPARE(pacC1.value("mechanism").toMap().value("state").toInt(), 2);
|
||
QCOMPARE(qRound(pacC1.value("allocatedW").toDouble()), 0);
|
||
QCOMPARE(pacC1.value("decision").toMap().value("code").toString(),
|
||
QString(DecisionCode::LockMinStateHold));
|
||
QCOMPARE(pacC1.value("lock").toMap().value("kind").toString(), QString("minStateHold"));
|
||
QCOMPARE(pacC1.value("lock").toMap().value("remainingS").toInt(), 300);
|
||
// Le budget du cycle en tient compte : la PAC n'a rien reçu.
|
||
QCOMPARE(qRound(premier.value("budget").toMap().value("allocatedW").toDouble()), 4000);
|
||
// Le relais, lui, vient de commuter : sa fenêtre minOn est pleine.
|
||
const QVariantMap relaisC1 = parIdC1.value("ecs-relais-telemetrie").toMap();
|
||
QCOMPARE(relaisC1.value("lock").toMap().value("kind").toString(), QString("minOn"));
|
||
QCOMPARE(relaisC1.value("lock").toMap().value("remainingS").toInt(), 300);
|
||
}
|
||
|
||
// ---- Le verrou EXPIRE, et la PAC prend sa part ------------------------------------------
|
||
notificationSpy.clear();
|
||
const QDateTime t1 = t0.addSecs(301); // > minStateHoldS (300)
|
||
arb->simulationCallUpdate(t1);
|
||
QCoreApplication::processEvents();
|
||
notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged");
|
||
QVERIFY2(!notifs.isEmpty(), "le déblocage de la PAC change l'allocation : il doit notifier");
|
||
QVariantMap notif = notifs.last().toMap().value("params").toMap();
|
||
|
||
// Cohérence avec la cascade : Routeur PV (rang 1) prend 3000 (clamp maxPowerW), reliquat
|
||
// 3000 → PAC (rang 2) forcée état 4 (~1600), reliquat 1400 → ECS relais (rang 3) 1000.
|
||
QCOMPARE(notif.value("degradedMode").toBool(), false);
|
||
QVERIFY(notif.contains("timestamp"));
|
||
QCOMPARE(notif.value("timestamp").toString(), t1.toUTC().toString(Qt::ISODate));
|
||
|
||
const QVariantMap budget = notif.value("budget").toMap();
|
||
QCOMPARE(qRound(budget.value("surplusW").toDouble()), 6000);
|
||
QCOMPARE(qRound(budget.value("evReservedW").toDouble()), 0);
|
||
QCOMPARE(qRound(budget.value("allocatedW").toDouble()), 5600);
|
||
QCOMPARE(qRound(budget.value("remainingW").toDouble()), 1400);
|
||
// ---- L'ABSENCE de `draw` se défend, parce que rien ne casse quand on la comble -----------
|
||
//
|
||
// `draw` ne porte qu'un champ : `committedW`. Les quatre autres du §14a — authorisedW,
|
||
// remainingW, binding, perPhaseBound — sont ABSENTS, jamais à 0. Le contrat le dit, mais un
|
||
// contrat ne fait échouer aucune compilation : le jour où quelqu'un ajoutera `authorisedW:
|
||
// 0` « pour compléter l'objet », six mois plus tard, rien ne le retiendra — et l'app
|
||
// affichera un plafond de soutirage qui n'existe pas, avec sa source, faute d'avoir un
|
||
// moyen de distinguer « pas de plafond » de « plafond nul ».
|
||
//
|
||
// Ce test est ce moyen. S'il casse, ce n'est pas lui qu'il faut corriger : c'est le champ
|
||
// ajouté qui doit arriver AVEC son transport §14a et une valeur vraie, pas à zéro.
|
||
const QVariantMap draw = notif.value("draw").toMap();
|
||
QCOMPARE(draw.keys(), QStringList{QStringLiteral("committedW")});
|
||
|
||
// Le relais tire déjà ses 1000 W depuis le cycle précédent : ils lui sont RECRÉDITÉS avant
|
||
// arrondi (anti-clignotement). Sans ce terme publié, l'identité serait fausse ici même.
|
||
QCOMPARE(qRound(budget.value("recreditedW").toDouble()), 1000);
|
||
// L'invariant publié : surplus + recrédit − alloué == restant. Le recrédit y figure parce
|
||
// que sans lui l'identité serait FAUSSE dès qu'une charge consomme déjà — c'est-à-dire dans
|
||
// le cas normal — et le client chercherait un écart inexplicable.
|
||
QCOMPARE(qRound(budget.value("surplusW").toDouble() + budget.value("recreditedW").toDouble()
|
||
- budget.value("allocatedW").toDouble()),
|
||
qRound(budget.value("remainingW").toDouble()));
|
||
|
||
const QVariantList loads = notif.value("loads").toList();
|
||
QCOMPARE(loads.size(), 3); // l'EV n'y est pas : décidé en amont
|
||
QVariantMap parId;
|
||
double sommeAllouee = 0;
|
||
for (const QVariant &v : loads) {
|
||
const QVariantMap e = v.toMap();
|
||
parId.insert(e.value("loadId").toString(), e);
|
||
sommeAllouee += e.value("allocatedW").toDouble();
|
||
}
|
||
// La somme des allocations RETOMBE sur budget.allocatedW : sans quoi un client qui
|
||
// additionne les lignes obtiendrait un total en désaccord avec l'en-tête.
|
||
QCOMPARE(qRound(sommeAllouee), qRound(budget.value("allocatedW").toDouble()));
|
||
|
||
const QVariantMap eVar = parId.value(varId.toString()).toMap();
|
||
const QVariantMap ePac = parId.value("pac-telemetrie").toMap();
|
||
const QVariantMap eRelay = parId.value("ecs-relais-telemetrie").toMap();
|
||
QVERIFY(!eVar.isEmpty() && !ePac.isEmpty() && !eRelay.isEmpty());
|
||
|
||
// Union discriminée de mécanisme : chaque variante sort avec SA charge utile, et seulement
|
||
// la sienne (LM-300/302 appliqués au sens sortant).
|
||
QCOMPARE(eVar.value("mechanism").toMap().value("kind").toString(), QString("variable"));
|
||
QCOMPARE(qRound(eVar.value("mechanism").toMap().value("setpointW").toDouble()), 3000);
|
||
QCOMPARE(qRound(eVar.value("mechanism").toMap().value("percent").toDouble()), 100);
|
||
QVERIFY(!eVar.value("mechanism").toMap().contains("state")); // charge utile d'un AUTRE mécanisme
|
||
QVERIFY(!eVar.value("mechanism").toMap().contains("stageW"));
|
||
|
||
QCOMPARE(ePac.value("mechanism").toMap().value("kind").toString(), QString("sgReady"));
|
||
QCOMPARE(ePac.value("mechanism").toMap().value("state").toInt(), 4);
|
||
QVERIFY(!ePac.value("mechanism").toMap().contains("stageW"));
|
||
|
||
QCOMPARE(eRelay.value("mechanism").toMap().value("kind").toString(), QString("relay"));
|
||
QCOMPARE(qRound(eRelay.value("mechanism").toMap().value("stageW").toDouble()), 1000);
|
||
QVERIFY(!eRelay.value("mechanism").toMap().contains("percent"));
|
||
|
||
// Motifs : des CODES, avec leurs paramètres numériques. Jamais une phrase.
|
||
QCOMPARE(eVar.value("decision").toMap().value("code").toString(),
|
||
QString(DecisionCode::SurplusSetpoint));
|
||
QCOMPARE(eVar.value("decision").toMap().value("params").toMap().value("stepped").toBool(), false);
|
||
QCOMPARE(ePac.value("decision").toMap().value("code").toString(), QString(DecisionCode::SgForced));
|
||
QCOMPARE(eRelay.value("decision").toMap().value("code").toString(),
|
||
QString(DecisionCode::SurplusSetpoint));
|
||
QCOMPARE(eRelay.value("decision").toMap().value("params").toMap().value("stepped").toBool(), true);
|
||
|
||
// Verrous en SECONDES restantes, pas en watts. Les deux charges viennent de commuter à t0,
|
||
// donc leur fenêtre est pleine. Le routeur PV n'a AUCUN verrou côté moteur : la clé est
|
||
// absente, ce qui est la vérité — et non un verrou à zéro, qui se lirait « fenêtre expirée ».
|
||
// La PAC vient de commuter en 4 : sa fenêtre repart pleine.
|
||
QCOMPARE(ePac.value("lock").toMap().value("kind").toString(), QString("minStateHold"));
|
||
QCOMPARE(ePac.value("lock").toMap().value("remainingS").toInt(), 300);
|
||
// Le relais, lui, a commuté au cycle PRÉCÉDENT (t0) : 301 s plus tard son minOn de 300 s a
|
||
// EXPIRÉ, et la clé disparaît. Un verrou qui ne s'efface pas serait un verrou permanent —
|
||
// le défaut d'ECS-412 —, et l'app afficherait une charge éternellement bloquée.
|
||
QVERIFY2(!eRelay.contains("lock"), "le verrou minOn du relais aurait dû expirer");
|
||
QVERIFY(!eVar.contains("lock"));
|
||
|
||
// Disponibilité, et absence de code de défaut sur une installation saine.
|
||
for (const QVariant &v : loads) {
|
||
QCOMPARE(v.toMap().value("available").toBool(), true);
|
||
QVERIFY(!v.toMap().contains("faultCode"));
|
||
}
|
||
|
||
// ---- 2. Snapshot ≡ notification --------------------------------------------------------
|
||
// Le Get ne recalcule pas : il rend l'objet publié. Un recalcul donnerait des verrous
|
||
// rafraîchis sous un timestamp périmé — un état qui n'a jamais existé.
|
||
resp = injectAndWait("NymeaEnergy.GetLoadTelemetry");
|
||
QCOMPARE(resp.toMap().value("params").toMap(), notif);
|
||
|
||
// ---- 5. ZÉRO langue naturelle dans la charge utile -------------------------------------
|
||
// Contrôle MÉCANIQUE : toute chaîne émise est ASCII et sans espace. Une phrase française en
|
||
// porterait forcément (accents et/ou espaces). Le libellé de la charge, lui, n'entre pas
|
||
// dans la charge utile : il est substitué au RENDU, côté journal.
|
||
std::function<void(const QVariant &, const QString &)> verifierCodes =
|
||
[&](const QVariant &v, const QString &chemin) {
|
||
if (v.userType() == QMetaType::QVariantMap) {
|
||
const QVariantMap m = v.toMap();
|
||
for (auto it = m.constBegin(); it != m.constEnd(); ++it)
|
||
verifierCodes(it.value(), chemin + '.' + it.key());
|
||
} else if (v.userType() == QMetaType::QVariantList) {
|
||
const QVariantList l = v.toList();
|
||
for (int i = 0; i < l.size(); ++i)
|
||
verifierCodes(l.at(i), QStringLiteral("%1[%2]").arg(chemin).arg(i));
|
||
} else if (v.userType() == QMetaType::QString) {
|
||
const QString s = v.toString();
|
||
for (const QChar &c : s)
|
||
QVERIFY2(c.unicode() >= 32 && c.unicode() < 127,
|
||
qUtf8Printable(QStringLiteral("%1 : caractère non ASCII dans « %2 »")
|
||
.arg(chemin, s)));
|
||
QVERIFY2(!s.contains(' '),
|
||
qUtf8Printable(QStringLiteral("%1 : espace dans « %2 » — une valeur "
|
||
"émise doit être un CODE, pas une phrase")
|
||
.arg(chemin, s)));
|
||
}
|
||
};
|
||
verifierCodes(notif, QStringLiteral("telemetry"));
|
||
|
||
// ---- 4. Arbitre FIGÉ détectable --------------------------------------------------------
|
||
// Aucun cycle nouveau : le battement republie l'instantané tel quel. Le client reçoit donc
|
||
// un état, et son timestamp N'AVANCE PAS — c'est la preuve de non-fraîcheur. Sans
|
||
// battement, « installation stable » et « moteur arrêté » sont le même silence.
|
||
notificationSpy.clear();
|
||
arb->publishTelemetryHeartbeat();
|
||
QCoreApplication::processEvents();
|
||
notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged");
|
||
QVERIFY2(!notifs.isEmpty(), "aucun battement de cœur");
|
||
const QVariantMap battement = notifs.last().toMap().value("params").toMap();
|
||
QCOMPARE(battement.value("timestamp").toString(), notif.value("timestamp").toString());
|
||
|
||
// ---- Le SEUIL : une oscillation de quelques watts ne réveille personne ------------------
|
||
// Le routeur est déjà à son plafond ; un surplus voisin ne change ni allocation ni code de
|
||
// motif. Sans seuil, chaque cycle notifierait pour rien sur une cible RPi Zero 2W.
|
||
notificationSpy.clear();
|
||
meter->setStateValue("currentPower", -6010); // +10 W, sous le seuil
|
||
arb->simulationCallUpdate(t1.addSecs(60));
|
||
QCoreApplication::processEvents();
|
||
QVERIFY2(checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged").isEmpty(),
|
||
"une variation de 10 W a notifié — le seuil ne mord pas");
|
||
// …mais le Get, lui, a bien avancé : l'état INTERNE est à jour même sans notification.
|
||
resp = injectAndWait("NymeaEnergy.GetLoadTelemetry");
|
||
const QVariantMap c2 = resp.toMap().value("params").toMap();
|
||
QCOMPARE(c2.value("timestamp").toString(), t1.addSecs(60).toUTC().toString(Qt::ISODate));
|
||
// Deuxième cycle : les charges consomment, donc le RECRÉDIT n'est plus nul — et c'est
|
||
// exactement le cas où l'identité serait fausse sans lui. L'allocation TOTALE, elle, n'a
|
||
// pas bougé : c'est pour cela que le seuil ne mord pas.
|
||
const QVariantMap b2 = c2.value("budget").toMap();
|
||
QVERIFY2(qRound(b2.value("recreditedW").toDouble()) > 0, "recrédit nul alors que les charges consomment");
|
||
QCOMPARE(qRound(b2.value("allocatedW").toDouble()), 5600);
|
||
QCOMPARE(qRound(b2.value("surplusW").toDouble() + b2.value("recreditedW").toDouble()
|
||
- b2.value("allocatedW").toDouble()),
|
||
qRound(b2.value("remainingW").toDouble()));
|
||
|
||
// ---- 3. Reprise à froid du mode dégradé ------------------------------------------------
|
||
// Un client qui se connecte APRÈS la bascule doit la lire, par GetLoadTelemetry ET par
|
||
// GetChargingSchedules. C'est la famille ECS-411 : une notification seule laisse le
|
||
// nouveau venu dans un silence ambigu.
|
||
notificationSpy.clear();
|
||
arb->recordMeterUpdate(t1.addSecs(60));
|
||
arb->evaluateMeterFreshness(t1.addSecs(60 + 91));
|
||
QCoreApplication::processEvents();
|
||
QVERIFY(arb->degradedMode());
|
||
|
||
notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged");
|
||
QVERIFY2(!notifs.isEmpty(), "bascule en mode dégradé sans notification de télémétrie");
|
||
const QVariantMap dgr = notifs.last().toMap().value("params").toMap();
|
||
QCOMPARE(dgr.value("degradedMode").toBool(), true);
|
||
// budget ABSENT : la planification est suspendue, il n'y a pas de budget pour ce cycle.
|
||
// Publier des zéros se lirait « arbitrage exécuté, rien à allouer » — l'inverse du vrai.
|
||
QVERIFY2(!dgr.contains("budget"), "budget publié en mode dégradé (planification suspendue)");
|
||
// Et `draw` avec lui, pour la même raison : sans plan, il n'y a pas de soutirage DÉCIDÉ.
|
||
// Un `committedW: 0` s'y lirait « ce cycle n'a rien acheté », alors qu'il n'y a pas eu de
|
||
// cycle du tout.
|
||
QVERIFY2(!dgr.contains("draw"), "draw publié en mode dégradé (aucun soutirage décidé)");
|
||
QVERIFY(!dgr.value("loads").toList().isEmpty());
|
||
QVariantMap dgrParId;
|
||
for (const QVariant &v : dgr.value("loads").toList()) {
|
||
QCOMPARE(v.toMap().value("decision").toMap().value("code").toString(),
|
||
QString(DecisionCode::DegradedL2));
|
||
QCOMPARE(qRound(v.toMap().value("allocatedW").toDouble()), 0);
|
||
dgrParId.insert(v.toMap().value("loadId").toString(), v.toMap());
|
||
}
|
||
|
||
// TROU DE SÉCURITÉ REFERMÉ (lot B-bis). Le repli L2 envoyait un Setpoint(0) à toute la
|
||
// table des charges pilotées et un State(2) à une table SÉPARÉE, réservée à la PAC codée en
|
||
// dur. Une PAC de CONFIGURATION vit dans la première : elle recevait donc un Setpoint, que
|
||
// SgReadyAdapter rejette d'entrée — et rien d'autre. Sous compteur muet elle restait en
|
||
// état 4 (forcé) indéfiniment, alors que SAFETY.md §L2 exige l'état 2.
|
||
//
|
||
// Le défaut était INVISIBLE dans la charge utile : le motif DEGRADED_L2 s'y affichait
|
||
// correctement, seule l'ÉCRITURE manquait. C'est pourquoi on vérifie ici les CONTACTS et
|
||
// le mécanisme publié, pas le motif.
|
||
QCOMPARE(dgrParId.value("pac-telemetrie").toMap()
|
||
.value("mechanism").toMap().value("state").toInt(), 2);
|
||
QCOMPARE(tm->findConfiguredThing(k1)->stateValue("power").toBool(), false);
|
||
QCOMPARE(tm->findConfiguredThing(k2)->stateValue("power").toBool(), false);
|
||
// Et la charge en watts est bien coupée elle aussi (le repli ne régresse pas de l'autre côté).
|
||
QCOMPARE(qRound(dgrParId.value("ecs-relais-telemetrie").toMap()
|
||
.value("mechanism").toMap().value("stageW").toDouble()), 0);
|
||
QCOMPARE(relayA->stateValue("power").toBool(), false);
|
||
verifierCodes(dgr, QStringLiteral("telemetry-degrade"));
|
||
|
||
// Le nouveau venu lit l'état par les DEUX portes.
|
||
resp = injectAndWait("NymeaEnergy.GetLoadTelemetry");
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("degradedMode").toBool(), true);
|
||
resp = injectAndWait("NymeaEnergy.GetChargingSchedules");
|
||
QVERIFY2(resp.toMap().value("params").toMap().contains("degradedMode"),
|
||
"GetChargingSchedules ne rend toujours pas degradedMode (poussé en notification "
|
||
"seule : le défaut que ce lot corrige)");
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("degradedMode").toBool(), true);
|
||
|
||
// Sortie du mode dégradé : symétrique, et publiée TOUT DE SUITE. La planification ne
|
||
// reprend qu'au cycle suivant ; sans cette republication, la télémétrie annoncerait encore
|
||
// degradedMode=true pendant une minute après la levée du repli.
|
||
notificationSpy.clear();
|
||
arb->recordMeterUpdate(t1.addSecs(300));
|
||
QCoreApplication::processEvents();
|
||
QVERIFY(!arb->degradedMode());
|
||
notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged");
|
||
QVERIFY2(!notifs.isEmpty(), "sortie du mode dégradé sans notification");
|
||
QCOMPARE(notifs.last().toMap().value("params").toMap().value("degradedMode").toBool(), false);
|
||
resp = injectAndWait("NymeaEnergy.GetChargingSchedules");
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("degradedMode").toBool(), false);
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testLoadConfigDomain()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testLoadConfigDomain nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [Lot B §3] o:domain — PURE MÉTADONNÉE d'intention. Trois exigences, plus une quatrième
|
||
// qui n'est pas dans la liste mais qui est la raison d'être du champ : l'arbitrage ne
|
||
// change pas. Un champ « sans effet » se prouve, il ne se déclare pas.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-domain.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QUuid ecsId = addEtmVariableLoad(27050);
|
||
Thing *ecsThing = tm->findConfiguredThing(ecsId);
|
||
QVERIFY(ecsThing);
|
||
ecsThing->setStateValue("currentPowerW", 0);
|
||
|
||
// 1. Charge AVEC domaine + charge SANS domaine : les deux acceptées.
|
||
QVariantList configs;
|
||
configs << QVariantMap{
|
||
{"id", ecsId.toString()}, {"label", "Chauffe-eau"}, {"adapter", "etmvariableload"},
|
||
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 2400}, {"maxPowerW", 2400},
|
||
{"priority", 1}, {"enabled", true}, {"domain", "ecs"}};
|
||
configs << QVariantMap{ // aucune clé « domain » : non classée
|
||
{"id", "{cafe0001-0000-0000-0000-000000000001}"}, {"label", "Sans domaine"},
|
||
{"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"maxPowerW", 1000},
|
||
{"priority", 2}, {"enabled", true}};
|
||
QVariant resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", configs}});
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
// 2. ALLER-RETOUR lire → réécrire sans perte. C'est le geste ordinaire de l'app, et c'est
|
||
// le mur qu'a touché LM-302 : GetLoadConfig sérialise via le méta-objet, donc TOUTES les
|
||
// propriétés déclarées. Tout mécanisme ou champ ajouté embarque ce test.
|
||
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
|
||
const QVariantList lu = resp.toMap().value("params").toMap().value("loadConfigs").toList();
|
||
QCOMPARE(lu.size(), 2);
|
||
QVariantMap avec, sans;
|
||
for (const QVariant &v : lu) {
|
||
if (v.toMap().value("id").toString() == ecsId.toString()) avec = v.toMap();
|
||
else sans = v.toMap();
|
||
}
|
||
QCOMPARE(avec.value("domain").toString(), QString("ecs"));
|
||
// Forme EXACTE d'une charge non classée en sortie RPC : la clé est PRÉSENTE et VIDE.
|
||
// GetLoadConfig sérialise via le méta-objet, donc toutes les propriétés déclarées sortent,
|
||
// y compris vides — comme « sgReady: {} » ou « powerLevels: [] » ici même. C'est le
|
||
// corollaire LM-302, et c'est ce qui rend l'aller-retour Get → Set possible. L'assertion
|
||
// est exacte plutôt que permissive : une tolérance masquerait un changement de forme, or
|
||
// c'est la forme que l'app consomme. Vérifié identique au banc le 2026-08-25.
|
||
QVERIFY2(sans.contains("domain"), "la propriété domain doit sortir même vide (méta-objet)");
|
||
QCOMPARE(sans.value("domain").toString(), QString());
|
||
QVERIFY(sans.value("sgReady").toMap().isEmpty()); // même forme, pour comparaison
|
||
|
||
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", lu}});
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
|
||
for (const QVariant &v : resp.toMap().value("params").toMap().value("loadConfigs").toList())
|
||
if (v.toMap().value("id").toString() == ecsId.toString())
|
||
QCOMPARE(v.toMap().value("domain").toString(), QString("ecs")); // survit au tour
|
||
|
||
// 3. Valeur HORS énumération : rejet EN BLOC. Une valeur libre acceptée aujourd'hui
|
||
// deviendrait une valeur à supporter le jour où la couche domaine en fera son
|
||
// discriminant.
|
||
QVariantList mauvais = configs;
|
||
QVariantMap m0 = mauvais.first().toMap();
|
||
m0["domain"] = "chauffe-eau"; // libellé, pas un code de l'énumération
|
||
mauvais[0] = m0;
|
||
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", mauvais}});
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorInvalidParameter"));
|
||
// Rien n'a été écrasé : la config valide tient toujours.
|
||
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("loadConfigs").toList().size(), 2);
|
||
|
||
// Toute la liste de l'énumération est acceptée — sinon « fermée » voudrait dire « arbitraire ».
|
||
for (const QString &d : LoadConfig::knownDomains()) {
|
||
QVariantList une;
|
||
une << QVariantMap{
|
||
{"id", ecsId.toString()}, {"label", "Chauffe-eau"}, {"adapter", "etmvariableload"},
|
||
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 2400}, {"maxPowerW", 2400},
|
||
{"priority", 1}, {"enabled", true}, {"domain", d}};
|
||
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", une}});
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
}
|
||
|
||
// 4. AUCUN effet sur l'arbitrage : la charge est servie exactement pareil, domaine ou non.
|
||
// Le champ enregistre une intention d'installateur ; il ne pilote rien.
|
||
meter->setStateValue("currentPower", -3000);
|
||
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
|
||
QCoreApplication::processEvents();
|
||
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 2400);
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testRuntimeViewIsSubstantial()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testRuntimeViewIsSubstantial nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [Lot B §2] La pure virtuelle `ILoadAdapter::runtimeView()` force chaque adaptateur à
|
||
// RÉPONDRE — elle ne l'oblige pas à répondre quelque chose. Une implémentation qui rendrait
|
||
// `LoadRuntimeView{}` compilerait tout aussi bien, passerait le dispatch, et sortirait de la
|
||
// télémétrie SANS BRUIT : le client verrait une charge sans mécanisme et sans verrou,
|
||
// indistinguable d'une charge libre. C'est exactement le silence ambigu que le garde-fou
|
||
// prétend empêcher, et le compilateur ne le voit pas.
|
||
//
|
||
// Ce test est le second verrou : pour CHAQUE adaptateur, la vue doit être SUBSTANTIELLE
|
||
// (mécanisme nommé, charge utile non vide) ET porter les valeurs RUNTIME réelles — pas des
|
||
// valeurs par défaut qui coïncideraient par hasard. On amène donc chaque adaptateur dans un
|
||
// état NON trivial avant de lire.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
|
||
// ---- 1. RelayRouter : palier appliqué en WATTS + verrou minOn en SECONDES --------------
|
||
QUuid rA = addPowerSwitch(1000, 26661);
|
||
QUuid rB = addPowerSwitch(1500, 26662);
|
||
QVERIFY(tm->findConfiguredThing(rA) && tm->findConfiguredThing(rB));
|
||
RelayRouter *routeur = new RelayRouter(tm, "rv-relais", "Routeur de relais",
|
||
QList<LoadConfigRelay>({ {rA.toString(), 1000}, {rB.toString(), 1500} }),
|
||
300, 0, 1, LoadNeeds(), arb);
|
||
|
||
LoadAction monte;
|
||
monte.kind = LoadAction::Setpoint; monte.funding = LoadAction::Surplus;
|
||
monte.powerW = 2500; monte.reason = { DecisionCode::SurplusSetpoint };
|
||
routeur->applyAction(monte, t0);
|
||
QCOMPARE(qRound(routeur->currentSetpointW()), 2500); // état NON trivial : palier haut
|
||
|
||
LoadRuntimeView vRelais = routeur->runtimeView(t0.addSecs(45));
|
||
QCOMPARE(vRelais.mechanismKind, QString("relay"));
|
||
// La charge utile porte le palier RÉELLEMENT appliqué, pas 0 ni le plafond.
|
||
QCOMPARE(qRound(vRelais.mechanism.value("stageW").toDouble()), 2500);
|
||
QCOMPARE(qRound(vRelais.mechanism.value("maxStageW").toDouble()), 2500);
|
||
// Verrou : minOn armé à t0, lu 45 s plus tard → 255 s restantes. Une vue vide donnerait 0,
|
||
// qui se lirait « fenêtre expirée » — l'inverse de la vérité.
|
||
QCOMPARE(vRelais.lockKind, QString("minOn"));
|
||
QCOMPARE(vRelais.lockRemainingS, 255);
|
||
QVERIFY(vRelais.faultCode.isEmpty()); // installation saine
|
||
// …et le verrou EXPIRE : sans cela, un « 255 » constant passerait le test ci-dessus.
|
||
QVERIFY(routeur->runtimeView(t0.addSecs(301)).lockKind.isEmpty());
|
||
|
||
// ---- 2. EtmVariableLoadAdapter : consigne, pourcentage dérivé, aucun verrou -----------
|
||
QUuid varId = addEtmVariableLoad(27060);
|
||
Thing *varThing = tm->findConfiguredThing(varId);
|
||
QVERIFY(varThing);
|
||
varThing->setStateValue("currentPowerW", 0);
|
||
EtmVariableLoadAdapter *continu = new EtmVariableLoadAdapter(
|
||
tm, varId.toString(), "Routeur PV", QList<int>(), 4000, /* minPowerW */ 0, 2, LoadNeeds(), arb);
|
||
|
||
LoadAction consigne;
|
||
consigne.kind = LoadAction::Setpoint; consigne.funding = LoadAction::Surplus;
|
||
consigne.powerW = 3000; consigne.reason = { DecisionCode::SurplusSetpoint };
|
||
continu->applyAction(consigne, t0);
|
||
QCOMPARE(qRound(continu->currentSetpointW()), 3000);
|
||
|
||
LoadRuntimeView vContinu = continu->runtimeView(t0);
|
||
QCOMPARE(vContinu.mechanismKind, QString("variable"));
|
||
QCOMPARE(qRound(vContinu.mechanism.value("setpointW").toDouble()), 3000);
|
||
QCOMPARE(qRound(vContinu.mechanism.value("maxPowerW").toDouble()), 4000);
|
||
// percent est DÉRIVÉ (3000/4000) : une valeur choisie pour qu'aucun défaut plausible —
|
||
// 0, 100, ou la consigne elle-même — ne puisse passer par accident.
|
||
QCOMPARE(vContinu.mechanism.value("percent").toDouble(), 75.0);
|
||
// Aucune fenêtre de verrou côté moteur : l'anti-rebond vit dans le thing. L'ABSENCE est ici
|
||
// la vérité, et elle est assertée — pas seulement tolérée.
|
||
QVERIFY(vContinu.lockKind.isEmpty());
|
||
QVERIFY(vContinu.faultCode.isEmpty());
|
||
|
||
// Le code de défaut n'est pas du code mort : un thing absent de la configuration le lève.
|
||
EtmVariableLoadAdapter *fantome = new EtmVariableLoadAdapter(
|
||
tm, "{deadbeef-0000-0000-0000-000000000042}", "Charge fantôme",
|
||
QList<int>(), 1000, /* minPowerW */ 0, 3, LoadNeeds(), arb);
|
||
QCOMPARE(fantome->runtimeView(t0).faultCode, QString("THING_MISSING"));
|
||
|
||
// ---- 3. SgReadyAdapter : état normé, estimation, verrou minStateHold ------------------
|
||
QUuid k1 = addPowerSwitch(0, 26663);
|
||
QUuid k2 = addPowerSwitch(0, 26664);
|
||
QVERIFY(tm->findConfiguredThing(k1) && tm->findConfiguredThing(k2));
|
||
SgReadyAdapter *pac = new SgReadyAdapter(
|
||
tm, "rv-pac", "PAC",
|
||
QHash<int, QList<QString>>({ {1, {k1.toString()}}, {2, {}},
|
||
{3, {k2.toString()}}, {4, {k1.toString(), k2.toString()}} }),
|
||
QHash<int, double>({ {1, 0.0}, {2, 0.0}, {3, 1500.0}, {4, 2600.0} }),
|
||
900, 4, arb);
|
||
|
||
LoadAction etat;
|
||
etat.kind = LoadAction::State; etat.funding = LoadAction::Surplus;
|
||
etat.state = 3; etat.reason = { DecisionCode::SgRecommended };
|
||
// ECS-412 — la première commande tombe dans l'armement à froid : REFUSÉE. C'est elle qui
|
||
// pose l'estampille, donc qui lance le compte à rebours (armement PARESSEUX, jamais
|
||
// permanent : c'est le blocage circulaire d'ECS-412 qu'on refuse de refaire).
|
||
pac->applyAction(etat, t0);
|
||
QCOMPARE(pac->currentState(), 2);
|
||
const QDateTime tPac = t0.addSecs(901); // verrou expiré
|
||
pac->applyAction(etat, tPac);
|
||
QCOMPARE(pac->currentState(), 3); // état NON trivial (le défaut est 2)
|
||
|
||
LoadRuntimeView vPac = pac->runtimeView(tPac.addSecs(120));
|
||
QCOMPARE(vPac.mechanismKind, QString("sgReady"));
|
||
QCOMPARE(vPac.mechanism.value("state").toInt(), 3);
|
||
QCOMPARE(qRound(vPac.mechanism.value("estimatedPowerW").toDouble()), 1500);
|
||
QCOMPARE(vPac.lockKind, QString("minStateHold"));
|
||
QCOMPARE(vPac.lockRemainingS, 780); // 900 − 120
|
||
QVERIFY(vPac.faultCode.isEmpty());
|
||
QVERIFY(pac->runtimeView(tPac.addSecs(901)).lockKind.isEmpty()); // le verrou expire
|
||
|
||
// ECS-110 — une PAC dont l'encodage ne permet pas l'état 2 n'est JAMAIS pilotable. Le taire
|
||
// la ferait passer pour une PAC au repos ; le code le dit.
|
||
SgReadyAdapter *bancale = new SgReadyAdapter(
|
||
tm, "rv-pac-bancale", "PAC sans état 2",
|
||
QHash<int, QList<QString>>({ {3, {k2.toString()}} }),
|
||
QHash<int, double>({ {3, 1500.0} }), 300, 5, arb);
|
||
QCOMPARE(bancale->runtimeView(t0).faultCode, QString("UNUSABLE_ENCODING"));
|
||
|
||
// ---- 4. EvAdapter : hors charge utile en beta, mais IMPLÉMENTÉ ------------------------
|
||
// Déclaré et écrit dès maintenant : en 3g, quand le VE rejoindra le waterfall unifié, il
|
||
// n'y aura rien à écrire ici. Une vue vide se découvrirait ce jour-là, en production.
|
||
// « ABC » et pas le défaut « All » : le mock n'accepte que A,B,C,AB,BC,AC,ABC.
|
||
QUuid chargerId = addCharger(QStringLiteral("ABC"));
|
||
QVERIFY(!chargerId.isNull());
|
||
arb->simulationCallUpdate(t0); // syncAdapters() crée les EvAdapter
|
||
QCoreApplication::processEvents();
|
||
EvCharger *borne = arb->registeredEvChargers().value(ThingId(chargerId.toString()));
|
||
QVERIFY2(borne, "la borne EV n'est pas enregistrée auprès de l'arbitre");
|
||
EvAdapter *ev = new EvAdapter(borne, arb);
|
||
|
||
LoadRuntimeView vEv = ev->runtimeView(t0);
|
||
QCOMPARE(vEv.mechanismKind, QString("evcharger"));
|
||
for (const char *cle : {"chargingEnabled", "pluggedIn", "currentA", "phaseCount"})
|
||
QVERIFY2(vEv.mechanism.contains(QLatin1String(cle)),
|
||
qUtf8Printable(QStringLiteral("clé « %1 » absente de la vue evcharger").arg(cle)));
|
||
|
||
// ---- 5. Le verrou générique : AUCUNE vue vide, pour AUCUN adaptateur ------------------
|
||
// C'est l'assertion qui échouerait si un adaptateur futur se contentait de « return {}; ».
|
||
// Elle porte sur la LISTE des adaptateurs, pas sur un cas particulier : ajouter un
|
||
// mécanisme sans l'ajouter ici laisse le trou ouvert, donc la liste est nommée en dur.
|
||
struct Cas { const char *nom; ILoadAdapter *a; };
|
||
const QList<Cas> tous = { {"relay-router", routeur}, {"etmvariableload", continu},
|
||
{"sg-ready", pac}, {"evcharger", ev} };
|
||
for (const Cas &c : tous) {
|
||
const LoadRuntimeView v = c.a->runtimeView(t0);
|
||
QVERIFY2(!v.mechanismKind.isEmpty(),
|
||
qUtf8Printable(QStringLiteral("%1 : mechanismKind VIDE — vue non implémentée").arg(c.nom)));
|
||
QVERIFY2(!v.mechanism.isEmpty(),
|
||
qUtf8Printable(QStringLiteral("%1 : charge utile de mécanisme VIDE").arg(c.nom)));
|
||
// Une charge utile qui ne porterait que « kind » serait une vue vide déguisée : le
|
||
// client n'y lirait rien de l'état réel. On exige au moins une grandeur.
|
||
QVERIFY2(v.mechanism.count() >= 2,
|
||
qUtf8Printable(QStringLiteral("%1 : charge utile réduite à un discriminant").arg(c.nom)));
|
||
}
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testSgReadyRestartRecovery()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testSgReadyRestartRecovery nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [lot B-bis / ECS-411 appliqué au mécanisme SG-Ready]
|
||
//
|
||
// Le SgReadyAdapter démarrait à l'état 2 « par défaut », quel que soit l'état réel des
|
||
// contacts. Tant que la PAC était codée en dur et construite une seule fois au démarrage du
|
||
// plugin, ça se voyait peu. Depuis qu'elle vient de la CONFIGURATION, elle est reconstruite
|
||
// à chaque changement de matériel — et l'hypothèse devient un mensonge répété.
|
||
//
|
||
// Deux dégâts, dont le second est le pire :
|
||
// 1. la télémétrie annonce l'état 2 (0 W alloué) pendant que la PAC tourne en forcé ;
|
||
// 2. LM-104 — applyAction(état 2) sur un adaptateur qui se CROIT en 2 est idempotent,
|
||
// donc MUET : les contacts restent en 4. L'hypothèse de prudence devient un
|
||
// MASQUAGE DE PANNE, ce que LM-104 interdit explicitement.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid k1 = addPowerSwitch(0, 26661);
|
||
QUuid k2 = addPowerSwitch(0, 26662);
|
||
Thing *tK1 = tm->findConfiguredThing(k1);
|
||
Thing *tK2 = tm->findConfiguredThing(k2);
|
||
QVERIFY(tK1 && tK2);
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
const QHash<int, QList<QString>> encodage({ {1, {k1.toString()}}, {2, {}},
|
||
{3, {k2.toString()}},
|
||
{4, {k1.toString(), k2.toString()}} });
|
||
const QHash<int, double> estim({ {1, 0.0}, {2, 0.0}, {3, 1500.0}, {4, 3000.0} });
|
||
|
||
// --- 1. Contacts en état 4 : l'adaptateur neuf le LIT, il ne suppose pas ---------------
|
||
tK1->setStateValue("power", true);
|
||
tK2->setStateValue("power", true);
|
||
JournalCapture j1;
|
||
SgReadyAdapter *pac4 = new SgReadyAdapter(tm, "pac-r4", "PAC reprise 4",
|
||
encodage, estim, 300, 1, arb);
|
||
QCOMPARE(pac4->currentState(), 4);
|
||
// ECS-411 : la télémétrie annonce la puissance ALLOUÉE de l'état lu, pas zéro. Sans la
|
||
// reprise, le waterfall croirait 3 kW libres qui circulent déjà.
|
||
QCOMPARE(qRound(pac4->telemetry().currentPowerW), 3000);
|
||
// ECS-411-b : le relevé publie ce qui a été LU, contact par contact, avec l'issue.
|
||
QVERIFY2(j1.contains("reprise au démarrage"), qUtf8Printable(j1.texte()));
|
||
QVERIFY2(j1.contains("état 4"), qUtf8Printable(j1.texte()));
|
||
QVERIFY2(j1.contains("fermé"), qUtf8Printable(j1.texte()));
|
||
QVERIFY2(j1.contains("correspondance exacte"), qUtf8Printable(j1.texte()));
|
||
|
||
// --- 2. LM-104 — le corollaire, qui est le vrai piège ----------------------------------
|
||
// Une remise à l'état 2 DOIT écrire. Sans la reprise, l'adaptateur se croirait déjà en 2,
|
||
// rendrait la main en idempotent, et les contacts resteraient fermés — panne masquée.
|
||
LoadAction versNeutre;
|
||
versNeutre.kind = LoadAction::State; versNeutre.funding = LoadAction::Surplus;
|
||
versNeutre.state = 2; versNeutre.force = true; // force : on teste l'écriture, pas le verrou
|
||
versNeutre.reason = { DecisionCode::SafeStateSgReady };
|
||
pac4->applyAction(versNeutre, t0);
|
||
QCoreApplication::processEvents();
|
||
QCOMPARE(tK1->stateValue("power").toBool(), false);
|
||
QCOMPARE(tK2->stateValue("power").toBool(), false);
|
||
QCOMPARE(pac4->currentState(), 2);
|
||
|
||
// --- 3. Contacts OUVERTS : l'issue est l'état neutre, et elle se dit quand même --------
|
||
// C'est le cas d'ECS-411-b : au palier/état NUL, l'ancien code se taisait, et « contacts
|
||
// lus, tous ouverts » ne se distinguait pas de « reprise non exécutée ». Or c'est
|
||
// précisément l'état qu'on vient vérifier après un démarrage.
|
||
tK1->setStateValue("power", false);
|
||
tK2->setStateValue("power", false);
|
||
JournalCapture j2;
|
||
SgReadyAdapter *pac2 = new SgReadyAdapter(tm, "pac-r2", "PAC reprise 2",
|
||
encodage, estim, 300, 1, arb);
|
||
QCOMPARE(pac2->currentState(), 2);
|
||
QVERIFY2(j2.contains("reprise au démarrage"), "l'issue NULLE doit se journaliser aussi");
|
||
QVERIFY2(j2.contains("état 2"), qUtf8Printable(j2.texte()));
|
||
QVERIFY2(j2.contains("ouvert"), qUtf8Printable(j2.texte()));
|
||
|
||
// --- 4. Contact INJOIGNABLE : supposé fermé, et la reprise passe en AVERTISSEMENT ------
|
||
// LM-104 — supposer conservateur pour ANNONCER. Le motif {K1 fermé, K2 absent supposé
|
||
// fermé} est l'encodage de l'état 4 : on annonce donc 3000 W, jamais 0.
|
||
tK1->setStateValue("power", true);
|
||
const QString k2absent = QStringLiteral("{deadbeef-2222-2222-2222-222222222222}");
|
||
JournalCapture j3;
|
||
SgReadyAdapter *pacKo = new SgReadyAdapter(tm, "pac-r-ko", "PAC contact absent",
|
||
QHash<int, QList<QString>>({ {1, {k1.toString()}}, {2, {}},
|
||
{3, {k2absent}}, {4, {k1.toString(), k2absent}} }),
|
||
estim, 300, 1, arb);
|
||
QCOMPARE(pacKo->currentState(), 4);
|
||
QVERIFY2(j3.contains("INJOIGNABLE"), qUtf8Printable(j3.texte()));
|
||
QVERIFY2(j3.contains("INCERTAIN"), qUtf8Printable(j3.texte()));
|
||
QVERIFY2(j3.contient(QtWarningMsg), "une reprise incertaine doit être un AVERTISSEMENT, "
|
||
"pas une information");
|
||
#endif
|
||
}
|
||
|
||
void Simulation::printStates(Thing *thing)
|
||
{
|
||
qCDebug(dcSimulation()) << "Thing states for" << thing->name();
|
||
foreach (const StateType &stateType, thing->thingClass().stateTypes()) {
|
||
qCDebug(dcSimulation()) << "-->" << stateType.name() << thing->stateValue(stateType.id());
|
||
}
|
||
}
|
||
|
||
void Simulation::updateChargerMeter(Thing *thing)
|
||
{
|
||
Action updateChargerAction(thing->thingClass().actionTypes().findByName("update").id(), thing->id());
|
||
NymeaCore::instance()->thingManager()->executeAction(updateChargerAction);
|
||
}
|
||
|
||
QStringList Simulation::plotOriginalData(int powerBalanceCount)
|
||
{
|
||
QStringList scriptLines;
|
||
scriptLines.append("set title 'Original energy data'");
|
||
scriptLines.append("set grid");
|
||
|
||
scriptLines.append("set timefmt '%s'");
|
||
scriptLines.append("set xdata time");
|
||
scriptLines.append("set xtics 3600");
|
||
scriptLines.append("set format x '%H:%M'");
|
||
|
||
scriptLines.append("set xlabel 'Time'");
|
||
scriptLines.append("set ylabel '[W]'");
|
||
|
||
scriptLines.append("set style fill transparent solid 0.3");
|
||
|
||
scriptLines.append("set x2tics 100");
|
||
scriptLines.append("set xtics nomirror");
|
||
scriptLines.append("set x2label 'iterations'");
|
||
scriptLines.append("set x2range [0:" + QString::number(powerBalanceCount) + "]");
|
||
|
||
QStringList plotLines;
|
||
plotLines.append("'original.csv' using 1:3 with boxes lt rgb '#7F75C23A' title 'Production'");
|
||
plotLines.append("'original.csv' using 1:4 with boxes lt rgb '#7F3590F3' title 'Consumption'");
|
||
plotLines.append("'original.csv' using 1:5 with line lt rgb 'orange' title 'House limit'");
|
||
plotLines.append("'original.csv' using 1:2 with line lt rgb 'red' title 'Meter'");
|
||
scriptLines.append("plot \\\n" + plotLines.join(", \\\n"));
|
||
scriptLines.append("");
|
||
return scriptLines;
|
||
}
|
||
|
||
QStringList Simulation::plotSimulation(const QString &title, int powerBalanceCount)
|
||
{
|
||
QStringList scriptLines;
|
||
scriptLines.append("set title '" + title + "'");
|
||
scriptLines.append("set grid");
|
||
|
||
scriptLines.append("set timefmt '%s'");
|
||
scriptLines.append("set xdata time");
|
||
scriptLines.append("set format x '%H:%M'");
|
||
scriptLines.append("set xtics 3600");
|
||
|
||
scriptLines.append("set xlabel 'time'");
|
||
scriptLines.append("set ylabel '[W]'");
|
||
|
||
scriptLines.append("set x2tics 100");
|
||
scriptLines.append("set xtics nomirror");
|
||
scriptLines.append("set x2label 'iterations'");
|
||
scriptLines.append("set x2range [0:" + QString::number(powerBalanceCount) + "]");
|
||
|
||
scriptLines.append("set style fill transparent solid 0.3");
|
||
|
||
scriptLines.append("set y2tics 10");
|
||
scriptLines.append("set ytics nomirror");
|
||
scriptLines.append("set y2label '[\%]'");
|
||
scriptLines.append("set y2range [0:100]");
|
||
|
||
QStringList plotLines;
|
||
plotLines.append("'simulation.csv' using 1:9 title 'Charger range' w filledcurves x1 lc rgb '#fff0f0f0'");
|
||
plotLines.append("'simulation.csv' using 1:8 notitle w filledcurves x1 lc rgb '#ffffffff'");
|
||
|
||
plotLines.append("'simulation.csv' using 1:3 with boxes lt rgb '#0A75C23A' title 'Production'");
|
||
plotLines.append("'simulation.csv' using 1:4 with boxes lt rgb '#7F3590F3' title 'Consumption'");
|
||
plotLines.append("'simulation.csv' using 1:17 with boxes lt rgb '#7FA020F0' title 'Energy storage'");
|
||
plotLines.append("'simulation.csv' using 1:5 with boxes lt rgb '#7FF3DE8A' title 'Charger'");
|
||
|
||
// plotLines.append("'simulation.csv' using 1:9 with line lt rgb '#EABC01' title 'Charger max'");
|
||
// plotLines.append("'simulation.csv' using 1:8 with line lt rgb '#F6CAAF' title 'Charger min'");
|
||
|
||
plotLines.append("'simulation.csv' using 1:13 with line lt rgb 'green' title 'Acquisition Limit'");
|
||
plotLines.append("'simulation.csv' using 1:11 with line lt rgb 'black' axes x1y2 title 'Battery [\%]'");
|
||
plotLines.append("'simulation.csv' using 1:18 with line lt rgb 'purple ' axes x1y2 title 'Energy storage [\%]'");
|
||
plotLines.append("'simulation.csv' using 1:14 with line lt rgb 'purple' axes x1y2 title 'Car plugged in into charger'");
|
||
plotLines.append("'simulation.csv' using 1:10 with line lt rgb 'orange' title 'House limit'");
|
||
plotLines.append("'simulation.csv' using 1:2 with line lt rgb 'red' title 'Meter'");
|
||
|
||
scriptLines.append("plot \\\n" + plotLines.join(", \\\n"));
|
||
scriptLines.append("");
|
||
return scriptLines;
|
||
}
|
||
|
||
QStringList Simulation::plotSpotMarketData(int powerBalanceCount)
|
||
{
|
||
QStringList scriptLines;
|
||
scriptLines.append("set title 'Spot maket data'");
|
||
scriptLines.append("set grid");
|
||
|
||
scriptLines.append("set timefmt '%s'");
|
||
scriptLines.append("set xdata time");
|
||
scriptLines.append("set format x '%H:%M'");
|
||
scriptLines.append("set xtics 3600");
|
||
|
||
scriptLines.append("set xlabel 'Time'");
|
||
scriptLines.append("set ylabel 'Price [Cent/kWh]'");
|
||
scriptLines.append("set x2tics 100");
|
||
scriptLines.append("set xtics nomirror");
|
||
scriptLines.append("set x2label 'iterations'");
|
||
scriptLines.append("set x2range [0:" + QString::number(powerBalanceCount) + "]");
|
||
|
||
scriptLines.append("set y2tics");
|
||
scriptLines.append("set ytics nomirror");
|
||
scriptLines.append("set y2label '[\%]'");
|
||
scriptLines.append("set y2range [0:100]");
|
||
|
||
QStringList plotLines;
|
||
plotLines.append("'simulation.csv' using 1:15 with boxes fs solid lt rgb '#fff0f0f0' axes x1y2 title 'Spotmarket scoring [%]'");
|
||
plotLines.append("'simulation.csv' using 1:16 with line lt rgb 'black' axes x1y1 title 'Price [Cent/kWh]'");
|
||
scriptLines.append("plot \\\n" + plotLines.join(", \\\n"));
|
||
scriptLines.append("");
|
||
return scriptLines;
|
||
}
|
||
|
||
|
||
QTEST_MAIN(Simulation)
|
||
|
||
namespace {
|
||
|
||
//! Prépare une borne du banc simulé : branchée, à l'arrêt, monophasée, 6-16 A.
|
||
void armerBorne(Thing *borne)
|
||
{
|
||
borne->setStateValue("connected", true);
|
||
borne->setStateValue("pluggedIn", true);
|
||
borne->setStateValue("power", false);
|
||
borne->setStateValue("charging", false);
|
||
borne->setStateValue("maxChargingCurrent", 6);
|
||
borne->setStateValue("currentPower", 0);
|
||
}
|
||
|
||
//! Les entrées de configuration, telles que l'APP les voit — par la frontière RPC, jamais par
|
||
//! un store privé : c'est la concordance des deux frontières qui est en jeu.
|
||
QHash<QString, QVariantMap> configsParId(const QVariant &getLoadConfigReply)
|
||
{
|
||
QHash<QString, QVariantMap> h;
|
||
const QVariantList l = getLoadConfigReply.toMap().value("params").toMap()
|
||
.value("loadConfigs").toList();
|
||
for (const QVariant &v : l)
|
||
h.insert(v.toMap().value("id").toString(), v.toMap());
|
||
return h;
|
||
}
|
||
|
||
//! Les entrées de télémétrie du dernier cycle, indexées par loadId.
|
||
QHash<QString, QVariantMap> telemetrieParId(const QVariantMap &snap)
|
||
{
|
||
QHash<QString, QVariantMap> h;
|
||
for (const QVariant &v : snap.value("loads").toList())
|
||
h.insert(v.toMap().value("loadId").toString(), v.toMap());
|
||
return h;
|
||
}
|
||
|
||
} // namespace
|
||
|
||
void Simulation::testEvChargerRankDecidesWhoCharges()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testEvChargerRankDecidesWhoCharges nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [3g-2 · LM-1204-b, moitié arbitrage] Deux bornes, un budget qui ne paie qu'un seul
|
||
// plancher. Ce qui est éprouvé ici est ce qu'AUCUN matériel ne prouve : que l'ARBITRE
|
||
// choisit, qu'il le dit, et que son choix suit le rang configuré.
|
||
//
|
||
// Le discriminant est le MOTIF publié (spec_loadmodel §12) :
|
||
// BELOW_MIN_POWER → l'arbitre a refusé, aucune commande n'est partie ;
|
||
// une borne à 0 W sans motif de plancher → c'est le matériel, le test ne prouve rien.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-evrank.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
// Tolérance d'acquisition à 1,0 : aucun démarrage sous tolérance ne vient brouiller la
|
||
// lecture du plancher. Ce mécanisme a son propre test.
|
||
QVERIFY(injectAndWait("NymeaEnergy.SetAcquisitionTolerance",
|
||
QVariantMap({{"acquisitionTolerance", 1.0}})).toMap()
|
||
.value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
|
||
|
||
// Deux bornes IDENTIQUES, plafonnées à 6 A : chacune prend exactement son plancher
|
||
// (1380 W) et pas un watt de plus. C'est ce qui rend le RELIQUAT visible — 2000 − 1380 =
|
||
// 620 W, sous le plancher de la seconde. Sans ce plafond, la première absorberait tout et
|
||
// la seconde publierait « pas de surplus », ce qui est vrai mais ne prouve pas le plancher.
|
||
QUuid borneAId = addCharger("A", 6, 26657);
|
||
QUuid borneBId = addCharger("A", 6, 26670);
|
||
QUuid voitureAId = addCar(26656);
|
||
QUuid voitureBId = addCar(26671);
|
||
QVERIFY(!borneAId.isNull() && !borneBId.isNull());
|
||
QVERIFY(!voitureAId.isNull() && !voitureBId.isNull());
|
||
|
||
Thing *borneA = tm->findConfiguredThing(borneAId);
|
||
Thing *borneB = tm->findConfiguredThing(borneBId);
|
||
QVERIFY(borneA && borneB);
|
||
armerBorne(borneA);
|
||
armerBorne(borneB);
|
||
for (const QUuid &v : {voitureAId, voitureBId}) {
|
||
Thing *voiture = tm->findConfiguredThing(v);
|
||
QVERIFY(voiture);
|
||
voiture->setStateValue("batteryLevel", 40);
|
||
voiture->setStateValue("capacity", 50);
|
||
voiture->setStateValue("minChargingCurrent", 6);
|
||
voiture->setStateValue("phaseCount", 1);
|
||
voiture->setStateValue("pluggedIn", true);
|
||
}
|
||
|
||
auto associer = [this](const QUuid &borne, const QUuid &voiture) {
|
||
QVariantMap info;
|
||
info.insert("evChargerId", borne);
|
||
info.insert("assignedCarId", voiture);
|
||
info.insert("chargingMode", "ChargingModeEco");
|
||
QVariant r = injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
|
||
QCOMPARE(r.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
};
|
||
associer(borneAId, voitureAId);
|
||
associer(borneBId, voitureBId);
|
||
|
||
// ---- 1. L'auto-provisionnement : les bornes ENTRENT dans la configuration --------------
|
||
// 3g-1 les arbitrait et les publiait sans qu'elles figurent dans GetLoadConfig — une charge
|
||
// publiée sans configuration est la charge fantôme que le lot B-bis avait supprimée.
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
meter->setStateValue("currentPower", 0); // aucun surplus : ce cycle ne sert qu'à
|
||
arb->simulationCallUpdate(t0); // faire entrer les bornes en configuration
|
||
QCoreApplication::processEvents();
|
||
|
||
QHash<QString, QVariantMap> cfgs = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
|
||
QVERIFY2(cfgs.contains(borneAId.toString()), "la borne A n'est pas entrée en configuration");
|
||
QVERIFY2(cfgs.contains(borneBId.toString()), "la borne B n'est pas entrée en configuration");
|
||
QCOMPARE(cfgs.value(borneAId.toString()).value("adapter").toString(), QStringLiteral("evcharger"));
|
||
QCOMPARE(cfgs.value(borneAId.toString()).value("domain").toString(), QStringLiteral("ev"));
|
||
QVERIFY(cfgs.value(borneAId.toString()).value("enabled").toBool());
|
||
|
||
// Aller-retour NEUTRE : relire la configuration et la réécrire mot pour mot ne change rien
|
||
// et ne reconstruit AUCUN adaptateur — l'adaptateur d'une borne vient du Thing, pas d'ici.
|
||
const QVariant avant = injectAndWait("NymeaEnergy.GetLoadConfig");
|
||
QVariant reecrit = injectAndWait("NymeaEnergy.SetLoadConfig",
|
||
QVariantMap({{"loadConfigs", avant.toMap().value("params").toMap().value("loadConfigs")}}));
|
||
QCOMPARE(reecrit.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
QCOMPARE(injectAndWait("NymeaEnergy.GetLoadConfig").toMap().value("params").toMap(),
|
||
avant.toMap().value("params").toMap());
|
||
|
||
// ---- 2. Le rang décide, et le MOTIF le dit --------------------------------------------
|
||
// Le réordonnancement passe par la MÊME frontière que l'app : GetLoadConfig, on change le
|
||
// rang, SetLoadConfig. Aucun raccourci par le store — c'est le chemin qui doit marcher.
|
||
auto poserRangs = [&](int rangA, int rangB) {
|
||
QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
|
||
.value("params").toMap().value("loadConfigs").toList();
|
||
for (int i = 0; i < l.count(); ++i) {
|
||
QVariantMap m = l.at(i).toMap();
|
||
if (m.value("id").toString() == borneAId.toString()) m.insert("priority", rangA);
|
||
if (m.value("id").toString() == borneBId.toString()) m.insert("priority", rangB);
|
||
l[i] = m;
|
||
}
|
||
QVariant r = injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}));
|
||
QCOMPARE(r.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
};
|
||
|
||
// Chaque cycle mesuré part de DEUX bornes à l'arrêt. Sans cela le recrédit
|
||
// anti-clignotement rendrait à celle qui charge déjà sa propre consommation — c'est le
|
||
// comportement voulu (le compteur l'inclut déjà), mais il rend le budget du cycle
|
||
// ambigu et le test ne mesurerait plus le rang.
|
||
auto cycle = [&](const QDateTime &t) {
|
||
for (Thing *b : {borneA, borneB}) {
|
||
b->setStateValue("power", false);
|
||
b->setStateValue("charging", false);
|
||
b->setStateValue("maxChargingCurrent", 6);
|
||
b->setStateValue("currentPower", 0);
|
||
}
|
||
meter->setStateValue("currentPower", -2000); // 2000 W exportés : UN seul plancher
|
||
arb->simulationCallUpdate(t);
|
||
QCoreApplication::processEvents();
|
||
return telemetrieParId(arb->loadTelemetry());
|
||
};
|
||
|
||
poserRangs(1, 2);
|
||
QHash<QString, QVariantMap> tele = cycle(t0.addSecs(60));
|
||
|
||
// Plancher : max(borne 6 A, voiture 6 A) × 230 V × 1 phase = 1380 W. Le budget de 2000 W
|
||
// en paie UN. La seconde ne reçoit pas « un peu » — elle reçoit 0 et son motif.
|
||
QCOMPARE(qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()), 1380);
|
||
QCOMPARE(qRound(tele.value(borneBId.toString()).value("allocatedW").toDouble()), 0);
|
||
// Le reliquat EXISTE (620 W) et il ne suffit pas : c'est bien le plancher qui refuse, pas
|
||
// l'absence de surplus. Les deux phrases envoient l'installateur vers des gestes opposés.
|
||
QCOMPARE(qRound(tele.value(borneBId.toString()).value("decision").toMap()
|
||
.value("params").toMap().value("budgetW").toDouble()), 620);
|
||
QCOMPARE(tele.value(borneBId.toString()).value("decision").toMap().value("code").toString(),
|
||
QString(DecisionCode::BelowMinPower));
|
||
QCOMPARE(qRound(tele.value(borneBId.toString()).value("decision").toMap()
|
||
.value("params").toMap().value("minPowerW").toDouble()), 1380);
|
||
|
||
// ---- 3. INVERSION du rang : la borne servie change ------------------------------------
|
||
// C'est le pendant EV de l'inversion ECS/SG-Ready déjà éprouvée, et la seule preuve que le
|
||
// rang est un RÉGLAGE et non un ordre de découverte.
|
||
poserRangs(2, 1);
|
||
tele = cycle(t0.addSecs(120));
|
||
|
||
QCOMPARE(qRound(tele.value(borneBId.toString()).value("allocatedW").toDouble()), 1380);
|
||
QCOMPARE(qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()), 0);
|
||
QCOMPARE(tele.value(borneAId.toString()).value("decision").toMap().value("code").toString(),
|
||
QString(DecisionCode::BelowMinPower));
|
||
|
||
// ---- 4. Une charge, UN commandeur -----------------------------------------------------
|
||
// La borne refusée doit être ÉTEINTE par la décision de l'arbitre. Avant 3g-2,
|
||
// adjustEvChargers() repassait derrière avec une consigne dérivée de sa propre logique :
|
||
// l'arbitre publiait un motif juste, et le matériel recevait autre chose.
|
||
QCOMPARE(borneA->stateValue("power").toBool(), false);
|
||
QCOMPARE(borneA->stateValue("charging").toBool(), false);
|
||
// Et la borne SERVIE l'est à la consigne du waterfall, pas à celle que le proxy aurait
|
||
// dérivée : 1380 W sur une phase, soit 6 A.
|
||
QCOMPARE(borneB->stateValue("power").toBool(), true);
|
||
QCOMPARE(borneB->stateValue("maxChargingCurrent").toInt(), 6);
|
||
|
||
// ---- 5. loads[] ⊆ GetLoadConfig, l'invariant que 3g-1 avait rompu ----------------------
|
||
QSet<QString> idsConfig;
|
||
for (const QString &id : configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")).keys())
|
||
idsConfig.insert(id);
|
||
for (auto it = tele.constBegin(); it != tele.constEnd(); ++it)
|
||
QVERIFY2(idsConfig.contains(it.key()),
|
||
qUtf8Printable(QStringLiteral("charge %1 publiée en télémétrie mais absente de "
|
||
"GetLoadConfig").arg(it.key())));
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testEvGridStartIsAnnouncedAndBounded()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testEvGridStartIsAnnouncedAndBounded nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [3g-2] `acquisitionTolerance` est un seuil de DÉCLENCHEMENT : la part du plancher qu'on
|
||
// accepte de ne pas couvrir par le surplus pour démarrer quand même. Le comportement
|
||
// existait chez le proxy, où il était invisible — confondu avec le plancher dans
|
||
// evFloorW(). Ici il est visible, chiffré, et borné.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-evgridstart.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
injectAndWait("NymeaEnergy.SetAcquisitionTolerance", QVariantMap({{"acquisitionTolerance", 0.5}}));
|
||
|
||
QUuid borneAId = addCharger("A", 16, 26657);
|
||
QUuid borneBId = addCharger("A", 16, 26670);
|
||
QUuid voitureAId = addCar(26656);
|
||
QUuid voitureBId = addCar(26671);
|
||
Thing *borneA = tm->findConfiguredThing(borneAId);
|
||
Thing *borneB = tm->findConfiguredThing(borneBId);
|
||
QVERIFY(borneA && borneB);
|
||
armerBorne(borneA);
|
||
armerBorne(borneB);
|
||
for (const QUuid &v : {voitureAId, voitureBId}) {
|
||
Thing *voiture = tm->findConfiguredThing(v);
|
||
QVERIFY(voiture);
|
||
voiture->setStateValue("batteryLevel", 40);
|
||
voiture->setStateValue("capacity", 50);
|
||
voiture->setStateValue("minChargingCurrent", 6);
|
||
voiture->setStateValue("phaseCount", 1);
|
||
voiture->setStateValue("pluggedIn", true);
|
||
}
|
||
for (const QPair<QUuid, QUuid> &paire : QList<QPair<QUuid, QUuid>>{{borneAId, voitureAId},
|
||
{borneBId, voitureBId}}) {
|
||
QVariantMap info;
|
||
info.insert("evChargerId", paire.first);
|
||
info.insert("assignedCarId", paire.second);
|
||
info.insert("chargingMode", "ChargingModeEco");
|
||
injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
|
||
}
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
meter->setStateValue("currentPower", 0); // cycle d'entrée en configuration seulement
|
||
arb->simulationCallUpdate(t0);
|
||
QCoreApplication::processEvents();
|
||
|
||
QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
|
||
.value("params").toMap().value("loadConfigs").toList();
|
||
for (int i = 0; i < l.count(); ++i) {
|
||
QVariantMap m = l.at(i).toMap();
|
||
if (m.value("id").toString() == borneAId.toString()) m.insert("priority", 1);
|
||
if (m.value("id").toString() == borneBId.toString()) m.insert("priority", 2);
|
||
l[i] = m;
|
||
}
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
// Les deux bornes à l'arrêt : le budget du cycle est sans ambiguïté (cf. testEvChargerRank…).
|
||
for (Thing *b : {borneA, borneB}) {
|
||
b->setStateValue("power", false);
|
||
b->setStateValue("charging", false);
|
||
b->setStateValue("maxChargingCurrent", 6);
|
||
b->setStateValue("currentPower", 0);
|
||
}
|
||
// 900 W de surplus : sous le plancher de 1380 W, mais au-dessus de sa moitié. Le proxy
|
||
// aurait démarré ici sans le dire ; le waterfall démarre et le DIT.
|
||
meter->setStateValue("currentPower", -900);
|
||
arb->simulationCallUpdate(t0.addSecs(60));
|
||
QCoreApplication::processEvents();
|
||
const QVariantMap snap = arb->loadTelemetry();
|
||
QHash<QString, QVariantMap> tele = telemetrieParId(snap);
|
||
|
||
// ---- Le motif dit COMBIEN est soutiré, et pourquoi -------------------------------------
|
||
const QVariantMap decisionA = tele.value(borneAId.toString()).value("decision").toMap();
|
||
QCOMPARE(decisionA.value("code").toString(), QString(DecisionCode::EvGridStart));
|
||
const QVariantMap pA = decisionA.value("params").toMap();
|
||
QCOMPARE(qRound(pA.value("floorW").toDouble()), 1380);
|
||
QCOMPARE(qRound(pA.value("budgetW").toDouble()), 900);
|
||
QCOMPARE(qRound(pA.value("gridW").toDouble()), 480); // 1380 − 900, SOUTIRÉS
|
||
// budgetW + gridW == allocatedW : la comptabilité est vérifiable par le client seul.
|
||
QCOMPARE(qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()), 1380);
|
||
QCOMPARE(qRound(pA.value("budgetW").toDouble() + pA.value("gridW").toDouble()), 1380);
|
||
|
||
// Le financement DIT la vérité sur l'origine : une part vient du réseau.
|
||
// R2 — le financement vit dans levels[], plus sur la charge. Un seul niveau aujourd'hui.
|
||
const QVariantList nivA = tele.value(borneAId.toString()).value("levels").toList();
|
||
QCOMPARE(nivA.count(), 1);
|
||
QCOMPARE(nivA.first().toMap().value("level").toString(), QString("comfort"));
|
||
QCOMPARE(nivA.first().toMap().value("funding").toString(), QString("grid"));
|
||
// Et le niveau publie ce qu'il a DÉCIDÉ : tant qu'il n'y en a qu'un, c'est allocatedW.
|
||
QCOMPARE(qRound(nivA.first().toMap().value("targetW").toDouble()),
|
||
qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()));
|
||
QVERIFY2(!tele.value(borneAId.toString()).contains("funding"),
|
||
"funding ne doit plus être publié au niveau de la CHARGE");
|
||
|
||
// ---- Borné par construction : UNE borne par cycle --------------------------------------
|
||
// Le démarrage consomme TOUT le budget restant ; la suivante trouve zéro et repart sur son
|
||
// motif ordinaire. Deux bornes ne peuvent pas se mettre à soutirer le même cycle.
|
||
QCOMPARE(qRound(tele.value(borneBId.toString()).value("allocatedW").toDouble()), 0);
|
||
QVERIFY2(tele.value(borneBId.toString()).value("decision").toMap().value("code").toString()
|
||
!= QString(DecisionCode::EvGridStart),
|
||
"deux bornes ont soutiré au réseau dans le même cycle");
|
||
|
||
// ---- La comptabilité du budget partage l'allocation ------------------------------------
|
||
const QVariantMap budget = snap.value("budget").toMap();
|
||
QCOMPARE(qRound(budget.value("allocatedW").toDouble()), 900); // la part de SURPLUS
|
||
QCOMPARE(qRound(budget.value("remainingW").toDouble()), 0);
|
||
|
||
// R3 — la part ACHETÉE a quitté evReservedW pour le registre du soutirage. evReservedW est
|
||
// une CORRECTION du budget (commandé non encore mesuré) : y verser une tranche
|
||
// d'allocation rendait toute définition du champ fausse pour l'un des deux termes, et
|
||
// interdisait de le nommer honnêtement à l'écran.
|
||
QCOMPARE(qRound(snap.value("draw").toMap().value("committedW").toDouble()), 480);
|
||
QCOMPARE(qRound(budget.value("evReservedW").toDouble()), 0);
|
||
|
||
// budgetW + gridW == allocatedW tient toujours, et se lit désormais SANS exception :
|
||
// la part surplus dans le registre d'allocation, la part achetée dans celui du soutirage.
|
||
QCOMPARE(qRound(budget.value("allocatedW").toDouble()
|
||
+ snap.value("draw").toMap().value("committedW").toDouble()),
|
||
qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()));
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testEvChargerConfigOutlivesItsThing()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testEvChargerConfigOutlivesItsThing nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [3g-2] Borne remplacée, ThingId qui change, appareil déposé : l'entrée de configuration
|
||
// n'est PAS supprimée d'office — c'est une configuration, et l'installateur a pu la classer.
|
||
// Elle doit se lire comme une charge dont l'appareil manque, avec le motif qui va bien.
|
||
// Le cas symétrique du THING_MISSING éprouvé avec la sonde.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-evorphan.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
meter->setStateValue("currentPower", -4000);
|
||
|
||
QUuid borneId = addCharger("A", 16, 26657);
|
||
QUuid voitureId = addCar(26656);
|
||
Thing *borne = tm->findConfiguredThing(borneId);
|
||
Thing *voiture = tm->findConfiguredThing(voitureId);
|
||
QVERIFY(borne && voiture);
|
||
armerBorne(borne);
|
||
voiture->setStateValue("batteryLevel", 40);
|
||
voiture->setStateValue("capacity", 50);
|
||
voiture->setStateValue("minChargingCurrent", 6);
|
||
voiture->setStateValue("phaseCount", 1);
|
||
voiture->setStateValue("pluggedIn", true);
|
||
|
||
QVariantMap info;
|
||
info.insert("evChargerId", borneId);
|
||
info.insert("assignedCarId", voitureId);
|
||
info.insert("chargingMode", "ChargingModeEco");
|
||
injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
arb->simulationCallUpdate(t0);
|
||
QCoreApplication::processEvents();
|
||
QVERIFY2(configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")).contains(borneId.toString()),
|
||
"la borne n'est pas entrée en configuration");
|
||
|
||
// ---- Le Thing disparaît ----------------------------------------------------------------
|
||
QVERIFY(removeDevice(borneId).toMap().value("params").toMap().value("thingError").toString()
|
||
== "ThingErrorNoError");
|
||
arb->simulationCallUpdate(t0.addSecs(60));
|
||
QCoreApplication::processEvents();
|
||
|
||
// L'entrée SURVIT : la supprimer effacerait un classement que l'installateur a posé, et le
|
||
// silence qui suivrait ne se distinguerait pas d'une borne jamais configurée.
|
||
QVERIFY2(configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")).contains(borneId.toString()),
|
||
"l'entrée de configuration a été supprimée avec le Thing");
|
||
|
||
// Et elle se LIT comme une charge sans appareil, à son rang, avec son motif.
|
||
const QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
|
||
QVERIFY2(tele.contains(borneId.toString()),
|
||
"la borne configurée sans appareil a disparu de la télémétrie");
|
||
const QVariantMap entree = tele.value(borneId.toString());
|
||
QCOMPARE(entree.value("available").toBool(), false);
|
||
QCOMPARE(entree.value("faultCode").toString(), QString("THING_MISSING"));
|
||
QCOMPARE(entree.value("decision").toMap().value("code").toString(),
|
||
QString(DecisionCode::LoadUnavailable));
|
||
QCOMPARE(qRound(entree.value("allocatedW").toDouble()), 0);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testUnpluggedChargerTakesNoBudget()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testUnpluggedChargerTakesNoBudget nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [banc 2026-08-27, RELEVE_3g2 §3.1] Une borne en Eco, voiture assignée, mais AUCUN
|
||
// véhicule branché. Elle ne peut rien tirer de ce qu'on lui alloue — et pourtant elle
|
||
// était servie : 4 853 W retirés du budget et donnés à personne, la charge suivante ne
|
||
// survivant que sur son propre recrédit.
|
||
//
|
||
// Le proxy n'avait pas besoin de ce filtre : son allocation n'était pas une cascade, donc
|
||
// gaspiller ne privait personne. C'est la transplantation qui rend le test nécessaire.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-unplugged.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QUuid borneId = addCharger("A", 16, 26657);
|
||
QUuid voitureId = addCar(26656);
|
||
QUuid routeurId = addEtmVariableLoad(27050);
|
||
Thing *borne = tm->findConfiguredThing(borneId);
|
||
Thing *voiture = tm->findConfiguredThing(voitureId);
|
||
Thing *routeur = tm->findConfiguredThing(routeurId);
|
||
QVERIFY(borne && voiture && routeur);
|
||
|
||
armerBorne(borne);
|
||
borne->setStateValue("pluggedIn", false); // ← le cas du banc : rien n'est branché
|
||
routeur->setStateValue("currentPowerW", 0);
|
||
voiture->setStateValue("batteryLevel", 40);
|
||
voiture->setStateValue("capacity", 50);
|
||
voiture->setStateValue("minChargingCurrent", 6);
|
||
voiture->setStateValue("phaseCount", 1);
|
||
|
||
QVariantMap info;
|
||
info.insert("evChargerId", borneId);
|
||
info.insert("assignedCarId", voitureId);
|
||
info.insert("chargingMode", "ChargingModeEco");
|
||
injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
meter->setStateValue("currentPower", 0);
|
||
arb->simulationCallUpdate(t0); // fait entrer la borne en configuration
|
||
QCoreApplication::processEvents();
|
||
|
||
// Une charge derrière la borne, au rang suivant : c'est elle qui doit recevoir le budget.
|
||
QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
|
||
.value("params").toMap().value("loadConfigs").toList();
|
||
for (int i = 0; i < l.count(); ++i) {
|
||
QVariantMap m = l.at(i).toMap();
|
||
if (m.value("id").toString() == borneId.toString()) m.insert("priority", 1);
|
||
l[i] = m;
|
||
}
|
||
l.append(QVariantMap{{"id", routeurId.toString()}, {"label", "Routeur PV"},
|
||
{"adapter", "etmvariableload"}, {"mode", "dynamic"},
|
||
{"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}});
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
// ---- Débranchée : elle ne doit PAS figurer dans le contexte ---------------------------
|
||
meter->setStateValue("currentPower", -3000);
|
||
arb->simulationCallUpdate(t0.addSecs(60));
|
||
QCoreApplication::processEvents();
|
||
QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
|
||
|
||
QVERIFY2(!tele.contains(borneId.toString()),
|
||
"une borne sans véhicule branché a été arbitrée");
|
||
// Et le budget est allé ENTIÈREMENT à la charge suivante. C'est la moitié du test qui
|
||
// compte : l'exclusion ne vaut que si elle ne coûte rien à personne.
|
||
QCOMPARE(qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble()), 3000);
|
||
QCOMPARE(qRound(arb->loadTelemetry().value("budget").toMap()
|
||
.value("allocatedW").toDouble()), 3000);
|
||
|
||
// ---- Branchée : elle redevient une charge du waterfall --------------------------------
|
||
// Sans cette seconde moitié, le test passerait aussi si la borne était exclue pour une
|
||
// raison quelconque — il ne prouverait pas que c'est `pluggedIn` qui décide.
|
||
borne->setStateValue("pluggedIn", true);
|
||
routeur->setStateValue("currentPowerW", 0);
|
||
arb->simulationCallUpdate(t0.addSecs(120));
|
||
QCoreApplication::processEvents();
|
||
tele = telemetrieParId(arb->loadTelemetry());
|
||
|
||
QVERIFY2(tele.contains(borneId.toString()),
|
||
"une borne avec véhicule branché doit entrer dans le waterfall");
|
||
QCOMPARE(qRound(tele.value(borneId.toString()).value("allocatedW").toDouble()), 3000);
|
||
QCOMPARE(qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble()), 0);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testChargerWithoutCarIsArbitrated()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testChargerWithoutCarIsArbitrated nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [LM-1208-b · banc 2026-08-28] Le cas de l'INVITÉ. Une borne en Eco, un véhicule branché,
|
||
// et AUCUNE voiture assignée — parce qu'une voiture qui n'est pas celle du propriétaire ne
|
||
// sera jamais déclarée. Elle tirait 3,9 kW au banc et l'arbitre l'ignorait : le critère
|
||
// « pas de voiture assignée » la sortait du waterfall précisément quand elle consommait.
|
||
//
|
||
// Deux choses sont éprouvées ici, et la seconde est celle qui se serait perdue :
|
||
// 1. la borne EST arbitrée sans voiture assignée ;
|
||
// 2. elle l'est avec le nombre de phases de la BORNE. Le manager ne prépare pas une
|
||
// borne sans voiture (prepareInformation la filtre), donc son ChargingProcessInfo
|
||
// n'existe pas — et le lire par défaut annonce « 1 phase » pour une borne triphasée.
|
||
// Le défaut serait SILENCIEUX : plafond à 3 680 W au lieu de 11 040, courant commandé
|
||
// à 16 A au lieu de 9, et 11 kW tirés d'un budget qui en comptait 3,7.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-nocar.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
// Tolérance d'acquisition à 1,0 : aucun démarrage sous tolérance ne vient brouiller la
|
||
// lecture du plancher — EV_GRID_START a son propre test.
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetAcquisitionTolerance",
|
||
QVariantMap({{"acquisitionTolerance", 1.0}})).toMap()
|
||
.value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
// La borne est TRIPHASÉE — c'est tout l'enjeu du point 2. Plancher 6 A × 230 V × 3
|
||
// = 4 140 W, plafond 16 A × 230 V × 3 = 11 040 W.
|
||
QUuid borneId = addCharger("ABC", 16, 26657);
|
||
QUuid routeurId = addEtmVariableLoad(27050);
|
||
Thing *borne = tm->findConfiguredThing(borneId);
|
||
Thing *routeur = tm->findConfiguredThing(routeurId);
|
||
QVERIFY(borne && routeur);
|
||
armerBorne(borne); // branchée, à l'arrêt, 6 A
|
||
QCOMPARE(borne->stateValue("phaseCount").toUInt(), 3u);
|
||
routeur->setStateValue("currentPowerW", 0);
|
||
|
||
// AUCUN assignedCarId — et aucune voiture n'est même ajoutée au système.
|
||
QVariantMap info;
|
||
info.insert("evChargerId", borneId);
|
||
info.insert("chargingMode", "ChargingModeEco");
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
meter->setStateValue("currentPower", 0);
|
||
arb->simulationCallUpdate(t0); // fait entrer la borne en configuration
|
||
QCoreApplication::processEvents();
|
||
|
||
// Une charge derrière la borne : sans elle, « la borne prend tout » ne se distinguerait pas
|
||
// de « personne ne prend rien ».
|
||
QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
|
||
.value("params").toMap().value("loadConfigs").toList();
|
||
for (int i = 0; i < l.count(); ++i) {
|
||
QVariantMap m = l.at(i).toMap();
|
||
if (m.value("id").toString() == borneId.toString()) m.insert("priority", 1);
|
||
l[i] = m;
|
||
}
|
||
l.append(QVariantMap{{"id", routeurId.toString()}, {"label", "Routeur PV"},
|
||
{"adapter", "etmvariableload"}, {"mode", "dynamic"},
|
||
{"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}});
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
// ---- Sans voiture assignée : la borne est ARBITRÉE ------------------------------------
|
||
meter->setStateValue("currentPower", -6000);
|
||
arb->simulationCallUpdate(t0.addSecs(60));
|
||
QCoreApplication::processEvents();
|
||
QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
|
||
|
||
QVERIFY2(tele.contains(borneId.toString()),
|
||
"une borne sans voiture assignée a été exclue du waterfall (LM-1208-b)");
|
||
|
||
// Le budget entier lui revient : 6 000 W passent sous son plafond TRIPHASÉ (11 040 W).
|
||
// Annoncée monophasée, elle aurait plafonné à 3 680 W et laissé 2 320 W au routeur — c'est
|
||
// exactement la lecture qui distingue les deux.
|
||
QCOMPARE(qRound(tele.value(borneId.toString()).value("allocatedW").toDouble()), 6000);
|
||
QCOMPARE(qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble()), 0);
|
||
|
||
// Et le COURANT commandé le confirme au matériel, pas seulement dans la charge utile :
|
||
// 6 000 / (230 × 3) = 8,7 → 9 A. Sur une phase supposée, c'eût été 16 A écrêtés au plafond
|
||
// de la borne — soit 11 kW tirés d'un budget qui en comptait 3,7.
|
||
QCOMPARE(borne->stateValue("maxChargingCurrent").toUInt(), 9u);
|
||
|
||
// ---- Le mode manuel, lui, RESTE une exclusion ------------------------------------------
|
||
// Sans cette moitié, le test passerait aussi si toute borne était arbitrée sans condition :
|
||
// il ne prouverait pas que c'est le critère « voiture » qui a été retiré, et lui seul.
|
||
info.insert("chargingMode", "ChargingModeNormal");
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
routeur->setStateValue("currentPowerW", 0);
|
||
arb->simulationCallUpdate(t0.addSecs(120));
|
||
QCoreApplication::processEvents();
|
||
tele = telemetrieParId(arb->loadTelemetry());
|
||
|
||
QVERIFY2(!tele.contains(borneId.toString()),
|
||
"une borne en mode manuel doit rester hors arbitrage");
|
||
QCOMPARE(qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble()), 3000);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testMeasurementSourceIsPublished()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testMeasurementSourceIsPublished nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [§11 · LM-1105/LM-1106] Il fallait désigner une charge comme SON PROPRE compteur dans
|
||
// meterThingId pour qu'elle publie ce qu'elle mesure déjà. Le moteur lit désormais
|
||
// l'appareil qu'il pilote — et publie D'OÙ vient le nombre, parce que l'écart
|
||
// commandé↔mesuré ne conclut pas la même chose selon la source.
|
||
//
|
||
// Ce test porte les trois sources ET les deux règles de priorité. Sans la seconde moitié
|
||
// il passerait aussi si le compteur explicite avait été purement et simplement remplacé.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-measure.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
// Trois mécanismes. Les relais du mock portent `currentPower` — c'est délibéré : le
|
||
// sg-ready doit répondre « non mesurable » MÊME LÀ, parce que ses contacts sont des
|
||
// contacts de SIGNAL et que le courant de la PAC ne les traverse pas.
|
||
QUuid rA = addPowerSwitch(1000, 26661); // relay-router, 1 relais
|
||
QUuid k1 = addPowerSwitch(0, 26662); // sg-ready K1
|
||
QUuid k2 = addPowerSwitch(0, 26663); // sg-ready K2
|
||
QUuid varId = addEtmVariableLoad(27041); // etmvariableload dynamic
|
||
Thing *varThing = tm->findConfiguredThing(varId);
|
||
QVERIFY(varThing && tm->findConfiguredThing(rA));
|
||
QVERIFY(tm->findConfiguredThing(k1) && tm->findConfiguredThing(k2));
|
||
varThing->setStateValue("currentPowerW", 0);
|
||
|
||
// Le compteur divisionnaire, posé EXPRÈS sur le routeur — il n'entrera en jeu qu'au 3.
|
||
QUuid divId = addMeter(26670);
|
||
Thing *div = tm->findConfiguredThing(divId);
|
||
QVERIFY(div);
|
||
div->setStateValue("connected", true);
|
||
div->setStateValue("currentPower", 1234);
|
||
|
||
LoadConfigStore *store = new LoadConfigStore(arb);
|
||
arb->setLoadConfigStore(store);
|
||
|
||
auto poserConfig = [&](const QString &meterThingId) {
|
||
LoadConfigs cfgs;
|
||
QVariantMap routeur{
|
||
{"id", varId.toString()}, {"label", "Routeur PV"}, {"adapter", "etmvariableload"},
|
||
{"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 1}, {"enabled", true}};
|
||
if (!meterThingId.isEmpty())
|
||
routeur.insert("meterThingId", meterThingId);
|
||
cfgs.append(LoadConfig::fromMap(routeur));
|
||
cfgs.append(LoadConfig::fromMap(sgReadyConfigMap("pac-mesure", "PAC", k1, k2,
|
||
800.0, 1600.0, 300, 2)));
|
||
cfgs.append(LoadConfig::fromMap(QVariantMap{
|
||
{"id", "ecs-mesure"}, {"label", "ECS relais"}, {"adapter", "relay-router"},
|
||
{"mode", "fixed"}, {"priority", 3}, {"enabled", true},
|
||
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()},
|
||
{"powerW", 1000}}},
|
||
{"minOnS", 0}, {"minOffS", 0}}));
|
||
QString err;
|
||
QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8());
|
||
};
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
|
||
// ---- 1. Les TROIS sources, sans une ligne de configuration de compteur ----------------
|
||
poserConfig(QString());
|
||
meter->setStateValue("currentPower", -6000);
|
||
arb->simulationCallUpdate(t0);
|
||
QCoreApplication::processEvents();
|
||
QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
|
||
|
||
// L'appareil piloté publie sa puissance : le moteur la lit, personne n'a rien désigné.
|
||
// 0 W est une MESURE, pas une absence — et la source ne doit pas basculer pour autant.
|
||
const QVariantMap mRouteur = tele.value(varId.toString()).value("measurement").toMap();
|
||
QCOMPARE(mRouteur.value("source").toString(), QString("device"));
|
||
QVERIFY2(mRouteur.contains("powerW"), "une charge mesurable doit publier sa valeur");
|
||
QCOMPARE(qRound(mRouteur.value("powerW").toDouble()), 0);
|
||
|
||
// LM-1105-a — le relay-router est servi : relais fermé, le mock rapporte 1 000 W. La mesure
|
||
// traverse bien le relais — c'est ce qui distingue un organe de COMMUTATION d'un contact
|
||
// de signal (voir la PAC juste dessous).
|
||
const QVariantMap mEcs = tele.value("ecs-mesure").value("measurement").toMap();
|
||
QCOMPARE(mEcs.value("source").toString(), QString("device"));
|
||
QCOMPARE(qRound(mEcs.value("powerW").toDouble()), 1000);
|
||
|
||
// LM-1105-b — la PAC : « none », ALORS QUE ses contacts portent currentPower. Un contact est
|
||
// un contact de signal — mesurer sa bobine ne mesure pas la PAC. C'est un état POSITIF,
|
||
// et powerW est OMIS : une nominale qui se ferait passer pour une mesure produirait un
|
||
// écart identiquement nul à tous les cycles.
|
||
const QVariantMap mPac = tele.value("pac-mesure").value("measurement").toMap();
|
||
QCOMPARE(mPac.value("source").toString(), QString("none"));
|
||
QVERIFY2(!mPac.contains("powerW"),
|
||
"une charge non mesurable a publié une puissance sous le nom d'une mesure");
|
||
|
||
// Et l'ancienne clé a bien DISPARU — deux noms pour une chose divergent.
|
||
QVERIFY2(!tele.value(varId.toString()).contains("measuredW"), "measuredW survit");
|
||
|
||
// ---- 1-bis. Palier 0 : la SOURCE ne bascule pas, la valeur seule tombe -----------------
|
||
// C'est la règle « la capacité se juge sur la CLASSE de tous les relais déclarés, jamais
|
||
// sur ceux qui sont fermés ». Jugée sur les relais fermés, elle répondrait « none » ici —
|
||
// et la source publiée basculerait à chaque extinction, sur une installation dont rien
|
||
// n'a changé. Il faut un budget NÉGATIF : à budget nul le recrédit anti-clignotement
|
||
// rendrait à l'ECS les 1 000 W qu'il consomme, et le relais resterait fermé.
|
||
meter->setStateValue("currentPower", 2000); // 2 000 W IMPORTÉS
|
||
arb->simulationCallUpdate(t0.addSecs(30));
|
||
QCoreApplication::processEvents();
|
||
tele = telemetrieParId(arb->loadTelemetry());
|
||
const QVariantMap mEcsEteint = tele.value("ecs-mesure").value("measurement").toMap();
|
||
QCOMPARE(mEcsEteint.value("source").toString(), QString("device"));
|
||
QCOMPARE(qRound(mEcsEteint.value("powerW").toDouble()), 0);
|
||
|
||
// ---- 2. La valeur SUIT l'appareil ------------------------------------------------------
|
||
meter->setStateValue("currentPower", -6000);
|
||
varThing->setStateValue("currentPowerW", 2900);
|
||
arb->simulationCallUpdate(t0.addSecs(60));
|
||
QCoreApplication::processEvents();
|
||
tele = telemetrieParId(arb->loadTelemetry());
|
||
QCOMPARE(tele.value(varId.toString()).value("measurement").toMap()
|
||
.value("source").toString(), QString("device"));
|
||
QCOMPARE(qRound(tele.value(varId.toString()).value("measurement").toMap()
|
||
.value("powerW").toDouble()), 2900);
|
||
|
||
// ---- 3. Le compteur EXPLICITE reste prioritaire ----------------------------------------
|
||
// Pas d'abord parce qu'il mesure mieux : parce qu'un réglage sans effet est pire que pas
|
||
// de réglage. 1234 W du compteur doivent l'emporter sur 2900 W déclarés par l'appareil.
|
||
poserConfig(divId.toString());
|
||
arb->simulationCallUpdate(t0.addSecs(120));
|
||
QCoreApplication::processEvents();
|
||
tele = telemetrieParId(arb->loadTelemetry());
|
||
const QVariantMap mAvecCompteur = tele.value(varId.toString()).value("measurement").toMap();
|
||
QCOMPARE(mAvecCompteur.value("source").toString(), QString("meter"));
|
||
QCOMPARE(qRound(mAvecCompteur.value("powerW").toDouble()), 1234);
|
||
|
||
// ---- 4. …mais la priorité porte sur la SOURCE, pas sur la valeur -----------------------
|
||
// Compteur déclaré et injoignable : on retombe sur l'appareil. Se taire perdrait une
|
||
// mesure qu'on a, et le silence se lirait « charge non mesurable » — ce qui serait faux.
|
||
poserConfig(QUuid::createUuid().toString());
|
||
arb->simulationCallUpdate(t0.addSecs(180));
|
||
QCoreApplication::processEvents();
|
||
tele = telemetrieParId(arb->loadTelemetry());
|
||
const QVariantMap mRepli = tele.value(varId.toString()).value("measurement").toMap();
|
||
QCOMPARE(mRepli.value("source").toString(), QString("device"));
|
||
QCOMPARE(qRound(mRepli.value("powerW").toDouble()), 2900);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testAutoProvisionedRankGoesToTheQueue()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testAutoProvisionedRankGoesToTheQueue nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [LM-1209] L'auto-provisionnement REMPLIT UN TROU : sans entrée, une borne serait arbitrée
|
||
// sans rang réglable. Il ne s'ensuit rien sur sa PLACE. Une borne détectée un mois après la
|
||
// mise en service ne doit pas passer devant l'ECS que quelqu'un a réglé exprès — une
|
||
// détection ne réordonne pas ce qu'un humain a décidé.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-queue.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
meter->setStateValue("currentPower", -4000);
|
||
|
||
// ---- Une installation DÉJÀ classée par un humain ---------------------------------------
|
||
QUuid rA = addPowerSwitch(1000, 26661);
|
||
QUuid varId = addEtmVariableLoad(27042);
|
||
QVERIFY(tm->findConfiguredThing(rA) && tm->findConfiguredThing(varId));
|
||
|
||
// Posées par la frontière RPC, comme l'app le ferait — c'est le magasin que le handler
|
||
// connaît, et c'est lui qui doit porter la marque de rang.
|
||
QVariantList l;
|
||
l.append(QVariantMap{{"id", "ecs-classe"}, {"label", "ECS classé"},
|
||
{"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", 1}, {"enabled", true}, {"domain", "ecs"},
|
||
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()},
|
||
{"powerW", 1000}}},
|
||
{"minOnS", 0}, {"minOffS", 0}});
|
||
l.append(QVariantMap{{"id", varId.toString()}, {"label", "Routeur PV"},
|
||
{"adapter", "etmvariableload"}, {"mode", "dynamic"},
|
||
{"maxPowerW", 3000}, {"priority", 2}, {"enabled", true},
|
||
{"domain", "ecs"}});
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
// ---- La borne arrive UN MOIS PLUS TARD -------------------------------------------------
|
||
QUuid borneId = addCharger("A", 16, 26657);
|
||
Thing *borne = tm->findConfiguredThing(borneId);
|
||
QVERIFY(borne);
|
||
armerBorne(borne);
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
arb->simulationCallUpdate(t0);
|
||
QCoreApplication::processEvents();
|
||
|
||
QHash<QString, QVariantMap> cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
|
||
QVERIFY2(cfg.contains(borneId.toString()), "la borne détectée n'est pas entrée en configuration");
|
||
|
||
// EN QUEUE : derrière tout le monde, pas en tête. C'est la correction du lot — le rang
|
||
// retombait à 1, à égalité avec l'ECS que quelqu'un avait posé là exprès.
|
||
QCOMPARE(cfg.value(borneId.toString()).value("priority").toInt(), 3);
|
||
|
||
// AUCUN autre rang n'a bougé : insérer est un geste de configuration, pas d'arbitrage.
|
||
QCOMPARE(cfg.value("ecs-classe").value("priority").toInt(), 1);
|
||
QCOMPARE(cfg.value(varId.toString()).value("priority").toInt(), 2);
|
||
|
||
// Et la création SE DIT : le journal ne suffit pas, l'app ne le lit pas et c'est elle qui
|
||
// montre le classement. « ce rang est un défaut, pas un choix. »
|
||
QCOMPARE(cfg.value(borneId.toString()).value("rankOrigin").toString(), QString("auto"));
|
||
// Les charges posées par un humain, elles, n'affirment rien — ni « choisi », ni « défaut ».
|
||
QVERIFY2(cfg.value("ecs-classe").value("rankOrigin").toString().isEmpty(),
|
||
"une charge posée par un humain ne doit rien affirmer sur l'origine de son rang");
|
||
|
||
// ---- Une SECONDE borne va encore derrière ----------------------------------------------
|
||
// Le rang produit doit rester LIBRE par construction : c'est ce qui rend l'égalité
|
||
// impossible par ce chemin, plutôt que de la refuser après coup.
|
||
QUuid borne2Id = addCharger("A", 16, 26670);
|
||
Thing *borne2 = tm->findConfiguredThing(borne2Id);
|
||
QVERIFY(borne2);
|
||
armerBorne(borne2);
|
||
arb->simulationCallUpdate(t0.addSecs(60));
|
||
QCoreApplication::processEvents();
|
||
|
||
cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
|
||
QCOMPARE(cfg.value(borne2Id.toString()).value("priority").toInt(), 4);
|
||
QCOMPARE(cfg.value(borneId.toString()).value("priority").toInt(), 3); // toujours pas bougé
|
||
QCOMPARE(cfg.value("ecs-classe").value("priority").toInt(), 1);
|
||
|
||
// Tous distincts : l'unicité est obtenue PAR CONSTRUCTION, pas par un refus tardif.
|
||
QSet<int> rangs;
|
||
for (auto it = cfg.constBegin(); it != cfg.constEnd(); ++it)
|
||
rangs.insert(it.value().value("priority").toInt());
|
||
QCOMPARE(rangs.count(), cfg.count());
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testDeletedEvChargerConfigComesBack()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testDeletedEvChargerConfigComesBack nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [LM-1209-e · mesuré sur `.75` le 2026-08-29] Le provisionnement vivait dans la branche
|
||
// « l'adaptateur vient de naître ». Or l'adaptateur est construit depuis le THING : il
|
||
// survit à la suppression de l'entrée de configuration. Une entrée supprimée depuis l'app
|
||
// n'était donc JAMAIS recréée — et la borne restait arbitrée, absente de GetLoadConfig, à
|
||
// un rang de queue non réglable. Le trou refermé en 3g-2, rouvert par le côté client.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-reprov.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
meter->setStateValue("currentPower", -4000);
|
||
|
||
QUuid borneId = addCharger("A", 16, 26657);
|
||
Thing *borne = tm->findConfiguredThing(borneId);
|
||
QVERIFY(borne);
|
||
armerBorne(borne);
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
arb->simulationCallUpdate(t0);
|
||
QCoreApplication::processEvents();
|
||
|
||
QHash<QString, QVariantMap> cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
|
||
QVERIFY2(cfg.contains(borneId.toString()), "la borne n'est pas entrée en configuration");
|
||
QCOMPARE(cfg.value(borneId.toString()).value("rankOrigin").toString(), QString("auto"));
|
||
const int rangInitial = cfg.value(borneId.toString()).value("priority").toInt();
|
||
|
||
// ---- L'installateur classe la borne : la marque tombe -----------------------------------
|
||
QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
|
||
.value("params").toMap().value("loadConfigs").toList();
|
||
for (int i = 0; i < l.count(); ++i) {
|
||
QVariantMap m = l.at(i).toMap();
|
||
if (m.value("id").toString() == borneId.toString()) m.insert("priority", rangInitial + 3);
|
||
l[i] = m;
|
||
}
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
|
||
QVERIFY2(cfg.value(borneId.toString()).value("rankOrigin").toString().isEmpty(),
|
||
"un rang posé par un humain doit lever la marque");
|
||
|
||
// ---- …puis il SUPPRIME l'entrée, le Thing restant vivant --------------------------------
|
||
QVariantList sans;
|
||
for (const QVariant &v : injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
|
||
.value("params").toMap().value("loadConfigs").toList())
|
||
if (v.toMap().value("id").toString() != borneId.toString())
|
||
sans.append(v);
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", sans}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
QVERIFY2(!configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")).contains(borneId.toString()),
|
||
"l'entrée devait être retirée par l'écriture cliente");
|
||
|
||
// ---- Le cycle suivant la RATTRAPE -------------------------------------------------------
|
||
// Sans LM-1209-e, rien ne se passe : l'adaptateur existe déjà, donc le provisionnement
|
||
// n'est plus jamais tenté, et la borne reste arbitrée hors configuration.
|
||
arb->simulationCallUpdate(t0.addSecs(60));
|
||
QCoreApplication::processEvents();
|
||
cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
|
||
|
||
QVERIFY2(cfg.contains(borneId.toString()),
|
||
"une borne dont le Thing vit doit retrouver une entrée de configuration");
|
||
|
||
// Et elle revient EN QUEUE et en « auto » : personne ne l'a classée, son rang est de
|
||
// nouveau un défaut. Le rang que l'installateur avait posé est perdu avec l'entrée — c'est
|
||
// le prix de la suppression, et c'est pourquoi la marque doit se voir.
|
||
QCOMPARE(cfg.value(borneId.toString()).value("rankOrigin").toString(), QString("auto"));
|
||
int rangMax = 0;
|
||
for (auto it = cfg.constBegin(); it != cfg.constEnd(); ++it)
|
||
if (it.key() != borneId.toString())
|
||
rangMax = qMax(rangMax, it.value().value("priority").toInt());
|
||
QCOMPARE(cfg.value(borneId.toString()).value("priority").toInt(), qMax(1, rangMax + 1));
|
||
|
||
// ---- Et le rattrapage ne se REJOUE pas quand l'entrée existe ----------------------------
|
||
// Sans quoi il réécrirait la configuration à chaque cycle — donc un changed(), donc un
|
||
// rebuild d'adaptateurs, donc des verrous réarmés en boucle (ECS-412).
|
||
const QVariantMap avant = cfg.value(borneId.toString());
|
||
arb->simulationCallUpdate(t0.addSecs(120));
|
||
QCoreApplication::processEvents();
|
||
const QVariantMap apres = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"))
|
||
.value(borneId.toString());
|
||
QCOMPARE(apres, avant);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testVanishedRelayFreezesTheLoad()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testVanishedRelayFreezesTheLoad nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [ECS-111] Le comportement était « non défini », et son effet dépassait l'affichage : le
|
||
// relais disparu était SAUTÉ, on retombait sur le nominal du PALIER ENTIER — part du relais
|
||
// fantôme comprise — et ce chiffre alimente le recrédit anti-clignotement, donc le BUDGET.
|
||
// L'arbitre se trompait sur le surplus pendant que `available` restait vrai.
|
||
//
|
||
// Ce test porte les deux moitiés, et c'est la seconde qui compte : ce que le fantôme
|
||
// laissait aux charges SUIVANTES.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-vanish.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QUuid rA = addPowerSwitch(2000, 26661); // ECS : un seul relais, 2000 W
|
||
QUuid varId = addEtmVariableLoad(27043); // la charge SUIVANTE, celle qui paie
|
||
Thing *routeur = tm->findConfiguredThing(varId);
|
||
QVERIFY(tm->findConfiguredThing(rA) && routeur);
|
||
routeur->setStateValue("currentPowerW", 0);
|
||
|
||
QVariantList l;
|
||
l.append(QVariantMap{{"id", "ecs-fantome"}, {"label", "ECS relais"},
|
||
{"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", 1}, {"enabled", true},
|
||
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()},
|
||
{"powerW", 2000}}},
|
||
{"minOnS", 0}, {"minOffS", 0}});
|
||
l.append(QVariantMap{{"id", varId.toString()}, {"label", "Routeur PV"},
|
||
{"adapter", "etmvariableload"}, {"mode", "dynamic"},
|
||
{"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}});
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
// ---- Régime sain : l'ECS prend son palier, le routeur prend le reste ------------------
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
meter->setStateValue("currentPower", -5000);
|
||
arb->simulationCallUpdate(t0);
|
||
QCoreApplication::processEvents();
|
||
QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
|
||
QCOMPARE(tele.value("ecs-fantome").value("available").toBool(), true);
|
||
QCOMPARE(qRound(tele.value("ecs-fantome").value("allocatedW").toDouble()), 2000);
|
||
const int routeurSain = qRound(tele.value(varId.toString()).value("allocatedW").toDouble());
|
||
QVERIFY2(routeurSain > 0, "le routeur doit être servi en régime sain");
|
||
|
||
// ---- Le relais DISPARAÎT du registre nymea --------------------------------------------
|
||
// Thing supprimé, ou plugin qui ne le voit plus : on ne sait pas dans quel état est le
|
||
// contact. C'est précisément pour ça qu'on ne peut pas « compter 0 W et servir le reste ».
|
||
QVERIFY(removeDevice(rA).toMap().value("params").toMap().value("thingError").toString()
|
||
== "ThingErrorNoError");
|
||
arb->simulationCallUpdate(t0.addSecs(60));
|
||
QCoreApplication::processEvents();
|
||
tele = telemetrieParId(arb->loadTelemetry());
|
||
|
||
// 1. La charge SORT DE L'ARBITRAGE, et elle le DIT.
|
||
const QVariantMap ecs = tele.value("ecs-fantome");
|
||
QCOMPARE(ecs.value("available").toBool(), false);
|
||
QCOMPARE(ecs.value("faultCode").toString(), QString("THING_MISSING"));
|
||
QCOMPARE(ecs.value("decision").toMap().value("code").toString(),
|
||
QString(DecisionCode::LoadUnavailable));
|
||
|
||
// 2. …mais PAS de la comptabilité : la puissance crue engagée reste allouée, parce qu'on
|
||
// ne sait plus la couper. C'est ECS-410, appliqué à une cause nouvelle.
|
||
QCOMPARE(qRound(ecs.value("allocatedW").toDouble()), 2000);
|
||
|
||
// 3. ET C'EST LA MOITIÉ QUI DÉCIDE : le fantôme est RETIRÉ du budget des suivantes au lieu
|
||
// de leur être recrédité. Avant le correctif, `currentPowerW` valait le nominal du
|
||
// palier entier et alimentait le recrédit — le routeur se voyait offrir un surplus qui
|
||
// n'existait pas. Ici il reçoit ce qui reste vraiment, pas davantage.
|
||
const int routeurFantome = qRound(tele.value(varId.toString()).value("allocatedW").toDouble());
|
||
QVERIFY2(routeurFantome <= routeurSain,
|
||
"la charge suivante ne doit pas recevoir PLUS quand un relais a disparu");
|
||
|
||
// 4. Et la mesure ne prétend rien : rien ne mesure une charge dont le câblage manque.
|
||
QCOMPARE(ecs.value("measurement").toMap().value("source").toString(), QString("none"));
|
||
QVERIFY2(!ecs.value("measurement").toMap().contains("powerW"),
|
||
"une charge au câblage incomplet ne doit pas publier de puissance mesurée");
|
||
#endif
|
||
}
|
||
|
||
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
|
||
}
|