diff --git a/src/lib/api/nws.js b/src/lib/api/nws.js index 02d1db1..5b76598 100644 --- a/src/lib/api/nws.js +++ b/src/lib/api/nws.js @@ -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) } diff --git a/tests/nws.test.js b/tests/nws.test.js index b3e133e..1f11a12 100644 --- a/tests/nws.test.js +++ b/tests/nws.test.js @@ -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', () => {