feat: add Gamma SBAS workflow and coverage design

This commit is contained in:
2026-05-27 13:01:48 +08:00
parent cc22b9ac2d
commit 9f0ba325f9
16 changed files with 9853 additions and 312 deletions
+21
View File
@@ -221,6 +221,27 @@ PYINT_GAMMA_ENV_SCRIPT=D:\Code\Insar_management_system_v2\deploy\wsl\profiles\ga
PYINT_DEFAULT_TIMEOUT_SECONDS=43200
PYINT_SMOKE_TEST_ENABLED=false
# -----------------------------------------------------------------------------
# Gamma SBAS-InSAR
# -----------------------------------------------------------------------------
GAMMA_SBAS_ENABLED=true
GAMMA_SBAS_RUNTIME_ID=gamma_sbas_runtime_v1
GAMMA_SBAS_WSL_DISTRO=Ubuntu-24.04
GAMMA_SBAS_PYTHON=/home/administrator/miniconda3/envs/insar_wsl_v1/bin/python
GAMMA_SBAS_ENV_SCRIPT=D:\Code\Insar_management_system_v2\deploy\wsl\profiles\gamma_env.sh
GAMMA_SBAS_WORK_ROOT=D:\Code\Insar_management_system_v2\backend\runtime\sbas_insar_production
GAMMA_SBAS_PRODUCT_ROOT=D:\production_results\timeseries\sbas
GAMMA_SBAS_SCRIPT_TEMPLATE_ROOT=D:\Code\Insar_management_system_v2\backend\templates\gamma_sbas
GAMMA_SBAS_SOURCE_ROOTS=D:\LuTan1_Image_Pool
GAMMA_SBAS_ORBIT_ROOTS=D:\orbit_pools\envi
GAMMA_SBAS_DEFAULT_RLKS=8
GAMMA_SBAS_DEFAULT_AZLKS=8
GAMMA_SBAS_DEFAULT_MB_MODE=0
GAMMA_SBAS_DEFAULT_REFERENCE_WINDOW=16
GAMMA_SBAS_AUTO_APPROVE_ITAB=true
GAMMA_SBAS_STEP_TIMEOUT_SECONDS=43200
GAMMA_SBAS_WORKFLOW_TIMEOUT_SECONDS=172800
# -----------------------------------------------------------------------------
# 时序 InSAR(旧 ISCE2/MintPy 链路)
Binary file not shown.
+132
View File
@@ -308,6 +308,25 @@ class Settings(BaseSettings):
PYINT_GAMMA_ENV_SCRIPT: str = ""
PYINT_DEFAULT_TIMEOUT_SECONDS: int = 43200
PYINT_SMOKE_TEST_ENABLED: bool = False
GAMMA_SBAS_ENABLED: bool = True
GAMMA_SBAS_RUNTIME_ID: str = ""
GAMMA_SBAS_WSL_DISTRO: str = ""
GAMMA_SBAS_PYTHON: str = ""
GAMMA_SBAS_ENV_SCRIPT: str = ""
GAMMA_SBAS_WORK_ROOT: str = ""
GAMMA_SBAS_PRODUCT_ROOT: str = ""
GAMMA_SBAS_SCRIPT_TEMPLATE_ROOT: str = ""
GAMMA_SBAS_SOURCE_ROOTS: str = ""
GAMMA_SBAS_ORBIT_ROOTS: str = ""
GAMMA_SBAS_DEFAULT_RLKS: int = 8
GAMMA_SBAS_DEFAULT_AZLKS: int = 8
GAMMA_SBAS_DEFAULT_MB_MODE: int = 0
GAMMA_SBAS_DEFAULT_REFERENCE_WINDOW: int = 16
GAMMA_SBAS_AUTO_APPROVE_ITAB: bool = True
GAMMA_SBAS_STEP_TIMEOUT_SECONDS: int = 43200
GAMMA_SBAS_WORKFLOW_TIMEOUT_SECONDS: int = 172800
JOB_WORKER_HEALTH_TIMEOUT: int = 60
JOB_WORKER_JOB_HEARTBEAT_INTERVAL: float = 5.0
JOB_WORKER_STALE_RECOVER_INTERVAL: float = 15.0
@@ -623,6 +642,79 @@ class Settings(BaseSettings):
)
if not self.PYINT_ORBIT_POOL_TXT:
object.__setattr__(self, "PYINT_ORBIT_POOL_TXT", self.ORBIT_POOL_ENVI)
if not self.GAMMA_SBAS_RUNTIME_ID:
object.__setattr__(self, "GAMMA_SBAS_RUNTIME_ID", "gamma_sbas_runtime_v1")
if not self.GAMMA_SBAS_WSL_DISTRO:
object.__setattr__(
self,
"GAMMA_SBAS_WSL_DISTRO",
self.WSL_DISTRO or self.PYINT_WSL_DISTRO or self.ISCE2_WSL_DISTRO,
)
if not self.GAMMA_SBAS_PYTHON:
object.__setattr__(
self,
"GAMMA_SBAS_PYTHON",
self.WSL_SHARED_PYTHON or self.PYINT_WSL_PYTHON or self.ISCE2_PYTHON,
)
if not self.GAMMA_SBAS_ENV_SCRIPT:
object.__setattr__(self, "GAMMA_SBAS_ENV_SCRIPT", self.PYINT_GAMMA_ENV_SCRIPT)
if not self.GAMMA_SBAS_WORK_ROOT:
object.__setattr__(
self,
"GAMMA_SBAS_WORK_ROOT",
os.path.join(backend_dir, "runtime", "sbas_insar_production"),
)
if not self.GAMMA_SBAS_PRODUCT_ROOT:
object.__setattr__(
self,
"GAMMA_SBAS_PRODUCT_ROOT",
os.path.join(self.TIMESERIES_PRODUCT_DIR, "sbas"),
)
if not self.GAMMA_SBAS_SCRIPT_TEMPLATE_ROOT:
object.__setattr__(
self,
"GAMMA_SBAS_SCRIPT_TEMPLATE_ROOT",
os.path.join(backend_dir, "templates", "gamma_sbas"),
)
if not self.GAMMA_SBAS_SOURCE_ROOTS:
def _local_split_paths(raw: str | None) -> list[str]:
items: list[str] = []
for part in str(raw or "").replace(";", ",").split(","):
text = part.strip().strip('"').strip("'")
if text:
items.append(text)
return items
lt1_roots = [
item
for value in (self.SOURCE_PRODUCT_DIRS, self.MONITOR_RADAR_DIRS, self.INSAR_STORAGE_DIRS)
for item in _local_split_paths(value)
if "lutan" in item.lower() or "lt1" in item.lower()
]
lt1_roots = list(dict.fromkeys(lt1_roots))
object.__setattr__(self, "GAMMA_SBAS_SOURCE_ROOTS", ";".join(lt1_roots) or r"D:\LuTan1_Image_Pool")
if not self.GAMMA_SBAS_ORBIT_ROOTS:
object.__setattr__(self, "GAMMA_SBAS_ORBIT_ROOTS", self.PYINT_ORBIT_POOL_TXT or self.ORBIT_POOL_ENVI)
object.__setattr__(self, "GAMMA_SBAS_DEFAULT_RLKS", max(1, int(self.GAMMA_SBAS_DEFAULT_RLKS or 8)))
object.__setattr__(self, "GAMMA_SBAS_DEFAULT_AZLKS", max(1, int(self.GAMMA_SBAS_DEFAULT_AZLKS or 8)))
gamma_sbas_mb_mode = int(self.GAMMA_SBAS_DEFAULT_MB_MODE or 0)
if gamma_sbas_mb_mode not in {0, 1, 2}:
gamma_sbas_mb_mode = 0
object.__setattr__(self, "GAMMA_SBAS_DEFAULT_MB_MODE", gamma_sbas_mb_mode)
object.__setattr__(
self,
"GAMMA_SBAS_DEFAULT_REFERENCE_WINDOW",
max(1, int(self.GAMMA_SBAS_DEFAULT_REFERENCE_WINDOW or 16)),
)
object.__setattr__(
self,
"GAMMA_SBAS_STEP_TIMEOUT_SECONDS",
max(60, int(self.GAMMA_SBAS_STEP_TIMEOUT_SECONDS or 43200)),
)
object.__setattr__(
self,
"GAMMA_SBAS_WORKFLOW_TIMEOUT_SECONDS",
max(self.GAMMA_SBAS_STEP_TIMEOUT_SECONDS, int(self.GAMMA_SBAS_WORKFLOW_TIMEOUT_SECONDS or 172800)),
)
if self.TIMESERIES_ENABLED:
if not self.TIMESERIES_WSL_DISTRO:
object.__setattr__(self, "TIMESERIES_WSL_DISTRO", self.WSL_DISTRO or self.ISCE2_WSL_DISTRO)
@@ -755,6 +847,10 @@ class Settings(BaseSettings):
os.makedirs(settings.PYINT_WORK_ROOT, exist_ok=True)
os.makedirs(settings.PYINT_OUTPUT_ROOT, exist_ok=True)
os.makedirs(settings.PYINT_DEM_ROOT, exist_ok=True)
if settings.GAMMA_SBAS_ENABLED:
os.makedirs(settings.GAMMA_SBAS_WORK_ROOT, exist_ok=True)
os.makedirs(settings.GAMMA_SBAS_PRODUCT_ROOT, exist_ok=True)
os.makedirs(settings.GAMMA_SBAS_SCRIPT_TEMPLATE_ROOT, exist_ok=True)
if settings.TIMESERIES_ENABLED and settings.TIMESERIES_WORK_ROOT:
os.makedirs(settings.TIMESERIES_WORK_ROOT, exist_ok=True)
@@ -1033,6 +1129,42 @@ def validate_runtime_config() -> dict[str, Any]:
expect_file=True,
)
if settings.GAMMA_SBAS_ENABLED:
if not settings.GAMMA_SBAS_RUNTIME_ID:
errors.append("GAMMA_SBAS_ENABLED=true but GAMMA_SBAS_RUNTIME_ID is not configured.")
if not settings.GAMMA_SBAS_WSL_DISTRO:
errors.append("GAMMA_SBAS_ENABLED=true but GAMMA_SBAS_WSL_DISTRO is not configured.")
if not settings.GAMMA_SBAS_PYTHON:
errors.append("GAMMA_SBAS_ENABLED=true but GAMMA_SBAS_PYTHON is not configured.")
_check_path(
label="GAMMA_SBAS_ENV_SCRIPT",
value=settings.GAMMA_SBAS_ENV_SCRIPT,
errors=errors,
warnings=warnings,
expect_file=True,
)
_check_path(
label="GAMMA_SBAS_WORK_ROOT",
value=settings.GAMMA_SBAS_WORK_ROOT,
errors=errors,
warnings=warnings,
expect_file=False,
)
_check_path(
label="GAMMA_SBAS_PRODUCT_ROOT",
value=settings.GAMMA_SBAS_PRODUCT_ROOT,
errors=errors,
warnings=warnings,
expect_file=False,
)
_check_path(
label="GAMMA_SBAS_SCRIPT_TEMPLATE_ROOT",
value=settings.GAMMA_SBAS_SCRIPT_TEMPLATE_ROOT,
errors=errors,
warnings=warnings,
expect_file=False,
)
if settings.ISCE2_ENABLED or settings.PYINT_ENABLED:
info.append(
"WSL shared runtime: "
+10
View File
@@ -190,6 +190,16 @@ async def lifespan(app: FastAPI):
)
if pairing_bootstrap.get("error"):
print(f">>> [Pairing] Startup bootstrap failed: {pairing_bootstrap['error']}")
print(
">>> [Gamma SBAS] enabled={0} runtime={1} distro={2} python={3} work_root={4} product_root={5}".format(
"YES" if settings.GAMMA_SBAS_ENABLED else "NO",
settings.GAMMA_SBAS_RUNTIME_ID or "?",
settings.GAMMA_SBAS_WSL_DISTRO or "?",
settings.GAMMA_SBAS_PYTHON or "?",
settings.GAMMA_SBAS_WORK_ROOT or "?",
settings.GAMMA_SBAS_PRODUCT_ROOT or "?",
)
)
# 当前项目默认 Manual-only 扫描模式,保留调度器关闭状态。
# scheduler_manager.start()
@@ -88,6 +88,59 @@ class SbasCoregistrationJobRequest(BaseModel):
timeout_seconds: int = Field(default=43200, ge=60, le=172800)
class SbasRdcDemRequest(BaseModel):
execute: bool = False
rlks: int = Field(default=8, ge=1, le=64)
class SbasRdcDemJobRequest(BaseModel):
rlks: int = Field(default=8, ge=1, le=64)
timeout_seconds: int = Field(default=43200, ge=60, le=172800)
class SbasInterferogramsRequest(BaseModel):
execute: bool = False
rlks: int = Field(default=8, ge=1, le=64)
azlks: int = Field(default=8, ge=1, le=64)
unwrap_threshold: float = Field(default=0.20, ge=0.01, le=0.95)
class SbasInterferogramsJobRequest(BaseModel):
rlks: int = Field(default=8, ge=1, le=64)
azlks: int = Field(default=8, ge=1, le=64)
unwrap_threshold: float = Field(default=0.20, ge=0.01, le=0.95)
timeout_seconds: int = Field(default=43200, ge=60, le=172800)
class SbasIptaTimeseriesRequest(BaseModel):
execute: bool = False
rlks: int = Field(default=8, ge=1, le=64)
reference_window: int = Field(default=16, ge=1, le=256)
mb_mode: int = Field(default=0, ge=0, le=2)
class SbasIptaTimeseriesJobRequest(BaseModel):
rlks: int = Field(default=8, ge=1, le=64)
reference_window: int = Field(default=16, ge=1, le=256)
mb_mode: int = Field(default=0, ge=0, le=2)
timeout_seconds: int = Field(default=43200, ge=60, le=172800)
class SbasWorkflowPrepareRequest(BaseModel):
force: bool = False
rlks: int = Field(default=8, ge=1, le=64)
azlks: int = Field(default=8, ge=1, le=64)
reference_window: int = Field(default=16, ge=1, le=256)
mb_mode: int = Field(default=0, ge=0, le=2)
class SbasWorkflowJobRequest(SbasWorkflowPrepareRequest):
from_step: str | None = Field(default=None, max_length=64)
to_step: str | None = Field(default=None, max_length=64)
only_steps: list[str] | None = None
timeout_seconds: int = Field(default=172800, ge=60, le=604800)
@router.get("/capabilities")
async def get_sbas_insar_capabilities():
return sbas_insar_production_service.get_capabilities()
@@ -168,6 +221,76 @@ async def get_sbas_insar_run(run_id: str):
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/runs/{run_id}/workflow", status_code=202)
async def prepare_sbas_insar_workflow(run_id: str, request: SbasWorkflowPrepareRequest):
try:
return await asyncio.to_thread(
sbas_insar_production_service.prepare_workflow,
run_id,
force=request.force,
rlks=request.rlks,
azlks=request.azlks,
reference_window=request.reference_window,
mb_mode=request.mb_mode,
)
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}/workflow/jobs", status_code=202)
async def submit_sbas_insar_workflow_job(run_id: str, request: SbasWorkflowJobRequest):
try:
await asyncio.to_thread(
sbas_insar_production_service.prepare_workflow,
run_id,
force=request.force,
rlks=request.rlks,
azlks=request.azlks,
reference_window=request.reference_window,
mb_mode=request.mb_mode,
)
from ..services.job_handlers import JOB_TYPE_SBAS_GAMMA_WORKFLOW
payload = {
"run_id": run_id,
"force": request.force,
"rlks": request.rlks,
"azlks": request.azlks,
"reference_window": request.reference_window,
"mb_mode": request.mb_mode,
"from_step": request.from_step,
"to_step": request.to_step,
"only_steps": request.only_steps or [],
"timeout_seconds": request.timeout_seconds,
}
task_id = await task_service.create_task(
task_type=JOB_TYPE_SBAS_GAMMA_WORKFLOW,
task_name=f"Gamma SBAS Workflow {run_id}",
params=payload,
)
job_id = await job_queue_service.create_job(
job_type=JOB_TYPE_SBAS_GAMMA_WORKFLOW,
payload=payload,
task_id=task_id,
max_attempts=1,
)
return {
"message": "Gamma SBAS workflow job queued.",
"run_id": run_id,
"task_id": task_id,
"job_id": job_id,
"job_type": JOB_TYPE_SBAS_GAMMA_WORKFLOW,
}
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 "already running" in message.lower() or "conflict" in message.lower() else 400
raise HTTPException(status_code=status_code, detail=message) 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:
@@ -274,6 +397,219 @@ async def submit_sbas_insar_coregistration_job(run_id: str, request: SbasCoregis
raise HTTPException(status_code=status_code, detail=message) from exc
@router.post("/runs/{run_id}/rdc-dem", status_code=202)
async def prepare_sbas_insar_rdc_dem(run_id: str, request: SbasRdcDemRequest):
try:
return await asyncio.to_thread(
sbas_insar_production_service.prepare_rdc_dem,
run_id,
execute=request.execute,
rlks=request.rlks,
)
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}/rdc-dem/jobs", status_code=202)
async def submit_sbas_insar_rdc_dem_job(run_id: str, request: SbasRdcDemJobRequest):
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 {"COREGISTRATION_READY", "RDC_DEM_FAILED"}:
await asyncio.to_thread(
sbas_insar_production_service.prepare_rdc_dem,
run_id,
execute=False,
rlks=request.rlks,
)
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 {"RDC_DEM_SCRIPT_READY", "RDC_DEM_RUNNING"}:
raise ValueError(f"run status does not allow RDC DEM job submission: {status}")
if status == "RDC_DEM_RUNNING":
raise ValueError("RDC DEM is already running for this run")
from ..services.job_handlers import JOB_TYPE_SBAS_RDC_DEM
payload = {
"run_id": run_id,
"rlks": request.rlks,
"timeout_seconds": request.timeout_seconds,
}
task_id = await task_service.create_task(
task_type=JOB_TYPE_SBAS_RDC_DEM,
task_name=f"SBAS-InSAR RDC DEM {run_id}",
params=payload,
)
job_id = await job_queue_service.create_job(
job_type=JOB_TYPE_SBAS_RDC_DEM,
payload=payload,
task_id=task_id,
max_attempts=1,
)
return {
"message": "SBAS-InSAR RDC DEM job queued.",
"run_id": run_id,
"task_id": task_id,
"job_id": job_id,
"job_type": JOB_TYPE_SBAS_RDC_DEM,
}
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 "already running" in message.lower() or "conflict" in message.lower() else 400
raise HTTPException(status_code=status_code, detail=message) from exc
@router.post("/runs/{run_id}/interferograms", status_code=202)
async def prepare_sbas_insar_interferograms(run_id: str, request: SbasInterferogramsRequest):
try:
return await asyncio.to_thread(
sbas_insar_production_service.prepare_interferograms,
run_id,
execute=request.execute,
rlks=request.rlks,
azlks=request.azlks,
unwrap_threshold=request.unwrap_threshold,
)
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}/interferograms/jobs", status_code=202)
async def submit_sbas_insar_interferograms_job(run_id: str, request: SbasInterferogramsJobRequest):
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 {"RDC_DEM_READY", "INTERFEROGRAMS_FAILED"}:
await asyncio.to_thread(
sbas_insar_production_service.prepare_interferograms,
run_id,
execute=False,
rlks=request.rlks,
azlks=request.azlks,
unwrap_threshold=request.unwrap_threshold,
)
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 {"INTERFEROGRAMS_SCRIPT_READY", "INTERFEROGRAMS_RUNNING"}:
raise ValueError(f"run status does not allow interferogram job submission: {status}")
if status == "INTERFEROGRAMS_RUNNING":
raise ValueError("interferograms are already running for this run")
from ..services.job_handlers import JOB_TYPE_SBAS_INTERFEROGRAMS
payload = {
"run_id": run_id,
"rlks": request.rlks,
"azlks": request.azlks,
"unwrap_threshold": request.unwrap_threshold,
"timeout_seconds": request.timeout_seconds,
}
task_id = await task_service.create_task(
task_type=JOB_TYPE_SBAS_INTERFEROGRAMS,
task_name=f"SBAS-InSAR Interferograms {run_id}",
params=payload,
)
job_id = await job_queue_service.create_job(
job_type=JOB_TYPE_SBAS_INTERFEROGRAMS,
payload=payload,
task_id=task_id,
max_attempts=1,
)
return {
"message": "SBAS-InSAR interferogram job queued.",
"run_id": run_id,
"task_id": task_id,
"job_id": job_id,
"job_type": JOB_TYPE_SBAS_INTERFEROGRAMS,
}
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 "already running" in message.lower() or "conflict" in message.lower() else 400
raise HTTPException(status_code=status_code, detail=message) from exc
@router.post("/runs/{run_id}/ipta-timeseries", status_code=202)
async def prepare_sbas_insar_ipta_timeseries(run_id: str, request: SbasIptaTimeseriesRequest):
try:
return await asyncio.to_thread(
sbas_insar_production_service.prepare_ipta_timeseries,
run_id,
execute=request.execute,
rlks=request.rlks,
reference_window=request.reference_window,
mb_mode=request.mb_mode,
)
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}/ipta-timeseries/jobs", status_code=202)
async def submit_sbas_insar_ipta_timeseries_job(run_id: str, request: SbasIptaTimeseriesJobRequest):
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 {"INTERFEROGRAMS_READY", "IPTA_TIMESERIES_FAILED"}:
await asyncio.to_thread(
sbas_insar_production_service.prepare_ipta_timeseries,
run_id,
execute=False,
rlks=request.rlks,
reference_window=request.reference_window,
mb_mode=request.mb_mode,
)
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 {"IPTA_TIMESERIES_SCRIPT_READY", "IPTA_TIMESERIES_RUNNING"}:
raise ValueError(f"run status does not allow IPTA time-series job submission: {status}")
if status == "IPTA_TIMESERIES_RUNNING":
raise ValueError("IPTA time-series is already running for this run")
from ..services.job_handlers import JOB_TYPE_SBAS_IPTA_TIMESERIES
payload = {
"run_id": run_id,
"rlks": request.rlks,
"reference_window": request.reference_window,
"mb_mode": request.mb_mode,
"timeout_seconds": request.timeout_seconds,
}
task_id = await task_service.create_task(
task_type=JOB_TYPE_SBAS_IPTA_TIMESERIES,
task_name=f"SBAS-InSAR IPTA Timeseries {run_id}",
params=payload,
)
job_id = await job_queue_service.create_job(
job_type=JOB_TYPE_SBAS_IPTA_TIMESERIES,
payload=payload,
task_id=task_id,
max_attempts=1,
)
return {
"message": "SBAS-InSAR IPTA time-series job queued.",
"run_id": run_id,
"task_id": task_id,
"job_id": job_id,
"job_type": JOB_TYPE_SBAS_IPTA_TIMESERIES,
}
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 "already running" in message.lower() 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:
+4
View File
@@ -1272,6 +1272,7 @@ async def _check_wsl_runtime() -> Dict[str, Any]:
required_by_engine = {
"isce2": bool(settings.ISCE2_ENABLED or settings.TIMESERIES_ENABLED),
"pyint": bool(settings.PYINT_ENABLED),
"gamma": bool(settings.GAMMA_SBAS_ENABLED),
}
for runtime in wsl_runtime_registry.runtimes.values():
required = bool(required_by_engine.get(runtime.engine_code, False))
@@ -1285,6 +1286,9 @@ async def _check_wsl_runtime() -> Dict[str, Any]:
distro_matches_shared = str(runtime.distro or "").strip() == str(
wsl_runtime_registry.shared_distro or ""
).strip()
if runtime.engine_code == "gamma":
python_matches_shared = bool(str(runtime.python_path or "").strip())
distro_matches_shared = bool(str(runtime.distro or "").strip())
runtime_ok = runner_exists and python_matches_shared and distro_matches_shared
if env_profile_exists is False and required:
runtime_ok = False
+348
View File
@@ -92,6 +92,10 @@ JOB_TYPE_REBUILD_DINSAR_CATALOG = "REBUILD_DINSAR_CATALOG"
JOB_TYPE_REBUILD_PSINSAR_CATALOG = "REBUILD_PSINSAR_CATALOG"
JOB_TYPE_SCAN_ASSET_INVENTORY = "SCAN_ASSET_INVENTORY"
JOB_TYPE_SBAS_COREGISTRATION = "SBAS_COREGISTRATION"
JOB_TYPE_SBAS_RDC_DEM = "SBAS_RDC_DEM"
JOB_TYPE_SBAS_INTERFEROGRAMS = "SBAS_INTERFEROGRAMS"
JOB_TYPE_SBAS_IPTA_TIMESERIES = "SBAS_IPTA_TIMESERIES"
JOB_TYPE_SBAS_GAMMA_WORKFLOW = "SBAS_GAMMA_WORKFLOW"
COPY_ALLOWED_STATUSES = {"PENDING", "IN_PROGRESS", "COMPLETED", "FAILED"}
@@ -4263,6 +4267,346 @@ async def _handle_sbas_coregistration(job: SystemJobORM) -> None:
)
async def _handle_sbas_rdc_dem(job: SystemJobORM) -> None:
if not job.task_id:
raise ValueError("SBAS_RDC_DEM requires task_id for progress tracking.")
payload = job.payload or {}
run_id = str(payload.get("run_id") or "").strip()
if not run_id:
raise ValueError("SBAS_RDC_DEM requires run_id payload.")
rlks = _normalize_positive_int(payload.get("rlks")) or 8
timeout_seconds = _normalize_positive_int(payload.get("timeout_seconds")) or 43200
await task_service.start_task(job.task_id, message="正在执行 SBAS-InSAR Gamma RDC DEM...")
await task_service.update_task(
job.task_id,
progress=5,
message=f"准备运行 Gamma gc_map1/gc_map_fine: run_id={run_id}",
)
await task_service.add_log(
job.task_id,
"INFO",
f"SBAS RDC DEM queued: run_id={run_id}, rlks={rlks}, timeout={timeout_seconds}s",
)
from .sbas_insar_production_service import sbas_insar_production_service
async def _task_keepalive() -> None:
progress = 12
while True:
await asyncio.sleep(60)
progress = min(88, progress + 2)
await task_service.update_task(
job.task_id,
progress=progress,
message=f"Gamma RDC DEM 仍在运行: run_id={run_id}",
)
runner_task = asyncio.create_task(
asyncio.to_thread(
sbas_insar_production_service.execute_rdc_dem,
run_id,
rlks=rlks,
timeout_seconds=timeout_seconds,
)
)
keepalive_task = asyncio.create_task(_task_keepalive())
try:
result = await runner_task
finally:
keepalive_task.cancel()
try:
await keepalive_task
except asyncio.CancelledError:
pass
manifest = result.get("manifest") or {}
run = result.get("run") or {}
summary = (manifest.get("rdc_dem") or {}).get("summary") or {}
status = str(run.get("status") or manifest.get("status") or "").strip()
if status != "RDC_DEM_READY":
raise RuntimeError(
"SBAS RDC DEM failed: "
f"status={status or 'UNKNOWN'}, "
f"missing_outputs={summary.get('missing_outputs') or []}"
)
await task_service.update_task(
job.task_id,
status="COMPLETED",
progress=100,
message=(
"SBAS-InSAR RDC DEM 完成: "
f"rdc_dem={((summary.get('outputs') or {}).get('rdc_dem') or {}).get('path') or '-'}"
),
)
async def _handle_sbas_interferograms(job: SystemJobORM) -> None:
if not job.task_id:
raise ValueError("SBAS_INTERFEROGRAMS requires task_id for progress tracking.")
payload = job.payload or {}
run_id = str(payload.get("run_id") or "").strip()
if not run_id:
raise ValueError("SBAS_INTERFEROGRAMS requires run_id payload.")
rlks = _normalize_positive_int(payload.get("rlks")) or 8
azlks = _normalize_positive_int(payload.get("azlks")) or 8
timeout_seconds = _normalize_positive_int(payload.get("timeout_seconds")) or 43200
try:
unwrap_threshold = float(payload.get("unwrap_threshold") or 0.20)
except (TypeError, ValueError):
unwrap_threshold = 0.20
await task_service.start_task(job.task_id, message="正在执行 SBAS-InSAR Gamma 差分干涉图...")
await task_service.update_task(
job.task_id,
progress=5,
message=f"准备运行 Gamma phase_sim_orb/SLC_diff_intf/mcf: run_id={run_id}",
)
await task_service.add_log(
job.task_id,
"INFO",
(
f"SBAS interferograms queued: run_id={run_id}, rlks={rlks}, azlks={azlks}, "
f"unwrap_threshold={unwrap_threshold}, timeout={timeout_seconds}s"
),
)
from .sbas_insar_production_service import sbas_insar_production_service
async def _task_keepalive() -> None:
progress = 12
while True:
await asyncio.sleep(60)
progress = min(88, progress + 2)
await task_service.update_task(
job.task_id,
progress=progress,
message=f"Gamma 差分干涉图仍在运行: run_id={run_id}",
)
runner_task = asyncio.create_task(
asyncio.to_thread(
sbas_insar_production_service.execute_interferograms,
run_id,
rlks=rlks,
azlks=azlks,
unwrap_threshold=unwrap_threshold,
timeout_seconds=timeout_seconds,
)
)
keepalive_task = asyncio.create_task(_task_keepalive())
try:
result = await runner_task
finally:
keepalive_task.cancel()
try:
await keepalive_task
except asyncio.CancelledError:
pass
manifest = result.get("manifest") or {}
run = result.get("run") or {}
summary = (manifest.get("interferograms") or {}).get("summary") or {}
status = str(run.get("status") or manifest.get("status") or "").strip()
if status != "INTERFEROGRAMS_READY":
raise RuntimeError(
"SBAS interferograms failed: "
f"status={status or 'UNKNOWN'}, "
f"missing_pairs={summary.get('missing_pairs') or []}, "
f"missing_tabs={summary.get('missing_tabs') or []}"
)
await task_service.update_task(
job.task_id,
status="COMPLETED",
progress=100,
message=(
"SBAS-InSAR 差分干涉图完成: "
f"{summary.get('ready_pair_count', 0)}/{summary.get('pair_count', 0)} pairs ready"
),
)
async def _handle_sbas_ipta_timeseries(job: SystemJobORM) -> None:
if not job.task_id:
raise ValueError("SBAS_IPTA_TIMESERIES requires task_id for progress tracking.")
payload = job.payload or {}
run_id = str(payload.get("run_id") or "").strip()
if not run_id:
raise ValueError("SBAS_IPTA_TIMESERIES requires run_id payload.")
rlks = _normalize_positive_int(payload.get("rlks")) or 8
reference_window = _normalize_positive_int(payload.get("reference_window")) or 16
try:
mb_mode = int(payload.get("mb_mode") or 0)
except (TypeError, ValueError):
mb_mode = 0
if mb_mode not in {0, 1, 2}:
mb_mode = 0
timeout_seconds = _normalize_positive_int(payload.get("timeout_seconds")) or 43200
await task_service.start_task(job.task_id, message="Running SBAS-InSAR Gamma IPTA time-series inversion...")
await task_service.update_task(
job.task_id,
progress=5,
message=f"Preparing Gamma mb/ts_rate: run_id={run_id}",
)
await task_service.add_log(
job.task_id,
"INFO",
(
f"SBAS IPTA time-series queued: run_id={run_id}, rlks={rlks}, "
f"reference_window={reference_window}, mb_mode={mb_mode}, "
f"timeout={timeout_seconds}s"
),
)
from .sbas_insar_production_service import sbas_insar_production_service
async def _task_keepalive() -> None:
progress = 12
while True:
await asyncio.sleep(60)
progress = min(88, progress + 2)
await task_service.update_task(
job.task_id,
progress=progress,
message=f"Gamma IPTA mb/ts_rate still running: run_id={run_id}",
)
runner_task = asyncio.create_task(
asyncio.to_thread(
sbas_insar_production_service.execute_ipta_timeseries,
run_id,
rlks=rlks,
reference_window=reference_window,
mb_mode=mb_mode,
timeout_seconds=timeout_seconds,
)
)
keepalive_task = asyncio.create_task(_task_keepalive())
try:
result = await runner_task
finally:
keepalive_task.cancel()
try:
await keepalive_task
except asyncio.CancelledError:
pass
manifest = result.get("manifest") or {}
run = result.get("run") or {}
summary = (manifest.get("ipta_timeseries") or {}).get("summary") or {}
status = str(run.get("status") or manifest.get("status") or "").strip()
if status != "IPTA_TIMESERIES_READY":
raise RuntimeError(
"SBAS IPTA time-series failed: "
f"status={status or 'UNKNOWN'}, "
f"missing_outputs={summary.get('missing_outputs') or []}"
)
await task_service.update_task(
job.task_id,
status="COMPLETED",
progress=100,
message=(
"SBAS-InSAR IPTA time-series complete: "
f"ts_rate={((summary.get('outputs') or {}).get('ts_rate') or {}).get('path') or '-'}"
),
)
async def _handle_sbas_gamma_workflow(job: SystemJobORM) -> None:
if not job.task_id:
raise ValueError("SBAS_GAMMA_WORKFLOW requires task_id for progress tracking.")
payload = job.payload or {}
run_id = str(payload.get("run_id") or "").strip()
if not run_id:
raise ValueError("SBAS_GAMMA_WORKFLOW requires run_id payload.")
from_step = str(payload.get("from_step") or "").strip() or None
to_step = str(payload.get("to_step") or "").strip() or None
only_steps_raw = payload.get("only_steps") or []
only_steps = [str(item).strip() for item in only_steps_raw if str(item).strip()] if isinstance(only_steps_raw, list) else []
force = bool(payload.get("force", False))
timeout_seconds = _normalize_positive_int(payload.get("timeout_seconds")) or int(settings.GAMMA_SBAS_WORKFLOW_TIMEOUT_SECONDS)
await task_service.start_task(job.task_id, message="Running Gamma SBAS expert workflow...")
await task_service.update_task(
job.task_id,
progress=5,
message=f"Preparing Gamma SBAS manifest runner: run_id={run_id}",
)
await task_service.add_log(
job.task_id,
"INFO",
(
f"SBAS Gamma workflow queued: run_id={run_id}, from={from_step or '-'}, "
f"to={to_step or '-'}, only={only_steps or '-'}, force={force}, timeout={timeout_seconds}s"
),
)
from .sbas_insar_production_service import sbas_insar_production_service
async def _task_keepalive() -> None:
progress = 10
while True:
await asyncio.sleep(60)
progress = min(92, progress + 2)
await task_service.update_task(
job.task_id,
progress=progress,
message="Gamma SBAS workflow is still running...",
)
runner_task = asyncio.create_task(
asyncio.to_thread(
sbas_insar_production_service.execute_workflow,
run_id,
from_step=from_step,
to_step=to_step,
only_steps=only_steps,
force=force,
timeout_seconds=timeout_seconds,
)
)
keepalive_task = asyncio.create_task(_task_keepalive())
try:
result = await runner_task
finally:
keepalive_task.cancel()
try:
await keepalive_task
except asyncio.CancelledError:
pass
run = result.get("run") or {}
manifest = result.get("manifest") or {}
workflow = manifest.get("workflow") or {}
summary = workflow.get("summary") or {}
status = str(run.get("status") or manifest.get("status") or "").strip()
if status not in {"WORKFLOW_COMPLETED", "WORKFLOW_PARTIAL"}:
raise RuntimeError(
"SBAS Gamma workflow failed: "
f"status={status or 'UNKNOWN'}, failed_steps={summary.get('failed_count') or 0}"
)
await task_service.update_task(
job.task_id,
status="COMPLETED",
progress=100,
message=(
f"Gamma SBAS expert workflow {status.lower()}: "
f"completed={summary.get('completed_count', 0)}, "
f"skipped={summary.get('skipped_count', 0)}, "
f"planned={summary.get('planned_count', 0)}"
),
)
_HANDLERS = {
JOB_TYPE_SCAN_DATA: _handle_scan_data,
JOB_TYPE_SCAN_ASSET_INVENTORY: _handle_scan_asset_inventory,
@@ -4301,6 +4645,10 @@ _HANDLERS = {
JOB_TYPE_GF3_UNPACK: _handle_gf3_unpack,
JOB_TYPE_GF3_BATCH_PROCESS: _handle_gf3_batch_process,
JOB_TYPE_SBAS_COREGISTRATION: _handle_sbas_coregistration,
JOB_TYPE_SBAS_RDC_DEM: _handle_sbas_rdc_dem,
JOB_TYPE_SBAS_INTERFEROGRAMS: _handle_sbas_interferograms,
JOB_TYPE_SBAS_IPTA_TIMESERIES: _handle_sbas_ipta_timeseries,
JOB_TYPE_SBAS_GAMMA_WORKFLOW: _handle_sbas_gamma_workflow,
}
File diff suppressed because it is too large Load Diff
@@ -112,6 +112,7 @@ def build_wsl_runtime_registry() -> WslRuntimeRegistry:
isce2_runner_windows = _project_file_windows("deploy", "wsl", "runners", "isce2_runner.py")
gamma_runner_windows = _project_file_windows("deploy", "wsl", "runners", "gamma_pyint_runner.py")
gamma_sbas_runner_windows = _project_file_windows("deploy", "wsl", "runners", "gamma_sbas_runner.py")
gamma_profile_windows = _project_file_windows("deploy", "wsl", "profiles", "gamma_env.sh")
runtimes = {
@@ -151,6 +152,33 @@ def build_wsl_runtime_registry() -> WslRuntimeRegistry:
"legacy_profile_env_var": "PYINT_GAMMA_ENV_SCRIPT",
},
),
settings.GAMMA_SBAS_RUNTIME_ID: WslRuntimeDefinition(
runtime_id=settings.GAMMA_SBAS_RUNTIME_ID,
engine_code="gamma",
display_name="Gamma SBAS Runtime V1",
distro=str(settings.GAMMA_SBAS_WSL_DISTRO or shared_distro).strip() or shared_distro,
conda_env_name=shared_conda_env,
python_path=str(settings.GAMMA_SBAS_PYTHON or shared_python_path).strip() or shared_python_path,
runner_path_windows=gamma_sbas_runner_windows,
runner_path_wsl=_windows_path_to_wsl_mount(gamma_sbas_runner_windows),
allowed_operations=("lt1_gamma_sbas_workflow", "lt1_gamma_sbas_step"),
env_profile_path_windows=str(settings.GAMMA_SBAS_ENV_SCRIPT or gamma_profile_windows).strip(),
env_profile_path_wsl=_windows_path_to_wsl_mount(
str(settings.GAMMA_SBAS_ENV_SCRIPT or gamma_profile_windows).strip()
),
metadata_json={
"shared_runtime": True,
"workflow_code": "sbas_insar",
"processor_code": "gamma_ipta_sbas",
"env_vars": {
"runtime_id": "GAMMA_SBAS_RUNTIME_ID",
"python": "GAMMA_SBAS_PYTHON",
"env_profile": "GAMMA_SBAS_ENV_SCRIPT",
"work_root": "GAMMA_SBAS_WORK_ROOT",
"product_root": "GAMMA_SBAS_PRODUCT_ROOT",
},
},
),
}
return WslRuntimeRegistry(
@@ -0,0 +1,369 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import json
import math
import re
import struct
from datetime import datetime
from pathlib import Path
from typing import Any
import numpy as np
def read_gamma_value(path: Path, key: str) -> str:
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
parts = line.split()
if parts and parts[0].rstrip(":") == key.rstrip(":"):
return parts[1]
raise KeyError(f"{key} not found in {path}")
def read_float32(path: Path, shape: tuple[int, int] | None = None) -> np.ndarray:
data = np.fromfile(path, dtype=">f4")
if shape is not None:
data = data.reshape(shape)
return data
def read_float32_pixel(path: Path, width: int, x: int, y: int) -> float:
with path.open("rb") as handle:
handle.seek((y * width + x) * 4)
chunk = handle.read(4)
if len(chunk) != 4:
return float("nan")
return float(struct.unpack(">f", chunk)[0])
def write_scaled_float32(input_path: Path, output_path: Path, scale: float) -> None:
data = np.fromfile(input_path, dtype=">f4")
output_path.parent.mkdir(parents=True, exist_ok=True)
(data * float(scale)).astype(">f4", copy=False).tofile(output_path)
def pick_auto_point(rate: np.ndarray, sigma: np.ndarray) -> tuple[int, int]:
lines, width = rate.shape
yy, xx = np.indices(rate.shape)
edge_mask = (
(xx > width * 0.1)
& (xx < width * 0.9)
& (yy > lines * 0.1)
& (yy < lines * 0.9)
)
finite = np.isfinite(rate) & np.isfinite(sigma)
valid = finite & edge_mask & (rate != 0.0) & (sigma > 0.0)
if not valid.any():
raise RuntimeError("No valid pixels available for monitor point selection")
abs_rate = np.abs(rate[valid])
sig = sigma[valid]
rate_min = np.percentile(abs_rate, 85)
rate_max = np.percentile(abs_rate, 99)
sigma_max = np.percentile(sig, 40)
candidate = valid & (np.abs(rate) >= rate_min) & (np.abs(rate) <= rate_max) & (sigma <= sigma_max)
if not candidate.any():
candidate = valid
score = np.zeros(rate.shape, dtype=np.float32)
score[candidate] = np.abs(rate[candidate]) / (sigma[candidate] + 1.0e-6)
y, x = np.unravel_index(int(np.argmax(score)), rate.shape)
return int(x), int(y)
def dem_grid(dem_par: Path) -> dict[str, float | int]:
return {
"width": int(read_gamma_value(dem_par, "width")),
"nlines": int(read_gamma_value(dem_par, "nlines")),
"corner_lon": float(read_gamma_value(dem_par, "corner_lon")),
"corner_lat": float(read_gamma_value(dem_par, "corner_lat")),
"post_lon": float(read_gamma_value(dem_par, "post_lon")),
"post_lat": float(read_gamma_value(dem_par, "post_lat")),
}
def radar_to_lonlat(x: int, y: int, dem_par: Path, lookup: Path) -> tuple[float | None, float | None]:
grid = dem_grid(dem_par)
width = int(grid["width"])
lines = int(grid["nlines"])
lut = np.fromfile(lookup, dtype=">c8").reshape((lines, width))
rng = lut.real
az = lut.imag
valid = np.isfinite(rng) & np.isfinite(az) & (rng > 0.0) & (az > 0.0)
if not valid.any():
return None, None
distance = np.full(rng.shape, np.inf, dtype=np.float32)
distance[valid] = (rng[valid] - float(x)) ** 2 + (az[valid] - float(y)) ** 2
gy, gx = np.unravel_index(int(np.argmin(distance)), distance.shape)
lon = float(grid["corner_lon"]) + (gx + 0.5) * float(grid["post_lon"])
lat = float(grid["corner_lat"]) + (gy + 0.5) * float(grid["post_lat"])
return float(lon), float(lat)
def lonlat_to_radar(lon: float, lat: float, dem_par: Path, lookup: Path) -> tuple[int, int]:
grid = dem_grid(dem_par)
width = int(grid["width"])
lines = int(grid["nlines"])
gx = int(round((lon - float(grid["corner_lon"])) / float(grid["post_lon"]) - 0.5))
gy = int(round((lat - float(grid["corner_lat"])) / float(grid["post_lat"]) - 0.5))
gx = max(0, min(width - 1, gx))
gy = max(0, min(lines - 1, gy))
lut = np.fromfile(lookup, dtype=">c8").reshape((lines, width))
value = lut[gy, gx]
if not (np.isfinite(value.real) and np.isfinite(value.imag) and value.real > 0 and value.imag > 0):
raise RuntimeError(f"manual lon/lat maps to invalid lookup pixel: lon={lon}, lat={lat}")
return int(round(float(value.real))), int(round(float(value.imag)))
def safe_point_id(value: str, fallback: str) -> str:
text = str(value or "").strip() or fallback
text = re.sub(r"[^A-Za-z0-9_.-]+", "_", text)[:64]
return text or fallback
def load_diff_files(timeseries_dir: Path) -> list[Path]:
tab = timeseries_dir / "diff_ts.tab"
if tab.is_file():
rows = [line.strip() for line in tab.read_text(encoding="utf-8", errors="replace").splitlines() if line.strip()]
files = [Path(row.split()[0]) for row in rows if row.split()]
files = [path for path in files if path.is_file()]
if files:
return files
return sorted(timeseries_dir.glob("diff_ts_*.diff"))
def point_records(
diff_files: list[Path],
*,
dates: list[str],
width: int,
x: int,
y: int,
scale_mm: float,
) -> list[dict[str, Any]]:
records: list[dict[str, Any]] = []
for index, path in enumerate(diff_files):
phase = read_float32_pixel(path, width, x, y)
away_mm = float(phase * scale_mm) if math.isfinite(phase) else float("nan")
records.append(
{
"date": dates[index] if index < len(dates) else f"step_{index + 1:03d}",
"phase_rad": float(phase),
"los_away_mm": away_mm,
"los_toward_mm": -away_mm if math.isfinite(away_mm) else float("nan"),
}
)
return records
def write_point_outputs(
point_dir: Path,
point: dict[str, Any],
*,
records: list[dict[str, Any]],
rate_value: float,
sigma_value: float,
wavelength: float,
reference_date: str,
) -> dict[str, str]:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
point_id = str(point["point_id"])
csv_path = point_dir / f"{point_id}_timeseries.csv"
json_path = point_dir / f"{point_id}_metadata.json"
png_path = point_dir / f"{point_id}_timeseries.png"
with csv_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=["date", "phase_rad", "los_away_mm", "los_toward_mm"])
writer.writeheader()
writer.writerows(records)
metadata = {
"schema": "insar.sbas-monitor-point/v1",
"point_id": point_id,
"selection": point.get("selection"),
"radar_pixel": {"range": int(point["range_pixel"]), "azimuth": int(point["azimuth_line"])},
"approx_lonlat": {"lon": point.get("lon"), "lat": point.get("lat")},
"reference_date": reference_date,
"los_convention": "toward radar positive; away from radar negative",
"los_rate_toward_mm_per_year": rate_value,
"los_sigma_mm_per_year": sigma_value,
"wavelength_m": wavelength,
"records": records,
}
json_path.write_text(json.dumps(metadata, indent=2, ensure_ascii=False), encoding="utf-8")
dates = [record["date"] for record in records]
disp = [record["los_toward_mm"] for record in records]
plt.figure(figsize=(8.0, 4.6), dpi=160)
plt.plot(dates, disp, marker="o", linewidth=2.0, color="#1f77b4")
plt.axhline(0, color="#666666", linewidth=0.8)
plt.grid(True, color="#dddddd", linewidth=0.7)
plt.title(
f"LOS displacement time series ({point_id})\n"
f"toward radar positive, rate={rate_value:.2f} mm/yr, sigma={sigma_value:.2f} mm/yr",
fontsize=10,
)
plt.xlabel("Date")
plt.ylabel("LOS displacement (mm)")
plt.tight_layout()
plt.savefig(png_path)
plt.close()
return {"png": str(png_path), "csv": str(csv_path), "metadata": str(json_path)}
def run_phase_to_los(args: argparse.Namespace) -> int:
write_scaled_float32(Path(args.input), Path(args.output), float(args.scale))
return 0
def run_monitor_points(args: argparse.Namespace) -> int:
timeseries_dir = Path(args.timeseries_dir)
export_dir = Path(args.export_dir)
point_dir = Path(args.point_dir)
mli_par = Path(args.mli_par)
slc_par = Path(args.slc_par) if args.slc_par else mli_par
dem_par = Path(args.dem_par)
lookup = Path(args.lookup)
monitor_config_path = Path(args.monitor_config)
summary_path = Path(args.summary_path)
point_dir.mkdir(parents=True, exist_ok=True)
width = int(read_gamma_value(mli_par, "range_samples"))
lines = int(read_gamma_value(mli_par, "azimuth_lines"))
shape = (lines, width)
dates = [item.strip() for item in str(args.dates or "").split(",") if item.strip()]
reference_date = str(args.reference_date or "").strip()
radar_freq = float(read_gamma_value(slc_par, "radar_frequency"))
wavelength = 299792458.0 / radar_freq
scale_mm = wavelength / (4.0 * math.pi) * 1000.0
rate_toward = read_float32(export_dir / "los_rate_toward_mm_per_year.rdc", shape)
sigma = read_float32(export_dir / "los_sigma_mm_per_year.rdc", shape)
diff_files = load_diff_files(timeseries_dir)
if not diff_files:
raise RuntimeError(f"No diff_ts files found in {timeseries_dir}")
config = {}
if monitor_config_path.is_file():
config = json.loads(monitor_config_path.read_text(encoding="utf-8"))
mode = str(config.get("mode") or "auto_low_sigma_high_rate")
selected_points: list[dict[str, Any]] = []
if mode == "manual_lonlat" and config.get("points"):
for index, raw in enumerate(config.get("points") or []):
lon = float(raw["lon"])
lat = float(raw["lat"])
x, y = lonlat_to_radar(lon, lat, dem_par, lookup)
selected_points.append(
{
"point_id": safe_point_id(raw.get("point_id"), f"manual_{index + 1:03d}"),
"selection": "manual_lonlat_nearest_lookup_pixel",
"range_pixel": x,
"azimuth_line": y,
"lon": lon,
"lat": lat,
}
)
else:
x, y = pick_auto_point(rate_toward, sigma)
lon, lat = radar_to_lonlat(x, y, dem_par, lookup)
selected_points.append(
{
"point_id": "auto_low_sigma_high_rate",
"selection": "automatic_low_sigma_high_rate_non_edge",
"range_pixel": x,
"azimuth_line": y,
"lon": lon,
"lat": lat,
}
)
outputs: list[dict[str, Any]] = []
for point in selected_points:
x = int(point["range_pixel"])
y = int(point["azimuth_line"])
if not (0 <= x < width and 0 <= y < lines):
raise ValueError(f"pixel out of bounds: x={x}, y={y}, width={width}, lines={lines}")
records = point_records(diff_files, dates=dates, width=width, x=x, y=y, scale_mm=scale_mm)
rate_value = float(rate_toward[y, x])
sigma_value = float(sigma[y, x])
files = write_point_outputs(
point_dir,
point,
records=records,
rate_value=rate_value,
sigma_value=sigma_value,
wavelength=wavelength,
reference_date=reference_date,
)
outputs.append(
{
**point,
"los_rate_toward_mm_per_year": rate_value,
"los_sigma_mm_per_year": sigma_value,
"record_count": len(records),
"files": files,
}
)
summary = {
"schema": "insar.gamma-sbas-monitor-points-summary/v1",
"generated_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
"ready": bool(outputs),
"mode": mode,
"reference_date": reference_date,
"width": width,
"lines": lines,
"wavelength_m": wavelength,
"diff_ts_count": len(diff_files),
"date_count": len(dates),
"monitor_points": outputs,
}
summary_path.parent.mkdir(parents=True, exist_ok=True)
summary_path.write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8")
print(json.dumps(summary, indent=2, ensure_ascii=False))
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Gamma SBAS product helper tools")
subparsers = parser.add_subparsers(dest="command", required=True)
phase = subparsers.add_parser("phase-to-los")
phase.add_argument("input")
phase.add_argument("output")
phase.add_argument("scale", type=float)
phase.set_defaults(func=run_phase_to_los)
monitor = subparsers.add_parser("monitor-points")
monitor.add_argument("--monitor-config", required=True)
monitor.add_argument("--timeseries-dir", required=True)
monitor.add_argument("--export-dir", required=True)
monitor.add_argument("--point-dir", required=True)
monitor.add_argument("--mli-par", required=True)
monitor.add_argument("--slc-par", required=True)
monitor.add_argument("--dem-par", required=True)
monitor.add_argument("--lookup", required=True)
monitor.add_argument("--dates", default="")
monitor.add_argument("--reference-date", default="")
monitor.add_argument("--summary-path", required=True)
monitor.set_defaults(func=run_monitor_points)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
return int(args.func(args))
if __name__ == "__main__":
raise SystemExit(main())
+259
View File
@@ -0,0 +1,259 @@
from __future__ import annotations
import argparse
import json
import os
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
TERMINAL_STATUSES = {"COMPLETED", "FAILED", "SKIPPED"}
def _utcnow() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def _load_json(path: str | Path) -> dict[str, Any]:
return json.loads(Path(path).read_text(encoding="utf-8"))
def _write_json(path: str | Path, payload: dict[str, Any]) -> None:
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def _tail(text: str, limit: int = 4000) -> str:
if not text:
return ""
return text[-limit:]
def _normalize_step_ids(value: str | None) -> set[str]:
text = str(value or "").strip()
if not text:
return set()
return {item.strip() for item in text.replace(";", ",").split(",") if item.strip()}
def _load_workflow_manifest(broker_manifest: dict[str, Any]) -> tuple[dict[str, Any], Path]:
payload = broker_manifest.get("payload") or {}
workflow_manifest_path = payload.get("workflow_manifest_wsl") or payload.get("workflow_manifest")
if not workflow_manifest_path:
raise ValueError("payload.workflow_manifest_wsl is required")
manifest_path = Path(str(workflow_manifest_path))
return _load_json(manifest_path), manifest_path
def _state_path(workflow_manifest: dict[str, Any]) -> Path:
state = workflow_manifest.get("state") or {}
explicit = state.get("step_status_path")
if explicit:
return Path(str(explicit))
run_root = Path(str(workflow_manifest.get("run_root_wsl") or workflow_manifest.get("run_root") or "."))
return run_root / "state" / "step_status.json"
def _script_env(workflow_manifest: dict[str, Any], step: dict[str, Any]) -> dict[str, str]:
env = os.environ.copy()
run_root = str(workflow_manifest.get("run_root_wsl") or workflow_manifest.get("run_root") or "")
params = workflow_manifest.get("params") or {}
env.update(
{
"GAMMA_SBAS_RUN_ROOT": run_root,
"GAMMA_SBAS_MANIFEST": str(workflow_manifest.get("manifest_path_wsl") or ""),
"GAMMA_SBAS_STEP_ID": str(step.get("id") or ""),
"GAMMA_SBAS_STEP_NAME": str(step.get("name") or step.get("id") or ""),
"GAMMA_SBAS_RLKS": str(params.get("rlks") or ""),
"GAMMA_SBAS_AZLKS": str(params.get("azlks") or ""),
"GAMMA_SBAS_MB_MODE": str(params.get("mb_mode") or ""),
"GAMMA_SBAS_REFERENCE_WINDOW": str(params.get("reference_window") or ""),
}
)
for key, value in (step.get("env") or {}).items():
env[str(key)] = str(value)
return env
def _selected_steps(steps: list[dict[str, Any]], only_steps: set[str], from_step: str | None, to_step: str | None) -> list[dict[str, Any]]:
if only_steps:
return [step for step in steps if str(step.get("id") or "") in only_steps]
if not from_step and not to_step:
return steps
selected: list[dict[str, Any]] = []
active = from_step is None
for step in steps:
step_id = str(step.get("id") or "")
if step_id == from_step:
active = True
if active:
selected.append(step)
if step_id == to_step:
break
return selected
def _run_step(
workflow_manifest: dict[str, Any],
step: dict[str, Any],
*,
state: dict[str, Any],
force: bool,
dry_run: bool,
timeout_seconds: int,
) -> dict[str, Any]:
step_id = str(step.get("id") or "").strip()
if not step_id:
raise ValueError("workflow step id must not be empty")
if step.get("enabled") is False:
return {
"id": step_id,
"name": step.get("name") or step_id,
"status": "SKIPPED",
"skipped_reason": "step disabled in workflow manifest",
"started_at": _utcnow(),
"ended_at": _utcnow(),
}
previous = (state.get("steps") or {}).get(step_id) or {}
if previous.get("status") == "COMPLETED" and not force:
return {**previous, "status": "SKIPPED", "skipped_reason": "already completed"}
script = Path(str(step.get("script_wsl") or step.get("script") or ""))
if not script.is_file():
raise FileNotFoundError(f"step script not found: {script}")
log_path = Path(str(step.get("log_wsl") or step.get("log") or ""))
if not log_path:
run_root = Path(str(workflow_manifest.get("run_root_wsl") or "."))
log_path = run_root / "logs" / f"{step_id}.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
started_at = _utcnow()
if dry_run:
return {
"id": step_id,
"name": step.get("name") or step_id,
"status": "DRY_RUN",
"script": str(script),
"log": str(log_path),
"started_at": started_at,
"ended_at": _utcnow(),
"returncode": None,
}
proc = subprocess.run(
["bash", str(script)],
cwd=str(script.parent),
text=True,
capture_output=True,
timeout=timeout_seconds,
check=False,
env=_script_env(workflow_manifest, step),
)
log_path.write_text(
"\n".join(
[
f"# step={step_id}",
f"# started_at={started_at}",
f"# ended_at={_utcnow()}",
f"# returncode={proc.returncode}",
"",
"## stdout",
proc.stdout or "",
"",
"## stderr",
proc.stderr or "",
]
),
encoding="utf-8",
)
return {
"id": step_id,
"name": step.get("name") or step_id,
"status": "COMPLETED" if proc.returncode == 0 else "FAILED",
"script": str(script),
"log": str(log_path),
"started_at": started_at,
"ended_at": _utcnow(),
"returncode": proc.returncode,
"stdout_tail": _tail(proc.stdout),
"stderr_tail": _tail(proc.stderr),
}
def main() -> int:
parser = argparse.ArgumentParser(description="Gamma SBAS manifest runner.")
parser.add_argument("--manifest", required=True, help="WSL path to broker manifest.")
parser.add_argument("--from-step", default="", help="First workflow step id to execute.")
parser.add_argument("--to-step", default="", help="Last workflow step id to execute.")
parser.add_argument("--only-steps", default="", help="Comma-separated step ids to execute.")
parser.add_argument("--force", action="store_true", help="Run completed steps again.")
parser.add_argument("--dry-run", action="store_true", help="Validate manifest and write dry-run state.")
parser.add_argument("--timeout-seconds", type=int, default=43200)
args = parser.parse_args()
broker_manifest = _load_json(args.manifest)
workflow_manifest, workflow_manifest_path = _load_workflow_manifest(broker_manifest)
workflow_manifest["manifest_path_wsl"] = str(workflow_manifest_path)
state_file = _state_path(workflow_manifest)
state = _load_json(state_file) if state_file.is_file() else {
"schema": "insar.gamma-sbas-step-status/v1",
"run_id": workflow_manifest.get("run_id"),
"steps": {},
}
state.setdefault("steps", {})
state["updated_at"] = _utcnow()
state["runner_manifest"] = args.manifest
all_steps = list(workflow_manifest.get("steps") or [])
selected = _selected_steps(
all_steps,
_normalize_step_ids(args.only_steps),
str(args.from_step or "").strip() or None,
str(args.to_step or "").strip() or None,
)
if not selected:
raise ValueError("no workflow steps selected")
overall_rc = 0
executed: list[str] = []
for step in selected:
step_id = str(step.get("id") or "")
result = _run_step(
workflow_manifest,
step,
state=state,
force=args.force,
dry_run=args.dry_run,
timeout_seconds=max(60, int(args.timeout_seconds or 43200)),
)
state["steps"][step_id] = result
state["updated_at"] = _utcnow()
_write_json(state_file, state)
executed.append(step_id)
if result.get("status") == "FAILED":
overall_rc = int(result.get("returncode") or 1)
break
summary = {
"runner": "gamma_sbas_runtime_v1",
"operation": broker_manifest.get("operation"),
"workflow_manifest": str(workflow_manifest_path),
"state_path": str(state_file),
"executed_steps": executed,
"returncode": overall_rc,
"dry_run": bool(args.dry_run),
}
print(json.dumps(summary, ensure_ascii=False))
return overall_rc
if __name__ == "__main__":
raise SystemExit(main())
+3 -1
View File
@@ -55,6 +55,8 @@
- [SBAS_INSAR_PRODUCTION_PIPELINE_DESIGN_20260519.md](SBAS_INSAR_PRODUCTION_PIPELINE_DESIGN_20260519.md)
Gamma/LT1 SBAS-InSAR 独立生产页面与统一流水线收口设计。
- [SBAS_INSAR_GEOJSON_RESULT_MANAGEMENT_DESIGN_20260527.md](SBAS_INSAR_GEOJSON_RESULT_MANAGEMENT_DESIGN_20260527.md)
SBAS-InSAR GeoJSON 覆盖范围、行政区/AOI 按需生产、独立结果管理和产品 catalog 设计。
- [TIMESERIES_LEGACY_DEPRECATION_20260521.md](TIMESERIES_LEGACY_DEPRECATION_20260521.md)
旧 ISCE2/MintPy 时序生产链路停用记录,定义当前入口隐藏、配置默认关闭和后续物理删除条件。
- [GAMMA_IPTA_LT1_SBAS_TRIAL_RUNBOOK_20260518.md](GAMMA_IPTA_LT1_SBAS_TRIAL_RUNBOOK_20260518.md)
@@ -137,4 +139,4 @@
- 已归档的各类 `*_EXPERIMENT_*` / `*_PROGRESS_*` / `*_TODO_*`
最后更新:2026-05-15
最近修订:2026-05-21
最近修订:2026-05-27
@@ -0,0 +1,531 @@
# SBAS-InSAR GeoJSON Coverage And Result Management Design
Date: 2026-05-27
## 1. Purpose
Gamma SBAS-InSAR production has completed an end-to-end twelve-node run. The next step is to make production and results usable by geography, not only by time sequence and run status.
This design defines:
- how production pages show approximate geographic coverage with GeoJSON/bbox;
- how users select administrative regions or AOI to discover and produce SBAS stacks on demand;
- how completed SBAS runs become result products with searchable geographic extent;
- how the results page should display LOS velocity, LOS sigma, and monitoring-point curves.
## 2. Current Facts
The current successful run already contains usable geographic metadata:
```text
run_id = sbas_7537cc71c998
stack center bucket = E129.2_N44.1
stack bbox = 128.7690438245, 43.7486321624, 129.6293024728, 44.3582486206
monitor point = 129.10207098755, 44.15041727515
```
Available metadata sources:
```text
stack_manifest.scenes[*].bbox
stack_manifest.scenes[*].center_lon / center_lat
stack_manifest.stack.center_bucket
rdc_dem_summary.dem_source.stack_bbox
monitor_points_summary.monitor_outputs[*].metadata.approx_lonlat
published GeoTIFF bounds from GDAL metadata
```
Existing platform capabilities:
```text
backend/geojson/全国行政区.geojson
backend/geojson/层级映射.json
GET /api/aoi/regions/children
GET /api/aoi/regions/{tree_id}/geometry
backend AOI helpers for region_tree_id / GeoJSON / uploaded AOI parsing
frontend Leaflet map and L.geoJSON support
frontend App.jsx existing source-scene footprint and AOI overlay patterns
```
The missing piece is productized SBAS-specific coverage and catalog behavior.
## 3. Design Principles
1. SBAS production is temporal, but SBAS result consumption is geographic.
2. Production UI should answer "am I processing the right place?" before a long workflow is submitted.
3. Results UI should answer "where is this product, what time range does it cover, and can I download the main outputs?"
4. One completed SBAS run is one result product bundle, not one product per GeoTIFF.
5. GeoJSON/bbox coverage is enough for the first production UX. Full raster map rendering can come later.
6. Administrative-region filtering should reuse existing AOI infrastructure rather than introduce a parallel region system.
## 4. Coverage Model
### 4.1 Stack Coverage
For stack discovery and production run display:
```json
{
"center": {"lon": 129.199, "lat": 44.053},
"bbox": {
"min_lon": 128.769,
"min_lat": 43.749,
"max_lon": 129.629,
"max_lat": 44.358
},
"bbox_geojson": {
"type": "Feature",
"properties": {"role": "sbas_stack_bbox"},
"geometry": {
"type": "Polygon",
"coordinates": [[
[128.769, 43.749],
[129.629, 43.749],
[129.629, 44.358],
[128.769, 44.358],
[128.769, 43.749]
]]
}
},
"scene_bbox_count": 7,
"scene_footprints_geojson": null
}
```
First implementation may use stack bbox only. Per-scene rectangles can be added when the map needs to show coverage stability.
### 4.2 Product Coverage
For completed results:
```json
{
"coverage_source": "stack_manifest_bbox_union",
"geotiff_bounds_verified": true,
"bbox": {...},
"center": {...},
"footprint_geojson": {...},
"administrative_hint": {
"province": "黑龙江省",
"city": null,
"county": null,
"method": "center_point_lookup"
}
}
```
The first administrative hint can be center-point based. Later it should become intersection-based and return all intersected regions with approximate overlap area.
## 5. Administrative Region And AOI Production
### 5.1 User Workflow
Production page should support three AOI sources:
```text
1. Administrative region selection
province -> city -> county, backed by /aoi/regions endpoints
2. Map-drawn rectangle
converted to bbox GeoJSON
3. Uploaded GeoJSON/SHP
reuse existing AOI parsing helpers
```
The user flow:
```text
select AOI / administrative region
discover SBAS stack candidates
show candidate time density + geographic coverage
select candidate
create production Run
submit Gamma SBAS workflow
```
### 5.2 Discovery Filter
Stack discovery should accept:
```json
{
"region_tree_id": "230000",
"aoi_geojson": {},
"aoi_bbox": {
"min_lon": 128.7,
"min_lat": 43.7,
"max_lon": 129.7,
"max_lat": 44.4
},
"aoi_overlap_min": 0.0,
"stable_stack_overlap_min": 0.3
}
```
Initial filtering can use bbox intersection:
```text
candidate stack is valid when union_bbox intersects AOI bbox
```
Second-stage filtering should use polygon intersection:
```text
candidate_score += common_stack_area_intersection_ratio
candidate_score += scene_count / temporal_density
candidate_score -= sparse_time_gap_penalty
```
### 5.3 Production Guardrails
Before running workflow:
```text
show bbox and administrative hint
show date list and max temporal gap
show DEM coverage status
warn when DEM covers center but not full stack bbox
warn when AOI overlap is low
```
The current run demonstrated a real issue:
```text
DEM source covers stack center = true
DEM source covers full stack bbox = false
```
This must be visible in the production page before the user trusts the result.
## 6. Production Page UI
Add `Geographic Coverage` block to `SbasInsarProductionPanel`.
Minimum fields:
```text
Center: lon / lat
BBox: min_lon, min_lat, max_lon, max_lat
Scene footprints: count
DEM coverage: bbox / center
Administrative hint
Monitor points if generated
Actions: view on map, copy GeoJSON, zoom to footprint
```
Map preview options:
```text
Phase 1: small unframed Leaflet map with rectangle overlay
Phase 2: shared main map overlay using existing App.jsx layer mechanisms
Phase 3: per-scene footprints and monitor points overlay
```
The production page should not become the result browser. It only provides enough geographic context to avoid wrong-location production.
## 7. Result Management Module
### 7.1 Module Boundary
Create a separate SBAS-InSAR result management module:
```text
navigation: Results / SBAS-InSAR Results
backend catalog: catalog_name = sbas_insar
product type: sbas_insar_bundle
source: completed Gamma SBAS production Run
```
Do not merge SBAS result semantics into D-InSAR result pages. Reuse the common catalog tables but keep service, API, and frontend module separate.
### 7.2 Product Row
One completed run becomes one product row:
```text
result_products.catalog_name = sbas_insar
result_products.run_key = sbas run_id
result_products.engine_code = gamma
result_products.processor_code = gamma_ipta_sbas
result_products.display_name = platform / orbit / area / date span
```
Required product metadata:
```text
platform
relative_orbit
orbit_direction
polarization
reference_date
start_date
end_date
scene_count
pair_count
bbox_min_lon / bbox_min_lat / bbox_max_lon / bbox_max_lat
center_lon / center_lat
administrative_hint
status
health_status
source_run_id
```
### 7.3 Asset Roles
Primary assets from the expert document:
```text
primary_velocity_geotiff
current: publish/geotiff/los_rate_toward_m_per_year.tif
expert equivalent: geo_los_def_rate.tif
primary_velocity_preview
current: publish/geotiff/los_rate_toward_m_per_year.hls.geo_preview.png
expert equivalent: los_def_rate.bmp / geo_los_def_rate.bmp
primary_velocity_rgb_geotiff
current: publish/geotiff/los_rate_toward_m_per_year.hls.geo_rgb.tif
expert equivalent: geo_los_def_rate_rgb.tif
quality_sigma_geotiff
current: publish/geotiff/los_sigma_m_per_year.tif
expert equivalent: diff.sigma_ts / geo_diff.sigma_ts
quality_sigma_preview
current: publish/geotiff/los_sigma_m_per_year.cc.geo_preview.png
expert equivalent: diff.sigma_ts.masked.bmp with cc.cm
monitor_timeseries_csv
current: publish/monitor_points/*_timeseries.csv
expert equivalent: disp_prt_2d output table
monitor_timeseries_plot
current: publish/monitor_points/*_timeseries.png
support_manifest
run_manifest.json, workflow_summary.json, gamma_command_manifest.json
```
Default result display should foreground:
```text
LOS velocity preview
LOS sigma preview
monitor point curve if present
footprint map
```
Audit/support files should be grouped separately.
## 8. Backend Design
### 8.1 Service
Add:
```text
backend/app/services/sbas_insar_catalog_service.py
```
Responsibilities:
```text
scan completed SBAS run directories
validate product bundle readiness
derive bbox/center/footprint GeoJSON
extract or verify GeoTIFF bounds
upsert result_products/result_assets/result_issues
provide list/detail/download APIs
bootstrap self-maintenance on startup
```
### 8.2 API
Add:
```text
GET /api/sbas-insar-products/catalog-status
POST /api/sbas-insar-products/rebuild-catalog
GET /api/sbas-insar-products
GET /api/sbas-insar-products/{product_id}
GET /api/sbas-insar-products/{product_id}/assets/{asset_id}
```
List filters:
```text
date_from
date_to
reference_date
platform
relative_orbit
orbit_direction
status
health_status
region_tree_id
aoi_bbox
aoi_geojson
intersects_bbox
has_monitor_points
limit / offset
```
### 8.3 Run Detail Extension
Extend production run detail:
```json
{
"geographic_coverage": {
"center": {"lon": 129.199, "lat": 44.053},
"bbox": {"min_lon": 128.769, "min_lat": 43.749, "max_lon": 129.629, "max_lat": 44.358},
"bbox_geojson": {},
"scene_bbox_count": 7,
"dem_covers_stack_bbox": false,
"dem_covers_stack_center": true,
"monitor_points": [{"point_id": "auto_low_sigma_high_rate", "lon": 129.102, "lat": 44.150}]
}
}
```
### 8.4 Startup Self-Maintenance
On startup:
```text
ensure result catalog tables exist through existing maintenance
bootstrap sbas_insar catalog state
scan completed SBAS run publish bundles
upsert missing product rows
record issues for:
missing bbox
missing primary velocity GeoTIFF
missing primary preview
missing sigma GeoTIFF
missing sigma preview
missing monitor files when monitor summary says ready
DEM coverage mismatch
```
This matches the current self-maintenance direction used by D-InSAR and PsInSAR catalogs.
## 9. Frontend Design
### 9.1 Production Page
Add:
```text
GeographicCoveragePanel
bbox text
center text
administrative hint
DEM coverage status
mini map rectangle
copy GeoJSON
zoom/open on main map
```
Candidate stack cards should show:
```text
date span
scene count
center bucket
center lon/lat
bbox short text
AOI overlap indicator when AOI is selected
```
### 9.2 Result Page
Add:
```text
SbasInsarProductsPanel
```
List page:
```text
filters: date range, administrative region, map AOI, platform, orbit, status
cards/table: product name, date span, bbox/admin hint, scene/pair count, preview thumbnail, health badge
actions: open detail, zoom to map, download primary GeoTIFF
```
Detail page:
```text
footprint map
LOS velocity preview
LOS sigma preview
monitor curve
key metadata table
asset table grouped by role
quality and issue summary
link to production Run
```
### 9.3 Map Layer Strategy
Use GeoJSON for the first implementation:
```text
bbox polygon for stack/product footprint
administrative region boundary layer from existing AOI endpoints
monitor point markers
optional per-scene footprints
```
Do not display RDC BMP as map layer. Only geocoded preview PNG/GeoTIFF-derived bounds should be used for map-oriented display.
## 10. Implementation Sequence
Recommended order:
```text
1. Backend geographic_coverage in SBAS run detail.
2. Production page coverage block and bbox GeoJSON mini-map.
3. Add AOI/region filters to SBAS stack discovery request/response.
4. Add sbas_insar_catalog_service and catalog rebuild API.
5. Register current completed run as first SBAS result product.
6. Build SbasInsarProductsPanel list/detail.
7. Add map AOI filtering and administrative-region filtering to result page.
8. Add startup bootstrap/self-check output.
```
The first user-visible win is step 1-2: the operator can immediately see whether a Run covers the intended location.
## 11. Validation
Use `sbas_7537cc71c998` as the first validation run.
Checks:
```text
geographic_coverage.bbox is present
bbox_geojson draws a rectangle in Leaflet
admin hint is present or explicitly unknown
DEM coverage warning is visible when bbox is not fully covered
result product row exists after catalog rebuild
result detail opens velocity/sigma previews
asset downloads work
map zoom to footprint works
AOI filter returns this product when AOI intersects bbox
AOI filter excludes this product when AOI is far away
```
## 12. Open Questions
1. Administrative-region naming should start with center-point lookup or intersection lookup?
Recommendation: center-point lookup first, intersection later.
2. Should stack discovery require AOI overlap, or only rank by AOI overlap?
Recommendation: default to rank/filter by bbox intersection, expose minimum overlap later.
3. Should results use `sbas_insar` or `psinsar` catalog name?
Recommendation: use `sbas_insar`. The current Gamma product is SBAS-InSAR, and old `psinsar` catalog semantics should not be overloaded.
4. Should GeoTIFF raster be rendered on map immediately?
Recommendation: not in the first slice. Start with footprint GeoJSON and preview images; raster tile rendering can be added after catalog registration is stable.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+24 -9
View File
@@ -24,6 +24,12 @@ export const listSbasInsarRuns = () =>
export const getSbasInsarRun = runId =>
apiClient.get(`/sbas-insar-production/runs/${encodeURIComponent(runId)}`).then(r => r.data);
export const prepareSbasInsarWorkflow = (runId, payload = {}) =>
apiClient.post(`/sbas-insar-production/runs/${encodeURIComponent(runId)}/workflow`, payload).then(r => r.data);
export const submitSbasInsarWorkflowJob = (runId, payload = {}) =>
apiClient.post(`/sbas-insar-production/runs/${encodeURIComponent(runId)}/workflow/jobs`, payload).then(r => r.data);
export const runSbasInsarBaselineAudit = (runId, payload = {}) =>
apiClient.post(`/sbas-insar-production/runs/${encodeURIComponent(runId)}/baseline-audit`, payload).then(r => r.data);
@@ -36,14 +42,23 @@ export const prepareSbasInsarCoregistration = (runId, payload = {}) =>
export const submitSbasInsarCoregistrationJob = (runId, payload = {}) =>
apiClient.post(`/sbas-insar-production/runs/${encodeURIComponent(runId)}/coregistration/jobs`, payload).then(r => r.data);
export const prepareSbasInsarRdcDem = (runId, payload = {}) =>
apiClient.post(`/sbas-insar-production/runs/${encodeURIComponent(runId)}/rdc-dem`, payload).then(r => r.data);
export const submitSbasInsarRdcDemJob = (runId, payload = {}) =>
apiClient.post(`/sbas-insar-production/runs/${encodeURIComponent(runId)}/rdc-dem/jobs`, payload).then(r => r.data);
export const prepareSbasInsarInterferograms = (runId, payload = {}) =>
apiClient.post(`/sbas-insar-production/runs/${encodeURIComponent(runId)}/interferograms`, payload).then(r => r.data);
export const submitSbasInsarInterferogramsJob = (runId, payload = {}) =>
apiClient.post(`/sbas-insar-production/runs/${encodeURIComponent(runId)}/interferograms/jobs`, payload).then(r => r.data);
export const prepareSbasInsarIptaTimeseries = (runId, payload = {}) =>
apiClient.post(`/sbas-insar-production/runs/${encodeURIComponent(runId)}/ipta-timeseries`, payload).then(r => r.data);
export const submitSbasInsarIptaTimeseriesJob = (runId, payload = {}) =>
apiClient.post(`/sbas-insar-production/runs/${encodeURIComponent(runId)}/ipta-timeseries/jobs`, payload).then(r => r.data);
export const getSbasInsarRunArtifactUrl = (runId, relativePath) =>
`/api/sbas-insar-production/runs/${encodeURIComponent(runId)}/artifacts/${encodeArtifactPath(relativePath)}`;
export const listSbasInsarTrialRuns = () =>
apiClient.get('/sbas-insar-production/trial-runs').then(r => r.data);
export const getSbasInsarTrialRun = trialId =>
apiClient.get(`/sbas-insar-production/trial-runs/${encodeURIComponent(trialId)}`).then(r => r.data);
export const getSbasInsarArtifactUrl = (trialId, relativePath) =>
`/api/sbas-insar-production/trial-runs/${encodeURIComponent(trialId)}/artifacts/${encodeArtifactPath(relativePath)}`;