Add SARscape SBAS prepared stack workflow

This commit is contained in:
2026-04-30 09:42:01 +08:00
parent 4c0d1f2c2b
commit 6696fe90fa
27 changed files with 4915 additions and 95 deletions
+23 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
import os
import re as _re
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
@@ -55,6 +55,12 @@ class ExtractDispRequest(BaseModel):
dest_dir: Optional[str] = None
class SarscapeSbasInspectRequest(BaseModel):
task_names: Optional[List[str]] = None
include_parameters: bool = False
timeout_seconds: Optional[int] = Field(default=120, ge=10, le=600)
def _normalize_existing_dir(path: Optional[str]) -> Optional[str]:
text = str(path or "").strip()
if not text:
@@ -162,6 +168,22 @@ async def inspect_dinsar_endpoint(
return envi_service.inspect_dinsar(request.root_dir)
@router.post("/idl/inspect/sarscape-sbas")
async def inspect_sarscape_sbas_endpoint(
request: SarscapeSbasInspectRequest,
admin_user: AuthUserORM = Depends(_require_admin),
):
_ = admin_user
try:
return envi_service.inspect_sarscape_sbas_tasks_subprocess(
request.task_names,
timeout_seconds=request.timeout_seconds or 120,
include_parameters=bool(request.include_parameters),
)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.post("/idl/jobs/import")
async def run_import_job_endpoint(
request: ImportJobRequest,
+22 -1
View File
@@ -18,6 +18,8 @@ from ..models import (
RadarData,
TimeseriesStackPlan,
TimeseriesStackPlanDetail,
TimeseriesStackPlanEdge,
TimeseriesStackPlanEdgeORM,
TimeseriesStackPlanItem,
TimeseriesStackPlanItemORM,
TimeseriesStackPlanORM,
@@ -89,11 +91,21 @@ def get_pairing_request_from_form(
def get_ps_request_from_form(
initial_overlap_threshold: float = Form(0.3),
final_overlap_threshold: float = Form(0.95)
final_overlap_threshold: float = Form(0.95),
time_baseline_min: int = Form(1),
time_baseline_max: int = Form(90),
spatial_baseline_max_meters: int = Form(3000),
network_overlap_threshold: float = Form(0.5),
num_connections: int = Form(1),
) -> PsRequest:
return PsRequest(
initial_overlap_threshold=initial_overlap_threshold,
final_overlap_threshold=final_overlap_threshold,
time_baseline_min=time_baseline_min,
time_baseline_max=time_baseline_max,
spatial_baseline_max_meters=spatial_baseline_max_meters,
network_overlap_threshold=network_overlap_threshold,
num_connections=num_connections,
)
@@ -205,11 +217,20 @@ async def get_timeseries_stack_plan_endpoint(
.where(TimeseriesStackPlanItemORM.plan_ref_id == plan.id)
.order_by(TimeseriesStackPlanItemORM.scene_rank.asc(), TimeseriesStackPlanItemORM.id.asc())
)
edges_result = await db.execute(
select(TimeseriesStackPlanEdgeORM)
.where(TimeseriesStackPlanEdgeORM.plan_ref_id == plan.id)
.order_by(TimeseriesStackPlanEdgeORM.edge_rank.asc(), TimeseriesStackPlanEdgeORM.id.asc())
)
payload = TimeseriesStackPlan.model_validate(plan).model_dump()
payload["items"] = [
TimeseriesStackPlanItem.model_validate(item)
for item in items_result.scalars().all()
]
payload["edges"] = [
TimeseriesStackPlanEdge.model_validate(edge)
for edge in edges_result.scalars().all()
]
return TimeseriesStackPlanDetail.model_validate(payload)
+49 -4
View File
@@ -25,6 +25,7 @@ from ..models import (
PsTaskItemORM,
RadarData,
RadarPair,
TimeseriesStackPlanEdgeORM,
TimeseriesStackPlanItemORM,
TimeseriesStackPlanORM,
)
@@ -144,6 +145,7 @@ def _normalize_lookup_key(value: Optional[str]) -> str:
def _build_plan_context(
plan: TimeseriesStackPlanORM,
plan_items: List[TimeseriesStackPlanItemORM],
plan_edges: Optional[List[TimeseriesStackPlanEdgeORM]] = None,
) -> Dict[str, Any]:
request_params = plan.request_params_json if isinstance(plan.request_params_json, dict) else {}
ordered_items = sorted(
@@ -164,6 +166,10 @@ def _build_plan_context(
}
for item in ordered_items
]
ordered_edges = sorted(
list(plan_edges or []),
key=lambda item: (int(item.edge_rank or 0), int(item.id or 0)),
)
return {
"source": "timeseries_stack_plan",
"plan_id": plan.plan_id,
@@ -176,12 +182,41 @@ def _build_plan_context(
"aoi_summary": plan.aoi_summary_json if isinstance(plan.aoi_summary_json, dict) else None,
"initial_overlap_threshold": request_params.get("initial_overlap_threshold"),
"final_overlap_threshold": request_params.get("final_overlap_threshold"),
"time_baseline_min": request_params.get("time_baseline_min"),
"time_baseline_max": request_params.get("time_baseline_max"),
"spatial_baseline_max_meters": request_params.get("spatial_baseline_max_meters"),
"network_overlap_threshold": request_params.get("network_overlap_threshold"),
"num_connections": request_params.get("num_connections"),
"network_edge_count": len(ordered_edges),
"stack_dates": [
str(item.imaging_date).strip()
for item in ordered_items
if str(item.imaging_date or "").strip()
],
"scenes": scenes,
"network_edges": [
{
"edge_id": item.id,
"edge_rank": item.edge_rank,
"master_plan_item_ref_id": item.master_plan_item_ref_id,
"slave_plan_item_ref_id": item.slave_plan_item_ref_id,
"metric_cache_ref_id": item.metric_cache_ref_id,
"master_scene_ref_id": item.master_scene_ref_id,
"slave_scene_ref_id": item.slave_scene_ref_id,
"master_imaging_date": item.master_imaging_date,
"slave_imaging_date": item.slave_imaging_date,
"temporal_baseline_days": item.temporal_baseline_days,
"spatial_baseline_meters": item.spatial_baseline_meters,
"perpendicular_baseline_meters": item.perpendicular_baseline_meters,
"scene_overlap_ratio": item.scene_overlap_ratio,
"pair_aoi_overlap_ratio": item.pair_aoi_overlap_ratio,
"selection_reason": item.selection_reason,
"selection_score": item.selection_score,
"enabled": bool(item.enabled),
"selection_meta": item.selection_meta_json if isinstance(item.selection_meta_json, dict) else None,
}
for item in ordered_edges
],
}
@@ -383,6 +418,7 @@ async def create_ps_batch_endpoint(
effective_plan_id = explicit_plan_id or (inferred_plan_ids[0] if inferred_plan_ids else None)
plan: Optional[TimeseriesStackPlanORM] = None
plan_items: List[TimeseriesStackPlanItemORM] = []
plan_edges: List[TimeseriesStackPlanEdgeORM] = []
plan_item_by_id: Dict[int, TimeseriesStackPlanItemORM] = {}
plan_item_by_scene_id: Dict[int, TimeseriesStackPlanItemORM] = {}
plan_item_by_path: Dict[str, TimeseriesStackPlanItemORM] = {}
@@ -408,6 +444,12 @@ async def create_ps_batch_endpoint(
.order_by(TimeseriesStackPlanItemORM.scene_rank.asc(), TimeseriesStackPlanItemORM.id.asc())
)
plan_items = items_result.scalars().all()
edges_result = await db.execute(
select(TimeseriesStackPlanEdgeORM)
.where(TimeseriesStackPlanEdgeORM.plan_ref_id == plan.id)
.order_by(TimeseriesStackPlanEdgeORM.edge_rank.asc(), TimeseriesStackPlanEdgeORM.id.asc())
)
plan_edges = edges_result.scalars().all()
plan_item_by_id = {int(item.id): item for item in plan_items if item.id is not None}
plan_item_by_scene_id = {
int(item.radar_data_ref_id): item
@@ -419,15 +461,18 @@ async def create_ps_batch_endpoint(
for item in plan_items
if _normalize_lookup_key(item.file_path)
}
plan_context = _build_plan_context(plan, plan_items, plan_edges)
if not planning_context:
planning_context = _build_plan_context(plan, plan_items)
planning_context = plan_context
else:
merged_context = {
**_build_plan_context(plan, plan_items),
**plan_context,
**planning_context,
}
if "scenes" not in planning_context:
merged_context["scenes"] = _build_plan_context(plan, plan_items).get("scenes") or []
merged_context["scenes"] = plan_context.get("scenes") or []
if "network_edges" not in planning_context:
merged_context["network_edges"] = plan_context.get("network_edges") or []
planning_context = merged_context
batch_id = str(uuid.uuid4())
@@ -466,7 +511,7 @@ async def create_ps_batch_endpoint(
planning_summary = {
key: value
for key, value in planning_context.items()
if key != "scenes"
if key not in {"scenes", "network_edges"}
}
remark_payload = {
**planning_summary,
@@ -20,6 +20,8 @@ class TimeseriesRunCreateRequest(BaseModel):
run_name: Optional[str] = Field(default=None, max_length=255)
reference_date: Optional[str] = Field(default=None, pattern=r"^\d{8}$|^$")
water_mask_mode: str = Field(default="synthetic_fallback", max_length=64)
processor_code: str = Field(default="isce2_stack_mintpy", max_length=64)
execution_mode: Optional[str] = Field(default=None, max_length=32)
notes: Optional[str] = Field(default=None, max_length=1000)
@field_validator("batch_id", mode="before")
@@ -58,6 +60,21 @@ class TimeseriesPreflightRequest(BaseModel):
return text
class SarscapeSbasPreflightRequest(BaseModel):
batch_id: str = Field(..., description="PS batch id")
reference_date: Optional[str] = Field(default=None, pattern=r"^\d{8}$|^$")
include_task_discovery: bool = True
discovery_timeout_seconds: int = Field(default=120, ge=10, le=600)
@field_validator("batch_id", mode="before")
@classmethod
def _validate_batch_id(cls, value: str) -> str:
text = str(value or "").strip()
if not text:
raise ValueError("batch_id is required")
return text
class TimeseriesRetryStepRequest(BaseModel):
step_id: str = Field(..., max_length=128)
@@ -82,6 +99,8 @@ async def create_timeseries_run(
run_name=request.run_name,
reference_date=request.reference_date,
water_mask_mode=request.water_mask_mode,
processor_code=request.processor_code,
execution_mode=request.execution_mode,
notes=request.notes,
created_by=getattr(current_user, "username", None),
db=db,
@@ -125,6 +144,25 @@ async def run_timeseries_preflight(
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/sarscape-sbas/preflight")
async def run_sarscape_sbas_preflight(
request: SarscapeSbasPreflightRequest,
current_user: AuthUserORM = Depends(_require_admin),
db: AsyncSession = Depends(get_db),
):
_ = current_user
try:
return await timeseries_service.get_sarscape_sbas_preflight_report(
batch_id=request.batch_id,
reference_date=request.reference_date,
include_task_discovery=request.include_task_discovery,
discovery_timeout_seconds=request.discovery_timeout_seconds,
db=db,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/runs")
async def list_timeseries_runs(
limit: int = 50,
@@ -149,6 +187,19 @@ async def get_timeseries_run_detail(
return detail
@router.get("/runs/{run_id}/prepared-stack")
async def get_timeseries_run_prepared_stack(
run_id: str,
current_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
summary = await timeseries_service.get_prepared_stack_summary(db, run_id=run_id)
if summary is None:
raise HTTPException(status_code=404, detail="Timeseries run not found")
return summary
@router.post("/runs/{run_id}/retry-step", status_code=202)
async def retry_timeseries_run_step(
run_id: str,