mirror of
https://github.com/smittix/intercept.git
synced 2026-07-06 16:48:12 -07:00
96172ca593
First-time run of ruff-format via pre-commit hook normalises quote style, trailing commas, and whitespace across 188 Python files. No logic changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
496 lines
15 KiB
Python
496 lines
15 KiB
Python
"""Meteor LRPT offline decode backend for ground-station observations."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from utils.logging import get_logger
|
|
from utils.weather_sat import WeatherSatDecoder
|
|
|
|
logger = get_logger("intercept.ground_station.meteor_backend")
|
|
|
|
OUTPUT_ROOT = Path("instance/ground_station/weather_outputs")
|
|
DECODE_TIMEOUT_SECONDS = 30 * 60
|
|
|
|
_NORAD_TO_SAT_KEY = {
|
|
57166: "METEOR-M2-3",
|
|
59051: "METEOR-M2-4",
|
|
}
|
|
|
|
|
|
def resolve_meteor_satellite_key(norad_id: int, satellite_name: str) -> str | None:
|
|
if norad_id in _NORAD_TO_SAT_KEY:
|
|
return _NORAD_TO_SAT_KEY[norad_id]
|
|
|
|
upper = str(satellite_name or "").upper()
|
|
if "M2-4" in upper:
|
|
return "METEOR-M2-4"
|
|
if "M2-3" in upper or "METEOR" in upper:
|
|
return "METEOR-M2-3"
|
|
return None
|
|
|
|
|
|
def launch_meteor_decode(
|
|
*,
|
|
obs_db_id: int | None,
|
|
norad_id: int,
|
|
satellite_name: str,
|
|
sample_rate: int,
|
|
data_path: Path,
|
|
emit_event,
|
|
register_output,
|
|
) -> None:
|
|
"""Run Meteor LRPT offline decode in a background thread."""
|
|
decode_job_id = _create_decode_job(
|
|
observation_id=obs_db_id,
|
|
norad_id=norad_id,
|
|
backend="meteor_lrpt",
|
|
input_path=data_path,
|
|
)
|
|
emit_event(
|
|
{
|
|
"type": "weather_decode_queued",
|
|
"decode_job_id": decode_job_id,
|
|
"norad_id": norad_id,
|
|
"satellite": satellite_name,
|
|
"backend": "meteor_lrpt",
|
|
"input_path": str(data_path),
|
|
}
|
|
)
|
|
t = threading.Thread(
|
|
target=_run_decode,
|
|
kwargs={
|
|
"decode_job_id": decode_job_id,
|
|
"obs_db_id": obs_db_id,
|
|
"norad_id": norad_id,
|
|
"satellite_name": satellite_name,
|
|
"sample_rate": sample_rate,
|
|
"data_path": data_path,
|
|
"emit_event": emit_event,
|
|
"register_output": register_output,
|
|
},
|
|
daemon=True,
|
|
name=f"gs-meteor-decode-{norad_id}",
|
|
)
|
|
t.start()
|
|
|
|
|
|
def _run_decode(
|
|
*,
|
|
decode_job_id: int | None,
|
|
obs_db_id: int | None,
|
|
norad_id: int,
|
|
satellite_name: str,
|
|
sample_rate: int,
|
|
data_path: Path,
|
|
emit_event,
|
|
register_output,
|
|
) -> None:
|
|
latest_status: dict[str, str | int | None] = {
|
|
"message": None,
|
|
"status": None,
|
|
"phase": None,
|
|
}
|
|
sat_key = resolve_meteor_satellite_key(norad_id, satellite_name)
|
|
if not sat_key:
|
|
_update_decode_job(
|
|
decode_job_id,
|
|
status="failed",
|
|
error_message="No Meteor satellite mapping is available for this observation.",
|
|
details={"reason": "unknown_satellite_mapping"},
|
|
completed=True,
|
|
)
|
|
emit_event(
|
|
{
|
|
"type": "weather_decode_failed",
|
|
"decode_job_id": decode_job_id,
|
|
"norad_id": norad_id,
|
|
"satellite": satellite_name,
|
|
"backend": "meteor_lrpt",
|
|
"message": "No Meteor satellite mapping is available for this observation.",
|
|
}
|
|
)
|
|
return
|
|
|
|
output_dir = OUTPUT_ROOT / f"{norad_id}_{int(time.time())}"
|
|
decoder = WeatherSatDecoder(output_dir=output_dir)
|
|
if decoder.decoder_available is None:
|
|
_update_decode_job(
|
|
decode_job_id,
|
|
status="failed",
|
|
error_message="SatDump backend is not available for Meteor LRPT decode.",
|
|
details={"reason": "backend_unavailable", "output_dir": str(output_dir)},
|
|
completed=True,
|
|
)
|
|
emit_event(
|
|
{
|
|
"type": "weather_decode_failed",
|
|
"decode_job_id": decode_job_id,
|
|
"norad_id": norad_id,
|
|
"satellite": satellite_name,
|
|
"backend": "meteor_lrpt",
|
|
"message": "SatDump backend is not available for Meteor LRPT decode.",
|
|
}
|
|
)
|
|
return
|
|
|
|
def _progress_cb(progress):
|
|
latest_status["message"] = progress.message or latest_status.get("message")
|
|
latest_status["status"] = progress.status
|
|
latest_status["phase"] = progress.capture_phase or latest_status.get("phase")
|
|
progress_event = progress.to_dict()
|
|
progress_event.pop("type", None)
|
|
emit_event(
|
|
{
|
|
"type": "weather_decode_progress",
|
|
"decode_job_id": decode_job_id,
|
|
"norad_id": norad_id,
|
|
"satellite": satellite_name,
|
|
"backend": "meteor_lrpt",
|
|
**progress_event,
|
|
}
|
|
)
|
|
|
|
decoder.set_callback(_progress_cb)
|
|
_update_decode_job(
|
|
decode_job_id,
|
|
status="decoding",
|
|
output_dir=output_dir,
|
|
details={
|
|
"sample_rate": sample_rate,
|
|
"input_path": str(data_path),
|
|
"satellite": satellite_name,
|
|
},
|
|
started=True,
|
|
)
|
|
emit_event(
|
|
{
|
|
"type": "weather_decode_started",
|
|
"decode_job_id": decode_job_id,
|
|
"norad_id": norad_id,
|
|
"satellite": satellite_name,
|
|
"backend": "meteor_lrpt",
|
|
"input_path": str(data_path),
|
|
}
|
|
)
|
|
|
|
ok, error = decoder.start_from_file(
|
|
satellite=sat_key,
|
|
input_file=data_path,
|
|
sample_rate=sample_rate,
|
|
)
|
|
if not ok:
|
|
details = _build_failure_details(
|
|
data_path=data_path,
|
|
sample_rate=sample_rate,
|
|
decoder=decoder,
|
|
latest_status=latest_status,
|
|
)
|
|
emit_event(
|
|
{
|
|
"type": "weather_decode_failed",
|
|
"decode_job_id": decode_job_id,
|
|
"norad_id": norad_id,
|
|
"satellite": satellite_name,
|
|
"backend": "meteor_lrpt",
|
|
"message": error or details["message"],
|
|
"failure_reason": details["reason"],
|
|
"details": details,
|
|
}
|
|
)
|
|
_update_decode_job(
|
|
decode_job_id,
|
|
status="failed",
|
|
error_message=error or details["message"],
|
|
details=details,
|
|
completed=True,
|
|
)
|
|
return
|
|
|
|
started = time.time()
|
|
while decoder.is_running and (time.time() - started) < DECODE_TIMEOUT_SECONDS:
|
|
time.sleep(1.0)
|
|
|
|
if decoder.is_running:
|
|
decoder.stop()
|
|
details = _build_failure_details(
|
|
data_path=data_path,
|
|
sample_rate=sample_rate,
|
|
decoder=decoder,
|
|
latest_status=latest_status,
|
|
override_reason="timeout",
|
|
override_message="Meteor decode timed out.",
|
|
)
|
|
emit_event(
|
|
{
|
|
"type": "weather_decode_failed",
|
|
"decode_job_id": decode_job_id,
|
|
"norad_id": norad_id,
|
|
"satellite": satellite_name,
|
|
"backend": "meteor_lrpt",
|
|
"message": details["message"],
|
|
"failure_reason": details["reason"],
|
|
"details": details,
|
|
}
|
|
)
|
|
_update_decode_job(
|
|
decode_job_id,
|
|
status="failed",
|
|
error_message=details["message"],
|
|
details=details,
|
|
completed=True,
|
|
)
|
|
return
|
|
|
|
images = decoder.get_images()
|
|
if not images:
|
|
details = _build_failure_details(
|
|
data_path=data_path,
|
|
sample_rate=sample_rate,
|
|
decoder=decoder,
|
|
latest_status=latest_status,
|
|
)
|
|
emit_event(
|
|
{
|
|
"type": "weather_decode_failed",
|
|
"decode_job_id": decode_job_id,
|
|
"norad_id": norad_id,
|
|
"satellite": satellite_name,
|
|
"backend": "meteor_lrpt",
|
|
"message": details["message"],
|
|
"failure_reason": details["reason"],
|
|
"details": details,
|
|
}
|
|
)
|
|
_update_decode_job(
|
|
decode_job_id,
|
|
status="failed",
|
|
error_message=details["message"],
|
|
details=details,
|
|
completed=True,
|
|
)
|
|
return
|
|
|
|
outputs = []
|
|
for image in images:
|
|
metadata = {
|
|
"satellite": image.satellite,
|
|
"mode": image.mode,
|
|
"frequency": image.frequency,
|
|
"product": image.product,
|
|
"timestamp": image.timestamp.isoformat(),
|
|
"size_bytes": image.size_bytes,
|
|
}
|
|
output_id = register_output(
|
|
observation_id=obs_db_id,
|
|
norad_id=norad_id,
|
|
output_type="image",
|
|
backend="meteor_lrpt",
|
|
file_path=image.path,
|
|
preview_path=image.path,
|
|
metadata=metadata,
|
|
)
|
|
outputs.append(
|
|
{
|
|
"id": output_id,
|
|
"file_path": str(image.path),
|
|
"filename": image.filename,
|
|
"product": image.product,
|
|
}
|
|
)
|
|
|
|
completion_details = {
|
|
"sample_rate": sample_rate,
|
|
"input_path": str(data_path),
|
|
"output_dir": str(output_dir),
|
|
"output_count": len(outputs),
|
|
}
|
|
_update_decode_job(
|
|
decode_job_id,
|
|
status="complete",
|
|
details=completion_details,
|
|
completed=True,
|
|
)
|
|
|
|
emit_event(
|
|
{
|
|
"type": "weather_decode_complete",
|
|
"decode_job_id": decode_job_id,
|
|
"norad_id": norad_id,
|
|
"satellite": satellite_name,
|
|
"backend": "meteor_lrpt",
|
|
"outputs": outputs,
|
|
}
|
|
)
|
|
|
|
|
|
def _build_failure_details(
|
|
*,
|
|
data_path: Path,
|
|
sample_rate: int,
|
|
decoder: WeatherSatDecoder,
|
|
latest_status: dict[str, str | int | None],
|
|
override_reason: str | None = None,
|
|
override_message: str | None = None,
|
|
) -> dict[str, str | int | None]:
|
|
file_size = data_path.stat().st_size if data_path.exists() else 0
|
|
status = decoder.get_status()
|
|
last_error = str(status.get("last_error") or latest_status.get("message") or "").strip()
|
|
return_code = status.get("last_returncode")
|
|
|
|
if override_reason:
|
|
reason = override_reason
|
|
elif sample_rate < 200_000:
|
|
reason = "sample_rate_too_low"
|
|
elif not data_path.exists():
|
|
reason = "missing_recording"
|
|
elif file_size < 5_000_000:
|
|
reason = "recording_too_small"
|
|
elif return_code not in (None, 0):
|
|
reason = "satdump_failed"
|
|
elif "samplerate" in last_error.lower() or "sample rate" in last_error.lower():
|
|
reason = "invalid_sample_rate"
|
|
elif "not found" in last_error.lower():
|
|
reason = "input_missing"
|
|
elif "permission" in last_error.lower():
|
|
reason = "permission_error"
|
|
else:
|
|
reason = "no_imagery_produced"
|
|
|
|
if override_message:
|
|
message = override_message
|
|
elif reason == "sample_rate_too_low":
|
|
message = f"Sample rate {sample_rate} Hz is too low for Meteor LRPT decoding."
|
|
elif reason == "missing_recording":
|
|
message = "The recording file was not found when decode started."
|
|
elif reason == "recording_too_small":
|
|
message = (
|
|
f"Recording is very small ({_format_bytes(file_size)}); this usually means the pass "
|
|
"ended early or little usable IQ was captured."
|
|
)
|
|
elif reason == "satdump_failed":
|
|
message = last_error or f"SatDump exited with code {return_code}."
|
|
elif reason == "invalid_sample_rate":
|
|
message = last_error or "SatDump rejected the recording sample rate."
|
|
elif reason == "input_missing":
|
|
message = last_error or "Input recording was not accessible to the decoder."
|
|
elif reason == "permission_error":
|
|
message = last_error or "Decoder could not access the recording or output path."
|
|
else:
|
|
message = (
|
|
last_error
|
|
or "Decode completed without any image outputs. This usually indicates weak signal, "
|
|
"incorrect sample rate, or a SatDump pipeline mismatch."
|
|
)
|
|
|
|
return {
|
|
"reason": reason,
|
|
"message": message,
|
|
"sample_rate": sample_rate,
|
|
"file_size_bytes": file_size,
|
|
"file_size_human": _format_bytes(file_size),
|
|
"last_error": last_error or None,
|
|
"last_returncode": return_code,
|
|
"capture_phase": status.get("capture_phase") or latest_status.get("phase"),
|
|
"input_path": str(data_path),
|
|
}
|
|
|
|
|
|
def _format_bytes(size_bytes: int) -> str:
|
|
if size_bytes < 1024:
|
|
return f"{size_bytes} B"
|
|
if size_bytes < 1024 * 1024:
|
|
return f"{size_bytes / 1024:.1f} KB"
|
|
if size_bytes < 1024 * 1024 * 1024:
|
|
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
|
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
|
|
|
|
|
|
def _create_decode_job(
|
|
*,
|
|
observation_id: int | None,
|
|
norad_id: int,
|
|
backend: str,
|
|
input_path: Path,
|
|
) -> int | None:
|
|
try:
|
|
from utils.database import get_db
|
|
|
|
with get_db() as conn:
|
|
cur = conn.execute(
|
|
"""
|
|
INSERT INTO ground_station_decode_jobs
|
|
(observation_id, norad_id, backend, status, input_path, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
observation_id,
|
|
norad_id,
|
|
backend,
|
|
"queued",
|
|
str(input_path),
|
|
_utcnow_iso(),
|
|
_utcnow_iso(),
|
|
),
|
|
)
|
|
return cur.lastrowid
|
|
except Exception as e:
|
|
logger.warning("Failed to create decode job: %s", e)
|
|
return None
|
|
|
|
|
|
def _update_decode_job(
|
|
decode_job_id: int | None,
|
|
*,
|
|
status: str,
|
|
output_dir: Path | None = None,
|
|
error_message: str | None = None,
|
|
details: dict | None = None,
|
|
started: bool = False,
|
|
completed: bool = False,
|
|
) -> None:
|
|
if decode_job_id is None:
|
|
return
|
|
try:
|
|
from utils.database import get_db
|
|
|
|
fields = ["status = ?", "updated_at = ?"]
|
|
values: list[object] = [status, _utcnow_iso()]
|
|
|
|
if output_dir is not None:
|
|
fields.append("output_dir = ?")
|
|
values.append(str(output_dir))
|
|
if error_message is not None:
|
|
fields.append("error_message = ?")
|
|
values.append(error_message)
|
|
if details is not None:
|
|
fields.append("details_json = ?")
|
|
values.append(json.dumps(details))
|
|
if started:
|
|
fields.append("started_at = ?")
|
|
values.append(_utcnow_iso())
|
|
if completed:
|
|
fields.append("completed_at = ?")
|
|
values.append(_utcnow_iso())
|
|
|
|
values.append(decode_job_id)
|
|
with get_db() as conn:
|
|
conn.execute(
|
|
f"""
|
|
UPDATE ground_station_decode_jobs
|
|
SET {", ".join(fields)}
|
|
WHERE id = ?
|
|
""",
|
|
values,
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Failed to update decode job %s: %s", decode_job_id, e)
|
|
|
|
|
|
def _utcnow_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|