diff --git a/HANDOFF.md b/HANDOFF.md index 3ba81fa..44c7800 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2230,5 +2230,21 @@ approximations successives ») ; cette nuit la VW a charge A FOND de 05:17 a 07: budget 0 kWh … activer complete ») ; EDF HP **partiel** ; midi **toujours faux** (« consigne inferieure au solaire »). 1 juste / 1 partiel / 1 faux, contre 0 juste avant. Pas encore un role actif. +### 09/10 01:35 — La regle « la nuit prepare le soir » etait muette : corrigee +- Nuit du 08-09/10 : VRM annonce **14,7 kWh** le 09/10 et la regle ne s'est pas declenchee. Cause (rejeu de l'entree + reelle de 01:30) : `nextDeficitWindow` prenait le creux de 12 h (apres un surplus a 11 h) pour « la traversee du + soir » -> 22,1 % requis. Defaut de ma version du 08/10. +- **Nouvelle definition** : de la fin de la fenetre de nuit au debut de la NUIT SUIVANTE (01:24), la batterie ne doit + jamais passer sous le plancher (profil habituel, solaire ecrete a 100 %, HC de midi NON comptee) ; plus petite + energie de fin de nuit qui le garantit (recherche dichotomique sur l'energie au-dessus du plancher) ; hors + d'atteinte -> le mieux possible sans gaspiller. Sorties `nightEvening.floorPct`, `shortfallKwh`. Raison : « Journee + faible annoncee : la nuit charge a x % (sinon il manquerait y kWh avant la nuit prochaine…) ». +- Tests bilan 92/92 : nouveau scenario sur l'ENTREE REELLE du 09/10 01:30 (`tests/energy-balance/fixtures/bilan-0910-0130.json`) + -> 50,4 % (27,1 + 7 / 0,30, verifie a la main) ; les deux rejeux du 27/09 reviennent a leurs valeurs d'avant le 08/10 + (25,3 % ; requis 41,4 %) : avec 18-25 kWh la journee remonte assez la batterie au profil habituel. Mutations : regle + coupee -> 1 ; minimum du parcours ignore -> 1. +- Deploye 01:37 ; calcul reel 01:40 : plancher et requis **50,4 %** (SOC 55 %). La session de nuit s'engagera quand le + SOC passera sous 50,4 % (la comparaison se fait au SOC courant : la batterie descend d'abord a 50,4 % puis est tenue). + --- -*Document mis a jour le : 08/10/2026 (~08:00) — conseiller relecteur de la journee (revue calculee + pistes) ; nuit : voiture qui profite d'EDF ferme pour la batterie, nuit qui prepare le soir les jours annonces faibles ; 08/10/2026 (~00:15) — jour faible : charge maximale en HC de midi (bilan 100 %, ACM plafond + solaire) ; 07/10/2026 (~22:45) — reprise de 16 h verifiee, EDF en HP a 18:03, bilan : reprise du soir et prevision recalee sur la matinee ; bilan HC de midi, reprise du soir apres coupure en pleine chauffe, mesure ECS sur 10 min ; recharge reseau HC : courant calcule (12 kVA) et consigne non tenue ; HC de midi : AUTO ne chauffe pas (BOOST), alerte v2, Jeep (contact, relecture, zone), recharge batterie bridee a 30 A par l'ACM ; jour faible = EDF les deux fenetres HC pour le chauffe-eau, alerte BOOST, Victron Mobile refait ; 1re decision jour faible en reel, mesure ECS reparee, douches 06:00/20:00, Grafana chauffe-eau, tableaux ECS ; chauffe-eau en HC de nuit les jours faibles (decision du bilan, workflow ECS sous controle du depot, VRM, V2.3 desactive) ; garde batterie reseau : voiture bridee a 6 A au lieu d'un arret ; garde-fou concierge 2 h ; allers-retours brefs de la borne retrouves dans l'historique HA (echange VW -> JEEP de 20 s), production 14,5 kWh contre 22 prevus, defaut garde batterie en mode reseau a trancher ; voiture a midi : la batterie n'est plus comptee comme du solaire (EDF automatique les jours de depart si le surplus ne couvre pas) ; nuit 05->06 : JEEP chargee sur EDF en complete (6,32 kWh), budget 0 du a la prevision, Windy/ECMWF contre VRM, a comparer ce soir ; concierge aligne sur la preuve fin de trajet ; a verifier : nuit 05->06 et mardi faible, comparaison VRM/Forecast.Solar ; JEEP identifiee aussi par sa fin de trajet, prevision de la nuit 05->06 ; identification JEEP reussie en reel, correction humaine qui prime (refus supprime), session du 04/10 coupee ; EVCS Master en quatre onglets ; Jeep lue par Uconnect et identifiee seule, capacite JEEP 10 kWh, concierge variante (a) ; releve par photo (SPARK), hass-uconnect en attente ; ecart semaine/week-end mesure (pas de profil week-end la nuit) ; nuit du budget : budget borne au plancher prudent, sans EDF = voiture bloquee meme EDF ferme (batterie seule) ; Energie : sauts corriges ; tableau Energie complete (Sud/Ouest, batterie par integration, borne ; aucun redemarrage HA) ; kilometrage des vehicules (km/kWh, capacite, releves de reference, agent cable 15/15) ; a verifier le 04/10 au matin : nuit du budget JEEP ; concierge : vehicule non confirme demande, heure de saisie ; Grafana vehicules en tableau ; Grafana vehicules : ZERO et Invite ajoutes, session 16:46 reetiquetee ZERO ; prevision SOC 10 h ~35 % ; concierge fiable : niveau note par le script, une ligne, dedoublonnage, simulation par drapeau ; agent : message incompris = question, bourse sur demande explicite (banc final 6/6) ; a verifier : nuit du samedi 03 au dimanche 04/10 (budget < besoin JEEP), cron orange-cloture lundi 05/10 apres 17:40, comparaison VRM / Forecast.Solar lundi 05/10* +*Document mis a jour le : 09/10/2026 (~01:45) — conseiller relecteur de la journee (revue calculee + pistes) ; nuit : voiture qui profite d'EDF ferme pour la batterie, nuit qui prepare le soir les jours annonces faibles ; 08/10/2026 (~00:15) — jour faible : charge maximale en HC de midi (bilan 100 %, ACM plafond + solaire) ; 07/10/2026 (~22:45) — reprise de 16 h verifiee, EDF en HP a 18:03, bilan : reprise du soir et prevision recalee sur la matinee ; bilan HC de midi, reprise du soir apres coupure en pleine chauffe, mesure ECS sur 10 min ; recharge reseau HC : courant calcule (12 kVA) et consigne non tenue ; HC de midi : AUTO ne chauffe pas (BOOST), alerte v2, Jeep (contact, relecture, zone), recharge batterie bridee a 30 A par l'ACM ; jour faible = EDF les deux fenetres HC pour le chauffe-eau, alerte BOOST, Victron Mobile refait ; 1re decision jour faible en reel, mesure ECS reparee, douches 06:00/20:00, Grafana chauffe-eau, tableaux ECS ; chauffe-eau en HC de nuit les jours faibles (decision du bilan, workflow ECS sous controle du depot, VRM, V2.3 desactive) ; garde batterie reseau : voiture bridee a 6 A au lieu d'un arret ; garde-fou concierge 2 h ; allers-retours brefs de la borne retrouves dans l'historique HA (echange VW -> JEEP de 20 s), production 14,5 kWh contre 22 prevus, defaut garde batterie en mode reseau a trancher ; voiture a midi : la batterie n'est plus comptee comme du solaire (EDF automatique les jours de depart si le surplus ne couvre pas) ; nuit 05->06 : JEEP chargee sur EDF en complete (6,32 kWh), budget 0 du a la prevision, Windy/ECMWF contre VRM, a comparer ce soir ; concierge aligne sur la preuve fin de trajet ; a verifier : nuit 05->06 et mardi faible, comparaison VRM/Forecast.Solar ; JEEP identifiee aussi par sa fin de trajet, prevision de la nuit 05->06 ; identification JEEP reussie en reel, correction humaine qui prime (refus supprime), session du 04/10 coupee ; EVCS Master en quatre onglets ; Jeep lue par Uconnect et identifiee seule, capacite JEEP 10 kWh, concierge variante (a) ; releve par photo (SPARK), hass-uconnect en attente ; ecart semaine/week-end mesure (pas de profil week-end la nuit) ; nuit du budget : budget borne au plancher prudent, sans EDF = voiture bloquee meme EDF ferme (batterie seule) ; Energie : sauts corriges ; tableau Energie complete (Sud/Ouest, batterie par integration, borne ; aucun redemarrage HA) ; kilometrage des vehicules (km/kWh, capacite, releves de reference, agent cable 15/15) ; a verifier le 04/10 au matin : nuit du budget JEEP ; concierge : vehicule non confirme demande, heure de saisie ; Grafana vehicules en tableau ; Grafana vehicules : ZERO et Invite ajoutes, session 16:46 reetiquetee ZERO ; prevision SOC 10 h ~35 % ; concierge fiable : niveau note par le script, une ligne, dedoublonnage, simulation par drapeau ; agent : message incompris = question, bourse sur demande explicite (banc final 6/6) ; a verifier : nuit du samedi 03 au dimanche 04/10 (budget < besoin JEEP), cron orange-cloture lundi 05/10 apres 17:40, comparaison VRM / Forecast.Solar lundi 05/10* diff --git a/src/energy-balance/BALANCE.js b/src/energy-balance/BALANCE.js index e413b88..d5facde 100644 --- a/src/energy-balance/BALANCE.js +++ b/src/energy-balance/BALANCE.js @@ -473,41 +473,50 @@ const hcMaxCharge = hcMiddayWindow && ecsNuitAujourdhui; // « tenir jusqu'a midi »). // --------------------------------------------------------------------- const hcNightWindow = hcReachActive && !hcMiddayWindow; +// ⚠️ CORRIGE LE 09/10 : la premiere version (08/10) visait « le debut de la +// traversee du soir » = la premiere fenetre de deficit apres le premier surplus. +// Nuit du 08-09/10 (VRM 14,7 kWh) : un surplus a 11 h puis un creux a 12 h -> +// « traversee » a 12 h, 22 % requis, regle muette alors que la journee etait +// faible. L'objectif est maintenant sans ambiguite : de la fin de la fenetre de +// nuit au debut de la NUIT SUIVANTE (01:24), la batterie ne doit JAMAIS passer +// sous le plancher, au profil habituel, solaire ecrete a 100 %. La HC de midi +// n'est pas comptee (prudence : elle est bornee par la maison, et ce que la nuit +// charge au meme prix ne lui coute rien). let nightEvening = { active: false }; -if (hcNightWindow) { - const ndEve = nextDeficitWindow(slotsP50); - if (ndEve.found && Number.isFinite(num(ndEve.startMs, NaN)) && ndEve.startMs > hcWindowEndMs) { - const socAtEvening = (s0) => { - let soc = s0; - for (const sl of slotsP50) { - const debut = num(sl.startMs, NaN); - if (!Number.isFinite(debut)) continue; - const a = Math.max(debut, hcWindowEndMs); - const b = Math.min(debut + 3600000, ndEve.startMs); - if (b <= a) continue; - const net = sl.netKwh * (b - a) / 3600000; - soc = net >= 0 ? Math.min(100, socAfterEnergy(soc, net)) - : Math.max(socFloorPct, socForEnergy(Math.max(0, energyBetweenSoc(0, soc) + net), 0)); - } - return soc; - }; - const requis = ndEve.requiredSocAtStartPct; - const base = Math.max(socFloorPct, hcFloorReachPct); - const objectif = Math.min(requis, socAtEvening(100)) - 0.05; - let floor = base; - if (socAtEvening(base) < objectif) { - let lo = base, hi = 100; - for (let k = 0; k < 30; k++) { const m = (lo + hi) / 2; if (socAtEvening(m) >= objectif) hi = m; else lo = m; } - floor = hi; +if (hcNightWindow && Number.isFinite(nextNightHcStartMs) && nextNightHcStartMs > hcWindowEndMs) { + const capKwh = energyBetweenSoc(socFloorPct, 100); + // Energie minimale au-dessus du plancher sur le parcours, depuis e0 en fin de nuit. + const minAuDessus = (e0) => { + let e = e0, mini = e0; + for (const sl of slotsP50) { + const debut = num(sl.startMs, NaN); + if (!Number.isFinite(debut)) continue; + const a = Math.max(debut, hcWindowEndMs); + const b = Math.min(debut + 3600000, nextNightHcStartMs); + if (b <= a) continue; + e = Math.min(capKwh, e + sl.netKwh * (b - a) / 3600000); + if (e < mini) mini = e; } - nightEvening = { - active: floor > base + 0.05, - floorPct: round1(floor), - eveningRequiredPct: round1(requis), - eveningProjectedPct: round1(socAtEvening(base)), - eveningStartHour: ndEve.startHour, - }; + return mini; + }; + const base = Math.max(socFloorPct, hcFloorReachPct); + const eBase = energyBetweenSoc(socFloorPct, base); + // Hors d'atteinte meme pleine : on vise le mieux possible, sans gaspiller. + const objectif = Math.min(0, minAuDessus(capKwh)) - 0.01; + let e0 = eBase; + if (minAuDessus(eBase) < objectif) { + let lo = eBase, hi = capKwh; + for (let k = 0; k < 40; k++) { const m = (lo + hi) / 2; if (minAuDessus(m) >= objectif) hi = m; else lo = m; } + e0 = hi; } + const floor = socAfterEnergy(socFloorPct, e0); + nightEvening = { + active: floor > base + 0.05, + floorPct: round1(Math.min(100, floor)), + // Sans relever : energie qui manquerait sous le plancher avant la nuit suivante. + shortfallKwh: round2(Math.max(0, -minAuDessus(eBase))), + untilMs: nextNightHcStartMs, + }; } const hcFloorTypicalPct = hcMaxCharge ? 100 : (nightEvening.active ? Math.max(hcFloorReachPct, nightEvening.floorPct) : hcFloorReachPct); diff --git a/src/energy-balance/BUILD_HA_CALLS.js b/src/energy-balance/BUILD_HA_CALLS.js index f32b59b..75dcacf 100644 --- a/src/energy-balance/BUILD_HA_CALLS.js +++ b/src/energy-balance/BUILD_HA_CALLS.js @@ -162,7 +162,7 @@ const reason = balance.crossing.found const hc = balance.hcReach || {}; const ne = hc.nightEvening || {}; const hcText = hc.active && ne.active - ? ` Journée faible annoncée : la nuit charge à ${ne.floorPct}% (batterie prévue à ${ne.eveningProjectedPct}% à ${ne.eveningStartHour} h pour ${ne.eveningRequiredPct}% requis ; tenir jusqu'à midi demandait ${hc.floorReachPct}%).` + ? ` Journée faible annoncée : la nuit charge à ${ne.floorPct}% (sinon il manquerait ${ne.shortfallKwh} kWh avant la nuit prochaine ; tenir jusqu'à midi demandait ${hc.floorReachPct}%).` : hc.active && hc.maxCharge ? ` Jour faible : charge maximale en HC de midi (tenir jusqu'à ${new Date(hc.nextHcStartMs).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' })} demandait ${hc.floorReachPct}%).` : hc.active && hc.raisesRequirement diff --git a/tests/energy-balance/fixtures/bilan-0910-0130.json b/tests/energy-balance/fixtures/bilan-0910-0130.json new file mode 100644 index 0000000..d80171a --- /dev/null +++ b/tests/energy-balance/fixtures/bilan-0910-0130.json @@ -0,0 +1 @@ +{"nowMs":1791502251039.645,"consumption_p50":[1,0.8,0.6,0.6,0.6,0.7,0.7,0.7,0.8,0.8,1.1,1.3,2.1,2,1.5,1.5,1.1,1.1,1,1.3,1.7,1.6,1.5,1.2],"consumption_p80":[1.2,1.1,0.9,0.7,0.7,0.7,0.8,0.8,0.9,1.2,1.8,2.6,2.8,3,2.3,1.9,2.1,1.9,1.7,1.8,2.2,2,2.2,1.6],"horizon":[{"startMs":1791500400000,"hour":1,"day":"2026-10-09","expectedKwh":0,"shapeQuality":"nuit"},{"startMs":1791504000000,"hour":2,"day":"2026-10-09","expectedKwh":0,"shapeQuality":"nuit"},{"startMs":1791507600000,"hour":3,"day":"2026-10-09","expectedKwh":0,"shapeQuality":"nuit"},{"startMs":1791511200000,"hour":4,"day":"2026-10-09","expectedKwh":0,"shapeQuality":"nuit"},{"startMs":1791514800000,"hour":5,"day":"2026-10-09","expectedKwh":0,"shapeQuality":"nuit"},{"startMs":1791518400000,"hour":6,"day":"2026-10-09","expectedKwh":0,"shapeQuality":"nuit"},{"startMs":1791522000000,"hour":7,"day":"2026-10-09","expectedKwh":0,"shapeQuality":"courbe"},{"startMs":1791525600000,"hour":8,"day":"2026-10-09","expectedKwh":0.04,"shapeQuality":"courbe"},{"startMs":1791529200000,"hour":9,"day":"2026-10-09","expectedKwh":0.4,"shapeQuality":"courbe"},{"startMs":1791532800000,"hour":10,"day":"2026-10-09","expectedKwh":1.05,"shapeQuality":"courbe"},{"startMs":1791536400000,"hour":11,"day":"2026-10-09","expectedKwh":1.55,"shapeQuality":"courbe"},{"startMs":1791540000000,"hour":12,"day":"2026-10-09","expectedKwh":2.08,"shapeQuality":"courbe"},{"startMs":1791543600000,"hour":13,"day":"2026-10-09","expectedKwh":2.33,"shapeQuality":"courbe"},{"startMs":1791547200000,"hour":14,"day":"2026-10-09","expectedKwh":2.3,"shapeQuality":"courbe"},{"startMs":1791550800000,"hour":15,"day":"2026-10-09","expectedKwh":2.27,"shapeQuality":"courbe"},{"startMs":1791554400000,"hour":16,"day":"2026-10-09","expectedKwh":1.62,"shapeQuality":"courbe"},{"startMs":1791558000000,"hour":17,"day":"2026-10-09","expectedKwh":0.81,"shapeQuality":"courbe"},{"startMs":1791561600000,"hour":18,"day":"2026-10-09","expectedKwh":0.23,"shapeQuality":"courbe"},{"startMs":1791565200000,"hour":19,"day":"2026-10-09","expectedKwh":0.03,"shapeQuality":"courbe"},{"startMs":1791568800000,"hour":20,"day":"2026-10-09","expectedKwh":0,"shapeQuality":"courbe"},{"startMs":1791572400000,"hour":21,"day":"2026-10-09","expectedKwh":0,"shapeQuality":"nuit"},{"startMs":1791576000000,"hour":22,"day":"2026-10-09","expectedKwh":0,"shapeQuality":"nuit"},{"startMs":1791579600000,"hour":23,"day":"2026-10-09","expectedKwh":0,"shapeQuality":"nuit"},{"startMs":1791583200000,"hour":0,"day":"2026-10-10","expectedKwh":0,"shapeQuality":"nuit"},{"startMs":1791586800000,"hour":1,"day":"2026-10-10","expectedKwh":0,"shapeQuality":"nuit"},{"startMs":1791590400000,"hour":2,"day":"2026-10-10","expectedKwh":0,"shapeQuality":"nuit"},{"startMs":1791594000000,"hour":3,"day":"2026-10-10","expectedKwh":0,"shapeQuality":"nuit"},{"startMs":1791597600000,"hour":4,"day":"2026-10-10","expectedKwh":0,"shapeQuality":"nuit"},{"startMs":1791601200000,"hour":5,"day":"2026-10-10","expectedKwh":0,"shapeQuality":"nuit"},{"startMs":1791604800000,"hour":6,"day":"2026-10-10","expectedKwh":0,"shapeQuality":"nuit"},{"startMs":1791608400000,"hour":7,"day":"2026-10-10","expectedKwh":0,"shapeQuality":"courbe"},{"startMs":1791612000000,"hour":8,"day":"2026-10-10","expectedKwh":0.07,"shapeQuality":"courbe"},{"startMs":1791615600000,"hour":9,"day":"2026-10-10","expectedKwh":0.87,"shapeQuality":"courbe"},{"startMs":1791619200000,"hour":10,"day":"2026-10-10","expectedKwh":2.19,"shapeQuality":"courbe"},{"startMs":1791622800000,"hour":11,"day":"2026-10-10","expectedKwh":3.44,"shapeQuality":"courbe"},{"startMs":1791626400000,"hour":12,"day":"2026-10-10","expectedKwh":4.35,"shapeQuality":"courbe"},{"startMs":1791630000000,"hour":13,"day":"2026-10-10","expectedKwh":4.61,"shapeQuality":"courbe"},{"startMs":1791633600000,"hour":14,"day":"2026-10-10","expectedKwh":4.27,"shapeQuality":"courbe"},{"startMs":1791637200000,"hour":15,"day":"2026-10-10","expectedKwh":3.9,"shapeQuality":"courbe"},{"startMs":1791640800000,"hour":16,"day":"2026-10-10","expectedKwh":3.06,"shapeQuality":"courbe"}],"soc_percent":"55.0","usable_capacity_kwh":"23.0","modules_en_service":"13.0","soc_floor_percent":22,"storm_alert_level":"Vert","storm_alert_detail":{"Inondation":"Vert","Neige-verglas":"Vert","Orages":"Vert","Pluie-inondation":"Vert","Vagues-submersion":"Vert","Vent violent":"Vert","attribution":"Data provided by Météo-France","friendly_name":"Météo-France forecast for city Pleumeur-Bodou - Bretagne (22) - FR 22 Weather alert","icon":"mdi:weather-cloudy-alert"},"manual_hc_floor_percent":"0.0","night_house_factor":0.924,"night_house_start_ms":1791487800000,"night_house_end_ms":1791529200000,"night_house_computed_ms":1791502044682,"night_house_forecast_kwh":"9.91","vehicle_connected":true,"vehicle_departure_ms":null,"vehicle_level_pct":"47.0","vehicle_level_valid":true,"vehicle_capacity_kwh":"10.0","vehicle_charger_kw":"3.66","vehicle_delivered_since_level_kwh":5.199999999999818,"hc_window_end_ms":1791523440000,"next_hc_start_ms":1791541440000,"next_night_hc_start_ms":1791588240000,"night_hc_start_ms":1791501840000,"night_hc_end_ms":1791523440000,"ecs_nuit_jour":"2026-10-08|off|jour suffisant : batterie prevue a 97.4 % a 18 h pour 70.6 % requis -> eau chaude au solaire","ecs_reprise_soir_kwh":"2.1","production_jour_kwh":"0","production_jour_statut":"complet","forecast_today_csv":"0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.04,0.40,1.05,1.55,2.08,2.33,2.30,2.27,1.62,0.81,0.23,0.03,0.00,0.00,0.00,0.00","resourceUsable":true,"shapeConfidence":1} \ No newline at end of file diff --git a/tests/energy-balance/scenarios-balance.mjs b/tests/energy-balance/scenarios-balance.mjs index 1c044d1..8ed171f 100644 --- a/tests/energy-balance/scenarios-balance.mjs +++ b/tests/energy-balance/scenarios-balance.mjs @@ -1,3 +1,4 @@ +import fs from 'node:fs'; // Scénarios du nœud BALANCE. // Le scénario central est un REJEU de la matinée du 24/08/2026 : la production // horaire est celle réellement mesurée ce jour-là (heures 06-09 h non @@ -421,8 +422,10 @@ export const balanceScenarios = [ // 59,6 % a 19 h pour 64,7 % requis -> +5,1 points (courbe plate, pas // d'ecretage) : 25,3 + 5,0 = 30,3 % en fin de nuit. Le 27/09 reel (22 kWh) // a vide la batterie a 01:00 : le sens est le bon. + // 09/10 : la regle « la nuit prepare jusqu'a la nuit suivante » ne joue pas ici : + // au profil habituel, 25,1 kWh remontent assez la batterie pour passer 01:24. expect: { usable: true, requiredSocPct: 40.2, prudentRequiredSocPct: 48.4, gridNeeded: false, - hcReachActive: true, nightEveningActive: true, hcFloorPct: 30.3, hcNightFloorPrudentPct: 48.4 }, + hcReachActive: true, nightEveningActive: false, hcFloorPct: 25.3, hcNightFloorPrudentPct: 48.4 }, }, { // Meme nuit, journee annoncee sombre (18 kWh, prevision VRM du 27) : le @@ -432,7 +435,7 @@ export const balanceScenarios = [ id: 'bilan-hc-nuit-2709-journee-sombre-bloque-p12', label: 'Nuit 26-27/09, journee sombre (18 kWh) : P12 plancher 72,1 % > SOC 72 → voiture sur le reseau HC', snapshot: snap2709('2026-09-27T01:20:00+02:00', 72, NUIT_2709, SOMBRE), - expect: { usable: true, requiredSocPct: 54, prudentRequiredSocPct: 72.1, gridNeeded: false, + expect: { usable: true, requiredSocPct: 41.4, prudentRequiredSocPct: 72.1, gridNeeded: false, hcReachActive: true, hcNightFloorPrudentPct: 72.1 }, }, { @@ -602,9 +605,19 @@ export const balanceScenarios = [ label: 'Fenetre HC de NUIT, decision jour faible du jour : la nuit garde « tenir jusqu a midi »', snapshot: Object.assign(snap2709('2026-09-27T01:20:00+02:00', 72, NUIT_2709), { ecs_nuit_jour: '2026-09-27|on|x' }), - // Pas de charge maximale la nuit ; la nuit prepare le soir (30,3 %, voir le - // scenario du 27/09 ci-dessus). - expect: { usable: true, hcReachActive: true, hcMaxCharge: false, nightEveningActive: true, hcFloorPct: 30.3 }, + expect: { usable: true, hcReachActive: true, hcMaxCharge: false, hcFloorPct: 25.3 }, + }, + { + // ENTREE REELLE du 09/10 01:30 (PREPARE_BALANCE, VRM 14,7 kWh) : la version du + // 08/10 prenait le creux de 12 h (apres un surplus a 11 h) pour la « traversee du + // soir » et restait muette. Au profil habituel, sans relever, il manque 7 kWh + // sous le plancher avant 01:24 le 10/10 -> fin de nuit a 27,1 + 7 / 0,30 = 50,4 % + // (verifie a la main, courbe plate, pas d'ecretage). + node: 'BALANCE', + id: 'bilan-hc-nuit-0910-journee-faible-reelle', + label: 'Nuit du 08-09/10, journee faible (VRM 14,7 kWh) : la nuit charge a 50,4 % pour passer la soiree', + snapshot: JSON.parse(fs.readFileSync(new URL('./fixtures/bilan-0910-0130.json', import.meta.url), 'utf8')), + expect: { usable: true, hcReachActive: true, nightEveningActive: true, hcFloorPct: 50.4 }, }, { node: 'BALANCE', diff --git a/workflows/evcs-energy-balance-v1.json b/workflows/evcs-energy-balance-v1.json index dcc414e..0f34f09 100644 --- a/workflows/evcs-energy-balance-v1.json +++ b/workflows/evcs-energy-balance-v1.json @@ -111,7 +111,7 @@ 120 ], "parameters": { - "jsCode": "// EVCS Energy Balance — BALANCE\n// =====================================================================\n// Rôle : produire les FAITS qui remplacent les seuils fixes. Rien d'autre.\n// Ce nœud ne pilote ni EDF, ni la borne : il publie des grandeurs que les deux\n// contrôleurs liront à la place de leurs constantes.\n//\n// La question à laquelle il répond n'est pas « le SOC est-il au-dessus de\n// 30 % ? » mais « la batterie contient-elle de quoi tenir jusqu'à ce que le\n// solaire prenne le relais ? ». Le 24/08/2026 à 06:43, la réponse du seuil fixe\n// et celle du bilan diffèrent : la production ne couvrait la maison qu'à partir\n// de 09 h, il fallait donc environ 0,8 kWh de réserve, soit ~5 points de SOC —\n// et non un palier arbitraire à 30 %.\n//\n// Entrées (toutes fournies, aucune constante physique en dur) :\n// consumption_p50[24] profil horaire mesuré, valeur typique (CONSUMPTION_PROFILE)\n// consumption_p80[24] idem, valeur prudente\n// horizon[] production attendue heure par heure (RESOURCE_ESTIMATE)\n// soc_percent SOC lissé pour le pilotage\n// usable_capacity_kwh capacité réellement disponible du parc batterie\n// soc_floor_percent plancher dur de sécurité\n//\n// Sorties : heure de croisement · énergie à couvrir · SOC requis · marge\n// disponible pour le véhicule · niveau de confiance.\n// =====================================================================\n\nfunction num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction round1(v) {\n return Number.isFinite(v) ? Math.round(v * 10) / 10 : null;\n}\n\nfunction round2(v) {\n return Number.isFinite(v) ? Math.round(v * 100) / 100 : null;\n}\n\nconst input = $input.first().json;\n\nconst CFG = {\n TIMEZONE: 'Europe/Paris',\n // Au-delà de cet horizon sans croisement trouvé, on cesse de chercher : la\n // réponse n'aurait plus de sens opérationnel.\n SEARCH_HOURS: 40,\n // PAS de marge de sécurité supplémentaire. La prudence est déjà portée,\n // entièrement, par le choix du percentile du profil de consommation (p80),\n // qui est mesuré. Vérifié le 24/08/2026 : sur toutes les fenêtres testées,\n // p80(somme des heures) est égal a somme(p80 par heure) — les heures de\n // consommation sont fortement corrélées, une matinée chargée l'étant sur\n // plusieurs heures d'affilée. Sommer les p80 ne cumule donc PAS deux\n // pessimismes, et ajouter un coefficient par-dessus ne serait qu'une\n // constante arbitraire de plus.\n};\n\nfunction profileOf(key) {\n const a = input[key];\n return Array.isArray(a) && a.length === 24 ? a.map(v => Math.max(0, num(v, 0))) : null;\n}\nconst consumptionP50 = profileOf('consumption_p50');\nconst consumptionP80 = profileOf('consumption_p80');\n\nconst horizon = Array.isArray(input.horizon) ? input.horizon : null;\n\nconst socPct = num(input.soc_percent, NaN);\nconst usableCapacityKwh = num(input.usable_capacity_kwh, NaN);\nconst socFloorPct = num(input.soc_floor_percent, 22);\n\n// =====================================================\n// CHARGE HC MANUELLE — UN PLANCHER, PAS UNE CIBLE (18/09/2026)\n// =====================================================\n// Le proprietaire veut pouvoir demander une recharge batterie en heures creuses\n// quand le bilan ne l'a pas jugee necessaire — journee d'hiver courte, ciel\n// couvert. La decision qu'il a tranchee : CONTRAINDRE le bilan plutot que de\n// l'ignorer, pour garder un seul arbitre. Ce projet a deja paye cher les\n// automates concurrents (charges programmees de l'ESS §26, consigne a 4200 W\n// §27, plancher SOC du GX §27).\n//\n// ⚠️ LE FORCAGE DOIT ETRE ICI, EN AMONT, ET NULLE PART AILLEURS. Mesure du\n// 18/09 sur banc : ecrire directement dans `input_number.evcs_balance_required_soc`\n// engage bien la session — contacteur ferme, takeover ON — mais laisse la\n// consigne reseau a 700 W, donc la batterie recoit une centaine de watts. C'est\n// le defaut du 01/09 et du 04/09 a l'identique, et c'est le seul resultat des\n// trois qui soit INDIAGNOSTICABLE depuis le tableau de bord : il a l'air de\n// marcher. La raison : `required_soc` ENGAGE la session, `grid_energy_needed_kwh`\n// la FINANCE, et les deux sont publiees separement. Pose ici, dans `coverage()`,\n// le plancher fait bouger les deux ENSEMBLE par construction — c'est une\n// consequence de la forme du code et non une discipline a tenir.\n//\n// ⚠️ ET CE PLANCHER EST DEJA FILTRE PAR L'HEURE QUAND IL ARRIVE ICI. Le bilan\n// ignore totalement les fenetres d'heures creuses (aucune occurrence de\n// `hc_night` / `hc_midday` dans ce sous-systeme) et doit le rester : il repond a\n// « de combien la batterie a-t-elle besoin », pas a « quand est-ce bon marche ».\n// C'est Home Assistant qui detient le calendrier tarifaire et qui ne publie une\n// valeur non nulle que pendant une fenetre creuse. Sans ce filtrage en amont, un\n// plancher a 85 % fuirait dans la journee et mordrait sur\n// `BATTERY_CLOUD_LOCKS.js` — `eveningSocStartFloorPct = max(eveningSocStartMin,\n// balanceRequiredSocPct)` — au point de bloquer la charge du vehicule le soir.\nconst manualHcFloorPct = (() => {\n const v = num(input.manual_hc_floor_percent, 0);\n return Number.isFinite(v) && v > socFloorPct && v <= 100 ? v : 0;\n})();\n\n// Sans l'une de ces entrées, le bilan ne peut rien affirmer. Il le dit, et les\n// contrôleurs retombent sur leurs règles historiques.\nconst missing = [];\nif (!consumptionP50) missing.push('consumption_p50');\nif (!consumptionP80) missing.push('consumption_p80');\nif (!horizon || !horizon.length) missing.push('horizon');\nif (!Number.isFinite(socPct)) missing.push('soc_percent');\nif (!Number.isFinite(usableCapacityKwh) || usableCapacityKwh <= 0) missing.push('usable_capacity_kwh');\n\nif (missing.length) {\n return [{\n json: {\n usable: false,\n missing,\n reason: `Bilan indisponible : ${missing.join(', ')}. Les contrôleurs doivent conserver leurs règles.`,\n cfg: CFG,\n },\n }];\n}\n\n// ---------------------------------------------------------------------\n// Bilan heure par heure, calculé DEUX FOIS.\n//\n// Pourquoi deux niveaux de prudence et pas un seul : la décision « faut-il du\n// réseau maintenant ? » est réévaluée à chaque cycle et reste réversible. Se\n// tromper par optimisme ne coûte qu'un démarrage réseau une heure plus tard,\n// le plancher dur garantissant la batterie ; se tromper par excès de prudence\n// coûte un import réseau bien réel, immédiatement.\n//\n// Vérifié sur la matinée du 24/08/2026 : avec le profil prudent, le bilan\n// exigerait 48 % de SOC pour franchir la matinée, alors que la consommation\n// réelle de ce jour-là n'a demandé que 0,8 kWh — le seuil fixe de 30 % aurait\n// été MOINS coûteux. Un bilan « intelligent » plus gourmand que le seuil bête\n// qu'il remplace n'a aucun intérêt.\n//\n// - profil TYPIQUE (p50) → décision réseau immédiate, réévaluée en continu.\n// - profil PRUDENT (p80) → planification : cible de nuit, dimensionnement\n// du stockage, choix qui engagent plusieurs heures.\n// ---------------------------------------------------------------------\n// ---------------------------------------------------------------------\n// LA NUIT PREVUE PAR LA SOIREE (28/09/2026, demande du proprietaire)\n//\n// Le profil p50 est une mediane HORAIRE : il ignore la soiree qui vient de se\n// passer. Or sur 24 nuits, la maison consomme d'autant plus de 21:30 a 09:00\n// que la soiree 18:00-21:30 a ete chargee (four) : nuit = 6,98 + 0,74 x soiree,\n// r2 = 0,40. Le service d'historisation publie le FACTEUR nuit prevue / nuit\n// moyenne (sensor.evcs_nuit_maison_prevue_kwh) ; on multiplie le profil par ce\n// facteur sur la seule fenetre 21:30 -> 09:00, au prorata des heures a cheval.\n// Le 28/09 : soiree 6,27 kWh -> facteur 1,09.\n//\n// Un FACTEUR et non des kWh : le profil vient des compteurs d'energie, la\n// regression des puissances integrees ; le rapport annule l'ecart de methode.\n// Dans les deux sens pour le profil TYPIQUE (c'est un estimateur, une soiree\n// legere allege la nuit) ; le PRUDENT ne descend jamais : max(p80, p50 recale).\n// Filet : facteur borne a [0,75 ; 1,45], les extremes des 24 nuits observees\n// (0,77 et 1,41 fois la moyenne) — hors de la, c'est une mesure fausse, pas une\n// nuit. Facteur absent, perime (> 30 min) ou hors fenetre : profil inchange.\nconst NIGHT_FACTOR_MIN = 0.75;\nconst NIGHT_FACTOR_MAX = 1.45;\nconst nowMsNight = num(input.nowMs, Date.now());\nconst nightStartMs = num(input.night_house_start_ms, NaN);\nconst nightEndMs = num(input.night_house_end_ms, NaN);\nconst nightFactorRaw = num(input.night_house_factor, NaN);\nconst nightComputedMs = num(input.night_house_computed_ms, NaN);\nconst nightModelActive =\n Number.isFinite(nightFactorRaw) && nightFactorRaw > 0 &&\n Number.isFinite(nightStartMs) && Number.isFinite(nightEndMs) && nightEndMs > nightStartMs &&\n nowMsNight >= nightStartMs && nowMsNight < nightEndMs &&\n Number.isFinite(nightComputedMs) && nowMsNight - nightComputedMs <= 30 * 60000;\nconst nightFactor = nightModelActive\n ? Math.min(NIGHT_FACTOR_MAX, Math.max(NIGHT_FACTOR_MIN, nightFactorRaw))\n : 1;\nfunction nightShare(startMs) {\n const debut = num(startMs, NaN);\n if (!nightModelActive || !Number.isFinite(debut)) return 0;\n const a = Math.max(debut, nightStartMs), b = Math.min(debut + 3600000, nightEndMs);\n return b > a ? (b - a) / 3600000 : 0;\n}\nfunction nightScaled(profile, slot) {\n const h = num(slot.hour, 0);\n return (profile[h] ?? 0) * (1 + (nightFactor - 1) * nightShare(slot.startMs));\n}\n\n// =====================================================================\n// JOUR FAIBLE : CE QUE LA JOURNEE DIT DEJA (07/10/2026, proprietaire)\n// =====================================================================\n// Le 07/10 la HC de midi visait 44 % (« arriver a 01:24 au-dessus de 22 % »),\n// et EDF a quand meme ete ferme en HEURES PLEINES a 18:03 (~8 kWh HP). Deux\n// erreurs du calcul, corrigees ici :\n// 1. La reprise du chauffe-eau de 16 h a 20 h (douche de 20 h, 2,1 kWh mesures\n// le 07/10) n'etait pas comptee : les jours faibles (decision du bilan),\n// `ecs_reprise_soir_kwh` est ajoute a la consommation de 16 h a 20 h du jour.\n// 2. La prevision de l'apres-midi n'etait pas recalee sur la matinee : VRM\n// annoncait ~9 kWh de 14 h a 19 h, il en est venu ~5, et la matinee le\n// disait deja (3,1 kWh produits a 12:36 pour ~5 prevus). Quand le reel du\n// jour est EN RETARD sur la prevision des heures ecoulees (au moins 2 kWh\n// prevus, production complete), les heures restantes du jour sont\n// multipliees par le meme rapport, borne a [0,4 ; 1]. Jamais a la hausse :\n// une matinee meilleure que prevu ne promet rien pour l'apres-midi.\nconst ECS_SOIR_H0 = 16;\nconst ECS_SOIR_H1 = 20;\nconst nowMsSlots = num(input.nowMs, Date.now());\nconst jourParis = (ms) => new Date(ms).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' });\nconst aujourdhuiParis = jourParis(nowMsSlots);\nfunction slotIsToday(slot) {\n return Number.isFinite(num(slot.startMs, NaN)) && jourParis(num(slot.startMs, 0)) === aujourdhuiParis;\n}\nconst ecsNuitAujourdhui = typeof input.ecs_nuit_jour === 'string'\n && input.ecs_nuit_jour.startsWith(aujourdhuiParis + '|on');\nconst ecsRepriseSoirKwh = ecsNuitAujourdhui ? Math.max(0, num(input.ecs_reprise_soir_kwh, 0)) : 0;\nlet prodRecal = { factor: 1, active: false };\n{\n const prevu = typeof input.forecast_today_csv === 'string' ? input.forecast_today_csv.split(',').map(Number) : [];\n const reel = num(input.production_jour_kwh, NaN);\n if (prevu.length === 24 && prevu.every(Number.isFinite) && Number.isFinite(reel)\n && input.production_jour_statut === 'complet') {\n const p = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit', hour12: false })\n .format(new Date(nowMsSlots)).split(':').map(Number);\n let ecoule = 0;\n for (let h = 0; h < 24; h++) ecoule += h < p[0] ? prevu[h] : (h === p[0] ? prevu[h] * p[1] / 60 : 0);\n if (ecoule >= 2) {\n const r = reel / ecoule;\n prodRecal = { factor: round2(Math.max(0.4, Math.min(1, r))), active: r < 1, ratio: round2(r),\n realKwh: round2(reel), expectedSoFarKwh: round2(ecoule) };\n }\n }\n}\nconst prodRecalFactor = prodRecal.factor;\n\nfunction buildSlots(consumption, prudent = false) {\n return horizon.slice(0, CFG.SEARCH_HOURS).map(slot => {\n const hour = num(slot.hour, 0);\n const aujourdhui = slotIsToday(slot);\n const productionKwh = Math.max(0, num(slot.expectedKwh, 0)) * (aujourdhui ? prodRecalFactor : 1);\n const consumptionKwh = (prudent\n ? Math.max(consumption[hour] ?? 0, nightScaled(consumptionP50, slot))\n : nightScaled(consumption, slot))\n + (aujourdhui && hour >= ECS_SOIR_H0 && hour < ECS_SOIR_H1 ? ecsRepriseSoirKwh / (ECS_SOIR_H1 - ECS_SOIR_H0) : 0);\n return {\n startMs: slot.startMs,\n hour,\n productionKwh: round2(productionKwh),\n consumptionKwh: round2(consumptionKwh),\n netKwh: round2(productionKwh - consumptionKwh),\n shapeQuality: slot.shapeQuality || 'inconnue',\n };\n });\n}\n\n// =====================================================================\n// COURBE D'ENERGIE DE LA BATTERIE — MESUREE, PAS SUPPOSEE (03/09/2026)\n// =====================================================================\n// La conversion energie <-> points de SOC etait une CONSTANTE\n// (`usable_capacity_kwh`, 15 kWh) la ou la realite est une COURBE.\n//\n// Mesure : 576 paliers de SOC sur 8 jours (`tools/mesure-courbe-batterie.mjs`).\n// Pour chaque palier ou le SOC reste a une valeur puis DESCEND d'un point, on\n// integre la puissance batterie — en ecartant les paliers ou la batterie a\n// charge ou ou le reseau a apporte quoi que ce soit.\n//\n// tranche paliers kWh/point\n// 21-31 88 0,097 <- effondrement pres du genou\n// 35-44 78 0,171\n// 45-54 76 0,169\n// 55-64 85 0,163\n// 65-74 80 0,171\n// 75-84 81 0,170\n// 85-94 62 0,169\n//\n// Plate a 0,169 de 33 a 93, genou net a 32-33, puis 0,097 : sous le genou, un\n// point de SOC vaut 57 % de ce qu'il vaut au-dessus. Coherent avec §0 (un\n// module en panne sur huit, un autre qui faiblit) et avec l'effondrement de la\n// jauge deja signale au §28 — ceci lui donne sa courbe.\n//\n// CE QUE LA CONSTANTE FAISAIT : 15 kWh se situe ENTRE les deux pentes, donc\n// l'erreur CHANGE DE SIGNE selon l'energie a couvrir —\n// 1 kWh a couvrir : SOC requis 28,7 % annonce contre 32,3 % reel (-3,6)\n// 4,3 kWh : 50,7 contre 52,1 (-1,4)\n// 8 kWh : 75,3 contre 74,0 (+1,3)\n// 11,7 kWh : 99,7 contre 95,7 (+4,1)\n// Trop BAS en fin de nuit (la direction dangereuse), trop HAUT en debut.\n//\n// Energie utile totale de 100 a 22 % : 12,39 kWh mesures, contre 11,70 vus par\n// le bilan et 13,65 par INPUT_PARSE. Les deux constantes existantes etaient\n// fausses, dans des sens opposes.\n//\n// ⚠️ A RE-MESURER apres toute evolution du parc (extension prevue cet hiver) :\n// `node tools/mesure-courbe-batterie.mjs 8`, puis mettre a jour ces trois\n// nombres. Ce ne sont pas des reglages, ce sont des resultats de mesure.\n//\n// ⚠️ CES PENTES ONT ETE MESUREES SUR SEPT MODULES. Retirer ou ajouter un module\n// divise ou multiplie la capacite sans changer la FORME de la courbe : on les\n// exprime donc par module, et on les multiplie par le nombre reellement en\n// service. Ce nombre se lit dans un helper — `input_number\n// .evcs_balance_modules_en_service` — et non dans le code : le parc a change\n// deux fois en une semaine et changera encore cet hiver. Helper absent ou\n// illisible => 7, la valeur sur laquelle la mesure a ete faite, donc aucun\n// changement de comportement tant que personne ne l'ecrit.\nconst MODULES_MESURE = 7;\nconst modulesEnService = (() => {\n const n = num(input.modules_en_service, MODULES_MESURE);\n return Number.isFinite(n) && n >= 1 && n <= 24 ? n : MODULES_MESURE;\n})();\nconst echelleParc = modulesEnService / MODULES_MESURE;\n\n// 20/09/2026 — RE-MESURE sur le parc ASSAINI (13 equivalents US2000 : 4 US5000\n// + 5 US2000C, cellules a 3-6 mV). `tools/mesure-courbe-batterie.mjs 8` :\n// 0,30 kWh/point PLAT de 25 a 97 %, PLUS DE GENOU. Le genou a 33 % etait bien\n// la signature de la cellule 12 morte du pack ancien (§50) — il a disparu avec\n// elle, exactement comme le predisait la note de prudence du 03/09. La reserve\n// reelle a quasiment double (12,4 -> ~23 kWh utiles de 100 a 22 %).\n// Base normalisee a 7 modules (MODULES_MESURE) : 0,30 x 7/13 = 0,1615 par point.\n// x echelleParc(13/7) = 0,30 au parc actuel. Pente unique => plus de genou.\n// A REMESURER apres toute evolution du parc (2e cable console en commande pour\n// surveiller aussi les cellules du pack 1 US5000).\nconst BATTERY_CURVE = {\n kneePct: 0,\n kwhPerPointAbove: 0.1615 * echelleParc,\n kwhPerPointBelow: 0.1615 * echelleParc,\n};\n\n// Energie que la batterie peut delivrer en descendant de socHi a socLo.\nfunction energyBetweenSoc(socLo, socHi) {\n if (!Number.isFinite(socLo) || !Number.isFinite(socHi) || socHi <= socLo) return 0;\n const knee = BATTERY_CURVE.kneePct;\n const lowSpan = Math.max(0, Math.min(socHi, knee) - Math.min(socLo, knee));\n const highSpan = Math.max(0, Math.max(socHi, knee) - Math.max(socLo, knee));\n return lowSpan * BATTERY_CURVE.kwhPerPointBelow\n + highSpan * BATTERY_CURVE.kwhPerPointAbove;\n}\n\n// SOC necessaire pour delivrer energyKwh en descendant jusqu'a floorPct.\n// NON ecrete : au-dela de 100 la valeur reste le signal de sous-dimensionnement\n// (cf. §26). L'extrapolation au-dessus de 100 utilise la pente haute.\nfunction socForEnergy(energyKwh, floorPct) {\n if (!Number.isFinite(energyKwh) || energyKwh <= 0) return floorPct;\n const knee = Math.max(floorPct, BATTERY_CURVE.kneePct);\n const belowKwh = (knee - floorPct) * BATTERY_CURVE.kwhPerPointBelow;\n if (energyKwh <= belowKwh) {\n return floorPct + energyKwh / BATTERY_CURVE.kwhPerPointBelow;\n }\n return knee + (energyKwh - belowKwh) / BATTERY_CURVE.kwhPerPointAbove;\n}\n\n// SOC atteint en ajoutant addKwh a partir de socPct.\nfunction socAfterEnergy(socPct, addKwh) {\n if (!Number.isFinite(addKwh) || addKwh <= 0) return socPct;\n return socForEnergy(energyBetweenSoc(0, socPct) + addKwh, 0);\n}\n\n// =====================================================\n// TENIR JUSQU'A LA PROCHAINE FENETRE HC (28/09/2026)\n// =====================================================\n// Strategie du proprietaire, apres la nuit du 26-27/09 : P12 avait charge la\n// JEEP sur la batterie (72 -> 44 %) parce qu'il restait assez pour atteindre le\n// solaire du matin — alors que ce meme bilan annoncait deja « prochaine\n// traversee 17,35 kWh des 18 h -> SOC requis 79,8 % ». La journee (22 kWh reels)\n// n'a pas pu reconstituer la reserve. Sa regle : une voiture branchee se charge\n// en heures creuses sur le reseau, et la batterie doit TENIR JUSQU'A LA\n// PROCHAINE FENETRE HC ; si elle ne le peut pas, la fenetre HC de midi la\n// complete.\n//\n// Traduction : PENDANT une fenetre HC, l'exigence vaut au moins le SOC qu'il\n// faut avoir A LA FIN de cette fenetre pour atteindre le DEBUT de la suivante\n// sans passer sous le plancher, production prevue comprise (tranches horaires\n// ci-dessus, heure par heure, profil de conso du profil calcule).\n//\n// ⚠️ LE CALENDRIER RESTE DANS HOME ASSISTANT (§73) : le bilan ne recoit que deux\n// instants, la fin de la fenetre en cours (absente hors fenetre) et le debut de\n// la suivante. Hors fenetre HC, rien ne change.\n//\n// ⚠️ INSERE DANS coverage(), COMME LE PLANCHER MANUEL : la cible (required_soc,\n// qui ENGAGE la session et releve le plancher de P12) et le manque en kWh (qui\n// FINANCE la consigne) bougent ensemble par construction — jamais de session\n// muette (§73). Sens unique : il ne peut que RELEVER l'exigence.\nconst hcWindowEndMs = num(input.hc_window_end_ms, NaN);\nconst nextHcStartMs = num(input.next_hc_start_ms, NaN);\nconst nextNightHcStartMs = num(input.next_night_hc_start_ms, NaN);\nconst hcReachActive = Number.isFinite(hcWindowEndMs) && Number.isFinite(nextHcStartMs)\n && nextHcStartMs > hcWindowEndMs;\n// DEUX HORIZONS, DEUX USAGES (decision du proprietaire, 28/09) :\n// - recharger la batterie au reseau : tenir jusqu'a la PROCHAINE fenetre HC\n// (la nuit : midi ; a midi : 01:24). Midi complete si besoin, et la matinee\n// garde de la place pour le solaire. -> required_soc (engage ET finance).\n// - laisser la voiture puiser dans la batterie la nuit (P12) : seulement si la\n// batterie tient ensuite jusqu'a la NUIT SUIVANTE (01:24), sans compter sur\n// midi. Sinon la voiture se charge sur le reseau en HC. -> required_soc_prudent,\n// qui n'est lu que comme plancher de P12 (et par le conseiller, pour info).\nconst hcNightReachActive = Number.isFinite(hcWindowEndMs) && Number.isFinite(nextNightHcStartMs)\n && nextNightHcStartMs > hcWindowEndMs;\n\n// Energie a avoir en reserve a la fin de la fenetre : parcours A REBOURS des\n// tranches comprises entre les deux instants (au prorata de leur recouvrement).\n// Un surplus ne rembourse qu'un deficit POSTERIEUR — d'ou le sens du parcours.\nfunction hcReachFloorPct(slots, untilMs = nextHcStartMs, fromMs = hcWindowEndMs) {\n if (!Number.isFinite(fromMs) || !Number.isFinite(untilMs) || untilMs <= fromMs) return 0;\n let needKwh = 0;\n for (let i = slots.length - 1; i >= 0; i--) {\n const s = slots[i];\n const debut = num(s.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, fromMs);\n const b = Math.min(debut + 3600000, untilMs);\n if (b <= a) continue;\n needKwh = Math.max(0, needKwh - s.netKwh * (b - a) / 3600000);\n }\n return needKwh > 0 ? socForEnergy(needKwh, socFloorPct) : 0;\n}\n\nfunction coverage(slots, hcFloorPct = 0) {\n const crossingIndex = slots.findIndex(s => s.netKwh > 0);\n const found = crossingIndex >= 0;\n const deficitSlots = found ? slots.slice(0, crossingIndex) : slots;\n const energyToCoverKwh = deficitSlots.reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n // Le plancher manuel ne peut que RELEVER l'exigence, jamais l'abaisser — meme\n // sens unique que les cablages de §38, §39 et §40 : le bilan ne pousse jamais\n // dans la direction « couvre moins bien ».\n const requiredSocRawPct = Math.max(\n socForEnergy(energyToCoverKwh, socFloorPct),\n manualHcFloorPct,\n hcFloorPct\n );\n const socNeededPct = requiredSocRawPct - socFloorPct;\n const requiredSocPct = Math.min(100, requiredSocRawPct);\n const socMarginPct = socPct - requiredSocPct;\n return {\n crossingIndex, found, deficitSlots, slots,\n energyToCoverKwh, socNeededPct, requiredSocPct, socMarginPct,\n gridNeeded: socMarginPct < 0,\n gridEnergyNeededKwh: socMarginPct < 0 ? energyBetweenSoc(socPct, requiredSocPct) : 0,\n };\n}\n\nconst slotsP50 = buildSlots(consumptionP50);\nconst slotsP80 = buildSlots(consumptionP80, true);\nconst hcFloorReachPct = hcReachFloorPct(slotsP50, nextHcStartMs);\nconst hcFloorNightPrudentPct = hcNightReachActive ? hcReachFloorPct(slotsP80, nextNightHcStartMs) : 0;\nconst hcFloorPrudentReachPct = Math.max(hcReachFloorPct(slotsP80, nextHcStartMs), hcFloorNightPrudentPct);\n// ---------------------------------------------------------------------\n// JOUR FAIBLE : LA HC DE MIDI CHARGE AU MAXIMUM (08/10/2026, proprietaire)\n//\n// Le 07/10, « tenir jusqu'a 01:24 » visait 44 % (profil habituel) : la soiree\n// a ete plus lourde, EDF s'est ferme en heures pleines a 18:03. Un jour faible\n// (decision du bilan apres 07:24, `ecs_nuit_jour` = on), l'apres-midi ne\n// remplira pas la batterie : un kWh pris a midi remplace un kWh d'heures pleines\n// du soir, ou au pire un kWh d'heures creuses de la nuit (meme prix, ~10 % de\n// pertes). Viser juste ne protege de rien.\n//\n// La cible devient 100 % pendant la fenetre de MIDI (la prochaine HC est celle\n// de la nuit) : ce qui est REELLEMENT charge est fixe par la physique, pas par\n// un nombre — l'ACM regle le courant sur ce que l'abonnement laisse libre une\n// fois la maison (et la voiture) servie, plus le solaire, plafonne par les\n// 140 A des deux MultiPlus. 65, 70 ou 80 % selon la cuisine et le soleil.\n// La nuit, la regle « tenir jusqu'a midi » est inchangee.\n// ---------------------------------------------------------------------\nconst hcMiddayWindow = hcReachActive && hcNightReachActive && nextHcStartMs === nextNightHcStartMs;\nconst hcMaxCharge = hcMiddayWindow && ecsNuitAujourdhui;\n// ---------------------------------------------------------------------\n// LA NUIT PREPARE AUSSI LE SOIR QUAND LA JOURNEE S'ANNONCE FAIBLE (08/10/2026)\n//\n// Nuit du 07-08/10 : session engagee, mais la cible « tenir jusqu'a midi »\n// valait 30,7 % alors que le meme bilan annoncait 99,3 % requis pour la\n// traversee de 18 h. Les heures creuses de la nuit coutent le meme prix que\n// celles de midi, et midi est borne par la maison (cuisine, voiture, BOOST).\n// Pendant la fenetre de NUIT : plus petit SOC de fin de fenetre qui, avec le\n// profil habituel (production ecretee a 100 %), amene la batterie au SOC requis\n// au debut de la traversee du soir. Si c'est hors d'atteinte, celui qui en\n// approche le plus SANS gaspiller (au-dela, le solaire du jour serait ecrete).\n// Journee suffisante -> rien ne change (le plus petit SOC est le plancher\n// « tenir jusqu'a midi »).\n// ---------------------------------------------------------------------\nconst hcNightWindow = hcReachActive && !hcMiddayWindow;\nlet nightEvening = { active: false };\nif (hcNightWindow) {\n const ndEve = nextDeficitWindow(slotsP50);\n if (ndEve.found && Number.isFinite(num(ndEve.startMs, NaN)) && ndEve.startMs > hcWindowEndMs) {\n const socAtEvening = (s0) => {\n let soc = s0;\n for (const sl of slotsP50) {\n const debut = num(sl.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, hcWindowEndMs);\n const b = Math.min(debut + 3600000, ndEve.startMs);\n if (b <= a) continue;\n const net = sl.netKwh * (b - a) / 3600000;\n soc = net >= 0 ? Math.min(100, socAfterEnergy(soc, net))\n : Math.max(socFloorPct, socForEnergy(Math.max(0, energyBetweenSoc(0, soc) + net), 0));\n }\n return soc;\n };\n const requis = ndEve.requiredSocAtStartPct;\n const base = Math.max(socFloorPct, hcFloorReachPct);\n const objectif = Math.min(requis, socAtEvening(100)) - 0.05;\n let floor = base;\n if (socAtEvening(base) < objectif) {\n let lo = base, hi = 100;\n for (let k = 0; k < 30; k++) { const m = (lo + hi) / 2; if (socAtEvening(m) >= objectif) hi = m; else lo = m; }\n floor = hi;\n }\n nightEvening = {\n active: floor > base + 0.05,\n floorPct: round1(floor),\n eveningRequiredPct: round1(requis),\n eveningProjectedPct: round1(socAtEvening(base)),\n eveningStartHour: ndEve.startHour,\n };\n }\n}\nconst hcFloorTypicalPct = hcMaxCharge ? 100\n : (nightEvening.active ? Math.max(hcFloorReachPct, nightEvening.floorPct) : hcFloorReachPct);\nconst hcFloorPrudentPct = hcMaxCharge ? 100 : hcFloorPrudentReachPct;\nconst typical = coverage(slotsP50, hcFloorTypicalPct);\nconst prudent = coverage(slotsP80, hcFloorPrudentPct);\n\n// ---------------------------------------------------------------------\n// CE QUE LA SESSION DE NUIT ACHETERA AU RESEAU (29/09/2026)\n//\n// Incident du 28/09 : la prevision annoncait « 0,14 kWh a prendre sur EDF » et\n// le reseau en a fourni 4,6. Les deux etaient justes, mais le premier est le\n// MANQUE de la batterie : une fois engagee, la session tient la batterie et la\n// maison passe TOUTE la fenetre HC de nuit sur le reseau (§40). Le chiffre qui\n// repond a « combien vais-je acheter » est donc la maison non couverte par le\n// solaire sur la fenetre (profil habituel, recale sur la soiree), depuis\n// maintenant si la fenetre est ouverte. Le manque batterie s'y ajoute, a part.\n// ---------------------------------------------------------------------\nconst nightHcStartMs = num(input.night_hc_start_ms, NaN);\nconst nightHcEndMs = num(input.night_hc_end_ms, NaN);\nconst nightHcActive = Number.isFinite(nightHcStartMs) && Number.isFinite(nightHcEndMs)\n && nightHcEndMs > nightHcStartMs;\nlet nightHcHouseKwh = null;\nif (nightHcActive) {\n const depuis = Math.max(nightHcStartMs, num(input.nowMs, Date.now()));\n nightHcHouseKwh = 0;\n for (const sl of slotsP50) {\n const debut = num(sl.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, depuis), b = Math.min(debut + 3600000, nightHcEndMs);\n if (b > a) nightHcHouseKwh += Math.max(0, -sl.netKwh) * (b - a) / 3600000;\n }\n}\n\n// ---------------------------------------------------------------------\n// BUDGET VOITURE DE LA NUIT (30/09/2026, decisions du proprietaire)\n//\n// « Definir combien la voiture peut prendre sans utiliser EDF, et brider la\n// charge a cette valeur ou autoriser une charge complete avec EDF. » Choix du\n// 30/09 : profil PRUDENT (p80) et plancher du bilan (22 %) ; par defaut « sans\n// EDF » ; en charge complete, le budget batterie d'abord, EDF pour le reste.\n//\n// Budget = la plus petite reserve au-dessus du plancher que la batterie\n// connaitra entre l'ouverture de la fenetre HC de nuit (ou maintenant si elle\n// est ouverte) et le debut de la fenetre de nuit SUIVANTE, sur le profil\n// prudent. Retirer X kWh a l'ouverture abaisse toute la trajectoire de X :\n// le budget est donc le creux de la trajectoire. L'horizon jusqu'a la nuit\n// suivante applique « la batterie tient jusqu'a la prochaine HC » ; la\n// batterie pleine ecrete la recharge (on ne stocke pas au-dela de 100 %).\n// Le 30/09 01:25 : SOC 57, prudent 44,7 -> ~3,7 kWh.\n// ---------------------------------------------------------------------\nlet nightVehicleBudgetKwh = null;\nif (nightHcActive && Number.isFinite(socPct)) {\n const maintenant = num(input.nowMs, Date.now());\n const depuis = Math.max(nightHcStartMs, maintenant);\n const jusqua = nightHcStartMs + 24 * 3600000;\n const plein = energyBetweenSoc(socFloorPct, 100);\n let reserve = socPct >= socFloorPct\n ? energyBetweenSoc(socFloorPct, socPct) : -energyBetweenSoc(socPct, socFloorPct);\n let creux = maintenant >= depuis ? reserve : Infinity;\n for (const sl of slotsP80) {\n const debut = num(sl.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, maintenant), b = Math.min(debut + 3600000, jusqua);\n if (b <= a) continue;\n // La tranche qui contient l'ouverture est coupee en deux : le creux ne se\n // lit qu'a partir de l'ouverture.\n const coupes = depuis > a && depuis < b ? [a, depuis, b] : [a, b];\n for (let i = 0; i + 1 < coupes.length; i++) {\n reserve = Math.min(plein, reserve + sl.netKwh * (coupes[i + 1] - coupes[i]) / 3600000);\n if (coupes[i + 1] >= depuis) creux = Math.min(creux, reserve);\n }\n }\n // BORNE PAR LE PLANCHER PRUDENT (04/10/2026). Nuit du 03-04/10 : SOC 47,\n // prudent 43,6 -> ~1,0 kWh de marge, mais le creux a donne 1,41 kWh. La\n // voiture a pris toute la marge, la batterie a touche la cible du matin a\n // 02:57 et la reprise reseau a ferme EDF. Le budget ne peut donc jamais\n // depasser l'energie au-dessus du SOC requis PRUDENT, celui-la meme que la\n // voiture n'a pas le droit de franchir en assistance batterie (P12) : les\n // deux criteres sont desormais les memes par construction.\n const margePrudente = Number.isFinite(prudent.requiredSocPct)\n ? (socPct > prudent.requiredSocPct ? energyBetweenSoc(prudent.requiredSocPct, socPct) : 0)\n : 0;\n nightVehicleBudgetKwh = Number.isFinite(creux) ? Math.max(0, Math.min(creux, margePrudente)) : null;\n}\n\n// ---------------------------------------------------------------------\n// VEHICULE BRANCHE EN HC AVEC UN DEPART AVANT LA PROCHAINE HC (28/09/2026)\n//\n// Regle du proprietaire : « JEEP branchee engage le reseau en HC midi si\n// l'analyse de production le justifie ». La JEEP repart a 15:30 certains jours\n// (calendrier dans HA) : ce qu'elle n'a pas pris avant, elle ne le prendra pas\n// en HC la nuit. Ailleurs, elle reste branchee et la HC de 01:24 la finit au\n// meme tarif — fermer le contacteur a midi n'y gagnerait rien et etoufferait le\n// solaire (installation sans injection).\n//\n// Ce que le solaire peut donner a la voiture avant son depart : le bilan net\n// prevu (production - maison) d'ici le depart, profil p50 comme required_soc.\n// Une batterie SOUS son plancher « tenir jusqu'a la prochaine HC » (evalue AU\n// DEPART) est d'abord remontee par ce surplus.\n// ⚠️ 06/10/2026 (proprietaire : « bascule automatique sur force_grid_now sauf si\n// le solaire donne la charge ce midi ») : la batterie au-dessus de ce plancher\n// n'est PLUS comptee. Le controleur ne donne pas la batterie a la voiture en\n// journee (demarrage sur excedent, §36) sauf par l'assistance batterie, a partir\n// de VEHICLE_BATTERY_ASSIST_SOC_PCT : seule la part au-dessus est comptee. Le\n// 06/10 a 13:12, 4,08 kWh de batterie comptes donnaient « solaire suffisant »\n// (5,86 pour 4,4) alors que le controleur attendait un excedent qui n'est pas\n// venu : la JEEP serait partie a 15:30 sans charge.\n// Le reseau est justifie quand le besoin de la voiture, borne par ce que son\n// chargeur peut absorber avant le depart, depasse ce solaire.\n//\n// Besoin = (100 - niveau saisi) % x capacite MESUREE au compteur de la borne,\n// moins ce que la borne a deja delivre depuis la saisie. Aucun capteur ne donne\n// le niveau de la voiture : sans saisie valable, la regle se tait.\n//\n// Vaut pour les TROIS vehicules (JEEP, VW, moto ZERO) : capacite, puissance de\n// charge et calendrier viennent de HA pour le vehicule selectionne.\n// La puissance du chargeur EMBARQUE (plateau mesure : JEEP 3,66 kW, VW\n// 3,68 kW) borne ce que le vehicule peut prendre avant son depart ; inconnue\n// (0), on ne borne pas — l'erreur va alors vers « reseau justifie », c'est-a-\n// dire vers le vehicule charge, jamais vers la batterie videe.\n// Seuil de 0,5 kWh : en dessous une session ne se justifie pas — une fermeture\n// du contacteur coute 50 a 150 Wh de transitoire (§57), on exige un gain d'au\n// moins trois fois ce cout.\nconst VEHICLE_GRID_MIN_SHORTFALL_KWH = 0.5;\n// = batteryAssistSolarStartSocMin de BATTERY_CLOUD_LOCKS.js (verifie par un\n// test qui lit le controleur : les deux valeurs ne peuvent pas diverger).\nconst VEHICLE_BATTERY_ASSIST_SOC_PCT = 90;\nconst vehicleChargerKw = num(input.vehicle_charger_kw, NaN);\nconst nowMsBalance = num(input.nowMs, Date.now());\nconst vehicleConnected = input.vehicle_connected === true || input.vehicle_connected === 'true';\nconst vehicleDepartureMs = num(input.vehicle_departure_ms, NaN);\nconst vehicleLevelPct = num(input.vehicle_level_pct, NaN);\nconst vehicleLevelValid = input.vehicle_level_valid === true || input.vehicle_level_valid === 'true';\nconst vehicleCapacityKwh = num(input.vehicle_capacity_kwh, NaN);\nconst vehicleDeliveredKwh = Math.max(0, num(input.vehicle_delivered_since_level_kwh, 0));\nconst vehicleNeedKwh =\n vehicleLevelValid && Number.isFinite(vehicleLevelPct) && vehicleLevelPct >= 0 && vehicleLevelPct <= 100\n && Number.isFinite(vehicleCapacityKwh) && vehicleCapacityKwh > 0\n ? Math.max(0, (100 - vehicleLevelPct) / 100 * vehicleCapacityKwh - vehicleDeliveredKwh)\n : NaN;\nconst vehicleRuleActive =\n hcReachActive && vehicleConnected && Number.isFinite(vehicleNeedKwh)\n && Number.isFinite(vehicleDepartureMs) && vehicleDepartureMs > nowMsBalance\n && vehicleDepartureMs < nextHcStartMs;\nlet vehicleSolarKwh = null;\nlet vehicleReachableKwh = null;\nlet vehicleShortfallKwh = null;\nlet vehicleFloorAtDeparturePct = null;\nif (vehicleRuleActive) {\n vehicleFloorAtDeparturePct = hcReachFloorPct(slotsP50, nextHcStartMs, vehicleDepartureMs);\n let netKwh = 0;\n for (const sl of slotsP50) {\n const debut = num(sl.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, nowMsBalance);\n const b = Math.min(debut + 3600000, vehicleDepartureMs);\n if (b > a) netKwh += sl.netKwh * (b - a) / 3600000;\n }\n // Difference SIGNEE : une batterie SOUS son plancher doit d'abord etre\n // remontee par ce meme surplus avant que la voiture n'en voie un kWh.\n const plancherDepart = Math.max(socFloorPct, vehicleFloorAtDeparturePct);\n const manqueBatterieKwh = socPct >= plancherDepart ? 0 : energyBetweenSoc(socPct, plancherDepart);\n const seuilAssist = Math.max(plancherDepart, VEHICLE_BATTERY_ASSIST_SOC_PCT);\n const batterieAssistKwh = socPct > seuilAssist ? energyBetweenSoc(seuilAssist, socPct) : 0;\n vehicleSolarKwh = Math.max(0, netKwh - manqueBatterieKwh) + batterieAssistKwh;\n vehicleReachableKwh = Number.isFinite(vehicleChargerKw) && vehicleChargerKw > 0\n ? Math.min(vehicleNeedKwh, vehicleChargerKw * (vehicleDepartureMs - nowMsBalance) / 3600000)\n : vehicleNeedKwh;\n vehicleShortfallKwh = Math.max(0, vehicleReachableKwh - vehicleSolarKwh);\n}\nconst vehicleGridNeeded = vehicleRuleActive && vehicleShortfallKwh > VEHICLE_GRID_MIN_SHORTFALL_KWH;\n\n// ---------------------------------------------------------------------\n// PROCHAINE FENÊTRE DE DÉFICIT (ajout du 24/08/2026)\n//\n// Le calcul ci-dessus répond à « combien d'énergie jusqu'à ce que le solaire\n// prenne le relais ». À 15 h, le solaire a déjà pris le relais : la réponse est\n// zéro et le SOC requis retombe sur le plancher. C'est exact mais inutile à\n// cette heure-là, car le déficit qui approche est celui de la nuit.\n//\n// On cherche donc AUSSI la prochaine fenêtre de déficit après le surplus en\n// cours : son heure de début, son énergie, et le SOC qu'il faudra avoir atteint\n// à ce moment-là. C'est cette grandeur qui remplace la cible de nuit fixe, et\n// qui dit combien de surplus il faut avoir stocké avant le coucher du soleil.\n//\n// Profil PRUDENT : contrairement à la décision réseau immédiate, cette\n// projection engage plusieurs heures et n'est pas révisable à volonté — la nuit\n// tombée, il n'y a plus de solaire pour rattraper une sous-estimation.\n// ---------------------------------------------------------------------\nfunction nextDeficitWindow(slots) {\n const firstSurplus = slots.findIndex(s => s.netKwh > 0);\n if (firstSurplus < 0) return { found: false, reason: 'deficit_en_cours' };\n\n const start = slots.findIndex((s, i) => i > firstSurplus && s.netKwh < 0);\n if (start < 0) return { found: false, reason: 'aucun_deficit_sur_horizon' };\n\n let end = slots.findIndex((s, i) => i > start && s.netKwh > 0);\n if (end < 0) end = slots.length;\n\n const energyKwh = slots.slice(start, end)\n .reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n\n // Surplus encore récupérable d'ici là : de quoi remplir, ou pas.\n const surplusBeforeKwh = slots.slice(0, start)\n .reduce((sum, s) => sum + Math.max(0, s.netKwh), 0);\n\n return {\n found: true,\n startHour: slots[start].hour,\n startMs: slots[start].startMs,\n hoursFromNow: start,\n endHour: end < slots.length ? slots[end].hour : null,\n complete: end < slots.length,\n energyKwh,\n // NON écrêté à 100 volontairement : une valeur au-dessus de 100 signifie\n // que même une batterie pleine ne suffit pas à traverser la fenêtre. C'est\n // exactement le signal de sous-dimensionnement du stockage — l'écrêter le\n // masquerait.\n requiredSocAtStartPct: socForEnergy(energyKwh, socFloorPct),\n surplusBeforeKwh,\n };\n}\n\nconst nextDeficit = nextDeficitWindow(prudent.slots);\n\n// ---------------------------------------------------------------------\n// CHAUFFE-EAU EN HC DE NUIT LES JOURS FAIBLES (07/10/2026, option b du\n// proprietaire). Le 06/10 (14,5 kWh produits), le chauffe-eau a pris 5,4 kWh\n// de 10 h a 15 h, soit l'energie qui manquait a la batterie pour la nuit.\n// Regle : si le solaire prevu (profil habituel) ne suffit pas a amener la\n// batterie au SOC requis au debut de la traversee du soir, EDF sera ferme la\n// nuit de toute facon -> l'eau se chauffe en HC de nuit sur EDF, pas avec le\n// solaire du jour.\n// ⚠️ DECISION UNE FOIS PAR JOUR, figee jusqu'au lendemain : retirer le\n// chauffe-eau de la journee AMELIORE la projection — reevaluee a chaque cycle,\n// la regle detruirait sa propre cause (oscillateur, cf. §24/§36). On decide au\n// premier calcul apres la fin de la fenetre HC de nuit du jour (la fenetre\n// suivante se termine alors dans plus de 12 h) ; la decision vaut pour la\n// journee et la nuit qui suit, jusqu'a la decision du lendemain.\n// ---------------------------------------------------------------------\nconst ecsNuitPrev = typeof input.ecs_nuit_jour === 'string' ? input.ecs_nuit_jour : '';\nconst ecsNuitPrevDate = ecsNuitPrev.split('|')[0] || '';\nconst nowMsEcs = num(input.nowMs, Date.now());\nconst todayParis = new Date(nowMsEcs).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' });\nconst ecsNightHcEndMs = num(input.night_hc_end_ms, NaN);\nconst ecsDecisionDue = ecsNuitPrevDate !== todayParis\n && Number.isFinite(ecsNightHcEndMs) && ecsNightHcEndMs - nowMsEcs > 12 * 3600000;\nlet ecsNight = { due: ecsDecisionDue, decided: false, date: todayParis };\nif (ecsDecisionDue) {\n const nd50 = nextDeficitWindow(slotsP50);\n if (nd50.found) {\n // Trajectoire SIGNEE jusqu'au debut de la traversee (le deficit du matin\n // compte), bornee au plancher et a 100 %.\n let socProj = socPct;\n for (let i = 0; i < nd50.hoursFromNow; i++) {\n const net = slotsP50[i].netKwh;\n // socAfterEnergy ignore une energie negative : la decharge passe par\n // l'energie stockee (meme courbe).\n socProj = net >= 0 ? Math.min(100, socAfterEnergy(socProj, net))\n : Math.max(socFloorPct, socForEnergy(Math.max(0, energyBetweenSoc(0, socProj) + net), 0));\n }\n const on = socProj < nd50.requiredSocAtStartPct;\n ecsNight = {\n due: true, decided: true, date: todayParis, on,\n projectedSocPct: round1(socProj),\n requiredSocPct: round1(nd50.requiredSocAtStartPct),\n startHour: nd50.startHour,\n reason: on\n ? `jour faible : batterie prevue a ${round1(socProj)} % a ${nd50.startHour} h pour ${round1(nd50.requiredSocAtStartPct)} % requis -> eau chaude en HC de nuit`\n : `jour suffisant : batterie prevue a ${round1(socProj)} % a ${nd50.startHour} h pour ${round1(nd50.requiredSocAtStartPct)} % requis -> eau chaude au solaire`,\n };\n }\n}\n\n// ---------------------------------------------------------------------\n// RÉSERVE D'ORAGE (27/08/2026)\n//\n// Les coupures réseau sont fréquentes ici lors des orages. Point de départ\n// souvent mal posé : une coupure n'arrête PAS l'installation. Le solaire et\n// la batterie continuent de fonctionner, seul le filet de secours disparaît.\n// La question n'est donc pas « combien stocker pour survivre » mais « la\n// batterie traverse-t-elle la prochaine fenêtre de déficit SANS le réseau ».\n//\n// C'est exactement la couverture PRUDENTE (p80) déjà calculée plus haut :\n// rien à inventer, aucune constante de plus. Le profil prudent est le bon\n// choix ici parce qu'une coupure retire précisément la possibilité de\n// rattraper une sous-estimation.\n//\n// Le niveau vient de la vigilance Météo-France, dont l'échelle officielle est\n// vert/jaune/orange/rouge. « Actif » à partir d'orange : c'est le seuil de\n// l'émetteur, pas un nombre choisi ici.\n// ---------------------------------------------------------------------\nconst STORM_SCALE = ['vert', 'jaune', 'orange', 'rouge'];\nconst STORM_ACTIVE_FROM = STORM_SCALE.indexOf('orange');\n\nconst stormRaw = typeof input.storm_alert_level === 'string'\n ? input.storm_alert_level.trim().toLowerCase()\n : '';\nconst stormIndex = STORM_SCALE.indexOf(stormRaw);\nconst stormKnown = stormIndex >= 0;\nconst stormActive = stormIndex >= STORM_ACTIVE_FROM;\n\n// Phénomènes atteignant le niveau d'alerte, s'ils sont exposés en attributs\n// (« Orages », « Vent violent »…). Purement informatif.\nconst stormDetail = (input.storm_alert_detail && typeof input.storm_alert_detail === 'object')\n ? input.storm_alert_detail\n : {};\nconst stormPhenomena = Object.keys(stormDetail)\n .filter(k => typeof stormDetail[k] === 'string'\n && STORM_SCALE.indexOf(stormDetail[k].trim().toLowerCase()) >= STORM_ACTIVE_FROM)\n .sort();\n\n// En surplus, la fenêtre qui compte est la prochaine (la nuit qui vient) ;\n// en déficit, c'est celle qu'on est en train de traverser.\nconst inSurplusNow = prudent.found && prudent.crossingIndex === 0;\nconst stormWindow = (inSurplusNow && nextDeficit.found)\n ? {\n kwh: nextDeficit.energyKwh,\n socPct: nextDeficit.requiredSocAtStartPct,\n startHour: nextDeficit.startHour,\n }\n : {\n kwh: prudent.energyToCoverKwh,\n socPct: prudent.requiredSocPct,\n startHour: null,\n };\nconst stormMarginPct = socPct - stormWindow.socPct;\n\nconst slots = typical.slots;\nconst crossingFound = typical.found;\nconst crossingIndex = typical.crossingIndex;\nconst crossing = crossingFound ? slots[crossingIndex] : null;\nconst deficitSlots = typical.deficitSlots;\n\n// ---------------------------------------------------------------------\n// Marge disponible pour le véhicule : surplus attendu après le croisement,\n// une fois la maison servie. C'est ce qui peut être chargé sans toucher au\n// réseau ni entamer la réserve.\n// ---------------------------------------------------------------------\nconst surplusAfterCrossingKwh = crossingFound\n ? slots.slice(crossingIndex).reduce((sum, s) => sum + Math.max(0, s.netKwh), 0)\n : 0;\n\n// ---------------------------------------------------------------------\n// Confiance : la part du calcul qui repose sur une forme de production minorée\n// (après-midi censuré). Elle ne dégrade PAS la réponse « faut-il du réseau »,\n// qui se joue sur les heures de déficit — celles-ci ne sont jamais censurées,\n// une heure censurée étant par construction une heure de surplus.\n// ---------------------------------------------------------------------\nconst deficitOnWeakShape = deficitSlots.filter(s => s.shapeQuality === 'minoree').length;\nconst confidence = {\n deficitHoursOnWeakShape: deficitOnWeakShape,\n deficitTrustworthy: deficitOnWeakShape === 0,\n surplusTrustworthy: !slots.slice(crossingFound ? crossingIndex : 0)\n .some(s => s.shapeQuality === 'minoree'),\n};\n\nreturn [{\n json: {\n usable: true,\n generatedAt: new Date(num(input.nowMs, Date.now())).toISOString(),\n\n crossing: {\n found: crossingFound,\n hour: crossing ? crossing.hour : null,\n startMs: crossing ? crossing.startMs : null,\n hoursFromNow: crossingFound ? crossingIndex : null,\n },\n\n coverage: {\n // Décision réseau immédiate — remplace input_number.victron_grid_soc_off.\n energyToCoverKwh: round2(typical.energyToCoverKwh),\n requiredSocPct: round1(typical.requiredSocPct),\n currentSocPct: round1(socPct),\n socMarginPct: round1(typical.socMarginPct),\n gridNeeded: typical.gridNeeded,\n gridEnergyNeededKwh: round2(typical.gridEnergyNeededKwh),\n\n // Planification (cible de nuit, dimensionnement) — ne pilote PAS le réseau.\n prudent: {\n energyToCoverKwh: round2(prudent.energyToCoverKwh),\n requiredSocPct: round1(prudent.requiredSocPct),\n socMarginPct: round1(prudent.socMarginPct),\n crossingHour: prudent.found ? prudent.slots[prudent.crossingIndex].hour : null,\n },\n },\n\n // Ce qu'il faudra avoir stocké avant la prochaine traversée déficitaire.\n nextDeficit: nextDeficit.found ? {\n found: true,\n startHour: nextDeficit.startHour,\n hoursFromNow: nextDeficit.hoursFromNow,\n endHour: nextDeficit.endHour,\n complete: nextDeficit.complete,\n energyKwh: round2(nextDeficit.energyKwh),\n requiredSocAtStartPct: round1(nextDeficit.requiredSocAtStartPct),\n surplusBeforeKwh: round2(nextDeficit.surplusBeforeKwh),\n // Franchissable avec le stockage actuel, batterie pleine ?\n feasibleWithCurrentStorage: nextDeficit.requiredSocAtStartPct <= 100,\n // Si non : capacité utile manquante. C'est le chiffre du dimensionnement.\n storageShortfallKwh: energyBetweenSoc(100, nextDeficit.requiredSocAtStartPct),\n reachable: nextDeficit.surplusBeforeKwh >= 0\n && socAfterEnergy(socPct, nextDeficit.surplusBeforeKwh)\n >= nextDeficit.requiredSocAtStartPct,\n } : { found: false, reason: nextDeficit.reason },\n\n vehicle: {\n surplusAvailableKwh: round2(surplusAfterCrossingKwh),\n },\n\n // Chauffe-eau en HC de nuit les jours faibles (07/10).\n ecsNight,\n // Reprise du soir et prevision du jour recalee sur la matinee (07/10).\n ecsReprise: { active: ecsRepriseSoirKwh > 0, kwh: round2(ecsRepriseSoirKwh) },\n prodRecal,\n\n // Nuit prevue par la soiree (28/09) : facteur applique au profil.\n nightModel: {\n active: nightModelActive,\n factor: Math.round(nightFactor * 1000) / 1000,\n rawFactor: Number.isFinite(nightFactorRaw) ? nightFactorRaw : null,\n forecastKwh: num(input.night_house_forecast_kwh, NaN) || null,\n },\n\n // Maison sur le reseau pendant la fenetre HC de nuit, si la session s'engage (29/09).\n nightHcImport: {\n active: nightHcActive,\n startMs: nightHcActive ? nightHcStartMs : null,\n endMs: nightHcActive ? nightHcEndMs : null,\n houseKwh: nightHcHouseKwh === null ? null : round2(nightHcHouseKwh),\n },\n\n // Ce que la voiture peut prendre sur la batterie cette nuit sans EDF (30/09).\n nightVehicleBudget: {\n active: nightVehicleBudgetKwh !== null,\n kwh: nightVehicleBudgetKwh === null ? null : round2(nightVehicleBudgetKwh),\n },\n\n // Voiture branchee en HC, depart avant la prochaine HC (28/09).\n vehicleMidday: {\n active: vehicleRuleActive,\n needKwh: Number.isFinite(vehicleNeedKwh) ? round2(vehicleNeedKwh) : null,\n departureMs: Number.isFinite(vehicleDepartureMs) ? vehicleDepartureMs : null,\n solarKwh: vehicleSolarKwh === null ? null : round2(vehicleSolarKwh),\n reachableKwh: vehicleReachableKwh === null ? null : round2(vehicleReachableKwh),\n shortfallKwh: vehicleShortfallKwh === null ? null : round2(vehicleShortfallKwh),\n floorAtDeparturePct: vehicleFloorAtDeparturePct === null ? null : round1(vehicleFloorAtDeparturePct),\n gridNeeded: vehicleGridNeeded,\n },\n\n // Tenir jusqu'a la prochaine fenetre HC (28/09) : SOC a avoir en fin de\n // fenetre HC en cours. 0 hors fenetre ou si la production suffit.\n hcReach: {\n active: hcReachActive,\n windowEndMs: hcReachActive ? hcWindowEndMs : null,\n nextHcStartMs: hcReachActive ? nextHcStartMs : null,\n floorPct: round1(Math.min(100, hcFloorTypicalPct)),\n // Jour faible a midi : cible 100 % (charge maximale) ; floorReachPct garde\n // le plancher « tenir jusqu'a 01:24 » calcule, pour comparaison.\n maxCharge: hcMaxCharge,\n // Nuit d'un jour annonce faible : SOC de fin de nuit pour arriver au soir.\n nightEvening,\n floorReachPct: round1(Math.min(100, hcFloorReachPct)),\n floorPrudentPct: round1(Math.min(100, hcFloorPrudentPct)),\n nextNightHcStartMs: hcNightReachActive ? nextNightHcStartMs : null,\n nightFloorPrudentPct: round1(Math.min(100, hcFloorNightPrudentPct)),\n raisesRequirement: hcReachActive && hcFloorTypicalPct >= typical.requiredSocPct - 0.05\n && hcFloorTypicalPct > socForEnergy(typical.energyToCoverKwh, socFloorPct),\n },\n\n // Réserve à tenir pour traverser SANS réseau la fenêtre de déficit qui\n // compte. Publiée en permanence — elle documente le risque même par temps\n // calme ; `active` dit seulement si la vigilance la rend d'actualité.\n storm: {\n level: stormKnown ? stormRaw : 'indisponible',\n known: stormKnown,\n active: stormActive,\n phenomena: stormPhenomena,\n windowStartHour: stormWindow.startHour,\n reserveKwh: round2(stormWindow.kwh),\n reserveSocPct: round1(stormWindow.socPct),\n socMarginPct: round1(stormMarginPct),\n covered: stormMarginPct >= 0,\n shortfallKwh: stormMarginPct < 0\n ? round2(energyBetweenSoc(socPct, stormWindow.socPct))\n : 0,\n },\n\n confidence,\n slots,\n\n inputs: {\n usableCapacityKwh: round2(usableCapacityKwh),\n socFloorPct: round1(socFloorPct),\n },\n\n cfg: CFG,\n },\n}];\n" + "jsCode": "// EVCS Energy Balance — BALANCE\n// =====================================================================\n// Rôle : produire les FAITS qui remplacent les seuils fixes. Rien d'autre.\n// Ce nœud ne pilote ni EDF, ni la borne : il publie des grandeurs que les deux\n// contrôleurs liront à la place de leurs constantes.\n//\n// La question à laquelle il répond n'est pas « le SOC est-il au-dessus de\n// 30 % ? » mais « la batterie contient-elle de quoi tenir jusqu'à ce que le\n// solaire prenne le relais ? ». Le 24/08/2026 à 06:43, la réponse du seuil fixe\n// et celle du bilan diffèrent : la production ne couvrait la maison qu'à partir\n// de 09 h, il fallait donc environ 0,8 kWh de réserve, soit ~5 points de SOC —\n// et non un palier arbitraire à 30 %.\n//\n// Entrées (toutes fournies, aucune constante physique en dur) :\n// consumption_p50[24] profil horaire mesuré, valeur typique (CONSUMPTION_PROFILE)\n// consumption_p80[24] idem, valeur prudente\n// horizon[] production attendue heure par heure (RESOURCE_ESTIMATE)\n// soc_percent SOC lissé pour le pilotage\n// usable_capacity_kwh capacité réellement disponible du parc batterie\n// soc_floor_percent plancher dur de sécurité\n//\n// Sorties : heure de croisement · énergie à couvrir · SOC requis · marge\n// disponible pour le véhicule · niveau de confiance.\n// =====================================================================\n\nfunction num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction round1(v) {\n return Number.isFinite(v) ? Math.round(v * 10) / 10 : null;\n}\n\nfunction round2(v) {\n return Number.isFinite(v) ? Math.round(v * 100) / 100 : null;\n}\n\nconst input = $input.first().json;\n\nconst CFG = {\n TIMEZONE: 'Europe/Paris',\n // Au-delà de cet horizon sans croisement trouvé, on cesse de chercher : la\n // réponse n'aurait plus de sens opérationnel.\n SEARCH_HOURS: 40,\n // PAS de marge de sécurité supplémentaire. La prudence est déjà portée,\n // entièrement, par le choix du percentile du profil de consommation (p80),\n // qui est mesuré. Vérifié le 24/08/2026 : sur toutes les fenêtres testées,\n // p80(somme des heures) est égal a somme(p80 par heure) — les heures de\n // consommation sont fortement corrélées, une matinée chargée l'étant sur\n // plusieurs heures d'affilée. Sommer les p80 ne cumule donc PAS deux\n // pessimismes, et ajouter un coefficient par-dessus ne serait qu'une\n // constante arbitraire de plus.\n};\n\nfunction profileOf(key) {\n const a = input[key];\n return Array.isArray(a) && a.length === 24 ? a.map(v => Math.max(0, num(v, 0))) : null;\n}\nconst consumptionP50 = profileOf('consumption_p50');\nconst consumptionP80 = profileOf('consumption_p80');\n\nconst horizon = Array.isArray(input.horizon) ? input.horizon : null;\n\nconst socPct = num(input.soc_percent, NaN);\nconst usableCapacityKwh = num(input.usable_capacity_kwh, NaN);\nconst socFloorPct = num(input.soc_floor_percent, 22);\n\n// =====================================================\n// CHARGE HC MANUELLE — UN PLANCHER, PAS UNE CIBLE (18/09/2026)\n// =====================================================\n// Le proprietaire veut pouvoir demander une recharge batterie en heures creuses\n// quand le bilan ne l'a pas jugee necessaire — journee d'hiver courte, ciel\n// couvert. La decision qu'il a tranchee : CONTRAINDRE le bilan plutot que de\n// l'ignorer, pour garder un seul arbitre. Ce projet a deja paye cher les\n// automates concurrents (charges programmees de l'ESS §26, consigne a 4200 W\n// §27, plancher SOC du GX §27).\n//\n// ⚠️ LE FORCAGE DOIT ETRE ICI, EN AMONT, ET NULLE PART AILLEURS. Mesure du\n// 18/09 sur banc : ecrire directement dans `input_number.evcs_balance_required_soc`\n// engage bien la session — contacteur ferme, takeover ON — mais laisse la\n// consigne reseau a 700 W, donc la batterie recoit une centaine de watts. C'est\n// le defaut du 01/09 et du 04/09 a l'identique, et c'est le seul resultat des\n// trois qui soit INDIAGNOSTICABLE depuis le tableau de bord : il a l'air de\n// marcher. La raison : `required_soc` ENGAGE la session, `grid_energy_needed_kwh`\n// la FINANCE, et les deux sont publiees separement. Pose ici, dans `coverage()`,\n// le plancher fait bouger les deux ENSEMBLE par construction — c'est une\n// consequence de la forme du code et non une discipline a tenir.\n//\n// ⚠️ ET CE PLANCHER EST DEJA FILTRE PAR L'HEURE QUAND IL ARRIVE ICI. Le bilan\n// ignore totalement les fenetres d'heures creuses (aucune occurrence de\n// `hc_night` / `hc_midday` dans ce sous-systeme) et doit le rester : il repond a\n// « de combien la batterie a-t-elle besoin », pas a « quand est-ce bon marche ».\n// C'est Home Assistant qui detient le calendrier tarifaire et qui ne publie une\n// valeur non nulle que pendant une fenetre creuse. Sans ce filtrage en amont, un\n// plancher a 85 % fuirait dans la journee et mordrait sur\n// `BATTERY_CLOUD_LOCKS.js` — `eveningSocStartFloorPct = max(eveningSocStartMin,\n// balanceRequiredSocPct)` — au point de bloquer la charge du vehicule le soir.\nconst manualHcFloorPct = (() => {\n const v = num(input.manual_hc_floor_percent, 0);\n return Number.isFinite(v) && v > socFloorPct && v <= 100 ? v : 0;\n})();\n\n// Sans l'une de ces entrées, le bilan ne peut rien affirmer. Il le dit, et les\n// contrôleurs retombent sur leurs règles historiques.\nconst missing = [];\nif (!consumptionP50) missing.push('consumption_p50');\nif (!consumptionP80) missing.push('consumption_p80');\nif (!horizon || !horizon.length) missing.push('horizon');\nif (!Number.isFinite(socPct)) missing.push('soc_percent');\nif (!Number.isFinite(usableCapacityKwh) || usableCapacityKwh <= 0) missing.push('usable_capacity_kwh');\n\nif (missing.length) {\n return [{\n json: {\n usable: false,\n missing,\n reason: `Bilan indisponible : ${missing.join(', ')}. Les contrôleurs doivent conserver leurs règles.`,\n cfg: CFG,\n },\n }];\n}\n\n// ---------------------------------------------------------------------\n// Bilan heure par heure, calculé DEUX FOIS.\n//\n// Pourquoi deux niveaux de prudence et pas un seul : la décision « faut-il du\n// réseau maintenant ? » est réévaluée à chaque cycle et reste réversible. Se\n// tromper par optimisme ne coûte qu'un démarrage réseau une heure plus tard,\n// le plancher dur garantissant la batterie ; se tromper par excès de prudence\n// coûte un import réseau bien réel, immédiatement.\n//\n// Vérifié sur la matinée du 24/08/2026 : avec le profil prudent, le bilan\n// exigerait 48 % de SOC pour franchir la matinée, alors que la consommation\n// réelle de ce jour-là n'a demandé que 0,8 kWh — le seuil fixe de 30 % aurait\n// été MOINS coûteux. Un bilan « intelligent » plus gourmand que le seuil bête\n// qu'il remplace n'a aucun intérêt.\n//\n// - profil TYPIQUE (p50) → décision réseau immédiate, réévaluée en continu.\n// - profil PRUDENT (p80) → planification : cible de nuit, dimensionnement\n// du stockage, choix qui engagent plusieurs heures.\n// ---------------------------------------------------------------------\n// ---------------------------------------------------------------------\n// LA NUIT PREVUE PAR LA SOIREE (28/09/2026, demande du proprietaire)\n//\n// Le profil p50 est une mediane HORAIRE : il ignore la soiree qui vient de se\n// passer. Or sur 24 nuits, la maison consomme d'autant plus de 21:30 a 09:00\n// que la soiree 18:00-21:30 a ete chargee (four) : nuit = 6,98 + 0,74 x soiree,\n// r2 = 0,40. Le service d'historisation publie le FACTEUR nuit prevue / nuit\n// moyenne (sensor.evcs_nuit_maison_prevue_kwh) ; on multiplie le profil par ce\n// facteur sur la seule fenetre 21:30 -> 09:00, au prorata des heures a cheval.\n// Le 28/09 : soiree 6,27 kWh -> facteur 1,09.\n//\n// Un FACTEUR et non des kWh : le profil vient des compteurs d'energie, la\n// regression des puissances integrees ; le rapport annule l'ecart de methode.\n// Dans les deux sens pour le profil TYPIQUE (c'est un estimateur, une soiree\n// legere allege la nuit) ; le PRUDENT ne descend jamais : max(p80, p50 recale).\n// Filet : facteur borne a [0,75 ; 1,45], les extremes des 24 nuits observees\n// (0,77 et 1,41 fois la moyenne) — hors de la, c'est une mesure fausse, pas une\n// nuit. Facteur absent, perime (> 30 min) ou hors fenetre : profil inchange.\nconst NIGHT_FACTOR_MIN = 0.75;\nconst NIGHT_FACTOR_MAX = 1.45;\nconst nowMsNight = num(input.nowMs, Date.now());\nconst nightStartMs = num(input.night_house_start_ms, NaN);\nconst nightEndMs = num(input.night_house_end_ms, NaN);\nconst nightFactorRaw = num(input.night_house_factor, NaN);\nconst nightComputedMs = num(input.night_house_computed_ms, NaN);\nconst nightModelActive =\n Number.isFinite(nightFactorRaw) && nightFactorRaw > 0 &&\n Number.isFinite(nightStartMs) && Number.isFinite(nightEndMs) && nightEndMs > nightStartMs &&\n nowMsNight >= nightStartMs && nowMsNight < nightEndMs &&\n Number.isFinite(nightComputedMs) && nowMsNight - nightComputedMs <= 30 * 60000;\nconst nightFactor = nightModelActive\n ? Math.min(NIGHT_FACTOR_MAX, Math.max(NIGHT_FACTOR_MIN, nightFactorRaw))\n : 1;\nfunction nightShare(startMs) {\n const debut = num(startMs, NaN);\n if (!nightModelActive || !Number.isFinite(debut)) return 0;\n const a = Math.max(debut, nightStartMs), b = Math.min(debut + 3600000, nightEndMs);\n return b > a ? (b - a) / 3600000 : 0;\n}\nfunction nightScaled(profile, slot) {\n const h = num(slot.hour, 0);\n return (profile[h] ?? 0) * (1 + (nightFactor - 1) * nightShare(slot.startMs));\n}\n\n// =====================================================================\n// JOUR FAIBLE : CE QUE LA JOURNEE DIT DEJA (07/10/2026, proprietaire)\n// =====================================================================\n// Le 07/10 la HC de midi visait 44 % (« arriver a 01:24 au-dessus de 22 % »),\n// et EDF a quand meme ete ferme en HEURES PLEINES a 18:03 (~8 kWh HP). Deux\n// erreurs du calcul, corrigees ici :\n// 1. La reprise du chauffe-eau de 16 h a 20 h (douche de 20 h, 2,1 kWh mesures\n// le 07/10) n'etait pas comptee : les jours faibles (decision du bilan),\n// `ecs_reprise_soir_kwh` est ajoute a la consommation de 16 h a 20 h du jour.\n// 2. La prevision de l'apres-midi n'etait pas recalee sur la matinee : VRM\n// annoncait ~9 kWh de 14 h a 19 h, il en est venu ~5, et la matinee le\n// disait deja (3,1 kWh produits a 12:36 pour ~5 prevus). Quand le reel du\n// jour est EN RETARD sur la prevision des heures ecoulees (au moins 2 kWh\n// prevus, production complete), les heures restantes du jour sont\n// multipliees par le meme rapport, borne a [0,4 ; 1]. Jamais a la hausse :\n// une matinee meilleure que prevu ne promet rien pour l'apres-midi.\nconst ECS_SOIR_H0 = 16;\nconst ECS_SOIR_H1 = 20;\nconst nowMsSlots = num(input.nowMs, Date.now());\nconst jourParis = (ms) => new Date(ms).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' });\nconst aujourdhuiParis = jourParis(nowMsSlots);\nfunction slotIsToday(slot) {\n return Number.isFinite(num(slot.startMs, NaN)) && jourParis(num(slot.startMs, 0)) === aujourdhuiParis;\n}\nconst ecsNuitAujourdhui = typeof input.ecs_nuit_jour === 'string'\n && input.ecs_nuit_jour.startsWith(aujourdhuiParis + '|on');\nconst ecsRepriseSoirKwh = ecsNuitAujourdhui ? Math.max(0, num(input.ecs_reprise_soir_kwh, 0)) : 0;\nlet prodRecal = { factor: 1, active: false };\n{\n const prevu = typeof input.forecast_today_csv === 'string' ? input.forecast_today_csv.split(',').map(Number) : [];\n const reel = num(input.production_jour_kwh, NaN);\n if (prevu.length === 24 && prevu.every(Number.isFinite) && Number.isFinite(reel)\n && input.production_jour_statut === 'complet') {\n const p = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit', hour12: false })\n .format(new Date(nowMsSlots)).split(':').map(Number);\n let ecoule = 0;\n for (let h = 0; h < 24; h++) ecoule += h < p[0] ? prevu[h] : (h === p[0] ? prevu[h] * p[1] / 60 : 0);\n if (ecoule >= 2) {\n const r = reel / ecoule;\n prodRecal = { factor: round2(Math.max(0.4, Math.min(1, r))), active: r < 1, ratio: round2(r),\n realKwh: round2(reel), expectedSoFarKwh: round2(ecoule) };\n }\n }\n}\nconst prodRecalFactor = prodRecal.factor;\n\nfunction buildSlots(consumption, prudent = false) {\n return horizon.slice(0, CFG.SEARCH_HOURS).map(slot => {\n const hour = num(slot.hour, 0);\n const aujourdhui = slotIsToday(slot);\n const productionKwh = Math.max(0, num(slot.expectedKwh, 0)) * (aujourdhui ? prodRecalFactor : 1);\n const consumptionKwh = (prudent\n ? Math.max(consumption[hour] ?? 0, nightScaled(consumptionP50, slot))\n : nightScaled(consumption, slot))\n + (aujourdhui && hour >= ECS_SOIR_H0 && hour < ECS_SOIR_H1 ? ecsRepriseSoirKwh / (ECS_SOIR_H1 - ECS_SOIR_H0) : 0);\n return {\n startMs: slot.startMs,\n hour,\n productionKwh: round2(productionKwh),\n consumptionKwh: round2(consumptionKwh),\n netKwh: round2(productionKwh - consumptionKwh),\n shapeQuality: slot.shapeQuality || 'inconnue',\n };\n });\n}\n\n// =====================================================================\n// COURBE D'ENERGIE DE LA BATTERIE — MESUREE, PAS SUPPOSEE (03/09/2026)\n// =====================================================================\n// La conversion energie <-> points de SOC etait une CONSTANTE\n// (`usable_capacity_kwh`, 15 kWh) la ou la realite est une COURBE.\n//\n// Mesure : 576 paliers de SOC sur 8 jours (`tools/mesure-courbe-batterie.mjs`).\n// Pour chaque palier ou le SOC reste a une valeur puis DESCEND d'un point, on\n// integre la puissance batterie — en ecartant les paliers ou la batterie a\n// charge ou ou le reseau a apporte quoi que ce soit.\n//\n// tranche paliers kWh/point\n// 21-31 88 0,097 <- effondrement pres du genou\n// 35-44 78 0,171\n// 45-54 76 0,169\n// 55-64 85 0,163\n// 65-74 80 0,171\n// 75-84 81 0,170\n// 85-94 62 0,169\n//\n// Plate a 0,169 de 33 a 93, genou net a 32-33, puis 0,097 : sous le genou, un\n// point de SOC vaut 57 % de ce qu'il vaut au-dessus. Coherent avec §0 (un\n// module en panne sur huit, un autre qui faiblit) et avec l'effondrement de la\n// jauge deja signale au §28 — ceci lui donne sa courbe.\n//\n// CE QUE LA CONSTANTE FAISAIT : 15 kWh se situe ENTRE les deux pentes, donc\n// l'erreur CHANGE DE SIGNE selon l'energie a couvrir —\n// 1 kWh a couvrir : SOC requis 28,7 % annonce contre 32,3 % reel (-3,6)\n// 4,3 kWh : 50,7 contre 52,1 (-1,4)\n// 8 kWh : 75,3 contre 74,0 (+1,3)\n// 11,7 kWh : 99,7 contre 95,7 (+4,1)\n// Trop BAS en fin de nuit (la direction dangereuse), trop HAUT en debut.\n//\n// Energie utile totale de 100 a 22 % : 12,39 kWh mesures, contre 11,70 vus par\n// le bilan et 13,65 par INPUT_PARSE. Les deux constantes existantes etaient\n// fausses, dans des sens opposes.\n//\n// ⚠️ A RE-MESURER apres toute evolution du parc (extension prevue cet hiver) :\n// `node tools/mesure-courbe-batterie.mjs 8`, puis mettre a jour ces trois\n// nombres. Ce ne sont pas des reglages, ce sont des resultats de mesure.\n//\n// ⚠️ CES PENTES ONT ETE MESUREES SUR SEPT MODULES. Retirer ou ajouter un module\n// divise ou multiplie la capacite sans changer la FORME de la courbe : on les\n// exprime donc par module, et on les multiplie par le nombre reellement en\n// service. Ce nombre se lit dans un helper — `input_number\n// .evcs_balance_modules_en_service` — et non dans le code : le parc a change\n// deux fois en une semaine et changera encore cet hiver. Helper absent ou\n// illisible => 7, la valeur sur laquelle la mesure a ete faite, donc aucun\n// changement de comportement tant que personne ne l'ecrit.\nconst MODULES_MESURE = 7;\nconst modulesEnService = (() => {\n const n = num(input.modules_en_service, MODULES_MESURE);\n return Number.isFinite(n) && n >= 1 && n <= 24 ? n : MODULES_MESURE;\n})();\nconst echelleParc = modulesEnService / MODULES_MESURE;\n\n// 20/09/2026 — RE-MESURE sur le parc ASSAINI (13 equivalents US2000 : 4 US5000\n// + 5 US2000C, cellules a 3-6 mV). `tools/mesure-courbe-batterie.mjs 8` :\n// 0,30 kWh/point PLAT de 25 a 97 %, PLUS DE GENOU. Le genou a 33 % etait bien\n// la signature de la cellule 12 morte du pack ancien (§50) — il a disparu avec\n// elle, exactement comme le predisait la note de prudence du 03/09. La reserve\n// reelle a quasiment double (12,4 -> ~23 kWh utiles de 100 a 22 %).\n// Base normalisee a 7 modules (MODULES_MESURE) : 0,30 x 7/13 = 0,1615 par point.\n// x echelleParc(13/7) = 0,30 au parc actuel. Pente unique => plus de genou.\n// A REMESURER apres toute evolution du parc (2e cable console en commande pour\n// surveiller aussi les cellules du pack 1 US5000).\nconst BATTERY_CURVE = {\n kneePct: 0,\n kwhPerPointAbove: 0.1615 * echelleParc,\n kwhPerPointBelow: 0.1615 * echelleParc,\n};\n\n// Energie que la batterie peut delivrer en descendant de socHi a socLo.\nfunction energyBetweenSoc(socLo, socHi) {\n if (!Number.isFinite(socLo) || !Number.isFinite(socHi) || socHi <= socLo) return 0;\n const knee = BATTERY_CURVE.kneePct;\n const lowSpan = Math.max(0, Math.min(socHi, knee) - Math.min(socLo, knee));\n const highSpan = Math.max(0, Math.max(socHi, knee) - Math.max(socLo, knee));\n return lowSpan * BATTERY_CURVE.kwhPerPointBelow\n + highSpan * BATTERY_CURVE.kwhPerPointAbove;\n}\n\n// SOC necessaire pour delivrer energyKwh en descendant jusqu'a floorPct.\n// NON ecrete : au-dela de 100 la valeur reste le signal de sous-dimensionnement\n// (cf. §26). L'extrapolation au-dessus de 100 utilise la pente haute.\nfunction socForEnergy(energyKwh, floorPct) {\n if (!Number.isFinite(energyKwh) || energyKwh <= 0) return floorPct;\n const knee = Math.max(floorPct, BATTERY_CURVE.kneePct);\n const belowKwh = (knee - floorPct) * BATTERY_CURVE.kwhPerPointBelow;\n if (energyKwh <= belowKwh) {\n return floorPct + energyKwh / BATTERY_CURVE.kwhPerPointBelow;\n }\n return knee + (energyKwh - belowKwh) / BATTERY_CURVE.kwhPerPointAbove;\n}\n\n// SOC atteint en ajoutant addKwh a partir de socPct.\nfunction socAfterEnergy(socPct, addKwh) {\n if (!Number.isFinite(addKwh) || addKwh <= 0) return socPct;\n return socForEnergy(energyBetweenSoc(0, socPct) + addKwh, 0);\n}\n\n// =====================================================\n// TENIR JUSQU'A LA PROCHAINE FENETRE HC (28/09/2026)\n// =====================================================\n// Strategie du proprietaire, apres la nuit du 26-27/09 : P12 avait charge la\n// JEEP sur la batterie (72 -> 44 %) parce qu'il restait assez pour atteindre le\n// solaire du matin — alors que ce meme bilan annoncait deja « prochaine\n// traversee 17,35 kWh des 18 h -> SOC requis 79,8 % ». La journee (22 kWh reels)\n// n'a pas pu reconstituer la reserve. Sa regle : une voiture branchee se charge\n// en heures creuses sur le reseau, et la batterie doit TENIR JUSQU'A LA\n// PROCHAINE FENETRE HC ; si elle ne le peut pas, la fenetre HC de midi la\n// complete.\n//\n// Traduction : PENDANT une fenetre HC, l'exigence vaut au moins le SOC qu'il\n// faut avoir A LA FIN de cette fenetre pour atteindre le DEBUT de la suivante\n// sans passer sous le plancher, production prevue comprise (tranches horaires\n// ci-dessus, heure par heure, profil de conso du profil calcule).\n//\n// ⚠️ LE CALENDRIER RESTE DANS HOME ASSISTANT (§73) : le bilan ne recoit que deux\n// instants, la fin de la fenetre en cours (absente hors fenetre) et le debut de\n// la suivante. Hors fenetre HC, rien ne change.\n//\n// ⚠️ INSERE DANS coverage(), COMME LE PLANCHER MANUEL : la cible (required_soc,\n// qui ENGAGE la session et releve le plancher de P12) et le manque en kWh (qui\n// FINANCE la consigne) bougent ensemble par construction — jamais de session\n// muette (§73). Sens unique : il ne peut que RELEVER l'exigence.\nconst hcWindowEndMs = num(input.hc_window_end_ms, NaN);\nconst nextHcStartMs = num(input.next_hc_start_ms, NaN);\nconst nextNightHcStartMs = num(input.next_night_hc_start_ms, NaN);\nconst hcReachActive = Number.isFinite(hcWindowEndMs) && Number.isFinite(nextHcStartMs)\n && nextHcStartMs > hcWindowEndMs;\n// DEUX HORIZONS, DEUX USAGES (decision du proprietaire, 28/09) :\n// - recharger la batterie au reseau : tenir jusqu'a la PROCHAINE fenetre HC\n// (la nuit : midi ; a midi : 01:24). Midi complete si besoin, et la matinee\n// garde de la place pour le solaire. -> required_soc (engage ET finance).\n// - laisser la voiture puiser dans la batterie la nuit (P12) : seulement si la\n// batterie tient ensuite jusqu'a la NUIT SUIVANTE (01:24), sans compter sur\n// midi. Sinon la voiture se charge sur le reseau en HC. -> required_soc_prudent,\n// qui n'est lu que comme plancher de P12 (et par le conseiller, pour info).\nconst hcNightReachActive = Number.isFinite(hcWindowEndMs) && Number.isFinite(nextNightHcStartMs)\n && nextNightHcStartMs > hcWindowEndMs;\n\n// Energie a avoir en reserve a la fin de la fenetre : parcours A REBOURS des\n// tranches comprises entre les deux instants (au prorata de leur recouvrement).\n// Un surplus ne rembourse qu'un deficit POSTERIEUR — d'ou le sens du parcours.\nfunction hcReachFloorPct(slots, untilMs = nextHcStartMs, fromMs = hcWindowEndMs) {\n if (!Number.isFinite(fromMs) || !Number.isFinite(untilMs) || untilMs <= fromMs) return 0;\n let needKwh = 0;\n for (let i = slots.length - 1; i >= 0; i--) {\n const s = slots[i];\n const debut = num(s.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, fromMs);\n const b = Math.min(debut + 3600000, untilMs);\n if (b <= a) continue;\n needKwh = Math.max(0, needKwh - s.netKwh * (b - a) / 3600000);\n }\n return needKwh > 0 ? socForEnergy(needKwh, socFloorPct) : 0;\n}\n\nfunction coverage(slots, hcFloorPct = 0) {\n const crossingIndex = slots.findIndex(s => s.netKwh > 0);\n const found = crossingIndex >= 0;\n const deficitSlots = found ? slots.slice(0, crossingIndex) : slots;\n const energyToCoverKwh = deficitSlots.reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n // Le plancher manuel ne peut que RELEVER l'exigence, jamais l'abaisser — meme\n // sens unique que les cablages de §38, §39 et §40 : le bilan ne pousse jamais\n // dans la direction « couvre moins bien ».\n const requiredSocRawPct = Math.max(\n socForEnergy(energyToCoverKwh, socFloorPct),\n manualHcFloorPct,\n hcFloorPct\n );\n const socNeededPct = requiredSocRawPct - socFloorPct;\n const requiredSocPct = Math.min(100, requiredSocRawPct);\n const socMarginPct = socPct - requiredSocPct;\n return {\n crossingIndex, found, deficitSlots, slots,\n energyToCoverKwh, socNeededPct, requiredSocPct, socMarginPct,\n gridNeeded: socMarginPct < 0,\n gridEnergyNeededKwh: socMarginPct < 0 ? energyBetweenSoc(socPct, requiredSocPct) : 0,\n };\n}\n\nconst slotsP50 = buildSlots(consumptionP50);\nconst slotsP80 = buildSlots(consumptionP80, true);\nconst hcFloorReachPct = hcReachFloorPct(slotsP50, nextHcStartMs);\nconst hcFloorNightPrudentPct = hcNightReachActive ? hcReachFloorPct(slotsP80, nextNightHcStartMs) : 0;\nconst hcFloorPrudentReachPct = Math.max(hcReachFloorPct(slotsP80, nextHcStartMs), hcFloorNightPrudentPct);\n// ---------------------------------------------------------------------\n// JOUR FAIBLE : LA HC DE MIDI CHARGE AU MAXIMUM (08/10/2026, proprietaire)\n//\n// Le 07/10, « tenir jusqu'a 01:24 » visait 44 % (profil habituel) : la soiree\n// a ete plus lourde, EDF s'est ferme en heures pleines a 18:03. Un jour faible\n// (decision du bilan apres 07:24, `ecs_nuit_jour` = on), l'apres-midi ne\n// remplira pas la batterie : un kWh pris a midi remplace un kWh d'heures pleines\n// du soir, ou au pire un kWh d'heures creuses de la nuit (meme prix, ~10 % de\n// pertes). Viser juste ne protege de rien.\n//\n// La cible devient 100 % pendant la fenetre de MIDI (la prochaine HC est celle\n// de la nuit) : ce qui est REELLEMENT charge est fixe par la physique, pas par\n// un nombre — l'ACM regle le courant sur ce que l'abonnement laisse libre une\n// fois la maison (et la voiture) servie, plus le solaire, plafonne par les\n// 140 A des deux MultiPlus. 65, 70 ou 80 % selon la cuisine et le soleil.\n// La nuit, la regle « tenir jusqu'a midi » est inchangee.\n// ---------------------------------------------------------------------\nconst hcMiddayWindow = hcReachActive && hcNightReachActive && nextHcStartMs === nextNightHcStartMs;\nconst hcMaxCharge = hcMiddayWindow && ecsNuitAujourdhui;\n// ---------------------------------------------------------------------\n// LA NUIT PREPARE AUSSI LE SOIR QUAND LA JOURNEE S'ANNONCE FAIBLE (08/10/2026)\n//\n// Nuit du 07-08/10 : session engagee, mais la cible « tenir jusqu'a midi »\n// valait 30,7 % alors que le meme bilan annoncait 99,3 % requis pour la\n// traversee de 18 h. Les heures creuses de la nuit coutent le meme prix que\n// celles de midi, et midi est borne par la maison (cuisine, voiture, BOOST).\n// Pendant la fenetre de NUIT : plus petit SOC de fin de fenetre qui, avec le\n// profil habituel (production ecretee a 100 %), amene la batterie au SOC requis\n// au debut de la traversee du soir. Si c'est hors d'atteinte, celui qui en\n// approche le plus SANS gaspiller (au-dela, le solaire du jour serait ecrete).\n// Journee suffisante -> rien ne change (le plus petit SOC est le plancher\n// « tenir jusqu'a midi »).\n// ---------------------------------------------------------------------\nconst hcNightWindow = hcReachActive && !hcMiddayWindow;\n// ⚠️ CORRIGE LE 09/10 : la premiere version (08/10) visait « le debut de la\n// traversee du soir » = la premiere fenetre de deficit apres le premier surplus.\n// Nuit du 08-09/10 (VRM 14,7 kWh) : un surplus a 11 h puis un creux a 12 h ->\n// « traversee » a 12 h, 22 % requis, regle muette alors que la journee etait\n// faible. L'objectif est maintenant sans ambiguite : de la fin de la fenetre de\n// nuit au debut de la NUIT SUIVANTE (01:24), la batterie ne doit JAMAIS passer\n// sous le plancher, au profil habituel, solaire ecrete a 100 %. La HC de midi\n// n'est pas comptee (prudence : elle est bornee par la maison, et ce que la nuit\n// charge au meme prix ne lui coute rien).\nlet nightEvening = { active: false };\nif (hcNightWindow && Number.isFinite(nextNightHcStartMs) && nextNightHcStartMs > hcWindowEndMs) {\n const capKwh = energyBetweenSoc(socFloorPct, 100);\n // Energie minimale au-dessus du plancher sur le parcours, depuis e0 en fin de nuit.\n const minAuDessus = (e0) => {\n let e = e0, mini = e0;\n for (const sl of slotsP50) {\n const debut = num(sl.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, hcWindowEndMs);\n const b = Math.min(debut + 3600000, nextNightHcStartMs);\n if (b <= a) continue;\n e = Math.min(capKwh, e + sl.netKwh * (b - a) / 3600000);\n if (e < mini) mini = e;\n }\n return mini;\n };\n const base = Math.max(socFloorPct, hcFloorReachPct);\n const eBase = energyBetweenSoc(socFloorPct, base);\n // Hors d'atteinte meme pleine : on vise le mieux possible, sans gaspiller.\n const objectif = Math.min(0, minAuDessus(capKwh)) - 0.01;\n let e0 = eBase;\n if (minAuDessus(eBase) < objectif) {\n let lo = eBase, hi = capKwh;\n for (let k = 0; k < 40; k++) { const m = (lo + hi) / 2; if (minAuDessus(m) >= objectif) hi = m; else lo = m; }\n e0 = hi;\n }\n const floor = socAfterEnergy(socFloorPct, e0);\n nightEvening = {\n active: floor > base + 0.05,\n floorPct: round1(Math.min(100, floor)),\n // Sans relever : energie qui manquerait sous le plancher avant la nuit suivante.\n shortfallKwh: round2(Math.max(0, -minAuDessus(eBase))),\n untilMs: nextNightHcStartMs,\n };\n}\nconst hcFloorTypicalPct = hcMaxCharge ? 100\n : (nightEvening.active ? Math.max(hcFloorReachPct, nightEvening.floorPct) : hcFloorReachPct);\nconst hcFloorPrudentPct = hcMaxCharge ? 100 : hcFloorPrudentReachPct;\nconst typical = coverage(slotsP50, hcFloorTypicalPct);\nconst prudent = coverage(slotsP80, hcFloorPrudentPct);\n\n// ---------------------------------------------------------------------\n// CE QUE LA SESSION DE NUIT ACHETERA AU RESEAU (29/09/2026)\n//\n// Incident du 28/09 : la prevision annoncait « 0,14 kWh a prendre sur EDF » et\n// le reseau en a fourni 4,6. Les deux etaient justes, mais le premier est le\n// MANQUE de la batterie : une fois engagee, la session tient la batterie et la\n// maison passe TOUTE la fenetre HC de nuit sur le reseau (§40). Le chiffre qui\n// repond a « combien vais-je acheter » est donc la maison non couverte par le\n// solaire sur la fenetre (profil habituel, recale sur la soiree), depuis\n// maintenant si la fenetre est ouverte. Le manque batterie s'y ajoute, a part.\n// ---------------------------------------------------------------------\nconst nightHcStartMs = num(input.night_hc_start_ms, NaN);\nconst nightHcEndMs = num(input.night_hc_end_ms, NaN);\nconst nightHcActive = Number.isFinite(nightHcStartMs) && Number.isFinite(nightHcEndMs)\n && nightHcEndMs > nightHcStartMs;\nlet nightHcHouseKwh = null;\nif (nightHcActive) {\n const depuis = Math.max(nightHcStartMs, num(input.nowMs, Date.now()));\n nightHcHouseKwh = 0;\n for (const sl of slotsP50) {\n const debut = num(sl.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, depuis), b = Math.min(debut + 3600000, nightHcEndMs);\n if (b > a) nightHcHouseKwh += Math.max(0, -sl.netKwh) * (b - a) / 3600000;\n }\n}\n\n// ---------------------------------------------------------------------\n// BUDGET VOITURE DE LA NUIT (30/09/2026, decisions du proprietaire)\n//\n// « Definir combien la voiture peut prendre sans utiliser EDF, et brider la\n// charge a cette valeur ou autoriser une charge complete avec EDF. » Choix du\n// 30/09 : profil PRUDENT (p80) et plancher du bilan (22 %) ; par defaut « sans\n// EDF » ; en charge complete, le budget batterie d'abord, EDF pour le reste.\n//\n// Budget = la plus petite reserve au-dessus du plancher que la batterie\n// connaitra entre l'ouverture de la fenetre HC de nuit (ou maintenant si elle\n// est ouverte) et le debut de la fenetre de nuit SUIVANTE, sur le profil\n// prudent. Retirer X kWh a l'ouverture abaisse toute la trajectoire de X :\n// le budget est donc le creux de la trajectoire. L'horizon jusqu'a la nuit\n// suivante applique « la batterie tient jusqu'a la prochaine HC » ; la\n// batterie pleine ecrete la recharge (on ne stocke pas au-dela de 100 %).\n// Le 30/09 01:25 : SOC 57, prudent 44,7 -> ~3,7 kWh.\n// ---------------------------------------------------------------------\nlet nightVehicleBudgetKwh = null;\nif (nightHcActive && Number.isFinite(socPct)) {\n const maintenant = num(input.nowMs, Date.now());\n const depuis = Math.max(nightHcStartMs, maintenant);\n const jusqua = nightHcStartMs + 24 * 3600000;\n const plein = energyBetweenSoc(socFloorPct, 100);\n let reserve = socPct >= socFloorPct\n ? energyBetweenSoc(socFloorPct, socPct) : -energyBetweenSoc(socPct, socFloorPct);\n let creux = maintenant >= depuis ? reserve : Infinity;\n for (const sl of slotsP80) {\n const debut = num(sl.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, maintenant), b = Math.min(debut + 3600000, jusqua);\n if (b <= a) continue;\n // La tranche qui contient l'ouverture est coupee en deux : le creux ne se\n // lit qu'a partir de l'ouverture.\n const coupes = depuis > a && depuis < b ? [a, depuis, b] : [a, b];\n for (let i = 0; i + 1 < coupes.length; i++) {\n reserve = Math.min(plein, reserve + sl.netKwh * (coupes[i + 1] - coupes[i]) / 3600000);\n if (coupes[i + 1] >= depuis) creux = Math.min(creux, reserve);\n }\n }\n // BORNE PAR LE PLANCHER PRUDENT (04/10/2026). Nuit du 03-04/10 : SOC 47,\n // prudent 43,6 -> ~1,0 kWh de marge, mais le creux a donne 1,41 kWh. La\n // voiture a pris toute la marge, la batterie a touche la cible du matin a\n // 02:57 et la reprise reseau a ferme EDF. Le budget ne peut donc jamais\n // depasser l'energie au-dessus du SOC requis PRUDENT, celui-la meme que la\n // voiture n'a pas le droit de franchir en assistance batterie (P12) : les\n // deux criteres sont desormais les memes par construction.\n const margePrudente = Number.isFinite(prudent.requiredSocPct)\n ? (socPct > prudent.requiredSocPct ? energyBetweenSoc(prudent.requiredSocPct, socPct) : 0)\n : 0;\n nightVehicleBudgetKwh = Number.isFinite(creux) ? Math.max(0, Math.min(creux, margePrudente)) : null;\n}\n\n// ---------------------------------------------------------------------\n// VEHICULE BRANCHE EN HC AVEC UN DEPART AVANT LA PROCHAINE HC (28/09/2026)\n//\n// Regle du proprietaire : « JEEP branchee engage le reseau en HC midi si\n// l'analyse de production le justifie ». La JEEP repart a 15:30 certains jours\n// (calendrier dans HA) : ce qu'elle n'a pas pris avant, elle ne le prendra pas\n// en HC la nuit. Ailleurs, elle reste branchee et la HC de 01:24 la finit au\n// meme tarif — fermer le contacteur a midi n'y gagnerait rien et etoufferait le\n// solaire (installation sans injection).\n//\n// Ce que le solaire peut donner a la voiture avant son depart : le bilan net\n// prevu (production - maison) d'ici le depart, profil p50 comme required_soc.\n// Une batterie SOUS son plancher « tenir jusqu'a la prochaine HC » (evalue AU\n// DEPART) est d'abord remontee par ce surplus.\n// ⚠️ 06/10/2026 (proprietaire : « bascule automatique sur force_grid_now sauf si\n// le solaire donne la charge ce midi ») : la batterie au-dessus de ce plancher\n// n'est PLUS comptee. Le controleur ne donne pas la batterie a la voiture en\n// journee (demarrage sur excedent, §36) sauf par l'assistance batterie, a partir\n// de VEHICLE_BATTERY_ASSIST_SOC_PCT : seule la part au-dessus est comptee. Le\n// 06/10 a 13:12, 4,08 kWh de batterie comptes donnaient « solaire suffisant »\n// (5,86 pour 4,4) alors que le controleur attendait un excedent qui n'est pas\n// venu : la JEEP serait partie a 15:30 sans charge.\n// Le reseau est justifie quand le besoin de la voiture, borne par ce que son\n// chargeur peut absorber avant le depart, depasse ce solaire.\n//\n// Besoin = (100 - niveau saisi) % x capacite MESUREE au compteur de la borne,\n// moins ce que la borne a deja delivre depuis la saisie. Aucun capteur ne donne\n// le niveau de la voiture : sans saisie valable, la regle se tait.\n//\n// Vaut pour les TROIS vehicules (JEEP, VW, moto ZERO) : capacite, puissance de\n// charge et calendrier viennent de HA pour le vehicule selectionne.\n// La puissance du chargeur EMBARQUE (plateau mesure : JEEP 3,66 kW, VW\n// 3,68 kW) borne ce que le vehicule peut prendre avant son depart ; inconnue\n// (0), on ne borne pas — l'erreur va alors vers « reseau justifie », c'est-a-\n// dire vers le vehicule charge, jamais vers la batterie videe.\n// Seuil de 0,5 kWh : en dessous une session ne se justifie pas — une fermeture\n// du contacteur coute 50 a 150 Wh de transitoire (§57), on exige un gain d'au\n// moins trois fois ce cout.\nconst VEHICLE_GRID_MIN_SHORTFALL_KWH = 0.5;\n// = batteryAssistSolarStartSocMin de BATTERY_CLOUD_LOCKS.js (verifie par un\n// test qui lit le controleur : les deux valeurs ne peuvent pas diverger).\nconst VEHICLE_BATTERY_ASSIST_SOC_PCT = 90;\nconst vehicleChargerKw = num(input.vehicle_charger_kw, NaN);\nconst nowMsBalance = num(input.nowMs, Date.now());\nconst vehicleConnected = input.vehicle_connected === true || input.vehicle_connected === 'true';\nconst vehicleDepartureMs = num(input.vehicle_departure_ms, NaN);\nconst vehicleLevelPct = num(input.vehicle_level_pct, NaN);\nconst vehicleLevelValid = input.vehicle_level_valid === true || input.vehicle_level_valid === 'true';\nconst vehicleCapacityKwh = num(input.vehicle_capacity_kwh, NaN);\nconst vehicleDeliveredKwh = Math.max(0, num(input.vehicle_delivered_since_level_kwh, 0));\nconst vehicleNeedKwh =\n vehicleLevelValid && Number.isFinite(vehicleLevelPct) && vehicleLevelPct >= 0 && vehicleLevelPct <= 100\n && Number.isFinite(vehicleCapacityKwh) && vehicleCapacityKwh > 0\n ? Math.max(0, (100 - vehicleLevelPct) / 100 * vehicleCapacityKwh - vehicleDeliveredKwh)\n : NaN;\nconst vehicleRuleActive =\n hcReachActive && vehicleConnected && Number.isFinite(vehicleNeedKwh)\n && Number.isFinite(vehicleDepartureMs) && vehicleDepartureMs > nowMsBalance\n && vehicleDepartureMs < nextHcStartMs;\nlet vehicleSolarKwh = null;\nlet vehicleReachableKwh = null;\nlet vehicleShortfallKwh = null;\nlet vehicleFloorAtDeparturePct = null;\nif (vehicleRuleActive) {\n vehicleFloorAtDeparturePct = hcReachFloorPct(slotsP50, nextHcStartMs, vehicleDepartureMs);\n let netKwh = 0;\n for (const sl of slotsP50) {\n const debut = num(sl.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, nowMsBalance);\n const b = Math.min(debut + 3600000, vehicleDepartureMs);\n if (b > a) netKwh += sl.netKwh * (b - a) / 3600000;\n }\n // Difference SIGNEE : une batterie SOUS son plancher doit d'abord etre\n // remontee par ce meme surplus avant que la voiture n'en voie un kWh.\n const plancherDepart = Math.max(socFloorPct, vehicleFloorAtDeparturePct);\n const manqueBatterieKwh = socPct >= plancherDepart ? 0 : energyBetweenSoc(socPct, plancherDepart);\n const seuilAssist = Math.max(plancherDepart, VEHICLE_BATTERY_ASSIST_SOC_PCT);\n const batterieAssistKwh = socPct > seuilAssist ? energyBetweenSoc(seuilAssist, socPct) : 0;\n vehicleSolarKwh = Math.max(0, netKwh - manqueBatterieKwh) + batterieAssistKwh;\n vehicleReachableKwh = Number.isFinite(vehicleChargerKw) && vehicleChargerKw > 0\n ? Math.min(vehicleNeedKwh, vehicleChargerKw * (vehicleDepartureMs - nowMsBalance) / 3600000)\n : vehicleNeedKwh;\n vehicleShortfallKwh = Math.max(0, vehicleReachableKwh - vehicleSolarKwh);\n}\nconst vehicleGridNeeded = vehicleRuleActive && vehicleShortfallKwh > VEHICLE_GRID_MIN_SHORTFALL_KWH;\n\n// ---------------------------------------------------------------------\n// PROCHAINE FENÊTRE DE DÉFICIT (ajout du 24/08/2026)\n//\n// Le calcul ci-dessus répond à « combien d'énergie jusqu'à ce que le solaire\n// prenne le relais ». À 15 h, le solaire a déjà pris le relais : la réponse est\n// zéro et le SOC requis retombe sur le plancher. C'est exact mais inutile à\n// cette heure-là, car le déficit qui approche est celui de la nuit.\n//\n// On cherche donc AUSSI la prochaine fenêtre de déficit après le surplus en\n// cours : son heure de début, son énergie, et le SOC qu'il faudra avoir atteint\n// à ce moment-là. C'est cette grandeur qui remplace la cible de nuit fixe, et\n// qui dit combien de surplus il faut avoir stocké avant le coucher du soleil.\n//\n// Profil PRUDENT : contrairement à la décision réseau immédiate, cette\n// projection engage plusieurs heures et n'est pas révisable à volonté — la nuit\n// tombée, il n'y a plus de solaire pour rattraper une sous-estimation.\n// ---------------------------------------------------------------------\nfunction nextDeficitWindow(slots) {\n const firstSurplus = slots.findIndex(s => s.netKwh > 0);\n if (firstSurplus < 0) return { found: false, reason: 'deficit_en_cours' };\n\n const start = slots.findIndex((s, i) => i > firstSurplus && s.netKwh < 0);\n if (start < 0) return { found: false, reason: 'aucun_deficit_sur_horizon' };\n\n let end = slots.findIndex((s, i) => i > start && s.netKwh > 0);\n if (end < 0) end = slots.length;\n\n const energyKwh = slots.slice(start, end)\n .reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n\n // Surplus encore récupérable d'ici là : de quoi remplir, ou pas.\n const surplusBeforeKwh = slots.slice(0, start)\n .reduce((sum, s) => sum + Math.max(0, s.netKwh), 0);\n\n return {\n found: true,\n startHour: slots[start].hour,\n startMs: slots[start].startMs,\n hoursFromNow: start,\n endHour: end < slots.length ? slots[end].hour : null,\n complete: end < slots.length,\n energyKwh,\n // NON écrêté à 100 volontairement : une valeur au-dessus de 100 signifie\n // que même une batterie pleine ne suffit pas à traverser la fenêtre. C'est\n // exactement le signal de sous-dimensionnement du stockage — l'écrêter le\n // masquerait.\n requiredSocAtStartPct: socForEnergy(energyKwh, socFloorPct),\n surplusBeforeKwh,\n };\n}\n\nconst nextDeficit = nextDeficitWindow(prudent.slots);\n\n// ---------------------------------------------------------------------\n// CHAUFFE-EAU EN HC DE NUIT LES JOURS FAIBLES (07/10/2026, option b du\n// proprietaire). Le 06/10 (14,5 kWh produits), le chauffe-eau a pris 5,4 kWh\n// de 10 h a 15 h, soit l'energie qui manquait a la batterie pour la nuit.\n// Regle : si le solaire prevu (profil habituel) ne suffit pas a amener la\n// batterie au SOC requis au debut de la traversee du soir, EDF sera ferme la\n// nuit de toute facon -> l'eau se chauffe en HC de nuit sur EDF, pas avec le\n// solaire du jour.\n// ⚠️ DECISION UNE FOIS PAR JOUR, figee jusqu'au lendemain : retirer le\n// chauffe-eau de la journee AMELIORE la projection — reevaluee a chaque cycle,\n// la regle detruirait sa propre cause (oscillateur, cf. §24/§36). On decide au\n// premier calcul apres la fin de la fenetre HC de nuit du jour (la fenetre\n// suivante se termine alors dans plus de 12 h) ; la decision vaut pour la\n// journee et la nuit qui suit, jusqu'a la decision du lendemain.\n// ---------------------------------------------------------------------\nconst ecsNuitPrev = typeof input.ecs_nuit_jour === 'string' ? input.ecs_nuit_jour : '';\nconst ecsNuitPrevDate = ecsNuitPrev.split('|')[0] || '';\nconst nowMsEcs = num(input.nowMs, Date.now());\nconst todayParis = new Date(nowMsEcs).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' });\nconst ecsNightHcEndMs = num(input.night_hc_end_ms, NaN);\nconst ecsDecisionDue = ecsNuitPrevDate !== todayParis\n && Number.isFinite(ecsNightHcEndMs) && ecsNightHcEndMs - nowMsEcs > 12 * 3600000;\nlet ecsNight = { due: ecsDecisionDue, decided: false, date: todayParis };\nif (ecsDecisionDue) {\n const nd50 = nextDeficitWindow(slotsP50);\n if (nd50.found) {\n // Trajectoire SIGNEE jusqu'au debut de la traversee (le deficit du matin\n // compte), bornee au plancher et a 100 %.\n let socProj = socPct;\n for (let i = 0; i < nd50.hoursFromNow; i++) {\n const net = slotsP50[i].netKwh;\n // socAfterEnergy ignore une energie negative : la decharge passe par\n // l'energie stockee (meme courbe).\n socProj = net >= 0 ? Math.min(100, socAfterEnergy(socProj, net))\n : Math.max(socFloorPct, socForEnergy(Math.max(0, energyBetweenSoc(0, socProj) + net), 0));\n }\n const on = socProj < nd50.requiredSocAtStartPct;\n ecsNight = {\n due: true, decided: true, date: todayParis, on,\n projectedSocPct: round1(socProj),\n requiredSocPct: round1(nd50.requiredSocAtStartPct),\n startHour: nd50.startHour,\n reason: on\n ? `jour faible : batterie prevue a ${round1(socProj)} % a ${nd50.startHour} h pour ${round1(nd50.requiredSocAtStartPct)} % requis -> eau chaude en HC de nuit`\n : `jour suffisant : batterie prevue a ${round1(socProj)} % a ${nd50.startHour} h pour ${round1(nd50.requiredSocAtStartPct)} % requis -> eau chaude au solaire`,\n };\n }\n}\n\n// ---------------------------------------------------------------------\n// RÉSERVE D'ORAGE (27/08/2026)\n//\n// Les coupures réseau sont fréquentes ici lors des orages. Point de départ\n// souvent mal posé : une coupure n'arrête PAS l'installation. Le solaire et\n// la batterie continuent de fonctionner, seul le filet de secours disparaît.\n// La question n'est donc pas « combien stocker pour survivre » mais « la\n// batterie traverse-t-elle la prochaine fenêtre de déficit SANS le réseau ».\n//\n// C'est exactement la couverture PRUDENTE (p80) déjà calculée plus haut :\n// rien à inventer, aucune constante de plus. Le profil prudent est le bon\n// choix ici parce qu'une coupure retire précisément la possibilité de\n// rattraper une sous-estimation.\n//\n// Le niveau vient de la vigilance Météo-France, dont l'échelle officielle est\n// vert/jaune/orange/rouge. « Actif » à partir d'orange : c'est le seuil de\n// l'émetteur, pas un nombre choisi ici.\n// ---------------------------------------------------------------------\nconst STORM_SCALE = ['vert', 'jaune', 'orange', 'rouge'];\nconst STORM_ACTIVE_FROM = STORM_SCALE.indexOf('orange');\n\nconst stormRaw = typeof input.storm_alert_level === 'string'\n ? input.storm_alert_level.trim().toLowerCase()\n : '';\nconst stormIndex = STORM_SCALE.indexOf(stormRaw);\nconst stormKnown = stormIndex >= 0;\nconst stormActive = stormIndex >= STORM_ACTIVE_FROM;\n\n// Phénomènes atteignant le niveau d'alerte, s'ils sont exposés en attributs\n// (« Orages », « Vent violent »…). Purement informatif.\nconst stormDetail = (input.storm_alert_detail && typeof input.storm_alert_detail === 'object')\n ? input.storm_alert_detail\n : {};\nconst stormPhenomena = Object.keys(stormDetail)\n .filter(k => typeof stormDetail[k] === 'string'\n && STORM_SCALE.indexOf(stormDetail[k].trim().toLowerCase()) >= STORM_ACTIVE_FROM)\n .sort();\n\n// En surplus, la fenêtre qui compte est la prochaine (la nuit qui vient) ;\n// en déficit, c'est celle qu'on est en train de traverser.\nconst inSurplusNow = prudent.found && prudent.crossingIndex === 0;\nconst stormWindow = (inSurplusNow && nextDeficit.found)\n ? {\n kwh: nextDeficit.energyKwh,\n socPct: nextDeficit.requiredSocAtStartPct,\n startHour: nextDeficit.startHour,\n }\n : {\n kwh: prudent.energyToCoverKwh,\n socPct: prudent.requiredSocPct,\n startHour: null,\n };\nconst stormMarginPct = socPct - stormWindow.socPct;\n\nconst slots = typical.slots;\nconst crossingFound = typical.found;\nconst crossingIndex = typical.crossingIndex;\nconst crossing = crossingFound ? slots[crossingIndex] : null;\nconst deficitSlots = typical.deficitSlots;\n\n// ---------------------------------------------------------------------\n// Marge disponible pour le véhicule : surplus attendu après le croisement,\n// une fois la maison servie. C'est ce qui peut être chargé sans toucher au\n// réseau ni entamer la réserve.\n// ---------------------------------------------------------------------\nconst surplusAfterCrossingKwh = crossingFound\n ? slots.slice(crossingIndex).reduce((sum, s) => sum + Math.max(0, s.netKwh), 0)\n : 0;\n\n// ---------------------------------------------------------------------\n// Confiance : la part du calcul qui repose sur une forme de production minorée\n// (après-midi censuré). Elle ne dégrade PAS la réponse « faut-il du réseau »,\n// qui se joue sur les heures de déficit — celles-ci ne sont jamais censurées,\n// une heure censurée étant par construction une heure de surplus.\n// ---------------------------------------------------------------------\nconst deficitOnWeakShape = deficitSlots.filter(s => s.shapeQuality === 'minoree').length;\nconst confidence = {\n deficitHoursOnWeakShape: deficitOnWeakShape,\n deficitTrustworthy: deficitOnWeakShape === 0,\n surplusTrustworthy: !slots.slice(crossingFound ? crossingIndex : 0)\n .some(s => s.shapeQuality === 'minoree'),\n};\n\nreturn [{\n json: {\n usable: true,\n generatedAt: new Date(num(input.nowMs, Date.now())).toISOString(),\n\n crossing: {\n found: crossingFound,\n hour: crossing ? crossing.hour : null,\n startMs: crossing ? crossing.startMs : null,\n hoursFromNow: crossingFound ? crossingIndex : null,\n },\n\n coverage: {\n // Décision réseau immédiate — remplace input_number.victron_grid_soc_off.\n energyToCoverKwh: round2(typical.energyToCoverKwh),\n requiredSocPct: round1(typical.requiredSocPct),\n currentSocPct: round1(socPct),\n socMarginPct: round1(typical.socMarginPct),\n gridNeeded: typical.gridNeeded,\n gridEnergyNeededKwh: round2(typical.gridEnergyNeededKwh),\n\n // Planification (cible de nuit, dimensionnement) — ne pilote PAS le réseau.\n prudent: {\n energyToCoverKwh: round2(prudent.energyToCoverKwh),\n requiredSocPct: round1(prudent.requiredSocPct),\n socMarginPct: round1(prudent.socMarginPct),\n crossingHour: prudent.found ? prudent.slots[prudent.crossingIndex].hour : null,\n },\n },\n\n // Ce qu'il faudra avoir stocké avant la prochaine traversée déficitaire.\n nextDeficit: nextDeficit.found ? {\n found: true,\n startHour: nextDeficit.startHour,\n hoursFromNow: nextDeficit.hoursFromNow,\n endHour: nextDeficit.endHour,\n complete: nextDeficit.complete,\n energyKwh: round2(nextDeficit.energyKwh),\n requiredSocAtStartPct: round1(nextDeficit.requiredSocAtStartPct),\n surplusBeforeKwh: round2(nextDeficit.surplusBeforeKwh),\n // Franchissable avec le stockage actuel, batterie pleine ?\n feasibleWithCurrentStorage: nextDeficit.requiredSocAtStartPct <= 100,\n // Si non : capacité utile manquante. C'est le chiffre du dimensionnement.\n storageShortfallKwh: energyBetweenSoc(100, nextDeficit.requiredSocAtStartPct),\n reachable: nextDeficit.surplusBeforeKwh >= 0\n && socAfterEnergy(socPct, nextDeficit.surplusBeforeKwh)\n >= nextDeficit.requiredSocAtStartPct,\n } : { found: false, reason: nextDeficit.reason },\n\n vehicle: {\n surplusAvailableKwh: round2(surplusAfterCrossingKwh),\n },\n\n // Chauffe-eau en HC de nuit les jours faibles (07/10).\n ecsNight,\n // Reprise du soir et prevision du jour recalee sur la matinee (07/10).\n ecsReprise: { active: ecsRepriseSoirKwh > 0, kwh: round2(ecsRepriseSoirKwh) },\n prodRecal,\n\n // Nuit prevue par la soiree (28/09) : facteur applique au profil.\n nightModel: {\n active: nightModelActive,\n factor: Math.round(nightFactor * 1000) / 1000,\n rawFactor: Number.isFinite(nightFactorRaw) ? nightFactorRaw : null,\n forecastKwh: num(input.night_house_forecast_kwh, NaN) || null,\n },\n\n // Maison sur le reseau pendant la fenetre HC de nuit, si la session s'engage (29/09).\n nightHcImport: {\n active: nightHcActive,\n startMs: nightHcActive ? nightHcStartMs : null,\n endMs: nightHcActive ? nightHcEndMs : null,\n houseKwh: nightHcHouseKwh === null ? null : round2(nightHcHouseKwh),\n },\n\n // Ce que la voiture peut prendre sur la batterie cette nuit sans EDF (30/09).\n nightVehicleBudget: {\n active: nightVehicleBudgetKwh !== null,\n kwh: nightVehicleBudgetKwh === null ? null : round2(nightVehicleBudgetKwh),\n },\n\n // Voiture branchee en HC, depart avant la prochaine HC (28/09).\n vehicleMidday: {\n active: vehicleRuleActive,\n needKwh: Number.isFinite(vehicleNeedKwh) ? round2(vehicleNeedKwh) : null,\n departureMs: Number.isFinite(vehicleDepartureMs) ? vehicleDepartureMs : null,\n solarKwh: vehicleSolarKwh === null ? null : round2(vehicleSolarKwh),\n reachableKwh: vehicleReachableKwh === null ? null : round2(vehicleReachableKwh),\n shortfallKwh: vehicleShortfallKwh === null ? null : round2(vehicleShortfallKwh),\n floorAtDeparturePct: vehicleFloorAtDeparturePct === null ? null : round1(vehicleFloorAtDeparturePct),\n gridNeeded: vehicleGridNeeded,\n },\n\n // Tenir jusqu'a la prochaine fenetre HC (28/09) : SOC a avoir en fin de\n // fenetre HC en cours. 0 hors fenetre ou si la production suffit.\n hcReach: {\n active: hcReachActive,\n windowEndMs: hcReachActive ? hcWindowEndMs : null,\n nextHcStartMs: hcReachActive ? nextHcStartMs : null,\n floorPct: round1(Math.min(100, hcFloorTypicalPct)),\n // Jour faible a midi : cible 100 % (charge maximale) ; floorReachPct garde\n // le plancher « tenir jusqu'a 01:24 » calcule, pour comparaison.\n maxCharge: hcMaxCharge,\n // Nuit d'un jour annonce faible : SOC de fin de nuit pour arriver au soir.\n nightEvening,\n floorReachPct: round1(Math.min(100, hcFloorReachPct)),\n floorPrudentPct: round1(Math.min(100, hcFloorPrudentPct)),\n nextNightHcStartMs: hcNightReachActive ? nextNightHcStartMs : null,\n nightFloorPrudentPct: round1(Math.min(100, hcFloorNightPrudentPct)),\n raisesRequirement: hcReachActive && hcFloorTypicalPct >= typical.requiredSocPct - 0.05\n && hcFloorTypicalPct > socForEnergy(typical.energyToCoverKwh, socFloorPct),\n },\n\n // Réserve à tenir pour traverser SANS réseau la fenêtre de déficit qui\n // compte. Publiée en permanence — elle documente le risque même par temps\n // calme ; `active` dit seulement si la vigilance la rend d'actualité.\n storm: {\n level: stormKnown ? stormRaw : 'indisponible',\n known: stormKnown,\n active: stormActive,\n phenomena: stormPhenomena,\n windowStartHour: stormWindow.startHour,\n reserveKwh: round2(stormWindow.kwh),\n reserveSocPct: round1(stormWindow.socPct),\n socMarginPct: round1(stormMarginPct),\n covered: stormMarginPct >= 0,\n shortfallKwh: stormMarginPct < 0\n ? round2(energyBetweenSoc(socPct, stormWindow.socPct))\n : 0,\n },\n\n confidence,\n slots,\n\n inputs: {\n usableCapacityKwh: round2(usableCapacityKwh),\n socFloorPct: round1(socFloorPct),\n },\n\n cfg: CFG,\n },\n}];\n" } }, { @@ -123,7 +123,7 @@ 120 ], "parameters": { - "jsCode": "// EVCS Energy Balance — BUILD_HA_CALLS\n// =====================================================================\n// GARANTIE MODE OMBRE : ce nœud ne produit QUE des écritures vers des entités\n// dont le nom commence par `evcs_balance_`, plus une trace logbook. Aucun\n// switch, aucune consigne de borne, aucune stratégie, aucun helper de takeover.\n// Le bilan observe et publie ; il ne pilote rien tant que le câblage réel n'a\n// pas été décidé explicitement.\n//\n// Cette garantie est VÉRIFIÉE par tests/energy-balance/ : la suite échoue si\n// une écriture sort de ce périmètre. Ne pas ajouter d'autres entités ici sans\n// changer le test en connaissance de cause.\n// =====================================================================\n\nconst balance = $input.first().json;\nconst censorship = $('CENSORSHIP').first().json;\nconst rawSnapshot = $('HA Snapshot Balance').first().json;\nconst snapshot = (rawSnapshot && typeof rawSnapshot.data === 'string')\n ? (() => { try { return JSON.parse(rawSnapshot.data); } catch { return rawSnapshot; } })()\n : ((rawSnapshot && rawSnapshot.data) || rawSnapshot);\n\nfunction stampParis() {\n const p = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',\n hour: '2-digit', minute: '2-digit', hour12: false,\n }).formatToParts(new Date());\n const g = t => p.find(x => x.type === t)?.value || '';\n return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;\n}\n\nconst calls = [];\n\nfunction push(domain, service, data) {\n calls.push({ json: { domain, service, data } });\n}\nfunction setText(entity, value) {\n push('input_text', 'set_value', { entity_id: entity, value: (value ?? '').toString().slice(0, 250) });\n}\nfunction setNumber(entity, value) {\n if (!Number.isFinite(value)) return;\n push('input_number', 'set_value', { entity_id: entity, value });\n}\nfunction setBool(entity, on) {\n push('input_boolean', on ? 'turn_on' : 'turn_off', { entity_id: entity });\n}\n\n// Kill-switch : à OFF, on ne touche à rien du tout, pas même au statut.\nconst enabled = (snapshot.balance_enabled ?? 'on').toString().toLowerCase() === 'on';\nif (!enabled) return [];\n\n// --- Ce que le bilan constate de la production, dans tous les cas ---------\nsetBool('input_boolean.evcs_balance_pv_censored', !!censorship.censorship.pvCensored);\nsetText('input_text.evcs_balance_censor_reason',\n censorship.censorship.pvCensored\n ? censorship.censorship.reasons.join(', ')\n : 'production non bridée');\nsetText('input_text.evcs_balance_last_run', stampParis());\n\n// --- Le bilan lui-même ---------------------------------------------------\nif (!balance.usable) {\n setText('input_text.evcs_balance_status', 'indisponible');\n // Jamais de verdict voiture perime quand le bilan ne sait plus rien.\n setBool('input_boolean.evcs_balance_vehicle_grid_needed', false);\n setText('input_text.evcs_balance_reason',\n `Entrées manquantes : ${(balance.missing || []).join(', ')}. Les contrôleurs gardent leurs règles.`);\n return calls;\n}\n\nconst c = balance.coverage;\n\n// `ok` ne veut pas dire « le calcul a abouti » mais « on peut s'appuyer\n// dessus ». Deux cas dégradent la réponse sans l'empêcher :\n// · aucun croisement production/consommation sur l'horizon — le SOC requis\n// n'est alors plus une exigence datée mais la somme d'un horizon entier ;\n// · des heures de déficit reposant sur une forme de production minorée\n// (repli sur l'enveloppe faute de courbe horaire fraîche).\n// Dans les deux cas le bilan publie quand même ses chiffres — ils restent\n// informatifs — mais il le DIT, pour qu'aucun câblage futur ne s'y fie en\n// silence. Mesuré le 25/08 : sans cette distinction, la nuit précédant la mise\n// en service de la courbe horaire, le bilan a publié un SOC requis de 100 %\n// avec un statut `ok`.\nconst degraded =\n !balance.crossing.found ||\n !(balance.confidence && balance.confidence.deficitTrustworthy);\nsetText('input_text.evcs_balance_status', degraded ? 'degrade' : 'ok');\nsetNumber('input_number.evcs_balance_required_soc', c.requiredSocPct);\nsetNumber('input_number.evcs_balance_required_soc_prudent', c.prudent.requiredSocPct);\n// Regle HC (28/09) publiee pour etre verifiable : 0 = hors fenetre HC.\nsetNumber('input_number.evcs_balance_hc_floor_pct', balance.hcReach ? balance.hcReach.floorPct : 0);\nsetNumber('input_number.evcs_balance_hc_floor_prudent_pct', balance.hcReach ? balance.hcReach.floorPrudentPct : 0);\n// Voiture branchee en HC, depart avant la prochaine HC (28/09). OFF hors regle.\nconst vm = balance.vehicleMidday || {};\nsetBool('input_boolean.evcs_balance_vehicle_grid_needed', !!vm.gridNeeded);\nsetNumber('input_number.evcs_balance_vehicle_need_kwh', vm.active ? vm.reachableKwh : 0);\nsetNumber('input_number.evcs_balance_vehicle_solar_kwh', vm.active ? vm.solarKwh : 0);\n// Maison sur le reseau pendant la fenetre HC de nuit si la session s'engage (29/09).\nconst nh = balance.nightHcImport || {};\nsetNumber('input_number.evcs_balance_night_hc_house_kwh', nh.active && nh.houseKwh !== null ? nh.houseKwh : 0);\n// Budget voiture de la nuit sans EDF (30/09) : 0 si inconnu — le controleur\n// ne donne alors rien de la batterie.\nconst nb = balance.nightVehicleBudget || {};\nsetNumber('input_number.evcs_balance_night_vehicle_budget_kwh', nb.active && nb.kwh !== null ? nb.kwh : 0);\nsetNumber('input_number.evcs_balance_energy_to_cover_kwh', c.energyToCoverKwh);\nsetNumber('input_number.evcs_balance_grid_energy_needed_kwh', c.gridEnergyNeededKwh);\nsetNumber('input_number.evcs_balance_vehicle_surplus_kwh', balance.vehicle.surplusAvailableKwh);\nsetBool('input_boolean.evcs_balance_grid_needed', !!c.gridNeeded);\n\n// Prochaine traversée déficitaire : ce que la décision immédiate ne montre pas.\n// Chauffe-eau (07/10) : n'ecrit QUE le jour de la decision (une fois par jour).\nconst en = balance.ecsNight || {};\nif (en.decided) {\n setBool('input_boolean.evcs_balance_ecs_nuit', !!en.on);\n setText('input_text.evcs_balance_ecs_nuit_jour', `${en.date}|${en.on ? 'on' : 'off'}|${en.reason}`);\n}\nconst nd = balance.nextDeficit || { found: false };\nif (nd.found) {\n setNumber('input_number.evcs_balance_next_deficit_kwh', nd.energyKwh);\n setNumber('input_number.evcs_balance_next_deficit_required_soc', nd.requiredSocAtStartPct);\n setNumber('input_number.evcs_balance_storage_shortfall_kwh', nd.storageShortfallKwh);\n setText('input_text.evcs_balance_next_deficit_window',\n `${String(nd.startHour).padStart(2, '0')} h → ${nd.endHour == null ? '?' : String(nd.endHour).padStart(2, '0') + ' h'}`\n + (nd.feasibleWithCurrentStorage ? '' : ' (stockage insuffisant)'));\n} else {\n setText('input_text.evcs_balance_next_deficit_window',\n nd.reason === 'deficit_en_cours' ? 'déficit en cours' : 'aucune sur l\\'horizon');\n}\n// --- Réserve d'orage -----------------------------------------------------\n// Publiée même hors vigilance : le chiffre dit en permanence si l'installation\n// passerait la prochaine nuit sans réseau. Le helper SOC est borné à 100 pour\n// rester dans la plage de l'entrée ; le dépassement éventuel reste lisible\n// dans la raison et dans le manque en kWh.\nconst st = balance.storm;\nif (st) {\n setText('input_text.evcs_balance_storm_level', st.level);\n setBool('input_boolean.evcs_balance_storm_active', !!st.active);\n setNumber('input_number.evcs_balance_storm_reserve_kwh', st.reserveKwh);\n setNumber('input_number.evcs_balance_storm_reserve_soc',\n Number.isFinite(st.reserveSocPct) ? Math.min(100, Math.max(0, st.reserveSocPct)) : null);\n setText('input_text.evcs_balance_storm_reason',\n (st.known ? `Vigilance ${st.level}` : 'Vigilance indisponible')\n + (st.phenomena.length ? ` (${st.phenomena.join(', ')})` : '')\n + ` — sans réseau il faut ${st.reserveKwh} kWh`\n + (st.windowStartHour == null ? ' pour la fenêtre en cours' : ` dès ${st.windowStartHour} h`)\n + `, soit SOC ${st.reserveSocPct}% ; actuel ${c.currentSocPct}% → `\n + (st.covered ? 'couvert.' : `manque ${st.shortfallKwh} kWh.`));\n}\n\nsetText('input_text.evcs_balance_crossing_hour',\n balance.crossing.found ? `${String(balance.crossing.hour).padStart(2, '0')} h` : 'aucun sur l\\'horizon');\n\nconst ndText = nd.found\n ? ` | prochaine traversée ${nd.energyKwh} kWh dès ${nd.startHour} h → SOC requis ${nd.requiredSocAtStartPct}%${nd.feasibleWithCurrentStorage ? '' : ` (manque ${Math.round(nd.storageShortfallKwh * 100) / 100} kWh de stockage)`}`\n : '';\n\nconst degradedNote = !balance.crossing.found\n ? ' ⚠ aucun croisement sur l\\'horizon : chiffre indicatif, pas une exigence datée.'\n : (degraded ? ' ⚠ forme de production minorée sur les heures de déficit.' : '');\nconst reason = balance.crossing.found\n ? `Croisement à ${balance.crossing.hour} h, ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}% (actuel ${c.currentSocPct}%). ${c.gridNeeded ? `Réseau : ${c.gridEnergyNeededKwh} kWh.` : 'Réseau non nécessaire.'}`\n : `Aucun croisement PV/conso sur l'horizon : ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}%.`;\n// Fenetre HC (28/09) : dire quand c'est « tenir jusqu'a la prochaine HC » qui\n// fixe l'exigence, sinon la raison affiche un croisement qui ne l'explique pas.\nconst hc = balance.hcReach || {};\nconst ne = hc.nightEvening || {};\nconst hcText = hc.active && ne.active\n ? ` Journée faible annoncée : la nuit charge à ${ne.floorPct}% (batterie prévue à ${ne.eveningProjectedPct}% à ${ne.eveningStartHour} h pour ${ne.eveningRequiredPct}% requis ; tenir jusqu'à midi demandait ${hc.floorReachPct}%).`\n : hc.active && hc.maxCharge\n ? ` Jour faible : charge maximale en HC de midi (tenir jusqu'à ${new Date(hc.nextHcStartMs).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' })} demandait ${hc.floorReachPct}%).`\n : hc.active && hc.raisesRequirement\n ? ` Tenir jusqu'à la prochaine HC (${new Date(hc.nextHcStartMs).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' })}) → ${hc.floorPct}% en fin de fenêtre.`\n : '';\nconst hhmm = ms => new Date(ms).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' });\nconst vehText = vm.active\n ? ` Voiture (départ ${hhmm(vm.departureMs)}) : ${vm.reachableKwh} kWh à donner, solaire ${vm.solarKwh} kWh → ${vm.gridNeeded ? 'réseau justifié' : 'solaire suffisant'}.`\n : '';\nconst nm = balance.nightModel || {};\nconst nightText = nm.active && Math.abs(nm.factor - 1) >= 0.005\n ? ` Nuit recalée sur la soirée : conso ×${String(nm.factor).replace('.', ',')}${nm.forecastKwh ? ` (${String(nm.forecastKwh).replace('.', ',')} kWh prévus 21:30-09:00)` : ''}.`\n : '';\nsetText('input_text.evcs_balance_reason', reason + nightText + hcText + vehText + degradedNote + ndText);\n\n// Trace longue durée, pour comparer jour après jour le seuil calculé au seuil\n// fixe encore en vigueur — c'est cette comparaison qui décidera du câblage.\npush('logbook', 'log', {\n name: 'EVCS Bilan énergétique (ombre)',\n message: `${reason} | seuil fixe actuel grid_soc_off = ${snapshot.grid_soc_off ?? 'n/a'}% | production ${censorship.censorship.pvCensored ? 'BRIDÉE (' + censorship.censorship.reasons.join(', ') + ')' : 'libre'}`,\n entity_id: 'input_text.evcs_balance_status',\n});\n\nreturn calls;\n" + "jsCode": "// EVCS Energy Balance — BUILD_HA_CALLS\n// =====================================================================\n// GARANTIE MODE OMBRE : ce nœud ne produit QUE des écritures vers des entités\n// dont le nom commence par `evcs_balance_`, plus une trace logbook. Aucun\n// switch, aucune consigne de borne, aucune stratégie, aucun helper de takeover.\n// Le bilan observe et publie ; il ne pilote rien tant que le câblage réel n'a\n// pas été décidé explicitement.\n//\n// Cette garantie est VÉRIFIÉE par tests/energy-balance/ : la suite échoue si\n// une écriture sort de ce périmètre. Ne pas ajouter d'autres entités ici sans\n// changer le test en connaissance de cause.\n// =====================================================================\n\nconst balance = $input.first().json;\nconst censorship = $('CENSORSHIP').first().json;\nconst rawSnapshot = $('HA Snapshot Balance').first().json;\nconst snapshot = (rawSnapshot && typeof rawSnapshot.data === 'string')\n ? (() => { try { return JSON.parse(rawSnapshot.data); } catch { return rawSnapshot; } })()\n : ((rawSnapshot && rawSnapshot.data) || rawSnapshot);\n\nfunction stampParis() {\n const p = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',\n hour: '2-digit', minute: '2-digit', hour12: false,\n }).formatToParts(new Date());\n const g = t => p.find(x => x.type === t)?.value || '';\n return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;\n}\n\nconst calls = [];\n\nfunction push(domain, service, data) {\n calls.push({ json: { domain, service, data } });\n}\nfunction setText(entity, value) {\n push('input_text', 'set_value', { entity_id: entity, value: (value ?? '').toString().slice(0, 250) });\n}\nfunction setNumber(entity, value) {\n if (!Number.isFinite(value)) return;\n push('input_number', 'set_value', { entity_id: entity, value });\n}\nfunction setBool(entity, on) {\n push('input_boolean', on ? 'turn_on' : 'turn_off', { entity_id: entity });\n}\n\n// Kill-switch : à OFF, on ne touche à rien du tout, pas même au statut.\nconst enabled = (snapshot.balance_enabled ?? 'on').toString().toLowerCase() === 'on';\nif (!enabled) return [];\n\n// --- Ce que le bilan constate de la production, dans tous les cas ---------\nsetBool('input_boolean.evcs_balance_pv_censored', !!censorship.censorship.pvCensored);\nsetText('input_text.evcs_balance_censor_reason',\n censorship.censorship.pvCensored\n ? censorship.censorship.reasons.join(', ')\n : 'production non bridée');\nsetText('input_text.evcs_balance_last_run', stampParis());\n\n// --- Le bilan lui-même ---------------------------------------------------\nif (!balance.usable) {\n setText('input_text.evcs_balance_status', 'indisponible');\n // Jamais de verdict voiture perime quand le bilan ne sait plus rien.\n setBool('input_boolean.evcs_balance_vehicle_grid_needed', false);\n setText('input_text.evcs_balance_reason',\n `Entrées manquantes : ${(balance.missing || []).join(', ')}. Les contrôleurs gardent leurs règles.`);\n return calls;\n}\n\nconst c = balance.coverage;\n\n// `ok` ne veut pas dire « le calcul a abouti » mais « on peut s'appuyer\n// dessus ». Deux cas dégradent la réponse sans l'empêcher :\n// · aucun croisement production/consommation sur l'horizon — le SOC requis\n// n'est alors plus une exigence datée mais la somme d'un horizon entier ;\n// · des heures de déficit reposant sur une forme de production minorée\n// (repli sur l'enveloppe faute de courbe horaire fraîche).\n// Dans les deux cas le bilan publie quand même ses chiffres — ils restent\n// informatifs — mais il le DIT, pour qu'aucun câblage futur ne s'y fie en\n// silence. Mesuré le 25/08 : sans cette distinction, la nuit précédant la mise\n// en service de la courbe horaire, le bilan a publié un SOC requis de 100 %\n// avec un statut `ok`.\nconst degraded =\n !balance.crossing.found ||\n !(balance.confidence && balance.confidence.deficitTrustworthy);\nsetText('input_text.evcs_balance_status', degraded ? 'degrade' : 'ok');\nsetNumber('input_number.evcs_balance_required_soc', c.requiredSocPct);\nsetNumber('input_number.evcs_balance_required_soc_prudent', c.prudent.requiredSocPct);\n// Regle HC (28/09) publiee pour etre verifiable : 0 = hors fenetre HC.\nsetNumber('input_number.evcs_balance_hc_floor_pct', balance.hcReach ? balance.hcReach.floorPct : 0);\nsetNumber('input_number.evcs_balance_hc_floor_prudent_pct', balance.hcReach ? balance.hcReach.floorPrudentPct : 0);\n// Voiture branchee en HC, depart avant la prochaine HC (28/09). OFF hors regle.\nconst vm = balance.vehicleMidday || {};\nsetBool('input_boolean.evcs_balance_vehicle_grid_needed', !!vm.gridNeeded);\nsetNumber('input_number.evcs_balance_vehicle_need_kwh', vm.active ? vm.reachableKwh : 0);\nsetNumber('input_number.evcs_balance_vehicle_solar_kwh', vm.active ? vm.solarKwh : 0);\n// Maison sur le reseau pendant la fenetre HC de nuit si la session s'engage (29/09).\nconst nh = balance.nightHcImport || {};\nsetNumber('input_number.evcs_balance_night_hc_house_kwh', nh.active && nh.houseKwh !== null ? nh.houseKwh : 0);\n// Budget voiture de la nuit sans EDF (30/09) : 0 si inconnu — le controleur\n// ne donne alors rien de la batterie.\nconst nb = balance.nightVehicleBudget || {};\nsetNumber('input_number.evcs_balance_night_vehicle_budget_kwh', nb.active && nb.kwh !== null ? nb.kwh : 0);\nsetNumber('input_number.evcs_balance_energy_to_cover_kwh', c.energyToCoverKwh);\nsetNumber('input_number.evcs_balance_grid_energy_needed_kwh', c.gridEnergyNeededKwh);\nsetNumber('input_number.evcs_balance_vehicle_surplus_kwh', balance.vehicle.surplusAvailableKwh);\nsetBool('input_boolean.evcs_balance_grid_needed', !!c.gridNeeded);\n\n// Prochaine traversée déficitaire : ce que la décision immédiate ne montre pas.\n// Chauffe-eau (07/10) : n'ecrit QUE le jour de la decision (une fois par jour).\nconst en = balance.ecsNight || {};\nif (en.decided) {\n setBool('input_boolean.evcs_balance_ecs_nuit', !!en.on);\n setText('input_text.evcs_balance_ecs_nuit_jour', `${en.date}|${en.on ? 'on' : 'off'}|${en.reason}`);\n}\nconst nd = balance.nextDeficit || { found: false };\nif (nd.found) {\n setNumber('input_number.evcs_balance_next_deficit_kwh', nd.energyKwh);\n setNumber('input_number.evcs_balance_next_deficit_required_soc', nd.requiredSocAtStartPct);\n setNumber('input_number.evcs_balance_storage_shortfall_kwh', nd.storageShortfallKwh);\n setText('input_text.evcs_balance_next_deficit_window',\n `${String(nd.startHour).padStart(2, '0')} h → ${nd.endHour == null ? '?' : String(nd.endHour).padStart(2, '0') + ' h'}`\n + (nd.feasibleWithCurrentStorage ? '' : ' (stockage insuffisant)'));\n} else {\n setText('input_text.evcs_balance_next_deficit_window',\n nd.reason === 'deficit_en_cours' ? 'déficit en cours' : 'aucune sur l\\'horizon');\n}\n// --- Réserve d'orage -----------------------------------------------------\n// Publiée même hors vigilance : le chiffre dit en permanence si l'installation\n// passerait la prochaine nuit sans réseau. Le helper SOC est borné à 100 pour\n// rester dans la plage de l'entrée ; le dépassement éventuel reste lisible\n// dans la raison et dans le manque en kWh.\nconst st = balance.storm;\nif (st) {\n setText('input_text.evcs_balance_storm_level', st.level);\n setBool('input_boolean.evcs_balance_storm_active', !!st.active);\n setNumber('input_number.evcs_balance_storm_reserve_kwh', st.reserveKwh);\n setNumber('input_number.evcs_balance_storm_reserve_soc',\n Number.isFinite(st.reserveSocPct) ? Math.min(100, Math.max(0, st.reserveSocPct)) : null);\n setText('input_text.evcs_balance_storm_reason',\n (st.known ? `Vigilance ${st.level}` : 'Vigilance indisponible')\n + (st.phenomena.length ? ` (${st.phenomena.join(', ')})` : '')\n + ` — sans réseau il faut ${st.reserveKwh} kWh`\n + (st.windowStartHour == null ? ' pour la fenêtre en cours' : ` dès ${st.windowStartHour} h`)\n + `, soit SOC ${st.reserveSocPct}% ; actuel ${c.currentSocPct}% → `\n + (st.covered ? 'couvert.' : `manque ${st.shortfallKwh} kWh.`));\n}\n\nsetText('input_text.evcs_balance_crossing_hour',\n balance.crossing.found ? `${String(balance.crossing.hour).padStart(2, '0')} h` : 'aucun sur l\\'horizon');\n\nconst ndText = nd.found\n ? ` | prochaine traversée ${nd.energyKwh} kWh dès ${nd.startHour} h → SOC requis ${nd.requiredSocAtStartPct}%${nd.feasibleWithCurrentStorage ? '' : ` (manque ${Math.round(nd.storageShortfallKwh * 100) / 100} kWh de stockage)`}`\n : '';\n\nconst degradedNote = !balance.crossing.found\n ? ' ⚠ aucun croisement sur l\\'horizon : chiffre indicatif, pas une exigence datée.'\n : (degraded ? ' ⚠ forme de production minorée sur les heures de déficit.' : '');\nconst reason = balance.crossing.found\n ? `Croisement à ${balance.crossing.hour} h, ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}% (actuel ${c.currentSocPct}%). ${c.gridNeeded ? `Réseau : ${c.gridEnergyNeededKwh} kWh.` : 'Réseau non nécessaire.'}`\n : `Aucun croisement PV/conso sur l'horizon : ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}%.`;\n// Fenetre HC (28/09) : dire quand c'est « tenir jusqu'a la prochaine HC » qui\n// fixe l'exigence, sinon la raison affiche un croisement qui ne l'explique pas.\nconst hc = balance.hcReach || {};\nconst ne = hc.nightEvening || {};\nconst hcText = hc.active && ne.active\n ? ` Journée faible annoncée : la nuit charge à ${ne.floorPct}% (sinon il manquerait ${ne.shortfallKwh} kWh avant la nuit prochaine ; tenir jusqu'à midi demandait ${hc.floorReachPct}%).`\n : hc.active && hc.maxCharge\n ? ` Jour faible : charge maximale en HC de midi (tenir jusqu'à ${new Date(hc.nextHcStartMs).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' })} demandait ${hc.floorReachPct}%).`\n : hc.active && hc.raisesRequirement\n ? ` Tenir jusqu'à la prochaine HC (${new Date(hc.nextHcStartMs).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' })}) → ${hc.floorPct}% en fin de fenêtre.`\n : '';\nconst hhmm = ms => new Date(ms).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' });\nconst vehText = vm.active\n ? ` Voiture (départ ${hhmm(vm.departureMs)}) : ${vm.reachableKwh} kWh à donner, solaire ${vm.solarKwh} kWh → ${vm.gridNeeded ? 'réseau justifié' : 'solaire suffisant'}.`\n : '';\nconst nm = balance.nightModel || {};\nconst nightText = nm.active && Math.abs(nm.factor - 1) >= 0.005\n ? ` Nuit recalée sur la soirée : conso ×${String(nm.factor).replace('.', ',')}${nm.forecastKwh ? ` (${String(nm.forecastKwh).replace('.', ',')} kWh prévus 21:30-09:00)` : ''}.`\n : '';\nsetText('input_text.evcs_balance_reason', reason + nightText + hcText + vehText + degradedNote + ndText);\n\n// Trace longue durée, pour comparer jour après jour le seuil calculé au seuil\n// fixe encore en vigueur — c'est cette comparaison qui décidera du câblage.\npush('logbook', 'log', {\n name: 'EVCS Bilan énergétique (ombre)',\n message: `${reason} | seuil fixe actuel grid_soc_off = ${snapshot.grid_soc_off ?? 'n/a'}% | production ${censorship.censorship.pvCensored ? 'BRIDÉE (' + censorship.censorship.reasons.join(', ') + ')' : 'libre'}`,\n entity_id: 'input_text.evcs_balance_status',\n});\n\nreturn calls;\n" } }, {