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:
parent
51006745ed
commit
3cb79f5918
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user