diff --git a/server/scripts/modules/currentweather.mjs b/server/scripts/modules/currentweather.mjs index 02eb86e..eac88e4 100644 --- a/server/scripts/modules/currentweather.mjs +++ b/server/scripts/modules/currentweather.mjs @@ -7,7 +7,7 @@ import { registerDisplay } from './navigation.mjs'; import { temperature, windSpeed, pressure, distanceKilometers, distanceMeters, } from './utils/units.mjs'; -import { getConditionTextWithWind } from './utils/weather.mjs'; +import { getConditionTextWithWind, getBestUsCurrentObservation } from './utils/weather.mjs'; import { getLargeIconFromWmoCodeWithWind } from './icons.mjs'; class CurrentWeather extends WeatherDisplay { @@ -17,7 +17,7 @@ class CurrentWeather extends WeatherDisplay { async getData(weatherParameters, refresh) { const superResult = super.getData(weatherParameters, refresh); - this.data = parseData(this.weatherParameters); + this.data = await parseData(this.weatherParameters); if (!this.data) { if (this.isEnabled) this.setStatus(STATUS.failed); @@ -130,7 +130,51 @@ const getCurrentWeatherByHourFromTime = (data) => { return closestTime; }; -const parseData = (weatherParameters) => { +const parseData = async (weatherParameters) => { + if (weatherParameters.supportsNoaaDisplays && weatherParameters.stationId) { + const observationResult = await getBestUsCurrentObservation(weatherParameters); + if (observationResult?.observation) { + weatherParameters.primaryObservationSource = observationResult.source; + const currentForecast = getCurrentWeatherByHourFromTime(weatherParameters) ?? {}; + const observation = observationResult.observation; + const temperatureConverter = temperature(); + const windConverter = windSpeed(); + const pressureConverter = pressure(); + const ceilingConverter = distanceMeters(); + const visibilityConverter = distanceKilometers(); + const ceilingMeters = Math.max(0, ((observation.temperature ?? 0) - (observation.dewPoint ?? 0)) * 68); + const pressureValue = observation.pressure ?? currentForecast.pressure_msl ?? null; + return { + city: weatherParameters.city, + timeZone: weatherParameters.timeZone, + Temperature: temperatureConverter(observation.temperature), + TemperatureUnit: temperatureConverter.units, + DewPoint: temperatureConverter(observation.dewPoint), + Ceiling: ceilingConverter(ceilingMeters), + CeilingUnit: ceilingConverter.units, + Visibility: visibilityConverter(observation.visibility), + VisibilityUnit: visibilityConverter.units, + WindSpeed: windConverter(observation.windSpeed), + WindSpeedRaw: observation.windSpeed, + WindDirection: directionToNSEW(observation.windDirection ?? 0), + Pressure: pressureValue === null ? '-' : pressureConverter(pressureValue * 100), + PressureDirection: currentForecast.pressureTrend ?? 'Steady', + Humidity: Math.round(observation.relativeHumidity ?? currentForecast.relative_humidity_2m ?? 0), + WindGust: windConverter(observation.windGust), + WindGustRaw: observation.windGust, + WindUnit: windConverter.units, + TextConditions: Number(observation.weatherCode ?? 0), + Icon: getLargeIconFromWmoCodeWithWind( + observation.weatherCode, + Boolean(observation.isDay), + observation.windSpeed, + observation.windGust + ), + }; + } + } + + weatherParameters.primaryObservationSource = 'forecast'; const currentForecast = getCurrentWeatherByHourFromTime(weatherParameters); if (!currentForecast) return null; diff --git a/server/scripts/modules/navigation.mjs b/server/scripts/modules/navigation.mjs index 2e36a57..7d04a86 100644 --- a/server/scripts/modules/navigation.mjs +++ b/server/scripts/modules/navigation.mjs @@ -3,7 +3,12 @@ import noSleep from './utils/nosleep.mjs'; import STATUS from './status.mjs'; import { wrap } from './utils/calc.mjs'; import { safeJson } from './utils/fetch.mjs'; -import { getPoint, getOpenMeteoForecast, aggregateWeatherForecastData } from './utils/weather.mjs'; +import { + getPoint, + getOpenMeteoForecast, + aggregateWeatherForecastData, + getAggregatedNoaaForecast, +} from './utils/weather.mjs'; import { debugFlag } from './utils/debug.mjs'; import settings from './settings.mjs'; import { stopScreenAudio } from './media.mjs'; @@ -118,11 +123,6 @@ const message = (data) => { const getWeather = async (latLon, haveDataCallback) => { const location = getStoredLocationMetadata() ?? getFallbackLocation(latLon); - const openMeteoForecast = await getOpenMeteoForecast(latLon.lat, latLon.lon); - if (!openMeteoForecast) return; - - const aggregatedForecast = aggregateWeatherForecastData(openMeteoForecast); - if (!aggregatedForecast) return; if (typeof haveDataCallback === 'function') haveDataCallback(location); @@ -131,11 +131,42 @@ const getWeather = async (latLon, haveDataCallback) => { let point = null; let stations = null; let stationId = ''; + let aggregatedForecast = null; + let resolvedTimeZone = ''; + let primaryForecastSource = ''; + let openMeteoForecast = null; if (noaaEligibleLocation) { point = await getPoint(latLon.lat, latLon.lon); } + if (point?.properties) { + resolvedTimeZone = point.properties.timeZone ?? ''; + } + + if (point?.properties) { + const noaaForecast = await getAggregatedNoaaForecast(point.properties); + if (noaaForecast?.validation?.accepted) { + aggregatedForecast = noaaForecast.aggregatedForecast; + primaryForecastSource = 'noaa'; + } else if (debugFlag('verbose-failures') && noaaForecast?.validation) { + console.warn(`NOAA forecast rejected for ${latLon.lat},${latLon.lon}: ${noaaForecast.validation.reasons.join(', ')}`); + } + } + + if (!aggregatedForecast || !resolvedTimeZone) { + openMeteoForecast = await getOpenMeteoForecast(latLon.lat, latLon.lon); + if (!openMeteoForecast) return; + if (!aggregatedForecast) { + aggregatedForecast = aggregateWeatherForecastData(openMeteoForecast); + if (!aggregatedForecast) return; + primaryForecastSource = 'open-meteo'; + } + if (!resolvedTimeZone) { + resolvedTimeZone = openMeteoForecast.timezone; + } + } + if (noaaEligibleLocation && point?.properties?.observationStations) { stations = await safeJson(point.properties.observationStations); stationId = stations?.features?.[0]?.properties?.stationIdentifier ?? ''; @@ -158,7 +189,7 @@ const getWeather = async (latLon, haveDataCallback) => { weatherParameters.state = state; weatherParameters.country = location.country ?? ''; weatherParameters.countryCode = location.countryCode ?? ''; - weatherParameters.timeZone = openMeteoForecast.timezone; + weatherParameters.timeZone = resolvedTimeZone; weatherParameters.forecast = aggregatedForecast; weatherParameters.supportsNoaaAlerts = supportsNoaaAlerts; weatherParameters.supportsNoaaDisplays = !!(noaaEligibleLocation && point && stations?.features?.length); @@ -169,6 +200,8 @@ const getWeather = async (latLon, haveDataCallback) => { weatherParameters.forecastGridData = point?.properties?.forecastGridData ?? ''; weatherParameters.stations = stations?.features ?? []; weatherParameters.relativeLocation = point?.properties?.relativeLocation?.properties ?? null; + weatherParameters.primaryForecastSource = primaryForecastSource; + weatherParameters.primaryObservationSource = ''; // update the main process for display purposes populateWeatherParameters(weatherParameters, point?.properties); diff --git a/server/scripts/modules/utils/weather.mjs b/server/scripts/modules/utils/weather.mjs index 4b2c527..44d597b 100644 --- a/server/scripts/modules/utils/weather.mjs +++ b/server/scripts/modules/utils/weather.mjs @@ -1,5 +1,8 @@ +import { DateTime, Duration } from '../../vendor/auto/luxon.mjs'; +import parseIconUrl from '../icons/icons-parse.mjs'; import { safeJson } from './fetch.mjs'; import { debugFlag } from './debug.mjs'; +import { enhanceObservationWithMapClick } from './mapclick.mjs'; const OPEN_METEO_FORECAST_PARAMETERS = [ 'daily=temperature_2m_max,temperature_2m_min,uv_index_max', @@ -17,8 +20,104 @@ const OPEN_METEO_RADAR_OBSERVATION_PARAMETERS = [ const OPEN_METEO_OBSERVATION_CACHE_TTL_MS = 10 * 60 * 1000; const OPEN_METEO_TRAVEL_FORECAST_CACHE_TTL_MS = 30 * 60 * 1000; +const NOAA_CURRENT_OBSERVATION_CACHE_TTL_MS = 5 * 60 * 1000; const openMeteoObservationCache = new Map(); const openMeteoTravelForecastCache = new Map(); +const noaaCurrentObservationCache = new Map(); + +const NOAA_ICON_TO_WEATHER_CODE = { + skc: 0, + few: 1, + sct: 2, + bkn: 2, + ovc: 3, + wind_skc: 0, + wind_few: 1, + wind_sct: 2, + wind_bkn: 2, + wind_ovc: 3, + wind_: 0, + fog: 45, + haze: 45, + smoke: 45, + dust: 45, + rain: 61, + rain_showers: 80, + rain_showers_hi: 80, + rain_showers_high: 80, + fzra: 66, + rain_fzra: 66, + snow_fzra: 67, + sleet: 77, + rain_sleet: 77, + snow_sleet: 77, + winter_mix: 77, + snow: 71, + blizzard: 75, + rain_snow: 77, + tsra: 95, + tsra_sct: 95, + tsra_hi: 95, + tornado: 99, + hurricane: 99, + tropical_storm: 95, + hot: 0, + cold: 0, +}; + +const NOAA_FORECAST_MIN_HOURLY_COUNT = 18; +const NOAA_FORECAST_MIN_DAILY_COUNT = 3; +const NOAA_FORECAST_CORE_COMPLETENESS_THRESHOLD = 0.9; +const NOAA_FORECAST_SUPPORT_COMPLETENESS_THRESHOLD = 0.8; +const NOAA_FORECAST_MAX_FALLBACK_RATIO = 0.25; +const NOAA_OBSERVATION_MAX_AGE_MINUTES = 120; + +const NOAA_GRID_FIELD_CANDIDATES = { + relative_humidity_2m: ['relativeHumidity'], + dew_point_2m: ['dewpoint'], + cloud_cover: ['skyCover'], + visibility: ['visibility'], + pressure_msl: ['pressure', 'barometricPressure', 'seaLevelPressure'], + surface_pressure: ['pressure', 'barometricPressure', 'seaLevelPressure'], + wind_gusts_10m: ['windGust'], + apparent_temperature: ['apparentTemperature'], + wind_speed_10m: ['windSpeed'], + wind_direction_10m: ['windDirection'], + precipitation_probability: ['probabilityOfPrecipitation'], + precipitation: ['quantitativePrecipitation'], + snowfall: ['snowfallAmount'], + uv_index: ['probabilityOfThunder'], +}; + +const HOURLY_FORECAST_FIELDS = [ + 'temperature_2m', + 'relative_humidity_2m', + 'dew_point_2m', + 'apparent_temperature', + 'precipitation_probability', + 'precipitation', + 'rain', + 'showers', + 'snowfall', + 'snow_depth', + 'weather_code', + 'pressure_msl', + 'surface_pressure', + 'cloud_cover', + 'visibility', + 'uv_index', + 'is_day', + 'sunshine_duration', + 'wind_speed_10m', + 'wind_direction_10m', + 'wind_gusts_10m', +]; + +const DAILY_FORECAST_FIELDS = [ + 'temperature_2m_max', + 'temperature_2m_min', + 'uv_index_max', +]; const getPoint = async (lat, lon) => { const point = await safeJson(`https://api.weather.gov/points/${lat.toFixed(4)},${lon.toFixed(4)}`); @@ -31,6 +130,448 @@ const getPoint = async (lat, lon) => { return point; }; +const parseNoaaValidTimeInterval = (validTime) => { + if (!validTime || !validTime.includes('/')) return null; + const [startIso, durationIso] = validTime.split('/'); + const start = DateTime.fromISO(startIso); + const duration = Duration.fromISO(durationIso); + if (!start.isValid || !duration.isValid) return null; + return { + start, + end: start.plus(duration), + }; +}; + +const expandNoaaGridValue = (gridEntry) => { + const interval = parseNoaaValidTimeInterval(gridEntry?.validTime); + if (!interval) return null; + return { + ...interval, + value: gridEntry?.value ?? null, + }; +}; + +const cardinalToDegrees = (direction) => { + const map = { + N: 0, + NNE: 22.5, + NE: 45, + ENE: 67.5, + E: 90, + ESE: 112.5, + SE: 135, + SSE: 157.5, + S: 180, + SSW: 202.5, + SW: 225, + WSW: 247.5, + W: 270, + WNW: 292.5, + NW: 315, + NNW: 337.5, + }; + return map[(direction ?? '').trim().toUpperCase()] ?? 0; +}; + +const parseNoaaWindDirectionString = (directionText) => { + if (typeof directionText !== 'string' || directionText.length === 0) return 0; + return cardinalToDegrees(directionText); +}; + +const parseNoaaWindSpeedString = (windSpeedText) => { + if (typeof windSpeedText !== 'string' || windSpeedText.length === 0) return null; + const normalized = windSpeedText.trim().toLowerCase(); + const values = [...normalized.matchAll(/\d+(?:\.\d+)?/g)].map((match) => parseFloat(match[0])); + if (values.length === 0) return null; + const baseValue = values[values.length - 1]; + if (normalized.includes('kt')) return baseValue * 1.852; + if (normalized.includes('mph')) return baseValue * 1.609344; + if (normalized.includes('km/h')) return baseValue; + if (normalized.includes('m/s')) return baseValue * 3.6; + return baseValue; +}; + +const convertNoaaValueByUom = (value, uom) => { + if (value === null || value === undefined) return null; + switch (uom) { + case 'wmoUnit:degC': + return value; + case 'wmoUnit:degF': + return (value - 32) * 5 / 9; + case 'wmoUnit:km_h-1': + return value; + case 'wmoUnit:m_s-1': + return value * 3.6; + case 'wmoUnit:kn': + return value * 1.852; + case 'wmoUnit:mph': + return value * 1.609344; + case 'wmoUnit:Pa': + return value / 100; + case 'wmoUnit:hPa': + return value; + case 'wmoUnit:m': + return value; + case 'wmoUnit:km': + return value * 1000; + case 'wmoUnit:mi': + return value * 1609.344; + case 'wmoUnit:percent': + case '%': + return value; + case 'wmoUnit:degree_(angle)': + return value; + case 'wmoUnit:mm': + return value; + case 'wmoUnit:cm': + return value * 10; + case 'wmoUnit:in': + return value * 25.4; + default: + return value; + } +}; + +const getGridValueAtTime = (gridProperty, targetTime) => { + const target = typeof targetTime === 'string' ? DateTime.fromISO(targetTime) : targetTime; + if (!gridProperty?.values?.length || !target?.isValid) return null; + for (const entry of gridProperty.values) { + const expanded = expandNoaaGridValue(entry); + if (!expanded) continue; + if (target >= expanded.start && target < expanded.end) { + return expanded.value; + } + } + return null; +}; + +const getNearestGridValueAtTime = (gridProperty, targetTime, toleranceHours = 6) => { + const target = typeof targetTime === 'string' ? DateTime.fromISO(targetTime) : targetTime; + if (!gridProperty?.values?.length || !target?.isValid) return null; + let best = null; + let bestDelta = Number.POSITIVE_INFINITY; + for (const entry of gridProperty.values) { + const expanded = expandNoaaGridValue(entry); + if (!expanded) continue; + const midpoint = expanded.start.plus({ + milliseconds: expanded.end.diff(expanded.start, 'milliseconds').milliseconds / 2, + }); + const delta = Math.abs(midpoint.toMillis() - target.toMillis()); + if (delta < bestDelta) { + bestDelta = delta; + best = expanded.value; + } + } + if (bestDelta > toleranceHours * 60 * 60 * 1000) return null; + return best; +}; + +const getNormalizedGridValueAtTime = (gridProperty, targetTime) => { + if (!gridProperty) return null; + let value = getGridValueAtTime(gridProperty, targetTime); + if (value === null || value === undefined) { + value = getNearestGridValueAtTime(gridProperty, targetTime); + } + return convertNoaaValueByUom(value, gridProperty.uom); +}; + +const getGridFieldValueAtTime = (gridProperties, fieldName, targetTime) => { + const candidates = NOAA_GRID_FIELD_CANDIDATES[fieldName] ?? [fieldName]; + for (const candidate of candidates) { + const normalized = getNormalizedGridValueAtTime(gridProperties?.[candidate], targetTime); + if (normalized !== null && normalized !== undefined) return normalized; + } + return null; +}; + +const inferCloudCoverFromToken = (token) => ({ + ovc: 100, + bkn: 70, + sct: 45, + few: 20, + skc: 0, + wind_ovc: 100, + wind_bkn: 70, + wind_sct: 45, + wind_few: 20, + wind_skc: 0, +}[token] ?? null); + +const mapNoaaIconTokenToWeatherCode = (token) => ({ + weatherCode: NOAA_ICON_TO_WEATHER_CODE[token] ?? 0, + fallback: !(token in NOAA_ICON_TO_WEATHER_CODE), +}); + +const mapNoaaIconUrlToWeatherCode = (iconUrl, isNightTime) => { + try { + const { conditionIcon } = parseIconUrl(iconUrl, isNightTime); + const mapped = mapNoaaIconTokenToWeatherCode(conditionIcon); + return { + ...mapped, + token: conditionIcon, + }; + } catch { + return { + weatherCode: 0, + fallback: true, + token: 'unknown', + }; + } +}; + +const inferPrecipFieldsFromToken = (token, precipitationValue, snowfallValue) => { + const precipitation = precipitationValue ?? 0; + const snowfall = snowfallValue ?? 0; + return { + rain: ['rain', 'rain_fzra', 'rain_sleet', 'rain_snow'].includes(token) ? (precipitation || 1) : 0, + showers: ['rain_showers', 'rain_showers_hi', 'rain_showers_high'].includes(token) ? (precipitation || 1) : 0, + snowfall: ['snow', 'blizzard', 'snow_sleet', 'snow_fzra', 'rain_snow', 'winter_mix'].includes(token) ? (snowfall || 1) : snowfall, + }; +}; + +const parseNoaaHourlyPeriod = (period, gridProperties) => { + const targetTime = DateTime.fromISO(period.startTime); + const temperatureC = convertNoaaValueByUom(period.temperature, period.temperatureUnit === 'F' ? 'wmoUnit:degF' : 'wmoUnit:degC'); + const windSpeedKmh = parseNoaaWindSpeedString(period.windSpeed); + const windDirectionDegrees = parseNoaaWindDirectionString(period.windDirection); + const iconMapping = mapNoaaIconUrlToWeatherCode(period.icon, !period.isDaytime); + const precipitation = getGridFieldValueAtTime(gridProperties, 'precipitation', targetTime) ?? 0; + const snowfall = getGridFieldValueAtTime(gridProperties, 'snowfall', targetTime) ?? 0; + const precipFields = inferPrecipFieldsFromToken(iconMapping.token, precipitation, snowfall); + return { + time: period.startTime, + temperature_2m: temperatureC, + relative_humidity_2m: getGridFieldValueAtTime(gridProperties, 'relative_humidity_2m', targetTime), + dew_point_2m: getGridFieldValueAtTime(gridProperties, 'dew_point_2m', targetTime) ?? temperatureC, + apparent_temperature: getGridFieldValueAtTime(gridProperties, 'apparent_temperature', targetTime) ?? temperatureC, + precipitation_probability: period.probabilityOfPrecipitation?.value + ?? getGridFieldValueAtTime(gridProperties, 'precipitation_probability', targetTime) + ?? 0, + precipitation, + rain: precipFields.rain, + showers: precipFields.showers, + snowfall: precipFields.snowfall, + snow_depth: 0, + weather_code: iconMapping.weatherCode, + pressure_msl: getGridFieldValueAtTime(gridProperties, 'pressure_msl', targetTime), + surface_pressure: getGridFieldValueAtTime(gridProperties, 'surface_pressure', targetTime), + cloud_cover: getGridFieldValueAtTime(gridProperties, 'cloud_cover', targetTime) ?? inferCloudCoverFromToken(iconMapping.token), + visibility: getGridFieldValueAtTime(gridProperties, 'visibility', targetTime), + uv_index: getGridFieldValueAtTime(gridProperties, 'uv_index', targetTime) ?? 0, + is_day: period.isDaytime ? 1 : 0, + sunshine_duration: 0, + wind_speed_10m: windSpeedKmh ?? getGridFieldValueAtTime(gridProperties, 'wind_speed_10m', targetTime) ?? 0, + wind_direction_10m: windDirectionDegrees ?? getGridFieldValueAtTime(gridProperties, 'wind_direction_10m', targetTime) ?? 0, + wind_gusts_10m: getGridFieldValueAtTime(gridProperties, 'wind_gusts_10m', targetTime) ?? 0, + _weatherCodeFallback: iconMapping.fallback, + _sourceIconToken: iconMapping.token, + }; +}; + +const buildNormalizedForecastResponse = (hourlyEntries) => { + const hourly = { time: [] }; + HOURLY_FORECAST_FIELDS.forEach((field) => { + hourly[field] = []; + }); + const dailyBuckets = {}; + hourlyEntries.forEach((entry) => { + hourly.time.push(entry.time); + HOURLY_FORECAST_FIELDS.forEach((field) => { + hourly[field].push(entry[field] ?? null); + }); + const date = entry.time.split('T')[0]; + if (!dailyBuckets[date]) dailyBuckets[date] = []; + dailyBuckets[date].push(entry); + }); + const dailyDates = Object.keys(dailyBuckets).sort(); + const daily = { time: dailyDates }; + DAILY_FORECAST_FIELDS.forEach((field) => { + daily[field] = []; + }); + dailyDates.forEach((date) => { + const entries = dailyBuckets[date]; + const temps = entries.map((entry) => entry.temperature_2m).filter((value) => value !== null && value !== undefined); + const uvValues = entries.map((entry) => entry.uv_index ?? 0).filter((value) => value !== null && value !== undefined); + daily.temperature_2m_max.push(temps.length ? Math.max(...temps) : null); + daily.temperature_2m_min.push(temps.length ? Math.min(...temps) : null); + daily.uv_index_max.push(uvValues.length ? Math.max(...uvValues) : 0); + }); + return { hourly, daily }; +}; + +const percentValid = (entries, fieldName, validator = (value) => value !== null && value !== undefined) => { + if (!entries.length) return 0; + return entries.filter((entry) => validator(entry[fieldName])).length / entries.length; +}; + +const validateNormalizedNoaaForecast = (hourlyEntries, aggregatedForecast) => { + const reasons = []; + const hourlyCount = hourlyEntries.length; + const dailyCount = Object.values(aggregatedForecast ?? {}).filter((day) => day + && day.temperature_2m_max !== null + && day.temperature_2m_min !== null + && day.weather_code !== null + && day.weather_code !== undefined).length; + const timePct = percentValid(hourlyEntries, 'time'); + const temperaturePct = percentValid(hourlyEntries, 'temperature_2m', (value) => value !== null && value !== undefined && !Number.isNaN(value)); + const weatherCodePct = percentValid(hourlyEntries, 'weather_code', Number.isInteger); + const isDayPct = percentValid(hourlyEntries, 'is_day', (value) => value !== null && value !== undefined); + const windSpeedPct = percentValid(hourlyEntries, 'wind_speed_10m', (value) => value !== null && value !== undefined && !Number.isNaN(value)); + const windDirectionPct = percentValid(hourlyEntries, 'wind_direction_10m', (value) => value !== null && value !== undefined && !Number.isNaN(value)); + const precipProbabilityPct = percentValid(hourlyEntries, 'precipitation_probability', (value) => value !== null && value !== undefined && !Number.isNaN(value)); + const weatherCodeFallbackPct = hourlyCount ? hourlyEntries.filter((entry) => entry._weatherCodeFallback).length / hourlyCount : 1; + + if (hourlyCount < NOAA_FORECAST_MIN_HOURLY_COUNT) reasons.push('insufficient-hourly-count'); + if (dailyCount < NOAA_FORECAST_MIN_DAILY_COUNT) reasons.push('insufficient-daily-count'); + if (timePct < NOAA_FORECAST_CORE_COMPLETENESS_THRESHOLD) reasons.push('missing-core-time'); + if (temperaturePct < NOAA_FORECAST_CORE_COMPLETENESS_THRESHOLD) reasons.push('missing-core-temperature'); + if (weatherCodePct < NOAA_FORECAST_CORE_COMPLETENESS_THRESHOLD) reasons.push('missing-core-weather-code'); + if (isDayPct < NOAA_FORECAST_CORE_COMPLETENESS_THRESHOLD) reasons.push('missing-core-day-flag'); + if (windSpeedPct < NOAA_FORECAST_SUPPORT_COMPLETENESS_THRESHOLD) reasons.push('missing-support-wind-speed'); + if (windDirectionPct < NOAA_FORECAST_SUPPORT_COMPLETENESS_THRESHOLD) reasons.push('missing-support-wind-direction'); + if (precipProbabilityPct < NOAA_FORECAST_SUPPORT_COMPLETENESS_THRESHOLD) reasons.push('missing-support-precip-probability'); + if (weatherCodeFallbackPct > NOAA_FORECAST_MAX_FALLBACK_RATIO) reasons.push('too-many-weather-code-fallbacks'); + + return { + accepted: reasons.length === 0, + reasons, + stats: { + hourlyCount, + dailyCount, + timePct, + temperaturePct, + weatherCodePct, + isDayPct, + windSpeedPct, + windDirectionPct, + precipProbabilityPct, + weatherCodeFallbackPct, + }, + }; +}; + +const getNoaaHourlyForecast = async (forecastHourlyUrl) => safeJson(forecastHourlyUrl); + +const getNoaaDailyForecast = async (forecastUrl) => safeJson(forecastUrl); + +const getNoaaForecastGridData = async (gridUrl) => safeJson(gridUrl); + +const normalizeNoaaHourlyForecast = (hourlyForecast, gridData) => { + const periods = hourlyForecast?.properties?.periods ?? []; + const gridProperties = gridData?.properties ?? {}; + return periods.map((period) => parseNoaaHourlyPeriod(period, gridProperties)); +}; + +const getAggregatedNoaaForecast = async (pointProperties) => { + if (!pointProperties?.forecastHourly) return false; + const [hourlyForecast, gridData] = await Promise.all([ + getNoaaHourlyForecast(pointProperties.forecastHourly), + pointProperties.forecastGridData ? getNoaaForecastGridData(pointProperties.forecastGridData) : Promise.resolve(null), + ]); + if (!hourlyForecast?.properties?.periods?.length) return false; + const hourlyEntries = normalizeNoaaHourlyForecast(hourlyForecast, gridData); + const normalizedForecastResponse = buildNormalizedForecastResponse(hourlyEntries); + const aggregatedForecast = aggregateWeatherForecastData(normalizedForecastResponse); + if (!aggregatedForecast) return false; + const validation = validateNormalizedNoaaForecast(hourlyEntries, aggregatedForecast); + return { + hourlyEntries, + forecast: normalizedForecastResponse, + aggregatedForecast, + validation, + }; +}; + +const validateNoaaCurrentObservation = (observation, now = Date.now()) => { + if (!observation) { + return { accepted: false, reason: 'missing-observation', ageMinutes: null }; + } + const ageMinutes = observation.timestamp ? (now - new Date(observation.timestamp).getTime()) / 60000 : null; + const hasCondition = observation.weatherCode !== null && observation.weatherCode !== undefined; + if (observation.temperature === null || observation.temperature === undefined || observation.windSpeed === null || observation.windSpeed === undefined || observation.windDirection === null || observation.windDirection === undefined || !hasCondition) { + return { accepted: false, reason: 'missing-core-fields', ageMinutes }; + } + if (ageMinutes !== null && !Number.isNaN(ageMinutes) && ageMinutes > NOAA_OBSERVATION_MAX_AGE_MINUTES) { + return { accepted: false, reason: 'stale-observation', ageMinutes }; + } + return { accepted: true, reason: 'ok', ageMinutes }; +}; + +const normalizeNoaaObservation = (properties, source = 'noaa') => { + const iconMapping = mapNoaaIconUrlToWeatherCode(properties.icon, undefined); + let isDay = true; + try { + isDay = !parseIconUrl(properties.icon).isNightTime; + } catch { + isDay = true; + } + return { + timestamp: properties.timestamp, + temperature: convertNoaaValueByUom(properties.temperature?.value ?? null, properties.temperature?.unitCode), + dewPoint: convertNoaaValueByUom(properties.dewpoint?.value ?? null, properties.dewpoint?.unitCode), + relativeHumidity: properties.relativeHumidity?.value ?? null, + pressure: convertNoaaValueByUom( + properties.barometricPressure?.value ?? properties.seaLevelPressure?.value ?? null, + properties.barometricPressure?.unitCode ?? properties.seaLevelPressure?.unitCode + ), + visibility: convertNoaaValueByUom(properties.visibility?.value ?? null, properties.visibility?.unitCode), + windSpeed: convertNoaaValueByUom(properties.windSpeed?.value ?? null, properties.windSpeed?.unitCode), + windGust: convertNoaaValueByUom(properties.windGust?.value ?? null, properties.windGust?.unitCode), + windDirection: convertNoaaValueByUom(properties.windDirection?.value ?? null, properties.windDirection?.unitCode) ?? 0, + weatherCode: iconMapping.weatherCode, + icon: properties.icon, + textDescription: properties.textDescription ?? '', + isDay, + _source: source, + }; +}; + +const getNoaaCurrentObservation = async (stationFeature) => { + const stationUrl = stationFeature?.id ?? stationFeature?.properties?.['@id']; + if (!stationUrl) return false; + const latestObservationUrl = `${stationUrl.replace(/\/$/, '')}/observations/latest`; + return safeJson(latestObservationUrl); +}; + +const getBestUsCurrentObservation = async (weatherParameters) => { + const stationId = weatherParameters?.stationId; + const stationFeature = weatherParameters?.stations?.find((station) => station.properties?.stationIdentifier === stationId) + ?? weatherParameters?.stations?.[0]; + if (!stationFeature || !stationId) return false; + const cacheKey = stationId; + const now = Date.now(); + const cached = noaaCurrentObservationCache.get(cacheKey); + if (cached && (now - cached.fetchedAt) < NOAA_CURRENT_OBSERVATION_CACHE_TTL_MS) { + return cached.data; + } + const rawObservation = await getNoaaCurrentObservation(stationFeature); + if (!rawObservation?.properties) return false; + const enhanced = await enhanceObservationWithMapClick(rawObservation.properties, { + stationId, + debugContext: 'noaa-current-observation', + maxAgeMinutes: NOAA_OBSERVATION_MAX_AGE_MINUTES, + requiredFields: [ + { name: 'temperature', check: (data) => data.temperature?.value === null || data.temperature?.value === undefined }, + { name: 'windSpeed', check: (data) => data.windSpeed?.value === null || data.windSpeed?.value === undefined }, + { name: 'windDirection', check: (data) => data.windDirection?.value === null || data.windDirection?.value === undefined }, + { name: 'icon', check: (data) => !data.icon && !data.textDescription }, + { name: 'dewpoint', check: (data) => data.dewpoint?.value === null || data.dewpoint?.value === undefined, required: false }, + { name: 'humidity', check: (data) => data.relativeHumidity?.value === null || data.relativeHumidity?.value === undefined, required: false }, + { name: 'pressure', check: (data) => (data.barometricPressure?.value ?? data.seaLevelPressure?.value) === null || (data.barometricPressure?.value ?? data.seaLevelPressure?.value) === undefined, required: false }, + { name: 'visibility', check: (data) => data.visibility?.value === null || data.visibility?.value === undefined, required: false }, + { name: 'windGust', check: (data) => data.windGust?.value === null || data.windGust?.value === undefined, required: false }, + ], + maxOptionalMissing: 3, + }); + const normalized = normalizeNoaaObservation(enhanced.data, enhanced.wasImproved ? 'mapclick' : 'noaa'); + const validation = validateNoaaCurrentObservation(normalized, now); + const result = validation.accepted ? { observation: normalized, source: normalized._source, validation } : false; + if (result) { + noaaCurrentObservationCache.set(cacheKey, { data: result, fetchedAt: now }); + } + return result; +}; + const getOpenMeteoForecast = async (lat, lon) => { const forecast = await safeJson(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&${OPEN_METEO_FORECAST_PARAMETERS}`); if (!forecast) { @@ -253,6 +794,8 @@ const aggregateWeatherForecastData = (forecastResponse) => { export { getPoint, getOpenMeteoForecast, + getAggregatedNoaaForecast, + getBestUsCurrentObservation, getAggregatedOpenMeteoForecast, getCachedAggregatedOpenMeteoForecast, getOpenMeteoObservationSnapshot, @@ -260,4 +803,6 @@ export { getConditionText, getWindDescriptor, getConditionTextWithWind, + validateNormalizedNoaaForecast, + validateNoaaCurrentObservation, };