diff --git a/HANDOFF.md b/HANDOFF.md index d78426b..cde8f50 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2063,5 +2063,22 @@ Deux freins empilaient leurs effets a midi (SOC 31 -> 35 % pour ~44 % requis) : ⚠️ A verifier : la prochaine session HC avec manque (cette nuit, jour faible) : courant ACM calcule, consigne, SOC atteint a 07:24 contre la cible. La fenetre de midi du 07/10 s'est fermee 2 min apres le deploiement. +### 07/10 14:31 — Bilan de la HC de midi, et deux corrections du chauffe-eau +- **Bilan** : EDF 12:24-14:24, ballon 12:25-14:25 ; **2,30 kWh** chauffes (compteur 19,19 -> 21,49), ~1,15 kW + en moyenne en BOOST ; batterie 30 -> 35 % (14:08), 34 % a 14:27 (~1,4 kWh, corrections arrivees a 14:17 et + 14:22) ; EDF ~10 kWh sur la fenetre ; maison 6,3 kW a 13 h (dont ~5 kW hors chauffe-eau) ; production + 6,2 kWh a 14:31. Alerte : `active:false` toute la fenetre (BOOST pose a 12:35, `max_w` 2 830) — correct. +- **Coupe en pleine chauffe** : a 14:24 le ballon tirait encore 2,3 kW (eau pas a 65 °C), et la securite du soir + (< 1,5 kWh depuis 07:24) ne pouvait plus jouer (2,7 kWh). **Correctif (accord, deploye 14:37)** : quand la + regle jour faible coupe le ballon alors qu'il chauffe (puissance mesuree ou precedente > 150 W), elle ecrit + `input_text.victron_water_heater_coupe_en_chauffe` (« AAAA-MM-JJ HH:MM ») ; ce jour-la, la securite de + 16 h-20 h joue meme au-dela de 1,5 kWh. Valeur posee a la main pour le 07/10 (« 2026-10-07 14:25 »). La + reprise peut se faire sur la batterie : arbitrage confort assume. +- **Mesure sur 10 min** : le compteur Tuya ne remonte que toutes les ~10 min ; mesure sur 5 min = 2,3 kW / 0 W + en alternance (regime qui clignote). `sampleWindowMinMin` 2 -> 9,5 (max 15 -> 25) ; la reference + (`energy_total_prev_kwh` / `_prev_at`) n'avance que si l'echantillon a servi ou est perime. +- Tests ECS 23/23 (horloge simulee dans le harnais pour la reprise du soir ; coupure de la veille ignoree), + mutation qui mord. + --- -*Document mis a jour le : 07/10/2026 (~14:25) — recharge reseau HC : courant calcule (12 kVA) et consigne non tenue ; HC de midi : AUTO ne chauffe pas (BOOST), alerte v2, Jeep (contact, relecture, zone), recharge batterie bridee a 30 A par l'ACM ; jour faible = EDF les deux fenetres HC pour le chauffe-eau, alerte BOOST, Victron Mobile refait ; 1re decision jour faible en reel, mesure ECS reparee, douches 06:00/20:00, Grafana chauffe-eau, tableaux ECS ; chauffe-eau en HC de nuit les jours faibles (decision du bilan, workflow ECS sous controle du depot, VRM, V2.3 desactive) ; garde batterie reseau : voiture bridee a 6 A au lieu d'un arret ; garde-fou concierge 2 h ; allers-retours brefs de la borne retrouves dans l'historique HA (echange VW -> JEEP de 20 s), production 14,5 kWh contre 22 prevus, defaut garde batterie en mode reseau a trancher ; voiture a midi : la batterie n'est plus comptee comme du solaire (EDF automatique les jours de depart si le surplus ne couvre pas) ; nuit 05->06 : JEEP chargee sur EDF en complete (6,32 kWh), budget 0 du a la prevision, Windy/ECMWF contre VRM, a comparer ce soir ; concierge aligne sur la preuve fin de trajet ; a verifier : nuit 05->06 et mardi faible, comparaison VRM/Forecast.Solar ; JEEP identifiee aussi par sa fin de trajet, prevision de la nuit 05->06 ; identification JEEP reussie en reel, correction humaine qui prime (refus supprime), session du 04/10 coupee ; EVCS Master en quatre onglets ; Jeep lue par Uconnect et identifiee seule, capacite JEEP 10 kWh, concierge variante (a) ; releve par photo (SPARK), hass-uconnect en attente ; ecart semaine/week-end mesure (pas de profil week-end la nuit) ; nuit du budget : budget borne au plancher prudent, sans EDF = voiture bloquee meme EDF ferme (batterie seule) ; Energie : sauts corriges ; tableau Energie complete (Sud/Ouest, batterie par integration, borne ; aucun redemarrage HA) ; kilometrage des vehicules (km/kWh, capacite, releves de reference, agent cable 15/15) ; a verifier le 04/10 au matin : nuit du budget JEEP ; concierge : vehicule non confirme demande, heure de saisie ; Grafana vehicules en tableau ; Grafana vehicules : ZERO et Invite ajoutes, session 16:46 reetiquetee ZERO ; prevision SOC 10 h ~35 % ; concierge fiable : niveau note par le script, une ligne, dedoublonnage, simulation par drapeau ; agent : message incompris = question, bourse sur demande explicite (banc final 6/6) ; a verifier : nuit du samedi 03 au dimanche 04/10 (budget < besoin JEEP), cron orange-cloture lundi 05/10 apres 17:40, comparaison VRM / Forecast.Solar lundi 05/10* +*Document mis a jour le : 07/10/2026 (~14:40) — bilan HC de midi, reprise du soir apres coupure en pleine chauffe, mesure ECS sur 10 min ; recharge reseau HC : courant calcule (12 kVA) et consigne non tenue ; HC de midi : AUTO ne chauffe pas (BOOST), alerte v2, Jeep (contact, relecture, zone), recharge batterie bridee a 30 A par l'ACM ; jour faible = EDF les deux fenetres HC pour le chauffe-eau, alerte BOOST, Victron Mobile refait ; 1re decision jour faible en reel, mesure ECS reparee, douches 06:00/20:00, Grafana chauffe-eau, tableaux ECS ; chauffe-eau en HC de nuit les jours faibles (decision du bilan, workflow ECS sous controle du depot, VRM, V2.3 desactive) ; garde batterie reseau : voiture bridee a 6 A au lieu d'un arret ; garde-fou concierge 2 h ; allers-retours brefs de la borne retrouves dans l'historique HA (echange VW -> JEEP de 20 s), production 14,5 kWh contre 22 prevus, defaut garde batterie en mode reseau a trancher ; voiture a midi : la batterie n'est plus comptee comme du solaire (EDF automatique les jours de depart si le surplus ne couvre pas) ; nuit 05->06 : JEEP chargee sur EDF en complete (6,32 kWh), budget 0 du a la prevision, Windy/ECMWF contre VRM, a comparer ce soir ; concierge aligne sur la preuve fin de trajet ; a verifier : nuit 05->06 et mardi faible, comparaison VRM/Forecast.Solar ; JEEP identifiee aussi par sa fin de trajet, prevision de la nuit 05->06 ; identification JEEP reussie en reel, correction humaine qui prime (refus supprime), session du 04/10 coupee ; EVCS Master en quatre onglets ; Jeep lue par Uconnect et identifiee seule, capacite JEEP 10 kWh, concierge variante (a) ; releve par photo (SPARK), hass-uconnect en attente ; ecart semaine/week-end mesure (pas de profil week-end la nuit) ; nuit du budget : budget borne au plancher prudent, sans EDF = voiture bloquee meme EDF ferme (batterie seule) ; Energie : sauts corriges ; tableau Energie complete (Sud/Ouest, batterie par integration, borne ; aucun redemarrage HA) ; kilometrage des vehicules (km/kWh, capacite, releves de reference, agent cable 15/15) ; a verifier le 04/10 au matin : nuit du budget JEEP ; concierge : vehicule non confirme demande, heure de saisie ; Grafana vehicules en tableau ; Grafana vehicules : ZERO et Invite ajoutes, session 16:46 reetiquetee ZERO ; prevision SOC 10 h ~35 % ; concierge fiable : niveau note par le script, une ligne, dedoublonnage, simulation par drapeau ; agent : message incompris = question, bourse sur demande explicite (banc final 6/6) ; a verifier : nuit du samedi 03 au dimanche 04/10 (budget < besoin JEEP), cron orange-cloture lundi 05/10 apres 17:40, comparaison VRM / Forecast.Solar lundi 05/10* diff --git a/packages/evcs_chauffe_eau_nuit.yaml b/packages/evcs_chauffe_eau_nuit.yaml index bf72a3f..688cc95 100755 --- a/packages/evcs_chauffe_eau_nuit.yaml +++ b/packages/evcs_chauffe_eau_nuit.yaml @@ -18,6 +18,13 @@ input_boolean: icon: mdi:water-boiler-off input_text: + # « AAAA-MM-JJ HH:MM » de la derniere coupure faite EN PLEINE CHAUFFE (fin des + # HC) par la regle jour faible : autorise la reprise de 16 h a 20 h ce jour-la + # (douche de 20 h). Ecrit par le workflow ECS (07/10/2026). + victron_water_heater_coupe_en_chauffe: + name: "Chauffe-eau - coupe en pleine chauffe" + max: 32 + icon: mdi:water-boiler-alert # Alerte « passe en BOOST » (07/10/2026) : ecrite par le workflow ECS quand, # en heures creuses sur EDF, le ballon ne tire que la pompe a chaleur. JSON # {active, since, fenetre, puissance_w, message} ou chaine vide. Le concierge diff --git a/src/water-heater/ARBITRATION.js b/src/water-heater/ARBITRATION.js index ccf4b62..406de99 100644 --- a/src/water-heater/ARBITRATION.js +++ b/src/water-heater/ARBITRATION.js @@ -282,6 +282,7 @@ const { } = normalized; let offJourFaible = false; let jourFaibleState = 'none'; +let coupeEnChauffe = null; // horodatage a ecrire si la coupure tombe en pleine chauffe if (advisorEnabled && ecsNuit) { const energieConnue = Number.isFinite(ecs24hKwh); const eauFroide = energieConnue && ecs24hKwh < ECS_EAU_FROIDE_KWH; @@ -292,8 +293,11 @@ if (advisorEnabled && ecsNuit) { ? pvPowerW >= houseAcPowerW : pvPowerW - houseAcPowerW >= autoPowerW); const heure = Number.isFinite(now.hour) ? now.hour : -1; + // 07/10 : coupe en PLEINE CHAUFFE a la fin des HC de midi (BOOST encore a 2,3 kW + // a 14:24) -> l'eau n'est pas a 65 °C : il reprend aussi de 16 h a 20 h. + const coupeEnChauffeAujourdhui = (input.coupe_en_chauffe || '').startsWith(now.date || '?'); const securiteSoir = heure >= ECS_SOIR_DEBUT_H && heure < ECS_SOIR_FIN_H - && !(Number.isFinite(ecsJourKwh) && ecsJourKwh >= ECS_EAU_FROIDE_KWH); + && (!(Number.isFinite(ecsJourKwh) && ecsJourKwh >= ECS_EAU_FROIDE_KWH) || coupeEnChauffeAujourdhui); const veutChauffer = !energieConnue || heuresCreusesEdf || eauFroide || solaireCouvre || securiteSoir; offJourFaible = true; jourFaibleState = !energieConnue ? 'jour_faible_energie_illisible' @@ -305,6 +309,10 @@ if (advisorEnabled && ecsNuit) { if (veutChauffer !== waterHeaterSwitchOn) { powerSwitchAction = veutChauffer ? 'turn_on' : 'turn_off'; powerSwitchDesiredOn = veutChauffer; + // Coupure alors qu'il chauffait (puissance mesuree au-dessus de l'arret). + if (!veutChauffer && Math.max(observedPowerW, normalized.observedPowerPrevW ?? 0) > 150) { + coupeEnChauffe = `${now.date} ${String(now.hour).padStart(2, '0')}:${String(now.minute).padStart(2, '0')}`; + } } else { powerSwitchAction = 'none'; powerSwitchDesiredOn = waterHeaterSwitchOn; @@ -406,7 +414,8 @@ return [{ powerSwitchDesiredOn, offJourFaible, jourFaibleState, - alerteChauffeEau + alerteChauffeEau, + coupeEnChauffe } } }]; \ 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 index 21713ea..9b545e9 100644 --- a/src/water-heater/HA_SNAPSHOT_TEMPLATE.json.tpl +++ b/src/water-heater/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -34,6 +34,7 @@ "ecs_jour_kwh": states("sensor.evcs_chauffe_eau_jour_kwh"), "off_jour_faible_prev": states("input_boolean.victron_water_heater_off_jour_faible"), "alerte_prev": states("input_text.evcs_alerte_chauffe_eau"), + "coupe_en_chauffe": states("input_text.victron_water_heater_coupe_en_chauffe"), "hc_midday_start": states("input_datetime.victron_evcs_hc_midday_start"), "hc_midday_stop": states("input_datetime.victron_evcs_hc_midday_stop"), "hc_night_start": states("input_datetime.victron_evcs_hc_night_start"), diff --git a/src/water-heater/NORMALIZE.js b/src/water-heater/NORMALIZE.js index e7d0f86..d071177 100644 --- a/src/water-heater/NORMALIZE.js +++ b/src/water-heater/NORMALIZE.js @@ -141,8 +141,11 @@ const cfg = { mobileNotifyDomain: 'notify', mobileNotifyService: 'mobile_app_iphone_16_de_tt', batteryUsableCapacityKwh: 17.5, - sampleWindowMinMin: 2, - sampleWindowMinMax: 15, + // 07/10/2026 : le compteur Tuya ne remonte que toutes les ~10 min ; mesure sur + // 5 min = une fois 2,3 kW, une fois 0 W (regime qui clignote). On mesure donc + // sur >= 9,5 min : la reference n'est avancee que quand l'echantillon sert. + sampleWindowMinMin: 9.5, + sampleWindowMinMax: 25, energyByDemandKwh: { low: { green: 0.9, auto: 1.4, boost: 2.0 }, normal: { green: 1.4, auto: 2.2, boost: 3.1 }, @@ -196,6 +199,7 @@ const ecsNuit = low(input.ecs_nuit) === 'on'; const hcSessionCommitted = low(input.hc_session_committed) === 'on'; const ecs24hKwh = num(input.ecs_24h_kwh, NaN); const ecsJourKwh = num(input.ecs_jour_kwh, NaN); +const observedPowerPrevW = Math.max(0, num(input.observed_power_helper_w, 0)); const offJourFaiblePrev = low(input.off_jour_faible_prev) === 'on'; const forecastSource = cleanStateText(input.forecast_source) || 'forecast_solar'; // Fenetres HC (« HH:MM:SS » des input_datetime du controleur), en minutes du jour. @@ -251,6 +255,7 @@ return [{ hcSessionCommitted, ecs24hKwh, ecsJourKwh, + observedPowerPrevW, offJourFaiblePrev, forecastSource, hcWindows diff --git a/src/water-heater/OUTPUT_BUILDER.js b/src/water-heater/OUTPUT_BUILDER.js index 4490f15..749a494 100644 --- a/src/water-heater/OUTPUT_BUILDER.js +++ b/src/water-heater/OUTPUT_BUILDER.js @@ -197,7 +197,8 @@ let { powerSwitchAction, powerSwitchDesiredOn, offJourFaible = false, - alerteChauffeEau = '' + alerteChauffeEau = '', + coupeEnChauffe = null } = finalDecision; reason = Array.isArray(reason) ? [...reason] : []; @@ -524,7 +525,10 @@ helper_budget_margin: { datetime: mismatchSinceAtEffective } }, + // La reference n'avance que si l'echantillon a servi, ou s'il est perime + // (trop vieux ou illisible) : sinon on laisse s'accumuler jusqu'a 9,5 min. helper_energy_prev_kwh: { + execute: !!sampleIsUsable || !(Number.isFinite(sampleDurationMin) && sampleDurationMin < 9.5), domain: 'input_number', service: 'set_value', data: { @@ -533,6 +537,7 @@ helper_budget_margin: { } }, helper_energy_prev_at: { + execute: !!sampleIsUsable || !(Number.isFinite(sampleDurationMin) && sampleDurationMin < 9.5), domain: 'input_datetime', service: 'set_datetime', data: { @@ -564,6 +569,16 @@ helper_budget_margin: { datetime: forcedOffAtSafe } }, + // Coupure en pleine chauffe (07/10/2026) : autorise la reprise de 16 h a 20 h. + helper_coupe_en_chauffe: { + execute: !!coupeEnChauffe, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_water_heater_coupe_en_chauffe', + value: coupeEnChauffe || '' + } + }, // Alerte « passe en BOOST » (07/10/2026), lue par le concierge OpenClaw. helper_alerte_chauffe_eau: { domain: 'input_text', diff --git a/tests/water-heater/run.mjs b/tests/water-heater/run.mjs index b1f77b3..d35b41f 100644 --- a/tests/water-heater/run.mjs +++ b/tests/water-heater/run.mjs @@ -11,10 +11,14 @@ 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) { +function executer(snapshot, fixedMs = null) { let item = snapshot, sorties = {}; + const D = fixedMs === null ? Date : class extends Date { + constructor(...a) { if (a.length === 0) super(fixedMs); else super(...a); } + static now() { return fixedMs; } + }; for (const n of CHAINE) { - const sb = { $input: { first: () => ({ json: item }), all: () => [{ json: item }] }, $env: {}, console }; + const sb = { $input: { first: () => ({ json: item }), all: () => [{ json: item }] }, $env: {}, console, Date: D }; vm.createContext(sb); const out = vm.runInContext(`(function(){ ${code[n]} })()`, sb, { timeout: 5000 }); sorties[n] = out; @@ -75,7 +79,7 @@ for (const c of cas) { } // Coupe = 0 W meme si le compteur remonte en retard (07/10 : « arret — 1595 W »). { - const stamp = new Date(Date.now() - 5 * 60000).toLocaleString('sv-SE', { timeZone: 'Europe/Paris' }); + const stamp = new Date(Date.now() - 10 * 60000).toLocaleString('sv-SE', { timeZone: 'Europe/Paris' }); const s = executer({ ...BASE, water_heater_switch: 'off', water_heater_energy_total_kwh: '18.88', energy_total_prev_kwh: '18.75', energy_prev_at: stamp }); const o = s.OBSERVATION[0].json.observation; if (o.observedPowerW === 0 && o.observedRegime === 'off') { ok++; console.log('✅ coupe-zero-watt'); } @@ -85,7 +89,7 @@ for (const c of cas) { { const nowMin = (() => { const p = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date()).split(':'); return Number(p[0]) * 60 + Number(p[1]); })(); const hm = (m) => { const x = ((m % 1440) + 1440) % 1440; return `${String(Math.floor(x / 60)).padStart(2, '0')}:${String(x % 60).padStart(2, '0')}:00`; }; - const stamp = new Date(Date.now() - 5 * 60000).toLocaleString('sv-SE', { timeZone: 'Europe/Paris' }); + const stamp = new Date(Date.now() - 10 * 60000).toLocaleString('sv-SE', { timeZone: 'Europe/Paris' }); const fenetre = { hc_midday_start: hm(nowMin - 30), hc_midday_stop: hm(nowMin + 60), hc_night_start: hm(nowMin + 300), hc_night_stop: hm(nowMin + 600) }; const base = { ...BASE, ...fenetre, grid_state: 'on', hc_session_committed: 'on', water_heater_switch: 'on', water_heater_energy_total_kwh: '18.65', energy_total_prev_kwh: '18.60', energy_prev_at: stamp }; // 600 W : pompe a chaleur @@ -100,12 +104,40 @@ for (const c of cas) { }; verif('alerte-boost-pompe-a-chaleur-en-hc', {}, true); verif('alerte-boost-auto-arrete-0-w-en-hc', { water_heater_energy_total_kwh: '18.60' }, true); - verif('alerte-boost-resistance-pas-d-alerte', { water_heater_energy_total_kwh: '18.75' }, false); + verif('alerte-boost-resistance-pas-d-alerte', { water_heater_energy_total_kwh: '18.80' }, false); verif('alerte-boost-resistance-deja-vue-dans-la-fenetre', { water_heater_energy_total_kwh: '18.60', alerte_prev: JSON.stringify({ active: false, since: debut, max_w: 1235 }) }, false); verif('alerte-boost-debut-de-fenetre-attente', { hc_midday_start: hm(nowMin - 5) }, false); verif('alerte-boost-edf-ouvert-pas-d-alerte', { grid_state: 'off' }, false); } +// 07/10 : mesure sur >= 9,5 min (compteur Tuya ~10 min) ; reference non avancee sur 5 min. +{ + const st5 = new Date(Date.now() - 5 * 60000).toLocaleString('sv-SE', { timeZone: 'Europe/Paris' }); + const s = executer({ ...BASE, ecs_nuit: 'off', water_heater_energy_total_kwh: '18.60', energy_total_prev_kwh: '18.60', + energy_prev_at: st5, observed_power_helper_w: '2300', observed_regime_helper: 'boost_high' }); + const o = s.OBSERVATION[0].json.observation; + const ref = s.BUILD_ACTIONS.map((x) => x.json).find((c) => c.data?.entity_id === 'input_number.victron_water_heater_energy_total_prev_kwh'); + if (o.observedPowerW === 2300 && !ref) { ok++; console.log('✅ mesure-5-min-garde-la-puissance-et-la-reference'); } + else { ko++; console.log(`❌ mesure-5-min-garde-la-puissance-et-la-reference | ${o.observedPowerW} W ref=${!!ref}`); } +} +// 07/10 : coupure en pleine chauffe a la fin des HC -> reprise de 16 h a 20 h meme au-dela de 1,5 kWh. +{ + const s1 = executer({ ...BASE, hc_session_committed: 'off', water_heater_switch: 'on', observed_power_helper_w: '2300', + pv_power_w: '1500', house_ac_power_w: '3500' }); + const fd1 = s1.ARBITRATION[0].json.finalDecision; + const ecrit = s1.BUILD_ACTIONS.map((x) => x.json).find((c) => c.data?.entity_id === 'input_text.victron_water_heater_coupe_en_chauffe'); + if (fd1.powerSwitchAction === 'turn_off' && fd1.coupeEnChauffe && ecrit) { ok++; console.log('✅ coupe-en-pleine-chauffe-memorisee'); } + else { ko++; console.log(`❌ coupe-en-pleine-chauffe-memorisee | ${fd1.powerSwitchAction} ${fd1.coupeEnChauffe} ${!!ecrit}`); } + const soir = Date.parse('2026-10-07T17:00:00+02:00'); + const s2 = executer({ ...BASE, water_heater_switch: 'off', off_jour_faible_prev: 'on', ecs_jour_kwh: '2.7', coupe_en_chauffe: '2026-10-07 14:25' }, soir); + const fd2 = s2.ARBITRATION[0].json.finalDecision; + if (fd2.powerSwitchAction === 'turn_on' && fd2.jourFaibleState === 'jour_faible_securite_soir') { ok++; console.log('✅ reprise-du-soir-apres-coupure-en-chauffe'); } + else { ko++; console.log(`❌ reprise-du-soir-apres-coupure-en-chauffe | ${fd2.powerSwitchAction} ${fd2.jourFaibleState}`); } + const s3 = executer({ ...BASE, water_heater_switch: 'off', off_jour_faible_prev: 'on', ecs_jour_kwh: '2.7', coupe_en_chauffe: '2026-10-06 14:25' }, soir); + const fd3 = s3.ARBITRATION[0].json.finalDecision; + if (fd3.powerSwitchAction === 'none' && fd3.jourFaibleState === 'jour_faible_coupe') { ok++; console.log('✅ coupure-en-chauffe-de-la-veille-ignoree'); } + else { ko++; console.log(`❌ coupure-en-chauffe-de-la-veille-ignoree | ${fd3.powerSwitchAction} ${fd3.jourFaibleState}`); } +} // 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' }); diff --git a/workflows/water-heater-ecs-v1.json b/workflows/water-heater-ecs-v1.json index 419261f..3704478 100644 --- a/workflows/water-heater-ecs-v1.json +++ b/workflows/water-heater-ecs-v1.json @@ -9,7 +9,7 @@ "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 ecsJourKwh = num(input.ecs_jour_kwh, NaN);\nconst offJourFaiblePrev = low(input.off_jour_faible_prev) === 'on';\nconst forecastSource = cleanStateText(input.forecast_source) || 'forecast_solar';\n// Fenetres HC (« HH:MM:SS » des input_datetime du controleur), en minutes du jour.\nconst hhmm = (v) => { const m = cleanStateText(v).match(/^(\\d{1,2}):(\\d{2})/); return m ? Number(m[1]) * 60 + Number(m[2]) : null; };\nconst hcWindows = [\n { nom: 'midi', debut: hhmm(input.hc_midday_start), fin: hhmm(input.hc_midday_stop), douche: '20 h' },\n { nom: 'nuit', debut: hhmm(input.hc_night_start), fin: hhmm(input.hc_night_stop), douche: '06 h' },\n].filter((w) => w.debut !== null && w.fin !== null);\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 ecsJourKwh,\n offJourFaiblePrev,\n forecastSource,\n hcWindows\n }\n }\n}];" + "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 // 07/10/2026 : le compteur Tuya ne remonte que toutes les ~10 min ; mesure sur\n // 5 min = une fois 2,3 kW, une fois 0 W (regime qui clignote). On mesure donc\n // sur >= 9,5 min : la reference n'est avancee que quand l'echantillon sert.\n sampleWindowMinMin: 9.5,\n sampleWindowMinMax: 25,\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 ecsJourKwh = num(input.ecs_jour_kwh, NaN);\nconst observedPowerPrevW = Math.max(0, num(input.observed_power_helper_w, 0));\nconst offJourFaiblePrev = low(input.off_jour_faible_prev) === 'on';\nconst forecastSource = cleanStateText(input.forecast_source) || 'forecast_solar';\n// Fenetres HC (« HH:MM:SS » des input_datetime du controleur), en minutes du jour.\nconst hhmm = (v) => { const m = cleanStateText(v).match(/^(\\d{1,2}):(\\d{2})/); return m ? Number(m[1]) * 60 + Number(m[2]) : null; };\nconst hcWindows = [\n { nom: 'midi', debut: hhmm(input.hc_midday_start), fin: hhmm(input.hc_midday_stop), douche: '20 h' },\n { nom: 'nuit', debut: hhmm(input.hc_night_start), fin: hhmm(input.hc_night_stop), douche: '06 h' },\n].filter((w) => w.debut !== null && w.fin !== null);\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 ecsJourKwh,\n observedPowerPrevW,\n offJourFaiblePrev,\n forecastSource,\n hcWindows\n }\n }\n}];" }, "id": "1226082d-222c-4837-99d3-35094bd1c4f1", "name": "ECS Normalize Inputs", @@ -61,7 +61,7 @@ }, { "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 ?? {};\nconst input = root.input ?? {};\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;\n// Douches a 06:00 et 20:00 tous les jours (proprietaire, 07/10) : couper toute\n// la journee laisserait l'eau froide a 20 h. Un jour faible, il chauffe donc\n// AUSSI sur un vrai surplus solaire (jamais sur la batterie), et a partir de\n// 16 h s'il n'a presque pas chauffe depuis 07:24 (securite de la douche du soir).\nconst ECS_SOIR_DEBUT_H = 16;\nconst ECS_SOIR_FIN_H = 20;\nconst {\n ecsNuit = false, hcSessionCommitted = false, ecs24hKwh = NaN, ecsJourKwh = NaN,\n offJourFaiblePrev = false, pvPowerW = 0, autoPowerW = 1200,\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 // Surplus reel : sous tension, la production couvre la maison (chauffe-eau\n // compris) ; hors tension, il reste de quoi l'alimenter. Reseau ouvert.\n const solaireCouvre = !gridIsOn && (waterHeaterSwitchOn\n ? pvPowerW >= houseAcPowerW\n : pvPowerW - houseAcPowerW >= autoPowerW);\n const heure = Number.isFinite(now.hour) ? now.hour : -1;\n const securiteSoir = heure >= ECS_SOIR_DEBUT_H && heure < ECS_SOIR_FIN_H\n && !(Number.isFinite(ecsJourKwh) && ecsJourKwh >= ECS_EAU_FROIDE_KWH);\n const veutChauffer = !energieConnue || heuresCreusesEdf || eauFroide || solaireCouvre || securiteSoir;\n offJourFaible = true;\n jourFaibleState = !energieConnue ? 'jour_faible_energie_illisible'\n : heuresCreusesEdf ? 'jour_faible_chauffe_hc_edf'\n : eauFroide ? 'jour_faible_eau_froide'\n : securiteSoir ? 'jour_faible_securite_soir'\n : solaireCouvre ? 'jour_faible_surplus_solaire'\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 : jourFaibleState === 'jour_faible_securite_soir' ? 'Jour faible : chauffe pour la douche de 20 h (peu chauffe depuis 07:24)'\n : jourFaibleState === 'jour_faible_surplus_solaire' ? 'Jour faible : surplus solaire reel, il chauffe sans toucher la batterie'\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\n// =====================================================\n// ALERTE « PASSE EN BOOST » (07/10/2026, proprietaire)\n// =====================================================\n// En heures creuses sur EDF, le ballon doit consommer AU MAXIMUM toute la\n// fenetre (douches 06:00 et 20:00 a 65 °C). Constat du 07/10 a midi : en AUTO\n// il etait ARRETE (0-36 W) alors que l'eau n'etait pas a 65 °C — passe en BOOST\n// a 12:35 il est reparti a 1 235 W. AUTO s'arrete plus bas : 0 W ne prouve\n// donc PAS une eau chaude. Regle : 15 min apres le debut de la fenetre, session\n// engagee, contacteur ferme, ballon sous tension, et la puissance de la\n// RESISTANCE jamais atteinte depuis le debut de la fenetre -> on le SIGNALE.\n// Memoire de la fenetre (puissance max vue) dans le helper lui-meme ; le\n// concierge n'envoie que `active: true`, une fois par `since` (debut de la\n// fenetre). Limite assumee : ballon deja a 65 °C en BOOST = 0 W aussi, donc un\n// message pour rien au plus par fenetre (aucune temperature dans HA).\nconst ALERTE_DELAI_MIN = 15;\nconst seuilResistanceW = ((normalized.greenPowerW ?? 620) + (normalized.autoPowerW ?? 1200)) / 2;\nconst minNow = Number.isFinite(now.minNow) ? now.minNow : null;\nconst fenetreHc = (normalized.hcWindows || []).find((w) => minNow !== null && (\n w.debut <= w.fin ? (minNow >= w.debut && minNow < w.fin) : (minNow >= w.debut || minNow < w.fin)));\nlet alerteChauffeEau = '';\nif (fenetreHc) {\n const depuisDebutMin = (minNow - fenetreHc.debut + 1440) % 1440;\n const h = String(Math.floor(fenetreHc.debut / 60)).padStart(2, '0') + ':' + String(fenetreHc.debut % 60).padStart(2, '0');\n const jourFenetre = (fenetreHc.debut > fenetreHc.fin && minNow < fenetreHc.fin)\n ? new Date(nowMs - 86400000).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }) : now.date;\n const since = `${jourFenetre} ${h}`;\n let prev = null;\n try { prev = JSON.parse(input.alerte_prev || ''); } catch { prev = null; }\n const maxPrev = prev && prev.since === since && Number.isFinite(prev.max_w) ? prev.max_w : 0;\n const maxW = Math.round(Math.max(maxPrev, waterHeaterSwitchOn ? observedPowerW : 0));\n const active = depuisDebutMin >= ALERTE_DELAI_MIN && hcSessionCommitted && gridIsOn\n && waterHeaterSwitchOn && maxW < seuilResistanceW;\n alerteChauffeEau = JSON.stringify({\n active, since, fenetre: fenetreHc.nom, max_w: maxW, puissance_w: Math.round(observedPowerW),\n ...(active ? { message: `🚿 Heures creuses EDF : le chauffe-eau ne chauffe pas a pleine puissance (${Math.round(observedPowerW)} W). Si tu n'es pas en BOOST, passe-le pour avoir 65 °C a ${fenetreHc.douche}.` } : {}),\n });\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 alerteChauffeEau\n }\n }\n}];" + "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 ?? {};\nconst input = root.input ?? {};\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;\n// Douches a 06:00 et 20:00 tous les jours (proprietaire, 07/10) : couper toute\n// la journee laisserait l'eau froide a 20 h. Un jour faible, il chauffe donc\n// AUSSI sur un vrai surplus solaire (jamais sur la batterie), et a partir de\n// 16 h s'il n'a presque pas chauffe depuis 07:24 (securite de la douche du soir).\nconst ECS_SOIR_DEBUT_H = 16;\nconst ECS_SOIR_FIN_H = 20;\nconst {\n ecsNuit = false, hcSessionCommitted = false, ecs24hKwh = NaN, ecsJourKwh = NaN,\n offJourFaiblePrev = false, pvPowerW = 0, autoPowerW = 1200,\n} = normalized;\nlet offJourFaible = false;\nlet jourFaibleState = 'none';\nlet coupeEnChauffe = null; // horodatage a ecrire si la coupure tombe en pleine chauffe\nif (advisorEnabled && ecsNuit) {\n const energieConnue = Number.isFinite(ecs24hKwh);\n const eauFroide = energieConnue && ecs24hKwh < ECS_EAU_FROIDE_KWH;\n const heuresCreusesEdf = hcSessionCommitted && gridIsOn;\n // Surplus reel : sous tension, la production couvre la maison (chauffe-eau\n // compris) ; hors tension, il reste de quoi l'alimenter. Reseau ouvert.\n const solaireCouvre = !gridIsOn && (waterHeaterSwitchOn\n ? pvPowerW >= houseAcPowerW\n : pvPowerW - houseAcPowerW >= autoPowerW);\n const heure = Number.isFinite(now.hour) ? now.hour : -1;\n // 07/10 : coupe en PLEINE CHAUFFE a la fin des HC de midi (BOOST encore a 2,3 kW\n // a 14:24) -> l'eau n'est pas a 65 °C : il reprend aussi de 16 h a 20 h.\n const coupeEnChauffeAujourdhui = (input.coupe_en_chauffe || '').startsWith(now.date || '?');\n const securiteSoir = heure >= ECS_SOIR_DEBUT_H && heure < ECS_SOIR_FIN_H\n && (!(Number.isFinite(ecsJourKwh) && ecsJourKwh >= ECS_EAU_FROIDE_KWH) || coupeEnChauffeAujourdhui);\n const veutChauffer = !energieConnue || heuresCreusesEdf || eauFroide || solaireCouvre || securiteSoir;\n offJourFaible = true;\n jourFaibleState = !energieConnue ? 'jour_faible_energie_illisible'\n : heuresCreusesEdf ? 'jour_faible_chauffe_hc_edf'\n : eauFroide ? 'jour_faible_eau_froide'\n : securiteSoir ? 'jour_faible_securite_soir'\n : solaireCouvre ? 'jour_faible_surplus_solaire'\n : 'jour_faible_coupe';\n if (veutChauffer !== waterHeaterSwitchOn) {\n powerSwitchAction = veutChauffer ? 'turn_on' : 'turn_off';\n powerSwitchDesiredOn = veutChauffer;\n // Coupure alors qu'il chauffait (puissance mesuree au-dessus de l'arret).\n if (!veutChauffer && Math.max(observedPowerW, normalized.observedPowerPrevW ?? 0) > 150) {\n coupeEnChauffe = `${now.date} ${String(now.hour).padStart(2, '0')}:${String(now.minute).padStart(2, '0')}`;\n }\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 : jourFaibleState === 'jour_faible_securite_soir' ? 'Jour faible : chauffe pour la douche de 20 h (peu chauffe depuis 07:24)'\n : jourFaibleState === 'jour_faible_surplus_solaire' ? 'Jour faible : surplus solaire reel, il chauffe sans toucher la batterie'\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\n// =====================================================\n// ALERTE « PASSE EN BOOST » (07/10/2026, proprietaire)\n// =====================================================\n// En heures creuses sur EDF, le ballon doit consommer AU MAXIMUM toute la\n// fenetre (douches 06:00 et 20:00 a 65 °C). Constat du 07/10 a midi : en AUTO\n// il etait ARRETE (0-36 W) alors que l'eau n'etait pas a 65 °C — passe en BOOST\n// a 12:35 il est reparti a 1 235 W. AUTO s'arrete plus bas : 0 W ne prouve\n// donc PAS une eau chaude. Regle : 15 min apres le debut de la fenetre, session\n// engagee, contacteur ferme, ballon sous tension, et la puissance de la\n// RESISTANCE jamais atteinte depuis le debut de la fenetre -> on le SIGNALE.\n// Memoire de la fenetre (puissance max vue) dans le helper lui-meme ; le\n// concierge n'envoie que `active: true`, une fois par `since` (debut de la\n// fenetre). Limite assumee : ballon deja a 65 °C en BOOST = 0 W aussi, donc un\n// message pour rien au plus par fenetre (aucune temperature dans HA).\nconst ALERTE_DELAI_MIN = 15;\nconst seuilResistanceW = ((normalized.greenPowerW ?? 620) + (normalized.autoPowerW ?? 1200)) / 2;\nconst minNow = Number.isFinite(now.minNow) ? now.minNow : null;\nconst fenetreHc = (normalized.hcWindows || []).find((w) => minNow !== null && (\n w.debut <= w.fin ? (minNow >= w.debut && minNow < w.fin) : (minNow >= w.debut || minNow < w.fin)));\nlet alerteChauffeEau = '';\nif (fenetreHc) {\n const depuisDebutMin = (minNow - fenetreHc.debut + 1440) % 1440;\n const h = String(Math.floor(fenetreHc.debut / 60)).padStart(2, '0') + ':' + String(fenetreHc.debut % 60).padStart(2, '0');\n const jourFenetre = (fenetreHc.debut > fenetreHc.fin && minNow < fenetreHc.fin)\n ? new Date(nowMs - 86400000).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }) : now.date;\n const since = `${jourFenetre} ${h}`;\n let prev = null;\n try { prev = JSON.parse(input.alerte_prev || ''); } catch { prev = null; }\n const maxPrev = prev && prev.since === since && Number.isFinite(prev.max_w) ? prev.max_w : 0;\n const maxW = Math.round(Math.max(maxPrev, waterHeaterSwitchOn ? observedPowerW : 0));\n const active = depuisDebutMin >= ALERTE_DELAI_MIN && hcSessionCommitted && gridIsOn\n && waterHeaterSwitchOn && maxW < seuilResistanceW;\n alerteChauffeEau = JSON.stringify({\n active, since, fenetre: fenetreHc.nom, max_w: maxW, puissance_w: Math.round(observedPowerW),\n ...(active ? { message: `🚿 Heures creuses EDF : le chauffe-eau ne chauffe pas a pleine puissance (${Math.round(observedPowerW)} W). Si tu n'es pas en BOOST, passe-le pour avoir 65 °C a ${fenetreHc.douche}.` } : {}),\n });\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 alerteChauffeEau,\n coupeEnChauffe\n }\n }\n}];" }, "id": "994b3242-616d-46e7-83ed-d236a117d15c", "name": "ECS Final Arbitration & Protection", @@ -74,7 +74,7 @@ }, { "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 alerteChauffeEau = ''\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 // Alerte « passe en BOOST » (07/10/2026), lue par le concierge OpenClaw.\n helper_alerte_chauffe_eau: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.evcs_alerte_chauffe_eau',\n value: alerteChauffeEau\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 }];" + "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 alerteChauffeEau = '',\n coupeEnChauffe = null\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 // La reference n'avance que si l'echantillon a servi, ou s'il est perime\n // (trop vieux ou illisible) : sinon on laisse s'accumuler jusqu'a 9,5 min.\n helper_energy_prev_kwh: {\n execute: !!sampleIsUsable || !(Number.isFinite(sampleDurationMin) && sampleDurationMin < 9.5),\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 execute: !!sampleIsUsable || !(Number.isFinite(sampleDurationMin) && sampleDurationMin < 9.5),\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 // Coupure en pleine chauffe (07/10/2026) : autorise la reprise de 16 h a 20 h.\n helper_coupe_en_chauffe: {\n execute: !!coupeEnChauffe,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_coupe_en_chauffe',\n value: coupeEnChauffe || ''\n }\n },\n // Alerte « passe en BOOST » (07/10/2026), lue par le concierge OpenClaw.\n helper_alerte_chauffe_eau: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.evcs_alerte_chauffe_eau',\n value: alerteChauffeEau\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", @@ -167,7 +167,7 @@ "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 \"ecs_jour_kwh\": states(\"sensor.evcs_chauffe_eau_jour_kwh\"),\n \"off_jour_faible_prev\": states(\"input_boolean.victron_water_heater_off_jour_faible\"),\n \"alerte_prev\": states(\"input_text.evcs_alerte_chauffe_eau\"),\n \"hc_midday_start\": states(\"input_datetime.victron_evcs_hc_midday_start\"),\n \"hc_midday_stop\": states(\"input_datetime.victron_evcs_hc_midday_stop\"),\n \"hc_night_start\": states(\"input_datetime.victron_evcs_hc_night_start\"),\n \"hc_night_stop\": states(\"input_datetime.victron_evcs_hc_night_stop\"),\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" + "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 \"ecs_jour_kwh\": states(\"sensor.evcs_chauffe_eau_jour_kwh\"),\n \"off_jour_faible_prev\": states(\"input_boolean.victron_water_heater_off_jour_faible\"),\n \"alerte_prev\": states(\"input_text.evcs_alerte_chauffe_eau\"),\n \"coupe_en_chauffe\": states(\"input_text.victron_water_heater_coupe_en_chauffe\"),\n \"hc_midday_start\": states(\"input_datetime.victron_evcs_hc_midday_start\"),\n \"hc_midday_stop\": states(\"input_datetime.victron_evcs_hc_midday_stop\"),\n \"hc_night_start\": states(\"input_datetime.victron_evcs_hc_night_start\"),\n \"hc_night_stop\": states(\"input_datetime.victron_evcs_hc_night_stop\"),\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" } ] },