808 lines
29 KiB
JavaScript
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,
|
|
};
|