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 <noreply@anthropic.com>
This commit is contained in:
Matt Johnson (via Claude) 2026-06-08 06:37:03 +00:00
commit b3105c65f5
2 changed files with 100 additions and 13 deletions

View file

@ -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()) 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, wire = _render(event_type=event_type, area_desc=area_desc,
geocoder_city=ge.get("city"), county=county, state=state, 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) _attach_commit(data, cap_id=cap_id, event_log_row_id=log_id)
return wire return wire
@ -219,7 +260,8 @@ def handle_nws(envelope: dict, subject: str,
# Cold-start race: row exists but broadcast was previously dropped. # Cold-start race: row exists but broadcast was previously dropped.
wire = _render(event_type=event_type, area_desc=area_desc, wire = _render(event_type=event_type, area_desc=area_desc,
geocoder_city=ge.get("city"), county=county, state=state, 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) _attach_commit(data, cap_id=cap_id, event_log_row_id=log_id)
return wire return wire
@ -232,22 +274,66 @@ def handle_nws(envelope: dict, subject: str,
wire = _render(event_type=event_type, area_desc=area_desc, wire = _render(event_type=event_type, area_desc=area_desc,
geocoder_city=ge.get("city"), county=county, state=state, 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,
prefix="Active") prefix="Active", d=d)
_attach_commit(data, cap_id=cap_id, event_log_row_id=log_id) _attach_commit(data, cap_id=cap_id, event_log_row_id=log_id)
return wire return wire
return None return None
def _render(*, event_type, area_desc, geocoder_city, county, state, def _render(*, event_type, area_desc, geocoder_city, county, state,
expires_epoch, lat, lon, now, prefix: str = "") -> str: expires_epoch, lat, lon, now, prefix: str = "", d: dict = None) -> str:
emoji = _emoji_for_event(event_type) d = d or {}
anchor = _location_anchor(area_desc, geocoder_city, county, state) params = d.get("parameters") or {}
expires_seg = _format_expires_short(expires_epoch, now=now)
coords = "" # Emoji: prefer SAME event code, fall back to event_type substring match
if isinstance(lat, (int, float)) and isinstance(lon, (int, float)): same_code = ((d.get("eventCode") or {}).get("SAME") or [""])[0]
coords = f", @ {lat:.3f},{lon:.3f}" 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 "" 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: def _category_to_event_type(category_raw: str) -> str:

View file

@ -194,8 +194,9 @@ def test_commit_callback_updates_last_broadcast(mem_db):
assert el["handled"] == 1 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) env = _nws_env(severity_str="Severe", lat=42.500, lon=-114.460)
wire = handle_nws(env, env["subject"], data={}, now=1_000_000) 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() assert "until" in wire.lower()