diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 381451b..61e58b8 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -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(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; diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 79bc4d4..aefe42a 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -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 m_adapters; //!< loadId (ThingId string) → EvAdapter*. QHash m_sgReadyAdapters; //!< loadId → SgReadyAdapter* (PAC). - QHash m_etmVariableLoadAdapters; //!< loadId → ECS/routeur. + QHash 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é). diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 058458b..a885867 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -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 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. diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index b176d89..5773200 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -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(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({ {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({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 }