feat(etm): câblage RelayRouter + repli L2 unifié (rév. 3) — routeur vivant, sécurité fermée

L'arbitre construit et pilote les routeurs ; le repli L2 couvre les DEUX types ENSEMBLE
(atomique : pas d'intermédiaire avec charge active sans repli).

- Map unique m_loadAdapters (QHash<QString, ILoadAdapter*>) : RelayRouter + EtmVariableLoadAdapter
  cohabitent par polymorphisme. registerRelayRouter() ajouté.
- rebuildLoadAdapters() : la distinction de TYPE vit ICI — RelayRouter si relays[], sinon
  EtmVariableLoadAdapter. Au-dessus tout est ILoadAdapter (Setpoint W).
- Dispatch Setpoint AGNOSTIQUE au type : routage par loadId, le polymorphisme absorbe (pas de
  if(RelayRouter)). Le scheduler distingue Setpoint (etmvariableload + relay-router) vs State
  (sg-ready) — nature de l'action, pas classe concrète.
- Repli L2 : applyDegradedMode boucle sur TOUS les ILoadAdapter → Setpoint(0) force=true. Pour
  le RelayRouter, coupe TOUS les relais (bypass minOn). Ferme le trou T2 pour le routeur.
- testMeterSilentFallback étendu au cas RELAIS : compteur muet → 2 relais OFF force=true,
  restent OFF sur N cycles. Certifie la sécurité relais.

Build prod 0/0 ; suite (config/L2/migrés) verte. Étape qui rouvre le déploiement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-06-28 12:40:28 +02:00
parent e16aca4d1a
commit 88626cfd56
4 changed files with 132 additions and 36 deletions

View File

@ -5,6 +5,7 @@
#include "adapters/evadapter.h"
#include "adapters/sgreadyadapter.h"
#include "adapters/etmvariableloadadapter.h"
#include "adapters/relayrouter.h"
#include "config/loadconfigstore.h"
#include "types/loadconfig.h"
#include "scheduler/rulebasedscheduler.h"
@ -103,13 +104,25 @@ void EnergyArbitrator::registerSgReadyAdapter(SgReadyAdapter *adapter)
void EnergyArbitrator::registerEtmVariableLoadAdapter(EtmVariableLoadAdapter *adapter)
{
const QString id = adapter->descriptor().id;
if (m_etmVariableLoadAdapters.contains(id)) {
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] EtmVariableLoadAdapter déjà enregistré:" << id;
if (m_loadAdapters.contains(id)) {
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] charge pilotée déjà enregistrée:" << id;
return;
}
adapter->setParent(this);
m_etmVariableLoadAdapters[id] = adapter;
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] EtmVariableLoadAdapter enregistré:" << adapter->descriptor().label;
m_loadAdapters[id] = adapter; // upcast EtmVariableLoadAdapter* → ILoadAdapter*
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] etmvariableload enregistré:" << adapter->descriptor().label;
}
void EnergyArbitrator::registerRelayRouter(RelayRouter *adapter)
{
const QString id = adapter->descriptor().id;
if (m_loadAdapters.contains(id)) {
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] charge pilotée déjà enregistrée:" << id;
return;
}
adapter->setParent(this);
m_loadAdapters[id] = adapter; // upcast RelayRouter* → ILoadAdapter*
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] relay-router enregistré:" << adapter->descriptor().label;
}
void EnergyArbitrator::setLoadConfigStore(LoadConfigStore *store)
@ -117,16 +130,18 @@ 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
connect(store, &LoadConfigStore::changed, this, &EnergyArbitrator::rebuildLoadAdapters);
rebuildLoadAdapters(); // construction initiale depuis la config persistée
}
void EnergyArbitrator::rebuildEtmVariableLoadAdapters()
void EnergyArbitrator::rebuildLoadAdapters()
{
// Purge des adaptateurs construits depuis la config précédente.
for (EtmVariableLoadAdapter *a : m_etmVariableLoadAdapters)
a->deleteLater();
m_etmVariableLoadAdapters.clear();
// Purge des adaptateurs construits depuis la config précédente (ILoadAdapter n'est pas QObject :
// les concrets le sont — dynamic_cast pour deleteLater).
for (ILoadAdapter *a : m_loadAdapters)
if (QObject *o = dynamic_cast<QObject *>(a))
o->deleteLater();
m_loadAdapters.clear();
if (!m_loadConfigStore)
return;
@ -140,14 +155,23 @@ void EnergyArbitrator::rebuildEtmVariableLoadAdapters()
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") << ")";
// La distinction de TYPE vit ICI (rév. 3) ; au-dessus, tout est ILoadAdapter (Setpoint W).
ILoadAdapter *adapter = nullptr;
if (c.isRelayRouter()) {
adapter = new RelayRouter(m_tm, c.id(), c.label(), c.relaysList(),
c.minOnS(), c.minOffS(), c.priority(), needs, this);
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] relay-router construit depuis config:"
<< c.label() << "(" << c.relaysList().count() << "relais)";
} else {
adapter = new EtmVariableLoadAdapter(m_tm, c.id(), c.label(), c.powerLevelsInt(),
c.maxPowerW(), c.priority(), needs, this);
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] etmvariableload construit depuis config:"
<< c.label() << "(" << (c.isDynamic() ? "dynamic" : "fixed") << ")";
}
m_loadAdapters[c.id()] = adapter;
}
qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator]" << m_etmVariableLoadAdapters.count()
<< "charge(s) etmvariableload active(s) (config).";
qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator]" << m_loadAdapters.count()
<< "charge(s) pilotée(s) active(s) (config).";
}
void EnergyArbitrator::update(const QDateTime &currentDateTime)
@ -220,8 +244,8 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
for (auto it = m_adapters.constBegin(); it != m_adapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext(now));
// --- loads[] : etmvariableload adapters (ECS/routeur) ---
for (auto it = m_etmVariableLoadAdapters.constBegin(); it != m_etmVariableLoadAdapters.constEnd(); ++it)
// --- loads[] : charges pilotées en watts (relay-router + etmvariableload), polymorphes ---
for (auto it = m_loadAdapters.constBegin(); it != m_loadAdapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext(now));
// --- loads[] : SG-Ready adapters (PAC) ---
@ -258,9 +282,10 @@ void EnergyArbitrator::applyActionsToAdapters(const Slot &slot, const QDateTime
qCWarning(dcNymeaEnergy()) << "[Arbitre] action State sans adaptateur SG-Ready:" << action.loadId;
} else if (action.kind == LoadAction::Setpoint) {
// Un Setpoint etmvariableload (ECS/routeur) est routé par loadId. Un Setpoint EV
// n'est PAS dans cette table → ignoré ici (dispatché par adjustEvChargers() amont).
EtmVariableLoadAdapter *adapter = m_etmVariableLoadAdapters.value(action.loadId);
// Dispatch AGNOSTIQUE au type : routage par loadId, le polymorphisme ILoadAdapter
// absorbe (relay-router OU etmvariableload). Un Setpoint EV n'est PAS dans cette table
// → ignoré ici (dispatché par adjustEvChargers() amont).
ILoadAdapter *adapter = m_loadAdapters.value(action.loadId);
if (adapter)
adapter->applyAction(action, now);
}
@ -307,10 +332,11 @@ 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).
// force=true → bypass anti-rebond honoré par le thing. Coupe toute charge pilotée pendant
// le silence compteur.
for (EtmVariableLoadAdapter *adapter : m_etmVariableLoadAdapters) {
// Charges pilotées en watts (relay-router ET etmvariableload) : repli Setpoint(0) force=true
// (contrat rév. 3 §9). Boucle sur TOUS les ILoadAdapter — ne PAS oublier un type, sinon des
// relais ECS resteraient allumés en compteur muet (trou T2 recréé). force=true → pour le
// relay-router, coupe TOUS les relais (bypass minOn/minOff) ; pour etmvariableload, setpoint 0.
for (ILoadAdapter *adapter : m_loadAdapters) {
LoadAction la;
la.loadId = adapter->descriptor().id;
la.kind = LoadAction::Setpoint;

View File

@ -14,6 +14,8 @@ class QTimer;
class EvAdapter;
class SgReadyAdapter;
class EtmVariableLoadAdapter;
class RelayRouter;
class ILoadAdapter;
class LoadConfigStore;
class RuleBasedScheduler;
@ -91,10 +93,17 @@ public:
* Adopté comme enfant Qt de l'arbitre. Appelé par le test (setup) ou en T4 la
* construction depuis \c LoadConfig.
* \note Le dispatch distingue un \c Setpoint etmvariableload d'un \c Setpoint EV par le
* \c loadId : seul l'EV n'est PAS dans \c m_etmVariableLoadAdapters (proxy amont jusqu'à 3g).
* \c loadId : seul l'EV n'est PAS dans \c m_loadAdapters (proxy amont jusqu'à 3g).
*/
void registerEtmVariableLoadAdapter(EtmVariableLoadAdapter *adapter);
/*!
* \brief Enregistre un RelayRouter (ECS multipalier, rév. 3) dans la même table d'adaptateurs.
* \param adapter Routeur à enregistrer ; \c descriptor().id unique. Adopté enfant Qt.
* Appelé par le test (setup) ou en prod la construction depuis \c LoadConfig (relays[]).
*/
void registerRelayRouter(RelayRouter *adapter);
/*!
* \brief Branche le store de config charge pilotée : construit les adaptateurs
* \c etmvariableload depuis la config et les reconstruit à chaque \c changed().
@ -187,12 +196,13 @@ 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().
* \brief (Re)construit \c m_loadAdapters depuis \c m_loadConfigStore (rév. 3).
* Purge les adaptateurs existants (deleteLater) puis crée, par config \c enabled==true, un
* \c RelayRouter (si \c relays[]) ou un \c EtmVariableLoadAdapter (sinon). La distinction de
* TYPE vit ici ; au-dessus tout est \c ILoadAdapter (Setpoint W). Appelé au branchement du
* store et à chaque \c LoadConfigStore::changed().
*/
void rebuildEtmVariableLoadAdapters();
void rebuildLoadAdapters();
/*!
* \brief Déclencheur RÉEL du watchdog L2 (SAFETY.md §L2) slot de \c m_meterWatchdog
@ -224,7 +234,7 @@ private:
RuleBasedScheduler *m_scheduler = nullptr;
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.
QHash<QString, ILoadAdapter *> m_loadAdapters; //!< loadId → charge pilotée (relay-router | etmvariableload).
ThingManager *m_tm = nullptr; //!< ThingManager (pour construire les adaptateurs config).
LoadConfigStore *m_loadConfigStore = nullptr; //!< Store config charge pilotée (non adopté).

View File

@ -101,9 +101,13 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
// UNIQUE et cascade à travers TOUTES ces charges par priorité :
// - etmvariableload (ECS/routeur, kind Setpoint W → buildSetpointAction, règle d'arrondi §5) ;
// - sg-ready (PAC, kind State → buildSgReadyStateAction, mapping sémantique).
// Charges en WATTS (Setpoint) : etmvariableload (continu) ET relay-router (rév. 3, combinaison
// de relais dérivée côté routeur). Charge à ÉTATS : sg-ready (PAC). Le scheduler distingue
// Setpoint vs State (nature de l'action), PAS la classe concrète d'adaptateur (frontière rév. 3).
QList<LoadContext> nonEvLoads;
for (const LoadContext &lc : ctx.loads) {
if (lc.adapter == QStringLiteral("etmvariableload") || lc.adapter == QStringLiteral("sg-ready"))
if (lc.adapter == QStringLiteral("etmvariableload") || lc.adapter == QStringLiteral("relay-router")
|| lc.adapter == QStringLiteral("sg-ready"))
nonEvLoads.append(lc);
}
std::sort(nonEvLoads.begin(), nonEvLoads.end(),
@ -113,7 +117,7 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
if (lc.adapter == QStringLiteral("sg-ready"))
slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW));
else
slot.actions.append(buildSetpointAction(lc, remainingSurplusW));
slot.actions.append(buildSetpointAction(lc, remainingSurplusW)); // etmvariableload + relay-router
}
// Grid funding (ECS/PAC) : dormant jusqu'à 3f (waterfall réseau) — non implémenté ici.

View File

@ -39,6 +39,7 @@ using namespace nymeaserver;
// [T3] ecsrelayadapter.h retiré : l'ECS est piloté en watts via EtmVariableLoadAdapter
// (interface etmvariableload, kind Setpoint). La combinatoire relais vit dans le thing.
#include "../../../energyplugin/etm/adapters/etmvariableloadadapter.h"
#include "../../../energyplugin/etm/adapters/relayrouter.h"
#include "../../../energyplugin/etm/types/loadconfig.h"
#include "../../../energyplugin/etm/config/loadconfigstore.h"
#endif
@ -222,6 +223,61 @@ void Simulation::testMeterSilentFallback()
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
setMeterW(-2500); cycle(t0.addSecs(302)); // surplus suffisant → 2400
QCOMPARE(qRound(ecs->currentSetpointW()), 2400);
// ===================== Cas RELAIS (rév. 3) — certification du trou T2 fermé =====================
// Compteur muet → le RelayRouter coupe TOUS ses relais, force=true (bypass minOn). Sans ce
// volet, le repli L2 du routeur n'est pas certifié : des relais ECS pourraient rester allumés.
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb2 = dynamic_cast<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 rA = addPowerSwitch(1000, 26661);
QUuid rB = addPowerSwitch(1500, 26662);
Thing *relayA = tm2->findConfiguredThing(rA);
Thing *relayB = tm2->findConfiguredThing(rB);
QVERIFY(relayA && relayB);
// RelayRouter : paliers DÉRIVÉS [0, 1000, 1500, 2500] ; minOn=300 s (pour prouver le bypass force).
RelayRouter *router = new RelayRouter(
tm2, "ecs-relais-repli", "ECS relais (repli)",
QList<LoadConfigRelay>({ {rA.toString(), 1000}, {rB.toString(), 1500} }),
300, 0, 1, LoadNeeds(), arb2);
arb2->registerRelayRouter(router);
auto cycle2 = [&](const QDateTime &now){ arb2->simulationCallUpdate(now); QCoreApplication::processEvents(); };
// Surplus 2500 → palier 2500 (A+B) : les DEUX relais ON.
arb2->recordMeterUpdate(t0);
m2->setStateValue("currentPower", -2500); cycle2(t0);
QCOMPARE(qRound(router->currentSetpointW()), 2500);
QCOMPARE(relayA->stateValue("power").toBool(), true);
QCOMPARE(relayB->stateValue("power").toBool(), true);
QVERIFY(!arb2->degradedMode());
// Compteur muet > 90 s → mode dégradé : TOUS les relais OFF, force=true (bypass minOn 300).
arb2->evaluateMeterFreshness(t0.addSecs(91));
QCoreApplication::processEvents();
QVERIFY(arb2->degradedMode());
QCOMPARE(qRound(router->currentSetpointW()), 0);
QCOMPARE(relayA->stateValue("power").toBool(), false); // trou T2 fermé : relais coupé
QCOMPARE(relayB->stateValue("power").toBool(), false);
// STABILITÉ : muet, faux surplus piège → les relais RESTENT OFF (planif suspendue).
m2->setStateValue("currentPower", -3000);
foreach (int dt, QList<int>({92, 200})) {
cycle2(t0.addSecs(dt));
QVERIFY2(arb2->degradedMode(), "degradedMode doit rester actif pendant le silence");
QCOMPARE(relayA->stateValue("power").toBool(), false);
QCOMPARE(relayB->stateValue("power").toBool(), false);
}
#endif
}