Local forecasting
This commit is contained in:
@@ -131,17 +131,17 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-r align-items-center gap-2">
|
<div class="flex-r align-items-center gap-2">
|
||||||
<div class="fs-6 m-0 p-0 flex-r align-items-center">
|
<div class="fs-6 m-0 p-0 flex-r align-items-center">
|
||||||
<div v-if="!data" class="d-inline-block pos-rel br-2 overflow-hidden" style="width: 50px; height: 1em"><Loading/></div>
|
<div v-if="!data" class="d-inline-block pos-rel br-2 overflow-hidden" style="width: 50px; height: 1.25em"><Loading/></div>
|
||||||
<template v-else>{{ Math.round(data.temperature) }}°C</template>
|
<template v-else>{{ Math.round(data.temperature) }}°C</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-c fg-muted">
|
<div class="flex-c fg-muted">
|
||||||
<div class="hi">↑
|
<div class="hi">
|
||||||
<div v-if="!data" class="d-inline-block pos-rel br-2 overflow-hidden" style="width: 25px; height: 1em"><Loading/></div>
|
<div v-if="!data" class="d-inline-block pos-rel br-2 overflow-hidden" style="width: 25px; height: 1em"><Loading/></div>
|
||||||
<template v-else>{{ 0 }}°</template>
|
<template v-else>↑ {{ 0 }}°</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="lo">↓
|
<div class="lo">
|
||||||
<div v-if="!data" class="d-inline-block pos-rel br-2 overflow-hidden" style="width: 25px; height: 1em"><Loading/></div>
|
<div v-if="!data" class="d-inline-block pos-rel br-2 overflow-hidden" style="width: 25px; height: 1em"><Loading/></div>
|
||||||
<template v-else>{{ 0 }}°</template>
|
<template v-else>↓ {{ 0 }}°</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ function dir(deg: number) {
|
|||||||
<img :src="BASE + d.icon" class="day-icon" :alt="(d.label?.toString() || '')" />
|
<img :src="BASE + d.icon" class="day-icon" :alt="(d.label?.toString() || '')" />
|
||||||
<div class="day-label">{{ d.label }}</div>
|
<div class="day-label">{{ d.label }}</div>
|
||||||
<div class="day-temps">
|
<div class="day-temps">
|
||||||
<span>{{ d.temp_max || '—' }}</span>
|
<span>↑{{ d.temperature_max || '—' }}</span>
|
||||||
<span class="day-low">{{ d.temp_min || '—' }}</span>
|
<span class="day-low">↓{{ d.temperature_min || '—' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="day-humid">🌡️ {{ d.humidity || 0 }}%</div>
|
<div class="day-humid">🌡️ {{ d.humidity || 0 }}%</div>
|
||||||
<div class="day-wind">🍃 {{ ~~(d.wind_gusts) || 0 }} Kph ({{dir(d.wind_dir)}})</div>
|
<div class="day-wind">🍃 {{ ~~(d.wind_gusts) || 0 }} Kph ({{dir(d.wind_dir)}})</div>
|
||||||
|
|||||||
@@ -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],
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "index.mjs",
|
"main": "index.mjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.mjs"
|
"start": "node src/server.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@influxdata/influxdb-client": "^1.33.2",
|
"@influxdata/influxdb-client": "^1.33.2",
|
||||||
|
|||||||
183
server/src/forecast.mjs
Normal file
183
server/src/forecast.mjs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
222
server/src/openmeteo.mjs
Normal file
222
server/src/openmeteo.mjs
Normal 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,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ 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 {getOpenMeteo} from './openmeteo.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';
|
||||||
@@ -63,22 +63,46 @@ app.get('/api/space', async (req, res) => {
|
|||||||
// ── Daily History/Forecast ────────────────────────────────────────────────────
|
// ── Daily History/Forecast ────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/hourly', async (req, res) => {
|
app.get('/api/hourly', async (req, res) => {
|
||||||
const {fields} = req.query;
|
const { fields } = req.query
|
||||||
const start = req.query.start || new Date(new Date().setHours(0, 0, 0, 0)).toISOString();
|
const now = new Date()
|
||||||
let now = new Date();
|
const plus24 = new Date(now.getTime() + 24 * 3600 * 1000)
|
||||||
now.setHours(new Date().getHours() - 1, 0, 0);
|
const start = req.query.start ? new Date(req.query.start) : new Date(now.setHours(0,0,0,0))
|
||||||
now = now.toISOString();
|
const end = req.query.end ? new Date(req.query.end) : plus24
|
||||||
const end = req.query.end || new Date().toISOString();
|
const coords = await getCoords()
|
||||||
const coords = await getCoords();
|
const sensors = await queryCurrent()
|
||||||
const [sensor, meteo] = await Promise.allSettled([
|
|
||||||
queryHourly(start, now),
|
// ── Past → now: InfluxDB ──────────────────────────────────────────────────
|
||||||
getOpenMeteo(coords.latitude, coords.longitude, now, end),
|
const historyEnd = new Date(Math.min(now, end))
|
||||||
]);
|
historyEnd.setMinutes(0, 0, 0)
|
||||||
const history = sensor.status === 'fulfilled' ? sensor.value : [];
|
const [historyResult] = await Promise.allSettled([
|
||||||
const forecast = meteo.status === 'fulfilled' ? meteo.value.hourly : [];
|
start < now ? queryHourly(start.toISOString(), historyEnd.toISOString()) : Promise.resolve([])
|
||||||
const hourly = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...forecast]);
|
])
|
||||||
res.json(filterArr(hourly, fields));
|
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 ───────────────────────────────────────────────────
|
// ── Hourly History/Forecast ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -95,7 +119,7 @@ app.get('/api/daily', async (req, res) => {
|
|||||||
|
|
||||||
const [sensor, meteo] = await Promise.allSettled([
|
const [sensor, meteo] = await Promise.allSettled([
|
||||||
start < now ? queryDaily(start.toISOString(), now.toISOString()) : Promise.resolve([]),
|
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 : []
|
const history = sensor.status === 'fulfilled' ? sensor.value : []
|
||||||
Reference in New Issue
Block a user