From 89f3f156ce36cb3c835ca82061c7901517b8b817 Mon Sep 17 00:00:00 2001 From: ztimson Date: Wed, 24 Jun 2026 13:13:39 -0400 Subject: [PATCH] 24h forecasting --- server/src/forecast.mjs | 54 +++++++++++- server/src/server.mjs | 181 +++++++++++++++++++++++----------------- 2 files changed, 158 insertions(+), 77 deletions(-) diff --git a/server/src/forecast.mjs b/server/src/forecast.mjs index c51cae8..214578a 100644 --- a/server/src/forecast.mjs +++ b/server/src/forecast.mjs @@ -1,7 +1,10 @@ // forecast.mjs -import { queryHourly, getCoords } from './influx.mjs'; +import {queryHourly, getCoords, queryCurrent} from './influx.mjs'; import { getCelestialForecast } from './celestial.mjs'; +let cache = { ts: 0, slots: [], summary: null } +const TTL = 10 * 60 * 1000 + // ── Physics Helpers ─────────────────────────────────────────────────────────── function linearTrend(values) { @@ -181,3 +184,52 @@ export async function get24HourForecast(currentSensors) { return enriched; } + +function summarise(slots) { + if (!slots.length) return null + const temps = slots.map(s => s.temperature).filter(Number.isFinite) + const precips = slots.map(s => s.precip_chance).filter(Number.isFinite) + const winds = slots.map(s => s.wind_speed).filter(Number.isFinite) + const gusts = slots.map(s => s.wind_gusts).filter(Number.isFinite) + const uvs = slots.map(s => s.uv_index).filter(Number.isFinite) + const solar = slots.map(s => s.solar_wm2).filter(Number.isFinite) + + // Most frequent label/code by occurrence + const labelCount = {} + for (const s of slots) labelCount[s.label] = (labelCount[s.label] || 0) + 1 + const label = Object.entries(labelCount).sort((a, b) => b[1] - a[1])[0][0] + const dominant = slots.find(s => s.label === label) + + return { + time: slots[0].time.slice(0, 10), + label, + code: dominant.code, + icon: dominant.icon ?? null, + temperature: Math.max(...temps), + temperature_max: Math.max(...temps), + temperature_min: Math.min(...temps), + precip_chance: Math.round(Math.max(...precips)), + wind_speed: Math.round(Math.max(...winds) * 10) / 10, + wind_gusts: gusts.length ? Math.round(Math.max(...gusts) * 10) / 10 : null, + uv_index: uvs.length ? Math.max(...uvs) : null, + uv_index_label: dominant.uv_index_label ?? null, + solar_wm2: solar.length ? Math.round(solar.reduce((a, b) => a + b, 0) / solar.length * 10) / 10 : null, + sunrise: slots.find(s => s.sunrise)?.sunrise ?? null, + sunset: slots.find(s => s.sunset)?.sunset ?? null, + // useful extras for /api/current + humidity: Math.round(slots.map(s => s.humidity).filter(Number.isFinite).reduce((a, b) => a + b, 0) / slots.length * 10) / 10, + pressure_hpa: Math.round(slots.map(s => s.pressure_hpa).filter(Number.isFinite).reduce((a, b) => a + b, 0) / slots.length * 10) / 10, + } +} + +export async function refreshForecast() { + const sensors = await queryCurrent() + const slots = await get24HourForecast(sensors) + cache = { ts: Date.now(), slots, summary: summarise(slots) } + return cache +} + +export async function ensureForecast() { + if (Date.now() - cache.ts > TTL) await refreshForecast() + return cache +} diff --git a/server/src/server.mjs b/server/src/server.mjs index 82e5670..160c72a 100644 --- a/server/src/server.mjs +++ b/server/src/server.mjs @@ -5,7 +5,6 @@ import {cfg} from './config.mjs'; import {queryCurrent, queryHourly, queryDaily, getCoords} from './influx.mjs'; import {getCelestialCurrent, getCelestialForecast} from './celestial.mjs'; import {getSpaceWeather} from './space.mjs'; -import {dailyForecast} from './openmeteo.mjs'; import {apiReference} from '@scalar/express-api-reference'; import {spec} from './spec.mjs'; import {existsSync} from 'fs'; @@ -16,6 +15,9 @@ const app = express(); const DIR = dirname(fileURLToPath(import.meta.url)); const CLIENT_DIST = resolve(DIR, 'public'); +refreshForecast() +setInterval(refreshForecast, 10 * 60 * 1000) + app.use(express.json()); app.use('/icons', express.static(resolve(DIR, 'public', 'icons'))); app.use((req, res, next) => { @@ -35,15 +37,110 @@ function filterArr(arr, fields) { return arr.map(row => filterFields(row, fields)); } -// ── Sensor Data ─────────────────────────────────────────────────────────────── +// ── Current ─────────────────────────────────────────────────────────────────── app.get('/api/current', async (req, res) => { - const {fields} = req.query; - const [sensors, coords] = await Promise.all([queryCurrent(), getCoords()]); - const condition = getWeatherCondition(sensors); - const space = getCelestialCurrent(coords.latitude, coords.longitude); - res.json(filterFields({...condition, ...sensors, ...space}, fields)); -}); + const { fields } = req.query + const [sensors, coords] = await Promise.all([queryCurrent(), getCoords()]) + const condition = getWeatherCondition(sensors) + const space = getCelestialCurrent(coords.latitude, coords.longitude) + const { summary } = await ensureForecast() + + // Merge forecast summary fields — sensor readings take priority + const merged = { + ...(summary ? { + precipitation_chance: summary.precip_chance, + temperature_max: summary.temperature_max, + temperature_min: summary.temperature_min, + uv_index_max: summary.uv_index, + } : {}), + ...condition, + ...sensors, + ...space, + } + + res.json(filterFields(merged, fields)) +}) + +// ── Hourly ──────────────────────────────────────────────────────────────────── + +app.get('/api/hourly', async (req, res) => { + const { fields } = req.query + const now = new Date() + const plus24 = new Date(now.getTime() + 24 * 3600 * 1000) + + const start = req.query.start ? new Date(req.query.start) : new Date(now.setHours(0, 0, 0, 0)) + const end = req.query.end ? new Date(req.query.end) : plus24 + + const coords = await getCoords() + const { slots } = await ensureForecast() + + // Past → now: InfluxDB + const historyEnd = new Date(Math.min(now, end)) + historyEnd.setMinutes(0, 0, 0) + + const [historyResult] = await Promise.allSettled([ + start < now ? queryHourly(start.toISOString(), historyEnd.toISOString()) : Promise.resolve([]) + ]) + const history = historyResult.status === 'fulfilled' ? historyResult.value : [] + + // Now → +24h: cached physics forecast + const physics = end > now + ? slots.filter(s => { const t = new Date(s.time); return t >= now && t <= new Date(Math.min(end, plus24)) }) + : [] + + // +24h → beyond: Open-Meteo + let meteo = [] + if (end > plus24) { + const [meteoResult] = await Promise.allSettled([getOpenMeteoHourly(coords.latitude, coords.longitude, plus24, end)]) + meteo = meteoResult.status === 'fulfilled' ? meteoResult.value : [] + } + + const hourly = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...physics, ...meteo]) + res.json(filterArr(hourly, fields)) +}) + +// ── Daily ───────────────────────────────────────────────────────────────────── + +app.get('/api/daily', async (req, res) => { + 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 coords = await getCoords() + + // Does the requested window overlap with today (the 24h forecast window)? + const todayStr = now.toISOString().slice(0, 10) + const { summary } = await ensureForecast() + const usePhysics = summary && start <= next && end >= now + + // Past influx history (days before today) + const [sensorResult] = await Promise.allSettled([ + start < now ? queryDaily(start.toISOString(), now.toISOString()) : Promise.resolve([]) + ]) + const history = sensorResult.status === 'fulfilled' ? sensorResult.value : [] + + // Future meteo (days after today) + const meteoStart = end > next ? next : null + const [meteoResult] = await Promise.allSettled([ + meteoStart && end > next ? getOpenMeteo(coords.latitude, coords.longitude, next, end) : Promise.resolve([]) + ]) + const meteo = meteoResult.status === 'fulfilled' ? meteoResult.value : [] + + // Splice in physics summary for today, skip any meteo row that duplicates it + const todaySlot = usePhysics ? [summary] : [] + const meteoClean = meteo.filter(r => r.time !== todayStr) + + const daily = getCelestialForecast( + coords.latitude, coords.longitude, + [...history, ...todaySlot, ...meteoClean] + ) + + res.json(filterArr(daily, fields)) +}) // ── Position ────────────────────────────────────────────────────────────────── @@ -60,74 +157,6 @@ app.get('/api/space', async (req, res) => { res.json(filterFields(await getSpaceWeather(), fields)); }); -// ── Daily History/Forecast ──────────────────────────────────────────────────── - -app.get('/api/hourly', async (req, res) => { - const { fields } = req.query - const now = new Date() - const plus24 = new Date(now.getTime() + 24 * 3600 * 1000) - const start = req.query.start ? new Date(req.query.start) : new Date(now.setHours(0,0,0,0)) - const end = req.query.end ? new Date(req.query.end) : plus24 - const coords = await getCoords() - const sensors = await queryCurrent() - - // ── Past → now: InfluxDB ────────────────────────────────────────────────── - const historyEnd = new Date(Math.min(now, end)) - historyEnd.setMinutes(0, 0, 0) - const [historyResult] = await Promise.allSettled([ - start < now ? queryHourly(start.toISOString(), historyEnd.toISOString()) : Promise.resolve([]) - ]) - const history = historyResult.status === 'fulfilled' ? historyResult.value : [] - // ── 24h Local Forecast ──────────────────────────────────────────────────── - let physics = [] - if (end > now) { - const forecastSlots = await get24HourForecast(sensors) - physics = forecastSlots.filter(s => { - const t = new Date(s.time) - return t >= now && t <= new Date(Math.min(end, plus24)) - }) - } - // ── +24h → Weather Service ──────────────────────────────────────────────── - let meteo = [] - if (end > plus24) { - const [meteoResult] = await Promise.allSettled([ - getOpenMeteoHourly(coords.latitude, coords.longitude, plus24, end) - ]) - meteo = meteoResult.status === 'fulfilled' ? meteoResult.value : [] - } - - const hourly = getCelestialForecast( - coords.latitude, - coords.longitude, - [...history, ...physics, ...meteo] - ) - res.json(filterArr(hourly, fields)) -}) - -// ── Hourly History/Forecast ─────────────────────────────────────────────────── - -app.get('/api/daily', async (req, res) => { - 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([ - start < now ? queryDaily(start.toISOString(), now.toISOString()) : Promise.resolve([]), - end > now ? dailyForecast(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 ──────────────────────────────────────────────────────────────── app.get('/api/air-traffic', async (req, res) => res.json(await getAirTraffic()));