La transplantation. Les bornes partagent le budget, décrémentent la cascade et portent un rang qui a enfin un sens — il était décoratif depuis le début. Le défaut corrigé n'était pas théorique : planSurplusCharging() calculait l'allocation une fois avant la boucle et ne la décrémentait jamais. Chaque borne recevait la totalité du surplus. Avec 4 kW et deux bornes, ce n'était pas « 2 kW à chacune » mais « 4 kW à chacune », et le rattrapage venait du compteur au cycle suivant, pas de l'arbitrage. Trois motifs EV disparaissent — des suppressions, pas des remplacements. EV_ECO_MIN en particulier : le courant minimum était la même idée que le plancher du §12, écrite une seconde fois pour un seul mécanisme. Le nombre de phases est décidé AVANT l'allocation et réutilisé à l'exécution, parce que c'est lui qui fixe le plancher — 6 A valent 1,4 kW en mono et 4,1 kW en tri. C'était le piège le plus silencieux du lot. evReservedW est restreint au financement réseau plutôt que supprimé : ce qui reste décidé en amont doit rester réservé, ce qui passe par la cascade ne le doit plus. Et une correction attrapée à la première observation en vrai de BATTERY_RESERVE : il était publié avec withheldW à 0 sur un surplus négatif, donc il disait « une règle vous bloque » là où il n'y avait simplement rien. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
145 lines
8.0 KiB
C++
145 lines
8.0 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 "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.
|
||
* \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 LoadAction kind=Setpoint, funding=Surplus, \c powerW = consigne, \c reason non vide.
|
||
*/
|
||
LoadAction buildSetpointAction(const LoadContext &lc, double &remainingSurplusW,
|
||
PlanBudget &budget) 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.
|
||
* \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;
|
||
};
|