Patrick Schurig a510202ac7 fix: la contradiction progressMeasurable/regime, et la garde qui la rend sûre
Le premier dit ce que le MATÉRIEL permet, le second ce que le MOTEUR fait.
Seul l'EvAdapter remplissait sessionMeasurable, donc sur toute charge non-VE
le premier disait oui et le second non — et l'avertissement de saisie de
l'app, qui se déclenche sur progressMeasurable, restait muet exactement sur
les charges achetées à l'aveugle. sessionWh est alimenté depuis le compteur
désigné.

L'avertissement de l'app est arrivé AVANT que j'écrive la garde, et il était
décisif. Un compteur qui disparaît peut publier 0 au lieu de se taire : la
V2C a forgé un zéro sur currentPower et ses booléens, mais laissé
sessionEnergy à sa dernière valeur. La politique dépend du plugin — donc
inutilisable comme garde.

Et le point à vérifier avait une réponse nette : il n'existe AUCUN signal de
joignabilité fiable. setupComplete parle du setup et non du lien ; connected
est maintenu par le plugin, la V2C basculant après trois échecs quand un
compteur générique peut ne jamais basculer. La garde ne pouvait pas reposer
là-dessus.

Elle porte donc sur la SÉMANTIQUE du compteur, seule chose que le moteur
sache sans parier : sur un compteur de session un recul est une session
neuve, sur un compteur de vie c'est une anomalie — on gèle, on ne recapture
jamais. Sans elle, le scénario chiffré sort tel quel : base reprise à 0
pendant l'absence, retour avec 10 kWh réels, deliveredWh à 10 000 en un
cycle, obligation déclarée tenue sans rien de livré. Et R6 ne l'attrape pas :
elle n'interdit ECO_FLOOR_MET que sous unmeasurable, alors que ce chemin est
MESURÉ — c'est le seul par lequel un faux « tenue » peut sortir, et c'est
celui qu'ouvrait l'alimentation depuis un compteur.

audit-controles.py dit maintenant en tête qu'il ne tranche pas et ne doit
jamais trancher : neuf des dix identités relevées contrôlaient réellement, et
un verdict automatique aurait supprimé neuf contrôles utiles.

AGENTS.md : la diversité du mock n'est pas un confort de test, c'est une
borne supérieure sur ce que la suite peut prouver — elle se fixe en écrivant
les classes, pas les tests.

Quatrième occurrence du piège d'insertion doxygen, rattrapée par le même
contrôle. Comme annoncé.

Suite : simulation 63/63, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-03 07:21:13 +02:00

847 lines
49 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 "../smartchargingmanager.h"
#include "scheduler/ischeduler.h"
#include "types/surpluscontext.h"
#include "types/plan.h"
// Inclus (et non déclaré en avant) : m_builtFrom stocke des LoadConfig PAR VALEUR — c'est ce
// qui permet au rebuild incrémental (ECS-412) de comparer l'ancienne et la nouvelle config.
#include "types/loadconfig.h"
#include "types/drawcap.h"
#include "types/ecoprogress.h"
#include "types/ecotarget.h"
#include <QDateTime>
#include <QVariantMap>
class QTimer;
class EvAdapter;
class SgReadyAdapter;
class EtmVariableLoadAdapter;
class RelayRouter;
class ILoadAdapter;
class LoadConfigStore;
class RuleBasedScheduler;
/*!
* \brief Arbitre central ETM — remplace SmartChargingManager::update() (ETM_ARBITRATOR).
*
* Hérite de SmartChargingManager pour conserver la compatibilité API complète avec
* NymeaEnergyJsonHandler sans modifier le code amont.
* Seul update() est surchargé : préparation → sécurité → planificateur → adapters.
*
* \invariant UN seul arbitre : EnergyArbitrator décide, les EvAdapter exécutent (règle 1).
* \invariant verifyOverloadProtection() est toujours appelée avant la planification (règle 4).
* \invariant Toute LoadAction transmise aux adapters a un \c reason non vide (règle 7).
* \invariant L'absence du root meter n'empêche pas le démarrage — cycle ignoré silencieusement.
*/
class EnergyArbitrator : public SmartChargingManager
{
Q_OBJECT
public:
/*!
* \brief Construit l'arbitre central. Mêmes dépendances que \c SmartChargingManager amont,
* dont il hérite (justification : AGENTS.md, « 3b-iii »).
* \param energyManager Gestionnaire d'énergie nymea (compteur racine, notifications).
* \param thingManager Registre des Things — résolution des appareils pilotés/mesurés.
* \param spotMarketManager Fournisseur de tarifs dynamiques (proxy EV).
* \param configuration Réglages persistés (limites de phase, tolérance, réserve).
* \param parent Parent QObject.
*/
explicit EnergyArbitrator(EnergyManager *energyManager, ThingManager *thingManager,
SpotMarketManager *spotMarketManager,
EnergyManagerConfiguration *configuration,
QObject *parent = nullptr);
/*!
* \brief Déclenche planSurplusCharging() (protégée) — appelé par RuleBasedScheduler.
* \param now Instant courant du cycle.
*/
void runSurplusPlanning(const QDateTime &now);
/*!
* \brief Déclenche planSpotMarketCharging() (protégée) — appelé par RuleBasedScheduler.
* \param now Instant courant du cycle.
*/
void runSpotMarketPlanning(const QDateTime &now);
/*!
* \brief Actions planifiées (résultat de runSurplus/SpotMarket).
* \return Référence constante vers la table EvCharger* → ChargingActions.
* \note Valide seulement après runSurplusPlanning() / runSpotMarketPlanning().
*/
const QHash<EvCharger *, ChargingActions> &scheduledActions() const;
/*!
* \brief Pont d'exécution pour EvAdapter — délègue à executeChargingAction() protégée.
* \param charger Borne EV cible.
* \param action ChargingAction à appliquer.
* \param now Instant de l'action (pour les locks anti-rebond).
*/
void doExecuteChargingAction(EvCharger *charger, const ChargingAction &action, const QDateTime &now);
/*!
* \brief Liste des EvCharger enregistrés (lecture seule).
* \return Table ThingId → EvCharger*.
*/
const QHash<ThingId, EvCharger *> &registeredEvChargers() const;
/*!
* \brief Root meter courant.
* \return Pointeur ou nullptr si aucun compteur principal n'est enregistré.
*/
RootMeter *registeredRootMeter() const;
/*!
* \brief Enregistre un EtmVariableLoadAdapter (ECS/routeur, interface \c etmvariableload)
* pour inclusion dans le contexte et le dispatch \c Setpoint (W).
* \param adapter Adaptateur à enregistrer ; son \c descriptor().id doit être unique.
* Adopté comme enfant Qt de l'arbitre. Appelé par le test (setup) ou — en T4 — la
* construction depuis \c LoadConfig.
* \note Le dispatch distingue un \c Setpoint etmvariableload d'un \c Setpoint EV par le
* \c loadId : seul l'EV n'est PAS dans \c m_loadAdapters (proxy amont jusqu'à 3g).
*/
void registerEtmVariableLoadAdapter(EtmVariableLoadAdapter *adapter);
/*!
* \brief Enregistre un RelayRouter (ECS multipalier, rév. 3) dans la même table d'adaptateurs.
* \param adapter Routeur à enregistrer ; \c descriptor().id unique. Adopté enfant Qt.
* Appelé par le test (setup) ou — en prod — la construction depuis \c LoadConfig (relays[]).
*/
void registerRelayRouter(RelayRouter *adapter);
/*!
* \brief Branche le store de config charge pilotée : construit les adaptateurs
* \c etmvariableload depuis la config et les reconstruit à chaque \c changed().
*
* Les charges \c enabled==false ne sont PAS construites (exclues de l'arbitrage,
* contrat §9). Depuis le lot B-bis, **toutes** les charges arbitrées passent par ici — y
* compris la PAC SG-Ready, qui n'a plus d'exemplaire codé en dur.
* \param store Store persistant (propriété de l'appelant ; non adopté).
*/
void setLoadConfigStore(LoadConfigStore *store);
/*!
* \brief Lève le verrou de défaut d'une charge pilotée (ECS-410).
* \param loadId Identifiant logique de la charge.
* \return Vrai si la charge existe ; faux si l'identifiant est inconnu.
* \note Acte délibéré de l'opérateur, exposé par \c NymeaEnergy.ClearLoadFault. Le défaut
* ne se lève jamais seul : c'est ce qui empêche \c available de clignoter.
*/
bool clearLoadFault(const QString &loadId);
/*!
* \brief Mode dégradé L2 actif (compteur muet > 90 s) — override de SmartChargingManager.
* \return \c true tant que les consignes de repli L2 tiennent ; \c false en régime normal.
* \note Exposé dans la notification \c NymeaEnergy.ChargingSchedulesChanged (champ
* \c degradedMode), émise aussi aux transitions de ce flag.
*/
bool degradedMode() const override { return m_degradedMode; }
/*!
* \brief Enregistre une mesure fraîche du compteur à l'instant \p now (logique L2).
*
* Met à jour \c m_lastMeterUpdate et, si le mode dégradé était actif, en sort
* (\c degradedMode=false + notification). \p now = temps de cycle.
* \note Logique injectable (temps en paramètre) — en production appelée par le
* handler \c powerBalanceChanged ; en simulation/test appelée directement. Le
* déclencheur réel (signal) est câblé sous \c \#ifndef ENERGY_SIMULATION.
* \param now Temps de cycle — jamais l'horloge, pour que la logique reste injectable.
*/
void recordMeterUpdate(const QDateTime &now);
/*!
* \brief Évalue la fraîcheur du compteur à \p now et bascule en mode dégradé si muet >90 s.
*
* Si \c now − \c m_lastMeterUpdate > 90 s et pas déjà dégradé → \c applyDegradedMode().
* Appliqué à la TRANSITION uniquement (idempotent ensuite). \p now = temps de cycle.
* \param now Temps de cycle — jamais l'horloge, pour que la logique reste injectable.
* \note Logique injectable — en production appelée par \c onMeterWatchdogTick() (QTimer
* horloge murale, indépendant car le compteur muet fige aussi \c update()) ; en
* simulation/test appelée directement avec le temps simulé. Symétrique de
* \c simulationCallUpdate : déclencheur réel en prod, logique testable par injection.
*/
void evaluateMeterFreshness(const QDateTime &now);
/*!
* \brief Dernier instantané de télémétrie d'arbitrage — CE QUI A ÉTÉ PUBLIÉ, pas un recalcul.
*
* \return La charge utile de \c NymeaEnergy.GetLoadTelemetry : \c timestamp (fin du dernier
* cycle d'arbitrage), \c degradedMode, \c budget, \c loads[]. Vide tant qu'aucun cycle
* n'a eu lieu (démarrage) — voir la note ci-dessous.
*
* \note **Snapshot ≡ notification, par construction.** C'est le MÊME objet que celui émis
* par \c loadTelemetryChanged() ; il n'est pas recalculé à la demande. Un recalcul
* donnerait des verrous rafraîchis sous un \c timestamp périmé — un état qui n'a jamais
* existé, et que le client ne pourrait rapprocher d'aucune notification.
* \note **Vide avant le premier cycle.** Un instantané forgé au démarrage (budget nul,
* aucune charge) se lirait comme « cycle exécuté, rien à allouer » — exactement le
* mensonge que \c timestamp existe pour empêcher. La carte vide est la seule réponse
* vraie ; le handler la rend telle quelle, sans \c timestamp (règle « omis, jamais nul »).
*/
QVariantMap loadTelemetry() const { return m_telemetry; }
/*!
* \brief Battement de cœur : republie l'instantané COURANT sans condition ni recalcul.
*
* C'est ce qui rend un arbitre FIGÉ détectable : les notifications continuent d'arriver
* tandis que leur \c timestamp cesse d'avancer. Sans battement, « installation stable » et
* « moteur arrêté » se ressemblent — un silence, donc un silence ambigu (règle 7-c).
*
* \note Logique injectable (symétrique de \c evaluateMeterFreshness) : en production
* appelée par un \c QTimer d'horloge murale câblé sous \c \#ifndef ENERGY_SIMULATION ;
* en simulation/test appelée directement. Le \c QTimer ne peut pas dépendre du cycle
* d'arbitrage : c'est précisément l'arrêt de ce cycle qu'il sert à révéler.
* \note Le battement ne double pas une notification fraîche : chaque publication RELANCE
* le timer. La règle vit donc dans le timer, et cette méthode reste inconditionnelle —
* testable sans attendre une période entière.
*/
void publishTelemetryHeartbeat();
/*!
* \brief Vrai si deux configs décrivent le MÊME matériel (câblage, paliers, verrous).
*
* \param a Config ayant servi à construire l'adaptateur. \param b Nouvelle config.
* \return Vrai si l'adaptateur peut être conservé — ECS-412. Rang, besoins et libellé
* sont volontairement ignorés : ils se mettent à jour en place.
*
* \note **Publique à dessein.** Ce prédicat décide si une écriture coûte un geste
* MATÉRIEL — contacts ouverts par l'état sûr, verrous réarmés à froid — ou une simple
* mise à jour en place. C'est à ce titre un élément de contrat, que le client annonce
* à l'installateur avant d'enregistrer, et il est éprouvé comme tel
* (\c tests/auto/loadmodel). Statique et sans état : l'exposer n'ouvre rien.
*/
static bool sameHardware(const LoadConfig &a, const LoadConfig &b);
/*!
* \brief [3g-1] Cette borne participe-t-elle au waterfall de surplus ?
*
* \param ev Borne interrogée ; \c nullptr rend faux.
* \return Vrai si la borne entre dans le waterfall de surplus ce cycle.
*
* **Deux** exclusions, et elles ne disent pas la même chose : le **mode manuel**
* (\c ChargingModeNormal) est une INTENTION — l'utilisateur pilote, l'arbitre s'abstient ;
* **aucun véhicule branché** (\c pluggedIn) est un CONSTAT — la borne ne peut rien tirer
* de ce qu'on lui allouerait.
*
* Les exclure du contexte est plus juste que leur donner un rang : leur consommation est
* déjà dans le compteur racine, donc dans le budget, sans qu'on ait à la leur allouer
* (LM-1208 — sortir de la décision n'est pas sortir de la comptabilité).
*
* \note **`pluggedIn` a été ajouté après le banc du 2026-08-27** : les exclusions
* d'origine venaient du filtre d'entrée du proxy, qui n'avait pas besoin de celle-ci —
* son allocation n'était pas une cascade, donc gaspiller ne privait personne. En
* cascade, une borne débranchée absorbe le budget des charges suivantes (4 853 W
* mesurés, \c docs/RELEVE_3g2.md §3.1).
* \note **« Pas de voiture assignée » a été RETIRÉ des exclusions le 2026-08-28**
* (LM-1208-b) : le critère contredisait LM-1206 — le rang et le plancher appartiennent à
* la borne, pas au véhicule — il n'était pas vérifiable, et il excluait la borne d'un
* invité au moment précis où elle consomme. Ce qu'on perd sans voiture assignée est une
* FONCTION (échéance, cible d'énergie, toutes deux restées au proxy), pas l'arbitrage.
* \warning Corollaire à ne pas perdre : une borne sans voiture n'a **pas** de
* \c ChargingProcessInfo — \c SmartChargingManager::prepareInformation() l'écarte
* toujours. Tout code qui lit \c internalProcessInfo() pour une borne du waterfall doit
* d'abord interroger \c internalHasProcessInfo() ; voir \c evTargetPhases().
*/
bool evParticipatesInWaterfall(EvCharger *ev) const;
/*!
* \brief [3g-1] Nombre de phases visé pour une borne, à un courant donné.
*
* Passe-plat vers la règle de bascule 1↔3 du manager, PLUS le plafond du véhicule.
* L'arbitre est la porte d'entrée de l'adaptateur vers cette règle : la réécrire dans
* l'adaptateur en ferait deux versions, et elles divergeraient au premier ajustement.
*
* \note **[3g-2] Le plafond véhicule fait partie de la règle.** \c getBestPhaseCount()
* ne le connaît pas : le proxy appliquait `qMin(carPhaseCount, …)` juste après l'appel.
* 3g-1 n'a repris que l'appel, si bien qu'une voiture monophasée pouvait se voir viser
* 3 phases — et le plancher publié triplait avec elle, sans qu'aucune allocation ne
* puisse jamais la démarrer. Le plafond est donc rendu ici, au seul endroit qui parle
* de phases pour le waterfall.
*
* \note **[LM-1208-b] Sans \c ChargingProcessInfo, la règle du manager n'existe pas** —
* c'est le cas d'une borne sans voiture assignée, que \c prepareInformation() écarte
* alors qu'elle est désormais arbitrée. La capacité du véhicule étant inconnue, on ne
* bascule pas et on retient le nombre de phases de la BORNE. Laisser
* \c internalProcessInfo() rendre son objet par défaut annoncerait « monophasée » une
* borne triphasée, et diviserait le budget du cycle par trois — silencieusement.
*
* \param ev Borne concernée.
* \param referenceA Courant de référence, en ampères TOTAUX toutes phases confondues —
* voir \c evReferenceCurrentA(). Peut être négatif en soutirage.
* \return Nombre de phases visé, dans [1, 3], jamais 0.
*/
uint evTargetPhases(EvCharger *ev, double referenceA);
/*!
* \brief [3g-2] Courant de référence (A) pour choisir le nombre de phases d'une borne.
*
* \param ev Borne. \return Somme du courant qu'elle tire DÉJÀ et du surplus disponible,
* en ampères totaux (toutes phases confondues). Négatif possible : en soutirage, la
* référence descend, et la règle de bascule vise le monophasé.
*
* \par Pourquoi la référence n'est pas le seul courant courant
* 3g-1 passait \c currentPower / 230, au motif que l'allocation n'est pas encore connue.
* Mais la bascule 1↔3 phases ne se décide pas sur ce que la borne tire : elle se décide
* sur ce qu'elle POURRAIT tirer — c'est la formule du proxy (`currentPower/230 +
* allowance`), et c'est la seule qui puisse répondre « redescends en monophasé, il n'y a
* plus que 800 W ». Avec le seul courant courant, une borne en triphasé restait en
* triphasé quel que soit l'effondrement du surplus, et son plancher publié restait à
* 4,1 kW — donc plus rien ne pouvait la servir.
*
* \note Le terme de stockage de \c planSurplusCharging() (tampon 500 W, réinjection au-
* dessus du seuil) n'entre PAS ici : il déplace la référence à la marge et n'a d'effet
* que sur le choix 1↔3, jamais sur l'allocation, qui est faite par le waterfall avec un
* budget qui, lui, tient compte du stockage.
*/
double evReferenceCurrentA(EvCharger *ev) const;
/*!
* \brief [3g-1] Plancher de modulation d'une borne, en watts, pour le nombre de phases VISÉ.
*
* \param ev Borne. \param phaseCount Nombre de phases visé — **pas** le nombre courant.
* \return Puissance minimale sous laquelle la borne ne démarre pas, 0 si indéterminable.
*
* \warning Le nombre de phases change le plancher du simple au triple (6 A × 230 V = 1,4 kW
* en monophasé, 4,1 kW en triphasé). Publier le plancher du nombre COURANT alors que
* l'exécution basculera vers un autre produirait un défaut silencieux : le waterfall
* allouerait 2 kW en croyant démarrer une borne qui en exige 4,1, et se croirait servi.
*
* \note Valeur **runtime**, pas un réglage : \c minimalChargingCurrent vaut
* `max(borne, voiture)` et dépend donc du véhicule branché.
*/
double evFloorW(EvCharger *ev, uint phaseCount) const;
/*!
* \brief Plafonds de tirage en vigueur à cet instant.
*
* \return La liste des \c DrawCap réellement actifs — vide si personne ne borne.
*
* \par Un seul mécanisme, plusieurs sources
* Le disjoncteur, une limite de gestionnaire de réseau et un plancher éco auto-imposé
* bornent tous le tirage ; ils ne diffèrent que par ce qu'on a le droit d'en faire.
* Les faire cohabiter dans UNE liste évite trois écrêtages concurrents dont le dernier
* gagnerait sans qu'on sache lequel a parlé.
*
* \note Un plafond absent n'est pas un plafond à zéro. La liste ne contient que ce qui
* existe, pour qu'\c resolveDrawMargin puisse répondre « sans objet » plutôt que
* « 0 W autorisé » — deux choses que rien ne distinguerait ensuite.
*/
QList<DrawCap> drawCaps() const;
/*!
* \brief Tirage par phase, en watts, tel que le compteur amont le lit.
*
* \param masque Phases à relever ; \c PhaseNone == les trois (convention amont).
* \param limiteA Limite par phase en ampères, nécessaire à l'inversion (voir ci-dessous).
* \return Un watt par phase retenue, dans l'ordre A, B, C. Liste vide sans compteur.
*
* \note La valeur est obtenue en **inversant** \c RootMeter::calculateAllowanceAmpere()
* phase par phase, et non en relisant les états. Les lectures amont ne sont pas
* symétriques entre phases ; les recopier ici en ferait deux versions vouées à diverger.
* L'inversion rend l'égalité au watt vraie par construction.
*/
QList<double> phaseDrawW(Electricity::Phases masque, uint limiteA) const;
/*!
* \brief [§10-C] Avancement d'une obligation éco, et le régime qui dit ce qu'il vaut.
*
* \param lc Charge du cycle.
* \param now Temps de cycle — sert à résoudre l'échéance, donc à détecter la BASCULE de
* période : à l'instant de l'heure limite quotidienne, une nouvelle obligation s'ouvre et
* la base d'avancement se reprend (\rule{LM-1013-c}). Sans lui, le lendemain hériterait de
* l'énergie de la veille et se croirait tenu sans avoir rien reçu.
* \return L'avancement — \c regime toujours renseigné quand l'obligation existe.
*
* \par La BASE, et les deux questions qu'elle pose
* `sessionWh` est un ACCUMULATEUR de plugin, pas un compteur remis à zéro pour nous : côté
* V2C, l'écart entre `sessionEnergy` et `chargeEnergy` est constant (7,2044 kWh mesurés sur
* deux échantillons à deux heures d'écart), parce qu'il porte les incréments d'avant la
* dernière remise à zéro de la borne. **Le lire directement ferait croire toute obligation
* tenue de 7,2 kWh dès le premier cycle** — un nombre crédible et faux que rien ne
* signalerait. L'avancement est donc une DIFFÉRENCE depuis une base.
*
* **Quand la base est-elle prise ?** À l'ouverture de l'OBLIGATION, jamais au démarrage de
* nymead : la reprendre au démarrage réinitialiserait l'avancement d'une session en cours
* sur une valeur déjà accumulée, et une recharge de nuit relue au matin repartirait de zéro.
*
* **Et si `sessionWh` RECULE ?** C'est une nouvelle session : le plugin remet son
* accumulateur à la valeur brute à l'ouverture. La base est alors RECAPTURÉE, sinon la
* différence devient négative. On ne refait pas la détection de remise à zéro de la borne —
* le plugin la fait déjà, à 30 s, là où le moteur travaille à la minute et raterait des
* cycles entiers. On ne détecte ici que le recul de l'accumulateur LUI-MÊME.
*
* \note Un recul infime est du bruit de lecture, pas une nouvelle session — même seuil de
* principe que le plugin. En dessous, la base suit la valeur sans rien conclure.
*/
EcoProgress ecoProgress(const LoadContext &lc, const QDateTime &now) const;
/*!
* \brief Note pourquoi l'obligation d'une charge n'a pas pu être servie ce cycle.
* \param loadId Charge. \param cause Code de \c MissedCause, vide si elle a été servie.
*
* \note Appelé par la passe éco à chaque cycle. À la CLÔTURE d'une période, c'est la
* dernière cause notée qui est publiée : celle qui valait au dernier cycle où l'on aurait
* encore pu servir. Retenir la plus fréquente serait plus juste en moyenne et moins utile
* en pratique — ce qu'on veut savoir est ce qui bloquait à la fin.
*/
void noterCauseEco(const QString &loadId, const QString &cause) const;
/*!
* \brief Note les watts ACHETÉS pour l'obligation d'une charge, et cumule l'énergie.
* \param loadId Charge. \param achatW Puissance achetée ce cycle. \param now Temps de cycle.
* \note Le cumul est remis à zéro à la bascule de période (\rule{LM-1014-b}) : le laisser
* courir ferait porter au lendemain la dépense de la veille. Le premier cycle d'une
* période ne contribue rien — il n'a pas de durée derrière lui, et en inventer une serait
* fabriquer de l'énergie.
*/
void noterAchatEco(const QString &loadId, double achatW, const QDateTime &now) const;
/*!
* \brief [L4] Déleste les charges du waterfall tant que le plafond de tirage est dépassé.
*
* \param now Temps de cycle.
* \note Évaluée AVANT tout dispatch du cycle : dans un même \c update() aucune boucle
* d'événements ne tourne, donc un adaptateur qui vient d'être commandé rapporte encore
* son état précédent. Les charges délestées sont REVENDIQUÉES (\c m_shedActions) et le
* dispatch les saute — un seul commandeur par organe.
*
* \par Ce que cette méthode ajoute
* La protection de surcharge amont ne connaît que les bornes VE. Une PAC ou un routeur
* pouvaient donc tenir un dépassement sans que rien ne les redescende. Les bornes restent
* traitées par l'amont — les délester ici aussi en ferait deux commandeurs.
*
* \par Ordre et verrous
* Ordre INVERSE du rang : la dernière servie est la première délestée. Les verrous ne sont
* franchis (\c LoadAction::force) que pour \c Breaker et \c GridOperator ; jamais pour
* \c EcoSelfImposed, où l'on renonce au plancher plutôt qu'au matériel (\rule{LM-1211}).
* Un franchissement est TOUJOURS dit dans le motif.
*
*/
void shedWaterfallForOverload(const QDateTime &now);
//! \brief Replace dans \c slot les décisions des charges délestées par L4.
//! \param[in,out] slot Actions du cycle. Une charge délestée porte le motif du DÉLESTAGE :
//! le plan ne l'a pas commandée, publier sa consigne montrerait ce qui n'a pas eu lieu.
void fusionnerDelestageDansSlot(Slot &slot) const;
/*!
* \brief Marge de soutirage de la MAISON, toutes phases confondues.
*
* \return La marge de la phase la plus contrainte — \c borne à faux si aucun plafond.
*
* \note Toutes les phases sont évaluées, et la pire l'emporte. Le modèle ne connaît qu'un
* NOMBRE de phases par charge, jamais LESQUELLES : une monophasée de phase inconnue peut
* tomber sur n'importe laquelle. Supposer mieux serait deviner, dans une couche de
* sécurité. Le pessimisme ne mord que là où il doit — une phase qui exporte a une marge
* plus grande que le plafond et ne borne personne.
*/
DrawMargin houseDrawMargin() const;
/*!
* \brief Marge de tirage vue par une borne, avec la source qui la borne.
*
* \param ev Borne. \param phaseCount Nombre de phases VISÉ.
* \return La marge retenue — \c borne à faux si aucun plafond n'est en vigueur.
*
* \note La marge est jugée sur les phases que la borne OCCUPE : une monophasée sur A n'a
* pas à répondre d'une surcharge sur C, qu'elle ne peut ni causer ni soulager.
* \note La marge peut être **négative** — elle porte alors l'ampleur du dépassement.
* L'écrêtage à zéro appartient au consommateur, pas à la résolution.
*/
DrawMargin evDrawMargin(EvCharger *ev, uint phaseCount) const;
/*!
* \brief [3g-2] Puissance maximale (W) que la protection de surcharge laisse à cette borne.
*
* \param ev Borne. \param phaseCount Nombre de phases VISÉ.
* \return Plafond en watts, ou **−1 si aucune limite** — \c phasePowerLimit à 0 signifie
* « non réglé » (contrat amont), et la protection est alors désactivée.
*
* \par Pourquoi cette méthode existe
* L'écrêtage par la limite de phase vivait dans \c planSurplusCharging()
* (\c calculateAllowanceAmpere, `qMin(newRawValue, allowance)`). En transplantant la
* décision de surplus dans le waterfall, 3g-1 a laissé cet écrêtage derrière lui — sans
* conséquence tant que rien n'était exécuté, mais 3g-2 exécute. Sans ce plafond, le
* waterfall pourrait allouer un surplus que le branchement ne supporte pas, et la seule
* chose qui rattraperait serait \c verifyOverloadProtection() **après coup**.
*
* \note Le plafond est la place LIBRE, et on n'y rend **pas** ce que la borne tire déjà.
* C'est délibéré et c'est de sécurité : sur une phase en dépassement la marge est
* négative, et c'est ce signe qui ramène la borne à son minimum. Rendre sa propre
* consommation lui rendrait exactement la puissance qui cause le dépassement
* (`testOverloadProtectionEcoMode`). La formulation inverse figurait ici et était
* fausse : le code ne l'a jamais fait.
* \note Valeur de CYCLE : elle dépend de ce que le reste de la maison consomme à cet
* instant. Elle est donc relue à chaque \c toLoadContext(), jamais mémorisée.
*/
double evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const;
/*!
* \brief [3g-2] Rang configuré d'une borne dans le waterfall.
*
* \param chargerId ThingId de la borne, sous sa forme chaîne.
* \return \c priority de son entrée \c LoadConfig \c evcharger. En l'absence d'entrée —
* fenêtre entre la détection du Thing et l'auto-provisionnement, ou store absent — un
* rang de queue plutôt qu'un rang de tête : une borne dont la configuration n'est pas
* encore posée ne doit pas passer devant des charges déjà classées.
*
* \note Lu à CHAQUE \c descriptor() : c'est ce qui rend le réordonnancement effectif à
* chaud sans reconstruire l'adaptateur, donc sans toucher aux verrous de la borne.
*/
int evPriority(const QString &chargerId) const;
/*! \brief Crochet amont — cette borne a-t-elle été commandée par le waterfall ce cycle ?
* \param evCharger Borne interrogée par \c adjustEvChargers().
* \return Vrai si elle figure dans \c Plan::waterfallEvIds du cycle courant. */
bool evCommandedByWaterfall(EvCharger *evCharger) const override;
/*! \brief Crochet amont — le surplus de cette borne appartient-il au waterfall ?
* \param evCharger Borne interrogée par \c planSurplusCharging().
* \return Vrai si elle passe le filtre d'entrée du waterfall (voiture assignée, mode non
* manuel). Interrogé AVANT que le waterfall n'ait décidé, d'où sa réponse plus large que
* celle de \c evCommandedByWaterfall(). */
bool evSurplusPlannedByWaterfall(EvCharger *evCharger) const override;
signals:
/*!
* \brief Émise quand l'état runtime d'arbitrage a CHANGÉ, ou au battement de cœur.
* \param telemetry Charge utile identique à celle de \c loadTelemetry().
* \note « Changé » exclut les grandeurs qui bougent en continu : les secondes restantes
* d'un verrou et les paramètres numériques d'un motif ne déclenchent rien, et une
* variation d'allocation inférieure au seuil non plus. Voir
* \c telemetrySignificantlyDiffers().
*/
void loadTelemetryChanged(const QVariantMap &telemetry);
protected:
/*!
* \brief Boucle principale ETM — surcharge SmartChargingManager::update().
*
* Ordre garanti :
* 1. updateManualSoCsWithoutMeter()
* 2. prepareInformation()
* 3. verifyOverloadProtection() + verifyOverloadProtectionRecovery()
* (si \c m_degradedMode actif : retour immédiat — planification/dispatch suspendus, L2)
* 4. m_scheduler->getPlan() → log des decisionReason
* 5. applyActionsToAdapters() (etmvariableload Setpoint W + SG-Ready State) + adjustEvChargers() (EV) → dispatch
*
* \param currentDateTime Instant courant (timer ou simulation).
*/
void update(const QDateTime &currentDateTime) override;
private:
/*!
* \brief Construit le SurplusContext §5 : meter brut + loads EV + SG-Ready.
*
* \c ctx.meter.exportW = mesure brute du compteur (AGENTS invariant 8 — aucune
* déduction interne). La déduction evReservedW est faite dans le scheduler.
* \param now Temps de cycle (\c ctx.timestamp) : pose \c ctx.timestamp et sert de SOURCE
* UNIQUE à la fenêtre de verrou PAC (\c minState/maxState) calculée par le SgReadyAdapter —
* cohérence décision (scheduler) / exécution (applyAction). Les charges etmvariableload
* (ECS/routeur) n'ont pas de fenêtre côté moteur (verrous dans le thing).
*/
SurplusContext buildContext(const QDateTime &now) const;
/*!
* \brief Synchronise m_adapters avec les EvCharger actuellement enregistrés.
* Crée les adapters manquants, supprime les adapters obsolètes.
* \note Les charges non-EV, elles, viennent toutes de \c LoadConfigStore.
*/
void syncAdapters();
/*!
* \brief Applique les actions d'un Slot aux LoadAdapters non-EV.
*
* Itère \c slot.actions et dispatche par \c loadId dans la table UNIQUE des charges
* arbitrées ; le polymorphisme \c ILoadAdapter absorbe le mécanisme. Les actions EV
* (\c Setpoint) restent dispatchées par \c adjustEvChargers() amont jusqu'à 3g — l'EV
* n'est pas dans cette table. L'adaptateur écrête/verrouille lui-même et ignore toute
* action sans \c reason ou de kind non supporté — aucune décision ici (règle 2).
* \param slot Créneau courant retourné par le scheduler.
* \param now Temps de cycle (\c ctx.timestamp) — transmis aux adaptateurs pour une
* évaluation des verrous cohérente avec celle vue par le scheduler.
*/
void applyActionsToAdapters(const Slot &slot, const QDateTime &now);
/*!
* \brief (Re)construit \c m_loadAdapters depuis \c m_loadConfigStore (rév. 3).
* Crée, par config \c enabled==true, un \c SgReadyAdapter (si \c sgReady), un
* \c RelayRouter (si \c relays[]) ou un \c EtmVariableLoadAdapter (sinon) — une branche
* de fabrique par mécanisme, LM-301. La distinction de TYPE vit ici ; au-dessus tout est
* \c ILoadAdapter. Reconstruction INCRÉMENTALE (ECS-412) ; ce qui sort de la config passe
* par \c applySafeState() avant destruction (ECS-413). Appelé au branchement du store et
* à chaque \c LoadConfigStore::changed().
*/
void rebuildLoadAdapters();
/*!
* \brief [3g-2] Crée l'entrée de configuration manquante d'une borne détectée.
*
* Une borne qui apparaît dans nymea n'a aucune entrée \c LoadConfig, donc aucun rang —
* et jusqu'à 3g-2 elle était arbitrée et publiée en télémétrie sans figurer dans
* \c GetLoadConfig. C'est l'invariant « \c loads[] ⊆ \c GetLoadConfig » du lot B-bis,
* rompu par 3g-1 : l'app s'y appuie pour afficher une charge, et une charge publiée sans
* configuration est exactement la charge fantôme que le lot B-bis avait supprimée.
*
* \par Ce que l'entrée contient, et ce qu'elle ne contient pas
* Le rang, le libellé, le domaine \c ev, \c enabled. Aucune charge utile : les limites de
* la borne viennent du Thing (voir \c LoadConfig::isEvCharger()).
*
* \par Rang par defaut — EN QUEUE (LM-1209)
* `1 + max(rangs existants)`, donc libre par construction et sans bousculer personne. Ce
* n'est pas une politique du plugin : le rang est un choix de l'installateur, et une
* détection ne réordonne pas ce qu'un humain a décidé. Une borne détectée un mois après la
* mise en service passerait sinon devant l'ECS que quelqu'un a réglé exprès.
*
* \par Appelée à CHAQUE cycle, pas seulement à la naissance de l'adaptateur
* \rule{LM-1209-e}. Rend la main aussitôt si l'entrée existe : la condition est « cette
* borne n'a pas d'entrée », jamais « l'adaptateur vient de naître ». C'est ce qui **rattrape
* une suppression depuis l'app** — l'adaptateur, construit depuis le Thing, survit à la
* disparition de l'entrée, et sans ce rattrapage la borne restait arbitrée mais absente de
* \c GetLoadConfig, à un rang non réglable.
*
* \note **Une entrée recréée revient en \c rankOrigin "auto"**, et c'est voulu : personne
* ne l'a classée, son rang est de nouveau un défaut. L'installateur qui vient de la
* supprimer la voit donc réapparaître **en queue, avec son badge** — ce qui se dit, au
* journal et dans la charge utile, plutôt que de se subir.
* \note **Journalisée, une ligne par borne à la création.** Une entrée créée d'office est
* une écriture que personne n'a demandée : sans trace, un installateur verrait apparaître
* des charges dans son écran sans savoir d'où elles viennent. Un REFUS, lui, n'est
* annoncé qu'une fois par borne (\c m_provisionRefuse) : le rattrapage étant cyclique,
* le répéter produirait un avertissement par minute et noierait le vrai signal.
* \note L'entrée n'est JAMAIS supprimée d'office quand le Thing disparaît — c'est une
* configuration, et l'installateur a pu la classer. Elle se lit alors comme une charge
* dont l'appareil manque (\c LOAD_UNAVAILABLE, \c faultCode \c THING_MISSING).
* \param ev Borne détectée, ou borne connue dont l'entrée manque.
*/
void provisionEvChargerConfig(EvCharger *ev);
/*! \brief [3g-2] Vrai si \p loadId désigne une borne DÉCLARÉE en configuration et active.
* \param loadId Identifiant de charge.
* \return Vrai même si le Thing correspondant a disparu — c'est précisément le cas qu'il
* sert à reconnaître, pour publier la charge comme « appareil manquant » plutôt que de
* l'omettre. */
bool estUneBorneConfiguree(const QString &loadId) const;
/*!
* \brief Déclencheur RÉEL du watchdog L2 (SAFETY.md §L2) — slot de \c m_meterWatchdog
* (QTimer 30 s, horloge murale ; câblé sous \c \#ifndef ENERGY_SIMULATION).
*
* Délègue simplement à \c evaluateMeterFreshness(QDateTime::currentDateTime()) : la
* LOGIQUE (seuil 90 s, bascule en dégradé) est dans cette méthode injectable, le QTimer
* n'est que le battement. Indépendant des signaux compteur : reste actif précisément
* quand le compteur est muet (le signal \c powerBalanceChanged ne fire plus, et
* \c update() — piloté par le compteur — s'arrête aussi). Voir \c evaluateMeterFreshness().
*/
void onMeterWatchdogTick();
/*!
* \brief Applique les consignes de repli L2 (SAFETY.md §L2, Variante B).
*
* Repli CONSERVATEUR (n'initie aucune charge) : ECS → palier 0 \c force=true (bypass
* anti-rebond) ; EV en charge → clamp courant minimum borne ; EV branché non chargeant
* ou débranché → aucune action (planification en cours respectée ; "jamais 0 A si
* branché" relève du failsafe L1). SG-Ready/Batterie : repli ajouté à l'arrivée de
* leurs adaptateurs (3e/3f). Positionne \c m_degradedMode.
*
* \note Appelé une seule fois à la TRANSITION vers le mode dégradé. Ensuite \c update()
* suspend la planification, donc les consignes tiennent sans ré-émission par tick.
* \param now Instant courant (locks anti-rebond des bornes EV).
*/
void applyDegradedMode(const QDateTime &now);
/*!
* \brief Construit l'instantané de télémétrie du cycle qui vient de s'exécuter.
*
* \param plan Plan retourné par le scheduler — source de \c budget (jamais recalculé ici).
* \param slot Créneau courant : ses actions donnent l'ordre du waterfall ET les motifs.
* \param now Temps de cycle, publié tel quel comme \c timestamp.
* \note Les charges EV sont EXCLUES : décidées par l'amont en amont du waterfall (décision
* B), leur puissance est réservée dans \c budget.evReservedW, pas allouée depuis le
* budget publié. Les inclure ferait une somme \c loads[] qui ne retomberait pas sur
* \c budget.allocatedW.
*/
void buildTelemetry(const Plan &plan, const Slot &slot, const QDateTime &now);
/*!
* \brief Construit l'instantané du mode dégradé L2 et le publie sans condition.
* \param now Instant de la bascule — c'est bien une décision, elle se date.
* \note Sans cet appel, un client qui se connecte APRÈS la bascule ne lirait jamais
* \c degradedMode : \c update() rend la main avant toute publication tant que le repli
* tient. C'est la reprise à froid, et c'est la famille ECS-411.
*/
void buildDegradedTelemetry(const QDateTime &now);
/*!
* \brief Ajoute à une entrée de télémétrie les MESURES de la charge, si elle en déclare.
*
* §11 (LM-1101, LM-1103). Deux champs optionnels de \c LoadConfig — \c meterThingId et
* \c sensorThingId — sont résolus ici, au moment de PUBLIER, et nulle part ailleurs.
*
* \note **Les adaptateurs ne voient jamais ces Things.** C'est ce qui rend « aucun effet
* sur les décisions » vrai par construction plutôt que par discipline : la mesure ne
* traverse aucun chemin d'arbitrage, elle est lue au dernier moment et publiée.
* \note Omis, jamais nul : un Thing absent de la configuration nymea, ou un état que le
* Thing ne porte pas, ne produit **aucune clé** — pas un zéro, qui se lirait « mesuré 0 W »
* là où la vérité est « pas de mesure ».
*
* \param entry Entrée de charge en cours de construction, modifiée en place.
* \param loadId Identifiant de la charge, clé dans le magasin de configuration.
*/
void attachMeasurements(QVariantMap &entry, const QString &loadId) const;
/*!
* \brief Qualifie l'écart d'une charge en LATENCE ou en REFUS, selon son mécanisme.
* \param[in,out] lc Charge du cycle ; sa télémétrie reçoit \c commandRefused et
* \c refusedSinceS. \param now Temps de cycle.
* \note Le seuil vient de \c ILoadAdapter::commandSettleS, jamais d'une constante : trop
* court pour une borne, trop long pour un relais.
*/
void qualifierRefus(LoadContext &lc, const QDateTime &now) const;
/*!
* \brief Alimente l'avancement d'une charge NON-VE depuis son compteur désigné.
* \param[in,out] lc Charge du cycle.
*
* \par La contradiction que cette méthode supprime
* \c progressMeasurable dit ce que le MATÉRIEL permet ; \c regime dit ce que le MOTEUR fait.
* Seul l'\c EvAdapter remplissait \c sessionMeasurable — donc sur toute charge non-VE le
* premier disait oui et le second non. L'avertissement de saisie de l'app se déclenchant sur
* \c progressMeasurable, il restait **muet exactement sur les charges achetées à l'aveugle**.
*
* \note Il faut un CUMUL (\c totalEnergyConsumed), pas une puissance : \c currentPower ne dit
* rien de ce qui a été livré. Même exigence que \c progressUnmeasurableCause, et elle doit
* être la même des deux côtés, sinon la contradiction se déplace au lieu de disparaître.
*/
void alimenterAvancement(LoadContext &lc) const;
/*!
* \brief Publie l'écart entre ce qu'on a COMMANDÉ et ce qui est MESURÉ.
*
* \param[in,out] entry Entrée de télémétrie de la charge.
* \param loadId Charge. \param source Source de mesure déjà résolue. \param mesureW Sa valeur.
*
* \par L'écart ne vaut pas la même chose selon la source, dans les DEUX sens
* | | concordance | divergence |
* |---|---|---|
* | \c device | **ne prouve rien** — plusieurs bornes renvoient leur consigne en guise de puissance | la charge n'obéit pas, et elle le dit elle-même |
* | \c meter | **preuve** — un tiers le constate | trois hypothèses : la charge, le compteur, le câblage |
*
* L'objet est donc **absent** sous \c none : il n'y a rien à comparer, et publier
* \c divergent:false affirmerait une concordance qu'on ne peut pas constater.
*
* \warning **Un écart n'est PAS un défaut, et ce champ ne le qualifie pas.** Une borne qui
* n'obéit pas parce que le véhicule limite, parce que le câble est en 16 A, ou parce
* qu'elle est en mode manuel, produit le MÊME nombre qu'une commande perdue. L'écart se
* publie, il ne se diagnostique pas — la cause n'est pas dans les watts.
*/
void attachCommandDivergence(QVariantMap &entry, const QString &loadId,
const QString &source, double mesureW) const;
/*!
* \brief Publie \c m_telemetry si elle diffère significativement de la dernière publiée.
* \param force Vrai pour publier sans condition (bascule de mode dégradé, battement).
*/
void publishTelemetry(bool force);
/*!
* \brief Vrai si deux instantanés diffèrent assez pour mériter une notification.
* \param a Dernier instantané publié. \param b Instantané courant.
* \return Vrai sur tout changement d'état QUALITATIF (mode dégradé, jeu de charges,
* disponibilité, code de défaut, nature du verrou, mécanisme, CODE de motif) ou sur une
* variation d'allocation dépassant le seuil.
* \note Sont volontairement IGNORÉS : les secondes restantes d'un verrou (elles décroissent
* à chaque seconde), et les paramètres numériques d'un motif (le budget bouge à chaque
* cycle). Les comparer ferait notifier en permanence et le seuil ne servirait à rien.
*/
static bool telemetrySignificantlyDiffers(const QVariantMap &a, const QVariantMap &b);
RuleBasedScheduler *m_scheduler = nullptr;
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
/*!
* \brief [3g-2] Bornes commandées par le waterfall AU CYCLE COURANT.
*
* Recopié de \c Plan::waterfallEvIds à chaque cycle, et lu par le crochet
* \c evCommandedByWaterfall() que \c adjustEvChargers() amont interroge. C'est la
* mémoire qui empêche le proxy de re-commander ce que l'arbitre vient de commander.
*
* \note **Vidé à l'entrée du mode dégradé L2**, où la planification est suspendue : sans
* cela, le jeu du dernier cycle sain continuerait d'exclure ces bornes du proxy alors
* que plus personne ne les commande — elles resteraient figées sur leur dernière
* consigne, compteur muet, ce que SAFETY.md interdit.
*/
QSet<QString> m_waterfallEvIds;
//! Bornes dont la configuration automatique a été REFUSÉE — pour n'avertir qu'une fois.
//! Depuis LM-1209-e le rattrapage est tenté à chaque cycle ; répéter l'avertissement en
//! produirait un par minute et noierait le vrai signal. Vidée au premier succès.
QSet<QString> m_provisionRefuse;
//! loadId → charge ARBITRÉE, tous mécanismes confondus (relay-router | etmvariableload |
//! sg-ready). Table UNIQUE, et c'est un invariant de contrat : toutes ses entrées viennent
//! de \c LoadConfigStore, donc l'ensemble publié par \c NymeaEnergy.GetLoadTelemetry est
//! exactement celui de \c GetLoadConfig. Une seconde table existait pour la PAC de banc
//! codée en dur ; elle a disparu avec elle (lot B-bis), et avec elle la possibilité qu'une
//! charge soit arbitrée sans être configurable.
QHash<QString, ILoadAdapter *> m_loadAdapters;
//! loadId → config AYANT SERVI à construire l'adaptateur. Permet le rebuild incrémental
//! (ECS-412) : on ne reconstruit que si le matériel a changé.
QHash<QString, LoadConfig> m_builtFrom;
//! loadId → action de DÉLESTAGE L4 du cycle. Revendication : le dispatch saute ces charges,
//! un seul commandeur par organe (règle absolue 1). Vidée à chaque évaluation de L4.
QHash<QString, LoadAction> m_shedActions;
//! loadId → énergie de session (Wh) au moment où l'obligation s'est OUVERTE. Base des
//! différences d'avancement. Recapturée quand l'accumulateur de la borne recule (nouvelle
//! session), jamais au démarrage de nymead — voir \c ecoProgress().
mutable QHash<QString, double> m_ecoBaseWh;
//! loadId → échéance en vigueur quand la base a été prise. Quand elle BASCULE (heure limite
//! quotidienne franchie), la période d'obligation change : la base se reprend, sinon le
//! lendemain hérite de l'énergie de la veille et se croit déjà tenu.
mutable QHash<QString, QDateTime> m_ecoBaseEcheance;
//! loadId → dernière cause de non-service notée par la passe éco (\c MissedCause). Vide
//! quand la charge a été servie. Publiée à la clôture d'une période non tenue.
mutable QHash<QString, QString> m_ecoCause;
//! loadId → instant où la divergence commandé/mesuré a commencé. Vidé dès qu'elle cesse.
//! Sans cette date, une divergence PERMANENTE ne se distingue pas d'un déphasage d'un
//! cycle entre l'écriture et sa mesure — et c'est toute la différence entre le normal et
//! le cas relevé.
mutable QHash<QString, QDateTime> m_divergenceDepuis;
//! loadId → énergie achetée (Wh) pour l'obligation, depuis l'ouverture de la PÉRIODE.
mutable QHash<QString, double> m_ecoAchatWh;
//! loadId → instant du dernier cycle où un achat a été noté, pour intégrer la durée.
mutable QHash<QString, QDateTime> m_ecoDernierAchat;
//! loadId → instant où l'avancement est devenu MESURABLE dans la période courante.
mutable QHash<QString, QDateTime> m_ecoMesureDepuis;
//! loadId → énergie livrée (Wh) sur les TRONÇONS MESURÉS DÉJÀ CLOS de la période courante.
//! Sans ce cumul, un aller-retour de régime — un lien Modbus qui bat — effacerait tout ce
//! qui précède : l'écran sous-estimerait, et la passe éco RACHÈTERAIT le déjà livré.
mutable QHash<QString, double> m_ecoLivreWh;
//! loadId → dernière valeur de \c sessionWh lue TANT QUE la mesure était disponible. C'est
//! elle qui clôt le tronçon : au moment où le régime tombe, la valeur courante ne vaut plus
//! rien (le Thing ne publie plus), et l'utiliser fabriquerait une énergie.
mutable QHash<QString, double> m_ecoDernierSessionWh;
//! loadId → nombre d'interruptions de mesure dans la période. Publié quand non nul : c'est
//! ce qui autorise l'écran à dire « mesuré PAR INTERMITTENCE depuis 09:14 » plutôt qu'une
//! continuité qui n'a pas eu lieu.
mutable QHash<QString, int> m_ecoTrous;
ThingManager *m_tm = nullptr; //!< ThingManager (pour construire les adaptateurs config).
LoadConfigStore *m_loadConfigStore = nullptr; //!< Store config charge pilotée (non adopté).
// --- L2 watchdog fraîcheur compteur (SAFETY.md §L2) ---
QTimer *m_meterWatchdog = nullptr; //!< Tick 30 s, indépendant des signaux compteur.
QDateTime m_lastMeterUpdate; //!< Horodatage du dernier powerBalanceChanged.
bool m_degradedMode = false; //!< Vrai si les consignes de repli L2 sont actives.
// --- Frontière de télémétrie (NymeaEnergy.GetLoadTelemetry / LoadTelemetryChanged) ---
QVariantMap m_telemetry; //!< Instantané courant ; vide avant le premier cycle.
QVariantMap m_publishedTelemetry; //!< Dernier instantané ÉMIS — base de comparaison du seuil.
QDateTime m_lastTelemetryPublish;//!< Horloge murale du dernier envoi (cadence du battement).
QTimer *m_telemetryHeartbeat = nullptr; //!< Battement 60 s, indépendant du cycle d'arbitrage.
};