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:
132
packages/evcs_battery_manual_hc.yaml
Executable file
132
packages/evcs_battery_manual_hc.yaml
Executable 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
|
||||
@@ -66,6 +66,41 @@ const socPct = num(input.soc_percent, NaN);
|
||||
const usableCapacityKwh = num(input.usable_capacity_kwh, NaN);
|
||||
const socFloorPct = num(input.soc_floor_percent, 22);
|
||||
|
||||
// =====================================================
|
||||
// CHARGE HC MANUELLE — UN PLANCHER, PAS UNE CIBLE (18/09/2026)
|
||||
// =====================================================
|
||||
// Le proprietaire veut pouvoir demander une recharge batterie en heures creuses
|
||||
// quand le bilan ne l'a pas jugee necessaire — journee d'hiver courte, ciel
|
||||
// couvert. La decision qu'il a tranchee : CONTRAINDRE le bilan plutot que de
|
||||
// l'ignorer, pour garder un seul arbitre. Ce projet a deja paye cher les
|
||||
// automates concurrents (charges programmees de l'ESS §26, consigne a 4200 W
|
||||
// §27, plancher SOC du GX §27).
|
||||
//
|
||||
// ⚠️ LE FORCAGE DOIT ETRE ICI, EN AMONT, ET NULLE PART AILLEURS. Mesure du
|
||||
// 18/09 sur banc : ecrire directement dans `input_number.evcs_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 et du 04/09 a l'identique, et c'est le seul resultat des
|
||||
// trois qui soit INDIAGNOSTICABLE depuis le tableau de bord : il a l'air de
|
||||
// marcher. La raison : `required_soc` ENGAGE la session, `grid_energy_needed_kwh`
|
||||
// la FINANCE, et les deux sont publiees separement. Pose ici, dans `coverage()`,
|
||||
// le plancher fait bouger les deux ENSEMBLE par construction — c'est une
|
||||
// consequence de la forme du code et non une discipline a tenir.
|
||||
//
|
||||
// ⚠️ ET CE PLANCHER EST DEJA FILTRE PAR L'HEURE QUAND IL ARRIVE ICI. Le bilan
|
||||
// ignore totalement les fenetres d'heures creuses (aucune occurrence de
|
||||
// `hc_night` / `hc_midday` dans ce sous-systeme) et doit le rester : il repond a
|
||||
// « de combien la batterie a-t-elle besoin », pas a « quand est-ce bon marche ».
|
||||
// C'est Home Assistant qui detient le calendrier tarifaire et qui ne publie une
|
||||
// valeur non nulle que pendant une fenetre creuse. Sans ce filtrage en amont, un
|
||||
// plancher a 85 % fuirait dans la journee et mordrait sur
|
||||
// `BATTERY_CLOUD_LOCKS.js` — `eveningSocStartFloorPct = max(eveningSocStartMin,
|
||||
// balanceRequiredSocPct)` — au point de bloquer la charge du vehicule le soir.
|
||||
const manualHcFloorPct = (() => {
|
||||
const v = num(input.manual_hc_floor_percent, 0);
|
||||
return Number.isFinite(v) && v > socFloorPct && v <= 100 ? v : 0;
|
||||
})();
|
||||
|
||||
// Sans l'une de ces entrées, le bilan ne peut rien affirmer. Il le dit, et les
|
||||
// contrôleurs retombent sur leurs règles historiques.
|
||||
const missing = [];
|
||||
@@ -223,7 +258,13 @@ function coverage(slots) {
|
||||
const found = crossingIndex >= 0;
|
||||
const deficitSlots = found ? slots.slice(0, crossingIndex) : slots;
|
||||
const energyToCoverKwh = deficitSlots.reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);
|
||||
const requiredSocRawPct = socForEnergy(energyToCoverKwh, socFloorPct);
|
||||
// Le plancher manuel ne peut que RELEVER l'exigence, jamais l'abaisser — meme
|
||||
// sens unique que les cablages de §38, §39 et §40 : le bilan ne pousse jamais
|
||||
// dans la direction « couvre moins bien ».
|
||||
const requiredSocRawPct = Math.max(
|
||||
socForEnergy(energyToCoverKwh, socFloorPct),
|
||||
manualHcFloorPct
|
||||
);
|
||||
const socNeededPct = requiredSocRawPct - socFloorPct;
|
||||
const requiredSocPct = Math.min(100, requiredSocRawPct);
|
||||
const socMarginPct = socPct - requiredSocPct;
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"forecast_updated": states("input_text.evcs_balance_forecast_updated"),
|
||||
"grid_soc_off": states("input_number.victron_grid_soc_off"),
|
||||
|
||||
"manual_hc_floor_percent": states("input_number.evcs_battery_manual_hc_floor"),
|
||||
|
||||
"pv_power_w": states("sensor.victron_pv_power_system"),
|
||||
|
||||
"house_ac_power_w": states("sensor.victron_consumption_power_l1"),
|
||||
|
||||
@@ -83,6 +83,10 @@ const SUMMARIZE = {
|
||||
energyToCoverKwh: o.coverage.energyToCoverKwh,
|
||||
requiredSocPct: o.coverage.requiredSocPct,
|
||||
gridNeeded: o.coverage.gridNeeded,
|
||||
// Le MANQUE, et pas l'energie a couvrir. C'est lui qui finance la session :
|
||||
// depuis le 18/09 la consigne reseau dimensionne sa part batterie dessus.
|
||||
// Il manquait au resume alors qu'il est desormais consomme en aval.
|
||||
gridEnergyNeededKwh: Math.round(o.coverage.gridEnergyNeededKwh * 100) / 100,
|
||||
prudentRequiredSocPct: o.coverage.prudent.requiredSocPct,
|
||||
deficitTrustworthy: o.confidence.deficitTrustworthy,
|
||||
nextDeficitFound: o.nextDeficit.found,
|
||||
|
||||
@@ -66,6 +66,61 @@ export const balanceScenarios = [
|
||||
deficitTrustworthy: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// ======== CHARGE HC MANUELLE (18/09/2026) ========
|
||||
// Le plancher manuel est pose EN AMONT, dans `coverage()`, et non en ecrivant
|
||||
// `balance_required_soc` : mesure sur banc, un forcage en aval engage la
|
||||
// session mais laisse la consigne reseau a 700 W — contacteur ferme, batterie
|
||||
// a cent watts, et ca A L'AIR de marcher. Ici la cible ET le manque bougent
|
||||
// ensemble, ce que ce scenario verifie en exigeant les deux.
|
||||
node: 'BALANCE',
|
||||
id: 'bilan-plancher-manuel-releve-la-cible-et-le-manque',
|
||||
label: 'Charge HC manuelle a 85 % : la cible passe de 30,5 a 85 % ET le manque suit (9,95 kWh au lieu de 0,82)',
|
||||
snapshot: {
|
||||
nowMs: Date.parse('2026-08-24T06:43:00+02:00'),
|
||||
consumption_p50: P50,
|
||||
consumption_p80: P80,
|
||||
horizon: horizonFrom('2026-08-24T06:43:00+02:00', 18, PROD_24_08),
|
||||
soc_percent: 21,
|
||||
usable_capacity_kwh: 15,
|
||||
soc_floor_percent: 22,
|
||||
manual_hc_floor_percent: 85,
|
||||
},
|
||||
expect: {
|
||||
usable: true,
|
||||
requiredSocPct: 85,
|
||||
gridNeeded: true,
|
||||
// 21 -> 85 traverse le genou de 33 % : 12 points a 0,097 puis 52 a 0,169.
|
||||
gridEnergyNeededKwh: 9.95,
|
||||
// Le plancher passe par `coverage()`, appelee pour les DEUX profils : la
|
||||
// cible prudente monte aussi. Consequence assumee — pendant une charge
|
||||
// manuelle, P12 cesse de descendre la batterie pour la voiture.
|
||||
prudentRequiredSocPct: 85,
|
||||
},
|
||||
},
|
||||
{
|
||||
node: 'BALANCE',
|
||||
id: 'bilan-plancher-manuel-ne-baisse-jamais-la-cible',
|
||||
label: 'CONTREFACTUEL — plancher manuel a 25 %, sous les 30,5 % calcules : le bilan garde SA cible',
|
||||
snapshot: {
|
||||
nowMs: Date.parse('2026-08-24T06:43:00+02:00'),
|
||||
consumption_p50: P50,
|
||||
consumption_p80: P80,
|
||||
horizon: horizonFrom('2026-08-24T06:43:00+02:00', 18, PROD_24_08),
|
||||
soc_percent: 21,
|
||||
usable_capacity_kwh: 15,
|
||||
soc_floor_percent: 22,
|
||||
manual_hc_floor_percent: 25,
|
||||
},
|
||||
expect: {
|
||||
// Sens unique, comme les cablages de §38, §39 et §40 : le plancher ne peut
|
||||
// que RELEVER l'exigence. Un reglage manuel trop bas ne desarme pas le
|
||||
// bilan, il est simplement sans effet.
|
||||
usable: true,
|
||||
requiredSocPct: 30.5,
|
||||
prudentRequiredSocPct: 41,
|
||||
},
|
||||
},
|
||||
{
|
||||
node: 'BALANCE',
|
||||
id: 'bilan-modules-valeur-aberrante-retombe-sur-sept',
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user