From 0916b62bfe084e2b27463ee8d1f477f7363e77a9 Mon Sep 17 00:00:00 2001 From: James Smith Date: Thu, 19 Mar 2026 11:27:38 +0000 Subject: [PATCH] Cache satellite pass predictions --- routes/satellite.py | 192 +++++++++++++++++++---------- templates/satellite_dashboard.html | 2 +- 2 files changed, 125 insertions(+), 69 deletions(-) diff --git a/routes/satellite.py b/routes/satellite.py index ec051d2..899b525 100644 --- a/routes/satellite.py +++ b/routes/satellite.py @@ -51,6 +51,8 @@ _tle_cache = dict(TLE_SATELLITES) # TTL is 1800 seconds (30 minutes) _track_cache: dict = {} _TRACK_CACHE_TTL = 1800 +_pass_cache: dict = {} +_PASS_CACHE_TTL = 300 _BUILTIN_NORAD_TO_KEY = { 25544: 'ISS', @@ -171,6 +173,31 @@ def _resolve_satellite_request(sat: object, tracked_by_norad: dict[int, dict], t if not display_name: display_name = (tracked.get('name') if tracked else None) or (tle_data[0] if tle_data else None) or (sat_key if sat_key else str(norad_id or 'UNKNOWN')) 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(): @@ -410,78 +437,107 @@ def satellite_dashboard(): return response -@satellite_bp.route('/predict', methods=['POST']) -def predict_passes(): - """Calculate satellite passes using skyfield.""" - try: - from skyfield.api import EarthSatellite, wgs84 - except ImportError: - return jsonify({ - 'status': 'error', - 'message': 'skyfield library not installed. Run: pip install skyfield' - }), 503 - - from utils.satellite_predict import predict_passes as _predict_passes - - data = request.json or {} - - # Validate inputs - try: - lat = validate_latitude(data.get('latitude', data.get('lat', 51.5074))) - lon = validate_longitude(data.get('longitude', data.get('lon', -0.1278))) - hours = validate_hours(data.get('hours', 24)) - min_el = validate_elevation(data.get('minEl', 10)) - except ValueError as e: - return api_error(str(e), 400) - - sat_input = data.get('satellites', ['ISS', 'METEOR-M2-3', 'METEOR-M2-4']) - passes = [] - colors = { - 'ISS': '#00ffff', - 'METEOR-M2': '#9370DB', - 'METEOR-M2-3': '#ff00ff', - 'METEOR-M2-4': '#00ff88', - } - tracked_by_norad, tracked_by_name = _get_tracked_satellite_maps() +@satellite_bp.route('/predict', methods=['POST']) +def predict_passes(): + """Calculate satellite passes using skyfield.""" + try: + from skyfield.api import EarthSatellite, wgs84 + except ImportError: + return jsonify({ + 'status': 'error', + 'message': 'skyfield library not installed. Run: pip install skyfield' + }), 503 - ts = _get_timescale() - observer = wgs84.latlon(lat, lon) - t0 = ts.now() - t1 = ts.utc(t0.utc_datetime() + timedelta(hours=hours)) + from utils.satellite_predict import predict_passes as _predict_passes - 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 + data = request.json or {} + + try: + # Validate inputs + lat = validate_latitude(data.get('latitude', data.get('lat', 51.5074))) + lon = validate_longitude(data.get('longitude', data.get('lon', -0.1278))) + hours = validate_hours(data.get('hours', 24)) + min_el = validate_elevation(data.get('minEl', 10)) + except ValueError as e: + return api_error(str(e), 400) - # Current position for map marker (computed once per satellite) - current_pos = None try: - satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts) - geo = satellite.at(ts.now()) - sp = wgs84.subpoint(geo) - current_pos = { - 'lat': float(sp.latitude.degrees), - 'lon': float(sp.longitude.degrees), - } - except Exception: - pass - - sat_passes = _predict_passes(tle_data, observer, ts, t0, t1, min_el=min_el) - for p in sat_passes: - p['satellite'] = sat_name - p['norad'] = norad_id or 0 - p['color'] = colors.get(sat_name, '#00ff00') - if current_pos: - p['currentPos'] = current_pos - passes.extend(sat_passes) - - passes.sort(key=lambda p: p['startTimeISO']) - - return jsonify({ - 'status': 'success', - 'passes': passes - }) + sat_input = data.get('satellites', ['ISS', 'METEOR-M2-3', 'METEOR-M2-4']) + passes = [] + colors = { + 'ISS': '#00ffff', + 'METEOR-M2': '#9370DB', + 'METEOR-M2-3': '#ff00ff', + 'METEOR-M2-4': '#00ff88', + } + 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() + observer = wgs84.latlon(lat, lon) + t0 = ts.now() + t1 = ts.utc(t0.utc_datetime() + timedelta(hours=hours)) + + for sat_name, norad_id, tle_data in resolved_satellites: + current_pos = None + try: + satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts) + geo = satellite.at(t0) + sp = wgs84.subpoint(geo) + current_pos = { + 'lat': float(sp.latitude.degrees), + 'lon': float(sp.longitude.degrees), + } + except Exception: + pass + + sat_passes = _predict_passes(tle_data, observer, ts, t0, t1, min_el=min_el) + for p in sat_passes: + p['satellite'] = sat_name + p['norad'] = norad_id + p['color'] = colors.get(sat_name, '#00ff00') + if current_pos: + p['currentPos'] = current_pos + passes.extend(sat_passes) + + passes.sort(key=lambda p: p['startTimeISO']) + _pass_cache[cache_key] = (passes, now_ts) + + return jsonify({ + 'status': 'success', + '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']) diff --git a/templates/satellite_dashboard.html b/templates/satellite_dashboard.html index 3de9891..8872b8e 100644 --- a/templates/satellite_dashboard.html +++ b/templates/satellite_dashboard.html @@ -604,7 +604,7 @@ let _passRequestId = 0; let _passAbortController = null; let _passTimeoutId = null; - const DASHBOARD_FETCH_TIMEOUT_MS = 10000; + const DASHBOARD_FETCH_TIMEOUT_MS = 30000; const BUILTIN_TX_FALLBACK = { 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' },