Patrick Schurig d290f0b746 ci: le contrôle de qualité documentaire — écrit, éprouvé, PAS en service
Étape 4 du chantier de documentation. tools/ci-quality.sh porte les trois
vérifications qui se posent au même endroit — elles partagent la même sortie
(doc-generated/) et la même commande de réparation, les séparer ferait trois jobs
qui régénèrent trois fois la même chose :

  1. l'index des règles est VRAI (code de retour de gen-rules.py) ;
  2. la doc Doxygen est complète — 0 avertissement, DoD §5 ;
  3. l'index est REPRODUCTIBLE : deux passes, un seul résultat.

PAS EN SERVICE, et l'extension le garantit : ci/gitea-workflow.yml.disabled ne
déclenche rien tant qu'il n'est pas déplacé dans .gitea/workflows/. La raison est
datée dans le fichier — forge muette, plus de trente commits en attente ici et
plus de soixante côté app ; un job activé à l'aveugle échouerait au moment précis
où tout le monde pousse, et le premier réflexe serait de le désactiver.

CHAQUE ÉCHEC DONNE LA COMMANDE À LANCER. Un job qui annonce « l'index est
périmé » sans dire quoi faire se contourne en le désactivant : c'est le chemin de
moindre effort, et il gagne toujours.

VÉRIFIÉ AVANT D'ÊTRE DÉCLARÉ PRÊT, et ça ne passait pas du premier coup.

— Les 26 avertissements Doxygen de ce matin sont CORRIGÉS, pas contournés par un
  cliquet sur un compte de référence : c'était de la dette DoD-5 (retours et
  paramètres non documentés dans relayrouter.h, energyarbitrator.h, loadconfig.h,
  les trois clearFault(), ILoadAdapter::updateSoftConfig), plus deux que j'avais
  introduits — un \rule{} dans un titre \par, et un \param energyLogs qui ne
  correspond à aucun argument. Le seuil est donc ZÉRO, le seul qui ne rote pas.

— Et le script lui-même échouait sur un dépôt SAIN : `grep -c ... || echo 0`
  affiche 0 PUIS sort en 1 quand il ne trouve rien, si bien que le « || » empile
  un second zéro et que le test entier casse. Corrigé ici et dans gen-doc.sh, qui
  portait le même piège. Une commande de mesure dont l'échec produit une lecture
  fausse — le motif de la semaine, appliqué à l'outil.

CE QUI N'EST PAS VÉRIFIÉ, et pourquoi : « régénérer et refuser si ça diffère de
ce qui est commité » n'a rien à comparer, doc-generated/ étant dans .gitignore.
Le risque qu'un tel diff attraperait — un artefact commité qui dérive de sa
source — a été SUPPRIMÉ en ne le commitant pas, pas déplacé. Ce qui reste à
garantir est que l'index soit vrai (contrôle 1) et déterministe (contrôle 3).

État au 2026-08-29 : ./tools/ci-quality.sh → RC=0, 113 règles, 0 avertissement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-29 11:33:46 +02:00

280 lines
15 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.
* \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 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);
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.
};