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 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 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) @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", mode="before") @classmethod def _normalize_optional_text(cls, value): if value is None: return None text = str(value).strip() return text or None 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_low_sigma_high_rate", 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) @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, ) 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, ) 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, 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}/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.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, )