mirror of
https://github.com/smittix/intercept.git
synced 2026-07-23 16:38:11 -07:00
Fix setup.sh hanging on Python 3.14/macOS and add satellite enhancements
- Add --no-cache-dir and --timeout 120 to all pip calls to prevent hanging on corrupt/stale pip HTTP cache (cachecontrol .pyc issue) - Replace silent python -c import verification with pip show to avoid import-time side effects hanging the installer - Switch optional packages to --only-binary :all: to skip source compilation on Python versions without pre-built wheels (prevents gevent/numpy hangs) - Warn early when Python 3.13+ is detected that some packages may be skipped - Add ground track caching with 30-minute TTL to satellite route - Add live satellite position tracker background thread via SSE fanout - Add satellite_predict, satellite_telemetry, and satnogs utilities Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+126
-218
@@ -1,218 +1,126 @@
|
||||
"""Weather satellite pass prediction utility.
|
||||
|
||||
Shared prediction logic used by both the API endpoint and the auto-scheduler.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
"""Return an ISO8601 UTC timestamp with a single timezone designator."""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=datetime.timezone.utc)
|
||||
else:
|
||||
dt = dt.astimezone(datetime.timezone.utc)
|
||||
return dt.isoformat().replace('+00:00', 'Z')
|
||||
|
||||
|
||||
def predict_passes(
|
||||
lat: float,
|
||||
lon: float,
|
||||
hours: int = 24,
|
||||
min_elevation: float = 15.0,
|
||||
include_trajectory: bool = False,
|
||||
include_ground_track: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Predict upcoming weather satellite passes for an observer location.
|
||||
|
||||
Args:
|
||||
lat: Observer latitude (-90 to 90)
|
||||
lon: Observer longitude (-180 to 180)
|
||||
hours: Hours ahead to predict (1-72)
|
||||
min_elevation: Minimum max elevation in degrees (0-90)
|
||||
include_trajectory: Include az/el trajectory points (30 points)
|
||||
include_ground_track: Include lat/lon ground track points (60 points)
|
||||
|
||||
Returns:
|
||||
List of pass dicts sorted by start time.
|
||||
|
||||
Raises:
|
||||
ImportError: If skyfield is not installed.
|
||||
"""
|
||||
from skyfield.almanac import find_discrete
|
||||
from skyfield.api import EarthSatellite, wgs84
|
||||
|
||||
from data.satellites import TLE_SATELLITES
|
||||
|
||||
# Use live TLE cache from satellite module if available (refreshed from CelesTrak).
|
||||
# Cache the reference locally so repeated calls don't re-import each time.
|
||||
tle_source = TLE_SATELLITES
|
||||
if not hasattr(predict_passes, '_tle_ref') or \
|
||||
(time.time() - getattr(predict_passes, '_tle_ref_ts', 0)) > 3600:
|
||||
try:
|
||||
from routes.satellite import _tle_cache
|
||||
if _tle_cache:
|
||||
predict_passes._tle_ref = _tle_cache
|
||||
predict_passes._tle_ref_ts = time.time()
|
||||
except ImportError:
|
||||
pass
|
||||
if hasattr(predict_passes, '_tle_ref') and predict_passes._tle_ref:
|
||||
tle_source = predict_passes._tle_ref
|
||||
|
||||
ts = _get_timescale()
|
||||
observer = wgs84.latlon(lat, lon)
|
||||
t0 = ts.now()
|
||||
t1 = ts.utc(t0.utc_datetime() + datetime.timedelta(hours=hours))
|
||||
|
||||
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:
|
||||
continue
|
||||
|
||||
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
|
||||
|
||||
def above_horizon(t, _sat=satellite):
|
||||
diff = _sat - observer
|
||||
topocentric = diff.at(t)
|
||||
alt, _, _ = topocentric.altaz()
|
||||
return alt.degrees > 0
|
||||
|
||||
above_horizon.step_days = 1 / 720
|
||||
|
||||
try:
|
||||
times, events = find_discrete(t0, t1, above_horizon)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
i = 0
|
||||
while i < len(times):
|
||||
if i < len(events) and events[i]: # Rising
|
||||
rise_time = times[i]
|
||||
set_time = None
|
||||
|
||||
for j in range(i + 1, len(times)):
|
||||
if not events[j]: # Setting
|
||||
set_time = times[j]
|
||||
i = j
|
||||
break
|
||||
else:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if set_time is None:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
rise_dt = rise_time.utc_datetime()
|
||||
set_dt = set_time.utc_datetime()
|
||||
duration_seconds = (
|
||||
set_dt - rise_dt
|
||||
).total_seconds()
|
||||
duration_minutes = round(duration_seconds / 60, 1)
|
||||
|
||||
# Calculate max elevation (always) and trajectory points (only if requested)
|
||||
max_el = 0.0
|
||||
max_el_az = 0.0
|
||||
trajectory: list[dict[str, float]] = []
|
||||
num_traj_points = 30
|
||||
|
||||
for k in range(num_traj_points):
|
||||
frac = k / (num_traj_points - 1)
|
||||
t_point = ts.utc(
|
||||
rise_time.utc_datetime()
|
||||
+ datetime.timedelta(seconds=duration_seconds * frac)
|
||||
)
|
||||
diff = satellite - observer
|
||||
topocentric = diff.at(t_point)
|
||||
alt, az, _ = topocentric.altaz()
|
||||
if alt.degrees > max_el:
|
||||
max_el = alt.degrees
|
||||
max_el_az = az.degrees
|
||||
if include_trajectory:
|
||||
trajectory.append({
|
||||
'el': float(max(0, alt.degrees)),
|
||||
'az': float(az.degrees),
|
||||
})
|
||||
|
||||
if max_el < min_elevation:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Rise/set azimuths
|
||||
rise_topo = (satellite - observer).at(rise_time)
|
||||
_, rise_az, _ = rise_topo.altaz()
|
||||
|
||||
set_topo = (satellite - observer).at(set_time)
|
||||
_, set_az, _ = set_topo.altaz()
|
||||
|
||||
pass_data: dict[str, Any] = {
|
||||
'id': f"{sat_key}_{rise_dt.strftime('%Y%m%d%H%M%S')}",
|
||||
'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': _format_utc_iso(rise_dt),
|
||||
'endTimeISO': _format_utc_iso(set_dt),
|
||||
'maxEl': round(max_el, 1),
|
||||
'maxElAz': round(max_el_az, 1),
|
||||
'riseAz': round(rise_az.degrees, 1),
|
||||
'setAz': round(set_az.degrees, 1),
|
||||
'duration': duration_minutes,
|
||||
'quality': (
|
||||
'excellent' if max_el >= 60
|
||||
else 'good' if max_el >= 30
|
||||
else 'fair'
|
||||
),
|
||||
}
|
||||
|
||||
if include_trajectory:
|
||||
pass_data['trajectory'] = trajectory
|
||||
|
||||
if include_ground_track:
|
||||
ground_track: list[dict[str, float]] = []
|
||||
for k in range(60):
|
||||
frac = k / 59
|
||||
t_point = ts.utc(
|
||||
rise_time.utc_datetime()
|
||||
+ datetime.timedelta(seconds=duration_seconds * frac)
|
||||
)
|
||||
geocentric = satellite.at(t_point)
|
||||
subpoint = wgs84.subpoint(geocentric)
|
||||
ground_track.append({
|
||||
'lat': float(subpoint.latitude.degrees),
|
||||
'lon': float(subpoint.longitude.degrees),
|
||||
})
|
||||
pass_data['groundTrack'] = ground_track
|
||||
|
||||
all_passes.append(pass_data)
|
||||
|
||||
i += 1
|
||||
|
||||
all_passes.sort(key=lambda p: p['startTimeISO'])
|
||||
return all_passes
|
||||
"""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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _get_timescale():
|
||||
global _cached_timescale
|
||||
if _cached_timescale is None:
|
||||
from skyfield.api import load
|
||||
_cached_timescale = load.timescale()
|
||||
return _cached_timescale
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def predict_passes(
|
||||
lat: float,
|
||||
lon: float,
|
||||
hours: int = 24,
|
||||
min_elevation: float = 15.0,
|
||||
include_trajectory: bool = False,
|
||||
include_ground_track: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Predict upcoming weather satellite passes for an observer location.
|
||||
|
||||
Args:
|
||||
lat: Observer latitude (-90 to 90)
|
||||
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
|
||||
|
||||
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.
|
||||
"""
|
||||
from skyfield.api import wgs84
|
||||
from utils.satellite_predict import predict_passes as _predict_passes
|
||||
|
||||
tle_source = _get_tle_source()
|
||||
ts = _get_timescale()
|
||||
observer = wgs84.latlon(lat, lon)
|
||||
t0 = ts.now()
|
||||
t1 = ts.utc(t0.utc_datetime() + datetime.timedelta(hours=hours))
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user