ws4kp-linhanced/server/scripts/modules/utils/derived-hazards.mjs
mrkmntal 8a3d8e3dac
Some checks are pending
build-docker / Build Image (push) Waiting to run
Update derived hazards to better handle tropical storms
2026-04-13 14:38:23 -04:00

262 lines
8.9 KiB
JavaScript

import { DateTime } from '../../vendor/auto/luxon.mjs';
const LOOKAHEAD_HOURS = 6;
const METERS_PER_MILE = 1609.344;
const KPH_PER_MPH = 1.609344;
const SEVERITY_RANK = {
Extreme: 2,
Severe: 1,
};
const RULE_PRIORITY = {
tropical: 6,
thunderstorm: 5,
freezing: 4,
snow: 3,
rain: 2,
wind: 1,
};
const WEATHER_CODES = {
freezing: new Set([56, 57, 66, 67]),
snow: new Set([71, 73, 75, 77, 85, 86]),
thunderstorm: new Set([95, 96, 99]),
rain: new Set([51, 53, 55, 61, 63, 65, 80, 81, 82]),
};
const thresholds = {
lowVisibilitySevere: 5 * METERS_PER_MILE,
lowVisibilityExtreme: 2 * METERS_PER_MILE,
gustSevere: 20 * KPH_PER_MPH,
gustExtreme: 35 * KPH_PER_MPH,
highWindSevere: 40 * KPH_PER_MPH,
highWindExtreme: 55 * KPH_PER_MPH,
tropicalWindSevere: 50,
tropicalWindExtreme: 63,
tropicalGustSevere: 75,
tropicalGustExtreme: 90,
tropicalPressureSevere: 1002,
tropicalPressureExtreme: 998,
freezingTempC: 1,
};
const buildDerivedHazard = ({
id,
severity,
description,
priority,
event = 'Severe Weather Alert',
}) => ({
id,
priority,
properties: {
event,
severity,
urgency: 'Expected',
description: `This is a derived local alert based on forecast conditions. ${description}`,
},
});
const getUpcomingHours = (weatherParameters) => {
const zone = weatherParameters?.timeZone || 'UTC';
const now = DateTime.now().setZone(zone);
const end = now.plus({ hours: LOOKAHEAD_HOURS });
const allHours = Object.values(weatherParameters?.forecast ?? {})
.flatMap((day) => day?.hours ?? [])
.map((hour) => ({
...hour,
forecastTime: DateTime.fromISO(hour.time, { zone }),
}))
.filter((hour) => hour.forecastTime.isValid)
.sort((a, b) => a.forecastTime.toMillis() - b.forecastTime.toMillis());
return allHours.filter((hour) => hour.forecastTime >= now && hour.forecastTime <= end);
};
const getWorstHour = (hours, evaluator) => hours.reduce((worst, hour) => {
const candidate = evaluator(hour);
if (!candidate) return worst;
if (!worst) return candidate;
if (SEVERITY_RANK[candidate.severity] > SEVERITY_RANK[worst.severity]) return candidate;
return worst;
}, null);
const evaluateThunderstorm = (hours) => getWorstHour(hours, (hour) => {
const code = Number(hour.weather_code ?? 0);
if (!WEATHER_CODES.thunderstorm.has(code)) return null;
if (code === 96 || code === 99) {
return {
severity: 'Extreme',
description: 'Thunderstorms with hail are possible in the next several hours and may create dangerous outdoor conditions.',
};
}
return {
severity: 'Severe',
description: 'Thunderstorms are possible in the next several hours and may create hazardous outdoor conditions.',
};
});
const evaluateFreezing = (hours) => getWorstHour(hours, (hour) => {
const code = Number(hour.weather_code ?? 0);
if (!WEATHER_CODES.freezing.has(code)) return null;
const temperature = hour.temperature_2m ?? Number.POSITIVE_INFINITY;
if (temperature > thresholds.freezingTempC) return null;
const visibility = hour.visibility ?? Number.POSITIVE_INFINITY;
const gusts = hour.wind_gusts_10m ?? 0;
const isExtreme = visibility <= thresholds.lowVisibilityExtreme || gusts >= thresholds.gustExtreme;
return {
severity: isExtreme ? 'Extreme' : 'Severe',
description: isExtreme
? 'Freezing precipitation with poor visibility or strong gusts is expected in the next several hours and may create dangerous travel conditions.'
: 'Freezing precipitation is expected in the next several hours and may create slippery travel conditions.',
};
});
const evaluateSnow = (hours) => getWorstHour(hours, (hour) => {
const code = Number(hour.weather_code ?? 0);
if (!WEATHER_CODES.snow.has(code)) return null;
const visibility = hour.visibility ?? Number.POSITIVE_INFINITY;
const gusts = hour.wind_gusts_10m ?? 0;
if (visibility <= thresholds.lowVisibilitySevere && gusts >= thresholds.gustSevere) {
return {
severity: visibility <= thresholds.lowVisibilityExtreme ? 'Extreme' : 'Severe',
description: visibility <= thresholds.lowVisibilityExtreme
? 'Snow, poor visibility, and gusty winds are expected in the next several hours and may create dangerous travel conditions.'
: 'Snow, reduced visibility, and gusty winds are expected in the next several hours and may create hazardous travel conditions.',
};
}
return null;
});
const evaluateRain = (hours) => getWorstHour(hours, (hour) => {
const code = Number(hour.weather_code ?? 0);
const visibility = hour.visibility ?? Number.POSITIVE_INFINITY;
const gusts = hour.wind_gusts_10m ?? 0;
const hasRain = WEATHER_CODES.rain.has(code) || (hour.rain ?? 0) > 0 || (hour.showers ?? 0) > 0;
if (!hasRain) return null;
if (visibility <= thresholds.lowVisibilityExtreme && gusts >= thresholds.gustExtreme) {
return {
severity: 'Extreme',
description: 'Heavy rain, very low visibility, and strong gusts are expected in the next several hours and may create dangerous travel conditions.',
};
}
if (visibility <= thresholds.lowVisibilitySevere && gusts >= thresholds.gustSevere) {
return {
severity: 'Severe',
description: 'Heavy rain, reduced visibility, and gusty winds are expected in the next several hours and may create hazardous travel conditions.',
};
}
return null;
});
const evaluateTropical = (hours) => getWorstHour(hours, (hour) => {
const code = Number(hour.weather_code ?? 0);
const sustainedWind = hour.wind_speed_10m ?? 0;
const gusts = hour.wind_gusts_10m ?? 0;
const pressureHpa = hour.pressure_msl ?? Number.POSITIVE_INFINITY;
const visibility = hour.visibility ?? Number.POSITIVE_INFINITY;
const hasRain = WEATHER_CODES.rain.has(code) || (hour.rain ?? 0) > 0 || (hour.showers ?? 0) > 0;
if (!hasRain) return null;
const meetsExtreme = (
(sustainedWind >= thresholds.tropicalWindExtreme || gusts >= thresholds.tropicalGustExtreme)
&& pressureHpa <= thresholds.tropicalPressureExtreme
);
if (meetsExtreme) {
return {
severity: 'Extreme',
description: visibility <= thresholds.lowVisibilitySevere
? 'Tropical storm conditions are expected in the next several hours, with very strong winds, heavy rain, poor visibility, and dangerous travel conditions.'
: 'Tropical storm conditions are expected in the next several hours, with very strong winds, heavy rain, and dangerous travel conditions.',
};
}
const meetsSevere = (
(sustainedWind >= thresholds.tropicalWindSevere || gusts >= thresholds.tropicalGustSevere)
&& pressureHpa <= thresholds.tropicalPressureSevere
);
if (meetsSevere) {
return {
severity: 'Severe',
description: 'Tropical storm conditions are possible in the next several hours, including heavy rain, strong winds, and dangerous travel conditions.',
};
}
return null;
});
const evaluateWind = (hours) => getWorstHour(hours, (hour) => {
const gusts = hour.wind_gusts_10m ?? 0;
if (gusts >= thresholds.highWindExtreme) {
return {
severity: 'Extreme',
description: 'Very strong wind gusts are expected in the next several hours and may create dangerous conditions for travel and outdoor activity.',
};
}
if (gusts >= thresholds.highWindSevere) {
return {
severity: 'Severe',
description: 'Strong wind gusts are expected in the next several hours and may create hazardous conditions for travel and outdoor activity.',
};
}
return null;
});
const deriveHazards = (weatherParameters) => {
const upcomingHours = getUpcomingHours(weatherParameters);
if (upcomingHours.length === 0) return [];
const tropicalCandidate = evaluateTropical(upcomingHours);
const thunderstormCandidate = evaluateThunderstorm(upcomingHours);
const freezingCandidate = evaluateFreezing(upcomingHours);
const snowCandidate = evaluateSnow(upcomingHours);
const rainCandidate = evaluateRain(upcomingHours);
const windCandidate = evaluateWind(upcomingHours);
const candidates = [
tropicalCandidate && buildDerivedHazard({
id: 'derived-severe-weather-alert-tropical',
priority: RULE_PRIORITY.tropical,
event: 'Tropical Storm Alert',
...tropicalCandidate,
}),
thunderstormCandidate && buildDerivedHazard({
id: 'derived-severe-weather-alert-thunderstorm',
priority: RULE_PRIORITY.thunderstorm,
...thunderstormCandidate,
}),
freezingCandidate && buildDerivedHazard({
id: 'derived-severe-weather-alert-freezing',
priority: RULE_PRIORITY.freezing,
...freezingCandidate,
}),
snowCandidate && buildDerivedHazard({
id: 'derived-severe-weather-alert-snow',
priority: RULE_PRIORITY.snow,
...snowCandidate,
}),
rainCandidate && buildDerivedHazard({
id: 'derived-severe-weather-alert-rain',
priority: RULE_PRIORITY.rain,
...rainCandidate,
}),
windCandidate && buildDerivedHazard({
id: 'derived-severe-weather-alert-wind',
priority: RULE_PRIORITY.wind,
...windCandidate,
}),
].filter(Boolean);
if (candidates.length === 0) return [];
candidates.sort((a, b) => {
const severityDiff = SEVERITY_RANK[b.properties.severity] - SEVERITY_RANK[a.properties.severity];
if (severityDiff !== 0) return severityDiff;
return b.priority - a.priority;
});
return [candidates[0]];
};
export default deriveHazards;