feat(etm): règle d'arrondi etmvariableload dans le waterfall + infra de test

RuleBasedScheduler : buildSetpointAction (contrat rév. 2 §5) — fixed = arrondi au plus
haut powerLevels ≤ budget ; dynamic = clamp(budget, 0, maxPowerW) ; recrédit currentPowerW
de DÉBUT de cycle (invariant 8) ; résidu cascadé. Waterfall unifié : etmvariableload
(Setpoint W) + sg-ready (State) triés par priorité, un seul budget.

EnergyArbitrator : registerEtmVariableLoadAdapter, inclusion dans buildContext, dispatch
Setpoint par loadId (l'EV reste au proxy amont).

Infra de test : ThingClass mock etmVariableLoad (currentPowerW read / powerSetpoint write)
+ handler executeAction + helper addEtmVariableLoad — valide l'implémentabilité de
l'interface avant le thing réel. Les scénarios simulation migrés arrivent en T4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-06-28 09:47:58 +02:00
parent 51006745ed
commit 3cb79f5918
8 changed files with 200 additions and 9 deletions

View File

@ -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 &currentDateTime)
{
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.
}
}

View File

@ -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<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
QHash<QString, SgReadyAdapter *> m_sgReadyAdapters; //!< loadId → SgReadyAdapter* (PAC).
QHash<QString, EtmVariableLoadAdapter *> 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.

View File

@ -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<LoadContext> 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<int> &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
{

View File

@ -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.
*

View File

@ -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");

View File

@ -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);

View File

@ -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);
}

View File

@ -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
}
]
}
]
}