function num(v, fallback = NaN) { const n = Number.parseFloat(v); return Number.isFinite(n) ? n : fallback; } function low(v) { return (v ?? '').toString().trim().toLowerCase(); } function cleanStateText(v) { const s = (v ?? '').toString().trim(); const x = s.toLowerCase(); return ['unknown', 'unavailable', 'none', 'null'].includes(x) ? '' : s; } function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') { const dtf = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); const parts = Object.fromEntries( dtf.formatToParts(new Date(utcTs)) .filter(p => p.type !== 'literal') .map(p => [p.type, p.value]) ); const asIfUtc = Date.UTC( Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour) === 24 ? 0 : Number(parts.hour), Number(parts.minute), Number(parts.second) ); const d = new Date(utcTs); const actualUtc = Date.UTC( d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds() ); return asIfUtc - actualUtc; } function parseParisStampToMs(s) { const text = (s ?? '').toString().trim(); const m = text.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/); if (!m) return null; const y = Number(m[1]); const mo = Number(m[2]); const d = Number(m[3]); const h = Number(m[4]); const mi = Number(m[5]); const se = Number(m[6]); if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null; const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se); const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris'); let ts = guessUtc - off1; const off2 = getTzOffsetMs(ts, 'Europe/Paris'); if (off2 !== off1) ts = guessUtc - off2; return ts; } function cleanParisStamp(v) { const s = cleanStateText(v); return parseParisStampToMs(s) !== null ? s : ''; } function minutesSince(ts, nowMs) { if (ts === null || nowMs === null) return null; return Math.max(0, (nowMs - ts) / 60000); } const root = $input.first()?.json ?? {}; const cfg = root.cfg ?? {}; const now = root.now ?? {}; const nowMs = root.nowMs ?? null; const input = root.input ?? {}; const normalized = root.normalized ?? {}; const { greenPowerW, autoPowerW, boostPowerW } = normalized; const evcsPriorityActiveRaw = low(input.evcs_priority_active || 'off') === 'on'; const energyPriorityMode = cleanStateText(input.energy_priority_mode || 'normal').toLowerCase() || 'normal'; const energyPriorityReason = cleanStateText(input.energy_priority_reason); const evcsPriorityUntilAt = cleanParisStamp(input.evcs_priority_until); const evcsPriorityUntilMs = parseParisStampToMs(evcsPriorityUntilAt); const evcsPriorityActive = evcsPriorityActiveRaw && energyPriorityMode === 'evcs_priority' && ( evcsPriorityUntilMs === null || (nowMs !== null && evcsPriorityUntilMs > nowMs + 1000) ); const waterHeaterSwitchOn = low(input.water_heater_switch || 'off') === 'on'; const waterHeaterEnergyTotalKwh = Math.max(0, num(input.water_heater_energy_total_kwh, 0)); const energyPrevKwh = num(input.energy_total_prev_kwh, NaN); const energyPrevAt = cleanParisStamp(input.energy_prev_at); const energyPrevMs = parseParisStampToMs(energyPrevAt); const observedPowerPrevW = Math.max(0, num(input.observed_power_helper_w, 0)); const observedRegimePrev = cleanStateText(input.observed_regime_helper).toLowerCase(); const mismatchSinceAt = cleanParisStamp(input.mode_mismatch_since); const mismatchSinceMsPrev = parseParisStampToMs(mismatchSinceAt); const forcedOffByAdvisorPrev = low(input.forced_off_by_advisor || 'off') === 'on'; const forcedOffReasonPrev = cleanStateText(input.forced_off_reason); const forcedOffAtPrev = cleanParisStamp(input.forced_off_at); const forcedOffAtPrevMs = parseParisStampToMs(forcedOffAtPrev); const pendingUserActionPrev = cleanStateText(input.pending_user_action_prev); const restoreSurplusW = Math.max(0, num(input.restore_surplus_w, 2500)); const modeMismatchDelayMin = Math.max(1, num(input.mode_mismatch_delay_min, 15)); const sampleDurationMin = minutesSince(energyPrevMs, nowMs); const energyDeltaKwh = Number.isFinite(energyPrevKwh) ? Math.max(0, waterHeaterEnergyTotalKwh - energyPrevKwh) : NaN; const sampleIsUsable = Number.isFinite(sampleDurationMin) && sampleDurationMin >= (cfg.sampleWindowMinMin ?? 2) && sampleDurationMin <= (cfg.sampleWindowMinMax ?? 15) && Number.isFinite(energyDeltaKwh); let observedPowerW = 0; // Coupe = 0 W (07/10/2026) : le compteur Tuya remonte avec retard, et la // derniere chauffe apparaissait apres la coupure (« arret — 1595 W »). if (!waterHeaterSwitchOn) { observedPowerW = 0; } else if (sampleIsUsable) { observedPowerW = Math.max(0, (energyDeltaKwh * 60000) / sampleDurationMin); } else if (waterHeaterSwitchOn && observedPowerPrevW > 0) { observedPowerW = observedPowerPrevW; } const offThresholdW = Math.max(80, greenPowerW * 0.25); const hpUpperW = (greenPowerW + autoPowerW) / 2; const resistiveUpperW = (autoPowerW + boostPowerW) / 2; let observedRegime = 'unknown'; if (!waterHeaterSwitchOn) { observedRegime = 'off'; } else if (!sampleIsUsable && observedRegimePrev) { observedRegime = observedRegimePrev; } else if (observedPowerW < offThresholdW) { observedRegime = 'off'; } else if (observedPowerW < hpUpperW) { observedRegime = 'hp_low'; } else if (observedPowerW < resistiveUpperW) { observedRegime = 'resistive_mid'; } else { observedRegime = 'boost_high'; } return [{ json: { ...root, meta: { ...(root.meta ?? {}), node: 'ecs_evcs_and_water_heater_observation' }, observation: { evcsPriorityActiveRaw, energyPriorityMode, energyPriorityReason, evcsPriorityUntilAt, evcsPriorityUntilMs, evcsPriorityActive, waterHeaterSwitchOn, waterHeaterEnergyTotalKwh, energyPrevKwh, energyPrevAt, energyPrevMs, observedPowerPrevW, observedRegimePrev, mismatchSinceAt, mismatchSinceMsPrev, forcedOffByAdvisorPrev, forcedOffReasonPrev, forcedOffAtPrev, forcedOffAtPrevMs, pendingUserActionPrev, restoreSurplusW, modeMismatchDelayMin, sampleDurationMin, energyDeltaKwh, sampleIsUsable, observedPowerW, observedRegime, offThresholdW, hpUpperW, resistiveUpperW } } }];