feat: le recrédit est conditionné à l'obéissance constatée

Le recrédit rendait au budget la conso d'une charge sur l'hypothèse qu'on
pourra la lui reprendre. Contre un refus établi, les watts prêtés étaient
réalloués aux suivantes pendant que la charge continuait de les tirer :
4 000 W alloués pour 500 W de surplus réel. Suspension faite, la charge
suivante ne reçoit plus que les 500 W qui existent.

Le seuil vient du MÉCANISME, jamais d'une constante. Un écart récent est une
latence — une charge qui vient d'être commandée n'a pas atteint sa consigne,
et suspendre là ferait osciller ce qui va bien. Latences mesurées sur la
V2C : montée ~30 s, descente ~7 s. L'asymétrie joue dans le bon sens, le
refus qui coûte de l'argent étant celui de la descente. Défaut prudent à
60 s : l'oscillation d'une charge saine est un coût certain, le refus n'est
qu'un risque.

Et ça se dit — RECREDIT_SUSPENDED, avec depuis quand et combien de watts
retenus. Une suspension change l'allocation de toutes les charges suivantes ;
sans le motif, on chercherait un défaut de cascade là où une charge n'obéit
pas.

Et une cinquième classe dans AGENTS.md, la seule qu'aucun contrôle ne pouvait
attraper : une PRÉMISSE FAUSSE SOUS UNE ARITHMÉTIQUE EXACTE. Les quatre
autres se laissent prendre par un instrument — un champ qui ment, un retour
indiscernable de son contraire, un instrument muet, une assertion vacante.
Celle-ci n'a aucune trace : l'identité publiée tient exactement, que la
charge obéisse ou non.

La parade tient en une phrase : une identité qui tient ne prouve pas que le
modèle est juste, seulement qu'il est cohérent. Ce qu'une identité ne peut
jamais attraper, c'est une hypothèse sur le monde. D'où la question à poser à
chaque identité publiée : sur quoi repose-t-elle qui ne soit pas dans ses
propres termes ? Ici, sur l'obéissance — grandeur absente de la formule,
qu'il a fallu aller mesurer ailleurs pour la rendre vérifiable.

Suite : simulation 60/60, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement. Contre-épreuve :
suspension débranchée, le recrédit revient à 4 400 W.

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-09-01 16:55:47 +02:00
parent 3eb59e86f9
commit 2c4d04a53f
13 changed files with 249 additions and 5 deletions

View File

@ -862,6 +862,33 @@ aucune, la décision est une intention, et elle se périmera à la vitesse du co
> d'exploiter une valeur de retour qu'aucun code n'exploitait, **l'épingler d'abord par un
> test** — le premier appelant est aussi le premier vérificateur.
> ### Une CINQUIÈME classe, et c'est la seule qu'aucun contrôle ne pouvait attraper (2026-09-01)
>
> **Une prémisse fausse sous une arithmétique exacte.**
>
> Les quatre autres se laissent prendre par un instrument : un champ qui ment (`ECS-111`), une
> valeur de retour indiscernable de son contraire (`ECS-415`), un instrument muet (le
> déclencheur à l'horloge murale), une assertion vacante (la contre-épreuve inerte). Toutes ont
> une trace — quelque chose, quelque part, dit le faux ou ne dit rien.
>
> Celle-ci n'en a aucune. Le recrédit anti-clignotement rend au budget la consommation d'une
> charge, **sur l'hypothèse qu'on pourra la lui reprendre**. Si la charge n'obéit pas, la maison
> importe 3,5 kW que personne n'a décidés — et pourtant l'identité publiée,
> `surplus + recrédit − alloué == restant`, **tient exactement**, dans les deux cas.
>
> **Aucun contrôle de cohérence ne pouvait l'attraper, et aucun n'aurait pu.** L'arithmétique
> est juste ; c'est l'hypothèse sur le monde qui ne l'est pas.
>
> **La parade, et elle se retient en une phrase :**
> **une identité qui tient ne prouve pas que le modèle est juste, seulement qu'il est cohérent.
> Ce qu'une identité ne peut jamais attraper, c'est une hypothèse sur le monde.**
>
> D'où la question à poser à chaque identité qu'on publie : **sur quoi repose-t-elle qui ne soit
> pas dans ses propres termes ?** Ici, sur l'obéissance de la charge — grandeur qui n'apparaît
> nulle part dans la formule, et qu'il a fallu aller mesurer ailleurs (`command.divergent`) pour
> la rendre vérifiable. Une hypothèse ne se teste pas par recoupement interne : elle se teste
> en la confrontant au monde, ou elle ne se teste pas.
> **Piège de build, 2026-09-01** : changer la DISPOSITION d'une structure dans un en-tête inclus
> transitivement (ici un `QDateTime` ajouté à `EcoProgress`, atteint par `LoadAction`) peut
> laisser des objets compilés contre l'ancienne. Le symptôme est un **plantage dans un

View File

@ -1021,6 +1021,7 @@ le **libellé de la charge n'y figure jamais** (le client le résout via `GetLoa
| `ECO_FLOOR_GRID` *(§10)* | `gridW`, `remainingWh`, `targetWh`, `remainingS`, `regime`, `atMaximum` | L'obligation éco est servie **en achetant**. Les six champs donnent la **trajectoire**, pas l'instant : la cadence de l'étalement fait un mur — 2 kWh restants valent 333 W à six heures de l'échéance et **le plafond dès seize minutes**. « 1000 W achetés » seul ne permet pas de prévoir la facture ; avec `remainingWh` et `remainingS`, si. `atMaximum` dit que l'étalement a cessé (point de non-retour), `regime` sur quoi on achète |
| `ECO_FLOOR_MET` *(§10)* | `deliveredWh`, `targetWh` | L'obligation est **tenue**. **INTERDIT sous `regime: unmeasurable`** (R6) : dire « tenue » suppose de savoir ce qui a été livré. Une échéance qui cesse d'être mesurable et continue de s'afficher comme tenue est pire qu'une échéance absente — elle rassure |
| `ECO_FLOOR_MISSED` *(§10)* | `targetWh`, `cause`, `o:missingWh` | Une période d'obligation s'est close **sans avoir été tenue**. `cause` ∈ `drawCap` \| `loadCapacity` \| `unmeasurable` \| `unavailable` — **trois causes, trois gestes** : revoir l'abonnement, lire la fiche technique de la charge, ou regarder ce que la borne publie. Sans elle, l'écran n'affiche qu'un reproche sans recours. `missingWh` est **absent** sous `unmeasurable` : on ne sait pas ce qui a été livré, donc pas ce qui manquait — un zéro se lirait « il ne manquait rien ». Motif d'ÉVÉNEMENT : publié le seul cycle de la bascule |
| `RECREDIT_SUSPENDED` | `refusedSinceS`, `withheldW` | Le recrédit anti-clignotement est **suspendu** : la charge ne suit pas la commande depuis plus longtemps que la latence de son mécanisme. Les watts ne sont **pas rendus au budget**, donc **toutes les charges suivantes reçoivent moins** — et sans ce motif, on chercherait un défaut de cascade là où une charge n'obéit pas. Un écart RÉCENT est une latence, pas un refus : le seuil vient du mécanisme (V2C mesurée : montée ~30 s, descente ~7 s), jamais d'une constante |
> ### `remainingWh` sous `unmeasurable` : **la cible INTACTE**, et il faut le lire ainsi
>

View File

@ -1,3 +1,35 @@
powersync-energy-plugin-nymea (1.15.2+etm46) trixie; urgency=medium
* LE RECRÉDIT EST CONDITIONNÉ À L'OBÉISSANCE CONSTATÉE. Il rendait au budget la conso d'une
charge sur l'hypothèse qu'on pourra la lui reprendre ; contre un refus établi, les watts
prêtés étaient réalloués aux suivantes pendant que la charge continuait de les tirer.
Mesuré : 4 000 W alloués pour 500 W de surplus réel. Suspension faite, B ne reçoit plus que
les 500 W qui existent.
* LE SEUIL VIENT DU MÉCANISME, jamais d'une constante — `ILoadAdapter::commandSettleS()`. Un
écart RÉCENT est une latence : une charge qui vient d'être commandée n'a pas atteint sa
consigne, et suspendre là ferait osciller ce qui va bien. Latences MESURÉES sur la V2C :
montée ~30 s, descente ~7 s. L'asymétrie joue dans le bon sens — le refus qui coûte de
l'argent est celui de la descente, et c'est le plus vite constaté. Défaut prudent à 60 s :
l'oscillation d'une charge saine est un coût certain, le refus n'est qu'un risque.
* ET ÇA SE DIT : motif `RECREDIT_SUSPENDED` avec `refusedSinceS` et `withheldW`. Une
suspension change l'allocation de TOUTES les charges suivantes ; sans le motif, on
chercherait un défaut de cascade là où une charge n'obéit pas.
* AGENTS.md — UNE CINQUIÈME CLASSE, et la seule qu'aucun contrôle ne pouvait attraper : une
PRÉMISSE FAUSSE SOUS UNE ARITHMÉTIQUE EXACTE. Les quatre autres se laissent prendre par un
instrument ; celle-ci n'a aucune trace — `surplus + recrédit − alloué == restant` tient
exactement, que la charge obéisse ou non. La parade : une identité qui tient ne prouve pas
que le modèle est juste, seulement qu'il est cohérent. Ce qu'une identité ne peut jamais
attraper, c'est une hypothèse sur le monde. D'où la question à poser à chaque identité
publiée : sur quoi repose-t-elle qui ne soit pas dans ses propres termes ?
* Troisième occurrence du même piège d'insertion doxygen — un bloc glissé entre un
commentaire existant et sa fonction. L'outil l'attrape à chaque fois, et c'est exactement
ce pour quoi il existe.
* Suite : simulation 60/60, loadmodel 22/22, charging 48/48 identique à la référence,
spotmarket 32/32, doxygen 0 avertissement. Contre-épreuve : suspension débranchée, le
recrédit revient à 4 400 W.
-- Patrick Schurig <etm.schurig@gmail.com> Tue, 01 Sep 2026 12:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm45) trixie; urgency=medium
* Commande perdue, étape 2 — L'ÉCART COMMANDÉ/MESURÉ EST PUBLIÉ (`command`), et gouverné par

View File

@ -85,6 +85,15 @@ public:
//! \return Vrai si un défaut a réellement été levé ; faux s'il n'y en avait pas.
bool clearFault() override { return false; }
/*!
* \brief Latences MESURÉES sur la V2C : montée ~30 s (le véhicule négocie), descente ~7 s.
* \param montee Vrai si la consigne augmente.
* \return Le temps d'établissement correspondant, en secondes.
* \note L'asymétrie joue dans le bon sens — le refus qui coûte de l'argent est celui de la
* descente, et c'est le plus vite constaté.
*/
int commandSettleS(bool montee) const override { return montee ? 30 : 7; }
/*!
* \brief Vue runtime — déclarée, mais HORS de la charge utile \c GetLoadTelemetry.
*

View File

@ -179,6 +179,33 @@ public:
*/
virtual bool clearFault() = 0;
/*!
* \brief Temps au-delà duquel un écart commandé/mesuré n'est PLUS une latence.
*
* \param montee Vrai si la consigne AUGMENTE, faux si elle diminue.
* \return Durée d'établissement du mécanisme, en secondes.
*
* \par Pourquoi une valeur PAR MÉCANISME, et pas une constante
* L'anti-clignotement protège contre la latence : une charge qui vient d'être commandée n'a
* pas encore atteint sa consigne, et suspendre le recrédit là ferait osciller une charge
* saine. Mais un écart qui **persiste** au-delà de cette durée n'est plus une latence,
* **c'est un refus** — et prêter contre un refus établi, c'est importer des kilowatts que
* personne n'a décidés.
*
* Un seuil unique serait **trop court pour une borne et trop long pour un relais** : mesuré
* sur la V2C, la montée prend ~30 s et la descente ~7 s. Le seuil se raisonne donc contre la
* latence connue du mécanisme, jamais contre un nombre choisi une fois pour toutes.
*
* \note L'ASYMÉTRIE est utile dans le bon sens : le cas dangereux — commander la baisse et
* ne pas être suivi — se juge sur la latence de DESCENTE, la plus courte. Le refus qui
* coûte de l'argent se constate donc plus vite que celui qui n'en coûte pas.
*
* \note Défaut prudent : 60 s dans les deux sens. Un mécanisme qui ne se déclare pas ne doit
* pas voir son recrédit suspendu trop tôt — l'oscillation d'une charge saine est un coût
* certain, le refus n'est qu'un risque.
*/
virtual int commandSettleS(bool montee) const { Q_UNUSED(montee) return 60; }
/*!
* \brief Vue runtime destinée à la frontière de télémétrie RPC (\c GetLoadTelemetry).
*

View File

@ -1002,6 +1002,30 @@ bool EnergyArbitrator::evSurplusPlannedByWaterfall(EvCharger *evCharger) const
return evParticipatesInWaterfall(evCharger);
}
void EnergyArbitrator::qualifierRefus(LoadContext &lc, const QDateTime &now) const
{
const QDateTime depuis = m_divergenceDepuis.value(lc.id);
if (!depuis.isValid())
return; // aucun écart en cours : rien à qualifier
const int ecoule = static_cast<int>(depuis.secsTo(now));
lc.telemetry.refusedSinceS = qMax(0, ecoule);
// Le seuil vient du MÉCANISME. Un écart récent est une latence — une charge qui vient
// d'être commandée n'a pas encore atteint sa consigne, et suspendre là ferait osciller une
// charge saine. Au-delà, ce n'est plus une latence, c'est un refus.
ILoadAdapter *a = m_loadAdapters.value(lc.id, nullptr);
if (!a)
a = m_adapters.value(lc.id, nullptr);
if (!a)
return;
// Sens de la commande : on juge la DESCENTE sur la latence de descente. Le cas dangereux —
// commander la baisse sans être suivi — est justement celui qui se constate le plus vite.
const bool montee = lc.telemetry.currentPowerW < lc.declared.maxPowerW / 2.0;
lc.telemetry.commandRefused = ecoule > a->commandSettleS(montee);
}
SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
{
SurplusContext ctx;
@ -1089,7 +1113,9 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
"branché).";
continue;
}
ctx.loads.append(it.value()->toLoadContext(now));
LoadContext lcq = it.value()->toLoadContext(now);
qualifierRefus(lcq, now);
ctx.loads.append(lcq);
}
// --- loads[] : bornes CONFIGURÉES dont l'appareil manque (3g-2) ----------------------
@ -1116,14 +1142,19 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
lc.telemetry.currentPowerW = 0;
lc.telemetry.lockMinPowerW = 0; // rien d'engagé : rien à geler au budget
lc.telemetry.lockMaxPowerW = -1;
ctx.loads.append(lc);
LoadContext lcq = lc;
qualifierRefus(lcq, now);
ctx.loads.append(lcq);
}
}
// --- loads[] : TOUTES les charges arbitrées, polymorphes (relay-router, etmvariableload,
// sg-ready) — une seule table, toutes construites depuis la configuration persistée. ---
for (auto it = m_loadAdapters.constBegin(); it != m_loadAdapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext(now));
for (auto it = m_loadAdapters.constBegin(); it != m_loadAdapters.constEnd(); ++it) {
LoadContext lcq = it.value()->toLoadContext(now);
qualifierRefus(lcq, now);
ctx.loads.append(lcq);
}
return ctx;
}

View File

@ -696,6 +696,16 @@ private:
*/
void attachMeasurements(QVariantMap &entry, const QString &loadId) const;
/*!
* \brief Qualifie l'écart d'une charge en LATENCE ou en REFUS, selon son mécanisme.
* \param[in,out] lc Charge du cycle ; sa télémétrie reçoit \c commandRefused et
* \c refusedSinceS. \param now Temps de cycle.
* \note Le seuil vient de \c ILoadAdapter::commandSettleS, jamais d'une constante : trop
* court pour une borne, trop long pour un relais.
*/
void qualifierRefus(LoadContext &lc, const QDateTime &now) const;
/*!
* \brief Publie l'écart entre ce qu'on a COMMANDÉ et ce qui est MESURÉ.
*

View File

@ -474,6 +474,12 @@ DecisionReason RuleBasedScheduler::choisirMotif(const MotifEntrees &e) const
return { DecisionCode::DrawCap,
drawCapReasonParams(e.marge, false, 0, false) };
}
else if (e.refuse && e.retenuW > 0)
// Le refus explique l'issue MIEUX que le budget : sans lui, la charge aurait eu sa
// conso recréditée et se serait probablement servie. Accuser le budget enverrait
// chercher du soleil là où une charge n'obéit pas.
return { DecisionCode::RecreditSuspended,
{{"refusedSinceS", e.refuseDepuisS}, {"withheldW", qRound(e.retenuW)}} };
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.
@ -504,7 +510,14 @@ RuleBasedScheduler::SetpointTarget RuleBasedScheduler::decideSetpoint(const Load
// LM-1004 — le recrédit est attribué à la PREMIÈRE passe où la charge apparaît, et une
// seule fois. Le recréditer aussi ici offrirait deux fois la même puissance : une charge
// servie en éco verrait sa consommation rendue au budget de surplus qu'elle ne finance pas.
const double recreditW = (m_reserveActive || recreditDejaAttribue)
// TROISIÈME exception, et elle protège d'une prémisse fausse. Le recrédit rend au budget la
// conso d'une charge SUR L'HYPOTHÈSE qu'on pourra la lui reprendre. Contre un refus établi
// — écart au-delà de la latence du mécanisme — c'est un prêt qu'on n'est pas sûr de se voir
// rembourser, et les watts prêtés sont réalloués aux suivantes pendant que la charge
// continue de les tirer. L'identité comptable TIENT malgré tout : aucun contrôle de
// cohérence ne peut l'attraper, c'est l'hypothèse qui est fausse, pas l'arithmétique.
const bool refuse = lc.telemetry.commandRefused;
const double recreditW = (m_reserveActive || recreditDejaAttribue || refuse)
? 0.0 : lc.telemetry.currentPowerW;
const double budgetW = remainingSurplusW + recreditW;
budget.recreditedW += recreditW;
@ -761,6 +774,12 @@ RuleBasedScheduler::SetpointTarget RuleBasedScheduler::decideSetpoint(const Load
t.entrees = { setpointW, vouluW, budgetW, plancherW,
lc.telemetry.currentPowerW, margeRestante,
sousPlancher, capEpuise, dynamic };
// APRÈS l'initialisation agrégée, qui écraserait ces champs. Le refus n'y figure pas comme
// membre initialisé pour que l'ordre des grandeurs du motif reste celui d'origine — il
// porte les précédences, et le réordonner changerait le motif publié sans changer un nombre.
t.entrees.refuse = refuse;
t.entrees.refuseDepuisS = lc.telemetry.refusedSinceS;
t.entrees.retenuW = refuse ? lc.telemetry.currentPowerW : 0.0;
// Résidu : budget − consigne engagée → charge suivante de la priorité (même cycle).
remainingSurplusW = budgetW - setpointW;

View File

@ -119,6 +119,11 @@ private:
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 La charge REFUSE la commande — écart au-delà de la latence de son mécanisme.
//! Son recrédit est alors suspendu, ce qui change l'allocation de toutes les suivantes.
bool refuse = false;
int refuseDepuisS = 0; //!< Depuis combien de secondes l'écart dure.
double retenuW = 0; //!< Watts NON rendus au budget du fait du refus.
};
/*!

View File

@ -135,6 +135,10 @@ QString renderFr(const DecisionReason &reason, const QString &label)
"livrer en %4 (cadence recalculée à chaque cycle)")
.arg(label).arg(w("gridW")).arg(w("remainingWh")).arg(delai);
}
if (c == QLatin1String(DecisionCode::RecreditSuspended))
return QStringLiteral("%1 ne suit pas la commande depuis %2 s — %3 W NON rendus au "
"budget : on ne prête pas ce qu'on ne peut pas reprendre")
.arg(label).arg(w("refusedSinceS")).arg(w("withheldW"));
if (c == QLatin1String(DecisionCode::EcoFloorMissed)) {
const QString cause = p.value(QStringLiteral("cause")).toString();
const QString pourquoi =

View File

@ -235,6 +235,28 @@ constexpr const char *EcoFloorMet = "ECO_FLOOR_MET";
*/
constexpr const char *EcoFloorMissed = "ECO_FLOOR_MISSED";
/*!
* \brief Le recrédit anti-clignotement est SUSPENDU : la charge refuse la commande.
*
* params : \c refusedSinceS (depuis combien de secondes l'écart dure),
* \c withheldW (les watts NON rendus au budget).
*
* \par Pourquoi cette suspension doit se dire
* Elle change l'allocation de **toutes les charges suivantes** : les watts non rendus ne leur
* sont pas offerts. Si personne ne sait pourquoi la charge d'après reçoit moins, le diagnostic
* est impossible — on chercherait un défaut de cascade là où il y a une charge qui n'obéit pas.
*
* \par Ce qu'elle empêche
* Le recrédit rend au budget la consommation d'une charge **sur l'hypothèse qu'on pourra la lui
* reprendre**. Contre un refus établi, ce prêt fait importer des kilowatts que personne n'a
* décidés — et l'identité comptable tient malgré tout, si bien qu'aucun contrôle de cohérence
* ne peut l'attraper.
*
* \note Le seuil est celui du MÉCANISME (\c ILoadAdapter::commandSettleS), jamais une
* constante : un écart récent est une latence, pas un refus.
*/
constexpr const char *RecreditSuspended = "RECREDIT_SUSPENDED";
// --- SG-Ready (PAC) ---------------------------------------------------------
//! params: \c state — le verrou minStateHold maintient l'état (court-cycling compresseur).
constexpr const char *LockMinStateHold = "LOCK_MIN_STATE_HOLD";

View File

@ -83,6 +83,32 @@ struct LoadContextTelemetry {
* booléen, les deux seraient indistinguables et le régime basculerait d'un cycle à l'autre
* sur une installation dont rien n'a changé (\rule{LM-1009} C1).
*/
/*!
* \brief La charge REFUSE la commande — écart établi au-delà de la latence du mécanisme.
*
* \par Ce que ce booléen protège
* Le recrédit anti-clignotement rend au budget la consommation d'une charge **sur
* l'hypothèse qu'on pourra la lui reprendre**. Si elle n'obéit pas, les watts prêtés sont
* réalloués aux suivantes pendant qu'elle continue de les tirer — et la maison importe ce
* que personne n'a décidé. L'identité comptable, elle, TIENT : c'est une prémisse fausse
* sous une arithmétique exacte, et aucun contrôle de cohérence ne peut l'attraper.
*
* \note Un écart RÉCENT n'est pas un refus, c'est une latence. Le seuil vient du mécanisme
* (\c ILoadAdapter::commandSettleS), jamais d'une constante : trop court pour une borne,
* trop long pour un relais.
*/
bool commandRefused = false;
//! Depuis combien de secondes l'écart dure. 0 si aucun écart.
int refusedSinceS = 0;
/*!
* \brief La borne COMPTE-T-ELLE l'énergie de sa session ? CAPACITÉ, pas valeur.
*
* Faux ⇒ \c sessionWh est sans objet, et l'avancement est \c unmeasurable. La question est
* posée à la ThingClass : une session qui démarre mesure 0 Wh et reste mesurable
* (\rule{LM-1009} C1).
*/
bool sessionMeasurable = false;
/*!

View File

@ -7249,6 +7249,37 @@ void Simulation::testRecreditIsALoanAgainstObedience()
// alloués pour 500 W de surplus réel. L'écart est exactement le recrédit.
QCOMPARE(qRound(plan.budget.surplusW), 500);
QCOMPARE(qRound(plan.budget.recreditedW), 4400);
// 5. LE PRÊT EST REFUSÉ quand le refus est ÉTABLI. Même scénario, mais la charge A ne suit
// pas la commande depuis plus longtemps que la latence de son mécanisme : ses watts ne
// sont plus rendus au budget, et B ne reçoit donc plus ce qu'on ne peut pas reprendre.
ctx.loads[0].telemetry.commandRefused = true;
ctx.loads[0].telemetry.refusedSinceS = 120;
const Plan refus = sched.getPlan(ctx);
QHash<QString, LoadAction> parIdR;
for (const LoadAction &act : refus.timeSlots.first().actions)
parIdR.insert(act.loadId, act);
QCOMPARE(qRound(refus.budget.recreditedW), 0); // on ne prête plus
// B ne reçoit QUE le surplus réel : 500 W, et non les 4 000 W qu'on ne peut pas reprendre.
QCOMPARE(qRound(parIdR.value("B-suivante").estimatedPowerW), 500);
// 6. ET ÇA SE DIT. Une suspension change l'allocation de TOUTES les suivantes ; si personne
// ne sait pourquoi B reçoit moins, on chercherait un défaut de cascade là où il y a une
// charge qui n'obéit pas. Le motif porte la suspension ET sa cause chiffrée.
QCOMPARE(parIdR.value("A-coupee").reason.code, QString(DecisionCode::RecreditSuspended));
QCOMPARE(parIdR.value("A-coupee").reason.params.value("refusedSinceS").toInt(), 120);
QCOMPARE(parIdR.value("A-coupee").reason.params.value("withheldW").toInt(), 4400);
const QString txt = renderFr(parIdR.value("A-coupee").reason, "A");
QVERIFY2(txt.contains("4400"), qUtf8Printable("motif: " + txt));
QVERIFY2(txt.contains("reprendre"), qUtf8Printable("motif: " + txt));
// 7. UN ÉCART RÉCENT N'EST PAS UN REFUS. La latence du mécanisme protège une charge saine
// qui n'a pas encore atteint sa consigne — suspendre là ferait osciller ce qui va bien.
// C'est `qualifierRefus()` qui tranche, sur le seuil du mécanisme et non une constante.
ctx.loads[0].telemetry.commandRefused = false; // écart présent mais RÉCENT
ctx.loads[0].telemetry.refusedSinceS = 3;
const Plan latence = sched.getPlan(ctx);
QCOMPARE(qRound(latence.budget.recreditedW), 4400); // le prêt est maintenu
QCOMPARE(qRound(plan.budget.allocatedW), 4000);
QVERIFY2(plan.budget.allocatedW > plan.budget.surplusW,
"le recrédit prête d'avance ce que la coupure de A doit libérer");