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
198 lines
10 KiB
C++
198 lines
10 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 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;
|
||
};
|