`noterAchatEco()` intégrait la CIBLE éco avant palier et verrou : sur un relay-router, une cible de 167 ou 495 W laisse le relais ouvert et ces watts étaient comptés achetés ; sous un optimiseur, l'ombre rule-based notait sa propre cible, jamais exécutée. Règle (choix par défaut du brief) : achatWh += ΔE × partÉco, ΔE = énergie MESURÉE de la charge depuis le cycle précédent (sessionWh), partÉco = min(1, Σ targetW « eco » / estimatedPowerW) de l'action APPLIQUÉE au cycle précédent (m_actionsAppliquees, lue avant le dispatch). Niveau unique `optimizer`, charge délestée ou rien d'appliqué : part 0. Un recul ne contribue jamais. - Sans mesure d'énergie : purchasedWh ABSENT (EcoProgress::purchased), jamais 0 ni la commande. Non borné par l'import du site : attribution ambiguë avec plusieurs charges. - Comptage à chaque cycle, AVANT ecoProgress() : la valeur publiée inclut l'intervalle écoulé, et l'intervalle qui enjambe l'échéance va à la période close (base gardée à la bascule). - Aucune décision ne lit purchasedWh : consignes inchangées. update() inchangé. - INTERFACE.md : contrat réécrit (« publié dans TOUS les régimes » retiré). - Recorder `decisions` : purchasedWh absent sans mesure, comme la RPC. - Tests : testPurchasedIsCountedOnTheMeasuredEnergy (simulation, cas i-vi) et testThePlanOfAnOptimizerBuysNothingEco (socketscheduler, cas vii). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
10203 lines
527 KiB
C++
10203 lines
527 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/rootmeter.h"
|
||
#include "../../../energyplugin/etm/types/loadconfig.h"
|
||
#include "../../../energyplugin/etm/config/loadconfigstore.h"
|
||
#include "../../../energyplugin/etm/ratios/energyratioscalculator.h"
|
||
#include "../../../energyplugin/etm/scheduler/rulebasedscheduler.h"
|
||
#include "../../../energyplugin/etm/types/decisionreason.h"
|
||
#endif
|
||
#include "../../../energyplugin/etm/types/decisionreason.h"
|
||
#include "../../../energyplugin/etm/spotmarketbuild.h"
|
||
#include "../../../energyplugin/energysettings.h"
|
||
#include "../../../energyplugin/spotmarket/spotmarketmanager.h"
|
||
#include <QNetworkAccessManager>
|
||
|
||
#include <functional>
|
||
#include <QDir>
|
||
#include <QFile>
|
||
#include <QFileInfo>
|
||
|
||
#include <QHash>
|
||
#include <QScopeGuard>
|
||
#include <QtMath>
|
||
#include <QtGlobal>
|
||
#include <QProcess>
|
||
#include <QDateTime>
|
||
#include <QSignalSpy>
|
||
#include <QProcessEnvironment>
|
||
#include <QCoreApplication>
|
||
#include <QSqlDatabase>
|
||
#include <QSqlQuery>
|
||
#include <QSqlRecord>
|
||
#include <QUuid>
|
||
|
||
#include <nymeacore.h>
|
||
|
||
#include "simulationtestpoint.h"
|
||
|
||
namespace {
|
||
#ifdef ETM_ARBITRATOR
|
||
/*! \brief Charge utile de configuration d'une PAC SG-Ready 2 bits (K1/K2), forme §4.
|
||
*
|
||
* Depuis le lot B-bis il n'existe plus d'enregistrement codé en dur : une PAC se déclare comme
|
||
* n'importe quelle autre charge. Ce fabricant évite d'étaler le même littéral dans chaque test
|
||
* — et rend visible qu'il n'y a QU'UNE façon de déclarer une PAC.
|
||
*/
|
||
QVariantMap sgReadyConfigMap(const QString &id, const QString &label,
|
||
const QUuid &k1, const QUuid &k2,
|
||
double p3, double p4, int minStateHoldS, int priority)
|
||
{
|
||
return QVariantMap{
|
||
{"id", id}, {"label", label}, {"adapter", "sg-ready"}, {"mode", "fixed"},
|
||
{"priority", priority}, {"enabled", true},
|
||
{"sgReady", QVariantMap{
|
||
{"minStateHoldS", minStateHoldS},
|
||
{"states", QVariantList()
|
||
<< QVariantMap{{"state", 1}, {"relays", QVariantList() << k1.toString()},
|
||
{"estimatedPowerW", 0.0}}
|
||
<< QVariantMap{{"state", 2}, {"relays", QVariantList()},
|
||
{"estimatedPowerW", 0.0}}
|
||
<< QVariantMap{{"state", 3}, {"relays", QVariantList() << k2.toString()},
|
||
{"estimatedPowerW", p3}}
|
||
<< QVariantMap{{"state", 4},
|
||
{"relays", QVariantList() << k1.toString() << k2.toString()},
|
||
{"estimatedPowerW", p4}}}}}};
|
||
}
|
||
|
||
/*! \brief Capture les messages Qt le temps d'une portée, pour asserter sur le TEXTE publié.
|
||
*
|
||
* AGENTS.md est explicite : les tests des règles 7-b à 7-d portent sur le TEXTE, jamais sur la
|
||
* non-vacuité d'une chaîne — « un test motif non vide n'aurait rien vu, dans aucun de ces cas ».
|
||
* Un relevé de reprise (ECS-411-b) relève de la même exigence : ce qui compte est qu'il DISE
|
||
* ce qui a été lu, pas qu'il existe.
|
||
*/
|
||
class JournalCapture
|
||
{
|
||
public:
|
||
/*! \brief Ouvre une capture. Plusieurs peuvent être vivantes en même temps.
|
||
*
|
||
* Le handler n'est installé QU'UNE FOIS, et chaîné au handler d'ORIGINE mémorisé à cette
|
||
* occasion. Le réinstaller à chaque construction serait un piège : \c qInstallMessageHandler
|
||
* rend le handler courant, qui est alors \c collecte() elle-même — la capture se chaînerait
|
||
* sur elle-même, chaque message se rappellerait indéfiniment en concaténant dans
|
||
* \c m_texte, et le processus mourrait en OOM. Défaut constaté le 2026-08-25 : la suite
|
||
* entière atteignait 7-8 Gio et emportait la session graphique, alors qu'un seul test —
|
||
* celui-ci, le seul à ouvrir DEUX captures — en était responsable.
|
||
*/
|
||
JournalCapture()
|
||
{
|
||
if (s_pile.isEmpty())
|
||
s_origine = qInstallMessageHandler(&JournalCapture::collecte);
|
||
s_pile.append(this);
|
||
}
|
||
//! Referme la capture ; le handler d'origine n'est rendu qu'à la dernière.
|
||
~JournalCapture()
|
||
{
|
||
s_pile.removeAll(this);
|
||
if (s_pile.isEmpty())
|
||
qInstallMessageHandler(s_origine);
|
||
}
|
||
|
||
bool contains(const QString &fragment) const { return m_texte.contains(fragment); }
|
||
bool contient(QtMsgType type) const { return m_types.contains(type); }
|
||
QString texte() const { return m_texte; }
|
||
|
||
private:
|
||
static void collecte(QtMsgType type, const QMessageLogContext &ctx, const QString &msg)
|
||
{
|
||
// Toutes les captures vivantes reçoivent le message — c'est ce que le chaînage visait.
|
||
for (JournalCapture *c : s_pile) {
|
||
c->m_texte += msg + QLatin1Char('\n');
|
||
c->m_types.insert(type);
|
||
}
|
||
if (s_origine)
|
||
s_origine(type, ctx, msg); // le message reste visible dans la sortie de test
|
||
}
|
||
QString m_texte;
|
||
QSet<QtMsgType> m_types;
|
||
static QList<JournalCapture *> s_pile;
|
||
static QtMessageHandler s_origine;
|
||
};
|
||
QList<JournalCapture *> JournalCapture::s_pile;
|
||
QtMessageHandler JournalCapture::s_origine = nullptr;
|
||
|
||
//! \brief Lit une grandeur entière du mécanisme d'une charge, par la frontière PUBLIQUE.
|
||
//! \return La valeur, ou -1 si la charge n'est pas publiée — un -1 fait échouer l'assertion
|
||
//! appelante, ce qu'un 0 silencieux ne ferait pas.
|
||
int mechanismInt(EnergyArbitrator *arb, const QString &loadId, const char *cle)
|
||
{
|
||
for (const QVariant &v : arb->loadTelemetry().value(QStringLiteral("loads")).toList())
|
||
if (v.toMap().value(QStringLiteral("loadId")).toString() == loadId)
|
||
return v.toMap().value(QStringLiteral("mechanism")).toMap()
|
||
.value(QLatin1String(cle)).toInt();
|
||
return -1;
|
||
}
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testEcsSurplusPV()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testEcsSurplusPV nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [T3] ECS piloté en WATTS via l'interface etmvariableload (contrat rév. 2 §3/§5) : l'arbitre
|
||
// arrondit le surplus au powerLevels déclaré (fixed) ou clampe à maxPowerW (dynamic), écrit
|
||
// powerSetpoint, et recrédite currentPowerW (début de cycle) pour l'anti-clignotement. La
|
||
// combinatoire matérielle (relais/triac) vit dans le thing — invisible côté moteur.
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
|
||
// =================== FIXED : paliers déclarés [0, 1200, 2400] ===================
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY2(arbitrator, "smartChargingManager n'est pas un EnergyArbitrator (ETM_ARBITRATOR requis)");
|
||
ThingManager *thingManager = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterThingId = addMeter();
|
||
QVERIFY(!meterThingId.isNull());
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterThingId);
|
||
Thing *meterThing = thingManager->findConfiguredThing(meterThingId);
|
||
QVERIFY(meterThing);
|
||
meterThing->setStateValue("connected", true);
|
||
|
||
QUuid ecsId = addEtmVariableLoad(27001);
|
||
QVERIFY(!ecsId.isNull());
|
||
Thing *ecsThing = thingManager->findConfiguredThing(ecsId);
|
||
QVERIFY(ecsThing);
|
||
|
||
EtmVariableLoadAdapter *ecs = new EtmVariableLoadAdapter(
|
||
thingManager, ecsId.toString(), "ECS variable",
|
||
QList<int>({0, 1200, 2400}), 2400, /* minPowerW */ 0, 1, LoadNeeds(), arbitrator);
|
||
arbitrator->registerEtmVariableLoadAdapter(ecs);
|
||
|
||
auto setMeterW = [&](double signedW){ meterThing->setStateValue("currentPower", signedW); }; // <0 = export
|
||
auto setLoadW = [&](double w){ ecsThing->setStateValue("currentPowerW", w); }; // mesure réelle simulée
|
||
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
|
||
|
||
// --- Cascade montante (la charge ne tire encore rien : currentPowerW = 0) ---
|
||
setLoadW(0);
|
||
setMeterW(-1000); cycle(t0); // budget 1000 < 1200 → 0 W
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
|
||
setMeterW(-1500); cycle(t0); // budget 1500 → palier 1200
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 1200);
|
||
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 1200); // round-trip interface
|
||
setMeterW(-2500); cycle(t0); // budget 2500 → palier 2400
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 2400);
|
||
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 2400);
|
||
|
||
// --- Anti-clignotement (recrédit) : la charge tire 2400, PV 2500 → export net 100.
|
||
// budget = 100 + 2400 (recrédit currentPowerW début de cycle) = 2500 → RESTE 2400.
|
||
// Sans recrédit : 100 → 0 → oscillation. C'est précisément le test du recrédit. ---
|
||
setLoadW(2400);
|
||
setMeterW(-100); cycle(t0);
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 2400);
|
||
|
||
// --- Délestage sur import : charge tire 2400, import 600 → budget = -600 + 2400 = 1800 → 1200. ---
|
||
setMeterW(600); cycle(t0);
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 1200);
|
||
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 1200);
|
||
|
||
// =================== DYNAMIC : modulation continue, maxPowerW 3000, sans powerLevels ===================
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb2 = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb2);
|
||
ThingManager *tm2 = NymeaCore::instance()->thingManager();
|
||
QUuid mId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(mId);
|
||
Thing *m2 = tm2->findConfiguredThing(mId);
|
||
QVERIFY(m2);
|
||
m2->setStateValue("connected", true);
|
||
QUuid rId = addEtmVariableLoad(27002);
|
||
Thing *rThing = tm2->findConfiguredThing(rId);
|
||
QVERIFY(rThing);
|
||
rThing->setStateValue("currentPowerW", 0);
|
||
|
||
EtmVariableLoadAdapter *router = new EtmVariableLoadAdapter(
|
||
tm2, rId.toString(), "Routeur PV", QList<int>(), 3000, /* minPowerW */ 0, 1, LoadNeeds(), arb2); // powerLevels vide → dynamic
|
||
arb2->registerEtmVariableLoadAdapter(router);
|
||
|
||
m2->setStateValue("currentPower", -2000); arb2->simulationCallUpdate(t0); QCoreApplication::processEvents();
|
||
QCOMPARE(qRound(router->currentSetpointW()), 2000); // clamp(2000, 0, 3000)
|
||
m2->setStateValue("currentPower", -4000); arb2->simulationCallUpdate(t0); QCoreApplication::processEvents();
|
||
QCOMPARE(qRound(router->currentSetpointW()), 3000); // plafonné à maxPowerW
|
||
m2->setStateValue("currentPower", 500); arb2->simulationCallUpdate(t0); QCoreApplication::processEvents();
|
||
QCOMPARE(qRound(router->currentSetpointW()), 0); // import → 0
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testMeterSilentFallback()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testMeterSilentFallback nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [T4] Repli L2 charge pilotée recâblé : compteur muet >90 s → setPowerSetpoint(0) force=true
|
||
// sur l'etmvariableload, planif suspendue (reste 0 sur N cycles), reprise au retour compteur.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY2(arbitrator, "smartChargingManager n'est pas un EnergyArbitrator (ETM_ARBITRATOR requis)");
|
||
ThingManager *thingManager = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterThingId = addMeter();
|
||
QVERIFY(!meterThingId.isNull());
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterThingId);
|
||
Thing *meterThing = thingManager->findConfiguredThing(meterThingId);
|
||
QVERIFY(meterThing);
|
||
meterThing->setStateValue("connected", true);
|
||
|
||
QUuid ecsId = addEtmVariableLoad(27010);
|
||
QVERIFY(!ecsId.isNull());
|
||
Thing *ecsThing = thingManager->findConfiguredThing(ecsId);
|
||
QVERIFY(ecsThing);
|
||
|
||
EtmVariableLoadAdapter *ecs = new EtmVariableLoadAdapter(
|
||
thingManager, ecsId.toString(), "ECS repli",
|
||
QList<int>({0, 2400}), 2400, /* minPowerW */ 0, 1, LoadNeeds(), arbitrator);
|
||
arbitrator->registerEtmVariableLoadAdapter(ecs);
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
auto setMeterW = [&](double signedW){ meterThing->setStateValue("currentPower", signedW); };
|
||
auto setLoadW = [&](double w){ ecsThing->setStateValue("currentPowerW", w); };
|
||
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
|
||
|
||
// ECS servi sur surplus, compteur frais à T0.
|
||
arbitrator->recordMeterUpdate(t0);
|
||
setLoadW(0);
|
||
setMeterW(-2500); cycle(t0); // budget 2500 → 2400
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 2400);
|
||
QVERIFY(!arbitrator->degradedMode());
|
||
|
||
// Compteur muet > 90 s → mode dégradé : setPowerSetpoint(0) force=true.
|
||
setLoadW(2400); // la charge tirait
|
||
arbitrator->evaluateMeterFreshness(t0.addSecs(91));
|
||
QCoreApplication::processEvents();
|
||
QVERIFY(arbitrator->degradedMode());
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
|
||
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 0);
|
||
|
||
// STABILITÉ : muet, plusieurs cycles, faux surplus piège → l'ECS RESTE à 0 (planif suspendue).
|
||
setMeterW(-3000);
|
||
foreach (int dt, QList<int>({92, 120, 200, 280})) {
|
||
cycle(t0.addSecs(dt));
|
||
QVERIFY2(arbitrator->degradedMode(), "degradedMode doit rester actif pendant le silence");
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
|
||
}
|
||
|
||
// REPRISE : compteur reparle → degradedMode retombe → recalcul (pas de restauration d'ancienne consigne).
|
||
arbitrator->recordMeterUpdate(t0.addSecs(300));
|
||
QVERIFY(!arbitrator->degradedMode());
|
||
setLoadW(0);
|
||
setMeterW(-1000); cycle(t0.addSecs(301)); // 1000 < 2400 → reste 0
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
|
||
setMeterW(-2500); cycle(t0.addSecs(302)); // surplus suffisant → 2400
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 2400);
|
||
|
||
// ===================== Cas RELAIS (rév. 3) — certification du trou T2 fermé =====================
|
||
// Compteur muet → le RelayRouter coupe TOUS ses relais, force=true (bypass minOn). Sans ce
|
||
// volet, le repli L2 du routeur n'est pas certifié : des relais ECS pourraient rester allumés.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb2 = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb2);
|
||
ThingManager *tm2 = NymeaCore::instance()->thingManager();
|
||
QUuid mId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(mId);
|
||
Thing *m2 = tm2->findConfiguredThing(mId);
|
||
QVERIFY(m2);
|
||
m2->setStateValue("connected", true);
|
||
|
||
QUuid rA = addPowerSwitch(1000, 26661);
|
||
QUuid rB = addPowerSwitch(1500, 26662);
|
||
Thing *relayA = tm2->findConfiguredThing(rA);
|
||
Thing *relayB = tm2->findConfiguredThing(rB);
|
||
QVERIFY(relayA && relayB);
|
||
|
||
// RelayRouter : paliers DÉRIVÉS [0, 1000, 1500, 2500] ; minOn=300 s (pour prouver le bypass force).
|
||
RelayRouter *router = new RelayRouter(
|
||
tm2, "ecs-relais-repli", "ECS relais (repli)",
|
||
QList<LoadConfigRelay>({ {rA.toString(), 1000}, {rB.toString(), 1500} }),
|
||
300, 0, 1, LoadNeeds(), arb2);
|
||
arb2->registerRelayRouter(router);
|
||
|
||
auto cycle2 = [&](const QDateTime &now){ arb2->simulationCallUpdate(now); QCoreApplication::processEvents(); };
|
||
|
||
// Surplus 2500 → palier 2500 (A+B) : les DEUX relais ON.
|
||
arb2->recordMeterUpdate(t0);
|
||
m2->setStateValue("currentPower", -2500); cycle2(t0);
|
||
QCOMPARE(qRound(router->currentSetpointW()), 2500);
|
||
QCOMPARE(relayA->stateValue("power").toBool(), true);
|
||
QCOMPARE(relayB->stateValue("power").toBool(), true);
|
||
QVERIFY(!arb2->degradedMode());
|
||
|
||
// Compteur muet > 90 s → mode dégradé : TOUS les relais OFF, force=true (bypass minOn 300).
|
||
arb2->evaluateMeterFreshness(t0.addSecs(91));
|
||
QCoreApplication::processEvents();
|
||
QVERIFY(arb2->degradedMode());
|
||
QCOMPARE(qRound(router->currentSetpointW()), 0);
|
||
QCOMPARE(relayA->stateValue("power").toBool(), false); // trou T2 fermé : relais coupé
|
||
QCOMPARE(relayB->stateValue("power").toBool(), false);
|
||
|
||
// STABILITÉ : muet, faux surplus piège → les relais RESTENT OFF (planif suspendue).
|
||
m2->setStateValue("currentPower", -3000);
|
||
foreach (int dt, QList<int>({92, 200})) {
|
||
cycle2(t0.addSecs(dt));
|
||
QVERIFY2(arb2->degradedMode(), "degradedMode doit rester actif pendant le silence");
|
||
QCOMPARE(relayA->stateValue("power").toBool(), false);
|
||
QCOMPARE(relayB->stateValue("power").toBool(), false);
|
||
}
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testLoadConfigPersistence()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testLoadConfigPersistence nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// Persistance §4 : SetConfigs → fichier écrit → relecture par un store neuf → round-trip
|
||
// mot pour mot (id/label/mode/powerLevels/maxPowerW/priority/enabled/needs).
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-persist.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
const QVariantMap entry{
|
||
{"id", "{11111111-2222-3333-4444-555555555555}"},
|
||
{"label", "Chauffe-eau"},
|
||
{"adapter", "etmvariableload"},
|
||
{"mode", "fixed"},
|
||
{"powerLevels", QVariantList() << 0 << 600 << 1200},
|
||
{"maxPowerW", 1200},
|
||
{"priority", 2},
|
||
{"enabled", true},
|
||
{"needs", QVariantMap{{"dailyDeadline", "06:00"}, {"minEnergyWhPerDay", 4000}}}
|
||
};
|
||
LoadConfigs cfgs;
|
||
cfgs.append(LoadConfig::fromMap(entry));
|
||
|
||
{
|
||
LoadConfigStore store1;
|
||
QString err;
|
||
QVERIFY2(store1.setConfigs(cfgs, &err), err.toUtf8());
|
||
}
|
||
QVERIFY(QFileInfo::exists(cfgPath));
|
||
|
||
LoadConfigStore store2; // relit le fichier
|
||
QCOMPARE(store2.configs().count(), 1);
|
||
const LoadConfig r = store2.configs().first();
|
||
QCOMPARE(r.label(), QString("Chauffe-eau"));
|
||
QCOMPARE(r.mode(), QString("fixed"));
|
||
QCOMPARE(r.powerLevelsInt(), QList<int>({0, 600, 1200}));
|
||
QCOMPARE(r.maxPowerW(), 1200);
|
||
QCOMPARE(r.priority(), 2);
|
||
QVERIFY(r.enabled());
|
||
QCOMPARE(r.needs().dailyDeadline(), QString("06:00"));
|
||
QCOMPARE(r.needs().minEnergyWhPerDay(), 4000);
|
||
|
||
// Validation : une config fixed sans 0 dans powerLevels est rejetée EN BLOC (rien persisté).
|
||
QVariantMap badEntry = entry;
|
||
badEntry["powerLevels"] = QVariantList() << 600 << 1200; // pas de 0
|
||
LoadConfigs bad;
|
||
bad.append(LoadConfig::fromMap(badEntry));
|
||
LoadConfigStore store3;
|
||
QString err2;
|
||
QVERIFY(!store3.setConfigs(bad, &err2));
|
||
QVERIFY(!err2.isEmpty());
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testLoadConfigBuildsAdapters()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testLoadConfigBuildsAdapters nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// SetConfigs → store.changed → l'arbitre (re)construit les adaptateurs etmvariableload.
|
||
// enabled==true servi par le surplus ; enabled==false JAMAIS piloté (exclu, contrat §9).
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arbitrator);
|
||
ThingManager *thingManager = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = thingManager->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QUuid id1 = addEtmVariableLoad(27020); // enabled
|
||
QUuid id2 = addEtmVariableLoad(27021); // disabled
|
||
Thing *t1 = thingManager->findConfiguredThing(id1);
|
||
Thing *t2 = thingManager->findConfiguredThing(id2);
|
||
QVERIFY(t1 && t2);
|
||
t1->setStateValue("currentPowerW", 0);
|
||
t2->setStateValue("currentPowerW", 0);
|
||
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-build.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
LoadConfigStore *store = new LoadConfigStore(arbitrator);
|
||
arbitrator->setLoadConfigStore(store);
|
||
|
||
LoadConfigs cfgs;
|
||
cfgs.append(LoadConfig::fromMap(QVariantMap{
|
||
{"id", id1.toString()}, {"label", "ECS actif"}, {"adapter", "etmvariableload"},
|
||
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 2400}, {"maxPowerW", 2400},
|
||
{"priority", 1}, {"enabled", true}}));
|
||
cfgs.append(LoadConfig::fromMap(QVariantMap{
|
||
{"id", id2.toString()}, {"label", "ECS désactivé"}, {"adapter", "etmvariableload"},
|
||
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 1200}, {"maxPowerW", 1200},
|
||
{"priority", 2}, {"enabled", false}}));
|
||
QString err;
|
||
QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8()); // persiste + changed → rebuild
|
||
|
||
// Surplus large (5000 W) : assez pour servir les DEUX si elles étaient actives.
|
||
meter->setStateValue("currentPower", -5000);
|
||
arbitrator->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
|
||
QCoreApplication::processEvents();
|
||
|
||
QCOMPARE(qRound(t1->stateValue("powerSetpoint").toDouble()), 2400); // enabled → servi
|
||
QCOMPARE(qRound(t2->stateValue("powerSetpoint").toDouble()), 0); // disabled → jamais piloté
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testLoadConfigRpc()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testLoadConfigRpc nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// End-to-end JSON-RPC (la couche que l'app consomme) : GetLoadConfig vide → SetLoadConfig
|
||
// (fixed réelle + dynamic) → energyError NoError → GetLoadConfig round-trip → effet RÉEL sur
|
||
// le thing (RPC → store → changed → arbitre reconstruit → adaptateur → powerSetpoint) →
|
||
// rejet d'une config invalide.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-rpc.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); // avant initTestCase → store sur ce chemin
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arbitrator);
|
||
ThingManager *thingManager = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = thingManager->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QUuid ecsId = addEtmVariableLoad(27030); // thing réel piloté par la config fixed
|
||
Thing *ecsThing = thingManager->findConfiguredThing(ecsId);
|
||
QVERIFY(ecsThing);
|
||
ecsThing->setStateValue("currentPowerW", 0);
|
||
|
||
// 1. GetLoadConfig initial → vide.
|
||
QVariant resp = injectAndWait("NymeaEnergy.GetLoadConfig");
|
||
QVERIFY(resp.toMap().value("params").toMap().value("loadConfigs").toList().isEmpty());
|
||
|
||
// 2. SetLoadConfig : les DEUX formes rév. 3 (mutuellement exclusives) doivent être acceptées
|
||
// par le MÊME schéma SET — sinon nymea rejette une forme avant le handler (bug objectRef
|
||
// strict de T4, doublé). Une etmvariableload fixed (thing réel) + une dynamic + une relay-router.
|
||
QVariantList loadConfigs;
|
||
loadConfigs << QVariantMap{
|
||
{"id", ecsId.toString()}, {"label", "Chauffe-eau"}, {"adapter", "etmvariableload"},
|
||
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 1200 << 2400}, {"maxPowerW", 2400},
|
||
{"priority", 2}, {"enabled", true},
|
||
{"needs", QVariantMap{{"dailyDeadline", "06:00"}, {"minEnergyWhPerDay", 4000}}}};
|
||
loadConfigs << QVariantMap{
|
||
{"id", "{b033b212-1adb-4df0-ba2b-8fa477de52a2}"}, {"label", "Routeur PV"},
|
||
{"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"maxPowerW", 3000},
|
||
{"priority", 1}, {"enabled", true}};
|
||
loadConfigs << QVariantMap{ // ← rév. 3 : relay-router (relays[])
|
||
{"id", "ecs-relais"}, {"label", "ECS relais"}, {"adapter", "relay-router"},
|
||
{"mode", "fixed"}, {"priority", 3}, {"enabled", true},
|
||
{"relays", QVariantList()
|
||
<< QVariantMap{{"thingId", "{aaaaaaaa-1111-2222-3333-444444444444}"}, {"powerW", 1000}}
|
||
<< QVariantMap{{"thingId", "{bbbbbbbb-1111-2222-3333-444444444444}"}, {"powerW", 1500}}},
|
||
{"minOnS", 60}, {"minOffS", 60}};
|
||
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", loadConfigs}});
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError"));
|
||
|
||
// 3. GetLoadConfig → round-trip des 3 entrées (les 2 formes), champs préservés (pack/unpack).
|
||
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
|
||
const QVariantList got = resp.toMap().value("params").toMap().value("loadConfigs").toList();
|
||
QCOMPARE(got.size(), 3);
|
||
QVariantMap fixedGot, relayGot;
|
||
for (const QVariant &v : got) {
|
||
const QVariantMap m = v.toMap();
|
||
if (m.value("id").toString() == ecsId.toString()) fixedGot = m;
|
||
if (m.value("adapter").toString() == "relay-router") relayGot = m;
|
||
}
|
||
QVERIFY(!fixedGot.isEmpty());
|
||
QCOMPARE(fixedGot.value("mode").toString(), QString("fixed"));
|
||
QCOMPARE(fixedGot.value("label").toString(), QString("Chauffe-eau"));
|
||
QVariantList levels = fixedGot.value("powerLevels").toList();
|
||
QCOMPARE(levels.size(), 3);
|
||
QCOMPARE(levels.last().toInt(), 2400);
|
||
QCOMPARE(fixedGot.value("needs").toMap().value("dailyDeadline").toString(), QString("06:00"));
|
||
// Forme relay-router : relays[] + minOnS round-trip (jamais piloté ici — RelayRouter en étape 4).
|
||
QVERIFY(!relayGot.isEmpty());
|
||
QVariantList relays = relayGot.value("relays").toList();
|
||
QCOMPARE(relays.size(), 2);
|
||
QCOMPARE(relays.last().toMap().value("powerW").toInt(), 1500);
|
||
QCOMPARE(relayGot.value("minOnS").toInt(), 60);
|
||
|
||
// 3bis. VA-ET-VIENT : réinjecter tel quel ce que GET vient de rendre. C'est le geste réel
|
||
// de l'app — lire, modifier un champ, réécrire — et il échouait : GET sérialise via le
|
||
// méta-objet, donc TOUTES les propriétés déclarées, y compris un « sgReady » VIDE sur une
|
||
// charge qui n'est pas SG-Ready. Le schéma SET exigeait « states » dès que la clé était
|
||
// présente, et nymea rejetait AVANT le handler. Constaté au banc le 2026-08-10, sur la
|
||
// première tentative réelle de SetLoadConfig — aucun test ne faisait ce va-et-vient.
|
||
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", got}});
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError"));
|
||
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("loadConfigs").toList().size(), 3);
|
||
|
||
// 4. Fichier persisté.
|
||
QVERIFY(QFileInfo::exists(cfgPath));
|
||
|
||
// 5. EFFET RÉEL : le Set RPC a reconstruit les adaptateurs → un cycle surplus pilote le thing.
|
||
// Cascade du waterfall unifié construit DEPUIS la config RPC : surplus 6000 → routeur dynamic
|
||
// (rang 1) prend 3000 (clamp maxPowerW), reliquat 3000 → ECS fixed (rang 2) prend le palier 2400.
|
||
meter->setStateValue("currentPower", -6000);
|
||
arbitrator->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
|
||
QCoreApplication::processEvents();
|
||
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 2400); // reliquat 3000 → palier 2400
|
||
|
||
// 6. Validation CONDITIONNELLE — rejet en bloc (energyError InvalidParameter) :
|
||
// (a) etmvariableload fixed sans 0 dans powerLevels ; (b) relay-router avec relays[] vide.
|
||
QVariantList badA;
|
||
badA << QVariantMap{
|
||
{"id", "{cccccccc-2222-3333-4444-555555555555}"}, {"label", "Bancal"}, {"adapter", "etmvariableload"},
|
||
{"mode", "fixed"}, {"powerLevels", QVariantList() << 1200 << 2400}, {"maxPowerW", 2400},
|
||
{"priority", 1}, {"enabled", true}};
|
||
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", badA}});
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorInvalidParameter"));
|
||
|
||
QVariantList badB;
|
||
badB << QVariantMap{
|
||
{"id", "ecs-vide"}, {"label", "ECS sans relais"}, {"adapter", "relay-router"},
|
||
{"mode", "fixed"}, {"priority", 1}, {"enabled", true},
|
||
{"relays", QVariantList()}}; // relays[] vide → invalide
|
||
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", badB}});
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorInvalidParameter"));
|
||
|
||
// Aucun rejet n'a écrasé : GetLoadConfig retourne toujours les 3 valides.
|
||
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("loadConfigs").toList().size(), 3);
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testSgReadySurplus()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testSgReadySurplus nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// Encodage SG-Ready 2 bits (K1,K2) : 1=[K1] blocage · 2=[] normal · 3=[K2] reco · 4=[K1,K2] forcé.
|
||
// estimatedPowerW déclaré : P3=1500, P4=3000. Hystérésis état 4 : entrée P4×1,2=3600, sortie P4×1,0=3000.
|
||
const QHash<int, double> pacPower({ {1, 0.0}, {2, 0.0}, {3, 1500.0}, {4, 3000.0} });
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
|
||
// ===================== Volets 1-3 : PAC seule =====================
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY2(arbitrator, "smartChargingManager n'est pas un EnergyArbitrator");
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QUuid k1 = addPowerSwitch(0, 26661);
|
||
QUuid k2 = addPowerSwitch(0, 26662);
|
||
Thing *relayK1 = tm->findConfiguredThing(k1);
|
||
Thing *relayK2 = tm->findConfiguredThing(k2);
|
||
QVERIFY(relayK1 && relayK2);
|
||
|
||
// [lot B-bis] La PAC est une charge de CONFIGURATION comme les autres — plus aucun
|
||
// adaptateur codé en dur, donc plus d'enregistrement direct : le test emprunte le chemin
|
||
// de production (SetConfigs → changed → rebuild → SgReadyAdapter).
|
||
const QString cfgPacPath = QDir::tempPath() + "/etm-loadcfg-sgready.json";
|
||
QFile::remove(cfgPacPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPacPath.toUtf8());
|
||
LoadConfigStore *storePac = new LoadConfigStore(arbitrator);
|
||
arbitrator->setLoadConfigStore(storePac);
|
||
|
||
LoadConfigs cfgsPac;
|
||
cfgsPac.append(LoadConfig::fromMap(sgReadyConfigMap("pac-test", "PAC test", k1, k2,
|
||
/*p3*/ 1500.0, /*p4*/ 3000.0,
|
||
/*minStateHoldS*/ 300, /*priority*/ 1)));
|
||
QString errPac;
|
||
QVERIFY2(storePac->setConfigs(cfgsPac, &errPac), errPac.toUtf8());
|
||
|
||
auto setMeterW = [&](double signedW){ meter->setStateValue("currentPower", signedW); }; // <0 export
|
||
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
|
||
// L'état de la PAC se lit désormais par la frontière publique — celle-là même que l'app
|
||
// consomme. Le test gagne au passage la vérification que la télémétrie suit l'arbitrage.
|
||
auto etatPac = [&](){ return mechanismInt(arbitrator, "pac-test", "state"); };
|
||
|
||
// --- Volet 0 : ARMEMENT À FROID du verrou (ECS-412 appliqué au minStateHold) ---
|
||
// Un adaptateur neuf ne sait PAS quand la PAC a commuté pour la dernière fois : un
|
||
// m_lastSwitch nul vaut « commutation venant d'avoir lieu », pas « jamais commuté ». Sans
|
||
// cela, un redémarrage de nymead pendant que le compresseur tourne autoriserait une coupure
|
||
// immédiate — du court-cycling provoqué par un redémarrage de service, exactement ce que
|
||
// minStateHold existe pour empêcher.
|
||
setMeterW(-3000); cycle(t0); // budget largement suffisant pour l'état 4…
|
||
QCOMPARE(etatPac(), 2); // …mais GELÉ par l'armement à froid
|
||
// L'armement est PARESSEUX : il expire. C'est le défaut d'ECS-412 qu'on refuse de refaire —
|
||
// un armement PERMANENT gelait toute charge à froid, sans issue (pas d'enclenchement → pas
|
||
// de commutation → pas d'estampille → blocage circulaire). L'estampille est posée au
|
||
// PREMIER cycle reçu, refusé ou non, si bien que le compte à rebours court dès maintenant.
|
||
const QDateTime tA = t0.addSecs(301); // > minStateHoldS (300) : le verrou a expiré
|
||
|
||
// --- Volet 1 : montée d'états 2 → 3 → 4 (mapping sémantique) ---
|
||
setMeterW(-1000); cycle(tA); // budget 1000 < P3 → état 2 (normal)
|
||
QCOMPARE(etatPac(), 2);
|
||
setMeterW(-2000); cycle(tA); // budget 2000 ≥ P3 → état 3 (reco)
|
||
QCOMPARE(etatPac(), 3);
|
||
QCOMPARE(relayK2->stateValue("power").toBool(), true);
|
||
QCOMPARE(relayK1->stateValue("power").toBool(), false);
|
||
setMeterW(-2500); cycle(tA.addSecs(400)); // budget 2500+1500=4000 ≥ P4×1,2 → état 4 (hold écoulé)
|
||
QCOMPARE(etatPac(), 4);
|
||
QCOMPARE(relayK1->stateValue("power").toBool(), true);
|
||
QCOMPARE(relayK2->stateValue("power").toBool(), true);
|
||
|
||
// --- Volet 2 : hystérésis 3↔4 (budget oscille dans la zone morte [P4×1,0 ; P4×1,2)) ---
|
||
// hold écoulé à chaque cycle (lastSwitch=tA+400) → c'est la ZONE MORTE qui tient l'état 4, pas le verrou.
|
||
setMeterW(-300); cycle(tA.addSecs(800)); // budget 300+3000=3300 ∈ [3000,3600) → reste 4
|
||
QCOMPARE(etatPac(), 4);
|
||
setMeterW(-100); cycle(tA.addSecs(1200)); // budget 3100 → reste 4
|
||
QCOMPARE(etatPac(), 4);
|
||
setMeterW(-500); cycle(tA.addSecs(1600)); // budget 3500 → reste 4
|
||
QCOMPARE(etatPac(), 4);
|
||
// En-dessous de P4×1,0 → sort enfin de l'état 4 (vers 3).
|
||
setMeterW(200); cycle(tA.addSecs(2000)); // import 200 → budget -200+3000=2800 < 3000 → état 3
|
||
QCOMPARE(etatPac(), 3);
|
||
|
||
// --- Volet 3 : protection court-cycling (changement avant minStateHold → GELÉ) ---
|
||
// lastSwitch=tA+2000. À tA+2100 (elapsed 100 < hold 300) : surplus abondant mais GELÉ en 3.
|
||
setMeterW(-3000); cycle(tA.addSecs(2100));
|
||
QCOMPARE(etatPac(), 3); // gelé malgré budget ≥ P4×1,2 (protection compresseur)
|
||
// À tA+2400 (elapsed 400 > hold) : MÊME surplus → bascule en 4. Seul le temps simulé a changé.
|
||
setMeterW(-3000); cycle(tA.addSecs(2400));
|
||
QCOMPARE(etatPac(), 4);
|
||
|
||
// ===================== Volet 4 : budget PARTAGÉ ECS(etmvariableload)↔PAC =====================
|
||
// [T3] Surplus 3000 W ; ECS palier 2400 W (etmvariableload, kind Setpoint), PAC P3 = 1500.
|
||
// Selon l'ordre de priorité, l'un se sert et l'autre voit le RELIQUAT → waterfall unifié
|
||
// (un seul budget cascade à travers etmvariableload ET sg-ready, triés par priorité).
|
||
auto runSharedBudget = [&](int ecsPrio, int pacPrio, double &ecsSetpointOut, int &pacStateOut) {
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm2 = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid mId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(mId);
|
||
Thing *m2 = tm2->findConfiguredThing(mId);
|
||
QVERIFY(m2);
|
||
m2->setStateValue("connected", true);
|
||
|
||
QUuid eId = addEtmVariableLoad(27003); // ECS etmvariableload
|
||
QUuid j1 = addPowerSwitch(0, 26661); // relais PAC K1
|
||
QUuid j2 = addPowerSwitch(0, 26662); // relais PAC K2
|
||
Thing *eThing = tm2->findConfiguredThing(eId);
|
||
QVERIFY(eThing);
|
||
eThing->setStateValue("currentPowerW", 0);
|
||
|
||
// LES DEUX charges viennent de la CONFIGURATION (lot B-bis) : c'est le chemin de
|
||
// production, et le seul depuis que la PAC n'a plus d'exemplaire codé en dur. Les
|
||
// mélanger — un adaptateur enregistré en direct + un store — ne marche pas, et c'est
|
||
// voulu : rebuildLoadAdapters() met en état sûr puis détruit tout ce que la config ne
|
||
// référence pas (ECS-413). Une charge « en dur » disparaîtrait au premier SetLoadConfig.
|
||
const QString cfgWfPath = QDir::tempPath() + "/etm-loadcfg-waterfall.json";
|
||
QFile::remove(cfgWfPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgWfPath.toUtf8());
|
||
LoadConfigStore *storeWf = new LoadConfigStore(arb);
|
||
arb->setLoadConfigStore(storeWf);
|
||
|
||
LoadConfigs cfgsWf;
|
||
// ECS : 1 palier à 2400 W (fixed). La charge ne tire encore rien (currentPowerW=0).
|
||
cfgsWf.append(LoadConfig::fromMap(QVariantMap{
|
||
{"id", eId.toString()}, {"label", "ECS waterfall"}, {"adapter", "etmvariableload"},
|
||
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 2400}, {"maxPowerW", 2400},
|
||
{"priority", ecsPrio}, {"enabled", true}}));
|
||
cfgsWf.append(LoadConfig::fromMap(sgReadyConfigMap("pac-wf", "PAC waterfall", j1, j2,
|
||
1500.0, 3000.0, 300, pacPrio)));
|
||
QString errWf;
|
||
QVERIFY2(storeWf->setConfigs(cfgsWf, &errWf), errWf.toUtf8());
|
||
|
||
m2->setStateValue("currentPower", -3000); // export 3000 W
|
||
// Cycle d'AMORÇAGE : il désarme le verrou à froid de la PAC (ECS-412) sans rien
|
||
// décider d'intéressant. La mesure se fait au cycle suivant, verrou expiré — sinon on
|
||
// mesurerait la protection compresseur au lieu de l'ordre de service.
|
||
arb->simulationCallUpdate(t0);
|
||
QCoreApplication::processEvents();
|
||
arb->simulationCallUpdate(t0.addSecs(301));
|
||
QCoreApplication::processEvents();
|
||
ecsSetpointOut = mechanismInt(arb, eId.toString(), "setpointW");
|
||
pacStateOut = mechanismInt(arb, "pac-wf", "state");
|
||
};
|
||
|
||
double ecsSetpoint = -1;
|
||
int pacState = -1;
|
||
|
||
// ECS prioritaire (rang 1) : ECS se sert (2400) → reliquat 600 < P3 → PAC reste NORMAL (2).
|
||
runSharedBudget(/*ecsPrio*/ 1, /*pacPrio*/ 2, ecsSetpoint, pacState);
|
||
QCOMPARE(qRound(ecsSetpoint), 2400);
|
||
QCOMPARE(pacState, 2);
|
||
|
||
// Priorités INVERSÉES — PAC prioritaire (rang 1) : PAC se sert (état 3, 1500) → reliquat
|
||
// 1500 < 2400 → l'ECS reste à 0 W. L'ordre de service s'inverse (même budget unique).
|
||
runSharedBudget(/*ecsPrio*/ 2, /*pacPrio*/ 1, ecsSetpoint, pacState);
|
||
QCOMPARE(qRound(ecsSetpoint), 0);
|
||
QCOMPARE(pacState, 3);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testEcsRelayTopologies()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testEcsRelayTopologies nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [rév. 3] La combinatoire watts→relais vit DANS le RelayRouter (couche routeur, frontière
|
||
// déplacée). On teste directement applyAction(Setpoint W) : paliers DÉRIVÉS, arrondi à la
|
||
// combinaison ≤ setpoint, off-before-on non-cascadé, et DÉDUPLICATION des niveaux.
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
auto sp = [&](double w) {
|
||
LoadAction a;
|
||
a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus;
|
||
// Le motif est un CODE : ce test ne porte pas sur le texte, seulement sur la
|
||
// non-vacuité exigée par la règle 7 (une action au code vide est rejetée).
|
||
a.powerW = w; a.reason = { DecisionCode::SurplusSetpoint };
|
||
return a;
|
||
};
|
||
// Les deux sorties sont initialisées par l'APPELANT et vérifiées APRÈS l'appel : un
|
||
// QVERIFY en échec sort de CE lambda, pas du test, et laisserait sinon des pointeurs
|
||
// non initialisés déréférencés juste après — comportement indéfini au lieu d'un échec
|
||
// de test lisible.
|
||
auto freshSetup = [&](ThingManager *&tm, QObject *&owner) {
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
tm = NymeaCore::instance()->thingManager();
|
||
owner = arb;
|
||
};
|
||
|
||
// ===================== Topologie 1 : 1 relais (dégénéré [0, 2000]) =====================
|
||
{
|
||
ThingManager *tm = nullptr; QObject *owner = nullptr;
|
||
freshSetup(tm, owner);
|
||
QVERIFY(tm && owner);
|
||
QUuid r = addPowerSwitch(2000, 26661);
|
||
Thing *relay = tm->findConfiguredThing(r);
|
||
QVERIFY(relay);
|
||
RelayRouter *ecs = new RelayRouter(tm, "ecs-1", "ECS 1 relais",
|
||
QList<LoadConfigRelay>({ {r.toString(), 2000} }), 0, 0, 1, LoadNeeds(), owner);
|
||
QCOMPARE(ecs->descriptor().declared.powerLevels, QList<int>({0, 2000}));
|
||
|
||
ecs->applyAction(sp(2500), t0); // 2500 → palier 2000
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 2000);
|
||
QCOMPARE(relay->stateValue("power").toBool(), true);
|
||
ecs->applyAction(sp(1000), t0.addSecs(1)); // 1000 < 2000 → 0
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
|
||
QCOMPARE(relay->stateValue("power").toBool(), false);
|
||
}
|
||
|
||
// ============= Topologie 2 : 3 relais 500/1000/2000 (NON-CASCADÉ, off-before-on) =============
|
||
{
|
||
ThingManager *tm = nullptr; QObject *owner = nullptr;
|
||
freshSetup(tm, owner);
|
||
QVERIFY(tm && owner);
|
||
QUuid r500 = addPowerSwitch(500, 26661);
|
||
QUuid r1000 = addPowerSwitch(1000, 26662);
|
||
QUuid r2000 = addPowerSwitch(2000, 26663);
|
||
Thing *t500 = tm->findConfiguredThing(r500);
|
||
Thing *t1000 = tm->findConfiguredThing(r1000);
|
||
Thing *t2000 = tm->findConfiguredThing(r2000);
|
||
QVERIFY(t500 && t1000 && t2000);
|
||
RelayRouter *ecs = new RelayRouter(tm, "ecs-3", "ECS 3 relais",
|
||
QList<LoadConfigRelay>({ {r500.toString(), 500}, {r1000.toString(), 1000}, {r2000.toString(), 2000} }),
|
||
0, 0, 1, LoadNeeds(), owner);
|
||
// 8 niveaux dérivés (2^3 combinaisons toutes distinctes).
|
||
QCOMPARE(ecs->descriptor().declared.powerLevels, QList<int>({0, 500, 1000, 1500, 2000, 2500, 3000, 3500}));
|
||
|
||
// 1700 → palier 1500 = {r500, r1000} (r2000 OFF).
|
||
ecs->applyAction(sp(1700), t0);
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 1500);
|
||
QCOMPARE(t500->stateValue("power").toBool(), true);
|
||
QCOMPARE(t1000->stateValue("power").toBool(), true);
|
||
QCOMPARE(t2000->stateValue("power").toBool(), false);
|
||
|
||
// Transition NON-CASCADÉE 1500 → 2000 = {r2000} SEUL : commute 3 relais (off-before-on).
|
||
ecs->applyAction(sp(2000), t0.addSecs(1));
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 2000);
|
||
QCOMPARE(t500->stateValue("power").toBool(), false);
|
||
QCOMPARE(t1000->stateValue("power").toBool(), false);
|
||
QCOMPARE(t2000->stateValue("power").toBool(), true);
|
||
}
|
||
|
||
// ============= Topologie 3 : DÉDUPLICATION (deux relais identiques 1000 W) =============
|
||
{
|
||
ThingManager *tm = nullptr; QObject *owner = nullptr;
|
||
freshSetup(tm, owner);
|
||
QVERIFY(tm && owner);
|
||
QUuid rA = addPowerSwitch(1000, 26661);
|
||
QUuid rB = addPowerSwitch(1000, 26662);
|
||
Thing *tA = tm->findConfiguredThing(rA);
|
||
Thing *tB = tm->findConfiguredThing(rB);
|
||
QVERIFY(tA && tB);
|
||
RelayRouter *ecs = new RelayRouter(tm, "ecs-dup", "ECS dédup",
|
||
QList<LoadConfigRelay>({ {rA.toString(), 1000}, {rB.toString(), 1000} }), 0, 0, 1, LoadNeeds(), owner);
|
||
// 4 combinaisons MAIS deux donnent 1000 W → FUSIONNÉES : 3 niveaux, pas 4.
|
||
QCOMPARE(ecs->descriptor().declared.powerLevels, QList<int>({0, 1000, 2000}));
|
||
|
||
// 1200 → palier 1000 = UN SEUL relais (le premier de la combinaison dédupliquée).
|
||
ecs->applyAction(sp(1200), t0);
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 1000);
|
||
QCOMPARE(tA->stateValue("power").toBool(), true);
|
||
QCOMPARE(tB->stateValue("power").toBool(), false);
|
||
// 2200 → palier 2000 = les deux.
|
||
ecs->applyAction(sp(2200), t0.addSecs(1));
|
||
QCOMPARE(qRound(ecs->currentSetpointW()), 2000);
|
||
QCOMPARE(tA->stateValue("power").toBool(), true);
|
||
QCOMPARE(tB->stateValue("power").toBool(), true);
|
||
}
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testEnergyRatiosAlignment()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("ETM_ARBITRATOR désactivé — ratios canoniques non compilés.");
|
||
#else
|
||
// Vecteurs joués 1:1 contre la sémantique du seam interim app
|
||
// (EnergyRatiosInterim.compute) : seed, normal, clamp-bas, den≤0→n/a,
|
||
// non-monotone (Δ<0)→reseed, nouveau jour local→reseed.
|
||
EnergyRatiosCalculator calc;
|
||
|
||
auto naAuto = [](const EnergyRatiosCalculator::Ratios &r) { QVERIFY(!r.autoconsommationValid); };
|
||
auto naAuton = [](const EnergyRatiosCalculator::Ratios &r) { QVERIFY(!r.autonomieValid); };
|
||
auto eqAuto = [](const EnergyRatiosCalculator::Ratios &r, double v) {
|
||
QVERIFY(r.autoconsommationValid); QVERIFY(qAbs(r.autoconsommation - v) < 1e-3);
|
||
};
|
||
auto eqAuton = [](const EnergyRatiosCalculator::Ratios &r, double v) {
|
||
QVERIFY(r.autonomieValid); QVERIFY(qAbs(r.autonomie - v) < 1e-3);
|
||
};
|
||
|
||
const QDateTime d29_00(QDate(2026, 6, 29), QTime(0, 0));
|
||
const QDateTime d29_06(QDate(2026, 6, 29), QTime(6, 0));
|
||
const QDateTime d29_08(QDate(2026, 6, 29), QTime(8, 0));
|
||
const QDateTime d29_10(QDate(2026, 6, 29), QTime(10, 0));
|
||
const QDateTime d30_02(QDate(2026, 6, 30), QTime(2, 0));
|
||
const QDateTime d30_10(QDate(2026, 6, 30), QTime(10, 0));
|
||
|
||
// A — 1er appel : seed baseline → deltas nuls → den≤0 → n/a (les deux).
|
||
EnergyRatiosCalculator::Ratios a = calc.compute(1000, 200, 3000, 2200, d29_00);
|
||
naAuto(a); naAuton(a);
|
||
|
||
// B — même jour, croissance monotone : auto=(500-100)/500=80%, autonomie=(1000-500)/1000=50%.
|
||
EnergyRatiosCalculator::Ratios b = calc.compute(1500, 300, 4000, 2700, d29_06);
|
||
eqAuto(b, 80.0); eqAuton(b, 50.0);
|
||
|
||
// C — clamp bas : dReturn(700) > dProd(600) → num<0 → auto borné à 0.0 ;
|
||
// autonomie=(1200-900)/1200=25%.
|
||
EnergyRatiosCalculator::Ratios c = calc.compute(1600, 900, 4200, 3100, d29_08);
|
||
eqAuto(c, 0.0); eqAuton(c, 25.0);
|
||
|
||
// D — compteur non monotone (production 900 < base 1000) → reseed → n/a.
|
||
EnergyRatiosCalculator::Ratios d = calc.compute(900, 900, 4200, 3100, d29_10);
|
||
naAuto(d); naAuton(d);
|
||
|
||
// E — nouveau jour local → reseed → n/a (même si cumuls croissants).
|
||
EnergyRatiosCalculator::Ratios e = calc.compute(2000, 500, 6000, 4000, d30_02);
|
||
naAuto(e); naAuton(e);
|
||
|
||
// F — même jour (30) : auto=(600-200)/600≈66.667%, autonomie=(1000-500)/1000=50%.
|
||
EnergyRatiosCalculator::Ratios f = calc.compute(2600, 700, 7000, 4500, d30_10);
|
||
eqAuto(f, 66.6667); eqAuton(f, 50.0);
|
||
|
||
// ── LA FRONTIÈRE DE PÉRIODE ET L'INSTANT DE MESURE, qui ne sont PAS le même instant ──
|
||
//
|
||
// Publier la seule frontière ferait écrire « aujourd'hui » sur des chiffres qui ne parlent
|
||
// pas de la journée : la baseline se repose à chaque redémarrage de nymead et à chaque
|
||
// recul de compteur, donc en plein jour. C'est la paire qui dit la vérité, pas le champ.
|
||
const QDateTime minuit29(QDate(2026, 6, 29), QTime(0, 0));
|
||
const QDateTime minuit30(QDate(2026, 6, 30), QTime(0, 0));
|
||
|
||
// Cas NORMAL — pose à minuit : les deux coïncident, et c'est ce qui rend l'écart lisible
|
||
// quand il apparaît.
|
||
QCOMPARE(a.debutPeriode, minuit29);
|
||
QCOMPARE(a.mesureDepuis, d29_00);
|
||
|
||
// Sans repose, l'instant de mesure NE BOUGE PAS : il date la baseline, pas le calcul.
|
||
QCOMPARE(b.debutPeriode, minuit29);
|
||
QCOMPARE(b.mesureDepuis, d29_00);
|
||
|
||
// REPOSE EN PLEIN JOUR (compteur non monotone à 10:00) — LES DEUX DIVERGENT. C'est le cas
|
||
// qui justifie la paire : la frontière dit toujours minuit, la mesure ne couvre que depuis
|
||
// 10 h, et un écran qui n'aurait que la frontière l'affirmerait à tort sur la journée.
|
||
QCOMPARE(d.debutPeriode, minuit29);
|
||
QCOMPARE(d.mesureDepuis, d29_10);
|
||
QVERIFY2(d.mesureDepuis > d.debutPeriode,
|
||
"une repose en plein jour doit se VOIR : sinon les ratios mentent sur leur portée");
|
||
|
||
// BASCULE DE JOUR — la frontière suit le nouveau jour, et la mesure date du premier calcul
|
||
// APRÈS minuit, jamais de minuit lui-même. Sur une box qui calcule en continu l'écart est
|
||
// de quelques secondes ; l'affirmer nul serait une approximation que rien n'oblige à faire.
|
||
QCOMPARE(e.debutPeriode, minuit30);
|
||
QCOMPARE(e.mesureDepuis, d30_02);
|
||
|
||
// Et la frontière ne rebouge pas dans la journée.
|
||
QCOMPARE(f.debutPeriode, minuit30);
|
||
QCOMPARE(f.mesureDepuis, d30_02);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testLoadConfigRelayRouter()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testLoadConfigRelayRouter nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// Chaîne COMPLÈTE rév. 3 : SetConfigs(relays[]) → store.changed → rebuild construit un
|
||
// RelayRouter → cycle surplus → arrondi scheduler → routeur → commutation relais → currentPowerW.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arbitrator);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QUuid rA = addPowerSwitch(1000, 26661);
|
||
QUuid rB = addPowerSwitch(1500, 26662);
|
||
Thing *relayA = tm->findConfiguredThing(rA);
|
||
Thing *relayB = tm->findConfiguredThing(rB);
|
||
QVERIFY(relayA && relayB);
|
||
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-relayrouter.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
LoadConfigStore *store = new LoadConfigStore(arbitrator);
|
||
arbitrator->setLoadConfigStore(store);
|
||
|
||
LoadConfigs cfgs;
|
||
cfgs.append(LoadConfig::fromMap(QVariantMap{
|
||
{"id", "ecs-relais"}, {"label", "ECS relais"}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", 1}, {"enabled", true},
|
||
{"relays", QVariantList()
|
||
<< QVariantMap{{"thingId", rA.toString()}, {"powerW", 1000}}
|
||
<< QVariantMap{{"thingId", rB.toString()}, {"powerW", 1500}}},
|
||
{"minOnS", 0}, {"minOffS", 0}}));
|
||
QString err;
|
||
QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8()); // persiste + changed → rebuild → RelayRouter
|
||
|
||
// Surplus 2500 → paliers dérivés [0,1000,1500,2500] → palier 2500 (rA+rB) → les deux ON.
|
||
meter->setStateValue("currentPower", -2500);
|
||
arbitrator->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
|
||
QCoreApplication::processEvents();
|
||
QCOMPARE(relayA->stateValue("power").toBool(), true);
|
||
QCOMPARE(relayB->stateValue("power").toBool(), true);
|
||
// currentPowerW remonte (mock : relais ON → currentPower = nominal) → source du recrédit.
|
||
QCOMPARE(qRound(relayA->stateValue("currentPower").toDouble()), 1000);
|
||
QCOMPARE(qRound(relayB->stateValue("currentPower").toDouble()), 1500);
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testEcsBudgetUnderLock()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testEcsBudgetUnderLock nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [ECS-306] Sous verrou minOn, l'adaptateur maintient un palier au-dessus du budget.
|
||
// Le scheduler DOIT décrémenter le budget de ce palier réel — sinon la charge de
|
||
// priorité suivante reçoit un résidu surestimé et l'installation soutire au réseau.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid rA = addPowerSwitch(2000, 26661); // charge 1, prioritaire, verrouillée minOn
|
||
QUuid rB = addPowerSwitch(1000, 26662); // charge 2, servie sur le résidu
|
||
Thing *tA = tm->findConfiguredThing(rA);
|
||
Thing *tB = tm->findConfiguredThing(rB);
|
||
QVERIFY(tA && tB);
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
|
||
RelayRouter *l1 = new RelayRouter(tm, "ecs-1", "ECS prioritaire",
|
||
QList<LoadConfigRelay>({ {rA.toString(), 2000} }), 300, 0, 1, LoadNeeds(), arb);
|
||
RelayRouter *l2 = new RelayRouter(tm, "ecs-2", "ECS secondaire",
|
||
QList<LoadConfigRelay>({ {rB.toString(), 1000} }), 0, 0, 2, LoadNeeds(), arb);
|
||
arb->registerRelayRouter(l1);
|
||
arb->registerRelayRouter(l2);
|
||
|
||
auto sp = [&](double w) {
|
||
LoadAction a; a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus;
|
||
a.powerW = w; a.reason = { DecisionCode::SurplusSetpoint }; return a;
|
||
};
|
||
|
||
// Amorçage : la charge 1 monte à 2000 W et arme son verrou minOn (300 s).
|
||
l1->applyAction(sp(2500), t0);
|
||
QCOMPARE(qRound(l1->currentSetpointW()), 2000);
|
||
|
||
// 60 s plus tard : minOn non écoulé → la fenêtre exposée impose un PLANCHER de 2000 W.
|
||
const QDateTime t1 = t0.addSecs(60);
|
||
LoadContext c1 = l1->toLoadContext(t1);
|
||
QCOMPARE(qRound(c1.telemetry.lockMinPowerW), 2000); // le canal ECS-306 existe…
|
||
QVERIFY(c1.telemetry.lockMaxPowerW >= c1.telemetry.lockMinPowerW);
|
||
|
||
// …et il est HONORÉ : avec un budget de 500 W, la charge 1 reste écrêtée à 2000 W et le
|
||
// résidu passé à la charge suivante est NÉGATIF, donc la charge 2 reste à 0.
|
||
RuleBasedScheduler sched(arb, nullptr);
|
||
SurplusContext ctx;
|
||
ctx.timestamp = t1;
|
||
ctx.meter.exportW = 500;
|
||
ctx.meter.importW = 0;
|
||
ctx.loads.append(c1);
|
||
ctx.loads.append(l2->toLoadContext(t1));
|
||
|
||
Plan plan = sched.getPlan(ctx);
|
||
Slot slot = plan.slotCovering(t1);
|
||
double a1 = -1, a2 = -1;
|
||
for (const LoadAction &a : slot.actions) {
|
||
if (a.loadId == "ecs-1") a1 = a.powerW;
|
||
if (a.loadId == "ecs-2") a2 = a.powerW;
|
||
}
|
||
QCOMPARE(qRound(a1), 2000); // maintenu par le verrou, au-dessus du budget
|
||
QCOMPARE(qRound(a2), 0); // le résidu tient compte des 2000 W engagés
|
||
QVERIFY2(!slot.actions.isEmpty(), "aucune action produite");
|
||
#endif
|
||
}
|
||
|
||
// [ECS-411-b] Collecteur de messages, pour asserter sur ce que le journal DIT. Sans lui,
|
||
// on ne pourrait tester que le palier déduit — or le défaut visé est précisément qu'un
|
||
// palier juste était annoncé par un silence.
|
||
static QStringList s_journal;
|
||
static QtMessageHandler s_handlerPrecedent = nullptr;
|
||
static void collecterJournal(QtMsgType t, const QMessageLogContext &c, const QString &m)
|
||
{
|
||
s_journal << m;
|
||
if (s_handlerPrecedent)
|
||
s_handlerPrecedent(t, c, m);
|
||
}
|
||
|
||
void Simulation::testEcsRestartRecovery()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testEcsRestartRecovery nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [ECS-411] Des relais déjà fermés au démarrage → le palier courant est DÉDUIT, pas
|
||
// remis à 0. Sinon le moteur croit 0 W pendant que le ballon tire sa puissance.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid r500 = addPowerSwitch(500, 26661);
|
||
QUuid r1000 = addPowerSwitch(1000, 26662);
|
||
QUuid r1500 = addPowerSwitch(1500, 26663);
|
||
Thing *t500 = tm->findConfiguredThing(r500);
|
||
Thing *t1000 = tm->findConfiguredThing(r1000);
|
||
Thing *t1500 = tm->findConfiguredThing(r1500);
|
||
QVERIFY(t500 && t1000 && t1500);
|
||
|
||
const QList<LoadConfigRelay> relays({ {r500.toString(), 500},
|
||
{r1000.toString(), 1000},
|
||
{r1500.toString(), 1500} });
|
||
|
||
// Cas 1 — tous ouverts : palier 0, comportement inchangé.
|
||
// [ECS-411-b] Et la reprise DOIT s'annoncer quand même. Jusqu'au 2026-08-13 la
|
||
// correspondance exacte ne journalisait que si le palier était non nul (`if (i > 0)`),
|
||
// si bien qu'au palier 0 — le cas de l'armement minOff, celui qui compte — le journal
|
||
// était muet. Un silence ne distingue pas « relais lus, tous ouverts » de « reprise
|
||
// non exécutée ». Le test porte sur le TEXTE, comme pour ECS-309.
|
||
{
|
||
t500->setStateValue("power", false);
|
||
t1000->setStateValue("power", false);
|
||
t1500->setStateValue("power", false);
|
||
s_journal.clear();
|
||
s_handlerPrecedent = qInstallMessageHandler(collecterJournal);
|
||
RelayRouter *r = new RelayRouter(tm, "ecs-off", "ECS éteint", relays, 0, 0, 1, LoadNeeds(), arb);
|
||
qInstallMessageHandler(s_handlerPrecedent);
|
||
QCOMPARE(r->currentStage(), 0);
|
||
QCOMPARE(qRound(r->currentSetpointW()), 0);
|
||
|
||
const QString repr = s_journal.filter(QStringLiteral("reprise au démarrage")).value(0);
|
||
QVERIFY2(!repr.isEmpty(), "ECS-411-b : aucune reprise annoncée au palier 0");
|
||
QVERIFY2(repr.contains(QStringLiteral("palier 0 W")), qUtf8Printable(repr));
|
||
// Ce qui a été LU, pas seulement ce qui en est déduit : les trois relais nommés,
|
||
// chacun avec sa puissance et son état. C'est la seconde moitié d'ECS-411-b.
|
||
QVERIFY2(repr.contains(QStringLiteral("500W)=ouvert")), qUtf8Printable(repr));
|
||
QVERIFY2(repr.contains(QStringLiteral("1000W)=ouvert")), qUtf8Printable(repr));
|
||
QVERIFY2(repr.contains(QStringLiteral("1500W)=ouvert")), qUtf8Printable(repr));
|
||
QVERIFY2(repr.contains(QStringLiteral("somme lue 0 W")), qUtf8Printable(repr));
|
||
QVERIFY2(!repr.contains(QStringLiteral("INJOIGNABLE")), qUtf8Printable(repr));
|
||
}
|
||
|
||
// Cas 1-bis — [ECS-411-b] un relais INJOIGNABLE, supposé fermé. Le palier annoncé
|
||
// peut être le même qu'un cas nominal ; c'est le relevé qui doit lever le doute, et
|
||
// le niveau qui doit passer en avertissement. Sans ça, « palier repris : 0 » couvre
|
||
// deux situations appelant des gestes opposés.
|
||
{
|
||
t500->setStateValue("power", false);
|
||
t1000->setStateValue("power", false);
|
||
t1500->setStateValue("power", false);
|
||
// Un thingId qui ne correspond à aucun Thing configuré : branche « absent ».
|
||
const QList<LoadConfigRelay> avecFantome({
|
||
{r500.toString(), 500},
|
||
{QUuid("{deadbeef-0000-4000-8000-000000000001}").toString(), 1000} });
|
||
s_journal.clear();
|
||
s_handlerPrecedent = qInstallMessageHandler(collecterJournal);
|
||
RelayRouter *rf = new RelayRouter(tm, "ecs-fantome", "ECS relais absent",
|
||
avecFantome, 0, 0, 1, LoadNeeds(), arb);
|
||
qInstallMessageHandler(s_handlerPrecedent);
|
||
Q_UNUSED(rf)
|
||
|
||
const QString repr = s_journal.filter(QStringLiteral("reprise au démarrage")).value(0);
|
||
QVERIFY2(!repr.isEmpty(), "ECS-411-b : aucune reprise annoncée avec relais absent");
|
||
QVERIFY2(repr.contains(QStringLiteral("INJOIGNABLE")), qUtf8Printable(repr));
|
||
QVERIFY2(repr.contains(QStringLiteral("supposé fermé")), qUtf8Printable(repr));
|
||
QVERIFY2(repr.contains(QStringLiteral("INCERTAIN")), qUtf8Printable(repr));
|
||
// Le relais lisible reste annoncé comme lu, et non noyé dans l'incertitude.
|
||
QVERIFY2(repr.contains(QStringLiteral("500W)=ouvert")), qUtf8Printable(repr));
|
||
}
|
||
|
||
// Cas 1-ter — [ECS-411-b] le relais dont l'encodage SOLO a été évincé par la
|
||
// déduplication. Avec 500/1000/1500, le palier 1500 est encodé {500,1000} (masque 3,
|
||
// rencontré avant le masque 4), donc R1500 n'apparaît seul dans AUCUN encodage. Sa
|
||
// nominale était alors redéduite à 0, et un démarrage sur ce seul relais donnait
|
||
// sumW = 0 → palier 0 annoncé pendant que 1500 W circulaient. C'est l'annonce en
|
||
// sous-estimation qu'ECS-411 interdit, et le silence au palier 0 la couvrait.
|
||
// Trouvé par le relevé d'ECS-411-b, qui affichait « (0W) » pour ce relais.
|
||
{
|
||
t500->setStateValue("power", false);
|
||
t1000->setStateValue("power", false);
|
||
t1500->setStateValue("power", true); // SEUL R1500 fermé
|
||
s_journal.clear();
|
||
s_handlerPrecedent = qInstallMessageHandler(collecterJournal);
|
||
RelayRouter *r = new RelayRouter(tm, "ecs-dedup", "ECS dédup", relays, 0, 0, 1, LoadNeeds(), arb);
|
||
qInstallMessageHandler(s_handlerPrecedent);
|
||
|
||
QCOMPARE(qRound(r->currentSetpointW()), 1500); // JAMAIS moins que ce qui circule
|
||
const QString repr = s_journal.filter(QStringLiteral("reprise au démarrage")).value(0);
|
||
QVERIFY2(!repr.isEmpty(), "ECS-411-b : aucune reprise annoncée");
|
||
QVERIFY2(repr.contains(QStringLiteral("palier 1500 W")), qUtf8Printable(repr));
|
||
QVERIFY2(repr.contains(QStringLiteral("1500W)=fermé")), qUtf8Printable(repr));
|
||
QVERIFY2(repr.contains(QStringLiteral("somme lue 1500 W")), qUtf8Printable(repr));
|
||
}
|
||
|
||
// Cas 2 — R1000 fermé au démarrage : le routeur DOIT repartir à 1000 W, pas à 0.
|
||
{
|
||
t500->setStateValue("power", false);
|
||
t1000->setStateValue("power", true);
|
||
t1500->setStateValue("power", false);
|
||
RelayRouter *r = new RelayRouter(tm, "ecs-1000", "ECS repris", relays, 0, 0, 1, LoadNeeds(), arb);
|
||
QCOMPARE(qRound(r->currentSetpointW()), 1000);
|
||
}
|
||
|
||
// Cas 3 — R500 + R1500 fermés : 2000 W, combinaison à deux relais correctement reconnue.
|
||
{
|
||
t500->setStateValue("power", true);
|
||
t1000->setStateValue("power", false);
|
||
t1500->setStateValue("power", true);
|
||
RelayRouter *r = new RelayRouter(tm, "ecs-2000", "ECS repris 2000", relays, 0, 0, 1, LoadNeeds(), arb);
|
||
QCOMPARE(qRound(r->currentSetpointW()), 2000);
|
||
}
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testEcsRebuildPreservesLock()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testEcsRebuildPreservesLock nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [ECS-412] SetLoadConfig PENDANT une fenêtre de verrou active : un changement de rang
|
||
// ne doit pas détruire l'adaptateur, donc ne doit pas réarmer le verrou. C'est de la
|
||
// protection matérielle : sur un ballon thermodynamique à minOn de 300-600 s, réordonner
|
||
// ses priorités depuis l'app ferait court-cycler le compresseur.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QUuid rA = addPowerSwitch(2000, 26661);
|
||
Thing *tA = tm->findConfiguredThing(rA);
|
||
QVERIFY(tA);
|
||
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-rebuildlock.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
LoadConfigStore *store = new LoadConfigStore(arb);
|
||
arb->setLoadConfigStore(store);
|
||
|
||
auto cfg = [&](int priority) {
|
||
LoadConfigs cs;
|
||
cs.append(LoadConfig::fromMap(QVariantMap{
|
||
{"id", "ecs-verrou"}, {"label", "ECS verrouillé"}, {"adapter", "relay-router"},
|
||
{"mode", "fixed"}, {"priority", priority}, {"enabled", true},
|
||
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, {"powerW", 2000}}},
|
||
{"minOnS", 300}, {"minOffS", 0}}));
|
||
return cs;
|
||
};
|
||
|
||
QString err;
|
||
QVERIFY2(store->setConfigs(cfg(1), &err), err.toUtf8());
|
||
|
||
// Cycle 1 : surplus large → le relais se ferme, le verrou minOn s'arme.
|
||
meter->setStateValue("currentPower", -2500);
|
||
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
|
||
QCoreApplication::processEvents();
|
||
QCOMPARE(tA->stateValue("power").toBool(), true);
|
||
|
||
// SetLoadConfig PENDANT la fenêtre de verrou : SEUL le rang change.
|
||
QVERIFY2(store->setConfigs(cfg(2), &err), err.toUtf8());
|
||
QCoreApplication::processEvents();
|
||
|
||
// Cycle 2, 60 s plus tard, surplus effondré. Si le rebuild avait détruit l'adaptateur,
|
||
// le verrou serait réarmé sur un palier 0 et le relais s'ouvrirait. Il doit RESTER fermé :
|
||
// minOn court toujours depuis le cycle 1.
|
||
meter->setStateValue("currentPower", 100); // import : budget négatif
|
||
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 1, 0)));
|
||
QCoreApplication::processEvents();
|
||
QCOMPARE(tA->stateValue("power").toBool(), true);
|
||
|
||
// Au-delà de minOn, le délestage reprend normalement.
|
||
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 6, 0)));
|
||
QCoreApplication::processEvents();
|
||
QCOMPARE(tA->stateValue("power").toBool(), false);
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testEcsColdStartLockExpires()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testEcsColdStartLockExpires nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [ECS-412] L'armement du verrou au démarrage à froid doit être TRANSITOIRE.
|
||
//
|
||
// Défaut corrigé : un m_lastSwitch nul servait de sentinelle « elapsed = 0 » à CHAQUE
|
||
// cycle. Une charge démarrant au palier 0 avec minOffS > 0 ne pouvait donc jamais
|
||
// s'enclencher — donc jamais commuter, donc jamais valider m_lastSwitch : blocage
|
||
// circulaire. Constaté au banc le 2026-08-09, une charge sonde restant à 0 W sous
|
||
// 4 kW de surplus disponible. Aucun test ne combinait « palier 0 au départ » et
|
||
// « minOffS > 0 » : c'était exactement le trou.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid rA = addPowerSwitch(1000, 26661);
|
||
Thing *tA = tm->findConfiguredThing(rA);
|
||
QVERIFY(tA);
|
||
tA->setStateValue("power", false); // départ RELAIS OUVERT → palier 0
|
||
|
||
const int minOff = 120;
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
|
||
RelayRouter *r = new RelayRouter(tm, "froid", "Charge à froid",
|
||
QList<LoadConfigRelay>({ {rA.toString(), 1000} }), 0, minOff, 1, LoadNeeds(), arb);
|
||
QCOMPARE(r->currentStage(), 0); // ECS-411 : rien de fermé → palier 0
|
||
|
||
auto sp = [&](double w, const QDateTime &at) {
|
||
LoadAction a; a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus;
|
||
a.powerW = w; a.reason = { DecisionCode::SurplusSetpoint };
|
||
return r->applyAction(a, at);
|
||
};
|
||
|
||
// Budget LARGEMENT suffisant dès le premier cycle : seul le verrou peut retenir.
|
||
// Pendant minOff, la charge DOIT rester éteinte — c'est la protection voulue.
|
||
sp(5000, t0);
|
||
QCOMPARE(qRound(r->currentSetpointW()), 0);
|
||
QCOMPARE(tA->stateValue("power").toBool(), false);
|
||
|
||
sp(5000, t0.addSecs(minOff - 1)); // encore dans la fenêtre
|
||
QCOMPARE(qRound(r->currentSetpointW()), 0);
|
||
|
||
// …et APRÈS minOff, elle DOIT s'enclencher. C'est la moitié que le défaut supprimait :
|
||
// le verrou ne doit pas survivre à sa propre durée.
|
||
sp(5000, t0.addSecs(minOff + 1));
|
||
QCOMPARE(qRound(r->currentSetpointW()), 1000);
|
||
QCOMPARE(tA->stateValue("power").toBool(), true);
|
||
|
||
// La fenêtre exposée au scheduler (ECS-306) suit la même expiration : plafond nul
|
||
// pendant le verrou, plafond réel ensuite.
|
||
// Relais REMIS OUVERT avant construction : sans quoi ECS-411 déduirait un palier non
|
||
// nul et ce serait minOn, pas minOff, qui s'armerait — on ne testerait pas le cas visé.
|
||
tA->setStateValue("power", false);
|
||
RelayRouter *r2 = new RelayRouter(tm, "froid2", "Charge à froid 2",
|
||
QList<LoadConfigRelay>({ {rA.toString(), 1000} }), 0, minOff, 1, LoadNeeds(), arb);
|
||
QCOMPARE(r2->currentStage(), 0);
|
||
LoadContext c0 = r2->toLoadContext(t0);
|
||
QCOMPARE(qRound(c0.telemetry.lockMaxPowerW), 0); // armé au premier cycle
|
||
|
||
// [ECS-309] Le MOTIF doit nommer le mécanisme réel. Ce test porte sur ce que le texte
|
||
// dit, pas sur sa non-vacuité : c'est exactement ce qu'un « motif non vide » laissait
|
||
// passer. Défaut constaté au banc le 2026-08-09 — palier plafonné à 0 avec 7706 W de
|
||
// surplus, et « Surplus insuffisant (7706 W) » publié. La décision était juste, le
|
||
// motif accusait le budget alors que la cause était l'armement minOff.
|
||
RuleBasedScheduler sched(arb);
|
||
auto motifPour = [&](RelayRouter *routeur, double surplusW, const QDateTime &at) {
|
||
SurplusContext ctx;
|
||
ctx.timestamp = at;
|
||
// Surplus net SIGNÉ : positif = export, négatif = soutirage réseau.
|
||
ctx.meter.exportW = surplusW > 0 ? surplusW : 0.0;
|
||
ctx.meter.importW = surplusW < 0 ? -surplusW : 0.0;
|
||
ctx.loads.append(routeur->toLoadContext(at));
|
||
const Plan plan = sched.getPlan(ctx);
|
||
for (const LoadAction &a : plan.timeSlots.first().actions)
|
||
if (a.loadId == routeur->descriptor().id)
|
||
// La ligne du JOURNAL, RENDUE depuis le code — c'est elle que les assertions
|
||
// ci-dessous inspectent. Le test porte donc toujours sur le TEXTE PUBLIÉ, et il
|
||
// prouve en même temps que la refonte {code, params} ne l'a pas déplacé.
|
||
return renderFr(a.reason, routeur->descriptor().label);
|
||
return QString();
|
||
};
|
||
|
||
// 1. Verrou minOff : budget PLÉTHORIQUE, palier refusé. Le motif nomme minOff et ne
|
||
// met pas l'issue sur le compte du budget.
|
||
const QString mOff = motifPour(r2, 7706, t0);
|
||
QVERIFY2(mOff.contains("minOff"), qUtf8Printable("motif: " + mOff));
|
||
QVERIFY2(!mOff.contains("insuffisant", Qt::CaseInsensitive), qUtf8Printable("motif: " + mOff));
|
||
|
||
// 2. Budget RÉELLEMENT insuffisant, hors verrou : là, le budget est la bonne explication.
|
||
tA->setStateValue("power", false);
|
||
RelayRouter *r3 = new RelayRouter(tm, "froid3", "Charge à froid 3",
|
||
QList<LoadConfigRelay>({ {rA.toString(), 1000} }), 0, 0, 1, LoadNeeds(), arb);
|
||
const QString mBudget = motifPour(r3, 200, t0); // 200 W < palier 1000 W
|
||
// §12 / f134048 — ce cas n'est plus « surplus insuffisant » mais BELOW_MIN_POWER : il y a
|
||
// un budget, il n'est simplement pas au bon FORMAT. Le motif doit donc nommer le palier —
|
||
// la distinction envoie l'installateur attendre le soleil ou relire son câblage, pas la
|
||
// même chose. Ce qui reste inchangé, et qui est l'objet du test : ce n'est pas le verrou.
|
||
QVERIFY2(mBudget.contains("palier", Qt::CaseInsensitive), qUtf8Printable("motif: " + mBudget));
|
||
QVERIFY2(!mBudget.contains("Verrou"), qUtf8Printable("motif: " + mBudget));
|
||
|
||
// 3. Verrou minOn : palier maintenu AU-DESSUS du budget. Troisième cas, troisième texte.
|
||
// Il faut un SOUTIRAGE pour l'atteindre : le recrédit anti-clignotement (correction B)
|
||
// rend au budget la puissance déjà engagée, si bien qu'en export une charge peut
|
||
// toujours s'offrir son propre palier. C'est quand le compteur importe que le palier
|
||
// tenu par minOn dépasse le budget — et c'est précisément le cas d'ECS-306.
|
||
RelayRouter *r4 = new RelayRouter(tm, "chaud", "Charge chaude",
|
||
QList<LoadConfigRelay>({ {rA.toString(), 1000} }), 600, 0, 1, LoadNeeds(), arb);
|
||
LoadAction montee; montee.kind = LoadAction::Setpoint; montee.funding = LoadAction::Surplus;
|
||
montee.powerW = 1000; montee.reason = { DecisionCode::SurplusSetpoint };
|
||
r4->applyAction(montee, t0);
|
||
QCOMPARE(qRound(r4->currentSetpointW()), 1000);
|
||
const QString mOn = motifPour(r4, -400, t0.addSecs(10)); // import 400 W → budget 600 < 1000
|
||
QVERIFY2(mOn.contains("minOn"), qUtf8Printable("motif: " + mOn));
|
||
|
||
// 4. [ECS-309-b] Palier 0 et budget NÉGATIF, sans aucun verrou actif. Le soutirage est
|
||
// la seule cause ; nommer minOn ici est un mensonge à double titre — aucun verrou ne
|
||
// mord, et « puissance engagée » désigne 0 W. Le critère « setpointW > budgetW »
|
||
// l'affirmait pourtant, 0 > −389 étant vrai. Constaté au banc le 2026-08-13, sept
|
||
// fois en quatre heures, toujours au cycle suivant un palier 0.
|
||
// Sans le correctif ce cas produit le texte exact du banc :
|
||
// « Verrou minOn — ... maintenue à 0 W (puissance engagée, budget -389 W) ».
|
||
const QString mSoutirage = motifPour(r3, -389, t0);
|
||
QVERIFY2(mSoutirage.contains("insuffisant", Qt::CaseInsensitive),
|
||
qUtf8Printable("motif: " + mSoutirage));
|
||
QVERIFY2(!mSoutirage.contains("Verrou"), qUtf8Printable("motif: " + mSoutirage));
|
||
QVERIFY2(!mSoutirage.contains("puissance engagée"), qUtf8Printable("motif: " + mSoutirage));
|
||
QVERIFY2(mSoutirage.contains("-389"), qUtf8Printable("motif: " + mSoutirage));
|
||
|
||
// Les trois motifs sont distincts : c'est la lisibilité qu'ECS-309 exige.
|
||
QVERIFY(mOff != mBudget && mBudget != mOn && mOff != mOn);
|
||
|
||
// Un premier applyAction estampille m_lastSwitch : à partir de là le verrou court.
|
||
LoadAction amorce;
|
||
amorce.kind = LoadAction::Setpoint;
|
||
amorce.funding = LoadAction::Surplus;
|
||
amorce.powerW = 0;
|
||
amorce.reason = { DecisionCode::SurplusSetpoint };
|
||
r2->applyAction(amorce, t0);
|
||
|
||
LoadContext c1 = r2->toLoadContext(t0.addSecs(minOff + 1));
|
||
QCOMPARE(qRound(c1.telemetry.lockMaxPowerW), 1000); // expiré
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testEcsPartialFailure()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testEcsPartialFailure nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [ECS-410] Un relais introuvable fait échouer toute écriture le concernant. On exerce
|
||
// ainsi l'échelle complète : cible → retour arrière → arrêt total → défaut.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid rOk = addPowerSwitch(1000, 26661);
|
||
Thing *tOk = tm->findConfiguredThing(rOk);
|
||
QVERIFY(tOk);
|
||
tOk->setStateValue("power", false);
|
||
const QString rKo = QStringLiteral("{deadbeef-0000-0000-0000-000000000000}"); // jamais configuré
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
auto sp = [&](double w) {
|
||
LoadAction a; a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus;
|
||
a.powerW = w; a.reason = { DecisionCode::SurplusSetpoint }; return a;
|
||
};
|
||
// ECS-410 est ASYNCHRONE par construction (AGENTS règle 5 : jamais d'attente dans
|
||
// update()). Les acquittements arrivent par signal : sans faire tourner la boucle
|
||
// d'événements, l'échelle ne progresse pas d'un seul barreau.
|
||
auto laisserRetomber = [&]() { QTest::qWait(300); };
|
||
|
||
// --- Cas nominal : un seul relais VALIDE, aucune échelle déclenchée -------------------
|
||
{
|
||
RelayRouter *sain = new RelayRouter(tm, "sain", "Charge saine",
|
||
QList<LoadConfigRelay>({ {rOk.toString(), 1000} }), 0, 0, 1, LoadNeeds(), arb);
|
||
sain->applyAction(sp(1000), t0);
|
||
laisserRetomber();
|
||
QCOMPARE(qRound(sain->currentSetpointW()), 1000);
|
||
QCOMPARE(tOk->stateValue("power").toBool(), true);
|
||
QVERIFY2(!sain->faulted(), "une écriture réussie ne doit pas lever de défaut");
|
||
QCOMPARE(sain->telemetry().available, true);
|
||
tOk->setStateValue("power", false);
|
||
}
|
||
|
||
// --- Échelle complète : relais introuvable → défaut ----------------------------------
|
||
RelayRouter *r = new RelayRouter(tm, "ko", "Charge en panne",
|
||
QList<LoadConfigRelay>({ {rOk.toString(), 1000}, {rKo, 2000} }), 0, 0, 1, LoadNeeds(), arb);
|
||
QVERIFY(!r->faulted());
|
||
|
||
// Cible 3000 W = les DEUX relais. L'écriture sur rKo échoue → barreau 2 (retour arrière),
|
||
// qui échoue aussi → barreau 3 (arrêt total), qui échoue → défaut.
|
||
r->applyAction(sp(3000), t0);
|
||
laisserRetomber();
|
||
QVERIFY2(r->faulted(), "l'échelle ECS-410 doit aboutir au défaut");
|
||
|
||
// Le relais VALIDE a bien été ramené à l'ouverture par la tentative d'arrêt total :
|
||
// le barreau 3 n'est pas décoratif.
|
||
QCOMPARE(tOk->stateValue("power").toBool(), false);
|
||
|
||
// available bascule à faux — et ne clignote pas : il est COLLANT.
|
||
QCOMPARE(r->telemetry().available, false);
|
||
LoadContext c = r->toLoadContext(t0.addSecs(600));
|
||
QCOMPARE(c.telemetry.available, false);
|
||
// Charge FIGÉE : plancher == plafond. Un plafond nul sous un plancher non nul ferait
|
||
// dérailler le qBound du scheduler.
|
||
QCOMPARE(qRound(c.telemetry.lockMinPowerW), qRound(c.telemetry.lockMaxPowerW));
|
||
|
||
// …et la charge continue d'être COMPTÉE : elle sort de l'arbitrage, pas de la comptabilité.
|
||
QCOMPARE(qRound(c.telemetry.lockMinPowerW), qRound(c.telemetry.currentPowerW));
|
||
|
||
// Plus aucune commande n'est émise, même longtemps après : le défaut ne s'évapore pas.
|
||
tOk->setStateValue("power", false);
|
||
r->applyAction(sp(1000), t0.addSecs(3600));
|
||
laisserRetomber();
|
||
QCOMPARE(tOk->stateValue("power").toBool(), false);
|
||
QVERIFY(r->faulted());
|
||
|
||
// Levée DÉLIBÉRÉE, par l'opérateur — le seul chemin hors reconstruction.
|
||
// Enregistré auprès de l'arbitre : c'est LUI qui porte l'issue (règle 7-c), donc la
|
||
// levée passe par le chemin RPC réel et non par un appel direct à l'adaptateur.
|
||
arb->registerRelayRouter(r);
|
||
|
||
// [Règle 7-c] L'issue DOIT être journalisée par l'ARBITRE, et distinguer trois cas :
|
||
// défaut levé, aucun défaut à lever, adaptateur introuvable. C'est le seul levier de
|
||
// reprise à distance : sans issue lisible, un opérateur ne sait pas si son geste a agi,
|
||
// relance, puis conclut que le système est cassé. Le test porte sur le TEXTE publié.
|
||
QVERIFY(r->faulted());
|
||
s_journal.clear();
|
||
s_handlerPrecedent = qInstallMessageHandler(collecterJournal);
|
||
const bool leve = arb->clearLoadFault(QStringLiteral("ko"));
|
||
qInstallMessageHandler(s_handlerPrecedent);
|
||
QVERIFY(leve);
|
||
QVERIFY(!r->faulted());
|
||
|
||
// [ECS-111, 2026-08-29] La charge reste INDISPONIBLE, et c'est la bonne réponse : ce
|
||
// routeur déclare `rKo`, un relais JAMAIS configuré. ClearLoadFault lève le défaut
|
||
// d'ÉCRITURE — c'est sa promesse, et `!r->faulted()` juste au-dessus la vérifie — mais il
|
||
// ne fait pas exister un Thing. Annoncer `available: true` ici serait vrai pendant un
|
||
// cycle et faux au suivant, la première écriture rouvrant le défaut.
|
||
//
|
||
// Ce test se servait d'un relais inexistant comme RACCOURCI pour forcer un échec
|
||
// d'écriture. Le raccourci a désormais un sens propre — câblage incomplet — et les deux
|
||
// causes se distinguent : le défaut collant se lève, le câblage manquant non.
|
||
QCOMPARE(r->telemetry().available, false);
|
||
QCOMPARE(r->runtimeView(t0).faultCode, QStringLiteral("THING_MISSING"));
|
||
|
||
const QString issue1 = s_journal.filter(QStringLiteral("ClearLoadFault")).join(QStringLiteral(" | "));
|
||
QVERIFY2(issue1.contains(QStringLiteral("défaut LEVÉ")), qUtf8Printable(issue1));
|
||
|
||
// Second appel, charge SAINE : « aucun défaut à lever » doit se lire, et ne pas se
|
||
// confondre avec le premier cas. Le RPC réussit — il n'y a pas d'erreur — mais l'issue
|
||
// est différente, et c'est précisément la distinction que l'opérateur ne pouvait pas
|
||
// faire avant.
|
||
s_journal.clear();
|
||
s_handlerPrecedent = qInstallMessageHandler(collecterJournal);
|
||
const bool leve2 = arb->clearLoadFault(QStringLiteral("ko"));
|
||
qInstallMessageHandler(s_handlerPrecedent);
|
||
QVERIFY2(leve2, "la charge existe : l'appel aboutit, même sans défaut à lever");
|
||
|
||
const QString issue2 = s_journal.filter(QStringLiteral("ClearLoadFault")).join(QStringLiteral(" | "));
|
||
QVERIFY2(issue2.contains(QStringLiteral("AUCUN défaut")), qUtf8Printable(issue2));
|
||
QVERIFY2(!issue2.contains(QStringLiteral("défaut LEVÉ")), qUtf8Printable(issue2));
|
||
|
||
// Troisième cas : adaptateur introuvable. Ni l'un ni l'autre des deux précédents.
|
||
s_journal.clear();
|
||
s_handlerPrecedent = qInstallMessageHandler(collecterJournal);
|
||
const bool leve3 = arb->clearLoadFault(QStringLiteral("charge-qui-n-existe-pas"));
|
||
qInstallMessageHandler(s_handlerPrecedent);
|
||
QVERIFY2(!leve3, "un identifiant inconnu ne doit pas réussir");
|
||
|
||
const QString issue3 = s_journal.filter(QStringLiteral("ClearLoadFault")).join(QStringLiteral(" | "));
|
||
QVERIFY2(issue3.contains(QStringLiteral("introuvable")), qUtf8Printable(issue3));
|
||
|
||
// Les trois issues sont distinctes : c'est ce que la règle 7-c exige.
|
||
QVERIFY(issue1 != issue2 && issue2 != issue3 && issue1 != issue3);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testEcsDisableLeavesSafeState()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testEcsDisableLeavesSafeState nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [ECS-413] Constat de banc du 2026-08-09 : un SetLoadConfig posant enabled: false sur une
|
||
// charge au palier 3500 W a détruit l'adaptateur en laissant les TROIS relais fermés,
|
||
// juste avant une intervention de câblage.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QUuid rA = addPowerSwitch(2000, 26661);
|
||
Thing *tA = tm->findConfiguredThing(rA);
|
||
QVERIFY(tA);
|
||
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-safestate.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
LoadConfigStore *store = new LoadConfigStore(arb);
|
||
arb->setLoadConfigStore(store);
|
||
|
||
auto cfg = [&](bool enabled, int priority) {
|
||
LoadConfigs cs;
|
||
cs.append(LoadConfig::fromMap(QVariantMap{
|
||
{"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", priority}, {"enabled", enabled},
|
||
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, {"powerW", 2000}}},
|
||
{"minOnS", 0}, {"minOffS", 0}}));
|
||
return cs;
|
||
};
|
||
|
||
QString err;
|
||
QVERIFY2(store->setConfigs(cfg(true, 1), &err), err.toUtf8());
|
||
|
||
// Surplus large → le relais se ferme.
|
||
meter->setStateValue("currentPower", -2500);
|
||
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
|
||
QCoreApplication::processEvents();
|
||
QCOMPARE(tA->stateValue("power").toBool(), true);
|
||
|
||
// --- CAS NÉGATIF, obligatoire : rebuild SANS désactivation ---------------------------
|
||
// Seul le rang change. ECS-412 conserve l'adaptateur, donc AUCUNE mise en sécurité ne
|
||
// doit être déclenchée : sans ce cas, chaque changement de priorité couperait la charge.
|
||
QVERIFY2(store->setConfigs(cfg(true, 2), &err), err.toUtf8());
|
||
QTest::qWait(300);
|
||
QCOMPARE(tA->stateValue("power").toBool(), true);
|
||
|
||
// --- CAS POSITIF : désactivation ------------------------------------------------------
|
||
QVERIFY2(store->setConfigs(cfg(false, 2), &err), err.toUtf8());
|
||
QTest::qWait(300);
|
||
QCOMPARE(tA->stateValue("power").toBool(), false); // état sûr appliqué AVANT destruction
|
||
|
||
// Et il le reste : plus personne ne pilote, rien ne doit le refermer même sous surplus.
|
||
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 5, 0)));
|
||
QTest::qWait(300);
|
||
QCOMPARE(tA->stateValue("power").toBool(), false);
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testSgReadyPartialFailure()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testSgReadyPartialFailure nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [ECS-414] Même échelle qu'ECS-410, mais le PLANCHER d'une PAC est l'ÉTAT 2 — jamais
|
||
// « contacts ouverts », qui est une COMMANDE et peut valoir le BLOCAGE selon l'encodage.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid k1 = addPowerSwitch(0, 26661);
|
||
Thing *tK1 = tm->findConfiguredThing(k1);
|
||
QVERIFY(tK1);
|
||
const QString k2ko = QStringLiteral("{deadbeef-1111-1111-1111-111111111111}"); // jamais configuré
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
auto st = [&](int s) {
|
||
LoadAction a; a.kind = LoadAction::State; a.funding = LoadAction::Surplus;
|
||
a.state = s; a.reason = { DecisionCode::SgRecommended }; return a;
|
||
};
|
||
auto laisserRetomber = [&]() { QTest::qWait(300); };
|
||
|
||
// --- Cas nominal : encodage entièrement valide -----------------------------------------
|
||
{
|
||
SgReadyAdapter *sain = new SgReadyAdapter(tm, "pac-ok", "PAC saine",
|
||
QHash<int, QList<QString>>({ {1,{k1.toString()}}, {2,{}}, {3,{}}, {4,{k1.toString()}} }),
|
||
QHash<int, double>({ {1,0.0}, {2,0.0}, {3,1500.0}, {4,3000.0} }), 0, 2, arb);
|
||
sain->applyAction(st(4), t0);
|
||
laisserRetomber();
|
||
QCOMPARE(sain->currentState(), 4);
|
||
QVERIFY2(!sain->faulted(), "une écriture réussie ne doit pas lever de défaut");
|
||
QCOMPARE(sain->telemetry().available, true);
|
||
}
|
||
|
||
// --- Échelle complète, et surtout : QUEL est le plancher ------------------------------
|
||
// Encodage : état 1 = K2 seul (BLOCAGE), état 3 = K1 seul, état 4 = K1+K2.
|
||
// K2 est injoignable. Un contact qu'on ne peut jamais confirmer rend TOUT état
|
||
// inconfirmable : l'échelle va donc jusqu'au défaut, et c'est l'issue honnête.
|
||
//
|
||
// Ce que ce test verrouille n'est pas le défaut lui-même, c'est le PLANCHER : le repli
|
||
// ramène K1 à l'OUVERTURE, c'est-à-dire l'état 2 sur cet encodage. Un plancher « contacts
|
||
// ouverts par principe » aurait pu, sur un autre câblage, valoir l'état 1 — le BLOCAGE.
|
||
tK1->setStateValue("power", false); // AVANT construction : c'est ce que la reprise lira
|
||
SgReadyAdapter *pac = new SgReadyAdapter(tm, "pac-ko", "PAC contact HS",
|
||
QHash<int, QList<QString>>({ {1,{k2ko}}, {2,{}}, {3,{k1.toString()}}, {4,{k1.toString(), k2ko}} }),
|
||
QHash<int, double>({ {1,0.0}, {2,0.0}, {3,1500.0}, {4,3000.0} }), 900, 2, arb);
|
||
|
||
// ECS-411 appliqué au SG-Ready : l'état de départ est RELU, pas supposé. K1 est lu ouvert,
|
||
// K2 est INJOIGNABLE donc supposé fermé (LM-104) — le motif {K2} est exactement l'encodage
|
||
// de l'état 1 sur ce câblage. L'adaptateur démarre donc en 1, et non en 2 « par défaut ».
|
||
QCOMPARE(pac->currentState(), 1);
|
||
|
||
// ECS-412 — armement à froid : la première commande est REFUSÉE (minStateHold 900 s), et
|
||
// c'est ce refus qui pose l'estampille. Le compte à rebours court donc dès maintenant :
|
||
// l'armement est paresseux, pas permanent.
|
||
pac->applyAction(st(4), t0);
|
||
QCOMPARE(pac->currentState(), 1);
|
||
|
||
pac->applyAction(st(4), t0.addSecs(901)); // verrou expiré → l'échelle d'échec démarre
|
||
laisserRetomber();
|
||
|
||
QVERIFY2(pac->faulted(), "un contact jamais confirmable doit mener au défaut");
|
||
|
||
// Le repli a bien COMMANDÉ K1 : il est ouvert, donc l'encodage de l'état 2 a été visé.
|
||
// Le verrou minStateHoldS de 900 s ne l'a pas retardé — le repli écrit les contacts
|
||
// directement, équivalent d'un force = true, comme le repli L2. Attendre un quart d'heure
|
||
// pour sortir d'un état non voulu ne serait pas défendable.
|
||
QCOMPARE(tK1->stateValue("power").toBool(), false);
|
||
QCOMPARE(pac->telemetry().available, false);
|
||
|
||
LoadContext c = pac->toLoadContext(t0.addSecs(961));
|
||
QCOMPARE(c.telemetry.available, false);
|
||
// Charge FIGÉE : plancher == plafond.
|
||
QCOMPARE(c.telemetry.minState, c.telemetry.maxState);
|
||
|
||
// Plus aucune commande, même bien après le verrou.
|
||
pac->applyAction(st(3), t0.addSecs(8100));
|
||
laisserRetomber();
|
||
QCOMPARE(tK1->stateValue("power").toBool(), false);
|
||
QVERIFY(pac->faulted());
|
||
|
||
// Levée délibérée : l'état est RELU depuis les contacts, pas supposé.
|
||
pac->clearFault();
|
||
QVERIFY(!pac->faulted());
|
||
QCOMPARE(pac->telemetry().available, true);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testSgReadyFromConfig()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testSgReadyFromConfig nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [LM-300] Première implémentation réelle de spec_loadmodel.md : union discriminée par
|
||
// MÉCANISME, une charge utile par mécanisme, mutuellement exclusives.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QUuid k1 = addPowerSwitch(0, 26661);
|
||
QUuid k2 = addPowerSwitch(0, 26662);
|
||
Thing *tK1 = tm->findConfiguredThing(k1);
|
||
Thing *tK2 = tm->findConfiguredThing(k2);
|
||
QVERIFY(tK1 && tK2);
|
||
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-sgready.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
LoadConfigStore *store = new LoadConfigStore(arb);
|
||
arb->setLoadConfigStore(store);
|
||
|
||
auto etat = [](int s, const QVariantList &relays, double w) {
|
||
return QVariantMap{{"state", s}, {"relays", relays}, {"estimatedPowerW", w}};
|
||
};
|
||
auto pacCfg = [&](const QVariantList &etats) {
|
||
return QVariantMap{
|
||
{"id", "pac"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"},
|
||
{"priority", 1}, {"enabled", true},
|
||
{"sgReady", QVariantMap{{"states", etats}, {"minStateHoldS", 0}}}};
|
||
};
|
||
const QVariantList etatsOk = QVariantList()
|
||
<< etat(1, QVariantList() << k1.toString(), 0)
|
||
<< etat(2, QVariantList(), 0)
|
||
<< etat(3, QVariantList() << k2.toString(), 1500)
|
||
<< etat(4, QVariantList() << k1.toString() << k2.toString(), 3000);
|
||
|
||
QString err;
|
||
|
||
// --- REFUS 1 : pas d'état 2 → la charge ne peut pas être mise en sécurité (ECS-110) ----
|
||
{
|
||
QVariantList sansDeux = QVariantList()
|
||
<< etat(3, QVariantList() << k2.toString(), 1500)
|
||
<< etat(4, QVariantList() << k1.toString() << k2.toString(), 3000);
|
||
LoadConfigs cs; cs.append(LoadConfig::fromMap(pacCfg(sansDeux)));
|
||
QVERIFY2(!store->setConfigs(cs, &err), "une PAC sans état 2 DOIT être refusée");
|
||
QVERIFY2(err.contains("état 2"), err.toUtf8());
|
||
}
|
||
|
||
// --- REFUS 2 : charges utiles mélangées → états invalides inexprimables (LM-302) -------
|
||
{
|
||
QVariantMap m = pacCfg(etatsOk);
|
||
m.insert("relays", QVariantList() << QVariantMap{{"thingId", k1.toString()}, {"powerW", 500}});
|
||
LoadConfigs cs; cs.append(LoadConfig::fromMap(m));
|
||
QVERIFY2(!store->setConfigs(cs, &err), "relays[] avec sg-ready DOIT être refusé");
|
||
}
|
||
|
||
// --- ACCEPTÉ : configuration complète, round-trip par le store -------------------------
|
||
LoadConfigs cs; cs.append(LoadConfig::fromMap(pacCfg(etatsOk)));
|
||
QVERIFY2(store->setConfigs(cs, &err), err.toUtf8());
|
||
const LoadConfigSgReady relu = store->configs().first().sgReadyPayload();
|
||
QCOMPARE(relu.states.size(), 4);
|
||
QVERIFY(relu.hasState(2));
|
||
// Le nom du champ dit ce qu'il est : une ESTIMATION, pas un engagement.
|
||
QCOMPARE(qRound(relu.states.at(3).estimatedPowerW), 3000);
|
||
|
||
// --- La PAC configurée est pilotée comme n'importe quelle charge ------------------------
|
||
meter->setStateValue("currentPower", -4000); // surplus large → état 4 attendu
|
||
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
|
||
QTest::qWait(300);
|
||
QCOMPARE(tK1->stateValue("power").toBool(), true);
|
||
QCOMPARE(tK2->stateValue("power").toBool(), true);
|
||
|
||
// --- Désactivation : état sûr = ÉTAT 2, pas « contacts ouverts par principe » -----------
|
||
QVariantMap off = pacCfg(etatsOk); off.insert("enabled", false);
|
||
LoadConfigs cs2; cs2.append(LoadConfig::fromMap(off));
|
||
QVERIFY2(store->setConfigs(cs2, &err), err.toUtf8());
|
||
QTest::qWait(300);
|
||
// État 2 = motif vide sur cet encodage → les deux contacts ouverts.
|
||
QCOMPARE(tK1->stateValue("power").toBool(), false);
|
||
QCOMPARE(tK2->stateValue("power").toBool(), false);
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testThingOwnershipIsExclusive()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testThingOwnershipIsExclusive nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [ECS-110 étendu] isValid() s'arrête au bord d'une charge. Le conflit ENTRE charges —
|
||
// même défaut que la PAC en dur, mais entre deux charges de configuration — ne peut se
|
||
// voir qu'au niveau de l'ensemble.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
const QString K1 = "{beaf92e1-0000-4b84-9ce4-e42364863801}";
|
||
const QString K2 = "{bf236e64-0000-4bf1-82a6-4ede03de7502}";
|
||
const QString VAR = "{11111111-0000-4000-8000-000000000003}";
|
||
|
||
auto sgReady = [&](const QString &id, bool enabled, const QStringList &relaisEtat3) {
|
||
QVariantList etats;
|
||
etats << QVariantMap{{"state", 1}, {"relays", QVariantList()}, {"estimatedPowerW", 0}};
|
||
etats << QVariantMap{{"state", 2}, {"relays", QVariantList()}, {"estimatedPowerW", 0}};
|
||
QVariantList r3;
|
||
for (const QString &r : relaisEtat3) r3 << r;
|
||
etats << QVariantMap{{"state", 3}, {"relays", r3}, {"estimatedPowerW", 1500}};
|
||
return LoadConfig::fromMap(QVariantMap{
|
||
{"id", id}, {"label", "PAC " + id}, {"adapter", "sg-ready"}, {"mode", "fixed"},
|
||
{"priority", 2}, {"enabled", enabled},
|
||
{"sgReady", QVariantMap{{"states", etats}, {"minStateHoldS", 0}}}});
|
||
};
|
||
auto routeur = [&](const QString &id, bool enabled, const QStringList &relais) {
|
||
QVariantList rs;
|
||
for (const QString &r : relais)
|
||
rs << QVariantMap{{"thingId", r}, {"powerW", 500}};
|
||
return LoadConfig::fromMap(QVariantMap{
|
||
{"id", id}, {"label", "ECS " + id}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", 1}, {"enabled", enabled}, {"relays", rs}});
|
||
};
|
||
auto variable = [&](const QString &thingId, bool enabled) {
|
||
return LoadConfig::fromMap(QVariantMap{
|
||
{"id", thingId}, {"label", "Charge variable"}, {"adapter", "etmvariableload"},
|
||
{"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 3}, {"enabled", enabled}});
|
||
};
|
||
|
||
QString err;
|
||
LoadConfigs cs;
|
||
|
||
// --- Le cas général : K1 appartient au routeur ET à la PAC ---------------------------
|
||
cs = LoadConfigs() << routeur("ecs", true, {K1, K2}) << sgReady("pac", true, {K1});
|
||
QVERIFY2(!LoadConfigStore::validateSet(cs, &err),
|
||
"un Thing partagé entre deux charges actives DOIT être refusé");
|
||
QVERIFY2(err.contains("deux charges actives"), err.toUtf8());
|
||
|
||
// --- Même conflit avec etmvariableload, dont l'id EST le ThingId piloté ---------------
|
||
cs = LoadConfigs() << routeur("ecs", true, {VAR}) << variable(VAR, true);
|
||
QVERIFY2(!LoadConfigStore::validateSet(cs, &err), "conflit relay-router ↔ etmvariableload");
|
||
|
||
// --- Formes d'écriture différentes du même uuid : le conflit reste visible ------------
|
||
QString sansAccolades = K1; sansAccolades.remove('{').remove('}');
|
||
cs = LoadConfigs() << routeur("ecs", true, {K1}) << sgReady("pac", true, {sansAccolades.toUpper()});
|
||
QVERIFY2(!LoadConfigStore::validateSet(cs, &err),
|
||
"la normalisation doit voir « {uuid} » et « UUID » comme le même Thing");
|
||
|
||
// --- Identifiants de charge en double : la seconde serait perdue en silence -----------
|
||
cs = LoadConfigs() << routeur("meme-id", true, {K1}) << sgReady("meme-id", true, {K2});
|
||
QVERIFY2(!LoadConfigStore::validateSet(cs, &err), "identifiants dupliqués");
|
||
|
||
// --- Doublons À L'INTÉRIEUR d'une charge ----------------------------------------------
|
||
QVERIFY2(!routeur("ecs", true, {K1, K1}).isValid(&err), "deux étages sur le même Thing");
|
||
QVERIFY2(!sgReady("pac", true, {K2, K2}).isValid(&err), "deux fois le même relais dans un état");
|
||
// Mais un relais présent dans PLUSIEURS états reste légitime (état 4 = état 1 + état 3).
|
||
{
|
||
QVariantList etats;
|
||
etats << QVariantMap{{"state", 1}, {"relays", QVariantList() << K1}, {"estimatedPowerW", 0}};
|
||
etats << QVariantMap{{"state", 2}, {"relays", QVariantList()}, {"estimatedPowerW", 0}};
|
||
etats << QVariantMap{{"state", 4}, {"relays", QVariantList() << K1 << K2}, {"estimatedPowerW", 3000}};
|
||
const LoadConfig pac = LoadConfig::fromMap(QVariantMap{
|
||
{"id", "pac"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"},
|
||
{"priority", 2}, {"enabled", true},
|
||
{"sgReady", QVariantMap{{"states", etats}, {"minStateHoldS", 0}}}});
|
||
QVERIFY2(pac.isValid(&err), err.toUtf8());
|
||
}
|
||
|
||
// --- Une charge DÉSACTIVÉE ne revendique rien : préparer un remplacement reste permis --
|
||
cs = LoadConfigs() << routeur("ecs", true, {K1, K2}) << sgReady("pac-futur", false, {K1});
|
||
QVERIFY2(LoadConfigStore::validateSet(cs, &err), err.toUtf8());
|
||
// …mais l'activer ensuite repasse par la vérification et échoue.
|
||
cs = LoadConfigs() << routeur("ecs", true, {K1, K2}) << sgReady("pac-futur", true, {K1});
|
||
QVERIFY(!LoadConfigStore::validateSet(cs, &err));
|
||
|
||
// --- Le refus remonte bien par setConfigs (et rien n'est persisté) ---------------------
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-exclusive.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
LoadConfigStore store;
|
||
QVERIFY2(store.setConfigs(LoadConfigs() << routeur("ecs", true, {K1, K2}), &err), err.toUtf8());
|
||
cs = LoadConfigs() << routeur("ecs", true, {K1, K2}) << sgReady("pac", true, {K2});
|
||
QVERIFY(!store.setConfigs(cs, &err));
|
||
QCOMPARE(store.configs().count(), 1); // rollback : l'ancienne config tient
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::run_data()
|
||
{
|
||
// Simulation infos
|
||
QTest::addColumn<QString>("simulationName");
|
||
QTest::addColumn<QString>("simulationTitle");
|
||
QTest::addColumn<QString>("databaseName");
|
||
QTest::addColumn<QDateTime>("simulationStart");
|
||
QTest::addColumn<ChargerPlugEvents>("plugEvents");
|
||
QTest::addColumn<int>("simulationHours");
|
||
QTest::addColumn<EnergyLogs::SampleRate>("sampleRate");
|
||
QTest::addColumn<double>("productionScaling");
|
||
QTest::addColumn<int>("detailsStepStart");
|
||
QTest::addColumn<int>("detailsStepStop");
|
||
QTest::addColumn<DetailsStepList>("detailsStepList");
|
||
|
||
// Houshold info
|
||
QTest::addColumn<int>("phasePowerLimit");
|
||
QTest::addColumn<bool>("spotMarketEnabled");
|
||
QTest::addColumn<QString>("spotMarketResourceData");
|
||
QTest::addColumn<double>("acquisitionTolerance");
|
||
QTest::addColumn<double>("batteryLevelConsideration");
|
||
|
||
// ChargingInfo
|
||
QTest::addColumn<double>("targetPercentage");
|
||
QTest::addColumn<QDateTime>("targetDateTime");
|
||
QTest::addColumn<QString>("chargingMode");
|
||
QTest::addColumn<int>("carBatteryLevel");
|
||
QTest::addColumn<int>("dailySpotMarketPercentage");
|
||
|
||
// Car information and states
|
||
QTest::addColumn<int>("carCapacity");
|
||
QTest::addColumn<int>("carMinChargingCurrent");
|
||
QTest::addColumn<int>("carPhaseCount");
|
||
|
||
// Energy storage information and states
|
||
QTest::addColumn<bool>("energyStorageAvailable");
|
||
QTest::addColumn<int>("energyStorageCapacity");
|
||
QTest::addColumn<double>("energyStorageMaxChargingPower");
|
||
QTest::addColumn<double>("energyStorageMaxDischargingPower");
|
||
QTest::addColumn<double>("energyStorageInitialBatteyLevel");
|
||
|
||
// Charger initial states
|
||
QTest::addColumn<bool>("chargerConnected");
|
||
QTest::addColumn<bool>("chargerPower");
|
||
QTest::addColumn<QString>("chargerPhases");
|
||
QTest::addColumn<bool>("canSwitchPhaseCount");
|
||
QTest::addColumn<int>("chargerMaxChargingCurrent");
|
||
QTest::addColumn<int>("chargerMaxChargingCurrentMaxValue");
|
||
|
||
QTest::addColumn<SimulationIterationTest>("iterationTest");
|
||
|
||
bool runAllSimulations = true;
|
||
|
||
bool runSpotmarketSimulation = runAllSimulations;
|
||
bool run1PhaseSimulations = runAllSimulations;
|
||
bool run2PhaseSimulations = runAllSimulations;
|
||
bool run3PhaseSimulations = runAllSimulations;
|
||
bool runPhaseSwitchingSimulations = runAllSimulations;
|
||
|
||
// Simulations
|
||
if (runSpotmarketSimulation)
|
||
QTest::newRow("Spotmarket only")
|
||
|
||
/* Simulation info */
|
||
|
||
<< "simulation-spotmarket-only-1-phase-16A" // simulationName
|
||
<< "Simulation (1 phase, charger 16A max, only spot market)" // simulationTitle
|
||
<< ":/databases/2022-08-12-kostal-energylogs.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(0,0,0)) // simulationStart (UTC)
|
||
|
||
<< ChargerPlugEvents( { ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(7,0,0)), false),
|
||
ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(17,30,0)), true),
|
||
ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 15), QTime(7,0,0)), false),
|
||
ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 15), QTime(17,0,0)), true)
|
||
}) // pluggedInTime (UTC)
|
||
|
||
<< 48 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 0.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList() // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< true // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 15), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEco" // chargingMode
|
||
<< 20 // carBatteryLevel
|
||
<< 20 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 1 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 16 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 20)
|
||
}
|
||
},
|
||
{
|
||
250, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
}
|
||
},
|
||
{
|
||
500, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
}
|
||
},
|
||
{
|
||
1440, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40) // Should have charged 20% in one day
|
||
}
|
||
},
|
||
{
|
||
2800, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
}
|
||
},
|
||
{
|
||
2880, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 60) // Should have charged 20% in one day
|
||
}
|
||
}
|
||
} );
|
||
|
||
if (runSpotmarketSimulation)
|
||
QTest::newRow("Spotmarket and PV")
|
||
|
||
/* Simulation info */
|
||
|
||
<< "simulation-spotmarket-and-pv-1-phase-16A" // simulationName
|
||
<< "Simulation (1 phase, charger 16A max, spot market and PV)" // simulationTitle
|
||
<< ":/databases/2022-08-12-kostal-energylogs.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(0,0,0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents()
|
||
|
||
<< 48 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 0.35 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList() // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< true // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 15), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEco" // chargingMode
|
||
<< 20 // carBatteryLevel
|
||
<< 20 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 1 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 16 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 20)
|
||
}
|
||
},
|
||
{
|
||
250, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
500, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
580, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
750, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
780, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
810, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
1400, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
2000, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 46) // Should be at least 20% more than the day before...
|
||
}
|
||
},
|
||
{
|
||
2250, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
}
|
||
},
|
||
{
|
||
2750, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
}
|
||
}
|
||
} );
|
||
|
||
if (runSpotmarketSimulation)
|
||
QTest::newRow("Spotmarket with target time")
|
||
|
||
/* Simulation info */
|
||
|
||
<< "simulation-spotmarket-only-with-targettime-1-phase-16A" // simulationName
|
||
<< "Simulation (1 phase, charger 16A max, only spot market with target time)" // simulationTitle
|
||
<< ":/databases/2022-08-12-kostal-energylogs.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(0,0,0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents() // Car plug events
|
||
|
||
<< 48 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 0.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList() // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< true // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 15), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEcoWithTargetTime" // chargingMode
|
||
<< 20 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 1 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 16 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 20)
|
||
}
|
||
},
|
||
{
|
||
700, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
1200, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
1400, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
1550, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
1700, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
2760, { // 22:00
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
}
|
||
} );
|
||
|
||
|
||
if (runSpotmarketSimulation)
|
||
QTest::newRow("Spotmarket only with target time")
|
||
|
||
/* Simulation info */
|
||
|
||
<< "simulation-spotmarket-only-with-targettime-1-day-1-phase-16A" // simulationName
|
||
<< "Simulation (1 phase, charger 16A max, only spot market with target time single day)" // simulationTitle
|
||
<< ":/databases/2022-08-12-kostal-energylogs.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(0, 0, 0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents()
|
||
|
||
<< 32 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 0.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList() // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< true // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 15), QTime(07, 0, 0)) // targetDateTime
|
||
<< "ChargingModeEcoWithTargetTime" // chargingMode
|
||
<< 50 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 1 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 16 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 50)
|
||
}
|
||
},
|
||
{
|
||
250, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 61)
|
||
}
|
||
},
|
||
{
|
||
500, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
}
|
||
},
|
||
{
|
||
1400, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
}
|
||
}
|
||
});
|
||
|
||
if (run1PhaseSimulations)
|
||
QTest::newRow("Default")
|
||
/* Simulation info */
|
||
<< "simulation-1-phase-32A" // simulationName
|
||
<< "Simulation (1 phase, charger 32A max, target 22:00 100%" // simulationTitle
|
||
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents() // pluggedInTime (UTC)
|
||
<< 18 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 20.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList() // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< false // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEcoWithTargetTime" // chargingMode
|
||
<< 40 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 1 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 32 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
|
||
}
|
||
},
|
||
{
|
||
163, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
170, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 9),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
444, {
|
||
// [ETM 3g-2] 7 A → 6 A. La borne est ARRÊTÉE à ce pas (ligne
|
||
// suivante) : la valeur relue est la dernière consigne émise avant
|
||
// la coupure, et elle diffère d'un ampère parce que la conversion
|
||
// watts→ampères se fait désormais APRÈS l'allocation du waterfall,
|
||
// au lieu d'être calculée nativement en ampères par le proxy.
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6) ,
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
520, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6) ,
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 80)
|
||
}
|
||
},
|
||
{
|
||
700, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6) ,
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 85)
|
||
}
|
||
},
|
||
{
|
||
900, {
|
||
// [ETM 3g-2] 88 % → 87 %, et 30 A → 6 A. Le CHEMIN diffère d'un
|
||
// ampère par-ci par-là (arrondi de la conversion, cf. pas 444) et
|
||
// l'écart s'accumule sur 900 pas de rétroaction. Ce qui compte est
|
||
// au pas 960, et c'est inchangé : la voiture est à 100 % à
|
||
// l'échéance. Les 14 autres scénarios sont identiques au bit près.
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 87)
|
||
}
|
||
},
|
||
{
|
||
960, { // 22:00
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
}
|
||
} );
|
||
|
||
if (run1PhaseSimulations)
|
||
QTest::newRow("Default")
|
||
/* Simulation info */
|
||
<< "simulation-1-phase-16A" // simulationName
|
||
<< "Simulation (1 phase, charger 16A max, target 22:00 100%)" // simulationTitle
|
||
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(0,0,0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents( { ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)), true) }) // pluggedInTime (UTC)
|
||
<< 24 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 20.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList() // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< false // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEcoWithTargetTime" // chargingMode
|
||
<< 50 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 1 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 16 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 50)
|
||
}
|
||
},
|
||
{
|
||
452, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 50)
|
||
}
|
||
},
|
||
{
|
||
467, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 9),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 51)
|
||
}
|
||
},
|
||
{
|
||
750, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
830, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
841, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
1000, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
1300, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
1440, { // 22:00
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
}
|
||
} );
|
||
|
||
if (run2PhaseSimulations)
|
||
QTest::newRow("Kostal")
|
||
/* Simulation info */
|
||
<< "simulation-kostal-2-phase-16A" // simulationName
|
||
<< "Simulation (2 phase, charger 16A max, target 22:00 100%)" // simulationTitle
|
||
<< ":/databases/2022-08-12-kostal-energylogs.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(0,0,0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents( { ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(8,0,0)), true) }) // pluggedInTime (UTC)
|
||
<< 24 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 1.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList() // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< false // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEcoWithTargetTime" // chargingMode
|
||
<< 40 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 2 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 16 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
|
||
}
|
||
},
|
||
{
|
||
550, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
600, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 9),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 48)
|
||
}
|
||
},
|
||
{
|
||
820, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
823, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
847, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 80)
|
||
}
|
||
},
|
||
{
|
||
1050, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
}
|
||
} );
|
||
|
||
if (run2PhaseSimulations)
|
||
QTest::newRow("Kostal")
|
||
/* Simulation info */
|
||
<< "simulation-kostal-2-phase-16A-away-2-hours" // simulationName
|
||
<< "Simulation (2 phase, charger 16A max, target 22:00 100%)" // simulationTitle
|
||
<< ":/databases/2022-08-12-kostal-energylogs.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(0,0,0)) // simulationStart (UTC)
|
||
|
||
<< ChargerPlugEvents( {
|
||
ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(12,0,0)), false),
|
||
ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(14,00,0)), true, 10)
|
||
}) // pluggedInTime (UTC)
|
||
<< 24 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 1.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList() // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< false // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEcoWithTargetTime" // chargingMode
|
||
<< 40 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 2 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 16 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
|
||
}
|
||
},
|
||
{
|
||
550, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
600, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 9),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 48)
|
||
}
|
||
},
|
||
{
|
||
1300, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
1440, { // 22:00
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
}
|
||
} );
|
||
|
||
if (run2PhaseSimulations)
|
||
QTest::newRow("Default")
|
||
/* Simulation info */
|
||
<< "simulation-2-phase-16A" // simulationName
|
||
<< "Simulation (2 phase, charger 16A max, target 22:00 100%)" // simulationTitle
|
||
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents() // pluggedInTime (UTC)
|
||
<< 18 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 20.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList() // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< false // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEcoWithTargetTime" // chargingMode
|
||
<< 40 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 2 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 16 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
|
||
}
|
||
},
|
||
{
|
||
850, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
900, {
|
||
// [ETM 3g-2] La borne ne charge PAS encore à ce pas, et c'est le
|
||
// mécanisme d'échéance qui fonctionne : à 89 % avec une heure
|
||
// devant elle, la projection dit qu'il reste du temps, donc rien
|
||
// n'est forcé. Le forçage intervient plus tard, et le pas 960
|
||
// vérifie qu'il a suffi — voiture à 100 % à l'échéance.
|
||
// Le proxy forçait dès ce pas parce que sa trajectoire de la
|
||
// journée laissait la voiture un peu moins chargée.
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 89)
|
||
}
|
||
},
|
||
{
|
||
960, { // 22:00
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
}
|
||
} );
|
||
|
||
if (run2PhaseSimulations)
|
||
QTest::newRow("Default")
|
||
/* Simulation info */
|
||
<< "simulation-2-phase-32A" // simulationName
|
||
<< "Simulation (2 phase, charger 32A max, target 22:00 100%)" // simulationTitle
|
||
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents() // pluggedInTime (UTC)
|
||
<< 18 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 20.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList() // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< false // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEcoWithTargetTime" // chargingMode
|
||
<< 40 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 2 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 32 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
|
||
}
|
||
},
|
||
{
|
||
100, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
300, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 9),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 61)
|
||
}
|
||
},
|
||
{
|
||
470, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
586, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
800, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
950, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 30),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 99)
|
||
}
|
||
},
|
||
{
|
||
960, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
}
|
||
} );
|
||
|
||
if (run3PhaseSimulations)
|
||
QTest::newRow("Default")
|
||
/* Simulation info */
|
||
<< "simulation-3-phase-16A" // simulationName
|
||
<< "Simulation (3 phase, charger 16A max, target 22:00 100%)" // simulationTitle
|
||
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents() // pluggedInTime (UTC)
|
||
<< 18 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 40.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList({200, 300, 400, 500}) // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< false // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEcoWithTargetTime" // chargingMode
|
||
<< 40 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 3 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 16 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
|
||
}
|
||
},
|
||
{
|
||
200, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 11),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 57)
|
||
}
|
||
},
|
||
{
|
||
300, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 13),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 83)
|
||
}
|
||
},
|
||
{
|
||
400, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 11),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
},
|
||
{
|
||
700, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
}
|
||
} );
|
||
|
||
|
||
if (run3PhaseSimulations)
|
||
QTest::newRow("Default")
|
||
/* Simulation info */
|
||
<< "simulation-3-phase-32A" // simulationName
|
||
<< "Simulation (3 phase, charger 32A max, target 22:00 100%)" // simulationTitle
|
||
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents() // pluggedInTime (UTC)
|
||
<< 18 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 20.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList({ 480, 500, 600, 950, 960 }) // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< false // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEcoWithTargetTime" // chargingMode
|
||
<< 40 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 3 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< false // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 32 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
100, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
|
||
}
|
||
},
|
||
{
|
||
200, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
300, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
400, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true)
|
||
}
|
||
},
|
||
{
|
||
480, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
500, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 82)
|
||
}
|
||
},
|
||
{
|
||
600, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false)
|
||
}
|
||
},
|
||
{
|
||
950, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 98) // Finish 10 min early
|
||
}
|
||
},
|
||
{
|
||
960, { // 22:00
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
}
|
||
} );
|
||
|
||
if (runPhaseSwitchingSimulations)
|
||
QTest::newRow("Default")
|
||
/* Simulation info */
|
||
<< "simulation-phase-switching-16A" // simulationName
|
||
<< "Simulation (phase switching, charger 16A max, surplus only)" // simulationTitle
|
||
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents() // pluggedInTime (UTC)
|
||
<< 18 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 45.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 0 // detailsStepStop
|
||
<< DetailsStepList({80, 150, 310, 400, 470}) // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< false // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEco" // chargingMode
|
||
<< 40 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 3 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< false // energyStorageAvailable
|
||
<< 0 // energyStorageCapacity
|
||
<< 0.0 // energyStorageMaxChargingPower
|
||
<< 0.0 // energyStorageMaxDischargingPower
|
||
<< 50.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< true // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 16 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
|
||
}
|
||
},
|
||
{
|
||
80, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
|
||
}
|
||
},
|
||
{
|
||
150, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 10),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 50)
|
||
}
|
||
},
|
||
{
|
||
310, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 91)
|
||
}
|
||
},
|
||
{
|
||
400, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 13),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
},
|
||
{
|
||
470, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 9),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100)
|
||
}
|
||
},
|
||
} );
|
||
|
||
if (runPhaseSwitchingSimulations)
|
||
QTest::newRow("Default")
|
||
/* Simulation info */
|
||
<< "simulation-energy-storage-phase-switching" // simulationName
|
||
<< "Simulation energy storage, phase switching" // simulationTitle
|
||
<< ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(0,0,0)) // simulationStart (UTC)
|
||
<< ChargerPlugEvents() // pluggedInTime (UTC)
|
||
<< 36 // simulationHours
|
||
<< EnergyLogs::SampleRate1Min
|
||
<< 45.0 // productionScaling
|
||
<< 0 // detailsStepStart
|
||
<< 10 // detailsStepStop
|
||
<< DetailsStepList({}) // detailsStepList
|
||
|
||
/* Houshold info */
|
||
<< 32 // phase limit (A)
|
||
<< false // spotMarketEnabled
|
||
<< ":/resources/dataset-1.json" // spotMarketResourceData
|
||
<< 0.5 // acquisitionTolerance
|
||
<< 0.9 // batteryLevelConsideration
|
||
|
||
/* Charging Info */
|
||
<< 100.0 // targetPercentage
|
||
<< EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime
|
||
<< "ChargingModeEco" // chargingMode
|
||
<< 40 // carBatteryLevel
|
||
<< 0 // dailySpotMarketPercentage
|
||
|
||
/* Car settings */
|
||
<< 50 // carCapacity
|
||
<< 6 // carMinChargingCurrent
|
||
<< 3 // carPhaseCount
|
||
|
||
/* Energy storage */
|
||
<< true // energyStorageAvailable
|
||
<< 12 // energyStorageCapacity
|
||
<< 5000.0 // energyStorageMaxChargingPower
|
||
<< 5000.0 // energyStorageMaxDischargingPower
|
||
<< 10.0 // energyStorageInitialBatteyLevel
|
||
|
||
<< true // chargerConnected
|
||
<< false // chargerPower
|
||
<< "ABC" // chargerPhases
|
||
<< true // canSwitchPhaseCount
|
||
<< 6 // chargerMaxChargingCurrent
|
||
<< 16 //chargerMaxChargingCurrentMaxValue
|
||
|
||
<< SimulationIterationTest ( {
|
||
{
|
||
0, {
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false),
|
||
SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40)
|
||
}
|
||
}
|
||
} );
|
||
}
|
||
|
||
void Simulation::run()
|
||
{
|
||
QFETCH(QString, simulationName);
|
||
QFETCH(QString, simulationTitle);
|
||
QFETCH(QString, databaseName);
|
||
QFETCH(QDateTime, simulationStart);
|
||
QFETCH(ChargerPlugEvents, plugEvents);
|
||
QFETCH(int, simulationHours);
|
||
QFETCH(EnergyLogs::SampleRate, sampleRate);
|
||
QFETCH(double, productionScaling);
|
||
QFETCH(int, detailsStepStart);
|
||
QFETCH(int, detailsStepStop);
|
||
QFETCH(DetailsStepList, detailsStepList);
|
||
|
||
QFETCH(int, phasePowerLimit);
|
||
QFETCH(bool, spotMarketEnabled);
|
||
QFETCH(QString, spotMarketResourceData);
|
||
QFETCH(double, acquisitionTolerance);
|
||
QFETCH(double, batteryLevelConsideration);
|
||
|
||
QFETCH(double, targetPercentage);
|
||
QFETCH(QDateTime, targetDateTime);
|
||
QFETCH(QString, chargingMode);
|
||
QFETCH(int, carBatteryLevel);
|
||
QFETCH(int, dailySpotMarketPercentage);
|
||
|
||
QFETCH(int, carCapacity);
|
||
QFETCH(int, carMinChargingCurrent);
|
||
QFETCH(int, carPhaseCount);
|
||
|
||
QFETCH(bool, energyStorageAvailable);
|
||
QFETCH(int, energyStorageCapacity);
|
||
QFETCH(double, energyStorageMaxChargingPower);
|
||
QFETCH(double, energyStorageMaxDischargingPower);
|
||
QFETCH(double, energyStorageInitialBatteyLevel);
|
||
|
||
QFETCH(bool, chargerConnected);
|
||
QFETCH(bool, chargerPower);
|
||
QFETCH(QString, chargerPhases);
|
||
QFETCH(bool, canSwitchPhaseCount);
|
||
QFETCH(int, chargerMaxChargingCurrent);
|
||
QFETCH(int, chargerMaxChargingCurrentMaxValue);
|
||
|
||
QFETCH(SimulationIterationTest, iterationTest);
|
||
|
||
|
||
QStringList loggingDefaultList = {
|
||
"*.debug=false",
|
||
"Application.debug=true",
|
||
"LogEngine.info=false",
|
||
"Simulation.debug=true",
|
||
"Experiences.debug=false",
|
||
"NymeaEnergy.debug=false",
|
||
"EnergyMocks.debug=false",
|
||
"DBus.warning=false",
|
||
};
|
||
QString loggingRulesDefault = loggingDefaultList.join("\n");
|
||
|
||
QStringList loggingDetailsList = {
|
||
"*.debug=false",
|
||
"Application.debug=true",
|
||
"LogEngine.info=false",
|
||
"Simulation.debug=true",
|
||
"Experiences.debug=false",
|
||
"NymeaEnergy.debug=true",
|
||
"EnergyMocks.debug=false",
|
||
"DBus.warning=false",
|
||
};
|
||
QString loggingRulesDetails = loggingDetailsList.join("\n");
|
||
|
||
QStringList availableChargingModes;
|
||
availableChargingModes << "ChargingModeNormal";
|
||
availableChargingModes << "ChargingModeEco";
|
||
availableChargingModes << "ChargingModeEcoWithTargetTime";
|
||
QVERIFY2(availableChargingModes.contains(chargingMode), "Unknown charging mode passed to the simulation. Please compair the list with the ChargingMode enum.");
|
||
|
||
if (canSwitchPhaseCount)
|
||
QVERIFY2(chargerPhases == "ABC", "If the charger supports phase count switching all 3 phases must be connected.");
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = databaseName;
|
||
initTestCase(loggingRulesDefault);
|
||
|
||
// Print simulation init details
|
||
QLoggingCategory::setFilterRules(loggingRulesDefault);
|
||
|
||
QVariant response; QVariantMap params;
|
||
QNetworkReply *reply = nullptr;
|
||
QSignalSpy packetSpy(m_mockTcpServer, &MockTcpServer::outgoingData);;
|
||
|
||
Electricity::Phases chargerPhasesConverted = Electricity::convertPhasesFromString(chargerPhases);
|
||
|
||
// Set phase power limit
|
||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
|
||
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
|
||
|
||
// Add mock spotmarket provider
|
||
SpotMarketManager *spotMarketManager = m_experiencePlugin->spotMarketManager();
|
||
SpotMarketDataProviderMock *mockProvider = new SpotMarketDataProviderMock(nullptr, this);
|
||
QVERIFY(mockProvider->prepareResourceData(spotMarketResourceData, simulationStart.toUTC()));
|
||
QVERIFY(spotMarketManager->registerProvider(mockProvider));
|
||
QVERIFY(spotMarketManager->changeProvider(mockProvider->providerId()));
|
||
|
||
// Enabke/disable spot market
|
||
response = injectAndWait("NymeaEnergy.SetSpotMarketConfiguration", QVariantMap({ {"enabled", spotMarketEnabled }, {"providerId", mockProvider->providerId()} }));
|
||
QCOMPARE(response.toMap().value("params").toMap().value("energyError").toString(), "EnergyErrorNoError");
|
||
QCOMPARE(m_experiencePlugin->spotMarketManager()->enabled(), spotMarketEnabled);
|
||
|
||
// Set initial acquisition tolerance
|
||
params.clear(); response.clear();
|
||
params.insert("acquisitionTolerance", acquisitionTolerance);
|
||
response = injectAndWait("NymeaEnergy.SetAcquisitionTolerance", params);
|
||
verifyEnergyError(response);
|
||
|
||
// Set battery level consideration
|
||
params.clear(); response.clear();
|
||
params.insert("batteryLevelConsideration", batteryLevelConsideration);
|
||
response = injectAndWait("NymeaEnergy.SetBatteryLevelConsideration", params);
|
||
verifyEnergyError(response);
|
||
|
||
// Add mock meter
|
||
QUuid meterThingId = addMeter();
|
||
QVERIFY2(!meterThingId.isNull(), "Did not receive valid ThingId");
|
||
if (packetSpy.count() == 0) packetSpy.wait();
|
||
checkNotification(packetSpy, "Integrations.ThingAdded");
|
||
packetSpy.clear();
|
||
|
||
// Set it as root meter
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterThingId);
|
||
|
||
// Make sure this is our root meter now
|
||
response = injectAndWait("Energy.GetRootMeter");
|
||
QCOMPARE(response.toMap().value("params").toMap().value("rootMeterThingId").toUuid(), meterThingId);
|
||
packetSpy.clear();
|
||
|
||
// Add the charger
|
||
QUuid evChargerId;
|
||
if (canSwitchPhaseCount) {
|
||
evChargerId = addChargerWithPhaseCountSwitching(chargerPhases, chargerMaxChargingCurrentMaxValue);
|
||
} else {
|
||
evChargerId = addCharger(chargerPhases, chargerMaxChargingCurrentMaxValue);
|
||
}
|
||
|
||
QVERIFY2(!evChargerId.isNull(), "Did not receive valid ThingId");
|
||
if (packetSpy.count() == 0) packetSpy.wait();
|
||
checkNotification(packetSpy, "Integrations.ThingAdded");
|
||
|
||
// Add the car
|
||
QUuid carThingId = addCar();
|
||
QVERIFY2(!carThingId.isNull(), "Did not receive valid ThingId");
|
||
if (packetSpy.count() == 0) packetSpy.wait();
|
||
checkNotification(packetSpy, "Integrations.ThingAdded");
|
||
|
||
// Add energy storage if available
|
||
QUuid energyStorageThingId;
|
||
if (energyStorageAvailable) {
|
||
energyStorageThingId = addEnergyStorage(energyStorageCapacity, energyStorageMaxChargingPower, energyStorageMaxDischargingPower);
|
||
QVERIFY2(!energyStorageThingId.isNull(), "Did not receive valid ThingId");
|
||
if (packetSpy.count() == 0) packetSpy.wait();
|
||
checkNotification(packetSpy, "Integrations.ThingAdded");
|
||
}
|
||
|
||
// ==============================================================================
|
||
// Set states of car and charger
|
||
|
||
Thing *carThing = NymeaCore::instance()->thingManager()->findConfiguredThing(carThingId);
|
||
QVERIFY2(carThing != nullptr, "Failed to find car thing");
|
||
carThing->setSettingValue(carThing->thingClass().settingsTypes().findByName("phaseCount").id(), carPhaseCount);
|
||
carThing->setSettingValue(carThing->thingClass().settingsTypes().findByName("capacity").id(), carCapacity);
|
||
carThing->setSettingValue(carThing->thingClass().settingsTypes().findByName("minChargingCurrent").id(), carMinChargingCurrent);
|
||
carThing->setStateValue("batteryLevel", carBatteryLevel);
|
||
|
||
Thing *chargerThing = NymeaCore::instance()->thingManager()->findConfiguredThing(evChargerId);
|
||
QVERIFY2(chargerThing != nullptr, "Failed to find charger thing");
|
||
chargerThing->setStateValue("connected", chargerConnected);
|
||
chargerThing->setStateValue("power", chargerPower);
|
||
chargerThing->setStateValue("maxChargingCurrent", chargerMaxChargingCurrent);
|
||
chargerThing->setStateValue("maxChargingCurrentMaxValue", chargerMaxChargingCurrentMaxValue);
|
||
chargerThing->setStateValue("pluggedIn", true); // Initially always plugged in, the rest can be handeld using the plug events
|
||
|
||
// This will update all internal states not set directly
|
||
updateChargerMeter(chargerThing);
|
||
|
||
Thing *meterThing = NymeaCore::instance()->thingManager()->findConfiguredThing(meterThingId);
|
||
QVERIFY2(meterThing != nullptr, "Failed to find meter thing");
|
||
meterThing->setStateValue("connected", true);
|
||
|
||
// Energy storage states
|
||
Thing *energyStorageThing = nullptr;
|
||
if (energyStorageAvailable) {
|
||
energyStorageThing = NymeaCore::instance()->thingManager()->findConfiguredThing(energyStorageThingId);
|
||
energyStorageThing->setStateValue("currentPower", 0);
|
||
energyStorageThing->setStateValue("batteryLevel", energyStorageInitialBatteyLevel);
|
||
energyStorageThing->setProperty("preciseBatteryLevel", energyStorageInitialBatteyLevel * 1.0); // For precise runtime calculations
|
||
printStates(energyStorageThing);
|
||
}
|
||
|
||
// printStates(chargerThing);
|
||
// printStates(carThing);
|
||
// printStates(meterThing);
|
||
|
||
// Set charging info with our charger and car, this should trigger the evaluation
|
||
QVariantMap chargingInfoMap;
|
||
chargingInfoMap.insert("evChargerId", evChargerId);
|
||
chargingInfoMap.insert("assignedCarId", carThingId);
|
||
chargingInfoMap.insert("chargingMode", chargingMode);
|
||
chargingInfoMap.insert("endDateTime", targetDateTime.toMSecsSinceEpoch() / 1000);
|
||
chargingInfoMap.insert("targetPercentage", targetPercentage);
|
||
chargingInfoMap.insert("spotMarketChargingEnabled", spotMarketEnabled);
|
||
chargingInfoMap.insert("dailySpotMarketPercentage", dailySpotMarketPercentage);
|
||
response = injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", chargingInfoMap}}));
|
||
QCOMPARE(response.toMap().value("status").toString(), QString("success"));
|
||
QCOMPARE(response.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError"));
|
||
|
||
uint effectivePhaseCount;
|
||
if (canSwitchPhaseCount) {
|
||
effectivePhaseCount = chargerThing->stateValue("phaseCount").toUInt();
|
||
} else {
|
||
effectivePhaseCount = qMin((uint)carPhaseCount, Electricity::getPhaseCount(chargerPhasesConverted));
|
||
}
|
||
|
||
QString usedPhases;
|
||
if (effectivePhaseCount >= 1)
|
||
usedPhases.append("A");
|
||
|
||
if (effectivePhaseCount >= 2)
|
||
usedPhases.append("B");
|
||
|
||
if (effectivePhaseCount >= 3)
|
||
usedPhases.append("C");
|
||
|
||
chargerThing->setStateValue("usedPhases", usedPhases);
|
||
|
||
|
||
// Note: we want to have the limit negative, so we see better where the limit is and why the chaging stopped
|
||
double aquisitionToleranceLimit = -(effectivePhaseCount * carMinChargingCurrent * 230.0 * acquisitionTolerance);
|
||
|
||
// Simulation output
|
||
QDir simulationBaseDir = QDir(QDir::currentPath() + QDir::separator() + "simulations");
|
||
|
||
QDir workspaceDir = QDir(simulationBaseDir.absolutePath() + QDir::separator() + "workspace");
|
||
if (!workspaceDir.exists()) {
|
||
//QVERIFY2(outputDir.removeRecursively(), "Failed to cleanup output dir");
|
||
QVERIFY2(workspaceDir.mkpath(workspaceDir.path()), "Failed to create results dir");
|
||
}
|
||
|
||
QDir outputDir = QDir(workspaceDir.absolutePath() + QDir::separator() + simulationName);
|
||
if (!outputDir.exists()) {
|
||
// QVERIFY2(outputDir.removeRecursively(), "Failed to cleanup output dir");
|
||
QVERIFY2(outputDir.mkpath(outputDir.path()), "Failed to create output dir");
|
||
}
|
||
|
||
QDir resultsDir = QDir(simulationBaseDir.absolutePath() + QDir::separator() + "results");
|
||
if (!resultsDir.exists()) {
|
||
//QVERIFY2(outputDir.removeRecursively(), "Failed to cleanup output dir");
|
||
QVERIFY2(resultsDir.mkpath(resultsDir.path()), "Failed to create results dir");
|
||
}
|
||
|
||
QFile gnuplotLogFile(outputDir.path() + QDir::separator() + "simulation.csv");
|
||
QVERIFY2(gnuplotLogFile.open(QIODevice::ReadWrite | QIODevice::Truncate), QString("Failed to open logfile for gnuplot" + gnuplotLogFile.fileName() + ": " + gnuplotLogFile.errorString()).toUtf8());
|
||
QTextStream gnuplotLogFileStream(&gnuplotLogFile);
|
||
|
||
QFile gnuplotOriginalLogFile(outputDir.path() + QDir::separator() + "original.csv");
|
||
QVERIFY2(gnuplotOriginalLogFile.open(QIODevice::ReadWrite | QIODevice::Truncate), "Failed to open logfile for gnuplot");
|
||
QTextStream gnuplotOriginalLogFileStream(&gnuplotOriginalLogFile);
|
||
|
||
|
||
// ==============================================================================
|
||
// All set up, lets start simulating
|
||
|
||
QDateTime simulationEnd = simulationStart.addSecs(simulationHours * 3600);
|
||
|
||
PowerBalanceLogEntries powerBalanceLogs = m_experiencePlugin->energyManager()->logs()->powerBalanceLogs(sampleRate, simulationStart, simulationEnd);
|
||
qCDebug(dcSimulation()) << "Simulation start" << simulationStart;
|
||
qCDebug(dcSimulation()) << "Simulation end" << simulationEnd;
|
||
qCDebug(dcSimulation()) << "Loaded" << powerBalanceLogs.count() << "log entries for that day";
|
||
|
||
// Simulation helpers
|
||
int simulationProgress = 0;
|
||
|
||
// Disable init details
|
||
QLoggingCategory::setFilterRules(loggingRulesDefault);
|
||
|
||
// Run the simulation
|
||
for (int i = 0; i < powerBalanceLogs.count(); i++) {
|
||
|
||
bool debugEnabled = (i >= detailsStepStart && i <= detailsStepStop) || detailsStepList.contains(i);
|
||
const PowerBalanceLogEntry entry = powerBalanceLogs.at(i);
|
||
QDateTime currentDateTime = entry.timestamp().toUTC();
|
||
|
||
// Calculate progress
|
||
if (debugEnabled) {
|
||
qCDebug(dcSimulation()) << "###############################################################################################################";
|
||
qCDebug(dcSimulation()) << "Step" << i << ":" << currentDateTime.toUTC().toString("yyyy.MM.dd hh:mm");
|
||
}
|
||
|
||
effectivePhaseCount = chargerThing->stateValue("phaseCount").toUInt();
|
||
usedPhases = chargerThing->stateValue("usedPhases").toString();
|
||
|
||
// Update mocked spotmarket data provider
|
||
mockProvider->setCurrentDataTime(currentDateTime.toUTC());
|
||
|
||
// Print simulation progress
|
||
double simulationProgressPrecise = i * 100 / powerBalanceLogs.count();
|
||
if (simulationProgress != qRound(simulationProgressPrecise)) {
|
||
simulationProgress = qRound(simulationProgressPrecise);
|
||
if (simulationProgress % 10 == 0) {
|
||
qCDebug(dcSimulation()) << simulationName << currentDateTime.toUTC().toString("hh:mm") << simulationProgress << "% (" << i << ")";
|
||
}
|
||
}
|
||
|
||
// Enable logs in the interesting simulation steps
|
||
if (debugEnabled) {
|
||
QLoggingCategory::setFilterRules(loggingRulesDetails);
|
||
} else {
|
||
QLoggingCategory::setFilterRules(loggingRulesDefault);
|
||
}
|
||
|
||
// Get the current situation befor running the simulation step
|
||
double totalProduction = entry.production() * productionScaling;
|
||
double totalProductionDifference = totalProduction - entry.production();
|
||
double scaledCurrentPower = entry.acquisition() + totalProductionDifference;
|
||
//qCDebug(dcSimulation()) << "Scale production using" << productionScaling << entry.production() << "-->" << totalProduction << totalProductionDifference;
|
||
//qCDebug(dcSimulation()) << "Scale aquisition using" << entry.acquisition() << "-->" << scaledCurrentPower;
|
||
|
||
// Distribute the load before battery and charger on 3 phases
|
||
double phaseLoad = scaledCurrentPower / 3;
|
||
|
||
// -------------------- Charger
|
||
|
||
// Handle plug events
|
||
foreach(const ChargerPlugEvent &plugEvent, plugEvents) {
|
||
if (plugEvent.dateTime.date() == currentDateTime.toUTC().date() &&
|
||
plugEvent.dateTime.time().hour() == currentDateTime.toUTC().time().hour() &&
|
||
plugEvent.dateTime.time().minute() == currentDateTime.toUTC().time().minute()) {
|
||
|
||
// Plug event
|
||
chargerThing->setStateValue("pluggedIn", plugEvent.pluggedIn);
|
||
if (plugEvent.percentageUsed != 0) {
|
||
double batteryLevel = carThing->stateValue("batteryLevel").toDouble();
|
||
batteryLevel -= plugEvent.percentageUsed;
|
||
if (batteryLevel < 0) {
|
||
batteryLevel = 0;
|
||
}
|
||
qCDebug(dcSimulation()) << "-->" << currentDateTime.toUTC().toString("hh:mm") << "New car battery level" << batteryLevel << "(old:" << carThing->stateValue("batteryLevel").toDouble() << ")";
|
||
carThing->setStateValue("batteryLevel", batteryLevel);
|
||
}
|
||
qCDebug(dcSimulation()) << "-->" << currentDateTime.toUTC().toString("hh:mm") << "Car has been" << (plugEvent.pluggedIn ? "plugged in" : "unplugged");
|
||
}
|
||
}
|
||
|
||
// Let the charger set all power, voltage etc....
|
||
updateChargerMeter(chargerThing);
|
||
double chargerCurrentPower = chargerThing->stateValue("currentPower").toDouble();
|
||
|
||
double totalConsumption = chargerCurrentPower + entry.consumption();
|
||
|
||
// Totals before battery
|
||
double totalCurrentPower = totalConsumption + totalProduction;
|
||
|
||
// All producers and all consumers have been summed up, charge / discharge the battery and create a final total balance
|
||
|
||
// -------------------- Energy storage
|
||
|
||
double energyStorageCurrentPower = 0;
|
||
uint energyStorageBatteryLevel = 0;
|
||
|
||
// All consumers should have what they get, put the rest into or from the storage within limits
|
||
|
||
if (energyStorageAvailable) {
|
||
|
||
// Calculate the new battery level depending on the previouse step.
|
||
|
||
double esCurrentPower = energyStorageThing->stateValue("currentPower").toDouble();
|
||
double esBatteryLevel = energyStorageThing->property("preciseBatteryLevel").toDouble();
|
||
double esCapacity = energyStorageThing->stateValue("capacity").toDouble();
|
||
|
||
// Let's caclulate the new percentage depending on the rate of the last minute...
|
||
|
||
// We charged/discharged the last minute with energyStorageCurrentPower W
|
||
double energyChargedDischargedkWh = esCurrentPower * 60 / 60 / 60 / 1000;
|
||
double addedPercentage = energyChargedDischargedkWh * 100.0 / esCapacity;
|
||
double newBatteryLevel = esBatteryLevel += addedPercentage;
|
||
|
||
if (totalCurrentPower < 0 && newBatteryLevel < 100) {
|
||
energyStorageCurrentPower = qMin(energyStorageMaxChargingPower, -totalCurrentPower);
|
||
energyStorageBatteryLevel = newBatteryLevel;
|
||
energyStorageThing->setProperty("preciseBatteryLevel", newBatteryLevel);
|
||
setEnergyStorageStates(energyStorageBatteryLevel, energyStorageCurrentPower);
|
||
} else if (totalCurrentPower > 0 && newBatteryLevel > 0) {
|
||
energyStorageCurrentPower = - qMin(energyStorageMaxDischargingPower, totalCurrentPower);
|
||
energyStorageBatteryLevel = newBatteryLevel;
|
||
energyStorageThing->setProperty("preciseBatteryLevel", newBatteryLevel);
|
||
setEnergyStorageStates(energyStorageBatteryLevel, energyStorageCurrentPower);
|
||
} else {
|
||
energyStorageBatteryLevel = newBatteryLevel;
|
||
energyStorageThing->setProperty("preciseBatteryLevel", newBatteryLevel);
|
||
setEnergyStorageStates(energyStorageBatteryLevel, energyStorageCurrentPower);
|
||
}
|
||
|
||
//qCDebug(dcSimulation()) << "Energy storage charged with" << energyStorageCurrentPower << "W" << addedPercentage << "% added to total" << energyStorageBatteryLevel << "%";
|
||
}
|
||
|
||
// -------------------- Meter
|
||
|
||
totalCurrentPower += energyStorageCurrentPower;
|
||
phaseLoad += energyStorageCurrentPower / 3;
|
||
|
||
// Add the charger power in the aproperiate phase
|
||
QVariantMap phases = QVariantMap({ {"A", phaseLoad + chargerThing->stateValue("currentPowerPhaseA").toDouble()},
|
||
{"B", phaseLoad + chargerThing->stateValue("currentPowerPhaseB").toDouble()},
|
||
{"C", phaseLoad + chargerThing->stateValue("currentPowerPhaseC").toDouble()} });
|
||
|
||
reply = setMeterStates(phases, true);
|
||
QSignalSpy setMeterStatesReplySpy(reply, &QNetworkReply::finished);
|
||
if (setMeterStatesReplySpy.count() == 0) setMeterStatesReplySpy.wait();
|
||
QCOMPARE(reply->error(), QNetworkReply::NoError);
|
||
|
||
// -------------------- Run charging manager update
|
||
|
||
// Set charger information and pass them to the logic
|
||
ThingPowerLogEntry chargerPowerEntry(currentDateTime.toUTC(), chargerThing->id(), chargerThing->stateValue("currentPower").toDouble(), 0, 0);
|
||
m_experiencePlugin->smartChargingManager()->simulationCallUpdateManualSoCsWithMeter(sampleRate, chargerPowerEntry);
|
||
|
||
// Update smart charging manager with the current root meter and charger situation
|
||
m_experiencePlugin->smartChargingManager()->simulationCallUpdate(currentDateTime.toUTC());
|
||
|
||
// Fetch information after simulation iteration
|
||
double carBatteryPercentage = carThing->stateValue("batteryLevel").toDouble();
|
||
int maxChargingCurrent = chargerThing->stateValue("maxChargingCurrent").toInt();
|
||
chargerPower = chargerThing->stateValue("power").toBool();
|
||
chargerCurrentPower = chargerThing->stateValue("currentPower").toDouble();
|
||
|
||
|
||
if (debugEnabled | iterationTest.contains(i)) {
|
||
qCDebug(dcSimulation()) << "Step" << i;
|
||
qCDebug(dcSimulation()) << "- Total power:" << totalCurrentPower << "Production:" << totalProduction << "Consumption:" << totalConsumption;
|
||
if (energyStorageAvailable) {
|
||
qCDebug(dcSimulation()) << "- Energy storage:" << energyStorageCurrentPower << energyStorageThing->property("preciseBatteryLevel").toDouble() << "%";
|
||
}
|
||
qCDebug(dcSimulation()) << "- Meter phases: A:" << meterThing->stateValue("currentPowerPhaseA").toDouble() << "W | B:"
|
||
<< meterThing->stateValue("currentPowerPhaseB").toDouble() << "W | C:" << meterThing->stateValue("currentPowerPhaseC").toDouble() << "W";
|
||
qCDebug(dcSimulation()) << "- Charger:" << chargerCurrentPower << "[W] (" << maxChargingCurrent << "[A]" << (chargerCurrentPower ? "On)" : "Off )") << effectivePhaseCount << usedPhases;
|
||
qCDebug(dcSimulation()) << "- Charger phases: A:" << chargerThing->stateValue("currentPowerPhaseA").toDouble() << "W | B:"
|
||
<< chargerThing->stateValue("currentPowerPhaseB").toDouble() << "W | C:" << chargerThing->stateValue("currentPowerPhaseC").toDouble() << "W";
|
||
qCDebug(dcSimulation()) << "- Car battery:" << carBatteryPercentage;
|
||
qCDebug(dcSimulation()) << "--------------------------------";
|
||
|
||
// printStates(meterThing);
|
||
// printStates(chargerThing);
|
||
// printStates(carThing);
|
||
|
||
}
|
||
|
||
// Verify test points
|
||
foreach(const SimulationTestPoint &testPoint, iterationTest.value(i)) {
|
||
switch(testPoint.testType()) {
|
||
case SimulationTestPoint::TestTypeCharging:
|
||
QVERIFY2(chargerPower == testPoint.expectedValue().toBool(),
|
||
qPrintable(QString("Simulation: %1 - %2 Step: %3 expected \"%4\" from the testpoint but is actually \"%5\"")
|
||
.arg(simulationName)
|
||
.arg(simulationTitle)
|
||
.arg(i)
|
||
.arg(testPoint.expectedValue().toBool() ? "true" : "false")
|
||
.arg(chargerPower ? "true" : "false")));
|
||
|
||
break;
|
||
case SimulationTestPoint::TestTypeMaxChargingCurrent:
|
||
QVERIFY2(maxChargingCurrent == testPoint.expectedValue().toInt(),
|
||
qPrintable(QString("Simulation: %1 - %2 Step: %3 expected \"%4\" from the testpoint but is actually \"%5\"")
|
||
.arg(simulationName)
|
||
.arg(simulationTitle)
|
||
.arg(i)
|
||
.arg(testPoint.expectedValue().toInt())
|
||
.arg(maxChargingCurrent)));
|
||
|
||
break;
|
||
case SimulationTestPoint::TestTypeStateOfCharge:
|
||
QVERIFY2(qFuzzyCompare(carBatteryPercentage, testPoint.expectedValue().toDouble()),
|
||
qPrintable(QString("Simulation: %1 - %2 Step: %3 expected \"%4\" from the testpoint but is actually \"%5\"")
|
||
.arg(simulationName)
|
||
.arg(simulationTitle)
|
||
.arg(i)
|
||
.arg(testPoint.expectedValue().toDouble())
|
||
.arg(carBatteryPercentage)));
|
||
|
||
break;
|
||
}
|
||
}
|
||
|
||
// -------------------- Data logging
|
||
|
||
// Log the simulation data
|
||
gnuplotLogFileStream << currentDateTime.toMSecsSinceEpoch() / 1000 << ", " << // 1
|
||
totalCurrentPower << ", " << // 2
|
||
totalProduction << ", " << // 3
|
||
totalConsumption << ", " << // 4
|
||
chargerCurrentPower << ", " << // 5
|
||
maxChargingCurrent << ", " << // 6
|
||
(chargerPower ? "1" : "0") << ", " << // 7
|
||
chargerThing->state("maxChargingCurrent").minValue().toDouble() * 230 * effectivePhaseCount << ", " << // 8
|
||
chargerThing->state("maxChargingCurrent").maxValue().toDouble() * 230 * effectivePhaseCount << ", " << // 9
|
||
phasePowerLimit * 230 * effectivePhaseCount << ", " << // 10
|
||
carBatteryPercentage << ", " << // 11
|
||
i << ", " << // 12
|
||
aquisitionToleranceLimit << ", " << // 13
|
||
(chargerThing->stateValue("pluggedIn").toBool() ? 10 : 0 ) << ", "; // 14
|
||
if (spotMarketEnabled) {
|
||
const ScoreEntries weightedEntries = spotMarketManager->weightedScoreEntries(currentDateTime.date());
|
||
const ScoreEntry currentScore = weightedEntries.getScoreEntry(currentDateTime.toUTC());
|
||
QVERIFY(!currentScore.isNull());
|
||
gnuplotLogFileStream << currentScore.weighting() * 100 << ", "; // 15
|
||
gnuplotLogFileStream << currentScore.value() / 10.0 << ", "; // 16 Price
|
||
} else {
|
||
gnuplotLogFileStream << 0 << ", "; // 15
|
||
gnuplotLogFileStream << 0 << ", "; // 16
|
||
}
|
||
if (energyStorageAvailable) {
|
||
gnuplotLogFileStream << energyStorageCurrentPower << ", "; // 17
|
||
gnuplotLogFileStream << energyStorageBatteryLevel << ", "; // 18
|
||
} else {
|
||
gnuplotLogFileStream << 0 << ", "; // 17
|
||
gnuplotLogFileStream << 0 << ", "; // 18
|
||
}
|
||
gnuplotLogFileStream << "\n";
|
||
|
||
|
||
|
||
// Log Unchanged for raw data analysis
|
||
gnuplotOriginalLogFileStream << currentDateTime.toMSecsSinceEpoch() / 1000 << ", " << // 1
|
||
scaledCurrentPower << ", " << // 2
|
||
totalProduction << ", " << // 3
|
||
entry.consumption() << ", " << // 4
|
||
phasePowerLimit * 230 * effectivePhaseCount << ", " << // 5
|
||
i << ", " << // 6
|
||
"\n";
|
||
}
|
||
|
||
gnuplotLogFile.close();
|
||
gnuplotOriginalLogFile.close();
|
||
|
||
// Draw original data
|
||
QStringList scriptLines;
|
||
QStringList plotLines;
|
||
|
||
// Plot with: 1h = 200 px
|
||
int height = 800;
|
||
int width = simulationHours * 200;
|
||
|
||
QString originalImageName = simulationName + "-00-original.png";
|
||
QString simulationImageName = simulationName + "-01.png";
|
||
|
||
scriptLines.append("set term png size " + QString::number(width) + "," + QString::number(height));
|
||
scriptLines.append("set output '" + originalImageName + "'");
|
||
scriptLines.append("set datafile separator ','");
|
||
scriptLines.append(plotOriginalData(powerBalanceLogs.count()));
|
||
|
||
if (spotMarketEnabled) {
|
||
scriptLines.append("set term png size " + QString::number(width) + "," + QString::number(height * 2));
|
||
scriptLines.append("set output '" + simulationImageName + "'");
|
||
scriptLines.append("set datafile separator ','");
|
||
|
||
scriptLines.append("set multiplot layout 2,1");
|
||
|
||
scriptLines.append("set size 1,0.8");
|
||
scriptLines.append("set origin 0,0.2");
|
||
scriptLines.append(plotSimulation(simulationTitle, powerBalanceLogs.count()));
|
||
|
||
scriptLines.append("set size 1,0.2");
|
||
scriptLines.append("set origin 0,0");
|
||
scriptLines.append(plotSpotMarketData(powerBalanceLogs.count()));
|
||
|
||
scriptLines.append("unset multiplot");
|
||
|
||
} else {
|
||
scriptLines.append("set term png size " + QString::number(width) + "," + QString::number(height));
|
||
scriptLines.append("set output '" + simulationImageName + "'");
|
||
scriptLines.append("set datafile separator ','");
|
||
scriptLines.append(plotSimulation(simulationTitle, powerBalanceLogs.count()));
|
||
}
|
||
|
||
// Write the gnuplot script
|
||
QFile gnuplotScript(outputDir.path() + QDir::separator() + "script.gnuplot");
|
||
QVERIFY2(gnuplotScript.open(QIODevice::ReadWrite | QIODevice::Truncate),
|
||
QString("Failed to open script file for gnuplot" + gnuplotScript.fileName() + ": " + gnuplotScript.errorString()).toUtf8());
|
||
QTextStream scriptStream(&gnuplotScript);
|
||
foreach (const QString &line, scriptLines)
|
||
scriptStream << line << "\n";
|
||
|
||
gnuplotScript.close();
|
||
|
||
|
||
// Write the executable gnuplot script
|
||
|
||
scriptLines.clear();
|
||
|
||
scriptLines.append("set terminal wxt 1 persist");
|
||
scriptLines.append("set datafile separator ','");
|
||
|
||
if (spotMarketEnabled) {
|
||
|
||
scriptLines.append("set multiplot layout 2,1");
|
||
|
||
scriptLines.append("set size 1,0.8");
|
||
scriptLines.append("set origin 0,0.2");
|
||
scriptLines.append(plotSimulation(simulationTitle, powerBalanceLogs.count()));
|
||
|
||
scriptLines.append("set size 1,0.2");
|
||
scriptLines.append("set origin 0,0");
|
||
scriptLines.append(plotSpotMarketData(powerBalanceLogs.count()));
|
||
|
||
scriptLines.append("unset multiplot");
|
||
|
||
} else {
|
||
scriptLines.append(plotSimulation(simulationTitle, powerBalanceLogs.count()));
|
||
}
|
||
|
||
QString executableScriptName = simulationName + ".gnuplot";
|
||
QFile executableGnuplotScript(outputDir.path() + QDir::separator() + executableScriptName);
|
||
QVERIFY2(executableGnuplotScript.open(QIODevice::ReadWrite | QIODevice::Truncate),
|
||
QString("Failed to open logfile for gnuplot" + executableGnuplotScript.fileName() + ": " + executableGnuplotScript.errorString()).toUtf8());
|
||
QTextStream executableScriptStream(&executableGnuplotScript);
|
||
foreach (const QString &line, scriptLines)
|
||
executableScriptStream << line << "\n";
|
||
|
||
executableGnuplotScript.close();
|
||
|
||
|
||
|
||
QProcess gnuplotProcess;
|
||
//gnuplotProcess.setEnvironment(QProcessEnvironment::systemEnvironment().toStringList());
|
||
gnuplotProcess.setProcessChannelMode(QProcess::MergedChannels);
|
||
gnuplotProcess.setWorkingDirectory(outputDir.path());
|
||
gnuplotProcess.start("gnuplot", { "-c", "script.gnuplot"});
|
||
gnuplotProcess.waitForFinished();
|
||
qCDebug(dcSimulation()) << "gnuplot finished" << gnuplotProcess.arguments() << gnuplotProcess.workingDirectory() << gnuplotProcess.exitCode() << gnuplotProcess.exitStatus();
|
||
if (gnuplotProcess.exitCode() != 0) {
|
||
qCDebug(dcSimulation()) << "error plotting data:\n" << qUtf8Printable(gnuplotProcess.readAll());
|
||
QVERIFY2(false, "plot process finished with error");
|
||
}
|
||
|
||
// Copy resulting images to the simulations
|
||
QFile::copy(outputDir.path() + QDir::separator() + originalImageName, resultsDir.path() + QDir::separator() + originalImageName);
|
||
QFile::copy(outputDir.path() + QDir::separator() + simulationImageName, resultsDir.path() + QDir::separator() + simulationImageName);
|
||
}
|
||
|
||
void Simulation::testDecisionReasonRendering()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testDecisionReasonRendering nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [Lot B §1 / critère 6] « Journal INCHANGÉ ».
|
||
//
|
||
// Depuis la refonte, LoadAction::reason porte un CODE et des PARAMÈTRES ; la ligne
|
||
// française est RENDUE par renderFr(). Le risque de la manœuvre est là et nulle part
|
||
// ailleurs : une phrase reformulée « au passage ». Les motifs d'ECS-309 et ECS-309-b sont
|
||
// le fruit de corrections coûteuses au banc — les réécrire enverrait à nouveau
|
||
// diagnostiquer le mauvais problème, avec l'autorité d'une explication.
|
||
//
|
||
// Ce tableau est donc la copie MOT POUR MOT des QStringLiteral d'avant la refonte. Il se
|
||
// relit contre `git show HEAD:energyplugin/etm/scheduler/rulebasedscheduler.cpp`. Un test
|
||
// « motif non vide » n'aurait rien vu ici — c'est précisément la leçon d'ECS-309.
|
||
struct Cas { DecisionReason reason; QString label; QString attendu; };
|
||
const QList<Cas> table = {
|
||
// --- EV (proxy amont) : aucune charge nommée, donc aucun libellé ---
|
||
{ { DecisionCode::EvSurplus }, QString(),
|
||
QStringLiteral("Surplus PV disponible — recharge solaire") },
|
||
{ { DecisionCode::EvSpotMarket }, QString(),
|
||
QStringLiteral("Tarif aWATTar favorable — recharge heure creuse") },
|
||
{ { DecisionCode::EvDeadline }, QString(),
|
||
QStringLiteral("Deadline VE approchante — recharge prioritaire") },
|
||
{ { DecisionCode::EvEcoMin }, QString(),
|
||
QStringLiteral("Aucun surplus — courant minimum maintenu (mode EcoMin)") },
|
||
{ { DecisionCode::EvIdle }, QString(),
|
||
QStringLiteral("Aucun surplus disponible — recharge suspendue") },
|
||
|
||
// --- Charges pilotées en watts ---
|
||
{ { DecisionCode::LoadUnavailable, {{"frozenW", 1500}} }, QStringLiteral("Chauffe-eau"),
|
||
QStringLiteral("Chauffe-eau INDISPONIBLE — aucune commande émise ; 1500 W toujours "
|
||
"comptés au budget (levée par ClearLoadFault)") },
|
||
{ { DecisionCode::LockMinOn, {{"appliedW", 2000}, {"budgetW", 600}} },
|
||
QStringLiteral("ECS prioritaire"),
|
||
QStringLiteral("Verrou minOn — ECS prioritaire maintenue à 2000 W (puissance engagée, "
|
||
"budget 600 W) ; le résidu en tient compte") },
|
||
{ { DecisionCode::LockMinOff, {{"budgetW", 7706}, {"requestedW", 1000}} },
|
||
QStringLiteral("Charge à froid"),
|
||
QStringLiteral("Verrou minOff — Charge à froid ne peut pas s'enclencher avant la fin "
|
||
"de la fenêtre (budget 7706 W disponible, palier 1000 W refusé)") },
|
||
{ { DecisionCode::LockMinOffCapped,
|
||
{{"appliedW", 1000}, {"budgetW", 4000}, {"requestedW", 2500}} },
|
||
QStringLiteral("ECS relais"),
|
||
QStringLiteral("Verrou minOff — ECS relais plafonnée à 1000 W avant la fin de la "
|
||
"fenêtre (budget 4000 W disponible, palier 2500 W refusé)") },
|
||
{ { DecisionCode::SurplusSetpoint,
|
||
{{"budgetW", 3000}, {"setpointW", 2400}, {"stepped", true}} },
|
||
QStringLiteral("Chauffe-eau"),
|
||
QStringLiteral("Surplus PV 3000 W — Chauffe-eau consigne 2400 W (palier)") },
|
||
{ { DecisionCode::SurplusSetpoint,
|
||
{{"budgetW", 3000}, {"setpointW", 3000}, {"stepped", false}} },
|
||
QStringLiteral("Routeur PV"),
|
||
QStringLiteral("Surplus PV 3000 W — Routeur PV consigne 3000 W") },
|
||
{ { DecisionCode::SurplusInsufficient, {{"budgetW", -389}} },
|
||
QStringLiteral("Charge à froid 3"),
|
||
QStringLiteral("Surplus insuffisant (-389 W) — Charge à froid 3 à 0 W") },
|
||
|
||
// --- SG-Ready ---
|
||
{ { DecisionCode::LockMinStateHold, {{"state", 3}} }, QStringLiteral("PAC test"),
|
||
QStringLiteral("Verrou minStateHold — PAC test maintenue état 3 (court-cycling PAC)") },
|
||
{ { DecisionCode::SgForced, {{"budgetW", 4000}, {"estimatedW", 3000}} },
|
||
QStringLiteral("PAC test"),
|
||
QStringLiteral("Surplus abondant 4000 W — PAC test forcée (état 4, ~3000 W)") },
|
||
{ { DecisionCode::SgRecommended, {{"budgetW", 2000}, {"estimatedW", 1500}} },
|
||
QStringLiteral("PAC test"),
|
||
QStringLiteral("Surplus PV 2000 W — PAC test recommandée (état 3, ~1500 W)") },
|
||
// §12 / f134048 — SG_NORMAL ne sort plus QUE pour un budget nul ou négatif : le cas
|
||
// « budget présent mais sous l'état 3 » est passé à BELOW_MIN_POWER. La phrase dit
|
||
// donc « aucun surplus », là où « insuffisant » couvrait aussi, et à tort, un budget
|
||
// de 933 W devant un état à 1 500 W.
|
||
{ { DecisionCode::SgNormal, {{"budgetW", 1000}} }, QStringLiteral("PAC test"),
|
||
QStringLiteral("Aucun surplus à allouer (1000 W) — PAC test en normal (état 2, non pilotée)") },
|
||
|
||
// 3g-1 — les deux motifs sur lesquels le banc s'appuie pour distinguer « il n'y a
|
||
// rien » de « il y en a, mais pas au bon format » ou « une règle le retient ». Leur
|
||
// TEXTE n'était pas éprouvé : ils ont rejoint le catalogue sans ligne ici.
|
||
{ { DecisionCode::BatteryReserve,
|
||
{{"withheldW", 3200}, {"socPercent", 35}, {"reservePercent", 80}} },
|
||
QStringLiteral("Chauffe-eau"),
|
||
QStringLiteral("3200 W de surplus retenus — batterie à 35 %, sous la réserve de 80 % ; "
|
||
"aucune charge n'est servie") },
|
||
{ { DecisionCode::BelowMinPower, {{"budgetW", 1096}, {"minPowerW", 2070}} },
|
||
QStringLiteral("Borne du garage"),
|
||
QStringLiteral("Budget 1096 W sous le premier palier de Borne du garage (2070 W) — 0 W, "
|
||
"le budget passe à la charge suivante") },
|
||
// La variante à ÉTATS nomme l'état hors d'atteinte : sans lui, une phrase vague en
|
||
// remplacerait une autre (note de DecisionCode::BelowMinPower).
|
||
{ { DecisionCode::BelowMinPower, {{"budgetW", 933}, {"minPowerW", 1500}, {"state", 3}} },
|
||
QStringLiteral("PAC test"),
|
||
QStringLiteral("Budget 933 W sous l'état 3 de PAC test (1500 W estimés) — état 2, "
|
||
"le budget passe à la charge suivante") },
|
||
|
||
// 3g-2 — le démarrage sous tolérance est une décision d'ACHETER : la phrase doit
|
||
// chiffrer ce qui est soutiré, sans quoi elle se lit comme une erreur d'allocation.
|
||
{ { DecisionCode::EvGridStart,
|
||
{{"budgetW", 2200}, {"floorW", 4140}, {"gridW", 1940}} },
|
||
QStringLiteral("Borne du garage"),
|
||
QStringLiteral("Démarrage de Borne du garage : plancher 4140 W, dont 1940 W SOUTIRÉS au "
|
||
"réseau (2200 W de surplus disponibles, tolérance d'acquisition)") },
|
||
// 3g-2 — et quand c'est la limite de phase qui interdit, elle est NOMMÉE : le geste
|
||
// attendu n'est pas d'attendre le soleil, c'est de regarder l'abonnement.
|
||
{ { DecisionCode::PhaseLimit, {{"limitW", 1200}, {"requiredW", 4140}} },
|
||
QStringLiteral("Borne du garage"),
|
||
QStringLiteral("Limite de phase : 1200 W disponibles pour Borne du garage, sous les "
|
||
"4140 W qu'elle exige — aucun démarrage possible ce cycle") },
|
||
|
||
// --- Sécurité et cycle de vie ---
|
||
{ { DecisionCode::DegradedL2 }, QString(),
|
||
QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)") },
|
||
{ { DecisionCode::SafeStateRelay }, QString(),
|
||
QStringLiteral("Charge désactivée — mise en état sûr avant retrait (ECS-413)") },
|
||
{ { DecisionCode::SafeStateSetpoint }, QString(),
|
||
QStringLiteral("Charge désactivée — mise en état sûr (0 W) avant retrait (ECS-413)") },
|
||
{ { DecisionCode::SafeStateSgReady }, QString(),
|
||
QStringLiteral("Charge désactivée — mise en état sûr (état 2) avant retrait (ECS-413)") },
|
||
|
||
// --- Marché spot hors du build livré (brief 3, T0) ---
|
||
{ { DecisionCode::SpotMarketNotInBuild }, QString(),
|
||
QStringLiteral("Marché spot non disponible dans ce build — aucun fournisseur compilé "
|
||
"(invariant 10, aucun flux sortant)") },
|
||
};
|
||
|
||
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,
|
||
// brief 3, T0
|
||
DecisionCode::SpotMarketNotInBuild };
|
||
for (const QString &code : catalogue)
|
||
QVERIFY2(codesCouverts.contains(code),
|
||
qUtf8Printable(QStringLiteral("code %1 sans ligne de rendu attendue").arg(code)));
|
||
|
||
// Repli d'un code HORS catalogue : jamais une chaîne vide. Un rendu vide ferait passer un
|
||
// motif présent-mais-inconnu pour un motif absent — le silence ambigu de la règle 7-c.
|
||
const QString inconnu = renderFr(DecisionReason("CODE_QUI_NEXISTE_PAS", {{"x", 1}}),
|
||
QStringLiteral("Charge"));
|
||
QVERIFY(!inconnu.isEmpty());
|
||
QVERIFY2(inconnu.contains("CODE_QUI_NEXISTE_PAS"), qUtf8Printable(inconnu));
|
||
QVERIFY(!renderFr(DecisionReason()).isEmpty()); // motif absent : dit, pas tu
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testLoadTelemetryRpc()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testLoadTelemetryRpc nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [Lot B §2] La frontière de télémétrie, de bout en bout : c'est la couche que l'app
|
||
// consomme. Le constat qui a motivé ce lot était que l'état runtime de l'arbitre ne
|
||
// franchissait AUCUNE frontière RPC — 90 s d'écoute, arbitre actif, zéro notification.
|
||
//
|
||
// Couvre les critères d'acceptation 1 à 5 : notification non nulle et cohérente,
|
||
// snapshot ≡ notification, reprise à froid du mode dégradé, arbitre figé détectable,
|
||
// zéro langue naturelle dans la charge utile.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-telemetry.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
// Le harnais de simulation n'abonne PAS le client aux notifications (il charge son
|
||
// experience mock APRÈS initTestCase, donc EnergyTestBase saute l'abonnement). Or ce test
|
||
// porte précisément sur les notifications : sans cet appel, il « passerait » en observant
|
||
// le silence qu'il est censé faire échouer.
|
||
enableNotifications({"NymeaEnergy"});
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
// ---- 0. AVANT tout cycle : « pas encore d'arbitrage » se DIT, il ne se devine pas ------
|
||
// timestamp ABSENT (omis, jamais nul) : forger un instant se lirait « cycle exécuté, rien à
|
||
// allouer », soit exactement le mensonge que ce champ existe pour empêcher.
|
||
QVariant resp = injectAndWait("NymeaEnergy.GetLoadTelemetry");
|
||
QVariantMap vide = resp.toMap().value("params").toMap();
|
||
QVERIFY2(!vide.contains("timestamp"), "timestamp forgé avant le premier cycle");
|
||
QVERIFY(vide.contains("degradedMode"));
|
||
QCOMPARE(vide.value("degradedMode").toBool(), false);
|
||
QVERIFY(vide.value("loads").toList().isEmpty());
|
||
|
||
// ---- Trois mécanismes, un seul budget : l'union discriminée doit sortir ENTIÈRE --------
|
||
QUuid rA = addPowerSwitch(1000, 26661); // relay-router (1 relais, minOn 300 s)
|
||
QUuid k1 = addPowerSwitch(0, 26662); // sg-ready K1
|
||
QUuid k2 = addPowerSwitch(0, 26663); // sg-ready K2
|
||
QUuid varId = addEtmVariableLoad(27040); // etmvariableload dynamic
|
||
Thing *relayA = tm->findConfiguredThing(rA);
|
||
Thing *varThing = tm->findConfiguredThing(varId);
|
||
QVERIFY(relayA && varThing);
|
||
QVERIFY(tm->findConfiguredThing(k1) && tm->findConfiguredThing(k2));
|
||
varThing->setStateValue("currentPowerW", 0);
|
||
|
||
LoadConfigStore *store = new LoadConfigStore(arb);
|
||
arb->setLoadConfigStore(store);
|
||
|
||
LoadConfigs cfgs;
|
||
cfgs.append(LoadConfig::fromMap(QVariantMap{
|
||
{"id", varId.toString()}, {"label", "Routeur PV"}, {"adapter", "etmvariableload"},
|
||
{"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 1}, {"enabled", true},
|
||
{"domain", "ecs"}}));
|
||
cfgs.append(LoadConfig::fromMap(QVariantMap{
|
||
{"id", "pac-telemetrie"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"},
|
||
{"priority", 2}, {"enabled", true}, {"domain", "hvac"},
|
||
{"sgReady", QVariantMap{
|
||
{"minStateHoldS", 300},
|
||
{"states", QVariantList()
|
||
<< QVariantMap{{"state", 1}, {"relays", QVariantList() << k1.toString()},
|
||
{"estimatedPowerW", 0.0}}
|
||
<< QVariantMap{{"state", 2}, {"relays", QVariantList()},
|
||
{"estimatedPowerW", 0.0}}
|
||
<< QVariantMap{{"state", 3}, {"relays", QVariantList() << k2.toString()},
|
||
{"estimatedPowerW", 800.0}}
|
||
<< QVariantMap{{"state", 4},
|
||
{"relays", QVariantList() << k1.toString() << k2.toString()},
|
||
{"estimatedPowerW", 1600.0}}}}}}));
|
||
cfgs.append(LoadConfig::fromMap(QVariantMap{
|
||
{"id", "ecs-relais-telemetrie"}, {"label", "ECS relais"}, {"adapter", "relay-router"},
|
||
{"mode", "fixed"}, {"priority", 3}, {"enabled", true}, {"domain", "ecs"},
|
||
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, {"powerW", 1000}}},
|
||
{"minOnS", 300}, {"minOffS", 0}}));
|
||
QString err;
|
||
QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8());
|
||
|
||
// ---- 1. Le CONSTAT rejoué : un cycle d'arbitrage produit-il une notification ? ---------
|
||
QSignalSpy notificationSpy(m_mockTcpServer, &MockTcpServer::outgoingData);
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
meter->setStateValue("currentPower", -6000); // 6000 W exportés
|
||
arb->simulationCallUpdate(t0);
|
||
QCoreApplication::processEvents();
|
||
|
||
QVariantList notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged");
|
||
QVERIFY2(!notifs.isEmpty(), "aucune notification LoadTelemetryChanged — c'est le constat "
|
||
"d'origine, arbitre actif et frontière RPC muette");
|
||
QVariantMap premier = notifs.last().toMap().value("params").toMap();
|
||
|
||
// PREMIER cycle : la PAC est GELÉE par l'armement à froid du minStateHold (ECS-412). Ce
|
||
// n'est pas un détail de test — c'est ce que l'app verra après CHAQUE redémarrage de
|
||
// nymead, pendant la durée configurée. La télémétrie doit donc le dire, et le dire en
|
||
// clair : un verrou nommé, des secondes restantes, et un motif qui accuse le verrou et
|
||
// non le budget.
|
||
{
|
||
QVariantMap parIdC1;
|
||
for (const QVariant &v : premier.value("loads").toList())
|
||
parIdC1.insert(v.toMap().value("loadId").toString(), v.toMap());
|
||
const QVariantMap pacC1 = parIdC1.value("pac-telemetrie").toMap();
|
||
QCOMPARE(pacC1.value("mechanism").toMap().value("state").toInt(), 2);
|
||
QCOMPARE(qRound(pacC1.value("allocatedW").toDouble()), 0);
|
||
QCOMPARE(pacC1.value("decision").toMap().value("code").toString(),
|
||
QString(DecisionCode::LockMinStateHold));
|
||
QCOMPARE(pacC1.value("lock").toMap().value("kind").toString(), QString("minStateHold"));
|
||
QCOMPARE(pacC1.value("lock").toMap().value("remainingS").toInt(), 300);
|
||
// Le budget du cycle en tient compte : la PAC n'a rien reçu.
|
||
QCOMPARE(qRound(premier.value("budget").toMap().value("allocatedW").toDouble()), 4000);
|
||
// Le relais, lui, vient de commuter : sa fenêtre minOn est pleine.
|
||
const QVariantMap relaisC1 = parIdC1.value("ecs-relais-telemetrie").toMap();
|
||
QCOMPARE(relaisC1.value("lock").toMap().value("kind").toString(), QString("minOn"));
|
||
QCOMPARE(relaisC1.value("lock").toMap().value("remainingS").toInt(), 300);
|
||
}
|
||
|
||
// ---- Le verrou EXPIRE, et la PAC prend sa part ------------------------------------------
|
||
notificationSpy.clear();
|
||
const QDateTime t1 = t0.addSecs(301); // > minStateHoldS (300)
|
||
arb->simulationCallUpdate(t1);
|
||
QCoreApplication::processEvents();
|
||
notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged");
|
||
QVERIFY2(!notifs.isEmpty(), "le déblocage de la PAC change l'allocation : il doit notifier");
|
||
QVariantMap notif = notifs.last().toMap().value("params").toMap();
|
||
|
||
// Cohérence avec la cascade : Routeur PV (rang 1) prend 3000 (clamp maxPowerW), reliquat
|
||
// 3000 → PAC (rang 2) forcée état 4 (~1600), reliquat 1400 → ECS relais (rang 3) 1000.
|
||
QCOMPARE(notif.value("degradedMode").toBool(), false);
|
||
QVERIFY(notif.contains("timestamp"));
|
||
QCOMPARE(notif.value("timestamp").toString(), t1.toUTC().toString(Qt::ISODate));
|
||
|
||
const QVariantMap budget = notif.value("budget").toMap();
|
||
QCOMPARE(qRound(budget.value("surplusW").toDouble()), 6000);
|
||
QCOMPARE(qRound(budget.value("evReservedW").toDouble()), 0);
|
||
QCOMPARE(qRound(budget.value("allocatedW").toDouble()), 5600);
|
||
QCOMPARE(qRound(budget.value("remainingW").toDouble()), 1400);
|
||
// ---- `draw` : ce qui EXISTE se publie, ce qui n'existe pas s'ABSENTE -------------------
|
||
//
|
||
// Ce test REMPLACE celui qui épinglait l'absence d'`authorisedW`. Celui-là avait un motif
|
||
// juste — « un plafond de soutirage qui n'existe pas ne doit pas s'afficher » — et il a
|
||
// survécu à ce motif : le lot délestage a CRÉÉ le plafond, et le champ est devenu une
|
||
// lecture au lieu d'une forge. Sa garde, elle, ne savait pas que le monde avait changé.
|
||
// C'est le troisième mode d'érosion d'AGENTS.md, et la parade est ci-dessous.
|
||
//
|
||
// ⚠️ **CE TEST DEVIENT FAUX** le jour où l'une de ces deux choses arrive :
|
||
// · un plafond de soutirage devient publiable SANS être en vigueur — alors la condition
|
||
// de présence change, et c'est elle qu'il faut réécrire, pas les assertions ;
|
||
// · `committedW` cesse d'exister à chaque cycle — alors même lui devient conditionnel.
|
||
// Tant qu'aucune des deux n'est vraie, un échec ici signale une régression, pas un test
|
||
// périmé.
|
||
const QVariantMap draw = notif.value("draw").toMap();
|
||
|
||
// `committedW` est publié TOUJOURS : la grandeur existe à chaque cycle, et zéro en est une
|
||
// valeur. C'est la moitié « le zéro est une valeur quand la grandeur existe ».
|
||
QVERIFY2(draw.contains(QStringLiteral("committedW")),
|
||
"le registre des watts achetés existe à chaque cycle, même vide");
|
||
|
||
// Et l'autre moitié : ici AUCUN plafond n'est réglé (`phasePowerLimit` vaut 0, contrat
|
||
// amont : la protection est désactivée). Les quatre champs qui décrivent l'autorisation
|
||
// doivent donc être ABSENTS — pas à zéro. Un `authorisedW: 0` avec un `binding` par défaut
|
||
// dirait « aucune autorisation, imposée par le branchement » là où la vérité est « aucun
|
||
// plafond n'existe ». Les deux affirmations commandent des gestes opposés.
|
||
for (const QString &champ : {QStringLiteral("authorisedW"), QStringLiteral("remainingW"),
|
||
QStringLiteral("binding"), QStringLiteral("perPhaseBound")})
|
||
QVERIFY2(!draw.contains(champ),
|
||
qUtf8Printable(QStringLiteral("sans plafond réglé, `%1` doit être ABSENT et "
|
||
"non nul — l'absence dit qu'aucun plafond "
|
||
"n'existe").arg(champ)));
|
||
|
||
// Le relais tire déjà ses 1000 W depuis le cycle précédent : ils lui sont RECRÉDITÉS avant
|
||
// arrondi (anti-clignotement). Sans ce terme publié, l'identité serait fausse ici même.
|
||
QCOMPARE(qRound(budget.value("recreditedW").toDouble()), 1000);
|
||
// L'invariant publié : surplus + recrédit − alloué == restant. Le recrédit y figure parce
|
||
// que sans lui l'identité serait FAUSSE dès qu'une charge consomme déjà — c'est-à-dire dans
|
||
// le cas normal — et le client chercherait un écart inexplicable.
|
||
QCOMPARE(qRound(budget.value("surplusW").toDouble() + budget.value("recreditedW").toDouble()
|
||
- budget.value("allocatedW").toDouble()),
|
||
qRound(budget.value("remainingW").toDouble()));
|
||
|
||
const QVariantList loads = notif.value("loads").toList();
|
||
QCOMPARE(loads.size(), 3); // l'EV n'y est pas : décidé en amont
|
||
QVariantMap parId;
|
||
double sommeAllouee = 0;
|
||
for (const QVariant &v : loads) {
|
||
const QVariantMap e = v.toMap();
|
||
parId.insert(e.value("loadId").toString(), e);
|
||
sommeAllouee += e.value("allocatedW").toDouble();
|
||
}
|
||
// La somme des allocations RETOMBE sur budget.allocatedW : sans quoi un client qui
|
||
// additionne les lignes obtiendrait un total en désaccord avec l'en-tête.
|
||
QCOMPARE(qRound(sommeAllouee), qRound(budget.value("allocatedW").toDouble()));
|
||
|
||
const QVariantMap eVar = parId.value(varId.toString()).toMap();
|
||
const QVariantMap ePac = parId.value("pac-telemetrie").toMap();
|
||
const QVariantMap eRelay = parId.value("ecs-relais-telemetrie").toMap();
|
||
QVERIFY(!eVar.isEmpty() && !ePac.isEmpty() && !eRelay.isEmpty());
|
||
|
||
// Union discriminée de mécanisme : chaque variante sort avec SA charge utile, et seulement
|
||
// la sienne (LM-300/302 appliqués au sens sortant).
|
||
QCOMPARE(eVar.value("mechanism").toMap().value("kind").toString(), QString("variable"));
|
||
QCOMPARE(qRound(eVar.value("mechanism").toMap().value("setpointW").toDouble()), 3000);
|
||
QCOMPARE(qRound(eVar.value("mechanism").toMap().value("percent").toDouble()), 100);
|
||
QVERIFY(!eVar.value("mechanism").toMap().contains("state")); // charge utile d'un AUTRE mécanisme
|
||
QVERIFY(!eVar.value("mechanism").toMap().contains("stageW"));
|
||
|
||
QCOMPARE(ePac.value("mechanism").toMap().value("kind").toString(), QString("sgReady"));
|
||
QCOMPARE(ePac.value("mechanism").toMap().value("state").toInt(), 4);
|
||
QVERIFY(!ePac.value("mechanism").toMap().contains("stageW"));
|
||
|
||
QCOMPARE(eRelay.value("mechanism").toMap().value("kind").toString(), QString("relay"));
|
||
QCOMPARE(qRound(eRelay.value("mechanism").toMap().value("stageW").toDouble()), 1000);
|
||
QVERIFY(!eRelay.value("mechanism").toMap().contains("percent"));
|
||
|
||
// Motifs : des CODES, avec leurs paramètres numériques. Jamais une phrase.
|
||
QCOMPARE(eVar.value("decision").toMap().value("code").toString(),
|
||
QString(DecisionCode::SurplusSetpoint));
|
||
QCOMPARE(eVar.value("decision").toMap().value("params").toMap().value("stepped").toBool(), false);
|
||
QCOMPARE(ePac.value("decision").toMap().value("code").toString(), QString(DecisionCode::SgForced));
|
||
QCOMPARE(eRelay.value("decision").toMap().value("code").toString(),
|
||
QString(DecisionCode::SurplusSetpoint));
|
||
QCOMPARE(eRelay.value("decision").toMap().value("params").toMap().value("stepped").toBool(), true);
|
||
|
||
// Verrous en SECONDES restantes, pas en watts. Les deux charges viennent de commuter à t0,
|
||
// donc leur fenêtre est pleine. Le routeur PV n'a AUCUN verrou côté moteur : la clé est
|
||
// absente, ce qui est la vérité — et non un verrou à zéro, qui se lirait « fenêtre expirée ».
|
||
// La PAC vient de commuter en 4 : sa fenêtre repart pleine.
|
||
QCOMPARE(ePac.value("lock").toMap().value("kind").toString(), QString("minStateHold"));
|
||
QCOMPARE(ePac.value("lock").toMap().value("remainingS").toInt(), 300);
|
||
// Le relais, lui, a commuté au cycle PRÉCÉDENT (t0) : 301 s plus tard son minOn de 300 s a
|
||
// EXPIRÉ, et la clé disparaît. Un verrou qui ne s'efface pas serait un verrou permanent —
|
||
// le défaut d'ECS-412 —, et l'app afficherait une charge éternellement bloquée.
|
||
QVERIFY2(!eRelay.contains("lock"), "le verrou minOn du relais aurait dû expirer");
|
||
QVERIFY(!eVar.contains("lock"));
|
||
|
||
// Disponibilité, et absence de code de défaut sur une installation saine.
|
||
for (const QVariant &v : loads) {
|
||
QCOMPARE(v.toMap().value("available").toBool(), true);
|
||
QVERIFY(!v.toMap().contains("faultCode"));
|
||
}
|
||
|
||
// ---- 2. Snapshot ≡ notification --------------------------------------------------------
|
||
// Le Get ne recalcule pas : il rend l'objet publié. Un recalcul donnerait des verrous
|
||
// rafraîchis sous un timestamp périmé — un état qui n'a jamais existé.
|
||
resp = injectAndWait("NymeaEnergy.GetLoadTelemetry");
|
||
QCOMPARE(resp.toMap().value("params").toMap(), notif);
|
||
|
||
// ---- 5. ZÉRO langue naturelle dans la charge utile -------------------------------------
|
||
// Contrôle MÉCANIQUE : toute chaîne émise est ASCII et sans espace. Une phrase française en
|
||
// porterait forcément (accents et/ou espaces). Le libellé de la charge, lui, n'entre pas
|
||
// dans la charge utile : il est substitué au RENDU, côté journal.
|
||
std::function<void(const QVariant &, const QString &)> verifierCodes =
|
||
[&](const QVariant &v, const QString &chemin) {
|
||
if (v.userType() == QMetaType::QVariantMap) {
|
||
const QVariantMap m = v.toMap();
|
||
for (auto it = m.constBegin(); it != m.constEnd(); ++it)
|
||
verifierCodes(it.value(), chemin + '.' + it.key());
|
||
} else if (v.userType() == QMetaType::QVariantList) {
|
||
const QVariantList l = v.toList();
|
||
for (int i = 0; i < l.size(); ++i)
|
||
verifierCodes(l.at(i), QStringLiteral("%1[%2]").arg(chemin).arg(i));
|
||
} else if (v.userType() == QMetaType::QString) {
|
||
const QString s = v.toString();
|
||
for (const QChar &c : s)
|
||
QVERIFY2(c.unicode() >= 32 && c.unicode() < 127,
|
||
qUtf8Printable(QStringLiteral("%1 : caractère non ASCII dans « %2 »")
|
||
.arg(chemin, s)));
|
||
QVERIFY2(!s.contains(' '),
|
||
qUtf8Printable(QStringLiteral("%1 : espace dans « %2 » — une valeur "
|
||
"émise doit être un CODE, pas une phrase")
|
||
.arg(chemin, s)));
|
||
}
|
||
};
|
||
verifierCodes(notif, QStringLiteral("telemetry"));
|
||
|
||
// ---- 4. Arbitre FIGÉ détectable --------------------------------------------------------
|
||
// Aucun cycle nouveau : le battement republie l'instantané tel quel. Le client reçoit donc
|
||
// un état, et son timestamp N'AVANCE PAS — c'est la preuve de non-fraîcheur. Sans
|
||
// battement, « installation stable » et « moteur arrêté » sont le même silence.
|
||
notificationSpy.clear();
|
||
arb->publishTelemetryHeartbeat();
|
||
QCoreApplication::processEvents();
|
||
notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged");
|
||
QVERIFY2(!notifs.isEmpty(), "aucun battement de cœur");
|
||
const QVariantMap battement = notifs.last().toMap().value("params").toMap();
|
||
QCOMPARE(battement.value("timestamp").toString(), notif.value("timestamp").toString());
|
||
|
||
// ---- Le SEUIL : une oscillation de quelques watts ne réveille personne ------------------
|
||
// Le routeur est déjà à son plafond ; un surplus voisin ne change ni allocation ni code de
|
||
// motif. Sans seuil, chaque cycle notifierait pour rien sur une cible RPi Zero 2W.
|
||
notificationSpy.clear();
|
||
meter->setStateValue("currentPower", -6010); // +10 W, sous le seuil
|
||
arb->simulationCallUpdate(t1.addSecs(60));
|
||
QCoreApplication::processEvents();
|
||
QVERIFY2(checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged").isEmpty(),
|
||
"une variation de 10 W a notifié — le seuil ne mord pas");
|
||
// …mais le Get, lui, a bien avancé : l'état INTERNE est à jour même sans notification.
|
||
resp = injectAndWait("NymeaEnergy.GetLoadTelemetry");
|
||
const QVariantMap c2 = resp.toMap().value("params").toMap();
|
||
QCOMPARE(c2.value("timestamp").toString(), t1.addSecs(60).toUTC().toString(Qt::ISODate));
|
||
// Deuxième cycle : les charges consomment, donc le RECRÉDIT n'est plus nul — et c'est
|
||
// exactement le cas où l'identité serait fausse sans lui. L'allocation TOTALE, elle, n'a
|
||
// pas bougé : c'est pour cela que le seuil ne mord pas.
|
||
const QVariantMap b2 = c2.value("budget").toMap();
|
||
QVERIFY2(qRound(b2.value("recreditedW").toDouble()) > 0, "recrédit nul alors que les charges consomment");
|
||
QCOMPARE(qRound(b2.value("allocatedW").toDouble()), 5600);
|
||
QCOMPARE(qRound(b2.value("surplusW").toDouble() + b2.value("recreditedW").toDouble()
|
||
- b2.value("allocatedW").toDouble()),
|
||
qRound(b2.value("remainingW").toDouble()));
|
||
|
||
// ---- 3. Reprise à froid du mode dégradé ------------------------------------------------
|
||
// Un client qui se connecte APRÈS la bascule doit la lire, par GetLoadTelemetry ET par
|
||
// GetChargingSchedules. C'est la famille ECS-411 : une notification seule laisse le
|
||
// nouveau venu dans un silence ambigu.
|
||
notificationSpy.clear();
|
||
arb->recordMeterUpdate(t1.addSecs(60));
|
||
arb->evaluateMeterFreshness(t1.addSecs(60 + 91));
|
||
QCoreApplication::processEvents();
|
||
QVERIFY(arb->degradedMode());
|
||
|
||
notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged");
|
||
QVERIFY2(!notifs.isEmpty(), "bascule en mode dégradé sans notification de télémétrie");
|
||
const QVariantMap dgr = notifs.last().toMap().value("params").toMap();
|
||
QCOMPARE(dgr.value("degradedMode").toBool(), true);
|
||
// budget ABSENT : la planification est suspendue, il n'y a pas de budget pour ce cycle.
|
||
// Publier des zéros se lirait « arbitrage exécuté, rien à allouer » — l'inverse du vrai.
|
||
QVERIFY2(!dgr.contains("budget"), "budget publié en mode dégradé (planification suspendue)");
|
||
// Et `draw` avec lui, pour la même raison : sans plan, il n'y a pas de soutirage DÉCIDÉ.
|
||
// Un `committedW: 0` s'y lirait « ce cycle n'a rien acheté », alors qu'il n'y a pas eu de
|
||
// cycle du tout.
|
||
QVERIFY2(!dgr.contains("draw"), "draw publié en mode dégradé (aucun soutirage décidé)");
|
||
QVERIFY(!dgr.value("loads").toList().isEmpty());
|
||
QVariantMap dgrParId;
|
||
for (const QVariant &v : dgr.value("loads").toList()) {
|
||
QCOMPARE(v.toMap().value("decision").toMap().value("code").toString(),
|
||
QString(DecisionCode::DegradedL2));
|
||
QCOMPARE(qRound(v.toMap().value("allocatedW").toDouble()), 0);
|
||
dgrParId.insert(v.toMap().value("loadId").toString(), v.toMap());
|
||
}
|
||
|
||
// TROU DE SÉCURITÉ REFERMÉ (lot B-bis). Le repli L2 envoyait un Setpoint(0) à toute la
|
||
// table des charges pilotées et un State(2) à une table SÉPARÉE, réservée à la PAC codée en
|
||
// dur. Une PAC de CONFIGURATION vit dans la première : elle recevait donc un Setpoint, que
|
||
// SgReadyAdapter rejette d'entrée — et rien d'autre. Sous compteur muet elle restait en
|
||
// état 4 (forcé) indéfiniment, alors que SAFETY.md §L2 exige l'état 2.
|
||
//
|
||
// Le défaut était INVISIBLE dans la charge utile : le motif DEGRADED_L2 s'y affichait
|
||
// correctement, seule l'ÉCRITURE manquait. C'est pourquoi on vérifie ici les CONTACTS et
|
||
// le mécanisme publié, pas le motif.
|
||
QCOMPARE(dgrParId.value("pac-telemetrie").toMap()
|
||
.value("mechanism").toMap().value("state").toInt(), 2);
|
||
QCOMPARE(tm->findConfiguredThing(k1)->stateValue("power").toBool(), false);
|
||
QCOMPARE(tm->findConfiguredThing(k2)->stateValue("power").toBool(), false);
|
||
// Et la charge en watts est bien coupée elle aussi (le repli ne régresse pas de l'autre côté).
|
||
QCOMPARE(qRound(dgrParId.value("ecs-relais-telemetrie").toMap()
|
||
.value("mechanism").toMap().value("stageW").toDouble()), 0);
|
||
QCOMPARE(relayA->stateValue("power").toBool(), false);
|
||
verifierCodes(dgr, QStringLiteral("telemetry-degrade"));
|
||
|
||
// Le nouveau venu lit l'état par les DEUX portes.
|
||
resp = injectAndWait("NymeaEnergy.GetLoadTelemetry");
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("degradedMode").toBool(), true);
|
||
resp = injectAndWait("NymeaEnergy.GetChargingSchedules");
|
||
QVERIFY2(resp.toMap().value("params").toMap().contains("degradedMode"),
|
||
"GetChargingSchedules ne rend toujours pas degradedMode (poussé en notification "
|
||
"seule : le défaut que ce lot corrige)");
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("degradedMode").toBool(), true);
|
||
|
||
// Sortie du mode dégradé : symétrique, et publiée TOUT DE SUITE. La planification ne
|
||
// reprend qu'au cycle suivant ; sans cette republication, la télémétrie annoncerait encore
|
||
// degradedMode=true pendant une minute après la levée du repli.
|
||
notificationSpy.clear();
|
||
arb->recordMeterUpdate(t1.addSecs(300));
|
||
QCoreApplication::processEvents();
|
||
QVERIFY(!arb->degradedMode());
|
||
notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged");
|
||
QVERIFY2(!notifs.isEmpty(), "sortie du mode dégradé sans notification");
|
||
QCOMPARE(notifs.last().toMap().value("params").toMap().value("degradedMode").toBool(), false);
|
||
resp = injectAndWait("NymeaEnergy.GetChargingSchedules");
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("degradedMode").toBool(), false);
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testLoadConfigDomain()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testLoadConfigDomain nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [Lot B §3] o:domain — PURE MÉTADONNÉE d'intention. Trois exigences, plus une quatrième
|
||
// qui n'est pas dans la liste mais qui est la raison d'être du champ : l'arbitrage ne
|
||
// change pas. Un champ « sans effet » se prouve, il ne se déclare pas.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-domain.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QUuid ecsId = addEtmVariableLoad(27050);
|
||
Thing *ecsThing = tm->findConfiguredThing(ecsId);
|
||
QVERIFY(ecsThing);
|
||
ecsThing->setStateValue("currentPowerW", 0);
|
||
|
||
// 1. Charge AVEC domaine + charge SANS domaine : les deux acceptées.
|
||
QVariantList configs;
|
||
configs << QVariantMap{
|
||
{"id", ecsId.toString()}, {"label", "Chauffe-eau"}, {"adapter", "etmvariableload"},
|
||
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 2400}, {"maxPowerW", 2400},
|
||
{"priority", 1}, {"enabled", true}, {"domain", "ecs"}};
|
||
configs << QVariantMap{ // aucune clé « domain » : non classée
|
||
{"id", "{cafe0001-0000-0000-0000-000000000001}"}, {"label", "Sans domaine"},
|
||
{"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"maxPowerW", 1000},
|
||
{"priority", 2}, {"enabled", true}};
|
||
QVariant resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", configs}});
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
// 2. ALLER-RETOUR lire → réécrire sans perte. C'est le geste ordinaire de l'app, et c'est
|
||
// le mur qu'a touché LM-302 : GetLoadConfig sérialise via le méta-objet, donc TOUTES les
|
||
// propriétés déclarées. Tout mécanisme ou champ ajouté embarque ce test.
|
||
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
|
||
const QVariantList lu = resp.toMap().value("params").toMap().value("loadConfigs").toList();
|
||
QCOMPARE(lu.size(), 2);
|
||
QVariantMap avec, sans;
|
||
for (const QVariant &v : lu) {
|
||
if (v.toMap().value("id").toString() == ecsId.toString()) avec = v.toMap();
|
||
else sans = v.toMap();
|
||
}
|
||
QCOMPARE(avec.value("domain").toString(), QString("ecs"));
|
||
// Forme EXACTE d'une charge non classée en sortie RPC : la clé est PRÉSENTE et VIDE.
|
||
// GetLoadConfig sérialise via le méta-objet, donc toutes les propriétés déclarées sortent,
|
||
// y compris vides — comme « sgReady: {} » ou « powerLevels: [] » ici même. C'est le
|
||
// corollaire LM-302, et c'est ce qui rend l'aller-retour Get → Set possible. L'assertion
|
||
// est exacte plutôt que permissive : une tolérance masquerait un changement de forme, or
|
||
// c'est la forme que l'app consomme. Vérifié identique au banc le 2026-08-25.
|
||
QVERIFY2(sans.contains("domain"), "la propriété domain doit sortir même vide (méta-objet)");
|
||
QCOMPARE(sans.value("domain").toString(), QString());
|
||
QVERIFY(sans.value("sgReady").toMap().isEmpty()); // même forme, pour comparaison
|
||
|
||
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", lu}});
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
|
||
for (const QVariant &v : resp.toMap().value("params").toMap().value("loadConfigs").toList())
|
||
if (v.toMap().value("id").toString() == ecsId.toString())
|
||
QCOMPARE(v.toMap().value("domain").toString(), QString("ecs")); // survit au tour
|
||
|
||
// 3. Valeur HORS énumération : rejet EN BLOC. Une valeur libre acceptée aujourd'hui
|
||
// deviendrait une valeur à supporter le jour où la couche domaine en fera son
|
||
// discriminant.
|
||
QVariantList mauvais = configs;
|
||
QVariantMap m0 = mauvais.first().toMap();
|
||
m0["domain"] = "chauffe-eau"; // libellé, pas un code de l'énumération
|
||
mauvais[0] = m0;
|
||
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", mauvais}});
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorInvalidParameter"));
|
||
// Rien n'a été écrasé : la config valide tient toujours.
|
||
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("loadConfigs").toList().size(), 2);
|
||
|
||
// Toute la liste de l'énumération est acceptée — sinon « fermée » voudrait dire « arbitraire ».
|
||
for (const QString &d : LoadConfig::knownDomains()) {
|
||
QVariantList une;
|
||
une << QVariantMap{
|
||
{"id", ecsId.toString()}, {"label", "Chauffe-eau"}, {"adapter", "etmvariableload"},
|
||
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 2400}, {"maxPowerW", 2400},
|
||
{"priority", 1}, {"enabled", true}, {"domain", d}};
|
||
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", une}});
|
||
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
}
|
||
|
||
// 4. AUCUN effet sur l'arbitrage : la charge est servie exactement pareil, domaine ou non.
|
||
// Le champ enregistre une intention d'installateur ; il ne pilote rien.
|
||
meter->setStateValue("currentPower", -3000);
|
||
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
|
||
QCoreApplication::processEvents();
|
||
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 2400);
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testRuntimeViewIsSubstantial()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testRuntimeViewIsSubstantial nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [Lot B §2] La pure virtuelle `ILoadAdapter::runtimeView()` force chaque adaptateur à
|
||
// RÉPONDRE — elle ne l'oblige pas à répondre quelque chose. Une implémentation qui rendrait
|
||
// `LoadRuntimeView{}` compilerait tout aussi bien, passerait le dispatch, et sortirait de la
|
||
// télémétrie SANS BRUIT : le client verrait une charge sans mécanisme et sans verrou,
|
||
// indistinguable d'une charge libre. C'est exactement le silence ambigu que le garde-fou
|
||
// prétend empêcher, et le compilateur ne le voit pas.
|
||
//
|
||
// Ce test est le second verrou : pour CHAQUE adaptateur, la vue doit être SUBSTANTIELLE
|
||
// (mécanisme nommé, charge utile non vide) ET porter les valeurs RUNTIME réelles — pas des
|
||
// valeurs par défaut qui coïncideraient par hasard. On amène donc chaque adaptateur dans un
|
||
// état NON trivial avant de lire.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
|
||
// ---- 1. RelayRouter : palier appliqué en WATTS + verrou minOn en SECONDES --------------
|
||
QUuid rA = addPowerSwitch(1000, 26661);
|
||
QUuid rB = addPowerSwitch(1500, 26662);
|
||
QVERIFY(tm->findConfiguredThing(rA) && tm->findConfiguredThing(rB));
|
||
RelayRouter *routeur = new RelayRouter(tm, "rv-relais", "Routeur de relais",
|
||
QList<LoadConfigRelay>({ {rA.toString(), 1000}, {rB.toString(), 1500} }),
|
||
300, 0, 1, LoadNeeds(), arb);
|
||
|
||
LoadAction monte;
|
||
monte.kind = LoadAction::Setpoint; monte.funding = LoadAction::Surplus;
|
||
monte.powerW = 2500; monte.reason = { DecisionCode::SurplusSetpoint };
|
||
routeur->applyAction(monte, t0);
|
||
QCOMPARE(qRound(routeur->currentSetpointW()), 2500); // état NON trivial : palier haut
|
||
|
||
LoadRuntimeView vRelais = routeur->runtimeView(t0.addSecs(45));
|
||
QCOMPARE(vRelais.mechanismKind, QString("relay"));
|
||
// La charge utile porte le palier RÉELLEMENT appliqué, pas 0 ni le plafond.
|
||
QCOMPARE(qRound(vRelais.mechanism.value("stageW").toDouble()), 2500);
|
||
QCOMPARE(qRound(vRelais.mechanism.value("maxStageW").toDouble()), 2500);
|
||
// Verrou : minOn armé à t0, lu 45 s plus tard → 255 s restantes. Une vue vide donnerait 0,
|
||
// qui se lirait « fenêtre expirée » — l'inverse de la vérité.
|
||
QCOMPARE(vRelais.lockKind, QString("minOn"));
|
||
QCOMPARE(vRelais.lockRemainingS, 255);
|
||
QVERIFY(vRelais.faultCode.isEmpty()); // installation saine
|
||
// …et le verrou EXPIRE : sans cela, un « 255 » constant passerait le test ci-dessus.
|
||
QVERIFY(routeur->runtimeView(t0.addSecs(301)).lockKind.isEmpty());
|
||
|
||
// ---- 2. EtmVariableLoadAdapter : consigne, pourcentage dérivé, aucun verrou -----------
|
||
QUuid varId = addEtmVariableLoad(27060);
|
||
Thing *varThing = tm->findConfiguredThing(varId);
|
||
QVERIFY(varThing);
|
||
varThing->setStateValue("currentPowerW", 0);
|
||
EtmVariableLoadAdapter *continu = new EtmVariableLoadAdapter(
|
||
tm, varId.toString(), "Routeur PV", QList<int>(), 4000, /* minPowerW */ 0, 2, LoadNeeds(), arb);
|
||
|
||
LoadAction consigne;
|
||
consigne.kind = LoadAction::Setpoint; consigne.funding = LoadAction::Surplus;
|
||
consigne.powerW = 3000; consigne.reason = { DecisionCode::SurplusSetpoint };
|
||
continu->applyAction(consigne, t0);
|
||
QCOMPARE(qRound(continu->currentSetpointW()), 3000);
|
||
|
||
LoadRuntimeView vContinu = continu->runtimeView(t0);
|
||
QCOMPARE(vContinu.mechanismKind, QString("variable"));
|
||
QCOMPARE(qRound(vContinu.mechanism.value("setpointW").toDouble()), 3000);
|
||
QCOMPARE(qRound(vContinu.mechanism.value("maxPowerW").toDouble()), 4000);
|
||
// percent est DÉRIVÉ (3000/4000) : une valeur choisie pour qu'aucun défaut plausible —
|
||
// 0, 100, ou la consigne elle-même — ne puisse passer par accident.
|
||
QCOMPARE(vContinu.mechanism.value("percent").toDouble(), 75.0);
|
||
// Aucune fenêtre de verrou côté moteur : l'anti-rebond vit dans le thing. L'ABSENCE est ici
|
||
// la vérité, et elle est assertée — pas seulement tolérée.
|
||
QVERIFY(vContinu.lockKind.isEmpty());
|
||
QVERIFY(vContinu.faultCode.isEmpty());
|
||
|
||
// Le code de défaut n'est pas du code mort : un thing absent de la configuration le lève.
|
||
EtmVariableLoadAdapter *fantome = new EtmVariableLoadAdapter(
|
||
tm, "{deadbeef-0000-0000-0000-000000000042}", "Charge fantôme",
|
||
QList<int>(), 1000, /* minPowerW */ 0, 3, LoadNeeds(), arb);
|
||
QCOMPARE(fantome->runtimeView(t0).faultCode, QString("THING_MISSING"));
|
||
|
||
// ---- 3. SgReadyAdapter : état normé, estimation, verrou minStateHold ------------------
|
||
QUuid k1 = addPowerSwitch(0, 26663);
|
||
QUuid k2 = addPowerSwitch(0, 26664);
|
||
QVERIFY(tm->findConfiguredThing(k1) && tm->findConfiguredThing(k2));
|
||
SgReadyAdapter *pac = new SgReadyAdapter(
|
||
tm, "rv-pac", "PAC",
|
||
QHash<int, QList<QString>>({ {1, {k1.toString()}}, {2, {}},
|
||
{3, {k2.toString()}}, {4, {k1.toString(), k2.toString()}} }),
|
||
QHash<int, double>({ {1, 0.0}, {2, 0.0}, {3, 1500.0}, {4, 2600.0} }),
|
||
900, 4, arb);
|
||
|
||
LoadAction etat;
|
||
etat.kind = LoadAction::State; etat.funding = LoadAction::Surplus;
|
||
etat.state = 3; etat.reason = { DecisionCode::SgRecommended };
|
||
// ECS-412 — la première commande tombe dans l'armement à froid : REFUSÉE. C'est elle qui
|
||
// pose l'estampille, donc qui lance le compte à rebours (armement PARESSEUX, jamais
|
||
// permanent : c'est le blocage circulaire d'ECS-412 qu'on refuse de refaire).
|
||
pac->applyAction(etat, t0);
|
||
QCOMPARE(pac->currentState(), 2);
|
||
const QDateTime tPac = t0.addSecs(901); // verrou expiré
|
||
pac->applyAction(etat, tPac);
|
||
QCOMPARE(pac->currentState(), 3); // état NON trivial (le défaut est 2)
|
||
|
||
LoadRuntimeView vPac = pac->runtimeView(tPac.addSecs(120));
|
||
QCOMPARE(vPac.mechanismKind, QString("sgReady"));
|
||
QCOMPARE(vPac.mechanism.value("state").toInt(), 3);
|
||
QCOMPARE(qRound(vPac.mechanism.value("estimatedPowerW").toDouble()), 1500);
|
||
QCOMPARE(vPac.lockKind, QString("minStateHold"));
|
||
QCOMPARE(vPac.lockRemainingS, 780); // 900 − 120
|
||
QVERIFY(vPac.faultCode.isEmpty());
|
||
QVERIFY(pac->runtimeView(tPac.addSecs(901)).lockKind.isEmpty()); // le verrou expire
|
||
|
||
// ECS-110 — une PAC dont l'encodage ne permet pas l'état 2 n'est JAMAIS pilotable. Le taire
|
||
// la ferait passer pour une PAC au repos ; le code le dit.
|
||
SgReadyAdapter *bancale = new SgReadyAdapter(
|
||
tm, "rv-pac-bancale", "PAC sans état 2",
|
||
QHash<int, QList<QString>>({ {3, {k2.toString()}} }),
|
||
QHash<int, double>({ {3, 1500.0} }), 300, 5, arb);
|
||
QCOMPARE(bancale->runtimeView(t0).faultCode, QString("UNUSABLE_ENCODING"));
|
||
|
||
// ---- 4. EvAdapter : hors charge utile en beta, mais IMPLÉMENTÉ ------------------------
|
||
// Déclaré et écrit dès maintenant : en 3g, quand le VE rejoindra le waterfall unifié, il
|
||
// n'y aura rien à écrire ici. Une vue vide se découvrirait ce jour-là, en production.
|
||
// « ABC » et pas le défaut « All » : le mock n'accepte que A,B,C,AB,BC,AC,ABC.
|
||
QUuid chargerId = addCharger(QStringLiteral("ABC"));
|
||
QVERIFY(!chargerId.isNull());
|
||
arb->simulationCallUpdate(t0); // syncAdapters() crée les EvAdapter
|
||
QCoreApplication::processEvents();
|
||
EvCharger *borne = arb->registeredEvChargers().value(ThingId(chargerId.toString()));
|
||
QVERIFY2(borne, "la borne EV n'est pas enregistrée auprès de l'arbitre");
|
||
EvAdapter *ev = new EvAdapter(borne, arb);
|
||
|
||
LoadRuntimeView vEv = ev->runtimeView(t0);
|
||
QCOMPARE(vEv.mechanismKind, QString("evcharger"));
|
||
for (const char *cle : {"chargingEnabled", "pluggedIn", "currentA", "phaseCount"})
|
||
QVERIFY2(vEv.mechanism.contains(QLatin1String(cle)),
|
||
qUtf8Printable(QStringLiteral("clé « %1 » absente de la vue evcharger").arg(cle)));
|
||
|
||
// ---- 5. Le verrou générique : AUCUNE vue vide, pour AUCUN adaptateur ------------------
|
||
// C'est l'assertion qui échouerait si un adaptateur futur se contentait de « return {}; ».
|
||
// Elle porte sur la LISTE des adaptateurs, pas sur un cas particulier : ajouter un
|
||
// mécanisme sans l'ajouter ici laisse le trou ouvert, donc la liste est nommée en dur.
|
||
struct Cas { const char *nom; ILoadAdapter *a; };
|
||
const QList<Cas> tous = { {"relay-router", routeur}, {"etmvariableload", continu},
|
||
{"sg-ready", pac}, {"evcharger", ev} };
|
||
for (const Cas &c : tous) {
|
||
const LoadRuntimeView v = c.a->runtimeView(t0);
|
||
QVERIFY2(!v.mechanismKind.isEmpty(),
|
||
qUtf8Printable(QStringLiteral("%1 : mechanismKind VIDE — vue non implémentée").arg(c.nom)));
|
||
QVERIFY2(!v.mechanism.isEmpty(),
|
||
qUtf8Printable(QStringLiteral("%1 : charge utile de mécanisme VIDE").arg(c.nom)));
|
||
// Une charge utile qui ne porterait que « kind » serait une vue vide déguisée : le
|
||
// client n'y lirait rien de l'état réel. On exige au moins une grandeur.
|
||
QVERIFY2(v.mechanism.count() >= 2,
|
||
qUtf8Printable(QStringLiteral("%1 : charge utile réduite à un discriminant").arg(c.nom)));
|
||
}
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testSgReadyRestartRecovery()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testSgReadyRestartRecovery nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [lot B-bis / ECS-411 appliqué au mécanisme SG-Ready]
|
||
//
|
||
// Le SgReadyAdapter démarrait à l'état 2 « par défaut », quel que soit l'état réel des
|
||
// contacts. Tant que la PAC était codée en dur et construite une seule fois au démarrage du
|
||
// plugin, ça se voyait peu. Depuis qu'elle vient de la CONFIGURATION, elle est reconstruite
|
||
// à chaque changement de matériel — et l'hypothèse devient un mensonge répété.
|
||
//
|
||
// Deux dégâts, dont le second est le pire :
|
||
// 1. la télémétrie annonce l'état 2 (0 W alloué) pendant que la PAC tourne en forcé ;
|
||
// 2. LM-104 — applyAction(état 2) sur un adaptateur qui se CROIT en 2 est idempotent,
|
||
// donc MUET : les contacts restent en 4. L'hypothèse de prudence devient un
|
||
// MASQUAGE DE PANNE, ce que LM-104 interdit explicitement.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid k1 = addPowerSwitch(0, 26661);
|
||
QUuid k2 = addPowerSwitch(0, 26662);
|
||
Thing *tK1 = tm->findConfiguredThing(k1);
|
||
Thing *tK2 = tm->findConfiguredThing(k2);
|
||
QVERIFY(tK1 && tK2);
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
const QHash<int, QList<QString>> encodage({ {1, {k1.toString()}}, {2, {}},
|
||
{3, {k2.toString()}},
|
||
{4, {k1.toString(), k2.toString()}} });
|
||
const QHash<int, double> estim({ {1, 0.0}, {2, 0.0}, {3, 1500.0}, {4, 3000.0} });
|
||
|
||
// --- 1. Contacts en état 4 : l'adaptateur neuf le LIT, il ne suppose pas ---------------
|
||
tK1->setStateValue("power", true);
|
||
tK2->setStateValue("power", true);
|
||
JournalCapture j1;
|
||
SgReadyAdapter *pac4 = new SgReadyAdapter(tm, "pac-r4", "PAC reprise 4",
|
||
encodage, estim, 300, 1, arb);
|
||
QCOMPARE(pac4->currentState(), 4);
|
||
// ECS-411 : la télémétrie annonce la puissance ALLOUÉE de l'état lu, pas zéro. Sans la
|
||
// reprise, le waterfall croirait 3 kW libres qui circulent déjà.
|
||
QCOMPARE(qRound(pac4->telemetry().currentPowerW), 3000);
|
||
// ECS-411-b : le relevé publie ce qui a été LU, contact par contact, avec l'issue.
|
||
QVERIFY2(j1.contains("reprise au démarrage"), qUtf8Printable(j1.texte()));
|
||
QVERIFY2(j1.contains("état 4"), qUtf8Printable(j1.texte()));
|
||
QVERIFY2(j1.contains("fermé"), qUtf8Printable(j1.texte()));
|
||
QVERIFY2(j1.contains("correspondance exacte"), qUtf8Printable(j1.texte()));
|
||
|
||
// --- 2. LM-104 — le corollaire, qui est le vrai piège ----------------------------------
|
||
// Une remise à l'état 2 DOIT écrire. Sans la reprise, l'adaptateur se croirait déjà en 2,
|
||
// rendrait la main en idempotent, et les contacts resteraient fermés — panne masquée.
|
||
LoadAction versNeutre;
|
||
versNeutre.kind = LoadAction::State; versNeutre.funding = LoadAction::Surplus;
|
||
versNeutre.state = 2; versNeutre.force = true; // force : on teste l'écriture, pas le verrou
|
||
versNeutre.reason = { DecisionCode::SafeStateSgReady };
|
||
pac4->applyAction(versNeutre, t0);
|
||
QCoreApplication::processEvents();
|
||
QCOMPARE(tK1->stateValue("power").toBool(), false);
|
||
QCOMPARE(tK2->stateValue("power").toBool(), false);
|
||
QCOMPARE(pac4->currentState(), 2);
|
||
|
||
// --- 3. Contacts OUVERTS : l'issue est l'état neutre, et elle se dit quand même --------
|
||
// C'est le cas d'ECS-411-b : au palier/état NUL, l'ancien code se taisait, et « contacts
|
||
// lus, tous ouverts » ne se distinguait pas de « reprise non exécutée ». Or c'est
|
||
// précisément l'état qu'on vient vérifier après un démarrage.
|
||
tK1->setStateValue("power", false);
|
||
tK2->setStateValue("power", false);
|
||
JournalCapture j2;
|
||
SgReadyAdapter *pac2 = new SgReadyAdapter(tm, "pac-r2", "PAC reprise 2",
|
||
encodage, estim, 300, 1, arb);
|
||
QCOMPARE(pac2->currentState(), 2);
|
||
QVERIFY2(j2.contains("reprise au démarrage"), "l'issue NULLE doit se journaliser aussi");
|
||
QVERIFY2(j2.contains("état 2"), qUtf8Printable(j2.texte()));
|
||
QVERIFY2(j2.contains("ouvert"), qUtf8Printable(j2.texte()));
|
||
|
||
// --- 4. Contact INJOIGNABLE : supposé fermé, et la reprise passe en AVERTISSEMENT ------
|
||
// LM-104 — supposer conservateur pour ANNONCER. Le motif {K1 fermé, K2 absent supposé
|
||
// fermé} est l'encodage de l'état 4 : on annonce donc 3000 W, jamais 0.
|
||
tK1->setStateValue("power", true);
|
||
const QString k2absent = QStringLiteral("{deadbeef-2222-2222-2222-222222222222}");
|
||
JournalCapture j3;
|
||
SgReadyAdapter *pacKo = new SgReadyAdapter(tm, "pac-r-ko", "PAC contact absent",
|
||
QHash<int, QList<QString>>({ {1, {k1.toString()}}, {2, {}},
|
||
{3, {k2absent}}, {4, {k1.toString(), k2absent}} }),
|
||
estim, 300, 1, arb);
|
||
QCOMPARE(pacKo->currentState(), 4);
|
||
QVERIFY2(j3.contains("INJOIGNABLE"), qUtf8Printable(j3.texte()));
|
||
QVERIFY2(j3.contains("INCERTAIN"), qUtf8Printable(j3.texte()));
|
||
QVERIFY2(j3.contient(QtWarningMsg), "une reprise incertaine doit être un AVERTISSEMENT, "
|
||
"pas une information");
|
||
#endif
|
||
}
|
||
|
||
void Simulation::printStates(Thing *thing)
|
||
{
|
||
qCDebug(dcSimulation()) << "Thing states for" << thing->name();
|
||
foreach (const StateType &stateType, thing->thingClass().stateTypes()) {
|
||
qCDebug(dcSimulation()) << "-->" << stateType.name() << thing->stateValue(stateType.id());
|
||
}
|
||
}
|
||
|
||
void Simulation::updateChargerMeter(Thing *thing)
|
||
{
|
||
Action updateChargerAction(thing->thingClass().actionTypes().findByName("update").id(), thing->id());
|
||
NymeaCore::instance()->thingManager()->executeAction(updateChargerAction);
|
||
}
|
||
|
||
QStringList Simulation::plotOriginalData(int powerBalanceCount)
|
||
{
|
||
QStringList scriptLines;
|
||
scriptLines.append("set title 'Original energy data'");
|
||
scriptLines.append("set grid");
|
||
|
||
scriptLines.append("set timefmt '%s'");
|
||
scriptLines.append("set xdata time");
|
||
scriptLines.append("set xtics 3600");
|
||
scriptLines.append("set format x '%H:%M'");
|
||
|
||
scriptLines.append("set xlabel 'Time'");
|
||
scriptLines.append("set ylabel '[W]'");
|
||
|
||
scriptLines.append("set style fill transparent solid 0.3");
|
||
|
||
scriptLines.append("set x2tics 100");
|
||
scriptLines.append("set xtics nomirror");
|
||
scriptLines.append("set x2label 'iterations'");
|
||
scriptLines.append("set x2range [0:" + QString::number(powerBalanceCount) + "]");
|
||
|
||
QStringList plotLines;
|
||
plotLines.append("'original.csv' using 1:3 with boxes lt rgb '#7F75C23A' title 'Production'");
|
||
plotLines.append("'original.csv' using 1:4 with boxes lt rgb '#7F3590F3' title 'Consumption'");
|
||
plotLines.append("'original.csv' using 1:5 with line lt rgb 'orange' title 'House limit'");
|
||
plotLines.append("'original.csv' using 1:2 with line lt rgb 'red' title 'Meter'");
|
||
scriptLines.append("plot \\\n" + plotLines.join(", \\\n"));
|
||
scriptLines.append("");
|
||
return scriptLines;
|
||
}
|
||
|
||
QStringList Simulation::plotSimulation(const QString &title, int powerBalanceCount)
|
||
{
|
||
QStringList scriptLines;
|
||
scriptLines.append("set title '" + title + "'");
|
||
scriptLines.append("set grid");
|
||
|
||
scriptLines.append("set timefmt '%s'");
|
||
scriptLines.append("set xdata time");
|
||
scriptLines.append("set format x '%H:%M'");
|
||
scriptLines.append("set xtics 3600");
|
||
|
||
scriptLines.append("set xlabel 'time'");
|
||
scriptLines.append("set ylabel '[W]'");
|
||
|
||
scriptLines.append("set x2tics 100");
|
||
scriptLines.append("set xtics nomirror");
|
||
scriptLines.append("set x2label 'iterations'");
|
||
scriptLines.append("set x2range [0:" + QString::number(powerBalanceCount) + "]");
|
||
|
||
scriptLines.append("set style fill transparent solid 0.3");
|
||
|
||
scriptLines.append("set y2tics 10");
|
||
scriptLines.append("set ytics nomirror");
|
||
scriptLines.append("set y2label '[\%]'");
|
||
scriptLines.append("set y2range [0:100]");
|
||
|
||
QStringList plotLines;
|
||
plotLines.append("'simulation.csv' using 1:9 title 'Charger range' w filledcurves x1 lc rgb '#fff0f0f0'");
|
||
plotLines.append("'simulation.csv' using 1:8 notitle w filledcurves x1 lc rgb '#ffffffff'");
|
||
|
||
plotLines.append("'simulation.csv' using 1:3 with boxes lt rgb '#0A75C23A' title 'Production'");
|
||
plotLines.append("'simulation.csv' using 1:4 with boxes lt rgb '#7F3590F3' title 'Consumption'");
|
||
plotLines.append("'simulation.csv' using 1:17 with boxes lt rgb '#7FA020F0' title 'Energy storage'");
|
||
plotLines.append("'simulation.csv' using 1:5 with boxes lt rgb '#7FF3DE8A' title 'Charger'");
|
||
|
||
// plotLines.append("'simulation.csv' using 1:9 with line lt rgb '#EABC01' title 'Charger max'");
|
||
// plotLines.append("'simulation.csv' using 1:8 with line lt rgb '#F6CAAF' title 'Charger min'");
|
||
|
||
plotLines.append("'simulation.csv' using 1:13 with line lt rgb 'green' title 'Acquisition Limit'");
|
||
plotLines.append("'simulation.csv' using 1:11 with line lt rgb 'black' axes x1y2 title 'Battery [\%]'");
|
||
plotLines.append("'simulation.csv' using 1:18 with line lt rgb 'purple ' axes x1y2 title 'Energy storage [\%]'");
|
||
plotLines.append("'simulation.csv' using 1:14 with line lt rgb 'purple' axes x1y2 title 'Car plugged in into charger'");
|
||
plotLines.append("'simulation.csv' using 1:10 with line lt rgb 'orange' title 'House limit'");
|
||
plotLines.append("'simulation.csv' using 1:2 with line lt rgb 'red' title 'Meter'");
|
||
|
||
scriptLines.append("plot \\\n" + plotLines.join(", \\\n"));
|
||
scriptLines.append("");
|
||
return scriptLines;
|
||
}
|
||
|
||
QStringList Simulation::plotSpotMarketData(int powerBalanceCount)
|
||
{
|
||
QStringList scriptLines;
|
||
scriptLines.append("set title 'Spot maket data'");
|
||
scriptLines.append("set grid");
|
||
|
||
scriptLines.append("set timefmt '%s'");
|
||
scriptLines.append("set xdata time");
|
||
scriptLines.append("set format x '%H:%M'");
|
||
scriptLines.append("set xtics 3600");
|
||
|
||
scriptLines.append("set xlabel 'Time'");
|
||
scriptLines.append("set ylabel 'Price [Cent/kWh]'");
|
||
scriptLines.append("set x2tics 100");
|
||
scriptLines.append("set xtics nomirror");
|
||
scriptLines.append("set x2label 'iterations'");
|
||
scriptLines.append("set x2range [0:" + QString::number(powerBalanceCount) + "]");
|
||
|
||
scriptLines.append("set y2tics");
|
||
scriptLines.append("set ytics nomirror");
|
||
scriptLines.append("set y2label '[\%]'");
|
||
scriptLines.append("set y2range [0:100]");
|
||
|
||
QStringList plotLines;
|
||
plotLines.append("'simulation.csv' using 1:15 with boxes fs solid lt rgb '#fff0f0f0' axes x1y2 title 'Spotmarket scoring [%]'");
|
||
plotLines.append("'simulation.csv' using 1:16 with line lt rgb 'black' axes x1y1 title 'Price [Cent/kWh]'");
|
||
scriptLines.append("plot \\\n" + plotLines.join(", \\\n"));
|
||
scriptLines.append("");
|
||
return scriptLines;
|
||
}
|
||
|
||
|
||
QTEST_MAIN(Simulation)
|
||
|
||
namespace {
|
||
|
||
//! Prépare une borne du banc simulé : branchée, à l'arrêt, monophasée, 6-16 A.
|
||
void armerBorne(Thing *borne)
|
||
{
|
||
borne->setStateValue("connected", true);
|
||
borne->setStateValue("pluggedIn", true);
|
||
borne->setStateValue("power", false);
|
||
borne->setStateValue("charging", false);
|
||
borne->setStateValue("maxChargingCurrent", 6);
|
||
borne->setStateValue("currentPower", 0);
|
||
}
|
||
|
||
//! Les entrées de configuration, telles que l'APP les voit — par la frontière RPC, jamais par
|
||
//! un store privé : c'est la concordance des deux frontières qui est en jeu.
|
||
QHash<QString, QVariantMap> configsParId(const QVariant &getLoadConfigReply)
|
||
{
|
||
QHash<QString, QVariantMap> h;
|
||
const QVariantList l = getLoadConfigReply.toMap().value("params").toMap()
|
||
.value("loadConfigs").toList();
|
||
for (const QVariant &v : l)
|
||
h.insert(v.toMap().value("id").toString(), v.toMap());
|
||
return h;
|
||
}
|
||
|
||
//! Les entrées de télémétrie du dernier cycle, indexées par loadId.
|
||
QHash<QString, QVariantMap> telemetrieParId(const QVariantMap &snap)
|
||
{
|
||
QHash<QString, QVariantMap> h;
|
||
for (const QVariant &v : snap.value("loads").toList())
|
||
h.insert(v.toMap().value("loadId").toString(), v.toMap());
|
||
return h;
|
||
}
|
||
|
||
} // namespace
|
||
|
||
void Simulation::testEvChargerRankDecidesWhoCharges()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testEvChargerRankDecidesWhoCharges nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [3g-2 · LM-1204-b, moitié arbitrage] Deux bornes, un budget qui ne paie qu'un seul
|
||
// plancher. Ce qui est éprouvé ici est ce qu'AUCUN matériel ne prouve : que l'ARBITRE
|
||
// choisit, qu'il le dit, et que son choix suit le rang configuré.
|
||
//
|
||
// Le discriminant est le MOTIF publié (spec_loadmodel §12) :
|
||
// BELOW_MIN_POWER → l'arbitre a refusé, aucune commande n'est partie ;
|
||
// une borne à 0 W sans motif de plancher → c'est le matériel, le test ne prouve rien.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-evrank.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
// Tolérance d'acquisition à 1,0 : aucun démarrage sous tolérance ne vient brouiller la
|
||
// lecture du plancher. Ce mécanisme a son propre test.
|
||
QVERIFY(injectAndWait("NymeaEnergy.SetAcquisitionTolerance",
|
||
QVariantMap({{"acquisitionTolerance", 1.0}})).toMap()
|
||
.value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
|
||
|
||
// Deux bornes IDENTIQUES, plafonnées à 6 A : chacune prend exactement son plancher
|
||
// (1380 W) et pas un watt de plus. C'est ce qui rend le RELIQUAT visible — 2000 − 1380 =
|
||
// 620 W, sous le plancher de la seconde. Sans ce plafond, la première absorberait tout et
|
||
// la seconde publierait « pas de surplus », ce qui est vrai mais ne prouve pas le plancher.
|
||
QUuid borneAId = addCharger("A", 6, 26657);
|
||
QUuid borneBId = addCharger("A", 6, 26670);
|
||
QUuid voitureAId = addCar(26656);
|
||
QUuid voitureBId = addCar(26671);
|
||
QVERIFY(!borneAId.isNull() && !borneBId.isNull());
|
||
QVERIFY(!voitureAId.isNull() && !voitureBId.isNull());
|
||
|
||
Thing *borneA = tm->findConfiguredThing(borneAId);
|
||
Thing *borneB = tm->findConfiguredThing(borneBId);
|
||
QVERIFY(borneA && borneB);
|
||
armerBorne(borneA);
|
||
armerBorne(borneB);
|
||
for (const QUuid &v : {voitureAId, voitureBId}) {
|
||
Thing *voiture = tm->findConfiguredThing(v);
|
||
QVERIFY(voiture);
|
||
voiture->setStateValue("batteryLevel", 40);
|
||
voiture->setStateValue("capacity", 50);
|
||
voiture->setStateValue("minChargingCurrent", 6);
|
||
voiture->setStateValue("phaseCount", 1);
|
||
voiture->setStateValue("pluggedIn", true);
|
||
}
|
||
|
||
auto associer = [this](const QUuid &borne, const QUuid &voiture) {
|
||
QVariantMap info;
|
||
info.insert("evChargerId", borne);
|
||
info.insert("assignedCarId", voiture);
|
||
info.insert("chargingMode", "ChargingModeEco");
|
||
QVariant r = injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
|
||
QCOMPARE(r.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
};
|
||
associer(borneAId, voitureAId);
|
||
associer(borneBId, voitureBId);
|
||
|
||
// ---- 1. L'auto-provisionnement : les bornes ENTRENT dans la configuration --------------
|
||
// 3g-1 les arbitrait et les publiait sans qu'elles figurent dans GetLoadConfig — une charge
|
||
// publiée sans configuration est la charge fantôme que le lot B-bis avait supprimée.
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
meter->setStateValue("currentPower", 0); // aucun surplus : ce cycle ne sert qu'à
|
||
arb->simulationCallUpdate(t0); // faire entrer les bornes en configuration
|
||
QCoreApplication::processEvents();
|
||
|
||
QHash<QString, QVariantMap> cfgs = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
|
||
QVERIFY2(cfgs.contains(borneAId.toString()), "la borne A n'est pas entrée en configuration");
|
||
QVERIFY2(cfgs.contains(borneBId.toString()), "la borne B n'est pas entrée en configuration");
|
||
QCOMPARE(cfgs.value(borneAId.toString()).value("adapter").toString(), QStringLiteral("evcharger"));
|
||
QCOMPARE(cfgs.value(borneAId.toString()).value("domain").toString(), QStringLiteral("ev"));
|
||
QVERIFY(cfgs.value(borneAId.toString()).value("enabled").toBool());
|
||
|
||
// Aller-retour NEUTRE : relire la configuration et la réécrire mot pour mot ne change rien
|
||
// et ne reconstruit AUCUN adaptateur — l'adaptateur d'une borne vient du Thing, pas d'ici.
|
||
const QVariant avant = injectAndWait("NymeaEnergy.GetLoadConfig");
|
||
QVariant reecrit = injectAndWait("NymeaEnergy.SetLoadConfig",
|
||
QVariantMap({{"loadConfigs", avant.toMap().value("params").toMap().value("loadConfigs")}}));
|
||
QCOMPARE(reecrit.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
QCOMPARE(injectAndWait("NymeaEnergy.GetLoadConfig").toMap().value("params").toMap(),
|
||
avant.toMap().value("params").toMap());
|
||
|
||
// ---- 2. Le rang décide, et le MOTIF le dit --------------------------------------------
|
||
// Le réordonnancement passe par la MÊME frontière que l'app : GetLoadConfig, on change le
|
||
// rang, SetLoadConfig. Aucun raccourci par le store — c'est le chemin qui doit marcher.
|
||
auto poserRangs = [&](int rangA, int rangB) {
|
||
QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
|
||
.value("params").toMap().value("loadConfigs").toList();
|
||
for (int i = 0; i < l.count(); ++i) {
|
||
QVariantMap m = l.at(i).toMap();
|
||
if (m.value("id").toString() == borneAId.toString()) m.insert("priority", rangA);
|
||
if (m.value("id").toString() == borneBId.toString()) m.insert("priority", rangB);
|
||
l[i] = m;
|
||
}
|
||
QVariant r = injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}));
|
||
QCOMPARE(r.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
};
|
||
|
||
// Chaque cycle mesuré part de DEUX bornes à l'arrêt. Sans cela le recrédit
|
||
// anti-clignotement rendrait à celle qui charge déjà sa propre consommation — c'est le
|
||
// comportement voulu (le compteur l'inclut déjà), mais il rend le budget du cycle
|
||
// ambigu et le test ne mesurerait plus le rang.
|
||
auto cycle = [&](const QDateTime &t) {
|
||
for (Thing *b : {borneA, borneB}) {
|
||
b->setStateValue("power", false);
|
||
b->setStateValue("charging", false);
|
||
b->setStateValue("maxChargingCurrent", 6);
|
||
b->setStateValue("currentPower", 0);
|
||
}
|
||
meter->setStateValue("currentPower", -2000); // 2000 W exportés : UN seul plancher
|
||
arb->simulationCallUpdate(t);
|
||
QCoreApplication::processEvents();
|
||
return telemetrieParId(arb->loadTelemetry());
|
||
};
|
||
|
||
poserRangs(1, 2);
|
||
QHash<QString, QVariantMap> tele = cycle(t0.addSecs(60));
|
||
|
||
// Plancher : max(borne 6 A, voiture 6 A) × 230 V × 1 phase = 1380 W. Le budget de 2000 W
|
||
// en paie UN. La seconde ne reçoit pas « un peu » — elle reçoit 0 et son motif.
|
||
QCOMPARE(qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()), 1380);
|
||
QCOMPARE(qRound(tele.value(borneBId.toString()).value("allocatedW").toDouble()), 0);
|
||
// Le reliquat EXISTE (620 W) et il ne suffit pas : c'est bien le plancher qui refuse, pas
|
||
// l'absence de surplus. Les deux phrases envoient l'installateur vers des gestes opposés.
|
||
QCOMPARE(qRound(tele.value(borneBId.toString()).value("decision").toMap()
|
||
.value("params").toMap().value("budgetW").toDouble()), 620);
|
||
QCOMPARE(tele.value(borneBId.toString()).value("decision").toMap().value("code").toString(),
|
||
QString(DecisionCode::BelowMinPower));
|
||
QCOMPARE(qRound(tele.value(borneBId.toString()).value("decision").toMap()
|
||
.value("params").toMap().value("minPowerW").toDouble()), 1380);
|
||
|
||
// ---- 3. INVERSION du rang : la borne servie change ------------------------------------
|
||
// C'est le pendant EV de l'inversion ECS/SG-Ready déjà éprouvée, et la seule preuve que le
|
||
// rang est un RÉGLAGE et non un ordre de découverte.
|
||
poserRangs(2, 1);
|
||
tele = cycle(t0.addSecs(120));
|
||
|
||
QCOMPARE(qRound(tele.value(borneBId.toString()).value("allocatedW").toDouble()), 1380);
|
||
QCOMPARE(qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()), 0);
|
||
QCOMPARE(tele.value(borneAId.toString()).value("decision").toMap().value("code").toString(),
|
||
QString(DecisionCode::BelowMinPower));
|
||
|
||
// ---- 4. Une charge, UN commandeur -----------------------------------------------------
|
||
// La borne refusée doit être ÉTEINTE par la décision de l'arbitre. Avant 3g-2,
|
||
// adjustEvChargers() repassait derrière avec une consigne dérivée de sa propre logique :
|
||
// l'arbitre publiait un motif juste, et le matériel recevait autre chose.
|
||
QCOMPARE(borneA->stateValue("power").toBool(), false);
|
||
QCOMPARE(borneA->stateValue("charging").toBool(), false);
|
||
// Et la borne SERVIE l'est à la consigne du waterfall, pas à celle que le proxy aurait
|
||
// dérivée : 1380 W sur une phase, soit 6 A.
|
||
QCOMPARE(borneB->stateValue("power").toBool(), true);
|
||
QCOMPARE(borneB->stateValue("maxChargingCurrent").toInt(), 6);
|
||
|
||
// ---- 5. loads[] ⊆ GetLoadConfig, l'invariant que 3g-1 avait rompu ----------------------
|
||
QSet<QString> idsConfig;
|
||
for (const QString &id : configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")).keys())
|
||
idsConfig.insert(id);
|
||
for (auto it = tele.constBegin(); it != tele.constEnd(); ++it)
|
||
QVERIFY2(idsConfig.contains(it.key()),
|
||
qUtf8Printable(QStringLiteral("charge %1 publiée en télémétrie mais absente de "
|
||
"GetLoadConfig").arg(it.key())));
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testEvGridStartIsAnnouncedAndBounded()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testEvGridStartIsAnnouncedAndBounded nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [3g-2] `acquisitionTolerance` est un seuil de DÉCLENCHEMENT : la part du plancher qu'on
|
||
// accepte de ne pas couvrir par le surplus pour démarrer quand même. Le comportement
|
||
// existait chez le proxy, où il était invisible — confondu avec le plancher dans
|
||
// evFloorW(). Ici il est visible, chiffré, et borné.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-evgridstart.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
injectAndWait("NymeaEnergy.SetAcquisitionTolerance", QVariantMap({{"acquisitionTolerance", 0.5}}));
|
||
|
||
QUuid borneAId = addCharger("A", 16, 26657);
|
||
QUuid borneBId = addCharger("A", 16, 26670);
|
||
QUuid voitureAId = addCar(26656);
|
||
QUuid voitureBId = addCar(26671);
|
||
Thing *borneA = tm->findConfiguredThing(borneAId);
|
||
Thing *borneB = tm->findConfiguredThing(borneBId);
|
||
QVERIFY(borneA && borneB);
|
||
armerBorne(borneA);
|
||
armerBorne(borneB);
|
||
for (const QUuid &v : {voitureAId, voitureBId}) {
|
||
Thing *voiture = tm->findConfiguredThing(v);
|
||
QVERIFY(voiture);
|
||
voiture->setStateValue("batteryLevel", 40);
|
||
voiture->setStateValue("capacity", 50);
|
||
voiture->setStateValue("minChargingCurrent", 6);
|
||
voiture->setStateValue("phaseCount", 1);
|
||
voiture->setStateValue("pluggedIn", true);
|
||
}
|
||
for (const QPair<QUuid, QUuid> &paire : QList<QPair<QUuid, QUuid>>{{borneAId, voitureAId},
|
||
{borneBId, voitureBId}}) {
|
||
QVariantMap info;
|
||
info.insert("evChargerId", paire.first);
|
||
info.insert("assignedCarId", paire.second);
|
||
info.insert("chargingMode", "ChargingModeEco");
|
||
injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
|
||
}
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
meter->setStateValue("currentPower", 0); // cycle d'entrée en configuration seulement
|
||
arb->simulationCallUpdate(t0);
|
||
QCoreApplication::processEvents();
|
||
|
||
QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
|
||
.value("params").toMap().value("loadConfigs").toList();
|
||
for (int i = 0; i < l.count(); ++i) {
|
||
QVariantMap m = l.at(i).toMap();
|
||
if (m.value("id").toString() == borneAId.toString()) m.insert("priority", 1);
|
||
if (m.value("id").toString() == borneBId.toString()) m.insert("priority", 2);
|
||
l[i] = m;
|
||
}
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
// Les deux bornes à l'arrêt : le budget du cycle est sans ambiguïté (cf. testEvChargerRank…).
|
||
for (Thing *b : {borneA, borneB}) {
|
||
b->setStateValue("power", false);
|
||
b->setStateValue("charging", false);
|
||
b->setStateValue("maxChargingCurrent", 6);
|
||
b->setStateValue("currentPower", 0);
|
||
}
|
||
// 900 W de surplus : sous le plancher de 1380 W, mais au-dessus de sa moitié. Le proxy
|
||
// aurait démarré ici sans le dire ; le waterfall démarre et le DIT.
|
||
meter->setStateValue("currentPower", -900);
|
||
arb->simulationCallUpdate(t0.addSecs(60));
|
||
QCoreApplication::processEvents();
|
||
const QVariantMap snap = arb->loadTelemetry();
|
||
QHash<QString, QVariantMap> tele = telemetrieParId(snap);
|
||
|
||
// ---- Le motif dit COMBIEN est soutiré, et pourquoi -------------------------------------
|
||
const QVariantMap decisionA = tele.value(borneAId.toString()).value("decision").toMap();
|
||
QCOMPARE(decisionA.value("code").toString(), QString(DecisionCode::EvGridStart));
|
||
const QVariantMap pA = decisionA.value("params").toMap();
|
||
QCOMPARE(qRound(pA.value("floorW").toDouble()), 1380);
|
||
QCOMPARE(qRound(pA.value("budgetW").toDouble()), 900);
|
||
QCOMPARE(qRound(pA.value("gridW").toDouble()), 480); // 1380 − 900, SOUTIRÉS
|
||
// budgetW + gridW == allocatedW : la comptabilité est vérifiable par le client seul.
|
||
QCOMPARE(qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()), 1380);
|
||
QCOMPARE(qRound(pA.value("budgetW").toDouble() + pA.value("gridW").toDouble()), 1380);
|
||
|
||
// Le financement DIT la vérité sur l'origine : une part vient du réseau.
|
||
// R2 — le financement vit dans levels[], plus sur la charge. Un seul niveau aujourd'hui.
|
||
const QVariantList nivA = tele.value(borneAId.toString()).value("levels").toList();
|
||
QCOMPARE(nivA.count(), 1);
|
||
QCOMPARE(nivA.first().toMap().value("level").toString(), QString("comfort"));
|
||
QCOMPARE(nivA.first().toMap().value("funding").toString(), QString("grid"));
|
||
// Et le niveau publie ce qu'il a DÉCIDÉ : tant qu'il n'y en a qu'un, c'est allocatedW.
|
||
QCOMPARE(qRound(nivA.first().toMap().value("targetW").toDouble()),
|
||
qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()));
|
||
QVERIFY2(!tele.value(borneAId.toString()).contains("funding"),
|
||
"funding ne doit plus être publié au niveau de la CHARGE");
|
||
|
||
// ---- Borné par construction : UNE borne par cycle --------------------------------------
|
||
// Le démarrage consomme TOUT le budget restant ; la suivante trouve zéro et repart sur son
|
||
// motif ordinaire. Deux bornes ne peuvent pas se mettre à soutirer le même cycle.
|
||
QCOMPARE(qRound(tele.value(borneBId.toString()).value("allocatedW").toDouble()), 0);
|
||
QVERIFY2(tele.value(borneBId.toString()).value("decision").toMap().value("code").toString()
|
||
!= QString(DecisionCode::EvGridStart),
|
||
"deux bornes ont soutiré au réseau dans le même cycle");
|
||
|
||
// ---- La comptabilité du budget partage l'allocation ------------------------------------
|
||
const QVariantMap budget = snap.value("budget").toMap();
|
||
QCOMPARE(qRound(budget.value("allocatedW").toDouble()), 900); // la part de SURPLUS
|
||
QCOMPARE(qRound(budget.value("remainingW").toDouble()), 0);
|
||
|
||
// R3 — la part ACHETÉE a quitté evReservedW pour le registre du soutirage. evReservedW est
|
||
// une CORRECTION du budget (commandé non encore mesuré) : y verser une tranche
|
||
// d'allocation rendait toute définition du champ fausse pour l'un des deux termes, et
|
||
// interdisait de le nommer honnêtement à l'écran.
|
||
QCOMPARE(qRound(snap.value("draw").toMap().value("committedW").toDouble()), 480);
|
||
QCOMPARE(qRound(budget.value("evReservedW").toDouble()), 0);
|
||
|
||
// budgetW + gridW == allocatedW tient toujours, et se lit désormais SANS exception :
|
||
// la part surplus dans le registre d'allocation, la part achetée dans celui du soutirage.
|
||
QCOMPARE(qRound(budget.value("allocatedW").toDouble()
|
||
+ snap.value("draw").toMap().value("committedW").toDouble()),
|
||
qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()));
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testEvChargerConfigOutlivesItsThing()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testEvChargerConfigOutlivesItsThing nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [3g-2] Borne remplacée, ThingId qui change, appareil déposé : l'entrée de configuration
|
||
// n'est PAS supprimée d'office — c'est une configuration, et l'installateur a pu la classer.
|
||
// Elle doit se lire comme une charge dont l'appareil manque, avec le motif qui va bien.
|
||
// Le cas symétrique du THING_MISSING éprouvé avec la sonde.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-evorphan.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
meter->setStateValue("currentPower", -4000);
|
||
|
||
QUuid borneId = addCharger("A", 16, 26657);
|
||
QUuid voitureId = addCar(26656);
|
||
Thing *borne = tm->findConfiguredThing(borneId);
|
||
Thing *voiture = tm->findConfiguredThing(voitureId);
|
||
QVERIFY(borne && voiture);
|
||
armerBorne(borne);
|
||
voiture->setStateValue("batteryLevel", 40);
|
||
voiture->setStateValue("capacity", 50);
|
||
voiture->setStateValue("minChargingCurrent", 6);
|
||
voiture->setStateValue("phaseCount", 1);
|
||
voiture->setStateValue("pluggedIn", true);
|
||
|
||
QVariantMap info;
|
||
info.insert("evChargerId", borneId);
|
||
info.insert("assignedCarId", voitureId);
|
||
info.insert("chargingMode", "ChargingModeEco");
|
||
injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
arb->simulationCallUpdate(t0);
|
||
QCoreApplication::processEvents();
|
||
QVERIFY2(configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")).contains(borneId.toString()),
|
||
"la borne n'est pas entrée en configuration");
|
||
|
||
// ---- Le Thing disparaît ----------------------------------------------------------------
|
||
QVERIFY(removeDevice(borneId).toMap().value("params").toMap().value("thingError").toString()
|
||
== "ThingErrorNoError");
|
||
arb->simulationCallUpdate(t0.addSecs(60));
|
||
QCoreApplication::processEvents();
|
||
|
||
// L'entrée SURVIT : la supprimer effacerait un classement que l'installateur a posé, et le
|
||
// silence qui suivrait ne se distinguerait pas d'une borne jamais configurée.
|
||
QVERIFY2(configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")).contains(borneId.toString()),
|
||
"l'entrée de configuration a été supprimée avec le Thing");
|
||
|
||
// Et elle se LIT comme une charge sans appareil, à son rang, avec son motif.
|
||
const QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
|
||
QVERIFY2(tele.contains(borneId.toString()),
|
||
"la borne configurée sans appareil a disparu de la télémétrie");
|
||
const QVariantMap entree = tele.value(borneId.toString());
|
||
QCOMPARE(entree.value("available").toBool(), false);
|
||
QCOMPARE(entree.value("faultCode").toString(), QString("THING_MISSING"));
|
||
QCOMPARE(entree.value("decision").toMap().value("code").toString(),
|
||
QString(DecisionCode::LoadUnavailable));
|
||
QCOMPARE(qRound(entree.value("allocatedW").toDouble()), 0);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testUnpluggedChargerTakesNoBudget()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testUnpluggedChargerTakesNoBudget nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [banc 2026-08-27, RELEVE_3g2 §3.1] Une borne en Eco, voiture assignée, mais AUCUN
|
||
// véhicule branché. Elle ne peut rien tirer de ce qu'on lui alloue — et pourtant elle
|
||
// était servie : 4 853 W retirés du budget et donnés à personne, la charge suivante ne
|
||
// survivant que sur son propre recrédit.
|
||
//
|
||
// Le proxy n'avait pas besoin de ce filtre : son allocation n'était pas une cascade, donc
|
||
// gaspiller ne privait personne. C'est la transplantation qui rend le test nécessaire.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-unplugged.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QUuid borneId = addCharger("A", 16, 26657);
|
||
QUuid voitureId = addCar(26656);
|
||
QUuid routeurId = addEtmVariableLoad(27050);
|
||
Thing *borne = tm->findConfiguredThing(borneId);
|
||
Thing *voiture = tm->findConfiguredThing(voitureId);
|
||
Thing *routeur = tm->findConfiguredThing(routeurId);
|
||
QVERIFY(borne && voiture && routeur);
|
||
|
||
armerBorne(borne);
|
||
borne->setStateValue("pluggedIn", false); // ← le cas du banc : rien n'est branché
|
||
routeur->setStateValue("currentPowerW", 0);
|
||
voiture->setStateValue("batteryLevel", 40);
|
||
voiture->setStateValue("capacity", 50);
|
||
voiture->setStateValue("minChargingCurrent", 6);
|
||
voiture->setStateValue("phaseCount", 1);
|
||
|
||
QVariantMap info;
|
||
info.insert("evChargerId", borneId);
|
||
info.insert("assignedCarId", voitureId);
|
||
info.insert("chargingMode", "ChargingModeEco");
|
||
injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
meter->setStateValue("currentPower", 0);
|
||
arb->simulationCallUpdate(t0); // fait entrer la borne en configuration
|
||
QCoreApplication::processEvents();
|
||
|
||
// Une charge derrière la borne, au rang suivant : c'est elle qui doit recevoir le budget.
|
||
QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
|
||
.value("params").toMap().value("loadConfigs").toList();
|
||
for (int i = 0; i < l.count(); ++i) {
|
||
QVariantMap m = l.at(i).toMap();
|
||
if (m.value("id").toString() == borneId.toString()) m.insert("priority", 1);
|
||
l[i] = m;
|
||
}
|
||
l.append(QVariantMap{{"id", routeurId.toString()}, {"label", "Routeur PV"},
|
||
{"adapter", "etmvariableload"}, {"mode", "dynamic"},
|
||
{"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}});
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
// ---- Débranchée : elle ne doit PAS figurer dans le contexte ---------------------------
|
||
meter->setStateValue("currentPower", -3000);
|
||
arb->simulationCallUpdate(t0.addSecs(60));
|
||
QCoreApplication::processEvents();
|
||
QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
|
||
|
||
QVERIFY2(!tele.contains(borneId.toString()),
|
||
"une borne sans véhicule branché a été arbitrée");
|
||
// Et le budget est allé ENTIÈREMENT à la charge suivante. C'est la moitié du test qui
|
||
// compte : l'exclusion ne vaut que si elle ne coûte rien à personne.
|
||
QCOMPARE(qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble()), 3000);
|
||
QCOMPARE(qRound(arb->loadTelemetry().value("budget").toMap()
|
||
.value("allocatedW").toDouble()), 3000);
|
||
|
||
// ---- Branchée : elle redevient une charge du waterfall --------------------------------
|
||
// Sans cette seconde moitié, le test passerait aussi si la borne était exclue pour une
|
||
// raison quelconque — il ne prouverait pas que c'est `pluggedIn` qui décide.
|
||
borne->setStateValue("pluggedIn", true);
|
||
routeur->setStateValue("currentPowerW", 0);
|
||
arb->simulationCallUpdate(t0.addSecs(120));
|
||
QCoreApplication::processEvents();
|
||
tele = telemetrieParId(arb->loadTelemetry());
|
||
|
||
QVERIFY2(tele.contains(borneId.toString()),
|
||
"une borne avec véhicule branché doit entrer dans le waterfall");
|
||
QCOMPARE(qRound(tele.value(borneId.toString()).value("allocatedW").toDouble()), 3000);
|
||
QCOMPARE(qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble()), 0);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testChargerWithoutCarIsArbitrated()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testChargerWithoutCarIsArbitrated nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [LM-1208-b · banc 2026-08-28] Le cas de l'INVITÉ. Une borne en Eco, un véhicule branché,
|
||
// et AUCUNE voiture assignée — parce qu'une voiture qui n'est pas celle du propriétaire ne
|
||
// sera jamais déclarée. Elle tirait 3,9 kW au banc et l'arbitre l'ignorait : le critère
|
||
// « pas de voiture assignée » la sortait du waterfall précisément quand elle consommait.
|
||
//
|
||
// Deux choses sont éprouvées ici, et la seconde est celle qui se serait perdue :
|
||
// 1. la borne EST arbitrée sans voiture assignée ;
|
||
// 2. elle l'est avec le nombre de phases de la BORNE. Le manager ne prépare pas une
|
||
// borne sans voiture (prepareInformation la filtre), donc son ChargingProcessInfo
|
||
// n'existe pas — et le lire par défaut annonce « 1 phase » pour une borne triphasée.
|
||
// Le défaut serait SILENCIEUX : plafond à 3 680 W au lieu de 11 040, courant commandé
|
||
// à 16 A au lieu de 9, et 11 kW tirés d'un budget qui en comptait 3,7.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-nocar.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
// Tolérance d'acquisition à 1,0 : aucun démarrage sous tolérance ne vient brouiller la
|
||
// lecture du plancher — EV_GRID_START a son propre test.
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetAcquisitionTolerance",
|
||
QVariantMap({{"acquisitionTolerance", 1.0}})).toMap()
|
||
.value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
// La borne est TRIPHASÉE — c'est tout l'enjeu du point 2. Plancher 6 A × 230 V × 3
|
||
// = 4 140 W, plafond 16 A × 230 V × 3 = 11 040 W.
|
||
QUuid borneId = addCharger("ABC", 16, 26657);
|
||
QUuid routeurId = addEtmVariableLoad(27050);
|
||
Thing *borne = tm->findConfiguredThing(borneId);
|
||
Thing *routeur = tm->findConfiguredThing(routeurId);
|
||
QVERIFY(borne && routeur);
|
||
armerBorne(borne); // branchée, à l'arrêt, 6 A
|
||
QCOMPARE(borne->stateValue("phaseCount").toUInt(), 3u);
|
||
routeur->setStateValue("currentPowerW", 0);
|
||
|
||
// AUCUN assignedCarId — et aucune voiture n'est même ajoutée au système.
|
||
QVariantMap info;
|
||
info.insert("evChargerId", borneId);
|
||
info.insert("chargingMode", "ChargingModeEco");
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
meter->setStateValue("currentPower", 0);
|
||
arb->simulationCallUpdate(t0); // fait entrer la borne en configuration
|
||
QCoreApplication::processEvents();
|
||
|
||
// Une charge derrière la borne : sans elle, « la borne prend tout » ne se distinguerait pas
|
||
// de « personne ne prend rien ».
|
||
QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
|
||
.value("params").toMap().value("loadConfigs").toList();
|
||
for (int i = 0; i < l.count(); ++i) {
|
||
QVariantMap m = l.at(i).toMap();
|
||
if (m.value("id").toString() == borneId.toString()) m.insert("priority", 1);
|
||
l[i] = m;
|
||
}
|
||
l.append(QVariantMap{{"id", routeurId.toString()}, {"label", "Routeur PV"},
|
||
{"adapter", "etmvariableload"}, {"mode", "dynamic"},
|
||
{"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}});
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
// ---- Sans voiture assignée : la borne est ARBITRÉE ------------------------------------
|
||
meter->setStateValue("currentPower", -6000);
|
||
arb->simulationCallUpdate(t0.addSecs(60));
|
||
QCoreApplication::processEvents();
|
||
QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
|
||
|
||
QVERIFY2(tele.contains(borneId.toString()),
|
||
"une borne sans voiture assignée a été exclue du waterfall (LM-1208-b)");
|
||
|
||
// Le budget entier lui revient : 6 000 W passent sous son plafond TRIPHASÉ (11 040 W).
|
||
// Annoncée monophasée, elle aurait plafonné à 3 680 W et laissé 2 320 W au routeur — c'est
|
||
// exactement la lecture qui distingue les deux.
|
||
QCOMPARE(qRound(tele.value(borneId.toString()).value("allocatedW").toDouble()), 6000);
|
||
QCOMPARE(qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble()), 0);
|
||
|
||
// Et le COURANT commandé le confirme au matériel, pas seulement dans la charge utile :
|
||
// 6 000 / (230 × 3) = 8,7 → 9 A. Sur une phase supposée, c'eût été 16 A écrêtés au plafond
|
||
// de la borne — soit 11 kW tirés d'un budget qui en comptait 3,7.
|
||
QCOMPARE(borne->stateValue("maxChargingCurrent").toUInt(), 9u);
|
||
|
||
// ---- Le mode manuel, lui, RESTE une exclusion ------------------------------------------
|
||
// Sans cette moitié, le test passerait aussi si toute borne était arbitrée sans condition :
|
||
// il ne prouverait pas que c'est le critère « voiture » qui a été retiré, et lui seul.
|
||
info.insert("chargingMode", "ChargingModeNormal");
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
routeur->setStateValue("currentPowerW", 0);
|
||
arb->simulationCallUpdate(t0.addSecs(120));
|
||
QCoreApplication::processEvents();
|
||
tele = telemetrieParId(arb->loadTelemetry());
|
||
|
||
QVERIFY2(!tele.contains(borneId.toString()),
|
||
"une borne en mode manuel doit rester hors arbitrage");
|
||
QCOMPARE(qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble()), 3000);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testMeasurementSourceIsPublished()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testMeasurementSourceIsPublished nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [§11 · LM-1105/LM-1106] Il fallait désigner une charge comme SON PROPRE compteur dans
|
||
// meterThingId pour qu'elle publie ce qu'elle mesure déjà. Le moteur lit désormais
|
||
// l'appareil qu'il pilote — et publie D'OÙ vient le nombre, parce que l'écart
|
||
// commandé↔mesuré ne conclut pas la même chose selon la source.
|
||
//
|
||
// Ce test porte les trois sources ET les deux règles de priorité. Sans la seconde moitié
|
||
// il passerait aussi si le compteur explicite avait été purement et simplement remplacé.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-measure.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
// Trois mécanismes. Les relais du mock portent `currentPower` — c'est délibéré : le
|
||
// sg-ready doit répondre « non mesurable » MÊME LÀ, parce que ses contacts sont des
|
||
// contacts de SIGNAL et que le courant de la PAC ne les traverse pas.
|
||
QUuid rA = addPowerSwitch(1000, 26661); // relay-router, 1 relais
|
||
QUuid k1 = addPowerSwitch(0, 26662); // sg-ready K1
|
||
QUuid k2 = addPowerSwitch(0, 26663); // sg-ready K2
|
||
QUuid varId = addEtmVariableLoad(27041); // etmvariableload dynamic
|
||
Thing *varThing = tm->findConfiguredThing(varId);
|
||
QVERIFY(varThing && tm->findConfiguredThing(rA));
|
||
QVERIFY(tm->findConfiguredThing(k1) && tm->findConfiguredThing(k2));
|
||
varThing->setStateValue("currentPowerW", 0);
|
||
|
||
// Le compteur divisionnaire, posé EXPRÈS sur le routeur — il n'entrera en jeu qu'au 3.
|
||
QUuid divId = addMeter(26670);
|
||
Thing *div = tm->findConfiguredThing(divId);
|
||
QVERIFY(div);
|
||
div->setStateValue("connected", true);
|
||
div->setStateValue("currentPower", 1234);
|
||
|
||
LoadConfigStore *store = new LoadConfigStore(arb);
|
||
arb->setLoadConfigStore(store);
|
||
|
||
auto poserConfig = [&](const QString &meterThingId) {
|
||
LoadConfigs cfgs;
|
||
QVariantMap routeur{
|
||
{"id", varId.toString()}, {"label", "Routeur PV"}, {"adapter", "etmvariableload"},
|
||
{"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 1}, {"enabled", true}};
|
||
if (!meterThingId.isEmpty())
|
||
routeur.insert("meterThingId", meterThingId);
|
||
cfgs.append(LoadConfig::fromMap(routeur));
|
||
cfgs.append(LoadConfig::fromMap(sgReadyConfigMap("pac-mesure", "PAC", k1, k2,
|
||
800.0, 1600.0, 300, 2)));
|
||
cfgs.append(LoadConfig::fromMap(QVariantMap{
|
||
{"id", "ecs-mesure"}, {"label", "ECS relais"}, {"adapter", "relay-router"},
|
||
{"mode", "fixed"}, {"priority", 3}, {"enabled", true},
|
||
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()},
|
||
{"powerW", 1000}}},
|
||
{"minOnS", 0}, {"minOffS", 0}}));
|
||
QString err;
|
||
QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8());
|
||
};
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
|
||
// ---- 1. Les TROIS sources, sans une ligne de configuration de compteur ----------------
|
||
poserConfig(QString());
|
||
meter->setStateValue("currentPower", -6000);
|
||
arb->simulationCallUpdate(t0);
|
||
QCoreApplication::processEvents();
|
||
QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
|
||
|
||
// L'appareil piloté publie sa puissance : le moteur la lit, personne n'a rien désigné.
|
||
// 0 W est une MESURE, pas une absence — et la source ne doit pas basculer pour autant.
|
||
const QVariantMap mRouteur = tele.value(varId.toString()).value("measurement").toMap();
|
||
QCOMPARE(mRouteur.value("source").toString(), QString("device"));
|
||
QVERIFY2(mRouteur.contains("powerW"), "une charge mesurable doit publier sa valeur");
|
||
QCOMPARE(qRound(mRouteur.value("powerW").toDouble()), 0);
|
||
|
||
// LM-1105-a — le relay-router est servi : relais fermé, le mock rapporte 1 000 W. La mesure
|
||
// traverse bien le relais — c'est ce qui distingue un organe de COMMUTATION d'un contact
|
||
// de signal (voir la PAC juste dessous).
|
||
const QVariantMap mEcs = tele.value("ecs-mesure").value("measurement").toMap();
|
||
QCOMPARE(mEcs.value("source").toString(), QString("device"));
|
||
QCOMPARE(qRound(mEcs.value("powerW").toDouble()), 1000);
|
||
|
||
// LM-1105-b — la PAC : « none », ALORS QUE ses contacts portent currentPower. Un contact est
|
||
// un contact de signal — mesurer sa bobine ne mesure pas la PAC. C'est un état POSITIF,
|
||
// et powerW est OMIS : une nominale qui se ferait passer pour une mesure produirait un
|
||
// écart identiquement nul à tous les cycles.
|
||
const QVariantMap mPac = tele.value("pac-mesure").value("measurement").toMap();
|
||
QCOMPARE(mPac.value("source").toString(), QString("none"));
|
||
QVERIFY2(!mPac.contains("powerW"),
|
||
"une charge non mesurable a publié une puissance sous le nom d'une mesure");
|
||
|
||
// Et l'ancienne clé a bien DISPARU — deux noms pour une chose divergent.
|
||
QVERIFY2(!tele.value(varId.toString()).contains("measuredW"), "measuredW survit");
|
||
|
||
// ---- 1-bis. Palier 0 : la SOURCE ne bascule pas, la valeur seule tombe -----------------
|
||
// C'est la règle « la capacité se juge sur la CLASSE de tous les relais déclarés, jamais
|
||
// sur ceux qui sont fermés ». Jugée sur les relais fermés, elle répondrait « none » ici —
|
||
// et la source publiée basculerait à chaque extinction, sur une installation dont rien
|
||
// n'a changé. Il faut un budget NÉGATIF : à budget nul le recrédit anti-clignotement
|
||
// rendrait à l'ECS les 1 000 W qu'il consomme, et le relais resterait fermé.
|
||
meter->setStateValue("currentPower", 2000); // 2 000 W IMPORTÉS
|
||
arb->simulationCallUpdate(t0.addSecs(30));
|
||
QCoreApplication::processEvents();
|
||
tele = telemetrieParId(arb->loadTelemetry());
|
||
const QVariantMap mEcsEteint = tele.value("ecs-mesure").value("measurement").toMap();
|
||
QCOMPARE(mEcsEteint.value("source").toString(), QString("device"));
|
||
QCOMPARE(qRound(mEcsEteint.value("powerW").toDouble()), 0);
|
||
|
||
// ---- 2. La valeur SUIT l'appareil ------------------------------------------------------
|
||
meter->setStateValue("currentPower", -6000);
|
||
varThing->setStateValue("currentPowerW", 2900);
|
||
arb->simulationCallUpdate(t0.addSecs(60));
|
||
QCoreApplication::processEvents();
|
||
tele = telemetrieParId(arb->loadTelemetry());
|
||
QCOMPARE(tele.value(varId.toString()).value("measurement").toMap()
|
||
.value("source").toString(), QString("device"));
|
||
QCOMPARE(qRound(tele.value(varId.toString()).value("measurement").toMap()
|
||
.value("powerW").toDouble()), 2900);
|
||
|
||
// ---- 3. Le compteur EXPLICITE reste prioritaire ----------------------------------------
|
||
// Pas d'abord parce qu'il mesure mieux : parce qu'un réglage sans effet est pire que pas
|
||
// de réglage. 1234 W du compteur doivent l'emporter sur 2900 W déclarés par l'appareil.
|
||
poserConfig(divId.toString());
|
||
arb->simulationCallUpdate(t0.addSecs(120));
|
||
QCoreApplication::processEvents();
|
||
tele = telemetrieParId(arb->loadTelemetry());
|
||
const QVariantMap mAvecCompteur = tele.value(varId.toString()).value("measurement").toMap();
|
||
QCOMPARE(mAvecCompteur.value("source").toString(), QString("meter"));
|
||
QCOMPARE(qRound(mAvecCompteur.value("powerW").toDouble()), 1234);
|
||
|
||
// ---- 4. …mais la priorité porte sur la SOURCE, pas sur la valeur -----------------------
|
||
// Compteur déclaré et injoignable : on retombe sur l'appareil. Se taire perdrait une
|
||
// mesure qu'on a, et le silence se lirait « charge non mesurable » — ce qui serait faux.
|
||
poserConfig(QUuid::createUuid().toString());
|
||
arb->simulationCallUpdate(t0.addSecs(180));
|
||
QCoreApplication::processEvents();
|
||
tele = telemetrieParId(arb->loadTelemetry());
|
||
const QVariantMap mRepli = tele.value(varId.toString()).value("measurement").toMap();
|
||
QCOMPARE(mRepli.value("source").toString(), QString("device"));
|
||
QCOMPARE(qRound(mRepli.value("powerW").toDouble()), 2900);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testAutoProvisionedRankGoesToTheQueue()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testAutoProvisionedRankGoesToTheQueue nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [LM-1209] L'auto-provisionnement REMPLIT UN TROU : sans entrée, une borne serait arbitrée
|
||
// sans rang réglable. Il ne s'ensuit rien sur sa PLACE. Une borne détectée un mois après la
|
||
// mise en service ne doit pas passer devant l'ECS que quelqu'un a réglé exprès — une
|
||
// détection ne réordonne pas ce qu'un humain a décidé.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-queue.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
meter->setStateValue("currentPower", -4000);
|
||
|
||
// ---- Une installation DÉJÀ classée par un humain ---------------------------------------
|
||
QUuid rA = addPowerSwitch(1000, 26661);
|
||
QUuid varId = addEtmVariableLoad(27042);
|
||
QVERIFY(tm->findConfiguredThing(rA) && tm->findConfiguredThing(varId));
|
||
|
||
// Posées par la frontière RPC, comme l'app le ferait — c'est le magasin que le handler
|
||
// connaît, et c'est lui qui doit porter la marque de rang.
|
||
QVariantList l;
|
||
l.append(QVariantMap{{"id", "ecs-classe"}, {"label", "ECS classé"},
|
||
{"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", 1}, {"enabled", true}, {"domain", "ecs"},
|
||
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()},
|
||
{"powerW", 1000}}},
|
||
{"minOnS", 0}, {"minOffS", 0}});
|
||
l.append(QVariantMap{{"id", varId.toString()}, {"label", "Routeur PV"},
|
||
{"adapter", "etmvariableload"}, {"mode", "dynamic"},
|
||
{"maxPowerW", 3000}, {"priority", 2}, {"enabled", true},
|
||
{"domain", "ecs"}});
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
// ---- La borne arrive UN MOIS PLUS TARD -------------------------------------------------
|
||
QUuid borneId = addCharger("A", 16, 26657);
|
||
Thing *borne = tm->findConfiguredThing(borneId);
|
||
QVERIFY(borne);
|
||
armerBorne(borne);
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
arb->simulationCallUpdate(t0);
|
||
QCoreApplication::processEvents();
|
||
|
||
QHash<QString, QVariantMap> cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
|
||
QVERIFY2(cfg.contains(borneId.toString()), "la borne détectée n'est pas entrée en configuration");
|
||
|
||
// EN QUEUE : derrière tout le monde, pas en tête. C'est la correction du lot — le rang
|
||
// retombait à 1, à égalité avec l'ECS que quelqu'un avait posé là exprès.
|
||
QCOMPARE(cfg.value(borneId.toString()).value("priority").toInt(), 3);
|
||
|
||
// AUCUN autre rang n'a bougé : insérer est un geste de configuration, pas d'arbitrage.
|
||
QCOMPARE(cfg.value("ecs-classe").value("priority").toInt(), 1);
|
||
QCOMPARE(cfg.value(varId.toString()).value("priority").toInt(), 2);
|
||
|
||
// Et la création SE DIT : le journal ne suffit pas, l'app ne le lit pas et c'est elle qui
|
||
// montre le classement. « ce rang est un défaut, pas un choix. »
|
||
QCOMPARE(cfg.value(borneId.toString()).value("rankOrigin").toString(), QString("auto"));
|
||
// Les charges posées par un humain, elles, n'affirment rien — ni « choisi », ni « défaut ».
|
||
QVERIFY2(cfg.value("ecs-classe").value("rankOrigin").toString().isEmpty(),
|
||
"une charge posée par un humain ne doit rien affirmer sur l'origine de son rang");
|
||
|
||
// ---- Une SECONDE borne va encore derrière ----------------------------------------------
|
||
// Le rang produit doit rester LIBRE par construction : c'est ce qui rend l'égalité
|
||
// impossible par ce chemin, plutôt que de la refuser après coup.
|
||
QUuid borne2Id = addCharger("A", 16, 26670);
|
||
Thing *borne2 = tm->findConfiguredThing(borne2Id);
|
||
QVERIFY(borne2);
|
||
armerBorne(borne2);
|
||
arb->simulationCallUpdate(t0.addSecs(60));
|
||
QCoreApplication::processEvents();
|
||
|
||
cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
|
||
QCOMPARE(cfg.value(borne2Id.toString()).value("priority").toInt(), 4);
|
||
QCOMPARE(cfg.value(borneId.toString()).value("priority").toInt(), 3); // toujours pas bougé
|
||
QCOMPARE(cfg.value("ecs-classe").value("priority").toInt(), 1);
|
||
|
||
// Tous distincts : l'unicité est obtenue PAR CONSTRUCTION, pas par un refus tardif.
|
||
QSet<int> rangs;
|
||
for (auto it = cfg.constBegin(); it != cfg.constEnd(); ++it)
|
||
rangs.insert(it.value().value("priority").toInt());
|
||
QCOMPARE(rangs.count(), cfg.count());
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testDeletedEvChargerConfigComesBack()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testDeletedEvChargerConfigComesBack nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [LM-1209-e · mesuré sur `.75` le 2026-08-29] Le provisionnement vivait dans la branche
|
||
// « l'adaptateur vient de naître ». Or l'adaptateur est construit depuis le THING : il
|
||
// survit à la suppression de l'entrée de configuration. Une entrée supprimée depuis l'app
|
||
// n'était donc JAMAIS recréée — et la borne restait arbitrée, absente de GetLoadConfig, à
|
||
// un rang de queue non réglable. Le trou refermé en 3g-2, rouvert par le côté client.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-reprov.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
meter->setStateValue("currentPower", -4000);
|
||
|
||
QUuid borneId = addCharger("A", 16, 26657);
|
||
Thing *borne = tm->findConfiguredThing(borneId);
|
||
QVERIFY(borne);
|
||
armerBorne(borne);
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
arb->simulationCallUpdate(t0);
|
||
QCoreApplication::processEvents();
|
||
|
||
QHash<QString, QVariantMap> cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
|
||
QVERIFY2(cfg.contains(borneId.toString()), "la borne n'est pas entrée en configuration");
|
||
QCOMPARE(cfg.value(borneId.toString()).value("rankOrigin").toString(), QString("auto"));
|
||
const int rangInitial = cfg.value(borneId.toString()).value("priority").toInt();
|
||
|
||
// ---- L'installateur classe la borne : la marque tombe -----------------------------------
|
||
QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
|
||
.value("params").toMap().value("loadConfigs").toList();
|
||
for (int i = 0; i < l.count(); ++i) {
|
||
QVariantMap m = l.at(i).toMap();
|
||
if (m.value("id").toString() == borneId.toString()) m.insert("priority", rangInitial + 3);
|
||
l[i] = m;
|
||
}
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
|
||
QVERIFY2(cfg.value(borneId.toString()).value("rankOrigin").toString().isEmpty(),
|
||
"un rang posé par un humain doit lever la marque");
|
||
|
||
// ---- …puis il SUPPRIME l'entrée, le Thing restant vivant --------------------------------
|
||
QVariantList sans;
|
||
for (const QVariant &v : injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
|
||
.value("params").toMap().value("loadConfigs").toList())
|
||
if (v.toMap().value("id").toString() != borneId.toString())
|
||
sans.append(v);
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", sans}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
QVERIFY2(!configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")).contains(borneId.toString()),
|
||
"l'entrée devait être retirée par l'écriture cliente");
|
||
|
||
// ---- Le cycle suivant la RATTRAPE -------------------------------------------------------
|
||
// Sans LM-1209-e, rien ne se passe : l'adaptateur existe déjà, donc le provisionnement
|
||
// n'est plus jamais tenté, et la borne reste arbitrée hors configuration.
|
||
arb->simulationCallUpdate(t0.addSecs(60));
|
||
QCoreApplication::processEvents();
|
||
cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
|
||
|
||
QVERIFY2(cfg.contains(borneId.toString()),
|
||
"une borne dont le Thing vit doit retrouver une entrée de configuration");
|
||
|
||
// Et elle revient EN QUEUE et en « auto » : personne ne l'a classée, son rang est de
|
||
// nouveau un défaut. Le rang que l'installateur avait posé est perdu avec l'entrée — c'est
|
||
// le prix de la suppression, et c'est pourquoi la marque doit se voir.
|
||
QCOMPARE(cfg.value(borneId.toString()).value("rankOrigin").toString(), QString("auto"));
|
||
int rangMax = 0;
|
||
for (auto it = cfg.constBegin(); it != cfg.constEnd(); ++it)
|
||
if (it.key() != borneId.toString())
|
||
rangMax = qMax(rangMax, it.value().value("priority").toInt());
|
||
QCOMPARE(cfg.value(borneId.toString()).value("priority").toInt(), qMax(1, rangMax + 1));
|
||
|
||
// ---- Et le rattrapage ne se REJOUE pas quand l'entrée existe ----------------------------
|
||
// Sans quoi il réécrirait la configuration à chaque cycle — donc un changed(), donc un
|
||
// rebuild d'adaptateurs, donc des verrous réarmés en boucle (ECS-412).
|
||
const QVariantMap avant = cfg.value(borneId.toString());
|
||
arb->simulationCallUpdate(t0.addSecs(120));
|
||
QCoreApplication::processEvents();
|
||
const QVariantMap apres = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"))
|
||
.value(borneId.toString());
|
||
QCOMPARE(apres, avant);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testVanishedRelayFreezesTheLoad()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testVanishedRelayFreezesTheLoad nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [ECS-111] Le comportement était « non défini », et son effet dépassait l'affichage : le
|
||
// relais disparu était SAUTÉ, on retombait sur le nominal du PALIER ENTIER — part du relais
|
||
// fantôme comprise — et ce chiffre alimente le recrédit anti-clignotement, donc le BUDGET.
|
||
// L'arbitre se trompait sur le surplus pendant que `available` restait vrai.
|
||
//
|
||
// Ce test porte les deux moitiés, et c'est la seconde qui compte : ce que le fantôme
|
||
// laissait aux charges SUIVANTES.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-vanish.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QUuid rA = addPowerSwitch(2000, 26661); // ECS : un seul relais, 2000 W
|
||
QUuid varId = addEtmVariableLoad(27043); // la charge SUIVANTE, celle qui paie
|
||
Thing *routeur = tm->findConfiguredThing(varId);
|
||
QVERIFY(tm->findConfiguredThing(rA) && routeur);
|
||
routeur->setStateValue("currentPowerW", 0);
|
||
|
||
QVariantList l;
|
||
l.append(QVariantMap{{"id", "ecs-fantome"}, {"label", "ECS relais"},
|
||
{"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", 1}, {"enabled", true},
|
||
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()},
|
||
{"powerW", 2000}}},
|
||
{"minOnS", 0}, {"minOffS", 0}});
|
||
l.append(QVariantMap{{"id", varId.toString()}, {"label", "Routeur PV"},
|
||
{"adapter", "etmvariableload"}, {"mode", "dynamic"},
|
||
{"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}});
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
// ---- Régime sain : l'ECS prend son palier, le routeur prend le reste ------------------
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
meter->setStateValue("currentPower", -5000);
|
||
arb->simulationCallUpdate(t0);
|
||
QCoreApplication::processEvents();
|
||
QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
|
||
QCOMPARE(tele.value("ecs-fantome").value("available").toBool(), true);
|
||
QCOMPARE(qRound(tele.value("ecs-fantome").value("allocatedW").toDouble()), 2000);
|
||
const int routeurSain = qRound(tele.value(varId.toString()).value("allocatedW").toDouble());
|
||
QVERIFY2(routeurSain > 0, "le routeur doit être servi en régime sain");
|
||
|
||
// ---- Le relais DISPARAÎT du registre nymea --------------------------------------------
|
||
// Thing supprimé, ou plugin qui ne le voit plus : on ne sait pas dans quel état est le
|
||
// contact. C'est précisément pour ça qu'on ne peut pas « compter 0 W et servir le reste ».
|
||
QVERIFY(removeDevice(rA).toMap().value("params").toMap().value("thingError").toString()
|
||
== "ThingErrorNoError");
|
||
arb->simulationCallUpdate(t0.addSecs(60));
|
||
QCoreApplication::processEvents();
|
||
tele = telemetrieParId(arb->loadTelemetry());
|
||
|
||
// 1. La charge SORT DE L'ARBITRAGE, et elle le DIT.
|
||
const QVariantMap ecs = tele.value("ecs-fantome");
|
||
QCOMPARE(ecs.value("available").toBool(), false);
|
||
QCOMPARE(ecs.value("faultCode").toString(), QString("THING_MISSING"));
|
||
QCOMPARE(ecs.value("decision").toMap().value("code").toString(),
|
||
QString(DecisionCode::LoadUnavailable));
|
||
|
||
// 2. …mais PAS de la comptabilité : la puissance crue engagée reste allouée, parce qu'on
|
||
// ne sait plus la couper. C'est ECS-410, appliqué à une cause nouvelle.
|
||
QCOMPARE(qRound(ecs.value("allocatedW").toDouble()), 2000);
|
||
|
||
// 3. ET C'EST LA MOITIÉ QUI DÉCIDE : le fantôme est RETIRÉ du budget des suivantes au lieu
|
||
// de leur être recrédité. Avant le correctif, `currentPowerW` valait le nominal du
|
||
// palier entier et alimentait le recrédit — le routeur se voyait offrir un surplus qui
|
||
// n'existait pas. Ici il reçoit ce qui reste vraiment, pas davantage.
|
||
const int routeurFantome = qRound(tele.value(varId.toString()).value("allocatedW").toDouble());
|
||
QVERIFY2(routeurFantome <= routeurSain,
|
||
"la charge suivante ne doit pas recevoir PLUS quand un relais a disparu");
|
||
|
||
// 4. Et la mesure ne prétend rien : rien ne mesure une charge dont le câblage manque.
|
||
QCOMPARE(ecs.value("measurement").toMap().value("source").toString(), QString("none"));
|
||
QVERIFY2(!ecs.value("measurement").toMap().contains("powerW"),
|
||
"une charge au câblage incomplet ne doit pas publier de puissance mesurée");
|
||
#endif
|
||
}
|
||
|
||
/*!
|
||
* \brief [upstream-fix] La place libre par phase se calcule sur l'état QUI A ÉTÉ TESTÉ.
|
||
*
|
||
* \par Le défaut
|
||
* \c RootMeter::calculateAllowanceAmpere() testait \c currentPhaseB / \c currentPhaseC
|
||
* (ampères) puis lisait \c currentPowerPhaseB / \c currentPowerPhaseC divisés par 230
|
||
* (watts) — asymétrique avec la phase A, qui lit bien l'état qu'elle teste. Sur un compteur
|
||
* publiant les courants par phase SANS les puissances par phase, la lecture rendait 0 : la
|
||
* place libre valait le plafond entier et la protection de surcharge n'écrêtait **rien** sur
|
||
* B et C, sans rien signaler.
|
||
*
|
||
* \par Pourquoi aucun test existant ne pouvait le trouver
|
||
* Le mock pose \c currentPhaseB = \c currentPowerPhaseB / 230 : les deux états y sont
|
||
* **cohérents par construction**, donc lire l'un ou l'autre y donne le même nombre. C'est
|
||
* l'angle mort — l'installation de référence n'exerce pas le cas. Ce test le fabrique
|
||
* explicitement, en désaccordant les deux états, ce qu'aucun chemin normal du mock ne permet.
|
||
*
|
||
* \par Ce qui est épinglé
|
||
* Une phase B tirant 20 A avec une puissance par phase à zéro doit consommer de la marge.
|
||
* Avant le correctif : 25 A de place libre annoncés sur une phase qui en tire 20.
|
||
*/
|
||
void Simulation::testPhaseAllowanceReadsTheStateItTests()
|
||
{
|
||
ThingManager *thingManager = NymeaCore::instance()->thingManager();
|
||
QUuid meterThingId = addMeter();
|
||
QVERIFY(!meterThingId.isNull());
|
||
Thing *meterThing = thingManager->findConfiguredThing(meterThingId);
|
||
QVERIFY(meterThing);
|
||
meterThing->setStateValue("connected", true);
|
||
|
||
RootMeter *meter = new RootMeter(meterThing);
|
||
|
||
// Compteur qui publie les COURANTS par phase, mais pas les puissances par phase. On le
|
||
// fabrique en désaccordant les états à la main : le mock, lui, les tient toujours
|
||
// cohérents, et c'est précisément ce qui rendait le défaut invisible.
|
||
meterThing->setStateValue("currentPhaseA", 0);
|
||
meterThing->setStateValue("currentPhaseB", 20);
|
||
meterThing->setStateValue("currentPhaseC", 0);
|
||
meterThing->setStateValue("currentPowerPhaseA", 0);
|
||
meterThing->setStateValue("currentPowerPhaseB", 0);
|
||
meterThing->setStateValue("currentPowerPhaseC", 0);
|
||
|
||
const uint plafondA = 25;
|
||
|
||
// La phase B tire 20 A sous un plafond de 25 : il reste 5 A, pas 25.
|
||
QCOMPARE(meter->calculateAllowanceAmpere(Electricity::PhaseB, plafondA), 5.0);
|
||
|
||
// La phase A, elle, était déjà juste. On le vérifie pour que le test dise que les trois
|
||
// phases se comportent DÉSORMAIS pareil, et pas seulement que B a changé.
|
||
QCOMPARE(meter->calculateAllowanceAmpere(Electricity::PhaseA, plafondA), 25.0);
|
||
|
||
// Et la phase la plus chargée l'emporte quand on interroge les trois ensemble.
|
||
QCOMPARE(meter->calculateAllowanceAmpere(
|
||
Electricity::PhaseA | Electricity::PhaseB | Electricity::PhaseC, plafondA), 5.0);
|
||
}
|
||
|
||
/*!
|
||
* \brief [Étape 3] L'autorisation de SOUTIRAGE borne la cascade, et le motif dit laquelle.
|
||
*
|
||
* \par Ce qui change
|
||
* La protection de surcharge ne bornait jusqu'ici que les bornes VE, via le plafond de cycle
|
||
* d'`evPhaseAllowanceW()`. Une charge de waterfall pouvait donc créer un dépassement que seule
|
||
* la protection L4 rattrapait — après coup. Le waterfall tient désormais compte des DEUX
|
||
* ressources : le surplus dit ce qu'on peut dépenser sans acheter, l'autorisation dit ce que
|
||
* le branchement supporte.
|
||
*
|
||
* \par La précédence, qui est le point subtil
|
||
* \rule{LM-1011} : `DRAW_CAP` est réservé à l'autorisation résiduelle **nulle**. S'il en reste,
|
||
* fût-ce moins que le plus petit palier, le motif est `BELOW_MIN_POWER` — « il y en a, mais pas
|
||
* au bon format ». Les deux phrases envoient l'installateur vers des gestes opposés : revoir un
|
||
* abonnement, ou relire une fiche technique.
|
||
*
|
||
* \par Pourquoi le contexte est bâti à la main
|
||
* La marge est un champ du \c SurplusContext. La poser directement épingle la RÈGLE plutôt que
|
||
* la chaîne qui produit la marge — celle-ci a ses propres tests
|
||
* (`testDrawMarginComparesHeadroomsNotCaps`, `testPhaseAllowanceReadsTheStateItTests`). Un test
|
||
* qui dépendrait des deux échouerait pour deux raisons sans dire laquelle.
|
||
*/
|
||
void Simulation::testDrawCapBoundsTheCascade()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testDrawCapBoundsTheCascade nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-drawcap.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
|
||
QUuid rA = addPowerSwitch(1000, 26690);
|
||
QVERIFY(tm->findConfiguredThing(rA));
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 8, 30), QTime(13, 0, 0));
|
||
RuleBasedScheduler sched(arb);
|
||
|
||
// Un relais de 1000 W : paliers {0, 1000}. Le cas *fixed* est le plus exigeant, parce que
|
||
// l'écrêtage à la marge doit RETOMBER sur un palier atteignable — sinon le mécanisme prend
|
||
// le palier au-dessus, c'est-à-dire exactement le dépassement qu'on vient de refuser.
|
||
RelayRouter *routeur = new RelayRouter(tm, "cap", "Charge sous plafond",
|
||
QList<LoadConfigRelay>({ {rA.toString(), 1000} }), 0, 0, 1, LoadNeeds(), arb);
|
||
|
||
auto decider = [&](double surplusW, const DrawMargin &marge) -> LoadAction {
|
||
SurplusContext ctx;
|
||
ctx.timestamp = t0;
|
||
ctx.meter.exportW = surplusW > 0 ? surplusW : 0.0;
|
||
ctx.meter.importW = surplusW < 0 ? -surplusW : 0.0;
|
||
ctx.drawMargin = marge;
|
||
ctx.loads.append(routeur->toLoadContext(t0));
|
||
const Plan plan = sched.getPlan(ctx);
|
||
for (const LoadAction &a : plan.timeSlots.first().actions)
|
||
if (a.loadId == routeur->descriptor().id)
|
||
return a;
|
||
return LoadAction();
|
||
};
|
||
|
||
DrawMargin aucun; // borne == false : personne ne borne
|
||
DrawMargin epuise; // autorisation NULLE, et même dépassée
|
||
epuise.borne = true; epuise.watts = -460; epuise.source = DrawCapSource::Breaker; epuise.phase = 0;
|
||
DrawMargin reste; // il RESTE 500 W, sous le palier de 1000
|
||
reste.borne = true; reste.watts = 500; reste.source = DrawCapSource::Breaker; reste.phase = 1;
|
||
DrawMargin large; // 5000 W : le plafond existe et ne mord pas
|
||
large.borne = true; large.watts = 5000; large.source = DrawCapSource::Breaker; large.phase = 0;
|
||
DrawMargin total; // plafond TOTAL : aucune phase coupable
|
||
total.borne = true; total.watts = -300; total.source = DrawCapSource::GridOperator; total.phase = -1;
|
||
|
||
// 1. CONTRE-ÉPREUVE — sans plafond, 5000 W de surplus servent le palier. C'est la ligne de
|
||
// base : si elle tombait, les suivantes ne prouveraient rien sur le plafond.
|
||
const LoadAction sansPlafond = decider(5000, aucun);
|
||
QCOMPARE(qRound(sansPlafond.estimatedPowerW), 1000);
|
||
QCOMPARE(sansPlafond.reason.code, QString(DecisionCode::SurplusSetpoint));
|
||
|
||
// 2. Un plafond LARGE ne change rien. Un plafond qui mord toujours serait un plafond qui
|
||
// ne borne pas : il coupe.
|
||
const LoadAction sousPlafondLarge = decider(5000, large);
|
||
QCOMPARE(qRound(sousPlafondLarge.estimatedPowerW), 1000);
|
||
QCOMPARE(sousPlafondLarge.reason.code, QString(DecisionCode::SurplusSetpoint));
|
||
|
||
// 3. Autorisation ÉPUISÉE : le surplus est pléthorique, et pourtant rien n'est servi. Le
|
||
// motif porte les quatre grandeurs gravées au §6.3 du design.
|
||
const LoadAction bloque = decider(5000, epuise);
|
||
QCOMPARE(qRound(bloque.estimatedPowerW), 0);
|
||
QCOMPARE(bloque.reason.code, QString(DecisionCode::DrawCap));
|
||
QCOMPARE(bloque.reason.params.value("marginW").toInt(), -460);
|
||
QCOMPARE(bloque.reason.params.value("overshootW").toInt(), 460);
|
||
QCOMPARE(bloque.reason.params.value("source").toString(), QString("connection"));
|
||
QCOMPARE(bloque.reason.params.value("phase").toString(), QString("A"));
|
||
|
||
// 4. \rule{LM-1011} — il RESTE 500 W d'autorisation, sous le palier de 1000. Ce n'est PAS
|
||
// DRAW_CAP : il y a de l'autorisation, elle n'est pas au bon format.
|
||
const LoadAction pasAuFormat = decider(5000, reste);
|
||
QCOMPARE(qRound(pasAuFormat.estimatedPowerW), 0);
|
||
QCOMPARE(pasAuFormat.reason.code, QString(DecisionCode::BelowMinPower));
|
||
QVERIFY2(pasAuFormat.reason.code != QString(DecisionCode::DrawCap),
|
||
"autorisation résiduelle non nulle : LM-1011 impose BELOW_MIN_POWER");
|
||
|
||
// 5. Plafond TOTAL : aucune phase n'est coupable, et le motif n'en invente pas une.
|
||
// Publier « A » par défaut serait indiscernable d'une vraie phase A, et enverrait
|
||
// mesurer au hasard.
|
||
const LoadAction plafondTotal = decider(5000, total);
|
||
QCOMPARE(plafondTotal.reason.code, QString(DecisionCode::DrawCap));
|
||
QCOMPARE(plafondTotal.reason.params.value("source").toString(), QString("gridOperator"));
|
||
QVERIFY2(!plafondTotal.reason.params.contains("phase"),
|
||
"un plafond total n'a pas de phase coupable — ne pas en publier une");
|
||
|
||
// 6. Le TEXTE publié nomme la ressource et la source. Règle 7-b : le motif nomme le
|
||
// mécanisme qui a produit l'issue. Dire « surplus » ici enverrait attendre le soleil
|
||
// devant 5000 W de surplus disponibles.
|
||
const QString texte = renderFr(bloque.reason, routeur->descriptor().label);
|
||
QVERIFY2(texte.contains("soutirage", Qt::CaseInsensitive), qUtf8Printable("motif: " + texte));
|
||
QVERIFY2(texte.contains("branchement", Qt::CaseInsensitive), qUtf8Printable("motif: " + texte));
|
||
QVERIFY2(!texte.contains("surplus", Qt::CaseInsensitive), qUtf8Printable("motif: " + texte));
|
||
#endif
|
||
}
|
||
|
||
/*!
|
||
* \brief [ECS-415] Un refus doit se DISTINGUER d'une application, par la valeur de retour.
|
||
*
|
||
* \par Le défaut
|
||
* `SgReadyAdapter::applyAction()` renvoyait, en cas de refus par le verrou `minStateHold`,
|
||
* **l'action demandée telle quelle**. Un appelant qui relisait `state` y retrouvait l'état
|
||
* qu'il venait de demander, et concluait au succès — alors que rien n'avait été écrit et que
|
||
* les contacts n'avaient pas bougé. Deux autres sorties avaient le même travers (motif vide,
|
||
* état non déclaré), et les deux qui corrigeaient bien `state` (\c !m_usable, \c m_faulted)
|
||
* laissaient `estimatedPowerW` à la valeur de l'appelant.
|
||
*
|
||
* \par Pourquoi c'est plus grave qu'un délestage manqué
|
||
* N'IMPORTE QUELLE descente d'état pouvait échouer en silence, et `available` restait vrai :
|
||
* la charge avait l'air saine pendant que la PAC tirait 3 kW. C'est la famille d'ECS-111 —
|
||
* une issue indiscernable de son contraire — appliquée cette fois au retour d'une commande.
|
||
*
|
||
* \par Ce qui est épinglé
|
||
* Le refus et l'application se lisent DÉSORMAIS par le même test, et le retour décrit toujours
|
||
* l'état réellement tenu, puissance estimée comprise.
|
||
*/
|
||
void Simulation::testARefusedStateIsDistinguishableFromAnApplied()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testARefusedStateIsDistinguishableFromAnApplied nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-415.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
|
||
QUuid k1 = addPowerSwitch(0, 26696);
|
||
QUuid k2 = addPowerSwitch(0, 26697);
|
||
Thing *tK1 = tm->findConfiguredThing(k1);
|
||
Thing *tK2 = tm->findConfiguredThing(k2);
|
||
QVERIFY(tK1 && tK2);
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 8, 30), QTime(13, 0, 0));
|
||
const QHash<int, QList<QString>> encodage({ {1, {k1.toString()}}, {2, {}},
|
||
{3, {k2.toString()}},
|
||
{4, {k1.toString(), k2.toString()}} });
|
||
const QHash<int, double> estim({ {1, 0.0}, {2, 0.0}, {3, 1500.0}, {4, 3000.0} });
|
||
|
||
// Contacts fermés : l'adaptateur neuf LIT l'état 4 (ECS-411-b). Le verrou minStateHold est
|
||
// armé à froid (ECS-412), donc toute descente est refusée ce cycle.
|
||
tK1->setStateValue("power", true);
|
||
tK2->setStateValue("power", true);
|
||
SgReadyAdapter *pac = new SgReadyAdapter(tm, "pac-415", "PAC refus lisible",
|
||
encodage, estim, 300, 1, arb);
|
||
QCOMPARE(pac->currentState(), 4);
|
||
|
||
LoadAction descente;
|
||
descente.loadId = "pac-415";
|
||
descente.kind = LoadAction::State;
|
||
descente.state = 2;
|
||
descente.estimatedPowerW = 0; // ce que l'APPELANT croit — c'est le piège
|
||
descente.reason = { DecisionCode::SgNormal, {{"budgetW", 0}} };
|
||
|
||
// --- 1. REFUSÉ par le verrou : le retour doit décrire l'état TENU, pas l'état demandé ---
|
||
const LoadAction refuse = pac->applyAction(descente, t0);
|
||
QCOMPARE(refuse.state, 4);
|
||
QCOMPARE(qRound(refuse.estimatedPowerW), 3000);
|
||
QCOMPARE(tK1->stateValue("power").toBool(), true);
|
||
QCOMPARE(tK2->stateValue("power").toBool(), true);
|
||
QCOMPARE(pac->currentState(), 4);
|
||
|
||
// --- 2. FORCÉ : le même appel, verrou franchi, doit se lire autrement -------------------
|
||
// C'est la moitié qui donne son sens à la première : si les deux retours étaient égaux,
|
||
// épingler la valeur du refus ne prouverait rien.
|
||
LoadAction forcee = descente;
|
||
forcee.force = true;
|
||
const LoadAction applique = pac->applyAction(forcee, t0);
|
||
QCOMPARE(applique.state, 2);
|
||
QCOMPARE(qRound(applique.estimatedPowerW), 0);
|
||
QCOMPARE(tK1->stateValue("power").toBool(), false);
|
||
QCOMPARE(tK2->stateValue("power").toBool(), false);
|
||
QCOMPARE(pac->currentState(), 2);
|
||
|
||
// --- 3. IDEMPOTENCE : l'état est bien celui demandé, mais la puissance vient de
|
||
// l'adaptateur. Un appelant qui remonte à 4 avec 0 W dans son enveloppe ne doit pas
|
||
// se voir rendre son propre zéro sur une PAC qui en tirerait 3000.
|
||
LoadAction remonte;
|
||
remonte.loadId = "pac-415";
|
||
remonte.kind = LoadAction::State;
|
||
remonte.state = 2; // état DÉJÀ tenu
|
||
remonte.estimatedPowerW = 12345; // valeur absurde de l'appelant
|
||
remonte.reason = { DecisionCode::SgNormal, {{"budgetW", 0}} };
|
||
const LoadAction idem = pac->applyAction(remonte, t0.addSecs(1));
|
||
QCOMPARE(idem.state, 2);
|
||
QCOMPARE(qRound(idem.estimatedPowerW), 0);
|
||
#endif
|
||
}
|
||
|
||
/*!
|
||
* \brief [Étape 4] L4 déleste une PAC en la faisant CHANGER D'ÉTAT, et dit qu'il a forcé.
|
||
*
|
||
* \par Règles couvertes
|
||
* \rule{LM-1211} (délestage en ordre inverse du rang), \rule{LM-1211-a} (un seul commandeur par
|
||
* organe), \rule{LM-1211-b} (le franchissement se CONSTATE), \rule{LM-1211-d} (délester peut
|
||
* être changer d'état).
|
||
*
|
||
* \par Le premier cas où délester ne veut pas dire retirer des watts
|
||
* Une PAC ne se module pas. Le bornage de la cascade (\rule{LM-1210}) ne peut rien contre une
|
||
* PAC déjà en état 4 : il empêche d'ALLOUER au-delà du plafond, il ne retire rien à personne.
|
||
* Seul le délestage peut la faire redescendre — et c'est un changement d'ÉTAT.
|
||
*
|
||
* \par C'est aussi le cas où le franchissement de verrou prend son sens
|
||
* `minStateHold` est le verrou qui s'oppose à cette descente. Le plan le constate et publie
|
||
* `LOCK_MIN_STATE_HOLD` ; le disjoncteur, lui, n'attend pas 300 secondes. L4 franchit, et **le
|
||
* dit** — un verrou franchi en silence, c'est un compresseur qui court-cycle sans que personne
|
||
* ne sache pourquoi.
|
||
*
|
||
* \par Les trois cycles sont tous NÉCESSAIRES, et chacun a été mesuré avant d'être asserté
|
||
* Deux versions antérieures de ce test passaient **sans rien prouver**, parce que leurs
|
||
* prémisses étaient supposées. Elles sont donc vérifiées ici, une par une :
|
||
* 1. l'armement à froid (ECS-412) **gèle la PAC à l'état 2** — on ne peut pas monter à 4 au
|
||
* premier cycle, et croire le contraire faisait tester une PAC qui n'a jamais chauffé ;
|
||
* 2. une fois le verrou expiré, le surplus la porte en état 4 et **réarme** le verrou ;
|
||
* 3. c'est seulement là que le dépassement rencontre un verrou actif.
|
||
*/
|
||
void Simulation::testL4ShedsAPacAndSaysItForcedTheLock()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-shed.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QUuid k1 = addPowerSwitch(0, 26694);
|
||
QUuid k2 = addPowerSwitch(0, 26695);
|
||
Thing *relais1 = tm->findConfiguredThing(k1);
|
||
Thing *relais2 = tm->findConfiguredThing(k2);
|
||
QVERIFY(relais1 && relais2);
|
||
|
||
LoadConfigStore *store = new LoadConfigStore(arb);
|
||
arb->setLoadConfigStore(store);
|
||
|
||
const int minStateHoldS = 300;
|
||
LoadConfigs cfgs;
|
||
cfgs.append(LoadConfig::fromMap(QVariantMap{
|
||
{"id", "pac-delestee"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"},
|
||
{"priority", 2}, {"enabled", true}, {"domain", "hvac"},
|
||
{"sgReady", QVariantMap{
|
||
{"minStateHoldS", minStateHoldS},
|
||
{"states", QVariantList()
|
||
<< QVariantMap{{"state", 1}, {"relays", QVariantList() << k1.toString()},
|
||
{"estimatedPowerW", 0.0}}
|
||
<< QVariantMap{{"state", 2}, {"relays", QVariantList()},
|
||
{"estimatedPowerW", 0.0}}
|
||
<< QVariantMap{{"state", 3}, {"relays", QVariantList() << k2.toString()},
|
||
{"estimatedPowerW", 800.0}}
|
||
<< QVariantMap{{"state", 4},
|
||
{"relays", QVariantList() << k1.toString() << k2.toString()},
|
||
{"estimatedPowerW", 3000.0}}}}}}));
|
||
QString err;
|
||
QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8());
|
||
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit",
|
||
QVariantMap({{"phasePowerLimit", 25}})).toMap()
|
||
.value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
auto cycle = [&](const QDateTime &at) {
|
||
arb->simulationCallUpdate(at);
|
||
QTest::qWait(50);
|
||
QCoreApplication::processEvents();
|
||
return telemetrieParId(arb->loadTelemetry()).value("pac-delestee");
|
||
};
|
||
|
||
// Date FIXE, et elle le redevient : `setPhasePowerLimit()` lançait un cycle d'arbitrage
|
||
// complet à l'horloge murale, faute de la garde ENERGY_SIMULATION que ses voisins ont.
|
||
// L'armement paresseux (ECS-412) figeait alors `m_lastSwitch` sur l'heure qu'il était, et
|
||
// ce scénario se déroulait « avant » son propre armement — il passait ou échouait SELON
|
||
// L'HEURE DE LA JOURNÉE. La garde rendue, une date fixe redevient préférable : elle rend
|
||
// les journaux reproductibles.
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 8, 30), QTime(13, 0, 0));
|
||
meter->setStateValue("currentPhaseA", 0);
|
||
meter->setStateValue("currentPhaseB", 0);
|
||
meter->setStateValue("currentPhaseC", 0);
|
||
meter->setStateValue("currentPower", -6000);
|
||
QVariantMap pac = cycle(t0);
|
||
// PRÉMISSE 1 — l'armement à froid gèle la PAC à l'état 2. Le surplus est pléthorique et
|
||
// n'y change rien : c'est ECS-412, et c'est ce que l'app verra après chaque redémarrage.
|
||
QCOMPARE(pac.value("mechanism").toMap().value("state").toInt(), 2);
|
||
QCOMPARE(pac.value("decision").toMap().value("code").toString(),
|
||
QString(DecisionCode::LockMinStateHold));
|
||
|
||
const QDateTime t1 = t0.addSecs(3600); // largement au-delà de l'armement à froid
|
||
pac = cycle(t1);
|
||
// PRÉMISSE 2 — verrou expiré, le surplus porte la PAC en état 4, et le verrou SE RÉARME.
|
||
// Sans une PAC réellement à 3 kW, le délestage n'aurait rien à retirer.
|
||
QCOMPARE(pac.value("mechanism").toMap().value("state").toInt(), 4);
|
||
QCOMPARE(pac.value("decision").toMap().value("code").toString(), QString(DecisionCode::SgForced));
|
||
QCOMPARE(relais1->stateValue("power").toBool(), true);
|
||
QCOMPARE(relais2->stateValue("power").toBool(), true);
|
||
QCOMPARE(pac.value("lock").toMap().value("remainingS").toInt(), minStateHoldS);
|
||
|
||
const QDateTime t2 = t1.addSecs(10);
|
||
meter->setStateValue("currentPhaseA", 30);
|
||
meter->setStateValue("currentPower", 6900);
|
||
pac = cycle(t2);
|
||
|
||
// 1. LA PAC EST REDESCENDUE, et seul L4 pouvait le faire : à t2 le verrou court encore.
|
||
QCOMPARE(pac.value("mechanism").toMap().value("state").toInt(), 2);
|
||
QCOMPARE(relais1->stateValue("power").toBool(), false);
|
||
QCOMPARE(relais2->stateValue("power").toBool(), false);
|
||
|
||
// 2. Le motif publié est celui du DÉLESTAGE, pas celui du plan. La charge utile doit dire
|
||
// ce qui a été exécuté : le plan n'a pas commandé cette charge — L4 l'a REVENDIQUÉE et
|
||
// le dispatch l'a sautée. Publier LOCK_MIN_STATE_HOLD montrerait une PAC « maintenue »
|
||
// que le matériel vient justement de couper.
|
||
const QVariantMap decision = pac.value("decision").toMap();
|
||
QCOMPARE(decision.value("code").toString(), QString(DecisionCode::DrawCap));
|
||
|
||
const QVariantMap p = decision.value("params").toMap();
|
||
QCOMPARE(p.value("shed").toBool(), true);
|
||
QCOMPARE(p.value("source").toString(), QString("connection"));
|
||
QCOMPARE(p.value("phase").toString(), QString("A"));
|
||
QCOMPARE(p.value("overshootW").toInt(), 1150);
|
||
QCOMPARE(p.value("marginW").toInt(), -1150);
|
||
// Les watts RÉELLEMENT rendus : la puissance allouée de l'état quitté.
|
||
QCOMPARE(p.value("shedW").toInt(), 3000);
|
||
|
||
// 3. LE FRANCHISSEMENT SE DIT, et il a été CONSTATÉ, jamais présumé : le délestage tente
|
||
// d'abord SANS forcer et ne pose ce marqueur que si le verrou a réellement refusé.
|
||
// Le déduire d'un verrou simplement ACTIF serait faux — un verrou peut être armé et
|
||
// permettre malgré tout le geste demandé. Le marqueur annoncerait alors un court-cycle
|
||
// qui n'a pas eu lieu, et le premier compresseur réellement maltraité passerait
|
||
// inaperçu au milieu des fausses alertes.
|
||
//
|
||
// Ce constat n'est possible que depuis \rule{ECS-415} : tant que le refus renvoyait
|
||
// l'action DEMANDÉE, il se lisait comme un succès, et le délestage n'a jamais forcé —
|
||
// il publiait 3 kW rendus par une PAC restée en état 4.
|
||
QCOMPARE(p.value("minOnBypassed").toBool(), true);
|
||
|
||
// 4. Le TEXTE nomme la ressource et la source, jamais le surplus : ici il n'y en a pas, et
|
||
// accuser le surplus enverrait attendre le soleil devant un disjoncteur qui sature.
|
||
DecisionReason motif(DecisionCode::DrawCap, p);
|
||
const QString texte = renderFr(motif, "PAC");
|
||
QVERIFY2(texte.contains("soutirage", Qt::CaseInsensitive), qUtf8Printable("motif: " + texte));
|
||
QVERIFY2(texte.contains("branchement", Qt::CaseInsensitive), qUtf8Printable("motif: " + texte));
|
||
QVERIFY2(!texte.contains("surplus", Qt::CaseInsensitive), qUtf8Printable("motif: " + texte));
|
||
#endif
|
||
}
|
||
|
||
/*!
|
||
* \brief [§10 étape B] `draw` publie l'autorisation — et la RÉSERVE BATTERIE ne l'annule pas.
|
||
*
|
||
* \par Les quatre champs se publient parce qu'ils EXISTENT
|
||
* `authorisedW`, `remainingW`, `binding` et `perPhaseBound` étaient absents parce qu'aucun
|
||
* plafond de soutirage n'existait. Le lot délestage en a créé un ; ils se LISENT désormais, au
|
||
* lieu d'être forgés. Leur condition de présence est donc « un plafond est en vigueur », et non
|
||
* « le transport du §14a existe » — le refus d'origine visait la forge, pas la lecture.
|
||
*
|
||
* \par Ce que ce test épingle vraiment : \rule{LM-1203-b} vue depuis la frontière
|
||
* Sous la réserve batterie, le budget de surplus est **annulé** — pas réduit. Mais la réserve
|
||
* n'annule QUE le surplus : l'**autorisation de soutirage** est une contrainte physique du
|
||
* branchement, et aucune règle de stockage ne la modifie. Un client verra donc
|
||
* `budget.remainingW == 0` **et** `draw.remainingW != 0` dans la même trame, et il aura raison
|
||
* de le signaler si personne ne le lui a dit. C'est dit — ici, et dans `INTERFACE.md`.
|
||
*
|
||
* \warning **Ce test devient faux** le jour où la réserve batterie borne aussi le soutirage —
|
||
* c'est-à-dire le jour où l'on déciderait qu'une batterie basse interdit d'acheter au réseau.
|
||
* Ce serait une décision de modèle, pas une correction : c'est alors l'assertion 3 qu'il faut
|
||
* réécrire, pas le moteur.
|
||
*/
|
||
void Simulation::testDrawAuthorisationSurvivesTheBatteryReserve()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
RuleBasedScheduler sched(arb);
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 8, 31), QTime(13, 0, 0));
|
||
|
||
auto planifier = [&](bool batterieBasse, bool avecPlafond) {
|
||
SurplusContext ctx;
|
||
ctx.timestamp = t0;
|
||
ctx.meter.exportW = 5000; // 5 kW de surplus disponibles
|
||
if (avecPlafond) {
|
||
ctx.drawMargin.borne = true;
|
||
ctx.drawMargin.watts = 3450; // 25 A − 10 A tirés, sur 230 V
|
||
ctx.drawMargin.source = DrawCapSource::Breaker;
|
||
ctx.drawMargin.phase = 0; // phase A : plafond PAR PHASE
|
||
}
|
||
if (batterieBasse) {
|
||
ctx.battery.present = true;
|
||
ctx.battery.socPercent = 10; // sous le seuil par défaut (20 %)
|
||
}
|
||
return sched.getPlan(ctx);
|
||
};
|
||
|
||
// 1. SANS plafond réglé : les quatre champs restent SANS OBJET. `capBorne` est ce qui
|
||
// décide de leur présence à la frontière — et il ne se déduit pas d'une valeur nulle.
|
||
const Plan sansPlafond = planifier(false, false);
|
||
QCOMPARE(sansPlafond.draw.capBorne, false);
|
||
|
||
// 2. AVEC plafond, hors réserve : l'autorisation se lit, rien n'est engagé, tout reste.
|
||
const Plan nominal = planifier(false, true);
|
||
QCOMPARE(nominal.draw.capBorne, true);
|
||
QCOMPARE(qRound(nominal.draw.authorisedW), 3450);
|
||
QCOMPARE(qRound(nominal.draw.committedW), 0);
|
||
QCOMPARE(qRound(nominal.draw.remainingW), 3450);
|
||
QCOMPARE(nominal.draw.bindingCode, QString("connection"));
|
||
QCOMPARE(nominal.draw.perPhaseBound, true);
|
||
// L'identité `authorisedW − committedW == remainingW` N'EST PAS ASSERTÉE ICI, et c'est
|
||
// délibéré : le moteur l'établit sur UNE LIGNE (`rulebasedscheduler.cpp`, `draw.remainingW =
|
||
// draw.authorisedW - draw.committedW`). L'asserter reviendrait à relire cette affectation —
|
||
// les deux membres viennent de la même source, l'identité se referme sur elle-même, et le
|
||
// contrôle n'attraperait rien.
|
||
//
|
||
// Ce qui contrôle réellement, ce sont les TROIS attentes littérales ci-dessus : elles
|
||
// viennent du scénario, pas du moteur. Relevé au balayage du 2026-09-03.
|
||
//
|
||
// ⚠️ À ne pas confondre avec l'identité du BUDGET (`surplus + recrédit − alloué ==
|
||
// restant`), qui, elle, croise DEUX calculs indépendants — `remainingSurplusW` filé le long
|
||
// de la cascade contre `allocatedW` accumulé en trois sites distincts. Celle-là attrape un
|
||
// site qui décrémenterait sans accumuler, et mérite d'être assertée.
|
||
|
||
// 3. SOUS LA RÉSERVE — le cœur du test. Le surplus est ANNULÉ, l'autorisation INTACTE.
|
||
const Plan sousReserve = planifier(true, true);
|
||
QCOMPARE(qRound(sousReserve.budget.remainingW), 0); // la réserve annule le surplus…
|
||
QCOMPARE(qRound(sousReserve.draw.remainingW), 3450); // …et ne touche pas au soutirage
|
||
QCOMPARE(sousReserve.draw.bindingCode, QString("connection"));
|
||
|
||
// 4. Et un plafond TOTAL se distingue d'un plafond par phase. Sans cette distinction, un
|
||
// installateur devant une triphasée irait mesurer une phase que rien ne désigne.
|
||
SurplusContext ctxTotal;
|
||
ctxTotal.timestamp = t0;
|
||
ctxTotal.meter.exportW = 5000;
|
||
ctxTotal.drawMargin.borne = true;
|
||
ctxTotal.drawMargin.watts = 2000;
|
||
ctxTotal.drawMargin.source = DrawCapSource::GridOperator;
|
||
ctxTotal.drawMargin.phase = -1; // total : aucune phase coupable
|
||
const Plan total = sched.getPlan(ctxTotal);
|
||
QCOMPARE(total.draw.perPhaseBound, false);
|
||
QCOMPARE(total.draw.bindingCode, QString("gridOperator"));
|
||
#endif
|
||
}
|
||
|
||
/*!
|
||
* \brief [§10 étape C] L'avancement est une DIFFÉRENCE depuis une base, et le régime se publie.
|
||
*
|
||
* \par Règles couvertes
|
||
* \rule{LM-1012-b} (l'avancement est une différence), \rule{LM-1012-b-i} (base à l'ouverture de
|
||
* l'obligation), \rule{LM-1012-b-ii} (recapture sur recul franc), \rule{LM-1014-c} (le cumul
|
||
* survit au changement de RÉGIME), \rule{LM-1014-c-i} (remise à zéro à la période),
|
||
* \rule{LM-1014-c-ii} (\c measuredSince garde le premier instant).
|
||
*
|
||
* \par Le piège, mesuré sur le banc le 2026-08-31
|
||
* `sessionEnergy` est l'ACCUMULATEUR du plugin V2C, pas un compteur remis à zéro pour nous :
|
||
* l'écart avec `chargeEnergy` est constant au dix-millième (7,2044 kWh sur deux échantillons à
|
||
* deux heures d'écart), parce qu'il porte les incréments d'avant la dernière remise à zéro de la
|
||
* borne. **Le lire directement ferait croire toute obligation tenue de 7,2 kWh dès le premier
|
||
* cycle** — un nombre crédible et faux que rien ne signalerait.
|
||
*
|
||
* \par Règle couverte
|
||
* \rule{LM-1012-c} — le RÉGIME d'avancement se publie, porté par le niveau, et
|
||
* \c deliveredWh est absent sous \c unmeasurable plutôt que nul.
|
||
*
|
||
* \par Les deux questions que la base pose, et leurs réponses
|
||
* 1. **Quand est-elle prise ?** À l'ouverture de l'OBLIGATION, jamais au démarrage de nymead :
|
||
* sinon un redémarrage en pleine session réinitialise l'avancement sur une valeur déjà
|
||
* accumulée, et la recharge de nuit relue au matin repart de zéro.
|
||
* 2. **Que devient-elle si `sessionWh` recule ?** Le plugin remet son accumulateur à la valeur
|
||
* brute à chaque nouvelle session : la base est RECAPTURÉE, sinon la différence devient
|
||
* négative. On ne refait pas la détection de remise à zéro de la BORNE — elle vit dans le
|
||
* plugin, à 30 s, là où le moteur travaille à la minute et raterait des cycles entiers.
|
||
*
|
||
* \warning **Ce test devient faux** si `sessionEnergy` cesse d'être un accumulateur de plugin
|
||
* pour devenir un compteur remis à zéro par session : la recapture serait alors sans objet, et
|
||
* c'est l'assertion 4 qu'il faudrait retirer, pas le moteur.
|
||
*/
|
||
void Simulation::testEcoProgressBaseAndRegime()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 8, 31), QTime(13, 0, 0));
|
||
LoadContext lc;
|
||
lc.id = "borne-eco";
|
||
lc.needs.minEnergyWhPerDay = 10000; // obligation : 10 kWh par jour
|
||
|
||
// 1. SANS obligation déclarée, il n'y a rien à publier — pas un avancement à zéro. Une
|
||
// charge sans obligation éco ne porte pas d'entrée « eco » : l'absence EST l'information.
|
||
LoadContext sansObligation = lc;
|
||
sansObligation.needs.minEnergyWhPerDay = 0;
|
||
QVERIFY2(arb->ecoProgress(sansObligation, t0).regime.isEmpty(),
|
||
"sans obligation, aucun régime : le niveau éco n'existe pas");
|
||
|
||
// 2. Borne qui ne COMPTE PAS : régime `unmeasurable`, et `deliveredWh` SANS OBJET.
|
||
// C1 de LM-1009 — une dégradation ne doit jamais se lire comme un avancement nul, sinon
|
||
// l'échéance continue de s'afficher comme si elle tenait.
|
||
LoadContext nonMesurable = lc;
|
||
nonMesurable.telemetry.sessionMeasurable = false;
|
||
nonMesurable.telemetry.sessionWh = 7204; // valeur présente mais SANS SIGNIFICATION
|
||
const EcoProgress sansMesure = arb->ecoProgress(nonMesurable, t0);
|
||
QCOMPARE(sansMesure.regime, QString(ProgressRegime::Unmeasurable));
|
||
QCOMPARE(sansMesure.delivered, false);
|
||
QVERIFY2(qFuzzyIsNull(sansMesure.deliveredWh),
|
||
"sous unmeasurable, deliveredWh n'est pas publié — et vaut donc 0 en interne");
|
||
|
||
// 3. LE PIÈGE LUI-MÊME. L'accumulateur vaut déjà 7204 Wh à l'ouverture de l'obligation.
|
||
// L'avancement doit être NUL, pas 7204 : la base est prise ICI.
|
||
LoadContext mesuree = lc;
|
||
mesuree.telemetry.sessionMeasurable = true;
|
||
mesuree.telemetry.sessionWh = 7204;
|
||
const EcoProgress ouverture = arb->ecoProgress(mesuree, t0);
|
||
QCOMPARE(ouverture.regime, QString(ProgressRegime::Measured));
|
||
QCOMPARE(ouverture.delivered, true);
|
||
QCOMPARE(qRound(ouverture.deliveredWh), 0);
|
||
QCOMPARE(qRound(ouverture.targetWh), 10000);
|
||
|
||
// …et l'avancement suit la DIFFÉRENCE, pas la valeur lue.
|
||
mesuree.telemetry.sessionWh = 9204;
|
||
QCOMPARE(qRound(arb->ecoProgress(mesuree, t0).deliveredWh), 2000);
|
||
|
||
// 4. RECUL FRANC : le plugin a remis son accumulateur à la valeur brute d'une nouvelle
|
||
// session. La base est recapturée — sans quoi la différence serait NÉGATIVE.
|
||
mesuree.telemetry.sessionWh = 120;
|
||
QCOMPARE(qRound(arb->ecoProgress(mesuree, t0).deliveredWh), 0);
|
||
mesuree.telemetry.sessionWh = 620;
|
||
QCOMPARE(qRound(arb->ecoProgress(mesuree, t0).deliveredWh), 500);
|
||
|
||
// 5. Recul INFIME : c'est du bruit de lecture, pas une nouvelle session. La base ne bouge
|
||
// pas — la recapturer ici perdrait l'avancement acquis à chaque frémissement du compteur.
|
||
mesuree.telemetry.sessionWh = 610;
|
||
const EcoProgress bruit = arb->ecoProgress(mesuree, t0);
|
||
QCOMPARE(qRound(bruit.deliveredWh), 490);
|
||
QVERIFY2(bruit.deliveredWh >= 0, "un avancement ne peut pas être négatif");
|
||
|
||
// 7. BASCULE DE PÉRIODE — le cas que la sonde a révélé, et qu'aucune lecture du code
|
||
// n'aurait montré. À l'instant de l'échéance quotidienne, une NOUVELLE période s'ouvre.
|
||
// Sans reprise de la base ici, le lendemain hériterait de l'énergie de la veille et se
|
||
// croirait tenu sans avoir rien reçu.
|
||
LoadContext quotidienne;
|
||
quotidienne.id = "borne-quotidienne";
|
||
quotidienne.needs.minEnergyWhPerDay = 2000;
|
||
quotidienne.needs.dailyDeadline = "07:00";
|
||
quotidienne.telemetry.sessionMeasurable = true;
|
||
|
||
const QDateTime veille = utcDateTime(QDate(2026, 8, 31), QTime(6, 0, 0)); // vise 07:00 J
|
||
const QDateTime apres = utcDateTime(QDate(2026, 8, 31), QTime(7, 30, 0)); // vise 07:00 J+1
|
||
|
||
quotidienne.telemetry.sessionWh = 1000;
|
||
QCOMPARE(qRound(arb->ecoProgress(quotidienne, veille).deliveredWh), 0); // base prise ici
|
||
quotidienne.telemetry.sessionWh = 2800;
|
||
QCOMPARE(qRound(arb->ecoProgress(quotidienne, veille).deliveredWh), 1800);
|
||
|
||
// L'échéance a basculé : la période est neuve, l'avancement repart de zéro — l'énergie de
|
||
// la veille n'est pas portée au crédit du jour suivant.
|
||
QCOMPARE(qRound(arb->ecoProgress(quotidienne, apres).deliveredWh), 0);
|
||
quotidienne.telemetry.sessionWh = 3300;
|
||
QCOMPARE(qRound(arb->ecoProgress(quotidienne, apres).deliveredWh), 500);
|
||
|
||
// 8. BASCULE DE RÉGIME DANS LA MÊME PÉRIODE — measured → unmeasurable → measured.
|
||
// C'est le motif d'une V2C dont le lien Modbus bat : chaque perte et retour fait
|
||
// basculer le régime DEUX fois. L'énergie livrée pendant les tronçons mesurés
|
||
// antérieurs ne doit pas disparaître — sinon l'écran sous-estime, et surtout la passe
|
||
// éco RACHÈTE ce qui a déjà été livré.
|
||
LoadContext bat;
|
||
bat.id = "borne-intermittente";
|
||
bat.needs.minEnergyWhPerDay = 4000;
|
||
bat.needs.dailyDeadline = "07:00";
|
||
|
||
bat.telemetry.sessionMeasurable = true;
|
||
bat.telemetry.sessionWh = 1000;
|
||
QCOMPARE(qRound(arb->ecoProgress(bat, t0).deliveredWh), 0); // base prise ici
|
||
bat.telemetry.sessionWh = 2500;
|
||
QCOMPARE(qRound(arb->ecoProgress(bat, t0).deliveredWh), 1500); // 1500 Wh livrés
|
||
|
||
// Le lien tombe : plus rien ne mesure.
|
||
bat.telemetry.sessionMeasurable = false;
|
||
QCOMPARE(arb->ecoProgress(bat, t0).regime, QString(ProgressRegime::Unmeasurable));
|
||
|
||
// Le lien revient, dans la MÊME période. Les 1500 Wh déjà livrés doivent SURVIVRE.
|
||
bat.telemetry.sessionMeasurable = true;
|
||
bat.telemetry.sessionWh = 2500;
|
||
QCOMPARE(qRound(arb->ecoProgress(bat, t0).deliveredWh), 1500);
|
||
bat.telemetry.sessionWh = 3000;
|
||
const EcoProgress reprise = arb->ecoProgress(bat, t0);
|
||
QCOMPARE(qRound(reprise.deliveredWh), 2000); // 1500 (tronçon clos) + 500 (en cours)
|
||
|
||
// TRANCHÉ — `measuredSince` garde le PREMIER instant mesuré, et l'intermittence se dit à
|
||
// part. Redater à chaque retour effacerait l'histoire au lieu de la raconter ; ne rien dire
|
||
// ferait croire à une continuité que le battement dément. L'écran peut alors écrire
|
||
// « mesuré PAR INTERMITTENCE depuis 09:14 », qui est la phrase vraie.
|
||
QCOMPARE(reprise.measuredSince, t0);
|
||
QCOMPARE(reprise.measuredGaps, 1);
|
||
|
||
// TRANCHÉ — à la BASCULE DE PÉRIODE, le cumul repart de zéro comme tout ce qui est rapporté
|
||
// à la période (LM-1014-b), et les trous avec lui : ils comptaient les interruptions de la
|
||
// période close, pas de la neuve.
|
||
// Au-delà de l'échéance visée depuis t0 (2026-08-31 13:00 → 2026-09-01 07:00) : c'est ce
|
||
// franchissement qui ouvre une période neuve. Un instant qui viserait la MÊME échéance ne
|
||
// ferait rien basculer — première version de ce test, qui échouait pour cette raison.
|
||
const QDateTime apresEcheance = utcDateTime(QDate(2026, 9, 1), QTime(8, 0, 0));
|
||
const EcoProgress neuve = arb->ecoProgress(bat, apresEcheance);
|
||
QCOMPARE(qRound(neuve.deliveredWh), 0);
|
||
QCOMPARE(neuve.measuredGaps, 0);
|
||
|
||
// 6. L'obligation retirée LIBÈRE la base : une nouvelle obligation repart d'un repère neuf,
|
||
// et n'hérite pas de l'avancement d'une obligation qui n'existe plus.
|
||
LoadContext retiree = mesuree;
|
||
retiree.needs.minEnergyWhPerDay = 0;
|
||
arb->ecoProgress(retiree, t0);
|
||
mesuree.telemetry.sessionWh = 5000;
|
||
QCOMPARE(qRound(arb->ecoProgress(mesuree, t0).deliveredWh), 0);
|
||
#endif
|
||
}
|
||
|
||
/*!
|
||
* \brief [§10 étape D] La passe ÉCO achète, et le motif dit la TRAJECTOIRE.
|
||
*
|
||
* \par Règles couvertes
|
||
* \rule{LM-1002} (deux passes sur la même liste ordonnée), \rule{LM-1007} (une charge sans
|
||
* obligation ne porte pas d'entrée « eco » — l'absence EST l'information), \rule{LM-1013-b}
|
||
* (l'étalement uniforme), \rule{LM-1210} (l'autorisation borne la cascade).
|
||
*
|
||
* \par Deux passes, une commande
|
||
* Même liste, même ordre ; ce qui change est la BORNE — l'autorisation de soutirage pour l'éco,
|
||
* le reliquat de surplus pour le confort. Les deux niveaux sont publiés, mais **une seule
|
||
* commande** part au matériel (\rule{LM-1003}) : émettre deux fois ferait commuter un relais en
|
||
* passe 1 puis le recommuter en passe 2, du court-cycling fabriqué par l'arbitre lui-même.
|
||
*
|
||
* \par Ce que le motif doit permettre, et pourquoi
|
||
* `ECO_FLOOR_GRID` est lu le matin où la facture monte. Les watts de l'instant n'y suffisent
|
||
* pas : la cadence fait un mur — 333 W à six heures de l'échéance, le plafond dès seize minutes.
|
||
* Un client qui lit « 1000 W achetés » ne peut pas deviner ce qu'il paiera dans deux heures ;
|
||
* avec le RESTE à livrer et le TEMPS restant, il le peut.
|
||
*/
|
||
void Simulation::testAModeWriteEndsTheDerogationAndKeepsTheChoice()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// \rule{LM-1500-g} — écrire un mode pendant une dérogation la TERMINE, et sans restituer.
|
||
//
|
||
// Le cas vient du banc, le 2026-09-08 : le bouton « boost » de l'app écrit encore
|
||
// ChargingModeNormal. Reçu pendant une dérogation, il faisait restituer les valeurs
|
||
// manuelles par l'amont — `chargingEnabled` à faux — et armait un verrou de 300 s, pendant
|
||
// que la dérogation continuait de commander 9 A. Deux commandeurs pour un organe, et six
|
||
// minutes de dérogation servie sur le papier dans une borne éteinte.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
|
||
const QUuid chargerId = addCharger(QStringLiteral("ABC"));
|
||
QVERIFY(!chargerId.isNull());
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 9, 8), QTime(9, 0, 0));
|
||
auto cycle = [&](const QDateTime &now) {
|
||
arb->simulationCallUpdate(now);
|
||
QCoreApplication::processEvents();
|
||
};
|
||
cycle(t0); // syncAdapters() crée l'EvAdapter
|
||
Thing *borne = tm->findConfiguredThing(ThingId(chargerId.toString()));
|
||
QVERIFY(borne);
|
||
borne->setStateValue("pluggedIn", true);
|
||
|
||
const QString loadId = ThingId(chargerId.toString()).toString();
|
||
auto ecrireMode = [&](ChargingInfo::ChargingMode m) {
|
||
ChargingInfo ci = arb->chargingInfo(ThingId(chargerId.toString()));
|
||
ci.setChargingMode(m);
|
||
arb->setChargingInfo(ci);
|
||
};
|
||
auto modeCourant = [&]() {
|
||
return arb->chargingInfo(ThingId(chargerId.toString())).chargingMode();
|
||
};
|
||
|
||
// ---- 1. CONTRE-ÉPREUVE : sans écriture de mode, la dérogation SURVIT au cycle ----------
|
||
// Sans elle, la fin du cas 2 pourrait venir de n'importe quoi d'autre.
|
||
ecrireMode(ChargingInfo::ChargingModeEco);
|
||
QVERIFY(arb->poseDerogation(loadId, 600, 0, t0));
|
||
QCOMPARE(arb->derogations().count(), 1);
|
||
cycle(t0.addSecs(60));
|
||
QCOMPARE(arb->derogations().count(), 1);
|
||
QCOMPARE(modeCourant(), ChargingInfo::ChargingModeEco);
|
||
|
||
// ---- 2. UNE ÉCRITURE DE MODE LA TERMINE ------------------------------------------------
|
||
// Choisir un mode est une décision DURABLE ; une dérogation répond à un besoin
|
||
// ponctuel. Le durable prime, et il prime tout de suite.
|
||
ecrireMode(ChargingInfo::ChargingModeEcoWithMinCurrent);
|
||
cycle(t0.addSecs(120));
|
||
QVERIFY2(arb->derogations().isEmpty(), "une écriture de mode doit terminer la dérogation");
|
||
|
||
// ---- 3. …ET LE CHOIX DE L'UTILISATEUR EST GARDÉ ----------------------------------------
|
||
// C'est ici que le test mord : le mode MÉMORISÉ était « Eco », et le restituer aurait
|
||
// écrasé le « Surplus + minimum réseau » que l'utilisateur vient de choisir. On lui
|
||
// reprendrait sa décision au nom de la nôtre, un tour de boucle plus tard, sans qu'il
|
||
// puisse le voir.
|
||
QCoreApplication::processEvents(); // laisse passer une restitution différée
|
||
QCOMPARE(modeCourant(), ChargingInfo::ChargingModeEcoWithMinCurrent);
|
||
|
||
// ---- 4. La fin par DURÉE, elle, restitue bien ------------------------------------------
|
||
// Contre-épreuve de la contre-épreuve : si la restitution ne marchait jamais, le cas 3
|
||
// passerait pour une bonne raison qui n'existe pas.
|
||
ecrireMode(ChargingInfo::ChargingModeNormal);
|
||
QVERIFY(arb->poseDerogation(loadId, 60, 0, t0.addSecs(120)));
|
||
QCOMPARE(modeCourant(), ChargingInfo::ChargingModeEco); // forcée en éco pour la durée
|
||
cycle(t0.addSecs(300)); // bien au-delà des 60 s
|
||
QVERIFY(arb->derogations().isEmpty());
|
||
QCoreApplication::processEvents(); // la restitution est différée
|
||
QCOMPARE(modeCourant(), ChargingInfo::ChargingModeNormal); // le mode MÉMORISÉ, pas un défaut
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testTheSnapshotServesTheChannelNeverTheTable()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// GetPvForecast sert la TABLE ; GetForecastSnapshot sert la DÉCISION. Un arbitre qui lirait
|
||
// la table lirait sa propre trace au lieu de son entrée : il ne mesurerait plus, il se
|
||
// relirait. Ce test tient la frontière, et les deux absences qui l'accompagnent.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
|
||
QString motif; QVariantMap params;
|
||
|
||
// 1. AUCUN CANDIDAT, AUCUN SITE — SILENCE. Ni courbe, ni motif : une installation sans
|
||
// plugin météo n'a rien à désigner, et lui annoncer un manque serait un avertissement
|
||
// qu'aucun geste ne peut lever.
|
||
QVERIFY(arb->setForecastSiteId(QString()));
|
||
const ForecastRevision muet = arb->forecastSnapshot(motif, params);
|
||
QVERIFY2(muet.points.isEmpty(), "sans canal, aucune courbe");
|
||
QVERIFY2(motif.isEmpty(), "et AUCUN motif : le silence ne porte que l'ignorance");
|
||
|
||
// 2. LA RÉPONSE RPC PORTE TOUJOURS capturedAt, même vide. C'est ce qui distingue « le moteur
|
||
// n'a pas répondu » de « le moteur a répondu, et il n'a rien » — deux états qu'une
|
||
// réponse vide confondrait.
|
||
const QVariantMap rep = injectAndWait("NymeaEnergy.GetForecastSnapshot")
|
||
.toMap().value("params").toMap();
|
||
QVERIFY2(rep.contains(QStringLiteral("capturedAt")),
|
||
"capturedAt date la RÉPONSE, pas la prévision — il est toujours là");
|
||
QVERIFY2(!rep.contains(QStringLiteral("forecastPoints")),
|
||
"une courbe absente est ABSENTE, jamais une liste de zéros");
|
||
|
||
// 3. CONTRE-ÉPREUVE de la frontière — la table peut contenir une courbe SANS que le
|
||
// snapshot en rende une. C'est le cœur : le snapshot lit le canal, pas la table. Si un
|
||
// jour quelqu'un le rebranche sur le magasin, cette ligne tombera.
|
||
ForecastStore *magasin = arb->forecastStore();
|
||
QVERIFY(magasin && magasin->isValid());
|
||
ForecastRevision fabriquee;
|
||
fabriquee.siteId = QStringLiteral("site-fantome");
|
||
fabriquee.issuedAt = QDateTime::currentDateTimeUtc();
|
||
ForecastPoint p;
|
||
p.targetAt = QDateTime::currentDateTimeUtc().addSecs(3600);
|
||
p.wh = 4242;
|
||
fabriquee.points.append(p);
|
||
QVERIFY2(magasin->enregistrer(fabriquee), "la table accepte bien cette courbe");
|
||
QVERIFY2(!magasin->courbe(p.targetAt.addSecs(-60), p.targetAt.addSecs(60)).isEmpty(),
|
||
"et la table la rend — la contre-épreuve n'est donc pas vide de sens");
|
||
|
||
const ForecastRevision apres = arb->forecastSnapshot(motif, params);
|
||
QVERIFY2(apres.points.isEmpty(),
|
||
"le snapshot ne doit RIEN rendre : la table n'est pas son entrée");
|
||
QVERIFY(motif.isEmpty());
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testSpotMarketIsOutOfTheBuild()
|
||
{
|
||
// BRIEF 3, T0 — le fournisseur spot aWATTar est HORS du build livré (invariant 10).
|
||
//
|
||
// C'était le seul flux sortant du moteur. La source reste dans l'arbre (synchronisation
|
||
// amont) mais n'est compilée qu'avec CONFIG+=etm_spotmarket. Ce test tient les trois
|
||
// promesses du build SANS l'option : aucun objet réseau, aucune activation possible, et un
|
||
// refus qui DIT pourquoi — par un code, pas par une phrase.
|
||
if (SpotMarketBuild::inclus())
|
||
QSKIP("build CONFIG+=etm_spotmarket : le fournisseur aWATTar est compilé, rien à vérifier ici.");
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
// 1. AUCUN QNetworkAccessManager. L'expérience de simulation construit son gestionnaire spot
|
||
// par SpotMarketBuild::creer(), le MÊME point que EnergyPluginNymea::init() : c'est donc
|
||
// le chemin de production qui est vérifié, pas une copie.
|
||
QVERIFY2(m_experiencePlugin->findChildren<QNetworkAccessManager *>().isEmpty(),
|
||
"un QNetworkAccessManager a été construit alors que le build n'a aucun fournisseur");
|
||
|
||
SpotMarketManager *spot = m_experiencePlugin->spotMarketManager();
|
||
QVERIFY(spot);
|
||
QVERIFY2(spot->availableProviders().isEmpty(), "aucun fournisseur ne doit être enregistré");
|
||
|
||
// 2. L'ACTIVATION EST REFUSÉE — et sans plantage : avant [upstream-fix], setEnabled(true) sur
|
||
// une liste vide déréférençait un fournisseur nul. C'est le chemin d'une box dont
|
||
// energy.conf porte encore SpotMarket/enabled=true d'un build antérieur.
|
||
spot->setEnabled(true);
|
||
QVERIFY2(!spot->enabled(), "le marché spot s'est activé sans fournisseur");
|
||
QVERIFY(!spot->available());
|
||
|
||
// 3. planSpotMarketCharging() est INERTE : sa première ligne rend la main sur !enabled(), et
|
||
// le point 2 établit que enabled() ne peut pas devenir vrai. On l'appelle quand même, pour
|
||
// que le jour où cette garde bouge, ce test passe par le chemin.
|
||
#ifdef ETM_ARBITRATOR
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
arb->runSpotMarketPlanning(QDateTime::currentDateTimeUtc());
|
||
for (auto it = arb->scheduledActions().constBegin(); it != arb->scheduledActions().constEnd(); ++it)
|
||
QVERIFY2(!it.value().contains(ChargingAction::ChargingActionIssuerSpotMarketCharging),
|
||
"une action spot a été planifiée par un build sans fournisseur");
|
||
#endif
|
||
|
||
// 4. LE RPC DIT POURQUOI. Un client ancien qui envoie l'identifiant aWATTar (Autriche)
|
||
// recevait InvalidParameter indistinct et accusait son providerId. Le code nomme la cause.
|
||
QVariantMap rep = injectAndWait("NymeaEnergy.SetSpotMarketConfiguration",
|
||
{{"enabled", true},
|
||
{"providerId", QUuid("5196b3cc-b2ee-46d6-b63a-7af2cf70ba67")}})
|
||
.toMap().value("params").toMap();
|
||
QCOMPARE(rep.value("energyError").toString(), QStringLiteral("EnergyErrorInvalidParameter"));
|
||
QCOMPARE(rep.value("fault").toString(), QString::fromLatin1(DecisionCode::SpotMarketNotInBuild));
|
||
// Sans providerId : même refus, même code.
|
||
rep = injectAndWait("NymeaEnergy.SetSpotMarketConfiguration", {{"enabled", true}})
|
||
.toMap().value("params").toMap();
|
||
QCOMPARE(rep.value("fault").toString(), QString::fromLatin1(DecisionCode::SpotMarketNotInBuild));
|
||
QVERIFY(!spot->enabled());
|
||
|
||
// 5. Désactiver reste accepté, sans motif : c'est l'état du build, le confirmer n'est pas
|
||
// une erreur.
|
||
rep = injectAndWait("NymeaEnergy.SetSpotMarketConfiguration", {{"enabled", false}})
|
||
.toMap().value("params").toMap();
|
||
QCOMPARE(rep.value("energyError").toString(), QStringLiteral("EnergyErrorNoError"));
|
||
QVERIFY(!rep.contains("fault"));
|
||
|
||
const QVariantMap conf = injectAndWait("NymeaEnergy.GetSpotMarketConfiguration")
|
||
.toMap().value("params").toMap();
|
||
QCOMPARE(conf.value("enabled").toBool(), false);
|
||
QCOMPARE(conf.value("available").toBool(), false);
|
||
QVERIFY(injectAndWait("NymeaEnergy.GetAvailableSpotMarketProviders")
|
||
.toMap().value("params").toMap().value("providers").toList().isEmpty());
|
||
}
|
||
|
||
void Simulation::testTheSiteAcLimitTravelsWithTheDesignation()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// Brief 3 T1 — `acLimitW`, paramètre de SITE porté par la désignation existante. Jamais un 0.
|
||
// Décision du 2026-10-13 (§30.3) : c'est la SEULE exception au remplacement en entier —
|
||
// ABSENT = INCHANGÉ, null = EFFACER. Une app plus ancienne, qui ignore le champ, effacerait
|
||
// sinon le plafond à chaque enregistrement du site. Ce test exerce l'optionnel par le RPC
|
||
// (l'AGENTS : un paramètre optionnel qu'aucun test n'appelle est un paramètre qu'on peut perdre).
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
const QUuid site = addMeter(); // n'importe quel Thing configuré tient lieu de site
|
||
QVERIFY(!site.isNull());
|
||
auto set = [&](const QVariantMap &p) {
|
||
return injectAndWait("NymeaEnergy.SetForecastSite", p).toMap();
|
||
};
|
||
auto get = [&]() {
|
||
return injectAndWait("NymeaEnergy.GetForecastSite").toMap().value("params").toMap();
|
||
};
|
||
auto erreur = [](const QVariantMap &r) {
|
||
return r.value("params").toMap().value("energyError").toString();
|
||
};
|
||
|
||
// 1. Désignation AVEC plafond : acceptée, relue, persistée.
|
||
QVariantMap r = set({{"forecastSiteThingId", site}, {"acLimitW", 3000}});
|
||
QCOMPARE(r.value("status").toString(), QStringLiteral("success"));
|
||
QCOMPARE(erreur(r), QStringLiteral("EnergyErrorNoError"));
|
||
QCOMPARE(get().value("acLimitW").toInt(), 3000);
|
||
QCOMPARE(arb->forecastAcLimitW().value_or(-1), 3000);
|
||
QCOMPARE(EnergySettings().value("forecastAcLimitW").toInt(), 3000);
|
||
|
||
// 2. REFUS sans rien modifier : zéro, hors bornes, fraction, chaîne, booléen, plafond sans
|
||
// site. Le type est déclaré Variant (pour laisser passer null) : la forme est contrôlée
|
||
// par le handler, et un refus de forme ne doit rien toucher non plus.
|
||
for (const QVariantMap &p : {QVariantMap{{"forecastSiteThingId", site}, {"acLimitW", 0}},
|
||
QVariantMap{{"forecastSiteThingId", site}, {"acLimitW", 99}},
|
||
QVariantMap{{"forecastSiteThingId", site}, {"acLimitW", 1000001}},
|
||
QVariantMap{{"forecastSiteThingId", site}, {"acLimitW", -3000}},
|
||
QVariantMap{{"forecastSiteThingId", site}, {"acLimitW", 2500.5}},
|
||
QVariantMap{{"forecastSiteThingId", site}, {"acLimitW", "3000"}},
|
||
QVariantMap{{"forecastSiteThingId", site}, {"acLimitW", true}},
|
||
QVariantMap{{"acLimitW", 3000}}}) {
|
||
r = set(p);
|
||
QCOMPARE(r.value("status").toString(), QStringLiteral("success"));
|
||
QCOMPARE(erreur(r), QStringLiteral("EnergyErrorInvalidParameter"));
|
||
QCOMPARE(get().value("acLimitW").toInt(), 3000);
|
||
QCOMPARE(get().value("forecastSiteThingId").toUuid(), site);
|
||
QCOMPARE(EnergySettings().value("forecastAcLimitW").toInt(), 3000);
|
||
}
|
||
|
||
// 3. Désignation SANS `acLimitW` : le plafond est CONSERVÉ — en mémoire, au RPC, sur disque.
|
||
r = set({{"forecastSiteThingId", site}});
|
||
QCOMPARE(erreur(r), QStringLiteral("EnergyErrorNoError"));
|
||
QCOMPARE(get().value("acLimitW").toInt(), 3000);
|
||
QCOMPARE(arb->forecastAcLimitW().value_or(-1), 3000);
|
||
QCOMPARE(EnergySettings().value("forecastAcLimitW").toInt(), 3000);
|
||
|
||
// 4. Une valeur entière transmise en double (2500.0) est un entier : acceptée.
|
||
r = set({{"forecastSiteThingId", site}, {"acLimitW", 2500.0}});
|
||
QCOMPARE(erreur(r), QStringLiteral("EnergyErrorNoError"));
|
||
QCOMPARE(get().value("acLimitW").toInt(), 2500);
|
||
|
||
// 5. `acLimitW: null` : le plafond est EFFACÉ — et omis à la lecture, pas 0 ; clé retirée.
|
||
r = set({{"forecastSiteThingId", site}, {"acLimitW", QVariant()}});
|
||
QCOMPARE(r.value("status").toString(), QStringLiteral("success"));
|
||
QCOMPARE(erreur(r), QStringLiteral("EnergyErrorNoError"));
|
||
QVERIFY2(!get().contains("acLimitW"), "aucun plafond se dit par une ABSENCE, jamais par 0");
|
||
QVERIFY(!arb->forecastAcLimitW().has_value());
|
||
QVERIFY(!EnergySettings().contains("forecastAcLimitW"));
|
||
QCOMPARE(get().value("forecastSiteThingId").toUuid(), site); // le site, lui, reste
|
||
|
||
// 6. Retirer le SITE retire le plafond : sans site, il n'a pas d'objet.
|
||
QCOMPARE(erreur(set({{"forecastSiteThingId", site}, {"acLimitW", 4000}})),
|
||
QStringLiteral("EnergyErrorNoError"));
|
||
QCOMPARE(erreur(set({{"forecastSiteThingId", ""}})), QStringLiteral("EnergyErrorNoError"));
|
||
QVERIFY(!get().contains("acLimitW"));
|
||
QVERIFY(!get().contains("forecastSiteThingId"));
|
||
QVERIFY(!EnergySettings().contains("forecastAcLimitW"));
|
||
// … et `null` sans site est accepté : effacer ce qui n'existe pas n'est pas une erreur.
|
||
QCOMPARE(erreur(set({{"acLimitW", QVariant()}})), QStringLiteral("EnergyErrorNoError"));
|
||
|
||
// 7. Pas de canal dans ce test : le snapshot ne rend ni courbe, ni champs de courbe.
|
||
QCOMPARE(erreur(set({{"forecastSiteThingId", site}, {"acLimitW", 3000}})),
|
||
QStringLiteral("EnergyErrorNoError"));
|
||
const QVariantMap snap = injectAndWait("NymeaEnergy.GetForecastSnapshot").toMap().value("params").toMap();
|
||
QVERIFY(!snap.contains("forecastPoints"));
|
||
QVERIFY(!snap.contains("corrections"));
|
||
QVERIFY(!snap.contains("conversion"));
|
||
|
||
QVERIFY(arb->setForecastSiteId(QString()));
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testTheSnapshotPublishesEachPlanesCorrections()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// Décision du 2026-10-13 (§30.2) — les corrections PAR PLAN, en donnée. Le `corrections` du
|
||
// site est l'intersection : sans le détail, « un plan sans température » et « aucun plan
|
||
// corrigé » se liraient pareil. Pas de booléen « divergent » : la divergence se LIT.
|
||
//
|
||
// Premier test qui fait passer une COURBE par le RPC : le mock porte désormais une classe
|
||
// `pvPlane` (canal `forecastCurveTransport`), rangée ici sous un site par `setParentId`.
|
||
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();
|
||
const QUuid site = addMeter(); // n'importe quel Thing configuré tient lieu de site
|
||
const QUuid est = addPvPlane(QStringLiteral("Toit est"));
|
||
const QUuid ouest = addPvPlane(QStringLiteral("Toit ouest"));
|
||
Thing *tEst = tm->findConfiguredThing(ThingId(est));
|
||
Thing *tOuest = tm->findConfiguredThing(ThingId(ouest));
|
||
QVERIFY(tEst && tOuest && !site.isNull());
|
||
tEst->setParentId(ThingId(site));
|
||
tOuest->setParentId(ThingId(site));
|
||
// Détacher les plans quoi qu'il arrive : un enfant restant ferait échouer le nettoyage.
|
||
const auto detacher = qScopeGuard([&]() {
|
||
tEst->setParentId(ThingId());
|
||
tOuest->setParentId(ThingId());
|
||
arb->setForecastSiteId(QString());
|
||
});
|
||
// L'identifiant AVEC accolades, tel que `candidateSiteThingIds` le rend. La forme sans
|
||
// accolades est tenue à part, par `testTheForecastSiteIdIsMatchedWhateverItsBraces`.
|
||
const QString siteId = ThingId(site).toString();
|
||
|
||
const qint64 t0 = QDateTime(QDate(2026, 10, 13), QTime(10, 0), QTimeZone::utc()).toSecsSinceEpoch();
|
||
auto charge = [&](const QVariantList &corrections, const QVariantList &p) {
|
||
return QVariantMap{{"v", 2}, {"unit", "W"}, {"convention", "mean over [t, t+step)"},
|
||
{"t0", t0}, {"step", 3600}, {"issuedAt", t0 - 3600},
|
||
{"model", "meteofrance_seamless"}, {"p", p},
|
||
{"corrections", corrections}};
|
||
};
|
||
auto snapshot = [&]() {
|
||
return injectAndWait("NymeaEnergy.GetForecastSnapshot").toMap().value("params").toMap();
|
||
};
|
||
// `perField` rendu sous forme { id → corrections }, l'ordre étant vérifié à part.
|
||
auto parPlan = [](const QVariantMap &snap) {
|
||
QMap<QUuid, QStringList> m;
|
||
for (const QVariant &e : snap.value("perField").toList())
|
||
m.insert(e.toMap().value("id").toUuid(), e.toMap().value("corrections").toStringList());
|
||
return m;
|
||
};
|
||
|
||
// 1. Plans DIVERGENTS : l'un corrigé en température, l'autre brut. Plafond 2000 W.
|
||
tEst->setStateValue("forecastCurveTransport", charge({"temperature"}, {1500, 1200}));
|
||
tOuest->setStateValue("forecastCurveTransport", charge({}, {1200, 300}));
|
||
QVariantMap r = injectAndWait("NymeaEnergy.SetForecastSite",
|
||
{{"forecastSiteThingId", siteId}, {"acLimitW", 2000}}).toMap();
|
||
QCOMPARE(r.value("params").toMap().value("energyError").toString(), QStringLiteral("EnergyErrorNoError"));
|
||
QVariantMap snap = snapshot();
|
||
QVERIFY2(snap.contains("forecastPoints"), qUtf8Printable(QJsonDocument::fromVariant(snap).toJson()));
|
||
const QVariantList pts = snap.value("forecastPoints").toList();
|
||
QCOMPARE(pts.count(), 2);
|
||
QCOMPARE(pts.at(0).toMap().value("forecastWh").toDouble(), 2000.0); // 2700 écrêté
|
||
QCOMPARE(pts.at(1).toMap().value("forecastWh").toDouble(), 1500.0);
|
||
// Le site : l'intersection, plus le plafond. La température N'Y EST PAS — un plan ne l'a pas.
|
||
QCOMPARE(snap.value("corrections").toStringList(), QStringList{"ac-limit"});
|
||
QCOMPARE(snap.value("conversion").toString(), QStringLiteral("physics"));
|
||
QCOMPARE(snap.value("acLimitW").toInt(), 2000);
|
||
// Le détail : une entrée par plan sommé, et la divergence se lit dans les données.
|
||
QMap<QUuid, QStringList> plans = parPlan(snap);
|
||
QCOMPARE(plans.count(), 2);
|
||
QCOMPARE(plans.value(est), QStringList{"temperature"});
|
||
QCOMPARE(plans.value(ouest), QStringList{});
|
||
// ac-limit est une correction du SITE : elle n'apparaît dans aucune entrée de plan.
|
||
for (const QStringList &c : plans)
|
||
QVERIFY(!c.contains("ac-limit"));
|
||
// Trié par identifiant : un instantané se compare.
|
||
const QVariantList brut = snap.value("perField").toList();
|
||
QVERIFY(brut.at(0).toMap().value("id").toUuid().toString()
|
||
< brut.at(1).toMap().value("id").toUuid().toString());
|
||
|
||
// 2. Plans CONCORDANTS, plafond effacé (`null` — l'absence le laisserait en place, §30.3) :
|
||
// la température remonte au site, `ac-limit` disparaît, et `acLimitW` est omis — jamais 0.
|
||
tOuest->setStateValue("forecastCurveTransport", charge({"temperature"}, {1200, 300}));
|
||
r = injectAndWait("NymeaEnergy.SetForecastSite",
|
||
{{"forecastSiteThingId", siteId}, {"acLimitW", QVariant()}}).toMap();
|
||
QCOMPARE(r.value("params").toMap().value("energyError").toString(), QStringLiteral("EnergyErrorNoError"));
|
||
snap = snapshot();
|
||
QCOMPARE(snap.value("corrections").toStringList(), QStringList{"temperature"});
|
||
QVERIFY(!snap.contains("acLimitW"));
|
||
QCOMPARE(snap.value("forecastPoints").toList().at(0).toMap().value("forecastWh").toDouble(), 2700.0);
|
||
plans = parPlan(snap);
|
||
QCOMPARE(plans.value(est), QStringList{"temperature"});
|
||
QCOMPARE(plans.value(ouest), QStringList{"temperature"});
|
||
|
||
// 3. Un plan REFUSÉ : ni courbe, ni détail par plan — le motif seul.
|
||
tOuest->setStateValue("forecastCurveTransport", charge({"inconnue"}, {1200, 300}));
|
||
snap = snapshot();
|
||
QCOMPARE(snap.value("fault").toString(), QString::fromLatin1(DecisionCode::ForecastPayloadRefused));
|
||
QVERIFY(!snap.contains("forecastPoints"));
|
||
QVERIFY(!snap.contains("perField"));
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testTheForecastSiteIdIsMatchedWhateverItsBraces()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// Constaté le 2026-10-13 (rapport des décisions, écart n° 1) : `SetForecastSite` stockait
|
||
// l'identifiant TEL QUE REÇU, puis le comparait EN CHAÎNE à `parentId().toString()`, qui a
|
||
// des accolades. Un UUID envoyé sans elles était accepté — et le snapshot restait MUET, la
|
||
// table vide. Une désignation qui a l'air faite et ne fait rien.
|
||
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();
|
||
const QUuid site = addMeter(); // n'importe quel Thing configuré tient lieu de site
|
||
const QUuid plan = addPvPlane(QStringLiteral("Toit"));
|
||
Thing *tPlan = tm->findConfiguredThing(ThingId(plan));
|
||
QVERIFY(tPlan && !site.isNull());
|
||
tPlan->setParentId(ThingId(site));
|
||
const auto detacher = qScopeGuard([&]() {
|
||
tPlan->setParentId(ThingId());
|
||
arb->setForecastSiteId(QString());
|
||
});
|
||
const qint64 t0 = QDateTime(QDate(2026, 10, 13), QTime(10, 0), QTimeZone::utc()).toSecsSinceEpoch();
|
||
tPlan->setStateValue("forecastCurveTransport",
|
||
QVariantMap{{"v", 2}, {"unit", "W"}, {"convention", "mean over [t, t+step)"},
|
||
{"t0", t0}, {"step", 3600}, {"issuedAt", t0 - 3600},
|
||
{"model", "meteofrance_seamless"}, {"p", QVariantList{1500, 1200}},
|
||
{"corrections", QVariantList{}}});
|
||
|
||
const QString canonique = ThingId(site).toString(); // {uuid}, minuscules
|
||
QVERIFY(canonique.startsWith('{'));
|
||
const QString nu = site.toString(QUuid::WithoutBraces);
|
||
auto snapshot = [&]() {
|
||
return injectAndWait("NymeaEnergy.GetForecastSnapshot").toMap().value("params").toMap();
|
||
};
|
||
auto courbeLue = [&](const char *forme) {
|
||
QString motif; QVariantMap params;
|
||
const ForecastRevision rev = arb->forecastSnapshot(motif, params);
|
||
QVERIFY2(motif.isEmpty(), qUtf8Printable(QStringLiteral("%1 : motif %2").arg(forme, motif)));
|
||
QVERIFY2(rev.points.count() == 2, forme);
|
||
QCOMPARE(rev.siteId, canonique);
|
||
const QVariantMap snap = snapshot();
|
||
QVERIFY2(snap.value("forecastPoints").toList().count() == 2,
|
||
qUtf8Printable(QStringLiteral("%1 : %2").arg(forme,
|
||
QString::fromUtf8(QJsonDocument::fromVariant(snap).toJson()))));
|
||
QCOMPARE(injectAndWait("NymeaEnergy.GetForecastSite").toMap().value("params").toMap()
|
||
.value("forecastSiteThingId").toString(), canonique);
|
||
QCOMPARE(arb->forecastSiteId(), canonique);
|
||
};
|
||
|
||
// 1. PAR LE RPC, avec puis sans accolades, puis en majuscules : le même site, la même courbe,
|
||
// et la désignation est rendue — et persistée — sous UNE seule forme.
|
||
for (const QString &forme : {canonique, nu, nu.toUpper()}) {
|
||
const QVariantMap r = injectAndWait("NymeaEnergy.SetForecastSite",
|
||
{{"forecastSiteThingId", forme}}).toMap();
|
||
QCOMPARE(r.value("params").toMap().value("energyError").toString(),
|
||
QStringLiteral("EnergyErrorNoError"));
|
||
courbeLue(qPrintable(forme));
|
||
QCOMPARE(EnergySettings().value("forecastSiteId").toString(), canonique);
|
||
}
|
||
|
||
// 2. Par l'arbitre directement, sans accolades : même normalisation, même chemin.
|
||
QVERIFY(arb->setForecastSiteId(nu));
|
||
courbeLue("setForecastSiteId sans accolades");
|
||
|
||
// 3. UNE VALEUR PERSISTÉE PAR UNE VERSION ANTÉRIEURE, sans accolades, puis relue : c'est le
|
||
// cas d'une box mise à jour, dont le réglage a été écrit avant le correctif.
|
||
QVERIFY(arb->setForecastSiteId(QString()) && arb->forecastSiteId().isEmpty());
|
||
EnergySettings().setValue(QStringLiteral("forecastSiteId"), nu);
|
||
arb->relireReglagesPrevision();
|
||
courbeLue("valeur persistée sans accolades");
|
||
|
||
// 4. Contre-épreuve : un AUTRE site, sous quelque forme que ce soit, ne lit pas ce plan.
|
||
// Le plan lui-même tient lieu d'autre site : un Thing existant, parent d'aucun plan.
|
||
QVERIFY(arb->setForecastSiteId(plan.toString(QUuid::WithoutBraces)));
|
||
QString motif; QVariantMap params;
|
||
QVERIFY(arb->forecastSnapshot(motif, params).points.isEmpty());
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testAnUnreadableForecastDateIsAnError()
|
||
{
|
||
// Décision du 2026-10-13 (§30.4). Constaté le 2026-10-12 : une date illisible devenait
|
||
// l'epoch 0, en silence — `to` illisible rendait une fenêtre vide, indistinguable de « rien
|
||
// d'enregistré ». Une erreur de l'APPELANT se lit désormais comme telle.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
const QString ok = QStringLiteral("2026-10-13T00:00:00Z");
|
||
const QString ko = QStringLiteral("pas-une-date");
|
||
auto lire = [&](const QVariantMap &p) {
|
||
return injectAndWait("NymeaEnergy.GetPvForecast", p).toMap();
|
||
};
|
||
|
||
// 1. Chaque date illisible, seule puis ensemble : erreur de paramètre, motif, champs nommés.
|
||
struct Cas { QVariantMap params; QStringList champs; };
|
||
const QList<Cas> cas {
|
||
{ {{"from", ko}, {"to", ok}}, {"from"} },
|
||
{ {{"from", ok}, {"to", ko}}, {"to"} },
|
||
{ {{"from", ok}, {"to", ok}, {"knownAt", ko}}, {"knownAt"} },
|
||
{ {{"from", ko}, {"to", ko}, {"knownAt", ko}}, {"from", "to", "knownAt"} },
|
||
{ {{"from", ""}, {"to", ok}}, {"from"} },
|
||
};
|
||
for (const Cas &c : cas) {
|
||
const QVariantMap r = lire(c.params);
|
||
// L'appel n'est PAS rejeté par la validation nymea : c'est le handler qui répond.
|
||
QCOMPARE(r.value("status").toString(), QStringLiteral("success"));
|
||
const QVariantMap rep = r.value("params").toMap();
|
||
QCOMPARE(rep.value("energyError").toString(), QStringLiteral("EnergyErrorInvalidParameter"));
|
||
QCOMPARE(rep.value("fault").toString(), QString::fromLatin1(DecisionCode::ForecastDateUnreadable));
|
||
QCOMPARE(rep.value("faultParams").toMap().value("fields").toStringList(), c.champs);
|
||
QVERIFY(rep.value("forecastPoints").toList().isEmpty());
|
||
}
|
||
|
||
// 2. Dates lisibles — avec et sans `knownAt`, avec décalage explicite : aucune erreur, aucun
|
||
// motif. Une fenêtre vide y veut dire « rien d'enregistré », et seulement cela.
|
||
for (const QVariantMap &p : {QVariantMap{{"from", ok}, {"to", "2026-10-14T00:00:00+02:00"}},
|
||
QVariantMap{{"from", ok}, {"to", ok}, {"knownAt", ok}}}) {
|
||
const QVariantMap rep = lire(p).value("params").toMap();
|
||
QCOMPARE(rep.value("energyError").toString(), QStringLiteral("EnergyErrorNoError"));
|
||
QVERIFY(!rep.contains("fault"));
|
||
QVERIFY(!rep.contains("faultParams"));
|
||
QVERIFY(rep.contains("forecastPoints"));
|
||
}
|
||
}
|
||
|
||
void Simulation::testEveryGetterReturnsWhatItDeclares()
|
||
{
|
||
// LE TROU QUE LE 2026-09-09 A OUVERT.
|
||
//
|
||
// `GetPvForecast` a répondu `{}` pendant une heure sur le banc : une réécriture de sa
|
||
// description avait emporté ses `returns.insert(...)`, et nymea élimine EN SILENCE toute
|
||
// charge utile qui ne correspond à aucun schéma déclaré. Trente et un tests unitaires, la
|
||
// simulation et ci-quality passaient — aucun ne regarde la SURFACE RPC.
|
||
//
|
||
// Ce n'est pas un défaut de logique, c'est un défaut de DÉCLARATION. Il ne se voit qu'en
|
||
// APPELANT, et il touche n'importe quelle méthode dont on retouche la description. Le
|
||
// contrôle porte donc sur la CLASSE entière, pas sur la méthode qui a mordu.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
const QVariantMap introspection =
|
||
injectAndWait("JSONRPC.Introspect").toMap().value("params").toMap();
|
||
const QVariantMap methodes = introspection.value("methods").toMap();
|
||
QVERIFY2(!methodes.isEmpty(), "l'introspection n'a rendu aucune méthode");
|
||
|
||
// Les paramètres REQUIS des rares getters qui en ont. Une table explicite plutôt qu'une
|
||
// fabrication automatique : une valeur devinée qui échoue ferait accuser le handler là où
|
||
// c'est l'appel qui est faux.
|
||
const QHash<QString, QVariantMap> parametres {
|
||
{ QStringLiteral("NymeaEnergy.GetPvForecast"),
|
||
{{"from", "2026-09-09T00:00:00Z"}, {"to", "2026-09-10T23:00:00Z"}} },
|
||
};
|
||
// LES OPTIONNELS AUSSI, et c'est une classe à part : aucun appel nominal ne les utilise,
|
||
// donc leur disparition ne casse rien de visible. Ce qui n'est jamais exercé n'est pas
|
||
// « peu testé » — c'est indistinguable d'absent, alors qu'un client les lit dans
|
||
// l'introspection et les croit disponibles.
|
||
const QHash<QString, QVariantMap> optionnels {
|
||
{ QStringLiteral("NymeaEnergy.GetPvForecast"),
|
||
{{"from", "2026-09-09T00:00:00Z"}, {"to", "2026-09-10T23:00:00Z"},
|
||
{"knownAt", "2026-09-09T12:00:00Z"}} },
|
||
};
|
||
|
||
int verifies = 0;
|
||
QStringList muettes;
|
||
for (auto it = methodes.constBegin(); it != methodes.constEnd(); ++it) {
|
||
const QString nom = it.key();
|
||
if (!nom.startsWith(QStringLiteral("NymeaEnergy.Get")))
|
||
continue; // les Set* MUTENT : les appeler à l'aveugle serait pire
|
||
const QVariantMap attendu = it.value().toMap().value("returns").toMap();
|
||
const QVariantMap brut = injectAndWait(nom, parametres.value(nom)).toMap();
|
||
const QVariantMap reponse = brut.value("params").toMap();
|
||
++verifies;
|
||
|
||
// LE STATUT D'ABORD. Le défaut du 2026-09-09 n'était PAS un retour supprimé — vérifié :
|
||
// nymea ne filtre pas la charge utile. C'était la déclaration des PARAMÈTRES qui avait
|
||
// disparu, si bien que l'appel était REJETÉ à la validation. Un client qui ne regarde
|
||
// que `params` voit alors une réponse vide et accuse le handler, qui n'a jamais été
|
||
// atteint. Regarder le statut nomme le vrai coupable.
|
||
if (brut.value(QStringLiteral("status")).toString() != QLatin1String("success")) {
|
||
muettes << QStringLiteral("%1 → statut « %2 » (%3)")
|
||
.arg(nom, brut.value(QStringLiteral("status")).toString(),
|
||
brut.value(QStringLiteral("error")).toString());
|
||
continue;
|
||
}
|
||
|
||
// LE CRITÈRE PORTE SUR LA RÉPONSE, PAS SUR LA DÉCLARATION — et c'est tout le sujet.
|
||
//
|
||
// Première version : « pour chaque clé déclarée, vérifier qu'elle est là ». La
|
||
// contre-épreuve l'a démolie — en retirant la déclaration de GetPvForecast, le contrôle
|
||
// ne voyait plus rien à vérifier et PASSAIT. Le défaut désactivait son propre détecteur :
|
||
// contre-épreuve inerte, exactement le mode d'érosion que l'AGENTS.md catalogue.
|
||
//
|
||
// UN GETTER QUI NE REND RIEN EST CASSÉ, quelle que soit sa déclaration. C'est cette
|
||
// affirmation-là qui tient debout sans elle.
|
||
if (reponse.isEmpty()) {
|
||
muettes << QStringLiteral("%1 → charge utile VIDE (déclaration absente ou handler muet)")
|
||
.arg(nom);
|
||
continue;
|
||
}
|
||
// Et EN PLUS, quand une déclaration existe, ses clés requises doivent être servies : une
|
||
// clé oubliée est le même défaut, en plus discret.
|
||
for (auto k = attendu.constBegin(); k != attendu.constEnd(); ++k) {
|
||
if (k.key().startsWith(QStringLiteral("o:")))
|
||
continue; // optionnelle : son absence est une information
|
||
if (!reponse.contains(k.key()))
|
||
muettes << QStringLiteral("%1 → « %2 » manquante").arg(nom, k.key());
|
||
}
|
||
}
|
||
|
||
// SECOND PASSAGE : les mêmes méthodes, avec leurs paramètres OPTIONNELS renseignés. Un
|
||
// optionnel supprimé de la déclaration ferait ici « Invalid params », comme les requis.
|
||
for (auto it = optionnels.constBegin(); it != optionnels.constEnd(); ++it) {
|
||
const QVariantMap brut = injectAndWait(it.key(), it.value()).toMap();
|
||
if (brut.value(QStringLiteral("status")).toString() != QLatin1String("success"))
|
||
muettes << QStringLiteral("%1 (avec optionnels) → statut « %2 » (%3)")
|
||
.arg(it.key(), brut.value(QStringLiteral("status")).toString(),
|
||
brut.value(QStringLiteral("error")).toString());
|
||
}
|
||
|
||
QVERIFY2(verifies >= 5,
|
||
qUtf8Printable(QStringLiteral("seulement %1 getter(s) parcouru(s) — le balayage "
|
||
"ne prouve rien s'il ne balaie rien").arg(verifies)));
|
||
QVERIFY2(muettes.isEmpty(),
|
||
qUtf8Printable(QStringLiteral("des méthodes ne rendent pas ce qu'elles déclarent :\n %1")
|
||
.arg(muettes.join(QStringLiteral("\n ")))));
|
||
}
|
||
|
||
void Simulation::testDerogationTakesTheHeadButNotTheSafety()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
RuleBasedScheduler sched(arb);
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 9, 8), QTime(9, 0, 0));
|
||
const QDateTime fin = t0.addSecs(1800);
|
||
|
||
// TROIS charges, et leurs rangs sont l'inverse de l'ordre attendu — c'est ainsi que la tête
|
||
// de passe se prouve. L'ECS porte le rang 1, le routeur le 5, la borne le 9 (la queue).
|
||
// Aucun surplus : la maison achète.
|
||
auto planifier = [&](bool derogBorne, bool derogRouteur, double margeW, const QString &sfx)
|
||
-> QHash<QString, LoadAction> {
|
||
SurplusContext ctx;
|
||
ctx.timestamp = t0;
|
||
ctx.meter.importW = 500;
|
||
ctx.drawMargin.borne = true;
|
||
ctx.drawMargin.watts = margeW;
|
||
ctx.drawMargin.source = DrawCapSource::Breaker;
|
||
ctx.drawMargin.phase = 0;
|
||
|
||
LoadContext ecs;
|
||
ecs.id = QStringLiteral("ecs-%1").arg(sfx);
|
||
ecs.label = "ECS";
|
||
ecs.adapter = "etmvariableload";
|
||
ecs.priority = 1; // RANG 1 — devant tout le monde
|
||
ecs.declared.maxPowerW = 3000;
|
||
ecs.needs.minEnergyWhPerDay = 2000;
|
||
ecs.needs.dailyDeadline = "11:00"; // 2 h → 1 000 W de cadence
|
||
ecs.telemetry.sessionMeasurable = true;
|
||
ctx.loads.append(ecs);
|
||
|
||
LoadContext routeur;
|
||
routeur.id = QStringLiteral("routeur-%1").arg(sfx);
|
||
routeur.label = "Routeur";
|
||
routeur.adapter = "etmvariableload";
|
||
routeur.priority = 5;
|
||
routeur.declared.maxPowerW = 3000;
|
||
routeur.derogation = derogRouteur;
|
||
routeur.derogationEndsAt = fin;
|
||
ctx.loads.append(routeur);
|
||
|
||
LoadContext borne;
|
||
borne.id = QStringLiteral("borne-%1").arg(sfx);
|
||
borne.label = "Borne";
|
||
borne.adapter = "evcharger";
|
||
borne.priority = 9; // RANG DE QUEUE
|
||
// Pour une borne, `maxPowerW` est le plafond DU CYCLE : l'adaptateur y a déjà fondu
|
||
// `evPhaseAllowanceW()`, c'est-à-dire la marge de soutirage MULTIPLIÉE par le nombre de
|
||
// phases occupées. C'est ce qui rend un second écrêtage inutile — et faux.
|
||
borne.declared.maxPowerW = 7000;
|
||
borne.telemetry.pluggedIn = true;
|
||
borne.derogation = derogBorne;
|
||
borne.derogationEndsAt = fin;
|
||
ctx.loads.append(borne);
|
||
|
||
const Plan plan = sched.getPlan(ctx);
|
||
QHash<QString, LoadAction> par;
|
||
for (const LoadAction &a : plan.timeSlots.first().actions)
|
||
par.insert(a.loadId.section('-', 0, 0), a);
|
||
return par;
|
||
};
|
||
|
||
// 1. CONTRE-ÉPREUVE — sans dérogation et sans surplus, la borne ne reçoit rien et l'ECS
|
||
// prend les 1 000 W de sa cadence. Sans elle, on ne saurait pas d'où viennent les
|
||
// 7 000 W du cas suivant.
|
||
const QHash<QString, LoadAction> sans = planifier(false, false, 10000, "sans");
|
||
QCOMPARE(qRound(sans.value("borne").estimatedPowerW), 0);
|
||
QCOMPARE(qRound(sans.value("ecs").estimatedPowerW), 1000);
|
||
|
||
// 2. AVEC DÉROGATION — la borne est servie à SON plafond de cycle, achetée au réseau.
|
||
const QHash<QString, LoadAction> avec = planifier(true, false, 10000, "avec");
|
||
const LoadAction b = avec.value("borne");
|
||
QCOMPARE(qRound(b.estimatedPowerW), 7000);
|
||
QCOMPARE(b.reason.code, QString(DecisionCode::DerogationActive));
|
||
QCOMPARE(b.levels.count(), 1);
|
||
QCOMPARE(b.levels.at(0).level, QString("eco"));
|
||
QCOMPARE(b.levels.at(0).funding, LoadAction::Grid);
|
||
QCOMPARE(qRound(b.counts.value(CountKey::DrawCommitted)), 7000);
|
||
QCOMPARE(b.reason.params.value("boundBy").toString(), QString("loadCeiling"));
|
||
QVERIFY(!b.reason.params.value("endsAt").toString().isEmpty());
|
||
QVERIFY2(!b.reason.params.contains("targetWh"), "pas de cible ⇒ pas de clé, jamais un zéro");
|
||
QCOMPARE(qRound(avec.value("ecs").estimatedPowerW), 1000);
|
||
|
||
// 3. LA TÊTE DE PASSE, prouvée par la RARETÉ. 7 500 W d'autorisation pour 8 000 demandés :
|
||
// la borne de rang 9 est servie ENTIÈREMENT et c'est l'ECS de rang 1 qui prend le
|
||
// reste — l'inverse exact de l'ordre des rangs.
|
||
const QHash<QString, LoadAction> rare = planifier(true, false, 7500, "rare");
|
||
QCOMPARE(qRound(rare.value("borne").estimatedPowerW), 7000);
|
||
QCOMPARE(qRound(rare.value("ecs").estimatedPowerW), 500);
|
||
|
||
// 4. \rule{LM-1500-h} — UNE BORNE N'EST PAS ÉCRÊTÉE DEUX FOIS. Défaut trouvé au banc le
|
||
// 2026-09-08.
|
||
// `margeRestante` est la marge d'UNE phase ; le plafond d'une borne triphasée est une
|
||
// enveloppe sur TROIS. Les comparer divisait son allocation par trois : 9 A par phase au
|
||
// lieu de 27, 4 kW au lieu des 11 que la voiture pouvait prendre. Avec 1 000 W de marge
|
||
// de maison, la borne reste donc servie à 7 000 — la sûreté la borne AILLEURS, dans le
|
||
// `maxPowerW` que l'adaptateur a déjà écrêté avec `evPhaseAllowanceW()`.
|
||
const QHash<QString, LoadAction> etroite = planifier(true, false, 1000, "etroite");
|
||
QCOMPARE(qRound(etroite.value("borne").estimatedPowerW), 7000);
|
||
QCOMPARE(etroite.value("borne").reason.params.value("boundBy").toString(),
|
||
QString("loadCeiling"));
|
||
|
||
// 5. …MAIS UNE CHARGE ORDINAIRE, SI. Le routeur n'a pas d'allocation par phase : la marge
|
||
// de la maison est la seule chose qui le borne, et le motif dit que c'est elle. C'est la
|
||
// contre-épreuve du cas 4 — sans elle, on ne saurait pas si la marge borne encore
|
||
// quelqu'un, et le cas 4 passerait pour une bonne raison qui n'existe pas.
|
||
const QHash<QString, LoadAction> bride = planifier(false, true, 1000, "bride");
|
||
QCOMPARE(qRound(bride.value("routeur").estimatedPowerW), 1000);
|
||
QCOMPARE(bride.value("routeur").reason.params.value("boundBy").toString(), QString("drawCap"));
|
||
|
||
// 6. AUTORISATION NULLE sur une charge ordinaire : rien n'est servi, et AUCUN niveau n'est
|
||
// publié plutôt qu'un zéro forgé.
|
||
const QHash<QString, LoadAction> zero = planifier(false, true, 0, "zero");
|
||
QCOMPARE(qRound(zero.value("routeur").estimatedPowerW), 0);
|
||
QVERIFY2(zero.value("routeur").levels.isEmpty(),
|
||
"sans autorisation il n'y a rien à publier : pas de niveau à zéro");
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testEcoPassBuysAndSaysTheTrajectory()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
RuleBasedScheduler sched(arb);
|
||
|
||
auto planifier = [&](const QDateTime &at, bool mesurable, double livreWh,
|
||
double margeW) -> LoadAction {
|
||
SurplusContext ctx;
|
||
ctx.timestamp = at;
|
||
ctx.meter.importW = 500; // la maison ACHÈTE : aucun surplus
|
||
ctx.drawMargin.borne = true;
|
||
ctx.drawMargin.watts = margeW;
|
||
ctx.drawMargin.source = DrawCapSource::Breaker;
|
||
ctx.drawMargin.phase = 0;
|
||
|
||
LoadContext ecs;
|
||
ecs.id = QStringLiteral("ecs-%1-%2").arg(mesurable).arg(qRound(margeW));
|
||
ecs.label = "ECS";
|
||
ecs.adapter = "etmvariableload";
|
||
ecs.priority = 1;
|
||
ecs.declared.maxPowerW = 3000;
|
||
ecs.needs.minEnergyWhPerDay = 2000;
|
||
ecs.needs.dailyDeadline = "07:00";
|
||
ecs.telemetry.sessionMeasurable = mesurable;
|
||
ecs.telemetry.sessionWh = livreWh;
|
||
ctx.loads.append(ecs);
|
||
|
||
const Plan plan = sched.getPlan(ctx);
|
||
return plan.timeSlots.first().actions.first();
|
||
};
|
||
|
||
const QDateTime deuxHeuresAvant = utcDateTime(QDate(2026, 8, 31), QTime(5, 0, 0));
|
||
|
||
// 1. DEUX NIVEAUX, et l'éco d'abord — l'ordre des passes, pas celui des rangs.
|
||
const LoadAction a = planifier(deuxHeuresAvant, true, 0, 5000);
|
||
QCOMPARE(a.levels.count(), 2);
|
||
QCOMPARE(a.levels.at(0).level, QString("eco"));
|
||
QCOMPARE(a.levels.at(1).level, QString("comfort"));
|
||
|
||
// 2. UNE SEULE COMMANDE, dont la puissance est la SOMME des niveaux. C'est l'invariant qui
|
||
// rend la charge utile vérifiable — et LM-1003 qui rend le matériel sûr.
|
||
QCOMPARE(qRound(a.estimatedPowerW), 1000); // 2 kWh en 2 h
|
||
double sommeNiveaux = 0;
|
||
for (const LoadAction::Level &n : a.levels)
|
||
sommeNiveaux += n.targetW;
|
||
QCOMPARE(qRound(sommeNiveaux), qRound(a.estimatedPowerW));
|
||
|
||
// 3. LE FINANCEMENT VIT DANS LE NIVEAU (R2). L'éco est acheté, le confort ne l'est pas —
|
||
// un `funding` par CHARGE serait indécidable ici, et c'est tout l'objet du changement.
|
||
QCOMPARE(a.levels.at(0).funding, LoadAction::Grid);
|
||
QCOMPARE(a.levels.at(1).funding, LoadAction::Surplus);
|
||
QCOMPARE(qRound(a.counts.value(CountKey::DrawCommitted)), 1000);
|
||
|
||
// 4. LA TRAJECTOIRE, et pas seulement l'instant. Chacun de ces champs répond à une question
|
||
// qu'un client se posera devant sa facture : combien, pour quoi, et jusqu'à quand.
|
||
const QVariantMap p = a.levels.at(0).reason.params;
|
||
QCOMPARE(a.levels.at(0).reason.code, QString(DecisionCode::EcoFloorGrid));
|
||
QCOMPARE(p.value("gridW").toInt(), 1000);
|
||
QCOMPARE(p.value("remainingWh").toInt(), 2000);
|
||
QCOMPARE(p.value("targetWh").toInt(), 2000);
|
||
QCOMPARE(p.value("remainingS").toInt(), 7200);
|
||
QCOMPARE(p.value("regime").toString(), QString("measured"));
|
||
QCOMPARE(p.value("atMaximum").toBool(), false);
|
||
|
||
// …et le TEXTE le dit, sans quoi le motif ne serait lisible que par un programme.
|
||
const QString texte = renderFr(a.levels.at(0).reason, "ECS");
|
||
QVERIFY2(texte.contains("2000 Wh"), qUtf8Printable("motif: " + texte));
|
||
QVERIFY2(texte.contains("2 h"), qUtf8Printable("motif: " + texte));
|
||
|
||
// 5. L'AUTORISATION DE SOUTIRAGE BORNE L'ÉCO. Le plancher éco est une raison d'acheter, pas
|
||
// un droit de faire disjoncter : marge à 400 W, on n'achète que 400.
|
||
const LoadAction borne = planifier(deuxHeuresAvant, true, 0, 400);
|
||
QCOMPARE(qRound(borne.levels.at(0).targetW), 400);
|
||
|
||
// 6. R6 — SOUS `unmeasurable`, JAMAIS « tenue ». Même avec 3 kWh au compteur de session, on
|
||
// ne sait pas ce qui a été livré POUR CETTE OBLIGATION : conclure serait affirmer sur ce
|
||
// qu'on ignore, et une échéance qui s'affiche comme tenue sans l'être rassure à tort.
|
||
const LoadAction inconnu = planifier(deuxHeuresAvant, false, 3000, 5000);
|
||
QCOMPARE(inconnu.levels.at(0).reason.code, QString(DecisionCode::EcoFloorGrid));
|
||
QVERIFY2(inconnu.levels.at(0).reason.code != QString(DecisionCode::EcoFloorMet),
|
||
"ECO_FLOOR_MET est interdit sous un régime non mesuré (R6)");
|
||
QCOMPARE(inconnu.levels.at(0).progress.regime, QString(ProgressRegime::Unmeasurable));
|
||
QVERIFY2(!inconnu.levels.at(0).progress.delivered,
|
||
"sous unmeasurable, deliveredWh n'a pas de sens et ne sera pas publié");
|
||
|
||
// 7. SOUS `unmeasurable`, `remainingWh` VAUT LA CIBLE INTACTE. Rien n'est soustrait, parce
|
||
// qu'on ne sait rien de ce qui a été livré. Soustraire une intégration interne ferait
|
||
// franchir la frontière à une ESTIMATION sous l'allure d'un fait — le chemin exact de
|
||
// `carBatteryLevel`, refusé en LM-1009 D3.
|
||
//
|
||
// Et que `regime` voyage dans les mêmes params ne suffirait pas : un client qui lit
|
||
// `remainingWh` sans regarder `regime` obtiendrait un nombre calculé, sans rien pour le
|
||
// lui dire. « 2000 Wh restants sur 2000 » doit signifier « rien de mesurable », pas
|
||
// « rien livré » — et l'écran ne peut trancher que si le contrat le dit.
|
||
const QVariantMap pu = inconnu.levels.at(0).reason.params;
|
||
QCOMPARE(pu.value("remainingWh").toInt(), pu.value("targetWh").toInt());
|
||
QCOMPARE(pu.value("remainingWh").toInt(), 2000);
|
||
|
||
// 8. IDENTITÉ SOUS `measured` — troisième contrôle vérifiable, au même titre que
|
||
// Σ counts == targetW et authorisedW − committedW == remainingW. C'est ce qui permet à
|
||
// un client de constater la cohérence au lieu de la supposer.
|
||
const LoadAction partiel = planifier(deuxHeuresAvant, true, 800, 5000);
|
||
const LoadAction suite = planifier(deuxHeuresAvant, true, 1400, 5000);
|
||
for (const LoadAction &act : {partiel, suite}) {
|
||
const QVariantMap pm = act.levels.at(0).reason.params;
|
||
QVERIFY2(act.levels.at(0).progress.delivered, "régime mesuré attendu");
|
||
QCOMPARE(qRound(act.levels.at(0).progress.deliveredWh) + pm.value("remainingWh").toInt(),
|
||
pm.value("targetWh").toInt());
|
||
}
|
||
#endif
|
||
}
|
||
|
||
/*!
|
||
* \brief [§10] Une obligation abandonnée est un FAIT produit — et un fait produit se dit.
|
||
*
|
||
* \par Ce motif n'existait pas le matin même
|
||
* Il n'avait pas d'ÉMETTEUR : le créer alors aurait posé un invariant sur un code que rien ne
|
||
* produit — un test toujours vert, donc un test qui ne protège rien. La bascule de période
|
||
* (\rule{LM-1013-c}) l'a fait naître. Le journal disait déjà l'abandon ; l'écran, non.
|
||
*
|
||
* \par Trois causes, trois gestes
|
||
* `drawCap` envoie regarder l'abonnement ou les autres consommateurs ; `loadCapacity` envoie
|
||
* lire la fiche technique de la charge ; `unmeasurable` envoie regarder ce que la borne publie.
|
||
* Sans la cause, l'écran n'affiche qu'un reproche sans recours.
|
||
*
|
||
* \warning **Ce test devient faux** si l'obligation cesse d'être bornée par l'autorisation de
|
||
* soutirage — alors `drawCap` ne serait plus une cause possible, et c'est le cas 1 qu'il
|
||
* faudrait réécrire, pas le moteur.
|
||
*/
|
||
void Simulation::testEcoFloorMissedSaysWhatWasLostAndWhy()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
RuleBasedScheduler sched(arb);
|
||
|
||
auto cycle = [&](const QString &id, const QDateTime &at, bool mesurable,
|
||
double livreWh, double margeW) -> LoadAction {
|
||
SurplusContext ctx;
|
||
ctx.timestamp = at;
|
||
ctx.meter.importW = 500;
|
||
ctx.drawMargin.borne = true;
|
||
ctx.drawMargin.watts = margeW;
|
||
ctx.drawMargin.source = DrawCapSource::Breaker;
|
||
ctx.drawMargin.phase = 0;
|
||
|
||
LoadContext ecs;
|
||
ecs.id = id;
|
||
ecs.label = "ECS";
|
||
ecs.adapter = "etmvariableload";
|
||
ecs.priority = 1;
|
||
ecs.declared.maxPowerW = 3000;
|
||
ecs.needs.minEnergyWhPerDay = 2000;
|
||
ecs.needs.dailyDeadline = "07:00";
|
||
ecs.telemetry.sessionMeasurable = mesurable;
|
||
ecs.telemetry.sessionWh = livreWh;
|
||
ctx.loads.append(ecs);
|
||
return sched.getPlan(ctx).timeSlots.first().actions.first();
|
||
};
|
||
|
||
const QDateTime avant = utcDateTime(QDate(2026, 8, 31), QTime(5, 0, 0));
|
||
const QDateTime apres = utcDateTime(QDate(2026, 8, 31), QTime(7, 30, 0));
|
||
|
||
// --- Cas 1 : MESURÉ, bridé par l'autorisation de soutirage ------------------------------
|
||
// Marge à 200 W alors que la cadence en demande 1000 : on note `drawCap`, et à la bascule
|
||
// c'est cette cause-là qui est publiée.
|
||
cycle("ecs-cap", avant, true, 0, 200);
|
||
cycle("ecs-cap", avant, true, 300, 200); // 300 Wh livrés sur 2000
|
||
const LoadAction close = cycle("ecs-cap", apres, true, 300, 200);
|
||
QCOMPARE(close.levels.at(0).reason.code, QString(DecisionCode::EcoFloorMissed));
|
||
const QVariantMap pc = close.levels.at(0).reason.params;
|
||
QCOMPARE(pc.value("cause").toString(), QString(MissedCause::DrawCap));
|
||
QCOMPARE(pc.value("missingWh").toInt(), 1700); // 2000 dus, 300 livrés
|
||
QCOMPARE(pc.value("targetWh").toInt(), 2000);
|
||
// Le TEXTE porte le manque ET le geste. « Non tenue » seul serait un reproche sans recours.
|
||
const QString tc = renderFr(close.levels.at(0).reason, "ECS");
|
||
QVERIFY2(tc.contains("1700"), qUtf8Printable("motif: " + tc));
|
||
QVERIFY2(tc.contains("soutirage"), qUtf8Printable("motif: " + tc));
|
||
|
||
// --- Cas 2 : NON MESURÉ -----------------------------------------------------------------
|
||
cycle("ecs-inc", avant, false, 0, 5000);
|
||
const LoadAction closeInc = cycle("ecs-inc", apres, false, 0, 5000);
|
||
QCOMPARE(closeInc.levels.at(0).reason.code, QString(DecisionCode::EcoFloorMissed));
|
||
const QVariantMap pi = closeInc.levels.at(0).reason.params;
|
||
QCOMPARE(pi.value("cause").toString(), QString(MissedCause::Unmeasurable));
|
||
// `missingWh` ABSENT, jamais 0. On ne sait pas ce qui a été livré, donc pas davantage ce
|
||
// qui manquait : un zéro se lirait « il ne manquait rien », là où la vérité est « on n'en
|
||
// sait rien ». Publier un chiffre inventerait la grandeur dont l'absence EST le problème.
|
||
QVERIFY2(!pi.contains("missingWh"),
|
||
"sous unmeasurable, le manque n'est pas chiffrable — pas de zéro forgé");
|
||
const QString ti = renderFr(closeInc.levels.at(0).reason, "ECS");
|
||
QVERIFY2(ti.contains("pas chiffrable"), qUtf8Printable("motif: " + ti));
|
||
|
||
// --- Cas 3 : obligation TENUE — aucune clôture manquée ----------------------------------
|
||
cycle("ecs-ok", avant, true, 0, 5000);
|
||
const LoadAction closeOk = cycle("ecs-ok", apres, true, 2500, 5000);
|
||
QVERIFY2(closeOk.levels.at(0).reason.code != QString(DecisionCode::EcoFloorMissed),
|
||
"une obligation tenue ne produit pas de motif de non-tenue");
|
||
|
||
// ===== Correctif b — ce que le cas 1 publiait sans le DIRE ===============================
|
||
// Le niveau portait le motif, mais personne ne le lisait là : ni la tête de l'action (donc
|
||
// ni `decision` en RPC ni la ligne [Arbitre]), ni le journal à la bascule.
|
||
//
|
||
// 1-b. EN TÊTE du motif au cycle de clôture — et à ce cycle-là seulement.
|
||
QCOMPARE(close.reason.code, QString(DecisionCode::EcoFloorMissed));
|
||
QCOMPARE(publishedLevel(close), QString("eco"));
|
||
QCOMPARE(close.reason.params.value("missingWh").toInt(), 1700);
|
||
const LoadAction lendemain = cycle("ecs-cap", apres.addSecs(60), true, 300, 200);
|
||
QVERIFY2(lendemain.reason.code != QString(DecisionCode::EcoFloorMissed),
|
||
"la non-tenue est un événement de CYCLE : elle ne se répète pas au suivant");
|
||
|
||
// 1-c. Le JOURNAL le dit à la bascule : livré, cible, manque et cause.
|
||
cycle("ecs-jrn", avant, true, 0, 200);
|
||
cycle("ecs-jrn", avant, true, 300, 200);
|
||
{
|
||
JournalCapture journal;
|
||
cycle("ecs-jrn", apres, true, 300, 200);
|
||
QVERIFY2(journal.contains("CLOSE NON TENUE"), qUtf8Printable(journal.texte()));
|
||
QVERIFY2(journal.contains("livré 300/2000 Wh, manque 1700 Wh"), qUtf8Printable(journal.texte()));
|
||
QVERIFY2(journal.contains(QString(MissedCause::DrawCap)), qUtf8Printable(journal.texte()));
|
||
}
|
||
|
||
// --- Cas 4 : MESURE INTERMITTENTE — le livré de clôture compte les tronçons CLOS --------
|
||
// 800 Wh mesurés, un trou, puis 500 Wh de plus sur un compteur qui a avancé pendant le
|
||
// trou (base reprise au retour). La période a reçu 1300 Wh mesurés : il manque 700 Wh, pas
|
||
// 1500 — la clôture ne comptait que le dernier tronçon.
|
||
cycle("ecs-trou", avant, true, 0, 5000); // base prise ici
|
||
cycle("ecs-trou", avant, true, 800, 5000); // 800 Wh
|
||
cycle("ecs-trou", avant, false, 800, 5000); // le lien tombe : tronçon clos
|
||
cycle("ecs-trou", avant, true, 5000, 5000); // retour, base reprise à 5000
|
||
cycle("ecs-trou", avant, true, 5500, 5000); // 500 Wh de plus
|
||
const LoadAction closeTrou = cycle("ecs-trou", apres, true, 5500, 5000);
|
||
QCOMPARE(closeTrou.reason.code, QString(DecisionCode::EcoFloorMissed));
|
||
QCOMPARE(closeTrou.reason.params.value("missingWh").toInt(), 700);
|
||
|
||
// --- Cas 5 : la NOTIFICATION, même quand rien d'autre ne bouge ---------------------------
|
||
// Une charge INDISPONIBLE garde LOAD_UNAVAILABLE en tête (préséance) et sa puissance gelée :
|
||
// à la clôture, seul un niveau change. Le filtre de notification ne comparait que le code de
|
||
// tête — la trame du seul cycle qui porte la non-tenue n'était jamais notifiée.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-missed.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
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", 2000);
|
||
QUuid relaisId = addPowerSwitch(1000, 27072);
|
||
QVERIFY(tm->findConfiguredThing(relaisId));
|
||
QVariantList l;
|
||
l.append(QVariantMap{{"id", "ecs-indispo"}, {"label", "ECS"}, {"adapter", "relay-router"},
|
||
{"mode", "fixed"}, {"priority", 1}, {"enabled", true},
|
||
{"relays", QVariantList() << QVariantMap{{"thingId", relaisId.toString()},
|
||
{"powerW", 1000}}},
|
||
{"minOnS", 0}, {"minOffS", 0},
|
||
{"needs", QVariantMap{{"dailyDeadline", "07:00"},
|
||
{"minEnergyWhPerDay", 2000}}}});
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
QVERIFY(removeDevice(relaisId).toMap().value("params").toMap().value("thingError").toString()
|
||
== "ThingErrorNoError");
|
||
|
||
const QDateTime t1 = utcDateTime(QDate(2026, 8, 31), QTime(6, 58, 0));
|
||
arb->simulationCallUpdate(t1);
|
||
arb->simulationCallUpdate(t1.addSecs(60)); // trame publiée, état stable
|
||
QCoreApplication::processEvents();
|
||
QCOMPARE(telemetrieParId(arb->loadTelemetry()).value("ecs-indispo").value("decision").toMap()
|
||
.value("code").toString(), QString(DecisionCode::LoadUnavailable));
|
||
|
||
QSignalSpy notifs(arb, &EnergyArbitrator::loadTelemetryChanged);
|
||
{
|
||
JournalCapture journal;
|
||
arb->simulationCallUpdate(t1.addSecs(180)); // 07:01 — la période se clôt
|
||
QCoreApplication::processEvents();
|
||
QVERIFY2(journal.contains("CLOSE NON TENUE"), qUtf8Printable(journal.texte()));
|
||
QVERIFY2(journal.contains("non chiffrable"), qUtf8Printable(journal.texte()));
|
||
}
|
||
const QVariantMap ch = telemetrieParId(arb->loadTelemetry()).value("ecs-indispo");
|
||
QCOMPARE(ch.value("decision").toMap().value("code").toString(),
|
||
QString(DecisionCode::LoadUnavailable)); // préséance : indisponible d'abord
|
||
bool niveauManque = false;
|
||
for (const QVariant &n : ch.value("levels").toList())
|
||
if (n.toMap().value("decision").toMap().value("code").toString()
|
||
== QLatin1String(DecisionCode::EcoFloorMissed))
|
||
niveauManque = true;
|
||
QVERIFY2(niveauManque, "le niveau éco de clôture doit être publié");
|
||
QVERIFY2(notifs.count() >= 1,
|
||
"la trame du cycle de clôture non tenue doit être notifiée, même sans autre changement");
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
/*!
|
||
* \brief [Correctif a] Le motif publié décrit l'action FUSIONNÉE — celui de la part majoritaire.
|
||
*
|
||
* \par Le défaut
|
||
* La fusion des passes additionnait les watts de l'éco à ceux du confort sans toucher au motif :
|
||
* une consigne achetée en entier au réseau se publiait « surplus insuffisant », `decision.level`
|
||
* valait « comfort », et le journal du routeur affichait la consigne fusionnée à côté du motif
|
||
* de la seule part confort. Constaté au banc (RAPPORT_CHAUFFE_EAU.md).
|
||
*
|
||
* \par La règle (décision du 2026-10-16)
|
||
* Le motif de tête est celui de la part MAJORITAIRE de la consigne, l'égalité allant à l'éco ;
|
||
* une part éco nulle ne prend jamais la tête. Les deux parts restent dans `levels`. Trois
|
||
* charges en consigne de puissance passent par la même fusion — relay-router, etmvariableload,
|
||
* borne « éco + minimum » — et chacune est éprouvée sur les mêmes cas.
|
||
*/
|
||
void Simulation::testTheDecisionDescribesTheMergedAction()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
RuleBasedScheduler sched(arb);
|
||
|
||
// 05:00 pour une échéance à 07:00 : 2000 Wh en 2 h, soit une cadence de 1000 W (mesurée).
|
||
const QDateTime t = utcDateTime(QDate(2026, 8, 31), QTime(5, 0, 0));
|
||
|
||
enum class Besoin { Aucun, Obligation, Minimum };
|
||
auto charge = [](const QString &type, const QString &id, Besoin besoin) {
|
||
LoadContext lc;
|
||
lc.id = id;
|
||
lc.label = type;
|
||
lc.adapter = type;
|
||
lc.priority = 1;
|
||
if (type == QLatin1String("relay-router")) {
|
||
lc.declared.powerLevels = {0, 500, 1000, 1500, 2000};
|
||
lc.declared.maxPowerW = 2000;
|
||
} else if (type == QLatin1String("etmvariableload")) {
|
||
lc.declared.maxPowerW = 3000;
|
||
} else {
|
||
lc.declared.maxPowerW = 7000;
|
||
lc.declared.minPowerW = 1000;
|
||
lc.declared.phases = 1;
|
||
lc.telemetry.pluggedIn = true;
|
||
}
|
||
if (besoin == Besoin::Obligation) {
|
||
lc.needs.minEnergyWhPerDay = 2000;
|
||
lc.needs.dailyDeadline = "07:00";
|
||
lc.telemetry.sessionMeasurable = true;
|
||
} else if (besoin == Besoin::Minimum) {
|
||
lc.needs.minPowerWAlways = 1000; // la borne « éco + minimum »
|
||
}
|
||
return lc;
|
||
};
|
||
auto planifier = [&](const LoadContext &lc, double exportW) -> LoadAction {
|
||
SurplusContext ctx;
|
||
ctx.timestamp = t;
|
||
ctx.meter.exportW = exportW;
|
||
ctx.meter.importW = exportW > 0 ? 0 : 500;
|
||
ctx.loads.append(lc);
|
||
return sched.getPlan(ctx).timeSlots.first().actions.first();
|
||
};
|
||
auto niveau = [](const LoadAction &a, const char *nom) -> LoadAction::Level {
|
||
for (const LoadAction::Level &n : a.levels)
|
||
if (n.level == QLatin1String(nom))
|
||
return n;
|
||
return {};
|
||
};
|
||
|
||
// Sur les trois charges, la part éco vaut 1000 W : la cadence de l'obligation pour les deux
|
||
// premières, le minimum pour la borne. Les exports ci-dessous dosent la part confort.
|
||
for (const QString &type : {QStringLiteral("relay-router"), QStringLiteral("etmvariableload"),
|
||
QStringLiteral("evcharger")}) {
|
||
const Besoin eco = type == QLatin1String("evcharger") ? Besoin::Minimum : Besoin::Obligation;
|
||
const QByteArray quoi = type.toUtf8();
|
||
|
||
// (i) éco seule, confort nul → le motif de l'achat, et le niveau qui le porte.
|
||
const LoadAction seul = planifier(charge(type, type + "-i", eco), 0);
|
||
QCOMPARE(qRound(niveau(seul, "eco").targetW), 1000);
|
||
QCOMPARE(qRound(niveau(seul, "comfort").targetW), 0);
|
||
QVERIFY2(seul.reason.code == QLatin1String(DecisionCode::EcoFloorGrid),
|
||
qUtf8Printable(QStringLiteral("%1 (i) : %2").arg(type, seul.reason.code)));
|
||
QCOMPARE(publishedLevel(seul), QString("eco"));
|
||
// La part confort RESTE lisible : la règle choisit la tête, elle n'efface rien.
|
||
QVERIFY2(!niveau(seul, "comfort").reason.isEmpty(), quoi.constData());
|
||
|
||
// (ii) éco = confort → égalité, la tête va à l'éco.
|
||
const LoadAction egal = planifier(charge(type, type + "-ii", eco), 1000);
|
||
QCOMPARE(qRound(niveau(egal, "comfort").targetW), 1000);
|
||
QVERIFY2(egal.reason.code == QLatin1String(DecisionCode::EcoFloorGrid),
|
||
qUtf8Printable(QStringLiteral("%1 (ii) : %2").arg(type, egal.reason.code)));
|
||
QCOMPARE(publishedLevel(egal), QString("eco"));
|
||
QCOMPARE(niveau(egal, "comfort").reason.code, QString(DecisionCode::SurplusSetpoint));
|
||
|
||
// (ii-b) confort > éco → le motif confort reste en tête, l'éco reste dans les niveaux.
|
||
const LoadAction confort = planifier(charge(type, type + "-iib", eco), 1500);
|
||
QCOMPARE(qRound(niveau(confort, "comfort").targetW), 1500);
|
||
QVERIFY2(confort.reason.code == QLatin1String(DecisionCode::SurplusSetpoint),
|
||
qUtf8Printable(QStringLiteral("%1 (ii-b) : %2").arg(type, confort.reason.code)));
|
||
QCOMPARE(publishedLevel(confort), QString("comfort"));
|
||
QCOMPARE(niveau(confort, "eco").reason.code, QString(DecisionCode::EcoFloorGrid));
|
||
|
||
// (iii) pas d'éco du tout → motif confort inchangé (non-régression).
|
||
const LoadAction sans = planifier(charge(type, type + "-iii", Besoin::Aucun), 1500);
|
||
QCOMPARE(sans.levels.count(), 1);
|
||
QCOMPARE(sans.reason.code, QString(DecisionCode::SurplusSetpoint));
|
||
QCOMPARE(publishedLevel(sans), QString("comfort"));
|
||
|
||
// (iv) obligation TENUE — niveau éco à 0 W (ECO_FLOOR_MET) : une part nulle ne prend
|
||
// jamais la tête, même devant un confort nul.
|
||
LoadContext tenue = charge(type, type + "-iv", Besoin::Obligation);
|
||
tenue.telemetry.sessionWh = 0;
|
||
planifier(tenue, 0); // base prise ici
|
||
tenue.telemetry.sessionWh = 2500; // 2500 Wh livrés sur 2000
|
||
const LoadAction met = planifier(tenue, 0);
|
||
QCOMPARE(niveau(met, "eco").reason.code, QString(DecisionCode::EcoFloorMet));
|
||
QVERIFY2(met.reason.code != QLatin1String(DecisionCode::EcoFloorMet),
|
||
qUtf8Printable(QStringLiteral("%1 (iv) : %2").arg(type, met.reason.code)));
|
||
QCOMPARE(met.reason.code, niveau(met, "comfort").reason.code);
|
||
QCOMPARE(publishedLevel(met), QString("comfort"));
|
||
}
|
||
|
||
// --- Le JOURNAL et la RPC, par le cycle réel de l'arbitre --------------------------------
|
||
// Le motif publié et la ventilation doivent sortir là où on les lit : la ligne [Arbitre],
|
||
// celle de l'adaptateur, et `decision` dans GetLoadTelemetry.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-motif-action.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
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 relaisId = addPowerSwitch(1000, 27070);
|
||
QUuid varId = addEtmVariableLoad(27071);
|
||
Thing *var = tm->findConfiguredThing(varId);
|
||
QVERIFY(var && tm->findConfiguredThing(relaisId));
|
||
var->setStateValue("currentPowerW", 0);
|
||
|
||
// Sans compteur d'énergie, l'avancement n'est pas mesurable : la cadence est constante,
|
||
// 2000 Wh / 24 h ≈ 83 W — une part éco non nulle, et un confort nul sous import.
|
||
const QVariantMap besoins{{"dailyDeadline", "07:00"}, {"minEnergyWhPerDay", 2000}};
|
||
QVariantList l;
|
||
l.append(QVariantMap{{"id", "ecs-motif"}, {"label", "ECS"}, {"adapter", "relay-router"},
|
||
{"mode", "fixed"}, {"priority", 1}, {"enabled", true},
|
||
{"relays", QVariantList() << QVariantMap{{"thingId", relaisId.toString()},
|
||
{"powerW", 1000}}},
|
||
{"minOnS", 0}, {"minOffS", 0}, {"needs", besoins}});
|
||
l.append(QVariantMap{{"id", varId.toString()}, {"label", "Routeur"},
|
||
{"adapter", "etmvariableload"}, {"mode", "dynamic"},
|
||
{"maxPowerW", 3000}, {"priority", 2}, {"enabled", true},
|
||
{"needs", besoins}});
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
meter->setStateValue("currentPower", 3000); // import franc : aucun surplus
|
||
{
|
||
JournalCapture journal;
|
||
arb->recordMeterUpdate(t0);
|
||
arb->simulationCallUpdate(t0);
|
||
QCoreApplication::processEvents();
|
||
|
||
const QString texte = journal.texte();
|
||
for (const char *prefixe : {"[RelayRouter]", "[EtmVariableLoadAdapter]", "[Arbitre]"}) {
|
||
bool vu = false;
|
||
for (const QString &ligne : texte.split('\n'))
|
||
if (ligne.contains(QLatin1String(prefixe)) && ligne.contains("ventilation")
|
||
&& ligne.contains("[ECO_FLOOR_GRID]") && ligne.contains("Obligation de"))
|
||
vu = true;
|
||
QVERIFY2(vu, qUtf8Printable(QStringLiteral("%1 : aucune ligne ne rend le motif éco et "
|
||
"la ventilation\n%2").arg(prefixe, texte)));
|
||
}
|
||
}
|
||
|
||
const QHash<QString, QVariantMap> parId = telemetrieParId(arb->loadTelemetry());
|
||
for (const QString &id : {QStringLiteral("ecs-motif"), varId.toString()}) {
|
||
const QVariantMap ch = parId.value(id);
|
||
QVERIFY2(!ch.isEmpty(), qUtf8Printable(id));
|
||
const QVariantMap decision = ch.value("decision").toMap();
|
||
QCOMPARE(decision.value("code").toString(), QString(DecisionCode::EcoFloorGrid));
|
||
QCOMPARE(decision.value("level").toString(), QString("eco"));
|
||
QCOMPARE(ch.value("levels").toList().count(), 2);
|
||
}
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
/*!
|
||
* \brief [Correctif c] L'acheté se compte sur l'énergie MESURÉE, à la part éco de l'APPLIQUÉ.
|
||
*
|
||
* \par Le défaut
|
||
* `purchasedWh` intégrait la CIBLE éco, avant palier et verrou. Sur un relay-router, le palier
|
||
* arrondit vers le bas : une cible de 300 W laisse le relais de 500 W ouvert, et ces watts
|
||
* étaient comptés achetés. Constaté au banc toute une nuit (ANALYSE_CORRECTIFS_MOTEUR.md, c).
|
||
*
|
||
* \par La règle
|
||
* `achatWh += ΔE × min(1, éco appliquée / puissance appliquée)`, ΔE lu sur le compteur de la
|
||
* charge, la part prise sur l'action appliquée au cycle PRÉCÉDENT. Sans mesure : absent.
|
||
*
|
||
* Chaque cas tourne trois cycles (A, B, C à 30 min) sur une charge neuve, et se lit en C :
|
||
* l'ancienne règle publiait l'achat d'un cycle avec un cycle de retard, si bien qu'une lecture
|
||
* en B ne l'aurait pas distinguée de la nouvelle. Routeur 500/1000/2000 W, compteur `meter`
|
||
* dont le test pose lui-même `totalEnergyConsumed` (puissance du compteur à 0 : le mock
|
||
* n'intègre rien de son côté).
|
||
*/
|
||
void Simulation::testPurchasedIsCountedOnTheMeasuredEnergy()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-achat.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();
|
||
|
||
const QUuid racineId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(racineId);
|
||
Thing *racine = tm->findConfiguredThing(racineId);
|
||
const QUuid compteurId = addMeter(27080);
|
||
Thing *compteur = tm->findConfiguredThing(compteurId);
|
||
const QUuid r500 = addPowerSwitch(500, 27081);
|
||
const QUuid r1000 = addPowerSwitch(1000, 27082);
|
||
const QUuid r2000 = addPowerSwitch(2000, 27083);
|
||
QVERIFY(racine && compteur && tm->findConfiguredThing(r500) && tm->findConfiguredThing(r1000)
|
||
&& tm->findConfiguredThing(r2000));
|
||
racine->setStateValue("connected", true);
|
||
compteur->setStateValue("connected", true);
|
||
compteur->setStateValue("currentPower", 0); // le mock n'intègre rien : le test pose
|
||
compteur->setStateValue("totalEnergyConsumed", 10.0);
|
||
|
||
// Une charge NEUVE par cas : son cumul, sa période et son action appliquée partent de rien.
|
||
// La précédente est d'abord RETIRÉE, et sa mise en sécurité (asynchrone) laissée aboutir :
|
||
// sans quoi la neuve relirait un relais encore fermé que l'ancienne éteint juste après.
|
||
auto poser = [&](const QString &id, int besoinWh, bool avecCompteur) {
|
||
const QString vide = injectAndWait("NymeaEnergy.SetLoadConfig",
|
||
QVariantMap({{"loadConfigs", QVariantList()}}))
|
||
.toMap().value("params").toMap().value("energyError").toString();
|
||
if (vide != QLatin1String("EnergyErrorNoError"))
|
||
return vide;
|
||
QTest::qWait(100);
|
||
QVariantMap m{{"id", id}, {"label", id}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", 1}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0},
|
||
{"relays", QVariantList{QVariantMap{{"thingId", r500.toString()}, {"powerW", 500}},
|
||
QVariantMap{{"thingId", r1000.toString()}, {"powerW", 1000}},
|
||
QVariantMap{{"thingId", r2000.toString()}, {"powerW", 2000}}}},
|
||
{"needs", QVariantMap{{"dailyDeadline", "07:00"}, {"minEnergyWhPerDay", besoinWh}}}};
|
||
if (avecCompteur)
|
||
m.insert("meterThingId", compteurId.toString());
|
||
const QString err = injectAndWait("NymeaEnergy.SetLoadConfig",
|
||
QVariantMap({{"loadConfigs", QVariantList{m}}}))
|
||
.toMap().value("params").toMap().value("energyError").toString();
|
||
QTest::qWait(100);
|
||
return err;
|
||
};
|
||
auto cycle = [&](const QDateTime &t) {
|
||
arb->recordMeterUpdate(t);
|
||
arb->simulationCallUpdate(t);
|
||
QTest::qWait(50); // les écritures de relais sont asynchrones : on lit l'état POSÉ
|
||
};
|
||
auto ajouterWh = [&](double wh) {
|
||
compteur->setStateValue("totalEnergyConsumed",
|
||
compteur->stateValue("totalEnergyConsumed").toDouble() + wh / 1000.0);
|
||
};
|
||
auto niveau = [&](const QString &id, const QString &nom) {
|
||
for (const QVariant &n : telemetrieParId(arb->loadTelemetry()).value(id).value("levels").toList())
|
||
if (n.toMap().value("level").toString() == nom)
|
||
return n.toMap();
|
||
return QVariantMap();
|
||
};
|
||
auto progres = [&](const QString &id) { return niveau(id, "eco").value("progress").toMap(); };
|
||
auto ferme = [&](const QUuid &r) { return tm->findConfiguredThing(r)->stateValue("power").toBool(); };
|
||
auto appliqueW = [&]() {
|
||
return (ferme(r500) ? 500 : 0) + (ferme(r1000) ? 1000 : 0) + (ferme(r2000) ? 2000 : 0);
|
||
};
|
||
|
||
// 2 h avant l'échéance : la cadence éco vaut besoin / 2 h.
|
||
const QDateTime tA = utcDateTime(QDate(2026, 8, 31), QTime(5, 0, 0));
|
||
const QDateTime tB = tA.addSecs(1800), tC = tA.addSecs(3600);
|
||
|
||
// (i) CIBLE 300 W, RELAIS OUVERT → 0. Le palier de 500 W ne s'enclenche pas ; rien ne passe.
|
||
racine->setStateValue("currentPower", 3000); // import franc : aucun confort
|
||
QCOMPARE(poser("achat-i", 600, true), QString("EnergyErrorNoError"));
|
||
cycle(tA);
|
||
QCOMPARE(qRound(niveau("achat-i", "eco").value("targetW").toDouble()), 300);
|
||
QCOMPARE(appliqueW(), 0);
|
||
cycle(tB);
|
||
cycle(tC);
|
||
QCOMPARE(progres("achat-i").value("regime").toString(), QString("measured"));
|
||
QVERIFY2(progres("achat-i").contains("purchasedWh"), "sous measured, l'acheté est publié");
|
||
QCOMPARE(progres("achat-i").value("purchasedWh").toDouble(), 0.0);
|
||
|
||
// (ii) ÉCO SEUL 500 W, COMPTEUR +250 Wh → 250.
|
||
QCOMPARE(poser("achat-ii", 1000, true), QString("EnergyErrorNoError"));
|
||
cycle(tA);
|
||
QCOMPARE(appliqueW(), 500);
|
||
ajouterWh(250);
|
||
cycle(tB);
|
||
cycle(tC);
|
||
QCOMPARE(qRound(progres("achat-ii").value("purchasedWh").toDouble()), 250);
|
||
QCOMPARE(qRound(progres("achat-ii").value("deliveredWh").toDouble()), 250);
|
||
|
||
// (iii) COMPTEUR FIGÉ, RELAIS FERMÉ → 0. La commande n'est pas une énergie.
|
||
QCOMPARE(poser("achat-iii", 1000, true), QString("EnergyErrorNoError"));
|
||
cycle(tA);
|
||
cycle(tB);
|
||
QVERIFY(appliqueW() > 0);
|
||
cycle(tC);
|
||
QCOMPARE(progres("achat-iii").value("purchasedWh").toDouble(), 0.0);
|
||
QCOMPARE(progres("achat-iii").value("deliveredWh").toDouble(), 0.0);
|
||
|
||
// (iv) ÉCO 500 + CONFORT 500, COMPTEUR +500 Wh → 250. La moitié seulement a été achetée.
|
||
racine->setStateValue("currentPower", -500); // 500 W exportés : le confort
|
||
QCOMPARE(poser("achat-iv", 1000, true), QString("EnergyErrorNoError"));
|
||
cycle(tA);
|
||
QCOMPARE(qRound(niveau("achat-iv", "eco").value("targetW").toDouble()), 500);
|
||
QCOMPARE(qRound(niveau("achat-iv", "comfort").value("targetW").toDouble()), 500);
|
||
QCOMPARE(appliqueW(), 1000);
|
||
racine->setStateValue("currentPower", 3000);
|
||
ajouterWh(500);
|
||
cycle(tB);
|
||
cycle(tC);
|
||
QCOMPARE(qRound(progres("achat-iv").value("purchasedWh").toDouble()), 250);
|
||
QCOMPARE(qRound(progres("achat-iv").value("deliveredWh").toDouble()), 500);
|
||
|
||
// (v) DÉROGATION : niveau « eco » au maximum, part 1 — l'énergie mesurée PENDANT la
|
||
// dérogation est achetée entière, et sur ce que le compteur a vu, pas sur 3 500 W × durée.
|
||
// La tranche d'AVANT (éco 500 + confort 500, part 0,5) garde sa propre part : un achat
|
||
// sauté pendant la dérogation la ferait recompter à la part de la dérogation.
|
||
racine->setStateValue("currentPower", -500);
|
||
QCOMPARE(poser("achat-v", 1000, true), QString("EnergyErrorNoError"));
|
||
cycle(tA); // période ouverte, éco + confort
|
||
QCOMPARE(appliqueW(), 1000);
|
||
QVERIFY(arb->poseDerogation("achat-v", 7200, 0, tA));
|
||
racine->setStateValue("currentPower", 3000);
|
||
ajouterWh(500); // 500 × 0,5 = 250 achetés
|
||
cycle(tB); // dérogation : 3 500 W appliqués
|
||
QCOMPARE(appliqueW(), 3500);
|
||
ajouterWh(250); // 250 × 1 = 250 achetés
|
||
cycle(tC);
|
||
QVERIFY(arb->leveDerogation("achat-v"));
|
||
cycle(tC.addSecs(1800)); // l'éco reprend et publie le cumul
|
||
QCOMPARE(qRound(progres("achat-v").value("purchasedWh").toDouble()), 500);
|
||
QCOMPARE(qRound(progres("achat-v").value("deliveredWh").toDouble()), 750);
|
||
|
||
// (vi) SANS MESURE D'ÉNERGIE → ABSENT. Jamais 0, jamais la commande.
|
||
QCOMPARE(poser("achat-vi", 1000, false), QString("EnergyErrorNoError"));
|
||
cycle(tA);
|
||
cycle(tB);
|
||
cycle(tC);
|
||
const QVariantMap sans = progres("achat-vi");
|
||
QCOMPARE(sans.value("regime").toString(), QString("unmeasurable"));
|
||
QVERIFY2(!sans.contains("purchasedWh"),
|
||
qUtf8Printable("sans mesure, purchasedWh doit être ABSENT : "
|
||
+ QJsonDocument::fromVariant(sans).toJson(QJsonDocument::Compact)));
|
||
QVERIFY(!sans.contains("deliveredWh"));
|
||
|
||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||
QFile::remove(cfgPath);
|
||
#endif
|
||
}
|
||
|
||
/*!
|
||
* \brief [Commande perdue, étape 2] L'écart se PUBLIE — il ne se diagnostique pas.
|
||
*
|
||
* \par Ce que l'écart vaut dépend de la SOURCE, dans les deux sens
|
||
* | | concordance | divergence |
|
||
* |---|---|---|
|
||
* | `device` | **ne prouve rien** — plusieurs bornes renvoient leur consigne en guise de puissance | la charge n'obéit pas, et elle le dit elle-même |
|
||
* | `meter` | **preuve** — un tiers le constate | trois hypothèses : la charge, le compteur, le câblage |
|
||
*
|
||
* Ce test le DÉMONTRE au lieu de l'énoncer : la borne du mock dérive sa puissance de sa propre
|
||
* consigne, si bien que `expectedW == observedW` **par construction**. Une concordance obtenue
|
||
* ainsi n'est pas une preuve d'obéissance — c'est un écho.
|
||
*
|
||
* \par Un écart n'est pas un défaut
|
||
* Une borne qui n'obéit pas parce que le véhicule limite, parce que le câble est en 16 A, ou
|
||
* parce qu'elle est en mode manuel, produit le **même nombre** qu'une commande perdue. Le champ
|
||
* ne porte donc aucune cause : la cause n'est pas dans les watts.
|
||
*/
|
||
void Simulation::testCommandDivergenceIsPublishedNotDiagnosed()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-div.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
meter->setStateValue("currentPower", -8000); // 8 kW exportés
|
||
|
||
QUuid borneId = addCharger("A", 16, 26698);
|
||
Thing *borne = tm->findConfiguredThing(borneId);
|
||
QVERIFY(borne);
|
||
armerBorne(borne);
|
||
|
||
QVariantMap info;
|
||
info.insert("evChargerId", borneId);
|
||
info.insert("chargingMode", "ChargingModeEco");
|
||
injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 9, 1), QTime(13, 0, 0));
|
||
arb->simulationCallUpdate(t0);
|
||
QCoreApplication::processEvents();
|
||
|
||
QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
|
||
const QVariantMap b = tele.value(borneId.toString());
|
||
// 1. SOUS `device`, LA CONCORDANCE NE PROUVE RIEN — et c'est mesurable ici même : la borne
|
||
// du mock dérive `currentPower` de sa propre consigne, donc les deux nombres coïncident
|
||
// PAR CONSTRUCTION. Le champ dit ce qu'il voit ; il ne dit pas que la borne obéit.
|
||
QCOMPARE(b.value("measurement").toMap().value("source").toString(), QString("device"));
|
||
const QVariantMap cmd = b.value("command").toMap();
|
||
QVERIFY2(!cmd.isEmpty(), "sous une source mesurante, l'écart doit être publié");
|
||
QCOMPARE(qRound(cmd.value("expectedW").toDouble()), qRound(cmd.value("observedW").toDouble()));
|
||
QCOMPARE(cmd.value("divergent").toBool(), false);
|
||
QVERIFY2(!cmd.contains("since"), "pas de divergence, donc pas de date de début");
|
||
|
||
// 2. DIVERGENCE — la borne annonce autre chose que ce qu'on lui a commandé. On publie les
|
||
// deux nombres, le verdict, et DEPUIS QUAND : sans la date, une divergence permanente ne
|
||
// se distingue pas d'un déphasage d'un cycle entre l'écriture et sa mesure, ce qui est
|
||
// toute la différence entre le normal et le cas relevé le 2026-08-31.
|
||
// On passe par un COMPTEUR EXPLICITE, et pas par la mesure de la borne : le mock recalcule
|
||
// `currentPower` depuis la consigne à chaque recommande, si bien qu'une divergence injectée
|
||
// là serait effacée avant d'être publiée — ce qui EST la démonstration du point 1, mais
|
||
// interdit d'exercer le point 2 par ce chemin. Sous `meter`, un tiers observe, et c'est
|
||
// aussi le seul régime où la divergence a valeur de preuve.
|
||
QUuid compteurBorne = addMeter(26699);
|
||
Thing *cb = tm->findConfiguredThing(compteurBorne);
|
||
QVERIFY(cb);
|
||
cb->setStateValue("connected", true);
|
||
cb->setStateValue("currentPower", 800); // le compteur voit 800 W…
|
||
|
||
QVariantList cfgs = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
|
||
.value("params").toMap().value("loadConfigs").toList();
|
||
QVariantList majs;
|
||
for (const QVariant &v : cfgs) {
|
||
QVariantMap m = v.toMap();
|
||
if (m.value("id").toString() == borneId.toString())
|
||
m.insert("meterThingId", compteurBorne.toString());
|
||
majs.append(m);
|
||
}
|
||
injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", majs}}));
|
||
|
||
arb->simulationCallUpdate(t0.addSecs(60));
|
||
QCoreApplication::processEvents();
|
||
const QVariantMap b2 = telemetrieParId(arb->loadTelemetry()).value(borneId.toString());
|
||
QCOMPARE(b2.value("measurement").toMap().value("source").toString(), QString("meter"));
|
||
const QVariantMap cmd2 = b2.value("command").toMap();
|
||
QCOMPARE(cmd2.value("divergent").toBool(), true);
|
||
QCOMPARE(qRound(cmd2.value("observedW").toDouble()), 800);
|
||
QVERIFY2(cmd2.contains("since"), "une divergence doit dire depuis quand elle dure");
|
||
|
||
// 3. Et le champ NE QUALIFIE PAS. Aucun code de cause, aucun défaut : véhicule qui limite,
|
||
// câble en 16 A ou commande perdue produisent le même nombre. Le publier comme un défaut
|
||
// ferait accuser une installation saine.
|
||
QVERIFY2(!cmd2.contains("cause") && !cmd2.contains("faultCode"),
|
||
"l'écart se publie, il ne se diagnostique pas — la cause n'est pas dans les watts");
|
||
|
||
// 4. SANS SOURCE MESURANTE, l'objet est ABSENT — pas `divergent: false`, qui affirmerait une
|
||
// concordance qu'on ne peut pas constater. Une charge de configuration sans compteur
|
||
// désigné ni état de puissance est exactement ce cas.
|
||
// Une charge SANS mesure est ajoutée exprès : sans elle, la boucle ci-dessous ne trouverait
|
||
// aucun cas et l'assertion serait VACANTE — elle passerait sans rien vérifier. C'est le
|
||
// piège de la contre-épreuve inerte, et il a été constaté ici même : la garde retirée, le
|
||
// test passait toujours.
|
||
// Le relais est VOLONTAIREMENT inexistant : toutes les classes du mock portent un état de
|
||
// puissance, donc aucune charge normale ne produit `source: none`. Une charge dont le
|
||
// câblage manque, si — et c'est un cas réel, pas un artifice (ECS-111).
|
||
const QString relaisSansMesure = "{deadbeef-0000-4000-8000-00000000c0de}";
|
||
LoadConfigStore *store = new LoadConfigStore(arb);
|
||
arb->setLoadConfigStore(store);
|
||
LoadConfigs sans;
|
||
sans.append(LoadConfig::fromMap(QVariantMap{
|
||
{"id", "ecs-sans-mesure"}, {"label", "ECS"}, {"adapter", "relay-router"},
|
||
{"mode", "fixed"}, {"priority", 5}, {"enabled", true}, {"domain", "ecs"},
|
||
{"relays", QVariantList() << QVariantMap{{"thingId", relaisSansMesure},
|
||
{"powerW", 1000}}}}));
|
||
QString err;
|
||
QVERIFY2(store->setConfigs(sans, &err), err.toUtf8());
|
||
arb->simulationCallUpdate(t0.addSecs(120));
|
||
QCoreApplication::processEvents();
|
||
|
||
int examinees = 0;
|
||
for (const QVariantMap &e : telemetrieParId(arb->loadTelemetry())) {
|
||
if (e.value("measurement").toMap().value("source").toString() != QLatin1String("none"))
|
||
continue;
|
||
++examinees;
|
||
QVERIFY2(!e.contains("command"),
|
||
"rien ne mesure : il n'y a rien à comparer, et l'objet doit être absent");
|
||
}
|
||
QVERIFY2(examinees > 0,
|
||
"aucune charge sans mesure dans ce cycle : l'assertion aurait été vacante");
|
||
#endif
|
||
}
|
||
|
||
/*!
|
||
* \brief [Commande perdue, étape 3] Le recrédit est un PRÊT consenti contre l'obéissance.
|
||
*
|
||
* \par Ce que la mesure a corrigé dans le diagnostic
|
||
* Le constat de l'app disait « un budget de surplus qui ignore ces watts ». Vérification faite,
|
||
* **ce n'est pas ce qui se passe** : quand une charge consomme et obéit, la comptabilité est
|
||
* juste — sa conso est recréditée puis réallouée à elle-même, et les suivantes ne reçoivent que
|
||
* le surplus réel. Corriger cela aurait été corriger un non-défaut.
|
||
*
|
||
* \par Le vrai défaut, et il est plus grave
|
||
* Le recrédit anti-clignotement repose sur une hypothèse écrite dans le code : *« le budget est
|
||
* une mesure dont la conso de la charge a déjà été retranchée »* — autrement dit **« je peux
|
||
* rendre ces watts parce que je peux les reprendre »**. C'est un **prêt consenti contre
|
||
* l'obéissance**, et rien ne vérifie que l'emprunteur rembourse.
|
||
*
|
||
* Ce test épingle le cas où la promesse est engagée : A est commandée à **0** et ses 4 400 W
|
||
* sont redistribués. Si A obéit, tout est juste. **Si A n'obéit pas** — commande perdue, mode
|
||
* manuel, véhicule qui limite — la maison tire 8 400 W pour 4 900 W de production, soit
|
||
* **3 500 W importés que personne n'a décidés**.
|
||
*
|
||
* \warning **Ce test épingle le comportement ACTUEL, qui est correct SOUS HYPOTHÈSE.** Il
|
||
* devient faux le jour où le recrédit sera conditionné à l'obéissance constatée
|
||
* (`command.divergent`) — et ce jour-là, c'est l'allocation de B qu'il faudra revoir, pas le
|
||
* test. La distinction est écrite ici pour qu'on ne le « corrige » pas par réflexe.
|
||
*/
|
||
void Simulation::testRecreditIsALoanAgainstObedience()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
RuleBasedScheduler sched(arb);
|
||
|
||
SurplusContext ctx;
|
||
ctx.timestamp = utcDateTime(QDate(2026, 9, 1), QTime(13, 0, 0));
|
||
ctx.meter.exportW = 500; // 500 W exportés SEULEMENT
|
||
|
||
LoadContext a;
|
||
a.id = "A-coupee"; a.label = "A"; a.adapter = "etmvariableload"; a.priority = 1;
|
||
a.declared.maxPowerW = 6000;
|
||
a.declared.minPowerW = 5000; // plancher AU-DESSUS du budget : A est coupée
|
||
a.telemetry.currentPowerW = 4400; // …mais elle consomme 4 400 W à cet instant
|
||
ctx.loads.append(a);
|
||
|
||
LoadContext b;
|
||
b.id = "B-suivante"; b.label = "B"; b.adapter = "etmvariableload"; b.priority = 2;
|
||
b.declared.maxPowerW = 4000;
|
||
ctx.loads.append(b);
|
||
|
||
const Plan plan = sched.getPlan(ctx);
|
||
QHash<QString, LoadAction> parId;
|
||
for (const LoadAction &act : plan.timeSlots.first().actions)
|
||
parId.insert(act.loadId, act);
|
||
|
||
// 1. A est coupée — son plancher n'est pas payable, et le budget PASSE à la suivante.
|
||
QCOMPARE(qRound(parId.value("A-coupee").estimatedPowerW), 0);
|
||
QCOMPARE(parId.value("A-coupee").reason.code, QString(DecisionCode::BelowMinPower));
|
||
|
||
// 2. B reçoit 4 000 W — c'est-à-dire la conso de A, PRÊTÉE d'avance sur sa coupure.
|
||
QCOMPARE(qRound(parId.value("B-suivante").estimatedPowerW), 4000);
|
||
|
||
// 3. LE PRÊT EST VISIBLE DANS LA COMPTABILITÉ, et c'est ce qui le rend vérifiable : 4 000 W
|
||
// alloués pour 500 W de surplus réel. L'écart est exactement le recrédit.
|
||
QCOMPARE(qRound(plan.budget.surplusW), 500);
|
||
QCOMPARE(qRound(plan.budget.recreditedW), 4400);
|
||
|
||
// 5. LE PRÊT EST REFUSÉ quand le refus est ÉTABLI. Même scénario, mais la charge A ne suit
|
||
// pas la commande depuis plus longtemps que la latence de son mécanisme : ses watts ne
|
||
// sont plus rendus au budget, et B ne reçoit donc plus ce qu'on ne peut pas reprendre.
|
||
ctx.loads[0].telemetry.commandRefused = true;
|
||
ctx.loads[0].telemetry.refusedSinceS = 120;
|
||
const Plan refus = sched.getPlan(ctx);
|
||
QHash<QString, LoadAction> parIdR;
|
||
for (const LoadAction &act : refus.timeSlots.first().actions)
|
||
parIdR.insert(act.loadId, act);
|
||
QCOMPARE(qRound(refus.budget.recreditedW), 0); // on ne prête plus
|
||
// B ne reçoit QUE le surplus réel : 500 W, et non les 4 000 W qu'on ne peut pas reprendre.
|
||
QCOMPARE(qRound(parIdR.value("B-suivante").estimatedPowerW), 500);
|
||
|
||
// 6. ET ÇA SE DIT. Une suspension change l'allocation de TOUTES les suivantes ; si personne
|
||
// ne sait pourquoi B reçoit moins, on chercherait un défaut de cascade là où il y a une
|
||
// charge qui n'obéit pas. Le motif porte la suspension ET sa cause chiffrée.
|
||
QCOMPARE(parIdR.value("A-coupee").reason.code, QString(DecisionCode::RecreditSuspended));
|
||
QCOMPARE(parIdR.value("A-coupee").reason.params.value("refusedSinceS").toInt(), 120);
|
||
QCOMPARE(parIdR.value("A-coupee").reason.params.value("withheldW").toInt(), 4400);
|
||
const QString txt = renderFr(parIdR.value("A-coupee").reason, "A");
|
||
QVERIFY2(txt.contains("4400"), qUtf8Printable("motif: " + txt));
|
||
QVERIFY2(txt.contains("reprendre"), qUtf8Printable("motif: " + txt));
|
||
|
||
// 7. UN ÉCART RÉCENT N'EST PAS UN REFUS. La latence du mécanisme protège une charge saine
|
||
// qui n'a pas encore atteint sa consigne — suspendre là ferait osciller ce qui va bien.
|
||
// C'est `qualifierRefus()` qui tranche, sur le seuil du mécanisme et non une constante.
|
||
ctx.loads[0].telemetry.commandRefused = false; // écart présent mais RÉCENT
|
||
ctx.loads[0].telemetry.refusedSinceS = 3;
|
||
const Plan latence = sched.getPlan(ctx);
|
||
QCOMPARE(qRound(latence.budget.recreditedW), 4400); // le prêt est maintenu
|
||
QCOMPARE(qRound(plan.budget.allocatedW), 4000);
|
||
QVERIFY2(plan.budget.allocatedW > plan.budget.surplusW,
|
||
"le recrédit prête d'avance ce que la coupure de A doit libérer");
|
||
|
||
// 4. L'identité de réconciliation TIENT malgré tout — surplus + recrédit − alloué == restant.
|
||
// C'est ce qui distingue un prêt d'une erreur : la comptabilité est juste, c'est
|
||
// l'hypothèse qu'elle repose dessus qui n'est pas vérifiée.
|
||
QCOMPARE(qRound(plan.budget.surplusW + plan.budget.recreditedW - plan.budget.allocatedW),
|
||
qRound(plan.budget.remainingW));
|
||
#endif
|
||
}
|
||
|
||
/*!
|
||
* \brief [R6] La MESURABILITÉ de l'avancement se déclare à la SAISIE, avec sa cause.
|
||
*
|
||
* \par Pourquoi ce signal ne peut pas vivre dans la télémétrie
|
||
* `measurement` n'existe que pour les charges de `loads[]`. Or une charge qu'on **déclare** est
|
||
* précisément celle qui n'y est pas encore — pas arbitrée, ou créée à l'instant. L'écran serait
|
||
* muet **au moment exact où il doit parler** : celui où l'installateur saisit une obligation qui
|
||
* achètera à l'aveugle tous les jours si rien ne la mesure.
|
||
*
|
||
* \par Trois causes, trois gestes
|
||
* `noMeter` se répare en une manipulation, `meterWithoutEnergy` demande de remplacer le
|
||
* compteur, `noSessionEnergy` ne se répare pas — c'est le matériel. Sans la cause, l'avertissement
|
||
* ne peut dire que « ça ne marchera pas ».
|
||
*
|
||
* \par Et `meterThingId` ne permet pas de le déduire
|
||
* Un compteur peut publier `currentPower` **sans** `totalEnergyConsumed` : mesuré le 2026-09-01
|
||
* sur l'ECS du banc, et reproduit ici par la classe `meterNoEnergy` — un compteur DÉCLARÉ tel,
|
||
* et non plus un `powerSwitch` détourné en instrument de mesure. Un client qui déduirait la
|
||
* mesurabilité de la présence du champ se tromperait sur ce cas précis.
|
||
*/
|
||
void Simulation::testProgressMeasurabilityIsDeclaredAtConfigTime()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-mes.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
QUuid compteur = addMeter(26702); // meter : porte totalEnergyConsumed
|
||
QUuid r1 = addPowerSwitch(1000, 26703);
|
||
QUuid r2 = addPowerSwitch(1000, 26704);
|
||
QUuid r3 = addPowerSwitch(1000, 26705);
|
||
// COMPTEUR SANS CUMUL, classe déclarée — plus un `powerSwitch` détourné en instrument de
|
||
// mesure. Le détour marchait (le moteur ne teste que `hasStateType`), mais il modélisait un
|
||
// montage que personne ne fait, et faisait tenir DEUX rôles à la même Thing dans le même
|
||
// scénario : relais de « sans-compteur » et compteur de « compteur-sans-energie ».
|
||
QUuid compteurSansEnergie = addMeterNoEnergy(26706);
|
||
QVERIFY(tm->findConfiguredThing(compteur) && tm->findConfiguredThing(r1));
|
||
QVERIFY2(tm->findConfiguredThing(compteurSansEnergie),
|
||
"la classe meterNoEnergy doit être configurable — sinon la branche par défaut du "
|
||
"mock a parlé, et c'est elle qu'il faut lire");
|
||
// Le contrat de la classe, vérifié plutôt que supposé : elle publie une puissance et ne
|
||
// totalise RIEN. Si un jour quelqu'un lui ajoutait le cumul, ce test-ci se tairait et le
|
||
// cas `meterWithoutEnergy` redeviendrait infabricable sans que rien ne le signale.
|
||
Thing *tSans = tm->findConfiguredThing(compteurSansEnergie);
|
||
QVERIFY2(tSans->thingClass().hasStateType("currentPower"), "un compteur publie une puissance");
|
||
QVERIFY2(!tSans->thingClass().hasStateType("totalEnergyConsumed"),
|
||
"meterNoEnergy ne doit PAS totaliser — c'est toute sa raison d'être");
|
||
|
||
// Écrit par la FRONTIÈRE RPC, comme l'app le ferait — c'est le magasin que le handler
|
||
// connaît. Un store créé à côté ne serait pas celui qu'il interroge : constaté ici même,
|
||
// GetLoadConfig rendait une liste VIDE et le test passait sans rien vérifier.
|
||
int rang = 0;
|
||
QVariantList aPoser;
|
||
auto relais = [&](const QString &id, const QUuid &thing, const QString &meter) {
|
||
++rang; // rangs UNIQUES : le store refuse deux charges de même rang
|
||
QVariantMap m{{"id", id}, {"label", id}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", rang}, {"enabled", true}, {"domain", "ecs"},
|
||
{"relays", QVariantList() << QVariantMap{{"thingId", thing.toString()},
|
||
{"powerW", 1000}}}};
|
||
if (!meter.isEmpty()) m.insert("meterThingId", meter);
|
||
return m;
|
||
};
|
||
aPoser << relais("sans-compteur", r1, QString());
|
||
aPoser << relais("compteur-ok", r2, compteur.toString());
|
||
aPoser << relais("compteur-sans-energie", r3, compteurSansEnergie.toString());
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig",
|
||
QVariantMap({{"loadConfigs", aPoser}})).toMap()
|
||
.value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
|
||
.value("params").toMap().value("loadConfigs").toList();
|
||
QHash<QString, QVariantMap> parId;
|
||
for (const QVariant &v : l) parId.insert(v.toMap().value("id").toString(), v.toMap());
|
||
|
||
// Garde contre l'assertion VACANTE : sans les trois entrées, tout ce qui suit passerait
|
||
// sans rien vérifier. Le piège a déjà été rencontré deux fois dans ce lot.
|
||
QCOMPARE(parId.count(), 3);
|
||
|
||
// 1. AUCUN COMPTEUR — réparable en une manipulation, et c'est la cause la plus fréquente.
|
||
QCOMPARE(parId.value("sans-compteur").value("progressMeasurable").toBool(), false);
|
||
QCOMPARE(parId.value("sans-compteur").value("progressUnmeasurableCause").toString(),
|
||
QString("noMeter"));
|
||
|
||
// 2. COMPTEUR AVEC ÉNERGIE — mesurable, et AUCUNE cause publiée : il n'y a rien à réparer.
|
||
QCOMPARE(parId.value("compteur-ok").value("progressMeasurable").toBool(), true);
|
||
QVERIFY2(!parId.value("compteur-ok").contains("progressUnmeasurableCause"),
|
||
"une charge mesurable ne porte pas de cause — l'absence est l'information");
|
||
|
||
// 3. COMPTEUR SANS ÉNERGIE — le cas qui interdit de déduire la mesurabilité de la seule
|
||
// présence de `meterThingId`. Le champ est là, le compteur existe, et pourtant rien ne
|
||
// cumule : la puissance ne suffit pas, il faut une énergie.
|
||
QCOMPARE(parId.value("compteur-sans-energie").value("progressMeasurable").toBool(), false);
|
||
QCOMPARE(parId.value("compteur-sans-energie").value("progressUnmeasurableCause").toString(),
|
||
QString("meterWithoutEnergy"));
|
||
QVERIFY2(!parId.value("compteur-sans-energie").value("meterThingId").toString().isEmpty(),
|
||
"le compteur EST désigné — c'est bien la déduction par meterThingId qui échoue");
|
||
#endif
|
||
}
|
||
|
||
/*!
|
||
* \brief [Inventaire du mock] La branche THERMIQUE, écrite depuis toujours et jamais exécutée.
|
||
*
|
||
* \par Le manque
|
||
* `attachMeasurements()` teste `hasState("temperature")` sur le Thing désigné par
|
||
* `sensorThingId`. **Aucune classe du mock ne portait cet état** : la branche « sonde présente »
|
||
* n'avait jamais été parcourue, et rien n'exerçait la seule entrée du volet thermique.
|
||
*
|
||
* \par Les trois cas, et le troisième est celui qu'on n'aurait pas pensé à écrire
|
||
* Aucune sonde désignée, une sonde qui mesure, et une sonde DÉSIGNÉE QUI NE MESURE PAS — un
|
||
* Thing existant sans état `temperature`. Le troisième produit la même charge utile que le
|
||
* premier (`temperatureC` absent), alors que le geste de réparation n'est pas le même : dans un
|
||
* cas il faut désigner une sonde, dans l'autre en changer.
|
||
*/
|
||
void Simulation::testThermalProbeFeedsTelemetry()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-thermo.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();
|
||
|
||
// Sans compteur racine, AUCUN cycle d'arbitrage ne tourne et `loads[]` reste vide : la
|
||
// garde d'assertion vacante ci-dessous l'a attrapé à la première exécution de ce test.
|
||
QUuid meterId = addMeter(26710);
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QUuid r1 = addPowerSwitch(1000, 26711);
|
||
QUuid r2 = addPowerSwitch(1000, 26712);
|
||
QUuid r3 = addPowerSwitch(1000, 26713);
|
||
QUuid sonde = addThermalProbe(26714);
|
||
QVERIFY2(tm->findConfiguredThing(sonde), "la classe thermalProbe doit être configurable");
|
||
|
||
Thing *tSonde = tm->findConfiguredThing(sonde);
|
||
QVERIFY2(tSonde->thingClass().hasStateType("temperature"),
|
||
"sans cet état, la branche testée reste inatteignable et le test serait VACANT");
|
||
tSonde->setStateValue("temperature", 54.5);
|
||
|
||
int rang = 0;
|
||
QVariantList aPoser;
|
||
auto charge = [&](const QString &id, const QUuid &relais, const QString &capteur) {
|
||
++rang;
|
||
QVariantMap m{{"id", id}, {"label", id}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", rang}, {"enabled", true}, {"domain", "ecs"},
|
||
{"relays", QVariantList() << QVariantMap{{"thingId", relais.toString()},
|
||
{"powerW", 1000}}}};
|
||
if (!capteur.isEmpty()) m.insert("sensorThingId", capteur);
|
||
return m;
|
||
};
|
||
aPoser << charge("sans-sonde", r1, QString());
|
||
aPoser << charge("avec-sonde", r2, sonde.toString());
|
||
// Une sonde DÉSIGNÉE qui ne mesure pas la température : un relais, ici. Le cas existe sur le
|
||
// terrain dès qu'on désigne le mauvais Thing, et il ne se distingue pas du premier.
|
||
aPoser << charge("sonde-muette", r3, r1.toString());
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig",
|
||
QVariantMap({{"loadConfigs", aPoser}})).toMap()
|
||
.value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
// Cycle DÉCLENCHÉ par l'horloge de simulation, jamais attendu de l'horloge murale.
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 9, 5), QTime(13, 0, 0));
|
||
meter->setStateValue("currentPower", -6000); // 6000 W exportés : de quoi servir
|
||
arb->simulationCallUpdate(t0);
|
||
QCoreApplication::processEvents();
|
||
|
||
QVariantList loads = injectAndWait("NymeaEnergy.GetLoadTelemetry").toMap()
|
||
.value("params").toMap().value("loads").toList();
|
||
QHash<QString, QVariantMap> parId;
|
||
for (const QVariant &v : loads) parId.insert(v.toMap().value("loadId").toString(), v.toMap());
|
||
|
||
// Garde contre l'assertion VACANTE — sans les trois charges, tout ce qui suit passerait
|
||
// sans rien vérifier.
|
||
QCOMPARE(parId.count(), 3);
|
||
|
||
// 1. SONDE QUI MESURE — la branche qui n'avait jamais été exécutée.
|
||
QVERIFY2(parId.value("avec-sonde").contains("temperatureC"),
|
||
"une sonde désignée ET capable doit publier sa lecture");
|
||
QCOMPARE(parId.value("avec-sonde").value("temperatureC").toDouble(), 54.5);
|
||
|
||
// 2. AUCUNE SONDE — le champ est ABSENT, jamais zéro. Un 0 se lirait « il fait 0 °C ».
|
||
QVERIFY2(!parId.value("sans-sonde").contains("temperatureC"),
|
||
"sans sonde, le champ est absent — un zéro serait une mesure");
|
||
|
||
// 3. SONDE DÉSIGNÉE MAIS MUETTE — même charge utile que le cas 2, geste de réparation
|
||
// DIFFÉRENT. C'est un silence de la famille `progressUnmeasurableCause`, relevé ici et
|
||
// non corrigé : le corriger demande un catalogue de causes thermiques qui n'existe pas.
|
||
QVERIFY2(!parId.value("sonde-muette").contains("temperatureC"),
|
||
"une sonde incapable ne publie rien — mais rien ne dit POURQUOI");
|
||
|
||
// 4. La lecture SUIT la sonde : une valeur figée passerait les trois assertions ci-dessus.
|
||
tSonde->setStateValue("temperature", 61.25);
|
||
arb->simulationCallUpdate(t0.addSecs(60));
|
||
QCoreApplication::processEvents();
|
||
loads = injectAndWait("NymeaEnergy.GetLoadTelemetry").toMap()
|
||
.value("params").toMap().value("loads").toList();
|
||
for (const QVariant &v : loads)
|
||
if (v.toMap().value("loadId").toString() == "avec-sonde")
|
||
QCOMPARE(v.toMap().value("temperatureC").toDouble(), 61.25);
|
||
#endif
|
||
}
|
||
|
||
/*!
|
||
* \brief [Inventaire du mock] « Saine mais NON MESURÉE » n'est pas « en défaut ».
|
||
*
|
||
* \par Le cas que le banc ne savait pas produire
|
||
* Toutes les classes pilotables du mock portaient une puissance. `measurement.source: none`
|
||
* n'était donc atteignable qu'avec un Thing **manquant** — ce qui traîne `available: false` avec
|
||
* lui et **mélange deux affirmations distinctes** : « rien ne mesure cette charge » et « cette
|
||
* charge est en panne ». Or un **relais sec** — il commute, il ne mesure pas — est banal sur le
|
||
* terrain, et parfaitement sain.
|
||
*
|
||
* Ce test n'aurait pas pu s'écrire avant la classe `dryRelay` : c'est l'inventaire à froid
|
||
* (`docs/INVENTAIRE_MOCK.md`) qui a rendu le manque visible, et le manque était structurel.
|
||
*
|
||
* \par Ce qu'il épingle
|
||
* Deux charges dans le même cycle, l'une saine sans mesure, l'autre au câblage manquant. Elles
|
||
* doivent se **distinguer** — mêmes `measurement.source`, `available` opposés.
|
||
*/
|
||
void Simulation::testHealthyButUnmeasuredDiffersFromFaulty()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-sec.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
meter->setStateValue("currentPower", -5000); // 5 kW exportés
|
||
|
||
// LE RELAIS SEC : il commute, il ne mesure pas. Il EXISTE, il est sain.
|
||
QUuid sec = addDryRelay(26681);
|
||
Thing *tSec = tm->findConfiguredThing(sec);
|
||
QVERIFY2(tSec, "le relais sec doit être instanciable");
|
||
QVERIFY2(!tSec->thingClass().hasStateType("currentPower"),
|
||
"un relais sec ne porte PAS de puissance — c'est tout l'objet de la classe");
|
||
|
||
QVariantList aPoser;
|
||
aPoser << QVariantMap{{"id", "ecs-relais-sec"}, {"label", "ECS relais sec"},
|
||
{"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", 1}, {"enabled", true}, {"domain", "ecs"},
|
||
{"relays", QVariantList() << QVariantMap{{"thingId", sec.toString()},
|
||
{"powerW", 1500}}}};
|
||
// …et une charge au CÂBLAGE MANQUANT, pour la comparaison.
|
||
aPoser << QVariantMap{{"id", "ecs-fantome"}, {"label", "ECS fantôme"},
|
||
{"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", 2}, {"enabled", true}, {"domain", "ecs"},
|
||
{"relays", QVariantList() << QVariantMap{
|
||
{"thingId", "{deadbeef-0000-4000-8000-00000000c0de}"},
|
||
{"powerW", 1500}}}};
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig",
|
||
QVariantMap({{"loadConfigs", aPoser}})).toMap()
|
||
.value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
arb->simulationCallUpdate(utcDateTime(QDate(2026, 9, 2), QTime(13, 0, 0)));
|
||
QCoreApplication::processEvents();
|
||
|
||
QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
|
||
const QVariantMap saine = tele.value("ecs-relais-sec");
|
||
const QVariantMap fantome = tele.value("ecs-fantome");
|
||
QVERIFY2(!saine.isEmpty() && !fantome.isEmpty(), "les deux charges doivent être publiées");
|
||
|
||
// 1. LES DEUX ne sont pas mesurées — même source, et c'est normal.
|
||
QCOMPARE(saine.value("measurement").toMap().value("source").toString(), QString("none"));
|
||
QCOMPARE(fantome.value("measurement").toMap().value("source").toString(), QString("none"));
|
||
|
||
// 2. MAIS ELLES SE DISTINGUENT, et c'est tout l'objet du lot : l'une est SAINE, l'autre en
|
||
// défaut. Avant la classe `dryRelay`, la première était impossible à fabriquer, et rien
|
||
// ne pouvait donc vérifier que le moteur les sépare.
|
||
QCOMPARE(saine.value("available").toBool(), true);
|
||
QCOMPARE(fantome.value("available").toBool(), false);
|
||
QCOMPARE(fantome.value("faultCode").toString(), QString("THING_MISSING"));
|
||
QVERIFY2(!saine.contains("faultCode"),
|
||
"une charge saine ne porte pas de code de défaut — l'absence est l'information");
|
||
|
||
// 3. Et aucune des deux ne publie `command` : rien ne mesure, donc rien à comparer. Sans le
|
||
// relais sec, cette assertion n'aurait porté que sur un cas en défaut.
|
||
QVERIFY2(!saine.contains("command"),
|
||
"sans mesure, l'écart commandé/mesuré est sans objet — même sur une charge saine");
|
||
#endif
|
||
}
|
||
|
||
/*!
|
||
* \brief Un compteur de VIE qui recule ne rebase JAMAIS l'avancement.
|
||
*
|
||
* \par Le chemin du faux « tenue », et il est le seul
|
||
* Un compteur qui disparaît peut publier **0 au lieu de se taire** — mesuré sur la V2C le
|
||
* 2026-08-30 : `currentPower` 4 396 → 0 et les booléens à `false` à la perte du lien. Si la base
|
||
* se reprenait sur ce zéro, le retour du compteur avec son cumul réel — 10 kWh sur l'ECS du banc
|
||
* — ferait sauter `deliveredWh` à 10 000 en un cycle : **l'obligation se déclarerait tenue sans
|
||
* que rien n'ait été livré**.
|
||
*
|
||
* Et `R6` ne s'y oppose pas : elle n'interdit `ECO_FLOOR_MET` que sous `unmeasurable`. Le chemin
|
||
* **mesuré** est donc le seul par lequel un faux « tenue » peut sortir — c'est-à-dire exactement
|
||
* celui qu'ouvre l'alimentation de `sessionWh` depuis un compteur.
|
||
*
|
||
* \par Pourquoi la garde porte sur la SÉMANTIQUE et non sur la joignabilité
|
||
* Il n'existe **aucun signal de joignabilité fiable** : `setupComplete` parle du setup et non du
|
||
* lien, et `connected` est maintenu par le plugin — la V2C bascule après trois échecs, un
|
||
* compteur générique peut ne jamais basculer. **Un accumulateur ne doit pas parier sur ce que
|
||
* chaque plugin fait de ses états en tombant** : la même V2C a forgé un zéro sur `currentPower`
|
||
* et laissé `sessionEnergy` à sa dernière valeur.
|
||
*
|
||
* Ce que le moteur sait sans parier : **un compteur de VIE ne recule pas.** Un recul y est une
|
||
* anomalie ; sur un compteur de SESSION, c'est une session neuve. Deux sources, deux gestes.
|
||
*/
|
||
void Simulation::testLifetimeCounterDropNeverRebasesTheProgress()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 9, 3), QTime(13, 0, 0));
|
||
|
||
LoadContext ecs;
|
||
ecs.id = "ecs-compteur-vie";
|
||
ecs.needs.minEnergyWhPerDay = 4000;
|
||
ecs.needs.dailyDeadline = "07:00";
|
||
ecs.telemetry.sessionMeasurable = true;
|
||
ecs.telemetry.sessionCumulative = true; // totalEnergyConsumed : monotone par nature
|
||
ecs.telemetry.sessionWh = 9000; // le compteur en est à 9 kWh de vie
|
||
|
||
QCOMPARE(qRound(arb->ecoProgress(ecs, t0).deliveredWh), 0); // base prise à 9000
|
||
ecs.telemetry.sessionWh = 9500;
|
||
QCOMPARE(qRound(arb->ecoProgress(ecs, t0).deliveredWh), 500); // 500 Wh livrés
|
||
|
||
// LE LIEN TOMBE et le plugin publie ZÉRO au lieu de se taire.
|
||
ecs.telemetry.sessionWh = 0;
|
||
const EcoProgress pendant = arb->ecoProgress(ecs, t0);
|
||
// On GÈLE : la valeur ne vaut rien, donc on ne conclut rien. Pas d'avancement publié, pas
|
||
// de recapture — le tronçon reste ouvert sur sa dernière valeur saine.
|
||
QCOMPARE(pendant.regime, QString(ProgressRegime::Unmeasurable));
|
||
QVERIFY2(!pendant.delivered, "sur une valeur non fiable, on ne publie pas d'avancement");
|
||
|
||
// LE COMPTEUR REVIENT avec son cumul réel. C'est ici que le faux « tenue » sortirait si la
|
||
// base avait été reprise à 0 : 10 000 Wh d'un coup sur une obligation de 4 000.
|
||
ecs.telemetry.sessionWh = 10000;
|
||
const EcoProgress apresRetour = arb->ecoProgress(ecs, t0);
|
||
QCOMPARE(apresRetour.regime, QString(ProgressRegime::Measured));
|
||
// 10 000 − 9 000 (base intacte) = 1 000 Wh. PAS 10 000.
|
||
QCOMPARE(qRound(apresRetour.deliveredWh), 1000);
|
||
QVERIFY2(apresRetour.deliveredWh < ecs.needs.minEnergyWhPerDay,
|
||
"l'obligation ne doit PAS se déclarer tenue sur un retour de compteur");
|
||
|
||
// Et la distinction tient : sur un compteur de SESSION, le même recul EST une session neuve.
|
||
LoadContext borne;
|
||
borne.id = "borne-session";
|
||
borne.needs.minEnergyWhPerDay = 4000;
|
||
borne.needs.dailyDeadline = "07:00";
|
||
borne.telemetry.sessionMeasurable = true;
|
||
borne.telemetry.sessionCumulative = false; // sessionEnergy : remis à zéro par session
|
||
borne.telemetry.sessionWh = 9000;
|
||
QCOMPARE(qRound(arb->ecoProgress(borne, t0).deliveredWh), 0);
|
||
borne.telemetry.sessionWh = 120; // nouvelle session
|
||
const EcoProgress neuve = arb->ecoProgress(borne, t0);
|
||
QCOMPARE(neuve.regime, QString(ProgressRegime::Measured)); // pas d'anomalie : on recapture
|
||
QCOMPARE(qRound(neuve.deliveredWh), 0);
|
||
#endif
|
||
}
|
||
|
||
/*!
|
||
* \brief [\rule{LM-1210-a}] Sur une triphasée DÉSÉQUILIBRÉE, c'est la PIRE phase qui borne.
|
||
*
|
||
* \par Un choix écrit, raisonné, et jamais exercé — jusqu'ici
|
||
* `LoadDeclared::phases` est un NOMBRE, pas un masque : le moteur ignore sur quelle phase une
|
||
* charge est câblée. Il retient donc la pire, seul choix qui ne devine pas — deviner dans une
|
||
* couche de sécurité se paie au disjoncteur.
|
||
*
|
||
* Ce test n'existait pas parce que **le mock ne savait produire qu'une triphasée équilibrée** :
|
||
* il posait `currentPhaseX = currentPowerPhaseX / 230`, cohérent par construction. Le manque
|
||
* était nommé deux fois — par l'inventaire du mock et par le balayage des contrôles — avant
|
||
* d'être comblé.
|
||
*
|
||
* \par Ce qu'il épingle
|
||
* La maison EXPORTE globalement, et une seule phase sature. La marge doit être celle de cette
|
||
* phase-là, négative, et non celle du total qui paraîtrait confortable.
|
||
*/
|
||
void Simulation::testWorstPhaseBindsWhenPhasesAreUnbalanced()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit",
|
||
QVariantMap({{"phasePowerLimit", 25}})).toMap()
|
||
.value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
// DÉSÉQUILIBRE : la phase B sature à 30 A sous un plafond de 25, pendant que A et C
|
||
// EXPORTENT. Impossible à écrire avant : le mock dérivait les courants des puissances.
|
||
meter->setStateValue("currentPhaseA", -10); // export
|
||
meter->setStateValue("currentPhaseB", 30); // SATURÉE
|
||
meter->setStateValue("currentPhaseC", -10); // export
|
||
meter->setStateValue("currentPower", -2300); // la maison exporte GLOBALEMENT
|
||
|
||
const DrawMargin marge = arb->houseDrawMargin();
|
||
QVERIFY2(marge.borne, "un plafond est réglé : la marge doit exister");
|
||
|
||
// 1. LA PIRE PHASE L'EMPORTE — et c'est bien B, pas le total. 25 A − 30 A = −5 A, soit
|
||
// −1150 W. Le total, lui, afficherait un confort trompeur : la maison exporte.
|
||
QCOMPARE(qRound(marge.watts), -1150);
|
||
QCOMPARE(marge.phase, 1); // phase B (0 = A, 1 = B, 2 = C)
|
||
|
||
// 1-bis. ET L'ÉCRAN DOIT POUVOIR NOMMER CETTE PHASE. `perPhaseBound` dit qu'une phase borne,
|
||
// jamais laquelle : l'écran ne pourrait dire que « bridé » sans dire par quoi, et
|
||
// enverrait mesurer au hasard sur trois phases dont deux vont bien. Le motif `DRAW_CAP`
|
||
// la porte aussi, mais il n'est émis que si une charge est REFUSÉE — un cycle qui borde
|
||
// sans rien refuser laisserait l'écran muet. Les deux endroits ne se recouvrent pas.
|
||
SurplusContext ctxPhase;
|
||
ctxPhase.timestamp = utcDateTime(QDate(2026, 9, 4), QTime(13, 0, 0));
|
||
ctxPhase.drawMargin = marge;
|
||
RuleBasedScheduler schedPhase(arb);
|
||
const Plan planPhase = schedPhase.getPlan(ctxPhase);
|
||
QCOMPARE(planPhase.draw.perPhaseBound, true);
|
||
QCOMPARE(planPhase.draw.bindingPhase, QString("B"));
|
||
|
||
// 2. ET LA MARGE EST NÉGATIVE alors que le compteur global est en EXPORT. C'est tout l'objet
|
||
// du choix : sans la lecture par phase, l'arbitre croirait disposer de 2 300 W à dépenser
|
||
// pendant qu'une phase est déjà au-delà de son disjoncteur.
|
||
QVERIFY2(meter->stateValue("currentPower").toDouble() < 0,
|
||
"la maison exporte : c'est le cas qui rend le pessimisme non trivial");
|
||
QVERIFY2(marge.watts < 0,
|
||
"la pire phase borne, même quand le total rassure");
|
||
|
||
// 3. Contre-jour : rééquilibrée, la même maison retrouve une marge confortable. Sans ce
|
||
// second point, le test ne distinguerait pas « la pire phase l'emporte » de « la marge
|
||
// est toujours négative ».
|
||
meter->setStateValue("currentPhaseB", -10);
|
||
const DrawMargin equilibree = arb->houseDrawMargin();
|
||
// La marge est ÉCRÊTÉE AU PLAFOND, et non augmentée par l'export : `calculateAllowanceAmpere`
|
||
// part de `phasePowerLimit` et ne fait que des `qMin`. Physiquement juste — l'export ne
|
||
// permet pas de tirer plus que le disjoncteur — mais ce n'est PAS ce que le design disait
|
||
// (« une phase qui exporte a une marge plus grande que le plafond »), et la phrase a été
|
||
// corrigée. La conclusion, elle, tient : une phase qui exporte est au maximum de la marge,
|
||
// donc elle ne borne jamais.
|
||
QCOMPARE(qRound(equilibree.watts), qRound(25.0 * 230.0));
|
||
QVERIFY2(equilibree.watts > 0, "rééquilibrée, la marge redevient positive");
|
||
#endif
|
||
}
|
||
|
||
/*!
|
||
* \brief [upstream-fix] La tension d'une phase ne doit pas servir à calculer une AUTRE phase.
|
||
*
|
||
* \par Le défaut
|
||
* `calculateAllowanceAmpere()` déclare `voltage` **une seule fois, hors des trois blocs**, et le
|
||
* bloc de la phase A la mute. Un compteur qui publie `voltagePhaseA` mais pas `voltagePhaseB`
|
||
* verrait donc la phase B calculée **avec la tension de A**.
|
||
*
|
||
* \par Pourquoi ce test n'existait pas
|
||
* Le mock posait `currentPhaseX = currentPowerPhaseX / 230` : tension implicite, identique
|
||
* partout, et le défaut invisible. C'est la **troisième fois** que l'ouverture du mock révèle un
|
||
* défaut plutôt que de seulement débloquer un test — après `dryRelay` et après la correction du
|
||
* raisonnement sur l'écrêtage de la marge.
|
||
*/
|
||
void Simulation::testPhaseVoltageDoesNotLeakBetweenPhases()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
QUuid meterId = addMeterPowerOnly(26691);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
QVERIFY2(!meter->thingClass().hasStateType("currentPhaseC"),
|
||
"ce compteur ne publie PAS les courants de B et C — c'est ce qui rend la branche "
|
||
"« puissance ÷ tension » atteignable");
|
||
QVERIFY2(meter->thingClass().hasStateType("voltagePhaseB"),
|
||
"il publie la tension de B…");
|
||
QVERIFY2(!meter->thingClass().hasStateType("voltagePhaseC"),
|
||
"…mais PAS celle de C : c'est exactement le cas où la tension de B fuirait vers C");
|
||
|
||
RootMeter *rm = new RootMeter(meter);
|
||
|
||
// LE MASQUE DOIT COUVRIR B **ET** C — c'est la condition de la fuite : le bloc de B écrit
|
||
// `voltage`, celui de C le relit. Interroger C SEULE ne révèle rien, puisque le bloc de B ne
|
||
// s'exécute pas. (Première version de ce test, qui passait pour cette raison.)
|
||
//
|
||
// Et C doit être la phase la plus CONTRAINTE, sinon le `qMin` de la fonction retiendrait B
|
||
// et masquerait l'écart. B : 1000 W sous 200 V = 5 A, marge 20 A. C : 4000 W sous une
|
||
// tension INCONNUE — donc 230 V par défaut, 17,4 A, marge 7,6 A. Avec la fuite, C serait
|
||
// calculée sous les 200 V de B : 20 A tirés, marge 5 A.
|
||
meter->setStateValue("voltagePhaseB", 200);
|
||
meter->setStateValue("currentPowerPhaseB", 1000);
|
||
meter->setStateValue("currentPowerPhaseC", 4000);
|
||
|
||
const uint plafond = 25;
|
||
const double marge = rm->calculateAllowanceAmpere(
|
||
Electricity::PhaseB | Electricity::PhaseC, plafond);
|
||
|
||
// Sans fuite : min(20 ; 25 − 4000/230) = 7,6 A. Avec la fuite : min(20 ; 25 − 4000/200) = 5.
|
||
QCOMPARE(qRound(marge * 10), qRound((25.0 - 4000.0 / 230.0) * 10));
|
||
|
||
// Et l'ORDRE ne doit rien changer : le résultat de C ne doit pas dépendre de ce qu'on a
|
||
// demandé avant. C'est la propriété que la variable partagée détruisait.
|
||
QCOMPARE(qRound(rm->calculateAllowanceAmpere(Electricity::PhaseC, plafond) * 10),
|
||
qRound((25.0 - 4000.0 / 230.0) * 10));
|
||
#endif
|
||
}
|
||
|
||
/*!
|
||
* \brief Le compteur du mock INTÈGRE réellement — un cumul figé n'existe pas.
|
||
*
|
||
* \par Le blocage qu'il lève
|
||
* `totalEnergyConsumed` valait **10 kWh depuis le 1er septembre** : une constante. `deliveredWh`
|
||
* restait donc à 0, `remainingWh` à 4 000, et **l'étalement adaptatif n'était jamais exercé** —
|
||
* le comportement se confondait avec `unmeasurable` pour une raison entièrement différente et
|
||
* bénigne. Un compteur figé est un compteur qui n'existe pas pour tout ce qui l'intègre.
|
||
*
|
||
* \par \rule{LM-1014-b} appliquée au mock lui-même
|
||
* `totalEnergyConsumed` est déclaré **`cached`** : c'est un compteur de VIE, il **continue** au
|
||
* redémarrage et ne repart pas de zéro. Repartir de zéro ferait **reculer un cumul monotone**,
|
||
* ce que la garde de sémantique traite comme une anomalie — le mock produirait alors un cas que
|
||
* le terrain ne produit pas.
|
||
*/
|
||
void Simulation::testMeterEnergyActuallyAccumulates()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
QUuid meterId = addMeter(26693);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
// Le compteur CONSOMME 3 600 W : un watt-heure par seconde, de quoi voir bouger le cumul.
|
||
meter->setStateValue("currentPower", 3600);
|
||
const double depart = meter->stateValue("totalEnergyConsumed").toDouble();
|
||
|
||
QTest::qWait(2500);
|
||
const double apres = meter->stateValue("totalEnergyConsumed").toDouble();
|
||
QVERIFY2(apres > depart,
|
||
qUtf8Printable(QStringLiteral("le cumul doit AVANCER : %1 → %2 kWh")
|
||
.arg(depart).arg(apres)));
|
||
|
||
// …et il avance dans un ORDRE DE GRANDEUR plausible : 3 600 W pendant ~2,5 s, soit ~2,5 Wh
|
||
// si toutes les secondes intègrent. La borne basse est LARGE et volontairement lâche : le
|
||
// test ne contrôle ni la cadence exacte du minuteur ni l'alignement du premier tic — qui
|
||
// n'intègre rien, faute de durée derrière lui. Une borne serrée en ferait un test instable,
|
||
// c'est-à-dire un test qu'on relancerait jusqu'à ce qu'il passe.
|
||
//
|
||
// Ce qui est réellement épinglé : il intègre, et il n'emballe pas. Le premier point est ce
|
||
// qui manquait ; le second est ce qui rendrait le mock inutilisable dans l'autre sens.
|
||
const double gagneWh = (apres - depart) * 1000.0;
|
||
QVERIFY2(gagneWh >= 0.5 && gagneWh < 10.0,
|
||
qUtf8Printable(QStringLiteral("ordre de grandeur plausible attendu (~2,5 Wh), "
|
||
"obtenu %1").arg(gagneWh)));
|
||
|
||
// EN EXPORT, il ne recule pas — il ne bouge pas. Un compteur de CONSOMMATION est monotone,
|
||
// et le faire reculer produirait un cas que le terrain ne produit pas.
|
||
meter->setStateValue("currentPower", -3000);
|
||
const double avantExport = meter->stateValue("totalEnergyConsumed").toDouble();
|
||
QTest::qWait(1500);
|
||
QCOMPARE(meter->stateValue("totalEnergyConsumed").toDouble(), avantExport);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testCountsSumToTarget()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testCountsSumToTarget nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [R3] « Chaque watt publié tombe dans exactement un compteur, et le mapping est publié. »
|
||
//
|
||
// Sept endroits construisent une LoadAction, et chacun doit renseigner `counts`. Vérifier
|
||
// cela en RELISANT le code se referait à chaque site ajouté, et échouerait le jour où
|
||
// quelqu'un en ajoute un huitième. Ce test relit la CHARGE UTILE : il ne fait confiance à
|
||
// aucun site, il constate le résultat sur toutes les charges du cycle.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-counts.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetAcquisitionTolerance",
|
||
QVariantMap({{"acquisitionTolerance", 0.5}})).toMap()
|
||
.value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
// Trois mécanismes + une borne : le plus de chemins de construction possible en un cycle.
|
||
QUuid rA = addPowerSwitch(1000, 26661);
|
||
QUuid k1 = addPowerSwitch(0, 26662);
|
||
QUuid k2 = addPowerSwitch(0, 26663);
|
||
QUuid varId = addEtmVariableLoad(27044);
|
||
QUuid borneId = addCharger("A", 16, 26657);
|
||
Thing *borne = tm->findConfiguredThing(borneId);
|
||
Thing *var = tm->findConfiguredThing(varId);
|
||
QVERIFY(borne && var && tm->findConfiguredThing(rA));
|
||
armerBorne(borne);
|
||
var->setStateValue("currentPowerW", 0);
|
||
|
||
QVariantMap info;
|
||
info.insert("evChargerId", borneId);
|
||
info.insert("chargingMode", "ChargingModeEco");
|
||
injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
meter->setStateValue("currentPower", 0);
|
||
arb->simulationCallUpdate(t0);
|
||
QCoreApplication::processEvents();
|
||
|
||
QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
|
||
.value("params").toMap().value("loadConfigs").toList();
|
||
// La borne au rang 1 — sinon, auto-provisionnée EN QUEUE (LM-1209), elle ne voit jamais de
|
||
// budget et le régime EV_GRID_START n'est pas exercé. C'est la contre-épreuve qui l'a
|
||
// montré : retirer la ventilation du partage laissait le test PASSER.
|
||
for (int i = 0; i < l.count(); ++i) {
|
||
QVariantMap m = l.at(i).toMap();
|
||
if (m.value("id").toString() == borneId.toString()) m.insert("priority", 1);
|
||
l[i] = m;
|
||
}
|
||
l.append(QVariantMap{{"id", varId.toString()}, {"label", "Routeur"},
|
||
{"adapter", "etmvariableload"}, {"mode", "dynamic"},
|
||
{"maxPowerW", 3000}, {"priority", 8}, {"enabled", true}});
|
||
l.append(QVariantMap{{"id", "ecs-counts"}, {"label", "ECS"}, {"adapter", "relay-router"},
|
||
{"mode", "fixed"}, {"priority", 9}, {"enabled", true},
|
||
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()},
|
||
{"powerW", 1000}}},
|
||
{"minOnS", 0}, {"minOffS", 0}});
|
||
l.append(sgReadyConfigMap("pac-counts", "PAC", k1, k2, 800.0, 1600.0, 0, 10));
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
// L'identité doit tenir dans TOUS les régimes, pas seulement quand tout est servi : un
|
||
// surplus large, un surplus qui force le démarrage sous tolérance, et un import qui
|
||
// déleste. Le troisième est celui où les allocations valent 0 — donc celui où un `counts`
|
||
// vide passerait inaperçu.
|
||
struct Regime { int compteurW; const char *quoi; bool attendEvGridStart; };
|
||
// EV_GRID_START EN PREMIER, et l'ordre est un fait du test, pas une préférence : dès qu'un
|
||
// cycle a fait charger la borne, son RECRÉDIT (correction B) porte le budget bien au-dessus
|
||
// du plancher, et le démarrage sous tolérance ne peut plus se produire. Placé en second, le
|
||
// régime n'était jamais exercé — la contre-épreuve l'a montré en laissant PASSER un partage
|
||
// amputé de moitié.
|
||
const QList<Regime> regimes = { {-900, "démarrage sous tolérance (EV_GRID_START)", true},
|
||
{-9000, "surplus large", false},
|
||
{2000, "import — délestage, allocations nulles", false} };
|
||
int cycle = 0;
|
||
for (const Regime &r : regimes) {
|
||
meter->setStateValue("currentPower", r.compteurW);
|
||
arb->simulationCallUpdate(t0.addSecs(60 * ++cycle));
|
||
QCoreApplication::processEvents();
|
||
const QVariantMap snap = arb->loadTelemetry();
|
||
const QVariantList charges = snap.value("loads").toList();
|
||
QVERIFY2(!charges.isEmpty(), r.quoi);
|
||
|
||
if (r.attendEvGridStart) {
|
||
bool vu = false;
|
||
for (const QVariant &v : charges)
|
||
if (v.toMap().value("decision").toMap().value("code").toString()
|
||
== QLatin1String(DecisionCode::EvGridStart))
|
||
vu = true;
|
||
QVERIFY2(vu, "le régime EV_GRID_START n'a PAS été exercé — le test ne prouverait "
|
||
"alors rien du partage entre les deux registres");
|
||
}
|
||
|
||
for (const QVariant &v : charges) {
|
||
const QVariantMap ch = v.toMap();
|
||
const QVariantList niveaux = ch.value("levels").toList();
|
||
QVERIFY2(!niveaux.isEmpty(),
|
||
qUtf8Printable(QStringLiteral("%1 : %2 sans levels[]")
|
||
.arg(r.quoi, ch.value("loadId").toString())));
|
||
for (const QVariant &nv : niveaux) {
|
||
const QVariantMap niveau = nv.toMap();
|
||
const QVariantMap counts = niveau.value("counts").toMap();
|
||
|
||
// 1. TOUJOURS présent, et jamais vide — même quand tout vaut 0. Un counts vide
|
||
// satisferait l'identité en perdant la destination, sur le cas le plus
|
||
// fréquent : c'est exactement la panne que R3 existe pour empêcher.
|
||
QVERIFY2(!counts.isEmpty(),
|
||
qUtf8Printable(QStringLiteral("%1 : %2 publie un counts VIDE")
|
||
.arg(r.quoi, ch.value("loadId").toString())));
|
||
|
||
// 2. Σ counts == targetW, à l'arrondi du watt près.
|
||
double somme = 0;
|
||
for (const QVariant &c : counts)
|
||
somme += c.toDouble();
|
||
QVERIFY2(qAbs(somme - niveau.value("targetW").toDouble()) < 1.0,
|
||
qUtf8Printable(QStringLiteral("%1 : %2 — Σ counts = %3, targetW = %4")
|
||
.arg(r.quoi, ch.value("loadId").toString())
|
||
.arg(somme).arg(niveau.value("targetW").toDouble())));
|
||
|
||
// 3. Les clés appartiennent au catalogue : une clé inventée ne se glisse pas
|
||
// dans la charge utile sans que quelqu'un l'ait décidée.
|
||
for (const QString &k : counts.keys())
|
||
QVERIFY2(k == QLatin1String(CountKey::BudgetAllocated)
|
||
|| k == QLatin1String(CountKey::DrawCommitted),
|
||
qUtf8Printable(QStringLiteral("clé de counts hors catalogue : %1").arg(k)));
|
||
}
|
||
}
|
||
|
||
// 4. Et la ventilation se RÉCONCILIE avec les compteurs du cycle — c'est l'identité que
|
||
// l'app calcule, relue ici sur la charge utile publiée.
|
||
double surplus = 0, achete = 0;
|
||
for (const QVariant &v : charges)
|
||
for (const QVariant &nv : v.toMap().value("levels").toList()) {
|
||
const QVariantMap c = nv.toMap().value("counts").toMap();
|
||
surplus += c.value(QString::fromLatin1(CountKey::BudgetAllocated)).toDouble();
|
||
achete += c.value(QString::fromLatin1(CountKey::DrawCommitted)).toDouble();
|
||
}
|
||
QVERIFY2(qAbs(surplus - snap.value("budget").toMap().value("allocatedW").toDouble()) < 1.0,
|
||
qUtf8Printable(QStringLiteral("%1 : Σ counts(surplus) = %2, budget.allocatedW = %3")
|
||
.arg(r.quoi).arg(surplus)
|
||
.arg(snap.value("budget").toMap().value("allocatedW").toDouble())));
|
||
QVERIFY2(qAbs(achete - snap.value("draw").toMap().value("committedW").toDouble()) < 1.0,
|
||
qUtf8Printable(QStringLiteral("%1 : Σ counts(acheté) = %2, draw.committedW = %3")
|
||
.arg(r.quoi).arg(achete)
|
||
.arg(snap.value("draw").toMap().value("committedW").toDouble())));
|
||
}
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testLevelAbsenceAndZeroDifferInMeaning()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("testLevelAbsenceAndZeroDifferInMeaning nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// [R1] « levels[] ne contient que les passes RÉELLEMENT parcourues. » La règle a deux
|
||
// moitiés qui se répondent, et publier l'une sans l'autre les rendrait indistinguables :
|
||
//
|
||
// niveau ABSENT → la passe n'a pas été parcourue ;
|
||
// niveau à 0 → la passe A ÉTÉ parcourue, elle n'avait rien à prendre.
|
||
//
|
||
// Un zéro forgé à la place d'une absence détruirait la première information ; une absence à
|
||
// la place d'un zéro détruirait la seconde. Aujourd'hui la passe unique est toujours
|
||
// parcourue quand la charge est arbitrée — c'est le MODE DÉGRADÉ qui porte le cas « aucune
|
||
// passe », la planification y étant suspendue.
|
||
//
|
||
// [R7] Et `decision.level` accompagne `levels[]` : les deux présents, ou les deux absents.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-levels.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
enableNotifications({"NymeaEnergy"});
|
||
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
|
||
QUuid varId = addEtmVariableLoad(27045);
|
||
Thing *var = tm->findConfiguredThing(varId);
|
||
QVERIFY(var);
|
||
var->setStateValue("currentPowerW", 0);
|
||
|
||
QVariantList l;
|
||
l.append(QVariantMap{{"id", varId.toString()}, {"label", "Routeur"},
|
||
{"adapter", "etmvariableload"}, {"mode", "dynamic"},
|
||
{"maxPowerW", 3000}, {"priority", 1}, {"enabled", true}});
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
|
||
// ---- 1. Passe parcourue AVEC budget : niveau publié, targetW > 0 -----------------------
|
||
meter->setStateValue("currentPower", -2000);
|
||
arb->recordMeterUpdate(t0);
|
||
arb->simulationCallUpdate(t0);
|
||
QCoreApplication::processEvents();
|
||
QVariantMap ch = telemetrieParId(arb->loadTelemetry()).value(varId.toString());
|
||
QCOMPARE(ch.value("levels").toList().count(), 1);
|
||
QVERIFY(qRound(ch.value("levels").toList().first().toMap().value("targetW").toDouble()) > 0);
|
||
QCOMPARE(ch.value("decision").toMap().value("level").toString(), QString("comfort"));
|
||
|
||
// ---- 2. Passe parcourue SANS budget : le niveau reste publié, à ZÉRO -------------------
|
||
// C'est la moitié qui compte. « Rien à prendre » n'est pas « pas de passe » : la charge a
|
||
// bien été examinée, le motif le dit, et l'écran doit pouvoir écrire « en attente » plutôt
|
||
// que de faire disparaître la ligne.
|
||
var->setStateValue("currentPowerW", 0);
|
||
meter->setStateValue("currentPower", 3000); // import franc
|
||
arb->recordMeterUpdate(t0.addSecs(60));
|
||
arb->simulationCallUpdate(t0.addSecs(60));
|
||
QCoreApplication::processEvents();
|
||
ch = telemetrieParId(arb->loadTelemetry()).value(varId.toString());
|
||
const QVariantList niv = ch.value("levels").toList();
|
||
QCOMPARE(niv.count(), 1);
|
||
QCOMPARE(qRound(niv.first().toMap().value("targetW").toDouble()), 0);
|
||
QVERIFY2(!niv.first().toMap().value("counts").toMap().isEmpty(),
|
||
"un niveau à zéro publie quand même sa destination");
|
||
QCOMPARE(ch.value("decision").toMap().value("level").toString(), QString("comfort"));
|
||
QVERIFY2(ch.value("decision").toMap().value("code").toString() != QString(),
|
||
"une passe parcourue sans budget porte un MOTIF, pas un silence");
|
||
|
||
// ---- 3. Aucune passe parcourue : levels[] ABSENT, et decision.level avec lui -----------
|
||
// Mode dégradé L2 : la planification est SUSPENDUE. Publier un niveau à zéro y mentirait —
|
||
// il n'y a pas eu de passe du tout, et « rien à prendre » supposerait qu'on ait regardé.
|
||
QSignalSpy spy(m_mockTcpServer, &MockTcpServer::outgoingData);
|
||
arb->evaluateMeterFreshness(t0.addSecs(60 + 95)); // compteur muet > 90 s
|
||
QCoreApplication::processEvents();
|
||
const QVariantList notifs = checkNotifications(spy, "NymeaEnergy.LoadTelemetryChanged");
|
||
QVERIFY2(!notifs.isEmpty(), "la bascule en mode dégradé doit notifier");
|
||
const QVariantMap dgr = notifs.last().toMap().value("params").toMap();
|
||
QCOMPARE(dgr.value("degradedMode").toBool(), true);
|
||
|
||
bool vuUneCharge = false;
|
||
for (const QVariant &v : dgr.value("loads").toList()) {
|
||
vuUneCharge = true;
|
||
const QVariantMap c = v.toMap();
|
||
QVERIFY2(!c.contains("levels"),
|
||
"levels[] publié en mode dégradé : aucune passe n'a pourtant été parcourue");
|
||
QVERIFY2(!c.value("decision").toMap().contains("level"),
|
||
"decision.level sans levels[] : le client devrait deviner à quoi il se rapporte");
|
||
}
|
||
QVERIFY2(vuUneCharge, "le mode dégradé doit publier les charges, sans leurs niveaux");
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testTheLearningLogIsFedByTheCycle()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// Brief 3 T2, de bout en bout : un vrai cycle lit les plans d'un site, leurs PARAMÈTRES sur le
|
||
// Thing (jamais recopiés), et remet une ligne par pas écoulé et par plan. Les unitaires de
|
||
// loadmodel éprouvent les règles ; celui-ci éprouve le CÂBLAGE.
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
ForecastLearningLog *log = arb->forecastLearningLog();
|
||
QVERIFY(log);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
const QUuid site = addMeter();
|
||
const QUuid sud = addPvPlane(QStringLiteral("Toit sud"));
|
||
const QUuid est = addPvPlane(QStringLiteral("Toit est"));
|
||
Thing *tSud = tm->findConfiguredThing(ThingId(sud));
|
||
Thing *tEst = tm->findConfiguredThing(ThingId(est));
|
||
QVERIFY(tSud && tEst && !site.isNull());
|
||
tSud->setParentId(ThingId(site));
|
||
tEst->setParentId(ThingId(site));
|
||
const auto detacher = qScopeGuard([&]() {
|
||
tSud->setParentId(ThingId());
|
||
tEst->setParentId(ThingId());
|
||
arb->setForecastSiteId(QString());
|
||
});
|
||
tSud->setParamValue(QStringLiteral("kwp"), 2.0);
|
||
tSud->setParamValue(QStringLiteral("performanceRatio"), 0.85);
|
||
tEst->setParamValue(QStringLiteral("kwp"), 1.5); // porte aussi tempCoeff/noct (défauts)
|
||
const QString siteId = ThingId(site).toString();
|
||
QVERIFY(arb->setForecastSiteId(siteId, 5000));
|
||
|
||
// Grille ancrée sur l'HORLOGE MURALE + 3 jours : les révisions du site sont datées par
|
||
// l'horloge murale (firstSeenAt), et « en vigueur à la veille / à H−1 » doit les trouver.
|
||
QDateTime h = QDateTime::currentDateTimeUtc();
|
||
h.setTime(QTime(h.time().hour(), 0));
|
||
const QDateTime t0 = h.addDays(3);
|
||
const qint64 t0s = t0.toSecsSinceEpoch();
|
||
auto charge = [&](int v, const QVariantList &p) {
|
||
QVariantMap c{{"v", v}, {"unit", "W"}, {"convention", "mean over [t, t+step)"},
|
||
{"t0", t0s}, {"step", 3600}, {"issuedAt", t0s - 3 * 86400},
|
||
{"model", "meteofrance_seamless"}, {"p", p}};
|
||
if (v >= 2) {
|
||
c["corrections"] = QVariantList{"temperature"};
|
||
c["gti"] = QVariantList{700, 560, 420};
|
||
c["tempC"] = QVariantList{18.5, 19.0, 19.5};
|
||
}
|
||
return c;
|
||
};
|
||
tSud->setStateValue("forecastCurveTransport", charge(2, {1500, 1200, 900}));
|
||
tEst->setStateValue("forecastCurveTransport", charge(1, {700, 600, 500}));
|
||
|
||
auto cycle = [&](const QDateTime &n) {
|
||
arb->simulationCallUpdate(n);
|
||
QCoreApplication::processEvents();
|
||
};
|
||
auto lignes = [&](qint64 cible) {
|
||
QList<QVariantMap> sortie;
|
||
const QString nom = QStringLiteral("lecture-%1").arg(QUuid::createUuid().toString());
|
||
{
|
||
QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), nom);
|
||
db.setDatabaseName(ForecastStore::cheminParDefaut());
|
||
if (db.open()) {
|
||
QSqlQuery q(db);
|
||
q.prepare(QStringLiteral("SELECT l.*, (SELECT contentId FROM forecastPv f "
|
||
" WHERE f.targetAt = l.targetAt AND f.siteId = l.siteId "
|
||
" ORDER BY lastSeenAt DESC LIMIT 1) AS courant "
|
||
"FROM forecastLearning l WHERE siteId = ? AND targetAt = ? "
|
||
"ORDER BY planId"));
|
||
q.addBindValue(siteId);
|
||
q.addBindValue(cible);
|
||
if (q.exec()) {
|
||
while (q.next()) {
|
||
QVariantMap m;
|
||
const QSqlRecord r = q.record();
|
||
for (int i = 0; i < r.count(); ++i)
|
||
m.insert(r.fieldName(i), q.value(i));
|
||
sortie << m;
|
||
}
|
||
}
|
||
db.close();
|
||
}
|
||
}
|
||
QSqlDatabase::removeDatabase(nom);
|
||
return sortie;
|
||
};
|
||
|
||
// 1. SANS PRODUCTEUR — les minutes arrivent, mais la « production » du bilan n'est alors
|
||
// qu'un zéro de construction : NULL, et la ligne n'est pas valide.
|
||
const int lots0 = log->lotsTraites();
|
||
cycle(t0.addSecs(300));
|
||
for (int m = 1; m <= 60; ++m)
|
||
log->observerProduction(t0.addSecs(60 * m), -1100);
|
||
cycle(t0.addSecs(3600 + 150));
|
||
QTRY_COMPARE(log->lotsTraites(), lots0 + 1);
|
||
QList<QVariantMap> l = lignes(t0s);
|
||
QCOMPARE(l.count(), 2);
|
||
const QVariantMap rSud = l.at(0).value("planId").toString() == ThingId(sud).toString() ? l.at(0) : l.at(1);
|
||
const QVariantMap rEst = l.at(0).value("planId").toString() == ThingId(est).toString() ? l.at(0) : l.at(1);
|
||
QCOMPARE(rSud.value("planId").toString(), ThingId(sud).toString());
|
||
QCOMPARE(rEst.value("planId").toString(), ThingId(est).toString());
|
||
// Paramètres LUS sur le Thing plan.
|
||
QCOMPARE(rSud.value("kwp").toDouble(), 2.0);
|
||
QCOMPARE(rSud.value("performanceRatio").toDouble(), 0.85);
|
||
QCOMPARE(rSud.value("tempCoeffPctPerK").toDouble(), -0.37);
|
||
QCOMPARE(rSud.value("noctC").toDouble(), 45.0);
|
||
QCOMPARE(rSud.value("gti").toDouble(), 700.0);
|
||
QCOMPARE(rSud.value("tempC").toDouble(), 18.5);
|
||
QCOMPARE(rSud.value("pW").toDouble(), 1500.0);
|
||
QCOMPARE(rSud.value("corrections").toString(), QStringLiteral("temperature"));
|
||
// v1 : le Thing porte tempCoeff/noct, la charge utile ne les a pas appliqués — NULL.
|
||
QCOMPARE(rEst.value("kwp").toDouble(), 1.5);
|
||
QCOMPARE(rEst.value("pW").toDouble(), 700.0);
|
||
for (const char *c : {"gti", "tempC", "tempCoeffPctPerK", "noctC"})
|
||
QVERIFY2(rEst.value(c).isNull(), c);
|
||
for (const QVariantMap &r : {rSud, rEst}) {
|
||
QCOMPARE(r.value("acLimitW").toInt(), 5000);
|
||
QCOMPARE(r.value("sitePlans").toInt(), 2);
|
||
QCOMPARE(r.value("sitePlansExpected").toInt(), 2);
|
||
QCOMPARE(r.value("measuredMinutes").toInt(), 60);
|
||
QVERIFY(r.value("measuredW").isNull());
|
||
QVERIFY(r.value("measuredSource").isNull());
|
||
QVERIFY(r.value("curtailed").isNull());
|
||
QVERIFY(r.value("snow").isNull()); // le compteur-site n'a pas de snowfall
|
||
QCOMPARE(r.value("valid").toInt(), 0);
|
||
// Les RENVOIS vers la révision du site écrite par le même cycle — sa clé de contenu.
|
||
QVERIFY(!r.value("courant").toString().isEmpty());
|
||
QCOMPARE(r.value("contentIdHourAhead").toString(), r.value("courant").toString());
|
||
QCOMPARE(r.value("contentIdDayAhead").toString(), r.value("courant").toString());
|
||
}
|
||
|
||
// 2. AVEC UN ONDULEUR — la même mesure devient une mesure : publiée, sourcée, valide.
|
||
addSolarInverter();
|
||
for (int m = 61; m <= 120; ++m)
|
||
log->observerProduction(t0.addSecs(60 * m), -1100);
|
||
cycle(t0.addSecs(7200 + 150));
|
||
QTRY_COMPARE(log->lotsTraites(), lots0 + 2);
|
||
l = lignes(t0s + 3600);
|
||
QCOMPARE(l.count(), 2);
|
||
for (const QVariantMap &r : l) {
|
||
QCOMPARE(r.value("measuredW").toDouble(), 1100.0);
|
||
QCOMPARE(r.value("measuredSource").toString(), QStringLiteral("power-balance-1min"));
|
||
QCOMPARE(r.value("curtailed").toInt(), 0);
|
||
QCOMPARE(r.value("valid").toInt(), 1);
|
||
// Les états observedGti* du mock sont à leur défaut (instant 0) : rien d'observé.
|
||
QVERIFY(r.value("gtiObserved").isNull());
|
||
QVERIFY(r.value("gtiObservedAt").isNull());
|
||
}
|
||
|
||
// 3. LE GTI OBSERVÉ (etm68), lu sur le Thing plan par le NOM de ses états. Publié pour
|
||
// t0 + 1 h, il moyenne la PREMIÈRE heure : il complète la ligne déjà écrite du pas t0,
|
||
// sur le plan qui le publie seulement.
|
||
const int complements0 = log->complementsTraites();
|
||
tSud->setStateValue("observedGti", 640.0);
|
||
tSud->setStateValue("observedGtiTime", quint32(t0s + 3600));
|
||
cycle(t0.addSecs(7200 + 300));
|
||
QTRY_COMPARE(log->complementsTraites(), complements0 + 1);
|
||
l = lignes(t0s);
|
||
QCOMPARE(l.count(), 2);
|
||
for (const QVariantMap &r : l) {
|
||
if (r.value("planId").toString() == ThingId(sud).toString()) {
|
||
QCOMPARE(r.value("gtiObserved").toDouble(), 640.0);
|
||
QCOMPARE(r.value("gtiObservedSource").toString(), QStringLiteral("openmeteo-satellite-state"));
|
||
QCOMPARE(r.value("gtiObservedAt").toLongLong(), t0s + 3600);
|
||
} else {
|
||
QVERIFY(r.value("gtiObserved").isNull());
|
||
}
|
||
QCOMPARE(r.value("valid").toInt(), 0); // inchangé : valid n'en dépend pas
|
||
}
|
||
for (const QVariantMap &r : lignes(t0s + 3600))
|
||
QVERIFY(r.value("gtiObserved").isNull());
|
||
#endif
|
||
}
|
||
|
||
// ============================================================================================
|
||
// Brief 3 T4 — consommation de base et prix
|
||
// ============================================================================================
|
||
|
||
namespace {
|
||
QVariantMap zoneT4(const QString &label, double prix, const QList<QPair<QString, QString>> &plages)
|
||
{
|
||
QVariantList r;
|
||
for (const auto &p : plages)
|
||
r << QVariantMap{{"from", p.first}, {"to", p.second}};
|
||
return QVariantMap{{"label", label}, {"priceCtkWh", prix}, {"ranges", r}};
|
||
}
|
||
|
||
//! HP/HC « 22:00-06:00 » à 15, le reste à 25 c€/kWh.
|
||
QVariantList hphcT4()
|
||
{
|
||
return {zoneT4("HC", 15.0, {{"22:00", "06:00"}}), zoneT4("HP", 25.0, {{"06:00", "22:00"}})};
|
||
}
|
||
|
||
//! Le fuseau de la BOX fait foi pour les plages : le test le pose, et le rend en sortant.
|
||
struct FuseauDeTest {
|
||
QByteArray avant;
|
||
bool avait;
|
||
explicit FuseauDeTest(const QByteArray &tz) : avant(qgetenv("TZ")), avait(qEnvironmentVariableIsSet("TZ"))
|
||
{ qputenv("TZ", tz); }
|
||
void poser(const QByteArray &tz) { qputenv("TZ", tz); }
|
||
~FuseauDeTest() { if (avait) qputenv("TZ", avant); else qunsetenv("TZ"); }
|
||
};
|
||
|
||
QList<QVariantMap> lignesSql(const QString &sql)
|
||
{
|
||
QList<QVariantMap> sortie;
|
||
const QString nom = QStringLiteral("lecture-%1").arg(QUuid::createUuid().toString());
|
||
{
|
||
QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), nom);
|
||
db.setDatabaseName(ForecastStore::cheminParDefaut());
|
||
if (db.open()) {
|
||
QSqlQuery q(db);
|
||
if (q.exec(sql)) {
|
||
while (q.next()) {
|
||
QVariantMap m;
|
||
const QSqlRecord r = q.record();
|
||
for (int i = 0; i < r.count(); ++i)
|
||
m.insert(r.fieldName(i), q.value(i));
|
||
sortie << m;
|
||
}
|
||
}
|
||
db.close();
|
||
}
|
||
}
|
||
QSqlDatabase::removeDatabase(nom);
|
||
return sortie;
|
||
}
|
||
}
|
||
|
||
void Simulation::testTheTariffRpcRoundTripsAndRefuses()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
const auto nettoyer = qScopeGuard([&]() { arb->setTariff(TariffConfig()); });
|
||
auto lire = [&]() { return injectAndWait("NymeaEnergy.GetTariff").toMap().value("params").toMap(); };
|
||
auto poser = [&](const QVariantMap &p) { return injectAndWait("NymeaEnergy.SetTariff", p).toMap(); };
|
||
auto erreur = [](const QVariantMap &r) { return r.value("params").toMap().value("energyError").toString(); };
|
||
|
||
// 0. Point de départ CONNU : rien de saisi (un réglage d'un autre test ne fuit pas ici).
|
||
QCOMPARE(erreur(poser({{"zones", QVariantList()}})), QStringLiteral("EnergyErrorNoError"));
|
||
|
||
// 1. RIEN DE SAISI — zones, revente et nature OMISES, jamais un tarif vide à 0. Ce qui ne
|
||
// dépend pas d'une saisie est TOUJOURS là : devise, contenu du prix, fuseau qui fait foi.
|
||
QVariantMap g = lire();
|
||
for (const char *k : {"zones", "exportCtkWh", "source"})
|
||
QVERIFY2(!g.contains(k), k);
|
||
QCOMPARE(g.value("currency").toString(), QStringLiteral("EUR"));
|
||
QCOMPARE(g.value("includes").toStringList(), (QStringList{"energy", "grid", "taxes"}));
|
||
QCOMPARE(g.value("timeZone").toString(), QString::fromUtf8(QTimeZone::systemTimeZoneId()));
|
||
|
||
// 2. HP/HC, HC EN DEUX FENÊTRES, revente saisie — l'aller-retour est verbatim.
|
||
const QVariantList deuxFenetres{zoneT4("HC", 15.16, {{"12:00", "14:00"}, {"22:00", "06:00"}}),
|
||
zoneT4("HP", 25.27, {{"06:00", "12:00"}, {"14:00", "22:00"}})};
|
||
QVariantMap r = poser({{"zones", deuxFenetres}, {"exportCtkWh", 4.0}});
|
||
QCOMPARE(r.value("status").toString(), QStringLiteral("success"));
|
||
QCOMPARE(erreur(r), QStringLiteral("EnergyErrorNoError"));
|
||
QVERIFY(!r.value("params").toMap().contains("fault"));
|
||
g = lire();
|
||
QCOMPARE(g.value("source").toString(), QStringLiteral("tou"));
|
||
QCOMPARE(g.value("exportCtkWh").toDouble(), 4.0);
|
||
QCOMPARE(QJsonDocument::fromVariant(g.value("zones")).toJson(QJsonDocument::Compact),
|
||
QJsonDocument::fromVariant(deuxFenetres).toJson(QJsonDocument::Compact));
|
||
|
||
// 3. PERSISTÉ — relu tel quel depuis les réglages du moteur.
|
||
const QVariantMap enMemoire = arb->tariff().toVariant();
|
||
arb->relireTarif();
|
||
QCOMPARE(arb->tariff().toVariant(), enMemoire);
|
||
QVERIFY(EnergySettings().contains("tariff"));
|
||
|
||
// 4. REFUSÉS — un code, l'endroit, et RIEN de modifié. Le statut nymea est `success` : c'est
|
||
// le moteur qui refuse, et il dit pourquoi.
|
||
struct Cas { const char *quoi; QVariantList zones; QVariantMap attendu; };
|
||
const QList<Cas> cas = {
|
||
{"trou", {zoneT4("HC", 15, {{"22:00", "06:00"}}), zoneT4("HP", 25, {{"06:00", "21:00"}})},
|
||
{{"cause", "GAP"}, {"at", "21:00"}}},
|
||
{"chevauchement", {zoneT4("HC", 15, {{"22:00", "06:00"}}), zoneT4("HP", 25, {{"05:00", "22:00"}})},
|
||
{{"cause", "OVERLAP"}, {"at", "05:00"}, {"zones", QVariantList{0, 1}}}},
|
||
{"prix négatif", {zoneT4("Base", -1, {{"00:00", "24:00"}})}, {{"cause", "PRICE"}, {"zone", 0}}},
|
||
{"heure illisible", {zoneT4("Base", 20, {{"0:00", "24:00"}})},
|
||
{{"cause", "RANGE_FORMAT"}, {"zone", 0}, {"range", 0}}},
|
||
};
|
||
for (const Cas &c : cas) {
|
||
r = poser({{"zones", c.zones}});
|
||
QCOMPARE(r.value("status").toString(), QStringLiteral("success"));
|
||
const QVariantMap p = r.value("params").toMap();
|
||
QVERIFY2(p.value("energyError").toString() == QLatin1String("EnergyErrorInvalidParameter"), c.quoi);
|
||
QCOMPARE(p.value("fault").toString(), QString::fromLatin1(DecisionCode::TariffRefused));
|
||
QVERIFY2(QJsonDocument::fromVariant(p.value("faultParams")).toJson(QJsonDocument::Compact)
|
||
== QJsonDocument::fromVariant(c.attendu).toJson(QJsonDocument::Compact),
|
||
qPrintable(QStringLiteral("%1 : %2").arg(c.quoi, QString::fromUtf8(
|
||
QJsonDocument::fromVariant(p.value("faultParams")).toJson(QJsonDocument::Compact)))));
|
||
QCOMPARE(arb->tariff().toVariant(), enMemoire); // rien n'a bougé
|
||
}
|
||
// Un prix en TEXTE n'atteint même pas le moteur : la validation nymea refuse le type déclaré.
|
||
r = poser({{"zones", QVariantList{QVariantMap{{"label", "Base"}, {"priceCtkWh", "20"},
|
||
{"ranges", QVariantList{QVariantMap{{"from", "00:00"}, {"to", "24:00"}}}}}}}});
|
||
QVERIFY(r.value("status").toString() != QLatin1String("success")
|
||
|| erreur(r) == QLatin1String("EnergyErrorInvalidParameter"));
|
||
QCOMPARE(arb->tariff().toVariant(), enMemoire);
|
||
|
||
// 5. TARIF UNIQUE, revente OMISE → 0, nature `fixed`.
|
||
QCOMPARE(erreur(poser({{"zones", QVariantList{zoneT4("Base", 20.16, {{"00:00", "24:00"}})}}})),
|
||
QStringLiteral("EnergyErrorNoError"));
|
||
g = lire();
|
||
QCOMPARE(g.value("source").toString(), QStringLiteral("fixed"));
|
||
QCOMPARE(g.value("exportCtkWh").toDouble(), 0.0);
|
||
|
||
// 6. EFFACEMENT — zones vides : tout disparaît, la clé persistée aussi.
|
||
QCOMPARE(erreur(poser({{"zones", QVariantList()}, {"exportCtkWh", 6.0}})), QStringLiteral("EnergyErrorNoError"));
|
||
QVERIFY(!lire().contains("zones"));
|
||
QVERIFY(!lire().contains("exportCtkWh"));
|
||
QVERIFY(!EnergySettings().contains("tariff"));
|
||
|
||
// 7. UN RÉGLAGE PERSISTÉ ILLISIBLE est IGNORÉ, pas effacé : une version future saura le lire.
|
||
{
|
||
EnergySettings e;
|
||
e.setValue("tariff", "{pas du json");
|
||
}
|
||
arb->relireTarif();
|
||
QVERIFY(arb->tariff().isEmpty());
|
||
QVERIFY(!lire().contains("zones"));
|
||
QVERIFY(EnergySettings().contains("tariff"));
|
||
EnergySettings().remove("tariff");
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testTheBaseloadSubtractsEachLoadBySource()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-baseload.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);
|
||
BaseloadLog *log = arb->baseloadLog();
|
||
QVERIFY(log);
|
||
QTRY_VERIFY(log->profilsCalcules() >= 1);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
|
||
// TROIS SOURCES, trois charges — le banc d'aujourd'hui en miniature :
|
||
// - `ecs` sur un RELAIS SEC : rien ne la mesure (none) → comptée à sa COMMANDE ;
|
||
// - `pac` sur un relais, avec un COMPTEUR désigné (meter) ;
|
||
// - `routeur` à consigne continue, mesuré par l'appareil (device).
|
||
const QUuid meterId = addMeter(26741);
|
||
const QUuid sec = addDryRelay(26742);
|
||
const QUuid sw = addPowerSwitch(800, 26743);
|
||
const QUuid cpt = addMeterNoEnergy(26744);
|
||
const QUuid varId = addEtmVariableLoad(27051);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
Thing *tCpt = tm->findConfiguredThing(cpt);
|
||
Thing *var = tm->findConfiguredThing(varId);
|
||
QVERIFY(meter && tCpt && var && tm->findConfiguredThing(sec) && tm->findConfiguredThing(sw));
|
||
meter->setStateValue("connected", true);
|
||
var->setStateValue("currentPowerW", 0);
|
||
QVariantList l;
|
||
l << QVariantMap{{"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", 1}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0},
|
||
{"relays", QVariantList{QVariantMap{{"thingId", sec.toString()}, {"powerW", 1000}}}}};
|
||
l << QVariantMap{{"id", "pac"}, {"label", "PAC"}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", 2}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0},
|
||
{"meterThingId", cpt.toString()},
|
||
{"relays", QVariantList{QVariantMap{{"thingId", sw.toString()}, {"powerW", 800}}}}};
|
||
l << QVariantMap{{"id", varId.toString()}, {"label", "Routeur"}, {"adapter", "etmvariableload"},
|
||
{"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 3}, {"enabled", true}};
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
auto attendre = [&](const QDateTime &n) {
|
||
const int c = log->profilsCalcules();
|
||
log->recalculer(n);
|
||
QTRY_VERIFY(log->profilsCalcules() > c);
|
||
};
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
|
||
// 1. SANS COMPTEUR RACINE, l'import est un zéro de construction : AUCUNE minute.
|
||
arb->noterMinuteBilan(t0, 500, 0, 0);
|
||
attendre(t0);
|
||
QCOMPARE(log->minutesEcrites(), 0);
|
||
|
||
// 2. Compteur racine désigné ; un cycle à fort export fait COMMANDER l'ECS (1 000 W).
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
meter->setStateValue("currentPower", -6000);
|
||
arb->recordMeterUpdate(t0);
|
||
arb->simulationCallUpdate(t0);
|
||
QCoreApplication::processEvents();
|
||
tCpt->setStateValue("currentPower", 800);
|
||
var->setStateValue("currentPowerW", 1200);
|
||
const QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
|
||
QCOMPARE(tele.value("ecs").value("measurement").toMap().value("source").toString(), QStringLiteral("none"));
|
||
QVERIFY2(qRound(tele.value("ecs").value("allocatedW").toDouble()) == 1000,
|
||
"précondition : l'ECS doit être COMMANDÉE à 1 000 W, sinon la commande retranchée ne prouve rien");
|
||
|
||
// Trois minutes : sans production ; avec production (négative chez nymea) ; avec stockage.
|
||
arb->noterMinuteBilan(t0.addSecs(60), 4200, 0, 0); // site 4 200
|
||
arb->noterMinuteBilan(t0.addSecs(120), 1200, -3000, 0); // site 1 200 + 3 000
|
||
arb->noterMinuteBilan(t0.addSecs(180), 5200, -3000, 1000); // site 5 200 + 3 000 − 1 000
|
||
attendre(t0.addSecs(180));
|
||
QCOMPARE(log->minutesEcrites(), 3);
|
||
const QList<QVariantMap> minutes = lignesSql("SELECT * FROM baseloadMinute ORDER BY at");
|
||
QCOMPARE(minutes.count(), 3);
|
||
const QList<double> sites{4200, 4200, 7200}, bases{1200, 1200, 4200};
|
||
for (int i = 0; i < 3; ++i) {
|
||
QCOMPARE(minutes.at(i).value("at").toLongLong(), t0.addSecs(60 * (i + 1)).toSecsSinceEpoch());
|
||
QCOMPARE(minutes.at(i).value("siteW").toDouble(), sites.at(i));
|
||
// base = site − ECS commandée (1 000) − PAC au compteur (800) − routeur à l'appareil (1 200)
|
||
QCOMPARE(minutes.at(i).value("baseW").toDouble(), bases.at(i));
|
||
QCOMPARE(minutes.at(i).value("commandedLoads").toInt(), 1);
|
||
}
|
||
QHash<QString, QVariantMap> parts;
|
||
for (const QVariantMap &c : lignesSql(QStringLiteral("SELECT * FROM baseloadMinuteLoad WHERE at = %1")
|
||
.arg(t0.addSecs(60).toSecsSinceEpoch())))
|
||
parts.insert(c.value("loadId").toString(), c);
|
||
QCOMPARE(parts.count(), 3);
|
||
QCOMPARE(parts.value("ecs").value("source").toString(), QStringLiteral("none"));
|
||
QCOMPARE(parts.value("ecs").value("powerW").toDouble(), 1000.0);
|
||
QCOMPARE(parts.value("pac").value("source").toString(), QStringLiteral("meter"));
|
||
QCOMPARE(parts.value("pac").value("powerW").toDouble(), 800.0);
|
||
QCOMPARE(parts.value(varId.toString()).value("source").toString(), QStringLiteral("device"));
|
||
QCOMPARE(parts.value(varId.toString()).value("powerW").toDouble(), 1200.0);
|
||
|
||
// 3. MODE DÉGRADÉ L2 — le compteur est muet : la minute répéterait une valeur figée.
|
||
arb->evaluateMeterFreshness(t0.addSecs(300));
|
||
arb->noterMinuteBilan(t0.addSecs(360), 4200, 0, 0);
|
||
attendre(t0.addSecs(360));
|
||
QCOMPARE(log->minutesEcrites(), 3);
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testTheSnapshotCarriesBaseloadAndPriceOnThePvGrid()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
FuseauDeTest fuseau("Europe/Paris");
|
||
cleanupTestCase();
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(arb);
|
||
QTRY_VERIFY(arb->baseloadLog()->profilsCalcules() >= 1);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
const QUuid site = addMeter(26745);
|
||
const QUuid plan = addPvPlane(QStringLiteral("Toit"));
|
||
Thing *tPlan = tm->findConfiguredThing(ThingId(plan));
|
||
QVERIFY(tPlan);
|
||
tPlan->setParentId(ThingId(site));
|
||
const auto detacher = qScopeGuard([&]() {
|
||
tPlan->setParentId(ThingId());
|
||
arb->setForecastSiteId(QString());
|
||
arb->setTariff(TariffConfig());
|
||
});
|
||
QVERIFY(arb->setForecastSiteId(ThingId(site).toString()));
|
||
|
||
// LA GRILLE DE pv : 49 créneaux depuis le minuit local du DIMANCHE DE 25 H (2026-10-25,
|
||
// 1792879200 = 2026-10-24T22:00Z, oracle Python) jusqu'au lundi 23:00 local. Un TROU au
|
||
// créneau 10 : la courbe l'omet, la grille le garde.
|
||
const qint64 t0 = 1792879200;
|
||
QVariantList p;
|
||
for (int i = 0; i < 49; ++i)
|
||
p << (i == 10 ? QVariant() : QVariant(500));
|
||
tPlan->setStateValue("forecastCurveTransport",
|
||
QVariantMap{{"v", 1}, {"unit", "W"}, {"convention", "mean over [t, t+step)"},
|
||
{"t0", t0}, {"step", 3600}, {"issuedAt", t0 - 3600},
|
||
{"model", "meteofrance_seamless"}, {"p", p}});
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetTariff", QVariantMap{{"zones", hphcT4()}, {"exportCtkWh", 6}})
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QStringLiteral("EnergyErrorNoError"));
|
||
auto snapshot = [&]() {
|
||
return injectAndWait("NymeaEnergy.GetForecastSnapshot").toMap().value("params").toMap();
|
||
};
|
||
|
||
QVariantMap s = snapshot();
|
||
QCOMPARE(s.value("forecastPoints").toList().count(), 48); // le trou est absent…
|
||
QCOMPARE(s.value("start").toString(), QStringLiteral("2026-10-24T22:00:00Z"));
|
||
QCOMPARE(s.value("resolutionS").toInt(), 3600);
|
||
QCOMPARE(s.value("steps").toInt(), 49); // …mais reste une case
|
||
|
||
// LE PRIX — oracle hors Qt : dimanche 25 h [HC×7, HP×16, HC×2], lundi [HC×6, HP×16, HC×2].
|
||
QVariantList attendu;
|
||
for (int i = 0; i < 7; ++i) attendu << 15.0;
|
||
for (int i = 0; i < 16; ++i) attendu << 25.0;
|
||
attendu << 15.0 << 15.0;
|
||
for (int i = 0; i < 6; ++i) attendu << 15.0;
|
||
for (int i = 0; i < 16; ++i) attendu << 25.0;
|
||
attendu << 15.0 << 15.0;
|
||
QCOMPARE(attendu.count(), 49);
|
||
const QVariantMap prix = s.value("price").toMap();
|
||
QCOMPARE(prix.value("importCtkWh").toList(), attendu);
|
||
QCOMPARE(prix.value("exportCtkWh").toList(), QVariantList(49, 6.0));
|
||
QCOMPARE(prix.value("currency").toString(), QStringLiteral("EUR"));
|
||
QCOMPARE(prix.value("source").toString(), QStringLiteral("tou"));
|
||
QCOMPARE(prix.value("includes").toStringList(), (QStringList{"energy", "grid", "taxes"}));
|
||
QCOMPARE(prix.value("knownUntil").toString(), QStringLiteral("2026-10-26T23:00:00Z"));
|
||
|
||
// LA BASE — aucune donnée : `none`, 49 null (jamais 0), et la qualité dit pourquoi.
|
||
const QVariantMap base = s.value("baseload").toMap();
|
||
QCOMPARE(base.value("model").toString(), QStringLiteral("none"));
|
||
QCOMPARE(base.value("p50W").toList().count(), 49);
|
||
for (const QVariant &v : base.value("p50W").toList())
|
||
QVERIFY(v.isNull());
|
||
QCOMPARE(base.value("quality").toMap().value("windowDays").toInt(), 28);
|
||
QCOMPARE(base.value("quality").toMap().value("minDays").toInt(), 7);
|
||
|
||
// RÉVÉLATEUR — la box passe à Londres : le créneau 7 (05:00Z) est 06:00 à Paris (HP), 05:00 à
|
||
// Londres (HC). Les plages se lisent dans le fuseau DE LA BOX, au moment de la lecture.
|
||
QCOMPARE(prix.value("importCtkWh").toList().at(7).toDouble(), 25.0);
|
||
fuseau.poser("Europe/London");
|
||
QCOMPARE(snapshot().value("price").toMap().value("importCtkWh").toList().at(7).toDouble(), 15.0);
|
||
fuseau.poser("Europe/Paris");
|
||
|
||
// SANS SITE : la courbe disparaît (motif), le tarif saisi NON — il est publié sur la grille
|
||
// par défaut, horaire, du minuit local du jour au minuit local du surlendemain.
|
||
QVERIFY(arb->setForecastSiteId(QString()));
|
||
s = snapshot();
|
||
QCOMPARE(s.value("fault").toString(), QString::fromLatin1(DecisionCode::MissingForecastSite));
|
||
QVERIFY(!s.contains("forecastPoints"));
|
||
const int n = s.value("steps").toInt();
|
||
QVERIFY2(n >= 47 && n <= 49, qPrintable(QString::number(n)));
|
||
QCOMPARE(s.value("resolutionS").toInt(), 3600);
|
||
const QDateTime debut = QDateTime::fromString(s.value("start").toString(), Qt::ISODate);
|
||
QCOMPARE(debut.toTimeZone(QTimeZone("Europe/Paris")).time(), QTime(0, 0));
|
||
QVERIFY(debut <= QDateTime::currentDateTimeUtc());
|
||
QCOMPARE(s.value("price").toMap().value("importCtkWh").toList().count(), n);
|
||
QCOMPARE(s.value("baseload").toMap().value("p50W").toList().count(), n);
|
||
|
||
// SANS TARIF : `price` est null — présent, et nul. Jamais une série de zéros.
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetTariff", QVariantMap{{"zones", QVariantList()}})
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QStringLiteral("EnergyErrorNoError"));
|
||
s = snapshot();
|
||
QVERIFY(s.contains("price"));
|
||
QVERIFY(s.value("price").isNull());
|
||
#endif
|
||
}
|
||
|
||
void Simulation::testNoDecisionReadsBaseloadOrPrice()
|
||
{
|
||
#ifndef ETM_ARBITRATOR
|
||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||
#else
|
||
// §11 — le prix et la base sont EXPOSÉS, jamais LUS par une décision. Ce test le REJOUE : le
|
||
// même cycle, aux mêmes entrées, avant et après qu'un tarif et un profil existent — et la
|
||
// charge utile de l'arbitrage doit être identique. S'il casse, ce n'est pas le test qu'il
|
||
// faut corriger : c'est un lot qui a branché le prix ou la base sur une décision sans le
|
||
// dire. Ce test devient FAUX le jour où un lot dédié le décide (e1, « en HC ») — alors il se
|
||
// réécrit avec cette décision, il ne se supprime pas.
|
||
FuseauDeTest fuseau("Europe/Paris");
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-s11.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);
|
||
const auto nettoyer = qScopeGuard([&]() { arb->setTariff(TariffConfig()); });
|
||
arb->setTariff(TariffConfig());
|
||
BaseloadLog *log = arb->baseloadLog();
|
||
QTRY_VERIFY(log->profilsCalcules() >= 1);
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
const QUuid meterId = addMeter(26746);
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
Thing *meter = tm->findConfiguredThing(meterId);
|
||
QVERIFY(meter);
|
||
meter->setStateValue("connected", true);
|
||
const QUuid sw = addPowerSwitch(1000, 26747);
|
||
const QUuid varId = addEtmVariableLoad(27052);
|
||
Thing *var = tm->findConfiguredThing(varId);
|
||
QVERIFY(var && tm->findConfiguredThing(sw));
|
||
var->setStateValue("currentPowerW", 0);
|
||
QVariantList l;
|
||
l << QVariantMap{{"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", 1}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0},
|
||
{"relays", QVariantList{QVariantMap{{"thingId", sw.toString()}, {"powerW", 1000}}}}};
|
||
l << QVariantMap{{"id", varId.toString()}, {"label", "Routeur"}, {"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"));
|
||
|
||
// Ce qui varie d'un cycle à l'autre SANS être une décision : l'horodatage, et les objets
|
||
// datés à l'horloge murale (écart commandé/mesuré, secondes de verrou).
|
||
auto normaliser = [](QVariantMap snap) {
|
||
snap.remove("timestamp");
|
||
QVariantList charges;
|
||
for (const QVariant &v : snap.value("loads").toList()) {
|
||
QVariantMap c = v.toMap();
|
||
c.remove("command");
|
||
c.remove("lock");
|
||
charges << c;
|
||
}
|
||
snap.insert("loads", charges);
|
||
return snap;
|
||
};
|
||
const QDateTime t = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||
auto cycle = [&](int i) {
|
||
meter->setStateValue("currentPower", -1800);
|
||
arb->recordMeterUpdate(t.addSecs(60 * i));
|
||
arb->simulationCallUpdate(t.addSecs(60 * i));
|
||
QCoreApplication::processEvents();
|
||
return normaliser(arb->loadTelemetry());
|
||
};
|
||
QVariantMap avant;
|
||
for (int i = 0; i < 6; ++i)
|
||
avant = cycle(i);
|
||
QVERIFY2(!avant.value("loads").toList().isEmpty(), "aucune charge arbitrée : rien à comparer");
|
||
QVERIFY2(cycle(6) == avant, "régime non établi : la comparaison ne prouverait rien");
|
||
|
||
// UN TARIF et UN PROFIL, et la preuve qu'ils sont VIVANTS — sinon l'égalité serait vacante.
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetTariff", QVariantMap{{"zones", hphcT4()}, {"exportCtkWh", 6}})
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QStringLiteral("EnergyErrorNoError"));
|
||
const qint64 fin = t.addSecs(6 * 60).toSecsSinceEpoch();
|
||
for (qint64 f = fin - 8 * 86400 + 60; f <= fin; f += 60)
|
||
arb->noterMinuteBilan(QDateTime::fromSecsSinceEpoch(f, QTimeZone::utc()), 2500, 0, 0);
|
||
const int c = log->profilsCalcules();
|
||
log->recalculer(QDateTime::fromSecsSinceEpoch(fin, QTimeZone::utc()));
|
||
QTRY_VERIFY_WITH_TIMEOUT(log->profilsCalcules() > c, 30000);
|
||
QCOMPARE(log->profil().model, QStringLiteral("profile"));
|
||
const QVariantMap s = injectAndWait("NymeaEnergy.GetForecastSnapshot").toMap().value("params").toMap();
|
||
QCOMPARE(s.value("baseload").toMap().value("model").toString(), QStringLiteral("profile"));
|
||
QVERIFY(!s.value("price").isNull());
|
||
|
||
// LE MÊME CYCLE, LES MÊMES ENTRÉES : la même décision, au watt et au motif près.
|
||
const QVariantMap apres = cycle(7);
|
||
QVERIFY2(apres == avant,
|
||
qPrintable(QStringLiteral("le tarif ou la base a changé une décision (§11) :\navant %1\naprès %2")
|
||
.arg(QString::fromUtf8(QJsonDocument::fromVariant(avant).toJson(QJsonDocument::Compact)),
|
||
QString::fromUtf8(QJsonDocument::fromVariant(apres).toJson(QJsonDocument::Compact)))));
|
||
#endif
|
||
}
|