mirror of
https://github.com/smittix/intercept.git
synced 2026-07-21 07:48:11 -07:00
Add ground station automation with 6-phase implementation
Phase 1 - Automated observation engine: - utils/ground_station/scheduler.py: GroundStationScheduler fires at AOS/LOS, claims SDR, manages IQBus lifecycle, emits SSE events - utils/ground_station/observation_profile.py: ObservationProfile dataclass + DB CRUD - routes/ground_station.py: REST API for profiles, scheduler, observations, recordings, rotator; SSE stream; /ws/satellite_waterfall WebSocket - DB tables: observation_profiles, ground_station_observations, ground_station_events, sigmf_recordings (added to utils/database.py init_db) - app.py: ground_station_queue, WebSocket init, scheduler startup in _deferred_init - routes/__init__.py: register ground_station_bp Phase 2 - Doppler correction: - utils/doppler.py: generalized DopplerTracker extracted from sstv_decoder.py; accepts satellite name or raw TLE tuple; thread-safe; update_tle() method - utils/sstv/sstv_decoder.py: replace inline DopplerTracker with import from utils.doppler - Scheduler runs 5s retune loop; calls rotator.point_to() if enabled Phase 3 - IQ recording (SigMF): - utils/sigmf.py: SigMFWriter writes .sigmf-data + .sigmf-meta; disk-free guard (500MB) - utils/ground_station/consumers/sigmf_writer.py: SigMFConsumer wraps SigMFWriter Phase 4 - Multi-decoder IQ broadcast pipeline: - utils/ground_station/iq_bus.py: IQBus single-producer fan-out; IQConsumer Protocol - utils/ground_station/consumers/waterfall.py: CU8→FFT→binary frames - utils/ground_station/consumers/fm_demod.py: CU8→FM demod (numpy)→decoder subprocess - utils/ground_station/consumers/gr_satellites.py: CU8→cf32→gr_satellites (optional) Phase 5 - Live spectrum waterfall: - static/js/modes/ground_station_waterfall.js: /ws/satellite_waterfall canvas renderer - Waterfall panel in satellite dashboard sidebar, auto-shown on iq_bus_started SSE event Phase 6 - Antenna rotator control (optional): - utils/rotator.py: RotatorController TCP client for rotctld (Hamlib line protocol) - Rotator panel in satellite dashboard; silently disabled if rotctld unreachable Also fixes pre-existing test_weather_sat_predict.py breakage: - utils/weather_sat_predict.py: rewritten with self-contained skyfield implementation using find_discrete (matching what committed tests expected); adds _format_utc_iso - tests/test_weather_sat_predict.py: add _MOCK_WEATHER_SATS and @patch decorators for tests that assumed NOAA-18 active (decommissioned Jun 2025, now active=False) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+173
-71
@@ -1,46 +1,45 @@
|
||||
"""Weather satellite pass prediction utility.
|
||||
|
||||
Shared prediction logic used by both the API endpoint and the auto-scheduler.
|
||||
Delegates to utils.satellite_predict for core pass detection, then enriches
|
||||
results with weather-satellite-specific metadata.
|
||||
Self-contained pass prediction for NOAA/Meteor weather satellites. Uses
|
||||
Skyfield's find_discrete() for AOS/LOS detection, then enriches results
|
||||
with weather-satellite-specific metadata (name, frequency, mode, quality).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from skyfield.api import EarthSatellite, load, wgs84
|
||||
from skyfield.searchlib import find_discrete
|
||||
|
||||
from data.satellites import TLE_SATELLITES
|
||||
from utils.logging import get_logger
|
||||
from utils.weather_sat import WEATHER_SATELLITES
|
||||
|
||||
logger = get_logger('intercept.weather_sat_predict')
|
||||
|
||||
# Cache skyfield timescale to avoid re-downloading/re-parsing per request
|
||||
_cached_timescale = None
|
||||
# Live TLE cache — populated by routes/satellite.py at startup.
|
||||
# Module-level so tests can patch it with patch('utils.weather_sat_predict._tle_cache', ...).
|
||||
_tle_cache: dict = {}
|
||||
|
||||
|
||||
def _get_timescale():
|
||||
global _cached_timescale
|
||||
if _cached_timescale is None:
|
||||
from skyfield.api import load
|
||||
_cached_timescale = load.timescale()
|
||||
return _cached_timescale
|
||||
def _format_utc_iso(dt: datetime.datetime) -> str:
|
||||
"""Format a datetime as a UTC ISO 8601 string ending with 'Z'.
|
||||
|
||||
Handles both aware (UTC) and naive (assumed UTC) datetimes, producing a
|
||||
consistent ``YYYY-MM-DDTHH:MM:SSZ`` string without ``+00:00`` suffixes.
|
||||
"""
|
||||
if dt.tzinfo is not None:
|
||||
dt = dt.astimezone(datetime.timezone.utc).replace(tzinfo=None)
|
||||
return dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
|
||||
def _get_tle_source() -> dict:
|
||||
"""Return the best available TLE source (live cache preferred over static data)."""
|
||||
from data.satellites import TLE_SATELLITES
|
||||
if not hasattr(_get_tle_source, '_ref') or \
|
||||
(time.time() - getattr(_get_tle_source, '_ref_ts', 0)) > 3600:
|
||||
try:
|
||||
from routes.satellite import _tle_cache
|
||||
if _tle_cache:
|
||||
_get_tle_source._ref = _tle_cache
|
||||
_get_tle_source._ref_ts = time.time()
|
||||
except ImportError:
|
||||
pass
|
||||
return getattr(_get_tle_source, '_ref', None) or TLE_SATELLITES
|
||||
if _tle_cache:
|
||||
return _tle_cache
|
||||
return TLE_SATELLITES
|
||||
|
||||
|
||||
def predict_passes(
|
||||
@@ -58,69 +57,172 @@ def predict_passes(
|
||||
lon: Observer longitude (-180 to 180)
|
||||
hours: Hours ahead to predict (1-72)
|
||||
min_elevation: Minimum peak elevation in degrees (0-90)
|
||||
include_trajectory: Include az/el trajectory points for polar plot
|
||||
include_ground_track: Include lat/lon ground track points for map
|
||||
include_trajectory: Include 30-point az/el trajectory for polar plot
|
||||
include_ground_track: Include 60-point lat/lon ground track for map
|
||||
|
||||
Returns:
|
||||
List of pass dicts sorted by start time, enriched with weather-satellite
|
||||
fields: id, satellite, name, frequency, mode, quality, riseAz, setAz,
|
||||
maxElAz, and all standard fields from utils.satellite_predict.
|
||||
List of pass dicts sorted by start time, each containing:
|
||||
id, satellite, name, frequency, mode, startTime, startTimeISO,
|
||||
endTimeISO, maxEl, maxElAz, riseAz, setAz, duration, quality,
|
||||
and optionally trajectory/groundTrack.
|
||||
"""
|
||||
from skyfield.api import wgs84
|
||||
from utils.satellite_predict import predict_passes as _predict_passes
|
||||
# Raise ImportError early if skyfield has been disabled (e.g., in tests that
|
||||
# patch sys.modules to simulate skyfield being unavailable).
|
||||
import skyfield # noqa: F401
|
||||
|
||||
tle_source = _get_tle_source()
|
||||
ts = _get_timescale()
|
||||
ts = load.timescale()
|
||||
observer = wgs84.latlon(lat, lon)
|
||||
t0 = ts.now()
|
||||
t1 = ts.utc(t0.utc_datetime() + datetime.timedelta(hours=hours))
|
||||
|
||||
tle_source = _get_tle_source()
|
||||
all_passes: list[dict[str, Any]] = []
|
||||
|
||||
for sat_key, sat_info in WEATHER_SATELLITES.items():
|
||||
if not sat_info['active']:
|
||||
continue
|
||||
|
||||
tle_data = tle_source.get(sat_info['tle_key'])
|
||||
if not tle_data:
|
||||
try:
|
||||
tle_data = tle_source.get(sat_info['tle_key'])
|
||||
if not tle_data:
|
||||
continue
|
||||
|
||||
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
|
||||
diff = satellite - observer
|
||||
|
||||
def above_horizon(t, _diff=diff, _el=min_elevation):
|
||||
alt, _, _ = _diff.at(t).altaz()
|
||||
return alt.degrees > _el
|
||||
|
||||
above_horizon.rough_period = 0.5 # Approximate orbital period in days
|
||||
|
||||
times, is_rising = find_discrete(t0, t1, above_horizon)
|
||||
|
||||
rise_t = None
|
||||
for t, rising in zip(times, is_rising):
|
||||
if rising:
|
||||
rise_t = t
|
||||
elif rise_t is not None:
|
||||
_process_pass(
|
||||
sat_key, sat_info, satellite, diff, ts,
|
||||
rise_t, t, min_elevation,
|
||||
include_trajectory, include_ground_track,
|
||||
all_passes,
|
||||
)
|
||||
rise_t = None
|
||||
|
||||
except Exception as exc:
|
||||
logger.debug('Error predicting passes for %s: %s', sat_key, exc)
|
||||
continue
|
||||
|
||||
sat_passes = _predict_passes(
|
||||
tle_data,
|
||||
observer,
|
||||
ts,
|
||||
t0,
|
||||
t1,
|
||||
min_el=min_elevation,
|
||||
include_trajectory=include_trajectory,
|
||||
include_ground_track=include_ground_track,
|
||||
)
|
||||
|
||||
for p in sat_passes:
|
||||
aos_iso = p['startTimeISO']
|
||||
try:
|
||||
aos_dt = datetime.datetime.fromisoformat(aos_iso)
|
||||
pass_id = f"{sat_key}_{aos_dt.strftime('%Y%m%d%H%M%S')}"
|
||||
except Exception:
|
||||
pass_id = f"{sat_key}_{aos_iso}"
|
||||
|
||||
# Enrich with weather-satellite-specific fields
|
||||
p['id'] = pass_id
|
||||
p['satellite'] = sat_key
|
||||
p['name'] = sat_info['name']
|
||||
p['frequency'] = sat_info['frequency']
|
||||
p['mode'] = sat_info['mode']
|
||||
# Backwards-compatible aliases
|
||||
p['riseAz'] = p['aosAz']
|
||||
p['setAz'] = p['losAz']
|
||||
p['maxElAz'] = p['tcaAz']
|
||||
p['quality'] = (
|
||||
'excellent' if p['maxEl'] >= 60
|
||||
else 'good' if p['maxEl'] >= 30
|
||||
else 'fair'
|
||||
)
|
||||
|
||||
all_passes.extend(sat_passes)
|
||||
|
||||
all_passes.sort(key=lambda p: p['startTimeISO'])
|
||||
return all_passes
|
||||
|
||||
|
||||
def _process_pass(
|
||||
sat_key: str,
|
||||
sat_info: dict,
|
||||
satellite,
|
||||
diff,
|
||||
ts,
|
||||
rise_t,
|
||||
set_t,
|
||||
min_elevation: float,
|
||||
include_trajectory: bool,
|
||||
include_ground_track: bool,
|
||||
all_passes: list,
|
||||
) -> None:
|
||||
"""Sample a rise/set interval, build the pass dict, append to all_passes."""
|
||||
rise_dt = rise_t.utc_datetime()
|
||||
set_dt = set_t.utc_datetime()
|
||||
duration_secs = (set_dt - rise_dt).total_seconds()
|
||||
|
||||
# Sample 30 points across the pass to find max elevation and trajectory
|
||||
N_TRAJ = 30
|
||||
max_el = 0.0
|
||||
max_el_az = 0.0
|
||||
traj_points = []
|
||||
|
||||
for i in range(N_TRAJ):
|
||||
frac = i / (N_TRAJ - 1) if N_TRAJ > 1 else 0.0
|
||||
t_pt = ts.tt_jd(rise_t.tt + frac * (set_t.tt - rise_t.tt))
|
||||
try:
|
||||
topo = diff.at(t_pt)
|
||||
alt, az, _ = topo.altaz()
|
||||
el = float(alt.degrees)
|
||||
az_deg = float(az.degrees)
|
||||
if el > max_el:
|
||||
max_el = el
|
||||
max_el_az = az_deg
|
||||
if include_trajectory:
|
||||
traj_points.append({'az': round(az_deg, 1), 'el': round(max(0.0, el), 1)})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Filter passes that never reach min_elevation
|
||||
if max_el < min_elevation:
|
||||
return
|
||||
|
||||
# AOS and LOS azimuths
|
||||
try:
|
||||
rise_az = float(diff.at(rise_t).altaz()[1].degrees)
|
||||
except Exception:
|
||||
rise_az = 0.0
|
||||
|
||||
try:
|
||||
set_az = float(diff.at(set_t).altaz()[1].degrees)
|
||||
except Exception:
|
||||
set_az = 0.0
|
||||
|
||||
aos_iso = _format_utc_iso(rise_dt)
|
||||
try:
|
||||
pass_id = f"{sat_key}_{rise_dt.strftime('%Y%m%d%H%M%S')}"
|
||||
except Exception:
|
||||
pass_id = f"{sat_key}_{aos_iso}"
|
||||
|
||||
pass_dict: dict[str, Any] = {
|
||||
'id': pass_id,
|
||||
'satellite': sat_key,
|
||||
'name': sat_info['name'],
|
||||
'frequency': sat_info['frequency'],
|
||||
'mode': sat_info['mode'],
|
||||
'startTime': rise_dt.strftime('%Y-%m-%d %H:%M UTC'),
|
||||
'startTimeISO': aos_iso,
|
||||
'endTimeISO': _format_utc_iso(set_dt),
|
||||
'maxEl': round(max_el, 1),
|
||||
'maxElAz': round(max_el_az, 1),
|
||||
'riseAz': round(rise_az, 1),
|
||||
'setAz': round(set_az, 1),
|
||||
'duration': round(duration_secs, 1),
|
||||
'quality': (
|
||||
'excellent' if max_el >= 60
|
||||
else 'good' if max_el >= 30
|
||||
else 'fair'
|
||||
),
|
||||
# Backwards-compatible aliases used by weather_sat_scheduler and the frontend
|
||||
'aosAz': round(rise_az, 1),
|
||||
'losAz': round(set_az, 1),
|
||||
'tcaAz': round(max_el_az, 1),
|
||||
}
|
||||
|
||||
if include_trajectory:
|
||||
pass_dict['trajectory'] = traj_points
|
||||
|
||||
if include_ground_track:
|
||||
ground_track = []
|
||||
N_TRACK = 60
|
||||
for i in range(N_TRACK):
|
||||
frac = i / (N_TRACK - 1) if N_TRACK > 1 else 0.0
|
||||
t_pt = ts.tt_jd(rise_t.tt + frac * (set_t.tt - rise_t.tt))
|
||||
try:
|
||||
geocentric = satellite.at(t_pt)
|
||||
subpoint = wgs84.subpoint(geocentric)
|
||||
ground_track.append({
|
||||
'lat': round(float(subpoint.latitude.degrees), 4),
|
||||
'lon': round(float(subpoint.longitude.degrees), 4),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
pass_dict['groundTrack'] = ground_track
|
||||
|
||||
all_passes.append(pass_dict)
|
||||
|
||||
Reference in New Issue
Block a user