diff --git a/meshai/notifications/env_reporter.py b/meshai/notifications/env_reporter.py index ed1a5aa..2848800 100644 --- a/meshai/notifications/env_reporter.py +++ b/meshai/notifications/env_reporter.py @@ -179,34 +179,17 @@ class EnvReporter: _cols = ("irwin_id, incident_name, current_acres, " "current_contained_pct, lat, lon, county, state, " "declared_at, last_event_at") - _where = "last_event_at >= ? AND tombstoned_at IS NULL" - # Query R: most-recent fires first. - recent = conn.execute( + _where = ("last_event_at >= ? AND tombstoned_at IS NULL " + "AND (current_contained_pct IS NULL OR current_contained_pct < 100)") + rows = conn.execute( f"SELECT {_cols} FROM fires WHERE {_where} " - "ORDER BY last_event_at DESC LIMIT 6", - (_cutoff,), + "ORDER BY last_event_at DESC LIMIT ?", + (_cutoff, limit), ).fetchall() - # Query L: largest fires first. - largest = conn.execute( - f"SELECT {_cols} FROM fires WHERE {_where} " - "ORDER BY current_acres DESC NULLS LAST LIMIT 6", - (_cutoff,), - ).fetchall() - # Merge: all of R in order, then L rows not already present. - seen_ids: set[str] = set() - merged: list[sqlite3.Row] = [] - for r in recent: - if r["irwin_id"] not in seen_ids: - seen_ids.add(r["irwin_id"]) - merged.append(r) - for r in largest: - if r["irwin_id"] not in seen_ids: - seen_ids.add(r["irwin_id"]) - merged.append(r) - if merged: - lines.append("ACTIVE WILDFIRES (WFIGS, last 7d, most recent first, then largest):") - for r in merged: + if rows: + lines.append("ACTIVE WILDFIRES (WFIGS, last 7d, most recent first):") + for r in rows: name = r["incident_name"] or "(unnamed)" acres = "?" if r["current_acres"] is None else f"{int(r['current_acres']):,} ac" cont = "?" if r["current_contained_pct"] is None else f"{r['current_contained_pct']}%" diff --git a/meshai/notifications/scheduled/fire_digest.py b/meshai/notifications/scheduled/fire_digest.py index a6d08b1..38ea967 100644 --- a/meshai/notifications/scheduled/fire_digest.py +++ b/meshai/notifications/scheduled/fire_digest.py @@ -82,43 +82,55 @@ async def render_digest(*, now: Optional[int] = None) -> tuple[str, str]: now = now if now is not None else int(time.time()) conn = get_db() + cutoff = now - 7 * 86400 rows = conn.execute( "SELECT incident_name, current_acres, current_contained_pct, county, state " - "FROM fires WHERE tombstoned_at IS NULL " + "FROM fires WHERE last_event_at >= ? " + "AND tombstoned_at IS NULL " "AND (current_contained_pct IS NULL OR current_contained_pct < 100) " - "AND last_event_at > strftime('%s', 'now', '-7 days') " - "ORDER BY current_acres DESC NULLS LAST LIMIT 20" + "ORDER BY last_event_at DESC", + (cutoff,), ).fetchall() if not rows: return "", "no_fires" - n = len(rows) - header = f"\U0001f525 Fire Digest \u2014 {n} active wildfire(s) in Idaho" + total = len(rows) + top = rows[:2] + remaining = total - len(top) + + header = f"\U0001f525 Fire Digest \u2014 {total} active wildfire(s)" fire_lines: list[str] = [] - for row in rows: + for row in top: name = row["incident_name"] or "(unnamed)" anchor = f"{row['county']} Co" if row["county"] else row["state"] or "" - contained = f"{int(row['current_contained_pct'])}% cont" if row["current_contained_pct"] is not None else "uncontained" - acres = f"{int(row['current_acres']):,} ac" if row["current_acres"] else "size unknown" + contained = (f"{int(row['current_contained_pct'])}% cont" + if row["current_contained_pct"] is not None else "uncontained") + acres = (f"{int(row['current_acres']):,} ac" + if row["current_acres"] else "size unknown") fire_lines.append(f"{name}: {acres}, {contained}, {anchor}") - # 220-byte budget: greedily fit lines + tail = "" + if remaining > 0: + tail = f"There are {remaining} additional wildfires. DM me for the full list." + + # Assemble within ~200-byte LoRa budget; trim fire lines, never the tail shown: list[str] = [] for line in fire_lines: - remaining = n - len(shown) - 1 - overflow = f"\n+ {remaining} more" if remaining > 0 else "" - candidate = "\n".join([header] + shown + [line]) + overflow - if len(candidate.encode("utf-8")) <= 220: + parts = [header] + shown + [line] + if tail: + parts.append(tail) + candidate = "\n".join(parts) + if len(candidate.encode("utf-8")) <= 200: shown.append(line) else: break - remaining = n - len(shown) - lines = [header] + shown - if remaining > 0: - lines.append(f"+ {remaining} more") - wire = "\n".join(lines) + + parts = [header] + shown + if tail: + parts.append(tail) + wire = "\n".join(parts) return wire, "deterministic" diff --git a/tests/test_env_reporter_fire_recency.py b/tests/test_env_reporter_fire_recency.py index 4bb2530..91f2156 100644 --- a/tests/test_env_reporter_fire_recency.py +++ b/tests/test_env_reporter_fire_recency.py @@ -1,8 +1,8 @@ -"""Tests for the hybrid recent+largest fire context in build_fires_detail. +"""Tests for recency-only fire ordering in build_fires_detail. -Validates that the env_reporter now surfaces small fresh fires alongside -large historic ones, excludes tombstoned fires, deduplicates, and respects -the block character cap. +Validates that the env_reporter surfaces fires ordered strictly by +last_event_at descending, excludes 100%-contained and tombstoned fires, +and respects the block character cap. """ from __future__ import annotations @@ -64,51 +64,56 @@ def _seed_scenario(conn): county="Lincoln", state="ID") -class TestFireRecencyHybrid: - """T1-T5: hybrid recent+largest query tests.""" +class TestFireRecencyOnly: + """T1-T5: recency-only ordering + contained exclusion.""" - def test_t1_fresh_tiny_fires_appear(self, reporter): - """T1: Both fresh tiny fires appear in build_fires_detail output.""" + def test_t1_output_strictly_recency_ordered(self, reporter): + """All listed fires appear in last_event_at descending order.""" conn = get_db() _seed_scenario(conn) text = reporter.build_fires_detail() - assert "Bingham Co. Assist 3" in text, ( - "Fresh tiny fire 'Bingham Co. Assist 3' missing from output") - assert "IA 1" in text, ( - "Fresh tiny fire 'IA 1' missing from output") + # Fresh fires (1h, 2h ago) must appear before older fires (3-6d) + pos_fresh1 = text.index("Bingham Co. Assist 3") + pos_fresh2 = text.index("IA 1") + assert pos_fresh1 < pos_fresh2, ( + "Most recent fire should appear before second most recent") + # Any old fire that appears must come after both fresh fires + for i in range(5, 11): # only non-contained old fires + name = f"Old Fire {i}" + if name in text: + pos_old = text.index(name) + assert pos_fresh2 < pos_old, ( + f"Fresh fires should appear before '{name}'") - def test_t2_largest_non_tombstoned_appears(self, reporter): - """T2: The largest non-tombstoned fire appears (hybrid keeps big fires).""" + def test_t2_contained_100_excluded(self, reporter): + """Fires with current_contained_pct == 100 are excluded.""" conn = get_db() _seed_scenario(conn) text = reporter.build_fires_detail() - # Old Fire 10 has acres=14050, the largest non-tombstoned - assert "Old Fire 10" in text, ( - "Largest non-tombstoned fire 'Old Fire 10' missing from output") + # Old fires 0-4 have contained=100 + for i in range(5): + assert f"Old Fire {i}:" not in text, ( + f"100%-contained 'Old Fire {i}' should be excluded") + + def test_t2b_null_containment_included(self, reporter): + """Fires with NULL containment (uncontained) are included.""" + conn = get_db() + _seed_scenario(conn) + text = reporter.build_fires_detail() + assert "Bingham Co. Assist 3" in text + assert "IA 1" in text def test_t3_tombstoned_excluded(self, reporter): - """T3: The tombstoned fire does NOT appear.""" + """Tombstoned fires do not appear.""" conn = get_db() _seed_scenario(conn) text = reporter.build_fires_detail() - assert "Tombstoned Blaze" not in text, ( - "Tombstoned fire should be excluded") + assert "Tombstoned Blaze" not in text - def test_t4_no_duplicates(self, reporter): - """T4: No incident is listed twice (dedup by irwin_id).""" - conn = get_db() - _seed_scenario(conn) - text = reporter.build_fires_detail() - # Each fire name should appear at most once in the output. - for name in ("Bingham Co. Assist 3", "IA 1", "Old Fire 10"): - count = text.count(name) - assert count <= 1, f"'{name}' appears {count} times (expected <=1)" - - def test_t5_respects_block_cap(self, reporter): - """T5: Output respects _block_cap() even with long names.""" + def test_t4_respects_block_cap(self, reporter): + """Output respects _block_cap() even with long names.""" conn = get_db() now = int(time.time()) - # Seed fires with very long names to exceed cap for i in range(12): long_name = f"Extremely Long Incident Name For Testing Purposes Number {i:02d} " + "X" * 100 _seed_fire(conn, irwin_id=f"LONG-{i:02d}", name=long_name, diff --git a/tests/test_fire_digest_recency.py b/tests/test_fire_digest_recency.py new file mode 100644 index 0000000..abe8a86 --- /dev/null +++ b/tests/test_fire_digest_recency.py @@ -0,0 +1,144 @@ +"""Tests for the fire digest deterministic renderer recency ordering. + +Validates that render_digest() lists the 2 most recent fires in +last_event_at descending order, excludes contained/tombstoned fires, +shows the correct "N additional" count, and stays within 200 bytes. +""" +from __future__ import annotations + +import asyncio +import time + +import pytest + +from meshai.persistence import get_db + + +def _seed_fire(conn, *, irwin_id, name, acres, contained=None, lat=43.6, lon=-116.2, + county="Ada", state="ID", declared_at=None, last_event_at=None, + tombstoned_at=None): + now = int(time.time()) + conn.execute( + "INSERT OR REPLACE INTO fires(irwin_id, incident_name, incident_type, " + "current_acres, current_contained_pct, lat, lon, county, state, " + "declared_at, last_event_at, tombstoned_at) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + (irwin_id, name, "WF", acres, contained, lat, lon, county, state, + declared_at or now, last_event_at or now, tombstoned_at), + ) + + +def _seed_scenario(conn): + """Seed fires: 3 active, 1 contained, 1 tombstoned.""" + now = int(time.time()) + day = 86400 + + # 3 active fires with distinct recency + _seed_fire(conn, irwin_id="F-01", name="Alpha Fire", + acres=500, contained=None, + last_event_at=now - 3600, # 1 hour ago (most recent) + county="Ada", state="ID") + _seed_fire(conn, irwin_id="F-02", name="Bravo Fire", + acres=200, contained=25, + last_event_at=now - 7200, # 2 hours ago + county="Boise", state="ID") + _seed_fire(conn, irwin_id="F-03", name="Charlie Fire", + acres=1000, contained=None, + last_event_at=now - 2 * day, # 2 days ago + county="Elmore", state="ID") + + # 1 fire 100% contained (should be excluded) + _seed_fire(conn, irwin_id="F-04", name="Contained Fire", + acres=800, contained=100, + last_event_at=now - 1800, # 30 min ago — very recent! + county="Gem", state="ID") + + # 1 tombstoned fire (should be excluded) + _seed_fire(conn, irwin_id="F-05", name="Tombstoned Fire", + acres=3000, contained=50, + last_event_at=now - day, + tombstoned_at=now - 3600, + county="Owyhee", state="ID") + + +class TestFireDigestRecency: + """Deterministic fire digest renderer tests.""" + + def test_top_2_listed_in_recency_order(self): + """The 2 most recent active fires are listed in order.""" + conn = get_db() + now = int(time.time()) + _seed_scenario(conn) + from meshai.notifications.scheduled.fire_digest import render_digest + wire, source = asyncio.run(render_digest(now=now)) + assert source == "deterministic" + assert "Alpha Fire" in wire + assert "Bravo Fire" in wire + pos_alpha = wire.index("Alpha Fire") + pos_bravo = wire.index("Bravo Fire") + assert pos_alpha < pos_bravo, "Alpha Fire (most recent) should appear before Bravo Fire" + + def test_contained_excluded(self): + """100%-contained fires are excluded from the digest.""" + conn = get_db() + now = int(time.time()) + _seed_scenario(conn) + from meshai.notifications.scheduled.fire_digest import render_digest + wire, source = asyncio.run(render_digest(now=now)) + assert "Contained Fire" not in wire + + def test_tombstoned_excluded(self): + """Tombstoned fires are excluded from the digest.""" + conn = get_db() + now = int(time.time()) + _seed_scenario(conn) + from meshai.notifications.scheduled.fire_digest import render_digest + wire, source = asyncio.run(render_digest(now=now)) + assert "Tombstoned Fire" not in wire + + def test_n_additional_count_correct(self): + """The 'N additional' tail sentence has the right count.""" + conn = get_db() + now = int(time.time()) + _seed_scenario(conn) + from meshai.notifications.scheduled.fire_digest import render_digest + wire, source = asyncio.run(render_digest(now=now)) + # 3 active fires total, 2 shown, 1 remaining + assert "There are 1 additional wildfires. DM me for the full list." in wire + + def test_n_zero_omits_sentence(self): + """When N == 0, the tail sentence is omitted entirely.""" + conn = get_db() + now = int(time.time()) + # Only 2 active fires + _seed_fire(conn, irwin_id="X-01", name="Fire One", + acres=100, contained=None, + last_event_at=now - 3600, + county="Ada", state="ID") + _seed_fire(conn, irwin_id="X-02", name="Fire Two", + acres=50, contained=10, + last_event_at=now - 7200, + county="Boise", state="ID") + from meshai.notifications.scheduled.fire_digest import render_digest + wire, source = asyncio.run(render_digest(now=now)) + assert source == "deterministic" + assert "additional" not in wire + assert "Fire One" in wire + assert "Fire Two" in wire + + def test_rendered_within_200_bytes(self): + """Rendered output must be <= 200 bytes for LoRa budget.""" + conn = get_db() + now = int(time.time()) + _seed_scenario(conn) + from meshai.notifications.scheduled.fire_digest import render_digest + wire, source = asyncio.run(render_digest(now=now)) + byte_len = len(wire.encode("utf-8")) + assert byte_len <= 200, f"Digest is {byte_len} bytes, exceeds 200-byte budget" + + def test_no_fires_returns_empty(self): + """No active fires -> empty wire, 'no_fires' source.""" + from meshai.notifications.scheduled.fire_digest import render_digest + wire, source = asyncio.run(render_digest()) + assert wire == "" + assert source == "no_fires"