Add Gamma SBAS production workflow
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user