feat: add SBAS AOI discovery and result management
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 [])
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
Reference in New Issue
Block a user