Bilan : reserve d orage, en mode ombre

Une coupure reseau n arrete pas l installation : le solaire et la batterie
continuent, seul le filet de secours disparait. La reserve a tenir est donc la
couverture prudente p80 que le bilan calcule deja — aucune constante nouvelle.
En deficit c est la fenetre traversee, en surplus la prochaine, car a 15 h la
reponse « rien a couvrir » est exacte et inutile.

Le seuil d activation est la vigilance orange, echelle officielle de
Meteo-France et non un nombre choisi ici. Le snapshot lit l alerte sans
connaitre la commune, donc le code marche des l installation de l integration
et publie « indisponible » en attendant.

Tests 38 -> 42. Le fixture du test de perimetre ombre ne contenait pas storm :
les nouvelles ecritures n etaient pas couvertes. Corrige, assertion explicite,
preuve par mutation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
This commit is contained in:
Tallagrand Thierry
2026-08-27 12:05:15 +02:00
parent 99aba9da0d
commit cf7eb47356
9 changed files with 294 additions and 5 deletions

View File

@@ -193,6 +193,60 @@ function nextDeficitWindow(slots) {
const nextDeficit = nextDeficitWindow(prudent.slots);
// ---------------------------------------------------------------------
// RÉSERVE D'ORAGE (27/08/2026)
//
// Les coupures réseau sont fréquentes ici lors des orages. Point de départ
// souvent mal posé : une coupure n'arrête PAS l'installation. Le solaire et
// la batterie continuent de fonctionner, seul le filet de secours disparaît.
// La question n'est donc pas « combien stocker pour survivre » mais « la
// batterie traverse-t-elle la prochaine fenêtre de déficit SANS le réseau ».
//
// C'est exactement la couverture PRUDENTE (p80) déjà calculée plus haut :
// rien à inventer, aucune constante de plus. Le profil prudent est le bon
// choix ici parce qu'une coupure retire précisément la possibilité de
// rattraper une sous-estimation.
//
// Le niveau vient de la vigilance Météo-France, dont l'échelle officielle est
// vert/jaune/orange/rouge. « Actif » à partir d'orange : c'est le seuil de
// l'émetteur, pas un nombre choisi ici.
// ---------------------------------------------------------------------
const STORM_SCALE = ['vert', 'jaune', 'orange', 'rouge'];
const STORM_ACTIVE_FROM = STORM_SCALE.indexOf('orange');
const stormRaw = typeof input.storm_alert_level === 'string'
? input.storm_alert_level.trim().toLowerCase()
: '';
const stormIndex = STORM_SCALE.indexOf(stormRaw);
const stormKnown = stormIndex >= 0;
const stormActive = stormIndex >= STORM_ACTIVE_FROM;
// Phénomènes atteignant le niveau d'alerte, s'ils sont exposés en attributs
// (« Orages », « Vent violent »…). Purement informatif.
const stormDetail = (input.storm_alert_detail && typeof input.storm_alert_detail === 'object')
? input.storm_alert_detail
: {};
const stormPhenomena = Object.keys(stormDetail)
.filter(k => typeof stormDetail[k] === 'string'
&& STORM_SCALE.indexOf(stormDetail[k].trim().toLowerCase()) >= STORM_ACTIVE_FROM)
.sort();
// En surplus, la fenêtre qui compte est la prochaine (la nuit qui vient) ;
// en déficit, c'est celle qu'on est en train de traverser.
const inSurplusNow = prudent.found && prudent.crossingIndex === 0;
const stormWindow = (inSurplusNow && nextDeficit.found)
? {
kwh: nextDeficit.energyKwh,
socPct: nextDeficit.requiredSocAtStartPct,
startHour: nextDeficit.startHour,
}
: {
kwh: prudent.energyToCoverKwh,
socPct: prudent.requiredSocPct,
startHour: null,
};
const stormMarginPct = socPct - stormWindow.socPct;
const slots = typical.slots;
const crossingFound = typical.found;
const crossingIndex = typical.crossingIndex;
@@ -278,6 +332,24 @@ return [{
surplusAvailableKwh: round2(surplusAfterCrossingKwh),
},
// 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é.
storm: {
level: stormKnown ? stormRaw : 'indisponible',
known: stormKnown,
active: stormActive,
phenomena: stormPhenomena,
windowStartHour: stormWindow.startHour,
reserveKwh: round2(stormWindow.kwh),
reserveSocPct: round1(stormWindow.socPct),
socMarginPct: round1(stormMarginPct),
covered: stormMarginPct >= 0,
shortfallKwh: stormMarginPct < 0
? round2((Math.abs(stormMarginPct) / 100) * usableCapacityKwh)
: 0,
},
confidence,
slots,