diff --git a/AGENTS.md b/AGENTS.md index e1d45be..64af38b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/INTERFACE.md b/INTERFACE.md index a64f950..e551e1d 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -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 > diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 9e5afed..8c9af22 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -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 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 diff --git a/energyplugin/etm/adapters/evadapter.h b/energyplugin/etm/adapters/evadapter.h index 31a50c5..c14501e 100644 --- a/energyplugin/etm/adapters/evadapter.h +++ b/energyplugin/etm/adapters/evadapter.h @@ -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. * diff --git a/energyplugin/etm/adapters/iloadadapter.h b/energyplugin/etm/adapters/iloadadapter.h index 6330660..5385c1e 100644 --- a/energyplugin/etm/adapters/iloadadapter.h +++ b/energyplugin/etm/adapters/iloadadapter.h @@ -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). * diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 89ff684..c85ba30 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -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(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; } diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index d2e7511..4c38b53 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -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É. * diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index d5e4e1d..098139a 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -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; diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.h b/energyplugin/etm/scheduler/rulebasedscheduler.h index 7d4efcd..373dbd8 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.h +++ b/energyplugin/etm/scheduler/rulebasedscheduler.h @@ -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. }; /*! diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index 17f8914..de15615 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -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 = diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index ac1ad3f..f609f23 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -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"; diff --git a/energyplugin/etm/types/surpluscontext.h b/energyplugin/etm/types/surpluscontext.h index 0fecc5e..76d17e6 100644 --- a/energyplugin/etm/types/surpluscontext.h +++ b/energyplugin/etm/types/surpluscontext.h @@ -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; /*! diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index dac935e..9f6f354 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -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 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");