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:
@@ -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,
|
||||
|
||||
|
||||
@@ -100,6 +100,27 @@ if (nd.found) {
|
||||
setText('input_text.evcs_balance_next_deficit_window',
|
||||
nd.reason === 'deficit_en_cours' ? 'déficit en cours' : 'aucune sur l\'horizon');
|
||||
}
|
||||
// --- Réserve d'orage -----------------------------------------------------
|
||||
// Publiée même hors vigilance : le chiffre dit en permanence si l'installation
|
||||
// passerait la prochaine nuit sans réseau. Le helper SOC est borné à 100 pour
|
||||
// rester dans la plage de l'entrée ; le dépassement éventuel reste lisible
|
||||
// dans la raison et dans le manque en kWh.
|
||||
const st = balance.storm;
|
||||
if (st) {
|
||||
setText('input_text.evcs_balance_storm_level', st.level);
|
||||
setBool('input_boolean.evcs_balance_storm_active', !!st.active);
|
||||
setNumber('input_number.evcs_balance_storm_reserve_kwh', st.reserveKwh);
|
||||
setNumber('input_number.evcs_balance_storm_reserve_soc',
|
||||
Number.isFinite(st.reserveSocPct) ? Math.min(100, Math.max(0, st.reserveSocPct)) : null);
|
||||
setText('input_text.evcs_balance_storm_reason',
|
||||
(st.known ? `Vigilance ${st.level}` : 'Vigilance indisponible')
|
||||
+ (st.phenomena.length ? ` (${st.phenomena.join(', ')})` : '')
|
||||
+ ` — sans réseau il faut ${st.reserveKwh} kWh`
|
||||
+ (st.windowStartHour == null ? ' pour la fenêtre en cours' : ` dès ${st.windowStartHour} h`)
|
||||
+ `, soit SOC ${st.reserveSocPct}% ; actuel ${c.currentSocPct}% → `
|
||||
+ (st.covered ? 'couvert.' : `manque ${st.shortfallKwh} kWh.`));
|
||||
}
|
||||
|
||||
setText('input_text.evcs_balance_crossing_hour',
|
||||
balance.crossing.found ? `${String(balance.crossing.hour).padStart(2, '0')} h` : 'aucun sur l\'horizon');
|
||||
|
||||
|
||||
@@ -47,5 +47,12 @@
|
||||
"forecast_tomorrow_kwh": states("sensor.energy_production_tomorrow"),
|
||||
"forecast_next_hour_w": states("sensor.power_production_next_hour"),
|
||||
|
||||
"sun_elevation": state_attr("sun.sun", "elevation")
|
||||
"sun_elevation": state_attr("sun.sun", "elevation"),
|
||||
|
||||
"storm_alert_level": (states.sensor
|
||||
| selectattr('entity_id', 'search', '_weather_alert')
|
||||
| map(attribute='state') | list | first) | default('indisponible', true),
|
||||
"storm_alert_detail": (states.sensor
|
||||
| selectattr('entity_id', 'search', '_weather_alert')
|
||||
| map(attribute='attributes') | list | first) | default({}, true)
|
||||
} | tojson }}
|
||||
|
||||
@@ -46,6 +46,13 @@ return [{
|
||||
// de code, cf. backlog du chantier).
|
||||
soc_floor_percent: 22,
|
||||
|
||||
// Vigilance Météo-France, lue sans connaître la commune (l'entité
|
||||
// s'appelle sensor.<commune>_weather_alert). Absente tant que
|
||||
// l'intégration n'est pas installée : le bilan publie alors
|
||||
// 'indisponible' et ne change rien.
|
||||
storm_alert_level: snapshot.storm_alert_level,
|
||||
storm_alert_detail: snapshot.storm_alert_detail,
|
||||
|
||||
// Repris pour l'écriture des helpers, sans influencer le calcul.
|
||||
resourceUsable: resource.usable,
|
||||
shapeConfidence: resource.source ? resource.source.shapeConfidence : null,
|
||||
|
||||
Reference in New Issue
Block a user