diff --git a/client/src/components/CurrentWeather.vue b/client/src/components/CurrentWeather.vue index baba99a..866ad4a 100644 --- a/client/src/components/CurrentWeather.vue +++ b/client/src/components/CurrentWeather.vue @@ -131,17 +131,17 @@ onMounted(async () => {
-
+
-
↑ +
- +
-
↓ +
- +
diff --git a/client/src/components/ForecastStrip.vue b/client/src/components/ForecastStrip.vue index df96c3d..3bc56a3 100644 --- a/client/src/components/ForecastStrip.vue +++ b/client/src/components/ForecastStrip.vue @@ -63,8 +63,8 @@ function dir(deg: number) {
{{ d.label }}
- {{ d.temp_max || '—' }} - {{ d.temp_min || '—' }} + ↑{{ d.temperature_max || '—' }} + ↓{{ d.temperature_min || '—' }}
🌡️ {{ d.humidity || 0 }}%
🍃 {{ ~~(d.wind_gusts) || 0 }} Kph ({{dir(d.wind_dir)}})
diff --git a/server/openmeteo.mjs b/server/openmeteo.mjs deleted file mode 100644 index 1d9ecdb..0000000 --- a/server/openmeteo.mjs +++ /dev/null @@ -1,117 +0,0 @@ -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 -} - -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(',') - -// start & end are Date objects -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}` - - console.log('[meteo] startStr :', startStr) - console.log('[meteo] endStr :', endStr) - console.log('[meteo] url :', url) - - 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) - return { - time, - label: WMO[code] || 'Unknown', - icon, - code, - humidity: data.daily.relative_humidity_2m_mean[i], - temperature: data.daily.temperature_2m_max[i], - temperature_max: data.daily.temperature_2m_max[i], - temperature_min: data.daily.temperature_2m_min[i], - 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: data.daily.uv_index_max[i], - solar_wm2: data.daily.shortwave_radiation_sum[i], - sunrise: data.daily.sunrise[i], - sunset: data.daily.sunset[i], - } - })) -} diff --git a/server/package.json b/server/package.json index 1f187cf..c75770d 100644 --- a/server/package.json +++ b/server/package.json @@ -4,7 +4,7 @@ "type": "module", "main": "index.mjs", "scripts": { - "start": "node server.mjs" + "start": "node src/server.mjs" }, "dependencies": { "@influxdata/influxdb-client": "^1.33.2", diff --git a/server/airtraffic.mjs b/server/src/airtraffic.mjs similarity index 100% rename from server/airtraffic.mjs rename to server/src/airtraffic.mjs diff --git a/server/celestial.mjs b/server/src/celestial.mjs similarity index 100% rename from server/celestial.mjs rename to server/src/celestial.mjs diff --git a/server/config.mjs b/server/src/config.mjs similarity index 100% rename from server/config.mjs rename to server/src/config.mjs diff --git a/server/src/forecast.mjs b/server/src/forecast.mjs new file mode 100644 index 0000000..c51cae8 --- /dev/null +++ b/server/src/forecast.mjs @@ -0,0 +1,183 @@ +// forecast.mjs +import { queryHourly, getCoords } from './influx.mjs'; +import { getCelestialForecast } from './celestial.mjs'; + +// ── Physics Helpers ─────────────────────────────────────────────────────────── + +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; +} + +// ── Pressure Tendency → WMO Weather Rule ───────────────────────────────────── + +function pressureRule(trend3h, currentHpa) { + if (trend3h < -2.0) return { label: 'Storm likely', code: 211, precipChance: 0.85 }; + if (trend3h < -0.8) return { label: 'Rain likely', code: 501, precipChance: 0.65 }; + if (trend3h < -0.3) return { label: 'Cloudy', code: 803, precipChance: 0.35 }; + if (trend3h > 1.5) return { label: 'Clearing', code: 801, precipChance: 0.05 }; + if (trend3h > 0.3) return { label: 'Improving', code: 800, precipChance: 0.10 }; + if (currentHpa < 1000) return { label: 'Unsettled', code: 802, precipChance: 0.30 }; + if (currentHpa < 1013) return { label: 'Partly cloudy', code: 802, precipChance: 0.15 }; + return { label: 'Fair', code: 800, precipChance: 0.05 }; +} + +// ── Solar Heating Curve ─────────────────────────────────────────────────────── +// Uses sun_elevation from celestial.mjs — max ~6°C gain at solar noon, clear sky + +function solarTempDelta(sunElevation, cloudFraction) { + if (sunElevation <= 0) return 0; + return Math.sin((sunElevation * Math.PI) / 180) * (1 - cloudFraction) * 6; +} + +// ── Nocturnal Cooling (Stefan–Boltzmann approximation) ──────────────────────── +// Clear nights lose more heat via longwave radiation + +function nocturnalCooling(cloudFraction) { + return (1 - cloudFraction) * 1.5; // °C/hr max clear-sky cooling +} + +// ── Humidity Forecast ───────────────────────────────────────────────────────── +// As temp rises, relative humidity drops (conserving absolute humidity) + +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)); +} + +// ── Cloud Cover Estimate ────────────────────────────────────────────────────── +// Derived from humidity + pressure tendency (Oktas-style heuristic) + +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; +} + +// ── Wind Forecast ───────────────────────────────────────────────────────────── +// Pressure gradient approximation — steeper falls = stronger winds + +function forecastWind(currentWind, pressureTrend) { + const boost = pressureTrend < -1 ? 1.3 : pressureTrend < -0.5 ? 1.1 : 1.0; + return Math.round(currentWind * boost * 10) / 10; +} + +// ── Main 24hr Forecast ──────────────────────────────────────────────────────── + +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.toISOString(), now.toISOString()); + + // Extract trend series from history + 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); // hPa/hr + const tempTrend = linearTrend(temps); // °C/hr + const humidityTrend = linearTrend(humidities); // %/hr + const pressure3h = pressureTrend * 3; // 3hr tendency for WMO rules + + // Seed from current sensors + 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); + + // Build 24 hourly slots + const slots = Array.from({ length: 24 }, (_, i) => { + const time = new Date(now.getTime() + (i + 1) * 3600 * 1000); + time.setMinutes(0, 0, 0); + + // Pressure decays toward mean over time (damped trend) + const pressureDamping = Math.exp(-i * 0.08); + const pressure = seedPressure + pressureTrend * (i + 1) * pressureDamping; + + // Cloud cover from humidity + pressure tendency + const clouds = estimateClouds(seedHumidity + humidityTrend * i, pressureTrend); + + // Temperature: solar heating + nocturnal cooling on top of background trend + // We use a placeholder sun_elevation here — getCelestialForecast will enrich it + // so we do a two-pass: first build slots, then apply celestial, then fix temp + const bgTemp = seedTemp + tempTrend * (i + 1); + const temperature = Math.round(bgTemp * 10) / 10; + + const humidity = forecastHumidity(seedAbsHum, temperature); + const wind_speed = forecastWind(seedWind, pressureTrend); + + return { + time: time.toISOString().slice(0, 16), // "YYYY-MM-DDTHH:MM" + 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, + wind_speed, + clouds, + precip_chance: Math.round(weather.precipChance * (1 - i * 0.02) * 100), // confidence decays + label: weather.label, + code: weather.code, + }; + }); + + // Pass through getCelestialForecast to enrich sun/moon data + const enriched = getCelestialForecast(coords.latitude, coords.longitude, slots); + + // Second pass — apply solar heating now that we have sun_elevation per slot + for (const slot of enriched) { + 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); + } + + return enriched; +} diff --git a/server/influx.mjs b/server/src/influx.mjs similarity index 100% rename from server/influx.mjs rename to server/src/influx.mjs diff --git a/server/src/openmeteo.mjs b/server/src/openmeteo.mjs new file mode 100644 index 0000000..64b6443 --- /dev/null +++ b/server/src/openmeteo.mjs @@ -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, + } + })) +} diff --git a/server/openweather.mjs b/server/src/openweather.mjs similarity index 100% rename from server/openweather.mjs rename to server/src/openweather.mjs diff --git a/server/server.mjs b/server/src/server.mjs similarity index 73% rename from server/server.mjs rename to server/src/server.mjs index 921504a..82e5670 100644 --- a/server/server.mjs +++ b/server/src/server.mjs @@ -5,7 +5,7 @@ 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 {getOpenMeteo} from './openmeteo.mjs'; +import {dailyForecast} from './openmeteo.mjs'; import {apiReference} from '@scalar/express-api-reference'; import {spec} from './spec.mjs'; import {existsSync} from 'fs'; @@ -63,22 +63,46 @@ app.get('/api/space', async (req, res) => { // ── Daily History/Forecast ──────────────────────────────────────────────────── app.get('/api/hourly', async (req, res) => { - const {fields} = req.query; - const start = req.query.start || new Date(new Date().setHours(0, 0, 0, 0)).toISOString(); - let now = new Date(); - now.setHours(new Date().getHours() - 1, 0, 0); - now = now.toISOString(); - const end = req.query.end || new Date().toISOString(); - const coords = await getCoords(); - const [sensor, meteo] = await Promise.allSettled([ - queryHourly(start, now), - getOpenMeteo(coords.latitude, coords.longitude, now, end), - ]); - const history = sensor.status === 'fulfilled' ? sensor.value : []; - const forecast = meteo.status === 'fulfilled' ? meteo.value.hourly : []; - const hourly = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...forecast]); - res.json(filterArr(hourly, fields)); -}); + 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 ─────────────────────────────────────────────────── @@ -95,7 +119,7 @@ app.get('/api/daily', async (req, res) => { const [sensor, meteo] = await Promise.allSettled([ start < now ? queryDaily(start.toISOString(), now.toISOString()) : Promise.resolve([]), - end > now ? getOpenMeteo(coords.latitude, coords.longitude, meteoStart, end) : Promise.resolve([]), + end > now ? dailyForecast(coords.latitude, coords.longitude, meteoStart, end) : Promise.resolve([]), ]) const history = sensor.status === 'fulfilled' ? sensor.value : [] diff --git a/server/space.mjs b/server/src/space.mjs similarity index 100% rename from server/space.mjs rename to server/src/space.mjs diff --git a/server/spec.mjs b/server/src/spec.mjs similarity index 100% rename from server/spec.mjs rename to server/src/spec.mjs