diff --git a/docs/RELEVE_APP_20260831.md b/docs/RELEVE_APP_20260831.md index eeff77a..ff0e7c9 100644 --- a/docs/RELEVE_APP_20260831.md +++ b/docs/RELEVE_APP_20260831.md @@ -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**. diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 7115fd3..969bba8 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -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; diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.h b/energyplugin/etm/scheduler/rulebasedscheduler.h index c0cc17e..892674d 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.h +++ b/energyplugin/etm/scheduler/rulebasedscheduler.h @@ -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; diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index fe3a8a4..a514a3a 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -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);