diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 969bba8..e455823 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -181,7 +181,7 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) // Charges pilotables non-EV triées par priorité ASCENDANTE : rang 1 = premier servi // (OPTIMIZER_PROTOCOL §5 + annexe C — la priorité est un rang). Le budget de surplus est // UNIQUE et cascade à travers TOUTES ces charges par priorité : - // - etmvariableload (ECS/routeur, kind Setpoint W → buildSetpointAction, règle d'arrondi §5) ; + // - etmvariableload (ECS/routeur, kind Setpoint W → decideSetpoint/emitSetpointAction, règle d'arrondi §5) ; // - sg-ready (PAC, kind State → buildSgReadyStateAction, mapping sémantique). // Charges en WATTS (Setpoint) : etmvariableload (continu) ET relay-router (rév. 3, combinaison // de relais dérivée côté routeur). Charge à ÉTATS : sg-ready (PAC). Le scheduler distingue @@ -226,7 +226,8 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) if (lc.adapter == QStringLiteral("sg-ready")) slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta)); else - slot.actions.append(buildSetpointAction(lc, remainingSurplusW, margeRestante, compta, draw)); // etmvariableload + relay-router + evcharger + slot.actions.append(emitSetpointAction( + decideSetpoint(lc, remainingSurplusW, margeRestante, compta, draw))); // etmvariableload + relay-router + evcharger // Le décrément vaut pour TOUTES les charges servies, bornes et PAC comprises : elles // consomment la même autorisation, qu'elles aient été bornées par elle ou non. Ne @@ -339,7 +340,7 @@ DecisionReason RuleBasedScheduler::choisirMotif(const MotifEntrees &e) const return { DecisionCode::SurplusInsufficient, {{"budgetW", qRound(e.budgetW)}} }; } -LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, +RuleBasedScheduler::SetpointTarget RuleBasedScheduler::decideSetpoint(const LoadContext &lc, double &remainingSurplusW, DrawMargin &margeRestante, PlanBudget &budget, PlanDraw &draw) const @@ -381,7 +382,8 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, // plancher » enverrait chercher du soleil là où il faut regarder l'abonnement // (règle 7-b). Le budget n'est pas consommé : il passe entier à la suivante. if (lc.declared.minPowerW > 0 && maxW < lc.declared.minPowerW) { - LoadAction la; + SetpointTarget t; + LoadAction &la = t.action; // alias : les affectations ci-dessous ne bougent pas la.loadId = lc.id; la.kind = LoadAction::Setpoint; la.funding = LoadAction::Surplus; @@ -394,7 +396,7 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, la.reason = { DecisionCode::PhaseLimit, {{"limitW", qRound(maxW)}, {"requiredW", qRound(lc.declared.minPowerW)}} }; remainingSurplusW = budgetW; // rien d'engagé : le recrédit repart intact - return la; + return t; } setpointW = qBound(0.0, budgetW, maxW); @@ -430,7 +432,8 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, if (estUneBorne && tolerance > 0 && budgetW > 0 && budgetW >= plancherW * tolerance) { const double soutireW = plancherW - budgetW; - LoadAction la; + SetpointTarget t; + LoadAction &la = t.action; // alias : les affectations ci-dessous ne bougent pas la.loadId = lc.id; la.kind = LoadAction::Setpoint; // Le financement dit la VÉRITÉ sur l'origine : une partie de cette puissance @@ -460,7 +463,7 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, budget.allocatedW += budgetW; draw.committedW += soutireW; remainingSurplusW = 0; - return la; + return t; } } } else { @@ -545,7 +548,8 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, // Le clamp ci-dessous suffit donc à produire la bonne comptabilité, sans branche spéciale. if (!lc.telemetry.available) { const double gele = lc.telemetry.lockMinPowerW; - LoadAction la; + SetpointTarget t; + LoadAction &la = t.action; // alias : les affectations ci-dessous ne bougent pas la.loadId = lc.id; la.kind = LoadAction::Setpoint; la.funding = LoadAction::Surplus; @@ -558,7 +562,7 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, la.reason = { DecisionCode::LoadUnavailable, {{"frozenW", qRound(gele)}} }; remainingSurplusW = budgetW - gele; budget.allocatedW += gele; // une puissance qu'on ne sait plus couper reste allouée - return la; + return t; } // ECS-306 — clamp lock-aware AVANT de décrémenter le budget (AGENTS règle 4 : les @@ -576,7 +580,8 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, if (setpointW < lc.telemetry.lockMinPowerW) setpointW = lc.telemetry.lockMinPowerW; // puissance engagée, non-coupable - LoadAction la; + SetpointTarget t; + LoadAction &la = t.action; // alias : les affectations ci-dessous ne bougent pas la.loadId = lc.id; la.kind = LoadAction::Setpoint; la.funding = LoadAction::Surplus; @@ -601,13 +606,27 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, // cause. Constaté au banc le 2026-08-13, sept fois en quatre heures, toujours au cycle // suivant un palier 0. Les deux branches se lisent maintenant sur le même axe : // le verrou a relevé la consigne (minOn), ou il l'a rabaissée (minOff). - la.reason = choisirMotif({ setpointW, vouluW, budgetW, plancherW, - lc.telemetry.currentPowerW, margeRestante, - sousPlancher, capEpuise, dynamic }); + // §10 / étape A2 — la DÉCISION s'arrête ici. Le motif se choisit à l'émission, sur ces + // entrées-là : c'est ce qui permettra à deux passes de contribuer à UNE commande, chacune + // avec son propre motif (R7). Rien d'autre ne bouge — et surtout pas l'ordre au-dessus. + t.motifAChoisir = true; + t.entrees = { setpointW, vouluW, budgetW, plancherW, + lc.telemetry.currentPowerW, margeRestante, + sousPlancher, capEpuise, dynamic }; // Résidu : budget − consigne engagée → charge suivante de la priorité (même cycle). remainingSurplusW = budgetW - setpointW; budget.allocatedW += setpointW; + return t; +} + +LoadAction RuleBasedScheduler::emitSetpointAction(const SetpointTarget &t) const +{ + LoadAction la = t.action; + // Les sorties anticipées (PHASE_LIMIT, EV_GRID_START, LOAD_UNAVAILABLE) portent DÉJÀ leur + // motif : il n'y a rien à choisir, la cause est connue au moment où la décision s'arrête. + if (t.motifAChoisir) + la.reason = choisirMotif(t.entrees); return la; } @@ -623,7 +642,7 @@ LoadAction RuleBasedScheduler::buildSgReadyStateAction(const LoadContext &lc, // revient au budget — comme l'ECS. Base : "quel surplus si la PAC n'était pas pilotée ?" // 3g-2 — même exception que pour les charges en watts : sous la réserve, pas de recrédit. // Une PAC en état 3 ou 4 y resterait sinon indéfiniment, la réserve n'agissant que sur les - // montées d'état. Voir la note détaillée dans buildSetpointAction(). + // montées d'état. Voir la note détaillée dans decideSetpoint(). const double allocatedNowW = m_reserveActive ? 0.0 : lc.declared.estimatedPowerW.value(currentState, 0.0); diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.h b/energyplugin/etm/scheduler/rulebasedscheduler.h index 892674d..32e3d51 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.h +++ b/energyplugin/etm/scheduler/rulebasedscheduler.h @@ -93,7 +93,7 @@ private: * 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. + * \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 @@ -128,10 +128,45 @@ private: */ DecisionReason choisirMotif(const MotifEntrees &e) const; - LoadAction buildSetpointAction(const LoadContext &lc, double &remainingSurplusW, + /*! + * \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 Construit un LoadAction "state" SG-Ready (PAC) par mapping SÉMANTIQUE du surplus. *