Fix Meteor LRPT decoding in Docker and enhance weather satellite UI

Docker fixes:
- Add missing COPY for /usr/local/share/ (pipeline definitions were never
  reaching the runtime image — root cause of silent SatDump failures)
- Add libfftw3-double3 and libfftw3-single3 runtime dependencies
- Handle arm64 vs x86 install path differences (/usr vs /usr/local)
- Split SatDump compile and staging into separate layers for better caching
- Add build-time assertions to catch missing pipelines early

UI enhancements:
- Timezone selector (UTC, Local, Eastern, Central, Mountain, Pacific)
  with localStorage persistence — all time displays update instantly
- Pass analysis bar showing 24h quality breakdown and best upcoming pass
- Enhanced pass cards with cardinal direction (NW→SE), BEST badge
- Console timestamps, log level filters (ALL/SIGNAL/PROG/ERR), COPY/CLR
- Pass count in stats strip
- Demo data mode for UI testing without SDR or live satellite pass
- Meteor M2-4 80k baud fallback pipeline option

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
mitchross
2026-03-25 00:05:31 -04:00
parent 1dde2a008e
commit 43fb735e4e
6 changed files with 722 additions and 167 deletions
+102 -93
View File
@@ -1,14 +1,14 @@
"""Weather satellite decoder focused on Meteor LRPT workflows.
Provides automated capture and decoding of weather imagery using SatDump.
Active satellites:
- Meteor-M2-3: 137.900 MHz (LRPT)
- Meteor-M2-4: 137.900 MHz (LRPT)
Legacy NOAA APT entries remain in ``WEATHER_SATELLITES`` for compatibility
and historical metadata, but they are no longer active operational targets.
"""
"""Weather satellite decoder focused on Meteor LRPT workflows.
Provides automated capture and decoding of weather imagery using SatDump.
Active satellites:
- Meteor-M2-3: 137.900 MHz (LRPT)
- Meteor-M2-4: 137.900 MHz (LRPT)
Legacy NOAA APT entries remain in ``WEATHER_SATELLITES`` for compatibility
and historical metadata, but they are no longer active operational targets.
"""
from __future__ import annotations
@@ -29,17 +29,17 @@ from typing import Callable
from utils.logging import get_logger
from utils.process import register_process, safe_terminate
logger = get_logger('intercept.weather_sat')
PROJECT_ROOT = Path(__file__).resolve().parent.parent
ALLOWED_OFFLINE_INPUT_DIRS = (
PROJECT_ROOT / 'data',
PROJECT_ROOT / 'instance' / 'ground_station' / 'recordings',
)
logger = get_logger('intercept.weather_sat')
PROJECT_ROOT = Path(__file__).resolve().parent.parent
ALLOWED_OFFLINE_INPUT_DIRS = (
PROJECT_ROOT / 'data',
PROJECT_ROOT / 'instance' / 'ground_station' / 'recordings',
)
# Weather satellite definitions.
# NOAA APT entries are retained as inactive compatibility metadata.
# Weather satellite definitions.
# NOAA APT entries are retained as inactive compatibility metadata.
WEATHER_SATELLITES = {
'NOAA-15': {
'name': 'NOAA 15',
@@ -86,6 +86,15 @@ WEATHER_SATELLITES = {
'description': 'Meteor-M2-4 LRPT (digital color imagery)',
'active': True,
},
'METEOR-M2-4-80K': {
'name': 'Meteor-M2-4 (80k)',
'frequency': 137.900,
'mode': 'LRPT',
'pipeline': 'meteor_m2-x_lrpt_80k',
'tle_key': 'METEOR-M2-4',
'description': 'Meteor-M2-4 LRPT 80k baud (fallback symbol rate)',
'active': True,
},
}
# Default sample rate for weather satellite reception
@@ -153,12 +162,12 @@ class CaptureProgress:
return result
class WeatherSatDecoder:
"""Weather satellite decoder using SatDump CLI.
Manages live SDR capture and offline decode for the active Meteor LRPT
workflow, while preserving compatibility with older weather-sat metadata.
"""
class WeatherSatDecoder:
"""Weather satellite decoder using SatDump CLI.
Manages live SDR capture and offline decode for the active Meteor LRPT
workflow, while preserving compatibility with older weather-sat metadata.
"""
def __init__(self, output_dir: str | Path | None = None):
self._process: subprocess.Popen | None = None
@@ -175,14 +184,14 @@ class WeatherSatDecoder:
self._pty_master_fd: int | None = None
self._current_satellite: str = ''
self._current_frequency: float = 0.0
self._current_mode: str = ''
self._capture_start_time: float = 0
self._device_index: int = -1
self._capture_output_dir: Path | None = None
self._on_complete_callback: Callable[[], None] | None = None
self._capture_phase: str = 'idle'
self._last_error_message: str = ''
self._last_process_returncode: int | None = None
self._current_mode: str = ''
self._capture_start_time: float = 0
self._device_index: int = -1
self._capture_output_dir: Path | None = None
self._on_complete_callback: Callable[[], None] | None = None
self._capture_phase: str = 'idle'
self._last_error_message: str = ''
self._last_process_returncode: int | None = None
# Ensure output directory exists
self._output_dir.mkdir(parents=True, exist_ok=True)
@@ -251,7 +260,7 @@ class WeatherSatDecoder:
No SDR hardware is required — SatDump runs in offline mode.
Args:
satellite: Satellite key (for example ``'METEOR-M2-3'``)
satellite: Satellite key (for example ``'METEOR-M2-3'``)
input_file: Path to IQ baseband or WAV audio file
sample_rate: Sample rate of the recording in Hz
@@ -283,16 +292,16 @@ class WeatherSatDecoder:
input_path = Path(input_file)
# Security: restrict offline decode inputs to application-owned
# capture directories so external paths cannot be injected.
try:
resolved = input_path.resolve()
if not any(resolved.is_relative_to(base) for base in ALLOWED_OFFLINE_INPUT_DIRS):
logger.warning(f"Path traversal blocked in start_from_file: {input_file}")
msg = 'Input file must be under INTERCEPT data or ground-station recordings'
self._emit_progress(CaptureProgress(
status='error',
message=msg,
# Security: restrict offline decode inputs to application-owned
# capture directories so external paths cannot be injected.
try:
resolved = input_path.resolve()
if not any(resolved.is_relative_to(base) for base in ALLOWED_OFFLINE_INPUT_DIRS):
logger.warning(f"Path traversal blocked in start_from_file: {input_file}")
msg = 'Input file must be under INTERCEPT data or ground-station recordings'
self._emit_progress(CaptureProgress(
status='error',
message=msg,
))
return False, msg
except (OSError, ValueError):
@@ -314,13 +323,13 @@ class WeatherSatDecoder:
self._current_satellite = satellite
self._current_frequency = sat_info['frequency']
self._current_mode = sat_info['mode']
self._device_index = -1 # Offline decode does not claim an SDR device
self._capture_start_time = time.time()
self._capture_phase = 'decoding'
self._last_error_message = ''
self._last_process_returncode = None
self._stop_event.clear()
self._current_mode = sat_info['mode']
self._device_index = -1 # Offline decode does not claim an SDR device
self._capture_start_time = time.time()
self._capture_phase = 'decoding'
self._last_error_message = ''
self._last_process_returncode = None
self._stop_event.clear()
try:
self._running = True
@@ -368,7 +377,7 @@ class WeatherSatDecoder:
"""Start weather satellite capture and decode.
Args:
satellite: Satellite key (for example ``'METEOR-M2-3'``)
satellite: Satellite key (for example ``'METEOR-M2-3'``)
device_index: RTL-SDR device index
gain: SDR gain in dB
sample_rate: Sample rate in Hz
@@ -410,13 +419,13 @@ class WeatherSatDecoder:
self._current_satellite = satellite
self._current_frequency = sat_info['frequency']
self._current_mode = sat_info['mode']
self._device_index = device_index
self._capture_start_time = time.time()
self._capture_phase = 'tuning'
self._last_error_message = ''
self._last_process_returncode = None
self._stop_event.clear()
self._current_mode = sat_info['mode']
self._device_index = device_index
self._capture_start_time = time.time()
self._capture_phase = 'tuning'
self._last_error_message = ''
self._last_process_returncode = None
self._stop_event.clear()
try:
self._running = True
@@ -890,17 +899,17 @@ class WeatherSatDecoder:
if was_running:
# Collect exit status (returncode is only set after poll/wait)
if process and process.returncode is None:
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
retcode = process.returncode if process else None
self._last_process_returncode = retcode
if retcode and retcode != 0:
self._capture_phase = 'error'
self._emit_progress(CaptureProgress(
if process and process.returncode is None:
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
retcode = process.returncode if process else None
self._last_process_returncode = retcode
if retcode and retcode != 0:
self._capture_phase = 'error'
self._emit_progress(CaptureProgress(
status='error',
satellite=self._current_satellite,
frequency=self._current_frequency,
@@ -1143,15 +1152,15 @@ class WeatherSatDecoder:
self._images.clear()
return count
def _emit_progress(self, progress: CaptureProgress) -> None:
"""Emit progress update to callback."""
if progress.status == 'error' and progress.message:
self._last_error_message = str(progress.message)
if self._callback:
try:
self._callback(progress)
except Exception as e:
logger.error(f"Error in progress callback: {e}")
def _emit_progress(self, progress: CaptureProgress) -> None:
"""Emit progress update to callback."""
if progress.status == 'error' and progress.message:
self._last_error_message = str(progress.message)
if self._callback:
try:
self._callback(progress)
except Exception as e:
logger.error(f"Error in progress callback: {e}")
def get_status(self) -> dict:
"""Get current decoder status."""
@@ -1159,19 +1168,19 @@ class WeatherSatDecoder:
if self._running and self._capture_start_time:
elapsed = int(time.time() - self._capture_start_time)
return {
'available': self._decoder is not None,
'decoder': self._decoder,
'running': self._running,
'satellite': self._current_satellite,
'frequency': self._current_frequency,
'mode': self._current_mode,
'capture_phase': self._capture_phase,
'elapsed_seconds': elapsed,
'image_count': len(self._images),
'last_error': self._last_error_message,
'last_returncode': self._last_process_returncode,
}
return {
'available': self._decoder is not None,
'decoder': self._decoder,
'running': self._running,
'satellite': self._current_satellite,
'frequency': self._current_frequency,
'mode': self._current_mode,
'capture_phase': self._capture_phase,
'elapsed_seconds': elapsed,
'image_count': len(self._images),
'last_error': self._last_error_message,
'last_returncode': self._last_process_returncode,
}
# Global decoder instance