Fix weather sat 0dB SNR: increase sample rate to 2.4 MHz for Meteor LRPT

The default 1 MHz sample rate was too low for SatDump's meteor_m2-x_lrpt
pipeline, causing NOSYNC and 0.000dB SNR. Bumped to 2.4 MHz (SatDump
recommendation) and wired up the WEATHER_SAT_SAMPLE_RATE config value
so it actually gets passed to decoder.start() from both the auto-scheduler
and manual start route.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-24 20:27:08 +00:00
parent b3af44652f
commit 0afa25e57c
4 changed files with 108 additions and 99 deletions

View File

@@ -331,7 +331,7 @@ SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
# Weather satellite settings # Weather satellite settings
WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0) WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0)
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 1000000) WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 2400000)
WEATHER_SAT_MIN_ELEVATION = _get_env_float('WEATHER_SAT_MIN_ELEVATION', 15.0) WEATHER_SAT_MIN_ELEVATION = _get_env_float('WEATHER_SAT_MIN_ELEVATION', 15.0)
WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24) WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24)
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEATHER_SAT_SCHEDULE_REFRESH_MINUTES', 30) WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEATHER_SAT_SCHEDULE_REFRESH_MINUTES', 30)

View File

@@ -18,6 +18,7 @@ from utils.weather_sat import (
is_weather_sat_available, is_weather_sat_available,
CaptureProgress, CaptureProgress,
WEATHER_SATELLITES, WEATHER_SATELLITES,
DEFAULT_SAMPLE_RATE,
) )
logger = get_logger('intercept.weather_sat') logger = get_logger('intercept.weather_sat')
@@ -164,6 +165,7 @@ def start_capture():
satellite=satellite, satellite=satellite,
device_index=device_index, device_index=device_index,
gain=gain, gain=gain,
sample_rate=DEFAULT_SAMPLE_RATE,
bias_t=bias_t, bias_t=bias_t,
) )
@@ -563,26 +565,26 @@ def enable_schedule():
'message': 'Invalid parameter value' 'message': 'Invalid parameter value'
}), 400 }), 400
scheduler = get_weather_sat_scheduler() scheduler = get_weather_sat_scheduler()
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback) scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
try: try:
result = scheduler.enable( result = scheduler.enable(
lat=lat, lat=lat,
lon=lon, lon=lon,
min_elevation=min_elev, min_elevation=min_elev,
device=device, device=device,
gain=gain_val, gain=gain_val,
bias_t=bool(data.get('bias_t', False)), bias_t=bool(data.get('bias_t', False)),
) )
except Exception as e: except Exception as e:
logger.exception("Failed to enable weather sat scheduler") logger.exception("Failed to enable weather sat scheduler")
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'Failed to enable scheduler' 'message': 'Failed to enable scheduler'
}), 500 }), 500
return jsonify({'status': 'ok', **result}) return jsonify({'status': 'ok', **result})
@weather_sat_bp.route('/schedule/disable', methods=['POST']) @weather_sat_bp.route('/schedule/disable', methods=['POST'])

View File

@@ -85,7 +85,11 @@ WEATHER_SATELLITES = {
} }
# Default sample rate for weather satellite reception # Default sample rate for weather satellite reception
DEFAULT_SAMPLE_RATE = 1000000 # 1 MHz try:
from config import WEATHER_SAT_SAMPLE_RATE as _configured_rate
DEFAULT_SAMPLE_RATE = _configured_rate
except ImportError:
DEFAULT_SAMPLE_RATE = 2400000 # 2.4 MHz — minimum for Meteor LRPT
@dataclass @dataclass

View File

@@ -4,12 +4,12 @@ Automatically captures satellite passes based on predicted pass times.
Uses threading.Timer for scheduling — no external dependencies required. Uses threading.Timer for scheduling — no external dependencies required.
""" """
from __future__ import annotations from __future__ import annotations
import threading import threading
import uuid import uuid
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Any, Callable from typing import Any, Callable
from utils.logging import get_logger from utils.logging import get_logger
from utils.weather_sat import get_weather_sat_decoder, WEATHER_SATELLITES, CaptureProgress from utils.weather_sat import get_weather_sat_decoder, WEATHER_SATELLITES, CaptureProgress
@@ -21,13 +21,15 @@ try:
from config import ( from config import (
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES, WEATHER_SAT_SCHEDULE_REFRESH_MINUTES,
WEATHER_SAT_CAPTURE_BUFFER_SECONDS, WEATHER_SAT_CAPTURE_BUFFER_SECONDS,
WEATHER_SAT_SAMPLE_RATE,
) )
except ImportError: except ImportError:
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = 30 WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = 30
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = 30 WEATHER_SAT_CAPTURE_BUFFER_SECONDS = 30
WEATHER_SAT_SAMPLE_RATE = 2400000
class ScheduledPass: class ScheduledPass:
"""A pass scheduled for automatic capture.""" """A pass scheduled for automatic capture."""
def __init__(self, pass_data: dict[str, Any]): def __init__(self, pass_data: dict[str, Any]):
@@ -46,13 +48,13 @@ class ScheduledPass:
self._timer: threading.Timer | None = None self._timer: threading.Timer | None = None
self._stop_timer: threading.Timer | None = None self._stop_timer: threading.Timer | None = None
@property @property
def start_dt(self) -> datetime: def start_dt(self) -> datetime:
return _parse_utc_iso(self.start_time) return _parse_utc_iso(self.start_time)
@property @property
def end_dt(self) -> datetime: def end_dt(self) -> datetime:
return _parse_utc_iso(self.end_time) return _parse_utc_iso(self.end_time)
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return { return {
@@ -71,7 +73,7 @@ class ScheduledPass:
} }
class WeatherSatScheduler: class WeatherSatScheduler:
"""Auto-scheduler for weather satellite captures.""" """Auto-scheduler for weather satellite captures."""
def __init__(self): def __init__(self):
@@ -200,10 +202,10 @@ class WeatherSatScheduler:
with self._lock: with self._lock:
return [p.to_dict() for p in self._passes] return [p.to_dict() for p in self._passes]
def _refresh_passes(self) -> None: def _refresh_passes(self) -> None:
"""Recompute passes and schedule timers.""" """Recompute passes and schedule timers."""
if not self._enabled: if not self._enabled:
return return
try: try:
from utils.weather_sat_predict import predict_passes from utils.weather_sat_predict import predict_passes
@@ -227,39 +229,39 @@ class WeatherSatScheduler:
p._stop_timer.cancel() p._stop_timer.cancel()
# Keep completed/skipped for history, replace scheduled # Keep completed/skipped for history, replace scheduled
history = [p for p in self._passes if p.status in ('complete', 'skipped', 'capturing')] history = [p for p in self._passes if p.status in ('complete', 'skipped', 'capturing')]
self._passes = history self._passes = history
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
buffer = WEATHER_SAT_CAPTURE_BUFFER_SECONDS buffer = WEATHER_SAT_CAPTURE_BUFFER_SECONDS
for pass_data in passes: for pass_data in passes:
try: try:
sp = ScheduledPass(pass_data) sp = ScheduledPass(pass_data)
start_dt = sp.start_dt start_dt = sp.start_dt
end_dt = sp.end_dt end_dt = sp.end_dt
except Exception as e: except Exception as e:
logger.warning(f"Skipping invalid pass data: {e}") logger.warning(f"Skipping invalid pass data: {e}")
continue continue
capture_start = start_dt - timedelta(seconds=buffer) capture_start = start_dt - timedelta(seconds=buffer)
capture_end = end_dt + timedelta(seconds=buffer) capture_end = end_dt + timedelta(seconds=buffer)
# Skip passes that are already over # Skip passes that are already over
if capture_end <= now: if capture_end <= now:
continue continue
# Check if already in history # Check if already in history
if any(h.id == sp.id for h in history): if any(h.id == sp.id for h in history):
continue continue
# Schedule capture timer. If we're already inside the capture # Schedule capture timer. If we're already inside the capture
# window, trigger immediately instead of skipping the pass. # window, trigger immediately instead of skipping the pass.
delay = max(0.0, (capture_start - now).total_seconds()) delay = max(0.0, (capture_start - now).total_seconds())
sp._timer = threading.Timer(delay, self._execute_capture, args=[sp]) sp._timer = threading.Timer(delay, self._execute_capture, args=[sp])
sp._timer.daemon = True sp._timer.daemon = True
sp._timer.start() sp._timer.start()
self._passes.append(sp) self._passes.append(sp)
logger.info( logger.info(
f"Scheduler refreshed: {sum(1 for p in self._passes if p.status == 'scheduled')} " f"Scheduler refreshed: {sum(1 for p in self._passes if p.status == 'scheduled')} "
@@ -330,6 +332,7 @@ class WeatherSatScheduler:
satellite=sp.satellite, satellite=sp.satellite,
device_index=self._device, device_index=self._device,
gain=self._gain, gain=self._gain,
sample_rate=WEATHER_SAT_SAMPLE_RATE,
bias_t=self._bias_t, bias_t=self._bias_t,
) )
@@ -374,31 +377,31 @@ class WeatherSatScheduler:
def _emit_event(self, event: dict[str, Any]) -> None: def _emit_event(self, event: dict[str, Any]) -> None:
"""Emit scheduler event to callback.""" """Emit scheduler event to callback."""
if self._event_callback: if self._event_callback:
try: try:
self._event_callback(event) self._event_callback(event)
except Exception as e: except Exception as e:
logger.error(f"Error in scheduler event callback: {e}") logger.error(f"Error in scheduler event callback: {e}")
def _parse_utc_iso(value: str) -> datetime: def _parse_utc_iso(value: str) -> datetime:
"""Parse UTC ISO8601 timestamp robustly across Python versions.""" """Parse UTC ISO8601 timestamp robustly across Python versions."""
if not value: if not value:
raise ValueError("missing timestamp") raise ValueError("missing timestamp")
text = str(value).strip() text = str(value).strip()
# Backward compatibility for malformed legacy strings. # Backward compatibility for malformed legacy strings.
text = text.replace('+00:00Z', 'Z') text = text.replace('+00:00Z', 'Z')
# Python <3.11 does not accept trailing 'Z' in fromisoformat. # Python <3.11 does not accept trailing 'Z' in fromisoformat.
if text.endswith('Z'): if text.endswith('Z'):
text = text[:-1] + '+00:00' text = text[:-1] + '+00:00'
dt = datetime.fromisoformat(text) dt = datetime.fromisoformat(text)
if dt.tzinfo is None: if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc) dt = dt.replace(tzinfo=timezone.utc)
else: else:
dt = dt.astimezone(timezone.utc) dt = dt.astimezone(timezone.utc)
return dt return dt
# Singleton # Singleton