mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-10 17:04:45 +02:00
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>
This commit is contained in:
parent
de35f9c748
commit
0da83e0d3d
9 changed files with 957 additions and 1 deletions
312
tests/test_band_conditions.py
Normal file
312
tests/test_band_conditions.py
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue