Add Meteor LRPT ground station pipeline

This commit is contained in:
James Smith
2026-03-18 22:01:52 +00:00
parent 5cae753e0d
commit e388baa464
7 changed files with 756 additions and 71 deletions
+77 -2
View File
@@ -7,6 +7,7 @@ to record raw IQ in SigMF format.
from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
@@ -16,6 +17,52 @@ from utils.logging import get_logger
logger = get_logger('intercept.ground_station.profile')
VALID_TASK_TYPES = {
'telemetry_ax25',
'telemetry_gmsk',
'telemetry_bpsk',
'weather_meteor_lrpt',
'record_iq',
}
def legacy_decoder_to_tasks(decoder_type: str | None, record_iq: bool = False) -> list[str]:
decoder = (decoder_type or 'fm').lower()
tasks: list[str] = []
if decoder in ('fm', 'afsk'):
tasks.append('telemetry_ax25')
elif decoder == 'gmsk':
tasks.append('telemetry_gmsk')
elif decoder == 'bpsk':
tasks.append('telemetry_bpsk')
elif decoder == 'iq_only':
tasks.append('record_iq')
if record_iq and 'record_iq' not in tasks:
tasks.append('record_iq')
return tasks
def tasks_to_legacy_decoder(tasks: list[str]) -> str:
normalized = normalize_tasks(tasks)
if 'telemetry_bpsk' in normalized:
return 'bpsk'
if 'telemetry_gmsk' in normalized:
return 'gmsk'
if 'telemetry_ax25' in normalized:
return 'afsk'
return 'iq_only'
def normalize_tasks(tasks: list[str] | None) -> list[str]:
result: list[str] = []
for task in tasks or []:
value = str(task or '').strip().lower()
if value and value in VALID_TASK_TYPES and value not in result:
result.append(value)
return result
@dataclass
class ObservationProfile:
"""Per-satellite capture configuration."""
@@ -30,29 +77,50 @@ class ObservationProfile:
enabled: bool = True
record_iq: bool = False
iq_sample_rate: int = 2_400_000
tasks: list[str] = field(default_factory=list)
id: int | None = None
created_at: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
def to_dict(self) -> dict[str, Any]:
normalized_tasks = self.get_tasks()
return {
'id': self.id,
'norad_id': self.norad_id,
'name': self.name,
'frequency_mhz': self.frequency_mhz,
'decoder_type': self.decoder_type,
'legacy_decoder_type': self.decoder_type,
'gain': self.gain,
'bandwidth_hz': self.bandwidth_hz,
'min_elevation': self.min_elevation,
'enabled': self.enabled,
'record_iq': self.record_iq,
'iq_sample_rate': self.iq_sample_rate,
'tasks': normalized_tasks,
'created_at': self.created_at,
}
def get_tasks(self) -> list[str]:
tasks = normalize_tasks(self.tasks)
if not tasks:
tasks = legacy_decoder_to_tasks(self.decoder_type, self.record_iq)
if self.record_iq and 'record_iq' not in tasks:
tasks.append('record_iq')
if 'weather_meteor_lrpt' in tasks and 'record_iq' not in tasks:
tasks.append('record_iq')
return tasks
@classmethod
def from_row(cls, row) -> 'ObservationProfile':
tasks = []
raw_tasks = row['tasks_json'] if 'tasks_json' in row.keys() else None
if raw_tasks:
try:
tasks = normalize_tasks(json.loads(raw_tasks))
except (TypeError, ValueError, json.JSONDecodeError):
tasks = []
return cls(
id=row['id'],
norad_id=row['norad_id'],
@@ -65,6 +133,7 @@ class ObservationProfile:
enabled=bool(row['enabled']),
record_iq=bool(row['record_iq']),
iq_sample_rate=row['iq_sample_rate'],
tasks=tasks,
created_at=row['created_at'],
)
@@ -97,17 +166,22 @@ def get_profile(norad_id: int) -> ObservationProfile | None:
def save_profile(profile: ObservationProfile) -> ObservationProfile:
"""Insert or replace an observation profile. Returns the saved profile."""
from utils.database import get_db
normalized_tasks = profile.get_tasks()
profile.tasks = normalized_tasks
profile.record_iq = 'record_iq' in normalized_tasks
profile.decoder_type = tasks_to_legacy_decoder(normalized_tasks)
with get_db() as conn:
conn.execute('''
INSERT INTO observation_profiles
(norad_id, name, frequency_mhz, decoder_type, gain,
(norad_id, name, frequency_mhz, decoder_type, tasks_json, gain,
bandwidth_hz, min_elevation, enabled, record_iq,
iq_sample_rate, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(norad_id) DO UPDATE SET
name=excluded.name,
frequency_mhz=excluded.frequency_mhz,
decoder_type=excluded.decoder_type,
tasks_json=excluded.tasks_json,
gain=excluded.gain,
bandwidth_hz=excluded.bandwidth_hz,
min_elevation=excluded.min_elevation,
@@ -119,6 +193,7 @@ def save_profile(profile: ObservationProfile) -> ObservationProfile:
profile.name,
profile.frequency_mhz,
profile.decoder_type,
json.dumps(normalized_tasks),
profile.gain,
profile.bandwidth_hz,
profile.min_elevation,