Merge feature/hc-batterie dans release/etm75

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HE6X3TcKdiuCucBHA2XX4n
This commit is contained in:
Patrick Schurig 2026-10-25 11:47:34 +01:00
commit e449019390
35 changed files with 4692 additions and 27 deletions

View File

@ -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.

474
docs/DESIGN_HC_BATTERIE.md Normal file
View File

@ -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).

View File

@ -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`.

View File

@ -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.

View File

@ -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**

View File

@ -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();

View File

@ -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;

View File

@ -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 <integrations/thing.h>
#include <integrations/thingactioninfo.h>
#include <integrations/thingmanager.h>
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<SurplusContext::StorageControl> StorageDischargeGuard::capacites() const
{
QList<SurplusContext::StorageControl> 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<void()> &suite,
const std::function<void(const QString &, const QString &)> &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();
});
}

View File

@ -0,0 +1,151 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QObject>
#include <QDateTime>
#include <QHash>
#include <QList>
#include <QString>
#include <QVariantList>
#include <functional>
#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<SurplusContext::StorageControl> 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<void()> &suite = {},
const std::function<void(const QString &, const QString &)> &refus = {});
ThingManager *m_tm = nullptr;
QHash<QString, Etat> m_etats; //!< thingId → état de la commande.
};

View File

@ -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 <QJsonDocument>
#include <QFileInfo>
#include <nymeasettings.h>
#include <QScopeGuard>
#include <cmath>
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 &currentDateTime)
{
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 &currentDateTime)
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 &currentDateTime)
// 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<QString> 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 &currentDateTime)
{
// 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<uint>(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<QString, double> 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<DrawCap> 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<EnergyArbitrator *>(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<double> 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.

View File

@ -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 &currentDateTime) 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 &currentDateTime) 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<QString, EvAdapter *> 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<QString, LoadAction> 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<QString, BorneTenue> 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

View File

@ -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 \

View File

@ -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<QString, LoadAction::Level> 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<QString> 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;

View File

@ -3,6 +3,7 @@
#pragma once
#include <QObject>
#include <QSet>
#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<QString> 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;
};

View File

@ -205,6 +205,48 @@ int TariffConfig::zoneAt(const QDateTime &instant, const QTimeZone &fuseau) cons
return m_parMinute.at(murale.hour() * 60 + murale.minute());
}
QList<int> TariffConfig::zonesCreuses() const
{
QList<int> 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<int> creuses = zonesCreuses();
return !creuses.isEmpty() && creuses.contains(zoneAt(instant, fuseau));
}
QDateTime TariffConfig::prochaineBascule(const QDateTime &instant, const QTimeZone &fuseau) const
{
const QList<int> 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<double> TariffConfig::importSeries(const QDateTime &start, int stepS, int steps,
const QTimeZone &fuseau) const
{

View File

@ -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<int> 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.

View File

@ -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()

View File

@ -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";

View File

@ -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;
};
/*!

View File

@ -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().
*

View File

@ -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}};
}

View File

@ -0,0 +1,162 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QDateTime>
#include <QHash>
#include <QList>
#include <QString>
#include <QStringList>
#include <QVariantMap>
/*!
* \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<SheddingEvent> &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<SheddingEvent> m_evenements;
QHash<QString, Ouvert> m_ouverts;
};

View File

@ -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<LoadContext> 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> storageControl; //!< Une entrée par batterie \c energystorage.
};

View File

@ -30,6 +30,7 @@
#include <QTimeZone>
#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 &params)
return createReply(currentLoadTelemetry());
}
JsonReply *NymeaEnergyJsonHandler::GetSheddingLog(const QVariantMap &params)
{
Q_UNUSED(params)
#ifdef ETM_ARBITRATOR
if (EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(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 &params)
{
const QVariantMap succes{{"forecastPoints", QVariantList()},

View File

@ -119,6 +119,17 @@ public:
* notification seule le laisse dans un silence ambigu (famille ECS-411).
*/
Q_INVOKABLE JsonReply *GetLoadTelemetry(const QVariantMap &params);
/*!
* \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 &params);
//! \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.

View File

@ -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 &currentDateTime); // [ETM] private → protected
protected:
void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime &currentDateTime); // [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 &currentDateTime); // [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é.

View File

@ -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}})));

View File

@ -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)

View File

@ -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<int>{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<int>{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<int>{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;

View File

@ -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();

File diff suppressed because it is too large Load Diff

View File

@ -27,6 +27,11 @@
#include "simulationbase.h"
#include <memory>
class EnergyArbitrator;
struct EcritureBatterie;
using namespace nymeaserver;
class ChargerPlugEvent
@ -52,6 +57,29 @@ public:
typedef QList<ChargerPlugEvent> ChargerPlugEvents;
typedef QList<int> 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<QList<struct EcritureBatterie>> ecrites; //!< Lues à la frontière nymea.
std::shared_ptr<QDateTime> 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);

View File

@ -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

View File

@ -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)",