Charge HC manuelle : un plancher qui CONTRAINT le bilan, pas une cible qui le remplace

Decision du proprietaire, dans ses termes : « contraindre le bilan, un seul
arbitre ». Ce projet a deja paye cher les automates concurrents — charges
programmees de l ESS, consigne a 4200 W, plancher SOC du GX — et un bouton qui
ecrirait sa propre cible en aurait cree un quatrieme.

⚠️ LE FORCAGE EST EN AMONT, DANS coverage(), ET NULLE PART AILLEURS. Mesure sur
banc : ecrire directement dans balance_required_soc engage bien la session —
contacteur ferme, takeover ON — mais laisse la consigne reseau a 700 W, donc la
batterie recoit une centaine de watts. C est le defaut du 01/09 a l identique,
et le seul des trois resultats INDIAGNOSTICABLE depuis le tableau de bord : il a
l air de marcher. La raison est que required_soc ENGAGE la session et
grid_energy_needed_kwh la FINANCE. Pose dans coverage(), le plancher fait bouger
les deux ensemble par construction.

Sens unique, comme les cablages de §38, §39 et §40 : le plancher ne peut que
RELEVER l exigence. Un reglage trop bas est sans effet, il ne desarme rien.

DEUX NOMBRES ET PAS UN, et c est architectural. `..._target` est le reglage du
proprietaire ; `..._floor` est sa valeur EFFECTIVE, mise a zero hors des fenetres
d heures creuses par une automatisation Home Assistant, et c est elle que le
bilan lit. Le bilan repond a « de combien la batterie a besoin », pas a « quand
est-ce bon marche » : il ne contient aucune notion d heure creuse (zero
occurrence de hc_night ou hc_midday dans tout src/energy-balance/) et doit le
rester. Home Assistant detient le calendrier tarifaire, le bilan detient
l energie.

Sans ce filtrage le plancher fuit dans la journee et mord sur
BATTERY_CLOUD_LOCKS — eveningSocStartFloorPct = max(eveningSocStartMin,
balanceRequiredSocPct) — au point de bloquer le demarrage de la charge du
vehicule le soir. Ce n est pas theorique, c est une ligne qui s applique tous les
soirs.

Le bouton se desarme a la sortie de la fenetre ou il a servi : une demande de
charge est un geste ponctuel, pas un reglage permanent oublie.

⚠️ EFFET STRUCTUREL CONSIGNE, qu aucune echeance ne corrige : coverage() est
appelee pour le profil typique ET le profil prudent, donc le plancher releve
aussi required_soc_prudent, qui est le plancher de P12. Pendant une charge
manuelle, la voiture cesse de descendre la batterie. Coherent, mais une nuit ou
l on veut les deux on n aura que l un.

Bilan 48 -> 50. Mutations : plancher ignore -> le scenario qui le releve ;
plancher qui remplace au lieu de relever -> le contrefactuel a 25 %. Chacune
tombe sur un jeu different. gridEnergyNeededKwh ajoute au resume de test : il
manquait alors qu il est desormais consomme en aval par la consigne reseau.

Gabarit du bilan rendu contre Home Assistant : 42 champs, JSON valide, helper
absent lu « unknown » donc plancher nul — le repli est sur avant meme que les
helpers existent.

⚠️ AUCUN PUT VERS n8n. Le workflow du bilan tourne toujours sans ce changement.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
This commit is contained in:
Tallagrand Thierry
2026-09-18 07:57:43 +02:00
parent 50e3fcc2df
commit 2654592b64
6 changed files with 237 additions and 3 deletions

View File

@@ -0,0 +1,132 @@
# Charge manuelle de la batterie en heures creuses
#
# LE BESOIN, tel que le proprietaire l'a pose le 18/09/2026 : « un bouton charge
# HC manuel si le bilan energetique n'a pas autorise la charge HC a cause des
# conditions meteo nuageuses et des journees hivernales d'ensoleillement
# courtes ». C'est sa strategie d'hiver documentee (MANUEL §0), jusqu'ici tenue
# a la main par des charges programmees dans le GX — desactivees le 27/08 parce
# qu'elles constituaient un second automate sur la meme batterie.
#
# LA DECISION QU'IL A TRANCHEE : CONTRAINDRE LE BILAN, PAS L'IGNORER, pour
# garder UN SEUL ARBITRE. Le bouton n'ecrit donc pas une cible : il pose un
# PLANCHER que le bilan integre a son calcul, et le bilan reste seul producteur
# de la cible et du manque.
#
# ⚠️ POURQUOI DEUX NOMBRES ET PAS UN. `..._target` est le reglage du
# proprietaire ; `..._floor` est sa valeur EFFECTIVE, mise a zero hors des
# fenetres d'heures creuses par l'automatisation ci-dessous. C'est ce second
# nombre que le bilan lit. La raison est architecturale : le bilan repond a « de
# combien la batterie a-t-elle besoin », pas a « quand est-ce bon marche » — il
# ne contient aucune notion d'heure creuse (verifie : zero occurrence de
# `hc_night` ou `hc_midday` dans tout `src/energy-balance/`) et doit le rester.
# Home Assistant detient le calendrier tarifaire, le bilan detient l'energie.
#
# ⚠️ ET SANS CE FILTRAGE, LE PLANCHER FUIT DANS LA JOURNEE. `BATTERY_CLOUD_LOCKS`
# calcule `eveningSocStartFloorPct = max(eveningSocStartMin, balanceRequiredSocPct)` :
# un plancher a 85 % laisse actif le soir bloquerait le demarrage de la charge du
# vehicule jusqu'a ce que la batterie y soit. Ce n'est pas theorique, c'est une
# ligne qui s'applique tous les soirs.
#
# ⚠️ EFFET STRUCTUREL A CONNAITRE, qu'aucune echeance ne corrige. Le plancher
# passe par `coverage()`, appelee pour le profil typique ET le profil prudent :
# il releve donc aussi `required_soc_prudent`, qui est le plancher de P12. Pendant
# une charge manuelle, la voiture cesse donc de descendre la batterie. C'est
# coherent — on ne remplit pas la batterie au reseau pendant que la voiture la
# vide — mais une nuit ou l'on veut les deux, on n'aura que l'un.
#
# ⚠️ AUCUN `initial:` DANS CE FICHIER. Declarer `initial:` DESACTIVE la
# restauration d'etat : le helper reprend cette valeur a CHAQUE demarrage de Home
# Assistant. Contrepartie connue : un helper cree de zero demarre au `min` pour un
# nombre et a `off` pour un booleen — d'ou le `min: 23` ci-dessous, qui est deja
# une valeur utilisable, et non 0 qui serait silencieusement sans effet.
input_boolean:
evcs_battery_manual_hc:
name: "🔋 Charge batterie en heures creuses (manuel)"
icon: mdi:battery-charging-medium
input_number:
# 23 au minimum : le plancher n'est pris en compte par le bilan que s'il est
# STRICTEMENT au-dessus du plancher dur de 22 %. En dessous il ne contraint
# rien, et un reglage sans effet est pire qu'un reglage absent.
evcs_battery_manual_hc_target:
name: "🔋 Cible de la charge manuelle"
min: 23
max: 100
step: 5
unit_of_measurement: "%"
mode: slider
icon: mdi:battery-heart-variant
# Ecrit par l'automatisation, jamais a la main. Zero = aucune contrainte.
evcs_battery_manual_hc_floor:
name: "🔋 Plancher effectif transmis au bilan"
min: 0
max: 100
step: 0.5
unit_of_measurement: "%"
mode: box
icon: mdi:arrow-collapse-up
automation:
- id: evcs_battery_manual_hc_fenetre
alias: "EVCS — la charge batterie manuelle ne vaut qu'en heures creuses"
description: >
Publie le plancher effectif lu par le bilan énergétique : la cible pendant
une fenêtre d'heures creuses, zéro en dehors. Et désarme le bouton à la
sortie de la fenêtre où il a servi, pour qu'une demande soit un geste
ponctuel et non un réglage permanent oublié.
mode: single
trigger:
- platform: time_pattern
minutes: "/1"
action:
- variables:
dans_hc: >
{% set ns = states('input_datetime.victron_evcs_hc_night_start').split(':') %}
{% set ne = states('input_datetime.victron_evcs_hc_night_stop').split(':') %}
{% set ms = states('input_datetime.victron_evcs_hc_midday_start').split(':') %}
{% set me = states('input_datetime.victron_evcs_hc_midday_stop').split(':') %}
{% set n1 = ns[0]|int * 60 + ns[1]|int %}
{% set n2 = ne[0]|int * 60 + ne[1]|int %}
{% set m1 = ms[0]|int * 60 + ms[1]|int %}
{% set m2 = me[0]|int * 60 + me[1]|int %}
{% set hm = now().hour * 60 + now().minute %}
{% set d = namespace(v=false) %}
{% if n1 <= n2 %}
{% if hm >= n1 and hm < n2 %}{% set d.v = true %}{% endif %}
{% else %}
{% if hm >= n1 or hm < n2 %}{% set d.v = true %}{% endif %}
{% endif %}
{% if hm >= m1 and hm < m2 %}{% set d.v = true %}{% endif %}
{{ d.v }}
arme: "{{ is_state('input_boolean.evcs_battery_manual_hc', 'on') }}"
plancher_actuel: "{{ states('input_number.evcs_battery_manual_hc_floor') | float(0) }}"
voulu: >
{{ (states('input_number.evcs_battery_manual_hc_target') | float(0))
if (arme and dans_hc == 'True') else 0 }}
- condition: template
value_template: "{{ (voulu | float(0) - plancher_actuel | float(0)) | abs > 0.01 }}"
- service: input_number.set_value
target:
entity_id: input_number.evcs_battery_manual_hc_floor
data:
value: "{{ voulu | float(0) }}"
# Desarmement a la sortie de la fenetre : le plancher etait actif, il ne
# l'est plus, et le bouton etait arme. Une demande de charge est un geste
# ponctuel ; sans ce desarmement elle se rejouerait toutes les nuits.
- if:
- condition: template
value_template: >
{{ arme and dans_hc != 'True' and (plancher_actuel | float(0)) > 0 }}
then:
- service: input_boolean.turn_off
target:
entity_id: input_boolean.evcs_battery_manual_hc
- service: logbook.log
data:
name: EVCS
message: >
Charge batterie manuelle terminée (sortie de la fenêtre d'heures
creuses). SOC {{ states('sensor.victron_battery_soc') | round(0) }} %.
entity_id: input_boolean.evcs_battery_manual_hc