From 7184fe4e1da1685e651db8e3d2d50a7ba3d56537 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 28 Jun 2026 09:51:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(etm):=20config=20charge=20pilot=C3=A9e=20p?= =?UTF-8?q?ersist=C3=A9e=20(Get/SetLoadConfig)=20+=20repli=20L2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pont moteur↔app : déclaration des charges etmvariableload persistée et éditable, construction des adaptateurs à chaud depuis la config (remplace le registre en dur). - LoadConfig / LoadConfigNeeds (Q_GADGET typés, introspectables) — forme mot pour mot du LoadDescriptor §4 (jonction inter-repos avec l'app). - LoadConfigStore : persistance atomique /var/lib/nymea/energy-load-configuration.json, validation en bloc, signal changed(). - Handler NymeaEnergy.GetLoadConfig / SetLoadConfig (+ notif LoadConfigChanged). GET typé objectRef ; SET schéma inline o: (powerLevels/needs conditionnels §4). - EnergyArbitrator : setLoadConfigStore + rebuildEtmVariableLoadAdapters (enabled==true seulement, §9) ; repli L2 = setPowerSetpoint(0) force=true sur tout etmvariableload (ferme le trou sécurité ouvert en T2). - Tests simulation : migration ECS→etmvariableload (arrondi fixed/dynamic, recrédit, délestage, round-trip powerSetpoint), budget partagé etmvariableload↔PAC, watchdog L2 réactivé, persistance + construction depuis config + injection RPC end-to-end. PAC SG-Ready reste hors config (§8, gelée). Les 5 tâches du brief sont couvertes. Co-Authored-By: Claude Opus 4.8 (1M context) --- energyplugin/energypluginnymea.cpp | 14 +- energyplugin/etm/config/loadconfigstore.cpp | 118 ++++ energyplugin/etm/config/loadconfigstore.h | 49 ++ energyplugin/etm/energyarbitrator.cpp | 56 +- energyplugin/etm/energyarbitrator.h | 23 +- energyplugin/etm/etm.pri | 4 + energyplugin/etm/types/loadconfig.cpp | 115 ++++ energyplugin/etm/types/loadconfig.h | 126 ++++ energyplugin/nymeaenergyjsonhandler.cpp | 90 ++- energyplugin/nymeaenergyjsonhandler.h | 8 +- .../experience/energyexperienceenergymock.cpp | 11 +- tests/auto/simulation/simulation.cpp | 554 +++++++++++------- tests/auto/simulation/simulation.h | 21 +- 13 files changed, 956 insertions(+), 233 deletions(-) create mode 100644 energyplugin/etm/config/loadconfigstore.cpp create mode 100644 energyplugin/etm/config/loadconfigstore.h create mode 100644 energyplugin/etm/types/loadconfig.cpp create mode 100644 energyplugin/etm/types/loadconfig.h diff --git a/energyplugin/energypluginnymea.cpp b/energyplugin/energypluginnymea.cpp index b07e2c5..5538f38 100644 --- a/energyplugin/energypluginnymea.cpp +++ b/energyplugin/energypluginnymea.cpp @@ -32,6 +32,7 @@ #ifdef ETM_ARBITRATOR #include "etm/energyarbitrator.h" #include "etm/adapters/sgreadyadapter.h" +#include "etm/config/loadconfigstore.h" #endif // [ETM] END @@ -50,13 +51,18 @@ void EnergyPluginNymea::init() QNetworkAccessManager *networkManager = new QNetworkAccessManager(this); SpotMarketManager *spotMarketManager = new SpotMarketManager(networkManager, this); + LoadConfigStore *loadConfigStore = nullptr; + #ifdef ETM_ARBITRATOR qCDebug(dcNymeaEnergy()) << "ETM_ARBITRATOR actif — EnergyArbitrator chargé."; EnergyArbitrator *chargingManager = new EnergyArbitrator(energyManager(), thingManager(), spotMarketManager, configuration, this); - // §1.d — Banc hems : SG-Ready PAC (ThingIds briefing terrain). - // NB (T2) : l'ECS banc (ex-EcsRelayAdapter relay-stages) est retiré — l'ECS passe en - // etmvariableload (Setpoint W) et sera construit depuis LoadConfig en T4. La PAC reste à états. + // T4 — charges pilotables etmvariableload (ECS/routeur) construites depuis la config + // persistée (remplace le registre en dur « 3g »). SetLoadConfig pilote ce store. + loadConfigStore = new LoadConfigStore(this); + chargingManager->setLoadConfigStore(loadConfigStore); + + // §1.d — Banc hems : SG-Ready PAC (ThingIds briefing terrain) — PAC hors config (§8, gelée). { ThingManager *tm = thingManager(); const QString K1 = "{beaf92e1-aedc-4b84-9ce4-e423648638cc}"; @@ -72,5 +78,5 @@ void EnergyPluginNymea::init() SmartChargingManager *chargingManager = new SmartChargingManager(energyManager(), thingManager(), spotMarketManager, configuration, this); #endif - jsonRpcServer()->registerExperienceHandler(new NymeaEnergyJsonHandler(spotMarketManager, chargingManager, this), 0, 8); + jsonRpcServer()->registerExperienceHandler(new NymeaEnergyJsonHandler(spotMarketManager, chargingManager, loadConfigStore, this), 0, 8); } diff --git a/energyplugin/etm/config/loadconfigstore.cpp b/energyplugin/etm/config/loadconfigstore.cpp new file mode 100644 index 0000000..75bf672 --- /dev/null +++ b/energyplugin/etm/config/loadconfigstore.cpp @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync + +#include "loadconfigstore.h" +#include "plugininfo.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +LoadConfigStore::LoadConfigStore(QObject *parent) + : QObject{parent} +{ + if (load()) + qCDebug(dcNymeaEnergy()) << "[LoadConfigStore]" << m_configs.count() + << "config(s) de charge pilotée chargée(s) depuis" << filePath(); + else + qCDebug(dcNymeaEnergy()) << "[LoadConfigStore] Aucune config de charge pilotée — démarrage à vide."; +} + +QString LoadConfigStore::filePath() const +{ + const QString env = QString(qgetenv("NYMEA_ENERGY_LOAD_CONFIG")); + if (!env.isEmpty()) + return env; + return NymeaSettings::storagePath() + "/energy-load-configuration.json"; +} + +bool LoadConfigStore::load() +{ + const QString path = filePath(); + if (!QFileInfo::exists(path)) + return false; + + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] Ouverture impossible:" << path << file.errorString(); + return false; + } + const QByteArray data = file.readAll(); + file.close(); + + QJsonParseError err; + const QJsonDocument doc = QJsonDocument::fromJson(data, &err); + if (err.error != QJsonParseError::NoError) { + qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] JSON invalide:" << err.errorString(); + return false; + } + + LoadConfigs loaded; + const QJsonArray loads = doc.object().value("loads").toArray(); + for (const QJsonValue &v : loads) { + const LoadConfig c = LoadConfig::fromMap(v.toObject().toVariantMap()); + QString why; + if (!c.isValid(&why)) { + // Tolérance ascendante : on ignore une entrée corrompue plutôt que de tout perdre. + qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] Entrée ignorée (invalide):" << why; + continue; + } + loaded.append(c); + } + m_configs = loaded; + return true; +} + +bool LoadConfigStore::setConfigs(const LoadConfigs &configs, QString *error) +{ + // Validation EN BLOC : une seule entrée invalide → rejet total (rien persisté). + for (const LoadConfig &c : configs) { + QString why; + if (!c.isValid(&why)) { + if (error) *error = QStringLiteral("Config \"%1\" invalide : %2").arg(c.label(), why); + return false; + } + } + + const LoadConfigs previous = m_configs; + m_configs = configs; + + if (!save(error)) { + m_configs = previous; // rollback mémoire si l'écriture échoue + return false; + } + + emit changed(); + return true; +} + +bool LoadConfigStore::save(QString *error) const +{ + QJsonArray loads; + for (const LoadConfig &c : m_configs) + loads.append(QJsonObject::fromVariantMap(c.toMap())); + + QJsonObject root; + root.insert("version", 1); + root.insert("loads", loads); + + // Écriture atomique (QSaveFile = tmp + rename) — jamais de fichier mi-écrit. + QSaveFile file(filePath()); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + if (error) *error = QStringLiteral("Écriture impossible : %1").arg(file.errorString()); + return false; + } + file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); + if (!file.commit()) { + if (error) *error = QStringLiteral("Commit échoué : %1").arg(file.errorString()); + return false; + } + qCDebug(dcNymeaEnergy()) << "[LoadConfigStore]" << m_configs.count() << "config(s) persistée(s) dans" << filePath(); + return true; +} diff --git a/energyplugin/etm/config/loadconfigstore.h b/energyplugin/etm/config/loadconfigstore.h new file mode 100644 index 0000000..373b73f --- /dev/null +++ b/energyplugin/etm/config/loadconfigstore.h @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include +#include "../types/loadconfig.h" + +/*! + * \brief Store persistant des configs de charge pilotée (contrat etmvariableload §4). + * + * Master store unique : \c setConfigs() valide, **persiste** (\c /var/lib/nymea/\c + * energy-load-configuration.json, à côté de \c energy-manager-configuration.json) et émet + * \c changed() — l'arbitre reconstruit ses adaptateurs sur ce signal, le handler notifie l'app. + * + * Format fichier : \c {"version":1,"loads":[ , ... ]}. Le tableau \c loads[] + * est **mot pour mot** le \c LoadDescriptor §4 (jonction inter-repos avec l'app). + * + * \invariant Écriture atomique (fichier temporaire + \c rename) — jamais de fichier mi-écrit. + * \invariant \c setConfigs() rejette en bloc (rien persisté) si une seule entrée est invalide. + */ +class LoadConfigStore : public QObject +{ + Q_OBJECT +public: + explicit LoadConfigStore(QObject *parent = nullptr); + + /*! \brief Configs actuellement en mémoire (chargées au démarrage ou via setConfigs). */ + LoadConfigs configs() const { return m_configs; } + + /*! + * \brief Remplace l'ensemble des configs : valide tout → persiste → \c emit changed(). + * \param configs Nouvel ensemble (remplace l'existant — l'app envoie la liste complète). + * \param[out] error Message FR si rejet (aucune écriture dans ce cas). + * \return true si validé + persisté ; false si invalide ou échec d'écriture. + */ + bool setConfigs(const LoadConfigs &configs, QString *error = nullptr); + +signals: + /*! \brief Émis après une persistance réussie de \c setConfigs(). */ + void changed(); + +private: + QString filePath() const; + bool load(); + bool save(QString *error) const; + + LoadConfigs m_configs; +}; diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index d1f041a..381451b 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -5,6 +5,8 @@ #include "adapters/evadapter.h" #include "adapters/sgreadyadapter.h" #include "adapters/etmvariableloadadapter.h" +#include "config/loadconfigstore.h" +#include "types/loadconfig.h" #include "scheduler/rulebasedscheduler.h" #include "types/surpluscontext.h" #include "types/plan.h" @@ -29,6 +31,7 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm, QObject *parent) : SmartChargingManager(em, tm, sm, conf, parent) , m_scheduler(new RuleBasedScheduler(this, this)) + , m_tm(tm) { // --- L2 : watchdog fraîcheur compteur (SAFETY.md §L2) --- // La LOGIQUE (recordMeterUpdate / evaluateMeterFreshness) prend le temps en paramètre @@ -109,6 +112,44 @@ void EnergyArbitrator::registerEtmVariableLoadAdapter(EtmVariableLoadAdapter *ad qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] EtmVariableLoadAdapter enregistré:" << adapter->descriptor().label; } +void EnergyArbitrator::setLoadConfigStore(LoadConfigStore *store) +{ + m_loadConfigStore = store; + if (!store) + return; + connect(store, &LoadConfigStore::changed, this, &EnergyArbitrator::rebuildEtmVariableLoadAdapters); + rebuildEtmVariableLoadAdapters(); // construction initiale depuis la config persistée +} + +void EnergyArbitrator::rebuildEtmVariableLoadAdapters() +{ + // Purge des adaptateurs construits depuis la config précédente. + for (EtmVariableLoadAdapter *a : m_etmVariableLoadAdapters) + a->deleteLater(); + m_etmVariableLoadAdapters.clear(); + + if (!m_loadConfigStore) + return; + + for (const LoadConfig &c : m_loadConfigStore->configs()) { + // enabled==false : rôle déclaré mais EXCLU de l'arbitrage (contrat §9) — pas d'adaptateur. + if (!c.enabled()) + continue; + + LoadNeeds needs; + needs.dailyDeadline = c.needs().dailyDeadline(); + needs.minEnergyWhPerDay = c.needs().minEnergyWhPerDay(); + + auto *adapter = new EtmVariableLoadAdapter( + m_tm, c.id(), c.label(), c.powerLevelsInt(), c.maxPowerW(), c.priority(), needs, this); + m_etmVariableLoadAdapters[c.id()] = adapter; + qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] etmvariableload construit depuis config:" + << c.label() << "(" << (c.isDynamic() ? "dynamic" : "fixed") << ")"; + } + qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator]" << m_etmVariableLoadAdapters.count() + << "charge(s) etmvariableload active(s) (config)."; +} + void EnergyArbitrator::update(const QDateTime ¤tDateTime) { qCDebug(dcNymeaEnergy()) << "Updating smart charging"; @@ -223,7 +264,6 @@ void EnergyArbitrator::applyActionsToAdapters(const Slot &slot, const QDateTime if (adapter) adapter->applyAction(action, now); } - // EV (Setpoint) : dispatché par adjustEvChargers() amont jusqu'à 3g. } } @@ -267,8 +307,18 @@ void EnergyArbitrator::applyDegradedMode(const QDateTime &now) const QString reason = QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)"); - // etmvariableload (ECS/routeur) : repli setPowerSetpoint(0) force=true (contrat rév. 2 §9) — - // câblé avec m_etmVariableLoadAdapters en T4 (config). En attendant, EV + PAC ci-dessous. + // etmvariableload (ECS/routeur) : repli setPowerSetpoint(0) force=true (contrat rév. 2 §9). + // force=true → bypass anti-rebond honoré par le thing. Coupe toute charge pilotée pendant + // le silence compteur. + for (EtmVariableLoadAdapter *adapter : m_etmVariableLoadAdapters) { + LoadAction la; + la.loadId = adapter->descriptor().id; + la.kind = LoadAction::Setpoint; + la.powerW = 0; + la.force = true; + la.reason = reason; + adapter->applyAction(la, now); + } // EV : repli CONSERVATEUR — n'initie aucune charge. On clampe seulement une charge // DÉJÀ en cours au courant minimum (force=true, bypass lock). Une borne branchée mais diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 7882d2b..79bc4d4 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -14,6 +14,7 @@ class QTimer; class EvAdapter; class SgReadyAdapter; class EtmVariableLoadAdapter; +class LoadConfigStore; class RuleBasedScheduler; /*! @@ -94,6 +95,16 @@ public: */ void registerEtmVariableLoadAdapter(EtmVariableLoadAdapter *adapter); + /*! + * \brief Branche le store de config charge pilotée : construit les adaptateurs + * \c etmvariableload depuis la config et les reconstruit à chaque \c changed(). + * + * Remplace l'enregistrement en dur (le « 3g »). Les charges \c enabled==false ne sont + * PAS construites (exclues de l'arbitrage, contrat §9). La PAC SG-Ready reste hors config. + * \param store Store persistant (propriété de l'appelant ; non adopté). + */ + void setLoadConfigStore(LoadConfigStore *store); + /*! * \brief Mode dégradé L2 actif (compteur muet > 90 s) — override de SmartChargingManager. * \return \c true tant que les consignes de repli L2 tiennent ; \c false en régime normal. @@ -135,7 +146,7 @@ protected: * 3. verifyOverloadProtection() + verifyOverloadProtectionRecovery() * (si \c m_degradedMode actif : retour immédiat — planification/dispatch suspendus, L2) * 4. m_scheduler->getPlan() → log des decisionReason - * 5. applyActionsToAdapters() (SG-Ready State) + adjustEvChargers() (EV) → dispatch + * 5. applyActionsToAdapters() (etmvariableload Setpoint W + SG-Ready State) + adjustEvChargers() (EV) → dispatch * * \param currentDateTime Instant courant (timer ou simulation). */ @@ -175,6 +186,14 @@ private: */ void applyActionsToAdapters(const Slot &slot, const QDateTime &now); + /*! + * \brief (Re)construit \c m_etmVariableLoadAdapters depuis \c m_loadConfigStore. + * Purge les adaptateurs existants (deleteLater) puis crée un \c EtmVariableLoadAdapter par + * config \c enabled==true (mode dérivé de \c powerLevels). Appelé au branchement du store + * et à chaque \c LoadConfigStore::changed(). + */ + void rebuildEtmVariableLoadAdapters(); + /*! * \brief Déclencheur RÉEL du watchdog L2 (SAFETY.md §L2) — slot de \c m_meterWatchdog * (QTimer 30 s, horloge murale ; câblé sous \c \#ifndef ENERGY_SIMULATION). @@ -206,6 +225,8 @@ private: QHash m_adapters; //!< loadId (ThingId string) → EvAdapter*. QHash m_sgReadyAdapters; //!< loadId → SgReadyAdapter* (PAC). QHash m_etmVariableLoadAdapters; //!< loadId → ECS/routeur. + ThingManager *m_tm = nullptr; //!< ThingManager (pour construire les adaptateurs config). + LoadConfigStore *m_loadConfigStore = nullptr; //!< Store config charge pilotée (non adopté). // --- L2 watchdog fraîcheur compteur (SAFETY.md §L2) --- QTimer *m_meterWatchdog = nullptr; //!< Tick 30 s, indépendant des signaux compteur. diff --git a/energyplugin/etm/etm.pri b/energyplugin/etm/etm.pri index 6de4fec..20e1589 100644 --- a/energyplugin/etm/etm.pri +++ b/energyplugin/etm/etm.pri @@ -3,6 +3,8 @@ HEADERS += \ $$PWD/types/loaddescriptor.h \ $$PWD/types/surpluscontext.h \ $$PWD/types/plan.h \ + $$PWD/types/loadconfig.h \ + $$PWD/config/loadconfigstore.h \ $$PWD/adapters/iloadadapter.h \ $$PWD/scheduler/ischeduler.h \ $$PWD/adapters/evadapter.h \ @@ -12,6 +14,8 @@ HEADERS += \ $$PWD/energyarbitrator.h \ SOURCES += \ + $$PWD/types/loadconfig.cpp \ + $$PWD/config/loadconfigstore.cpp \ $$PWD/adapters/evadapter.cpp \ $$PWD/adapters/sgreadyadapter.cpp \ $$PWD/adapters/etmvariableloadadapter.cpp \ diff --git a/energyplugin/etm/types/loadconfig.cpp b/energyplugin/etm/types/loadconfig.cpp new file mode 100644 index 0000000..3349280 --- /dev/null +++ b/energyplugin/etm/types/loadconfig.cpp @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync + +#include "loadconfig.h" +#include + +// ---- LoadConfigNeeds ------------------------------------------------------- + +QVariantMap LoadConfigNeeds::toMap() const +{ + QVariantMap m; + if (!m_dailyDeadline.isEmpty()) + m.insert("dailyDeadline", m_dailyDeadline); + if (m_minEnergyWhPerDay > 0) + m.insert("minEnergyWhPerDay", m_minEnergyWhPerDay); + return m; +} + +LoadConfigNeeds LoadConfigNeeds::fromMap(const QVariantMap &map) +{ + LoadConfigNeeds n; + n.m_dailyDeadline = map.value("dailyDeadline").toString(); + n.m_minEnergyWhPerDay = map.value("minEnergyWhPerDay").toInt(); + return n; +} + +// ---- LoadConfig ------------------------------------------------------------ + +QVariantList LoadConfig::powerLevels() const +{ + QVariantList list; + for (int l : m_powerLevels) + list.append(l); + return list; +} + +void LoadConfig::setPowerLevels(const QVariantList &v) +{ + m_powerLevels.clear(); + for (const QVariant &item : v) + m_powerLevels.append(item.toInt()); + // Tri croissant + déduplication (contrat §6 : l'energymanager re-trie par sécurité). + std::sort(m_powerLevels.begin(), m_powerLevels.end()); + m_powerLevels.erase(std::unique(m_powerLevels.begin(), m_powerLevels.end()), m_powerLevels.end()); +} + +bool LoadConfig::isValid(QString *error) const +{ + auto fail = [&](const QString &msg) { if (error) *error = msg; return false; }; + + if (m_adapter != QStringLiteral("etmvariableload")) + return fail(QStringLiteral("adapter doit être \"etmvariableload\" (reçu: %1)").arg(m_adapter)); + if (m_id.isEmpty()) + return fail(QStringLiteral("id de charge vide")); + if (m_mode != QStringLiteral("fixed") && m_mode != QStringLiteral("dynamic")) + return fail(QStringLiteral("mode doit être \"fixed\" ou \"dynamic\" (reçu: %1)").arg(m_mode)); + + if (m_mode == QStringLiteral("fixed")) { + if (m_powerLevels.isEmpty()) + return fail(QStringLiteral("mode fixed : powerLevels requis (non vide)")); + if (m_powerLevels.first() != 0) + return fail(QStringLiteral("mode fixed : powerLevels doit inclure 0")); + } else { // dynamic + if (m_maxPowerW <= 0) + return fail(QStringLiteral("mode dynamic : maxPowerW requis (> 0)")); + if (!m_powerLevels.isEmpty()) + return fail(QStringLiteral("mode dynamic : powerLevels doit être absent")); + } + return true; +} + +QVariantMap LoadConfig::toMap() const +{ + QVariantMap m; + m.insert("id", m_id); + m.insert("label", m_label); + m.insert("adapter", m_adapter); + m.insert("mode", m_mode); + if (!m_powerLevels.isEmpty()) + m.insert("powerLevels", powerLevels()); + m.insert("maxPowerW", m_maxPowerW); + m.insert("priority", m_priority); + m.insert("enabled", m_enabled); + const QVariantMap needs = m_needs.toMap(); + if (!needs.isEmpty()) + m.insert("needs", needs); + return m; +} + +LoadConfig LoadConfig::fromMap(const QVariantMap &map) +{ + LoadConfig c; + c.m_id = map.value("id").toString(); + c.m_label = map.value("label").toString(); + c.m_adapter = map.value("adapter", QStringLiteral("etmvariableload")).toString(); + c.m_mode = map.value("mode").toString(); + c.setPowerLevels(map.value("powerLevels").toList()); // trie + déduplique + c.m_maxPowerW = map.value("maxPowerW").toInt(); + c.m_priority = map.value("priority").toInt(); + c.m_enabled = map.value("enabled", true).toBool(); + c.m_needs = LoadConfigNeeds::fromMap(map.value("needs").toMap()); + return c; +} + +// ---- LoadConfigs ----------------------------------------------------------- + +QVariant LoadConfigs::get(int index) const +{ + return QVariant::fromValue(at(index)); +} + +void LoadConfigs::put(const QVariant &variant) +{ + append(variant.value()); +} diff --git a/energyplugin/etm/types/loadconfig.h b/energyplugin/etm/types/loadconfig.h new file mode 100644 index 0000000..5ed598f --- /dev/null +++ b/energyplugin/etm/types/loadconfig.h @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include +#include +#include +#include +#include + +/*! + * \brief Besoins énergétiques d'une charge pilotée (contrat etmvariableload §4 \c needs). + * + * Q_GADGET typé pour l'introspection JSON-RPC (champs self-describing côté app). + */ +class LoadConfigNeeds +{ + Q_GADGET + Q_PROPERTY(QString dailyDeadline READ dailyDeadline WRITE setDailyDeadline) + Q_PROPERTY(int minEnergyWhPerDay READ minEnergyWhPerDay WRITE setMinEnergyWhPerDay) +public: + LoadConfigNeeds() {} + + QString dailyDeadline() const { return m_dailyDeadline; } + void setDailyDeadline(const QString &v) { m_dailyDeadline = v; } + int minEnergyWhPerDay() const { return m_minEnergyWhPerDay; } + void setMinEnergyWhPerDay(int v) { m_minEnergyWhPerDay = v; } + + QVariantMap toMap() const; + static LoadConfigNeeds fromMap(const QVariantMap &map); + +private: + QString m_dailyDeadline; //!< "HH:MM" (vide = pas d'échéance quotidienne). + int m_minEnergyWhPerDay = 0; +}; +Q_DECLARE_METATYPE(LoadConfigNeeds) + +/*! + * \brief Déclaration de config d'une charge pilotée — contrat etmvariableload §4. + * + * \b Source de vérité inter-repos : la forme de \c toMap()/\c fromMap() (et donc des + * Q_PROPERTY) est **mot pour mot** le \c LoadDescriptor §4 émis par l'app + * (\c id, \c label, \c adapter, \c mode, \c powerLevels, \c maxPowerW, \c priority, + * \c enabled, \c needs). Ne PAS diverger — c'est ce que reçoit \c SetLoadConfig. + * + * Distinct de \c LoadDescriptor (etm/types) qui mire OPTIMIZER_PROTOCOL §5 (sans + * \c enabled / \c mode). Ce type est la config persistée, pas le contexte d'arbitrage. + */ +class LoadConfig +{ + Q_GADGET + Q_PROPERTY(QString id READ id WRITE setId) + Q_PROPERTY(QString label READ label WRITE setLabel) + Q_PROPERTY(QString adapter READ adapter WRITE setAdapter) + Q_PROPERTY(QString mode READ mode WRITE setMode) + Q_PROPERTY(QVariantList powerLevels READ powerLevels WRITE setPowerLevels) + Q_PROPERTY(int maxPowerW READ maxPowerW WRITE setMaxPowerW) + Q_PROPERTY(int priority READ priority WRITE setPriority) + Q_PROPERTY(bool enabled READ enabled WRITE setEnabled) + Q_PROPERTY(LoadConfigNeeds needs READ needs WRITE setNeeds) +public: + LoadConfig() {} + + QString id() const { return m_id; } + void setId(const QString &v) { m_id = v; } + QString label() const { return m_label; } + void setLabel(const QString &v) { m_label = v; } + QString adapter() const { return m_adapter; } + void setAdapter(const QString &v) { m_adapter = v; } + QString mode() const { return m_mode; } + void setMode(const QString &v) { m_mode = v; } + + //! powerLevels en QVariantList (introspection) ; trié + dédupliqué à l'écriture. + QVariantList powerLevels() const; + void setPowerLevels(const QVariantList &v); + //! Accès typé pour la construction de l'adaptateur. + QList powerLevelsInt() const { return m_powerLevels; } + + int maxPowerW() const { return m_maxPowerW; } + void setMaxPowerW(int v) { m_maxPowerW = v; } + int priority() const { return m_priority; } + void setPriority(int v) { m_priority = v; } + bool enabled() const { return m_enabled; } + void setEnabled(bool v) { m_enabled = v; } + LoadConfigNeeds needs() const { return m_needs; } + void setNeeds(const LoadConfigNeeds &v) { m_needs = v; } + + //! Vrai si *dynamic* (powerLevels vide). Sinon *fixed*. + bool isDynamic() const { return m_powerLevels.isEmpty(); } + + /*! + * \brief Valide la cohérence (contrat §4/§6) : adapter, id, mode, paliers/plafond. + * \param[out] error Message FR si invalide. + * \return true si la config est exploitable. + */ + bool isValid(QString *error = nullptr) const; + + QVariantMap toMap() const; + static LoadConfig fromMap(const QVariantMap &map); + +private: + QString m_id; + QString m_label; + QString m_adapter = QStringLiteral("etmvariableload"); + QString m_mode; + QList m_powerLevels; //!< Trié croissant, 0 inclus en *fixed* ; vide en *dynamic*. + int m_maxPowerW = 0; + int m_priority = 0; + bool m_enabled = true; + LoadConfigNeeds m_needs; +}; +Q_DECLARE_METATYPE(LoadConfig) + +/*! \brief Liste de LoadConfig — gadget pour registerObject(). */ +class LoadConfigs : public QList +{ + Q_GADGET + Q_PROPERTY(int count READ count) +public: + LoadConfigs() {} + LoadConfigs(const QList &other) : QList(other) {} + Q_INVOKABLE QVariant get(int index) const; + Q_INVOKABLE void put(const QVariant &variant); +}; +Q_DECLARE_METATYPE(LoadConfigs) diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 28583db..2c7fab3 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -26,16 +26,19 @@ #include "types/charginginfo.h" #include "smartchargingmanager.h" #include "spotmarket/spotmarketmanager.h" +#include "etm/types/loadconfig.h" +#include "etm/config/loadconfigstore.h" #include #include Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy) -NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketManager, SmartChargingManager *smartChargingManager, QObject *parent): +NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketManager, SmartChargingManager *smartChargingManager, LoadConfigStore *loadConfigStore, QObject *parent): JsonHandler{parent}, m_spotMarketManager{spotMarketManager}, - m_smartChargingManager{smartChargingManager} + m_smartChargingManager{smartChargingManager}, + m_loadConfigStore{loadConfigStore} { registerEnum(); @@ -47,6 +50,8 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana registerObject(); registerObject(); registerObject(); + registerObject(); + registerObject(); QVariantMap params, returns; QString description; @@ -139,6 +144,37 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana registerMethod("GetChargingSchedules", description, params, returns, Types::PermissionScopeControlThings); + // [ETM] Configuration des charges pilotées (interface etmvariableload, contrat §4). + params.clear(); returns.clear(); + description = "Get the configured controllable loads (etmvariableload)."; + returns.insert("loadConfigs", QVariantList() << objectRef()); + registerMethod("GetLoadConfig", description, params, returns, Types::PermissionScopeControlThings); + + params.clear(); returns.clear(); + description = "Replace the full set of controllable load configs. Rejected as a whole " + "(energyError) if any entry is invalid; persisted and applied atomically."; + // Schéma inline (pas objectRef) : le contrat §4 rend powerLevels conditionnel + // (absent si dynamic) et needs optionnel — objectRef rendrait TOUS les champs requis et + // rejetterait l'entrée §4 de l'app. Les clés "o:" marquent l'optionnel. Le GET reste typé + // objectRef (pack émet toujours tous les champs). + QVariantMap needsItem; + needsItem.insert("o:dailyDeadline", enumValueName(String)); + needsItem.insert("o:minEnergyWhPerDay", enumValueName(Uint)); + QVariantMap loadItem; + loadItem.insert("id", enumValueName(String)); + loadItem.insert("label", enumValueName(String)); + loadItem.insert("adapter", enumValueName(String)); + loadItem.insert("mode", enumValueName(String)); + loadItem.insert("o:powerLevels", QVariantList() << enumValueName(Uint)); + loadItem.insert("maxPowerW", enumValueName(Uint)); + loadItem.insert("priority", enumValueName(Uint)); + loadItem.insert("enabled", enumValueName(Bool)); + loadItem.insert("o:needs", needsItem); + params.insert("loadConfigs", QVariantList() << loadItem); + returns.insert("energyError", enumRef()); + registerMethod("SetLoadConfig", description, params, returns, Types::PermissionScopeControlThings); + + // Notifications params.clear(); description = "Emitted whenever the phase power limit configuration changes."; @@ -230,6 +266,23 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana emit ChargingSchedulesChanged(params); }); + // [ETM] LoadConfigChanged : émise après toute persistance réussie (SetLoadConfig ou + // édition externe du fichier relue). Porte la liste complète à jour. + params.clear(); + description = "Emitted whenever the controllable load configuration changed."; + params.insert("loadConfigs", QVariantList() << objectRef()); + registerNotification("LoadConfigChanged", description, params); + if (m_loadConfigStore) { + connect(m_loadConfigStore, &LoadConfigStore::changed, this, [this](){ + QVariantMap params; + QVariantList list; + foreach (const LoadConfig &c, m_loadConfigStore->configs()) + list << pack(c); + params.insert("loadConfigs", list); + emit LoadConfigChanged(params); + }); + } + // Spot market manager params.clear(); description = "Emitted whenever the spot market manager status changed."; @@ -433,6 +486,39 @@ JsonReply *NymeaEnergyJsonHandler::GetChargingSchedules(const QVariantMap ¶m return createReply(returns); } +JsonReply *NymeaEnergyJsonHandler::GetLoadConfig(const QVariantMap ¶ms) +{ + Q_UNUSED(params) + QVariantMap returns; + QVariantList list; + if (m_loadConfigStore) { + foreach (const LoadConfig &c, m_loadConfigStore->configs()) + list << pack(c); // inclut les charges enabled==false (éditables par l'app) + } + returns.insert("loadConfigs", list); + return createReply(returns); +} + +JsonReply *NymeaEnergyJsonHandler::SetLoadConfig(const QVariantMap ¶ms) +{ + if (!m_loadConfigStore) { + qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig sans store configuré."; + return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}}); + } + + LoadConfigs configs; + foreach (const QVariant &item, params.value("loadConfigs").toList()) + configs.append(LoadConfig::fromMap(item.toMap())); + + QString why; + if (!m_loadConfigStore->setConfigs(configs, &why)) { + qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig rejetée:" << why; + return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}}); + } + // store.setConfigs() a déjà persisté + émis changed() → l'arbitre reconstruit, la notif part. + return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}}); +} + void NymeaEnergyJsonHandler::sendSpotMarketConfigurationChangedNotification() { QVariantMap params; diff --git a/energyplugin/nymeaenergyjsonhandler.h b/energyplugin/nymeaenergyjsonhandler.h index c9d7989..045cf51 100644 --- a/energyplugin/nymeaenergyjsonhandler.h +++ b/energyplugin/nymeaenergyjsonhandler.h @@ -33,12 +33,13 @@ class SmartChargingManager; class SpotMarketManager; +class LoadConfigStore; class NymeaEnergyJsonHandler : public JsonHandler { Q_OBJECT public: - explicit NymeaEnergyJsonHandler(SpotMarketManager *spotMarketManager, SmartChargingManager *smartChargingManager, QObject *parent = nullptr); + explicit NymeaEnergyJsonHandler(SpotMarketManager *spotMarketManager, SmartChargingManager *smartChargingManager, LoadConfigStore *loadConfigStore = nullptr, QObject *parent = nullptr); QString name() const override; @@ -64,6 +65,9 @@ public: Q_INVOKABLE JsonReply *GetChargingSchedules(const QVariantMap ¶ms); + Q_INVOKABLE JsonReply *GetLoadConfig(const QVariantMap ¶ms); + Q_INVOKABLE JsonReply *SetLoadConfig(const QVariantMap ¶ms); + signals: void PhasePowerLimitChanged(const QVariantMap ¶ms); void AcquisitionToleranceChanged(const QVariantMap ¶ms); @@ -75,10 +79,12 @@ signals: void SpotMarketScoreEntriesChanged(const QVariantMap ¶ms); void ChargingSchedulesChanged(const QVariantMap ¶ms); void BatteryLevelConsiderationChanged(const QVariantMap ¶ms); + void LoadConfigChanged(const QVariantMap ¶ms); private: SpotMarketManager *m_spotMarketManager; SmartChargingManager *m_smartChargingManager = nullptr; + LoadConfigStore *m_loadConfigStore = nullptr; void sendSpotMarketConfigurationChangedNotification(); diff --git a/tests/auto/simulation/experience/energyexperienceenergymock.cpp b/tests/auto/simulation/experience/energyexperienceenergymock.cpp index df705a6..650b45d 100644 --- a/tests/auto/simulation/experience/energyexperienceenergymock.cpp +++ b/tests/auto/simulation/experience/energyexperienceenergymock.cpp @@ -34,6 +34,7 @@ #include "../../../energyplugin/spotmarket/spotmarketmanager.h" #ifdef ETM_ARBITRATOR #include "../../../energyplugin/etm/energyarbitrator.h" +#include "../../../energyplugin/etm/config/loadconfigstore.h" #endif #include @@ -84,16 +85,22 @@ void ExperiencePluginEnergyMock::init() EnergyManagerConfiguration *configuration = new EnergyManagerConfiguration(this); QNetworkAccessManager *networkManager = new QNetworkAccessManager(this); m_spotMarketManager = new SpotMarketManager(networkManager, this); + LoadConfigStore *loadConfigStore = nullptr; // [ETM] BEGIN — flip identique à energypluginnymea.cpp #ifdef ETM_ARBITRATOR qCDebug(dcEnergyExperience()) << "ETM_ARBITRATOR actif — EnergyArbitrator chargé (simulation)."; - m_smartChargingManager = new EnergyArbitrator(m_energyManager, thingManager(), m_spotMarketManager, configuration, this); + EnergyArbitrator *arbitrator = new EnergyArbitrator(m_energyManager, thingManager(), m_spotMarketManager, configuration, this); + // Store config charge pilotée câblé comme en prod (energypluginnymea.cpp) : permet le test + // d'injection RPC NymeaEnergy.Get/SetLoadConfig end-to-end (handler → store → arbitre). + loadConfigStore = new LoadConfigStore(this); + arbitrator->setLoadConfigStore(loadConfigStore); + m_smartChargingManager = arbitrator; #else m_smartChargingManager = new SmartChargingManager(m_energyManager, thingManager(), m_spotMarketManager, configuration, this); #endif // [ETM] END - m_nymeaEnergyJsonHandler = new NymeaEnergyJsonHandler(m_spotMarketManager, m_smartChargingManager, this); + m_nymeaEnergyJsonHandler = new NymeaEnergyJsonHandler(m_spotMarketManager, m_smartChargingManager, loadConfigStore, this); jsonRpcServer()->registerExperienceHandler(m_nymeaEnergyJsonHandler, 0, 2); } diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index cd0f528..56221a6 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -35,10 +35,18 @@ using namespace nymeaserver; #include "../../mocks/spotmarketprovider/spotmarketdataprovidermock.h" #ifdef ETM_ARBITRATOR #include "../../../energyplugin/etm/energyarbitrator.h" -#include "../../../energyplugin/etm/adapters/ecsrelayadapter.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/types/loadconfig.h" +#include "../../../energyplugin/etm/config/loadconfigstore.h" #endif +#include +#include +#include + #include #include #include @@ -57,88 +65,92 @@ void Simulation::testEcsSurplusPV() #ifndef ETM_ARBITRATOR QSKIP("testEcsSurplusPV nécessite ETM_ARBITRATOR."); #else - // Préambule harnais (comme run()) : DB + (ré)initialisation → expérience chargée et - // arbitre FRAIS (pas d'adaptateur ECS résiduel d'un test précédent). + // [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(); - // --- Root meter --- QUuid meterThingId = addMeter(); - QVERIFY2(!meterThingId.isNull(), "meter thingId invalide"); + QVERIFY(!meterThingId.isNull()); m_experiencePlugin->energyManager()->setRootMeter(meterThingId); Thing *meterThing = thingManager->findConfiguredThing(meterThingId); QVERIFY(meterThing); meterThing->setStateValue("connected", true); - // --- Deux relais ECS : palier 1 = relayA (1200 W), palier 2 = A+B (2400 W) --- - QUuid relayAId = addPowerSwitch(1200, 26661); - QUuid relayBId = addPowerSwitch(1200, 26662); - QVERIFY(!relayAId.isNull() && !relayBId.isNull()); - Thing *relayA = thingManager->findConfiguredThing(relayAId); - Thing *relayB = thingManager->findConfiguredThing(relayBId); - QVERIFY(relayA && relayB); + QUuid ecsId = addEtmVariableLoad(27001); + QVERIFY(!ecsId.isNull()); + Thing *ecsThing = thingManager->findConfiguredThing(ecsId); + QVERIFY(ecsThing); - // minOn = 300 s (protection compresseur) ; minOff = 0 (pas de délai de redémarrage ici). - const int minOnS = 300; - EcsRelayAdapter *ecs = new EcsRelayAdapter( - thingManager, "ecs-surplus-test", "Chauffe-eau (test surplus)", - QList({0, 1200, 2400}), - QList>({ {}, {relayAId.toString()}, {relayAId.toString(), relayBId.toString()} }), - minOnS, 0, 1, arbitrator); - arbitrator->registerEcsAdapter(ecs); + EtmVariableLoadAdapter *ecs = new EtmVariableLoadAdapter( + thingManager, ecsId.toString(), "ECS variable", + QList({0, 1200, 2400}), 2400, 1, LoadNeeds(), arbitrator); + arbitrator->registerEtmVariableLoadAdapter(ecs); - const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); - // currentPower compteur : < 0 = export (surplus PV), > 0 = import (réseau). - auto setMeterW = [&](double signedW){ meterThing->setStateValue("currentPower", signedW); }; + 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(); }; - // ============ Régime 1 — cascade montante sur surplus (export) ============ - setMeterW(-1000); cycle(t0); // 1000 W < palier 1 → éteint - QCOMPARE(ecs->currentStage(), 0); - QCOMPARE(relayA->stateValue("power").toBool(), false); + // --- 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); - setMeterW(-1500); cycle(t0); // 1500 W → palier 1 (relayA) - QCOMPARE(ecs->currentStage(), 1); - QCOMPARE(relayA->stateValue("power").toBool(), true); - QCOMPARE(relayB->stateValue("power").toBool(), false); + // --- 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); - setMeterW(-2500); cycle(t0.addSecs(1)); // 2500 W → palier 2 (montée autorisée même sous minOn) - QCOMPARE(ecs->currentStage(), 2); - QCOMPARE(relayA->stateValue("power").toBool(), true); - QCOMPARE(relayB->stateValue("power").toBool(), true); + // --- 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); - // ============ Régime 2 — anti-clignotement (recrédit, hors verrou) ============ - // T0+400 : minOn écoulé → plancher de verrou = 0. PV 2500, ECS tire 2400 → export net 100. - // budget = 100 + 2400 (recrédit conso) = 2500 → RESTE palier 2. Sans recrédit : 100 → éteint. - // Le plancher étant 0, c'est bien le RECRÉDIT qui maintient le palier, pas le verrou. - setMeterW(-100); cycle(t0.addSecs(400)); - QCOMPARE(ecs->currentStage(), 2); + // =================== 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); - // Redescente propre au palier 1 (import partiel, minOn écoulé) pour préparer le régime 3. - setMeterW(600); cycle(t0.addSecs(401)); // import 600 → budget = -600 + 2400 = 1800 → palier 1 - QCOMPARE(ecs->currentStage(), 1); - QCOMPARE(relayB->stateValue("power").toBool(), false); + EtmVariableLoadAdapter *router = new EtmVariableLoadAdapter( + tm2, rId.toString(), "Routeur PV", QList(), 3000, 1, LoadNeeds(), arb2); // powerLevels vide → dynamic + arb2->registerEtmVariableLoadAdapter(router); - // ============ Régime 3 — PROTECTION COMPRESSEUR : import < minOn → RESTE ============ - // lastSwitch = T0+401. T0+501 (elapsed 100 < minOn 300) : PV 0, ECS tire 1200 → import 1200. - // budget = -1200 + 1200 = 0 → le scheduler voudrait palier 0, MAIS plancher de verrou = 1. - setMeterW(1200); cycle(t0.addSecs(501)); - QCOMPARE(ecs->currentStage(), 1); // RESTE allumé — protection compresseur - QCOMPARE(relayA->stateValue("power").toBool(), true); - - // ============ Régime 4 — minOn écoulé → DÉLESTE ============ - // T0+801 (elapsed depuis T0+401 = 400 > minOn 300) : MÊME import 1200. Plancher = 0 → palier 0. - // Seul le TEMPS SIMULÉ a changé entre régime 3 et 4 : si le seam de temps était faux - // (horloge murale), la décision ne basculerait pas. Ce test prouve le seam ET la protection. - setMeterW(1200); cycle(t0.addSecs(801)); - QCOMPARE(ecs->currentStage(), 0); // DÉLESTE - QCOMPARE(relayA->stateValue("power").toBool(), false); + 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 } @@ -147,79 +159,282 @@ 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(); - // --- Root meter + un relais ECS (palier 1 = 2400 W) --- QUuid meterThingId = addMeter(); - QVERIFY2(!meterThingId.isNull(), "meter thingId invalide"); + QVERIFY(!meterThingId.isNull()); m_experiencePlugin->energyManager()->setRootMeter(meterThingId); Thing *meterThing = thingManager->findConfiguredThing(meterThingId); QVERIFY(meterThing); meterThing->setStateValue("connected", true); - QUuid relayAId = addPowerSwitch(2400, 26661); - QVERIFY(!relayAId.isNull()); - Thing *relayA = thingManager->findConfiguredThing(relayAId); - QVERIFY(relayA); + QUuid ecsId = addEtmVariableLoad(27010); + QVERIFY(!ecsId.isNull()); + Thing *ecsThing = thingManager->findConfiguredThing(ecsId); + QVERIFY(ecsThing); - // minOn = 300 s (protection compresseur) ; minOff = 0. - EcsRelayAdapter *ecs = new EcsRelayAdapter( - thingManager, "ecs-fallback-test", "Chauffe-eau (test repli)", - QList({0, 2400}), - QList>({ {}, {relayAId.toString()} }), - 300, 0, 1, arbitrator); - arbitrator->registerEcsAdapter(ecs); + EtmVariableLoadAdapter *ecs = new EtmVariableLoadAdapter( + thingManager, ecsId.toString(), "ECS repli", + QList({0, 2400}), 2400, 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 allumé sur surplus, compteur frais à T0 --- + // ECS servi sur surplus, compteur frais à T0. arbitrator->recordMeterUpdate(t0); - setMeterW(-2500); cycle(t0); // surplus 2500 → palier 1 (lastSwitch ECS = T0) - QCOMPARE(ecs->currentStage(), 1); - QCOMPARE(relayA->stateValue("power").toBool(), true); + setLoadW(0); + setMeterW(-2500); cycle(t0); // budget 2500 → 2400 + QCOMPARE(qRound(ecs->currentSetpointW()), 2400); QVERIFY(!arbitrator->degradedMode()); - // --- Compteur muet > 90 s → mode dégradé ; le repli force=true coupe l'ECS MÊME sous minOn --- - // (ECS commuté à T0, elapsed 91 s < minOn 300 → normalement verrouillé ; force=true bypasse.) + // 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(ecs->currentStage(), 0); // coupé malgré minOn (sécurité bypasse l'anti-flapping) - QCOMPARE(relayA->stateValue("power").toBool(), false); + QCOMPARE(qRound(ecs->currentSetpointW()), 0); + QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 0); - // --- STABILITÉ : compteur toujours muet, plusieurs update() → l'ECS RESTE à 0 --- - // (planification suspendue : pas de getPlan() sur cache mort qui rallumerait l'ECS.) - // On garde même un faux surplus au compteur pour piéger une éventuelle replanification. + // 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(ecs->currentStage(), 0); - QCOMPARE(relayA->stateValue("power").toBool(), false); + QCOMPARE(qRound(ecs->currentSetpointW()), 0); } - // --- REPRISE : le compteur re-parle → degradedMode retombe → recalcul normal --- + // 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); +#endif +} - // Preuve "recalcul, pas restauration d'ancienne consigne" : surplus FAIBLE → l'ECS - // RESTE éteint (recalculé), il ne revient PAS à son ancien palier 1. - setMeterW(-1000); cycle(t0.addSecs(301)); // 1000 W < palier 1 → éteint - QCOMPARE(ecs->currentStage(), 0); +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()); - // Puis surplus suffisant → l'ECS resuit le surplus normalement. - setMeterW(-2500); cycle(t0.addSecs(302)); - QCOMPARE(ecs->currentStage(), 1); - QCOMPARE(relayA->stateValue("power").toBool(), true); + 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 : une fixed (thing réel) + une dynamic. + 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}}; + resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", loadConfigs}}); + QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); + + // 3. GetLoadConfig → round-trip des 2 entrées, champs §4 préservés (via pack/unpack Q_GADGET). + resp = injectAndWait("NymeaEnergy.GetLoadConfig"); + const QVariantList got = resp.toMap().value("params").toMap().value("loadConfigs").toList(); + QCOMPARE(got.size(), 2); + QVariantMap fixedGot; + for (const QVariant &v : got) + if (v.toMap().value("id").toString() == ecsId.toString()) fixedGot = v.toMap(); + 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")); + + // 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. Config invalide (fixed sans 0) → rejet en bloc, energyError InvalidParameter. + QVariantList bad; + bad << 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", bad}}); + QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorInvalidParameter")); + // Le rejet n'a rien écrasé : GetLoadConfig retourne toujours les 2 valides. + resp = injectAndWait("NymeaEnergy.GetLoadConfig"); + QCOMPARE(resp.toMap().value("params").toMap().value("loadConfigs").toList().size(), 2); + + qunsetenv("NYMEA_ENERGY_LOAD_CONFIG"); + QFile::remove(cfgPath); #endif } @@ -296,11 +511,11 @@ void Simulation::testSgReadySurplus() setMeterW(-3000); cycle(t0.addSecs(2400)); QCOMPARE(pac->currentState(), 4); - // ===================== Volet 4 : interaction budget PARTAGÉ ECS↔PAC ===================== - // Surplus 3000 W, ECS palier 1 = 2400 W, PAC P3 = 1500. Selon l'ordre de priorité, - // l'un se sert et l'autre voit le RELIQUAT → preuve du waterfall unifié (un seul budget). - // priority fixé à la création → on ré-initialise un arbitre frais par ordre testé. - auto runSharedBudget = [&](int ecsPrio, int pacPrio, int &ecsStageOut, int &pacStateOut) { + // ===================== 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(); @@ -314,17 +529,17 @@ void Simulation::testSgReadySurplus() QVERIFY(m2); m2->setStateValue("connected", true); - QUuid ke = addPowerSwitch(0, 26663); // relais ECS - QUuid j1 = addPowerSwitch(0, 26661); // relais PAC K1 - QUuid j2 = addPowerSwitch(0, 26662); // relais PAC K2 + 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); - // ECS : 1 palier à 2400 W, verrous à 0 (on teste le partage de budget, pas l'anti-rebond). - EcsRelayAdapter *ecs = new EcsRelayAdapter( - tm2, "ecs-wf", "ECS waterfall", - QList({0, 2400}), - QList>({ {}, {ke.toString()} }), - 0, 0, ecsPrio, arb); - arb->registerEcsAdapter(ecs); + // ECS : 1 palier à 2400 W (fixed). La charge ne tire encore rien (currentPowerW=0). + EtmVariableLoadAdapter *ecsWf = new EtmVariableLoadAdapter( + tm2, eId.toString(), "ECS waterfall", QList({0, 2400}), 2400, ecsPrio, LoadNeeds(), arb); + arb->registerEtmVariableLoadAdapter(ecsWf); SgReadyAdapter *pacWf = new SgReadyAdapter( tm2, "pac-wf", "PAC waterfall", @@ -333,123 +548,36 @@ void Simulation::testSgReadySurplus() pacPower, 300, pacPrio, arb); arb->registerSgReadyAdapter(pacWf); - m2->setStateValue("currentPower", -3000); // export 3000 W + m2->setStateValue("currentPower", -3000); // export 3000 W arb->simulationCallUpdate(t0); QCoreApplication::processEvents(); - ecsStageOut = ecs->currentStage(); + ecsSetpointOut = ecsWf->currentSetpointW(); pacStateOut = pacWf->currentState(); }; - int ecsStage = -1, pacState = -1; + double ecsSetpoint = -1; + int pacState = -1; - // ECS prioritaire (rang 1) : ECS se sert (2400) → reliquat 600 < P3 → PAC reste en NORMAL (2). - runSharedBudget(/*ecsPrio*/ 1, /*pacPrio*/ 2, ecsStage, pacState); - QCOMPARE(ecsStage, 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 éteint (palier 0). L'ordre de service s'inverse. - runSharedBudget(/*ecsPrio*/ 2, /*pacPrio*/ 1, ecsStage, pacState); - QCOMPARE(ecsStage, 0); + // 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 - const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); - - // Setup commun : arbitre frais + root meter. Retourne meter + thingManager via réf. - auto freshSetup = [&](EnergyArbitrator *&arb, ThingManager *&tm, Thing *&meter) { - cleanupTestCase(); - m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; - initTestCase(); - arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); - QVERIFY(arb); - tm = NymeaCore::instance()->thingManager(); - QUuid mId = addMeter(); - m_experiencePlugin->energyManager()->setRootMeter(mId); - meter = tm->findConfiguredThing(mId); - QVERIFY(meter); - meter->setStateValue("connected", true); - }; - - // ===================== Topologie 1 : ECS simple (1 relais, [0, 2000]) ===================== - { - EnergyArbitrator *arb; ThingManager *tm; Thing *meter; - freshSetup(arb, tm, meter); - QUuid r = addPowerSwitch(2000, 26661); - Thing *relay = tm->findConfiguredThing(r); - QVERIFY(relay); - EcsRelayAdapter *ecs = new EcsRelayAdapter( - tm, "ecs-1relay", "ECS simple", - QList({0, 2000}), - QList>({ {}, {r.toString()} }), - 0, 0, 1, arb); - arb->registerEcsAdapter(ecs); - - meter->setStateValue("currentPower", -2500); // surplus 2500 → palier 1 - arb->simulationCallUpdate(t0); QCoreApplication::processEvents(); - QCOMPARE(ecs->currentStage(), 1); - QCOMPARE(relay->stateValue("power").toBool(), true); - - meter->setStateValue("currentPower", 1000); // import 1000 → palier 0 (off) - arb->simulationCallUpdate(t0.addSecs(1)); QCoreApplication::processEvents(); - QCOMPARE(ecs->currentStage(), 0); - QCOMPARE(relay->stateValue("power").toBool(), false); - } - - // ============= Topologie 2 : ECS 3 relais 500/1000/2000 W (mapping NON-CASCADÉ) ============= - { - EnergyArbitrator *arb; ThingManager *tm; Thing *meter; - freshSetup(arb, tm, meter); - 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); - - // 8 niveaux binaires ; encodage bit0=r500, bit1=r1000, bit2=r2000. - EcsRelayAdapter *ecs = new EcsRelayAdapter( - tm, "ecs-3relay", "ECS 3 relais", - QList({0, 500, 1000, 1500, 2000, 2500, 3000, 3500}), - QList>({ - {}, // 0 - {r500.toString()}, // 500 - {r1000.toString()}, // 1000 - {r500.toString(), r1000.toString()}, // 1500 - {r2000.toString()}, // 2000 - {r500.toString(), r2000.toString()}, // 2500 - {r1000.toString(), r2000.toString()}, // 3000 - {r500.toString(), r1000.toString(), r2000.toString()}// 3500 - }), - 0, 0, 1, arb); - arb->registerEcsAdapter(ecs); - - // Surplus 1700 → palier 1500 = [r500, r1000] (r2000 éteint). - meter->setStateValue("currentPower", -1700); - arb->simulationCallUpdate(t0); QCoreApplication::processEvents(); - QCOMPARE(ecs->currentStage(), 3); - QCOMPARE(t500->stateValue("power").toBool(), true); - QCOMPARE(t1000->stateValue("power").toBool(), true); - QCOMPARE(t2000->stateValue("power").toBool(), false); - - // Transition NON-CASCADÉE 1500 → 2000 : à stage 3 l'ECS mesure 1500 W (r500+r1000), - // export net 700 → budget 700+1500=2200 → palier 2000 = [r2000] SEUL. - // Vérifie le set FINAL : r500 OFF, r1000 OFF, r2000 ON (commutation de 3 relais). - meter->setStateValue("currentPower", -700); - arb->simulationCallUpdate(t0.addSecs(1)); QCoreApplication::processEvents(); - QCOMPARE(ecs->currentStage(), 4); - QCOMPARE(t500->stateValue("power").toBool(), false); - QCOMPARE(t1000->stateValue("power").toBool(), false); - QCOMPARE(t2000->stateValue("power").toBool(), true); - } -#endif + // [T2] DÉSACTIVÉ — les topologies relais (1 relais, 3 relais non-cascadé, mapping + // powerLevels↔combinaisons) sont désormais la responsabilité du THING etmvariableload, plus + // de l'energymanager (contrat rév. 2 §1/§3 : la combinatoire matérielle vit dans le thing). + // Côté moteur, seul l'arrondi au powerLevels (Setpoint W) sera testé — TODO T3. + QSKIP("TODO T3 — la combinatoire relais part dans le thing ; le moteur ne teste que l'arrondi W."); } void Simulation::run_data() diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 6090452..64b5e2a 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -56,20 +56,27 @@ private slots: void run_data(); void run(); - // Waterfall ECS (EcsRelayAdapter) : cascade surplus, anti-clignotement (recrédit), - // et protection compresseur (import < minOn → RESTE ; import > minOn → déleste). + // [T3] etmvariableload (ECS/routeur) : arrondi fixed (powerLevels) + clamp dynamic + // (maxPowerW), recrédit currentPowerW (anti-clignotement), délestage, round-trip powerSetpoint. void testEcsSurplusPV(); - // Watchdog L2 : compteur muet >90 s → mode dégradé (ECS off force=true, bypass minOn), - // planification suspendue (ECS reste 0 sur N cycles), reprise au retour du compteur. + // [T4] Watchdog L2 : compteur muet >90 s → etmvariableload setPowerSetpoint(0) force=true, + // planif suspendue (reste 0 sur N cycles), reprise au retour compteur. void testMeterSilentFallback(); + // [T4] Persistance LoadConfig (§4) : SetConfigs → fichier → relecture round-trip + validation. + void testLoadConfigPersistence(); + // [T4] Construction des adaptateurs depuis la config : enabled servi, disabled exclu (§9). + void testLoadConfigBuildsAdapters(); + // [T4] Injection RPC end-to-end : NymeaEnergy.Get/SetLoadConfig (couche consommée par l'app). + void testLoadConfigRpc(); + // SG-Ready (PAC) : montée d'états sur surplus, hystérésis 3↔4, protection court-cycling, - // et interaction budget PARTAGÉ ECS↔PAC (preuve du waterfall unifié 3e). + // + Volet 4 budget PARTAGÉ etmvariableload(ECS)↔PAC (waterfall unifié, inversion priorité). void testSgReadySurplus(); - // ECS topologies : 1 relais (simple) + 3 relais valeurs différentes (mapping NON-CASCADÉ, - // transition 1500→2000 qui commute 3 relais) — vérifie le set de relais final correct. + // [T2] DÉSACTIVÉ (QSKIP) — la combinatoire relais part dans le thing etmvariableload ; + // le moteur ne testera plus que l'arrondi en watts. TODO T3. void testEcsRelayTopologies(); void printStates(Thing *thing);