From 8750b1ddf001e36f8bfa2ed64e350a37b26cf033 Mon Sep 17 00:00:00 2001 From: full-bars <45684698+full-bars@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:48:45 -0700 Subject: [PATCH] fix: PNS max EF rating truncated at first tornado's && delimiter _handle_pns stopped searching for EF ratings at the first "&&" marker, meant to strip the trailing EF Scale legend. But "&&" also separates every individual tornado entry in multi-tornado damage survey PNS products, so only the first tornado's rating was ever considered. Confirmed live against KPBZ's July 21 2026 event, which reported "Max: EF0" despite containing an EF1 tornado later in the same product. --- CHANGELOG.md | 1 + cogs/reports.py | 6 +++++- tests/test_reports.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1481f7bd..24690087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to this project will be documented in this file. - **MD AI Summary Threads**: Mesoscale Discussion AI summaries now create a thread named `MD #{num}` on the MD message instead of posting a standalone message in the channel (#613, #614). The AI Analysis button detects the thread and posts inside it with an ephemeral link to the user. Falls back to channel posting when thread creation fails. ### Fixed +- **Wrong Max EF Rating in Multi-Tornado Damage Survey PNS**: `_handle_pns` stripped everything after the first `&&` marker, intended to cut the trailing "EF Scale" legend out of the search text. But NWS damage survey PNS products also use `&&` as the delimiter *between every individual tornado entry*, so on any product with more than one tornado, only the first tornado's rating was ever considered — the max EF shown could be lower than the actual strongest tornado in the survey if a later entry was stronger. Confirmed live against KPBZ's July 21, 2026 tornado event Update #3, which reported "Max: EF0" despite containing an EF1 tornado (Zanesville 1, Muskingum County, OH). Now only `EF SCALE:` / `THE ENHANCED FUJITA SCALE` truncate the search, leaving every tornado entry's rating intact. - **Tropical Product Posting Crash**: `post_tropical_product` crashed with `AttributeError: 'NoneType' object has no attribute 'splitlines'` on every real NHC product, because most product types (anything without a "SUMMARY OF..." section) parse to `summary: None`, and `dict.get(key, default)` doesn't apply the default when the key is present with a `None` value. No tropical product had ever successfully posted before this fix. - **Tropical Products Posting to Dev Channel**: `cogs/tropical.py` posted to the hardcoded dev channel unconditionally. Now posts to the production tropical channel by default. - **Wrong NHC Product Labels**: `TCP` (the actual Public Advisory — "SUMMARY OF ... ADVISORY NUMBER X") was mislabeled "PROBABILITIES", and `TCV` (the Tropical Cyclone Watch/Warning summary product) was mislabeled "ADVISORY", swapping the two most common tropical products' titles. Confirmed live against Hurricane Fausto Advisory 14 and Tropical Storm Bertha Advisory 12, both of which posted as "NHC PROBABILITIES" instead of "NHC ADVISORY". Corrected per NHC's own product descriptions (nhc.noaa.gov/aboutnhcprod.shtml). diff --git a/cogs/reports.py b/cogs/reports.py index 69154acf..13451c9c 100644 --- a/cogs/reports.py +++ b/cogs/reports.py @@ -291,8 +291,12 @@ async def _handle_pns(self, product_id: str, raw_text: str): return # 1. Strip boilerplate (EF Scale key) to avoid false EF5 matches + # NOTE: "&&" is NOT used as a stop marker here — multi-tornado PNS + # products use "&&" as the delimiter *between every tornado entry*, + # not just before the trailing legend, so stopping on it would + # truncate the search after the first tornado and miss the real max. search_text = raw_text - for stop_marker in ("&&", "EF SCALE:", "THE ENHANCED FUJITA SCALE"): + for stop_marker in ("EF SCALE:", "THE ENHANCED FUJITA SCALE"): m_stop = re.search(re.escape(stop_marker), raw_text, re.I) if m_stop: search_text = raw_text[: m_stop.start()] diff --git a/tests/test_reports.py b/tests/test_reports.py index e8ce4963..9b99a348 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -269,6 +269,48 @@ async def test_handle_pns_extracts_max_ef_rating(): assert "EF3" in embed.description +@pytest.mark.asyncio +async def test_handle_pns_extracts_max_ef_rating_across_ampamp_delimited_tornadoes(): + """Regression: "&&" separates every tornado entry in real PNS products, + not just the trailing EF Scale legend. The max EF must be found even when + a stronger tornado appears after an earlier "&&" than a weaker first one.""" + cog, channel = _make_cog() + + multi_pns = """\ +...NWS DAMAGE SURVEY FOR 07/21/2026 TORNADO EVENT... + +..First Tornado... + +Rating: EF0 +Estimated Peak Wind: 80 mph + +&& + +.Second Tornado... + +Rating: EF1 +Estimated Peak Wind: 105 mph + +&& + +EF Scale: The Enhanced Fujita Scale classifies tornadoes into the +following categories: + +EF0.....65 to 85 mph +EF1.....86 to 110 mph + +SUMMARY: Two tornadoes confirmed near Example City OK. +$$ +""" + with patch("cogs.reports.add_significant_event", AsyncMock()), patch( + "utils.state_store.find_matching_tornado", AsyncMock(return_value=None) + ), patch.object(cog, "_check_for_surveys", AsyncMock()): + await cog._handle_pns("202607232205-KPBZ-PNSPBZ", multi_pns) + + embed: discord.Embed = channel.send.call_args.kwargs["embed"] + assert "EF1" in embed.description + + @pytest.mark.asyncio async def test_handle_pns_parses_numerical_date_for_survey_check(): """MM/DD/YYYY date triggers _check_for_surveys with the correct ISO date."""