function num(v, fallback = NaN) { const n = Number.parseFloat(v); return Number.isFinite(n) ? n : fallback; } function low(v) { return (v ?? '').toString().trim().toLowerCase(); } function parseMaybeJson(v) { if (typeof v !== 'string') return v; try { return JSON.parse(v); } catch { return v; } } function extractSnapshot(input) { if (typeof input === 'string') return parseMaybeJson(input); if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data); if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body); if (input?.data && typeof input.data === 'object') return input.data; if (input?.body && typeof input.body === 'object') return input.body; return input ?? {}; } function clamp(v, min, max) { return Math.min(Math.max(v, min), max); } function to2(n) { return String(n).padStart(2, '0'); } function getParisNowParts() { const fmt = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); const parts = Object.fromEntries( fmt.formatToParts(new Date()) .filter(p => p.type !== 'literal') .map(p => [p.type, p.value]) ); const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour); const date = `${parts.year}-${parts.month}-${parts.day}`; const stamp = `${date} ${to2(hour)}:${parts.minute}:${parts.second}`; return { year: Number(parts.year), month: Number(parts.month), day: Number(parts.day), hour, minute: Number(parts.minute), second: Number(parts.second), date, stamp, minNow: hour * 60 + Number(parts.minute), }; } function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') { const dtf = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); const parts = Object.fromEntries( dtf.formatToParts(new Date(utcTs)) .filter(p => p.type !== 'literal') .map(p => [p.type, p.value]) ); const asIfUtc = Date.UTC( Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour) === 24 ? 0 : Number(parts.hour), Number(parts.minute), Number(parts.second) ); const d = new Date(utcTs); const actualUtc = Date.UTC( d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds() ); return asIfUtc - actualUtc; } function parseParisStampToMs(s) { const text = (s ?? '').toString().trim(); const m = text.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/); if (!m) return null; const y = Number(m[1]); const mo = Number(m[2]); const d = Number(m[3]); const h = Number(m[4]); const mi = Number(m[5]); const se = Number(m[6]); if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null; const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se); const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris'); let ts = guessUtc - off1; const off2 = getTzOffsetMs(ts, 'Europe/Paris'); if (off2 !== off1) ts = guessUtc - off2; return ts; } function cleanStateText(v) { const s = (v ?? '').toString().trim(); const x = s.toLowerCase(); return ['unknown', 'unavailable', 'none', 'null'].includes(x) ? '' : s; } const cfg = { mobileNotifyDomain: 'notify', mobileNotifyService: 'mobile_app_iphone_16_de_tt', batteryUsableCapacityKwh: 17.5, sampleWindowMinMin: 2, sampleWindowMinMax: 15, energyByDemandKwh: { low: { green: 0.9, auto: 1.4, boost: 2.0 }, normal: { green: 1.4, auto: 2.2, boost: 3.1 }, high: { green: 2.2, auto: 3.4, boost: 4.6 }, three_showers: { green: 3.0, auto: 4.4, boost: 5.8 } } }; const raw = $input.first()?.json ?? {}; const input = extractSnapshot(raw); const now = getParisNowParts(); const nowMs = Date.now(); const soc = clamp(num(input.soc_percent, 0), 0, 100); const batteryCurrentA = num(input.battery_current_a, 0); const pvPowerW = Math.max(0, num(input.pv_power_w, 0)); const pvPowerAvg10mW = Math.max(0, num(input.pv_power_10m_avg_w, pvPowerW)); const houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0)); const gridPowerW = num(input.grid_power_w, 0); const gridIsOn = low(input.grid_state || 'off') === 'on'; const acInAvailable = num(input.victron_acin1_available, 0) === 1; const gridAvailable = gridIsOn || acInAvailable; const todayRemainingKwh = Math.max(0, num(input.today_remaining_kwh, 0)); const tomorrowKwh = Math.max(0, num(input.tomorrow_kwh, 0)); const advisorEnabled = low(input.advisor_enabled || 'off') === 'on'; const notificationsEnabled = low(input.notifications_enabled || 'off') === 'on'; const hotWaterDemandRaw = low(input.hot_water_demand || 'normal'); const hotWaterDemand = ['low', 'normal', 'high', 'three_showers'].includes(hotWaterDemandRaw) ? hotWaterDemandRaw : 'normal'; const greenPowerW = Math.max(0, num(input.green_power_w, 620)); const autoPowerW = Math.max(0, num(input.auto_power_w, 1200)); const boostPowerW = Math.max(0, num(input.boost_power_w, 2000)); const strongForecastKwh = Math.max(0, num(input.strong_forecast_kwh, 18)); const mediumForecastKwh = Math.max(0, num(input.medium_forecast_kwh, 10)); const strongPvNowW = Math.max(0, num(input.strong_pv_now_w, 2500)); const mediumPvNowW = Math.max(0, num(input.medium_pv_now_w, 1500)); const boostMinSocStrongForecastPct = clamp(num(input.morning_boost_min_soc_strong_day, 35), 0, 100); const boostMinSocMediumForecastPct = clamp(num(input.boost_min_soc_medium_day, 50), 0, 100); const endOfDaySocGuard = clamp(num(input.end_of_day_soc_guard, 85), 0, 100); const endOfDaySocTarget = clamp(num(input.end_of_day_soc_target, 85), 0, 100); const baseLoadRemainingKwh = Math.max(0, num(input.base_load_remaining_kwh, 0)); const flexibleSafetyMarginKwh = Math.max(0, num(input.flexible_safety_margin_kwh, 0)); const acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 8000)); const lastSignature = cleanStateText(input.last_signature); // Jour faible (07/10/2026) : decision du bilan, session reseau HC engagee, // energie du chauffe-eau sur 24 h (service d'historisation), propriete de la coupure. const ecsNuit = low(input.ecs_nuit) === 'on'; const hcSessionCommitted = low(input.hc_session_committed) === 'on'; const ecs24hKwh = num(input.ecs_24h_kwh, NaN); const offJourFaiblePrev = low(input.off_jour_faible_prev) === 'on'; const forecastSource = cleanStateText(input.forecast_source) || 'forecast_solar'; return [{ json: { meta: { workflow: 'water_heater_advisor', architecture: 'ecs_6nodes_simple_early_boost', node: 'ecs_normalize_inputs', timestamp_paris: now.stamp }, cfg, now, nowMs, input, normalized: { soc, batteryCurrentA, pvPowerW, pvPowerAvg10mW, houseAcPowerW, gridPowerW, gridIsOn, acInAvailable, gridAvailable, todayRemainingKwh, tomorrowKwh, advisorEnabled, notificationsEnabled, hotWaterDemand, greenPowerW, autoPowerW, boostPowerW, strongForecastKwh, mediumForecastKwh, strongPvNowW, mediumPvNowW, boostMinSocStrongForecastPct, boostMinSocMediumForecastPct, endOfDaySocGuard, endOfDaySocTarget, baseLoadRemainingKwh, flexibleSafetyMarginKwh, acLoadsMaxSafeW, lastSignature, ecsNuit, hcSessionCommitted, ecs24hKwh, offJourFaiblePrev, forecastSource } } }];