fix(satpass): clean broadcast format (short names, degrees, compass, friendly observers) (#76)

Rewrite the satellite-pass wire to a single clean line: short ham names
(ISS/AO-27/AO-91), numeric max elevation (max 77°) instead of a bucket word,
collapsed compass sweeps (no E→E→E), and friendly observer names — dropping
the meaningless synthetic coverage_center parenthetical (and no longer seeding
that observer when explicit observers are configured). Absolute local time
kept for the 12h-advance heads-up.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-06 20:44:02 -06:00 committed by GitHub
commit 8d3f96857f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 276 additions and 96 deletions

View file

@ -22,13 +22,13 @@ Severity mapping:
3 = priority (>= 45 deg max elevation) 3 = priority (>= 45 deg max elevation)
<= 2 = routine <= 2 = routine
Broadcast wire format (two lines, LoRa-tight): Broadcast wire format (single line, LoRa-tight, absolute local time a
Consolidated (multi-observer): ~12h-ahead heads-up):
Line 1: 🛰 {name} {bucket}, {aos_compass}{los_compass} 🛰 {short_name} {rise} {AM/PM} {TZ}[ tomorrow], max {el}° {compass} ({dur} min)[ (region)]
Line 2: {duration} min window, {rise}{set} {AM/PM} MDT ({entry_obs}{exit_obs}) - short_name: short ham designation (ISS/AO-27/AO-91), else cleaned catalog
Single observer: - compass: aospeaklos with consecutive duplicates collapsed (no EEE)
Line 1: 🛰 {name} {bucket}, {aos_compass}{los_compass} - region: appended ONLY for a genuine multi-observer sweep with different
Line 2: {duration} min window, {rise}{set} {AM/PM} MDT friendly names; dropped for a single observer or the synthetic coverage_center
DM wire format (compact, exact degrees): DM wire format (compact, exact degrees):
{name} {HH:MM}{HH:MM} {TZ} max {el}° {aos_compass}{los_compass} {name} {HH:MM}{HH:MM} {TZ} max {el}° {aos_compass}{los_compass}
""" """
@ -36,6 +36,7 @@ from __future__ import annotations
import json import json
import logging import logging
import re
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Optional from typing import Any, Optional
@ -113,7 +114,11 @@ def _parse_iso_epoch(s) -> Optional[int]:
def _elevation_bucket(max_el: float) -> str: def _elevation_bucket(max_el: float) -> str:
"""Map max elevation to human-readable bucket name.""" """Map max elevation to human-readable bucket name.
Retained for the DM/other paths; the broadcast wire now shows numeric
degrees (`max NN°`) instead of a bucket word.
"""
if max_el >= 60: if max_el >= 60:
return "overhead" return "overhead"
if max_el >= 30: if max_el >= 30:
@ -121,6 +126,88 @@ def _elevation_bucket(max_el: float) -> str:
return "low pass" return "low pass"
# Short ham designations for common broadcast satellites, keyed by NORAD id.
# Listeners recognize "AO-91" far faster than the cluttered catalog name
# "RADFXSAT (FOX-1B)".
_SHORT_SAT_NAMES = {
25544: "ISS",
22825: "AO-27",
43017: "AO-91",
}
# Name-substring fallback (upper-cased contains) for when the NORAD id isn't
# in the map but the catalog name is recognizable.
_SHORT_NAME_SUBSTR = (
("ZARYA", "ISS"),
("EYESAT", "AO-27"),
("AO-27", "AO-27"),
("RADFXSAT", "AO-91"),
("FOX-1B", "AO-91"),
)
def _short_sat_name(norad_id: Optional[int], sat_name: Optional[str]) -> str:
"""Resolve a short, listener-friendly satellite name.
NORAD-id map first, then a name-substring fallback, then a cleaned
catalog name (parenthetical stripped, e.g. "RADFXSAT (FOX-1B)" ->
"RADFXSAT"). Always returns a non-empty string.
"""
nid: Optional[int] = None
if norad_id is not None:
try:
nid = int(norad_id)
except (TypeError, ValueError):
nid = None
if nid is not None and nid in _SHORT_SAT_NAMES:
return _SHORT_SAT_NAMES[nid]
up = (sat_name or "").upper()
for sub, short in _SHORT_NAME_SUBSTR:
if sub in up:
return short
cleaned = re.sub(r"\s*\(.*?\)", "", sat_name or "").strip()
return cleaned or (sat_name or "").strip() or "SAT"
def _collapse_compass(*points: Optional[str]) -> str:
"""Join compass points, dropping empties and consecutive duplicates.
"E","E","E" -> "E"; "E","SE","SE" -> "E→SE"; "S","W","NW" -> "S→W→NW".
"""
out: list[str] = []
for p in points:
if not p:
continue
if not out or out[-1] != p:
out.append(p)
return "".join(out)
# Synthetic coverage-centroid observer markers — these are meaningless to
# listeners, so the region parenthetical is dropped when either endpoint is one.
_SYNTHETIC_OBSERVERS = {"coverage_center", "coverage center"}
def _is_synthetic_observer(label: Optional[str]) -> bool:
return bool(label) and label.strip().lower() in _SYNTHETIC_OBSERVERS
def _region_paren(entry: Optional[str], exit_: Optional[str]) -> str:
"""Region suffix for a genuine multi-observer sweep, else ''.
Only rendered when both endpoints exist, differ, and neither is the
synthetic coverage-centroid observer. Single observer / synthetic ->
no parenthetical.
"""
if not entry or not exit_ or entry == exit_:
return ""
if _is_synthetic_observer(entry) or _is_synthetic_observer(exit_):
return ""
return f" ({entry}{exit_})"
def _format_time_12h(epoch: Optional[int]) -> str: def _format_time_12h(epoch: Optional[int]) -> str:
"""Format epoch to h:mm AM/PM in America/Boise.""" """Format epoch to h:mm AM/PM in America/Boise."""
if epoch is None: if epoch is None:
@ -204,54 +291,53 @@ def format_pass(*, sat_name: str, max_el: float,
broadcast: bool = True, broadcast: bool = True,
entry_observer: Optional[str] = None, entry_observer: Optional[str] = None,
exit_observer: Optional[str] = None, exit_observer: Optional[str] = None,
peak_compass: Optional[str] = None) -> str: peak_compass: Optional[str] = None,
norad_id: Optional[int] = None) -> str:
"""Unified pass formatter with mode switch. """Unified pass formatter with mode switch.
broadcast=True: Two-line format with buckets, 12h times, LoRa budget. broadcast=True: Single clean line, absolute local time (a ~12h-ahead
🛰 {name} {bucket}, {aos_compass}[peak_compass]{los_compass} heads-up), LoRa budget.
{duration} min window, {rise}{set} {AM/PM} {TZ} [tomorrow] [(region)] 🛰 {short_name} {rise} {AM/PM} {TZ}[ tomorrow], max {el}° {compass} ({dur} min)[ (region)]
broadcast=False: Compact DM format with exact degrees. broadcast=False: Compact DM format with exact degrees.
{name} {HH:MM}{HH:MM} {TZ} max {el}° {aos_compass}[peak]{los_compass} {name} {HH:MM}{HH:MM} {TZ} max {el}° {aos_compass}[peak]{los_compass}
peak_compass: compass direction at peak elevation. When provided, the peak_compass: compass direction at peak elevation. Threaded through the
compass segment renders as aospeaklos; when None it stays aoslos compass sweep (aospeaklos); consecutive duplicate points are
(preserving legacy callers that don't thread the peak field). collapsed so a degenerate "E→E→E" renders as "E".
norad_id: used to resolve the short ham name for the broadcast wire.
""" """
# Compass sweep segment: include the peak point only when supplied. # Compass sweep segment: aos->peak->los with consecutive duplicates dropped.
if peak_compass: compass_seg = _collapse_compass(aos_compass, peak_compass, los_compass)
compass_seg = f"{aos_compass}{peak_compass}{los_compass}"
# Duration in whole minutes
if aos_epoch is not None and los_epoch is not None:
dur_min = max(1, round((los_epoch - aos_epoch) / 60))
else: else:
compass_seg = f"{aos_compass}{los_compass}" dur_min = 0
if broadcast: if broadcast:
bucket = _elevation_bucket(max_el) name = _short_sat_name(norad_id, sat_name)
# Duration in whole minutes
if aos_epoch is not None and los_epoch is not None:
dur_min = max(1, round((los_epoch - aos_epoch) / 60))
else:
dur_min = 0
rise_str = _format_time_12h(aos_epoch) rise_str = _format_time_12h(aos_epoch)
set_str = _format_time_12h(los_epoch) ampm = _format_ampm(aos_epoch)
ampm = _format_ampm(los_epoch)
tz = _tz_abbr(aos_epoch) tz = _tz_abbr(aos_epoch)
date_lbl = _date_label(aos_epoch) date_lbl = _date_label(aos_epoch)
el = int(round(max_el)) if max_el is not None else 0
region = _region_paren(entry_observer, exit_observer)
line1 = f"\U0001F6F0\uFE0F {sat_name} {bucket}, {compass_seg}" # Elevation + compass; the compass may be empty when no azimuth data
# was available, in which case it is simply omitted (no stray space).
# Build time portion core = f"max {el}\u00B0"
time_part = f"{dur_min} min window, {rise_str}\u2013{set_str} {ampm} {tz}{date_lbl}" if compass_seg:
core += f" {compass_seg}"
# Region parenthetical: multi-observer sweep or single-observer location line = (
if entry_observer and exit_observer and entry_observer != exit_observer: f"\U0001F6F0\uFE0F {name} {rise_str} {ampm} {tz}{date_lbl}, "
line2 = f"{time_part} ({entry_observer}\u2192{exit_observer})" f"{core} ({dur_min} min){region}"
elif entry_observer: )
line2 = f"{time_part} ({entry_observer})"
else:
line2 = time_part
# Safety cap: fit the broadcast string to the mesh packet budget. # Safety cap: fit the broadcast string to the mesh packet budget.
return fit_to_budget(f"{line1}\n{line2}", budget_for("satpass")) return fit_to_budget(line, budget_for("satpass"))
else: else:
# DM format: compact with exact degrees # DM format: compact with exact degrees
aos_str = _format_time_24h(aos_epoch) aos_str = _format_time_24h(aos_epoch)
@ -500,7 +586,7 @@ def gate_consolidated_pass(consolidated: dict, *,
return None return None
# Build consolidated wire — always pass observer names for region context # Build consolidated wire — always pass observer names for region context
wire = format_pass(sat_name=sat_name, max_el=max_el, wire = format_pass(sat_name=sat_name, max_el=max_el, norad_id=norad_id,
aos_epoch=aos_epoch, los_epoch=los_epoch, aos_epoch=aos_epoch, los_epoch=los_epoch,
aos_compass=aos_compass, los_compass=los_compass, aos_compass=aos_compass, los_compass=los_compass,
peak_compass=peak_compass, peak_compass=peak_compass,

View file

@ -159,8 +159,10 @@ class SatpassAdapter:
"aos_compass": entry["aos_compass"], "aos_compass": entry["aos_compass"],
"los_compass": exit_["los_compass"], "los_compass": exit_["los_compass"],
"peak_compass": best["peak_compass"], "peak_compass": best["peak_compass"],
"entry_observer": entry["observer"], # Entry/exit carry the FRIENDLY observer name for the wire's region
"exit_observer": exit_["observer"], # parenthetical; observer_list stays slugs for the audit column.
"entry_observer": entry.get("observer_label") or entry["observer"],
"exit_observer": exit_.get("observer_label") or exit_["observer"],
"observer_list": ",".join(r["observer"] for r in by_aos), "observer_list": ",".join(r["observer"] for r in by_aos),
} }
@ -208,6 +210,7 @@ class SatpassAdapter:
"norad_id": norad, "norad_id": norad,
"sat_name": name, "sat_name": name,
"observer": obs["slug"], "observer": obs["slug"],
"observer_label": obs.get("name") or obs["slug"],
"max_elevation": p.max_elevation, "max_elevation": p.max_elevation,
"aos_epoch": aos_epoch, "aos_epoch": aos_epoch,
"los_epoch": los_epoch, "los_epoch": los_epoch,

View file

@ -419,7 +419,15 @@ class MeshAI:
_cov_bbox, _cov_bbox,
"native", "native",
) )
if _sat_scope is not None: # Only seed the synthetic coverage-centroid observer when the
# operator has NOT listed explicit satpass observers. Explicit
# observers win — the centroid is meaningless to listeners and, if
# seeded alongside real stations, leaks into broadcasts as the
# stale "coverage_center" region. (Existing DB rows are left for an
# ops cleanup; this only stops re-creating it.)
_explicit_observers = list(
getattr(self.config.environmental.satpass, "observers", None) or [])
if _sat_scope is not None and not _explicit_observers:
lat, lon = _sat_scope["centroid"] lat, lon = _sat_scope["centroid"]
observers_to_seed = [ observers_to_seed = [
{"slug": "coverage_center", "name": "Coverage Center", {"slug": "coverage_center", "name": "Coverage Center",

View file

@ -333,12 +333,11 @@ class TestElevationDefault:
class TestBroadcastWireFormat: class TestBroadcastWireFormat:
"""Two-line format, buckets, byte budget.""" """Two-line format, buckets, byte budget."""
def test_exact_two_line_example(self): def test_exact_single_line_example(self):
"""Formatter produces the exact two-line example from spec.""" """Formatter produces the exact single-line target format."""
from meshai.central.satpass_handler import format_pass from meshai.central.satpass_handler import format_pass
# ISS high pass, SW→NE, 6 minute window, 8:388:44 PM MDT # ISS, SW->NE, 6-minute window, rises 8:38 PM MDT, max 55 deg.
# We need epoch values that produce 8:38 PM and 8:44 PM MDT
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
tz = ZoneInfo("America/Boise") tz = ZoneInfo("America/Boise")
aos_dt = datetime(2026, 6, 12, 20, 38, 0, tzinfo=tz) aos_dt = datetime(2026, 6, 12, 20, 38, 0, tzinfo=tz)
@ -347,22 +346,19 @@ class TestBroadcastWireFormat:
los_epoch = int(los_dt.timestamp()) los_epoch = int(los_dt.timestamp())
wire = format_pass( wire = format_pass(
sat_name="ISS", max_el=55.0, sat_name="ISS", norad_id=25544, max_el=55.0,
aos_epoch=aos_epoch, los_epoch=los_epoch, aos_epoch=aos_epoch, los_epoch=los_epoch,
aos_compass="SW", los_compass="NE", aos_compass="SW", los_compass="NE",
broadcast=True, broadcast=True,
) )
lines = wire.split("\n") # Single line: absolute local rise time, numeric max elevation, and a
assert len(lines) == 2 # date qualifier ("Fri Jun 12" \u2014 the fixed date is always in the past).
assert "\n" not in wire
# No peak_compass passed here \u2192 the sweep stays aos\u2192los (SW\u2192NE). assert wire == (
assert lines[0] == "\U0001F6F0\uFE0F ISS high pass, SW\u2192NE" "\U0001F6F0\uFE0F ISS 8:38 PM MDT Fri Jun 12, "
# line2 renders "min window" + a date qualifier for a pass not "max 55\u00B0 SW\u2192NE (6 min)"
# occurring today (the fixed 2026-06-12 date is always in the past )
# relative to run time).
assert lines[1].startswith("6 min window, 8:38\u20138:44 PM MDT")
assert lines[1] == "6 min window, 8:38\u20138:44 PM MDT Fri Jun 12"
def test_bucket_overhead_at_60(self): def test_bucket_overhead_at_60(self):
"""max_el=60 should be 'overhead'.""" """max_el=60 should be 'overhead'."""
@ -648,3 +644,96 @@ class TestStalenessGuard:
# but the staleness guard specifically must not block it. # but the staleness guard specifically must not block it.
# Since all other filters pass, wire should be produced. # Since all other filters pass, wire should be produced.
assert result is not None, "None los_epoch should fall through staleness guard" assert result is not None, "None los_epoch should fall through staleness guard"
# ══════════════════════════════════════════════════════════════════════
# 6. CLEAN FORMAT: short names, degrees, compass collapse, friendly obs
# ══════════════════════════════════════════════════════════════════════
class TestCleanBroadcastFormat:
"""The format-cleanup rules (short names, degrees, compass, observers)."""
@staticmethod
def _wire(**kw):
from meshai.central.satpass_handler import format_pass
from zoneinfo import ZoneInfo
tz = ZoneInfo("America/Boise")
base = dict(
sat_name="X", max_el=50.0,
aos_epoch=int(datetime(2026, 6, 12, 20, 38, 0, tzinfo=tz).timestamp()),
los_epoch=int(datetime(2026, 6, 12, 20, 44, 0, tzinfo=tz).timestamp()),
aos_compass="S", los_compass="N", broadcast=True,
)
base.update(kw)
return format_pass(**base)
def test_short_name_ao91_from_norad(self):
wire = self._wire(norad_id=43017, sat_name="RADFXSAT (FOX-1B)")
assert "AO-91" in wire
assert "RADFXSAT" not in wire
assert "FOX-1B" not in wire
def test_short_name_iss_and_ao27(self):
assert "ISS" in self._wire(norad_id=25544, sat_name="ISS (ZARYA)")
assert "AO-27" in self._wire(norad_id=22825, sat_name="EYESAT-1 (AO-27)")
def test_short_name_substring_fallback(self):
# No NORAD mapping, but the catalog name is recognizable.
wire = self._wire(norad_id=99999, sat_name="RADFXSAT (FOX-1B)")
assert "AO-91" in wire
def test_unmapped_name_is_cleaned(self):
# Parenthetical stripped for an unmapped satellite.
wire = self._wire(norad_id=40000, sat_name="METEOR-M2 (WEATHER)")
assert "METEOR-M2" in wire
assert "(WEATHER)" not in wire
def test_compass_collapse_all_equal(self):
wire = self._wire(aos_compass="E", peak_compass="E", los_compass="E")
assert "E→E→E" not in wire
assert " E " in wire # a lone collapsed "E"
def test_compass_collapse_trailing_dup(self):
wire = self._wire(aos_compass="E", peak_compass="SE", los_compass="SE")
assert "E→SE" in wire
assert "E→SE→SE" not in wire
def test_compass_no_collapse_when_distinct(self):
wire = self._wire(aos_compass="S", peak_compass="W", los_compass="NW")
assert "S→W→NW" in wire
def test_shows_numeric_degrees_not_bucket(self):
wire = self._wire(max_el=77.0)
assert "max 77°" in wire
assert "high pass" not in wire
assert "overhead" not in wire
def test_multi_observer_region_shown(self):
wire = self._wire(entry_observer="Treasure Valley",
exit_observer="Magic Valley")
assert "(Treasure Valley→Magic Valley)" in wire
def test_single_observer_no_region(self):
wire = self._wire(entry_observer="Boise", exit_observer="Boise")
assert "(" not in wire.split("min)")[-1] # nothing after the (N min)
def test_coverage_center_region_dropped(self):
wire = self._wire(entry_observer="coverage_center",
exit_observer="Magic Valley")
assert "coverage_center" not in wire
assert "Coverage Center" not in wire
# Also the friendly synthetic label form.
wire2 = self._wire(entry_observer="Coverage Center",
exit_observer="Magic Valley")
assert "Coverage Center" not in wire2
def test_golden_line(self):
# A full golden line matching the target format for a sample pass.
wire = self._wire(
norad_id=25544, sat_name="ISS (ZARYA)", max_el=77.0,
aos_compass="S", peak_compass=None, los_compass="NW",
)
assert wire == (
"\U0001F6F0 ISS 8:38 PM MDT Fri Jun 12, "
"max 77° S→NW (6 min)"
)

View file

@ -185,12 +185,9 @@ def test_satpass_predict_compass_from_raw_azimuths():
assert result is not None, "handler returned None for satpass_predict envelope" assert result is not None, "handler returned None for satpass_predict envelope"
wire, _ = result wire, _ = result
lines = wire.split("\n") # Single-line wire: extract the compass segment between "max NN° " and " (".
assert len(lines) == 2, f"Expected 2 lines, got {len(lines)}: {wire!r}" assert "\n" not in wire, f"Expected single line: {wire!r}"
compass = wire.split("° ", 1)[1].split(" (", 1)[0]
# Line 1 format: name bucket, aos→peak→los. Raw azimuths convert
# to a non-empty 3-point compass sweep.
compass = lines[0].split(", ")[-1]
parts = compass.split("\u2192") parts = compass.split("\u2192")
assert len(parts) == 3, f"Expected aos->peak->los sweep, got {parts!r}" assert len(parts) == 3, f"Expected aos->peak->los sweep, got {parts!r}"
# 163.2 -> S, 245.0 -> SW (peak), 348.7 -> N (8-point compass) # 163.2 -> S, 245.0 -> SW (peak), 348.7 -> N (8-point compass)
@ -218,9 +215,9 @@ def test_n2yo_precomputed_compass_unchanged():
assert result is not None, "handler returned None for n2yo envelope" assert result is not None, "handler returned None for n2yo envelope"
wire, _ = result wire, _ = result
lines = wire.split("\n") # Single-line wire: must use the precomputed strings verbatim: SE→ENE→N.
# Must use the precomputed strings verbatim: SE→ENE→N assert "\n" not in wire, f"Expected single line: {wire!r}"
compass = lines[0].split(", ")[-1] compass = wire.split("° ", 1)[1].split(" (", 1)[0]
parts = compass.split("\u2192") parts = compass.split("\u2192")
assert len(parts) == 3, f"Expected aos->peak->los sweep, got {parts!r}" assert len(parts) == 3, f"Expected aos->peak->los sweep, got {parts!r}"
assert parts[0] == "SE", f"Expected precomputed aos SE: {parts!r}" assert parts[0] == "SE", f"Expected precomputed aos SE: {parts!r}"
@ -255,8 +252,12 @@ def test_no_compass_no_azimuth_no_crash():
assert result is not None, "handler crashed or returned None — should produce wire with empty compass" assert result is not None, "handler crashed or returned None — should produce wire with empty compass"
wire, _ = result wire, _ = result
lines = wire.split("\n") # Single clean line; with no azimuth data the compass segment is simply
assert len(lines) == 2 # omitted (no arrow, no stray double space) and the wire still renders.
# Arrow still present even with empty (peak empty -> no peak segment): assert "\n" not in wire, f"Expected single line: {wire!r}"
assert "\u2192" in lines[0], f"Expected arrow in line 1 even with empty compass: {lines[0]!r}" assert wire.startswith("\U0001F6F0")
assert "max" in wire
assert "min)" in wire
assert "\u2192" not in wire # empty compass -> no sweep arrows
assert "\u00b0 (" not in wire # no stray double space where compass would be
# No crash = test passes # No crash = test passes

View file

@ -154,7 +154,7 @@ class TestSatpassHandler:
assert result2 is None assert result2 is None
def test_wire_format(self, mock_adapter_config): def test_wire_format(self, mock_adapter_config):
"""Wire format should have 2 lines with correct info.""" """Wire is a single clean line: short name, degrees, collapsed compass."""
env = _envelope(sat_name="ISS", max_el=75, observer="Boise", env = _envelope(sat_name="ISS", max_el=75, observer="Boise",
direction="S", aos_compass="SW", los_compass="NE") direction="S", aos_compass="SW", los_compass="NE")
result = _ingest_and_consolidate(env, "central.sat.pass.iss", result = _ingest_and_consolidate(env, "central.sat.pass.iss",
@ -162,14 +162,12 @@ class TestSatpassHandler:
assert result is not None assert result is not None
wire, _ = result wire, _ = result
lines = wire.split("\n") assert "\n" not in wire # single line now
assert len(lines) == 2 assert "ISS" in wire
assert "ISS" in lines[0] assert "max 75°" in wire # numeric elevation, not a bucket word
assert "overhead" in lines[0] assert "overhead" not in wire
assert "SW" in lines[0] # aos_compass assert "min window" not in wire
assert "S" in lines[0] # peak_compass (new) assert "SW→S→NE" in wire # aos -> peak -> los, collapsed
assert "NE" in lines[0] # los_compass
assert "min window" in lines[1]
def test_commit_callback_attached(self, mock_adapter_config): def test_commit_callback_attached(self, mock_adapter_config):
"""Broadcast should attach commit callback (on the consolidation data).""" """Broadcast should attach commit callback (on the consolidation data)."""

View file

@ -136,9 +136,9 @@ def test_two_observers_consolidate_to_one_broadcast(monkeypatch):
assert row["los_at"] == T0 + 400 # twin (latest LOS) assert row["los_at"] == T0 + 400 # twin (latest LOS)
assert "boise" in row["observer"] and "twin" in row["observer"] assert "boise" in row["observer"] and "twin" in row["observer"]
# Wire carries entry->exit region + aos->peak->los compass sweep. # Wire carries entry->exit region (FRIENDLY names) + aos->peak->los sweep.
wire = evt["wire"] wire = evt["wire"]
assert "boise→twin" in wire # entry -> exit assert "(Boise→Twin Falls)" in wire # friendly entry -> exit
assert "SW→S→NE" in wire # aos -> peak(twin) -> los assert "SW→S→NE" in wire # aos -> peak(twin) -> los

View file

@ -175,18 +175,13 @@ def test_noaa18_wire_message_format():
assert result is not None assert result is not None
wire, _ = result wire, _ = result
lines = wire.split("\n") # Single clean line: name, numeric elevation, aos->peak->los compass sweep.
assert len(lines) == 2, f"Expected 2 lines, got {len(lines)}: {wire!r}" assert "\n" not in wire, f"Expected single line: {wire!r}"
assert "NOAA 18" in wire # no mapping -> cleaned catalog name
# Line 1: satellite name + bucket + compass sweep (aos->peak->los) assert "max 23°" in wire # 22.69 rounds to 23, not "low pass"
assert "NOAA 18" in lines[0] assert "low pass" not in wire
assert "low pass" in lines[0] # 22.69 < 30 = low pass assert "min window" not in wire
assert "SE" in lines[0] # aos_compass assert "SE→ENE→N" in wire # aos -> peak -> los
assert "ENE" in lines[0] # peak_compass (new)
assert "N" in lines[0] # los_compass
# Line 2: duration + time window ("min window")
assert "min window" in lines[1]
def test_missing_norad_id_rejected(): def test_missing_norad_id_rejected():