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:
52
HANDOFF.md
52
HANDOFF.md
@@ -1313,6 +1313,58 @@ hiver doit se faire APRÈS ce correctif, sous peine de dimensionner sur une
|
|||||||
installation qui perd les trois quarts de sa production dès que le réseau est
|
installation qui perd les trois quarts de sa production dès que le réseau est
|
||||||
présent.
|
présent.
|
||||||
|
|
||||||
|
### Réserve d'orage (déployé 27/08 12:04, mode ombre, tests 42/42)
|
||||||
|
Besoin : les coupures réseau d'orage sont fréquentes sur ce site, et le
|
||||||
|
propriétaire veut garder de quoi tenir jusqu'au retour du solaire.
|
||||||
|
|
||||||
|
**Point de départ, souvent mal posé** : une coupure n'arrête PAS
|
||||||
|
l'installation. Le solaire et la batterie continuent, 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 réseau ».
|
||||||
|
C'est **exactement la couverture prudente (p80) que le bilan calcule déjà** :
|
||||||
|
aucune constante nouvelle. Le profil prudent est le bon choix ici précisément
|
||||||
|
parce qu'une coupure retire la possibilité de rattraper une sous-estimation.
|
||||||
|
|
||||||
|
Chaîne : `HA_SNAPSHOT_TEMPLATE` lit la vigilance **sans connaître la commune**
|
||||||
|
(`states.sensor | selectattr('entity_id','search','_weather_alert')`) →
|
||||||
|
`PREPARE_BALANCE` passe-plat → `BALANCE` calcule le bloc `storm` →
|
||||||
|
`BUILD_HA_CALLS` publie 5 helpers `evcs_balance_storm_*`.
|
||||||
|
|
||||||
|
Fenêtre retenue : en déficit, celle qu'on traverse (couverture prudente) ; en
|
||||||
|
surplus, la prochaine (`nextDeficit`) — car à 15 h la réponse « rien à couvrir »
|
||||||
|
est exacte et inutile, le risque étant la nuit qui vient.
|
||||||
|
|
||||||
|
Seuil d'activation : **vigilance orange**, c'est-à-dire l'échelle officielle de
|
||||||
|
Météo-France (vert/jaune/orange/rouge) et non un nombre choisi ici.
|
||||||
|
|
||||||
|
Helpers (package `evcs_energy_balance.yaml`, sauvegarde
|
||||||
|
`*.bak.20260827`, rechargés et vérifiés) : `evcs_balance_storm_level` ·
|
||||||
|
`_storm_active` · `_storm_reserve_kwh` · `_storm_reserve_soc` (borné à 100 pour
|
||||||
|
la plage de l'entrée) · `_storm_reason`.
|
||||||
|
|
||||||
|
Tests 38 → 42. Deux résultats notables figés :
|
||||||
|
- 24/08 06:43, SOC 21 % : réserve 2,42 kWh (SOC 38,1 %) → **manque 2,57 kWh**
|
||||||
|
pour tenir la matinée sans réseau ;
|
||||||
|
- 24/08 15:30, SOC 95 %, vigilance rouge : la fenêtre qui compte devient celle
|
||||||
|
de 19 h, 12,77 kWh, **SOC requis 107,1 %** — même pleine, la batterie
|
||||||
|
actuelle ne passe pas la nuit sans réseau. Valeur volontairement NON écrêtée :
|
||||||
|
c'est le signal de dimensionnement pour l'achat de batteries de cet hiver.
|
||||||
|
|
||||||
|
Garantie mode ombre étendue : le fixture synthétique `BALANCE_OK` de
|
||||||
|
`scenarios-shadow.mjs` ne contenait pas `storm`, donc les nouvelles écritures
|
||||||
|
n'étaient PAS couvertes par le test de périmètre. Corrigé, plus une assertion
|
||||||
|
explicite `wroteStorm: 5`. Preuve par mutation : une écriture détournée vers
|
||||||
|
`input_boolean.victron_evcs_night_grid_takeover_needed` fait échouer la suite
|
||||||
|
(`outOfScope` la nomme).
|
||||||
|
|
||||||
|
**RESTE À FAIRE — action utilisateur** : l'intégration **Météo-France n'est pas
|
||||||
|
installée** (HA n'a que `met` / Met.no). Elle est gratuite, sans clé d'API,
|
||||||
|
intégrée au cœur de HA. Le flux de configuration a été testé par API : il
|
||||||
|
réclame un **nom de commune** et refuse les coordonnées (`{"city":"empty"}` sur
|
||||||
|
48.7732657 / -3.5187179) — donc à ajouter par l'interface, ou à me donner la
|
||||||
|
commune. Tant qu'elle est absente, le bilan publie `indisponible`, `active`
|
||||||
|
reste faux, et la réserve est quand même chiffrée : **rien ne casse**.
|
||||||
|
|
||||||
### Historique HA plus court que configuré
|
### Historique HA plus court que configuré
|
||||||
`configuration.yaml` déclare `purge_keep_days: 90`, mais l'historique brut
|
`configuration.yaml` déclare `purge_keep_days: 90`, mais l'historique brut
|
||||||
s'arrête au **20/08** (~7 jours) : testé vide au 01/06, 29/07, 10/08, 17/08 ;
|
s'arrête au **20/08** (~7 jours) : testé vide au 01/06, 29/07, 10/08, 17/08 ;
|
||||||
|
|||||||
@@ -193,6 +193,60 @@ function nextDeficitWindow(slots) {
|
|||||||
|
|
||||||
const nextDeficit = nextDeficitWindow(prudent.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 slots = typical.slots;
|
||||||
const crossingFound = typical.found;
|
const crossingFound = typical.found;
|
||||||
const crossingIndex = typical.crossingIndex;
|
const crossingIndex = typical.crossingIndex;
|
||||||
@@ -278,6 +332,24 @@ return [{
|
|||||||
surplusAvailableKwh: round2(surplusAfterCrossingKwh),
|
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,
|
confidence,
|
||||||
slots,
|
slots,
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,27 @@ if (nd.found) {
|
|||||||
setText('input_text.evcs_balance_next_deficit_window',
|
setText('input_text.evcs_balance_next_deficit_window',
|
||||||
nd.reason === 'deficit_en_cours' ? 'déficit en cours' : 'aucune sur l\'horizon');
|
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',
|
setText('input_text.evcs_balance_crossing_hour',
|
||||||
balance.crossing.found ? `${String(balance.crossing.hour).padStart(2, '0')} h` : 'aucun sur l\'horizon');
|
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_tomorrow_kwh": states("sensor.energy_production_tomorrow"),
|
||||||
"forecast_next_hour_w": states("sensor.power_production_next_hour"),
|
"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 }}
|
} | tojson }}
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ return [{
|
|||||||
// de code, cf. backlog du chantier).
|
// de code, cf. backlog du chantier).
|
||||||
soc_floor_percent: 22,
|
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.
|
// Repris pour l'écriture des helpers, sans influencer le calcul.
|
||||||
resourceUsable: resource.usable,
|
resourceUsable: resource.usable,
|
||||||
shapeConfidence: resource.source ? resource.source.shapeConfidence : null,
|
shapeConfidence: resource.source ? resource.source.shapeConfidence : null,
|
||||||
|
|||||||
@@ -88,6 +88,14 @@ const SUMMARIZE = {
|
|||||||
nextDeficitEnergyKwh: o.nextDeficit.found ? o.nextDeficit.energyKwh : null,
|
nextDeficitEnergyKwh: o.nextDeficit.found ? o.nextDeficit.energyKwh : null,
|
||||||
nextDeficitRequiredSocPct: o.nextDeficit.found ? o.nextDeficit.requiredSocAtStartPct : null,
|
nextDeficitRequiredSocPct: o.nextDeficit.found ? o.nextDeficit.requiredSocAtStartPct : null,
|
||||||
storageShortfallKwh: o.nextDeficit.found ? Math.round(o.nextDeficit.storageShortfallKwh * 100) / 100 : null,
|
storageShortfallKwh: o.nextDeficit.found ? Math.round(o.nextDeficit.storageShortfallKwh * 100) / 100 : null,
|
||||||
|
// Réserve d'orage : niveau lu, activation, et le chiffre qui compte —
|
||||||
|
// ce qu'il faut en batterie pour traverser sans réseau.
|
||||||
|
stormLevel: o.storm.level,
|
||||||
|
stormActive: o.storm.active,
|
||||||
|
stormReserveKwh: o.storm.reserveKwh,
|
||||||
|
stormReserveSocPct: o.storm.reserveSocPct,
|
||||||
|
stormCovered: o.storm.covered,
|
||||||
|
stormShortfallKwh: o.storm.shortfallKwh,
|
||||||
}) : ({ usable: false, missing: o.missing.join(',') }),
|
}) : ({ usable: false, missing: o.missing.join(',') }),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,6 +109,9 @@ SUMMARIZE.BUILD_HA_CALLS = (calls) => {
|
|||||||
outOfScope: entities.filter(e => !/^(input_text|input_number|input_boolean)\.evcs_balance_/.test(e)).join(','),
|
outOfScope: entities.filter(e => !/^(input_text|input_number|input_boolean)\.evcs_balance_/.test(e)).join(','),
|
||||||
wroteRequiredSoc: entities.includes('input_number.evcs_balance_required_soc'),
|
wroteRequiredSoc: entities.includes('input_number.evcs_balance_required_soc'),
|
||||||
wroteGridNeeded: entities.includes('input_boolean.evcs_balance_grid_needed'),
|
wroteGridNeeded: entities.includes('input_boolean.evcs_balance_grid_needed'),
|
||||||
|
// Les 5 écritures de la réserve d'orage sont-elles bien émises ET dans le
|
||||||
|
// périmètre ombre ? (outOfScope les attraperait si elles en sortaient).
|
||||||
|
wroteStorm: entities.filter(e => /evcs_balance_storm_/.test(e)).length,
|
||||||
hasLogbook: calls.some(c => c.json.domain === 'logbook'),
|
hasLogbook: calls.some(c => c.json.domain === 'logbook'),
|
||||||
wroteProfiles: entities.includes('input_text.evcs_balance_consumption_p50'),
|
wroteProfiles: entities.includes('input_text.evcs_balance_consumption_p50'),
|
||||||
status: (calls.find(c => c.json.data.entity_id === 'input_text.evcs_balance_status') || { json: { data: {} } }).json.data.value,
|
status: (calls.find(c => c.json.data.entity_id === 'input_text.evcs_balance_status') || { json: { data: {} } }).json.data.value,
|
||||||
|
|||||||
@@ -145,4 +145,113 @@ export const balanceScenarios = [
|
|||||||
missing: 'usable_capacity_kwh',
|
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: 38.1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: 38.1,
|
||||||
|
stormCovered: false,
|
||||||
|
// SOC 21 contre 38,1 requis : il manque 2,57 kWh pour tenir la matinée
|
||||||
|
// sans réseau.
|
||||||
|
stormShortfallKwh: 2.57,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: 107.1,
|
||||||
|
stormCovered: false,
|
||||||
|
stormShortfallKwh: 1.82,
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ const BALANCE_OK = {
|
|||||||
},
|
},
|
||||||
vehicle: { surplusAvailableKwh: 20.17 },
|
vehicle: { surplusAvailableKwh: 20.17 },
|
||||||
confidence: { deficitTrustworthy: true },
|
confidence: { deficitTrustworthy: true },
|
||||||
|
// La réserve d'orage fait partie de la sortie normale de BALANCE : elle doit
|
||||||
|
// figurer ici, sinon la garantie de périmètre ne couvre pas ses écritures.
|
||||||
|
storm: {
|
||||||
|
level: 'orange', known: true, active: true, phenomena: ['Orages'],
|
||||||
|
windowStartHour: null, reserveKwh: 2.42, reserveSocPct: 38.1,
|
||||||
|
socMarginPct: -17.1, covered: false, shortfallKwh: 2.57,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Le même bilan, mais sans croisement trouvé sur l'horizon : le SOC requis
|
// Le même bilan, mais sans croisement trouvé sur l'horizon : le SOC requis
|
||||||
@@ -54,6 +61,9 @@ export const shadowScenarios = [
|
|||||||
outOfScope: '',
|
outOfScope: '',
|
||||||
wroteRequiredSoc: true,
|
wroteRequiredSoc: true,
|
||||||
wroteGridNeeded: true,
|
wroteGridNeeded: true,
|
||||||
|
// Les 5 écritures de la réserve d'orage sont émises et restent dans le
|
||||||
|
// périmètre ombre.
|
||||||
|
wroteStorm: 5,
|
||||||
hasLogbook: true,
|
hasLogbook: true,
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user