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:
@@ -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é.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -102,6 +102,10 @@ const SUMMARIZE = {
|
||||
stormReserveSocPct: o.storm.reserveSocPct,
|
||||
stormCovered: o.storm.covered,
|
||||
stormShortfallKwh: o.storm.shortfallKwh,
|
||||
// Tenir jusqu'a la prochaine fenetre HC (28/09).
|
||||
hcReachActive: o.hcReach ? o.hcReach.active : false,
|
||||
hcFloorPct: o.hcReach ? o.hcReach.floorPct : null,
|
||||
hcNightFloorPrudentPct: o.hcReach ? o.hcReach.nightFloorPrudentPct : null,
|
||||
}) : ({ usable: false, missing: o.missing.join(',') }),
|
||||
};
|
||||
|
||||
|
||||
@@ -35,6 +35,36 @@ function horizonFrom(startIso, hours, production) {
|
||||
});
|
||||
}
|
||||
|
||||
// ======== TENIR JUSQU'A LA PROCHAINE FENETRE HC (28/09/2026) ========
|
||||
// Donnees REELLES de la nuit du 26-27/09 : profils de conso publies ce jour-la,
|
||||
// prevision horaire Forecast.Solar dont disposait le systeme a 01:15 (25,1 kWh
|
||||
// pour le 27), parc de 13 modules. La variante « sombre » ramene la journee a
|
||||
// 18 kWh, ce qu'annoncait VRM pour le 27 (22 kWh reels).
|
||||
const P50_2709 = [1.10,0.80,0.70,0.60,0.70,0.70,0.70,0.70,0.80,0.90,1.50,1.70,1.40,1.80,1.50,1.20,1.10,0.90,0.90,1.10,1.70,1.50,1.40,1.20];
|
||||
const P80_2709 = [1.20,1.10,0.90,0.80,0.80,0.80,1.00,0.80,0.90,1.30,2.10,2.70,2.80,2.70,2.30,2.00,1.90,1.50,1.50,1.50,2.10,2.00,2.00,1.60];
|
||||
const FS_2709 = [0,0,0,0,0,0,0,0,0.24,1.06,1.87,2.51,2.97,3.26,3.28,3.07,2.67,2.11,1.38,0.61,0.06,0,0,0];
|
||||
const FS_2809 = [0,0,0,0,0,0,0,0,0.23,1.03,1.85,2.49,2.97,3.26,3.29,3.07,2.65,2.08,1.35,0.60,0.06,0,0,0];
|
||||
function horizon2709(startIso, hours, facteur27 = 1) {
|
||||
const h0 = Math.floor(Date.parse(startIso) / 3600000) * 3600000;
|
||||
return Array.from({ length: hours }, (_, i) => {
|
||||
const ms = h0 + i * 3600000;
|
||||
const hour = Number.parseInt(new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', hour12: false }).format(new Date(ms)), 10);
|
||||
const le27 = new Date(ms).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }) === '2026-09-27';
|
||||
return { startMs: ms, hour, expectedKwh: le27 ? FS_2709[hour] * facteur27 : FS_2809[hour], shapeQuality: 'fiable' };
|
||||
});
|
||||
}
|
||||
function snap2709(nowIso, soc, fenetre, facteur27 = 1) {
|
||||
const s = { nowMs: Date.parse(nowIso), consumption_p50: P50_2709, consumption_p80: P80_2709,
|
||||
horizon: horizon2709(nowIso, 40, facteur27), soc_percent: soc, usable_capacity_kwh: 23,
|
||||
soc_floor_percent: 22, modules_en_service: 13 };
|
||||
if (fenetre) Object.assign(s, { hc_window_end_ms: Date.parse(fenetre[0]),
|
||||
next_hc_start_ms: Date.parse(fenetre[1]), next_night_hc_start_ms: Date.parse(fenetre[2]) });
|
||||
return s;
|
||||
}
|
||||
const NUIT_2709 = ['2026-09-27T07:24:00+02:00', '2026-09-27T12:24:00+02:00', '2026-09-28T01:24:00+02:00'];
|
||||
const MIDI_2709 = ['2026-09-27T14:24:00+02:00', '2026-09-28T01:24:00+02:00', '2026-09-28T01:24:00+02:00'];
|
||||
const SOMBRE = 18 / 25.1;
|
||||
|
||||
export const balanceScenarios = [
|
||||
{
|
||||
// Le parc a change deux fois en une semaine et rechangera cet hiver : le
|
||||
@@ -359,4 +389,48 @@ export const balanceScenarios = [
|
||||
stormShortfallKwh: 0.98,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Nuit du 26-27/09 rejouee avec la prevision de l'epoque (25,1 kWh) : le
|
||||
// plancher de P12 (tenir jusqu'a la NUIT SUIVANTE, profil prudent) vaut
|
||||
// 48,4 % — la voiture pouvait puiser de 72 a 48 %, et c'est a peu pres ce qui
|
||||
// s'est passe (44 %). Calcul juste a zero marge : le reel (22 kWh) a vide la
|
||||
// batterie a 01:00. La recharge au reseau, elle, ne vise que midi (25,3 %).
|
||||
node: 'BALANCE',
|
||||
id: 'bilan-hc-nuit-2709-prevision-forecast-solar',
|
||||
label: 'Nuit 26-27/09, prevision 25,1 kWh : P12 plancher 48,4 % (nuit suivante), reseau vise midi 25,3 %',
|
||||
snapshot: snap2709('2026-09-27T01:20:00+02:00', 72, NUIT_2709),
|
||||
expect: { usable: true, requiredSocPct: 40.2, prudentRequiredSocPct: 48.4, gridNeeded: false,
|
||||
hcReachActive: true, hcFloorPct: 25.3, hcNightFloorPrudentPct: 48.4 },
|
||||
},
|
||||
{
|
||||
// Meme nuit, journee annoncee sombre (18 kWh, prevision VRM du 27) : le
|
||||
// plancher de P12 monte a 72,1 %, au-dessus du SOC — la JEEP se charge sur
|
||||
// le reseau en HC au lieu de vider la batterie. Strategie du proprietaire.
|
||||
node: 'BALANCE',
|
||||
id: 'bilan-hc-nuit-2709-journee-sombre-bloque-p12',
|
||||
label: 'Nuit 26-27/09, journee sombre (18 kWh) : P12 plancher 72,1 % > SOC 72 → voiture sur le reseau HC',
|
||||
snapshot: snap2709('2026-09-27T01:20:00+02:00', 72, NUIT_2709, SOMBRE),
|
||||
expect: { usable: true, requiredSocPct: 41.4, prudentRequiredSocPct: 72.1, gridNeeded: false,
|
||||
hcReachActive: true, hcNightFloorPrudentPct: 72.1 },
|
||||
},
|
||||
{
|
||||
// Midi sombre, batterie a 30 % : tenir jusqu'a 01:24 demande 38,1 % a la fin
|
||||
// de la fenetre -> session reseau de midi, et le MANQUE suit la cible
|
||||
// (2,42 kWh = 8,1 points x 0,30 kWh/point). Jamais de session muette.
|
||||
node: 'BALANCE',
|
||||
id: 'bilan-hc-midi-sombre-complete-la-batterie',
|
||||
label: 'Midi sombre, SOC 30 : cible 38,1 % pour tenir jusqu a 01:24, reseau 2,42 kWh (cible et manque ensemble)',
|
||||
snapshot: snap2709('2026-09-27T12:30:00+02:00', 30, MIDI_2709, SOMBRE),
|
||||
expect: { usable: true, requiredSocPct: 38.1, gridNeeded: true, gridEnergyNeededKwh: 2.42,
|
||||
hcReachActive: true, hcFloorPct: 38.1 },
|
||||
},
|
||||
{
|
||||
// Meme instant HORS fenetre HC (pas d'instants transmis) : rien ne change,
|
||||
// le bilan retombe sur le plancher dur apres le croisement.
|
||||
node: 'BALANCE',
|
||||
id: 'bilan-hc-hors-fenetre-inchange',
|
||||
label: 'Hors fenetre HC : aucune exigence ajoutee (22 %, pas de reseau)',
|
||||
snapshot: snap2709('2026-09-27T12:30:00+02:00', 30, null, SOMBRE),
|
||||
expect: { usable: true, requiredSocPct: 22, gridNeeded: false, hcReachActive: false, hcFloorPct: 0 },
|
||||
},
|
||||
];
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user