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

View File

@@ -7,15 +7,16 @@ ISS SSTV events occur during special commemorations and typically transmit on 14
from __future__ import annotations
import queue
import threading
import time
from pathlib import Path
from typing import Generator
from typing import Any
from flask import Blueprint, jsonify, request, Response, send_file
import app as app_module
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.sstv import (
get_sstv_decoder,
@@ -36,6 +37,24 @@ ISS_SSTV_FREQ_TOLERANCE_MHZ = 0.05
# Queue for SSE progress streaming
_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
sstv_active_device: int | None = None
@@ -409,8 +428,8 @@ def delete_all_images():
return jsonify({'status': 'ok', 'deleted': count})
@sstv_bp.route('/stream')
def stream_progress():
@sstv_bp.route('/stream')
def stream_progress():
"""
SSE stream of SSTV decode progress.
@@ -422,31 +441,42 @@ def stream_progress():
Returns:
SSE stream (text/event-stream)
"""
def _on_msg(msg: dict[str, Any]) -> None:
process_event('sstv', msg, msg.get('type'))
response = Response(
sse_stream_fanout(
source_queue=_sstv_queue,
channel_key='sstv',
timeout=1.0,
keepalive_interval=30.0,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
def _on_msg(msg: dict[str, Any]) -> None:
process_event('sstv', msg, msg.get('type'))
response = Response(
sse_stream_fanout(
source_queue=_sstv_queue,
channel_key='sstv',
timeout=1.0,
keepalive_interval=30.0,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
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')
def iss_schedule():
"""
Get ISS pass schedule for SSTV reception.
Calculates ISS passes directly using skyfield.
Results are cached for 15 minutes per rounded location.
Query parameters:
latitude: Observer latitude (required)
@@ -456,6 +486,8 @@ def iss_schedule():
Returns:
JSON with ISS pass schedule.
"""
global _iss_schedule_cache, _iss_schedule_cache_time, _iss_schedule_cache_key
lat = request.args.get('latitude', type=float)
lon = request.args.get('longitude', type=float)
hours = request.args.get('hours', 48, type=int)
@@ -466,8 +498,18 @@ def iss_schedule():
'message': 'latitude and longitude parameters required'
}), 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:
from skyfield.api import load, wgs84, EarthSatellite
from skyfield.api import wgs84, EarthSatellite
from skyfield.almanac import find_discrete
from datetime import timedelta
from data.satellites import TLE_SATELLITES
@@ -480,7 +522,7 @@ def iss_schedule():
'message': 'ISS TLE data not available'
}), 500
ts = load.timescale()
ts = _get_timescale()
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
observer = wgs84.latlon(lat, lon)
@@ -543,13 +585,21 @@ def iss_schedule():
i += 1
return jsonify({
result = {
'status': 'ok',
'passes': passes,
'count': len(passes),
'sstv_frequency': ISS_SSTV_FREQ,
'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:
return jsonify({
@@ -565,13 +615,65 @@ def iss_schedule():
}), 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')
def iss_position():
"""
Get current ISS position from real-time API.
Uses the Open Notify API for accurate real-time position,
with fallback to "Where The ISS At" API.
Uses the "Where The ISS At" API for accurate real-time position,
with fallback to Open Notify API. Raw position is cached for 10 seconds;
observer-relative data (elevation/azimuth) is computed per-request.
Query parameters:
latitude: Observer latitude (optional, for elevation calc)
@@ -580,68 +682,32 @@ def iss_position():
Returns:
JSON with ISS current position.
"""
import requests
from datetime import datetime
observer_lat = request.args.get('latitude', type=float)
observer_lon = request.args.get('longitude', type=float)
# Try primary API: Where The ISS At
try:
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
if response.status_code == 200:
data = response.json()
iss_lat = float(data['latitude'])
iss_lon = float(data['longitude'])
pos = _fetch_iss_position()
if pos is None:
return jsonify({
'status': 'error',
'message': 'Unable to fetch ISS position from real-time APIs'
}), 503
result = {
'status': 'ok',
'lat': iss_lat,
'lon': iss_lon,
'altitude': float(data.get('altitude', 420)),
'timestamp': datetime.utcnow().isoformat(),
'source': 'wheretheiss'
}
result = {
'status': 'ok',
'lat': pos['lat'],
'lon': pos['lon'],
'altitude': pos['altitude'],
'timestamp': datetime.utcnow().isoformat(),
'source': pos['source'],
}
# 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))
# Calculate observer-relative data if location provided
if observer_lat is not None and observer_lon is not None:
result.update(_calculate_observer_data(pos['lat'], pos['lon'], observer_lat, observer_lon))
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
return jsonify(result)
def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs_lon: float) -> dict:

View File

@@ -39,13 +39,23 @@ const SSTV = (function() {
* Initialize the SSTV mode
*/
function init() {
checkStatus();
loadImages();
// Load location inputs first (sync localStorage read needed for lat/lon params)
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();
startIssTracking();
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.
setTimeout(() => invalidateMap(), 80);
setTimeout(() => invalidateMap(), 260);