diff --git a/INTERFACE.md b/INTERFACE.md index cd7db74..5d5fd9c 100644 --- a/INTERFACE.md +++ b/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"`. diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 6e908c4..aaef4d3 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -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 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 diff --git a/docs/DESIGN_DEROGATION.md b/docs/DESIGN_DEROGATION.md index 180618c..c57fb34 100644 --- a/docs/DESIGN_DEROGATION.md +++ b/docs/DESIGN_DEROGATION.md @@ -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. diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 556fd8d..eee0c4b 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -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(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(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 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(); diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 82891a2..77395ba 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -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 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 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 m_derogationFin; + //! \brief Mode mémorisé rendu, publié avec l'événement de fin. + QHash m_derogationModeRendu; + mutable QHash m_divergenceDepuis; //! loadId → énergie achetée (Wh) pour l'obligation, depuis l'ouverture de la PÉRIODE. mutable QHash m_ecoAchatWh; diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 2d9d016..fd35739 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -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 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 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 diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index f609f23..a4ff65d 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -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"; diff --git a/energyplugin/etm/types/derogation.h b/energyplugin/etm/types/derogation.h new file mode 100644 index 0000000..1fba7b5 --- /dev/null +++ b/energyplugin/etm/types/derogation.h @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include + +/*! + * \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(); } +}; diff --git a/energyplugin/etm/types/surpluscontext.h b/energyplugin/etm/types/surpluscontext.h index bf7f4cc..0ee2711 100644 --- a/energyplugin/etm/types/surpluscontext.h +++ b/energyplugin/etm/types/surpluscontext.h @@ -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. }; /*! diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 1eb480a..2ed5ca5 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -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()); + 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()); + 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(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(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) diff --git a/energyplugin/nymeaenergyjsonhandler.h b/energyplugin/nymeaenergyjsonhandler.h index 0157091..9340720 100644 --- a/energyplugin/nymeaenergyjsonhandler.h +++ b/energyplugin/nymeaenergyjsonhandler.h @@ -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); diff --git a/specs/spec_loadmodel.md b/specs/spec_loadmodel.md index d285126..dfc7822 100644 --- a/specs/spec_loadmodel.md +++ b/specs/spec_loadmodel.md @@ -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. diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index ccc232d..888e5df 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -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(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 { + 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 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 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 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 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 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 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 diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 1fbcce2..69957a8 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -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