Patrick Schurig a0c02fbd34 feat(lot D): libellé jusqu'à l'adaptateur, relais comparés par table, paliers publiés
Les trois premiers constats de BRIEF_agent_plugin.md, dans l'ordre de ce qu'ils coûtent.

§2 — Renommer une charge n'atteignait jamais l'adaptateur. updateSoftConfig() ne
portait que priority et needs, donc un changement de libellé seul était compté
« inchangée » : l'adaptateur gardait son ancien nom et le journal contredisait
GetLoadConfig jusqu'à la prochaine reconstruction. `label` entre dans la signature et
dans le prédicat soft de l'arbitre, sans entrer dans sameHardware() — mise à jour EN
PLACE, pas reconstruction. La vraie prise était le Q_UNUSED de SgReadyAdapter, hérité
d'avant le lot B-bis : sans lui, renommer la PAC — le cas même qu'on allait tester —
n'aurait rien changé, et on aurait conclu que le correctif ne marchait pas.

§3 — relays[] n'est plus comparé par index. Un réordonnancement reconstruisait la
charge : contacts ouverts, verrous réarmés à froid. Comparer comme un ENSEMBLE aurait
été faux dans l'autre sens — à somme égale la table retient la première combinaison
rencontrée, donc échanger deux relais de même puissance change le contact qui sert ce
palier. sameHardware() compare la TABLE DÉRIVÉE : deux listes qui produisent les mêmes
paliers avec les mêmes contacts sont matériellement identiques, par construction.

§4 — mechanism.stagesW publie les paliers atteignables, en télémétrie et pas dans
GetLoadConfig. La télémétrie n'a aucun chemin d'écriture, donc « lecture seule » y est
vrai par construction ; GetLoadConfig aurait cassé l'aller-retour neutre dont l'app
dépend pour écrire — elle renvoie verbatim ce qu'elle a lu, le champ serait revenu
dans SetLoadConfig, et un refus en bloc aurait emporté toutes les charges.

deriveStages() est l'implémentation unique de la combinatoire, partagée par les trois
usages. C'était le fond de la demande du §4 : une seule règle, pas de divergence.

tests/auto/loadmodel : cible neuve sans serveur nymea, 2 ms. Elle a attrapé un défaut
de la correction elle-même avant le banc — operator== comparait les listes de ThingIds
et voyait une différence entre fermer {K1,K2} et fermer {K2,K1}.

tools/repair_energylogs.py : §1, et ce n'est PAS un correctif de ce plugin. La cause
est extérieure et déjà corrigée (SunSpec float32 aux mots permutés, d08cf50 daté
05:30:14 UTC — la seconde du premier échantillon empoisonné). La séquelle, elle, était
définitive : à 7,5e33 kWh le plus petit incrément représentable vaut 1,67e18, donc le
compteur n'était pas faux mais GELÉ, et rechargé tel quel à chaque démarrage.
Appliqué sur .75 : 62 726 anomalies → 0, accumulation reprise et vérifiée en direct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-26 10:07:22 +02:00

198 lines
11 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QDateTime>
#include <QVariantMap>
#include "../types/loadaction.h"
#include "../types/loaddescriptor.h"
#include "../types/surpluscontext.h"
/*!
* \brief Vue runtime minimale exposée par un adaptateur à l'arbitre.
*/
struct LoadTelemetry {
double currentPowerW = 0; //!< Puissance mesurée (W).
bool available = true; //!< Faux si l'appareil nymea est absent ou en erreur.
QDateTime lastActionAt; //!< Dernier instant où applyAction() a produit un effet.
};
/*!
* \brief Vue runtime d'une charge destinée à la FRONTIÈRE DE TÉLÉMÉTRIE (RPC), et à elle seule.
*
* Distincte de \c LoadTelemetry (que l'arbitre lit pour DÉCIDER) et de \c LoadContext (ce que
* le scheduler reçoit). Elle porte ce qui n'a de sens que pour EXPLIQUER une décision à un
* humain : le défaut, le verrou en secondes restantes, et l'état du mécanisme.
*
* \par Des codes, jamais des phrases
* \c faultCode, \c lockKind et \c mechanismKind sont des CODES d'énumération fermée. La
* phrase est fabriquée par le client, dans la langue de son utilisateur — même raison que
* pour \c DecisionReason (cf. \c decisionreason.h).
*
* \par Des secondes, pas des watts
* \c lockMinPowerW / \c lockMaxPowerW (dans \c LoadContextTelemetry) sont un artefact du
* mécanisme d'allocation : illisibles pour un utilisateur. Le temps restant, lui, se lit.
* Ceci n'enfreint pas ECS-306 : cette exigence interdit qu'un index de PALIER franchisse la
* frontière d'ALLOCATION. Une valeur d'AFFICHAGE sortante est un autre chemin, et
* \c stageW / \c state sont des watts et des états normés, pas des index.
*
* \par Union discriminée par mécanisme
* \c mechanismKind discrimine ; \c mechanism porte la charge utile de la variante, et
* d'elle seule (LM-300). Ajouter un mécanisme = une variante ici et son test — rien d'autre.
*/
struct LoadRuntimeView {
//! Code de défaut ; VIDE = aucun défaut. Énumération fermée, cf. \c INTERFACE.md.
QString faultCode;
//! Verrou ACTIF maintenant : "minOn" | "minOff" | "minStateHold" ; vide = aucun.
QString lockKind;
//! Secondes restantes du verrou nommé par \c lockKind ; sans objet si \c lockKind est vide.
int lockRemainingS = 0;
//! Mécanisme : "relay" | "variable" | "sgReady" | "evcharger".
QString mechanismKind;
//! Charge utile du mécanisme (clés propres à la variante) ; jamais celle d'un autre.
QVariantMap mechanism;
};
/*!
* \brief Interface pure des adaptateurs de charge.
*
* Les implémentations concrètes héritent de QObject + ILoadAdapter et déclarent leurs
* propres signaux (telemetryChanged, descriptorChanged).
*
* \invariant Les adaptateurs EXÉCUTENT, ils ne décident pas (AGENTS règle 2).
* \invariant applyAction() écrête les valeurs selon les limites matérielles réelles
* (second filet après l'écrêtage de l'arbitre).
* \invariant applyAction() avec un \c reason.code vide doit être rejetée silencieusement.
* \invariant Les méthodes non-applyAction() retournent immédiatement (pas de I/O bloquant).
* \invariant **Temps = paramètre, jamais l'horloge.** Toute logique temporelle d'un
* adaptateur (verrous minOn/minOff, fenêtres, fraîcheur…) utilise EXCLUSIVEMENT le
* \c now (= \c ctx.timestamp) reçu en paramètre de \c toLoadContext()/applyAction().
* JAMAIS \c QDateTime::currentDateTime(). C'est cette source unique, partagée avec le
* scheduler, qui rend impossible toute divergence décision/exécution et qui rend la
* logique injectable en simulation. Contrat pour tout futur adaptateur (SgReady, Battery).
*/
class ILoadAdapter {
public:
virtual ~ILoadAdapter() = default;
/*!
* \brief Description statique de la charge : capacités, limites, priorité, needs.
* \return LoadDescriptor construit depuis la configuration matérielle.
* \note Peut être rappelé à chaque cycle — l'implémentation doit être légère.
*/
virtual LoadDescriptor descriptor() const = 0;
/*!
* \brief Télémétrie runtime (puissance, disponibilité, dernière action).
* \return LoadTelemetry issue de l'état courant de l'appareil nymea.
*/
virtual LoadTelemetry telemetry() const = 0;
/*!
* \brief Construit l'entrée §5 loads[] pour le SurplusContext.
* \param now Temps de cycle (\c ctx.timestamp). Source unique pour l'évaluation des
* verrous (minStage/maxStage) — JAMAIS \c QDateTime::currentDateTime() côté adaptateur,
* afin que décision (scheduler) et exécution (applyAction) partagent le même temps.
* \return LoadContext incluant declared, limits, needs et télémétrie type-spécifique.
*/
virtual LoadContext toLoadContext(const QDateTime &now) const = 0;
/*!
* \brief Applique l'action et retourne ce qui a réellement été envoyé au matériel.
*
* L'arbitre a déjà écrêté selon les limites et le budget — ceci est le second filet.
*
* \param action Action à appliquer. Doit avoir \c reason.code non vide.
* \param now Temps de cycle (\c ctx.timestamp) — MÊME source que toLoadContext(),
* pour que l'évaluation des verrous coïncide avec celle vue par le scheduler.
* \return L'action après écrêtage matériel (peut différer de l'entrée).
* \note Retour silencieux sans effet si \c action.reason.code est vide.
*/
virtual LoadAction applyAction(const LoadAction &action, const QDateTime &now) = 0;
/*!
* \brief Met à jour les champs de configuration qui ne touchent PAS le matériel.
*
* Sert au rebuild INCRÉMENTAL (ECS-412) : quand seul le rang de service ou les besoins
* changent, l'arbitre met à jour l'adaptateur en place au lieu de le détruire. Détruire
* réarmerait les verrous — sur un ballon thermodynamique à \c minOn de 300 à 600 s, un
* client qui réordonne ses priorités depuis l'app pourrait faire court-cycler son
* compresseur — et provoquerait des réécritures de relais inutiles.
*
* \param priority Nouveau rang de service (ASC, 1 = premier servi).
* \param needs Nouveaux besoins déclarés.
* \warning NE DOIT jamais toucher au câblage, aux paliers ni aux verrous : ces
* changements-là exigent une vraie reconstruction.
* \note **Pure virtuelle à dessein**, sans implémentation par défaut. Un adaptateur qui
* n'est pas construit depuis \c LoadConfig n'a rien à mettre à jour, mais il doit le
* DÉCLARER : un défaut vide silencieux ferait qu'un futur adaptateur construit depuis
* la config ignorerait sans bruit les changements de rang.
*/
virtual void updateSoftConfig(const QString &label, int priority,
const LoadNeeds &needs) = 0;
/*!
* \brief Lève le verrou de défaut de la charge (ECS-410).
*
* Le défaut est COLLANT par conception — « cesser toute commande jusqu'à intervention ».
* Sa levée est un acte délibéré de l'opérateur, jamais une expiration : sans quoi
* \c available oscillerait d'un cycle à l'autre et le waterfall réagirait à chaque
* battement.
*
* \note **Pure virtuelle à dessein.** Tous les adaptateurs ne savent pas encore tomber en
* défaut — seul \c RelayRouter implémente l'échelle ECS-410 — mais chacun doit le
* DÉCLARER. La généralisation est portée par **ECS-414**.
*
* \return \c true si un défaut a RÉELLEMENT été levé, \c false s'il n'y en avait pas.
*
* \note **Pourquoi un booléen et non \c void** (règle 7-c d'AGENTS.md). C'est le seul
* levier de reprise à distance : ECS-410 pose un verrou collant que seuls ce RPC ou une
* reconstruction lèvent. Sans valeur de retour, l'arbitre ne peut pas distinguer
* « défaut levé » de « aucun défaut à lever », et un opérateur à distance ne peut pas
* savoir si son geste a agi — il relancera, puis conclura que le système est cassé.
* Le type de retour force chaque implémentation, présente et future, à répondre à la
* question ; une ligne de journal dans chaque adaptateur se serait oubliée à la
* cinquième.
*/
virtual bool clearFault() = 0;
/*!
* \brief Vue runtime destinée à la frontière de télémétrie RPC (\c GetLoadTelemetry).
*
* \param now Temps de cycle — MÊME source que \c toLoadContext()/\c applyAction(), afin
* que le verrou annoncé au client soit celui qui a réellement borné la décision.
* \return Défaut, verrou en secondes restantes, et charge utile du mécanisme.
*
* \note **Pure virtuelle à dessein**, sans implémentation par défaut. Un mécanisme ajouté
* qui hériterait d'un défaut vide sortirait de la télémétrie SANS BRUIT : le client
* verrait une charge sans mécanisme et sans verrou, indistinguable d'une charge libre.
* C'est précisément le silence ambigu de la règle 7-c. Le compilateur pose donc la
* question à chaque nouvel adaptateur.
* \note Cette méthode n'entre JAMAIS dans une décision : l'arbitre ne la lit que pour
* publier. Un adaptateur qui s'en servirait pour décider violerait la règle 2.
*/
virtual LoadRuntimeView runtimeView(const QDateTime &now) const = 0;
/*!
* \brief Amène le matériel dans l'ÉTAT SÛR de cet adaptateur (ECS-413).
*
* Appelé quand la charge est désactivée (\c enabled: false) ou retirée de la
* configuration — un acte DÉLIBÉRÉ de l'opérateur. Sans cela, l'adaptateur est détruit et
* le matériel reste dans son DERNIER ÉTAT COMMANDÉ : constaté au banc le 2026-08-09, trois
* relais laissés fermés juste avant une intervention de câblage.
*
* \param now Temps de cycle. Passe par le chemin d'action normal avec \c force = true,
* comme le mode dégradé L2 : l'arrêt prime sur les verrous.
*
* \warning **NE PAS appeler à l'arrêt du plugin ni au redémarrage de nymead.** L'état doit
* y être CONSERVÉ — c'est ce qu'ECS-411 relit au démarrage, et couper l'eau chaude à
* chaque redémarrage de service serait une régression. La distinction est
* intentionnelle : la désactivation est délibérée, un redémarrage ne l'est pas.
*
* \note L'état sûr est PROPRE à chaque adaptateur — relais ouverts pour un routeur,
* consigne 0 W pour une charge continue, mais **état 2** pour une PAC SG-Ready, jamais
* le blocage (\c docs/SAFETY.md). Une formulation « tout couper » serait fausse.
*/
virtual void applySafeState(const QDateTime &now) = 0;
};