from __future__ import annotations import asyncio import mimetypes import subprocess from fastapi import APIRouter, HTTPException from fastapi.responses import FileResponse from pydantic import BaseModel, Field, field_validator, model_validator from ..services.job_queue_service import job_queue_service from ..services.sbas_insar_production_service import sbas_insar_production_service from ..services.task_service import task_service router = APIRouter(prefix="/sbas-insar-production", tags=["sbas-insar-production"]) class SbasAoiBbox(BaseModel): min_lon: float = Field(ge=-180, le=180) min_lat: float = Field(ge=-90, le=90) max_lon: float = Field(ge=-180, le=180) max_lat: float = Field(ge=-90, le=90) @model_validator(mode="after") def _validate_order(self): if self.min_lon >= self.max_lon or self.min_lat >= self.max_lat: raise ValueError("aoi_bbox min values must be smaller than max values") return self class SbasStackDiscoverRequest(BaseModel): source_roots: list[str] | None = None orbit_roots: list[str] | None = None min_scenes: int = Field(default=3, ge=2, le=100) require_orbits: bool = True include_scenes: bool = False limit: int = Field(default=30, ge=0, le=500) platform: str | None = Field(default=None, max_length=16) relative_orbit: str | None = Field(default=None, max_length=32) orbit_direction: str | None = Field(default=None, max_length=32) admin_region: str | None = Field(default=None, max_length=120) discovery_mode: str = Field(default="strict", pattern="^(strict|aoi)$") aoi_bbox: SbasAoiBbox | None = None min_aoi_coverage_ratio: float = Field(default=0.01, ge=0, le=1) min_common_overlap_ratio: float = Field(default=0.0, ge=0, le=1) @field_validator("source_roots", "orbit_roots", mode="before") @classmethod def _normalize_roots(cls, value): if value is None: return None if isinstance(value, str): items = [value] else: items = list(value) cleaned = [str(item or "").strip() for item in items if str(item or "").strip()] return cleaned or None @field_validator("platform", "relative_orbit", "orbit_direction", "admin_region", mode="before") @classmethod def _normalize_optional_text(cls, value): if value is None: return None text = str(value).strip() return text or None @field_validator("discovery_mode", mode="before") @classmethod def _normalize_discovery_mode(cls, value): text = str(value or "strict").strip().lower() return text or "strict" class SbasMonitorPoint(BaseModel): point_id: str | None = Field(default=None, max_length=64) label: str | None = Field(default=None, max_length=120) lon: float = Field(ge=-180, le=180) lat: float = Field(ge=-90, le=90) class SbasRunSubmitRequest(SbasStackDiscoverRequest): run_label: str | None = Field(default=None, max_length=120) dry_run: bool = True monitor_point_strategy: str = Field(default="auto_representative_points", max_length=64) monitor_points: list[SbasMonitorPoint] | None = None class SbasBaselineAuditRequest(BaseModel): execute: bool = True rlks: int = Field(default=8, ge=1, le=64) azlks: int = Field(default=8, ge=1, le=64) max_delta_n: int = Field(default=1, ge=1, le=100) timeout_seconds: int = Field(default=21600, ge=60, le=86400) class SbasItabDecisionRequest(BaseModel): decision: str = Field(pattern="^(approve|reject)$") reviewer: str | None = Field(default=None, max_length=120) note: str | None = Field(default=None, max_length=1000) class SbasCoregistrationRequest(BaseModel): execute: bool = False rlks: int = Field(default=8, ge=1, le=64) azlks: int = Field(default=8, ge=1, le=64) class SbasCoregistrationJobRequest(BaseModel): rlks: int = Field(default=8, ge=1, le=64) azlks: int = Field(default=8, ge=1, le=64) timeout_seconds: int = Field(default=43200, ge=60, le=172800) class SbasRdcDemRequest(BaseModel): execute: bool = False rlks: int = Field(default=8, ge=1, le=64) class SbasRdcDemJobRequest(BaseModel): rlks: int = Field(default=8, ge=1, le=64) timeout_seconds: int = Field(default=43200, ge=60, le=172800) class SbasInterferogramsRequest(BaseModel): execute: bool = False rlks: int = Field(default=8, ge=1, le=64) azlks: int = Field(default=8, ge=1, le=64) unwrap_threshold: float = Field(default=0.20, ge=0.01, le=0.95) class SbasInterferogramsJobRequest(BaseModel): rlks: int = Field(default=8, ge=1, le=64) azlks: int = Field(default=8, ge=1, le=64) unwrap_threshold: float = Field(default=0.20, ge=0.01, le=0.95) timeout_seconds: int = Field(default=43200, ge=60, le=172800) class SbasIptaTimeseriesRequest(BaseModel): execute: bool = False rlks: int = Field(default=8, ge=1, le=64) reference_window: int = Field(default=16, ge=1, le=256) mb_mode: int = Field(default=0, ge=0, le=2) class SbasIptaTimeseriesJobRequest(BaseModel): rlks: int = Field(default=8, ge=1, le=64) reference_window: int = Field(default=16, ge=1, le=256) mb_mode: int = Field(default=0, ge=0, le=2) timeout_seconds: int = Field(default=43200, ge=60, le=172800) class SbasWorkflowPrepareRequest(BaseModel): force: bool = False rlks: int = Field(default=8, ge=1, le=64) azlks: int = Field(default=8, ge=1, le=64) reference_window: int = Field(default=16, ge=1, le=256) mb_mode: int = Field(default=0, ge=0, le=2) class SbasWorkflowJobRequest(SbasWorkflowPrepareRequest): from_step: str | None = Field(default=None, max_length=64) to_step: str | None = Field(default=None, max_length=64) only_steps: list[str] | None = None timeout_seconds: int = Field(default=172800, ge=60, le=604800) @router.get("/capabilities") async def get_sbas_insar_capabilities(): return sbas_insar_production_service.get_capabilities() @router.post("/stacks/discover") async def discover_sbas_insar_stacks(request: SbasStackDiscoverRequest): try: return await asyncio.to_thread( sbas_insar_production_service.discover_stacks, source_roots=request.source_roots, orbit_roots=request.orbit_roots, min_scenes=request.min_scenes, require_orbits=request.require_orbits, include_scenes=request.include_scenes, limit=request.limit, platform=request.platform, relative_orbit=request.relative_orbit, orbit_direction=request.orbit_direction, admin_region=request.admin_region, discovery_mode=request.discovery_mode, aoi_bbox=request.aoi_bbox.model_dump() if request.aoi_bbox else None, min_aoi_coverage_ratio=request.min_aoi_coverage_ratio, min_common_overlap_ratio=request.min_common_overlap_ratio, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @router.post("/stacks/{stack_id}/audit") async def audit_sbas_insar_stack(stack_id: str, request: SbasStackDiscoverRequest): try: return await asyncio.to_thread( sbas_insar_production_service.audit_stack, stack_id, source_roots=request.source_roots, orbit_roots=request.orbit_roots, min_scenes=request.min_scenes, require_orbits=request.require_orbits, discovery_mode=request.discovery_mode, admin_region=request.admin_region, aoi_bbox=request.aoi_bbox.model_dump() if request.aoi_bbox else None, min_aoi_coverage_ratio=request.min_aoi_coverage_ratio, min_common_overlap_ratio=request.min_common_overlap_ratio, ) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @router.post("/stacks/{stack_id}/runs", status_code=202) async def submit_sbas_insar_run(stack_id: str, request: SbasRunSubmitRequest): try: return await asyncio.to_thread( sbas_insar_production_service.create_run, stack_id, run_label=request.run_label, source_roots=request.source_roots, orbit_roots=request.orbit_roots, min_scenes=request.min_scenes, require_orbits=request.require_orbits, discovery_mode=request.discovery_mode, admin_region=request.admin_region, aoi_bbox=request.aoi_bbox.model_dump() if request.aoi_bbox else None, min_aoi_coverage_ratio=request.min_aoi_coverage_ratio, min_common_overlap_ratio=request.min_common_overlap_ratio, monitor_points=[ point.model_dump(exclude_none=True) for point in (request.monitor_points or []) ], monitor_point_strategy=request.monitor_point_strategy, dry_run=request.dry_run, ) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @router.get("/runs") async def list_sbas_insar_runs(): return await asyncio.to_thread(sbas_insar_production_service.list_runs) @router.get("/runs/{run_id}") async def get_sbas_insar_run(run_id: str): try: return await asyncio.to_thread(sbas_insar_production_service.get_run_detail, run_id) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @router.post("/runs/{run_id}/workflow", status_code=202) async def prepare_sbas_insar_workflow(run_id: str, request: SbasWorkflowPrepareRequest): try: return await asyncio.to_thread( sbas_insar_production_service.prepare_workflow, run_id, force=request.force, rlks=request.rlks, azlks=request.azlks, reference_window=request.reference_window, mb_mode=request.mb_mode, ) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @router.post("/runs/{run_id}/workflow/jobs", status_code=202) async def submit_sbas_insar_workflow_job(run_id: str, request: SbasWorkflowJobRequest): try: await asyncio.to_thread( sbas_insar_production_service.prepare_workflow, run_id, force=request.force, rlks=request.rlks, azlks=request.azlks, reference_window=request.reference_window, mb_mode=request.mb_mode, ) from ..services.job_handlers import JOB_TYPE_SBAS_GAMMA_WORKFLOW payload = { "run_id": run_id, "force": request.force, "rlks": request.rlks, "azlks": request.azlks, "reference_window": request.reference_window, "mb_mode": request.mb_mode, "from_step": request.from_step, "to_step": request.to_step, "only_steps": request.only_steps or [], "timeout_seconds": request.timeout_seconds, } task_id = await task_service.create_task( task_type=JOB_TYPE_SBAS_GAMMA_WORKFLOW, task_name=f"Gamma SBAS Workflow {run_id}", params=payload, ) job_id = await job_queue_service.create_job( job_type=JOB_TYPE_SBAS_GAMMA_WORKFLOW, payload=payload, task_id=task_id, max_attempts=1, ) return { "message": "Gamma SBAS workflow job queued.", "run_id": run_id, "task_id": task_id, "job_id": job_id, "job_type": JOB_TYPE_SBAS_GAMMA_WORKFLOW, } except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: message = str(exc) status_code = 409 if "already running" in message.lower() or "conflict" in message.lower() else 400 raise HTTPException(status_code=status_code, detail=message) from exc @router.post("/runs/{run_id}/baseline-audit", status_code=202) async def run_sbas_insar_baseline_audit(run_id: str, request: SbasBaselineAuditRequest): try: return await asyncio.to_thread( sbas_insar_production_service.run_baseline_audit, run_id, execute=request.execute, rlks=request.rlks, azlks=request.azlks, max_delta_n=request.max_delta_n, timeout_seconds=request.timeout_seconds, ) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except subprocess.TimeoutExpired as exc: raise HTTPException(status_code=504, detail=f"baseline audit timed out after {exc.timeout}s") from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @router.post("/runs/{run_id}/itab-decision") async def decide_sbas_insar_itab(run_id: str, request: SbasItabDecisionRequest): try: return await asyncio.to_thread( sbas_insar_production_service.decide_itab, run_id, decision=request.decision, reviewer=request.reviewer, note=request.note, ) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @router.post("/runs/{run_id}/coregistration", status_code=202) async def prepare_sbas_insar_coregistration(run_id: str, request: SbasCoregistrationRequest): try: return await asyncio.to_thread( sbas_insar_production_service.prepare_coregistration, run_id, execute=request.execute, rlks=request.rlks, azlks=request.azlks, ) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @router.post("/runs/{run_id}/coregistration/jobs", status_code=202) async def submit_sbas_insar_coregistration_job(run_id: str, request: SbasCoregistrationJobRequest): try: run_detail = await asyncio.to_thread(sbas_insar_production_service.get_run_detail, run_id) status = str((run_detail.get("run") or {}).get("status") or "").strip() if status in {"ITAB_APPROVED", "COREGISTRATION_FAILED"}: await asyncio.to_thread( sbas_insar_production_service.prepare_coregistration, run_id, execute=False, rlks=request.rlks, azlks=request.azlks, ) run_detail = await asyncio.to_thread(sbas_insar_production_service.get_run_detail, run_id) status = str((run_detail.get("run") or {}).get("status") or "").strip() if status not in {"COREGISTRATION_SCRIPT_READY", "COREGISTRATION_RUNNING"}: raise ValueError(f"run status does not allow coregistration job submission: {status}") if status == "COREGISTRATION_RUNNING": raise ValueError("coregistration is already running for this run") from ..services.job_handlers import JOB_TYPE_SBAS_COREGISTRATION payload = { "run_id": run_id, "rlks": request.rlks, "azlks": request.azlks, "timeout_seconds": request.timeout_seconds, } task_id = await task_service.create_task( task_type=JOB_TYPE_SBAS_COREGISTRATION, task_name=f"SBAS-InSAR 共参考配准: {run_id}", params=payload, ) job_id = await job_queue_service.create_job( job_type=JOB_TYPE_SBAS_COREGISTRATION, payload=payload, task_id=task_id, max_attempts=1, ) return { "message": "SBAS-InSAR coregistration job queued.", "run_id": run_id, "task_id": task_id, "job_id": job_id, "job_type": JOB_TYPE_SBAS_COREGISTRATION, } except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: message = str(exc) status_code = 409 if "冲突" in message or "conflict" in message.lower() else 400 raise HTTPException(status_code=status_code, detail=message) from exc @router.post("/runs/{run_id}/rdc-dem", status_code=202) async def prepare_sbas_insar_rdc_dem(run_id: str, request: SbasRdcDemRequest): try: return await asyncio.to_thread( sbas_insar_production_service.prepare_rdc_dem, run_id, execute=request.execute, rlks=request.rlks, ) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @router.post("/runs/{run_id}/rdc-dem/jobs", status_code=202) async def submit_sbas_insar_rdc_dem_job(run_id: str, request: SbasRdcDemJobRequest): try: run_detail = await asyncio.to_thread(sbas_insar_production_service.get_run_detail, run_id) status = str((run_detail.get("run") or {}).get("status") or "").strip() if status in {"COREGISTRATION_READY", "RDC_DEM_FAILED"}: await asyncio.to_thread( sbas_insar_production_service.prepare_rdc_dem, run_id, execute=False, rlks=request.rlks, ) run_detail = await asyncio.to_thread(sbas_insar_production_service.get_run_detail, run_id) status = str((run_detail.get("run") or {}).get("status") or "").strip() if status not in {"RDC_DEM_SCRIPT_READY", "RDC_DEM_RUNNING"}: raise ValueError(f"run status does not allow RDC DEM job submission: {status}") if status == "RDC_DEM_RUNNING": raise ValueError("RDC DEM is already running for this run") from ..services.job_handlers import JOB_TYPE_SBAS_RDC_DEM payload = { "run_id": run_id, "rlks": request.rlks, "timeout_seconds": request.timeout_seconds, } task_id = await task_service.create_task( task_type=JOB_TYPE_SBAS_RDC_DEM, task_name=f"SBAS-InSAR RDC DEM {run_id}", params=payload, ) job_id = await job_queue_service.create_job( job_type=JOB_TYPE_SBAS_RDC_DEM, payload=payload, task_id=task_id, max_attempts=1, ) return { "message": "SBAS-InSAR RDC DEM job queued.", "run_id": run_id, "task_id": task_id, "job_id": job_id, "job_type": JOB_TYPE_SBAS_RDC_DEM, } except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: message = str(exc) status_code = 409 if "already running" in message.lower() or "conflict" in message.lower() else 400 raise HTTPException(status_code=status_code, detail=message) from exc @router.post("/runs/{run_id}/interferograms", status_code=202) async def prepare_sbas_insar_interferograms(run_id: str, request: SbasInterferogramsRequest): try: return await asyncio.to_thread( sbas_insar_production_service.prepare_interferograms, run_id, execute=request.execute, rlks=request.rlks, azlks=request.azlks, unwrap_threshold=request.unwrap_threshold, ) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @router.post("/runs/{run_id}/interferograms/jobs", status_code=202) async def submit_sbas_insar_interferograms_job(run_id: str, request: SbasInterferogramsJobRequest): try: run_detail = await asyncio.to_thread(sbas_insar_production_service.get_run_detail, run_id) status = str((run_detail.get("run") or {}).get("status") or "").strip() if status in {"RDC_DEM_READY", "INTERFEROGRAMS_FAILED"}: await asyncio.to_thread( sbas_insar_production_service.prepare_interferograms, run_id, execute=False, rlks=request.rlks, azlks=request.azlks, unwrap_threshold=request.unwrap_threshold, ) run_detail = await asyncio.to_thread(sbas_insar_production_service.get_run_detail, run_id) status = str((run_detail.get("run") or {}).get("status") or "").strip() if status not in {"INTERFEROGRAMS_SCRIPT_READY", "INTERFEROGRAMS_RUNNING"}: raise ValueError(f"run status does not allow interferogram job submission: {status}") if status == "INTERFEROGRAMS_RUNNING": raise ValueError("interferograms are already running for this run") from ..services.job_handlers import JOB_TYPE_SBAS_INTERFEROGRAMS payload = { "run_id": run_id, "rlks": request.rlks, "azlks": request.azlks, "unwrap_threshold": request.unwrap_threshold, "timeout_seconds": request.timeout_seconds, } task_id = await task_service.create_task( task_type=JOB_TYPE_SBAS_INTERFEROGRAMS, task_name=f"SBAS-InSAR Interferograms {run_id}", params=payload, ) job_id = await job_queue_service.create_job( job_type=JOB_TYPE_SBAS_INTERFEROGRAMS, payload=payload, task_id=task_id, max_attempts=1, ) return { "message": "SBAS-InSAR interferogram job queued.", "run_id": run_id, "task_id": task_id, "job_id": job_id, "job_type": JOB_TYPE_SBAS_INTERFEROGRAMS, } except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: message = str(exc) status_code = 409 if "already running" in message.lower() or "conflict" in message.lower() else 400 raise HTTPException(status_code=status_code, detail=message) from exc @router.post("/runs/{run_id}/ipta-timeseries", status_code=202) async def prepare_sbas_insar_ipta_timeseries(run_id: str, request: SbasIptaTimeseriesRequest): try: return await asyncio.to_thread( sbas_insar_production_service.prepare_ipta_timeseries, run_id, execute=request.execute, rlks=request.rlks, reference_window=request.reference_window, mb_mode=request.mb_mode, ) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @router.post("/runs/{run_id}/ipta-timeseries/jobs", status_code=202) async def submit_sbas_insar_ipta_timeseries_job(run_id: str, request: SbasIptaTimeseriesJobRequest): try: run_detail = await asyncio.to_thread(sbas_insar_production_service.get_run_detail, run_id) status = str((run_detail.get("run") or {}).get("status") or "").strip() if status in {"INTERFEROGRAMS_READY", "IPTA_TIMESERIES_FAILED"}: await asyncio.to_thread( sbas_insar_production_service.prepare_ipta_timeseries, run_id, execute=False, rlks=request.rlks, reference_window=request.reference_window, mb_mode=request.mb_mode, ) run_detail = await asyncio.to_thread(sbas_insar_production_service.get_run_detail, run_id) status = str((run_detail.get("run") or {}).get("status") or "").strip() if status not in {"IPTA_TIMESERIES_SCRIPT_READY", "IPTA_TIMESERIES_RUNNING"}: raise ValueError(f"run status does not allow IPTA time-series job submission: {status}") if status == "IPTA_TIMESERIES_RUNNING": raise ValueError("IPTA time-series is already running for this run") from ..services.job_handlers import JOB_TYPE_SBAS_IPTA_TIMESERIES payload = { "run_id": run_id, "rlks": request.rlks, "reference_window": request.reference_window, "mb_mode": request.mb_mode, "timeout_seconds": request.timeout_seconds, } task_id = await task_service.create_task( task_type=JOB_TYPE_SBAS_IPTA_TIMESERIES, task_name=f"SBAS-InSAR IPTA Timeseries {run_id}", params=payload, ) job_id = await job_queue_service.create_job( job_type=JOB_TYPE_SBAS_IPTA_TIMESERIES, payload=payload, task_id=task_id, max_attempts=1, ) return { "message": "SBAS-InSAR IPTA time-series job queued.", "run_id": run_id, "task_id": task_id, "job_id": job_id, "job_type": JOB_TYPE_SBAS_IPTA_TIMESERIES, } except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: message = str(exc) status_code = 409 if "already running" in message.lower() or "conflict" in message.lower() else 400 raise HTTPException(status_code=status_code, detail=message) from exc @router.get("/runs/{run_id}/artifacts/{relative_path:path}") async def get_sbas_insar_run_artifact(run_id: str, relative_path: str): try: artifact_path = sbas_insar_production_service.resolve_run_artifact_path(run_id, relative_path) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc media_type = mimetypes.guess_type(str(artifact_path))[0] or "application/octet-stream" return FileResponse( artifact_path, media_type=media_type, filename=artifact_path.name, ) @router.get("/trial-runs") async def list_sbas_insar_trial_runs(): return await asyncio.to_thread(sbas_insar_production_service.list_trial_runs) @router.get("/trial-runs/{trial_id}") async def get_sbas_insar_trial_run(trial_id: str): try: return await asyncio.to_thread(sbas_insar_production_service.get_trial_detail, trial_id) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @router.get("/trial-runs/{trial_id}/artifacts/{relative_path:path}") async def get_sbas_insar_artifact(trial_id: str, relative_path: str): try: artifact_path = sbas_insar_production_service.resolve_artifact_path(trial_id, relative_path) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc media_type = mimetypes.guess_type(str(artifact_path))[0] or "application/octet-stream" return FileResponse( artifact_path, media_type=media_type, filename=artifact_path.name, )