feat(etm): RelayRouter — couche routeur watts→relais (contrat rév. 3)
Restauration de l'ex-EcsRelayAdapter (b7bfd58) repositionné en ROUTEUR distinct, sous l'optimiseur watt-pur. ILoadAdapter consommant Setpoint(W) au lieu de Stage : - Paliers DÉRIVÉS : toutes les combinaisons (sommes de sous-ensembles) des relais, dédupliquées, triées, 0 inclus — source de vérité unique = les relais (descriptor().powerLevels/maxPowerW). - applyAction(Setpoint W) : mappe powerW → combinaison la plus haute ≤ powerW, applique les verrous minOn/minOff EN INTERNE (clamp, plus exposés au scheduler), commute via ThingManager::executeAction (interface "power"), off-before-on. force=true → bypass (repli L2). - currentPowerW : mesuré si relais expose "currentPower", sinon nominal commandé (GPIO bool nu). - LoadContext : watts uniquement (powerLevels/currentPowerW) — aucun relais ne franchit la frontière. Classe INERTE à ce commit (non câblée à l'arbitre) : aucune charge ne l'utilise, donc aucune charge sans repli L2. Câblage + repli L2 = étape 4 (atomiquement). Build prod 0/0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5585a5c483
commit
e16aca4d1a
245
energyplugin/etm/adapters/relayrouter.cpp
Normal file
245
energyplugin/etm/adapters/relayrouter.cpp
Normal file
@ -0,0 +1,245 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
|
||||
#include "relayrouter.h"
|
||||
#include "plugininfo.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QMap>
|
||||
#include <QSet>
|
||||
#include <integrations/thingmanager.h>
|
||||
#include <integrations/thing.h>
|
||||
#include <types/action.h>
|
||||
#include <types/param.h>
|
||||
|
||||
namespace {
|
||||
//! Au-delà, 2^N combinaisons explose. 16 relais = 65536 combos — bien au-delà de tout ECS réel.
|
||||
constexpr int MaxRelays = 16;
|
||||
}
|
||||
|
||||
RelayRouter::RelayRouter(ThingManager *thingManager,
|
||||
const QString &id,
|
||||
const QString &label,
|
||||
const QList<LoadConfigRelay> &relays,
|
||||
int minOnS,
|
||||
int minOffS,
|
||||
int priority,
|
||||
const LoadNeeds &needs,
|
||||
QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_thingManager(thingManager)
|
||||
, m_id(id)
|
||||
, m_label(label)
|
||||
, m_minOnS(minOnS)
|
||||
, m_minOffS(minOffS)
|
||||
, m_priority(priority)
|
||||
, m_needs(needs)
|
||||
{
|
||||
int n = relays.size();
|
||||
if (n > MaxRelays) {
|
||||
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label << "—" << n
|
||||
<< "relais > max" << MaxRelays << ": tronqué.";
|
||||
n = MaxRelays;
|
||||
}
|
||||
|
||||
// Paliers DÉRIVÉS : toutes les sommes de sous-ensembles, dédupliquées par puissance, triées
|
||||
// (QMap = clés croissantes), 0 inclus (sous-ensemble vide = masque 0). Pour des puissances
|
||||
// identiques (ex. deux relais 1000 W), on garde la première combinaison rencontrée.
|
||||
QMap<int, QList<QString>> byPower;
|
||||
for (int mask = 0; mask < (1 << n); ++mask) {
|
||||
int sum = 0;
|
||||
QList<QString> set;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (mask & (1 << i)) {
|
||||
sum += relays.at(i).powerW;
|
||||
set.append(relays.at(i).thingId);
|
||||
}
|
||||
}
|
||||
if (!byPower.contains(sum))
|
||||
byPower.insert(sum, set);
|
||||
}
|
||||
for (auto it = byPower.constBegin(); it != byPower.constEnd(); ++it) {
|
||||
m_levels.append(it.key());
|
||||
m_relayMapping.append(it.value());
|
||||
}
|
||||
// byPower contient toujours la clé 0 (masque vide) → m_levels[0] == 0.
|
||||
Q_ASSERT(!m_levels.isEmpty() && m_levels.first() == 0);
|
||||
}
|
||||
|
||||
LoadDescriptor RelayRouter::descriptor() const
|
||||
{
|
||||
LoadDescriptor d;
|
||||
d.id = m_id;
|
||||
d.label = m_label;
|
||||
d.adapter = QStringLiteral("relay-router");
|
||||
d.priority = m_priority;
|
||||
|
||||
// Paliers DÉRIVÉS exposés à l'optimiseur (watts) — JAMAIS les relais.
|
||||
d.declared.powerLevels = m_levels;
|
||||
d.declared.maxPowerW = m_levels.isEmpty() ? 0 : m_levels.last();
|
||||
d.limits.minOnS = m_minOnS;
|
||||
d.limits.minOffS = m_minOffS;
|
||||
d.needs = m_needs;
|
||||
|
||||
d.supportedKinds = { LoadAction::Setpoint };
|
||||
return d;
|
||||
}
|
||||
|
||||
LoadTelemetry RelayRouter::telemetry() const
|
||||
{
|
||||
LoadTelemetry t;
|
||||
t.available = true;
|
||||
t.lastActionAt = m_lastActionAt;
|
||||
|
||||
// currentPowerW :
|
||||
// - MESURÉ dès qu'au moins un relais du palier expose un state "currentPower" (somme).
|
||||
// Un thermostat coupé (relais ON mais 0 W) → 0, jamais de puissance fantôme.
|
||||
// - NOMINAL commandé (repli) si aucun relais actif ne mesure (GPIO bool nu). Dans ce cas
|
||||
// currentPowerW reflète le COMMANDÉ, pas le mesuré (contrat rév. 3 §3.a).
|
||||
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 *relay = m_thingManager->findConfiguredThing(ThingId(thingId));
|
||||
if (!relay)
|
||||
continue;
|
||||
if (!relay->thingClass().stateTypes().findByName("currentPower").id().isNull()) {
|
||||
metered = true;
|
||||
power += relay->stateValue("currentPower").toDouble();
|
||||
}
|
||||
}
|
||||
if (!metered && m_currentStage > 0 && m_currentStage < m_levels.size())
|
||||
power = m_levels.at(m_currentStage); // nominal commandé
|
||||
|
||||
t.currentPowerW = power;
|
||||
return t;
|
||||
}
|
||||
|
||||
LoadContext RelayRouter::toLoadContext(const QDateTime &now) const
|
||||
{
|
||||
Q_UNUSED(now) // pas de fenêtre de verrou exposée : le verrou est INTERNE (frontière rév. 3).
|
||||
|
||||
LoadContext ctx;
|
||||
ctx.id = m_id;
|
||||
ctx.adapter = QStringLiteral("relay-router");
|
||||
ctx.label = m_label;
|
||||
ctx.priority = m_priority;
|
||||
ctx.declared = descriptor().declared; // powerLevels/maxPowerW dérivés (watts)
|
||||
ctx.needs = m_needs;
|
||||
|
||||
ctx.telemetry.currentPowerW = telemetry().currentPowerW;
|
||||
// Aucun stage/minStage/maxStage : ces champs n'existent plus (rév. 2) et le relais ne
|
||||
// franchit pas la frontière. L'optimiseur ne voit que des watts.
|
||||
return ctx;
|
||||
}
|
||||
|
||||
LoadAction RelayRouter::applyAction(const LoadAction &action, const QDateTime &now)
|
||||
{
|
||||
if (action.kind != LoadAction::Setpoint)
|
||||
return action;
|
||||
|
||||
if (action.reason.isEmpty()) {
|
||||
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
||||
<< "— LoadAction sans reason rejetée.";
|
||||
return action;
|
||||
}
|
||||
|
||||
int newStage = stageForPower(action.powerW);
|
||||
|
||||
// Verrou anti-rebond INTERNE (clamp), au temps de cycle. Bypass si force==true (repli L2).
|
||||
if (!action.force) {
|
||||
int minStage, maxStage;
|
||||
lockWindow(now, minStage, maxStage);
|
||||
newStage = qBound(minStage, newStage, maxStage);
|
||||
}
|
||||
|
||||
LoadAction applied = action;
|
||||
applied.powerW = m_levels.at(newStage);
|
||||
applied.estimatedPowerW = m_levels.at(newStage);
|
||||
|
||||
if (newStage == m_currentStage)
|
||||
return applied; // idempotent — pas de re-commutation
|
||||
|
||||
qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
||||
<< "→ consigne" << qRound(action.powerW) << "W → palier"
|
||||
<< m_levels.at(newStage) << "W"
|
||||
<< (action.force ? "(force)" : "")
|
||||
<< "|" << action.reason;
|
||||
|
||||
applyRelayStage(newStage);
|
||||
|
||||
m_currentStage = newStage;
|
||||
m_lastSwitch = now;
|
||||
m_lastActionAt = now;
|
||||
return applied;
|
||||
}
|
||||
|
||||
// ---- privé ---------------------------------------------------------------
|
||||
|
||||
int RelayRouter::stageForPower(double powerW) const
|
||||
{
|
||||
int stage = 0; // m_levels[0] == 0 garanti
|
||||
for (int i = 0; i < m_levels.size(); ++i) {
|
||||
if (m_levels.at(i) <= powerW)
|
||||
stage = i;
|
||||
else
|
||||
break; // m_levels triés croissants
|
||||
}
|
||||
return stage;
|
||||
}
|
||||
|
||||
void RelayRouter::lockWindow(const QDateTime &now, int &minStage, int &maxStage) const
|
||||
{
|
||||
const int topStage = m_levels.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 (puissance engagée non-coupable).
|
||||
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;
|
||||
}
|
||||
|
||||
void RelayRouter::applyRelayStage(int stage)
|
||||
{
|
||||
// Set CIBLE du palier (delta complet : chaque relais connu amené à son état on/off cible).
|
||||
// Gère les mappings NON-CASCADÉS (monter d'un palier peut éteindre des 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()) << "[RelayRouter]" << 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
|
||||
}
|
||||
};
|
||||
|
||||
// off-before-on : couper d'abord les relais hors-cible, puis enclencher 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);
|
||||
}
|
||||
109
energyplugin/etm/adapters/relayrouter.h
Normal file
109
energyplugin/etm/adapters/relayrouter.h
Normal file
@ -0,0 +1,109 @@
|
||||
// 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"
|
||||
#include "../types/loadconfig.h" // LoadConfigRelay
|
||||
|
||||
class Thing;
|
||||
class ThingManager;
|
||||
|
||||
/*!
|
||||
* \brief ROUTEUR de relais (contrat rév. 3) — traduit un \c Setpoint(W) de l'optimiseur en
|
||||
* combinaison de things \c power, côté experience-plugin.
|
||||
*
|
||||
* \par Frontière rév. 3
|
||||
* L'optimiseur (scheduler) est **watt-pur** : il émet \c LoadAction{Setpoint, powerW}. Le
|
||||
* `RelayRouter` est la couche **sous** l'optimiseur qui connaît les relais : il mappe
|
||||
* \c powerW → la combinaison atteignable la plus haute ≤ \c powerW, applique l'anti-rebond
|
||||
* (\c minOn/minOff) **ici** (plus dans le scheduler), commute les relais via
|
||||
* \c ThingManager::executeAction (interface \c power), et agrège \c currentPowerW. **Aucun
|
||||
* relais, aucun index de combinaison ne remonte dans le \c LoadContext** (seuls les watts
|
||||
* dérivés `powerLevels`/`maxPowerW` y figurent).
|
||||
*
|
||||
* \par Paliers DÉRIVÉS
|
||||
* Les \c powerLevels (et \c maxPowerW) sont **calculés** depuis la liste de relais : toutes
|
||||
* les sommes de sous-ensembles atteignables, dédupliquées, triées, \c 0 inclus. **Source de
|
||||
* vérité unique = les relais** (l'app affiche le même calcul, l'optimiseur les lit pour
|
||||
* l'arrondi/résidu).
|
||||
*
|
||||
* \invariant \c supportedKinds == { Setpoint }. Autres kinds : retour sans effet.
|
||||
* \invariant applyAction() rejette silencieusement toute action dont \c reason est vide.
|
||||
* \invariant Verrous \c minOn/minOff appliqués en INTERNE (clamp), bypassés si \c force==true
|
||||
* (repli L2). **Temps = paramètre** (cf. \c ILoadAdapter) : \c now reçu, jamais l'horloge.
|
||||
* \invariant Transition relais en **off-before-on** : coupe d'abord les relais hors-cible.
|
||||
*/
|
||||
class RelayRouter : public QObject, public ILoadAdapter
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
/*!
|
||||
* \brief Constructeur.
|
||||
* \param thingManager Résout les ThingIds des relais et exécute les actions \c power.
|
||||
* \param id Identifiant LOGIQUE de la charge (rév. 3 : plus un thingId unique).
|
||||
* \param label Nom lisible (logs, app).
|
||||
* \param relays Liste \c {thingId, powerW} des relais \c power. Les paliers atteignables
|
||||
* sont dérivés de toutes leurs combinaisons.
|
||||
* \param minOnS/minOffS Verrous anti-rebond (protection relais/compresseur), appliqués ici.
|
||||
* \param priority Rang dans le waterfall (1 = servi en premier).
|
||||
* \param needs Besoins déclarés (exposés via descriptor().needs).
|
||||
* \param parent Propriétaire Qt.
|
||||
*/
|
||||
explicit RelayRouter(ThingManager *thingManager,
|
||||
const QString &id,
|
||||
const QString &label,
|
||||
const QList<LoadConfigRelay> &relays,
|
||||
int minOnS,
|
||||
int minOffS,
|
||||
int priority,
|
||||
const LoadNeeds &needs = LoadNeeds(),
|
||||
QObject *parent = nullptr);
|
||||
|
||||
//! \return LoadDescriptor : adapter="relay-router", powerLevels/maxPowerW DÉRIVÉS, needs.
|
||||
LoadDescriptor descriptor() const override;
|
||||
|
||||
//! \return currentPowerW = somme des \c currentPower des relais ON (mesuré), sinon nominal commandé.
|
||||
LoadTelemetry telemetry() const override;
|
||||
|
||||
//! \return LoadContext §5 (watts uniquement : powerLevels, currentPowerW — aucun relais).
|
||||
LoadContext toLoadContext(const QDateTime &now) const override;
|
||||
|
||||
/*!
|
||||
* \brief Applique un \c Setpoint(W) : mappe en combinaison de relais, clampe par les verrous,
|
||||
* commute (off-before-on), publie l'état.
|
||||
* \param action LoadAction kind \c Setpoint (\c powerW). Autres kinds : retour sans effet.
|
||||
* \param now Temps de cycle (verrous + estampille).
|
||||
* \return L'action après écrêtage (\c powerW = palier réellement appliqué).
|
||||
*/
|
||||
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
|
||||
|
||||
//! \brief Palier courant (0 = tout coupé).
|
||||
int currentStage() const { return m_currentStage; }
|
||||
//! \brief Puissance (W) du palier courant.
|
||||
double currentSetpointW() const { return m_currentStage < m_levels.size() ? m_levels.at(m_currentStage) : 0.0; }
|
||||
|
||||
private:
|
||||
//! Palier le plus haut dont la puissance ≤ \p powerW (≥ 0).
|
||||
int stageForPower(double powerW) const;
|
||||
//! Fenêtre de paliers autorisée à \p now par minOn/minOff (verrou INTERNE, plus exposé).
|
||||
void lockWindow(const QDateTime &now, int &minStage, int &maxStage) const;
|
||||
void applyRelayStage(int stage);
|
||||
|
||||
ThingManager *m_thingManager;
|
||||
QString m_id;
|
||||
QString m_label;
|
||||
QList<int> m_levels; //!< Paliers W dérivés, triés, [0]=0.
|
||||
QList<QList<QString>> m_relayMapping; //!< ThingIds ON par palier (dérivé des combinaisons).
|
||||
int m_minOnS;
|
||||
int m_minOffS;
|
||||
int m_priority;
|
||||
LoadNeeds m_needs;
|
||||
|
||||
int m_currentStage = 0;
|
||||
QDateTime m_lastSwitch; //!< Dernier changement (null = jamais).
|
||||
QDateTime m_lastActionAt;
|
||||
};
|
||||
@ -10,6 +10,7 @@ HEADERS += \
|
||||
$$PWD/adapters/evadapter.h \
|
||||
$$PWD/adapters/sgreadyadapter.h \
|
||||
$$PWD/adapters/etmvariableloadadapter.h \
|
||||
$$PWD/adapters/relayrouter.h \
|
||||
$$PWD/scheduler/rulebasedscheduler.h \
|
||||
$$PWD/energyarbitrator.h \
|
||||
|
||||
@ -19,5 +20,6 @@ SOURCES += \
|
||||
$$PWD/adapters/evadapter.cpp \
|
||||
$$PWD/adapters/sgreadyadapter.cpp \
|
||||
$$PWD/adapters/etmvariableloadadapter.cpp \
|
||||
$$PWD/adapters/relayrouter.cpp \
|
||||
$$PWD/scheduler/rulebasedscheduler.cpp \
|
||||
$$PWD/energyarbitrator.cpp \
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user