ws4kp-linhanced/server/scripts/modules/utils/weather.mjs
mrkmntal 8958ef4d38
Some checks are pending
build-docker / Build Image (push) Waiting to run
feat: prefer NOAA data for US subject locations
2026-04-13 22:34:42 -04:00

808 lines
29 KiB
JavaScript

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',
'hourly=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',
'timezone=auto',
'models=best_match',
].join('&');
const OPEN_METEO_RADAR_OBSERVATION_PARAMETERS = [
'hourly=temperature_2m,weather_code,is_day,wind_speed_10m,wind_gusts_10m,wind_direction_10m',
'forecast_days=1',
'timezone=auto',
'models=best_match',
].join('&');
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)}`);
if (!point) {
if (debugFlag('verbose-failures')) {
console.warn(`Unable to get points for ${lat},${lon}`);
}
return false;
}
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) {
if (debugFlag('verbose-failures')) {
console.warn(`Unable to get Open-Meteo forecast for ${lat},${lon}`);
}
return false;
}
return forecast;
};
const getAggregatedOpenMeteoForecast = async (lat, lon) => {
const forecast = await getOpenMeteoForecast(lat, lon);
if (!forecast) return false;
const aggregatedForecast = aggregateWeatherForecastData(forecast);
if (!aggregatedForecast) {
if (debugFlag('verbose-failures')) {
console.warn(`Unable to aggregate Open-Meteo forecast for ${lat},${lon}`);
}
return false;
}
return {
forecast,
aggregatedForecast,
};
};
const getCachedAggregatedOpenMeteoForecast = async (lat, lon) => {
const cacheKey = `${lat.toFixed(4)},${lon.toFixed(4)}`;
const cachedEntry = openMeteoTravelForecastCache.get(cacheKey);
const now = Date.now();
if (cachedEntry && (now - cachedEntry.fetchedAt) < OPEN_METEO_TRAVEL_FORECAST_CACHE_TTL_MS) {
return cachedEntry.data;
}
const forecast = await getAggregatedOpenMeteoForecast(lat, lon);
if (forecast) {
openMeteoTravelForecastCache.set(cacheKey, {
data: forecast,
fetchedAt: now,
});
return forecast;
}
if (cachedEntry) {
return cachedEntry.data;
}
return false;
};
const getOpenMeteoObservationSnapshot = async (lat, lon) => {
const cacheKey = `${lat.toFixed(4)},${lon.toFixed(4)}`;
const cachedEntry = openMeteoObservationCache.get(cacheKey);
const now = Date.now();
if (cachedEntry && (now - cachedEntry.fetchedAt) < OPEN_METEO_OBSERVATION_CACHE_TTL_MS) {
return cachedEntry.data;
}
const forecast = await safeJson(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&${OPEN_METEO_RADAR_OBSERVATION_PARAMETERS}`);
if (!forecast?.hourly?.time?.length) {
if (debugFlag('verbose-failures')) {
console.warn(`Unable to get Open-Meteo radar observation snapshot for ${lat},${lon}`);
}
if (cachedEntry) {
return cachedEntry.data;
}
return false;
}
let nearestIndex = 0;
let nearestDelta = Number.POSITIVE_INFINITY;
forecast.hourly.time.forEach((time, index) => {
const delta = Math.abs(new Date(time).getTime() - now);
if (delta < nearestDelta) {
nearestDelta = delta;
nearestIndex = index;
}
});
const snapshot = {
time: forecast.hourly.time[nearestIndex],
temperature: forecast.hourly.temperature_2m?.[nearestIndex] ?? null,
weatherCode: forecast.hourly.weather_code?.[nearestIndex] ?? 0,
isDay: Boolean(forecast.hourly.is_day?.[nearestIndex] ?? 1),
windSpeed: forecast.hourly.wind_speed_10m?.[nearestIndex] ?? 0,
windGusts: forecast.hourly.wind_gusts_10m?.[nearestIndex] ?? 0,
windDirection: forecast.hourly.wind_direction_10m?.[nearestIndex] ?? 0,
timezone: forecast.timezone,
};
openMeteoObservationCache.set(cacheKey, {
data: snapshot,
fetchedAt: now,
});
return snapshot;
};
const weatherConditions = [
{ codes: [0], text: ['Clear'] },
{ codes: [1, 2, 3], text: ['Mostly Clear', 'Some Clouds', 'Overcast'] },
{ codes: [45, 48], text: ['Fog', 'Depositing rime fog'] },
{ codes: [51, 53, 55], text: ['Light Drizzle', 'Moderate Drizzle', 'Dense Drizzle'] },
{ codes: [56, 57], text: ['Light Freezing Drizzle', 'Dense Freezing Drizzle'] },
{ codes: [61, 63, 65], text: ['Slight Rain', 'Moderate Rain', 'Heavy Rain'] },
{ codes: [66, 67], text: ['Light Freezing Rain', 'Heavy Freezing Rain'] },
{ codes: [71, 73, 75], text: ['Slight Snow Fall', 'Moderate Snow Fall', 'Heavy Snow Fall'] },
{ codes: [77], text: ['Snow Grains'] },
{ codes: [80, 81, 82], text: ['Slight Rain Showers', 'Moderate Rain Showers', 'Violent Rain Showers'] },
{ codes: [85, 86], text: ['Slight Snow Showers', 'Heavy Snow Showers'] },
{ codes: [95], text: ['Thunderstorm'] },
{ codes: [96, 99], text: ['Thunderstorm with Slight Hail', 'Thunderstorm with Heavy Hail'] },
];
// Wind descriptor thresholds (km/h)
const getWindDescriptor = (windSpeedKmh, windGustsKmh) => {
// Use max of sustained wind or weighted gusts
const maxWind = Math.max(windSpeedKmh, (windGustsKmh || 0) * 0.8);
if (maxWind >= 56) return 'Very Windy';
if (maxWind >= 36) return 'Windy';
if (maxWind >= 21) return 'Breezy';
return null;
};
// Get condition text with wind descriptor
const getConditionTextWithWind = (weatherCode, windSpeedKmh, windGustsKmh) => {
const baseCondition = getConditionText(weatherCode);
const windDesc = getWindDescriptor(windSpeedKmh, windGustsKmh);
if (windDesc) {
// For clear sky conditions, just use the wind descriptor
if (weatherCode === 0) {
return windDesc;
}
return `${baseCondition} ${windDesc}`;
}
return baseCondition;
};
const getConditionText = (code) => {
const condition = weatherConditions.find((item) => item.codes.includes(Number(code)));
if (!condition) {
console.warn(`Unable to determine weather condition from code: ${code}`);
return 'Unknown Conditions';
}
const index = condition.codes.findIndex((item) => item === Number(code));
return condition.text[index];
};
const aggregateWeatherForecastData = (forecastResponse) => {
if (!forecastResponse?.hourly || !forecastResponse?.daily) {
console.warn('aggregateWeatherForecastData: missing hourly or daily forecast data.');
return null;
}
const { hourly, daily } = forecastResponse;
const keys = Object.keys(hourly).filter((key) => key !== 'time');
const dailyData = {};
hourly.time.forEach((timestamp, index) => {
const date = timestamp.split('T')[0];
if (!dailyData[date]) {
dailyData[date] = { hours: [], weather_code_counts: {} };
keys.forEach((key) => {
dailyData[date][key] = { sum: 0, count: 0 };
});
}
const hourData = { time: timestamp };
keys.forEach((key) => {
const value = hourly[key][index];
hourData[key] = value;
if (value !== null) {
dailyData[date][key].sum += value;
dailyData[date][key].count += 1;
}
});
if (hourly.weather_code?.[index] !== undefined && hourly.weather_code[index] !== null) {
const weatherCode = hourly.weather_code[index];
dailyData[date].weather_code_counts[weatherCode] = (dailyData[date].weather_code_counts[weatherCode] || 0) + 1;
}
dailyData[date].hours.push(hourData);
});
const dailyAverages = {};
Object.entries(dailyData).forEach(([date, data]) => {
dailyAverages[date] = { hours: data.hours };
keys.forEach((key) => {
const { sum, count } = data[key];
dailyAverages[date][key] = count > 0 ? sum / count : null;
});
const weatherCodes = Object.entries(data.weather_code_counts);
if (weatherCodes.length > 0) {
[dailyAverages[date].weather_code] = weatherCodes.reduce((a, b) => (b[1] > a[1] ? b : a));
}
});
daily.time.forEach((date, index) => {
if (!dailyAverages[date]) {
dailyAverages[date] = { hours: [] };
}
dailyAverages[date].temperature_2m_max = daily.temperature_2m_max[index];
dailyAverages[date].temperature_2m_min = daily.temperature_2m_min[index];
dailyAverages[date].uv_index_max = daily.uv_index_max[index];
});
return dailyAverages;
};
export {
getPoint,
getOpenMeteoForecast,
getAggregatedNoaaForecast,
getBestUsCurrentObservation,
getAggregatedOpenMeteoForecast,
getCachedAggregatedOpenMeteoForecast,
getOpenMeteoObservationSnapshot,
aggregateWeatherForecastData,
getConditionText,
getWindDescriptor,
getConditionTextWithWind,
validateNormalizedNoaaForecast,
validateNoaaCurrentObservation,
};