refactor(§10 étape A2): décider et émettre se séparent, sans rien déplacer

Tant que décider et émettre étaient le même geste, une seconde passe était
littéralement inexprimable : chaque décision produisait immédiatement une
action, et le budget se décrémentait dans le même mouvement.
`decideSetpoint()` rend désormais une CIBLE ; `emitSetpointAction()` en forme
la commande et le motif.

La contrainte du lot est tenue et vérifiée sur le fichier produit : vouluW
est capturé AVANT l'écrêtage de verrou (ECS-309), l'écrêtage précède le
décrément du budget (ECS-306), et les deux restent dans la phase de
DÉCISION. Un déplacement d'un cran publierait des motifs faux sans qu'aucun
nombre ne bouge.

Le remaniement est fait par ALIAS DE RÉFÉRENCE — `LoadAction &la = t.action`
— précisément pour que les vingt lignes d'affectation ne soient pas
retouchées. Ce qui n'est pas réécrit ne peut pas dériver. Et le typage a
servi de garde-fou : `SetpointTarget` n'étant pas `LoadAction`, un `return`
converti au mauvais endroit n'aurait pas compilé.

Les trois sorties anticipées (PHASE_LIMIT, EV_GRID_START, LOAD_UNAVAILABLE)
portent déjà leur motif : la cause est connue au moment où la décision
s'arrête, il n'y a rien à choisir.

Suite complète : simulation 54/54, charging 48/48 identique à la référence
ligne à ligne, loadmodel 21/21, spotmarket 32/32.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
This commit is contained in:
Patrick Schurig 2026-08-30 23:59:52 +02:00
parent b2e1cf208d
commit 9fcc66fe0e
2 changed files with 70 additions and 16 deletions

View File

@ -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);

View File

@ -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.
*