diff --git a/client/src/components/MapView.vue b/client/src/components/MapView.vue index 74f9981..0368f52 100644 --- a/client/src/components/MapView.vue +++ b/client/src/components/MapView.vue @@ -13,6 +13,7 @@ import XYZ from 'ol/source/XYZ' import Feature from 'ol/Feature' import Point from 'ol/geom/Point' import CircleGeom from 'ol/geom/Circle' +import { defaults as defaultInteractions } from 'ol/interaction' import { fromLonLat, toLonLat } from 'ol/proj' import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style' import 'ol/ol.css' @@ -187,6 +188,7 @@ onMounted(async () => { map = new Map({ target: mapEl.value!, layers: [buildBaseLayer(props.dark), stationLayer], + interactions: defaultInteractions({ altShiftDragRotate: false, pinchRotate: false }), view: new View({ center: fromLonLat([lon, lat]), zoom: 8, maxZoom: 13 }), controls: [], }) diff --git a/server/src/forecast.mjs b/server/src/forecast.mjs index c9f151c..6213345 100644 --- a/server/src/forecast.mjs +++ b/server/src/forecast.mjs @@ -1,6 +1,7 @@ import {queryHourly, getCoords, queryCurrent} from './sensors.mjs'; import {getCelestialCurrent} from './celestial.mjs'; import {localDateStr} from './config.mjs'; +import {frostRisk} from './openweather.mjs'; export let lastForecast = { ts: 0, slots: [], summary: null } export const forecastTTL = 5 * 60 * 60_000 @@ -188,6 +189,7 @@ export async function get24HourForecast(currentSensors) { 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.frost_risk = frostRisk(slot.temperature, slot.dew_point, slot.humidity) slot.solar_wm2 = slot.sun_elevation > 0 ? Math.round(Math.sin((slot.sun_elevation * Math.PI) / 180) * 1000 * (1 - slot.clouds * 0.75) * 100) / 100 @@ -252,6 +254,7 @@ function summarise(slots) { precipitation: avg(fin('precipitation')), precipitation_chance: Math.round(peak(fin('precipitation_chance'))), accumulation: finAll('precipitation').reduce((a, b) => a + b, 0), + frost_risk: slots.find(s => s.frost_risk !== 'None')?.frost_risk ?? slots.at(-1)?.frost_risk ?? 'None', uv_index: peak(fin('uv_index')), uv_dose: finAll('uv_dose').reduce((a, b) => a + b, 0), solar_wm2: avg(fin('solar_wm2')), diff --git a/server/src/openmeteo.mjs b/server/src/openmeteo.mjs index 5109d18..80e01bb 100644 --- a/server/src/openmeteo.mjs +++ b/server/src/openmeteo.mjs @@ -4,6 +4,7 @@ import { fileURLToPath } from 'url' import { tzOffsetMinutes } from './config.mjs' import { getWeatherCondition } from './forecast.mjs' import { getCelestialForecast } from './celestial.mjs' +import {frostRisk} from './openweather.mjs'; const DIR = dirname(fileURLToPath(import.meta.url)) const ICON_DIR = resolve(DIR, 'public', 'icons') @@ -138,6 +139,7 @@ export async function dailyWeather(lat, lon, start, end) { precipitation: precip, precipitation_chance: precipChance, accumulation: precip, + frost_risk: frostRisk(temp, dewPoint(temp, humidity), humidity), uv_index: data.daily.uv_index_max[i] ?? 0, uv_index_max: data.daily.uv_index_max[i] ?? 0, uv_dose: 0, @@ -241,6 +243,7 @@ export async function hourlyWeather(lat, lon, start, end) { precipitation: precip, precipitation_chance: precipChance, accumulation: precip, + frost_risk: frostRisk(temp, dewPoint(temp, humidity), humidity), uv_index: uv, uv_index_max: uv, uv_dose: 0, diff --git a/server/src/openweather.mjs b/server/src/openweather.mjs index 6c7184d..f172d4e 100644 --- a/server/src/openweather.mjs +++ b/server/src/openweather.mjs @@ -18,3 +18,11 @@ export async function fetchIcon(icon) { await downloadIcon(icon, iconPath); return iconPath; } + +export function frostRisk(tempC, dewPointC, humidity) { + if (tempC > 4) return 'None' + if (tempC <= 0 && dewPointC <= 0) return 'High' + if (tempC <= 2 && humidity > 85) return 'Moderate' + if (tempC <= 4) return 'Low' + return 'None' +} diff --git a/server/src/sensors.mjs b/server/src/sensors.mjs index 9fa4802..22bbaa3 100644 --- a/server/src/sensors.mjs +++ b/server/src/sensors.mjs @@ -1,4 +1,5 @@ import {cfg} from './config.mjs'; +import { frostRisk } from './forecast.mjs'; async function query(c, q, start, end) { const url = new URL('/api/v1/query_range', c.DB_HOST); @@ -30,9 +31,15 @@ function metricToFields(results) { } export async function queryCurrent() { - const c = cfg(); - const results = await queryInstant(c, `{__name__!=""}`); - return {time: new Date(), ...metricToFields(results)}; + const c = cfg() + const results = await queryInstant(c, `{__name__!=""}`) + const fields = metricToFields(results) + const dp = fields.dew_point ?? null + return { + time: new Date(), + ...fields, + frost_risk: frostRisk(fields.temperature ?? 99, dp ?? 99, fields.humidity ?? 0), + } } export async function queryHourly(start, end) { diff --git a/server/src/spec.mjs b/server/src/spec.mjs index 33cbd59..b64e6fe 100644 --- a/server/src/spec.mjs +++ b/server/src/spec.mjs @@ -10,17 +10,16 @@ export const spec = { '/api/position': { get: { summary: 'Station position', - description: 'Latest GPS position and info', - parameters: [ ], + description: 'Latest GPS position and altitude', responses: { 200: { description: 'Last known position', - content: { 'application/json': { schema: { $ref: '#/components/schemas/DataRow' } } } + content: { 'application/json': { schema: { $ref: '#/components/schemas/Position' } } } } } } }, - '/api/data': { + '/api/current': { get: { summary: 'Current conditions', description: 'Latest reading of all metrics — sensor, forecast, celestial & space weather merged into a single flat object', @@ -82,153 +81,79 @@ export const spec = { Position: { type: 'object', properties: { - lat: { type: 'number', description: 'Latitude' }, - lon: { type: 'number', description: 'Longitude' }, - alt: { type: 'number', description: 'Altitude' }, + latitude: { type: 'number', description: 'Latitude (°)' }, + longitude: { type: 'number', description: 'Longitude (°)' }, + altitude: { type: 'number', description: 'Altitude (m)' }, } }, DataRow: { type: 'object', properties: { - time: { type: 'string', description: 'ISO8601 timestamp or date' }, + time: { type: 'string', format: 'date-time', description: 'ISO8601 timestamp' }, // 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. 01d' }, - 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' }, + temperature: { type: 'number', description: 'Temperature (°C)' }, + 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 (%)' }, + humidity_abs: { type: 'number', description: 'Absolute humidity (g/m³)' }, + dew_point: { type: 'number', description: 'Dew point (°C)' }, + heat_index: { type: 'number', description: 'Feels like / heat index (°C)' }, + vapor_pressure_deficit: { type: 'number', description: 'Vapour pressure deficit (kPa)' }, + 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' }, + frost_risk: { type: 'string', enum: ['None','Low','Moderate','High'], description: 'Frost risk level' }, + air_quality: { type: 'number', description: 'Air quality index score (0–100)' }, + air_quality_label: { type: 'string', enum: ['Excellent','Good','Fair','Poor','Very Poor'] }, + // Light & Solar + lux: { type: 'number', description: 'Illuminance (lux)' }, + uv_index: { type: 'number', description: 'UV index' }, + uv_index_max: { type: 'number', description: 'Daily max UV index' }, + uv_dose: { type: 'number', description: 'Accumulated UV dose today (mJ/cm²)' }, + solar_wm2: { type: 'number', description: 'Solar irradiance (W/m²)' }, + daily_light_integral: { type: 'number', description: 'Daily light integral (mol/m²/day)' }, + clouds: { type: 'number', description: 'Cloud cover fraction (0–1)' }, + visibility: { type: 'number', description: 'Estimated visibility (km)' }, + daylight: { type: 'number', description: 'Hours of daylight' }, + // Weather + label: { type: 'string', description: 'Human readable weather label' }, + icon: { type: 'string', description: 'OWM icon code e.g. 01d' }, + raining: { type: 'string', enum: ['True','False'] }, + precipitation: { type: 'number', description: 'Precipitation (mm)' }, + precipitation_chance: { type: 'number', description: 'Precipitation probability (%)' }, + accumulation: { type: 'number', description: 'Accumulated precipitation (mm)' }, + storm_trend: { type: 'number', description: '1=worsening, 0=stable, -1=improving' }, + // Wind + wind_speed: { type: 'number', description: 'Wind speed (km/h)' }, + wind_gusts: { type: 'number', description: 'Wind gusts (km/h)' }, + wind_direction: { type: 'number', description: 'Wind direction (°)' }, // 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 (°)' }, + lightning_rate: { type: 'number', description: 'Strike rate (strikes/hr)' }, + lightning_distance: { type: 'number', description: 'Lightning strike distance (km)' }, // 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'] }, + sun_elevation: { type: 'number', description: 'Solar elevation angle (°)' }, + sun_azimuth: { type: 'number', description: 'Solar azimuth (°)' }, + sunrise: { type: 'string', format: 'date-time', description: 'Sunrise time' }, + sunset: { type: 'string', format: 'date-time', description: 'Sunset time' }, + daytime: { type: 'boolean', description: 'True if between sunrise and sunset' }, // 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³)' }, + moon_phase: { type: 'string', description: 'Moon phase name' }, + moon_illumination: { type: 'number', description: 'Moon illumination (%)' }, + moon_elevation: { type: 'number', description: 'Moon elevation (°)' }, + moon_azimuth: { type: 'number', description: 'Moon azimuth (°)' }, + moonrise: { type: 'string', format: 'date-time', description: 'Moonrise time' }, + moonset: { type: 'string', format: 'date-time', description: 'Moonset time' }, + moon_new: { type: 'string', format: 'date-time', description: 'Next new moon' }, + moon_full: { type: 'string', format: 'date-time', description: 'Next full moon' }, + // Seismic + seismic_magnitude: { type: 'number', description: 'Peak seismic magnitude (g)' }, + // Ground + ground_distance: { type: 'number', description: 'LIDAR ground distance (cm)' }, + // GPS + latitude: { type: 'number', description: 'Latitude (°)' }, + longitude: { type: 'number', description: 'Longitude (°)' }, + altitude: { type: 'number', description: 'Altitude (m)' }, } } }