L'app a validé le préalable sans réserve, et sa formulation de pourquoi counts n'a pas été livré mérite d'être gardée : une identité dépendante du temps est pire que pas d'identité. Vraie au premier cycle après un changement, fausse après convergence, elle passe tous les tests — qui portent sur des cycles isolés — et certifie un total faux chez les clients, avec le sceau d'une identité publiée. draw.committedW est publié dans la MÊME TRAME que budget : les deux décrivent le même cycle, et les lire à deux instants ferait comparer deux mondes. SEUL, et c'est la deuxième demande, celle qui compte. authorisedW, remainingW, binding, perPhaseBound sont ABSENTS, jamais à 0 — un authorisedW: 0 assorti d'un binding par défaut afficherait un plafond de soutirage qui n'existe pas, avec sa source. Un chiffre crédible et faux, c'est-à-dire la faute même que l'objet doit rendre impossible. budget.evReservedW n'a plus qu'UNE nature. Il recevait la part réseau d'EV_GRID_START — une vraie tranche d'allocation — en plus de la correction A. Déménagée dans draw.committedW. Sans ce déplacement, aucune définition du champ n'aurait été vraie pour ses deux termes, et l'app aurait renommé son libellé sur une définition à moitié fausse. Sa définition est écrite, puisqu'elle affichait « Réservé au véhicule » : c'est une CORRECTION du budget de surplus, pas une réservation ; elle tend vers 0 à mesure que la mesure rattrape la commande, sans qu'aucune décision n'ait changé. Le contrat dit : ne l'additionner à rien, il ne se réconcilie jamais. Le libellé affirme deux choses fausses — que c'est réservé, et que ça concerne le véhicule plutôt que la latence de sa mesure. EV_GRID_START cesse d'être une exception : ses deux moitiés tombent dans des compteurs de même nature. Le partage demeure — c'est un fait, une part est achetée — mais il ne s'énonce plus comme une entorse. Publication seule, aucune arithmétique de budget ne change : vérifié avant d'écrire que la ligne alimentant evReservedW depuis la cascade n'avait aucun retour dans le calcul. simulation 33/33, charging 16/16, loadmodel 20/20, amd64 0/0, doxygen 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
6116 lines
308 KiB
C++
6116 lines
308 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);
|
||
// 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)");
|
||
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
|
||
}
|