diff --git a/HANDOFF.md b/HANDOFF.md index 5003e1d..d3ff57d 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1908,5 +1908,51 @@ orange-cloture (verifie par SPARK : envoye le 05/10 a 17:42). (G inconnue 3 h -> trace, H inconnue 1 h -> question, I JEEP 3 h -> info). Sauvegarde `concierge-live.js.bak-2h-20261007-011338`. +### 07/10 — Le chauffe-eau prenait l'energie de la batterie : eau chaude en HC de nuit les jours faibles +**Constat (rapport du matin)** : le 06/10 (14,5 kWh produits, VRM 22,1), batterie chargee 4,6 kWh / +dechargee 16,3 ; SOC 57 -> 62 % seulement de 9 h a 16 h. Le chauffe-eau +(`sensor.tt_chauffe_eau_pleumeur_energie_totale`) a pris **5,4 kWh de 10:00 a 15:25** (~1,2 kW), 37 % +de la production, en partie sur la batterie (12 h-13 h : PV < maison). Sans lui, ~80 % a 16 h. +Lecture des deux workflows ECS (sous-agent, lecture seule) : le conseiller ne COUPE le switch que sous +priorite EVCS ; « off » n'est qu'un avis -> le ballon suit son mode AUTO. Il conseillait meme +`boost_early_strong_forecast` (10:05, 11:00, 14:40) : prevision Forecast.Solar, « forte » si le reste du +jour OU DEMAIN >= 18 kWh, ou PV >= 2,5 kW. Deux workflows actifs ecrivaient les memes helpers et le meme +switch : V2.3 (`A11aOb50cGtpJmvn`, en erreur a chaque execution) et ECS 6 Nodes (`yX2j8m04e3pxaoQq`). +**Decisions du proprietaire (07/10)** : option b (eau chaude en HC de nuit les jours faibles), conseiller +sur VRM comme le bilan, un seul workflow, securite eau chaude 1,5 kWh / 24 h. +- **V2.3 desactive** (07/10 ~06:55, `deactivate_workflow`, code intact, reactivable). +- **ECS 6 Nodes sous controle du depot** : `tools/n8n-pull.mjs` (nouveau, GET seul), + `workflows/water-heater-ecs-v1.json`, `src/water-heater/*` (8 fichiers, `lib-node-map.mjs` id + `water-heater`), tests `node tests/water-heater/run.mjs` 9/9 (chaine complete Normalize -> BUILD en vm), + branches dans `n8n-push.mjs`. Sauvegarde `water-heater-ecs-v1.json.bak-20261007` (scratchpad) + backup + GET automatique du push. +- **Bilan (`BALANCE.js`, deploye 07:00)** : `ecsNight` — UNE decision par jour, au premier calcul apres la + fin de la fenetre HC de nuit (la fenetre suivante finit dans > 12 h), figee jusqu'au lendemain (retirer + le chauffe-eau ameliore la projection : reevaluee a chaque cycle, la regle oscillerait). Jour faible = + trajectoire SIGNEE du profil habituel jusqu'au debut de la traversee du soir < SOC requis a ce debut. + Publie `input_boolean.evcs_balance_ecs_nuit` et `input_text.evcs_balance_ecs_nuit_jour` + (« AAAA-MM-JJ|on|raison »), lus en retour par le gabarit (`ecs_nuit_jour`). Tests bilan 83/83 (+4 : + sombre -> on 50,3 < 65,3 ; normal -> off ; deja decide -> rien ; 00:30 avant la fin de la nuit -> rien), + mutations qui mordent (sens du test : 2 ; decision non figee : 1). +- **Workflow ECS (deploye 07:04)** : gabarit = prevision de `input_select.evcs_balance_forecast_source` + (VRM : heures restantes, heure en cours au prorata ; demain = somme ; repli Forecast.Solar si courbe + malformee) ; prevision « forte/moyenne » sur le reste du JOUR seulement ; ARBITRATION : jour faible -> + switch coupe, allume seulement si `victron_evcs_night_grid_session_committed` ET contacteur ferme + (session reseau HC, nuit ou midi) ou si < 1,5 kWh en 24 h ; energie 24 h illisible -> aucune + restriction ; jour normal -> remise sous tension seulement si la coupure etait la sienne + (`input_boolean.victron_water_heater_off_jour_faible`) ; une coupure manuelle est respectee. + Mutations : session non exigee -> 1 ; securite eau froide retiree -> 1. +- **Service d'historisation** : `sensor.evcs_chauffe_eau_24h_kwh` (somme des hausses du compteur sur 24 h, + `tools/chauffe-eau.mjs`, tests `tests/allers-retours/chauffe-eau.mjs` 5/5), toutes les 5 min ; 5,59 kWh + a 07:03. Paquet HA `packages/evcs_chauffe_eau_nuit.yaml` (3 helpers, check_config valide, recharges). +- Gabarit rendu par HA en reel a 07:0x : reste du jour VRM 16,37 kWh, demain 33,39, source vrm. Premiere + execution apres le PUT : « Forecast: medium » (avant : strong). +- ⚠️ A verifier : premiere decision du bilan apres 07:24 et l'action sur le switch ; premiere nuit ou le + chauffe-eau chauffe en session HC ; le profil de consommation (appris sur l'historique) mettra + quelques jours a deplacer ~5 kWh du jour vers la nuit les jours faibles. +- Question du proprietaire (charger la batterie en HC de midi pour atteindre la nuit) : deja en place + depuis le 28/09 (« tenir jusqu'a la prochaine HC ») ; le 06/10 : 43,4 % requis a 14:24, SOC 57 -> pas + d'EDF, batterie arrivee a 31 % a 01:24 (> 22) : decision juste. + --- -*Document mis a jour le : 07/10/2026 (~01:20) — 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 : 07/10/2026 (~07:10) — 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/docs/MANUEL_COMPLET.md b/docs/MANUEL_COMPLET.md index 67192b8..1e3e28d 100644 --- a/docs/MANUEL_COMPLET.md +++ b/docs/MANUEL_COMPLET.md @@ -353,6 +353,15 @@ semaine, 3,5 le week-end) ; le reste (~1 kWh le samedi, ~2,7 le dimanche) est la vraie demande (repas a la maison). Jugement : pas de profil week-end pour la nuit (1 a 2 points de SOC, sous l'ecart p50/p80) ; a reconsiderer pour midi. +**Chauffe-eau les jours faibles (07/10/2026, decision du proprietaire)** : quand +le bilan prevoit (une fois par jour, apres 07:24) que le solaire ne remplira pas +la batterie pour la nuit, le chauffe-eau est COUPE le jour et chauffe pendant la +session reseau en heures creuses (EDF ferme), jamais sur la batterie ; il chauffe +quand meme s'il a pris moins de 1,5 kWh en 24 h. Le 06/10 il avait pris 5,4 kWh +de 10 h a 15 h, l'energie qui manquait a la batterie. Un seul workflow commande +le chauffe-eau (« Water Heater Advisor ECS 6 Nodes », V2.3 desactive le 07/10), +avec la prevision VRM comme le bilan. + Les repas ne sont pas les memes d'un jour a l'autre, et l'heure du diner se decale entre 20 h et 23 h. Consequences directes pour le bilan energetique : diff --git a/packages/evcs_chauffe_eau_nuit.yaml b/packages/evcs_chauffe_eau_nuit.yaml new file mode 100755 index 0000000..3abf5d4 --- /dev/null +++ b/packages/evcs_chauffe_eau_nuit.yaml @@ -0,0 +1,25 @@ +# ===================================================== +# EVCS — CHAUFFE-EAU EN HC DE NUIT LES JOURS FAIBLES (07/10/2026, option b du +# proprietaire). Decision du bilan energetique, UNE FOIS PAR JOUR apres la fin +# de la fenetre HC de nuit, figee jusqu'au lendemain (sinon retirer le +# chauffe-eau de la journee changerait la projection : oscillateur). +# Le workflow « Water Heater Advisor ECS 6 Nodes » l'applique sur +# switch.tt_chauffe_eau_pleumeur_switch. Ecrits par le bilan : ne pas editer. +# ===================================================== +input_boolean: + evcs_balance_ecs_nuit: + name: "EVCS - Chauffe-eau en HC de nuit (jour faible)" + icon: mdi:water-boiler-alert + # Ecrit par le workflow ECS : la coupure du chauffe-eau appartient a la regle + # « jour faible ». Sert a le remettre sous tension un jour normal, sans + # toucher a une coupure faite a la main. + victron_water_heater_off_jour_faible: + name: "Chauffe-eau - coupe par la regle jour faible" + icon: mdi:water-boiler-off + +input_text: + # « AAAA-MM-JJ|on|raison » : date de la decision, decision, explication. + evcs_balance_ecs_nuit_jour: + name: "EVCS - Chauffe-eau : decision du jour" + max: 255 + icon: mdi:calendar-check diff --git a/src/energy-balance/BALANCE.js b/src/energy-balance/BALANCE.js index ee3fbf6..b0db0db 100644 --- a/src/energy-balance/BALANCE.js +++ b/src/energy-balance/BALANCE.js @@ -613,6 +613,55 @@ function nextDeficitWindow(slots) { const nextDeficit = nextDeficitWindow(prudent.slots); +// --------------------------------------------------------------------- +// CHAUFFE-EAU EN HC DE NUIT LES JOURS FAIBLES (07/10/2026, option b du +// proprietaire). Le 06/10 (14,5 kWh produits), le chauffe-eau a pris 5,4 kWh +// de 10 h a 15 h, soit l'energie qui manquait a la batterie pour la nuit. +// Regle : si le solaire prevu (profil habituel) ne suffit pas a amener la +// batterie au SOC requis au debut de la traversee du soir, EDF sera ferme la +// nuit de toute facon -> l'eau se chauffe en HC de nuit sur EDF, pas avec le +// solaire du jour. +// ⚠️ DECISION UNE FOIS PAR JOUR, figee jusqu'au lendemain : retirer le +// chauffe-eau de la journee AMELIORE la projection — reevaluee a chaque cycle, +// la regle detruirait sa propre cause (oscillateur, cf. §24/§36). On decide au +// premier calcul apres la fin de la fenetre HC de nuit du jour (la fenetre +// suivante se termine alors dans plus de 12 h) ; la decision vaut pour la +// journee et la nuit qui suit, jusqu'a la decision du lendemain. +// --------------------------------------------------------------------- +const ecsNuitPrev = typeof input.ecs_nuit_jour === 'string' ? input.ecs_nuit_jour : ''; +const ecsNuitPrevDate = ecsNuitPrev.split('|')[0] || ''; +const nowMsEcs = num(input.nowMs, Date.now()); +const todayParis = new Date(nowMsEcs).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }); +const ecsNightHcEndMs = num(input.night_hc_end_ms, NaN); +const ecsDecisionDue = ecsNuitPrevDate !== todayParis + && Number.isFinite(ecsNightHcEndMs) && ecsNightHcEndMs - nowMsEcs > 12 * 3600000; +let ecsNight = { due: ecsDecisionDue, decided: false, date: todayParis }; +if (ecsDecisionDue) { + const nd50 = nextDeficitWindow(slotsP50); + if (nd50.found) { + // Trajectoire SIGNEE jusqu'au debut de la traversee (le deficit du matin + // compte), bornee au plancher et a 100 %. + let socProj = socPct; + for (let i = 0; i < nd50.hoursFromNow; i++) { + const net = slotsP50[i].netKwh; + // socAfterEnergy ignore une energie negative : la decharge passe par + // l'energie stockee (meme courbe). + socProj = net >= 0 ? Math.min(100, socAfterEnergy(socProj, net)) + : Math.max(socFloorPct, socForEnergy(Math.max(0, energyBetweenSoc(0, socProj) + net), 0)); + } + const on = socProj < nd50.requiredSocAtStartPct; + ecsNight = { + due: true, decided: true, date: todayParis, on, + projectedSocPct: round1(socProj), + requiredSocPct: round1(nd50.requiredSocAtStartPct), + startHour: nd50.startHour, + reason: on + ? `jour faible : batterie prevue a ${round1(socProj)} % a ${nd50.startHour} h pour ${round1(nd50.requiredSocAtStartPct)} % requis -> eau chaude en HC de nuit` + : `jour suffisant : batterie prevue a ${round1(socProj)} % a ${nd50.startHour} h pour ${round1(nd50.requiredSocAtStartPct)} % requis -> eau chaude au solaire`, + }; + } +} + // --------------------------------------------------------------------- // RÉSERVE D'ORAGE (27/08/2026) // @@ -749,6 +798,9 @@ return [{ surplusAvailableKwh: round2(surplusAfterCrossingKwh), }, + // Chauffe-eau en HC de nuit les jours faibles (07/10). + ecsNight, + // Nuit prevue par la soiree (28/09) : facteur applique au profil. nightModel: { active: nightModelActive, diff --git a/src/energy-balance/BUILD_HA_CALLS.js b/src/energy-balance/BUILD_HA_CALLS.js index 1299bae..4755386 100644 --- a/src/energy-balance/BUILD_HA_CALLS.js +++ b/src/energy-balance/BUILD_HA_CALLS.js @@ -105,6 +105,12 @@ setNumber('input_number.evcs_balance_vehicle_surplus_kwh', balance.vehicle.surpl setBool('input_boolean.evcs_balance_grid_needed', !!c.gridNeeded); // Prochaine traversée déficitaire : ce que la décision immédiate ne montre pas. +// Chauffe-eau (07/10) : n'ecrit QUE le jour de la decision (une fois par jour). +const en = balance.ecsNight || {}; +if (en.decided) { + setBool('input_boolean.evcs_balance_ecs_nuit', !!en.on); + setText('input_text.evcs_balance_ecs_nuit_jour', `${en.date}|${en.on ? 'on' : 'off'}|${en.reason}`); +} const nd = balance.nextDeficit || { found: false }; if (nd.found) { setNumber('input_number.evcs_balance_next_deficit_kwh', nd.energyKwh); diff --git a/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl b/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl index a701b3a..3bcd5b7 100644 --- a/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl +++ b/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -81,6 +81,7 @@ "next_night_hc_start_ms": (as_timestamp(ns.nuit) * 1000) if ns.nuit != '' else none, "night_hc_start_ms": (as_timestamp(ns.nd) * 1000) if ns.nd != '' else none, "night_hc_end_ms": (as_timestamp(ns.nf) * 1000) if ns.nf != '' else none, + "ecs_nuit_jour": states("input_text.evcs_balance_ecs_nuit_jour"), "nowMs": as_timestamp(now()) * 1000, "balance_enabled": states("input_boolean.evcs_balance_enabled"), diff --git a/src/energy-balance/PREPARE_BALANCE.js b/src/energy-balance/PREPARE_BALANCE.js index d568eeb..e88705b 100644 --- a/src/energy-balance/PREPARE_BALANCE.js +++ b/src/energy-balance/PREPARE_BALANCE.js @@ -81,6 +81,8 @@ return [{ // session de nuit s'engage. night_hc_start_ms: snapshot.night_hc_start_ms, night_hc_end_ms: snapshot.night_hc_end_ms, + // Decision du jour pour le chauffe-eau (07/10) : « AAAA-MM-JJ|on|raison ». + ecs_nuit_jour: snapshot.ecs_nuit_jour, // Repris pour l'écriture des helpers, sans influencer le calcul. resourceUsable: resource.usable, diff --git a/src/water-heater/ARBITRATION.js b/src/water-heater/ARBITRATION.js new file mode 100644 index 0000000..35b86de --- /dev/null +++ b/src/water-heater/ARBITRATION.js @@ -0,0 +1,353 @@ +function regimeRank(regime) { + switch (regime) { + case 'off': return 0; + case 'hp_low': return 1; + case 'resistive_mid': return 2; + case 'boost_high': return 3; + default: return -1; + } +} + +function allowedRankForMode(mode) { + switch (mode) { + case 'off': return 0; + case 'green': return 1; + case 'auto': return 2; + case 'boost': return 3; + default: return 0; + } +} + +function round1(v) { + return Math.round(v * 10) / 10; +} + +const root = $input.first()?.json ?? {}; +const now = root.now ?? {}; +const nowMs = root.nowMs ?? null; +const normalized = root.normalized ?? {}; +const observation = root.observation ?? {}; +const energyModel = root.energyModel ?? {}; +const baseDecision = root.baseDecision ?? {}; + +let { + recommendedMode, + adviceColor, + state, + reason, + baseRecommendedMode, + baseAdviceColor, + baseState +} = baseDecision; + +reason = Array.isArray(reason) ? [...reason] : []; + +const { + advisorEnabled, + soc, + batteryCurrentA, + pvPowerAvg10mW, + houseAcPowerW, + todayRemainingKwh, + tomorrowKwh, + baseLoadRemainingKwh, + flexibleSafetyMarginKwh, + acLoadsMaxSafeW, + hotWaterDemand, + gridIsOn, + acInAvailable, + gridAvailable +} = normalized; + +const { + evcsPriorityActive, + energyPriorityReason, + waterHeaterSwitchOn, + observedPowerW, + observedRegime, + mismatchSinceAt, + mismatchSinceMsPrev, + forcedOffByAdvisorPrev, + forcedOffReasonPrev, + forcedOffAtPrev, + restoreSurplusW, + modeMismatchDelayMin, + sampleDurationMin, + energyDeltaKwh, + sampleIsUsable +} = observation; + +const { + targetSoc, + energyNeededToReachTargetKwh, + greenEnergyKwh, + autoEnergyKwh, + boostEnergyKwh, + projectedGreenSoc, + projectedAutoSoc, + projectedBoostSoc, + greenBudgetMarginKwh, + autoBudgetMarginKwh, + boostBudgetMarginKwh, + forecastStrength, + netPvHeadroomW, + pvCoversHouseNow, + pvStartedWell, + greenAllowed, + autoAllowed, + boostAllowed, + batteryNotDrainingHard +} = energyModel; + +if (advisorEnabled && evcsPriorityActive) { + if (recommendedMode === 'boost' || recommendedMode === 'auto') { + if (greenAllowed && batteryNotDrainingHard) { + recommendedMode = 'green'; + adviceColor = 'orange'; + state = 'evcs_priority_green_cap'; + reason.push(`Priorité EVCS active : chauffe-eau plafonné à GREEN (${energyPriorityReason || 'raison non précisée'})`); + } else { + recommendedMode = 'off'; + adviceColor = 'red'; + state = 'evcs_priority_off_cap'; + reason.push(`Priorité EVCS active : chauffe-eau déconseillé (${energyPriorityReason || 'raison non précisée'})`); + } + } else if (recommendedMode === 'green' && (!greenAllowed || !batteryNotDrainingHard)) { + recommendedMode = 'off'; + adviceColor = 'red'; + state = 'evcs_priority_green_blocked'; + reason.push(`Priorité EVCS active : GREEN devient lui-même trop coûteux (${energyPriorityReason || 'raison non précisée'})`); + } +} + +const selectedLoadKwh = + recommendedMode === 'green' ? greenEnergyKwh : + recommendedMode === 'auto' ? autoEnergyKwh : + recommendedMode === 'boost' ? boostEnergyKwh : + 0; + +const selectedProjectedSoc = + recommendedMode === 'green' ? projectedGreenSoc : + recommendedMode === 'auto' ? projectedAutoSoc : + recommendedMode === 'boost' ? projectedBoostSoc : + soc; + +const selectedBudgetMarginKwh = + recommendedMode === 'green' ? greenBudgetMarginKwh : + recommendedMode === 'auto' ? autoBudgetMarginKwh : + recommendedMode === 'boost' ? boostBudgetMarginKwh : + todayRemainingKwh - baseLoadRemainingKwh - flexibleSafetyMarginKwh - energyNeededToReachTargetKwh; + +const legacyAdviceOk = recommendedMode !== 'off'; + +const observedRank = regimeRank(observedRegime); +const allowedRank = allowedRankForMode(recommendedMode); + +const mismatchActive = + waterHeaterSwitchOn && + observedRank >= 0 && + allowedRank >= 0 && + observedRank > allowedRank; + +let mismatchSeverity = 'none'; +if (mismatchActive) { + if (recommendedMode === 'off') { + mismatchSeverity = 'critical'; + } else if (observedRank - allowedRank >= 2) { + mismatchSeverity = 'high'; + } else { + mismatchSeverity = 'medium'; + } +} + +const mismatchSinceMsEffective = mismatchActive ? (mismatchSinceMsPrev ?? nowMs) : null; +const mismatchSinceAtEffective = mismatchActive ? (mismatchSinceMsPrev ? mismatchSinceAt : now.stamp) : ''; +const mismatchDurationMin = mismatchActive && mismatchSinceMsEffective !== null ? Math.max(0, (nowMs - mismatchSinceMsEffective) / 60000) : 0; + +let pendingUserAction = ''; +if (mismatchActive) { + if (recommendedMode === 'off') { + pendingUserAction = evcsPriorityActive + ? 'Priorité EVCS : coupe temporairement le chauffe-eau ou laisse-le hors tension' + : 'Le chauffe-eau consomme alors qu\'il est déconseillé : coupe-le ou attends une meilleure fenêtre'; + } else if (recommendedMode === 'green') { + pendingUserAction = observedRegime === 'boost_high' + ? 'Passe le chauffe-eau en GREEN si possible, il semble en régime BOOST/résistance forte' + : 'Passe le chauffe-eau en GREEN si possible, il semble plus gourmand que prévu'; + } else if (recommendedMode === 'auto') { + pendingUserAction = 'Le chauffe-eau semble trop gourmand pour AUTO : repasse-le en AUTO modéré ou GREEN si possible'; + } +} + +const firstMismatchRun = mismatchActive && (mismatchSinceMsPrev === null || mismatchDurationMin < 1); + +const realConflictNow = + evcsPriorityActive && + ( + batteryCurrentA < -1 || + pvPowerAvg10mW < restoreSurplusW || + houseAcPowerW > acLoadsMaxSafeW || + recommendedMode === 'off' || + observedRegime === 'boost_high' || + (recommendedMode === 'green' && observedRegime === 'resistive_mid') + ); + +const forceOffNow = + advisorEnabled && + waterHeaterSwitchOn && + !forcedOffByAdvisorPrev && + mismatchActive && + mismatchDurationMin >= modeMismatchDelayMin && + realConflictNow; + +const restoreEnergyOk = + pvPowerAvg10mW >= restoreSurplusW && + batteryCurrentA >= -1 && + houseAcPowerW <= acLoadsMaxSafeW; + +const restoreNow = + advisorEnabled && + forcedOffByAdvisorPrev && + !waterHeaterSwitchOn && + ( + (!evcsPriorityActive && recommendedMode !== 'off') || + restoreEnergyOk + ); + +let forcedOffByAdvisor = forcedOffByAdvisorPrev; +let forcedOffReason = forcedOffReasonPrev; +let forcedOffAt = forcedOffAtPrev; +let protectionState = 'none'; +let powerSwitchAction = 'none'; +let powerSwitchDesiredOn = waterHeaterSwitchOn; + +if (forceOffNow) { + powerSwitchAction = 'turn_off'; + powerSwitchDesiredOn = false; + forcedOffByAdvisor = true; + forcedOffReason = `Coupure temporaire ECS: priorité EVCS + régime ${observedRegime} trop gourmand`; + forcedOffAt = now.stamp; + protectionState = 'forced_off_now_evcs_priority'; + state = 'forced_off_now_evcs_priority'; + adviceColor = 'red'; + reason.push(forcedOffReason); +} +else if (restoreNow) { + powerSwitchAction = 'turn_on'; + powerSwitchDesiredOn = true; + forcedOffByAdvisor = false; + forcedOffReason = ''; + protectionState = 'restored_after_evcs_priority'; + state = 'restored_after_evcs_priority'; + reason.push('Remise sous tension ECS : conflit EVCS terminé ou surplus redevenu suffisant'); +} +else if (forcedOffByAdvisorPrev && !waterHeaterSwitchOn) { + protectionState = 'forced_off_wait_restore'; + state = 'forced_off_wait_restore'; + reason.push(`ECS temporairement coupé par l'advisor (${forcedOffReasonPrev || 'raison non précisée'})`); +} +else if (mismatchActive && evcsPriorityActive) { + protectionState = 'watch_mismatch_evcs_priority'; + state = 'watch_mismatch_evcs_priority'; + reason.push(`Écart de régime ECS sous priorité EVCS depuis ${round1(mismatchDurationMin)} min`); +} + +if (!forceOffNow && !restoreNow && forcedOffByAdvisorPrev && waterHeaterSwitchOn) { + forcedOffByAdvisor = false; + forcedOffReason = ''; +} + +// ===================================================== +// JOUR FAIBLE : EAU CHAUDE EN HC DE NUIT (07/10/2026, option b du proprietaire) +// ===================================================== +// Le bilan decide une fois par jour (input_boolean.evcs_balance_ecs_nuit) : si +// le solaire prevu ne remplit pas la batterie pour la nuit, EDF sera ferme de +// toute facon -> le chauffe-eau est COUPE le jour et chauffe pendant une +// session reseau HC engagee (contacteur ferme), au tarif creux, jamais sur la +// batterie. Securite eau chaude : moins de ECS_EAU_FROIDE_KWH en 24 h -> il +// chauffe quand meme. Energie 24 h illisible -> aucune restriction (le +// comportement d'avant revient, jamais un ballon froid par panne de capteur). +// Jour normal : le chauffe-eau coupe par cette regle est remis sous tension. +const ECS_EAU_FROIDE_KWH = 1.5; +const { + ecsNuit = false, hcSessionCommitted = false, ecs24hKwh = NaN, offJourFaiblePrev = false, +} = normalized; +let offJourFaible = false; +let jourFaibleState = 'none'; +if (advisorEnabled && ecsNuit) { + const energieConnue = Number.isFinite(ecs24hKwh); + const eauFroide = energieConnue && ecs24hKwh < ECS_EAU_FROIDE_KWH; + const heuresCreusesEdf = hcSessionCommitted && gridIsOn; + const veutChauffer = !energieConnue || heuresCreusesEdf || eauFroide; + offJourFaible = true; + jourFaibleState = !energieConnue ? 'jour_faible_energie_illisible' + : heuresCreusesEdf ? 'jour_faible_chauffe_hc_edf' + : eauFroide ? 'jour_faible_eau_froide' + : 'jour_faible_coupe'; + if (veutChauffer !== waterHeaterSwitchOn) { + powerSwitchAction = veutChauffer ? 'turn_on' : 'turn_off'; + powerSwitchDesiredOn = veutChauffer; + } else { + powerSwitchAction = 'none'; + powerSwitchDesiredOn = waterHeaterSwitchOn; + } + forcedOffByAdvisor = false; + forcedOffReason = ''; + protectionState = jourFaibleState; + state = jourFaibleState; + reason.push( + jourFaibleState === 'jour_faible_chauffe_hc_edf' ? 'Jour faible : eau chaude en heures creuses sur EDF (session reseau engagee)' + : jourFaibleState === 'jour_faible_eau_froide' ? `Jour faible mais seulement ${round1(ecs24hKwh)} kWh en 24 h : chauffe pour garantir l eau chaude` + : jourFaibleState === 'jour_faible_energie_illisible' ? 'Jour faible, energie 24 h illisible : aucune restriction' + : 'Jour faible : chauffe-eau coupe, il chauffera en heures creuses sur EDF' + ); +} else if (advisorEnabled && offJourFaiblePrev && !waterHeaterSwitchOn) { + powerSwitchAction = 'turn_on'; + powerSwitchDesiredOn = true; + jourFaibleState = 'jour_normal_remise_sous_tension'; + protectionState = jourFaibleState; + state = jourFaibleState; + reason.push('Jour normal : chauffe-eau remis sous tension (il chauffe au solaire)'); +} + +return [{ + json: { + ...root, + meta: { + ...(root.meta ?? {}), + node: 'ecs_final_arbitration_and_protection' + }, + finalDecision: { + recommendedMode, + adviceColor, + state, + reason, + baseRecommendedMode, + baseAdviceColor, + baseState, + selectedLoadKwh, + selectedProjectedSoc, + selectedBudgetMarginKwh, + legacyAdviceOk, + mismatchActive, + mismatchSeverity, + mismatchSinceAtEffective, + mismatchSinceMsEffective, + mismatchDurationMin, + pendingUserAction, + firstMismatchRun, + realConflictNow, + forceOffNow, + restoreEnergyOk, + restoreNow, + forcedOffByAdvisor, + forcedOffReason, + forcedOffAt, + protectionState, + powerSwitchAction, + powerSwitchDesiredOn, + offJourFaible, + jourFaibleState + } + } +}]; \ No newline at end of file diff --git a/src/water-heater/BASE_DECISION.js b/src/water-heater/BASE_DECISION.js new file mode 100644 index 0000000..3b88b02 --- /dev/null +++ b/src/water-heater/BASE_DECISION.js @@ -0,0 +1,128 @@ +const root = $input.first()?.json ?? {}; +const normalized = root.normalized ?? {}; +const energyModel = root.energyModel ?? {}; + +const { + advisorEnabled, + hotWaterDemand, + soc, + pvPowerW, + pvPowerAvg10mW, + houseAcPowerW, + acLoadsMaxSafeW +} = normalized; + +const { + targetSoc, + forecastStrength, + netPvHeadroomW, + pvCoversHouseNow, + acSafe, + greenAllowed, + earlyBoostStrongReady, + earlyBoostMediumReady, + autoReady, + greenReady, + projectedGreenSoc, + projectedAutoSoc, + projectedBoostSoc, + greenBudgetMarginKwh, + autoBudgetMarginKwh, + boostBudgetMarginKwh +} = energyModel; + +let recommendedMode = 'off'; +let adviceColor = 'red'; +let state = 'off_waiting_pv'; +let reason = []; + +if (!advisorEnabled) { + recommendedMode = 'off'; + adviceColor = 'red'; + state = 'advisor_disabled'; + reason.push('Advisor chauffe-eau désactivé'); +} +else if (!acSafe) { + recommendedMode = 'off'; + adviceColor = 'red'; + state = 'off_ac_loads_too_high'; + reason.push(`Charges maison trop élevées pour ECS (${Math.round(houseAcPowerW)} W > seuil ${Math.round(acLoadsMaxSafeW)} W)`); +} +else if (earlyBoostStrongReady) { + recommendedMode = 'boost'; + adviceColor = 'green'; + state = 'boost_early_strong_forecast'; + reason.push('Boost tôt autorisé : forecast fort + SOC suffisant + PV net maison favorable'); +} +else if (earlyBoostMediumReady) { + recommendedMode = 'boost'; + adviceColor = 'orange'; + state = 'boost_early_medium_forecast'; + reason.push('Boost tôt autorisé : forecast moyen + SOC élevé + surplus net maison suffisant'); +} +else if (autoReady) { + recommendedMode = 'auto'; + adviceColor = 'green'; + state = 'auto_from_pv_net'; + reason.push('Mode auto recommandé : le PV couvre déjà la maison'); +} +else if (greenReady) { + recommendedMode = 'green'; + adviceColor = 'green'; + state = 'green_waiting_pv_ramp'; + reason.push('Mode green recommandé : démarrage PV détecté'); +} +else { + recommendedMode = 'off'; + adviceColor = 'red'; + if (!pvCoversHouseNow) { + state = 'wait_for_pv_to_cover_house'; + reason.push('Attente : le PV ne couvre pas encore la maison'); + } else if (!greenAllowed) { + state = 'off_budget_guard'; + reason.push('Attente : garde-fou budget / SOC fin de journée'); + } else { + state = 'wait_for_pv_or_soc'; + reason.push('Attente d\'un meilleur niveau PV / forecast / SOC'); + } +} + +reason.push(`Demande ECS: ${hotWaterDemand}`); +reason.push(`Forecast: ${forecastStrength}`); +reason.push(`SOC: ${Math.round(soc)}%`); +reason.push(`PV: ${Math.round(pvPowerW)} W | PV10: ${Math.round(pvPowerAvg10mW)} W`); +reason.push(`Maison: ${Math.round(houseAcPowerW)} W`); +reason.push(`Surplus net estimé: ${Math.round(netPvHeadroomW)} W`); + +if (recommendedMode === 'boost') { + reason.push(`Boost budget: marge ${boostBudgetMarginKwh.toFixed(2)} kWh | SOC fin ${projectedBoostSoc.toFixed(1)}%`); +} else if (recommendedMode === 'auto') { + reason.push(`Auto budget: marge ${autoBudgetMarginKwh.toFixed(2)} kWh | SOC fin ${projectedAutoSoc.toFixed(1)}%`); +} else if (recommendedMode === 'green') { + reason.push(`Green budget: marge ${greenBudgetMarginKwh.toFixed(2)} kWh | SOC fin ${projectedGreenSoc.toFixed(1)}%`); +} else { + reason.push(`Target fin de jour: ${targetSoc.toFixed(1)}%`); +} + +const baseRecommendedMode = recommendedMode; +const baseAdviceColor = adviceColor; +const baseState = state; + +return [{ + json: { + ...root, + meta: { + ...(root.meta ?? {}), + node: 'ecs_base_advisor_decision' + }, + baseDecision: { + recommendedMode, + adviceColor, + state, + reason, + baseRecommendedMode, + baseAdviceColor, + baseState + } + } +}]; \ No newline at end of file diff --git a/src/water-heater/BUILD_ACTIONS.js b/src/water-heater/BUILD_ACTIONS.js new file mode 100644 index 0000000..89f5e7a --- /dev/null +++ b/src/water-heater/BUILD_ACTIONS.js @@ -0,0 +1,148 @@ +const root = $input.first()?.json ?? {}; +const calls = root.ha_service_calls ?? {}; +const decision = root.decision ?? {}; + +function isObject(v) { + return v !== null && typeof v === 'object' && !Array.isArray(v); +} + +function isValidParisDateTime(v) { + return typeof v === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(v.trim()); +} + +function normalizeText(v) { + return (v ?? '').toString(); +} + +function shouldExecute(call) { + if (!isObject(call)) return false; + return call.execute !== false; +} + +function getBaseUrl() { + return ($env.HA_BASE_URL || 'http://10.10.10.17:8123').replace(/\/$/, ''); +} + +function getDebugEntityId(data) { + if (!isObject(data)) return ''; + if (typeof data.entity_id === 'string' && data.entity_id.trim()) return data.entity_id.trim(); + return ''; +} + +function sanitizeCall(key, call) { + if (!isObject(call)) return null; + if (!shouldExecute(call)) return null; + + const domain = normalizeText(call.domain).trim(); + const service = normalizeText(call.service).trim(); + const data = isObject(call.data) ? { ...call.data } : {}; + + if (!domain || !service) return null; + + // Garde-fous ciblés + if (domain === 'input_datetime' && service === 'set_datetime') { + if (!isValidParisDateTime(data.datetime)) return null; + } + + if (domain === 'input_text' && service === 'set_value') { + data.value = normalizeText(data.value); + } + + if (domain === 'input_select' && service === 'select_option') { + if (!normalizeText(data.option).trim()) return null; + } + + if (domain === 'input_number' && service === 'set_value') { + const n = Number(data.value); + if (!Number.isFinite(n)) return null; + data.value = n; + } + + if ((domain === 'switch' || domain === 'input_boolean') && !data.entity_id) { + return null; + } + + if (domain === 'notify') { + data.title = normalizeText(data.title); + data.message = normalizeText(data.message); + } + + const baseUrl = getBaseUrl(); + const debugEntityId = getDebugEntityId(data); + const debugUrl = `${baseUrl}/api/services/${domain}/${service}`; + + return { + json: { + action: key, + domain, + service, + data, + + // mini debug demandé + debug_action: key, + debug_entity_id: debugEntityId || '(none)', + debug_url: debugUrl, + + meta: { + source: 'water_heater_advisor', + timestamp_paris: root.timestamp_paris ?? null, + recommended_mode: decision.recommended_mode ?? null, + advice_color: decision.advice_color ?? null, + state: decision.state ?? null + } + } + }; +} + +// Ordre conseillé +const orderedKeys = [ + 'helper_recommended_mode', + 'helper_advice_color', + 'helper_state', + 'helper_reason', + 'helper_signature', + + 'helper_projected_soc', + 'helper_budget_margin', + 'helper_load_kwh', + + 'helper_legacy_advice_ok', + 'helper_green_allowed', + 'helper_auto_allowed', + 'helper_boost_allowed', + + 'helper_observed_power', + 'helper_observed_regime', + 'helper_pending_user_action', + 'helper_mismatch_since', + + 'helper_energy_prev_kwh', + 'helper_energy_prev_at', + + 'helper_forced_off_by_advisor', + 'helper_forced_off_reason', + 'helper_forced_off_at', + + 'water_heater_power_switch', + + 'logbook', + 'mobile_notification' +]; + +const actions = []; +const alreadySeen = new Set(); + +for (const key of orderedKeys) { + alreadySeen.add(key); + const built = sanitizeCall(key, calls[key]); + if (built) actions.push(built); +} + +// Sécurité si de nouveaux appels sont ajoutés plus tard +for (const [key, call] of Object.entries(calls)) { + if (alreadySeen.has(key)) continue; + const built = sanitizeCall(key, call); + if (built) actions.push(built); +} + +return actions; diff --git a/src/water-heater/ENERGY_MODEL.js b/src/water-heater/ENERGY_MODEL.js new file mode 100644 index 0000000..9b47fe0 --- /dev/null +++ b/src/water-heater/ENERGY_MODEL.js @@ -0,0 +1,207 @@ +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} + +const root = $input.first()?.json ?? {}; +const cfg = root.cfg ?? {}; +const normalized = root.normalized ?? {}; + +const { + soc, + batteryCurrentA, + pvPowerW, + pvPowerAvg10mW, + houseAcPowerW, + todayRemainingKwh, + tomorrowKwh, + advisorEnabled, + hotWaterDemand, + greenPowerW, + autoPowerW, + boostPowerW, + strongForecastKwh, + mediumForecastKwh, + strongPvNowW, + mediumPvNowW, + boostMinSocStrongForecastPct, + boostMinSocMediumForecastPct, + endOfDaySocGuard, + endOfDaySocTarget, + baseLoadRemainingKwh, + flexibleSafetyMarginKwh, + acLoadsMaxSafeW +} = normalized; + +const targetSoc = Math.max(endOfDaySocTarget, endOfDaySocGuard); +const batteryCapacityKwh = cfg.batteryUsableCapacityKwh ?? 17.5; + +const energyTable = + (cfg.energyByDemandKwh && cfg.energyByDemandKwh[hotWaterDemand]) || + (cfg.energyByDemandKwh && cfg.energyByDemandKwh.normal) || { + green: 1.4, + auto: 2.2, + boost: 3.1 + }; + +const greenEnergyKwh = energyTable.green; +const autoEnergyKwh = energyTable.auto; +const boostEnergyKwh = energyTable.boost; + +const energyNeededToReachTargetKwh = + Math.max(0, ((targetSoc - soc) / 100) * batteryCapacityKwh); + +function projectedEndSocAfterMode(loadKwh) { + const netKwh = + todayRemainingKwh - + baseLoadRemainingKwh - + flexibleSafetyMarginKwh - + loadKwh; + + const deltaSoc = (netKwh / batteryCapacityKwh) * 100; + return clamp(soc + deltaSoc, 0, 100); +} + +function budgetMarginKwh(loadKwh) { + return ( + todayRemainingKwh - + baseLoadRemainingKwh - + flexibleSafetyMarginKwh - + loadKwh - + energyNeededToReachTargetKwh + ); +} + +const projectedGreenSoc = projectedEndSocAfterMode(greenEnergyKwh); +const projectedAutoSoc = projectedEndSocAfterMode(autoEnergyKwh); +const projectedBoostSoc = projectedEndSocAfterMode(boostEnergyKwh); + +const greenBudgetMarginKwh = budgetMarginKwh(greenEnergyKwh); +const autoBudgetMarginKwh = budgetMarginKwh(autoEnergyKwh); +const boostBudgetMarginKwh = budgetMarginKwh(boostEnergyKwh); + +// 07/10/2026 : la force de la journee se juge sur le reste du JOUR. Avant, le +// lendemain suffisait (« || tomorrowKwh ») : le 06/10, journee de 14,5 kWh, le +// boost etait conseille parce que mercredi etait annonce fort. +const strongForecast = + todayRemainingKwh >= strongForecastKwh; + +const mediumForecast = + !strongForecast && + todayRemainingKwh >= mediumForecastKwh; + +const forecastStrength = + strongForecast ? 'strong' : + mediumForecast ? 'medium' : + 'weak'; + +const pv10mW = Number.isFinite(pvPowerAvg10mW) ? pvPowerAvg10mW : pvPowerW; +const netPvHeadroomW = Math.round(pv10mW - houseAcPowerW); +const pvCoversHouseNow = pvPowerW > houseAcPowerW; +const strongPvNow = pv10mW >= strongPvNowW || pvPowerW >= strongPvNowW; +const mediumPvNow = pv10mW >= mediumPvNowW || pvPowerW >= mediumPvNowW; + +const pvStartedWell = + mediumPvNow && + pvCoversHouseNow && + netPvHeadroomW >= Math.max(300, Math.round(greenPowerW * 0.5)); + +const acSafe = acLoadsMaxSafeW <= 0 ? true : houseAcPowerW <= acLoadsMaxSafeW; +const batteryNotDrainingHard = batteryCurrentA >= -2; + +const greenAllowed = + advisorEnabled && + acSafe && + ( + projectedGreenSoc >= targetSoc - 6 || + greenBudgetMarginKwh >= -1.0 || + soc >= targetSoc + ); + +const autoAllowed = + advisorEnabled && + acSafe && + ( + autoBudgetMarginKwh >= 0 || + projectedAutoSoc >= targetSoc || + (strongForecast && projectedAutoSoc >= targetSoc - 2) + ); + +const boostAllowedByBudget = + advisorEnabled && + acSafe && + ( + projectedBoostSoc >= targetSoc || + boostBudgetMarginKwh >= 0 || + (strongForecast && projectedBoostSoc >= targetSoc - 5) || + (strongForecast && boostBudgetMarginKwh >= -1.0) + ); + +const earlyBoostStrongReady = + boostAllowedByBudget && + soc >= boostMinSocStrongForecastPct && + pvStartedWell && + (strongForecast || strongPvNow); + +const earlyBoostMediumReady = + boostAllowedByBudget && + mediumForecast && + soc >= boostMinSocMediumForecastPct && + pvStartedWell && + netPvHeadroomW >= greenPowerW; + +const autoReady = + autoAllowed && + mediumPvNow && + pvCoversHouseNow && + netPvHeadroomW >= 0; + +const greenReady = + greenAllowed && + ( + pv10mW >= greenPowerW || + (pvPowerW >= greenPowerW && pvCoversHouseNow) + ); + +const boostAllowed = earlyBoostStrongReady || earlyBoostMediumReady; + +return [{ + json: { + ...root, + meta: { + ...(root.meta ?? {}), + node: 'ecs_energy_model_and_early_boost_signals' + }, + energyModel: { + targetSoc, + batteryCapacityKwh, + greenEnergyKwh, + autoEnergyKwh, + boostEnergyKwh, + energyNeededToReachTargetKwh, + projectedGreenSoc, + projectedAutoSoc, + projectedBoostSoc, + greenBudgetMarginKwh, + autoBudgetMarginKwh, + boostBudgetMarginKwh, + forecastStrength, + strongForecast, + mediumForecast, + strongPvNow, + mediumPvNow, + netPvHeadroomW, + pvCoversHouseNow, + pvStartedWell, + acSafe, + batteryNotDrainingHard, + greenAllowed, + autoAllowed, + boostAllowedByBudget, + boostAllowed, + earlyBoostStrongReady, + earlyBoostMediumReady, + autoReady, + greenReady + } + } +}]; \ No newline at end of file diff --git a/src/water-heater/HA_SNAPSHOT_TEMPLATE.json.tpl b/src/water-heater/HA_SNAPSHOT_TEMPLATE.json.tpl new file mode 100644 index 0000000..ebb0537 --- /dev/null +++ b/src/water-heater/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -0,0 +1,91 @@ +{#- 07/10/2026 : prevision lue comme le bilan (input_select.evcs_balance_forecast_source). + VRM : reste du jour = heures restantes de la courbe horaire (heure en cours au + prorata), demain = somme de la courbe de demain. Courbe absente ou + malformee -> repli sur Forecast.Solar. -#} +{%- set src = states('input_select.evcs_balance_forecast_source') -%} +{%- set vt = states('input_text.evcs_balance_vrm_today').split(',') -%} +{%- set vd = states('input_text.evcs_balance_vrm_tomorrow').split(',') -%} +{%- set p = namespace(reste=0, demain=0, ok=(src == 'vrm' and vt | length == 24 and vd | length == 24)) -%} +{%- if p.ok -%} + {%- for i in range(now().hour, 24) -%} + {%- set v = vt[i] | float(0) -%} + {%- set p.reste = p.reste + (v * (1 - now().minute / 60) if i == now().hour else v) -%} + {%- endfor -%} + {%- for v in vd -%}{%- set p.demain = p.demain + (v | float(0)) -%}{%- endfor -%} +{%- endif -%} +{{ { + "soc_percent": states("sensor.victron_battery_soc"), + "battery_current_a": states("sensor.victron_battery_current"), + "pv_power_w": states("sensor.victron_pv_power_system"), + "pv_power_10m_avg_w": states("sensor.victron_pv_power_10m_avg"), + "house_ac_power_w": states("sensor.victron_consumption_power_l1"), + "grid_power_w": states("sensor.victron_grid_power_l1"), + "grid_state": states("switch.edf_general_ok_switch"), + "victron_acin1_available": states("sensor.victron_ac_in1_available"), + + "today_remaining_kwh": (p.reste | round(2)) if p.ok else states("sensor.energy_production_today_remaining"), + "tomorrow_kwh": (p.demain | round(2)) if p.ok else states("sensor.energy_production_tomorrow"), + "forecast_source": "vrm" if p.ok else "forecast_solar", + + "ecs_nuit": states("input_boolean.evcs_balance_ecs_nuit"), + "ecs_nuit_jour": states("input_text.evcs_balance_ecs_nuit_jour"), + "hc_session_committed": states("input_boolean.victron_evcs_night_grid_session_committed"), + "ecs_24h_kwh": states("sensor.evcs_chauffe_eau_24h_kwh"), + "off_jour_faible_prev": states("input_boolean.victron_water_heater_off_jour_faible"), + + "advisor_enabled": states("input_boolean.victron_water_heater_advisor_enabled"), + "notifications_enabled": states("input_boolean.victron_water_heater_notifications"), + + "hot_water_demand": states("input_select.victron_water_heater_hot_water_demand"), + "morning_boost_priority_enabled": states("input_boolean.victron_water_heater_morning_boost_priority_enabled"), + "morning_boost_latest_time": states("input_datetime.victron_water_heater_morning_boost_latest_time"), + "late_red_time": states("input_datetime.victron_water_heater_late_red_time"), + + "green_power_w": states("input_number.victron_water_heater_green_power_w"), + "auto_power_w": states("input_number.victron_water_heater_auto_power_w"), + "boost_power_w": states("input_number.victron_water_heater_boost_power_w"), + + "strong_forecast_kwh": states("input_number.victron_water_heater_strong_forecast_kwh"), + "medium_forecast_kwh": states("input_number.victron_water_heater_medium_forecast_kwh"), + "strong_pv_now_w": states("input_number.victron_water_heater_strong_pv_now_w"), + "medium_pv_now_w": states("input_number.victron_water_heater_medium_pv_now_w"), + + "morning_boost_min_soc_strong_day": states("input_number.victron_water_heater_morning_boost_min_soc_strong_day"), + "boost_min_soc_medium_day": states("input_number.victron_water_heater_boost_min_soc_medium_day"), + "end_of_day_soc_guard": states("input_number.victron_water_heater_end_of_day_soc_guard"), + + "end_of_day_soc_target": states("input_number.victron_end_of_day_soc_target"), + "base_load_remaining_kwh": states("input_number.victron_base_load_remaining_kwh"), + "flexible_safety_margin_kwh": states("input_number.victron_flexible_safety_margin_kwh"), + "ac_loads_max_safe_w": states("input_number.victron_ac_loads_max_safe_w"), + + "day_start_time": states("input_datetime.victron_flexible_day_start_time"), + "day_stop_time": states("input_datetime.victron_flexible_day_stop_time"), + + "last_signature": states("input_text.victron_water_heater_advice_last_signature"), + + "evcs_priority_active": states("input_boolean.victron_evcs_priority_active"), + "energy_priority_mode": states("input_select.victron_energy_priority_mode"), + "energy_priority_reason": states("input_text.victron_energy_priority_reason"), + "evcs_priority_until": states("input_datetime.victron_evcs_priority_until"), + + "water_heater_switch": states("switch.tt_chauffe_eau_pleumeur_switch"), + "water_heater_energy_total_kwh": states("sensor.tt_chauffe_eau_pleumeur_energie_totale"), + + "energy_total_prev_kwh": states("input_number.victron_water_heater_energy_total_prev_kwh"), + "energy_prev_at": states("input_datetime.victron_water_heater_energy_prev_at"), + + "observed_regime_helper": states("input_text.victron_water_heater_observed_regime"), + "observed_power_helper_w": states("input_number.victron_water_heater_observed_power_w"), + + "mode_mismatch_since": states("input_datetime.victron_water_heater_mode_mismatch_since"), + + "forced_off_by_advisor": states("input_boolean.victron_water_heater_forced_off_by_advisor"), + "forced_off_reason": states("input_text.victron_water_heater_forced_off_reason"), + "forced_off_at": states("input_datetime.victron_water_heater_forced_off_at"), + + "pending_user_action_prev": states("input_text.victron_water_heater_pending_user_action"), + + "restore_surplus_w": states("input_number.victron_water_heater_restore_surplus_w"), + "mode_mismatch_delay_min": states("input_number.victron_water_heater_mode_mismatch_delay_min") +} | tojson }} diff --git a/src/water-heater/NORMALIZE.js b/src/water-heater/NORMALIZE.js new file mode 100644 index 0000000..e43aafd --- /dev/null +++ b/src/water-heater/NORMALIZE.js @@ -0,0 +1,250 @@ +function num(v, fallback = NaN) { + const n = Number.parseFloat(v); + return Number.isFinite(n) ? n : fallback; +} + +function low(v) { + return (v ?? '').toString().trim().toLowerCase(); +} + +function parseMaybeJson(v) { + if (typeof v !== 'string') return v; + try { + return JSON.parse(v); + } catch { + return v; + } +} + +function extractSnapshot(input) { + if (typeof input === 'string') return parseMaybeJson(input); + if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data); + if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body); + if (input?.data && typeof input.data === 'object') return input.data; + if (input?.body && typeof input.body === 'object') return input.body; + return input ?? {}; +} + +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} + +function to2(n) { + return String(n).padStart(2, '0'); +} + +function getParisNowParts() { + const fmt = new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + fmt.formatToParts(new Date()) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour); + const date = `${parts.year}-${parts.month}-${parts.day}`; + const stamp = `${date} ${to2(hour)}:${parts.minute}:${parts.second}`; + + return { + year: Number(parts.year), + month: Number(parts.month), + day: Number(parts.day), + hour, + minute: Number(parts.minute), + second: Number(parts.second), + date, + stamp, + minNow: hour * 60 + Number(parts.minute), + }; +} + +function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') { + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + dtf.formatToParts(new Date(utcTs)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const asIfUtc = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour) === 24 ? 0 : Number(parts.hour), + Number(parts.minute), + Number(parts.second) + ); + + const d = new Date(utcTs); + const actualUtc = Date.UTC( + d.getUTCFullYear(), + d.getUTCMonth(), + d.getUTCDate(), + d.getUTCHours(), + d.getUTCMinutes(), + d.getUTCSeconds() + ); + + return asIfUtc - actualUtc; +} + +function parseParisStampToMs(s) { + const text = (s ?? '').toString().trim(); + const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/); + if (!m) return null; + + const y = Number(m[1]); + const mo = Number(m[2]); + const d = Number(m[3]); + const h = Number(m[4]); + const mi = Number(m[5]); + const se = Number(m[6]); + + if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null; + + const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se); + const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris'); + let ts = guessUtc - off1; + const off2 = getTzOffsetMs(ts, 'Europe/Paris'); + + if (off2 !== off1) ts = guessUtc - off2; + return ts; +} + +function cleanStateText(v) { + const s = (v ?? '').toString().trim(); + const x = s.toLowerCase(); + return ['unknown', 'unavailable', 'none', 'null'].includes(x) ? '' : s; +} + +const cfg = { + mobileNotifyDomain: 'notify', + mobileNotifyService: 'mobile_app_iphone_16_de_tt', + batteryUsableCapacityKwh: 17.5, + sampleWindowMinMin: 2, + sampleWindowMinMax: 15, + energyByDemandKwh: { + low: { green: 0.9, auto: 1.4, boost: 2.0 }, + normal: { green: 1.4, auto: 2.2, boost: 3.1 }, + high: { green: 2.2, auto: 3.4, boost: 4.6 }, + three_showers: { green: 3.0, auto: 4.4, boost: 5.8 } + } +}; + +const raw = $input.first()?.json ?? {}; +const input = extractSnapshot(raw); +const now = getParisNowParts(); +const nowMs = Date.now(); + +const soc = clamp(num(input.soc_percent, 0), 0, 100); +const batteryCurrentA = num(input.battery_current_a, 0); +const pvPowerW = Math.max(0, num(input.pv_power_w, 0)); +const pvPowerAvg10mW = Math.max(0, num(input.pv_power_10m_avg_w, pvPowerW)); +const houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0)); +const gridPowerW = num(input.grid_power_w, 0); +const gridIsOn = low(input.grid_state || 'off') === 'on'; +const acInAvailable = num(input.victron_acin1_available, 0) === 1; +const gridAvailable = gridIsOn || acInAvailable; +const todayRemainingKwh = Math.max(0, num(input.today_remaining_kwh, 0)); +const tomorrowKwh = Math.max(0, num(input.tomorrow_kwh, 0)); +const advisorEnabled = low(input.advisor_enabled || 'off') === 'on'; +const notificationsEnabled = low(input.notifications_enabled || 'off') === 'on'; + +const hotWaterDemandRaw = low(input.hot_water_demand || 'normal'); +const hotWaterDemand = ['low', 'normal', 'high', 'three_showers'].includes(hotWaterDemandRaw) + ? hotWaterDemandRaw + : 'normal'; + +const greenPowerW = Math.max(0, num(input.green_power_w, 620)); +const autoPowerW = Math.max(0, num(input.auto_power_w, 1200)); +const boostPowerW = Math.max(0, num(input.boost_power_w, 2000)); +const strongForecastKwh = Math.max(0, num(input.strong_forecast_kwh, 18)); +const mediumForecastKwh = Math.max(0, num(input.medium_forecast_kwh, 10)); +const strongPvNowW = Math.max(0, num(input.strong_pv_now_w, 2500)); +const mediumPvNowW = Math.max(0, num(input.medium_pv_now_w, 1500)); +const boostMinSocStrongForecastPct = clamp(num(input.morning_boost_min_soc_strong_day, 35), 0, 100); +const boostMinSocMediumForecastPct = clamp(num(input.boost_min_soc_medium_day, 50), 0, 100); +const endOfDaySocGuard = clamp(num(input.end_of_day_soc_guard, 85), 0, 100); +const endOfDaySocTarget = clamp(num(input.end_of_day_soc_target, 85), 0, 100); +const baseLoadRemainingKwh = Math.max(0, num(input.base_load_remaining_kwh, 0)); +const flexibleSafetyMarginKwh = Math.max(0, num(input.flexible_safety_margin_kwh, 0)); +const acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 8000)); +const lastSignature = cleanStateText(input.last_signature); +// Jour faible (07/10/2026) : decision du bilan, session reseau HC engagee, +// energie du chauffe-eau sur 24 h (service d'historisation), propriete de la coupure. +const ecsNuit = low(input.ecs_nuit) === 'on'; +const hcSessionCommitted = low(input.hc_session_committed) === 'on'; +const ecs24hKwh = num(input.ecs_24h_kwh, NaN); +const offJourFaiblePrev = low(input.off_jour_faible_prev) === 'on'; +const forecastSource = cleanStateText(input.forecast_source) || 'forecast_solar'; + +return [{ + json: { + meta: { + workflow: 'water_heater_advisor', + architecture: 'ecs_6nodes_simple_early_boost', + node: 'ecs_normalize_inputs', + timestamp_paris: now.stamp + }, + cfg, + now, + nowMs, + input, + normalized: { + soc, + batteryCurrentA, + pvPowerW, + pvPowerAvg10mW, + houseAcPowerW, + gridPowerW, + gridIsOn, + acInAvailable, + gridAvailable, + todayRemainingKwh, + tomorrowKwh, + advisorEnabled, + notificationsEnabled, + hotWaterDemand, + greenPowerW, + autoPowerW, + boostPowerW, + strongForecastKwh, + mediumForecastKwh, + strongPvNowW, + mediumPvNowW, + boostMinSocStrongForecastPct, + boostMinSocMediumForecastPct, + endOfDaySocGuard, + endOfDaySocTarget, + baseLoadRemainingKwh, + flexibleSafetyMarginKwh, + acLoadsMaxSafeW, + lastSignature, + ecsNuit, + hcSessionCommitted, + ecs24hKwh, + offJourFaiblePrev, + forecastSource + } + } +}]; \ No newline at end of file diff --git a/src/water-heater/OBSERVATION.js b/src/water-heater/OBSERVATION.js new file mode 100644 index 0000000..e53045b --- /dev/null +++ b/src/water-heater/OBSERVATION.js @@ -0,0 +1,207 @@ +function num(v, fallback = NaN) { + const n = Number.parseFloat(v); + return Number.isFinite(n) ? n : fallback; +} + +function low(v) { + return (v ?? '').toString().trim().toLowerCase(); +} + +function cleanStateText(v) { + const s = (v ?? '').toString().trim(); + const x = s.toLowerCase(); + return ['unknown', 'unavailable', 'none', 'null'].includes(x) ? '' : s; +} + +function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') { + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + dtf.formatToParts(new Date(utcTs)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const asIfUtc = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour) === 24 ? 0 : Number(parts.hour), + Number(parts.minute), + Number(parts.second) + ); + + const d = new Date(utcTs); + const actualUtc = Date.UTC( + d.getUTCFullYear(), + d.getUTCMonth(), + d.getUTCDate(), + d.getUTCHours(), + d.getUTCMinutes(), + d.getUTCSeconds() + ); + + return asIfUtc - actualUtc; +} + +function parseParisStampToMs(s) { + const text = (s ?? '').toString().trim(); + const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/); + if (!m) return null; + + const y = Number(m[1]); + const mo = Number(m[2]); + const d = Number(m[3]); + const h = Number(m[4]); + const mi = Number(m[5]); + const se = Number(m[6]); + + if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null; + + const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se); + const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris'); + let ts = guessUtc - off1; + const off2 = getTzOffsetMs(ts, 'Europe/Paris'); + + if (off2 !== off1) ts = guessUtc - off2; + return ts; +} + +function cleanParisStamp(v) { + const s = cleanStateText(v); + return parseParisStampToMs(s) !== null ? s : ''; +} + +function minutesSince(ts, nowMs) { + if (ts === null || nowMs === null) return null; + return Math.max(0, (nowMs - ts) / 60000); +} + +const root = $input.first()?.json ?? {}; +const cfg = root.cfg ?? {}; +const now = root.now ?? {}; +const nowMs = root.nowMs ?? null; +const input = root.input ?? {}; +const normalized = root.normalized ?? {}; + +const { greenPowerW, autoPowerW, boostPowerW } = normalized; + +const evcsPriorityActiveRaw = low(input.evcs_priority_active || 'off') === 'on'; +const energyPriorityMode = cleanStateText(input.energy_priority_mode || 'normal').toLowerCase() || 'normal'; +const energyPriorityReason = cleanStateText(input.energy_priority_reason); +const evcsPriorityUntilAt = cleanParisStamp(input.evcs_priority_until); +const evcsPriorityUntilMs = parseParisStampToMs(evcsPriorityUntilAt); + +const evcsPriorityActive = + evcsPriorityActiveRaw && + energyPriorityMode === 'evcs_priority' && + ( + evcsPriorityUntilMs === null || + (nowMs !== null && evcsPriorityUntilMs > nowMs + 1000) + ); + +const waterHeaterSwitchOn = low(input.water_heater_switch || 'off') === 'on'; +const waterHeaterEnergyTotalKwh = Math.max(0, num(input.water_heater_energy_total_kwh, 0)); +const energyPrevKwh = num(input.energy_total_prev_kwh, NaN); +const energyPrevAt = cleanParisStamp(input.energy_prev_at); +const energyPrevMs = parseParisStampToMs(energyPrevAt); +const observedPowerPrevW = Math.max(0, num(input.observed_power_helper_w, 0)); +const observedRegimePrev = cleanStateText(input.observed_regime_helper).toLowerCase(); +const mismatchSinceAt = cleanParisStamp(input.mode_mismatch_since); +const mismatchSinceMsPrev = parseParisStampToMs(mismatchSinceAt); +const forcedOffByAdvisorPrev = low(input.forced_off_by_advisor || 'off') === 'on'; +const forcedOffReasonPrev = cleanStateText(input.forced_off_reason); +const forcedOffAtPrev = cleanParisStamp(input.forced_off_at); +const forcedOffAtPrevMs = parseParisStampToMs(forcedOffAtPrev); +const pendingUserActionPrev = cleanStateText(input.pending_user_action_prev); +const restoreSurplusW = Math.max(0, num(input.restore_surplus_w, 2500)); +const modeMismatchDelayMin = Math.max(1, num(input.mode_mismatch_delay_min, 15)); + +const sampleDurationMin = minutesSince(energyPrevMs, nowMs); +const energyDeltaKwh = + Number.isFinite(energyPrevKwh) + ? Math.max(0, waterHeaterEnergyTotalKwh - energyPrevKwh) + : NaN; + +const sampleIsUsable = + Number.isFinite(sampleDurationMin) && + sampleDurationMin >= (cfg.sampleWindowMinMin ?? 2) && + sampleDurationMin <= (cfg.sampleWindowMinMax ?? 15) && + Number.isFinite(energyDeltaKwh); + +let observedPowerW = 0; +if (sampleIsUsable) { + observedPowerW = Math.max(0, (energyDeltaKwh * 60000) / sampleDurationMin); +} else if (waterHeaterSwitchOn && observedPowerPrevW > 0) { + observedPowerW = observedPowerPrevW; +} + +const offThresholdW = Math.max(80, greenPowerW * 0.25); +const hpUpperW = (greenPowerW + autoPowerW) / 2; +const resistiveUpperW = (autoPowerW + boostPowerW) / 2; + +let observedRegime = 'unknown'; +if (!waterHeaterSwitchOn) { + observedRegime = 'off'; +} else if (!sampleIsUsable && observedRegimePrev) { + observedRegime = observedRegimePrev; +} else if (observedPowerW < offThresholdW) { + observedRegime = 'off'; +} else if (observedPowerW < hpUpperW) { + observedRegime = 'hp_low'; +} else if (observedPowerW < resistiveUpperW) { + observedRegime = 'resistive_mid'; +} else { + observedRegime = 'boost_high'; +} + +return [{ + json: { + ...root, + meta: { + ...(root.meta ?? {}), + node: 'ecs_evcs_and_water_heater_observation' + }, + observation: { + evcsPriorityActiveRaw, + energyPriorityMode, + energyPriorityReason, + evcsPriorityUntilAt, + evcsPriorityUntilMs, + evcsPriorityActive, + waterHeaterSwitchOn, + waterHeaterEnergyTotalKwh, + energyPrevKwh, + energyPrevAt, + energyPrevMs, + observedPowerPrevW, + observedRegimePrev, + mismatchSinceAt, + mismatchSinceMsPrev, + forcedOffByAdvisorPrev, + forcedOffReasonPrev, + forcedOffAtPrev, + forcedOffAtPrevMs, + pendingUserActionPrev, + restoreSurplusW, + modeMismatchDelayMin, + sampleDurationMin, + energyDeltaKwh, + sampleIsUsable, + observedPowerW, + observedRegime, + offThresholdW, + hpUpperW, + resistiveUpperW + } + } +}]; \ No newline at end of file diff --git a/src/water-heater/OUTPUT_BUILDER.js b/src/water-heater/OUTPUT_BUILDER.js new file mode 100644 index 0000000..f93fc98 --- /dev/null +++ b/src/water-heater/OUTPUT_BUILDER.js @@ -0,0 +1,612 @@ +function round1(v) { + return Math.round(v * 10) / 10; +} + +function round2(v) { + return Math.round(v * 100) / 100; +} + +function safeText(v, max = 255) { + return (v ?? '').toString().slice(0, max); +} + +function boolService(flag) { + return flag ? 'turn_on' : 'turn_off'; +} + +function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') { + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + dtf.formatToParts(new Date(utcTs)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const asIfUtc = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour) === 24 ? 0 : Number(parts.hour), + Number(parts.minute), + Number(parts.second) + ); + + const d = new Date(utcTs); + const actualUtc = Date.UTC( + d.getUTCFullYear(), + d.getUTCMonth(), + d.getUTCDate(), + d.getUTCHours(), + d.getUTCMinutes(), + d.getUTCSeconds() + ); + + return asIfUtc - actualUtc; +} + +function parseParisStampToMs(s) { + const text = (s ?? '').toString().trim(); + const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/); + if (!m) return null; + + const y = Number(m[1]); + const mo = Number(m[2]); + const d = Number(m[3]); + const h = Number(m[4]); + const mi = Number(m[5]); + const se = Number(m[6]); + + if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null; + + const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se); + const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris'); + let ts = guessUtc - off1; + const off2 = getTzOffsetMs(ts, 'Europe/Paris'); + + if (off2 !== off1) ts = guessUtc - off2; + return ts; +} + +const root = $input.first()?.json ?? {}; +const cfg = root.cfg ?? {}; +const now = root.now ?? {}; +const input = root.input ?? {}; +const normalized = root.normalized ?? {}; +const observation = root.observation ?? {}; +const energyModel = root.energyModel ?? {}; +const finalDecision = root.finalDecision ?? {}; + +const { + soc, + batteryCurrentA, + pvPowerW, + pvPowerAvg10mW, + houseAcPowerW, + gridPowerW, + gridIsOn, + acInAvailable, + gridAvailable, + todayRemainingKwh, + tomorrowKwh, + advisorEnabled, + notificationsEnabled, + hotWaterDemand, + greenPowerW, + autoPowerW, + boostPowerW, + strongForecastKwh, + mediumForecastKwh, + strongPvNowW, + mediumPvNowW, + boostMinSocStrongForecastPct, + boostMinSocMediumForecastPct, + endOfDaySocGuard, + endOfDaySocTarget, + baseLoadRemainingKwh, + flexibleSafetyMarginKwh, + acLoadsMaxSafeW, + lastSignature +} = normalized; + +const { + evcsPriorityActive, + energyPriorityMode, + energyPriorityReason, + evcsPriorityUntilAt, + waterHeaterSwitchOn, + waterHeaterEnergyTotalKwh, + restoreSurplusW, + modeMismatchDelayMin, + sampleDurationMin, + energyDeltaKwh, + sampleIsUsable, + observedPowerW, + observedRegime +} = observation; + +const { + targetSoc, + batteryCapacityKwh, + greenEnergyKwh, + autoEnergyKwh, + boostEnergyKwh, + energyNeededToReachTargetKwh, + projectedGreenSoc, + projectedAutoSoc, + projectedBoostSoc, + greenBudgetMarginKwh, + autoBudgetMarginKwh, + boostBudgetMarginKwh, + forecastStrength, + strongForecast, + mediumForecast, + strongPvNow, + mediumPvNow, + netPvHeadroomW, + pvCoversHouseNow, + pvStartedWell, + acSafe, + batteryNotDrainingHard, + greenAllowed, + autoAllowed, + boostAllowedByBudget, + boostAllowed, + earlyBoostStrongReady, + earlyBoostMediumReady, + autoReady, + greenReady +} = energyModel; + +let { + recommendedMode, + adviceColor, + state, + reason, + baseRecommendedMode, + baseAdviceColor, + baseState, + selectedLoadKwh, + selectedProjectedSoc, + selectedBudgetMarginKwh, + legacyAdviceOk, + mismatchActive, + mismatchSeverity, + mismatchSinceAtEffective, + mismatchSinceMsEffective, + mismatchDurationMin, + pendingUserAction, + firstMismatchRun, + realConflictNow, + forceOffNow, + restoreEnergyOk, + restoreNow, + forcedOffByAdvisor, + forcedOffReason, + forcedOffAt, + protectionState, + powerSwitchAction, + powerSwitchDesiredOn, + offJourFaible = false +} = finalDecision; + +reason = Array.isArray(reason) ? [...reason] : []; + +const forcedOffAtSafe = forcedOffAt && parseParisStampToMs(forcedOffAt) !== null ? forcedOffAt : ''; +const forcedOffReasonSafe = forcedOffByAdvisor ? safeText(forcedOffReason, 255) : ''; +const pendingUserActionSafe = safeText(pendingUserAction, 255); + +if (reason.length === 0) { + if (recommendedMode === 'boost') reason.push('Boost recommandé'); + else if (recommendedMode === 'auto') reason.push('Mode auto recommandé'); + else if (recommendedMode === 'green') reason.push('Mode green recommandé'); + else reason.push('Chauffe-eau déconseillé pour le moment'); +} + +reason.push(`Demande ECS: ${hotWaterDemand}`); +reason.push(`Conseil final: ${recommendedMode}`); +reason.push(`Conseil initial: ${baseRecommendedMode}`); +reason.push(`Couleur conseil: ${adviceColor}`); +reason.push(`Forecast du jour: ${round1(todayRemainingKwh)} kWh`); +reason.push(`Production demain: ${round1(tomorrowKwh)} kWh`); +reason.push(`Classe forecast: ${forecastStrength}`); +reason.push(`PV actuel: ${Math.round(pvPowerW)} W | PV10: ${Math.round(pvPowerAvg10mW)} W`); +reason.push(`Charges AC maison: ${Math.round(houseAcPowerW)} W`); +reason.push(`Surplus net estimé: ${Math.round(netPvHeadroomW)} W`); +reason.push(`PV couvre maison: ${pvCoversHouseNow ? 'oui' : 'non'} | PV bien démarré: ${pvStartedWell ? 'oui' : 'non'}`); +reason.push(`Batterie: SOC ${round1(soc)}% | courant ${round1(batteryCurrentA)} A`); +reason.push(`Réseau: cmd=${gridIsOn ? 'ON' : 'OFF'} | acin=${acInAvailable ? 'OK' : 'KO'} | dispo=${gridAvailable ? 'oui' : 'non'}`); +reason.push(`Target fin de jour: ${round1(targetSoc)}%`); +reason.push(`Base load restante: ${round1(baseLoadRemainingKwh)} kWh`); +reason.push(`Marge sécurité: ${round1(flexibleSafetyMarginKwh)} kWh`); +reason.push(`Green ${round1(greenEnergyKwh)}kWh → SOC fin ${round1(projectedGreenSoc)}% | marge ${round1(greenBudgetMarginKwh)}kWh`); +reason.push(`Auto ${round1(autoEnergyKwh)}kWh → SOC fin ${round1(projectedAutoSoc)}% | marge ${round1(autoBudgetMarginKwh)}kWh`); +reason.push(`Boost ${round1(boostEnergyKwh)}kWh → SOC fin ${round1(projectedBoostSoc)}% | marge ${round1(boostBudgetMarginKwh)}kWh`); +reason.push(`Régime observé: ${observedRegime} | puissance ≈ ${Math.round(observedPowerW)} W | delta ${sampleIsUsable ? round2(energyDeltaKwh) : 'n/a'} kWh/${sampleIsUsable ? round1(sampleDurationMin) : 'n/a'} min`); +reason.push(`Prio EVCS: ${evcsPriorityActive ? 'ON' : 'OFF'}${energyPriorityReason ? ` | ${energyPriorityReason}` : ''}`); +reason.push(`Mismatch: ${mismatchActive ? 'oui' : 'non'} | sévérité=${mismatchSeverity} | durée=${round1(mismatchDurationMin)} min`); +reason.push(`Protection ECS: ${protectionState}`); +if (pendingUserActionSafe) reason.push(`Action attendue: ${pendingUserActionSafe}`); + +const shortReason = reason.join(' | ').slice(0, 255); + +const signature = [ + now.date, + `d=${hotWaterDemand}`, + `m=${recommendedMode}`, + `base=${baseRecommendedMode}`, + `c=${adviceColor}`, + `fs=${strongForecast ? '1' : '0'}`, + `fm=${mediumForecast ? '1' : '0'}`, + `pb=${pvStartedWell ? '1' : '0'}`, + `soc=${round1(soc)}`, + `pv=${Math.round(pvPowerW)}`, + `pv10=${Math.round(pvPowerAvg10mW)}`, + `npv=${Math.round(netPvHeadroomW)}`, + `obs=${observedRegime}`, + `prio=${evcsPriorityActive ? '1' : '0'}`, + `cut=${forcedOffByAdvisor ? '1' : '0'}` +].join('|'); + +const signatureChanged = signature !== lastSignature; + +let notifyWanted = false; +let notifyTitle = 'Water Heater Advisor'; +let notifyMessage = ''; + +if (notificationsEnabled) { + if (forceOffNow) { + notifyWanted = true; + notifyTitle = 'ECS coupé temporairement'; + notifyMessage = forcedOffReasonSafe || 'Le chauffe-eau a été coupé temporairement'; + } else if (restoreNow) { + notifyWanted = true; + notifyTitle = 'ECS remis sous tension'; + notifyMessage = 'Le chauffe-eau est réalimenté car la priorité EVCS est terminée ou le surplus est redevenu suffisant.'; + } else if (firstMismatchRun && pendingUserActionSafe) { + notifyWanted = true; + notifyTitle = 'Action utilisateur ECS conseillée'; + notifyMessage = pendingUserActionSafe; + } else if (signatureChanged) { + notifyWanted = true; + notifyTitle = `Water Heater Advisor: ${recommendedMode.toUpperCase()} / ${adviceColor.toUpperCase()}`; + notifyMessage = `Demande=${hotWaterDemand} | régime=${observedRegime} | SOC fin estimé=${round1(selectedProjectedSoc)}% | marge=${round1(selectedBudgetMarginKwh)} kWh`; + } +} +const BUDGET_MARGIN_MIN = -50; +const BUDGET_MARGIN_MAX = 50; +const result = { + timestamp_paris: now.stamp, + inputs: { + soc_percent: soc, + battery_current_a: batteryCurrentA, + pv_power_w: pvPowerW, + pv_power_10m_avg_w: pvPowerAvg10mW, + house_ac_power_w: houseAcPowerW, + grid_power_w: gridPowerW, + grid_state: gridIsOn ? 'on' : 'off', + victron_acin1_available: acInAvailable ? 1 : 0, + today_remaining_kwh: round2(todayRemainingKwh), + tomorrow_kwh: round2(tomorrowKwh), + advisor_enabled: advisorEnabled, + notifications_enabled: notificationsEnabled, + hot_water_demand: hotWaterDemand, + green_power_w: greenPowerW, + auto_power_w: autoPowerW, + boost_power_w: boostPowerW, + strong_forecast_kwh: strongForecastKwh, + medium_forecast_kwh: mediumForecastKwh, + strong_pv_now_w: strongPvNowW, + medium_pv_now_w: mediumPvNowW, + boost_min_soc_strong_forecast_pct: boostMinSocStrongForecastPct, + boost_min_soc_medium_forecast_pct: boostMinSocMediumForecastPct, + end_of_day_soc_guard: endOfDaySocGuard, + end_of_day_soc_target: endOfDaySocTarget, + base_load_remaining_kwh: baseLoadRemainingKwh, + flexible_safety_margin_kwh: flexibleSafetyMarginKwh, + ac_loads_max_safe_w: acLoadsMaxSafeW, + water_heater_switch: waterHeaterSwitchOn, + water_heater_energy_total_kwh: round2(waterHeaterEnergyTotalKwh), + last_signature: lastSignature || null, + evcs_priority_active: evcsPriorityActive, + energy_priority_mode: energyPriorityMode, + energy_priority_reason: energyPriorityReason || null, + evcs_priority_until: evcsPriorityUntilAt || null + }, + computed: { + forecast_strength: forecastStrength, + strong_forecast: strongForecast, + medium_forecast: mediumForecast, + strong_pv_now: strongPvNow, + medium_pv_now: mediumPvNow, + net_pv_headroom_w: Math.round(netPvHeadroomW), + pv_covers_house_now: pvCoversHouseNow, + pv_started_well: pvStartedWell, + battery_usable_capacity_kwh_internal: batteryCapacityKwh, + energy_needed_to_reach_target_kwh: round2(energyNeededToReachTargetKwh), + green_energy_kwh: round2(greenEnergyKwh), + auto_energy_kwh: round2(autoEnergyKwh), + boost_energy_kwh: round2(boostEnergyKwh), + projected_green_soc: round1(projectedGreenSoc), + projected_auto_soc: round1(projectedAutoSoc), + projected_boost_soc: round1(projectedBoostSoc), + green_budget_margin_kwh: round2(greenBudgetMarginKwh), + auto_budget_margin_kwh: round2(autoBudgetMarginKwh), + boost_budget_margin_kwh: round2(boostBudgetMarginKwh), + ac_safe: acSafe, + battery_not_draining_hard: batteryNotDrainingHard, + green_allowed: greenAllowed, + auto_allowed: autoAllowed, + boost_allowed_by_budget: boostAllowedByBudget, + boost_allowed: boostAllowed, + early_boost_strong_ready: earlyBoostStrongReady, + early_boost_medium_ready: earlyBoostMediumReady, + auto_ready: autoReady, + green_ready: greenReady, + base_recommended_mode: baseRecommendedMode, + base_advice_color: baseAdviceColor, + base_state: baseState, + observed_power_w: Math.round(observedPowerW), + observed_regime: observedRegime, + sample_duration_min: sampleIsUsable ? round1(sampleDurationMin) : null, + energy_delta_kwh: sampleIsUsable ? round2(energyDeltaKwh) : null, + mismatch_active: mismatchActive, + mismatch_severity: mismatchSeverity, + mismatch_duration_min: round1(mismatchDurationMin), + evcs_priority_active: evcsPriorityActive, + protection_state: protectionState, + real_conflict_now: realConflictNow, + restore_energy_ok: restoreEnergyOk, + restore_surplus_w: restoreSurplusW, + mode_mismatch_delay_min: modeMismatchDelayMin + }, + decision: { + recommended_mode: recommendedMode, + advice_color: adviceColor, + state, + legacy_advice_ok: legacyAdviceOk, + selected_load_kwh: round2(selectedLoadKwh), + projected_end_of_day_soc: round1(selectedProjectedSoc), + budget_margin_kwh: round2(selectedBudgetMarginKwh), + observed_power_w: Math.round(observedPowerW), + observed_regime: observedRegime, + mismatch_active: mismatchActive, + mismatch_severity: mismatchSeverity, + mismatch_duration_min: round1(mismatchDurationMin), + pending_user_action: pendingUserActionSafe, + power_switch_action: powerSwitchAction, + power_switch_desired_on: powerSwitchDesiredOn, + force_off_now: forceOffNow, + restore_now: restoreNow, + forced_off_by_advisor: forcedOffByAdvisor, + forced_off_reason: forcedOffReasonSafe, + forced_off_at: forcedOffByAdvisor ? forcedOffAtSafe || null : null, + signature, + signature_changed: signatureChanged, + should_notify: notifyWanted, + reason, + short_reason: shortReason + }, + ha_service_calls: { + helper_recommended_mode: { + domain: 'input_select', + service: 'select_option', + data: { + entity_id: 'input_select.victron_water_heater_recommended_mode', + option: recommendedMode + } + }, + helper_advice_color: { + domain: 'input_select', + service: 'select_option', + data: { + entity_id: 'input_select.victron_water_heater_advice_color', + option: adviceColor + } + }, + helper_state: { + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_water_heater_advice_state', + value: state + } + }, + helper_reason: { + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_water_heater_advice_reason', + value: shortReason || 'Aucune raison' + } + }, + helper_signature: { + execute: signatureChanged, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_water_heater_advice_last_signature', + value: signature + } + }, + helper_projected_soc: { + domain: 'input_number', + service: 'set_value', + data: { + entity_id: 'input_number.victron_water_heater_projected_end_of_day_soc', + value: round1(selectedProjectedSoc) + } + }, +helper_budget_margin: { + domain: 'input_number', + service: 'set_value', + data: { + entity_id: 'input_number.victron_water_heater_budget_margin_kwh', + value: Math.max(BUDGET_MARGIN_MIN, Math.min(BUDGET_MARGIN_MAX, round2(selectedBudgetMarginKwh))) + } +}, + helper_load_kwh: { + domain: 'input_number', + service: 'set_value', + data: { + entity_id: 'input_number.victron_load_water_heater_kwh', + value: round2(selectedLoadKwh) + } + }, + helper_legacy_advice_ok: { + domain: 'input_boolean', + service: boolService(legacyAdviceOk), + data: { + entity_id: 'input_boolean.victron_advice_water_heater_ok' + } + }, + helper_green_allowed: { + domain: 'input_boolean', + service: boolService(greenAllowed), + data: { + entity_id: 'input_boolean.victron_water_heater_green_allowed' + } + }, + helper_auto_allowed: { + domain: 'input_boolean', + service: boolService(autoAllowed), + data: { + entity_id: 'input_boolean.victron_water_heater_auto_allowed' + } + }, + helper_boost_allowed: { + domain: 'input_boolean', + service: boolService(boostAllowed), + data: { + entity_id: 'input_boolean.victron_water_heater_boost_allowed' + } + }, + helper_observed_power: { + domain: 'input_number', + service: 'set_value', + data: { + entity_id: 'input_number.victron_water_heater_observed_power_w', + value: Math.round(observedPowerW) + } + }, + helper_observed_regime: { + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_water_heater_observed_regime', + value: observedRegime + } + }, + helper_pending_user_action: { + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_water_heater_pending_user_action', + value: pendingUserActionSafe + } + }, + helper_mismatch_since: { + execute: mismatchActive && !!mismatchSinceAtEffective, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_water_heater_mode_mismatch_since', + datetime: mismatchSinceAtEffective + } + }, + helper_energy_prev_kwh: { + domain: 'input_number', + service: 'set_value', + data: { + entity_id: 'input_number.victron_water_heater_energy_total_prev_kwh', + value: round2(waterHeaterEnergyTotalKwh) + } + }, + helper_energy_prev_at: { + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_water_heater_energy_prev_at', + datetime: now.stamp + } + }, + helper_forced_off_by_advisor: { + domain: 'input_boolean', + service: boolService(forcedOffByAdvisor), + data: { + entity_id: 'input_boolean.victron_water_heater_forced_off_by_advisor' + } + }, + helper_forced_off_reason: { + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_water_heater_forced_off_reason', + value: forcedOffReasonSafe + } + }, + helper_forced_off_at: { + execute: forceOffNow && !!forcedOffAtSafe, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_water_heater_forced_off_at', + datetime: forcedOffAtSafe + } + }, + // Jour faible (07/10/2026) : la coupure appartient a la regle « eau chaude en HC de nuit ». + helper_off_jour_faible: { + domain: 'input_boolean', + service: boolService(offJourFaible), + data: { + entity_id: 'input_boolean.victron_water_heater_off_jour_faible' + } + }, + water_heater_power_switch: { + execute: powerSwitchAction !== 'none', + domain: 'switch', + service: powerSwitchDesiredOn ? 'turn_on' : 'turn_off', + data: { + entity_id: 'switch.tt_chauffe_eau_pleumeur_switch' + } + }, + logbook: { + execute: signatureChanged || forceOffNow || restoreNow, + domain: 'logbook', + service: 'log', + data: { + name: 'Water Heater Advisor', + message: `${recommendedMode}/${adviceColor} | regime=${observedRegime} | prioEVCS=${evcsPriorityActive ? 'on' : 'off'} | cut=${forcedOffByAdvisor ? 'on' : 'off'} | ${shortReason}`, + entity_id: 'switch.tt_chauffe_eau_pleumeur_switch' + } + }, + mobile_notification: { + execute: notifyWanted, + domain: cfg.mobileNotifyDomain, + service: cfg.mobileNotifyService, + data: { + title: notifyTitle, + message: notifyMessage, + data: { + push: { + 'interruption-level': 'time-sensitive', + sound: { name: 'default' } + }, + ttl: 0, + priority: 'high' + } + } + } + } +}; + +return [{ json: result }]; \ No newline at end of file diff --git a/tests/allers-retours/chauffe-eau.mjs b/tests/allers-retours/chauffe-eau.mjs new file mode 100644 index 0000000..f2375ab --- /dev/null +++ b/tests/allers-retours/chauffe-eau.mjs @@ -0,0 +1,13 @@ +#!/usr/bin/env node +// Energie du chauffe-eau sur 24 h (07/10/2026). Usage : node tests/allers-retours/chauffe-eau.mjs +import { energieSur24h } from '../../tools/chauffe-eau.mjs'; +let ok = 0, ko = 0; +const v = (n, a, b) => { if (a === b) { ok++; console.log(`✅ ${n}`); } else { ko++; console.log(`❌ ${n} : attendu ${b}, obtenu ${a}`); } }; +const s = (...x) => x.map((state) => ({ state: String(state) })); +v('journee-du-06-10', energieSur24h(s(12.739, 13.04, 14.23, 15.42, 16.57, 17.74, 18.15, 18.233)), 5.49); +v('compteur-immobile-0-kwh', energieSur24h(s(18.254)), 0); +v('indisponible-ignore', energieSur24h(s(10, 'unavailable', 11)), 1); +v('remise-a-zero-sans-negatif', energieSur24h(s(10, 11, 0, 0.5)), 1.5); +v('illisible-null', energieSur24h(s('unavailable')), null); +console.log(ko ? `\n${ko} ECHEC(S)` : `\nCHAUFFE-EAU 24 H OK — ${ok}/${ok}`); +process.exit(ko ? 1 : 0); diff --git a/tests/energy-balance/run.mjs b/tests/energy-balance/run.mjs index e918a4e..12d2afb 100644 --- a/tests/energy-balance/run.mjs +++ b/tests/energy-balance/run.mjs @@ -122,6 +122,12 @@ const SUMMARIZE = { vehicleSolarKwh: o.vehicleMidday ? o.vehicleMidday.solarKwh : null, vehicleFloorAtDeparturePct: o.vehicleMidday ? o.vehicleMidday.floorAtDeparturePct : null, vehicleGridNeeded: o.vehicleMidday ? o.vehicleMidday.gridNeeded : false, + // Chauffe-eau en HC de nuit les jours faibles (07/10). + ecsDue: o.ecsNight ? o.ecsNight.due : null, + ecsDecided: o.ecsNight ? o.ecsNight.decided : null, + ecsOn: o.ecsNight ? (o.ecsNight.on ?? null) : null, + ecsProjectedSocPct: o.ecsNight ? (o.ecsNight.projectedSocPct ?? null) : null, + ecsRequiredSocPct: o.ecsNight ? (o.ecsNight.requiredSocPct ?? null) : null, }) : ({ usable: false, missing: o.missing.join(',') }), }; diff --git a/tests/energy-balance/scenarios-balance.mjs b/tests/energy-balance/scenarios-balance.mjs index 88569d8..47b1c25 100644 --- a/tests/energy-balance/scenarios-balance.mjs +++ b/tests/energy-balance/scenarios-balance.mjs @@ -522,6 +522,39 @@ export const balanceScenarios = [ snapshot: jeep(snap2709('2026-09-27T15:00:00+02:00', 30, null, SOMBRE), 12), expect: { usable: true, vehicleActive: false, vehicleGridNeeded: false }, }, + // ======== CHAUFFE-EAU EN HC DE NUIT LES JOURS FAIBLES (07/10/2026) ======== + { + node: 'BALANCE', + id: 'bilan-ecs-jour-sombre-eau-la-nuit', + label: 'Matin 07:30, journee sombre (18 kWh), SOC 40 : la batterie n arrive pas au requis du soir -> chauffe-eau en HC de nuit', + snapshot: Object.assign(snap2709('2026-09-27T07:30:00+02:00', 40, null, SOMBRE), + { night_hc_end_ms: Date.parse('2026-09-28T07:24:00+02:00'), ecs_nuit_jour: '2026-09-26|off|x' }), + expect: { usable: true, ecsDue: true, ecsDecided: true, ecsOn: true }, + }, + { + node: 'BALANCE', + id: 'bilan-ecs-jour-ensoleille-eau-au-solaire', + label: 'Matin 07:30, journee normale (25,1 kWh), SOC 70 : la batterie atteint le requis du soir -> chauffe-eau au solaire', + snapshot: Object.assign(snap2709('2026-09-27T07:30:00+02:00', 70, null), + { night_hc_end_ms: Date.parse('2026-09-28T07:24:00+02:00'), ecs_nuit_jour: '2026-09-26|on|x' }), + expect: { usable: true, ecsDue: true, ecsDecided: true, ecsOn: false }, + }, + { + node: 'BALANCE', + id: 'bilan-ecs-decision-figee-dans-la-journee', + label: 'Deja decide aujourd hui : pas de nouvelle decision (sinon retirer le chauffe-eau changerait la projection -> oscillateur)', + snapshot: Object.assign(snap2709('2026-09-27T13:00:00+02:00', 40, null, SOMBRE), + { night_hc_end_ms: Date.parse('2026-09-28T07:24:00+02:00'), ecs_nuit_jour: '2026-09-27|on|x' }), + expect: { usable: true, ecsDue: false, ecsDecided: false }, + }, + { + node: 'BALANCE', + id: 'bilan-ecs-pas-de-decision-avant-la-fin-de-la-nuit', + label: '00:30, nouvelle date mais fenetre HC de nuit pas encore passee : la decision de la veille vaut encore', + snapshot: Object.assign(snap2709('2026-09-27T00:30:00+02:00', 40, null, SOMBRE), + { night_hc_end_ms: Date.parse('2026-09-27T07:24:00+02:00'), ecs_nuit_jour: '2026-09-26|on|x' }), + expect: { usable: true, ecsDue: false, ecsDecided: false }, + }, // ======== LES TROIS VEHICULES (28/09/2026) ======== { node: 'BALANCE', diff --git a/tests/water-heater/run.mjs b/tests/water-heater/run.mjs new file mode 100644 index 0000000..7926c62 --- /dev/null +++ b/tests/water-heater/run.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +// Workflow « Water Heater Advisor ECS 6 Nodes » (07/10/2026) : la chaine +// Normalize -> ... -> BUILD executee sur des snapshots, comme en production. +// Usage : node tests/water-heater/run.mjs +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const CHAINE = ['NORMALIZE', 'OBSERVATION', 'ENERGY_MODEL', 'BASE_DECISION', 'ARBITRATION', 'OUTPUT_BUILDER', 'BUILD_ACTIONS']; +const code = Object.fromEntries(CHAINE.map((n) => [n, fs.readFileSync(path.join(ROOT, `src/water-heater/${n}.js`), 'utf8')])); + +function executer(snapshot) { + let item = snapshot, sorties = {}; + for (const n of CHAINE) { + const sb = { $input: { first: () => ({ json: item }), all: () => [{ json: item }] }, $env: {}, console }; + vm.createContext(sb); + const out = vm.runInContext(`(function(){ ${code[n]} })()`, sb, { timeout: 5000 }); + sorties[n] = out; + item = out[0].json; + } + return sorties; +} + +// Snapshot de base : matin d'un jour faible, contacteur ouvert, chauffe-eau sous tension. +const BASE = { + soc_percent: '45', battery_current_a: '-5', pv_power_w: '900', pv_power_10m_avg_w: '850', + house_ac_power_w: '700', grid_power_w: '0', grid_state: 'off', victron_acin1_available: '0', + today_remaining_kwh: '15', tomorrow_kwh: '25', forecast_source: 'vrm', + advisor_enabled: 'on', notifications_enabled: 'off', hot_water_demand: 'normal', + water_heater_switch: 'on', water_heater_energy_total_kwh: '18.25', + ecs_nuit: 'on', hc_session_committed: 'off', ecs_24h_kwh: '5.5', off_jour_faible_prev: 'off', + evcs_priority_active: 'off', forced_off_by_advisor: 'off', +}; +const cas = [ + { id: 'jour-faible-coupe-le-jour', snap: {}, attendu: { action: 'turn_off', desired: false, etat: 'jour_faible_coupe', owner: true } }, + { id: 'jour-faible-chauffe-en-hc-edf', snap: { water_heater_switch: 'off', off_jour_faible_prev: 'on', grid_state: 'on', hc_session_committed: 'on' }, + attendu: { action: 'turn_on', desired: true, etat: 'jour_faible_chauffe_hc_edf', owner: true } }, + { id: 'jour-faible-edf-ferme-hors-session-reste-coupe', snap: { water_heater_switch: 'off', off_jour_faible_prev: 'on', grid_state: 'on', hc_session_committed: 'off' }, + attendu: { action: 'none', etat: 'jour_faible_coupe', owner: true } }, + { id: 'jour-faible-eau-froide-chauffe', snap: { water_heater_switch: 'off', off_jour_faible_prev: 'on', ecs_24h_kwh: '0.4' }, + attendu: { action: 'turn_on', desired: true, etat: 'jour_faible_eau_froide', owner: true } }, + { id: 'jour-faible-energie-illisible-aucune-restriction', snap: { ecs_24h_kwh: 'indisponible' }, + attendu: { action: 'none', etat: 'jour_faible_energie_illisible', owner: true } }, + { id: 'jour-normal-remise-sous-tension', snap: { ecs_nuit: 'off', water_heater_switch: 'off', off_jour_faible_prev: 'on' }, + attendu: { action: 'turn_on', desired: true, etat: 'jour_normal_remise_sous_tension', owner: false } }, + { id: 'jour-normal-coupure-manuelle-respectee', snap: { ecs_nuit: 'off', water_heater_switch: 'off', off_jour_faible_prev: 'off' }, + attendu: { action: 'none', owner: false } }, + { id: 'conseiller-desactive-rien', snap: { advisor_enabled: 'off' }, attendu: { action: 'none', owner: false } }, +]; + +let ok = 0, ko = 0; +for (const c of cas) { + const s = executer({ ...BASE, ...c.snap }); + const fd = s.ARBITRATION[0].json.finalDecision; + const appels = s.BUILD_ACTIONS.map((x) => x.json); + const sw = appels.find((a) => a.domain === 'switch' && a.data?.entity_id === 'switch.tt_chauffe_eau_pleumeur_switch'); + const own = appels.find((a) => JSON.stringify(a).includes('victron_water_heater_off_jour_faible')); + const erreurs = []; + if (fd.powerSwitchAction !== c.attendu.action) erreurs.push(`action ${fd.powerSwitchAction} (attendu ${c.attendu.action})`); + if (c.attendu.desired !== undefined && fd.powerSwitchDesiredOn !== c.attendu.desired) erreurs.push(`desired ${fd.powerSwitchDesiredOn}`); + if (c.attendu.etat && fd.jourFaibleState !== c.attendu.etat) erreurs.push(`etat ${fd.jourFaibleState} (attendu ${c.attendu.etat})`); + if (fd.offJourFaible !== c.attendu.owner) erreurs.push(`propriete ${fd.offJourFaible}`); + if (c.attendu.action !== 'none' && !sw) erreurs.push('aucun appel switch emis par BUILD'); + if (c.attendu.action === 'none' && sw) erreurs.push('appel switch emis alors qu aucune action'); + if (!own) erreurs.push('helper de propriete absent des appels'); + if (erreurs.length) { ko++; console.log(`❌ ${c.id} | ${erreurs.join(' ; ')}`); } else { ok++; console.log(`✅ ${c.id}`); } +} +// Prevision forte = le reste du jour seulement (06/10 : demain fort ne rend plus forte une journee faible). +{ + const s = executer({ ...BASE, ecs_nuit: 'off', today_remaining_kwh: '8', tomorrow_kwh: '30' }); + const f = s.ENERGY_MODEL[0].json.energyModel.forecastStrength; + if (f === 'weak') { ok++; console.log('✅ prevision-forte-sur-le-jour-seulement'); } + else { ko++; console.log(`❌ prevision-forte-sur-le-jour-seulement | ${f}`); } +} +console.log(ko ? `\n${ko} ECHEC(S) — NE PAS POUSSER EN PRODUCTION` : `\nWATER HEATER OK — ${ok}/${ok}`); +process.exit(ko ? 1 : 0); diff --git a/tools/chauffe-eau.mjs b/tools/chauffe-eau.mjs new file mode 100644 index 0000000..2ed0cd8 --- /dev/null +++ b/tools/chauffe-eau.mjs @@ -0,0 +1,14 @@ +// Energie du chauffe-eau sur 24 h (07/10/2026), pour la regle « jour faible : +// eau chaude en HC de nuit » du workflow Water Heater Advisor ECS 6 Nodes : +// moins de 1,5 kWh en 24 h -> il chauffe quand meme (securite eau chaude). +// Somme des HAUSSES du compteur cumule : une remise a zero ou une lecture +// indisponible ne cree ni energie negative ni saut. +export const COMPTEUR_CHAUFFE_EAU = 'sensor.tt_chauffe_eau_pleumeur_energie_totale'; + +export function energieSur24h(serie) { + const vals = (serie || []).map((e) => Number.parseFloat(e.state)).filter(Number.isFinite); + if (!vals.length) return null; // compteur illisible sur 24 h : on ne dit rien + let kwh = 0; + for (let i = 1; i < vals.length; i++) if (vals[i] > vals[i - 1]) kwh += vals[i] - vals[i - 1]; + return Math.round(kwh * 100) / 100; +} diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index 09a9152..2065fb9 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -33,6 +33,7 @@ import { creerSchemaKm, ingererReleves, ingererNiveauSaisi, ingererJeep } from ' import { texteRapport, envoyer, donneesNuit, texteDepuis } from './rapport-nuit.mjs'; import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, minuitLocal, COMPTEURS_IMPORT, COMPTEUR_EDF, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit, vrmVersCourbes, nuitMaisonPrevue } from './diagnostics-agent.mjs'; import { transitionsDansSerie } from './allers-retours.mjs'; +import { energieSur24h, COMPTEUR_CHAUFFE_EAU } from './chauffe-eau.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ONCE = process.argv.includes('--once'); @@ -2064,6 +2065,25 @@ async function valeursAMinuit() { return valeurs; } +// Chauffe-eau sur 24 h (07/10/2026) : securite eau chaude de la regle « jour +// faible » du workflow ECS. Illisible -> « indisponible » : le workflow ne +// restreint alors rien (jamais un ballon froid par panne de capteur). +async function publierChauffeEau24h() { + const fin = new Date(), debut = new Date(fin.getTime() - 24 * 3600e3); + const r = await fetch( + `${HA_URL}/api/history/period/${debut.toISOString()}?end_time=${encodeURIComponent(fin.toISOString())}` + + `&filter_entity_id=${COMPTEUR_CHAUFFE_EAU}&minimal_response&no_attributes`, + { headers: { Authorization: `Bearer ${HA_TOKEN}` }, signal: AbortSignal.timeout(15000) }, + ); + if (!r.ok) throw new Error(`historique HA ${r.status}`); + const kwh = energieSur24h((await r.json())[0]); + await publierCapteur('sensor.evcs_chauffe_eau_24h_kwh', kwh === null ? 'indisponible' : kwh, { + friendly_name: 'EVCS — chauffe-eau sur 24 h', unit_of_measurement: 'kWh', icon: 'mdi:water-boiler', + compteur: COMPTEUR_CHAUFFE_EAU, calcule_a: fin.toISOString(), + }); + return 1; +} + async function publierImportJour(st) { const minuit = await valeursAMinuit(); const courant = {}; @@ -2171,6 +2191,7 @@ async function publierNuitMaison(client) { return 1; } let bilanNuitPublie = false; // publie des le premier cycle, puis toutes les 5 min +let chauffeEauPublie = false; // chauffe-eau sur 24 h (07/10) : premier cycle puis toutes les 5 min async function publierBilanNuit(client) { if (DRY || !client) return 0; const d = await donneesNuit(client); @@ -2282,6 +2303,9 @@ async function cycle(client) { nuitMaison: (DRY || (nuitMaisonPubliee && new Date().getMinutes() % 5 !== 2)) ? 0 : await publierNuitMaison(client).then((n) => { nuitMaisonPubliee = true; return n; }).catch((e) => { console.error(`nuit de la maison : ${e.message}`); return 0; }), + chauffeEau24h: (DRY || (chauffeEauPublie && new Date().getMinutes() % 5 !== 4)) ? 0 : await publierChauffeEau24h().then((n) => { chauffeEauPublie = true; return n; }).catch((e) => { + console.error(`chauffe-eau 24 h : ${e.message}`); return 0; + }), bilanNuit: (DRY || (bilanNuitPublie && new Date().getMinutes() % 5 !== 1)) ? 0 : await publierBilanNuit(client).then((n) => { bilanNuitPublie = true; return n; }).catch((e) => { console.error(`bilan de nuit : ${e.message}`); return 0; }), diff --git a/tools/lib-node-map.mjs b/tools/lib-node-map.mjs index af839dd..7738e51 100644 --- a/tools/lib-node-map.mjs +++ b/tools/lib-node-map.mjs @@ -61,6 +61,22 @@ export const WORKFLOWS = [ { file: 'HA_SNAPSHOT_FORECAST.json.tpl', node: 'HA Snapshot Forecast', kind: 'template' }, ], }, + { + // Chauffe-eau (07/10/2026) : seul workflow ECS actif (V2.3 desactive le 07/10). + id: 'water-heater', + workflowFile: 'workflows/water-heater-ecs-v1.json', + srcDir: 'src/water-heater', + nodes: [ + { file: 'NORMALIZE.js', node: 'ECS Normalize Inputs', kind: 'jsCode' }, + { file: 'OBSERVATION.js', node: 'ECS EVCS & Water Heater Observation', kind: 'jsCode' }, + { file: 'ENERGY_MODEL.js', node: 'ECS Energy Model & Early Boost Signals', kind: 'jsCode' }, + { file: 'BASE_DECISION.js', node: 'ECS Base Advisor Decision', kind: 'jsCode' }, + { file: 'ARBITRATION.js', node: 'ECS Final Arbitration & Protection', kind: 'jsCode' }, + { file: 'OUTPUT_BUILDER.js', node: 'ECS Output Builder', kind: 'jsCode' }, + { file: 'BUILD_ACTIONS.js', node: 'BUILD ECS HA Actions1', kind: 'jsCode' }, + { file: 'HA_SNAPSHOT_TEMPLATE.json.tpl', node: 'HA Snapshot Water Heater Advisor V2', kind: 'template' }, + ], + }, ]; // Rétro-compatibilité (outils historiques mono-workflow) diff --git a/tools/n8n-pull.mjs b/tools/n8n-pull.mjs index 44657f4..bff85c8 100644 --- a/tools/n8n-pull.mjs +++ b/tools/n8n-pull.mjs @@ -1,47 +1,23 @@ #!/usr/bin/env node -// Usage: node tools/n8n-pull.mjs [workflowId] [outputFile] -// GET un workflow n8n et l'écrit en JSON (pretty) dans un fichier local. -// Clé API lue depuis ~/.config/opencode/opencode.json (mcp.n8n-mcp.environment). - +// Usage: node tools/n8n-pull.mjs +// GET d'un workflow n8n vers un fichier local (mise sous controle du depot). +// Meme configuration d'API que n8n-push.mjs. N'ecrit rien sur le serveur. import fs from 'fs'; import os from 'os'; import path from 'path'; -const WORKFLOW_ID = process.argv[2] || 'ATswvGfG4KJUls0K'; -const OUT = process.argv[3] || `workflows/pull-${WORKFLOW_ID}.json`; - +const [ID, OUT] = process.argv.slice(2); +if (!ID || !OUT) { console.error('Usage: node tools/n8n-pull.mjs '); process.exit(1); } function loadApiConfig() { - if (process.env.N8N_API_KEY) { - return { - url: process.env.N8N_URL || 'https://n8n.airtria.pro', - key: process.env.N8N_API_KEY - }; - } - const cfgPath = path.join(os.homedir(), '.config/opencode/opencode.json'); - const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); + if (process.env.N8N_API_KEY) return { url: process.env.N8N_URL || 'https://n8n.airtria.pro', key: process.env.N8N_API_KEY }; + const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.config/opencode/opencode.json'), 'utf8')); const env = cfg?.mcp?.['n8n-mcp']?.environment || {}; - if (!env.N8N_API_KEY) { - console.error('Clé API n8n introuvable (env N8N_API_KEY ou opencode.json)'); - process.exit(1); - } - return { - url: (env.N8N_API_URL || 'https://n8n.airtria.pro/api/v1').replace(/\/api\/v1\/?$/, ''), - key: env.N8N_API_KEY - }; + if (!env.N8N_API_KEY) { console.error('Clé API n8n introuvable'); process.exit(1); } + return { url: (env.N8N_API_URL || 'https://n8n.airtria.pro/api/v1').replace(/\/api\/v1\/?$/, ''), key: env.N8N_API_KEY }; } - const { url, key } = loadApiConfig(); - -const res = await fetch(`${url}/api/v1/workflows/${WORKFLOW_ID}`, { - headers: { 'X-N8N-API-KEY': key } -}); -if (!res.ok) { - console.error(`GET échoué: ${res.status} ${res.statusText}`); - console.error(await res.text()); - process.exit(1); -} -const wf = await res.json(); -fs.mkdirSync(path.dirname(OUT), { recursive: true }); +const r = await fetch(`${url}/api/v1/workflows/${ID}`, { headers: { 'X-N8N-API-KEY': key } }); +if (!r.ok) { console.error(`GET ${r.status}`); process.exit(1); } +const wf = await r.json(); fs.writeFileSync(OUT, JSON.stringify(wf, null, 2) + '\n'); -console.log(`OK: "${wf.name}" (${wf.nodes?.length} nodes, active=${wf.active}) -> ${OUT}`); -console.log(`updatedAt: ${wf.updatedAt}`); +console.log(`${OUT} ← "${wf.name}" (${wf.nodes.length} nodes, active=${wf.active})`); diff --git a/tools/n8n-push.mjs b/tools/n8n-push.mjs index fb8cddc..df2dcf5 100644 --- a/tools/n8n-push.mjs +++ b/tools/n8n-push.mjs @@ -110,6 +110,7 @@ const isEvcsWorkflow = path.basename(INPUT) === 'evcs-surplus-controller-v2.6.js const isAdvisorWorkflow = path.basename(INPUT) === 'evcs-advisor-shadow-v1.json'; const isHysteresisWorkflow = path.basename(INPUT) === 'victron-grid-hysteresis-v1.json'; const isAcmWorkflow = path.basename(INPUT) === 'victron-auto-charge-manager-v1.2.json'; +const isWaterHeaterWorkflow = path.basename(INPUT) === 'water-heater-ecs-v1.json'; const isBalanceWorkflow = path.basename(INPUT) === 'evcs-energy-balance-v1.json' || path.basename(INPUT) === 'evcs-energy-profiles-daily-v1.json' || path.basename(INPUT) === 'evcs-solar-forecast-v1.json'; @@ -120,6 +121,7 @@ const testSuite = isHysteresisWorkflow && fs.existsSync('tests/golden-hysteresis/run.mjs') ? 'tests/golden-hysteresis/run.mjs' : isAcmWorkflow && fs.existsSync('tests/golden-acm/run.mjs') ? 'tests/golden-acm/run.mjs' : isBalanceWorkflow && fs.existsSync('tests/energy-balance/run.mjs') ? 'tests/energy-balance/run.mjs' : + isWaterHeaterWorkflow && fs.existsSync('tests/water-heater/run.mjs') ? 'tests/water-heater/run.mjs' : null; if (testSuite) { if (skipGolden) { diff --git a/workflows/evcs-energy-balance-v1.json b/workflows/evcs-energy-balance-v1.json index af4a2e8..9bb8bc9 100644 --- a/workflows/evcs-energy-balance-v1.json +++ b/workflows/evcs-energy-balance-v1.json @@ -51,7 +51,7 @@ "parameters": [ { "name": "template", - "value": "{#- Fenetres d'heures creuses (28/09/2026). Le calendrier tarifaire reste dans\n Home Assistant (helpers victron_evcs_hc_*) ; le bilan ne recoit que deux\n instants : la fin de la fenetre HC en cours (vide hors fenetre) et le debut\n de la fenetre HC suivante. Strategie du proprietaire : la batterie doit\n tenir jusqu'a la prochaine fenetre HC. -#}\n{%- set ns = namespace(fin='', suivant='', nuit='', fen=[], nd='', nf='') -%}\n{%- for j in [0, 1, 2] -%}\n {%- for a, b, t in [('victron_evcs_hc_night_start', 'victron_evcs_hc_night_stop', 'nuit'), ('victron_evcs_hc_midday_start', 'victron_evcs_hc_midday_stop', 'midi')] -%}\n {%- set d = today_at(states('input_datetime.' ~ a)) + timedelta(days=j) -%}\n {%- set f = today_at(states('input_datetime.' ~ b)) + timedelta(days=j) -%}\n {%- set f = f + timedelta(days=1) if f <= d else f -%}\n {%- set ns.fen = ns.fen + [[d, f, t]] -%}\n {%- endfor -%}\n{%- endfor -%}\n{%- for w in ns.fen | sort(attribute='0') -%}\n {%- if w[0] <= now() < w[1] and ns.fin == '' -%}{%- set ns.fin = w[1] -%}{%- endif -%}\n{%- endfor -%}\n{%- set repere = ns.fin if ns.fin != '' else now() -%}\n{%- for w in ns.fen | sort(attribute='0') -%}\n {%- if w[0] >= repere and ns.suivant == '' -%}{%- set ns.suivant = w[0] -%}{%- endif -%}\n {%- if w[0] >= repere and w[2] == 'nuit' and ns.nuit == '' -%}{%- set ns.nuit = w[0] -%}{%- endif -%}\n{%- endfor -%}\n{#- Fenetre HC de NUIT en cours, sinon la prochaine (29/09) : sert a prevoir\n ce que la maison achetera au reseau si la session de nuit s'engage. -#}\n{%- for w in ns.fen | sort(attribute='0') -%}\n {%- if w[2] == 'nuit' and w[1] > now() and ns.nd == '' -%}{%- set ns.nd = w[0] -%}{%- set ns.nf = w[1] -%}{%- endif -%}\n{%- endfor -%}\n{#- Vehicule branche a midi (28/09/2026) — JEEP, VW ou ZERO, selon le\n selecteur. Pour chacun, HA porte le calendrier (jours et heure de depart\n apres le dejeuner), la capacite et la puissance de charge MESUREES ; le\n bilan recoit un instant de depart, vide si le vehicule ne repart pas\n aujourd'hui. Le niveau saisi n'est retenu que s'il date de ce branchement\n (tolerance 1 h avant). Capacite ou puissance a 0 = inconnue. -#}\n{%- set vh = namespace(dep='', valide=false, cap=none, kw=none) -%}\n{%- set jours = ['lun', 'mar', 'mer', 'jeu', 'ven', 'sam', 'dim'] -%}\n{%- set cle = {'JEEP': 'jeep', 'VW': 'vw', 'ZERO': 'zero'}.get(states('input_select.victron_evcs_vehicle')) -%}\n{%- if cle -%}\n {%- set vh.cap = states('input_number.victron_evcs_capacity_' ~ cle ~ '_kwh') -%}\n {%- set vh.kw = states('input_number.victron_evcs_charger_kw_' ~ cle) -%}\n {%- set heure = states('input_datetime.victron_evcs_' ~ cle ~ '_midday_departure') -%}\n {%- if jours[now().weekday()] in states('input_text.victron_evcs_' ~ cle ~ '_midday_departure_days') | lower\n and heure not in ['unknown', 'unavailable', ''] -%}\n {%- set d = today_at(heure) -%}\n {%- if d > now() -%}{%- set vh.dep = d -%}{%- endif -%}\n {%- endif -%}\n{%- endif -%}\n{%- set branche = states.binary_sensor.victron_evcs_vehicle_connected -%}\n{%- set niveau = states.input_number.victron_evcs_vehicle_level_pct -%}\n{#- Depart ponctuel (VW, ZERO : pas de jours fixes) : pour aujourd'hui, a venir,\n saisi depuis ce branchement. Le plus proche des deux departs l'emporte. -#}\n{%- set ponctuel = states.input_datetime.victron_evcs_departure_ponctuel -%}\n{%- if branche is not none and ponctuel is not none and branche.state == 'on'\n and ponctuel.state not in ['unknown', 'unavailable', ''] -%}\n {%- set p = as_datetime(ponctuel.attributes.timestamp) | as_local -%}\n {%- if p.date() == now().date() and p > now()\n and as_timestamp(ponctuel.last_changed) >= as_timestamp(branche.last_changed) - 3600 -%}\n {%- set vh.dep = p if vh.dep == '' or p < vh.dep else vh.dep -%}\n {%- endif -%}\n{%- endif -%}\n{%- if branche is not none and niveau is not none and branche.state == 'on' -%}\n {#- Date de SAISIE ecrite par l'automatisation, pas last_changed : une saisie\n identique ne change pas l'etat du helper mais compte comme une saisie. -#}\n {%- set vh.valide = (as_timestamp(states('input_datetime.victron_evcs_vehicle_level_at'), 0)) >= as_timestamp(branche.last_changed) - 3600 -%}\n{%- endif -%}\n{{ {\n \"ts\": now().isoformat(),\n \"night_house_factor\": state_attr(\"sensor.evcs_nuit_maison_prevue_kwh\", \"facteur\"),\n \"night_house_start_ms\": state_attr(\"sensor.evcs_nuit_maison_prevue_kwh\", \"debut_ms\"),\n \"night_house_end_ms\": state_attr(\"sensor.evcs_nuit_maison_prevue_kwh\", \"fin_ms\"),\n \"night_house_computed_ms\": (as_timestamp(state_attr(\"sensor.evcs_nuit_maison_prevue_kwh\", \"calcule_a\"), 0) * 1000),\n \"night_house_forecast_kwh\": states(\"sensor.evcs_nuit_maison_prevue_kwh\"),\n \"vehicle_connected\": is_state(\"binary_sensor.victron_evcs_vehicle_connected\", \"on\"),\n \"vehicle_departure_ms\": (as_timestamp(vh.dep) * 1000) if vh.dep != '' else none,\n \"vehicle_level_pct\": states(\"input_number.victron_evcs_vehicle_level_pct\"),\n \"vehicle_level_valid\": vh.valide,\n \"vehicle_capacity_kwh\": vh.cap,\n \"vehicle_charger_kw\": vh.kw,\n \"vehicle_delivered_since_level_kwh\": ((states(\"sensor.evcs_hq2239dnt7p_total_energy\") | float(0)) - (states(\"input_number.victron_evcs_vehicle_level_ref_kwh\") | float(0))) if states(\"sensor.evcs_hq2239dnt7p_total_energy\") | is_number else none,\n \"hc_window_end_ms\": (as_timestamp(ns.fin) * 1000) if ns.fin != '' else none,\n \"next_hc_start_ms\": (as_timestamp(ns.suivant) * 1000) if ns.suivant != '' else none,\n \"next_night_hc_start_ms\": (as_timestamp(ns.nuit) * 1000) if ns.nuit != '' else none,\n \"night_hc_start_ms\": (as_timestamp(ns.nd) * 1000) if ns.nd != '' else none,\n \"night_hc_end_ms\": (as_timestamp(ns.nf) * 1000) if ns.nf != '' else none,\n \"nowMs\": as_timestamp(now()) * 1000,\n\n \"balance_enabled\": states(\"input_boolean.evcs_balance_enabled\"),\n \"usable_capacity_kwh\": states(\"input_number.evcs_balance_usable_capacity_kwh\"),\n \"modules_en_service\": states(\"input_number.evcs_balance_modules_en_service\"),\n\n \"consumption_p50_csv\": states(\"input_text.evcs_balance_consumption_p50\"),\n \"consumption_p80_csv\": states(\"input_text.evcs_balance_consumption_p80\"),\n \"envelope_csv\": states(\"input_text.evcs_balance_envelope\"),\n \"censorship_rate_csv\": states(\"input_text.evcs_balance_censorship_rate\"),\n\n \"forecast_source\": states(\"input_select.evcs_balance_forecast_source\"),\n \"forecast_day0\": states(\"input_text.evcs_balance_vrm_day0\") if is_state(\"input_select.evcs_balance_forecast_source\", \"vrm\") else states(\"input_text.evcs_balance_forecast_day0\"),\n \"forecast_today_csv\": states(\"input_text.evcs_balance_vrm_today\") if is_state(\"input_select.evcs_balance_forecast_source\", \"vrm\") else states(\"input_text.evcs_balance_forecast_today\"),\n \"forecast_tomorrow_csv\": states(\"input_text.evcs_balance_vrm_tomorrow\") if is_state(\"input_select.evcs_balance_forecast_source\", \"vrm\") else states(\"input_text.evcs_balance_forecast_tomorrow\"),\n \"forecast_updated\": states(\"input_text.evcs_balance_vrm_updated\") if is_state(\"input_select.evcs_balance_forecast_source\", \"vrm\") else states(\"input_text.evcs_balance_forecast_updated\"),\n \"grid_soc_off\": states(\"input_number.victron_grid_soc_off\"),\n\n \"manual_hc_floor_percent\": states(\"input_number.evcs_battery_manual_hc_floor\"),\n\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"vebus_ac_out_l1_power_w\": states(\"sensor.victron_ve_bus_ac_out_l1_power\"),\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"battery_voltage_v\": states(\"sensor.victron_battery_voltage\"),\n \"vebus_dc_power_w\": states(\"sensor.victron_ve_bus_dc_power_system\"),\n\n \"grid_switch\": states(\"switch.edf_general_ok_switch\"),\n \"acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n \"grid_power_l1_w\": states(\"sensor.victron_grid_power_l1\"),\n\n \"multiplus_state\": states(\"sensor.airtria_mpii_v1_state\"),\n \"multiplus_overload_alarm\": states(\"sensor.airtria_mpii_v1_overload_alarm\"),\n \"system_state\": states(\"sensor.gx_device_system_state\"),\n \"ess_batterylife_state\": states(\"sensor.gx_device_ess_batterylife_state\"),\n \"dc_battery_state\": states(\"sensor.gx_device_dc_battery_state\"),\n\n \"mppt_1_state\": states(\"sensor.airtria_mppt1_state\"),\n \"mppt_2_state\": states(\"sensor.mppt_450_200_hq2449yr9pt_state\"),\n \"mppt_3_state\": states(\"sensor.v1_state\"),\n\n \"evcs_native_status\": states(\"sensor.evcs_hq2239dnt7p_status\"),\n\n \"forecast_today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"forecast_today_kwh\": states(\"sensor.energy_production_today\"),\n \"forecast_tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\"),\n \"forecast_next_hour_w\": states(\"sensor.power_production_next_hour\"),\n\n \"sun_elevation\": state_attr(\"sun.sun\", \"elevation\"),\n\n \"storm_alert_level\": (states.sensor\n | selectattr('entity_id', 'search', '_weather_alert')\n | map(attribute='state') | list | first) | default('indisponible', true),\n \"storm_alert_detail\": (states.sensor\n | selectattr('entity_id', 'search', '_weather_alert')\n | map(attribute='attributes') | list | first) | default({}, true)\n} | tojson }}\n" + "value": "{#- Fenetres d'heures creuses (28/09/2026). Le calendrier tarifaire reste dans\n Home Assistant (helpers victron_evcs_hc_*) ; le bilan ne recoit que deux\n instants : la fin de la fenetre HC en cours (vide hors fenetre) et le debut\n de la fenetre HC suivante. Strategie du proprietaire : la batterie doit\n tenir jusqu'a la prochaine fenetre HC. -#}\n{%- set ns = namespace(fin='', suivant='', nuit='', fen=[], nd='', nf='') -%}\n{%- for j in [0, 1, 2] -%}\n {%- for a, b, t in [('victron_evcs_hc_night_start', 'victron_evcs_hc_night_stop', 'nuit'), ('victron_evcs_hc_midday_start', 'victron_evcs_hc_midday_stop', 'midi')] -%}\n {%- set d = today_at(states('input_datetime.' ~ a)) + timedelta(days=j) -%}\n {%- set f = today_at(states('input_datetime.' ~ b)) + timedelta(days=j) -%}\n {%- set f = f + timedelta(days=1) if f <= d else f -%}\n {%- set ns.fen = ns.fen + [[d, f, t]] -%}\n {%- endfor -%}\n{%- endfor -%}\n{%- for w in ns.fen | sort(attribute='0') -%}\n {%- if w[0] <= now() < w[1] and ns.fin == '' -%}{%- set ns.fin = w[1] -%}{%- endif -%}\n{%- endfor -%}\n{%- set repere = ns.fin if ns.fin != '' else now() -%}\n{%- for w in ns.fen | sort(attribute='0') -%}\n {%- if w[0] >= repere and ns.suivant == '' -%}{%- set ns.suivant = w[0] -%}{%- endif -%}\n {%- if w[0] >= repere and w[2] == 'nuit' and ns.nuit == '' -%}{%- set ns.nuit = w[0] -%}{%- endif -%}\n{%- endfor -%}\n{#- Fenetre HC de NUIT en cours, sinon la prochaine (29/09) : sert a prevoir\n ce que la maison achetera au reseau si la session de nuit s'engage. -#}\n{%- for w in ns.fen | sort(attribute='0') -%}\n {%- if w[2] == 'nuit' and w[1] > now() and ns.nd == '' -%}{%- set ns.nd = w[0] -%}{%- set ns.nf = w[1] -%}{%- endif -%}\n{%- endfor -%}\n{#- Vehicule branche a midi (28/09/2026) — JEEP, VW ou ZERO, selon le\n selecteur. Pour chacun, HA porte le calendrier (jours et heure de depart\n apres le dejeuner), la capacite et la puissance de charge MESUREES ; le\n bilan recoit un instant de depart, vide si le vehicule ne repart pas\n aujourd'hui. Le niveau saisi n'est retenu que s'il date de ce branchement\n (tolerance 1 h avant). Capacite ou puissance a 0 = inconnue. -#}\n{%- set vh = namespace(dep='', valide=false, cap=none, kw=none) -%}\n{%- set jours = ['lun', 'mar', 'mer', 'jeu', 'ven', 'sam', 'dim'] -%}\n{%- set cle = {'JEEP': 'jeep', 'VW': 'vw', 'ZERO': 'zero'}.get(states('input_select.victron_evcs_vehicle')) -%}\n{%- if cle -%}\n {%- set vh.cap = states('input_number.victron_evcs_capacity_' ~ cle ~ '_kwh') -%}\n {%- set vh.kw = states('input_number.victron_evcs_charger_kw_' ~ cle) -%}\n {%- set heure = states('input_datetime.victron_evcs_' ~ cle ~ '_midday_departure') -%}\n {%- if jours[now().weekday()] in states('input_text.victron_evcs_' ~ cle ~ '_midday_departure_days') | lower\n and heure not in ['unknown', 'unavailable', ''] -%}\n {%- set d = today_at(heure) -%}\n {%- if d > now() -%}{%- set vh.dep = d -%}{%- endif -%}\n {%- endif -%}\n{%- endif -%}\n{%- set branche = states.binary_sensor.victron_evcs_vehicle_connected -%}\n{%- set niveau = states.input_number.victron_evcs_vehicle_level_pct -%}\n{#- Depart ponctuel (VW, ZERO : pas de jours fixes) : pour aujourd'hui, a venir,\n saisi depuis ce branchement. Le plus proche des deux departs l'emporte. -#}\n{%- set ponctuel = states.input_datetime.victron_evcs_departure_ponctuel -%}\n{%- if branche is not none and ponctuel is not none and branche.state == 'on'\n and ponctuel.state not in ['unknown', 'unavailable', ''] -%}\n {%- set p = as_datetime(ponctuel.attributes.timestamp) | as_local -%}\n {%- if p.date() == now().date() and p > now()\n and as_timestamp(ponctuel.last_changed) >= as_timestamp(branche.last_changed) - 3600 -%}\n {%- set vh.dep = p if vh.dep == '' or p < vh.dep else vh.dep -%}\n {%- endif -%}\n{%- endif -%}\n{%- if branche is not none and niveau is not none and branche.state == 'on' -%}\n {#- Date de SAISIE ecrite par l'automatisation, pas last_changed : une saisie\n identique ne change pas l'etat du helper mais compte comme une saisie. -#}\n {%- set vh.valide = (as_timestamp(states('input_datetime.victron_evcs_vehicle_level_at'), 0)) >= as_timestamp(branche.last_changed) - 3600 -%}\n{%- endif -%}\n{{ {\n \"ts\": now().isoformat(),\n \"night_house_factor\": state_attr(\"sensor.evcs_nuit_maison_prevue_kwh\", \"facteur\"),\n \"night_house_start_ms\": state_attr(\"sensor.evcs_nuit_maison_prevue_kwh\", \"debut_ms\"),\n \"night_house_end_ms\": state_attr(\"sensor.evcs_nuit_maison_prevue_kwh\", \"fin_ms\"),\n \"night_house_computed_ms\": (as_timestamp(state_attr(\"sensor.evcs_nuit_maison_prevue_kwh\", \"calcule_a\"), 0) * 1000),\n \"night_house_forecast_kwh\": states(\"sensor.evcs_nuit_maison_prevue_kwh\"),\n \"vehicle_connected\": is_state(\"binary_sensor.victron_evcs_vehicle_connected\", \"on\"),\n \"vehicle_departure_ms\": (as_timestamp(vh.dep) * 1000) if vh.dep != '' else none,\n \"vehicle_level_pct\": states(\"input_number.victron_evcs_vehicle_level_pct\"),\n \"vehicle_level_valid\": vh.valide,\n \"vehicle_capacity_kwh\": vh.cap,\n \"vehicle_charger_kw\": vh.kw,\n \"vehicle_delivered_since_level_kwh\": ((states(\"sensor.evcs_hq2239dnt7p_total_energy\") | float(0)) - (states(\"input_number.victron_evcs_vehicle_level_ref_kwh\") | float(0))) if states(\"sensor.evcs_hq2239dnt7p_total_energy\") | is_number else none,\n \"hc_window_end_ms\": (as_timestamp(ns.fin) * 1000) if ns.fin != '' else none,\n \"next_hc_start_ms\": (as_timestamp(ns.suivant) * 1000) if ns.suivant != '' else none,\n \"next_night_hc_start_ms\": (as_timestamp(ns.nuit) * 1000) if ns.nuit != '' else none,\n \"night_hc_start_ms\": (as_timestamp(ns.nd) * 1000) if ns.nd != '' else none,\n \"night_hc_end_ms\": (as_timestamp(ns.nf) * 1000) if ns.nf != '' else none,\n \"ecs_nuit_jour\": states(\"input_text.evcs_balance_ecs_nuit_jour\"),\n \"nowMs\": as_timestamp(now()) * 1000,\n\n \"balance_enabled\": states(\"input_boolean.evcs_balance_enabled\"),\n \"usable_capacity_kwh\": states(\"input_number.evcs_balance_usable_capacity_kwh\"),\n \"modules_en_service\": states(\"input_number.evcs_balance_modules_en_service\"),\n\n \"consumption_p50_csv\": states(\"input_text.evcs_balance_consumption_p50\"),\n \"consumption_p80_csv\": states(\"input_text.evcs_balance_consumption_p80\"),\n \"envelope_csv\": states(\"input_text.evcs_balance_envelope\"),\n \"censorship_rate_csv\": states(\"input_text.evcs_balance_censorship_rate\"),\n\n \"forecast_source\": states(\"input_select.evcs_balance_forecast_source\"),\n \"forecast_day0\": states(\"input_text.evcs_balance_vrm_day0\") if is_state(\"input_select.evcs_balance_forecast_source\", \"vrm\") else states(\"input_text.evcs_balance_forecast_day0\"),\n \"forecast_today_csv\": states(\"input_text.evcs_balance_vrm_today\") if is_state(\"input_select.evcs_balance_forecast_source\", \"vrm\") else states(\"input_text.evcs_balance_forecast_today\"),\n \"forecast_tomorrow_csv\": states(\"input_text.evcs_balance_vrm_tomorrow\") if is_state(\"input_select.evcs_balance_forecast_source\", \"vrm\") else states(\"input_text.evcs_balance_forecast_tomorrow\"),\n \"forecast_updated\": states(\"input_text.evcs_balance_vrm_updated\") if is_state(\"input_select.evcs_balance_forecast_source\", \"vrm\") else states(\"input_text.evcs_balance_forecast_updated\"),\n \"grid_soc_off\": states(\"input_number.victron_grid_soc_off\"),\n\n \"manual_hc_floor_percent\": states(\"input_number.evcs_battery_manual_hc_floor\"),\n\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"vebus_ac_out_l1_power_w\": states(\"sensor.victron_ve_bus_ac_out_l1_power\"),\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"battery_voltage_v\": states(\"sensor.victron_battery_voltage\"),\n \"vebus_dc_power_w\": states(\"sensor.victron_ve_bus_dc_power_system\"),\n\n \"grid_switch\": states(\"switch.edf_general_ok_switch\"),\n \"acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n \"grid_power_l1_w\": states(\"sensor.victron_grid_power_l1\"),\n\n \"multiplus_state\": states(\"sensor.airtria_mpii_v1_state\"),\n \"multiplus_overload_alarm\": states(\"sensor.airtria_mpii_v1_overload_alarm\"),\n \"system_state\": states(\"sensor.gx_device_system_state\"),\n \"ess_batterylife_state\": states(\"sensor.gx_device_ess_batterylife_state\"),\n \"dc_battery_state\": states(\"sensor.gx_device_dc_battery_state\"),\n\n \"mppt_1_state\": states(\"sensor.airtria_mppt1_state\"),\n \"mppt_2_state\": states(\"sensor.mppt_450_200_hq2449yr9pt_state\"),\n \"mppt_3_state\": states(\"sensor.v1_state\"),\n\n \"evcs_native_status\": states(\"sensor.evcs_hq2239dnt7p_status\"),\n\n \"forecast_today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"forecast_today_kwh\": states(\"sensor.energy_production_today\"),\n \"forecast_tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\"),\n \"forecast_next_hour_w\": states(\"sensor.power_production_next_hour\"),\n\n \"sun_elevation\": state_attr(\"sun.sun\", \"elevation\"),\n\n \"storm_alert_level\": (states.sensor\n | selectattr('entity_id', 'search', '_weather_alert')\n | map(attribute='state') | list | first) | default('indisponible', true),\n \"storm_alert_detail\": (states.sensor\n | selectattr('entity_id', 'search', '_weather_alert')\n | map(attribute='attributes') | list | first) | default({}, true)\n} | tojson }}\n" } ] }, @@ -99,7 +99,7 @@ 120 ], "parameters": { - "jsCode": "// EVCS Energy Balance — PREPARE_BALANCE\n// =====================================================================\n// Colle de câblage n8n, et rien d'autre. Les nœuds de calcul (CENSORSHIP,\n// RESOURCE_ESTIMATE, BALANCE) sont volontairement écrits pour ne lire que\n// `$input`, afin de rester testables hors de n8n. Ce nœud rassemble donc ce\n// que BALANCE attend, en puisant dans les nœuds précédents.\n//\n// Il fait aussi la seule conversion nécessaire : les profils horaires vivent\n// dans des helpers `input_text` sous forme de CSV compact (24 valeurs, moins\n// de 255 caractères), il faut les redonner sous forme de tableaux.\n// =====================================================================\n\nfunction parseCsv24(raw) {\n if (typeof raw !== 'string') return null;\n const parts = raw.split(',').map(v => Number.parseFloat(v.trim()));\n if (parts.length !== 24 || parts.some(v => !Number.isFinite(v))) return null;\n return parts;\n}\n\n// Le nœud HTTP de n8n renvoie le corps du template dans `data`, sous forme de\n// chaîne. Hors n8n (tests), l'objet arrive déjà décodé — on accepte les deux.\nfunction unwrapSnapshot(raw) {\n if (raw && typeof raw.data === 'string') {\n try { return JSON.parse(raw.data); } catch { return raw; }\n }\n return (raw && raw.data) || raw;\n}\n\nconst snapshot = unwrapSnapshot($('HA Snapshot Balance').first().json);\nconst resource = $input.first().json;\n\nreturn [{\n json: {\n nowMs: Number.parseFloat(snapshot.nowMs),\n\n consumption_p50: parseCsv24(snapshot.consumption_p50_csv),\n consumption_p80: parseCsv24(snapshot.consumption_p80_csv),\n\n horizon: resource.horizon || [],\n\n soc_percent: snapshot.soc_percent,\n usable_capacity_kwh: snapshot.usable_capacity_kwh,\n modules_en_service: snapshot.modules_en_service,\n // Plancher dur : même valeur de repli que INPUT_PARSE.js\n // (evcs_night_battery_hard_floor_pct n'est aujourd'hui exposé par AUCUN\n // helper — la valeur « 22 % » documentée partout est en réalité un défaut\n // de code, cf. backlog du chantier).\n soc_floor_percent: 22,\n\n // Vigilance Météo-France, lue sans connaître la commune (l'entité\n // s'appelle sensor._weather_alert). Absente tant que\n // l'intégration n'est pas installée : le bilan publie alors\n // 'indisponible' et ne change rien.\n storm_alert_level: snapshot.storm_alert_level,\n storm_alert_detail: snapshot.storm_alert_detail,\n\n // Fenetres HC (28/09, « tenir jusqu'a la prochaine HC ») : horodatages\n // calcules par le gabarit depuis les helpers victron_evcs_hc_*. Sans cette\n // recopie, BALANCE.js ne les voit pas et la regle reste muette.\n // Plancher de charge HC manuelle (18/09) : oublie ici jusqu'au 28/09, le\n // bouton n'avait donc jamais atteint le bilan.\n manual_hc_floor_percent: snapshot.manual_hc_floor_percent,\n // Nuit de la maison prevue par la soiree (28/09) : facteur sur le profil.\n night_house_factor: snapshot.night_house_factor,\n night_house_start_ms: snapshot.night_house_start_ms,\n night_house_end_ms: snapshot.night_house_end_ms,\n night_house_computed_ms: snapshot.night_house_computed_ms,\n night_house_forecast_kwh: snapshot.night_house_forecast_kwh,\n // Vehicule branche a midi (28/09, JEEP/VW/ZERO) : depart du jour et besoin saisi.\n vehicle_connected: snapshot.vehicle_connected,\n vehicle_departure_ms: snapshot.vehicle_departure_ms,\n vehicle_level_pct: snapshot.vehicle_level_pct,\n vehicle_level_valid: snapshot.vehicle_level_valid,\n vehicle_capacity_kwh: snapshot.vehicle_capacity_kwh,\n vehicle_charger_kw: snapshot.vehicle_charger_kw,\n vehicle_delivered_since_level_kwh: snapshot.vehicle_delivered_since_level_kwh,\n hc_window_end_ms: snapshot.hc_window_end_ms,\n next_hc_start_ms: snapshot.next_hc_start_ms,\n next_night_hc_start_ms: snapshot.next_night_hc_start_ms,\n // Fenetre HC de nuit en cours ou prochaine (29/09) : import prevu si la\n // session de nuit s'engage.\n night_hc_start_ms: snapshot.night_hc_start_ms,\n night_hc_end_ms: snapshot.night_hc_end_ms,\n\n // Repris pour l'écriture des helpers, sans influencer le calcul.\n resourceUsable: resource.usable,\n shapeConfidence: resource.source ? resource.source.shapeConfidence : null,\n },\n}];\n" + "jsCode": "// EVCS Energy Balance — PREPARE_BALANCE\n// =====================================================================\n// Colle de câblage n8n, et rien d'autre. Les nœuds de calcul (CENSORSHIP,\n// RESOURCE_ESTIMATE, BALANCE) sont volontairement écrits pour ne lire que\n// `$input`, afin de rester testables hors de n8n. Ce nœud rassemble donc ce\n// que BALANCE attend, en puisant dans les nœuds précédents.\n//\n// Il fait aussi la seule conversion nécessaire : les profils horaires vivent\n// dans des helpers `input_text` sous forme de CSV compact (24 valeurs, moins\n// de 255 caractères), il faut les redonner sous forme de tableaux.\n// =====================================================================\n\nfunction parseCsv24(raw) {\n if (typeof raw !== 'string') return null;\n const parts = raw.split(',').map(v => Number.parseFloat(v.trim()));\n if (parts.length !== 24 || parts.some(v => !Number.isFinite(v))) return null;\n return parts;\n}\n\n// Le nœud HTTP de n8n renvoie le corps du template dans `data`, sous forme de\n// chaîne. Hors n8n (tests), l'objet arrive déjà décodé — on accepte les deux.\nfunction unwrapSnapshot(raw) {\n if (raw && typeof raw.data === 'string') {\n try { return JSON.parse(raw.data); } catch { return raw; }\n }\n return (raw && raw.data) || raw;\n}\n\nconst snapshot = unwrapSnapshot($('HA Snapshot Balance').first().json);\nconst resource = $input.first().json;\n\nreturn [{\n json: {\n nowMs: Number.parseFloat(snapshot.nowMs),\n\n consumption_p50: parseCsv24(snapshot.consumption_p50_csv),\n consumption_p80: parseCsv24(snapshot.consumption_p80_csv),\n\n horizon: resource.horizon || [],\n\n soc_percent: snapshot.soc_percent,\n usable_capacity_kwh: snapshot.usable_capacity_kwh,\n modules_en_service: snapshot.modules_en_service,\n // Plancher dur : même valeur de repli que INPUT_PARSE.js\n // (evcs_night_battery_hard_floor_pct n'est aujourd'hui exposé par AUCUN\n // helper — la valeur « 22 % » documentée partout est en réalité un défaut\n // de code, cf. backlog du chantier).\n soc_floor_percent: 22,\n\n // Vigilance Météo-France, lue sans connaître la commune (l'entité\n // s'appelle sensor._weather_alert). Absente tant que\n // l'intégration n'est pas installée : le bilan publie alors\n // 'indisponible' et ne change rien.\n storm_alert_level: snapshot.storm_alert_level,\n storm_alert_detail: snapshot.storm_alert_detail,\n\n // Fenetres HC (28/09, « tenir jusqu'a la prochaine HC ») : horodatages\n // calcules par le gabarit depuis les helpers victron_evcs_hc_*. Sans cette\n // recopie, BALANCE.js ne les voit pas et la regle reste muette.\n // Plancher de charge HC manuelle (18/09) : oublie ici jusqu'au 28/09, le\n // bouton n'avait donc jamais atteint le bilan.\n manual_hc_floor_percent: snapshot.manual_hc_floor_percent,\n // Nuit de la maison prevue par la soiree (28/09) : facteur sur le profil.\n night_house_factor: snapshot.night_house_factor,\n night_house_start_ms: snapshot.night_house_start_ms,\n night_house_end_ms: snapshot.night_house_end_ms,\n night_house_computed_ms: snapshot.night_house_computed_ms,\n night_house_forecast_kwh: snapshot.night_house_forecast_kwh,\n // Vehicule branche a midi (28/09, JEEP/VW/ZERO) : depart du jour et besoin saisi.\n vehicle_connected: snapshot.vehicle_connected,\n vehicle_departure_ms: snapshot.vehicle_departure_ms,\n vehicle_level_pct: snapshot.vehicle_level_pct,\n vehicle_level_valid: snapshot.vehicle_level_valid,\n vehicle_capacity_kwh: snapshot.vehicle_capacity_kwh,\n vehicle_charger_kw: snapshot.vehicle_charger_kw,\n vehicle_delivered_since_level_kwh: snapshot.vehicle_delivered_since_level_kwh,\n hc_window_end_ms: snapshot.hc_window_end_ms,\n next_hc_start_ms: snapshot.next_hc_start_ms,\n next_night_hc_start_ms: snapshot.next_night_hc_start_ms,\n // Fenetre HC de nuit en cours ou prochaine (29/09) : import prevu si la\n // session de nuit s'engage.\n night_hc_start_ms: snapshot.night_hc_start_ms,\n night_hc_end_ms: snapshot.night_hc_end_ms,\n // Decision du jour pour le chauffe-eau (07/10) : « AAAA-MM-JJ|on|raison ».\n ecs_nuit_jour: snapshot.ecs_nuit_jour,\n\n // Repris pour l'écriture des helpers, sans influencer le calcul.\n resourceUsable: resource.usable,\n shapeConfidence: resource.source ? resource.source.shapeConfidence : null,\n },\n}];\n" } }, { @@ -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\nfunction buildSlots(consumption, prudent = false) {\n return horizon.slice(0, CFG.SEARCH_HOURS).map(slot => {\n const hour = num(slot.hour, 0);\n const productionKwh = Math.max(0, num(slot.expectedKwh, 0));\n const consumptionKwh = prudent\n ? Math.max(consumption[hour] ?? 0, nightScaled(consumptionP50, slot))\n : nightScaled(consumption, slot);\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 hcFloorTypicalPct = hcReachFloorPct(slotsP50, nextHcStartMs);\nconst hcFloorNightPrudentPct = hcNightReachActive ? hcReachFloorPct(slotsP80, nextNightHcStartMs) : 0;\nconst hcFloorPrudentPct = Math.max(hcReachFloorPct(slotsP80, nextHcStartMs), hcFloorNightPrudentPct);\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// 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 // 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 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\nfunction buildSlots(consumption, prudent = false) {\n return horizon.slice(0, CFG.SEARCH_HOURS).map(slot => {\n const hour = num(slot.hour, 0);\n const productionKwh = Math.max(0, num(slot.expectedKwh, 0));\n const consumptionKwh = prudent\n ? Math.max(consumption[hour] ?? 0, nightScaled(consumptionP50, slot))\n : nightScaled(consumption, slot);\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 hcFloorTypicalPct = hcReachFloorPct(slotsP50, nextHcStartMs);\nconst hcFloorNightPrudentPct = hcNightReachActive ? hcReachFloorPct(slotsP80, nextNightHcStartMs) : 0;\nconst hcFloorPrudentPct = Math.max(hcReachFloorPct(slotsP80, nextHcStartMs), hcFloorNightPrudentPct);\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\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 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.\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 hcText = 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 hcText = 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" } }, { diff --git a/workflows/water-heater-ecs-v1.json b/workflows/water-heater-ecs-v1.json new file mode 100644 index 0000000..0849847 --- /dev/null +++ b/workflows/water-heater-ecs-v1.json @@ -0,0 +1,719 @@ +{ + "updatedAt": "2026-07-09T21:45:54.813Z", + "createdAt": "2026-04-23T00:41:43.725Z", + "id": "yX2j8m04e3pxaoQq", + "name": "Water Heater Advisor ECS 6 Nodes + Build + Execute", + "description": null, + "active": true, + "isArchived": false, + "nodes": [ + { + "parameters": { + "jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input ?? {};\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction getParisNowParts() {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date())\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const stamp = `${date} ${to2(hour)}:${parts.minute}:${parts.second}`;\n\n return {\n year: Number(parts.year),\n month: Number(parts.month),\n day: Number(parts.day),\n hour,\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n stamp,\n minNow: hour * 60 + Number(parts.minute),\n };\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\\\d{4})-(\\\\d{2})-(\\\\d{2}) (\\\\d{2}):(\\\\d{2}):(\\\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction cleanStateText(v) {\n const s = (v ?? '').toString().trim();\n const x = s.toLowerCase();\n return ['unknown', 'unavailable', 'none', 'null'].includes(x) ? '' : s;\n}\n\nconst cfg = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n batteryUsableCapacityKwh: 17.5,\n sampleWindowMinMin: 2,\n sampleWindowMinMax: 15,\n energyByDemandKwh: {\n low: { green: 0.9, auto: 1.4, boost: 2.0 },\n normal: { green: 1.4, auto: 2.2, boost: 3.1 },\n high: { green: 2.2, auto: 3.4, boost: 4.6 },\n three_showers: { green: 3.0, auto: 4.4, boost: 5.8 }\n }\n};\n\nconst raw = $input.first()?.json ?? {};\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\nconst nowMs = Date.now();\n\nconst soc = clamp(num(input.soc_percent, 0), 0, 100);\nconst batteryCurrentA = num(input.battery_current_a, 0);\nconst pvPowerW = Math.max(0, num(input.pv_power_w, 0));\nconst pvPowerAvg10mW = Math.max(0, num(input.pv_power_10m_avg_w, pvPowerW));\nconst houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));\nconst gridPowerW = num(input.grid_power_w, 0);\nconst gridIsOn = low(input.grid_state || 'off') === 'on';\nconst acInAvailable = num(input.victron_acin1_available, 0) === 1;\nconst gridAvailable = gridIsOn || acInAvailable;\nconst todayRemainingKwh = Math.max(0, num(input.today_remaining_kwh, 0));\nconst tomorrowKwh = Math.max(0, num(input.tomorrow_kwh, 0));\nconst advisorEnabled = low(input.advisor_enabled || 'off') === 'on';\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\n\nconst hotWaterDemandRaw = low(input.hot_water_demand || 'normal');\nconst hotWaterDemand = ['low', 'normal', 'high', 'three_showers'].includes(hotWaterDemandRaw)\n ? hotWaterDemandRaw\n : 'normal';\n\nconst greenPowerW = Math.max(0, num(input.green_power_w, 620));\nconst autoPowerW = Math.max(0, num(input.auto_power_w, 1200));\nconst boostPowerW = Math.max(0, num(input.boost_power_w, 2000));\nconst strongForecastKwh = Math.max(0, num(input.strong_forecast_kwh, 18));\nconst mediumForecastKwh = Math.max(0, num(input.medium_forecast_kwh, 10));\nconst strongPvNowW = Math.max(0, num(input.strong_pv_now_w, 2500));\nconst mediumPvNowW = Math.max(0, num(input.medium_pv_now_w, 1500));\nconst boostMinSocStrongForecastPct = clamp(num(input.morning_boost_min_soc_strong_day, 35), 0, 100);\nconst boostMinSocMediumForecastPct = clamp(num(input.boost_min_soc_medium_day, 50), 0, 100);\nconst endOfDaySocGuard = clamp(num(input.end_of_day_soc_guard, 85), 0, 100);\nconst endOfDaySocTarget = clamp(num(input.end_of_day_soc_target, 85), 0, 100);\nconst baseLoadRemainingKwh = Math.max(0, num(input.base_load_remaining_kwh, 0));\nconst flexibleSafetyMarginKwh = Math.max(0, num(input.flexible_safety_margin_kwh, 0));\nconst acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 8000));\nconst lastSignature = cleanStateText(input.last_signature);\n// Jour faible (07/10/2026) : decision du bilan, session reseau HC engagee,\n// energie du chauffe-eau sur 24 h (service d'historisation), propriete de la coupure.\nconst ecsNuit = low(input.ecs_nuit) === 'on';\nconst hcSessionCommitted = low(input.hc_session_committed) === 'on';\nconst ecs24hKwh = num(input.ecs_24h_kwh, NaN);\nconst offJourFaiblePrev = low(input.off_jour_faible_prev) === 'on';\nconst forecastSource = cleanStateText(input.forecast_source) || 'forecast_solar';\n\nreturn [{\n json: {\n meta: {\n workflow: 'water_heater_advisor',\n architecture: 'ecs_6nodes_simple_early_boost',\n node: 'ecs_normalize_inputs',\n timestamp_paris: now.stamp\n },\n cfg,\n now,\n nowMs,\n input,\n normalized: {\n soc,\n batteryCurrentA,\n pvPowerW,\n pvPowerAvg10mW,\n houseAcPowerW,\n gridPowerW,\n gridIsOn,\n acInAvailable,\n gridAvailable,\n todayRemainingKwh,\n tomorrowKwh,\n advisorEnabled,\n notificationsEnabled,\n hotWaterDemand,\n greenPowerW,\n autoPowerW,\n boostPowerW,\n strongForecastKwh,\n mediumForecastKwh,\n strongPvNowW,\n mediumPvNowW,\n boostMinSocStrongForecastPct,\n boostMinSocMediumForecastPct,\n endOfDaySocGuard,\n endOfDaySocTarget,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW,\n lastSignature,\n ecsNuit,\n hcSessionCommitted,\n ecs24hKwh,\n offJourFaiblePrev,\n forecastSource\n }\n }\n}];" + }, + "id": "1226082d-222c-4837-99d3-35094bd1c4f1", + "name": "ECS Normalize Inputs", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 160, + -160 + ] + }, + { + "parameters": { + "jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction cleanStateText(v) {\n const s = (v ?? '').toString().trim();\n const x = s.toLowerCase();\n return ['unknown', 'unavailable', 'none', 'null'].includes(x) ? '' : s;\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\\\d{4})-(\\\\d{2})-(\\\\d{2}) (\\\\d{2}):(\\\\d{2}):(\\\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction cleanParisStamp(v) {\n const s = cleanStateText(v);\n return parseParisStampToMs(s) !== null ? s : '';\n}\n\nfunction minutesSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 60000);\n}\n\nconst root = $input.first()?.json ?? {};\nconst cfg = root.cfg ?? {};\nconst now = root.now ?? {};\nconst nowMs = root.nowMs ?? null;\nconst input = root.input ?? {};\nconst normalized = root.normalized ?? {};\n\nconst { greenPowerW, autoPowerW, boostPowerW } = normalized;\n\nconst evcsPriorityActiveRaw = low(input.evcs_priority_active || 'off') === 'on';\nconst energyPriorityMode = cleanStateText(input.energy_priority_mode || 'normal').toLowerCase() || 'normal';\nconst energyPriorityReason = cleanStateText(input.energy_priority_reason);\nconst evcsPriorityUntilAt = cleanParisStamp(input.evcs_priority_until);\nconst evcsPriorityUntilMs = parseParisStampToMs(evcsPriorityUntilAt);\n\nconst evcsPriorityActive =\n evcsPriorityActiveRaw &&\n energyPriorityMode === 'evcs_priority' &&\n (\n evcsPriorityUntilMs === null ||\n (nowMs !== null && evcsPriorityUntilMs > nowMs + 1000)\n );\n\nconst waterHeaterSwitchOn = low(input.water_heater_switch || 'off') === 'on';\nconst waterHeaterEnergyTotalKwh = Math.max(0, num(input.water_heater_energy_total_kwh, 0));\nconst energyPrevKwh = num(input.energy_total_prev_kwh, NaN);\nconst energyPrevAt = cleanParisStamp(input.energy_prev_at);\nconst energyPrevMs = parseParisStampToMs(energyPrevAt);\nconst observedPowerPrevW = Math.max(0, num(input.observed_power_helper_w, 0));\nconst observedRegimePrev = cleanStateText(input.observed_regime_helper).toLowerCase();\nconst mismatchSinceAt = cleanParisStamp(input.mode_mismatch_since);\nconst mismatchSinceMsPrev = parseParisStampToMs(mismatchSinceAt);\nconst forcedOffByAdvisorPrev = low(input.forced_off_by_advisor || 'off') === 'on';\nconst forcedOffReasonPrev = cleanStateText(input.forced_off_reason);\nconst forcedOffAtPrev = cleanParisStamp(input.forced_off_at);\nconst forcedOffAtPrevMs = parseParisStampToMs(forcedOffAtPrev);\nconst pendingUserActionPrev = cleanStateText(input.pending_user_action_prev);\nconst restoreSurplusW = Math.max(0, num(input.restore_surplus_w, 2500));\nconst modeMismatchDelayMin = Math.max(1, num(input.mode_mismatch_delay_min, 15));\n\nconst sampleDurationMin = minutesSince(energyPrevMs, nowMs);\nconst energyDeltaKwh =\n Number.isFinite(energyPrevKwh)\n ? Math.max(0, waterHeaterEnergyTotalKwh - energyPrevKwh)\n : NaN;\n\nconst sampleIsUsable =\n Number.isFinite(sampleDurationMin) &&\n sampleDurationMin >= (cfg.sampleWindowMinMin ?? 2) &&\n sampleDurationMin <= (cfg.sampleWindowMinMax ?? 15) &&\n Number.isFinite(energyDeltaKwh);\n\nlet observedPowerW = 0;\nif (sampleIsUsable) {\n observedPowerW = Math.max(0, (energyDeltaKwh * 60000) / sampleDurationMin);\n} else if (waterHeaterSwitchOn && observedPowerPrevW > 0) {\n observedPowerW = observedPowerPrevW;\n}\n\nconst offThresholdW = Math.max(80, greenPowerW * 0.25);\nconst hpUpperW = (greenPowerW + autoPowerW) / 2;\nconst resistiveUpperW = (autoPowerW + boostPowerW) / 2;\n\nlet observedRegime = 'unknown';\nif (!waterHeaterSwitchOn) {\n observedRegime = 'off';\n} else if (!sampleIsUsable && observedRegimePrev) {\n observedRegime = observedRegimePrev;\n} else if (observedPowerW < offThresholdW) {\n observedRegime = 'off';\n} else if (observedPowerW < hpUpperW) {\n observedRegime = 'hp_low';\n} else if (observedPowerW < resistiveUpperW) {\n observedRegime = 'resistive_mid';\n} else {\n observedRegime = 'boost_high';\n}\n\nreturn [{\n json: {\n ...root,\n meta: {\n ...(root.meta ?? {}),\n node: 'ecs_evcs_and_water_heater_observation'\n },\n observation: {\n evcsPriorityActiveRaw,\n energyPriorityMode,\n energyPriorityReason,\n evcsPriorityUntilAt,\n evcsPriorityUntilMs,\n evcsPriorityActive,\n waterHeaterSwitchOn,\n waterHeaterEnergyTotalKwh,\n energyPrevKwh,\n energyPrevAt,\n energyPrevMs,\n observedPowerPrevW,\n observedRegimePrev,\n mismatchSinceAt,\n mismatchSinceMsPrev,\n forcedOffByAdvisorPrev,\n forcedOffReasonPrev,\n forcedOffAtPrev,\n forcedOffAtPrevMs,\n pendingUserActionPrev,\n restoreSurplusW,\n modeMismatchDelayMin,\n sampleDurationMin,\n energyDeltaKwh,\n sampleIsUsable,\n observedPowerW,\n observedRegime,\n offThresholdW,\n hpUpperW,\n resistiveUpperW\n }\n }\n}];" + }, + "id": "0979b36d-13b0-492a-bafb-8c99219bbdb5", + "name": "ECS EVCS & Water Heater Observation", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 384, + -160 + ] + }, + { + "parameters": { + "jsCode": "function clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nconst root = $input.first()?.json ?? {};\nconst cfg = root.cfg ?? {};\nconst normalized = root.normalized ?? {};\n\nconst {\n soc,\n batteryCurrentA,\n pvPowerW,\n pvPowerAvg10mW,\n houseAcPowerW,\n todayRemainingKwh,\n tomorrowKwh,\n advisorEnabled,\n hotWaterDemand,\n greenPowerW,\n autoPowerW,\n boostPowerW,\n strongForecastKwh,\n mediumForecastKwh,\n strongPvNowW,\n mediumPvNowW,\n boostMinSocStrongForecastPct,\n boostMinSocMediumForecastPct,\n endOfDaySocGuard,\n endOfDaySocTarget,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW\n} = normalized;\n\nconst targetSoc = Math.max(endOfDaySocTarget, endOfDaySocGuard);\nconst batteryCapacityKwh = cfg.batteryUsableCapacityKwh ?? 17.5;\n\nconst energyTable =\n (cfg.energyByDemandKwh && cfg.energyByDemandKwh[hotWaterDemand]) ||\n (cfg.energyByDemandKwh && cfg.energyByDemandKwh.normal) || {\n green: 1.4,\n auto: 2.2,\n boost: 3.1\n };\n\nconst greenEnergyKwh = energyTable.green;\nconst autoEnergyKwh = energyTable.auto;\nconst boostEnergyKwh = energyTable.boost;\n\nconst energyNeededToReachTargetKwh =\n Math.max(0, ((targetSoc - soc) / 100) * batteryCapacityKwh);\n\nfunction projectedEndSocAfterMode(loadKwh) {\n const netKwh =\n todayRemainingKwh -\n baseLoadRemainingKwh -\n flexibleSafetyMarginKwh -\n loadKwh;\n\n const deltaSoc = (netKwh / batteryCapacityKwh) * 100;\n return clamp(soc + deltaSoc, 0, 100);\n}\n\nfunction budgetMarginKwh(loadKwh) {\n return (\n todayRemainingKwh -\n baseLoadRemainingKwh -\n flexibleSafetyMarginKwh -\n loadKwh -\n energyNeededToReachTargetKwh\n );\n}\n\nconst projectedGreenSoc = projectedEndSocAfterMode(greenEnergyKwh);\nconst projectedAutoSoc = projectedEndSocAfterMode(autoEnergyKwh);\nconst projectedBoostSoc = projectedEndSocAfterMode(boostEnergyKwh);\n\nconst greenBudgetMarginKwh = budgetMarginKwh(greenEnergyKwh);\nconst autoBudgetMarginKwh = budgetMarginKwh(autoEnergyKwh);\nconst boostBudgetMarginKwh = budgetMarginKwh(boostEnergyKwh);\n\n// 07/10/2026 : la force de la journee se juge sur le reste du JOUR. Avant, le\n// lendemain suffisait (« || tomorrowKwh ») : le 06/10, journee de 14,5 kWh, le\n// boost etait conseille parce que mercredi etait annonce fort.\nconst strongForecast =\n todayRemainingKwh >= strongForecastKwh;\n\nconst mediumForecast =\n !strongForecast &&\n todayRemainingKwh >= mediumForecastKwh;\n\nconst forecastStrength =\n strongForecast ? 'strong' :\n mediumForecast ? 'medium' :\n 'weak';\n\nconst pv10mW = Number.isFinite(pvPowerAvg10mW) ? pvPowerAvg10mW : pvPowerW;\nconst netPvHeadroomW = Math.round(pv10mW - houseAcPowerW);\nconst pvCoversHouseNow = pvPowerW > houseAcPowerW;\nconst strongPvNow = pv10mW >= strongPvNowW || pvPowerW >= strongPvNowW;\nconst mediumPvNow = pv10mW >= mediumPvNowW || pvPowerW >= mediumPvNowW;\n\nconst pvStartedWell =\n mediumPvNow &&\n pvCoversHouseNow &&\n netPvHeadroomW >= Math.max(300, Math.round(greenPowerW * 0.5));\n\nconst acSafe = acLoadsMaxSafeW <= 0 ? true : houseAcPowerW <= acLoadsMaxSafeW;\nconst batteryNotDrainingHard = batteryCurrentA >= -2;\n\nconst greenAllowed =\n advisorEnabled &&\n acSafe &&\n (\n projectedGreenSoc >= targetSoc - 6 ||\n greenBudgetMarginKwh >= -1.0 ||\n soc >= targetSoc\n );\n\nconst autoAllowed =\n advisorEnabled &&\n acSafe &&\n (\n autoBudgetMarginKwh >= 0 ||\n projectedAutoSoc >= targetSoc ||\n (strongForecast && projectedAutoSoc >= targetSoc - 2)\n );\n\nconst boostAllowedByBudget =\n advisorEnabled &&\n acSafe &&\n (\n projectedBoostSoc >= targetSoc ||\n boostBudgetMarginKwh >= 0 ||\n (strongForecast && projectedBoostSoc >= targetSoc - 5) ||\n (strongForecast && boostBudgetMarginKwh >= -1.0)\n );\n\nconst earlyBoostStrongReady =\n boostAllowedByBudget &&\n soc >= boostMinSocStrongForecastPct &&\n pvStartedWell &&\n (strongForecast || strongPvNow);\n\nconst earlyBoostMediumReady =\n boostAllowedByBudget &&\n mediumForecast &&\n soc >= boostMinSocMediumForecastPct &&\n pvStartedWell &&\n netPvHeadroomW >= greenPowerW;\n\nconst autoReady =\n autoAllowed &&\n mediumPvNow &&\n pvCoversHouseNow &&\n netPvHeadroomW >= 0;\n\nconst greenReady =\n greenAllowed &&\n (\n pv10mW >= greenPowerW ||\n (pvPowerW >= greenPowerW && pvCoversHouseNow)\n );\n\nconst boostAllowed = earlyBoostStrongReady || earlyBoostMediumReady;\n\nreturn [{\n json: {\n ...root,\n meta: {\n ...(root.meta ?? {}),\n node: 'ecs_energy_model_and_early_boost_signals'\n },\n energyModel: {\n targetSoc,\n batteryCapacityKwh,\n greenEnergyKwh,\n autoEnergyKwh,\n boostEnergyKwh,\n energyNeededToReachTargetKwh,\n projectedGreenSoc,\n projectedAutoSoc,\n projectedBoostSoc,\n greenBudgetMarginKwh,\n autoBudgetMarginKwh,\n boostBudgetMarginKwh,\n forecastStrength,\n strongForecast,\n mediumForecast,\n strongPvNow,\n mediumPvNow,\n netPvHeadroomW,\n pvCoversHouseNow,\n pvStartedWell,\n acSafe,\n batteryNotDrainingHard,\n greenAllowed,\n autoAllowed,\n boostAllowedByBudget,\n boostAllowed,\n earlyBoostStrongReady,\n earlyBoostMediumReady,\n autoReady,\n greenReady\n }\n }\n}];" + }, + "id": "0becfc3a-3305-4a97-95b8-0fe1de446e37", + "name": "ECS Energy Model & Early Boost Signals", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 608, + -160 + ] + }, + { + "parameters": { + "jsCode": "const root = $input.first()?.json ?? {};\nconst normalized = root.normalized ?? {};\nconst energyModel = root.energyModel ?? {};\n\nconst {\n advisorEnabled,\n hotWaterDemand,\n soc,\n pvPowerW,\n pvPowerAvg10mW,\n houseAcPowerW,\n acLoadsMaxSafeW\n} = normalized;\n\nconst {\n targetSoc,\n forecastStrength,\n netPvHeadroomW,\n pvCoversHouseNow,\n acSafe,\n greenAllowed,\n earlyBoostStrongReady,\n earlyBoostMediumReady,\n autoReady,\n greenReady,\n projectedGreenSoc,\n projectedAutoSoc,\n projectedBoostSoc,\n greenBudgetMarginKwh,\n autoBudgetMarginKwh,\n boostBudgetMarginKwh\n} = energyModel;\n\nlet recommendedMode = 'off';\nlet adviceColor = 'red';\nlet state = 'off_waiting_pv';\nlet reason = [];\n\nif (!advisorEnabled) {\n recommendedMode = 'off';\n adviceColor = 'red';\n state = 'advisor_disabled';\n reason.push('Advisor chauffe-eau désactivé');\n}\nelse if (!acSafe) {\n recommendedMode = 'off';\n adviceColor = 'red';\n state = 'off_ac_loads_too_high';\n reason.push(`Charges maison trop élevées pour ECS (${Math.round(houseAcPowerW)} W > seuil ${Math.round(acLoadsMaxSafeW)} W)`);\n}\nelse if (earlyBoostStrongReady) {\n recommendedMode = 'boost';\n adviceColor = 'green';\n state = 'boost_early_strong_forecast';\n reason.push('Boost tôt autorisé : forecast fort + SOC suffisant + PV net maison favorable');\n}\nelse if (earlyBoostMediumReady) {\n recommendedMode = 'boost';\n adviceColor = 'orange';\n state = 'boost_early_medium_forecast';\n reason.push('Boost tôt autorisé : forecast moyen + SOC élevé + surplus net maison suffisant');\n}\nelse if (autoReady) {\n recommendedMode = 'auto';\n adviceColor = 'green';\n state = 'auto_from_pv_net';\n reason.push('Mode auto recommandé : le PV couvre déjà la maison');\n}\nelse if (greenReady) {\n recommendedMode = 'green';\n adviceColor = 'green';\n state = 'green_waiting_pv_ramp';\n reason.push('Mode green recommandé : démarrage PV détecté');\n}\nelse {\n recommendedMode = 'off';\n adviceColor = 'red';\n if (!pvCoversHouseNow) {\n state = 'wait_for_pv_to_cover_house';\n reason.push('Attente : le PV ne couvre pas encore la maison');\n } else if (!greenAllowed) {\n state = 'off_budget_guard';\n reason.push('Attente : garde-fou budget / SOC fin de journée');\n } else {\n state = 'wait_for_pv_or_soc';\n reason.push('Attente d\\'un meilleur niveau PV / forecast / SOC');\n }\n}\n\nreason.push(`Demande ECS: ${hotWaterDemand}`);\nreason.push(`Forecast: ${forecastStrength}`);\nreason.push(`SOC: ${Math.round(soc)}%`);\nreason.push(`PV: ${Math.round(pvPowerW)} W | PV10: ${Math.round(pvPowerAvg10mW)} W`);\nreason.push(`Maison: ${Math.round(houseAcPowerW)} W`);\nreason.push(`Surplus net estimé: ${Math.round(netPvHeadroomW)} W`);\n\nif (recommendedMode === 'boost') {\n reason.push(`Boost budget: marge ${boostBudgetMarginKwh.toFixed(2)} kWh | SOC fin ${projectedBoostSoc.toFixed(1)}%`);\n} else if (recommendedMode === 'auto') {\n reason.push(`Auto budget: marge ${autoBudgetMarginKwh.toFixed(2)} kWh | SOC fin ${projectedAutoSoc.toFixed(1)}%`);\n} else if (recommendedMode === 'green') {\n reason.push(`Green budget: marge ${greenBudgetMarginKwh.toFixed(2)} kWh | SOC fin ${projectedGreenSoc.toFixed(1)}%`);\n} else {\n reason.push(`Target fin de jour: ${targetSoc.toFixed(1)}%`);\n}\n\nconst baseRecommendedMode = recommendedMode;\nconst baseAdviceColor = adviceColor;\nconst baseState = state;\n\nreturn [{\n json: {\n ...root,\n meta: {\n ...(root.meta ?? {}),\n node: 'ecs_base_advisor_decision'\n },\n baseDecision: {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState\n }\n }\n}];" + }, + "id": "7a862398-6aeb-4720-91ed-fd2eb2cc9d7e", + "name": "ECS Base Advisor Decision", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 832, + -160 + ] + }, + { + "parameters": { + "jsCode": "function regimeRank(regime) {\n switch (regime) {\n case 'off': return 0;\n case 'hp_low': return 1;\n case 'resistive_mid': return 2;\n case 'boost_high': return 3;\n default: return -1;\n }\n}\n\nfunction allowedRankForMode(mode) {\n switch (mode) {\n case 'off': return 0;\n case 'green': return 1;\n case 'auto': return 2;\n case 'boost': return 3;\n default: return 0;\n }\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nconst root = $input.first()?.json ?? {};\nconst now = root.now ?? {};\nconst nowMs = root.nowMs ?? null;\nconst normalized = root.normalized ?? {};\nconst observation = root.observation ?? {};\nconst energyModel = root.energyModel ?? {};\nconst baseDecision = root.baseDecision ?? {};\n\nlet {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState\n} = baseDecision;\n\nreason = Array.isArray(reason) ? [...reason] : [];\n\nconst {\n advisorEnabled,\n soc,\n batteryCurrentA,\n pvPowerAvg10mW,\n houseAcPowerW,\n todayRemainingKwh,\n tomorrowKwh,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW,\n hotWaterDemand,\n gridIsOn,\n acInAvailable,\n gridAvailable\n} = normalized;\n\nconst {\n evcsPriorityActive,\n energyPriorityReason,\n waterHeaterSwitchOn,\n observedPowerW,\n observedRegime,\n mismatchSinceAt,\n mismatchSinceMsPrev,\n forcedOffByAdvisorPrev,\n forcedOffReasonPrev,\n forcedOffAtPrev,\n restoreSurplusW,\n modeMismatchDelayMin,\n sampleDurationMin,\n energyDeltaKwh,\n sampleIsUsable\n} = observation;\n\nconst {\n targetSoc,\n energyNeededToReachTargetKwh,\n greenEnergyKwh,\n autoEnergyKwh,\n boostEnergyKwh,\n projectedGreenSoc,\n projectedAutoSoc,\n projectedBoostSoc,\n greenBudgetMarginKwh,\n autoBudgetMarginKwh,\n boostBudgetMarginKwh,\n forecastStrength,\n netPvHeadroomW,\n pvCoversHouseNow,\n pvStartedWell,\n greenAllowed,\n autoAllowed,\n boostAllowed,\n batteryNotDrainingHard\n} = energyModel;\n\nif (advisorEnabled && evcsPriorityActive) {\n if (recommendedMode === 'boost' || recommendedMode === 'auto') {\n if (greenAllowed && batteryNotDrainingHard) {\n recommendedMode = 'green';\n adviceColor = 'orange';\n state = 'evcs_priority_green_cap';\n reason.push(`Priorité EVCS active : chauffe-eau plafonné à GREEN (${energyPriorityReason || 'raison non précisée'})`);\n } else {\n recommendedMode = 'off';\n adviceColor = 'red';\n state = 'evcs_priority_off_cap';\n reason.push(`Priorité EVCS active : chauffe-eau déconseillé (${energyPriorityReason || 'raison non précisée'})`);\n }\n } else if (recommendedMode === 'green' && (!greenAllowed || !batteryNotDrainingHard)) {\n recommendedMode = 'off';\n adviceColor = 'red';\n state = 'evcs_priority_green_blocked';\n reason.push(`Priorité EVCS active : GREEN devient lui-même trop coûteux (${energyPriorityReason || 'raison non précisée'})`);\n }\n}\n\nconst selectedLoadKwh =\n recommendedMode === 'green' ? greenEnergyKwh :\n recommendedMode === 'auto' ? autoEnergyKwh :\n recommendedMode === 'boost' ? boostEnergyKwh :\n 0;\n\nconst selectedProjectedSoc =\n recommendedMode === 'green' ? projectedGreenSoc :\n recommendedMode === 'auto' ? projectedAutoSoc :\n recommendedMode === 'boost' ? projectedBoostSoc :\n soc;\n\nconst selectedBudgetMarginKwh =\n recommendedMode === 'green' ? greenBudgetMarginKwh :\n recommendedMode === 'auto' ? autoBudgetMarginKwh :\n recommendedMode === 'boost' ? boostBudgetMarginKwh :\n todayRemainingKwh - baseLoadRemainingKwh - flexibleSafetyMarginKwh - energyNeededToReachTargetKwh;\n\nconst legacyAdviceOk = recommendedMode !== 'off';\n\nconst observedRank = regimeRank(observedRegime);\nconst allowedRank = allowedRankForMode(recommendedMode);\n\nconst mismatchActive =\n waterHeaterSwitchOn &&\n observedRank >= 0 &&\n allowedRank >= 0 &&\n observedRank > allowedRank;\n\nlet mismatchSeverity = 'none';\nif (mismatchActive) {\n if (recommendedMode === 'off') {\n mismatchSeverity = 'critical';\n } else if (observedRank - allowedRank >= 2) {\n mismatchSeverity = 'high';\n } else {\n mismatchSeverity = 'medium';\n }\n}\n\nconst mismatchSinceMsEffective = mismatchActive ? (mismatchSinceMsPrev ?? nowMs) : null;\nconst mismatchSinceAtEffective = mismatchActive ? (mismatchSinceMsPrev ? mismatchSinceAt : now.stamp) : '';\nconst mismatchDurationMin = mismatchActive && mismatchSinceMsEffective !== null ? Math.max(0, (nowMs - mismatchSinceMsEffective) / 60000) : 0;\n\nlet pendingUserAction = '';\nif (mismatchActive) {\n if (recommendedMode === 'off') {\n pendingUserAction = evcsPriorityActive\n ? 'Priorité EVCS : coupe temporairement le chauffe-eau ou laisse-le hors tension'\n : 'Le chauffe-eau consomme alors qu\\'il est déconseillé : coupe-le ou attends une meilleure fenêtre';\n } else if (recommendedMode === 'green') {\n pendingUserAction = observedRegime === 'boost_high'\n ? 'Passe le chauffe-eau en GREEN si possible, il semble en régime BOOST/résistance forte'\n : 'Passe le chauffe-eau en GREEN si possible, il semble plus gourmand que prévu';\n } else if (recommendedMode === 'auto') {\n pendingUserAction = 'Le chauffe-eau semble trop gourmand pour AUTO : repasse-le en AUTO modéré ou GREEN si possible';\n }\n}\n\nconst firstMismatchRun = mismatchActive && (mismatchSinceMsPrev === null || mismatchDurationMin < 1);\n\nconst realConflictNow =\n evcsPriorityActive &&\n (\n batteryCurrentA < -1 ||\n pvPowerAvg10mW < restoreSurplusW ||\n houseAcPowerW > acLoadsMaxSafeW ||\n recommendedMode === 'off' ||\n observedRegime === 'boost_high' ||\n (recommendedMode === 'green' && observedRegime === 'resistive_mid')\n );\n\nconst forceOffNow =\n advisorEnabled &&\n waterHeaterSwitchOn &&\n !forcedOffByAdvisorPrev &&\n mismatchActive &&\n mismatchDurationMin >= modeMismatchDelayMin &&\n realConflictNow;\n\nconst restoreEnergyOk =\n pvPowerAvg10mW >= restoreSurplusW &&\n batteryCurrentA >= -1 &&\n houseAcPowerW <= acLoadsMaxSafeW;\n\nconst restoreNow =\n advisorEnabled &&\n forcedOffByAdvisorPrev &&\n !waterHeaterSwitchOn &&\n (\n (!evcsPriorityActive && recommendedMode !== 'off') ||\n restoreEnergyOk\n );\n\nlet forcedOffByAdvisor = forcedOffByAdvisorPrev;\nlet forcedOffReason = forcedOffReasonPrev;\nlet forcedOffAt = forcedOffAtPrev;\nlet protectionState = 'none';\nlet powerSwitchAction = 'none';\nlet powerSwitchDesiredOn = waterHeaterSwitchOn;\n\nif (forceOffNow) {\n powerSwitchAction = 'turn_off';\n powerSwitchDesiredOn = false;\n forcedOffByAdvisor = true;\n forcedOffReason = `Coupure temporaire ECS: priorité EVCS + régime ${observedRegime} trop gourmand`;\n forcedOffAt = now.stamp;\n protectionState = 'forced_off_now_evcs_priority';\n state = 'forced_off_now_evcs_priority';\n adviceColor = 'red';\n reason.push(forcedOffReason);\n}\nelse if (restoreNow) {\n powerSwitchAction = 'turn_on';\n powerSwitchDesiredOn = true;\n forcedOffByAdvisor = false;\n forcedOffReason = '';\n protectionState = 'restored_after_evcs_priority';\n state = 'restored_after_evcs_priority';\n reason.push('Remise sous tension ECS : conflit EVCS terminé ou surplus redevenu suffisant');\n}\nelse if (forcedOffByAdvisorPrev && !waterHeaterSwitchOn) {\n protectionState = 'forced_off_wait_restore';\n state = 'forced_off_wait_restore';\n reason.push(`ECS temporairement coupé par l'advisor (${forcedOffReasonPrev || 'raison non précisée'})`);\n}\nelse if (mismatchActive && evcsPriorityActive) {\n protectionState = 'watch_mismatch_evcs_priority';\n state = 'watch_mismatch_evcs_priority';\n reason.push(`Écart de régime ECS sous priorité EVCS depuis ${round1(mismatchDurationMin)} min`);\n}\n\nif (!forceOffNow && !restoreNow && forcedOffByAdvisorPrev && waterHeaterSwitchOn) {\n forcedOffByAdvisor = false;\n forcedOffReason = '';\n}\n\n// =====================================================\n// JOUR FAIBLE : EAU CHAUDE EN HC DE NUIT (07/10/2026, option b du proprietaire)\n// =====================================================\n// Le bilan decide une fois par jour (input_boolean.evcs_balance_ecs_nuit) : si\n// le solaire prevu ne remplit pas la batterie pour la nuit, EDF sera ferme de\n// toute facon -> le chauffe-eau est COUPE le jour et chauffe pendant une\n// session reseau HC engagee (contacteur ferme), au tarif creux, jamais sur la\n// batterie. Securite eau chaude : moins de ECS_EAU_FROIDE_KWH en 24 h -> il\n// chauffe quand meme. Energie 24 h illisible -> aucune restriction (le\n// comportement d'avant revient, jamais un ballon froid par panne de capteur).\n// Jour normal : le chauffe-eau coupe par cette regle est remis sous tension.\nconst ECS_EAU_FROIDE_KWH = 1.5;\nconst {\n ecsNuit = false, hcSessionCommitted = false, ecs24hKwh = NaN, offJourFaiblePrev = false,\n} = normalized;\nlet offJourFaible = false;\nlet jourFaibleState = 'none';\nif (advisorEnabled && ecsNuit) {\n const energieConnue = Number.isFinite(ecs24hKwh);\n const eauFroide = energieConnue && ecs24hKwh < ECS_EAU_FROIDE_KWH;\n const heuresCreusesEdf = hcSessionCommitted && gridIsOn;\n const veutChauffer = !energieConnue || heuresCreusesEdf || eauFroide;\n offJourFaible = true;\n jourFaibleState = !energieConnue ? 'jour_faible_energie_illisible'\n : heuresCreusesEdf ? 'jour_faible_chauffe_hc_edf'\n : eauFroide ? 'jour_faible_eau_froide'\n : 'jour_faible_coupe';\n if (veutChauffer !== waterHeaterSwitchOn) {\n powerSwitchAction = veutChauffer ? 'turn_on' : 'turn_off';\n powerSwitchDesiredOn = veutChauffer;\n } else {\n powerSwitchAction = 'none';\n powerSwitchDesiredOn = waterHeaterSwitchOn;\n }\n forcedOffByAdvisor = false;\n forcedOffReason = '';\n protectionState = jourFaibleState;\n state = jourFaibleState;\n reason.push(\n jourFaibleState === 'jour_faible_chauffe_hc_edf' ? 'Jour faible : eau chaude en heures creuses sur EDF (session reseau engagee)'\n : jourFaibleState === 'jour_faible_eau_froide' ? `Jour faible mais seulement ${round1(ecs24hKwh)} kWh en 24 h : chauffe pour garantir l eau chaude`\n : jourFaibleState === 'jour_faible_energie_illisible' ? 'Jour faible, energie 24 h illisible : aucune restriction'\n : 'Jour faible : chauffe-eau coupe, il chauffera en heures creuses sur EDF'\n );\n} else if (advisorEnabled && offJourFaiblePrev && !waterHeaterSwitchOn) {\n powerSwitchAction = 'turn_on';\n powerSwitchDesiredOn = true;\n jourFaibleState = 'jour_normal_remise_sous_tension';\n protectionState = jourFaibleState;\n state = jourFaibleState;\n reason.push('Jour normal : chauffe-eau remis sous tension (il chauffe au solaire)');\n}\n\nreturn [{\n json: {\n ...root,\n meta: {\n ...(root.meta ?? {}),\n node: 'ecs_final_arbitration_and_protection'\n },\n finalDecision: {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState,\n selectedLoadKwh,\n selectedProjectedSoc,\n selectedBudgetMarginKwh,\n legacyAdviceOk,\n mismatchActive,\n mismatchSeverity,\n mismatchSinceAtEffective,\n mismatchSinceMsEffective,\n mismatchDurationMin,\n pendingUserAction,\n firstMismatchRun,\n realConflictNow,\n forceOffNow,\n restoreEnergyOk,\n restoreNow,\n forcedOffByAdvisor,\n forcedOffReason,\n forcedOffAt,\n protectionState,\n powerSwitchAction,\n powerSwitchDesiredOn,\n offJourFaible,\n jourFaibleState\n }\n }\n}];" + }, + "id": "994b3242-616d-46e7-83ed-d236a117d15c", + "name": "ECS Final Arbitration & Protection", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1056, + -160 + ] + }, + { + "parameters": { + "jsCode": "function round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round2(v) {\n return Math.round(v * 100) / 100;\n}\n\nfunction safeText(v, max = 255) {\n return (v ?? '').toString().slice(0, max);\n}\n\nfunction boolService(flag) {\n return flag ? 'turn_on' : 'turn_off';\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\\\d{4})-(\\\\d{2})-(\\\\d{2}) (\\\\d{2}):(\\\\d{2}):(\\\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nconst root = $input.first()?.json ?? {};\nconst cfg = root.cfg ?? {};\nconst now = root.now ?? {};\nconst input = root.input ?? {};\nconst normalized = root.normalized ?? {};\nconst observation = root.observation ?? {};\nconst energyModel = root.energyModel ?? {};\nconst finalDecision = root.finalDecision ?? {};\n\nconst {\n soc,\n batteryCurrentA,\n pvPowerW,\n pvPowerAvg10mW,\n houseAcPowerW,\n gridPowerW,\n gridIsOn,\n acInAvailable,\n gridAvailable,\n todayRemainingKwh,\n tomorrowKwh,\n advisorEnabled,\n notificationsEnabled,\n hotWaterDemand,\n greenPowerW,\n autoPowerW,\n boostPowerW,\n strongForecastKwh,\n mediumForecastKwh,\n strongPvNowW,\n mediumPvNowW,\n boostMinSocStrongForecastPct,\n boostMinSocMediumForecastPct,\n endOfDaySocGuard,\n endOfDaySocTarget,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW,\n lastSignature\n} = normalized;\n\nconst {\n evcsPriorityActive,\n energyPriorityMode,\n energyPriorityReason,\n evcsPriorityUntilAt,\n waterHeaterSwitchOn,\n waterHeaterEnergyTotalKwh,\n restoreSurplusW,\n modeMismatchDelayMin,\n sampleDurationMin,\n energyDeltaKwh,\n sampleIsUsable,\n observedPowerW,\n observedRegime\n} = observation;\n\nconst {\n targetSoc,\n batteryCapacityKwh,\n greenEnergyKwh,\n autoEnergyKwh,\n boostEnergyKwh,\n energyNeededToReachTargetKwh,\n projectedGreenSoc,\n projectedAutoSoc,\n projectedBoostSoc,\n greenBudgetMarginKwh,\n autoBudgetMarginKwh,\n boostBudgetMarginKwh,\n forecastStrength,\n strongForecast,\n mediumForecast,\n strongPvNow,\n mediumPvNow,\n netPvHeadroomW,\n pvCoversHouseNow,\n pvStartedWell,\n acSafe,\n batteryNotDrainingHard,\n greenAllowed,\n autoAllowed,\n boostAllowedByBudget,\n boostAllowed,\n earlyBoostStrongReady,\n earlyBoostMediumReady,\n autoReady,\n greenReady\n} = energyModel;\n\nlet {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState,\n selectedLoadKwh,\n selectedProjectedSoc,\n selectedBudgetMarginKwh,\n legacyAdviceOk,\n mismatchActive,\n mismatchSeverity,\n mismatchSinceAtEffective,\n mismatchSinceMsEffective,\n mismatchDurationMin,\n pendingUserAction,\n firstMismatchRun,\n realConflictNow,\n forceOffNow,\n restoreEnergyOk,\n restoreNow,\n forcedOffByAdvisor,\n forcedOffReason,\n forcedOffAt,\n protectionState,\n powerSwitchAction,\n powerSwitchDesiredOn,\n offJourFaible = false\n} = finalDecision;\n\nreason = Array.isArray(reason) ? [...reason] : [];\n\nconst forcedOffAtSafe = forcedOffAt && parseParisStampToMs(forcedOffAt) !== null ? forcedOffAt : '';\nconst forcedOffReasonSafe = forcedOffByAdvisor ? safeText(forcedOffReason, 255) : '';\nconst pendingUserActionSafe = safeText(pendingUserAction, 255);\n\nif (reason.length === 0) {\n if (recommendedMode === 'boost') reason.push('Boost recommandé');\n else if (recommendedMode === 'auto') reason.push('Mode auto recommandé');\n else if (recommendedMode === 'green') reason.push('Mode green recommandé');\n else reason.push('Chauffe-eau déconseillé pour le moment');\n}\n\nreason.push(`Demande ECS: ${hotWaterDemand}`);\nreason.push(`Conseil final: ${recommendedMode}`);\nreason.push(`Conseil initial: ${baseRecommendedMode}`);\nreason.push(`Couleur conseil: ${adviceColor}`);\nreason.push(`Forecast du jour: ${round1(todayRemainingKwh)} kWh`);\nreason.push(`Production demain: ${round1(tomorrowKwh)} kWh`);\nreason.push(`Classe forecast: ${forecastStrength}`);\nreason.push(`PV actuel: ${Math.round(pvPowerW)} W | PV10: ${Math.round(pvPowerAvg10mW)} W`);\nreason.push(`Charges AC maison: ${Math.round(houseAcPowerW)} W`);\nreason.push(`Surplus net estimé: ${Math.round(netPvHeadroomW)} W`);\nreason.push(`PV couvre maison: ${pvCoversHouseNow ? 'oui' : 'non'} | PV bien démarré: ${pvStartedWell ? 'oui' : 'non'}`);\nreason.push(`Batterie: SOC ${round1(soc)}% | courant ${round1(batteryCurrentA)} A`);\nreason.push(`Réseau: cmd=${gridIsOn ? 'ON' : 'OFF'} | acin=${acInAvailable ? 'OK' : 'KO'} | dispo=${gridAvailable ? 'oui' : 'non'}`);\nreason.push(`Target fin de jour: ${round1(targetSoc)}%`);\nreason.push(`Base load restante: ${round1(baseLoadRemainingKwh)} kWh`);\nreason.push(`Marge sécurité: ${round1(flexibleSafetyMarginKwh)} kWh`);\nreason.push(`Green ${round1(greenEnergyKwh)}kWh → SOC fin ${round1(projectedGreenSoc)}% | marge ${round1(greenBudgetMarginKwh)}kWh`);\nreason.push(`Auto ${round1(autoEnergyKwh)}kWh → SOC fin ${round1(projectedAutoSoc)}% | marge ${round1(autoBudgetMarginKwh)}kWh`);\nreason.push(`Boost ${round1(boostEnergyKwh)}kWh → SOC fin ${round1(projectedBoostSoc)}% | marge ${round1(boostBudgetMarginKwh)}kWh`);\nreason.push(`Régime observé: ${observedRegime} | puissance ≈ ${Math.round(observedPowerW)} W | delta ${sampleIsUsable ? round2(energyDeltaKwh) : 'n/a'} kWh/${sampleIsUsable ? round1(sampleDurationMin) : 'n/a'} min`);\nreason.push(`Prio EVCS: ${evcsPriorityActive ? 'ON' : 'OFF'}${energyPriorityReason ? ` | ${energyPriorityReason}` : ''}`);\nreason.push(`Mismatch: ${mismatchActive ? 'oui' : 'non'} | sévérité=${mismatchSeverity} | durée=${round1(mismatchDurationMin)} min`);\nreason.push(`Protection ECS: ${protectionState}`);\nif (pendingUserActionSafe) reason.push(`Action attendue: ${pendingUserActionSafe}`);\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n `d=${hotWaterDemand}`,\n `m=${recommendedMode}`,\n `base=${baseRecommendedMode}`,\n `c=${adviceColor}`,\n `fs=${strongForecast ? '1' : '0'}`,\n `fm=${mediumForecast ? '1' : '0'}`,\n `pb=${pvStartedWell ? '1' : '0'}`,\n `soc=${round1(soc)}`,\n `pv=${Math.round(pvPowerW)}`,\n `pv10=${Math.round(pvPowerAvg10mW)}`,\n `npv=${Math.round(netPvHeadroomW)}`,\n `obs=${observedRegime}`,\n `prio=${evcsPriorityActive ? '1' : '0'}`,\n `cut=${forcedOffByAdvisor ? '1' : '0'}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\nlet notifyWanted = false;\nlet notifyTitle = 'Water Heater Advisor';\nlet notifyMessage = '';\n\nif (notificationsEnabled) {\n if (forceOffNow) {\n notifyWanted = true;\n notifyTitle = 'ECS coupé temporairement';\n notifyMessage = forcedOffReasonSafe || 'Le chauffe-eau a été coupé temporairement';\n } else if (restoreNow) {\n notifyWanted = true;\n notifyTitle = 'ECS remis sous tension';\n notifyMessage = 'Le chauffe-eau est réalimenté car la priorité EVCS est terminée ou le surplus est redevenu suffisant.';\n } else if (firstMismatchRun && pendingUserActionSafe) {\n notifyWanted = true;\n notifyTitle = 'Action utilisateur ECS conseillée';\n notifyMessage = pendingUserActionSafe;\n } else if (signatureChanged) {\n notifyWanted = true;\n notifyTitle = `Water Heater Advisor: ${recommendedMode.toUpperCase()} / ${adviceColor.toUpperCase()}`;\n notifyMessage = `Demande=${hotWaterDemand} | régime=${observedRegime} | SOC fin estimé=${round1(selectedProjectedSoc)}% | marge=${round1(selectedBudgetMarginKwh)} kWh`;\n }\n}\nconst BUDGET_MARGIN_MIN = -50;\nconst BUDGET_MARGIN_MAX = 50;\nconst result = {\n timestamp_paris: now.stamp,\n inputs: {\n soc_percent: soc,\n battery_current_a: batteryCurrentA,\n pv_power_w: pvPowerW,\n pv_power_10m_avg_w: pvPowerAvg10mW,\n house_ac_power_w: houseAcPowerW,\n grid_power_w: gridPowerW,\n grid_state: gridIsOn ? 'on' : 'off',\n victron_acin1_available: acInAvailable ? 1 : 0,\n today_remaining_kwh: round2(todayRemainingKwh),\n tomorrow_kwh: round2(tomorrowKwh),\n advisor_enabled: advisorEnabled,\n notifications_enabled: notificationsEnabled,\n hot_water_demand: hotWaterDemand,\n green_power_w: greenPowerW,\n auto_power_w: autoPowerW,\n boost_power_w: boostPowerW,\n strong_forecast_kwh: strongForecastKwh,\n medium_forecast_kwh: mediumForecastKwh,\n strong_pv_now_w: strongPvNowW,\n medium_pv_now_w: mediumPvNowW,\n boost_min_soc_strong_forecast_pct: boostMinSocStrongForecastPct,\n boost_min_soc_medium_forecast_pct: boostMinSocMediumForecastPct,\n end_of_day_soc_guard: endOfDaySocGuard,\n end_of_day_soc_target: endOfDaySocTarget,\n base_load_remaining_kwh: baseLoadRemainingKwh,\n flexible_safety_margin_kwh: flexibleSafetyMarginKwh,\n ac_loads_max_safe_w: acLoadsMaxSafeW,\n water_heater_switch: waterHeaterSwitchOn,\n water_heater_energy_total_kwh: round2(waterHeaterEnergyTotalKwh),\n last_signature: lastSignature || null,\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason || null,\n evcs_priority_until: evcsPriorityUntilAt || null\n },\n computed: {\n forecast_strength: forecastStrength,\n strong_forecast: strongForecast,\n medium_forecast: mediumForecast,\n strong_pv_now: strongPvNow,\n medium_pv_now: mediumPvNow,\n net_pv_headroom_w: Math.round(netPvHeadroomW),\n pv_covers_house_now: pvCoversHouseNow,\n pv_started_well: pvStartedWell,\n battery_usable_capacity_kwh_internal: batteryCapacityKwh,\n energy_needed_to_reach_target_kwh: round2(energyNeededToReachTargetKwh),\n green_energy_kwh: round2(greenEnergyKwh),\n auto_energy_kwh: round2(autoEnergyKwh),\n boost_energy_kwh: round2(boostEnergyKwh),\n projected_green_soc: round1(projectedGreenSoc),\n projected_auto_soc: round1(projectedAutoSoc),\n projected_boost_soc: round1(projectedBoostSoc),\n green_budget_margin_kwh: round2(greenBudgetMarginKwh),\n auto_budget_margin_kwh: round2(autoBudgetMarginKwh),\n boost_budget_margin_kwh: round2(boostBudgetMarginKwh),\n ac_safe: acSafe,\n battery_not_draining_hard: batteryNotDrainingHard,\n green_allowed: greenAllowed,\n auto_allowed: autoAllowed,\n boost_allowed_by_budget: boostAllowedByBudget,\n boost_allowed: boostAllowed,\n early_boost_strong_ready: earlyBoostStrongReady,\n early_boost_medium_ready: earlyBoostMediumReady,\n auto_ready: autoReady,\n green_ready: greenReady,\n base_recommended_mode: baseRecommendedMode,\n base_advice_color: baseAdviceColor,\n base_state: baseState,\n observed_power_w: Math.round(observedPowerW),\n observed_regime: observedRegime,\n sample_duration_min: sampleIsUsable ? round1(sampleDurationMin) : null,\n energy_delta_kwh: sampleIsUsable ? round2(energyDeltaKwh) : null,\n mismatch_active: mismatchActive,\n mismatch_severity: mismatchSeverity,\n mismatch_duration_min: round1(mismatchDurationMin),\n evcs_priority_active: evcsPriorityActive,\n protection_state: protectionState,\n real_conflict_now: realConflictNow,\n restore_energy_ok: restoreEnergyOk,\n restore_surplus_w: restoreSurplusW,\n mode_mismatch_delay_min: modeMismatchDelayMin\n },\n decision: {\n recommended_mode: recommendedMode,\n advice_color: adviceColor,\n state,\n legacy_advice_ok: legacyAdviceOk,\n selected_load_kwh: round2(selectedLoadKwh),\n projected_end_of_day_soc: round1(selectedProjectedSoc),\n budget_margin_kwh: round2(selectedBudgetMarginKwh),\n observed_power_w: Math.round(observedPowerW),\n observed_regime: observedRegime,\n mismatch_active: mismatchActive,\n mismatch_severity: mismatchSeverity,\n mismatch_duration_min: round1(mismatchDurationMin),\n pending_user_action: pendingUserActionSafe,\n power_switch_action: powerSwitchAction,\n power_switch_desired_on: powerSwitchDesiredOn,\n force_off_now: forceOffNow,\n restore_now: restoreNow,\n forced_off_by_advisor: forcedOffByAdvisor,\n forced_off_reason: forcedOffReasonSafe,\n forced_off_at: forcedOffByAdvisor ? forcedOffAtSafe || null : null,\n signature,\n signature_changed: signatureChanged,\n should_notify: notifyWanted,\n reason,\n short_reason: shortReason\n },\n ha_service_calls: {\n helper_recommended_mode: {\n domain: 'input_select',\n service: 'select_option',\n data: {\n entity_id: 'input_select.victron_water_heater_recommended_mode',\n option: recommendedMode\n }\n },\n helper_advice_color: {\n domain: 'input_select',\n service: 'select_option',\n data: {\n entity_id: 'input_select.victron_water_heater_advice_color',\n option: adviceColor\n }\n },\n helper_state: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_advice_state',\n value: state\n }\n },\n helper_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_advice_reason',\n value: shortReason || 'Aucune raison'\n }\n },\n helper_signature: {\n execute: signatureChanged,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_advice_last_signature',\n value: signature\n }\n },\n helper_projected_soc: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_projected_end_of_day_soc',\n value: round1(selectedProjectedSoc)\n }\n },\nhelper_budget_margin: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_budget_margin_kwh',\n value: Math.max(BUDGET_MARGIN_MIN, Math.min(BUDGET_MARGIN_MAX, round2(selectedBudgetMarginKwh)))\n }\n},\n helper_load_kwh: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_load_water_heater_kwh',\n value: round2(selectedLoadKwh)\n }\n },\n helper_legacy_advice_ok: {\n domain: 'input_boolean',\n service: boolService(legacyAdviceOk),\n data: {\n entity_id: 'input_boolean.victron_advice_water_heater_ok'\n }\n },\n helper_green_allowed: {\n domain: 'input_boolean',\n service: boolService(greenAllowed),\n data: {\n entity_id: 'input_boolean.victron_water_heater_green_allowed'\n }\n },\n helper_auto_allowed: {\n domain: 'input_boolean',\n service: boolService(autoAllowed),\n data: {\n entity_id: 'input_boolean.victron_water_heater_auto_allowed'\n }\n },\n helper_boost_allowed: {\n domain: 'input_boolean',\n service: boolService(boostAllowed),\n data: {\n entity_id: 'input_boolean.victron_water_heater_boost_allowed'\n }\n },\n helper_observed_power: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_observed_power_w',\n value: Math.round(observedPowerW)\n }\n },\n helper_observed_regime: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_observed_regime',\n value: observedRegime\n }\n },\n helper_pending_user_action: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_pending_user_action',\n value: pendingUserActionSafe\n }\n },\n helper_mismatch_since: {\n execute: mismatchActive && !!mismatchSinceAtEffective,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_water_heater_mode_mismatch_since',\n datetime: mismatchSinceAtEffective\n }\n },\n helper_energy_prev_kwh: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_energy_total_prev_kwh',\n value: round2(waterHeaterEnergyTotalKwh)\n }\n },\n helper_energy_prev_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_water_heater_energy_prev_at',\n datetime: now.stamp\n }\n },\n helper_forced_off_by_advisor: {\n domain: 'input_boolean',\n service: boolService(forcedOffByAdvisor),\n data: {\n entity_id: 'input_boolean.victron_water_heater_forced_off_by_advisor'\n }\n },\n helper_forced_off_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_forced_off_reason',\n value: forcedOffReasonSafe\n }\n },\n helper_forced_off_at: {\n execute: forceOffNow && !!forcedOffAtSafe,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_water_heater_forced_off_at',\n datetime: forcedOffAtSafe\n }\n },\n // Jour faible (07/10/2026) : la coupure appartient a la regle « eau chaude en HC de nuit ».\n helper_off_jour_faible: {\n domain: 'input_boolean',\n service: boolService(offJourFaible),\n data: {\n entity_id: 'input_boolean.victron_water_heater_off_jour_faible'\n }\n },\n water_heater_power_switch: {\n execute: powerSwitchAction !== 'none',\n domain: 'switch',\n service: powerSwitchDesiredOn ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.tt_chauffe_eau_pleumeur_switch'\n }\n },\n logbook: {\n execute: signatureChanged || forceOffNow || restoreNow,\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Water Heater Advisor',\n message: `${recommendedMode}/${adviceColor} | regime=${observedRegime} | prioEVCS=${evcsPriorityActive ? 'on' : 'off'} | cut=${forcedOffByAdvisor ? 'on' : 'off'} | ${shortReason}`,\n entity_id: 'switch.tt_chauffe_eau_pleumeur_switch'\n }\n },\n mobile_notification: {\n execute: notifyWanted,\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: notifyTitle,\n message: notifyMessage,\n data: {\n push: {\n 'interruption-level': 'time-sensitive',\n sound: { name: 'default' }\n },\n ttl: 0,\n priority: 'high'\n }\n }\n }\n }\n};\n\nreturn [{ json: result }];" + }, + "id": "481de0f8-22a8-4aae-b47d-493098383c6c", + "name": "ECS Output Builder", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1280, + -160 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}\n", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + ($env.HA_TOKEN || '') }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.data }}", + "options": { + "response": { + "response": { + "responseFormat": "json" + } + }, + "timeout": 10000 + } + }, + "id": "89d75698-da63-4598-a35b-86f0ade8a11c", + "name": "Execute HA Service", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1728, + -160 + ], + "continueOnFail": true + }, + { + "parameters": { + "rule": { + "interval": [ + { + "field": "minutes" + } + ] + } + }, + "id": "edabb941-0e10-429b-bf0e-9af229a08d28", + "name": "Schedule Trigger", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [ + -288, + -160 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=http://10.10.10.17:8123/api/template\n", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Accept", + "value": "text/plain" + } + ] + }, + "sendBody": true, + "bodyParameters": { + "parameters": [ + { + "name": "template", + "value": "{#- 07/10/2026 : prevision lue comme le bilan (input_select.evcs_balance_forecast_source).\n VRM : reste du jour = heures restantes de la courbe horaire (heure en cours au\n prorata), demain = somme de la courbe de demain. Courbe absente ou\n malformee -> repli sur Forecast.Solar. -#}\n{%- set src = states('input_select.evcs_balance_forecast_source') -%}\n{%- set vt = states('input_text.evcs_balance_vrm_today').split(',') -%}\n{%- set vd = states('input_text.evcs_balance_vrm_tomorrow').split(',') -%}\n{%- set p = namespace(reste=0, demain=0, ok=(src == 'vrm' and vt | length == 24 and vd | length == 24)) -%}\n{%- if p.ok -%}\n {%- for i in range(now().hour, 24) -%}\n {%- set v = vt[i] | float(0) -%}\n {%- set p.reste = p.reste + (v * (1 - now().minute / 60) if i == now().hour else v) -%}\n {%- endfor -%}\n {%- for v in vd -%}{%- set p.demain = p.demain + (v | float(0)) -%}{%- endfor -%}\n{%- endif -%}\n{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"pv_power_10m_avg_w\": states(\"sensor.victron_pv_power_10m_avg\"),\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"victron_acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n\n \"today_remaining_kwh\": (p.reste | round(2)) if p.ok else states(\"sensor.energy_production_today_remaining\"),\n \"tomorrow_kwh\": (p.demain | round(2)) if p.ok else states(\"sensor.energy_production_tomorrow\"),\n \"forecast_source\": \"vrm\" if p.ok else \"forecast_solar\",\n\n \"ecs_nuit\": states(\"input_boolean.evcs_balance_ecs_nuit\"),\n \"ecs_nuit_jour\": states(\"input_text.evcs_balance_ecs_nuit_jour\"),\n \"hc_session_committed\": states(\"input_boolean.victron_evcs_night_grid_session_committed\"),\n \"ecs_24h_kwh\": states(\"sensor.evcs_chauffe_eau_24h_kwh\"),\n \"off_jour_faible_prev\": states(\"input_boolean.victron_water_heater_off_jour_faible\"),\n\n \"advisor_enabled\": states(\"input_boolean.victron_water_heater_advisor_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_water_heater_notifications\"),\n\n \"hot_water_demand\": states(\"input_select.victron_water_heater_hot_water_demand\"),\n \"morning_boost_priority_enabled\": states(\"input_boolean.victron_water_heater_morning_boost_priority_enabled\"),\n \"morning_boost_latest_time\": states(\"input_datetime.victron_water_heater_morning_boost_latest_time\"),\n \"late_red_time\": states(\"input_datetime.victron_water_heater_late_red_time\"),\n\n \"green_power_w\": states(\"input_number.victron_water_heater_green_power_w\"),\n \"auto_power_w\": states(\"input_number.victron_water_heater_auto_power_w\"),\n \"boost_power_w\": states(\"input_number.victron_water_heater_boost_power_w\"),\n\n \"strong_forecast_kwh\": states(\"input_number.victron_water_heater_strong_forecast_kwh\"),\n \"medium_forecast_kwh\": states(\"input_number.victron_water_heater_medium_forecast_kwh\"),\n \"strong_pv_now_w\": states(\"input_number.victron_water_heater_strong_pv_now_w\"),\n \"medium_pv_now_w\": states(\"input_number.victron_water_heater_medium_pv_now_w\"),\n\n \"morning_boost_min_soc_strong_day\": states(\"input_number.victron_water_heater_morning_boost_min_soc_strong_day\"),\n \"boost_min_soc_medium_day\": states(\"input_number.victron_water_heater_boost_min_soc_medium_day\"),\n \"end_of_day_soc_guard\": states(\"input_number.victron_water_heater_end_of_day_soc_guard\"),\n\n \"end_of_day_soc_target\": states(\"input_number.victron_end_of_day_soc_target\"),\n \"base_load_remaining_kwh\": states(\"input_number.victron_base_load_remaining_kwh\"),\n \"flexible_safety_margin_kwh\": states(\"input_number.victron_flexible_safety_margin_kwh\"),\n \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\n\n \"day_start_time\": states(\"input_datetime.victron_flexible_day_start_time\"),\n \"day_stop_time\": states(\"input_datetime.victron_flexible_day_stop_time\"),\n\n \"last_signature\": states(\"input_text.victron_water_heater_advice_last_signature\"),\n\n \"evcs_priority_active\": states(\"input_boolean.victron_evcs_priority_active\"),\n \"energy_priority_mode\": states(\"input_select.victron_energy_priority_mode\"),\n \"energy_priority_reason\": states(\"input_text.victron_energy_priority_reason\"),\n \"evcs_priority_until\": states(\"input_datetime.victron_evcs_priority_until\"),\n\n \"water_heater_switch\": states(\"switch.tt_chauffe_eau_pleumeur_switch\"),\n \"water_heater_energy_total_kwh\": states(\"sensor.tt_chauffe_eau_pleumeur_energie_totale\"),\n\n \"energy_total_prev_kwh\": states(\"input_number.victron_water_heater_energy_total_prev_kwh\"),\n \"energy_prev_at\": states(\"input_datetime.victron_water_heater_energy_prev_at\"),\n\n \"observed_regime_helper\": states(\"input_text.victron_water_heater_observed_regime\"),\n \"observed_power_helper_w\": states(\"input_number.victron_water_heater_observed_power_w\"),\n\n \"mode_mismatch_since\": states(\"input_datetime.victron_water_heater_mode_mismatch_since\"),\n\n \"forced_off_by_advisor\": states(\"input_boolean.victron_water_heater_forced_off_by_advisor\"),\n \"forced_off_reason\": states(\"input_text.victron_water_heater_forced_off_reason\"),\n \"forced_off_at\": states(\"input_datetime.victron_water_heater_forced_off_at\"),\n\n \"pending_user_action_prev\": states(\"input_text.victron_water_heater_pending_user_action\"),\n\n \"restore_surplus_w\": states(\"input_number.victron_water_heater_restore_surplus_w\"),\n \"mode_mismatch_delay_min\": states(\"input_number.victron_water_heater_mode_mismatch_delay_min\")\n} | tojson }}\n" + } + ] + }, + "options": {} + }, + "id": "264b44f4-227a-482e-b4da-9468855ce429", + "name": "HA Snapshot Water Heater Advisor V2", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + -64, + -160 + ], + "credentials": { + "httpBearerAuth": { + "id": "uv3sknYYigxBkaVN", + "name": "Bearer Auth account 2" + } + } + }, + { + "parameters": { + "jsCode": "const root = $input.first()?.json ?? {};\nconst calls = root.ha_service_calls ?? {};\nconst decision = root.decision ?? {};\n\nfunction isObject(v) {\n return v !== null && typeof v === 'object' && !Array.isArray(v);\n}\n\nfunction isValidParisDateTime(v) {\n return typeof v === 'string' && /^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$/.test(v.trim());\n}\n\nfunction normalizeText(v) {\n return (v ?? '').toString();\n}\n\nfunction shouldExecute(call) {\n if (!isObject(call)) return false;\n return call.execute !== false;\n}\n\nfunction getBaseUrl() {\n return ($env.HA_BASE_URL || 'http://10.10.10.17:8123').replace(/\\/$/, '');\n}\n\nfunction getDebugEntityId(data) {\n if (!isObject(data)) return '';\n if (typeof data.entity_id === 'string' && data.entity_id.trim()) return data.entity_id.trim();\n return '';\n}\n\nfunction sanitizeCall(key, call) {\n if (!isObject(call)) return null;\n if (!shouldExecute(call)) return null;\n\n const domain = normalizeText(call.domain).trim();\n const service = normalizeText(call.service).trim();\n const data = isObject(call.data) ? { ...call.data } : {};\n\n if (!domain || !service) return null;\n\n // Garde-fous ciblés\n if (domain === 'input_datetime' && service === 'set_datetime') {\n if (!isValidParisDateTime(data.datetime)) return null;\n }\n\n if (domain === 'input_text' && service === 'set_value') {\n data.value = normalizeText(data.value);\n }\n\n if (domain === 'input_select' && service === 'select_option') {\n if (!normalizeText(data.option).trim()) return null;\n }\n\n if (domain === 'input_number' && service === 'set_value') {\n const n = Number(data.value);\n if (!Number.isFinite(n)) return null;\n data.value = n;\n }\n\n if ((domain === 'switch' || domain === 'input_boolean') && !data.entity_id) {\n return null;\n }\n\n if (domain === 'notify') {\n data.title = normalizeText(data.title);\n data.message = normalizeText(data.message);\n }\n\n const baseUrl = getBaseUrl();\n const debugEntityId = getDebugEntityId(data);\n const debugUrl = `${baseUrl}/api/services/${domain}/${service}`;\n\n return {\n json: {\n action: key,\n domain,\n service,\n data,\n\n // mini debug demandé\n debug_action: key,\n debug_entity_id: debugEntityId || '(none)',\n debug_url: debugUrl,\n\n meta: {\n source: 'water_heater_advisor',\n timestamp_paris: root.timestamp_paris ?? null,\n recommended_mode: decision.recommended_mode ?? null,\n advice_color: decision.advice_color ?? null,\n state: decision.state ?? null\n }\n }\n };\n}\n\n// Ordre conseillé\nconst orderedKeys = [\n 'helper_recommended_mode',\n 'helper_advice_color',\n 'helper_state',\n 'helper_reason',\n 'helper_signature',\n\n 'helper_projected_soc',\n 'helper_budget_margin',\n 'helper_load_kwh',\n\n 'helper_legacy_advice_ok',\n 'helper_green_allowed',\n 'helper_auto_allowed',\n 'helper_boost_allowed',\n\n 'helper_observed_power',\n 'helper_observed_regime',\n 'helper_pending_user_action',\n 'helper_mismatch_since',\n\n 'helper_energy_prev_kwh',\n 'helper_energy_prev_at',\n\n 'helper_forced_off_by_advisor',\n 'helper_forced_off_reason',\n 'helper_forced_off_at',\n\n 'water_heater_power_switch',\n\n 'logbook',\n 'mobile_notification'\n];\n\nconst actions = [];\nconst alreadySeen = new Set();\n\nfor (const key of orderedKeys) {\n alreadySeen.add(key);\n const built = sanitizeCall(key, calls[key]);\n if (built) actions.push(built);\n}\n\n// Sécurité si de nouveaux appels sont ajoutés plus tard\nfor (const [key, call] of Object.entries(calls)) {\n if (alreadySeen.has(key)) continue;\n const built = sanitizeCall(key, call);\n if (built) actions.push(built);\n}\n\nreturn actions;\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1504, + -160 + ], + "id": "3b9b3251-a9ea-4fb4-ba6e-899bc39371e7", + "name": "BUILD ECS HA Actions1" + } + ], + "connections": { + "ECS Normalize Inputs": { + "main": [ + [ + { + "node": "ECS EVCS & Water Heater Observation", + "type": "main", + "index": 0 + } + ] + ] + }, + "ECS EVCS & Water Heater Observation": { + "main": [ + [ + { + "node": "ECS Energy Model & Early Boost Signals", + "type": "main", + "index": 0 + } + ] + ] + }, + "ECS Energy Model & Early Boost Signals": { + "main": [ + [ + { + "node": "ECS Base Advisor Decision", + "type": "main", + "index": 0 + } + ] + ] + }, + "ECS Base Advisor Decision": { + "main": [ + [ + { + "node": "ECS Final Arbitration & Protection", + "type": "main", + "index": 0 + } + ] + ] + }, + "ECS Final Arbitration & Protection": { + "main": [ + [ + { + "node": "ECS Output Builder", + "type": "main", + "index": 0 + } + ] + ] + }, + "ECS Output Builder": { + "main": [ + [ + { + "node": "BUILD ECS HA Actions1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Schedule Trigger": { + "main": [ + [ + { + "node": "HA Snapshot Water Heater Advisor V2", + "type": "main", + "index": 0 + } + ] + ] + }, + "HA Snapshot Water Heater Advisor V2": { + "main": [ + [ + { + "node": "ECS Normalize Inputs", + "type": "main", + "index": 0 + } + ] + ] + }, + "BUILD ECS HA Actions1": { + "main": [ + [ + { + "node": "Execute HA Service", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate", + "availableInMCP": true, + "timeSavedMode": "fixed", + "callerPolicy": "workflowsFromSameOwner", + "saveDataErrorExecution": "all" + }, + "staticData": { + "node:Schedule Trigger": { + "recurrenceRules": [ + null + ], + "recurrenceRuleSignatures": [ + null + ] + } + }, + "meta": null, + "nodeGroups": [], + "pinData": {}, + "versionId": "104ba5d2-fa60-4b6e-9d00-d0b853c79fd4", + "activeVersionId": "104ba5d2-fa60-4b6e-9d00-d0b853c79fd4", + "versionCounter": 87, + "triggerCount": 1, + "sourceWorkflowId": null, + "shared": [ + { + "updatedAt": "2026-04-23T00:41:43.730Z", + "createdAt": "2026-04-23T00:41:43.730Z", + "role": "workflow:owner", + "workflowId": "yX2j8m04e3pxaoQq", + "projectId": "1q2yd8IyTc8fb25V", + "project": { + "updatedAt": "2026-02-06T16:27:24.469Z", + "createdAt": "2026-02-06T16:23:40.444Z", + "id": "1q2yd8IyTc8fb25V", + "name": "thierry tallagrand ", + "type": "personal", + "icon": null, + "description": null, + "customTelemetryTags": [], + "creatorId": "8b9e9552-cd74-4863-82a3-174b3b3befa2" + } + } + ], + "tags": [], + "activeVersion": { + "updatedAt": "2026-06-12T21:11:20.000Z", + "createdAt": "2026-05-06T14:58:05.398Z", + "versionId": "104ba5d2-fa60-4b6e-9d00-d0b853c79fd4", + "workflowId": "yX2j8m04e3pxaoQq", + "nodes": [ + { + "parameters": { + "jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input ?? {};\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction getParisNowParts() {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date())\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const stamp = `${date} ${to2(hour)}:${parts.minute}:${parts.second}`;\n\n return {\n year: Number(parts.year),\n month: Number(parts.month),\n day: Number(parts.day),\n hour,\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n stamp,\n minNow: hour * 60 + Number(parts.minute),\n };\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\\\d{4})-(\\\\d{2})-(\\\\d{2}) (\\\\d{2}):(\\\\d{2}):(\\\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction cleanStateText(v) {\n const s = (v ?? '').toString().trim();\n const x = s.toLowerCase();\n return ['unknown', 'unavailable', 'none', 'null'].includes(x) ? '' : s;\n}\n\nconst cfg = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n batteryUsableCapacityKwh: 17.5,\n sampleWindowMinMin: 2,\n sampleWindowMinMax: 15,\n energyByDemandKwh: {\n low: { green: 0.9, auto: 1.4, boost: 2.0 },\n normal: { green: 1.4, auto: 2.2, boost: 3.1 },\n high: { green: 2.2, auto: 3.4, boost: 4.6 },\n three_showers: { green: 3.0, auto: 4.4, boost: 5.8 }\n }\n};\n\nconst raw = $input.first()?.json ?? {};\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\nconst nowMs = Date.now();\n\nconst soc = clamp(num(input.soc_percent, 0), 0, 100);\nconst batteryCurrentA = num(input.battery_current_a, 0);\nconst pvPowerW = Math.max(0, num(input.pv_power_w, 0));\nconst pvPowerAvg10mW = Math.max(0, num(input.pv_power_10m_avg_w, pvPowerW));\nconst houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));\nconst gridPowerW = num(input.grid_power_w, 0);\nconst gridIsOn = low(input.grid_state || 'off') === 'on';\nconst acInAvailable = num(input.victron_acin1_available, 0) === 1;\nconst gridAvailable = gridIsOn || acInAvailable;\nconst todayRemainingKwh = Math.max(0, num(input.today_remaining_kwh, 0));\nconst tomorrowKwh = Math.max(0, num(input.tomorrow_kwh, 0));\nconst advisorEnabled = low(input.advisor_enabled || 'off') === 'on';\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\n\nconst hotWaterDemandRaw = low(input.hot_water_demand || 'normal');\nconst hotWaterDemand = ['low', 'normal', 'high', 'three_showers'].includes(hotWaterDemandRaw)\n ? hotWaterDemandRaw\n : 'normal';\n\nconst greenPowerW = Math.max(0, num(input.green_power_w, 620));\nconst autoPowerW = Math.max(0, num(input.auto_power_w, 1200));\nconst boostPowerW = Math.max(0, num(input.boost_power_w, 2000));\nconst strongForecastKwh = Math.max(0, num(input.strong_forecast_kwh, 18));\nconst mediumForecastKwh = Math.max(0, num(input.medium_forecast_kwh, 10));\nconst strongPvNowW = Math.max(0, num(input.strong_pv_now_w, 2500));\nconst mediumPvNowW = Math.max(0, num(input.medium_pv_now_w, 1500));\nconst boostMinSocStrongForecastPct = clamp(num(input.morning_boost_min_soc_strong_day, 35), 0, 100);\nconst boostMinSocMediumForecastPct = clamp(num(input.boost_min_soc_medium_day, 50), 0, 100);\nconst endOfDaySocGuard = clamp(num(input.end_of_day_soc_guard, 85), 0, 100);\nconst endOfDaySocTarget = clamp(num(input.end_of_day_soc_target, 85), 0, 100);\nconst baseLoadRemainingKwh = Math.max(0, num(input.base_load_remaining_kwh, 0));\nconst flexibleSafetyMarginKwh = Math.max(0, num(input.flexible_safety_margin_kwh, 0));\nconst acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 8000));\nconst lastSignature = cleanStateText(input.last_signature);\n\nreturn [{\n json: {\n meta: {\n workflow: 'water_heater_advisor',\n architecture: 'ecs_6nodes_simple_early_boost',\n node: 'ecs_normalize_inputs',\n timestamp_paris: now.stamp\n },\n cfg,\n now,\n nowMs,\n input,\n normalized: {\n soc,\n batteryCurrentA,\n pvPowerW,\n pvPowerAvg10mW,\n houseAcPowerW,\n gridPowerW,\n gridIsOn,\n acInAvailable,\n gridAvailable,\n todayRemainingKwh,\n tomorrowKwh,\n advisorEnabled,\n notificationsEnabled,\n hotWaterDemand,\n greenPowerW,\n autoPowerW,\n boostPowerW,\n strongForecastKwh,\n mediumForecastKwh,\n strongPvNowW,\n mediumPvNowW,\n boostMinSocStrongForecastPct,\n boostMinSocMediumForecastPct,\n endOfDaySocGuard,\n endOfDaySocTarget,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW,\n lastSignature\n }\n }\n}];" + }, + "id": "1226082d-222c-4837-99d3-35094bd1c4f1", + "name": "ECS Normalize Inputs", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 160, + -160 + ] + }, + { + "parameters": { + "jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction cleanStateText(v) {\n const s = (v ?? '').toString().trim();\n const x = s.toLowerCase();\n return ['unknown', 'unavailable', 'none', 'null'].includes(x) ? '' : s;\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\\\d{4})-(\\\\d{2})-(\\\\d{2}) (\\\\d{2}):(\\\\d{2}):(\\\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction cleanParisStamp(v) {\n const s = cleanStateText(v);\n return parseParisStampToMs(s) !== null ? s : '';\n}\n\nfunction minutesSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 60000);\n}\n\nconst root = $input.first()?.json ?? {};\nconst cfg = root.cfg ?? {};\nconst now = root.now ?? {};\nconst nowMs = root.nowMs ?? null;\nconst input = root.input ?? {};\nconst normalized = root.normalized ?? {};\n\nconst { greenPowerW, autoPowerW, boostPowerW } = normalized;\n\nconst evcsPriorityActiveRaw = low(input.evcs_priority_active || 'off') === 'on';\nconst energyPriorityMode = cleanStateText(input.energy_priority_mode || 'normal').toLowerCase() || 'normal';\nconst energyPriorityReason = cleanStateText(input.energy_priority_reason);\nconst evcsPriorityUntilAt = cleanParisStamp(input.evcs_priority_until);\nconst evcsPriorityUntilMs = parseParisStampToMs(evcsPriorityUntilAt);\n\nconst evcsPriorityActive =\n evcsPriorityActiveRaw &&\n energyPriorityMode === 'evcs_priority' &&\n (\n evcsPriorityUntilMs === null ||\n (nowMs !== null && evcsPriorityUntilMs > nowMs + 1000)\n );\n\nconst waterHeaterSwitchOn = low(input.water_heater_switch || 'off') === 'on';\nconst waterHeaterEnergyTotalKwh = Math.max(0, num(input.water_heater_energy_total_kwh, 0));\nconst energyPrevKwh = num(input.energy_total_prev_kwh, NaN);\nconst energyPrevAt = cleanParisStamp(input.energy_prev_at);\nconst energyPrevMs = parseParisStampToMs(energyPrevAt);\nconst observedPowerPrevW = Math.max(0, num(input.observed_power_helper_w, 0));\nconst observedRegimePrev = cleanStateText(input.observed_regime_helper).toLowerCase();\nconst mismatchSinceAt = cleanParisStamp(input.mode_mismatch_since);\nconst mismatchSinceMsPrev = parseParisStampToMs(mismatchSinceAt);\nconst forcedOffByAdvisorPrev = low(input.forced_off_by_advisor || 'off') === 'on';\nconst forcedOffReasonPrev = cleanStateText(input.forced_off_reason);\nconst forcedOffAtPrev = cleanParisStamp(input.forced_off_at);\nconst forcedOffAtPrevMs = parseParisStampToMs(forcedOffAtPrev);\nconst pendingUserActionPrev = cleanStateText(input.pending_user_action_prev);\nconst restoreSurplusW = Math.max(0, num(input.restore_surplus_w, 2500));\nconst modeMismatchDelayMin = Math.max(1, num(input.mode_mismatch_delay_min, 15));\n\nconst sampleDurationMin = minutesSince(energyPrevMs, nowMs);\nconst energyDeltaKwh =\n Number.isFinite(energyPrevKwh)\n ? Math.max(0, waterHeaterEnergyTotalKwh - energyPrevKwh)\n : NaN;\n\nconst sampleIsUsable =\n Number.isFinite(sampleDurationMin) &&\n sampleDurationMin >= (cfg.sampleWindowMinMin ?? 2) &&\n sampleDurationMin <= (cfg.sampleWindowMinMax ?? 15) &&\n Number.isFinite(energyDeltaKwh);\n\nlet observedPowerW = 0;\nif (sampleIsUsable) {\n observedPowerW = Math.max(0, (energyDeltaKwh * 60000) / sampleDurationMin);\n} else if (waterHeaterSwitchOn && observedPowerPrevW > 0) {\n observedPowerW = observedPowerPrevW;\n}\n\nconst offThresholdW = Math.max(80, greenPowerW * 0.25);\nconst hpUpperW = (greenPowerW + autoPowerW) / 2;\nconst resistiveUpperW = (autoPowerW + boostPowerW) / 2;\n\nlet observedRegime = 'unknown';\nif (!waterHeaterSwitchOn) {\n observedRegime = 'off';\n} else if (!sampleIsUsable && observedRegimePrev) {\n observedRegime = observedRegimePrev;\n} else if (observedPowerW < offThresholdW) {\n observedRegime = 'off';\n} else if (observedPowerW < hpUpperW) {\n observedRegime = 'hp_low';\n} else if (observedPowerW < resistiveUpperW) {\n observedRegime = 'resistive_mid';\n} else {\n observedRegime = 'boost_high';\n}\n\nreturn [{\n json: {\n ...root,\n meta: {\n ...(root.meta ?? {}),\n node: 'ecs_evcs_and_water_heater_observation'\n },\n observation: {\n evcsPriorityActiveRaw,\n energyPriorityMode,\n energyPriorityReason,\n evcsPriorityUntilAt,\n evcsPriorityUntilMs,\n evcsPriorityActive,\n waterHeaterSwitchOn,\n waterHeaterEnergyTotalKwh,\n energyPrevKwh,\n energyPrevAt,\n energyPrevMs,\n observedPowerPrevW,\n observedRegimePrev,\n mismatchSinceAt,\n mismatchSinceMsPrev,\n forcedOffByAdvisorPrev,\n forcedOffReasonPrev,\n forcedOffAtPrev,\n forcedOffAtPrevMs,\n pendingUserActionPrev,\n restoreSurplusW,\n modeMismatchDelayMin,\n sampleDurationMin,\n energyDeltaKwh,\n sampleIsUsable,\n observedPowerW,\n observedRegime,\n offThresholdW,\n hpUpperW,\n resistiveUpperW\n }\n }\n}];" + }, + "id": "0979b36d-13b0-492a-bafb-8c99219bbdb5", + "name": "ECS EVCS & Water Heater Observation", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 384, + -160 + ] + }, + { + "parameters": { + "jsCode": "function clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nconst root = $input.first()?.json ?? {};\nconst cfg = root.cfg ?? {};\nconst normalized = root.normalized ?? {};\n\nconst {\n soc,\n batteryCurrentA,\n pvPowerW,\n pvPowerAvg10mW,\n houseAcPowerW,\n todayRemainingKwh,\n tomorrowKwh,\n advisorEnabled,\n hotWaterDemand,\n greenPowerW,\n autoPowerW,\n boostPowerW,\n strongForecastKwh,\n mediumForecastKwh,\n strongPvNowW,\n mediumPvNowW,\n boostMinSocStrongForecastPct,\n boostMinSocMediumForecastPct,\n endOfDaySocGuard,\n endOfDaySocTarget,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW\n} = normalized;\n\nconst targetSoc = Math.max(endOfDaySocTarget, endOfDaySocGuard);\nconst batteryCapacityKwh = cfg.batteryUsableCapacityKwh ?? 17.5;\n\nconst energyTable =\n (cfg.energyByDemandKwh && cfg.energyByDemandKwh[hotWaterDemand]) ||\n (cfg.energyByDemandKwh && cfg.energyByDemandKwh.normal) || {\n green: 1.4,\n auto: 2.2,\n boost: 3.1\n };\n\nconst greenEnergyKwh = energyTable.green;\nconst autoEnergyKwh = energyTable.auto;\nconst boostEnergyKwh = energyTable.boost;\n\nconst energyNeededToReachTargetKwh =\n Math.max(0, ((targetSoc - soc) / 100) * batteryCapacityKwh);\n\nfunction projectedEndSocAfterMode(loadKwh) {\n const netKwh =\n todayRemainingKwh -\n baseLoadRemainingKwh -\n flexibleSafetyMarginKwh -\n loadKwh;\n\n const deltaSoc = (netKwh / batteryCapacityKwh) * 100;\n return clamp(soc + deltaSoc, 0, 100);\n}\n\nfunction budgetMarginKwh(loadKwh) {\n return (\n todayRemainingKwh -\n baseLoadRemainingKwh -\n flexibleSafetyMarginKwh -\n loadKwh -\n energyNeededToReachTargetKwh\n );\n}\n\nconst projectedGreenSoc = projectedEndSocAfterMode(greenEnergyKwh);\nconst projectedAutoSoc = projectedEndSocAfterMode(autoEnergyKwh);\nconst projectedBoostSoc = projectedEndSocAfterMode(boostEnergyKwh);\n\nconst greenBudgetMarginKwh = budgetMarginKwh(greenEnergyKwh);\nconst autoBudgetMarginKwh = budgetMarginKwh(autoEnergyKwh);\nconst boostBudgetMarginKwh = budgetMarginKwh(boostEnergyKwh);\n\nconst strongForecast =\n todayRemainingKwh >= strongForecastKwh ||\n tomorrowKwh >= strongForecastKwh;\n\nconst mediumForecast =\n !strongForecast &&\n (\n todayRemainingKwh >= mediumForecastKwh ||\n tomorrowKwh >= mediumForecastKwh\n );\n\nconst forecastStrength =\n strongForecast ? 'strong' :\n mediumForecast ? 'medium' :\n 'weak';\n\nconst pv10mW = Number.isFinite(pvPowerAvg10mW) ? pvPowerAvg10mW : pvPowerW;\nconst netPvHeadroomW = Math.round(pv10mW - houseAcPowerW);\nconst pvCoversHouseNow = pvPowerW > houseAcPowerW;\nconst strongPvNow = pv10mW >= strongPvNowW || pvPowerW >= strongPvNowW;\nconst mediumPvNow = pv10mW >= mediumPvNowW || pvPowerW >= mediumPvNowW;\n\nconst pvStartedWell =\n mediumPvNow &&\n pvCoversHouseNow &&\n netPvHeadroomW >= Math.max(300, Math.round(greenPowerW * 0.5));\n\nconst acSafe = acLoadsMaxSafeW <= 0 ? true : houseAcPowerW <= acLoadsMaxSafeW;\nconst batteryNotDrainingHard = batteryCurrentA >= -2;\n\nconst greenAllowed =\n advisorEnabled &&\n acSafe &&\n (\n projectedGreenSoc >= targetSoc - 6 ||\n greenBudgetMarginKwh >= -1.0 ||\n soc >= targetSoc\n );\n\nconst autoAllowed =\n advisorEnabled &&\n acSafe &&\n (\n autoBudgetMarginKwh >= 0 ||\n projectedAutoSoc >= targetSoc ||\n (strongForecast && projectedAutoSoc >= targetSoc - 2)\n );\n\nconst boostAllowedByBudget =\n advisorEnabled &&\n acSafe &&\n (\n projectedBoostSoc >= targetSoc ||\n boostBudgetMarginKwh >= 0 ||\n (strongForecast && projectedBoostSoc >= targetSoc - 5) ||\n (strongForecast && boostBudgetMarginKwh >= -1.0)\n );\n\nconst earlyBoostStrongReady =\n boostAllowedByBudget &&\n soc >= boostMinSocStrongForecastPct &&\n pvStartedWell &&\n (strongForecast || strongPvNow);\n\nconst earlyBoostMediumReady =\n boostAllowedByBudget &&\n mediumForecast &&\n soc >= boostMinSocMediumForecastPct &&\n pvStartedWell &&\n netPvHeadroomW >= greenPowerW;\n\nconst autoReady =\n autoAllowed &&\n mediumPvNow &&\n pvCoversHouseNow &&\n netPvHeadroomW >= 0;\n\nconst greenReady =\n greenAllowed &&\n (\n pv10mW >= greenPowerW ||\n (pvPowerW >= greenPowerW && pvCoversHouseNow)\n );\n\nconst boostAllowed = earlyBoostStrongReady || earlyBoostMediumReady;\n\nreturn [{\n json: {\n ...root,\n meta: {\n ...(root.meta ?? {}),\n node: 'ecs_energy_model_and_early_boost_signals'\n },\n energyModel: {\n targetSoc,\n batteryCapacityKwh,\n greenEnergyKwh,\n autoEnergyKwh,\n boostEnergyKwh,\n energyNeededToReachTargetKwh,\n projectedGreenSoc,\n projectedAutoSoc,\n projectedBoostSoc,\n greenBudgetMarginKwh,\n autoBudgetMarginKwh,\n boostBudgetMarginKwh,\n forecastStrength,\n strongForecast,\n mediumForecast,\n strongPvNow,\n mediumPvNow,\n netPvHeadroomW,\n pvCoversHouseNow,\n pvStartedWell,\n acSafe,\n batteryNotDrainingHard,\n greenAllowed,\n autoAllowed,\n boostAllowedByBudget,\n boostAllowed,\n earlyBoostStrongReady,\n earlyBoostMediumReady,\n autoReady,\n greenReady\n }\n }\n}];" + }, + "id": "0becfc3a-3305-4a97-95b8-0fe1de446e37", + "name": "ECS Energy Model & Early Boost Signals", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 608, + -160 + ] + }, + { + "parameters": { + "jsCode": "const root = $input.first()?.json ?? {};\nconst normalized = root.normalized ?? {};\nconst energyModel = root.energyModel ?? {};\n\nconst {\n advisorEnabled,\n hotWaterDemand,\n soc,\n pvPowerW,\n pvPowerAvg10mW,\n houseAcPowerW,\n acLoadsMaxSafeW\n} = normalized;\n\nconst {\n targetSoc,\n forecastStrength,\n netPvHeadroomW,\n pvCoversHouseNow,\n acSafe,\n greenAllowed,\n earlyBoostStrongReady,\n earlyBoostMediumReady,\n autoReady,\n greenReady,\n projectedGreenSoc,\n projectedAutoSoc,\n projectedBoostSoc,\n greenBudgetMarginKwh,\n autoBudgetMarginKwh,\n boostBudgetMarginKwh\n} = energyModel;\n\nlet recommendedMode = 'off';\nlet adviceColor = 'red';\nlet state = 'off_waiting_pv';\nlet reason = [];\n\nif (!advisorEnabled) {\n recommendedMode = 'off';\n adviceColor = 'red';\n state = 'advisor_disabled';\n reason.push('Advisor chauffe-eau désactivé');\n}\nelse if (!acSafe) {\n recommendedMode = 'off';\n adviceColor = 'red';\n state = 'off_ac_loads_too_high';\n reason.push(`Charges maison trop élevées pour ECS (${Math.round(houseAcPowerW)} W > seuil ${Math.round(acLoadsMaxSafeW)} W)`);\n}\nelse if (earlyBoostStrongReady) {\n recommendedMode = 'boost';\n adviceColor = 'green';\n state = 'boost_early_strong_forecast';\n reason.push('Boost tôt autorisé : forecast fort + SOC suffisant + PV net maison favorable');\n}\nelse if (earlyBoostMediumReady) {\n recommendedMode = 'boost';\n adviceColor = 'orange';\n state = 'boost_early_medium_forecast';\n reason.push('Boost tôt autorisé : forecast moyen + SOC élevé + surplus net maison suffisant');\n}\nelse if (autoReady) {\n recommendedMode = 'auto';\n adviceColor = 'green';\n state = 'auto_from_pv_net';\n reason.push('Mode auto recommandé : le PV couvre déjà la maison');\n}\nelse if (greenReady) {\n recommendedMode = 'green';\n adviceColor = 'green';\n state = 'green_waiting_pv_ramp';\n reason.push('Mode green recommandé : démarrage PV détecté');\n}\nelse {\n recommendedMode = 'off';\n adviceColor = 'red';\n if (!pvCoversHouseNow) {\n state = 'wait_for_pv_to_cover_house';\n reason.push('Attente : le PV ne couvre pas encore la maison');\n } else if (!greenAllowed) {\n state = 'off_budget_guard';\n reason.push('Attente : garde-fou budget / SOC fin de journée');\n } else {\n state = 'wait_for_pv_or_soc';\n reason.push('Attente d\\'un meilleur niveau PV / forecast / SOC');\n }\n}\n\nreason.push(`Demande ECS: ${hotWaterDemand}`);\nreason.push(`Forecast: ${forecastStrength}`);\nreason.push(`SOC: ${Math.round(soc)}%`);\nreason.push(`PV: ${Math.round(pvPowerW)} W | PV10: ${Math.round(pvPowerAvg10mW)} W`);\nreason.push(`Maison: ${Math.round(houseAcPowerW)} W`);\nreason.push(`Surplus net estimé: ${Math.round(netPvHeadroomW)} W`);\n\nif (recommendedMode === 'boost') {\n reason.push(`Boost budget: marge ${boostBudgetMarginKwh.toFixed(2)} kWh | SOC fin ${projectedBoostSoc.toFixed(1)}%`);\n} else if (recommendedMode === 'auto') {\n reason.push(`Auto budget: marge ${autoBudgetMarginKwh.toFixed(2)} kWh | SOC fin ${projectedAutoSoc.toFixed(1)}%`);\n} else if (recommendedMode === 'green') {\n reason.push(`Green budget: marge ${greenBudgetMarginKwh.toFixed(2)} kWh | SOC fin ${projectedGreenSoc.toFixed(1)}%`);\n} else {\n reason.push(`Target fin de jour: ${targetSoc.toFixed(1)}%`);\n}\n\nconst baseRecommendedMode = recommendedMode;\nconst baseAdviceColor = adviceColor;\nconst baseState = state;\n\nreturn [{\n json: {\n ...root,\n meta: {\n ...(root.meta ?? {}),\n node: 'ecs_base_advisor_decision'\n },\n baseDecision: {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState\n }\n }\n}];" + }, + "id": "7a862398-6aeb-4720-91ed-fd2eb2cc9d7e", + "name": "ECS Base Advisor Decision", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 832, + -160 + ] + }, + { + "parameters": { + "jsCode": "function regimeRank(regime) {\n switch (regime) {\n case 'off': return 0;\n case 'hp_low': return 1;\n case 'resistive_mid': return 2;\n case 'boost_high': return 3;\n default: return -1;\n }\n}\n\nfunction allowedRankForMode(mode) {\n switch (mode) {\n case 'off': return 0;\n case 'green': return 1;\n case 'auto': return 2;\n case 'boost': return 3;\n default: return 0;\n }\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nconst root = $input.first()?.json ?? {};\nconst now = root.now ?? {};\nconst nowMs = root.nowMs ?? null;\nconst normalized = root.normalized ?? {};\nconst observation = root.observation ?? {};\nconst energyModel = root.energyModel ?? {};\nconst baseDecision = root.baseDecision ?? {};\n\nlet {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState\n} = baseDecision;\n\nreason = Array.isArray(reason) ? [...reason] : [];\n\nconst {\n advisorEnabled,\n soc,\n batteryCurrentA,\n pvPowerAvg10mW,\n houseAcPowerW,\n todayRemainingKwh,\n tomorrowKwh,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW,\n hotWaterDemand,\n gridIsOn,\n acInAvailable,\n gridAvailable\n} = normalized;\n\nconst {\n evcsPriorityActive,\n energyPriorityReason,\n waterHeaterSwitchOn,\n observedPowerW,\n observedRegime,\n mismatchSinceAt,\n mismatchSinceMsPrev,\n forcedOffByAdvisorPrev,\n forcedOffReasonPrev,\n forcedOffAtPrev,\n restoreSurplusW,\n modeMismatchDelayMin,\n sampleDurationMin,\n energyDeltaKwh,\n sampleIsUsable\n} = observation;\n\nconst {\n targetSoc,\n energyNeededToReachTargetKwh,\n greenEnergyKwh,\n autoEnergyKwh,\n boostEnergyKwh,\n projectedGreenSoc,\n projectedAutoSoc,\n projectedBoostSoc,\n greenBudgetMarginKwh,\n autoBudgetMarginKwh,\n boostBudgetMarginKwh,\n forecastStrength,\n netPvHeadroomW,\n pvCoversHouseNow,\n pvStartedWell,\n greenAllowed,\n autoAllowed,\n boostAllowed,\n batteryNotDrainingHard\n} = energyModel;\n\nif (advisorEnabled && evcsPriorityActive) {\n if (recommendedMode === 'boost' || recommendedMode === 'auto') {\n if (greenAllowed && batteryNotDrainingHard) {\n recommendedMode = 'green';\n adviceColor = 'orange';\n state = 'evcs_priority_green_cap';\n reason.push(`Priorité EVCS active : chauffe-eau plafonné à GREEN (${energyPriorityReason || 'raison non précisée'})`);\n } else {\n recommendedMode = 'off';\n adviceColor = 'red';\n state = 'evcs_priority_off_cap';\n reason.push(`Priorité EVCS active : chauffe-eau déconseillé (${energyPriorityReason || 'raison non précisée'})`);\n }\n } else if (recommendedMode === 'green' && (!greenAllowed || !batteryNotDrainingHard)) {\n recommendedMode = 'off';\n adviceColor = 'red';\n state = 'evcs_priority_green_blocked';\n reason.push(`Priorité EVCS active : GREEN devient lui-même trop coûteux (${energyPriorityReason || 'raison non précisée'})`);\n }\n}\n\nconst selectedLoadKwh =\n recommendedMode === 'green' ? greenEnergyKwh :\n recommendedMode === 'auto' ? autoEnergyKwh :\n recommendedMode === 'boost' ? boostEnergyKwh :\n 0;\n\nconst selectedProjectedSoc =\n recommendedMode === 'green' ? projectedGreenSoc :\n recommendedMode === 'auto' ? projectedAutoSoc :\n recommendedMode === 'boost' ? projectedBoostSoc :\n soc;\n\nconst selectedBudgetMarginKwh =\n recommendedMode === 'green' ? greenBudgetMarginKwh :\n recommendedMode === 'auto' ? autoBudgetMarginKwh :\n recommendedMode === 'boost' ? boostBudgetMarginKwh :\n todayRemainingKwh - baseLoadRemainingKwh - flexibleSafetyMarginKwh - energyNeededToReachTargetKwh;\n\nconst legacyAdviceOk = recommendedMode !== 'off';\n\nconst observedRank = regimeRank(observedRegime);\nconst allowedRank = allowedRankForMode(recommendedMode);\n\nconst mismatchActive =\n waterHeaterSwitchOn &&\n observedRank >= 0 &&\n allowedRank >= 0 &&\n observedRank > allowedRank;\n\nlet mismatchSeverity = 'none';\nif (mismatchActive) {\n if (recommendedMode === 'off') {\n mismatchSeverity = 'critical';\n } else if (observedRank - allowedRank >= 2) {\n mismatchSeverity = 'high';\n } else {\n mismatchSeverity = 'medium';\n }\n}\n\nconst mismatchSinceMsEffective = mismatchActive ? (mismatchSinceMsPrev ?? nowMs) : null;\nconst mismatchSinceAtEffective = mismatchActive ? (mismatchSinceMsPrev ? mismatchSinceAt : now.stamp) : '';\nconst mismatchDurationMin = mismatchActive && mismatchSinceMsEffective !== null ? Math.max(0, (nowMs - mismatchSinceMsEffective) / 60000) : 0;\n\nlet pendingUserAction = '';\nif (mismatchActive) {\n if (recommendedMode === 'off') {\n pendingUserAction = evcsPriorityActive\n ? 'Priorité EVCS : coupe temporairement le chauffe-eau ou laisse-le hors tension'\n : 'Le chauffe-eau consomme alors qu\\'il est déconseillé : coupe-le ou attends une meilleure fenêtre';\n } else if (recommendedMode === 'green') {\n pendingUserAction = observedRegime === 'boost_high'\n ? 'Passe le chauffe-eau en GREEN si possible, il semble en régime BOOST/résistance forte'\n : 'Passe le chauffe-eau en GREEN si possible, il semble plus gourmand que prévu';\n } else if (recommendedMode === 'auto') {\n pendingUserAction = 'Le chauffe-eau semble trop gourmand pour AUTO : repasse-le en AUTO modéré ou GREEN si possible';\n }\n}\n\nconst firstMismatchRun = mismatchActive && (mismatchSinceMsPrev === null || mismatchDurationMin < 1);\n\nconst realConflictNow =\n evcsPriorityActive &&\n (\n batteryCurrentA < -1 ||\n pvPowerAvg10mW < restoreSurplusW ||\n houseAcPowerW > acLoadsMaxSafeW ||\n recommendedMode === 'off' ||\n observedRegime === 'boost_high' ||\n (recommendedMode === 'green' && observedRegime === 'resistive_mid')\n );\n\nconst forceOffNow =\n advisorEnabled &&\n waterHeaterSwitchOn &&\n !forcedOffByAdvisorPrev &&\n mismatchActive &&\n mismatchDurationMin >= modeMismatchDelayMin &&\n realConflictNow;\n\nconst restoreEnergyOk =\n pvPowerAvg10mW >= restoreSurplusW &&\n batteryCurrentA >= -1 &&\n houseAcPowerW <= acLoadsMaxSafeW;\n\nconst restoreNow =\n advisorEnabled &&\n forcedOffByAdvisorPrev &&\n !waterHeaterSwitchOn &&\n (\n (!evcsPriorityActive && recommendedMode !== 'off') ||\n restoreEnergyOk\n );\n\nlet forcedOffByAdvisor = forcedOffByAdvisorPrev;\nlet forcedOffReason = forcedOffReasonPrev;\nlet forcedOffAt = forcedOffAtPrev;\nlet protectionState = 'none';\nlet powerSwitchAction = 'none';\nlet powerSwitchDesiredOn = waterHeaterSwitchOn;\n\nif (forceOffNow) {\n powerSwitchAction = 'turn_off';\n powerSwitchDesiredOn = false;\n forcedOffByAdvisor = true;\n forcedOffReason = `Coupure temporaire ECS: priorité EVCS + régime ${observedRegime} trop gourmand`;\n forcedOffAt = now.stamp;\n protectionState = 'forced_off_now_evcs_priority';\n state = 'forced_off_now_evcs_priority';\n adviceColor = 'red';\n reason.push(forcedOffReason);\n}\nelse if (restoreNow) {\n powerSwitchAction = 'turn_on';\n powerSwitchDesiredOn = true;\n forcedOffByAdvisor = false;\n forcedOffReason = '';\n protectionState = 'restored_after_evcs_priority';\n state = 'restored_after_evcs_priority';\n reason.push('Remise sous tension ECS : conflit EVCS terminé ou surplus redevenu suffisant');\n}\nelse if (forcedOffByAdvisorPrev && !waterHeaterSwitchOn) {\n protectionState = 'forced_off_wait_restore';\n state = 'forced_off_wait_restore';\n reason.push(`ECS temporairement coupé par l'advisor (${forcedOffReasonPrev || 'raison non précisée'})`);\n}\nelse if (mismatchActive && evcsPriorityActive) {\n protectionState = 'watch_mismatch_evcs_priority';\n state = 'watch_mismatch_evcs_priority';\n reason.push(`Écart de régime ECS sous priorité EVCS depuis ${round1(mismatchDurationMin)} min`);\n}\n\nif (!forceOffNow && !restoreNow && forcedOffByAdvisorPrev && waterHeaterSwitchOn) {\n forcedOffByAdvisor = false;\n forcedOffReason = '';\n}\n\nreturn [{\n json: {\n ...root,\n meta: {\n ...(root.meta ?? {}),\n node: 'ecs_final_arbitration_and_protection'\n },\n finalDecision: {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState,\n selectedLoadKwh,\n selectedProjectedSoc,\n selectedBudgetMarginKwh,\n legacyAdviceOk,\n mismatchActive,\n mismatchSeverity,\n mismatchSinceAtEffective,\n mismatchSinceMsEffective,\n mismatchDurationMin,\n pendingUserAction,\n firstMismatchRun,\n realConflictNow,\n forceOffNow,\n restoreEnergyOk,\n restoreNow,\n forcedOffByAdvisor,\n forcedOffReason,\n forcedOffAt,\n protectionState,\n powerSwitchAction,\n powerSwitchDesiredOn\n }\n }\n}];" + }, + "id": "994b3242-616d-46e7-83ed-d236a117d15c", + "name": "ECS Final Arbitration & Protection", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1056, + -160 + ] + }, + { + "parameters": { + "jsCode": "function round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round2(v) {\n return Math.round(v * 100) / 100;\n}\n\nfunction safeText(v, max = 255) {\n return (v ?? '').toString().slice(0, max);\n}\n\nfunction boolService(flag) {\n return flag ? 'turn_on' : 'turn_off';\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\\\d{4})-(\\\\d{2})-(\\\\d{2}) (\\\\d{2}):(\\\\d{2}):(\\\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nconst root = $input.first()?.json ?? {};\nconst cfg = root.cfg ?? {};\nconst now = root.now ?? {};\nconst input = root.input ?? {};\nconst normalized = root.normalized ?? {};\nconst observation = root.observation ?? {};\nconst energyModel = root.energyModel ?? {};\nconst finalDecision = root.finalDecision ?? {};\n\nconst {\n soc,\n batteryCurrentA,\n pvPowerW,\n pvPowerAvg10mW,\n houseAcPowerW,\n gridPowerW,\n gridIsOn,\n acInAvailable,\n gridAvailable,\n todayRemainingKwh,\n tomorrowKwh,\n advisorEnabled,\n notificationsEnabled,\n hotWaterDemand,\n greenPowerW,\n autoPowerW,\n boostPowerW,\n strongForecastKwh,\n mediumForecastKwh,\n strongPvNowW,\n mediumPvNowW,\n boostMinSocStrongForecastPct,\n boostMinSocMediumForecastPct,\n endOfDaySocGuard,\n endOfDaySocTarget,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW,\n lastSignature\n} = normalized;\n\nconst {\n evcsPriorityActive,\n energyPriorityMode,\n energyPriorityReason,\n evcsPriorityUntilAt,\n waterHeaterSwitchOn,\n waterHeaterEnergyTotalKwh,\n restoreSurplusW,\n modeMismatchDelayMin,\n sampleDurationMin,\n energyDeltaKwh,\n sampleIsUsable,\n observedPowerW,\n observedRegime\n} = observation;\n\nconst {\n targetSoc,\n batteryCapacityKwh,\n greenEnergyKwh,\n autoEnergyKwh,\n boostEnergyKwh,\n energyNeededToReachTargetKwh,\n projectedGreenSoc,\n projectedAutoSoc,\n projectedBoostSoc,\n greenBudgetMarginKwh,\n autoBudgetMarginKwh,\n boostBudgetMarginKwh,\n forecastStrength,\n strongForecast,\n mediumForecast,\n strongPvNow,\n mediumPvNow,\n netPvHeadroomW,\n pvCoversHouseNow,\n pvStartedWell,\n acSafe,\n batteryNotDrainingHard,\n greenAllowed,\n autoAllowed,\n boostAllowedByBudget,\n boostAllowed,\n earlyBoostStrongReady,\n earlyBoostMediumReady,\n autoReady,\n greenReady\n} = energyModel;\n\nlet {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState,\n selectedLoadKwh,\n selectedProjectedSoc,\n selectedBudgetMarginKwh,\n legacyAdviceOk,\n mismatchActive,\n mismatchSeverity,\n mismatchSinceAtEffective,\n mismatchSinceMsEffective,\n mismatchDurationMin,\n pendingUserAction,\n firstMismatchRun,\n realConflictNow,\n forceOffNow,\n restoreEnergyOk,\n restoreNow,\n forcedOffByAdvisor,\n forcedOffReason,\n forcedOffAt,\n protectionState,\n powerSwitchAction,\n powerSwitchDesiredOn\n} = finalDecision;\n\nreason = Array.isArray(reason) ? [...reason] : [];\n\nconst forcedOffAtSafe = forcedOffAt && parseParisStampToMs(forcedOffAt) !== null ? forcedOffAt : '';\nconst forcedOffReasonSafe = forcedOffByAdvisor ? safeText(forcedOffReason, 255) : '';\nconst pendingUserActionSafe = safeText(pendingUserAction, 255);\n\nif (reason.length === 0) {\n if (recommendedMode === 'boost') reason.push('Boost recommandé');\n else if (recommendedMode === 'auto') reason.push('Mode auto recommandé');\n else if (recommendedMode === 'green') reason.push('Mode green recommandé');\n else reason.push('Chauffe-eau déconseillé pour le moment');\n}\n\nreason.push(`Demande ECS: ${hotWaterDemand}`);\nreason.push(`Conseil final: ${recommendedMode}`);\nreason.push(`Conseil initial: ${baseRecommendedMode}`);\nreason.push(`Couleur conseil: ${adviceColor}`);\nreason.push(`Forecast du jour: ${round1(todayRemainingKwh)} kWh`);\nreason.push(`Production demain: ${round1(tomorrowKwh)} kWh`);\nreason.push(`Classe forecast: ${forecastStrength}`);\nreason.push(`PV actuel: ${Math.round(pvPowerW)} W | PV10: ${Math.round(pvPowerAvg10mW)} W`);\nreason.push(`Charges AC maison: ${Math.round(houseAcPowerW)} W`);\nreason.push(`Surplus net estimé: ${Math.round(netPvHeadroomW)} W`);\nreason.push(`PV couvre maison: ${pvCoversHouseNow ? 'oui' : 'non'} | PV bien démarré: ${pvStartedWell ? 'oui' : 'non'}`);\nreason.push(`Batterie: SOC ${round1(soc)}% | courant ${round1(batteryCurrentA)} A`);\nreason.push(`Réseau: cmd=${gridIsOn ? 'ON' : 'OFF'} | acin=${acInAvailable ? 'OK' : 'KO'} | dispo=${gridAvailable ? 'oui' : 'non'}`);\nreason.push(`Target fin de jour: ${round1(targetSoc)}%`);\nreason.push(`Base load restante: ${round1(baseLoadRemainingKwh)} kWh`);\nreason.push(`Marge sécurité: ${round1(flexibleSafetyMarginKwh)} kWh`);\nreason.push(`Green ${round1(greenEnergyKwh)}kWh → SOC fin ${round1(projectedGreenSoc)}% | marge ${round1(greenBudgetMarginKwh)}kWh`);\nreason.push(`Auto ${round1(autoEnergyKwh)}kWh → SOC fin ${round1(projectedAutoSoc)}% | marge ${round1(autoBudgetMarginKwh)}kWh`);\nreason.push(`Boost ${round1(boostEnergyKwh)}kWh → SOC fin ${round1(projectedBoostSoc)}% | marge ${round1(boostBudgetMarginKwh)}kWh`);\nreason.push(`Régime observé: ${observedRegime} | puissance ≈ ${Math.round(observedPowerW)} W | delta ${sampleIsUsable ? round2(energyDeltaKwh) : 'n/a'} kWh/${sampleIsUsable ? round1(sampleDurationMin) : 'n/a'} min`);\nreason.push(`Prio EVCS: ${evcsPriorityActive ? 'ON' : 'OFF'}${energyPriorityReason ? ` | ${energyPriorityReason}` : ''}`);\nreason.push(`Mismatch: ${mismatchActive ? 'oui' : 'non'} | sévérité=${mismatchSeverity} | durée=${round1(mismatchDurationMin)} min`);\nreason.push(`Protection ECS: ${protectionState}`);\nif (pendingUserActionSafe) reason.push(`Action attendue: ${pendingUserActionSafe}`);\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n `d=${hotWaterDemand}`,\n `m=${recommendedMode}`,\n `base=${baseRecommendedMode}`,\n `c=${adviceColor}`,\n `fs=${strongForecast ? '1' : '0'}`,\n `fm=${mediumForecast ? '1' : '0'}`,\n `pb=${pvStartedWell ? '1' : '0'}`,\n `soc=${round1(soc)}`,\n `pv=${Math.round(pvPowerW)}`,\n `pv10=${Math.round(pvPowerAvg10mW)}`,\n `npv=${Math.round(netPvHeadroomW)}`,\n `obs=${observedRegime}`,\n `prio=${evcsPriorityActive ? '1' : '0'}`,\n `cut=${forcedOffByAdvisor ? '1' : '0'}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\nlet notifyWanted = false;\nlet notifyTitle = 'Water Heater Advisor';\nlet notifyMessage = '';\n\nif (notificationsEnabled) {\n if (forceOffNow) {\n notifyWanted = true;\n notifyTitle = 'ECS coupé temporairement';\n notifyMessage = forcedOffReasonSafe || 'Le chauffe-eau a été coupé temporairement';\n } else if (restoreNow) {\n notifyWanted = true;\n notifyTitle = 'ECS remis sous tension';\n notifyMessage = 'Le chauffe-eau est réalimenté car la priorité EVCS est terminée ou le surplus est redevenu suffisant.';\n } else if (firstMismatchRun && pendingUserActionSafe) {\n notifyWanted = true;\n notifyTitle = 'Action utilisateur ECS conseillée';\n notifyMessage = pendingUserActionSafe;\n } else if (signatureChanged) {\n notifyWanted = true;\n notifyTitle = `Water Heater Advisor: ${recommendedMode.toUpperCase()} / ${adviceColor.toUpperCase()}`;\n notifyMessage = `Demande=${hotWaterDemand} | régime=${observedRegime} | SOC fin estimé=${round1(selectedProjectedSoc)}% | marge=${round1(selectedBudgetMarginKwh)} kWh`;\n }\n}\nconst BUDGET_MARGIN_MIN = -50;\nconst BUDGET_MARGIN_MAX = 50;\nconst result = {\n timestamp_paris: now.stamp,\n inputs: {\n soc_percent: soc,\n battery_current_a: batteryCurrentA,\n pv_power_w: pvPowerW,\n pv_power_10m_avg_w: pvPowerAvg10mW,\n house_ac_power_w: houseAcPowerW,\n grid_power_w: gridPowerW,\n grid_state: gridIsOn ? 'on' : 'off',\n victron_acin1_available: acInAvailable ? 1 : 0,\n today_remaining_kwh: round2(todayRemainingKwh),\n tomorrow_kwh: round2(tomorrowKwh),\n advisor_enabled: advisorEnabled,\n notifications_enabled: notificationsEnabled,\n hot_water_demand: hotWaterDemand,\n green_power_w: greenPowerW,\n auto_power_w: autoPowerW,\n boost_power_w: boostPowerW,\n strong_forecast_kwh: strongForecastKwh,\n medium_forecast_kwh: mediumForecastKwh,\n strong_pv_now_w: strongPvNowW,\n medium_pv_now_w: mediumPvNowW,\n boost_min_soc_strong_forecast_pct: boostMinSocStrongForecastPct,\n boost_min_soc_medium_forecast_pct: boostMinSocMediumForecastPct,\n end_of_day_soc_guard: endOfDaySocGuard,\n end_of_day_soc_target: endOfDaySocTarget,\n base_load_remaining_kwh: baseLoadRemainingKwh,\n flexible_safety_margin_kwh: flexibleSafetyMarginKwh,\n ac_loads_max_safe_w: acLoadsMaxSafeW,\n water_heater_switch: waterHeaterSwitchOn,\n water_heater_energy_total_kwh: round2(waterHeaterEnergyTotalKwh),\n last_signature: lastSignature || null,\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason || null,\n evcs_priority_until: evcsPriorityUntilAt || null\n },\n computed: {\n forecast_strength: forecastStrength,\n strong_forecast: strongForecast,\n medium_forecast: mediumForecast,\n strong_pv_now: strongPvNow,\n medium_pv_now: mediumPvNow,\n net_pv_headroom_w: Math.round(netPvHeadroomW),\n pv_covers_house_now: pvCoversHouseNow,\n pv_started_well: pvStartedWell,\n battery_usable_capacity_kwh_internal: batteryCapacityKwh,\n energy_needed_to_reach_target_kwh: round2(energyNeededToReachTargetKwh),\n green_energy_kwh: round2(greenEnergyKwh),\n auto_energy_kwh: round2(autoEnergyKwh),\n boost_energy_kwh: round2(boostEnergyKwh),\n projected_green_soc: round1(projectedGreenSoc),\n projected_auto_soc: round1(projectedAutoSoc),\n projected_boost_soc: round1(projectedBoostSoc),\n green_budget_margin_kwh: round2(greenBudgetMarginKwh),\n auto_budget_margin_kwh: round2(autoBudgetMarginKwh),\n boost_budget_margin_kwh: round2(boostBudgetMarginKwh),\n ac_safe: acSafe,\n battery_not_draining_hard: batteryNotDrainingHard,\n green_allowed: greenAllowed,\n auto_allowed: autoAllowed,\n boost_allowed_by_budget: boostAllowedByBudget,\n boost_allowed: boostAllowed,\n early_boost_strong_ready: earlyBoostStrongReady,\n early_boost_medium_ready: earlyBoostMediumReady,\n auto_ready: autoReady,\n green_ready: greenReady,\n base_recommended_mode: baseRecommendedMode,\n base_advice_color: baseAdviceColor,\n base_state: baseState,\n observed_power_w: Math.round(observedPowerW),\n observed_regime: observedRegime,\n sample_duration_min: sampleIsUsable ? round1(sampleDurationMin) : null,\n energy_delta_kwh: sampleIsUsable ? round2(energyDeltaKwh) : null,\n mismatch_active: mismatchActive,\n mismatch_severity: mismatchSeverity,\n mismatch_duration_min: round1(mismatchDurationMin),\n evcs_priority_active: evcsPriorityActive,\n protection_state: protectionState,\n real_conflict_now: realConflictNow,\n restore_energy_ok: restoreEnergyOk,\n restore_surplus_w: restoreSurplusW,\n mode_mismatch_delay_min: modeMismatchDelayMin\n },\n decision: {\n recommended_mode: recommendedMode,\n advice_color: adviceColor,\n state,\n legacy_advice_ok: legacyAdviceOk,\n selected_load_kwh: round2(selectedLoadKwh),\n projected_end_of_day_soc: round1(selectedProjectedSoc),\n budget_margin_kwh: round2(selectedBudgetMarginKwh),\n observed_power_w: Math.round(observedPowerW),\n observed_regime: observedRegime,\n mismatch_active: mismatchActive,\n mismatch_severity: mismatchSeverity,\n mismatch_duration_min: round1(mismatchDurationMin),\n pending_user_action: pendingUserActionSafe,\n power_switch_action: powerSwitchAction,\n power_switch_desired_on: powerSwitchDesiredOn,\n force_off_now: forceOffNow,\n restore_now: restoreNow,\n forced_off_by_advisor: forcedOffByAdvisor,\n forced_off_reason: forcedOffReasonSafe,\n forced_off_at: forcedOffByAdvisor ? forcedOffAtSafe || null : null,\n signature,\n signature_changed: signatureChanged,\n should_notify: notifyWanted,\n reason,\n short_reason: shortReason\n },\n ha_service_calls: {\n helper_recommended_mode: {\n domain: 'input_select',\n service: 'select_option',\n data: {\n entity_id: 'input_select.victron_water_heater_recommended_mode',\n option: recommendedMode\n }\n },\n helper_advice_color: {\n domain: 'input_select',\n service: 'select_option',\n data: {\n entity_id: 'input_select.victron_water_heater_advice_color',\n option: adviceColor\n }\n },\n helper_state: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_advice_state',\n value: state\n }\n },\n helper_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_advice_reason',\n value: shortReason || 'Aucune raison'\n }\n },\n helper_signature: {\n execute: signatureChanged,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_advice_last_signature',\n value: signature\n }\n },\n helper_projected_soc: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_projected_end_of_day_soc',\n value: round1(selectedProjectedSoc)\n }\n },\nhelper_budget_margin: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_budget_margin_kwh',\n value: Math.max(BUDGET_MARGIN_MIN, Math.min(BUDGET_MARGIN_MAX, round2(selectedBudgetMarginKwh)))\n }\n},\n helper_load_kwh: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_load_water_heater_kwh',\n value: round2(selectedLoadKwh)\n }\n },\n helper_legacy_advice_ok: {\n domain: 'input_boolean',\n service: boolService(legacyAdviceOk),\n data: {\n entity_id: 'input_boolean.victron_advice_water_heater_ok'\n }\n },\n helper_green_allowed: {\n domain: 'input_boolean',\n service: boolService(greenAllowed),\n data: {\n entity_id: 'input_boolean.victron_water_heater_green_allowed'\n }\n },\n helper_auto_allowed: {\n domain: 'input_boolean',\n service: boolService(autoAllowed),\n data: {\n entity_id: 'input_boolean.victron_water_heater_auto_allowed'\n }\n },\n helper_boost_allowed: {\n domain: 'input_boolean',\n service: boolService(boostAllowed),\n data: {\n entity_id: 'input_boolean.victron_water_heater_boost_allowed'\n }\n },\n helper_observed_power: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_observed_power_w',\n value: Math.round(observedPowerW)\n }\n },\n helper_observed_regime: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_observed_regime',\n value: observedRegime\n }\n },\n helper_pending_user_action: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_pending_user_action',\n value: pendingUserActionSafe\n }\n },\n helper_mismatch_since: {\n execute: mismatchActive && !!mismatchSinceAtEffective,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_water_heater_mode_mismatch_since',\n datetime: mismatchSinceAtEffective\n }\n },\n helper_energy_prev_kwh: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_energy_total_prev_kwh',\n value: round2(waterHeaterEnergyTotalKwh)\n }\n },\n helper_energy_prev_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_water_heater_energy_prev_at',\n datetime: now.stamp\n }\n },\n helper_forced_off_by_advisor: {\n domain: 'input_boolean',\n service: boolService(forcedOffByAdvisor),\n data: {\n entity_id: 'input_boolean.victron_water_heater_forced_off_by_advisor'\n }\n },\n helper_forced_off_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_forced_off_reason',\n value: forcedOffReasonSafe\n }\n },\n helper_forced_off_at: {\n execute: forceOffNow && !!forcedOffAtSafe,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_water_heater_forced_off_at',\n datetime: forcedOffAtSafe\n }\n },\n water_heater_power_switch: {\n execute: powerSwitchAction !== 'none',\n domain: 'switch',\n service: powerSwitchDesiredOn ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.tt_chauffe_eau_pleumeur_switch'\n }\n },\n logbook: {\n execute: signatureChanged || forceOffNow || restoreNow,\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Water Heater Advisor',\n message: `${recommendedMode}/${adviceColor} | regime=${observedRegime} | prioEVCS=${evcsPriorityActive ? 'on' : 'off'} | cut=${forcedOffByAdvisor ? 'on' : 'off'} | ${shortReason}`,\n entity_id: 'switch.tt_chauffe_eau_pleumeur_switch'\n }\n },\n mobile_notification: {\n execute: notifyWanted,\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: notifyTitle,\n message: notifyMessage,\n data: {\n push: {\n 'interruption-level': 'time-sensitive',\n sound: { name: 'default' }\n },\n ttl: 0,\n priority: 'high'\n }\n }\n }\n }\n};\n\nreturn [{ json: result }];" + }, + "id": "481de0f8-22a8-4aae-b47d-493098383c6c", + "name": "ECS Output Builder", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1280, + -160 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}\n", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + ($env.HA_TOKEN || '') }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.data }}", + "options": { + "response": { + "response": { + "responseFormat": "json" + } + }, + "timeout": 10000 + } + }, + "id": "89d75698-da63-4598-a35b-86f0ade8a11c", + "name": "Execute HA Service", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1728, + -160 + ], + "continueOnFail": true + }, + { + "parameters": { + "rule": { + "interval": [ + { + "field": "minutes" + } + ] + } + }, + "id": "edabb941-0e10-429b-bf0e-9af229a08d28", + "name": "Schedule Trigger", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [ + -288, + -160 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=http://10.10.10.17:8123/api/template\n", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Accept", + "value": "text/plain" + } + ] + }, + "sendBody": true, + "bodyParameters": { + "parameters": [ + { + "name": "template", + "value": "{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"pv_power_10m_avg_w\": states(\"sensor.victron_pv_power_10m_avg\"),\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"victron_acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n\n \"today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\"),\n\n \"advisor_enabled\": states(\"input_boolean.victron_water_heater_advisor_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_water_heater_notifications\"),\n\n \"hot_water_demand\": states(\"input_select.victron_water_heater_hot_water_demand\"),\n \"morning_boost_priority_enabled\": states(\"input_boolean.victron_water_heater_morning_boost_priority_enabled\"),\n \"morning_boost_latest_time\": states(\"input_datetime.victron_water_heater_morning_boost_latest_time\"),\n \"late_red_time\": states(\"input_datetime.victron_water_heater_late_red_time\"),\n\n \"green_power_w\": states(\"input_number.victron_water_heater_green_power_w\"),\n \"auto_power_w\": states(\"input_number.victron_water_heater_auto_power_w\"),\n \"boost_power_w\": states(\"input_number.victron_water_heater_boost_power_w\"),\n\n \"strong_forecast_kwh\": states(\"input_number.victron_water_heater_strong_forecast_kwh\"),\n \"medium_forecast_kwh\": states(\"input_number.victron_water_heater_medium_forecast_kwh\"),\n \"strong_pv_now_w\": states(\"input_number.victron_water_heater_strong_pv_now_w\"),\n \"medium_pv_now_w\": states(\"input_number.victron_water_heater_medium_pv_now_w\"),\n\n \"morning_boost_min_soc_strong_day\": states(\"input_number.victron_water_heater_morning_boost_min_soc_strong_day\"),\n \"boost_min_soc_medium_day\": states(\"input_number.victron_water_heater_boost_min_soc_medium_day\"),\n \"end_of_day_soc_guard\": states(\"input_number.victron_water_heater_end_of_day_soc_guard\"),\n\n \"end_of_day_soc_target\": states(\"input_number.victron_end_of_day_soc_target\"),\n \"base_load_remaining_kwh\": states(\"input_number.victron_base_load_remaining_kwh\"),\n \"flexible_safety_margin_kwh\": states(\"input_number.victron_flexible_safety_margin_kwh\"),\n \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\n\n \"day_start_time\": states(\"input_datetime.victron_flexible_day_start_time\"),\n \"day_stop_time\": states(\"input_datetime.victron_flexible_day_stop_time\"),\n\n \"last_signature\": states(\"input_text.victron_water_heater_advice_last_signature\"),\n\n \"evcs_priority_active\": states(\"input_boolean.victron_evcs_priority_active\"),\n \"energy_priority_mode\": states(\"input_select.victron_energy_priority_mode\"),\n \"energy_priority_reason\": states(\"input_text.victron_energy_priority_reason\"),\n \"evcs_priority_until\": states(\"input_datetime.victron_evcs_priority_until\"),\n\n \"water_heater_switch\": states(\"switch.tt_chauffe_eau_pleumeur_switch\"),\n \"water_heater_energy_total_kwh\": states(\"sensor.tt_chauffe_eau_pleumeur_energie_totale\"),\n\n \"energy_total_prev_kwh\": states(\"input_number.victron_water_heater_energy_total_prev_kwh\"),\n \"energy_prev_at\": states(\"input_datetime.victron_water_heater_energy_prev_at\"),\n\n \"observed_regime_helper\": states(\"input_text.victron_water_heater_observed_regime\"),\n \"observed_power_helper_w\": states(\"input_number.victron_water_heater_observed_power_w\"),\n\n \"mode_mismatch_since\": states(\"input_datetime.victron_water_heater_mode_mismatch_since\"),\n\n \"forced_off_by_advisor\": states(\"input_boolean.victron_water_heater_forced_off_by_advisor\"),\n \"forced_off_reason\": states(\"input_text.victron_water_heater_forced_off_reason\"),\n \"forced_off_at\": states(\"input_datetime.victron_water_heater_forced_off_at\"),\n\n \"pending_user_action_prev\": states(\"input_text.victron_water_heater_pending_user_action\"),\n\n \"restore_surplus_w\": states(\"input_number.victron_water_heater_restore_surplus_w\"),\n \"mode_mismatch_delay_min\": states(\"input_number.victron_water_heater_mode_mismatch_delay_min\")\n} | tojson }}\n" + } + ] + }, + "options": {} + }, + "id": "264b44f4-227a-482e-b4da-9468855ce429", + "name": "HA Snapshot Water Heater Advisor V2", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + -64, + -160 + ], + "credentials": { + "httpBearerAuth": { + "id": "uv3sknYYigxBkaVN", + "name": "Bearer Auth account 2" + } + } + }, + { + "parameters": { + "jsCode": "const root = $input.first()?.json ?? {};\nconst calls = root.ha_service_calls ?? {};\nconst decision = root.decision ?? {};\n\nfunction isObject(v) {\n return v !== null && typeof v === 'object' && !Array.isArray(v);\n}\n\nfunction isValidParisDateTime(v) {\n return typeof v === 'string' && /^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$/.test(v.trim());\n}\n\nfunction normalizeText(v) {\n return (v ?? '').toString();\n}\n\nfunction shouldExecute(call) {\n if (!isObject(call)) return false;\n return call.execute !== false;\n}\n\nfunction getBaseUrl() {\n return ($env.HA_BASE_URL || 'http://10.10.10.17:8123').replace(/\\/$/, '');\n}\n\nfunction getDebugEntityId(data) {\n if (!isObject(data)) return '';\n if (typeof data.entity_id === 'string' && data.entity_id.trim()) return data.entity_id.trim();\n return '';\n}\n\nfunction sanitizeCall(key, call) {\n if (!isObject(call)) return null;\n if (!shouldExecute(call)) return null;\n\n const domain = normalizeText(call.domain).trim();\n const service = normalizeText(call.service).trim();\n const data = isObject(call.data) ? { ...call.data } : {};\n\n if (!domain || !service) return null;\n\n // Garde-fous ciblés\n if (domain === 'input_datetime' && service === 'set_datetime') {\n if (!isValidParisDateTime(data.datetime)) return null;\n }\n\n if (domain === 'input_text' && service === 'set_value') {\n data.value = normalizeText(data.value);\n }\n\n if (domain === 'input_select' && service === 'select_option') {\n if (!normalizeText(data.option).trim()) return null;\n }\n\n if (domain === 'input_number' && service === 'set_value') {\n const n = Number(data.value);\n if (!Number.isFinite(n)) return null;\n data.value = n;\n }\n\n if ((domain === 'switch' || domain === 'input_boolean') && !data.entity_id) {\n return null;\n }\n\n if (domain === 'notify') {\n data.title = normalizeText(data.title);\n data.message = normalizeText(data.message);\n }\n\n const baseUrl = getBaseUrl();\n const debugEntityId = getDebugEntityId(data);\n const debugUrl = `${baseUrl}/api/services/${domain}/${service}`;\n\n return {\n json: {\n action: key,\n domain,\n service,\n data,\n\n // mini debug demandé\n debug_action: key,\n debug_entity_id: debugEntityId || '(none)',\n debug_url: debugUrl,\n\n meta: {\n source: 'water_heater_advisor',\n timestamp_paris: root.timestamp_paris ?? null,\n recommended_mode: decision.recommended_mode ?? null,\n advice_color: decision.advice_color ?? null,\n state: decision.state ?? null\n }\n }\n };\n}\n\n// Ordre conseillé\nconst orderedKeys = [\n 'helper_recommended_mode',\n 'helper_advice_color',\n 'helper_state',\n 'helper_reason',\n 'helper_signature',\n\n 'helper_projected_soc',\n 'helper_budget_margin',\n 'helper_load_kwh',\n\n 'helper_legacy_advice_ok',\n 'helper_green_allowed',\n 'helper_auto_allowed',\n 'helper_boost_allowed',\n\n 'helper_observed_power',\n 'helper_observed_regime',\n 'helper_pending_user_action',\n 'helper_mismatch_since',\n\n 'helper_energy_prev_kwh',\n 'helper_energy_prev_at',\n\n 'helper_forced_off_by_advisor',\n 'helper_forced_off_reason',\n 'helper_forced_off_at',\n\n 'water_heater_power_switch',\n\n 'logbook',\n 'mobile_notification'\n];\n\nconst actions = [];\nconst alreadySeen = new Set();\n\nfor (const key of orderedKeys) {\n alreadySeen.add(key);\n const built = sanitizeCall(key, calls[key]);\n if (built) actions.push(built);\n}\n\n// Sécurité si de nouveaux appels sont ajoutés plus tard\nfor (const [key, call] of Object.entries(calls)) {\n if (alreadySeen.has(key)) continue;\n const built = sanitizeCall(key, call);\n if (built) actions.push(built);\n}\n\nreturn actions;\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1504, + -160 + ], + "id": "3b9b3251-a9ea-4fb4-ba6e-899bc39371e7", + "name": "BUILD ECS HA Actions1" + } + ], + "connections": { + "ECS Normalize Inputs": { + "main": [ + [ + { + "node": "ECS EVCS & Water Heater Observation", + "type": "main", + "index": 0 + } + ] + ] + }, + "ECS EVCS & Water Heater Observation": { + "main": [ + [ + { + "node": "ECS Energy Model & Early Boost Signals", + "type": "main", + "index": 0 + } + ] + ] + }, + "ECS Energy Model & Early Boost Signals": { + "main": [ + [ + { + "node": "ECS Base Advisor Decision", + "type": "main", + "index": 0 + } + ] + ] + }, + "ECS Base Advisor Decision": { + "main": [ + [ + { + "node": "ECS Final Arbitration & Protection", + "type": "main", + "index": 0 + } + ] + ] + }, + "ECS Final Arbitration & Protection": { + "main": [ + [ + { + "node": "ECS Output Builder", + "type": "main", + "index": 0 + } + ] + ] + }, + "ECS Output Builder": { + "main": [ + [ + { + "node": "BUILD ECS HA Actions1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Schedule Trigger": { + "main": [ + [ + { + "node": "HA Snapshot Water Heater Advisor V2", + "type": "main", + "index": 0 + } + ] + ] + }, + "HA Snapshot Water Heater Advisor V2": { + "main": [ + [ + { + "node": "ECS Normalize Inputs", + "type": "main", + "index": 0 + } + ] + ] + }, + "BUILD ECS HA Actions1": { + "main": [ + [ + { + "node": "Execute HA Service", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "nodeGroups": [], + "authors": "thierry tallagrand", + "name": "Version 104ba5d2", + "description": "", + "autosaved": true, + "workflowPublishHistory": [ + { + "createdAt": "2026-05-07T12:41:54.515Z", + "id": 84, + "workflowId": "yX2j8m04e3pxaoQq", + "versionId": "104ba5d2-fa60-4b6e-9d00-d0b853c79fd4", + "event": "activated", + "userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2" + }, + { + "createdAt": "2026-06-12T21:11:20.249Z", + "id": 112, + "workflowId": "yX2j8m04e3pxaoQq", + "versionId": "104ba5d2-fa60-4b6e-9d00-d0b853c79fd4", + "event": "activated", + "userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2" + }, + { + "createdAt": "2026-06-12T21:11:27.050Z", + "id": 114, + "workflowId": "yX2j8m04e3pxaoQq", + "versionId": "104ba5d2-fa60-4b6e-9d00-d0b853c79fd4", + "event": "activated", + "userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2" + }, + { + "createdAt": "2026-07-09T21:45:54.876Z", + "id": 200, + "workflowId": "yX2j8m04e3pxaoQq", + "versionId": "104ba5d2-fa60-4b6e-9d00-d0b853c79fd4", + "event": "activated", + "userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2" + }, + { + "createdAt": "2026-06-12T21:11:16.258Z", + "id": 111, + "workflowId": "yX2j8m04e3pxaoQq", + "versionId": "104ba5d2-fa60-4b6e-9d00-d0b853c79fd4", + "event": "deactivated", + "userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2" + }, + { + "createdAt": "2026-06-12T21:11:27.017Z", + "id": 113, + "workflowId": "yX2j8m04e3pxaoQq", + "versionId": "104ba5d2-fa60-4b6e-9d00-d0b853c79fd4", + "event": "deactivated", + "userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2" + }, + { + "createdAt": "2026-07-09T21:45:54.856Z", + "id": 199, + "workflowId": "yX2j8m04e3pxaoQq", + "versionId": "104ba5d2-fa60-4b6e-9d00-d0b853c79fd4", + "event": "deactivated", + "userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2" + } + ] + } +}