324 lines
12 KiB
Python
324 lines
12 KiB
Python
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,
|
|
)
|