Le recrédit rendait au budget la conso d'une charge sur l'hypothèse qu'on pourra la lui reprendre. Contre un refus établi, les watts prêtés étaient réalloués aux suivantes pendant que la charge continuait de les tirer : 4 000 W alloués pour 500 W de surplus réel. Suspension faite, la charge suivante ne reçoit plus que les 500 W qui existent. Le seuil vient du MÉCANISME, jamais d'une constante. Un écart récent est une latence — une charge qui vient d'être commandée n'a pas atteint sa consigne, et suspendre là ferait osciller ce qui va bien. Latences mesurées sur la V2C : montée ~30 s, descente ~7 s. L'asymétrie joue dans le bon sens, le refus qui coûte de l'argent étant celui de la descente. Défaut prudent à 60 s : l'oscillation d'une charge saine est un coût certain, le refus n'est qu'un risque. Et ça se dit — RECREDIT_SUSPENDED, avec depuis quand et combien de watts retenus. Une suspension change l'allocation de toutes les charges suivantes ; sans le motif, on chercherait un défaut de cascade là où une charge n'obéit pas. Et une cinquième classe dans AGENTS.md, la seule qu'aucun contrôle ne pouvait attraper : une PRÉMISSE FAUSSE SOUS UNE ARITHMÉTIQUE EXACTE. Les quatre autres se laissent prendre par un instrument — un champ qui ment, un retour indiscernable de son contraire, un instrument muet, une assertion vacante. Celle-ci n'a aucune trace : l'identité publiée tient exactement, que la charge obéisse ou non. La parade tient en une phrase : une identité qui tient ne prouve pas que le modèle est juste, seulement qu'il est cohérent. Ce qu'une identité ne peut jamais attraper, c'est une hypothèse sur le monde. D'où la question à poser à chaque identité publiée : sur quoi repose-t-elle qui ne soit pas dans ses propres termes ? Ici, sur l'obéissance — grandeur absente de la formule, qu'il a fallu aller mesurer ailleurs pour la rendre vérifiable. Suite : simulation 60/60, loadmodel 22/22, charging 48/48 identique à la référence, spotmarket 32/32, doxygen 0 avertissement. Contre-épreuve : suspension débranchée, le recrédit revient à 4 400 W. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
226 lines
13 KiB
C++
226 lines
13 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 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;
|
||
};
|