refactor(§10 étape A1): le choix du motif devient une fonction pure

Préalable au reste, et il n'ajoute aucune fonction. La chaîne de sélection
du motif vivait au milieu de l'émission : il n'y avait qu'un motif possible
parce qu'il n'y avait qu'un endroit pour le former. Chaque passe devra
choisir SON motif sur SES grandeurs (R7 — le motif dit de quel niveau il
parle), donc la chaîne sort avant que la seconde passe n'arrive.

`MotifEntrees` regroupe exactement ce dont le choix dépend : ce qui n'y
figure pas ne peut pas influencer le motif, et c'est vérifiable à l'œil.

Comportement CONSTANT, et vérifié comme tel : la fonction extraite est
IDENTIQUE TOKEN POUR TOKEN à la chaîne d'origine, comparée par
normalisation. L'ordre des branches est ce qui porte les précédences
(LM-1011 : BELOW_MIN_POWER prime sur DRAW_CAP tant qu'il reste de
l'autorisation) — le réordonner changerait le motif publié sans qu'aucun
nombre ne bouge.

Deux erreurs attrapées en chemin, toutes deux par relecture :
  - la substitution avait préfixé les CLÉS de charge utile ("budgetW" →
    "e.budgetW"), soit un changement de contrat silencieux ;
  - un `else if` transformé en `if` : sans effet ici puisque chaque branche
    retourne, mais une divergence gratuite dans une fonction dont l'ordre
    des branches est la spécification.

Et un piège de HARNAIS, sans rapport avec l'étape mais qui la bloquait :
testL4ShedsAPacAndSaysItForcedTheLock échouait 5 fois sur 5 alors qu'il
passait la veille, à code identique. Ni le code sous test, ni une fragilité
de temporisation — un cycle d'arbitrage tourne à l'horloge MURALE avant le
premier cycle simulé (sonde : action SG_NORMAL estampillée « 22:52:07 »).
L'armement paresseux d'ECS-412 fige m_lastSwitch sur cette heure, et un
scénario daté en dur se déroule « avant » son propre armement : elapsed
négatif, verrou jamais ouvert. Le test passait ou échouait SELON L'HEURE
QU'IL EST, ce qui est pire que les deux, et c'est aussi l'explication du
remainingS: 1288 observé plus tôt sans qu'on sache le lire.

Corrigé en ancrant l'origine du scénario sur l'horloge réelle : la
chronologie relative suffit, l'origine doit vivre dans la même horloge que
ce qui touche le système. Le commentaire dit à quelle condition ce choix
devient faux, conformément au troisième mode d'érosion.

Reste ouvert et consigné : pourquoi un cycle complet s'exécute à l'horloge
murale dans une suite compilée avec ENERGY_SIMULATION, où les minuteurs sont
explicitement exclus. Pas un défaut de production — une seule horloge y
existe — mais une faille de reproductibilité pour tout test manipulant un
Thing.

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

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 22:58:54 +02:00
parent 2a584de913
commit b2f19443fc
4 changed files with 145 additions and 51 deletions

View File

@ -78,3 +78,34 @@ zéro**, et **ça ne se déduisait pas du code**.
le genre d'erreur qui produit un nombre crédible et faux, et que rien ne signalerait.
L'app a corrigé sa maquette et son modèle en conséquence.
---
## ANNEXE — un piège de harnais, découvert en faisant l'étape A du §10
*(2026-08-30, en cherchant pourquoi un test vert la veille échouait déterministiquement.)*
`testL4ShedsAPacAndSaysItForcedTheLock` échouait **cinq fois sur cinq**, et la cause n'était ni
le code sous test ni une fragilité de temporisation : **un cycle d'arbitrage tourne à l'horloge
MURALE avant le premier cycle simulé.** Relevé par sonde : la PAC reçoit une action `SG_NORMAL`
estampillée `22:52:07` — l'heure qu'il était.
L'armement paresseux d'`ECS-412` fige alors `m_lastSwitch` sur cette heure. Un scénario daté en
dur (13:00 simulé) se déroule donc **« avant » son propre armement** : `elapsed` est négatif, le
verrou ne s'ouvre jamais, la PAC n'atteint pas l'état 4, et la prémisse du test s'effondre.
> **Le test passait ou échouait selon l'heure de la journée** — ce qui est pire que les deux, et
> ce qui explique aussi le `remainingS: 1288` observé plus tôt sans qu'on sache l'expliquer.
**Correctif retenu** : ancrer l'origine du scénario sur `QDateTime::currentDateTimeUtc()`. La
chronologie relative est tout ce dont il a besoin ; l'origine doit vivre dans la **même horloge
que ce qui le touche**. Le commentaire dit à quelle condition ce choix devient faux — le jour où
l'arbitrage ne peut plus tourner à l'horloge murale dans la suite, une date fixe redevient
préférable parce qu'elle rend les journaux reproductibles.
**Ce qui reste ouvert, et ne se corrige pas dans un lot de test** : *pourquoi* un cycle
d'arbitrage complet s'exécute-t-il à l'horloge murale dans une suite compilée avec
`ENERGY_SIMULATION`, alors que les minuteurs et les connexions temps réel y sont explicitement
exclus (`energyarbitrator.cpp:52-75`) ? Un changement d'état de Thing suffit à le déclencher. Ce
n'est **pas** un défaut de production — il n'y a là-bas qu'une seule horloge — mais c'est une
faille de reproductibilité qui touchera **tout test de simulation manipulant un Thing**.

View File

@ -274,6 +274,71 @@ LoadAction RuleBasedScheduler::buildTimeRequirementAction(EvCharger *ev,
return la;
}
DecisionReason RuleBasedScheduler::choisirMotif(const MotifEntrees &e) const
{
// ECS-309 — le motif nomme le MÉCANISME qui a produit l'issue, jamais un mécanisme
// plausible. La règle 7 d'AGENTS.md exige un motif non vide ; celle-ci exige qu'il soit
// vrai. Un motif faux envoie diagnostiquer le mauvais problème, avec l'autorité d'une
// explication — constaté au banc le 2026-08-09 : palier 0 avec 7706 W disponibles, et
// « Surplus insuffisant (7706 W) » publié.
//
// §10 / étape A — extraite en FONCTION PURE, et c'est le préalable au reste. Chaque passe
// devra choisir SON motif sur SES grandeurs (R7 : le motif dit de quel niveau il parle) ;
// tant que la chaîne vivait au milieu de l'émission, il n'y avait qu'un motif possible
// parce qu'il n'y avait qu'un endroit pour le former. L'ordre des branches est conservé
// au branchement près : c'est lui qui porte les précédences (LM-1011 notamment).
if (e.setpointW > e.vouluW)
return { DecisionCode::LockMinOn,
{{"appliedW", qRound(e.setpointW)}, {"budgetW", qRound(e.budgetW)}} };
else if (e.setpointW < e.vouluW)
// Le budget payait vouluW ; c'est la fenêtre minOff qui interdit de monter. Le motif
// cite le budget DISPONIBLE, précisément pour couper court à l'explication budgétaire.
return e.setpointW <= 0
? DecisionReason(DecisionCode::LockMinOff,
{{"budgetW", qRound(e.budgetW)}, {"requestedW", qRound(e.vouluW)}})
: DecisionReason(DecisionCode::LockMinOffCapped,
{{"appliedW", qRound(e.setpointW)}, {"budgetW", qRound(e.budgetW)},
{"requestedW", qRound(e.vouluW)}});
else if (e.setpointW > 0)
// « stepped » remplace le suffixe « (palier) » composé en dur : le client décide
// comment il le dit, la box dit seulement que la consigne est arrondie sur un palier.
return { DecisionCode::SurplusSetpoint,
{{"budgetW", qRound(e.budgetW)}, {"setpointW", qRound(e.setpointW)},
{"stepped", !e.dynamic}} };
else if (m_reserveActive && (m_reserveWithheldW + e.consoW) > 0)
// 3g-1 — il Y A du surplus, et une règle l'interdit. À ne pas confondre avec
// « il n'y en a pas » : le geste attendu n'est pas le même, et le second envoie
// chercher une panne inexistante.
//
// 3g-2 — ce que la réserve retient à CETTE charge a deux termes, et il faut les deux :
// le surplus annulé (le même pour toutes), ET sa propre consommation, que le recrédit
// lui aurait rendue. Sans le second, une charge en marche coupée par la réserve sur un
// surplus nul publiait « surplus insuffisant » — vrai sur le surplus, faux sur la
// cause : sans la réserve, elle aurait continué de tourner.
return { DecisionCode::BatteryReserve,
{{"socPercent", qRound(m_reserveSocPct)},
{"reservePercent", qRound(m_reserveSeuilPct)},
{"withheldW", qRound(m_reserveWithheldW + e.consoW)}} };
else if (e.capEpuise) {
// L'autorisation de soutirage est épuisée. Le motif porte la marge, la source,
// l'ampleur du dépassement et la phase qui borne — chacune commande un geste
// différent, et aucune ne se déduit des autres.
// shed = FAUX : ici la cascade REFUSE d'allouer, elle ne retire rien à personne. Le
// même code de motif porte les deux gestes, et le booléen les distingue — le déduire
// de la présence de `shedW` marcherait aujourd'hui et casserait à la première clé
// ajoutée.
return { DecisionCode::DrawCap,
drawCapReasonParams(e.marge, false, 0, false) };
}
else if (e.sousPlancher)
// §12 — dire QUE le budget est sous le plancher, pas qu'il est insuffisant : la
// seconde phrase enverrait chercher du soleil là où il faut lire une fiche technique.
return { DecisionCode::BelowMinPower,
{{"budgetW", qRound(e.budgetW)}, {"minPowerW", qRound(e.plancherW)}} };
else
return { DecisionCode::SurplusInsufficient, {{"budgetW", qRound(e.budgetW)}} };
}
LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
double &remainingSurplusW,
DrawMargin &margeRestante,
@ -536,56 +601,9 @@ 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).
if (setpointW > vouluW)
la.reason = { DecisionCode::LockMinOn,
{{"appliedW", qRound(setpointW)}, {"budgetW", qRound(budgetW)}} };
else if (setpointW < vouluW)
// Le budget payait vouluW ; c'est la fenêtre minOff qui interdit de monter. Le motif
// cite le budget DISPONIBLE, précisément pour couper court à l'explication budgétaire.
la.reason = setpointW <= 0
? DecisionReason(DecisionCode::LockMinOff,
{{"budgetW", qRound(budgetW)}, {"requestedW", qRound(vouluW)}})
: DecisionReason(DecisionCode::LockMinOffCapped,
{{"appliedW", qRound(setpointW)}, {"budgetW", qRound(budgetW)},
{"requestedW", qRound(vouluW)}});
else if (setpointW > 0)
// « stepped » remplace le suffixe « (palier) » composé en dur : le client décide
// comment il le dit, la box dit seulement que la consigne est arrondie sur un palier.
la.reason = { DecisionCode::SurplusSetpoint,
{{"budgetW", qRound(budgetW)}, {"setpointW", qRound(setpointW)},
{"stepped", !dynamic}} };
else if (m_reserveActive && (m_reserveWithheldW + lc.telemetry.currentPowerW) > 0)
// 3g-1 — il Y A du surplus, et une règle l'interdit. À ne pas confondre avec
// « il n'y en a pas » : le geste attendu n'est pas le même, et le second envoie
// chercher une panne inexistante.
//
// 3g-2 — ce que la réserve retient à CETTE charge a deux termes, et il faut les deux :
// le surplus annulé (le même pour toutes), ET sa propre consommation, que le recrédit
// lui aurait rendue. Sans le second, une charge en marche coupée par la réserve sur un
// surplus nul publiait « surplus insuffisant » — vrai sur le surplus, faux sur la
// cause : sans la réserve, elle aurait continué de tourner.
la.reason = { DecisionCode::BatteryReserve,
{{"socPercent", qRound(m_reserveSocPct)},
{"reservePercent", qRound(m_reserveSeuilPct)},
{"withheldW", qRound(m_reserveWithheldW + lc.telemetry.currentPowerW)}} };
else if (capEpuise) {
// L'autorisation de soutirage est épuisée. Le motif porte la marge, la source,
// l'ampleur du dépassement et la phase qui borne — chacune commande un geste
// différent, et aucune ne se déduit des autres.
// shed = FAUX : ici la cascade REFUSE d'allouer, elle ne retire rien à personne. Le
// même code de motif porte les deux gestes, et le booléen les distingue — le déduire
// de la présence de `shedW` marcherait aujourd'hui et casserait à la première clé
// ajoutée.
la.reason = { DecisionCode::DrawCap,
drawCapReasonParams(margeRestante, false, 0, false) };
}
else if (sousPlancher)
// §12 — dire QUE le budget est sous le plancher, pas qu'il est insuffisant : la
// seconde phrase enverrait chercher du soleil là où il faut lire une fiche technique.
la.reason = { DecisionCode::BelowMinPower,
{{"budgetW", qRound(budgetW)}, {"minPowerW", qRound(plancherW)}} };
else
la.reason = { DecisionCode::SurplusInsufficient, {{"budgetW", qRound(budgetW)}} };
la.reason = choisirMotif({ 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;

View File

@ -98,6 +98,36 @@ private:
//! \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 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;
LoadAction buildSetpointAction(const LoadContext &lc, double &remainingSurplusW,
DrawMargin &margeRestante,
PlanBudget &budget, PlanDraw &draw) const;

View File

@ -6497,7 +6497,22 @@ void Simulation::testL4ShedsAPacAndSaysItForcedTheLock()
return telemetrieParId(arb->loadTelemetry()).value("pac-delestee");
};
const QDateTime t0 = utcDateTime(QDate(2026, 8, 30), QTime(13, 0, 0));
// ⚠️ ANCRÉ SUR L'HORLOGE RÉELLE, et c'est délibéré — mesuré le 2026-08-30.
//
// Un cycle d'arbitrage se déclenche à l'horloge MURALE avant le premier cycle simulé (relevé
// par sonde : la PAC voit une action SG_NORMAL estampillée « 22:52:07 »). L'armement
// paresseux d'ECS-412 fige alors `m_lastSwitch` sur cette heure-là. Un scénario daté en dur
// se déroule donc « avant » son propre armement : `elapsed` est NÉGATIF, le verrou ne
// s'ouvre jamais, et la PAC n'atteint pas l'état 4 — le test échoue ou passe selon l'HEURE
// QU'IL EST, ce qui est pire que les deux.
//
// La chronologie relative est tout ce dont le scénario a besoin ; l'origine, elle, doit
// vivre dans la même horloge que ce qui le touche.
//
// **Ce test devient faux le jour où l'arbitrage cesse de pouvoir tourner à l'horloge murale
// dans la suite** — alors l'ancrage redevient inutile et une date fixe est préférable, parce
// qu'elle rend les journaux reproductibles.
const QDateTime t0 = QDateTime::currentDateTimeUtc();
meter->setStateValue("currentPhaseA", 0);
meter->setStateValue("currentPhaseB", 0);
meter->setStateValue("currentPhaseC", 0);