feat(§15): le boost est une DÉROGATION — servie au maximum, et qui s'efface
Une dérogation n'est pas un mode. Un mode se choisit une fois et vaut jusqu'au prochain choix ; une dérogation répond à un besoin ponctuel et s'éteint avec lui. Un boost encore actif après la fin est un mode qu'on a oublié de quitter. Elle se superpose au mode courant, sert la charge à son MAXIMUM en tête de passe éco — devant toute autre charge, quel que soit son rang — financée au réseau. Le rang classe ce que l'installateur décide (LM-1002) ; une dérogation est une action ponctuelle de l'utilisateur, et passe par une porte que LM-1002 ne ferme pas. Mais elle ne surplombe PAS la sûreté : DRAW_CAP la borne et le délestage la reprend. Servie la première dans ce qui RESTE, jamais au-delà — sans quoi on rouvrirait le second chemin d'achat refusé au minimum réseau (LM-1500-e). LA DURÉE EST OBLIGATOIRE ET BORNÉE, refusée à la pose hors de [60 s, 6 h]. C'est la règle du lot : une dérogation qui ne sait pas dire quand elle finit ne devrait pas exister (LM-1500). Elle reste armée même avec une cible, parce qu'une cible repose sur un instrument et qu'un instrument peut se taire — à la perte du lien, la V2C FIGE sessionEnergy au lieu de l'effacer (LM-1012-d) : une dérogation « 20 kWh puis stop » dont le compteur reste à 12 kWh n'atteint jamais sa cible. Trois décisions prises en écrivant, absentes du design : - Les fins se lisent dans le CONTEXTE DU CYCLE, pas dans les adaptateurs. evaluerDerogations() tourne entre buildContext() et getPlan(), sur ce que le scheduler va lire. Une seconde lecture donnerait une seconde vérité, et une dérogation pourrait finir sur une mesure que le plan ne voit pas. C'est aussi ce qui rend le débranchement gratuit : la borne est déjà filtrée du contexte, son absence EST le signal. - measurementLost ne mord QUE s'il y a une cible (LM-1500-b). Sans cible, rien ne dépend de la mesure : couper là punirait la charge du silence d'un instrument dont elle n'a pas besoin. Et le critère est telemetry.sessionMeasurable, jamais progress.regime : `progress` est absent d'une charge sans obligation en énergie, c'est-à-dire exactement le cas du boost — le critère aurait été inerte. - La base de la cible se capture au PREMIER CYCLE, pas à la pose : au moment où l'utilisateur appuie, aucun cycle n'a construit de contexte. Prendre 0 compterait depuis le début de la session — une dérogation « 5 kWh » posée sur une session déjà à 6 kWh serait atteinte avant d'avoir commencé (LM-1012-b). Le mode précédent se MÉMORISE, il ne se déduit pas (LM-1500-d). Revenir à un défaut détruirait un réglage en silence, et sur une borne la sortirait de l'arbitrage : une dérogation qui laisse l'installation moins pilotée qu'avant elle. Une borne en manuel est basculée en éco pour la durée — seul cas où le mode est écrit, et modeForce retient qu'on l'a fait. La passe confort est SAUTÉE sous dérogation : la fusion des niveaux est une addition, et la dérogation demande déjà le maximum. Conséquence comptable à connaître : elle est comptée entièrement en achat alors que le surplus présent la finance en partie — gridW la surestime au soleil. Approximation héritée du §10, non aggravée, et la raison pour laquelle la première vérification au banc se fait le matin. La fin est un ÉVÉNEMENT et dit sa CAUSE (LM-1500-f), publiée dans derogationEnded[] au NIVEAU DE LA TRAME : au débranchement la charge n'est déjà plus dans loads[], et l'événement disparaîtrait quand il explique le plus de choses. Cinq causes, cinq gestes. Le mode dégradé la porte aussi — c'est là que measurementLost se produit. telemetrySignificantlyDiffers ne la filtre jamais : un événement ne vit qu'un cycle. Éprouvé par testDerogationTakesTheHeadButNotTheSafety, dont la tête de passe est prouvée par la RARETÉ : 7 500 W d'autorisation pour 8 000 demandés, la borne de rang 9 servie ENTIÈREMENT et l'ECS de rang 1 réduite aux 500 restants — l'inverse exact de l'ordre des rangs. Contre-épreuve : la même borne sans dérogation reçoit 0 W. Et à autorisation nulle, AUCUN niveau — pas un niveau à zéro. simulation 68/68, 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
06e728415f
commit
1eae99143b
72
INTERFACE.md
72
INTERFACE.md
@ -1122,6 +1122,29 @@ 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 |
|
||||
|
||||
> ### La FIN d'une dérogation est un événement de TRAME, pas un motif
|
||||
>
|
||||
> Elle est publiée dans `derogationEnded[]` **à côté** de `loads[]`, et non dans la charge :
|
||||
> au débranchement la charge n'y est **déjà plus**, et l'événement disparaîtrait exactement quand
|
||||
> il explique le plus de choses. `decision` porte ce que le cycle a **décidé** ; une fin est un
|
||||
> **événement**, et l'y loger obligerait à choisir entre les deux.
|
||||
>
|
||||
> ```json
|
||||
> "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
|
||||
> laquelle a mordu laisserait l'utilisateur relancer une dérogation que le lien empêchera encore.
|
||||
>
|
||||
> `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.
|
||||
>
|
||||
> Le champ est **absent** hors du cycle de la fin — jamais une liste vide qui se lirait « rien ne
|
||||
> s'est terminé, on regarde ».
|
||||
|
||||
> ### `ECO_FLOOR_GRID` sert DEUX besoins, et dit lequel a décidé
|
||||
>
|
||||
@ -1545,6 +1568,55 @@ Retourne les cotations pondérées du provider actif. Liste vide si désactivé
|
||||
|
||||
---
|
||||
|
||||
### `NymeaEnergy.SetLoadDerogation`
|
||||
|
||||
**Une dérogation n'est pas un mode.** Elle se superpose au mode courant, sert la charge à son
|
||||
**maximum**, financée au **réseau**, en **tête de passe éco** — devant toute autre charge, quel
|
||||
que soit son rang — et **s'efface d'elle-même**. C'est le « boost » côté client.
|
||||
|
||||
Elle ne passe donc **pas** par `SetChargingInfo`, qui écrit un réglage durable.
|
||||
|
||||
**Params :**
|
||||
|
||||
| Champ | Type | Description |
|
||||
|---|---|---|
|
||||
| `loadId` | String | Charge visée (`ThingId` pour une borne, `id` de `LoadConfig` sinon) |
|
||||
| `durationS` | Int | **Obligatoire**, borné `[60, 21600]`. C'est le **filet**, et il reste armé même quand `targetWh` est donné |
|
||||
| `targetWh` | Double | *(optionnel)* Énergie livrée **depuis la pose**. `0` ou absent = pas de cible |
|
||||
|
||||
> **La durée est obligatoire, et c'est la règle du lot** : une dérogation qui ne sait pas dire
|
||||
> quand elle finit ne devrait pas exister (`LM-1500`). Elle reste armée même avec une cible,
|
||||
> parce qu'une cible repose sur un **instrument** et qu'un instrument peut se taire — à la perte
|
||||
> du lien Modbus, la V2C **fige** `sessionEnergy` au lieu de l'effacer.
|
||||
|
||||
> **Une énergie, jamais un SoC.** Il n'y a pas de communication véhicule ; « 80 % » demanderait la
|
||||
> capacité, l'état de santé et le rendement (`LM-1009`).
|
||||
|
||||
**Ce qui la borne** : l'autorisation de soutirage (`DRAW_CAP`) et le délestage — **elle prend la
|
||||
tête de la passe éco, elle ne surplombe pas la sûreté**. Servie la première *dans ce qui reste*,
|
||||
jamais au-delà. Le motif publie `boundBy: "drawCap"` quand le plafond a mordu.
|
||||
|
||||
**Le mode de charge précédent est mémorisé et restitué à la fin.** Si la borne était en
|
||||
`ChargingModeNormal` (manuel, donc hors arbitrage), elle est basculée en éco pour la durée — seul
|
||||
cas où le mode est écrit — puis **rendue à `Normal`**.
|
||||
|
||||
**Returns :** `{ "energyError": "EnergyErrorNoError" }` — `EnergyErrorInvalidParameter` si la
|
||||
charge est inconnue ou la durée hors bornes.
|
||||
|
||||
---
|
||||
|
||||
### `NymeaEnergy.ClearLoadDerogation`
|
||||
|
||||
Lève une dérogation **avant son terme** et restitue le mode mémorisé. La fin est publiée avec la
|
||||
cause `cancelled` — une fin **par décision**, distincte des quatre fins par mécanisme.
|
||||
|
||||
**Params :** `{ "loadId": "{...}" }`
|
||||
|
||||
**Returns :** `{ "energyError": "EnergyErrorNoError" }` — `EnergyErrorInvalidParameter` si aucune
|
||||
dérogation n'était en cours sur cette charge.
|
||||
|
||||
---
|
||||
|
||||
## Notifications
|
||||
|
||||
S'abonner via `JSONRPC.SetNotificationStatus` avec le namespace `"NymeaEnergy"`.
|
||||
|
||||
@ -1,3 +1,29 @@
|
||||
powersync-energy-plugin-nymea (1.15.2+etm59) trixie; urgency=medium
|
||||
|
||||
* Le BOOST, implémenté comme une DÉROGATION et non comme un mode. Elle se
|
||||
superpose au mode courant, sert la charge à son maximum en tête de passe
|
||||
éco — devant toute autre charge, quel que soit son rang — financée au
|
||||
réseau, et s'efface d'elle-même. Deux verbes : SetLoadDerogation et
|
||||
ClearLoadDerogation ; la durée est OBLIGATOIRE et bornée [60 s, 6 h]
|
||||
(LM-1500 : une dérogation qui ne sait pas dire quand elle finit ne devrait
|
||||
pas exister). Quatre fins par mécanisme — cible atteinte, mesure perdue,
|
||||
débranchement, durée — plus « cancelled », publiées dans derogationEnded[]
|
||||
au niveau de la trame : au débranchement, la charge n'est déjà plus dans
|
||||
loads[]. Le mode précédent est MÉMORISÉ et restitué (LM-1500-d) ; une borne
|
||||
en manuel est basculée en éco pour la durée, seul cas où le mode est écrit.
|
||||
DRAW_CAP la borne et le délestage la reprend : elle prend la tête de la
|
||||
passe éco, elle ne surplombe pas la sûreté (LM-1500-e).
|
||||
* La fin par MESURE ne mord que s'il y a une cible (LM-1500-b), et son critère
|
||||
est telemetry.sessionMeasurable et non progress.regime : `progress` est
|
||||
absent d'une charge sans obligation en énergie — exactement le cas du boost,
|
||||
où le critère aurait été inerte.
|
||||
* §15 du modèle de charge : LM-1500 et ses six déclinaisons.
|
||||
Simulation::testDerogationTakesTheHeadButNotTheSafety prouve la tête de
|
||||
passe par la RARETÉ — 7 500 W d'autorisation pour 8 000 demandés, la borne
|
||||
de rang 9 servie entièrement et l'ECS de rang 1 réduite au reste.
|
||||
|
||||
-- Patrick Schurig <etm.schurig@gmail.com> Tue, 08 Sep 2026 10:29:40 +0200
|
||||
|
||||
powersync-energy-plugin-nymea (1.15.2+etm58) trixie; urgency=medium
|
||||
|
||||
* LM-1004 corrigée : le recrédit anti-clignotement appartient à la passe qui
|
||||
|
||||
@ -212,3 +212,86 @@ qu'en mock, et c'est le sixième manque de `INVENTAIRE_MOCK.md` sous un autre an
|
||||
|
||||
**La durée, elle, s'éprouve partout** — une raison de plus d'en faire le filet plutôt qu'un
|
||||
recours.
|
||||
|
||||
---
|
||||
|
||||
## 9. Implémenté — `+etm59`, 2026-09-08
|
||||
|
||||
Le design ci-dessus est posé **d'un bloc** et non par les quatre pas du §7 : la fenêtre de peu de
|
||||
production du matin était le bon moment pour éprouver un mécanisme qui **achète**, et découper
|
||||
l'implémentation aurait fait passer cette fenêtre.
|
||||
|
||||
### Où vit quoi
|
||||
|
||||
| | fichier |
|
||||
|---|---|
|
||||
| le type, les quatre fins, les seuils | `etm/types/derogation.h` |
|
||||
| pose, levée, évaluation des fins, restitution du mode | `etm/energyarbitrator.cpp` |
|
||||
| service en **tête de passe éco** | `etm/scheduler/rulebasedscheduler.cpp` |
|
||||
| `SetLoadDerogation` / `ClearLoadDerogation`, `derogationEnded` | `nymeaenergyjsonhandler.cpp` |
|
||||
|
||||
### Trois décisions prises en écrivant, qui ne sont pas dans le design
|
||||
|
||||
**a. Les fins se lisent dans le CONTEXTE DU CYCLE, pas dans les adaptateurs.**
|
||||
`evaluerDerogations()` tourne **entre** `buildContext()` et `getPlan()`, sur les `LoadContext`
|
||||
que le scheduler va lire. Une seconde lecture donnerait une seconde vérité, et une dérogation
|
||||
pourrait se terminer sur une mesure que le plan ne voit pas. C'est aussi ce qui rend le
|
||||
débranchement gratuit : une borne débranchée est déjà filtrée du contexte par
|
||||
`evParticipatesInWaterfall()`, son absence **est** le signal.
|
||||
|
||||
**b. `measurementLost` ne mord QUE s'il y a une cible.**
|
||||
Le design disait « une dérogation dont la cible cesse d'être mesurable doit cesser ». Sans cible,
|
||||
rien ne dépend de la mesure : le filet de durée suffit, et couper là punirait la charge du
|
||||
silence d'un instrument dont elle n'a pas besoin. La garde porte donc sur `targetWh > 0`.
|
||||
|
||||
C'est aussi pourquoi le critère est `telemetry.sessionMeasurable` et **non** `progress.regime` :
|
||||
`progress` est absent sur une charge sans obligation en énergie — c'est-à-dire exactement le cas
|
||||
du boost, où le critère aurait été inerte.
|
||||
|
||||
**c. La base de la cible se capture au PREMIER CYCLE, pas à la pose.**
|
||||
Au moment où l'utilisateur appuie, aucun cycle n'a construit de contexte et le cumul de session
|
||||
n'est lisible nulle part. Prendre `0` compterait depuis le début de la **session de charge** :
|
||||
une dérogation « 5 kWh » posée sur une session déjà à 6 kWh serait atteinte avant d'avoir
|
||||
commencé. Mécanisme de `LM-1012-b`, même raison — une base se prend sur ce qu'on mesure.
|
||||
|
||||
### Le seul endroit où le mode est ÉCRIT
|
||||
|
||||
Une borne en `Normal` (manuel) est **hors waterfall** : le moteur ne la commande pas, donc une
|
||||
dérogation n'y produirait rien du tout. La pose la bascule alors en éco pour la durée, et
|
||||
`modeForce` retient qu'elle l'a fait. C'est le seul cas — et c'est lui qui donne sa raison d'être
|
||||
à `previousMode` : sans mémorisation, on rendrait une borne manuelle en éco, c'est-à-dire qu'on
|
||||
lui prendrait un réglage en silence.
|
||||
|
||||
### La passe confort est SAUTÉE sous dérogation
|
||||
|
||||
La fusion des niveaux est une **addition** (`la.powerW += eco.targetW`). La dérogation demande
|
||||
déjà le maximum de la charge : y ajouter du surplus dépasserait son plafond matériel. Le surplus
|
||||
non dépensé reste au budget et passe aux charges suivantes.
|
||||
|
||||
**Conséquence comptable à connaître** : la dérogation est comptée **entièrement en achat** alors
|
||||
que le surplus présent la finance en partie, physiquement — `gridW` la surestime dès qu'il y a du
|
||||
soleil. Ce n'est pas une approximation neuve : le §10 la porte déjà pour le plancher éco, et elle
|
||||
sera reprise avec lui. C'est aussi pourquoi la première vérification au banc se fait le matin.
|
||||
|
||||
### Ce que le test prouve, et par quelle contre-épreuve
|
||||
|
||||
`Simulation::testDerogationTakesTheHeadButNotTheSafety` — deux charges, la borne au **rang 9**
|
||||
(la queue) et l'ECS au **rang 1**, sans aucun surplus :
|
||||
|
||||
1. **contre-épreuve** — la même borne **sans** dérogation reçoit `0 W`, l'ECS ses 1 000 W de
|
||||
cadence. Sans elle, on ne saurait pas d'où viennent les 7 000 W du cas suivant ;
|
||||
2. avec dérogation : 7 000 W, `funding = Grid`, motif `DEROGATION_ACTIVE`, `endsAt` publié, et
|
||||
**pas** de `targetWh` puisqu'il n'y en a pas ;
|
||||
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**, l'ECS de rang 1 prend les 500 restants. L'inverse
|
||||
exact de l'ordre des rangs, donc la preuve que c'est bien la tête de passe qui agit ;
|
||||
4. 1 000 W d'autorisation : servie à 1 000, `boundBy: drawCap` — elle ne surplombe pas la sûreté ;
|
||||
5. autorisation nulle : **aucun niveau**, pas un niveau à zéro.
|
||||
|
||||
### Ce qui reste ouvert
|
||||
|
||||
- La **fin par cible** n'est pas éprouvée au banc : elle demande une session assez longue pour
|
||||
franchir un seuil, et la V2C a un `sessionEnergy` qui **se fige** à la perte du lien
|
||||
(`LM-1012-d`) — c'est précisément ce que le filet de durée couvre.
|
||||
- `ClearLoadDerogation` publie la cause `cancelled`, qui n'est pas une des quatre fins par
|
||||
mécanisme : c'est une fin **par décision**, et la distinguer est le but.
|
||||
|
||||
@ -407,6 +407,10 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
||||
shedWaterfallForOverload(currentDateTime);
|
||||
|
||||
SurplusContext ctx = buildContext(currentDateTime);
|
||||
// Les dérogations vivent ENTRE la construction et la décision : elles se terminent sur ce
|
||||
// que le contexte rapporte, et les survivantes y sont marquées avant que le plan ne soit
|
||||
// fait. Les évaluer après le plan servirait un cycle de plus une dérogation déjà finie.
|
||||
evaluerDerogations(ctx, currentDateTime);
|
||||
Plan plan = m_scheduler->getPlan(ctx);
|
||||
Slot slot = plan.slotCovering(currentDateTime);
|
||||
|
||||
@ -823,7 +827,13 @@ bool EnergyArbitrator::evParticipatesInWaterfall(EvCharger *ev) const
|
||||
// monophasée et le budget divisé par trois.
|
||||
|
||||
// Mode manuel : l'utilisateur pilote, l'arbitre s'abstient. Hors arbitrage — pas rang 0.
|
||||
if (info.chargingMode() == ChargingInfo::ChargingModeNormal)
|
||||
//
|
||||
// Sauf DÉROGATION en cours. La pose bascule bien le mode en éco (cf. poseDerogation), donc
|
||||
// ce test suffirait ; on le garde explicite parce qu'il dit l'ordre des choses : la
|
||||
// dérogation SURMONTE le mode. Si un jour la pose cessait d'écrire le mode, la borne
|
||||
// resterait servie au lieu de sortir silencieusement de l'arbitrage.
|
||||
if (info.chargingMode() == ChargingInfo::ChargingModeNormal
|
||||
&& !m_derogations.contains(ev->id().toString()))
|
||||
return false;
|
||||
|
||||
// Aucun véhicule branché : la borne ne peut RIEN consommer de ce qu'on lui allouerait.
|
||||
@ -1051,6 +1061,179 @@ bool EnergyArbitrator::evSurplusPlannedByWaterfall(EvCharger *evCharger) const
|
||||
return evParticipatesInWaterfall(evCharger);
|
||||
}
|
||||
|
||||
namespace {
|
||||
//! Bornes de la durée du filet : au moins une minute, au plus six heures. Une dérogation qui
|
||||
//! durerait davantage n'est plus un besoin ponctuel — c'est un mode qu'on n'a pas voulu nommer.
|
||||
constexpr int DerogationDureeMinS = 60;
|
||||
constexpr int DerogationDureeMaxS = 6 * 3600;
|
||||
}
|
||||
|
||||
bool EnergyArbitrator::poseDerogation(const QString &loadId, int dureeS, double targetWh)
|
||||
{
|
||||
const bool estBorne = m_adapters.contains(loadId);
|
||||
if (!estBorne && !m_loadAdapters.contains(loadId)) {
|
||||
qCWarning(dcNymeaEnergy()) << "[Dérogation] charge inconnue :" << loadId;
|
||||
return false;
|
||||
}
|
||||
// \rule{LM-1500} — la durée est BORNÉE des deux côtés, et l'absence de durée n'est pas
|
||||
// acceptée : une dérogation qui ne sait pas dire quand elle finit ne devrait pas exister.
|
||||
// Refuser ici est le seul endroit où on peut encore la faire respecter.
|
||||
if (dureeS < DerogationDureeMinS || dureeS > DerogationDureeMaxS) {
|
||||
qCWarning(dcNymeaEnergy()) << "[Dérogation] durée hors bornes :" << dureeS
|
||||
<< "s (attendu" << DerogationDureeMinS << ".."
|
||||
<< DerogationDureeMaxS << ")";
|
||||
return false;
|
||||
}
|
||||
|
||||
Derogation d;
|
||||
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();
|
||||
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
|
||||
// le cumul de session n'est lisible que là (cf. Derogation::baseCapturee).
|
||||
|
||||
if (estBorne) {
|
||||
const ThingId id(loadId);
|
||||
const ChargingInfo avant = chargingInfo(id);
|
||||
d.previousMode = static_cast<int>(avant.chargingMode());
|
||||
// Une borne en MANUEL est hors waterfall : le moteur ne la commande pas, et une
|
||||
// dérogation n'y produirait rien du tout. On la fait entrer dans l'arbitrage pour la
|
||||
// durée — et c'est le SEUL cas où le mode est touché.
|
||||
if (avant.chargingMode() == ChargingInfo::ChargingModeNormal) {
|
||||
ChargingInfo pendant = avant;
|
||||
pendant.setChargingMode(ChargingInfo::ChargingModeEco);
|
||||
setChargingInfo(pendant);
|
||||
d.modeForce = true;
|
||||
}
|
||||
}
|
||||
|
||||
m_derogations.insert(loadId, d);
|
||||
m_derogationFin.remove(loadId);
|
||||
qCInfo(dcNymeaEnergy()).noquote()
|
||||
<< QStringLiteral("[Dérogation] POSÉE %1 — durée %2 s, %3, mode mémorisé %4%5")
|
||||
.arg(loadId).arg(dureeS)
|
||||
.arg(d.targetWh > 0 ? QStringLiteral("cible %1 Wh").arg(d.targetWh, 0, 'f', 0)
|
||||
: QStringLiteral("sans cible"))
|
||||
.arg(d.previousMode)
|
||||
.arg(d.modeForce ? QStringLiteral(" (forcé en éco pour la durée)") : QString());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool EnergyArbitrator::leveDerogation(const QString &loadId)
|
||||
{
|
||||
if (!m_derogations.contains(loadId))
|
||||
return false;
|
||||
const Derogation d = m_derogations.take(loadId);
|
||||
rendreModeMemorise(d);
|
||||
m_derogationFin.insert(loadId, QStringLiteral("cancelled"));
|
||||
m_derogationModeRendu.insert(loadId, d.previousMode);
|
||||
qCInfo(dcNymeaEnergy()) << "[Dérogation] LEVÉE à la main" << loadId
|
||||
<< "— mode rendu" << d.previousMode;
|
||||
return true;
|
||||
}
|
||||
|
||||
void EnergyArbitrator::rendreModeMemorise(const Derogation &d)
|
||||
{
|
||||
// \rule{LM-1500-d} — RETOUR AU MODE MÉMORISÉ, et à lui seul. Revenir à un défaut sortirait
|
||||
// une borne manuelle
|
||||
// de la main de son utilisateur — le contraire exact de ce qu'une dérogation promet, qui
|
||||
// est de rendre la main.
|
||||
if (!d.modeForce || d.previousMode < 0)
|
||||
return;
|
||||
const ThingId id(d.loadId);
|
||||
if (!internalEvChargers().contains(id))
|
||||
return; // la borne a disparu : il n'y a plus de réglage à rendre
|
||||
ChargingInfo info = chargingInfo(id);
|
||||
info.setChargingMode(static_cast<ChargingInfo::ChargingMode>(d.previousMode));
|
||||
setChargingInfo(info);
|
||||
}
|
||||
|
||||
void EnergyArbitrator::evaluerDerogations(SurplusContext &ctx, const QDateTime &now)
|
||||
{
|
||||
// Motifs d'ÉVÉNEMENT : ils valent pour CE cycle et disparaissent. Une fin qui resterait
|
||||
// affichée serait indistinguable d'une fin qui vient de se produire.
|
||||
m_derogationFin.clear();
|
||||
m_derogationModeRendu.clear();
|
||||
if (m_derogations.isEmpty())
|
||||
return;
|
||||
|
||||
// On lit le CONTEXTE DU CYCLE, pas les adaptateurs : c'est exactement ce que le scheduler
|
||||
// va lire pour décider. Une seconde lecture donnerait une seconde vérité, et la dérogation
|
||||
// pourrait se terminer sur une mesure que le plan ne voit pas.
|
||||
QHash<QString, LoadContext *> parId;
|
||||
for (LoadContext &lc : ctx.loads)
|
||||
parId.insert(lc.id, &lc);
|
||||
|
||||
const QDateTime nowUtc = now.toUTC();
|
||||
for (const QString &id : m_derogations.keys()) {
|
||||
Derogation &d = m_derogations[id];
|
||||
LoadContext *lc = parId.value(id, nullptr);
|
||||
|
||||
// ABSENT DU CONTEXTE = la charge n'est plus arbitrable. Pour une borne c'est le
|
||||
// débranchement (evParticipatesInWaterfall la filtre), et la mesure du 2026-09-07 dit
|
||||
// que ce constat-là est FERME : `pluggedIn` tombe à faux et y reste. On le confirme
|
||||
// quand même — le seuil est bas, et l'asymétrie joue dans le bon sens.
|
||||
const bool branchee = lc && lc->telemetry.available;
|
||||
const bool mesurable = lc && lc->telemetry.sessionMeasurable;
|
||||
|
||||
d.absencesConsecutives = branchee ? 0 : d.absencesConsecutives + 1;
|
||||
d.sansMesureConsecutifs = mesurable ? 0 : d.sansMesureConsecutifs + 1;
|
||||
|
||||
// Base capturée au premier cycle qui la rend lisible, jamais supposée.
|
||||
if (!d.baseCapturee && mesurable) {
|
||||
d.baseWh = lc->telemetry.sessionWh;
|
||||
d.baseCapturee = true;
|
||||
}
|
||||
const double livreWh = (d.baseCapturee && mesurable)
|
||||
? qMax(0.0, lc->telemetry.sessionWh - d.baseWh)
|
||||
: 0.0;
|
||||
|
||||
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) {
|
||||
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
|
||||
// cesser : elle ACHÈTE, et
|
||||
// acheter sans savoir où l'on en est, c'est acheter à l'aveugle. Sans cible, rien
|
||||
// ne dépend de la mesure — le filet de durée suffit, et la couper ici serait
|
||||
// punir la charge du silence d'un instrument dont elle n'a pas besoin.
|
||||
cause = QString::fromLatin1(DerogationEnd::MeasurementLost);
|
||||
} else if (d.absencesConsecutives >= DerogationConfirmationsAbsence) {
|
||||
cause = QString::fromLatin1(DerogationEnd::Unplugged);
|
||||
} else if (nowUtc >= d.expiresAt) {
|
||||
cause = QString::fromLatin1(DerogationEnd::DurationElapsed);
|
||||
}
|
||||
|
||||
if (cause.isEmpty()) {
|
||||
// Elle continue : on la POSE dans le contexte, et c'est de là que le scheduler la
|
||||
// servira en tête de passe éco.
|
||||
if (lc) {
|
||||
lc->derogation = true;
|
||||
lc->derogationEndsAt = d.expiresAt;
|
||||
lc->derogationTargetWh = d.targetWh;
|
||||
lc->derogationDeliveredWh = livreWh;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const Derogation finie = m_derogations.take(id);
|
||||
rendreModeMemorise(finie);
|
||||
m_derogationFin.insert(id, cause);
|
||||
m_derogationModeRendu.insert(id, finie.previousMode);
|
||||
qCInfo(dcNymeaEnergy()).noquote()
|
||||
<< QStringLiteral("[Dérogation] FIN %1 — cause %2, livré %3 Wh sur %4, mode rendu %5")
|
||||
.arg(id, cause).arg(livreWh, 0, 'f', 0)
|
||||
.arg(finie.targetWh > 0 ? QStringLiteral("%1 Wh").arg(finie.targetWh, 0, 'f', 0)
|
||||
: QStringLiteral("aucune cible"))
|
||||
.arg(finie.previousMode);
|
||||
}
|
||||
}
|
||||
|
||||
void EnergyArbitrator::alimenterAvancement(LoadContext &lc) const
|
||||
{
|
||||
// CONTRADICTION CORRIGÉE (2026-09-03, remontée par l'app). `progressMeasurable` dit ce que
|
||||
@ -1772,6 +1955,8 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
|
||||
<< "arbitrée mais SANS décision dans le plan — omise.";
|
||||
|
||||
snap.insert(QStringLiteral("loads"), loads);
|
||||
|
||||
attacherFinsDerogation(snap);
|
||||
m_telemetry = snap;
|
||||
}
|
||||
|
||||
@ -1987,9 +2172,44 @@ void EnergyArbitrator::buildDegradedTelemetry(const QDateTime &now)
|
||||
entree(id, m_adapters.value(id));
|
||||
|
||||
snap.insert(QStringLiteral("loads"), loads);
|
||||
// Le mode dégradé porte l'événement LUI AUSSI : `measurementLost` s'y produit précisément,
|
||||
// et c'est le moment où l'utilisateur a le plus besoin de savoir que sa dérogation a cessé.
|
||||
attacherFinsDerogation(snap);
|
||||
m_telemetry = snap;
|
||||
}
|
||||
|
||||
void EnergyArbitrator::attacherFinsDerogation(QVariantMap &snap) const
|
||||
{
|
||||
// \rule{LM-1500-f} — FINS DE DÉROGATION, publiées au NIVEAU DE LA TRAME, pas dans loads[].
|
||||
//
|
||||
// Deux raisons, et la seconde est décisive :
|
||||
// · la charge peut avoir QUITTÉ loads[] au moment où sa dérogation se termine — c'est
|
||||
// même le cas le plus fréquent, celui du débranchement — et l'événement disparaîtrait
|
||||
// exactement quand il explique le plus de choses ;
|
||||
// · `decision` porte ce que le cycle a DÉCIDÉ pour cette charge ; une fin est un
|
||||
// ÉVÉNEMENT, pas une décision, et l'y loger obligerait à choisir entre les deux.
|
||||
//
|
||||
// Événement de CYCLE : présent le seul cycle de la fin, absent partout ailleurs. Une fin
|
||||
// qui resterait affichée serait indistinguable d'une fin qui vient de se produire.
|
||||
if (!m_derogationFin.isEmpty()) {
|
||||
QVariantList fins;
|
||||
QStringList finIds = m_derogationFin.keys();
|
||||
finIds.sort(); // ordre stable : un instantané se compare
|
||||
for (const QString &id : finIds) {
|
||||
QVariantMap f;
|
||||
f.insert(QStringLiteral("loadId"), id);
|
||||
f.insert(QStringLiteral("cause"), m_derogationFin.value(id));
|
||||
// Le mode auquel on REVIENT, publié plutôt que laissé à déduire : le client ne
|
||||
// peut pas le retrouver, il n'a jamais vu celui d'avant la pose.
|
||||
const int rendu = m_derogationModeRendu.value(id, -1);
|
||||
if (rendu >= 0)
|
||||
f.insert(QStringLiteral("restoredMode"), rendu);
|
||||
fins.append(f);
|
||||
}
|
||||
snap.insert(QStringLiteral("derogationEnded"), fins);
|
||||
}
|
||||
}
|
||||
|
||||
void EnergyArbitrator::publishTelemetry(bool force)
|
||||
{
|
||||
if (m_telemetry.isEmpty())
|
||||
@ -2022,6 +2242,11 @@ bool EnergyArbitrator::telemetrySignificantlyDiffers(const QVariantMap &a, const
|
||||
return true;
|
||||
if (a.contains(QStringLiteral("budget")) != b.contains(QStringLiteral("budget")))
|
||||
return true; // entrée ou sortie du mode où il n'y a pas de budget
|
||||
// Un ÉVÉNEMENT se notifie TOUJOURS. Il ne vit qu'un cycle : le filtrer sur « ça se ressemble
|
||||
// assez » le perdrait définitivement, et le client ne saurait jamais que sa dérogation a
|
||||
// pris fin — ni pourquoi.
|
||||
if (b.contains(QStringLiteral("derogationEnded")))
|
||||
return true;
|
||||
|
||||
const double allocA = a.value(QStringLiteral("budget")).toMap()
|
||||
.value(QStringLiteral("allocatedW")).toDouble();
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
// qui permet au rebuild incrémental (ECS-412) de comparer l'ancienne et la nouvelle config.
|
||||
#include "types/loadconfig.h"
|
||||
#include "types/drawcap.h"
|
||||
#include "types/derogation.h"
|
||||
#include "types/ecoprogress.h"
|
||||
#include "types/ecotarget.h"
|
||||
|
||||
@ -42,6 +43,27 @@ class EnergyArbitrator : public SmartChargingManager
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
/*!
|
||||
* \brief Pose une DÉROGATION sur une charge : servie au maximum, financée au réseau.
|
||||
* \param loadId Charge visée.
|
||||
* \param dureeS Durée du FILET, en secondes. Obligatoire et bornée — jamais « jusqu'à
|
||||
* annulation » : une dérogation dont la fin repose sur un instrument doit survivre au
|
||||
* silence de cet instrument.
|
||||
* \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.
|
||||
*
|
||||
* \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);
|
||||
|
||||
//! \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.
|
||||
bool leveDerogation(const QString &loadId);
|
||||
|
||||
//! \brief Les dérogations en cours. \return Liste, vide dans le cas normal.
|
||||
QList<Derogation> derogations() const { return m_derogations.values(); }
|
||||
|
||||
/*!
|
||||
* \brief Construit l'arbitre central. Mêmes dépendances que \c SmartChargingManager amont,
|
||||
* dont il hérite (justification : AGENTS.md, « 3b-iii »).
|
||||
@ -721,6 +743,31 @@ private:
|
||||
*/
|
||||
void alimenterAvancement(LoadContext &lc) const;
|
||||
|
||||
/*!
|
||||
* \brief Fait vivre les dérogations d'un cycle : les termine, ou les pose dans le contexte.
|
||||
* \param ctx Contexte du cycle, MODIFIÉ — les dérogations survivantes y sont marquées.
|
||||
* \param now Temps de cycle.
|
||||
*
|
||||
* Quatre causes de fin, évaluées dans cet ordre — la plus informative d'abord, parce que
|
||||
* c'est elle que l'utilisateur doit lire : cible atteinte, mesure perdue, débranchement,
|
||||
* durée. La durée vient en dernier : c'est le filet, pas un diagnostic.
|
||||
*
|
||||
* \note Lit le CONTEXTE, jamais les adaptateurs : c'est ce que le scheduler lira pour
|
||||
* décider. Deux lectures feraient deux vérités, et une dérogation pourrait se terminer
|
||||
* sur une mesure que le plan ne voit pas.
|
||||
*/
|
||||
void evaluerDerogations(SurplusContext &ctx, const QDateTime &now);
|
||||
|
||||
//! \brief Rend le mode mémorisé — et seulement si la pose l'avait forcé.
|
||||
//! \param d Dérogation terminée.
|
||||
void rendreModeMemorise(const Derogation &d);
|
||||
|
||||
//! \brief Attache les fins de dérogation du cycle à la trame de télémétrie.
|
||||
//! \param snap Trame en construction, MODIFIÉE ; rien n'est ajouté s'il n'y a pas de fin.
|
||||
//! \note Au NIVEAU DE LA TRAME et non dans \c loads[] : au débranchement, la charge n'y est
|
||||
//! déjà plus, et l'événement disparaîtrait quand il explique le plus de choses.
|
||||
void attacherFinsDerogation(QVariantMap &snap) const;
|
||||
|
||||
|
||||
|
||||
/*!
|
||||
@ -811,6 +858,14 @@ private:
|
||||
//! Sans cette date, une divergence PERMANENTE ne se distingue pas d'un déphasage d'un
|
||||
//! cycle entre l'écriture et sa mesure — et c'est toute la différence entre le normal et
|
||||
//! le cas relevé.
|
||||
//! \brief Dérogations en cours, par charge. Vide dans le cas normal.
|
||||
QHash<QString, Derogation> m_derogations;
|
||||
//! \brief Cause de la fin, pour le SEUL cycle où elle survient — motif d'événement.
|
||||
//! Sans elle, l'écran ne distingue pas « terminée normalement » de « annulée ».
|
||||
QHash<QString, QString> m_derogationFin;
|
||||
//! \brief Mode mémorisé rendu, publié avec l'événement de fin.
|
||||
QHash<QString, int> m_derogationModeRendu;
|
||||
|
||||
mutable QHash<QString, QDateTime> m_divergenceDepuis;
|
||||
//! loadId → énergie achetée (Wh) pour l'obligation, depuis l'ouverture de la PÉRIODE.
|
||||
mutable QHash<QString, double> m_ecoAchatWh;
|
||||
|
||||
@ -247,7 +247,74 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
||||
// de niveaux est ce qui rend cet ordre lisible sans qu'un client ait à le redériver.
|
||||
QHash<QString, LoadAction::Level> niveauxEco;
|
||||
|
||||
// --- TÊTE DE PASSE ÉCO — les DÉROGATIONS --------------------------------------------------
|
||||
//
|
||||
// \rule{LM-1500-e} — elles passent AVANT tout le reste de la passe éco, RANG COMPRIS, et
|
||||
// c'est le sens même du
|
||||
// mot : « le plus vite possible, tout de suite ». Une dérogation qui attendrait son rang
|
||||
// serait un mode de plus, pas une dérogation.
|
||||
//
|
||||
// Ce qu'elle ne surplombe PAS, et il faut le lire dans le code : l'autorisation de
|
||||
// soutirage la borne (elle est servie dans ce qui RESTE, jamais au-delà), et le délestage
|
||||
// la reprend comme n'importe quelle charge — L4 ne consulte pas les motifs. Sans ces deux
|
||||
// bornes on ouvrirait un second chemin d'achat, celui-là même qu'on a refusé au minimum
|
||||
// réseau.
|
||||
QSet<QString> sousDerogation;
|
||||
for (const LoadContext &lc : nonEvLoads) {
|
||||
if (!lc.derogation)
|
||||
continue;
|
||||
sousDerogation.insert(lc.id); // marqué même non servie : la passe confort doit la sauter
|
||||
|
||||
// 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)
|
||||
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
|
||||
// l'énergie. Et il est noté même si la charge porte par ailleurs une obligation — le
|
||||
// boost achète pour de vrai, et ne pas l'y compter ferait racheter la même énergie deux
|
||||
// fois quand la dérogation s'effacera.
|
||||
m_arbitrator->noterAchatEco(lc.id, achatW, ctx.timestamp);
|
||||
|
||||
if (achatW <= 0)
|
||||
continue; // rien d'achetable ce cycle : pas de niveau plutôt qu'un zéro forgé
|
||||
|
||||
LoadAction::Level der;
|
||||
der.level = QStringLiteral("eco");
|
||||
der.targetW = achatW;
|
||||
der.funding = LoadAction::Grid;
|
||||
der.counts[CountKey::DrawCommitted] = achatW;
|
||||
QVariantMap motif{{"maxW", qRound(lc.declared.maxPowerW)},
|
||||
{"gridW", qRound(achatW)},
|
||||
{"endsAt", lc.derogationEndsAt.toUTC().toString(Qt::ISODate)}};
|
||||
// La cible n'est publiée QUE si elle existe. Un `targetWh: 0` se lirait « cible
|
||||
// atteinte » sur une dérogation qui n'en a pas — et `deliveredWh` sans cible ne
|
||||
// répondrait à aucune question.
|
||||
if (lc.derogationTargetWh > 0) {
|
||||
motif.insert(QStringLiteral("targetWh"), qRound(lc.derogationTargetWh));
|
||||
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.
|
||||
if (bridePlafond)
|
||||
motif.insert(QStringLiteral("boundBy"), QStringLiteral("drawCap"));
|
||||
der.reason = { DecisionCode::DerogationActive, motif };
|
||||
niveauxEco.insert(lc.id, der);
|
||||
|
||||
draw.committedW += achatW;
|
||||
if (margeRestante.borne)
|
||||
margeRestante.watts -= achatW;
|
||||
}
|
||||
|
||||
for (const LoadContext &lc : nonEvLoads) {
|
||||
// Une dérogation REMPLACE l'obligation le temps qu'elle dure : elle demande déjà le
|
||||
// maximum, donc tout ce que la cadence pourrait réclamer est déjà servi. Continuer à
|
||||
// évaluer l'obligation ici produirait un second niveau « eco » pour la même charge, et
|
||||
// l'addition des deux dépasserait son plafond matériel.
|
||||
if (sousDerogation.contains(lc.id))
|
||||
continue;
|
||||
const EcoProgress avancement = m_arbitrator->ecoProgress(lc, ctx.timestamp);
|
||||
const EcoTarget cible = computeEcoTarget(lc.needs, avancement,
|
||||
lc.declared.maxPowerW, ctx.timestamp);
|
||||
@ -364,6 +431,36 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
||||
|
||||
// ================= PASSE 2 — CONFORT ======================================================
|
||||
for (const LoadContext &lc : nonEvLoads) {
|
||||
if (sousDerogation.contains(lc.id)) {
|
||||
// PASSE CONFORT SAUTÉE. La dérogation demande déjà le maximum de la charge : il n'y
|
||||
// a rien à y ajouter, et l'ajouter quand même dépasserait son plafond matériel — la
|
||||
// fusion des niveaux est une ADDITION (cf. plus bas). Le surplus non dépensé reste
|
||||
// au budget et passe aux charges suivantes, qui en ont l'usage.
|
||||
//
|
||||
// CONSÉQUENCE COMPTABLE À CONNAÎTRE : la dérogation est comptée entièrement en
|
||||
// ACHAT alors que le surplus présent la finance en partie, physiquement. `gridW` la
|
||||
// surestime donc quand il y a du soleil. Ce n'est pas une approximation neuve : le
|
||||
// §10 la porte déjà pour le plancher éco, et elle sera reprise avec lui.
|
||||
if (niveauxEco.contains(lc.id)) {
|
||||
const LoadAction::Level der = niveauxEco.take(lc.id);
|
||||
LoadAction la;
|
||||
la.loadId = lc.id;
|
||||
la.kind = LoadAction::Setpoint;
|
||||
la.funding = LoadAction::Grid;
|
||||
la.powerW = der.targetW;
|
||||
la.estimatedPowerW = der.targetW;
|
||||
la.chargingEnabled = der.targetW > 0;
|
||||
la.reason = der.reason;
|
||||
la.counts = der.counts;
|
||||
la.levels.append(der);
|
||||
slot.actions.append(la);
|
||||
}
|
||||
// La marge N'EST PAS redécrémentée : la tête de passe l'a déjà fait. La décompter
|
||||
// deux fois priverait les charges suivantes d'une autorisation encore disponible.
|
||||
if (lc.adapter == QStringLiteral("evcharger"))
|
||||
waterfallEvIds.insert(lc.id);
|
||||
continue;
|
||||
}
|
||||
if (lc.adapter == QStringLiteral("sg-ready"))
|
||||
slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta));
|
||||
else
|
||||
|
||||
@ -257,6 +257,29 @@ constexpr const char *EcoFloorMissed = "ECO_FLOOR_MISSED";
|
||||
*/
|
||||
constexpr const char *RecreditSuspended = "RECREDIT_SUSPENDED";
|
||||
|
||||
/*!
|
||||
* \brief Cette charge est en DÉROGATION — elle est servie au maximum, financée au réseau.
|
||||
* params : \c maxW (ce qu'on lui accorde), \c gridW (ce qu'on achète pour elle),
|
||||
* \c endsAt (instant du filet, ISO UTC), \c o:targetWh (cible en énergie si déclarée),
|
||||
* \c o:deliveredWh (livré depuis la pose, si une cible est déclarée).
|
||||
*
|
||||
* \note Une dérogation n'est pas un mode : elle se superpose et s'efface. Le motif publie
|
||||
* \c endsAt pour que l'écran montre un compte à rebours plutôt qu'un état sans horizon.
|
||||
*/
|
||||
constexpr const char *DerogationActive = "DEROGATION_ACTIVE";
|
||||
|
||||
/*!
|
||||
* \brief La dérogation vient de se terminer — motif d'ÉVÉNEMENT, publié le seul cycle de la fin.
|
||||
* params : \c cause ∈ \c targetReached | \c unplugged | \c measurementLost | \c durationElapsed,
|
||||
* \c restoredMode (le mode mémorisé auquel on revient).
|
||||
*
|
||||
* \note **Les quatre causes appellent des gestes différents**, et une fin qui ne dirait pas
|
||||
* laquelle a mordu laisserait l'utilisateur relancer une dérogation que le lien empêchera
|
||||
* encore. Sans cet événement, l'écran ne distingue pas non plus « terminée normalement » de
|
||||
* « annulée par quelqu'un ».
|
||||
*/
|
||||
constexpr const char *DerogationEnded = "DEROGATION_ENDED";
|
||||
|
||||
// --- SG-Ready (PAC) ---------------------------------------------------------
|
||||
//! params: \c state — le verrou minStateHold maintient l'état (court-cycling compresseur).
|
||||
constexpr const char *LockMinStateHold = "LOCK_MIN_STATE_HOLD";
|
||||
|
||||
129
energyplugin/etm/types/derogation.h
Normal file
129
energyplugin/etm/types/derogation.h
Normal file
@ -0,0 +1,129 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#pragma once
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QString>
|
||||
|
||||
/*!
|
||||
* \file derogation.h
|
||||
* \brief Une DÉROGATION — un besoin ponctuel qui se superpose au mode, et s'efface.
|
||||
*
|
||||
* \par Ce que ce n'est pas
|
||||
* Ce n'est **pas un mode**. Un mode se choisit une fois et vaut jusqu'au prochain choix ; une
|
||||
* dérogation répond à un besoin **ponctuel** et s'éteint avec lui.
|
||||
*
|
||||
* > Une dérogation encore active après la fin est un mode qu'on a oublié de quitter. Une
|
||||
* > dérogation qui rend la main est une dérogation terminée.
|
||||
*
|
||||
* Elle n'a donc ni cadence, ni échéance quotidienne : les deux supposent un besoin qui dure.
|
||||
*
|
||||
* \rule{LM-1500} — une dérogation qui ne sait pas dire quand elle finit ne devrait pas exister.
|
||||
*
|
||||
* \par Ce qui la borne
|
||||
* La **sûreté seule** — l'autorisation de soutirage et le délestage. Elle prend la tête de la
|
||||
* passe éco, mais ne la surplombe pas : si le plafond mord, elle est servie la première **dans
|
||||
* ce qui reste**, jamais au-delà. Sans cela on ouvrirait un second chemin d'achat, celui qu'on a
|
||||
* refusé pour le minimum réseau.
|
||||
*
|
||||
* \see docs/DESIGN_DEROGATION.md
|
||||
*/
|
||||
|
||||
//! \brief Ce qui met fin à une dérogation. QUATRE causes, quatre gestes distincts.
|
||||
namespace DerogationEnd {
|
||||
//! La cible en énergie est atteinte. Succès — aucun geste attendu.
|
||||
constexpr const char *TargetReached = "targetReached";
|
||||
//! Le véhicule est parti (confirmé, cf. \c DerogationConfirmationsAbsence).
|
||||
constexpr const char *Unplugged = "unplugged";
|
||||
//! \brief On ne sait plus où en est la charge : sa mesure a disparu.
|
||||
//! Geste attendu : regarder le lien ou le compteur. La dérogation CESSE — elle n'attend pas
|
||||
//! sa durée, parce qu'elle achète et qu'acheter sans savoir où l'on en est, c'est acheter à
|
||||
//! l'aveugle (R6 transposée : active oui, vérifiable non, donc on s'arrête).
|
||||
constexpr const char *MeasurementLost = "measurementLost";
|
||||
//! Le temps imparti est écoulé — le filet, toujours armé.
|
||||
constexpr const char *DurationElapsed = "durationElapsed";
|
||||
}
|
||||
|
||||
//! \brief Absences CONSÉCUTIVES avant de conclure au débranchement (\rule{LM-1500-c}).
|
||||
//!
|
||||
//! Le débranchement peut clignoter. Mais les deux erreurs ne coûtent pas la même chose :
|
||||
//! **terminer à tort rend la main** (la charge repasse à son mode mémorisé, l'utilisateur
|
||||
//! relance), **continuer à tort achète au maximum**. Le seuil est donc bas — prompt à conclure
|
||||
//! la fin, lent à conclure la poursuite.
|
||||
constexpr int DerogationConfirmationsAbsence = 2;
|
||||
|
||||
//! \brief Cycles à mesure absente avant de conclure \c MeasurementLost. Encore plus bas, pour
|
||||
//! la même raison : c'est le cas où continuer coûte le plus cher.
|
||||
constexpr int DerogationConfirmationsMesure = 2;
|
||||
|
||||
/*!
|
||||
* \brief Une dérogation en cours sur une charge.
|
||||
*
|
||||
* \note Tous les instants sont **ancrés en UTC à la capture**. Un \c QDateTime local résout son
|
||||
* époque à la conversion : il se déplacerait au changement de fuseau, et une dérogation dont
|
||||
* l'échéance bouge d'une heure n'est plus un filet (dérive mesurée le 2026-09-06).
|
||||
*/
|
||||
struct Derogation {
|
||||
//! Charge visée — \c ThingId pour une borne, \c id de \c LoadConfig sinon.
|
||||
QString loadId;
|
||||
QDateTime startedAt; //!< Instant de la pose, UTC.
|
||||
/*!
|
||||
* \brief Échéance du filet — UTC, **toujours valide**. Jamais « jusqu'à annulation ».
|
||||
*
|
||||
* \rule{LM-1500-a} — la durée est le FILET, et il reste armé même quand il y a mieux.
|
||||
*
|
||||
* Une dérogation en porte une **même quand elle a une meilleure condition de fin**, parce
|
||||
* que cette condition repose sur un INSTRUMENT et qu'un instrument peut se taire. Mesuré sur
|
||||
* la V2C (\rule{LM-1012-d}) : à la perte du lien, \c sessionEnergy **reste à sa dernière
|
||||
* valeur**. Une dérogation « 20 kWh puis stop » dont le compteur se fige à 12 kWh n'atteint
|
||||
* jamais sa cible.
|
||||
*/
|
||||
QDateTime expiresAt;
|
||||
|
||||
//! \brief Cible en ÉNERGIE (Wh) depuis \c baseWh. 0 = pas de cible, seule la durée borne.
|
||||
//! L'énergie et non le SOC : « 20 kWh » se mesure, « 80 % » demanderait la capacité,
|
||||
//! l'état de santé et le rendement (\rule{LM-1009}, même raison).
|
||||
double targetWh = 0;
|
||||
//! Valeur du cumul de session à la pose — la cible se compte DEPUIS elle.
|
||||
double baseWh = 0;
|
||||
/*!
|
||||
* \brief Faux tant que \c baseWh n'a pas été capturée sur une mesure RÉELLE.
|
||||
*
|
||||
* La pose n'a pas lieu dans un cycle : au moment où l'utilisateur appuie, personne n'a
|
||||
* encore construit le contexte, et le cumul de session n'est lisible que là. Prendre 0
|
||||
* pour base ferait compter la cible depuis le début de la SESSION de charge et non depuis
|
||||
* la pose — une dérogation « 5 kWh » posée sur une session déjà à 6 kWh serait atteinte
|
||||
* avant d'avoir commencé.
|
||||
*
|
||||
* La base est donc capturée au PREMIER cycle où la mesure existe. C'est le mécanisme de
|
||||
* \rule{LM-1012-b}, pour la même raison : une base se prend sur ce qu'on mesure, pas sur ce
|
||||
* qu'on suppose.
|
||||
*/
|
||||
bool baseCapturee = false;
|
||||
|
||||
//! \brief Le mode d'avant, MÉMORISÉ. Revenir à un défaut détruirait un réglage en silence,
|
||||
//! et pour une borne sortirait la charge de l'arbitrage — l'inverse du but.
|
||||
int previousMode = -1;
|
||||
/*!
|
||||
* \brief Vrai si la pose a dû ÉCRIRE le mode pour que la charge entre dans l'arbitrage.
|
||||
*
|
||||
* Une borne en mode MANUEL est hors waterfall (\c evParticipatesInWaterfall) : le moteur ne
|
||||
* la commande pas, donc une dérogation n'y produirait rien. La pose la bascule alors en éco
|
||||
* pour la durée, et c'est le seul cas où le mode est touché.
|
||||
*
|
||||
* Sans ce booléen la restitution serait une réécriture inutile dans le cas courant — et
|
||||
* surtout on ne saurait plus distinguer « le moteur a changé le mode » de « l'utilisateur
|
||||
* l'avait déjà mis là », ce qui est exactement ce que \c previousMode existe pour éviter.
|
||||
*/
|
||||
bool modeForce = false;
|
||||
|
||||
//! \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.
|
||||
int absencesConsecutives = 0;
|
||||
//! \brief Cycles CONSÉCUTIFS sans cumul lisible ; même remise à zéro, même raison.
|
||||
int sansMesureConsecutifs = 0;
|
||||
|
||||
//! \brief Une dérogation utilisable a une charge et une échéance.
|
||||
//! \return Faux si l'une des deux manque — l'échéance est le filet, elle ne s'omet jamais.
|
||||
bool isValid() const { return !loadId.isEmpty() && expiresAt.isValid(); }
|
||||
};
|
||||
@ -206,6 +206,22 @@ struct LoadContext {
|
||||
LoadContextTelemetry telemetry;//!< État runtime au \c timestamp du contexte.
|
||||
LoadNeeds needs; //!< Besoins déclarés par l'utilisateur (échéance, énergie minimale).
|
||||
LoadLimits limits; //!< Contraintes anti-rebond et verrous temporels.
|
||||
|
||||
/*!
|
||||
* \brief Cette charge est-elle en DÉROGATION ce cycle, et jusqu'à quand.
|
||||
*
|
||||
* Une dérogation n'est ni un mode ni un besoin : elle se **superpose** au mode courant et
|
||||
* s'efface. Elle vit donc ici, dans le contexte du cycle — jamais dans \c needs, qui
|
||||
* déclare ce qui dure, ni dans la configuration, qui se persiste.
|
||||
*
|
||||
* \c derogationEndsAt est l'échéance du **filet**, ancrée en UTC : elle est toujours valide
|
||||
* quand \c derogation est vrai. Publiée pour que l'écran montre un compte à rebours.
|
||||
*/
|
||||
bool derogation = false;
|
||||
//! Échéance du FILET, en UTC ; toujours valide quand \c derogation est vrai.
|
||||
QDateTime derogationEndsAt;
|
||||
double derogationTargetWh = 0; //!< 0 = pas de cible en énergie.
|
||||
double derogationDeliveredWh = 0; //!< Livré depuis la pose ; sens si cible.
|
||||
};
|
||||
|
||||
/*!
|
||||
|
||||
@ -298,6 +298,34 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
||||
returns = loadTelemetrySchema();
|
||||
registerMethod("GetLoadTelemetry", description, params, returns, Types::PermissionScopeControlThings);
|
||||
|
||||
// --- Dérogations -------------------------------------------------------------------------
|
||||
//
|
||||
// Une dérogation n'est PAS un mode : elle ne passe donc pas par SetChargingInfo, qui écrit
|
||||
// un réglage durable. Deux verbes, et une DURÉE OBLIGATOIRE — c'est la règle du lot : une
|
||||
// dérogation qui ne sait pas dire quand elle finit ne devrait pas exister, et la faire
|
||||
// respecter au moment de la pose est le seul endroit où c'est encore possible.
|
||||
params.clear(); returns.clear();
|
||||
description = "Grant a DEROGATION on a load: it is served at its maximum, funded from the "
|
||||
"grid, ahead of every other eco need regardless of rank. It is NOT a mode - it "
|
||||
"overlays the current one and clears itself. 'durationS' is MANDATORY and "
|
||||
"bounded [60, 21600]: it is the safety net, and it stays armed even when a "
|
||||
"target is given, because a target relies on an instrument and an instrument "
|
||||
"can fall silent. 'targetWh' is optional energy delivered SINCE the grant - "
|
||||
"energy, never SoC. Draw authorisation and overload shedding still bound it. "
|
||||
"The previous charging mode is memorised and restored when it ends.";
|
||||
params.insert("loadId", enumValueName(String));
|
||||
params.insert("durationS", enumValueName(Int));
|
||||
params.insert("o:targetWh", enumValueName(Double));
|
||||
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
|
||||
registerMethod("SetLoadDerogation", description, params, returns, Types::PermissionScopeControlThings);
|
||||
|
||||
params.clear(); returns.clear();
|
||||
description = "Cancel a derogation before its term and restore the memorised charging mode. "
|
||||
"Returns EnergyErrorInvalidParameter if no derogation was running on that load.";
|
||||
params.insert("loadId", enumValueName(String));
|
||||
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
|
||||
registerMethod("ClearLoadDerogation", description, params, returns, Types::PermissionScopeControlThings);
|
||||
|
||||
|
||||
// Notifications
|
||||
params.clear();
|
||||
@ -951,6 +979,18 @@ QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema()
|
||||
QVariantMap drawItem;
|
||||
drawItem.insert("committedW", enumValueName(Double));
|
||||
schema.insert("o:draw", drawItem);
|
||||
// Fin de dérogation — ÉVÉNEMENT de cycle, absent hors du cycle où elle se produit. Publié
|
||||
// au niveau de la trame et non dans loads[] : au débranchement, la charge n'y est déjà
|
||||
// plus. « restoredMode » est le mode mémorisé auquel on revient — le client ne peut pas le
|
||||
// déduire, il n'a jamais vu celui d'avant la pose.
|
||||
QVariantMap derogEndItem;
|
||||
derogEndItem.insert("loadId", enumValueName(String));
|
||||
derogEndItem.insert("cause", enumValueName(String)); // targetReached | unplugged |
|
||||
// measurementLost | durationElapsed |
|
||||
// cancelled
|
||||
derogEndItem.insert("o:restoredMode", enumValueName(Int));
|
||||
schema.insert("o:derogationEnded", QVariantList() << derogEndItem);
|
||||
|
||||
schema.insert("loads", QVariantList() << loadItem);
|
||||
return schema;
|
||||
}
|
||||
@ -979,6 +1019,44 @@ JsonReply *NymeaEnergyJsonHandler::GetLoadTelemetry(const QVariantMap ¶ms)
|
||||
return createReply(currentLoadTelemetry());
|
||||
}
|
||||
|
||||
JsonReply *NymeaEnergyJsonHandler::SetLoadDerogation(const QVariantMap ¶ms)
|
||||
{
|
||||
#ifdef ETM_ARBITRATOR
|
||||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager);
|
||||
if (!arb)
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
|
||||
const QString loadId = params.value("loadId").toString();
|
||||
const int durationS = params.value("durationS").toInt();
|
||||
const double targetWh = params.value("targetWh", 0).toDouble();
|
||||
// La validation vit dans poseDerogation() : bornes de durée, existence de la charge,
|
||||
// mémorisation du mode. La refaire ici en donnerait DEUX versions, et c'est celle du moteur
|
||||
// qui compte — le RPC n'est pas le seul chemin de pose.
|
||||
const bool ok = arb->poseDerogation(loadId, durationS, targetWh);
|
||||
return createReply({{"energyError",
|
||||
enumValueName(ok ? EnergyManager::EnergyErrorNoError
|
||||
: EnergyManager::EnergyErrorInvalidParameter)}});
|
||||
#else
|
||||
Q_UNUSED(params)
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
|
||||
#endif
|
||||
}
|
||||
|
||||
JsonReply *NymeaEnergyJsonHandler::ClearLoadDerogation(const QVariantMap ¶ms)
|
||||
{
|
||||
#ifdef ETM_ARBITRATOR
|
||||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager);
|
||||
if (!arb)
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
|
||||
const bool ok = arb->leveDerogation(params.value("loadId").toString());
|
||||
return createReply({{"energyError",
|
||||
enumValueName(ok ? EnergyManager::EnergyErrorNoError
|
||||
: EnergyManager::EnergyErrorInvalidParameter)}});
|
||||
#else
|
||||
Q_UNUSED(params)
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
|
||||
#endif
|
||||
}
|
||||
|
||||
JsonReply *NymeaEnergyJsonHandler::GetEnergyRatios(const QVariantMap ¶ms)
|
||||
{
|
||||
Q_UNUSED(params)
|
||||
|
||||
@ -116,6 +116,13 @@ public:
|
||||
* notification seule le laisse dans un silence ambigu (famille ECS-411).
|
||||
*/
|
||||
Q_INVOKABLE JsonReply *GetLoadTelemetry(const QVariantMap ¶ms);
|
||||
//! \brief Pose une dérogation (servie au maximum, financée au réseau, en tête de passe éco).
|
||||
//! \param params \c loadId, \c durationS (obligatoire, borné), \c targetWh (optionnel).
|
||||
//! \return \c energyError — \c InvalidParameter si la charge est inconnue ou la durée hors bornes.
|
||||
Q_INVOKABLE JsonReply *SetLoadDerogation(const QVariantMap ¶ms);
|
||||
//! \brief Lève une dérogation avant son terme et rend le mode mémorisé.
|
||||
//! \param params \c loadId. \return \c energyError — \c InvalidParameter si aucune n'était en cours.
|
||||
Q_INVOKABLE JsonReply *ClearLoadDerogation(const QVariantMap ¶ms);
|
||||
|
||||
signals:
|
||||
void PhasePowerLimitChanged(const QVariantMap ¶ms);
|
||||
|
||||
@ -2279,3 +2279,84 @@ porte de l'app. Et il serait pire ici : sur les bornes, le second commandeur ét
|
||||
|
||||
**« Pas réglable aujourd'hui » est la bonne réponse à donner à l'utilisateur**, et elle vaut
|
||||
mieux qu'un réglage dont personne ne garantit l'effet.
|
||||
|
||||
---
|
||||
|
||||
## §15 — Les dérogations
|
||||
|
||||
*Ouvert et implémenté le 2026-09-08. Design complet : `docs/DESIGN_DEROGATION.md`.*
|
||||
|
||||
### LM-1500 — Une dérogation qui ne sait pas dire QUAND elle finit ne devrait pas exister
|
||||
|
||||
C'est la règle du lot, et elle vaut au-delà du VE.
|
||||
|
||||
Une dérogation **achète**. Le mécanisme qui la termine doit donc être connu **avant** de la
|
||||
poser, jamais découvert en cours de route : « jusqu'à annulation » n'est pas une fin, c'est un
|
||||
mode déguisé, et un mode qu'on a oublié de quitter continue d'acheter indéfiniment.
|
||||
|
||||
Conséquence exécutoire : la **durée est obligatoire et bornée** (`[60 s, 6 h]`), refusée à la
|
||||
pose. C'est le seul endroit où l'on peut encore la faire respecter.
|
||||
|
||||
### LM-1500-a — La durée est le FILET, et il reste armé même quand il y a mieux
|
||||
|
||||
Une dérogation porte une échéance de durée **même lorsqu'elle a une meilleure condition de fin**,
|
||||
parce que cette condition-là repose sur un **instrument**, et qu'un instrument peut se taire.
|
||||
|
||||
Mesuré sur la V2C (`LM-1012-d`) : à la perte du lien Modbus, `sessionEnergy` **reste à sa
|
||||
dernière valeur** au lieu de disparaître. Une dérogation « 20 kWh puis stop » dont le compteur se
|
||||
fige à 12 kWh n'atteint jamais sa cible — et sans filet, elle achèterait jusqu'à ce que quelqu'un
|
||||
s'en aperçoive.
|
||||
|
||||
**La durée s'éprouve partout**, sans matériel et sans lien : une raison de plus d'en faire le
|
||||
filet plutôt qu'un recours.
|
||||
|
||||
### LM-1500-b — La fin par MESURE ne mord que s'il y a une cible
|
||||
|
||||
« Une dérogation dont la **cible** cesse d'être mesurable doit cesser » — R6 transposée : active
|
||||
oui, vérifiable non, donc on s'arrête. Acheter au maximum sans savoir où l'on en est, c'est
|
||||
acheter à l'aveugle.
|
||||
|
||||
Mais **sans cible, rien ne dépend de la mesure** : le filet de durée suffit, et couper là
|
||||
punirait la charge du silence d'un instrument dont elle n'a pas besoin.
|
||||
|
||||
Le critère se lit sur `telemetry.sessionMeasurable`, **jamais** sur `progress.regime` : `progress`
|
||||
est absent d'une charge sans obligation en énergie — c'est-à-dire exactement le cas du boost, où
|
||||
le critère aurait été **inerte**.
|
||||
|
||||
### LM-1500-c — Les seuils de confirmation sont ASYMÉTRIQUES
|
||||
|
||||
Les deux erreurs ne coûtent pas la même chose. **Terminer à tort rend la main** : la charge
|
||||
repasse à son mode mémorisé, l'utilisateur relance. **Continuer à tort achète au maximum.**
|
||||
|
||||
Le moteur est donc **prompt à conclure la fin, lent à conclure la poursuite** — deux cycles
|
||||
suffisent. Et les compteurs se remettent à zéro dès que le signal se dément : un clignotement ne
|
||||
doit pas s'accumuler d'un épisode à l'autre.
|
||||
|
||||
### LM-1500-d — Le mode précédent se MÉMORISE, il ne se déduit pas
|
||||
|
||||
Revenir à un **défaut** détruirait un réglage en silence. Sur une borne, retomber sur `Normal`
|
||||
la sortirait de l'arbitrage : une dérogation qui laisserait l'installation **moins pilotée
|
||||
qu'avant elle**, l'inverse exact de son but.
|
||||
|
||||
Même famille que le repli d'échéance qui réécrivait `EcoWithMinCurrent` en `Eco`.
|
||||
|
||||
### LM-1500-e — Elle prend la TÊTE de la passe éco ; elle ne surplombe pas la sûreté
|
||||
|
||||
Le rang classe ce que l'**installateur** décide (`LM-1002`) ; une dérogation est une **action
|
||||
ponctuelle de l'utilisateur**, et passe par une porte que `LM-1002` ne ferme pas. Un boost servi
|
||||
après une autre charge contredirait « tout de suite », qui est sa définition.
|
||||
|
||||
Mais `DRAW_CAP` la borne et le délestage la reprend : elle est servie la première **dans ce qui
|
||||
reste**, jamais au-delà. Sans cette limite on ouvrirait un **second chemin d'achat** — celui-là
|
||||
même qui a été refusé au minimum réseau (`LM-1013-b-vi`, `DESIGN_MIN_RESEAU.md` §3).
|
||||
|
||||
### LM-1500-f — La fin est un ÉVÉNEMENT, et elle dit sa CAUSE
|
||||
|
||||
Publiée le seul cycle où elle survient, au **niveau de la trame** et non dans `loads[]` : au
|
||||
débranchement, la charge n'y est déjà plus, et l'événement disparaîtrait exactement quand il
|
||||
explique le plus de choses.
|
||||
|
||||
Les quatre causes appellent des **gestes différents** — cible atteinte (rien à faire), mesure
|
||||
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.
|
||||
|
||||
@ -6931,6 +6931,122 @@ 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::testDerogationTakesTheHeadButNotTheSafety()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
|
||||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(arb);
|
||||
RuleBasedScheduler sched(arb);
|
||||
|
||||
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)
|
||||
-> QHash<QString, LoadAction> {
|
||||
SurplusContext ctx;
|
||||
ctx.timestamp = t0;
|
||||
ctx.meter.importW = 500; // la maison ACHÈTE : aucun surplus du tout
|
||||
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.label = "ECS";
|
||||
ecs.adapter = "etmvariableload";
|
||||
ecs.priority = 1; // RANG 1 — devant la borne
|
||||
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 borne;
|
||||
borne.id = QStringLiteral("borne-%1").arg(suffixe);
|
||||
borne.label = "Borne";
|
||||
borne.adapter = "evcharger";
|
||||
borne.priority = 9; // RANG DE QUEUE
|
||||
borne.declared.maxPowerW = 7000;
|
||||
borne.telemetry.pluggedIn = true;
|
||||
borne.derogation = derogation;
|
||||
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)
|
||||
par.insert(a.loadId.section('-', 0, 0), a);
|
||||
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");
|
||||
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");
|
||||
const LoadAction b = avec.value("borne");
|
||||
QCOMPARE(qRound(b.estimatedPowerW), 7000);
|
||||
QCOMPARE(b.reason.code, QString(DecisionCode::DerogationActive));
|
||||
QCOMPARE(b.levels.count(), 1);
|
||||
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 ».
|
||||
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.
|
||||
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");
|
||||
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);
|
||||
|
||||
// 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(),
|
||||
"sans autorisation il n'y a rien à publier : pas de niveau à zéro");
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::testEcoPassBuysAndSaysTheTrajectory()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
|
||||
@ -208,6 +208,7 @@ private slots:
|
||||
void testRecreditIsALoanAgainstObedience();
|
||||
void testARefusedStateIsDistinguishableFromAnApplied();
|
||||
void testL4ShedsAPacAndSaysItForcedTheLock();
|
||||
void testDerogationTakesTheHeadButNotTheSafety();
|
||||
|
||||
// ── §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