// 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 . * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ #include "simulation.h" #include #include #include #include using namespace nymeaserver; #include "../../../energyplugin/smartchargingmanager.h" #include "../../mocks/spotmarketprovider/spotmarketdataprovidermock.h" #ifdef ETM_ARBITRATOR #include "../../../energyplugin/etm/energyarbitrator.h" #include "../../../energyplugin/etm/adapters/sgreadyadapter.h" // [T3] ecsrelayadapter.h retiré : l'ECS est piloté en watts via EtmVariableLoadAdapter // (interface etmvariableload, kind Setpoint). La combinatoire relais vit dans le thing. #include "../../../energyplugin/etm/adapters/etmvariableloadadapter.h" #include "../../../energyplugin/etm/adapters/relayrouter.h" #include "../../../energyplugin/etm/adapters/evadapter.h" #include "../../../energyplugin/evcharger.h" #include "../../../energyplugin/etm/types/loadconfig.h" #include "../../../energyplugin/etm/config/loadconfigstore.h" #include "../../../energyplugin/etm/ratios/energyratioscalculator.h" #include "../../../energyplugin/etm/scheduler/rulebasedscheduler.h" #include "../../../energyplugin/etm/types/decisionreason.h" #endif #include #include #include #include #include #include #include #include #include #include #include #include #include #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 m_types; static QList s_pile; static QtMessageHandler s_origine; }; QList 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(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({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(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(), 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(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({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({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(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({ {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({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({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(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(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 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(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(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(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({ {r.toString(), 2000} }), 0, 0, 1, LoadNeeds(), owner); QCOMPARE(ecs->descriptor().declared.powerLevels, QList({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({ {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({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({ {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({0, 1000, 2000})); // 1200 → palier 1000 = UN SEUL relais (le premier de la combinaison dédupliquée). ecs->applyAction(sp(1200), t0); QCOMPARE(qRound(ecs->currentSetpointW()), 1000); QCOMPARE(tA->stateValue("power").toBool(), true); QCOMPARE(tB->stateValue("power").toBool(), false); // 2200 → palier 2000 = les deux. ecs->applyAction(sp(2200), t0.addSecs(1)); QCOMPARE(qRound(ecs->currentSetpointW()), 2000); QCOMPARE(tA->stateValue("power").toBool(), true); QCOMPARE(tB->stateValue("power").toBool(), true); } #endif } void Simulation::testEnergyRatiosAlignment() { #ifndef ETM_ARBITRATOR QSKIP("ETM_ARBITRATOR désactivé — ratios canoniques non compilés."); #else // Vecteurs joués 1:1 contre la sémantique du seam interim app // (EnergyRatiosInterim.compute) : seed, normal, clamp-bas, den≤0→n/a, // non-monotone (Δ<0)→reseed, nouveau jour local→reseed. EnergyRatiosCalculator calc; auto naAuto = [](const EnergyRatiosCalculator::Ratios &r) { QVERIFY(!r.autoconsommationValid); }; auto naAuton = [](const EnergyRatiosCalculator::Ratios &r) { QVERIFY(!r.autonomieValid); }; auto eqAuto = [](const EnergyRatiosCalculator::Ratios &r, double v) { QVERIFY(r.autoconsommationValid); QVERIFY(qAbs(r.autoconsommation - v) < 1e-3); }; auto eqAuton = [](const EnergyRatiosCalculator::Ratios &r, double v) { QVERIFY(r.autonomieValid); QVERIFY(qAbs(r.autonomie - v) < 1e-3); }; const QDateTime d29_00(QDate(2026, 6, 29), QTime(0, 0)); const QDateTime d29_06(QDate(2026, 6, 29), QTime(6, 0)); const QDateTime d29_08(QDate(2026, 6, 29), QTime(8, 0)); const QDateTime d29_10(QDate(2026, 6, 29), QTime(10, 0)); const QDateTime d30_02(QDate(2026, 6, 30), QTime(2, 0)); const QDateTime d30_10(QDate(2026, 6, 30), QTime(10, 0)); // A — 1er appel : seed baseline → deltas nuls → den≤0 → n/a (les deux). EnergyRatiosCalculator::Ratios a = calc.compute(1000, 200, 3000, 2200, d29_00); naAuto(a); naAuton(a); // B — même jour, croissance monotone : auto=(500-100)/500=80%, autonomie=(1000-500)/1000=50%. EnergyRatiosCalculator::Ratios b = calc.compute(1500, 300, 4000, 2700, d29_06); eqAuto(b, 80.0); eqAuton(b, 50.0); // C — clamp bas : dReturn(700) > dProd(600) → num<0 → auto borné à 0.0 ; // autonomie=(1200-900)/1200=25%. EnergyRatiosCalculator::Ratios c = calc.compute(1600, 900, 4200, 3100, d29_08); eqAuto(c, 0.0); eqAuton(c, 25.0); // D — compteur non monotone (production 900 < base 1000) → reseed → n/a. EnergyRatiosCalculator::Ratios d = calc.compute(900, 900, 4200, 3100, d29_10); naAuto(d); naAuton(d); // E — nouveau jour local → reseed → n/a (même si cumuls croissants). EnergyRatiosCalculator::Ratios e = calc.compute(2000, 500, 6000, 4000, d30_02); naAuto(e); naAuton(e); // F — même jour (30) : auto=(600-200)/600≈66.667%, autonomie=(1000-500)/1000=50%. EnergyRatiosCalculator::Ratios f = calc.compute(2600, 700, 7000, 4500, d30_10); eqAuto(f, 66.6667); eqAuton(f, 50.0); #endif } void Simulation::testLoadConfigRelayRouter() { #ifndef ETM_ARBITRATOR QSKIP("testLoadConfigRelayRouter nécessite ETM_ARBITRATOR."); #else // Chaîne COMPLÈTE rév. 3 : SetConfigs(relays[]) → store.changed → rebuild construit un // RelayRouter → cycle surplus → arrondi scheduler → routeur → commutation relais → currentPowerW. cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arbitrator = dynamic_cast(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(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({ {rA.toString(), 2000} }), 300, 0, 1, LoadNeeds(), arb); RelayRouter *l2 = new RelayRouter(tm, "ecs-2", "ECS secondaire", QList({ {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(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 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 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(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(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({ {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({ {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({ {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({ {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(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({ {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({ {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()); QCOMPARE(r->telemetry().available, true); 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(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(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>({ {1,{k1.toString()}}, {2,{}}, {3,{}}, {4,{k1.toString()}} }), QHash({ {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>({ {1,{k2ko}}, {2,{}}, {3,{k1.toString()}}, {4,{k1.toString(), k2ko}} }), QHash({ {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(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("simulationName"); QTest::addColumn("simulationTitle"); QTest::addColumn("databaseName"); QTest::addColumn("simulationStart"); QTest::addColumn("plugEvents"); QTest::addColumn("simulationHours"); QTest::addColumn("sampleRate"); QTest::addColumn("productionScaling"); QTest::addColumn("detailsStepStart"); QTest::addColumn("detailsStepStop"); QTest::addColumn("detailsStepList"); // Houshold info QTest::addColumn("phasePowerLimit"); QTest::addColumn("spotMarketEnabled"); QTest::addColumn("spotMarketResourceData"); QTest::addColumn("acquisitionTolerance"); QTest::addColumn("batteryLevelConsideration"); // ChargingInfo QTest::addColumn("targetPercentage"); QTest::addColumn("targetDateTime"); QTest::addColumn("chargingMode"); QTest::addColumn("carBatteryLevel"); QTest::addColumn("dailySpotMarketPercentage"); // Car information and states QTest::addColumn("carCapacity"); QTest::addColumn("carMinChargingCurrent"); QTest::addColumn("carPhaseCount"); // Energy storage information and states QTest::addColumn("energyStorageAvailable"); QTest::addColumn("energyStorageCapacity"); QTest::addColumn("energyStorageMaxChargingPower"); QTest::addColumn("energyStorageMaxDischargingPower"); QTest::addColumn("energyStorageInitialBatteyLevel"); // Charger initial states QTest::addColumn("chargerConnected"); QTest::addColumn("chargerPower"); QTest::addColumn("chargerPhases"); QTest::addColumn("canSwitchPhaseCount"); QTest::addColumn("chargerMaxChargingCurrent"); QTest::addColumn("chargerMaxChargingCurrentMaxValue"); QTest::addColumn("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, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 7) , 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, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 30) , SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 88) } }, { 960, { // 22:00 SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 30) , 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, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true) } }, { 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 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") }, // --- Sécurité et cycle de vie --- { { DecisionCode::DegradedL2 }, QString(), QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)") }, { { DecisionCode::SafeStateRelay }, QString(), QStringLiteral("Charge désactivée — mise en état sûr avant retrait (ECS-413)") }, { { DecisionCode::SafeStateSetpoint }, QString(), QStringLiteral("Charge désactivée — mise en état sûr (0 W) avant retrait (ECS-413)") }, { { DecisionCode::SafeStateSgReady }, QString(), QStringLiteral("Charge désactivée — mise en état sûr (état 2) avant retrait (ECS-413)") }, }; QSet 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 }; 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(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); ThingManager *tm = NymeaCore::instance()->thingManager(); // Le harnais de simulation n'abonne PAS le client aux notifications (il charge son // experience mock APRÈS initTestCase, donc EnergyTestBase saute l'abonnement). Or ce test // porte précisément sur les notifications : sans cet appel, il « passerait » en observant // le silence qu'il est censé faire échouer. enableNotifications({"NymeaEnergy"}); QUuid meterId = addMeter(); m_experiencePlugin->energyManager()->setRootMeter(meterId); Thing *meter = tm->findConfiguredThing(meterId); QVERIFY(meter); meter->setStateValue("connected", true); // ---- 0. AVANT tout cycle : « pas encore d'arbitrage » se DIT, il ne se devine pas ------ // timestamp ABSENT (omis, jamais nul) : forger un instant se lirait « cycle exécuté, rien à // allouer », soit exactement le mensonge que ce champ existe pour empêcher. QVariant resp = injectAndWait("NymeaEnergy.GetLoadTelemetry"); QVariantMap vide = resp.toMap().value("params").toMap(); QVERIFY2(!vide.contains("timestamp"), "timestamp forgé avant le premier cycle"); QVERIFY(vide.contains("degradedMode")); QCOMPARE(vide.value("degradedMode").toBool(), false); QVERIFY(vide.value("loads").toList().isEmpty()); // ---- Trois mécanismes, un seul budget : l'union discriminée doit sortir ENTIÈRE -------- QUuid rA = addPowerSwitch(1000, 26661); // relay-router (1 relais, minOn 300 s) QUuid k1 = addPowerSwitch(0, 26662); // sg-ready K1 QUuid k2 = addPowerSwitch(0, 26663); // sg-ready K2 QUuid varId = addEtmVariableLoad(27040); // etmvariableload dynamic Thing *relayA = tm->findConfiguredThing(rA); Thing *varThing = tm->findConfiguredThing(varId); QVERIFY(relayA && varThing); QVERIFY(tm->findConfiguredThing(k1) && tm->findConfiguredThing(k2)); varThing->setStateValue("currentPowerW", 0); LoadConfigStore *store = new LoadConfigStore(arb); arb->setLoadConfigStore(store); LoadConfigs cfgs; cfgs.append(LoadConfig::fromMap(QVariantMap{ {"id", varId.toString()}, {"label", "Routeur PV"}, {"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 1}, {"enabled", true}, {"domain", "ecs"}})); cfgs.append(LoadConfig::fromMap(QVariantMap{ {"id", "pac-telemetrie"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"}, {"priority", 2}, {"enabled", true}, {"domain", "hvac"}, {"sgReady", QVariantMap{ {"minStateHoldS", 300}, {"states", QVariantList() << QVariantMap{{"state", 1}, {"relays", QVariantList() << k1.toString()}, {"estimatedPowerW", 0.0}} << QVariantMap{{"state", 2}, {"relays", QVariantList()}, {"estimatedPowerW", 0.0}} << QVariantMap{{"state", 3}, {"relays", QVariantList() << k2.toString()}, {"estimatedPowerW", 800.0}} << QVariantMap{{"state", 4}, {"relays", QVariantList() << k1.toString() << k2.toString()}, {"estimatedPowerW", 1600.0}}}}}})); cfgs.append(LoadConfig::fromMap(QVariantMap{ {"id", "ecs-relais-telemetrie"}, {"label", "ECS relais"}, {"adapter", "relay-router"}, {"mode", "fixed"}, {"priority", 3}, {"enabled", true}, {"domain", "ecs"}, {"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, {"powerW", 1000}}}, {"minOnS", 300}, {"minOffS", 0}})); QString err; QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8()); // ---- 1. Le CONSTAT rejoué : un cycle d'arbitrage produit-il une notification ? --------- QSignalSpy notificationSpy(m_mockTcpServer, &MockTcpServer::outgoingData); const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); meter->setStateValue("currentPower", -6000); // 6000 W exportés arb->simulationCallUpdate(t0); QCoreApplication::processEvents(); QVariantList notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged"); QVERIFY2(!notifs.isEmpty(), "aucune notification LoadTelemetryChanged — c'est le constat " "d'origine, arbitre actif et frontière RPC muette"); QVariantMap premier = notifs.last().toMap().value("params").toMap(); // PREMIER cycle : la PAC est GELÉE par l'armement à froid du minStateHold (ECS-412). Ce // n'est pas un détail de test — c'est ce que l'app verra après CHAQUE redémarrage de // nymead, pendant la durée configurée. La télémétrie doit donc le dire, et le dire en // clair : un verrou nommé, des secondes restantes, et un motif qui accuse le verrou et // non le budget. { QVariantMap parIdC1; for (const QVariant &v : premier.value("loads").toList()) parIdC1.insert(v.toMap().value("loadId").toString(), v.toMap()); const QVariantMap pacC1 = parIdC1.value("pac-telemetrie").toMap(); QCOMPARE(pacC1.value("mechanism").toMap().value("state").toInt(), 2); QCOMPARE(qRound(pacC1.value("allocatedW").toDouble()), 0); QCOMPARE(pacC1.value("decision").toMap().value("code").toString(), QString(DecisionCode::LockMinStateHold)); QCOMPARE(pacC1.value("lock").toMap().value("kind").toString(), QString("minStateHold")); QCOMPARE(pacC1.value("lock").toMap().value("remainingS").toInt(), 300); // Le budget du cycle en tient compte : la PAC n'a rien reçu. QCOMPARE(qRound(premier.value("budget").toMap().value("allocatedW").toDouble()), 4000); // Le relais, lui, vient de commuter : sa fenêtre minOn est pleine. const QVariantMap relaisC1 = parIdC1.value("ecs-relais-telemetrie").toMap(); QCOMPARE(relaisC1.value("lock").toMap().value("kind").toString(), QString("minOn")); QCOMPARE(relaisC1.value("lock").toMap().value("remainingS").toInt(), 300); } // ---- Le verrou EXPIRE, et la PAC prend sa part ------------------------------------------ notificationSpy.clear(); const QDateTime t1 = t0.addSecs(301); // > minStateHoldS (300) arb->simulationCallUpdate(t1); QCoreApplication::processEvents(); notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged"); QVERIFY2(!notifs.isEmpty(), "le déblocage de la PAC change l'allocation : il doit notifier"); QVariantMap notif = notifs.last().toMap().value("params").toMap(); // Cohérence avec la cascade : Routeur PV (rang 1) prend 3000 (clamp maxPowerW), reliquat // 3000 → PAC (rang 2) forcée état 4 (~1600), reliquat 1400 → ECS relais (rang 3) 1000. QCOMPARE(notif.value("degradedMode").toBool(), false); QVERIFY(notif.contains("timestamp")); QCOMPARE(notif.value("timestamp").toString(), t1.toUTC().toString(Qt::ISODate)); const QVariantMap budget = notif.value("budget").toMap(); QCOMPARE(qRound(budget.value("surplusW").toDouble()), 6000); QCOMPARE(qRound(budget.value("evReservedW").toDouble()), 0); QCOMPARE(qRound(budget.value("allocatedW").toDouble()), 5600); QCOMPARE(qRound(budget.value("remainingW").toDouble()), 1400); // Le relais tire déjà ses 1000 W depuis le cycle précédent : ils lui sont RECRÉDITÉS avant // arrondi (anti-clignotement). Sans ce terme publié, l'identité serait fausse ici même. QCOMPARE(qRound(budget.value("recreditedW").toDouble()), 1000); // L'invariant publié : surplus + recrédit − alloué == restant. Le recrédit y figure parce // que sans lui l'identité serait FAUSSE dès qu'une charge consomme déjà — c'est-à-dire dans // le cas normal — et le client chercherait un écart inexplicable. QCOMPARE(qRound(budget.value("surplusW").toDouble() + budget.value("recreditedW").toDouble() - budget.value("allocatedW").toDouble()), qRound(budget.value("remainingW").toDouble())); const QVariantList loads = notif.value("loads").toList(); QCOMPARE(loads.size(), 3); // l'EV n'y est pas : décidé en amont QVariantMap parId; double sommeAllouee = 0; for (const QVariant &v : loads) { const QVariantMap e = v.toMap(); parId.insert(e.value("loadId").toString(), e); sommeAllouee += e.value("allocatedW").toDouble(); } // La somme des allocations RETOMBE sur budget.allocatedW : sans quoi un client qui // additionne les lignes obtiendrait un total en désaccord avec l'en-tête. QCOMPARE(qRound(sommeAllouee), qRound(budget.value("allocatedW").toDouble())); const QVariantMap eVar = parId.value(varId.toString()).toMap(); const QVariantMap ePac = parId.value("pac-telemetrie").toMap(); const QVariantMap eRelay = parId.value("ecs-relais-telemetrie").toMap(); QVERIFY(!eVar.isEmpty() && !ePac.isEmpty() && !eRelay.isEmpty()); // Union discriminée de mécanisme : chaque variante sort avec SA charge utile, et seulement // la sienne (LM-300/302 appliqués au sens sortant). QCOMPARE(eVar.value("mechanism").toMap().value("kind").toString(), QString("variable")); QCOMPARE(qRound(eVar.value("mechanism").toMap().value("setpointW").toDouble()), 3000); QCOMPARE(qRound(eVar.value("mechanism").toMap().value("percent").toDouble()), 100); QVERIFY(!eVar.value("mechanism").toMap().contains("state")); // charge utile d'un AUTRE mécanisme QVERIFY(!eVar.value("mechanism").toMap().contains("stageW")); QCOMPARE(ePac.value("mechanism").toMap().value("kind").toString(), QString("sgReady")); QCOMPARE(ePac.value("mechanism").toMap().value("state").toInt(), 4); QVERIFY(!ePac.value("mechanism").toMap().contains("stageW")); QCOMPARE(eRelay.value("mechanism").toMap().value("kind").toString(), QString("relay")); QCOMPARE(qRound(eRelay.value("mechanism").toMap().value("stageW").toDouble()), 1000); QVERIFY(!eRelay.value("mechanism").toMap().contains("percent")); // Motifs : des CODES, avec leurs paramètres numériques. Jamais une phrase. QCOMPARE(eVar.value("decision").toMap().value("code").toString(), QString(DecisionCode::SurplusSetpoint)); QCOMPARE(eVar.value("decision").toMap().value("params").toMap().value("stepped").toBool(), false); QCOMPARE(ePac.value("decision").toMap().value("code").toString(), QString(DecisionCode::SgForced)); QCOMPARE(eRelay.value("decision").toMap().value("code").toString(), QString(DecisionCode::SurplusSetpoint)); QCOMPARE(eRelay.value("decision").toMap().value("params").toMap().value("stepped").toBool(), true); // Verrous en SECONDES restantes, pas en watts. Les deux charges viennent de commuter à t0, // donc leur fenêtre est pleine. Le routeur PV n'a AUCUN verrou côté moteur : la clé est // absente, ce qui est la vérité — et non un verrou à zéro, qui se lirait « fenêtre expirée ». // La PAC vient de commuter en 4 : sa fenêtre repart pleine. QCOMPARE(ePac.value("lock").toMap().value("kind").toString(), QString("minStateHold")); QCOMPARE(ePac.value("lock").toMap().value("remainingS").toInt(), 300); // Le relais, lui, a commuté au cycle PRÉCÉDENT (t0) : 301 s plus tard son minOn de 300 s a // EXPIRÉ, et la clé disparaît. Un verrou qui ne s'efface pas serait un verrou permanent — // le défaut d'ECS-412 —, et l'app afficherait une charge éternellement bloquée. QVERIFY2(!eRelay.contains("lock"), "le verrou minOn du relais aurait dû expirer"); QVERIFY(!eVar.contains("lock")); // Disponibilité, et absence de code de défaut sur une installation saine. for (const QVariant &v : loads) { QCOMPARE(v.toMap().value("available").toBool(), true); QVERIFY(!v.toMap().contains("faultCode")); } // ---- 2. Snapshot ≡ notification -------------------------------------------------------- // Le Get ne recalcule pas : il rend l'objet publié. Un recalcul donnerait des verrous // rafraîchis sous un timestamp périmé — un état qui n'a jamais existé. resp = injectAndWait("NymeaEnergy.GetLoadTelemetry"); QCOMPARE(resp.toMap().value("params").toMap(), notif); // ---- 5. ZÉRO langue naturelle dans la charge utile ------------------------------------- // Contrôle MÉCANIQUE : toute chaîne émise est ASCII et sans espace. Une phrase française en // porterait forcément (accents et/ou espaces). Le libellé de la charge, lui, n'entre pas // dans la charge utile : il est substitué au RENDU, côté journal. std::function verifierCodes = [&](const QVariant &v, const QString &chemin) { if (v.userType() == QMetaType::QVariantMap) { const QVariantMap m = v.toMap(); for (auto it = m.constBegin(); it != m.constEnd(); ++it) verifierCodes(it.value(), chemin + '.' + it.key()); } else if (v.userType() == QMetaType::QVariantList) { const QVariantList l = v.toList(); for (int i = 0; i < l.size(); ++i) verifierCodes(l.at(i), QStringLiteral("%1[%2]").arg(chemin).arg(i)); } else if (v.userType() == QMetaType::QString) { const QString s = v.toString(); for (const QChar &c : s) QVERIFY2(c.unicode() >= 32 && c.unicode() < 127, qUtf8Printable(QStringLiteral("%1 : caractère non ASCII dans « %2 »") .arg(chemin, s))); QVERIFY2(!s.contains(' '), qUtf8Printable(QStringLiteral("%1 : espace dans « %2 » — une valeur " "émise doit être un CODE, pas une phrase") .arg(chemin, s))); } }; verifierCodes(notif, QStringLiteral("telemetry")); // ---- 4. Arbitre FIGÉ détectable -------------------------------------------------------- // Aucun cycle nouveau : le battement republie l'instantané tel quel. Le client reçoit donc // un état, et son timestamp N'AVANCE PAS — c'est la preuve de non-fraîcheur. Sans // battement, « installation stable » et « moteur arrêté » sont le même silence. notificationSpy.clear(); arb->publishTelemetryHeartbeat(); QCoreApplication::processEvents(); notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged"); QVERIFY2(!notifs.isEmpty(), "aucun battement de cœur"); const QVariantMap battement = notifs.last().toMap().value("params").toMap(); QCOMPARE(battement.value("timestamp").toString(), notif.value("timestamp").toString()); // ---- Le SEUIL : une oscillation de quelques watts ne réveille personne ------------------ // Le routeur est déjà à son plafond ; un surplus voisin ne change ni allocation ni code de // motif. Sans seuil, chaque cycle notifierait pour rien sur une cible RPi Zero 2W. notificationSpy.clear(); meter->setStateValue("currentPower", -6010); // +10 W, sous le seuil arb->simulationCallUpdate(t1.addSecs(60)); QCoreApplication::processEvents(); QVERIFY2(checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged").isEmpty(), "une variation de 10 W a notifié — le seuil ne mord pas"); // …mais le Get, lui, a bien avancé : l'état INTERNE est à jour même sans notification. resp = injectAndWait("NymeaEnergy.GetLoadTelemetry"); const QVariantMap c2 = resp.toMap().value("params").toMap(); QCOMPARE(c2.value("timestamp").toString(), t1.addSecs(60).toUTC().toString(Qt::ISODate)); // Deuxième cycle : les charges consomment, donc le RECRÉDIT n'est plus nul — et c'est // exactement le cas où l'identité serait fausse sans lui. L'allocation TOTALE, elle, n'a // pas bougé : c'est pour cela que le seuil ne mord pas. const QVariantMap b2 = c2.value("budget").toMap(); QVERIFY2(qRound(b2.value("recreditedW").toDouble()) > 0, "recrédit nul alors que les charges consomment"); QCOMPARE(qRound(b2.value("allocatedW").toDouble()), 5600); QCOMPARE(qRound(b2.value("surplusW").toDouble() + b2.value("recreditedW").toDouble() - b2.value("allocatedW").toDouble()), qRound(b2.value("remainingW").toDouble())); // ---- 3. Reprise à froid du mode dégradé ------------------------------------------------ // Un client qui se connecte APRÈS la bascule doit la lire, par GetLoadTelemetry ET par // GetChargingSchedules. C'est la famille ECS-411 : une notification seule laisse le // nouveau venu dans un silence ambigu. notificationSpy.clear(); arb->recordMeterUpdate(t1.addSecs(60)); arb->evaluateMeterFreshness(t1.addSecs(60 + 91)); QCoreApplication::processEvents(); QVERIFY(arb->degradedMode()); notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged"); QVERIFY2(!notifs.isEmpty(), "bascule en mode dégradé sans notification de télémétrie"); const QVariantMap dgr = notifs.last().toMap().value("params").toMap(); QCOMPARE(dgr.value("degradedMode").toBool(), true); // budget ABSENT : la planification est suspendue, il n'y a pas de budget pour ce cycle. // Publier des zéros se lirait « arbitrage exécuté, rien à allouer » — l'inverse du vrai. QVERIFY2(!dgr.contains("budget"), "budget publié en mode dégradé (planification suspendue)"); QVERIFY(!dgr.value("loads").toList().isEmpty()); QVariantMap dgrParId; for (const QVariant &v : dgr.value("loads").toList()) { QCOMPARE(v.toMap().value("decision").toMap().value("code").toString(), QString(DecisionCode::DegradedL2)); QCOMPARE(qRound(v.toMap().value("allocatedW").toDouble()), 0); dgrParId.insert(v.toMap().value("loadId").toString(), v.toMap()); } // TROU DE SÉCURITÉ REFERMÉ (lot B-bis). Le repli L2 envoyait un Setpoint(0) à toute la // table des charges pilotées et un State(2) à une table SÉPARÉE, réservée à la PAC codée en // dur. Une PAC de CONFIGURATION vit dans la première : elle recevait donc un Setpoint, que // SgReadyAdapter rejette d'entrée — et rien d'autre. Sous compteur muet elle restait en // état 4 (forcé) indéfiniment, alors que SAFETY.md §L2 exige l'état 2. // // Le défaut était INVISIBLE dans la charge utile : le motif DEGRADED_L2 s'y affichait // correctement, seule l'ÉCRITURE manquait. C'est pourquoi on vérifie ici les CONTACTS et // le mécanisme publié, pas le motif. QCOMPARE(dgrParId.value("pac-telemetrie").toMap() .value("mechanism").toMap().value("state").toInt(), 2); QCOMPARE(tm->findConfiguredThing(k1)->stateValue("power").toBool(), false); QCOMPARE(tm->findConfiguredThing(k2)->stateValue("power").toBool(), false); // Et la charge en watts est bien coupée elle aussi (le repli ne régresse pas de l'autre côté). QCOMPARE(qRound(dgrParId.value("ecs-relais-telemetrie").toMap() .value("mechanism").toMap().value("stageW").toDouble()), 0); QCOMPARE(relayA->stateValue("power").toBool(), false); verifierCodes(dgr, QStringLiteral("telemetry-degrade")); // Le nouveau venu lit l'état par les DEUX portes. resp = injectAndWait("NymeaEnergy.GetLoadTelemetry"); QCOMPARE(resp.toMap().value("params").toMap().value("degradedMode").toBool(), true); resp = injectAndWait("NymeaEnergy.GetChargingSchedules"); QVERIFY2(resp.toMap().value("params").toMap().contains("degradedMode"), "GetChargingSchedules ne rend toujours pas degradedMode (poussé en notification " "seule : le défaut que ce lot corrige)"); QCOMPARE(resp.toMap().value("params").toMap().value("degradedMode").toBool(), true); // Sortie du mode dégradé : symétrique, et publiée TOUT DE SUITE. La planification ne // reprend qu'au cycle suivant ; sans cette republication, la télémétrie annoncerait encore // degradedMode=true pendant une minute après la levée du repli. notificationSpy.clear(); arb->recordMeterUpdate(t1.addSecs(300)); QCoreApplication::processEvents(); QVERIFY(!arb->degradedMode()); notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged"); QVERIFY2(!notifs.isEmpty(), "sortie du mode dégradé sans notification"); QCOMPARE(notifs.last().toMap().value("params").toMap().value("degradedMode").toBool(), false); resp = injectAndWait("NymeaEnergy.GetChargingSchedules"); QCOMPARE(resp.toMap().value("params").toMap().value("degradedMode").toBool(), false); qunsetenv("NYMEA_ENERGY_LOAD_CONFIG"); QFile::remove(cfgPath); #endif } void Simulation::testLoadConfigDomain() { #ifndef ETM_ARBITRATOR QSKIP("testLoadConfigDomain nécessite ETM_ARBITRATOR."); #else // [Lot B §3] o:domain — PURE MÉTADONNÉE d'intention. Trois exigences, plus une quatrième // qui n'est pas dans la liste mais qui est la raison d'être du champ : l'arbitrage ne // change pas. Un champ « sans effet » se prouve, il ne se déclare pas. const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-domain.json"; QFile::remove(cfgPath); qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(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(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({ {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(), 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(), 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>({ {1, {k1.toString()}}, {2, {}}, {3, {k2.toString()}}, {4, {k1.toString(), k2.toString()}} }), QHash({ {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>({ {3, {k2.toString()}} }), QHash({ {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 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(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> encodage({ {1, {k1.toString()}}, {2, {}}, {3, {k2.toString()}}, {4, {k1.toString(), k2.toString()}} }); const QHash 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>({ {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)