perf(satellite): compute ground tracks in thread pool, not inline

Ground track computation (90 Skyfield points per satellite) was blocking
the 1Hz tracker loop on every cache miss. On cold start with multiple
tracked satellites this could stall the SSE stream for several seconds.

Tracks are now computed in a 2-worker ThreadPoolExecutor. The tracker
loop emits position without groundTrack on cache miss; clients retain
the previous track via SSE merge until the new one is ready.
This commit is contained in:
James Smith
2026-03-19 21:55:00 +00:00
parent c9a3bea40d
commit fb4c5238f3
+36 -15
View File
@@ -52,6 +52,11 @@ _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
# Thread pool for background ground-track computation (non-blocking from 1Hz tracker loop)
from concurrent.futures import ThreadPoolExecutor as _ThreadPoolExecutor
_track_executor = _ThreadPoolExecutor(max_workers=2, thread_name_prefix='sat-track')
_track_in_progress: set = set() # cache keys currently being computed
_pass_cache: dict = {} _pass_cache: dict = {}
_PASS_CACHE_TTL = 300 _PASS_CACHE_TTL = 300
@@ -253,27 +258,43 @@ def _start_satellite_tracker():
'altitude': float(subpoint.elevation.km), 'altitude': float(subpoint.elevation.km),
} }
# Ground track with caching (90 points, TTL 1800s) # Ground track with caching (90 points, TTL 1800s).
# If the cache is stale, kick off background computation so the
# 1Hz tracker loop is not blocked. The client retains the previous
# track via SSE merge until the new one arrives next tick.
cache_key_track = (sat_name, tle1[:20]) cache_key_track = (sat_name, tle1[:20])
cached = _track_cache.get(cache_key_track) cached = _track_cache.get(cache_key_track)
if cached and (time.time() - cached[1]) < _TRACK_CACHE_TTL: if cached and (time.time() - cached[1]) < _TRACK_CACHE_TTL:
pos['groundTrack'] = cached[0] pos['groundTrack'] = cached[0]
else: elif cache_key_track not in _track_in_progress:
track = [] _track_in_progress.add(cache_key_track)
for minutes_offset in range(-45, 46, 1): _sat_ref = satellite
t_point = ts.utc(now_dt + timedelta(minutes=minutes_offset)) _ts_ref = ts
_now_dt_ref = now_dt
def _compute_track(_sat=_sat_ref, _ts=_ts_ref, _now_dt=_now_dt_ref, _key=cache_key_track):
try: try:
geo = satellite.at(t_point) track = []
sp = wgs84.subpoint(geo) for minutes_offset in range(-45, 46, 1):
track.append({ t_point = _ts.utc(_now_dt + timedelta(minutes=minutes_offset))
'lat': float(sp.latitude.degrees), try:
'lon': float(sp.longitude.degrees), geo = _sat.at(t_point)
'past': minutes_offset < 0, sp = wgs84.subpoint(geo)
}) track.append({
'lat': float(sp.latitude.degrees),
'lon': float(sp.longitude.degrees),
'past': minutes_offset < 0,
})
except Exception:
continue
_track_cache[_key] = (track, time.time())
except Exception: except Exception:
continue pass
_track_cache[cache_key_track] = (track, time.time()) finally:
pos['groundTrack'] = track _track_in_progress.discard(_key)
_track_executor.submit(_compute_track)
# groundTrack omitted this tick; frontend retains prior value
positions.append(pos) positions.append(pos)
except Exception: except Exception: