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:
parent
3cb79f5918
commit
7184fe4e1d
@ -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);
|
||||
}
|
||||
|
||||
118
energyplugin/etm/config/loadconfigstore.cpp
Normal file
118
energyplugin/etm/config/loadconfigstore.cpp
Normal 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;
|
||||
}
|
||||
49
energyplugin/etm/config/loadconfigstore.h
Normal file
49
energyplugin/etm/config/loadconfigstore.h
Normal 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;
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 \
|
||||
|
||||
115
energyplugin/etm/types/loadconfig.cpp
Normal file
115
energyplugin/etm/types/loadconfig.cpp
Normal 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>());
|
||||
}
|
||||
126
energyplugin/etm/types/loadconfig.h
Normal file
126
energyplugin/etm/types/loadconfig.h
Normal 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)
|
||||
@ -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 ¶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<LoadConfig>(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;
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user