Un onduleur qui refuse la consigne (GEN24 sans « Allow Control », exception 0x04) la voyait reposée à chaque cycle, avec un qCWarning à chaque fois, toute la nuit, et `held` la publiait comme tenue. - 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 (marge de 5 s pour la gigue du cycle). La série se clôt à la première séquence acceptée jusqu'au bout (retour au tiers du minuteur) ou à la levée. - La levée n'est jamais espacée : elle part au cycle même où la décision tombe, série en cours ou non. - Journal : un seul qCWarning par série, les refus suivants en qCDebug, un qCInfo à l'acceptation qui clôt la série. - Télémétrie : pendant une série, `held` est faux (et `dischargeBlocked` avec lui), et la batterie publie o:refusedWrites, o:refusedSince, o:nextAttemptAt. `held` faux plutôt qu'un drapeau à côté : un client qui ignore le champ nouveau ne doit pas afficher une protection absente. Mock : propriété dynamique `refuserEcritures` sur la batterie pilotable. Test testRefusedWritesAreSpacedAndNeverDelayTheRelease ; les six contre-épreuves (espacement, avertissement, held, levée, deux fins de série) le font rougir. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
262 lines
17 KiB
Markdown
262 lines
17 KiB
Markdown
# 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.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).
|
||
- 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).
|