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

@@ -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 ;

View File

@@ -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,

View File

@@ -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');

View File

@@ -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 }}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
},
},
]; ];

View File

@@ -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