Files
weather-station/server/src/forecast.mjs
2026-06-24 15:37:43 -04:00

247 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// forecast.mjs
import {queryHourly, getCoords, queryCurrent} from './influx.mjs';
import { getCelestialForecast } from './celestial.mjs';
import {getWeatherCondition} from './openweather.mjs';
export let lastForecast = { ts: 0, slots: [], summary: null }
const TTL = 10 * 60 * 1000
// ── 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 (StefanBoltzmann 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);
const snapshot = {
time: time.toISOString().slice(0, 16), // "YYYY-MM-DDTHH:MM"
daytime: false,
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,
precipitation_chance: Math.round(weather.precipChance * (1 - i * 0.02) * 100), // confidence decays
};
return Object.assign(snapshot, getWeatherCondition(snapshot));
});
// 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) {
if (slot.sunrise && slot.sunset) {
const t = new Date(slot.time).getTime();
slot.daytime = t >= new Date(slot.sunrise).getTime() && t < new Date(slot.sunset).getTime();
} else {
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;
slot.vapor_pressure_deficit = vaporPressureDeficit(slot.temperature, slot.humidity);
slot.humidity_abs = absoluteHumidity(slot.temperature, slot.humidity);
}
return enriched;
}
function summarise(slots) {
if (!slots.length) return null
const temps = slots.map(s => s.temperature).filter(Number.isFinite)
const precips = slots.map(s => s.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
const labelCount = {}
for (const s of slots.filter(s => s.daytime)) labelCount[s.label] = (labelCount[s.label] || 0) + 1
const label = Object.entries(labelCount).sort((a, b) => b[1] - a[1])[0][0]
const dominant = slots.find(s => s.label === label)
return {
time: slots[0].time.slice(0, 10),
label: dominant.label,
code: dominant.code,
icon: `/icons/${dominant.icon}.png`,
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,
}
}
export async function refreshForecast() {
const sensors = await queryCurrent().catch(() => null)
if (!sensors) return lastForecast
const slots = await get24HourForecast(sensors).catch(() => null)
if (!slots) return lastForecast
lastForecast = { ts: Date.now(), slots, summary: summarise(slots) }
return lastForecast
}
export async function ensureForecast() {
if (Date.now() - lastForecast.ts > TTL) await refreshForecast()
return lastForecast
}