From b3105c65f576502406e1170664a7dbbb8eeb73b8 Mon Sep 17 00:00:00 2001 From: "Matt Johnson (via Claude)" Date: Mon, 8 Jun 2026 06:37:03 +0000 Subject: [PATCH] nws: multi-line wire format with SAME emoji, NWS office, hazard/impact/instruction Replace single-line _render() with structured 6-line format: L1: SAME emoji + event type + NWS office (from WMO identifier) L2: area (first areaDesc segment, max 60 chars) L3: hazard (from HAZARD.../TORNADO... or maxWindGust/maxHailSize params) L4: impact (from IMPACT... in description) L5: expires L6: instruction (max 80 chars) Add module-level helpers: _SAME_EMOJI, _NWS_OFFICE_SHORT, _nws_office(), _parse_nws_description(). Emoji prefers SAME event code, falls back to _emoji_for_event() substring match. All _render() call sites pass d=d. Update test to match new format (coordinates removed from wire). Co-Authored-By: Claude Opus 4.6 --- meshai/central/nws_handler.py | 108 ++++++++++++++++++++++++++++++---- tests/test_nws_handler.py | 5 +- 2 files changed, 100 insertions(+), 13 deletions(-) diff --git a/meshai/central/nws_handler.py b/meshai/central/nws_handler.py index 572f56d..ade56e5 100644 --- a/meshai/central/nws_handler.py +++ b/meshai/central/nws_handler.py @@ -66,6 +66,46 @@ _EVENT_EMOJI = [ ] +_SAME_EMOJI = { + "TOR": "🌪️", "SVR": "⛈️", "FFW": "🌊", "FLW": "🌊", + "WSW": "❄️", "BZW": "❄️", "WCY": "❄️", "EWW": "💨", + "HWW": "💨", "FRW": "🔥", "SPS": "🌬️", "SMW": "⛈️", + "MAW": "🌊", "ADR": "⚠️", +} + +_NWS_OFFICE_SHORT = { + "KBOI": "Boise", "KPIH": "Pocatello", "KMSO": "Missoula", + "KOTX": "Spokane", "KSLC": "Salt Lake City", "KMFR": "Medford", + "KPDT": "Pendleton", "KSEW": "Seattle", +} + + +def _nws_office(params: dict) -> str: + try: + wmo = (params.get("WMOidentifier") or [""])[0] + code = wmo.split()[1] + return _NWS_OFFICE_SHORT.get(code, code[1:]) + except Exception: + return "" + + +def _parse_nws_description(description: str) -> dict: + result = {} + patterns = { + "hazard": r"HAZARD\.\.\.(.*?)(?=\n\n|\nSOURCE|\nIMPACT|\nLocations|$)", + "impact": r"IMPACT\.\.\.(.*?)(?=\n\n|\nLocations|$)", + "tornado": r"TORNADO\.\.\.(.*?)(?=\n\n|\n[A-Z]+\.\.\.|$)", + "tornado_threat": r"TORNADO DAMAGE THREAT\.\.\.(.*?)(?=\n\n|\n[A-Z]+\.\.\.|$)", + } + for key, pattern in patterns.items(): + m = re.search(pattern, description or "", re.DOTALL | re.IGNORECASE) + if m: + text = m.group(1).replace("\n", " ").strip() + if text: + result[key] = text[:80] + return result + + def _now() -> int: return int(time.time()) @@ -211,7 +251,8 @@ def handle_nws(envelope: dict, subject: str, ) wire = _render(event_type=event_type, area_desc=area_desc, geocoder_city=ge.get("city"), county=county, state=state, - expires_epoch=expires_epoch, lat=lat, lon=lon, now=now) + expires_epoch=expires_epoch, lat=lat, lon=lon, now=now, + d=d) _attach_commit(data, cap_id=cap_id, event_log_row_id=log_id) return wire @@ -219,7 +260,8 @@ def handle_nws(envelope: dict, subject: str, # Cold-start race: row exists but broadcast was previously dropped. wire = _render(event_type=event_type, area_desc=area_desc, geocoder_city=ge.get("city"), county=county, state=state, - expires_epoch=expires_epoch, lat=lat, lon=lon, now=now) + expires_epoch=expires_epoch, lat=lat, lon=lon, now=now, + d=d) _attach_commit(data, cap_id=cap_id, event_log_row_id=log_id) return wire @@ -232,22 +274,66 @@ def handle_nws(envelope: dict, subject: str, wire = _render(event_type=event_type, area_desc=area_desc, geocoder_city=ge.get("city"), county=county, state=state, expires_epoch=expires_epoch, lat=lat, lon=lon, now=now, - prefix="Active") + prefix="Active", d=d) _attach_commit(data, cap_id=cap_id, event_log_row_id=log_id) return wire return None def _render(*, event_type, area_desc, geocoder_city, county, state, - expires_epoch, lat, lon, now, prefix: str = "") -> str: - emoji = _emoji_for_event(event_type) - anchor = _location_anchor(area_desc, geocoder_city, county, state) - expires_seg = _format_expires_short(expires_epoch, now=now) - coords = "" - if isinstance(lat, (int, float)) and isinstance(lon, (int, float)): - coords = f", @ {lat:.3f},{lon:.3f}" + expires_epoch, lat, lon, now, prefix: str = "", d: dict = None) -> str: + d = d or {} + params = d.get("parameters") or {} + + # Emoji: prefer SAME event code, fall back to event_type substring match + same_code = ((d.get("eventCode") or {}).get("SAME") or [""])[0] + emoji = _SAME_EMOJI.get(same_code) or _emoji_for_event(event_type) + + # Office + office = _nws_office(params) + office_seg = f" — NWS {office}" if office else "" prefix_seg = f"{prefix}: " if prefix else "" - return f"{emoji} {prefix_seg}{event_type or 'Weather Alert'}: {anchor}, {expires_seg}{coords}" + + # Line 1 + line1 = f"{emoji} {prefix_seg}{event_type or 'Weather Alert'}{office_seg}" + + # Line 2: first areaDesc segment only, max 60 chars + area = area_desc or county or "" + area = area.split(";")[0].strip() + if len(area) > 60: + area = area[:57] + "..." + line2 = area + + # Parse description + desc = _parse_nws_description(d.get("description") or "") + + # Line 3: hazard + hazard = desc.get("tornado") or desc.get("hazard") or "" + if not hazard: + wind = (params.get("maxWindGust") or [""])[0] + hail = (params.get("maxHailSize") or [""])[0] + bits = [] + if wind and wind not in ("0 MPH", ""): bits.append(f"{wind} winds") + if hail and hail not in ("0.00", "0", ""): bits.append(f"{hail} in hail") + hazard = ". ".join(bits) + line3 = hazard + + # Line 4: impact + impact = desc.get("impact") or "" + line4 = impact + + # Line 5: expires + expires_seg = _format_expires_short(expires_epoch, now=now) if expires_epoch else "" + line5 = f"Expires: {expires_seg}" if expires_seg else "" + + # Line 6: instruction (max 80 chars) + instruction = (d.get("instruction") or "").strip() + if len(instruction) > 80: + instruction = instruction[:77] + "..." + line6 = instruction + + lines = [l for l in (line1, line2, line3, line4, line5, line6) if l] + return "\n".join(lines) def _category_to_event_type(category_raw: str) -> str: diff --git a/tests/test_nws_handler.py b/tests/test_nws_handler.py index 77b9731..9ab22c9 100644 --- a/tests/test_nws_handler.py +++ b/tests/test_nws_handler.py @@ -194,8 +194,9 @@ def test_commit_callback_updates_last_broadcast(mem_db): assert el["handled"] == 1 -def test_wire_includes_coords_and_expires(mem_db): +def test_wire_includes_event_area_and_expires(mem_db): env = _nws_env(severity_str="Severe", lat=42.500, lon=-114.460) wire = handle_nws(env, env["subject"], data={}, now=1_000_000) - assert "@ 42.500,-114.460" in wire + assert "Severe Thunderstorm Warning" in wire + assert "Twin Falls County" in wire assert "until" in wire.lower()