24h forecasting
This commit is contained in:
@@ -1,7 +1,10 @@
|
|||||||
// forecast.mjs
|
// forecast.mjs
|
||||||
import { queryHourly, getCoords } from './influx.mjs';
|
import {queryHourly, getCoords, queryCurrent} from './influx.mjs';
|
||||||
import { getCelestialForecast } from './celestial.mjs';
|
import { getCelestialForecast } from './celestial.mjs';
|
||||||
|
|
||||||
|
let cache = { ts: 0, slots: [], summary: null }
|
||||||
|
const TTL = 10 * 60 * 1000
|
||||||
|
|
||||||
// ── Physics Helpers ───────────────────────────────────────────────────────────
|
// ── Physics Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function linearTrend(values) {
|
function linearTrend(values) {
|
||||||
@@ -181,3 +184,52 @@ export async function get24HourForecast(currentSensors) {
|
|||||||
|
|
||||||
return enriched;
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {cfg} from './config.mjs';
|
|||||||
import {queryCurrent, queryHourly, queryDaily, getCoords} from './influx.mjs';
|
import {queryCurrent, queryHourly, queryDaily, getCoords} from './influx.mjs';
|
||||||
import {getCelestialCurrent, getCelestialForecast} from './celestial.mjs';
|
import {getCelestialCurrent, getCelestialForecast} from './celestial.mjs';
|
||||||
import {getSpaceWeather} from './space.mjs';
|
import {getSpaceWeather} from './space.mjs';
|
||||||
import {dailyForecast} from './openmeteo.mjs';
|
|
||||||
import {apiReference} from '@scalar/express-api-reference';
|
import {apiReference} from '@scalar/express-api-reference';
|
||||||
import {spec} from './spec.mjs';
|
import {spec} from './spec.mjs';
|
||||||
import {existsSync} from 'fs';
|
import {existsSync} from 'fs';
|
||||||
@@ -16,6 +15,9 @@ const app = express();
|
|||||||
const DIR = dirname(fileURLToPath(import.meta.url));
|
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||||
const CLIENT_DIST = resolve(DIR, 'public');
|
const CLIENT_DIST = resolve(DIR, 'public');
|
||||||
|
|
||||||
|
refreshForecast()
|
||||||
|
setInterval(refreshForecast, 10 * 60 * 1000)
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use('/icons', express.static(resolve(DIR, 'public', 'icons')));
|
app.use('/icons', express.static(resolve(DIR, 'public', 'icons')));
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
@@ -35,15 +37,110 @@ function filterArr(arr, fields) {
|
|||||||
return arr.map(row => filterFields(row, fields));
|
return arr.map(row => filterFields(row, fields));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sensor Data ───────────────────────────────────────────────────────────────
|
// ── Current ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/current', async (req, res) => {
|
app.get('/api/current', async (req, res) => {
|
||||||
const {fields} = req.query;
|
const { fields } = req.query
|
||||||
const [sensors, coords] = await Promise.all([queryCurrent(), getCoords()]);
|
const [sensors, coords] = await Promise.all([queryCurrent(), getCoords()])
|
||||||
const condition = getWeatherCondition(sensors);
|
const condition = getWeatherCondition(sensors)
|
||||||
const space = getCelestialCurrent(coords.latitude, coords.longitude);
|
const space = getCelestialCurrent(coords.latitude, coords.longitude)
|
||||||
res.json(filterFields({...condition, ...sensors, ...space}, fields));
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Position ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -60,74 +157,6 @@ app.get('/api/space', async (req, res) => {
|
|||||||
res.json(filterFields(await getSpaceWeather(), fields));
|
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 ────────────────────────────────────────────────────────────────
|
// ── ADSB Proxy ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/air-traffic', async (req, res) => res.json(await getAirTraffic()));
|
app.get('/api/air-traffic', async (req, res) => res.json(await getAirTraffic()));
|
||||||
|
|||||||
Reference in New Issue
Block a user