import {queryHourly, getCoords, queryCurrent} from './sensors.mjs'; import {getCelestialCurrent} from './celestial.mjs'; import {localDateStr} from './config.mjs'; import {frostRisk} from './openweather.mjs'; export let lastForecast = { ts: 0, slots: [], summary: null } export const forecastTTL = 5 * 60 * 60_000 function estimatePrecipitation(precipChance, clouds) { if (precipChance < 30) return 0; const intensity = (precipChance / 100) * clouds; if (intensity > 0.7) return Math.round(intensity * 15 * 10) / 10; if (intensity > 0.4) return Math.round(intensity * 6 * 10) / 10; return Math.round(intensity * 2 * 10) / 10; } function estimateUV(sunElevation, cloudFraction) { if (sunElevation <= 0) return 0; const clearSkyUV = Math.sin((sunElevation * Math.PI) / 180) * 12; return Math.round(clearSkyUV * (1 - cloudFraction * 0.75) * 10) / 10; } function linearTrend(values) { const n = values.length; if (n < 2) return 0; const xMean = (n - 1) / 2; const yMean = values.reduce((a, b) => a + b, 0) / n; const num = values.reduce((s, v, i) => s + (i - xMean) * (v - yMean), 0); const den = values.reduce((s, _, i) => s + (i - xMean) ** 2, 0); return den === 0 ? 0 : num / den; } function dewPoint(tempC, humidity) { const a = 17.27, b = 237.7; const gamma = (a * tempC) / (b + tempC) + Math.log(humidity / 100); return (b * gamma) / (a - gamma); } function heatIndex(tempC, humidity) { const T = tempC * 9 / 5 + 32; if (T < 80) return tempC; const HI = -42.379 + 2.04901523 * T + 10.14333127 * humidity - 0.22475541 * T * humidity - 0.00683783 * T * T - 0.05481717 * humidity * humidity + 0.00122874 * T * T * humidity + 0.00085282 * T * humidity * humidity - 0.00000199 * T * T * humidity * humidity; return (HI - 32) * 5 / 9; } function vaporPressureDeficit(tempC, humidity) { const svp = 0.6108 * Math.exp((17.27 * tempC) / (tempC + 237.3)); return Math.round(svp * (1 - humidity / 100) * 1000) / 1000; } function absoluteHumidity(tempC, humidity) { const svp = 0.6108 * Math.exp((17.27 * tempC) / (tempC + 237.3)); return Math.round((humidity / 100 * svp * 2165) / (tempC + 273.15) * 1000) / 1000; } function pressureRule(trend3h, currentHpa) { if (trend3h < -2.0) return { label: 'Storm likely', precipitation_chance: 0.85 }; if (trend3h < -0.8) return { label: 'Rain likely', precipitation_chance: 0.65 }; if (trend3h < -0.3) return { label: 'Cloudy', precipitation_chance: 0.35 }; if (trend3h > 1.5) return { label: 'Clearing', precipitation_chance: 0.05 }; if (trend3h > 0.3) return { label: 'Improving', precipitation_chance: 0.10 }; if (currentHpa < 1000) return { label: 'Unsettled', precipitation_chance: 0.30 }; if (currentHpa < 1013) return { label: 'Partly cloudy', precipitation_chance: 0.15 }; return { label: 'Fair', precipitation_chance: 0.05 }; } function solarTempDelta(sunElevation, cloudFraction) { if (sunElevation <= 0) return 0; return Math.sin((sunElevation * Math.PI) / 180) * (1 - cloudFraction) * 6; } function nocturnalCooling(cloudFraction) { return (1 - cloudFraction) * 1.5; } function forecastHumidity(absHumidity, forecastTempC) { const svp = 0.6108 * Math.exp((17.27 * forecastTempC) / (forecastTempC + 237.3)); const rh = (absHumidity * (forecastTempC + 273.15)) / (svp * 2165) * 100; return Math.min(100, Math.max(0, Math.round(rh * 10) / 10)); } function estimateClouds(humidity, pressureTrend) { let base = humidity / 100 * 0.8; if (pressureTrend < -0.3) base = Math.min(1, base + 0.2); if (pressureTrend > 0.3) base = Math.max(0, base - 0.15); return Math.round(base * 100) / 100; } function forecastWind(currentWind, pressureTrend) { const boost = pressureTrend < -1 ? 1.3 : pressureTrend < -0.5 ? 1.1 : 1.0; return Math.round(currentWind * boost * 10) / 10; } export async function get24HourForecast(currentSensors) { const now = new Date(); const sixAgo = new Date(now - 6 * 3600 * 1000); const coords = await getCoords(); const history = await queryHourly(sixAgo, now); const pressures = history.map(r => r.pressure_hpa).filter(Number.isFinite); const temps = history.map(r => r.temperature).filter(Number.isFinite); const humidities = history.map(r => r.humidity).filter(Number.isFinite); const winds = history.map(r => r.wind_speed).filter(Number.isFinite); const pressureTrend = linearTrend(pressures); const tempTrend = linearTrend(temps); const humidityTrend = linearTrend(humidities); const pressure3h = pressureTrend * 3; const seedTemp = currentSensors.temperature ?? temps.at(-1) ?? 20; const seedHumidity = currentSensors.humidity ?? humidities.at(-1) ?? 60; const seedPressure = currentSensors.pressure_hpa ?? pressures.at(-1) ?? 1013; const seedWind = currentSensors.wind_speed ?? winds.at(-1) ?? 0; const seedAbsHum = currentSensors.humidity_abs ?? absoluteHumidity(seedTemp, seedHumidity); const weather = pressureRule(pressure3h, seedPressure); let runningAccumulation = 0; const slots = Array.from({ length: 24 }, (_, i) => { const time = new Date(now.getTime() + (i + 1) * 3600 * 1000); time.setMinutes(0, 0, 0); const pressureDamping = Math.exp(-i * 0.08); const pressure = seedPressure + pressureTrend * (i + 1) * pressureDamping; const pressure_slp = Math.round((pressure + (coords.altitude ?? 0) / 8.5) * 100) / 100; const clouds = estimateClouds(seedHumidity + humidityTrend * i, pressureTrend); const bgTemp = seedTemp + tempTrend * (i + 1); const temperature = Math.round(bgTemp * 10) / 10; const humidity = forecastHumidity(seedAbsHum, temperature); const wind_speed = forecastWind(seedWind, pressureTrend); const precipitation_chance = Math.round(weather.precipitation_chance * (1 - i * 0.02) * 100); const raining = precipitation_chance > 60 ? 'True' : 'False'; const precipitation = estimatePrecipitation(precipitation_chance, clouds); runningAccumulation = Math.round((runningAccumulation + precipitation) * 100) / 100; const celestial = getCelestialCurrent(coords.latitude, coords.longitude, time); const uv_index = estimateUV(celestial.sun_elevation ?? 0, clouds); return { time, temperature, humidity, humidity_abs: absoluteHumidity(temperature, humidity), dew_point: Math.round(dewPoint(temperature, humidity) * 100) / 100, heat_index: Math.round(heatIndex(temperature, humidity) * 100) / 100, vapor_pressure_deficit: vaporPressureDeficit(temperature, humidity), pressure_hpa: Math.round(pressure * 100) / 100, pressure_slp, pressure_rate: Math.round(pressureTrend * 100) / 100, storm_trend: pressure3h < -2 ? 1 : pressure3h > 1.5 ? -1 : 0, wind_speed, wind_gusts: Math.round(wind_speed * 1.4 * 10) / 10, wind_direction: currentSensors.wind_direction ?? 0, clouds, raining, precipitation, precipitation_chance, accumulation: runningAccumulation, uv_index, uv_dose: 0, solar_wm2: 0, daily_light_integral: 0, lux: 0, visibility: 50, ...celestial, }; }); for (const slot of slots) { if (slot.sunrise && slot.sunset) { const t = slot.time instanceof Date ? slot.time.getTime() : new Date(slot.time).getTime(); const rise = slot.sunrise instanceof Date ? slot.sunrise.getTime() : new Date(slot.sunrise).getTime(); const set = slot.sunset instanceof Date ? slot.sunset.getTime() : new Date(slot.sunset).getTime(); slot.daytime = t >= rise && t < set; } else { slot.daytime = (slot.sun_elevation ?? 0) > 0; } const solar = solarTempDelta(slot.sun_elevation ?? 0, slot.clouds); const cool = slot.daytime ? 0 : nocturnalCooling(slot.clouds); slot.temperature = Math.round((slot.temperature + solar - cool) * 10) / 10; slot.humidity = forecastHumidity(seedAbsHum, slot.temperature); slot.dew_point = Math.round(dewPoint(slot.temperature, slot.humidity) * 100) / 100; slot.heat_index = Math.round(heatIndex(slot.temperature, slot.humidity) * 100) / 100; slot.vapor_pressure_deficit = vaporPressureDeficit(slot.temperature, slot.humidity); slot.humidity_abs = absoluteHumidity(slot.temperature, slot.humidity); slot.frost_risk = frostRisk(slot.temperature, slot.dew_point, slot.humidity) slot.solar_wm2 = slot.sun_elevation > 0 ? Math.round(Math.sin((slot.sun_elevation * Math.PI) / 180) * 1000 * (1 - slot.clouds * 0.75) * 100) / 100 : 0; slot.daily_light_integral = Math.round(slot.solar_wm2 * 3600 / 1_000_000 * 4.57 * 100) / 100; slot.lux = slot.solar_wm2 > 0 ? Math.round(slot.solar_wm2 * 120) : 0; slot.visibility = slot.clouds > 0.85 ? 10 : 50; Object.assign(slot, getWeatherCondition(slot)); } return slots; } function groupByLocalDay(slots) { const days = {} for (const slot of slots) { const key = localDateStr(new Date(slot.time)) if (!days[key]) days[key] = [] days[key].push(slot) } return days } function summarise(slots) { if (!slots.length) return null const daytime = slots.filter(s => s.daytime) const source = daytime.length ? daytime : slots const avg = (arr) => arr.length ? Math.round(arr.reduce((a, b) => a + b, 0) / arr.length * 100) / 100 : null const peak = (arr) => arr.length ? Math.max(...arr) : null const fin = (key) => source.map(s => s[key]).filter(Number.isFinite) const finAll = (key) => slots.map(s => s[key]).filter(Number.isFinite) const labelCount = {} for (const s of source) labelCount[s.label] = (labelCount[s.label] || 0) + 1 const label = Object.entries(labelCount).sort((a, b) => b[1] - a[1])[0][0] const dominant = source.find(s => s.label === label) return { time: slots[0].time, label: dominant.label, icon: dominant.icon, temperature: peak(fin('temperature')), temperature_max: peak(finAll('temperature')), temperature_min: Math.min(...finAll('temperature')), humidity: avg(fin('humidity')), humidity_abs: avg(fin('humidity_abs')), dew_point: avg(fin('dew_point')), heat_index: peak(fin('heat_index')), vapor_pressure_deficit: avg(fin('vapor_pressure_deficit')), pressure_hpa: avg(fin('pressure_hpa')), pressure_slp: avg(fin('pressure_slp')), pressure_rate: avg(fin('pressure_rate')), storm_trend: slots.at(-1)?.storm_trend ?? 0, wind_speed: peak(fin('wind_speed')), wind_gusts: peak(fin('wind_gusts')), wind_direction: avg(fin('wind_direction')), clouds: avg(fin('clouds')), raining: peak(fin('precipitation_chance')) > 60 ? 'True' : 'False', precipitation: avg(fin('precipitation')), precipitation_chance: Math.round(peak(fin('precipitation_chance'))), accumulation: finAll('precipitation').reduce((a, b) => a + b, 0), frost_risk: slots.find(s => s.frost_risk !== 'None')?.frost_risk ?? slots.at(-1)?.frost_risk ?? 'None', uv_index: peak(fin('uv_index')), uv_dose: finAll('uv_dose').reduce((a, b) => a + b, 0), solar_wm2: avg(fin('solar_wm2')), daily_light_integral: Math.round(finAll('daily_light_integral').reduce((a, b) => a + b, 0) * 100) / 100, lux: peak(fin('lux')), visibility: avg(fin('visibility')), sunrise: slots.find(s => s.sunrise)?.sunrise ?? null, sunset: slots.find(s => s.sunset)?.sunset ?? null, daylight: slots.find(s => s.daylight)?.daylight ?? null, moon_phase: dominant.moon_phase ?? null, moon_illumination: dominant.moon_illumination ?? null, moon_elevation: dominant.moon_elevation ?? null, moon_azimuth: dominant.moon_azimuth ?? null, moon_new: dominant.moon_new ?? null, moon_full: dominant.moon_full ?? null, moonrise: slots.find(s => s.moonrise)?.moonrise ?? null, moonset: slots.find(s => s.moonset)?.moonset ?? null, } } export async function getForecast() { if (Date.now() - lastForecast.ts < forecastTTL) return lastForecast; const sensors = await queryCurrent().catch(() => null) if (!sensors) return lastForecast const slots = await get24HourForecast(sensors).catch(() => null) if (!slots) return lastForecast const days = groupByLocalDay(slots) const todayKey = localDateStr(new Date()) const todaySlots = days[todayKey] ?? slots lastForecast = { ts: Date.now(), slots, summary: summarise(todaySlots) } return lastForecast } export function getWeatherCondition(data) { if(data.lightning_rate > 0 && data.raining === 'True') { return {label: 'Thunderstorm', icon: '11d'}; } if(data.raining === 'True') { if(data.precipitation > 7.6) return {label: 'Heavy Rain', icon: '10d'}; if(data.precipitation > 2.5) return {label: 'Moderate Rain', icon: '10d'}; return {label: 'Light Rain', icon: '09d'}; } if(data.visibility < 10) return {label: 'Mist', icon: '50d'}; const dayNight = data.daytime ? 'd' : 'n'; const c = data.clouds > 1 ? data.clouds / 100 : data.clouds; if(c > 0.85) return {label: 'Overcast Clouds', icon: `04${dayNight}`}; if(c > 0.50) return {label: 'Broken Clouds', icon: `04${dayNight}`}; if(c > 0.25) return {label: 'Scattered Clouds', icon: `03${dayNight}`}; if(c > 0.10) return {label: 'Few Clouds', icon: `02${dayNight}`}; return {label: 'Clear Sky', icon: `01${dayNight}`}; }