fix: cache ISS position/schedule and parallelize SSTV init API calls

SSTV mode was slow to populate next-pass countdown and ISS location map
due to uncached skyfield computation and sequential JS API calls.

- Cache ISS position (10s TTL) and schedule (15min TTL, keyed by rounded lat/lon)
- Cache skyfield timescale object (expensive to create on every request)
- Reduce external API timeouts from 5s to 3s
- Fire checkStatus, loadImages, loadIssSchedule, updateIssPosition in parallel via Promise.all

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-03-02 18:05:57 +00:00
parent 5534493bd1
commit e2e92b6b38
2 changed files with 160 additions and 84 deletions
+146 -80
View File
@@ -7,15 +7,16 @@ ISS SSTV events occur during special commemorations and typically transmit on 14
from __future__ import annotations from __future__ import annotations
import queue import queue
import threading
import time import time
from pathlib import Path from pathlib import Path
from typing import Generator from typing import Any
from flask import Blueprint, jsonify, request, Response, send_file from flask import Blueprint, jsonify, request, Response, send_file
import app as app_module import app as app_module
from utils.logging import get_logger from utils.logging import get_logger
from utils.sse import sse_stream_fanout from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.sstv import ( from utils.sstv import (
get_sstv_decoder, get_sstv_decoder,
@@ -36,6 +37,24 @@ ISS_SSTV_FREQ_TOLERANCE_MHZ = 0.05
# Queue for SSE progress streaming # Queue for SSE progress streaming
_sstv_queue: queue.Queue = queue.Queue(maxsize=100) _sstv_queue: queue.Queue = queue.Queue(maxsize=100)
# ---------------------------------------------------------------------------
# Caching — ISS position (external API) and schedule (skyfield computation)
# ---------------------------------------------------------------------------
_iss_position_cache: dict | None = None
_iss_position_cache_time: float = 0
_iss_position_lock = threading.Lock()
ISS_POSITION_CACHE_TTL = 10 # seconds
_iss_schedule_cache: dict | None = None
_iss_schedule_cache_time: float = 0
_iss_schedule_cache_key: str | None = None
_iss_schedule_lock = threading.Lock()
ISS_SCHEDULE_CACHE_TTL = 900 # 15 minutes
# Reusable skyfield timescale (expensive to create)
_timescale = None
_timescale_lock = threading.Lock()
# Track which device is being used # Track which device is being used
sstv_active_device: int | None = None sstv_active_device: int | None = None
@@ -409,8 +428,8 @@ def delete_all_images():
return jsonify({'status': 'ok', 'deleted': count}) return jsonify({'status': 'ok', 'deleted': count})
@sstv_bp.route('/stream') @sstv_bp.route('/stream')
def stream_progress(): def stream_progress():
""" """
SSE stream of SSTV decode progress. SSE stream of SSTV decode progress.
@@ -422,31 +441,42 @@ def stream_progress():
Returns: Returns:
SSE stream (text/event-stream) SSE stream (text/event-stream)
""" """
def _on_msg(msg: dict[str, Any]) -> None: def _on_msg(msg: dict[str, Any]) -> None:
process_event('sstv', msg, msg.get('type')) process_event('sstv', msg, msg.get('type'))
response = Response( response = Response(
sse_stream_fanout( sse_stream_fanout(
source_queue=_sstv_queue, source_queue=_sstv_queue,
channel_key='sstv', channel_key='sstv',
timeout=1.0, timeout=1.0,
keepalive_interval=30.0, keepalive_interval=30.0,
on_message=_on_msg, on_message=_on_msg,
), ),
mimetype='text/event-stream', mimetype='text/event-stream',
) )
response.headers['Cache-Control'] = 'no-cache' response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no' response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive' response.headers['Connection'] = 'keep-alive'
return response return response
def _get_timescale():
"""Return a cached skyfield timescale (expensive to create)."""
global _timescale
with _timescale_lock:
if _timescale is None:
from skyfield.api import load
_timescale = load.timescale()
return _timescale
@sstv_bp.route('/iss-schedule') @sstv_bp.route('/iss-schedule')
def iss_schedule(): def iss_schedule():
""" """
Get ISS pass schedule for SSTV reception. Get ISS pass schedule for SSTV reception.
Calculates ISS passes directly using skyfield. Calculates ISS passes directly using skyfield.
Results are cached for 15 minutes per rounded location.
Query parameters: Query parameters:
latitude: Observer latitude (required) latitude: Observer latitude (required)
@@ -456,6 +486,8 @@ def iss_schedule():
Returns: Returns:
JSON with ISS pass schedule. JSON with ISS pass schedule.
""" """
global _iss_schedule_cache, _iss_schedule_cache_time, _iss_schedule_cache_key
lat = request.args.get('latitude', type=float) lat = request.args.get('latitude', type=float)
lon = request.args.get('longitude', type=float) lon = request.args.get('longitude', type=float)
hours = request.args.get('hours', 48, type=int) hours = request.args.get('hours', 48, type=int)
@@ -466,8 +498,18 @@ def iss_schedule():
'message': 'latitude and longitude parameters required' 'message': 'latitude and longitude parameters required'
}), 400 }), 400
# Cache key: rounded lat/lon (1 decimal place) so nearby locations share cache
cache_key = f"{round(lat, 1)}:{round(lon, 1)}:{hours}"
with _iss_schedule_lock:
now = time.time()
if (_iss_schedule_cache is not None
and cache_key == _iss_schedule_cache_key
and (now - _iss_schedule_cache_time) < ISS_SCHEDULE_CACHE_TTL):
return jsonify(_iss_schedule_cache)
try: try:
from skyfield.api import load, wgs84, EarthSatellite from skyfield.api import wgs84, EarthSatellite
from skyfield.almanac import find_discrete from skyfield.almanac import find_discrete
from datetime import timedelta from datetime import timedelta
from data.satellites import TLE_SATELLITES from data.satellites import TLE_SATELLITES
@@ -480,7 +522,7 @@ def iss_schedule():
'message': 'ISS TLE data not available' 'message': 'ISS TLE data not available'
}), 500 }), 500
ts = load.timescale() ts = _get_timescale()
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts) satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
observer = wgs84.latlon(lat, lon) observer = wgs84.latlon(lat, lon)
@@ -543,13 +585,21 @@ def iss_schedule():
i += 1 i += 1
return jsonify({ result = {
'status': 'ok', 'status': 'ok',
'passes': passes, 'passes': passes,
'count': len(passes), 'count': len(passes),
'sstv_frequency': ISS_SSTV_FREQ, 'sstv_frequency': ISS_SSTV_FREQ,
'note': 'ISS SSTV events are not continuous. Check ARISS.org for scheduled events.' 'note': 'ISS SSTV events are not continuous. Check ARISS.org for scheduled events.'
}) }
# Update cache
with _iss_schedule_lock:
_iss_schedule_cache = result
_iss_schedule_cache_time = time.time()
_iss_schedule_cache_key = cache_key
return jsonify(result)
except ImportError: except ImportError:
return jsonify({ return jsonify({
@@ -565,13 +615,65 @@ def iss_schedule():
}), 500 }), 500
def _fetch_iss_position() -> dict | None:
"""Fetch raw ISS lat/lon/altitude from external APIs, with 10s cache."""
global _iss_position_cache, _iss_position_cache_time
with _iss_position_lock:
now = time.time()
if _iss_position_cache is not None and (now - _iss_position_cache_time) < ISS_POSITION_CACHE_TTL:
return _iss_position_cache
import requests
cached = None
# Try primary API: Where The ISS At
try:
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=3)
if response.status_code == 200:
data = response.json()
cached = {
'lat': float(data['latitude']),
'lon': float(data['longitude']),
'altitude': float(data.get('altitude', 420)),
'source': 'wheretheiss',
}
except Exception as e:
logger.warning(f"Where The ISS At API failed: {e}")
# Try fallback API: Open Notify
if cached is None:
try:
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=3)
if response.status_code == 200:
data = response.json()
if data.get('message') == 'success':
cached = {
'lat': float(data['iss_position']['latitude']),
'lon': float(data['iss_position']['longitude']),
'altitude': 420,
'source': 'open-notify',
}
except Exception as e:
logger.warning(f"Open Notify API failed: {e}")
if cached is not None:
with _iss_position_lock:
_iss_position_cache = cached
_iss_position_cache_time = time.time()
return cached
@sstv_bp.route('/iss-position') @sstv_bp.route('/iss-position')
def iss_position(): def iss_position():
""" """
Get current ISS position from real-time API. Get current ISS position from real-time API.
Uses the Open Notify API for accurate real-time position, Uses the "Where The ISS At" API for accurate real-time position,
with fallback to "Where The ISS At" API. with fallback to Open Notify API. Raw position is cached for 10 seconds;
observer-relative data (elevation/azimuth) is computed per-request.
Query parameters: Query parameters:
latitude: Observer latitude (optional, for elevation calc) latitude: Observer latitude (optional, for elevation calc)
@@ -580,68 +682,32 @@ def iss_position():
Returns: Returns:
JSON with ISS current position. JSON with ISS current position.
""" """
import requests
from datetime import datetime from datetime import datetime
observer_lat = request.args.get('latitude', type=float) observer_lat = request.args.get('latitude', type=float)
observer_lon = request.args.get('longitude', type=float) observer_lon = request.args.get('longitude', type=float)
# Try primary API: Where The ISS At pos = _fetch_iss_position()
try: if pos is None:
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5) return jsonify({
if response.status_code == 200: 'status': 'error',
data = response.json() 'message': 'Unable to fetch ISS position from real-time APIs'
iss_lat = float(data['latitude']) }), 503
iss_lon = float(data['longitude'])
result = { result = {
'status': 'ok', 'status': 'ok',
'lat': iss_lat, 'lat': pos['lat'],
'lon': iss_lon, 'lon': pos['lon'],
'altitude': float(data.get('altitude', 420)), 'altitude': pos['altitude'],
'timestamp': datetime.utcnow().isoformat(), 'timestamp': datetime.utcnow().isoformat(),
'source': 'wheretheiss' 'source': pos['source'],
} }
# Calculate observer-relative data if location provided # Calculate observer-relative data if location provided
if observer_lat is not None and observer_lon is not None: if observer_lat is not None and observer_lon is not None:
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon)) result.update(_calculate_observer_data(pos['lat'], pos['lon'], observer_lat, observer_lon))
return jsonify(result) return jsonify(result)
except Exception as e:
logger.warning(f"Where The ISS At API failed: {e}")
# Try fallback API: Open Notify
try:
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
if response.status_code == 200:
data = response.json()
if data.get('message') == 'success':
iss_lat = float(data['iss_position']['latitude'])
iss_lon = float(data['iss_position']['longitude'])
result = {
'status': 'ok',
'lat': iss_lat,
'lon': iss_lon,
'altitude': 420, # Approximate ISS altitude in km
'timestamp': datetime.utcnow().isoformat(),
'source': 'open-notify'
}
# Calculate observer-relative data if location provided
if observer_lat is not None and observer_lon is not None:
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
return jsonify(result)
except Exception as e:
logger.warning(f"Open Notify API failed: {e}")
# Both APIs failed
return jsonify({
'status': 'error',
'message': 'Unable to fetch ISS position from real-time APIs'
}), 503
def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs_lon: float) -> dict: def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs_lon: float) -> dict:
+14 -4
View File
@@ -39,13 +39,23 @@ const SSTV = (function() {
* Initialize the SSTV mode * Initialize the SSTV mode
*/ */
function init() { function init() {
checkStatus(); // Load location inputs first (sync localStorage read needed for lat/lon params)
loadImages();
loadLocationInputs(); loadLocationInputs();
loadIssSchedule();
// Fire all API calls in parallel — schedule is the slowest, don't let it block
Promise.all([
checkStatus(),
loadImages(),
loadIssSchedule(),
updateIssPosition(),
]).catch(err => console.error('SSTV init error:', err));
// DOM-only setup (no network, fast)
initMap(); initMap();
startIssTracking();
startCountdown(); startCountdown();
// ISS tracking interval (first call already in Promise.all above)
if (issUpdateInterval) clearInterval(issUpdateInterval);
issUpdateInterval = setInterval(updateIssPosition, 5000);
// Ensure Leaflet recomputes dimensions after the SSTV pane becomes visible. // Ensure Leaflet recomputes dimensions after the SSTV pane becomes visible.
setTimeout(() => invalidateMap(), 80); setTimeout(() => invalidateMap(), 80);
setTimeout(() => invalidateMap(), 260); setTimeout(() => invalidateMap(), 260);