diff --git a/AGENTS.md b/AGENTS.md index 5777d88..5578000 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -869,6 +869,36 @@ Règles absolues : absent = aucun cycle ; `budget` absent = planification suspendue. Un zéro publié se lirait « exécuté, rien à faire ». +## `setChargingInfo()` DÉCLENCHE UN CYCLE — donc ce qui l'entoure a un ordre + +**`SmartChargingManager::setChargingInfo()` appelle `update()` avant de rendre la main** +(hors `ENERGY_SIMULATION`). Écrire un mode de charge n'est **pas** un geste inerte : c'est +poser une consigne *et* faire tourner un cycle complet — contexte, plan, dispatch. + +Deux conséquences, et elles vont en sens contraire. Les deux ont été trouvées en préparant le +premier essai de dérogation (2026-09-08), aucune n'aurait laissé de trace. + +**Ce qui doit venir AVANT l'appel** : tout état que le cycle déclenché doit voir. Une dérogation +insérée *après* `setChargingInfo()` était invisible du cycle qu'elle venait de provoquer, et +n'était servie qu'au suivant — soixante secondes de retard sur un mécanisme dont la définition +est « tout de suite ». + +**Ce qui ne doit PAS être appelé DEPUIS un cycle** : `update()` n'a **aucun verrou de +ré-entrance**. Appeler `setChargingInfo()` depuis quoi que ce soit qui tourne sous `update()` — +une évaluation de fin, une transition de mode, un repli — imbrique un cycle dans un cycle : deux +plans construits et deux dispatchs pour le même instant, sur des charges qu'un seul des deux +croit avoir commandées. Le remède est un `QTimer::singleShot(0, this, …)`, qui rend la main au +tour de boucle suivant. + +> **Ni un test ni le journal ne montrent cette faute.** Le journal affiche deux cycles, ce qui +> est le cas normal ; la suite passe, parce que `ENERGY_SIMULATION` **supprime précisément +> l'appel à `update()`** — le chemin fautif n'existe pas sous test. C'est un cas de la famille +> « l'environnement de vérification masque une classe entière de défauts ». + +**Quelqu'un déplacera ces lignes un jour en croyant réordonner du code inerte.** C'est pour lui +que ce paragraphe existe : l'ordre autour d'un `setChargingInfo()` est porteur, et le commentaire +au point d'appel dit lequel des deux pièges il évite. + ## CE QUI TIENT ET CE QUI S'ÉRODE **La différence n'a jamais été la qualité de l'argument — c'est de savoir s'il existe une machine diff --git a/INTERFACE.md b/INTERFACE.md index 5d5fd9c..53a1280 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -1122,7 +1122,7 @@ le **libellé de la charge n'y figure jamais** (le client le résout via `GetLoa | `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 | -| `DEROGATION_ACTIVE` *(§15)* | `maxW`, `gridW`, `endsAt`, `o:targetWh`, `o:deliveredWh`, `o:boundBy` | Cette charge est en **dérogation** : servie à son maximum, financée au réseau, en tête de passe éco. Ce n'est **pas un mode** — elle se superpose et s'efface, et `endsAt` (ISO 8601 UTC) est le **filet** qui permet à l'écran de montrer un compte à rebours plutôt qu'un état sans horizon. `targetWh` et `deliveredWh` sont **absents** quand aucune cible n'a été donnée : un `targetWh: 0` se lirait « cible atteinte ». `boundBy: "drawCap"` dit que le plafond de soutirage a mordu — sans lui, une dérogation servie à moitié se lit comme une borne qui ne suit pas | +| `DEROGATION_ACTIVE` *(§15)* | `maxW`, `gridW`, `endsAt`, `o:targetWh`, `o:deliveredWh`, `o:boundBy` | Cette charge est en **dérogation** : servie à son maximum, financée au réseau, en tête de passe éco. Ce n'est **pas un mode** — elle se superpose et s'efface, et `endsAt` (ISO 8601 UTC) est le **filet** qui permet à l'écran de montrer un compte à rebours plutôt qu'un état sans horizon. `targetWh` et `deliveredWh` sont **absents** quand aucune cible n'a été donnée : un `targetWh: 0` se lirait « cible atteinte ». `boundBy` dit **qui** a bridé — `drawCap` (le plafond de soutirage de la maison) ou `loadCeiling` (la charge est à son plafond de cycle ; pour une borne ce plafond porte déjà son autorisation de phase, et `maxW` est alors la vraie réponse à « pourquoi pas plus »). Sans lui, une dérogation servie à moitié se lit comme une charge qui ne suit pas | > ### La FIN d'une dérogation est un événement de TRAME, pas un motif > @@ -1135,11 +1135,16 @@ le **libellé de la charge n'y figure jamais** (le client le résout via `GetLoa > "derogationEnded": [ { "loadId": "{...}", "cause": "durationElapsed", "restoredMode": 3 } ] > ``` > -> `cause` ∈ `targetReached` | `unplugged` | `measurementLost` | `durationElapsed` | `cancelled`. -> **Cinq causes, cinq gestes** — cible atteinte : rien à faire ; mesure perdue : regarder le lien -> ou le compteur ; débranchement ; durée écoulée ; annulée à la main. Une fin qui ne dirait pas +> `cause` ∈ `targetReached` | `unplugged` | `measurementLost` | `durationElapsed` | +> `modeChanged` | `cancelled`. **Six causes, six gestes** — cible atteinte : rien à faire ; +> mesure perdue : regarder le lien ou le compteur ; débranchement ; durée écoulée ; +> **mode changé : quelqu'un a repris le volant** ; annulée à la main. Une fin qui ne dirait pas > laquelle a mordu laisserait l'utilisateur relancer une dérogation que le lien empêchera encore. > +> Sous `modeChanged`, `restoredMode` est **absent** : le mode mémorisé n'est PAS restitué, il +> écraserait celui que l'utilisateur vient de choisir. Écrire un mode pendant une dérogation la +> termine — le durable prime sur le ponctuel. +> > `restoredMode` est le `ChargingMode` **mémorisé** auquel on revient : le client ne peut pas le > déduire, il n'a jamais vu celui d'avant la pose. Présent seulement si un mode avait été écrit. > diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 0b9bfb4..76d1a08 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,28 @@ +powersync-energy-plugin-nymea (1.15.2+etm61) trixie; urgency=medium + + * LM-1500-h — une BORNE n'est pas écrêtée deux fois. La tête de passe éco + appliquait margeRestante, qui est la marge d'UNE phase, au plafond TRIPHASÉ + d'une borne : 6 210 W au lieu de 13 800, soit 9 A par phase au lieu de 27 — + 4 kW au lieu des 11 que la voiture pouvait prendre. Le plafond de cycle + d'une borne porte DÉJÀ l'autorisation de soutirage, multipliée par le nombre + de phases occupées (evPhaseAllowanceW). La passe confort ne fait pas cette + faute et son commentaire dit pourquoi depuis 3g-2. Erreur du côté prudent, + donc invisible du disjoncteur, du délestage et de tout test de dépassement. + * LM-1500-g — écrire un MODE pendant une dérogation la termine (cause + modeChanged), et SANS restituer le mode mémorisé, qui écraserait celui que + l'utilisateur vient de choisir. Constaté au banc : le bouton « boost » de + l'app écrit encore ChargingModeNormal ; reçu pendant une dérogation, il + faisait restituer les valeurs manuelles (chargingEnabled à faux) et armait + un verrou de 300 s pendant que la dérogation commandait 9 A — deux + commandeurs pour un organe, six minutes de charge nulle servie sur le papier. + * Les motifs DEROGATION_ACTIVE et DEROGATION_ENDED sont rendus en français au + journal ; ils sortaient en « motif inconnu ». + * poseDerogation() prend l'instant en paramètre : elle lisait l'horloge murale + quand le cycle lit l'horloge injectée, et la fin par durée n'arrivait jamais + sous test. + + -- Patrick Schurig Tue, 08 Sep 2026 11:35:34 +0200 + powersync-energy-plugin-nymea (1.15.2+etm60) trixie; urgency=medium * Dérogation : deux effets de setChargingInfo(), qui déclenche lui-même un diff --git a/docs/DESIGN_DEROGATION.md b/docs/DESIGN_DEROGATION.md index 1db9b29..907829c 100644 --- a/docs/DESIGN_DEROGATION.md +++ b/docs/DESIGN_DEROGATION.md @@ -316,3 +316,92 @@ courant, et le mode est rendu avant le suivant. **Effet heureux du retour à `Normal`** : l'amont restaure alors les valeurs manuelles (`manualChargingEnabled`, `manualMaxChargingCurrent`, `manualDesiredPhaseCount`). La borne retrouve donc *ses* réglages, et pas seulement son mode. + +--- + +## 10. Premier essai au banc — 2026-09-08, 11:05:54 → 11:15:54 (`+etm60`) + +V2C Trydan, voiture branchée, **mode manuel** avant la pose. Durée 600 s, sans cible. +Authentification désactivée sur `.75` pour l'essai ; appel par WebSocket `4444`. + +### Les quatre refus mordent sur la vraie machine + +| appel | réponse | +|---|---| +| `durationS: 30` (sous le plancher de 60) | `EnergyErrorInvalidParameter` | +| `durationS: 25200` (au-delà des 6 h) | `EnergyErrorInvalidParameter` | +| `loadId` inconnu | `EnergyErrorInvalidParameter` | +| `ClearLoadDerogation` sans dérogation en cours | `EnergyErrorInvalidParameter` | + +Journal : `[Dérogation] durée hors bornes : 25200 s (attendu 60 .. 21600)`, +`[Dérogation] charge inconnue`. + +### La pose est servie DANS LA MÊME SECONDE + +``` +11:05:54 [Dérogation] POSÉE {4d1adafd…} — durée 600 s, sans cible, + mode mémorisé 0 (forcé en éco pour la durée) +11:05:54 Charging mode changed for "V2C Trydan" ChargingModeEco +11:05:54 Executing action V2C Trydan to power: ON, Charging current: 9A +``` + +**C'est la correction `+etm60` qui se voit ici**, et rien d'autre : le cycle déclenché par +`setChargingInfo()` trouve la dérogation déjà posée et la sert. Dans l'ordre inverse il l'aurait +manquée, et la borne serait partie en éco ordinaire jusqu'au cycle suivant. + +### Ce qui la borne, et rien d'autre + +`boundBy: "drawCap"` à **tous** les cycles, et `maxW: 13800` (20 A × 3 × 230 V) **jamais +atteint** : + +``` +draw : { authorisedW: 5984, committedW: 5984, remainingW: 0, + binding: "connection", bindingPhase: "A", perPhaseBound: true } +``` + +La dérogation prend la tête, **consomme toute l'autorisation de soutirage — et s'arrête là**. +`remainingW` tombe à `0`, jamais en négatif. Les charges suivantes ne reçoivent plus que du +surplus : le chauffe-eau passe `BELOW_MIN_POWER` (241 W restants), la PAC tient sur son recrédit. + +> **« Pleine puissance » ne veut pas dire 13 800 W.** La phase A du branchement 3×30 A n'en +> autorisait que ~6 000 avec la maison dessus. La borne est allée au maximum que la **sûreté** +> permettait, et le motif l'a dit à chaque cycle. C'est `LM-1500-e` observée, pas contournée. + +### Une étiquette de journal à ne pas mal lire + +L'amont journalise `Issuer: Surplus` sur une action pourtant financée au **réseau**. +`ChargingActionIssuerSurplusCharging` nomme le **canal** par lequel l'arbitre commande la borne, +pas l'origine des watts — `EV_GRID_START` porte déjà la même étiquette depuis 3g-2. Rien à +corriger, mais un lecteur du journal peut y voir une contradiction avec `funding: grid`. + +### Ce que le premier essai a trouvé, et que le code seul ne disait pas — `+etm61` + +**a. La collision de commandeurs (`LM-1500-g`).** À 11:09:51 le bouton « boost » de l'app a écrit +`ChargingModeNormal` pendant la dérogation. L'amont a alors restitué les valeurs manuelles — +`chargingEnabled` à **faux** — et armé un verrou de 300 s, pendant que la dérogation continuait +de commander 9 A. Six minutes de dérogation servie **sur le papier** dans une borne éteinte, que +seul `[Divergence] DÉBUT — attendu 6210 W, mesuré 0 W` a signalées, sans pouvoir en dire la cause. + +Corrigé : une écriture de mode **termine** la dérogation (cause `modeChanged`), et **sans** +restituer le mode mémorisé — il écraserait celui que l'utilisateur vient de choisir. + +**b. Le double écrêtage des bornes (`LM-1500-h`).** La tête de passe éco appliquait +`margeRestante` — la marge d'**une** phase — au plafond **triphasé** d'une borne. 6 210 W au lieu +de 13 800 : **9 A par phase au lieu de 27**, 4 kW au lieu des 11 que la voiture pouvait prendre. + +La passe confort ne fait pas cette faute, et son commentaire dit pourquoi depuis 3g-2 : *« leur +plafond de cycle est déjà écrêté par `evPhaseAllowanceW()`, qui connaît les phases qu'elles +occupent »*. Je l'ai réécrite quinze lignes plus haut sans la relire. + +> **Une règle notée à un seul endroit du code n'est pas une règle.** Et une erreur qui va dans le +> sens prudent ne se signale nulle part : ni le disjoncteur, ni le délestage, ni un test qui +> vérifie qu'on ne dépasse pas. Elle ne se voit qu'en **regardant la voiture**. + +**c. Le motif n'était pas rendu au journal.** `[Arbitre] … (motif inconnu DEROGATION_ACTIVE …)` : +le catalogue français de `decisionreason.cpp` ignorait les deux codes neufs. Le repli 7-c a +fonctionné — le code brut et ses paramètres, jamais une chaîne vide — mais la ligne que +l'utilisateur lit était illisible. + +**d. La pose lisait l'horloge murale, le cycle l'horloge injectée.** Sans conséquence en +production, où les deux sont la même ; en test, la fin par durée n'arrivait jamais. Trouvé par le +test, pas par la lecture — `poseDerogation()` prend maintenant l'instant en paramètre. diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index f7c3f66..a94345f 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -1068,7 +1068,8 @@ constexpr int DerogationDureeMinS = 60; constexpr int DerogationDureeMaxS = 6 * 3600; } -bool EnergyArbitrator::poseDerogation(const QString &loadId, int dureeS, double targetWh) +bool EnergyArbitrator::poseDerogation(const QString &loadId, int dureeS, double targetWh, + const QDateTime &now) { const bool estBorne = m_adapters.contains(loadId); if (!estBorne && !m_loadAdapters.contains(loadId)) { @@ -1089,7 +1090,13 @@ bool EnergyArbitrator::poseDerogation(const QString &loadId, int dureeS, double d.loadId = loadId; // ANCRÉS EN UTC À LA CAPTURE. Un QDateTime local résout son époque à la CONVERSION : une // échéance qui se déplace d'une heure au changement de fuseau n'est plus un filet. - d.startedAt = QDateTime::currentDateTime().toUTC(); + // + // ET SUR L'HORLOGE DU CYCLE, pas sur une autre. En production les deux sont la même — + // update() est déclenché avec QDateTime::currentDateTime(). Sous test, le cycle est + // INJECTÉ : une échéance prise sur l'horloge murale et comparée à un instant simulé + // n'expire jamais, et la fin par durée passerait pour absente alors qu'elle est seulement + // hors d'atteinte. Trouvé par le test, pas par la lecture. + d.startedAt = (now.isValid() ? now : QDateTime::currentDateTime()).toUTC(); d.expiresAt = d.startedAt.addSecs(dureeS); d.targetWh = qMax(0.0, targetWh); // baseWh reste à capturer : au moment de la pose, aucun cycle n'a construit de contexte et @@ -1109,6 +1116,10 @@ bool EnergyArbitrator::poseDerogation(const QString &loadId, int dureeS, double d.modeForce = true; aBasculer = true; } + // Ce que la dérogation s'attend à voir tant qu'elle dure : le mode forcé, ou celui qui + // était déjà là. Toute autre valeur au cycle suivant est une écriture extérieure. + d.modeAttendu = d.modeForce ? static_cast(ChargingInfo::ChargingModeEco) + : d.previousMode; } m_derogations.insert(loadId, d); @@ -1209,10 +1220,25 @@ void EnergyArbitrator::evaluerDerogations(SurplusContext &ctx, const QDateTime & ? qMax(0.0, lc->telemetry.sessionWh - d.baseWh) : 0.0; + // \rule{LM-1500-g} — QUELQU'UN A REPRIS LE VOLANT, testé AVANT tout le reste, parce que c'est une + // décision et non un constat. Choisir un mode est durable ; une dérogation répond à un + // besoin ponctuel, et le durable prime. + // + // Le laisser passer coûte cher, et le banc l'a montré (2026-09-08) : un + // ChargingModeNormal écrit pendant une dérogation fait restituer les valeurs manuelles + // par l'amont — chargingEnabled à faux — et arme un verrou de 300 s. La dérogation + // continuait de commander 9 A dans une borne éteinte et verrouillée : deux commandeurs + // pour un organe, que seul le journal de divergence voyait. + bool volantRepris = false; + if (d.modeAttendu >= 0 && internalEvChargers().contains(ThingId(id))) + volantRepris = static_cast(chargingInfo(ThingId(id)).chargingMode()) != d.modeAttendu; + QString cause; // ORDRE : la plus informative d'abord. C'est la cause que l'utilisateur doit lire, et // la durée n'est pas un diagnostic — elle vient donc en dernier, comme filet. - if (d.targetWh > 0 && d.baseCapturee && livreWh >= d.targetWh) { + if (volantRepris) { + cause = QString::fromLatin1(DerogationEnd::ModeChanged); + } else if (d.targetWh > 0 && d.baseCapturee && livreWh >= d.targetWh) { cause = QString::fromLatin1(DerogationEnd::TargetReached); } else if (d.targetWh > 0 && d.sansMesureConsecutifs >= DerogationConfirmationsMesure) { // \rule{LM-1500-b} — une dérogation dont la CIBLE cesse d'être mesurable doit @@ -1240,7 +1266,11 @@ void EnergyArbitrator::evaluerDerogations(SurplusContext &ctx, const QDateTime & } const Derogation finie = m_derogations.take(id); - rendreModeMemorise(finie); + // PAS de restitution quand le volant a été repris : le mode mémorisé écraserait celui + // que l'utilisateur vient de choisir — on lui reprendrait sa décision au nom de la + // nôtre, à un tour de boucle près et sans qu'il puisse le voir. + if (cause != QString::fromLatin1(DerogationEnd::ModeChanged)) + rendreModeMemorise(finie); m_derogationFin.insert(id, cause); m_derogationModeRendu.insert(id, finie.previousMode); qCInfo(dcNymeaEnergy()).noquote() diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 77395ba..e2e9c84 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -52,10 +52,15 @@ public: * \param targetWh Cible en énergie (Wh) depuis la pose. 0 = pas de cible. * \return Faux si la charge est inconnue ou la durée hors bornes. * + * \param now Instant de la pose. Invalide (défaut) = l'horloge murale, qui est CELLE DU + * CYCLE en production. Le paramètre existe pour le test, où le cycle est injecté : une + * échéance prise sur une horloge et comparée sur une autre n'expire jamais. + * * \note Le mode courant est MÉMORISÉ ici, et rendu à la fin. Revenir à un défaut * détruirait un réglage en silence — et sur une borne, la sortirait de l'arbitrage. */ - bool poseDerogation(const QString &loadId, int dureeS, double targetWh = 0); + bool poseDerogation(const QString &loadId, int dureeS, double targetWh = 0, + const QDateTime &now = QDateTime()); //! \brief Lève une dérogation avant son terme, et rend le mode mémorisé. //! \param loadId Charge visée. \return Faux si aucune dérogation n'était en cours. diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index fd35739..73acdb5 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -268,8 +268,22 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) // LE MAXIMUM DE LA CHARGE, et rien d'autre. Une dérogation ne module pas : c'est sa // définition côté client, et une dérogation qui doserait redeviendrait un mode. double achatW = lc.telemetry.available ? lc.declared.maxPowerW : 0.0; - const bool bridePlafond = margeRestante.borne && achatW > qMax(0.0, margeRestante.watts); - if (margeRestante.borne) + + // \rule{LM-1500-h} — LES BORNES SONT HORS DE LA MARGE DE LA MAISON, comme dans la + // passe confort, et pour + // la même raison — qui y est écrite : leur plafond de cycle est DÉJÀ écrêté par + // `evPhaseAllowanceW()`, qui connaît les phases qu'elles occupent et multiplie la + // marge par leur nombre. Leur appliquer en plus `margeRestante`, c'est comparer une + // enveloppe TRIPHASÉE à la marge d'UNE SEULE phase. + // + // Mesuré au banc le 2026-09-08 : marge de 6 210 W sur la phase la plus chargée, borne + // triphasée dont le plafond du cycle valait 13 800 W. Le double écrêtage l'a servie à + // 6 210 W, soit 9 A par phase au lieu de 27 — 4 kW au lieu des 11 que la voiture + // pouvait prendre. Sûr, mais faux : le boost ne faisait plus ce que son nom promet. + const bool estUneBorne = lc.adapter == QStringLiteral("evcharger"); + const bool bridePlafond = !estUneBorne && margeRestante.borne + && achatW > qMax(0.0, margeRestante.watts); + if (!estUneBorne && margeRestante.borne) achatW = qBound(0.0, achatW, qMax(0.0, margeRestante.watts)); // L'achat est noté à CHAQUE cycle, y compris nul : c'est le passage du temps qui intègre @@ -297,9 +311,15 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) motif.insert(QStringLiteral("deliveredWh"), qRound(lc.derogationDeliveredWh)); } // QUI a bridé, publié plutôt que déduit : sans cela, une dérogation servie à moitié se - // lit comme une borne qui ne suit pas, alors que c'est le plafond de soutirage. + // lit comme une borne qui ne suit pas, alors qu'elle est au bout de ce qu'on lui + // permet. Deux valeurs, deux gestes : l'abonnement, ou la charge elle-même. if (bridePlafond) motif.insert(QStringLiteral("boundBy"), QStringLiteral("drawCap")); + else if (lc.declared.maxPowerW > 0 && achatW >= lc.declared.maxPowerW) + // Elle est à SON plafond de cycle. Pour une borne, ce plafond porte déjà + // l'autorisation de phase : `maxW` est donc la vraie réponse à « pourquoi pas + // plus », et il est publié à côté. + motif.insert(QStringLiteral("boundBy"), QStringLiteral("loadCeiling")); der.reason = { DecisionCode::DerogationActive, motif }; niveauxEco.insert(lc.id, der); diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index de15615..9d5495d 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -182,6 +182,36 @@ QString renderFr(const DecisionReason &reason, const QString &label) return QStringLiteral("Aucun surplus à allouer (%1 W) — %2 en normal (état 2, non pilotée)") .arg(w("budgetW")).arg(label); + // --- Dérogations -------------------------------------------------------- + if (c == QLatin1String(DecisionCode::DerogationActive)) { + // QUI borne est la première chose à lire : sans cela, une dérogation servie à 6 000 W + // au lieu de 13 800 se lit comme une borne qui ne suit pas, alors que c'est le + // branchement qui décide. + const QString borne = p.value(QStringLiteral("boundBy")).toString() == QLatin1String("drawCap") + ? QStringLiteral(", bornée par l'autorisation de soutirage") + : QString(); + if (p.contains(QStringLiteral("targetWh"))) + return QStringLiteral("Dérogation sur %1 — %2 W achetés sur %3 W possibles%4 ; " + "%5 Wh livrés sur %6 Wh visés") + .arg(label).arg(w("gridW")).arg(w("maxW")).arg(borne) + .arg(w("deliveredWh")).arg(w("targetWh")); + return QStringLiteral("Dérogation sur %1 — %2 W achetés sur %3 W possibles%4") + .arg(label).arg(w("gridW")).arg(w("maxW")).arg(borne); + } + if (c == QLatin1String(DecisionCode::DerogationEnded)) { + // CINQ causes, cinq gestes. La phrase les distingue, parce qu'un « terminé » nu + // laisserait relancer une dérogation que le lien empêchera encore. + const QString cause = p.value(QStringLiteral("cause")).toString(); + QString pourquoi = QStringLiteral("cause inconnue (%1)").arg(cause); + if (cause == QLatin1String("targetReached")) pourquoi = QStringLiteral("cible atteinte"); + else if (cause == QLatin1String("unplugged")) pourquoi = QStringLiteral("véhicule débranché"); + else if (cause == QLatin1String("measurementLost")) pourquoi = QStringLiteral("mesure perdue — regarder le lien ou le compteur"); + else if (cause == QLatin1String("durationElapsed")) pourquoi = QStringLiteral("durée écoulée"); + else if (cause == QLatin1String("modeChanged")) pourquoi = QStringLiteral("mode changé — quelqu'un a repris le volant"); + else if (cause == QLatin1String("cancelled")) pourquoi = QStringLiteral("annulée"); + return QStringLiteral("Dérogation terminée sur %1 — %2").arg(label).arg(pourquoi); + } + // --- Sécurité et cycle de vie ------------------------------------------ if (c == QLatin1String(DecisionCode::DegradedL2)) return QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)"); diff --git a/energyplugin/etm/types/derogation.h b/energyplugin/etm/types/derogation.h index 1fba7b5..9a1ba03 100644 --- a/energyplugin/etm/types/derogation.h +++ b/energyplugin/etm/types/derogation.h @@ -42,6 +42,21 @@ constexpr const char *Unplugged = "unplugged"; constexpr const char *MeasurementLost = "measurementLost"; //! Le temps imparti est écoulé — le filet, toujours armé. constexpr const char *DurationElapsed = "durationElapsed"; +/*! + * \brief Le MODE DE CHARGE a été écrit pendant la dérogation : quelqu'un a repris le volant. + * + * Une dérogation répond à un besoin ponctuel ; choisir un mode est une décision DURABLE, et + * elle prime. La dérogation cesse — et **sans restituer** le mode mémorisé : celui qu'on + * restituerait écraserait celui que l'utilisateur vient de choisir. + * + * \par Pourquoi ce n'est pas une subtilité + * Constaté au banc le 2026-09-08 : un \c ChargingModeNormal écrit pendant une dérogation fait + * restituer les valeurs manuelles par l'amont (\c chargingEnabled à faux) et **arme un verrou + * de 300 s**. La dérogation continuait de commander 9 A dans une borne éteinte, verrouillée — + * deux commandeurs pour un organe, exactement ce que l'arbitre unique existe pour empêcher. + * Seul le journal de divergence le voyait, et il ne pouvait pas en dire la cause. + */ +constexpr const char *ModeChanged = "modeChanged"; } //! \brief Absences CONSÉCUTIVES avant de conclure au débranchement (\rule{LM-1500-c}). @@ -116,6 +131,14 @@ struct Derogation { * l'avait déjà mis là », ce qui est exactement ce que \c previousMode existe pour éviter. */ bool modeForce = false; + /*! + * \brief Le mode que la dérogation s'attend à VOIR pendant qu'elle dure. + * + * Celui qu'elle a forcé, ou celui qui était déjà là. Toute autre valeur signifie qu'une + * écriture extérieure est passée — et une écriture de mode est une décision durable, qui + * prime sur un besoin ponctuel. + */ + int modeAttendu = -1; //! \brief Cycles CONSÉCUTIFS sans la charge dans le contexte ; remis à zéro dès qu'elle //! reparaît, pour qu'un clignotement ne s'accumule pas d'un épisode à l'autre. diff --git a/specs/spec_loadmodel.md b/specs/spec_loadmodel.md index dfc7822..4854d53 100644 --- a/specs/spec_loadmodel.md +++ b/specs/spec_loadmodel.md @@ -2360,3 +2360,38 @@ Les quatre causes appellent des **gestes différents** — cible atteinte (rien perdue (regarder le lien), débranchement, durée écoulée. Une fin qui ne dirait pas laquelle a mordu laisserait l'utilisateur relancer une dérogation que le lien empêchera encore. La cause `cancelled` s'ajoute aux quatre : c'est une fin **par décision**, et la distinguer est le but. + +### LM-1500-g — Écrire un MODE pendant une dérogation la termine, et sans restituer + +Choisir un mode est une décision **durable** ; une dérogation répond à un besoin **ponctuel**. +Le durable prime, et il prime **tout de suite**. + +**Et sans restituer le mode mémorisé** : celui qu'on restituerait écraserait celui que +l'utilisateur vient de choisir. On lui reprendrait sa décision au nom de la nôtre, un tour de +boucle plus tard, sans qu'il puisse le voir. + +**Ce n'est pas une subtilité, c'est une collision de commandeurs.** Constaté au banc le +2026-09-08 : le bouton « boost » de l'app écrit encore `ChargingModeNormal`. Reçu pendant une +dérogation, il fait restituer les valeurs manuelles par l'amont — `chargingEnabled` à **faux** — +et **arme un verrou de 300 s**, pendant que la dérogation continue de commander 9 A. Deux +commandeurs pour un organe, et six minutes de dérogation servie **sur le papier** dans une borne +éteinte. Seul le journal de divergence le voyait, et il ne pouvait pas en dire la cause. + +### LM-1500-h — Une BORNE n'est pas écrêtée deux fois + +Le plafond de cycle d'une borne (`declared.maxPowerW`) porte **déjà** l'autorisation de +soutirage : `evPhaseAllowanceW()` la calcule et la **multiplie par le nombre de phases occupées**. +Lui appliquer en plus `margeRestante`, c'est comparer une enveloppe **triphasée** à la marge +**d'une seule phase** — et diviser son allocation par trois. + +Mesuré au banc le 2026-09-08 : marge de 6 210 W sur la phase la plus chargée, borne triphasée +dont le plafond du cycle valait 13 800 W. Le double écrêtage l'a servie à 6 210 W, soit **9 A par +phase au lieu de 27** — **4 kW au lieu des 11** que la voiture pouvait prendre. + +**Sûr, mais faux.** L'erreur va dans le sens prudent, donc rien ne la signale : ni le +disjoncteur, ni le délestage, ni un test qui vérifie qu'on ne dépasse pas. Elle ne se voit qu'en +comparant ce qui est servi à ce qui aurait dû l'être — c'est-à-dire en regardant la voiture. + +La passe confort l'avait déjà écrit (« mieux vaut la borne mieux informée que la borne la plus +sévère ») ; la tête de passe éco l'a réappris à ses dépens. **Une règle notée à un seul endroit +du code n'est pas une règle.** diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 888e5df..6e57574 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -6931,6 +6931,85 @@ void Simulation::testEcoProgressBaseAndRegime() * Un client qui lit « 1000 W achetés » ne peut pas deviner ce qu'il paiera dans deux heures ; * avec le RESTE à livrer et le TEMPS restant, il le peut. */ +void Simulation::testAModeWriteEndsTheDerogationAndKeepsTheChoice() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // \rule{LM-1500-g} — écrire un mode pendant une dérogation la TERMINE, et sans restituer. + // + // Le cas vient du banc, le 2026-09-08 : le bouton « boost » de l'app écrit encore + // ChargingModeNormal. Reçu pendant une dérogation, il faisait restituer les valeurs + // manuelles par l'amont — `chargingEnabled` à faux — et armait un verrou de 300 s, pendant + // que la dérogation continuait de commander 9 A. Deux commandeurs pour un organe, et six + // minutes de dérogation servie sur le papier dans une borne éteinte. + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + ThingManager *tm = NymeaCore::instance()->thingManager(); + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + + const QUuid chargerId = addCharger(QStringLiteral("ABC")); + QVERIFY(!chargerId.isNull()); + const QDateTime t0 = utcDateTime(QDate(2026, 9, 8), QTime(9, 0, 0)); + auto cycle = [&](const QDateTime &now) { + arb->simulationCallUpdate(now); + QCoreApplication::processEvents(); + }; + cycle(t0); // syncAdapters() crée l'EvAdapter + Thing *borne = tm->findConfiguredThing(ThingId(chargerId.toString())); + QVERIFY(borne); + borne->setStateValue("pluggedIn", true); + + const QString loadId = ThingId(chargerId.toString()).toString(); + auto ecrireMode = [&](ChargingInfo::ChargingMode m) { + ChargingInfo ci = arb->chargingInfo(ThingId(chargerId.toString())); + ci.setChargingMode(m); + arb->setChargingInfo(ci); + }; + auto modeCourant = [&]() { + return arb->chargingInfo(ThingId(chargerId.toString())).chargingMode(); + }; + + // ---- 1. CONTRE-ÉPREUVE : sans écriture de mode, la dérogation SURVIT au cycle ---------- + // Sans elle, la fin du cas 2 pourrait venir de n'importe quoi d'autre. + ecrireMode(ChargingInfo::ChargingModeEco); + QVERIFY(arb->poseDerogation(loadId, 600, 0, t0)); + QCOMPARE(arb->derogations().count(), 1); + cycle(t0.addSecs(60)); + QCOMPARE(arb->derogations().count(), 1); + QCOMPARE(modeCourant(), ChargingInfo::ChargingModeEco); + + // ---- 2. UNE ÉCRITURE DE MODE LA TERMINE ------------------------------------------------ + // Choisir un mode est une décision DURABLE ; une dérogation répond à un besoin + // ponctuel. Le durable prime, et il prime tout de suite. + ecrireMode(ChargingInfo::ChargingModeEcoWithMinCurrent); + cycle(t0.addSecs(120)); + QVERIFY2(arb->derogations().isEmpty(), "une écriture de mode doit terminer la dérogation"); + + // ---- 3. …ET LE CHOIX DE L'UTILISATEUR EST GARDÉ ---------------------------------------- + // C'est ici que le test mord : le mode MÉMORISÉ était « Eco », et le restituer aurait + // écrasé le « Surplus + minimum réseau » que l'utilisateur vient de choisir. On lui + // reprendrait sa décision au nom de la nôtre, un tour de boucle plus tard, sans qu'il + // puisse le voir. + QCoreApplication::processEvents(); // laisse passer une restitution différée + QCOMPARE(modeCourant(), ChargingInfo::ChargingModeEcoWithMinCurrent); + + // ---- 4. La fin par DURÉE, elle, restitue bien ------------------------------------------ + // Contre-épreuve de la contre-épreuve : si la restitution ne marchait jamais, le cas 3 + // passerait pour une bonne raison qui n'existe pas. + ecrireMode(ChargingInfo::ChargingModeNormal); + QVERIFY(arb->poseDerogation(loadId, 60, 0, t0.addSecs(120))); + QCOMPARE(modeCourant(), ChargingInfo::ChargingModeEco); // forcée en éco pour la durée + cycle(t0.addSecs(300)); // bien au-delà des 60 s + QVERIFY(arb->derogations().isEmpty()); + QCoreApplication::processEvents(); // la restitution est différée + QCOMPARE(modeCourant(), ChargingInfo::ChargingModeNormal); // le mode MÉMORISÉ, pas un défaut +#endif +} + void Simulation::testDerogationTakesTheHeadButNotTheSafety() { #ifndef ETM_ARBITRATOR @@ -6944,49 +7023,57 @@ void Simulation::testDerogationTakesTheHeadButNotTheSafety() QVERIFY(arb); RuleBasedScheduler sched(arb); - const QDateTime t0 = utcDateTime(QDate(2026, 9, 8), QTime(9, 0, 0)); + const QDateTime t0 = utcDateTime(QDate(2026, 9, 8), QTime(9, 0, 0)); const QDateTime fin = t0.addSecs(1800); - // \rule{LM-1500-e} — la tête de passe, et sa borne de sûreté. - // Deux charges. La BORNE porte le rang 9 — la QUEUE — et c'est délibéré : si la dérogation - // passait quand même devant l'ECS de rang 1, ce ne peut être que parce qu'elle prend la - // tête, et non parce qu'elle avait le meilleur rang. - auto planifier = [&](bool derogation, double margeW, const QString &suffixe) + // TROIS charges, et leurs rangs sont l'inverse de l'ordre attendu — c'est ainsi que la tête + // de passe se prouve. L'ECS porte le rang 1, le routeur le 5, la borne le 9 (la queue). + // Aucun surplus : la maison achète. + auto planifier = [&](bool derogBorne, bool derogRouteur, double margeW, const QString &sfx) -> QHash { SurplusContext ctx; ctx.timestamp = t0; - ctx.meter.importW = 500; // la maison ACHÈTE : aucun surplus du tout + ctx.meter.importW = 500; ctx.drawMargin.borne = true; ctx.drawMargin.watts = margeW; ctx.drawMargin.source = DrawCapSource::Breaker; ctx.drawMargin.phase = 0; LoadContext ecs; - ecs.id = QStringLiteral("ecs-%1").arg(suffixe); + ecs.id = QStringLiteral("ecs-%1").arg(sfx); ecs.label = "ECS"; ecs.adapter = "etmvariableload"; - ecs.priority = 1; // RANG 1 — devant la borne + ecs.priority = 1; // RANG 1 — devant tout le monde ecs.declared.maxPowerW = 3000; ecs.needs.minEnergyWhPerDay = 2000; ecs.needs.dailyDeadline = "11:00"; // 2 h → 1 000 W de cadence ecs.telemetry.sessionMeasurable = true; - ecs.telemetry.sessionWh = 0; ctx.loads.append(ecs); + LoadContext routeur; + routeur.id = QStringLiteral("routeur-%1").arg(sfx); + routeur.label = "Routeur"; + routeur.adapter = "etmvariableload"; + routeur.priority = 5; + routeur.declared.maxPowerW = 3000; + routeur.derogation = derogRouteur; + routeur.derogationEndsAt = fin; + ctx.loads.append(routeur); + LoadContext borne; - borne.id = QStringLiteral("borne-%1").arg(suffixe); + borne.id = QStringLiteral("borne-%1").arg(sfx); borne.label = "Borne"; borne.adapter = "evcharger"; borne.priority = 9; // RANG DE QUEUE + // Pour une borne, `maxPowerW` est le plafond DU CYCLE : l'adaptateur y a déjà fondu + // `evPhaseAllowanceW()`, c'est-à-dire la marge de soutirage MULTIPLIÉE par le nombre de + // phases occupées. C'est ce qui rend un second écrêtage inutile — et faux. borne.declared.maxPowerW = 7000; borne.telemetry.pluggedIn = true; - borne.derogation = derogation; + borne.derogation = derogBorne; borne.derogationEndsAt = fin; ctx.loads.append(borne); - // Le plan est tenu dans une LOCALE : en C++17, le temporaire d'une expression de - // range-for est détruit avant le corps de la boucle, et itérer sur - // `getPlan(ctx).timeSlots.first().actions` lirait un plan déjà libéré. const Plan plan = sched.getPlan(ctx); QHash par; for (const LoadAction &a : plan.timeSlots.first().actions) @@ -6994,17 +7081,15 @@ void Simulation::testDerogationTakesTheHeadButNotTheSafety() return par; }; - // 1. CONTRE-ÉPREUVE — la MÊME borne SANS dérogation. Aucun surplus, aucune obligation : elle - // n'a rien à recevoir, et l'ECS prend les 1 000 W de sa cadence. Sans cette ligne, on ne - // saurait pas si les 7 000 W du cas suivant viennent de la dérogation ou d'ailleurs. - const QHash sans = planifier(false, 10000, "sans"); + // 1. CONTRE-ÉPREUVE — sans dérogation et sans surplus, la borne ne reçoit rien et l'ECS + // prend les 1 000 W de sa cadence. Sans elle, on ne saurait pas d'où viennent les + // 7 000 W du cas suivant. + const QHash sans = planifier(false, false, 10000, "sans"); QCOMPARE(qRound(sans.value("borne").estimatedPowerW), 0); QCOMPARE(qRound(sans.value("ecs").estimatedPowerW), 1000); - // 2. AVEC DÉROGATION — la borne est servie à son MAXIMUM, achetée au réseau, et son motif - // le dit. Elle ne module pas : c'est la définition côté client, « à pleine puissance, - // tout de suite ». - const QHash avec = planifier(true, 10000, "avec"); + // 2. AVEC DÉROGATION — la borne est servie à SON plafond de cycle, achetée au réseau. + const QHash avec = planifier(true, false, 10000, "avec"); const LoadAction b = avec.value("borne"); QCOMPARE(qRound(b.estimatedPowerW), 7000); QCOMPARE(b.reason.code, QString(DecisionCode::DerogationActive)); @@ -7012,37 +7097,43 @@ void Simulation::testDerogationTakesTheHeadButNotTheSafety() QCOMPARE(b.levels.at(0).level, QString("eco")); QCOMPARE(b.levels.at(0).funding, LoadAction::Grid); QCOMPARE(qRound(b.counts.value(CountKey::DrawCommitted)), 7000); - // L'échéance du FILET est publiée : sans elle, l'écran montre un état sans horizon, et - // l'utilisateur ne peut pas distinguer « ça va s'arrêter » de « ça durera toujours ». + QCOMPARE(b.reason.params.value("boundBy").toString(), QString("loadCeiling")); QVERIFY(!b.reason.params.value("endsAt").toString().isEmpty()); - // Pas de cible ⇒ pas de clé. Un `targetWh: 0` se lirait « cible atteinte ». - QVERIFY(!b.reason.params.contains("targetWh")); - // L'ECS garde sa cadence : la dérogation passe DEVANT, elle ne prive pas ce qui suit tant - // que l'autorisation le permet. + QVERIFY2(!b.reason.params.contains("targetWh"), "pas de cible ⇒ pas de clé, jamais un zéro"); QCOMPARE(qRound(avec.value("ecs").estimatedPowerW), 1000); - // 3. LA TÊTE DE PASSE, prouvée par la RARETÉ. 7 500 W d'autorisation pour 7 000 + 1 000 - // demandés : il n'y en a pas pour les deux. La borne de rang 9 est servie ENTIÈREMENT et - // c'est l'ECS de rang 1 qui prend le reste — l'inverse exact de l'ordre des rangs. - const QHash rare = planifier(true, 7500, "rare"); + // 3. LA TÊTE DE PASSE, prouvée par la RARETÉ. 7 500 W d'autorisation pour 8 000 demandés : + // la borne de rang 9 est servie ENTIÈREMENT et c'est l'ECS de rang 1 qui prend le + // reste — l'inverse exact de l'ordre des rangs. + const QHash rare = planifier(true, false, 7500, "rare"); QCOMPARE(qRound(rare.value("borne").estimatedPowerW), 7000); QCOMPARE(qRound(rare.value("ecs").estimatedPowerW), 500); - // 4. …MAIS ELLE NE SURPLOMBE PAS LA SÛRETÉ. Le plafond de soutirage la BORNE comme n'importe - // quel achat : 1 000 W d'autorisation, 1 000 W servis, et le motif dit QUI a bridé. - // C'est la ligne qui distingue une dérogation d'un second chemin d'achat — celui qu'on a - // refusé au minimum réseau. - const QHash bridee = planifier(true, 1000, "bridee"); - QCOMPARE(qRound(bridee.value("borne").estimatedPowerW), 1000); - QCOMPARE(bridee.value("borne").reason.params.value("boundBy").toString(), QString("drawCap")); - QCOMPARE(qRound(bridee.value("ecs").estimatedPowerW), 0); + // 4. \rule{LM-1500-h} — UNE BORNE N'EST PAS ÉCRÊTÉE DEUX FOIS. Défaut trouvé au banc le + // 2026-09-08. + // `margeRestante` est la marge d'UNE phase ; le plafond d'une borne triphasée est une + // enveloppe sur TROIS. Les comparer divisait son allocation par trois : 9 A par phase au + // lieu de 27, 4 kW au lieu des 11 que la voiture pouvait prendre. Avec 1 000 W de marge + // de maison, la borne reste donc servie à 7 000 — la sûreté la borne AILLEURS, dans le + // `maxPowerW` que l'adaptateur a déjà écrêté avec `evPhaseAllowanceW()`. + const QHash etroite = planifier(true, false, 1000, "etroite"); + QCOMPARE(qRound(etroite.value("borne").estimatedPowerW), 7000); + QCOMPARE(etroite.value("borne").reason.params.value("boundBy").toString(), + QString("loadCeiling")); - // 5. AUTORISATION NULLE — rien n'est servi, et la dérogation n'émet AUCUN niveau plutôt - // qu'un zéro forgé. Une dérogation qui passerait outre ferait disjoncter, ce qui est - // précisément ce qu'aucun besoin ne justifie. - const QHash zero = planifier(true, 0, "zero"); - QCOMPARE(qRound(zero.value("borne").estimatedPowerW), 0); - QVERIFY2(zero.value("borne").levels.isEmpty(), + // 5. …MAIS UNE CHARGE ORDINAIRE, SI. Le routeur n'a pas d'allocation par phase : la marge + // de la maison est la seule chose qui le borne, et le motif dit que c'est elle. C'est la + // contre-épreuve du cas 4 — sans elle, on ne saurait pas si la marge borne encore + // quelqu'un, et le cas 4 passerait pour une bonne raison qui n'existe pas. + const QHash bride = planifier(false, true, 1000, "bride"); + QCOMPARE(qRound(bride.value("routeur").estimatedPowerW), 1000); + QCOMPARE(bride.value("routeur").reason.params.value("boundBy").toString(), QString("drawCap")); + + // 6. AUTORISATION NULLE sur une charge ordinaire : rien n'est servi, et AUCUN niveau n'est + // publié plutôt qu'un zéro forgé. + const QHash zero = planifier(false, true, 0, "zero"); + QCOMPARE(qRound(zero.value("routeur").estimatedPowerW), 0); + QVERIFY2(zero.value("routeur").levels.isEmpty(), "sans autorisation il n'y a rien à publier : pas de niveau à zéro"); #endif } diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 69957a8..7992e14 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -209,6 +209,7 @@ private slots: void testARefusedStateIsDistinguishableFromAnApplied(); void testL4ShedsAPacAndSaysItForcedTheLock(); void testDerogationTakesTheHeadButNotTheSafety(); + void testAModeWriteEndsTheDerogationAndKeepsTheChoice(); // ── §12 / LM-1209 ────────────────────────────────────────────────────── // Une borne détectée après la mise en service entre EN QUEUE : elle ne passe devant