meshai/tests/test_band_conditions.py
K7ZVX 0da83e0d3d feat(v0.5.11): band conditions scheduled broadcaster (3x/day HF propagation)
First clock-driven broadcaster in meshai, distinct from the v0.5.8b/v0.5.9/v0.5.10 event-driven adapters. The same persistence + dispatcher + cold-start patterns apply, but the trigger is the wall clock at 06:00 / 14:00 / 22:00 Mountain Time (default; GUI-configurable per Rule 17).

Components: (1) meshai/notifications/scheduled/band_conditions.py with BandConditionsScheduler (asyncio loop, mirrors the existing DigestScheduler shape), compute_band_ratings() with two-tier data sourcing -- (a) latest swpc_kindex + swpc_alerts F10.7 rows from persistence within the last 6h, (b) HamQSL.com solarxml.php fallback when SWPC is stale or incomplete, (c) silent skip when both fail, format_band_conditions_wire() multi-line MEDIUM output (~115-120B). (2) v3 schema migration adding band_conditions_broadcasts(broadcast_id PK AUTO, sent_at, scheduled_for UNIQUE, ratings_json, source). UNIQUE(scheduled_for) enforces per-slot dedup so a retry storm cannot double-broadcast. (3) Dispatcher.dispatch_scheduled_broadcast() bypasses the toggle / rules / freshness-gate pipeline but DOES honour the v0.5.8b cold-start grace -- first scheduled broadcast within the grace window after meshai starts is suppressed, mesh_broadcasts_out audit row only inserted on actual delivery. Channel selection routes through the rf_propagation toggle\'s broadcast_channel since band conditions IS RF-propagation info. (4) NotificationsConfig gains band_conditions_enabled (default true), band_conditions_schedule (list of HH:MM strings, default ["06:00","14:00","22:00"]), band_conditions_tz (default "America/Boise" so DST handles automatically). (5) Notifications.tsx grows a Band Conditions card between Cold-Start Grace and Master Toggles with the enable toggle + 3 TimeInput slots + a one-liner explaining the source priority. (6) build_pipeline + start_pipeline spawn the BandConditionsScheduler alongside the existing DigestScheduler -- best-effort, scheduler failures must NOT break notifications startup.

Wire format examples (multi-line, all under 130B target):

  ☀️ Day Propagation
  📡 Band Conditions:
  80-40m: 🟡 Fair
  30-20m: 🟢 Good
  17-15m: 🟢 Good
  12-10m: 🟡 Fair

  🌞 Day Propagation  (14:00 slot when storm onset, Kp=6 SFI=110)
  📡 Band Conditions:
  80-40m: 🔴 Poor
  30-20m: 🔴 Poor
  17-15m: 🔴 Poor
  12-10m: 🟡 Fair

  🌙 Night Propagation  (22:00 slot, recovery, Kp=4 SFI=120)
  📡 Band Conditions:
  80-40m: 🟡 Fair
  30-20m: 🟡 Fair
  17-15m: 🔴 Poor
  12-10m: 🔴 Poor

Tests: was 686 (v0.5.10 baseline), now 704 (+18 net new -- quiet/storm condition ratings, HamQSL XML parse fallback, both-fail silent-skip path, is_day_slot per HH:MM, wire format for all 3 slot variants, byte-size guard, 6-line shape, fire_slot record row, dedup via UNIQUE constraint, silent-skip path, slot_epoch DST alignment summer + winter). Synthetic 24h probe verified the 3 expected slots fire correctly with quiet/storm/recovery scenarios + the 4th no-data scenario lands as source=\'skipped_no_data\' with no broadcast.

usgs_nwis deferred to v0.5.12 (threshold-curation work). Master OFF in prod.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 07:38:51 +00:00

312 lines
11 KiB
Python

"""Tests for v0.5.11 band-conditions scheduled broadcaster."""
import asyncio
import json
import time
from datetime import datetime, timezone
from unittest.mock import patch
import pytest
from meshai.notifications.scheduled.band_conditions import (
BandConditionsScheduler,
_heuristic_ratings,
compute_band_ratings,
format_band_conditions_wire,
is_day_slot,
record_slot_attempt,
slot_epoch,
)
from meshai.persistence import close_thread_connection, init_db
from meshai.persistence import db as persistence_db
@pytest.fixture
def mem_db(monkeypatch, tmp_path):
db_path = str(tmp_path / "band-test.sqlite")
monkeypatch.setenv("MESHAI_DB_PATH", db_path)
persistence_db._initialised.clear()
close_thread_connection()
conn = init_db()
yield conn
close_thread_connection()
persistence_db._initialised.discard(db_path)
def _insert_swpc_kindex(conn, *, kp, when=None):
when = when or int(time.time())
conn.execute(
"INSERT INTO swpc_events(event_id, event_type, payload_json, "
"occurred_at, first_seen_at, last_broadcast_at) VALUES (?,?,?,?,?,?)",
(f"kp_{when}", "swpc_kindex",
json.dumps({"kp_index": kp, "time": "2026-06-05T15:00:00Z"}),
when, when, None),
)
def _insert_swpc_alert(conn, *, sfi, when=None):
when = when or int(time.time())
conn.execute(
"INSERT INTO swpc_events(event_id, event_type, payload_json, "
"occurred_at, first_seen_at, last_broadcast_at) VALUES (?,?,?,?,?,?)",
(f"sfi_{when}", "swpc_alerts",
json.dumps({"F10.7": sfi, "product_id": "F10.7-Daily"}),
when, when, None),
)
# ---- (a) compute with fresh SWPC quiet conditions ----------------------
def test_compute_quiet_conditions_all_good_to_fair(mem_db):
"""Kp=2 SFI=140 -> all bands Good/Fair on day; 80-40m Good at night."""
now = int(time.time())
_insert_swpc_kindex(mem_db, kp=2.0, when=now - 600)
_insert_swpc_alert(mem_db, sfi=140.0, when=now - 1200)
result = compute_band_ratings(now=now, hh_mm="14:00", allow_hamqsl=False)
assert result is not None
ratings, source = result
assert source == "swpc_local"
# High SFI + low Kp on day -> 30-20m and 17-15m and 12-10m all Good.
assert ratings["30-20m"] == "Good"
assert ratings["17-15m"] == "Good"
assert ratings["12-10m"] == "Fair" # SFI 140 right at the edge of Fair/Good
# 80-40m on day is usually Fair at best.
assert ratings["80-40m"] in ("Fair", "Good")
# ---- (b) compute with storm conditions ---------------------------------
def test_compute_storm_conditions_mostly_poor(mem_db):
"""Kp=7 (G3) crushes ratings to Poor on upper bands."""
now = int(time.time())
_insert_swpc_kindex(mem_db, kp=7.0, when=now - 600)
_insert_swpc_alert(mem_db, sfi=90.0, when=now - 1200)
result = compute_band_ratings(now=now, hh_mm="14:00", allow_hamqsl=False)
assert result is not None
ratings, _ = result
assert ratings["17-15m"] == "Poor"
assert ratings["12-10m"] == "Poor"
assert ratings["30-20m"] == "Poor"
assert ratings["80-40m"] == "Poor"
# ---- (c) HamQSL fallback when SWPC missing -----------------------------
def test_compute_falls_back_to_hamqsl_when_no_swpc(mem_db):
"""No SWPC rows -> HamQSL fallback. Mock the HTTP call."""
XML = '''<?xml version="1.0"?>
<solar><solardata>
<calculatedconditions>
<band name="80m-40m" time="day">Fair</band>
<band name="80m-40m" time="night">Good</band>
<band name="30m-20m" time="day">Good</band>
<band name="30m-20m" time="night">Fair</band>
<band name="17m-15m" time="day">Good</band>
<band name="17m-15m" time="night">Poor</band>
<band name="12m-10m" time="day">Fair</band>
<band name="12m-10m" time="night">Poor</band>
</calculatedconditions>
</solardata></solar>
'''
class Resp:
status_code = 200
text = XML
def mock_get(url, timeout):
return Resp()
result = compute_band_ratings(now=int(time.time()), hh_mm="14:00",
_http_get=mock_get)
assert result is not None
ratings, source = result
assert source == "hamqsl_fallback"
assert ratings["80-40m"] == "Fair"
assert ratings["12-10m"] == "Fair"
# ---- (d) both fail -> None silent skip ---------------------------------
def test_compute_returns_none_when_both_sources_fail(mem_db):
def mock_get(url, timeout):
raise httpx_TimeoutError("simulated")
class httpx_TimeoutError(Exception): pass
result = compute_band_ratings(now=int(time.time()), hh_mm="14:00",
_http_get=lambda u,t: (_ for _ in ()).throw(Exception("timeout")))
assert result is None
# ---- (e) time-of-day selection -----------------------------------------
@pytest.mark.parametrize("hh_mm, expected_day", [
("06:00", True),
("14:00", True),
("22:00", False),
("23:30", False),
])
def test_is_day_slot(hh_mm, expected_day):
assert is_day_slot(hh_mm) is expected_day
# ---- (f) wire format ---------------------------------------------------
def test_wire_format_day_slot():
ratings = {"80-40m": "Fair", "30-20m": "Good",
"17-15m": "Fair", "12-10m": "Poor"}
wire = format_band_conditions_wire(ratings, "14:00")
assert wire.startswith("🌞 Day Propagation")
assert "📡 Band Conditions:" in wire
assert "80-40m: 🟡 Fair" in wire
assert "30-20m: 🟢 Good" in wire
assert "17-15m: 🟡 Fair" in wire
assert "12-10m: 🔴 Poor" in wire
def test_wire_format_night_slot():
ratings = {"80-40m": "Good", "30-20m": "Fair",
"17-15m": "Poor", "12-10m": "Poor"}
wire = format_band_conditions_wire(ratings, "22:00")
assert wire.startswith("🌙 Night Propagation")
assert "80-40m: 🟢 Good" in wire
def test_wire_format_morning_slot():
ratings = {"80-40m": "Fair", "30-20m": "Good",
"17-15m": "Fair", "12-10m": "Poor"}
wire = format_band_conditions_wire(ratings, "06:00")
assert wire.startswith("☀️ Day Propagation")
# ---- (g) byte size under target ----------------------------------------
def test_wire_byte_size_under_target():
ratings = {"80-40m": "Good", "30-20m": "Good",
"17-15m": "Good", "12-10m": "Good"}
wire = format_band_conditions_wire(ratings, "14:00")
nb = len(wire.encode("utf-8"))
assert nb < 130, f"wire {nb}B exceeds soft target -- {wire!r}"
def test_wire_has_4_band_rows_and_2_header_lines():
ratings = {"80-40m": "Good", "30-20m": "Fair",
"17-15m": "Fair", "12-10m": "Poor"}
wire = format_band_conditions_wire(ratings, "06:00")
lines = wire.split("\n")
assert len(lines) == 6 # emoji headline + 📡 header + 4 band rows
# ---- (h) cold-start grace + dedup (i) via scheduler.fire_slot ----------
class _MockDispatcher:
def __init__(self):
self.calls = []
async def dispatch_scheduled_broadcast(self, *, text, source_event_table,
source_event_pk):
self.calls.append((text, source_event_table, source_event_pk))
return True
def _build_cfg():
from meshai.config import Config
cfg = Config()
cfg.notifications.rules = []
cfg.notifications.cold_start_grace_seconds = 0
cfg.notifications.band_conditions_enabled = True
cfg.notifications.band_conditions_schedule = ["06:00", "14:00", "22:00"]
rf = cfg.notifications.toggles.get("rf_propagation")
if rf is not None:
rf.enabled = True
rf.broadcast_channel = 1
return cfg
def test_fire_slot_records_broadcast_row(mem_db):
now = int(time.time())
_insert_swpc_kindex(mem_db, kp=2.0, when=now - 600)
_insert_swpc_alert(mem_db, sfi=140.0, when=now - 1200)
sched = BandConditionsScheduler(_build_cfg(), _MockDispatcher())
slot = now + 60 # arbitrary epoch
asyncio.run(sched.fire_slot(slot, "14:00"))
row = mem_db.execute(
"SELECT source, ratings_json FROM band_conditions_broadcasts "
"WHERE scheduled_for=?", (slot,)).fetchone()
assert row is not None
assert row["source"] == "swpc_local"
assert json.loads(row["ratings_json"])["30-20m"] == "Good"
def test_fire_slot_dedup_via_unique_constraint(mem_db):
"""Same scheduled_for fired twice -> only one row, only one broadcast."""
now = int(time.time())
_insert_swpc_kindex(mem_db, kp=2.0, when=now - 600)
_insert_swpc_alert(mem_db, sfi=140.0, when=now - 1200)
disp = _MockDispatcher()
sched = BandConditionsScheduler(_build_cfg(), disp)
slot = now + 60
asyncio.run(sched.fire_slot(slot, "14:00"))
asyncio.run(sched.fire_slot(slot, "14:00")) # dup firing
n_rows = mem_db.execute(
"SELECT COUNT(*) AS n FROM band_conditions_broadcasts "
"WHERE scheduled_for=?", (slot,)).fetchone()["n"]
assert n_rows == 1
assert len(disp.calls) == 1 # second firing aborted before dispatch
def test_fire_slot_silent_skip_when_no_data(mem_db):
"""No SWPC + (mocked) HamQSL failure -> source='skipped_no_data'."""
# Patch the HamQSL fetcher inside the module to simulate the HTTP error.
from meshai.notifications.scheduled import band_conditions as bc_mod
original = bc_mod._fetch_hamqsl
bc_mod._fetch_hamqsl = lambda day, _http_get=None: None
try:
sched = BandConditionsScheduler(_build_cfg(), _MockDispatcher())
slot = int(time.time()) + 60
asyncio.run(sched.fire_slot(slot, "14:00"))
row = mem_db.execute(
"SELECT source FROM band_conditions_broadcasts "
"WHERE scheduled_for=?", (slot,)).fetchone()
assert row is not None
assert row["source"] == "skipped_no_data"
finally:
bc_mod._fetch_hamqsl = original
# ---- record_slot_attempt direct unit test ------------------------------
def test_record_slot_attempt_returns_id_first_then_none(mem_db):
"""First insert returns a row id; duplicate returns None."""
bid1 = record_slot_attempt(1_000_000, source="swpc_local",
ratings={"80-40m": "Good"})
assert isinstance(bid1, int)
bid2 = record_slot_attempt(1_000_000, source="swpc_local",
ratings={"80-40m": "Good"})
assert bid2 is None
# ---- slot_epoch alignment ----------------------------------------------
def test_slot_epoch_returns_local_time_alignment():
"""slot_epoch('06:00', America/Boise) should be the same wall-clock
hour regardless of UTC offset (DST handled automatically)."""
now_dt = datetime(2026, 6, 5, 12, 0, tzinfo=timezone.utc)
ep = slot_epoch(now_dt, "06:00", "America/Boise")
# Mountain Daylight Time in June -> UTC-6 -> 06:00 MDT == 12:00 UTC.
dt = datetime.fromtimestamp(ep, tz=timezone.utc)
assert dt.hour == 12 and dt.minute == 0 # 06:00 MDT in June
# Winter date -> UTC-7 -> 06:00 MST == 13:00 UTC.
winter_dt = datetime(2026, 1, 15, 12, 0, tzinfo=timezone.utc)
ep = slot_epoch(winter_dt, "06:00", "America/Boise")
dt = datetime.fromtimestamp(ep, tz=timezone.utc)
assert dt.hour == 13 and dt.minute == 0 # 06:00 MST in January