diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index f9b06d9..d1f041a 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -4,6 +4,7 @@ #include "energyarbitrator.h" #include "adapters/evadapter.h" #include "adapters/sgreadyadapter.h" +#include "adapters/etmvariableloadadapter.h" #include "scheduler/rulebasedscheduler.h" #include "types/surpluscontext.h" #include "types/plan.h" @@ -96,6 +97,18 @@ void EnergyArbitrator::registerSgReadyAdapter(SgReadyAdapter *adapter) qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] SgReadyAdapter enregistré:" << adapter->descriptor().label; } +void EnergyArbitrator::registerEtmVariableLoadAdapter(EtmVariableLoadAdapter *adapter) +{ + const QString id = adapter->descriptor().id; + if (m_etmVariableLoadAdapters.contains(id)) { + qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] EtmVariableLoadAdapter déjà enregistré:" << id; + return; + } + adapter->setParent(this); + m_etmVariableLoadAdapters[id] = adapter; + qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] EtmVariableLoadAdapter enregistré:" << adapter->descriptor().label; +} + void EnergyArbitrator::update(const QDateTime ¤tDateTime) { qCDebug(dcNymeaEnergy()) << "Updating smart charging"; @@ -166,7 +179,9 @@ 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) → câblés en T4 (config) --- + // --- loads[] : etmvariableload adapters (ECS/routeur) --- + for (auto it = m_etmVariableLoadAdapters.constBegin(); it != m_etmVariableLoadAdapters.constEnd(); ++it) + ctx.loads.append(it.value()->toLoadContext(now)); // --- loads[] : SG-Ready adapters (PAC) --- for (auto it = m_sgReadyAdapters.constBegin(); it != m_sgReadyAdapters.constEnd(); ++it) @@ -200,9 +215,15 @@ void EnergyArbitrator::applyActionsToAdapters(const Slot &slot, const QDateTime adapter->applyAction(action, now); else 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); + if (adapter) + adapter->applyAction(action, now); } // EV (Setpoint) : dispatché par adjustEvChargers() amont jusqu'à 3g. - // etmvariableload (Setpoint W) : dispatch via m_etmVariableLoadAdapters → câblé en T3/T4. } } diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index b68930b..7882d2b 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -13,6 +13,7 @@ class QTimer; class EvAdapter; class SgReadyAdapter; +class EtmVariableLoadAdapter; class RuleBasedScheduler; /*! @@ -82,6 +83,17 @@ public: */ void registerSgReadyAdapter(SgReadyAdapter *adapter); + /*! + * \brief Enregistre un EtmVariableLoadAdapter (ECS/routeur, interface \c etmvariableload) + * pour inclusion dans le contexte et le dispatch \c Setpoint (W). + * \param adapter Adaptateur à enregistrer ; son \c descriptor().id doit être unique. + * 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). + */ + void registerEtmVariableLoadAdapter(EtmVariableLoadAdapter *adapter); + /*! * \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. @@ -193,6 +205,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. // --- L2 watchdog fraîcheur compteur (SAFETY.md §L2) --- QTimer *m_meterWatchdog = nullptr; //!< Tick 30 s, indépendant des signaux compteur. diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 604bc83..058458b 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -98,20 +98,23 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) // Charges pilotables non-EV triées par priorité ASCENDANTE : rang 1 = premier servi // (OPTIMIZER_PROTOCOL §5 + annexe C — la priorité est un rang). Le budget de surplus est - // UNIQUE et cascade à travers ces charges par priorité. - // - // T2 : seule la PAC SG-Ready (kind State) est traitée ici. Les charges etmvariableload - // (ECS/routeur, kind Setpoint W) rejoindront ce waterfall avec la règle d'arrondi en T3. + // 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). QList nonEvLoads; for (const LoadContext &lc : ctx.loads) { - if (lc.adapter == QStringLiteral("sg-ready")) + if (lc.adapter == QStringLiteral("etmvariableload") || lc.adapter == QStringLiteral("sg-ready")) nonEvLoads.append(lc); } std::sort(nonEvLoads.begin(), nonEvLoads.end(), [](const LoadContext &a, const LoadContext &b) { return a.priority < b.priority; }); - for (const LoadContext &lc : nonEvLoads) - slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW)); + for (const LoadContext &lc : nonEvLoads) { + if (lc.adapter == QStringLiteral("sg-ready")) + slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW)); + else + slot.actions.append(buildSetpointAction(lc, remainingSurplusW)); + } // Grid funding (ECS/PAC) : dormant jusqu'à 3f (waterfall réseau) — non implémenté ici. @@ -170,6 +173,53 @@ LoadAction RuleBasedScheduler::buildIdleAction(EvCharger *ev) const return la; } +LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, + double &remainingSurplusW) const +{ + // Recrédit anti-clignotement (Correction B / contrat rév. 2 §5) : la conso de DÉBUT de cycle + // est déjà soustraite de l'export mesuré → on la rend au budget local avant d'arrondir. + // PAS de relecture post-setpoint (invariant 8 : aucune boucle de feedback). + const double budgetW = remainingSurplusW + lc.telemetry.currentPowerW; + + const QList &levels = lc.declared.powerLevels; + const bool dynamic = levels.isEmpty(); + + double setpointW; + if (dynamic) { + // Dynamic (routeur PV / triac) : modulation continue bornée au plafond déclaré. + const double maxW = lc.declared.maxPowerW > 0 ? lc.declared.maxPowerW : budgetW; + setpointW = qBound(0.0, budgetW, maxW); + } else { + // Fixed : plus haut palier déclaré ≤ budget (levels triés croissants, 0 inclus → + // garantit une consigne valide même budget ≤ 0). Granularité connue → zéro cycle de retard. + int chosen = 0; + for (int l : levels) { + if (l <= budgetW) chosen = l; + else break; + } + setpointW = chosen; + } + + LoadAction la; + la.loadId = lc.id; + la.kind = LoadAction::Setpoint; + la.funding = LoadAction::Surplus; + la.powerW = setpointW; + la.estimatedPowerW = setpointW; + + if (setpointW > 0) + la.reason = QStringLiteral("Surplus PV %1 W — %2 consigne %3 W%4") + .arg(qRound(budgetW)).arg(lc.label).arg(qRound(setpointW)) + .arg(dynamic ? QString() : QStringLiteral(" (palier)")); + else + la.reason = QStringLiteral("Surplus insuffisant (%1 W) — %2 à 0 W") + .arg(qRound(budgetW)).arg(lc.label); + + // Résidu : budget − consigne engagée → charge suivante de la priorité (même cycle). + remainingSurplusW = budgetW - setpointW; + return la; +} + LoadAction RuleBasedScheduler::buildSgReadyStateAction(const LoadContext &lc, double &remainingSurplusW) const { diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.h b/energyplugin/etm/scheduler/rulebasedscheduler.h index d304b3c..0c5b6a1 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.h +++ b/energyplugin/etm/scheduler/rulebasedscheduler.h @@ -84,6 +84,23 @@ private: */ LoadAction buildIdleAction(EvCharger *ev) const; + /*! + * \brief Construit un LoadAction \c Setpoint (W) pour une charge \c etmvariableload + * (ECS résistif / routeur PV) par la règle d'arrondi du contrat rév. 2 §5. + * + * Recrédit anti-clignotement (Correction B / contrat §5) : le budget local = + * \c remainingSurplusW + \c lc.telemetry.currentPowerW (conso de DÉBUT de cycle, déjà + * soustraite de l'export mesuré — JAMAIS une relecture post-setpoint, invariant 8). Puis : + * - **fixed** (\c powerLevels non vide) : setpoint = plus haut palier déclaré ≤ budget ; + * - **dynamic** (\c powerLevels vide) : setpoint = \c clamp(budget, 0, maxPowerW). + * Le résidu \c budget − setpoint repart vers la charge suivante de la priorité (même cycle). + * + * \param lc Charge \c etmvariableload du SurplusContext. + * \param[in,out] remainingSurplusW Budget de surplus restant (W) ; mis à jour pour la suite. + * \return LoadAction kind=Setpoint, funding=Surplus, \c powerW = consigne, \c reason non vide. + */ + LoadAction buildSetpointAction(const LoadContext &lc, double &remainingSurplusW) const; + /*! * \brief Construit un LoadAction "state" SG-Ready (PAC) par mapping SÉMANTIQUE du surplus. * diff --git a/tests/auto/common/energytestbase.cpp b/tests/auto/common/energytestbase.cpp index 4dc6c56..ae9b966 100644 --- a/tests/auto/common/energytestbase.cpp +++ b/tests/auto/common/energytestbase.cpp @@ -482,6 +482,24 @@ QUuid EnergyTestBase::addPowerSwitch(double nominalPower, quint16 port) return response.toMap().value("params").toMap().value("thingId").toUuid(); } +QUuid EnergyTestBase::addEtmVariableLoad(quint16 port) +{ + QVariantList thingParams; + QVariantMap portParam; + portParam.insert("paramTypeId", "{c8d52a1b-4f3e-4b02-a07c-3d9f2e6b8c51}"); // etmVariableLoad port + portParam.insert("value", port); + thingParams.append(portParam); + + QVariantMap params; + params.insert("thingClassId", "{b7c41f0a-3e2d-4a91-9f6b-2c8e1d5a7b40}"); // etmVariableLoadThingClassId + params.insert("name", QString("etmVariableLoad %1").arg(port)); + params.insert("thingParams", thingParams); + + QVariant response = injectAndWait("Integrations.AddThing", params); + verifyThingError(response); + return response.toMap().value("params").toMap().value("thingId").toUuid(); +} + void EnergyTestBase::removeDevices() { QVariant configuredDevices = injectAndWait("Integrations.GetThings"); diff --git a/tests/auto/common/energytestbase.h b/tests/auto/common/energytestbase.h index 4030462..04c8f04 100644 --- a/tests/auto/common/energytestbase.h +++ b/tests/auto/common/energytestbase.h @@ -94,6 +94,8 @@ public: QUuid addSimpleCharger(double maxChargingCurrentUpperLimit = 32, quint16 port = 26659); QUuid addEnergyStorage(uint capacity = 10, double maxChargingPowerUpperLimit = 5000, double maxDischargingPowerUpperLimit = 11500, quint16 port = 26660); QUuid addPowerSwitch(double nominalPower = 2000, quint16 port = 26661); + //! Mock thing implémentant l'interface etmvariableload (states currentPowerW / powerSetpoint). + QUuid addEtmVariableLoad(quint16 port = 27001); void removeDevices(); QVariant removeDevice(const QUuid &thingId); diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp index 3467395..918f9b8 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp @@ -383,6 +383,30 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info) qCDebug(dcEnergyMocks()) << "Setting up power switch" << thing->name() << "finished successfully"; info->finish(Thing::ThingErrorNoError); return; + + } else if (thing->thingClassId() == etmVariableLoadThingClassId) { + // Mock etmvariableload : un controller (nécessaire pour logActionExecuted dans + // executeAction) + injection éventuelle de currentPowerW. Le test impose plutôt + // currentPowerW directement via setStateValue (mesure simulée de la charge). + EnergyMockController *controller = new EnergyMockController(thing, this); + ParamType paramType = thing->thingClass().paramTypes().findByName("port"); + quint16 port = thing->paramValue(paramType.id()).toUInt(); + if (!controller->listen(QHostAddress::Any, port)) { + qCWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString(); + delete controller; + info->finish(Thing::ThingErrorThingInUse); + return; + } + + connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){ + if (query.hasQueryItem("currentPowerW")) + thing->setStateValue("currentPowerW", QVariant(query.queryItemValue("currentPowerW")).toDouble()); + }); + + m_controllers.insert(thing, controller); + qCDebug(dcEnergyMocks()) << "Setting up etmVariableLoad" << thing->name() << "finished successfully"; + info->finish(Thing::ThingErrorNoError); + return; } } @@ -512,6 +536,17 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info) } } + if (thing->thingClassId() == etmVariableLoadThingClassId) { + if (actionType.name() == "powerSetpoint") { + // L'EtmVariableLoadAdapter écrit la consigne ; le mock l'enregistre dans le state. + // currentPowerW (puissance réellement appliquée) reste piloté par le test — + // c'est le thing réel qui choisirait la combinaison matérielle (contrat §3). + double setpoint = action.paramValue(actionType.paramTypes().findByName("powerSetpoint").id()).toDouble(); + thing->setStateValue("powerSetpoint", setpoint); + qCDebug(dcEnergyMocks()) << "Mock etmVariableLoad" << thing->name() << "powerSetpoint" << setpoint; + } + } + info->finish(Thing::ThingErrorNoError); } diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json index 65d3eb7..0dbae05 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json @@ -859,6 +859,41 @@ "defaultValue": 0 } ] + }, + { + "name": "etmVariableLoad", + "displayName": "Mocked etmvariableload (ECS/routeur à puissance pilotable)", + "id": "b7c41f0a-3e2d-4a91-9f6b-2c8e1d5a7b40", + "createMethods": ["user"], + "paramTypes": [ + { + "id": "c8d52a1b-4f3e-4b02-a07c-3d9f2e6b8c51", + "name": "port", + "displayName": "Port", + "type": "uint", + "defaultValue": 27001 + } + ], + "stateTypes": [ + { + "id": "d9e63b2c-5a4f-4c13-b18d-4e0a3f7c9d62", + "name": "currentPowerW", + "displayName": "Current power (W)", + "type": "double", + "unit": "Watt", + "defaultValue": 0 + }, + { + "id": "e0f74c3d-6b5a-4d24-9f2e-5f1b408ad073", + "name": "powerSetpoint", + "displayName": "Power setpoint", + "displayNameAction": "Set power setpoint", + "type": "double", + "unit": "Watt", + "defaultValue": 0, + "writable": true + } + ] } ] }