m_currentStage valait 0 à la construction alors que des contacts peuvent être
fermés : le moteur croyait 0 W pendant que le ballon tirait sa puissance. Même
famille de défaut qu'ECS-410 — annoncer une puissance non appliquée.
deduceStageFromThings() lit l'état réel des Things au constructeur, en trois
temps :
1. correspondance EXACTE de l'ensemble de relais fermés avec un palier — cas
nominal ;
2. à défaut, correspondance par PUISSANCE. Ce cas se produit avec les encodages
dédupliqués : deux combinaisons de même puissance existent, une seule est
conservée dans la table. Toute somme de sous-ensemble figure nécessairement
dans m_levels, qui est construit de ces sommes — la reprise aboutit donc
toujours, et le premier applyAction() normalise l'encodage des relais ;
3. hors table (troncature à MaxRelays) : palier MAXIMAL, jamais 0.
Un relais introuvable est supposé FERMÉ, avec avertissement. Le principe est
constant sur les trois branches : ne jamais annoncer moins que ce qui peut être
appliqué. Sous-estimer est le défaut qu'ECS-411 corrige ; surestimer ne fait que
retarder une montée en puissance.
Le repli initial sur le palier 0 — écrit dans un premier jet — reproduisait
exactement le défaut visé et a été corrigé avant ce commit.
Test : testEcsRestartRecovery — trois cas, dont une combinaison à deux relais
(R500+R1500 = 2000 W) et le cas tout-ouvert.
Build amd64 0 erreur. Simulation : 13/13.
Réf. specs/spec_ecs.md §5 ECS-411 (0.5.1).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
128 lines
6.3 KiB
C++
128 lines
6.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; }
|
|
|
|
//! \brief Met à jour les champs qui ne touchent PAS le matériel (ECS-412).
|
|
//! \param priority Nouveau rang de service.
|
|
//! \param needs Nouveaux besoins déclarés.
|
|
//! \note Permet à l'arbitre de refléter un changement de priorité SANS détruire
|
|
//! l'adaptateur — donc sans réarmer les verrous ni recommuter les relais.
|
|
void updateSoftConfig(int priority, const LoadNeeds &needs) override;
|
|
|
|
private:
|
|
//! Palier le plus haut dont la puissance ≤ \p powerW (≥ 0).
|
|
int stageForPower(double powerW) const;
|
|
/*!
|
|
* \brief Fenêtre de paliers autorisée à \p now par minOn/minOff (verrou INTERNE).
|
|
* \param now Temps de cycle.
|
|
* \param[out] minStage Palier plancher (puissance engagée non-coupable).
|
|
* \param[out] maxStage Palier plafond (interdiction de redémarrer).
|
|
* \note Un \c m_lastSwitch NUL vaut « commutation venant d'avoir lieu », donc verrou
|
|
* **ARMÉ** pour sa durée configurée — cf. ECS-412, démarrage à froid.
|
|
*/
|
|
void lockWindow(const QDateTime &now, int &minStage, int &maxStage) const;
|
|
//! \brief Déduit le palier courant de l'état RÉEL des Things relais (ECS-411).
|
|
//! \return Palier dont l'encodage correspond aux relais fermés ; à défaut, celui de
|
|
//! même PUISSANCE. Ne retourne jamais un palier inférieur à ce qui est appliqué.
|
|
int deduceStageFromThings() 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;
|
|
};
|