Add Gamma SBAS production workflow

This commit is contained in:
2026-05-21 04:53:39 +08:00
parent 9000feeee8
commit cc22b9ac2d
46 changed files with 11386 additions and 332 deletions
+2
View File
@@ -22,6 +22,7 @@ from . import (
ps_products,
radar,
root_registry,
sbas_insar_production,
stats,
task_batches,
tasks_runtime,
@@ -53,6 +54,7 @@ def include_all_routers(router: APIRouter) -> None:
router.include_router(dinsar.router)
router.include_router(dinsar_products.router)
router.include_router(dinsar_production.router)
router.include_router(sbas_insar_production.router)
router.include_router(timeseries_production.router)
router.include_router(ps_products.router)
router.include_router(ai.router)
+165 -58
View File
@@ -1,45 +1,65 @@
"""Flood disaster analysis router.
This router exposes the flood-analysis business API while reusing the
existing water/flood processing records and jobs during migration.
"""
"""Flood disaster analysis router."""
from __future__ import annotations
from typing import Optional
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db
from ..models import AuthUserORM
from . import water as water_compat
from ..services import flood_analysis_service
from ..services import flood_overlay_service
from ..services import flood_product_service
from .dependencies import _get_current_user, _require_admin
router = APIRouter()
class FloodPreprocessRequest(BaseModel):
radar_data_id: int = Field(..., description="RadarDataORM 主键")
radar_data_id: int = Field(..., description="RadarDataORM primary key")
class FloodWaterExtractionRequest(BaseModel):
scene_id: Optional[int] = Field(default=None, description="SARSceneGeoORM 主键")
input_path: Optional[str] = Field(default=None, description="直接指定 GeoTIFF/ENVI 路径")
scene_id: Optional[int] = Field(default=None, description="SARSceneGeoORM primary key")
input_path: Optional[str] = Field(default=None, description="Direct GeoTIFF/ENVI input path")
class FloodPairSearchRequest(BaseModel):
pre_start: Optional[str] = Field(default=None, description="灾前开始日期 YYYYMMDD")
pre_end: Optional[str] = Field(default=None, description="灾前结束日期 YYYYMMDD")
post_start: Optional[str] = Field(default=None, description="灾后开始日期 YYYYMMDD")
post_end: Optional[str] = Field(default=None, description="灾后结束日期 YYYYMMDD")
overlap_threshold: float = Field(default=0.3, ge=0.0, le=1.0, description="最小重叠比例")
pre_start: Optional[str] = Field(default=None, description="Pre-flood start date in YYYYMMDD")
pre_end: Optional[str] = Field(default=None, description="Pre-flood end date in YYYYMMDD")
post_start: Optional[str] = Field(default=None, description="Post-flood start date in YYYYMMDD")
post_end: Optional[str] = Field(default=None, description="Post-flood end date in YYYYMMDD")
overlap_threshold: float = Field(default=0.3, ge=0.0, le=1.0, description="Minimum overlap ratio")
class FloodDisasterPairSearchRequest(BaseModel):
disaster_name: Optional[str] = Field(default=None, description="Disaster/event display name")
disaster_date: str = Field(..., description="Disaster date in YYYYMMDD")
region_tree_id: Optional[str] = Field(default=None, description="AOI admin region tree id")
aoi_geojson: Optional[dict[str, Any]] = Field(default=None, description="AOI GeoJSON FeatureCollection")
pre_window_days: int = Field(default=30, ge=1, le=365)
post_window_days: int = Field(default=30, ge=1, le=365)
min_aoi_coverage_ratio: float = Field(default=0.2, ge=0.0, le=1.0)
min_pair_overlap_ratio: float = Field(default=0.3, ge=0.0, le=1.0)
max_pairs: int = Field(default=50, ge=1, le=200)
satellites: Optional[list[str]] = None
polarization: Optional[str] = None
imaging_mode: Optional[str] = None
product_level: Optional[str] = None
require_same_polarization: bool = True
require_same_imaging_mode: bool = False
class FloodDetectionRequest(BaseModel):
pre_scene_id: int = Field(..., description="灾前 SARSceneGeoORM 主键")
post_scene_id: int = Field(..., description="灾后 SARSceneGeoORM 主键")
refine: bool = Field(default=False, description="是否启用 MRF 精化")
pre_scene_id: int = Field(..., description="Pre-flood SARSceneGeoORM primary key")
post_scene_id: int = Field(..., description="Post-flood SARSceneGeoORM primary key")
refine: bool = Field(default=False, description="Enable MRF refinement")
class FloodOverlayRequest(BaseModel):
near_threshold_m: float = Field(default=500.0, ge=0.0, le=10000.0, description="Near-flood threshold in meters")
@router.post("/flood/preprocess", status_code=202)
@@ -48,8 +68,7 @@ async def submit_flood_preprocess(
db: AsyncSession = Depends(get_db),
admin_user: AuthUserORM = Depends(_require_admin),
):
"""提交水体提取前置处理任务,当前复用旧 water geocode 链路。"""
return await water_compat.submit_geocode(req, db=db, admin_user=admin_user)
return await flood_analysis_service.submit_geocode_job(req, db=db)
@router.get("/flood/scenes")
@@ -59,8 +78,7 @@ async def list_flood_scenes(
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
"""列出可作为水体提取输入的地理编码场景。"""
return await water_compat.list_scenes(limit=limit, offset=offset, db=db, current_user=current_user)
return await flood_analysis_service.list_scenes(limit=limit, offset=offset, db=db)
@router.get("/flood/scenes/done-radar-ids")
@@ -68,7 +86,7 @@ async def list_flood_done_radar_ids(
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await water_compat.list_done_scene_radar_ids(db=db, current_user=current_user)
return await flood_analysis_service.list_done_scene_radar_ids(db=db)
@router.get("/flood/scenes/active-radar-ids")
@@ -76,7 +94,7 @@ async def list_flood_active_radar_ids(
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await water_compat.list_active_scene_radar_ids(db=db, current_user=current_user)
return await flood_analysis_service.list_active_scene_radar_ids(db=db)
@router.post("/flood/scenes/{scene_id}/reset")
@@ -85,7 +103,7 @@ async def reset_flood_scene(
db: AsyncSession = Depends(get_db),
admin_user: AuthUserORM = Depends(_require_admin),
):
return await water_compat.reset_scene_status(scene_id=scene_id, db=db, admin_user=admin_user)
return await flood_analysis_service.reset_scene_status(scene_id=scene_id, db=db)
@router.post("/flood/water-extractions", status_code=202)
@@ -94,8 +112,7 @@ async def submit_flood_water_extraction(
db: AsyncSession = Depends(get_db),
admin_user: AuthUserORM = Depends(_require_admin),
):
"""提交单景水体提取任务,当前复用 Otsu 快速水体检测实现。"""
return await water_compat.submit_water_detect(req, db=db, admin_user=admin_user)
return await flood_analysis_service.submit_water_extraction(req, db=db)
@router.get("/flood/water-extractions")
@@ -106,12 +123,11 @@ async def list_flood_water_extractions(
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await water_compat.list_water_detections(
return await flood_analysis_service.list_water_extractions(
limit=limit,
offset=offset,
status=status,
db=db,
current_user=current_user,
)
@@ -121,11 +137,7 @@ async def get_flood_water_extraction_preview(
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await water_compat.get_water_detection_preview(
detection_id=extraction_id,
db=db,
current_user=current_user,
)
return await flood_analysis_service.get_water_extraction_preview(extraction_id=extraction_id, db=db)
@router.post("/flood/pairs/search")
@@ -134,7 +146,16 @@ async def search_flood_pairs(
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await water_compat.find_water_pairs(req, db=db, current_user=current_user)
return await flood_analysis_service.search_pairs(req, db=db)
@router.post("/flood/disaster-pairs/search")
async def search_flood_disaster_pairs(
req: FloodDisasterPairSearchRequest,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await flood_analysis_service.search_disaster_pairs(req, db=db)
@router.post("/flood/detections", status_code=202)
@@ -143,7 +164,7 @@ async def submit_flood_detection(
db: AsyncSession = Depends(get_db),
admin_user: AuthUserORM = Depends(_require_admin),
):
return await water_compat.submit_flood_detect(req, db=db, admin_user=admin_user)
return await flood_analysis_service.submit_flood_detection(req, db=db)
@router.get("/flood/detections")
@@ -151,7 +172,7 @@ async def list_flood_detections(
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await water_compat.list_flood_events(db=db, current_user=current_user)
return await flood_analysis_service.list_flood_detections(db=db)
@router.get("/flood/detections/{detection_id}/preview/{layer}")
@@ -161,23 +182,109 @@ async def get_flood_detection_preview(
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
normalized = layer.strip().lower()
if normalized == "pre":
return await water_compat.flood_event_pre_preview(
event_id=detection_id,
db=db,
current_user=current_user,
)
if normalized == "post":
return await water_compat.flood_event_post_preview(
event_id=detection_id,
db=db,
current_user=current_user,
)
if normalized == "classified":
return await water_compat.flood_event_classified_preview(
event_id=detection_id,
db=db,
current_user=current_user,
)
raise HTTPException(status_code=404, detail=f"不支持的洪涝预览图层: {layer}")
return await flood_analysis_service.get_flood_detection_preview(
detection_id=detection_id,
layer=layer,
db=db,
)
@router.post("/flood/detections/{detection_id}/overlay", status_code=201)
async def run_flood_overlay(
detection_id: int,
req: FloodOverlayRequest | None = None,
db: AsyncSession = Depends(get_db),
admin_user: AuthUserORM = Depends(_require_admin),
):
threshold = req.near_threshold_m if req else 500.0
return await flood_overlay_service.run_overlay(
detection_id=detection_id,
db=db,
near_threshold_m=threshold,
)
@router.get("/flood/detections/{detection_id}/impact")
async def get_flood_impact(
detection_id: int,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await flood_overlay_service.get_overlay_result(detection_id=detection_id, db=db)
@router.post("/flood/detections/{detection_id}/products", status_code=201)
async def create_flood_product(
detection_id: int,
db: AsyncSession = Depends(get_db),
admin_user: AuthUserORM = Depends(_require_admin),
):
return await flood_product_service.create_flood_product_for_detection(detection_id=detection_id, db=db)
@router.get("/flood/products")
async def list_flood_products(
limit: int = 20,
offset: int = 0,
status: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await flood_product_service.list_flood_products(
db=db,
limit=limit,
offset=offset,
status=status,
)
@router.get("/flood/products/{product_id}")
async def get_flood_product(
product_id: str,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await flood_product_service.get_flood_product(product_id_or_pk=product_id, db=db)
@router.get("/flood/products/{product_id}/manifest")
async def get_flood_product_manifest(
product_id: str,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await flood_product_service.get_flood_product_manifest(product_id_or_pk=product_id, db=db)
@router.get("/flood/results")
async def list_flood_results(
limit: int = 20,
offset: int = 0,
status: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await flood_product_service.list_flood_products(
db=db,
limit=limit,
offset=offset,
status=status,
)
@router.get("/flood/results/{product_id}")
async def get_flood_result(
product_id: str,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await flood_product_service.get_flood_product(product_id_or_pk=product_id, db=db)
@router.get("/flood/results/{product_id}/manifest")
async def get_flood_result_manifest(
product_id: str,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await flood_product_service.get_flood_product_manifest(product_id_or_pk=product_id, db=db)
+64 -2
View File
@@ -3,11 +3,11 @@ from __future__ import annotations
from typing import List, Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from ..config import read_int_env
from ..config import read_int_env, settings, split_env_paths
from ..database import get_db
from ..models import AuthUserORM, SystemTaskORM, TaskLogORM
from ..scheduler import MONITOR_CONFIG
@@ -41,11 +41,23 @@ class MonitorConfig(BaseModel):
radar_dirs: List[str] = []
orbit_dir: Optional[str] = None
dinsar_dirs: List[str] = []
gf3_archive_source_dirs: List[str] = []
gf3_source_dirs: List[str] = []
gf3_storage_dirs: List[str] = []
# Manual-only: config is read from .env
class GF3UnpackConfig(BaseModel):
source_dirs: List[str] = []
target_dirs: List[str] = []
archive_exts: List[str] = []
delete_archive: bool = False
class GF3UnpackRunRequest(BaseModel):
max_files_per_run: Optional[int] = Field(default=None, ge=0)
@router.post("/monitor/config")
async def update_monitor_config(config: MonitorConfig):
"""
@@ -142,6 +154,56 @@ async def run_gf3_batch_process(admin_user: AuthUserORM = Depends(_require_admin
raise HTTPException(status_code=409, detail=str(e))
@router.get("/monitor/gf3-unpack/config")
async def get_gf3_unpack_config(admin_user: AuthUserORM = Depends(_require_admin)):
return GF3UnpackConfig(
source_dirs=MONITOR_CONFIG.get("gf3_archive_source_dirs") or [],
target_dirs=MONITOR_CONFIG.get("gf3_source_dirs") or [],
archive_exts=split_env_paths(settings.GF3_ARCHIVE_EXTS),
delete_archive=bool(settings.GF3_UNPACK_DELETE_ARCHIVE),
)
@router.post("/monitor/gf3-unpack", status_code=202)
async def run_gf3_unpack(
request_data: GF3UnpackRunRequest | None = None,
admin_user: AuthUserORM = Depends(_require_admin),
):
"""
将 GF3 压缩包池解包到 GF3_SOURCE_DIRS,作为后续 L1A→L2 预处理输入。
"""
gf3_archive_source_dirs = MONITOR_CONFIG.get("gf3_archive_source_dirs") or []
gf3_source_dirs = MONITOR_CONFIG.get("gf3_source_dirs") or []
if not gf3_archive_source_dirs:
raise HTTPException(status_code=400, detail="GF3_ARCHIVE_SOURCE_DIRS is not configured.")
if not gf3_source_dirs:
raise HTTPException(status_code=400, detail="GF3_SOURCE_DIRS is not configured.")
max_files = None
if request_data is not None and request_data.max_files_per_run is not None:
max_files = max(0, int(request_data.max_files_per_run))
task_type = "GF3_UNPACK"
task_name = "GF3 压缩包解包"
payload = {
"source_dirs": gf3_archive_source_dirs,
"target_dirs": gf3_source_dirs,
"archive_exts": split_env_paths(settings.GF3_ARCHIVE_EXTS),
}
if max_files is not None:
payload["max_files_per_run"] = max_files
try:
task_id = await task_service.create_task(task_type, task_name, params=payload)
await job_queue_service.create_job(task_type, payload=payload, task_id=task_id)
return {
"message": "GF3 解包任务已提交",
"task_id": task_id,
}
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
@router.get("/monitor/status")
async def get_monitor_status():
"""
@@ -0,0 +1,323 @@
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,
)