Cache satellite pass predictions

This commit is contained in:
James Smith
2026-03-19 11:27:38 +00:00
parent 0b22393395
commit 0916b62bfe
2 changed files with 125 additions and 69 deletions

View File

@@ -51,6 +51,8 @@ _tle_cache = dict(TLE_SATELLITES)
# TTL is 1800 seconds (30 minutes) # TTL is 1800 seconds (30 minutes)
_track_cache: dict = {} _track_cache: dict = {}
_TRACK_CACHE_TTL = 1800 _TRACK_CACHE_TTL = 1800
_pass_cache: dict = {}
_PASS_CACHE_TTL = 300
_BUILTIN_NORAD_TO_KEY = { _BUILTIN_NORAD_TO_KEY = {
25544: 'ISS', 25544: 'ISS',
@@ -173,6 +175,31 @@ def _resolve_satellite_request(sat: object, tracked_by_norad: dict[int, dict], t
return display_name, norad_id, tle_data return display_name, norad_id, tle_data
def _make_pass_cache_key(
lat: float,
lon: float,
hours: int,
min_el: float,
resolved_satellites: list[tuple[str, int, tuple[str, str, str]]],
) -> tuple:
"""Build a stable cache key for predicted passes."""
return (
round(lat, 4),
round(lon, 4),
int(hours),
round(float(min_el), 1),
tuple(
(
sat_name,
norad_id,
tle_data[1][:32],
tle_data[2][:32],
)
for sat_name, norad_id, tle_data in resolved_satellites
),
)
def _start_satellite_tracker(): def _start_satellite_tracker():
"""Background thread: push live satellite positions to satellite_queue every second.""" """Background thread: push live satellite positions to satellite_queue every second."""
import app as app_module import app as app_module
@@ -425,8 +452,8 @@ def predict_passes():
data = request.json or {} data = request.json or {}
# Validate inputs
try: try:
# Validate inputs
lat = validate_latitude(data.get('latitude', data.get('lat', 51.5074))) lat = validate_latitude(data.get('latitude', data.get('lat', 51.5074)))
lon = validate_longitude(data.get('longitude', data.get('lon', -0.1278))) lon = validate_longitude(data.get('longitude', data.get('lon', -0.1278)))
hours = validate_hours(data.get('hours', 24)) hours = validate_hours(data.get('hours', 24))
@@ -434,6 +461,7 @@ def predict_passes():
except ValueError as e: except ValueError as e:
return api_error(str(e), 400) return api_error(str(e), 400)
try:
sat_input = data.get('satellites', ['ISS', 'METEOR-M2-3', 'METEOR-M2-4']) sat_input = data.get('satellites', ['ISS', 'METEOR-M2-3', 'METEOR-M2-4'])
passes = [] passes = []
colors = { colors = {
@@ -444,21 +472,44 @@ def predict_passes():
} }
tracked_by_norad, tracked_by_name = _get_tracked_satellite_maps() tracked_by_norad, tracked_by_name = _get_tracked_satellite_maps()
resolved_satellites: list[tuple[str, int, tuple[str, str, str]]] = []
for sat in sat_input:
sat_name, norad_id, tle_data = _resolve_satellite_request(
sat,
tracked_by_norad,
tracked_by_name,
)
if not tle_data:
continue
resolved_satellites.append((sat_name, norad_id or 0, tle_data))
if not resolved_satellites:
return jsonify({
'status': 'success',
'passes': [],
'cached': False,
})
cache_key = _make_pass_cache_key(lat, lon, hours, min_el, resolved_satellites)
cached = _pass_cache.get(cache_key)
now_ts = time.time()
if cached and (now_ts - cached[1]) < _PASS_CACHE_TTL:
return jsonify({
'status': 'success',
'passes': cached[0],
'cached': True,
})
ts = _get_timescale() ts = _get_timescale()
observer = wgs84.latlon(lat, lon) observer = wgs84.latlon(lat, lon)
t0 = ts.now() t0 = ts.now()
t1 = ts.utc(t0.utc_datetime() + timedelta(hours=hours)) t1 = ts.utc(t0.utc_datetime() + timedelta(hours=hours))
for sat in sat_input: for sat_name, norad_id, tle_data in resolved_satellites:
sat_name, norad_id, tle_data = _resolve_satellite_request(sat, tracked_by_norad, tracked_by_name)
if not tle_data:
continue
# Current position for map marker (computed once per satellite)
current_pos = None current_pos = None
try: try:
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts) satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
geo = satellite.at(ts.now()) geo = satellite.at(t0)
sp = wgs84.subpoint(geo) sp = wgs84.subpoint(geo)
current_pos = { current_pos = {
'lat': float(sp.latitude.degrees), 'lat': float(sp.latitude.degrees),
@@ -470,18 +521,23 @@ def predict_passes():
sat_passes = _predict_passes(tle_data, observer, ts, t0, t1, min_el=min_el) sat_passes = _predict_passes(tle_data, observer, ts, t0, t1, min_el=min_el)
for p in sat_passes: for p in sat_passes:
p['satellite'] = sat_name p['satellite'] = sat_name
p['norad'] = norad_id or 0 p['norad'] = norad_id
p['color'] = colors.get(sat_name, '#00ff00') p['color'] = colors.get(sat_name, '#00ff00')
if current_pos: if current_pos:
p['currentPos'] = current_pos p['currentPos'] = current_pos
passes.extend(sat_passes) passes.extend(sat_passes)
passes.sort(key=lambda p: p['startTimeISO']) passes.sort(key=lambda p: p['startTimeISO'])
_pass_cache[cache_key] = (passes, now_ts)
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
'passes': passes 'passes': passes,
'cached': False,
}) })
except Exception as exc:
logger.exception('Satellite pass calculation failed')
return api_error(f'Failed to calculate passes: {exc}', 500)
@satellite_bp.route('/position', methods=['POST']) @satellite_bp.route('/position', methods=['POST'])

View File

@@ -604,7 +604,7 @@
let _passRequestId = 0; let _passRequestId = 0;
let _passAbortController = null; let _passAbortController = null;
let _passTimeoutId = null; let _passTimeoutId = null;
const DASHBOARD_FETCH_TIMEOUT_MS = 10000; const DASHBOARD_FETCH_TIMEOUT_MS = 30000;
const BUILTIN_TX_FALLBACK = { const BUILTIN_TX_FALLBACK = {
25544: [ 25544: [
{ description: 'APRS digipeater', downlink_low: 145.825, downlink_high: 145.825, uplink_low: null, uplink_high: null, mode: 'FM AX.25', baud: 1200, status: 'active', type: 'beacon', service: 'Packet' }, { description: 'APRS digipeater', downlink_low: 145.825, downlink_high: 145.825, uplink_low: null, uplink_high: null, mode: 'FM AX.25', baud: 1200, status: 'active', type: 'beacon', service: 'Packet' },