Use local time
This commit is contained in:
@@ -8,14 +8,39 @@ export function cfg() {
|
||||
config({ path: resolve(ROOT, '.env'), override: true })
|
||||
config({ path: resolve(ROOT, '.env.local'), override: true })
|
||||
return {
|
||||
PORT: process.env.PORT || 3000,
|
||||
ADSB_URL: process.env.ADSB_URL || '',
|
||||
INFLUX_URL: process.env.INFLUX_URL || 'http://localhost:8086',
|
||||
INFLUX_TOKEN: process.env.INFLUX_TOKEN || '',
|
||||
INFLUX_ORG: process.env.INFLUX_ORG || 'weather',
|
||||
INFLUX_BUCKET: process.env.INFLUX_BUCKET || 'station',
|
||||
LATITUDE: parseFloat(process.env.LATITUDE || '0'),
|
||||
LONGITUDE: parseFloat(process.env.LONGITUDE || '0'),
|
||||
ALTITUDE: parseFloat(process.env.ALTITUDE || '0'),
|
||||
PORT: process.env.PORT || 3000,
|
||||
ADSB_URL: process.env.ADSB_URL || '',
|
||||
INFLUX_URL: process.env.INFLUX_URL || 'http://localhost:8086',
|
||||
INFLUX_TOKEN: process.env.INFLUX_TOKEN || '',
|
||||
INFLUX_ORG: process.env.INFLUX_ORG || 'weather',
|
||||
INFLUX_BUCKET: process.env.INFLUX_BUCKET || 'station',
|
||||
LATITUDE: parseFloat(process.env.LATITUDE || '0'),
|
||||
LONGITUDE: parseFloat(process.env.LONGITUDE || '0'),
|
||||
ALTITUDE: parseFloat(process.env.ALTITUDE || '0'),
|
||||
TZ: process.env.TZ || 'UTC',
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the UTC offset in minutes for the configured TZ at a given date
|
||||
export function tzOffsetMinutes(date = new Date()) {
|
||||
const tz = cfg().TZ
|
||||
// Format the date in the target timezone and compare to UTC
|
||||
const local = new Date(date.toLocaleString('en-US', { timeZone: tz }))
|
||||
const utc = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' }))
|
||||
return Math.round((local - utc) / 60_000)
|
||||
}
|
||||
|
||||
// Shift a UTC date to local-time-as-if-UTC (for day bucketing)
|
||||
export function toLocalMidnight(date = new Date()) {
|
||||
const offset = tzOffsetMinutes(date)
|
||||
const local = new Date(date.getTime() + offset * 60_000)
|
||||
local.setUTCHours(0, 0, 0, 0)
|
||||
return local
|
||||
}
|
||||
|
||||
// Get YYYY-MM-DD string in local timezone
|
||||
export function localDateStr(date = new Date()) {
|
||||
const offset = tzOffsetMinutes(date)
|
||||
const shifted = new Date(date.getTime() + offset * 60_000)
|
||||
return shifted.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// forecast.mjs
|
||||
import {queryHourly, getCoords, queryCurrent} from './influx.mjs';
|
||||
import {getCelestialCurrent} from './celestial.mjs';
|
||||
import {getWeatherCondition} from './openweather.mjs';
|
||||
import {localDateStr} from './config.mjs';
|
||||
|
||||
export let lastForecast = { ts: 0, slots: [], summary: null }
|
||||
export const forecastTTL = 5 * 60 * 60_000
|
||||
|
||||
// ── Physics Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function linearTrend(values) {
|
||||
const n = values.length;
|
||||
if (n < 2) return 0;
|
||||
@@ -45,46 +43,32 @@ function absoluteHumidity(tempC, humidity) {
|
||||
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 (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
|
||||
return (1 - cloudFraction) * 1.5;
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
@@ -92,16 +76,11 @@ function estimateClouds(humidity, pressureTrend) {
|
||||
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);
|
||||
@@ -109,61 +88,49 @@ export async function get24HourForecast(currentSensors) {
|
||||
const coords = await getCoords();
|
||||
const history = await queryHourly(sixAgo, now);
|
||||
|
||||
// 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
|
||||
const pressureTrend = linearTrend(pressures);
|
||||
const tempTrend = linearTrend(temps);
|
||||
const humidityTrend = linearTrend(humidities);
|
||||
const pressure3h = pressureTrend * 3;
|
||||
|
||||
// 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 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 pressure = seedPressure + pressureTrend * (i + 1) * pressureDamping;
|
||||
const clouds = estimateClouds(seedHumidity + humidityTrend * i, pressureTrend);
|
||||
const bgTemp = seedTemp + tempTrend * (i + 1);
|
||||
const temperature = Math.round(bgTemp * 10) / 10;
|
||||
|
||||
const humidity = forecastHumidity(seedAbsHum, temperature);
|
||||
const wind_speed = forecastWind(seedWind, pressureTrend);
|
||||
const humidity = forecastHumidity(seedAbsHum, temperature);
|
||||
const wind_speed = forecastWind(seedWind, pressureTrend);
|
||||
|
||||
const celestial = getCelestialCurrent(coords.latitude, coords.longitude, time);
|
||||
let snapshot = {
|
||||
const snapshot = {
|
||||
time,
|
||||
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,
|
||||
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,
|
||||
pressure_hpa: Math.round(pressure * 100) / 100,
|
||||
wind_speed,
|
||||
clouds,
|
||||
precipitation_chance: Math.round(weather.precipChance * (1 - i * 0.02) * 100),
|
||||
precipitation_chance: Math.round(weather.precipChance * (1 - i * 0.02) * 100),
|
||||
...celestial
|
||||
};
|
||||
|
||||
@@ -172,10 +139,9 @@ export async function get24HourForecast(currentSensors) {
|
||||
return snapshot;
|
||||
});
|
||||
|
||||
// Second pass — apply solar heating now that we have sun_elevation per slot
|
||||
for (const slot of slots) {
|
||||
if (slot.sunrise && slot.sunset) {
|
||||
const t = slot.time instanceof Date ? slot.time.getTime() : new Date(slot.time).getTime();
|
||||
const t = slot.time instanceof Date ? slot.time.getTime() : new Date(slot.time).getTime();
|
||||
const rise = slot.sunrise instanceof Date ? slot.sunrise.getTime() : new Date(slot.sunrise).getTime();
|
||||
const set = slot.sunset instanceof Date ? slot.sunset.getTime() : new Date(slot.sunset).getTime();
|
||||
slot.daytime = t >= rise && t < set;
|
||||
@@ -183,62 +149,84 @@ export async function get24HourForecast(currentSensors) {
|
||||
slot.daytime = (slot.sun_elevation ?? 0) > 0;
|
||||
}
|
||||
|
||||
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;
|
||||
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);
|
||||
slot.humidity_abs = absoluteHumidity(slot.temperature, slot.humidity);
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
// Group slots into local-time day buckets, summarise each as daytime-only
|
||||
function groupByLocalDay(slots) {
|
||||
const days = {}
|
||||
for (const slot of slots) {
|
||||
const key = localDateStr(new Date(slot.time))
|
||||
if (!days[key]) days[key] = []
|
||||
days[key].push(slot)
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
function summarise(slots) {
|
||||
if (!slots.length) return null
|
||||
const temps = slots.map(s => s.temperature).filter(Number.isFinite)
|
||||
const precips = slots.map(s => s.precipitation_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
|
||||
// Use only daytime slots for representative label/stats, fallback to all if none
|
||||
const daytime = slots.filter(s => s.daytime)
|
||||
const source = daytime.length ? daytime : slots
|
||||
|
||||
const temps = source.map(s => s.temperature).filter(Number.isFinite)
|
||||
const precips = source.map(s => s.precipitation_chance).filter(Number.isFinite)
|
||||
const winds = source.map(s => s.wind_speed).filter(Number.isFinite)
|
||||
const gusts = source.map(s => s.wind_gusts).filter(Number.isFinite)
|
||||
const uvs = source.map(s => s.uv_index).filter(Number.isFinite)
|
||||
const solar = source.map(s => s.solar_wm2).filter(Number.isFinite)
|
||||
|
||||
// Min/max temp uses ALL slots for the full day range
|
||||
const allTemps = slots.map(s => s.temperature).filter(Number.isFinite)
|
||||
|
||||
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)
|
||||
for (const s of source) labelCount[s.label] = (labelCount[s.label] || 0) + 1
|
||||
const label = Object.entries(labelCount).sort((a, b) => b[1] - a[1])[0][0]
|
||||
const dominant = source.find(s => s.label === label)
|
||||
|
||||
return {
|
||||
time: slots[0].time,
|
||||
label: dominant.label,
|
||||
code: dominant.code,
|
||||
icon: dominant.icon,
|
||||
temperature: Math.max(...temps),
|
||||
temperature_max: Math.max(...temps),
|
||||
temperature_min: Math.min(...temps),
|
||||
precipitation_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,
|
||||
time: slots[0].time,
|
||||
label: dominant.label,
|
||||
code: dominant.code,
|
||||
icon: dominant.icon,
|
||||
temperature: Math.max(...temps),
|
||||
temperature_max: Math.max(...allTemps),
|
||||
temperature_min: Math.min(...allTemps),
|
||||
precipitation_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,
|
||||
humidity: Math.round(source.map(s => s.humidity).filter(Number.isFinite).reduce((a, b) => a + b, 0) / source.length * 10) / 10,
|
||||
pressure_hpa: Math.round(source.map(s => s.pressure_hpa).filter(Number.isFinite).reduce((a, b) => a + b, 0) / source.length * 10) / 10,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getForecast() {
|
||||
if(Date.now() - lastForecast.ts < forecastTTL) return lastForecast;
|
||||
if (Date.now() - lastForecast.ts < forecastTTL) return lastForecast;
|
||||
const sensors = await queryCurrent().catch(() => null)
|
||||
if(!sensors) return lastForecast
|
||||
if (!sensors) return lastForecast
|
||||
const slots = await get24HourForecast(sensors).catch(() => null)
|
||||
if(!slots) return lastForecast
|
||||
lastForecast = { ts: Date.now(), slots, summary: summarise(slots) }
|
||||
if (!slots) return lastForecast
|
||||
|
||||
// Summarise today's local-day slots only
|
||||
const days = groupByLocalDay(slots)
|
||||
const todayKey = localDateStr(new Date())
|
||||
const todaySlots = days[todayKey] ?? slots
|
||||
lastForecast = { ts: Date.now(), slots, summary: summarise(todaySlots) }
|
||||
return lastForecast
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// openmeteo.mjs
|
||||
import { createWriteStream, existsSync, mkdirSync } from 'fs'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { pipeline } from 'stream/promises'
|
||||
import { localDateStr } from './config.mjs'
|
||||
|
||||
const DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const ICON_DIR = resolve(DIR, 'public', 'icons')
|
||||
@@ -53,8 +53,6 @@ async function cachedFetch(key, url) {
|
||||
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)
|
||||
@@ -98,16 +96,6 @@ function cloudsLabel(fraction) {
|
||||
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 = [
|
||||
@@ -133,13 +121,17 @@ export async function dailyWeather(lat, lon, start, end) {
|
||||
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
|
||||
|
||||
// Open-Meteo returns local date strings — parse as local midnight using TZ
|
||||
// e.g. "2026-03-01" → local midnight, not UTC midnight
|
||||
const tzOffset = (new Date(new Date().toLocaleString('en-US', { timeZone: require('./config.mjs').cfg().TZ })) - new Date(new Date().toLocaleString('en-US', { timeZone: 'UTC' }))) / 60_000
|
||||
const localMidnight = new Date(new Date(`${time}T00:00:00Z`).getTime() - tzOffset * 60_000)
|
||||
|
||||
return {
|
||||
label: WMO[code] || 'Unknown',
|
||||
icon,
|
||||
code,
|
||||
time: new Date(time + 'T00:00:00Z'),
|
||||
time: localMidnight,
|
||||
temperature: temp,
|
||||
temperature_max: temp,
|
||||
temperature_min: data.daily.temperature_2m_min[i],
|
||||
@@ -156,9 +148,9 @@ export async function dailyWeather(lat, lon, start, end) {
|
||||
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],
|
||||
clouds: null,
|
||||
sunrise: data.daily.sunrise[i] ? new Date(data.daily.sunrise[i]) : null,
|
||||
sunset: data.daily.sunset[i] ? new Date(data.daily.sunset[i]) : null,
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -178,25 +170,34 @@ export async function hourlyWeather(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}`
|
||||
`&hourly=${HOURLY_FIELDS}&daily=sunrise,sunset&timezone=auto&start_date=${startStr}&end_date=${endStr}`
|
||||
|
||||
const data = await cachedFetch(`hourly_${lat}_${lon}_${startStr}_${endStr}`, url)
|
||||
if (!data?.hourly?.time) return []
|
||||
|
||||
// Build sunrise/sunset map keyed by local date string from Open-Meteo
|
||||
// Open-Meteo returns these as local time ISO strings e.g. "2026-03-01T06:45"
|
||||
const sunMap = {}
|
||||
if (data.daily?.time) {
|
||||
data.daily.time.forEach((date, i) => {
|
||||
sunMap[date] = { sunrise: data.daily.sunrise[i], sunset: data.daily.sunset[i] }
|
||||
sunMap[date] = {
|
||||
sunrise: data.daily.sunrise[i] ? new Date(data.daily.sunrise[i]) : null,
|
||||
sunset: data.daily.sunset[i] ? new Date(data.daily.sunset[i]) : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.all(data.hourly.time.map(async (time, i) => {
|
||||
const date = time.slice(0, 10)
|
||||
const sun = sunMap[date] ?? {}
|
||||
const t = new Date(time).getTime()
|
||||
const daytime = sun.sunrise && sun.sunset
|
||||
? t >= new Date(sun.sunrise).getTime() && t < new Date(sun.sunset).getTime()
|
||||
// Open-Meteo returns local time strings — treat as UTC-equivalent for Date parsing
|
||||
// then key into sunMap using the local date portion
|
||||
const slotDate = new Date(time) // parsed as local ISO (no Z suffix) — browser/node both treat naive as local
|
||||
const dateKey = localDateStr(slotDate)
|
||||
const sun = sunMap[dateKey] ?? {}
|
||||
const t = slotDate.getTime()
|
||||
const daytime = sun.sunrise && sun.sunset
|
||||
? t >= sun.sunrise.getTime() && t < sun.sunset.getTime()
|
||||
: false
|
||||
|
||||
const code = data.hourly.weathercode[i]
|
||||
const icon = await ensureIcon(code).catch(() => null)
|
||||
const temp = data.hourly.temperature_2m[i]
|
||||
@@ -205,7 +206,7 @@ export async function hourlyWeather(lat, lon, start, end) {
|
||||
const clouds = Math.round(data.hourly.cloudcover[i]) / 100
|
||||
|
||||
return {
|
||||
time: time,
|
||||
time: slotDate,
|
||||
daytime,
|
||||
label: WMO[code] || 'Unknown',
|
||||
icon,
|
||||
@@ -215,8 +216,7 @@ export async function hourlyWeather(lat, lon, start, end) {
|
||||
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),
|
||||
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],
|
||||
@@ -228,7 +228,7 @@ export async function hourlyWeather(lat, lon, start, end) {
|
||||
solar_wm2: data.hourly.shortwave_radiation[i],
|
||||
clouds_label: cloudsLabel(clouds),
|
||||
visibility: data.hourly.visibility[i] != null
|
||||
? Math.round(data.hourly.visibility[i] / 1000 * 10) / 10 // m → km
|
||||
? Math.round(data.hourly.visibility[i] / 1000 * 10) / 10
|
||||
: null,
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import express from 'express';
|
||||
import {resolve, dirname} from 'path';
|
||||
import {fileURLToPath} from 'url';
|
||||
import {cfg} from './config.mjs';
|
||||
import {cfg, localDateStr, tzOffsetMinutes} from './config.mjs';
|
||||
import {queryCurrent, queryHourly, queryDaily, getCoords} from './influx.mjs';
|
||||
import {getCelestialCurrent, getCelestialForecast} from './celestial.mjs';
|
||||
import {getSpaceWeather} from './space.mjs';
|
||||
@@ -13,6 +13,16 @@ import {getWeatherCondition} from './openweather.mjs';
|
||||
import {lastForecast, getForecast, forecastTTL} from './forecast.mjs';
|
||||
import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
|
||||
|
||||
// ── Uncaught error handlers ───────────────────────────────────────────────────
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('[unhandledRejection]', promise, '\nReason:', reason)
|
||||
})
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('[uncaughtException]', err)
|
||||
})
|
||||
|
||||
const app = express();
|
||||
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const CLIENT_DIST = resolve(DIR, '../public');
|
||||
@@ -44,14 +54,13 @@ app.get('/api/current', async (req, res) => {
|
||||
const space = getCelestialCurrent(coords.latitude, coords.longitude)
|
||||
const condition = getWeatherCondition(Object.assign(sensors, space))
|
||||
|
||||
// Merge forecast summary fields — sensor readings take priority
|
||||
const forecast = await getForecast();
|
||||
const merged = {
|
||||
...(forecast?.summary ? {
|
||||
precipitation_chance: forecast.summary.precipitation_chance,
|
||||
temperature_max: forecast.summary.temperature_max,
|
||||
temperature_min: forecast.summary.temperature_min,
|
||||
uv_index_max: forecast.summary.uv_index,
|
||||
precipitation_chance: forecast.summary.precipitation_chance,
|
||||
temperature_max: forecast.summary.temperature_max,
|
||||
temperature_min: forecast.summary.temperature_min,
|
||||
uv_index_max: forecast.summary.uv_index,
|
||||
} : {}),
|
||||
...condition,
|
||||
...sensors,
|
||||
@@ -73,7 +82,6 @@ app.get('/api/hourly', async (req, res) => {
|
||||
|
||||
const coords = await getCoords()
|
||||
|
||||
// Past → now: InfluxDB
|
||||
const historyEnd = new Date(Math.min(now, end))
|
||||
historyEnd.setMinutes(0, 0, 0)
|
||||
|
||||
@@ -82,12 +90,10 @@ app.get('/api/hourly', async (req, res) => {
|
||||
])
|
||||
const history = historyResult.status === 'fulfilled' ? historyResult.value : []
|
||||
|
||||
// Now → +24h: cached physics forecast
|
||||
const physics = end > now
|
||||
? lastForecast.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([hourlyWeather(coords.latitude, coords.longitude, plus24, end)])
|
||||
@@ -103,22 +109,32 @@ app.get('/api/hourly', async (req, res) => {
|
||||
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
|
||||
|
||||
// Use local midnight as "today" boundary
|
||||
const offset = tzOffsetMinutes(now)
|
||||
const localNow = new Date(now.getTime() + offset * 60_000)
|
||||
localNow.setUTCHours(0, 0, 0, 0)
|
||||
const todayLocal = new Date(localNow.getTime() - offset * 60_000) // back to UTC
|
||||
const nextLocal = new Date(todayLocal.getTime() + 24 * 3600_000)
|
||||
|
||||
const start = req.query.start ? new Date(req.query.start) : todayLocal
|
||||
const end = req.query.end ? new Date(req.query.end) : nextLocal
|
||||
|
||||
const coords = await getCoords()
|
||||
const todayStr = now.toISOString().slice(0, 10) // ✅ string for comparison
|
||||
const usePhysics = lastForecast.summary && start <= now && end > now // ✅ only when today is in window
|
||||
|
||||
const todayStr = localDateStr(now)
|
||||
const usePhysics = lastForecast.summary && start <= now && end > now
|
||||
|
||||
const [sensorResult] = await Promise.allSettled([start < now ? queryDaily(start, now) : Promise.resolve([])])
|
||||
const history = sensorResult.status === 'fulfilled' ? sensorResult.value : []
|
||||
|
||||
// ✅ meteo starts from whichever is later: next or start
|
||||
const meteoFrom = new Date(Math.max(next.getTime(), start.getTime()))
|
||||
const [meteoResult] = await Promise.allSettled([end > next ? dailyWeather(coords.latitude, coords.longitude, meteoFrom, end) : Promise.resolve([])])
|
||||
const meteoFrom = new Date(Math.max(nextLocal.getTime(), start.getTime()))
|
||||
const [meteoResult] = await Promise.allSettled([end > nextLocal ? dailyWeather(coords.latitude, coords.longitude, meteoFrom, end) : Promise.resolve([])])
|
||||
const meteo = meteoResult.status === 'fulfilled' ? meteoResult.value : []
|
||||
|
||||
const todaySlot = usePhysics ? [lastForecast.summary] : []
|
||||
const meteoClean = meteo.filter(r => String(r.time).slice(0, 10) !== todayStr) // ✅ safe string compare
|
||||
// Filter meteo using local date string so we never double-count today
|
||||
const meteoClean = meteo.filter(r => localDateStr(new Date(r.time)) !== todayStr)
|
||||
|
||||
const daily = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...todaySlot, ...meteoClean])
|
||||
res.json(filterArr(daily, fields))
|
||||
@@ -175,6 +191,7 @@ app.use((err, req, res, next) => {
|
||||
})
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const c = cfg();
|
||||
|
||||
setTimeout(getForecast, 1)
|
||||
|
||||
Reference in New Issue
Block a user