feat: add SBAS AOI discovery and result management

This commit is contained in:
2026-05-28 22:06:08 +08:00
parent 9f0ba325f9
commit 0b7a875ba8
18 changed files with 4100 additions and 62 deletions
+2
View File
@@ -20,6 +20,7 @@ from . import (
orbit,
pairing,
ps_products,
sbas_insar_products,
radar,
root_registry,
sbas_insar_production,
@@ -55,6 +56,7 @@ def include_all_routers(router: APIRouter) -> None:
router.include_router(dinsar_products.router)
router.include_router(dinsar_production.router)
router.include_router(sbas_insar_production.router)
router.include_router(sbas_insar_products.router)
router.include_router(timeseries_production.router)
router.include_router(ps_products.router)
router.include_router(ai.router)
+42 -3
View File
@@ -6,7 +6,7 @@ import subprocess
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_validator, model_validator
from ..services.job_queue_service import job_queue_service
from ..services.sbas_insar_production_service import sbas_insar_production_service
@@ -16,6 +16,19 @@ from ..services.task_service import task_service
router = APIRouter(prefix="/sbas-insar-production", tags=["sbas-insar-production"])
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):
source_roots: list[str] | None = None
orbit_roots: list[str] | None = None
@@ -26,6 +39,11 @@ class SbasStackDiscoverRequest(BaseModel):
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=0.0, ge=0, le=1)
@field_validator("source_roots", "orbit_roots", mode="before")
@classmethod
@@ -39,7 +57,7 @@ class SbasStackDiscoverRequest(BaseModel):
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")
@field_validator("platform", "relative_orbit", "orbit_direction", "admin_region", mode="before")
@classmethod
def _normalize_optional_text(cls, value):
if value is None:
@@ -47,6 +65,12 @@ class SbasStackDiscoverRequest(BaseModel):
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)
@@ -58,7 +82,7 @@ class SbasMonitorPoint(BaseModel):
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_point_strategy: str = Field(default="auto_representative_points", max_length=64)
monitor_points: list[SbasMonitorPoint] | None = None
@@ -160,6 +184,11 @@ async def discover_sbas_insar_stacks(request: SbasStackDiscoverRequest):
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
@@ -175,6 +204,11 @@ async def audit_sbas_insar_stack(stack_id: str, request: SbasStackDiscoverReques
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
@@ -193,6 +227,11 @@ async def submit_sbas_insar_run(stack_id: str, request: SbasRunSubmitRequest):
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 [])
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
import os
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db
from ..models import AuthUserORM
from ..services.job_queue_service import job_queue_service
from ..services.sbas_insar_catalog_service import (
JOB_TYPE_REBUILD_SBAS_INSAR_CATALOG,
TASK_TYPE_REBUILD_SBAS_INSAR_CATALOG,
sbas_insar_catalog_service,
)
from ..services.task_service import task_service
from .dependencies import _add_operation_audit_log, _get_current_user, _require_admin
router = APIRouter()
class SbasInsarCatalogRebuildRequest(BaseModel):
full_rebuild: bool = True
@router.get("/sbas-insar-products/catalog-status")
async def get_sbas_insar_catalog_status(
current_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
return await sbas_insar_catalog_service.get_catalog_status(db)
@router.post("/sbas-insar-products/rebuild", status_code=202)
async def queue_sbas_insar_catalog_rebuild(
request: SbasInsarCatalogRebuildRequest,
http_request: Request,
db: AsyncSession = Depends(get_db),
admin_user: AuthUserORM = Depends(_require_admin),
):
_ = admin_user
task_id = await task_service.create_task(
TASK_TYPE_REBUILD_SBAS_INSAR_CATALOG,
"SBAS-InSAR result catalog rebuild",
params={"full_rebuild": request.full_rebuild},
db=db,
)
await job_queue_service.create_job(
JOB_TYPE_REBUILD_SBAS_INSAR_CATALOG,
payload={"full_rebuild": request.full_rebuild},
task_id=task_id,
db=db,
)
await _add_operation_audit_log(
db,
request=http_request,
action="sbas_insar_catalog_rebuild_queued",
resource="sbas-insar-products/rebuild",
detail={"task_id": task_id, "full_rebuild": request.full_rebuild},
)
await db.commit()
return {
"message": "SBAS-InSAR result catalog rebuild has been queued.",
"task_id": task_id,
}
@router.get("/sbas-insar-products")
async def list_sbas_insar_products(
limit: int = 100,
offset: int = 0,
status: str | None = None,
query: str | None = None,
admin_region: str | None = None,
current_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
return await sbas_insar_catalog_service.list_products(
db,
limit=limit,
offset=offset,
status=status,
query=query,
admin_region=admin_region,
)
@router.get("/sbas-insar-products/{product_db_id}")
async def get_sbas_insar_product_detail(
product_db_id: int,
current_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
detail = await sbas_insar_catalog_service.get_product_detail(db, product_db_id=product_db_id)
if detail is None:
raise HTTPException(status_code=404, detail="SBAS-InSAR product not found")
return detail
@router.get("/sbas-insar-products/{product_db_id}/preview")
async def get_sbas_insar_product_preview(
product_db_id: int,
current_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
detail = await sbas_insar_catalog_service.get_product_detail(db, product_db_id=product_db_id)
if detail is None:
raise HTTPException(status_code=404, detail="SBAS-InSAR product not found")
preview_path = str(detail.get("preview_path") or "").strip()
if not preview_path or not os.path.isfile(preview_path):
raise HTTPException(status_code=404, detail="Preview not found")
return FileResponse(preview_path, media_type="image/png")
@router.get("/sbas-insar-products/{product_db_id}/assets/{asset_id}")
async def get_sbas_insar_product_asset(
product_db_id: int,
asset_id: int,
current_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
asset = await sbas_insar_catalog_service.get_asset(db, product_db_id=product_db_id, asset_id=asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="SBAS-InSAR product asset not found")
if not asset.absolute_path or not os.path.isfile(asset.absolute_path):
raise HTTPException(status_code=404, detail="Asset file not found")
return FileResponse(
asset.absolute_path,
media_type=asset.media_type or "application/octet-stream",
filename=asset.asset_name or os.path.basename(asset.absolute_path),
)