Patrick Schurig 0e15203a67 feat(lot E §11): compteur et sonde par charge, publiés et sans effet sur les décisions
Deux champs optionnels de même forme sur LoadConfig — o:meterThingId, o:sensorThingId —
et leur publication en télémétrie. Rien d'autre : l'arbitre ne s'en sert pas pour
décider, et le §12 comme le §10 restent à faire.

« Aucun effet sur les décisions » est vrai par CONSTRUCTION, pas par discipline : les
adaptateurs ne voient jamais ces Things. attachMeasurements() les résout au moment de
publier, dans les deux chemins de télémétrie — y compris le mode dégradé, où un
exploitant veut précisément voir ce que la charge fait pendant que l'arbitre ne décide
plus rien.

measuredW est publié À CÔTÉ de allocatedW et jamais fondu dedans : l'un est ce que le
waterfall a alloué, l'autre ce qu'un compteur a vu. Les confondre rendrait la somme des
allocations irréconciliable avec budget.allocatedW — et injecter une mesure en retard
sur la commande réintroduirait l'oscillation que le recrédit a supprimée.

claimedThingIds() les exclut, et l'exclusion est maintenant commentée sur place : LM-201
vaut pour ce qui commande, ceux-ci mesurent. Sans ce commentaire, la symétrie avec
relays[] invitait à les ajouter — ce qui ferait refuser un compteur divisionnaire
partagé ou une sonde de pièce commune.

spec 0.2.1 : la précondition de livraison du §10 (extension du délestage au waterfall,
même lot ou avant, jamais après) est écrite comme CONDITION et non comme conseil — la
protection de surcharge ne déleste que les bornes, et des planchers éco qui
s'additionnent un matin sans soleil sur un branchement 6 kVA feraient disjoncter
l'installation. Et LM-1008 tranche la cible de confort : saisie en degrés, frontière de
l'arbitre en énergie, conversion à la couche domaine — et aucune conversion nécessaire
aujourd'hui, un comparateur suffit, c'est l'arrêt qui compte et pas le dosage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-26 10:34:41 +02:00

391 lines
21 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 <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:
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.
*/
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.
* \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);
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 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 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*.
//! 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;
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.
};