feat(etm): config charge pilotée persistée (Get/SetLoadConfig) + repli L2

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<LoadConfig> ; 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) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-06-28 09:51:38 +02:00
parent 3cb79f5918
commit 7184fe4e1d
13 changed files with 956 additions and 233 deletions

View File

@ -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);
}

View File

@ -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 <nymeasettings.h>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QJsonParseError>
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;
}

View File

@ -0,0 +1,49 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QObject>
#include <QString>
#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":[ <LoadConfig §4>, ... ]}. 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;
};

View File

@ -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 &currentDateTime)
{
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

View File

@ -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<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
QHash<QString, SgReadyAdapter *> m_sgReadyAdapters; //!< loadId → SgReadyAdapter* (PAC).
QHash<QString, EtmVariableLoadAdapter *> 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.

View File

@ -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 \

View File

@ -0,0 +1,115 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "loadconfig.h"
#include <algorithm>
// ---- 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<LoadConfig>());
}

View File

@ -0,0 +1,126 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QObject>
#include <QString>
#include <QList>
#include <QVariant>
#include <QVariantList>
#include <QVariantMap>
/*!
* \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<int> 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<int> 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<LoadConfig, LoadConfigs>(). */
class LoadConfigs : public QList<LoadConfig>
{
Q_GADGET
Q_PROPERTY(int count READ count)
public:
LoadConfigs() {}
LoadConfigs(const QList<LoadConfig> &other) : QList(other) {}
Q_INVOKABLE QVariant get(int index) const;
Q_INVOKABLE void put(const QVariant &variant);
};
Q_DECLARE_METATYPE(LoadConfigs)

View File

@ -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 <energymanager.h>
#include <QLoggingCategory>
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<ChargingInfo::ChargingMode>();
@ -47,6 +50,8 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
registerObject<ScoreEntry, ScoreEntries>();
registerObject<ChargingAction>();
registerObject<ChargingSchedule, ChargingSchedules>();
registerObject<LoadConfigNeeds>();
registerObject<LoadConfig, LoadConfigs>();
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<LoadConfig>());
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<LoadConfig>) : 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<LoadConfig> (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<EnergyManager::EnergyError>());
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<LoadConfig>());
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<LoadConfig>(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 &param
return createReply(returns);
}
JsonReply *NymeaEnergyJsonHandler::GetLoadConfig(const QVariantMap &params)
{
Q_UNUSED(params)
QVariantMap returns;
QVariantList list;
if (m_loadConfigStore) {
foreach (const LoadConfig &c, m_loadConfigStore->configs())
list << pack<LoadConfig>(c); // inclut les charges enabled==false (éditables par l'app)
}
returns.insert("loadConfigs", list);
return createReply(returns);
}
JsonReply *NymeaEnergyJsonHandler::SetLoadConfig(const QVariantMap &params)
{
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;

View File

@ -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 &params);
Q_INVOKABLE JsonReply *GetLoadConfig(const QVariantMap &params);
Q_INVOKABLE JsonReply *SetLoadConfig(const QVariantMap &params);
signals:
void PhasePowerLimitChanged(const QVariantMap &params);
void AcquisitionToleranceChanged(const QVariantMap &params);
@ -75,10 +79,12 @@ signals:
void SpotMarketScoreEntriesChanged(const QVariantMap &params);
void ChargingSchedulesChanged(const QVariantMap &params);
void BatteryLevelConsiderationChanged(const QVariantMap &params);
void LoadConfigChanged(const QVariantMap &params);
private:
SpotMarketManager *m_spotMarketManager;
SmartChargingManager *m_smartChargingManager = nullptr;
LoadConfigStore *m_loadConfigStore = nullptr;
void sendSpotMarketConfigurationChangedNotification();

View File

@ -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 <jsonrpc/jsonrpcserver.h>
@ -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);
}

View File

@ -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 <QDir>
#include <QFile>
#include <QFileInfo>
#include <QHash>
#include <QtMath>
#include <QtGlobal>
@ -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<EnergyArbitrator *>(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<int>({0, 1200, 2400}),
QList<QList<QString>>({ {}, {relayAId.toString()}, {relayAId.toString(), relayBId.toString()} }),
minOnS, 0, 1, arbitrator);
arbitrator->registerEcsAdapter(ecs);
EtmVariableLoadAdapter *ecs = new EtmVariableLoadAdapter(
thingManager, ecsId.toString(), "ECS variable",
QList<int>({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<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb2);
ThingManager *tm2 = NymeaCore::instance()->thingManager();
QUuid mId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(mId);
Thing *m2 = tm2->findConfiguredThing(mId);
QVERIFY(m2);
m2->setStateValue("connected", true);
QUuid rId = addEtmVariableLoad(27002);
Thing *rThing = tm2->findConfiguredThing(rId);
QVERIFY(rThing);
rThing->setStateValue("currentPowerW", 0);
// 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<int>(), 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<EnergyArbitrator *>(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<int>({0, 2400}),
QList<QList<QString>>({ {}, {relayAId.toString()} }),
300, 0, 1, arbitrator);
arbitrator->registerEcsAdapter(ecs);
EtmVariableLoadAdapter *ecs = new EtmVariableLoadAdapter(
thingManager, ecsId.toString(), "ECS repli",
QList<int>({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<int>({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<int>({0, 600, 1200}));
QCOMPARE(r.maxPowerW(), 1200);
QCOMPARE(r.priority(), 2);
QVERIFY(r.enabled());
QCOMPARE(r.needs().dailyDeadline(), QString("06:00"));
QCOMPARE(r.needs().minEnergyWhPerDay(), 4000);
// Validation : une config fixed sans 0 dans powerLevels est rejetée EN BLOC (rien persisté).
QVariantMap badEntry = entry;
badEntry["powerLevels"] = QVariantList() << 600 << 1200; // pas de 0
LoadConfigs bad;
bad.append(LoadConfig::fromMap(badEntry));
LoadConfigStore store3;
QString err2;
QVERIFY(!store3.setConfigs(bad, &err2));
QVERIFY(!err2.isEmpty());
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
#endif
}
void Simulation::testLoadConfigBuildsAdapters()
{
#ifndef ETM_ARBITRATOR
QSKIP("testLoadConfigBuildsAdapters nécessite ETM_ARBITRATOR.");
#else
// SetConfigs → store.changed → l'arbitre (re)construit les adaptateurs etmvariableload.
// enabled==true servi par le surplus ; enabled==false JAMAIS piloté (exclu, contrat §9).
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arbitrator);
ThingManager *thingManager = NymeaCore::instance()->thingManager();
QUuid meterId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(meterId);
Thing *meter = thingManager->findConfiguredThing(meterId);
QVERIFY(meter);
meter->setStateValue("connected", true);
QUuid id1 = addEtmVariableLoad(27020); // enabled
QUuid id2 = addEtmVariableLoad(27021); // disabled
Thing *t1 = thingManager->findConfiguredThing(id1);
Thing *t2 = thingManager->findConfiguredThing(id2);
QVERIFY(t1 && t2);
t1->setStateValue("currentPowerW", 0);
t2->setStateValue("currentPowerW", 0);
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-build.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
LoadConfigStore *store = new LoadConfigStore(arbitrator);
arbitrator->setLoadConfigStore(store);
LoadConfigs cfgs;
cfgs.append(LoadConfig::fromMap(QVariantMap{
{"id", id1.toString()}, {"label", "ECS actif"}, {"adapter", "etmvariableload"},
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 2400}, {"maxPowerW", 2400},
{"priority", 1}, {"enabled", true}}));
cfgs.append(LoadConfig::fromMap(QVariantMap{
{"id", id2.toString()}, {"label", "ECS désactivé"}, {"adapter", "etmvariableload"},
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 1200}, {"maxPowerW", 1200},
{"priority", 2}, {"enabled", false}}));
QString err;
QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8()); // persiste + changed → rebuild
// Surplus large (5000 W) : assez pour servir les DEUX si elles étaient actives.
meter->setStateValue("currentPower", -5000);
arbitrator->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
QCoreApplication::processEvents();
QCOMPARE(qRound(t1->stateValue("powerSetpoint").toDouble()), 2400); // enabled → servi
QCOMPARE(qRound(t2->stateValue("powerSetpoint").toDouble()), 0); // disabled → jamais piloté
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
#endif
}
void Simulation::testLoadConfigRpc()
{
#ifndef ETM_ARBITRATOR
QSKIP("testLoadConfigRpc nécessite ETM_ARBITRATOR.");
#else
// End-to-end JSON-RPC (la couche que l'app consomme) : GetLoadConfig vide → SetLoadConfig
// (fixed réelle + dynamic) → energyError NoError → GetLoadConfig round-trip → effet RÉEL sur
// le thing (RPC → store → changed → arbitre reconstruit → adaptateur → powerSetpoint) →
// rejet d'une config invalide.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-rpc.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); // avant initTestCase → store sur ce chemin
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arbitrator);
ThingManager *thingManager = NymeaCore::instance()->thingManager();
QUuid meterId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(meterId);
Thing *meter = thingManager->findConfiguredThing(meterId);
QVERIFY(meter);
meter->setStateValue("connected", true);
QUuid ecsId = addEtmVariableLoad(27030); // thing réel piloté par la config fixed
Thing *ecsThing = thingManager->findConfiguredThing(ecsId);
QVERIFY(ecsThing);
ecsThing->setStateValue("currentPowerW", 0);
// 1. GetLoadConfig initial → vide.
QVariant resp = injectAndWait("NymeaEnergy.GetLoadConfig");
QVERIFY(resp.toMap().value("params").toMap().value("loadConfigs").toList().isEmpty());
// 2. SetLoadConfig : 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<int>({0, 2400}),
QList<QList<QString>>({ {}, {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<int>({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<EnergyArbitrator *>(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<int>({0, 2000}),
QList<QList<QString>>({ {}, {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<int>({0, 500, 1000, 1500, 2000, 2500, 3000, 3500}),
QList<QList<QString>>({
{}, // 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()

View File

@ -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);