Le surplus dit ce qu'on peut dépenser sans acheter ; l'autorisation de soutirage dit ce que le branchement supporte. Une charge doit tenir dans les deux. Jusqu'ici seules les bornes VE étaient bornées par la seconde — une charge de waterfall pouvait donc créer un dépassement que seule la protection L4 rattrapait, après coup. C'est ce qui change. `SurplusContext::drawMargin` est une valeur de CYCLE, résolue une fois par `houseDrawMargin()` et jamais relue : deux lectures à deux instants feraient décider sur deux mondes. Chaque charge servie l'entame. Le motif porte les quatre grandeurs gravées au §6.3 : la marge (SIGNÉE), l'ampleur du dépassement, la source, et la phase qui borne. La phase n'est publiée que s'il y en a une — un plafond total n'en a pas, et en annoncer une enverrait mesurer au hasard. Précédence LM-1011 appliquée telle quelle : DRAW_CAP seulement à autorisation NULLE, sinon BELOW_MIN_POWER. Les bornes VE ne sont pas écrêtées par la marge de la maison, et ce n'est pas un oubli : `evPhaseAllowanceW()` connaît LES PHASES QU'ELLES OCCUPENT. Leur appliquer en plus la pire phase les priverait d'une phase saine parce qu'une autre sature. Entre deux bornes justes on garde la mieux informée, pas la plus sévère. Elles décrémentent la marge comme les autres. LIMITE DE MODÈLE assumée (LM-1210-a) : `LoadDeclared::phases` est un nombre, pas un masque — le moteur ignore sur quelle phase une charge est câblée. La pire phase est donc retenue. Deviner, dans une couche de sécurité, se paie au disjoncteur. Le pessimisme est borné en pratique et coûte du rendement, jamais de la sécurité ; le lever demande un masque déclaré, donc config, interface et migration. Documenté dans la spec et le design, à trancher. Suite complète : simulation 52/52, charging 48/48 identique à la référence ligne à ligne — les bornes ne bougent pas, c'est l'invariant du lot — loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement. Contre-épreuve : bornage débranché, la charge est servie 1000 W alors que l'autorisation est déjà dépassée de 460 W. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
141 lines
8.2 KiB
C++
141 lines
8.2 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 LoadAction kind=Setpoint, funding=Surplus, \c powerW = consigne, \c reason non vide.
|
||
*/
|
||
//! \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.
|
||
LoadAction buildSetpointAction(const LoadContext &lc, double &remainingSurplusW,
|
||
DrawMargin &margeRestante,
|
||
PlanBudget &budget, PlanDraw &draw) 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;
|
||
};
|