// 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; }