Bilan : tenir jusqu'a la prochaine fenetre HC (strategie du proprietaire, 28/09)

Nuit du 26-27/09 : P12 a charge la JEEP sur la batterie (72 -> 44 %) ; la
journee sombre n'a pas reconstitue la reserve. Strategie : voiture branchee =
charge en HC sur le reseau ; la batterie tient jusqu'a la prochaine HC, midi
complete si besoin.
- HA transmet la fin de la fenetre HC en cours et le debut des suivantes (le
  calendrier reste dans HA, §73)
- pendant une fenetre HC, required_soc >= SOC pour atteindre la PROCHAINE HC
  (engage ET finance la session, insere dans coverage() comme le plancher
  manuel) ; required_soc_prudent (plancher de P12) >= SOC pour atteindre la
  NUIT SUIVANTE -> journee sombre = voiture sur le reseau HC
- a midi la session et la voiture en grid_hc_allowed se chargent ensemble
  (aucun changement du controleur)
- 4 scenarios reels du 27/09, bilan 54/54, deux mutations qui mordent

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 02:06:06 +02:00
parent 72c1f6035f
commit 8de284cd17
6 changed files with 196 additions and 8 deletions

View File

@@ -257,7 +257,66 @@ function socAfterEnergy(socPct, addKwh) {
return socForEnergy(energyBetweenSoc(0, socPct) + addKwh, 0);
}
function coverage(slots) {
// =====================================================
// TENIR JUSQU'A LA PROCHAINE FENETRE HC (28/09/2026)
// =====================================================
// Strategie du proprietaire, apres la nuit du 26-27/09 : P12 avait charge la
// JEEP sur la batterie (72 -> 44 %) parce qu'il restait assez pour atteindre le
// solaire du matin — alors que ce meme bilan annoncait deja « prochaine
// traversee 17,35 kWh des 18 h -> SOC requis 79,8 % ». La journee (22 kWh reels)
// n'a pas pu reconstituer la reserve. Sa regle : une voiture branchee se charge
// en heures creuses sur le reseau, et la batterie doit TENIR JUSQU'A LA
// PROCHAINE FENETRE HC ; si elle ne le peut pas, la fenetre HC de midi la
// complete.
//
// Traduction : PENDANT une fenetre HC, l'exigence vaut au moins le SOC qu'il
// faut avoir A LA FIN de cette fenetre pour atteindre le DEBUT de la suivante
// sans passer sous le plancher, production prevue comprise (tranches horaires
// ci-dessus, heure par heure, profil de conso du profil calcule).
//
// ⚠️ LE CALENDRIER RESTE DANS HOME ASSISTANT (§73) : le bilan ne recoit que deux
// instants, la fin de la fenetre en cours (absente hors fenetre) et le debut de
// la suivante. Hors fenetre HC, rien ne change.
//
// ⚠️ INSERE DANS coverage(), COMME LE PLANCHER MANUEL : la cible (required_soc,
// qui ENGAGE la session et releve le plancher de P12) et le manque en kWh (qui
// FINANCE la consigne) bougent ensemble par construction — jamais de session
// muette (§73). Sens unique : il ne peut que RELEVER l'exigence.
const hcWindowEndMs = num(input.hc_window_end_ms, NaN);
const nextHcStartMs = num(input.next_hc_start_ms, NaN);
const nextNightHcStartMs = num(input.next_night_hc_start_ms, NaN);
const hcReachActive = Number.isFinite(hcWindowEndMs) && Number.isFinite(nextHcStartMs)
&& nextHcStartMs > hcWindowEndMs;
// DEUX HORIZONS, DEUX USAGES (decision du proprietaire, 28/09) :
// - recharger la batterie au reseau : tenir jusqu'a la PROCHAINE fenetre HC
// (la nuit : midi ; a midi : 01:24). Midi complete si besoin, et la matinee
// garde de la place pour le solaire. -> required_soc (engage ET finance).
// - laisser la voiture puiser dans la batterie la nuit (P12) : seulement si la
// batterie tient ensuite jusqu'a la NUIT SUIVANTE (01:24), sans compter sur
// midi. Sinon la voiture se charge sur le reseau en HC. -> required_soc_prudent,
// qui n'est lu que comme plancher de P12 (et par le conseiller, pour info).
const hcNightReachActive = Number.isFinite(hcWindowEndMs) && Number.isFinite(nextNightHcStartMs)
&& nextNightHcStartMs > hcWindowEndMs;
// 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;
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 b = Math.min(debut + 3600000, untilMs);
if (b <= a) continue;
needKwh = Math.max(0, needKwh - s.netKwh * (b - a) / 3600000);
}
return needKwh > 0 ? socForEnergy(needKwh, socFloorPct) : 0;
}
function coverage(slots, hcFloorPct = 0) {
const crossingIndex = slots.findIndex(s => s.netKwh > 0);
const found = crossingIndex >= 0;
const deficitSlots = found ? slots.slice(0, crossingIndex) : slots;
@@ -267,7 +326,8 @@ function coverage(slots) {
// dans la direction « couvre moins bien ».
const requiredSocRawPct = Math.max(
socForEnergy(energyToCoverKwh, socFloorPct),
manualHcFloorPct
manualHcFloorPct,
hcFloorPct
);
const socNeededPct = requiredSocRawPct - socFloorPct;
const requiredSocPct = Math.min(100, requiredSocRawPct);
@@ -280,8 +340,13 @@ function coverage(slots) {
};
}
const typical = coverage(buildSlots(consumptionP50));
const prudent = coverage(buildSlots(consumptionP80));
const slotsP50 = buildSlots(consumptionP50);
const slotsP80 = buildSlots(consumptionP80);
const hcFloorTypicalPct = hcReachFloorPct(slotsP50, nextHcStartMs);
const hcFloorNightPrudentPct = hcNightReachActive ? hcReachFloorPct(slotsP80, nextNightHcStartMs) : 0;
const hcFloorPrudentPct = Math.max(hcReachFloorPct(slotsP80, nextHcStartMs), hcFloorNightPrudentPct);
const typical = coverage(slotsP50, hcFloorTypicalPct);
const prudent = coverage(slotsP80, hcFloorPrudentPct);
// ---------------------------------------------------------------------
// PROCHAINE FENÊTRE DE DÉFICIT (ajout du 24/08/2026)
@@ -472,6 +537,20 @@ return [{
surplusAvailableKwh: round2(surplusAfterCrossingKwh),
},
// 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: {
active: hcReachActive,
windowEndMs: hcReachActive ? hcWindowEndMs : null,
nextHcStartMs: hcReachActive ? nextHcStartMs : null,
floorPct: round1(Math.min(100, hcFloorTypicalPct)),
floorPrudentPct: round1(Math.min(100, hcFloorPrudentPct)),
nextNightHcStartMs: hcNightReachActive ? nextNightHcStartMs : null,
nightFloorPrudentPct: round1(Math.min(100, hcFloorNightPrudentPct)),
raisesRequirement: hcReachActive && hcFloorTypicalPct >= typical.requiredSocPct - 0.05
&& hcFloorTypicalPct > socForEnergy(typical.energyToCoverKwh, socFloorPct),
},
// Réserve à tenir pour traverser SANS réseau la fenêtre de déficit qui
// compte. Publiée en permanence — elle documente le risque même par temps
// calme ; `active` dit seulement si la vigilance la rend d'actualité.

View File

@@ -134,7 +134,13 @@ const degradedNote = !balance.crossing.found
const reason = balance.crossing.found
? `Croisement à ${balance.crossing.hour} h, ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}% (actuel ${c.currentSocPct}%). ${c.gridNeeded ? `Réseau : ${c.gridEnergyNeededKwh} kWh.` : 'Réseau non nécessaire.'}`
: `Aucun croisement PV/conso sur l'horizon : ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}%.`;
setText('input_text.evcs_balance_reason', reason + degradedNote + ndText);
// Fenetre HC (28/09) : dire quand c'est « tenir jusqu'a la prochaine HC » qui
// fixe l'exigence, sinon la raison affiche un croisement qui ne l'explique pas.
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);
// 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

@@ -1,5 +1,30 @@
{#- Fenetres d'heures creuses (28/09/2026). Le calendrier tarifaire reste dans
Home Assistant (helpers victron_evcs_hc_*) ; le bilan ne recoit que deux
instants : la fin de la fenetre HC en cours (vide hors fenetre) et le debut
de la fenetre HC suivante. Strategie du proprietaire : la batterie doit
tenir jusqu'a la prochaine fenetre HC. -#}
{%- set ns = namespace(fin='', suivant='', nuit='', fen=[]) -%}
{%- for j in [0, 1, 2] -%}
{%- for a, b, t in [('victron_evcs_hc_night_start', 'victron_evcs_hc_night_stop', 'nuit'), ('victron_evcs_hc_midday_start', 'victron_evcs_hc_midday_stop', 'midi')] -%}
{%- set d = today_at(states('input_datetime.' ~ a)) + timedelta(days=j) -%}
{%- set f = today_at(states('input_datetime.' ~ b)) + timedelta(days=j) -%}
{%- set f = f + timedelta(days=1) if f <= d else f -%}
{%- set ns.fen = ns.fen + [[d, f, t]] -%}
{%- endfor -%}
{%- endfor -%}
{%- for w in ns.fen | sort(attribute='0') -%}
{%- if w[0] <= now() < w[1] and ns.fin == '' -%}{%- set ns.fin = w[1] -%}{%- endif -%}
{%- endfor -%}
{%- set repere = ns.fin if ns.fin != '' else now() -%}
{%- for w in ns.fen | sort(attribute='0') -%}
{%- 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 -%}
{{ {
"ts": now().isoformat(),
"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,
"nowMs": as_timestamp(now()) * 1000,
"balance_enabled": states("input_boolean.evcs_balance_enabled"),