Patrick Schurig 3e3e80d445 feat(etm): RelayRouter — couche routeur watts→relais (contrat rév. 3)
Restauration de l'ex-EcsRelayAdapter (26444a5) 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>
2026-06-28 12:03:17 +02:00

110 lines
5.3 KiB
C++

// 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;
};