fix(dérogation): une borne n'est pas écrêtée deux fois, et un mode écrit la termine
Deux défauts trouvés par le premier essai au banc (2026-09-08, 11:05→11:16), et le premier est celui que Patrick a vu en regardant la voiture. LM-1500-h — UNE BORNE N'EST PAS ÉCRÊTÉE DEUX FOIS. La tête de passe éco appliquait `margeRestante` au plafond d'une borne. Or `margeRestante` est la marge d'UNE phase, et le plafond d'une borne triphasée est une enveloppe sur TROIS : les comparer divise son allocation par trois. Mesuré : marge de 6 210 W sur la phase la plus chargée, plafond de cycle à 13 800 W, borne servie à 6 210 — 9 A par phase au lieu de 27, 4 kW au lieu des 11 que la voiture pouvait prendre. Le plafond d'une borne porte DÉJÀ l'autorisation de soutirage : evPhaseAllowanceW() la calcule et la multiplie par le nombre de phases occupées. La passe confort ne fait pas cette faute, et son commentaire dit pourquoi depuis 3g-2 — « mieux vaut la borne mieux informée que la borne la plus sévère ». 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. L'erreur allait dans le sens PRUDENT, donc rien ne la signalait — ni le disjoncteur, ni le délestage, ni un test qui vérifie qu'on ne dépasse pas. Elle ne se voyait qu'en regardant la voiture. LM-1500-g — ÉCRIRE UN MODE PENDANT UNE DÉROGATION LA TERMINE. Choisir un mode est 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é, qui écraserait celui que l'utilisateur vient de choisir. Le cas vient du banc : 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, sans pouvoir en dire la cause. Deux autres corrections du même essai : - DEROGATION_ACTIVE et DEROGATION_ENDED sont rendus en français au journal ; ils sortaient en « (motif inconnu …) ». Le repli 7-c a tenu — le code brut et ses paramètres, jamais une chaîne vide — mais la ligne lue par l'utilisateur était illisible. - poseDerogation() prend l'instant en paramètre. Elle lisait l'horloge murale quand le cycle lit l'horloge injectée : sans effet en production, où les deux sont la même, mais sous test la fin par durée n'arrivait jamais. Trouvé par le test, pas par la lecture. Et `setChargingInfo() déclenche un cycle` est gravé dans AGENTS.md comme une PROPRIÉTÉ : ce qui l'entoure a un ordre qui compte, dans les deux sens, et ENERGY_SIMULATION supprime précisément l'appel — le chemin fautif n'existe pas sous test. simulation 69/69, loadmodel 26/26, charging 48/48, spotmarket 32/32, ci-quality 4/4, build 0 erreur 0 warning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
This commit is contained in:
parent
106806d8ce
commit
be401dec40
30
AGENTS.md
30
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
|
||||
|
||||
13
INTERFACE.md
13
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.
|
||||
>
|
||||
|
||||
@ -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 <etm.schurig@gmail.com> 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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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<int>(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<int>(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()
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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)");
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.**
|
||||
|
||||
@ -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<EnergyArbitrator *>(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<QString, LoadAction> {
|
||||
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<QString, LoadAction> 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<QString, LoadAction> 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<QString, LoadAction> 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<QString, LoadAction> avec = planifier(true, 10000, "avec");
|
||||
// 2. AVEC DÉROGATION — la borne est servie à SON plafond de cycle, achetée au réseau.
|
||||
const QHash<QString, LoadAction> 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<QString, LoadAction> 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<QString, LoadAction> 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<QString, LoadAction> 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<QString, LoadAction> 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<QString, LoadAction> 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<QString, LoadAction> 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<QString, LoadAction> 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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user