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:
parent
e16aca4d1a
commit
88626cfd56
@ -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 ¤tDateTime)
|
||||
@ -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;
|
||||
|
||||
@ -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é).
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user