docs : heures creuses et batterie — design, sûreté L2, inventaire du mock (lot e1)

DESIGN_HC_BATTERIE.md : la règle des heures creuses, les modes de borne
concernés et le changement de comportement, la lecture du modèle SunSpec 124
(StorCtl_Mod = bit qui ACTIVE la limite, OutWRte en %, séquence NO_DISCHARGE
du plugin) qui fixe l'ordre des écritures, le minuteur de retour comme sûreté
(et l'hypothèse non levée du plugin sur le 124), l'invariant 8 et les règles de
réserve, ce qui est publié, les preuves, et ce que le lot ne fait pas.

SAFETY.md : le repli L2 lève la limite de décharge explicitement.
INVENTAIRE_MOCK.md : la classe controllableStorage, et ce qu'elle n'émule pas.
DESIGN_PREVISION_PV.md §32.4 : la décision e1 est prise, pointeur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-24 08:38:29 +02:00
parent d9372c14ba
commit 4ffa8391cf
4 changed files with 251 additions and 1 deletions

226
docs/DESIGN_HC_BATTERIE.md Normal file
View File

@ -0,0 +1,226 @@
# 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 plancher de la borne), 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 plancher de la borne, au moins |
| 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 |
| é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. La borne
déclare un plafond écrêté par `evPhaseAllowanceW()`, `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) : c'est une
proposition, pas ce lot.
**À 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 serait plus propre (§7) ; ce n'est pas
nécessaire pour ce lot.
### 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.
### 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.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).
- **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`). 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 |
| `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).
- 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` — à
proposer au dépôt du plugin.
- La batterie comme charge rangée du waterfall (lot 3f, `spec_loadmodel.md` §14).

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 **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 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. 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` | ✅ | ❌ | — | | `powerSwitch` | ✅ | ❌ | — |
| `etmVariableLoad` | ✅ *(`currentPowerW`)* | ❌ | — | | `etmVariableLoad` | ✅ *(`currentPowerW`)* | ❌ | — |
| `energyStorage` | ✅ | ❌ | — | | `energyStorage` | ✅ | ❌ | — |
| `controllableStorage` *(lot e1, 2026-10-24)* | ✅ | ❌ | — |
| `car`, `notification` | ❌ | ❌ | — | | `car`, `notification` | ❌ | ❌ | — |
**Ce que la table donne, et c'est la bonne nouvelle** : `sessionEnergy` et `totalEnergyConsumed` **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 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 (la classe les porte toujours — le cas « openmeteo d'avant le brief 2 » n'est éprouvé qu'en
unitaire, par `lirePlan()`). 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 | | EV débranché | Aucune action |
| ECS | Relais coupé (palier 0, `force=true`) | | 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. | | 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. « 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** La garantie *"jamais 0 A si le VE est branché"* appartient au **failsafe L1 de la borne**