Local forecasting

This commit is contained in:
2026-06-24 13:06:19 -04:00
parent 25c6bcce7b
commit d540ab5efb
14 changed files with 455 additions and 143 deletions

222
server/src/openmeteo.mjs Normal file
View File

@@ -0,0 +1,222 @@
// openmeteo.mjs
import { createWriteStream, existsSync, mkdirSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
import { pipeline } from 'stream/promises'
const DIR = dirname(fileURLToPath(import.meta.url))
const ICON_DIR = resolve(DIR, 'public', 'icons')
const CACHE = {}
const CACHE_MS = 15 * 60 * 1000
if (!existsSync(ICON_DIR)) mkdirSync(ICON_DIR, { recursive: true })
const WMO = {
0: 'Clear Sky', 1: 'Mainly Clear', 2: 'Partly Cloudy',
3: 'Overcast', 45: 'Fog', 48: 'Icy Fog',
51: 'Light Drizzle', 53: 'Drizzle', 55: 'Heavy Drizzle',
61: 'Light Rain', 63: 'Rain', 65: 'Heavy Rain',
71: 'Light Snow', 73: 'Snow', 75: 'Heavy Snow',
77: 'Snow Grains', 80: 'Light Showers', 81: 'Showers',
82: 'Heavy Showers', 85: 'Snow Showers', 86: 'Heavy Snow Showers',
95: 'Thunderstorm', 96: 'Thunderstorm w/ Hail', 99: 'Thunderstorm w/ Heavy Hail',
}
const OWM_ICONS = {
0: '01d', 1: '01d', 2: '02d', 3: '04d',
45: '50d', 48: '50d',
51: '09d', 53: '09d', 55: '09d',
61: '10d', 63: '10d', 65: '10d',
71: '13d', 73: '13d', 75: '13d', 77: '13d',
80: '09d', 81: '09d', 82: '09d',
85: '13d', 86: '13d',
95: '11d', 96: '11d', 99: '11d',
}
async function ensureIcon(code) {
const owmCode = OWM_ICONS[code] || '01d'
const filename = `${owmCode}.png`
const filepath = resolve(ICON_DIR, filename)
if (!existsSync(filepath)) {
const res = await fetch(`https://openweathermap.org/img/wn/${owmCode}@2x.png`)
await pipeline(res.body, createWriteStream(filepath))
}
return `/icons/${filename}`
}
async function cachedFetch(key, url) {
const now = Date.now()
if (CACHE[key] && now - CACHE[key].ts < CACHE_MS) return CACHE[key].data
const res = await fetch(url)
const data = await res.json()
CACHE[key] = { ts: now, data }
return data
}
// ── Physics helpers (mirror forecast.mjs — no import to keep this self-contained) ──
function dewPoint(tempC, humidity) {
const a = 17.27, b = 237.7
const gamma = (a * tempC) / (b + tempC) + Math.log(humidity / 100)
return Math.round((b * gamma) / (a - gamma) * 100) / 100
}
function heatIndex(tempC, humidity) {
const T = tempC * 9 / 5 + 32
if (T < 80) return Math.round(tempC * 100) / 100
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 Math.round((HI - 32) * 5 / 9 * 100) / 100
}
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 uvLabel(uv) {
if (uv < 3) return 'Low'
if (uv < 6) return 'Moderate'
if (uv < 8) return 'High'
if (uv < 11) return 'Very High'
return 'Extreme'
}
function cloudsLabel(fraction) {
if (fraction < 0.1) return 'Clear'
if (fraction < 0.3) return 'Mostly Clear'
if (fraction < 0.6) return 'Partly Cloudy'
if (fraction < 0.9) return 'Mostly Cloudy'
return 'Overcast'
}
function airQualityLabel(aqi) {
if (!aqi) return null
if (aqi <= 50) return 'Good'
if (aqi <= 100) return 'Moderate'
if (aqi <= 150) return 'Unhealthy for Sensitive'
if (aqi <= 200) return 'Unhealthy'
if (aqi <= 300) return 'Very Unhealthy'
return 'Hazardous'
}
// ── Daily ─────────────────────────────────────────────────────────────────────
const DAILY_FIELDS = [
'temperature_2m_max', 'temperature_2m_min',
'precipitation_sum', 'precipitation_probability_max',
'windspeed_10m_max', 'windgusts_10m_max', 'winddirection_10m_dominant',
'weathercode', 'sunrise', 'sunset', 'relative_humidity_2m_mean',
'uv_index_max', 'shortwave_radiation_sum',
].join(',')
export async function getOpenMeteo(lat, lon, start, end) {
const startStr = start.toISOString().slice(0, 10)
const endStr = end.toISOString().slice(0, 10)
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}` +
`&daily=${DAILY_FIELDS}&timezone=auto&start_date=${startStr}&end_date=${endStr}`
const data = await cachedFetch(`daily_${lat}_${lon}_${startStr}_${endStr}`, url)
if (!data?.daily?.time) return []
return Promise.all(data.daily.time.map(async (time, i) => {
const code = data.daily.weathercode[i]
const icon = await ensureIcon(code).catch(() => null)
const temp = data.daily.temperature_2m_max[i]
const humidity = data.daily.relative_humidity_2m_mean[i]
const uv = data.daily.uv_index_max[i]
const clouds = null // not available in daily
return {
time,
label: WMO[code] || 'Unknown',
icon,
code,
temperature: temp,
temperature_max: temp,
temperature_min: data.daily.temperature_2m_min[i],
humidity,
humidity_abs: absoluteHumidity(temp, humidity),
dew_point: dewPoint(temp, humidity),
heat_index: heatIndex(temp, humidity),
vapor_pressure_deficit: vaporPressureDeficit(temp, humidity),
precipitation: data.daily.precipitation_sum[i],
precipitation_chance: data.daily.precipitation_probability_max[i],
wind_speed: data.daily.windspeed_10m_max[i],
wind_gusts: data.daily.windgusts_10m_max[i],
wind_direction: data.daily.winddirection_10m_dominant[i],
uv_index: uv,
uv_index_label: uvLabel(uv),
solar_wm2: data.daily.shortwave_radiation_sum[i],
clouds,
sunrise: data.daily.sunrise[i],
sunset: data.daily.sunset[i],
}
}))
}
// ── Hourly ────────────────────────────────────────────────────────────────────
const HOURLY_FIELDS = [
'temperature_2m', 'relative_humidity_2m', 'dewpoint_2m',
'apparent_temperature', 'precipitation_probability', 'precipitation',
'weathercode', 'surface_pressure', 'cloudcover',
'windspeed_10m', 'windgusts_10m', 'winddirection_10m',
'uv_index', 'shortwave_radiation', 'vapour_pressure_deficit',
'visibility',
].join(',')
export async function getOpenMeteoHourly(lat, lon, start, end) {
const startStr = start instanceof Date ? start.toISOString().slice(0, 10) : start.slice(0, 10)
const endStr = end instanceof Date ? end.toISOString().slice(0, 10) : end.slice(0, 10)
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}` +
`&hourly=${HOURLY_FIELDS}&timezone=auto&start_date=${startStr}&end_date=${endStr}`
const data = await cachedFetch(`hourly_${lat}_${lon}_${startStr}_${endStr}`, url)
if (!data?.hourly?.time) return []
return Promise.all(data.hourly.time.map(async (time, i) => {
const code = data.hourly.weathercode[i]
const icon = await ensureIcon(code).catch(() => null)
const temp = data.hourly.temperature_2m[i]
const humidity = data.hourly.relative_humidity_2m[i]
const uv = data.hourly.uv_index[i]
const clouds = Math.round(data.hourly.cloudcover[i]) / 100
return {
time: time.slice(0, 16),
label: WMO[code] || 'Unknown',
icon,
code,
temperature: temp,
humidity,
humidity_abs: absoluteHumidity(temp, humidity),
dew_point: dewPoint(temp, humidity),
heat_index: heatIndex(temp, humidity),
vapor_pressure_deficit: data.hourly.vapour_pressure_deficit[i]
?? vaporPressureDeficit(temp, humidity),
pressure_hpa: data.hourly.surface_pressure[i],
precipitation: data.hourly.precipitation[i],
precipitation_chance: data.hourly.precipitation_probability[i],
wind_speed: data.hourly.windspeed_10m[i],
wind_gusts: data.hourly.windgusts_10m[i],
wind_direction: data.hourly.winddirection_10m[i],
uv_index: uv,
uv_index_label: uvLabel(uv),
solar_wm2: data.hourly.shortwave_radiation[i],
clouds,
clouds_label: cloudsLabel(clouds),
visibility: data.hourly.visibility[i] != null
? Math.round(data.hourly.visibility[i] / 1000 * 10) / 10 // m → km
: null,
}
}))
}