feat: prefer NOAA data for US subject locations
Some checks are pending
build-docker / Build Image (push) Waiting to run

This commit is contained in:
mrkmntal 2026-04-13 22:34:42 -04:00
commit 8958ef4d38
3 changed files with 632 additions and 10 deletions

View file

@ -7,7 +7,7 @@ import { registerDisplay } from './navigation.mjs';
import { import {
temperature, windSpeed, pressure, distanceKilometers, distanceMeters, temperature, windSpeed, pressure, distanceKilometers, distanceMeters,
} from './utils/units.mjs'; } from './utils/units.mjs';
import { getConditionTextWithWind } from './utils/weather.mjs'; import { getConditionTextWithWind, getBestUsCurrentObservation } from './utils/weather.mjs';
import { getLargeIconFromWmoCodeWithWind } from './icons.mjs'; import { getLargeIconFromWmoCodeWithWind } from './icons.mjs';
class CurrentWeather extends WeatherDisplay { class CurrentWeather extends WeatherDisplay {
@ -17,7 +17,7 @@ class CurrentWeather extends WeatherDisplay {
async getData(weatherParameters, refresh) { async getData(weatherParameters, refresh) {
const superResult = super.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.data) {
if (this.isEnabled) this.setStatus(STATUS.failed); if (this.isEnabled) this.setStatus(STATUS.failed);
@ -130,7 +130,51 @@ const getCurrentWeatherByHourFromTime = (data) => {
return closestTime; 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); const currentForecast = getCurrentWeatherByHourFromTime(weatherParameters);
if (!currentForecast) return null; if (!currentForecast) return null;

View file

@ -3,7 +3,12 @@ import noSleep from './utils/nosleep.mjs';
import STATUS from './status.mjs'; import STATUS from './status.mjs';
import { wrap } from './utils/calc.mjs'; import { wrap } from './utils/calc.mjs';
import { safeJson } from './utils/fetch.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 { debugFlag } from './utils/debug.mjs';
import settings from './settings.mjs'; import settings from './settings.mjs';
import { stopScreenAudio } from './media.mjs'; import { stopScreenAudio } from './media.mjs';
@ -118,11 +123,6 @@ const message = (data) => {
const getWeather = async (latLon, haveDataCallback) => { const getWeather = async (latLon, haveDataCallback) => {
const location = getStoredLocationMetadata() ?? getFallbackLocation(latLon); 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); if (typeof haveDataCallback === 'function') haveDataCallback(location);
@ -131,11 +131,42 @@ const getWeather = async (latLon, haveDataCallback) => {
let point = null; let point = null;
let stations = null; let stations = null;
let stationId = ''; let stationId = '';
let aggregatedForecast = null;
let resolvedTimeZone = '';
let primaryForecastSource = '';
let openMeteoForecast = null;
if (noaaEligibleLocation) { if (noaaEligibleLocation) {
point = await getPoint(latLon.lat, latLon.lon); 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) { if (noaaEligibleLocation && point?.properties?.observationStations) {
stations = await safeJson(point.properties.observationStations); stations = await safeJson(point.properties.observationStations);
stationId = stations?.features?.[0]?.properties?.stationIdentifier ?? ''; stationId = stations?.features?.[0]?.properties?.stationIdentifier ?? '';
@ -158,7 +189,7 @@ const getWeather = async (latLon, haveDataCallback) => {
weatherParameters.state = state; weatherParameters.state = state;
weatherParameters.country = location.country ?? ''; weatherParameters.country = location.country ?? '';
weatherParameters.countryCode = location.countryCode ?? ''; weatherParameters.countryCode = location.countryCode ?? '';
weatherParameters.timeZone = openMeteoForecast.timezone; weatherParameters.timeZone = resolvedTimeZone;
weatherParameters.forecast = aggregatedForecast; weatherParameters.forecast = aggregatedForecast;
weatherParameters.supportsNoaaAlerts = supportsNoaaAlerts; weatherParameters.supportsNoaaAlerts = supportsNoaaAlerts;
weatherParameters.supportsNoaaDisplays = !!(noaaEligibleLocation && point && stations?.features?.length); weatherParameters.supportsNoaaDisplays = !!(noaaEligibleLocation && point && stations?.features?.length);
@ -169,6 +200,8 @@ const getWeather = async (latLon, haveDataCallback) => {
weatherParameters.forecastGridData = point?.properties?.forecastGridData ?? ''; weatherParameters.forecastGridData = point?.properties?.forecastGridData ?? '';
weatherParameters.stations = stations?.features ?? []; weatherParameters.stations = stations?.features ?? [];
weatherParameters.relativeLocation = point?.properties?.relativeLocation?.properties ?? null; weatherParameters.relativeLocation = point?.properties?.relativeLocation?.properties ?? null;
weatherParameters.primaryForecastSource = primaryForecastSource;
weatherParameters.primaryObservationSource = '';
// update the main process for display purposes // update the main process for display purposes
populateWeatherParameters(weatherParameters, point?.properties); populateWeatherParameters(weatherParameters, point?.properties);

View file

@ -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 { safeJson } from './fetch.mjs';
import { debugFlag } from './debug.mjs'; import { debugFlag } from './debug.mjs';
import { enhanceObservationWithMapClick } from './mapclick.mjs';
const OPEN_METEO_FORECAST_PARAMETERS = [ const OPEN_METEO_FORECAST_PARAMETERS = [
'daily=temperature_2m_max,temperature_2m_min,uv_index_max', '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_OBSERVATION_CACHE_TTL_MS = 10 * 60 * 1000;
const OPEN_METEO_TRAVEL_FORECAST_CACHE_TTL_MS = 30 * 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 openMeteoObservationCache = new Map();
const openMeteoTravelForecastCache = 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 getPoint = async (lat, lon) => {
const point = await safeJson(`https://api.weather.gov/points/${lat.toFixed(4)},${lon.toFixed(4)}`); 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; 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 getOpenMeteoForecast = async (lat, lon) => {
const forecast = await safeJson(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&${OPEN_METEO_FORECAST_PARAMETERS}`); const forecast = await safeJson(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&${OPEN_METEO_FORECAST_PARAMETERS}`);
if (!forecast) { if (!forecast) {
@ -253,6 +794,8 @@ const aggregateWeatherForecastData = (forecastResponse) => {
export { export {
getPoint, getPoint,
getOpenMeteoForecast, getOpenMeteoForecast,
getAggregatedNoaaForecast,
getBestUsCurrentObservation,
getAggregatedOpenMeteoForecast, getAggregatedOpenMeteoForecast,
getCachedAggregatedOpenMeteoForecast, getCachedAggregatedOpenMeteoForecast,
getOpenMeteoObservationSnapshot, getOpenMeteoObservationSnapshot,
@ -260,4 +803,6 @@ export {
getConditionText, getConditionText,
getWindDescriptor, getWindDescriptor,
getConditionTextWithWind, getConditionTextWithWind,
validateNormalizedNoaaForecast,
validateNoaaCurrentObservation,
}; };