diff --git a/docs/DESIGN_DELESTAGE.md b/docs/DESIGN_DELESTAGE.md index a973594..7c3f352 100644 --- a/docs/DESIGN_DELESTAGE.md +++ b/docs/DESIGN_DELESTAGE.md @@ -299,3 +299,60 @@ la descente, mais `applyAction()` renvoyait l'action **demandée**, donc `state vivra **hors du moteur**. Les confondre ferait hériter à ce lot d'une contrainte qui ne le concerne pas. - **Il ne déplace pas la frontière de sécurité.** L4 garde le dernier mot, L0 reste physique. + +## 8. Le délestage VISIBLE (lot du 2026-10-25) + +Question de Patrick : « Si le délestage coupe une charge, est-ce que le client voit une alerte +quelque part ? » Avant ce lot, non. Décision du 2026-10-22 : l'alerte passe par l'app, par les +événements JSON-RPC, sans aucun flux sortant (invariant 10). **Ce lot publie, il ne décide rien +de plus** : aucune consigne, aucun émetteur, aucun verrou ne change (invariant 2, règle 4). + +**8.1 — La borne : motif `OVERLOAD_THROTTLED`.** `verifyOverloadProtection()` rabaisse une borne +du dépassement arrondi à l'ampère supérieur, et la coupe sous son plancher. Ses consignes portent +`ChargingActionIssuerOverloadProtection`, mais rien n'en sortait. `executeChargingAction()` est +devenu virtuel (marque `[ETM]`, corps amont inchangé) : l'arbitre laisse partir la consigne, +puis la lit. + +| Consigne | Effet | +|---|---| +| protection, baisse ou coupure | la tenue s'ouvre ou se prolonge ; la borne publie `OVERLOAD_THROTTLED` | +| protection, remise au minimum après coupure (`verifyOverloadProtectionRecovery`) | la tenue se prolonge | +| protection, retour au courant manuel (`verifyOverloadProtectionRecovery`) | la tenue est levée | +| tout autre émetteur (le cycle reprend la borne) | la tenue est levée | +| émetteur inconnu (consigne par défaut de la boucle amont, voir 8.5) | rien | + +Paramètres : `fromA`, `toA` (0 si coupée), `cut`, `overshootW` (le plus fort dépassement de la +tenue), `limitA`, `phase` (absente pour une triphasée). La préséance est celle de L4 : l'entrée +perd ses niveaux, et `allocatedW` dit ce que la protection laisse à la borne. Hors d'un cycle +(`powerBalanceChanged`), l'instantané publié est complété et republié tel quel : son +`timestamp` reste celui du dernier cycle, et le geste est daté par le journal. + +**8.2 — Le journal court.** En mémoire, 20 épisodes (`SheddingLog`), lu par +`NymeaEnergy.GetSheddingLog`, même droit que `GetLoadTelemetry`. **Perdu au redémarrage de +nymead : accepté.** Il est hors de la télémétrie parce qu'il la doublerait (20 épisodes à deux +charges utiles de mécanisme, à chaque battement). La télémétrie n'en porte que le résumé, +`shedding: {revision, open}`, **absent** tant que rien n'a été délesté depuis le démarrage. + +- **Un épisode, pas un cycle.** Il s'ouvre au premier geste. Il se ferme à la fin du premier cycle + complet où la protection ne tient plus la charge, et la fin est datée du premier instant libre. +- **Toutes les charges de la cascade**, enregistrées dans `shedWaterfallForOverload()` sans liste + de types : « avant » et « après » sont la charge utile de `runtimeView()` (palier des relais, + consigne, état SG-Ready). Une charge de la cascade reste tenue tant que le plafond est épuisé au + compteur, ou que son motif est `DRAW_CAP`. +- « Après » garde l'état le plus bas de l'épisode, et `cut` dit si la charge a été coupée au moins + une fois. Une PAC rendue à l'état 2 n'est pas coupée. + +**8.3 — La notification.** `shedding.revision` change à chaque ouverture et à chaque fermeture. +`telemetrySignificantlyDiffers()` le compare : `LoadTelemetryChanged` part, même quand rien +d'autre n'a bougé. + +**8.4 — Limites connues.** Sous le mode dégradé L2, aucun cycle ne tourne : le journal ne se +ferme pas, il reprend au retour du compteur. Une charge que la cascade laisse éteinte parce que +la marge restante est sous son palier (`BELOW_MIN_POWER`) n'est plus tenue au sens du journal : +l'épisode se ferme quand le plafond n'est plus épuisé. + +**8.5 — Défaut amont relevé, NON corrigé.** Dans la boucle de `verifyOverloadProtection()`, une +borne monophasée active dont la phase ne dépasse pas reçoit `ChargingAction()` par défaut : +non enclenchée, 0 A, émetteur inconnu. Elle est donc coupée (verrous permettant) quand une autre +phase dépasse. Ce lot ne le publie pas comme geste de protection et ne le corrige pas : c'est +une décision de sécurité, hors de son périmètre. diff --git a/docs/DESIGN_HC_BATTERIE.md b/docs/DESIGN_HC_BATTERIE.md new file mode 100644 index 0000000..608676e --- /dev/null +++ b/docs/DESIGN_HC_BATTERIE.md @@ -0,0 +1,474 @@ +# Heures creuses pour la borne, batterie protégée pendant sa charge — lot e1 (2026-10-24) + +*Décisions de Patrick du 2026-10-24. Ce document décrit ce que le code fait, les preuves qui le +rejouent, et ce qu'il ne fait pas. Code : `etm/tariff/tariffconfig.*`, +`etm/scheduler/rulebasedscheduler.cpp`, `etm/adapters/storagedischargeguard.*`, +`etm/energyarbitrator.cpp` (`buildContext`, `update`, `applyDegradedMode`).* + +## 1. Les heures creuses + +**Règle** : les heures creuses sont **la ou les zones au prix le plus bas** d'un tarif à zones +horaires (`tou`) dont les prix **diffèrent** (`TariffConfig::zonesCreuses()`). + +- « HC » n'a pas de code : le libellé est une donnée de l'installateur. Le prix se compare. +- Plusieurs zones au même prix minimal sont toutes creuses (une nuit et un début d'après-midi + saisis comme deux zones). +- **Aucune heure creuse** sans tarif, avec un tarif unique (`fixed`), ou avec des zones toutes au + même prix. Dans ces trois cas **rien de ce lot ne s'exécute** : le moteur est celui d'etm74 + (preuve `decisions` IDENTIQUE, et `testWithoutOffPeakTheEngineIsEtm74`). +- Lecture murale, minute **absolue** par minute (`enHeuresCreuses()`, `prochaineBascule()`), dans + le fuseau de la box relu à chaque cycle. Les jours de 23 h et de 25 h sont exacts (nuit + 22:00-06:00 de 7 h et de 9 h), épinglés sur des époques calculées hors de Qt. +- Le **prix** n'est toujours lu par aucune décision, et `SurplusContext::tariff` n'est toujours + pas rempli : l'optimiseur ne reçoit rien de neuf. Les heures creuses vivent dans + `SurplusContext::offPeak`, interne au moteur. + +## 2. La borne : surplus le jour, complément en heures creuses + +**Modes concernés** — `ChargingModeEcoWithMinCurrent`, `ChargingModeEcoMinWithTargetTime`, +`ChargingModeEcoWithTargetTime` : ceux dont le besoin réseau peut attendre. `EvAdapter` le +DÉCLARE (`LoadNeeds::offPeakShift`, `offPeakMinPowerW` = le plafond de cycle de la borne depuis +le lot e2, son plancher au lot e1 — §8, D7), le scheduler le DÉCIDE (règle 2). `ChargingModeEco` ne demande jamais le réseau, `ChargingModeNormal` est +piloté par l'utilisateur : tous deux sont inchangés. + +| | hors heures creuses | en heures creuses | +|---|---|---| +| minimum réseau (`minPowerWAlways`) | **retiré** — surplus seulement | le **plafond** de la borne (lot e2, §8 D7) | +| démarrage sous tolérance (`EV_GRID_START`) | **refusé** (le minimum par une autre porte) | inchangé | +| motif d'une consigne nulle | **`EV_WAITING_OFF_PEAK`** `{at, startsAt, budgetW}` | motifs ordinaires | +| motif de la consigne servie | — | **`EV_OFF_PEAK_FULL_POWER`** (lot e2, §8 D7) | +| échéance (`EV_DEADLINE`, proxy) | inchangée | inchangée | + +**Le changement de comportement** : une borne « Éco avec minimum » ne prend plus ses 6 A au +réseau le soir. Elle charge au surplus tant qu'il y en a, puis attend 22:00. Le motif le dit : +« Borne attend les heures creuses (22:00) — hors heures creuses, surplus seulement (… W) ». +`EV_WAITING_OFF_PEAK` remplace `SURPLUS_INSUFFICIENT`, `BELOW_MIN_POWER` et `BATTERY_RESERVE`, +parce que la cause de la consigne nulle n'est plus le manque de surplus. Le plafond de soutirage +(`DRAW_CAP`), le refus (`RECREDIT_SUSPENDED`), la limite de phase, l'indisponibilité et les +verrous gardent la tête : ils diraient la même chose en heures creuses. + +**En heures creuses**, le plancher est servi au réseau par la passe éco (`ECO_FLOOR_GRID`), +bornée par l'autorisation de soutirage ; le confort ajoute le surplus s'il y en a. *Lot e2 : +c'est désormais le plafond, sous `EV_OFF_PEAK_FULL_POWER` — §8, D7.* La borne +déclare un plafond écrêté par `evPhaseAllowanceW()` — en heures creuses par +`evOffPeakAllowanceW()`, qui lui rend son courant mesuré moins 1 A (§8.2, D8) —, et +`verifyOverloadProtection()` garde le dernier mot : l'invariant 2 tient sans rien ajouter. + +**L'échéance** : la puissance nécessaire pour tenir une échéance reste celle du proxy amont, qui +force la charge à pleine puissance au point de non-retour. Ce point est calculé sur l'échéance, +pas sur la fin des heures creuses : une échéance trop proche peut donc encore se servir en +heures pleines. Une échéance est une promesse de l'utilisateur, le tarif ne la rompt pas. Étaler +la charge sur les heures creuses demanderait une énergie restante mesurée (LM-1009) : ce sera un +lot à part (§8, D3). + +Le refus d'`EV_GRID_START` hors heures creuses est confirmé (§8, D2) : seul le mode boost +(`ChargingModeNormal`) charge tout de suite. + +**À 06:00**, le minimum est retiré : une borne qui charge encore s'arrête (au premier cycle où +son verrou d'enclenchement le permet, 300 s par défaut). + +## 3. La batterie : décharge bloquée pendant la charge de la voiture en heures creuses + +### 3.1 Ce que le modèle SunSpec 124 dit — et ce que le plugin écrit + +Lu dans le plugin `sunspec` (`integrationpluginsunspec.cpp`, `sunspecstoragelogic.*`, +`froniusrules.*`) et dans la définition du modèle 124 de `libnymea-sunspec` : + +- `StorCtl_Mod` est un **champ de bits** — « Activate hold/discharge/charge storage control + mode » : bit 0 `CHARGE`, bit 1 `DiSCHARGE`. Le bit **active la limite** correspondante, il + n'« autorise » pas l'opération. +- `OutWRte` est la limite de décharge, en **pourcentage** de la puissance maximale (`% WDisChaMax` + dans le modèle, « % de `WChaMax` » dans la documentation du plugin). **0 % vaut 0 W quelle que + soit la référence** : l'unité n'a pas d'incidence sur la valeur écrite ici, ni le facteur + d'échelle `InOutWRte_SF` (que le plugin relit avant chaque écriture, AC-12). +- L'état `enableDischarging` du plugin **est** ce bit (`readStates()`), et l'action le pose par + lecture-modification-écriture de `StorCtl_Mod`. L'état `dischargingRate` **est** `OutWRte`. +- Le contrat du plugin (AC-11, `FroniusRules::planBatteryMode()`) le confirme : + `NO_DISCHARGE` = `StorCtl_Mod = 2`, `OutWRte = 0` ; `NORMAL` = `StorCtl_Mod = 0`, `OutWRte = 100`. + +Donc **`enableDischarging = false` LÈVE la limite** — il ne bloque rien. La séquence qui bloque +réellement la décharge est **`dischargingRate = 0`, puis `enableDischarging = true`**. Le retour +à l'automatisme est **`enableDischarging = false`, puis `dischargingRate = 100`** (le maximum de +l'état). + +**Le plugin permet de l'écrire, en deux actions.** Chacune est un plan d'écriture complet (ordre +imposé, relecture, minuteur de retour réarmé en dernier). Elles ne sont pas atomiques : le garde +les **enchaîne** (la seconde part à la confirmation de la première), dans l'ordre qui rend +chaque état intermédiaire sûr : + +- pose : le taux d'abord. Tant que la limite est inactive, le taux est inerte ; quand elle + s'active, il vaut déjà 0. Si la première écriture échoue, la seconde ne part pas — la limite + reste inactive, la batterie en automatique ; +- levée : la limite d'abord, l'automatisme revient tout de suite ; le taux est remis au maximum + pour qu'une limite réactivée plus tard ne reparte pas à 0 %. + +`FroniusRules::planBatteryMode(NO_DISCHARGE)` écrirait les deux registres en une séquence, mais +aucune action du plugin ne l'emprunte. L'exposer est décidé, dans le dépôt du plugin (§8, D6) ; +le garde reste compatible avec les deux actions actuelles. + +### 3.2 Quand + +**Heure creuse ET bornes qui tirent réellement ≥ 230 W (mesuré, toutes bornes, mode manuel +compris) ET au moins une batterie pilotable.** Sinon la batterie est rendue à son automatisme, +et une consigne tenue est **levée explicitement**. La décision est prise par le rule-based à +chaque cycle (`RuleBasedScheduler::storageDecision()`), y compris en ombre d'un optimiseur. + +La batterie est bloquée au cycle où la voiture **tire** : au cycle de 22:00 la borne vient d'être +commandée et lit encore 0 W, le blocage part à 22:01. Elle est rendue au cycle où la borne ne +tire plus (fin de charge) ou à 06:00. + +Ce que le blocage bloque : **toute** décharge, maison comprise. « Ne pas se décharger dans la +voiture » ne s'exprime pas autrement sans asservir `OutWRte` à la consommation de la maison — +une boucle de régulation que le lot ne fait pas. C'est le geste voulu (§8, D1), comme le +blocage par une borne en mode boost (D4) et le seuil de 230 W (D5). + +### 3.3 Pilotable, ou non + +Par l'interface `energystorage` et les **états présents** — aucune ThingClass n'est nommée +(`StorageDischargeGuard::capacite()`, question posée à la classe) : + +| cause | condition | effet | +|---|---|---| +| — | `enableDischarging` (booléen) et `dischargingRate` (0 admis) modifiables, `revertTime` ≥ 120 s | commandée | +| `noDischargeControl` | un des deux états absent ou non modifiable | lue comme avant, `STORAGE_NOT_PROTECTED` | +| `noRevertTimer` | pas de paramètre `revertTime`, ou 0 | idem — un moteur arrêté la laisserait bloquée sans fin | +| `revertTimerTooShort` | `revertTime` < 120 s | idem — elle expirerait entre deux cycles | + +### 3.4 Le minuteur et le rafraîchissement + +Chaque écriture du plugin se termine par `InOutWRte_RvrtTms = revertTime` (300 s sous Fronius, +`FroniusRules::DefaultRevertTimeSeconds`). Le garde réécrit la séquence dès que la dernière a +**le tiers du minuteur** (100 s) : à un cycle par minute, deux écritures sont espacées de 120 s, +180 s si un cycle manque — toujours sous 300 s. Il la réécrit aussi, sans attendre, si la +batterie ne rapporte plus la limite (onduleur revenu seul à l'automatisme). + +**La sûreté est le minuteur**, pas le moteur : si `nymead` s'arrête, plus rien ne réécrit, et le +GEN24 reprend son automatisme au plus 300 s après la dernière écriture. Sous compteur muet (L2), +le garde lève la limite explicitement. Au redémarrage, une limite à 0 % trouvée active est +reprise (rafraîchie ou levée). + +⚠️ **Hypothèse non levée, et elle est du plugin** : la sémantique du minuteur est documentée par +le constructeur au chapitre général et illustrée sur le modèle 123, pas sur le 124 (AC-10 du +plugin, « décision D4 non validée »). Les tests la modélisent ; seule une mesure sur un GEN24 +réel la prouve. + +### 3.4 bis Un onduleur qui refuse (suite du lot e1) + +Un GEN24 dont « Allow Control » est désactivé répond 0x04 à toute écriture. Avant cette suite, la +batterie ne rapportant jamais la limite, le garde reposait la séquence à chaque cycle, avec un +`qCWarning` à chaque fois, toute la nuit. + +- **Espacement** : une pose refusée (taux OU limite) ouvre une **série de refus**. La pose + suivante attend **1, 2, 5, 10 puis 15 min** au plus, à compter de la pose refusée, avec une + marge de 5 s pour la gigue d'un cycle par minute. Quinze minutes au plus : un réglage corrigé + en cours de nuit est repris dans le quart d'heure. +- **Fin de série** : à la première séquence **acceptée jusqu'au bout** (taux puis limite), le + rythme normal reprend (tiers du minuteur) ; ou à la **levée**, qui clôt la série — la pose + suivante est une consigne neuve. +- **La levée n'est jamais espacée** : rendre l'automatisme part au cycle même où la décision + tombe, série en cours ou non. Elle n'est pas répétée si elle est refusée (le minuteur de + l'onduleur la fera) ; son refus reste un `qCWarning`. +- **Journal** : un seul `qCWarning` par série (« … REFUSÉE (…) : la décharge n'est PAS + bloquée … »), les refus suivants et les nouvelles tentatives en `qCDebug`, et un `qCInfo` à + l'acceptation qui clôt la série (« … ACCEPTÉE après N refus »). +- **Télémétrie** : pendant une série, **`held` est faux** — et `dischargeBlocked` avec lui — et + la batterie publie `refusedWrites` (poses refusées d'affilée), `refusedSince` et + `nextAttemptAt`. La décision reste `STORAGE_DISCHARGE_BLOCKED` : c'est ce que le moteur a + décidé, l'exécution est ce qui échoue. + +**Pourquoi `held` faux plutôt que `held` vrai plus un drapeau** : `held` et `dischargeBlocked` +sont lus comme « la batterie ne se décharge pas ». Un client qui ignore un champ nouveau — +c'est-à-dire tous les clients d'aujourd'hui — continuerait d'afficher une protection qui +n'existe pas. Avec `held` faux, le client existant dit vrai sans changement, et le client à jour +lit en plus pourquoi et jusqu'à quand. Le passage de `dischargeBlocked` à faux notifie, ce qu'un +drapeau à côté n'aurait pas fait. Un refus se lit au cycle **suivant** la pose : le verdict de +l'onduleur arrive après la publication du cycle qui a écrit, comme `observed`. + +### 3.5 Sécurité et invariant 8 (« pas de boucle ») + +La décision ne lit **ni le budget ni la réserve**, et n'y verse rien. Le budget reste compteur + +PV mesurés. Les règles de réserve (`rulebasedscheduler.cpp`, « Prise en compte du stockage ») +restent justes sous blocage : + +- **la nuit**, batterie bloquée et sans PV : `powerW` ≈ 0, aucune branche ne modifie le budget, + qui vaut −(import). Le blocage augmente l'import, le budget le constate : aucun surplus n'est + fabriqué (`testBlockedDischargeShowsAtTheMeterAndL4Decides`, `surplusW == −import`) ; +- **sous la réserve**, le surplus positif est annulé : il n'y en a pas la nuit, la règle est + sans effet ; +- **au-dessus de la réserve et en charge** (PV avant 06:00 l'été) : la charge de la batterie + est rendue au budget. C'est vrai sous blocage aussi : bloquer la décharge ne touche pas la + charge, et le GEN24 réduit sa charge pour couvrir une consommation nouvelle — ce qu'il ne peut + plus couvrir vient alors du réseau, pas de la batterie, et le compteur le montre au cycle + suivant ; +- **en décharge** (`powerW` < 0, si l'onduleur ignorait la consigne) : retranchée, comme avant — + le sens prudent. + +**Le soutirage ajouté** — ce que la batterie couvrait passe au réseau — se mesure au cycle +suivant : la marge de soutirage du contexte (`houseDrawMargin()`) et `verifyOverloadProtection()` +le lisent au compteur, et coupent la borne s'il dépasse (éprouvé avec une limite de 25 A). La +borne coupée ne tire plus : la batterie est rendue au cycle d'après. + +**Oscillation possible, signalée** : sur une installation au bord de sa limite de phase, la suite +« bloquer → la phase dépasse → L4 coupe la borne → rendre → la batterie couvre → la borne +redémarre » peut se répéter. Sa période est bornée par le verrou d'enclenchement de la borne +(300 s), et L4 tient la sécurité à chaque tour. + +**Hystérésis de la réserve de 20 %** : non touchée. Effet indirect seulement : la batterie ne se +vide plus dans la voiture la nuit, elle passe plus souvent la réserve le matin. + +## 4. Ce qui est publié + +- **Journal** : `[Arbitre] Batterie → Décharge de la batterie bloquée : la voiture charge en + heures creuses (1380 W, jusqu'à 06:00)`, à chaque cycle où la question se pose ; `[Batterie] … + décharge BLOQUÉE` et `… RENDUE à l'automatisme` aux transitions (`qCInfo`), le rafraîchissement + en `qCDebug`, un refus d'écriture en `qCWarning` (règle 7-d) — une fois par série de refus + pour la pose (§3.4 bis). +- **Télémétrie** (`GetLoadTelemetry` et `LoadTelemetryChanged`, même charge utile) : un bloc + `storage`, **absent** sans heure creuse ou sans batterie — `dischargeBlocked` (ce que le moteur + TIENT après le dispatch), `decision` `{code, params}`, et `batteries[]` avec ce qui est LU + (`observed.enableDischarging`, `observed.dischargingRate`) ; pendant une série de refus, + `held` faux et `refusedWrites`, `refusedSince`, `nextAttemptAt`. Une transition du bloc notifie. +- **Motifs** : `EV_WAITING_OFF_PEAK`, `STORAGE_DISCHARGE_BLOCKED`, `STORAGE_AUTOMATIC` + (`peak` | `evNotCharging` | `degraded`), `STORAGE_NOT_PROTECTED`. Aucun n'est émissible par + un optimiseur. +- **Aucun flux sortant nouveau** (invariant 10) : le garde parle au ThingManager, comme tous les + adaptateurs. + +## 5. Jamais + +`gridCharging`, `enableCharging`, `chargingRate` ne sont jamais écrits, ni un `dischargingRate` +négatif (charge forcée). La recharge de la batterie sur le réseau est l'offre Pro. Le test de la +journée le vérifie sur toutes les écritures exécutées, et la contre-épreuve qui écrit +`gridCharging` le fait rougir. + +## 6. Preuves + +| Preuve | Ce qu'elle rejoue | +|---|---| +| `testOffPeakIsTheCheapestZone` (loadmodel) | la règle ; aucun / unique / prix égaux ; jours de 23 et 25 h | +| `testOffPeakDemoDay` | 12 h, attente à 18 h, refus d'`EV_GRID_START`, reprise à 22:00, blocage à 22:01, minuteur tenu 88 cycles, levée à la fin de charge, aucune écriture interdite | +| `testOffPeakEndsAtSixAndOnDstDays` | arrêt et levée à 06:00, jours de 23, 24 et 25 h | +| `testWithoutOffPeakTheEngineIsEtm74` | trois formes sans heure creuse : minimum à 18 h, ni bloc ni écriture, trames identiques | +| `testABatteryWithoutControlIsReadAsBefore` | sans états, sans minuteur, absente | +| `testBlockedDischargeShowsAtTheMeterAndL4Decides` | invariant 8, soutirage mesuré, L4 coupe, levée | +| `testAMeterSilenceHandsTheBatteryBack` | L2 lève explicitement | +| `testRefusedWritesAreSpacedAndNeverDelayTheRelease` | onduleur qui refuse : poses à 0, 1, 3, 8, 18, 33, 48 min, un avertissement par série, `held` faux ; acceptation → rythme normal ; levée immédiate en pleine série, qui clôt la série | +| `decisions` | IDENTIQUE à la référence sans tarif | + +Les écritures sont lues **à la frontière nymea** (`ThingManager::actionExecuted`), et le GEN24 est +modélisé dans le test (`limiteTenue()`), jamais dans le moteur ni dans le mock. + +## 7. Ce que ce lot ne fait pas + +- La recharge de la batterie sur le réseau ; toute optimisation par le prix (offre Pro). +- L'étalement d'une échéance sur les heures creuses (§2) — lot à part (§8, D3). +- Une action atomique « limite de décharge » dans le plugin (`planBatteryMode(NO_DISCHARGE)` + existe et n'est pas exposée), et un état au nom moins trompeur que `enableDischarging` — + décidés, faits dans le dépôt du plugin (§8, D6). +- La batterie comme charge rangée du waterfall (lot 3f, `spec_loadmodel.md` §14). + +## 8. Décisions de Patrick (2026-10-24) + +Les questions ouvertes du lot e1 sont tranchées. Chacune confirme le comportement décrit plus haut : +aucune ne change le code. + +| # | Question | Décision du 2026-10-24 | +|---|---|---| +| D1 | Bloquer **toute** la décharge, maison comprise, pendant la charge de la voiture en heures creuses (§3.2) ? | **Oui.** C'est le geste voulu ; aucune régulation de `OutWRte` sur la consommation de la maison. | +| D2 | Refuser `EV_GRID_START` hors heures creuses pour les trois modes concernés (§2) ? | **Confirmé.** En mode Éco, la borne attend les heures creuses ; seul le mode boost (`ChargingModeNormal`, piloté par l'utilisateur) charge tout de suite. | +| D3 | Étaler une échéance sur les heures creuses (§2) ? | **Plus tard, lot à part.** Il demande une énergie restante mesurée (LM-1009). | +| D4 | Une borne en `ChargingModeNormal` qui charge en heures creuses déclenche-t-elle le blocage (§3.2) ? | **Oui, c'est le but** : la mesure porte sur toutes les bornes, mode compris. | +| D5 | Seuil de « la voiture charge réellement » : 230 W sur la somme des bornes (§3.2) ? | **Accepté.** | +| D6 | Plugin SunSpec : une action « limite de décharge » en une séquence (`planBatteryMode(NO_DISCHARGE)`), et `enableDischarging` renommé ou redécrit (§3.1, §7) ? | **Oui aux deux**, dans le dépôt du plugin, à la charge de l'orchestrateur. Le garde ne change pas : il reste compatible avec les deux actions actuelles (`dischargingRate` puis `enableDischarging`). | +| D7 | À quelle puissance la borne charge-t-elle en heures creuses (lot e2) ? | **À la pleine puissance permise**, et non plus au plancher. La session finit plus tôt, et la décharge de la batterie n'est bloquée que pendant ce temps-là : ensuite, elle couvre la maison pour le reste de la nuit. Détail en §8.1. | +| D8 | *(2026-10-25)* Près de la limite de phase, la consigne de la borne alterne à chaque cycle (16, 7, 16, 7 A sous 25 A). Corriger avant la démo ? | **Oui, maintenant.** En heures creuses, le plafond de la borne lui rend ce qu'elle tire, **mesuré**, moins 1 A par phase. Détail en §8.2. Le même jour : la borne **tenue au plancher** en surcharge (L4 décide de couper) est **confirmée** ; les écarts 3 et 4 de §8.1 sont reportés. | +| D9 | *(2026-10-25, suite)* L'écart 4 n'est pas qu'un défaut de publication : à l'aube, le surplus s'ajoute au plafond, qui porte la limite de phase. Borner la somme avant la démo ? | **Oui, avant la démo.** La somme éco + confort ne dépasse jamais `declared.maxPowerW`, et l'achat publié ne dépasse pas ce qui est réellement acheté. Détail en §8.3. La dérogation sans heure creuse, les phases étiquetées : plus tard, chacune dans son lot. Le compteur en retard sur la borne est accepté tel quel. | + +La mesure du minuteur de retour sur un vrai GEN24 (hypothèse AC-10 du plugin, §3.4) reste +ouverte : elle relève du plugin et d'un essai sur matériel réel. + +### 8.1 D7 — pleine puissance en heures creuses (lot e2) + +**Pourquoi.** Au plancher (6 A, 1,4 kW en monophasé), une nuit de 22:00 à 06:00 livre au plus +11 kWh : une session de 10 à 25 kWh n'est pas finie au départ. Et la décharge de la batterie +reste bloquée toute la nuit, puisque la voiture charge toute la nuit : la batterie est encore +pleine le matin, et le surplus solaire part au réseau. + +**La règle.** Dans les trois modes qui reportent en heures creuses (§2), le minimum servi au +réseau en heures creuses est le **plafond de cycle** de la borne (`declared.maxPowerW`) : + +- il porte le courant maximal de la borne, ses phases, et la limite de phase + (`evPhaseAllowanceW()` ; depuis D8, `evOffPeakAllowanceW()`, §8.2) — rien n'est retiré de ces + écrêtages ; +- il ne descend jamais sous le plancher : la limite de phase **rabaisse** la borne, elle ne la + coupe pas ; couper reste le geste de `verifyOverloadProtection()` (L4), qui garde le dernier + mot ; +- hors heures creuses, et sans heure creuse dans le tarif, rien ne change. + +Par exemple : 3 680 W en monophasé 16 A, 11 040 W en triphasé 16 A. + +**Les phases.** En heures creuses, une borne qui peut charger en triphasé charge en triphasé. +La règle de bascule du manager est inchangée ; c'est le courant qu'on lui présente qui change. +Hors heures creuses, c'est « courant de la borne + surplus ». En heures creuses, c'est la pleine +puissance de la borne. Raison : la nuit, « courant + surplus » bride dans un cas précis — une +batterie (non pilotable, ou pas encore bloquée) couvre la maison et la voiture, le compteur lit +≈ 0, et la référence vaut le courant que la borne tire déjà. À 6 A en monophasé, la règle garde +le monophasé : 3,7 kW au lieu de 11. La limite de phase n'entre pas dans le choix, et c'est sans +perte : par phase, elle borne pareil une monophasée et une triphasée, donc trois phases donnent +toujours au moins autant. + +**Sécurité.** Le plafond d'une borne porte déjà l'autorisation de soutirage, résolue sur les +phases qu'elle occupe et multipliée par leur nombre. La passe éco ne lui oppose donc pas en plus +la marge d'**une** phase de la maison (`LM-1500-h`, déjà appliquée en tête de passe et en passe +confort). Sans cela, une triphasée serait servie à 7 A au lieu de 16. Une autre charge allumée +la nuit réduit le plafond au cycle suivant ; sous le plancher, la borne est tenue au plancher, et +L4 coupe au dépassement. Cette exemption ne vaut **qu'en heures creuses** : sans heure creuse, le +plancher éco d'une borne reste écrêté par la marge de la maison, comme avant. + +**Surcharge au plancher — confirmé le 2026-10-25.** En heures creuses, quand la limite de phase +ne laisse plus le plancher, la stratégie tient la borne à son plancher (`boundBy: floor`) et L4 +décide de la couper, comme pour la dérogation. On n'est pas revenu au « rien acheté » du lot e1. + +**Écarts ouverts** (rien n'est changé ; décision du 2026-10-25 : plus tard, chacun dans son lot) : + +- *Écart 3 — le double écrêtage hors heures creuses.* Sans heure creuse, le plancher éco d'une + borne triphasée est encore comparé à la marge d'**une** phase de la maison (le double + écrêtage que `LM-1500-h` refuse ailleurs). L'étendre changerait des décisions sans tarif : + **lot à part**. +- *Écart 4 — le partage achat/surplus en heures creuses.* **Corrigé pour la pleine puissance + d'heures creuses (D9, §8.3).** À l'aube, le surplus s'ajoutait au niveau éco : la consigne + (`estimatedPowerW`) pouvait dépasser le plafond de cycle, et donc la limite de phase qu'il porte. + La dérogation, elle, ne cumule pas (sa passe confort est sautée) ; son achat publié reste + entier quand le soleil la finance en partie — c'est la conséquence comptable déjà écrite dans + le code, et elle reste à reprendre avec la dérogation. +- *L'oscillation près de la limite de phase* était le troisième point de cette liste. Elle est + corrigée : §8.2. + +**Fin de session.** Le blocage de la décharge se lève sur la puissance **mesurée** des bornes, +au seuil de 230 W (D5). Une borne commandée à 16 A dont la voiture ne prend plus rien (pleine, +ou session arrêtée par le véhicule) ne tient donc pas la batterie bloquée. + +**Motif.** `EV_OFF_PEAK_FULL_POWER {gridW, maxW, phases, until, endsAt, o:boundBy}` remplace, +en heures creuses, `ECO_FLOOR_GRID` sur la borne. `gridW` est la puissance retenue, `maxW` la +pleine puissance du matériel aux phases visées. `boundBy` n'est présent que si la protection de +surcharge l'a rabaissée : `phaseLimit` (à ce que la limite laisse) ou `floor` (tenue au +plancher, L4 décide de la suite). Rendu : « Borne charge à pleine puissance en heures creuses : +11040 W achetés au réseau (3 phases, jusqu'à 06:00) ». + +**Preuves.** `testOffPeakChargesAtFullPower`, `testOffPeakFullPowerSwitchesToThreePhases`, +`testOffPeakFullPowerYieldsToThePhaseLimit` ; les tests du lot e1 en heures creuses attendent +désormais le plafond. Sans heure creuse, la preuve `decisions` est identique. + +**Lot futur, non implémenté** — *réserver seulement ce dont la maison a besoin jusqu'au soleil, +d'après la prévision.* Aujourd'hui la décharge est bloquée entière tant que la voiture charge +(D1). Une version plus fine laisserait la batterie se décharger dans la voiture au-delà de ce que +la maison consommera d'ici le retour du soleil — consommation de base prévue (`BaseloadLog`) et +heure de la première production utile (prévision PV). Elle demande une prévision fiable des deux, +et un pilotage de `OutWRte` plus fin qu'un tout-ou-rien : c'est un lot à part. + +### 8.2 D8 — près de la limite de phase, la consigne se pose (2026-10-25) + +**Le défaut.** Le plafond d'une borne était la place libre au compteur, **sans lui rendre ce +qu'elle tire** (`evPhaseAllowanceW()`). Avec un compteur qui voit la borne, sa propre +consommation lui retirait la place au cycle suivant, puis la lui rendait au cycle d'après. Mesuré +en simulation, maison à 2 A par phase, borne 16 A, mono comme tri : + +| Limite | Avant (consigne cycle par cycle) | Après | +|---|---|---| +| 25 A | 16, 7, 16, 7… (11,5 A en moyenne) | 16 A tenus | +| 32 A | 16, 14, 16, 14… | 16 A tenus | +| 16 A | 14, 6, 8, 6, 8… (7 A en moyenne) | 13 A tenus, 15 A au compteur | + +**La règle.** En heures creuses, et pour la pleine puissance du lot e2 seulement, le plafond est +lu par `EnergyArbitrator::evOffPeakAllowanceW()` : + +> plafond par phase = limite − (tirage de la phase − courant **mesuré** de la borne) − **1 A**, +> arrondi à l'ampère inférieur, × phases. + +C'est la règle de l'échéance chez l'amont (`adjustEvChargers()`). Elle converge en **un** cycle : +le résultat ne dépend plus de ce que la borne tirait, seulement de ce que tire le reste de la +maison. + +- *Mesure, pas consigne.* Le courant rendu est l'état `currentPower` de la borne, sur les phases + où elle le mesure. Une voiture qui prend 10 A d'une borne commandée à 16 n'occupe que 10 A : + lui rendre 16 lui ouvrirait 6 A de trop. Une borne sans mesure, à l'arrêt, ou qui bascule de + phases (mesurées ≠ visées) ne rend rien, et la marge s'applique quand même. +- *La marge : 1 A par phase.* C'est celle de l'amont, pour la même raison : laisser à un appareil + moyen allumé entre deux cycles la place de démarrer avant que L4 ait à couper. Elle couvre aussi + l'écart entre la puissance annoncée par la borne et le courant lu au compteur (tension ≠ 230 V, + ±3 % à 16 A, soit 0,5 A). L'arrondi à l'ampère **inférieur** garantit qu'elle n'est pas mangée + par l'adaptateur, qui arrondit la consigne au plus proche. Plus grande, elle coûterait 230 W + par phase toute la nuit, sans raison mesurée ; plus petite, elle ne couvrirait plus l'écart de + mesure. +- *La sécurité en dépassement tient.* C'était la raison de ne rien rendre. Si une phase tire + `I > limite`, dont `E` pour la borne, la place rendue vaut `limite − (I − E) − 1 < E − 1` : la + consigne baisse d'au moins 1 A par cycle, jusqu'à sortir du dépassement ou jusqu'au plancher, où + la stratégie tient la borne et où L4 décide. Cela suppose que le compteur voie la borne — ce + que L4 suppose déjà. +- *Un compteur qui ne voit pas la borne* ne la mène pas seule à la limite : le tirage de la maison + sans la borne est écrêté à zéro, donc une phase vaut au plus sa limite moins la marge. +- *Où.* Seulement là où `EvAdapter` vise la pleine puissance d'heures creuses (trois modes, heure + creuse en cours). Hors heures creuses et sans tarif, `evPhaseAllowanceW()` reste la règle, et + les décisions sans tarif ne changent pas. En heures creuses, une dérogation sur la borne lit le + même plafond (c'est le même `declared.maxPowerW`). Sans heure creuse, la dérogation garde + l'ancienne règle : la faire passer par la même voie changerait son comportement sans tarif, et + ce n'est pas décidé. + +**Hypothèses.** Les phases de la borne sont celles du compteur (même hypothèse que L4 et que +l'amont). Une borne qui annonce plus que ce que le compteur voit d'elle reçoit au plus 1 A sous la +limite. Un compteur rafraîchi après la borne (borne qui monte, compteur pas encore) peut +surestimer la place d'un cycle, dans la même limite ; L4, lui, lit le compteur en temps réel. + +**Preuves.** `testOffPeakFullPowerSettlesUnderThePhaseLimit` (sept cas, douze cycles : la consigne +se pose dès le premier cycle, la phase la plus chargée reste à 1 A au moins sous la limite ; un +cas à 2,4 A de maison prouve l'arrondi) ; `testOffPeakFullPowerYieldsToThePhaseLimit`, réécrit +avec un compteur qui voit la borne (mesure et non consigne, dépassement, plancher, coupure par +L4) ; `testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter` (écrêtage à zéro) ; +`testOverloadProtectionEcoMode`, hors heures creuses, inchangé. Dans +`testBlockedDischargeShowsAtTheMeterAndL4Decides`, la borne en dépassement finit à son plancher +(L4 la ramène de 16 à 13 A, la stratégie laisse 9 A, L4 la ramène à 6 A) au lieu d'être coupée : +l'assertion attend désormais « plancher ou coupée ». + +### 8.3 D9 — la somme éco + confort tient dans le plafond (2026-10-25, suite) + +**Le défaut (écart 4).** En heures creuses, le niveau éco d'une borne vaut son plafond de cycle +(`declared.maxPowerW`), qui porte la limite de phase. À l'aube, la passe confort lui ajoutait le +surplus, et seul le courant maximal de la borne écrêtait la somme. Mesuré en simulation : borne +monophasée sur A, limite 16 A, maison à 2 A sur A, phases B et C exportant 1 500 W chacune. Le +plafond vaut 13 A ; la consigne montait à 16 A, et la phase A à **18 A pour 16**. Le partage +publiait 2 990 W achetés quand le compteur n'en importait que 450. + +**La règle.** Pour une borne servie à pleine puissance en heures creuses +(`EV_OFF_PEAK_FULL_POWER`) : + +- la passe confort sert le surplus **dans** le plafond, et non par-dessus ; +- l'achat **cède** ce que le surplus finance : achat = plafond − part confort, jamais négatif. + La somme reste donc le plafond, et l'achat publié (`gridW`, `draw.committedW`, `counts`) est ce + qui reste à acheter ; +- la part confort n'a pas de plancher : le plancher porte sur la somme, qui vaut le plafond, donc + jamais moins que lui. Sans cela, un surplus sous le plancher serait refusé, ou démarré par + `EV_GRID_START` — un second achat en plus du premier. + +Le motif suit la règle de la part majoritaire : à l'aube, quand le surplus finance plus de la +moitié du plafond, la décision publiée est celle du confort ; le niveau éco reste lisible dans +`levels`. La marge de soutirage du reste de la cascade est décomptée comme avant (prudence +inchangée). Hors heures creuses et sans tarif, rien ne change : la règle ne s'applique qu'aux +bornes qui portent `EV_OFF_PEAK_FULL_POWER`. + +| Cas (simulation) | Avant | Après | +|---|---|---| +| Aube, 2 540 W de surplus | 16 A, phase A à 18 A ; 2 990 W achetés | 13 A, phase A à 15 A ; 450 W achetés, 2 540 W de surplus | +| Plein soleil, surplus > plafond | 16 A | 13 A ; 0 W acheté, 2 990 W de surplus | +| Nuit | 13 A ; 2 990 W achetés | inchangé | +| Compteur aveugle aussi au total | 13, 16, 16… A | 13, 15, 15… A | + +Avec un compteur aveugle au total, le surplus est **fabriqué** par le recrédit : la consigne +tient le plafond, mais le partage publié, lui, ne peut pas être juste — le compteur ne dit pas ce +qui est acheté. + +**Preuves.** `testOffPeakFullPowerAtDawnStaysInTheCeiling` (nuit, aube, plein soleil, quatre +cycles chacun : consigne et phase, somme, achat ≤ import du compteur) ; +`testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter`, étendu au total aveugle. La garde de +bascule de §8.2 est désormais éprouvée : `testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases` +(borne en monophasé à 10 A qui bascule en triphasé, maison à 12 A sur B, limite 25 A : 12 A, et B +à 24 A ; sans la garde, 15 A et B à 27 A). diff --git a/docs/DESIGN_PREVISION_PV.md b/docs/DESIGN_PREVISION_PV.md index a6fc727..3b668e2 100644 --- a/docs/DESIGN_PREVISION_PV.md +++ b/docs/DESIGN_PREVISION_PV.md @@ -1679,3 +1679,9 @@ jours de 23 et 25 h (oracles Python `zoneinfo` dans les tests). **Pour e1 (plus tard, sans être lu maintenant)** : `TariffConfig::zoneAt(instant, fuseau)` rend la zone en vigueur ; « HC » n'a pas de code — le libellé est une donnée de l'installateur. Le lot e1 devra décider d'une règle (par exemple : la ou les zones au prix le plus bas), pas la deviner ici. + +> **FAIT (lot e1, 2026-10-24)** — la règle retenue est celle-là : les heures creuses sont la ou +> les zones au prix le plus bas d'un tarif `tou` dont les prix diffèrent +> (`TariffConfig::zonesCreuses()`). Elles décident de deux choses, et le prix d'aucune : +> la borne en mode « avec minimum » ou « avec échéance » et la décharge de la batterie. Design, +> preuves et limites : `docs/DESIGN_HC_BATTERIE.md`. diff --git a/docs/INVENTAIRE_MOCK.md b/docs/INVENTAIRE_MOCK.md index e6e1bb9..bbc1dfe 100644 --- a/docs/INVENTAIRE_MOCK.md +++ b/docs/INVENTAIRE_MOCK.md @@ -28,6 +28,7 @@ rien sur les cas qu'il ne sait pas produire. | `powerSwitch` | ✅ | ❌ | — | | `etmVariableLoad` | ✅ *(`currentPowerW`)* | ❌ | — | | `energyStorage` | ✅ | ❌ | — | +| `controllableStorage` *(lot e1, 2026-10-24)* | ✅ | ❌ | — | | `car`, `notification` | ❌ | ❌ | — | **Ce que la table donne, et c'est la bonne nouvelle** : `sessionEnergy` et `totalEnergyConsumed` @@ -228,3 +229,20 @@ qui ne mesure qu'une charge. site, il ne l'a pas — `snow` n'est éprouvé qu'en unitaire), et un plan **sans** les paramètres v2 (la classe les porte toujours — le cas « openmeteo d'avant le brief 2 » n'est éprouvé qu'en unitaire, par `lirePlan()`). + +## 10. Batterie pilotable — `controllableStorage` (lot e1, 2026-10-24) + +| Classe | Porte | Ce qu'elle ouvre | +|---|---|---| +| `controllableStorage` | interface `energystorage` ; états MODIFIABLES `enableDischarging`, `dischargingRate`, `enableCharging`, `chargingRate`, `gridCharging` (les noms du `sunspecStorage` du plugin SunSpec, modèle 124) ; paramètre `revertTime` (minuteur de retour, 300 s par défaut, 0 = aucun) | la commande de la décharge en heures creuses, ses deux refus (`noRevertTimer` avec `revertTime = 0` ; `noDischargeControl` avec `energyStorage`, qui n'a pas ces états) | + +**Ce que le mock ne fait PAS, à dessein** : il n'émule pas l'onduleur. Il range ce qu'on écrit, +et c'est tout — le minuteur de retour n'expire jamais de lui-même. Le modèle de l'onduleur (« la +limite tient tant que la dernière écriture a moins de `revertTime` ») vit dans le TEST +(`limiteTenue()`), qui lit les écritures à la frontière nymea (`ThingManager::actionExecuted`) et +non dans la mémoire du garde : un modèle logé dans le mock ou dans le moteur se donnerait raison. + +**Ce qui reste introuvable ici** : une écriture ACCEPTÉE puis IGNORÉE par l'onduleur (le plugin +la relit et publie `controlHealthy = false`, AC-13 du plugin), et le retour SPONTANÉ de l'onduleur +à l'automatisme à l'échéance du minuteur. Le premier se lit dans `storage.batteries[].observed` ; +le second n'est éprouvé que par le modèle du test. diff --git a/docs/SAFETY.md b/docs/SAFETY.md index 1a1264b..a62a013 100644 --- a/docs/SAFETY.md +++ b/docs/SAFETY.md @@ -62,7 +62,7 @@ repli **conservatrice** : le repli n'INITIE rien, il borne ce qui tourne déjà. | EV débranché | Aucune action | | ECS | Relais coupé (palier 0, `force=true`) | | SG-Ready PAC | État **2** (normal — mains off, la PAC chauffe selon son thermostat). JAMAIS état 1 (blocage) : bloquer une PAC sous compteur muet = maison qui ne chauffe plus sans raison visible. | -| Batterie | Aucune charge réseau (surplus uniquement, plafonné à 0 si compteur muet) | +| Batterie | Rendue à son **automatisme** : une limite de décharge posée en heures creuses (lot e1) est **levée explicitement** à la transition (`enableDischarging = false`, puis `dischargingRate` au maximum). Le moteur ne commande jamais de charge réseau, il n'y a donc rien d'autre à replier. Si la levée échoue, le minuteur de retour de l'onduleur (`revertTime`) la fait tomber seul : c'est pour cela qu'une batterie sans minuteur n'est jamais commandée. | « Maintenu » ≠ « démarré » : le mode dégradé ne force jamais l'activation d'une charge. La garantie *"jamais 0 A si le VE est branché"* appartient au **failsafe L1 de la borne** diff --git a/energyplugin/etm/adapters/evadapter.cpp b/energyplugin/etm/adapters/evadapter.cpp index 565eb98..bf2a0e9 100644 --- a/energyplugin/etm/adapters/evadapter.cpp +++ b/energyplugin/etm/adapters/evadapter.cpp @@ -51,7 +51,8 @@ LoadTelemetry EvAdapter::telemetry() const LoadContext EvAdapter::toLoadContext(const QDateTime &now) const { - Q_UNUSED(now) // Les verrous EV (enclenchement, changement de courant) vivent en aval. + // Les verrous EV (enclenchement, changement de courant) vivent en aval : `now` ne sert qu'à + // savoir si l'instant tombe en heures creuses (lot e2). LoadContext ctx; ctx.id = m_charger->thing()->id().toString(); ctx.adapter = QStringLiteral("evcharger"); @@ -74,7 +75,29 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const // avec CETTE voiture » est tranchée une fois par le manager, et evTargetPhases() la lit. // Le prédicat local n'en voyait qu'une moitié (l'état existe-t-il) et retombait sur le // nombre de phases COURANT, sans la voiture. - const double courantReferenceA = m_parent->evReferenceCurrentA(m_charger); + const ChargingInfo::ChargingMode mode = m_parent->chargingInfo(m_charger->id()).chargingMode(); + const bool reportable = mode == ChargingInfo::ChargingModeEcoWithMinCurrent + || mode == ChargingInfo::ChargingModeEcoMinWithTargetTime + || mode == ChargingInfo::ChargingModeEcoWithTargetTime; + + // Lot e2 — EN HEURES CREUSES, la référence est la PLEINE PUISSANCE de la borne, et non plus + // « courant + surplus ». La règle de bascule reste celle du manager ; c'est ce qu'on lui + // demande qui change. + // « Courant + surplus » répond à « combien le soleil donne-t-il ? », et la nuit la réponse + // décide mal : la maison importe, la référence est négative et la règle vise le triphasé — + // sauf quand une batterie couvre la maison et la voiture. Le compteur lit alors ≈ 0, la + // référence vaut le courant que la borne tire déjà (6 A en monophasé), et la règle la GARDE + // en monophasé : 3,7 kW au plus pour une borne qui en ferait 11. En heures creuses, la + // question n'est plus le surplus mais ce que la borne peut prendre. + // + // La limite de phase n'entre pas dans le choix, et c'est sans perte : par phase, elle borne + // de la même façon une monophasée et une triphasée, donc trois phases donnent toujours au + // moins autant (marge × 3 contre marge × 1). Sous le plancher, aucune des deux ne tient, et + // c'est la protection de surcharge qui tranche. + const bool pleinePuissanceHc = reportable && m_parent->enHeuresCreuses(now); + const double courantReferenceA = pleinePuissanceHc + ? m_charger->maxChargingCurrentMaxValue() * 3.0 + : m_parent->evReferenceCurrentA(m_charger); m_targetPhases = m_parent->evTargetPhases(m_charger, courantReferenceA); if (m_targetPhases == 0) m_targetPhases = 1; @@ -93,7 +116,6 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const // La valeur n'est PAS stockée : elle se dérive du matériel à chaque cycle, comme le // plancher juste au-dessus. L'écrire en configuration à l'appairage créerait une copie qui // se périme au premier recâblage — `phaseCount` change, la copie reste fausse en silence. - const ChargingInfo::ChargingMode mode = m_parent->chargingInfo(m_charger->id()).chargingMode(); if (mode == ChargingInfo::ChargingModeEcoWithMinCurrent || mode == ChargingInfo::ChargingModeEcoMinWithTargetTime) { ctx.needs.minPowerWAlways = ctx.declared.minPowerW; @@ -107,7 +129,13 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const // APRÈS coup — c'est-à-dire après avoir commandé. Le plafond doit donc être DÉCLARÉ, comme // n'importe quelle borne d'adaptateur (règle 4). ctx.declared.maxPowerW = m_charger->maxChargingCurrentMaxValue() * 230.0 * m_targetPhases; - const double plafondPhaseW = m_parent->evPhaseAllowanceW(m_charger, m_targetPhases); + // Lot e2, suite (2026-10-25) — EN HEURES CREUSES, le plafond rend à la borne ce qu'elle tire, + // moins 1 A par phase. Sans ce retour, à pleine puissance, sa propre consommation lui + // retirait la place au cycle suivant et la lui rendait au cycle d'après : 16, 7, 16, 7 A sous + // une limite de 25 A, avec une maison à 2 A. Hors heures creuses, la règle d'avant. + const double plafondPhaseW = pleinePuissanceHc + ? m_parent->evOffPeakAllowanceW(m_charger, m_targetPhases) + : m_parent->evPhaseAllowanceW(m_charger, m_targetPhases); if (plafondPhaseW >= 0) { // …mais JAMAIS sous le plancher de la borne. C'est la règle de l'amont, reprise mot // pour mot (`if (newValueInt <= minValue) newValueInt = minValue`), et la répartition @@ -120,6 +148,24 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const qMax(plafondPhaseW, ctx.declared.minPowerW)); } + // --- Lots e1 et e2 : le besoin RÉSEAU se reporte en heures creuses ----------------- + // + // DÉCLARÉ ici, DÉCIDÉ par le scheduler (règle 2) — et seulement si le tarif saisi a des + // heures creuses ; sans elles, ces deux champs n'ont aucun effet. Trois modes : les deux + // « avec minimum » et « avec échéance ». `Eco` seul ne demande jamais le réseau, `Normal` + // est piloté par l'utilisateur. + // + // Lot e2 (décision du 2026-10-24) — le minimum d'heures creuses est le PLAFOND du cycle, et + // non plus le plancher. Au plancher (6 A, 1,4 kW en monophasé), une session de 10 à 25 kWh + // n'était pas finie au départ, et la décharge de la batterie restait bloquée toute la nuit. + // À pleine puissance, la session finit tôt et la batterie est rendue pour le reste de la nuit. + // Le plafond est pris APRÈS l'écrêtage ci-dessus : il porte le courant maximal, les phases et + // la limite de phase, et ne descend jamais sous le plancher — la stratégie rabaisse, L4 coupe. + if (reportable) { + ctx.needs.offPeakShift = true; + ctx.needs.offPeakMinPowerW = ctx.declared.maxPowerW; + } + ctx.telemetry.currentPowerW = m_charger->currentPower(); ctx.telemetry.available = m_charger->available(); ctx.telemetry.pluggedIn = m_charger->pluggedIn(); diff --git a/energyplugin/etm/adapters/evadapter.h b/energyplugin/etm/adapters/evadapter.h index c14501e..c80a3fe 100644 --- a/energyplugin/etm/adapters/evadapter.h +++ b/energyplugin/etm/adapters/evadapter.h @@ -46,9 +46,17 @@ public: /*! * \brief Construit l'entrée loads[] §5 du SurplusContext. - * \param now Temps de cycle (\c ctx.timestamp). Inutilisé ici : l'EV n'a pas de verrou - * de palier (hors waterfall ECS/SG-Ready). Présent pour l'uniformité de \c ILoadAdapter. + * \param now Temps de cycle (\c ctx.timestamp). L'EV n'a pas de verrou de palier (hors + * waterfall ECS/SG-Ready) ; l'instant ne sert qu'à savoir s'il tombe en heures creuses + * (lot e2) — la borne y vise sa pleine puissance, et ses phases s'y choisissent. * \return LoadContext incluant declared, limits, needs et télémétrie EV. + * \note **Lot e2** — dans les trois modes qui reportent en heures creuses, le minimum + * d'heures creuses (\c needs.offPeakMinPowerW) est le PLAFOND du cycle + * (\c declared.maxPowerW : courant maximal, phases, limite de phase), jamais sous le + * plancher ; et le nombre de phases se choisit sur ce plafond, plus sur le surplus. + * \note **Lot e2, suite** — dans ces mêmes conditions, la limite de phase est lue par + * \c EnergyArbitrator::evOffPeakAllowanceW() : la place libre plus le courant MESURÉ de la + * borne, moins 1 A par phase. Hors heures creuses, \c evPhaseAllowanceW(), inchangée. */ LoadContext toLoadContext(const QDateTime &now) const override; diff --git a/energyplugin/etm/adapters/storagedischargeguard.cpp b/energyplugin/etm/adapters/storagedischargeguard.cpp new file mode 100644 index 0000000..e7651b0 --- /dev/null +++ b/energyplugin/etm/adapters/storagedischargeguard.cpp @@ -0,0 +1,349 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync + +#include "storagedischargeguard.h" + +#include "plugininfo.h" + +#include +#include +#include + +namespace { +const QString EtatLimite = QStringLiteral("enableDischarging"); // bit DiSCHARGE de StorCtl_Mod +const QString EtatTaux = QStringLiteral("dischargingRate"); // OutWRte, % de la puissance max + +//! Délai minimal entre deux réécritures déclenchées par une batterie qui ne rapporte plus la +//! consigne : l'état relu d'un onduleur suit l'écriture d'un poll (≈ un cycle). Réécrire plus +//! vite reviendrait à réécrire ce qui est en vol. +constexpr int ReecritureMinS = 55; + +//! Après le n-ième refus d'affilée d'une pose, la suivante attend `EspacementRefusS[n - 1]` +//! secondes (le dernier palier ensuite) : 1, 2, 5, 10 puis 15 min. Un onduleur qui refuse (GEN24 +//! sans « Allow Control », exception Modbus 0x04) refusera encore au cycle suivant ; insister à +//! chaque minute toute la nuit n'apprend rien et charge le bus. Quinze minutes au plus : un +//! réglage corrigé en cours de nuit est repris dans le quart d'heure. +constexpr int EspacementRefusS[] = {60, 120, 300, 600, 900}; +constexpr int PaliersRefus = int(sizeof(EspacementRefusS) / sizeof(EspacementRefusS[0])); +//! Un cycle « par minute » arrive à quelques secondes près : sans cette marge, l'attente d'une +//! minute en coûterait deux (même raison que \c ReecritureMinS). +constexpr int MargeCycleS = 5; + +//! La valeur de `dischargingRate` qui rend la décharge LIBRE : le maximum de l'état, 100 % à défaut. +double tauxLibre(Thing *t) +{ + const QVariant max = t->thingClass().stateTypes().findByName(EtatTaux).maxValue(); + return max.isValid() ? max.toDouble() : 100.0; +} +} + +StorageDischargeGuard::StorageDischargeGuard(ThingManager *tm, QObject *parent) + : QObject(parent) + , m_tm(tm) +{ +} + +SurplusContext::StorageControl StorageDischargeGuard::capacite(Thing *t) +{ + SurplusContext::StorageControl c; + if (!t) + return c; + c.thingId = t->id().toString(); + + // La question est posée à la CLASSE : les deux états existent, sont modifiables, ont leur + // action, et la limite admet 0 %. Une valeur d'état ne dit rien de ce qu'on peut écrire. + StateTypes etats = t->thingClass().stateTypes(); + ActionTypes actions = t->thingClass().actionTypes(); + const StateType limite = etats.findByName(EtatLimite); + const StateType taux = etats.findByName(EtatTaux); + const bool pilotable = !limite.id().isNull() && limite.writable() + && limite.type() == QMetaType::Bool + && !actions.findByName(EtatLimite).id().isNull() + && !taux.id().isNull() && taux.writable() + && !actions.findByName(EtatTaux).id().isNull() + && (!taux.minValue().isValid() || taux.minValue().toDouble() <= 0.0); + if (!pilotable) { + c.cause = QStringLiteral("noDischargeControl"); + return c; + } + + // LE MINUTEUR DE RETOUR, et c'est lui la sûreté : sans lui, un moteur arrêté laisserait la + // batterie bloquée indéfiniment. On ne commande pas ce qui ne sait pas revenir seul. + const ParamType minuteur = t->thingClass().paramTypes().findByName(QStringLiteral("revertTime")); + const uint revertS = minuteur.id().isNull() ? 0 : t->paramValue(minuteur.id()).toUInt(); + if (revertS == 0) { + c.cause = QStringLiteral("noRevertTimer"); + return c; + } + if (revertS < uint(MinRevertTimeS)) { + c.cause = QStringLiteral("revertTimerTooShort"); + return c; + } + c.controllable = true; + return c; +} + +QList StorageDischargeGuard::capacites() const +{ + QList l; + if (!m_tm) + return l; + for (Thing *t : m_tm->configuredThings().filterByInterface(QStringLiteral("energystorage"))) + l << capacite(t); + return l; +} + +void StorageDischargeGuard::appliquer(bool configure, bool bloquer, const QDateTime &now) +{ + if (!m_tm) + return; + + // Une batterie disparue n'a plus d'état à tenir. + for (auto it = m_etats.begin(); it != m_etats.end();) { + if (!m_tm->findConfiguredThing(ThingId(it.key()))) + it = m_etats.erase(it); + else + ++it; + } + + for (Thing *t : m_tm->configuredThings().filterByInterface(QStringLiteral("energystorage"))) { + const QString id = t->id().toString(); + // SANS heures creuses, rien n'est lu ni écrit — sauf une consigne qu'on tiendrait encore. + if (!configure && !(m_etats.contains(id) && m_etats.value(id).tenue)) + continue; + const SurplusContext::StorageControl cap = capacite(t); + if (!cap.controllable) + continue; // lue comme avant le lot ; le motif STORAGE_NOT_PROTECTED le dit + + Etat &e = m_etats[id]; + const bool limiteActive = t->stateValue(EtatLimite).toBool(); + const bool aZero = qFuzzyIsNull(t->stateValue(EtatTaux).toDouble()); + if (!e.vue) { + e.vue = true; + // ADOPTION — une limite à 0 % déjà active au premier regard est celle qu'un moteur + // précédent a laissée (redémarrage de nymead). La laisser expirer contredirait + // « consigne levée explicitement » ; la reprendre la fait rafraîchir ou lever. + if (configure && limiteActive && aZero) { + e.tenue = true; + e.depuis = now; + qCInfo(dcNymeaEnergy()) << "[Batterie]" << t->name() + << "— limite de décharge à 0 % trouvée active au démarrage : reprise."; + } + } + + if (bloquer) { + // SÉRIE DE REFUS : pas de nouvelle pose avant l'échéance de l'espacement. La levée, + // elle, n'attend jamais (branche ci-dessous). + if (e.refus > 0 && now < e.prochainEssai.addSecs(-MargeCycleS)) + continue; + const qint64 age = e.derniereEcriture.isValid() ? e.derniereEcriture.secsTo(now) : -1; + const int revertS = t->paramValue(t->thingClass().paramTypes() + .findByName(QStringLiteral("revertTime")).id()).toInt(); + // Le TIERS du minuteur : à 300 s, une réécriture toutes les 100 s au plus tard ; à un + // cycle par minute, l'écart réel entre deux écritures reste sous 180 s, cycle manqué + // compris — bien avant l'expiration. + const bool echu = age < 0 || age >= revertS / 3; + // L'onduleur ne rapporte plus la consigne : il est revenu seul à l'automatisme (un + // minuteur expiré, un redémarrage) — on la repose sans attendre l'échéance. + const bool perdue = e.tenue && e.enCours == 0 && !(limiteActive && aZero) + && age >= ReecritureMinS; + if (!e.tenue || echu || perdue) + bloquerUne(t, e, now, e.tenue && !perdue); + } else if (e.tenue) { + leverUne(t, e, now); + } + } +} + +void StorageDischargeGuard::leverTout(const QDateTime &now) +{ + if (!m_tm) + return; + for (auto it = m_etats.begin(); it != m_etats.end(); ++it) { + Thing *t = m_tm->findConfiguredThing(ThingId(it.key())); + if (t && it.value().tenue) + leverUne(t, it.value(), now); + } +} + +bool StorageDischargeGuard::tientUneConsigne() const +{ + for (const Etat &e : m_etats) + if (e.tenue && e.refus == 0) + return true; + return false; +} + +QVariantList StorageDischargeGuard::vue() const +{ + QVariantList l; + if (!m_tm) + return l; + for (Thing *t : m_tm->configuredThings().filterByInterface(QStringLiteral("energystorage"))) { + const SurplusContext::StorageControl cap = capacite(t); + const Etat e = m_etats.value(cap.thingId); + QVariantMap m{{QStringLiteral("thingId"), cap.thingId}, + {QStringLiteral("controllable"), cap.controllable}, + {QStringLiteral("held"), e.tenue && e.refus == 0}}; + if (!cap.cause.isEmpty()) + m.insert(QStringLiteral("cause"), cap.cause); + if (e.tenue && e.refus == 0 && e.depuis.isValid()) + m.insert(QStringLiteral("heldSince"), e.depuis.toUTC().toString(Qt::ISODate)); + // UNE SÉRIE DE REFUS : `held` est faux — l'onduleur ne tient pas ce qu'il refuse — et ces + // trois champs disent pourquoi, et quand le moteur réessaiera. + if (e.tenue && e.refus > 0) { + m.insert(QStringLiteral("refusedWrites"), e.refus); + m.insert(QStringLiteral("refusedSince"), e.refusDepuis.toUTC().toString(Qt::ISODate)); + m.insert(QStringLiteral("nextAttemptAt"), e.prochainEssai.toUTC().toString(Qt::ISODate)); + } + if (e.derniereEcriture.isValid()) + m.insert(QStringLiteral("lastWriteAt"), e.derniereEcriture.toUTC().toString(Qt::ISODate)); + // CE QUI A ÉTÉ LU, pas ce qu'on a écrit : c'est la seule preuve que la batterie a suivi. + QVariantMap lu; + if (!t->thingClass().stateTypes().findByName(EtatLimite).id().isNull()) + lu.insert(EtatLimite, t->stateValue(EtatLimite).toBool()); + if (!t->thingClass().stateTypes().findByName(EtatTaux).id().isNull()) + lu.insert(EtatTaux, t->stateValue(EtatTaux).toDouble()); + if (!lu.isEmpty()) + m.insert(QStringLiteral("observed"), lu); + l << m; + } + return l; +} + +void StorageDischargeGuard::bloquerUne(Thing *t, Etat &e, const QDateTime &now, bool rafraichissement) +{ + if (e.tenue && e.refus > 0) + qCDebug(dcNymeaEnergy()) << "[Batterie]" << t->name() << "— nouvelle tentative de pose après" + << e.refus << "refus."; + else if (rafraichissement) + qCDebug(dcNymeaEnergy()) << "[Batterie]" << t->name() + << "— limite de décharge à 0 % rafraîchie (minuteur de retour réarmé)."; + else + qCInfo(dcNymeaEnergy()) << "[Batterie]" << t->name() + << "— décharge BLOQUÉE : limite de décharge à 0 %" + << "(dischargingRate = 0, puis enableDischarging = true)."; + if (!e.tenue) + e.depuis = now; + e.tenue = true; + e.derniereEcriture = now; + // ORDRE : le taux d'abord, la limite ensuite. Tant que la limite est inactive, le taux est + // inerte ; quand elle s'active, il vaut déjà 0. L'inverse activerait une limite au taux + // précédent, peut-être 100 %, le temps d'une écriture. Si la première échoue, la seconde ne + // part pas : la limite reste inactive, donc la batterie en automatique — le sens sûr. + // La séquence est ACCEPTÉE quand la seconde écriture l'est ; un refus de l'une OU de l'autre + // compte pour la série. + const QString id = t->id().toString(); + const auto refus = [this, id](const QString &etat, const QString &cause) { + noterRefus(id, etat, cause); + }; + ecrire(t, EtatTaux, 0, [this, id, refus]() { + if (Thing *suite = m_tm->findConfiguredThing(ThingId(id))) + ecrire(suite, EtatLimite, true, [this, id]() { noterAcceptation(id); }, refus); + }, refus); +} + +void StorageDischargeGuard::noterRefus(const QString &id, const QString &etat, const QString &cause) +{ + if (!m_etats.contains(id) || !m_etats.value(id).tenue) + return; // la consigne a été levée entre-temps : ce verdict tardif ne pèse plus + Etat &e = m_etats[id]; + Thing *t = m_tm->findConfiguredThing(ThingId(id)); + const QString nom = t ? t->name() : id; + const int attente = EspacementRefusS[qMin(e.refus + 1, PaliersRefus) - 1]; + // UN SEUL AVERTISSEMENT PAR SÉRIE : le premier refus est aussi visible que l'application + // (règle 7-d) ; les suivants ne disent rien de neuf, et la télémétrie les compte. + if (e.refus == 0) { + e.refusDepuis = e.derniereEcriture; + qCWarning(dcNymeaEnergy()).noquote() + << "[Batterie]" << nom << "— écriture de" << etat + << QStringLiteral("REFUSÉE (%1) : la décharge").arg(cause) + << "n'est PAS bloquée. Nouvelles tentatives espacées (1, 2, 5, 10 puis 15 min) ; ce" + << "refus n'est plus signalé qu'en débogage jusqu'à la première écriture acceptée."; + } else { + qCDebug(dcNymeaEnergy()).noquote() + << "[Batterie]" << nom << "— écriture de" << etat + << QStringLiteral("encore refusée (%1),").arg(cause) + << e.refus + 1 << "refus d'affilée ; prochain essai dans" << attente << "s."; + } + ++e.refus; + e.prochainEssai = e.derniereEcriture.addSecs(attente); +} + +void StorageDischargeGuard::noterAcceptation(const QString &id) +{ + if (!m_etats.contains(id)) + return; + Etat &e = m_etats[id]; + if (e.refus > 0) { + Thing *t = m_tm->findConfiguredThing(ThingId(id)); + qCInfo(dcNymeaEnergy()) << "[Batterie]" << (t ? t->name() : id) + << "— limite de décharge à 0 % ACCEPTÉE après" << e.refus + << "refus : décharge bloquée, retour au rythme normal."; + } + e.refus = 0; + e.refusDepuis = QDateTime(); + e.prochainEssai = QDateTime(); +} + +void StorageDischargeGuard::leverUne(Thing *t, Etat &e, const QDateTime &now) +{ + qCInfo(dcNymeaEnergy()) << "[Batterie]" << t->name() + << "— décharge RENDUE à l'automatisme : limite levée" + << "(enableDischarging = false, puis dischargingRate =" << tauxLibre(t) << ")."; + e.tenue = false; + e.depuis = QDateTime(); + e.derniereEcriture = now; + // La levée CLÔT une série de refus : la prochaine pose est une nouvelle consigne, au rythme + // normal, et son premier refus sera de nouveau signalé. + e.refus = 0; + e.refusDepuis = QDateTime(); + e.prochainEssai = QDateTime(); + // ORDRE INVERSE : lever la limite rend l'automatisme TOUT DE SUITE ; le taux suit, pour + // qu'une limite réactivée plus tard par quiconque ne reparte pas à 0 %. La seconde part même + // si la première échoue : remettre le taux au maximum ne bloque rien. + ecrire(t, EtatLimite, false); + ecrire(t, EtatTaux, tauxLibre(t)); +} + +void StorageDischargeGuard::ecrire(Thing *t, const QString &etat, const QVariant &valeur, + const std::function &suite, + const std::function &refus) +{ + const StateType st = t->thingClass().stateTypes().findByName(etat); + Action action(st.id(), t->id(), Action::TriggeredByRule); + action.setParams(ParamList() << Param(st.id(), valeur)); + ThingActionInfo *info = m_tm->executeAction(action); + if (!info) { + const QString cause = QStringLiteral("aucune réponse du gestionnaire"); + if (refus) + refus(etat, cause); + else + qCWarning(dcNymeaEnergy()) << "[Batterie]" << t->name() << "— écriture de" << etat + << "impossible (" << cause << ")."; + return; + } + const QString id = t->id().toString(); + ++m_etats[id].enCours; + // Aucune attente : verdict par signal, `this` en contexte (même modèle qu'ECS-414). Un REFUS + // est au moins aussi visible que l'application (règle 7-d) — une fois par série pour la pose + // (\c noterRefus), à chaque fois pour la levée, qui n'est jamais répétée. + connect(info, &ThingActionInfo::finished, this, [this, info, id, etat, valeur, suite, refus]() { + if (m_etats.contains(id)) + --m_etats[id].enCours; + if (info->status() != Thing::ThingErrorNoError) { + if (refus) { + QString statut; + QDebug(&statut).nospace() << info->status(); + refus(etat, QStringLiteral("statut ") + statut); + return; + } + Thing *t = m_tm->findConfiguredThing(ThingId(id)); + qCWarning(dcNymeaEnergy()) << "[Batterie]" << (t ? t->name() : id) << "— écriture de" + << etat << "=" << valeur << "REFUSÉE, statut" << info->status(); + return; + } + if (suite) + suite(); + }); +} diff --git a/energyplugin/etm/adapters/storagedischargeguard.h b/energyplugin/etm/adapters/storagedischargeguard.h new file mode 100644 index 0000000..8249321 --- /dev/null +++ b/energyplugin/etm/adapters/storagedischargeguard.h @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +#include "../types/surpluscontext.h" + +class Thing; +class ThingManager; + +/*! + * \brief Bloque la DÉCHARGE d'une batterie, et rien d'autre — lot e1 (2026-10-24). + * + * Le moteur décide (\c RuleBasedScheduler::storageDecision()), ce garde EXÉCUTE (règle 2) : il + * écrit les consignes, les rafraîchit avant que le minuteur de retour de l'onduleur n'expire, et + * les LÈVE explicitement quand la décision tombe. Il n'entre pas dans le waterfall et n'a pas de + * \c LoadConfig : la batterie n'est pas une charge rangée (\c spec_loadmodel.md §14, lot 3f). + * + * \par Ce qu'il écrit — modèle SunSpec 124, lu dans le plugin + * `StorCtl_Mod` est un champ de bits : le bit \c DiSCHARGE **ACTIVE la limite** de décharge + * `OutWRte`, il n'« autorise » pas la décharge. L'état \c enableDischarging du plugin est ce + * bit, \c dischargingRate est `OutWRte` (% de la puissance maximale). Bloquer la décharge, + * c'est donc `dischargingRate = 0` PUIS `enableDischarging = true` — la séquence + * `NO_DISCHARGE` du plugin (`StorCtl_Mod = 2`, `OutWRte = 0`). Écrire + * `enableDischarging = false` LÈVE la limite : c'est le geste de RETOUR à l'automatisme, suivi + * de `dischargingRate` à son maximum (la séquence `NORMAL`, `OutWRte = 100`). + * \par Ce qu'il n'écrit JAMAIS + * \c gridCharging, \c enableCharging, \c chargingRate : la recharge depuis le réseau est hors + * produit (décision de Patrick, 2026-10-24). Une `dischargingRate` négative (charge FORCÉE) + * n'est jamais écrite non plus : seules 0 et le maximum de l'état le sont. + * \par La sûreté, c'est le minuteur de l'onduleur + * Chaque écriture du plugin réarme `InOutWRte_RvrtTms` (paramètre \c revertTime). Un moteur + * arrêté cesse de rafraîchir, et la batterie retrouve son automatisme au bout du minuteur. + * D'où le refus de commander une batterie SANS minuteur (\c noRevertTimer) ou avec un minuteur + * plus court que deux cycles (\c revertTimerTooShort) : la consigne y survivrait au moteur, ou + * expirerait entre deux rafraîchissements. + * \par Un onduleur qui REFUSE (suite du lot e1, 2026-10-24) + * Un GEN24 dont « Allow Control » est désactivé répond 0x04 à chaque écriture. Une pose refusée + * (taux ou limite) ouvre une SÉRIE de refus : la pose suivante attend 1, 2, 5, 10 puis 15 min + * au plus ; un seul \c qCWarning par série, les suivants en \c qCDebug. La série se CLÔT à la + * première séquence acceptée (retour au rythme du tiers du minuteur) ou à la levée. La levée + * n'est JAMAIS espacée. Pendant la série, la consigne n'est pas publiée comme tenue (\c vue()). + * \par Générique + * Par l'interface \c energystorage et les états PRÉSENTS — jamais par une ThingClass Fronius. + * Une batterie sans ces états est lue comme avant le lot. + */ +class StorageDischargeGuard : public QObject +{ + Q_OBJECT +public: + //! Minuteur de retour minimal (s) : deux cycles de 60 s, le rafraîchissement venant au tiers. + static constexpr int MinRevertTimeS = 120; + + /*! + * \brief Construit le garde. \param tm Registre des Things (non possédé). \param parent Parent Qt. + */ + explicit StorageDischargeGuard(ThingManager *tm, QObject *parent = nullptr); + + /*! + * \brief Ce que la batterie PERMET — lu sur sa ThingClass et ses paramètres, jamais sur une + * valeur d'état (une limite inactive reste une limite pilotable). + * \param t Batterie (\c energystorage). \return Sa capacité, \c cause vide si pilotable. + */ + static SurplusContext::StorageControl capacite(Thing *t); + + //! \brief Capacités de toutes les batteries configurées. \return Une entrée par batterie. + QList capacites() const; + + /*! + * \brief Exécute la décision du cycle. + * \param configure Le tarif saisi a des heures creuses ce cycle. + * \param bloquer Bloquer la décharge (décision du scheduler). + * \param now Temps de cycle — date les écritures et décide du rafraîchissement. + * + * - \p bloquer : sur chaque batterie pilotable, la limite est posée si elle ne l'est pas, ou + * RAFRAÎCHIE dès que la dernière écriture a l'âge du tiers du minuteur de retour, ou si la + * batterie ne rapporte plus la consigne (onduleur revenu seul à l'automatisme). + * Pendant une série de refus, aucune pose avant l'échéance de l'espacement. + * - sinon : toute consigne TENUE est levée explicitement (jamais laissée expirer), refusée + * ou non, et sans attendre l'espacement. + * + * \note Sans heures creuses (\p configure faux), le garde ne lit et n'écrit RIEN — sauf pour + * lever une consigne qu'il tiendrait encore (tarif effacé en pleine charge). C'est ce qui + * rend « sans tarif = etm74 » vrai par construction. + * \note À la PREMIÈRE vue d'une batterie (démarrage de \c nymead), une limite à 0 % déjà + * active est ADOPTÉE comme sienne, si \p configure : c'est la consigne qu'un moteur + * précédent a laissée, et elle est alors rafraîchie ou levée comme les autres. + */ + void appliquer(bool configure, bool bloquer, const QDateTime &now); + + /*! + * \brief Lève toute consigne tenue, sans condition — repli L2. + * \param now Instant de la levée. + */ + void leverTout(const QDateTime &now); + + /*! + * \brief Une consigne est-elle tenue sur au moins une batterie ? + * \return Vrai si une limite est posée et que sa dernière pose n'a pas été refusée. + */ + bool tientUneConsigne() const; + + /*! + * \brief Ce qui a été LU et ÉCRIT, par batterie — pour la télémétrie. + * \return Une carte par batterie : \c thingId, \c controllable, \c o:cause, \c held (consigne + * posée et NON refusée), \c o:heldSince, \c o:lastWriteAt (instants ISO UTC), + * \c observed {\c enableDischarging, \c dischargingRate} si la batterie les expose ; pendant + * une série de refus, \c held est faux et \c o:refusedWrites (poses refusées d'affilée), + * \c o:refusedSince, \c o:nextAttemptAt (ISO UTC) sont publiés. + * \note Un verdict arrive après le cycle qui a écrit : un refus se lit au cycle suivant. + */ + QVariantList vue() const; + +private: + struct Etat { + bool vue = false; //!< La batterie a déjà été examinée (adoption faite). + bool tenue = false; //!< Le moteur tient une limite à 0 %. + QDateTime depuis; //!< Début de la consigne tenue. + QDateTime derniereEcriture; //!< Dernière séquence écrite (temps de cycle). + int enCours = 0; //!< Écritures dont l'issue n'est pas encore connue. + int refus = 0; //!< Poses refusées d'affilée (0 : pas de série en cours). + QDateTime refusDepuis; //!< Pose qui a ouvert la série de refus. + QDateTime prochainEssai; //!< Pas de nouvelle pose avant cet instant (série en cours). + }; + + //! Pose la limite à 0 % : \c dischargingRate puis \c enableDischarging, enchaînés. + void bloquerUne(Thing *t, Etat &e, const QDateTime &now, bool rafraichissement); + //! Lève la limite : \c enableDischarging à faux puis \c dischargingRate au maximum. + void leverUne(Thing *t, Etat &e, const QDateTime &now); + //! Un refus de la pose de \p id (état \p etat, \p cause lisible) : avertit une fois par + //! série, compte, et fixe le prochain essai. + void noterRefus(const QString &id, const QString &etat, const QString &cause); + //! La pose de \p id est acceptée jusqu'au bout : clôt la série de refus. + void noterAcceptation(const QString &id); + //! Écrit un état modifiable ; \p suite est appelée si l'écriture réussit, \p refus (état, + //! cause) si elle échoue — sans \p refus, l'échec est un \c qCWarning. + void ecrire(Thing *t, const QString &etat, const QVariant &valeur, + const std::function &suite = {}, + const std::function &refus = {}); + + ThingManager *m_tm = nullptr; + QHash m_etats; //!< thingId → état de la commande. +}; diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index ab5cda3..0db272e 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -8,6 +8,7 @@ #include "adapters/sgreadyadapter.h" #include "adapters/etmvariableloadadapter.h" #include "adapters/relayrouter.h" +#include "adapters/storagedischargeguard.h" #include "config/loadconfigstore.h" #include "types/loadconfig.h" #include "scheduler/rulebasedscheduler.h" @@ -30,6 +31,7 @@ #include #include #include +#include #include namespace { @@ -58,6 +60,7 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm, , m_scheduler(new RuleBasedScheduler(this, this)) , m_strategy(m_scheduler) , m_em(em) + , m_storageGuard(new StorageDischargeGuard(tm, this)) , m_tm(tm) { // --- L2 : watchdog fraîcheur compteur (SAFETY.md §L2) --- @@ -430,6 +433,10 @@ void EnergyArbitrator::rebuildLoadAdapters() void EnergyArbitrator::update(const QDateTime ¤tDateTime) { qCDebug(dcNymeaEnergy()) << "Updating smart charging"; + // Un geste de protection fait PENDANT le cycle est publié par le cycle ; hors cycle + // (powerBalanceChanged), par executeChargingAction() lui-même. + m_dansCycle = true; + const auto finDuCycle = qScopeGuard([this]() { m_dansCycle = false; }); // Ordre IDENTIQUE à SmartChargingManager::update() — INTERDIT de réordonner. // SCM : 1.updateManual 2.prepareInfo 3.verifyOverload 4.verifyRecovery // 5.planSpot 6.planSurplus 7.adjustEv @@ -529,6 +536,15 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime) applyActionsToAdapters(slot, currentDateTime); // charges arbitrées (Setpoint W, State) ET bornes du waterfall adjustEvChargers(currentDateTime); // bornes RESTÉES au proxy : échéance, spot market + // Lot e1 — la batterie, À LA MÊME POSITION que les autres écritures du cycle. La décision est + // celle du rule-based, qui tourne aussi en ombre d'un optimiseur. Sans heures creuses le garde + // ne lit ni n'écrit rien, sauf pour lever une consigne qu'il tiendrait encore. + const StorageDecision batterie = m_scheduler->storageDecision(); + m_storageGuard->appliquer(ctx.offPeak.configured, batterie.evaluated && batterie.block, + currentDateTime); + if (batterie.evaluated) + qCInfo(dcNymeaEnergy()) << "[Arbitre] Batterie →" << renderFr(batterie.reason); + // Brief 4 T3 — sous un plan d'OPTIMISEUR seulement : la puissance publiée est celle que les // adaptateurs viennent d'APPLIQUER, le financement et le budget se recalculent sur elle (Q3, // Q4), et une action écrêtée est marquée. AVANT la fusion L4, qui remplace ensuite les @@ -547,6 +563,11 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime) // ce que la stratégie espérait. Publier avant produirait un instantané qu'aucun relais ne // confirme — la même famille de défaut qu'annoncer une puissance non appliquée (ECS-410). buildTelemetry(plan, slot, currentDateTime); + attacherStockage(m_telemetry, batterie); + // Lot délestage visible — APRÈS le dispatch et la fusion L4 : la tenue se juge sur ce qui a + // été exécuté. Publication seule, rien ici ne commande. + suivreDelestages(slot, currentDateTime); + attacherDelestage(m_telemetry); publishTelemetry(false); // Brief 4 T4 — le plan en vigueur (GetPlan) et la stratégie qui a commandé ce cycle (§7). @@ -797,6 +818,9 @@ void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now) { m_shedActions.clear(); const DrawMargin marge = houseDrawMargin(); + // Lot délestage visible — noté pour le journal seulement : un plafond encore épuisé TIENT les + // charges délestées, quel que soit le motif que la cascade leur donne ensuite. + m_plafondEpuiseAuCycle = marge.borne && marge.watts <= 0; if (!marge.borne || marge.watts >= 0) return; // aucun plafond en vigueur, ou aucun dépassement : rien à délester @@ -848,6 +872,11 @@ void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now) LoadAction shed; shed.loadId = id; + // Lot délestage visible — l'état AVANT le geste, lu comme l'adaptateur le publie. Lecture + // seule (runtimeView est const) : le journal n'entre dans aucune des décisions qui suivent. + LoadRuntimeView vueAvant = adaptateur->runtimeView(now); + vueAvant.mechanism.insert(QStringLiteral("kind"), vueAvant.mechanismKind); + // DÉLESTER N'EST PAS TOUJOURS RETIRER DES WATTS. Une PAC ne se module pas : on la fait // redescendre d'ÉTAT, et l'état sûr est 2 (normal), jamais 1 — bloquer une PAC n'est // pas la mettre en sécurité, c'est arrêter le chauffage sans raison visible (ECS-413). @@ -923,6 +952,22 @@ void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now) // REVENDICATION : cette charge est commandée par L4 ce cycle, le dispatch la sautera. m_shedActions.insert(id, publie); + // Le JOURNAL, après le geste : ce qui a été rendu, et le mécanisme tel qu'il est laissé. + // Aucun type de charge n'est nommé ici — l'adaptateur dit lui-même ce qu'il tient. + LoadRuntimeView vueApres = adaptateur->runtimeView(now); + vueApres.mechanism.insert(QStringLiteral("kind"), vueApres.mechanismKind); + SheddingAct geste; + geste.source = drawCapSourceCode(marge.source); + if (marge.phase >= 0 && marge.phase <= 2) + geste.phase = QString(QChar('A' + marge.phase)); + geste.overshootW = qMax(0.0, -marge.watts); + // Une coupure est une consigne en watts menée à zéro. Une PAC rendue à l'état 2 n'est + // pas coupée : elle chauffe sur son propre thermostat (ECS-413). + geste.cut = shed.kind == LoadAction::Setpoint && apresW <= 0; + geste.before = { engageW, vueAvant.mechanism }; + geste.after = { apresW, vueApres.mechanism }; + m_journalDelestage.tenir(id, now, geste); + qCInfo(dcNymeaEnergy()) << "[Arbitre] Délestage L4" << id << "→" << renderFr(publie.reason, id) << "| rendu:" << rendu << "W | reste à délester:" << resteW << "W"; @@ -950,6 +995,227 @@ void EnergyArbitrator::fusionnerDelestageDansSlot(Slot &slot) const } } +void EnergyArbitrator::suivreDelestages(const Slot &slot, const QDateTime &now) +{ + // Une charge de la cascade reste TENUE tant que le plafond la tient : il est encore épuisé + // au compteur, ou son motif est DRAW_CAP — délestée ce cycle (shed: true), ou la cascade + // refuse de la rallumer, l'autorisation étant prise par les charges servies avant elle + // (shed: false). C'est la suite que shedWaterfallForOverload() annonce lui-même : « le cycle + // suivant, la cascade ne les rallume pas non plus ». Le plafond épuisé compte à lui seul : + // une PAC rendue à l'état 2 publie ensuite son verrou, pas le plafond, et le dépassement dure. + QSet tenues; + for (const LoadAction &a : slot.actions) + if (a.reason.code == QLatin1String(DecisionCode::DrawCap)) + tenues.insert(a.loadId); + + for (const QString &id : m_journalDelestage.ouverts()) { + const bool borneVivante = internalEvChargers().contains(ThingId(id)); + if (borneVivante || m_bornesTenues.contains(id)) { + // Une borne : sa tenue suit ses consignes. Seule une borne disparue se relâche ici. + if (!borneVivante) { + m_bornesTenues.remove(id); + m_journalDelestage.relacher(id, now); + } + continue; + } + if (tenues.contains(id) || m_plafondEpuiseAuCycle) + m_journalDelestage.prolonger(id); + else + m_journalDelestage.relacher(id, now); + } + if (m_journalDelestage.finDeCycle()) + qCInfo(dcNymeaEnergy()).noquote() << "[Délestage] journal — épisode(s) clos ; en cours :" + << (m_journalDelestage.ouverts().isEmpty() + ? QStringLiteral("aucun") + : m_journalDelestage.ouverts().join(QStringLiteral(", "))); +} + +void EnergyArbitrator::attacherDelestage(QVariantMap &snap) const +{ + if (snap.isEmpty()) + return; // aucun cycle encore : rien à compléter, et rien à inventer + // ABSENT tant que rien n'a été délesté depuis le démarrage : l'absence dit « aucun + // délestage », un bloc à zéro le dirait mal — et la télémétrie d'une maison qui n'a jamais + // délesté reste exactement celle d'avant ce lot. + const QVariantMap resume = m_journalDelestage.resume(); + if (resume.isEmpty()) + snap.remove(QStringLiteral("shedding")); + else + snap.insert(QStringLiteral("shedding"), resume); + + if (m_bornesTenues.isEmpty()) + return; + QVariantList loads = snap.value(QStringLiteral("loads")).toList(); + for (QVariant &v : loads) { + QVariantMap entry = v.toMap(); + const auto t = m_bornesTenues.constFind(entry.value(QStringLiteral("loadId")).toString()); + if (t == m_bornesTenues.constEnd()) + continue; + // Préséance L4 : l'action entière est celle de la protection, sans niveaux (R7 — les + // deux absents ensemble), et l'allocation publiée est ce qu'elle laisse à la borne. + entry.insert(QStringLiteral("decision"), motifTenue(*t).toMap()); + entry.remove(QStringLiteral("levels")); + entry.insert(QStringLiteral("allocatedW"), t->cut ? 0.0 : t->toA * 230.0 * t->phases); + v = entry; + } + snap.insert(QStringLiteral("loads"), loads); +} + +DecisionReason EnergyArbitrator::motifTenue(const BorneTenue &t) const +{ + QVariantMap params{{QStringLiteral("fromA"), t.fromA}, + {QStringLiteral("toA"), t.toA}, + {QStringLiteral("cut"), t.cut}, + {QStringLiteral("overshootW"), qRound(t.overshootW)}, + {QStringLiteral("limitA"), phasePowerLimit()}}; + // Absente pour une borne triphasée : la protection la juge sur la phase la plus chargée, + // aucune phase n'est à elle seule coupable. Même règle que DRAW_CAP. + if (!t.phase.isEmpty()) + params.insert(QStringLiteral("phase"), t.phase); + return DecisionReason(DecisionCode::OverloadThrottled, params); +} + +void EnergyArbitrator::executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, + const QDateTime ¤tDateTime) +{ + // AVANT l'écriture : ce que la borne tenait, et ce que ses verrous laisseront passer. Les + // verrous sont lus ICI, avec le même instant que les setters amont : c'est ce qui permet de + // publier ce que la borne a RÉELLEMENT reçu, et non ce qui lui a été demandé. + const bool avantActive = evCharger->chargingEnabled(); + const uint avantA = evCharger->maxChargingCurrent(); + const bool courantPasse = chargingAction.force() || !evCharger->chargingCurrentLocked(currentDateTime); + const bool activationPasse = chargingAction.force() || !evCharger->chargingEnabledLocked(currentDateTime); + const uint demandeA = qBound(evCharger->maxChargingCurrentMinValue(), + static_cast(qMax(0, qRound(chargingAction.maxChargingCurrent()))), + evCharger->maxChargingCurrentMaxValue()); + + // La consigne part TELLE QUELLE, par le corps amont : la publication vient après, et ne + // touche ni à la consigne, ni à l'émetteur, ni aux verrous (règle 4). + SmartChargingManager::executeChargingAction(evCharger, chargingAction, currentDateTime); + + if (!noterGesteBorne(evCharger, chargingAction.issuer(), avantActive, avantA, + activationPasse ? chargingAction.chargingEnabled() : avantActive, + courantPasse ? demandeA : avantA, currentDateTime)) + return; + // HORS CYCLE — powerBalanceChanged : aucun cycle ne publiera ce geste avant une minute. + // L'instantané publié est complété et republié tel quel : son timestamp reste celui du + // dernier cycle, le geste est daté par le journal. + if (!m_dansCycle && !m_telemetry.isEmpty()) { + attacherDelestage(m_telemetry); + publishTelemetry(false); + } +} + +bool EnergyArbitrator::noterGesteBorne(EvCharger *evCharger, ChargingAction::ChargingActionIssuer emetteur, + bool avantActive, uint avantA, bool apresActive, uint apresA, + const QDateTime &now) +{ + const QString id = evCharger->id().toString(); + + // L'émetteur INCONNU n'est émis que par un chemin : la boucle de verifyOverloadProtection() + // sur une borne monophasée dont la phase ne dépasse pas, qui reçoit une consigne par défaut. + // Ce n'est pas un geste de la protection au sens de son émetteur : il n'ouvre ni ne ferme rien. + if (emetteur == ChargingAction::ChargingActionIssuerUnknown) + return false; + + const bool tenueAvant = m_bornesTenues.contains(id); + + if (emetteur != ChargingAction::ChargingActionIssuerOverloadProtection) { + // Le cycle reprend la main : la protection ne tient plus la borne. + if (!tenueAvant) + return false; + m_bornesTenues.remove(id); + m_journalDelestage.relacher(id, now); + qCInfo(dcNymeaEnergy()).noquote() << "[Délestage] borne" << evCharger->name() + << "— le cycle reprend la main, la protection ne la tient plus."; + return true; + } + + // Rien n'a passé les verrous de la borne : la protection a demandé, la borne n'a rien reçu. + // Un refus se dit au moins aussi fort qu'un geste (règle 7-d) — mais il ne s'enregistre pas + // comme un délestage, qui n'a pas eu lieu. + if (apresActive == avantActive && apresA == avantA) { + qCInfo(dcNymeaEnergy()).noquote() << "[Délestage] borne" << evCharger->name() + << "— geste de la protection REFUSÉ par les verrous de la " + "borne : elle garde" << (avantActive ? avantA : 0u) << "A."; + return false; + } + + const uint phases = qMax(1u, evCharger->phaseCount()); + const bool coupe = !apresActive; + const uint commandeA = apresA; + const uint versA = coupe ? 0u : commandeA; + + if (!coupe && avantActive && versA >= avantA) { + // verifyOverloadProtectionRecovery() rend le courant manuel : la tenue est levée. + if (!tenueAvant) + return false; + m_bornesTenues.remove(id); + m_journalDelestage.relacher(id, now); + qCInfo(dcNymeaEnergy()).noquote() << "[Délestage] borne" << evCharger->name() + << "— la protection lui rend" << versA << "A : tenue levée."; + return true; + } + if (!coupe && !avantActive && !tenueAvant) + return false; // une remise en route sans tenue en cours n'a rien à prolonger + + // Le dépassement que la protection a vu — relu sur le MÊME compteur, dans le même appel, et + // jugé comme elle : sur la phase de la borne si elle est monophasée, sur la plus chargée sinon. + QString phase; + double depassementW = 0; + if (RootMeter *compteur = internalRootMeter()) { + const double limiteW = 230.0 * phasePowerLimit(); + const QHash parPhase{{QStringLiteral("A"), compteur->currentPowerPhaseA()}, + {QStringLiteral("B"), compteur->currentPowerPhaseB()}, + {QStringLiteral("C"), compteur->currentPowerPhaseC()}}; + if (evCharger->phaseCount() == 1) { + phase = chargerPhaseFor(evCharger); + depassementW = qMax(0.0, parPhase.value(phase) - limiteW); + } else { + for (double w : parPhase) + depassementW = qMax(depassementW, w - limiteW); + } + } + + BorneTenue &t = m_bornesTenues[id]; + if (!tenueAvant) { + // Un épisode encore ouvert — le cycle a repris la borne, la protection la reprend avant + // qu'un cycle complet ne soit passé : c'est le MÊME épisode, et « avant » est le sien. + const SheddingEvent *ouvert = m_journalDelestage.enCours(id); + t.fromA = ouvert ? ouvert->before.mechanism.value(QStringLiteral("currentA")).toUInt() + : (avantActive ? avantA : 0); + t.phase = ouvert ? ouvert->phase : phase; + t.overshootW = ouvert ? ouvert->overshootW : 0; + } + t.toA = versA; + t.cut = coupe; + t.phases = phases; + if (depassementW > t.overshootW) { + t.overshootW = depassementW; + t.phase = phase; + } + + auto etat = [phases](bool active, uint ampere) { + return SheddingState{ active ? ampere * 230.0 * phases : 0.0, + QVariantMap{{QStringLiteral("kind"), QStringLiteral("evcharger")}, + {QStringLiteral("chargingEnabled"), active}, + {QStringLiteral("currentA"), ampere}, + {QStringLiteral("phaseCount"), phases}} }; + }; + SheddingAct geste; + geste.source = drawCapSourceCode(DrawCapSource::Breaker); // la limite par phase du branchement + geste.phase = phase; + geste.overshootW = depassementW; + geste.cut = coupe; + geste.before = etat(avantActive, avantA); + geste.after = etat(!coupe, commandeA); + m_journalDelestage.tenir(id, now, geste); + + qCInfo(dcNymeaEnergy()).noquote() << "[Délestage] borne" << id << "→" + << renderFr(motifTenue(t), evCharger->name()); + return true; // le motif publié suit chaque geste, même quand le journal ne bouge pas +} + bool EnergyArbitrator::evParticipatesInWaterfall(EvCharger *ev) const { if (!ev) @@ -1200,6 +1466,9 @@ double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const // qui ramène la borne à son minimum. Avec le retour, elle se verrait autoriser ce qu'elle // consomme déjà — c'est-à-dire précisément la puissance qui cause le dépassement. // (`testOverloadProtectionEcoMode` : 16 A ramenés à 6 A ; avec le retour, 9 A maintenus.) + // Ce test a un compteur qui ne voit pas la borne baisser : le retour y est un trou. En heures + // creuses, evOffPeakAllowanceW() rend le courant MESURÉ moins une marge, et sa propre preuve + // de baisse en dépassement suppose que le compteur voie la borne. // // L'écrêtage à zéro est fait ICI, chez le consommateur, et non dans la résolution : la // marge PORTE le dépassement (elle vaut -1200 quand on dépasse de 1200 W) parce que @@ -1208,6 +1477,59 @@ double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const return qMax(0.0, marge.watts) * phaseCount; } +namespace { +//! Marge du plafond d'heures creuses, par phase. Celle de l'amont pour l'échéance +//! (`adjustEvChargers()`), pour la même raison : ne pas coller à la limite, et laisser à un +//! appareil moyen allumé entre deux cycles la place de démarrer avant que L4 n'ait à couper. +constexpr double MargeHeuresCreusesA = 1.0; +} + +double EnergyArbitrator::evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const +{ + RootMeter *meter = internalRootMeter(); + if (!ev || phaseCount == 0 || !meter) + return -1; + + const QList plafonds = drawCaps(); + if (plafonds.isEmpty()) + return -1; + + // Ce que la borne tire, MESURÉ, et sur quelles phases. Pas la consigne : une voiture qui + // prend 8 A d'une borne commandée à 16 n'occupe que 8 A de la phase, et lui rendre 16 + // ouvrirait 8 A qu'elle peut prendre d'un coup. Une borne sans mesure ne rend rien. + const bool mesuree = !ev->thing()->thingClass().stateTypes().findByName("currentPower").id().isNull(); + const Electricity::Phases tirees = mesuree ? ev->meteredPhases() : Electricity::PhaseNone; + // Rendre sur une phase que la borne n'occupe pas créerait de la place qui n'existe pas — et + // la borne, en la prenant ailleurs, ne la refermerait jamais. Pendant une bascule (phases + // mesurées ≠ phases visées), on ne rend donc rien. + const bool attribuable = tirees != Electricity::PhaseNone + && Electricity::getPhaseCount(tirees) == phaseCount; + const Electricity::Phases masque = attribuable + ? tirees : const_cast(this)->phasesForCount(phaseCount); + const double tireW = attribuable ? qMax(0.0, ev->currentPower()) : 0.0; + + // Le tirage de la maison SANS la borne, phase par phase, écrêté à zéro : une borne qui + // annoncerait plus que ce que le compteur voit ne se fabrique pas de place au-delà de la + // limite. Au mieux, une phase vaut sa limite. + QList sansLaBorne = phaseDrawW(masque, phasePowerLimit()); + for (double &w : sansLaBorne) + w = qMax(0.0, w - tireW / phaseCount); + const DrawMargin marge = resolveDrawMargin(plafonds, sansLaBorne, + qMax(0.0, meter->currentPower() - tireW)); + if (!marge.borne) + return -1; + + // Moins la marge, à l'ampère INFÉRIEUR : l'adaptateur arrondit la consigne au plus proche, + // et un plafond de 13,6 A deviendrait 14 A — la marge serait mangée de moitié. + // + // En dépassement, ce plafond BAISSE, et c'est la raison de sécurité de evPhaseAllowanceW() + // qui tient toujours : si la phase tire I > limite, dont E pour la borne, la place rendue + // vaut limite − (I − E) − 1 < E − 1. La consigne descend d'au moins 1 A par cycle, jusqu'à + // sortir du dépassement ou jusqu'au plancher, où L4 décide. + const double parPhaseA = std::floor(marge.watts / 230.0 - MargeHeuresCreusesA); + return qMax(0.0, parPhaseA) * 230.0 * phaseCount; +} + bool EnergyArbitrator::evCommandedByWaterfall(EvCharger *evCharger) const { return evCharger && m_waterfallEvIds.contains(evCharger->thing()->id().toString()); @@ -1497,6 +1819,15 @@ void EnergyArbitrator::qualifierRefus(LoadContext &lc, const QDateTime &now) con lc.telemetry.commandRefused = ecoule > a->commandSettleS(montee); } +bool EnergyArbitrator::enHeuresCreuses(const QDateTime &now) const +{ + // Une seule lecture, partagée par le contexte et par la borne : deux copies divergeraient au + // premier changement de règle, et la borne viserait sa pleine puissance hors des heures que + // le scheduler sert. + return !m_tariff.zonesCreuses().isEmpty() + && m_tariff.enHeuresCreuses(now, QTimeZone::systemTimeZone()); +} + SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const { SurplusContext ctx; @@ -1520,6 +1851,28 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const } // SurplusPv : interface inverter — déféré (remplissage prévu en 3d) + // --- Heures creuses (lot e1) -------------------------------------------------------- + // La SEULE lecture du tarif par une décision. Le fuseau de la box est relu à chaque cycle : + // il peut changer en marche (le banc l'a fait), et une plage tarifaire est un RÉGLAGE mural. + // Sans heure creuse (aucun tarif, tarif unique, prix tous égaux), `configured` reste faux et + // rien de ce lot ne s'exécute. + if (!m_tariff.zonesCreuses().isEmpty()) { + const QTimeZone fuseau = QTimeZone::systemTimeZone(); + ctx.offPeak.configured = true; + ctx.offPeak.active = enHeuresCreuses(now); + ctx.offPeak.nextChange = m_tariff.prochaineBascule(now, fuseau); + if (ctx.offPeak.nextChange.isValid()) + ctx.offPeak.nextChangeWall = ctx.offPeak.nextChange.toTimeZone(fuseau).toString(QStringLiteral("HH:mm")); + + // Ce que la décision sur la batterie lit — et seulement quand elle peut se poser. La + // puissance des bornes est MESURÉE, toutes bornes confondues (mode manuel compris) : + // une voiture qui charge en heures creuses vide la batterie quel que soit son mode. + for (EvCharger *ev : internalEvChargers()) + if (ev->available()) + ctx.evMeasuredW += qMax(0.0, ev->currentPower()); + ctx.storageControl = m_storageGuard->capacites(); + } + // --- Stockage (3g-1) ------------------------------------------------------------------ // Rempli ICI et plus dans le chemin VE. La prise en compte de la batterie n'a jamais été // une règle « véhicule » : c'est une règle de BUDGET, et elle vivait dans @@ -1926,7 +2279,11 @@ void EnergyArbitrator::applyDegradedMode(const QDateTime &now) ev->setMaxChargingCurrent(ev->maxChargingCurrentMinValue(), now, true); } - // Batterie (aucune charge réseau) : repli ajouté avec son adaptateur (3f). + // Batterie : rendue à son AUTOMATISME, explicitement (lot e1). Sous compteur muet le moteur + // cesse de commander ; une limite de décharge tenue serait sinon rafraîchie par personne et + // ne tomberait qu'au minuteur de l'onduleur. Aucune charge réseau n'existe à replier : le + // moteur n'en commande jamais. + m_storageGuard->leverTout(now); // Reprise à froid (critère ECS-411) : un client qui se connecte APRÈS la bascule doit lire // degradedMode. update() rend la main avant toute publication tant que le repli tient ; sans @@ -2407,9 +2764,31 @@ void EnergyArbitrator::buildDegradedTelemetry(const QDateTime &now) // 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); + // La batterie, si la question se posait : rendue à son automatisme par le repli. + if (!m_tariff.zonesCreuses().isEmpty() && !m_storageGuard->capacites().isEmpty()) { + StorageDecision repli; + repli.evaluated = true; + repli.reason = { DecisionCode::StorageAutomatic, {{"cause", QStringLiteral("degraded")}} }; + attacherStockage(snap, repli); + } + // La protection de surcharge tourne aussi sous L2 : ce qu'elle tient se publie ici aussi. + attacherDelestage(snap); m_telemetry = snap; } +void EnergyArbitrator::attacherStockage(QVariantMap &snap, const StorageDecision &d) const +{ + if (!d.evaluated) + return; // pas d'heure creuse, ou pas de batterie : la question ne s'est pas posée + QVariantMap st; + // CE QUE LE MOTEUR TIENT après le dispatch, pas ce que le plan demandait : une batterie non + // pilotable sous une décision « bloquer » n'est pas bloquée, et le motif dit pourquoi. + st.insert(QStringLiteral("dischargeBlocked"), m_storageGuard->tientUneConsigne()); + st.insert(QStringLiteral("decision"), d.reason.toMap()); + st.insert(QStringLiteral("batteries"), m_storageGuard->vue()); + snap.insert(QStringLiteral("storage"), st); +} + QStringList EnergyArbitrator::forecastSiteCandidates() const { QStringList sites; @@ -3383,6 +3762,19 @@ bool EnergyArbitrator::telemetrySignificantlyDiffers(const QVariantMap &a, const // pris fin — ni pourquoi. if (b.contains(QStringLiteral("derogationEnded"))) return true; + // Lot délestage visible — l'OUVERTURE et la FERMETURE d'un épisode se notifient toujours, + // même quand rien d'autre n'a bougé : c'est l'alerte que l'app attend. + if (a.value(QStringLiteral("shedding")).toMap().value(QStringLiteral("revision")) + != b.value(QStringLiteral("shedding")).toMap().value(QStringLiteral("revision"))) + return true; + // Lot e1 — la batterie : apparition du bloc, consigne tenue ou levée, CODE du motif. + const QVariantMap sa = a.value(QStringLiteral("storage")).toMap(); + const QVariantMap sb = b.value(QStringLiteral("storage")).toMap(); + if (a.contains(QStringLiteral("storage")) != b.contains(QStringLiteral("storage")) + || sa.value(QStringLiteral("dischargeBlocked")) != sb.value(QStringLiteral("dischargeBlocked")) + || sa.value(QStringLiteral("decision")).toMap().value(QStringLiteral("code")) + != sb.value(QStringLiteral("decision")).toMap().value(QStringLiteral("code"))) + return true; // Même règle pour la CLÔTURE NON TENUE (correctif b) : un niveau ECO_FLOOR_MISSED ne vit // qu'un cycle. Le code de TÊTE ne suffit pas à le voir passer — sous LOAD_UNAVAILABLE il reste // en tête, et rien d'autre ne bouge forcément. diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 2eca7a6..0c25850 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -11,6 +11,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/sheddinglog.h" #include "types/derogation.h" #include "forecast/forecaststore.h" #include "forecast/forecastlearninglog.h" @@ -29,6 +30,7 @@ class EvAdapter; class SgReadyAdapter; class EtmVariableLoadAdapter; class RelayRouter; +class StorageDischargeGuard; class ILoadAdapter; class LoadConfigStore; class RuleBasedScheduler; @@ -122,10 +124,25 @@ public: /*! * \brief Le tarif saisi. \return Vide si aucun tarif n'est saisi. - * \note EXPOSÉ, jamais lu par une décision : \c SurplusContext::tariff n'est pas rempli (§11). + * \note Une seule décision le lit — les HEURES CREUSES (lot e1, \c SurplusContext::offPeak), + * et seulement par \c TariffConfig::zonesCreuses() : jamais le prix. \c SurplusContext::tariff + * n'est toujours pas rempli, l'optimiseur ne le reçoit pas par là. */ const TariffConfig &tariff() const { return m_tariff; } + /*! + * \brief L'instant \p now tombe-t-il en heures creuses du tarif saisi ? (lot e2) + * + * Même lecture que \c SurplusContext::offPeak (\c buildContext()) : zones au prix le plus + * bas, heure murale du fuseau de la box relu à l'appel. Elle existe pour la borne, qui doit + * le savoir AVANT l'allocation : en heures creuses elle vise sa pleine puissance, donc ses + * phases se choisissent sur son plafond et non sur le surplus (\c EvAdapter::toLoadContext()). + * + * \param now Temps de cycle (injectable). \return Faux sans heure creuse (aucun tarif, tarif + * unique, prix tous égaux) — rien du lot ne s'exécute alors. + */ + bool enHeuresCreuses(const QDateTime &now) const; + /*! * \brief Remplace le tarif saisi, à chaud, et le persiste. Un tarif vide l'efface. * \param t Tarif DÉJÀ validé (\c TariffConfig::fromVariant) — il n'en existe pas d'autre. @@ -506,6 +523,23 @@ public: */ QVariantMap loadTelemetry() const { return m_telemetry; } + /*! + * \brief Le journal court des délestages — charge utile de \c NymeaEnergy.GetSheddingLog. + * + * \return \c revision, \c capacity et \c events : les \c capacity derniers épisodes de + * protection, du plus ancien au plus récent. Un épisode en cours n'a pas d'\c endedAt. + * + * \par Ce qui y entre + * Les deux couches de protection, et elles seules : la protection de surcharge de l'amont + * sur les bornes (\c executeChargingAction()), et le délestage du waterfall + * (\c shedWaterfallForOverload()) pour TOUTES les autres charges, sans liste de types. + * + * \note En mémoire seulement : perdu au redémarrage de nymead. La télémétrie en porte le + * résumé (\c shedding : \c revision et \c open), absent tant que le journal est vide — + * c'est lui qui notifie l'ouverture et la fermeture d'un épisode. + */ + QVariantMap sheddingLog() const { return m_journalDelestage.toMap(); } + /*! * \brief Battement de cœur : republie l'instantané COURANT sans condition ni recalcul. * @@ -774,6 +808,41 @@ public: //! le plan ne l'a pas commandée, publier sa consigne montrerait ce qui n'a pas eu lieu. void fusionnerDelestageDansSlot(Slot &slot) const; + /*! + * \brief Fin de cycle du journal des délestages : prolonge, relâche, ferme. + * \param slot Actions du cycle, délestage L4 fusionné. Une charge de la cascade reste tenue + * tant que son motif est \c DRAW_CAP — délestée ce cycle, ou que la cascade refuse de la + * rallumer sous le même plafond. + * \param now Temps de cycle. + * \note Les bornes ne passent pas par ici : leur tenue suit les consignes elles-mêmes + * (\c executeChargingAction()). Une borne qui a disparu est relâchée. + */ + void suivreDelestages(const Slot &slot, const QDateTime &now); + + /*! + * \brief Pose dans \p snap ce que la protection tient : le résumé du journal, et le motif + * \c OVERLOAD_THROTTLED des bornes tenues. + * \param[in,out] snap Instantané de télémétrie. Rien n'est ajouté à un instantané vide. + * \note Idempotent : rejouée hors cycle sur l'instantané publié, elle ne double rien. Une + * borne tenue perd ses niveaux, comme une charge délestée par L4 — l'action entière est + * celle de la protection, et \c allocatedW dit ce qu'elle laisse à la borne. + */ + void attacherDelestage(QVariantMap &snap) const; + + /*! + * \brief Classe une consigne de borne qui vient de partir, et note ce qu'elle dit de la protection. + * \param evCharger Borne. \param emetteur Émetteur de la consigne. + * \param avantActive La borne était-elle enclenchée avant la consigne ? + * \param avantA Son courant avant la consigne. + * \param apresActive Enclenchement que la borne a REÇU — l'ancien si son verrou a refusé. + * \param apresA Courant que la borne a REÇU, borné à ses limites — l'ancien si son verrou a refusé. + * \param now Temps de cycle. + * \return Vrai si la tenue ou le journal ont changé. + */ + bool noterGesteBorne(EvCharger *evCharger, ChargingAction::ChargingActionIssuer emetteur, + bool avantActive, uint avantA, bool apresActive, uint apresA, + const QDateTime &now); + /*! * \brief Marge de soutirage de la MAISON, toutes phases confondues. * @@ -826,6 +895,42 @@ public: */ double evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const; + /*! + * \brief Lot e2, suite — plafond d'une borne À PLEINE PUISSANCE EN HEURES CREUSES : la place + * libre, plus ce que la borne tire, moins une marge de 1 A par phase. + * + * \param ev Borne. \param phaseCount Nombre de phases VISÉ. + * \return Plafond en watts, multiple de 230 W par phase, jamais négatif ; ou **−1 si aucune + * limite** (même convention que \c evPhaseAllowanceW()). + * + * \par Pourquoi + * \c evPhaseAllowanceW() ne rend pas à la borne ce qu'elle tire. À pleine puissance, et avec + * un compteur qui voit la borne, sa propre consommation lui retire donc la place au cycle + * suivant, puis la lui rend au cycle d'après : 16, 7, 16, 7 A sous une limite de 25 A. C'est + * la règle de l'échéance chez l'amont (\c adjustEvChargers() : marge − 1 A + courant de la + * borne) qui la fait se poser. + * + * \par Contrat + * - Le courant rendu est MESURÉ sur la borne (état \c currentPower de sa classe), jamais + * déduit de sa consigne. Il n'est rendu que sur les phases où la borne le mesure, et + * seulement si elles sont aussi nombreuses que les phases visées. Sinon — borne sans + * mesure, à l'arrêt, ou qui bascule — rien n'est rendu, et la marge s'applique quand même. + * - Une phase ne peut pas valoir plus que sa limite, moins la marge : ce que la maison tire + * sans la borne est écrêté à zéro. Une borne qui annoncerait plus que ce que le compteur + * voit ne se fabrique donc pas de place au-delà de la limite. + * - **En dépassement, le plafond baisse** : sur une phase au-dessus de la limite, il vaut au + * plus le courant mesuré de la borne moins 1 A. Il redescend donc à chaque cycle, jusqu'à + * sortir du dépassement ou jusqu'au plancher — où la stratégie tient la borne et où + * \c verifyOverloadProtection() (L4) décide. Cela suppose que le compteur voie la borne, + * comme L4 le suppose déjà. + * - Arrondi à l'ampère INFÉRIEUR : la consigne, arrondie au plus proche par l'adaptateur, ne + * mange jamais la marge. + * + * \note Réservée aux heures creuses et à la pleine puissance du lot e2. Hors de là, + * \c evPhaseAllowanceW() reste la règle, et les décisions sans tarif ne changent pas. + */ + double evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const; + /*! * \brief [3g-2] Rang configuré d'une borne dans le waterfall. * @@ -885,6 +990,25 @@ protected: */ void update(const QDateTime ¤tDateTime) override; + /*! + * \brief Exécute une consigne de borne par le corps amont, puis PUBLIE un geste de protection. + * + * \param evCharger Borne commandée. \param chargingAction Consigne, transmise TELLE QUELLE. + * \param currentDateTime Temps de cycle. + * + * \par Ce qui est publié + * Une consigne émise par la protection de surcharge (\c ChargingActionIssuerOverloadProtection) + * qui baisse ou coupe la borne ouvre ou prolonge son épisode au journal, et la borne publie + * \c OVERLOAD_THROTTLED. La remise au minimum après coupure prolonge la tenue. La reprise au + * courant manuel, ou toute consigne d'un autre émetteur, la lève. + * + * \par Ce qui ne change pas + * La consigne part d'abord, inchangée ; la lecture vient après. Hors d'un cycle — la + * protection tourne aussi sur \c powerBalanceChanged — la télémétrie est republiée sur-le-champ. + */ + void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, + const QDateTime ¤tDateTime) override; + private: /*! * \brief Construit le SurplusContext §5 : meter brut + loads EV + SG-Ready. @@ -1029,6 +1153,14 @@ private: */ void buildDegradedTelemetry(const QDateTime &now); + /*! + * \brief Ajoute à la trame l'état de la DÉCHARGE de la batterie (lot e1), si la question se pose. + * \param[in,out] snap Trame en construction. \param d Décision du cycle. + * \note Rien n'est ajouté quand \c d.evaluated est faux : sans heure creuse ou sans batterie, + * la trame est celle d'avant le lot, au bit près. + */ + void attacherStockage(QVariantMap &snap, const StorageDecision &d) const; + /*! * \brief Ajoute à une entrée de télémétrie les MESURES de la charge, si elle en déclare. * @@ -1234,6 +1366,8 @@ private: QString m_dossierTrace; //! Gestionnaire d'énergie (production PV instantanée du fil optimiseur). Non possédé. EnergyManager *m_em = nullptr; + //! Exécute la décision sur la décharge de la batterie (lot e1). Possédé. + StorageDischargeGuard *m_storageGuard = nullptr; QHash m_adapters; //!< loadId (ThingId string) → EvAdapter*. /*! * \brief [3g-2] Bornes commandées par le waterfall AU CYCLE COURANT. @@ -1296,6 +1430,26 @@ private: //! loadId → action de DÉLESTAGE L4 du cycle. Revendication : le dispatch saute ces charges, //! un seul commandeur par organe (règle absolue 1). Vidée à chaque évaluation de L4. QHash m_shedActions; + //! Journal court des délestages (lot délestage visible). AUCUNE DÉCISION N'EN DÉPEND. + SheddingLog m_journalDelestage; + //! Ce que la protection de surcharge tient d'une borne : la base du motif OVERLOAD_THROTTLED. + struct BorneTenue { + uint fromA = 0; //!< Courant avant le premier geste. + uint toA = 0; //!< Courant tenu ; 0 si coupée. + uint phases = 1; //!< Phases de la borne, pour la puissance laissée. + bool cut = false; //!< Coupée par le dernier geste. + QString phase; //!< Phase jugée ; vide pour une borne triphasée. + double overshootW = 0; //!< Plus fort dépassement de la tenue. + }; + //! loadId de borne → tenue en cours. Vide dans le cas normal. + QHash m_bornesTenues; + //! Le motif OVERLOAD_THROTTLED d'une tenue — construit ICI, en un seul endroit. + DecisionReason motifTenue(const BorneTenue &t) const; + //! Vrai pendant update() : un geste de protection y est publié par le cycle lui-même. + bool m_dansCycle = false; + //! Le plafond de soutirage était-il épuisé au compteur, à l'évaluation L4 du cycle ? Lu par + //! le seul journal des délestages : une charge délestée reste tenue tant qu'il l'est. + bool m_plafondEpuiseAuCycle = false; //! loadId → action APPLIQUÉE AU CYCLE : ce que l'adaptateur a rendu au dispatch //! (\c applyActionsToAdapters()), après écrêtage et verrous — pas ce que le plan demandait. //! Vidée à chaque dispatch ; les délestages L4 et le repli L2 n'y entrent pas (ils ont leur diff --git a/energyplugin/etm/etm.pri b/energyplugin/etm/etm.pri index 845f353..5f086b6 100644 --- a/energyplugin/etm/etm.pri +++ b/energyplugin/etm/etm.pri @@ -5,6 +5,7 @@ HEADERS += \ $$PWD/types/surpluscontext.h \ $$PWD/types/plan.h \ $$PWD/types/drawcap.h \ + $$PWD/types/sheddinglog.h \ $$PWD/types/ecoprogress.h \ $$PWD/types/ecotarget.h \ $$PWD/types/loadconfig.h \ @@ -16,6 +17,7 @@ HEADERS += \ $$PWD/adapters/sgreadyadapter.h \ $$PWD/adapters/etmvariableloadadapter.h \ $$PWD/adapters/relayrouter.h \ + $$PWD/adapters/storagedischargeguard.h \ $$PWD/scheduler/rulebasedscheduler.h \ $$PWD/scheduler/optimizerprotocol.h \ $$PWD/scheduler/optimizerclient.h \ @@ -32,6 +34,7 @@ HEADERS += \ SOURCES += \ $$PWD/types/decisionreason.cpp \ $$PWD/types/drawcap.cpp \ + $$PWD/types/sheddinglog.cpp \ $$PWD/types/ecotarget.cpp \ $$PWD/types/loadconfig.cpp \ $$PWD/tariff/tariffconfig.cpp \ @@ -41,6 +44,7 @@ SOURCES += \ $$PWD/adapters/sgreadyadapter.cpp \ $$PWD/adapters/etmvariableloadadapter.cpp \ $$PWD/adapters/relayrouter.cpp \ + $$PWD/adapters/storagedischargeguard.cpp \ $$PWD/scheduler/rulebasedscheduler.cpp \ $$PWD/scheduler/optimizerprotocol.cpp \ $$PWD/scheduler/optimizerclient.cpp \ diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index cede9e9..bf1ca82 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -246,6 +246,31 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) continue; nonEvLoads.append(lc); } + + // ---- Heures creuses (lot e1) --------------------------------------------------------- + // Le minimum RÉSEAU d'une charge qui le déclare reportable (\c offPeakShift) se sert en + // heures creuses, et seulement là. Hors heures creuses il est RETIRÉ — surplus seulement — et + // la charge est notée « en attente » pour que son motif le dise. Sans heures creuses dans le + // tarif saisi, rien de ceci ne s'exécute : c'est la garantie « sans tarif = etm74 ». + m_attendCreuses.clear(); + m_creusesAt.clear(); + m_creusesStartsAt.clear(); + if (ctx.offPeak.configured) { + if (!ctx.offPeak.active) { + m_creusesAt = ctx.offPeak.nextChangeWall; + m_creusesStartsAt = ctx.offPeak.nextChange.toUTC().toString(Qt::ISODate); + } + for (LoadContext &lc : nonEvLoads) { + if (!lc.needs.offPeakShift) + continue; + if (ctx.offPeak.active) { + lc.needs.minPowerWAlways = qMax(lc.needs.minPowerWAlways, lc.needs.offPeakMinPowerW); + } else { + lc.needs.minPowerWAlways = 0; + m_attendCreuses.insert(lc.id); + } + } + } // 3g-2 — tri TOTAL et STABLE. Le rang seul ne suffit pas : deux charges de même rang // laissaient l'ordre à std::sort, qui n'est pas stable, sur une liste dont l'origine est // un QHash — donc sans ordre garanti d'un cycle à l'autre. Au banc, deux bornes à @@ -276,6 +301,9 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) // donc après un éco de rang 3 — c'est le sens même des deux passes, et le publier sous forme // de niveaux est ce qui rend cet ordre lisible sans qu'un client ait à le redériver. QHash niveauxEco; + //! Lot e2, suite (écart 4) — les bornes servies à leur plafond de cycle en heures creuses. En + //! passe confort, leur surplus PREND la place de l'achat au lieu de s'y ajouter. + QSet plafondPartage; // --- TÊTE DE PASSE ÉCO — les DÉROGATIONS -------------------------------------------------- // @@ -432,8 +460,24 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) // un droit de faire disjoncter : si l'autorisation est épuisée, on n'achète pas, et // c'est le délestage qui rattrapera ce qui a déjà été engagé. double achatW = cible.watts; - const bool bridePlafond = margeRestante.borne && achatW > qMax(0.0, margeRestante.watts); - if (margeRestante.borne) + + // Lot e2 — LA BORNE À PLEINE PUISSANCE EN HEURES CREUSES. Son minimum y est son plafond de + // cycle, et ce plafond porte DÉJÀ l'autorisation de soutirage : `evPhaseAllowanceW()` la + // résout sur les phases qu'elle occupe et la multiplie par leur nombre. \rule{LM-1500-h} + // s'applique donc ici comme en tête de passe et en passe confort : lui opposer en plus + // `margeRestante`, la marge d'UNE phase, diviserait une borne triphasée par trois — 7 A + // par phase au lieu de 16 quand la maison tire 4 A sur une limite de 25 A, la nuit même + // où on veut qu'elle finisse tôt. + // + // Restreint à ce cas, et c'est délibéré : sans heures creuses, le plancher éco d'une borne + // reste écrêté comme avant (engagement « sans tarif, rien ne change »). Cet écart-là reste + // à trancher (DESIGN_HC_BATTERIE §8.1) ; il n'est pas corrigé en passant. + const bool pleinePuissanceHc = ctx.offPeak.active && lc.needs.offPeakShift + && lc.adapter == QStringLiteral("evcharger") + && cible.borneParLeMinimum; + const bool bridePlafond = !pleinePuissanceHc && margeRestante.borne + && achatW > qMax(0.0, margeRestante.watts); + if (!pleinePuissanceHc && margeRestante.borne) achatW = qBound(0.0, achatW, qMax(0.0, margeRestante.watts)); // La CAUSE se note à chaque cycle, servie ou non. À la clôture, c'est la dernière qui @@ -475,6 +519,31 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) } eco.reason = { DecisionCode::EcoFloorGrid, motif }; + // Lot e2 — le motif dit « pleine puissance », la puissance RETENUE, et ce qui la bride. + // ECO_FLOOR_GRID parlerait d'une obligation et d'un reste à livrer qu'une borne n'a pas ; + // et une borne servie sous sa pleine puissance se lirait comme une borne qui ne suit pas. + if (pleinePuissanceHc) { + // La pleine puissance du MATÉRIEL, aux phases visées : ce que la borne prendrait sans + // limite de phase. L'écart au plafond du cycle est ce que la protection a retiré. + const double materielW = lc.declared.maxA * 230.0 * qMax(1, lc.declared.phases); + QVariantMap pp{{"gridW", qRound(achatW)}, + {"maxW", qRound(materielW)}, + {"phases", qMax(1, lc.declared.phases)}, + {"until", ctx.offPeak.nextChangeWall}, + {"endsAt", ctx.offPeak.nextChange.toUTC().toString(Qt::ISODate)}}; + if (achatW < materielW - 0.5) { + // La limite de phase rabaisse la borne. Jusqu'au plancher, c'est la stratégie qui + // la tient (`phaseLimit`) ; au plancher, la stratégie ne descend plus — couper est + // le geste de L4, et le motif le dit (`floor`). + const bool auPlancher = lc.declared.minPowerW > 0 + && achatW <= lc.declared.minPowerW + 0.5; + pp.insert(QStringLiteral("boundBy"), auPlancher ? QStringLiteral("floor") + : QStringLiteral("phaseLimit")); + } + eco.reason = { DecisionCode::EvOffPeakFullPower, pp }; + plafondPartage.insert(lc.id); + } + // SOUS LE PREMIER PALIER, rien n'est acheté. Une charge à paliers ne délivre que ses // paliers, et son mécanisme arrondit vers le bas : 83 W devant un premier palier de // 500 W laissent le relais ouvert, et « 83 W achetés » se lisait sur un ballon froid. @@ -533,11 +602,22 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) waterfallEvIds.insert(lc.id); continue; } + // Lot e2, suite (écart 4, décision du 2026-10-25) — LA SOMME ÉCO + CONFORT EST BORNÉE AU + // PLAFOND DE CYCLE. Le niveau éco vaut déjà ce plafond, qui porte la limite de phase ; à + // l'aube, le surplus s'y ajoutait, et seul le courant maximal de la borne écrêtait la + // somme : 13 A de plafond plus 2 540 W de surplus faisaient 16 A sous une limite de 16 A. + // Le confort est donc servi DANS le plafond, et l'achat cède ce que le surplus finance + // (plus bas, à la fusion). Le plancher ne s'applique qu'à la somme, qui vaut le plafond, + // donc jamais moins que lui : la part confort n'en a pas — sans quoi un surplus sous le + // plancher serait refusé, ou démarré par EV_GRID_START, un second achat en plus du premier. + LoadContext lcConfort = lc; + if (plafondPartage.contains(lc.id)) + lcConfort.declared.minPowerW = 0; if (lc.adapter == QStringLiteral("sg-ready")) slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta)); else slot.actions.append(emitSetpointAction( - decideSetpoint(lc, remainingSurplusW, margeRestante, compta, draw))); + decideSetpoint(lcConfort, remainingSurplusW, margeRestante, compta, draw))); // etmvariableload + relay-router + evcharger // Le décrément vaut pour TOUTES les charges servies, bornes et PAC comprises : elles @@ -549,8 +629,19 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) // court-cycling fabriqué par l'arbitre lui-même. if (!slot.actions.isEmpty() && niveauxEco.contains(lc.id)) { LoadAction &la = slot.actions.last(); - const LoadAction::Level eco = niveauxEco.take(lc.id); + LoadAction::Level eco = niveauxEco.take(lc.id); const double confortW = la.estimatedPowerW; // la part confort, avant l'addition + if (plafondPartage.contains(lc.id)) { + // L'ACHAT CÈDE CE QUE LE SURPLUS FINANCE : la somme reste le plafond, et l'achat + // publié (`gridW`, `drawCommittedW`, `draw.committedW`) est ce qui reste à acheter + // — jamais plus que le compteur n'importe. La marge de soutirage n'est pas rendue : + // la ligne qui suit la fusion décompte la consigne entière, prudence inchangée. + const double financeW = qBound(0.0, confortW, eco.targetW); + eco.targetW -= financeW; + eco.counts[CountKey::DrawCommitted] = eco.targetW; + eco.reason.params.insert(QStringLiteral("gridW"), qRound(eco.targetW)); + draw.committedW -= financeW; + } la.levels.prepend(eco); // éco AVANT confort : l'ordre des passes la.powerW += eco.targetW; la.estimatedPowerW += eco.targetW; @@ -577,6 +668,39 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) // Grid funding (ECS/PAC) : dormant jusqu'à 3f (waterfall réseau) — non implémenté ici. + // ---- La DÉCHARGE de la batterie en heures creuses (lot e1) ----------------------------- + // En heures creuses, pendant qu'une voiture tire RÉELLEMENT de la puissance, la batterie ne + // se décharge pas dans la voiture. Rien d'autre : sa charge, et sa charge sur le réseau, ne + // sont jamais touchées. La décision ne lit ni le budget ni la réserve, et elle n'y verse + // rien : bloquer la décharge augmente le SOUTIRAGE, mesuré au cycle suivant par le compteur, + // et ne fabrique aucun surplus (invariant 8). Hors heures creuses, sans voiture, la batterie + // est rendue à son automatisme — et le garde lève ce qu'il tenait. + m_storage = StorageDecision(); + if (ctx.offPeak.configured && ctx.battery.present) { + m_storage.evaluated = true; + QString cause; + bool pilotable = false; + for (const SurplusContext::StorageControl &c : ctx.storageControl) { + if (c.controllable) + pilotable = true; + else if (cause.isEmpty()) + cause = c.cause; + } + if (!ctx.offPeak.active) { + m_storage.reason = { DecisionCode::StorageAutomatic, {{"cause", QStringLiteral("peak")}} }; + } else if (ctx.evMeasuredW < EvChargingThresholdW) { + m_storage.reason = { DecisionCode::StorageAutomatic, {{"cause", QStringLiteral("evNotCharging")}} }; + } else if (!pilotable) { + m_storage.reason = { DecisionCode::StorageNotProtected, {{"cause", cause}} }; + } else { + m_storage.block = true; + m_storage.reason = { DecisionCode::StorageDischargeBlocked, + {{"evPowerW", qRound(ctx.evMeasuredW)}, + {"until", ctx.offPeak.nextChangeWall}, + {"endsAt", ctx.offPeak.nextChange.toUTC().toString(Qt::ISODate)}} }; + } + } + Plan plan; plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces); plan.strategy = QStringLiteral("rules"); @@ -643,6 +767,14 @@ DecisionReason RuleBasedScheduler::choisirMotif(const MotifEntrees &e) const return { DecisionCode::SurplusSetpoint, {{"budgetW", qRound(e.budgetW)}, {"setpointW", qRound(e.setpointW)}, {"stepped", !e.dynamic}} }; + else if (e.attendCreuses && !e.capEpuise && !(e.refuse && e.retenuW > 0)) + // Lot e1 — la consigne est nulle parce que le minimum réseau est REPORTÉ aux heures + // creuses, pas parce que le surplus manque : sans le tarif, le minimum aurait été servi. + // Le plafond de soutirage et le refus gardent la tête : ils diraient la même chose en + // heures creuses, et ce sont eux qu'il faudrait lire. + return { DecisionCode::EvWaitingOffPeak, + {{"at", m_creusesAt}, {"startsAt", m_creusesStartsAt}, + {"budgetW", qRound(e.budgetW)}} }; else if (m_reserveActive && (m_reserveWithheldW + e.consoW) > 0) // 3g-1 — il Y A du surplus, et une règle l'interdit. À ne pas confondre avec // « il n'y en a pas » : le geste attendu n'est pas le même, et le second envoie @@ -797,7 +929,10 @@ RuleBasedScheduler::SetpointTarget RuleBasedScheduler::decideSetpoint(const Load // suivante trouve un budget nul et repart sur son motif ordinaire ; // · il reste sous le plafond de la protection de surcharge, contrôlé plus haut. const double tolerance = m_arbitrator->acquisitionTolerance(); - if (estUneBorne && tolerance > 0 && budgetW > 0 && budgetW >= plancherW * tolerance) { + // Lot e1 — hors heures creuses, une borne qui attend les heures creuses n'est servie + // qu'au SURPLUS : démarrer en soutirant serait le minimum réseau par une autre porte. + if (estUneBorne && tolerance > 0 && budgetW > 0 && budgetW >= plancherW * tolerance + && !m_attendCreuses.contains(lc.id)) { const double soutireW = plancherW - budgetW; SetpointTarget t; @@ -987,6 +1122,7 @@ RuleBasedScheduler::SetpointTarget RuleBasedScheduler::decideSetpoint(const Load t.entrees.refuse = refuse; t.entrees.refuseDepuisS = lc.telemetry.refusedSinceS; t.entrees.retenuW = refuse ? lc.telemetry.currentPowerW : 0.0; + t.entrees.attendCreuses = m_attendCreuses.contains(lc.id); // Résidu : budget − consigne engagée → charge suivante de la priorité (même cycle). remainingSurplusW = budgetW - setpointW; diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.h b/energyplugin/etm/scheduler/rulebasedscheduler.h index a42c8df..fd8858b 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.h +++ b/energyplugin/etm/scheduler/rulebasedscheduler.h @@ -3,6 +3,7 @@ #pragma once #include +#include #include "ischeduler.h" class EvCharger; @@ -61,6 +62,18 @@ public: */ Plan getPlan(const SurplusContext &ctx) override; + /*! + * \brief La décision du dernier \c getPlan() sur la décharge de la batterie (lot e1). + * \return \c evaluated faux sans heures creuses ou sans batterie. + * \note Un état du rule-based, comme la réserve : il est calculé à CHAQUE cycle, y compris en + * ombre d'un optimiseur — c'est donc lui qui commande la batterie dans les deux stratégies. + */ + StorageDecision storageDecision() const { return m_storage; } + + //! Puissance mesurée des bornes (W) au-delà de laquelle « une voiture charge » : un ampère + //! sur une phase. Sous le plancher de 6 A d'une borne, au-dessus du bruit d'un compteur. + static constexpr double EvChargingThresholdW = 230.0; + private: /*! * \brief Construit un LoadAction pour le cas "délai requis" (TimeRequirement). @@ -119,6 +132,8 @@ private: bool refuse = false; int refuseDepuisS = 0; //!< Depuis combien de secondes l'écart dure. double retenuW = 0; //!< Watts NON rendus au budget du fait du refus. + //! Lot e1 — le minimum réseau de la charge est REPORTÉ aux heures creuses ce cycle. + bool attendCreuses = false; }; /*! @@ -216,4 +231,13 @@ private: //! existe pour supprimer, retournée. Constaté à la PREMIÈRE observation en vrai, le //! 2026-08-26 — surplus à −3 555 W, motif publié, \c withheldW à 0. double m_reserveWithheldW = 0; + + // --- Heures creuses : retenu d'un cycle pour le MOTIF (lot e1) ----------------------- + //! Charges dont le minimum réseau est reporté aux heures creuses CE cycle (hors heures creuses). + QSet m_attendCreuses; + QString m_creusesAt; //!< Début des heures creuses, heure murale \c "HH:MM". + QString m_creusesStartsAt; //!< Le même début, instant ISO UTC. + + //! Décision du dernier cycle sur la décharge de la batterie (lot e1). + StorageDecision m_storage; }; diff --git a/energyplugin/etm/tariff/tariffconfig.cpp b/energyplugin/etm/tariff/tariffconfig.cpp index 5c2d658..71bd393 100644 --- a/energyplugin/etm/tariff/tariffconfig.cpp +++ b/energyplugin/etm/tariff/tariffconfig.cpp @@ -205,6 +205,48 @@ int TariffConfig::zoneAt(const QDateTime &instant, const QTimeZone &fuseau) cons return m_parMinute.at(murale.hour() * 60 + murale.minute()); } +QList TariffConfig::zonesCreuses() const +{ + QList creuses; + if (m_zones.count() < 2) + return creuses; // aucun tarif, ou tarif unique + double bas = m_zones.first().priceCtkWh; + double haut = bas; + for (const TariffZone &z : m_zones) { + bas = qMin(bas, z.priceCtkWh); + haut = qMax(haut, z.priceCtkWh); + } + if (!(bas < haut)) + return creuses; // tous au même prix : aucune heure n'est meilleure + for (int i = 0; i < m_zones.count(); ++i) + if (m_zones.at(i).priceCtkWh == bas) + creuses << i; + return creuses; +} + +bool TariffConfig::enHeuresCreuses(const QDateTime &instant, const QTimeZone &fuseau) const +{ + const QList creuses = zonesCreuses(); + return !creuses.isEmpty() && creuses.contains(zoneAt(instant, fuseau)); +} + +QDateTime TariffConfig::prochaineBascule(const QDateTime &instant, const QTimeZone &fuseau) const +{ + const QList creuses = zonesCreuses(); + if (creuses.isEmpty() || !instant.isValid()) + return {}; + const bool depart = creuses.contains(zoneAt(instant, fuseau)); + // Minute ABSOLUE suivante, puis pas d'une minute : chaque minute prend la zone de son heure + // murale (même parcours qu'importSeries()). + const qint64 debut = (instant.toSecsSinceEpoch() / 60 + 1) * 60; + for (qint64 a = debut; a <= debut + 50 * 3600; a += 60) { + const QDateTime t = QDateTime::fromSecsSinceEpoch(a, QTimeZone::utc()); + if (creuses.contains(zoneAt(t, fuseau)) != depart) + return t; + } + return {}; +} + QList TariffConfig::importSeries(const QDateTime &start, int stepS, int steps, const QTimeZone &fuseau) const { diff --git a/energyplugin/etm/tariff/tariffconfig.h b/energyplugin/etm/tariff/tariffconfig.h index 5730ada..1f0275a 100644 --- a/energyplugin/etm/tariff/tariffconfig.h +++ b/energyplugin/etm/tariff/tariffconfig.h @@ -21,10 +21,12 @@ * instants absolus. Le même réglage donne donc 23 ou 25 créneaux horaires selon le jour — * c'est voulu (AGENTS.md, « un RÉGLAGE … un INSTANT »). * - * \par Aucune décision ne le lit (§11) + * \par Une seule décision le lit : les HEURES CREUSES (lot e1, 2026-10-24) * Ce type est EXPOSÉ (\c GetTariff, \c GetForecastSnapshot), jamais versé dans - * \c SurplusContext::tariff. Son usage par une décision (rattrapage éco en HC, e1) est un lot - * séparé ; \c zoneAt() existe pour lui, et rien ne l'appelle aujourd'hui depuis le plan. + * \c SurplusContext::tariff — l'optimiseur ne le reçoit pas par là. Le moteur n'en tire + * qu'une chose : quelles minutes sont « creuses » (\c zonesCreuses(), \c enHeuresCreuses()), + * et rien d'autre ne lit le prix. Sans tarif ou avec un tarif unique, il n'y a pas d'heure + * creuse, et le moteur se comporte exactement comme avant ce lot. */ //! \brief Une plage horaire locale `[from, to)`, en minutes depuis minuit ; `from > to` passe minuit. @@ -106,11 +108,47 @@ public: * \param instant Instant absolu. * \param fuseau Fuseau de la box. * \return Index dans \c zones() ; -1 si aucun tarif. - * \note Réservé au lot qui branchera une décision dessus (e1) — aucun appelant aujourd'hui - * hors des tests et de \c importSeries(). */ int zoneAt(const QDateTime &instant, const QTimeZone &fuseau) const; + /*! + * \brief Les zones « creuses » : celles au prix LE PLUS BAS d'un tarif à zones horaires. + * \return Index dans \c zones(), croissants ; VIDE sans tarif, avec un tarif unique, ou quand + * toutes les zones ont le même prix. + * + * \par Pourquoi le prix, et pas le libellé + * « HC » n'a pas de code : le libellé est une donnée de l'installateur, qui peut écrire + * « Creuses », « Nuit » ou « Off-peak ». Le prix, lui, se compare. Plusieurs zones au même + * prix minimal sont TOUTES creuses — deux fenêtres saisies comme deux zones (nuit et début + * d'après-midi) ne changent pas de nature parce qu'on les a nommées séparément. + * \par Pourquoi « des prix distincts » est exigé + * Un tarif dont toutes les zones coûtent le même prix n'a pas d'heure meilleure qu'une + * autre : le dire « creux » partout reviendrait à servir le minimum réseau à toute heure, + * c'est-à-dire à changer le comportement d'un tarif qui, de fait, est unique. + */ + QList zonesCreuses() const; + + /*! + * \brief L'instant tombe-t-il en heures creuses ? + * \param instant Instant absolu. \param fuseau Fuseau de la box. + * \return Vrai si la zone en vigueur est dans \c zonesCreuses() ; faux sans heure creuse. + */ + bool enHeuresCreuses(const QDateTime &instant, const QTimeZone &fuseau) const; + + /*! + * \brief La prochaine BASCULE creuse ↔ pleine après \p instant. + * \param instant Instant absolu. \param fuseau Fuseau de la box. + * \return Premier instant (UTC, à la minute) où \c enHeuresCreuses() change de valeur ; + * invalide sans heure creuse. + * \par Calculée minute ABSOLUE par minute + * Comme \c importSeries() : chaque minute prend la zone de son heure murale. Un jour de + * 23 h saute l'heure qui n'existe pas, un jour de 25 h parcourt deux fois l'heure répétée — + * jamais de soustraction de dates, qui rendrait la durée d'horloge murale (AGENTS.md, + * « l'arithmétique de dates »). Recherche bornée à 50 h : toute zone saisie revient chaque + * jour, donc une bascule existe toujours dans cette fenêtre quand il y a des heures creuses. + */ + QDateTime prochaineBascule(const QDateTime &instant, const QTimeZone &fuseau) const; + /*! * \brief Le prix d'import sur une grille d'instants absolus. * \param start Début du premier créneau. diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index 01409c2..7bbd312 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -123,6 +123,20 @@ QString renderFr(const DecisionReason &reason, const QString &label) "%3 n'est pas servie") .arg(ou).arg(origine).arg(label); } + // La protection de surcharge de l'amont tient la borne. La phrase dit ce que la borne + // reçoit, puis pourquoi : le branchement, la phase, sa limite. + if (c == QLatin1String(DecisionCode::OverloadThrottled)) { + const QString pourquoi = p.contains(QStringLiteral("phase")) + ? QStringLiteral("le branchement atteignait sa limite (phase %1, %2 A)") + .arg(p.value(QStringLiteral("phase")).toString()).arg(w("limitA")) + : QStringLiteral("le branchement atteignait sa limite (%1 A par phase)").arg(w("limitA")); + const QString qui = label.isEmpty() ? QStringLiteral("Borne") : label; + if (p.value(QStringLiteral("cut")).toBool()) + return QStringLiteral("%1 coupée : %2, dépassement de %3 W") + .arg(qui, pourquoi).arg(w("overshootW")); + return QStringLiteral("%1 abaissée à %2 A : %3, dépassement de %4 W") + .arg(qui).arg(w("toA")).arg(pourquoi).arg(w("overshootW")); + } // La TRAJECTOIRE, pas l'instant. Les watts seuls laisseraient croire à une dépense stable // là où la cadence va se durcir d'un coup ; le reste et l'échéance disent où l'on va. auto delaiEco = [&p]() { @@ -328,6 +342,55 @@ QString renderFr(const DecisionReason &reason, const QString &label) return QStringLiteral("Prix favorable (%1 ct/kWh) — %2 servie sur ce créneau").arg(prix, label); } + // --- Heures creuses et stockage (lot e1) -------------------------------- + if (c == QLatin1String(DecisionCode::EvWaitingOffPeak)) + return QStringLiteral("%1 attend les heures creuses (%2) — hors heures creuses, surplus " + "seulement (%3 W)") + .arg(label, p.value(QStringLiteral("at")).toString()).arg(w("budgetW")); + // Lot e2 — la puissance RETENUE d'abord ; et si elle n'est pas la pleine puissance, QUI l'a + // retirée. Sans la seconde phrase, une borne bridée se lirait comme une borne qui ne suit pas. + if (c == QLatin1String(DecisionCode::EvOffPeakFullPower)) { + const QString jusqua = p.value(QStringLiteral("until")).toString(); + const QString bride = p.value(QStringLiteral("boundBy")).toString(); + if (bride == QLatin1String("floor")) + return QStringLiteral("%1 charge en heures creuses, tenue à son plancher (%2 W) par la " + "protection de surcharge du branchement : la limite de phase " + "est atteinte, la protection décide de la suite (pleine " + "puissance : %3 W, jusqu'à %4)") + .arg(label).arg(w("gridW")).arg(w("maxW")).arg(jusqua); + if (bride == QLatin1String("phaseLimit")) + return QStringLiteral("%1 charge en heures creuses à %2 W, bridée par la protection de " + "surcharge du branchement : la limite de phase ne laisse pas " + "plus (pleine puissance : %3 W, jusqu'à %4)") + .arg(label).arg(w("gridW")).arg(w("maxW")).arg(jusqua); + return QStringLiteral("%1 charge à pleine puissance en heures creuses : %2 W achetés au " + "réseau (%3 phase%4, jusqu'à %5)") + .arg(label).arg(w("gridW")).arg(w("phases")) + .arg(w("phases") > 1 ? QStringLiteral("s") : QString()).arg(jusqua); + } + if (c == QLatin1String(DecisionCode::StorageDischargeBlocked)) + return QStringLiteral("Décharge de la batterie bloquée : la voiture charge en heures " + "creuses (%1 W, jusqu'à %2)") + .arg(w("evPowerW")).arg(p.value(QStringLiteral("until")).toString()); + if (c == QLatin1String(DecisionCode::StorageAutomatic)) { + const QString cause = p.value(QStringLiteral("cause")).toString(); + QString pourquoi = QStringLiteral("cause inconnue (%1)").arg(cause); + if (cause == QLatin1String("peak")) pourquoi = QStringLiteral("hors heures creuses"); + else if (cause == QLatin1String("evNotCharging")) pourquoi = QStringLiteral("aucune voiture ne charge"); + else if (cause == QLatin1String("degraded")) pourquoi = QStringLiteral("mode dégradé L2"); + return QStringLiteral("Batterie en automatique — %1").arg(pourquoi); + } + if (c == QLatin1String(DecisionCode::StorageNotProtected)) { + const QString cause = p.value(QStringLiteral("cause")).toString(); + QString pourquoi = QStringLiteral("cause inconnue (%1)").arg(cause); + if (cause == QLatin1String("noDischargeControl")) + pourquoi = QStringLiteral("elle n'expose pas de limite de décharge pilotable"); + else if (cause == QLatin1String("noRevertTimer")) + pourquoi = QStringLiteral("elle n'a pas de minuteur de retour, le moteur ne la commande pas"); + return QStringLiteral("Batterie non protégée : la voiture charge en heures creuses, mais %1") + .arg(pourquoi); + } + // --- Sécurité et cycle de vie ------------------------------------------ if (c == QLatin1String(DecisionCode::DegradedL2)) return QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)"); @@ -384,13 +447,14 @@ QStringList DecisionCode::knownCodes() return {EvSpotMarket, EvDeadline, EvSurplus, EvEcoMin, EvIdle, LoadUnavailable, LockMinOn, LockMinOff, LockMinOffCapped, SurplusSetpoint, SurplusInsufficient, BatteryReserve, BelowMinPower, EvGridStart, PhaseLimit, DrawCap, - EcoFloorGrid, EcoFloorBelowStage, EcoFloorMet, EcoFloorMissed, RecreditSuspended, + OverloadThrottled, EcoFloorGrid, EcoFloorBelowStage, EcoFloorMet, EcoFloorMissed, RecreditSuspended, DerogationActive, DerogationEnded, MissingForecastSite, ForecastPayloadRefused, SpotMarketNotInBuild, ForecastDateUnreadable, TariffRefused, LoadConfigRefused, LockMinStateHold, SgForced, SgRecommended, SgNormal, OptimizerDecision, PriceSlot, - DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady}; + EvWaitingOffPeak, EvOffPeakFullPower, StorageDischargeBlocked, StorageAutomatic, + StorageNotProtected, DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady}; } QStringList DecisionCode::optimizerEmittableCodes() diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index 8bd7876..edcfb76 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -176,6 +176,29 @@ constexpr const char *PhaseLimit = "PHASE_LIMIT"; */ constexpr const char *DrawCap = "DRAW_CAP"; +/*! + * \brief La PROTECTION DE SURCHARGE de l'amont tient la borne : abaissée ou coupée. + * + * params : \c fromA (courant avant le premier geste), \c toA (courant tenu, 0 si coupée), + * \c cut (booléen), \c overshootW (le plus fort dépassement constaté pendant la tenue), + * \c limitA (la limite par phase du branchement, \c phasePowerLimit), \c phase (code opaque + * \c A | \c B | \c C — **absent** pour une borne triphasée : la protection la rabaisse alors sur + * la phase la plus chargée, sans qu'une phase soit à elle seule coupable). + * + * \par Ce qu'il publie, et ce qu'il ne décide pas + * \c SmartChargingManager::verifyOverloadProtection() rabaisse la borne du dépassement arrondi + * à l'ampère supérieur, et la coupe sous son plancher. Ce geste ne laissait ni motif, ni + * télémétrie. Ce code le PUBLIE, après coup : la protection reste celle de l'amont, à l'identique. + * + * \par Quand il tient, quand il se lève + * Il tient tant que la dernière commande appliquée à la borne vient de la protection — la baisse, + * la coupure, et la remise au courant minimal par \c verifyOverloadProtectionRecovery(). Il se lève + * quand le cycle commande à nouveau la borne, ou quand la reprise lui rend son courant manuel. + * + * \note Préséance : celle du délestage L4 — l'action entière est remplacée, sans niveaux. + */ +constexpr const char *OverloadThrottled = "OVERLOAD_THROTTLED"; + /*! * \brief L'obligation éco est servie en ACHETANT au réseau. * @@ -428,6 +451,70 @@ constexpr const char *OptimizerDecision = "OPTIMIZER_DECISION"; */ constexpr const char *PriceSlot = "PRICE_SLOT"; +// --- Heures creuses et stockage (lot e1) ----------------------------------- +/*! + * \brief params: \c at, \c startsAt, \c budgetW — la borne ATTEND LES HEURES CREUSES. + * + * Hors heures creuses, une borne en mode « avec minimum » ou « avec échéance » n'est servie + * qu'au surplus : son minimum réseau est REPORTÉ aux heures creuses. Ce motif remplace alors + * \c SURPLUS_INSUFFICIENT, \c BELOW_MIN_POWER et \c BATTERY_RESERVE, parce que la cause de la + * consigne nulle n'est plus le manque de surplus : sans le tarif, le minimum aurait été servi. + * + * \c at est l'heure MURALE de la box (\c "HH:MM", le réglage tel qu'il a été saisi), + * \c startsAt le même début en INSTANT (ISO UTC) — un réglage et un instant ne se rendent pas + * au même endroit (AGENTS.md). \c budgetW est le budget de surplus vu par la borne. + * + * \note N'est émis QUE si le tarif saisi a des heures creuses. Un motif de limite (phase, + * plafond de soutirage, verrou, indisponibilité) garde la tête : il dit ce qui empêcherait + * la borne de charger même en heures creuses. + */ +constexpr const char *EvWaitingOffPeak = "EV_WAITING_OFF_PEAK"; +/*! + * \brief params: \c gridW, \c maxW, \c phases, \c until, \c endsAt, \c o:boundBy — la borne + * charge à PLEINE PUISSANCE en heures creuses (lot e2). + * + * Dans les mêmes trois modes que \c EV_WAITING_OFF_PEAK, le minimum servi au réseau en heures + * creuses est le plafond du cycle de la borne. \c gridW est la puissance RETENUE, achetée ; + * \c maxW la pleine puissance du matériel aux \c phases visées (courant maximal × 230 V × + * phases). \c until (\c "HH:MM", heure murale) et \c endsAt (instant) disent quand les heures + * creuses finissent. + * + * \c boundBy n'est présent que si \c gridW < \c maxW : la protection de surcharge du + * branchement a rabaissé la borne. \c phaseLimit — à ce que la limite de phase laisse ; + * \c floor — au plancher de la borne, que la stratégie ne franchit jamais : couper revient + * alors à la protection de surcharge (L4), qui garde le dernier mot. + * + * \note N'est émis QUE si le tarif saisi a des heures creuses. Aucun optimiseur ne l'émet. + */ +constexpr const char *EvOffPeakFullPower = "EV_OFF_PEAK_FULL_POWER"; +/*! + * \brief params: \c evPowerW, \c until, \c endsAt — la DÉCHARGE de la batterie est BLOQUÉE. + * + * En heures creuses, pendant qu'une voiture tire réellement de la puissance (\c evPowerW, + * mesurée), la batterie ne se décharge pas dans la voiture : le moteur active sa limite de + * décharge à 0 %. Il ne touche jamais à sa charge, ni à la charge sur le réseau. \c until + * (\c "HH:MM", heure murale) et \c endsAt (instant) disent quand les heures creuses finissent — + * au plus tard, la batterie retrouve alors son automatisme. + */ +constexpr const char *StorageDischargeBlocked = "STORAGE_DISCHARGE_BLOCKED"; +/*! + * \brief params: \c cause — la batterie est rendue à son AUTOMATISME. + * + * \c cause ∈ \c peak (hors heures creuses), \c evNotCharging (aucune voiture ne tire de + * puissance), \c degraded (mode dégradé L2 : le moteur ne commande plus rien). Le moteur ne + * pose alors aucune consigne ; s'il en tenait une, il l'a LEVÉE explicitement. + */ +constexpr const char *StorageAutomatic = "STORAGE_AUTOMATIC"; +/*! + * \brief params: \c cause — la décharge AURAIT dû être bloquée, et la batterie ne le permet pas. + * + * \c cause ∈ \c noDischargeControl (pas d'états \c enableDischarging et \c dischargingRate + * modifiables), \c noRevertTimer (aucun minuteur de retour : un moteur arrêté laisserait la + * batterie bloquée sans limite de temps, et le moteur refuse donc de la commander). La batterie + * est lue comme avant le lot, et se décharge dans la voiture. + */ +constexpr const char *StorageNotProtected = "STORAGE_NOT_PROTECTED"; + // --- Sécurité et cycle de vie ---------------------------------------------- //! params: — · consigne de repli du watchdog L2 (compteur muet, SAFETY.md §L2). constexpr const char *DegradedL2 = "DEGRADED_L2"; diff --git a/energyplugin/etm/types/loaddescriptor.h b/energyplugin/etm/types/loaddescriptor.h index 10982ed..50f040c 100644 --- a/energyplugin/etm/types/loaddescriptor.h +++ b/energyplugin/etm/types/loaddescriptor.h @@ -109,6 +109,22 @@ struct LoadNeeds { * L'écrire en configuration créerait une copie qui se périme au premier recâblage. */ double minPowerWAlways = 0; + + /*! + * \brief Le besoin RÉSEAU de cette charge se reporte-t-il en heures creuses ? (lot e1) + * + * Déclaré par l'adaptateur depuis le mode de l'utilisateur, comme \c minPowerWAlways ; c'est + * le SCHEDULER qui en fait une décision, et seulement si le tarif saisi a des heures creuses. + * Sans heures creuses, ce champ n'a aucun effet. + * + * Avec : hors heures creuses, \c minPowerWAlways est retiré (surplus seulement) ; en heures + * creuses, il vaut au moins \c offPeakMinPowerW. Non sérialisé vers l'optimiseur. + */ + bool offPeakShift = false; + //! Minimum servi en heures creuses quand \c offPeakShift est vrai (W), dérivé comme + //! \c minPowerWAlways. 0 = aucun. Pour une borne, c'est son PLAFOND de cycle depuis le lot e2 + //! (le plancher au lot e1) : elle y charge à pleine puissance, limite de phase comprise. + double offPeakMinPowerW = 0; }; /*! diff --git a/energyplugin/etm/types/plan.h b/energyplugin/etm/types/plan.h index c2048f2..89344c6 100644 --- a/energyplugin/etm/types/plan.h +++ b/energyplugin/etm/types/plan.h @@ -148,6 +148,21 @@ struct PlanDraw { bool perPhaseBound = false; }; +/*! + * \brief La décision du cycle sur la DÉCHARGE de la batterie (lot e1) — interne au moteur. + * + * Prise par le rule-based à chaque cycle (y compris en ombre d'un optimiseur), exécutée par + * \c StorageDischargeGuard. Jamais sérialisée vers l'optimiseur. + * + * \invariant \c evaluated faux ⇔ il n'y a pas d'heure creuse dans le tarif saisi, ou aucune + * batterie : rien n'est publié, rien n'est écrit (sauf la levée d'une consigne encore tenue). + */ +struct StorageDecision { + bool evaluated = false; //!< La question s'est posée ce cycle. + bool block = false; //!< Bloquer la décharge des batteries pilotables. + DecisionReason reason; //!< Pourquoi — non vide quand \c evaluated. +}; + /*! * \brief Plan d'optimisation retourné par IScheduler::getPlan(). * diff --git a/energyplugin/etm/types/sheddinglog.cpp b/energyplugin/etm/types/sheddinglog.cpp new file mode 100644 index 0000000..a98a7e5 --- /dev/null +++ b/energyplugin/etm/types/sheddinglog.cpp @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync + +#include "sheddinglog.h" + +QVariantMap SheddingState::toMap() const +{ + return QVariantMap{{QStringLiteral("powerW"), qRound(powerW)}, + {QStringLiteral("mechanism"), mechanism}}; +} + +QVariantMap SheddingEvent::toMap() const +{ + QVariantMap m{{QStringLiteral("id"), id}, + {QStringLiteral("loadId"), loadId}, + {QStringLiteral("startedAt"), startedAt.toUTC().toString(Qt::ISODate)}, + {QStringLiteral("source"), source}, + {QStringLiteral("overshootW"), qRound(overshootW)}, + {QStringLiteral("cut"), cut}, + {QStringLiteral("before"), before.toMap()}, + {QStringLiteral("after"), after.toMap()}}; + // Absents quand ils n'existent pas : un épisode en cours n'a pas de fin, et un plafond total + // n'a pas de phase coupable. Une valeur par défaut dirait le contraire. + if (endedAt.isValid()) + m.insert(QStringLiteral("endedAt"), endedAt.toUTC().toString(Qt::ISODate)); + if (!phase.isEmpty()) + m.insert(QStringLiteral("phase"), phase); + return m; +} + +SheddingLog::SheddingLog(int capacity) + : m_capacity(qMax(1, capacity)) +{ +} + +SheddingEvent *SheddingLog::trouver(int id) +{ + for (SheddingEvent &e : m_evenements) + if (e.id == id) + return &e; + return nullptr; +} + +void SheddingLog::evincer() +{ + while (m_evenements.size() > m_capacity) { + // Le plus ancien épisode CLOS d'abord : un épisode en cours est ce que l'écran affiche + // en ce moment, l'évincer effacerait l'alerte pendant qu'elle a lieu. + int victime = 0; + for (int i = 0; i < m_evenements.size(); ++i) { + if (m_evenements.at(i).endedAt.isValid()) { + victime = i; + break; + } + } + // Le suivi n'est retiré que si l'évincé est en cours : un épisode CLOS de la même charge + // n'a plus de suivi, et retirer par loadId emporterait celui de l'épisode qui s'ouvre. + const SheddingEvent &evince = m_evenements.at(victime); + const auto suivi = m_ouverts.constFind(evince.loadId); + if (suivi != m_ouverts.constEnd() && suivi->id == evince.id) + m_ouverts.remove(evince.loadId); + m_evenements.removeAt(victime); + } +} + +void SheddingLog::tenir(const QString &loadId, const QDateTime &now, const SheddingAct &act) +{ + auto it = m_ouverts.find(loadId); + SheddingEvent *e = it != m_ouverts.end() ? trouver(it->id) : nullptr; + if (!e) { + SheddingEvent n; + n.id = m_prochainId++; + n.loadId = loadId; + n.startedAt = now.toUTC(); // ancré à la capture (AGENTS, piège du fuseau) + n.source = act.source; + n.phase = act.phase; + n.overshootW = act.overshootW; + n.cut = act.cut; + n.before = act.before; + n.after = act.after; + m_evenements.append(n); + m_ouverts.insert(loadId, Ouvert{n.id, m_cycle, QDateTime()}); + ++m_revision; + evincer(); + return; + } + + it->dernierCycleTenu = m_cycle; + it->libreDepuis = QDateTime(); + + // Ce qui est PUBLIÉ peut changer : la révision ne bouge que dans ce cas. + const QVariantMap avant = e->toMap(); + if (act.overshootW > e->overshootW) { + e->overshootW = act.overshootW; + e->phase = act.phase; + e->source = act.source; + } + e->cut = e->cut || act.cut; + // « Après » garde le plus BAS : c'est ce que la protection a fait de plus fort. À égalité, + // le plus récent, qui dit l'état tenu. + if (act.after.powerW <= e->after.powerW) + e->after = act.after; + if (e->toMap() != avant) + ++m_revision; +} + +void SheddingLog::prolonger(const QString &loadId) +{ + auto it = m_ouverts.find(loadId); + if (it == m_ouverts.end()) + return; + it->dernierCycleTenu = m_cycle; + it->libreDepuis = QDateTime(); +} + +void SheddingLog::relacher(const QString &loadId, const QDateTime &now) +{ + auto it = m_ouverts.find(loadId); + if (it == m_ouverts.end() || it->libreDepuis.isValid()) + return; // la fin se date du PREMIER instant libre, pas du dernier + it->libreDepuis = now.toUTC(); +} + +bool SheddingLog::finDeCycle() +{ + bool ferme = false; + for (auto it = m_ouverts.begin(); it != m_ouverts.end();) { + // Libre, et pas tenue pendant le cycle qui s'achève : un cycle complet sans protection. + if (it->libreDepuis.isValid() && it->dernierCycleTenu < m_cycle) { + if (SheddingEvent *e = trouver(it->id)) + e->endedAt = it->libreDepuis; + it = m_ouverts.erase(it); + ferme = true; + } else { + ++it; + } + } + if (ferme) + ++m_revision; + ++m_cycle; + return ferme; +} + +const SheddingEvent *SheddingLog::enCours(const QString &loadId) const +{ + const auto it = m_ouverts.constFind(loadId); + if (it == m_ouverts.constEnd()) + return nullptr; + for (const SheddingEvent &e : m_evenements) + if (e.id == it->id) + return &e; + return nullptr; +} + +QStringList SheddingLog::ouverts() const +{ + QStringList ids = m_ouverts.keys(); + ids.sort(); + return ids; +} + +QVariantMap SheddingLog::toMap() const +{ + QVariantList evts; + for (const SheddingEvent &e : m_evenements) + evts.append(e.toMap()); + return QVariantMap{{QStringLiteral("revision"), m_revision}, + {QStringLiteral("capacity"), m_capacity}, + {QStringLiteral("events"), evts}}; +} + +QVariantMap SheddingLog::resume() const +{ + if (m_revision == 0) + return QVariantMap(); + // Une QVariantList de chaînes, comme le reste de la télémétrie — pas une QStringList : le JSON + // est le même, mais un consommateur qui parcourt la carte par type (l'enregistreur de + // `decisions` anonymise les ThingId des listes) ne reconnaîtrait pas la seconde. + QVariantList ids; + for (const QString &id : ouverts()) + ids.append(id); + return QVariantMap{{QStringLiteral("revision"), m_revision}, + {QStringLiteral("open"), ids}}; +} diff --git a/energyplugin/etm/types/sheddinglog.h b/energyplugin/etm/types/sheddinglog.h new file mode 100644 index 0000000..cc71999 --- /dev/null +++ b/energyplugin/etm/types/sheddinglog.h @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include +#include +#include +#include +#include + +/*! + * \file sheddinglog.h + * \brief Journal COURT des délestages : un événement par ÉPISODE, en mémoire. + * + * \par Pourquoi il existe + * Un délestage ne vivait qu'un cycle dans la télémétrie (\c DRAW_CAP, \c shed: true), puis + * disparaissait. Une borne coupée la nuit par la protection de surcharge ne laissait aucune + * trace hors du journal système. Ce journal garde les N derniers épisodes, de quoi répondre + * le matin à « pourquoi ma borne s'est-elle arrêtée ? ». + * + * \par Ce qu'il ne fait pas + * Il ne décide rien. Il est alimenté par les deux couches de protection, après leur geste, + * et aucune décision ne le lit. Il est perdu au redémarrage de nymead : c'est accepté. + * + * \par Un épisode, pas un cycle + * Une charge tenue 20 minutes par la protection fait UNE entrée, avec un début et une fin. + * L'épisode s'ouvre au premier geste de protection, et se ferme à la fin du premier cycle + * COMPLET où la protection ne tient plus la charge. La fin est datée du premier instant où + * elle ne la tenait plus (\c relacher()). Un geste repris entre-temps prolonge l'épisode. + * + * \par Le mécanisme, sans liste de types + * « Avant » et « après » portent la charge utile de mécanisme TELLE QUE L'ADAPTATEUR LA + * PUBLIE (\c ILoadAdapter::runtimeView()) : un palier pour des relais, une consigne pour une + * charge continue, un état pour une PAC SG-Ready, un courant pour une borne. Un adaptateur + * ajouté demain est couvert sans rien changer ici. + */ + +//! \brief L'état d'une charge d'un côté du délestage. +struct SheddingState { + //! Puissance engagée, en watts — ce que la charge tenait (avant) ou tient (après). + double powerW = 0; + //! Charge utile de mécanisme, \c kind compris. Celle que \c GetLoadTelemetry publie. + QVariantMap mechanism; + + //! \return Forme sérialisée \c {powerW, mechanism}. + QVariantMap toMap() const; +}; + +//! \brief Ce qu'un geste de protection constate, au moment où il agit. +struct SheddingAct { + //! Source du plafond, code opaque : \c connection, \c gridOperator ou \c selfImposed. + QString source; + //! Phase qui dépasse (\c A, \c B, \c C), vide si aucune phase n'est coupable. + QString phase; + //! Dépassement mesuré au geste, en watts. + double overshootW = 0; + //! Vrai si le geste COUPE la charge. Un changement d'état SG-Ready n'est pas une coupure. + bool cut = false; + //! L'état avant le geste. + SheddingState before; + //! L'état laissé par le geste. + SheddingState after; +}; + +//! \brief Un épisode de délestage, tel que \c GetSheddingLog le publie. +struct SheddingEvent { + int id = 0; //!< Identifiant croissant, jamais réutilisé pendant la vie du processus. + QString loadId; //!< Charge délestée (identifiant de \c GetLoadConfig). + QDateTime startedAt; //!< Premier geste de l'épisode, en UTC. + QDateTime endedAt; //!< Fin de l'épisode, en UTC. Invalide = épisode en cours. + QString source; //!< Source du plafond au plus fort du dépassement. + QString phase; //!< Phase au plus fort du dépassement ; vide si aucune. + double overshootW = 0; //!< Le plus fort dépassement vu pendant l'épisode, en watts. + bool cut = false; //!< Vrai si la charge a été coupée au moins une fois. + SheddingState before; //!< L'état avant le premier geste. + SheddingState after; //!< L'état le plus bas tenu pendant l'épisode. + + //! \return Forme sérialisée. \c endedAt et \c phase sont absents quand ils n'existent pas. + QVariantMap toMap() const; +}; + +/*! + * \brief Journal circulaire des épisodes de délestage. + * + * \invariant Au plus \c capacity() événements. Le plus ancien épisode CLOS sort le premier ; + * un épisode en cours n'est évincé que si tous le sont. + * \invariant \c revision() change à chaque ouverture, fermeture, ou changement d'un contenu + * publié. Prolonger ou relâcher un épisode, sans le fermer, ne la change pas. + */ +class SheddingLog +{ +public: + //! Taille par défaut du journal. + static constexpr int DefaultCapacity = 20; + + //! \param capacity Nombre d'épisodes gardés, au moins 1. + explicit SheddingLog(int capacity = DefaultCapacity); + + /*! + * \brief Un geste de protection sur \p loadId : ouvre l'épisode, ou le prolonge. + * \param loadId Charge délestée. + * \param now Instant du geste. + * \param act Ce que le geste constate. + * \note Dans un épisode en cours, l'état « après » garde le plus bas, \c cut s'accumule, et + * le dépassement garde le plus fort, avec sa phase et sa source. + */ + void tenir(const QString &loadId, const QDateTime &now, const SheddingAct &act); + + //! \brief La protection tient encore \p loadId ce cycle, sans nouveau geste. + //! \param loadId Charge visée. Sans épisode en cours, rien ne se passe. + void prolonger(const QString &loadId); + + //! \brief La protection ne tient plus \p loadId : la fin sera datée de \p now. + //! \param loadId Charge visée. \param now Premier instant libre. Sans épisode, rien. + void relacher(const QString &loadId, const QDateTime &now); + + /*! + * \brief Fin d'un cycle d'arbitrage : ferme les épisodes libres depuis un cycle complet. + * \return Vrai si un épisode s'est fermé. + */ + bool finDeCycle(); + + //! \param loadId Charge visée. \return Vrai si un épisode est en cours sur elle. + bool estOuvert(const QString &loadId) const { return m_ouverts.contains(loadId); } + //! \param loadId Charge visée. \return Son épisode en cours, ou nul s'il n'y en a pas. + const SheddingEvent *enCours(const QString &loadId) const; + //! \return Les charges dont un épisode est en cours, triées. + QStringList ouverts() const; + //! \return Les épisodes gardés, du plus ancien au plus récent. + const QList &evenements() const { return m_evenements; } + //! \return Le compteur de révision ; 0 tant que rien n'a été noté. + int revision() const { return m_revision; } + //! \return Le nombre maximal d'épisodes gardés. + int capacity() const { return m_capacity; } + + //! \return La charge utile de \c GetSheddingLog : \c revision, \c capacity, \c events. + QVariantMap toMap() const; + /*! + * \brief Le résumé publié dans la télémétrie : \c revision et \c open. + * \return Vide tant qu'aucun épisode n'a été noté depuis le démarrage : l'absence du bloc + * dit « aucun délestage », un bloc à zéro le dirait mal. + */ + QVariantMap resume() const; + +private: + struct Ouvert { + int id = 0; + int dernierCycleTenu = 0; + QDateTime libreDepuis; //!< Invalide tant que la protection tient la charge. + }; + + SheddingEvent *trouver(int id); + void evincer(); + + int m_capacity; + int m_revision = 0; + int m_prochainId = 1; + int m_cycle = 0; + QList m_evenements; + QHash m_ouverts; +}; diff --git a/energyplugin/etm/types/surpluscontext.h b/energyplugin/etm/types/surpluscontext.h index 0ee2711..9eb7976 100644 --- a/energyplugin/etm/types/surpluscontext.h +++ b/energyplugin/etm/types/surpluscontext.h @@ -39,6 +39,27 @@ struct SurplusBattery { double maxDischargeW = 0; //!< Puissance de décharge maximale admissible (W). }; +/*! + * \brief Les HEURES CREUSES du cycle, tirées du tarif saisi (lot e1) — INTERNE au moteur. + * + * Jamais sérialisé vers l'optimiseur (il n'est pas dans le §5 du protocole, comme \c PlanBudget) : + * c'est la lecture que le rule-based fait du tarif, pas une donnée d'entrée du contrat. + * + * \invariant \c configured faux ⇔ aucun tarif, tarif unique, ou zones toutes au même prix + * (\c TariffConfig::zonesCreuses() vide). Les autres champs sont alors sans objet, et + * AUCUNE décision ne change : c'est la garantie « sans tarif = etm74 ». + */ +struct SurplusOffPeak { + bool configured = false; //!< Il existe des heures creuses dans le tarif saisi. + bool active = false; //!< \c SurplusContext::timestamp tombe en heures creuses. + //! Prochaine bascule (UTC, à la minute) : DÉBUT des heures creuses si \c active est faux, + //! leur FIN sinon. Un INSTANT, ancré à la capture. + QDateTime nextChange; + //! La même bascule en heure MURALE de la box, \c "HH:MM" — le réglage tel que l'installateur + //! l'a saisi (« 22:00 »), pour le motif publié. + QString nextChangeWall; +}; + /*! \brief Entrée tarifaire (créneau HP/HC ou spot market). */ struct TariffEntry { QDateTime from; //!< Début du créneau ; il court jusqu'au \c from du suivant. @@ -257,4 +278,29 @@ struct SurplusContext { QList loads; //!< Une entrée par charge pilotable, tous types confondus. SurplusTariff tariff; //!< Contexte tarifaire (créneau courant + suivants). // forecast : opaque, transmis tel quel si présent — réservé V2 + + //! Heures creuses du cycle (lot e1). Interne, non sérialisé — voir \c SurplusOffPeak. + SurplusOffPeak offPeak; + /*! + * \brief Puissance MESURÉE tirée par l'ensemble des bornes (W), toutes, y compris celles + * hors waterfall (mode manuel). Interne, non sérialisé. + * \note Lue au compteur de chaque borne (\c currentPower), jamais déduite d'une commande : + * la décharge de la batterie n'est bloquée que pour une voiture qui tire RÉELLEMENT. + */ + double evMeasuredW = 0; + /*! + * \brief Le stockage EXPOSE-t-il une limite de décharge pilotable ? Interne, non sérialisé. + * + * Vide = aucune batterie, ou aucune n'est pilotable. Sinon, une entrée par batterie, + * déclarée par \c StorageDischargeGuard (capacité de la ThingClass et du minuteur de retour, + * jamais une valeur d'état). + */ + struct StorageControl { + QString thingId; //!< ThingId de la batterie. + bool controllable = false; //!< Peut-on bloquer sa décharge en sûreté ? + //! Sinon, pourquoi : \c noDischargeControl (états absents ou non modifiables), + //! \c noRevertTimer (aucun minuteur de retour : un moteur arrêté la laisserait bloquée). + QString cause; + }; + QList storageControl; //!< Une entrée par batterie \c energystorage. }; diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index f4d853a..e32adb8 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -30,6 +30,7 @@ #include #endif #include "etm/types/decisionreason.h" // hors ETM_ARBITRATOR aussi : SPOT_MARKET_NOT_IN_BUILD +#include "etm/types/sheddinglog.h" // hors ETM_ARBITRATOR aussi : GetSheddingLog rend un journal vide #include "types/charginginfo.h" #include "smartchargingmanager.h" #include "spotmarket/spotmarketmanager.h" @@ -331,6 +332,39 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana returns = loadTelemetrySchema(); registerMethod("GetLoadTelemetry", description, params, returns, Types::PermissionScopeNone); + // [ETM] Journal court des délestages. Hors de la télémétrie, parce qu'il la doublerait : vingt + // épisodes portant chacun deux charges utiles de mécanisme, émis à chaque battement. La + // télémétrie n'en porte que le résumé (shedding.revision, shedding.open) — c'est lui qui + // notifie. Même droit que GetLoadTelemetry : c'est de la lecture d'état, un compte sans droit la lit. + params.clear(); returns.clear(); + description = "Get the short log of load-shedding episodes: the last 'capacity' episodes of " + "overload protection, oldest first, EV chargers and cascade loads alike. ONE " + "event per episode, not per cycle: 'endedAt' is omitted while the episode is " + "running. 'before' and 'after' carry the mechanism payload as the adapter " + "publishes it (stage for relays, setpoint, SG-Ready state, charger current); " + "'after' is the lowest state held during the episode. In memory only: lost " + "when nymead restarts. No own notification: GetLoadTelemetry carries " + "'shedding.revision', and LoadTelemetryChanged is emitted when an episode opens " + "or closes."; + QVariantMap etatItem; + etatItem.insert("powerW", enumValueName(Int)); + etatItem.insert("mechanism", enumValueName(Variant)); + QVariantMap evenementItem; + evenementItem.insert("id", enumValueName(Int)); + evenementItem.insert("loadId", enumValueName(String)); + evenementItem.insert("startedAt", enumValueName(String)); // ISO 8601 UTC + evenementItem.insert("o:endedAt", enumValueName(String)); // absent = en cours + evenementItem.insert("source", enumValueName(String)); // connection | gridOperator | selfImposed + evenementItem.insert("o:phase", enumValueName(String)); // A | B | C ; absent = aucune + evenementItem.insert("overshootW", enumValueName(Int)); + evenementItem.insert("cut", enumValueName(Bool)); + evenementItem.insert("before", etatItem); + evenementItem.insert("after", etatItem); + returns.insert("revision", enumValueName(Int)); + returns.insert("capacity", enumValueName(Int)); + returns.insert("events", QVariantList() << evenementItem); + registerMethod("GetSheddingLog", description, params, returns, Types::PermissionScopeNone); + // --- Prévision PV ------------------------------------------------------------------------ // // UN `Get*`, ET PAS DE `*Changed` — amendement assumé à l'invariant 11, qui vise l'ÉTAT DE @@ -1426,6 +1460,35 @@ QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema() derogEndItem.insert("o:restoredMode", enumValueName(Int)); schema.insert("o:derogationEnded", QVariantList() << derogEndItem); + // Lot e1 — la DÉCHARGE DE LA BATTERIE en heures creuses. ABSENT tant que le tarif saisi n'a + // pas d'heure creuse ou qu'aucune batterie n'existe : la question ne se pose pas, et un + // « non bloquée » publié sans elle se lirait comme une décision. Hors de « loads » : la + // batterie n'est pas une charge rangée, et « loads » reste l'ensemble de GetLoadConfig. + // « dischargeBlocked » est ce que le moteur TIENT après le dispatch ; « observed » ce que la + // batterie RAPPORTE — les deux, parce qu'un onduleur peut accepter une écriture et l'ignorer. + QVariantMap observedItem; + observedItem.insert("o:enableDischarging", enumValueName(Bool)); + observedItem.insert("o:dischargingRate", enumValueName(Double)); + QVariantMap batteryItem; + batteryItem.insert("thingId", enumValueName(Uuid)); + batteryItem.insert("controllable", enumValueName(Bool)); + batteryItem.insert("o:cause", enumValueName(String)); // noDischargeControl | noRevertTimer | + // revertTimerTooShort + batteryItem.insert("held", enumValueName(Bool)); + batteryItem.insert("o:heldSince", enumValueName(String)); // ISO 8601 UTC + batteryItem.insert("o:lastWriteAt", enumValueName(String)); // ISO 8601 UTC + // Série de refus de l'onduleur : « held » est alors FAUX, et ces trois champs disent depuis + // quand, combien de poses d'affilée, et quand le moteur réessaiera (espacement 1 → 15 min). + batteryItem.insert("o:refusedWrites", enumValueName(Int)); + batteryItem.insert("o:refusedSince", enumValueName(String)); // ISO 8601 UTC + batteryItem.insert("o:nextAttemptAt", enumValueName(String)); // ISO 8601 UTC + batteryItem.insert("o:observed", observedItem); + QVariantMap storageItem; + storageItem.insert("dischargeBlocked", enumValueName(Bool)); + storageItem.insert("decision", decisionItem); + storageItem.insert("batteries", QVariantList() << batteryItem); + schema.insert("o:storage", storageItem); + schema.insert("loads", QVariantList() << loadItem); return schema; } @@ -1454,6 +1517,17 @@ JsonReply *NymeaEnergyJsonHandler::GetLoadTelemetry(const QVariantMap ¶ms) return createReply(currentLoadTelemetry()); } +JsonReply *NymeaEnergyJsonHandler::GetSheddingLog(const QVariantMap ¶ms) +{ + Q_UNUSED(params) +#ifdef ETM_ARBITRATOR + if (EnergyArbitrator *arb = dynamic_cast(m_smartChargingManager)) + return createReply(arb->sheddingLog()); +#endif + // Sans arbitre, aucun délestage ne peut être noté : le journal est vide, et le dit. + return createReply(SheddingLog().toMap()); +} + JsonReply *NymeaEnergyJsonHandler::GetPvForecast(const QVariantMap ¶ms) { const QVariantMap succes{{"forecastPoints", QVariantList()}, diff --git a/energyplugin/nymeaenergyjsonhandler.h b/energyplugin/nymeaenergyjsonhandler.h index cf1e16d..f5b8748 100644 --- a/energyplugin/nymeaenergyjsonhandler.h +++ b/energyplugin/nymeaenergyjsonhandler.h @@ -119,6 +119,17 @@ public: * notification seule le laisse dans un silence ambigu (famille ECS-411). */ Q_INVOKABLE JsonReply *GetLoadTelemetry(const QVariantMap ¶ms); + + /*! + * \brief [ETM] Journal court des délestages : les derniers épisodes de protection. + * \param params Aucun. + * \return \c revision, \c capacity, \c events — un événement par ÉPISODE, borne et charges + * de la cascade confondues. Rendu tel que tenu en mémoire, jamais recalculé. + * \note Pas de notification propre : la télémétrie porte \c shedding.revision, et + * \c LoadTelemetryChanged part à chaque ouverture et à chaque fermeture. Même droit que + * \c GetLoadTelemetry. Perdu au redémarrage de nymead. + */ + Q_INVOKABLE JsonReply *GetSheddingLog(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. diff --git a/energyplugin/smartchargingmanager.h b/energyplugin/smartchargingmanager.h index 8c90e9c..45c0cf3 100644 --- a/energyplugin/smartchargingmanager.h +++ b/energyplugin/smartchargingmanager.h @@ -131,8 +131,12 @@ signals: // 4. internalEvChargers(), internalChargingActions(), internalRootMeter(), // internalProcessInfo(), internalHasProcessInfo() // — accesseurs ETM neufs, documentés ci-dessous. -// 5. bestPhaseCountFor(), phasesForCount() — passe-plats ETM vers deux règles amont +// 5. bestPhaseCountFor(), phasesForCount(), chargerPhaseFor() +// — passe-plats ETM vers trois règles amont // privées, documentés ci-dessous. +// 8. executeChargingAction() — virtual ajouté (le corps reste amont), pour +// que l'arbitre PUBLIE les gestes de la +// protection de surcharge. Documenté ci-dessous. // 7. setEvChargingState() — écriture ETM d'un état RUNTIME, documentée // ci-dessous. Pas un réglage : jamais persistée. // 6. evSurplusPlannedByWaterfall(), evCommandedByWaterfall() @@ -154,7 +158,28 @@ protected slots: void verifyOverloadProtectionRecovery(const QDateTime ¤tDateTime); // [ETM] private → protected protected: - void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime ¤tDateTime); // [ETM] private → protected + /*! + * \brief [ETM] Exécute une consigne de borne — corps amont, inchangé. + * \param evCharger Borne commandée. \param chargingAction Consigne et émetteur. + * \param currentDateTime Temps de cycle, passé aux verrous de la borne. + * \note `private → protected`, puis `virtual` ajouté (lot délestage visible). C'est le seul + * point par où passent TOUS les gestes de la protection de surcharge — y compris ceux de + * \c verifyOverloadProtection() déclenchés par \c powerBalanceChanged, hors cycle. + * \c EnergyArbitrator le surcharge pour les PUBLIER : il appelle ce corps tel quel, puis + * lit ce qui a été commandé. Une surcharge qui modifierait la consigne avant de l'appeler + * ferait décider la publication à la place de la protection (règle 4). + */ + virtual void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime ¤tDateTime); // [ETM] private → protected, virtual added + + /*! + * \brief [ETM] Phase de raccordement d'une borne monophasée, selon la protection de surcharge. + * \param evCharger Borne. \return \c "A", \c "B" ou \c "C". + * \note `private → protected`, même raison que \c bestPhaseCountFor : l'arbitre publie la + * phase sur laquelle la protection a jugé la borne. La redériver en ferait deux versions, + * et le motif nommerait une autre phase que celle qui a décidé. + */ + QString chargerPhaseFor(EvCharger *evCharger) const + { return chargerPhaseKey(evCharger); } /*! * \brief [ETM 3g-1] Nombre de phases le plus adapté à un courant de surplus donné. diff --git a/tests/auto/charging/testcharging.cpp b/tests/auto/charging/testcharging.cpp index 33e1611..ca1706a 100644 --- a/tests/auto/charging/testcharging.cpp +++ b/tests/auto/charging/testcharging.cpp @@ -2202,6 +2202,7 @@ void TestCharging::testNymeaEnergyPermissionScopes() {"GetSpotMarketScoreEntries", none}, {"GetLoadConfig", none}, {"GetLoadTelemetry", none}, + {"GetSheddingLog", none}, // même droit que la télémétrie : un compte sans droit le lit {"GetEnergyRatios", none}, {"GetPvForecast", none}, {"GetForecastSite", none}, @@ -2322,12 +2323,15 @@ void TestCharging::testNymeaEnergyPermissionsEnforced() // Le transport du harnais EXIGE l'authentification. Sans elle, nymea ne vérifie aucun droit // et rien de ce qui suit ne prouverait quoi que ce soit. QVERIFY(lecteur.authentificationExigee); - for (const QString &m : {QStringLiteral("GetLoadTelemetry"), QStringLiteral("GetTariff"), - QStringLiteral("GetBatteryLevelConsideration")}) { + for (const QString &m : {QStringLiteral("GetLoadTelemetry"), QStringLiteral("GetSheddingLog"), + QStringLiteral("GetTariff"), QStringLiteral("GetBatteryLevelConsideration")}) { const QVariantMap r = appeler(lecteur, m); QVERIFY2(reussi(r), qPrintable(m + " : " + QJsonDocument::fromVariant(r).toJson(QJsonDocument::Compact))); } QCOMPARE(appeler(lecteur, "GetTariff").value("params"), tarifAvant); // la vraie valeur, pas une coquille + // Le journal des délestages : un compte sans aucun droit le lit en entier. + QCOMPARE(appeler(lecteur, "GetSheddingLog").value("params"), lireAdmin("GetSheddingLog")); + QVERIFY(appeler(lecteur, "GetSheddingLog").value("params").toMap().contains("events")); QVERIFY(refuse(appeler(lecteur, "SetTariff", {{"zones", QVariantList()}}))); QCOMPARE(lireAdmin("GetTariff"), tarifAvant); QVERIFY(refuse(appeler(lecteur, "SetLoadDerogation", {{"loadId", "droits-relais"}, {"durationS", 600}}))); diff --git a/tests/auto/decisions/reference/decisions.ndjson.xz b/tests/auto/decisions/reference/decisions.ndjson.xz index 7becd81..c727814 100644 Binary files a/tests/auto/decisions/reference/decisions.ndjson.xz and b/tests/auto/decisions/reference/decisions.ndjson.xz differ diff --git a/tests/auto/loadmodel/testdecisioncatalogue.cpp b/tests/auto/loadmodel/testdecisioncatalogue.cpp index 2dd46ce..88096fa 100644 --- a/tests/auto/loadmodel/testdecisioncatalogue.cpp +++ b/tests/auto/loadmodel/testdecisioncatalogue.cpp @@ -53,6 +53,7 @@ void TestLoadModel::testOptimizerMayEmitOnlyWhitelistedCodes() for (const char *c : {DecisionCode::DegradedL2, DecisionCode::SafeStateRelay, DecisionCode::SafeStateSetpoint, DecisionCode::SafeStateSgReady, DecisionCode::LoadUnavailable, DecisionCode::DrawCap, + DecisionCode::OverloadThrottled, // la protection de surcharge de l'amont DecisionCode::PhaseLimit, DecisionCode::DerogationActive, DecisionCode::DerogationEnded, DecisionCode::EcoFloorMet, DecisionCode::EcoFloorBelowStage, // l'optimiseur ignore les paliers (E9) diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index 9b97864..dc21b93 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -2656,6 +2656,91 @@ void TestLoadModel::testTheTariffFollowsLocalTimeAcrossDstDays() QCOMPARE(t.zoneAt(instant, QTimeZone::utc()), 0); } +void TestLoadModel::testOffPeakIsTheCheapestZone() +{ + // Lot e1 (2026-10-24) — les heures creuses sont LA OU LES ZONES AU PRIX LE PLUS BAS d'un + // tarif à zones horaires. Ni tarif, ni tarif unique, ni zones toutes au même prix : aucune + // heure creuse, et le moteur ne change rien (preuve `decisions`). + QString cause; + QVariantMap ou; + const QTimeZone paris("Europe/Paris"); + const auto lire = [&](const QVariantMap &saisie) { + const TariffConfig t = TariffConfig::fromVariant(saisie, cause, ou); + if (!cause.isEmpty()) + qWarning() << "tarif refusé" << cause << ou; + return t; + }; + const QDateTime midi = QDateTime(QDate(2026, 6, 8), QTime(12, 0), paris); + + // SANS HEURE CREUSE — trois formes, une seule réponse. + QVERIFY(TariffConfig().zonesCreuses().isEmpty()); + QVERIFY(!TariffConfig().enHeuresCreuses(midi, paris)); + QVERIFY(!TariffConfig().prochaineBascule(midi, paris).isValid()); + const TariffConfig unique = lire(saisieTarif({zoneTarif("Base", 20.16, {{"00:00", "24:00"}})})); + QVERIFY(cause.isEmpty()); + QCOMPARE(unique.source(), QStringLiteral("fixed")); + QVERIFY(unique.zonesCreuses().isEmpty()); + QVERIFY(!unique.prochaineBascule(midi, paris).isValid()); + const TariffConfig egaux = lire(saisieTarif({zoneTarif("Jour", 20.0, {{"06:00", "22:00"}}), + zoneTarif("Nuit", 20.0, {{"22:00", "06:00"}})})); + QVERIFY(cause.isEmpty()); + QCOMPARE(egaux.source(), QStringLiteral("tou")); + QVERIFY2(egaux.zonesCreuses().isEmpty(), "zones au même prix : aucune n'est creuse"); + + // LE LIBELLÉ NE DÉCIDE PAS : la zone nommée « HC » mais plus chère n'est pas creuse. + const TariffConfig trompeur = lire(saisieTarif({zoneTarif("HC", 30.0, {{"22:00", "06:00"}}), + zoneTarif("HP", 18.0, {{"06:00", "22:00"}})})); + QVERIFY(cause.isEmpty()); + QCOMPARE(trompeur.zonesCreuses(), QList{1}); + + // PLUSIEURS ZONES AU PRIX MINIMAL : toutes creuses. La plus chère (HP) ne l'est pas, ni la + // zone intermédiaire. + const TariffConfig trois = lire(saisieTarif({zoneTarif("Nuit", 12.0, {{"22:00", "06:00"}}), + zoneTarif("Pointe", 30.0, {{"06:00", "12:00"}, {"16:00", "22:00"}}), + zoneTarif("Midi", 12.0, {{"12:00", "14:00"}}), + zoneTarif("Après-midi", 20.0, {{"14:00", "16:00"}})})); + QVERIFY(cause.isEmpty()); + QCOMPARE(trois.zonesCreuses(), (QList{0, 2})); + QVERIFY(trois.enHeuresCreuses(QDateTime(QDate(2026, 6, 8), QTime(13, 0), paris), paris)); + QVERIFY(!trois.enHeuresCreuses(QDateTime(QDate(2026, 6, 8), QTime(15, 0), paris), paris)); + + // LE CAS DE LA DÉMO — 22:00-06:00. Bascules lues à la minute ; époques = ORACLES hors de Qt + // (Python zoneinfo, cf. rapport du lot) — jamais recalculées avec QDateTime. + const TariffConfig t = lire(hphc()); + QVERIFY(cause.isEmpty()); + QCOMPARE(t.zonesCreuses(), QList{0}); + const auto bascule = [&](qint64 depuis) { + return t.prochaineBascule(QDateTime::fromSecsSinceEpoch(depuis, QTimeZone::utc()), paris) + .toSecsSinceEpoch(); + }; + // Jour ordinaire : 18:00 → entrée à 22:00 (1780948800) ; 21:59:59 HP, 22:00 HC. + QCOMPARE(bascule(1780934400), qint64(1780948800)); + QVERIFY(!t.enHeuresCreuses(QDateTime::fromSecsSinceEpoch(1780948799, QTimeZone::utc()), paris)); + QVERIFY(t.enHeuresCreuses(QDateTime::fromSecsSinceEpoch(1780948800, QTimeZone::utc()), paris)); + // …puis sortie à 06:00 (1780977600) : la nuit dure 8 h. + QCOMPARE(bascule(1780948800), qint64(1780977600)); + // Une seconde avant l'entrée : la bascule est la minute suivante, pas celle d'après. + QCOMPARE(bascule(1780948799), qint64(1780948800)); + + // JOUR DE 23 H (2026-03-29) : entrée 22:00 CET (1774731600), sortie 06:00 CEST (1774756800) + // — 7 h réelles, pas 8. Une soustraction de dates dirait 8. + QCOMPARE(bascule(1774731600 - 3600), qint64(1774731600)); + QCOMPARE(bascule(1774731600), qint64(1774756800)); + // JOUR DE 25 H (2026-10-25) : entrée 22:00 CEST (1792872000), sortie 06:00 CET (1792904400) + // — 9 h réelles. L'heure murale 02:30 existe deux fois ; les deux sont creuses. + QCOMPARE(bascule(1792872000), qint64(1792904400)); + QVERIFY(t.enHeuresCreuses(QDateTime::fromSecsSinceEpoch(1792888200, QTimeZone::utc()), paris)); + QVERIFY(t.enHeuresCreuses(QDateTime::fromSecsSinceEpoch(1792888200 + 3600, QTimeZone::utc()), paris)); + // …et le soir du 25 : entrée à 22:00 CET (1792962000). + QCOMPARE(bascule(1792904400), qint64(1792962000)); + + // UN RÉGLAGE MURAL : le même instant, lu à Londres, n'est pas dans la même zone. + const QDateTime z2130 = QDateTime::fromSecsSinceEpoch(1780948800 - 30 * 60, QTimeZone::utc()); // 21:30 Paris + QVERIFY(!t.enHeuresCreuses(z2130, paris)); + QVERIFY(!t.enHeuresCreuses(z2130, QTimeZone("Europe/London"))); // 20:30 Londres + QVERIFY(t.enHeuresCreuses(z2130, QTimeZone("Asia/Tokyo"))); // 04:30 Tokyo +} + void TestLoadModel::testAStraddlingSlotGetsTheTimeWeightedMean() { QString cause; diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index 17b4c5d..51d14bb 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -142,6 +142,8 @@ private slots: void testTheTariffIsValidatedStrictly(); //! Les plages sont des heures MURALES : jours de 23 h et de 25 h, oracle hors Qt. void testTheTariffFollowsLocalTimeAcrossDstDays(); + //! Lot e1 : heures creuses = zones au prix le plus bas ; bascules exactes les jours de 23 et 25 h. + void testOffPeakIsTheCheapestZone(); //! Un créneau à cheval sur deux zones reçoit la moyenne pondérée par la durée. void testAStraddlingSlotGetsTheTimeWeightedMean(); diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 220d9ce..4cb6008 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -149,6 +149,15 @@ public: QString texte() const { return m_texte; } //! Les messages un par un : une fausse ligne de journal ne se voit qu'À L'INTÉRIEUR d'un message. QStringList messages() const { return m_messages; } + //! Combien de messages de niveau \p type contiennent \p fragment. + int compte(QtMsgType type, const QString &fragment) const + { + int n = 0; + for (int i = 0; i < m_messages.size(); ++i) + if (m_typeParMessage.at(i) == type && m_messages.at(i).contains(fragment)) + ++n; + return n; + } private: static void collecte(QtMsgType type, const QMessageLogContext &ctx, const QString &msg) @@ -157,6 +166,7 @@ private: for (JournalCapture *c : s_pile) { c->m_texte += msg + QLatin1Char('\n'); c->m_messages.append(msg); + c->m_typeParMessage.append(type); c->m_types.insert(type); } if (s_origine) @@ -164,6 +174,7 @@ private: } QString m_texte; QStringList m_messages; + QList m_typeParMessage; QSet m_types; static QList s_pile; static QtMessageHandler s_origine; @@ -10543,8 +10554,16 @@ void Simulation::testNoDecisionReadsBaseloadOrPrice() // même cycle, aux mêmes entrées, avant et après qu'un tarif et un profil existent — et la // charge utile de l'arbitrage doit être identique. S'il casse, ce n'est pas le test qu'il // faut corriger : c'est un lot qui a branché le prix ou la base sur une décision sans le - // dire. Ce test devient FAUX le jour où un lot dédié le décide (e1, « en HC ») — alors il se - // réécrit avec cette décision, il ne se supprime pas. + // dire. + // + // RÉÉCRIT PAR LE LOT e1 (2026-10-24), qui a décidé — et seulement ceci : le tarif dit QUAND + // sont les heures creuses (zones au prix le plus bas), et deux choses en dépendent, la borne + // en mode « avec minimum » ou « avec échéance » et la décharge de la batterie. Ce test n'a + // ni l'une ni l'autre : un chauffe-eau et un routeur, à 15:00, en heures pleines. Le tarif ne + // doit donc RIEN y changer, et le prix lui-même ne décide toujours de rien. Les deux + // décisions du lot sont éprouvées ailleurs (testOffPeakDemoDay et la suite). Ce test devient + // faux le jour où un lot lit le PRIX (Pro, optimisation du coût) ou étend les heures creuses + // à d'autres charges : il se réécrit alors avec cette décision, il ne se supprime pas. FuseauDeTest fuseau("Europe/Paris"); const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-s11.json"; QFile::remove(cfgPath); @@ -10624,9 +10643,1755 @@ void Simulation::testNoDecisionReadsBaseloadOrPrice() // LE MÊME CYCLE, LES MÊMES ENTRÉES : la même décision, au watt et au motif près. const QVariantMap apres = cycle(7); + QVERIFY2(!apres.contains("storage"), "aucune batterie : la question de la décharge ne se pose pas"); QVERIFY2(apres == avant, qPrintable(QStringLiteral("le tarif ou la base a changé une décision (§11) :\navant %1\naprès %2") .arg(QString::fromUtf8(QJsonDocument::fromVariant(avant).toJson(QJsonDocument::Compact)), QString::fromUtf8(QJsonDocument::fromVariant(apres).toJson(QJsonDocument::Compact))))); #endif } + +// ============================================================================================ +// Lot e1 (2026-10-24) — heures creuses pour la borne, batterie protégée pendant sa charge +// ============================================================================================ + +//! Une écriture RÉELLEMENT exécutée sur une batterie, lue à la frontière nymea +//! (\c ThingManager::actionExecuted) — jamais dans la mémoire du garde, qui se donnerait raison. +struct EcritureBatterie { + QDateTime cycle; //!< Cycle pendant lequel elle a été exécutée. + QString etat; //!< Nom de l'état écrit. + QVariant valeur; +}; + +namespace { + +//! Ce que l'onduleur ferait de ces écritures : la limite de décharge à 0 % TIENT tant que la +//! dernière écriture date de moins que le minuteur de retour, et que la dernière séquence est +//! « taux 0 puis limite active ». Modèle du GEN24 (minuteur réarmé à CHAQUE écriture, plugin +//! sunspec `planSetpoint()`), indépendant du code du moteur. +bool limiteTenue(const QList &ecrites, const QDateTime &a, int revertS) +{ + bool active = false; + double taux = 100; + QDateTime derniere; + for (const EcritureBatterie &e : ecrites) { + if (e.cycle > a) + break; + if (e.etat == QLatin1String("enableDischarging")) active = e.valeur.toBool(); + if (e.etat == QLatin1String("dischargingRate")) taux = e.valeur.toDouble(); + derniere = e.cycle; + } + return active && qFuzzyIsNull(taux) && derniere.isValid() && derniere.secsTo(a) < revertS; +} + +//! La batterie pilotable du mock : `enableDischarging`, `dischargingRate`, minuteur `revertTime`. +QVariantMap batteriePilotable(uint revertS) +{ + return QVariantMap{ + {"thingClassId", QStringLiteral("{648e1a54-96c9-4f0f-9fc8-4326ab7c643e}")}, + {"name", QStringLiteral("Batterie pilotable")}, + {"thingParams", QVariantList{ + QVariantMap{{"paramTypeId", QStringLiteral("{6250973b-c0f5-4f2d-9bc9-b077898025de}")}, {"value", 26809}}, + QVariantMap{{"paramTypeId", QStringLiteral("{13b73424-5076-46b9-8eb8-38ee0078236e}")}, {"value", revertS}}}}}; +} + +//! Ce que la borne monophasée 16 A du scénario tire en heures creuses : son PLAFOND depuis le +//! lot e2 (son plancher, 1 380 W, au lot e1). Le compteur des tests l'inclut — sans quoi il +//! fabriquerait un surplus que la borne, recréditée de sa propre consommation, se servirait. +constexpr int TirageHcW = 3680; + +} // namespace + +Simulation::ScenarioHc Simulation::preparerScenarioHc(const QString &nom, BatterieHc batterie, + const QVariantList &tarif, BorneHc typeBorne) +{ + ScenarioHc s; + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-hc-" + nom + ".json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + s.arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + if (!s.arb) + return s; + s.arb->setTariff(TariffConfig()); + ThingManager *tm = NymeaCore::instance()->thingManager(); + + const QUuid meterId = addMeter(26801); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + s.meter = tm->findConfiguredThing(meterId); + s.meter->setStateValue("connected", true); + + s.borneId = typeBorne == BorneHc::Monophasee ? addCharger("A", 16, 26802) + : typeBorne == BorneHc::Triphasee ? addCharger("ABC", 16, 26802) + : addChargerWithPhaseCountSwitching("ABC", 16, 26802); + const QUuid voitureId = addCar(26803); + s.borne = tm->findConfiguredThing(s.borneId); + Thing *voiture = tm->findConfiguredThing(voitureId); + armerBorne(s.borne); + s.borne->setStateValue("pluggedIn", false); // la voiture arrive à 18 h + voiture->setStateValue("batteryLevel", 30); + voiture->setStateValue("capacity", 60); + voiture->setStateValue("minChargingCurrent", 6); + voiture->setStateValue("phaseCount", typeBorne == BorneHc::Monophasee ? 1 : 3); + QVariantMap info{{"evChargerId", s.borneId}, {"assignedCarId", voitureId}, + {"chargingMode", "ChargingModeEcoWithMinCurrent"}, {"targetPercentage", 100}}; + injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})); + + QUuid batterieId; + if (batterie == BatterieHc::Pilotable || batterie == BatterieHc::SansMinuteur) { + const QVariant r = injectAndWait("Integrations.AddThing", + batteriePilotable(batterie == BatterieHc::Pilotable ? 300 : 0)); + batterieId = r.toMap().value("params").toMap().value("thingId").toUuid(); + } else if (batterie == BatterieHc::SansEtats) { + batterieId = addEnergyStorage(8, 3300, 3300, 26804); + } + if (!batterieId.isNull()) { + s.batterie = tm->findConfiguredThing(batterieId); + s.batterie->setStateValue("batteryLevel", 60); + s.batterie->setStateValue("currentPower", 0); + } + + if (!tarif.isEmpty()) + injectAndWait("NymeaEnergy.SetTariff", QVariantMap{{"zones", tarif}}); + + // Les écritures sur la batterie, lues là où nymea les exécute. + s.ecrites = std::make_shared>(); + s.cycleCourant = std::make_shared(); + auto ecrites = s.ecrites; + auto cycleCourant = s.cycleCourant; + connect(tm, &ThingManager::actionExecuted, s.arb, [tm, batterieId, ecrites, cycleCourant] + (const Action &action, Thing::ThingError status) { + if (batterieId.isNull() || action.thingId() != ThingId(batterieId) || status != Thing::ThingErrorNoError) + return; + Thing *t = tm->findConfiguredThing(action.thingId()); + ActionTypes types = t->thingClass().actionTypes(); + const ActionType type = types.findById(action.actionTypeId()); + ecrites->append({*cycleCourant, type.name(), action.param(ParamTypeId(type.id())).value()}); + }); + return s; +} + +QVariantMap Simulation::cycleHc(ScenarioHc &s, const QDateTime &a, double compteurW) +{ + *s.cycleCourant = a; + s.meter->setStateValue("currentPower", compteurW); + s.arb->recordMeterUpdate(a); + s.arb->simulationCallUpdate(a); + // Les écritures de la batterie s'enchaînent sur la fin de la précédente (signal en file) : + // deux tours de boucle suffisent au mock, qui répond sur-le-champ. + for (int i = 0; i < 4; ++i) + QCoreApplication::processEvents(); + return s.arb->loadTelemetry(); +} + +void Simulation::testOffPeakDemoDay() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // LA JOURNÉE DE LA DÉMO — tarif 22:00-06:00, surplus à midi, voiture branchée à 18 h en + // « Éco avec minimum », batterie à 60 %. Ce que Patrick veut montrer : la borne charge au + // surplus la journée, ATTEND les heures creuses le soir, et pendant qu'elle charge en heures + // creuses la batterie ne se décharge pas dans la voiture. + FuseauDeTest fuseau("Europe/Paris"); + ScenarioHc s = preparerScenarioHc(QStringLiteral("journee"), BatterieHc::Pilotable, hphcT4()); + QVERIFY(s.arb && s.meter && s.borne && s.batterie); + const QString borne = s.borneId.toString(); + const auto decision = [&](const QVariantMap &snap) { + return telemetrieParId(snap).value(borne).value("decision").toMap(); + }; + const auto stockage = [](const QVariantMap &snap) { return snap.value("storage").toMap(); }; + + // 12:00 — surplus, voiture absente : rien n'est demandé à la batterie, et elle le dit. + QVariantMap snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(10, 0)), -3000); + QCOMPARE(stockage(snap).value("decision").toMap().value("code").toString(), + QString(DecisionCode::StorageAutomatic)); + QCOMPARE(stockage(snap).value("decision").toMap().value("params").toMap().value("cause").toString(), + QStringLiteral("peak")); + QCOMPARE(stockage(snap).value("dischargeBlocked").toBool(), false); + + // 18:00 — la voiture arrive. Pas de surplus : SANS le tarif, le minimum réseau partirait + // (ECO_FLOOR_GRID). AVEC, la borne attend les heures creuses — et le motif le dit. + s.borne->setStateValue("pluggedIn", true); + for (int m = 0; m <= 2; ++m) + snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(16, m)), 600); + QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EvWaitingOffPeak)); + QCOMPARE(decision(snap).value("params").toMap().value("at").toString(), QStringLiteral("22:00")); + QCOMPARE(decision(snap).value("params").toMap().value("startsAt").toString(), + QStringLiteral("2026-06-08T20:00:00Z")); + QCOMPARE(telemetrieParId(snap).value(borne).value("allocatedW").toDouble(), 0.0); + QCOMPARE(s.borne->stateValue("power").toBool(), false); + const QString texte = renderFr(DecisionReason(DecisionCode::EvWaitingOffPeak, + decision(snap).value("params").toMap()), "Borne"); + QVERIFY2(texte.contains(QStringLiteral("attend les heures creuses (22:00)")), qUtf8Printable(texte)); + + // 19:30 — 900 W de surplus, sous le plancher de 1 380 W, et une tolérance d'acquisition à + // 0,5 : sans le tarif, EV_GRID_START démarrerait en soutirant 480 W. Hors heures creuses, + // c'est le minimum réseau par une autre porte : refusé. + injectAndWait("NymeaEnergy.SetAcquisitionTolerance", QVariantMap({{"acquisitionTolerance", 0.5}})); + snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(17, 30)), -900); + QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EvWaitingOffPeak)); + QCOMPARE(s.borne->stateValue("power").toBool(), false); + + // 21:59 — toujours rien, et la batterie n'a reçu AUCUNE écriture de la journée. + snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(19, 59)), 600); + QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EvWaitingOffPeak)); + QVERIFY2(s.ecrites->isEmpty(), "une écriture sur la batterie hors heures creuses"); + + // 22:00 — LA REPRISE. Le minimum est servi au réseau (passe éco) — à PLEINE PUISSANCE + // depuis le lot e2 : 16 A, le plafond de la borne, et non plus son plancher de 6 A. + snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(20, 0)), 600); + QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EvOffPeakFullPower)); + QCOMPARE(qRound(telemetrieParId(snap).value(borne).value("allocatedW").toDouble()), 3680); + QCOMPARE(s.borne->stateValue("power").toBool(), true); + QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 16); + // La batterie n'est bloquée qu'au cycle où la voiture TIRE : à 22:00 le compteur de la + // borne lisait encore 0. + QCOMPARE(stockage(snap).value("decision").toMap().value("params").toMap().value("cause").toString(), + QStringLiteral("evNotCharging")); + QVERIFY(s.ecrites->isEmpty()); + + // 22:01 → 23:30 — la voiture tire : la décharge est BLOQUÉE, et TENUE. + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(20, 1)); + snap = cycleHc(s, a, TirageHcW + 300); + QCOMPARE(stockage(snap).value("decision").toMap().value("code").toString(), + QString(DecisionCode::StorageDischargeBlocked)); + QCOMPARE(stockage(snap).value("dischargeBlocked").toBool(), true); + QCOMPARE(stockage(snap).value("decision").toMap().value("params").toMap().value("until").toString(), + QStringLiteral("06:00")); + // L'ORDRE, lu à la frontière : le taux à 0 AVANT d'activer la limite. + QCOMPARE(s.ecrites->size(), 2); + QCOMPARE(s.ecrites->at(0).etat, QStringLiteral("dischargingRate")); + QCOMPARE(s.ecrites->at(0).valeur.toDouble(), 0.0); + QCOMPARE(s.ecrites->at(1).etat, QStringLiteral("enableDischarging")); + QCOMPARE(s.ecrites->at(1).valeur.toBool(), true); + // `held` est ce que le moteur TIENT ; `observed` ce que la batterie RAPPORTE au moment de + // publier. La seconde écriture part à la confirmation de la première : au cycle de la pose, + // la limite n'est pas encore relue active — c'est au cycle suivant qu'elle l'est. + QVariantMap bat = stockage(snap).value("batteries").toList().value(0).toMap(); + QCOMPARE(bat.value("held").toBool(), true); + QCOMPARE(bat.value("observed").toMap().value("dischargingRate").toDouble(), 0.0); + snap = cycleHc(s, a.addSecs(60), TirageHcW + 300); + bat = stockage(snap).value("batteries").toList().value(0).toMap(); + QCOMPARE(bat.value("observed").toMap().value("enableDischarging").toBool(), true); + QCOMPARE(bat.value("observed").toMap().value("dischargingRate").toDouble(), 0.0); + QCOMPARE(s.ecrites->size(), 2); // rien de réécrit une minute après la pose + // PAR LA FRONTIÈRE RPC, pas par l'accesseur : le bloc passe la validation du schéma déclaré + // (`o:storage`), et le Get rend ce qui a été publié. + const QVariantMap rpc = injectAndWait("NymeaEnergy.GetLoadTelemetry").toMap().value("params").toMap(); + QCOMPARE(rpc.value("storage").toMap().value("dischargeBlocked").toBool(), true); + QCOMPARE(rpc.value("storage").toMap().value("decision").toMap().value("code").toString(), + QString(DecisionCode::StorageDischargeBlocked)); + QCOMPARE(rpc.value("storage").toMap().value("batteries").toList().value(0).toMap() + .value("thingId").toUuid(), s.batterie->id()); + const QString texteBat = renderFr(DecisionReason(DecisionCode::StorageDischargeBlocked, + stockage(snap).value("decision").toMap().value("params").toMap())); + QVERIFY2(texteBat.startsWith(QStringLiteral("Décharge de la batterie bloquée : la voiture charge en heures creuses")), + qUtf8Printable(texteBat)); + + // LE MINUTEUR : à chaque cycle, l'onduleur modélisé hors du moteur tient encore la limite — + // donc le moteur a réécrit avant chaque échéance de 300 s. + for (int m = 3; m <= 89; ++m) { + a = utcDateTime(QDate(2026, 6, 8), QTime(20, 0)).addSecs(60 * m); + snap = cycleHc(s, a, TirageHcW + 300); + QVERIFY2(limiteTenue(*s.ecrites, a.addSecs(59), 300), + qUtf8Printable(QStringLiteral("limite expirée à %1").arg(a.toString(Qt::ISODate)))); + } + // …et sans réécrire à chaque cycle : au tiers du minuteur, pas plus souvent. + QVERIFY2(s.ecrites->size() <= 2 + 2 * (88 / 2 + 1), qUtf8Printable(QString::number(s.ecrites->size()))); + + // 23:30 — la voiture est pleine : la borne ne tire plus rien. La limite est LEVÉE au cycle + // suivant, explicitement : la limite d'abord, le taux remis au maximum ensuite. + s.borne->setStateValue("currentPower", 0); + s.borne->setStateValue("charging", false); + const int avantFin = s.ecrites->size(); + a = utcDateTime(QDate(2026, 6, 8), QTime(21, 30)); + snap = cycleHc(s, a, 300); + QCOMPARE(stockage(snap).value("dischargeBlocked").toBool(), false); + QCOMPARE(stockage(snap).value("decision").toMap().value("params").toMap().value("cause").toString(), + QStringLiteral("evNotCharging")); + QCOMPARE(s.ecrites->size(), avantFin + 2); + QCOMPARE(s.ecrites->at(avantFin).etat, QStringLiteral("enableDischarging")); + QCOMPARE(s.ecrites->at(avantFin).valeur.toBool(), false); + QCOMPARE(s.ecrites->at(avantFin + 1).etat, QStringLiteral("dischargingRate")); + QCOMPARE(s.ecrites->at(avantFin + 1).valeur.toDouble(), 100.0); + QCOMPARE(s.batterie->stateValue("enableDischarging").toBool(), false); + QVERIFY(!limiteTenue(*s.ecrites, a, 300)); + // Rendue, elle ne reçoit plus rien. + s.borne->setStateValue("currentPower", 0); + cycleHc(s, a.addSecs(60), 300); + QCOMPARE(s.ecrites->size(), avantFin + 2); + + // JAMAIS la charge, ni la charge sur le réseau — sur toute la journée. + for (const EcritureBatterie &e : *s.ecrites) + QVERIFY2(e.etat == QLatin1String("enableDischarging") || e.etat == QLatin1String("dischargingRate"), + qUtf8Printable(QStringLiteral("écriture interdite : %1").arg(e.etat))); + for (const EcritureBatterie &e : *s.ecrites) + if (e.etat == QLatin1String("dischargingRate")) + QVERIFY2(e.valeur.toDouble() >= 0, "un taux négatif force la CHARGE"); + QCOMPARE(s.batterie->stateValue("gridCharging").toBool(), false); + QCOMPARE(s.batterie->stateValue("enableCharging").toBool(), false); + QCOMPARE(s.batterie->stateValue("chargingRate").toInt(), 100); +#endif +} + +void Simulation::testOffPeakEndsAtSixAndOnDstDays() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // 06:00 — la voiture charge encore : la borne s'ARRÊTE (surplus seulement, il n'y en a pas) + // et la batterie est rendue. Puis les deux jours de l'heure d'été, aux instants calculés + // hors de Qt (Python zoneinfo) : 05:59 encore creuse, 06:00 déjà pleine. + FuseauDeTest fuseau("Europe/Paris"); + ScenarioHc s = preparerScenarioHc(QStringLiteral("six"), BatterieHc::Pilotable, hphcT4()); + QVERIFY(s.arb && s.borne && s.batterie); + const QString borne = s.borneId.toString(); + s.borne->setStateValue("pluggedIn", true); + + struct Bascule { const char *quoi; qint64 sixHeures; }; + const QList jours = { + {"2026-03-29, jour de 23 h (06:00 CEST)", 1774756800}, + {"2026-06-09, jour de 24 h", 1780977600}, + {"2026-10-25, jour de 25 h (06:00 CET)", 1792904400}, + }; + for (const Bascule &j : jours) { + const QDateTime six = QDateTime::fromSecsSinceEpoch(j.sixHeures, QTimeZone::utc()); + // Une heure en heures creuses : la borne charge à pleine puissance, la voiture tire. Le + // verrou d'enclenchement de la borne (300 s) est écoulé à 06:00 : c'est l'HEURE qui + // l'arrête. + QVariantMap snap; + for (int m : {60, 30, 3, 2, 1}) + snap = cycleHc(s, six.addSecs(-60 * m), TirageHcW + 300); + QVERIFY2(telemetrieParId(snap).value(borne).value("decision").toMap().value("code").toString() + == QString(DecisionCode::EvOffPeakFullPower), j.quoi); + QVERIFY2(snap.value("storage").toMap().value("dischargeBlocked").toBool(), j.quoi); + QVERIFY2(s.borne->stateValue("power").toBool(), j.quoi); + + // 06:00 — pleines : surplus seulement, la borne s'arrête, la batterie est rendue. + const int avant = s.ecrites->size(); + snap = cycleHc(s, six, TirageHcW + 300); + const QVariantMap d = telemetrieParId(snap).value(borne).value("decision").toMap(); + QVERIFY2(d.value("code").toString() == QString(DecisionCode::EvWaitingOffPeak), j.quoi); + QVERIFY2(d.value("params").toMap().value("at").toString() == QStringLiteral("22:00"), j.quoi); + QVERIFY2(!s.borne->stateValue("power").toBool(), j.quoi); + QVERIFY2(!snap.value("storage").toMap().value("dischargeBlocked").toBool(), j.quoi); + QVERIFY2(snap.value("storage").toMap().value("decision").toMap().value("params").toMap() + .value("cause").toString() == QStringLiteral("peak"), j.quoi); + QVERIFY2(s.ecrites->size() == avant + 2 && s.ecrites->at(avant).etat == QStringLiteral("enableDischarging") + && !s.ecrites->at(avant).valeur.toBool(), j.quoi); + // La borne est remise en état de tirer pour le jour suivant (elle l'a été par le moteur). + s.borne->setStateValue("currentPower", 0); + } +#endif +} + +void Simulation::testWithoutOffPeakTheEngineIsEtm74() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // SANS HEURE CREUSE, RIEN NE CHANGE — trois formes : aucun tarif, un tarif unique, deux zones + // au même prix. Le minimum réseau part à 18 h comme en etm74, aucune trame ne porte + // `storage`, la batterie ne reçoit rien. La preuve au bit près est `decisions` ; ce test dit + // pourquoi elle tient. + FuseauDeTest fuseau("Europe/Paris"); + const QList> tarifs = { + {"aucun tarif", QVariantList()}, + {"tarif unique", QVariantList{zoneT4("Base", 20.0, {{"00:00", "24:00"}})}}, + {"zones au même prix", QVariantList{zoneT4("Jour", 20.0, {{"06:00", "22:00"}}), + zoneT4("Nuit", 20.0, {{"22:00", "06:00"}})}}, + }; + QVariantMap reference; + for (const auto &t : tarifs) { + ScenarioHc s = preparerScenarioHc(QStringLiteral("etm74"), BatterieHc::Pilotable, t.second); + QVERIFY(s.arb && s.borne && s.batterie); + if (!t.second.isEmpty()) + QVERIFY2(!s.arb->tariff().isEmpty(), t.first); // le tarif est VIVANT + s.borne->setStateValue("pluggedIn", true); + QVariantMap snap; + for (int m = 0; m <= 3; ++m) + snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(16, m)), 600 + (m > 0 ? 1380 : 0)); + const QVariantMap d = telemetrieParId(snap).value(s.borneId.toString()).value("decision").toMap(); + QVERIFY2(d.value("code").toString() == QString(DecisionCode::EcoFloorGrid), t.first); + QVERIFY2(s.borne->stateValue("power").toBool(), t.first); + QVERIFY2(!snap.contains("storage"), t.first); + QVERIFY2(s.ecrites->isEmpty(), t.first); + // La trame est la MÊME d'un cas à l'autre, aux grandeurs datées et aux identifiants près. + snap.remove("timestamp"); + QVariantList charges; + for (const QVariant &v : snap.value("loads").toList()) { + QVariantMap c = v.toMap(); + c.remove("loadId"); + c.remove("command"); + c.remove("lock"); + charges << c; + } + snap.insert("loads", charges); + if (reference.isEmpty()) + reference = snap; + else + QVERIFY2(snap == reference, t.first); + } +#endif +} + +void Simulation::testABatteryWithoutControlIsReadAsBefore() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // Trois batteries que le moteur ne doit PAS commander, et une absente. Chacune se lit comme + // avant le lot ; le motif dit pourquoi elle n'est pas protégée. + FuseauDeTest fuseau("Europe/Paris"); + const QDateTime hc = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); // 23:00 Paris + + struct Cas { const char *quoi; BatterieHc batterie; const char *cause; }; + const QList cas = { + {"sans états de commande", BatterieHc::SansEtats, "noDischargeControl"}, + {"sans minuteur de retour", BatterieHc::SansMinuteur, "noRevertTimer"}, + }; + for (const Cas &c : cas) { + ScenarioHc s = preparerScenarioHc(QStringLiteral("nonpilotable"), c.batterie, hphcT4()); + QVERIFY(s.arb && s.borne && s.batterie); + s.borne->setStateValue("pluggedIn", true); + QVariantMap snap; + for (int m = 0; m <= 2; ++m) + snap = cycleHc(s, hc.addSecs(60 * m), TirageHcW + 300); + const QVariantMap st = snap.value("storage").toMap(); + QVERIFY2(!st.value("dischargeBlocked").toBool(), c.quoi); + QVERIFY2(st.value("decision").toMap().value("code").toString() + == QString(DecisionCode::StorageNotProtected), c.quoi); + QVERIFY2(st.value("decision").toMap().value("params").toMap().value("cause").toString() + == QLatin1String(c.cause), c.quoi); + const QVariantMap b = st.value("batteries").toList().value(0).toMap(); + QVERIFY2(!b.value("controllable").toBool() && b.value("cause").toString() == QLatin1String(c.cause), c.quoi); + QVERIFY2(s.ecrites->isEmpty(), c.quoi); + // …et la borne, elle, charge quand même en heures creuses : le lot ne la prive de rien. + QVERIFY2(telemetrieParId(snap).value(s.borneId.toString()).value("decision").toMap() + .value("code").toString() == QString(DecisionCode::EvOffPeakFullPower), c.quoi); + } + + // BATTERIE ABSENTE : la question ne se pose pas — aucun bloc `storage`, et la borne attend + // puis reprend comme avec une batterie. + ScenarioHc s = preparerScenarioHc(QStringLiteral("sansbatterie"), BatterieHc::Aucune, hphcT4()); + QVERIFY(s.arb && s.borne && !s.batterie); + s.borne->setStateValue("pluggedIn", true); + QVariantMap snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(16, 0)), 600); + QCOMPARE(telemetrieParId(snap).value(s.borneId.toString()).value("decision").toMap() + .value("code").toString(), QString(DecisionCode::EvWaitingOffPeak)); + QVERIFY(!snap.contains("storage")); + snap = cycleHc(s, hc, 600); + QCOMPARE(telemetrieParId(snap).value(s.borneId.toString()).value("decision").toMap() + .value("code").toString(), QString(DecisionCode::EvOffPeakFullPower)); + QVERIFY(!snap.contains("storage")); +#endif +} + +void Simulation::testBlockedDischargeShowsAtTheMeterAndL4Decides() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // SÉCURITÉ — bloquer la décharge AUGMENTE le soutirage : ce que la batterie couvrait passe au + // réseau. Le moteur ne le compense par aucun calcul ; il le MESURE au cycle suivant, et la + // protection de surcharge et le plafond de soutirage gardent le dernier mot sur la borne. + // Invariant 8 : le blocage ne fabrique aucun surplus — le budget reste compteur + PV. + FuseauDeTest fuseau("Europe/Paris"); + ScenarioHc s = preparerScenarioHc(QStringLiteral("securite"), BatterieHc::Pilotable, hphcT4()); + QVERIFY(s.arb && s.borne && s.batterie); + const QString borne = s.borneId.toString(); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + s.borne->setStateValue("pluggedIn", true); + // La protection amont lit la PUISSANCE par phase, la marge de soutirage le COURANT : les deux. + const auto phaseA = [&](double amperes) { + s.meter->setStateValue("currentPhaseA", amperes); + s.meter->setStateValue("currentPowerPhaseA", amperes * 230); + }; + phaseA(8); + + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); + QVariantMap snap; + for (int m = 0; m <= 2; ++m) + snap = cycleHc(s, a.addSecs(60 * m), TirageHcW + 300); + QVERIFY(snap.value("storage").toMap().value("dischargeBlocked").toBool()); + QVERIFY(s.borne->stateValue("power").toBool()); + + // INVARIANT 8 — la batterie bloquée ne charge ni ne décharge (0 W), la maison importe : le + // budget publié est exactement −(import), et non « import − ce que la batterie aurait donné ». + s.batterie->setStateValue("currentPower", 0); + snap = cycleHc(s, a.addSecs(180), TirageHcW + 1800); + QCOMPARE(qRound(snap.value("budget").toMap().value("surplusW").toDouble()), -(TirageHcW + 1800)); + + // Le soutirage monte : la phase A passe à 28 A pour une limite de 25 A. La borne n'est plus + // servie (plafond de soutirage, protection de surcharge) — et la batterie, qui ne voit plus + // de voiture tirer, est rendue au cycle d'après. + // + // Depuis D8 (2026-10-25), le plafond d'heures creuses rend à la borne son courant MESURÉ. L4 + // la ramène d'abord de 16 à 13 A ; la stratégie lit 13 A tirés sous 28 A au compteur, et il + // reste 25 − 15 − 1 = 9 A : elle RÉDUIT, elle ne coupe plus d'elle-même. L4 garde le dernier + // mot et retire encore les 3 A du dépassement — la borne finit à son plancher, ou coupée. + phaseA(28); + snap = cycleHc(s, a.addSecs(240), 6440); + QVERIFY2(!s.borne->stateValue("power").toBool() || s.borne->stateValue("maxChargingCurrent").toInt() <= 6, + qUtf8Printable(QJsonDocument::fromVariant(telemetrieParId(snap).value(borne)).toJson(QJsonDocument::Compact))); + s.borne->setStateValue("currentPower", 0); + phaseA(20); + snap = cycleHc(s, a.addSecs(300), 4600); + QVERIFY(!snap.value("storage").toMap().value("dischargeBlocked").toBool()); + QCOMPARE(s.batterie->stateValue("enableDischarging").toBool(), false); +#endif +} + +void Simulation::testAMeterSilenceHandsTheBatteryBack() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // L2 — compteur muet : le moteur cesse de commander, et la batterie est LEVÉE explicitement, + // pas laissée au minuteur. La trame du repli le dit (STORAGE_AUTOMATIC, cause degraded). + FuseauDeTest fuseau("Europe/Paris"); + ScenarioHc s = preparerScenarioHc(QStringLiteral("l2"), BatterieHc::Pilotable, hphcT4()); + QVERIFY(s.arb && s.borne && s.batterie); + s.borne->setStateValue("pluggedIn", true); + const QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); + QVariantMap snap; + for (int m = 0; m <= 2; ++m) + snap = cycleHc(s, a.addSecs(60 * m), TirageHcW + 300); + QVERIFY(snap.value("storage").toMap().value("dischargeBlocked").toBool()); + QCOMPARE(s.batterie->stateValue("enableDischarging").toBool(), true); + + *s.cycleCourant = a.addSecs(120 + 91); + s.arb->evaluateMeterFreshness(a.addSecs(120 + 91)); + for (int i = 0; i < 4; ++i) + QCoreApplication::processEvents(); + QVERIFY(s.arb->degradedMode()); + QCOMPARE(s.batterie->stateValue("enableDischarging").toBool(), false); + QCOMPARE(s.batterie->stateValue("dischargingRate").toInt(), 100); + snap = s.arb->loadTelemetry(); + QCOMPARE(snap.value("storage").toMap().value("decision").toMap().value("params").toMap() + .value("cause").toString(), QStringLiteral("degraded")); + QCOMPARE(snap.value("storage").toMap().value("dischargeBlocked").toBool(), false); +#endif +} + +void Simulation::testRefusedWritesAreSpacedAndNeverDelayTheRelease() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // L'ONDULEUR QUI REFUSE — un GEN24 dont « Allow Control » est désactivé répond 0x04 à toute + // écriture. Avant cette suite, le garde reposait la consigne à chaque cycle, avec un + // avertissement à chaque fois, toute la nuit, et publiait `held` vrai. + FuseauDeTest fuseau("Europe/Paris"); + ScenarioHc s = preparerScenarioHc(QStringLiteral("refus"), BatterieHc::Pilotable, hphcT4()); + QVERIFY(s.arb && s.borne && s.batterie); + s.borne->setStateValue("pluggedIn", true); + s.batterie->setProperty("refuserEcritures", true); + + // TOUTES les tentatives, refusées comprises, lues à la frontière nymea : `s.ecrites` ne garde + // que les acceptées. + struct Tentative { QDateTime cycle; QString etat; QVariant valeur; bool acceptee; }; + QList tentatives; + QObject portee; + ThingManager *tm = NymeaCore::instance()->thingManager(); + const ThingId batterieId = s.batterie->id(); + connect(tm, &ThingManager::actionExecuted, &portee, [&](const Action &action, Thing::ThingError status) { + if (action.thingId() != batterieId) + return; + ActionTypes types = s.batterie->thingClass().actionTypes(); + const ActionType type = types.findById(action.actionTypeId()); + tentatives.append({*s.cycleCourant, type.name(), action.param(ParamTypeId(type.id())).value(), + status == Thing::ThingErrorNoError}); + }); + const auto poses = [&]() { + QList l; + for (const Tentative &t : tentatives) + if (t.etat == QLatin1String("dischargingRate") && qFuzzyIsNull(t.valeur.toDouble())) + l << t.cycle; + return l; + }; + const auto batterie = [](const QVariantMap &snap) { + return snap.value("storage").toMap().value("batteries").toList().value(0).toMap(); + }; + const QString serie = QStringLiteral("n'est PAS bloquée"); // l'avertissement d'une SÉRIE + JournalCapture journal; + + // 1. REFUS EN SÉRIE — 50 minutes en heures creuses, la voiture tire. + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); // 23:00 à Paris + QVariantMap snap; + for (int m = 0; m <= 50; ++m) + snap = cycleHc(s, t0.addSecs(60 * m), TirageHcW + 300); + const QList p = poses(); + QVERIFY(!p.isEmpty()); + const QDateTime premiere = p.first(); + QVERIFY2(premiere <= t0.addSecs(120), qUtf8Printable(premiere.toString(Qt::ISODate))); + // L'ESPACEMENT : 1, 2, 5, 10, puis 15 min au plus entre deux poses. + QList ecarts; + for (const QDateTime &d : p) + ecarts << premiere.secsTo(d); + QCOMPARE(ecarts, (QList{0, 60, 180, 480, 1080, 1980, 2880})); + // Le taux est refusé : la limite n'est JAMAIS envoyée derrière (le sens sûr). + for (const Tentative &t : tentatives) + QVERIFY2(t.etat == QLatin1String("dischargingRate") && !t.acceptee, qUtf8Printable(t.etat)); + // UN SEUL avertissement pour la série, et une seule annonce de pose. + QCOMPARE(journal.compte(QtWarningMsg, serie), 1); + QCOMPARE(journal.compte(QtInfoMsg, QStringLiteral("décharge BLOQUÉE")), 1); + // LA TÉLÉMÉTRIE ne prétend pas tenir ce que l'onduleur refuse. La DÉCISION, elle, reste + // « bloquer » : c'est l'exécution qui échoue, et la batterie dit pourquoi. + QCOMPARE(snap.value("storage").toMap().value("decision").toMap().value("code").toString(), + QString(DecisionCode::StorageDischargeBlocked)); + QCOMPARE(snap.value("storage").toMap().value("dischargeBlocked").toBool(), false); + QVariantMap bat = batterie(snap); + QCOMPARE(bat.value("held").toBool(), false); + QVERIFY(!bat.contains("heldSince")); + QCOMPARE(bat.value("refusedWrites").toInt(), 7); + QCOMPARE(bat.value("refusedSince").toString(), premiere.toString(Qt::ISODate)); + QCOMPARE(bat.value("nextAttemptAt").toString(), premiere.addSecs(2880 + 900).toString(Qt::ISODate)); + // …et par la frontière RPC : les champs passent la validation du schéma déclaré. + const QVariantMap rpc = injectAndWait("NymeaEnergy.GetLoadTelemetry").toMap().value("params").toMap(); + QCOMPARE(rpc.value("storage").toMap().value("batteries").toList().value(0).toMap() + .value("refusedWrites").toInt(), 7); + + // 2. L'ONDULEUR ACCEPTE de nouveau (réglage corrigé) : la pose part à l'échéance prévue, pas + // avant, puis le rythme normal (tiers du minuteur) reprend. + s.batterie->setProperty("refuserEcritures", false); + const QDateTime reprise = premiere.addSecs(2880 + 900); + QDateTime a = t0.addSecs(60 * 50); + while (a < reprise) { + a = a.addSecs(60); + snap = cycleHc(s, a, TirageHcW + 300); + } + QCOMPARE(poses().size(), 8); + QCOMPARE(poses().last(), reprise); + QCOMPARE(tentatives.last().etat, QStringLiteral("enableDischarging")); + QVERIFY(tentatives.last().acceptee); + QCOMPARE(journal.compte(QtInfoMsg, QStringLiteral("ACCEPTÉE après 7 refus")), 1); + a = a.addSecs(60); + snap = cycleHc(s, a, TirageHcW + 300); + QCOMPARE(snap.value("storage").toMap().value("dischargeBlocked").toBool(), true); + bat = batterie(snap); + QCOMPARE(bat.value("held").toBool(), true); + QVERIFY(!bat.contains("refusedWrites") && !bat.contains("refusedSince") && !bat.contains("nextAttemptAt")); + // Le rythme normal : l'onduleur modélisé hors du moteur tient la limite à chaque cycle — + // un espacement de 15 min l'aurait laissée expirer au bout de 300 s. + const int poseesAvant = s.ecrites->size(); + for (int m = 0; m < 20; ++m) { + a = a.addSecs(60); + snap = cycleHc(s, a, TirageHcW + 300); + QVERIFY2(limiteTenue(*s.ecrites, a.addSecs(59), 300), + qUtf8Printable(QStringLiteral("limite expirée à %1").arg(a.toString(Qt::ISODate)))); + } + QVERIFY2(s.ecrites->size() - poseesAvant <= 2 * (20 / 2 + 1), qUtf8Printable(QString::number(s.ecrites->size()))); + + // 3. LA LEVÉE PENDANT UNE SÉRIE DE REFUS : immédiate. Une nouvelle série s'ouvre (un + // rafraîchissement refusé) et elle a son propre avertissement. + s.batterie->setProperty("refuserEcritures", true); + for (int garde = 0; batterie(snap).value("refusedWrites").toInt() < 3; ++garde) { + QVERIFY2(garde < 30, "la série de refus ne s'est pas ouverte"); + a = a.addSecs(60); + snap = cycleHc(s, a, TirageHcW + 300); + } + QCOMPARE(journal.compte(QtWarningMsg, serie), 2); + const QDateTime prochain = QDateTime::fromString(batterie(snap).value("nextAttemptAt").toString(), Qt::ISODate); + a = a.addSecs(60); + QVERIFY2(prochain > a, "la levée doit tomber AVANT le prochain essai, sinon elle ne prouve rien"); + s.borne->setStateValue("currentPower", 0); + s.borne->setStateValue("charging", false); + const int avantLevee = tentatives.size(); + snap = cycleHc(s, a, 300); + QCOMPARE(tentatives.size(), avantLevee + 2); + QCOMPARE(tentatives.at(avantLevee).cycle, a); + QCOMPARE(tentatives.at(avantLevee).etat, QStringLiteral("enableDischarging")); + QCOMPARE(tentatives.at(avantLevee).valeur.toBool(), false); + QCOMPARE(tentatives.at(avantLevee + 1).etat, QStringLiteral("dischargingRate")); + QCOMPARE(tentatives.at(avantLevee + 1).valeur.toDouble(), 100.0); + QCOMPARE(snap.value("storage").toMap().value("dischargeBlocked").toBool(), false); + bat = batterie(snap); + QCOMPARE(bat.value("held").toBool(), false); + QVERIFY(!bat.contains("refusedWrites") && !bat.contains("nextAttemptAt")); + + // 4. La levée a CLOS la série : la voiture tire de nouveau, la pose part tout de suite, et + // son premier refus est signalé comme celui d'une nouvelle série. + s.borne->setStateValue("currentPower", TirageHcW); + s.borne->setStateValue("charging", true); + const int avantRepose = poses().size(); + a = a.addSecs(60); + snap = cycleHc(s, a, TirageHcW + 300); + QCOMPARE(poses().size(), avantRepose + 1); + QCOMPARE(poses().last(), a); + a = a.addSecs(60); + snap = cycleHc(s, a, TirageHcW + 300); + QCOMPARE(journal.compte(QtWarningMsg, serie), 3); + QCOMPARE(batterie(snap).value("refusedWrites").toInt(), 1); +#endif +} + +// ============================================================================================ +// Lot e2 (2026-10-24) — en heures creuses, la borne charge à la pleine puissance permise +// ============================================================================================ + +void Simulation::testOffPeakChargesAtFullPower() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // DÉCISION DU 2026-10-24 — en heures creuses, la borne reçoit son PLAFOND de cycle, plus son + // plancher. Au plancher (6 A), une session de 10 à 25 kWh n'était pas finie au départ et la + // batterie restait bloquée toute la nuit. Monophasée 16 A : 3 680 W ; triphasée 16 A : + // 11 040 W. Hors heures creuses, rien ne change. Quand la voiture ne prend plus rien, la + // batterie est rendue — même si la borne reste commandée à 16 A. + FuseauDeTest fuseau("Europe/Paris"); + struct Cas { const char *quoi; BorneHc borne; int phases; int pleineW; }; + const QList cas = { + {"monophasée 16 A", BorneHc::Monophasee, 1, 3680}, + {"triphasée 16 A", BorneHc::Triphasee, 3, 11040}, + }; + for (const Cas &c : cas) { + ScenarioHc s = preparerScenarioHc(QStringLiteral("pleine-%1").arg(c.phases), + BatterieHc::Pilotable, hphcT4(), c.borne); + QVERIFY2(s.arb && s.borne && s.batterie, c.quoi); + const QString borne = s.borneId.toString(); + const auto charge = [&](const QVariantMap &snap) { return telemetrieParId(snap).value(borne); }; + s.borne->setStateValue("pluggedIn", true); + + // 18:00 — HORS heures creuses : INCHANGÉ. Surplus seulement, la borne attend. + QVariantMap snap; + for (int m = 0; m <= 2; ++m) + snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(16, m)), 600); + QVERIFY2(charge(snap).value("decision").toMap().value("code").toString() + == QString(DecisionCode::EvWaitingOffPeak), c.quoi); + QVERIFY2(qFuzzyIsNull(charge(snap).value("allocatedW").toDouble()), c.quoi); + QVERIFY2(!s.borne->stateValue("power").toBool(), c.quoi); + + // 22:00 — EN heures creuses : le PLAFOND, acheté au réseau, et le motif le dit. + snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(20, 0)), 600); + const QVariantMap d = charge(snap).value("decision").toMap(); + QVERIFY2(d.value("code").toString() == QString(DecisionCode::EvOffPeakFullPower), + qUtf8Printable(QJsonDocument::fromVariant(charge(snap)).toJson(QJsonDocument::Compact))); + QCOMPARE(qRound(charge(snap).value("allocatedW").toDouble()), c.pleineW); + QCOMPARE(qRound(snap.value("draw").toMap().value("committedW").toDouble()), c.pleineW); + QVERIFY2(s.borne->stateValue("power").toBool(), c.quoi); + QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 16); + const QVariantMap p = d.value("params").toMap(); + QCOMPARE(p.value("gridW").toInt(), c.pleineW); + QCOMPARE(p.value("maxW").toInt(), c.pleineW); + QCOMPARE(p.value("phases").toInt(), c.phases); + QCOMPARE(p.value("until").toString(), QStringLiteral("06:00")); + QCOMPARE(p.value("endsAt").toString(), QStringLiteral("2026-06-09T04:00:00Z")); + QVERIFY2(!p.contains("boundBy"), "pleine puissance : rien ne la bride, et aucune clé ne le prétend"); + QCOMPARE(renderFr(DecisionReason(DecisionCode::EvOffPeakFullPower, p), QStringLiteral("Borne")), + QStringLiteral("Borne charge à pleine puissance en heures creuses : %1 W achetés au " + "réseau (%2, jusqu'à 06:00)") + .arg(c.pleineW).arg(c.phases == 1 ? QStringLiteral("1 phase") : QStringLiteral("3 phases"))); + + // 22:01 — la voiture tire sa pleine puissance : la décharge est bloquée. + s.borne->setStateValue("currentPower", c.pleineW); + snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(20, 1)), c.pleineW + 300); + QVERIFY2(snap.value("storage").toMap().value("dischargeBlocked").toBool(), c.quoi); + QCOMPARE(snap.value("storage").toMap().value("decision").toMap().value("params").toMap() + .value("evPowerW").toInt(), c.pleineW); + + // 23:00 — FIN DE SESSION : la voiture est pleine. La borne reste commandée à 16 A + // (elle est en heures creuses, son minimum tient), mais elle ne tire plus que 100 W, + // sous le seuil de 230 W : la batterie est RENDUE au cycle même. + s.borne->setStateValue("currentPower", 100); + s.borne->setStateValue("charging", false); + snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(21, 0)), 400); + QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 16); + QVERIFY2(s.borne->stateValue("power").toBool(), "commandée à 16 A, et c'est la voiture qui ne prend rien"); + QVERIFY2(!snap.value("storage").toMap().value("dischargeBlocked").toBool(), c.quoi); + QCOMPARE(snap.value("storage").toMap().value("decision").toMap().value("params").toMap() + .value("cause").toString(), QStringLiteral("evNotCharging")); + QCOMPARE(s.batterie->stateValue("enableDischarging").toBool(), false); + QCOMPARE(s.batterie->stateValue("dischargingRate").toInt(), 100); + } +#endif +} + +void Simulation::testOffPeakFullPowerSwitchesToThreePhases() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // LE CHOIX DES PHASES, là où il bridait. Borne triphasée à bascule, voiture triphasée, la + // borne tire déjà 6 A sur UNE phase et une batterie NON pilotable couvre la maison : le + // compteur lit 0. La référence « courant + surplus » vaut alors 6 A, et la règle de bascule + // du manager garde le monophasé — 3 680 W au plus, toute la nuit, tant que la batterie + // couvre. En heures creuses la référence est la pleine puissance : trois phases, 11 040 W. + // Hors heures creuses, la règle d'avant s'applique toujours. + FuseauDeTest fuseau("Europe/Paris"); + ScenarioHc s = preparerScenarioHc(QStringLiteral("bascule"), BatterieHc::SansEtats, hphcT4(), + BorneHc::Bascule); + QVERIFY(s.arb && s.borne && s.batterie); + const QString borne = s.borneId.toString(); + s.borne->setStateValue("pluggedIn", true); + + const auto tireEnMono = [&]() { + s.borne->setStateValue("power", true); + s.borne->setStateValue("charging", true); + s.borne->setStateValue("maxChargingCurrent", 6); + s.borne->setStateValue("currentPower", 1380); + }; + + // 22:00 — en heures creuses : TROIS phases, pleine puissance. + QVariantMap snap; + for (int m = 0; m <= 1; ++m) { + tireEnMono(); + snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(20, m)), 0); + } + const QVariantMap d = telemetrieParId(snap).value(borne).value("decision").toMap(); + QVERIFY2(d.value("code").toString() == QString(DecisionCode::EvOffPeakFullPower), + qUtf8Printable(QJsonDocument::fromVariant(d).toJson(QJsonDocument::Compact))); + QCOMPARE(d.value("params").toMap().value("phases").toInt(), 3); + QCOMPARE(qRound(telemetrieParId(snap).value(borne).value("allocatedW").toDouble()), 11040); + QCOMPARE(s.borne->stateValue("desiredPhaseCount").toUInt(), 3u); + QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 16); + // La batterie n'est pas pilotable : le lot ne la commande pas, et le dit. + QCOMPARE(snap.value("storage").toMap().value("decision").toMap().value("code").toString(), + QString(DecisionCode::StorageNotProtected)); + + // TÉMOIN — le lendemain 21:59, hors heures creuses, mêmes mesures : la règle d'avant, + // monophasée. Sans ce témoin, « trois phases » pourrait venir d'ailleurs que de la référence. + tireEnMono(); + snap = cycleHc(s, utcDateTime(QDate(2026, 6, 9), QTime(19, 59)), 0); + QCOMPARE(s.borne->stateValue("desiredPhaseCount").toUInt(), 1u); +#endif +} + +namespace { + +//! Le compteur VOIT la borne : chaque phase porte la maison, plus ce que la borne y tire — +//! MESURÉ sur la borne (états par phase du mock), jamais déduit de sa consigne. Rend le total. +double compteurVoitLaBorne(Thing *compteur, Thing *borne, double maisonA) +{ + double totalW = 0; + for (const char *ph : {"A", "B", "C"}) { + const double a = maisonA + borne->stateValue(QStringLiteral("currentPhase%1").arg(ph)).toDouble(); + compteur->setStateValue(QStringLiteral("currentPhase%1").arg(ph), a); + compteur->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), a * 230); + totalW += a * 230; + } + return totalW; +} + +//! Intensité de la phase la plus chargée au compteur. +double pirePhaseA(Thing *compteur) +{ + double pire = 0; + for (const char *ph : {"A", "B", "C"}) + pire = qMax(pire, compteur->stateValue(QStringLiteral("currentPhase%1").arg(ph)).toDouble()); + return pire; +} + +//! Consigne de la borne, en ampères — 0 si elle est coupée. +int consigneA(Thing *borne) +{ + return borne->stateValue("power").toBool() ? borne->stateValue("maxChargingCurrent").toInt() : 0; +} + +} // namespace + +void Simulation::testOffPeakFullPowerYieldsToThePhaseLimit() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // SÉCURITÉ (invariant 2) — à pleine puissance, la borne reste sous la limite de phase, et une + // autre charge allumée la nuit la RÉDUIT sans que la stratégie la coupe. Couper reste le geste + // de la protection de surcharge (L4), qui garde le dernier mot. Borne triphasée 16 A, limite + // de branchement 25 A par phase. + // + // Le compteur VOIT la borne (lot e2, suite) : chaque phase porte la maison plus ce que la + // borne y tire, mesuré sur elle. C'est la condition où le plafond d'heures creuses lui rend + // son courant, et c'est la seule où la preuve de baisse en dépassement a un sens. + FuseauDeTest fuseau("Europe/Paris"); + ScenarioHc s = preparerScenarioHc(QStringLiteral("limite"), BatterieHc::Pilotable, hphcT4(), + BorneHc::Triphasee); + QVERIFY(s.arb && s.borne && s.batterie); + const QString borne = s.borneId.toString(); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + s.borne->setStateValue("pluggedIn", true); + const auto decision = [&](const QVariantMap &snap) { + return telemetrieParId(snap).value(borne).value("decision").toMap(); + }; + //! Ce que la borne tire sur chaque phase, mesuré sur elle. + const auto tireA = [&]() { return s.borne->stateValue("currentPhaseA").toDouble(); }; + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); + QVariantMap snap; + const auto cycle = [&](double maisonA) { + a = a.addSecs(360); // au-delà des verrous de la borne + snap = cycleHc(s, a, compteurVoitLaBorne(s.meter, s.borne, maisonA)); + }; + + // 1. LA MARGE D'UNE PHASE NE BRIDE PAS UNE BORNE TRIPHASÉE (LM-1500-h). La maison tire 4 A + // par phase : 21 A de marge sur CHAQUE phase, 4 830 W sur la pire. Comparer ces 4 830 W à + // l'enveloppe triphasée la servirait à 7 A ; la borne reçoit ses 11 040 W. + cycle(4); + QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EvOffPeakFullPower)); + QCOMPARE(qRound(telemetrieParId(snap).value(borne).value("allocatedW").toDouble()), 11040); + QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 16); + QVERIFY(!decision(snap).value("params").toMap().contains("boundBy")); + + // 2. LA MESURE, PAS LA CONSIGNE. La voiture ne prend que 10 A des 16 commandés, et un four + // s'allume : 15 A de maison, 25 A au compteur. La place rendue est celle que la voiture + // OCCUPE — 25 − 15 − 1 = 9 A. Rendre la consigne en aurait offert 15, que la voiture + // pourrait prendre d'un coup : 30 A sur une limite de 25. + for (const char *ph : {"A", "B", "C"}) { + s.borne->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 10.0); + s.borne->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 2300.0); + } + s.borne->setStateValue("currentPower", 6900.0); + cycle(15); + QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 9); + QCOMPARE(decision(snap).value("params").toMap().value("boundBy").toString(), QStringLiteral("phaseLimit")); + QCOMPARE(decision(snap).value("params").toMap().value("gridW").toInt(), 6210); + + // 3. Le four s'éteint : la borne remonte à sa pleine puissance. + cycle(4); + QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 16); + QCOMPARE(tireA(), 16.0); + + // 4. DÉPASSEMENT — le four se rallume pendant que la borne tire 16 A : 31 A pour 25. Tant + // que la phase dépasse, ce que la STRATÉGIE retient baisse, d'au moins 1 A sous ce que la + // borne tirait, et la consigne finale aussi — L4 a pu trancher plus bas, il garde le dernier + // mot. Hors du dépassement, la consigne se pose à 25 − 15 − 1 = 9 A, sans alterner. + QStringList suite; + QList consignes; + for (int k = 0; k < 5; ++k) { + const double avantA = tireA(); + compteurVoitLaBorne(s.meter, s.borne, 15); + const bool depasse = pirePhaseA(s.meter) > 25; + cycle(15); + const int retenuA = qRound(decision(snap).value("params").toMap().value("gridW").toDouble() / 690.0); + consignes << consigneA(s.borne); + suite << QStringLiteral("%1 A tirés%2 → stratégie %3 A, consigne %4 A") + .arg(avantA).arg(depasse ? QStringLiteral(" (dépassement)") : QString()) + .arg(retenuA).arg(consignes.last()); + if (depasse) { + // Au plancher, la stratégie TIENT la borne (décision du 2026-10-25) : c'est L4 qui coupe. + QVERIFY2(retenuA <= avantA - 1 || retenuA == 6, qUtf8Printable(suite.join(" ; "))); + QVERIFY2(consignes.last() <= avantA - 1, qUtf8Printable(suite.join(" ; "))); + } + } + qInfo().noquote() << "[dépassement]" << suite.join(QStringLiteral(" ; ")); + QVERIFY2(consignes.mid(2) == (QList{9, 9, 9}), qUtf8Printable(suite.join(" ; "))); + compteurVoitLaBorne(s.meter, s.borne, 15); + QVERIFY2(pirePhaseA(s.meter) <= 24, qUtf8Printable(suite.join(", "))); + QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EvOffPeakFullPower)); + QCOMPARE(decision(snap).value("params").toMap().value("boundBy").toString(), QStringLiteral("phaseLimit")); + QCOMPARE(decision(snap).value("params").toMap().value("gridW").toInt(), 6210); + QCOMPARE(decision(snap).value("params").toMap().value("maxW").toInt(), 11040); + QVERIFY(s.borne->stateValue("power").toBool()); + const QString texte = renderFr(DecisionReason(DecisionCode::EvOffPeakFullPower, + decision(snap).value("params").toMap()), "Borne"); + QVERIFY2(texte.contains(QStringLiteral("bridée par la protection de surcharge du branchement")), + qUtf8Printable(texte)); + + // 5. LA MAISON MONTE ENCORE (19 A par phase) : avec la borne au plancher, 25 A au compteur, + // pile la limite, et 5 A de place rendue — sous le plancher triphasé. La stratégie tient la + // borne À SON PLANCHER — 4 140 W, 6 A — et ne la coupe PAS. (Le premier cycle dépasse : la + // borne tire encore 9 A, et L4 tranche ; on lit l'état posé.) + for (int k = 0; k < 3; ++k) + cycle(19); + QCOMPARE(decision(snap).value("params").toMap().value("boundBy").toString(), QStringLiteral("floor")); + QCOMPARE(decision(snap).value("params").toMap().value("gridW").toInt(), 4140); + QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 6); + QVERIFY2(s.borne->stateValue("power").toBool(), "la stratégie ne coupe jamais : c'est le geste de L4"); + compteurVoitLaBorne(s.meter, s.borne, 19); + QCOMPARE(pirePhaseA(s.meter), 25.0); + + // 6. LIMITE DE BRANCHEMENT ATTEINTE — la borne à 6 A et la maison à 22 A : 28 A par phase pour + // 25. La protection de surcharge (L4) a le dernier mot : 6 − 3 = 3 A, sous le minimum de + // la voiture, elle COUPE — et la stratégie, au même cycle, ne la rallume pas. + cycle(22); + QVERIFY2(!s.borne->stateValue("power").toBool(), + qUtf8Printable(QJsonDocument::fromVariant(telemetrieParId(snap).value(borne)).toJson(QJsonDocument::Compact))); +#endif +} + +// ============================================================================================ +// Lot e2, suite (2026-10-25) — près de la limite de phase, la consigne se stabilise +// ============================================================================================ + +void Simulation::testOffPeakFullPowerSettlesUnderThePhaseLimit() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // L'OSCILLATION mesurée au lot e2 (question 2) : le plafond d'une borne était la place LIBRE + // au compteur, sans lui rendre ce qu'elle tire. Un compteur qui voit la borne fait donc + // alterner la consigne : à 16 A la place libre tombe à 7 A, à 7 A elle remonte à 16 A — sous + // une limite de 25 A, 16, 7, 16, 7… En heures creuses, le plafond lui rend son courant + // MESURÉ, moins 1 A : la consigne se pose, et sous une limite qui ne laisse pas 16 A elle se + // pose sous la limite, à 1 A d'elle. + // + // Maison à 2 A par phase (2,4 A pour le cas de l'arrondi), douze cycles espacés de 6 min + // (au-delà des verrous de la borne). + // Le compteur de chaque cycle porte ce que la borne tirait à la fin du précédent. + FuseauDeTest fuseau("Europe/Paris"); + struct Cas { const char *quoi; BorneHc borne; int phases; int limiteA; int attenduA; double maisonA; }; + const QList cas = { + {"monophasée, limite 25 A", BorneHc::Monophasee, 1, 25, 16, 2}, + {"monophasée, limite 32 A", BorneHc::Monophasee, 1, 32, 16, 2}, + {"triphasée, limite 25 A", BorneHc::Triphasee, 3, 25, 16, 2}, + {"triphasée, limite 32 A", BorneHc::Triphasee, 3, 32, 16, 2}, + // 16 − 2 = 14 A de place : la consigne se pose à 13 A, 1 A sous la limite. + {"monophasée, limite 16 A", BorneHc::Monophasee, 1, 16, 13, 2}, + {"triphasée, limite 16 A", BorneHc::Triphasee, 3, 16, 13, 2}, + // L'ARRONDI : 16 − 2,4 − 1 = 12,6 A. Pris au plus proche, 13 A porteraient la phase à + // 15,4 A — la marge mangée. À l'ampère inférieur : 12 A, 14,4 A au compteur. + {"monophasée, limite 16 A, maison à 2,4 A", BorneHc::Monophasee, 1, 16, 12, 2.4}, + }; + constexpr int Cycles = 12; + // Tous les cas sont MESURÉS avant qu'un seul soit jugé : le journal porte la série complète, + // cycle par cycle, même quand le premier cas échoue. + QList> toutesConsignes; + QList> toutesPires; + QStringList traces; + for (const Cas &c : cas) { + ScenarioHc s = preparerScenarioHc(QStringLiteral("oscillation-%1-%2-%3").arg(c.phases).arg(c.limiteA) + .arg(qRound(c.maisonA * 10)), + BatterieHc::Aucune, hphcT4(), c.borne); + QVERIFY2(s.arb && s.borne, c.quoi); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", c.limiteA}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + s.borne->setStateValue("pluggedIn", true); + + QList consignes; + QList pires; + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); // 23:00 à Paris + for (int k = 0; k < Cycles; ++k, a = a.addSecs(360)) { + cycleHc(s, a, compteurVoitLaBorne(s.meter, s.borne, c.maisonA)); + consignes << consigneA(s.borne); + compteurVoitLaBorne(s.meter, s.borne, c.maisonA); // ce que la consigne produit + pires << pirePhaseA(s.meter); + } + QStringList trace; + for (int k = 0; k < Cycles; ++k) + trace << QStringLiteral("%1 A (%2)").arg(consignes.at(k)).arg(pires.at(k)); + traces << trace.join(QStringLiteral(", ")); + qInfo().noquote() << "[oscillation]" << c.quoi << ":" << traces.last(); + toutesConsignes << consignes; + toutesPires << pires; + } + + for (int i = 0; i < cas.size(); ++i) { + const Cas &c = cas.at(i); + const QList &consignes = toutesConsignes.at(i); + const QList &pires = toutesPires.at(i); + // Posée dès le premier cycle, et TENUE : aucune alternance sur les douze. + for (int k = 0; k < Cycles; ++k) + QVERIFY2(consignes.at(k) == c.attenduA, + qUtf8Printable(QStringLiteral("%1 — cycle %2 : %3").arg(c.quoi).arg(k).arg(traces.at(i)))); + // Et SOUS la limite, avec la marge : la phase la plus chargée n'y touche jamais. + for (int k = 0; k < Cycles; ++k) + QVERIFY2(pires.at(k) <= c.limiteA - 1 + 1e-6, + qUtf8Printable(QStringLiteral("%1 — cycle %2 : %3").arg(c.quoi).arg(k).arg(traces.at(i)))); + } +#endif +} + +void Simulation::testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // LES PHASES DU COMPTEUR NE VOIENT PAS LA BORNE. Le retour du courant suppose le contraire, et + // c'est là qu'il pourrait mentir : la borne annonce 13 A que la phase ne montre pas, et + // « maison sans la borne » vaudrait 2 − 13 = −11 A. Sans garde, la place rendue serait + // 16 + 11 − 1 = 26 A, et la borne seule irait à la limite. Écrêtée à zéro, la maison sans la + // borne vaut au moins 0 : la borne reste à 1 A sous la limite. + // + // Deux variantes. Dans la première, le TOTAL du compteur contient la borne : c'est le banc de + // la garde. Dans la seconde, il ne la voit pas non plus, et le recrédit fabrique alors un + // surplus de la taille de la borne. Avant que la somme éco + confort soit bornée au plafond + // (écart 4, décision du 2026-10-25), la passe confort l'ajoutait au plafond de 15 A, et la + // borne recevait 16 A sous une limite de 16 A : 13, 16, 16… Le plafond tient désormais. + // Borne monophasée 16 A, limite 16 A, maison à 2 A par phase. + FuseauDeTest fuseau("Europe/Paris"); + for (const bool totalVoitLaBorne : {true, false}) { + const char *quoi = totalVoitLaBorne ? "total qui voit la borne" : "total aveugle"; + ScenarioHc s = preparerScenarioHc(QStringLiteral("compteur-aveugle-%1").arg(totalVoitLaBorne), + BatterieHc::Aucune, hphcT4(), BorneHc::Monophasee); + QVERIFY2(s.arb && s.borne, quoi); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 16}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + s.borne->setStateValue("pluggedIn", true); + const auto phasesAveugles = [&]() { + for (const char *ph : {"A", "B", "C"}) { + s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 2.0); + s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 460.0); + } + return 1380.0 + (totalVoitLaBorne ? s.borne->stateValue("currentPower").toDouble() : 0.0); + }; + QList consignes; + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); + for (int k = 0; k < 6; ++k, a = a.addSecs(360)) { + cycleHc(s, a, phasesAveugles()); + consignes << consigneA(s.borne); + } + qInfo().noquote() << "[compteur aveugle]" << quoi << ":" << consignes; + // 16 − 2 − 1 = 13 A à l'arrêt ; puis la borne tire 13 A que la phase ne montre pas, et la + // place rendue plafonne à 16 − 0 − 1 = 15 A — surplus fabriqué ou non. + QVERIFY2(consignes == (QList{13, 15, 15, 15, 15, 15}), quoi); + } +#endif +} + +void Simulation::testOffPeakFullPowerAtDawnStaysInTheCeiling() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // L'AUBE EN HEURES CREUSES (écart 4, décision du 2026-10-25). Le niveau éco d'une borne y vaut + // son plafond de cycle, qui porte la limite de phase. Le surplus du matin s'y AJOUTAIT, et + // seul le courant maximal de la borne écrêtait la somme : la limite de phase était perdue. + // + // Le cas rare du lot e2 : borne monophasée sur la phase A, limite 16 A, maison à 2 A sur A ; + // les phases B et C exportent 1 500 W chacune. Plafond : 16 − 2 − 1 = 13 A (2 990 W). Borne à + // 13 A, le compteur importe 460 + 2 990 − 3 000 = 450 W ; le surplus recrédité vaut 2 540 W. + // Avant : 2 990 + 2 540 W, soit 24 A demandés, 16 A servis, la phase A à 18 A pour 16. + // Après : 13 A, dont 2 540 W de surplus et 450 W achetés — ce que le compteur importe. + FuseauDeTest fuseau("Europe/Paris"); + ScenarioHc s = preparerScenarioHc(QStringLiteral("aube"), BatterieHc::Aucune, hphcT4(), + BorneHc::Monophasee); + QVERIFY(s.arb && s.borne); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 16}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + s.borne->setStateValue("pluggedIn", true); + const QString borne = s.borneId.toString(); + + //! Le compteur voit la borne sur A ; B et C portent chacune \p exportPhaseW d'export. + const auto compteur = [&](double exportPhaseW) { + const double a = 2.0 + s.borne->stateValue("currentPhaseA").toDouble(); + s.meter->setStateValue("currentPhaseA", a); + s.meter->setStateValue("currentPowerPhaseA", a * 230); + for (const char *ph : {"B", "C"}) { + s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(ph), -exportPhaseW / 230); + s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), -exportPhaseW); + } + return a * 230 - 2 * exportPhaseW; + }; + //! La part d'un niveau, et son motif. + const auto niveau = [&](const QVariantMap &snap, const QString &quel) { + for (const QVariant &n : telemetrieParId(snap).value(borne).value("levels").toList()) + if (n.toMap().value("level").toString() == quel) + return n.toMap(); + return QVariantMap(); + }; + + struct Etape { const char *quoi; double exportPhaseW; double achatW; double surplusW; }; + const QList etapes = { + // Témoin de nuit : rien à partager, tout est acheté — comme avant. + {"nuit", 0, 2990, 0}, + // L'aube : le surplus finance 2 540 W, l'achat cède d'autant. + {"aube", 1500, 450, 2540}, + // Plein soleil : le surplus dépasse le plafond. Rien n'est acheté, la borne reste à 13 A. + {"plein soleil", 3000, 0, 2990}, + }; + QDateTime a = utcDateTime(QDate(2026, 6, 9), QTime(2, 30)); // 04:30 à Paris + for (const Etape &e : etapes) { + QVariantMap snap; + QStringList trace; + for (int k = 0; k < 4; ++k) { // quatre cycles : la consigne se pose et TIENT + a = a.addSecs(360); + snap = cycleHc(s, a, compteur(e.exportPhaseW)); + const double importW = compteur(e.exportPhaseW); // ce que la consigne produit + const QVariantMap eco = niveau(snap, "eco"); + const QVariantMap confort = niveau(snap, "comfort"); + trace << QStringLiteral("%1 A, achat %2 W, surplus %3 W, import %4 W, phase A %5 A") + .arg(consigneA(s.borne)).arg(eco.value("targetW").toDouble()) + .arg(confort.value("targetW").toDouble()).arg(importW) + .arg(s.meter->stateValue("currentPhaseA").toDouble()); + const QByteArray ctx = QStringLiteral("%1 — %2").arg(e.quoi, trace.join(" ; ")).toUtf8(); + + // LA CONSIGNE ≤ LE PLAFOND, et la phase sous la limite, avec la marge. + QVERIFY2(consigneA(s.borne) == 13, ctx.constData()); + QVERIFY2(s.meter->stateValue("currentPhaseA").toDouble() <= 15 + 1e-6, ctx.constData()); + QVERIFY2(qRound(telemetrieParId(snap).value(borne).value("allocatedW").toDouble()) == 2990, + ctx.constData()); + + // LE PARTAGE : la somme vaut le plafond, l'achat ce qui reste à acheter — et jamais plus + // que ce que le compteur importe une fois la consigne posée. + QVERIFY2(qRound(eco.value("targetW").toDouble()) == qRound(e.achatW), ctx.constData()); + QVERIFY2(qRound(confort.value("targetW").toDouble()) == qRound(e.surplusW), ctx.constData()); + QVERIFY2(eco.value("targetW").toDouble() <= qMax(0.0, importW) + 0.5, ctx.constData()); + QVERIFY2(qRound(eco.value("counts").toMap().value(CountKey::DrawCommitted).toDouble()) + == qRound(e.achatW), ctx.constData()); + QVERIFY2(qRound(snap.value("draw").toMap().value("committedW").toDouble()) == qRound(e.achatW), + ctx.constData()); + const QVariantMap motifEco = eco.value("decision").toMap(); + QVERIFY2(motifEco.value("code").toString() == QString(DecisionCode::EvOffPeakFullPower), + ctx.constData()); + QVERIFY2(motifEco.value("params").toMap().value("gridW").toInt() == qRound(e.achatW), + ctx.constData()); + QVERIFY2(motifEco.value("params").toMap().value("boundBy").toString() == QStringLiteral("phaseLimit"), + ctx.constData()); + } + qInfo().noquote() << "[aube]" << e.quoi << ":" << trace.join(QStringLiteral(" ; ")); + } +#endif +} + +void Simulation::testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // LA GARDE DE BASCULE (question 3 du lot e2, suite). Pendant une bascule, la borne tire sur + // une phase et vise les trois : rendre son courant sur la phase où elle le tire ne dit rien + // des deux autres. Borne triphasée à bascule, encore en monophasé à 10 A sur A, limite 25 A ; + // la maison tire 2 A sur A et C, 12 A sur B. Au compteur : A = 12, B = 12, C = 2 A. + // + // Avec la garde, rien n'est rendu : 25 − 12 − 1 = 12 A sur trois phases, et B finit à 24 A. + // Rendre les 10 A quand même (sur A seule, ou répartis sur les trois) donnerait 15 A, et B + // finirait à 27 A pour 25 — une place prise sur une phase que la borne n'occupait pas. + FuseauDeTest fuseau("Europe/Paris"); + ScenarioHc s = preparerScenarioHc(QStringLiteral("garde-bascule"), BatterieHc::Aucune, hphcT4(), + BorneHc::Bascule); + QVERIFY(s.arb && s.borne); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + const QString borne = s.borneId.toString(); + const QList maisonA = {2, 12, 2}; + //! Le compteur voit la borne : la maison, plus ce que la borne tire, phase par phase. + const auto compteur = [&]() { + double totalW = 0; + const QStringList phases = {"A", "B", "C"}; + for (int i = 0; i < 3; ++i) { + const double a = maisonA.at(i) + + s.borne->stateValue(QStringLiteral("currentPhase%1").arg(phases.at(i))).toDouble(); + s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(phases.at(i)), a); + s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(phases.at(i)), a * 230); + totalW += a * 230; + } + return totalW; + }; + + // La borne charge en monophasé à 10 A : mesurée sur A seule, une phase, quand le cycle en vise trois. + s.borne->setStateValue("pluggedIn", true); + s.borne->setStateValue("power", true); + s.borne->setStateValue("charging", true); + s.borne->setStateValue("desiredPhaseCount", 1); + s.borne->setStateValue("usedPhases", QStringLiteral("A")); + s.borne->setStateValue("phaseCount", 1); + s.borne->setStateValue("maxChargingCurrent", 10); + s.borne->setStateValue("currentPhaseA", 10.0); + s.borne->setStateValue("currentPowerPhaseA", 2300.0); + for (const char *ph : {"B", "C"}) { + s.borne->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 0.0); + s.borne->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 0.0); + } + s.borne->setStateValue("currentPower", 2300.0); + + const QVariantMap snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(21, 0)), compteur()); + const QVariantMap d = telemetrieParId(snap).value(borne).value("decision").toMap(); + compteur(); // ce que la consigne produit, sur les trois phases + const QString trace = QStringLiteral("phases %1, consigne %2 A, retenu %3 W, au compteur A %4 B %5 C %6 A") + .arg(s.borne->stateValue("desiredPhaseCount").toUInt()).arg(consigneA(s.borne)) + .arg(d.value("params").toMap().value("gridW").toInt()) + .arg(s.meter->stateValue("currentPhaseA").toDouble()) + .arg(s.meter->stateValue("currentPhaseB").toDouble()) + .arg(s.meter->stateValue("currentPhaseC").toDouble()); + qInfo().noquote() << "[garde de bascule]" << trace; + + QVERIFY2(d.value("code").toString() == QString(DecisionCode::EvOffPeakFullPower), qUtf8Printable(trace)); + QVERIFY2(s.borne->stateValue("desiredPhaseCount").toUInt() == 3u, qUtf8Printable(trace)); + QVERIFY2(d.value("params").toMap().value("gridW").toInt() == 12 * 690, qUtf8Printable(trace)); + QVERIFY2(consigneA(s.borne) == 12, qUtf8Printable(trace)); + for (const char *ph : {"A", "B", "C"}) + QVERIFY2(s.meter->stateValue(QStringLiteral("currentPhase%1").arg(ph)).toDouble() <= 24 + 1e-6, + qUtf8Printable(trace)); +#endif +} + +// ============================================================================================ +// Lot délestage visible (2026-10-25) — la protection PUBLIE ce qu'elle fait, elle ne décide rien +// de plus. Question de Patrick : « Si le délestage coupe une charge, est-ce que le client voit une +// alerte quelque part ? » Avant ce lot : non. +// ============================================================================================ + +namespace { + +//! Les événements du journal des délestages, par la frontière RPC — comme l'app les lit. +QVariantList evenementsDelestage(const QVariant &reponse) +{ + return reponse.toMap().value("params").toMap().value("events").toList(); +} + +//! Un compteur aux trois phases posées à la main (A), les deux autres à 2 A. +void poserPhaseA(Thing *compteur, double amperesA) +{ + compteur->setStateValue("currentPhaseA", amperesA); + compteur->setStateValue("currentPowerPhaseA", amperesA * 230); + for (const char *ph : {"B", "C"}) { + compteur->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 2.0); + compteur->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 460.0); + } + compteur->setStateValue("currentPower", (amperesA + 4) * 230); +} + +} // namespace + +void Simulation::testOverloadProtectionReasonOnTheCharger() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // Une borne monophasée 16 A sur la phase A, en « éco avec minimum », servie à pleine puissance + // en heures creuses ; limite de branchement 25 A. Le compteur VOIT la borne. La protection de + // surcharge de l'amont la rabaisse, puis la coupe : avant ce lot, rien n'en sortait. + FuseauDeTest fuseau("Europe/Paris"); + ScenarioHc s = preparerScenarioHc(QStringLiteral("protection"), BatterieHc::Aucune, hphcT4(), + BorneHc::Monophasee); + QVERIFY(s.arb && s.borne); + const QString borne = s.borneId.toString(); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + s.borne->setStateValue("pluggedIn", true); + + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); // 23:00 à Paris : heures creuses + QVariantMap snap; + const auto cycle = [&](double maisonA) { + a = a.addSecs(360); // au-delà des verrous de la borne + snap = cycleHc(s, a, compteurVoitLaBorne(s.meter, s.borne, maisonA)); + }; + const auto entree = [&]() { return telemetrieParId(snap).value(borne); }; + const auto decision = [&]() { return entree().value("decision").toMap(); }; + const auto journal = [&]() { return evenementsDelestage(injectAndWait("NymeaEnergy.GetSheddingLog")); }; + + // 0. TÉMOIN — maison à 2 A : la borne à pleine puissance, aucun délestage. Le bloc `shedding` + // est ABSENT, et la télémétrie est exactement celle d'avant ce lot. + cycle(2); + cycle(2); + QCOMPARE(consigneA(s.borne), 16); + QCOMPARE(decision().value("code").toString(), QString(DecisionCode::EvOffPeakFullPower)); + QVERIFY2(!snap.contains("shedding"), "aucun délestage : le bloc doit être absent, pas à zéro"); + QVERIFY(journal().isEmpty()); + + // 1. RABAISSÉE — un four s'allume : 12 A de maison, 28 A sur la phase A pour 25. La + // protection rabaisse la borne du dépassement arrondi à l'ampère supérieur. + QSignalSpy notifs(s.arb, &EnergyArbitrator::loadTelemetryChanged); + cycle(12); + const int tenusA = consigneA(s.borne); + QVERIFY2(tenusA > 0 && tenusA < 16, qUtf8Printable(QString::number(tenusA))); + QCOMPARE(decision().value("code").toString(), QString(DecisionCode::OverloadThrottled)); + QVariantMap p = decision().value("params").toMap(); + QCOMPARE(p.value("fromA").toInt(), 16); + QCOMPARE(p.value("toA").toInt(), tenusA); // ce que la borne a réellement reçu + QCOMPARE(p.value("cut").toBool(), false); + QCOMPARE(p.value("phase").toString(), QStringLiteral("A")); + QCOMPARE(p.value("limitA").toInt(), 25); + QCOMPARE(p.value("overshootW").toInt(), 690); // (28 − 25) × 230 + // Préséance L4 : l'action entière est celle de la protection — sans niveaux, et l'allocation + // dit ce qu'elle laisse à la borne. + QVERIFY(!entree().contains("levels")); + QVERIFY(!decision().contains("level")); + QCOMPARE(qRound(entree().value("allocatedW").toDouble()), tenusA * 230); + QCOMPARE(renderFr(DecisionReason(DecisionCode::OverloadThrottled, p), "Borne"), + QStringLiteral("Borne abaissée à %1 A : le branchement atteignait sa limite (phase A, " + "25 A), dépassement de 690 W").arg(tenusA)); + // La notification est partie, et elle porte le résumé du journal. + QVERIFY(!notifs.isEmpty()); + QVariantMap resume = notifs.last().first().toMap().value("shedding").toMap(); + QCOMPARE(resume.value("open").toStringList(), QStringList{borne}); + QVERIFY(resume.value("revision").toInt() > 0); + QVariantList evts = journal(); + QCOMPARE(evts.size(), 1); + QVariantMap e = evts.first().toMap(); + QCOMPARE(e.value("loadId").toString(), borne); + QVERIFY(!e.contains("endedAt")); + QCOMPARE(e.value("source").toString(), QStringLiteral("connection")); + QCOMPARE(e.value("phase").toString(), QStringLiteral("A")); + QCOMPARE(e.value("before").toMap().value("mechanism").toMap().value("kind").toString(), + QStringLiteral("evcharger")); + QCOMPARE(e.value("before").toMap().value("mechanism").toMap().value("currentA").toInt(), 16); + QCOMPARE(e.value("before").toMap().value("powerW").toInt(), 16 * 230); + QCOMPARE(e.value("after").toMap().value("mechanism").toMap().value("currentA").toInt(), tenusA); + QCOMPARE(e.value("cut").toBool(), false); + const QString debut = e.value("startedAt").toString(); + + // 2. COUPÉE — la maison monte à 22 A : même au plancher de 6 A, la phase dépasse. La + // protection coupe, et c'est TOUJOURS le même épisode. + cycle(22); + QCOMPARE(consigneA(s.borne), 0); + QCOMPARE(decision().value("code").toString(), QString(DecisionCode::OverloadThrottled)); + p = decision().value("params").toMap(); + QCOMPARE(p.value("cut").toBool(), true); + QCOMPARE(p.value("toA").toInt(), 0); + QCOMPARE(p.value("fromA").toInt(), 16); + QCOMPARE(qRound(entree().value("allocatedW").toDouble()), 0); + const QString coupee = renderFr(DecisionReason(DecisionCode::OverloadThrottled, p), "Borne"); + QVERIFY2(coupee.startsWith(QStringLiteral("Borne coupée : le branchement atteignait sa limite " + "(phase A, 25 A)")), qUtf8Printable(coupee)); + evts = journal(); + QCOMPARE(evts.size(), 1); + e = evts.first().toMap(); + QCOMPARE(e.value("startedAt").toString(), debut); + QCOMPARE(e.value("cut").toBool(), true); + QCOMPARE(e.value("after").toMap().value("powerW").toInt(), 0); + QCOMPARE(e.value("after").toMap().value("mechanism").toMap().value("chargingEnabled").toBool(), false); + QVERIFY(!e.contains("endedAt")); + + // 3. RETOUR — le four s'éteint. Le cycle reprend la borne : le motif se lève AU MÊME CYCLE. + // L'épisode, lui, n'est clos qu'après un cycle complet sans protection — daté de la reprise. + cycle(2); + const QDateTime reprise = a; + QCOMPARE(consigneA(s.borne), 16); + QCOMPARE(decision().value("code").toString(), QString(DecisionCode::EvOffPeakFullPower)); + QVERIFY(entree().contains("levels")); + QVERIFY(!journal().first().toMap().contains("endedAt")); + + notifs.clear(); + const int revisionAvant = snap.value("shedding").toMap().value("revision").toInt(); + cycle(2); + evts = journal(); + QCOMPARE(evts.size(), 1); // UN épisode, pas un par cycle + e = evts.first().toMap(); + QCOMPARE(e.value("startedAt").toString(), debut); + QCOMPARE(e.value("endedAt").toString(), reprise.toUTC().toString(Qt::ISODate)); + QVERIFY(snap.value("shedding").toMap().value("open").toStringList().isEmpty()); + QCOMPARE(snap.value("shedding").toMap().value("revision").toInt(), revisionAvant + 1); + // 4. LA FERMETURE NOTIFIE, alors que rien d'autre ne change : même motif, même allocation. + QCOMPARE(notifs.size(), 1); + QVERIFY(notifs.first().first().toMap().value("shedding").toMap().value("open").toStringList().isEmpty()); + // Contre-épreuve : le même cycle, rejoué, ne notifie RIEN. La notification précédente + // venait donc de la fermeture seule. + notifs.clear(); + cycle(2); + QCOMPARE(notifs.size(), 0); +#endif +} + +void Simulation::testOverloadProtectionOnAManualChargerIsLogged() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // Une borne en mode MANUEL est hors de l'arbitrage, donc hors de loads[] : seule la protection + // de surcharge la touche encore, et elle peut la rabaisser la nuit sans que rien ne le dise. + // Ici le journal le dit, et la notification part même si rien d'autre ne change. + ScenarioHc s = preparerScenarioHc(QStringLiteral("manuel"), BatterieHc::Aucune, QVariantList(), + BorneHc::Monophasee); + QVERIFY(s.arb && s.borne); + const QString borne = s.borneId.toString(); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + // Ce que l'usager a réglé à la main — c'est ce que la reprise lui rendra. + { + EnergySettings reglages; + reglages.beginGroup("ChargingInfos"); + reglages.beginGroup(borne); + reglages.setValue("manualChargingEnabled", true); + reglages.setValue("manualMaxChargingCurrent", 16); + reglages.setValue("manualDesiredPhaseCount", 1); + reglages.endGroup(); + reglages.endGroup(); + } + s.borne->setStateValue("pluggedIn", true); + // La borne est DÉJÀ dans l'état manuel : le passage en mode manuel n'a rien à restituer, donc + // n'écrit rien. Sinon le code amont écrirait à l'horloge murale et armerait les verrous de la + // borne sur cet instant — un scénario daté en dur se déroulerait « avant » eux, et la + // protection serait refusée à chaque geste. + s.borne->setStateValue("power", true); + s.borne->setStateValue("maxChargingCurrent", 16); + const QVariantMap info = injectAndWait("NymeaEnergy.GetChargingInfos").toMap().value("params").toMap() + .value("chargingInfos").toList().first().toMap(); + QVariantMap manuel = info; + manuel.insert("chargingMode", "ChargingModeNormal"); + QCOMPARE(injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", manuel}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + QCOMPARE(consigneA(s.borne), 16); + + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(10, 0)); + QVariantMap snap; + const auto cycle = [&](double phaseA) { + a = a.addSecs(60); + poserPhaseA(s.meter, phaseA); + s.arb->recordMeterUpdate(a); + s.arb->simulationCallUpdate(a); + QCoreApplication::processEvents(); + snap = s.arb->loadTelemetry(); + }; + const auto journal = [&]() { return evenementsDelestage(injectAndWait("NymeaEnergy.GetSheddingLog")); }; + + cycle(18); // 2 A de maison + 16 A de borne + QVERIFY(!telemetrieParId(snap).contains(borne)); // hors arbitrage : hors de loads[] + QVERIFY(!snap.contains("shedding")); + + // 1. 28 A sur la phase A : la protection rabaisse la borne, deux fois dans l'appel (dans le + // cycle, puis sur powerBalanceChanged — le compteur n'a pas encore bougé). + QSignalSpy notifs(s.arb, &EnergyArbitrator::loadTelemetryChanged); + cycle(28); + const int tenusA = consigneA(s.borne); + QVERIFY2(tenusA > 0 && tenusA < 16, qUtf8Printable(QString::number(tenusA))); + QVERIFY(!telemetrieParId(snap).contains(borne)); // toujours hors de loads[] + QCOMPARE(snap.value("shedding").toMap().value("open").toStringList(), QStringList{borne}); + // L'OUVERTURE NOTIFIE, et rien d'autre ne pouvait le faire : la borne n'est pas dans loads[]. + QVERIFY(!notifs.isEmpty()); + QCOMPARE(notifs.last().first().toMap().value("shedding").toMap().value("open").toStringList(), + QStringList{borne}); + QVariantList evts = journal(); + QCOMPARE(evts.size(), 1); + QVariantMap e = evts.first().toMap(); + QCOMPARE(e.value("before").toMap().value("mechanism").toMap().value("currentA").toInt(), 16); + QCOMPARE(e.value("after").toMap().value("mechanism").toMap().value("currentA").toInt(), tenusA); + QCOMPARE(e.value("phase").toString(), QStringLiteral("A")); + QCOMPARE(e.value("overshootW").toInt(), 690); + QVERIFY(!e.contains("endedAt")); + + // 2. La maison redescend : verifyOverloadProtectionRecovery() rend les 16 A manuels. La tenue + // est levée ; l'épisode se clôt au cycle complet suivant, daté de la reprise. + cycle(2 + tenusA); + const QDateTime reprise = a; + QCOMPARE(consigneA(s.borne), 16); + notifs.clear(); + cycle(18); + e = journal().first().toMap(); + QCOMPARE(e.value("endedAt").toString(), reprise.toUTC().toString(Qt::ISODate)); + QVERIFY(snap.value("shedding").toMap().value("open").toStringList().isEmpty()); + // LA FERMETURE NOTIFIE, seule cause possible : la borne n'est pas publiée dans loads[]. + QCOMPARE(notifs.size(), 1); + notifs.clear(); + cycle(18); + QCOMPARE(notifs.size(), 0); // contre-épreuve : rien ne change, rien ne part + QCOMPARE(journal().size(), 1); +#endif +} + +void Simulation::testSheddingLogCoversEveryMechanism() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // Deux mécanismes que L4 déleste de deux façons : un chauffe-eau à relais (palier en watts, + // coupé) et une PAC SG-Ready (état, rendue à 2 — jamais coupée). Le journal les enregistre + // sans connaître leur type : chacun dit « avant » et « après » dans la charge utile que son + // adaptateur publie. Puis le dépassement dure 20 minutes : UNE entrée par charge. + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-journal-delestage.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + ThingManager *tm = NymeaCore::instance()->thingManager(); + const QUuid meterId = addMeter(); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + Thing *meter = tm->findConfiguredThing(meterId); + QVERIFY(meter); + meter->setStateValue("connected", true); + + const QUuid k1 = addPowerSwitch(0, 26694); + const QUuid k2 = addPowerSwitch(0, 26695); + const QUuid ecsRelais = addPowerSwitch(2000, 26696); + QVERIFY(tm->findConfiguredThing(k1) && tm->findConfiguredThing(k2) && tm->findConfiguredThing(ecsRelais)); + + LoadConfigStore *store = new LoadConfigStore(arb); + arb->setLoadConfigStore(store); + LoadConfigs cfgs; + cfgs.append(LoadConfig::fromMap(QVariantMap{ + {"id", "pac-delestee"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"}, + {"priority", 2}, {"enabled", true}, {"domain", "hvac"}, + {"sgReady", QVariantMap{ + {"minStateHoldS", 300}, + {"states", QVariantList() + << QVariantMap{{"state", 1}, {"relays", QVariantList() << k1.toString()}, {"estimatedPowerW", 0.0}} + << QVariantMap{{"state", 2}, {"relays", QVariantList()}, {"estimatedPowerW", 0.0}} + << QVariantMap{{"state", 3}, {"relays", QVariantList() << k2.toString()}, {"estimatedPowerW", 800.0}} + << QVariantMap{{"state", 4}, {"relays", QVariantList() << k1.toString() << k2.toString()}, + {"estimatedPowerW", 3000.0}}}}}})); + cfgs.append(LoadConfig::fromMap(QVariantMap{ + {"id", "ecs-delestee"}, {"label", "Chauffe-eau"}, {"adapter", "relay-router"}, + {"mode", "fixed"}, {"priority", 3}, {"enabled", true}, {"domain", "ecs"}, + {"relays", QVariantList() << QVariantMap{{"thingId", ecsRelais.toString()}, {"powerW", 2000}}}, + {"minOnS", 0}, {"minOffS", 0}})); + QString err; + QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8()); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + + QVariantMap snap; + const auto cycle = [&](const QDateTime &at, double phaseA, double totalW) { + meter->setStateValue("currentPhaseA", phaseA); + meter->setStateValue("currentPhaseB", 0); + meter->setStateValue("currentPhaseC", 0); + meter->setStateValue("currentPower", totalW); + arb->simulationCallUpdate(at); + QTest::qWait(20); + QCoreApplication::processEvents(); + snap = arb->loadTelemetry(); + return telemetrieParId(snap); + }; + const auto journal = [&]() { return evenementsDelestage(injectAndWait("NymeaEnergy.GetSheddingLog")); }; + const auto parCharge = [&]() { + QHash h; + for (const QVariant &v : journal()) + h.insert(v.toMap().value("loadId").toString(), v.toMap()); + return h; + }; + + // Le surplus porte les deux charges : la PAC en état 4 (après son armement à froid), le + // chauffe-eau à 2000 W. + const QDateTime t0 = utcDateTime(QDate(2026, 8, 30), QTime(13, 0, 0)); + cycle(t0, 0, -6000); + QHash tele = cycle(t0.addSecs(3600), 0, -6000); + QCOMPARE(tele.value("pac-delestee").value("mechanism").toMap().value("state").toInt(), 4); + QCOMPARE(qRound(tele.value("ecs-delestee").value("mechanism").toMap().value("stageW").toDouble()), 2000); + QVERIFY(!snap.contains("shedding")); + + // DÉPASSEMENT de 2 300 W sur la phase A (35 A pour 25) : L4 déleste en ordre inverse du rang, + // le chauffe-eau (rang 3, 2 000 W) puis la PAC (rang 2, 3 000 W). + QSignalSpy notifs(arb, &EnergyArbitrator::loadTelemetryChanged); + const QDateTime t2 = t0.addSecs(3610); + tele = cycle(t2, 35, 8050); + QCOMPARE(tele.value("ecs-delestee").value("decision").toMap().value("code").toString(), QString(DecisionCode::DrawCap)); + QCOMPARE(tele.value("pac-delestee").value("decision").toMap().value("code").toString(), QString(DecisionCode::DrawCap)); + QCOMPARE(snap.value("shedding").toMap().value("open").toStringList(), + (QStringList{"ecs-delestee", "pac-delestee"})); + QVERIFY(!notifs.isEmpty()); + QCOMPARE(notifs.last().first().toMap().value("shedding").toMap().value("open").toStringList(), + (QStringList{"ecs-delestee", "pac-delestee"})); + + QHash j = parCharge(); + QCOMPARE(j.size(), 2); + // Le chauffe-eau : un PALIER, en watts, COUPÉ. + const QVariantMap ecs = j.value("ecs-delestee"); + QCOMPARE(ecs.value("before").toMap().value("mechanism").toMap().value("kind").toString(), QStringLiteral("relay")); + QCOMPARE(qRound(ecs.value("before").toMap().value("mechanism").toMap().value("stageW").toDouble()), 2000); + QCOMPARE(qRound(ecs.value("after").toMap().value("mechanism").toMap().value("stageW").toDouble()), 0); + QCOMPARE(ecs.value("before").toMap().value("powerW").toInt(), 2000); + QCOMPARE(ecs.value("after").toMap().value("powerW").toInt(), 0); + QCOMPARE(ecs.value("cut").toBool(), true); + // La PAC : un ÉTAT, de 4 à 2 — rendue à son thermostat, pas coupée (ECS-413). + const QVariantMap pac = j.value("pac-delestee"); + QCOMPARE(pac.value("before").toMap().value("mechanism").toMap().value("kind").toString(), QStringLiteral("sgReady")); + QCOMPARE(pac.value("before").toMap().value("mechanism").toMap().value("state").toInt(), 4); + QCOMPARE(pac.value("after").toMap().value("mechanism").toMap().value("state").toInt(), 2); + QCOMPARE(pac.value("before").toMap().value("powerW").toInt(), 3000); + QCOMPARE(pac.value("cut").toBool(), false); + for (const QVariantMap &ev : {ecs, pac}) { + QCOMPARE(ev.value("source").toString(), QStringLiteral("connection")); + QCOMPARE(ev.value("phase").toString(), QStringLiteral("A")); + QCOMPARE(ev.value("overshootW").toInt(), 2300); + QCOMPARE(ev.value("startedAt").toString(), t2.toUTC().toString(Qt::ISODate)); + QVERIFY(!ev.contains("endedAt")); + } + + // LE DÉPASSEMENT DURE 20 MINUTES — un cycle par minute. Plus rien à délester (tout est à 0), + // et la cascade ne rallume rien sous un plafond épuisé : les deux épisodes restent OUVERTS, + // et il n'en naît aucun autre. + QDateTime t = t2; + for (int k = 0; k < 20; ++k) { + t = t.addSecs(60); + cycle(t, 35, 8050); + } + j = parCharge(); + QCOMPARE(journal().size(), 2); + QVERIFY(!j.value("ecs-delestee").contains("endedAt")); + QVERIFY(!j.value("pac-delestee").contains("endedAt")); + + // Le dépassement cesse : le plafond ne tient plus personne. Les deux épisodes se ferment à la + // fin de ce cycle, datés de lui — 21 minutes, une entrée chacun. + notifs.clear(); + const QDateTime fin = t.addSecs(60); + cycle(fin, 0, -6000); + j = parCharge(); + QCOMPARE(journal().size(), 2); + for (const QString &id : {QStringLiteral("ecs-delestee"), QStringLiteral("pac-delestee")}) { + QCOMPARE(j.value(id).value("startedAt").toString(), t2.toUTC().toString(Qt::ISODate)); + QCOMPARE(j.value(id).value("endedAt").toString(), fin.toUTC().toString(Qt::ISODate)); + } + QVERIFY(snap.value("shedding").toMap().value("open").toStringList().isEmpty()); + QVERIFY(!notifs.isEmpty()); + QVERIFY(notifs.last().first().toMap().value("shedding").toMap().value("open").toStringList().isEmpty()); +#endif +} + +void Simulation::testSheddingLogKeepsTheLastEpisodes() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // Vingt et un épisodes sur un chauffe-eau : un cycle en dépassement, un cycle libre. Le journal + // en garde vingt ; le plus ancien sort, et aucun identifiant n'est réutilisé. + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-journal-tournant.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + ThingManager *tm = NymeaCore::instance()->thingManager(); + const QUuid meterId = addMeter(); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + Thing *meter = tm->findConfiguredThing(meterId); + QVERIFY(meter); + meter->setStateValue("connected", true); + const QUuid relais = addPowerSwitch(2000, 26697); + QVERIFY(tm->findConfiguredThing(relais)); + + LoadConfigStore *store = new LoadConfigStore(arb); + arb->setLoadConfigStore(store); + QString err; + QVERIFY2(store->setConfigs(LoadConfigs() << LoadConfig::fromMap(QVariantMap{ + {"id", "ecs-tournant"}, {"label", "Chauffe-eau"}, {"adapter", "relay-router"}, + {"mode", "fixed"}, {"priority", 1}, {"enabled", true}, + {"relays", QVariantList() << QVariantMap{{"thingId", relais.toString()}, {"powerW", 2000}}}, + {"minOnS", 0}, {"minOffS", 0}}), &err), err.toUtf8()); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + + QDateTime t = utcDateTime(QDate(2026, 8, 30), QTime(13, 0, 0)); + const auto cycle = [&](double phaseA, double totalW) { + t = t.addSecs(60); + meter->setStateValue("currentPhaseA", phaseA); + meter->setStateValue("currentPhaseB", 0); + meter->setStateValue("currentPhaseC", 0); + meter->setStateValue("currentPower", totalW); + arb->simulationCallUpdate(t); + QCoreApplication::processEvents(); + }; + + cycle(0, -6000); // le chauffe-eau s'enclenche sur le surplus + QStringList debuts; + for (int n = 0; n < SheddingLog::DefaultCapacity + 1; ++n) { + cycle(30, 6900); // 1 150 W de trop : délesté + debuts << t.toUTC().toString(Qt::ISODate); + cycle(0, -6000); // libre : rallumé, l'épisode se ferme + } + const QVariantMap reponse = injectAndWait("NymeaEnergy.GetSheddingLog").toMap().value("params").toMap(); + const QVariantList evts = reponse.value("events").toList(); + QCOMPARE(reponse.value("capacity").toInt(), SheddingLog::DefaultCapacity); + QCOMPARE(evts.size(), SheddingLog::DefaultCapacity); + // Le PREMIER épisode est sorti : le plus ancien gardé est le deuxième. + QCOMPARE(evts.first().toMap().value("id").toInt(), 2); + QCOMPARE(evts.first().toMap().value("startedAt").toString(), debuts.at(1)); + QCOMPARE(evts.last().toMap().value("id").toInt(), SheddingLog::DefaultCapacity + 1); + QCOMPARE(evts.last().toMap().value("startedAt").toString(), debuts.last()); + for (const QVariant &v : evts) + QVERIFY2(v.toMap().contains("endedAt"), + qUtf8Printable(QJsonDocument::fromVariant(v).toJson(QJsonDocument::Compact))); +#endif +} diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index edfacb8..ac20465 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -27,6 +27,11 @@ #include "simulationbase.h" +#include + +class EnergyArbitrator; +struct EcritureBatterie; + using namespace nymeaserver; class ChargerPlugEvent @@ -52,6 +57,29 @@ public: typedef QList ChargerPlugEvents; typedef QList DetailsStepList; +private: + // ── Lot e1 — scénario commun ────────────────────────────────────────────────────────── + //! Quelle batterie pose le scénario. + enum class BatterieHc { Aucune, Pilotable, SansEtats, SansMinuteur }; + //! Le banc d'un scénario d'heures creuses, et le journal des écritures sur la batterie. + struct ScenarioHc { + EnergyArbitrator *arb = nullptr; + Thing *meter = nullptr; + Thing *borne = nullptr; + Thing *batterie = nullptr; + QUuid borneId; + std::shared_ptr> ecrites; //!< Lues à la frontière nymea. + std::shared_ptr cycleCourant; //!< Date les écritures. + }; + //! Quelle borne pose le scénario (lot e2) : 16 A, monophasée, triphasée, ou triphasée à + //! bascule de phases. La voiture suit : monophasée avec la première, triphasée sinon. + enum class BorneHc { Monophasee, Triphasee, Bascule }; + //! Serveur neuf, compteur, borne 16 A « Éco avec minimum » (voiture débranchée), batterie. + ScenarioHc preparerScenarioHc(const QString &nom, BatterieHc batterie, const QVariantList &tarif, + BorneHc borne = BorneHc::Monophasee); + //! Un cycle à l'instant \p a, compteur à \p compteurW (import > 0) ; rend la trame publiée. + QVariantMap cycleHc(ScenarioHc &s, const QDateTime &a, double compteurW); + private slots: void run_data(); void run(); @@ -232,6 +260,46 @@ private slots: void testTheSnapshotCarriesBaseloadAndPriceOnThePvGrid(); //! Brief 3 T4, §11 — un tarif et un profil n'ont AUCUN effet sur le plan. void testNoDecisionReadsBaseloadOrPrice(); + + // ── Lot e1 — heures creuses pour la borne, batterie protégée pendant sa charge ──────── + //! La journée de la démo : surplus à midi, attente des HC à 18 h, reprise à 22:00, décharge + //! bloquée tenue (minuteur réarmé) puis LEVÉE à la fin de charge ; jamais la charge réseau. + void testOffPeakDemoDay(); + //! 06:00 : la borne s'arrête et la batterie est rendue — jour de 24, 23 et 25 heures. + void testOffPeakEndsAtSixAndOnDstDays(); + //! Sans tarif, tarif unique, zones au même prix : le moteur est etm74 (minimum, ni bloc ni écriture). + void testWithoutOffPeakTheEngineIsEtm74(); + //! Batterie sans états de commande, sans minuteur, absente : lue comme avant, motif publié. + void testABatteryWithoutControlIsReadAsBefore(); + //! Le soutirage que le blocage ajoute se MESURE au cycle suivant ; L4 et le plafond décident. + void testBlockedDischargeShowsAtTheMeterAndL4Decides(); + //! L2 : compteur muet, la batterie est levée explicitement, la trame du repli le dit. + void testAMeterSilenceHandsTheBatteryBack(); + //! Onduleur qui REFUSE : poses espacées (1 → 15 min), un avertissement par série, `held` faux ; + //! une pose acceptée rend le rythme normal ; la levée n'attend jamais. + void testRefusedWritesAreSpacedAndNeverDelayTheRelease(); + //! Lot e2 — en heures creuses, la borne reçoit son PLAFOND (3 680 W en mono, 11 040 W en tri) ; + //! hors heures creuses inchangé ; voiture pleine sous 230 W : la batterie est rendue. + void testOffPeakChargesAtFullPower(); + //! Lot e2 — borne à bascule : en heures creuses, trois phases même quand le compteur lit 0. + void testOffPeakFullPowerSwitchesToThreePhases(); + //! Lot e2 — limite de phase, compteur qui voit la borne : la borne est réduite, jamais sous son + //! plancher par la stratégie ; la marge d'une phase ne bride pas une triphasée (LM-1500-h) ; le + //! courant rendu est le MESURÉ ; en dépassement la consigne baisse ; L4 coupe au dépassement. + void testOffPeakFullPowerYieldsToThePhaseLimit(); + //! Lot e2, suite — compteur qui voit la borne, maison à 2 A : la consigne se pose sans + //! alterner (mono et tri, limites 25 et 32 A), et sous une limite de 16 A elle se pose dessous, + //! à l'ampère inférieur. + void testOffPeakFullPowerSettlesUnderThePhaseLimit(); + //! Lot e2, suite — compteur qui ne voit pas la borne : le retour de son courant ne la mène pas + //! seule à la limite (maison sans la borne écrêtée à zéro) ; total aveugle aussi : le surplus + //! fabriqué ne la porte plus au-delà du plafond (15 A, jamais 16). + void testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter(); + //! Lot e2, suite (écart 4) — à l'aube en heures creuses, le surplus prend la place de l'achat + //! dans le plafond, au lieu de s'y ajouter : consigne ≤ plafond, achat ≤ import du compteur. + void testOffPeakFullPowerAtDawnStaysInTheCeiling(); + //! Lot e2, suite — pendant une bascule de phases, le plafond ne rend rien du courant de la borne. + void testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases(); //! TOUTE méthode Get* du namespace rend ce que sa déclaration promet. void testEveryGetterReturnsWhatItDeclares(); //! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro. @@ -265,6 +333,19 @@ private slots: // relais fantôme comprise — et ce chiffre entrait dans le RECRÉDIT, donc dans le budget. void testVanishedRelayFreezesTheLoad(); + // ── Lot délestage visible (2026-10-25) ────────────────────────────────── + //! La borne abaissée puis coupée par la protection de surcharge le DIT (OVERLOAD_THROTTLED), + //! le motif se lève au retour du cycle, et l'épisode fait UNE entrée du journal. + void testOverloadProtectionReasonOnTheCharger(); + //! Une borne en mode manuel, hors de loads[] : l'épisode se lit au journal, s'ouvre et se + //! ferme par une notification qui n'a pas d'autre cause, et la reprise amont le referme. + void testOverloadProtectionOnAManualChargerIsLogged(); + //! Deux mécanismes délestés par L4 (relais, SG-Ready) : un épisode de 20 min = une entrée + //! par charge, avant / après dans le langage de chaque mécanisme. + void testSheddingLogCoversEveryMechanism(); + //! N + 1 épisodes : le plus ancien sort, les identifiants ne sont jamais réutilisés. + void testSheddingLogKeepsTheLastEpisodes(); + void printStates(Thing *thing); void updateChargerMeter(Thing *thing); diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp index d792aa1..e90a4ab 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp @@ -430,6 +430,16 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info) return; + } else if (thing->thingClassId() == controllableStorageThingClassId) { + // BATTERIE À LIMITE DE DÉCHARGE PILOTABLE (lot e1) — les états modifiables d'un + // `sunspecStorage` (modèle 124) : `enableDischarging` ACTIVE la limite, `dischargingRate` + // la fixe en % (OutWRte), et le paramètre `revertTime` porte le minuteur de retour. Le + // mock n'émule PAS l'onduleur : il range ce qu'on écrit, et le test lit les écritures + // au fil des actions exécutées (ThingManager::actionExecuted). Pas de contrôleur HTTP : + // les tests posent les états directement. + info->finish(Thing::ThingErrorNoError); + return; + } else if (thing->thingClassId() == dryRelayThingClassId) { // RELAIS SEC — il commute, il ne mesure pas. C'est toute sa raison d'être : le mock ne // savait produire aucune charge pilotable SANS puissance, si bien qu'une charge « non @@ -593,6 +603,22 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info) c->logActionExecuted(actionType, action); // Update states + if (thing->thingClassId() == controllableStorageThingClassId) { + // Chaque état modifiable prend la valeur écrite, rien d'autre : la batterie qui + // obéit. Une écriture de `gridCharging`, `enableCharging` ou `chargingRate` passe + // aussi — c'est au TEST de dire qu'elle n'aurait jamais dû partir. + // Propriété dynamique `refuserEcritures`, posée par le test : l'onduleur qui REFUSE + // (GEN24 sans « Allow Control », exception Modbus 0x04) — rien n'est appliqué. + if (thing->property("refuserEcritures").toBool()) { + info->finish(Thing::ThingErrorHardwareFailure); + return; + } + const QString nom = actionType.name(); + thing->setStateValue(nom, action.paramValue(actionType.paramTypes().findByName(nom).id())); + info->finish(Thing::ThingErrorNoError); + return; + } + if (thing->thingClassId() == dryRelayThingClassId) { if (actionType.name() == "power") { // Aucune écriture de `currentPower` : la classe ne porte pas l'état. C'est diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json index c3d2e6a..bdfd49b 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json @@ -1013,6 +1013,124 @@ } ] }, + { + "name": "controllableStorage", + "displayName": "Mocked energy storage with a discharge limit", + "id": "648e1a54-96c9-4f0f-9fc8-4326ab7c643e", + "createMethods": [ + "user" + ], + "interfaces": [ + "energystorage" + ], + "paramTypes": [ + { + "id": "6250973b-c0f5-4f2d-9bc9-b077898025de", + "name": "port", + "displayName": "Port", + "type": "uint", + "defaultValue": 26690 + }, + { + "id": "13b73424-5076-46b9-8eb8-38ee0078236e", + "name": "revertTime", + "displayName": "Revert time", + "type": "uint", + "unit": "Seconds", + "minValue": 0, + "maxValue": 28800, + "defaultValue": 300 + } + ], + "stateTypes": [ + { + "id": "f288ff92-0404-496b-a26b-5590b6896d76", + "name": "capacity", + "displayName": "Capacity", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 7.68 + }, + { + "id": "ca7fcade-1599-4e7d-a0d7-a70461c16493", + "name": "batteryLevel", + "displayName": "Battery level", + "displayNameAction": "Set battery level", + "type": "int", + "unit": "Percentage", + "minValue": 0, + "maxValue": 100, + "writable": true, + "defaultValue": 60 + }, + { + "id": "1f74f29f-55fa-420d-a2b3-4d2a53018dad", + "name": "currentPower", + "displayName": "Current power consumption", + "type": "double", + "unit": "Watt", + "defaultValue": 0 + }, + { + "id": "e1381991-e137-43c7-a75a-62ea97271dca", + "name": "batteryCritical", + "displayName": "Battery level critical", + "type": "bool", + "defaultValue": false + }, + { + "id": "ad1597dd-b1ff-454e-99e7-293425c64c6b", + "name": "enableCharging", + "displayName": "Charge limit active", + "displayNameAction": "Set charge limit active", + "type": "bool", + "writable": true, + "defaultValue": false + }, + { + "id": "11ba1548-03be-4232-a615-b8edbc564437", + "name": "enableDischarging", + "displayName": "Discharge limit active", + "displayNameAction": "Set discharge limit active", + "type": "bool", + "writable": true, + "defaultValue": false + }, + { + "id": "0c2d52b4-c117-477f-a13c-173bce8816a6", + "name": "chargingRate", + "displayName": "Charging rate", + "displayNameAction": "Set charging rate", + "type": "int", + "unit": "Percentage", + "minValue": -100, + "maxValue": 100, + "writable": true, + "defaultValue": 100 + }, + { + "id": "55ac7803-9fb4-4f4e-b38a-4af2b45eeb2e", + "name": "dischargingRate", + "displayName": "Discharging rate", + "displayNameAction": "Set discharging rate", + "type": "int", + "unit": "Percentage", + "minValue": -100, + "maxValue": 100, + "writable": true, + "defaultValue": 100 + }, + { + "id": "26c0e94e-fdb0-485d-afa3-ab157052d365", + "name": "gridCharging", + "displayName": "Grid charging", + "displayNameAction": "Set grid charging", + "type": "bool", + "writable": true, + "defaultValue": false + } + ] + }, { "name": "powerSwitch", "displayName": "Mocked Power Switch (relais ECS)",