Le recrédit rendait au budget la conso d'une charge sur l'hypothèse qu'on pourra la lui reprendre. Contre un refus établi, les watts prêtés étaient réalloués aux suivantes pendant que la charge continuait de les tirer : 4 000 W alloués pour 500 W de surplus réel. Suspension faite, la charge suivante ne reçoit plus que les 500 W qui existent. Le seuil vient du MÉCANISME, jamais d'une constante. Un écart récent est une latence — une charge qui vient d'être commandée n'a pas atteint sa consigne, et suspendre là ferait osciller ce qui va bien. Latences mesurées sur la V2C : montée ~30 s, descente ~7 s. L'asymétrie joue dans le bon sens, le refus qui coûte de l'argent étant celui de la descente. Défaut prudent à 60 s : l'oscillation d'une charge saine est un coût certain, le refus n'est qu'un risque. Et ça se dit — RECREDIT_SUSPENDED, avec depuis quand et combien de watts retenus. Une suspension change l'allocation de toutes les charges suivantes ; sans le motif, on chercherait un défaut de cascade là où une charge n'obéit pas. Et une cinquième classe dans AGENTS.md, la seule qu'aucun contrôle ne pouvait attraper : une PRÉMISSE FAUSSE SOUS UNE ARITHMÉTIQUE EXACTE. Les quatre autres se laissent prendre par un instrument — un champ qui ment, un retour indiscernable de son contraire, un instrument muet, une assertion vacante. Celle-ci n'a aucune trace : l'identité publiée tient exactement, que la charge obéisse ou non. La parade tient en une phrase : une identité qui tient ne prouve pas que le modèle est juste, seulement qu'il est cohérent. Ce qu'une identité ne peut jamais attraper, c'est une hypothèse sur le monde. D'où la question à poser à chaque identité publiée : sur quoi repose-t-elle qui ne soit pas dans ses propres termes ? Ici, sur l'obéissance — grandeur absente de la formule, qu'il a fallu aller mesurer ailleurs pour la rendre vérifiable. Suite : simulation 60/60, loadmodel 22/22, charging 48/48 identique à la référence, spotmarket 32/32, doxygen 0 avertissement. Contre-épreuve : suspension débranchée, le recrédit revient à 4 400 W. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
281 lines
16 KiB
C++
281 lines
16 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 label Nouveau libellé d'affichage.
|
|
* \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 Temps au-delà duquel un écart commandé/mesuré n'est PLUS une latence.
|
|
*
|
|
* \param montee Vrai si la consigne AUGMENTE, faux si elle diminue.
|
|
* \return Durée d'établissement du mécanisme, en secondes.
|
|
*
|
|
* \par Pourquoi une valeur PAR MÉCANISME, et pas une constante
|
|
* L'anti-clignotement protège contre la latence : une charge qui vient d'être commandée n'a
|
|
* pas encore atteint sa consigne, et suspendre le recrédit là ferait osciller une charge
|
|
* saine. Mais un écart qui **persiste** au-delà de cette durée n'est plus une latence,
|
|
* **c'est un refus** — et prêter contre un refus établi, c'est importer des kilowatts que
|
|
* personne n'a décidés.
|
|
*
|
|
* Un seuil unique serait **trop court pour une borne et trop long pour un relais** : mesuré
|
|
* sur la V2C, la montée prend ~30 s et la descente ~7 s. Le seuil se raisonne donc contre la
|
|
* latence connue du mécanisme, jamais contre un nombre choisi une fois pour toutes.
|
|
*
|
|
* \note L'ASYMÉTRIE est utile dans le bon sens : le cas dangereux — commander la baisse et
|
|
* ne pas être suivi — se juge sur la latence de DESCENTE, la plus courte. Le refus qui
|
|
* coûte de l'argent se constate donc plus vite que celui qui n'en coûte pas.
|
|
*
|
|
* \note Défaut prudent : 60 s dans les deux sens. Un mécanisme qui ne se déclare pas ne doit
|
|
* pas voir son recrédit suspendu trop tôt — l'oscillation d'une charge saine est un coût
|
|
* certain, le refus n'est qu'un risque.
|
|
*/
|
|
virtual int commandSettleS(bool montee) const { Q_UNUSED(montee) return 60; }
|
|
|
|
/*!
|
|
* \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;
|
|
};
|