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
253 lines
14 KiB
C++
253 lines
14 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 Mesure PROPRE de l'appareil piloté — §11, LM-1105/LM-1106.
|
|
*
|
|
* Distincte de \c LoadTelemetry::currentPowerW, et la distinction est tout l'objet du type.
|
|
* Ce champ-là est le **juge runtime du budget** : il vaut une mesure quand il en existe une et
|
|
* la puissance **commandée ou déclarée** sinon, parce que le recrédit anti-clignotement a
|
|
* besoin d'un nombre à chaque cycle. Celui-ci ne vaut **que** ce qu'un appareil a réellement
|
|
* mesuré, ou rien.
|
|
*
|
|
* \par Pourquoi une CAPACITÉ et non une valeur nulle
|
|
* \c available répond à « cette charge est-elle mesurable ? », question posée à la
|
|
* \c 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 et la source publiée
|
|
* basculerait d'un cycle à l'autre sur une installation dont rien n'a changé.
|
|
*/
|
|
struct LoadMeasurement {
|
|
//! Vrai si les Things pilotés portent l'état de puissance — CAPACITÉ, pas valeur.
|
|
bool available = false;
|
|
//! Puissance réellement mesurée (W) ; sans objet quand \c available est faux.
|
|
double powerW = 0;
|
|
};
|
|
|
|
/*!
|
|
* \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 Cette charge est-elle mesurée par les appareils qu'elle pilote, et combien ?
|
|
*
|
|
* §11 — **LM-1105** : le moteur lit l'appareil qu'il pilote plutôt que d'exiger qu'on le
|
|
* désigne comme son propre compteur dans \c meterThingId. **LM-1106** : ce qui est publié
|
|
* est un couple {valeur, source}, et c'est cette méthode qui répond pour la source
|
|
* \c "device".
|
|
*
|
|
* \return \c available faux = **rien ne mesure cette charge** (\c source: "none" à la
|
|
* publication) ; vrai = \c powerW est une mesure réelle.
|
|
*
|
|
* \note **La question porte sur la ThingClass, jamais sur la valeur ni sur l'instant.** Un
|
|
* \c RelayRouter au palier 0 n'a aucun relais fermé : interroger les relais ACTIFS
|
|
* ferait osciller la source publiée d'un cycle à l'autre. Elle se pose sur TOUS les
|
|
* Things déclarés de la charge.
|
|
* \note **Et elle présuppose que le Thing piloté est TRAVERSÉ par la puissance.** Un relais
|
|
* de \c RelayRouter 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 son
|
|
* courant ne passe jamais par lui. Un contact qui mesurerait quelque chose mesurerait sa
|
|
* propre bobine. \c SgReadyAdapter répond donc toujours « non mesurable », et c'est le
|
|
* seul mécanisme pour lequel \c meterThingId reste la seule voie.
|
|
* \note **Une mesure PARTIELLE n'est pas une mesure.** Là où plusieurs Things portent la
|
|
* charge, il faut qu'ils la portent TOUS : sommer les seuls relais mesurants
|
|
* publierait une puissance sous-évaluée, et l'écart commandé↔mesuré enverrait chercher
|
|
* une panne qui n'existe pas (LM-1102).
|
|
* \note **Pure virtuelle à dessein**, comme \c runtimeView() et pour la même raison : un
|
|
* mécanisme ajouté qui hériterait d'un défaut « non mesurable » sortirait de la mesure
|
|
* SANS BRUIT, et son écran afficherait « pas mesurable » avec l'autorité d'un constat.
|
|
* Le compilateur pose la question à chaque nouvel adaptateur.
|
|
* \note N'entre JAMAIS dans une décision : l'arbitre ne la lit que pour publier (règle 2).
|
|
*/
|
|
virtual LoadMeasurement deviceMeasurement() 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;
|
|
};
|