La frontière FABRIQUAIT le niveau à partir de l'action : elle ne pouvait donc en produire qu'un, puisqu'il n'y avait qu'un endroit pour le former. Le plan les porte désormais (`LoadAction::Level`), et la télémétrie les traduit au lieu de les décider — une frontière qui invente une structure finit par diverger de ce que le moteur a réellement décidé. Comportement CONSTANT : un seul niveau « comfort » aujourd'hui, et les quatre suites sont vertes avec charging identique à la référence ligne à ligne. Le test qui exige un `levels[]` non vide a trouvé un site d'émission oublié — SG-Ready passe par un autre chemin que les consignes en watts. D'où un POINT UNIQUE de pose (`poserNiveauConfort`) : recopier la pose à chaque site la ferait diverger au premier site ajouté, et l'invariant Σ levels[].targetW == estimatedPowerW tomberait en silence, une charge sans niveau publiant simplement une liste vide. ECO_FLOOR_GRID rend lisible la TRAJECTOIRE, pas l'instant. La cadence de l'étalement ne monte pas, elle fait un mur : 333 W à six heures de l'échéance, le plafond dès seize minutes. Un client qui lit « 333 W achetés » ne peut pas deviner qu'il paiera dix fois plus dans cinq heures si le soleil ne vient pas. Le motif porte donc gridW, remainingWh, targetWh, remainingS, le régime, et atMaximum — les deux derniers disant respectivement sur quoi on achète et si l'étalement a cessé. ECO_FLOOR_MET est INTERDIT sous `unmeasurable` (R6) : dire « tenue » suppose de savoir ce qui a été livré. Une échéance qui cesse d'être mesurable et continue de s'afficher comme tenue est pire qu'une échéance absente — elle rassure. LM-1014-b généralise les deux défauts de cumul de ces deux jours : toute grandeur cumulée rapportée à une période doit dire ce qu'elle fait au changement de période. La question à poser une fois, à l'écriture : que vaut-elle à la seconde qui suit la bascule ? Ne pas choisir revient à choisir « elle continue », presque toujours faux et toujours silencieux. Suite : loadmodel 22/22, simulation 56/56, charging 48/48 identique, 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
215 lines
12 KiB
C++
215 lines
12 KiB
C++
// 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 \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[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 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) 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;
|
||
};
|