Patrick Schurig 3cb79f5918 feat(etm): règle d'arrondi etmvariableload dans le waterfall + infra de test
RuleBasedScheduler : buildSetpointAction (contrat rév. 2 §5) — fixed = arrondi au plus
haut powerLevels ≤ budget ; dynamic = clamp(budget, 0, maxPowerW) ; recrédit currentPowerW
de DÉBUT de cycle (invariant 8) ; résidu cascadé. Waterfall unifié : etmvariableload
(Setpoint W) + sg-ready (State) triés par priorité, un seul budget.

EnergyArbitrator : registerEtmVariableLoadAdapter, inclusion dans buildContext, dispatch
Setpoint par loadId (l'EV reste au proxy amont).

Infra de test : ThingClass mock etmVariableLoad (currentPowerW read / powerSetpoint write)
+ handler executeAction + helper addEtmVariableLoad — valide l'implémentabilité de
l'interface avant le thing réel. Les scénarios simulation migrés arrivent en T4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 09:47:58 +02:00

121 lines
6.2 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 <QObject>
#include "ischeduler.h"
class EvCharger;
class ChargingAction;
class EnergyArbitrator;
/*!
* \brief Planificateur règles GPL : EV (proxy amont) + waterfall surplus SG-Ready (PAC).
*
* \c getPlan() produit un plan à 1 créneau en DEUX temps :
*
* 1. **EV — proxy amont (beta, jusqu'à 3g)** : délègue la planification EV à
* \c planSurplusCharging() / \c planSpotMarketCharging() héritées de SmartChargingManager,
* relit \c m_chargingActions et les reformate en LoadAction(Setpoint) annotées d'un
* \c reason français. Le dispatch EV réel reste dans \c adjustEvChargers() amont.
*
* 2. **Waterfall non-EV** : un budget de surplus UNIQUE — net SIGNÉ
* \c (exportW importW) evReservedW — cascade par **priorité ASC** (rang, 1 = premier
* servi). En T2, seule la PAC \c sg-ready (\c buildSgReadyStateAction, kind State) y est
* traitée : anti-clignotement par **recrédit** de la conso allouée, **clamp lock-aware**
* \c minState/maxState (verrou \c minStateHold, protection court-cycling compresseur) calculé
* au MÊME \c ctx.timestamp que l'exécution (cf. \c ILoadAdapter). Ces LoadAction sont
* RÉELLEMENT dispatchées par \c EnergyArbitrator::applyActionsToAdapters().
*
* Les charges \c etmvariableload (ECS/routeur, kind Setpoint W) rejoindront ce waterfall avec
* la règle d'arrondi (contrat rév. 2 §5) en **T3**. À partir de **3g**, l'EV rejoint aussi le
* waterfall unifié (toutes charges classables ensemble).
*
* \invariant getPlan() retourne IMMÉDIATEMENT (AGENTS invariant 5) et toujours un Plan valide.
* \invariant Toute LoadAction a un \c reason non vide, en français.
* \invariant EV (étape 1) : priorité Deadline VE > Surplus PV > aWATTar > Min courant > Idle
* (iso-fonctionnel amont 3b). Non-EV (étape 2) : ordre = \c priority croissant.
*/
class RuleBasedScheduler : public QObject, public IScheduler
{
Q_OBJECT
public:
/*!
* \brief Constructeur.
* \param arbitrator Arbitre propriétaire — fournit l'accès à la planification et à l'état.
* \param parent Propriétaire Qt.
*/
explicit RuleBasedScheduler(EnergyArbitrator *arbitrator, QObject *parent = nullptr);
/*!
* \brief Retourne le plan pour le slot courant (EV proxy + waterfall non-EV).
*
* Étape 1 (EV) : \c runSpotMarketPlanning() + \c runSurplusPlanning() puis reformatage
* de \c scheduledActions() en LoadAction(Setpoint) — log [Arbitre], dispatch amont.
* Étape 2 (non-EV) : waterfall surplus sur la PAC \c sg-ready triée par priorité —
* LoadAction(State) réellement dispatchées (etmvariableload ECS/routeur arrive en T3).
*
* \param ctx SurplusContext courant. Utilisé : \c ctx.timestamp (temps de cycle / verrous),
* \c ctx.meter (surplus net signé), \c ctx.loads (déclarés, télémétrie, fenêtres de verrou).
* \return Plan à 1 créneau couvrant \c ctx.timestamp + 60 s.
*/
Plan getPlan(const SurplusContext &ctx) override;
private:
/*!
* \brief Construit un LoadAction pour le cas "délai requis" (TimeRequirement).
* \param ev EvCharger concerné.
* \param ca ChargingAction planifiée (courant et phases déjà calculés par planSurplusCharging).
* \return LoadAction avec funding=Grid et reason "Deadline VE".
*/
LoadAction buildTimeRequirementAction(EvCharger *ev, const ChargingAction &ca) const;
/*!
* \brief Construit un LoadAction "courant minimum" pour les modes EcoMin.
* \param ev EvCharger concerné.
* \return LoadAction avec funding=Surplus, chargingEnabled=true, currentA=min.
*/
LoadAction buildMinCurrentAction(EvCharger *ev) const;
/*!
* \brief Construit un LoadAction "idle" (recharge désactivée, aucun surplus).
* \param ev EvCharger concerné.
* \return LoadAction avec chargingEnabled=false et reason appropriée.
*/
LoadAction buildIdleAction(EvCharger *ev) const;
/*!
* \brief Construit un LoadAction \c Setpoint (W) pour une charge \c etmvariableload
* (ECS résistif / routeur PV) par la règle d'arrondi du contrat rév. 2 §5.
*
* Recrédit anti-clignotement (Correction B / contrat §5) : le budget local =
* \c remainingSurplusW + \c lc.telemetry.currentPowerW (conso de DÉBUT de cycle, déjà
* soustraite de l'export mesuré — JAMAIS une relecture post-setpoint, invariant 8). Puis :
* - **fixed** (\c powerLevels non vide) : setpoint = plus haut palier déclaré ≤ budget ;
* - **dynamic** (\c powerLevels vide) : setpoint = \c clamp(budget, 0, maxPowerW).
* Le résidu \c budget setpoint repart vers la charge suivante de la priorité (même cycle).
*
* \param lc Charge \c etmvariableload du SurplusContext.
* \param[in,out] remainingSurplusW Budget de surplus restant (W) ; mis à jour pour la suite.
* \return LoadAction kind=Setpoint, funding=Surplus, \c powerW = consigne, \c reason non vide.
*/
LoadAction buildSetpointAction(const LoadContext &lc, double &remainingSurplusW) const;
/*!
* \brief Construit un LoadAction "state" SG-Ready (PAC) par mapping SÉMANTIQUE du surplus.
*
* 4 états normés (qualitatifs, pas des paliers) : surplus abondant stable → 4 (forcé,
* hystérésis P4×1,2 entrée / P4×1,0 sortie) ; surplus durable → 3 (recommandation, ≥P3) ;
* sinon → 2 (normal, mains off). L'état 1 (effacement) n'est PAS déclenché par le surplus
* seul (déféré : signal tarif/réseau). Recrédit (correction B) sur la puissance allouée
* (déclaré, 0 pour 1/2). Clamp lock-aware via \c minState/maxState (court-cycling PAC).
*
* \param lc Charge SG-Ready (adapter == "sg-ready") du SurplusContext.
* \param[in,out] remainingSurplusW Budget de surplus restant (W), mis à jour pour la suite.
* \return LoadAction kind=State, funding=Surplus, \c reason français non vide.
*/
LoadAction buildSgReadyStateAction(const LoadContext &lc, double &remainingSurplusW) const;
EnergyArbitrator *m_arbitrator;
};