fix(weather-sat): lower default gain to 30 dB to prevent ADC saturation

Strong passes at 40 dB (the previous default) cause RTL-SDR ADC clipping,
producing a distorted IQ stream that SatDump cannot lock onto. 30 dB is
a safer starting point that still captures weak passes cleanly.

Also adds a UI hint below the gain control explaining the saturation issue.

Closes #185
This commit is contained in:
James Smith
2026-04-05 14:20:06 +01:00
parent 6572119360
commit 6ea34a4c60
3 changed files with 215 additions and 214 deletions
+1 -1
View File
@@ -459,7 +459,7 @@ SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45) 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', 30.0)
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 2400000) 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)
+165 -165
View File
@@ -1,17 +1,17 @@
"""Weather Satellite decoder routes. """Weather Satellite decoder routes.
Provides endpoints for capturing and decoding Meteor LRPT weather
imagery, including shared results produced by the ground-station
observation pipeline.
"""
from __future__ import annotations Provides endpoints for capturing and decoding Meteor LRPT weather
imagery, including shared results produced by the ground-station
import json observation pipeline.
import queue """
from pathlib import Path
from __future__ import annotations
from flask import Blueprint, Response, jsonify, request, send_file
import json
import queue
from pathlib import Path
from flask import Blueprint, Response, jsonify, request, send_file
from utils.logging import get_logger from utils.logging import get_logger
from utils.responses import api_error from utils.responses import api_error
@@ -33,21 +33,21 @@ from utils.weather_sat import (
is_weather_sat_available, is_weather_sat_available,
) )
logger = get_logger('intercept.weather_sat') logger = get_logger('intercept.weather_sat')
weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat') weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat')
# Queue for SSE progress streaming # Queue for SSE progress streaming
_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100) _weather_sat_queue: queue.Queue = queue.Queue(maxsize=100)
METEOR_NORAD_IDS = { METEOR_NORAD_IDS = {
'METEOR-M2-3': 57166, 'METEOR-M2-3': 57166,
'METEOR-M2-4': 59051, 'METEOR-M2-4': 59051,
} }
ALLOWED_TEST_DECODE_DIRS = ( ALLOWED_TEST_DECODE_DIRS = (
Path(__file__).resolve().parent.parent / 'data', Path(__file__).resolve().parent.parent / 'data',
Path(__file__).resolve().parent.parent / 'instance' / 'ground_station' / 'recordings', Path(__file__).resolve().parent.parent / 'instance' / 'ground_station' / 'recordings',
) )
def _progress_callback(progress: CaptureProgress) -> None: def _progress_callback(progress: CaptureProgress) -> None:
@@ -132,9 +132,9 @@ def start_capture():
JSON body: JSON body:
{ {
"satellite": "METEOR-M2-3", // Required: satellite key "satellite": "METEOR-M2-3", // Required: satellite key
"device": 0, // RTL-SDR device index (default: 0) "device": 0, // RTL-SDR device index (default: 0)
"gain": 40.0, // SDR gain in dB (default: 40) "gain": 30.0, // SDR gain in dB (default: 30)
"bias_t": false // Enable bias-T for LNA (default: false) "bias_t": false // Enable bias-T for LNA (default: false)
} }
@@ -176,7 +176,7 @@ def start_capture():
# Validate device index and gain # Validate device index and gain
try: try:
device_index = validate_device_index(data.get('device', 0)) device_index = validate_device_index(data.get('device', 0))
gain = validate_gain(data.get('gain', 40.0)) gain = validate_gain(data.get('gain', 30.0))
except ValueError as e: except ValueError as e:
logger.warning('Invalid parameter in start_capture: %s', e) logger.warning('Invalid parameter in start_capture: %s', e)
return jsonify({ return jsonify({
@@ -260,7 +260,7 @@ def test_decode():
JSON body: JSON body:
{ {
"satellite": "METEOR-M2-3", // Required: satellite key "satellite": "METEOR-M2-3", // Required: satellite key
"input_file": "/path/to/file", // Required: server-side file path "input_file": "/path/to/file", // Required: server-side file path
"sample_rate": 1000000 // Sample rate in Hz (default: 1000000) "sample_rate": 1000000 // Sample rate in Hz (default: 1000000)
} }
@@ -304,14 +304,14 @@ def test_decode():
from pathlib import Path from pathlib import Path
input_path = Path(input_file) input_path = Path(input_file)
# Restrict test-decode to application-owned sample and recording paths. # Restrict test-decode to application-owned sample and recording paths.
try: try:
resolved = input_path.resolve() resolved = input_path.resolve()
if not any(resolved.is_relative_to(base) for base in ALLOWED_TEST_DECODE_DIRS): if not any(resolved.is_relative_to(base) for base in ALLOWED_TEST_DECODE_DIRS):
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'input_file must be under INTERCEPT data or ground-station recordings' 'message': 'input_file must be under INTERCEPT data or ground-station recordings'
}), 403 }), 403
except (OSError, ValueError): except (OSError, ValueError):
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -388,8 +388,8 @@ def stop_capture():
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@weather_sat_bp.route('/images') @weather_sat_bp.route('/images')
def list_images(): def list_images():
"""Get list of decoded weather satellite images. """Get list of decoded weather satellite images.
Query parameters: Query parameters:
@@ -399,41 +399,41 @@ def list_images():
Returns: Returns:
JSON with list of decoded images. JSON with list of decoded images.
""" """
decoder = get_weather_sat_decoder() decoder = get_weather_sat_decoder()
images = [ images = [
{ {
**img.to_dict(), **img.to_dict(),
'source': 'weather_sat', 'source': 'weather_sat',
'deletable': True, 'deletable': True,
} }
for img in decoder.get_images() for img in decoder.get_images()
] ]
images.extend(_get_ground_station_images()) images.extend(_get_ground_station_images())
# Filter by satellite if specified # Filter by satellite if specified
satellite_filter = request.args.get('satellite') satellite_filter = request.args.get('satellite')
if satellite_filter: if satellite_filter:
images = [ images = [
img for img in images img for img in images
if str(img.get('satellite', '')).upper() == satellite_filter.upper() if str(img.get('satellite', '')).upper() == satellite_filter.upper()
] ]
images.sort(key=lambda img: img.get('timestamp') or '', reverse=True) images.sort(key=lambda img: img.get('timestamp') or '', reverse=True)
# Apply limit # Apply limit
limit = request.args.get('limit', type=int) limit = request.args.get('limit', type=int)
if limit and limit > 0: if limit and limit > 0:
images = images[:limit] images = images[:limit]
return jsonify({ return jsonify({
'status': 'ok', 'status': 'ok',
'images': images, 'images': images,
'count': len(images), 'count': len(images),
}) })
@weather_sat_bp.route('/images/<filename>') @weather_sat_bp.route('/images/<filename>')
def get_image(filename: str): def get_image(filename: str):
"""Serve a decoded weather satellite image file. """Serve a decoded weather satellite image file.
Args: Args:
@@ -456,38 +456,38 @@ def get_image(filename: str):
if not image_path.exists(): if not image_path.exists():
return api_error('Image not found', 404) return api_error('Image not found', 404)
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg' mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
return send_file(image_path, mimetype=mimetype) return send_file(image_path, mimetype=mimetype)
@weather_sat_bp.route('/images/shared/<int:output_id>') @weather_sat_bp.route('/images/shared/<int:output_id>')
def get_shared_image(output_id: int): def get_shared_image(output_id: int):
"""Serve a Meteor image stored in ground-station outputs.""" """Serve a Meteor image stored in ground-station outputs."""
try: try:
from utils.database import get_db from utils.database import get_db
with get_db() as conn: with get_db() as conn:
row = conn.execute( row = conn.execute(
''' '''
SELECT file_path FROM ground_station_outputs SELECT file_path FROM ground_station_outputs
WHERE id=? AND output_type='image' WHERE id=? AND output_type='image'
''', ''',
(output_id,), (output_id,),
).fetchone() ).fetchone()
except Exception as e: except Exception as e:
logger.warning("Failed to load shared weather image %s: %s", output_id, e) logger.warning("Failed to load shared weather image %s: %s", output_id, e)
return api_error('Image not found', 404) return api_error('Image not found', 404)
if not row: if not row:
return api_error('Image not found', 404) return api_error('Image not found', 404)
image_path = Path(row['file_path']) image_path = Path(row['file_path'])
if not image_path.exists(): if not image_path.exists():
return api_error('Image not found', 404) return api_error('Image not found', 404)
suffix = image_path.suffix.lower() suffix = image_path.suffix.lower()
mimetype = 'image/png' if suffix == '.png' else 'image/jpeg' mimetype = 'image/png' if suffix == '.png' else 'image/jpeg'
return send_file(image_path, mimetype=mimetype) return send_file(image_path, mimetype=mimetype)
@weather_sat_bp.route('/images/<filename>', methods=['DELETE']) @weather_sat_bp.route('/images/<filename>', methods=['DELETE'])
@@ -512,71 +512,71 @@ def delete_image(filename: str):
@weather_sat_bp.route('/images', methods=['DELETE']) @weather_sat_bp.route('/images', methods=['DELETE'])
def delete_all_images(): def delete_all_images():
"""Delete all decoded weather satellite images. """Delete all decoded weather satellite images.
Returns: Returns:
JSON with count of deleted images. JSON with count of deleted images.
""" """
decoder = get_weather_sat_decoder() decoder = get_weather_sat_decoder()
count = decoder.delete_all_images() count = decoder.delete_all_images()
return jsonify({'status': 'ok', 'deleted': count}) return jsonify({'status': 'ok', 'deleted': count})
def _get_ground_station_images() -> list[dict]: def _get_ground_station_images() -> list[dict]:
try: try:
from utils.database import get_db from utils.database import get_db
with get_db() as conn: with get_db() as conn:
rows = conn.execute( rows = conn.execute(
''' '''
SELECT id, norad_id, file_path, metadata_json, created_at SELECT id, norad_id, file_path, metadata_json, created_at
FROM ground_station_outputs FROM ground_station_outputs
WHERE output_type='image' AND backend='meteor_lrpt' WHERE output_type='image' AND backend='meteor_lrpt'
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 200 LIMIT 200
''' '''
).fetchall() ).fetchall()
except Exception as e: except Exception as e:
logger.debug("Failed to fetch ground-station weather outputs: %s", e) logger.debug("Failed to fetch ground-station weather outputs: %s", e)
return [] return []
images: list[dict] = [] images: list[dict] = []
for row in rows: for row in rows:
file_path = Path(row['file_path']) file_path = Path(row['file_path'])
if not file_path.exists(): if not file_path.exists():
continue continue
metadata = {} metadata = {}
raw_metadata = row['metadata_json'] raw_metadata = row['metadata_json']
if raw_metadata: if raw_metadata:
try: try:
metadata = json.loads(raw_metadata) metadata = json.loads(raw_metadata)
except json.JSONDecodeError: except json.JSONDecodeError:
metadata = {} metadata = {}
satellite = metadata.get('satellite') or _satellite_from_norad(row['norad_id']) satellite = metadata.get('satellite') or _satellite_from_norad(row['norad_id'])
images.append({ images.append({
'filename': file_path.name, 'filename': file_path.name,
'satellite': satellite, 'satellite': satellite,
'mode': metadata.get('mode', 'LRPT'), 'mode': metadata.get('mode', 'LRPT'),
'timestamp': metadata.get('timestamp') or row['created_at'], 'timestamp': metadata.get('timestamp') or row['created_at'],
'frequency': metadata.get('frequency', 137.9), 'frequency': metadata.get('frequency', 137.9),
'size_bytes': metadata.get('size_bytes') or file_path.stat().st_size, 'size_bytes': metadata.get('size_bytes') or file_path.stat().st_size,
'product': metadata.get('product', ''), 'product': metadata.get('product', ''),
'url': f"/weather-sat/images/shared/{row['id']}", 'url': f"/weather-sat/images/shared/{row['id']}",
'source': 'ground_station', 'source': 'ground_station',
'deletable': False, 'deletable': False,
'output_id': row['id'], 'output_id': row['id'],
}) })
return images return images
def _satellite_from_norad(norad_id: int | None) -> str: def _satellite_from_norad(norad_id: int | None) -> str:
for satellite, known_norad in METEOR_NORAD_IDS.items(): for satellite, known_norad in METEOR_NORAD_IDS.items():
if known_norad == norad_id: if known_norad == norad_id:
return satellite return satellite
return 'METEOR' return 'METEOR'
@weather_sat_bp.route('/stream') @weather_sat_bp.route('/stream')
@@ -689,7 +689,7 @@ def enable_schedule():
"longitude": -0.1, // Required "longitude": -0.1, // Required
"min_elevation": 15, // Minimum pass elevation (default: 15) "min_elevation": 15, // Minimum pass elevation (default: 15)
"device": 0, // RTL-SDR device index (default: 0) "device": 0, // RTL-SDR device index (default: 0)
"gain": 40.0, // SDR gain (default: 40) "gain": 30.0, // SDR gain (default: 30)
"bias_t": false // Enable bias-T (default: false) "bias_t": false // Enable bias-T (default: false)
} }
+49 -48
View File
@@ -2,13 +2,13 @@
<div id="weatherSatMode" class="mode-content"> <div id="weatherSatMode" class="mode-content">
<div class="section"> <div class="section">
<h3>Weather Satellite Decoder</h3> <h3>Weather Satellite Decoder</h3>
<div class="alpha-mode-notice"> <div class="alpha-mode-notice">
ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions. ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions.
</div> </div>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;"> <p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Receive and decode Meteor LRPT weather imagery. Receive and decode Meteor LRPT weather imagery.
Uses SatDump for live SDR capture and image processing, and also shows Meteor imagery produced by the ground-station scheduler. Uses SatDump for live SDR capture and image processing, and also shows Meteor imagery produced by the ground-station scheduler.
</p> </p>
</div> </div>
<div class="section"> <div class="section">
@@ -18,11 +18,12 @@
<select id="weatherSatSelect" class="mode-select"> <select id="weatherSatSelect" class="mode-select">
<option value="METEOR-M2-3" selected>Meteor-M2-3 (137.900 MHz LRPT)</option> <option value="METEOR-M2-3" selected>Meteor-M2-3 (137.900 MHz LRPT)</option>
<option value="METEOR-M2-4">Meteor-M2-4 (137.900 MHz LRPT)</option> <option value="METEOR-M2-4">Meteor-M2-4 (137.900 MHz LRPT)</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Gain (dB)</label> <label>Gain (dB)</label>
<input type="number" id="weatherSatGain" value="40" step="0.1" min="0" max="50"> <input type="number" id="weatherSatGain" value="30" step="0.1" min="0" max="50">
<p class="info-text" style="font-size: 10px; color: var(--text-dim); margin-top: 3px;">Reduce if decoding fails on strong passes (ADC saturation).</p>
</div> </div>
<div class="form-group"> <div class="form-group">
<label style="display: flex; align-items: center; gap: 6px;"> <label style="display: flex; align-items: center; gap: 6px;">
@@ -69,7 +70,7 @@
<li><strong style="color: var(--text-primary);">Connection:</strong> Solder elements to coax center + shield, connect to SDR via SMA</li> <li><strong style="color: var(--text-primary);">Connection:</strong> Solder elements to coax center + shield, connect to SDR via SMA</li>
</ul> </ul>
<p style="margin-top: 6px; color: var(--text-dim); font-style: italic;"> <p style="margin-top: 6px; color: var(--text-dim); font-style: italic;">
Best starter antenna. Good enough for a clean Meteor LRPT pass when the satellite gets high overhead. Best starter antenna. Good enough for a clean Meteor LRPT pass when the satellite gets high overhead.
</p> </p>
</div> </div>
@@ -132,8 +133,8 @@
<li><strong style="color: var(--text-primary);">Antenna up:</strong> Point the antenna straight UP (zenith) for best overhead coverage</li> <li><strong style="color: var(--text-primary);">Antenna up:</strong> Point the antenna straight UP (zenith) for best overhead coverage</li>
<li><strong style="color: var(--text-primary);">Avoid:</strong> Metal roofs, power lines, buildings blocking the sky</li> <li><strong style="color: var(--text-primary);">Avoid:</strong> Metal roofs, power lines, buildings blocking the sky</li>
<li><strong style="color: var(--text-primary);">Coax length:</strong> Keep short (&lt;10m). Signal loss at 137 MHz is ~3 dB per 10m of RG-58</li> <li><strong style="color: var(--text-primary);">Coax length:</strong> Keep short (&lt;10m). Signal loss at 137 MHz is ~3 dB per 10m of RG-58</li>
<li><strong style="color: var(--text-primary);">LNA:</strong> Mount at the antenna feed point, NOT at the SDR end. <li><strong style="color: var(--text-primary);">LNA:</strong> Mount at the antenna feed point, NOT at the SDR end.
Recommended: a low-noise 137 MHz filtered LNA near the antenna feed point</li> Recommended: a low-noise 137 MHz filtered LNA near the antenna feed point</li>
<li><strong style="color: var(--text-primary);">Bias-T:</strong> Enable the Bias-T checkbox above if your LNA is powered via the coax from the SDR</li> <li><strong style="color: var(--text-primary);">Bias-T:</strong> Enable the Bias-T checkbox above if your LNA is powered via the coax from the SDR</li>
</ul> </ul>
</div> </div>
@@ -162,9 +163,9 @@
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td> <td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RHCP</td> <td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RHCP</td>
</tr> </tr>
<tr> <tr>
<td style="padding: 3px 4px; color: var(--text-dim);">Meteor (LRPT) bandwidth</td> <td style="padding: 3px 4px; color: var(--text-dim);">Meteor (LRPT) bandwidth</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~140 kHz</td> <td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~140 kHz</td>
</tr> </tr>
</table> </table>
</div> </div>
@@ -177,29 +178,29 @@
<span class="wxsat-collapse-icon collapsed" style="font-size: 10px; transition: transform 0.2s; display: inline-block;">&#9660;</span> <span class="wxsat-collapse-icon collapsed" style="font-size: 10px; transition: transform 0.2s; display: inline-block;">&#9660;</span>
</h3> </h3>
<div class="wxsat-test-decode-body collapsed" style="overflow: hidden;"> <div class="wxsat-test-decode-body collapsed" style="overflow: hidden;">
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;"> <p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
Decode a pre-recorded Meteor IQ file without SDR hardware. Decode a pre-recorded Meteor IQ file without SDR hardware.
Shared ground-station recordings are also accepted by the backend. Shared ground-station recordings are also accepted by the backend.
</p> </p>
<div class="form-group"> <div class="form-group">
<label>Satellite</label> <label>Satellite</label>
<select id="wxsatTestSatSelect" class="mode-select"> <select id="wxsatTestSatSelect" class="mode-select">
<option value="METEOR-M2-3" selected>Meteor-M2-3 (LRPT)</option> <option value="METEOR-M2-3" selected>Meteor-M2-3 (LRPT)</option>
<option value="METEOR-M2-4">Meteor-M2-4 (LRPT)</option> <option value="METEOR-M2-4">Meteor-M2-4 (LRPT)</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>File Path (server-side)</label> <label>File Path (server-side)</label>
<input type="text" id="wxsatTestFilePath" value="data/weather_sat/samples/meteor_lrpt.sigmf-data" style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 11px;"> <input type="text" id="wxsatTestFilePath" value="data/weather_sat/samples/meteor_lrpt.sigmf-data" style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 11px;">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Sample Rate</label> <label>Sample Rate</label>
<select id="wxsatTestSampleRate" class="mode-select"> <select id="wxsatTestSampleRate" class="mode-select">
<option value="500000">500 kHz (IQ LRPT)</option> <option value="500000">500 kHz (IQ LRPT)</option>
<option value="1000000">1 MHz (IQ narrow)</option> <option value="1000000">1 MHz (IQ narrow)</option>
<option value="2400000" selected>2.4 MHz (INTERCEPT default)</option> <option value="2400000" selected>2.4 MHz (INTERCEPT default)</option>
</select> </select>
</div> </div>
<button class="mode-btn" onclick="WeatherSat.testDecode()" style="width: 100%; margin-top: 4px;"> <button class="mode-btn" onclick="WeatherSat.testDecode()" style="width: 100%; margin-top: 4px;">
Test Decode Test Decode
</button> </button>
@@ -226,12 +227,12 @@
<div class="section"> <div class="section">
<h3>Resources</h3> <h3>Resources</h3>
<div style="display: flex; flex-direction: column; gap: 6px;"> <div style="display: flex; flex-direction: column; gap: 6px;">
<a href="https://github.com/SatDump/SatDump" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;"> <a href="https://github.com/SatDump/SatDump" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
SatDump Documentation SatDump Documentation
</a> </a>
<a href="https://www.rtl-sdr.com/rtl-sdr-tutorial-receiving-meteor-m2-weather-satellite-images/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;"> <a href="https://www.rtl-sdr.com/rtl-sdr-tutorial-receiving-meteor-m2-weather-satellite-images/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
Meteor Reception Guide Meteor Reception Guide
</a> </a>
</div> </div>
</div> </div>
</div> </div>