meshai/work/meshai/env/tle_fetch.py
malice 41178831e4
chore(central-ripout 2b): relocate satellite code to env/satellite/ (#162)
* chore(central-ripout 2b): create env/satellite package, move pass_predictor

pass_predictor.py was 100% live (no dead entrypoint) — SGP4 pass
computation used by both the native satpass adapter and the on-demand
!satpass command. Straight move, no code changes: meshai.central.pass_predictor
-> meshai.env.satellite.pass_predictor. Owner directive: satellite code gets
its own folder under the feed adapters, separate from env.satpass (the
adapter) to avoid colliding with env/satpass.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(central-ripout 2b): split satpass_handler.py -> env/satellite/pass_format.py

satpass_handler.py was a split file: live wire-formatting/gate logic plus
dead Central-envelope ingest machinery whose only caller was the
already-deleted central/consumer.py NATS bridge.

Moved (live, verified via rg — external callers in env/satpass.py and
commands/satpass_cmd.py, or transitively called by them):
  gate_consolidated_pass, format_pass, _check_rate_cap, _upsert_satpass,
  _attach_commit, _map_severity, _canonical_id, _azimuth_to_compass,
  _short_sat_name, _collapse_compass, _region_paren, _is_synthetic_observer,
  _format_time_12h/24h, _format_ampm, _tz_abbr, _date_label,
  plus the _SHORT_SAT_NAMES/_SHORT_NAME_SUBSTR/_SYNTHETIC_OBSERVERS tables.

Dropped (dead — zero callers outside the already-deleted consumer.py and
handle_satpass/consolidate_satpass_pending themselves; verified with rg):
  handle_satpass, consolidate_satpass_pending, _cleanup_pending,
  load_pending_schedule, _log_event_returning_id, _coerce_float,
  _coerce_int, _parse_iso_epoch, _now, CONSOLIDATION_DELAY,
  _pending_consolidation_ids, drain_pending_consolidation_ids,
  _elevation_bucket (already-orphaned pre-ripout: superseded by numeric
  "max NN°" wire format, zero callers anywhere but its own tests),
  SCHEMA_SATPASS_EVENTS/SCHEMA_SATPASS_PENDING (unused string constants —
  actual schema lives in persistence/migrations/*.sql, never imported).
  Also dropped now-unused `json`/`time`/`Any` imports.

Straight code move otherwise — no logic changes to any moved function.
Two docstrings updated for accuracy (module docstring, and
gate_consolidated_pass's docstring which referenced the now-deleted
Central consumer path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(central-ripout 2b): split tle_handler.py -> env/satellite/tle_store.py

tle_handler.py was a split file: live storage helpers plus a dead
Central-envelope ingest entrypoint whose only caller was the
already-deleted central/consumer.py NATS bridge.

Moved (live — used by env.tle_fetch, env.satpass, commands.satpass_cmd,
verified via rg): upsert_tle, get_fresh_tles, get_tle_by_norad,
search_tle_by_name.

Dropped (dead — handle_tle's only callers were tests and the deleted
consumer.py; verified with rg): handle_tle.

Straight code move otherwise — no logic changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(central-ripout 2b): repoint satellite consumers at env.satellite

Rewire the three production consumers (including their lazy/function-body
imports, not just module-top ones) to the new location:
  - env/satpass.py: central.tle_handler -> env.satellite.tle_store,
    central.pass_predictor -> env.satellite.pass_predictor,
    central.satpass_handler -> env.satellite.pass_format
  - env/tle_fetch.py: central.tle_handler.upsert_tle -> env.satellite.tle_store
  - commands/satpass_cmd.py: all three, same mapping

Also refreshed docstrings that pointed at the old module paths or described
the now-fully-deleted Central consolidation path
(consolidate_satpass_pending / satpass_pending buffer) as a live
alternative, and updated central/__init__.py's module docstring to stop
listing the three relocated modules among central's remaining contents.

No behavior changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(central-ripout 2b): update satpass/tle test suite for the relocation

Repoints every remaining test import at the new env.satellite.* modules
and removes/adapts coverage for the Central envelope-ingest path deleted
in this pass (handle_satpass, consolidate_satpass_pending, handle_tle, and
the filtering/coercion/staleness logic that lived only inside them):

- test_satpass_native.py, test_tle_fetch.py, test_satpass_command.py:
  import-path updates only (pass_predictor, tle_store). Also dropped
  test_satpass_command.py's TestTLEUpsert.test_returns_none_always
  (handle_tle-specific contract, no longer applicable) and rewrote its two
  latest-wins tests to call upsert_tle directly — same behavior under test,
  now exercised through the still-live primitive instead of the dead
  wrapper.
- test_satpass_native.py: deleted test_central_consolidate_feeds_shared_gate_merged
  (spied on consolidate_satpass_pending, which no longer exists). The
  merge-across-observers logic it guarded is native-side (_consolidate)
  and already covered by test_two_observers_consolidate_to_one_broadcast.
- test_satpass_handler.py: gutted to the one test that calls format_pass
  directly (test_format_pass_worst_case_fits_140); the rest exercised
  handle_satpass's observer/norad/elevation filters, which have no live
  equivalent (the native adapter filters at the config level, not
  per-envelope) and is redundant with test_satpass_native.py's dedup/wire
  coverage via the real SatpassAdapter path.
- test_satpass_broadcast_safety.py: kept every test that calls format_pass
  or gate_consolidated_pass-adjacent REGISTRY checks directly (wire format,
  clean-format rules, REGISTRY defaults); deleted TestNoradIdTypeCoercion
  and TestStalenessGuard (handle_satpass-only logic, no live equivalent)
  and the 6 _elevation_bucket tests (_elevation_bucket itself was dead
  before this pass too — zero callers anywhere but its own tests, already
  superseded by the numeric "max NN°" wire format per its own docstring).
- test_satpass_persisted_timer.py: dropped test_due_at_persisted_on_normal_ingest
  (handle_satpass-only); kept the two schema/migration tests, which don't
  touch satpass_handler.
- Deleted outright (tested ONLY the dead Central envelope-ingest path, no
  live equivalent to port to): test_satpass_event_path.py,
  test_satpass_compass_fallback.py, test_satpass_wire_fields.py.

Full suite: 2059 passed, 0 failed (was 0 failed on main pre-change).
Satpass/TLE subset (99 tests across 8 files) verified green in isolation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:46:36 -06:00

237 lines
8.7 KiB
Python

"""Celestrak TLE fetcher — native, keyless population of sat_tles.
Storage-only native adapter: it does NOT emit mesh Events. Its sole job is
to keep the shared `sat_tles` table populated with fresh two-line element
sets so the native SGP4 pass-predictor has current orbital data when
satpass runs with feed_source="native".
Source: Celestrak GP API (https://celestrak.org/NORAD/elements/gp.php),
FORMAT=tle (classic 3-line: name / line1 / line2). Two selector styles:
- GROUP (e.g. ?GROUP=weather&FORMAT=tle) — a curated catalog group
- CATNR (e.g. ?CATNR=25544&FORMAT=tle) — a single NORAD id
Config (SatpassConfig): `tle_groups` (list of group names), `norad_ids`
(list of NORAD catalog ids), `tle_refresh_seconds` (poll interval; TLEs
update ~daily so the default is 6h). Gated on feed_source=="native" via
the normal EnvironmentalStore registration.
Upserts flow through `env.satellite.tle_store.upsert_tle`, the shared
latest-epoch-wins helper used by every writer of the `sat_tles` table. The
TLE line-1 epoch field (columns 19-32) is parsed into an ISO-8601 string so
epochs are directly comparable regardless of source.
"""
from __future__ import annotations
import datetime
import logging
import time
from typing import TYPE_CHECKING, Optional
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
if TYPE_CHECKING:
from ..config import SatpassConfig
logger = logging.getLogger(__name__)
GP_BASE_URL = "https://celestrak.org/NORAD/elements/gp.php"
def _cfg_str(config, attr: str, default: str) -> str:
"""Read a string config field, falling back to `default` if absent,
empty, or not a real string (e.g. an unconfigured test mock)."""
value = getattr(config, attr, None)
return value if isinstance(value, str) and value else default
def parse_tle_epoch(line1: str) -> str:
"""Parse the epoch from TLE line 1 into an ISO-8601 UTC string.
The epoch occupies columns 19-32 (1-indexed) of line 1 in the form
``YYDDD.DDDDDDDD``: a two-digit year, day-of-year, and fractional day.
Two-digit years 57-99 map to 1957-1999; 00-56 map to 2000-2056 (the
standard NORAD windowing).
Returns an ISO-8601 string (e.g. "2026-07-01T12:00:00+00:00") so the
value is lexicographically comparable with the ISO epochs the Central
handler stores. Raises ValueError on a malformed field.
"""
raw = line1[18:32].strip()
yy = int(raw[0:2])
year = 2000 + yy if yy < 57 else 1900 + yy
doy = float(raw[2:])
if doy < 1:
raise ValueError(f"bad day-of-year in TLE epoch: {raw!r}")
dt = (datetime.datetime(year, 1, 1, tzinfo=datetime.timezone.utc)
+ datetime.timedelta(days=doy - 1.0))
return dt.isoformat()
def parse_tle_block(text: str) -> list[dict]:
"""Parse a 3-line-per-satellite TLE block into records.
Accepts the Celestrak FORMAT=tle payload: repeating triples of
(name, line1, line2). Tolerant of blank lines and trailing whitespace.
A malformed triple (missing/short lines, non-integer NORAD id, or an
unparseable epoch) is skipped with a debug log rather than aborting the
whole block.
Returns a list of dicts: {norad_id, name, line1, line2, epoch}.
"""
lines = [ln.rstrip() for ln in text.splitlines() if ln.strip()]
out: list[dict] = []
i = 0
while i + 2 <= len(lines) - 1:
# Three lines (name, line1, line2) available at i, i+1, i+2.
name = lines[i].strip()
line1 = lines[i + 1]
line2 = lines[i + 2]
# Validate structural markers before consuming the triple.
if not (line1.startswith("1 ") and line2.startswith("2 ")):
# Not aligned to a triple boundary — skip one line and resync.
i += 1
continue
try:
norad_id = int(line1[2:7])
epoch = parse_tle_epoch(line1)
except (ValueError, IndexError) as e:
logger.debug("tle_fetch: skipping malformed TLE for %r: %s", name, e)
i += 3
continue
out.append({
"norad_id": norad_id,
"name": name or f"SAT-{norad_id}",
"line1": line1,
"line2": line2,
"epoch": epoch,
})
i += 3
return out
class TLEFetchAdapter:
"""Native Celestrak TLE fetcher — populates sat_tles, emits no events."""
def __init__(self, config: "SatpassConfig"):
self._config = config
self._tle_base_url = _cfg_str(config, "tle_base_url", GP_BASE_URL)
self._last_tick = 0.0
self._last_error: Optional[str] = None
self._consecutive_errors = 0
self._is_loaded = False
self._upserted = 0 # rows written on the most recent successful tick
self._interval = int(getattr(config, "tle_refresh_seconds", 21600) or 21600)
# -- fetch targets --------------------------------------------------------
def _targets(self) -> list[tuple[str, str]]:
"""Build the (label, url) fetch list from config groups + norad_ids."""
targets: list[tuple[str, str]] = []
for group in (getattr(self._config, "tle_groups", None) or []):
targets.append(
(f"GROUP={group}", f"{self._tle_base_url}?GROUP={group}&FORMAT=tle"))
for norad in (getattr(self._config, "norad_ids", None) or []):
targets.append(
(f"CATNR={norad}", f"{self._tle_base_url}?CATNR={norad}&FORMAT=tle"))
return targets
# -- polling --------------------------------------------------------------
def tick(self, now: Optional[float] = None) -> bool:
"""One slow poll. Returns True if any TLE row changed.
Resilient: a failed target logs + is skipped and updates health; a
thrown exception never propagates out of tick().
"""
now = now if now is not None else time.time()
if now - self._last_tick < self._interval:
return False
self._last_tick = now
targets = self._targets()
if not targets:
logger.debug("tle_fetch: no groups/norad_ids configured; nothing to fetch")
self._is_loaded = True
self._upserted = 0
return False
written = 0
errors = 0
for label, url in targets:
try:
text = self._fetch(url)
except Exception as e:
errors += 1
self._last_error = f"{label}: {e}"
logger.warning("tle_fetch: fetch failed for %s: %s", label, e)
continue
try:
written += self._store(parse_tle_block(text))
except Exception as e:
errors += 1
self._last_error = f"{label}: store {e}"
logger.warning("tle_fetch: store failed for %s: %s", label, e)
self._upserted = written
if errors == 0:
self._last_error = None
self._consecutive_errors = 0
else:
self._consecutive_errors += 1
self._is_loaded = True
return written > 0
def _fetch(self, url: str) -> str:
"""HTTP GET a Celestrak GP endpoint, returning the decoded body."""
headers = {"User-Agent": "MeshAI/1.0", "Accept": "text/plain"}
req = Request(url, headers=headers)
try:
with urlopen(req, timeout=20) as resp:
return resp.read().decode("utf-8", errors="replace")
except HTTPError as e:
raise RuntimeError(f"HTTP {e.code}") from e
except URLError as e:
raise RuntimeError(str(e.reason)) from e
def _store(self, records: list[dict]) -> int:
"""Upsert parsed TLE records into sat_tles. Returns rows written."""
if not records:
return 0
from meshai.persistence import get_db
from meshai.env.satellite.tle_store import upsert_tle
conn = get_db()
now = int(time.time())
written = 0
for rec in records:
if upsert_tle(conn, rec["norad_id"], rec["name"],
rec["line1"], rec["line2"], rec["epoch"], now=now):
written += 1
return written
# -- store integration ----------------------------------------------------
def get_events(self) -> list:
"""Storage-only adapter: never produces mesh events."""
return []
def to_event(self, evt: dict):
"""Storage-only adapter: no event translation."""
return None
@property
def health_status(self) -> dict:
return {
"source": "satpass_tle",
"is_loaded": self._is_loaded,
"last_error": self._last_error,
"consecutive_errors": self._consecutive_errors,
"event_count": 0,
"last_fetch": self._last_tick,
"tles_upserted": self._upserted,
}