diff --git a/server/alert/tone.mp3 b/server/alert/tone.mp3 index 0564869..790d2b4 100644 Binary files a/server/alert/tone.mp3 and b/server/alert/tone.mp3 differ diff --git a/server/scripts/modules/hazards.mjs b/server/scripts/modules/hazards.mjs index 2e53998..174295c 100644 --- a/server/scripts/modules/hazards.mjs +++ b/server/scripts/modules/hazards.mjs @@ -3,6 +3,7 @@ import STATUS from './status.mjs'; import { setAlertToneActive } from './media.mjs'; import { safeJson } from './utils/fetch.mjs'; +import deriveHazards from './utils/derived-hazards.mjs'; import WeatherDisplay from './weatherdisplay.mjs'; import { registerDisplay } from './navigation.mjs'; import calculateScrollTiming from './utils/scroll-timing.mjs'; @@ -56,14 +57,6 @@ class Hazards extends WeatherDisplay { async getData(weatherParameters, refresh) { // super checks for enabled const superResult = super.getData(weatherParameters, refresh); - if (!this.weatherParameters?.supportsNoaaAlerts) { - this.data = []; - setAlertToneActive(false); - this.timing.totalScreens = 0; - this.getDataCallback(); - this.setStatus(STATUS.loaded); - return; - } // hazards performs a silent refresh, but does not fall back to a previous fetch if no data is available // this is intentional to ensure the latest alerts only are displayed. @@ -81,24 +74,28 @@ class Hazards extends WeatherDisplay { try { const previousSignature = this.alertSignature; - // get the forecast using centralized safe handling - const url = new URL('https://api.weather.gov/alerts/active'); - url.searchParams.append('point', `${this.weatherParameters.latitude},${this.weatherParameters.longitude}`); - url.searchParams.append('status', 'actual'); - const alerts = await safeJson(url, { retryCount: 3, stillWaiting: () => this.stillWaiting() }); - - if (!alerts) { - if (debugFlag('verbose-failures')) { - console.warn('Active Alerts request failed; assuming no active alerts'); - } - this.data = []; + if (!this.weatherParameters?.supportsNoaaAlerts) { + this.data = deriveHazards(this.weatherParameters); } else { - const allUnsortedAlerts = alerts.features ?? []; - const unsortedAlerts = allUnsortedAlerts.slice(0, 5); - const hasImmediate = unsortedAlerts.reduce((acc, hazard) => acc || hazard.properties.urgency === 'Immediate', false); - const sortedAlerts = unsortedAlerts.sort((a, b) => (calcSeverity(b.properties.severity, b.properties.event)) - (calcSeverity(a.properties.severity, a.properties.event))); - const filteredAlerts = sortedAlerts.filter((hazard) => hazard.properties.severity !== 'Unknown' && (!hasImmediate || (hazard.properties.urgency === 'Immediate'))); - this.data = filteredAlerts; + // get the forecast using centralized safe handling + const url = new URL('https://api.weather.gov/alerts/active'); + url.searchParams.append('point', `${this.weatherParameters.latitude},${this.weatherParameters.longitude}`); + url.searchParams.append('status', 'actual'); + const alerts = await safeJson(url, { retryCount: 3, stillWaiting: () => this.stillWaiting() }); + + if (!alerts) { + if (debugFlag('verbose-failures')) { + console.warn('Active Alerts request failed; assuming no active alerts'); + } + this.data = []; + } else { + const allUnsortedAlerts = alerts.features ?? []; + const unsortedAlerts = allUnsortedAlerts.slice(0, 5); + const hasImmediate = unsortedAlerts.reduce((acc, hazard) => acc || hazard.properties.urgency === 'Immediate', false); + const sortedAlerts = unsortedAlerts.sort((a, b) => (calcSeverity(b.properties.severity, b.properties.event)) - (calcSeverity(a.properties.severity, a.properties.event))); + const filteredAlerts = sortedAlerts.filter((hazard) => hazard.properties.severity !== 'Unknown' && (!hasImmediate || (hazard.properties.urgency === 'Immediate'))); + this.data = filteredAlerts; + } } this.alertSignature = getAlertSignature(this.data); setAlertToneActive(this.data.length > 0); diff --git a/server/scripts/modules/utils/derived-hazards.mjs b/server/scripts/modules/utils/derived-hazards.mjs new file mode 100644 index 0000000..6e1e73c --- /dev/null +++ b/server/scripts/modules/utils/derived-hazards.mjs @@ -0,0 +1,205 @@ +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 = { + 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, + freezingTempC: 1, +}; + +const buildDerivedHazard = ({ id, severity, description, priority }) => ({ + id, + priority, + properties: { + event: 'Severe Weather Alert', + 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 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 thunderstormCandidate = evaluateThunderstorm(upcomingHours); + const freezingCandidate = evaluateFreezing(upcomingHours); + const snowCandidate = evaluateSnow(upcomingHours); + const rainCandidate = evaluateRain(upcomingHours); + const windCandidate = evaluateWind(upcomingHours); + + const candidates = [ + 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;