Le comportement était « non défini » depuis l'écriture de la spec. telemetry() SAUTAIT un relais qui ne résout plus : ou bien plus aucun relais actif ne mesurait et l'on retombait sur le nominal du PALIER ENTIER — part du fantôme comprise — ou bien des survivants mesuraient et l'on publiait une somme sous-évaluée. Et ce n'était pas de l'affichage : telemetry().currentPowerW alimente le recrédit anti-clignotement, donc le BUDGET. L'arbitre se trompait sur le surplus disponible pendant qu'available restait vrai. Un mensonge qui décide, pas un mensonge qui s'affiche. Option (a), la seule cohérente avec la famille : available faux, faultCode THING_MISSING, traitement ECS-410 — sortie de l'ARBITRAGE, maintien dans la COMPTABILITÉ, figée à la puissance crue engagée. La puissance est donc RETIRÉE du budget des suivantes au lieu de leur être recréditée. Aucune somme partielle. (b) — compter 0 W et servir le reste — est écartée : un relais absent du registre est un Thing supprimé ou un plugin qui ne le voit plus, on ne sait pas dans quel état est le contact, et servir le reste reviendrait à supposer. Jugé sur TOUS les relais déclarés, jamais sur ceux du palier courant — sinon la disponibilité basculerait au rythme du thermostat. Même règle que deviceMeasurement(), qui traitait DÉJÀ le cas correctement : aligner l'ancien sur le neuf, pas inventer une politique. THING_MISSING prime sur WRITE_FAILED : un relais absent EXPLIQUE les écritures qui échouent, et annoncer WRITE_FAILED enverrait l'opérateur au bus quand la cause est dans la configuration. D'où le corollaire, écrit dans la doc de clearFault : ClearLoadFault lève le défaut d'ÉCRITURE, il ne fait pas exister un Thing. testEcsPartialFailure a dû être ajusté et c'est instructif : il utilisait un relais JAMAIS CONFIGURÉ comme raccourci pour forcer un échec d'écriture. Le raccourci a maintenant un sens propre, et le test distingue les deux causes — le défaut collant se lève, le câblage manquant non. Trouvé ni par un test ni par le banc, mais par la LECTURE d'une règle que l'index a rendue visible. Consigné comme tel dans spec_ecs.md et AGENTS.md : c'est le premier défaut réel exhumé par le chantier de documentation. simulation 33/33, charging 16/16, loadmodel 20/20, amd64 0/0, doxygen 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
296 lines
16 KiB
C++
296 lines
16 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
|
#pragma once
|
|
|
|
#include <QObject>
|
|
#include <QSet>
|
|
#include <QDateTime>
|
|
#include <QList>
|
|
#include <QString>
|
|
#include "iloadadapter.h"
|
|
|
|
class ThingActionInfo;
|
|
#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.
|
|
*/
|
|
/*!
|
|
* \brief Table de paliers dérivée d'une liste de relais — ce que le routeur SAIT faire.
|
|
*
|
|
* Deux listes de relais qui produisent la même table sont **matériellement identiques** :
|
|
* mêmes puissances atteignables, mêmes contacts fermés pour chacune. C'est la seule
|
|
* comparaison qui ait un sens, et elle est vraie par construction plutôt que par pari.
|
|
*
|
|
* \note L'ordre de déclaration des relais n'est PAS inerte, et c'est pourquoi comparer
|
|
* les listes comme des ensembles serait faux : à somme égale, la table retient la
|
|
* **première** combinaison rencontrée. Deux relais de même puissance échangés dans la
|
|
* liste donnent donc les mêmes paliers mais un **contact différent** pour ce palier —
|
|
* autre contacteur qui s'use, autre résistance qui chauffe. La table le voit ; un
|
|
* ensemble ne le verrait pas.
|
|
*/
|
|
struct RelayStageTable
|
|
{
|
|
QList<int> levels; //!< Paliers W triés croissants, \c levels[0] == 0.
|
|
QList<QList<QString>> mapping; //!< ThingIds fermés pour chaque palier de \c levels.
|
|
|
|
/*!
|
|
* \brief Égalité MATÉRIELLE : mêmes paliers, mêmes contacts fermés pour chacun.
|
|
*
|
|
* Les ThingIds d'une combinaison sont comparés comme un **ensemble**, pas comme une
|
|
* liste. Ils sont collectés dans l'ordre de déclaration des relais, si bien que
|
|
* fermer {K1,K2} et fermer {K2,K1} produit deux listes différentes pour un geste
|
|
* électrique identique. Comparer les listes ferait reconstruire la charge sur un
|
|
* simple réordonnancement — exactement le défaut que cette table corrige.
|
|
*
|
|
* L'ordre reste significatif là où il l'est vraiment : dans le CHOIX de la
|
|
* combinaison retenue à somme égale, que \c levels et l'appariement position par
|
|
* position capturent.
|
|
*/
|
|
//! \param o Table comparée. \return Vrai si les deux tables décrivent le MÊME câblage.
|
|
bool operator==(const RelayStageTable &o) const
|
|
{
|
|
if (levels != o.levels || mapping.size() != o.mapping.size())
|
|
return false;
|
|
for (int i = 0; i < mapping.size(); ++i) {
|
|
const QList<QString> &a = mapping.at(i);
|
|
const QList<QString> &b = o.mapping.at(i);
|
|
if (QSet<QString>(a.begin(), a.end()) != QSet<QString>(b.begin(), b.end()))
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
//! \brief Négation de \c operator==. \param o Table comparée. \return Vrai si elles diffèrent.
|
|
bool operator!=(const RelayStageTable &o) const { return !(*this == o); }
|
|
};
|
|
|
|
/*!
|
|
* \brief Adaptateur d'une charge commandée par une COMBINAISON de relais (rév. 3).
|
|
*
|
|
* Traduit une enveloppe en watts en un palier, et le palier en contacts à fermer. Toute la
|
|
* combinatoire vit ici : l'optimiseur ne voit que des watts, jamais un relais ni un index de
|
|
* palier (ECS-306). Les paliers sont DÉRIVÉS des sous-ensembles de relais (\c deriveStages),
|
|
* ce qui couvre les topologies non cascadées et les puissances quelconques.
|
|
*
|
|
* \invariant Coupure avant fermeture à chaque transition — jamais deux contacts en conflit.
|
|
* \invariant Les verrous \c minOn / \c minOff sont INTERNES à l'adaptateur, évalués sur le
|
|
* \c now reçu en paramètre, jamais sur l'horloge.
|
|
* \invariant Un échec d'écriture pose un défaut COLLANT (ECS-410) : plus aucune commande
|
|
* n'est émise jusqu'à \c clearFault().
|
|
*/
|
|
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.
|
|
* \rule{LM-303} — les verrous appartiennent au MÉCANISME, pas au domaine : c'est le
|
|
* relais qui craint le court-cycle, pas le service rendu.
|
|
* \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;
|
|
|
|
//! \param now Temps de cycle — source unique des verrous (contrat \c ILoadAdapter).
|
|
//! \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. \return Index du palier, 0 = tout coupé.
|
|
int currentStage() const { return m_currentStage; }
|
|
//! \brief Puissance du palier courant. \return Watts du palier appliqué ; 0 si hors table.
|
|
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 label Nouveau libellé d'affichage.
|
|
//! \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(const QString &label, int priority, const LoadNeeds &needs) override;
|
|
|
|
/*!
|
|
* \brief Dérive la table de paliers d'une liste de relais. **Implémentation unique.**
|
|
*
|
|
* Trois appelants, une seule règle : la construction du routeur, la comparaison de
|
|
* \c EnergyArbitrator::sameHardware(), et la publication en télémétrie. Dupliquer
|
|
* cette combinatoire ailleurs — y compris côté app — expose à une divergence que
|
|
* personne ne verrait passer.
|
|
*
|
|
* \param relays Relais déclarés, dans leur ordre de configuration (l'ordre compte,
|
|
* cf. \c RelayStageTable).
|
|
* \return La table dérivée. Toujours non vide : le sous-ensemble vide donne le palier 0.
|
|
*/
|
|
static RelayStageTable deriveStages(const QList<LoadConfigRelay> &relays);
|
|
|
|
/*!
|
|
* \brief Lève le verrou de défaut (ECS-410, RPC \c NymeaEnergy.ClearLoadFault).
|
|
* \return Vrai si un défaut a RÉELLEMENT été levé, faux s'il n'y en avait pas.
|
|
* \note **Lever le défaut ne rend pas forcément la charge disponible.** \rule{ECS-111} :
|
|
* un relais déclaré qui manque au registre la garde indisponible, et ClearLoadFault n'y
|
|
* peut rien — il ne fait pas exister un Thing. Les deux causes sont distinctes et se
|
|
* lisent séparément : \c faulted() pour le défaut d'écriture collant,
|
|
* \c telemetry().available pour l'aptitude réelle à être commandée.
|
|
* \note Le défaut est COLLANT par conception — « cesser toute commande jusqu'à
|
|
* intervention ». Sa levée est donc un acte délibéré et tracé de l'opérateur, jamais
|
|
* une expiration. L'état matériel réel étant inconnu après un défaut, il est RELU
|
|
* (même principe qu'ECS-411) et non supposé.
|
|
*/
|
|
bool clearFault() override;
|
|
|
|
/*!
|
|
* \brief Vue runtime pour la télémétrie RPC : palier en watts, défaut, verrou en secondes.
|
|
* \param now Temps de cycle — même source que \c lockWindow(), donc le verrou annoncé est
|
|
* celui qui a réellement borné la décision.
|
|
* \return \c mechanismKind "relay" ; \c mechanism \c {stageW, maxStageW} — des WATTS,
|
|
* jamais un index de palier ni un ThingId (la frontière rév. 3 tient aussi en sortie).
|
|
*/
|
|
LoadRuntimeView runtimeView(const QDateTime &now) const override;
|
|
|
|
/*!
|
|
* \brief Mesure propre des relais qui portent la charge (§11 / LM-1105).
|
|
*
|
|
* \return Somme des \c currentPower des relais FERMÉS au palier courant, à condition que
|
|
* **tous** les relais déclarés portent l'état ; « non mesurable » sinon.
|
|
*
|
|
* \note **La capacité se juge sur TOUS les relais déclarés, la valeur se lit sur les relais
|
|
* FERMÉS.** Juger la capacité sur les relais fermés ferait répondre « non mesurable » au
|
|
* palier 0, où aucun ne l'est — la source publiée basculerait à chaque extinction, sur
|
|
* une installation dont rien n'a changé (LM-1106).
|
|
* \note **Tous, et pas au moins un (LM-1105-a).** Une topologie mixte — un relais mesurant, deux nus —
|
|
* produirait une somme SOUS-ÉVALUÉE présentée comme la mesure de la charge, et l'écart
|
|
* commandé↔mesuré enverrait chercher une panne qui n'existe pas (LM-1102). Une mesure
|
|
* partielle n'est pas une mesure ; c'est le cas où \c meterThingId garde tout son sens.
|
|
* \note Distincte de \c telemetry(), qui accepte « au moins un » et retombe sur le palier
|
|
* NOMINAL sinon : elle doit rendre un nombre à chaque cycle pour le recrédit. Ici, un
|
|
* nominal n'est pas une mesure.
|
|
*/
|
|
LoadMeasurement deviceMeasurement() const override;
|
|
|
|
//! \brief État sûr du routeur : TOUS relais ouverts (ECS-413).
|
|
//! \param now Temps de cycle — l'action passe en \c force = true, verrous bypassés.
|
|
void applySafeState(const QDateTime &now) override;
|
|
|
|
//! \return Vrai tant qu'une écriture émise n'est pas acquittée (ECS-410).
|
|
bool writesPending() const { return m_pending > 0; }
|
|
|
|
//! \return Vrai si la charge est en défaut (plus aucune commande émise).
|
|
bool faulted() const { return m_faulted; }
|
|
|
|
private:
|
|
//! Barreaux de l'échelle ECS-410. Une tentative par barreau, jamais de boucle.
|
|
enum Phase { PhaseNominale, PhaseRepli, PhaseArretTotal, PhaseDefaut };
|
|
|
|
void writeRelay(const QString &thingId, bool on);
|
|
void emitRelayWrites(int stage);
|
|
//! Verdict pris quand toutes les écritures d'une étape sont acquittées.
|
|
void settleTransition();
|
|
|
|
//! 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);
|
|
|
|
/*!
|
|
* \brief Un relais DÉCLARÉ manque-t-il au registre nymea ? \rule{ECS-111}
|
|
* \return Vrai dès qu'un seul \c thingId déclaré ne résout plus.
|
|
* \note Jugé sur TOUS les relais déclarés, jamais sur ceux du palier courant : au palier 0
|
|
* aucun n'est fermé, et juger là-dessus ferait basculer la disponibilité au rythme du
|
|
* thermostat. Même règle que \c deviceMeasurement().
|
|
*/
|
|
bool aUnRelaisManquant() const;
|
|
|
|
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).
|
|
//! Puissance nominale DÉCLARÉE de chaque relais. Ne pas la redéduire de
|
|
//! m_relayMapping : la déduplication par puissance peut évincer l'encodage solo d'un
|
|
//! relais (ex. 500/1000/1500 — le palier 1500 est encodé {500,1000}, et le relais 1500
|
|
//! n'apparaît alors jamais seul), ce qui lui donnerait une nominale nulle. Voir ECS-411-b.
|
|
QHash<QString, int> m_relayNominalW;
|
|
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;
|
|
|
|
// --- ECS-410 : suivi asynchrone des écritures ---------------------------------------
|
|
int m_pending = 0; //!< Écritures émises non encore acquittées.
|
|
bool m_writeFailed = false; //!< Au moins un acquittement en erreur sur l'étape courante.
|
|
int m_stagePrev = 0; //!< Palier avant la transition en cours.
|
|
int m_stageTarget = 0; //!< Palier visé par la transition en cours.
|
|
Phase m_phase = PhaseNominale;
|
|
bool m_faulted = false; //!< Verrou de défaut. COLLANT : seul clearFault() le lève.
|
|
};
|