// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #pragma once #include #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 \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. * \param recreditDejaAttribue Vrai si la passe ÉCO a déjà crédité cette charge. Le recrédit * anti-clignotement s'attribue à la PREMIÈRE passe où la charge apparaît et une seule fois * (\rule{LM-1004}) — le recréditer deux fois rendrait au budget de surplus une puissance * que l'éco a financée au réseau. Champ EXPLICITE plutôt que discipline : la discipline ne * se teste pas. * \param[in,out] margeRestante Autorisation de SOUTIRAGE restante (W), consommée le long * de la cascade au même titre que le surplus. \c borne à faux = aucun plafond en vigueur, * ce qui n'est PAS une marge nulle : sans plafond la cascade n'est pas bornée, à marge * nulle elle s'arrête. Les bornes VE ne sont pas ÉCRÊTÉES par elle — leur plafond de * cycle connaît déjà les phases qu'elles occupent — mais elles la DÉCRÉMENTENT * (\rule{LM-1210}). * \param[in,out] budget Comptabilité PUBLIÉE : \c allocatedW et \c recreditedW sont * accumulés ici, dans la cascade même. Le recrédit y figure parce que sans lui * l'identité \c surplus + \c recrédit − \c alloué == \c restant serait fausse dès * qu'une charge consomme déjà — c'est-à-dire dans le cas normal. * \return La cible décidée. Le motif est formé par \c emitSetpointAction(). */ //! \param draw Registre des watts ACHETÉS (R3) — alimenté par EV_GRID_START, qui est le //! seul endroit de la cascade où le moteur sache partager une allocation entre le //! surplus et le réseau. /*! * \brief Grandeurs dont dépend le choix d'un motif — et rien d'autre. * * Regroupées pour que \c choisirMotif() soit une fonction **pure** de ce qu'elle lit. Toute * grandeur qui n'y figure pas ne peut pas influencer le motif, et c'est vérifiable à l'œil. */ struct MotifEntrees { double setpointW = 0; //!< Consigne retenue, APRÈS écrêtage de verrou. double vouluW = 0; //!< Ce que la stratégie voulait, AVANT les verrous (ECS-309). double budgetW = 0; //!< Budget local du cycle, recrédit inclus. double plancherW = 0; //!< Premier palier atteignable — le motif doit le NOMMER. double consoW = 0; //!< Consommation de début de cycle de la charge. DrawMargin marge; //!< Marge de soutirage restante, avec sa source. bool sousPlancher = false; //!< Budget > 0 mais sous le premier palier. bool capEpuise = false; //!< Autorisation de soutirage NULLE (\rule{LM-1011}). bool dynamic = false; //!< Mécanisme à modulation continue (pas de paliers). //! \brief La charge REFUSE la commande — écart au-delà de la latence de son mécanisme. //! Son recrédit est alors suspendu, ce qui change l'allocation de toutes les suivantes. bool refuse = false; int refuseDepuisS = 0; //!< Depuis combien de secondes l'écart dure. double retenuW = 0; //!< Watts NON rendus au budget du fait du refus. }; /*! * \brief Choisit le motif d'une décision — fonction PURE de \c MotifEntrees. * \param e Les grandeurs qui décident, et elles seules. * \return Le motif, jamais vide. * * \note L'ORDRE des branches porte les précédences (\rule{LM-1011} : `BELOW_MIN_POWER` * prime sur `DRAW_CAP` tant qu'il reste de l'autorisation). Le réordonner change le motif * publié **sans changer aucun nombre** — le genre de régression qu'une somme correcte * masque. */ DecisionReason choisirMotif(const MotifEntrees &e) const; /*! * \brief Ce qu'une passe a DÉCIDÉ pour une charge — avant que le motif ne soit formé. * * \par Pourquoi la décision et l'émission se séparent (§10, étape A2) * Deux passes devront contribuer à **une seule** commande (\rule{LM-1003}). Tant que * décider et émettre étaient le même geste, la seconde passe était littéralement * inexprimable : chaque décision produisait immédiatement une action. * * \warning Ce qui NE se déplace pas, et ne doit jamais l'être : \c vouluW est capturé * **avant** l'écrêtage de verrou (\c ECS-309, sinon le motif ne distingue plus « le * budget ne payait pas » de « le verrou a refusé »), et l'écrêtage précède le décrément * du budget (\c ECS-306, sinon les charges suivantes reçoivent un résidu surestimé). * Les deux vivent dans la phase de DÉCISION, et un déplacement d'un cran publierait des * motifs faux **sans qu'aucun nombre ne bouge**. */ struct SetpointTarget { LoadAction action; //!< Tout, sauf le motif quand il reste à choisir. bool motifAChoisir = false; //!< Faux pour les sorties anticipées, qui le portent déjà. MotifEntrees entrees; //!< Les grandeurs du choix, si \c motifAChoisir. }; /*! * \brief Décide la consigne d'une charge et consomme les ressources du cycle. * \return La cible, motif compris pour les sorties anticipées. * \note C'est ici, et nulle part ailleurs, que le budget et l'autorisation sont entamés. */ SetpointTarget decideSetpoint(const LoadContext &lc, double &remainingSurplusW, DrawMargin &margeRestante, PlanBudget &budget, PlanDraw &draw, bool recreditDejaAttribue = false) const; /*! * \brief Forme la commande à partir d'une cible décidée — et forme le motif. * \param t Cible issue de \c decideSetpoint(). * \return Le \c LoadAction émis, motif non vide. * \note Ne consomme aucune ressource : tout a été entamé à la décision. C'est ce qui rendra * possible d'émettre UNE commande à partir de DEUX passes. */ LoadAction emitSetpointAction(const SetpointTarget &t) const; /*! * \brief Pose le niveau « comfort » sur une action — POINT UNIQUE. * \param[in,out] la Action émise ; son niveau est ajouté depuis ses propres grandeurs. * \note Recopier cette pose à chaque site d'émission la ferait diverger au premier site * ajouté, et l'invariant Σ \c levels[].targetW == \c estimatedPowerW tomberait en * silence — une charge sans niveau publie simplement une liste vide. */ static void poserNiveauConfort(LoadAction &la); /*! * \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. * \param[in,out] budget Comptabilité publiée (\c allocatedW / \c recreditedW) — ici le * recrédit est la puissance DÉCLARÉE de l'état courant, pas une mesure. * \return LoadAction kind=State, funding=Surplus, \c reason non vide. */ LoadAction buildSgReadyStateAction(const LoadContext &lc, double &remainingSurplusW, PlanBudget &budget) const; EnergyArbitrator *m_arbitrator; // --- Réserve batterie : retenu d'un cycle pour le MOTIF (3g-1) ----------------------- //! Vrai quand le budget de surplus a été confisqué par la réserve de stockage. bool m_reserveActive = false; double m_reserveSocPct = 0; //!< SOC constaté au moment de la confiscation. double m_reserveSeuilPct = 0; //!< Seuil configuré, en pourcentage. //! Surplus qui EXISTAIT et n'a pas été distribué. C'est ce chiffre, pas le SOC, qui rend //! la cause lisible : « 3 200 W retenus » se comprend là où « batterie à 47 % » se subit. //! //! \warning **Il conditionne le motif, il ne l'accompagne pas.** Nul, la réserve n'a rien //! confisqué — l'installation importait, et les charges n'auraient rien reçu de toute //! façon. Publier \c BATTERY_RESERVE dans ce cas dirait « une règle vous bloque » là où //! la vérité est « il n'y a pas de surplus » : exactement la confusion que ce motif //! existe pour supprimer, retournée. Constaté à la PREMIÈRE observation en vrai, le //! 2026-08-26 — surplus à −3 555 W, motif publié, \c withheldW à 0. double m_reserveWithheldW = 0; };