mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
fix(nws): keep weather alerts in one packet without dropping storm path (#1)
Weather alerts ran ~250-310 chars and were blind-sliced to 200 in the central consumer, silently dropping storm motion + the impacted-town list. The town list was also pre-capped to 80 chars at parse time, destroying the middle/end of the storm path before formatting. - nws_handler: preserve the full impacted-town list; when the message overflows one mesh packet, sample the path (first -> middle -> last) instead of truncating the tail, so both path endpoints survive - consumer: pass precomposed titles through verbatim (no [:200] chop) - adapter_config: add nws.single_packet_max_chars (default 200) - tests: path-sampling + short-list coverage Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e6bc101194
commit
3c3ad3f42e
4 changed files with 153 additions and 21 deletions
|
|
@ -586,6 +586,11 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
|
||||||
"type": "int",
|
"type": "int",
|
||||||
"description": "Maximum characters for the area field on line 2 of NWS wire. Truncates at last word boundary.",
|
"description": "Maximum characters for the area field on line 2 of NWS wire. Truncates at last word boundary.",
|
||||||
},
|
},
|
||||||
|
("nws", "single_packet_max_chars"): {
|
||||||
|
"default": 200,
|
||||||
|
"type": "int",
|
||||||
|
"description": "Maximum characters for a single NWS mesh packet. Budget enforced after all fields are assembled; only the locations town-list is trimmed to fit.",
|
||||||
|
},
|
||||||
|
|
||||||
# =================================================================
|
# =================================================================
|
||||||
# AVALANCHE -- 1 setting (min danger level broadcast floor)
|
# AVALANCHE -- 1 setting (min danger level broadcast floor)
|
||||||
|
|
|
||||||
|
|
@ -608,7 +608,7 @@ class CentralConsumer:
|
||||||
data["_meshai_precomposed"] = True
|
data["_meshai_precomposed"] = True
|
||||||
|
|
||||||
kwargs = dict(
|
kwargs = dict(
|
||||||
title=str(title)[:200],
|
title=str(title) if data.get("_meshai_precomposed") else str(title)[:200],
|
||||||
summary="",
|
summary="",
|
||||||
lat=lat,
|
lat=lat,
|
||||||
lon=lon,
|
lon=lon,
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,9 @@ def _parse_nws_description(description: str) -> dict:
|
||||||
if m:
|
if m:
|
||||||
text = m.group(1).replace("\n", " ").strip()
|
text = m.group(1).replace("\n", " ").strip()
|
||||||
if text:
|
if text:
|
||||||
result[key] = text[:80]
|
# Preserve the FULL town list for path-sampling in _render();
|
||||||
|
# all other fields keep the 80-char cap.
|
||||||
|
result[key] = text[:400] if key == "locations" else text[:80]
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -401,27 +403,57 @@ def _render(*, event_type, area_desc, geocoder_city, county, state,
|
||||||
cert_seg = f" | {certainty}"
|
cert_seg = f" | {certainty}"
|
||||||
line3 = f"{hazard_text}{cert_seg}" if hazard_text else ""
|
line3 = f"{hazard_text}{cert_seg}" if hazard_text else ""
|
||||||
|
|
||||||
# Line 4: motion + locations
|
# Line 4: motion + locations (path-sampled if the full town list won't fit).
|
||||||
compass, speed_mph = _parse_motion(params)
|
compass, speed_mph = _parse_motion(params)
|
||||||
motion = f"Moving {compass} {speed_mph} mph" if compass and speed_mph else ""
|
motion = f"Moving {compass} {speed_mph} mph" if compass and speed_mph else ""
|
||||||
locations = (desc.get("locations") or "").rstrip("., ")
|
|
||||||
_loc_limit = int(adapter_config.nws.locations_max_chars)
|
|
||||||
if len(locations) > _loc_limit:
|
|
||||||
cut = locations[:_loc_limit].rsplit(" ", 1)[0]
|
|
||||||
if not cut:
|
|
||||||
cut = locations[:_loc_limit]
|
|
||||||
locations = cut + "\u2026"
|
|
||||||
if motion and locations:
|
|
||||||
line4 = f"{motion} — {locations}"
|
|
||||||
elif motion:
|
|
||||||
line4 = motion
|
|
||||||
elif locations:
|
|
||||||
line4 = locations
|
|
||||||
else:
|
|
||||||
line4 = ""
|
|
||||||
|
|
||||||
lines = [l for l in (line1, line2, line3, line4) if l]
|
# Parse the (now-full) locations string into an ordered town list. Path
|
||||||
return "\n".join(lines)
|
# order = soonest-impact first ... farthest-along last. The tail element
|
||||||
|
# frequently starts with "and " (e.g. "and Shoshone") -> strip that.
|
||||||
|
raw = (desc.get("locations") or "").rstrip("., ")
|
||||||
|
towns = [t.strip() for t in raw.split(",") if t.strip()]
|
||||||
|
if towns:
|
||||||
|
towns[-1] = re.sub(r"^and\s+", "", towns[-1], flags=re.IGNORECASE).strip()
|
||||||
|
towns = [t for t in towns if t]
|
||||||
|
|
||||||
|
def _line4(locs: str) -> str:
|
||||||
|
if motion and locs:
|
||||||
|
return f"{motion} — {locs}"
|
||||||
|
if motion:
|
||||||
|
return motion
|
||||||
|
return locs or ""
|
||||||
|
|
||||||
|
PACKET_LIMIT = int(getattr(adapter_config.nws, "single_packet_max_chars", 200))
|
||||||
|
|
||||||
|
# Prefer the FULL town list; use it verbatim if the whole message fits.
|
||||||
|
full_locs = ", ".join(towns)
|
||||||
|
line4 = _line4(full_locs)
|
||||||
|
msg = "\n".join(l for l in (line1, line2, line3, line4) if l)
|
||||||
|
|
||||||
|
if len(msg) > PACKET_LIMIT:
|
||||||
|
# Won't fit: collapse locations to a path sample that never loses the
|
||||||
|
# endpoints -> soonest-impact -> midway -> farthest-along.
|
||||||
|
if len(towns) >= 3:
|
||||||
|
sampled = [towns[0], towns[len(towns) // 2], towns[-1]]
|
||||||
|
elif len(towns) == 2:
|
||||||
|
sampled = [towns[0], towns[-1]]
|
||||||
|
else:
|
||||||
|
sampled = list(towns)
|
||||||
|
# De-dup consecutive repeats (a short list can make first == middle),
|
||||||
|
# so we never render "Buhl → Buhl → Shoshone".
|
||||||
|
deduped = []
|
||||||
|
for t in sampled:
|
||||||
|
if not deduped or deduped[-1] != t:
|
||||||
|
deduped.append(t)
|
||||||
|
path = " → ".join(deduped)
|
||||||
|
line4 = _line4(path)
|
||||||
|
msg = "\n".join(l for l in (line1, line2, line3, line4) if l)
|
||||||
|
|
||||||
|
# Final hard-cap safety net for the pathological case (extremely long town
|
||||||
|
# names overflow even the first->middle->last sample plus the other lines).
|
||||||
|
if len(msg) > PACKET_LIMIT:
|
||||||
|
msg = msg[:PACKET_LIMIT - 1].rstrip() + "…"
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
def _category_to_event_type(category_raw: str) -> str:
|
def _category_to_event_type(category_raw: str) -> str:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Tests for v0.5.10 NWS handler."""
|
"""Tests for v0.5.10 NWS handler."""
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from meshai.central.nws_handler import handle_nws, _emoji_for_event
|
from meshai.central.nws_handler import handle_nws, _emoji_for_event, _render
|
||||||
from meshai.persistence import close_thread_connection, init_db
|
from meshai.persistence import close_thread_connection, init_db
|
||||||
from meshai.persistence import db as persistence_db
|
from meshai.persistence import db as persistence_db
|
||||||
|
|
||||||
|
|
@ -239,3 +239,98 @@ def test_non_warning_category_no_severity_override(mem_db):
|
||||||
wire = handle_nws(env, env["subject"], data=data, now=3_000_000)
|
wire = handle_nws(env, env["subject"], data=data, now=3_000_000)
|
||||||
assert wire is not None
|
assert wire is not None
|
||||||
assert "_severity_override" not in data
|
assert "_severity_override" not in data
|
||||||
|
|
||||||
|
|
||||||
|
# ---- packet-budget enforcement ----
|
||||||
|
|
||||||
|
|
||||||
|
def test_svr_long_locations_path_sampled(mem_db):
|
||||||
|
"""SVR with a long town list: render must fit in 200 chars, and the town
|
||||||
|
list must be represented as a PATH SAMPLE (first -> middle -> last) rather
|
||||||
|
than a tail-drop. The old bug dropped the final town ('Shoshone')."""
|
||||||
|
# Long list; first town "Buhl", last town "and Shoshone" (exercises the
|
||||||
|
# leading-"and " strip on the tail element).
|
||||||
|
long_locations = (
|
||||||
|
"Buhl, Eden, Hazelton, Murtaugh, Richfield, Dietrich, "
|
||||||
|
"Gooding, Hagerman, Wendell, and Shoshone"
|
||||||
|
)
|
||||||
|
description = (
|
||||||
|
"HAZARD...Damaging winds to 60 mph and quarter-size hail.\n\n"
|
||||||
|
f"Locations impacted include...{long_locations}"
|
||||||
|
)
|
||||||
|
d = {
|
||||||
|
"eventCode": {"SAME": ["SVR"]},
|
||||||
|
"certainty": "Observed",
|
||||||
|
"parameters": {
|
||||||
|
"maxWindGust": ["60 MPH"],
|
||||||
|
"maxHailSize": ["1.00"],
|
||||||
|
# 254 DEG 35 KT -> "Moving W 40 mph"
|
||||||
|
"eventMotionDescription": ["2200000T254DEG...35KT 42.5,-114.5"],
|
||||||
|
},
|
||||||
|
"description": description,
|
||||||
|
}
|
||||||
|
rendered = _render(
|
||||||
|
event_type="Severe Thunderstorm Warning",
|
||||||
|
area_desc="Twin Falls County",
|
||||||
|
geocoder_city=None,
|
||||||
|
county="Twin Falls",
|
||||||
|
state="ID",
|
||||||
|
expires_epoch=1_751_400_000,
|
||||||
|
lat=42.5,
|
||||||
|
lon=-114.46,
|
||||||
|
now=1_751_400_000,
|
||||||
|
d=d,
|
||||||
|
)
|
||||||
|
|
||||||
|
# (a) fits in one mesh packet
|
||||||
|
assert len(rendered) <= 200, (
|
||||||
|
f"rendered is {len(rendered)} chars (expected <= 200):\n{rendered!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# (b) all data-point categories present
|
||||||
|
assert "Severe Thunderstorm Warning" in rendered, "event type missing"
|
||||||
|
assert "Until" in rendered, "expiry time segment missing"
|
||||||
|
assert "Twin Falls County" in rendered, "area missing"
|
||||||
|
assert "mph winds" in rendered or "hail" in rendered, "hazard segment missing"
|
||||||
|
assert "Moving" in rendered, "motion segment missing"
|
||||||
|
|
||||||
|
# (c) path-sampling, not tail-drop: first town, last town, and the arrow
|
||||||
|
assert "→" in rendered, "no arrow -> not path-sampled"
|
||||||
|
assert "Buhl" in rendered, "first town missing"
|
||||||
|
assert "Shoshone" in rendered, "last town dropped (the old tail-trim bug)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_svr_short_locations_shown_in_full(mem_db):
|
||||||
|
"""Short town list that fits in one packet: show the FULL comma-joined
|
||||||
|
list, and never emit the path-sample arrow."""
|
||||||
|
short_locations = "Buhl, Eden, and Hazelton"
|
||||||
|
description = (
|
||||||
|
"HAZARD...Damaging winds to 60 mph and quarter-size hail.\n\n"
|
||||||
|
f"Locations impacted include...{short_locations}"
|
||||||
|
)
|
||||||
|
d = {
|
||||||
|
"eventCode": {"SAME": ["SVR"]},
|
||||||
|
"certainty": "Observed",
|
||||||
|
"parameters": {
|
||||||
|
"maxWindGust": ["60 MPH"],
|
||||||
|
"maxHailSize": ["1.00"],
|
||||||
|
"eventMotionDescription": ["2200000T254DEG...35KT 42.5,-114.5"],
|
||||||
|
},
|
||||||
|
"description": description,
|
||||||
|
}
|
||||||
|
rendered = _render(
|
||||||
|
event_type="Severe Thunderstorm Warning",
|
||||||
|
area_desc="Twin Falls County",
|
||||||
|
geocoder_city=None,
|
||||||
|
county="Twin Falls",
|
||||||
|
state="ID",
|
||||||
|
expires_epoch=1_751_400_000,
|
||||||
|
lat=42.5,
|
||||||
|
lon=-114.46,
|
||||||
|
now=1_751_400_000,
|
||||||
|
d=d,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(rendered) <= 200
|
||||||
|
assert "→" not in rendered, "short list should not be path-sampled"
|
||||||
|
assert "Buhl, Eden, Hazelton" in rendered, "full comma-joined list expected"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue