1061 lines
41 KiB
Python
1061 lines
41 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import mimetypes
|
|
import subprocess
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from fastapi.responses import FileResponse
|
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
from sqlalchemy import String, cast, or_, select
|
|
|
|
from .. import database
|
|
from ..config import settings
|
|
from ..models import AuthUserORM, SystemJobORM, SystemTaskORM, TaskLogORM
|
|
from ..services.job_queue_service import job_queue_service
|
|
from ..services.landsar_sbas_service import landsar_sbas_service
|
|
from ..services.sbas_insar_production_service import sbas_insar_production_service
|
|
from ..services.task_service import task_service
|
|
from .dependencies import _require_admin
|
|
|
|
|
|
router = APIRouter(prefix="/sbas-insar-production", tags=["sbas-insar-production"])
|
|
|
|
|
|
def _new_session():
|
|
if database.AsyncSessionLocal is None:
|
|
database.init_db()
|
|
if database.AsyncSessionLocal is None:
|
|
raise RuntimeError("Database session factory is not initialized.")
|
|
return database.AsyncSessionLocal()
|
|
|
|
|
|
def _dt(value):
|
|
return value.isoformat() if value else None
|
|
|
|
|
|
def _task_payload_matches(run_id: str):
|
|
return or_(
|
|
cast(SystemTaskORM.params, String).ilike(f"%{run_id}%"),
|
|
SystemTaskORM.task_name.ilike(f"%{run_id}%"),
|
|
)
|
|
|
|
|
|
def _job_payload_matches(run_id: str, task_ids: list[str]):
|
|
conditions = [
|
|
cast(SystemJobORM.payload, String).ilike(f"%{run_id}%"),
|
|
SystemJobORM.workflow_run_id == run_id,
|
|
]
|
|
if task_ids:
|
|
conditions.append(SystemJobORM.task_id.in_(task_ids))
|
|
return or_(*conditions)
|
|
|
|
|
|
async def _load_run_background_activity(run_id: str) -> dict:
|
|
try:
|
|
async with _new_session() as db:
|
|
task_result = await db.execute(
|
|
select(SystemTaskORM)
|
|
.where(_task_payload_matches(run_id))
|
|
.order_by(SystemTaskORM.created_at.desc(), SystemTaskORM.id.desc())
|
|
.limit(10)
|
|
)
|
|
tasks = list(task_result.scalars().all())
|
|
task_ids = [
|
|
str(task.task_id or "").strip()
|
|
for task in tasks
|
|
if str(task.task_id or "").strip()
|
|
]
|
|
job_result = await db.execute(
|
|
select(SystemJobORM)
|
|
.where(_job_payload_matches(run_id, task_ids))
|
|
.order_by(SystemJobORM.created_at.desc(), SystemJobORM.id.desc())
|
|
.limit(10)
|
|
)
|
|
jobs = list(job_result.scalars().all())
|
|
if not task_ids:
|
|
task_ids = sorted({
|
|
str(job.task_id or "").strip()
|
|
for job in jobs
|
|
if str(job.task_id or "").strip()
|
|
})
|
|
logs = []
|
|
if task_ids:
|
|
log_result = await db.execute(
|
|
select(TaskLogORM)
|
|
.where(TaskLogORM.task_id.in_(task_ids))
|
|
.order_by(TaskLogORM.timestamp.desc(), TaskLogORM.id.desc())
|
|
.limit(20)
|
|
)
|
|
logs = list(log_result.scalars().all())
|
|
except Exception as exc:
|
|
return {
|
|
"schema": "insar.sbas-background-activity/v1",
|
|
"error": str(exc),
|
|
"tasks": [],
|
|
"jobs": [],
|
|
"task_logs": [],
|
|
"active": False,
|
|
}
|
|
|
|
active_task_statuses = {"PENDING", "RUNNING"}
|
|
active_job_statuses = {"READY", "PENDING", "RUNNING", "RETRY"}
|
|
return {
|
|
"schema": "insar.sbas-background-activity/v1",
|
|
"tasks": [
|
|
{
|
|
"task_id": task.task_id,
|
|
"task_type": task.task_type,
|
|
"task_name": task.task_name,
|
|
"status": task.status,
|
|
"progress": task.progress,
|
|
"message": task.message,
|
|
"created_at": _dt(task.created_at),
|
|
"updated_at": _dt(task.updated_at),
|
|
"started_at": _dt(task.started_at),
|
|
"ended_at": _dt(task.ended_at),
|
|
}
|
|
for task in tasks
|
|
],
|
|
"jobs": [
|
|
{
|
|
"job_id": job.job_id,
|
|
"job_type": job.job_type,
|
|
"status": job.status,
|
|
"attempts": job.attempts,
|
|
"max_attempts": job.max_attempts,
|
|
"locked_by": job.locked_by,
|
|
"locked_at": _dt(job.locked_at),
|
|
"heartbeat_at": _dt(job.heartbeat_at),
|
|
"created_at": _dt(job.created_at),
|
|
"updated_at": _dt(job.updated_at),
|
|
"started_at": _dt(job.started_at),
|
|
"finished_at": _dt(job.finished_at),
|
|
"last_error": job.last_error,
|
|
"task_id": job.task_id,
|
|
}
|
|
for job in jobs
|
|
],
|
|
"task_logs": [
|
|
{
|
|
"task_id": log.task_id,
|
|
"level": log.log_level,
|
|
"message": log.message,
|
|
"timestamp": _dt(log.timestamp),
|
|
}
|
|
for log in logs
|
|
],
|
|
"active": any(str(task.status or "").upper() in active_task_statuses for task in tasks)
|
|
or any(str(job.status or "").upper() in active_job_statuses for job in jobs),
|
|
}
|
|
|
|
|
|
def _load_wsl_process_summary(run_id: str) -> dict:
|
|
distro = str(settings.GAMMA_SBAS_WSL_DISTRO or settings.WSL_DISTRO or "").strip()
|
|
command = [
|
|
"wsl.exe",
|
|
*([] if not distro else ["-d", distro]),
|
|
"--",
|
|
"bash",
|
|
"-lc",
|
|
(
|
|
"ps -eo pid,etime,stat,args --no-headers | "
|
|
"grep -E 'gamma|SLC_interp|base_calc|mk_diff|mk_unw|ts_rate| mb |python|bash' | "
|
|
"grep -v grep | head -20"
|
|
),
|
|
]
|
|
try:
|
|
completed = subprocess.run(
|
|
command,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
check=False,
|
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
)
|
|
except FileNotFoundError:
|
|
return {"available": False, "error": "wsl.exe not found", "processes": []}
|
|
except Exception as exc:
|
|
return {"available": False, "error": str(exc), "processes": []}
|
|
|
|
processes = []
|
|
for line in (completed.stdout or "").splitlines():
|
|
text = line.strip()
|
|
if not text:
|
|
continue
|
|
parts = text.split(None, 3)
|
|
processes.append(
|
|
{
|
|
"pid": parts[0] if len(parts) > 0 else "",
|
|
"etime": parts[1] if len(parts) > 1 else "",
|
|
"stat": parts[2] if len(parts) > 2 else "",
|
|
"command": parts[3] if len(parts) > 3 else text,
|
|
"matches_run": run_id in text,
|
|
}
|
|
)
|
|
return {
|
|
"available": completed.returncode in {0, 1},
|
|
"returncode": completed.returncode,
|
|
"distro": distro or None,
|
|
"processes": processes,
|
|
"stderr_tail": (completed.stderr or "")[-1200:],
|
|
}
|
|
|
|
|
|
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):
|
|
sensor_family: str = Field(default="LT1", pattern="^(LT1|S1)$")
|
|
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_factory=lambda: float(settings.GAMMA_SBAS_MIN_COMMON_OVERLAP_RATIO or 0.30),
|
|
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("sensor_family", mode="before")
|
|
@classmethod
|
|
def _normalize_sensor_family(cls, value):
|
|
text = str(value or "LT1").strip().upper()
|
|
if text in {"SENTINEL1", "SENTINEL-1"}:
|
|
return "S1"
|
|
return text or "LT1"
|
|
|
|
@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)
|
|
|
|
|
|
class LandsarSbasAutoWorkflowRequest(SbasStackDiscoverRequest):
|
|
sensor_family: str = Field(default="LT1", pattern="^LT1$")
|
|
require_orbits: bool = False
|
|
run_label: str | None = Field(default=None, max_length=160)
|
|
dem_path: str | None = Field(default=None, max_length=1024)
|
|
timeout_seconds: int | None = Field(default=None, ge=60, le=604800)
|
|
import_timeout_seconds: int | None = Field(default=None, ge=60, le=604800)
|
|
workflow_timeout_seconds: int | None = Field(default=None, ge=60, le=604800)
|
|
params: dict[str, object] = Field(default_factory=dict)
|
|
|
|
|
|
@router.get("/capabilities")
|
|
async def get_sbas_insar_capabilities():
|
|
capabilities = sbas_insar_production_service.get_capabilities()
|
|
capabilities["processors"] = [
|
|
{
|
|
"processor_code": capabilities.get("processor_code"),
|
|
"profile_code": "lt1_gamma_sbas",
|
|
"engine_code": capabilities.get("engine_code"),
|
|
"label": "Gamma / IPTA SBAS",
|
|
"enabled": bool((capabilities.get("runtime") or {}).get("enabled", True)),
|
|
},
|
|
{
|
|
**landsar_sbas_service.get_capabilities(),
|
|
"label": "LandSAR SBAS",
|
|
},
|
|
]
|
|
return capabilities
|
|
|
|
|
|
@router.get("/landsar/capabilities")
|
|
async def get_landsar_sbas_capabilities():
|
|
return landsar_sbas_service.get_capabilities()
|
|
|
|
|
|
@router.post("/landsar/workflows/auto", status_code=202)
|
|
async def submit_landsar_sbas_auto_workflow(request: LandsarSbasAutoWorkflowRequest):
|
|
try:
|
|
from ..services.job_handlers import JOB_TYPE_SBAS_LANDSAR_WORKFLOW
|
|
|
|
selection_request = {
|
|
"run_label": request.run_label,
|
|
"source_roots": request.source_roots,
|
|
"orbit_roots": request.orbit_roots,
|
|
"min_scenes": request.min_scenes,
|
|
"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,
|
|
"limit": request.limit,
|
|
"dem_path": request.dem_path,
|
|
"timeout_seconds": request.timeout_seconds,
|
|
"import_timeout_seconds": request.import_timeout_seconds,
|
|
"params": dict(request.params or {}),
|
|
}
|
|
payload = {
|
|
"auto_select": True,
|
|
"selection_request": selection_request,
|
|
"timeout_seconds": request.workflow_timeout_seconds or request.timeout_seconds,
|
|
}
|
|
task_id = await task_service.create_task(
|
|
task_type=JOB_TYPE_SBAS_LANDSAR_WORKFLOW,
|
|
task_name="LandSAR SBAS Auto Workflow",
|
|
params=payload,
|
|
)
|
|
job_id = await job_queue_service.create_job(
|
|
job_type=JOB_TYPE_SBAS_LANDSAR_WORKFLOW,
|
|
payload=payload,
|
|
task_id=task_id,
|
|
max_attempts=1,
|
|
)
|
|
return {
|
|
"message": "LandSAR SBAS auto workflow job queued. Stack selection and input import will run in the background.",
|
|
"run_id": None,
|
|
"selection_pending": True,
|
|
"task_id": task_id,
|
|
"job_id": job_id,
|
|
"job_type": JOB_TYPE_SBAS_LANDSAR_WORKFLOW,
|
|
"status": "QUEUED",
|
|
}
|
|
except ValueError as exc:
|
|
message = str(exc)
|
|
status_code = 409 if "already running" in message.lower() or "conflict" in message.lower() or "任务冲突" in message else 400
|
|
raise HTTPException(status_code=status_code, detail=message) from exc
|
|
|
|
|
|
@router.get("/landsar/runs")
|
|
async def list_landsar_sbas_runs():
|
|
return await asyncio.to_thread(landsar_sbas_service.list_runs)
|
|
|
|
|
|
@router.get("/landsar/runs/{run_id}")
|
|
async def get_landsar_sbas_run(run_id: str):
|
|
try:
|
|
return await asyncio.to_thread(landsar_sbas_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.get("/landsar/runs/{run_id}/artifacts/{relative_path:path}")
|
|
async def get_landsar_sbas_run_artifact(run_id: str, relative_path: str):
|
|
try:
|
|
artifact_path = await asyncio.to_thread(landsar_sbas_service.resolve_artifact, 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,
|
|
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
|
)
|
|
|
|
|
|
@router.post("/stacks/discover")
|
|
async def discover_sbas_insar_stacks(request: SbasStackDiscoverRequest):
|
|
try:
|
|
return await asyncio.to_thread(
|
|
sbas_insar_production_service.discover_stacks,
|
|
sensor_family=request.sensor_family,
|
|
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,
|
|
sensor_family=request.sensor_family,
|
|
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,
|
|
sensor_family=request.sensor_family,
|
|
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:
|
|
detail = await asyncio.to_thread(sbas_insar_production_service.get_run_detail, run_id)
|
|
background_activity, wsl_processes = await asyncio.gather(
|
|
_load_run_background_activity(run_id),
|
|
asyncio.to_thread(_load_wsl_process_summary, run_id),
|
|
)
|
|
runtime_status = dict(detail.get("runtime_status") or {})
|
|
runtime_status["background_activity"] = background_activity
|
|
runtime_status["wsl_processes"] = wsl_processes
|
|
runtime_status["active"] = bool(
|
|
runtime_status.get("active")
|
|
or background_activity.get("active")
|
|
or any(item.get("matches_run") for item in wsl_processes.get("processes") or [])
|
|
)
|
|
detail["runtime_status"] = runtime_status
|
|
return detail
|
|
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.delete("/runs/{run_id}")
|
|
async def delete_sbas_insar_run(
|
|
run_id: str,
|
|
current_user: AuthUserORM = Depends(_require_admin),
|
|
):
|
|
_ = current_user
|
|
try:
|
|
async with _new_session() as db:
|
|
result = await sbas_insar_production_service.delete_run_record(run_id, db=db)
|
|
try:
|
|
from ..services.sbas_insar_catalog_service import sbas_insar_catalog_service
|
|
|
|
catalog_result = await sbas_insar_catalog_service.rebuild_catalog(db, full_rebuild=True)
|
|
except Exception as catalog_exc:
|
|
catalog_result = {
|
|
"status": "WARN",
|
|
"message": f"SBAS catalog rebuild failed after run deletion: {catalog_exc}",
|
|
}
|
|
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 "active task/job" in message or "running" in message.lower() else 400
|
|
raise HTTPException(status_code=status_code, detail=message) from exc
|
|
return {
|
|
**result,
|
|
"catalog": catalog_result,
|
|
}
|
|
|
|
|
|
@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,
|
|
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
|
)
|
|
|
|
|
|
@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,
|
|
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
|
)
|