// 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', }; }); } 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, }, }, ];