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
179 lines
9.0 KiB
C++
179 lines
9.0 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;
|
|
|
|
class Thing;
|
|
class ThingManager;
|
|
|
|
/*!
|
|
* \brief Adaptateur charge à puissance pilotable — interface \c etmvariableload (contrat
|
|
* INTERFACE_etmvariableload.md). Action \c kind:Setpoint en WATTS.
|
|
*
|
|
* Couvre les charges dont la consigne est une puissance : ECS multipalier, routeur PV /
|
|
* triac, résistance modulable. \b Pas la PAC SG-Ready (modèle à états, \c SgReadyAdapter).
|
|
*
|
|
* \par Frontière (contrat §1 / §5) — non négociable
|
|
* L'energymanager raisonne UNIQUEMENT en watts. \b Aucune résistance, relais, combinaison
|
|
* ni index de palier matériel ne traverse cet adaptateur. La connaissance matérielle (quelle
|
|
* résistance, quel triac, sur quelle phase), l'anti-rebond (minOn/minOff) et l'équilibrage
|
|
* de phases vivent ENTIÈREMENT dans le \b thing qui implémente l'interface. Cet adaptateur
|
|
* se contente d'écrire une consigne \c powerSetpoint (W) et de relire \c currentPowerW.
|
|
*
|
|
* \par States nymea consommés sur le thing cible (\c m_id)
|
|
* - \c currentPowerW (read) : puissance réellement appliquée — juge runtime (contrat §4).
|
|
* - \c powerSetpoint (write) : consigne demandée par l'energymanager.
|
|
* - \c maxPowerW / \c powerLevels (read) : déclarés par le thing pour le wizard ; la
|
|
* planification utilise la DÉCLARATION de config (passée au constructeur, contrat §4),
|
|
* pas ces states — l'adaptateur n'écrase jamais la déclaration.
|
|
*
|
|
* \invariant applyAction() rejette silencieusement toute action dont \c reason est vide.
|
|
* \invariant Seul le kind \c Setpoint est traité ; les autres kinds retournent sans effet.
|
|
* \invariant La consigne est écrêtée à \c [0, maxPowerW] avant envoi matériel (second filet
|
|
* après l'écrêtage de l'arbitre). L'arrondi au \c powerLevels (mode *fixed*) est la
|
|
* responsabilité du SCHEDULER (contrat §4), pas de l'adaptateur.
|
|
* \invariant **Temps = paramètre, jamais l'horloge** (cf. \c ILoadAdapter) : aucune logique
|
|
* temporelle ici (les verrous vivent dans le thing) — \c now sert uniquement aux estampilles.
|
|
*
|
|
* \note T1 (squelette) : l'adaptateur consomme l'interface côté moteur. La déclaration de
|
|
* l'interface nymea \c etmvariableload et le driver thing relèvent d'une session device dédiée.
|
|
*/
|
|
class EtmVariableLoadAdapter : public QObject, public ILoadAdapter
|
|
{
|
|
Q_OBJECT
|
|
public:
|
|
/*!
|
|
* \brief Constructeur.
|
|
* \param thingManager Gestionnaire nymea pour résoudre \p id en Thing.
|
|
* \param id ThingId (string) du thing implémentant \c etmvariableload, et
|
|
* identifiant logique de la charge.
|
|
* \param label Nom lisible (logs, app).
|
|
* \param powerLevels Paliers atteignables en W (DÉCLARÉS, triés croissants, \c 0 inclus)
|
|
* en mode *fixed* ; liste vide ⇒ *dynamic* (modulation 0..maxPowerW).
|
|
* \param maxPowerW Plafond physique (W) — contrat §2.
|
|
* \param priority Rang dans le waterfall (contrat §5 / OPTIMIZER_PROTOCOL §5) : valeur
|
|
* plus BASSE = servi en premier (rang 1 = premier servi).
|
|
* \param needs Besoins déclarés (échéances, énergie min) — exposés au scheduler
|
|
* via \c descriptor().needs. Vide par défaut.
|
|
* \param parent Propriétaire Qt.
|
|
*/
|
|
explicit EtmVariableLoadAdapter(ThingManager *thingManager,
|
|
const QString &id,
|
|
const QString &label,
|
|
const QList<int> &powerLevels,
|
|
int maxPowerW,
|
|
int priority,
|
|
const LoadNeeds &needs = LoadNeeds(),
|
|
QObject *parent = nullptr);
|
|
|
|
/*!
|
|
* \brief Description statique : adapter="etmvariableload", powerLevels, maxPowerW, priority.
|
|
* \return LoadDescriptor déclaratif (référence de planification, contrat §4).
|
|
*/
|
|
LoadDescriptor descriptor() const override;
|
|
|
|
/*!
|
|
* \brief Télémétrie runtime. \c currentPowerW = state \c currentPowerW MESURÉ du thing
|
|
* (juge de quantification, contrat §4) ; \c available faux si le thing est absent.
|
|
* \return LoadTelemetry ; \c currentPowerW vaut 0 si le thing est absent ou n'expose
|
|
* pas l'état \c currentPowerW (jamais de puissance fantôme).
|
|
*/
|
|
LoadTelemetry telemetry() const override;
|
|
|
|
/*!
|
|
* \brief Construit l'entrée loads[] §5 (adapter="etmvariableload").
|
|
* \param now Temps de cycle (\c ctx.timestamp) — estampille uniquement ; aucune fenêtre de
|
|
* verrou côté moteur (l'anti-rebond vit dans le thing, contrat §2).
|
|
* \return LoadContext incluant declared (powerLevels/maxPowerW) et \c currentPowerW.
|
|
*/
|
|
LoadContext toLoadContext(const QDateTime &now) const override;
|
|
|
|
/*!
|
|
* \brief Écrit la consigne \c powerSetpoint (W) sur le thing.
|
|
* \param action LoadAction de kind \c Setpoint ; consigne lue dans \c action.powerW. Autres
|
|
* kinds : retour sans effet.
|
|
* \param now Temps de cycle (\c ctx.timestamp) — estampille \c m_lastActionAt.
|
|
* \return L'action après écrêtage matériel (\c powerW borné à [0, maxPowerW]).
|
|
*
|
|
* \invariant Si \c action.reason est vide → retour sans effet (log warning).
|
|
* \invariant \c action.force == true (repli L2 : \c setPowerSetpoint(0)) est transmis tel
|
|
* quel ; le bypass anti-rebond est honoré par le thing, pas ici.
|
|
*/
|
|
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
|
|
|
|
//! \brief Met à jour rang et besoins sans reconstruire (ECS-412).
|
|
//! \param priority Nouveau rang de service. \param needs Nouveaux besoins.
|
|
void updateSoftConfig(int priority, const LoadNeeds &needs) override
|
|
{ m_priority = priority; m_needs = needs; }
|
|
|
|
//! \brief Sans effet : cet adaptateur n'implémente pas encore l'échelle de défaut ECS-410.
|
|
//! La généralisation est portée par **ECS-414** (lot de mise en configuration
|
|
//! du SgReadyAdapter). Déclaré explicitement plutôt qu'hérité d'un défaut vide.
|
|
//! \brief Lève le verrou de défaut (ECS-414). La consigne réelle est RELUE, pas supposée.
|
|
bool clearFault() override;
|
|
|
|
//! \return Vrai si la charge est en défaut (plus aucune consigne écrite).
|
|
bool faulted() const { return m_faulted; }
|
|
|
|
/*!
|
|
* \brief Vue runtime pour la télémétrie RPC : consigne, pourcentage, défaut.
|
|
* \param now Inutilisé — cet adaptateur n'a AUCUNE fenêtre de verrou côté moteur :
|
|
* l'anti-rebond vit dans le thing. Le client lit donc \c lock absent, ce qui est la
|
|
* vérité, et non un verrou nul qui laisserait croire à une fenêtre expirée.
|
|
* \return \c mechanismKind "variable" ; \c mechanism \c {setpointW, percent, maxPowerW}.
|
|
*/
|
|
LoadRuntimeView runtimeView(const QDateTime &now) const override;
|
|
|
|
//! \brief État sûr : consigne **0 W**, en \c force = true (ECS-413). \param now Temps de cycle.
|
|
void applySafeState(const QDateTime &now) override;
|
|
|
|
/*!
|
|
* \brief Dernière consigne (W) effectivement écrite (avant écrêtage thing).
|
|
* \return Consigne commandée en W, bornée à \c maxPowerW ; 0 tant qu'aucune action
|
|
* n'a été appliquée. C'est le COMMANDÉ, pas le mesuré.
|
|
*/
|
|
double currentSetpointW() const { return m_currentSetpointW; }
|
|
|
|
private:
|
|
//! Vrai si aucun palier déclaré n'est fourni ⇒ modulation continue (contrat §2).
|
|
bool isDynamic() const { return m_powerLevels.isEmpty(); }
|
|
|
|
//! Écrit le state \c powerSetpoint (W) via executeAction (repli \c setStateValue pour mock),
|
|
//! et suit son acquittement (ECS-414, modèle d'ECS-410).
|
|
void writeSetpoint(double powerW);
|
|
//! Verdict quand l'écriture est acquittée ; fait avancer l'échelle d'un cran.
|
|
void settleTransition();
|
|
|
|
//! Barreaux de l'échelle. Plancher de cet adaptateur : consigne 0 W.
|
|
enum Phase { PhaseNominale, PhaseRepli, PhasePlancher, PhaseDefaut };
|
|
|
|
//! Lit le state \c currentPowerW du thing cible (0 si absent / non exposé).
|
|
double readCurrentPowerW() const;
|
|
|
|
ThingManager *m_thingManager;
|
|
QString m_id;
|
|
QString m_label;
|
|
QList<int> m_powerLevels; //!< Paliers W déclarés (vide = dynamic).
|
|
int m_maxPowerW;
|
|
int m_priority;
|
|
LoadNeeds m_needs;
|
|
|
|
double m_currentSetpointW = 0; //!< Dernière consigne écrite (W).
|
|
|
|
// --- ECS-414 : suivi asynchrone (modèle d'ECS-410) ----------------------------------
|
|
int m_pending = 0;
|
|
bool m_writeFailed = false;
|
|
double m_setpointPrev = 0;
|
|
double m_setpointTarget = 0;
|
|
Phase m_phase = PhaseNominale;
|
|
bool m_faulted = false;
|
|
QDateTime m_lastActionAt;
|
|
};
|