La courbe energie/SOC a ete mesuree sur sept modules. Retirer un module divise la capacite sans changer la forme de la courbe : les pentes sont donc mises a l'echelle du nombre reellement en service, lu dans input_number.evcs_balance_modules_en_service. Le controleur EVCS lit le meme helper pour sa capacite utile - les deux decrivent forcement le meme parc. Helper absent, illisible ou hors bornes => sept, la valeur de la mesure. Les 44 scenarios du bilan et les 80 golden passent donc sans qu'un seul attendu ait ete touche : la bascule ne change rien tant que personne ne l'ecrit. Deux scenarios couvrent la bascule elle-meme, l'un avec quatre modules (SOC exige 30,5 -> 35,2 %), l'autre avec une valeur aberrante qui doit retomber sur sept. Preuve par mutation : ignorer l'echelle fait tomber le premier et lui seul. Le genou a 33 % est conserve par PRUDENCE et non par mesure : l'hypothese est qu'il vient des cellules faibles du pack retire, donc qu'il disparait avec lui. Le garder revient a sous-estimer la reserve, c'est-a-dire a se tromper du bon cote. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
308 lines
10 KiB
JavaScript
308 lines
10 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',
|
|
};
|
|
});
|
|
}
|
|
|
|
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 30,5 a 35,2 %',
|
|
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: 35.2,
|
|
gridNeeded: true,
|
|
prudentRequiredSocPct: 51.7,
|
|
deficitTrustworthy: true,
|
|
},
|
|
},
|
|
{
|
|
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: 30.5,
|
|
prudentRequiredSocPct: 41,
|
|
},
|
|
},
|
|
{
|
|
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 30,5 % (courbe mesurée). 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: 30.5,
|
|
gridNeeded: true,
|
|
prudentRequiredSocPct: 41,
|
|
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: 30.5,
|
|
},
|
|
},
|
|
{
|
|
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: 102.2,
|
|
storageShortfallKwh: 0.38,
|
|
},
|
|
},
|
|
{
|
|
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: 41,
|
|
},
|
|
},
|
|
{
|
|
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: 41,
|
|
stormCovered: false,
|
|
// SOC 21 contre 38,1 requis : il manque 2,57 kWh pour tenir la matinée
|
|
// sans réseau.
|
|
stormShortfallKwh: 2.52,
|
|
},
|
|
},
|
|
{
|
|
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: 102.2,
|
|
stormCovered: false,
|
|
stormShortfallKwh: 1.22,
|
|
},
|
|
},
|
|
];
|