Use local time

This commit is contained in:
2026-06-24 20:31:58 -04:00
parent d6e799e956
commit 60f76b06d4
13 changed files with 476 additions and 309 deletions

View File

@@ -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 (StefanBoltzmann 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
}