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

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)
# 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_MIN_ELEVATION = _get_env_float('WEATHER_SAT_MIN_ELEVATION', 15.0)
WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24)

View File

@@ -1,17 +1,17 @@
"""Weather Satellite decoder routes.
Provides endpoints for capturing and decoding Meteor LRPT weather
imagery, including shared results produced by the ground-station
observation pipeline.
"""
"""Weather Satellite decoder routes.
from __future__ import annotations
import json
import queue
from pathlib import Path
from flask import Blueprint, Response, jsonify, request, send_file
Provides endpoints for capturing and decoding Meteor LRPT weather
imagery, including shared results produced by the ground-station
observation pipeline.
"""
from __future__ import annotations
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.responses import api_error
@@ -33,21 +33,21 @@ from utils.weather_sat import (
is_weather_sat_available,
)
logger = get_logger('intercept.weather_sat')
weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat')
logger = get_logger('intercept.weather_sat')
weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat')
# Queue for SSE progress streaming
_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100)
METEOR_NORAD_IDS = {
'METEOR-M2-3': 57166,
'METEOR-M2-4': 59051,
}
ALLOWED_TEST_DECODE_DIRS = (
Path(__file__).resolve().parent.parent / 'data',
Path(__file__).resolve().parent.parent / 'instance' / 'ground_station' / 'recordings',
)
_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100)
METEOR_NORAD_IDS = {
'METEOR-M2-3': 57166,
'METEOR-M2-4': 59051,
}
ALLOWED_TEST_DECODE_DIRS = (
Path(__file__).resolve().parent.parent / 'data',
Path(__file__).resolve().parent.parent / 'instance' / 'ground_station' / 'recordings',
)
def _progress_callback(progress: CaptureProgress) -> None:
@@ -132,9 +132,9 @@ def start_capture():
JSON body:
{
"satellite": "METEOR-M2-3", // Required: satellite key
"satellite": "METEOR-M2-3", // Required: satellite key
"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)
}
@@ -176,7 +176,7 @@ def start_capture():
# Validate device index and gain
try:
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:
logger.warning('Invalid parameter in start_capture: %s', e)
return jsonify({
@@ -260,7 +260,7 @@ def test_decode():
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
"sample_rate": 1000000 // Sample rate in Hz (default: 1000000)
}
@@ -304,14 +304,14 @@ def test_decode():
from pathlib import Path
input_path = Path(input_file)
# Restrict test-decode to application-owned sample and recording paths.
try:
resolved = input_path.resolve()
if not any(resolved.is_relative_to(base) for base in ALLOWED_TEST_DECODE_DIRS):
return jsonify({
'status': 'error',
'message': 'input_file must be under INTERCEPT data or ground-station recordings'
}), 403
# Restrict test-decode to application-owned sample and recording paths.
try:
resolved = input_path.resolve()
if not any(resolved.is_relative_to(base) for base in ALLOWED_TEST_DECODE_DIRS):
return jsonify({
'status': 'error',
'message': 'input_file must be under INTERCEPT data or ground-station recordings'
}), 403
except (OSError, ValueError):
return jsonify({
'status': 'error',
@@ -388,8 +388,8 @@ def stop_capture():
return jsonify({'status': 'stopped'})
@weather_sat_bp.route('/images')
def list_images():
@weather_sat_bp.route('/images')
def list_images():
"""Get list of decoded weather satellite images.
Query parameters:
@@ -399,41 +399,41 @@ def list_images():
Returns:
JSON with list of decoded images.
"""
decoder = get_weather_sat_decoder()
images = [
{
**img.to_dict(),
'source': 'weather_sat',
'deletable': True,
}
for img in decoder.get_images()
]
images.extend(_get_ground_station_images())
# Filter by satellite if specified
satellite_filter = request.args.get('satellite')
if satellite_filter:
images = [
img for img in images
if str(img.get('satellite', '')).upper() == satellite_filter.upper()
]
images.sort(key=lambda img: img.get('timestamp') or '', reverse=True)
# Apply limit
limit = request.args.get('limit', type=int)
if limit and limit > 0:
images = images[:limit]
return jsonify({
'status': 'ok',
'images': images,
'count': len(images),
})
decoder = get_weather_sat_decoder()
images = [
{
**img.to_dict(),
'source': 'weather_sat',
'deletable': True,
}
for img in decoder.get_images()
]
images.extend(_get_ground_station_images())
# Filter by satellite if specified
satellite_filter = request.args.get('satellite')
if satellite_filter:
images = [
img for img in images
if str(img.get('satellite', '')).upper() == satellite_filter.upper()
]
images.sort(key=lambda img: img.get('timestamp') or '', reverse=True)
# Apply limit
limit = request.args.get('limit', type=int)
if limit and limit > 0:
images = images[:limit]
return jsonify({
'status': 'ok',
'images': images,
'count': len(images),
})
@weather_sat_bp.route('/images/<filename>')
def get_image(filename: str):
@weather_sat_bp.route('/images/<filename>')
def get_image(filename: str):
"""Serve a decoded weather satellite image file.
Args:
@@ -456,38 +456,38 @@ def get_image(filename: str):
if not image_path.exists():
return api_error('Image not found', 404)
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
return send_file(image_path, mimetype=mimetype)
@weather_sat_bp.route('/images/shared/<int:output_id>')
def get_shared_image(output_id: int):
"""Serve a Meteor image stored in ground-station outputs."""
try:
from utils.database import get_db
with get_db() as conn:
row = conn.execute(
'''
SELECT file_path FROM ground_station_outputs
WHERE id=? AND output_type='image'
''',
(output_id,),
).fetchone()
except Exception as e:
logger.warning("Failed to load shared weather image %s: %s", output_id, e)
return api_error('Image not found', 404)
if not row:
return api_error('Image not found', 404)
image_path = Path(row['file_path'])
if not image_path.exists():
return api_error('Image not found', 404)
suffix = image_path.suffix.lower()
mimetype = 'image/png' if suffix == '.png' else 'image/jpeg'
return send_file(image_path, mimetype=mimetype)
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
return send_file(image_path, mimetype=mimetype)
@weather_sat_bp.route('/images/shared/<int:output_id>')
def get_shared_image(output_id: int):
"""Serve a Meteor image stored in ground-station outputs."""
try:
from utils.database import get_db
with get_db() as conn:
row = conn.execute(
'''
SELECT file_path FROM ground_station_outputs
WHERE id=? AND output_type='image'
''',
(output_id,),
).fetchone()
except Exception as e:
logger.warning("Failed to load shared weather image %s: %s", output_id, e)
return api_error('Image not found', 404)
if not row:
return api_error('Image not found', 404)
image_path = Path(row['file_path'])
if not image_path.exists():
return api_error('Image not found', 404)
suffix = image_path.suffix.lower()
mimetype = 'image/png' if suffix == '.png' else 'image/jpeg'
return send_file(image_path, mimetype=mimetype)
@weather_sat_bp.route('/images/<filename>', methods=['DELETE'])
@@ -512,71 +512,71 @@ def delete_image(filename: str):
@weather_sat_bp.route('/images', methods=['DELETE'])
def delete_all_images():
def delete_all_images():
"""Delete all decoded weather satellite images.
Returns:
JSON with count of deleted images.
"""
decoder = get_weather_sat_decoder()
count = decoder.delete_all_images()
return jsonify({'status': 'ok', 'deleted': count})
def _get_ground_station_images() -> list[dict]:
try:
from utils.database import get_db
with get_db() as conn:
rows = conn.execute(
'''
SELECT id, norad_id, file_path, metadata_json, created_at
FROM ground_station_outputs
WHERE output_type='image' AND backend='meteor_lrpt'
ORDER BY created_at DESC
LIMIT 200
'''
).fetchall()
except Exception as e:
logger.debug("Failed to fetch ground-station weather outputs: %s", e)
return []
images: list[dict] = []
for row in rows:
file_path = Path(row['file_path'])
if not file_path.exists():
continue
metadata = {}
raw_metadata = row['metadata_json']
if raw_metadata:
try:
metadata = json.loads(raw_metadata)
except json.JSONDecodeError:
metadata = {}
satellite = metadata.get('satellite') or _satellite_from_norad(row['norad_id'])
images.append({
'filename': file_path.name,
'satellite': satellite,
'mode': metadata.get('mode', 'LRPT'),
'timestamp': metadata.get('timestamp') or row['created_at'],
'frequency': metadata.get('frequency', 137.9),
'size_bytes': metadata.get('size_bytes') or file_path.stat().st_size,
'product': metadata.get('product', ''),
'url': f"/weather-sat/images/shared/{row['id']}",
'source': 'ground_station',
'deletable': False,
'output_id': row['id'],
})
return images
def _satellite_from_norad(norad_id: int | None) -> str:
for satellite, known_norad in METEOR_NORAD_IDS.items():
if known_norad == norad_id:
return satellite
return 'METEOR'
count = decoder.delete_all_images()
return jsonify({'status': 'ok', 'deleted': count})
def _get_ground_station_images() -> list[dict]:
try:
from utils.database import get_db
with get_db() as conn:
rows = conn.execute(
'''
SELECT id, norad_id, file_path, metadata_json, created_at
FROM ground_station_outputs
WHERE output_type='image' AND backend='meteor_lrpt'
ORDER BY created_at DESC
LIMIT 200
'''
).fetchall()
except Exception as e:
logger.debug("Failed to fetch ground-station weather outputs: %s", e)
return []
images: list[dict] = []
for row in rows:
file_path = Path(row['file_path'])
if not file_path.exists():
continue
metadata = {}
raw_metadata = row['metadata_json']
if raw_metadata:
try:
metadata = json.loads(raw_metadata)
except json.JSONDecodeError:
metadata = {}
satellite = metadata.get('satellite') or _satellite_from_norad(row['norad_id'])
images.append({
'filename': file_path.name,
'satellite': satellite,
'mode': metadata.get('mode', 'LRPT'),
'timestamp': metadata.get('timestamp') or row['created_at'],
'frequency': metadata.get('frequency', 137.9),
'size_bytes': metadata.get('size_bytes') or file_path.stat().st_size,
'product': metadata.get('product', ''),
'url': f"/weather-sat/images/shared/{row['id']}",
'source': 'ground_station',
'deletable': False,
'output_id': row['id'],
})
return images
def _satellite_from_norad(norad_id: int | None) -> str:
for satellite, known_norad in METEOR_NORAD_IDS.items():
if known_norad == norad_id:
return satellite
return 'METEOR'
@weather_sat_bp.route('/stream')
@@ -689,7 +689,7 @@ def enable_schedule():
"longitude": -0.1, // Required
"min_elevation": 15, // Minimum pass elevation (default: 15)
"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)
}

View File

@@ -2,13 +2,13 @@
<div id="weatherSatMode" class="mode-content">
<div class="section">
<h3>Weather Satellite Decoder</h3>
<div class="alpha-mode-notice">
ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions.
</div>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
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.
</p>
<div class="alpha-mode-notice">
ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions.
</div>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
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.
</p>
</div>
<div class="section">
@@ -18,11 +18,12 @@
<select id="weatherSatSelect" class="mode-select">
<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>
</select>
</div>
</select>
</div>
<div class="form-group">
<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 class="form-group">
<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>
</ul>
<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>
</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);">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);">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>
<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>
<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>
</div>
@@ -162,9 +163,9 @@
<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>
</tr>
<tr>
<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>
<tr>
<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>
</tr>
</table>
</div>
@@ -177,29 +178,29 @@
<span class="wxsat-collapse-icon collapsed" style="font-size: 10px; transition: transform 0.2s; display: inline-block;">&#9660;</span>
</h3>
<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;">
Decode a pre-recorded Meteor IQ file without SDR hardware.
Shared ground-station recordings are also accepted by the backend.
</p>
<div class="form-group">
<label>Satellite</label>
<select id="wxsatTestSatSelect" class="mode-select">
<option value="METEOR-M2-3" selected>Meteor-M2-3 (LRPT)</option>
<option value="METEOR-M2-4">Meteor-M2-4 (LRPT)</option>
</select>
</div>
<div class="form-group">
<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;">
</div>
<div class="form-group">
<label>Sample Rate</label>
<select id="wxsatTestSampleRate" class="mode-select">
<option value="500000">500 kHz (IQ LRPT)</option>
<option value="1000000">1 MHz (IQ narrow)</option>
<option value="2400000" selected>2.4 MHz (INTERCEPT default)</option>
</select>
</div>
<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.
Shared ground-station recordings are also accepted by the backend.
</p>
<div class="form-group">
<label>Satellite</label>
<select id="wxsatTestSatSelect" class="mode-select">
<option value="METEOR-M2-3" selected>Meteor-M2-3 (LRPT)</option>
<option value="METEOR-M2-4">Meteor-M2-4 (LRPT)</option>
</select>
</div>
<div class="form-group">
<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;">
</div>
<div class="form-group">
<label>Sample Rate</label>
<select id="wxsatTestSampleRate" class="mode-select">
<option value="500000">500 kHz (IQ LRPT)</option>
<option value="1000000">1 MHz (IQ narrow)</option>
<option value="2400000" selected>2.4 MHz (INTERCEPT default)</option>
</select>
</div>
<button class="mode-btn" onclick="WeatherSat.testDecode()" style="width: 100%; margin-top: 4px;">
Test Decode
</button>
@@ -226,12 +227,12 @@
<div class="section">
<h3>Resources</h3>
<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;">
SatDump Documentation
</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;">
Meteor Reception Guide
</a>
</div>
</div>
</div>
<a href="https://github.com/SatDump/SatDump" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
SatDump Documentation
</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;">
Meteor Reception Guide
</a>
</div>
</div>
</div>