Patrick Schurig 1b8c09c9a2 fix: ECS-410-b — l'issue de ClearLoadFault, et l'asymétrie refus/succès (règles 7-c, 7-d)
ClearLoadFault est le SEUL levier de reprise à distance : ECS-410 pose un verrou collant que
seuls ce RPC ou une reconstruction lèvent. L'arbitre journalisait la demande de l'opérateur,
jamais son issue — si bien qu'appelé depuis l'étranger, on ne distinguait pas « le défaut a
été levé » de « il n'y en avait pas » de « l'appel n'a pas atteint l'adaptateur ». L'opérateur
relance trois fois et conclut que le système est cassé.

L'issue est écrite dans l'ARBITRE, pas dans chaque adaptateur : une ligne là couvre les quatre
d'un coup et ne peut pas être oubliée par un adaptateur futur ; répétée dans chacun, elle
manquerait le cinquième. Les trois cas sont distincts à la lecture.

ILoadAdapter::clearFault() rend un bool au lieu de void. Le type de retour force chaque
implémentation, présente et future, à répondre à la question « un défaut a-t-il réellement été
levé ». EvAdapter, qui n'a pas de mécanisme de défaut, le dit désormais explicitement plutôt
que par un corps vide.

Règle 7-d — un refus doit être au moins aussi visible que l'application correspondante. Le
refus par verrou minStateHold de SgReadyAdapter était en qCDebug quand l'application, dix
lignes plus bas, était en qCInfo. L'asymétrie est le défaut, pas le niveau : un resserrement
de la journalisation — et on vient d'en faire un pour tenir deux semaines — aurait fait
disparaître le refus AVANT le succès, ne laissant au journal que les décisions abouties. Or un
refus est plus informatif qu'une application réussie : il dit qu'une décision a été prise et
n'a pas été exécutée. Vérification faite sur les autres adaptateurs : c'était le seul cas. Les
qCDebug restants sont symétriques, et le marqueur L2 par cycle reste en debug à dessein, son
entrée étant en qCWarning et sa sortie en qCInfo.

Le retour idempotent du routeur reste DIFFÉRÉ, et ira en qCDebug : c'est le cas le plus
fréquent — plusieurs milliers de lignes par jour sur un budget ramené à 6,5 Mo — et son
ambiguïté est partiellement couverte, le scheduler journalisant sa décision et l'armement à
froid s'exécutant avant ce retour. Faible gain, coût réel : l'inverse des deux cas corrigés.

Le test porte sur le TEXTE des trois issues et vérifie qu'elles sont deux à deux distinctes.
Suite complète : 112 tests, 0 échec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 20:27:53 +02:00

170 lines
8.6 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"
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.
*/
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;
/*!
* \brief Lève le verrou de défaut (ECS-410, RPC \c NymeaEnergy.ClearLoadFault).
* \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 É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);
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.
};