refactor(etm): retirer le kind Stage (ECS→Setpoint), chemin State PAC conservé

Contrat etmvariableload rév. 2 §6 : LoadAction garde DEUX kinds (Setpoint W + état
SG-Ready). On supprime UNIQUEMENT le kind Stage (index de palier ECS) ; l'ECS passera
en etmvariableload (Setpoint W) en T3.

- LoadAction : enum Kind { Setpoint, State, Constraint } (retrait Stage + champ stage).
- SurplusContext : retrait stage/minStage/maxStage de LoadContextTelemetry. state/minState/
  maxState (PAC) CONSERVÉS.
- LoadDescriptor : retrait du champ stages (relay-stages orphelin).
- RuleBasedScheduler : retrait buildEcsStageAction ; waterfall = sg-ready uniquement.
- EnergyArbitrator : retrait registerEcsAdapter / m_ecsAdapters / dispatch Stage /
  boucle contexte ECS / repli L2 ECS. Chemin State (PAC) intact.
- Suppression de EcsRelayAdapter ; retrait du registre en dur ECS (energypluginnymea).

NB : entre ce commit et T4 le repli L2 charge pilotée est absent (recâblé en T4) —
commit intermédiaire d'une branche feature, jamais déployé tel quel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-06-28 09:23:19 +02:00
parent b7bfd58139
commit 51006745ed
11 changed files with 51 additions and 519 deletions

View File

@ -31,7 +31,6 @@
// [ETM] BEGIN — EnergyArbitrator flip. Remove block to revert to upstream SmartChargingManager.
#ifdef ETM_ARBITRATOR
#include "etm/energyarbitrator.h"
#include "etm/adapters/ecsrelayadapter.h"
#include "etm/adapters/sgreadyadapter.h"
#endif
// [ETM] END
@ -55,20 +54,11 @@ void EnergyPluginNymea::init()
qCDebug(dcNymeaEnergy()) << "ETM_ARBITRATOR actif — EnergyArbitrator chargé.";
EnergyArbitrator *chargingManager = new EnergyArbitrator(energyManager(), thingManager(), spotMarketManager, configuration, this);
// §1.d — Banc hems : adaptateurs ECS 3 relais + SG-Ready PAC (ThingIds briefing terrain).
// §1.d — Banc hems : SG-Ready PAC (ThingIds briefing terrain).
// NB (T2) : l'ECS banc (ex-EcsRelayAdapter relay-stages) est retiré — l'ECS passe en
// etmvariableload (Setpoint W) et sera construit depuis LoadConfig en T4. La PAC reste à états.
{
ThingManager *tm = thingManager();
const QString R500 = "{8538782f-2c8c-4a30-bfce-c8140f791c9b}";
const QString R1000 = "{2ebe6bef-829b-4695-9d11-ddb4c16c5448}";
const QString R2000 = "{b033b212-1adb-4df0-ba2b-8fa477de52a2}";
auto *ecs = new EcsRelayAdapter(
tm, "ecs-terrain", "ECS banc",
QList<int>({0, 500, 1000, 1500, 2000, 2500, 3000, 3500}),
QList<QList<QString>>({ {}, {R500}, {R1000}, {R500,R1000},
{R2000}, {R500,R2000}, {R1000,R2000}, {R500,R1000,R2000} }),
60, 60, 1, chargingManager);
chargingManager->registerEcsAdapter(ecs);
const QString K1 = "{beaf92e1-aedc-4b84-9ce4-e423648638cc}";
const QString K2 = "{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}";
auto *pac = new SgReadyAdapter(

View File

@ -1,217 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "ecsrelayadapter.h"
#include "plugininfo.h"
#include <QDateTime>
#include <integrations/thingmanager.h>
#include <integrations/thing.h>
#include <types/action.h>
#include <types/param.h>
EcsRelayAdapter::EcsRelayAdapter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QList<int> &stages,
const QList<QList<QString>> &relayMapping,
int minOnS,
int minOffS,
int priority,
QObject *parent)
: QObject(parent)
, m_thingManager(thingManager)
, m_id(id)
, m_label(label)
, m_stages(stages)
, m_relayMapping(relayMapping)
, m_minOnS(minOnS)
, m_minOffS(minOffS)
, m_priority(priority)
{
Q_ASSERT(!m_stages.isEmpty() && m_stages.first() == 0);
Q_ASSERT(m_relayMapping.size() == m_stages.size());
}
LoadDescriptor EcsRelayAdapter::descriptor() const
{
LoadDescriptor d;
d.id = m_id;
d.label = m_label;
d.adapter = QStringLiteral("relay-stages");
d.priority = m_priority;
d.declared.stages = m_stages;
d.limits.minOnS = m_minOnS;
d.limits.minOffS = m_minOffS;
d.supportedKinds = { LoadAction::Stage };
return d;
}
LoadTelemetry EcsRelayAdapter::telemetry() const
{
LoadTelemetry t;
t.available = true;
t.lastActionAt = m_lastActionAt;
// Source de currentPowerW (consommée par le recrédit anti-clignotement du waterfall,
// RuleBasedScheduler::buildEcsStageAction — correction B) :
// - MESURÉE dès qu'au moins un relais du stage expose un state "currentPower" :
// somme des mesures. Un thermostat interne coupé (relais ON mais 0 W réel) renvoie
// alors 0 → recrédit 0, jamais de puissance fantôme.
// - DÉCLARÉE (repli) : stages[stage] UNIQUEMENT si aucun relais actif ne mesure
// (relais nus / mock sans powermetering). Approximation = palier à sa nominale.
double power = 0;
bool metered = false;
const QList<QString> &activeRelays = m_currentStage < m_relayMapping.size()
? m_relayMapping.at(m_currentStage)
: QList<QString>();
for (const QString &thingId : activeRelays) {
Thing *t2 = m_thingManager->findConfiguredThing(ThingId(thingId));
if (!t2)
continue;
if (!t2->thingClass().stateTypes().findByName("currentPower").id().isNull()) {
metered = true;
power += t2->stateValue("currentPower").toDouble();
}
}
// Repli déclaré : seulement si la mesure n'est pas disponible (pas si elle vaut 0).
if (!metered && m_currentStage > 0 && m_currentStage < m_stages.size())
power = m_stages.at(m_currentStage);
t.currentPowerW = power;
return t;
}
LoadContext EcsRelayAdapter::toLoadContext(const QDateTime &now) const
{
LoadContext ctx;
ctx.id = m_id;
ctx.adapter = QStringLiteral("relay-stages");
ctx.label = m_label;
ctx.priority = m_priority;
ctx.declared = descriptor().declared;
ctx.limits = descriptor().limits;
const LoadTelemetry tel = telemetry();
ctx.telemetry.currentPowerW = tel.currentPowerW;
ctx.telemetry.stage = m_currentStage;
ctx.telemetry.lastSwitch = m_lastSwitch;
// Fenêtre de verrou évaluée au temps de cycle : le scheduler y borne son choix
// (plancher = puissance engagée non-coupable sous minOn ; plafond = redémarrage minOff).
lockWindow(now, ctx.telemetry.minStage, ctx.telemetry.maxStage);
return ctx;
}
LoadAction EcsRelayAdapter::applyAction(const LoadAction &action, const QDateTime &now)
{
if (action.kind != LoadAction::Stage)
return action;
if (action.reason.isEmpty()) {
qCWarning(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
<< "— LoadAction sans reason rejetée.";
return action;
}
const int newStage = qBound(0, action.stage, m_stages.size() - 1);
if (newStage == m_currentStage)
return action; // Aucun changement → idempotent
// Verrous anti-rebond évalués au temps de cycle (même fenêtre que le scheduler) —
// bypassés si force == true (L2 watchdog).
if (!action.force && lockActive(newStage, now)) {
qCDebug(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
<< "— verrou anti-rebond actif, stage" << newStage << "ignoré.";
return action;
}
qCInfo(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
<< "→ stage" << newStage
<< "(" << (m_currentStage < m_stages.size() ? m_stages.at(m_currentStage) : 0) << "W"
<< "" << m_stages.at(newStage) << "W)"
<< "|" << action.reason;
applyRelayStage(newStage);
m_currentStage = newStage;
m_lastSwitch = now; // estampille au temps de cycle (cohérent avec la fenêtre de verrou)
m_lastActionAt = now;
LoadAction applied = action;
applied.stage = newStage;
applied.estimatedPowerW = m_stages.at(newStage);
return applied;
}
// ---- privé ---------------------------------------------------------------
void EcsRelayAdapter::lockWindow(const QDateTime &now, int &minStage, int &maxStage) const
{
const int topStage = m_stages.size() - 1;
const bool valid = m_lastSwitch.isValid();
const qint64 elapsed = valid ? m_lastSwitch.secsTo(now) : 0;
// Plancher : si ON et minOn non écoulé → interdit de descendre sous le palier courant
// (puissance engagée non-coupable, protection compresseur / anti court-cycling).
minStage = (m_currentStage > 0 && valid && elapsed < m_minOnS) ? m_currentStage : 0;
// Plafond : si à l'arrêt et minOff non écoulé → interdit de redémarrer.
maxStage = (m_currentStage == 0 && valid && elapsed < m_minOffS) ? 0 : topStage;
}
bool EcsRelayAdapter::lockActive(int newStage, const QDateTime &now) const
{
// MÊME calcul que la fenêtre exposée au scheduler → décision et exécution coïncident.
int minStage, maxStage;
lockWindow(now, minStage, maxStage);
return newStage < minStage || newStage > maxStage;
}
void EcsRelayAdapter::applyRelayStage(int stage)
{
// Set de relais CIBLE du palier (delta complet : chaque relais connu est amené à son
// état cible on/off, pas d'ajout incrémental). Gère les mappings NON-CASCADÉS où monter
// d'un palier éteint des relais (ex. 3 résistances 500/1000/2000 W : 1500=[r500,r1000]
// → 2000=[r2000] commute 3 relais).
const QSet<QString> wantOn = [&]() {
QSet<QString> s;
if (stage < m_relayMapping.size())
for (const QString &id : m_relayMapping.at(stage))
s.insert(id);
return s;
}();
QSet<QString> allRelays;
for (const auto &list : m_relayMapping)
for (const QString &id : list)
allRelays.insert(id);
auto writeRelay = [&](const QString &thingId, bool on) {
Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId));
if (!relay) {
qCWarning(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
<< "— relais non trouvé:" << thingId;
return;
}
StateType powerStateType = relay->thingClass().stateTypes().findByName("power");
if (!powerStateType.id().isNull()) {
Action powerAction(powerStateType.id(), relay->id(), Action::TriggeredByRule);
powerAction.setParams(ParamList() << Param(powerStateType.id(), on));
m_thingManager->executeAction(powerAction);
} else {
relay->setStateValue("power", on); // repli mock
}
};
// Intention (résistif : transitoire inoffensif, mais portée comme SG-Ready) : COUPER
// d'abord les relais hors-cible, PUIS enclencher ceux de la cible → pas de sur-puissance
// transitoire (somme des deux paliers) sur une transition non-cascadée.
for (const QString &id : allRelays)
if (!wantOn.contains(id))
writeRelay(id, false);
for (const QString &id : wantOn)
writeRelay(id, true);
}

View File

@ -1,123 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QObject>
#include <QDateTime>
#include <QList>
#include <QString>
#include "iloadadapter.h"
class Thing;
class ThingManager;
/*!
* \brief Adaptateur pour chauffe-eau ou tout relais N paliers (interface relay-stages).
*
* Pilote en production N Things powerswitch nymea : \c m_relayMapping[stage] contient
* la liste des ThingIds à mettre ON pour ce palier (les autres sont mis OFF).
*
* Exemple chauffe-eau 2400W, 2 résistances Waveshare :
* stage 0 : {} A=OFF, B=OFF
* stage 1 : {"thingId-A"} A=ON, B=OFF (1200 W)
* stage 2 : {"thingId-A", "thingId-B"} A=ON, B=ON (2400 W)
*
* \invariant applyAction() rejette silencieusement toute action dont \c reason est vide.
* \invariant applyAction() applique les verrous anti-rebond \c minOnS / \c minOffS
* SAUF si \c action.force == true (réservé L2 watchdog).
* \invariant Le stage est écrêté à [0, stages().size()-1] avant envoi matériel.
* \invariant Seul le kind Stage est traité ; les autres kinds retournent sans effet.
*/
class EcsRelayAdapter : public QObject, public ILoadAdapter
{
Q_OBJECT
public:
/*!
* \brief Constructeur.
* \param thingManager Gestionnaire nymea pour résoudre les ThingIds en Things.
* \param id Identifiant logique de la charge (ThingId de l'objet ECS dans nymea,
* ou identifiant arbitraire unique pour le mock).
* \param label Nom lisible affiché dans les logs et l'app.
* \param stages Puissances en W par palier, index 0 = off : [0, 1200, 2400].
* \param relayMapping relayMapping[i] = liste de ThingIds powerswitch ON pour le palier i.
* \param minOnS Durée minimale ON (s) anti-rebond.
* \param minOffS Durée minimale OFF (s) anti-rebond.
* \param priority Rang dans le waterfall (OPTIMIZER_PROTOCOL §5) : valeur plus BASSE
* = servi en premier (rang 1 = premier servi).
* \param parent Propriétaire Qt.
*/
explicit EcsRelayAdapter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QList<int> &stages,
const QList<QList<QString>> &relayMapping,
int minOnS,
int minOffS,
int priority,
QObject *parent = nullptr);
/*!
* \brief Description statique de la charge.
* \return LoadDescriptor avec adapter="relay-stages", stages, minOnS/minOffS, priority.
*/
LoadDescriptor descriptor() const override;
/*!
* \brief Télémétrie runtime : puissance mesurée, stage courant, lastSwitch.
* \return LoadTelemetry avec currentPowerW issu de la somme des Things actifs.
*/
LoadTelemetry telemetry() const override;
/*!
* \brief Construit l'entrée loads[] §5 du SurplusContext.
* \param now Temps de cycle (\c ctx.timestamp) source unique pour minStage/maxStage.
* \return LoadContext incluant declared, limits et télémétrie ECS (stage, currentPowerW,
* lastSwitch, et la fenêtre de verrou \c minStage/maxStage évaluée à \p now).
*/
LoadContext toLoadContext(const QDateTime &now) const override;
/*!
* \brief Applique un changement de palier sur les relais.
*
* \param action LoadAction de kind Stage. Autres kinds : retour sans effet.
* \param now Temps de cycle (\c ctx.timestamp) MÊME source que toLoadContext(),
* utilisée pour l'évaluation des verrous et l'estampille \c m_lastSwitch.
* \return L'action après écrêtage (stage borné à [0, stages.size()-1]).
*
* \invariant Si \c action.reason est vide retour sans effet (log warning).
* \invariant Si verrous anti-rebond actifs ET \c action.force == false retour sans effet (log).
* \invariant Si \c action.force == true bypass verrous (L2 watchdog uniquement).
* \invariant Toute modification de stage met à jour \c m_lastSwitch (= \p now).
*/
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
/*! \brief Stage courant (0 = off). */
int currentStage() const { return m_currentStage; }
private:
/*!
* \brief Fenêtre de paliers autorisée à l'instant \p now par les verrous minOn/minOff.
* \param now Temps de cycle.
* \param[out] minStage Plancher : palier courant si minOn non écoulé (puissance engagée
* non-coupable), sinon 0.
* \param[out] maxStage Plafond : 0 si à l'arrêt et minOff non écoulé (redémarrage interdit),
* sinon le palier le plus haut.
*/
void lockWindow(const QDateTime &now, int &minStage, int &maxStage) const;
bool lockActive(int newStage, const QDateTime &now) const;
void applyRelayStage(int stage);
ThingManager *m_thingManager;
QString m_id;
QString m_label;
QList<int> m_stages; //!< Puissances W par palier, [0]=off.
QList<QList<QString>> m_relayMapping; //!< ThingIds ON par palier.
int m_minOnS;
int m_minOffS;
int m_priority;
int m_currentStage = 0;
QDateTime m_lastSwitch; //!< Dernier changement de palier (null = jamais).
QDateTime m_lastActionAt;
};

View File

@ -3,7 +3,6 @@
#include "energyarbitrator.h"
#include "adapters/evadapter.h"
#include "adapters/ecsrelayadapter.h"
#include "adapters/sgreadyadapter.h"
#include "scheduler/rulebasedscheduler.h"
#include "types/surpluscontext.h"
@ -85,18 +84,6 @@ RootMeter *EnergyArbitrator::registeredRootMeter() const
return internalRootMeter();
}
void EnergyArbitrator::registerEcsAdapter(EcsRelayAdapter *adapter)
{
const QString id = adapter->descriptor().id;
if (m_ecsAdapters.contains(id)) {
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] EcsRelayAdapter déjà enregistré:" << id;
return;
}
adapter->setParent(this);
m_ecsAdapters[id] = adapter;
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] EcsRelayAdapter enregistré:" << adapter->descriptor().label;
}
void EnergyArbitrator::registerSgReadyAdapter(SgReadyAdapter *adapter)
{
const QString id = adapter->descriptor().id;
@ -150,7 +137,7 @@ void EnergyArbitrator::update(const QDateTime &currentDateTime)
}
// 7 : dispatch matériel (même position que l'amont — m_chargingActions rempli par getPlan())
applyActionsToAdapters(slot, currentDateTime); // ECS (kind==Stage) → m_ecsAdapters
applyActionsToAdapters(slot, currentDateTime); // PAC (kind==State) → m_sgReadyAdapters
adjustEvChargers(currentDateTime); // EV (kind==Setpoint) → proxy amont jusqu'à 3g
}
@ -179,9 +166,7 @@ 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[] : ECS relay adapters ---
for (auto it = m_ecsAdapters.constBegin(); it != m_ecsAdapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext(now));
// --- loads[] : etmvariableload adapters (ECS/routeur) → câblés en T4 (config) ---
// --- loads[] : SG-Ready adapters (PAC) ---
for (auto it = m_sgReadyAdapters.constBegin(); it != m_sgReadyAdapters.constEnd(); ++it)
@ -209,14 +194,7 @@ void EnergyArbitrator::applyActionsToAdapters(const Slot &slot, const QDateTime
{
for (const LoadAction &action : slot.actions) {
// L'adaptateur applique, écrête et verrouille — il ne décide pas (règle 2).
if (action.kind == LoadAction::Stage) {
EcsRelayAdapter *adapter = m_ecsAdapters.value(action.loadId);
if (adapter)
adapter->applyAction(action, now);
else
qCWarning(dcNymeaEnergy()) << "[Arbitre] action Stage sans adaptateur ECS:" << action.loadId;
} else if (action.kind == LoadAction::State) {
if (action.kind == LoadAction::State) {
SgReadyAdapter *adapter = m_sgReadyAdapters.value(action.loadId);
if (adapter)
adapter->applyAction(action, now);
@ -224,6 +202,7 @@ void EnergyArbitrator::applyActionsToAdapters(const Slot &slot, const QDateTime
qCWarning(dcNymeaEnergy()) << "[Arbitre] action State sans adaptateur SG-Ready:" << action.loadId;
}
// EV (Setpoint) : dispatché par adjustEvChargers() amont jusqu'à 3g.
// etmvariableload (Setpoint W) : dispatch via m_etmVariableLoadAdapters → câblé en T3/T4.
}
}
@ -267,16 +246,8 @@ void EnergyArbitrator::applyDegradedMode(const QDateTime &now)
const QString reason =
QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)");
// ECS : tous paliers coupés (stage 0), force=true → bypass des verrous anti-rebond.
for (EcsRelayAdapter *adapter : m_ecsAdapters) {
LoadAction la;
la.loadId = adapter->descriptor().id;
la.kind = LoadAction::Stage;
la.stage = 0;
la.force = true;
la.reason = reason;
adapter->applyAction(la, now); // force=true → bypass verrous ; now = temps de cycle
}
// etmvariableload (ECS/routeur) : repli setPowerSetpoint(0) force=true (contrat rév. 2 §9) —
// câblé avec m_etmVariableLoadAdapters en T4 (config). En attendant, EV + PAC ci-dessous.
// EV : repli CONSERVATEUR — n'initie aucune charge. On clampe seulement une charge
// DÉJÀ en cours au courant minimum (force=true, bypass lock). Une borne branchée mais

View File

@ -12,7 +12,6 @@
class QTimer;
class EvAdapter;
class EcsRelayAdapter;
class SgReadyAdapter;
class RuleBasedScheduler;
@ -76,15 +75,6 @@ public:
*/
RootMeter *registeredRootMeter() const;
/*!
* \brief Enregistre un EcsRelayAdapter pour inclusion dans le contexte et le dispatch.
*
* Appelé par le test (setup) ou la configuration de production.
* L'adaptateur est adopté comme enfant Qt de l'arbitre.
* \param adapter Adaptateur à enregistrer. Son \c descriptor().id doit être unique.
*/
void registerEcsAdapter(EcsRelayAdapter *adapter);
/*!
* \brief Enregistre un SgReadyAdapter (PAC) pour inclusion dans le contexte et le dispatch.
* \param adapter Adaptateur à enregistrer ; son \c descriptor().id doit être unique.
@ -133,7 +123,7 @@ protected:
* 3. verifyOverloadProtection() + verifyOverloadProtectionRecovery()
* (si \c m_degradedMode actif : retour immédiat planification/dispatch suspendus, L2)
* 4. m_scheduler->getPlan() log des decisionReason
* 5. applyActionsToAdapters() (ECS Stage + SG-Ready State) + adjustEvChargers() (EV) dispatch
* 5. applyActionsToAdapters() (SG-Ready State) + adjustEvChargers() (EV) dispatch
*
* \param currentDateTime Instant courant (timer ou simulation).
*/
@ -141,32 +131,32 @@ protected:
private:
/*!
* \brief Construit le SurplusContext §5 : meter brut + loads EV + ECS + SG-Ready.
* \brief Construit le SurplusContext §5 : meter brut + loads EV + SG-Ready.
*
* \c ctx.meter.exportW = mesure brute du compteur (AGENTS invariant 8 aucune
* déduction interne). La déduction evReservedW est faite dans le scheduler.
* \param now Temps de cycle (\c ctx.timestamp) : pose \c ctx.timestamp et sert de SOURCE
* UNIQUE aux fenêtres de verrou (\c minStage/maxStage, \c minState/maxState) calculées
* par chaque adaptateur cohérence décision (scheduler) / exécution (applyAction).
* UNIQUE à la fenêtre de verrou PAC (\c minState/maxState) calculée par le SgReadyAdapter
* cohérence décision (scheduler) / exécution (applyAction). Les charges etmvariableload
* (ECS/routeur) n'ont pas de fenêtre côté moteur (verrous dans le thing).
*/
SurplusContext buildContext(const QDateTime &now) const;
/*!
* \brief Synchronise m_adapters avec les EvCharger actuellement enregistrés.
* Crée les adapters manquants, supprime les adapters obsolètes.
* \note Découverte ECS via interface 'ecsrelay' ThingManager déféré 3g config.
* En beta, les EcsRelayAdapters sont enregistrés via registerEcsAdapter().
* \note Découverte etmvariableload/SG-Ready via config câblé en T4.
*/
void syncAdapters();
/*!
* \brief Applique les actions d'un Slot aux LoadAdapters non-EV (ECS + SG-Ready).
* \brief Applique les actions d'un Slot aux LoadAdapters non-EV.
*
* Itère \c slot.actions et dispatche selon le kind : \c Stage \c m_ecsAdapters
* (EcsRelayAdapter) ; \c State \c m_sgReadyAdapters (SgReadyAdapter). Les actions EV
* (\c Setpoint) restent dispatchées par \c adjustEvChargers() amont jusqu'à 3g.
* L'adaptateur écrête/verrouille lui-même (anti-rebond) et ignore toute action sans
* \c reason ou de kind non supporté aucune décision ici (règle 2).
* Itère \c slot.actions et dispatche selon le kind : \c State \c m_sgReadyAdapters
* (SgReadyAdapter, PAC). Les actions EV (\c Setpoint) restent dispatchées par
* \c adjustEvChargers() amont jusqu'à 3g ; les charges etmvariableload (\c Setpoint W)
* seront dispatchées via leur adaptateur en T3/T4. L'adaptateur écrête/verrouille lui-même
* et ignore toute action sans \c reason ou de kind non supporté aucune décision ici (règle 2).
* \param slot Créneau courant retourné par le scheduler.
* \param now Temps de cycle (\c ctx.timestamp) transmis aux adaptateurs pour une
* évaluation des verrous cohérente avec celle vue par le scheduler.
@ -202,7 +192,6 @@ private:
RuleBasedScheduler *m_scheduler = nullptr;
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
QHash<QString, EcsRelayAdapter *> m_ecsAdapters; //!< loadId → EcsRelayAdapter*.
QHash<QString, SgReadyAdapter *> m_sgReadyAdapters; //!< loadId → SgReadyAdapter* (PAC).
// --- L2 watchdog fraîcheur compteur (SAFETY.md §L2) ---

View File

@ -6,7 +6,6 @@ HEADERS += \
$$PWD/adapters/iloadadapter.h \
$$PWD/scheduler/ischeduler.h \
$$PWD/adapters/evadapter.h \
$$PWD/adapters/ecsrelayadapter.h \
$$PWD/adapters/sgreadyadapter.h \
$$PWD/adapters/etmvariableloadadapter.h \
$$PWD/scheduler/rulebasedscheduler.h \
@ -14,7 +13,6 @@ HEADERS += \
SOURCES += \
$$PWD/adapters/evadapter.cpp \
$$PWD/adapters/ecsrelayadapter.cpp \
$$PWD/adapters/sgreadyadapter.cpp \
$$PWD/adapters/etmvariableloadadapter.cpp \
$$PWD/scheduler/rulebasedscheduler.cpp \

View File

@ -96,23 +96,22 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
// transitoire sous minOn est géré par le verrou de l'adaptateur, pas par le budget.)
double remainingSurplusW = (ctx.meter.exportW - ctx.meter.importW) - evReservedW;
// Charges pilotables non-EV (ECS paliers + SG-Ready) 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 TOUTES ces charges par priorité.
// 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.
QList<LoadContext> nonEvLoads;
for (const LoadContext &lc : ctx.loads) {
if (lc.adapter == QStringLiteral("relay-stages") || lc.adapter == QStringLiteral("sg-ready"))
if (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) {
if (lc.adapter == QStringLiteral("sg-ready"))
slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW));
else
slot.actions.append(buildEcsStageAction(lc, remainingSurplusW));
}
for (const LoadContext &lc : nonEvLoads)
slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW));
// Grid funding (ECS/PAC) : dormant jusqu'à 3f (waterfall réseau) — non implémenté ici.
@ -171,58 +170,6 @@ LoadAction RuleBasedScheduler::buildIdleAction(EvCharger *ev) const
return la;
}
LoadAction RuleBasedScheduler::buildEcsStageAction(const LoadContext &lc,
double &remainingSurplusW) const
{
// Correction B (anti-clignotement) : on recrédite la conso actuelle de l'ECS au budget.
// Sans ce recrédit, allumer un palier ferait chuter l'export mesuré → palier 0 → oscillation.
const double budgetChargeW = remainingSurplusW + lc.telemetry.currentPowerW;
// Palier déclaré le plus haut qui tient dans le budget. stages[0] == 0 par construction
// (Q_ASSERT EcsRelayAdapter).
const QList<int> &stages = lc.declared.stages;
const int topStage = stages.size() - 1;
int budgetStage = 0;
for (int i = 0; i < stages.size(); ++i) {
if (stages.at(i) <= budgetChargeW)
budgetStage = i;
}
// Verrou (minOn/minOff) : le palier choisi est borné par la fenêtre déclarée par
// l'adaptateur (calculée au MÊME ctx.timestamp). Une charge verrouillée ON garde son
// palier (puissance engagée non-coupable) ; à l'arrêt sous minOff elle ne redémarre pas.
const int lo = qBound(0, lc.telemetry.minStage, topStage);
const int hi = qBound(lo, lc.telemetry.maxStage, topStage);
const int bestStage = qBound(lo, budgetStage, hi);
LoadAction la;
la.loadId = lc.id;
la.kind = LoadAction::Stage;
la.funding = LoadAction::Surplus;
la.stage = bestStage;
la.estimatedPowerW = stages.at(bestStage);
if (bestStage > budgetStage)
// Maintenu au-dessus du budget : protection compresseur (minOn non écoulé).
la.reason = QStringLiteral("Verrou minOn — %1 maintenu palier %2 (%3 W, surplus %4 W)")
.arg(lc.label).arg(bestStage).arg(stages.at(bestStage)).arg(qRound(budgetChargeW));
else if (bestStage < budgetStage)
// Empêché de monter/redémarrer par minOff malgré le surplus.
la.reason = QStringLiteral("Verrou minOff — %1 maintenu palier %2 (redémarrage trop tôt)")
.arg(lc.label).arg(bestStage);
else if (bestStage > 0)
la.reason = QStringLiteral("Surplus PV %1 W — %2 palier %3 (%4 W)")
.arg(qRound(budgetChargeW)).arg(lc.label)
.arg(bestStage).arg(stages.at(bestStage));
else
la.reason = QStringLiteral("Surplus insuffisant (%1 W) — %2 éteint")
.arg(qRound(budgetChargeW)).arg(lc.label);
// Budget restant pour les charges ECS suivantes (rang supérieur / priorité plus basse).
remainingSurplusW = budgetChargeW - la.estimatedPowerW;
return la;
}
LoadAction RuleBasedScheduler::buildSgReadyStateAction(const LoadContext &lc,
double &remainingSurplusW) const
{

View File

@ -10,7 +10,7 @@ class ChargingAction;
class EnergyArbitrator;
/*!
* \brief Planificateur règles GPL : EV (proxy amont) + waterfall surplus ECS / SG-Ready.
* \brief Planificateur règles GPL : EV (proxy amont) + waterfall surplus SG-Ready (PAC).
*
* \c getPlan() produit un plan à 1 créneau en DEUX temps :
*
@ -19,16 +19,17 @@ class EnergyArbitrator;
* relit \c m_chargingActions et les reformate en LoadAction(Setpoint) annotées d'un
* \c reason français. Le dispatch EV réel reste dans \c adjustEvChargers() amont.
*
* 2. **Waterfall non-EV (3c/3e)** : un budget de surplus UNIQUE net SIGNÉ
* 2. **Waterfall non-EV** : un budget de surplus UNIQUE net SIGNÉ
* \c (exportW importW) evReservedW cascade par **priorité ASC** (rang, 1 = premier
* servi) à travers les charges \c relay-stages (ECS, \c buildEcsStageAction) ET
* \c sg-ready (PAC, \c buildSgReadyStateAction). Anti-clignotement par **recrédit** de la
* conso allouée ; **clamp lock-aware** \c minStage/maxStage et \c minState/maxState (verrous
* minOn/minOff/minStateHold, protection compresseur) la fenêtre est calculée au MÊME
* \c ctx.timestamp que l'exécution (cf. \c ILoadAdapter). Ces LoadAction sont RÉELLEMENT
* dispatchées par \c EnergyArbitrator::applyActionsToAdapters().
* servi). En T2, seule la PAC \c sg-ready (\c buildSgReadyStateAction, kind State) y est
* traitée : anti-clignotement par **recrédit** de la conso allouée, **clamp lock-aware**
* \c minState/maxState (verrou \c minStateHold, protection court-cycling compresseur) calculé
* au MÊME \c ctx.timestamp que l'exécution (cf. \c ILoadAdapter). Ces LoadAction sont
* RÉELLEMENT dispatchées par \c EnergyArbitrator::applyActionsToAdapters().
*
* À partir de **3g** : l'EV rejoindra le waterfall unifié (toutes charges classables ensemble).
* Les charges \c etmvariableload (ECS/routeur, kind Setpoint W) rejoindront ce waterfall avec
* la règle d'arrondi (contrat rév. 2 §5) en **T3**. À partir de **3g**, l'EV rejoint aussi le
* waterfall unifié (toutes charges classables ensemble).
*
* \invariant getPlan() retourne IMMÉDIATEMENT (AGENTS invariant 5) et toujours un Plan valide.
* \invariant Toute LoadAction a un \c reason non vide, en français.
@ -51,8 +52,8 @@ public:
*
* Étape 1 (EV) : \c runSpotMarketPlanning() + \c runSurplusPlanning() puis reformatage
* de \c scheduledActions() en LoadAction(Setpoint) log [Arbitre], dispatch amont.
* Étape 2 (non-EV) : waterfall surplus sur les charges \c relay-stages / \c sg-ready
* triées par priorité LoadAction(Stage/State) réellement dispatchées.
* Étape 2 (non-EV) : waterfall surplus sur la PAC \c sg-ready triée par priorité
* LoadAction(State) réellement dispatchées (etmvariableload ECS/routeur arrive en T3).
*
* \param ctx SurplusContext courant. Utilisé : \c ctx.timestamp (temps de cycle / verrous),
* \c ctx.meter (surplus net signé), \c ctx.loads (déclarés, télémétrie, fenêtres de verrou).
@ -83,21 +84,6 @@ private:
*/
LoadAction buildIdleAction(EvCharger *ev) const;
/*!
* \brief Construit un LoadAction "stage" ECS par cascade de surplus (waterfall §6).
*
* Retient le palier déclaré le plus haut dont la puissance tient dans le budget courant.
* Correction B (anti-clignotement) : recrédite d'abord \c lc.telemetry.currentPowerW au
* budget (la conso actuelle de l'ECS est déjà soustraite de l'export mesuré), puis
* décrémente \c remainingSurplusW de la puissance du palier retenu.
*
* \param lc Charge ECS (adapter == "relay-stages") du SurplusContext.
* \param[in,out] remainingSurplusW Budget de surplus restant (W) ; mis à jour pour la
* charge ECS suivante (priorité inférieure / rang supérieur).
* \return LoadAction kind=Stage, funding=Surplus, \c reason français non vide.
*/
LoadAction buildEcsStageAction(const LoadContext &lc, double &remainingSurplusW) const;
/*!
* \brief Construit un LoadAction "state" SG-Ready (PAC) par mapping SÉMANTIQUE du surplus.
*

View File

@ -16,8 +16,10 @@
* \c currentA, \c phaseCount sont significatifs.
*/
struct LoadAction {
/*! \brief Type d'action : consigne continue, palier discret, état ou contrainte. */
enum Kind { Setpoint, Stage, State, Constraint };
/*! \brief Type d'action : consigne en watts (etmvariableload), état SG-Ready ou contrainte.
* \note Le kind \c Stage (index de palier ECS) a é RETIRÉ (contrat etmvariableload rév. 2
* §6) : l'ECS est piloté en watts via \c Setpoint, l'index de palier vit dans le thing. */
enum Kind { Setpoint, State, Constraint };
/*! \brief Financement interne : Surplus (PV) ou Grid (réseau). Non sérialisé. */
enum Funding { Surplus, Grid };
/*! \brief Source d'énergie pour Setpoint batterie : "solar" ou "grid". */
@ -38,9 +40,6 @@ struct LoadAction {
double powerW = 0;
Source source = Solar;
// --- Stage relay-stages (ECS) ---
int stage = 0; //!< Index de palier (0 = off, 1 = 1er palier, ...).
// --- State sg-ready ---
int state = 0; //!< État SG-Ready (1-4).

View File

@ -19,10 +19,6 @@ struct LoadDeclared {
double maxA = 0; //!< Courant maximum (A).
int phases = 0; //!< Nombre de phases disponibles (1 ou 3).
// --- relay-stages (ECS) ---
//! Puissances en W par palier : [0, 1200, 2400] — index 0 = off.
QList<int> stages;
// --- etmvariableload (charge à puissance pilotable : EV / ECS résistif / routeur PV) ---
//! Paliers atteignables en W (contrat etmvariableload §3), triés croissants, \c 0 inclus.
//! Présent en mode *fixed*. Vide ⇒ modulation continue (*dynamic*, borne \c maxPowerW).
@ -86,7 +82,7 @@ struct LoadNeeds {
struct LoadDescriptor {
QString id; //!< ThingId de la charge (string).
QString label; //!< Nom lisible (affiché dans les logs).
//! Type d'adaptateur : "evcharger"|"relay-stages"|"sg-ready"|"battery".
//! Type d'adaptateur : "evcharger"|"etmvariableload"|"sg-ready"|"battery".
QString adapter;
int priority = 0;
LoadDeclared declared;

View File

@ -59,13 +59,9 @@ struct LoadContextTelemetry {
bool pluggedIn = false;
bool charging = false;
double sessionWh = 0; //!< Énergie chargée dans la session courante (Wh).
// --- relay-stages ---
int stage = 0;
//! Fenêtre de paliers autorisée MAINTENANT par les verrous (calculée au temps de cycle).
//! Le scheduler y borne son choix : minStage = plancher (verrou minOn, puissance
//! engagée non-coupable) ; maxStage = plafond (verrou minOff, redémarrage interdit).
int minStage = 0;
int maxStage = 0;
// --- etmvariableload (EV/ECS/routeur) : pas de fenêtre de verrou côté moteur —
// l'anti-rebond (minOn/minOff) vit dans le thing (contrat rév. 2 §3). Seul
// currentPowerW (ci-dessus) sert au résidu de la règle d'arrondi (contrat §5).
// --- sg-ready ---
int state = 0;
//! Fenêtre d'états autorisée MAINTENANT par le verrou minStateHold (protection
@ -91,7 +87,7 @@ struct LoadLearned {
*/
struct LoadContext {
QString id;
QString adapter; //!< "evcharger"|"relay-stages"|"sg-ready"|"battery".
QString adapter; //!< "evcharger"|"etmvariableload"|"sg-ready"|"battery".
QString label;
int priority = 0;
LoadDeclared declared;