mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 01:14:45 +02:00
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>
312 lines
11 KiB
Python
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
|