"""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()