Patrick Schurig 1ec89bf030 feat(telemetry): lot B — frontière de télémétrie d'arbitrage + o:domain — 1.15.2+etm14
Constat d'origine, rejoué et daté au banc : 90 s d'écoute, arbitre actif, ~2 kW exportés
→ ZÉRO notification NymeaEnergy.*. L'état runtime de l'arbitre (allocation, budget, verrous,
paliers, disponibilité, motifs) ne franchissait aucune frontière RPC : il vivait exclusivement
dans qCInfo(dcNymeaEnergy). Expliquer les décisions au client n'avait donc aucun transport.
Après déploiement, même geste : 13 notifications en 480 s.

Aucune phrase composée par la box. La locale nymea est PAR CONNEXION (JSONRPC.Hello o:locale),
et NymeaEnergyJsonHandler est un JsonHandler sans pluginId — donc hors du catalogue de
traduction : rien ne rattraperait une phrase française émise là. La box transporte un CODE et
des PARAMÈTRES ; la phrase est fabriquée par le client.

LoadAction::reason devient DecisionReason{code, params}. La ligne française du journal est
RENDUE depuis ce couple par renderFr(), source unique — jamais maintenue en parallèle. Les 19
codes sont EXTRAITS des sites existants, pas inventés ; les motifs d'ECS-309 et ECS-309-b sont
repris mot pour mot. Prouvé par comparaison de traces (1726 lignes de journal identiques en
simulation), par une table d'or qui échoue au premier code sans phrase attendue, et au banc sur
7 motifs distincts. Le libellé de la charge n'entre pas dans la charge utile : il est substitué
au RENDU, par l'appelant qui le connaît.

Les verrous sortent en SECONDES RESTANTES, pas en watts. 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. Le mécanisme sort en union discriminée
(relay|variable|sgReady), toutes clés internes optionnelles au schéma (LM-302).

ILoadAdapter::runtimeView() est PURE VIRTUELLE à dessein — mais la pure virtuelle force à
RÉPONDRE, pas à répondre quelque chose. testRuntimeViewIsSubstantial() est le second verrou :
les quatre adaptateurs, amenés dans un état non trivial, doivent rendre les valeurs runtime
réelles, les verrous en secondes ET leur expiration, et les codes de défaut. Deux mutants
« vue vide » introduits puis retirés : le test échoue sur chacun.

budget publié : surplusW, evReservedW, recreditedW, allocatedW, remainingW, avec l'identité
surplus + recrédit − alloué == restant. recreditedW n'est pas décoratif : sans lui l'identité
serait fausse DÈS QU'UNE CHARGE CONSOMME, et le client chercherait un écart inexplicable. Le
banc l'a montré aussitôt — surplusW −250 W avec recreditedW 3000 W — rendant lisible l'artefact
de banc documenté dans AGENTS.md (relais sans mesure + rootmeter aveugle).

Champs omis, jamais nuls : timestamp absent = aucun cycle depuis le démarrage ; budget absent =
mode dégradé, planification suspendue. Publier un zéro se lirait « exécuté, rien à allouer ».

Cadence : changement qualitatif ou variation d'allocation > 50 W, plus un battement de 60 s.
C'est le battement qui rend un arbitre FIGÉ détectable — les notifications continuent tandis que
le timestamp cesse d'avancer. Observé 3 fois au banc. Volume mesuré : ≈ 1,7 Mo/jour, un client.

GetChargingSchedules rend enfin degradedMode : il était POUSSÉ en notification seule, donc
illisible par un client connecté après la bascule (famille ECS-411). Règles absolues 11 et 12
ajoutées à AGENTS.md.

LoadConfig : champ optionnel domain (ecs·ev·heating·hvac·battery), PURE métadonnée d'intention
que l'arbitre ne lit pas. Absente ou vide = non classée. Forme exacte découverte au banc et
corrigée dans la doc ET le test : GetLoadConfig sérialise via le méta-objet, donc la propriété
sort TOUJOURS, à "" — comme sgReady:{} ou powerLevels:[]. C'est le corollaire LM-302.

Règle 7-c, deux silences fermés : les retours idempotents du RelayRouter et du SgReadyAdapter
étaient MUETS — « consigne reçue, palier déjà bon » ne se distinguait pas de « l'action n'est
jamais parvenue à l'adaptateur », et c'est le cas ordinaire qu'on vient vérifier.

⚠️ Porté au contrat : loads[] n'est PAS en correspondance 1:1 avec GetLoadConfig. « pac-terrain »
est la PAC SG-Ready CODÉE EN DUR d'energypluginnymea.cpp (K1/K2 = Relay4/Relay5, {3:1500,
4:3000}) — arbitrée et publiée, mais inconfigurable : ni rang, ni domain, ni enabled:false.
INTERFACE.md porte la règle de détection pour l'app. Son sort reste À TRANCHER (RELEVE_LOTB §8-bis b).

Déploiement : la recette de TEST_TERRAIN.md §b portait un glob « dpkg -i /tmp/…_*_arm64.deb »,
de la même famille que celui qui a rétrogradé .75 de +etm12 à +etm9. Remplacée par
etm-powersync-deploy/deploy.sh (archivage avant dépôt, installation par nom, refus de
rétrogradation, contrôle de version après coup).

Vérifié sur .75 (1.15.2+etm13 puis +etm14) : critères 1, 2, 4, 5, 6, 7, 8 du brief. Critère 3
(épisode dégradé) vérifié en simulation seulement — le déclencher demande de faire taire un
simulateur SunSpec partagé, hors périmètre. Relevé complet, critère par critère, avec ce qui n'a
PAS été vérifié sur la machine et pourquoi : docs/RELEVE_LOTB.md.

Suites : simulation 38/38, charging 46/46, spotmarket 32/32, y compris en build DEBUG où les
Q_ASSERT_X du validateur JSON-RPC sont actifs (le schéma déclaré correspond donc à la charge
utile émise, retours ET notifications). Cross-arm64 : 0 avertissement. Doxygen : 10, inchangé.

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

179 lines
9.1 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 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 É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.
};