Patrick Schurig e392c1c7b8 feat(§11): la mesure d'une charge se lit sur l'appareil, et publie sa source
LM-1105/LM-1106 implantés. measuredW disparaît, sans alias.

ILoadAdapter::deviceMeasurement() — PURE VIRTUELLE, comme runtimeView() et pour
la même raison : un mécanisme ajouté qui hériterait d'un « non mesurable » par
défaut sortirait de la mesure sans bruit, et son écran afficherait « pas
mesurable » avec l'autorité d'un constat. Le compilateur pose la question.

Le type porte une CAPACITÉ, pas une valeur nulle : `available` répond à « cette
charge est-elle mesurable », question posée à la ThingClass, jamais à la valeur
du moment. Un routeur au palier 0 mesure 0 W et reste mesurable ; sans ce
booléen les deux seraient indistinguables.

attachMeasurements() résout meter → device → none. Le compteur explicite garde la
priorité, et pas d'abord parce qu'il mesure mieux : un réglage sans effet est
pire que pas de réglage. La priorité porte sur la SOURCE, pas sur la valeur —
compteur déclaré injoignable, on retombe sur l'appareil, parce que se taire
perdrait une mesure qu'on a et que le silence se lirait « non mesurable ».

Sous `none`, powerW est OMIS. La puissance nominale reste lisible dans
mechanism.*, qui dit d'où elle vient ; la faire passer pour une mesure produirait
un écart commandé↔mesuré identiquement nul à tous les cycles — un « tout
concorde » permanent, corollaire LM-104.

DEUX AFFINEMENTS IMPOSÉS PAR L'IMPLANTATION, tous deux de modèle :

LM-1105-a — une mesure PARTIELLE n'est pas une mesure. Là où plusieurs Things
portent la charge, il faut qu'ils la portent TOUS : trois relais dont un seul
mesure annonceraient 500 W là où 1 500 W coulent, et l'écart enverrait chercher
une panne inexistante. À ne pas confondre avec LoadTelemetry::currentPowerW, qui
accepte « au moins un » et retombe sur le nominal — celui-là est le juge runtime
du budget et doit rendre un nombre à chaque cycle. Les fusionner rouvrirait la
correction B.

LM-1105-b — la règle présuppose que le Thing piloté est TRAVERSÉ par la
puissance. Un relais de relay-router commute la charge, donc sa puissance EST
celle de la charge ; un contact SG-Ready est un contact de SIGNAL — la PAC est
alimentée ailleurs et un contact qui mesurerait quelque chose mesurerait sa
propre bobine. sg-ready est donc `none` par CONSTRUCTION, même sur des relais qui
mesurent — c'est le cas du banc — et c'est le seul mécanisme pour lequel
meterThingId reste la seule voie.

RelayRouter : la capacité se juge sur la ThingClass de TOUS les relais déclarés,
la valeur se lit sur les relais FERMÉS. Jugée sur les relais actifs, la source
vaudrait `device` au palier 1 et `none` au palier 0 — un bloc de mesure qui
apparaît et disparaît au rythme du thermostat. VÉRIFIÉ ÉCHOUANT ainsi.

testMeasurementSourceIsPublished : les trois sources sans une ligne de config de
compteur, le palier 0 qui ne fait pas basculer la source, la PAC en `none` alors
que ses contacts mesurent, la disparition de measuredW, puis les deux règles de
priorité — le compteur explicite l'emporte (1 234 contre 2 900), un compteur
injoignable fait retomber sur l'appareil.

Ne touche PAS ce que l'arbitre lit : LM-1101 tient, LoadTelemetry::currentPowerW
est inchangé, et le recrédit anti-clignotement n'a jamais transité par
meterThingId.

amd64 0 erreur / 0 avertissement. Suites, un processus par test : simulation
30/30, charging 16/16, loadmodel 18/18, spotmarket 7/7.

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

198 lines
10 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 minPowerW Plancher de modulation (W) — §12 / LM-1201. `0` = aucun plancher.
* Sous cette valeur la charge ne prend RIEN : le domaine de la
* consigne est `{0} ∪ [minPowerW, maxPowerW]`. **Déclaré**, parce
* qu'aucune donnée du thing ne permet de le deviner.
* \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 minPowerW,
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 libellé, rang et besoins sans reconstruire (ECS-412).
//! \param label Nouveau libellé — il part au journal DÈS ce cycle.
//! \param priority Nouveau rang de service. \param needs Nouveaux besoins.
void updateSoftConfig(const QString &label, int priority, const LoadNeeds &needs) override
{ m_label = label; 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 Mesure propre de la charge pilotée (§11 / LM-1105).
* \return La puissance que le Thing rapporte lui-même, quand sa ThingClass porte
* \c currentPowerW ; « non mesurable » sinon.
* \note **Ne retombe PAS sur la consigne en vol**, contrairement à \c telemetry(). Celle-ci
* doit rendre un nombre à chaque cycle — le recrédit anti-clignotement en dépend — et
* annonce donc la plus haute consigne possible quand rien ne mesure (ECS-414). Ici, une
* consigne n'est pas une mesure : la publier comme telle produirait un écart
* commandé↔mesuré identiquement nul (LM-1106).
*/
LoadMeasurement deviceMeasurement() 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_minPowerW; //!< Plancher de modulation (W) ; 0 = aucun (§12).
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;
};