feat(v0.6-phase3): reminder system + schema split + NWS dedup relaxation
Third broadcast type Active: clock-driven re-broadcasts of still-live
events at human-scale cadences. WFIGS fires 8h, itd_511 work zones daily
8 AM Mountain, SWPC G-storms 8h. NWS is NOT a clock reminder -- instead
the per-CAP-id dedup is relaxed to allow re-broadcast if >3h since last.
Schema split first_broadcast_at + last_broadcast_at on all reminder-
eligible tables. Wire prefix logic: New (first sight), Update (WFIGS
material change), Active (clock reminder). All cadences, channels, day-
of-week patterns, timezones, and termination conditions GUI-editable
from day one via the existing adapter_config editor. Termination:
tombstone OR containment_100 OR end_date_passed (no max-count). Quiet
hours not respected -- ripped out in Phase 2.
Schema (v11.sql):
- ALTER TABLE fires|nws_alerts|traffic_events|quake_events|swpc_events|
gauge_readings ADD COLUMN first_broadcast_at REAL
- Backfill: UPDATE ... SET first_broadcast_at = last_broadcast_at
WHERE last_broadcast_at IS NOT NULL
- ALTER TABLE adapter_meta ADD COLUMN reminder_enabled INTEGER NOT NULL
DEFAULT 0
- UPDATE adapter_meta SET reminder_enabled=1 WHERE adapter IN
('wfigs', 'swpc') -- itd_511_work_zone is a new meta row seeded
with reminder_enabled=1
- SCHEMA_VERSION 10 -> 11
Handler commit-callbacks (wfigs/nws/quake/swpc/incident):
- UPDATE ... SET last_broadcast_at=?, first_broadcast_at=COALESCE(
first_broadcast_at, ?) -- first_broadcast_at stamped once, never
overwritten
NWS handler (meshai/central/nws_handler.py):
- _render() gains a prefix kwarg
- After-first-broadcast branch: when (now - last_broadcast_at) >=
adapter_config.nws.duplicate_allowed_after_seconds (default 10800
= 3h), allow the re-broadcast with prefix=Active. Under the
window, suppress as before. The commit callback continues to
update last_broadcast_at.
ReminderScheduler (meshai/notifications/reminders/__init__.py):
- Async loop, ticks every 60s
- Each tick: SELECT adapter FROM adapter_meta WHERE reminder_enabled=1
- Per adapter, load reminders_<adapter> config from adapter_config
(cadence_kind, cadence_value, channels, terminate_when, dow_mask,
timezone)
- Interval cadence: rows where last_broadcast_at <= now - cadence_value
- Clock cadence: localizes now to configured tz, finds slots that
just passed in the last tick window, gated by dow_mask
- Termination conditions checked per adapter:
wfigs.containment_100 -> current_contained_pct >= 100
wfigs.last_event_age_24h -> last_event_at older than 24h
swpc.end_date_passed -> payload_json end_time in past
itd_511_work_zone.end_date_passed -> traffic_events.end_at in past
- Active: prefix on every emitted wire; dispatcher.dispatch_scheduled_
broadcast() honors cold-start grace, bypasses toggle path
- On success, last_broadcast_at = now; first_broadcast_at preserved
Launched from notifications/pipeline/__init__.py:start_pipeline()
alongside BandConditionsScheduler.
adapter_config registry (+15 new keys, 43 -> 58):
- reminders_wfigs.cadence_kind/cadence_value/channels/terminate_when
- reminders_swpc.cadence_kind/cadence_value/channels/terminate_when
- reminders_itd_511_work_zone.cadence_kind/cadence_value/channels/
dow_mask/timezone/terminate_when
- nws.duplicate_allowed_after_seconds
adapter_meta (+4 rows, 15 -> 19):
- reminders_wfigs, reminders_swpc, reminders_itd_511_work_zone
(pseudo-adapters carrying the reminder config)
- itd_511_work_zone (reminder target row; reminder_enabled=1)
- reminder_enabled flag added to wfigs/swpc (existing rows updated by
v11.sql) and to itd_511_work_zone seed.
Tests (tests/test_reminders.py, 10 cases):
- wfigs reminder fires past 8h cadence, stamps last_broadcast_at,
preserves first_broadcast_at
- reminder skipped within cadence
- reminder skipped when containment_100, last_event_age_24h
- swpc reminder fires (interval)
- work_zone clock reminder fires at 08:00 Mountain on enabled DOW
- work_zone reminder skipped when end_date_passed
- work_zone reminder skipped outside slot window
- reminder_enabled=0 suppresses all reminders for that adapter
tests/test_nws_dedup_relaxation.py (5 cases):
- First sighting renders without Active: prefix
- Re-broadcast within 3h suppressed
- Re-broadcast after 3h allowed with Active: prefix
- adapter_config.nws.duplicate_allowed_after_seconds override takes
effect (1h window verified)
- First sighting stamps first_broadcast_at=committed_at,
last_broadcast_at=committed_at; 4h later broadcast stamps
last_broadcast_at only, first_broadcast_at preserved
Test count: 829 -> 844 (+15 new, 0 regressions). Foundation tests
updated for new counts (REGISTRY=58, ADAPTER_META=19, schema=v11).
2026-06-05 21:11:32 +00:00
|
|
|
"""v0.6-phase3 ReminderScheduler tests."""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import time
|
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from meshai.notifications.reminders import ReminderScheduler
|
|
|
|
|
from meshai.persistence import get_db
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- helpers --------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _seed_fire(conn, *, irwin_id, last_broadcast_at, current_contained_pct=10,
|
|
|
|
|
last_event_at=None, name="Test Fire"):
|
|
|
|
|
if last_event_at is None: last_event_at = 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, first_broadcast_at, last_broadcast_at) "
|
|
|
|
|
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
|
|
|
(irwin_id, name, "WF", 500, current_contained_pct,
|
|
|
|
|
42.5, -114.5, "Cassia", "ID",
|
|
|
|
|
last_broadcast_at, last_event_at, last_broadcast_at, last_broadcast_at),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _seed_swpc(conn, *, event_id, last_broadcast_at, event_type="swpc_kindex"):
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT OR REPLACE INTO swpc_events(event_id, event_type, severity_int, "
|
|
|
|
|
"payload_json, occurred_at, first_seen_at, first_broadcast_at, "
|
|
|
|
|
"last_broadcast_at) VALUES (?,?,?,?,?,?,?,?)",
|
|
|
|
|
(event_id, event_type, 7, "{}", last_broadcast_at, last_broadcast_at,
|
|
|
|
|
last_broadcast_at, last_broadcast_at),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _seed_work_zone(conn, *, external_id, last_broadcast_at, end_at=None,
|
|
|
|
|
sub_type="road_works"):
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT OR REPLACE INTO traffic_events(source, external_id, road, "
|
|
|
|
|
"direction, county, state, sub_type, impact, first_seen_at, "
|
|
|
|
|
"last_seen_at, first_broadcast_at, last_broadcast_at, end_at) "
|
|
|
|
|
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
|
|
|
("itd_511", external_id, "I-84", "E", "Ada", "ID", sub_type,
|
|
|
|
|
None, last_broadcast_at, last_broadcast_at,
|
|
|
|
|
last_broadcast_at, last_broadcast_at, end_at),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 03:43:06 +00:00
|
|
|
def _enable_wfigs_reminders():
|
|
|
|
|
"""Enable wfigs reminders (default is disabled in adapter_config)."""
|
|
|
|
|
from meshai.persistence import get_db
|
|
|
|
|
conn = get_db()
|
|
|
|
|
conn.execute(
|
|
|
|
|
"UPDATE adapter_config SET default_json='true' "
|
|
|
|
|
"WHERE adapter='reminders_wfigs' AND key='enabled'"
|
|
|
|
|
)
|
|
|
|
|
conn.execute(
|
|
|
|
|
"UPDATE adapter_config SET value_json='true' "
|
|
|
|
|
"WHERE adapter='reminders_wfigs' AND key='enabled'"
|
|
|
|
|
)
|
|
|
|
|
from meshai.adapter_config import adapter_config as _ac
|
|
|
|
|
_ac.invalidate()
|
|
|
|
|
|
|
|
|
|
|
feat(v0.6-phase3): reminder system + schema split + NWS dedup relaxation
Third broadcast type Active: clock-driven re-broadcasts of still-live
events at human-scale cadences. WFIGS fires 8h, itd_511 work zones daily
8 AM Mountain, SWPC G-storms 8h. NWS is NOT a clock reminder -- instead
the per-CAP-id dedup is relaxed to allow re-broadcast if >3h since last.
Schema split first_broadcast_at + last_broadcast_at on all reminder-
eligible tables. Wire prefix logic: New (first sight), Update (WFIGS
material change), Active (clock reminder). All cadences, channels, day-
of-week patterns, timezones, and termination conditions GUI-editable
from day one via the existing adapter_config editor. Termination:
tombstone OR containment_100 OR end_date_passed (no max-count). Quiet
hours not respected -- ripped out in Phase 2.
Schema (v11.sql):
- ALTER TABLE fires|nws_alerts|traffic_events|quake_events|swpc_events|
gauge_readings ADD COLUMN first_broadcast_at REAL
- Backfill: UPDATE ... SET first_broadcast_at = last_broadcast_at
WHERE last_broadcast_at IS NOT NULL
- ALTER TABLE adapter_meta ADD COLUMN reminder_enabled INTEGER NOT NULL
DEFAULT 0
- UPDATE adapter_meta SET reminder_enabled=1 WHERE adapter IN
('wfigs', 'swpc') -- itd_511_work_zone is a new meta row seeded
with reminder_enabled=1
- SCHEMA_VERSION 10 -> 11
Handler commit-callbacks (wfigs/nws/quake/swpc/incident):
- UPDATE ... SET last_broadcast_at=?, first_broadcast_at=COALESCE(
first_broadcast_at, ?) -- first_broadcast_at stamped once, never
overwritten
NWS handler (meshai/central/nws_handler.py):
- _render() gains a prefix kwarg
- After-first-broadcast branch: when (now - last_broadcast_at) >=
adapter_config.nws.duplicate_allowed_after_seconds (default 10800
= 3h), allow the re-broadcast with prefix=Active. Under the
window, suppress as before. The commit callback continues to
update last_broadcast_at.
ReminderScheduler (meshai/notifications/reminders/__init__.py):
- Async loop, ticks every 60s
- Each tick: SELECT adapter FROM adapter_meta WHERE reminder_enabled=1
- Per adapter, load reminders_<adapter> config from adapter_config
(cadence_kind, cadence_value, channels, terminate_when, dow_mask,
timezone)
- Interval cadence: rows where last_broadcast_at <= now - cadence_value
- Clock cadence: localizes now to configured tz, finds slots that
just passed in the last tick window, gated by dow_mask
- Termination conditions checked per adapter:
wfigs.containment_100 -> current_contained_pct >= 100
wfigs.last_event_age_24h -> last_event_at older than 24h
swpc.end_date_passed -> payload_json end_time in past
itd_511_work_zone.end_date_passed -> traffic_events.end_at in past
- Active: prefix on every emitted wire; dispatcher.dispatch_scheduled_
broadcast() honors cold-start grace, bypasses toggle path
- On success, last_broadcast_at = now; first_broadcast_at preserved
Launched from notifications/pipeline/__init__.py:start_pipeline()
alongside BandConditionsScheduler.
adapter_config registry (+15 new keys, 43 -> 58):
- reminders_wfigs.cadence_kind/cadence_value/channels/terminate_when
- reminders_swpc.cadence_kind/cadence_value/channels/terminate_when
- reminders_itd_511_work_zone.cadence_kind/cadence_value/channels/
dow_mask/timezone/terminate_when
- nws.duplicate_allowed_after_seconds
adapter_meta (+4 rows, 15 -> 19):
- reminders_wfigs, reminders_swpc, reminders_itd_511_work_zone
(pseudo-adapters carrying the reminder config)
- itd_511_work_zone (reminder target row; reminder_enabled=1)
- reminder_enabled flag added to wfigs/swpc (existing rows updated by
v11.sql) and to itd_511_work_zone seed.
Tests (tests/test_reminders.py, 10 cases):
- wfigs reminder fires past 8h cadence, stamps last_broadcast_at,
preserves first_broadcast_at
- reminder skipped within cadence
- reminder skipped when containment_100, last_event_age_24h
- swpc reminder fires (interval)
- work_zone clock reminder fires at 08:00 Mountain on enabled DOW
- work_zone reminder skipped when end_date_passed
- work_zone reminder skipped outside slot window
- reminder_enabled=0 suppresses all reminders for that adapter
tests/test_nws_dedup_relaxation.py (5 cases):
- First sighting renders without Active: prefix
- Re-broadcast within 3h suppressed
- Re-broadcast after 3h allowed with Active: prefix
- adapter_config.nws.duplicate_allowed_after_seconds override takes
effect (1h window verified)
- First sighting stamps first_broadcast_at=committed_at,
last_broadcast_at=committed_at; 4h later broadcast stamps
last_broadcast_at only, first_broadcast_at preserved
Test count: 829 -> 844 (+15 new, 0 regressions). Foundation tests
updated for new counts (REGISTRY=58, ADAPTER_META=19, schema=v11).
2026-06-05 21:11:32 +00:00
|
|
|
@pytest.fixture
|
|
|
|
|
def mock_dispatcher():
|
|
|
|
|
d = MagicMock()
|
|
|
|
|
d.dispatch_scheduled_broadcast = AsyncMock(return_value=True)
|
|
|
|
|
return d
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Interval cadence (wfigs)
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_wfigs_reminder_fires_past_cadence(mock_dispatcher):
|
|
|
|
|
"""A fire whose last_broadcast_at is past the 8h cadence emits Active:."""
|
|
|
|
|
now = 1_780_000_000
|
|
|
|
|
conn = get_db()
|
2026-06-10 03:43:06 +00:00
|
|
|
_enable_wfigs_reminders()
|
feat(v0.6-phase3): reminder system + schema split + NWS dedup relaxation
Third broadcast type Active: clock-driven re-broadcasts of still-live
events at human-scale cadences. WFIGS fires 8h, itd_511 work zones daily
8 AM Mountain, SWPC G-storms 8h. NWS is NOT a clock reminder -- instead
the per-CAP-id dedup is relaxed to allow re-broadcast if >3h since last.
Schema split first_broadcast_at + last_broadcast_at on all reminder-
eligible tables. Wire prefix logic: New (first sight), Update (WFIGS
material change), Active (clock reminder). All cadences, channels, day-
of-week patterns, timezones, and termination conditions GUI-editable
from day one via the existing adapter_config editor. Termination:
tombstone OR containment_100 OR end_date_passed (no max-count). Quiet
hours not respected -- ripped out in Phase 2.
Schema (v11.sql):
- ALTER TABLE fires|nws_alerts|traffic_events|quake_events|swpc_events|
gauge_readings ADD COLUMN first_broadcast_at REAL
- Backfill: UPDATE ... SET first_broadcast_at = last_broadcast_at
WHERE last_broadcast_at IS NOT NULL
- ALTER TABLE adapter_meta ADD COLUMN reminder_enabled INTEGER NOT NULL
DEFAULT 0
- UPDATE adapter_meta SET reminder_enabled=1 WHERE adapter IN
('wfigs', 'swpc') -- itd_511_work_zone is a new meta row seeded
with reminder_enabled=1
- SCHEMA_VERSION 10 -> 11
Handler commit-callbacks (wfigs/nws/quake/swpc/incident):
- UPDATE ... SET last_broadcast_at=?, first_broadcast_at=COALESCE(
first_broadcast_at, ?) -- first_broadcast_at stamped once, never
overwritten
NWS handler (meshai/central/nws_handler.py):
- _render() gains a prefix kwarg
- After-first-broadcast branch: when (now - last_broadcast_at) >=
adapter_config.nws.duplicate_allowed_after_seconds (default 10800
= 3h), allow the re-broadcast with prefix=Active. Under the
window, suppress as before. The commit callback continues to
update last_broadcast_at.
ReminderScheduler (meshai/notifications/reminders/__init__.py):
- Async loop, ticks every 60s
- Each tick: SELECT adapter FROM adapter_meta WHERE reminder_enabled=1
- Per adapter, load reminders_<adapter> config from adapter_config
(cadence_kind, cadence_value, channels, terminate_when, dow_mask,
timezone)
- Interval cadence: rows where last_broadcast_at <= now - cadence_value
- Clock cadence: localizes now to configured tz, finds slots that
just passed in the last tick window, gated by dow_mask
- Termination conditions checked per adapter:
wfigs.containment_100 -> current_contained_pct >= 100
wfigs.last_event_age_24h -> last_event_at older than 24h
swpc.end_date_passed -> payload_json end_time in past
itd_511_work_zone.end_date_passed -> traffic_events.end_at in past
- Active: prefix on every emitted wire; dispatcher.dispatch_scheduled_
broadcast() honors cold-start grace, bypasses toggle path
- On success, last_broadcast_at = now; first_broadcast_at preserved
Launched from notifications/pipeline/__init__.py:start_pipeline()
alongside BandConditionsScheduler.
adapter_config registry (+15 new keys, 43 -> 58):
- reminders_wfigs.cadence_kind/cadence_value/channels/terminate_when
- reminders_swpc.cadence_kind/cadence_value/channels/terminate_when
- reminders_itd_511_work_zone.cadence_kind/cadence_value/channels/
dow_mask/timezone/terminate_when
- nws.duplicate_allowed_after_seconds
adapter_meta (+4 rows, 15 -> 19):
- reminders_wfigs, reminders_swpc, reminders_itd_511_work_zone
(pseudo-adapters carrying the reminder config)
- itd_511_work_zone (reminder target row; reminder_enabled=1)
- reminder_enabled flag added to wfigs/swpc (existing rows updated by
v11.sql) and to itd_511_work_zone seed.
Tests (tests/test_reminders.py, 10 cases):
- wfigs reminder fires past 8h cadence, stamps last_broadcast_at,
preserves first_broadcast_at
- reminder skipped within cadence
- reminder skipped when containment_100, last_event_age_24h
- swpc reminder fires (interval)
- work_zone clock reminder fires at 08:00 Mountain on enabled DOW
- work_zone reminder skipped when end_date_passed
- work_zone reminder skipped outside slot window
- reminder_enabled=0 suppresses all reminders for that adapter
tests/test_nws_dedup_relaxation.py (5 cases):
- First sighting renders without Active: prefix
- Re-broadcast within 3h suppressed
- Re-broadcast after 3h allowed with Active: prefix
- adapter_config.nws.duplicate_allowed_after_seconds override takes
effect (1h window verified)
- First sighting stamps first_broadcast_at=committed_at,
last_broadcast_at=committed_at; 4h later broadcast stamps
last_broadcast_at only, first_broadcast_at preserved
Test count: 829 -> 844 (+15 new, 0 regressions). Foundation tests
updated for new counts (REGISTRY=58, ADAPTER_META=19, schema=v11).
2026-06-05 21:11:32 +00:00
|
|
|
_seed_fire(conn, irwin_id="F1", last_broadcast_at=now - 9 * 3600)
|
|
|
|
|
|
|
|
|
|
sch = ReminderScheduler(mock_dispatcher, clock=lambda: now)
|
|
|
|
|
fired = asyncio.run(sch.tick_once())
|
|
|
|
|
assert fired == 1
|
|
|
|
|
mock_dispatcher.dispatch_scheduled_broadcast.assert_called_once()
|
|
|
|
|
args = mock_dispatcher.dispatch_scheduled_broadcast.call_args.kwargs
|
|
|
|
|
assert "Active" in args["text"]
|
|
|
|
|
assert args["source_event_table"] == "fires"
|
|
|
|
|
assert args["source_event_pk"] == "F1"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_wfigs_reminder_skipped_within_cadence(mock_dispatcher):
|
|
|
|
|
"""A fire broadcast 1h ago is within the 8h cadence -> no reminder."""
|
|
|
|
|
now = 1_780_000_000
|
|
|
|
|
conn = get_db()
|
|
|
|
|
_seed_fire(conn, irwin_id="F1", last_broadcast_at=now - 3600)
|
|
|
|
|
|
|
|
|
|
sch = ReminderScheduler(mock_dispatcher, clock=lambda: now)
|
|
|
|
|
fired = asyncio.run(sch.tick_once())
|
|
|
|
|
assert fired == 0
|
|
|
|
|
mock_dispatcher.dispatch_scheduled_broadcast.assert_not_called()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_wfigs_reminder_skipped_when_containment_100(mock_dispatcher):
|
|
|
|
|
"""containment_100 termination overrides the cadence."""
|
|
|
|
|
now = 1_780_000_000
|
|
|
|
|
conn = get_db()
|
|
|
|
|
_seed_fire(conn, irwin_id="F1", last_broadcast_at=now - 9 * 3600,
|
|
|
|
|
current_contained_pct=100)
|
|
|
|
|
|
|
|
|
|
sch = ReminderScheduler(mock_dispatcher, clock=lambda: now)
|
|
|
|
|
fired = asyncio.run(sch.tick_once())
|
|
|
|
|
assert fired == 0
|
|
|
|
|
mock_dispatcher.dispatch_scheduled_broadcast.assert_not_called()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_wfigs_reminder_skipped_when_last_event_age_24h(mock_dispatcher):
|
|
|
|
|
"""If last_event_at > 24h ago, treat the fire as gone -> no reminder."""
|
|
|
|
|
now = 1_780_000_000
|
|
|
|
|
conn = get_db()
|
|
|
|
|
_seed_fire(conn, irwin_id="F1", last_broadcast_at=now - 9 * 3600,
|
|
|
|
|
last_event_at=now - 48 * 3600)
|
|
|
|
|
|
|
|
|
|
sch = ReminderScheduler(mock_dispatcher, clock=lambda: now)
|
|
|
|
|
fired = asyncio.run(sch.tick_once())
|
|
|
|
|
assert fired == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_wfigs_reminder_stamps_last_broadcast_at(mock_dispatcher):
|
|
|
|
|
now = 1_780_000_000
|
|
|
|
|
conn = get_db()
|
2026-06-10 03:43:06 +00:00
|
|
|
_enable_wfigs_reminders()
|
feat(v0.6-phase3): reminder system + schema split + NWS dedup relaxation
Third broadcast type Active: clock-driven re-broadcasts of still-live
events at human-scale cadences. WFIGS fires 8h, itd_511 work zones daily
8 AM Mountain, SWPC G-storms 8h. NWS is NOT a clock reminder -- instead
the per-CAP-id dedup is relaxed to allow re-broadcast if >3h since last.
Schema split first_broadcast_at + last_broadcast_at on all reminder-
eligible tables. Wire prefix logic: New (first sight), Update (WFIGS
material change), Active (clock reminder). All cadences, channels, day-
of-week patterns, timezones, and termination conditions GUI-editable
from day one via the existing adapter_config editor. Termination:
tombstone OR containment_100 OR end_date_passed (no max-count). Quiet
hours not respected -- ripped out in Phase 2.
Schema (v11.sql):
- ALTER TABLE fires|nws_alerts|traffic_events|quake_events|swpc_events|
gauge_readings ADD COLUMN first_broadcast_at REAL
- Backfill: UPDATE ... SET first_broadcast_at = last_broadcast_at
WHERE last_broadcast_at IS NOT NULL
- ALTER TABLE adapter_meta ADD COLUMN reminder_enabled INTEGER NOT NULL
DEFAULT 0
- UPDATE adapter_meta SET reminder_enabled=1 WHERE adapter IN
('wfigs', 'swpc') -- itd_511_work_zone is a new meta row seeded
with reminder_enabled=1
- SCHEMA_VERSION 10 -> 11
Handler commit-callbacks (wfigs/nws/quake/swpc/incident):
- UPDATE ... SET last_broadcast_at=?, first_broadcast_at=COALESCE(
first_broadcast_at, ?) -- first_broadcast_at stamped once, never
overwritten
NWS handler (meshai/central/nws_handler.py):
- _render() gains a prefix kwarg
- After-first-broadcast branch: when (now - last_broadcast_at) >=
adapter_config.nws.duplicate_allowed_after_seconds (default 10800
= 3h), allow the re-broadcast with prefix=Active. Under the
window, suppress as before. The commit callback continues to
update last_broadcast_at.
ReminderScheduler (meshai/notifications/reminders/__init__.py):
- Async loop, ticks every 60s
- Each tick: SELECT adapter FROM adapter_meta WHERE reminder_enabled=1
- Per adapter, load reminders_<adapter> config from adapter_config
(cadence_kind, cadence_value, channels, terminate_when, dow_mask,
timezone)
- Interval cadence: rows where last_broadcast_at <= now - cadence_value
- Clock cadence: localizes now to configured tz, finds slots that
just passed in the last tick window, gated by dow_mask
- Termination conditions checked per adapter:
wfigs.containment_100 -> current_contained_pct >= 100
wfigs.last_event_age_24h -> last_event_at older than 24h
swpc.end_date_passed -> payload_json end_time in past
itd_511_work_zone.end_date_passed -> traffic_events.end_at in past
- Active: prefix on every emitted wire; dispatcher.dispatch_scheduled_
broadcast() honors cold-start grace, bypasses toggle path
- On success, last_broadcast_at = now; first_broadcast_at preserved
Launched from notifications/pipeline/__init__.py:start_pipeline()
alongside BandConditionsScheduler.
adapter_config registry (+15 new keys, 43 -> 58):
- reminders_wfigs.cadence_kind/cadence_value/channels/terminate_when
- reminders_swpc.cadence_kind/cadence_value/channels/terminate_when
- reminders_itd_511_work_zone.cadence_kind/cadence_value/channels/
dow_mask/timezone/terminate_when
- nws.duplicate_allowed_after_seconds
adapter_meta (+4 rows, 15 -> 19):
- reminders_wfigs, reminders_swpc, reminders_itd_511_work_zone
(pseudo-adapters carrying the reminder config)
- itd_511_work_zone (reminder target row; reminder_enabled=1)
- reminder_enabled flag added to wfigs/swpc (existing rows updated by
v11.sql) and to itd_511_work_zone seed.
Tests (tests/test_reminders.py, 10 cases):
- wfigs reminder fires past 8h cadence, stamps last_broadcast_at,
preserves first_broadcast_at
- reminder skipped within cadence
- reminder skipped when containment_100, last_event_age_24h
- swpc reminder fires (interval)
- work_zone clock reminder fires at 08:00 Mountain on enabled DOW
- work_zone reminder skipped when end_date_passed
- work_zone reminder skipped outside slot window
- reminder_enabled=0 suppresses all reminders for that adapter
tests/test_nws_dedup_relaxation.py (5 cases):
- First sighting renders without Active: prefix
- Re-broadcast within 3h suppressed
- Re-broadcast after 3h allowed with Active: prefix
- adapter_config.nws.duplicate_allowed_after_seconds override takes
effect (1h window verified)
- First sighting stamps first_broadcast_at=committed_at,
last_broadcast_at=committed_at; 4h later broadcast stamps
last_broadcast_at only, first_broadcast_at preserved
Test count: 829 -> 844 (+15 new, 0 regressions). Foundation tests
updated for new counts (REGISTRY=58, ADAPTER_META=19, schema=v11).
2026-06-05 21:11:32 +00:00
|
|
|
_seed_fire(conn, irwin_id="F1", last_broadcast_at=now - 9 * 3600)
|
|
|
|
|
sch = ReminderScheduler(mock_dispatcher, clock=lambda: now)
|
|
|
|
|
asyncio.run(sch.tick_once())
|
|
|
|
|
row = conn.execute("SELECT last_broadcast_at, first_broadcast_at FROM fires WHERE irwin_id='F1'").fetchone()
|
|
|
|
|
assert row["last_broadcast_at"] == now
|
|
|
|
|
# first_broadcast_at preserved
|
|
|
|
|
assert row["first_broadcast_at"] == now - 9 * 3600
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reminder_disabled_adapter_does_nothing(mock_dispatcher):
|
|
|
|
|
"""When adapter_meta.reminder_enabled=0 for wfigs, no reminders fire."""
|
|
|
|
|
now = 1_780_000_000
|
|
|
|
|
conn = get_db()
|
|
|
|
|
_seed_fire(conn, irwin_id="F1", last_broadcast_at=now - 9 * 3600)
|
|
|
|
|
conn.execute("UPDATE adapter_meta SET reminder_enabled=0 WHERE adapter='wfigs'")
|
|
|
|
|
sch = ReminderScheduler(mock_dispatcher, clock=lambda: now)
|
|
|
|
|
fired = asyncio.run(sch.tick_once())
|
|
|
|
|
assert fired == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# SWPC interval cadence
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_swpc_reminder_fires(mock_dispatcher):
|
|
|
|
|
now = 1_780_000_000
|
|
|
|
|
conn = get_db()
|
|
|
|
|
_seed_swpc(conn, event_id="S1", last_broadcast_at=now - 10 * 3600)
|
|
|
|
|
sch = ReminderScheduler(mock_dispatcher, clock=lambda: now)
|
|
|
|
|
fired = asyncio.run(sch.tick_once())
|
|
|
|
|
assert fired == 1
|
|
|
|
|
args = mock_dispatcher.dispatch_scheduled_broadcast.call_args.kwargs
|
|
|
|
|
assert "Active" in args["text"]
|
|
|
|
|
assert args["source_event_table"] == "swpc_events"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# itd_511_work_zone clock cadence
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_work_zone_reminder_fires_at_clock_slot(mock_dispatcher):
|
|
|
|
|
"""At the 08:00 Mountain slot, an active work zone broadcasts Active:."""
|
|
|
|
|
# 08:00 America/Boise on a Monday. Compute UTC equivalent.
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
from zoneinfo import ZoneInfo
|
|
|
|
|
# Pick a Monday at exactly 08:00 Mountain.
|
|
|
|
|
local = datetime(2026, 6, 8, 8, 0, 0, tzinfo=ZoneInfo("America/Boise"))
|
|
|
|
|
now = int(local.timestamp())
|
|
|
|
|
conn = get_db()
|
|
|
|
|
_seed_work_zone(conn, external_id="WZ1", last_broadcast_at=now - 48 * 3600)
|
|
|
|
|
sch = ReminderScheduler(mock_dispatcher, clock=lambda: now, tick_seconds=60)
|
|
|
|
|
fired = asyncio.run(sch.tick_once())
|
|
|
|
|
assert fired == 1
|
|
|
|
|
args = mock_dispatcher.dispatch_scheduled_broadcast.call_args.kwargs
|
|
|
|
|
assert "Active" in args["text"]
|
|
|
|
|
assert args["source_event_table"] == "traffic_events"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_work_zone_reminder_skipped_when_end_date_passed(mock_dispatcher):
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
from zoneinfo import ZoneInfo
|
|
|
|
|
local = datetime(2026, 6, 8, 8, 0, 0, tzinfo=ZoneInfo("America/Boise"))
|
|
|
|
|
now = int(local.timestamp())
|
|
|
|
|
conn = get_db()
|
|
|
|
|
_seed_work_zone(conn, external_id="WZ1",
|
|
|
|
|
last_broadcast_at=now - 48 * 3600,
|
|
|
|
|
end_at=now - 3600) # end_at in the past
|
|
|
|
|
sch = ReminderScheduler(mock_dispatcher, clock=lambda: now, tick_seconds=60)
|
|
|
|
|
fired = asyncio.run(sch.tick_once())
|
|
|
|
|
assert fired == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_work_zone_reminder_skipped_outside_slot(mock_dispatcher):
|
|
|
|
|
"""At 14:00 Mountain (no slot), no reminder fires."""
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
from zoneinfo import ZoneInfo
|
|
|
|
|
local = datetime(2026, 6, 8, 14, 0, 0, tzinfo=ZoneInfo("America/Boise"))
|
|
|
|
|
now = int(local.timestamp())
|
|
|
|
|
conn = get_db()
|
|
|
|
|
_seed_work_zone(conn, external_id="WZ1", last_broadcast_at=now - 48 * 3600)
|
|
|
|
|
sch = ReminderScheduler(mock_dispatcher, clock=lambda: now, tick_seconds=60)
|
|
|
|
|
fired = asyncio.run(sch.tick_once())
|
|
|
|
|
assert fired == 0
|