Files
EVCS/tests/energy-balance/scenarios-balance.mjs
Tallagrand Thierry 8de284cd17 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
2026-09-28 02:06:06 +02:00

437 lines
18 KiB
JavaScript

// Scénarios du nœud BALANCE.
// Le scénario central est un REJEU de la matinée du 24/08/2026 : la production
// horaire est celle réellement mesurée ce jour-là (heures 06-09 h non
// censurées, donc fiables), le SOC est celui relevé à 06:43, et la capacité
// est celle du parc réel (7 modules actifs, ~15 kWh).
// C'est la situation qui a déclenché tout le chantier : le seuil fixe de 30 %
// et le yoyo EDF de 2 à 4 minutes.
const P50 = [0.70,0.70,0.70,0.70,0.60,0.70,0.70,0.80,0.90,1.40,1.40,2.00,
2.40,1.90,2.10,1.90,1.80,2.00,1.50,1.70,1.60,1.30,1.30,1.10];
const P80 = [1.00,0.70,0.70,0.70,0.70,0.80,1.30,1.80,1.70,2.00,2.50,3.10,
3.50,2.60,2.50,2.30,2.20,2.40,2.30,1.90,1.90,1.60,1.40,1.20];
// Production horaire réellement mesurée le 24/08/2026.
const PROD_24_08 = {
6: 0.03, 7: 0.65, 8: 2.70, 9: 5.62, 10: 7.17, 11: 9.33,
12: 4.37, 13: 2.10, 14: 1.20, 15: 0.90, 16: 0.70, 17: 0.50,
18: 0.30, 19: 0.10, 20: 0.00, 21: 0.00, 22: 0.00, 23: 0.00,
};
function horizonFrom(startIso, hours, production) {
const h0 = Math.floor(Date.parse(startIso) / 3600000) * 3600000;
return Array.from({ length: hours }, (_, i) => {
const ms = h0 + i * 3600000;
// parseInt et non Number : en fr-FR le format renvoie « 06 h ».
const hour = Number.parseInt(new Intl.DateTimeFormat('fr-FR', {
timeZone: 'Europe/Paris', hour: '2-digit', hour12: false,
}).format(new Date(ms)), 10);
return {
startMs: ms,
hour,
expectedKwh: production[hour] ?? 0,
shapeQuality: hour >= 12 && hour <= 19 ? 'minoree' : 'fiable',
};
});
}
// ======== 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
// nombre de modules se lit dans un helper au lieu d'etre code. Ce scenario
// rejoue exactement la matinee du 24/08 avec quatre modules au lieu de
// sept. Meme energie a couvrir, meme creneau — seule la capacite change,
// donc le SOC exige monte de 30,5 a 35,2 %. Son jumeau ci-dessous, sans le
// champ, prouve que l'absence du helper ne change rien.
node: 'BALANCE',
id: 'bilan-parc-reduit-a-quatre-modules',
label: 'Pack ancien isole : quatre modules au lieu de sept, meme matinee du 24/08 → le SOC exige passe de 27,1 a 30,9 %',
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,
modules_en_service: 4,
},
expect: {
usable: true,
crossingHour: 8,
energyToCoverKwh: 0.82,
requiredSocPct: 30.9,
gridNeeded: true,
prudentRequiredSocPct: 48.2,
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 27,1 a 85 % ET le manque suit (10,34 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: 10.34,
// 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 27,1 % 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: 27.1,
prudentRequiredSocPct: 37,
},
},
{
node: 'BALANCE',
id: 'bilan-modules-valeur-aberrante-retombe-sur-sept',
label: 'Helper illisible (0 module) : on retombe sur les sept de la mesure plutot que de diviser par zero',
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,
modules_en_service: 0,
},
expect: {
usable: true,
requiredSocPct: 27.1,
prudentRequiredSocPct: 37,
},
},
{
node: 'BALANCE',
id: 'bilan-rejeu-24-08-matin',
label: 'Rejeu réel du 24/08 06:43 : croisement à 08 h, 0,82 kWh à couvrir → SOC requis 27,1 % (courbe plate remesuree 20/09). Le seuil fixe de 30 % tombait juste ce matin-là — par coïncidence, pas par construction',
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,
},
expect: {
usable: true,
crossingHour: 8,
energyToCoverKwh: 0.82,
requiredSocPct: 27.1,
gridNeeded: true,
prudentRequiredSocPct: 37,
deficitTrustworthy: true,
},
},
{
node: 'BALANCE',
id: 'bilan-soc-suffisant-pas-de-reseau',
label: 'Même matinée avec un SOC de 45 % : aucun réseau nécessaire',
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: 45,
usable_capacity_kwh: 15,
soc_floor_percent: 22,
},
expect: {
usable: true,
gridNeeded: false,
requiredSocPct: 27.1,
},
},
{
node: 'BALANCE',
id: 'bilan-nuit-sans-croisement',
label: 'Nuit sans production sur tout l\'horizon : le déficit complet est annoncé, pas masqué',
snapshot: {
nowMs: Date.parse('2026-08-24T22:00:00+02:00'),
consumption_p50: P50,
consumption_p80: P80,
horizon: horizonFrom('2026-08-24T22:00:00+02:00', 6, {}),
soc_percent: 60,
usable_capacity_kwh: 15,
soc_floor_percent: 22,
},
expect: {
usable: true,
crossingFound: false,
gridNeeded: false,
},
},
{
node: 'BALANCE',
id: 'bilan-prochaine-fenetre-deficit',
label: 'Projection à 15 h : le solaire couvre déjà, mais la nuit qui vient doit être annoncée',
snapshot: {
nowMs: Date.parse('2026-08-24T15:30:00+02:00'),
consumption_p50: P50,
consumption_p80: P80,
horizon: horizonFrom('2026-08-24T15:30:00+02:00', 24, {
...PROD_24_08,
15: 3.00, 16: 2.80, 17: 2.60, 18: 2.40, 19: 1.20, 20: 0.20,
// lendemain matin
7: 1.50, 8: 3.00, 9: 5.00,
}),
soc_percent: 95,
usable_capacity_kwh: 15,
soc_floor_percent: 22,
},
expect: {
usable: true,
// fenêtre en cours : le solaire couvre, rien à franchir
gridNeeded: false,
// mais la nuit qui vient doit être chiffrée
nextDeficitFound: true,
nextDeficitStartHour: 19,
nextDeficitEnergyKwh: 12.77,
// Au-dessus de 100 : même pleine, la batterie actuelle ne passe pas la
// nuit sur le profil prudent. C'est le chiffre du dimensionnement.
nextDeficitRequiredSocPct: 101.1,
storageShortfallKwh: 0.17,
},
},
{
node: 'BALANCE',
id: 'bilan-entrees-manquantes',
label: 'Capacité batterie absente : le bilan se déclare inutilisable, les contrôleurs gardent leurs règles',
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', 6, PROD_24_08),
soc_percent: 21,
},
expect: {
usable: false,
missing: 'usable_capacity_kwh',
},
},
// ================= RÉSERVE D'ORAGE (27/08/2026) =================
// Une coupure réseau n'arrête PAS l'installation : le solaire et la batterie
// continuent, seul le filet de secours disparaît. La réserve à tenir est donc
// la couverture PRUDENTE (p80) de la fenêtre de déficit qui compte — pas une
// nouvelle constante.
{
node: 'BALANCE',
id: 'orage-vigilance-absente',
label: 'Intégration Météo-France non installée : niveau indisponible, aucune activation, mais la réserve reste chiffrée',
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,
storm_alert_level: 'indisponible',
},
expect: {
usable: true,
stormLevel: 'indisponible',
stormActive: false,
// La réserve se calcule quand même : 2,42 kWh pour la matinée du 24/08
// sur le profil prudent, soit SOC 38,1 %.
stormReserveKwh: 2.42,
stormReserveSocPct: 37,
},
},
{
node: 'BALANCE',
id: 'orage-vigilance-jaune-pas-active',
label: 'Vigilance jaune : en dessous du seuil de Météo-France, la réserve est publiée mais pas déclarée d\'actualité',
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,
storm_alert_level: 'Jaune',
},
expect: {
usable: true,
stormLevel: 'jaune',
stormActive: false,
stormReserveKwh: 2.42,
},
},
{
node: 'BALANCE',
id: 'orage-vigilance-orange-deficit-decouvert',
label: 'Vigilance orange à 06:43 avec SOC 21 % : la matinée ne passe pas sans réseau, le manque est chiffré',
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,
storm_alert_level: 'Orange',
storm_alert_detail: { 'Orages': 'Orange', 'Vent violent': 'Jaune' },
},
expect: {
usable: true,
stormLevel: 'orange',
stormActive: true,
stormReserveKwh: 2.42,
stormReserveSocPct: 37,
stormCovered: false,
// SOC 21 contre 38,1 requis : il manque 2,57 kWh pour tenir la matinée
// sans réseau.
stormShortfallKwh: 2.58,
},
},
{
node: 'BALANCE',
id: 'orage-vigilance-rouge-surplus-nuit-a-venir',
label: 'Vigilance rouge à 15:30, batterie à 95 % : la fenêtre qui compte est la nuit qui vient, pas celle en cours',
snapshot: {
nowMs: Date.parse('2026-08-24T15:30:00+02:00'),
consumption_p50: P50,
consumption_p80: P80,
horizon: horizonFrom('2026-08-24T15:30:00+02:00', 24, {
...PROD_24_08,
15: 3.00, 16: 2.80, 17: 2.60, 18: 2.40, 19: 1.20, 20: 0.20,
7: 1.50, 8: 3.00, 9: 5.00,
}),
soc_percent: 95,
usable_capacity_kwh: 15,
soc_floor_percent: 22,
storm_alert_level: 'Rouge',
},
expect: {
usable: true,
stormLevel: 'rouge',
stormActive: true,
// Bascule sur la fenêtre de 19 h : 12,77 kWh, soit un SOC requis de
// 107,1 % — même pleine, la batterie actuelle ne passe pas la nuit sans
// réseau. C'est le signal de dimensionnement, il ne doit pas être écrêté.
stormReserveKwh: 12.77,
stormReserveSocPct: 101.1,
stormCovered: false,
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 },
},
];