Local forecasting
This commit is contained in:
37
server/src/airtraffic.mjs
Normal file
37
server/src/airtraffic.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import { cfg } from './config.mjs'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const SHAPES_PATH = resolve(DIR, 'public', 'aircraft.json')
|
||||
|
||||
const history = new Map() // icao -> [{lat, lon, alt, ts}]
|
||||
const MAX_HISTORY = 500
|
||||
|
||||
export async function getShapes() {
|
||||
return JSON.parse(await readFile(SHAPES_PATH, 'utf8'))
|
||||
}
|
||||
|
||||
export async function getAirTraffic() {
|
||||
const { ADSB_URL } = cfg()
|
||||
if (!ADSB_URL) return { data: [] }
|
||||
const r = await fetch(`${ADSB_URL}/data/aircraft.json`)
|
||||
const j = await r.json()
|
||||
const aircraft = j.aircraft || []
|
||||
|
||||
for (const a of aircraft) {
|
||||
if (!a.hex || !a.lat || !a.lon) continue
|
||||
const key = a.hex.toLowerCase()
|
||||
if (!history.has(key)) history.set(key, [])
|
||||
const trail = history.get(key)
|
||||
trail.push({ latitude: a.lat, longitude: a.lon, altitude: a.alt_baro || 0, ts: Date.now() })
|
||||
if (trail.length > MAX_HISTORY) trail.shift()
|
||||
}
|
||||
|
||||
return { data: aircraft }
|
||||
}
|
||||
|
||||
export async function getAirTrafficHistory(icao) {
|
||||
return { history: history.get(icao?.toLowerCase()) || [] }
|
||||
}
|
||||
237
server/src/celestial.mjs
Normal file
237
server/src/celestial.mjs
Normal file
@@ -0,0 +1,237 @@
|
||||
const RAD = Math.PI / 180;
|
||||
const DEG = 180 / Math.PI;
|
||||
|
||||
function jdn(date) {
|
||||
return (date instanceof Date ? date : new Date(date)) / 86400000 + 2440587.5;
|
||||
}
|
||||
|
||||
function jdnToDate(jd) {
|
||||
return new Date((jd - 2440587.5) * 86400000);
|
||||
}
|
||||
|
||||
function sunPosition(lat, lon, date = new Date()) {
|
||||
const d = jdn(date) - 2451545.0;
|
||||
const L = (280.46 + 0.9856474 * d) % 360;
|
||||
const g = (357.528 + 0.9856003 * d) % 360;
|
||||
const lam = L + 1.915 * Math.sin(g * RAD) + 0.02 * Math.sin(2 * g * RAD);
|
||||
const eps = 23.439 - 0.0000004 * d;
|
||||
const sinL = Math.sin(lam * RAD);
|
||||
const ra = Math.atan2(Math.cos(eps * RAD) * sinL, Math.cos(lam * RAD)) * DEG;
|
||||
const dec = Math.asin(Math.sin(eps * RAD) * sinL) * DEG;
|
||||
const UT = date.getUTCHours() + date.getUTCMinutes() / 60 + date.getUTCSeconds() / 3600;
|
||||
const GMST = (6.697375 + 0.0657098242 * d + UT) % 24;
|
||||
const LMST = (GMST + lon / 15) % 24;
|
||||
const ha = LMST * 15 - ra;
|
||||
const elev = Math.asin(
|
||||
Math.sin(lat * RAD) * Math.sin(dec * RAD) +
|
||||
Math.cos(lat * RAD) * Math.cos(dec * RAD) * Math.cos(ha * RAD)
|
||||
) * DEG;
|
||||
const az = Math.atan2(
|
||||
-Math.sin(ha * RAD),
|
||||
Math.tan(dec * RAD) * Math.cos(lat * RAD) - Math.sin(lat * RAD) * Math.cos(ha * RAD)
|
||||
) * DEG;
|
||||
return {
|
||||
sun_elevation: Math.round(elev * 10) / 10,
|
||||
sun_azimuth: Math.round(((az + 360) % 360) * 10) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
function sunriseSunset(lat, lon, date = new Date()) {
|
||||
const d = Math.floor(jdn(date)) - 2451545;
|
||||
const noon = 2451545 + 0.0009 + ((-lon) / 360) + Math.round(d - (-lon) / 360);
|
||||
const M = (357.5291 + 0.98560028 * (noon - 2451545)) % 360;
|
||||
const C = 1.9148 * Math.sin(M * RAD) + 0.02 * Math.sin(2 * M * RAD) + 0.0003 * Math.sin(3 * M * RAD);
|
||||
const lam = (M + C + 180 + 102.9372) % 360;
|
||||
const jnoon = noon + 0.0053 * Math.sin(M * RAD) - 0.0069 * Math.sin(2 * lam * RAD);
|
||||
const dec = Math.asin(Math.sin(23.4397 * RAD) * Math.sin(lam * RAD)) * DEG;
|
||||
const cosH = (Math.sin(-0.8333 * RAD) - Math.sin(lat * RAD) * Math.sin(dec * RAD)) / (Math.cos(lat * RAD) * Math.cos(dec * RAD));
|
||||
|
||||
if(Math.abs(cosH) > 1) {
|
||||
return {sunrise: null, sunset: null, daytime: cosH < -1};
|
||||
}
|
||||
|
||||
const H = Math.acos(cosH) * DEG;
|
||||
const rise = jdnToDate(jnoon - H / 360);
|
||||
const set = jdnToDate(jnoon + H / 360);
|
||||
const noon_d = jdnToDate(jnoon);
|
||||
|
||||
// Round to nearest second, drop milliseconds
|
||||
const fmt = d => new Date(Math.round(d.getTime() / 1000) * 1000).toISOString();
|
||||
|
||||
return {
|
||||
sunrise: fmt(rise),
|
||||
sunset: fmt(set),
|
||||
daylight: Math.round((set - rise) / 36000) / 100,
|
||||
daytime: cosH < -1
|
||||
};
|
||||
}
|
||||
|
||||
// Smooth sunspot number approximation from solar flux F10.7
|
||||
// SSN ≈ 1.61 * F10.7 - 63.7 (linear regression, valid for F10.7 > 70)
|
||||
export function ssnFromFlux(f107) {
|
||||
if(!f107) return null;
|
||||
return Math.max(0, Math.round(1.61 * f107 - 63.7));
|
||||
}
|
||||
|
||||
export function sunActivityLabel(f107) {
|
||||
if(!f107) return null;
|
||||
if(f107 < 80) return 'Very Low';
|
||||
if(f107 < 100) return 'Low';
|
||||
if(f107 < 150) return 'Moderate';
|
||||
if(f107 < 200) return 'High';
|
||||
return 'Very High';
|
||||
}
|
||||
|
||||
function moonPosition(lat, lon, date = new Date()) {
|
||||
const d = jdn(date) - 2451545;
|
||||
const L = (218.316 + 13.176396 * d) % 360;
|
||||
const M = (134.963 + 13.064993 * d) % 360;
|
||||
const F = (93.272 + 13.229350 * d) % 360;
|
||||
const lon_ = L + 6.289 * Math.sin(M * RAD);
|
||||
const b = 5.128 * Math.sin(F * RAD);
|
||||
const dec = Math.asin(
|
||||
Math.sin(b * RAD) * Math.cos(23.4397 * RAD) +
|
||||
Math.cos(b * RAD) * Math.sin(23.4397 * RAD) * Math.sin(lon_ * RAD)
|
||||
) * DEG;
|
||||
const ra = Math.atan2(
|
||||
Math.sin(lon_ * RAD) * Math.cos(23.4397 * RAD) - Math.tan(b * RAD) * Math.sin(23.4397 * RAD),
|
||||
Math.cos(lon_ * RAD)
|
||||
) * DEG;
|
||||
const UT = date.getUTCHours() + date.getUTCMinutes() / 60 + date.getUTCSeconds() / 3600;
|
||||
const GMST = (6.697375 + 0.0657098242 * d + UT) % 24;
|
||||
const LMST = (GMST + lon / 15) % 24;
|
||||
const ha = LMST * 15 - ra;
|
||||
const elev = Math.asin(
|
||||
Math.sin(lat * RAD) * Math.sin(dec * RAD) +
|
||||
Math.cos(lat * RAD) * Math.cos(dec * RAD) * Math.cos(ha * RAD)
|
||||
) * DEG;
|
||||
const az = Math.atan2(
|
||||
-Math.sin(ha * RAD),
|
||||
Math.tan(dec * RAD) * Math.cos(lat * RAD) - Math.sin(lat * RAD) * Math.cos(ha * RAD)
|
||||
) * DEG;
|
||||
return {
|
||||
moon_elevation: Math.round(elev * 10) / 10,
|
||||
moon_azimuth: Math.round(((az + 360) % 360) * 10) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
function sunMoonDistance(date = new Date()) {
|
||||
const d = jdn(date) - 2451545;
|
||||
// Sun ecliptic longitude
|
||||
const Ls = (280.46 + 0.9856474 * d) % 360;
|
||||
const gs = (357.528 + 0.9856003 * d) % 360;
|
||||
const sunLon = Ls + 1.915 * Math.sin(gs * RAD) + 0.02 * Math.sin(2 * gs * RAD);
|
||||
// Moon ecliptic longitude
|
||||
const Lm = (218.316 + 13.176396 * d) % 360;
|
||||
const Mm = (134.963 + 13.064993 * d) % 360;
|
||||
const Fm = (93.272 + 13.229350 * d) % 360;
|
||||
const moonLon = Lm + 6.289 * Math.sin(Mm * RAD);
|
||||
const moonLat = 5.128 * Math.sin(Fm * RAD);
|
||||
// Angular separation
|
||||
const dLon = (moonLon - sunLon) * RAD;
|
||||
const sep = Math.acos(
|
||||
Math.cos(moonLat * RAD) * Math.cos(dLon)
|
||||
) * DEG;
|
||||
return { sun_moon_separation: Math.round(sep * 10) / 10 };
|
||||
}
|
||||
|
||||
function moonPhase(date = new Date()) {
|
||||
const jd = jdn(date);
|
||||
const cycle = 29.53058867;
|
||||
const known = 2451550.1;
|
||||
const phase = ((jd - known) % cycle + cycle) % cycle;
|
||||
// More accurate illumination using proper phase angle
|
||||
const illum = Math.round((1 - Math.cos(phase / cycle * 2 * Math.PI)) / 2 * 1000) / 10;
|
||||
let name;
|
||||
if(phase < 1.85) name = 'New Moon';
|
||||
else if(phase < 7.38) name = 'Waxing Crescent';
|
||||
else if(phase < 9.22) name = 'First Quarter';
|
||||
else if(phase < 14.76) name = 'Waxing Gibbous';
|
||||
else if(phase < 16.61) name = 'Full Moon';
|
||||
else if(phase < 22.15) name = 'Waning Gibbous';
|
||||
else if(phase < 23.99) name = 'Last Quarter';
|
||||
else name = 'Waning Crescent';
|
||||
return { moon_phase: name, moon_illumination: illum };
|
||||
}
|
||||
|
||||
function nextMoonEvents(date = new Date()) {
|
||||
const cycle = 29.53058867;
|
||||
const jd = jdn(date);
|
||||
const known = 2451550.1;
|
||||
const phase = ((jd - known) % cycle + cycle) % cycle;
|
||||
const toNew = (cycle - phase) % cycle;
|
||||
const toFull = phase < 14.76 ? 14.76 - phase : cycle - phase + 14.76;
|
||||
return {
|
||||
moon_new: jdnToDate(jd + toNew).toISOString(),
|
||||
moon_full: jdnToDate(jd + toFull).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function moonriseMoonset(lat, lon, date = new Date()) {
|
||||
const base = new Date(date);
|
||||
base.setUTCHours(0, 0, 0, 0);
|
||||
let prev = null;
|
||||
let moonrise = null;
|
||||
let moonset = null;
|
||||
|
||||
// Scan 48h in 10min steps — covers edge cases where moon rises/sets next UTC day
|
||||
for(let m = 0; m <= 2880; m += 10) {
|
||||
const t = new Date(base.getTime() + m * 60000);
|
||||
const d = jdn(t) - 2451545;
|
||||
const L = (218.316 + 13.176396 * d) % 360;
|
||||
const M = (134.963 + 13.064993 * d) % 360;
|
||||
const F = (93.272 + 13.229350 * d) % 360;
|
||||
const lon_ = L + 6.289 * Math.sin(M * RAD);
|
||||
const b = 5.128 * Math.sin(F * RAD);
|
||||
const dec = Math.asin(
|
||||
Math.sin(b * RAD) * Math.cos(23.4397 * RAD) +
|
||||
Math.cos(b * RAD) * Math.sin(23.4397 * RAD) * Math.sin(lon_ * RAD)
|
||||
) * DEG;
|
||||
const GMST = (6.697375 + 0.0657098242 * d + (t.getUTCHours() + t.getUTCMinutes() / 60)) % 24;
|
||||
const LMST = (GMST + lon / 15) % 24;
|
||||
const ra = Math.atan2(
|
||||
Math.sin(lon_ * RAD) * Math.cos(23.4397 * RAD) - Math.tan(b * RAD) * Math.sin(23.4397 * RAD),
|
||||
Math.cos(lon_ * RAD)
|
||||
) * DEG;
|
||||
const ha = LMST * 15 - ra;
|
||||
const elev = Math.asin(
|
||||
Math.sin(lat * RAD) * Math.sin(dec * RAD) +
|
||||
Math.cos(lat * RAD) * Math.cos(dec * RAD) * Math.cos(ha * RAD)
|
||||
) * DEG;
|
||||
|
||||
if(prev !== null) {
|
||||
if(prev < 0 && elev >= 0 && !moonrise) moonrise = new Date(t.getTime() - 5 * 60000).toISOString();
|
||||
if(prev >= 0 && elev < 0 && !moonset) moonset = new Date(t.getTime() - 5 * 60000).toISOString();
|
||||
}
|
||||
prev = elev;
|
||||
if(moonrise && moonset) break;
|
||||
}
|
||||
|
||||
return {moonrise: moonrise, moonset: moonset};
|
||||
}
|
||||
|
||||
function nextSolsticeEquinox(date = new Date()) {
|
||||
const y = date.getFullYear();
|
||||
return {
|
||||
summer_solstice: new Date(Date.UTC(y, 5, 21)),
|
||||
winter_solstice: new Date(Date.UTC(y, 11, 21)),
|
||||
vernal_equinox: new Date(Date.UTC(y, 2, 20)),
|
||||
autumnal_equinox: new Date(Date.UTC(y + 1, 2, 20)),
|
||||
};
|
||||
}
|
||||
|
||||
export function getCelestialCurrent(lat, lon, date = new Date()) {
|
||||
return {
|
||||
...sunPosition(lat, lon, date),
|
||||
...sunriseSunset(lat, lon, date),
|
||||
...moonPhase(date),
|
||||
...moonPosition(lat, lon, date),
|
||||
...sunMoonDistance(date),
|
||||
...nextMoonEvents(date),
|
||||
...moonriseMoonset(lat, lon, date),
|
||||
};
|
||||
}
|
||||
|
||||
export function getCelestialForecast(lat, lon, hours) {
|
||||
return hours.map((data) => Object.assign(data, getCelestialCurrent(lat, lon, new Date(data.time))))
|
||||
}
|
||||
21
server/src/config.mjs
Normal file
21
server/src/config.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
import { config } from 'dotenv'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
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'),
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
150
server/src/influx.mjs
Normal file
150
server/src/influx.mjs
Normal file
@@ -0,0 +1,150 @@
|
||||
import {InfluxDB} from '@influxdata/influxdb-client';
|
||||
import {cfg} from './config.mjs';
|
||||
|
||||
const MEASUREMENTS = ['accumulation', 'environment', 'light', 'lightning', 'rain', 'seismic', 'wind'];
|
||||
|
||||
let client, currentUrl, currentToken;
|
||||
|
||||
function getClient() {
|
||||
const c = cfg();
|
||||
if(c.INFLUX_URL !== currentUrl || c.INFLUX_TOKEN !== currentToken) {
|
||||
client = new InfluxDB({url: c.INFLUX_URL, token: c.INFLUX_TOKEN});
|
||||
currentUrl = c.INFLUX_URL;
|
||||
currentToken = c.INFLUX_TOKEN;
|
||||
}
|
||||
return {client, c};
|
||||
}
|
||||
|
||||
async function query(flux) {
|
||||
const {client, c} = getClient();
|
||||
const api = client.getQueryApi(c.INFLUX_ORG);
|
||||
const rows = [];
|
||||
return new Promise((resolve, reject) => {
|
||||
api.queryRows(flux, {
|
||||
next(row, meta) { rows.push(meta.toObject(row)); },
|
||||
error(err) { reject(err); },
|
||||
complete() { resolve(rows); },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function truncateToHour(iso) {
|
||||
const d = new Date(iso);
|
||||
d.setMinutes(0, 0, 0);
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:00`;
|
||||
}
|
||||
|
||||
function truncateToDay(iso) {
|
||||
return new Date(iso).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function extractRow(row) {
|
||||
const out = {};
|
||||
for(const [k, v] of Object.entries(row)) {
|
||||
if(!k.startsWith('_') && k !== 'result' && k !== 'table') out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function queryCurrent() {
|
||||
const {c} = getClient();
|
||||
const flux = `
|
||||
from(bucket: "${c.INFLUX_BUCKET}")
|
||||
|> range(start: -1h)
|
||||
|> filter(fn: (r) => ${MEASUREMENTS.map(m => `r._measurement == "${m}"`).join(' or ')})
|
||||
|> last()
|
||||
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
||||
`;
|
||||
const rows = await query(flux);
|
||||
const result = {};
|
||||
for(const row of rows) {
|
||||
Object.assign(result, extractRow(row));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function queryHourly(start, end) {
|
||||
const {c} = getClient();
|
||||
const flux = `
|
||||
from(bucket: "${c.INFLUX_BUCKET}")
|
||||
|> range(start: ${start}, stop: ${end})
|
||||
|> filter(fn: (r) => ${MEASUREMENTS.map(m => `r._measurement == "${m}"`).join(' or ')})
|
||||
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|
||||
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
||||
`;
|
||||
const rows = await query(flux);
|
||||
return rows.map(row => ({
|
||||
time: truncateToHour(row._time),
|
||||
...extractRow(row),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function queryDaily(start, end) {
|
||||
const {c} = getClient();
|
||||
const measurements = ['accumulation', 'environment', 'light', 'lightning', 'seismic'];
|
||||
const results = {};
|
||||
|
||||
for(const m of measurements) {
|
||||
const [means, mins, maxs] = await Promise.all([
|
||||
query(`
|
||||
from(bucket: "${c.INFLUX_BUCKET}")
|
||||
|> range(start: ${start.toISOString()}, stop: ${end.toISOString()})
|
||||
|> filter(fn: (r) => r._measurement == "${m}")
|
||||
|> aggregateWindow(every: 1d, fn: mean, createEmpty: false)
|
||||
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
||||
`),
|
||||
query(`
|
||||
from(bucket: "${c.INFLUX_BUCKET}")
|
||||
|> range(start: ${start.toISOString()}, stop: ${end.toISOString()})
|
||||
|> filter(fn: (r) => r._measurement == "${m}")
|
||||
|> aggregateWindow(every: 1d, fn: min, createEmpty: false)
|
||||
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
||||
`),
|
||||
query(`
|
||||
from(bucket: "${c.INFLUX_BUCKET}")
|
||||
|> range(start: ${start.toISOString()}, stop: ${end.toISOString()})
|
||||
|> filter(fn: (r) => r._measurement == "${m}")
|
||||
|> aggregateWindow(every: 1d, fn: max, createEmpty: false)
|
||||
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
||||
`),
|
||||
]);
|
||||
|
||||
// Mean values are the base — flat field names, no suffix
|
||||
for(const row of means) {
|
||||
const time = truncateToDay(row._time);
|
||||
if(!results[time]) results[time] = {time};
|
||||
Object.assign(results[time], extractRow(row));
|
||||
}
|
||||
|
||||
// Only promote temp min/max to named fields, ignore the rest
|
||||
for(const row of mins) {
|
||||
const time = truncateToDay(row._time);
|
||||
if(!results[time]) results[time] = {time};
|
||||
if(row.temperature != null) results[time].env_temp_min_c = row.temperature;
|
||||
}
|
||||
for(const row of maxs) {
|
||||
const time = truncateToDay(row._time);
|
||||
if(!results[time]) results[time] = {time};
|
||||
if(row.temperature != null) results[time].env_temp_max_c = row.temperature;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(results).sort((a, b) => a.time.localeCompare(b.time));
|
||||
}
|
||||
|
||||
export async function getCoords() {
|
||||
const {c} = getClient();
|
||||
const flux = `
|
||||
from(bucket: "${c.INFLUX_BUCKET}")
|
||||
|> range(start: -24h)
|
||||
|> filter(fn: (r) => r._measurement == "gps")
|
||||
|> last()
|
||||
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
||||
`;
|
||||
const rows = await query(flux);
|
||||
if(rows.length && rows[0].latitude && rows[0].longitude) {
|
||||
return {latitude: rows[0].latitude, longitude: rows[0].longitude, altitude: rows[0].altitude || c.ALTITUDE};
|
||||
}
|
||||
return {latitude: c.LATITUDE, longitude: c.LONGITUDE, altitude: c.ALTITUDE};
|
||||
}
|
||||
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,
|
||||
}
|
||||
}))
|
||||
}
|
||||
78
server/src/openweather.mjs
Normal file
78
server/src/openweather.mjs
Normal file
@@ -0,0 +1,78 @@
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const DIR = resolve(dirname(fileURLToPath(import.meta.url)), 'public', 'icons')
|
||||
|
||||
export const WMO = {
|
||||
0: { label: 'Clear Sky', icon: '01d' },
|
||||
1: { label: 'Mainly Clear', icon: '01d' },
|
||||
2: { label: 'Partly Cloudy', icon: '02d' },
|
||||
3: { label: 'Overcast', icon: '04d' },
|
||||
45: { label: 'Fog', icon: '50d' },
|
||||
48: { label: 'Icy Fog', icon: '50d' },
|
||||
51: { label: 'Light Drizzle', icon: '09d' },
|
||||
53: { label: 'Drizzle', icon: '09d' },
|
||||
55: { label: 'Heavy Drizzle', icon: '09d' },
|
||||
61: { label: 'Light Rain', icon: '10d' },
|
||||
63: { label: 'Rain', icon: '10d' },
|
||||
65: { label: 'Heavy Rain', icon: '10d' },
|
||||
71: { label: 'Light Snow', icon: '13d' },
|
||||
73: { label: 'Snow', icon: '13d' },
|
||||
75: { label: 'Heavy Snow', icon: '13d' },
|
||||
77: { label: 'Snow Grains', icon: '13d' },
|
||||
80: { label: 'Light Showers', icon: '09d' },
|
||||
81: { label: 'Showers', icon: '09d' },
|
||||
82: { label: 'Heavy Showers', icon: '09d' },
|
||||
85: { label: 'Snow Showers', icon: '13d' },
|
||||
86: { label: 'Heavy Snow Showers', icon: '13d' },
|
||||
95: { label: 'Thunderstorm', icon: '11d' },
|
||||
96: { label: 'Thunderstorm w/ Hail', icon: '11d' },
|
||||
99: { label: 'Thunderstorm w/ Hail', icon: '11d' },
|
||||
}
|
||||
|
||||
export async function downloadIcons() {
|
||||
if (!existsSync(DIR)) mkdirSync(DIR, { recursive: true })
|
||||
const icons = new Set(Object.values(WMO).map(w => [w.icon, w.icon.replace('d','n')]).flat())
|
||||
await Promise.all([...icons].map(async icon => {
|
||||
const path = resolve(DIR, `${icon}.png`)
|
||||
if (existsSync(path)) return
|
||||
const res = await fetch(`https://openweathermap.org/img/wn/${icon}@2x.png`)
|
||||
const buf = Buffer.from(await res.arrayBuffer())
|
||||
writeFileSync(path, buf)
|
||||
console.log(` 📥 Downloaded icon: ${icon}.png`)
|
||||
}))
|
||||
console.log('✅ Weather icons ready')
|
||||
}
|
||||
|
||||
export function getWeatherCondition(data) {
|
||||
// Determine primary condition
|
||||
if (data.lightning_rate > 0 && data.raining === "True") {
|
||||
return { label: "Thunderstorm", code: 211, icon: "11d" };
|
||||
}
|
||||
|
||||
if (data.raining === "True") {
|
||||
if (data.precipitation > 7.6) return { label: "Heavy Rain", code: 502, icon: "10d" };
|
||||
if (data.precipitation > 2.5) return { label: "Moderate Rain", code: 501, icon: "10d" };
|
||||
return { label: "Light Rain", code: 500, icon: "09d" };
|
||||
}
|
||||
|
||||
if (data.lightning_rate > 500) {
|
||||
return { label: "Thunderstorm", code: 211, icon: "11d" };
|
||||
}
|
||||
|
||||
if (data.visibility < 10) {
|
||||
return { label: "Mist", code: 701, icon: "50d" };
|
||||
}
|
||||
|
||||
// Cloud-based conditions
|
||||
const isDay = data.daytime;
|
||||
const dayNight = isDay ? "d" : "n";
|
||||
|
||||
if (data.clouds > 85) return { label: "Overcast Clouds", code: 804, icon: `04${dayNight}` };
|
||||
if (data.clouds > 50) return { label: "Broken Clouds", code: 803, icon: `04${dayNight}` };
|
||||
if (data.clouds > 25) return { label: "Scattered Clouds",code: 802, icon: `03${dayNight}` };
|
||||
if (data.clouds > 10) return { label: "Few Clouds", code: 801, icon: `02${dayNight}` };
|
||||
|
||||
return { label: "Clear Sky", code: 800, icon: `01${dayNight}` };
|
||||
}
|
||||
158
server/src/server.mjs
Normal file
158
server/src/server.mjs
Normal file
@@ -0,0 +1,158 @@
|
||||
import express from 'express';
|
||||
import {resolve, dirname} from 'path';
|
||||
import {fileURLToPath} from 'url';
|
||||
import {cfg} from './config.mjs';
|
||||
import {queryCurrent, queryHourly, queryDaily, getCoords} from './influx.mjs';
|
||||
import {getCelestialCurrent, getCelestialForecast} from './celestial.mjs';
|
||||
import {getSpaceWeather} from './space.mjs';
|
||||
import {dailyForecast} from './openmeteo.mjs';
|
||||
import {apiReference} from '@scalar/express-api-reference';
|
||||
import {spec} from './spec.mjs';
|
||||
import {existsSync} from 'fs';
|
||||
import {getAirTraffic, getAirTrafficHistory, getShapes} from './airtraffic.mjs';
|
||||
import {getWeatherCondition} from './openweather.mjs';
|
||||
|
||||
const app = express();
|
||||
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const CLIENT_DIST = resolve(DIR, 'public');
|
||||
|
||||
app.use(express.json());
|
||||
app.use('/icons', express.static(resolve(DIR, 'public', 'icons')));
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
next();
|
||||
});
|
||||
app.use(express.static(CLIENT_DIST));
|
||||
|
||||
function filterFields(obj, fields) {
|
||||
if(!fields) return obj;
|
||||
const keys = new Set(fields.split(',').map(f => f.trim()));
|
||||
return Object.fromEntries(Object.entries(obj).filter(([k]) => keys.has(k)));
|
||||
}
|
||||
|
||||
function filterArr(arr, fields) {
|
||||
if(!fields) return arr;
|
||||
return arr.map(row => filterFields(row, fields));
|
||||
}
|
||||
|
||||
// ── Sensor Data ───────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/current', async (req, res) => {
|
||||
const {fields} = req.query;
|
||||
const [sensors, coords] = await Promise.all([queryCurrent(), getCoords()]);
|
||||
const condition = getWeatherCondition(sensors);
|
||||
const space = getCelestialCurrent(coords.latitude, coords.longitude);
|
||||
res.json(filterFields({...condition, ...sensors, ...space}, fields));
|
||||
});
|
||||
|
||||
// ── Position ──────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/position', async (req, res) => {
|
||||
const {fields} = req.query;
|
||||
const data = await getCoords();
|
||||
res.json(filterFields(data, fields));
|
||||
});
|
||||
|
||||
// ── Space ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/space', async (req, res) => {
|
||||
const {fields} = req.query;
|
||||
res.json(filterFields(await getSpaceWeather(), fields));
|
||||
});
|
||||
|
||||
// ── Daily History/Forecast ────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/hourly', async (req, res) => {
|
||||
const { fields } = req.query
|
||||
const now = new Date()
|
||||
const plus24 = new Date(now.getTime() + 24 * 3600 * 1000)
|
||||
const start = req.query.start ? new Date(req.query.start) : new Date(now.setHours(0,0,0,0))
|
||||
const end = req.query.end ? new Date(req.query.end) : plus24
|
||||
const coords = await getCoords()
|
||||
const sensors = await queryCurrent()
|
||||
|
||||
// ── Past → now: InfluxDB ──────────────────────────────────────────────────
|
||||
const historyEnd = new Date(Math.min(now, end))
|
||||
historyEnd.setMinutes(0, 0, 0)
|
||||
const [historyResult] = await Promise.allSettled([
|
||||
start < now ? queryHourly(start.toISOString(), historyEnd.toISOString()) : Promise.resolve([])
|
||||
])
|
||||
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 ───────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
const meteoStart = start >= next ? start : next
|
||||
const coords = await getCoords()
|
||||
|
||||
const [sensor, meteo] = await Promise.allSettled([
|
||||
start < now ? queryDaily(start.toISOString(), now.toISOString()) : Promise.resolve([]),
|
||||
end > now ? dailyForecast(coords.latitude, coords.longitude, meteoStart, end) : Promise.resolve([]),
|
||||
])
|
||||
|
||||
const history = sensor.status === 'fulfilled' ? sensor.value : []
|
||||
const forecast = meteo.status === 'fulfilled' ? meteo.value : []
|
||||
const daily = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...forecast])
|
||||
res.json(filterArr(daily, fields))
|
||||
})
|
||||
|
||||
// ── ADSB Proxy ────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/air-traffic', async (req, res) => res.json(await getAirTraffic()));
|
||||
app.get('/api/air-traffic/:icao', async (req, res) => res.json(await getAirTrafficHistory(req.params.icao)));
|
||||
app.get('/api/air-traffic-shapes', async (req, res) => res.json(await getShapes()));
|
||||
|
||||
// ── DOCS ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.use('/docs', apiReference({spec: {url: '/openapi.json'}, theme: 'default'}));
|
||||
app.get('/openapi.json', (req, res) => res.json(spec));
|
||||
app.get('/openapi.yaml', async (req, res) => {
|
||||
const {stringify} = await import('yaml');
|
||||
res.setHeader('Content-Type', 'text/yaml');
|
||||
res.send(stringify(spec));
|
||||
});
|
||||
|
||||
// ── Website ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('*', (req, res) => {
|
||||
const index = resolve(CLIENT_DIST, 'index.html');
|
||||
if(existsSync(index)) res.sendFile(index);
|
||||
else res.status(404).send('Client not built yet — run npm run build in /client');
|
||||
});
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const c = cfg();
|
||||
app.listen(c.PORT, () => console.log(`🌦 Weather API — http://localhost:${c.PORT}`));
|
||||
69
server/src/space.mjs
Normal file
69
server/src/space.mjs
Normal file
@@ -0,0 +1,69 @@
|
||||
import { ssnFromFlux, sunActivityLabel } from './celestial.mjs'
|
||||
|
||||
const CACHE = {}
|
||||
const CACHE_MS = 10 * 60 * 1000
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
async function getKpAp() {
|
||||
const data = await cachedFetch('kp', 'https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json')
|
||||
// First row is always the header
|
||||
const rows = Array.isArray(data) ? data.slice(1).filter(r => Array.isArray(r)) : []
|
||||
if (!rows.length) return {}
|
||||
const latest = rows[rows.length - 1]
|
||||
const kp = parseFloat(latest[1])
|
||||
const ap = parseFloat(latest[2])
|
||||
return {
|
||||
space_kp_index: isNaN(kp) ? null : kp,
|
||||
space_ap_index: isNaN(ap) ? null : ap,
|
||||
space_geomagnetic_storm: kp >= 7 ? 'Severe' : kp >= 6 ? 'Strong' : kp >= 5 ? 'Moderate' : kp >= 4 ? 'Minor' : 'None',
|
||||
}
|
||||
}
|
||||
|
||||
async function getSolarWind() {
|
||||
const [plasma, mag] = await Promise.all([
|
||||
cachedFetch('plasma', 'https://services.swpc.noaa.gov/products/solar-wind/plasma.json'),
|
||||
cachedFetch('mag', 'https://services.swpc.noaa.gov/products/solar-wind/mag.json'),
|
||||
])
|
||||
// Skip header row, find last valid row
|
||||
const pRows = Array.isArray(plasma) ? plasma.slice(1).filter(r => Array.isArray(r) && r[2] !== null) : []
|
||||
const mRows = Array.isArray(mag) ? mag.slice(1).filter(r => Array.isArray(r) && r[3] !== null) : []
|
||||
const p = pRows[pRows.length - 1] || []
|
||||
const m = mRows[mRows.length - 1] || []
|
||||
return {
|
||||
space_solar_wind_speed_kms: p[2] != null ? parseFloat(p[2]) : null,
|
||||
space_solar_wind_density: p[1] != null ? parseFloat(p[1]) : null,
|
||||
space_solar_wind_temp: p[3] != null ? parseFloat(p[3]) : null,
|
||||
space_imf_bz: m[3] != null ? parseFloat(m[3]) : null,
|
||||
space_imf_bt: m[6] != null ? parseFloat(m[6]) : null,
|
||||
}
|
||||
}
|
||||
|
||||
async function getSolarFlux() {
|
||||
const data = await cachedFetch('flux', 'https://services.swpc.noaa.gov/products/10cm-flux-30-day.json')
|
||||
// Format: [["yyyy-mm-dd", flux, ...], ...] — no header row on this one
|
||||
const rows = Array.isArray(data) ? data.filter(r => Array.isArray(r) && !isNaN(parseFloat(r[1]))) : []
|
||||
if (!rows.length) return {}
|
||||
const f107 = parseFloat(rows[rows.length - 1][1])
|
||||
return {
|
||||
space_solar_flux_f107: isNaN(f107) ? null : f107,
|
||||
sun_ssn: ssnFromFlux(f107),
|
||||
sun_activity_label: sunActivityLabel(f107),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSpaceWeather() {
|
||||
const [kpAp, wind, flux] = await Promise.allSettled([getKpAp(), getSolarWind(), getSolarFlux()])
|
||||
return {
|
||||
...(kpAp.status === 'fulfilled' ? kpAp.value : {}),
|
||||
...(wind.status === 'fulfilled' ? wind.value : {}),
|
||||
...(flux.status === 'fulfilled' ? flux.value : {}),
|
||||
}
|
||||
}
|
||||
236
server/src/spec.mjs
Normal file
236
server/src/spec.mjs
Normal file
@@ -0,0 +1,236 @@
|
||||
export const spec = {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'Weather Station',
|
||||
version: '1.0.0',
|
||||
description: 'Hyperlocal weather station — sensor data, forecasts, celestial & space weather',
|
||||
},
|
||||
servers: [{ url: 'http://localhost:3000' }],
|
||||
paths: {
|
||||
'/api/position': {
|
||||
get: {
|
||||
summary: 'Station position',
|
||||
description: 'Latest GPS position and info',
|
||||
parameters: [ ],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Last known position',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataRow' } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api/data': {
|
||||
get: {
|
||||
summary: 'Current conditions',
|
||||
description: 'Latest reading of all metrics — sensor, forecast, celestial & space weather merged into a single flat object',
|
||||
parameters: [
|
||||
{ name: 'fields', in: 'query', required: false, schema: { type: 'string' },
|
||||
description: 'Comma-separated list of fields to return. Omit for all.' }
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Current conditions',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataRow' } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api/hourly': {
|
||||
get: {
|
||||
summary: 'Hourly data',
|
||||
description: 'Hourly aggregated sensor data merged with Open-Meteo hourly forecast, celestial positions and space weather. Defaults to today.',
|
||||
parameters: [
|
||||
{ name: 'start', in: 'query', required: false, schema: { type: 'string', format: 'date-time' },
|
||||
description: 'ISO8601 start timestamp. Defaults to today 00:00.' },
|
||||
{ name: 'end', in: 'query', required: false, schema: { type: 'string', format: 'date-time' },
|
||||
description: 'ISO8601 end timestamp. Defaults to now.' },
|
||||
{ name: 'fields', in: 'query', required: false, schema: { type: 'string' },
|
||||
description: 'Comma-separated list of fields to return. Omit for all.' },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Array of hourly rows',
|
||||
content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/DataRow' } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api/daily': {
|
||||
get: {
|
||||
summary: 'Daily data',
|
||||
description: 'Daily aggregated sensor data (mean/min/max) merged with Open-Meteo daily forecast and celestial events. Defaults to today.',
|
||||
parameters: [
|
||||
{ name: 'start', in: 'query', required: false, schema: { type: 'string', format: 'date-time' },
|
||||
description: 'ISO8601 start timestamp. Defaults to today 00:00.' },
|
||||
{ name: 'end', in: 'query', required: false, schema: { type: 'string', format: 'date-time' },
|
||||
description: 'ISO8601 end timestamp. Defaults to now.' },
|
||||
{ name: 'fields', in: 'query', required: false, schema: { type: 'string' },
|
||||
description: 'Comma-separated list of fields to return. Omit for all.' },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Array of daily rows',
|
||||
content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/DataRow' } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
Position: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lat: { type: 'number', description: 'Latitude' },
|
||||
lon: { type: 'number', description: 'Longitude' },
|
||||
alt: { type: 'number', description: 'Altitude' },
|
||||
}
|
||||
},
|
||||
DataRow: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
time: { type: 'string', description: 'ISO8601 timestamp or date' },
|
||||
// Environment
|
||||
temperature: { type: 'number', description: 'Temperature (°C)' },
|
||||
env_temp_f: { type: 'number', description: 'Temperature (°F)' },
|
||||
env_temp_min_c: { type: 'number', description: 'Daily min temperature (°C)' },
|
||||
env_temp_max_c: { type: 'number', description: 'Daily max temperature (°C)' },
|
||||
humidity: { type: 'number', description: 'Relative humidity (%)' },
|
||||
dew_point: { type: 'number', description: 'Dew point (°C)' },
|
||||
heat_index: { type: 'number', description: 'Feels like / heat index (°C)' },
|
||||
pressure_hpa: { type: 'number', description: 'Station pressure (hPa)' },
|
||||
pressure_slp: { type: 'number', description: 'Sea level pressure (hPa)' },
|
||||
pressure_rate: { type: 'number', description: 'Pressure change rate (hPa/hr)' },
|
||||
pressure_trend: { type: 'string', enum: ['Rising','Falling','Stable'], description: 'Pressure trend label' },
|
||||
humidity_abs: { type: 'number', description: 'Absolute humidity (g/m³)' },
|
||||
vapor_pressure_deficit: { type: 'number', description: 'Vapour pressure deficit (kPa)' },
|
||||
air_quality_ohms: { type: 'number', description: 'BME680 gas resistance (Ω)' },
|
||||
air_quality: { type: 'number', description: 'Air quality index score (0–100)' },
|
||||
air_quality_label: { type: 'string', enum: ['Excellent','Good','Fair','Poor','Very Poor'] },
|
||||
frost_risk: { type: 'string', enum: ['None','Low','Moderate','High'] },
|
||||
// Light
|
||||
lux: { type: 'number', description: 'Illuminance (lux)' },
|
||||
uv_index: { type: 'number', description: 'UV index' },
|
||||
solar_wm2: { type: 'number', description: 'Solar irradiance (W/m²)' },
|
||||
uv_dose: { type: 'number', description: 'Accumulated UV dose today (mJ/cm²)' },
|
||||
light_burn_time_min: { type: 'number', description: 'Time to sunburn skin type 2 (min)' },
|
||||
clouds: { type: 'number', description: 'Estimated cloud cover (%)' },
|
||||
light_cloud_label: { type: 'string', enum: ['Clear','Partly Cloudy','Mostly Cloudy','Overcast'] },
|
||||
light_dli: { type: 'number', description: 'Daily light integral (mol/m²/day)' },
|
||||
daylight: { type: 'number', description: 'Hours above daylight threshold today' },
|
||||
visibility: { type: 'number', description: 'Estimated visibility (km)' },
|
||||
uv_index_max: { type: 'number', description: 'Daily max UV index' },
|
||||
solar_wm2_sum: { type: 'number', description: 'Daily solar radiation sum (MJ/m²)' },
|
||||
// Wind (Open-Meteo)
|
||||
wind_speed_kmh: { type: 'number', description: 'Wind speed (km/h)' },
|
||||
wind_direction_deg: { type: 'number', description: 'Wind direction (°)' },
|
||||
wind_gusts_kmh: { type: 'number', description: 'Wind gusts (km/h)' },
|
||||
wind_speed_max_kmh: { type: 'number', description: 'Daily max wind speed (km/h)' },
|
||||
wind_gusts_max_kmh: { type: 'number', description: 'Daily max wind gusts (km/h)' },
|
||||
// Forecast
|
||||
forecast_weathercode: { type: 'number', description: 'WMO weather code' },
|
||||
forecast_weather_label: { type: 'string', description: 'Human readable weather label' },
|
||||
forecast_weather_icon: { type: 'string', description: 'Local icon path e.g. /icons/01d.png' },
|
||||
forecast_precipitation_mm: { type: 'number', description: 'Precipitation (mm)' },
|
||||
forecast_precipitation_probability: { type: 'number', description: 'Precipitation probability (%)' },
|
||||
forecast_snowfall_mm: { type: 'number', description: 'Snowfall (mm)' },
|
||||
forecast_snow_depth_m: { type: 'number', description: 'Snow depth (m)' },
|
||||
forecast_cape: { type: 'number', description: 'Convective available potential energy (J/kg)' },
|
||||
forecast_freezing_level_m: { type: 'number', description: 'Freezing level altitude (m)' },
|
||||
forecast_evapotranspiration_mm:{ type: 'number', description: 'Evapotranspiration (mm)' },
|
||||
// Seismic
|
||||
seismic_ax: { type: 'number', description: 'Peak acceleration X axis (g)' },
|
||||
seismic_ay: { type: 'number', description: 'Peak acceleration Y axis (g)' },
|
||||
seismic_az: { type: 'number', description: 'Peak acceleration Z axis (g, includes gravity)' },
|
||||
seismic_magnitude: { type: 'number', description: 'Peak seismic magnitude (g, gravity removed)' },
|
||||
// Compass
|
||||
compass_heading: { type: 'number', description: 'Magnetic heading (°)' },
|
||||
compass_x: { type: 'number' },
|
||||
compass_y: { type: 'number' },
|
||||
compass_z: { type: 'number' },
|
||||
// Ground / Accumulation
|
||||
ground_distance: { type: 'number', description: 'LIDAR ground distance (cm)' },
|
||||
lidar_strength: { type: 'number', description: 'LIDAR signal strength' },
|
||||
ground_calibration: { type: 'number', description: 'Calibrated baseline distance (cm)' },
|
||||
accumulation: { type: 'number', description: 'Snow/flood depth (cm)' },
|
||||
ground_accumulation_type: { type: 'string', enum: ['snow','slush','ice','flood'], description: 'Accumulation type' },
|
||||
// Lightning
|
||||
lightning_distance: { type: 'number', description: 'Lightning strike distance (km)' },
|
||||
lightning_energy: { type: 'number', description: 'Lightning energy' },
|
||||
lightning_strikes_per_hour: { type: 'number', description: 'Strike rate (strikes/hr)' },
|
||||
storm_direction: { type: 'string', enum: ['Approaching','Retreating','Stationary'] },
|
||||
lightning_false_positive: { type: 'number', description: '1 if disturber/false positive' },
|
||||
lightning_detector_sensitivity:{ type: 'number', description: 'AS3935 noise floor setting (0–7)' },
|
||||
// GPS
|
||||
latitude: { type: 'number', description: 'Latitude (°)' },
|
||||
longitude: { type: 'number', description: 'Longitude (°)' },
|
||||
altitude: { type: 'number', description: 'Altitude (m)' },
|
||||
gps_satellites: { type: 'number', description: 'Satellites in view' },
|
||||
gps_speed_kmh: { type: 'number', description: 'Ground speed (km/h)' },
|
||||
gps_heading: { type: 'number', description: 'GPS heading (°)' },
|
||||
// Sun
|
||||
sun_elevation: { type: 'number', description: 'Solar elevation angle (°)' },
|
||||
sun_azimuth: { type: 'number', description: 'Solar azimuth (°)' },
|
||||
sun_sunrise: { type: 'string', description: 'Sunrise time (ISO8601)' },
|
||||
sun_sunset: { type: 'string', description: 'Sunset time (ISO8601)' },
|
||||
sun_solar_noon: { type: 'string', description: 'Solar noon (ISO8601)' },
|
||||
sun_day_length_hours: { type: 'number', description: 'Day length (hours)' },
|
||||
sun_golden_hour_morning_start: { type: 'string' },
|
||||
sun_golden_hour_morning_end: { type: 'string' },
|
||||
sun_golden_hour_evening_start: { type: 'string' },
|
||||
sun_golden_hour_evening_end: { type: 'string' },
|
||||
sun_is_day: { type: 'number', description: '1 if daytime' },
|
||||
sun_ssn: { type: 'number', description: 'Estimated sunspot number' },
|
||||
sun_activity_label: { type: 'string', enum: ['Very Low','Low','Moderate','High','Very High'] },
|
||||
// Moon
|
||||
moon_phase: { type: 'string', description: 'Moon phase name' },
|
||||
moon_illumination_pct: { type: 'number', description: 'Moon illumination (%)' },
|
||||
moon_moonrise: { type: 'string', description: 'Moonrise time (ISO8601)' },
|
||||
moon_moonset: { type: 'string', description: 'Moonset time (ISO8601)' },
|
||||
moon_next_full: { type: 'string', description: 'Next full moon (ISO8601)' },
|
||||
moon_next_new: { type: 'string', description: 'Next new moon (ISO8601)' },
|
||||
// Season
|
||||
season_next_event: { type: 'string', description: 'Next solstice/equinox name' },
|
||||
season_next_event_date: { type: 'string' },
|
||||
season_last_event: { type: 'string' },
|
||||
season_last_event_date: { type: 'string' },
|
||||
// Space weather
|
||||
space_kp_index: { type: 'number', description: 'Planetary K index (0–9)' },
|
||||
space_ap_index: { type: 'number', description: 'Ap geomagnetic index' },
|
||||
space_geomagnetic_storm: { type: 'string', enum: ['None','Minor','Moderate','Strong','Severe'] },
|
||||
space_solar_wind_speed_kms: { type: 'number', description: 'Solar wind speed (km/s)' },
|
||||
space_solar_wind_density: { type: 'number', description: 'Solar wind proton density (p/cm³)' },
|
||||
space_solar_wind_temp: { type: 'number', description: 'Solar wind temperature (K)' },
|
||||
space_imf_bz: { type: 'number', description: 'IMF Bz component (nT)' },
|
||||
space_imf_bt: { type: 'number', description: 'IMF total field Bt (nT)' },
|
||||
space_solar_flux_f107: { type: 'number', description: 'Solar flux F10.7 index' },
|
||||
// Marine
|
||||
wave_height: { type: 'number', description: 'Significant wave height (m)' },
|
||||
wave_direction: { type: 'number', description: 'Wave direction (°)' },
|
||||
wave_period: { type: 'number', description: 'Wave period (s)' },
|
||||
swell_wave_height: { type: 'number', description: 'Swell wave height (m)' },
|
||||
swell_wave_direction: { type: 'number', description: 'Swell direction (°)' },
|
||||
swell_wave_period: { type: 'number', description: 'Swell period (s)' },
|
||||
wind_wave_height: { type: 'number', description: 'Wind wave height (m)' },
|
||||
wave_height_max: { type: 'number', description: 'Daily max wave height (m)' },
|
||||
// AQ
|
||||
pm10: { type: 'number', description: 'PM10 (μg/m³)' },
|
||||
pm2_5: { type: 'number', description: 'PM2.5 (μg/m³)' },
|
||||
carbon_monoxide: { type: 'number', description: 'CO (μg/m³)' },
|
||||
nitrogen_dioxide: { type: 'number', description: 'NO₂ (μg/m³)' },
|
||||
sulphur_dioxide: { type: 'number', description: 'SO₂ (μg/m³)' },
|
||||
ozone: { type: 'number', description: 'O₃ (μg/m³)' },
|
||||
aerosol_optical_depth: { type: 'number' },
|
||||
dust: { type: 'number', description: 'Dust (μg/m³)' },
|
||||
alder_pollen: { type: 'number', description: 'Alder pollen (grains/m³)' },
|
||||
birch_pollen: { type: 'number', description: 'Birch pollen (grains/m³)' },
|
||||
grass_pollen: { type: 'number', description: 'Grass pollen (grains/m³)' },
|
||||
mugwort_pollen: { type: 'number', description: 'Mugwort pollen (grains/m³)' },
|
||||
olive_pollen: { type: 'number', description: 'Olive pollen (grains/m³)' },
|
||||
ragweed_pollen: { type: 'number', description: 'Ragweed pollen (grains/m³)' },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user