etm-powersync-energy-plugin.../docs/DESIGN_HC_BATTERIE.md
Patrick Schurig b144c09178 docs : heures creuses et batterie — les questions du lot e1 deviennent des décisions datées
Décisions de Patrick du 2026-10-24, §8 de DESIGN_HC_BATTERIE.md : toute la
décharge bloquée, maison comprise ; EV_GRID_START refusé hors heures
creuses (seul le mode boost charge tout de suite) ; blocage aussi par une
borne en mode Normal ; seuil de 230 W accepté ; étalement de l'échéance
renvoyé à un lot à part ; action « limite de décharge » et nouveau nom
d'enableDischarging décidés dans le dépôt du plugin. Aucune ne change le
code ; le garde reste compatible avec les deux actions actuelles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:25:19 +02:00

19 KiB
Raw Blame History

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

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.