diff --git a/client/src/services/units.ts b/client/src/services/units.ts index 285cb06..7b4fcd8 100644 --- a/client/src/services/units.ts +++ b/client/src/services/units.ts @@ -10,7 +10,6 @@ export interface MetricMeta { export const METRICS: Record = { // Environment temperature: { label: 'Temperature', unit: '°C', icon: '🌡️', group: 'Environment', precision: 1, color: '#f97316' }, - env_temp_f: { label: 'Temperature', unit: '°F', icon: '🌡️', group: 'Environment', precision: 1, color: '#f97316' }, humidity: { label: 'Humidity', unit: '%', icon: '💧', group: 'Environment', precision: 1, color: '#38bdf8' }, dew_point: { label: 'Dew Point', unit: '°C', icon: '🌫️', group: 'Environment', precision: 1, color: '#818cf8' }, heat_index: { label: 'Feels Like', unit: '°C', icon: '🤔', group: 'Environment', precision: 1, color: '#fb923c' }, @@ -27,28 +26,28 @@ export const METRICS: Record = { uv_dose: { label: 'UV Dose Today', unit: ' mJ/cm²', icon: '📡', group: 'Light', precision: 2, color: '#f9a8d4' }, light_burn_time_min: { label: 'Burn Time', unit: ' min', icon: '🔥', group: 'Light', precision: 0, color: '#fb7185' }, clouds: { label: 'Cloud Cover', unit: '%', icon: '☁️', group: 'Light', precision: 0, color: '#94a3b8' }, - light_dli: { label: 'Daily Light Integral', unit: ' mol/m²', icon: '🌱', group: 'Light', precision: 3, color: '#86efac' }, + day_light_integral: { label: 'Daily Light Integral', unit: ' mol/m²', icon: '🌱', group: 'Light', precision: 3, color: '#86efac' }, visibility: { label: 'Visibility', unit: ' km', icon: '👁️', group: 'Light', precision: 1, color: '#7dd3fc' }, // Wind - wind_speed_kmh: { label: 'Wind Speed', unit: ' km/h', icon: '💨', group: 'Wind', precision: 1, color: '#93c5fd' }, - wind_gusts_kmh: { label: 'Wind Gusts', unit: ' km/h', icon: '🌬️', group: 'Wind', precision: 1, color: '#60a5fa' }, - wind_direction_deg: { label: 'Wind Direction', unit: '°', icon: '🧭', group: 'Wind', precision: 0, color: '#818cf8' }, + wind_speed: { label: 'Wind Speed', unit: ' km/h', icon: '💨', group: 'Wind', precision: 1, color: '#93c5fd' }, + wind_gusts: { label: 'Wind Gusts', unit: ' km/h', icon: '🌬️', group: 'Wind', precision: 1, color: '#60a5fa' }, + wind_direction: { label: 'Wind Direction', unit: '°', icon: '🧭', group: 'Wind', precision: 0, color: '#818cf8' }, // Seismic - seismic_magnitude: { label: 'Seismic Magnitude', unit: ' g', icon: '🌍', group: 'Seismic', precision: 4, color: '#f87171' }, - seismic_ax: { label: 'Accel X', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' }, - seismic_ay: { label: 'Accel Y', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' }, - seismic_az: { label: 'Accel Z', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' }, + magnitude: { label: 'Seismic Magnitude', unit: ' g', icon: '🌍', group: 'Seismic', precision: 4, color: '#f87171' }, + seismic_x: { label: 'Accel X', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' }, + seismic_y: { label: 'Accel Y', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' }, + seismic_z: { label: 'Accel Z', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' }, // Compass - compass_heading: { label: 'Compass Heading', unit: '°', icon: '🧭', group: 'Compass', precision: 1, color: '#c084fc' }, + heading: { label: 'Compass Heading', unit: '°', icon: '🧭', group: 'Compass', precision: 1, color: '#c084fc' }, // Ground ground_distance: { label: 'Ground Distance', unit: ' cm', icon: '📏', group: 'Ground', precision: 0, color: '#a3e635' }, accumulation: { label: 'Accumulation Depth', unit: ' cm', icon: '❄️', group: 'Ground', precision: 1, color: '#bfdbfe' }, lidar_strength: { label: 'LIDAR Strength', unit: '', icon: '📡', group: 'Ground', precision: 0, color: '#6ee7b7' }, // Lightning lightning_distance: { label: 'Lightning Distance', unit: ' km', icon: '⚡', group: 'Lightning', precision: 0, color: '#fde047' }, - lightning_strikes_per_hour: { label: 'Strike Rate', unit: '/hr', icon: '⚡', group: 'Lightning', precision: 1, color: '#facc15' }, + lightning_rate: { label: 'Strike Rate', unit: '/hr', icon: '⚡', group: 'Lightning', precision: 1, color: '#facc15' }, // Moon - moon_illumination_pct: { label: 'Moon Illumination', unit: '%', icon: '🌙', group: 'Celestial', precision: 0, color: '#e2e8f0' }, + moon_illumination: { label: 'Moon Illumination', unit: '%', icon: '🌙', group: 'Celestial', precision: 0, color: '#e2e8f0' }, // Sun sun_elevation: { label: 'Solar Elevation', unit: '°', icon: '☀️', group: 'Celestial', precision: 1, color: '#fef08a' }, // Space @@ -58,11 +57,6 @@ export const METRICS: Record = { // Marine wave_height: { label: 'Wave Height', unit: ' m', icon: '🌊', group: 'Marine', precision: 2, color: '#38bdf8' }, swell_wave_height: { label: 'Swell Height', unit: ' m', icon: '🌊', group: 'Marine', precision: 2, color: '#7dd3fc' }, - wave_period: { label: 'Wave Period', unit: ' s', icon: '⏱️', group: 'Marine', precision: 1, color: '#bae6fd' }, - // AQ - pm2_5: { label: 'PM2.5', unit: ' μg/m³', icon: '🏭', group: 'Air Quality', precision: 1, color: '#fca5a5' }, - pm10: { label: 'PM10', unit: ' μg/m³', icon: '🏭', group: 'Air Quality', precision: 1, color: '#fdba74' }, - ozone: { label: 'Ozone', unit: ' μg/m³', icon: '🌐', group: 'Air Quality', precision: 1, color: '#6ee7b7' }, // Forecast forecast_precipitation_mm: { label: 'Precipitation', unit: ' mm', icon: '🌧️', group: 'Forecast', precision: 1, color: '#38bdf8' }, forecast_precipitation_probability: { label: 'Rain Chance', unit: '%', icon: '🌂', group: 'Forecast', precision: 0, color: '#60a5fa' }, diff --git a/server/influx.mjs b/server/influx.mjs index 9659952..0578377 100644 --- a/server/influx.mjs +++ b/server/influx.mjs @@ -89,21 +89,21 @@ export async function queryDaily(start, end) { const [means, mins, maxs] = await Promise.all([ query(` from(bucket: "${c.INFLUX_BUCKET}") - |> range(start: ${start}, stop: ${end}) + |> range(start: ${start.toISOString()}, stop: ${end.toISOString()}) |> filter(fn: (r) => r._measurement == "${m}") |> aggregateWindow(every: 1d, fn: mean, createEmpty: false) |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value") `), query(` from(bucket: "${c.INFLUX_BUCKET}") - |> range(start: ${start}, stop: ${end}) + |> range(start: ${start.toISOString()}, stop: ${end.toISOString()}) |> filter(fn: (r) => r._measurement == "${m}") |> aggregateWindow(every: 1d, fn: min, createEmpty: false) |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value") `), query(` from(bucket: "${c.INFLUX_BUCKET}") - |> range(start: ${start}, stop: ${end}) + |> range(start: ${start.toISOString()}, stop: ${end.toISOString()}) |> filter(fn: (r) => r._measurement == "${m}") |> aggregateWindow(every: 1d, fn: max, createEmpty: false) |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value") diff --git a/server/openmeteo.mjs b/server/openmeteo.mjs index d31cb95..6aa7ada 100644 --- a/server/openmeteo.mjs +++ b/server/openmeteo.mjs @@ -10,81 +10,55 @@ const CACHE_MS = 15 * 60 * 1000 if (!existsSync(ICON_DIR)) mkdirSync(ICON_DIR, { recursive: true }) -// WMO weather code map const WMO = { - 0: { label: 'Clear Sky' }, - 1: { label: 'Mainly Clear' }, - 2: { label: 'Partly Cloudy' }, - 3: { label: 'Overcast' }, - 45: { label: 'Fog' }, - 48: { label: 'Icy Fog' }, - 51: { label: 'Light Drizzle' }, - 53: { label: 'Drizzle' }, - 55: { label: 'Heavy Drizzle' }, - 61: { label: 'Light Rain' }, - 63: { label: 'Rain' }, - 65: { label: 'Heavy Rain' }, - 71: { label: 'Light Snow' }, - 73: { label: 'Snow' }, - 75: { label: 'Heavy Snow' }, - 77: { label: 'Snow Grains' }, - 80: { label: 'Light Showers' }, - 81: { label: 'Showers' }, - 82: { label: 'Heavy Showers' }, - 85: { label: 'Snow Showers' }, - 86: { label: 'Heavy Snow Showers' }, - 95: { label: 'Thunderstorm' }, - 96: { label: 'Thunderstorm w/ Hail' }, - 99: { label: 'Thunderstorm w/ Heavy Hail' }, + 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', } -// OWM icon mapping by WMO code + is_day const OWM_ICONS = { - 0: { day: '01d', night: '01n' }, - 1: { day: '01d', night: '01n' }, - 2: { day: '02d', night: '02n' }, - 3: { day: '04d', night: '04n' }, - 45: { day: '50d', night: '50n' }, - 48: { day: '50d', night: '50n' }, - 51: { day: '09d', night: '09n' }, - 53: { day: '09d', night: '09n' }, - 55: { day: '09d', night: '09n' }, - 61: { day: '10d', night: '10n' }, - 63: { day: '10d', night: '10n' }, - 65: { day: '10d', night: '10n' }, - 71: { day: '13d', night: '13n' }, - 73: { day: '13d', night: '13n' }, - 75: { day: '13d', night: '13n' }, - 77: { day: '13d', night: '13n' }, - 80: { day: '09d', night: '09n' }, - 81: { day: '09d', night: '09n' }, - 82: { day: '09d', night: '09n' }, - 85: { day: '13d', night: '13n' }, - 86: { day: '13d', night: '13n' }, - 95: { day: '11d', night: '11n' }, - 96: { day: '11d', night: '11n' }, - 99: { day: '11d', night: '11n' }, + 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, isDay) { - const variant = isDay ? 'day' : 'night' - const owmCode = OWM_ICONS[code]?.[variant] || '01d' +async function ensureIcon(code) { + const owmCode = OWM_ICONS[code] || '01d' const filename = `${owmCode}.png` const filepath = resolve(ICON_DIR, filename) if (!existsSync(filepath)) { - const url = `https://openweathermap.org/img/wn/${owmCode}@2x.png` - const res = await fetch(url) + const res = await fetch(`https://openweathermap.org/img/wn/${owmCode}@2x.png`) await pipeline(res.body, createWriteStream(filepath)) } return `/icons/${filename}` } -export async function resolveWeather(code, isDay = 1) { - const label = WMO[code]?.label || 'Unknown' - const icon = await ensureIcon(code, isDay).catch(() => null) - return { forecast_weather_label: label, forecast_weather_icon: icon, forecast_weathercode: code } -} - async function cachedFetch(key, url) { const now = Date.now() if (CACHE[key] && now - CACHE[key].ts < CACHE_MS) return CACHE[key].data @@ -94,178 +68,46 @@ async function cachedFetch(key, url) { return data } -const HOURLY_WEATHER = [ - 'temperature_2m', 'relative_humidity_2m', 'precipitation', 'precipitation_probability', - 'weathercode', 'windspeed_10m', 'winddirection_10m', 'windgusts_10m', - 'pressure_msl', 'cloudcover', 'visibility', 'cape', 'uv_index', 'is_day', - 'et0_fao_evapotranspiration', 'dewpoint_2m', 'apparent_temperature', - 'snowfall', 'snow_depth', 'freezinglevel_height', +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', + 'uv_index_max', 'shortwave_radiation_sum', ].join(',') -const DAILY_WEATHER = [ - 'temperature_2m_max', 'temperature_2m_min', 'precipitation_sum', - 'precipitation_probability_max', 'windspeed_10m_max', 'windgusts_10m_max', - 'winddirection_10m_dominant', 'weathercode', 'sunrise', 'sunset', - 'uv_index_max', 'snowfall_sum', 'precipitation_hours', 'shortwave_radiation_sum', -].join(',') - -const HOURLY_AQ = [ - 'pm10', 'pm2_5', 'carbon_monoxide', 'nitrogen_dioxide', 'sulphur_dioxide', - 'ozone', 'aerosol_optical_depth', 'dust', 'uv_index', -].join(',') - -const HOURLY_MARINE = [ - 'wave_height', 'wave_direction', 'wave_period', - 'wind_wave_height', 'swell_wave_height', 'swell_wave_direction', 'swell_wave_period', -].join(',') - -function zipHourly(data, prefix = '') { - if (!data?.hourly) return [] - return data.hourly.time.map((time, i) => { - const row = { time } - for (const [k, v] of Object.entries(data.hourly)) { - if (k !== 'time') row[prefix + k] = v[i] - } - return row - }) -} - -function zipDaily(data, prefix = '') { - if (!data?.daily) return [] - return data.daily.time.map((time, i) => { - const row = { time } - for (const [k, v] of Object.entries(data.daily)) { - if (k !== 'time') row[prefix + k] = v[i] - } - return row - }) -} - -// Remap Open-Meteo field names to our prefixed convention -function remapWeatherFields(row) { - const map = { - temperature_2m: 'temperature', - apparent_temperature: 'heat_index', - dewpoint_2m: 'dew_point', - relative_humidity_2m: 'humidity', - pressure_msl: 'pressure_slp', - cloudcover: 'clouds', - uv_index: 'uv_index', - visibility: 'visibility', - windspeed_10m: 'wind_speed_kmh', - winddirection_10m: 'wind_direction_deg', - windgusts_10m: 'wind_gusts_kmh', - weathercode: 'forecast_weathercode', - precipitation: 'forecast_precipitation_mm', - precipitation_probability: 'forecast_precipitation_probability', - snowfall: 'forecast_snowfall_mm', - snow_depth: 'forecast_snow_depth_m', - cape: 'forecast_cape', - is_day: 'sun_is_day', - freezinglevel_height: 'forecast_freezing_level_m', - et0_fao_evapotranspiration: 'forecast_evapotranspiration_mm', - // daily - temperature_2m_max: 'env_temp_max_c', - temperature_2m_min: 'env_temp_min_c', - precipitation_sum: 'forecast_precipitation_mm', - precipitation_probability_max: 'forecast_precipitation_probability', - windspeed_10m_max: 'wind_speed_max_kmh', - windgusts_10m_max: 'wind_gusts_max_kmh', - winddirection_10m_dominant: 'wind_direction_deg', - uv_index_max: 'uv_index_max', - snowfall_sum: 'forecast_snowfall_mm', - shortwave_radiation_sum: 'solar_wm2_sum', - sunrise: 'sun_sunrise', - sunset: 'sun_sunset', - } - const out = {} - for (const [k, v] of Object.entries(row)) { - out[map[k] || k] = v - } - return out -} - +// start & end are Date objects export async function getOpenMeteo(lat, lon, start, end) { - const base = start && end - ? `&start_date=${start.slice(0,10)}&end_date=${end.slice(0,10)}` - : `&forecast_days=7` + const startStr = start.toISOString().slice(0, 10) + const endStr = end.toISOString().slice(0, 10) - const [weather, aq, marine] = await Promise.allSettled([ - cachedFetch(`weather_${lat}_${lon}_${start}_${end}`, - `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&hourly=${HOURLY_WEATHER}&daily=${DAILY_WEATHER}¤t_weather=true&timezone=auto${base}`), - cachedFetch(`aq_${lat}_${lon}_${start}_${end}`, - `https://air-quality-api.open-meteo.com/v1/air-quality?latitude=${lat}&longitude=${lon}&hourly=${HOURLY_AQ}&timezone=auto${base}`), - cachedFetch(`marine_${lat}_${lon}_${start}_${end}`, - `https://marine-api.open-meteo.com/v1/marine?latitude=${lat}&longitude=${lon}&hourly=${HOURLY_MARINE}&daily=wave_height_max,wave_direction_dominant,wave_period_max&timezone=auto${base}`), - ]) + 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 w = weather.status === 'fulfilled' ? weather.value : {} - const a = aq.status === 'fulfilled' ? aq.value : {} - const m = marine.status === 'fulfilled' ? marine.value : {} + const data = await cachedFetch(`daily_${lat}_${lon}_${startStr}_${endStr}`, url) - // Build hourly merged map - const hourlyMap = {} - for (const row of zipHourly(w)) { - const remapped = remapWeatherFields(row) - hourlyMap[row.time] = remapped - } - for (const row of zipHourly(a)) { - const { time, ...rest } = row - hourlyMap[time] = { ...hourlyMap[time], ...rest } - } - for (const row of zipHourly(m)) { - const { time, ...rest } = row - hourlyMap[time] = { ...hourlyMap[time], ...rest } - } + if (!data?.daily?.time) return [] - // Build daily merged map - const dailyMap = {} - for (const row of zipDaily(w)) { - const remapped = remapWeatherFields(row) - dailyMap[row.time] = remapped - } - for (const row of zipDaily(m)) { - const { time, ...rest } = row - dailyMap[time] = { ...dailyMap[time], ...rest } - } - - // Resolve current weather - let current = null - if (w.current_weather) { - const cw = w.current_weather - const wInfo = await resolveWeather(cw.weathercode, cw.is_day).catch(() => ({})) - current = { - wind_speed_kmh: cw.windspeed, - wind_direction_deg: cw.winddirection, - sun_is_day: cw.is_day, - ...wInfo, + return Promise.all(data.daily.time.map(async (time, i) => { + const code = data.daily.weathercode[i] + const icon = await ensureIcon(code).catch(() => null) + return { + time, + label: WMO[code] || 'Unknown', + icon, + forecast_weathercode: code, + temperature: data.daily.temperature_2m_max[i], + env_temp_max_c: data.daily.temperature_2m_max[i], + env_temp_min_c: data.daily.temperature_2m_min[i], + precipitation: data.daily.precipitation_sum[i], + forecast_precipitation_probability: 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: data.daily.uv_index_max[i], + solar_wm2: data.daily.shortwave_radiation_sum[i], + sunrise: data.daily.sunrise[i], + sunset: data.daily.sunset[i], } - } - - // Resolve weather labels + icons for all hourly/daily rows - const hourlyArr = await Promise.all( - Object.values(hourlyMap) - .sort((a, b) => a.time.localeCompare(b.time)) - .map(async row => { - if (row.forecast_weathercode != null) { - const wInfo = await resolveWeather(row.forecast_weathercode, row.sun_is_day ?? 1).catch(() => ({})) - return { ...row, ...wInfo } - } - return row - }) - ) - - const dailyArr = await Promise.all( - Object.values(dailyMap) - .sort((a, b) => a.time.localeCompare(b.time)) - .map(async row => { - if (row.forecast_weathercode != null) { - const wInfo = await resolveWeather(row.forecast_weathercode, 1).catch(() => ({})) - return { ...row, ...wInfo } - } - return row - }) - ) - - return { current, hourly: hourlyArr, daily: dailyArr } + })) } diff --git a/server/server.mjs b/server/server.mjs index 0f5a6d3..921504a 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -83,23 +83,26 @@ app.get('/api/hourly', async (req, res) => { // ── Hourly History/Forecast ─────────────────────────────────────────────────── app.get('/api/daily', async (req, res) => { - const {fields} = req.query; - const start = new Date(req.query.start) || new Date(new Date().setHours(0, 0, 0, 0)); - let now = new Date(); - now.setHours(0, 0, 0, 0); - let next = new Date(now); - next.setDate(now.getDate() + 1); - const end = new Date(req.query.end) || new Date(); - const coords = await getCoords(); + const { fields } = req.query + const now = new Date() + now.setHours(0, 0, 0, 0) + const next = new Date(now) + next.setDate(now.getDate() + 1) + const start = req.query.start ? new Date(req.query.start) : now + const end = req.query.end ? new Date(req.query.end) : next + const meteoStart = start >= next ? start : next + const coords = await getCoords() + const [sensor, meteo] = await Promise.allSettled([ - now > start ? queryDaily(start.toISOString(), now.toISOString()) : Promise.resolve([]), - getOpenMeteo(coords.latitude, coords.longitude, (start.getTime() > next.getTime() ? start : next).toISOString(), end.toISOString()), - ]); - const history = sensor.status === 'fulfilled' ? sensor.value : []; - const forecast = meteo.status === 'fulfilled' ? meteo.value.daily : []; - const daily = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...forecast]); - res.json(filterArr(daily, fields)); -}); + start < now ? queryDaily(start.toISOString(), now.toISOString()) : Promise.resolve([]), + end > now ? getOpenMeteo(coords.latitude, coords.longitude, meteoStart, end) : Promise.resolve([]), + ]) + + const history = sensor.status === 'fulfilled' ? sensor.value : [] + const forecast = meteo.status === 'fulfilled' ? meteo.value : [] + const daily = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...forecast]) + res.json(filterArr(daily, fields)) +}) // ── ADSB Proxy ────────────────────────────────────────────────────────────────