Weight NWS weather-code mapping by precipitation probability

NWS text like 'Slight Chance Showers And Thunderstorms' (~17-24%) was mapping
straight to WMO 95 / thunderstorm icon, over-stating a mere chance of storms.
shortForecastToWmo now takes the period's precip probability (plus chance-like
wording) and downgrades low-chance storms to light showers (80) and low-chance
rain to light rain (61), keeping real/severe storms at 95. This also stops the
false thunderstorm alert banner for low-chance days. Today/Monday for Rogers
County now render as showers instead of storms.
This commit is contained in:
hermes-explorigin 2026-08-16 20:58:40 +00:00
parent 32f749444e
commit ebe81b6bfa
2 changed files with 48 additions and 11 deletions

View File

@ -57,14 +57,33 @@ export function parseWind(s) {
return Math.max(...nums.map(Number))
}
/** Map an NWS shortForecast string to a WMO code for icons/severity. */
export function shortForecastToWmo(text) {
/**
* Map an NWS shortForecast string to a WMO code for icons/severity.
* @param {string} text - NWS shortForecast text, e.g. "Slight Chance Showers And Thunderstorms"
* @param {number|null} prob - precipitation probability (0-100) for that period,
* so low-chance wording does not render as a full weather condition.
*/
export function shortForecastToWmo(text, prob = null) {
const t = (text || '').toLowerCase()
if (/thunderstorm|tstm|severe thunder/.test(t)) return /\bsevere\b/.test(t) ? 96 : 95
if (/freez|x-ice|ice pellets/.test(t) && /rain|drizzle|shower/.test(t)) return 66
if (/snow|blizzard|flurr|sleet|snow shower/.test(t)) return /heavy|blizzard/.test(t) ? 75 : 71
if (/freez|ice/.test(t)) return 66
if (/rain|shower|drizzle|sprinkl/.test(t)) return /heavy|strong|torrential/.test(t) ? 65 : /slight|isolated|scattered/.test(t) ? 61 : 63
const chanceLike = () =>
(prob != null && prob < 30) ||
/\bslight chance\b|chance of|scattered|isolated|fewer chance|\bchance\b/.test(t)
if (/thunderstorm|tstm/.test(t)) {
// An actual severe/thunderstorm is a real condition; a mere *chance* of
// one is just light showers until something organises.
const severe = /\bsevere\b|\bstrong\b|warning|watch/.test(t)
if (severe) return 95
if (chanceLike()) return 80 // light rain showers, not a full thunderstorm
return 95
}
if (/freez|x-ice|ice pellets|freezing/.test(t) && /rain|drizzle|shower/.test(t)) return 66
if (/snow|blizzard|flurr|snow shower/.test(t)) return /heavy|blizzard/.test(t) ? 75 : 71
if (/rain|shower|drizzle|sprinkl/.test(t)) {
if (/heavy|strong|torrential/.test(t)) return 65
if (chanceLike()) return 61
return 63
}
if (/fog|haze|smoke|brume/.test(t)) return 45
if (/partly (cloudy|sunny)/.test(t)) return 2
if (/overcast|covering|cloudy/.test(t)) return 3
@ -123,7 +142,7 @@ function buildDaily(periods, tz, units) {
else d.lows.push(c)
}
d.precip.push(prob(pd))
d.codes.push(shortForecastToWmo(pd.shortForecast))
d.codes.push(shortForecastToWmo(pd.shortForecast, prob(pd)))
const ws = parseWind(pd.windSpeed)
if (ws != null) d.winds.push(ws)
}
@ -166,7 +185,7 @@ export function normalizeNWS(src) {
temperature_2m: temp(cur.temperature, units),
apparent_temperature: apparent,
relative_humidity_2m: cur.relativeHumidity?.value ?? null,
weather_code: shortForecastToWmo(cur.shortForecast),
weather_code: shortForecastToWmo(cur.shortForecast, prob(cur)),
wind_speed_10m: ws != null ? speed(ws, units) : null,
wind_direction_10m: compassToDeg(cur.windDirection),
wind_gusts_10m: null,
@ -182,7 +201,7 @@ export function normalizeNWS(src) {
time.push(localTimeString(pd.startTime, tz))
temperature_2m.push(temp(pd.temperature, units))
precipitation_probability.push(prob(pd))
weather_code.push(shortForecastToWmo(pd.shortForecast))
weather_code.push(shortForecastToWmo(pd.shortForecast, prob(pd)))
const ws = parseWind(pd.windSpeed)
wind_speed_10m.push(ws != null ? speed(ws, units) : null)
}

View File

@ -18,12 +18,26 @@ describe('nws helpers', () => {
expect(shortForecastToWmo('Mostly Sunny')).toBe(2)
expect(shortForecastToWmo('Partly Cloudy')).toBe(2)
expect(shortForecastToWmo('Overcast')).toBe(3)
expect(shortForecastToWmo('Slight Chance Showers And Thunderstorms')).toBe(95)
expect(shortForecastToWmo('Heavy Rain')).toBe(65)
expect(shortForecastToWmo('Light Snow')).toBe(71)
expect(shortForecastToWmo('Areas of Fog')).toBe(45)
})
it('downgrades low-chance thunderstorms so they are not shown as storms', () => {
// The word matters less than the raw probability.
// 17% "slight chance" of showers/thunderstorms -> light rain showers, NOT a storm.
expect(shortForecastToWmo('Slight Chance Showers And Thunderstorms')).toBe(80)
expect(shortForecastToWmo('Slight Chance Showers And Thunderstorms', 17)).toBe(80)
expect(shortForecastToWmo('Thunderstorms', 20)).toBe(80)
expect(shortForecastToWmo('Isolated Thunderstorms', 20)).toBe(80)
// A real storm (wordy + high probability) stays a storm.
expect(shortForecastToWmo('Thunderstorms', 70)).toBe(95)
expect(shortForecastToWmo('Severe Thunderstorm Warning', 80)).toBe(95)
// Low-chance plain rain downgrades to light rain too.
expect(shortForecastToWmo('Chance of Rain', 15)).toBe(61)
expect(shortForecastToWmo('Showers And Thunderstorms', 50)).toBe(95)
})
it('compassToDeg maps compass points to degrees', () => {
expect(compassToDeg('N')).toBe(0)
expect(compassToDeg('NE')).toBe(45)
@ -69,6 +83,10 @@ describe('normalizeNWS (recorded live fixture for Rogers County, OK)', () => {
expect(Math.max(...data.daily.temperature_2m_max)).toBeLessThan(115)
expect(data.daily.temperature_2m_min[0]).toBe(75)
expect(data.daily.precipitation_probability_max[0]).toBe(24)
// Today/Monday are only ~17-24% "slight chance" of storms -> light showers.
expect(data.daily.weather_code[0]).toBe(80)
expect(data.daily.weather_code[1]).toBe(80)
expect(data.current.weather_code).toBe(2) // "Mostly Sunny" hour
})
it('builds a realistic current from the first hourly period', () => {