JEEP branchee a midi : reseau en HC seulement si le solaire ne suffit pas avant son depart

Regle du proprietaire (28/09). Le bilan compare le besoin saisi (niveau x capacite
mesuree - deja delivre) au solaire disponible avant le depart de 15:30 (mar/mer/
ven/sam) sans descendre la batterie sous le plancher « tenir jusqu'a la prochaine
HC ». Le controleur relaie le verdict sous les gardes du bilan ; session de midi
engagee -> voiture sur le reseau avec la batterie. Bilan 61/61, golden 121/121.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uJ47NxgfpGnuuMr3f2n7Z
This commit is contained in:
Tallagrand Thierry
2026-09-28 13:55:40 +02:00
parent 9b1c9d30cf
commit 3b9b67268c
17 changed files with 501 additions and 14 deletions

View File

@@ -301,14 +301,14 @@ const hcNightReachActive = Number.isFinite(hcWindowEndMs) && Number.isFinite(nex
// Energie a avoir en reserve a la fin de la fenetre : parcours A REBOURS des
// tranches comprises entre les deux instants (au prorata de leur recouvrement).
// Un surplus ne rembourse qu'un deficit POSTERIEUR — d'ou le sens du parcours.
function hcReachFloorPct(slots, untilMs = nextHcStartMs) {
if (!Number.isFinite(hcWindowEndMs) || !Number.isFinite(untilMs) || untilMs <= hcWindowEndMs) return 0;
function hcReachFloorPct(slots, untilMs = nextHcStartMs, fromMs = hcWindowEndMs) {
if (!Number.isFinite(fromMs) || !Number.isFinite(untilMs) || untilMs <= fromMs) return 0;
let needKwh = 0;
for (let i = slots.length - 1; i >= 0; i--) {
const s = slots[i];
const debut = num(s.startMs, NaN);
if (!Number.isFinite(debut)) continue;
const a = Math.max(debut, hcWindowEndMs);
const a = Math.max(debut, fromMs);
const b = Math.min(debut + 3600000, untilMs);
if (b <= a) continue;
needKwh = Math.max(0, needKwh - s.netKwh * (b - a) / 3600000);
@@ -348,6 +348,77 @@ const hcFloorPrudentPct = Math.max(hcReachFloorPct(slotsP80, nextHcStartMs), hcF
const typical = coverage(slotsP50, hcFloorTypicalPct);
const prudent = coverage(slotsP80, hcFloorPrudentPct);
// ---------------------------------------------------------------------
// VOITURE BRANCHEE EN HC AVEC UN DEPART AVANT LA PROCHAINE HC (28/09/2026)
//
// Regle du proprietaire : « JEEP branchee engage le reseau en HC midi si
// l'analyse de production le justifie ». La JEEP repart a 15:30 certains jours
// (calendrier dans HA) : ce qu'elle n'a pas pris avant, elle ne le prendra pas
// en HC la nuit. Ailleurs, elle reste branchee et la HC de 01:24 la finit au
// meme tarif — fermer le contacteur a midi n'y gagnerait rien et etoufferait le
// solaire (installation sans injection).
//
// Ce que le solaire peut donner a la voiture avant son depart SANS toucher a
// la reserve de la nuit : l'energie de la batterie au-dessus du plancher
// « tenir jusqu'a la prochaine HC » evalue AU DEPART, plus le bilan net prevu
// (production - maison) d'ici le depart. Profil p50, comme required_soc.
// Le reseau est justifie quand le besoin de la voiture, borne par ce que son
// chargeur peut absorber avant le depart, depasse ce solaire.
//
// Besoin = (100 - niveau saisi) % x capacite MESUREE au compteur de la borne,
// moins ce que la borne a deja delivre depuis la saisie. Aucun capteur ne donne
// le niveau de la voiture : sans saisie valable, la regle se tait.
//
// Deux constantes, justifiees :
// - 3,7 kW : plateau MESURE des deux vehicules du foyer (3 655 et 3 680 W a
// consigne pleine, MANUEL §0) ; au-dela la voiture n'absorbe rien de plus.
// - 0,5 kWh : seuil de manque en dessous duquel une session ne se justifie
// pas — une fermeture du contacteur coute 50 a 150 Wh de transitoire (§57),
// on exige un gain d'au moins trois fois ce cout.
const VEHICLE_CHARGER_KW = 3.7;
const VEHICLE_GRID_MIN_SHORTFALL_KWH = 0.5;
const nowMsBalance = num(input.nowMs, Date.now());
const vehicleConnected = input.vehicle_connected === true || input.vehicle_connected === 'true';
const vehicleDepartureMs = num(input.vehicle_departure_ms, NaN);
const vehicleLevelPct = num(input.vehicle_level_pct, NaN);
const vehicleLevelValid = input.vehicle_level_valid === true || input.vehicle_level_valid === 'true';
const vehicleCapacityKwh = num(input.vehicle_capacity_kwh, NaN);
const vehicleDeliveredKwh = Math.max(0, num(input.vehicle_delivered_since_level_kwh, 0));
const vehicleNeedKwh =
vehicleLevelValid && Number.isFinite(vehicleLevelPct) && vehicleLevelPct >= 0 && vehicleLevelPct <= 100
&& Number.isFinite(vehicleCapacityKwh) && vehicleCapacityKwh > 0
? Math.max(0, (100 - vehicleLevelPct) / 100 * vehicleCapacityKwh - vehicleDeliveredKwh)
: NaN;
const vehicleRuleActive =
hcReachActive && vehicleConnected && Number.isFinite(vehicleNeedKwh)
&& Number.isFinite(vehicleDepartureMs) && vehicleDepartureMs > nowMsBalance
&& vehicleDepartureMs < nextHcStartMs;
let vehicleSolarKwh = null;
let vehicleReachableKwh = null;
let vehicleShortfallKwh = null;
let vehicleFloorAtDeparturePct = null;
if (vehicleRuleActive) {
vehicleFloorAtDeparturePct = hcReachFloorPct(slotsP50, nextHcStartMs, vehicleDepartureMs);
let netKwh = 0;
for (const sl of slotsP50) {
const debut = num(sl.startMs, NaN);
if (!Number.isFinite(debut)) continue;
const a = Math.max(debut, nowMsBalance);
const b = Math.min(debut + 3600000, vehicleDepartureMs);
if (b > a) netKwh += sl.netKwh * (b - a) / 3600000;
}
// Difference SIGNEE : une batterie SOUS son plancher doit d'abord etre
// remontee par ce meme surplus avant que la voiture n'en voie un kWh.
const plancherDepart = Math.max(socFloorPct, vehicleFloorAtDeparturePct);
const batterieAuDessusKwh = socPct >= plancherDepart
? energyBetweenSoc(plancherDepart, socPct)
: -energyBetweenSoc(socPct, plancherDepart);
vehicleSolarKwh = Math.max(0, batterieAuDessusKwh + netKwh);
vehicleReachableKwh = Math.min(vehicleNeedKwh, VEHICLE_CHARGER_KW * (vehicleDepartureMs - nowMsBalance) / 3600000);
vehicleShortfallKwh = Math.max(0, vehicleReachableKwh - vehicleSolarKwh);
}
const vehicleGridNeeded = vehicleRuleActive && vehicleShortfallKwh > VEHICLE_GRID_MIN_SHORTFALL_KWH;
// ---------------------------------------------------------------------
// PROCHAINE FENÊTRE DE DÉFICIT (ajout du 24/08/2026)
//
@@ -537,6 +608,18 @@ return [{
surplusAvailableKwh: round2(surplusAfterCrossingKwh),
},
// Voiture branchee en HC, depart avant la prochaine HC (28/09).
vehicleMidday: {
active: vehicleRuleActive,
needKwh: Number.isFinite(vehicleNeedKwh) ? round2(vehicleNeedKwh) : null,
departureMs: Number.isFinite(vehicleDepartureMs) ? vehicleDepartureMs : null,
solarKwh: vehicleSolarKwh === null ? null : round2(vehicleSolarKwh),
reachableKwh: vehicleReachableKwh === null ? null : round2(vehicleReachableKwh),
shortfallKwh: vehicleShortfallKwh === null ? null : round2(vehicleShortfallKwh),
floorAtDeparturePct: vehicleFloorAtDeparturePct === null ? null : round1(vehicleFloorAtDeparturePct),
gridNeeded: vehicleGridNeeded,
},
// Tenir jusqu'a la prochaine fenetre HC (28/09) : SOC a avoir en fin de
// fenetre HC en cours. 0 hors fenetre ou si la production suffit.
hcReach: {

View File

@@ -58,6 +58,8 @@ setText('input_text.evcs_balance_last_run', stampParis());
// --- Le bilan lui-même ---------------------------------------------------
if (!balance.usable) {
setText('input_text.evcs_balance_status', 'indisponible');
// Jamais de verdict voiture perime quand le bilan ne sait plus rien.
setBool('input_boolean.evcs_balance_vehicle_grid_needed', false);
setText('input_text.evcs_balance_reason',
`Entrées manquantes : ${(balance.missing || []).join(', ')}. Les contrôleurs gardent leurs règles.`);
return calls;
@@ -85,6 +87,11 @@ setNumber('input_number.evcs_balance_required_soc_prudent', c.prudent.requiredSo
// Regle HC (28/09) publiee pour etre verifiable : 0 = hors fenetre HC.
setNumber('input_number.evcs_balance_hc_floor_pct', balance.hcReach ? balance.hcReach.floorPct : 0);
setNumber('input_number.evcs_balance_hc_floor_prudent_pct', balance.hcReach ? balance.hcReach.floorPrudentPct : 0);
// Voiture branchee en HC, depart avant la prochaine HC (28/09). OFF hors regle.
const vm = balance.vehicleMidday || {};
setBool('input_boolean.evcs_balance_vehicle_grid_needed', !!vm.gridNeeded);
setNumber('input_number.evcs_balance_vehicle_need_kwh', vm.active ? vm.reachableKwh : 0);
setNumber('input_number.evcs_balance_vehicle_solar_kwh', vm.active ? vm.solarKwh : 0);
setNumber('input_number.evcs_balance_energy_to_cover_kwh', c.energyToCoverKwh);
setNumber('input_number.evcs_balance_grid_energy_needed_kwh', c.gridEnergyNeededKwh);
setNumber('input_number.evcs_balance_vehicle_surplus_kwh', balance.vehicle.surplusAvailableKwh);
@@ -143,7 +150,11 @@ const hc = balance.hcReach || {};
const hcText = hc.active && hc.raisesRequirement
? ` Tenir jusqu'à la prochaine HC (${new Date(hc.nextHcStartMs).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' })}) → ${hc.floorPct}% en fin de fenêtre.`
: '';
setText('input_text.evcs_balance_reason', reason + hcText + degradedNote + ndText);
const hhmm = ms => new Date(ms).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' });
const vehText = vm.active
? ` Voiture (départ ${hhmm(vm.departureMs)}) : ${vm.reachableKwh} kWh à donner, solaire ${vm.solarKwh} kWh → ${vm.gridNeeded ? 'réseau justifié' : 'solaire suffisant'}.`
: '';
setText('input_text.evcs_balance_reason', reason + hcText + vehText + degradedNote + ndText);
// Trace longue durée, pour comparer jour après jour le seuil calculé au seuil
// fixe encore en vigueur — c'est cette comparaison qui décidera du câblage.

View File

@@ -20,8 +20,34 @@
{%- if w[0] >= repere and ns.suivant == '' -%}{%- set ns.suivant = w[0] -%}{%- endif -%}
{%- if w[0] >= repere and w[2] == 'nuit' and ns.nuit == '' -%}{%- set ns.nuit = w[0] -%}{%- endif -%}
{%- endfor -%}
{#- Voiture branchee a midi (28/09/2026). Le calendrier (jours et heure de
depart de la JEEP apres le dejeuner) reste dans HA : le bilan recoit un
instant de depart, vide si la voiture ne repart pas aujourd'hui. Le niveau
saisi n'est retenu que s'il date de ce branchement (tolerance 1 h avant). -#}
{%- set vh = namespace(dep='', valide=false, cap=none) -%}
{%- set jours = ['lun', 'mar', 'mer', 'jeu', 'ven', 'sam', 'dim'] -%}
{%- if is_state('input_select.victron_evcs_vehicle', 'JEEP') -%}
{%- set vh.cap = states('input_number.victron_evcs_capacity_jeep_kwh') -%}
{%- if jours[now().weekday()] in states('input_text.victron_evcs_jeep_midday_departure_days') | lower -%}
{%- set d = today_at(states('input_datetime.victron_evcs_jeep_midday_departure')) -%}
{%- if d > now() -%}{%- set vh.dep = d -%}{%- endif -%}
{%- endif -%}
{%- elif is_state('input_select.victron_evcs_vehicle', 'VW') -%}
{%- set vh.cap = states('input_number.victron_evcs_capacity_vw_kwh') -%}
{%- endif -%}
{%- set branche = states.binary_sensor.victron_evcs_vehicle_connected -%}
{%- set niveau = states.input_number.victron_evcs_vehicle_level_pct -%}
{%- if branche is not none and niveau is not none and branche.state == 'on' -%}
{%- set vh.valide = as_timestamp(niveau.last_changed) >= as_timestamp(branche.last_changed) - 3600 -%}
{%- endif -%}
{{ {
"ts": now().isoformat(),
"vehicle_connected": is_state("binary_sensor.victron_evcs_vehicle_connected", "on"),
"vehicle_departure_ms": (as_timestamp(vh.dep) * 1000) if vh.dep != '' else none,
"vehicle_level_pct": states("input_number.victron_evcs_vehicle_level_pct"),
"vehicle_level_valid": vh.valide,
"vehicle_capacity_kwh": vh.cap,
"vehicle_delivered_since_level_kwh": ((states("sensor.evcs_hq2239dnt7p_total_energy") | float(0)) - (states("input_number.victron_evcs_vehicle_level_ref_kwh") | float(0))) if states("sensor.evcs_hq2239dnt7p_total_energy") | is_number else none,
"hc_window_end_ms": (as_timestamp(ns.fin) * 1000) if ns.fin != '' else none,
"next_hc_start_ms": (as_timestamp(ns.suivant) * 1000) if ns.suivant != '' else none,
"next_night_hc_start_ms": (as_timestamp(ns.nuit) * 1000) if ns.nuit != '' else none,

View File

@@ -60,6 +60,13 @@ return [{
// Plancher de charge HC manuelle (18/09) : oublie ici jusqu'au 28/09, le
// bouton n'avait donc jamais atteint le bilan.
manual_hc_floor_percent: snapshot.manual_hc_floor_percent,
// Voiture branchee a midi (28/09) : depart du jour et besoin saisi.
vehicle_connected: snapshot.vehicle_connected,
vehicle_departure_ms: snapshot.vehicle_departure_ms,
vehicle_level_pct: snapshot.vehicle_level_pct,
vehicle_level_valid: snapshot.vehicle_level_valid,
vehicle_capacity_kwh: snapshot.vehicle_capacity_kwh,
vehicle_delivered_since_level_kwh: snapshot.vehicle_delivered_since_level_kwh,
hc_window_end_ms: snapshot.hc_window_end_ms,
next_hc_start_ms: snapshot.next_hc_start_ms,
next_night_hc_start_ms: snapshot.next_night_hc_start_ms,

View File

@@ -153,6 +153,7 @@
"dvcc_max_charge_current_a": states("sensor.victron_max_charge_current_setting"),
"balance_status": states("input_text.evcs_balance_status"),
"balance_last_run": states("input_text.evcs_balance_last_run"),
"balance_vehicle_grid_needed": states("input_boolean.evcs_balance_vehicle_grid_needed"),
"allow_grid_hc_midday": states("input_boolean.victron_evcs_allow_grid_hc_midday"),
"probe_enabled": states("input_boolean.victron_evcs_probe_enabled"),

View File

@@ -487,6 +487,9 @@ const params = {
// BILAN ENERGETIQUE (03/09/2026). Meme format d'horodatage que l'advisor
// ("DD/MM HH:mm"), d'ou la reutilisation du parseur.
balanceRequiredSocPct: num(input.balance_required_soc, NaN),
// Voiture branchee en HC avec un depart avant la prochaine HC (28/09) : le
// bilan juge que le solaire ne lui donnera pas son besoin avant le depart.
balanceVehicleGridNeeded: low(input.balance_vehicle_grid_needed || 'off') === 'on',
// P12 — charge nuit sur batterie. Le plancher est le SOC requis PRUDENT (p80)
// et non le p50 : la marge est mesuree, pas inventee. On descend la voiture
// jusqu'a ce que la batterie n'ait plus que ce qu'il lui faut pour tenir

View File

@@ -739,10 +739,24 @@ const nightGridSessionUncovered =
// `required_soc` = 22,0 pour `energy_to_cover` = 0,0. Il ne remonte qu'un jour
// sombre — ou quand le proprietaire pose un plancher manuel, ce qui est
// exactement le cas d'usage.
//
// ET LA VOITURE (28/09/2026, strategie du proprietaire) : « JEEP branchee
// engage le reseau en HC midi si l'analyse de production le justifie ». Le
// bilan fait l'analyse (besoin saisi contre solaire disponible avant le depart,
// batterie gardee pour tenir jusqu'a 01:24) et publie un verdict ; on le relaie,
// sous les memes gardes de fraicheur et de statut que la cible.
const middayVehicleGridNeeded =
balanceMorningTargetApplied &&
params.balanceVehicleGridNeeded &&
parsed.evcsVehicleConnected;
const middayGridSessionUncovered =
balanceMorningTargetApplied &&
Number.isFinite(params.balanceRequiredSocPct) &&
parsed.soc < params.balanceRequiredSocPct;
(
(Number.isFinite(params.balanceRequiredSocPct) &&
parsed.soc < params.balanceRequiredSocPct) ||
middayVehicleGridNeeded
);
const gridSessionInHcWindow =
profile === 'night_hc' || profile === 'midday_hc_or_solar';
@@ -931,7 +945,15 @@ else if (profile === 'night_hc') {
}
else if (profile === 'midday_hc_or_solar' || profile === 'morning_solar') {
if (strongSolarSignal) {
// Session de midi engagee et contacteur ferme : la voiture charge sur le
// reseau avec la batterie (« batterie et voiture ensemble », 28/09), meme au
// soleil. La consigne garde son credit solaire (HA_SERVICE_CALLS_OUTPUT) :
// elle ne preleve au reseau que ce que les chargeurs ne donnent pas.
if (profile === 'midday_hc_or_solar' && nightGridSessionCommitted && parsed.gridAvailable) {
sourceMode = 'grid_hc_allowed';
}
else if (strongSolarSignal) {
sourceMode = 'solar_only';
}
@@ -1168,6 +1190,7 @@ return [{
nightBatterySurplusAvailable,
nightBatterySurplusExhausted,
nightGridSessionCommitted,
middayVehicleGridNeeded,
nightGridSessionUncovered,
balanceTargetAgeMin:
balanceTargetAgeMin === null ? null : Math.round(balanceTargetAgeMin * 10) / 10,