From 4ffa8391cfa2d62d8730d46fef8b503ab3037d43 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 24 Oct 2026 08:38:29 +0200 Subject: [PATCH] =?UTF-8?q?docs=20:=20heures=20creuses=20et=20batterie=20?= =?UTF-8?q?=E2=80=94=20design,=20s=C3=BBret=C3=A9=20L2,=20inventaire=20du?= =?UTF-8?q?=20mock=20(lot=20e1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/DESIGN_HC_BATTERIE.md | 226 ++++++++++++++++++++++++++++++++++++ docs/DESIGN_PREVISION_PV.md | 6 + docs/INVENTAIRE_MOCK.md | 18 +++ docs/SAFETY.md | 2 +- 4 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 docs/DESIGN_HC_BATTERIE.md diff --git a/docs/DESIGN_HC_BATTERIE.md b/docs/DESIGN_HC_BATTERIE.md new file mode 100644 index 0000000..75c5d28 --- /dev/null +++ b/docs/DESIGN_HC_BATTERIE.md @@ -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). diff --git a/docs/DESIGN_PREVISION_PV.md b/docs/DESIGN_PREVISION_PV.md index a6fc727..3b668e2 100644 --- a/docs/DESIGN_PREVISION_PV.md +++ b/docs/DESIGN_PREVISION_PV.md @@ -1679,3 +1679,9 @@ jours de 23 et 25 h (oracles Python `zoneinfo` dans les tests). **Pour e1 (plus tard, sans être lu maintenant)** : `TariffConfig::zoneAt(instant, fuseau)` rend la zone en vigueur ; « HC » n'a pas de code — le libellé est une donnée de l'installateur. Le lot e1 devra décider d'une règle (par exemple : la ou les zones au prix le plus bas), pas la deviner ici. + +> **FAIT (lot e1, 2026-10-24)** — la règle retenue est celle-là : les heures creuses sont la ou +> les zones au prix le plus bas d'un tarif `tou` dont les prix diffèrent +> (`TariffConfig::zonesCreuses()`). Elles décident de deux choses, et le prix d'aucune : +> la borne en mode « avec minimum » ou « avec échéance » et la décharge de la batterie. Design, +> preuves et limites : `docs/DESIGN_HC_BATTERIE.md`. diff --git a/docs/INVENTAIRE_MOCK.md b/docs/INVENTAIRE_MOCK.md index e6e1bb9..bbc1dfe 100644 --- a/docs/INVENTAIRE_MOCK.md +++ b/docs/INVENTAIRE_MOCK.md @@ -28,6 +28,7 @@ rien sur les cas qu'il ne sait pas produire. | `powerSwitch` | ✅ | ❌ | — | | `etmVariableLoad` | ✅ *(`currentPowerW`)* | ❌ | — | | `energyStorage` | ✅ | ❌ | — | +| `controllableStorage` *(lot e1, 2026-10-24)* | ✅ | ❌ | — | | `car`, `notification` | ❌ | ❌ | — | **Ce que la table donne, et c'est la bonne nouvelle** : `sessionEnergy` et `totalEnergyConsumed` @@ -228,3 +229,20 @@ qui ne mesure qu'une charge. site, il ne l'a pas — `snow` n'est éprouvé qu'en unitaire), et un plan **sans** les paramètres v2 (la classe les porte toujours — le cas « openmeteo d'avant le brief 2 » n'est éprouvé qu'en unitaire, par `lirePlan()`). + +## 10. Batterie pilotable — `controllableStorage` (lot e1, 2026-10-24) + +| Classe | Porte | Ce qu'elle ouvre | +|---|---|---| +| `controllableStorage` | interface `energystorage` ; états MODIFIABLES `enableDischarging`, `dischargingRate`, `enableCharging`, `chargingRate`, `gridCharging` (les noms du `sunspecStorage` du plugin SunSpec, modèle 124) ; paramètre `revertTime` (minuteur de retour, 300 s par défaut, 0 = aucun) | la commande de la décharge en heures creuses, ses deux refus (`noRevertTimer` avec `revertTime = 0` ; `noDischargeControl` avec `energyStorage`, qui n'a pas ces états) | + +**Ce que le mock ne fait PAS, à dessein** : il n'émule pas l'onduleur. Il range ce qu'on écrit, +et c'est tout — le minuteur de retour n'expire jamais de lui-même. Le modèle de l'onduleur (« la +limite tient tant que la dernière écriture a moins de `revertTime` ») vit dans le TEST +(`limiteTenue()`), qui lit les écritures à la frontière nymea (`ThingManager::actionExecuted`) et +non dans la mémoire du garde : un modèle logé dans le mock ou dans le moteur se donnerait raison. + +**Ce qui reste introuvable ici** : une écriture ACCEPTÉE puis IGNORÉE par l'onduleur (le plugin +la relit et publie `controlHealthy = false`, AC-13 du plugin), et le retour SPONTANÉ de l'onduleur +à l'automatisme à l'échéance du minuteur. Le premier se lit dans `storage.batteries[].observed` ; +le second n'est éprouvé que par le modèle du test. diff --git a/docs/SAFETY.md b/docs/SAFETY.md index 1a1264b..a62a013 100644 --- a/docs/SAFETY.md +++ b/docs/SAFETY.md @@ -62,7 +62,7 @@ repli **conservatrice** : le repli n'INITIE rien, il borne ce qui tourne déjà. | EV débranché | Aucune action | | ECS | Relais coupé (palier 0, `force=true`) | | SG-Ready PAC | État **2** (normal — mains off, la PAC chauffe selon son thermostat). JAMAIS état 1 (blocage) : bloquer une PAC sous compteur muet = maison qui ne chauffe plus sans raison visible. | -| Batterie | Aucune charge réseau (surplus uniquement, plafonné à 0 si compteur muet) | +| Batterie | Rendue à son **automatisme** : une limite de décharge posée en heures creuses (lot e1) est **levée explicitement** à la transition (`enableDischarging = false`, puis `dischargingRate` au maximum). Le moteur ne commande jamais de charge réseau, il n'y a donc rien d'autre à replier. Si la levée échoue, le minuteur de retour de l'onduleur (`revertTime`) la fait tomber seul : c'est pour cela qu'une batterie sans minuteur n'est jamais commandée. | « Maintenu » ≠ « démarré » : le mode dégradé ne force jamais l'activation d'une charge. La garantie *"jamais 0 A si le VE est branché"* appartient au **failsafe L1 de la borne**