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
847 lines
49 KiB
C++
847 lines
49 KiB
C++
// 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 *> ®isteredEvChargers() 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 ¤tDateTime) 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.
|
||
};
|