// 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); // ── LA FRONTIÈRE DE PÉRIODE ET L'INSTANT DE MESURE, qui ne sont PAS le même instant ── // // Publier la seule frontière ferait écrire « aujourd'hui » sur des chiffres qui ne parlent // pas de la journée : la baseline se repose à chaque redémarrage de nymead et à chaque // recul de compteur, donc en plein jour. C'est la paire qui dit la vérité, pas le champ. const QDateTime minuit29(QDate(2026, 6, 29), QTime(0, 0)); const QDateTime minuit30(QDate(2026, 6, 30), QTime(0, 0)); // Cas NORMAL — pose à minuit : les deux coïncident, et c'est ce qui rend l'écart lisible // quand il apparaît. QCOMPARE(a.debutPeriode, minuit29); QCOMPARE(a.mesureDepuis, d29_00); // Sans repose, l'instant de mesure NE BOUGE PAS : il date la baseline, pas le calcul. QCOMPARE(b.debutPeriode, minuit29); QCOMPARE(b.mesureDepuis, d29_00); // REPOSE EN PLEIN JOUR (compteur non monotone à 10:00) — LES DEUX DIVERGENT. C'est le cas // qui justifie la paire : la frontière dit toujours minuit, la mesure ne couvre que depuis // 10 h, et un écran qui n'aurait que la frontière l'affirmerait à tort sur la journée. QCOMPARE(d.debutPeriode, minuit29); QCOMPARE(d.mesureDepuis, d29_10); QVERIFY2(d.mesureDepuis > d.debutPeriode, "une repose en plein jour doit se VOIR : sinon les ratios mentent sur leur portée"); // BASCULE DE JOUR — la frontière suit le nouveau jour, et la mesure date du premier calcul // APRÈS minuit, jamais de minuit lui-même. Sur une box qui calcule en continu l'écart est // de quelques secondes ; l'affirmer nul serait une approximation que rien n'oblige à faire. QCOMPARE(e.debutPeriode, minuit30); QCOMPARE(e.mesureDepuis, d30_02); // Et la frontière ne rebouge pas dans la journée. QCOMPARE(f.debutPeriode, minuit30); QCOMPARE(f.mesureDepuis, d30_02); #endif } void Simulation::testLoadConfigRelayRouter() { #ifndef ETM_ARBITRATOR QSKIP("testLoadConfigRelayRouter nécessite ETM_ARBITRATOR."); #else // Chaîne COMPLÈTE rév. 3 : SetConfigs(relays[]) → store.changed → rebuild construit un // RelayRouter → cycle surplus → arrondi scheduler → routeur → commutation relais → currentPowerW. cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arbitrator = dynamic_cast(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); // ---- `draw` : ce qui EXISTE se publie, ce qui n'existe pas s'ABSENTE ------------------- // // Ce test REMPLACE celui qui épinglait l'absence d'`authorisedW`. Celui-là avait un motif // juste — « un plafond de soutirage qui n'existe pas ne doit pas s'afficher » — et il a // survécu à ce motif : le lot délestage a CRÉÉ le plafond, et le champ est devenu une // lecture au lieu d'une forge. Sa garde, elle, ne savait pas que le monde avait changé. // C'est le troisième mode d'érosion d'AGENTS.md, et la parade est ci-dessous. // // ⚠️ **CE TEST DEVIENT FAUX** le jour où l'une de ces deux choses arrive : // · un plafond de soutirage devient publiable SANS être en vigueur — alors la condition // de présence change, et c'est elle qu'il faut réécrire, pas les assertions ; // · `committedW` cesse d'exister à chaque cycle — alors même lui devient conditionnel. // Tant qu'aucune des deux n'est vraie, un échec ici signale une régression, pas un test // périmé. const QVariantMap draw = notif.value("draw").toMap(); // `committedW` est publié TOUJOURS : la grandeur existe à chaque cycle, et zéro en est une // valeur. C'est la moitié « le zéro est une valeur quand la grandeur existe ». QVERIFY2(draw.contains(QStringLiteral("committedW")), "le registre des watts achetés existe à chaque cycle, même vide"); // Et l'autre moitié : ici AUCUN plafond n'est réglé (`phasePowerLimit` vaut 0, contrat // amont : la protection est désactivée). Les quatre champs qui décrivent l'autorisation // doivent donc être ABSENTS — pas à zéro. Un `authorisedW: 0` avec un `binding` par défaut // dirait « aucune autorisation, imposée par le branchement » là où la vérité est « aucun // plafond n'existe ». Les deux affirmations commandent des gestes opposés. for (const QString &champ : {QStringLiteral("authorisedW"), QStringLiteral("remainingW"), QStringLiteral("binding"), QStringLiteral("perPhaseBound")}) QVERIFY2(!draw.contains(champ), qUtf8Printable(QStringLiteral("sans plafond réglé, `%1` doit être ABSENT et " "non nul — l'absence dit qu'aucun plafond " "n'existe").arg(champ))); // Le relais tire déjà ses 1000 W depuis le cycle précédent : ils lui sont RECRÉDITÉS avant // arrondi (anti-clignotement). Sans ce terme publié, l'identité serait fausse ici même. QCOMPARE(qRound(budget.value("recreditedW").toDouble()), 1000); // L'invariant publié : surplus + recrédit − alloué == restant. Le recrédit y figure parce // que sans lui l'identité serait FAUSSE dès qu'une charge consomme déjà — c'est-à-dire dans // le cas normal — et le client chercherait un écart inexplicable. QCOMPARE(qRound(budget.value("surplusW").toDouble() + budget.value("recreditedW").toDouble() - budget.value("allocatedW").toDouble()), qRound(budget.value("remainingW").toDouble())); const QVariantList loads = notif.value("loads").toList(); QCOMPARE(loads.size(), 3); // l'EV n'y est pas : décidé en amont QVariantMap parId; double sommeAllouee = 0; for (const QVariant &v : loads) { const QVariantMap e = v.toMap(); parId.insert(e.value("loadId").toString(), e); sommeAllouee += e.value("allocatedW").toDouble(); } // La somme des allocations RETOMBE sur budget.allocatedW : sans quoi un client qui // additionne les lignes obtiendrait un total en désaccord avec l'en-tête. QCOMPARE(qRound(sommeAllouee), qRound(budget.value("allocatedW").toDouble())); const QVariantMap eVar = parId.value(varId.toString()).toMap(); const QVariantMap ePac = parId.value("pac-telemetrie").toMap(); const QVariantMap eRelay = parId.value("ecs-relais-telemetrie").toMap(); QVERIFY(!eVar.isEmpty() && !ePac.isEmpty() && !eRelay.isEmpty()); // Union discriminée de mécanisme : chaque variante sort avec SA charge utile, et seulement // la sienne (LM-300/302 appliqués au sens sortant). QCOMPARE(eVar.value("mechanism").toMap().value("kind").toString(), QString("variable")); QCOMPARE(qRound(eVar.value("mechanism").toMap().value("setpointW").toDouble()), 3000); QCOMPARE(qRound(eVar.value("mechanism").toMap().value("percent").toDouble()), 100); QVERIFY(!eVar.value("mechanism").toMap().contains("state")); // charge utile d'un AUTRE mécanisme QVERIFY(!eVar.value("mechanism").toMap().contains("stageW")); QCOMPARE(ePac.value("mechanism").toMap().value("kind").toString(), QString("sgReady")); QCOMPARE(ePac.value("mechanism").toMap().value("state").toInt(), 4); QVERIFY(!ePac.value("mechanism").toMap().contains("stageW")); QCOMPARE(eRelay.value("mechanism").toMap().value("kind").toString(), QString("relay")); QCOMPARE(qRound(eRelay.value("mechanism").toMap().value("stageW").toDouble()), 1000); QVERIFY(!eRelay.value("mechanism").toMap().contains("percent")); // Motifs : des CODES, avec leurs paramètres numériques. Jamais une phrase. QCOMPARE(eVar.value("decision").toMap().value("code").toString(), QString(DecisionCode::SurplusSetpoint)); QCOMPARE(eVar.value("decision").toMap().value("params").toMap().value("stepped").toBool(), false); QCOMPARE(ePac.value("decision").toMap().value("code").toString(), QString(DecisionCode::SgForced)); QCOMPARE(eRelay.value("decision").toMap().value("code").toString(), QString(DecisionCode::SurplusSetpoint)); QCOMPARE(eRelay.value("decision").toMap().value("params").toMap().value("stepped").toBool(), true); // Verrous en SECONDES restantes, pas en watts. Les deux charges viennent de commuter à t0, // donc leur fenêtre est pleine. Le routeur PV n'a AUCUN verrou côté moteur : la clé est // absente, ce qui est la vérité — et non un verrou à zéro, qui se lirait « fenêtre expirée ». // La PAC vient de commuter en 4 : sa fenêtre repart pleine. QCOMPARE(ePac.value("lock").toMap().value("kind").toString(), QString("minStateHold")); QCOMPARE(ePac.value("lock").toMap().value("remainingS").toInt(), 300); // Le relais, lui, a commuté au cycle PRÉCÉDENT (t0) : 301 s plus tard son minOn de 300 s a // EXPIRÉ, et la clé disparaît. Un verrou qui ne s'efface pas serait un verrou permanent — // le défaut d'ECS-412 —, et l'app afficherait une charge éternellement bloquée. QVERIFY2(!eRelay.contains("lock"), "le verrou minOn du relais aurait dû expirer"); QVERIFY(!eVar.contains("lock")); // Disponibilité, et absence de code de défaut sur une installation saine. for (const QVariant &v : loads) { QCOMPARE(v.toMap().value("available").toBool(), true); QVERIFY(!v.toMap().contains("faultCode")); } // ---- 2. Snapshot ≡ notification -------------------------------------------------------- // Le Get ne recalcule pas : il rend l'objet publié. Un recalcul donnerait des verrous // rafraîchis sous un timestamp périmé — un état qui n'a jamais existé. resp = injectAndWait("NymeaEnergy.GetLoadTelemetry"); QCOMPARE(resp.toMap().value("params").toMap(), notif); // ---- 5. ZÉRO langue naturelle dans la charge utile ------------------------------------- // Contrôle MÉCANIQUE : toute chaîne émise est ASCII et sans espace. Une phrase française en // porterait forcément (accents et/ou espaces). Le libellé de la charge, lui, n'entre pas // dans la charge utile : il est substitué au RENDU, côté journal. std::function 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 } /*! * \brief [ECS-415] Un refus doit se DISTINGUER d'une application, par la valeur de retour. * * \par Le défaut * `SgReadyAdapter::applyAction()` renvoyait, en cas de refus par le verrou `minStateHold`, * **l'action demandée telle quelle**. Un appelant qui relisait `state` y retrouvait l'état * qu'il venait de demander, et concluait au succès — alors que rien n'avait été écrit et que * les contacts n'avaient pas bougé. Deux autres sorties avaient le même travers (motif vide, * état non déclaré), et les deux qui corrigeaient bien `state` (\c !m_usable, \c m_faulted) * laissaient `estimatedPowerW` à la valeur de l'appelant. * * \par Pourquoi c'est plus grave qu'un délestage manqué * N'IMPORTE QUELLE descente d'état pouvait échouer en silence, et `available` restait vrai : * la charge avait l'air saine pendant que la PAC tirait 3 kW. C'est la famille d'ECS-111 — * une issue indiscernable de son contraire — appliquée cette fois au retour d'une commande. * * \par Ce qui est épinglé * Le refus et l'application se lisent DÉSORMAIS par le même test, et le retour décrit toujours * l'état réellement tenu, puissance estimée comprise. */ void Simulation::testARefusedStateIsDistinguishableFromAnApplied() { #ifndef ETM_ARBITRATOR QSKIP("testARefusedStateIsDistinguishableFromAnApplied nécessite ETM_ARBITRATOR."); #else const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-415.json"; QFile::remove(cfgPath); qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); ThingManager *tm = NymeaCore::instance()->thingManager(); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); QUuid k1 = addPowerSwitch(0, 26696); QUuid k2 = addPowerSwitch(0, 26697); Thing *tK1 = tm->findConfiguredThing(k1); Thing *tK2 = tm->findConfiguredThing(k2); QVERIFY(tK1 && tK2); const QDateTime t0 = utcDateTime(QDate(2026, 8, 30), QTime(13, 0, 0)); const QHash> 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} }); // Contacts fermés : l'adaptateur neuf LIT l'état 4 (ECS-411-b). Le verrou minStateHold est // armé à froid (ECS-412), donc toute descente est refusée ce cycle. tK1->setStateValue("power", true); tK2->setStateValue("power", true); SgReadyAdapter *pac = new SgReadyAdapter(tm, "pac-415", "PAC refus lisible", encodage, estim, 300, 1, arb); QCOMPARE(pac->currentState(), 4); LoadAction descente; descente.loadId = "pac-415"; descente.kind = LoadAction::State; descente.state = 2; descente.estimatedPowerW = 0; // ce que l'APPELANT croit — c'est le piège descente.reason = { DecisionCode::SgNormal, {{"budgetW", 0}} }; // --- 1. REFUSÉ par le verrou : le retour doit décrire l'état TENU, pas l'état demandé --- const LoadAction refuse = pac->applyAction(descente, t0); QCOMPARE(refuse.state, 4); QCOMPARE(qRound(refuse.estimatedPowerW), 3000); QCOMPARE(tK1->stateValue("power").toBool(), true); QCOMPARE(tK2->stateValue("power").toBool(), true); QCOMPARE(pac->currentState(), 4); // --- 2. FORCÉ : le même appel, verrou franchi, doit se lire autrement ------------------- // C'est la moitié qui donne son sens à la première : si les deux retours étaient égaux, // épingler la valeur du refus ne prouverait rien. LoadAction forcee = descente; forcee.force = true; const LoadAction applique = pac->applyAction(forcee, t0); QCOMPARE(applique.state, 2); QCOMPARE(qRound(applique.estimatedPowerW), 0); QCOMPARE(tK1->stateValue("power").toBool(), false); QCOMPARE(tK2->stateValue("power").toBool(), false); QCOMPARE(pac->currentState(), 2); // --- 3. IDEMPOTENCE : l'état est bien celui demandé, mais la puissance vient de // l'adaptateur. Un appelant qui remonte à 4 avec 0 W dans son enveloppe ne doit pas // se voir rendre son propre zéro sur une PAC qui en tirerait 3000. LoadAction remonte; remonte.loadId = "pac-415"; remonte.kind = LoadAction::State; remonte.state = 2; // état DÉJÀ tenu remonte.estimatedPowerW = 12345; // valeur absurde de l'appelant remonte.reason = { DecisionCode::SgNormal, {{"budgetW", 0}} }; const LoadAction idem = pac->applyAction(remonte, t0.addSecs(1)); QCOMPARE(idem.state, 2); QCOMPARE(qRound(idem.estimatedPowerW), 0); #endif } /*! * \brief [Étape 4] L4 déleste une PAC en la faisant CHANGER D'ÉTAT, et dit qu'il a forcé. * * \par Règles couvertes * \rule{LM-1211} (délestage en ordre inverse du rang), \rule{LM-1211-a} (un seul commandeur par * organe), \rule{LM-1211-b} (le franchissement se CONSTATE), \rule{LM-1211-d} (délester peut * être changer d'état). * * \par Le premier cas où délester ne veut pas dire retirer des watts * Une PAC ne se module pas. Le bornage de la cascade (\rule{LM-1210}) ne peut rien contre une * PAC déjà en état 4 : il empêche d'ALLOUER au-delà du plafond, il ne retire rien à personne. * Seul le délestage peut la faire redescendre — et c'est un changement d'ÉTAT. * * \par C'est aussi le cas où le franchissement de verrou prend son sens * `minStateHold` est le verrou qui s'oppose à cette descente. Le plan le constate et publie * `LOCK_MIN_STATE_HOLD` ; le disjoncteur, lui, n'attend pas 300 secondes. L4 franchit, et **le * dit** — un verrou franchi en silence, c'est un compresseur qui court-cycle sans que personne * ne sache pourquoi. * * \par Les trois cycles sont tous NÉCESSAIRES, et chacun a été mesuré avant d'être asserté * Deux versions antérieures de ce test passaient **sans rien prouver**, parce que leurs * prémisses étaient supposées. Elles sont donc vérifiées ici, une par une : * 1. l'armement à froid (ECS-412) **gèle la PAC à l'état 2** — on ne peut pas monter à 4 au * premier cycle, et croire le contraire faisait tester une PAC qui n'a jamais chauffé ; * 2. une fois le verrou expiré, le surplus la porte en état 4 et **réarme** le verrou ; * 3. c'est seulement là que le dépassement rencontre un verrou actif. */ void Simulation::testL4ShedsAPacAndSaysItForcedTheLock() { #ifndef ETM_ARBITRATOR QSKIP("nécessite ETM_ARBITRATOR."); #else const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-shed.json"; QFile::remove(cfgPath); qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); ThingManager *tm = NymeaCore::instance()->thingManager(); QUuid meterId = addMeter(); m_experiencePlugin->energyManager()->setRootMeter(meterId); Thing *meter = tm->findConfiguredThing(meterId); QVERIFY(meter); meter->setStateValue("connected", true); QUuid k1 = addPowerSwitch(0, 26694); QUuid k2 = addPowerSwitch(0, 26695); Thing *relais1 = tm->findConfiguredThing(k1); Thing *relais2 = tm->findConfiguredThing(k2); QVERIFY(relais1 && relais2); LoadConfigStore *store = new LoadConfigStore(arb); arb->setLoadConfigStore(store); const int minStateHoldS = 300; LoadConfigs cfgs; cfgs.append(LoadConfig::fromMap(QVariantMap{ {"id", "pac-delestee"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"}, {"priority", 2}, {"enabled", true}, {"domain", "hvac"}, {"sgReady", QVariantMap{ {"minStateHoldS", minStateHoldS}, {"states", QVariantList() << QVariantMap{{"state", 1}, {"relays", QVariantList() << k1.toString()}, {"estimatedPowerW", 0.0}} << QVariantMap{{"state", 2}, {"relays", QVariantList()}, {"estimatedPowerW", 0.0}} << QVariantMap{{"state", 3}, {"relays", QVariantList() << k2.toString()}, {"estimatedPowerW", 800.0}} << QVariantMap{{"state", 4}, {"relays", QVariantList() << k1.toString() << k2.toString()}, {"estimatedPowerW", 3000.0}}}}}})); QString err; QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8()); QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}})).toMap() .value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); auto cycle = [&](const QDateTime &at) { arb->simulationCallUpdate(at); QTest::qWait(50); QCoreApplication::processEvents(); return telemetrieParId(arb->loadTelemetry()).value("pac-delestee"); }; // Date FIXE, et elle le redevient : `setPhasePowerLimit()` lançait un cycle d'arbitrage // complet à l'horloge murale, faute de la garde ENERGY_SIMULATION que ses voisins ont. // L'armement paresseux (ECS-412) figeait alors `m_lastSwitch` sur l'heure qu'il était, et // ce scénario se déroulait « avant » son propre armement — il passait ou échouait SELON // L'HEURE DE LA JOURNÉE. La garde rendue, une date fixe redevient préférable : elle rend // les journaux reproductibles. const QDateTime t0 = utcDateTime(QDate(2026, 8, 30), QTime(13, 0, 0)); meter->setStateValue("currentPhaseA", 0); meter->setStateValue("currentPhaseB", 0); meter->setStateValue("currentPhaseC", 0); meter->setStateValue("currentPower", -6000); QVariantMap pac = cycle(t0); // PRÉMISSE 1 — l'armement à froid gèle la PAC à l'état 2. Le surplus est pléthorique et // n'y change rien : c'est ECS-412, et c'est ce que l'app verra après chaque redémarrage. QCOMPARE(pac.value("mechanism").toMap().value("state").toInt(), 2); QCOMPARE(pac.value("decision").toMap().value("code").toString(), QString(DecisionCode::LockMinStateHold)); const QDateTime t1 = t0.addSecs(3600); // largement au-delà de l'armement à froid pac = cycle(t1); // PRÉMISSE 2 — verrou expiré, le surplus porte la PAC en état 4, et le verrou SE RÉARME. // Sans une PAC réellement à 3 kW, le délestage n'aurait rien à retirer. QCOMPARE(pac.value("mechanism").toMap().value("state").toInt(), 4); QCOMPARE(pac.value("decision").toMap().value("code").toString(), QString(DecisionCode::SgForced)); QCOMPARE(relais1->stateValue("power").toBool(), true); QCOMPARE(relais2->stateValue("power").toBool(), true); QCOMPARE(pac.value("lock").toMap().value("remainingS").toInt(), minStateHoldS); const QDateTime t2 = t1.addSecs(10); meter->setStateValue("currentPhaseA", 30); meter->setStateValue("currentPower", 6900); pac = cycle(t2); // 1. LA PAC EST REDESCENDUE, et seul L4 pouvait le faire : à t2 le verrou court encore. QCOMPARE(pac.value("mechanism").toMap().value("state").toInt(), 2); QCOMPARE(relais1->stateValue("power").toBool(), false); QCOMPARE(relais2->stateValue("power").toBool(), false); // 2. Le motif publié est celui du DÉLESTAGE, pas celui du plan. La charge utile doit dire // ce qui a été exécuté : le plan n'a pas commandé cette charge — L4 l'a REVENDIQUÉE et // le dispatch l'a sautée. Publier LOCK_MIN_STATE_HOLD montrerait une PAC « maintenue » // que le matériel vient justement de couper. const QVariantMap decision = pac.value("decision").toMap(); QCOMPARE(decision.value("code").toString(), QString(DecisionCode::DrawCap)); const QVariantMap p = decision.value("params").toMap(); QCOMPARE(p.value("shed").toBool(), true); QCOMPARE(p.value("source").toString(), QString("connection")); QCOMPARE(p.value("phase").toString(), QString("A")); QCOMPARE(p.value("overshootW").toInt(), 1150); QCOMPARE(p.value("marginW").toInt(), -1150); // Les watts RÉELLEMENT rendus : la puissance allouée de l'état quitté. QCOMPARE(p.value("shedW").toInt(), 3000); // 3. LE FRANCHISSEMENT SE DIT, et il a été CONSTATÉ, jamais présumé : le délestage tente // d'abord SANS forcer et ne pose ce marqueur que si le verrou a réellement refusé. // Le déduire d'un verrou simplement ACTIF serait faux — un verrou peut être armé et // permettre malgré tout le geste demandé. Le marqueur annoncerait alors un court-cycle // qui n'a pas eu lieu, et le premier compresseur réellement maltraité passerait // inaperçu au milieu des fausses alertes. // // Ce constat n'est possible que depuis \rule{ECS-415} : tant que le refus renvoyait // l'action DEMANDÉE, il se lisait comme un succès, et le délestage n'a jamais forcé — // il publiait 3 kW rendus par une PAC restée en état 4. QCOMPARE(p.value("minOnBypassed").toBool(), true); // 4. Le TEXTE nomme la ressource et la source, jamais le surplus : ici il n'y en a pas, et // accuser le surplus enverrait attendre le soleil devant un disjoncteur qui sature. DecisionReason motif(DecisionCode::DrawCap, p); const QString texte = renderFr(motif, "PAC"); QVERIFY2(texte.contains("soutirage", Qt::CaseInsensitive), qUtf8Printable("motif: " + texte)); QVERIFY2(texte.contains("branchement", Qt::CaseInsensitive), qUtf8Printable("motif: " + texte)); QVERIFY2(!texte.contains("surplus", Qt::CaseInsensitive), qUtf8Printable("motif: " + texte)); #endif } /*! * \brief [§10 étape B] `draw` publie l'autorisation — et la RÉSERVE BATTERIE ne l'annule pas. * * \par Les quatre champs se publient parce qu'ils EXISTENT * `authorisedW`, `remainingW`, `binding` et `perPhaseBound` étaient absents parce qu'aucun * plafond de soutirage n'existait. Le lot délestage en a créé un ; ils se LISENT désormais, au * lieu d'être forgés. Leur condition de présence est donc « un plafond est en vigueur », et non * « le transport du §14a existe » — le refus d'origine visait la forge, pas la lecture. * * \par Ce que ce test épingle vraiment : \rule{LM-1203-b} vue depuis la frontière * Sous la réserve batterie, le budget de surplus est **annulé** — pas réduit. Mais la réserve * n'annule QUE le surplus : l'**autorisation de soutirage** est une contrainte physique du * branchement, et aucune règle de stockage ne la modifie. Un client verra donc * `budget.remainingW == 0` **et** `draw.remainingW != 0` dans la même trame, et il aura raison * de le signaler si personne ne le lui a dit. C'est dit — ici, et dans `INTERFACE.md`. * * \warning **Ce test devient faux** le jour où la réserve batterie borne aussi le soutirage — * c'est-à-dire le jour où l'on déciderait qu'une batterie basse interdit d'acheter au réseau. * Ce serait une décision de modèle, pas une correction : c'est alors l'assertion 3 qu'il faut * réécrire, pas le moteur. */ void Simulation::testDrawAuthorisationSurvivesTheBatteryReserve() { #ifndef ETM_ARBITRATOR QSKIP("nécessite ETM_ARBITRATOR."); #else cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); RuleBasedScheduler sched(arb); const QDateTime t0 = utcDateTime(QDate(2026, 8, 31), QTime(13, 0, 0)); auto planifier = [&](bool batterieBasse, bool avecPlafond) { SurplusContext ctx; ctx.timestamp = t0; ctx.meter.exportW = 5000; // 5 kW de surplus disponibles if (avecPlafond) { ctx.drawMargin.borne = true; ctx.drawMargin.watts = 3450; // 25 A − 10 A tirés, sur 230 V ctx.drawMargin.source = DrawCapSource::Breaker; ctx.drawMargin.phase = 0; // phase A : plafond PAR PHASE } if (batterieBasse) { ctx.battery.present = true; ctx.battery.socPercent = 10; // sous le seuil par défaut (20 %) } return sched.getPlan(ctx); }; // 1. SANS plafond réglé : les quatre champs restent SANS OBJET. `capBorne` est ce qui // décide de leur présence à la frontière — et il ne se déduit pas d'une valeur nulle. const Plan sansPlafond = planifier(false, false); QCOMPARE(sansPlafond.draw.capBorne, false); // 2. AVEC plafond, hors réserve : l'autorisation se lit, rien n'est engagé, tout reste. const Plan nominal = planifier(false, true); QCOMPARE(nominal.draw.capBorne, true); QCOMPARE(qRound(nominal.draw.authorisedW), 3450); QCOMPARE(qRound(nominal.draw.committedW), 0); QCOMPARE(qRound(nominal.draw.remainingW), 3450); QCOMPARE(nominal.draw.bindingCode, QString("connection")); QCOMPARE(nominal.draw.perPhaseBound, true); // L'identité `authorisedW − committedW == remainingW` N'EST PAS ASSERTÉE ICI, et c'est // délibéré : le moteur l'établit sur UNE LIGNE (`rulebasedscheduler.cpp`, `draw.remainingW = // draw.authorisedW - draw.committedW`). L'asserter reviendrait à relire cette affectation — // les deux membres viennent de la même source, l'identité se referme sur elle-même, et le // contrôle n'attraperait rien. // // Ce qui contrôle réellement, ce sont les TROIS attentes littérales ci-dessus : elles // viennent du scénario, pas du moteur. Relevé au balayage du 2026-09-03. // // ⚠️ À ne pas confondre avec l'identité du BUDGET (`surplus + recrédit − alloué == // restant`), qui, elle, croise DEUX calculs indépendants — `remainingSurplusW` filé le long // de la cascade contre `allocatedW` accumulé en trois sites distincts. Celle-là attrape un // site qui décrémenterait sans accumuler, et mérite d'être assertée. // 3. SOUS LA RÉSERVE — le cœur du test. Le surplus est ANNULÉ, l'autorisation INTACTE. const Plan sousReserve = planifier(true, true); QCOMPARE(qRound(sousReserve.budget.remainingW), 0); // la réserve annule le surplus… QCOMPARE(qRound(sousReserve.draw.remainingW), 3450); // …et ne touche pas au soutirage QCOMPARE(sousReserve.draw.bindingCode, QString("connection")); // 4. Et un plafond TOTAL se distingue d'un plafond par phase. Sans cette distinction, un // installateur devant une triphasée irait mesurer une phase que rien ne désigne. SurplusContext ctxTotal; ctxTotal.timestamp = t0; ctxTotal.meter.exportW = 5000; ctxTotal.drawMargin.borne = true; ctxTotal.drawMargin.watts = 2000; ctxTotal.drawMargin.source = DrawCapSource::GridOperator; ctxTotal.drawMargin.phase = -1; // total : aucune phase coupable const Plan total = sched.getPlan(ctxTotal); QCOMPARE(total.draw.perPhaseBound, false); QCOMPARE(total.draw.bindingCode, QString("gridOperator")); #endif } /*! * \brief [§10 étape C] L'avancement est une DIFFÉRENCE depuis une base, et le régime se publie. * * \par Règles couvertes * \rule{LM-1012-b} (l'avancement est une différence), \rule{LM-1012-b-i} (base à l'ouverture de * l'obligation), \rule{LM-1012-b-ii} (recapture sur recul franc), \rule{LM-1014-c} (le cumul * survit au changement de RÉGIME), \rule{LM-1014-c-i} (remise à zéro à la période), * \rule{LM-1014-c-ii} (\c measuredSince garde le premier instant). * * \par Le piège, mesuré sur le banc le 2026-08-31 * `sessionEnergy` est l'ACCUMULATEUR du plugin V2C, pas un compteur remis à zéro pour nous : * l'écart avec `chargeEnergy` est constant au dix-millième (7,2044 kWh sur deux échantillons à * deux heures d'écart), parce qu'il porte les incréments d'avant la dernière remise à zéro de la * borne. **Le lire directement ferait croire toute obligation tenue de 7,2 kWh dès le premier * cycle** — un nombre crédible et faux que rien ne signalerait. * * \par Règle couverte * \rule{LM-1012-c} — le RÉGIME d'avancement se publie, porté par le niveau, et * \c deliveredWh est absent sous \c unmeasurable plutôt que nul. * * \par Les deux questions que la base pose, et leurs réponses * 1. **Quand est-elle prise ?** À l'ouverture de l'OBLIGATION, jamais au démarrage de nymead : * sinon un redémarrage en pleine session réinitialise l'avancement sur une valeur déjà * accumulée, et la recharge de nuit relue au matin repart de zéro. * 2. **Que devient-elle si `sessionWh` recule ?** Le plugin remet son accumulateur à la valeur * brute à chaque nouvelle session : la base est RECAPTURÉE, sinon la différence devient * négative. On ne refait pas la détection de remise à zéro de la BORNE — elle vit dans le * plugin, à 30 s, là où le moteur travaille à la minute et raterait des cycles entiers. * * \warning **Ce test devient faux** si `sessionEnergy` cesse d'être un accumulateur de plugin * pour devenir un compteur remis à zéro par session : la recapture serait alors sans objet, et * c'est l'assertion 4 qu'il faudrait retirer, pas le moteur. */ void Simulation::testEcoProgressBaseAndRegime() { #ifndef ETM_ARBITRATOR QSKIP("nécessite ETM_ARBITRATOR."); #else cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); const QDateTime t0 = utcDateTime(QDate(2026, 8, 31), QTime(13, 0, 0)); LoadContext lc; lc.id = "borne-eco"; lc.needs.minEnergyWhPerDay = 10000; // obligation : 10 kWh par jour // 1. SANS obligation déclarée, il n'y a rien à publier — pas un avancement à zéro. Une // charge sans obligation éco ne porte pas d'entrée « eco » : l'absence EST l'information. LoadContext sansObligation = lc; sansObligation.needs.minEnergyWhPerDay = 0; QVERIFY2(arb->ecoProgress(sansObligation, t0).regime.isEmpty(), "sans obligation, aucun régime : le niveau éco n'existe pas"); // 2. Borne qui ne COMPTE PAS : régime `unmeasurable`, et `deliveredWh` SANS OBJET. // C1 de LM-1009 — une dégradation ne doit jamais se lire comme un avancement nul, sinon // l'échéance continue de s'afficher comme si elle tenait. LoadContext nonMesurable = lc; nonMesurable.telemetry.sessionMeasurable = false; nonMesurable.telemetry.sessionWh = 7204; // valeur présente mais SANS SIGNIFICATION const EcoProgress sansMesure = arb->ecoProgress(nonMesurable, t0); QCOMPARE(sansMesure.regime, QString(ProgressRegime::Unmeasurable)); QCOMPARE(sansMesure.delivered, false); QVERIFY2(qFuzzyIsNull(sansMesure.deliveredWh), "sous unmeasurable, deliveredWh n'est pas publié — et vaut donc 0 en interne"); // 3. LE PIÈGE LUI-MÊME. L'accumulateur vaut déjà 7204 Wh à l'ouverture de l'obligation. // L'avancement doit être NUL, pas 7204 : la base est prise ICI. LoadContext mesuree = lc; mesuree.telemetry.sessionMeasurable = true; mesuree.telemetry.sessionWh = 7204; const EcoProgress ouverture = arb->ecoProgress(mesuree, t0); QCOMPARE(ouverture.regime, QString(ProgressRegime::Measured)); QCOMPARE(ouverture.delivered, true); QCOMPARE(qRound(ouverture.deliveredWh), 0); QCOMPARE(qRound(ouverture.targetWh), 10000); // …et l'avancement suit la DIFFÉRENCE, pas la valeur lue. mesuree.telemetry.sessionWh = 9204; QCOMPARE(qRound(arb->ecoProgress(mesuree, t0).deliveredWh), 2000); // 4. RECUL FRANC : le plugin a remis son accumulateur à la valeur brute d'une nouvelle // session. La base est recapturée — sans quoi la différence serait NÉGATIVE. mesuree.telemetry.sessionWh = 120; QCOMPARE(qRound(arb->ecoProgress(mesuree, t0).deliveredWh), 0); mesuree.telemetry.sessionWh = 620; QCOMPARE(qRound(arb->ecoProgress(mesuree, t0).deliveredWh), 500); // 5. Recul INFIME : c'est du bruit de lecture, pas une nouvelle session. La base ne bouge // pas — la recapturer ici perdrait l'avancement acquis à chaque frémissement du compteur. mesuree.telemetry.sessionWh = 610; const EcoProgress bruit = arb->ecoProgress(mesuree, t0); QCOMPARE(qRound(bruit.deliveredWh), 490); QVERIFY2(bruit.deliveredWh >= 0, "un avancement ne peut pas être négatif"); // 7. BASCULE DE PÉRIODE — le cas que la sonde a révélé, et qu'aucune lecture du code // n'aurait montré. À l'instant de l'échéance quotidienne, une NOUVELLE période s'ouvre. // Sans reprise de la base ici, le lendemain hériterait de l'énergie de la veille et se // croirait tenu sans avoir rien reçu. LoadContext quotidienne; quotidienne.id = "borne-quotidienne"; quotidienne.needs.minEnergyWhPerDay = 2000; quotidienne.needs.dailyDeadline = "07:00"; quotidienne.telemetry.sessionMeasurable = true; const QDateTime veille = utcDateTime(QDate(2026, 8, 31), QTime(6, 0, 0)); // vise 07:00 J const QDateTime apres = utcDateTime(QDate(2026, 8, 31), QTime(7, 30, 0)); // vise 07:00 J+1 quotidienne.telemetry.sessionWh = 1000; QCOMPARE(qRound(arb->ecoProgress(quotidienne, veille).deliveredWh), 0); // base prise ici quotidienne.telemetry.sessionWh = 2800; QCOMPARE(qRound(arb->ecoProgress(quotidienne, veille).deliveredWh), 1800); // L'échéance a basculé : la période est neuve, l'avancement repart de zéro — l'énergie de // la veille n'est pas portée au crédit du jour suivant. QCOMPARE(qRound(arb->ecoProgress(quotidienne, apres).deliveredWh), 0); quotidienne.telemetry.sessionWh = 3300; QCOMPARE(qRound(arb->ecoProgress(quotidienne, apres).deliveredWh), 500); // 8. BASCULE DE RÉGIME DANS LA MÊME PÉRIODE — measured → unmeasurable → measured. // C'est le motif d'une V2C dont le lien Modbus bat : chaque perte et retour fait // basculer le régime DEUX fois. L'énergie livrée pendant les tronçons mesurés // antérieurs ne doit pas disparaître — sinon l'écran sous-estime, et surtout la passe // éco RACHÈTE ce qui a déjà été livré. LoadContext bat; bat.id = "borne-intermittente"; bat.needs.minEnergyWhPerDay = 4000; bat.needs.dailyDeadline = "07:00"; bat.telemetry.sessionMeasurable = true; bat.telemetry.sessionWh = 1000; QCOMPARE(qRound(arb->ecoProgress(bat, t0).deliveredWh), 0); // base prise ici bat.telemetry.sessionWh = 2500; QCOMPARE(qRound(arb->ecoProgress(bat, t0).deliveredWh), 1500); // 1500 Wh livrés // Le lien tombe : plus rien ne mesure. bat.telemetry.sessionMeasurable = false; QCOMPARE(arb->ecoProgress(bat, t0).regime, QString(ProgressRegime::Unmeasurable)); // Le lien revient, dans la MÊME période. Les 1500 Wh déjà livrés doivent SURVIVRE. bat.telemetry.sessionMeasurable = true; bat.telemetry.sessionWh = 2500; QCOMPARE(qRound(arb->ecoProgress(bat, t0).deliveredWh), 1500); bat.telemetry.sessionWh = 3000; const EcoProgress reprise = arb->ecoProgress(bat, t0); QCOMPARE(qRound(reprise.deliveredWh), 2000); // 1500 (tronçon clos) + 500 (en cours) // TRANCHÉ — `measuredSince` garde le PREMIER instant mesuré, et l'intermittence se dit à // part. Redater à chaque retour effacerait l'histoire au lieu de la raconter ; ne rien dire // ferait croire à une continuité que le battement dément. L'écran peut alors écrire // « mesuré PAR INTERMITTENCE depuis 09:14 », qui est la phrase vraie. QCOMPARE(reprise.measuredSince, t0); QCOMPARE(reprise.measuredGaps, 1); // TRANCHÉ — à la BASCULE DE PÉRIODE, le cumul repart de zéro comme tout ce qui est rapporté // à la période (LM-1014-b), et les trous avec lui : ils comptaient les interruptions de la // période close, pas de la neuve. // Au-delà de l'échéance visée depuis t0 (2026-08-31 13:00 → 2026-09-01 07:00) : c'est ce // franchissement qui ouvre une période neuve. Un instant qui viserait la MÊME échéance ne // ferait rien basculer — première version de ce test, qui échouait pour cette raison. const QDateTime apresEcheance = utcDateTime(QDate(2026, 9, 1), QTime(8, 0, 0)); const EcoProgress neuve = arb->ecoProgress(bat, apresEcheance); QCOMPARE(qRound(neuve.deliveredWh), 0); QCOMPARE(neuve.measuredGaps, 0); // 6. L'obligation retirée LIBÈRE la base : une nouvelle obligation repart d'un repère neuf, // et n'hérite pas de l'avancement d'une obligation qui n'existe plus. LoadContext retiree = mesuree; retiree.needs.minEnergyWhPerDay = 0; arb->ecoProgress(retiree, t0); mesuree.telemetry.sessionWh = 5000; QCOMPARE(qRound(arb->ecoProgress(mesuree, t0).deliveredWh), 0); #endif } /*! * \brief [§10 étape D] La passe ÉCO achète, et le motif dit la TRAJECTOIRE. * * \par Règles couvertes * \rule{LM-1002} (deux passes sur la même liste ordonnée), \rule{LM-1007} (une charge sans * obligation ne porte pas d'entrée « eco » — l'absence EST l'information), \rule{LM-1013-b} * (l'étalement uniforme), \rule{LM-1210} (l'autorisation borne la cascade). * * \par Deux passes, une commande * Même liste, même ordre ; ce qui change est la BORNE — l'autorisation de soutirage pour l'éco, * le reliquat de surplus pour le confort. Les deux niveaux sont publiés, mais **une seule * commande** part au matériel (\rule{LM-1003}) : émettre deux fois ferait commuter un relais en * passe 1 puis le recommuter en passe 2, du court-cycling fabriqué par l'arbitre lui-même. * * \par Ce que le motif doit permettre, et pourquoi * `ECO_FLOOR_GRID` est lu le matin où la facture monte. Les watts de l'instant n'y suffisent * pas : la cadence fait un mur — 333 W à six heures de l'échéance, le plafond dès seize minutes. * Un client qui lit « 1000 W achetés » ne peut pas deviner ce qu'il paiera dans deux heures ; * avec le RESTE à livrer et le TEMPS restant, il le peut. */ void Simulation::testEcoPassBuysAndSaysTheTrajectory() { #ifndef ETM_ARBITRATOR QSKIP("nécessite ETM_ARBITRATOR."); #else cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); RuleBasedScheduler sched(arb); auto planifier = [&](const QDateTime &at, bool mesurable, double livreWh, double margeW) -> LoadAction { SurplusContext ctx; ctx.timestamp = at; ctx.meter.importW = 500; // la maison ACHÈTE : aucun surplus ctx.drawMargin.borne = true; ctx.drawMargin.watts = margeW; ctx.drawMargin.source = DrawCapSource::Breaker; ctx.drawMargin.phase = 0; LoadContext ecs; ecs.id = QStringLiteral("ecs-%1-%2").arg(mesurable).arg(qRound(margeW)); ecs.label = "ECS"; ecs.adapter = "etmvariableload"; ecs.priority = 1; ecs.declared.maxPowerW = 3000; ecs.needs.minEnergyWhPerDay = 2000; ecs.needs.dailyDeadline = "07:00"; ecs.telemetry.sessionMeasurable = mesurable; ecs.telemetry.sessionWh = livreWh; ctx.loads.append(ecs); const Plan plan = sched.getPlan(ctx); return plan.timeSlots.first().actions.first(); }; const QDateTime deuxHeuresAvant = utcDateTime(QDate(2026, 8, 31), QTime(5, 0, 0)); // 1. DEUX NIVEAUX, et l'éco d'abord — l'ordre des passes, pas celui des rangs. const LoadAction a = planifier(deuxHeuresAvant, true, 0, 5000); QCOMPARE(a.levels.count(), 2); QCOMPARE(a.levels.at(0).level, QString("eco")); QCOMPARE(a.levels.at(1).level, QString("comfort")); // 2. UNE SEULE COMMANDE, dont la puissance est la SOMME des niveaux. C'est l'invariant qui // rend la charge utile vérifiable — et LM-1003 qui rend le matériel sûr. QCOMPARE(qRound(a.estimatedPowerW), 1000); // 2 kWh en 2 h double sommeNiveaux = 0; for (const LoadAction::Level &n : a.levels) sommeNiveaux += n.targetW; QCOMPARE(qRound(sommeNiveaux), qRound(a.estimatedPowerW)); // 3. LE FINANCEMENT VIT DANS LE NIVEAU (R2). L'éco est acheté, le confort ne l'est pas — // un `funding` par CHARGE serait indécidable ici, et c'est tout l'objet du changement. QCOMPARE(a.levels.at(0).funding, LoadAction::Grid); QCOMPARE(a.levels.at(1).funding, LoadAction::Surplus); QCOMPARE(qRound(a.counts.value(CountKey::DrawCommitted)), 1000); // 4. LA TRAJECTOIRE, et pas seulement l'instant. Chacun de ces champs répond à une question // qu'un client se posera devant sa facture : combien, pour quoi, et jusqu'à quand. const QVariantMap p = a.levels.at(0).reason.params; QCOMPARE(a.levels.at(0).reason.code, QString(DecisionCode::EcoFloorGrid)); QCOMPARE(p.value("gridW").toInt(), 1000); QCOMPARE(p.value("remainingWh").toInt(), 2000); QCOMPARE(p.value("targetWh").toInt(), 2000); QCOMPARE(p.value("remainingS").toInt(), 7200); QCOMPARE(p.value("regime").toString(), QString("measured")); QCOMPARE(p.value("atMaximum").toBool(), false); // …et le TEXTE le dit, sans quoi le motif ne serait lisible que par un programme. const QString texte = renderFr(a.levels.at(0).reason, "ECS"); QVERIFY2(texte.contains("2000 Wh"), qUtf8Printable("motif: " + texte)); QVERIFY2(texte.contains("2 h"), qUtf8Printable("motif: " + texte)); // 5. L'AUTORISATION DE SOUTIRAGE BORNE L'ÉCO. Le plancher éco est une raison d'acheter, pas // un droit de faire disjoncter : marge à 400 W, on n'achète que 400. const LoadAction borne = planifier(deuxHeuresAvant, true, 0, 400); QCOMPARE(qRound(borne.levels.at(0).targetW), 400); // 6. R6 — SOUS `unmeasurable`, JAMAIS « tenue ». Même avec 3 kWh au compteur de session, on // ne sait pas ce qui a été livré POUR CETTE OBLIGATION : conclure serait affirmer sur ce // qu'on ignore, et une échéance qui s'affiche comme tenue sans l'être rassure à tort. const LoadAction inconnu = planifier(deuxHeuresAvant, false, 3000, 5000); QCOMPARE(inconnu.levels.at(0).reason.code, QString(DecisionCode::EcoFloorGrid)); QVERIFY2(inconnu.levels.at(0).reason.code != QString(DecisionCode::EcoFloorMet), "ECO_FLOOR_MET est interdit sous un régime non mesuré (R6)"); QCOMPARE(inconnu.levels.at(0).progress.regime, QString(ProgressRegime::Unmeasurable)); QVERIFY2(!inconnu.levels.at(0).progress.delivered, "sous unmeasurable, deliveredWh n'a pas de sens et ne sera pas publié"); // 7. SOUS `unmeasurable`, `remainingWh` VAUT LA CIBLE INTACTE. Rien n'est soustrait, parce // qu'on ne sait rien de ce qui a été livré. Soustraire une intégration interne ferait // franchir la frontière à une ESTIMATION sous l'allure d'un fait — le chemin exact de // `carBatteryLevel`, refusé en LM-1009 D3. // // Et que `regime` voyage dans les mêmes params ne suffirait pas : un client qui lit // `remainingWh` sans regarder `regime` obtiendrait un nombre calculé, sans rien pour le // lui dire. « 2000 Wh restants sur 2000 » doit signifier « rien de mesurable », pas // « rien livré » — et l'écran ne peut trancher que si le contrat le dit. const QVariantMap pu = inconnu.levels.at(0).reason.params; QCOMPARE(pu.value("remainingWh").toInt(), pu.value("targetWh").toInt()); QCOMPARE(pu.value("remainingWh").toInt(), 2000); // 8. IDENTITÉ SOUS `measured` — troisième contrôle vérifiable, au même titre que // Σ counts == targetW et authorisedW − committedW == remainingW. C'est ce qui permet à // un client de constater la cohérence au lieu de la supposer. const LoadAction partiel = planifier(deuxHeuresAvant, true, 800, 5000); const LoadAction suite = planifier(deuxHeuresAvant, true, 1400, 5000); for (const LoadAction &act : {partiel, suite}) { const QVariantMap pm = act.levels.at(0).reason.params; QVERIFY2(act.levels.at(0).progress.delivered, "régime mesuré attendu"); QCOMPARE(qRound(act.levels.at(0).progress.deliveredWh) + pm.value("remainingWh").toInt(), pm.value("targetWh").toInt()); } #endif } /*! * \brief [§10] Une obligation abandonnée est un FAIT produit — et un fait produit se dit. * * \par Ce motif n'existait pas le matin même * Il n'avait pas d'ÉMETTEUR : le créer alors aurait posé un invariant sur un code que rien ne * produit — un test toujours vert, donc un test qui ne protège rien. La bascule de période * (\rule{LM-1013-c}) l'a fait naître. Le journal disait déjà l'abandon ; l'écran, non. * * \par Trois causes, trois gestes * `drawCap` envoie regarder l'abonnement ou les autres consommateurs ; `loadCapacity` envoie * lire la fiche technique de la charge ; `unmeasurable` envoie regarder ce que la borne publie. * Sans la cause, l'écran n'affiche qu'un reproche sans recours. * * \warning **Ce test devient faux** si l'obligation cesse d'être bornée par l'autorisation de * soutirage — alors `drawCap` ne serait plus une cause possible, et c'est le cas 1 qu'il * faudrait réécrire, pas le moteur. */ void Simulation::testEcoFloorMissedSaysWhatWasLostAndWhy() { #ifndef ETM_ARBITRATOR QSKIP("nécessite ETM_ARBITRATOR."); #else cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); RuleBasedScheduler sched(arb); auto cycle = [&](const QString &id, const QDateTime &at, bool mesurable, double livreWh, double margeW) -> LoadAction { SurplusContext ctx; ctx.timestamp = at; ctx.meter.importW = 500; ctx.drawMargin.borne = true; ctx.drawMargin.watts = margeW; ctx.drawMargin.source = DrawCapSource::Breaker; ctx.drawMargin.phase = 0; LoadContext ecs; ecs.id = id; ecs.label = "ECS"; ecs.adapter = "etmvariableload"; ecs.priority = 1; ecs.declared.maxPowerW = 3000; ecs.needs.minEnergyWhPerDay = 2000; ecs.needs.dailyDeadline = "07:00"; ecs.telemetry.sessionMeasurable = mesurable; ecs.telemetry.sessionWh = livreWh; ctx.loads.append(ecs); return sched.getPlan(ctx).timeSlots.first().actions.first(); }; const QDateTime avant = utcDateTime(QDate(2026, 8, 31), QTime(5, 0, 0)); const QDateTime apres = utcDateTime(QDate(2026, 8, 31), QTime(7, 30, 0)); // --- Cas 1 : MESURÉ, bridé par l'autorisation de soutirage ------------------------------ // Marge à 200 W alors que la cadence en demande 1000 : on note `drawCap`, et à la bascule // c'est cette cause-là qui est publiée. cycle("ecs-cap", avant, true, 0, 200); cycle("ecs-cap", avant, true, 300, 200); // 300 Wh livrés sur 2000 const LoadAction close = cycle("ecs-cap", apres, true, 300, 200); QCOMPARE(close.levels.at(0).reason.code, QString(DecisionCode::EcoFloorMissed)); const QVariantMap pc = close.levels.at(0).reason.params; QCOMPARE(pc.value("cause").toString(), QString(MissedCause::DrawCap)); QCOMPARE(pc.value("missingWh").toInt(), 1700); // 2000 dus, 300 livrés QCOMPARE(pc.value("targetWh").toInt(), 2000); // Le TEXTE porte le manque ET le geste. « Non tenue » seul serait un reproche sans recours. const QString tc = renderFr(close.levels.at(0).reason, "ECS"); QVERIFY2(tc.contains("1700"), qUtf8Printable("motif: " + tc)); QVERIFY2(tc.contains("soutirage"), qUtf8Printable("motif: " + tc)); // --- Cas 2 : NON MESURÉ ----------------------------------------------------------------- cycle("ecs-inc", avant, false, 0, 5000); const LoadAction closeInc = cycle("ecs-inc", apres, false, 0, 5000); QCOMPARE(closeInc.levels.at(0).reason.code, QString(DecisionCode::EcoFloorMissed)); const QVariantMap pi = closeInc.levels.at(0).reason.params; QCOMPARE(pi.value("cause").toString(), QString(MissedCause::Unmeasurable)); // `missingWh` ABSENT, jamais 0. On ne sait pas ce qui a été livré, donc pas davantage ce // qui manquait : un zéro se lirait « il ne manquait rien », là où la vérité est « on n'en // sait rien ». Publier un chiffre inventerait la grandeur dont l'absence EST le problème. QVERIFY2(!pi.contains("missingWh"), "sous unmeasurable, le manque n'est pas chiffrable — pas de zéro forgé"); const QString ti = renderFr(closeInc.levels.at(0).reason, "ECS"); QVERIFY2(ti.contains("pas chiffrable"), qUtf8Printable("motif: " + ti)); // --- Cas 3 : obligation TENUE — aucune clôture manquée ---------------------------------- cycle("ecs-ok", avant, true, 0, 5000); const LoadAction closeOk = cycle("ecs-ok", apres, true, 2500, 5000); QVERIFY2(closeOk.levels.at(0).reason.code != QString(DecisionCode::EcoFloorMissed), "une obligation tenue ne produit pas de motif de non-tenue"); #endif } /*! * \brief [Commande perdue, étape 2] L'écart se PUBLIE — il ne se diagnostique pas. * * \par Ce que l'écart vaut dépend de la SOURCE, dans les deux sens * | | concordance | divergence | * |---|---|---| * | `device` | **ne prouve rien** — plusieurs bornes renvoient leur consigne en guise de puissance | la charge n'obéit pas, et elle le dit elle-même | * | `meter` | **preuve** — un tiers le constate | trois hypothèses : la charge, le compteur, le câblage | * * Ce test le DÉMONTRE au lieu de l'énoncer : la borne du mock dérive sa puissance de sa propre * consigne, si bien que `expectedW == observedW` **par construction**. Une concordance obtenue * ainsi n'est pas une preuve d'obéissance — c'est un écho. * * \par Un écart n'est pas un défaut * Une borne qui n'obéit pas parce que le véhicule limite, parce que le câble est en 16 A, ou * parce qu'elle est en mode manuel, produit le **même nombre** qu'une commande perdue. Le champ * ne porte donc aucune cause : la cause n'est pas dans les watts. */ void Simulation::testCommandDivergenceIsPublishedNotDiagnosed() { #ifndef ETM_ARBITRATOR QSKIP("nécessite ETM_ARBITRATOR."); #else const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-div.json"; QFile::remove(cfgPath); qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); ThingManager *tm = NymeaCore::instance()->thingManager(); QUuid meterId = addMeter(); m_experiencePlugin->energyManager()->setRootMeter(meterId); Thing *meter = tm->findConfiguredThing(meterId); QVERIFY(meter); meter->setStateValue("connected", true); meter->setStateValue("currentPower", -8000); // 8 kW exportés QUuid borneId = addCharger("A", 16, 26698); Thing *borne = tm->findConfiguredThing(borneId); QVERIFY(borne); armerBorne(borne); QVariantMap info; info.insert("evChargerId", borneId); info.insert("chargingMode", "ChargingModeEco"); injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})); const QDateTime t0 = utcDateTime(QDate(2026, 9, 1), QTime(13, 0, 0)); arb->simulationCallUpdate(t0); QCoreApplication::processEvents(); QHash tele = telemetrieParId(arb->loadTelemetry()); const QVariantMap b = tele.value(borneId.toString()); // 1. SOUS `device`, LA CONCORDANCE NE PROUVE RIEN — et c'est mesurable ici même : la borne // du mock dérive `currentPower` de sa propre consigne, donc les deux nombres coïncident // PAR CONSTRUCTION. Le champ dit ce qu'il voit ; il ne dit pas que la borne obéit. QCOMPARE(b.value("measurement").toMap().value("source").toString(), QString("device")); const QVariantMap cmd = b.value("command").toMap(); QVERIFY2(!cmd.isEmpty(), "sous une source mesurante, l'écart doit être publié"); QCOMPARE(qRound(cmd.value("expectedW").toDouble()), qRound(cmd.value("observedW").toDouble())); QCOMPARE(cmd.value("divergent").toBool(), false); QVERIFY2(!cmd.contains("since"), "pas de divergence, donc pas de date de début"); // 2. DIVERGENCE — la borne annonce autre chose que ce qu'on lui a commandé. On publie les // deux nombres, le verdict, et DEPUIS QUAND : sans la date, une divergence permanente ne // se distingue pas d'un déphasage d'un cycle entre l'écriture et sa mesure, ce qui est // toute la différence entre le normal et le cas relevé le 2026-08-31. // On passe par un COMPTEUR EXPLICITE, et pas par la mesure de la borne : le mock recalcule // `currentPower` depuis la consigne à chaque recommande, si bien qu'une divergence injectée // là serait effacée avant d'être publiée — ce qui EST la démonstration du point 1, mais // interdit d'exercer le point 2 par ce chemin. Sous `meter`, un tiers observe, et c'est // aussi le seul régime où la divergence a valeur de preuve. QUuid compteurBorne = addMeter(26699); Thing *cb = tm->findConfiguredThing(compteurBorne); QVERIFY(cb); cb->setStateValue("connected", true); cb->setStateValue("currentPower", 800); // le compteur voit 800 W… QVariantList cfgs = injectAndWait("NymeaEnergy.GetLoadConfig").toMap() .value("params").toMap().value("loadConfigs").toList(); QVariantList majs; for (const QVariant &v : cfgs) { QVariantMap m = v.toMap(); if (m.value("id").toString() == borneId.toString()) m.insert("meterThingId", compteurBorne.toString()); majs.append(m); } injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", majs}})); arb->simulationCallUpdate(t0.addSecs(60)); QCoreApplication::processEvents(); const QVariantMap b2 = telemetrieParId(arb->loadTelemetry()).value(borneId.toString()); QCOMPARE(b2.value("measurement").toMap().value("source").toString(), QString("meter")); const QVariantMap cmd2 = b2.value("command").toMap(); QCOMPARE(cmd2.value("divergent").toBool(), true); QCOMPARE(qRound(cmd2.value("observedW").toDouble()), 800); QVERIFY2(cmd2.contains("since"), "une divergence doit dire depuis quand elle dure"); // 3. Et le champ NE QUALIFIE PAS. Aucun code de cause, aucun défaut : véhicule qui limite, // câble en 16 A ou commande perdue produisent le même nombre. Le publier comme un défaut // ferait accuser une installation saine. QVERIFY2(!cmd2.contains("cause") && !cmd2.contains("faultCode"), "l'écart se publie, il ne se diagnostique pas — la cause n'est pas dans les watts"); // 4. SANS SOURCE MESURANTE, l'objet est ABSENT — pas `divergent: false`, qui affirmerait une // concordance qu'on ne peut pas constater. Une charge de configuration sans compteur // désigné ni état de puissance est exactement ce cas. // Une charge SANS mesure est ajoutée exprès : sans elle, la boucle ci-dessous ne trouverait // aucun cas et l'assertion serait VACANTE — elle passerait sans rien vérifier. C'est le // piège de la contre-épreuve inerte, et il a été constaté ici même : la garde retirée, le // test passait toujours. // Le relais est VOLONTAIREMENT inexistant : toutes les classes du mock portent un état de // puissance, donc aucune charge normale ne produit `source: none`. Une charge dont le // câblage manque, si — et c'est un cas réel, pas un artifice (ECS-111). const QString relaisSansMesure = "{deadbeef-0000-4000-8000-00000000c0de}"; LoadConfigStore *store = new LoadConfigStore(arb); arb->setLoadConfigStore(store); LoadConfigs sans; sans.append(LoadConfig::fromMap(QVariantMap{ {"id", "ecs-sans-mesure"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"}, {"priority", 5}, {"enabled", true}, {"domain", "ecs"}, {"relays", QVariantList() << QVariantMap{{"thingId", relaisSansMesure}, {"powerW", 1000}}}})); QString err; QVERIFY2(store->setConfigs(sans, &err), err.toUtf8()); arb->simulationCallUpdate(t0.addSecs(120)); QCoreApplication::processEvents(); int examinees = 0; for (const QVariantMap &e : telemetrieParId(arb->loadTelemetry())) { if (e.value("measurement").toMap().value("source").toString() != QLatin1String("none")) continue; ++examinees; QVERIFY2(!e.contains("command"), "rien ne mesure : il n'y a rien à comparer, et l'objet doit être absent"); } QVERIFY2(examinees > 0, "aucune charge sans mesure dans ce cycle : l'assertion aurait été vacante"); #endif } /*! * \brief [Commande perdue, étape 3] Le recrédit est un PRÊT consenti contre l'obéissance. * * \par Ce que la mesure a corrigé dans le diagnostic * Le constat de l'app disait « un budget de surplus qui ignore ces watts ». Vérification faite, * **ce n'est pas ce qui se passe** : quand une charge consomme et obéit, la comptabilité est * juste — sa conso est recréditée puis réallouée à elle-même, et les suivantes ne reçoivent que * le surplus réel. Corriger cela aurait été corriger un non-défaut. * * \par Le vrai défaut, et il est plus grave * Le recrédit anti-clignotement repose sur une hypothèse écrite dans le code : *« le budget est * une mesure dont la conso de la charge a déjà été retranchée »* — autrement dit **« je peux * rendre ces watts parce que je peux les reprendre »**. C'est un **prêt consenti contre * l'obéissance**, et rien ne vérifie que l'emprunteur rembourse. * * Ce test épingle le cas où la promesse est engagée : A est commandée à **0** et ses 4 400 W * sont redistribués. Si A obéit, tout est juste. **Si A n'obéit pas** — commande perdue, mode * manuel, véhicule qui limite — la maison tire 8 400 W pour 4 900 W de production, soit * **3 500 W importés que personne n'a décidés**. * * \warning **Ce test épingle le comportement ACTUEL, qui est correct SOUS HYPOTHÈSE.** Il * devient faux le jour où le recrédit sera conditionné à l'obéissance constatée * (`command.divergent`) — et ce jour-là, c'est l'allocation de B qu'il faudra revoir, pas le * test. La distinction est écrite ici pour qu'on ne le « corrige » pas par réflexe. */ void Simulation::testRecreditIsALoanAgainstObedience() { #ifndef ETM_ARBITRATOR QSKIP("nécessite ETM_ARBITRATOR."); #else cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); RuleBasedScheduler sched(arb); SurplusContext ctx; ctx.timestamp = utcDateTime(QDate(2026, 9, 1), QTime(13, 0, 0)); ctx.meter.exportW = 500; // 500 W exportés SEULEMENT LoadContext a; a.id = "A-coupee"; a.label = "A"; a.adapter = "etmvariableload"; a.priority = 1; a.declared.maxPowerW = 6000; a.declared.minPowerW = 5000; // plancher AU-DESSUS du budget : A est coupée a.telemetry.currentPowerW = 4400; // …mais elle consomme 4 400 W à cet instant ctx.loads.append(a); LoadContext b; b.id = "B-suivante"; b.label = "B"; b.adapter = "etmvariableload"; b.priority = 2; b.declared.maxPowerW = 4000; ctx.loads.append(b); const Plan plan = sched.getPlan(ctx); QHash parId; for (const LoadAction &act : plan.timeSlots.first().actions) parId.insert(act.loadId, act); // 1. A est coupée — son plancher n'est pas payable, et le budget PASSE à la suivante. QCOMPARE(qRound(parId.value("A-coupee").estimatedPowerW), 0); QCOMPARE(parId.value("A-coupee").reason.code, QString(DecisionCode::BelowMinPower)); // 2. B reçoit 4 000 W — c'est-à-dire la conso de A, PRÊTÉE d'avance sur sa coupure. QCOMPARE(qRound(parId.value("B-suivante").estimatedPowerW), 4000); // 3. LE PRÊT EST VISIBLE DANS LA COMPTABILITÉ, et c'est ce qui le rend vérifiable : 4 000 W // alloués pour 500 W de surplus réel. L'écart est exactement le recrédit. QCOMPARE(qRound(plan.budget.surplusW), 500); QCOMPARE(qRound(plan.budget.recreditedW), 4400); // 5. LE PRÊT EST REFUSÉ quand le refus est ÉTABLI. Même scénario, mais la charge A ne suit // pas la commande depuis plus longtemps que la latence de son mécanisme : ses watts ne // sont plus rendus au budget, et B ne reçoit donc plus ce qu'on ne peut pas reprendre. ctx.loads[0].telemetry.commandRefused = true; ctx.loads[0].telemetry.refusedSinceS = 120; const Plan refus = sched.getPlan(ctx); QHash parIdR; for (const LoadAction &act : refus.timeSlots.first().actions) parIdR.insert(act.loadId, act); QCOMPARE(qRound(refus.budget.recreditedW), 0); // on ne prête plus // B ne reçoit QUE le surplus réel : 500 W, et non les 4 000 W qu'on ne peut pas reprendre. QCOMPARE(qRound(parIdR.value("B-suivante").estimatedPowerW), 500); // 6. ET ÇA SE DIT. Une suspension change l'allocation de TOUTES les suivantes ; si personne // ne sait pourquoi B reçoit moins, on chercherait un défaut de cascade là où il y a une // charge qui n'obéit pas. Le motif porte la suspension ET sa cause chiffrée. QCOMPARE(parIdR.value("A-coupee").reason.code, QString(DecisionCode::RecreditSuspended)); QCOMPARE(parIdR.value("A-coupee").reason.params.value("refusedSinceS").toInt(), 120); QCOMPARE(parIdR.value("A-coupee").reason.params.value("withheldW").toInt(), 4400); const QString txt = renderFr(parIdR.value("A-coupee").reason, "A"); QVERIFY2(txt.contains("4400"), qUtf8Printable("motif: " + txt)); QVERIFY2(txt.contains("reprendre"), qUtf8Printable("motif: " + txt)); // 7. UN ÉCART RÉCENT N'EST PAS UN REFUS. La latence du mécanisme protège une charge saine // qui n'a pas encore atteint sa consigne — suspendre là ferait osciller ce qui va bien. // C'est `qualifierRefus()` qui tranche, sur le seuil du mécanisme et non une constante. ctx.loads[0].telemetry.commandRefused = false; // écart présent mais RÉCENT ctx.loads[0].telemetry.refusedSinceS = 3; const Plan latence = sched.getPlan(ctx); QCOMPARE(qRound(latence.budget.recreditedW), 4400); // le prêt est maintenu QCOMPARE(qRound(plan.budget.allocatedW), 4000); QVERIFY2(plan.budget.allocatedW > plan.budget.surplusW, "le recrédit prête d'avance ce que la coupure de A doit libérer"); // 4. L'identité de réconciliation TIENT malgré tout — surplus + recrédit − alloué == restant. // C'est ce qui distingue un prêt d'une erreur : la comptabilité est juste, c'est // l'hypothèse qu'elle repose dessus qui n'est pas vérifiée. QCOMPARE(qRound(plan.budget.surplusW + plan.budget.recreditedW - plan.budget.allocatedW), qRound(plan.budget.remainingW)); #endif } /*! * \brief [R6] La MESURABILITÉ de l'avancement se déclare à la SAISIE, avec sa cause. * * \par Pourquoi ce signal ne peut pas vivre dans la télémétrie * `measurement` n'existe que pour les charges de `loads[]`. Or une charge qu'on **déclare** est * précisément celle qui n'y est pas encore — pas arbitrée, ou créée à l'instant. L'écran serait * muet **au moment exact où il doit parler** : celui où l'installateur saisit une obligation qui * achètera à l'aveugle tous les jours si rien ne la mesure. * * \par Trois causes, trois gestes * `noMeter` se répare en une manipulation, `meterWithoutEnergy` demande de remplacer le * compteur, `noSessionEnergy` ne se répare pas — c'est le matériel. Sans la cause, l'avertissement * ne peut dire que « ça ne marchera pas ». * * \par Et `meterThingId` ne permet pas de le déduire * Un compteur peut publier `currentPower` **sans** `totalEnergyConsumed` : mesuré le 2026-09-01 * sur l'ECS du banc, et reproduit ici avec un `powerSwitch` désigné comme compteur. Un client qui * déduirait la mesurabilité de la présence du champ se tromperait sur ce cas précis. */ void Simulation::testProgressMeasurabilityIsDeclaredAtConfigTime() { #ifndef ETM_ARBITRATOR QSKIP("nécessite ETM_ARBITRATOR."); #else const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-mes.json"; QFile::remove(cfgPath); qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); ThingManager *tm = NymeaCore::instance()->thingManager(); QUuid compteur = addMeter(26702); // meter : porte totalEnergyConsumed QUuid r1 = addPowerSwitch(1000, 26703); // powerSwitch : power + currentPower SEULEMENT QUuid r2 = addPowerSwitch(1000, 26704); QUuid r3 = addPowerSwitch(1000, 26705); QVERIFY(tm->findConfiguredThing(compteur) && tm->findConfiguredThing(r1)); // Écrit par la FRONTIÈRE RPC, comme l'app le ferait — c'est le magasin que le handler // connaît. Un store créé à côté ne serait pas celui qu'il interroge : constaté ici même, // GetLoadConfig rendait une liste VIDE et le test passait sans rien vérifier. int rang = 0; QVariantList aPoser; auto relais = [&](const QString &id, const QUuid &thing, const QString &meter) { ++rang; // rangs UNIQUES : le store refuse deux charges de même rang QVariantMap m{{"id", id}, {"label", id}, {"adapter", "relay-router"}, {"mode", "fixed"}, {"priority", rang}, {"enabled", true}, {"domain", "ecs"}, {"relays", QVariantList() << QVariantMap{{"thingId", thing.toString()}, {"powerW", 1000}}}}; if (!meter.isEmpty()) m.insert("meterThingId", meter); return m; }; aPoser << relais("sans-compteur", r1, QString()); aPoser << relais("compteur-ok", r2, compteur.toString()); aPoser << relais("compteur-sans-energie", r3, r1.toString()); QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", aPoser}})).toMap() .value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap() .value("params").toMap().value("loadConfigs").toList(); QHash parId; for (const QVariant &v : l) parId.insert(v.toMap().value("id").toString(), v.toMap()); // Garde contre l'assertion VACANTE : sans les trois entrées, tout ce qui suit passerait // sans rien vérifier. Le piège a déjà été rencontré deux fois dans ce lot. QCOMPARE(parId.count(), 3); // 1. AUCUN COMPTEUR — réparable en une manipulation, et c'est la cause la plus fréquente. QCOMPARE(parId.value("sans-compteur").value("progressMeasurable").toBool(), false); QCOMPARE(parId.value("sans-compteur").value("progressUnmeasurableCause").toString(), QString("noMeter")); // 2. COMPTEUR AVEC ÉNERGIE — mesurable, et AUCUNE cause publiée : il n'y a rien à réparer. QCOMPARE(parId.value("compteur-ok").value("progressMeasurable").toBool(), true); QVERIFY2(!parId.value("compteur-ok").contains("progressUnmeasurableCause"), "une charge mesurable ne porte pas de cause — l'absence est l'information"); // 3. COMPTEUR SANS ÉNERGIE — le cas qui interdit de déduire la mesurabilité de la seule // présence de `meterThingId`. Le champ est là, le compteur existe, et pourtant rien ne // cumule : la puissance ne suffit pas, il faut une énergie. QCOMPARE(parId.value("compteur-sans-energie").value("progressMeasurable").toBool(), false); QCOMPARE(parId.value("compteur-sans-energie").value("progressUnmeasurableCause").toString(), QString("meterWithoutEnergy")); QVERIFY2(!parId.value("compteur-sans-energie").value("meterThingId").toString().isEmpty(), "le compteur EST désigné — c'est bien la déduction par meterThingId qui échoue"); #endif } /*! * \brief [Inventaire du mock] « Saine mais NON MESURÉE » n'est pas « en défaut ». * * \par Le cas que le banc ne savait pas produire * Toutes les classes pilotables du mock portaient une puissance. `measurement.source: none` * n'était donc atteignable qu'avec un Thing **manquant** — ce qui traîne `available: false` avec * lui et **mélange deux affirmations distinctes** : « rien ne mesure cette charge » et « cette * charge est en panne ». Or un **relais sec** — il commute, il ne mesure pas — est banal sur le * terrain, et parfaitement sain. * * Ce test n'aurait pas pu s'écrire avant la classe `dryRelay` : c'est l'inventaire à froid * (`docs/INVENTAIRE_MOCK.md`) qui a rendu le manque visible, et le manque était structurel. * * \par Ce qu'il épingle * Deux charges dans le même cycle, l'une saine sans mesure, l'autre au câblage manquant. Elles * doivent se **distinguer** — mêmes `measurement.source`, `available` opposés. */ void Simulation::testHealthyButUnmeasuredDiffersFromFaulty() { #ifndef ETM_ARBITRATOR QSKIP("nécessite ETM_ARBITRATOR."); #else const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-sec.json"; QFile::remove(cfgPath); qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); ThingManager *tm = NymeaCore::instance()->thingManager(); QUuid meterId = addMeter(); m_experiencePlugin->energyManager()->setRootMeter(meterId); Thing *meter = tm->findConfiguredThing(meterId); QVERIFY(meter); meter->setStateValue("connected", true); meter->setStateValue("currentPower", -5000); // 5 kW exportés // LE RELAIS SEC : il commute, il ne mesure pas. Il EXISTE, il est sain. QUuid sec = addDryRelay(26681); Thing *tSec = tm->findConfiguredThing(sec); QVERIFY2(tSec, "le relais sec doit être instanciable"); QVERIFY2(!tSec->thingClass().hasStateType("currentPower"), "un relais sec ne porte PAS de puissance — c'est tout l'objet de la classe"); QVariantList aPoser; aPoser << QVariantMap{{"id", "ecs-relais-sec"}, {"label", "ECS relais sec"}, {"adapter", "relay-router"}, {"mode", "fixed"}, {"priority", 1}, {"enabled", true}, {"domain", "ecs"}, {"relays", QVariantList() << QVariantMap{{"thingId", sec.toString()}, {"powerW", 1500}}}}; // …et une charge au CÂBLAGE MANQUANT, pour la comparaison. aPoser << QVariantMap{{"id", "ecs-fantome"}, {"label", "ECS fantôme"}, {"adapter", "relay-router"}, {"mode", "fixed"}, {"priority", 2}, {"enabled", true}, {"domain", "ecs"}, {"relays", QVariantList() << QVariantMap{ {"thingId", "{deadbeef-0000-4000-8000-00000000c0de}"}, {"powerW", 1500}}}}; QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", aPoser}})).toMap() .value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); arb->simulationCallUpdate(utcDateTime(QDate(2026, 9, 2), QTime(13, 0, 0))); QCoreApplication::processEvents(); QHash tele = telemetrieParId(arb->loadTelemetry()); const QVariantMap saine = tele.value("ecs-relais-sec"); const QVariantMap fantome = tele.value("ecs-fantome"); QVERIFY2(!saine.isEmpty() && !fantome.isEmpty(), "les deux charges doivent être publiées"); // 1. LES DEUX ne sont pas mesurées — même source, et c'est normal. QCOMPARE(saine.value("measurement").toMap().value("source").toString(), QString("none")); QCOMPARE(fantome.value("measurement").toMap().value("source").toString(), QString("none")); // 2. MAIS ELLES SE DISTINGUENT, et c'est tout l'objet du lot : l'une est SAINE, l'autre en // défaut. Avant la classe `dryRelay`, la première était impossible à fabriquer, et rien // ne pouvait donc vérifier que le moteur les sépare. QCOMPARE(saine.value("available").toBool(), true); QCOMPARE(fantome.value("available").toBool(), false); QCOMPARE(fantome.value("faultCode").toString(), QString("THING_MISSING")); QVERIFY2(!saine.contains("faultCode"), "une charge saine ne porte pas de code de défaut — l'absence est l'information"); // 3. Et aucune des deux ne publie `command` : rien ne mesure, donc rien à comparer. Sans le // relais sec, cette assertion n'aurait porté que sur un cas en défaut. QVERIFY2(!saine.contains("command"), "sans mesure, l'écart commandé/mesuré est sans objet — même sur une charge saine"); #endif } /*! * \brief Un compteur de VIE qui recule ne rebase JAMAIS l'avancement. * * \par Le chemin du faux « tenue », et il est le seul * Un compteur qui disparaît peut publier **0 au lieu de se taire** — mesuré sur la V2C le * 2026-08-30 : `currentPower` 4 396 → 0 et les booléens à `false` à la perte du lien. Si la base * se reprenait sur ce zéro, le retour du compteur avec son cumul réel — 10 kWh sur l'ECS du banc * — ferait sauter `deliveredWh` à 10 000 en un cycle : **l'obligation se déclarerait tenue sans * que rien n'ait été livré**. * * Et `R6` ne s'y oppose pas : elle n'interdit `ECO_FLOOR_MET` que sous `unmeasurable`. Le chemin * **mesuré** est donc le seul par lequel un faux « tenue » peut sortir — c'est-à-dire exactement * celui qu'ouvre l'alimentation de `sessionWh` depuis un compteur. * * \par Pourquoi la garde porte sur la SÉMANTIQUE et non sur la joignabilité * Il n'existe **aucun signal de joignabilité fiable** : `setupComplete` parle du setup et non du * lien, et `connected` est maintenu par le plugin — la V2C bascule après trois échecs, un * compteur générique peut ne jamais basculer. **Un accumulateur ne doit pas parier sur ce que * chaque plugin fait de ses états en tombant** : la même V2C a forgé un zéro sur `currentPower` * et laissé `sessionEnergy` à sa dernière valeur. * * Ce que le moteur sait sans parier : **un compteur de VIE ne recule pas.** Un recul y est une * anomalie ; sur un compteur de SESSION, c'est une session neuve. Deux sources, deux gestes. */ void Simulation::testLifetimeCounterDropNeverRebasesTheProgress() { #ifndef ETM_ARBITRATOR QSKIP("nécessite ETM_ARBITRATOR."); #else cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); const QDateTime t0 = utcDateTime(QDate(2026, 9, 3), QTime(13, 0, 0)); LoadContext ecs; ecs.id = "ecs-compteur-vie"; ecs.needs.minEnergyWhPerDay = 4000; ecs.needs.dailyDeadline = "07:00"; ecs.telemetry.sessionMeasurable = true; ecs.telemetry.sessionCumulative = true; // totalEnergyConsumed : monotone par nature ecs.telemetry.sessionWh = 9000; // le compteur en est à 9 kWh de vie QCOMPARE(qRound(arb->ecoProgress(ecs, t0).deliveredWh), 0); // base prise à 9000 ecs.telemetry.sessionWh = 9500; QCOMPARE(qRound(arb->ecoProgress(ecs, t0).deliveredWh), 500); // 500 Wh livrés // LE LIEN TOMBE et le plugin publie ZÉRO au lieu de se taire. ecs.telemetry.sessionWh = 0; const EcoProgress pendant = arb->ecoProgress(ecs, t0); // On GÈLE : la valeur ne vaut rien, donc on ne conclut rien. Pas d'avancement publié, pas // de recapture — le tronçon reste ouvert sur sa dernière valeur saine. QCOMPARE(pendant.regime, QString(ProgressRegime::Unmeasurable)); QVERIFY2(!pendant.delivered, "sur une valeur non fiable, on ne publie pas d'avancement"); // LE COMPTEUR REVIENT avec son cumul réel. C'est ici que le faux « tenue » sortirait si la // base avait été reprise à 0 : 10 000 Wh d'un coup sur une obligation de 4 000. ecs.telemetry.sessionWh = 10000; const EcoProgress apresRetour = arb->ecoProgress(ecs, t0); QCOMPARE(apresRetour.regime, QString(ProgressRegime::Measured)); // 10 000 − 9 000 (base intacte) = 1 000 Wh. PAS 10 000. QCOMPARE(qRound(apresRetour.deliveredWh), 1000); QVERIFY2(apresRetour.deliveredWh < ecs.needs.minEnergyWhPerDay, "l'obligation ne doit PAS se déclarer tenue sur un retour de compteur"); // Et la distinction tient : sur un compteur de SESSION, le même recul EST une session neuve. LoadContext borne; borne.id = "borne-session"; borne.needs.minEnergyWhPerDay = 4000; borne.needs.dailyDeadline = "07:00"; borne.telemetry.sessionMeasurable = true; borne.telemetry.sessionCumulative = false; // sessionEnergy : remis à zéro par session borne.telemetry.sessionWh = 9000; QCOMPARE(qRound(arb->ecoProgress(borne, t0).deliveredWh), 0); borne.telemetry.sessionWh = 120; // nouvelle session const EcoProgress neuve = arb->ecoProgress(borne, t0); QCOMPARE(neuve.regime, QString(ProgressRegime::Measured)); // pas d'anomalie : on recapture QCOMPARE(qRound(neuve.deliveredWh), 0); #endif } /*! * \brief [\rule{LM-1210-a}] Sur une triphasée DÉSÉQUILIBRÉE, c'est la PIRE phase qui borne. * * \par Un choix écrit, raisonné, et jamais exercé — jusqu'ici * `LoadDeclared::phases` est un NOMBRE, pas un masque : le moteur ignore sur quelle phase une * charge est câblée. Il retient donc la pire, seul choix qui ne devine pas — deviner dans une * couche de sécurité se paie au disjoncteur. * * Ce test n'existait pas parce que **le mock ne savait produire qu'une triphasée équilibrée** : * il posait `currentPhaseX = currentPowerPhaseX / 230`, cohérent par construction. Le manque * était nommé deux fois — par l'inventaire du mock et par le balayage des contrôles — avant * d'être comblé. * * \par Ce qu'il épingle * La maison EXPORTE globalement, et une seule phase sature. La marge doit être celle de cette * phase-là, négative, et non celle du total qui paraîtrait confortable. */ void Simulation::testWorstPhaseBindsWhenPhasesAreUnbalanced() { #ifndef ETM_ARBITRATOR QSKIP("nécessite ETM_ARBITRATOR."); #else cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); ThingManager *tm = NymeaCore::instance()->thingManager(); QUuid meterId = addMeter(); m_experiencePlugin->energyManager()->setRootMeter(meterId); Thing *meter = tm->findConfiguredThing(meterId); QVERIFY(meter); meter->setStateValue("connected", true); QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}})).toMap() .value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); // DÉSÉQUILIBRE : la phase B sature à 30 A sous un plafond de 25, pendant que A et C // EXPORTENT. Impossible à écrire avant : le mock dérivait les courants des puissances. meter->setStateValue("currentPhaseA", -10); // export meter->setStateValue("currentPhaseB", 30); // SATURÉE meter->setStateValue("currentPhaseC", -10); // export meter->setStateValue("currentPower", -2300); // la maison exporte GLOBALEMENT const DrawMargin marge = arb->houseDrawMargin(); QVERIFY2(marge.borne, "un plafond est réglé : la marge doit exister"); // 1. LA PIRE PHASE L'EMPORTE — et c'est bien B, pas le total. 25 A − 30 A = −5 A, soit // −1150 W. Le total, lui, afficherait un confort trompeur : la maison exporte. QCOMPARE(qRound(marge.watts), -1150); QCOMPARE(marge.phase, 1); // phase B (0 = A, 1 = B, 2 = C) // 1-bis. ET L'ÉCRAN DOIT POUVOIR NOMMER CETTE PHASE. `perPhaseBound` dit qu'une phase borne, // jamais laquelle : l'écran ne pourrait dire que « bridé » sans dire par quoi, et // enverrait mesurer au hasard sur trois phases dont deux vont bien. Le motif `DRAW_CAP` // la porte aussi, mais il n'est émis que si une charge est REFUSÉE — un cycle qui borde // sans rien refuser laisserait l'écran muet. Les deux endroits ne se recouvrent pas. SurplusContext ctxPhase; ctxPhase.timestamp = utcDateTime(QDate(2026, 9, 4), QTime(13, 0, 0)); ctxPhase.drawMargin = marge; RuleBasedScheduler schedPhase(arb); const Plan planPhase = schedPhase.getPlan(ctxPhase); QCOMPARE(planPhase.draw.perPhaseBound, true); QCOMPARE(planPhase.draw.bindingPhase, QString("B")); // 2. ET LA MARGE EST NÉGATIVE alors que le compteur global est en EXPORT. C'est tout l'objet // du choix : sans la lecture par phase, l'arbitre croirait disposer de 2 300 W à dépenser // pendant qu'une phase est déjà au-delà de son disjoncteur. QVERIFY2(meter->stateValue("currentPower").toDouble() < 0, "la maison exporte : c'est le cas qui rend le pessimisme non trivial"); QVERIFY2(marge.watts < 0, "la pire phase borne, même quand le total rassure"); // 3. Contre-jour : rééquilibrée, la même maison retrouve une marge confortable. Sans ce // second point, le test ne distinguerait pas « la pire phase l'emporte » de « la marge // est toujours négative ». meter->setStateValue("currentPhaseB", -10); const DrawMargin equilibree = arb->houseDrawMargin(); // La marge est ÉCRÊTÉE AU PLAFOND, et non augmentée par l'export : `calculateAllowanceAmpere` // part de `phasePowerLimit` et ne fait que des `qMin`. Physiquement juste — l'export ne // permet pas de tirer plus que le disjoncteur — mais ce n'est PAS ce que le design disait // (« une phase qui exporte a une marge plus grande que le plafond »), et la phrase a été // corrigée. La conclusion, elle, tient : une phase qui exporte est au maximum de la marge, // donc elle ne borne jamais. QCOMPARE(qRound(equilibree.watts), qRound(25.0 * 230.0)); QVERIFY2(equilibree.watts > 0, "rééquilibrée, la marge redevient positive"); #endif } /*! * \brief [upstream-fix] La tension d'une phase ne doit pas servir à calculer une AUTRE phase. * * \par Le défaut * `calculateAllowanceAmpere()` déclare `voltage` **une seule fois, hors des trois blocs**, et le * bloc de la phase A la mute. Un compteur qui publie `voltagePhaseA` mais pas `voltagePhaseB` * verrait donc la phase B calculée **avec la tension de A**. * * \par Pourquoi ce test n'existait pas * Le mock posait `currentPhaseX = currentPowerPhaseX / 230` : tension implicite, identique * partout, et le défaut invisible. C'est la **troisième fois** que l'ouverture du mock révèle un * défaut plutôt que de seulement débloquer un test — après `dryRelay` et après la correction du * raisonnement sur l'écrêtage de la marge. */ void Simulation::testPhaseVoltageDoesNotLeakBetweenPhases() { #ifndef ETM_ARBITRATOR QSKIP("nécessite ETM_ARBITRATOR."); #else cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); ThingManager *tm = NymeaCore::instance()->thingManager(); QUuid meterId = addMeterPowerOnly(26691); Thing *meter = tm->findConfiguredThing(meterId); QVERIFY(meter); meter->setStateValue("connected", true); QVERIFY2(!meter->thingClass().hasStateType("currentPhaseC"), "ce compteur ne publie PAS les courants de B et C — c'est ce qui rend la branche " "« puissance ÷ tension » atteignable"); QVERIFY2(meter->thingClass().hasStateType("voltagePhaseB"), "il publie la tension de B…"); QVERIFY2(!meter->thingClass().hasStateType("voltagePhaseC"), "…mais PAS celle de C : c'est exactement le cas où la tension de B fuirait vers C"); RootMeter *rm = new RootMeter(meter); // LE MASQUE DOIT COUVRIR B **ET** C — c'est la condition de la fuite : le bloc de B écrit // `voltage`, celui de C le relit. Interroger C SEULE ne révèle rien, puisque le bloc de B ne // s'exécute pas. (Première version de ce test, qui passait pour cette raison.) // // Et C doit être la phase la plus CONTRAINTE, sinon le `qMin` de la fonction retiendrait B // et masquerait l'écart. B : 1000 W sous 200 V = 5 A, marge 20 A. C : 4000 W sous une // tension INCONNUE — donc 230 V par défaut, 17,4 A, marge 7,6 A. Avec la fuite, C serait // calculée sous les 200 V de B : 20 A tirés, marge 5 A. meter->setStateValue("voltagePhaseB", 200); meter->setStateValue("currentPowerPhaseB", 1000); meter->setStateValue("currentPowerPhaseC", 4000); const uint plafond = 25; const double marge = rm->calculateAllowanceAmpere( Electricity::PhaseB | Electricity::PhaseC, plafond); // Sans fuite : min(20 ; 25 − 4000/230) = 7,6 A. Avec la fuite : min(20 ; 25 − 4000/200) = 5. QCOMPARE(qRound(marge * 10), qRound((25.0 - 4000.0 / 230.0) * 10)); // Et l'ORDRE ne doit rien changer : le résultat de C ne doit pas dépendre de ce qu'on a // demandé avant. C'est la propriété que la variable partagée détruisait. QCOMPARE(qRound(rm->calculateAllowanceAmpere(Electricity::PhaseC, plafond) * 10), qRound((25.0 - 4000.0 / 230.0) * 10)); #endif } /*! * \brief Le compteur du mock INTÈGRE réellement — un cumul figé n'existe pas. * * \par Le blocage qu'il lève * `totalEnergyConsumed` valait **10 kWh depuis le 1er septembre** : une constante. `deliveredWh` * restait donc à 0, `remainingWh` à 4 000, et **l'étalement adaptatif n'était jamais exercé** — * le comportement se confondait avec `unmeasurable` pour une raison entièrement différente et * bénigne. Un compteur figé est un compteur qui n'existe pas pour tout ce qui l'intègre. * * \par \rule{LM-1014-b} appliquée au mock lui-même * `totalEnergyConsumed` est déclaré **`cached`** : c'est un compteur de VIE, il **continue** au * redémarrage et ne repart pas de zéro. Repartir de zéro ferait **reculer un cumul monotone**, * ce que la garde de sémantique traite comme une anomalie — le mock produirait alors un cas que * le terrain ne produit pas. */ void Simulation::testMeterEnergyActuallyAccumulates() { #ifndef ETM_ARBITRATOR QSKIP("nécessite ETM_ARBITRATOR."); #else cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); ThingManager *tm = NymeaCore::instance()->thingManager(); QUuid meterId = addMeter(26693); Thing *meter = tm->findConfiguredThing(meterId); QVERIFY(meter); meter->setStateValue("connected", true); // Le compteur CONSOMME 3 600 W : un watt-heure par seconde, de quoi voir bouger le cumul. meter->setStateValue("currentPower", 3600); const double depart = meter->stateValue("totalEnergyConsumed").toDouble(); QTest::qWait(2500); const double apres = meter->stateValue("totalEnergyConsumed").toDouble(); QVERIFY2(apres > depart, qUtf8Printable(QStringLiteral("le cumul doit AVANCER : %1 → %2 kWh") .arg(depart).arg(apres))); // …et il avance dans un ORDRE DE GRANDEUR plausible : 3 600 W pendant ~2,5 s, soit ~2,5 Wh // si toutes les secondes intègrent. La borne basse est LARGE et volontairement lâche : le // test ne contrôle ni la cadence exacte du minuteur ni l'alignement du premier tic — qui // n'intègre rien, faute de durée derrière lui. Une borne serrée en ferait un test instable, // c'est-à-dire un test qu'on relancerait jusqu'à ce qu'il passe. // // Ce qui est réellement épinglé : il intègre, et il n'emballe pas. Le premier point est ce // qui manquait ; le second est ce qui rendrait le mock inutilisable dans l'autre sens. const double gagneWh = (apres - depart) * 1000.0; QVERIFY2(gagneWh >= 0.5 && gagneWh < 10.0, qUtf8Printable(QStringLiteral("ordre de grandeur plausible attendu (~2,5 Wh), " "obtenu %1").arg(gagneWh))); // EN EXPORT, il ne recule pas — il ne bouge pas. Un compteur de CONSOMMATION est monotone, // et le faire reculer produirait un cas que le terrain ne produit pas. meter->setStateValue("currentPower", -3000); const double avantExport = meter->stateValue("totalEnergyConsumed").toDouble(); QTest::qWait(1500); QCOMPARE(meter->stateValue("totalEnergyConsumed").toDouble(), avantExport); #endif } void Simulation::testCountsSumToTarget() { #ifndef ETM_ARBITRATOR QSKIP("testCountsSumToTarget nécessite ETM_ARBITRATOR."); #else // [R3] « Chaque watt publié tombe dans exactement un compteur, et le mapping est publié. » // // Sept endroits construisent une LoadAction, et chacun doit renseigner `counts`. Vérifier // cela en RELISANT le code se referait à chaque site ajouté, et échouerait le jour où // quelqu'un en ajoute un huitième. Ce test relit la CHARGE UTILE : il ne fait confiance à // aucun site, il constate le résultat sur toutes les charges du cycle. const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-counts.json"; QFile::remove(cfgPath); qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); cleanupTestCase(); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); EnergyArbitrator *arb = dynamic_cast(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 }