818 lines
40 KiB
JavaScript
818 lines
40 KiB
JavaScript
// EVCS Energy Balance — BALANCE
|
|
// =====================================================================
|
|
// Rôle : produire les FAITS qui remplacent les seuils fixes. Rien d'autre.
|
|
// Ce nœud ne pilote ni EDF, ni la borne : il publie des grandeurs que les deux
|
|
// contrôleurs liront à la place de leurs constantes.
|
|
//
|
|
// La question à laquelle il répond n'est pas « le SOC est-il au-dessus de
|
|
// 30 % ? » mais « la batterie contient-elle de quoi tenir jusqu'à ce que le
|
|
// solaire prenne le relais ? ». Le 24/08/2026 à 06:43, la réponse du seuil fixe
|
|
// et celle du bilan diffèrent : la production ne couvrait la maison qu'à partir
|
|
// de 09 h, il fallait donc environ 0,8 kWh de réserve, soit ~5 points de SOC —
|
|
// et non un palier arbitraire à 30 %.
|
|
//
|
|
// Entrées (toutes fournies, aucune constante physique en dur) :
|
|
// consumption_p50[24] profil horaire mesuré, valeur typique (CONSUMPTION_PROFILE)
|
|
// consumption_p80[24] idem, valeur prudente
|
|
// horizon[] production attendue heure par heure (RESOURCE_ESTIMATE)
|
|
// soc_percent SOC lissé pour le pilotage
|
|
// usable_capacity_kwh capacité réellement disponible du parc batterie
|
|
// soc_floor_percent plancher dur de sécurité
|
|
//
|
|
// Sorties : heure de croisement · énergie à couvrir · SOC requis · marge
|
|
// disponible pour le véhicule · niveau de confiance.
|
|
// =====================================================================
|
|
|
|
function num(v, fallback = NaN) {
|
|
const n = Number.parseFloat(v);
|
|
return Number.isFinite(n) ? n : fallback;
|
|
}
|
|
|
|
function round1(v) {
|
|
return Number.isFinite(v) ? Math.round(v * 10) / 10 : null;
|
|
}
|
|
|
|
function round2(v) {
|
|
return Number.isFinite(v) ? Math.round(v * 100) / 100 : null;
|
|
}
|
|
|
|
const input = $input.first().json;
|
|
|
|
const CFG = {
|
|
TIMEZONE: 'Europe/Paris',
|
|
// Au-delà de cet horizon sans croisement trouvé, on cesse de chercher : la
|
|
// réponse n'aurait plus de sens opérationnel.
|
|
SEARCH_HOURS: 40,
|
|
// PAS de marge de sécurité supplémentaire. La prudence est déjà portée,
|
|
// entièrement, par le choix du percentile du profil de consommation (p80),
|
|
// qui est mesuré. Vérifié le 24/08/2026 : sur toutes les fenêtres testées,
|
|
// p80(somme des heures) est égal a somme(p80 par heure) — les heures de
|
|
// consommation sont fortement corrélées, une matinée chargée l'étant sur
|
|
// plusieurs heures d'affilée. Sommer les p80 ne cumule donc PAS deux
|
|
// pessimismes, et ajouter un coefficient par-dessus ne serait qu'une
|
|
// constante arbitraire de plus.
|
|
};
|
|
|
|
function profileOf(key) {
|
|
const a = input[key];
|
|
return Array.isArray(a) && a.length === 24 ? a.map(v => Math.max(0, num(v, 0))) : null;
|
|
}
|
|
const consumptionP50 = profileOf('consumption_p50');
|
|
const consumptionP80 = profileOf('consumption_p80');
|
|
|
|
const horizon = Array.isArray(input.horizon) ? input.horizon : null;
|
|
|
|
const socPct = num(input.soc_percent, NaN);
|
|
const usableCapacityKwh = num(input.usable_capacity_kwh, NaN);
|
|
const socFloorPct = num(input.soc_floor_percent, 22);
|
|
|
|
// =====================================================
|
|
// CHARGE HC MANUELLE — UN PLANCHER, PAS UNE CIBLE (18/09/2026)
|
|
// =====================================================
|
|
// Le proprietaire veut pouvoir demander une recharge batterie en heures creuses
|
|
// quand le bilan ne l'a pas jugee necessaire — journee d'hiver courte, ciel
|
|
// couvert. La decision qu'il a tranchee : CONTRAINDRE le bilan plutot que de
|
|
// l'ignorer, pour garder un seul arbitre. Ce projet a deja paye cher les
|
|
// automates concurrents (charges programmees de l'ESS §26, consigne a 4200 W
|
|
// §27, plancher SOC du GX §27).
|
|
//
|
|
// ⚠️ LE FORCAGE DOIT ETRE ICI, EN AMONT, ET NULLE PART AILLEURS. Mesure du
|
|
// 18/09 sur banc : ecrire directement dans `input_number.evcs_balance_required_soc`
|
|
// engage bien la session — contacteur ferme, takeover ON — mais laisse la
|
|
// consigne reseau a 700 W, donc la batterie recoit une centaine de watts. C'est
|
|
// le defaut du 01/09 et du 04/09 a l'identique, et c'est le seul resultat des
|
|
// trois qui soit INDIAGNOSTICABLE depuis le tableau de bord : il a l'air de
|
|
// marcher. La raison : `required_soc` ENGAGE la session, `grid_energy_needed_kwh`
|
|
// la FINANCE, et les deux sont publiees separement. Pose ici, dans `coverage()`,
|
|
// le plancher fait bouger les deux ENSEMBLE par construction — c'est une
|
|
// consequence de la forme du code et non une discipline a tenir.
|
|
//
|
|
// ⚠️ ET CE PLANCHER EST DEJA FILTRE PAR L'HEURE QUAND IL ARRIVE ICI. Le bilan
|
|
// ignore totalement les fenetres d'heures creuses (aucune occurrence de
|
|
// `hc_night` / `hc_midday` dans ce sous-systeme) et doit le rester : il repond a
|
|
// « de combien la batterie a-t-elle besoin », pas a « quand est-ce bon marche ».
|
|
// C'est Home Assistant qui detient le calendrier tarifaire et qui ne publie une
|
|
// valeur non nulle que pendant une fenetre creuse. Sans ce filtrage en amont, un
|
|
// plancher a 85 % fuirait dans la journee et mordrait sur
|
|
// `BATTERY_CLOUD_LOCKS.js` — `eveningSocStartFloorPct = max(eveningSocStartMin,
|
|
// balanceRequiredSocPct)` — au point de bloquer la charge du vehicule le soir.
|
|
const manualHcFloorPct = (() => {
|
|
const v = num(input.manual_hc_floor_percent, 0);
|
|
return Number.isFinite(v) && v > socFloorPct && v <= 100 ? v : 0;
|
|
})();
|
|
|
|
// Sans l'une de ces entrées, le bilan ne peut rien affirmer. Il le dit, et les
|
|
// contrôleurs retombent sur leurs règles historiques.
|
|
const missing = [];
|
|
if (!consumptionP50) missing.push('consumption_p50');
|
|
if (!consumptionP80) missing.push('consumption_p80');
|
|
if (!horizon || !horizon.length) missing.push('horizon');
|
|
if (!Number.isFinite(socPct)) missing.push('soc_percent');
|
|
if (!Number.isFinite(usableCapacityKwh) || usableCapacityKwh <= 0) missing.push('usable_capacity_kwh');
|
|
|
|
if (missing.length) {
|
|
return [{
|
|
json: {
|
|
usable: false,
|
|
missing,
|
|
reason: `Bilan indisponible : ${missing.join(', ')}. Les contrôleurs doivent conserver leurs règles.`,
|
|
cfg: CFG,
|
|
},
|
|
}];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Bilan heure par heure, calculé DEUX FOIS.
|
|
//
|
|
// Pourquoi deux niveaux de prudence et pas un seul : la décision « faut-il du
|
|
// réseau maintenant ? » est réévaluée à chaque cycle et reste réversible. Se
|
|
// tromper par optimisme ne coûte qu'un démarrage réseau une heure plus tard,
|
|
// le plancher dur garantissant la batterie ; se tromper par excès de prudence
|
|
// coûte un import réseau bien réel, immédiatement.
|
|
//
|
|
// Vérifié sur la matinée du 24/08/2026 : avec le profil prudent, le bilan
|
|
// exigerait 48 % de SOC pour franchir la matinée, alors que la consommation
|
|
// réelle de ce jour-là n'a demandé que 0,8 kWh — le seuil fixe de 30 % aurait
|
|
// été MOINS coûteux. Un bilan « intelligent » plus gourmand que le seuil bête
|
|
// qu'il remplace n'a aucun intérêt.
|
|
//
|
|
// - profil TYPIQUE (p50) → décision réseau immédiate, réévaluée en continu.
|
|
// - profil PRUDENT (p80) → planification : cible de nuit, dimensionnement
|
|
// du stockage, choix qui engagent plusieurs heures.
|
|
// ---------------------------------------------------------------------
|
|
// ---------------------------------------------------------------------
|
|
// LA NUIT PREVUE PAR LA SOIREE (28/09/2026, demande du proprietaire)
|
|
//
|
|
// Le profil p50 est une mediane HORAIRE : il ignore la soiree qui vient de se
|
|
// passer. Or sur 24 nuits, la maison consomme d'autant plus de 21:30 a 09:00
|
|
// que la soiree 18:00-21:30 a ete chargee (four) : nuit = 6,98 + 0,74 x soiree,
|
|
// r2 = 0,40. Le service d'historisation publie le FACTEUR nuit prevue / nuit
|
|
// moyenne (sensor.evcs_nuit_maison_prevue_kwh) ; on multiplie le profil par ce
|
|
// facteur sur la seule fenetre 21:30 -> 09:00, au prorata des heures a cheval.
|
|
// Le 28/09 : soiree 6,27 kWh -> facteur 1,09.
|
|
//
|
|
// Un FACTEUR et non des kWh : le profil vient des compteurs d'energie, la
|
|
// regression des puissances integrees ; le rapport annule l'ecart de methode.
|
|
// Dans les deux sens pour le profil TYPIQUE (c'est un estimateur, une soiree
|
|
// legere allege la nuit) ; le PRUDENT ne descend jamais : max(p80, p50 recale).
|
|
// Filet : facteur borne a [0,75 ; 1,45], les extremes des 24 nuits observees
|
|
// (0,77 et 1,41 fois la moyenne) — hors de la, c'est une mesure fausse, pas une
|
|
// nuit. Facteur absent, perime (> 30 min) ou hors fenetre : profil inchange.
|
|
const NIGHT_FACTOR_MIN = 0.75;
|
|
const NIGHT_FACTOR_MAX = 1.45;
|
|
const nowMsNight = num(input.nowMs, Date.now());
|
|
const nightStartMs = num(input.night_house_start_ms, NaN);
|
|
const nightEndMs = num(input.night_house_end_ms, NaN);
|
|
const nightFactorRaw = num(input.night_house_factor, NaN);
|
|
const nightComputedMs = num(input.night_house_computed_ms, NaN);
|
|
const nightModelActive =
|
|
Number.isFinite(nightFactorRaw) && nightFactorRaw > 0 &&
|
|
Number.isFinite(nightStartMs) && Number.isFinite(nightEndMs) && nightEndMs > nightStartMs &&
|
|
nowMsNight >= nightStartMs && nowMsNight < nightEndMs &&
|
|
Number.isFinite(nightComputedMs) && nowMsNight - nightComputedMs <= 30 * 60000;
|
|
const nightFactor = nightModelActive
|
|
? Math.min(NIGHT_FACTOR_MAX, Math.max(NIGHT_FACTOR_MIN, nightFactorRaw))
|
|
: 1;
|
|
function nightShare(startMs) {
|
|
const debut = num(startMs, NaN);
|
|
if (!nightModelActive || !Number.isFinite(debut)) return 0;
|
|
const a = Math.max(debut, nightStartMs), b = Math.min(debut + 3600000, nightEndMs);
|
|
return b > a ? (b - a) / 3600000 : 0;
|
|
}
|
|
function nightScaled(profile, slot) {
|
|
const h = num(slot.hour, 0);
|
|
return (profile[h] ?? 0) * (1 + (nightFactor - 1) * nightShare(slot.startMs));
|
|
}
|
|
|
|
function buildSlots(consumption, prudent = false) {
|
|
return horizon.slice(0, CFG.SEARCH_HOURS).map(slot => {
|
|
const hour = num(slot.hour, 0);
|
|
const productionKwh = Math.max(0, num(slot.expectedKwh, 0));
|
|
const consumptionKwh = prudent
|
|
? Math.max(consumption[hour] ?? 0, nightScaled(consumptionP50, slot))
|
|
: nightScaled(consumption, slot);
|
|
return {
|
|
startMs: slot.startMs,
|
|
hour,
|
|
productionKwh: round2(productionKwh),
|
|
consumptionKwh: round2(consumptionKwh),
|
|
netKwh: round2(productionKwh - consumptionKwh),
|
|
shapeQuality: slot.shapeQuality || 'inconnue',
|
|
};
|
|
});
|
|
}
|
|
|
|
// =====================================================================
|
|
// COURBE D'ENERGIE DE LA BATTERIE — MESUREE, PAS SUPPOSEE (03/09/2026)
|
|
// =====================================================================
|
|
// La conversion energie <-> points de SOC etait une CONSTANTE
|
|
// (`usable_capacity_kwh`, 15 kWh) la ou la realite est une COURBE.
|
|
//
|
|
// Mesure : 576 paliers de SOC sur 8 jours (`tools/mesure-courbe-batterie.mjs`).
|
|
// Pour chaque palier ou le SOC reste a une valeur puis DESCEND d'un point, on
|
|
// integre la puissance batterie — en ecartant les paliers ou la batterie a
|
|
// charge ou ou le reseau a apporte quoi que ce soit.
|
|
//
|
|
// tranche paliers kWh/point
|
|
// 21-31 88 0,097 <- effondrement pres du genou
|
|
// 35-44 78 0,171
|
|
// 45-54 76 0,169
|
|
// 55-64 85 0,163
|
|
// 65-74 80 0,171
|
|
// 75-84 81 0,170
|
|
// 85-94 62 0,169
|
|
//
|
|
// Plate a 0,169 de 33 a 93, genou net a 32-33, puis 0,097 : sous le genou, un
|
|
// point de SOC vaut 57 % de ce qu'il vaut au-dessus. Coherent avec §0 (un
|
|
// module en panne sur huit, un autre qui faiblit) et avec l'effondrement de la
|
|
// jauge deja signale au §28 — ceci lui donne sa courbe.
|
|
//
|
|
// CE QUE LA CONSTANTE FAISAIT : 15 kWh se situe ENTRE les deux pentes, donc
|
|
// l'erreur CHANGE DE SIGNE selon l'energie a couvrir —
|
|
// 1 kWh a couvrir : SOC requis 28,7 % annonce contre 32,3 % reel (-3,6)
|
|
// 4,3 kWh : 50,7 contre 52,1 (-1,4)
|
|
// 8 kWh : 75,3 contre 74,0 (+1,3)
|
|
// 11,7 kWh : 99,7 contre 95,7 (+4,1)
|
|
// Trop BAS en fin de nuit (la direction dangereuse), trop HAUT en debut.
|
|
//
|
|
// Energie utile totale de 100 a 22 % : 12,39 kWh mesures, contre 11,70 vus par
|
|
// le bilan et 13,65 par INPUT_PARSE. Les deux constantes existantes etaient
|
|
// fausses, dans des sens opposes.
|
|
//
|
|
// ⚠️ A RE-MESURER apres toute evolution du parc (extension prevue cet hiver) :
|
|
// `node tools/mesure-courbe-batterie.mjs 8`, puis mettre a jour ces trois
|
|
// nombres. Ce ne sont pas des reglages, ce sont des resultats de mesure.
|
|
//
|
|
// ⚠️ CES PENTES ONT ETE MESUREES SUR SEPT MODULES. Retirer ou ajouter un module
|
|
// divise ou multiplie la capacite sans changer la FORME de la courbe : on les
|
|
// exprime donc par module, et on les multiplie par le nombre reellement en
|
|
// service. Ce nombre se lit dans un helper — `input_number
|
|
// .evcs_balance_modules_en_service` — et non dans le code : le parc a change
|
|
// deux fois en une semaine et changera encore cet hiver. Helper absent ou
|
|
// illisible => 7, la valeur sur laquelle la mesure a ete faite, donc aucun
|
|
// changement de comportement tant que personne ne l'ecrit.
|
|
const MODULES_MESURE = 7;
|
|
const modulesEnService = (() => {
|
|
const n = num(input.modules_en_service, MODULES_MESURE);
|
|
return Number.isFinite(n) && n >= 1 && n <= 24 ? n : MODULES_MESURE;
|
|
})();
|
|
const echelleParc = modulesEnService / MODULES_MESURE;
|
|
|
|
// 20/09/2026 — RE-MESURE sur le parc ASSAINI (13 equivalents US2000 : 4 US5000
|
|
// + 5 US2000C, cellules a 3-6 mV). `tools/mesure-courbe-batterie.mjs 8` :
|
|
// 0,30 kWh/point PLAT de 25 a 97 %, PLUS DE GENOU. Le genou a 33 % etait bien
|
|
// la signature de la cellule 12 morte du pack ancien (§50) — il a disparu avec
|
|
// elle, exactement comme le predisait la note de prudence du 03/09. La reserve
|
|
// reelle a quasiment double (12,4 -> ~23 kWh utiles de 100 a 22 %).
|
|
// Base normalisee a 7 modules (MODULES_MESURE) : 0,30 x 7/13 = 0,1615 par point.
|
|
// x echelleParc(13/7) = 0,30 au parc actuel. Pente unique => plus de genou.
|
|
// A REMESURER apres toute evolution du parc (2e cable console en commande pour
|
|
// surveiller aussi les cellules du pack 1 US5000).
|
|
const BATTERY_CURVE = {
|
|
kneePct: 0,
|
|
kwhPerPointAbove: 0.1615 * echelleParc,
|
|
kwhPerPointBelow: 0.1615 * echelleParc,
|
|
};
|
|
|
|
// Energie que la batterie peut delivrer en descendant de socHi a socLo.
|
|
function energyBetweenSoc(socLo, socHi) {
|
|
if (!Number.isFinite(socLo) || !Number.isFinite(socHi) || socHi <= socLo) return 0;
|
|
const knee = BATTERY_CURVE.kneePct;
|
|
const lowSpan = Math.max(0, Math.min(socHi, knee) - Math.min(socLo, knee));
|
|
const highSpan = Math.max(0, Math.max(socHi, knee) - Math.max(socLo, knee));
|
|
return lowSpan * BATTERY_CURVE.kwhPerPointBelow
|
|
+ highSpan * BATTERY_CURVE.kwhPerPointAbove;
|
|
}
|
|
|
|
// SOC necessaire pour delivrer energyKwh en descendant jusqu'a floorPct.
|
|
// NON ecrete : au-dela de 100 la valeur reste le signal de sous-dimensionnement
|
|
// (cf. §26). L'extrapolation au-dessus de 100 utilise la pente haute.
|
|
function socForEnergy(energyKwh, floorPct) {
|
|
if (!Number.isFinite(energyKwh) || energyKwh <= 0) return floorPct;
|
|
const knee = Math.max(floorPct, BATTERY_CURVE.kneePct);
|
|
const belowKwh = (knee - floorPct) * BATTERY_CURVE.kwhPerPointBelow;
|
|
if (energyKwh <= belowKwh) {
|
|
return floorPct + energyKwh / BATTERY_CURVE.kwhPerPointBelow;
|
|
}
|
|
return knee + (energyKwh - belowKwh) / BATTERY_CURVE.kwhPerPointAbove;
|
|
}
|
|
|
|
// SOC atteint en ajoutant addKwh a partir de socPct.
|
|
function socAfterEnergy(socPct, addKwh) {
|
|
if (!Number.isFinite(addKwh) || addKwh <= 0) return socPct;
|
|
return socForEnergy(energyBetweenSoc(0, socPct) + addKwh, 0);
|
|
}
|
|
|
|
// =====================================================
|
|
// TENIR JUSQU'A LA PROCHAINE FENETRE HC (28/09/2026)
|
|
// =====================================================
|
|
// Strategie du proprietaire, apres la nuit du 26-27/09 : P12 avait charge la
|
|
// JEEP sur la batterie (72 -> 44 %) parce qu'il restait assez pour atteindre le
|
|
// solaire du matin — alors que ce meme bilan annoncait deja « prochaine
|
|
// traversee 17,35 kWh des 18 h -> SOC requis 79,8 % ». La journee (22 kWh reels)
|
|
// n'a pas pu reconstituer la reserve. Sa regle : une voiture branchee se charge
|
|
// en heures creuses sur le reseau, et la batterie doit TENIR JUSQU'A LA
|
|
// PROCHAINE FENETRE HC ; si elle ne le peut pas, la fenetre HC de midi la
|
|
// complete.
|
|
//
|
|
// Traduction : PENDANT une fenetre HC, l'exigence vaut au moins le SOC qu'il
|
|
// faut avoir A LA FIN de cette fenetre pour atteindre le DEBUT de la suivante
|
|
// sans passer sous le plancher, production prevue comprise (tranches horaires
|
|
// ci-dessus, heure par heure, profil de conso du profil calcule).
|
|
//
|
|
// ⚠️ LE CALENDRIER RESTE DANS HOME ASSISTANT (§73) : le bilan ne recoit que deux
|
|
// instants, la fin de la fenetre en cours (absente hors fenetre) et le debut de
|
|
// la suivante. Hors fenetre HC, rien ne change.
|
|
//
|
|
// ⚠️ INSERE DANS coverage(), COMME LE PLANCHER MANUEL : la cible (required_soc,
|
|
// qui ENGAGE la session et releve le plancher de P12) et le manque en kWh (qui
|
|
// FINANCE la consigne) bougent ensemble par construction — jamais de session
|
|
// muette (§73). Sens unique : il ne peut que RELEVER l'exigence.
|
|
const hcWindowEndMs = num(input.hc_window_end_ms, NaN);
|
|
const nextHcStartMs = num(input.next_hc_start_ms, NaN);
|
|
const nextNightHcStartMs = num(input.next_night_hc_start_ms, NaN);
|
|
const hcReachActive = Number.isFinite(hcWindowEndMs) && Number.isFinite(nextHcStartMs)
|
|
&& nextHcStartMs > hcWindowEndMs;
|
|
// DEUX HORIZONS, DEUX USAGES (decision du proprietaire, 28/09) :
|
|
// - recharger la batterie au reseau : tenir jusqu'a la PROCHAINE fenetre HC
|
|
// (la nuit : midi ; a midi : 01:24). Midi complete si besoin, et la matinee
|
|
// garde de la place pour le solaire. -> required_soc (engage ET finance).
|
|
// - laisser la voiture puiser dans la batterie la nuit (P12) : seulement si la
|
|
// batterie tient ensuite jusqu'a la NUIT SUIVANTE (01:24), sans compter sur
|
|
// midi. Sinon la voiture se charge sur le reseau en HC. -> required_soc_prudent,
|
|
// qui n'est lu que comme plancher de P12 (et par le conseiller, pour info).
|
|
const hcNightReachActive = Number.isFinite(hcWindowEndMs) && Number.isFinite(nextNightHcStartMs)
|
|
&& nextNightHcStartMs > hcWindowEndMs;
|
|
|
|
// Energie a avoir en reserve a la fin de la fenetre : parcours A REBOURS des
|
|
// tranches comprises entre les deux instants (au prorata de leur recouvrement).
|
|
// Un surplus ne rembourse qu'un deficit POSTERIEUR — d'ou le sens du parcours.
|
|
function hcReachFloorPct(slots, untilMs = nextHcStartMs, fromMs = hcWindowEndMs) {
|
|
if (!Number.isFinite(fromMs) || !Number.isFinite(untilMs) || untilMs <= fromMs) return 0;
|
|
let needKwh = 0;
|
|
for (let i = slots.length - 1; i >= 0; i--) {
|
|
const s = slots[i];
|
|
const debut = num(s.startMs, NaN);
|
|
if (!Number.isFinite(debut)) continue;
|
|
const a = Math.max(debut, fromMs);
|
|
const b = Math.min(debut + 3600000, untilMs);
|
|
if (b <= a) continue;
|
|
needKwh = Math.max(0, needKwh - s.netKwh * (b - a) / 3600000);
|
|
}
|
|
return needKwh > 0 ? socForEnergy(needKwh, socFloorPct) : 0;
|
|
}
|
|
|
|
function coverage(slots, hcFloorPct = 0) {
|
|
const crossingIndex = slots.findIndex(s => s.netKwh > 0);
|
|
const found = crossingIndex >= 0;
|
|
const deficitSlots = found ? slots.slice(0, crossingIndex) : slots;
|
|
const energyToCoverKwh = deficitSlots.reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);
|
|
// Le plancher manuel ne peut que RELEVER l'exigence, jamais l'abaisser — meme
|
|
// sens unique que les cablages de §38, §39 et §40 : le bilan ne pousse jamais
|
|
// dans la direction « couvre moins bien ».
|
|
const requiredSocRawPct = Math.max(
|
|
socForEnergy(energyToCoverKwh, socFloorPct),
|
|
manualHcFloorPct,
|
|
hcFloorPct
|
|
);
|
|
const socNeededPct = requiredSocRawPct - socFloorPct;
|
|
const requiredSocPct = Math.min(100, requiredSocRawPct);
|
|
const socMarginPct = socPct - requiredSocPct;
|
|
return {
|
|
crossingIndex, found, deficitSlots, slots,
|
|
energyToCoverKwh, socNeededPct, requiredSocPct, socMarginPct,
|
|
gridNeeded: socMarginPct < 0,
|
|
gridEnergyNeededKwh: socMarginPct < 0 ? energyBetweenSoc(socPct, requiredSocPct) : 0,
|
|
};
|
|
}
|
|
|
|
const slotsP50 = buildSlots(consumptionP50);
|
|
const slotsP80 = buildSlots(consumptionP80, true);
|
|
const hcFloorTypicalPct = hcReachFloorPct(slotsP50, nextHcStartMs);
|
|
const hcFloorNightPrudentPct = hcNightReachActive ? hcReachFloorPct(slotsP80, nextNightHcStartMs) : 0;
|
|
const hcFloorPrudentPct = Math.max(hcReachFloorPct(slotsP80, nextHcStartMs), hcFloorNightPrudentPct);
|
|
const typical = coverage(slotsP50, hcFloorTypicalPct);
|
|
const prudent = coverage(slotsP80, hcFloorPrudentPct);
|
|
|
|
// ---------------------------------------------------------------------
|
|
// CE QUE LA SESSION DE NUIT ACHETERA AU RESEAU (29/09/2026)
|
|
//
|
|
// Incident du 28/09 : la prevision annoncait « 0,14 kWh a prendre sur EDF » et
|
|
// le reseau en a fourni 4,6. Les deux etaient justes, mais le premier est le
|
|
// MANQUE de la batterie : une fois engagee, la session tient la batterie et la
|
|
// maison passe TOUTE la fenetre HC de nuit sur le reseau (§40). Le chiffre qui
|
|
// repond a « combien vais-je acheter » est donc la maison non couverte par le
|
|
// solaire sur la fenetre (profil habituel, recale sur la soiree), depuis
|
|
// maintenant si la fenetre est ouverte. Le manque batterie s'y ajoute, a part.
|
|
// ---------------------------------------------------------------------
|
|
const nightHcStartMs = num(input.night_hc_start_ms, NaN);
|
|
const nightHcEndMs = num(input.night_hc_end_ms, NaN);
|
|
const nightHcActive = Number.isFinite(nightHcStartMs) && Number.isFinite(nightHcEndMs)
|
|
&& nightHcEndMs > nightHcStartMs;
|
|
let nightHcHouseKwh = null;
|
|
if (nightHcActive) {
|
|
const depuis = Math.max(nightHcStartMs, num(input.nowMs, Date.now()));
|
|
nightHcHouseKwh = 0;
|
|
for (const sl of slotsP50) {
|
|
const debut = num(sl.startMs, NaN);
|
|
if (!Number.isFinite(debut)) continue;
|
|
const a = Math.max(debut, depuis), b = Math.min(debut + 3600000, nightHcEndMs);
|
|
if (b > a) nightHcHouseKwh += Math.max(0, -sl.netKwh) * (b - a) / 3600000;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// BUDGET VOITURE DE LA NUIT (30/09/2026, decisions du proprietaire)
|
|
//
|
|
// « Definir combien la voiture peut prendre sans utiliser EDF, et brider la
|
|
// charge a cette valeur ou autoriser une charge complete avec EDF. » Choix du
|
|
// 30/09 : profil PRUDENT (p80) et plancher du bilan (22 %) ; par defaut « sans
|
|
// EDF » ; en charge complete, le budget batterie d'abord, EDF pour le reste.
|
|
//
|
|
// Budget = la plus petite reserve au-dessus du plancher que la batterie
|
|
// connaitra entre l'ouverture de la fenetre HC de nuit (ou maintenant si elle
|
|
// est ouverte) et le debut de la fenetre de nuit SUIVANTE, sur le profil
|
|
// prudent. Retirer X kWh a l'ouverture abaisse toute la trajectoire de X :
|
|
// le budget est donc le creux de la trajectoire. L'horizon jusqu'a la nuit
|
|
// suivante applique « la batterie tient jusqu'a la prochaine HC » ; la
|
|
// batterie pleine ecrete la recharge (on ne stocke pas au-dela de 100 %).
|
|
// Le 30/09 01:25 : SOC 57, prudent 44,7 -> ~3,7 kWh.
|
|
// ---------------------------------------------------------------------
|
|
let nightVehicleBudgetKwh = null;
|
|
if (nightHcActive && Number.isFinite(socPct)) {
|
|
const maintenant = num(input.nowMs, Date.now());
|
|
const depuis = Math.max(nightHcStartMs, maintenant);
|
|
const jusqua = nightHcStartMs + 24 * 3600000;
|
|
const plein = energyBetweenSoc(socFloorPct, 100);
|
|
let reserve = socPct >= socFloorPct
|
|
? energyBetweenSoc(socFloorPct, socPct) : -energyBetweenSoc(socPct, socFloorPct);
|
|
let creux = maintenant >= depuis ? reserve : Infinity;
|
|
for (const sl of slotsP80) {
|
|
const debut = num(sl.startMs, NaN);
|
|
if (!Number.isFinite(debut)) continue;
|
|
const a = Math.max(debut, maintenant), b = Math.min(debut + 3600000, jusqua);
|
|
if (b <= a) continue;
|
|
// La tranche qui contient l'ouverture est coupee en deux : le creux ne se
|
|
// lit qu'a partir de l'ouverture.
|
|
const coupes = depuis > a && depuis < b ? [a, depuis, b] : [a, b];
|
|
for (let i = 0; i + 1 < coupes.length; i++) {
|
|
reserve = Math.min(plein, reserve + sl.netKwh * (coupes[i + 1] - coupes[i]) / 3600000);
|
|
if (coupes[i + 1] >= depuis) creux = Math.min(creux, reserve);
|
|
}
|
|
}
|
|
// BORNE PAR LE PLANCHER PRUDENT (04/10/2026). Nuit du 03-04/10 : SOC 47,
|
|
// prudent 43,6 -> ~1,0 kWh de marge, mais le creux a donne 1,41 kWh. La
|
|
// voiture a pris toute la marge, la batterie a touche la cible du matin a
|
|
// 02:57 et la reprise reseau a ferme EDF. Le budget ne peut donc jamais
|
|
// depasser l'energie au-dessus du SOC requis PRUDENT, celui-la meme que la
|
|
// voiture n'a pas le droit de franchir en assistance batterie (P12) : les
|
|
// deux criteres sont desormais les memes par construction.
|
|
const margePrudente = Number.isFinite(prudent.requiredSocPct)
|
|
? (socPct > prudent.requiredSocPct ? energyBetweenSoc(prudent.requiredSocPct, socPct) : 0)
|
|
: 0;
|
|
nightVehicleBudgetKwh = Number.isFinite(creux) ? Math.max(0, Math.min(creux, margePrudente)) : null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// VEHICULE BRANCHE EN HC AVEC UN DEPART AVANT LA PROCHAINE HC (28/09/2026)
|
|
//
|
|
// Regle du proprietaire : « JEEP branchee engage le reseau en HC midi si
|
|
// l'analyse de production le justifie ». La JEEP repart a 15:30 certains jours
|
|
// (calendrier dans HA) : ce qu'elle n'a pas pris avant, elle ne le prendra pas
|
|
// en HC la nuit. Ailleurs, elle reste branchee et la HC de 01:24 la finit au
|
|
// meme tarif — fermer le contacteur a midi n'y gagnerait rien et etoufferait le
|
|
// solaire (installation sans injection).
|
|
//
|
|
// Ce que le solaire peut donner a la voiture avant son depart SANS toucher a
|
|
// la reserve de la nuit : l'energie de la batterie au-dessus du plancher
|
|
// « tenir jusqu'a la prochaine HC » evalue AU DEPART, plus le bilan net prevu
|
|
// (production - maison) d'ici le depart. Profil p50, comme required_soc.
|
|
// Le reseau est justifie quand le besoin de la voiture, borne par ce que son
|
|
// chargeur peut absorber avant le depart, depasse ce solaire.
|
|
//
|
|
// Besoin = (100 - niveau saisi) % x capacite MESUREE au compteur de la borne,
|
|
// moins ce que la borne a deja delivre depuis la saisie. Aucun capteur ne donne
|
|
// le niveau de la voiture : sans saisie valable, la regle se tait.
|
|
//
|
|
// Vaut pour les TROIS vehicules (JEEP, VW, moto ZERO) : capacite, puissance de
|
|
// charge et calendrier viennent de HA pour le vehicule selectionne.
|
|
// La puissance du chargeur EMBARQUE (plateau mesure : JEEP 3,66 kW, VW
|
|
// 3,68 kW) borne ce que le vehicule peut prendre avant son depart ; inconnue
|
|
// (0), on ne borne pas — l'erreur va alors vers « reseau justifie », c'est-a-
|
|
// dire vers le vehicule charge, jamais vers la batterie videe.
|
|
// Seuil de 0,5 kWh : en dessous une session ne se justifie pas — une fermeture
|
|
// du contacteur coute 50 a 150 Wh de transitoire (§57), on exige un gain d'au
|
|
// moins trois fois ce cout.
|
|
const VEHICLE_GRID_MIN_SHORTFALL_KWH = 0.5;
|
|
const vehicleChargerKw = num(input.vehicle_charger_kw, NaN);
|
|
const nowMsBalance = num(input.nowMs, Date.now());
|
|
const vehicleConnected = input.vehicle_connected === true || input.vehicle_connected === 'true';
|
|
const vehicleDepartureMs = num(input.vehicle_departure_ms, NaN);
|
|
const vehicleLevelPct = num(input.vehicle_level_pct, NaN);
|
|
const vehicleLevelValid = input.vehicle_level_valid === true || input.vehicle_level_valid === 'true';
|
|
const vehicleCapacityKwh = num(input.vehicle_capacity_kwh, NaN);
|
|
const vehicleDeliveredKwh = Math.max(0, num(input.vehicle_delivered_since_level_kwh, 0));
|
|
const vehicleNeedKwh =
|
|
vehicleLevelValid && Number.isFinite(vehicleLevelPct) && vehicleLevelPct >= 0 && vehicleLevelPct <= 100
|
|
&& Number.isFinite(vehicleCapacityKwh) && vehicleCapacityKwh > 0
|
|
? Math.max(0, (100 - vehicleLevelPct) / 100 * vehicleCapacityKwh - vehicleDeliveredKwh)
|
|
: NaN;
|
|
const vehicleRuleActive =
|
|
hcReachActive && vehicleConnected && Number.isFinite(vehicleNeedKwh)
|
|
&& Number.isFinite(vehicleDepartureMs) && vehicleDepartureMs > nowMsBalance
|
|
&& vehicleDepartureMs < nextHcStartMs;
|
|
let vehicleSolarKwh = null;
|
|
let vehicleReachableKwh = null;
|
|
let vehicleShortfallKwh = null;
|
|
let vehicleFloorAtDeparturePct = null;
|
|
if (vehicleRuleActive) {
|
|
vehicleFloorAtDeparturePct = hcReachFloorPct(slotsP50, nextHcStartMs, vehicleDepartureMs);
|
|
let netKwh = 0;
|
|
for (const sl of slotsP50) {
|
|
const debut = num(sl.startMs, NaN);
|
|
if (!Number.isFinite(debut)) continue;
|
|
const a = Math.max(debut, nowMsBalance);
|
|
const b = Math.min(debut + 3600000, vehicleDepartureMs);
|
|
if (b > a) netKwh += sl.netKwh * (b - a) / 3600000;
|
|
}
|
|
// Difference SIGNEE : une batterie SOUS son plancher doit d'abord etre
|
|
// remontee par ce meme surplus avant que la voiture n'en voie un kWh.
|
|
const plancherDepart = Math.max(socFloorPct, vehicleFloorAtDeparturePct);
|
|
const batterieAuDessusKwh = socPct >= plancherDepart
|
|
? energyBetweenSoc(plancherDepart, socPct)
|
|
: -energyBetweenSoc(socPct, plancherDepart);
|
|
vehicleSolarKwh = Math.max(0, batterieAuDessusKwh + netKwh);
|
|
vehicleReachableKwh = Number.isFinite(vehicleChargerKw) && vehicleChargerKw > 0
|
|
? Math.min(vehicleNeedKwh, vehicleChargerKw * (vehicleDepartureMs - nowMsBalance) / 3600000)
|
|
: vehicleNeedKwh;
|
|
vehicleShortfallKwh = Math.max(0, vehicleReachableKwh - vehicleSolarKwh);
|
|
}
|
|
const vehicleGridNeeded = vehicleRuleActive && vehicleShortfallKwh > VEHICLE_GRID_MIN_SHORTFALL_KWH;
|
|
|
|
// ---------------------------------------------------------------------
|
|
// PROCHAINE FENÊTRE DE DÉFICIT (ajout du 24/08/2026)
|
|
//
|
|
// Le calcul ci-dessus répond à « combien d'énergie jusqu'à ce que le solaire
|
|
// prenne le relais ». À 15 h, le solaire a déjà pris le relais : la réponse est
|
|
// zéro et le SOC requis retombe sur le plancher. C'est exact mais inutile à
|
|
// cette heure-là, car le déficit qui approche est celui de la nuit.
|
|
//
|
|
// On cherche donc AUSSI la prochaine fenêtre de déficit après le surplus en
|
|
// cours : son heure de début, son énergie, et le SOC qu'il faudra avoir atteint
|
|
// à ce moment-là. C'est cette grandeur qui remplace la cible de nuit fixe, et
|
|
// qui dit combien de surplus il faut avoir stocké avant le coucher du soleil.
|
|
//
|
|
// Profil PRUDENT : contrairement à la décision réseau immédiate, cette
|
|
// projection engage plusieurs heures et n'est pas révisable à volonté — la nuit
|
|
// tombée, il n'y a plus de solaire pour rattraper une sous-estimation.
|
|
// ---------------------------------------------------------------------
|
|
function nextDeficitWindow(slots) {
|
|
const firstSurplus = slots.findIndex(s => s.netKwh > 0);
|
|
if (firstSurplus < 0) return { found: false, reason: 'deficit_en_cours' };
|
|
|
|
const start = slots.findIndex((s, i) => i > firstSurplus && s.netKwh < 0);
|
|
if (start < 0) return { found: false, reason: 'aucun_deficit_sur_horizon' };
|
|
|
|
let end = slots.findIndex((s, i) => i > start && s.netKwh > 0);
|
|
if (end < 0) end = slots.length;
|
|
|
|
const energyKwh = slots.slice(start, end)
|
|
.reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);
|
|
|
|
// Surplus encore récupérable d'ici là : de quoi remplir, ou pas.
|
|
const surplusBeforeKwh = slots.slice(0, start)
|
|
.reduce((sum, s) => sum + Math.max(0, s.netKwh), 0);
|
|
|
|
return {
|
|
found: true,
|
|
startHour: slots[start].hour,
|
|
startMs: slots[start].startMs,
|
|
hoursFromNow: start,
|
|
endHour: end < slots.length ? slots[end].hour : null,
|
|
complete: end < slots.length,
|
|
energyKwh,
|
|
// NON écrêté à 100 volontairement : une valeur au-dessus de 100 signifie
|
|
// que même une batterie pleine ne suffit pas à traverser la fenêtre. C'est
|
|
// exactement le signal de sous-dimensionnement du stockage — l'écrêter le
|
|
// masquerait.
|
|
requiredSocAtStartPct: socForEnergy(energyKwh, socFloorPct),
|
|
surplusBeforeKwh,
|
|
};
|
|
}
|
|
|
|
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;
|
|
const crossing = crossingFound ? slots[crossingIndex] : null;
|
|
const deficitSlots = typical.deficitSlots;
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Marge disponible pour le véhicule : surplus attendu après le croisement,
|
|
// une fois la maison servie. C'est ce qui peut être chargé sans toucher au
|
|
// réseau ni entamer la réserve.
|
|
// ---------------------------------------------------------------------
|
|
const surplusAfterCrossingKwh = crossingFound
|
|
? slots.slice(crossingIndex).reduce((sum, s) => sum + Math.max(0, s.netKwh), 0)
|
|
: 0;
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Confiance : la part du calcul qui repose sur une forme de production minorée
|
|
// (après-midi censuré). Elle ne dégrade PAS la réponse « faut-il du réseau »,
|
|
// qui se joue sur les heures de déficit — celles-ci ne sont jamais censurées,
|
|
// une heure censurée étant par construction une heure de surplus.
|
|
// ---------------------------------------------------------------------
|
|
const deficitOnWeakShape = deficitSlots.filter(s => s.shapeQuality === 'minoree').length;
|
|
const confidence = {
|
|
deficitHoursOnWeakShape: deficitOnWeakShape,
|
|
deficitTrustworthy: deficitOnWeakShape === 0,
|
|
surplusTrustworthy: !slots.slice(crossingFound ? crossingIndex : 0)
|
|
.some(s => s.shapeQuality === 'minoree'),
|
|
};
|
|
|
|
return [{
|
|
json: {
|
|
usable: true,
|
|
generatedAt: new Date(num(input.nowMs, Date.now())).toISOString(),
|
|
|
|
crossing: {
|
|
found: crossingFound,
|
|
hour: crossing ? crossing.hour : null,
|
|
startMs: crossing ? crossing.startMs : null,
|
|
hoursFromNow: crossingFound ? crossingIndex : null,
|
|
},
|
|
|
|
coverage: {
|
|
// Décision réseau immédiate — remplace input_number.victron_grid_soc_off.
|
|
energyToCoverKwh: round2(typical.energyToCoverKwh),
|
|
requiredSocPct: round1(typical.requiredSocPct),
|
|
currentSocPct: round1(socPct),
|
|
socMarginPct: round1(typical.socMarginPct),
|
|
gridNeeded: typical.gridNeeded,
|
|
gridEnergyNeededKwh: round2(typical.gridEnergyNeededKwh),
|
|
|
|
// Planification (cible de nuit, dimensionnement) — ne pilote PAS le réseau.
|
|
prudent: {
|
|
energyToCoverKwh: round2(prudent.energyToCoverKwh),
|
|
requiredSocPct: round1(prudent.requiredSocPct),
|
|
socMarginPct: round1(prudent.socMarginPct),
|
|
crossingHour: prudent.found ? prudent.slots[prudent.crossingIndex].hour : null,
|
|
},
|
|
},
|
|
|
|
// Ce qu'il faudra avoir stocké avant la prochaine traversée déficitaire.
|
|
nextDeficit: nextDeficit.found ? {
|
|
found: true,
|
|
startHour: nextDeficit.startHour,
|
|
hoursFromNow: nextDeficit.hoursFromNow,
|
|
endHour: nextDeficit.endHour,
|
|
complete: nextDeficit.complete,
|
|
energyKwh: round2(nextDeficit.energyKwh),
|
|
requiredSocAtStartPct: round1(nextDeficit.requiredSocAtStartPct),
|
|
surplusBeforeKwh: round2(nextDeficit.surplusBeforeKwh),
|
|
// Franchissable avec le stockage actuel, batterie pleine ?
|
|
feasibleWithCurrentStorage: nextDeficit.requiredSocAtStartPct <= 100,
|
|
// Si non : capacité utile manquante. C'est le chiffre du dimensionnement.
|
|
storageShortfallKwh: energyBetweenSoc(100, nextDeficit.requiredSocAtStartPct),
|
|
reachable: nextDeficit.surplusBeforeKwh >= 0
|
|
&& socAfterEnergy(socPct, nextDeficit.surplusBeforeKwh)
|
|
>= nextDeficit.requiredSocAtStartPct,
|
|
} : { found: false, reason: nextDeficit.reason },
|
|
|
|
vehicle: {
|
|
surplusAvailableKwh: round2(surplusAfterCrossingKwh),
|
|
},
|
|
|
|
// Nuit prevue par la soiree (28/09) : facteur applique au profil.
|
|
nightModel: {
|
|
active: nightModelActive,
|
|
factor: Math.round(nightFactor * 1000) / 1000,
|
|
rawFactor: Number.isFinite(nightFactorRaw) ? nightFactorRaw : null,
|
|
forecastKwh: num(input.night_house_forecast_kwh, NaN) || null,
|
|
},
|
|
|
|
// Maison sur le reseau pendant la fenetre HC de nuit, si la session s'engage (29/09).
|
|
nightHcImport: {
|
|
active: nightHcActive,
|
|
startMs: nightHcActive ? nightHcStartMs : null,
|
|
endMs: nightHcActive ? nightHcEndMs : null,
|
|
houseKwh: nightHcHouseKwh === null ? null : round2(nightHcHouseKwh),
|
|
},
|
|
|
|
// Ce que la voiture peut prendre sur la batterie cette nuit sans EDF (30/09).
|
|
nightVehicleBudget: {
|
|
active: nightVehicleBudgetKwh !== null,
|
|
kwh: nightVehicleBudgetKwh === null ? null : round2(nightVehicleBudgetKwh),
|
|
},
|
|
|
|
// Voiture branchee en HC, depart avant la prochaine HC (28/09).
|
|
vehicleMidday: {
|
|
active: vehicleRuleActive,
|
|
needKwh: Number.isFinite(vehicleNeedKwh) ? round2(vehicleNeedKwh) : null,
|
|
departureMs: Number.isFinite(vehicleDepartureMs) ? vehicleDepartureMs : null,
|
|
solarKwh: vehicleSolarKwh === null ? null : round2(vehicleSolarKwh),
|
|
reachableKwh: vehicleReachableKwh === null ? null : round2(vehicleReachableKwh),
|
|
shortfallKwh: vehicleShortfallKwh === null ? null : round2(vehicleShortfallKwh),
|
|
floorAtDeparturePct: vehicleFloorAtDeparturePct === null ? null : round1(vehicleFloorAtDeparturePct),
|
|
gridNeeded: vehicleGridNeeded,
|
|
},
|
|
|
|
// Tenir jusqu'a la prochaine fenetre HC (28/09) : SOC a avoir en fin de
|
|
// fenetre HC en cours. 0 hors fenetre ou si la production suffit.
|
|
hcReach: {
|
|
active: hcReachActive,
|
|
windowEndMs: hcReachActive ? hcWindowEndMs : null,
|
|
nextHcStartMs: hcReachActive ? nextHcStartMs : null,
|
|
floorPct: round1(Math.min(100, hcFloorTypicalPct)),
|
|
floorPrudentPct: round1(Math.min(100, hcFloorPrudentPct)),
|
|
nextNightHcStartMs: hcNightReachActive ? nextNightHcStartMs : null,
|
|
nightFloorPrudentPct: round1(Math.min(100, hcFloorNightPrudentPct)),
|
|
raisesRequirement: hcReachActive && hcFloorTypicalPct >= typical.requiredSocPct - 0.05
|
|
&& hcFloorTypicalPct > socForEnergy(typical.energyToCoverKwh, socFloorPct),
|
|
},
|
|
|
|
// 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(energyBetweenSoc(socPct, stormWindow.socPct))
|
|
: 0,
|
|
},
|
|
|
|
confidence,
|
|
slots,
|
|
|
|
inputs: {
|
|
usableCapacityKwh: round2(usableCapacityKwh),
|
|
socFloorPct: round1(socFloorPct),
|
|
},
|
|
|
|
cfg: CFG,
|
|
},
|
|
}];
|