// 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/rootmeter.h" #include "../../../energyplugin/etm/types/loadconfig.h" #include "../../../energyplugin/etm/config/loadconfigstore.h" #include "../../../energyplugin/etm/ratios/energyratioscalculator.h" #include "../../../energyplugin/etm/scheduler/rulebasedscheduler.h" #include "../../../energyplugin/etm/types/decisionreason.h" #endif #include #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()); // [ECS-111, 2026-08-29] La charge reste INDISPONIBLE, et c'est la bonne réponse : ce // routeur déclare `rKo`, un relais JAMAIS configuré. ClearLoadFault lève le défaut // d'ÉCRITURE — c'est sa promesse, et `!r->faulted()` juste au-dessus la vérifie — mais il // ne fait pas exister un Thing. Annoncer `available: true` ici serait vrai pendant un // cycle et faux au suivant, la première écriture rouvrant le défaut. // // Ce test se servait d'un relais inexistant comme RACCOURCI pour forcer un échec // d'écriture. Le raccourci a désormais un sens propre — câblage incomplet — et les deux // causes se distinguent : le défaut collant se lève, le câblage manquant non. QCOMPARE(r->telemetry().available, false); QCOMPARE(r->runtimeView(t0).faultCode, QStringLiteral("THING_MISSING")); const QString issue1 = s_journal.filter(QStringLiteral("ClearLoadFault")).join(QStringLiteral(" | ")); QVERIFY2(issue1.contains(QStringLiteral("défaut LEVÉ")), qUtf8Printable(issue1)); // Second appel, charge SAINE : « aucun défaut à lever » doit se lire, et ne pas se // confondre avec le premier cas. Le RPC réussit — il n'y a pas d'erreur — mais l'issue // est différente, et c'est précisément la distinction que l'opérateur ne pouvait pas // faire avant. s_journal.clear(); s_handlerPrecedent = qInstallMessageHandler(collecterJournal); const bool leve2 = arb->clearLoadFault(QStringLiteral("ko")); qInstallMessageHandler(s_handlerPrecedent); QVERIFY2(leve2, "la charge existe : l'appel aboutit, même sans défaut à lever"); const QString issue2 = s_journal.filter(QStringLiteral("ClearLoadFault")).join(QStringLiteral(" | ")); QVERIFY2(issue2.contains(QStringLiteral("AUCUN défaut")), qUtf8Printable(issue2)); QVERIFY2(!issue2.contains(QStringLiteral("défaut LEVÉ")), qUtf8Printable(issue2)); // Troisième cas : adaptateur introuvable. Ni l'un ni l'autre des deux précédents. s_journal.clear(); s_handlerPrecedent = qInstallMessageHandler(collecterJournal); const bool leve3 = arb->clearLoadFault(QStringLiteral("charge-qui-n-existe-pas")); qInstallMessageHandler(s_handlerPrecedent); QVERIFY2(!leve3, "un identifiant inconnu ne doit pas réussir"); const QString issue3 = s_journal.filter(QStringLiteral("ClearLoadFault")).join(QStringLiteral(" | ")); QVERIFY2(issue3.contains(QStringLiteral("introuvable")), qUtf8Printable(issue3)); // Les trois issues sont distinctes : c'est ce que la règle 7-c exige. QVERIFY(issue1 != issue2 && issue2 != issue3 && issue1 != issue3); #endif } void Simulation::testEcsDisableLeavesSafeState() { #ifndef ETM_ARBITRATOR QSKIP("testEcsDisableLeavesSafeState nécessite ETM_ARBITRATOR."); #else // [ECS-413] Constat de banc du 2026-08-09 : un SetLoadConfig posant enabled: false sur une // charge au palier 3500 W a détruit l'adaptateur en laissant les TROIS relais fermés, // juste avant une intervention de câblage. cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(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, { // [ETM 3g-2] 7 A → 6 A. La borne est ARRÊTÉE à ce pas (ligne // suivante) : la valeur relue est la dernière consigne émise avant // la coupure, et elle diffère d'un ampère parce que la conversion // watts→ampères se fait désormais APRÈS l'allocation du waterfall, // au lieu d'être calculée nativement en ampères par le proxy. SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6) , SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false) } }, { 520, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6) , SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 80) } }, { 700, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6) , SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 85) } }, { 900, { // [ETM 3g-2] 88 % → 87 %, et 30 A → 6 A. Le CHEMIN diffère d'un // ampère par-ci par-là (arrondi de la conversion, cf. pas 444) et // l'écart s'accumule sur 900 pas de rétroaction. Ce qui compte est // au pas 960, et c'est inchangé : la voiture est à 100 % à // l'échéance. Les 14 autres scénarios sont identiques au bit près. SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 87) } }, { 960, { // 22:00 SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100) } } } ); if (run1PhaseSimulations) QTest::newRow("Default") /* Simulation info */ << "simulation-1-phase-16A" // simulationName << "Simulation (1 phase, charger 16A max, target 22:00 100%)" // simulationTitle << ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName << EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(0,0,0)) // simulationStart (UTC) << ChargerPlugEvents( { ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)), true) }) // pluggedInTime (UTC) << 24 // simulationHours << EnergyLogs::SampleRate1Min << 20.0 // productionScaling << 0 // detailsStepStart << 0 // detailsStepStop << DetailsStepList() // detailsStepList /* Houshold info */ << 32 // phase limit (A) << false // spotMarketEnabled << ":/resources/dataset-1.json" // spotMarketResourceData << 0.5 // acquisitionTolerance << 0.9 // batteryLevelConsideration /* Charging Info */ << 100.0 // targetPercentage << EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime << "ChargingModeEcoWithTargetTime" // chargingMode << 50 // carBatteryLevel << 0 // dailySpotMarketPercentage /* Car settings */ << 50 // carCapacity << 6 // carMinChargingCurrent << 1 // carPhaseCount /* Energy storage */ << false // energyStorageAvailable << 0 // energyStorageCapacity << 0.0 // energyStorageMaxChargingPower << 0.0 // energyStorageMaxDischargingPower << 50.0 // energyStorageInitialBatteyLevel << true // chargerConnected << false // chargerPower << "ABC" // chargerPhases << false // canSwitchPhaseCount << 6 // chargerMaxChargingCurrent << 16 //chargerMaxChargingCurrentMaxValue << SimulationIterationTest ( { { 0, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 50) } }, { 452, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 50) } }, { 467, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 9), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 51) } }, { 750, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true) } }, { 830, { SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false) } }, { 841, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true) } }, { 1000, { SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false) } }, { 1300, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true) } }, { 1440, { // 22:00 SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100) } } } ); if (run2PhaseSimulations) QTest::newRow("Kostal") /* Simulation info */ << "simulation-kostal-2-phase-16A" // simulationName << "Simulation (2 phase, charger 16A max, target 22:00 100%)" // simulationTitle << ":/databases/2022-08-12-kostal-energylogs.sqlite" // databaseName << EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(0,0,0)) // simulationStart (UTC) << ChargerPlugEvents( { ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(8,0,0)), true) }) // pluggedInTime (UTC) << 24 // simulationHours << EnergyLogs::SampleRate1Min << 1.0 // productionScaling << 0 // detailsStepStart << 0 // detailsStepStop << DetailsStepList() // detailsStepList /* Houshold info */ << 32 // phase limit (A) << false // spotMarketEnabled << ":/resources/dataset-1.json" // spotMarketResourceData << 0.5 // acquisitionTolerance << 0.9 // batteryLevelConsideration /* Charging Info */ << 100.0 // targetPercentage << EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(22,0,0)) // targetDateTime << "ChargingModeEcoWithTargetTime" // chargingMode << 40 // carBatteryLevel << 0 // dailySpotMarketPercentage /* Car settings */ << 50 // carCapacity << 6 // carMinChargingCurrent << 2 // carPhaseCount /* Energy storage */ << false // energyStorageAvailable << 0 // energyStorageCapacity << 0.0 // energyStorageMaxChargingPower << 0.0 // energyStorageMaxDischargingPower << 50.0 // energyStorageInitialBatteyLevel << true // chargerConnected << false // chargerPower << "ABC" // chargerPhases << false // canSwitchPhaseCount << 6 // chargerMaxChargingCurrent << 16 //chargerMaxChargingCurrentMaxValue << SimulationIterationTest ( { { 0, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40) } }, { 550, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true) } }, { 600, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 9), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 48) } }, { 820, { SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false) } }, { 823, { SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false) } }, { 847, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 80) } }, { 1050, { SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100) } } } ); if (run2PhaseSimulations) QTest::newRow("Kostal") /* Simulation info */ << "simulation-kostal-2-phase-16A-away-2-hours" // simulationName << "Simulation (2 phase, charger 16A max, target 22:00 100%)" // simulationTitle << ":/databases/2022-08-12-kostal-energylogs.sqlite" // databaseName << EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(0,0,0)) // simulationStart (UTC) << ChargerPlugEvents( { ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(12,0,0)), false), ChargerPlugEvent(EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(14,00,0)), true, 10) }) // pluggedInTime (UTC) << 24 // simulationHours << EnergyLogs::SampleRate1Min << 1.0 // productionScaling << 0 // detailsStepStart << 0 // detailsStepStop << DetailsStepList() // detailsStepList /* Houshold info */ << 32 // phase limit (A) << false // spotMarketEnabled << ":/resources/dataset-1.json" // spotMarketResourceData << 0.5 // acquisitionTolerance << 0.9 // batteryLevelConsideration /* Charging Info */ << 100.0 // targetPercentage << EnergyTestBase::utcDateTime(QDate(2022, 8, 14), QTime(22,0,0)) // targetDateTime << "ChargingModeEcoWithTargetTime" // chargingMode << 40 // carBatteryLevel << 0 // dailySpotMarketPercentage /* Car settings */ << 50 // carCapacity << 6 // carMinChargingCurrent << 2 // carPhaseCount /* Energy storage */ << false // energyStorageAvailable << 0 // energyStorageCapacity << 0.0 // energyStorageMaxChargingPower << 0.0 // energyStorageMaxDischargingPower << 50.0 // energyStorageInitialBatteyLevel << true // chargerConnected << false // chargerPower << "ABC" // chargerPhases << false // canSwitchPhaseCount << 6 // chargerMaxChargingCurrent << 16 //chargerMaxChargingCurrentMaxValue << SimulationIterationTest ( { { 0, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40) } }, { 550, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true) } }, { 600, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 9), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 48) } }, { 1300, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true) } }, { 1440, { // 22:00 SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100) } } } ); if (run2PhaseSimulations) QTest::newRow("Default") /* Simulation info */ << "simulation-2-phase-16A" // simulationName << "Simulation (2 phase, charger 16A max, target 22:00 100%)" // simulationTitle << ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName << EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)) // simulationStart (UTC) << ChargerPlugEvents() // pluggedInTime (UTC) << 18 // simulationHours << EnergyLogs::SampleRate1Min << 20.0 // productionScaling << 0 // detailsStepStart << 0 // detailsStepStop << DetailsStepList() // detailsStepList /* Houshold info */ << 32 // phase limit (A) << false // spotMarketEnabled << ":/resources/dataset-1.json" // spotMarketResourceData << 0.5 // acquisitionTolerance << 0.9 // batteryLevelConsideration /* Charging Info */ << 100.0 // targetPercentage << EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime << "ChargingModeEcoWithTargetTime" // chargingMode << 40 // carBatteryLevel << 0 // dailySpotMarketPercentage /* Car settings */ << 50 // carCapacity << 6 // carMinChargingCurrent << 2 // carPhaseCount /* Energy storage */ << false // energyStorageAvailable << 0 // energyStorageCapacity << 0.0 // energyStorageMaxChargingPower << 0.0 // energyStorageMaxDischargingPower << 50.0 // energyStorageInitialBatteyLevel << true // chargerConnected << false // chargerPower << "ABC" // chargerPhases << false // canSwitchPhaseCount << 6 // chargerMaxChargingCurrent << 16 //chargerMaxChargingCurrentMaxValue << SimulationIterationTest ( { { 0, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40) } }, { 850, { SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false) } }, { 900, { // [ETM 3g-2] La borne ne charge PAS encore à ce pas, et c'est le // mécanisme d'échéance qui fonctionne : à 89 % avec une heure // devant elle, la projection dit qu'il reste du temps, donc rien // n'est forcé. Le forçage intervient plus tard, et le pas 960 // vérifie qu'il a suffi — voiture à 100 % à l'échéance. // Le proxy forçait dès ce pas parce que sa trajectoire de la // journée laissait la voiture un peu moins chargée. SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 89) } }, { 960, { // 22:00 SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100) } } } ); if (run2PhaseSimulations) QTest::newRow("Default") /* Simulation info */ << "simulation-2-phase-32A" // simulationName << "Simulation (2 phase, charger 32A max, target 22:00 100%)" // simulationTitle << ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName << EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)) // simulationStart (UTC) << ChargerPlugEvents() // pluggedInTime (UTC) << 18 // simulationHours << EnergyLogs::SampleRate1Min << 20.0 // productionScaling << 0 // detailsStepStart << 0 // detailsStepStop << DetailsStepList() // detailsStepList /* Houshold info */ << 32 // phase limit (A) << false // spotMarketEnabled << ":/resources/dataset-1.json" // spotMarketResourceData << 0.5 // acquisitionTolerance << 0.9 // batteryLevelConsideration /* Charging Info */ << 100.0 // targetPercentage << EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime << "ChargingModeEcoWithTargetTime" // chargingMode << 40 // carBatteryLevel << 0 // dailySpotMarketPercentage /* Car settings */ << 50 // carCapacity << 6 // carMinChargingCurrent << 2 // carPhaseCount /* Energy storage */ << false // energyStorageAvailable << 0 // energyStorageCapacity << 0.0 // energyStorageMaxChargingPower << 0.0 // energyStorageMaxDischargingPower << 50.0 // energyStorageInitialBatteyLevel << true // chargerConnected << false // chargerPower << "ABC" // chargerPhases << false // canSwitchPhaseCount << 6 // chargerMaxChargingCurrent << 32 //chargerMaxChargingCurrentMaxValue << SimulationIterationTest ( { { 0, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40) } }, { 100, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true) } }, { 300, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 9), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 61) } }, { 470, { SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false) } }, { 586, { SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false) } }, { 800, { SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false) } }, { 950, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 30), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 99) } }, { 960, { SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100) } } } ); if (run3PhaseSimulations) QTest::newRow("Default") /* Simulation info */ << "simulation-3-phase-16A" // simulationName << "Simulation (3 phase, charger 16A max, target 22:00 100%)" // simulationTitle << ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName << EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)) // simulationStart (UTC) << ChargerPlugEvents() // pluggedInTime (UTC) << 18 // simulationHours << EnergyLogs::SampleRate1Min << 40.0 // productionScaling << 0 // detailsStepStart << 0 // detailsStepStop << DetailsStepList({200, 300, 400, 500}) // detailsStepList /* Houshold info */ << 32 // phase limit (A) << false // spotMarketEnabled << ":/resources/dataset-1.json" // spotMarketResourceData << 0.5 // acquisitionTolerance << 0.9 // batteryLevelConsideration /* Charging Info */ << 100.0 // targetPercentage << EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime << "ChargingModeEcoWithTargetTime" // chargingMode << 40 // carBatteryLevel << 0 // dailySpotMarketPercentage /* Car settings */ << 50 // carCapacity << 6 // carMinChargingCurrent << 3 // carPhaseCount /* Energy storage */ << false // energyStorageAvailable << 0 // energyStorageCapacity << 0.0 // energyStorageMaxChargingPower << 0.0 // energyStorageMaxDischargingPower << 50.0 // energyStorageInitialBatteyLevel << true // chargerConnected << false // chargerPower << "ABC" // chargerPhases << false // canSwitchPhaseCount << 6 // chargerMaxChargingCurrent << 16 //chargerMaxChargingCurrentMaxValue << SimulationIterationTest ( { { 0, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40) } }, { 200, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 11), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 57) } }, { 300, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 13), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 83) } }, { 400, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 11), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100) } }, { 700, { SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100) } } } ); if (run3PhaseSimulations) QTest::newRow("Default") /* Simulation info */ << "simulation-3-phase-32A" // simulationName << "Simulation (3 phase, charger 32A max, target 22:00 100%)" // simulationTitle << ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName << EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)) // simulationStart (UTC) << ChargerPlugEvents() // pluggedInTime (UTC) << 18 // simulationHours << EnergyLogs::SampleRate1Min << 20.0 // productionScaling << 0 // detailsStepStart << 0 // detailsStepStop << DetailsStepList({ 480, 500, 600, 950, 960 }) // detailsStepList /* Houshold info */ << 32 // phase limit (A) << false // spotMarketEnabled << ":/resources/dataset-1.json" // spotMarketResourceData << 0.5 // acquisitionTolerance << 0.9 // batteryLevelConsideration /* Charging Info */ << 100.0 // targetPercentage << EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime << "ChargingModeEcoWithTargetTime" // chargingMode << 40 // carBatteryLevel << 0 // dailySpotMarketPercentage /* Car settings */ << 50 // carCapacity << 6 // carMinChargingCurrent << 3 // carPhaseCount /* Energy storage */ << false // energyStorageAvailable << 0 // energyStorageCapacity << 0.0 // energyStorageMaxChargingPower << 0.0 // energyStorageMaxDischargingPower << 50.0 // energyStorageInitialBatteyLevel << true // chargerConnected << false // chargerPower << "ABC" // chargerPhases << false // canSwitchPhaseCount << 6 // chargerMaxChargingCurrent << 32 //chargerMaxChargingCurrentMaxValue << SimulationIterationTest ( { { 100, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40) } }, { 200, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true) } }, { 300, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true) } }, { 400, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true) } }, { 480, { SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false) } }, { 500, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 82) } }, { 600, { SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false) } }, { 950, { SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 98) // Finish 10 min early } }, { 960, { // 22:00 SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100) } } } ); if (runPhaseSwitchingSimulations) QTest::newRow("Default") /* Simulation info */ << "simulation-phase-switching-16A" // simulationName << "Simulation (phase switching, charger 16A max, surplus only)" // simulationTitle << ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName << EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(6,0,0)) // simulationStart (UTC) << ChargerPlugEvents() // pluggedInTime (UTC) << 18 // simulationHours << EnergyLogs::SampleRate1Min << 45.0 // productionScaling << 0 // detailsStepStart << 0 // detailsStepStop << DetailsStepList({80, 150, 310, 400, 470}) // detailsStepList /* Houshold info */ << 32 // phase limit (A) << false // spotMarketEnabled << ":/resources/dataset-1.json" // spotMarketResourceData << 0.5 // acquisitionTolerance << 0.9 // batteryLevelConsideration /* Charging Info */ << 100.0 // targetPercentage << EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime << "ChargingModeEco" // chargingMode << 40 // carBatteryLevel << 0 // dailySpotMarketPercentage /* Car settings */ << 50 // carCapacity << 6 // carMinChargingCurrent << 3 // carPhaseCount /* Energy storage */ << false // energyStorageAvailable << 0 // energyStorageCapacity << 0.0 // energyStorageMaxChargingPower << 0.0 // energyStorageMaxDischargingPower << 50.0 // energyStorageInitialBatteyLevel << true // chargerConnected << false // chargerPower << "ABC" // chargerPhases << true // canSwitchPhaseCount << 6 // chargerMaxChargingCurrent << 16 //chargerMaxChargingCurrentMaxValue << SimulationIterationTest ( { { 0, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40) } }, { 80, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40) } }, { 150, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 10), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 50) } }, { 310, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 91) } }, { 400, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 13), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100) } }, { 470, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 9), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100) } }, } ); if (runPhaseSwitchingSimulations) QTest::newRow("Default") /* Simulation info */ << "simulation-energy-storage-phase-switching" // simulationName << "Simulation energy storage, phase switching" // simulationTitle << ":/databases/2022-06-28-energylogs-micha.sqlite" // databaseName << EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(0,0,0)) // simulationStart (UTC) << ChargerPlugEvents() // pluggedInTime (UTC) << 36 // simulationHours << EnergyLogs::SampleRate1Min << 45.0 // productionScaling << 0 // detailsStepStart << 10 // detailsStepStop << DetailsStepList({}) // detailsStepList /* Houshold info */ << 32 // phase limit (A) << false // spotMarketEnabled << ":/resources/dataset-1.json" // spotMarketResourceData << 0.5 // acquisitionTolerance << 0.9 // batteryLevelConsideration /* Charging Info */ << 100.0 // targetPercentage << EnergyTestBase::utcDateTime(QDate(2022, 6, 27), QTime(22,0,0)) // targetDateTime << "ChargingModeEco" // chargingMode << 40 // carBatteryLevel << 0 // dailySpotMarketPercentage /* Car settings */ << 50 // carCapacity << 6 // carMinChargingCurrent << 3 // carPhaseCount /* Energy storage */ << true // energyStorageAvailable << 12 // energyStorageCapacity << 5000.0 // energyStorageMaxChargingPower << 5000.0 // energyStorageMaxDischargingPower << 10.0 // energyStorageInitialBatteyLevel << true // chargerConnected << false // chargerPower << "ABC" // chargerPhases << true // canSwitchPhaseCount << 6 // chargerMaxChargingCurrent << 16 //chargerMaxChargingCurrentMaxValue << SimulationIterationTest ( { { 0, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6), SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 40) } } } ); } void Simulation::run() { QFETCH(QString, simulationName); QFETCH(QString, simulationTitle); QFETCH(QString, databaseName); QFETCH(QDateTime, simulationStart); QFETCH(ChargerPlugEvents, plugEvents); QFETCH(int, simulationHours); QFETCH(EnergyLogs::SampleRate, sampleRate); QFETCH(double, productionScaling); QFETCH(int, detailsStepStart); QFETCH(int, detailsStepStop); QFETCH(DetailsStepList, detailsStepList); QFETCH(int, phasePowerLimit); QFETCH(bool, spotMarketEnabled); QFETCH(QString, spotMarketResourceData); QFETCH(double, acquisitionTolerance); QFETCH(double, batteryLevelConsideration); QFETCH(double, targetPercentage); QFETCH(QDateTime, targetDateTime); QFETCH(QString, chargingMode); QFETCH(int, carBatteryLevel); QFETCH(int, dailySpotMarketPercentage); QFETCH(int, carCapacity); QFETCH(int, carMinChargingCurrent); QFETCH(int, carPhaseCount); QFETCH(bool, energyStorageAvailable); QFETCH(int, energyStorageCapacity); QFETCH(double, energyStorageMaxChargingPower); QFETCH(double, energyStorageMaxDischargingPower); QFETCH(double, energyStorageInitialBatteyLevel); QFETCH(bool, chargerConnected); QFETCH(bool, chargerPower); QFETCH(QString, chargerPhases); QFETCH(bool, canSwitchPhaseCount); QFETCH(int, chargerMaxChargingCurrent); QFETCH(int, chargerMaxChargingCurrentMaxValue); QFETCH(SimulationIterationTest, iterationTest); QStringList loggingDefaultList = { "*.debug=false", "Application.debug=true", "LogEngine.info=false", "Simulation.debug=true", "Experiences.debug=false", "NymeaEnergy.debug=false", "EnergyMocks.debug=false", "DBus.warning=false", }; QString loggingRulesDefault = loggingDefaultList.join("\n"); QStringList loggingDetailsList = { "*.debug=false", "Application.debug=true", "LogEngine.info=false", "Simulation.debug=true", "Experiences.debug=false", "NymeaEnergy.debug=true", "EnergyMocks.debug=false", "DBus.warning=false", }; QString loggingRulesDetails = loggingDetailsList.join("\n"); QStringList availableChargingModes; availableChargingModes << "ChargingModeNormal"; availableChargingModes << "ChargingModeEco"; availableChargingModes << "ChargingModeEcoWithTargetTime"; QVERIFY2(availableChargingModes.contains(chargingMode), "Unknown charging mode passed to the simulation. Please compair the list with the ChargingMode enum."); if (canSwitchPhaseCount) QVERIFY2(chargerPhases == "ABC", "If the charger supports phase count switching all 3 phases must be connected."); cleanupTestCase(); m_energyLogDbFilePath = databaseName; initTestCase(loggingRulesDefault); // Print simulation init details QLoggingCategory::setFilterRules(loggingRulesDefault); QVariant response; QVariantMap params; QNetworkReply *reply = nullptr; QSignalSpy packetSpy(m_mockTcpServer, &MockTcpServer::outgoingData);; Electricity::Phases chargerPhasesConverted = Electricity::convertPhasesFromString(chargerPhases); // Set phase power limit response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}})); QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError"); // Add mock spotmarket provider SpotMarketManager *spotMarketManager = m_experiencePlugin->spotMarketManager(); SpotMarketDataProviderMock *mockProvider = new SpotMarketDataProviderMock(nullptr, this); QVERIFY(mockProvider->prepareResourceData(spotMarketResourceData, simulationStart.toUTC())); QVERIFY(spotMarketManager->registerProvider(mockProvider)); QVERIFY(spotMarketManager->changeProvider(mockProvider->providerId())); // Enabke/disable spot market response = injectAndWait("NymeaEnergy.SetSpotMarketConfiguration", QVariantMap({ {"enabled", spotMarketEnabled }, {"providerId", mockProvider->providerId()} })); QCOMPARE(response.toMap().value("params").toMap().value("energyError").toString(), "EnergyErrorNoError"); QCOMPARE(m_experiencePlugin->spotMarketManager()->enabled(), spotMarketEnabled); // Set initial acquisition tolerance params.clear(); response.clear(); params.insert("acquisitionTolerance", acquisitionTolerance); response = injectAndWait("NymeaEnergy.SetAcquisitionTolerance", params); verifyEnergyError(response); // Set battery level consideration params.clear(); response.clear(); params.insert("batteryLevelConsideration", batteryLevelConsideration); response = injectAndWait("NymeaEnergy.SetBatteryLevelConsideration", params); verifyEnergyError(response); // Add mock meter QUuid meterThingId = addMeter(); QVERIFY2(!meterThingId.isNull(), "Did not receive valid ThingId"); if (packetSpy.count() == 0) packetSpy.wait(); checkNotification(packetSpy, "Integrations.ThingAdded"); packetSpy.clear(); // Set it as root meter m_experiencePlugin->energyManager()->setRootMeter(meterThingId); // Make sure this is our root meter now response = injectAndWait("Energy.GetRootMeter"); QCOMPARE(response.toMap().value("params").toMap().value("rootMeterThingId").toUuid(), meterThingId); packetSpy.clear(); // Add the charger QUuid evChargerId; if (canSwitchPhaseCount) { evChargerId = addChargerWithPhaseCountSwitching(chargerPhases, chargerMaxChargingCurrentMaxValue); } else { evChargerId = addCharger(chargerPhases, chargerMaxChargingCurrentMaxValue); } QVERIFY2(!evChargerId.isNull(), "Did not receive valid ThingId"); if (packetSpy.count() == 0) packetSpy.wait(); checkNotification(packetSpy, "Integrations.ThingAdded"); // Add the car QUuid carThingId = addCar(); QVERIFY2(!carThingId.isNull(), "Did not receive valid ThingId"); if (packetSpy.count() == 0) packetSpy.wait(); checkNotification(packetSpy, "Integrations.ThingAdded"); // Add energy storage if available QUuid energyStorageThingId; if (energyStorageAvailable) { energyStorageThingId = addEnergyStorage(energyStorageCapacity, energyStorageMaxChargingPower, energyStorageMaxDischargingPower); QVERIFY2(!energyStorageThingId.isNull(), "Did not receive valid ThingId"); if (packetSpy.count() == 0) packetSpy.wait(); checkNotification(packetSpy, "Integrations.ThingAdded"); } // ============================================================================== // Set states of car and charger Thing *carThing = NymeaCore::instance()->thingManager()->findConfiguredThing(carThingId); QVERIFY2(carThing != nullptr, "Failed to find car thing"); carThing->setSettingValue(carThing->thingClass().settingsTypes().findByName("phaseCount").id(), carPhaseCount); carThing->setSettingValue(carThing->thingClass().settingsTypes().findByName("capacity").id(), carCapacity); carThing->setSettingValue(carThing->thingClass().settingsTypes().findByName("minChargingCurrent").id(), carMinChargingCurrent); carThing->setStateValue("batteryLevel", carBatteryLevel); Thing *chargerThing = NymeaCore::instance()->thingManager()->findConfiguredThing(evChargerId); QVERIFY2(chargerThing != nullptr, "Failed to find charger thing"); chargerThing->setStateValue("connected", chargerConnected); chargerThing->setStateValue("power", chargerPower); chargerThing->setStateValue("maxChargingCurrent", chargerMaxChargingCurrent); chargerThing->setStateValue("maxChargingCurrentMaxValue", chargerMaxChargingCurrentMaxValue); chargerThing->setStateValue("pluggedIn", true); // Initially always plugged in, the rest can be handeld using the plug events // This will update all internal states not set directly updateChargerMeter(chargerThing); Thing *meterThing = NymeaCore::instance()->thingManager()->findConfiguredThing(meterThingId); QVERIFY2(meterThing != nullptr, "Failed to find meter thing"); meterThing->setStateValue("connected", true); // Energy storage states Thing *energyStorageThing = nullptr; if (energyStorageAvailable) { energyStorageThing = NymeaCore::instance()->thingManager()->findConfiguredThing(energyStorageThingId); energyStorageThing->setStateValue("currentPower", 0); energyStorageThing->setStateValue("batteryLevel", energyStorageInitialBatteyLevel); energyStorageThing->setProperty("preciseBatteryLevel", energyStorageInitialBatteyLevel * 1.0); // For precise runtime calculations printStates(energyStorageThing); } // printStates(chargerThing); // printStates(carThing); // printStates(meterThing); // Set charging info with our charger and car, this should trigger the evaluation QVariantMap chargingInfoMap; chargingInfoMap.insert("evChargerId", evChargerId); chargingInfoMap.insert("assignedCarId", carThingId); chargingInfoMap.insert("chargingMode", chargingMode); chargingInfoMap.insert("endDateTime", targetDateTime.toMSecsSinceEpoch() / 1000); chargingInfoMap.insert("targetPercentage", targetPercentage); chargingInfoMap.insert("spotMarketChargingEnabled", spotMarketEnabled); chargingInfoMap.insert("dailySpotMarketPercentage", dailySpotMarketPercentage); response = injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", chargingInfoMap}})); QCOMPARE(response.toMap().value("status").toString(), QString("success")); QCOMPARE(response.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); uint effectivePhaseCount; if (canSwitchPhaseCount) { effectivePhaseCount = chargerThing->stateValue("phaseCount").toUInt(); } else { effectivePhaseCount = qMin((uint)carPhaseCount, Electricity::getPhaseCount(chargerPhasesConverted)); } QString usedPhases; if (effectivePhaseCount >= 1) usedPhases.append("A"); if (effectivePhaseCount >= 2) usedPhases.append("B"); if (effectivePhaseCount >= 3) usedPhases.append("C"); chargerThing->setStateValue("usedPhases", usedPhases); // Note: we want to have the limit negative, so we see better where the limit is and why the chaging stopped double aquisitionToleranceLimit = -(effectivePhaseCount * carMinChargingCurrent * 230.0 * acquisitionTolerance); // Simulation output QDir simulationBaseDir = QDir(QDir::currentPath() + QDir::separator() + "simulations"); QDir workspaceDir = QDir(simulationBaseDir.absolutePath() + QDir::separator() + "workspace"); if (!workspaceDir.exists()) { //QVERIFY2(outputDir.removeRecursively(), "Failed to cleanup output dir"); QVERIFY2(workspaceDir.mkpath(workspaceDir.path()), "Failed to create results dir"); } QDir outputDir = QDir(workspaceDir.absolutePath() + QDir::separator() + simulationName); if (!outputDir.exists()) { // QVERIFY2(outputDir.removeRecursively(), "Failed to cleanup output dir"); QVERIFY2(outputDir.mkpath(outputDir.path()), "Failed to create output dir"); } QDir resultsDir = QDir(simulationBaseDir.absolutePath() + QDir::separator() + "results"); if (!resultsDir.exists()) { //QVERIFY2(outputDir.removeRecursively(), "Failed to cleanup output dir"); QVERIFY2(resultsDir.mkpath(resultsDir.path()), "Failed to create results dir"); } QFile gnuplotLogFile(outputDir.path() + QDir::separator() + "simulation.csv"); QVERIFY2(gnuplotLogFile.open(QIODevice::ReadWrite | QIODevice::Truncate), QString("Failed to open logfile for gnuplot" + gnuplotLogFile.fileName() + ": " + gnuplotLogFile.errorString()).toUtf8()); QTextStream gnuplotLogFileStream(&gnuplotLogFile); QFile gnuplotOriginalLogFile(outputDir.path() + QDir::separator() + "original.csv"); QVERIFY2(gnuplotOriginalLogFile.open(QIODevice::ReadWrite | QIODevice::Truncate), "Failed to open logfile for gnuplot"); QTextStream gnuplotOriginalLogFileStream(&gnuplotOriginalLogFile); // ============================================================================== // All set up, lets start simulating QDateTime simulationEnd = simulationStart.addSecs(simulationHours * 3600); PowerBalanceLogEntries powerBalanceLogs = m_experiencePlugin->energyManager()->logs()->powerBalanceLogs(sampleRate, simulationStart, simulationEnd); qCDebug(dcSimulation()) << "Simulation start" << simulationStart; qCDebug(dcSimulation()) << "Simulation end" << simulationEnd; qCDebug(dcSimulation()) << "Loaded" << powerBalanceLogs.count() << "log entries for that day"; // Simulation helpers int simulationProgress = 0; // Disable init details QLoggingCategory::setFilterRules(loggingRulesDefault); // Run the simulation for (int i = 0; i < powerBalanceLogs.count(); i++) { bool debugEnabled = (i >= detailsStepStart && i <= detailsStepStop) || detailsStepList.contains(i); const PowerBalanceLogEntry entry = powerBalanceLogs.at(i); QDateTime currentDateTime = entry.timestamp().toUTC(); // Calculate progress if (debugEnabled) { qCDebug(dcSimulation()) << "###############################################################################################################"; qCDebug(dcSimulation()) << "Step" << i << ":" << currentDateTime.toUTC().toString("yyyy.MM.dd hh:mm"); } effectivePhaseCount = chargerThing->stateValue("phaseCount").toUInt(); usedPhases = chargerThing->stateValue("usedPhases").toString(); // Update mocked spotmarket data provider mockProvider->setCurrentDataTime(currentDateTime.toUTC()); // Print simulation progress double simulationProgressPrecise = i * 100 / powerBalanceLogs.count(); if (simulationProgress != qRound(simulationProgressPrecise)) { simulationProgress = qRound(simulationProgressPrecise); if (simulationProgress % 10 == 0) { qCDebug(dcSimulation()) << simulationName << currentDateTime.toUTC().toString("hh:mm") << simulationProgress << "% (" << i << ")"; } } // Enable logs in the interesting simulation steps if (debugEnabled) { QLoggingCategory::setFilterRules(loggingRulesDetails); } else { QLoggingCategory::setFilterRules(loggingRulesDefault); } // Get the current situation befor running the simulation step double totalProduction = entry.production() * productionScaling; double totalProductionDifference = totalProduction - entry.production(); double scaledCurrentPower = entry.acquisition() + totalProductionDifference; //qCDebug(dcSimulation()) << "Scale production using" << productionScaling << entry.production() << "-->" << totalProduction << totalProductionDifference; //qCDebug(dcSimulation()) << "Scale aquisition using" << entry.acquisition() << "-->" << scaledCurrentPower; // Distribute the load before battery and charger on 3 phases double phaseLoad = scaledCurrentPower / 3; // -------------------- Charger // Handle plug events foreach(const ChargerPlugEvent &plugEvent, plugEvents) { if (plugEvent.dateTime.date() == currentDateTime.toUTC().date() && plugEvent.dateTime.time().hour() == currentDateTime.toUTC().time().hour() && plugEvent.dateTime.time().minute() == currentDateTime.toUTC().time().minute()) { // Plug event chargerThing->setStateValue("pluggedIn", plugEvent.pluggedIn); if (plugEvent.percentageUsed != 0) { double batteryLevel = carThing->stateValue("batteryLevel").toDouble(); batteryLevel -= plugEvent.percentageUsed; if (batteryLevel < 0) { batteryLevel = 0; } qCDebug(dcSimulation()) << "-->" << currentDateTime.toUTC().toString("hh:mm") << "New car battery level" << batteryLevel << "(old:" << carThing->stateValue("batteryLevel").toDouble() << ")"; carThing->setStateValue("batteryLevel", batteryLevel); } qCDebug(dcSimulation()) << "-->" << currentDateTime.toUTC().toString("hh:mm") << "Car has been" << (plugEvent.pluggedIn ? "plugged in" : "unplugged"); } } // Let the charger set all power, voltage etc.... updateChargerMeter(chargerThing); double chargerCurrentPower = chargerThing->stateValue("currentPower").toDouble(); double totalConsumption = chargerCurrentPower + entry.consumption(); // Totals before battery double totalCurrentPower = totalConsumption + totalProduction; // All producers and all consumers have been summed up, charge / discharge the battery and create a final total balance // -------------------- Energy storage double energyStorageCurrentPower = 0; uint energyStorageBatteryLevel = 0; // All consumers should have what they get, put the rest into or from the storage within limits if (energyStorageAvailable) { // Calculate the new battery level depending on the previouse step. double esCurrentPower = energyStorageThing->stateValue("currentPower").toDouble(); double esBatteryLevel = energyStorageThing->property("preciseBatteryLevel").toDouble(); double esCapacity = energyStorageThing->stateValue("capacity").toDouble(); // Let's caclulate the new percentage depending on the rate of the last minute... // We charged/discharged the last minute with energyStorageCurrentPower W double energyChargedDischargedkWh = esCurrentPower * 60 / 60 / 60 / 1000; double addedPercentage = energyChargedDischargedkWh * 100.0 / esCapacity; double newBatteryLevel = esBatteryLevel += addedPercentage; if (totalCurrentPower < 0 && newBatteryLevel < 100) { energyStorageCurrentPower = qMin(energyStorageMaxChargingPower, -totalCurrentPower); energyStorageBatteryLevel = newBatteryLevel; energyStorageThing->setProperty("preciseBatteryLevel", newBatteryLevel); setEnergyStorageStates(energyStorageBatteryLevel, energyStorageCurrentPower); } else if (totalCurrentPower > 0 && newBatteryLevel > 0) { energyStorageCurrentPower = - qMin(energyStorageMaxDischargingPower, totalCurrentPower); energyStorageBatteryLevel = newBatteryLevel; energyStorageThing->setProperty("preciseBatteryLevel", newBatteryLevel); setEnergyStorageStates(energyStorageBatteryLevel, energyStorageCurrentPower); } else { energyStorageBatteryLevel = newBatteryLevel; energyStorageThing->setProperty("preciseBatteryLevel", newBatteryLevel); setEnergyStorageStates(energyStorageBatteryLevel, energyStorageCurrentPower); } //qCDebug(dcSimulation()) << "Energy storage charged with" << energyStorageCurrentPower << "W" << addedPercentage << "% added to total" << energyStorageBatteryLevel << "%"; } // -------------------- Meter totalCurrentPower += energyStorageCurrentPower; phaseLoad += energyStorageCurrentPower / 3; // Add the charger power in the aproperiate phase QVariantMap phases = QVariantMap({ {"A", phaseLoad + chargerThing->stateValue("currentPowerPhaseA").toDouble()}, {"B", phaseLoad + chargerThing->stateValue("currentPowerPhaseB").toDouble()}, {"C", phaseLoad + chargerThing->stateValue("currentPowerPhaseC").toDouble()} }); reply = setMeterStates(phases, true); QSignalSpy setMeterStatesReplySpy(reply, &QNetworkReply::finished); if (setMeterStatesReplySpy.count() == 0) setMeterStatesReplySpy.wait(); QCOMPARE(reply->error(), QNetworkReply::NoError); // -------------------- Run charging manager update // Set charger information and pass them to the logic ThingPowerLogEntry chargerPowerEntry(currentDateTime.toUTC(), chargerThing->id(), chargerThing->stateValue("currentPower").toDouble(), 0, 0); m_experiencePlugin->smartChargingManager()->simulationCallUpdateManualSoCsWithMeter(sampleRate, chargerPowerEntry); // Update smart charging manager with the current root meter and charger situation m_experiencePlugin->smartChargingManager()->simulationCallUpdate(currentDateTime.toUTC()); // Fetch information after simulation iteration double carBatteryPercentage = carThing->stateValue("batteryLevel").toDouble(); int maxChargingCurrent = chargerThing->stateValue("maxChargingCurrent").toInt(); chargerPower = chargerThing->stateValue("power").toBool(); chargerCurrentPower = chargerThing->stateValue("currentPower").toDouble(); if (debugEnabled | iterationTest.contains(i)) { qCDebug(dcSimulation()) << "Step" << i; qCDebug(dcSimulation()) << "- Total power:" << totalCurrentPower << "Production:" << totalProduction << "Consumption:" << totalConsumption; if (energyStorageAvailable) { qCDebug(dcSimulation()) << "- Energy storage:" << energyStorageCurrentPower << energyStorageThing->property("preciseBatteryLevel").toDouble() << "%"; } qCDebug(dcSimulation()) << "- Meter phases: A:" << meterThing->stateValue("currentPowerPhaseA").toDouble() << "W | B:" << meterThing->stateValue("currentPowerPhaseB").toDouble() << "W | C:" << meterThing->stateValue("currentPowerPhaseC").toDouble() << "W"; qCDebug(dcSimulation()) << "- Charger:" << chargerCurrentPower << "[W] (" << maxChargingCurrent << "[A]" << (chargerCurrentPower ? "On)" : "Off )") << effectivePhaseCount << usedPhases; qCDebug(dcSimulation()) << "- Charger phases: A:" << chargerThing->stateValue("currentPowerPhaseA").toDouble() << "W | B:" << chargerThing->stateValue("currentPowerPhaseB").toDouble() << "W | C:" << chargerThing->stateValue("currentPowerPhaseC").toDouble() << "W"; qCDebug(dcSimulation()) << "- Car battery:" << carBatteryPercentage; qCDebug(dcSimulation()) << "--------------------------------"; // printStates(meterThing); // printStates(chargerThing); // printStates(carThing); } // Verify test points foreach(const SimulationTestPoint &testPoint, iterationTest.value(i)) { switch(testPoint.testType()) { case SimulationTestPoint::TestTypeCharging: QVERIFY2(chargerPower == testPoint.expectedValue().toBool(), qPrintable(QString("Simulation: %1 - %2 Step: %3 expected \"%4\" from the testpoint but is actually \"%5\"") .arg(simulationName) .arg(simulationTitle) .arg(i) .arg(testPoint.expectedValue().toBool() ? "true" : "false") .arg(chargerPower ? "true" : "false"))); break; case SimulationTestPoint::TestTypeMaxChargingCurrent: QVERIFY2(maxChargingCurrent == testPoint.expectedValue().toInt(), qPrintable(QString("Simulation: %1 - %2 Step: %3 expected \"%4\" from the testpoint but is actually \"%5\"") .arg(simulationName) .arg(simulationTitle) .arg(i) .arg(testPoint.expectedValue().toInt()) .arg(maxChargingCurrent))); break; case SimulationTestPoint::TestTypeStateOfCharge: QVERIFY2(qFuzzyCompare(carBatteryPercentage, testPoint.expectedValue().toDouble()), qPrintable(QString("Simulation: %1 - %2 Step: %3 expected \"%4\" from the testpoint but is actually \"%5\"") .arg(simulationName) .arg(simulationTitle) .arg(i) .arg(testPoint.expectedValue().toDouble()) .arg(carBatteryPercentage))); break; } } // -------------------- Data logging // Log the simulation data gnuplotLogFileStream << currentDateTime.toMSecsSinceEpoch() / 1000 << ", " << // 1 totalCurrentPower << ", " << // 2 totalProduction << ", " << // 3 totalConsumption << ", " << // 4 chargerCurrentPower << ", " << // 5 maxChargingCurrent << ", " << // 6 (chargerPower ? "1" : "0") << ", " << // 7 chargerThing->state("maxChargingCurrent").minValue().toDouble() * 230 * effectivePhaseCount << ", " << // 8 chargerThing->state("maxChargingCurrent").maxValue().toDouble() * 230 * effectivePhaseCount << ", " << // 9 phasePowerLimit * 230 * effectivePhaseCount << ", " << // 10 carBatteryPercentage << ", " << // 11 i << ", " << // 12 aquisitionToleranceLimit << ", " << // 13 (chargerThing->stateValue("pluggedIn").toBool() ? 10 : 0 ) << ", "; // 14 if (spotMarketEnabled) { const ScoreEntries weightedEntries = spotMarketManager->weightedScoreEntries(currentDateTime.date()); const ScoreEntry currentScore = weightedEntries.getScoreEntry(currentDateTime.toUTC()); QVERIFY(!currentScore.isNull()); gnuplotLogFileStream << currentScore.weighting() * 100 << ", "; // 15 gnuplotLogFileStream << currentScore.value() / 10.0 << ", "; // 16 Price } else { gnuplotLogFileStream << 0 << ", "; // 15 gnuplotLogFileStream << 0 << ", "; // 16 } if (energyStorageAvailable) { gnuplotLogFileStream << energyStorageCurrentPower << ", "; // 17 gnuplotLogFileStream << energyStorageBatteryLevel << ", "; // 18 } else { gnuplotLogFileStream << 0 << ", "; // 17 gnuplotLogFileStream << 0 << ", "; // 18 } gnuplotLogFileStream << "\n"; // Log Unchanged for raw data analysis gnuplotOriginalLogFileStream << currentDateTime.toMSecsSinceEpoch() / 1000 << ", " << // 1 scaledCurrentPower << ", " << // 2 totalProduction << ", " << // 3 entry.consumption() << ", " << // 4 phasePowerLimit * 230 * effectivePhaseCount << ", " << // 5 i << ", " << // 6 "\n"; } gnuplotLogFile.close(); gnuplotOriginalLogFile.close(); // Draw original data QStringList scriptLines; QStringList plotLines; // Plot with: 1h = 200 px int height = 800; int width = simulationHours * 200; QString originalImageName = simulationName + "-00-original.png"; QString simulationImageName = simulationName + "-01.png"; scriptLines.append("set term png size " + QString::number(width) + "," + QString::number(height)); scriptLines.append("set output '" + originalImageName + "'"); scriptLines.append("set datafile separator ','"); scriptLines.append(plotOriginalData(powerBalanceLogs.count())); if (spotMarketEnabled) { scriptLines.append("set term png size " + QString::number(width) + "," + QString::number(height * 2)); scriptLines.append("set output '" + simulationImageName + "'"); scriptLines.append("set datafile separator ','"); scriptLines.append("set multiplot layout 2,1"); scriptLines.append("set size 1,0.8"); scriptLines.append("set origin 0,0.2"); scriptLines.append(plotSimulation(simulationTitle, powerBalanceLogs.count())); scriptLines.append("set size 1,0.2"); scriptLines.append("set origin 0,0"); scriptLines.append(plotSpotMarketData(powerBalanceLogs.count())); scriptLines.append("unset multiplot"); } else { scriptLines.append("set term png size " + QString::number(width) + "," + QString::number(height)); scriptLines.append("set output '" + simulationImageName + "'"); scriptLines.append("set datafile separator ','"); scriptLines.append(plotSimulation(simulationTitle, powerBalanceLogs.count())); } // Write the gnuplot script QFile gnuplotScript(outputDir.path() + QDir::separator() + "script.gnuplot"); QVERIFY2(gnuplotScript.open(QIODevice::ReadWrite | QIODevice::Truncate), QString("Failed to open script file for gnuplot" + gnuplotScript.fileName() + ": " + gnuplotScript.errorString()).toUtf8()); QTextStream scriptStream(&gnuplotScript); foreach (const QString &line, scriptLines) scriptStream << line << "\n"; gnuplotScript.close(); // Write the executable gnuplot script scriptLines.clear(); scriptLines.append("set terminal wxt 1 persist"); scriptLines.append("set datafile separator ','"); if (spotMarketEnabled) { scriptLines.append("set multiplot layout 2,1"); scriptLines.append("set size 1,0.8"); scriptLines.append("set origin 0,0.2"); scriptLines.append(plotSimulation(simulationTitle, powerBalanceLogs.count())); scriptLines.append("set size 1,0.2"); scriptLines.append("set origin 0,0"); scriptLines.append(plotSpotMarketData(powerBalanceLogs.count())); scriptLines.append("unset multiplot"); } else { scriptLines.append(plotSimulation(simulationTitle, powerBalanceLogs.count())); } QString executableScriptName = simulationName + ".gnuplot"; QFile executableGnuplotScript(outputDir.path() + QDir::separator() + executableScriptName); QVERIFY2(executableGnuplotScript.open(QIODevice::ReadWrite | QIODevice::Truncate), QString("Failed to open logfile for gnuplot" + executableGnuplotScript.fileName() + ": " + executableGnuplotScript.errorString()).toUtf8()); QTextStream executableScriptStream(&executableGnuplotScript); foreach (const QString &line, scriptLines) executableScriptStream << line << "\n"; executableGnuplotScript.close(); QProcess gnuplotProcess; //gnuplotProcess.setEnvironment(QProcessEnvironment::systemEnvironment().toStringList()); gnuplotProcess.setProcessChannelMode(QProcess::MergedChannels); gnuplotProcess.setWorkingDirectory(outputDir.path()); gnuplotProcess.start("gnuplot", { "-c", "script.gnuplot"}); gnuplotProcess.waitForFinished(); qCDebug(dcSimulation()) << "gnuplot finished" << gnuplotProcess.arguments() << gnuplotProcess.workingDirectory() << gnuplotProcess.exitCode() << gnuplotProcess.exitStatus(); if (gnuplotProcess.exitCode() != 0) { qCDebug(dcSimulation()) << "error plotting data:\n" << qUtf8Printable(gnuplotProcess.readAll()); QVERIFY2(false, "plot process finished with error"); } // Copy resulting images to the simulations QFile::copy(outputDir.path() + QDir::separator() + originalImageName, resultsDir.path() + QDir::separator() + originalImageName); QFile::copy(outputDir.path() + QDir::separator() + simulationImageName, resultsDir.path() + QDir::separator() + simulationImageName); } void Simulation::testDecisionReasonRendering() { #ifndef ETM_ARBITRATOR QSKIP("testDecisionReasonRendering nécessite ETM_ARBITRATOR."); #else // [Lot B §1 / critère 6] « Journal INCHANGÉ ». // // Depuis la refonte, LoadAction::reason porte un CODE et des PARAMÈTRES ; la ligne // française est RENDUE par renderFr(). Le risque de la manœuvre est là et nulle part // ailleurs : une phrase reformulée « au passage ». Les motifs d'ECS-309 et ECS-309-b sont // le fruit de corrections coûteuses au banc — les réécrire enverrait à nouveau // diagnostiquer le mauvais problème, avec l'autorité d'une explication. // // Ce tableau est donc la copie MOT POUR MOT des QStringLiteral d'avant la refonte. Il se // relit contre `git show HEAD:energyplugin/etm/scheduler/rulebasedscheduler.cpp`. Un test // « motif non vide » n'aurait rien vu ici — c'est précisément la leçon d'ECS-309. struct Cas { DecisionReason reason; QString label; QString attendu; }; const QList table = { // --- EV (proxy amont) : aucune charge nommée, donc aucun libellé --- { { DecisionCode::EvSurplus }, QString(), QStringLiteral("Surplus PV disponible — recharge solaire") }, { { DecisionCode::EvSpotMarket }, QString(), QStringLiteral("Tarif aWATTar favorable — recharge heure creuse") }, { { DecisionCode::EvDeadline }, QString(), QStringLiteral("Deadline VE approchante — recharge prioritaire") }, { { DecisionCode::EvEcoMin }, QString(), QStringLiteral("Aucun surplus — courant minimum maintenu (mode EcoMin)") }, { { DecisionCode::EvIdle }, QString(), QStringLiteral("Aucun surplus disponible — recharge suspendue") }, // --- Charges pilotées en watts --- { { DecisionCode::LoadUnavailable, {{"frozenW", 1500}} }, QStringLiteral("Chauffe-eau"), QStringLiteral("Chauffe-eau INDISPONIBLE — aucune commande émise ; 1500 W toujours " "comptés au budget (levée par ClearLoadFault)") }, { { DecisionCode::LockMinOn, {{"appliedW", 2000}, {"budgetW", 600}} }, QStringLiteral("ECS prioritaire"), QStringLiteral("Verrou minOn — ECS prioritaire maintenue à 2000 W (puissance engagée, " "budget 600 W) ; le résidu en tient compte") }, { { DecisionCode::LockMinOff, {{"budgetW", 7706}, {"requestedW", 1000}} }, QStringLiteral("Charge à froid"), QStringLiteral("Verrou minOff — Charge à froid ne peut pas s'enclencher avant la fin " "de la fenêtre (budget 7706 W disponible, palier 1000 W refusé)") }, { { DecisionCode::LockMinOffCapped, {{"appliedW", 1000}, {"budgetW", 4000}, {"requestedW", 2500}} }, QStringLiteral("ECS relais"), QStringLiteral("Verrou minOff — ECS relais plafonnée à 1000 W avant la fin de la " "fenêtre (budget 4000 W disponible, palier 2500 W refusé)") }, { { DecisionCode::SurplusSetpoint, {{"budgetW", 3000}, {"setpointW", 2400}, {"stepped", true}} }, QStringLiteral("Chauffe-eau"), QStringLiteral("Surplus PV 3000 W — Chauffe-eau consigne 2400 W (palier)") }, { { DecisionCode::SurplusSetpoint, {{"budgetW", 3000}, {"setpointW", 3000}, {"stepped", false}} }, QStringLiteral("Routeur PV"), QStringLiteral("Surplus PV 3000 W — Routeur PV consigne 3000 W") }, { { DecisionCode::SurplusInsufficient, {{"budgetW", -389}} }, QStringLiteral("Charge à froid 3"), QStringLiteral("Surplus insuffisant (-389 W) — Charge à froid 3 à 0 W") }, // --- SG-Ready --- { { DecisionCode::LockMinStateHold, {{"state", 3}} }, QStringLiteral("PAC test"), QStringLiteral("Verrou minStateHold — PAC test maintenue état 3 (court-cycling PAC)") }, { { DecisionCode::SgForced, {{"budgetW", 4000}, {"estimatedW", 3000}} }, QStringLiteral("PAC test"), QStringLiteral("Surplus abondant 4000 W — PAC test forcée (état 4, ~3000 W)") }, { { DecisionCode::SgRecommended, {{"budgetW", 2000}, {"estimatedW", 1500}} }, QStringLiteral("PAC test"), QStringLiteral("Surplus PV 2000 W — PAC test recommandée (état 3, ~1500 W)") }, // §12 / f134048 — SG_NORMAL ne sort plus QUE pour un budget nul ou négatif : le cas // « budget présent mais sous l'état 3 » est passé à BELOW_MIN_POWER. La phrase dit // donc « aucun surplus », là où « insuffisant » couvrait aussi, et à tort, un budget // de 933 W devant un état à 1 500 W. { { DecisionCode::SgNormal, {{"budgetW", 1000}} }, QStringLiteral("PAC test"), QStringLiteral("Aucun surplus à allouer (1000 W) — PAC test en normal (état 2, non pilotée)") }, // 3g-1 — les deux motifs sur lesquels le banc s'appuie pour distinguer « il n'y a // rien » de « il y en a, mais pas au bon format » ou « une règle le retient ». Leur // TEXTE n'était pas éprouvé : ils ont rejoint le catalogue sans ligne ici. { { DecisionCode::BatteryReserve, {{"withheldW", 3200}, {"socPercent", 35}, {"reservePercent", 80}} }, QStringLiteral("Chauffe-eau"), QStringLiteral("3200 W de surplus retenus — batterie à 35 %, sous la réserve de 80 % ; " "aucune charge n'est servie") }, { { DecisionCode::BelowMinPower, {{"budgetW", 1096}, {"minPowerW", 2070}} }, QStringLiteral("Borne du garage"), QStringLiteral("Budget 1096 W sous le premier palier de Borne du garage (2070 W) — 0 W, " "le budget passe à la charge suivante") }, // La variante à ÉTATS nomme l'état hors d'atteinte : sans lui, une phrase vague en // remplacerait une autre (note de DecisionCode::BelowMinPower). { { DecisionCode::BelowMinPower, {{"budgetW", 933}, {"minPowerW", 1500}, {"state", 3}} }, QStringLiteral("PAC test"), QStringLiteral("Budget 933 W sous l'état 3 de PAC test (1500 W estimés) — état 2, " "le budget passe à la charge suivante") }, // 3g-2 — le démarrage sous tolérance est une décision d'ACHETER : la phrase doit // chiffrer ce qui est soutiré, sans quoi elle se lit comme une erreur d'allocation. { { DecisionCode::EvGridStart, {{"budgetW", 2200}, {"floorW", 4140}, {"gridW", 1940}} }, QStringLiteral("Borne du garage"), QStringLiteral("Démarrage de Borne du garage : plancher 4140 W, dont 1940 W SOUTIRÉS au " "réseau (2200 W de surplus disponibles, tolérance d'acquisition)") }, // 3g-2 — et quand c'est la limite de phase qui interdit, elle est NOMMÉE : le geste // attendu n'est pas d'attendre le soleil, c'est de regarder l'abonnement. { { DecisionCode::PhaseLimit, {{"limitW", 1200}, {"requiredW", 4140}} }, QStringLiteral("Borne du garage"), QStringLiteral("Limite de phase : 1200 W disponibles pour Borne du garage, sous les " "4140 W qu'elle exige — aucun démarrage possible ce cycle") }, // --- Sécurité et cycle de vie --- { { DecisionCode::DegradedL2 }, QString(), QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)") }, { { DecisionCode::SafeStateRelay }, QString(), QStringLiteral("Charge désactivée — mise en état sûr avant retrait (ECS-413)") }, { { DecisionCode::SafeStateSetpoint }, QString(), QStringLiteral("Charge désactivée — mise en état sûr (0 W) avant retrait (ECS-413)") }, { { DecisionCode::SafeStateSgReady }, QString(), QStringLiteral("Charge désactivée — mise en état sûr (état 2) avant retrait (ECS-413)") }, }; QSet codesCouverts; for (const Cas &c : table) { const QString rendu = renderFr(c.reason, c.label); QVERIFY2(rendu == c.attendu, qUtf8Printable(QStringLiteral("code %1\n rendu : %2\n attendu : %3") .arg(c.reason.code, rendu, c.attendu))); codesCouverts.insert(c.reason.code); } // Le catalogue est FERMÉ : tout code ajouté sans ligne de tableau doit faire échouer ce // test, sinon la garantie « le journal est rendu depuis le code » se perd au premier oubli. const QStringList catalogue = { DecisionCode::EvSurplus, DecisionCode::EvSpotMarket, DecisionCode::EvDeadline, DecisionCode::EvEcoMin, DecisionCode::EvIdle, DecisionCode::LoadUnavailable, DecisionCode::LockMinOn, DecisionCode::LockMinOff, DecisionCode::LockMinOffCapped, DecisionCode::SurplusSetpoint, DecisionCode::SurplusInsufficient, DecisionCode::LockMinStateHold, DecisionCode::SgForced, DecisionCode::SgRecommended, DecisionCode::SgNormal, DecisionCode::DegradedL2, DecisionCode::SafeStateRelay, DecisionCode::SafeStateSetpoint, DecisionCode::SafeStateSgReady, // 3g-1 : ajoutés au catalogue sans être ajoutés ICI — la clôture avait dérivé, donc // la garantie « tout code a une ligne de rendu éprouvée » ne tenait plus. DecisionCode::BatteryReserve, DecisionCode::BelowMinPower, // 3g-2 DecisionCode::EvGridStart, DecisionCode::PhaseLimit }; for (const QString &code : catalogue) QVERIFY2(codesCouverts.contains(code), qUtf8Printable(QStringLiteral("code %1 sans ligne de rendu attendue").arg(code))); // Repli d'un code HORS catalogue : jamais une chaîne vide. Un rendu vide ferait passer un // motif présent-mais-inconnu pour un motif absent — le silence ambigu de la règle 7-c. const QString inconnu = renderFr(DecisionReason("CODE_QUI_NEXISTE_PAS", {{"x", 1}}), QStringLiteral("Charge")); QVERIFY(!inconnu.isEmpty()); QVERIFY2(inconnu.contains("CODE_QUI_NEXISTE_PAS"), qUtf8Printable(inconnu)); QVERIFY(!renderFr(DecisionReason()).isEmpty()); // motif absent : dit, pas tu #endif } void Simulation::testLoadTelemetryRpc() { #ifndef ETM_ARBITRATOR QSKIP("testLoadTelemetryRpc nécessite ETM_ARBITRATOR."); #else // [Lot B §2] La frontière de télémétrie, de bout en bout : c'est la couche que l'app // consomme. Le constat qui a motivé ce lot était que l'état runtime de l'arbitre ne // franchissait AUCUNE frontière RPC — 90 s d'écoute, arbitre actif, zéro notification. // // Couvre les critères d'acceptation 1 à 5 : notification non nulle et cohérente, // snapshot ≡ notification, reprise à froid du mode dégradé, arbitre figé détectable, // zéro langue naturelle dans la charge utile. const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-telemetry.json"; QFile::remove(cfgPath); qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); ThingManager *tm = NymeaCore::instance()->thingManager(); // Le harnais de simulation n'abonne PAS le client aux notifications (il charge son // experience mock APRÈS initTestCase, donc EnergyTestBase saute l'abonnement). Or ce test // porte précisément sur les notifications : sans cet appel, il « passerait » en observant // le silence qu'il est censé faire échouer. enableNotifications({"NymeaEnergy"}); QUuid meterId = addMeter(); m_experiencePlugin->energyManager()->setRootMeter(meterId); Thing *meter = tm->findConfiguredThing(meterId); QVERIFY(meter); meter->setStateValue("connected", true); // ---- 0. AVANT tout cycle : « pas encore d'arbitrage » se DIT, il ne se devine pas ------ // timestamp ABSENT (omis, jamais nul) : forger un instant se lirait « cycle exécuté, rien à // allouer », soit exactement le mensonge que ce champ existe pour empêcher. QVariant resp = injectAndWait("NymeaEnergy.GetLoadTelemetry"); QVariantMap vide = resp.toMap().value("params").toMap(); QVERIFY2(!vide.contains("timestamp"), "timestamp forgé avant le premier cycle"); QVERIFY(vide.contains("degradedMode")); QCOMPARE(vide.value("degradedMode").toBool(), false); QVERIFY(vide.value("loads").toList().isEmpty()); // ---- Trois mécanismes, un seul budget : l'union discriminée doit sortir ENTIÈRE -------- QUuid rA = addPowerSwitch(1000, 26661); // relay-router (1 relais, minOn 300 s) QUuid k1 = addPowerSwitch(0, 26662); // sg-ready K1 QUuid k2 = addPowerSwitch(0, 26663); // sg-ready K2 QUuid varId = addEtmVariableLoad(27040); // etmvariableload dynamic Thing *relayA = tm->findConfiguredThing(rA); Thing *varThing = tm->findConfiguredThing(varId); QVERIFY(relayA && varThing); QVERIFY(tm->findConfiguredThing(k1) && tm->findConfiguredThing(k2)); varThing->setStateValue("currentPowerW", 0); LoadConfigStore *store = new LoadConfigStore(arb); arb->setLoadConfigStore(store); LoadConfigs cfgs; cfgs.append(LoadConfig::fromMap(QVariantMap{ {"id", varId.toString()}, {"label", "Routeur PV"}, {"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 1}, {"enabled", true}, {"domain", "ecs"}})); cfgs.append(LoadConfig::fromMap(QVariantMap{ {"id", "pac-telemetrie"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"}, {"priority", 2}, {"enabled", true}, {"domain", "hvac"}, {"sgReady", QVariantMap{ {"minStateHoldS", 300}, {"states", QVariantList() << QVariantMap{{"state", 1}, {"relays", QVariantList() << k1.toString()}, {"estimatedPowerW", 0.0}} << QVariantMap{{"state", 2}, {"relays", QVariantList()}, {"estimatedPowerW", 0.0}} << QVariantMap{{"state", 3}, {"relays", QVariantList() << k2.toString()}, {"estimatedPowerW", 800.0}} << QVariantMap{{"state", 4}, {"relays", QVariantList() << k1.toString() << k2.toString()}, {"estimatedPowerW", 1600.0}}}}}})); cfgs.append(LoadConfig::fromMap(QVariantMap{ {"id", "ecs-relais-telemetrie"}, {"label", "ECS relais"}, {"adapter", "relay-router"}, {"mode", "fixed"}, {"priority", 3}, {"enabled", true}, {"domain", "ecs"}, {"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, {"powerW", 1000}}}, {"minOnS", 300}, {"minOffS", 0}})); QString err; QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8()); // ---- 1. Le CONSTAT rejoué : un cycle d'arbitrage produit-il une notification ? --------- QSignalSpy notificationSpy(m_mockTcpServer, &MockTcpServer::outgoingData); const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); meter->setStateValue("currentPower", -6000); // 6000 W exportés arb->simulationCallUpdate(t0); QCoreApplication::processEvents(); QVariantList notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged"); QVERIFY2(!notifs.isEmpty(), "aucune notification LoadTelemetryChanged — c'est le constat " "d'origine, arbitre actif et frontière RPC muette"); QVariantMap premier = notifs.last().toMap().value("params").toMap(); // PREMIER cycle : la PAC est GELÉE par l'armement à froid du minStateHold (ECS-412). Ce // n'est pas un détail de test — c'est ce que l'app verra après CHAQUE redémarrage de // nymead, pendant la durée configurée. La télémétrie doit donc le dire, et le dire en // clair : un verrou nommé, des secondes restantes, et un motif qui accuse le verrou et // non le budget. { QVariantMap parIdC1; for (const QVariant &v : premier.value("loads").toList()) parIdC1.insert(v.toMap().value("loadId").toString(), v.toMap()); const QVariantMap pacC1 = parIdC1.value("pac-telemetrie").toMap(); QCOMPARE(pacC1.value("mechanism").toMap().value("state").toInt(), 2); QCOMPARE(qRound(pacC1.value("allocatedW").toDouble()), 0); QCOMPARE(pacC1.value("decision").toMap().value("code").toString(), QString(DecisionCode::LockMinStateHold)); QCOMPARE(pacC1.value("lock").toMap().value("kind").toString(), QString("minStateHold")); QCOMPARE(pacC1.value("lock").toMap().value("remainingS").toInt(), 300); // Le budget du cycle en tient compte : la PAC n'a rien reçu. QCOMPARE(qRound(premier.value("budget").toMap().value("allocatedW").toDouble()), 4000); // Le relais, lui, vient de commuter : sa fenêtre minOn est pleine. const QVariantMap relaisC1 = parIdC1.value("ecs-relais-telemetrie").toMap(); QCOMPARE(relaisC1.value("lock").toMap().value("kind").toString(), QString("minOn")); QCOMPARE(relaisC1.value("lock").toMap().value("remainingS").toInt(), 300); } // ---- Le verrou EXPIRE, et la PAC prend sa part ------------------------------------------ notificationSpy.clear(); const QDateTime t1 = t0.addSecs(301); // > minStateHoldS (300) arb->simulationCallUpdate(t1); QCoreApplication::processEvents(); notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged"); QVERIFY2(!notifs.isEmpty(), "le déblocage de la PAC change l'allocation : il doit notifier"); QVariantMap notif = notifs.last().toMap().value("params").toMap(); // Cohérence avec la cascade : Routeur PV (rang 1) prend 3000 (clamp maxPowerW), reliquat // 3000 → PAC (rang 2) forcée état 4 (~1600), reliquat 1400 → ECS relais (rang 3) 1000. QCOMPARE(notif.value("degradedMode").toBool(), false); QVERIFY(notif.contains("timestamp")); QCOMPARE(notif.value("timestamp").toString(), t1.toUTC().toString(Qt::ISODate)); const QVariantMap budget = notif.value("budget").toMap(); QCOMPARE(qRound(budget.value("surplusW").toDouble()), 6000); QCOMPARE(qRound(budget.value("evReservedW").toDouble()), 0); QCOMPARE(qRound(budget.value("allocatedW").toDouble()), 5600); QCOMPARE(qRound(budget.value("remainingW").toDouble()), 1400); // ---- L'ABSENCE de `draw` se défend, parce que rien ne casse quand on la comble ----------- // // `draw` ne porte qu'un champ : `committedW`. Les quatre autres du §14a — authorisedW, // remainingW, binding, perPhaseBound — sont ABSENTS, jamais à 0. Le contrat le dit, mais un // contrat ne fait échouer aucune compilation : le jour où quelqu'un ajoutera `authorisedW: // 0` « pour compléter l'objet », six mois plus tard, rien ne le retiendra — et l'app // affichera un plafond de soutirage qui n'existe pas, avec sa source, faute d'avoir un // moyen de distinguer « pas de plafond » de « plafond nul ». // // Ce test est ce moyen. S'il casse, ce n'est pas lui qu'il faut corriger : c'est le champ // ajouté qui doit arriver AVEC son transport §14a et une valeur vraie, pas à zéro. const QVariantMap draw = notif.value("draw").toMap(); QCOMPARE(draw.keys(), QStringList{QStringLiteral("committedW")}); // Le relais tire déjà ses 1000 W depuis le cycle précédent : ils lui sont RECRÉDITÉS avant // arrondi (anti-clignotement). Sans ce terme publié, l'identité serait fausse ici même. QCOMPARE(qRound(budget.value("recreditedW").toDouble()), 1000); // L'invariant publié : surplus + recrédit − alloué == restant. Le recrédit y figure parce // que sans lui l'identité serait FAUSSE dès qu'une charge consomme déjà — c'est-à-dire dans // le cas normal — et le client chercherait un écart inexplicable. QCOMPARE(qRound(budget.value("surplusW").toDouble() + budget.value("recreditedW").toDouble() - budget.value("allocatedW").toDouble()), qRound(budget.value("remainingW").toDouble())); const QVariantList loads = notif.value("loads").toList(); QCOMPARE(loads.size(), 3); // l'EV n'y est pas : décidé en amont QVariantMap parId; double sommeAllouee = 0; for (const QVariant &v : loads) { const QVariantMap e = v.toMap(); parId.insert(e.value("loadId").toString(), e); sommeAllouee += e.value("allocatedW").toDouble(); } // La somme des allocations RETOMBE sur budget.allocatedW : sans quoi un client qui // additionne les lignes obtiendrait un total en désaccord avec l'en-tête. QCOMPARE(qRound(sommeAllouee), qRound(budget.value("allocatedW").toDouble())); const QVariantMap eVar = parId.value(varId.toString()).toMap(); const QVariantMap ePac = parId.value("pac-telemetrie").toMap(); const QVariantMap eRelay = parId.value("ecs-relais-telemetrie").toMap(); QVERIFY(!eVar.isEmpty() && !ePac.isEmpty() && !eRelay.isEmpty()); // Union discriminée de mécanisme : chaque variante sort avec SA charge utile, et seulement // la sienne (LM-300/302 appliqués au sens sortant). QCOMPARE(eVar.value("mechanism").toMap().value("kind").toString(), QString("variable")); QCOMPARE(qRound(eVar.value("mechanism").toMap().value("setpointW").toDouble()), 3000); QCOMPARE(qRound(eVar.value("mechanism").toMap().value("percent").toDouble()), 100); QVERIFY(!eVar.value("mechanism").toMap().contains("state")); // charge utile d'un AUTRE mécanisme QVERIFY(!eVar.value("mechanism").toMap().contains("stageW")); QCOMPARE(ePac.value("mechanism").toMap().value("kind").toString(), QString("sgReady")); QCOMPARE(ePac.value("mechanism").toMap().value("state").toInt(), 4); QVERIFY(!ePac.value("mechanism").toMap().contains("stageW")); QCOMPARE(eRelay.value("mechanism").toMap().value("kind").toString(), QString("relay")); QCOMPARE(qRound(eRelay.value("mechanism").toMap().value("stageW").toDouble()), 1000); QVERIFY(!eRelay.value("mechanism").toMap().contains("percent")); // Motifs : des CODES, avec leurs paramètres numériques. Jamais une phrase. QCOMPARE(eVar.value("decision").toMap().value("code").toString(), QString(DecisionCode::SurplusSetpoint)); QCOMPARE(eVar.value("decision").toMap().value("params").toMap().value("stepped").toBool(), false); QCOMPARE(ePac.value("decision").toMap().value("code").toString(), QString(DecisionCode::SgForced)); QCOMPARE(eRelay.value("decision").toMap().value("code").toString(), QString(DecisionCode::SurplusSetpoint)); QCOMPARE(eRelay.value("decision").toMap().value("params").toMap().value("stepped").toBool(), true); // Verrous en SECONDES restantes, pas en watts. Les deux charges viennent de commuter à t0, // donc leur fenêtre est pleine. Le routeur PV n'a AUCUN verrou côté moteur : la clé est // absente, ce qui est la vérité — et non un verrou à zéro, qui se lirait « fenêtre expirée ». // La PAC vient de commuter en 4 : sa fenêtre repart pleine. QCOMPARE(ePac.value("lock").toMap().value("kind").toString(), QString("minStateHold")); QCOMPARE(ePac.value("lock").toMap().value("remainingS").toInt(), 300); // Le relais, lui, a commuté au cycle PRÉCÉDENT (t0) : 301 s plus tard son minOn de 300 s a // EXPIRÉ, et la clé disparaît. Un verrou qui ne s'efface pas serait un verrou permanent — // le défaut d'ECS-412 —, et l'app afficherait une charge éternellement bloquée. QVERIFY2(!eRelay.contains("lock"), "le verrou minOn du relais aurait dû expirer"); QVERIFY(!eVar.contains("lock")); // Disponibilité, et absence de code de défaut sur une installation saine. for (const QVariant &v : loads) { QCOMPARE(v.toMap().value("available").toBool(), true); QVERIFY(!v.toMap().contains("faultCode")); } // ---- 2. Snapshot ≡ notification -------------------------------------------------------- // Le Get ne recalcule pas : il rend l'objet publié. Un recalcul donnerait des verrous // rafraîchis sous un timestamp périmé — un état qui n'a jamais existé. resp = injectAndWait("NymeaEnergy.GetLoadTelemetry"); QCOMPARE(resp.toMap().value("params").toMap(), notif); // ---- 5. ZÉRO langue naturelle dans la charge utile ------------------------------------- // Contrôle MÉCANIQUE : toute chaîne émise est ASCII et sans espace. Une phrase française en // porterait forcément (accents et/ou espaces). Le libellé de la charge, lui, n'entre pas // dans la charge utile : il est substitué au RENDU, côté journal. std::function verifierCodes = [&](const QVariant &v, const QString &chemin) { if (v.userType() == QMetaType::QVariantMap) { const QVariantMap m = v.toMap(); for (auto it = m.constBegin(); it != m.constEnd(); ++it) verifierCodes(it.value(), chemin + '.' + it.key()); } else if (v.userType() == QMetaType::QVariantList) { const QVariantList l = v.toList(); for (int i = 0; i < l.size(); ++i) verifierCodes(l.at(i), QStringLiteral("%1[%2]").arg(chemin).arg(i)); } else if (v.userType() == QMetaType::QString) { const QString s = v.toString(); for (const QChar &c : s) QVERIFY2(c.unicode() >= 32 && c.unicode() < 127, qUtf8Printable(QStringLiteral("%1 : caractère non ASCII dans « %2 »") .arg(chemin, s))); QVERIFY2(!s.contains(' '), qUtf8Printable(QStringLiteral("%1 : espace dans « %2 » — une valeur " "émise doit être un CODE, pas une phrase") .arg(chemin, s))); } }; verifierCodes(notif, QStringLiteral("telemetry")); // ---- 4. Arbitre FIGÉ détectable -------------------------------------------------------- // Aucun cycle nouveau : le battement republie l'instantané tel quel. Le client reçoit donc // un état, et son timestamp N'AVANCE PAS — c'est la preuve de non-fraîcheur. Sans // battement, « installation stable » et « moteur arrêté » sont le même silence. notificationSpy.clear(); arb->publishTelemetryHeartbeat(); QCoreApplication::processEvents(); notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged"); QVERIFY2(!notifs.isEmpty(), "aucun battement de cœur"); const QVariantMap battement = notifs.last().toMap().value("params").toMap(); QCOMPARE(battement.value("timestamp").toString(), notif.value("timestamp").toString()); // ---- Le SEUIL : une oscillation de quelques watts ne réveille personne ------------------ // Le routeur est déjà à son plafond ; un surplus voisin ne change ni allocation ni code de // motif. Sans seuil, chaque cycle notifierait pour rien sur une cible RPi Zero 2W. notificationSpy.clear(); meter->setStateValue("currentPower", -6010); // +10 W, sous le seuil arb->simulationCallUpdate(t1.addSecs(60)); QCoreApplication::processEvents(); QVERIFY2(checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged").isEmpty(), "une variation de 10 W a notifié — le seuil ne mord pas"); // …mais le Get, lui, a bien avancé : l'état INTERNE est à jour même sans notification. resp = injectAndWait("NymeaEnergy.GetLoadTelemetry"); const QVariantMap c2 = resp.toMap().value("params").toMap(); QCOMPARE(c2.value("timestamp").toString(), t1.addSecs(60).toUTC().toString(Qt::ISODate)); // Deuxième cycle : les charges consomment, donc le RECRÉDIT n'est plus nul — et c'est // exactement le cas où l'identité serait fausse sans lui. L'allocation TOTALE, elle, n'a // pas bougé : c'est pour cela que le seuil ne mord pas. const QVariantMap b2 = c2.value("budget").toMap(); QVERIFY2(qRound(b2.value("recreditedW").toDouble()) > 0, "recrédit nul alors que les charges consomment"); QCOMPARE(qRound(b2.value("allocatedW").toDouble()), 5600); QCOMPARE(qRound(b2.value("surplusW").toDouble() + b2.value("recreditedW").toDouble() - b2.value("allocatedW").toDouble()), qRound(b2.value("remainingW").toDouble())); // ---- 3. Reprise à froid du mode dégradé ------------------------------------------------ // Un client qui se connecte APRÈS la bascule doit la lire, par GetLoadTelemetry ET par // GetChargingSchedules. C'est la famille ECS-411 : une notification seule laisse le // nouveau venu dans un silence ambigu. notificationSpy.clear(); arb->recordMeterUpdate(t1.addSecs(60)); arb->evaluateMeterFreshness(t1.addSecs(60 + 91)); QCoreApplication::processEvents(); QVERIFY(arb->degradedMode()); notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged"); QVERIFY2(!notifs.isEmpty(), "bascule en mode dégradé sans notification de télémétrie"); const QVariantMap dgr = notifs.last().toMap().value("params").toMap(); QCOMPARE(dgr.value("degradedMode").toBool(), true); // budget ABSENT : la planification est suspendue, il n'y a pas de budget pour ce cycle. // Publier des zéros se lirait « arbitrage exécuté, rien à allouer » — l'inverse du vrai. QVERIFY2(!dgr.contains("budget"), "budget publié en mode dégradé (planification suspendue)"); // Et `draw` avec lui, pour la même raison : sans plan, il n'y a pas de soutirage DÉCIDÉ. // Un `committedW: 0` s'y lirait « ce cycle n'a rien acheté », alors qu'il n'y a pas eu de // cycle du tout. QVERIFY2(!dgr.contains("draw"), "draw publié en mode dégradé (aucun soutirage décidé)"); QVERIFY(!dgr.value("loads").toList().isEmpty()); QVariantMap dgrParId; for (const QVariant &v : dgr.value("loads").toList()) { QCOMPARE(v.toMap().value("decision").toMap().value("code").toString(), QString(DecisionCode::DegradedL2)); QCOMPARE(qRound(v.toMap().value("allocatedW").toDouble()), 0); dgrParId.insert(v.toMap().value("loadId").toString(), v.toMap()); } // TROU DE SÉCURITÉ REFERMÉ (lot B-bis). Le repli L2 envoyait un Setpoint(0) à toute la // table des charges pilotées et un State(2) à une table SÉPARÉE, réservée à la PAC codée en // dur. Une PAC de CONFIGURATION vit dans la première : elle recevait donc un Setpoint, que // SgReadyAdapter rejette d'entrée — et rien d'autre. Sous compteur muet elle restait en // état 4 (forcé) indéfiniment, alors que SAFETY.md §L2 exige l'état 2. // // Le défaut était INVISIBLE dans la charge utile : le motif DEGRADED_L2 s'y affichait // correctement, seule l'ÉCRITURE manquait. C'est pourquoi on vérifie ici les CONTACTS et // le mécanisme publié, pas le motif. QCOMPARE(dgrParId.value("pac-telemetrie").toMap() .value("mechanism").toMap().value("state").toInt(), 2); QCOMPARE(tm->findConfiguredThing(k1)->stateValue("power").toBool(), false); QCOMPARE(tm->findConfiguredThing(k2)->stateValue("power").toBool(), false); // Et la charge en watts est bien coupée elle aussi (le repli ne régresse pas de l'autre côté). QCOMPARE(qRound(dgrParId.value("ecs-relais-telemetrie").toMap() .value("mechanism").toMap().value("stageW").toDouble()), 0); QCOMPARE(relayA->stateValue("power").toBool(), false); verifierCodes(dgr, QStringLiteral("telemetry-degrade")); // Le nouveau venu lit l'état par les DEUX portes. resp = injectAndWait("NymeaEnergy.GetLoadTelemetry"); QCOMPARE(resp.toMap().value("params").toMap().value("degradedMode").toBool(), true); resp = injectAndWait("NymeaEnergy.GetChargingSchedules"); QVERIFY2(resp.toMap().value("params").toMap().contains("degradedMode"), "GetChargingSchedules ne rend toujours pas degradedMode (poussé en notification " "seule : le défaut que ce lot corrige)"); QCOMPARE(resp.toMap().value("params").toMap().value("degradedMode").toBool(), true); // Sortie du mode dégradé : symétrique, et publiée TOUT DE SUITE. La planification ne // reprend qu'au cycle suivant ; sans cette republication, la télémétrie annoncerait encore // degradedMode=true pendant une minute après la levée du repli. notificationSpy.clear(); arb->recordMeterUpdate(t1.addSecs(300)); QCoreApplication::processEvents(); QVERIFY(!arb->degradedMode()); notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged"); QVERIFY2(!notifs.isEmpty(), "sortie du mode dégradé sans notification"); QCOMPARE(notifs.last().toMap().value("params").toMap().value("degradedMode").toBool(), false); resp = injectAndWait("NymeaEnergy.GetChargingSchedules"); QCOMPARE(resp.toMap().value("params").toMap().value("degradedMode").toBool(), false); qunsetenv("NYMEA_ENERGY_LOAD_CONFIG"); QFile::remove(cfgPath); #endif } void Simulation::testLoadConfigDomain() { #ifndef ETM_ARBITRATOR QSKIP("testLoadConfigDomain nécessite ETM_ARBITRATOR."); #else // [Lot B §3] o:domain — PURE MÉTADONNÉE d'intention. Trois exigences, plus une quatrième // qui n'est pas dans la liste mais qui est la raison d'être du champ : l'arbitrage ne // change pas. Un champ « sans effet » se prouve, il ne se déclare pas. const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-domain.json"; QFile::remove(cfgPath); qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(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) namespace { //! Prépare une borne du banc simulé : branchée, à l'arrêt, monophasée, 6-16 A. void armerBorne(Thing *borne) { borne->setStateValue("connected", true); borne->setStateValue("pluggedIn", true); borne->setStateValue("power", false); borne->setStateValue("charging", false); borne->setStateValue("maxChargingCurrent", 6); borne->setStateValue("currentPower", 0); } //! Les entrées de configuration, telles que l'APP les voit — par la frontière RPC, jamais par //! un store privé : c'est la concordance des deux frontières qui est en jeu. QHash configsParId(const QVariant &getLoadConfigReply) { QHash h; const QVariantList l = getLoadConfigReply.toMap().value("params").toMap() .value("loadConfigs").toList(); for (const QVariant &v : l) h.insert(v.toMap().value("id").toString(), v.toMap()); return h; } //! Les entrées de télémétrie du dernier cycle, indexées par loadId. QHash telemetrieParId(const QVariantMap &snap) { QHash h; for (const QVariant &v : snap.value("loads").toList()) h.insert(v.toMap().value("loadId").toString(), v.toMap()); return h; } } // namespace void Simulation::testEvChargerRankDecidesWhoCharges() { #ifndef ETM_ARBITRATOR QSKIP("testEvChargerRankDecidesWhoCharges nécessite ETM_ARBITRATOR."); #else // [3g-2 · LM-1204-b, moitié arbitrage] Deux bornes, un budget qui ne paie qu'un seul // plancher. Ce qui est éprouvé ici est ce qu'AUCUN matériel ne prouve : que l'ARBITRE // choisit, qu'il le dit, et que son choix suit le rang configuré. // // Le discriminant est le MOTIF publié (spec_loadmodel §12) : // BELOW_MIN_POWER → l'arbitre a refusé, aucune commande n'est partie ; // une borne à 0 W sans motif de plancher → c'est le matériel, le test ne prouve rien. const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-evrank.json"; QFile::remove(cfgPath); qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); ThingManager *tm = NymeaCore::instance()->thingManager(); QUuid meterId = addMeter(); m_experiencePlugin->energyManager()->setRootMeter(meterId); Thing *meter = tm->findConfiguredThing(meterId); QVERIFY(meter); meter->setStateValue("connected", true); // Tolérance d'acquisition à 1,0 : aucun démarrage sous tolérance ne vient brouiller la // lecture du plancher. Ce mécanisme a son propre test. QVERIFY(injectAndWait("NymeaEnergy.SetAcquisitionTolerance", QVariantMap({{"acquisitionTolerance", 1.0}})).toMap() .value("params").toMap().value("energyError").toString() == "EnergyErrorNoError"); // Deux bornes IDENTIQUES, plafonnées à 6 A : chacune prend exactement son plancher // (1380 W) et pas un watt de plus. C'est ce qui rend le RELIQUAT visible — 2000 − 1380 = // 620 W, sous le plancher de la seconde. Sans ce plafond, la première absorberait tout et // la seconde publierait « pas de surplus », ce qui est vrai mais ne prouve pas le plancher. QUuid borneAId = addCharger("A", 6, 26657); QUuid borneBId = addCharger("A", 6, 26670); QUuid voitureAId = addCar(26656); QUuid voitureBId = addCar(26671); QVERIFY(!borneAId.isNull() && !borneBId.isNull()); QVERIFY(!voitureAId.isNull() && !voitureBId.isNull()); Thing *borneA = tm->findConfiguredThing(borneAId); Thing *borneB = tm->findConfiguredThing(borneBId); QVERIFY(borneA && borneB); armerBorne(borneA); armerBorne(borneB); for (const QUuid &v : {voitureAId, voitureBId}) { Thing *voiture = tm->findConfiguredThing(v); QVERIFY(voiture); voiture->setStateValue("batteryLevel", 40); voiture->setStateValue("capacity", 50); voiture->setStateValue("minChargingCurrent", 6); voiture->setStateValue("phaseCount", 1); voiture->setStateValue("pluggedIn", true); } auto associer = [this](const QUuid &borne, const QUuid &voiture) { QVariantMap info; info.insert("evChargerId", borne); info.insert("assignedCarId", voiture); info.insert("chargingMode", "ChargingModeEco"); QVariant r = injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})); QCOMPARE(r.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); }; associer(borneAId, voitureAId); associer(borneBId, voitureBId); // ---- 1. L'auto-provisionnement : les bornes ENTRENT dans la configuration -------------- // 3g-1 les arbitrait et les publiait sans qu'elles figurent dans GetLoadConfig — une charge // publiée sans configuration est la charge fantôme que le lot B-bis avait supprimée. const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); meter->setStateValue("currentPower", 0); // aucun surplus : ce cycle ne sert qu'à arb->simulationCallUpdate(t0); // faire entrer les bornes en configuration QCoreApplication::processEvents(); QHash cfgs = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")); QVERIFY2(cfgs.contains(borneAId.toString()), "la borne A n'est pas entrée en configuration"); QVERIFY2(cfgs.contains(borneBId.toString()), "la borne B n'est pas entrée en configuration"); QCOMPARE(cfgs.value(borneAId.toString()).value("adapter").toString(), QStringLiteral("evcharger")); QCOMPARE(cfgs.value(borneAId.toString()).value("domain").toString(), QStringLiteral("ev")); QVERIFY(cfgs.value(borneAId.toString()).value("enabled").toBool()); // Aller-retour NEUTRE : relire la configuration et la réécrire mot pour mot ne change rien // et ne reconstruit AUCUN adaptateur — l'adaptateur d'une borne vient du Thing, pas d'ici. const QVariant avant = injectAndWait("NymeaEnergy.GetLoadConfig"); QVariant reecrit = injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", avant.toMap().value("params").toMap().value("loadConfigs")}})); QCOMPARE(reecrit.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); QCOMPARE(injectAndWait("NymeaEnergy.GetLoadConfig").toMap().value("params").toMap(), avant.toMap().value("params").toMap()); // ---- 2. Le rang décide, et le MOTIF le dit -------------------------------------------- // Le réordonnancement passe par la MÊME frontière que l'app : GetLoadConfig, on change le // rang, SetLoadConfig. Aucun raccourci par le store — c'est le chemin qui doit marcher. auto poserRangs = [&](int rangA, int rangB) { QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap() .value("params").toMap().value("loadConfigs").toList(); for (int i = 0; i < l.count(); ++i) { QVariantMap m = l.at(i).toMap(); if (m.value("id").toString() == borneAId.toString()) m.insert("priority", rangA); if (m.value("id").toString() == borneBId.toString()) m.insert("priority", rangB); l[i] = m; } QVariant r = injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})); QCOMPARE(r.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); }; // Chaque cycle mesuré part de DEUX bornes à l'arrêt. Sans cela le recrédit // anti-clignotement rendrait à celle qui charge déjà sa propre consommation — c'est le // comportement voulu (le compteur l'inclut déjà), mais il rend le budget du cycle // ambigu et le test ne mesurerait plus le rang. auto cycle = [&](const QDateTime &t) { for (Thing *b : {borneA, borneB}) { b->setStateValue("power", false); b->setStateValue("charging", false); b->setStateValue("maxChargingCurrent", 6); b->setStateValue("currentPower", 0); } meter->setStateValue("currentPower", -2000); // 2000 W exportés : UN seul plancher arb->simulationCallUpdate(t); QCoreApplication::processEvents(); return telemetrieParId(arb->loadTelemetry()); }; poserRangs(1, 2); QHash tele = cycle(t0.addSecs(60)); // Plancher : max(borne 6 A, voiture 6 A) × 230 V × 1 phase = 1380 W. Le budget de 2000 W // en paie UN. La seconde ne reçoit pas « un peu » — elle reçoit 0 et son motif. QCOMPARE(qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()), 1380); QCOMPARE(qRound(tele.value(borneBId.toString()).value("allocatedW").toDouble()), 0); // Le reliquat EXISTE (620 W) et il ne suffit pas : c'est bien le plancher qui refuse, pas // l'absence de surplus. Les deux phrases envoient l'installateur vers des gestes opposés. QCOMPARE(qRound(tele.value(borneBId.toString()).value("decision").toMap() .value("params").toMap().value("budgetW").toDouble()), 620); QCOMPARE(tele.value(borneBId.toString()).value("decision").toMap().value("code").toString(), QString(DecisionCode::BelowMinPower)); QCOMPARE(qRound(tele.value(borneBId.toString()).value("decision").toMap() .value("params").toMap().value("minPowerW").toDouble()), 1380); // ---- 3. INVERSION du rang : la borne servie change ------------------------------------ // C'est le pendant EV de l'inversion ECS/SG-Ready déjà éprouvée, et la seule preuve que le // rang est un RÉGLAGE et non un ordre de découverte. poserRangs(2, 1); tele = cycle(t0.addSecs(120)); QCOMPARE(qRound(tele.value(borneBId.toString()).value("allocatedW").toDouble()), 1380); QCOMPARE(qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()), 0); QCOMPARE(tele.value(borneAId.toString()).value("decision").toMap().value("code").toString(), QString(DecisionCode::BelowMinPower)); // ---- 4. Une charge, UN commandeur ----------------------------------------------------- // La borne refusée doit être ÉTEINTE par la décision de l'arbitre. Avant 3g-2, // adjustEvChargers() repassait derrière avec une consigne dérivée de sa propre logique : // l'arbitre publiait un motif juste, et le matériel recevait autre chose. QCOMPARE(borneA->stateValue("power").toBool(), false); QCOMPARE(borneA->stateValue("charging").toBool(), false); // Et la borne SERVIE l'est à la consigne du waterfall, pas à celle que le proxy aurait // dérivée : 1380 W sur une phase, soit 6 A. QCOMPARE(borneB->stateValue("power").toBool(), true); QCOMPARE(borneB->stateValue("maxChargingCurrent").toInt(), 6); // ---- 5. loads[] ⊆ GetLoadConfig, l'invariant que 3g-1 avait rompu ---------------------- QSet idsConfig; for (const QString &id : configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")).keys()) idsConfig.insert(id); for (auto it = tele.constBegin(); it != tele.constEnd(); ++it) QVERIFY2(idsConfig.contains(it.key()), qUtf8Printable(QStringLiteral("charge %1 publiée en télémétrie mais absente de " "GetLoadConfig").arg(it.key()))); #endif } void Simulation::testEvGridStartIsAnnouncedAndBounded() { #ifndef ETM_ARBITRATOR QSKIP("testEvGridStartIsAnnouncedAndBounded nécessite ETM_ARBITRATOR."); #else // [3g-2] `acquisitionTolerance` est un seuil de DÉCLENCHEMENT : la part du plancher qu'on // accepte de ne pas couvrir par le surplus pour démarrer quand même. Le comportement // existait chez le proxy, où il était invisible — confondu avec le plancher dans // evFloorW(). Ici il est visible, chiffré, et borné. const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-evgridstart.json"; QFile::remove(cfgPath); qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); ThingManager *tm = NymeaCore::instance()->thingManager(); QUuid meterId = addMeter(); m_experiencePlugin->energyManager()->setRootMeter(meterId); Thing *meter = tm->findConfiguredThing(meterId); QVERIFY(meter); meter->setStateValue("connected", true); injectAndWait("NymeaEnergy.SetAcquisitionTolerance", QVariantMap({{"acquisitionTolerance", 0.5}})); QUuid borneAId = addCharger("A", 16, 26657); QUuid borneBId = addCharger("A", 16, 26670); QUuid voitureAId = addCar(26656); QUuid voitureBId = addCar(26671); Thing *borneA = tm->findConfiguredThing(borneAId); Thing *borneB = tm->findConfiguredThing(borneBId); QVERIFY(borneA && borneB); armerBorne(borneA); armerBorne(borneB); for (const QUuid &v : {voitureAId, voitureBId}) { Thing *voiture = tm->findConfiguredThing(v); QVERIFY(voiture); voiture->setStateValue("batteryLevel", 40); voiture->setStateValue("capacity", 50); voiture->setStateValue("minChargingCurrent", 6); voiture->setStateValue("phaseCount", 1); voiture->setStateValue("pluggedIn", true); } for (const QPair &paire : QList>{{borneAId, voitureAId}, {borneBId, voitureBId}}) { QVariantMap info; info.insert("evChargerId", paire.first); info.insert("assignedCarId", paire.second); info.insert("chargingMode", "ChargingModeEco"); injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})); } const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); meter->setStateValue("currentPower", 0); // cycle d'entrée en configuration seulement arb->simulationCallUpdate(t0); QCoreApplication::processEvents(); QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap() .value("params").toMap().value("loadConfigs").toList(); for (int i = 0; i < l.count(); ++i) { QVariantMap m = l.at(i).toMap(); if (m.value("id").toString() == borneAId.toString()) m.insert("priority", 1); if (m.value("id").toString() == borneBId.toString()) m.insert("priority", 2); l[i] = m; } QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); // Les deux bornes à l'arrêt : le budget du cycle est sans ambiguïté (cf. testEvChargerRank…). for (Thing *b : {borneA, borneB}) { b->setStateValue("power", false); b->setStateValue("charging", false); b->setStateValue("maxChargingCurrent", 6); b->setStateValue("currentPower", 0); } // 900 W de surplus : sous le plancher de 1380 W, mais au-dessus de sa moitié. Le proxy // aurait démarré ici sans le dire ; le waterfall démarre et le DIT. meter->setStateValue("currentPower", -900); arb->simulationCallUpdate(t0.addSecs(60)); QCoreApplication::processEvents(); const QVariantMap snap = arb->loadTelemetry(); QHash tele = telemetrieParId(snap); // ---- Le motif dit COMBIEN est soutiré, et pourquoi ------------------------------------- const QVariantMap decisionA = tele.value(borneAId.toString()).value("decision").toMap(); QCOMPARE(decisionA.value("code").toString(), QString(DecisionCode::EvGridStart)); const QVariantMap pA = decisionA.value("params").toMap(); QCOMPARE(qRound(pA.value("floorW").toDouble()), 1380); QCOMPARE(qRound(pA.value("budgetW").toDouble()), 900); QCOMPARE(qRound(pA.value("gridW").toDouble()), 480); // 1380 − 900, SOUTIRÉS // budgetW + gridW == allocatedW : la comptabilité est vérifiable par le client seul. QCOMPARE(qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()), 1380); QCOMPARE(qRound(pA.value("budgetW").toDouble() + pA.value("gridW").toDouble()), 1380); // Le financement DIT la vérité sur l'origine : une part vient du réseau. // R2 — le financement vit dans levels[], plus sur la charge. Un seul niveau aujourd'hui. const QVariantList nivA = tele.value(borneAId.toString()).value("levels").toList(); QCOMPARE(nivA.count(), 1); QCOMPARE(nivA.first().toMap().value("level").toString(), QString("comfort")); QCOMPARE(nivA.first().toMap().value("funding").toString(), QString("grid")); // Et le niveau publie ce qu'il a DÉCIDÉ : tant qu'il n'y en a qu'un, c'est allocatedW. QCOMPARE(qRound(nivA.first().toMap().value("targetW").toDouble()), qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble())); QVERIFY2(!tele.value(borneAId.toString()).contains("funding"), "funding ne doit plus être publié au niveau de la CHARGE"); // ---- Borné par construction : UNE borne par cycle -------------------------------------- // Le démarrage consomme TOUT le budget restant ; la suivante trouve zéro et repart sur son // motif ordinaire. Deux bornes ne peuvent pas se mettre à soutirer le même cycle. QCOMPARE(qRound(tele.value(borneBId.toString()).value("allocatedW").toDouble()), 0); QVERIFY2(tele.value(borneBId.toString()).value("decision").toMap().value("code").toString() != QString(DecisionCode::EvGridStart), "deux bornes ont soutiré au réseau dans le même cycle"); // ---- La comptabilité du budget partage l'allocation ------------------------------------ const QVariantMap budget = snap.value("budget").toMap(); QCOMPARE(qRound(budget.value("allocatedW").toDouble()), 900); // la part de SURPLUS QCOMPARE(qRound(budget.value("remainingW").toDouble()), 0); // R3 — la part ACHETÉE a quitté evReservedW pour le registre du soutirage. evReservedW est // une CORRECTION du budget (commandé non encore mesuré) : y verser une tranche // d'allocation rendait toute définition du champ fausse pour l'un des deux termes, et // interdisait de le nommer honnêtement à l'écran. QCOMPARE(qRound(snap.value("draw").toMap().value("committedW").toDouble()), 480); QCOMPARE(qRound(budget.value("evReservedW").toDouble()), 0); // budgetW + gridW == allocatedW tient toujours, et se lit désormais SANS exception : // la part surplus dans le registre d'allocation, la part achetée dans celui du soutirage. QCOMPARE(qRound(budget.value("allocatedW").toDouble() + snap.value("draw").toMap().value("committedW").toDouble()), qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble())); #endif } void Simulation::testEvChargerConfigOutlivesItsThing() { #ifndef ETM_ARBITRATOR QSKIP("testEvChargerConfigOutlivesItsThing nécessite ETM_ARBITRATOR."); #else // [3g-2] Borne remplacée, ThingId qui change, appareil déposé : l'entrée de configuration // n'est PAS supprimée d'office — c'est une configuration, et l'installateur a pu la classer. // Elle doit se lire comme une charge dont l'appareil manque, avec le motif qui va bien. // Le cas symétrique du THING_MISSING éprouvé avec la sonde. const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-evorphan.json"; QFile::remove(cfgPath); qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); ThingManager *tm = NymeaCore::instance()->thingManager(); QUuid meterId = addMeter(); m_experiencePlugin->energyManager()->setRootMeter(meterId); Thing *meter = tm->findConfiguredThing(meterId); QVERIFY(meter); meter->setStateValue("connected", true); meter->setStateValue("currentPower", -4000); QUuid borneId = addCharger("A", 16, 26657); QUuid voitureId = addCar(26656); Thing *borne = tm->findConfiguredThing(borneId); Thing *voiture = tm->findConfiguredThing(voitureId); QVERIFY(borne && voiture); armerBorne(borne); voiture->setStateValue("batteryLevel", 40); voiture->setStateValue("capacity", 50); voiture->setStateValue("minChargingCurrent", 6); voiture->setStateValue("phaseCount", 1); voiture->setStateValue("pluggedIn", true); QVariantMap info; info.insert("evChargerId", borneId); info.insert("assignedCarId", voitureId); info.insert("chargingMode", "ChargingModeEco"); injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})); const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); arb->simulationCallUpdate(t0); QCoreApplication::processEvents(); QVERIFY2(configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")).contains(borneId.toString()), "la borne n'est pas entrée en configuration"); // ---- Le Thing disparaît ---------------------------------------------------------------- QVERIFY(removeDevice(borneId).toMap().value("params").toMap().value("thingError").toString() == "ThingErrorNoError"); arb->simulationCallUpdate(t0.addSecs(60)); QCoreApplication::processEvents(); // L'entrée SURVIT : la supprimer effacerait un classement que l'installateur a posé, et le // silence qui suivrait ne se distinguerait pas d'une borne jamais configurée. QVERIFY2(configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")).contains(borneId.toString()), "l'entrée de configuration a été supprimée avec le Thing"); // Et elle se LIT comme une charge sans appareil, à son rang, avec son motif. const QHash tele = telemetrieParId(arb->loadTelemetry()); QVERIFY2(tele.contains(borneId.toString()), "la borne configurée sans appareil a disparu de la télémétrie"); const QVariantMap entree = tele.value(borneId.toString()); QCOMPARE(entree.value("available").toBool(), false); QCOMPARE(entree.value("faultCode").toString(), QString("THING_MISSING")); QCOMPARE(entree.value("decision").toMap().value("code").toString(), QString(DecisionCode::LoadUnavailable)); QCOMPARE(qRound(entree.value("allocatedW").toDouble()), 0); #endif } void Simulation::testUnpluggedChargerTakesNoBudget() { #ifndef ETM_ARBITRATOR QSKIP("testUnpluggedChargerTakesNoBudget nécessite ETM_ARBITRATOR."); #else // [banc 2026-08-27, RELEVE_3g2 §3.1] Une borne en Eco, voiture assignée, mais AUCUN // véhicule branché. Elle ne peut rien tirer de ce qu'on lui alloue — et pourtant elle // était servie : 4 853 W retirés du budget et donnés à personne, la charge suivante ne // survivant que sur son propre recrédit. // // Le proxy n'avait pas besoin de ce filtre : son allocation n'était pas une cascade, donc // gaspiller ne privait personne. C'est la transplantation qui rend le test nécessaire. const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-unplugged.json"; QFile::remove(cfgPath); qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); ThingManager *tm = NymeaCore::instance()->thingManager(); QUuid meterId = addMeter(); m_experiencePlugin->energyManager()->setRootMeter(meterId); Thing *meter = tm->findConfiguredThing(meterId); QVERIFY(meter); meter->setStateValue("connected", true); QUuid borneId = addCharger("A", 16, 26657); QUuid voitureId = addCar(26656); QUuid routeurId = addEtmVariableLoad(27050); Thing *borne = tm->findConfiguredThing(borneId); Thing *voiture = tm->findConfiguredThing(voitureId); Thing *routeur = tm->findConfiguredThing(routeurId); QVERIFY(borne && voiture && routeur); armerBorne(borne); borne->setStateValue("pluggedIn", false); // ← le cas du banc : rien n'est branché routeur->setStateValue("currentPowerW", 0); voiture->setStateValue("batteryLevel", 40); voiture->setStateValue("capacity", 50); voiture->setStateValue("minChargingCurrent", 6); voiture->setStateValue("phaseCount", 1); QVariantMap info; info.insert("evChargerId", borneId); info.insert("assignedCarId", voitureId); info.insert("chargingMode", "ChargingModeEco"); injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})); const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); meter->setStateValue("currentPower", 0); arb->simulationCallUpdate(t0); // fait entrer la borne en configuration QCoreApplication::processEvents(); // Une charge derrière la borne, au rang suivant : c'est elle qui doit recevoir le budget. QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap() .value("params").toMap().value("loadConfigs").toList(); for (int i = 0; i < l.count(); ++i) { QVariantMap m = l.at(i).toMap(); if (m.value("id").toString() == borneId.toString()) m.insert("priority", 1); l[i] = m; } l.append(QVariantMap{{"id", routeurId.toString()}, {"label", "Routeur PV"}, {"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}}); QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); // ---- Débranchée : elle ne doit PAS figurer dans le contexte --------------------------- meter->setStateValue("currentPower", -3000); arb->simulationCallUpdate(t0.addSecs(60)); QCoreApplication::processEvents(); QHash tele = telemetrieParId(arb->loadTelemetry()); QVERIFY2(!tele.contains(borneId.toString()), "une borne sans véhicule branché a été arbitrée"); // Et le budget est allé ENTIÈREMENT à la charge suivante. C'est la moitié du test qui // compte : l'exclusion ne vaut que si elle ne coûte rien à personne. QCOMPARE(qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble()), 3000); QCOMPARE(qRound(arb->loadTelemetry().value("budget").toMap() .value("allocatedW").toDouble()), 3000); // ---- Branchée : elle redevient une charge du waterfall -------------------------------- // Sans cette seconde moitié, le test passerait aussi si la borne était exclue pour une // raison quelconque — il ne prouverait pas que c'est `pluggedIn` qui décide. borne->setStateValue("pluggedIn", true); routeur->setStateValue("currentPowerW", 0); arb->simulationCallUpdate(t0.addSecs(120)); QCoreApplication::processEvents(); tele = telemetrieParId(arb->loadTelemetry()); QVERIFY2(tele.contains(borneId.toString()), "une borne avec véhicule branché doit entrer dans le waterfall"); QCOMPARE(qRound(tele.value(borneId.toString()).value("allocatedW").toDouble()), 3000); QCOMPARE(qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble()), 0); #endif } void Simulation::testChargerWithoutCarIsArbitrated() { #ifndef ETM_ARBITRATOR QSKIP("testChargerWithoutCarIsArbitrated nécessite ETM_ARBITRATOR."); #else // [LM-1208-b · banc 2026-08-28] Le cas de l'INVITÉ. Une borne en Eco, un véhicule branché, // et AUCUNE voiture assignée — parce qu'une voiture qui n'est pas celle du propriétaire ne // sera jamais déclarée. Elle tirait 3,9 kW au banc et l'arbitre l'ignorait : le critère // « pas de voiture assignée » la sortait du waterfall précisément quand elle consommait. // // Deux choses sont éprouvées ici, et la seconde est celle qui se serait perdue : // 1. la borne EST arbitrée sans voiture assignée ; // 2. elle l'est avec le nombre de phases de la BORNE. Le manager ne prépare pas une // borne sans voiture (prepareInformation la filtre), donc son ChargingProcessInfo // n'existe pas — et le lire par défaut annonce « 1 phase » pour une borne triphasée. // Le défaut serait SILENCIEUX : plafond à 3 680 W au lieu de 11 040, courant commandé // à 16 A au lieu de 9, et 11 kW tirés d'un budget qui en comptait 3,7. const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-nocar.json"; QFile::remove(cfgPath); qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); ThingManager *tm = NymeaCore::instance()->thingManager(); QUuid meterId = addMeter(); m_experiencePlugin->energyManager()->setRootMeter(meterId); Thing *meter = tm->findConfiguredThing(meterId); QVERIFY(meter); meter->setStateValue("connected", true); // Tolérance d'acquisition à 1,0 : aucun démarrage sous tolérance ne vient brouiller la // lecture du plancher — EV_GRID_START a son propre test. QCOMPARE(injectAndWait("NymeaEnergy.SetAcquisitionTolerance", QVariantMap({{"acquisitionTolerance", 1.0}})).toMap() .value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); // La borne est TRIPHASÉE — c'est tout l'enjeu du point 2. Plancher 6 A × 230 V × 3 // = 4 140 W, plafond 16 A × 230 V × 3 = 11 040 W. QUuid borneId = addCharger("ABC", 16, 26657); QUuid routeurId = addEtmVariableLoad(27050); Thing *borne = tm->findConfiguredThing(borneId); Thing *routeur = tm->findConfiguredThing(routeurId); QVERIFY(borne && routeur); armerBorne(borne); // branchée, à l'arrêt, 6 A QCOMPARE(borne->stateValue("phaseCount").toUInt(), 3u); routeur->setStateValue("currentPowerW", 0); // AUCUN assignedCarId — et aucune voiture n'est même ajoutée au système. QVariantMap info; info.insert("evChargerId", borneId); info.insert("chargingMode", "ChargingModeEco"); QCOMPARE(injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); meter->setStateValue("currentPower", 0); arb->simulationCallUpdate(t0); // fait entrer la borne en configuration QCoreApplication::processEvents(); // Une charge derrière la borne : sans elle, « la borne prend tout » ne se distinguerait pas // de « personne ne prend rien ». QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap() .value("params").toMap().value("loadConfigs").toList(); for (int i = 0; i < l.count(); ++i) { QVariantMap m = l.at(i).toMap(); if (m.value("id").toString() == borneId.toString()) m.insert("priority", 1); l[i] = m; } l.append(QVariantMap{{"id", routeurId.toString()}, {"label", "Routeur PV"}, {"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}}); QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); // ---- Sans voiture assignée : la borne est ARBITRÉE ------------------------------------ meter->setStateValue("currentPower", -6000); arb->simulationCallUpdate(t0.addSecs(60)); QCoreApplication::processEvents(); QHash tele = telemetrieParId(arb->loadTelemetry()); QVERIFY2(tele.contains(borneId.toString()), "une borne sans voiture assignée a été exclue du waterfall (LM-1208-b)"); // Le budget entier lui revient : 6 000 W passent sous son plafond TRIPHASÉ (11 040 W). // Annoncée monophasée, elle aurait plafonné à 3 680 W et laissé 2 320 W au routeur — c'est // exactement la lecture qui distingue les deux. QCOMPARE(qRound(tele.value(borneId.toString()).value("allocatedW").toDouble()), 6000); QCOMPARE(qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble()), 0); // Et le COURANT commandé le confirme au matériel, pas seulement dans la charge utile : // 6 000 / (230 × 3) = 8,7 → 9 A. Sur une phase supposée, c'eût été 16 A écrêtés au plafond // de la borne — soit 11 kW tirés d'un budget qui en comptait 3,7. QCOMPARE(borne->stateValue("maxChargingCurrent").toUInt(), 9u); // ---- Le mode manuel, lui, RESTE une exclusion ------------------------------------------ // Sans cette moitié, le test passerait aussi si toute borne était arbitrée sans condition : // il ne prouverait pas que c'est le critère « voiture » qui a été retiré, et lui seul. info.insert("chargingMode", "ChargingModeNormal"); QCOMPARE(injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); routeur->setStateValue("currentPowerW", 0); arb->simulationCallUpdate(t0.addSecs(120)); QCoreApplication::processEvents(); tele = telemetrieParId(arb->loadTelemetry()); QVERIFY2(!tele.contains(borneId.toString()), "une borne en mode manuel doit rester hors arbitrage"); QCOMPARE(qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble()), 3000); #endif } void Simulation::testMeasurementSourceIsPublished() { #ifndef ETM_ARBITRATOR QSKIP("testMeasurementSourceIsPublished nécessite ETM_ARBITRATOR."); #else // [§11 · LM-1105/LM-1106] Il fallait désigner une charge comme SON PROPRE compteur dans // meterThingId pour qu'elle publie ce qu'elle mesure déjà. Le moteur lit désormais // l'appareil qu'il pilote — et publie D'OÙ vient le nombre, parce que l'écart // commandé↔mesuré ne conclut pas la même chose selon la source. // // Ce test porte les trois sources ET les deux règles de priorité. Sans la seconde moitié // il passerait aussi si le compteur explicite avait été purement et simplement remplacé. const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-measure.json"; QFile::remove(cfgPath); qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); ThingManager *tm = NymeaCore::instance()->thingManager(); QUuid meterId = addMeter(); m_experiencePlugin->energyManager()->setRootMeter(meterId); Thing *meter = tm->findConfiguredThing(meterId); QVERIFY(meter); meter->setStateValue("connected", true); // Trois mécanismes. Les relais du mock portent `currentPower` — c'est délibéré : le // sg-ready doit répondre « non mesurable » MÊME LÀ, parce que ses contacts sont des // contacts de SIGNAL et que le courant de la PAC ne les traverse pas. QUuid rA = addPowerSwitch(1000, 26661); // relay-router, 1 relais QUuid k1 = addPowerSwitch(0, 26662); // sg-ready K1 QUuid k2 = addPowerSwitch(0, 26663); // sg-ready K2 QUuid varId = addEtmVariableLoad(27041); // etmvariableload dynamic Thing *varThing = tm->findConfiguredThing(varId); QVERIFY(varThing && tm->findConfiguredThing(rA)); QVERIFY(tm->findConfiguredThing(k1) && tm->findConfiguredThing(k2)); varThing->setStateValue("currentPowerW", 0); // Le compteur divisionnaire, posé EXPRÈS sur le routeur — il n'entrera en jeu qu'au 3. QUuid divId = addMeter(26670); Thing *div = tm->findConfiguredThing(divId); QVERIFY(div); div->setStateValue("connected", true); div->setStateValue("currentPower", 1234); LoadConfigStore *store = new LoadConfigStore(arb); arb->setLoadConfigStore(store); auto poserConfig = [&](const QString &meterThingId) { LoadConfigs cfgs; QVariantMap routeur{ {"id", varId.toString()}, {"label", "Routeur PV"}, {"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 1}, {"enabled", true}}; if (!meterThingId.isEmpty()) routeur.insert("meterThingId", meterThingId); cfgs.append(LoadConfig::fromMap(routeur)); cfgs.append(LoadConfig::fromMap(sgReadyConfigMap("pac-mesure", "PAC", k1, k2, 800.0, 1600.0, 300, 2))); cfgs.append(LoadConfig::fromMap(QVariantMap{ {"id", "ecs-mesure"}, {"label", "ECS relais"}, {"adapter", "relay-router"}, {"mode", "fixed"}, {"priority", 3}, {"enabled", true}, {"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, {"powerW", 1000}}}, {"minOnS", 0}, {"minOffS", 0}})); QString err; QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8()); }; const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); // ---- 1. Les TROIS sources, sans une ligne de configuration de compteur ---------------- poserConfig(QString()); meter->setStateValue("currentPower", -6000); arb->simulationCallUpdate(t0); QCoreApplication::processEvents(); QHash tele = telemetrieParId(arb->loadTelemetry()); // L'appareil piloté publie sa puissance : le moteur la lit, personne n'a rien désigné. // 0 W est une MESURE, pas une absence — et la source ne doit pas basculer pour autant. const QVariantMap mRouteur = tele.value(varId.toString()).value("measurement").toMap(); QCOMPARE(mRouteur.value("source").toString(), QString("device")); QVERIFY2(mRouteur.contains("powerW"), "une charge mesurable doit publier sa valeur"); QCOMPARE(qRound(mRouteur.value("powerW").toDouble()), 0); // LM-1105-a — le relay-router est servi : relais fermé, le mock rapporte 1 000 W. La mesure // traverse bien le relais — c'est ce qui distingue un organe de COMMUTATION d'un contact // de signal (voir la PAC juste dessous). const QVariantMap mEcs = tele.value("ecs-mesure").value("measurement").toMap(); QCOMPARE(mEcs.value("source").toString(), QString("device")); QCOMPARE(qRound(mEcs.value("powerW").toDouble()), 1000); // LM-1105-b — la PAC : « none », ALORS QUE ses contacts portent currentPower. Un contact est // un contact de signal — mesurer sa bobine ne mesure pas la PAC. C'est un état POSITIF, // et powerW est OMIS : une nominale qui se ferait passer pour une mesure produirait un // écart identiquement nul à tous les cycles. const QVariantMap mPac = tele.value("pac-mesure").value("measurement").toMap(); QCOMPARE(mPac.value("source").toString(), QString("none")); QVERIFY2(!mPac.contains("powerW"), "une charge non mesurable a publié une puissance sous le nom d'une mesure"); // Et l'ancienne clé a bien DISPARU — deux noms pour une chose divergent. QVERIFY2(!tele.value(varId.toString()).contains("measuredW"), "measuredW survit"); // ---- 1-bis. Palier 0 : la SOURCE ne bascule pas, la valeur seule tombe ----------------- // C'est la règle « la capacité se juge sur la CLASSE de tous les relais déclarés, jamais // sur ceux qui sont fermés ». Jugée sur les relais fermés, elle répondrait « none » ici — // et la source publiée basculerait à chaque extinction, sur une installation dont rien // n'a changé. Il faut un budget NÉGATIF : à budget nul le recrédit anti-clignotement // rendrait à l'ECS les 1 000 W qu'il consomme, et le relais resterait fermé. meter->setStateValue("currentPower", 2000); // 2 000 W IMPORTÉS arb->simulationCallUpdate(t0.addSecs(30)); QCoreApplication::processEvents(); tele = telemetrieParId(arb->loadTelemetry()); const QVariantMap mEcsEteint = tele.value("ecs-mesure").value("measurement").toMap(); QCOMPARE(mEcsEteint.value("source").toString(), QString("device")); QCOMPARE(qRound(mEcsEteint.value("powerW").toDouble()), 0); // ---- 2. La valeur SUIT l'appareil ------------------------------------------------------ meter->setStateValue("currentPower", -6000); varThing->setStateValue("currentPowerW", 2900); arb->simulationCallUpdate(t0.addSecs(60)); QCoreApplication::processEvents(); tele = telemetrieParId(arb->loadTelemetry()); QCOMPARE(tele.value(varId.toString()).value("measurement").toMap() .value("source").toString(), QString("device")); QCOMPARE(qRound(tele.value(varId.toString()).value("measurement").toMap() .value("powerW").toDouble()), 2900); // ---- 3. Le compteur EXPLICITE reste prioritaire ---------------------------------------- // Pas d'abord parce qu'il mesure mieux : parce qu'un réglage sans effet est pire que pas // de réglage. 1234 W du compteur doivent l'emporter sur 2900 W déclarés par l'appareil. poserConfig(divId.toString()); arb->simulationCallUpdate(t0.addSecs(120)); QCoreApplication::processEvents(); tele = telemetrieParId(arb->loadTelemetry()); const QVariantMap mAvecCompteur = tele.value(varId.toString()).value("measurement").toMap(); QCOMPARE(mAvecCompteur.value("source").toString(), QString("meter")); QCOMPARE(qRound(mAvecCompteur.value("powerW").toDouble()), 1234); // ---- 4. …mais la priorité porte sur la SOURCE, pas sur la valeur ----------------------- // Compteur déclaré et injoignable : on retombe sur l'appareil. Se taire perdrait une // mesure qu'on a, et le silence se lirait « charge non mesurable » — ce qui serait faux. poserConfig(QUuid::createUuid().toString()); arb->simulationCallUpdate(t0.addSecs(180)); QCoreApplication::processEvents(); tele = telemetrieParId(arb->loadTelemetry()); const QVariantMap mRepli = tele.value(varId.toString()).value("measurement").toMap(); QCOMPARE(mRepli.value("source").toString(), QString("device")); QCOMPARE(qRound(mRepli.value("powerW").toDouble()), 2900); #endif } void Simulation::testAutoProvisionedRankGoesToTheQueue() { #ifndef ETM_ARBITRATOR QSKIP("testAutoProvisionedRankGoesToTheQueue nécessite ETM_ARBITRATOR."); #else // [LM-1209] L'auto-provisionnement REMPLIT UN TROU : sans entrée, une borne serait arbitrée // sans rang réglable. Il ne s'ensuit rien sur sa PLACE. Une borne détectée un mois après la // mise en service ne doit pas passer devant l'ECS que quelqu'un a réglé exprès — une // détection ne réordonne pas ce qu'un humain a décidé. const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-queue.json"; QFile::remove(cfgPath); qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); ThingManager *tm = NymeaCore::instance()->thingManager(); QUuid meterId = addMeter(); m_experiencePlugin->energyManager()->setRootMeter(meterId); Thing *meter = tm->findConfiguredThing(meterId); QVERIFY(meter); meter->setStateValue("connected", true); meter->setStateValue("currentPower", -4000); // ---- Une installation DÉJÀ classée par un humain --------------------------------------- QUuid rA = addPowerSwitch(1000, 26661); QUuid varId = addEtmVariableLoad(27042); QVERIFY(tm->findConfiguredThing(rA) && tm->findConfiguredThing(varId)); // Posées par la frontière RPC, comme l'app le ferait — c'est le magasin que le handler // connaît, et c'est lui qui doit porter la marque de rang. QVariantList l; l.append(QVariantMap{{"id", "ecs-classe"}, {"label", "ECS classé"}, {"adapter", "relay-router"}, {"mode", "fixed"}, {"priority", 1}, {"enabled", true}, {"domain", "ecs"}, {"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, {"powerW", 1000}}}, {"minOnS", 0}, {"minOffS", 0}}); l.append(QVariantMap{{"id", varId.toString()}, {"label", "Routeur PV"}, {"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}, {"domain", "ecs"}}); QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); // ---- La borne arrive UN MOIS PLUS TARD ------------------------------------------------- QUuid borneId = addCharger("A", 16, 26657); Thing *borne = tm->findConfiguredThing(borneId); QVERIFY(borne); armerBorne(borne); const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); arb->simulationCallUpdate(t0); QCoreApplication::processEvents(); QHash cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")); QVERIFY2(cfg.contains(borneId.toString()), "la borne détectée n'est pas entrée en configuration"); // EN QUEUE : derrière tout le monde, pas en tête. C'est la correction du lot — le rang // retombait à 1, à égalité avec l'ECS que quelqu'un avait posé là exprès. QCOMPARE(cfg.value(borneId.toString()).value("priority").toInt(), 3); // AUCUN autre rang n'a bougé : insérer est un geste de configuration, pas d'arbitrage. QCOMPARE(cfg.value("ecs-classe").value("priority").toInt(), 1); QCOMPARE(cfg.value(varId.toString()).value("priority").toInt(), 2); // Et la création SE DIT : le journal ne suffit pas, l'app ne le lit pas et c'est elle qui // montre le classement. « ce rang est un défaut, pas un choix. » QCOMPARE(cfg.value(borneId.toString()).value("rankOrigin").toString(), QString("auto")); // Les charges posées par un humain, elles, n'affirment rien — ni « choisi », ni « défaut ». QVERIFY2(cfg.value("ecs-classe").value("rankOrigin").toString().isEmpty(), "une charge posée par un humain ne doit rien affirmer sur l'origine de son rang"); // ---- Une SECONDE borne va encore derrière ---------------------------------------------- // Le rang produit doit rester LIBRE par construction : c'est ce qui rend l'égalité // impossible par ce chemin, plutôt que de la refuser après coup. QUuid borne2Id = addCharger("A", 16, 26670); Thing *borne2 = tm->findConfiguredThing(borne2Id); QVERIFY(borne2); armerBorne(borne2); arb->simulationCallUpdate(t0.addSecs(60)); QCoreApplication::processEvents(); cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")); QCOMPARE(cfg.value(borne2Id.toString()).value("priority").toInt(), 4); QCOMPARE(cfg.value(borneId.toString()).value("priority").toInt(), 3); // toujours pas bougé QCOMPARE(cfg.value("ecs-classe").value("priority").toInt(), 1); // Tous distincts : l'unicité est obtenue PAR CONSTRUCTION, pas par un refus tardif. QSet rangs; for (auto it = cfg.constBegin(); it != cfg.constEnd(); ++it) rangs.insert(it.value().value("priority").toInt()); QCOMPARE(rangs.count(), cfg.count()); #endif } void Simulation::testDeletedEvChargerConfigComesBack() { #ifndef ETM_ARBITRATOR QSKIP("testDeletedEvChargerConfigComesBack nécessite ETM_ARBITRATOR."); #else // [LM-1209-e · mesuré sur `.75` le 2026-08-29] Le provisionnement vivait dans la branche // « l'adaptateur vient de naître ». Or l'adaptateur est construit depuis le THING : il // survit à la suppression de l'entrée de configuration. Une entrée supprimée depuis l'app // n'était donc JAMAIS recréée — et la borne restait arbitrée, absente de GetLoadConfig, à // un rang de queue non réglable. Le trou refermé en 3g-2, rouvert par le côté client. const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-reprov.json"; QFile::remove(cfgPath); qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); ThingManager *tm = NymeaCore::instance()->thingManager(); QUuid meterId = addMeter(); m_experiencePlugin->energyManager()->setRootMeter(meterId); Thing *meter = tm->findConfiguredThing(meterId); QVERIFY(meter); meter->setStateValue("connected", true); meter->setStateValue("currentPower", -4000); QUuid borneId = addCharger("A", 16, 26657); Thing *borne = tm->findConfiguredThing(borneId); QVERIFY(borne); armerBorne(borne); const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); arb->simulationCallUpdate(t0); QCoreApplication::processEvents(); QHash cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")); QVERIFY2(cfg.contains(borneId.toString()), "la borne n'est pas entrée en configuration"); QCOMPARE(cfg.value(borneId.toString()).value("rankOrigin").toString(), QString("auto")); const int rangInitial = cfg.value(borneId.toString()).value("priority").toInt(); // ---- L'installateur classe la borne : la marque tombe ----------------------------------- QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap() .value("params").toMap().value("loadConfigs").toList(); for (int i = 0; i < l.count(); ++i) { QVariantMap m = l.at(i).toMap(); if (m.value("id").toString() == borneId.toString()) m.insert("priority", rangInitial + 3); l[i] = m; } QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")); QVERIFY2(cfg.value(borneId.toString()).value("rankOrigin").toString().isEmpty(), "un rang posé par un humain doit lever la marque"); // ---- …puis il SUPPRIME l'entrée, le Thing restant vivant -------------------------------- QVariantList sans; for (const QVariant &v : injectAndWait("NymeaEnergy.GetLoadConfig").toMap() .value("params").toMap().value("loadConfigs").toList()) if (v.toMap().value("id").toString() != borneId.toString()) sans.append(v); QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", sans}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); QVERIFY2(!configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")).contains(borneId.toString()), "l'entrée devait être retirée par l'écriture cliente"); // ---- Le cycle suivant la RATTRAPE ------------------------------------------------------- // Sans LM-1209-e, rien ne se passe : l'adaptateur existe déjà, donc le provisionnement // n'est plus jamais tenté, et la borne reste arbitrée hors configuration. arb->simulationCallUpdate(t0.addSecs(60)); QCoreApplication::processEvents(); cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")); QVERIFY2(cfg.contains(borneId.toString()), "une borne dont le Thing vit doit retrouver une entrée de configuration"); // Et elle revient EN QUEUE et en « auto » : personne ne l'a classée, son rang est de // nouveau un défaut. Le rang que l'installateur avait posé est perdu avec l'entrée — c'est // le prix de la suppression, et c'est pourquoi la marque doit se voir. QCOMPARE(cfg.value(borneId.toString()).value("rankOrigin").toString(), QString("auto")); int rangMax = 0; for (auto it = cfg.constBegin(); it != cfg.constEnd(); ++it) if (it.key() != borneId.toString()) rangMax = qMax(rangMax, it.value().value("priority").toInt()); QCOMPARE(cfg.value(borneId.toString()).value("priority").toInt(), qMax(1, rangMax + 1)); // ---- Et le rattrapage ne se REJOUE pas quand l'entrée existe ---------------------------- // Sans quoi il réécrirait la configuration à chaque cycle — donc un changed(), donc un // rebuild d'adaptateurs, donc des verrous réarmés en boucle (ECS-412). const QVariantMap avant = cfg.value(borneId.toString()); arb->simulationCallUpdate(t0.addSecs(120)); QCoreApplication::processEvents(); const QVariantMap apres = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")) .value(borneId.toString()); QCOMPARE(apres, avant); #endif } void Simulation::testVanishedRelayFreezesTheLoad() { #ifndef ETM_ARBITRATOR QSKIP("testVanishedRelayFreezesTheLoad nécessite ETM_ARBITRATOR."); #else // [ECS-111] Le comportement était « non défini », et son effet dépassait l'affichage : le // relais disparu était SAUTÉ, on retombait sur le nominal du PALIER ENTIER — part du relais // fantôme comprise — et ce chiffre alimente le recrédit anti-clignotement, donc le BUDGET. // L'arbitre se trompait sur le surplus pendant que `available` restait vrai. // // Ce test porte les deux moitiés, et c'est la seconde qui compte : ce que le fantôme // laissait aux charges SUIVANTES. const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-vanish.json"; QFile::remove(cfgPath); qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); ThingManager *tm = NymeaCore::instance()->thingManager(); QUuid meterId = addMeter(); m_experiencePlugin->energyManager()->setRootMeter(meterId); Thing *meter = tm->findConfiguredThing(meterId); QVERIFY(meter); meter->setStateValue("connected", true); QUuid rA = addPowerSwitch(2000, 26661); // ECS : un seul relais, 2000 W QUuid varId = addEtmVariableLoad(27043); // la charge SUIVANTE, celle qui paie Thing *routeur = tm->findConfiguredThing(varId); QVERIFY(tm->findConfiguredThing(rA) && routeur); routeur->setStateValue("currentPowerW", 0); QVariantList l; l.append(QVariantMap{{"id", "ecs-fantome"}, {"label", "ECS relais"}, {"adapter", "relay-router"}, {"mode", "fixed"}, {"priority", 1}, {"enabled", true}, {"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, {"powerW", 2000}}}, {"minOnS", 0}, {"minOffS", 0}}); l.append(QVariantMap{{"id", varId.toString()}, {"label", "Routeur PV"}, {"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}}); QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); // ---- Régime sain : l'ECS prend son palier, le routeur prend le reste ------------------ const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); meter->setStateValue("currentPower", -5000); arb->simulationCallUpdate(t0); QCoreApplication::processEvents(); QHash tele = telemetrieParId(arb->loadTelemetry()); QCOMPARE(tele.value("ecs-fantome").value("available").toBool(), true); QCOMPARE(qRound(tele.value("ecs-fantome").value("allocatedW").toDouble()), 2000); const int routeurSain = qRound(tele.value(varId.toString()).value("allocatedW").toDouble()); QVERIFY2(routeurSain > 0, "le routeur doit être servi en régime sain"); // ---- Le relais DISPARAÎT du registre nymea -------------------------------------------- // Thing supprimé, ou plugin qui ne le voit plus : on ne sait pas dans quel état est le // contact. C'est précisément pour ça qu'on ne peut pas « compter 0 W et servir le reste ». QVERIFY(removeDevice(rA).toMap().value("params").toMap().value("thingError").toString() == "ThingErrorNoError"); arb->simulationCallUpdate(t0.addSecs(60)); QCoreApplication::processEvents(); tele = telemetrieParId(arb->loadTelemetry()); // 1. La charge SORT DE L'ARBITRAGE, et elle le DIT. const QVariantMap ecs = tele.value("ecs-fantome"); QCOMPARE(ecs.value("available").toBool(), false); QCOMPARE(ecs.value("faultCode").toString(), QString("THING_MISSING")); QCOMPARE(ecs.value("decision").toMap().value("code").toString(), QString(DecisionCode::LoadUnavailable)); // 2. …mais PAS de la comptabilité : la puissance crue engagée reste allouée, parce qu'on // ne sait plus la couper. C'est ECS-410, appliqué à une cause nouvelle. QCOMPARE(qRound(ecs.value("allocatedW").toDouble()), 2000); // 3. ET C'EST LA MOITIÉ QUI DÉCIDE : le fantôme est RETIRÉ du budget des suivantes au lieu // de leur être recrédité. Avant le correctif, `currentPowerW` valait le nominal du // palier entier et alimentait le recrédit — le routeur se voyait offrir un surplus qui // n'existait pas. Ici il reçoit ce qui reste vraiment, pas davantage. const int routeurFantome = qRound(tele.value(varId.toString()).value("allocatedW").toDouble()); QVERIFY2(routeurFantome <= routeurSain, "la charge suivante ne doit pas recevoir PLUS quand un relais a disparu"); // 4. Et la mesure ne prétend rien : rien ne mesure une charge dont le câblage manque. QCOMPARE(ecs.value("measurement").toMap().value("source").toString(), QString("none")); QVERIFY2(!ecs.value("measurement").toMap().contains("powerW"), "une charge au câblage incomplet ne doit pas publier de puissance mesurée"); #endif } /*! * \brief [upstream-fix] La place libre par phase se calcule sur l'état QUI A ÉTÉ TESTÉ. * * \par Le défaut * \c RootMeter::calculateAllowanceAmpere() testait \c currentPhaseB / \c currentPhaseC * (ampères) puis lisait \c currentPowerPhaseB / \c currentPowerPhaseC divisés par 230 * (watts) — asymétrique avec la phase A, qui lit bien l'état qu'elle teste. Sur un compteur * publiant les courants par phase SANS les puissances par phase, la lecture rendait 0 : la * place libre valait le plafond entier et la protection de surcharge n'écrêtait **rien** sur * B et C, sans rien signaler. * * \par Pourquoi aucun test existant ne pouvait le trouver * Le mock pose \c currentPhaseB = \c currentPowerPhaseB / 230 : les deux états y sont * **cohérents par construction**, donc lire l'un ou l'autre y donne le même nombre. C'est * l'angle mort — l'installation de référence n'exerce pas le cas. Ce test le fabrique * explicitement, en désaccordant les deux états, ce qu'aucun chemin normal du mock ne permet. * * \par Ce qui est épinglé * Une phase B tirant 20 A avec une puissance par phase à zéro doit consommer de la marge. * Avant le correctif : 25 A de place libre annoncés sur une phase qui en tire 20. */ void Simulation::testPhaseAllowanceReadsTheStateItTests() { ThingManager *thingManager = NymeaCore::instance()->thingManager(); QUuid meterThingId = addMeter(); QVERIFY(!meterThingId.isNull()); Thing *meterThing = thingManager->findConfiguredThing(meterThingId); QVERIFY(meterThing); meterThing->setStateValue("connected", true); RootMeter *meter = new RootMeter(meterThing); // Compteur qui publie les COURANTS par phase, mais pas les puissances par phase. On le // fabrique en désaccordant les états à la main : le mock, lui, les tient toujours // cohérents, et c'est précisément ce qui rendait le défaut invisible. meterThing->setStateValue("currentPhaseA", 0); meterThing->setStateValue("currentPhaseB", 20); meterThing->setStateValue("currentPhaseC", 0); meterThing->setStateValue("currentPowerPhaseA", 0); meterThing->setStateValue("currentPowerPhaseB", 0); meterThing->setStateValue("currentPowerPhaseC", 0); const uint plafondA = 25; // La phase B tire 20 A sous un plafond de 25 : il reste 5 A, pas 25. QCOMPARE(meter->calculateAllowanceAmpere(Electricity::PhaseB, plafondA), 5.0); // La phase A, elle, était déjà juste. On le vérifie pour que le test dise que les trois // phases se comportent DÉSORMAIS pareil, et pas seulement que B a changé. QCOMPARE(meter->calculateAllowanceAmpere(Electricity::PhaseA, plafondA), 25.0); // Et la phase la plus chargée l'emporte quand on interroge les trois ensemble. QCOMPARE(meter->calculateAllowanceAmpere( Electricity::PhaseA | Electricity::PhaseB | Electricity::PhaseC, plafondA), 5.0); } /*! * \brief [Étape 3] L'autorisation de SOUTIRAGE borne la cascade, et le motif dit laquelle. * * \par Ce qui change * La protection de surcharge ne bornait jusqu'ici que les bornes VE, via le plafond de cycle * d'`evPhaseAllowanceW()`. Une charge de waterfall pouvait donc créer un dépassement que seule * la protection L4 rattrapait — après coup. Le waterfall tient désormais compte des DEUX * ressources : le surplus dit ce qu'on peut dépenser sans acheter, l'autorisation dit ce que * le branchement supporte. * * \par La précédence, qui est le point subtil * \rule{LM-1011} : `DRAW_CAP` est réservé à l'autorisation résiduelle **nulle**. S'il en reste, * fût-ce moins que le plus petit palier, le motif est `BELOW_MIN_POWER` — « il y en a, mais pas * au bon format ». Les deux phrases envoient l'installateur vers des gestes opposés : revoir un * abonnement, ou relire une fiche technique. * * \par Pourquoi le contexte est bâti à la main * La marge est un champ du \c SurplusContext. La poser directement épingle la RÈGLE plutôt que * la chaîne qui produit la marge — celle-ci a ses propres tests * (`testDrawMarginComparesHeadroomsNotCaps`, `testPhaseAllowanceReadsTheStateItTests`). Un test * qui dépendrait des deux échouerait pour deux raisons sans dire laquelle. */ void Simulation::testDrawCapBoundsTheCascade() { #ifndef ETM_ARBITRATOR QSKIP("testDrawCapBoundsTheCascade nécessite ETM_ARBITRATOR."); #else const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-drawcap.json"; QFile::remove(cfgPath); qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); ThingManager *tm = NymeaCore::instance()->thingManager(); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); QUuid rA = addPowerSwitch(1000, 26690); QVERIFY(tm->findConfiguredThing(rA)); const QDateTime t0 = utcDateTime(QDate(2026, 8, 30), QTime(13, 0, 0)); RuleBasedScheduler sched(arb); // Un relais de 1000 W : paliers {0, 1000}. Le cas *fixed* est le plus exigeant, parce que // l'écrêtage à la marge doit RETOMBER sur un palier atteignable — sinon le mécanisme prend // le palier au-dessus, c'est-à-dire exactement le dépassement qu'on vient de refuser. RelayRouter *routeur = new RelayRouter(tm, "cap", "Charge sous plafond", QList({ {rA.toString(), 1000} }), 0, 0, 1, LoadNeeds(), arb); auto decider = [&](double surplusW, const DrawMargin &marge) -> LoadAction { SurplusContext ctx; ctx.timestamp = t0; ctx.meter.exportW = surplusW > 0 ? surplusW : 0.0; ctx.meter.importW = surplusW < 0 ? -surplusW : 0.0; ctx.drawMargin = marge; ctx.loads.append(routeur->toLoadContext(t0)); const Plan plan = sched.getPlan(ctx); for (const LoadAction &a : plan.timeSlots.first().actions) if (a.loadId == routeur->descriptor().id) return a; return LoadAction(); }; DrawMargin aucun; // borne == false : personne ne borne DrawMargin epuise; // autorisation NULLE, et même dépassée epuise.borne = true; epuise.watts = -460; epuise.source = DrawCapSource::Breaker; epuise.phase = 0; DrawMargin reste; // il RESTE 500 W, sous le palier de 1000 reste.borne = true; reste.watts = 500; reste.source = DrawCapSource::Breaker; reste.phase = 1; DrawMargin large; // 5000 W : le plafond existe et ne mord pas large.borne = true; large.watts = 5000; large.source = DrawCapSource::Breaker; large.phase = 0; DrawMargin total; // plafond TOTAL : aucune phase coupable total.borne = true; total.watts = -300; total.source = DrawCapSource::GridOperator; total.phase = -1; // 1. CONTRE-ÉPREUVE — sans plafond, 5000 W de surplus servent le palier. C'est la ligne de // base : si elle tombait, les suivantes ne prouveraient rien sur le plafond. const LoadAction sansPlafond = decider(5000, aucun); QCOMPARE(qRound(sansPlafond.estimatedPowerW), 1000); QCOMPARE(sansPlafond.reason.code, QString(DecisionCode::SurplusSetpoint)); // 2. Un plafond LARGE ne change rien. Un plafond qui mord toujours serait un plafond qui // ne borne pas : il coupe. const LoadAction sousPlafondLarge = decider(5000, large); QCOMPARE(qRound(sousPlafondLarge.estimatedPowerW), 1000); QCOMPARE(sousPlafondLarge.reason.code, QString(DecisionCode::SurplusSetpoint)); // 3. Autorisation ÉPUISÉE : le surplus est pléthorique, et pourtant rien n'est servi. Le // motif porte les quatre grandeurs gravées au §6.3 du design. const LoadAction bloque = decider(5000, epuise); QCOMPARE(qRound(bloque.estimatedPowerW), 0); QCOMPARE(bloque.reason.code, QString(DecisionCode::DrawCap)); QCOMPARE(bloque.reason.params.value("marginW").toInt(), -460); QCOMPARE(bloque.reason.params.value("overshootW").toInt(), 460); QCOMPARE(bloque.reason.params.value("source").toString(), QString("connection")); QCOMPARE(bloque.reason.params.value("phase").toString(), QString("A")); // 4. \rule{LM-1011} — il RESTE 500 W d'autorisation, sous le palier de 1000. Ce n'est PAS // DRAW_CAP : il y a de l'autorisation, elle n'est pas au bon format. const LoadAction pasAuFormat = decider(5000, reste); QCOMPARE(qRound(pasAuFormat.estimatedPowerW), 0); QCOMPARE(pasAuFormat.reason.code, QString(DecisionCode::BelowMinPower)); QVERIFY2(pasAuFormat.reason.code != QString(DecisionCode::DrawCap), "autorisation résiduelle non nulle : LM-1011 impose BELOW_MIN_POWER"); // 5. Plafond TOTAL : aucune phase n'est coupable, et le motif n'en invente pas une. // Publier « A » par défaut serait indiscernable d'une vraie phase A, et enverrait // mesurer au hasard. const LoadAction plafondTotal = decider(5000, total); QCOMPARE(plafondTotal.reason.code, QString(DecisionCode::DrawCap)); QCOMPARE(plafondTotal.reason.params.value("source").toString(), QString("gridOperator")); QVERIFY2(!plafondTotal.reason.params.contains("phase"), "un plafond total n'a pas de phase coupable — ne pas en publier une"); // 6. Le TEXTE publié nomme la ressource et la source. Règle 7-b : le motif nomme le // mécanisme qui a produit l'issue. Dire « surplus » ici enverrait attendre le soleil // devant 5000 W de surplus disponibles. const QString texte = renderFr(bloque.reason, routeur->descriptor().label); QVERIFY2(texte.contains("soutirage", Qt::CaseInsensitive), qUtf8Printable("motif: " + texte)); QVERIFY2(texte.contains("branchement", Qt::CaseInsensitive), qUtf8Printable("motif: " + texte)); QVERIFY2(!texte.contains("surplus", Qt::CaseInsensitive), qUtf8Printable("motif: " + texte)); #endif } void Simulation::testCountsSumToTarget() { #ifndef ETM_ARBITRATOR QSKIP("testCountsSumToTarget nécessite ETM_ARBITRATOR."); #else // [R3] « Chaque watt publié tombe dans exactement un compteur, et le mapping est publié. » // // Sept endroits construisent une LoadAction, et chacun doit renseigner `counts`. Vérifier // cela en RELISANT le code se referait à chaque site ajouté, et échouerait le jour où // quelqu'un en ajoute un huitième. Ce test relit la CHARGE UTILE : il ne fait confiance à // aucun site, il constate le résultat sur toutes les charges du cycle. const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-counts.json"; QFile::remove(cfgPath); qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); ThingManager *tm = NymeaCore::instance()->thingManager(); QUuid meterId = addMeter(); m_experiencePlugin->energyManager()->setRootMeter(meterId); Thing *meter = tm->findConfiguredThing(meterId); QVERIFY(meter); meter->setStateValue("connected", true); QCOMPARE(injectAndWait("NymeaEnergy.SetAcquisitionTolerance", QVariantMap({{"acquisitionTolerance", 0.5}})).toMap() .value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); // Trois mécanismes + une borne : le plus de chemins de construction possible en un cycle. QUuid rA = addPowerSwitch(1000, 26661); QUuid k1 = addPowerSwitch(0, 26662); QUuid k2 = addPowerSwitch(0, 26663); QUuid varId = addEtmVariableLoad(27044); QUuid borneId = addCharger("A", 16, 26657); Thing *borne = tm->findConfiguredThing(borneId); Thing *var = tm->findConfiguredThing(varId); QVERIFY(borne && var && tm->findConfiguredThing(rA)); armerBorne(borne); var->setStateValue("currentPowerW", 0); QVariantMap info; info.insert("evChargerId", borneId); info.insert("chargingMode", "ChargingModeEco"); injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})); const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); meter->setStateValue("currentPower", 0); arb->simulationCallUpdate(t0); QCoreApplication::processEvents(); QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap() .value("params").toMap().value("loadConfigs").toList(); // La borne au rang 1 — sinon, auto-provisionnée EN QUEUE (LM-1209), elle ne voit jamais de // budget et le régime EV_GRID_START n'est pas exercé. C'est la contre-épreuve qui l'a // montré : retirer la ventilation du partage laissait le test PASSER. for (int i = 0; i < l.count(); ++i) { QVariantMap m = l.at(i).toMap(); if (m.value("id").toString() == borneId.toString()) m.insert("priority", 1); l[i] = m; } l.append(QVariantMap{{"id", varId.toString()}, {"label", "Routeur"}, {"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 8}, {"enabled", true}}); l.append(QVariantMap{{"id", "ecs-counts"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"}, {"priority", 9}, {"enabled", true}, {"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, {"powerW", 1000}}}, {"minOnS", 0}, {"minOffS", 0}}); l.append(sgReadyConfigMap("pac-counts", "PAC", k1, k2, 800.0, 1600.0, 0, 10)); QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); // L'identité doit tenir dans TOUS les régimes, pas seulement quand tout est servi : un // surplus large, un surplus qui force le démarrage sous tolérance, et un import qui // déleste. Le troisième est celui où les allocations valent 0 — donc celui où un `counts` // vide passerait inaperçu. struct Regime { int compteurW; const char *quoi; bool attendEvGridStart; }; // EV_GRID_START EN PREMIER, et l'ordre est un fait du test, pas une préférence : dès qu'un // cycle a fait charger la borne, son RECRÉDIT (correction B) porte le budget bien au-dessus // du plancher, et le démarrage sous tolérance ne peut plus se produire. Placé en second, le // régime n'était jamais exercé — la contre-épreuve l'a montré en laissant PASSER un partage // amputé de moitié. const QList regimes = { {-900, "démarrage sous tolérance (EV_GRID_START)", true}, {-9000, "surplus large", false}, {2000, "import — délestage, allocations nulles", false} }; int cycle = 0; for (const Regime &r : regimes) { meter->setStateValue("currentPower", r.compteurW); arb->simulationCallUpdate(t0.addSecs(60 * ++cycle)); QCoreApplication::processEvents(); const QVariantMap snap = arb->loadTelemetry(); const QVariantList charges = snap.value("loads").toList(); QVERIFY2(!charges.isEmpty(), r.quoi); if (r.attendEvGridStart) { bool vu = false; for (const QVariant &v : charges) if (v.toMap().value("decision").toMap().value("code").toString() == QLatin1String(DecisionCode::EvGridStart)) vu = true; QVERIFY2(vu, "le régime EV_GRID_START n'a PAS été exercé — le test ne prouverait " "alors rien du partage entre les deux registres"); } for (const QVariant &v : charges) { const QVariantMap ch = v.toMap(); const QVariantList niveaux = ch.value("levels").toList(); QVERIFY2(!niveaux.isEmpty(), qUtf8Printable(QStringLiteral("%1 : %2 sans levels[]") .arg(r.quoi, ch.value("loadId").toString()))); for (const QVariant &nv : niveaux) { const QVariantMap niveau = nv.toMap(); const QVariantMap counts = niveau.value("counts").toMap(); // 1. TOUJOURS présent, et jamais vide — même quand tout vaut 0. Un counts vide // satisferait l'identité en perdant la destination, sur le cas le plus // fréquent : c'est exactement la panne que R3 existe pour empêcher. QVERIFY2(!counts.isEmpty(), qUtf8Printable(QStringLiteral("%1 : %2 publie un counts VIDE") .arg(r.quoi, ch.value("loadId").toString()))); // 2. Σ counts == targetW, à l'arrondi du watt près. double somme = 0; for (const QVariant &c : counts) somme += c.toDouble(); QVERIFY2(qAbs(somme - niveau.value("targetW").toDouble()) < 1.0, qUtf8Printable(QStringLiteral("%1 : %2 — Σ counts = %3, targetW = %4") .arg(r.quoi, ch.value("loadId").toString()) .arg(somme).arg(niveau.value("targetW").toDouble()))); // 3. Les clés appartiennent au catalogue : une clé inventée ne se glisse pas // dans la charge utile sans que quelqu'un l'ait décidée. for (const QString &k : counts.keys()) QVERIFY2(k == QLatin1String(CountKey::BudgetAllocated) || k == QLatin1String(CountKey::DrawCommitted), qUtf8Printable(QStringLiteral("clé de counts hors catalogue : %1").arg(k))); } } // 4. Et la ventilation se RÉCONCILIE avec les compteurs du cycle — c'est l'identité que // l'app calcule, relue ici sur la charge utile publiée. double surplus = 0, achete = 0; for (const QVariant &v : charges) for (const QVariant &nv : v.toMap().value("levels").toList()) { const QVariantMap c = nv.toMap().value("counts").toMap(); surplus += c.value(QString::fromLatin1(CountKey::BudgetAllocated)).toDouble(); achete += c.value(QString::fromLatin1(CountKey::DrawCommitted)).toDouble(); } QVERIFY2(qAbs(surplus - snap.value("budget").toMap().value("allocatedW").toDouble()) < 1.0, qUtf8Printable(QStringLiteral("%1 : Σ counts(surplus) = %2, budget.allocatedW = %3") .arg(r.quoi).arg(surplus) .arg(snap.value("budget").toMap().value("allocatedW").toDouble()))); QVERIFY2(qAbs(achete - snap.value("draw").toMap().value("committedW").toDouble()) < 1.0, qUtf8Printable(QStringLiteral("%1 : Σ counts(acheté) = %2, draw.committedW = %3") .arg(r.quoi).arg(achete) .arg(snap.value("draw").toMap().value("committedW").toDouble()))); } #endif } void Simulation::testLevelAbsenceAndZeroDifferInMeaning() { #ifndef ETM_ARBITRATOR QSKIP("testLevelAbsenceAndZeroDifferInMeaning nécessite ETM_ARBITRATOR."); #else // [R1] « levels[] ne contient que les passes RÉELLEMENT parcourues. » La règle a deux // moitiés qui se répondent, et publier l'une sans l'autre les rendrait indistinguables : // // niveau ABSENT → la passe n'a pas été parcourue ; // niveau à 0 → la passe A ÉTÉ parcourue, elle n'avait rien à prendre. // // Un zéro forgé à la place d'une absence détruirait la première information ; une absence à // la place d'un zéro détruirait la seconde. Aujourd'hui la passe unique est toujours // parcourue quand la charge est arbitrée — c'est le MODE DÉGRADÉ qui porte le cas « aucune // passe », la planification y étant suspendue. // // [R7] Et `decision.level` accompagne `levels[]` : les deux présents, ou les deux absents. const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-levels.json"; QFile::remove(cfgPath); qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); enableNotifications({"NymeaEnergy"}); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); ThingManager *tm = NymeaCore::instance()->thingManager(); QUuid meterId = addMeter(); m_experiencePlugin->energyManager()->setRootMeter(meterId); Thing *meter = tm->findConfiguredThing(meterId); QVERIFY(meter); meter->setStateValue("connected", true); QUuid varId = addEtmVariableLoad(27045); Thing *var = tm->findConfiguredThing(varId); QVERIFY(var); var->setStateValue("currentPowerW", 0); QVariantList l; l.append(QVariantMap{{"id", varId.toString()}, {"label", "Routeur"}, {"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 1}, {"enabled", true}}); QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); // ---- 1. Passe parcourue AVEC budget : niveau publié, targetW > 0 ----------------------- meter->setStateValue("currentPower", -2000); arb->recordMeterUpdate(t0); arb->simulationCallUpdate(t0); QCoreApplication::processEvents(); QVariantMap ch = telemetrieParId(arb->loadTelemetry()).value(varId.toString()); QCOMPARE(ch.value("levels").toList().count(), 1); QVERIFY(qRound(ch.value("levels").toList().first().toMap().value("targetW").toDouble()) > 0); QCOMPARE(ch.value("decision").toMap().value("level").toString(), QString("comfort")); // ---- 2. Passe parcourue SANS budget : le niveau reste publié, à ZÉRO ------------------- // C'est la moitié qui compte. « Rien à prendre » n'est pas « pas de passe » : la charge a // bien été examinée, le motif le dit, et l'écran doit pouvoir écrire « en attente » plutôt // que de faire disparaître la ligne. var->setStateValue("currentPowerW", 0); meter->setStateValue("currentPower", 3000); // import franc arb->recordMeterUpdate(t0.addSecs(60)); arb->simulationCallUpdate(t0.addSecs(60)); QCoreApplication::processEvents(); ch = telemetrieParId(arb->loadTelemetry()).value(varId.toString()); const QVariantList niv = ch.value("levels").toList(); QCOMPARE(niv.count(), 1); QCOMPARE(qRound(niv.first().toMap().value("targetW").toDouble()), 0); QVERIFY2(!niv.first().toMap().value("counts").toMap().isEmpty(), "un niveau à zéro publie quand même sa destination"); QCOMPARE(ch.value("decision").toMap().value("level").toString(), QString("comfort")); QVERIFY2(ch.value("decision").toMap().value("code").toString() != QString(), "une passe parcourue sans budget porte un MOTIF, pas un silence"); // ---- 3. Aucune passe parcourue : levels[] ABSENT, et decision.level avec lui ----------- // Mode dégradé L2 : la planification est SUSPENDUE. Publier un niveau à zéro y mentirait — // il n'y a pas eu de passe du tout, et « rien à prendre » supposerait qu'on ait regardé. QSignalSpy spy(m_mockTcpServer, &MockTcpServer::outgoingData); arb->evaluateMeterFreshness(t0.addSecs(60 + 95)); // compteur muet > 90 s QCoreApplication::processEvents(); const QVariantList notifs = checkNotifications(spy, "NymeaEnergy.LoadTelemetryChanged"); QVERIFY2(!notifs.isEmpty(), "la bascule en mode dégradé doit notifier"); const QVariantMap dgr = notifs.last().toMap().value("params").toMap(); QCOMPARE(dgr.value("degradedMode").toBool(), true); bool vuUneCharge = false; for (const QVariant &v : dgr.value("loads").toList()) { vuUneCharge = true; const QVariantMap c = v.toMap(); QVERIFY2(!c.contains("levels"), "levels[] publié en mode dégradé : aucune passe n'a pourtant été parcourue"); QVERIFY2(!c.value("decision").toMap().contains("level"), "decision.level sans levels[] : le client devrait deviner à quoi il se rapporte"); } QVERIFY2(vuUneCharge, "le mode dégradé doit publier les charges, sans leurs niveaux"); #endif }