feat: add SBAS AOI discovery and result management
This commit is contained in:
+27
-1
@@ -19,6 +19,7 @@ from .services.pairing_state_service import pairing_state_service
|
||||
from .services.psinsar_catalog_service import psinsar_catalog_service
|
||||
from .services.result_catalog_service import result_catalog_service
|
||||
from .services.root_registry_service import root_registry_service
|
||||
from .services.sbas_insar_catalog_service import sbas_insar_catalog_service
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -83,6 +84,18 @@ async def lifespan(app: FastAPI):
|
||||
"queued": False,
|
||||
"error": str(exc),
|
||||
}
|
||||
try:
|
||||
sbas_catalog_bootstrap = await sbas_insar_catalog_service.bootstrap_catalog_on_startup_clean()
|
||||
except Exception as exc:
|
||||
sbas_catalog_bootstrap = {
|
||||
"storage_root": settings.GAMMA_SBAS_WORK_ROOT,
|
||||
"manifest_count": 0,
|
||||
"db_count": 0,
|
||||
"needs_rebuild": False,
|
||||
"rebuilt": False,
|
||||
"queued": False,
|
||||
"error": str(exc),
|
||||
}
|
||||
try:
|
||||
pairing_bootstrap = await pairing_state_service.bootstrap_pairing_cache_state()
|
||||
except Exception as exc:
|
||||
@@ -178,6 +191,17 @@ async def lifespan(app: FastAPI):
|
||||
)
|
||||
if ps_catalog_bootstrap.get("error"):
|
||||
print(f">>> [Timeseries Catalog] Startup bootstrap failed: {ps_catalog_bootstrap['error']}")
|
||||
print(
|
||||
">>> [Gamma SBAS Catalog] root={0} runs={1} db={2} rebuild={3} rebuilt={4}".format(
|
||||
sbas_catalog_bootstrap.get("storage_root") or "?",
|
||||
sbas_catalog_bootstrap.get("manifest_count", 0),
|
||||
sbas_catalog_bootstrap.get("db_count", 0),
|
||||
"YES" if sbas_catalog_bootstrap.get("needs_rebuild") else "NO",
|
||||
"YES" if sbas_catalog_bootstrap.get("rebuilt") else "NO",
|
||||
)
|
||||
)
|
||||
if sbas_catalog_bootstrap.get("error"):
|
||||
print(f">>> [Gamma SBAS Catalog] Startup bootstrap failed: {sbas_catalog_bootstrap['error']}")
|
||||
print(
|
||||
">>> [Pairing] status={0} scenes={1} pairs={2} dirty={3} metric={4} rebuild={5}".format(
|
||||
pairing_bootstrap.get("status") or "?",
|
||||
@@ -214,17 +238,19 @@ async def lifespan(app: FastAPI):
|
||||
health.get("timeseries_result_catalog", {})
|
||||
or health.get("psinsar_result_catalog", {})
|
||||
).get("ok")
|
||||
sbas_catalog_ok = health.get("sbas_insar_result_catalog", {}).get("ok")
|
||||
pairing_ok = health.get("pairing_system", {}).get("ok")
|
||||
idl_ok = health.get("idl", {}).get("ok")
|
||||
product_packages_ok = health.get("product_packages", {}).get("ok")
|
||||
wsl_runtime_ok = health.get("wsl_runtime", {}).get("ok")
|
||||
print(
|
||||
">>> [Health] DB:{0} Schema:{1} Worker:{2} DInSAR-Catalog:{3} Timeseries-Catalog:{4} Packages:{5} WSL:{6} Pairing:{7} IDL:{8}".format(
|
||||
">>> [Health] DB:{0} Schema:{1} Worker:{2} DInSAR-Catalog:{3} Timeseries-Catalog:{4} SBAS-Catalog:{5} Packages:{6} WSL:{7} Pairing:{8} IDL:{9}".format(
|
||||
"OK" if db_ok else "FAIL",
|
||||
"OK" if schema_ok else "FAIL",
|
||||
"OK" if worker_ok else "FAIL",
|
||||
"OK" if dinsar_catalog_ok else "FAIL",
|
||||
"OK" if psinsar_catalog_ok else "FAIL",
|
||||
"OK" if sbas_catalog_ok else "FAIL",
|
||||
"OK" if product_packages_ok else "FAIL",
|
||||
"OK" if wsl_runtime_ok else "FAIL",
|
||||
"OK" if pairing_ok else "FAIL",
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -0,0 +1,392 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from shapely.geometry import Point, shape
|
||||
from shapely.ops import unary_union
|
||||
|
||||
try:
|
||||
from shapely.validation import make_valid as _make_valid_geometry
|
||||
except Exception: # pragma: no cover - depends on the installed Shapely version.
|
||||
_make_valid_geometry = None
|
||||
|
||||
|
||||
_LEVEL_RANK = {
|
||||
"country": 0,
|
||||
"province": 1,
|
||||
"city": 2,
|
||||
"district": 3,
|
||||
"county": 3,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _RegionGeometryRecord:
|
||||
tree_id: str
|
||||
name: str
|
||||
level: str | None
|
||||
adcode: str | None
|
||||
geometry: Any
|
||||
area: float
|
||||
|
||||
|
||||
_REGION_GEOMETRY_CACHE: list[_RegionGeometryRecord] | None = None
|
||||
_REGION_BY_ID_LOOKUP_CACHE: dict[str, dict[str, Any]] | None = None
|
||||
_REGION_LOAD_ERROR: str | None = None
|
||||
|
||||
|
||||
def _backend_geojson_dir() -> Path:
|
||||
return Path(__file__).resolve().parents[2] / "geojson"
|
||||
|
||||
|
||||
def _normalize_region_index_node(raw: dict[str, Any]) -> dict[str, Any] | None:
|
||||
tree_id = str(raw.get("treeID") or raw.get("tree_id") or raw.get("treeId") or "").strip()
|
||||
if not tree_id:
|
||||
return None
|
||||
parent_raw = raw.get("parent")
|
||||
parent_tree_id = str(parent_raw).strip() if parent_raw is not None else None
|
||||
if parent_tree_id == "":
|
||||
parent_tree_id = None
|
||||
depth = len(tree_id.split("-"))
|
||||
level = {1: "country", 2: "province", 3: "city", 4: "district"}.get(depth, "unknown")
|
||||
return {
|
||||
"tree_id": tree_id,
|
||||
"parent_tree_id": parent_tree_id,
|
||||
"name": str(raw.get("name") or tree_id).strip(),
|
||||
"level": level,
|
||||
}
|
||||
|
||||
|
||||
def _load_region_index_from_files() -> dict[str, dict[str, Any]]:
|
||||
geojson_dir = _backend_geojson_dir()
|
||||
candidates = [geojson_dir / "层级映射.json", *sorted(geojson_dir.glob("*.json"))]
|
||||
for path in candidates:
|
||||
if not path.is_file() or path.name == "treeid_fill_report.json":
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(payload, list):
|
||||
continue
|
||||
nodes: dict[str, dict[str, Any]] = {}
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
node = _normalize_region_index_node(item)
|
||||
if node:
|
||||
nodes[node["tree_id"]] = node
|
||||
if nodes:
|
||||
return nodes
|
||||
return {}
|
||||
|
||||
|
||||
def _load_region_geometry_from_files() -> dict[str, list[dict[str, Any]]]:
|
||||
geojson_dir = _backend_geojson_dir()
|
||||
candidates = [
|
||||
geojson_dir / "全国行政区.geojson",
|
||||
geojson_dir / "中华人民共和国.geojson",
|
||||
*sorted(geojson_dir.glob("*.geojson"), key=lambda item: item.stat().st_size if item.exists() else 0, reverse=True),
|
||||
]
|
||||
for path in candidates:
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
features = payload.get("features") if isinstance(payload, dict) else payload
|
||||
if not isinstance(features, list):
|
||||
continue
|
||||
feature_index: dict[str, list[dict[str, Any]]] = {}
|
||||
for feature in features:
|
||||
if not isinstance(feature, dict) or feature.get("type") != "Feature":
|
||||
continue
|
||||
props = feature.get("properties") or {}
|
||||
if not isinstance(props, dict):
|
||||
continue
|
||||
tree_id = str(props.get("treeID") or props.get("tree_id") or props.get("treeId") or "").strip()
|
||||
if tree_id:
|
||||
feature_index.setdefault(tree_id, []).append(feature)
|
||||
if feature_index:
|
||||
return feature_index
|
||||
return {}
|
||||
|
||||
|
||||
def _repair_geometry(geometry):
|
||||
if geometry is None or geometry.is_empty:
|
||||
return None
|
||||
if getattr(geometry, "is_valid", True):
|
||||
return geometry
|
||||
if _make_valid_geometry is not None:
|
||||
try:
|
||||
fixed = _make_valid_geometry(geometry)
|
||||
if fixed is not None and not fixed.is_empty:
|
||||
return fixed
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
fixed = geometry.buffer(0)
|
||||
if fixed is not None and not fixed.is_empty:
|
||||
return fixed
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _merge_region_geometries(geometries: list[Any]):
|
||||
fixed_geometries = []
|
||||
for geometry in geometries:
|
||||
fixed = _repair_geometry(geometry)
|
||||
if fixed is not None and not fixed.is_empty:
|
||||
fixed_geometries.append(fixed)
|
||||
if not fixed_geometries:
|
||||
return None
|
||||
if len(fixed_geometries) == 1:
|
||||
return fixed_geometries[0]
|
||||
try:
|
||||
merged = unary_union(fixed_geometries)
|
||||
return _repair_geometry(merged)
|
||||
except Exception:
|
||||
repaired_buffers = []
|
||||
for geometry in fixed_geometries:
|
||||
try:
|
||||
buffered = geometry.buffer(0)
|
||||
except Exception:
|
||||
continue
|
||||
if buffered is not None and not buffered.is_empty:
|
||||
repaired_buffers.append(buffered)
|
||||
if not repaired_buffers:
|
||||
return None
|
||||
try:
|
||||
merged = unary_union(repaired_buffers)
|
||||
return _repair_geometry(merged)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _build_region_path(tree_id: str, region_by_id: dict[str, dict[str, Any]]) -> tuple[list[str], list[str]]:
|
||||
names: list[str] = []
|
||||
tree_ids: list[str] = []
|
||||
current = tree_id
|
||||
guard = 0
|
||||
while current and guard < 12:
|
||||
node = region_by_id.get(current) or {}
|
||||
name = str(node.get("name") or current).strip()
|
||||
if name:
|
||||
names.append(name)
|
||||
tree_ids.append(current)
|
||||
current = str(node.get("parent_tree_id") or "").strip()
|
||||
guard += 1
|
||||
names.reverse()
|
||||
tree_ids.reverse()
|
||||
if names and names[0] in {"中国", "中华人民共和国"}:
|
||||
names = names[1:]
|
||||
if tree_ids and tree_ids[0] == "1":
|
||||
tree_ids = tree_ids[1:]
|
||||
return names, tree_ids
|
||||
|
||||
|
||||
def _load_region_records() -> tuple[list[_RegionGeometryRecord], dict[str, dict[str, Any]], str | None]:
|
||||
global _REGION_BY_ID_LOOKUP_CACHE, _REGION_GEOMETRY_CACHE, _REGION_LOAD_ERROR
|
||||
if _REGION_GEOMETRY_CACHE is not None:
|
||||
return _REGION_GEOMETRY_CACHE, _REGION_BY_ID_LOOKUP_CACHE or {}, _REGION_LOAD_ERROR
|
||||
try:
|
||||
from ..routers import dependencies as deps
|
||||
|
||||
deps._load_region_index()
|
||||
deps._load_region_geometry_index()
|
||||
region_by_id = deps._REGION_BY_ID_CACHE or {}
|
||||
geometry_by_id = deps._REGION_GEOMETRY_BY_ID_CACHE or {}
|
||||
except Exception:
|
||||
region_by_id = _load_region_index_from_files()
|
||||
geometry_by_id = _load_region_geometry_from_files()
|
||||
if not region_by_id or not geometry_by_id:
|
||||
_REGION_LOAD_ERROR = "AOI region index or geometry file is unavailable."
|
||||
return [], {}, _REGION_LOAD_ERROR
|
||||
try:
|
||||
records: list[_RegionGeometryRecord] = []
|
||||
for tree_id, features in geometry_by_id.items():
|
||||
geometries = []
|
||||
merged_props: dict[str, Any] = {}
|
||||
for feature in features:
|
||||
if not isinstance(feature, dict) or not feature.get("geometry"):
|
||||
continue
|
||||
try:
|
||||
geom = shape(feature["geometry"])
|
||||
except Exception:
|
||||
continue
|
||||
if geom.is_empty:
|
||||
continue
|
||||
geometries.append(geom)
|
||||
props = feature.get("properties") or {}
|
||||
if isinstance(props, dict):
|
||||
merged_props.update({key: value for key, value in props.items() if value not in (None, "")})
|
||||
if not geometries:
|
||||
continue
|
||||
geometry = _merge_region_geometries(geometries)
|
||||
if geometry is None or geometry.is_empty:
|
||||
continue
|
||||
node = region_by_id.get(tree_id) or {}
|
||||
records.append(
|
||||
_RegionGeometryRecord(
|
||||
tree_id=str(tree_id),
|
||||
name=str(merged_props.get("name") or node.get("name") or tree_id).strip(),
|
||||
level=str(merged_props.get("level") or node.get("level") or "").strip() or None,
|
||||
adcode=str(merged_props.get("adcode") or "").strip() or None,
|
||||
geometry=geometry,
|
||||
area=float(getattr(geometry, "area", 0.0) or 0.0),
|
||||
)
|
||||
)
|
||||
records.sort(
|
||||
key=lambda item: (
|
||||
-_LEVEL_RANK.get(str(item.level or "").lower(), len(item.tree_id.split("-"))),
|
||||
item.area,
|
||||
item.tree_id,
|
||||
)
|
||||
)
|
||||
_REGION_GEOMETRY_CACHE = records
|
||||
_REGION_BY_ID_LOOKUP_CACHE = region_by_id
|
||||
_REGION_LOAD_ERROR = None
|
||||
return records, region_by_id, None
|
||||
except Exception as exc:
|
||||
_REGION_LOAD_ERROR = str(exc)
|
||||
return [], {}, _REGION_LOAD_ERROR
|
||||
|
||||
|
||||
def lookup_admin_region_for_point(lon: Any, lat: Any) -> dict[str, Any] | None:
|
||||
try:
|
||||
lon_value = float(lon)
|
||||
lat_value = float(lat)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not (-180 <= lon_value <= 180 and -90 <= lat_value <= 90):
|
||||
return None
|
||||
|
||||
records, region_by_id, error = _load_region_records()
|
||||
if error:
|
||||
return {
|
||||
"match_status": "unavailable",
|
||||
"message": error,
|
||||
"center": {"lon": lon_value, "lat": lat_value},
|
||||
}
|
||||
point = Point(lon_value, lat_value)
|
||||
best: _RegionGeometryRecord | None = None
|
||||
for record in records:
|
||||
try:
|
||||
if record.geometry.covers(point):
|
||||
best = record
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if best is None:
|
||||
return {
|
||||
"match_status": "not_matched",
|
||||
"center": {"lon": lon_value, "lat": lat_value},
|
||||
}
|
||||
|
||||
path_names, path_tree_ids = _build_region_path(best.tree_id, region_by_id)
|
||||
display_name = " / ".join(path_names or [best.name])
|
||||
return {
|
||||
"match_status": "matched",
|
||||
"tree_id": best.tree_id,
|
||||
"name": best.name,
|
||||
"level": best.level,
|
||||
"adcode": best.adcode,
|
||||
"path_names": path_names,
|
||||
"path_tree_ids": path_tree_ids,
|
||||
"display_name": display_name,
|
||||
"center": {"lon": lon_value, "lat": lat_value},
|
||||
"source": "aoi_region_geometry",
|
||||
}
|
||||
|
||||
|
||||
def lookup_admin_region_geometry(query: str | None) -> dict[str, Any] | None:
|
||||
text = str(query or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
records, region_by_id, error = _load_region_records()
|
||||
if error:
|
||||
return {
|
||||
"match_status": "unavailable",
|
||||
"message": error,
|
||||
"query": text,
|
||||
}
|
||||
|
||||
query_lower = text.lower()
|
||||
matches: list[tuple[tuple[int, int, float, str], _RegionGeometryRecord, dict[str, Any]]] = []
|
||||
for record in records:
|
||||
path_names, path_tree_ids = _build_region_path(record.tree_id, region_by_id)
|
||||
display_name = " / ".join(path_names or [record.name])
|
||||
name_lower = str(record.name or "").lower()
|
||||
display_lower = display_name.lower()
|
||||
adcode_lower = str(record.adcode or "").lower()
|
||||
tree_id_lower = str(record.tree_id or "").lower()
|
||||
path_lowers = [str(item or "").lower() for item in path_names]
|
||||
|
||||
score: int | None = None
|
||||
if query_lower in {name_lower, display_lower, adcode_lower, tree_id_lower}:
|
||||
score = 0
|
||||
elif query_lower in path_lowers:
|
||||
score = 1
|
||||
elif query_lower and query_lower in name_lower:
|
||||
score = 2
|
||||
elif query_lower and query_lower in display_lower:
|
||||
score = 3
|
||||
elif query_lower and query_lower in " ".join(path_lowers + [adcode_lower, tree_id_lower]):
|
||||
score = 4
|
||||
if score is None:
|
||||
continue
|
||||
|
||||
level_rank = _LEVEL_RANK.get(str(record.level or "").lower(), len(record.tree_id.split("-")))
|
||||
summary = {
|
||||
"match_status": "matched",
|
||||
"query": text,
|
||||
"tree_id": record.tree_id,
|
||||
"name": record.name,
|
||||
"level": record.level,
|
||||
"adcode": record.adcode,
|
||||
"path_names": path_names,
|
||||
"path_tree_ids": path_tree_ids,
|
||||
"display_name": display_name,
|
||||
"bbox": {
|
||||
"min_lon": float(record.geometry.bounds[0]),
|
||||
"min_lat": float(record.geometry.bounds[1]),
|
||||
"max_lon": float(record.geometry.bounds[2]),
|
||||
"max_lat": float(record.geometry.bounds[3]),
|
||||
},
|
||||
"source": "aoi_region_geometry",
|
||||
}
|
||||
matches.append(((score, level_rank, record.area, record.tree_id), record, summary))
|
||||
|
||||
if not matches:
|
||||
return {
|
||||
"match_status": "not_matched",
|
||||
"query": text,
|
||||
}
|
||||
|
||||
matches.sort(key=lambda item: item[0])
|
||||
_, record, summary = matches[0]
|
||||
return {
|
||||
**summary,
|
||||
"geometry": record.geometry,
|
||||
}
|
||||
|
||||
|
||||
def admin_region_matches(region: dict[str, Any] | None, query: str | None) -> bool:
|
||||
text = str(query or "").strip().lower()
|
||||
if not text:
|
||||
return True
|
||||
if not isinstance(region, dict):
|
||||
return False
|
||||
values: list[str] = []
|
||||
for key in ("display_name", "name", "tree_id", "adcode", "level"):
|
||||
value = region.get(key)
|
||||
if value:
|
||||
values.append(str(value))
|
||||
for item in region.get("path_names") or []:
|
||||
values.append(str(item))
|
||||
return text in " ".join(values).lower()
|
||||
@@ -298,6 +298,14 @@ async def _check_timeseries_result_catalog() -> Dict[str, Any]:
|
||||
)
|
||||
|
||||
|
||||
async def _check_sbas_insar_result_catalog() -> Dict[str, Any]:
|
||||
return await _check_catalog(
|
||||
catalog_name="sbas_insar",
|
||||
storage_root=os.path.join(settings.GAMMA_SBAS_WORK_ROOT, "runs"),
|
||||
enabled=bool(settings.GAMMA_SBAS_ENABLED),
|
||||
)
|
||||
|
||||
|
||||
async def _check_psinsar_result_catalog() -> Dict[str, Any]:
|
||||
return await _check_timeseries_result_catalog()
|
||||
|
||||
@@ -472,6 +480,7 @@ def _sanitize_health_status(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
payload.get("timeseries_result_catalog", {}) or payload.get("psinsar_result_catalog", {}) or {}
|
||||
)
|
||||
psinsar_result_catalog = timeseries_result_catalog
|
||||
sbas_insar_result_catalog = payload.get("sbas_insar_result_catalog", {}) or {}
|
||||
dinsar_bridge = payload.get("dinsar_bridge", {}) or {}
|
||||
source_roots = payload.get("source_roots", {}) or {}
|
||||
sar_analysis_ready = payload.get("sar_analysis_ready", {}) or {}
|
||||
@@ -486,6 +495,7 @@ def _sanitize_health_status(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
sanitized_dinsar_catalog = _sanitize_catalog_status(dinsar_result_catalog)
|
||||
sanitized_timeseries_catalog = _sanitize_catalog_status(timeseries_result_catalog)
|
||||
sanitized_psinsar_catalog = sanitized_timeseries_catalog
|
||||
sanitized_sbas_insar_catalog = _sanitize_catalog_status(sbas_insar_result_catalog)
|
||||
sanitized_dinsar_bridge = _sanitize_bridge_status(dinsar_bridge)
|
||||
sanitized_source_roots = _sanitize_source_roots_status(source_roots)
|
||||
sanitized_sar_analysis_ready = _sanitize_sar_analysis_ready_status(sar_analysis_ready)
|
||||
@@ -511,10 +521,12 @@ def _sanitize_health_status(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"dinsar_result_catalog": sanitized_dinsar_catalog,
|
||||
"timeseries_result_catalog": sanitized_timeseries_catalog,
|
||||
"psinsar_result_catalog": sanitized_psinsar_catalog,
|
||||
"sbas_insar_result_catalog": sanitized_sbas_insar_catalog,
|
||||
"catalogs": {
|
||||
"dinsar": sanitized_dinsar_catalog,
|
||||
"timeseries": sanitized_timeseries_catalog,
|
||||
"psinsar": sanitized_psinsar_catalog,
|
||||
"sbas_insar": sanitized_sbas_insar_catalog,
|
||||
},
|
||||
"dinsar_bridge": sanitized_dinsar_bridge,
|
||||
"source_roots": sanitized_source_roots,
|
||||
@@ -1340,6 +1352,7 @@ async def get_health_status(
|
||||
result_catalog_status = await _check_result_catalog()
|
||||
timeseries_result_catalog_status = await _check_timeseries_result_catalog()
|
||||
psinsar_result_catalog_status = timeseries_result_catalog_status
|
||||
sbas_insar_result_catalog_status = await _check_sbas_insar_result_catalog()
|
||||
dinsar_bridge_status = await _check_dinsar_bridge()
|
||||
source_roots_status = await _check_source_roots()
|
||||
sar_analysis_ready_status = await _check_sar_analysis_ready()
|
||||
@@ -1366,6 +1379,7 @@ async def get_health_status(
|
||||
wsl_runtime_status.get("ok"),
|
||||
pairing_system_status.get("ok"),
|
||||
(not settings.TIMESERIES_ENABLED) or timeseries_result_catalog_status.get("ok"),
|
||||
(not settings.GAMMA_SBAS_ENABLED) or sbas_insar_result_catalog_status.get("ok"),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1378,10 +1392,12 @@ async def get_health_status(
|
||||
"dinsar_result_catalog": result_catalog_status,
|
||||
"timeseries_result_catalog": timeseries_result_catalog_status,
|
||||
"psinsar_result_catalog": psinsar_result_catalog_status,
|
||||
"sbas_insar_result_catalog": sbas_insar_result_catalog_status,
|
||||
"catalogs": {
|
||||
"dinsar": result_catalog_status,
|
||||
"timeseries": timeseries_result_catalog_status,
|
||||
"psinsar": psinsar_result_catalog_status,
|
||||
"sbas_insar": sbas_insar_result_catalog_status,
|
||||
},
|
||||
"dinsar_bridge": dinsar_bridge_status,
|
||||
"source_roots": source_roots_status,
|
||||
|
||||
@@ -37,6 +37,7 @@ from .engine_lock_service import engine_lock_service
|
||||
from .envi_service import build_envi_runner_command, get_envi_runner_cwd, get_envi_runner_env
|
||||
from .psinsar_catalog_service import psinsar_catalog_service
|
||||
from .result_catalog_service import result_catalog_service
|
||||
from .sbas_insar_catalog_service import sbas_insar_catalog_service
|
||||
from .task_service import task_service
|
||||
from .timeseries_service import (
|
||||
JOB_TYPE_TIMESERIES_MATERIALIZE,
|
||||
@@ -90,6 +91,7 @@ JOB_TYPE_PYINT_RUN = "PYINT_RUN"
|
||||
JOB_TYPE_PUBLISH_DINSAR_PRODUCTS = "PUBLISH_DINSAR_PRODUCTS"
|
||||
JOB_TYPE_REBUILD_DINSAR_CATALOG = "REBUILD_DINSAR_CATALOG"
|
||||
JOB_TYPE_REBUILD_PSINSAR_CATALOG = "REBUILD_PSINSAR_CATALOG"
|
||||
JOB_TYPE_REBUILD_SBAS_INSAR_CATALOG = "REBUILD_SBAS_INSAR_CATALOG"
|
||||
JOB_TYPE_SCAN_ASSET_INVENTORY = "SCAN_ASSET_INVENTORY"
|
||||
JOB_TYPE_SBAS_COREGISTRATION = "SBAS_COREGISTRATION"
|
||||
JOB_TYPE_SBAS_RDC_DEM = "SBAS_RDC_DEM"
|
||||
@@ -4188,6 +4190,30 @@ async def _handle_rebuild_psinsar_catalog(job: SystemJobORM) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _handle_rebuild_sbas_insar_catalog(job: SystemJobORM) -> None:
|
||||
if not job.task_id:
|
||||
raise ValueError("REBUILD_SBAS_INSAR_CATALOG requires task_id for progress tracking.")
|
||||
payload = job.payload or {}
|
||||
full_rebuild = bool(payload.get("full_rebuild", True))
|
||||
|
||||
await task_service.start_task(job.task_id, message="Rebuilding SBAS-InSAR result catalog...")
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await sbas_insar_catalog_service.rebuild_catalog(
|
||||
db,
|
||||
full_rebuild=full_rebuild,
|
||||
)
|
||||
await task_service.update_task(
|
||||
job.task_id,
|
||||
status="COMPLETED",
|
||||
progress=100,
|
||||
message=(
|
||||
f"SBAS-InSAR result catalog rebuilt: runs={result.get('run_count', 0)}, "
|
||||
f"registered={result.get('registered', 0)}, failed={result.get('failed', 0)}, "
|
||||
f"issues={result.get('issue_count', 0)}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _handle_sbas_coregistration(job: SystemJobORM) -> None:
|
||||
if not job.task_id:
|
||||
raise ValueError("SBAS_COREGISTRATION requires task_id for progress tracking.")
|
||||
@@ -4623,6 +4649,7 @@ _HANDLERS = {
|
||||
JOB_TYPE_TIMESERIES_EXPORT_PUBLISH: _handle_timeseries_export_publish,
|
||||
JOB_TYPE_TIMESERIES_REGISTER_PRODUCT: _handle_timeseries_register_product,
|
||||
JOB_TYPE_REBUILD_PSINSAR_CATALOG: _handle_rebuild_psinsar_catalog,
|
||||
JOB_TYPE_REBUILD_SBAS_INSAR_CATALOG: _handle_rebuild_sbas_insar_catalog,
|
||||
JOB_TYPE_COPY_DATA: _handle_copy_data,
|
||||
JOB_TYPE_UNPACK: _handle_unpack_archives,
|
||||
JOB_TYPE_UNPACK_SENTINEL1: _handle_unpack_sentinel1,
|
||||
|
||||
@@ -0,0 +1,912 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from geoalchemy2.shape import from_shape
|
||||
from shapely.geometry import Polygon
|
||||
from sqlalchemy import String, cast, delete, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import settings
|
||||
from ..models import ResultAssetORM, ResultCatalogStateORM, ResultIssueORM, ResultProductORM
|
||||
from .admin_region_lookup_service import lookup_admin_region_for_point
|
||||
from .sbas_insar_production_service import sbas_insar_production_service
|
||||
|
||||
|
||||
SBAS_INSAR_CATALOG_NAME = "sbas_insar"
|
||||
JOB_TYPE_REBUILD_SBAS_INSAR_CATALOG = "REBUILD_SBAS_INSAR_CATALOG"
|
||||
TASK_TYPE_REBUILD_SBAS_INSAR_CATALOG = "REBUILD_SBAS_INSAR_CATALOG"
|
||||
|
||||
_READY_STATUSES = {"PRODUCTS_READY", "MONITOR_POINTS_READY", "WORKFLOW_COMPLETED"}
|
||||
_REQUIRED_ASSET_ROLES = {"primary_geotiff", "quality_geotiff"}
|
||||
|
||||
_CORE_ASSETS = (
|
||||
("run_manifest", "Run manifest", "run_manifest.json", True, False),
|
||||
("stack_manifest", "Stack manifest", "stack_manifest.json", True, False),
|
||||
("workflow_summary", "Workflow summary", "workflow_summary.json", False, False),
|
||||
("product_summary", "Product summary", "product_summary.json", False, False),
|
||||
("quality_summary", "Quality summary", "quality_summary.json", False, False),
|
||||
("monitor_points_summary", "Monitor points summary", "monitor_points_summary.json", False, False),
|
||||
(
|
||||
"point_vector_summary",
|
||||
"LOS point-vector summary",
|
||||
"publish/vectors/los_rate_points_summary.json",
|
||||
False,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"point_vector_geojson_gz",
|
||||
"LOS point-vector GeoJSON.gz",
|
||||
"publish/vectors/los_rate_points.geojson.gz",
|
||||
False,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"primary_geocoded_preview",
|
||||
"LOS velocity preview, toward radar positive",
|
||||
"publish/geotiff/los_rate_toward_m_per_year.hls.geo_preview.png",
|
||||
False,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"quality_geocoded_preview",
|
||||
"LOS velocity sigma preview",
|
||||
"publish/geotiff/los_sigma_m_per_year.cc.geo_preview.png",
|
||||
False,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"primary_geotiff",
|
||||
"LOS velocity GeoTIFF, toward radar positive",
|
||||
"publish/geotiff/los_rate_toward_m_per_year.tif",
|
||||
True,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"alternate_geotiff",
|
||||
"LOS velocity GeoTIFF, away from radar positive",
|
||||
"publish/geotiff/los_rate_away_m_per_year.tif",
|
||||
False,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"quality_geotiff",
|
||||
"LOS velocity sigma GeoTIFF",
|
||||
"publish/geotiff/los_sigma_m_per_year.tif",
|
||||
True,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"primary_rgb_geotiff",
|
||||
"LOS velocity RGB GeoTIFF",
|
||||
"publish/geotiff/los_rate_toward_m_per_year.hls.geo_rgb.tif",
|
||||
False,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"quality_rgb_geotiff",
|
||||
"LOS velocity sigma RGB GeoTIFF",
|
||||
"publish/geotiff/los_sigma_m_per_year.cc.geo_rgb.tif",
|
||||
False,
|
||||
False,
|
||||
),
|
||||
("gamma_phase_rate", "Gamma phase-rate GeoTIFF", "publish/geotiff/ts_rate_rad_per_year.tif", False, False),
|
||||
("gamma_sigma_rate", "Gamma sigma-rate GeoTIFF", "publish/geotiff/sigma_rate_rad_per_year.tif", False, False),
|
||||
("height_correction", "Height correction GeoTIFF", "publish/geotiff/hgt_correction_m.tif", False, False),
|
||||
)
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.utcnow()
|
||||
|
||||
|
||||
def _normalize_path(path: str | os.PathLike[str]) -> str:
|
||||
return os.path.normpath(os.path.abspath(os.fspath(path)))
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
with path.open("r", encoding="utf-8") as fp:
|
||||
payload = json.load(fp)
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _safe_read_json(path: Path) -> dict[str, Any]:
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
return _read_json(path)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _safe_float(value: Any) -> Optional[float]:
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if parsed != parsed:
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> Optional[int]:
|
||||
try:
|
||||
return int(float(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_datetime(value: Any) -> Optional[datetime]:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
return datetime.fromisoformat(text).replace(tzinfo=None)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _stable_digest(*parts: Any, length: int = 20) -> str:
|
||||
payload = "||".join(str(part or "") for part in parts)
|
||||
return hashlib.sha1(payload.encode("utf-8", errors="ignore")).hexdigest()[:length]
|
||||
|
||||
|
||||
def _asset_format(path: str) -> Optional[str]:
|
||||
lowered = path.lower()
|
||||
if lowered.endswith(".geojson.gz"):
|
||||
return "geojson.gz"
|
||||
ext = Path(path).suffix.lower()
|
||||
return {
|
||||
".bmp": "bmp",
|
||||
".csv": "csv",
|
||||
".geo": "gamma_binary",
|
||||
".gz": "gzip",
|
||||
".json": "json",
|
||||
".log": "log",
|
||||
".png": "png",
|
||||
".sh": "shell",
|
||||
".tif": "geotiff",
|
||||
".tiff": "geotiff",
|
||||
".txt": "text",
|
||||
}.get(ext)
|
||||
|
||||
|
||||
def _media_type(path: str) -> Optional[str]:
|
||||
lowered = path.lower()
|
||||
if lowered.endswith(".geojson.gz"):
|
||||
return "application/gzip"
|
||||
ext = Path(path).suffix.lower()
|
||||
explicit = {
|
||||
".bmp": "image/bmp",
|
||||
".csv": "text/csv",
|
||||
".gz": "application/gzip",
|
||||
".json": "application/json",
|
||||
".log": "text/plain",
|
||||
".png": "image/png",
|
||||
".sh": "text/x-shellscript",
|
||||
".tif": "image/tiff",
|
||||
".tiff": "image/tiff",
|
||||
".txt": "text/plain",
|
||||
}
|
||||
return explicit.get(ext) or mimetypes.guess_type(path)[0]
|
||||
|
||||
|
||||
def _bbox_polygon(
|
||||
min_lon: Optional[float],
|
||||
min_lat: Optional[float],
|
||||
max_lon: Optional[float],
|
||||
max_lat: Optional[float],
|
||||
):
|
||||
if None in (min_lon, min_lat, max_lon, max_lat):
|
||||
return None
|
||||
if min_lon == max_lon or min_lat == max_lat:
|
||||
return None
|
||||
return Polygon(
|
||||
[
|
||||
(min_lon, min_lat),
|
||||
(max_lon, min_lat),
|
||||
(max_lon, max_lat),
|
||||
(min_lon, max_lat),
|
||||
(min_lon, min_lat),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _stack_dates_from_manifest(stack_manifest: dict[str, Any], manifest: dict[str, Any], stack: dict[str, Any]) -> list[str]:
|
||||
values: list[str] = []
|
||||
for source in (
|
||||
stack_manifest.get("dates"),
|
||||
stack.get("dates"),
|
||||
manifest.get("dates"),
|
||||
[scene.get("date") for scene in stack_manifest.get("scenes") or [] if isinstance(scene, dict)],
|
||||
[scene.get("date") for scene in manifest.get("scenes") or [] if isinstance(scene, dict)],
|
||||
):
|
||||
if not isinstance(source, list):
|
||||
continue
|
||||
for item in source:
|
||||
text = str(item or "").strip()
|
||||
if text:
|
||||
values.append(text)
|
||||
return sorted(dict.fromkeys(values))
|
||||
|
||||
|
||||
class SbasInsarCatalogService:
|
||||
def get_run_root(self) -> str:
|
||||
root = Path(settings.GAMMA_SBAS_WORK_ROOT or Path(settings.BACKEND_DIR) / "runtime" / "sbas_insar_production")
|
||||
run_root = root / "runs"
|
||||
run_root.mkdir(parents=True, exist_ok=True)
|
||||
return _normalize_path(run_root)
|
||||
|
||||
def _iter_run_manifest_paths(self, run_root: Optional[str] = None) -> list[str]:
|
||||
root = Path(run_root or self.get_run_root())
|
||||
if not root.is_dir():
|
||||
return []
|
||||
return [
|
||||
_normalize_path(path)
|
||||
for path in sorted(root.glob("*/run_manifest.json"))
|
||||
if self._is_publish_ready(path.parent, _safe_read_json(path))
|
||||
]
|
||||
|
||||
def _is_publish_ready(self, run_dir: Path, manifest: dict[str, Any]) -> bool:
|
||||
status = str(manifest.get("status") or "").strip().upper()
|
||||
required_outputs_ready = all(
|
||||
(run_dir / relative_path).is_file()
|
||||
for role, _name, relative_path, is_required, _is_primary in _CORE_ASSETS
|
||||
if role in _REQUIRED_ASSET_ROLES and is_required
|
||||
)
|
||||
return status in _READY_STATUSES or required_outputs_ready
|
||||
|
||||
def _tree_fingerprint(self, manifest_paths: list[str]) -> str:
|
||||
records: list[dict[str, Any]] = []
|
||||
for raw_path in manifest_paths:
|
||||
manifest_path = Path(raw_path)
|
||||
run_dir = manifest_path.parent
|
||||
tracked_paths = [
|
||||
manifest_path,
|
||||
run_dir / "product_summary.json",
|
||||
run_dir / "quality_summary.json",
|
||||
run_dir / "monitor_points_summary.json",
|
||||
]
|
||||
tracked_paths.extend(run_dir / relative_path for _role, _name, relative_path, _required, _primary in _CORE_ASSETS)
|
||||
for path in tracked_paths:
|
||||
if not path.exists():
|
||||
continue
|
||||
stat = path.stat()
|
||||
records.append(
|
||||
{
|
||||
"path": str(path.relative_to(run_dir)).replace("\\", "/"),
|
||||
"run": run_dir.name,
|
||||
"size": stat.st_size,
|
||||
"mtime_ns": stat.st_mtime_ns,
|
||||
}
|
||||
)
|
||||
encoded = json.dumps(records, sort_keys=True, ensure_ascii=True)
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
async def _get_or_create_catalog_state(self, db: AsyncSession, *, storage_root: str) -> ResultCatalogStateORM:
|
||||
result = await db.execute(
|
||||
select(ResultCatalogStateORM).where(ResultCatalogStateORM.catalog_name == SBAS_INSAR_CATALOG_NAME)
|
||||
)
|
||||
state = result.scalar_one_or_none()
|
||||
if state is None:
|
||||
state = ResultCatalogStateORM(
|
||||
catalog_name=SBAS_INSAR_CATALOG_NAME,
|
||||
product_family="timeseries",
|
||||
storage_root=storage_root,
|
||||
status="READY",
|
||||
needs_rebuild=False,
|
||||
)
|
||||
db.add(state)
|
||||
await db.flush()
|
||||
elif state.storage_root != storage_root:
|
||||
state.storage_root = storage_root
|
||||
if state.product_family != "timeseries":
|
||||
state.product_family = "timeseries"
|
||||
return state
|
||||
|
||||
def _asset_row(
|
||||
self,
|
||||
run_dir: Path,
|
||||
*,
|
||||
role: str,
|
||||
name: str,
|
||||
relative_path: str,
|
||||
is_required: bool,
|
||||
is_primary: bool,
|
||||
) -> ResultAssetORM:
|
||||
absolute_path = run_dir / relative_path
|
||||
exists = absolute_path.is_file()
|
||||
return ResultAssetORM(
|
||||
asset_role=role[:32],
|
||||
asset_name=name,
|
||||
relative_path=relative_path.replace("\\", "/"),
|
||||
absolute_path=_normalize_path(absolute_path),
|
||||
format=_asset_format(relative_path),
|
||||
media_type=_media_type(relative_path),
|
||||
is_required=is_required,
|
||||
is_primary=is_primary,
|
||||
exists_flag=exists,
|
||||
file_size=absolute_path.stat().st_size if exists else None,
|
||||
srid=4326 if (
|
||||
(relative_path.lower().endswith((".tif", ".tiff")) and "/geotiff/" in relative_path)
|
||||
or relative_path.lower().endswith(".geojson.gz")
|
||||
) else None,
|
||||
)
|
||||
|
||||
def _monitor_asset_rows(self, run_dir: Path) -> list[ResultAssetORM]:
|
||||
monitor_dir = run_dir / "publish" / "monitor_points"
|
||||
if not monitor_dir.is_dir():
|
||||
return []
|
||||
rows: list[ResultAssetORM] = []
|
||||
for path in sorted(monitor_dir.iterdir()):
|
||||
if not path.is_file():
|
||||
continue
|
||||
suffix = path.suffix.lower()
|
||||
if suffix not in {".png", ".csv", ".json"}:
|
||||
continue
|
||||
role = {
|
||||
".png": "monitor_point_curve",
|
||||
".csv": "monitor_point_csv",
|
||||
".json": "monitor_point_metadata",
|
||||
}[suffix]
|
||||
relative_path = str(path.relative_to(run_dir)).replace("\\", "/")
|
||||
rows.append(
|
||||
self._asset_row(
|
||||
run_dir,
|
||||
role=role,
|
||||
name=path.name,
|
||||
relative_path=relative_path,
|
||||
is_required=False,
|
||||
is_primary=False,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
def _build_product(self, manifest_path: str) -> ResultProductORM:
|
||||
manifest_file = Path(manifest_path)
|
||||
run_dir = manifest_file.parent
|
||||
manifest = _read_json(manifest_file)
|
||||
if not self._is_publish_ready(run_dir, manifest):
|
||||
raise ValueError(f"run is not publish-ready: {manifest.get('status') or 'UNKNOWN'}")
|
||||
|
||||
detail = sbas_insar_production_service.get_run_detail(run_dir.name)
|
||||
coverage = detail.get("geographic_coverage") or {}
|
||||
stack_manifest = _safe_read_json(run_dir / "stack_manifest.json")
|
||||
product_summary = _safe_read_json(run_dir / "product_summary.json")
|
||||
quality_summary = _safe_read_json(run_dir / "quality_summary.json")
|
||||
monitor_summary = _safe_read_json(run_dir / "monitor_points_summary.json")
|
||||
point_vector_summary = _safe_read_json(run_dir / "publish" / "vectors" / "los_rate_points_summary.json")
|
||||
workflow_summary = _safe_read_json(run_dir / "workflow_summary.json")
|
||||
|
||||
bbox = coverage.get("bbox") or {}
|
||||
min_lon = _safe_float(bbox.get("min_lon"))
|
||||
min_lat = _safe_float(bbox.get("min_lat"))
|
||||
max_lon = _safe_float(bbox.get("max_lon"))
|
||||
max_lat = _safe_float(bbox.get("max_lat"))
|
||||
poly = _bbox_polygon(min_lon, min_lat, max_lon, max_lat)
|
||||
|
||||
run_id = str(manifest.get("run_id") or run_dir.name).strip() or run_dir.name
|
||||
stack = stack_manifest.get("stack") or manifest.get("stack") or {}
|
||||
stack_id = str(manifest.get("stack_id") or stack_manifest.get("stack_id") or stack.get("stack_id") or "").strip()
|
||||
stack_dates = _stack_dates_from_manifest(stack_manifest, manifest, stack)
|
||||
reference_date = str(
|
||||
manifest.get("reference_date")
|
||||
or stack.get("reference_date")
|
||||
or (manifest.get("coregistration") or {}).get("reference_date")
|
||||
or ""
|
||||
).strip() or None
|
||||
display_name = stack_id or f"Gamma SBAS {run_id}"
|
||||
product_id = str(manifest.get("product_id") or "").strip() or f"gamma_sbas_{run_id}"
|
||||
if len(product_id) > 64:
|
||||
product_id = f"gamma_sbas_{_stable_digest(product_id, run_dir, length=32)}"
|
||||
|
||||
assets: list[ResultAssetORM] = [
|
||||
self._asset_row(
|
||||
run_dir,
|
||||
role=role,
|
||||
name=name,
|
||||
relative_path=relative_path,
|
||||
is_required=is_required,
|
||||
is_primary=is_primary,
|
||||
)
|
||||
for role, name, relative_path, is_required, is_primary in _CORE_ASSETS
|
||||
]
|
||||
assets.extend(self._monitor_asset_rows(run_dir))
|
||||
preview_asset = next((asset for asset in assets if asset.asset_role == "primary_geocoded_preview" and asset.exists_flag), None)
|
||||
primary_asset = next((asset for asset in assets if asset.asset_role == "primary_geotiff" and asset.exists_flag), None)
|
||||
missing_required = [asset for asset in assets if asset.is_required and not asset.exists_flag]
|
||||
|
||||
produced_at = (
|
||||
_parse_datetime(monitor_summary.get("generated_at"))
|
||||
or _parse_datetime(product_summary.get("generated_at"))
|
||||
or _parse_datetime(workflow_summary.get("generated_at"))
|
||||
or _parse_datetime(manifest.get("updated_at"))
|
||||
or _parse_datetime(manifest.get("created_at"))
|
||||
)
|
||||
center = coverage.get("center") or {}
|
||||
admin_region = coverage.get("admin_region") or lookup_admin_region_for_point(center.get("lon"), center.get("lat"))
|
||||
scene_count = (
|
||||
_safe_int(manifest.get("scene_count"))
|
||||
or len(stack_manifest.get("scenes") or [])
|
||||
or len(stack_dates)
|
||||
)
|
||||
|
||||
summary_json = {
|
||||
"schema": "insar.gamma-sbas-result-catalog-summary/v1",
|
||||
"run_id": run_id,
|
||||
"stack_id": stack_id or None,
|
||||
"stack": stack,
|
||||
"reference_date": reference_date,
|
||||
"stack_dates": stack_dates,
|
||||
"stack_size": len(stack_dates),
|
||||
"date_start": stack_dates[0] if stack_dates else None,
|
||||
"date_end": stack_dates[-1] if stack_dates else None,
|
||||
"scene_count": scene_count,
|
||||
"pair_count": _safe_int(manifest.get("pair_count")),
|
||||
"status": manifest.get("status"),
|
||||
"next_stage": manifest.get("next_stage"),
|
||||
"los_sign_convention": (
|
||||
product_summary.get("los_sign_convention")
|
||||
or "toward radar positive; away from radar negative"
|
||||
),
|
||||
"default_los_product": product_summary.get("default_los_product") or "los_rate_toward_m_per_year",
|
||||
"center": center or None,
|
||||
"admin_region": admin_region,
|
||||
"geographic_coverage": coverage,
|
||||
"quality": quality_summary,
|
||||
"monitor_points": monitor_summary,
|
||||
"point_vector": point_vector_summary,
|
||||
"workflow": {
|
||||
"status": ((manifest.get("workflow") or {}).get("status")),
|
||||
"summary": ((manifest.get("workflow") or {}).get("summary")) or workflow_summary,
|
||||
},
|
||||
"source_run_dir": str(run_dir),
|
||||
}
|
||||
|
||||
product = ResultProductORM(
|
||||
product_id=product_id,
|
||||
catalog_name=SBAS_INSAR_CATALOG_NAME,
|
||||
product_family="timeseries",
|
||||
product_type="sbas_insar",
|
||||
display_name=display_name,
|
||||
task_name="Gamma SBAS-InSAR",
|
||||
task_alias=run_id,
|
||||
stack_key=stack_id or run_id,
|
||||
run_key=run_id,
|
||||
profile_code=str(stack.get("relative_orbit") or manifest.get("relative_orbit") or "").strip() or None,
|
||||
engine_code="gamma",
|
||||
engine_version=str((manifest.get("engine") or {}).get("version") or "").strip() or None,
|
||||
package_schema=str(manifest.get("schema") or "").strip() or "insar.gamma-sbas-run/v1",
|
||||
package_layout="gamma_sbas_expert_workflow_run",
|
||||
processor_code="gamma_ipta_sbas",
|
||||
runtime_id=settings.GAMMA_SBAS_RUNTIME_ID,
|
||||
status="READY" if not missing_required else "INCOMPLETE",
|
||||
health_status="OK" if not missing_required else "WARN",
|
||||
publish_dir=_normalize_path(run_dir / "publish"),
|
||||
manifest_path=_normalize_path(manifest_file),
|
||||
source_primary_path=primary_asset.absolute_path if primary_asset else None,
|
||||
native_output_dir=_normalize_path(run_dir),
|
||||
preview_path=preview_asset.absolute_path if preview_asset else None,
|
||||
primary_asset_path=primary_asset.absolute_path if primary_asset else None,
|
||||
summary_json=summary_json,
|
||||
tags_json={
|
||||
"sensor": stack.get("satellite") or manifest.get("platform"),
|
||||
"orbit_direction": stack.get("orbit_direction") or manifest.get("direction"),
|
||||
"product": "Gamma SBAS",
|
||||
"admin_region": (admin_region or {}).get("display_name") if isinstance(admin_region, dict) else None,
|
||||
},
|
||||
min_lon=min_lon,
|
||||
min_lat=min_lat,
|
||||
max_lon=max_lon,
|
||||
max_lat=max_lat,
|
||||
geom=from_shape(poly, srid=4326) if poly is not None else None,
|
||||
coverage_polygon=(coverage.get("geojson") or coverage.get("scene_footprints_geojson")),
|
||||
produced_at=produced_at,
|
||||
published_at=produced_at,
|
||||
)
|
||||
for asset in assets:
|
||||
product.assets.append(asset)
|
||||
if asset.is_required and not asset.exists_flag:
|
||||
product.issues.append(
|
||||
ResultIssueORM(
|
||||
asset=asset,
|
||||
issue_code="MISSING_REQUIRED_ASSET",
|
||||
severity="ERROR",
|
||||
status="OPEN",
|
||||
scope="file",
|
||||
message=f"Required SBAS asset is missing: {asset.relative_path}",
|
||||
)
|
||||
)
|
||||
if not preview_asset:
|
||||
product.issues.append(
|
||||
ResultIssueORM(
|
||||
issue_code="MISSING_PREVIEW",
|
||||
severity="WARN",
|
||||
status="OPEN",
|
||||
scope="product",
|
||||
message="Primary geocoded preview PNG is missing.",
|
||||
)
|
||||
)
|
||||
if poly is None:
|
||||
product.issues.append(
|
||||
ResultIssueORM(
|
||||
issue_code="MISSING_COVERAGE",
|
||||
severity="WARN",
|
||||
status="OPEN",
|
||||
scope="product",
|
||||
message="No valid EPSG:4326 geographic coverage bbox was found.",
|
||||
)
|
||||
)
|
||||
return product
|
||||
|
||||
async def rebuild_catalog(self, db: AsyncSession, *, full_rebuild: bool = True) -> dict[str, Any]:
|
||||
run_root = self.get_run_root()
|
||||
manifest_paths = await asyncio.to_thread(self._iter_run_manifest_paths, run_root)
|
||||
fingerprint = await asyncio.to_thread(self._tree_fingerprint, manifest_paths)
|
||||
state = await self._get_or_create_catalog_state(db, storage_root=run_root)
|
||||
state.status = "REBUILDING"
|
||||
state.needs_rebuild = False
|
||||
state.last_message = "SBAS catalog rebuild in progress"
|
||||
await db.commit()
|
||||
|
||||
if full_rebuild:
|
||||
await db.execute(delete(ResultProductORM).where(ResultProductORM.catalog_name == SBAS_INSAR_CATALOG_NAME))
|
||||
await db.commit()
|
||||
|
||||
registered = 0
|
||||
failed = 0
|
||||
issue_count = 0
|
||||
details: list[dict[str, Any]] = []
|
||||
for manifest_path in manifest_paths:
|
||||
try:
|
||||
product = await asyncio.to_thread(self._build_product, manifest_path)
|
||||
product_issue_count = len(product.issues)
|
||||
product_id = product.product_id
|
||||
product_status = product.status
|
||||
db.add(product)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
registered += 1
|
||||
issue_count += product_issue_count
|
||||
details.append(
|
||||
{
|
||||
"manifest_path": manifest_path,
|
||||
"product_id": product_id,
|
||||
"status": product_status,
|
||||
"issues": product_issue_count,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
failed += 1
|
||||
issue_count += 1
|
||||
details.append({"manifest_path": manifest_path, "status": "error", "message": str(exc)})
|
||||
|
||||
await db.commit()
|
||||
db_count_result = await db.execute(
|
||||
select(func.count(ResultProductORM.id)).where(ResultProductORM.catalog_name == SBAS_INSAR_CATALOG_NAME)
|
||||
)
|
||||
db_count = int(db_count_result.scalar_one() or 0)
|
||||
state = await self._get_or_create_catalog_state(db, storage_root=run_root)
|
||||
state.manifest_count = len(manifest_paths)
|
||||
state.manifest_fingerprint = fingerprint
|
||||
state.db_count = db_count
|
||||
state.issue_count = issue_count
|
||||
state.needs_rebuild = False
|
||||
state.status = "READY" if failed == 0 else "WARN"
|
||||
now = _utcnow()
|
||||
state.last_full_rebuild_at = now
|
||||
state.last_incremental_scan_at = now
|
||||
state.last_message = (
|
||||
f"SBAS catalog rebuild finished: runs={len(manifest_paths)}, "
|
||||
f"registered={registered}, failed={failed}, issues={issue_count}"
|
||||
)
|
||||
await db.commit()
|
||||
return {
|
||||
"catalog_name": SBAS_INSAR_CATALOG_NAME,
|
||||
"storage_root": run_root,
|
||||
"run_count": len(manifest_paths),
|
||||
"manifest_count": len(manifest_paths),
|
||||
"manifest_fingerprint": fingerprint,
|
||||
"registered": registered,
|
||||
"failed": failed,
|
||||
"issue_count": issue_count,
|
||||
"details": details,
|
||||
}
|
||||
|
||||
async def list_products(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
status: Optional[str] = None,
|
||||
query: Optional[str] = None,
|
||||
admin_region: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
safe_limit = max(1, min(int(limit or 100), 500))
|
||||
safe_offset = max(0, int(offset or 0))
|
||||
stmt = select(ResultProductORM).where(ResultProductORM.catalog_name == SBAS_INSAR_CATALOG_NAME)
|
||||
count_stmt = select(func.count(ResultProductORM.id)).where(ResultProductORM.catalog_name == SBAS_INSAR_CATALOG_NAME)
|
||||
if status:
|
||||
stmt = stmt.where(ResultProductORM.status == status)
|
||||
count_stmt = count_stmt.where(ResultProductORM.status == status)
|
||||
if query:
|
||||
like_value = f"%{query.strip()}%"
|
||||
predicate = or_(
|
||||
ResultProductORM.display_name.ilike(like_value),
|
||||
ResultProductORM.product_id.ilike(like_value),
|
||||
ResultProductORM.run_key.ilike(like_value),
|
||||
ResultProductORM.stack_key.ilike(like_value),
|
||||
)
|
||||
stmt = stmt.where(predicate)
|
||||
count_stmt = count_stmt.where(predicate)
|
||||
if admin_region:
|
||||
like_value = f"%{admin_region.strip()}%"
|
||||
predicate = or_(
|
||||
cast(ResultProductORM.summary_json, String).ilike(like_value),
|
||||
cast(ResultProductORM.tags_json, String).ilike(like_value),
|
||||
)
|
||||
stmt = stmt.where(predicate)
|
||||
count_stmt = count_stmt.where(predicate)
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = int(total_result.scalar_one() or 0)
|
||||
result = await db.execute(
|
||||
stmt.order_by(ResultProductORM.published_at.desc().nullslast(), ResultProductORM.id.desc())
|
||||
.offset(safe_offset)
|
||||
.limit(safe_limit)
|
||||
)
|
||||
items: list[dict[str, Any]] = []
|
||||
for product in result.scalars().all():
|
||||
summary = product.summary_json or {}
|
||||
items.append(
|
||||
{
|
||||
"id": product.id,
|
||||
"product_id": product.product_id,
|
||||
"display_name": product.display_name,
|
||||
"run_key": product.run_key,
|
||||
"stack_key": product.stack_key,
|
||||
"engine_code": product.engine_code,
|
||||
"processor_code": product.processor_code,
|
||||
"runtime_id": product.runtime_id,
|
||||
"status": product.status,
|
||||
"health_status": product.health_status,
|
||||
"preview_path": product.preview_path,
|
||||
"primary_asset_path": product.primary_asset_path,
|
||||
"reference_date": summary.get("reference_date"),
|
||||
"date_start": summary.get("date_start"),
|
||||
"date_end": summary.get("date_end"),
|
||||
"stack_dates": summary.get("stack_dates") or [],
|
||||
"stack_size": summary.get("stack_size") or len(summary.get("stack_dates") or []),
|
||||
"scene_count": summary.get("scene_count"),
|
||||
"pair_count": summary.get("pair_count"),
|
||||
"los_sign_convention": summary.get("los_sign_convention"),
|
||||
"center": summary.get("center") or ((summary.get("geographic_coverage") or {}).get("center")),
|
||||
"admin_region": summary.get("admin_region") or ((summary.get("geographic_coverage") or {}).get("admin_region")),
|
||||
"min_lon": product.min_lon,
|
||||
"min_lat": product.min_lat,
|
||||
"max_lon": product.max_lon,
|
||||
"max_lat": product.max_lat,
|
||||
"published_at": product.published_at,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"limit": safe_limit,
|
||||
"offset": safe_offset,
|
||||
"has_more": safe_offset + len(items) < total,
|
||||
}
|
||||
|
||||
async def get_product_detail(self, db: AsyncSession, *, product_db_id: int) -> Optional[dict[str, Any]]:
|
||||
result = await db.execute(select(ResultProductORM).where(ResultProductORM.id == product_db_id))
|
||||
product = result.scalar_one_or_none()
|
||||
if product is None or product.catalog_name != SBAS_INSAR_CATALOG_NAME:
|
||||
return None
|
||||
assets_result = await db.execute(
|
||||
select(ResultAssetORM)
|
||||
.where(ResultAssetORM.product_ref_id == product.id)
|
||||
.order_by(ResultAssetORM.is_primary.desc(), ResultAssetORM.asset_role.asc(), ResultAssetORM.id.asc())
|
||||
)
|
||||
issues_result = await db.execute(
|
||||
select(ResultIssueORM)
|
||||
.where(ResultIssueORM.product_ref_id == product.id)
|
||||
.order_by(ResultIssueORM.severity.asc(), ResultIssueORM.id.asc())
|
||||
)
|
||||
summary = product.summary_json or {}
|
||||
return {
|
||||
"id": product.id,
|
||||
"product_id": product.product_id,
|
||||
"catalog_name": product.catalog_name,
|
||||
"product_type": product.product_type,
|
||||
"display_name": product.display_name,
|
||||
"run_key": product.run_key,
|
||||
"run_id": summary.get("run_id") or product.run_key,
|
||||
"stack_key": product.stack_key,
|
||||
"profile_code": product.profile_code,
|
||||
"engine_code": product.engine_code,
|
||||
"engine_version": product.engine_version,
|
||||
"package_schema": product.package_schema,
|
||||
"package_layout": product.package_layout,
|
||||
"processor_code": product.processor_code,
|
||||
"runtime_id": product.runtime_id,
|
||||
"status": product.status,
|
||||
"health_status": product.health_status,
|
||||
"publish_dir": product.publish_dir,
|
||||
"manifest_path": product.manifest_path,
|
||||
"source_primary_path": product.source_primary_path,
|
||||
"native_output_dir": product.native_output_dir,
|
||||
"preview_path": product.preview_path,
|
||||
"primary_asset_path": product.primary_asset_path,
|
||||
"reference_date": summary.get("reference_date"),
|
||||
"date_start": summary.get("date_start"),
|
||||
"date_end": summary.get("date_end"),
|
||||
"stack_dates": summary.get("stack_dates") or [],
|
||||
"stack_size": summary.get("stack_size") or len(summary.get("stack_dates") or []),
|
||||
"scene_count": summary.get("scene_count"),
|
||||
"pair_count": summary.get("pair_count"),
|
||||
"los_sign_convention": summary.get("los_sign_convention"),
|
||||
"default_los_product": summary.get("default_los_product"),
|
||||
"quality": summary.get("quality"),
|
||||
"monitor_points": summary.get("monitor_points"),
|
||||
"point_vector": summary.get("point_vector"),
|
||||
"workflow": summary.get("workflow"),
|
||||
"geographic_coverage": summary.get("geographic_coverage"),
|
||||
"center": summary.get("center") or ((summary.get("geographic_coverage") or {}).get("center")),
|
||||
"admin_region": summary.get("admin_region") or ((summary.get("geographic_coverage") or {}).get("admin_region")),
|
||||
"coverage_polygon": product.coverage_polygon,
|
||||
"min_lon": product.min_lon,
|
||||
"min_lat": product.min_lat,
|
||||
"max_lon": product.max_lon,
|
||||
"max_lat": product.max_lat,
|
||||
"produced_at": product.produced_at,
|
||||
"published_at": product.published_at,
|
||||
"registered_at": product.registered_at,
|
||||
"updated_at": product.updated_at,
|
||||
"assets": [
|
||||
{
|
||||
"id": asset.id,
|
||||
"asset_role": asset.asset_role,
|
||||
"asset_name": asset.asset_name,
|
||||
"relative_path": asset.relative_path,
|
||||
"absolute_path": asset.absolute_path,
|
||||
"format": asset.format,
|
||||
"media_type": asset.media_type,
|
||||
"is_required": asset.is_required,
|
||||
"is_primary": asset.is_primary,
|
||||
"exists_flag": asset.exists_flag,
|
||||
"file_size": asset.file_size,
|
||||
"srid": asset.srid,
|
||||
}
|
||||
for asset in assets_result.scalars().all()
|
||||
],
|
||||
"issues": [
|
||||
{
|
||||
"id": issue.id,
|
||||
"issue_code": issue.issue_code,
|
||||
"severity": issue.severity,
|
||||
"status": issue.status,
|
||||
"scope": issue.scope,
|
||||
"message": issue.message,
|
||||
"detected_at": issue.detected_at,
|
||||
}
|
||||
for issue in issues_result.scalars().all()
|
||||
],
|
||||
}
|
||||
|
||||
async def get_asset(self, db: AsyncSession, *, product_db_id: int, asset_id: int) -> Optional[ResultAssetORM]:
|
||||
result = await db.execute(
|
||||
select(ResultAssetORM)
|
||||
.join(ResultProductORM, ResultProductORM.id == ResultAssetORM.product_ref_id)
|
||||
.where(
|
||||
ResultProductORM.id == product_db_id,
|
||||
ResultProductORM.catalog_name == SBAS_INSAR_CATALOG_NAME,
|
||||
ResultAssetORM.id == asset_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_catalog_status(self, db: AsyncSession) -> dict[str, Any]:
|
||||
run_root = self.get_run_root()
|
||||
manifest_paths = await asyncio.to_thread(self._iter_run_manifest_paths, run_root)
|
||||
fingerprint = await asyncio.to_thread(self._tree_fingerprint, manifest_paths)
|
||||
state = await self._get_or_create_catalog_state(db, storage_root=run_root)
|
||||
db_count_result = await db.execute(
|
||||
select(func.count(ResultProductORM.id)).where(ResultProductORM.catalog_name == SBAS_INSAR_CATALOG_NAME)
|
||||
)
|
||||
db_count = int(db_count_result.scalar_one() or 0)
|
||||
needs_rebuild = (
|
||||
state.manifest_count != len(manifest_paths)
|
||||
or state.db_count != db_count
|
||||
or state.manifest_fingerprint != fingerprint
|
||||
)
|
||||
state.manifest_count = len(manifest_paths)
|
||||
state.db_count = db_count
|
||||
state.needs_rebuild = needs_rebuild
|
||||
state.last_incremental_scan_at = _utcnow()
|
||||
state.status = "WARN" if needs_rebuild else "READY"
|
||||
state.last_message = (
|
||||
f"SBAS catalog rebuild required: runs={len(manifest_paths)}, db={db_count}"
|
||||
if needs_rebuild
|
||||
else "SBAS catalog is in sync"
|
||||
)
|
||||
await db.commit()
|
||||
return {
|
||||
"catalog_name": state.catalog_name,
|
||||
"product_family": state.product_family,
|
||||
"storage_root": state.storage_root,
|
||||
"status": state.status,
|
||||
"needs_rebuild": state.needs_rebuild,
|
||||
"run_count": len(manifest_paths),
|
||||
"manifest_count": state.manifest_count,
|
||||
"manifest_fingerprint": state.manifest_fingerprint,
|
||||
"current_manifest_fingerprint": fingerprint,
|
||||
"db_count": db_count,
|
||||
"issue_count": state.issue_count,
|
||||
"last_message": state.last_message,
|
||||
"last_boot_check_at": state.last_boot_check_at,
|
||||
"last_full_rebuild_at": state.last_full_rebuild_at,
|
||||
"last_incremental_scan_at": state.last_incremental_scan_at,
|
||||
}
|
||||
|
||||
async def bootstrap_catalog_on_startup_clean(self) -> dict[str, Any]:
|
||||
from ..database import AsyncSessionLocal
|
||||
|
||||
if AsyncSessionLocal is None:
|
||||
raise RuntimeError("Database session factory is not initialized.")
|
||||
async with AsyncSessionLocal() as db:
|
||||
run_root = self.get_run_root()
|
||||
manifest_paths = await asyncio.to_thread(self._iter_run_manifest_paths, run_root)
|
||||
fingerprint = await asyncio.to_thread(self._tree_fingerprint, manifest_paths)
|
||||
state = await self._get_or_create_catalog_state(db, storage_root=run_root)
|
||||
db_count_result = await db.execute(
|
||||
select(func.count(ResultProductORM.id)).where(ResultProductORM.catalog_name == SBAS_INSAR_CATALOG_NAME)
|
||||
)
|
||||
db_count = int(db_count_result.scalar_one() or 0)
|
||||
needs_rebuild = (
|
||||
state.manifest_count != len(manifest_paths)
|
||||
or state.db_count != db_count
|
||||
or state.manifest_fingerprint != fingerprint
|
||||
)
|
||||
state.last_boot_check_at = _utcnow()
|
||||
await db.commit()
|
||||
|
||||
rebuilt = False
|
||||
result: dict[str, Any] = {}
|
||||
if needs_rebuild and settings.RESULT_CATALOG_AUTO_REBUILD_ON_STARTUP:
|
||||
result = await self.rebuild_catalog(db, full_rebuild=True)
|
||||
rebuilt = True
|
||||
db_count = int(result.get("registered") or db_count)
|
||||
else:
|
||||
state.manifest_count = len(manifest_paths)
|
||||
state.db_count = db_count
|
||||
state.manifest_fingerprint = fingerprint if not needs_rebuild else state.manifest_fingerprint
|
||||
state.needs_rebuild = needs_rebuild
|
||||
state.status = "WARN" if needs_rebuild else "READY"
|
||||
state.last_message = "SBAS boot check complete"
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"storage_root": run_root,
|
||||
"manifest_count": len(manifest_paths),
|
||||
"current_manifest_fingerprint": fingerprint,
|
||||
"indexed_manifest_fingerprint": state.manifest_fingerprint,
|
||||
"db_count": db_count,
|
||||
"needs_rebuild": needs_rebuild and not rebuilt,
|
||||
"rebuilt": rebuilt,
|
||||
"queued": False,
|
||||
"registered": result.get("registered"),
|
||||
"failed": result.get("failed"),
|
||||
}
|
||||
|
||||
|
||||
sbas_insar_catalog_service = SbasInsarCatalogService()
|
||||
@@ -13,7 +13,14 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from shapely.geometry import box as shapely_box
|
||||
|
||||
from ..config import settings
|
||||
from .admin_region_lookup_service import (
|
||||
admin_region_matches,
|
||||
lookup_admin_region_for_point,
|
||||
lookup_admin_region_geometry,
|
||||
)
|
||||
|
||||
|
||||
PRODUCT_DEFINITIONS = (
|
||||
@@ -745,7 +752,7 @@ class SbasInsarProductionService:
|
||||
"default_strategy": "gamma_geocode_back_data2geotiff_los_sign_conversion",
|
||||
"geocoded_preview_source": "EPSG:4326 GeoTIFF",
|
||||
},
|
||||
"monitor_point_modes": ["auto_low_sigma_high_rate", "manual_lonlat"],
|
||||
"monitor_point_modes": ["auto_representative_points", "auto_low_sigma_high_rate", "manual_lonlat"],
|
||||
"default_los_convention": {
|
||||
"key": "los_rate_toward_mm_per_year",
|
||||
"description": "toward radar positive; away from radar negative",
|
||||
@@ -778,10 +785,20 @@ class SbasInsarProductionService:
|
||||
platform: str | None = None,
|
||||
relative_orbit: str | None = None,
|
||||
orbit_direction: str | None = None,
|
||||
admin_region: str | None = None,
|
||||
discovery_mode: str = "strict",
|
||||
aoi_bbox: dict[str, Any] | None = None,
|
||||
min_aoi_coverage_ratio: float = 0.01,
|
||||
min_common_overlap_ratio: float = 0.0,
|
||||
force_refresh: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
source_paths = self._resolve_source_roots(source_roots)
|
||||
orbit_paths = self._resolve_orbit_roots(orbit_roots)
|
||||
normalized_mode = self._normalize_discovery_mode(discovery_mode)
|
||||
min_aoi_coverage_ratio = max(0.0, min(1.0, float(min_aoi_coverage_ratio or 0.0)))
|
||||
min_common_overlap_ratio = max(0.0, min(1.0, float(min_common_overlap_ratio or 0.0)))
|
||||
discovery_aoi = self._build_discovery_aoi(admin_region=admin_region, aoi_bbox=aoi_bbox)
|
||||
effective_mode = "aoi" if normalized_mode == "aoi" and discovery_aoi.get("geometry") is not None else "strict"
|
||||
cache_key = self._discovery_cache_key(
|
||||
source_paths=source_paths,
|
||||
orbit_paths=orbit_paths,
|
||||
@@ -792,6 +809,11 @@ class SbasInsarProductionService:
|
||||
platform=platform,
|
||||
relative_orbit=relative_orbit,
|
||||
orbit_direction=orbit_direction,
|
||||
admin_region=admin_region,
|
||||
discovery_mode=effective_mode,
|
||||
aoi_bbox=aoi_bbox,
|
||||
min_aoi_coverage_ratio=min_aoi_coverage_ratio,
|
||||
min_common_overlap_ratio=min_common_overlap_ratio,
|
||||
)
|
||||
if not force_refresh:
|
||||
cached = self._read_discovery_cache(cache_key)
|
||||
@@ -804,6 +826,7 @@ class SbasInsarProductionService:
|
||||
platform_filter = str(platform or "").strip().upper()
|
||||
rel_filter = str(relative_orbit or "").strip()
|
||||
direction_filter = str(orbit_direction or "").strip().upper()
|
||||
aoi_geometry = discovery_aoi.get("geometry") if effective_mode == "aoi" else None
|
||||
|
||||
for root in source_paths:
|
||||
try:
|
||||
@@ -819,18 +842,54 @@ class SbasInsarProductionService:
|
||||
continue
|
||||
if direction_filter and str(scene.get("orbit_direction") or "").upper() != direction_filter:
|
||||
continue
|
||||
if aoi_geometry is not None:
|
||||
scene = self._scene_with_aoi_metrics(scene, aoi_geometry)
|
||||
if not scene.get("aoi_intersects"):
|
||||
continue
|
||||
if float(scene.get("aoi_overlap_ratio") or 0.0) < min_aoi_coverage_ratio:
|
||||
continue
|
||||
scenes.append(scene)
|
||||
except Exception as exc:
|
||||
errors.append({"source_root": str(root), "error": str(exc)})
|
||||
|
||||
grouped: dict[str, list[dict[str, Any]]] = {}
|
||||
grouped_initial: dict[str, list[dict[str, Any]]] = {}
|
||||
for scene in scenes:
|
||||
grouped.setdefault(self._stack_group_key(scene), []).append(scene)
|
||||
group_key = self._aoi_stack_group_key(scene) if effective_mode == "aoi" else self._stack_group_key(scene)
|
||||
grouped_initial.setdefault(group_key, []).append(scene)
|
||||
|
||||
if effective_mode == "aoi":
|
||||
grouped: dict[str, list[dict[str, Any]]] = {}
|
||||
for observation_key, group_scenes in grouped_initial.items():
|
||||
for cluster in self._cluster_aoi_scenes(group_scenes):
|
||||
cluster_key = self._aoi_cluster_key(observation_key, cluster)
|
||||
clustered_scenes = [
|
||||
{
|
||||
**scene,
|
||||
"aoi_cluster_key": cluster_key,
|
||||
"aoi_cluster_source": "footprint_common_overlap",
|
||||
}
|
||||
for scene in cluster
|
||||
]
|
||||
grouped[cluster_key] = clustered_scenes
|
||||
else:
|
||||
grouped = grouped_initial
|
||||
|
||||
candidates = [
|
||||
self._build_stack_candidate(group_scenes, min_scenes=min_scenes, require_orbits=require_orbits)
|
||||
self._build_stack_candidate(
|
||||
group_scenes,
|
||||
min_scenes=min_scenes,
|
||||
require_orbits=require_orbits,
|
||||
discovery_mode=effective_mode,
|
||||
aoi_summary=discovery_aoi.get("summary"),
|
||||
min_common_overlap_ratio=min_common_overlap_ratio,
|
||||
)
|
||||
for group_scenes in grouped.values()
|
||||
]
|
||||
if admin_region and effective_mode != "aoi":
|
||||
candidates = [
|
||||
candidate for candidate in candidates
|
||||
if admin_region_matches(candidate.get("admin_region"), admin_region)
|
||||
]
|
||||
candidates.sort(
|
||||
key=lambda item: (
|
||||
int(item.get("status") != "READY"),
|
||||
@@ -852,6 +911,11 @@ class SbasInsarProductionService:
|
||||
"orbit_roots": [str(path) for path in orbit_paths],
|
||||
"min_scenes": min_scenes,
|
||||
"require_orbits": require_orbits,
|
||||
"discovery_mode": effective_mode,
|
||||
"requested_discovery_mode": normalized_mode,
|
||||
"aoi": discovery_aoi.get("summary"),
|
||||
"min_aoi_coverage_ratio": min_aoi_coverage_ratio,
|
||||
"min_common_overlap_ratio": min_common_overlap_ratio,
|
||||
"scene_count": len(scenes),
|
||||
"candidate_count": len(candidates),
|
||||
"errors": errors[:50],
|
||||
@@ -874,6 +938,11 @@ class SbasInsarProductionService:
|
||||
orbit_roots: list[str] | None = None,
|
||||
min_scenes: int = 3,
|
||||
require_orbits: bool = True,
|
||||
discovery_mode: str = "strict",
|
||||
admin_region: str | None = None,
|
||||
aoi_bbox: dict[str, Any] | None = None,
|
||||
min_aoi_coverage_ratio: float = 0.01,
|
||||
min_common_overlap_ratio: float = 0.0,
|
||||
) -> dict[str, Any]:
|
||||
discovery = self.discover_stacks(
|
||||
source_roots=source_roots,
|
||||
@@ -882,6 +951,11 @@ class SbasInsarProductionService:
|
||||
require_orbits=require_orbits,
|
||||
include_scenes=True,
|
||||
limit=0,
|
||||
discovery_mode=discovery_mode,
|
||||
admin_region=admin_region,
|
||||
aoi_bbox=aoi_bbox,
|
||||
min_aoi_coverage_ratio=min_aoi_coverage_ratio,
|
||||
min_common_overlap_ratio=min_common_overlap_ratio,
|
||||
)
|
||||
candidate = next(
|
||||
(item for item in discovery.get("items", []) if item.get("stack_id") == stack_id),
|
||||
@@ -927,6 +1001,9 @@ class SbasInsarProductionService:
|
||||
"status": "READY_FOR_GAMMA_BASELINE_AUDIT" if not blockers else "BLOCKED",
|
||||
"require_orbits": require_orbits,
|
||||
"min_scenes": min_scenes,
|
||||
"discovery_mode": candidate.get("discovery_mode") or discovery.get("discovery_mode") or "strict",
|
||||
"aoi": candidate.get("aoi") or discovery.get("aoi"),
|
||||
"common_overlap_ratio": candidate.get("common_overlap_ratio"),
|
||||
"stack": {
|
||||
key: candidate.get(key)
|
||||
for key in [
|
||||
@@ -941,6 +1018,7 @@ class SbasInsarProductionService:
|
||||
"reference_date",
|
||||
]
|
||||
},
|
||||
"geographic_coverage": self._build_stack_geographic_coverage({"scenes": usable_scenes}),
|
||||
"scenes": usable_scenes,
|
||||
"excluded_scenes": [
|
||||
scene for scene in candidate.get("scenes", [])
|
||||
@@ -983,7 +1061,12 @@ class SbasInsarProductionService:
|
||||
min_scenes: int = 3,
|
||||
require_orbits: bool = True,
|
||||
monitor_points: list[dict[str, Any]] | None = None,
|
||||
monitor_point_strategy: str = "auto_low_sigma_high_rate",
|
||||
monitor_point_strategy: str = "auto_representative_points",
|
||||
discovery_mode: str = "strict",
|
||||
admin_region: str | None = None,
|
||||
aoi_bbox: dict[str, Any] | None = None,
|
||||
min_aoi_coverage_ratio: float = 0.01,
|
||||
min_common_overlap_ratio: float = 0.0,
|
||||
dry_run: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
audit = self.audit_stack(
|
||||
@@ -992,6 +1075,11 @@ class SbasInsarProductionService:
|
||||
orbit_roots=orbit_roots,
|
||||
min_scenes=min_scenes,
|
||||
require_orbits=require_orbits,
|
||||
discovery_mode=discovery_mode,
|
||||
admin_region=admin_region,
|
||||
aoi_bbox=aoi_bbox,
|
||||
min_aoi_coverage_ratio=min_aoi_coverage_ratio,
|
||||
min_common_overlap_ratio=min_common_overlap_ratio,
|
||||
)
|
||||
manifest = audit["manifest"]
|
||||
if manifest.get("status") != "READY_FOR_GAMMA_BASELINE_AUDIT":
|
||||
@@ -1023,6 +1111,9 @@ class SbasInsarProductionService:
|
||||
"status": "WORKFLOW_READY",
|
||||
"created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
|
||||
"stack_id": stack_id,
|
||||
"discovery_mode": manifest.get("discovery_mode"),
|
||||
"aoi": manifest.get("aoi"),
|
||||
"common_overlap_ratio": manifest.get("common_overlap_ratio"),
|
||||
"stack_manifest_path": audit["manifest_path"],
|
||||
"pair_network_path": audit["pair_network_path"],
|
||||
"workflow_manifest_path": str(run_dir / "manifest.json"),
|
||||
@@ -1106,6 +1197,7 @@ class SbasInsarProductionService:
|
||||
}
|
||||
workflow_state = self._read_optional_json(run_dir / "state" / "step_status.json")
|
||||
monitor_points = self._read_optional_json(run_dir / "monitor_points.json")
|
||||
geographic_coverage = self._build_run_geographic_coverage(run_dir, manifest)
|
||||
return {
|
||||
"run": self._build_run_card(run_dir, manifest),
|
||||
"manifest": manifest,
|
||||
@@ -1113,6 +1205,7 @@ class SbasInsarProductionService:
|
||||
"workflow_manifest": workflow_manifest,
|
||||
"workflow_state": workflow_state,
|
||||
"monitor_points": monitor_points,
|
||||
"geographic_coverage": geographic_coverage,
|
||||
"artifacts": self._build_run_artifacts(run_dir),
|
||||
}
|
||||
|
||||
@@ -2483,6 +2576,9 @@ class SbasInsarProductionService:
|
||||
},
|
||||
"outputs": {
|
||||
"export_dir": str(export_dir),
|
||||
"vector_dir": str(run_dir / "publish" / "vectors"),
|
||||
"point_vector_geojson_gz": str(run_dir / "publish" / "vectors" / "los_rate_points.geojson.gz"),
|
||||
"point_vector_summary": str(run_dir / "publish" / "vectors" / "los_rate_points_summary.json"),
|
||||
"product_summary": str(run_dir / "product_summary.json"),
|
||||
"quality_summary": str(run_dir / "quality_summary.json"),
|
||||
},
|
||||
@@ -3614,6 +3710,11 @@ class SbasInsarProductionService:
|
||||
platform: str | None,
|
||||
relative_orbit: str | None,
|
||||
orbit_direction: str | None,
|
||||
admin_region: str | None,
|
||||
discovery_mode: str,
|
||||
aoi_bbox: dict[str, Any] | None,
|
||||
min_aoi_coverage_ratio: float,
|
||||
min_common_overlap_ratio: float,
|
||||
) -> str:
|
||||
payload = {
|
||||
"source_paths": [os.path.normcase(str(path.resolve())) for path in source_paths],
|
||||
@@ -3633,6 +3734,12 @@ class SbasInsarProductionService:
|
||||
"platform": str(platform or "").strip().upper(),
|
||||
"relative_orbit": str(relative_orbit or "").strip(),
|
||||
"orbit_direction": str(orbit_direction or "").strip().upper(),
|
||||
"admin_region": str(admin_region or "").strip(),
|
||||
"discovery_mode": str(discovery_mode or "strict").strip().lower(),
|
||||
"aoi_bbox": SbasInsarProductionService._normalize_bbox(aoi_bbox),
|
||||
"min_aoi_coverage_ratio": float(min_aoi_coverage_ratio),
|
||||
"min_common_overlap_ratio": float(min_common_overlap_ratio),
|
||||
"response_shape": "aoi_discovery_v1",
|
||||
}
|
||||
return hashlib.sha1(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
@@ -4433,6 +4540,249 @@ class SbasInsarProductionService:
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_bbox(value: Any) -> dict[str, float] | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
try:
|
||||
min_lon = float(value["min_lon"])
|
||||
min_lat = float(value["min_lat"])
|
||||
max_lon = float(value["max_lon"])
|
||||
max_lat = float(value["max_lat"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
if min_lon >= max_lon or min_lat >= max_lat:
|
||||
return None
|
||||
return {
|
||||
"min_lon": min_lon,
|
||||
"min_lat": min_lat,
|
||||
"max_lon": max_lon,
|
||||
"max_lat": max_lat,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _bbox_to_geojson_feature(cls, bbox: dict[str, Any] | None, *, properties: dict[str, Any] | None = None) -> dict[str, Any] | None:
|
||||
normalized = cls._normalize_bbox(bbox)
|
||||
if not normalized:
|
||||
return None
|
||||
min_lon = normalized["min_lon"]
|
||||
min_lat = normalized["min_lat"]
|
||||
max_lon = normalized["max_lon"]
|
||||
max_lat = normalized["max_lat"]
|
||||
return {
|
||||
"type": "Feature",
|
||||
"properties": properties or {},
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [[
|
||||
[min_lon, min_lat],
|
||||
[max_lon, min_lat],
|
||||
[max_lon, max_lat],
|
||||
[min_lon, max_lat],
|
||||
[min_lon, min_lat],
|
||||
]],
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _point_to_geojson_feature(point: dict[str, Any] | None, *, properties: dict[str, Any] | None = None) -> dict[str, Any] | None:
|
||||
if not isinstance(point, dict):
|
||||
return None
|
||||
try:
|
||||
lon = float(point["lon"])
|
||||
lat = float(point["lat"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
return {
|
||||
"type": "Feature",
|
||||
"properties": properties or {},
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [lon, lat],
|
||||
},
|
||||
}
|
||||
|
||||
def _build_stack_geographic_coverage(self, stack_manifest: dict[str, Any]) -> dict[str, Any]:
|
||||
scenes = stack_manifest.get("scenes") or []
|
||||
usable_scenes = [
|
||||
scene for scene in scenes
|
||||
if isinstance(scene, dict) and isinstance(scene.get("bbox"), dict)
|
||||
]
|
||||
bbox_union = self._stack_bbox_union(stack_manifest)
|
||||
bbox_intersection = self._bbox_intersection([scene.get("bbox") for scene in usable_scenes])
|
||||
center = self._stack_center(stack_manifest)
|
||||
union_feature = self._bbox_to_geojson_feature(
|
||||
bbox_union,
|
||||
properties={
|
||||
"role": "stack_bbox_union",
|
||||
"source": "lt1_scene_metadata",
|
||||
"scene_count": len(usable_scenes),
|
||||
},
|
||||
)
|
||||
intersection_feature = self._bbox_to_geojson_feature(
|
||||
bbox_intersection,
|
||||
properties={
|
||||
"role": "stack_bbox_intersection",
|
||||
"source": "lt1_scene_metadata",
|
||||
"scene_count": len(usable_scenes),
|
||||
},
|
||||
)
|
||||
center_feature = self._point_to_geojson_feature(
|
||||
center,
|
||||
properties={"role": "stack_center", "source": "scene_centers_or_bbox"},
|
||||
)
|
||||
scene_features: list[dict[str, Any]] = []
|
||||
for scene in usable_scenes:
|
||||
feature = self._bbox_to_geojson_feature(
|
||||
scene.get("bbox"),
|
||||
properties={
|
||||
"role": "scene_bbox",
|
||||
"scene_name": scene.get("scene_name"),
|
||||
"date": scene.get("date"),
|
||||
"satellite": scene.get("satellite"),
|
||||
"relative_orbit": scene.get("relative_orbit"),
|
||||
},
|
||||
)
|
||||
if feature:
|
||||
scene_features.append(feature)
|
||||
overview_features = [
|
||||
item for item in [union_feature, intersection_feature, center_feature]
|
||||
if item
|
||||
]
|
||||
return {
|
||||
"schema": "insar.sbas-geographic-coverage/v1",
|
||||
"crs": "EPSG:4326",
|
||||
"source": "lt1_scene_metadata",
|
||||
"bbox": bbox_union,
|
||||
"bbox_intersection": bbox_intersection,
|
||||
"center": center,
|
||||
"admin_region": lookup_admin_region_for_point(
|
||||
(center or {}).get("lon"),
|
||||
(center or {}).get("lat"),
|
||||
),
|
||||
"scene_bbox_count": len(scene_features),
|
||||
"geojson": {
|
||||
"type": "FeatureCollection",
|
||||
"features": overview_features,
|
||||
},
|
||||
"scene_footprints_geojson": {
|
||||
"type": "FeatureCollection",
|
||||
"features": scene_features,
|
||||
},
|
||||
}
|
||||
|
||||
def _build_run_geographic_coverage(self, run_dir: Path, run_manifest: dict[str, Any]) -> dict[str, Any]:
|
||||
stack_manifest = self._read_optional_json(run_dir / "stack_manifest.json")
|
||||
if not stack_manifest:
|
||||
stack_manifest_path = Path(str(run_manifest.get("stack_manifest_path") or ""))
|
||||
if stack_manifest_path.is_file():
|
||||
stack_manifest = self._read_optional_json(stack_manifest_path)
|
||||
stack_manifest = stack_manifest or {}
|
||||
coverage = self._build_stack_geographic_coverage(stack_manifest)
|
||||
rdc_dem = run_manifest.get("rdc_dem") or {}
|
||||
rdc_dem_summary = (
|
||||
(rdc_dem.get("summary") if isinstance(rdc_dem, dict) else None)
|
||||
or self._read_optional_json(run_dir / "rdc_dem_summary.json")
|
||||
or {}
|
||||
)
|
||||
dem_source = rdc_dem_summary.get("dem_source") or (rdc_dem.get("dem_source") if isinstance(rdc_dem, dict) else None) or {}
|
||||
dem_coverage = self._normalize_bbox(dem_source.get("coverage")) if isinstance(dem_source, dict) else None
|
||||
monitor_summary = (
|
||||
(run_manifest.get("monitor_point_products") or {}).get("summary")
|
||||
or self._read_optional_json(run_dir / "monitor_points_summary.json")
|
||||
or {}
|
||||
)
|
||||
monitor_points: list[dict[str, Any]] = []
|
||||
for item in monitor_summary.get("monitor_outputs") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
metadata = item.get("metadata") or {}
|
||||
lonlat = metadata.get("approx_lonlat") or {}
|
||||
try:
|
||||
lon = float(lonlat["lon"])
|
||||
lat = float(lonlat["lat"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
monitor_points.append(
|
||||
{
|
||||
"point_id": item.get("point_id") or metadata.get("point_id"),
|
||||
"lon": lon,
|
||||
"lat": lat,
|
||||
"selection": metadata.get("selection"),
|
||||
"los_rate_toward_mm_per_year": metadata.get("los_rate_toward_mm_per_year"),
|
||||
"los_sigma_mm_per_year": metadata.get("los_sigma_mm_per_year"),
|
||||
"source": "monitor_points_summary",
|
||||
}
|
||||
)
|
||||
if not monitor_points:
|
||||
for item in monitor_summary.get("monitor_points") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
lon = float(item["lon"])
|
||||
lat = float(item["lat"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
monitor_points.append(
|
||||
{
|
||||
"point_id": item.get("point_id"),
|
||||
"lon": lon,
|
||||
"lat": lat,
|
||||
"selection": item.get("selection"),
|
||||
"los_rate_toward_mm_per_year": item.get("los_rate_toward_mm_per_year"),
|
||||
"los_sigma_mm_per_year": item.get("los_sigma_mm_per_year"),
|
||||
"source": "monitor_points_summary",
|
||||
}
|
||||
)
|
||||
monitor_features = [
|
||||
feature for feature in (
|
||||
self._point_to_geojson_feature(
|
||||
{"lon": point["lon"], "lat": point["lat"]},
|
||||
properties={
|
||||
"role": "monitor_point",
|
||||
"point_id": point.get("point_id"),
|
||||
"selection": point.get("selection"),
|
||||
"los_rate_toward_mm_per_year": point.get("los_rate_toward_mm_per_year"),
|
||||
"los_sigma_mm_per_year": point.get("los_sigma_mm_per_year"),
|
||||
},
|
||||
)
|
||||
for point in monitor_points
|
||||
)
|
||||
if feature
|
||||
]
|
||||
dem_feature = self._bbox_to_geojson_feature(
|
||||
dem_coverage,
|
||||
properties={
|
||||
"role": "dem_coverage",
|
||||
"source": "rdc_dem_summary",
|
||||
"covers_stack_bbox": dem_source.get("covers_stack_bbox"),
|
||||
"covers_stack_center": dem_source.get("covers_stack_center"),
|
||||
},
|
||||
)
|
||||
features = list((coverage.get("geojson") or {}).get("features") or [])
|
||||
if dem_feature:
|
||||
features.append(dem_feature)
|
||||
features.extend(monitor_features)
|
||||
coverage.update(
|
||||
{
|
||||
"source": "run_stack_manifest",
|
||||
"run_id": run_manifest.get("run_id") or run_dir.name,
|
||||
"stack_id": run_manifest.get("stack_id") or stack_manifest.get("stack_id"),
|
||||
"stack": stack_manifest.get("stack") or run_manifest.get("stack") or {},
|
||||
"date_start": min(self._stack_dates(stack_manifest), default=None),
|
||||
"date_end": max(self._stack_dates(stack_manifest), default=None),
|
||||
"dem_coverage": dem_coverage,
|
||||
"dem_covers_stack_bbox": dem_source.get("covers_stack_bbox"),
|
||||
"dem_covers_stack_center": dem_source.get("covers_stack_center"),
|
||||
"monitor_points": monitor_points,
|
||||
"geojson": {
|
||||
"type": "FeatureCollection",
|
||||
"features": features,
|
||||
},
|
||||
}
|
||||
)
|
||||
return coverage
|
||||
|
||||
@staticmethod
|
||||
def _file_record(path: Path) -> dict[str, Any]:
|
||||
exists = path.is_file()
|
||||
@@ -4478,12 +4828,157 @@ class SbasInsarProductionService:
|
||||
]
|
||||
return "|".join(str(part or "") for part in parts)
|
||||
|
||||
@staticmethod
|
||||
def _aoi_stack_group_key(scene: dict[str, Any]) -> str:
|
||||
parts = [
|
||||
scene.get("satellite"),
|
||||
scene.get("satellite_mode"),
|
||||
scene.get("relative_orbit"),
|
||||
scene.get("orbit_direction"),
|
||||
scene.get("imaging_mode"),
|
||||
scene.get("polarization"),
|
||||
]
|
||||
return "|".join(str(part or "") for part in parts)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_discovery_mode(value: str | None) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
return "aoi" if text == "aoi" else "strict"
|
||||
|
||||
def _build_discovery_aoi(
|
||||
self,
|
||||
*,
|
||||
admin_region: str | None,
|
||||
aoi_bbox: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
bbox = self._normalize_bbox(aoi_bbox)
|
||||
if bbox:
|
||||
geometry = shapely_box(
|
||||
bbox["min_lon"],
|
||||
bbox["min_lat"],
|
||||
bbox["max_lon"],
|
||||
bbox["max_lat"],
|
||||
)
|
||||
return {
|
||||
"geometry": geometry,
|
||||
"summary": {
|
||||
"match_status": "matched",
|
||||
"source": "bbox",
|
||||
"bbox": bbox,
|
||||
"display_name": "Custom AOI bbox",
|
||||
},
|
||||
}
|
||||
|
||||
region = lookup_admin_region_geometry(admin_region)
|
||||
if not region:
|
||||
return {"geometry": None, "summary": None}
|
||||
geometry = region.get("geometry")
|
||||
summary = {key: value for key, value in region.items() if key != "geometry"}
|
||||
if geometry is None or getattr(geometry, "is_empty", False):
|
||||
return {"geometry": None, "summary": summary}
|
||||
return {"geometry": geometry, "summary": summary}
|
||||
|
||||
def _scene_with_aoi_metrics(self, scene: dict[str, Any], aoi_geometry: Any) -> dict[str, Any]:
|
||||
bbox = self._normalize_bbox(scene.get("bbox"))
|
||||
if not bbox:
|
||||
return {**scene, "aoi_intersects": False, "aoi_overlap_ratio": 0.0}
|
||||
scene_geometry = shapely_box(
|
||||
bbox["min_lon"],
|
||||
bbox["min_lat"],
|
||||
bbox["max_lon"],
|
||||
bbox["max_lat"],
|
||||
)
|
||||
try:
|
||||
intersects = bool(scene_geometry.intersects(aoi_geometry))
|
||||
except Exception:
|
||||
return {**scene, "aoi_intersects": False, "aoi_overlap_ratio": 0.0}
|
||||
if not intersects:
|
||||
return {**scene, "aoi_intersects": False, "aoi_overlap_ratio": 0.0}
|
||||
try:
|
||||
intersection_area = float(scene_geometry.intersection(aoi_geometry).area or 0.0)
|
||||
scene_area = float(scene_geometry.area or 0.0)
|
||||
aoi_area = float(getattr(aoi_geometry, "area", 0.0) or 0.0)
|
||||
except Exception:
|
||||
intersection_area = 0.0
|
||||
scene_area = 0.0
|
||||
aoi_area = 0.0
|
||||
return {
|
||||
**scene,
|
||||
"aoi_intersects": True,
|
||||
"aoi_overlap_ratio": intersection_area / scene_area if scene_area > 0 else 0.0,
|
||||
"aoi_covered_ratio": intersection_area / aoi_area if aoi_area > 0 else None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _bbox_area(value: dict[str, Any] | None) -> float:
|
||||
if not value:
|
||||
return 0.0
|
||||
try:
|
||||
width = float(value["max_lon"]) - float(value["min_lon"])
|
||||
height = float(value["max_lat"]) - float(value["min_lat"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return 0.0
|
||||
return width * height if width > 0 and height > 0 else 0.0
|
||||
|
||||
def _cluster_aoi_scenes(self, scenes: list[dict[str, Any]]) -> list[list[dict[str, Any]]]:
|
||||
sorted_scenes = sorted(
|
||||
scenes,
|
||||
key=lambda item: (
|
||||
str(item.get("date") or ""),
|
||||
float(item.get("center_lon") or 0.0),
|
||||
float(item.get("center_lat") or 0.0),
|
||||
),
|
||||
)
|
||||
clusters: list[dict[str, Any]] = []
|
||||
for scene in sorted_scenes:
|
||||
scene_bbox = self._normalize_bbox(scene.get("bbox"))
|
||||
if not scene_bbox:
|
||||
continue
|
||||
best_index: int | None = None
|
||||
best_score = -1.0
|
||||
for index, cluster in enumerate(clusters):
|
||||
candidate_intersection = self._bbox_intersection(
|
||||
[cluster.get("bbox_intersection"), scene_bbox]
|
||||
)
|
||||
if not candidate_intersection:
|
||||
continue
|
||||
score = self._bbox_area(candidate_intersection)
|
||||
if score > best_score:
|
||||
best_index = index
|
||||
best_score = score
|
||||
if best_index is None:
|
||||
clusters.append({"bbox_intersection": scene_bbox, "scenes": [scene]})
|
||||
continue
|
||||
cluster = clusters[best_index]
|
||||
cluster["bbox_intersection"] = self._bbox_intersection(
|
||||
[cluster.get("bbox_intersection"), scene_bbox]
|
||||
)
|
||||
cluster["scenes"].append(scene)
|
||||
|
||||
return [cluster["scenes"] for cluster in clusters if cluster.get("scenes")]
|
||||
|
||||
def _aoi_cluster_key(self, observation_key: str, scenes: list[dict[str, Any]]) -> str:
|
||||
bbox = self._bbox_intersection([scene.get("bbox") for scene in scenes])
|
||||
if bbox:
|
||||
lon = (bbox["min_lon"] + bbox["max_lon"]) / 2
|
||||
lat = (bbox["min_lat"] + bbox["max_lat"]) / 2
|
||||
spatial_key = f"overlap_E{lon:.2f}_N{lat:.2f}"
|
||||
else:
|
||||
center = self._stack_center({"scenes": scenes}) or {}
|
||||
lon = self._as_float(center.get("lon"))
|
||||
lat = self._as_float(center.get("lat"))
|
||||
spatial_key = f"center_{self._center_bucket(lon, lat)}"
|
||||
return f"{observation_key}|{spatial_key}"
|
||||
|
||||
def _build_stack_candidate(
|
||||
self,
|
||||
scenes: list[dict[str, Any]],
|
||||
*,
|
||||
min_scenes: int,
|
||||
require_orbits: bool,
|
||||
discovery_mode: str = "strict",
|
||||
aoi_summary: dict[str, Any] | None = None,
|
||||
min_common_overlap_ratio: float = 0.0,
|
||||
) -> dict[str, Any]:
|
||||
scenes = sorted(scenes, key=lambda item: str(item.get("date") or ""))
|
||||
first = scenes[0]
|
||||
@@ -4491,7 +4986,11 @@ class SbasInsarProductionService:
|
||||
usable = orbit_ready if require_orbits else scenes
|
||||
dates = [scene.get("date") for scene in scenes if scene.get("date")]
|
||||
usable_dates = [scene.get("date") for scene in usable if scene.get("date")]
|
||||
group_key = self._stack_group_key(first)
|
||||
mode = self._normalize_discovery_mode(discovery_mode)
|
||||
group_key = (
|
||||
str(first.get("aoi_cluster_key") or "")
|
||||
or (self._aoi_stack_group_key(first) if mode == "aoi" else self._stack_group_key(first))
|
||||
)
|
||||
stack_id = self._stable_id(group_key)
|
||||
temporal_gaps = self._temporal_gaps(usable_dates)
|
||||
blockers: list[str] = []
|
||||
@@ -4499,11 +4998,55 @@ class SbasInsarProductionService:
|
||||
blockers.append(f"usable_scene_count {len(usable)} < min_scenes {min_scenes}")
|
||||
if require_orbits and len(orbit_ready) < len(scenes):
|
||||
blockers.append("missing precise orbit for one or more scenes")
|
||||
usable_stack = {"scenes": usable}
|
||||
bbox_intersection = self._bbox_intersection([scene.get("bbox") for scene in usable])
|
||||
bbox_union = self._stack_bbox_union(usable_stack)
|
||||
common_overlap_ratio = (
|
||||
self._bbox_area(bbox_intersection) / self._bbox_area(bbox_union)
|
||||
if bbox_intersection and bbox_union and self._bbox_area(bbox_union) > 0
|
||||
else 0.0
|
||||
)
|
||||
if mode == "aoi" and usable and not bbox_intersection:
|
||||
blockers.append("no common overlap across usable scenes")
|
||||
if mode == "aoi" and min_common_overlap_ratio > 0 and common_overlap_ratio < min_common_overlap_ratio:
|
||||
blockers.append(
|
||||
f"common_overlap_ratio {common_overlap_ratio:.3f} < min_common_overlap_ratio {min_common_overlap_ratio:.3f}"
|
||||
)
|
||||
center = self._stack_center(usable_stack)
|
||||
admin_region = lookup_admin_region_for_point(
|
||||
(center or {}).get("lon"),
|
||||
(center or {}).get("lat"),
|
||||
)
|
||||
aoi_overlap_values = [
|
||||
float(scene.get("aoi_overlap_ratio") or 0.0)
|
||||
for scene in usable
|
||||
if scene.get("aoi_overlap_ratio") is not None
|
||||
]
|
||||
return {
|
||||
"stack_id": stack_id,
|
||||
"status": "READY" if not blockers else "BLOCKED",
|
||||
"blockers": blockers,
|
||||
"discovery_mode": mode,
|
||||
"aoi": aoi_summary,
|
||||
"group_key": group_key,
|
||||
"hard_group_fields": [
|
||||
"satellite",
|
||||
"satellite_mode",
|
||||
"relative_orbit",
|
||||
"orbit_direction",
|
||||
"imaging_mode",
|
||||
"polarization",
|
||||
] if mode == "aoi" else [
|
||||
"satellite",
|
||||
"satellite_mode",
|
||||
"receiving_station",
|
||||
"relative_orbit",
|
||||
"orbit_direction",
|
||||
"imaging_mode",
|
||||
"polarization",
|
||||
"center_bucket",
|
||||
],
|
||||
"soft_group_fields": ["receiving_station", "center_bucket"] if mode == "aoi" else [],
|
||||
"satellite": first.get("satellite"),
|
||||
"satellite_mode": first.get("satellite_mode"),
|
||||
"receiving_station": first.get("receiving_station"),
|
||||
@@ -4523,7 +5066,17 @@ class SbasInsarProductionService:
|
||||
"reference_date": usable_dates[len(usable_dates) // 2] if usable_dates else None,
|
||||
"temporal_gaps_days": temporal_gaps,
|
||||
"max_temporal_gap_days": max(temporal_gaps) if temporal_gaps else 0,
|
||||
"bbox_intersection": self._bbox_intersection([scene.get("bbox") for scene in usable]),
|
||||
"bbox": bbox_union,
|
||||
"bbox_intersection": bbox_intersection,
|
||||
"common_overlap_ratio": common_overlap_ratio,
|
||||
"aoi_overlap_ratio_min": min(aoi_overlap_values) if aoi_overlap_values else None,
|
||||
"aoi_overlap_ratio_max": max(aoi_overlap_values) if aoi_overlap_values else None,
|
||||
"aoi_overlap_ratio_mean": (
|
||||
sum(aoi_overlap_values) / len(aoi_overlap_values)
|
||||
if aoi_overlap_values else None
|
||||
),
|
||||
"center": center,
|
||||
"admin_region": admin_region,
|
||||
"scenes": scenes,
|
||||
}
|
||||
|
||||
@@ -5072,19 +5625,22 @@ class SbasInsarProductionService:
|
||||
mode = "manual_lonlat"
|
||||
note = "Manual monitoring points are stored for extraction after geocoded products are available."
|
||||
else:
|
||||
mode = strategy or "auto_low_sigma_high_rate"
|
||||
mode = strategy or "auto_representative_points"
|
||||
if mode == "auto_low_sigma_high_rate":
|
||||
mode = "auto_representative_points"
|
||||
note = (
|
||||
"Automatic point is only a production placeholder until users provide a point layer "
|
||||
"or approve a quality-filtered sampler."
|
||||
"Automatic representative points are report-preview candidates until users provide "
|
||||
"a point layer or approve final monitoring locations."
|
||||
)
|
||||
return {
|
||||
"schema": "insar.sbas-monitor-points/v1",
|
||||
"mode": mode,
|
||||
"points": normalized_points,
|
||||
"auto_count": 5,
|
||||
"default_auto_strategy": {
|
||||
"key": "auto_low_sigma_high_rate",
|
||||
"selection": "low LOS sigma, high absolute LOS velocity, non-edge valid pixel",
|
||||
"usage": "debug/sample only; not a business monitoring network",
|
||||
"key": "auto_representative_points",
|
||||
"selection": "away/toward/high-absolute-rate/stable/center valid pixels with low sigma and non-edge constraints",
|
||||
"usage": "preview candidates only; not a business monitoring network",
|
||||
},
|
||||
"reference_date": (stack_manifest.get("stack") or {}).get("reference_date"),
|
||||
"coordinate_system": "EPSG:4326 for manual lon/lat points; radar coordinates are derived during publishing",
|
||||
@@ -6062,6 +6618,14 @@ class SbasInsarProductionService:
|
||||
python_bin = settings.GAMMA_SBAS_PYTHON or settings.WSL_SHARED_PYTHON or settings.PYINT_WSL_PYTHON or "/home/administrator/miniconda3/envs/insar_wsl_v1/bin/python"
|
||||
tool_script = Path(settings.PROJECT_ROOT) / "deploy" / "wsl" / "runners" / "gamma_sbas_product_tools.py"
|
||||
phase_to_los = wavelength / (4.0 * math.pi)
|
||||
stack_manifest = self._read_optional_json(run_dir / "stack_manifest.json")
|
||||
stack_dates = self._stack_dates(stack_manifest)
|
||||
date_start = min(stack_dates, default="")
|
||||
date_end = max(stack_dates, default="")
|
||||
coverage = self._build_stack_geographic_coverage(stack_manifest)
|
||||
admin_region = coverage.get("admin_region") or {}
|
||||
admin_province = str(admin_region.get("province") or "").strip()
|
||||
admin_city = str(admin_region.get("city") or "").strip()
|
||||
lines = [
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
@@ -6081,9 +6645,14 @@ class SbasInsarProductionService:
|
||||
f'RLKS="{rlks}"',
|
||||
f'WAVELENGTH="{wavelength:.12g}"',
|
||||
f'PHASE_TO_LOS="{phase_to_los:.12g}"',
|
||||
f'DATE_START="{date_start}"',
|
||||
f'DATE_END="{date_end}"',
|
||||
f'ADMIN_PROVINCE="{admin_province}"',
|
||||
f'ADMIN_CITY="{admin_city}"',
|
||||
"",
|
||||
f'source "{env_script}" >/dev/null 2>&1',
|
||||
'mkdir -p "${EXPORT_DIR}" "${LOG_DIR}"',
|
||||
'VECTOR_DIR="${RUN_ROOT}/publish/vectors"',
|
||||
'mkdir -p "${EXPORT_DIR}" "${VECTOR_DIR}" "${LOG_DIR}"',
|
||||
"",
|
||||
'RDC_WIDTH="$(awk \'$1 == "range_samples:" {print $2; exit}\' "${MLI_PAR}")"',
|
||||
'GEO_WIDTH="$(awk \'$1 == "width:" {print $2; exit}\' "${DEM_PAR}")"',
|
||||
@@ -6218,7 +6787,21 @@ class SbasInsarProductionService:
|
||||
"",
|
||||
' make_preview "${EXPORT_DIR}/los_rate_toward_mm_per_year.tif" "${RATE_CMAP}" "${EXPORT_DIR}/los_rate_toward_mm_per_year.geo_preview.png"',
|
||||
' make_preview "${EXPORT_DIR}/los_sigma_mm_per_year.tif" "${SIGMA_CMAP}" "${EXPORT_DIR}/los_sigma_mm_per_year.geo_preview.png"',
|
||||
"",
|
||||
' "${PYTHON_BIN}" "${TOOL_SCRIPT}" export-points-geojson \\',
|
||||
' --toward-tif "${EXPORT_DIR}/los_rate_toward_mm_per_year.tif" \\',
|
||||
' --away-tif "${EXPORT_DIR}/los_rate_away_mm_per_year.tif" \\',
|
||||
' --sigma-tif "${EXPORT_DIR}/los_sigma_mm_per_year.tif" \\',
|
||||
' --output "${VECTOR_DIR}/los_rate_points.geojson.gz" \\',
|
||||
' --summary-path "${VECTOR_DIR}/los_rate_points_summary.json" \\',
|
||||
' --run-id "${RUN_ROOT##*/}" \\',
|
||||
' --date-start "${DATE_START}" \\',
|
||||
' --date-end "${DATE_END}" \\',
|
||||
' --reference-date "${REF_DATE}" \\',
|
||||
' --admin-province "${ADMIN_PROVINCE}" \\',
|
||||
' --admin-city "${ADMIN_CITY}"',
|
||||
' ls -lh "${EXPORT_DIR}"',
|
||||
' ls -lh "${VECTOR_DIR}"',
|
||||
'} >"${LOG_DIR}/publish_products.log" 2>&1',
|
||||
"",
|
||||
'echo "Published Gamma SBAS products: ${EXPORT_DIR}"',
|
||||
@@ -6853,6 +7436,12 @@ class SbasInsarProductionService:
|
||||
"los_rate_m_per_year_tif": export_dir / "los_rate_m_per_year.tif",
|
||||
"los_rate_away_m_per_year_hls_bmp": export_dir / "los_rate_away_m_per_year.hls.bmp",
|
||||
}
|
||||
vector_dir = run_dir / "publish" / "vectors"
|
||||
vector_outputs = {
|
||||
"point_vector_geojson_gz": vector_dir / "los_rate_points.geojson.gz",
|
||||
"point_vector_summary": vector_dir / "los_rate_points_summary.json",
|
||||
}
|
||||
point_vector_summary = self._read_optional_json(vector_outputs["point_vector_summary"]) or {}
|
||||
missing_outputs = [
|
||||
name for name, path in required_outputs.items()
|
||||
if not path.is_file() or path.stat().st_size <= 0
|
||||
@@ -6961,8 +7550,10 @@ class SbasInsarProductionService:
|
||||
},
|
||||
"outputs": {
|
||||
"export_dir": str(export_dir),
|
||||
**{name: self._file_record(path) for name, path in {**required_outputs, **optional_outputs}.items()},
|
||||
"vector_dir": str(vector_dir),
|
||||
**{name: self._file_record(path) for name, path in {**required_outputs, **optional_outputs, **vector_outputs}.items()},
|
||||
},
|
||||
"point_vector_summary": point_vector_summary,
|
||||
"rdc_size_checks": rdc_size_checks,
|
||||
"quality_summary": quality_stats,
|
||||
"product_summary": product_summary,
|
||||
@@ -7489,6 +8080,10 @@ class SbasInsarProductionService:
|
||||
|
||||
def _build_run_card(self, run_dir: Path, manifest: dict[str, Any]) -> dict[str, Any]:
|
||||
stack = manifest.get("stack") or {}
|
||||
try:
|
||||
coverage = self._build_run_geographic_coverage(run_dir, manifest)
|
||||
except Exception:
|
||||
coverage = {}
|
||||
return {
|
||||
"run_id": manifest.get("run_id") or run_dir.name,
|
||||
"run_label": manifest.get("run_label"),
|
||||
@@ -7501,12 +8096,19 @@ class SbasInsarProductionService:
|
||||
"scene_count": manifest.get("scene_count"),
|
||||
"pair_count": manifest.get("pair_count"),
|
||||
"next_stage": manifest.get("next_stage"),
|
||||
"discovery_mode": manifest.get("discovery_mode"),
|
||||
"aoi": manifest.get("aoi"),
|
||||
"common_overlap_ratio": manifest.get("common_overlap_ratio"),
|
||||
"platform": stack.get("satellite"),
|
||||
"relative_orbit": stack.get("relative_orbit"),
|
||||
"direction": stack.get("orbit_direction"),
|
||||
"polarization": stack.get("polarization"),
|
||||
"center_bucket": stack.get("center_bucket"),
|
||||
"reference_date": stack.get("reference_date"),
|
||||
"date_start": coverage.get("date_start"),
|
||||
"date_end": coverage.get("date_end"),
|
||||
"center": coverage.get("center"),
|
||||
"admin_region": coverage.get("admin_region"),
|
||||
"run_dir": str(run_dir),
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import gzip
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
@@ -44,7 +45,7 @@ def write_scaled_float32(input_path: Path, output_path: Path, scale: float) -> N
|
||||
(data * float(scale)).astype(">f4", copy=False).tofile(output_path)
|
||||
|
||||
|
||||
def pick_auto_point(rate: np.ndarray, sigma: np.ndarray) -> tuple[int, int]:
|
||||
def monitor_valid_mask(rate: np.ndarray, sigma: np.ndarray) -> np.ndarray:
|
||||
lines, width = rate.shape
|
||||
yy, xx = np.indices(rate.shape)
|
||||
edge_mask = (
|
||||
@@ -57,6 +58,42 @@ def pick_auto_point(rate: np.ndarray, sigma: np.ndarray) -> tuple[int, int]:
|
||||
valid = finite & edge_mask & (rate != 0.0) & (sigma > 0.0)
|
||||
if not valid.any():
|
||||
raise RuntimeError("No valid pixels available for monitor point selection")
|
||||
return valid
|
||||
|
||||
|
||||
def remove_near_selected(candidate: np.ndarray, selected: list[tuple[int, int]], min_distance: int) -> np.ndarray:
|
||||
if not selected:
|
||||
return candidate
|
||||
yy, xx = np.indices(candidate.shape)
|
||||
filtered = candidate.copy()
|
||||
min_distance_sq = float(min_distance * min_distance)
|
||||
for x, y in selected:
|
||||
filtered &= ((xx - float(x)) ** 2 + (yy - float(y)) ** 2) >= min_distance_sq
|
||||
return filtered
|
||||
|
||||
|
||||
def pick_scored_point(
|
||||
score: np.ndarray,
|
||||
candidate: np.ndarray,
|
||||
selected: list[tuple[int, int]],
|
||||
*,
|
||||
min_distance: int,
|
||||
) -> tuple[int, int] | None:
|
||||
filtered = remove_near_selected(candidate, selected, min_distance)
|
||||
if not filtered.any():
|
||||
filtered = candidate
|
||||
if not filtered.any():
|
||||
return None
|
||||
safe_score = np.full(score.shape, -np.inf, dtype=np.float64)
|
||||
safe_score[filtered] = score[filtered]
|
||||
y, x = np.unravel_index(int(np.nanargmax(safe_score)), score.shape)
|
||||
if not np.isfinite(safe_score[y, x]):
|
||||
return None
|
||||
return int(x), int(y)
|
||||
|
||||
|
||||
def pick_auto_point(rate: np.ndarray, sigma: np.ndarray) -> tuple[int, int]:
|
||||
valid = monitor_valid_mask(rate, sigma)
|
||||
|
||||
abs_rate = np.abs(rate[valid])
|
||||
sig = sigma[valid]
|
||||
@@ -73,6 +110,97 @@ def pick_auto_point(rate: np.ndarray, sigma: np.ndarray) -> tuple[int, int]:
|
||||
return int(x), int(y)
|
||||
|
||||
|
||||
def pick_auto_points(rate: np.ndarray, sigma: np.ndarray, *, count: int = 5) -> list[dict[str, Any]]:
|
||||
valid = monitor_valid_mask(rate, sigma)
|
||||
lines, width = rate.shape
|
||||
yy, xx = np.indices(rate.shape)
|
||||
min_distance = max(24, int(min(width, lines) * 0.08))
|
||||
sigma_max = float(np.percentile(sigma[valid], 40))
|
||||
low_sigma = valid & (sigma <= sigma_max)
|
||||
if not low_sigma.any():
|
||||
low_sigma = valid
|
||||
|
||||
abs_rate = np.abs(rate)
|
||||
abs_valid = abs_rate[valid]
|
||||
high_abs_min = float(np.percentile(abs_valid, 85))
|
||||
high_abs_max = float(np.percentile(abs_valid, 99))
|
||||
low_abs_max = float(np.percentile(abs_valid, 25))
|
||||
cx = (width - 1) / 2.0
|
||||
cy = (lines - 1) / 2.0
|
||||
|
||||
definitions = [
|
||||
{
|
||||
"point_id": "auto_away_high_rate_low_sigma",
|
||||
"selection": "automatic_away_from_radar_high_rate_low_sigma_non_edge",
|
||||
"candidate": low_sigma & (rate < 0.0) & (abs_rate >= high_abs_min) & (abs_rate <= high_abs_max),
|
||||
"score": (-rate) / (sigma + 1.0e-6),
|
||||
},
|
||||
{
|
||||
"point_id": "auto_toward_high_rate_low_sigma",
|
||||
"selection": "automatic_toward_radar_high_rate_low_sigma_non_edge",
|
||||
"candidate": low_sigma & (rate > 0.0) & (abs_rate >= high_abs_min) & (abs_rate <= high_abs_max),
|
||||
"score": rate / (sigma + 1.0e-6),
|
||||
},
|
||||
{
|
||||
"point_id": "auto_low_sigma_high_rate",
|
||||
"selection": "automatic_low_sigma_high_abs_rate_non_edge",
|
||||
"candidate": low_sigma & (abs_rate >= high_abs_min) & (abs_rate <= high_abs_max),
|
||||
"score": abs_rate / (sigma + 1.0e-6),
|
||||
},
|
||||
{
|
||||
"point_id": "auto_stable_low_sigma",
|
||||
"selection": "automatic_near_zero_rate_low_sigma_non_edge",
|
||||
"candidate": low_sigma & (abs_rate <= low_abs_max),
|
||||
"score": 1.0 / ((abs_rate + 1.0) * (sigma + 1.0e-6)),
|
||||
},
|
||||
{
|
||||
"point_id": "auto_center_valid",
|
||||
"selection": "automatic_valid_pixel_nearest_stack_center",
|
||||
"candidate": valid,
|
||||
"score": -((xx - cx) ** 2 + (yy - cy) ** 2),
|
||||
},
|
||||
]
|
||||
|
||||
selected_xy: list[tuple[int, int]] = []
|
||||
selected_points: list[dict[str, Any]] = []
|
||||
for definition in definitions:
|
||||
if len(selected_points) >= count:
|
||||
break
|
||||
candidate = definition["candidate"]
|
||||
if not candidate.any():
|
||||
candidate = low_sigma if low_sigma.any() else valid
|
||||
picked = pick_scored_point(
|
||||
np.asarray(definition["score"], dtype=np.float64),
|
||||
candidate,
|
||||
selected_xy,
|
||||
min_distance=min_distance,
|
||||
)
|
||||
if picked is None:
|
||||
continue
|
||||
x, y = picked
|
||||
selected_xy.append((x, y))
|
||||
selected_points.append(
|
||||
{
|
||||
"point_id": definition["point_id"],
|
||||
"selection": definition["selection"],
|
||||
"range_pixel": x,
|
||||
"azimuth_line": y,
|
||||
}
|
||||
)
|
||||
|
||||
if not selected_points:
|
||||
x, y = pick_auto_point(rate, sigma)
|
||||
selected_points.append(
|
||||
{
|
||||
"point_id": "auto_low_sigma_high_rate",
|
||||
"selection": "automatic_low_sigma_high_rate_non_edge",
|
||||
"range_pixel": x,
|
||||
"azimuth_line": y,
|
||||
}
|
||||
)
|
||||
return selected_points[:count]
|
||||
|
||||
|
||||
def dem_grid(dem_par: Path) -> dict[str, float | int]:
|
||||
return {
|
||||
"width": int(read_gamma_value(dem_par, "width")),
|
||||
@@ -218,6 +346,190 @@ def write_point_outputs(
|
||||
return {"png": str(png_path), "csv": str(csv_path), "metadata": str(json_path)}
|
||||
|
||||
|
||||
def read_geotiff_float32(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
import rasterio
|
||||
|
||||
with rasterio.open(path) as src:
|
||||
transform = src.transform
|
||||
return {
|
||||
"array": src.read(1).astype(np.float32, copy=False),
|
||||
"width": src.width,
|
||||
"height": src.height,
|
||||
"nodata": src.nodata,
|
||||
"crs": src.crs.to_string() if src.crs else None,
|
||||
"transform": (transform.a, transform.b, transform.c, transform.d, transform.e, transform.f),
|
||||
}
|
||||
except Exception as rasterio_exc:
|
||||
try:
|
||||
from osgeo import gdal
|
||||
except Exception as gdal_exc:
|
||||
raise RuntimeError("rasterio or osgeo.gdal is required to read GeoTIFF files") from gdal_exc
|
||||
dataset = gdal.Open(str(path), gdal.GA_ReadOnly)
|
||||
if dataset is None:
|
||||
raise RuntimeError(f"Unable to open GeoTIFF: {path}") from rasterio_exc
|
||||
band = dataset.GetRasterBand(1)
|
||||
array = band.ReadAsArray().astype(np.float32, copy=False)
|
||||
geotransform = dataset.GetGeoTransform()
|
||||
return {
|
||||
"array": array,
|
||||
"width": int(dataset.RasterXSize),
|
||||
"height": int(dataset.RasterYSize),
|
||||
"nodata": band.GetNoDataValue(),
|
||||
"crs": dataset.GetProjection() or None,
|
||||
"transform": (
|
||||
float(geotransform[1]),
|
||||
float(geotransform[2]),
|
||||
float(geotransform[0]),
|
||||
float(geotransform[4]),
|
||||
float(geotransform[5]),
|
||||
float(geotransform[3]),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def normalize_crs_label(value: Any) -> str | None:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
upper = text.upper()
|
||||
if "EPSG" in upper and "4326" in upper:
|
||||
return "EPSG:4326"
|
||||
if "WGS 84" in upper or "WGS_1984" in upper:
|
||||
return "EPSG:4326"
|
||||
return text[:240]
|
||||
|
||||
|
||||
def pixel_center(transform: tuple[float, float, float, float, float, float], row: int, col: int) -> tuple[float, float]:
|
||||
a, b, c, d, e, f = transform
|
||||
x = c + (col + 0.5) * a + (row + 0.5) * b
|
||||
y = f + (col + 0.5) * d + (row + 0.5) * e
|
||||
return float(x), float(y)
|
||||
|
||||
|
||||
def run_export_points_geojson(args: argparse.Namespace) -> int:
|
||||
|
||||
toward_path = Path(args.toward_tif)
|
||||
away_path = Path(args.away_tif)
|
||||
sigma_path = Path(args.sigma_tif)
|
||||
output_path = Path(args.output)
|
||||
summary_path = Path(args.summary_path)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
summary_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
run_id = str(args.run_id or "").strip()
|
||||
date_start = str(args.date_start or "").strip()
|
||||
date_end = str(args.date_end or "").strip()
|
||||
reference_date = str(args.reference_date or "").strip()
|
||||
admin_province = str(args.admin_province or "").strip()
|
||||
admin_city = str(args.admin_city or "").strip()
|
||||
|
||||
fields = [
|
||||
"run_id",
|
||||
"row",
|
||||
"col",
|
||||
"lon",
|
||||
"lat",
|
||||
"los_rate_toward_mm_per_year",
|
||||
"los_rate_away_mm_per_year",
|
||||
"los_sigma_mm_per_year",
|
||||
"date_start",
|
||||
"date_end",
|
||||
"reference_date",
|
||||
"admin_province",
|
||||
"admin_city",
|
||||
]
|
||||
|
||||
toward_meta = read_geotiff_float32(toward_path)
|
||||
away_meta = read_geotiff_float32(away_path)
|
||||
sigma_meta = read_geotiff_float32(sigma_path)
|
||||
width = int(toward_meta["width"])
|
||||
height = int(toward_meta["height"])
|
||||
if (width, height) != (int(away_meta["width"]), int(away_meta["height"])):
|
||||
raise RuntimeError("toward and away GeoTIFF dimensions do not match")
|
||||
if (width, height) != (int(sigma_meta["width"]), int(sigma_meta["height"])):
|
||||
raise RuntimeError("toward and sigma GeoTIFF dimensions do not match")
|
||||
|
||||
toward = np.asarray(toward_meta["array"], dtype=np.float32)
|
||||
away = np.asarray(away_meta["array"], dtype=np.float32)
|
||||
sigma = np.asarray(sigma_meta["array"], dtype=np.float32)
|
||||
valid = np.isfinite(toward) & np.isfinite(away) & np.isfinite(sigma) & (sigma > 0.0)
|
||||
if toward_meta.get("nodata") is not None:
|
||||
valid &= toward != float(toward_meta["nodata"])
|
||||
if away_meta.get("nodata") is not None:
|
||||
valid &= away != float(away_meta["nodata"])
|
||||
if sigma_meta.get("nodata") is not None:
|
||||
valid &= sigma != float(sigma_meta["nodata"])
|
||||
|
||||
transform = tuple(float(value) for value in toward_meta["transform"])
|
||||
feature_count = 0
|
||||
with gzip.open(output_path, "wt", encoding="utf-8", compresslevel=6) as handle:
|
||||
handle.write('{"type":"FeatureCollection","features":[\n')
|
||||
first = True
|
||||
for row in range(height):
|
||||
cols = np.where(valid[row])[0]
|
||||
for col in cols.tolist():
|
||||
lon, lat = pixel_center(transform, row, int(col))
|
||||
properties = {
|
||||
"run_id": run_id,
|
||||
"row": int(row),
|
||||
"col": int(col),
|
||||
"lon": lon,
|
||||
"lat": lat,
|
||||
"los_rate_toward_mm_per_year": float(toward[row, col]),
|
||||
"los_rate_away_mm_per_year": float(away[row, col]),
|
||||
"los_sigma_mm_per_year": float(sigma[row, col]),
|
||||
"date_start": date_start,
|
||||
"date_end": date_end,
|
||||
"reference_date": reference_date,
|
||||
"admin_province": admin_province,
|
||||
"admin_city": admin_city,
|
||||
}
|
||||
feature = {
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Point", "coordinates": [lon, lat]},
|
||||
"properties": properties,
|
||||
}
|
||||
if not first:
|
||||
handle.write(",\n")
|
||||
handle.write(json.dumps(feature, ensure_ascii=False, separators=(",", ":")))
|
||||
first = False
|
||||
feature_count += 1
|
||||
handle.write("\n]}\n")
|
||||
|
||||
crs = normalize_crs_label(toward_meta.get("crs"))
|
||||
|
||||
summary = {
|
||||
"schema": "insar.gamma-sbas-point-vector-summary/v1",
|
||||
"generated_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
|
||||
"ready": output_path.is_file() and output_path.stat().st_size > 0,
|
||||
"feature_count": feature_count,
|
||||
"output_geojson_gz": str(output_path),
|
||||
"output_size_bytes": output_path.stat().st_size if output_path.is_file() else 0,
|
||||
"fields": fields,
|
||||
"source_geotiffs": {
|
||||
"los_rate_toward_mm_per_year": str(toward_path),
|
||||
"los_rate_away_mm_per_year": str(away_path),
|
||||
"los_sigma_mm_per_year": str(sigma_path),
|
||||
},
|
||||
"width": width,
|
||||
"height": height,
|
||||
"crs": crs,
|
||||
"date_start": date_start,
|
||||
"date_end": date_end,
|
||||
"reference_date": reference_date,
|
||||
"admin_region": {
|
||||
"province": admin_province or None,
|
||||
"city": admin_city or None,
|
||||
},
|
||||
"los_convention": "toward radar positive; away from radar negative",
|
||||
"frontend_policy": "download_only; do not render full point GeoJSON in browser",
|
||||
}
|
||||
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 run_phase_to_los(args: argparse.Namespace) -> int:
|
||||
write_scaled_float32(Path(args.input), Path(args.output), float(args.scale))
|
||||
return 0
|
||||
@@ -273,18 +585,10 @@ def run_monitor_points(args: argparse.Namespace) -> int:
|
||||
}
|
||||
)
|
||||
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,
|
||||
}
|
||||
)
|
||||
auto_count = int(config.get("auto_count") or 5)
|
||||
for point in pick_auto_points(rate_toward, sigma, count=max(1, min(auto_count, 12))):
|
||||
lon, lat = radar_to_lonlat(int(point["range_pixel"]), int(point["azimuth_line"]), dem_par, lookup)
|
||||
selected_points.append({**point, "lon": lon, "lat": lat})
|
||||
|
||||
outputs: list[dict[str, Any]] = []
|
||||
for point in selected_points:
|
||||
@@ -343,6 +647,20 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
phase.add_argument("scale", type=float)
|
||||
phase.set_defaults(func=run_phase_to_los)
|
||||
|
||||
vector = subparsers.add_parser("export-points-geojson")
|
||||
vector.add_argument("--toward-tif", required=True)
|
||||
vector.add_argument("--away-tif", required=True)
|
||||
vector.add_argument("--sigma-tif", required=True)
|
||||
vector.add_argument("--output", required=True)
|
||||
vector.add_argument("--summary-path", required=True)
|
||||
vector.add_argument("--run-id", default="")
|
||||
vector.add_argument("--date-start", default="")
|
||||
vector.add_argument("--date-end", default="")
|
||||
vector.add_argument("--reference-date", default="")
|
||||
vector.add_argument("--admin-province", default="")
|
||||
vector.add_argument("--admin-city", default="")
|
||||
vector.set_defaults(func=run_export_points_geojson)
|
||||
|
||||
monitor = subparsers.add_parser("monitor-points")
|
||||
monitor.add_argument("--monitor-config", required=True)
|
||||
monitor.add_argument("--timeseries-dir", required=True)
|
||||
|
||||
@@ -497,6 +497,120 @@ Recommended order:
|
||||
|
||||
The first user-visible win is step 1-2: the operator can immediately see whether a Run covers the intended location.
|
||||
|
||||
## 10.1 Implementation Note 2026-05-27
|
||||
|
||||
Implemented the first slice after commit `9f0ba32`:
|
||||
|
||||
```text
|
||||
backend/app/services/sbas_insar_production_service.py
|
||||
frontend/src/SbasInsarProductionPanel.jsx
|
||||
```
|
||||
|
||||
Backend now returns `geographic_coverage` from `GET /api/sbas-insar-production/runs/{run_id}`. The field is derived from `stack_manifest.json`, `rdc_dem_summary.json`, and `monitor_points_summary.json` without changing the Gamma expert workflow outputs.
|
||||
|
||||
The returned structure includes:
|
||||
|
||||
```text
|
||||
bbox
|
||||
bbox_intersection
|
||||
center
|
||||
scene_bbox_count
|
||||
scene_footprints_geojson
|
||||
dem_coverage
|
||||
dem_covers_stack_bbox
|
||||
dem_covers_stack_center
|
||||
monitor_points
|
||||
geojson FeatureCollection
|
||||
```
|
||||
|
||||
Frontend now displays the coverage block in two places:
|
||||
|
||||
```text
|
||||
candidate stack discovery detail
|
||||
selected production Run detail
|
||||
```
|
||||
|
||||
The mini-map uses the existing Leaflet/offline tile configuration and draws:
|
||||
|
||||
```text
|
||||
stack bbox rectangle
|
||||
DEM coverage rectangle when available
|
||||
monitor point markers when available
|
||||
```
|
||||
|
||||
Validation against `sbas_7537cc71c998`:
|
||||
|
||||
```text
|
||||
geographic_coverage.bbox = 128.7690438245,43.7486321624,129.6293024728,44.3582486206
|
||||
geojson feature count = 5
|
||||
monitor point = auto_low_sigma_high_rate, 129.10207098755,44.15041727515
|
||||
backend AST syntax check passed with configured Python
|
||||
frontend npm run build passed
|
||||
```
|
||||
|
||||
Remaining result-management work starts at catalog registration and a separate SBAS products page.
|
||||
|
||||
## 10.2 Implementation Note 2026-05-27 Result Catalog
|
||||
|
||||
Implemented the SBAS result-management slice:
|
||||
|
||||
```text
|
||||
backend/app/services/sbas_insar_catalog_service.py
|
||||
backend/app/routers/sbas_insar_products.py
|
||||
frontend/src/api/sbasInsarProducts.js
|
||||
frontend/src/SbasInsarProductsPanel.jsx
|
||||
```
|
||||
|
||||
Backend behavior:
|
||||
|
||||
```text
|
||||
catalog_name = sbas_insar
|
||||
storage root = GAMMA_SBAS_WORK_ROOT/runs
|
||||
source of truth = completed Gamma SBAS run folders
|
||||
startup bootstrap = scan publish-ready runs and rebuild index when stale
|
||||
manual rebuild = POST /api/sbas-insar-products/rebuild through job queue
|
||||
list/detail = GET /api/sbas-insar-products and /{id}
|
||||
asset serving = /api/sbas-insar-products/{id}/assets/{asset_id}
|
||||
```
|
||||
|
||||
The catalog registers only database metadata and file pointers. It does not copy the large Gamma outputs.
|
||||
|
||||
Important registered assets:
|
||||
|
||||
```text
|
||||
LOS velocity geocoded preview
|
||||
LOS sigma geocoded preview
|
||||
LOS velocity GeoTIFF, toward radar positive
|
||||
LOS velocity GeoTIFF, away from radar positive
|
||||
LOS sigma GeoTIFF
|
||||
Gamma ts_rate and sigma_rate GeoTIFFs
|
||||
monitor-point PNG/CSV/metadata
|
||||
run, stack, workflow, product, quality, and monitor summaries
|
||||
```
|
||||
|
||||
Frontend behavior:
|
||||
|
||||
```text
|
||||
Production Management -> SBAS-InSAR 结果
|
||||
catalog health cards
|
||||
searchable result list
|
||||
result detail
|
||||
coverage map with stack bbox, DEM bbox, and monitor points
|
||||
velocity/sigma/monitor preview panels
|
||||
quality statistics
|
||||
asset download/open links
|
||||
issue list
|
||||
```
|
||||
|
||||
Known next slices:
|
||||
|
||||
```text
|
||||
AOI / administrative-region filter for list and discovery
|
||||
GeoTIFF raster overlay or server-side tile generation
|
||||
multi-monitor-point comparison view
|
||||
explicit orbit-trend/detrend quality diagnostics
|
||||
```
|
||||
|
||||
## 11. Validation
|
||||
|
||||
Use `sbas_7537cc71c998` as the first validation run.
|
||||
@@ -516,7 +630,37 @@ AOI filter returns this product when AOI intersects bbox
|
||||
AOI filter excludes this product when AOI is far away
|
||||
```
|
||||
|
||||
## 12. Open Questions
|
||||
## 12. 2026-05-27 Center-Region UI Closeout
|
||||
|
||||
The gray footprint maps are no longer the primary SBAS UI contract. Production planning and result management now show a location summary instead:
|
||||
|
||||
```text
|
||||
center lon/lat
|
||||
center administrative region
|
||||
stack bbox and common-overlap bbox as text
|
||||
scene footprint count
|
||||
monitor point count
|
||||
```
|
||||
|
||||
Administrative lookup uses the existing `backend/geojson` AOI region data. The lookup starts with center-point containment, repairs invalid administrative geometries where possible, and falls back to a clear unavailable/not-matched state instead of blocking SBAS production.
|
||||
|
||||
The SBAS result catalog now extracts dates from `stack_manifest.scenes[*].date`. This fixes the old list symptom:
|
||||
|
||||
```text
|
||||
before: - 至 - / 0景 / 6对
|
||||
after : 20240422 至 20250908 / 7景 / 6对
|
||||
```
|
||||
|
||||
The current validation run `sbas_7537cc71c998` rebuilt successfully into the result catalog and matched:
|
||||
|
||||
```text
|
||||
center = 129.1949239855143, 44.053833584014285
|
||||
admin region = 黑龙江省 / 牡丹江市
|
||||
date range = 20240422 至 20250908
|
||||
scene/pair count = 7 / 6
|
||||
```
|
||||
|
||||
## 13. Open Questions
|
||||
|
||||
1. Administrative-region naming should start with center-point lookup or intersection lookup?
|
||||
Recommendation: center-point lookup first, intersection later.
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# SBAS-InSAR 点矢量导出与多监测点曲线设计
|
||||
|
||||
## 背景
|
||||
|
||||
当前 Gamma SBAS 专家路径已经产出 LOS 形变速率、LOS sigma、RGB 预览和单个自动监测点曲线。论文和报告中常见的表达方式不是只展示一个自动点,而是以 LOS 速率栅格为主图,并配合若干代表点的时序曲线、质量图和统计说明。
|
||||
|
||||
专家文档第十二步“结果输出、地理编码与点位时序”给出的标准路径包括:
|
||||
|
||||
- `ts_rate` 计算平均形变速率;
|
||||
- `rasdt_pwr` 生成速率预览图;
|
||||
- `geocode_back` 地理编码速率结果;
|
||||
- `data2geotiff` 输出 GeoTIFF;
|
||||
- `disp_prt_2d` 根据 `disp_point.txt` 输出点位时序。
|
||||
|
||||
专家文档没有要求把所有有效像元直接矢量化。全量点矢量属于发布产物扩展,不改变 Gamma/SBAS 计算链路。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 保持 GeoTIFF 作为可信主产品。
|
||||
2. 新增全量有效像元点 GeoJSON.gz,供用户下载后在 QGIS、ArcGIS、Python 或精细制图流程中使用。
|
||||
3. 前端不渲染全量点,只展示文件、点数、字段和下载入口。
|
||||
4. 默认自动监测点从 1 个扩展为多个代表点,便于结果页展示多条时序曲线。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不把全量点 GeoJSON 作为前端地图图层渲染。
|
||||
- 不用点矢量替代 LOS 速率 GeoTIFF。
|
||||
- 不把自动点解释为专家确认点、业务监测网或最终工程控制点。
|
||||
|
||||
## 点矢量产品
|
||||
|
||||
输出目录:
|
||||
|
||||
```text
|
||||
publish/vectors/
|
||||
los_rate_points.geojson.gz
|
||||
los_rate_points_summary.json
|
||||
```
|
||||
|
||||
点定义:
|
||||
|
||||
- 来源:地理编码后的 `los_rate_toward_mm_per_year.tif`、`los_rate_away_mm_per_year.tif`、`los_sigma_mm_per_year.tif`。
|
||||
- 一个有效像元中心点对应一个 GeoJSON Feature。
|
||||
- 有效条件:速率、sigma 为有限数值,且不是 NoData/0 掩膜值。
|
||||
|
||||
字段:
|
||||
|
||||
```text
|
||||
run_id
|
||||
row
|
||||
col
|
||||
lon
|
||||
lat
|
||||
los_rate_toward_mm_per_year
|
||||
los_rate_away_mm_per_year
|
||||
los_sigma_mm_per_year
|
||||
date_start
|
||||
date_end
|
||||
reference_date
|
||||
admin_province
|
||||
admin_city
|
||||
```
|
||||
|
||||
summary 字段:
|
||||
|
||||
```text
|
||||
schema
|
||||
generated_at
|
||||
ready
|
||||
feature_count
|
||||
output_geojson_gz
|
||||
fields
|
||||
source_geotiffs
|
||||
date_start
|
||||
date_end
|
||||
reference_date
|
||||
los_convention
|
||||
```
|
||||
|
||||
前端展示:
|
||||
|
||||
- 点数;
|
||||
- 文件大小;
|
||||
- 字段说明;
|
||||
- 下载按钮。
|
||||
|
||||
## 多监测点曲线
|
||||
|
||||
默认自动点建议为 5 个:
|
||||
|
||||
```text
|
||||
P1 auto_away_high_rate_low_sigma
|
||||
P2 auto_toward_high_rate_low_sigma
|
||||
P3 auto_abs_high_rate_low_sigma
|
||||
P4 auto_stable_low_sigma
|
||||
P5 auto_center_valid
|
||||
```
|
||||
|
||||
选择原则:
|
||||
|
||||
- 排除边缘区域;
|
||||
- 只使用有效像元;
|
||||
- sigma 越低越优先;
|
||||
- 高形变点用于展示明显形变信号;
|
||||
- 稳定点用于对比;
|
||||
- 中心点用于空间代表性;
|
||||
- 点之间设置最小距离,避免扎堆。
|
||||
|
||||
手动点:
|
||||
|
||||
- 仍保留 `manual_lonlat` 模式;
|
||||
- 当用户或后续点位管理页面提供点位时,按手动点优先;
|
||||
- 自动点仅作为无手动点时的默认代表点。
|
||||
|
||||
前端展示:
|
||||
|
||||
- 保留每个点的 PNG/CSV/metadata 下载;
|
||||
- 结果页可展示多张点位曲线预览;
|
||||
- 后续再实现同一坐标轴上的多曲线叠加。
|
||||
|
||||
## 生产链路位置
|
||||
|
||||
点矢量和多监测点都放在专家路径第十二步之后:
|
||||
|
||||
1. Gamma 输出速率、sigma、GeoTIFF;
|
||||
2. 生成点矢量 GeoJSON.gz;
|
||||
3. 提取多个监测点时序;
|
||||
4. catalog 自动登记产物;
|
||||
5. 前端展示下载和曲线预览。
|
||||
|
||||
这样不会改变核心 SBAS 计算过程,只扩展发布和结果管理层。
|
||||
|
||||
## 风险与约束
|
||||
|
||||
- GeoJSON 体积会随范围快速增大,所以必须 gzip 压缩,前端不得加载。
|
||||
- 大范围任务后续应增加抽稀点矢量、CSV/Parquet 或 GeoPackage/FlatGeobuf 导出。
|
||||
- 自动点只适合作为快速检查和报告初稿候选点,正式报告应支持用户指定点、导入点位或专家确认点位。
|
||||
@@ -0,0 +1,311 @@
|
||||
# SBAS-InSAR 序列发现与 AOI 选栈设计
|
||||
|
||||
## 现状结论
|
||||
|
||||
当前系统不是限制最多 7 景。对 `D:\LuTan1_Image_Pool` 的检查结果为:
|
||||
|
||||
- LT1 场景目录:1500 个;
|
||||
- 按当前严格规则分组后:886 个候选序列;
|
||||
- 最大可用序列:7 个日期。
|
||||
|
||||
当前发现逻辑的硬分组键为:
|
||||
|
||||
```text
|
||||
satellite
|
||||
satellite_mode
|
||||
receiving_station
|
||||
relative_orbit
|
||||
orbit_direction
|
||||
imaging_mode
|
||||
polarization
|
||||
center_bucket
|
||||
```
|
||||
|
||||
其中 `center_bucket` 约为 0.1 度经纬度格网。这个规则保守、容易复现,但它不是标准 SBAS 选栈方法。它会把相邻 frame、中心点略有偏移但实际覆盖同一 AOI 的影像拆成不同序列。
|
||||
|
||||
## 一般 SBAS 选序列方法
|
||||
|
||||
SBAS 序列选择通常不是先按影像中心点硬分组,而是围绕一个目标区域 AOI 建栈:
|
||||
|
||||
1. 选择目标区域
|
||||
AOI 可以是行政区、工程区、多边形、bbox、中心点缓冲区或已有项目范围。
|
||||
|
||||
2. 选择同一观测几何
|
||||
一般要求同一轨道方向、同一相对轨道、同一成像模式、同一极化、相近视角和足够 footprint 重叠。
|
||||
对 LT1 当前实现,默认仍应保持 LT1A/LT1B 分开;跨星合并只能作为高级实验模式。
|
||||
|
||||
3. 按 AOI 覆盖筛选影像
|
||||
影像 footprint 需要覆盖 AOI,或者至少满足指定覆盖比例。最终处理范围通常取所有入选影像的公共交集。
|
||||
|
||||
4. 检查时间密度
|
||||
关注日期数量、最大时间间隔、季节性断档、时间跨度。SBAS 越密越好,但必须保证网络连通。
|
||||
|
||||
5. 检查轨道和 DEM 可用性
|
||||
精轨缺失的影像可先展示,但默认不进入可生产栈。
|
||||
|
||||
6. 构建小基线网络
|
||||
不是简单相邻配对。常见做法是根据时间基线和垂直基线构图,选择满足阈值的边,并保证图连通。
|
||||
|
||||
7. 用处理引擎验证基线
|
||||
真实垂直基线应由 Gamma `base_calc` 或等价步骤计算。元数据阶段只能做预筛选,不能替代最终 baseline audit。
|
||||
|
||||
## 专家文档关系
|
||||
|
||||
专家文档没有写自动“找序列”算法,它假设用户已经准备好 `RAW/<date>/` 数据,并在运行前手动修改日期、阈值、宽高和种子点等参数。
|
||||
|
||||
文档中的 `base_calc` 小基线阈值示例类似:
|
||||
|
||||
```text
|
||||
spatial baseline: -1000 1000
|
||||
temporal baseline: 0 120
|
||||
```
|
||||
|
||||
这说明专家链路里真正决定 SBAS 网络的是 base_calc/itab 阶段。系统需要做的是把“人工准备 RAW 日期序列”产品化成可审查的 AOI 选栈和网络计划。
|
||||
|
||||
## 设计目标
|
||||
|
||||
1. 保留当前严格模式,作为快速、保守、可复现实验路径。
|
||||
2. 增加 AOI 发现模式,按行政区/AOI 查找覆盖同一目标区域的影像。
|
||||
3. 把 `center_bucket` 从用户可见的生产条件降级为内部诊断字段。
|
||||
4. 把接收站从硬条件降级为软提示,除非后续实测证明必须拆分。
|
||||
5. 在创建 Run 前只展示用户需要判断的生产信息:时间范围、景数、覆盖质量、网络质量和风险标签。
|
||||
6. 生成可审查的 Stack Manifest v2 和 Pair Network Plan,再交给 Gamma baseline audit 验证。
|
||||
|
||||
## 发现模式
|
||||
|
||||
### 1. Strict 模式
|
||||
|
||||
当前模式,继续保留。
|
||||
|
||||
适用场景:
|
||||
|
||||
- 快速测试;
|
||||
- 已经验证能跑通的固定栈;
|
||||
- 用户希望尽量避免覆盖差异和几何风险。
|
||||
|
||||
硬分组字段:
|
||||
|
||||
```text
|
||||
satellite
|
||||
relative_orbit
|
||||
orbit_direction
|
||||
imaging_mode
|
||||
polarization
|
||||
center_bucket
|
||||
```
|
||||
|
||||
`receiving_station` 建议改为默认软字段,不再强拆。
|
||||
|
||||
### 2. AOI 模式
|
||||
|
||||
新推荐模式。
|
||||
|
||||
输入:
|
||||
|
||||
```text
|
||||
admin_region
|
||||
bbox
|
||||
geojson polygon
|
||||
center + radius
|
||||
```
|
||||
|
||||
处理:
|
||||
|
||||
1. 找到所有 footprint 与 AOI 相交的 LT1 场景;
|
||||
2. 按观测几何分组;
|
||||
3. 计算每景 AOI 覆盖比例;
|
||||
4. 过滤覆盖比例不足的影像;
|
||||
5. 计算公共交集范围;
|
||||
6. 统计日期、时间间隔和精轨完整性;
|
||||
7. 输出候选栈。
|
||||
|
||||
建议默认阈值:
|
||||
|
||||
```text
|
||||
min_scenes: 5
|
||||
dev_min_scenes: 3
|
||||
min_aoi_coverage_ratio: 0.80
|
||||
min_common_overlap_ratio: 0.60
|
||||
warn_max_gap_days: 120
|
||||
hard_fail_max_gap_days: none,改为 warning
|
||||
```
|
||||
|
||||
如果 AOI 是行政区且行政区很大,不应要求单景覆盖整个行政区。应允许用户进一步选择 bbox/工程区,或者默认用行政区中心缓冲区进行候选发现。
|
||||
|
||||
### 3. 内部诊断
|
||||
|
||||
诊断不是用户入口,也不作为生产模式展示。它只用于日志、运维、自检和开发排查。
|
||||
|
||||
内部输出:
|
||||
|
||||
```text
|
||||
raw_scene_count
|
||||
parsed_scene_count
|
||||
group_count
|
||||
top_groups_by_scene_count
|
||||
top_groups_by_date_count
|
||||
excluded_by_missing_orbit
|
||||
excluded_by_geometry
|
||||
excluded_by_aoi_coverage
|
||||
excluded_by_common_overlap
|
||||
```
|
||||
|
||||
这些信息可以写入 manifest/log,必要时在管理员调试页查看。普通用户不需要看到“为什么只有 7 景”这类开发解释。
|
||||
|
||||
## 小基线网络设计
|
||||
|
||||
发现阶段只生成候选网络,最终以 Gamma `base_calc` 为准。
|
||||
|
||||
建议流程:
|
||||
|
||||
1. 对候选日期生成全部可能 pair;
|
||||
2. 先按时间基线过滤;
|
||||
3. 运行或计划 Gamma `base_calc` 得到真实垂直基线;
|
||||
4. 按垂直基线过滤;
|
||||
5. 检查网络连通性;
|
||||
6. 如果断开,允许加入 bridge edge,并标记为超阈值连接;
|
||||
7. 输出 `itab` 和 pair network summary;
|
||||
8. 前端要求用户审批。
|
||||
|
||||
推荐网络策略:
|
||||
|
||||
```text
|
||||
primary: connected small-baseline graph
|
||||
fallback: adjacent chain
|
||||
bridge: allow one or more warning edges when sparse archive causes seasonal gap
|
||||
```
|
||||
|
||||
对当前数据尤其重要:如果严格使用 120 天时间阈值,2024-10 到 2025-05 的 224 天断档会导致网络断开。系统应显示风险,而不是静默删除后续年份。
|
||||
|
||||
## Stack Manifest v2
|
||||
|
||||
新增字段:
|
||||
|
||||
```text
|
||||
discovery_mode
|
||||
aoi
|
||||
aoi_source
|
||||
geometry_group_key
|
||||
hard_group_fields
|
||||
soft_group_fields
|
||||
scene_coverage
|
||||
common_intersection
|
||||
date_stats
|
||||
orbit_stats
|
||||
candidate_pair_network
|
||||
diagnostics
|
||||
```
|
||||
|
||||
每景新增:
|
||||
|
||||
```text
|
||||
aoi_overlap_ratio
|
||||
common_intersection_participation
|
||||
selection_status
|
||||
selection_reasons
|
||||
```
|
||||
|
||||
每个 pair 新增:
|
||||
|
||||
```text
|
||||
temporal_baseline_days
|
||||
perpendicular_baseline_m
|
||||
pair_status
|
||||
bridge_edge
|
||||
rejection_reason
|
||||
```
|
||||
|
||||
## 前端设计
|
||||
|
||||
候选序列发现页增加:
|
||||
|
||||
1. 生产区域选择:行政区、bbox、GeoJSON 或中心点缓冲区;
|
||||
2. 观测条件:轨道方向、相对轨道、极化、时间范围;
|
||||
3. 高级参数折叠区:覆盖阈值、最小景数、是否要求精轨完整;
|
||||
4. 候选列表显示:
|
||||
- 日期数;
|
||||
- 可生产景数;
|
||||
- AOI 覆盖率;
|
||||
- 公共交集面积;
|
||||
- 最大时间间隔;
|
||||
- 网络质量;
|
||||
- 风险标签;
|
||||
- 推荐/可生产/需确认状态。
|
||||
|
||||
候选详情显示:
|
||||
|
||||
```text
|
||||
日期列表
|
||||
覆盖范围摘要
|
||||
时间跨度和最大间隔
|
||||
pair network 摘要
|
||||
base_calc 审核结果
|
||||
```
|
||||
|
||||
用户界面不展示原始分组数、center_bucket 分裂原因、解析失败目录等开发诊断。若需要追踪问题,这些信息进入后台日志或管理员自检接口。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
### 阶段一:AOI 选栈入口
|
||||
|
||||
- 增加 AOI discovery mode 参数;
|
||||
- 支持行政区/bbox/GeoJSON 输入;
|
||||
- 前端只显示推荐候选和风险标签。
|
||||
|
||||
### 阶段二:场景覆盖筛选
|
||||
|
||||
- 使用已有 LT1 bbox 元数据;
|
||||
- 用 shapely 计算 AOI 交集和覆盖率;
|
||||
- 输出 AOI candidate stack。
|
||||
|
||||
### 阶段三:Stack Manifest v2
|
||||
|
||||
- 记录 AOI、覆盖率、软硬分组字段;
|
||||
- 创建 Run 时冻结 v2 manifest;
|
||||
- 保持现有生产链路可读取 scenes 列表。
|
||||
|
||||
### 阶段四:网络计划升级
|
||||
|
||||
- 发现阶段生成候选 pair graph;
|
||||
- baseline audit 阶段用 Gamma `base_calc` 回填真实 Bperp;
|
||||
- 前端审批连通网络,而不是只审批相邻链。
|
||||
|
||||
### 阶段五:生产联调
|
||||
|
||||
- 用当前 1500 景数据池分别测试:
|
||||
- 严格模式是否仍得到 7 景;
|
||||
- AOI 模式是否能扩大目标区域候选;
|
||||
- 扩大后公共交集是否仍足够;
|
||||
- Gamma coreg/base_calc 是否接受新栈。
|
||||
|
||||
### 阶段六:内部诊断与自检
|
||||
|
||||
- 将严格分组统计、排除原因和解析错误写入 discovery log;
|
||||
- 管理员自检接口可查看诊断摘要;
|
||||
- 普通生产页面不展示开发诊断细节。
|
||||
|
||||
## 风险
|
||||
|
||||
- AOI 模式可能把相邻 frame 合进来,导致公共交集变小。
|
||||
- 跨 LT1A/LT1B 合并可能存在几何和相位一致性风险,默认不启用。
|
||||
- 接收站是否可合并需要用实测验证;先作为软字段。
|
||||
- 时间阈值过严会把稀疏数据切断,过宽会降低反演质量,需要前端显式提示。
|
||||
|
||||
## 当前建议
|
||||
|
||||
短期先做 AOI 模式候选发现,不要把“为什么只有几景”的开发诊断放到普通用户页面。
|
||||
生产仍默认走可靠可审查的栈,等 AOI 候选经过 baseline audit 和一次完整 Gamma 测试后,再把 AOI 模式设为推荐入口。
|
||||
|
||||
## 2026-05-28 实施记录
|
||||
|
||||
本轮已把“生产区域”接入 SBAS 候选发现链路:
|
||||
|
||||
- `/sbas-insar-production/stacks/discover` 支持 `discovery_mode=aoi`、`admin_region`、`aoi_bbox`、`min_aoi_coverage_ratio`、`min_common_overlap_ratio`;
|
||||
- 后端可把行政区名称解析成 AOI 几何,使用 LT1 元数据 bbox 与 AOI 相交关系筛选场景;
|
||||
- AOI 模式按观测几何分组,不再把 `center_bucket` 和 `receiving_station` 作为用户生产入口的硬拆分条件;
|
||||
- 候选结果新增 `discovery_mode`、`aoi`、`common_overlap_ratio`、`aoi_overlap_ratio_mean/min/max`、`hard_group_fields`、`soft_group_fields`;
|
||||
- `audit_stack` 和 `create_run` 已传递同一套 AOI 参数,确保发现、Manifest、Run 计划冻结的是同一候选序列;
|
||||
- 前端“候选 SBAS 序列发现”改为“SBAS 生产区域”,用户只输入行政区并查看日期、景数、精轨、公共重叠和覆盖摘要;
|
||||
- Run 列表不再显示 `center_bucket`,改显示行政区、平台和相对轨道。
|
||||
|
||||
当前前端只暴露行政区入口;bbox/GeoJSON 可作为下一步高级入口接入,但不应在普通页面展示开发诊断信息。
|
||||
@@ -9,6 +9,7 @@ import { PanelLoadingBody } from './components/app/AppLoadingFallbacks';
|
||||
|
||||
const LazyDinsarProductionPanel = lazy(() => import('./DinsarProductionPanel'));
|
||||
const LazySbasInsarProductionPanel = lazy(() => import('./SbasInsarProductionPanel'));
|
||||
const LazySbasInsarProductsPanel = lazy(() => import('./SbasInsarProductsPanel'));
|
||||
const LazyDinsarProductsPanel = lazy(() => import('./DinsarProductsPanel'));
|
||||
|
||||
const shellStyle = {
|
||||
@@ -67,6 +68,10 @@ export default function ProductionWorkspace({
|
||||
onTaskStart?.(taskId, 'D-InSAR 产物任务已入队,等待处理...');
|
||||
};
|
||||
|
||||
const handleSbasProductQueued = taskId => {
|
||||
onTaskStart?.(taskId, 'SBAS-InSAR result catalog task queued.');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={shellStyle}>
|
||||
<div style={heroStyle}>
|
||||
@@ -178,6 +183,12 @@ export default function ProductionWorkspace({
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
{activeView === 'sbas_insar_products' && (
|
||||
<LazySbasInsarProductsPanel
|
||||
readOnly={readOnly}
|
||||
onJobQueued={handleSbasProductQueued}
|
||||
/>
|
||||
)}
|
||||
{activeView === 'dinsar_products' && (
|
||||
<LazyDinsarProductsPanel
|
||||
readOnly={readOnly}
|
||||
|
||||
@@ -94,6 +94,62 @@ function formatBytes(value) {
|
||||
return `${current.toFixed(current >= 100 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function normalizeBbox(bbox) {
|
||||
if (!bbox || typeof bbox !== 'object') return null;
|
||||
const minLon = Number(bbox.min_lon);
|
||||
const minLat = Number(bbox.min_lat);
|
||||
const maxLon = Number(bbox.max_lon);
|
||||
const maxLat = Number(bbox.max_lat);
|
||||
if (![minLon, minLat, maxLon, maxLat].every(Number.isFinite)) return null;
|
||||
if (minLon >= maxLon || minLat >= maxLat) return null;
|
||||
return { min_lon: minLon, min_lat: minLat, max_lon: maxLon, max_lat: maxLat };
|
||||
}
|
||||
|
||||
function formatCoord(value, digits = 5) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return '-';
|
||||
return numeric.toFixed(digits);
|
||||
}
|
||||
|
||||
function formatBbox(bbox) {
|
||||
const normalized = normalizeBbox(bbox);
|
||||
if (!normalized) return '-';
|
||||
return [
|
||||
formatCoord(normalized.min_lon),
|
||||
formatCoord(normalized.min_lat),
|
||||
formatCoord(normalized.max_lon),
|
||||
formatCoord(normalized.max_lat),
|
||||
].join(', ');
|
||||
}
|
||||
|
||||
function bboxCenter(bbox) {
|
||||
const normalized = normalizeBbox(bbox);
|
||||
if (!normalized) return null;
|
||||
return {
|
||||
lon: (normalized.min_lon + normalized.max_lon) / 2,
|
||||
lat: (normalized.min_lat + normalized.max_lat) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function formatCenter(center) {
|
||||
if (!center) return '-';
|
||||
const lon = Number(center.lon);
|
||||
const lat = Number(center.lat);
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return '-';
|
||||
return `${formatCoord(lon)}, ${formatCoord(lat)}`;
|
||||
}
|
||||
|
||||
function formatAdminRegion(region) {
|
||||
if (!region || typeof region !== 'object') return '-';
|
||||
return region.display_name || region.name || region.tree_id || '-';
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return '-';
|
||||
return `${(numeric * 100).toFixed(numeric >= 0.1 ? 0 : 1)}%`;
|
||||
}
|
||||
|
||||
function StatusBadge({ value }) {
|
||||
const okValues = new Set([
|
||||
'READY',
|
||||
@@ -156,6 +212,276 @@ function RunArtifactLink({ runId, artifact }) {
|
||||
);
|
||||
}
|
||||
|
||||
function LocationSummaryPanel({ coverage }) {
|
||||
const bbox = normalizeBbox(coverage?.bbox);
|
||||
const intersection = normalizeBbox(coverage?.bbox_intersection);
|
||||
const center = coverage?.center || bboxCenter(bbox);
|
||||
const adminRegion = coverage?.admin_region;
|
||||
const monitorPoints = Array.isArray(coverage?.monitor_points) ? coverage.monitor_points : [];
|
||||
return (
|
||||
<div style={{ border: '1px solid #dbeafe', borderRadius: 8, padding: 10, background: '#eff6ff' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'center' }}>
|
||||
<div style={valueStyle}>位置摘要</div>
|
||||
<span style={mutedStyle}>center / admin region</span>
|
||||
</div>
|
||||
<div style={{ ...metricGridStyle, marginTop: 8 }}>
|
||||
<Metric label="中心点" value={formatCenter(center)} />
|
||||
<Metric label="行政区" value={formatAdminRegion(adminRegion)} />
|
||||
<Metric label="Stack bbox" value={formatBbox(bbox)} />
|
||||
<Metric label="交集 bbox" value={formatBbox(intersection)} />
|
||||
<Metric label="单景范围数" value={`${(coverage?.scene_footprints_geojson?.features || []).length || coverage?.scene_bbox_count || 0}`} />
|
||||
<Metric label="监测点" value={`${monitorPoints.length}`} />
|
||||
</div>
|
||||
{monitorPoints.length > 0 && (
|
||||
<div style={{ ...mutedStyle, marginTop: 8, wordBreak: 'break-word' }}>
|
||||
监测点:{monitorPoints.map(point => `${point.point_id || 'point'} (${formatCenter(point)})`).join(';')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
function UnusedSceneFootprintGeographicCoverageMap({ coverage }) {
|
||||
const mapElementRef = useRef(null);
|
||||
const mapRef = useRef(null);
|
||||
const tileLayerRef = useRef(null);
|
||||
const layerGroupRef = useRef(null);
|
||||
const bbox = normalizeBbox(coverage?.bbox);
|
||||
const monitorPoints = Array.isArray(coverage?.monitor_points) ? coverage.monitor_points : [];
|
||||
const sceneFootprints = useMemo(
|
||||
() => normalizeFeatureCollection(coverage?.scene_footprints_geojson),
|
||||
[coverage],
|
||||
);
|
||||
const coverageGeojson = useMemo(
|
||||
() => normalizeCoverageGeojson(coverage?.geojson),
|
||||
[coverage],
|
||||
);
|
||||
const sceneFeatureCount = sceneFootprints.features.length;
|
||||
const coverageFeatureCount = coverageGeojson.features.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapElementRef.current || !bbox) return undefined;
|
||||
if (!mapRef.current) {
|
||||
mapRef.current = L.map(mapElementRef.current, {
|
||||
attributionControl: false,
|
||||
zoomControl: false,
|
||||
scrollWheelZoom: false,
|
||||
doubleClickZoom: false,
|
||||
boxZoom: false,
|
||||
keyboard: false,
|
||||
dragging: true,
|
||||
});
|
||||
const baseLayer = getBaseLayerConfig(TILE_LAYER_DEFAULT_KEY);
|
||||
tileLayerRef.current = L.tileLayer(baseLayer.url, {
|
||||
...TILE_LAYER_OPTIONS,
|
||||
attribution: baseLayer.attribution,
|
||||
}).addTo(mapRef.current);
|
||||
layerGroupRef.current = L.layerGroup().addTo(mapRef.current);
|
||||
}
|
||||
|
||||
const map = mapRef.current;
|
||||
const layerGroup = layerGroupRef.current;
|
||||
layerGroup.clearLayers();
|
||||
const stackBounds = L.latLngBounds([bbox.min_lat, bbox.min_lon], [bbox.max_lat, bbox.max_lon]);
|
||||
|
||||
L.rectangle(stackBounds, {
|
||||
color: '#475569',
|
||||
weight: 1,
|
||||
dashArray: '5 5',
|
||||
fillOpacity: 0,
|
||||
}).addTo(layerGroup);
|
||||
|
||||
let fitBounds = stackBounds;
|
||||
if (sceneFeatureCount > 0) {
|
||||
const sceneLayer = L.geoJSON(sceneFootprints, {
|
||||
style: feature => {
|
||||
const date = String(feature?.properties?.date || '');
|
||||
const tone = date.endsWith('22') || date.endsWith('17') ? '#2563eb' : '#0891b2';
|
||||
return {
|
||||
color: tone,
|
||||
weight: 1.6,
|
||||
opacity: 0.9,
|
||||
fillColor: tone,
|
||||
fillOpacity: 0.12,
|
||||
};
|
||||
},
|
||||
onEachFeature: (feature, layer) => {
|
||||
const label = featureLabel(feature);
|
||||
if (label) {
|
||||
layer.bindTooltip(label, { sticky: true });
|
||||
}
|
||||
},
|
||||
}).addTo(layerGroup);
|
||||
const sceneBounds = sceneLayer.getBounds();
|
||||
if (sceneBounds.isValid()) {
|
||||
fitBounds = sceneBounds;
|
||||
}
|
||||
} else {
|
||||
L.rectangle(stackBounds, {
|
||||
color: '#2563eb',
|
||||
weight: 2,
|
||||
fillColor: '#38bdf8',
|
||||
fillOpacity: 0.12,
|
||||
}).addTo(layerGroup);
|
||||
}
|
||||
|
||||
if (coverageFeatureCount > 0) {
|
||||
L.geoJSON(coverageGeojson, {
|
||||
style: coveragePolygonStyle,
|
||||
pointToLayer: coveragePointMarker,
|
||||
onEachFeature: (feature, layer) => {
|
||||
const label = coverageFeatureLabel(feature);
|
||||
if (label) {
|
||||
layer.bindTooltip(label, { sticky: true });
|
||||
}
|
||||
},
|
||||
}).addTo(layerGroup);
|
||||
}
|
||||
|
||||
monitorPoints.forEach(point => {
|
||||
const lon = Number(point.lon);
|
||||
const lat = Number(point.lat);
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return;
|
||||
L.circleMarker([lat, lon], {
|
||||
radius: 5,
|
||||
color: '#7c3aed',
|
||||
weight: 2,
|
||||
fillColor: '#ffffff',
|
||||
fillOpacity: 1,
|
||||
})
|
||||
.bindTooltip(String(point.point_id || 'monitor point'), { direction: 'top' })
|
||||
.addTo(layerGroup);
|
||||
});
|
||||
|
||||
map.fitBounds(fitBounds.pad(0.12), { animate: false, maxZoom: 12 });
|
||||
window.setTimeout(() => map.invalidateSize(), 0);
|
||||
return undefined;
|
||||
}, [bbox, coverageFeatureCount, coverageGeojson, monitorPoints, sceneFeatureCount, sceneFootprints]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove();
|
||||
mapRef.current = null;
|
||||
tileLayerRef.current = null;
|
||||
layerGroupRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!bbox) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: 180,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
border: '1px solid #d8dee8',
|
||||
borderRadius: 8,
|
||||
background: '#f8fafc',
|
||||
color: '#64748b',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
暂无可展示的地理范围
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={mapElementRef}
|
||||
style={{
|
||||
height: 180,
|
||||
border: '1px solid #d8dee8',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
background: '#eef2f7',
|
||||
}}
|
||||
/>
|
||||
<div style={{ ...mutedStyle, display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 6 }}>
|
||||
<span><strong style={{ color: '#2563eb' }}>Blue</strong> scene footprints ({sceneFeatureCount})</span>
|
||||
<span><strong style={{ color: '#16a34a' }}>Green dashed</strong> coverage GeoJSON ({coverageFeatureCount})</span>
|
||||
<span><strong style={{ color: '#475569' }}>Gray dashed</strong> outer bbox</span>
|
||||
<span><strong style={{ color: '#7c3aed' }}>Purple</strong> monitor points</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UnusedGeographicCoveragePanel({ coverage }) {
|
||||
const bbox = normalizeBbox(coverage?.bbox);
|
||||
const intersection = normalizeBbox(coverage?.bbox_intersection);
|
||||
const center = coverage?.center || bboxCenter(bbox);
|
||||
const monitorPoints = Array.isArray(coverage?.monitor_points) ? coverage.monitor_points : [];
|
||||
const geojsonText = coverage?.geojson ? JSON.stringify(coverage.geojson) : '';
|
||||
|
||||
if (!bbox) {
|
||||
return (
|
||||
<div style={{ border: '1px solid #e2e8f0', borderRadius: 8, padding: 10, background: '#f8fafc' }}>
|
||||
<div style={valueStyle}>地理范围</div>
|
||||
<div style={{ ...mutedStyle, marginTop: 6 }}>
|
||||
当前 Run 尚未找到 LT1 元数据 bbox。后续按行政区/AOI 生产时会在这里显示范围。
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid #99f6e4', borderRadius: 8, padding: 10, background: '#f0fdfa' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'center' }}>
|
||||
<div style={valueStyle}>地理范围</div>
|
||||
<span style={mutedStyle}>EPSG:4326 / GeoJSON</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(240px, 360px) minmax(0, 1fr)', gap: 10, marginTop: 8 }}>
|
||||
<SceneFootprintGeographicCoverageMap coverage={coverage} />
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={metricGridStyle}>
|
||||
<Metric label="Stack bbox" value={formatBbox(bbox)} />
|
||||
<Metric label="交集 bbox" value={formatBbox(intersection)} />
|
||||
<Metric
|
||||
label="中心点"
|
||||
value={center ? `${formatCoord(center.lon)}, ${formatCoord(center.lat)}` : '-'}
|
||||
/>
|
||||
<Metric label="单景范围数" value={`${(coverage?.scene_footprints_geojson?.features || []).length || coverage?.scene_bbox_count || 0}`} />
|
||||
<Metric label="范围来源" value={(coverage?.scene_footprints_geojson?.features || []).length > 0 ? 'scene GeoJSON' : 'stack bbox'} />
|
||||
<Metric label="监测点" value={`${monitorPoints.length}`} />
|
||||
</div>
|
||||
{monitorPoints.length > 0 && (
|
||||
<div style={{ ...mutedStyle, wordBreak: 'break-word' }}>
|
||||
监测点:{monitorPoints.map(point => `${point.point_id || 'point'} (${formatCoord(point.lon)}, ${formatCoord(point.lat)})`).join(';')}
|
||||
</div>
|
||||
)}
|
||||
{geojsonText && (
|
||||
<details>
|
||||
<summary style={{ ...mutedStyle, cursor: 'pointer', fontWeight: 650 }}>查看 GeoJSON</summary>
|
||||
<pre
|
||||
style={{
|
||||
margin: '6px 0 0',
|
||||
maxHeight: 120,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
border: '1px solid #ccfbf1',
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
background: '#ffffff',
|
||||
color: '#334155',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
{geojsonText}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
*/
|
||||
|
||||
export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
const [capabilities, setCapabilities] = useState(null);
|
||||
const [runs, setRuns] = useState([]);
|
||||
@@ -170,6 +496,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
const [auditLoading, setAuditLoading] = useState(false);
|
||||
const [stackAudit, setStackAudit] = useState(null);
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
const [stackAdminRegionQuery, setStackAdminRegionQuery] = useState('');
|
||||
const [baselineAuditLoading, setBaselineAuditLoading] = useState(false);
|
||||
const [itabDecisionLoading, setItabDecisionLoading] = useState(false);
|
||||
const [coregistrationLoading, setCoregistrationLoading] = useState(false);
|
||||
@@ -188,6 +515,20 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
const [workflowJobLoading, setWorkflowJobLoading] = useState(false);
|
||||
const [workflowJob, setWorkflowJob] = useState(null);
|
||||
|
||||
const stackDiscoveryPayload = useMemo(() => {
|
||||
const adminRegion = stackAdminRegionQuery.trim();
|
||||
return {
|
||||
min_scenes: 3,
|
||||
require_orbits: true,
|
||||
include_scenes: false,
|
||||
limit: 30,
|
||||
discovery_mode: adminRegion ? 'aoi' : 'strict',
|
||||
admin_region: adminRegion || undefined,
|
||||
min_aoi_coverage_ratio: 0.01,
|
||||
min_common_overlap_ratio: 0,
|
||||
};
|
||||
}, [stackAdminRegionQuery]);
|
||||
|
||||
const loadProductionRuns = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
@@ -240,22 +581,17 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
setError('');
|
||||
setStackAudit(null);
|
||||
try {
|
||||
const data = await discoverSbasInsarStacks({
|
||||
min_scenes: 3,
|
||||
require_orbits: true,
|
||||
include_scenes: false,
|
||||
limit: 30,
|
||||
});
|
||||
const data = await discoverSbasInsarStacks(stackDiscoveryPayload);
|
||||
const items = Array.isArray(data?.items) ? data.items : [];
|
||||
setStackCandidates(items);
|
||||
setSelectedStackId(current => current || items[0]?.stack_id || '');
|
||||
setSelectedStackId(items[0]?.stack_id || '');
|
||||
} catch (exc) {
|
||||
setError(exc?.response?.data?.detail || exc.message || 'SBAS-InSAR 栈发现失败');
|
||||
setStackCandidates([]);
|
||||
} finally {
|
||||
setDiscovering(false);
|
||||
}
|
||||
}, []);
|
||||
}, [stackDiscoveryPayload]);
|
||||
|
||||
const handleAuditStack = useCallback(async stackId => {
|
||||
if (!stackId) return;
|
||||
@@ -263,8 +599,8 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
setError('');
|
||||
try {
|
||||
const data = await auditSbasInsarStack(stackId, {
|
||||
min_scenes: 3,
|
||||
require_orbits: true,
|
||||
...stackDiscoveryPayload,
|
||||
include_scenes: true,
|
||||
});
|
||||
setStackAudit(data);
|
||||
} catch (exc) {
|
||||
@@ -273,7 +609,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
} finally {
|
||||
setAuditLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [stackDiscoveryPayload]);
|
||||
|
||||
const handleSubmitRun = useCallback(async () => {
|
||||
if (!selectedStackId || readOnly) return;
|
||||
@@ -282,13 +618,12 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
try {
|
||||
const candidate = stackCandidates.find(item => item.stack_id === selectedStackId);
|
||||
const data = await submitSbasInsarRun(selectedStackId, {
|
||||
...stackDiscoveryPayload,
|
||||
run_label: candidate
|
||||
? `${candidate.satellite || 'LT1'} ${candidate.relative_orbit || ''} ${candidate.center_bucket || ''}`.trim()
|
||||
? `${candidate.satellite || 'LT1'} ${formatAdminRegion(candidate.admin_region)} relOrbit ${candidate.relative_orbit || ''}`.trim()
|
||||
: undefined,
|
||||
min_scenes: 3,
|
||||
require_orbits: true,
|
||||
dry_run: false,
|
||||
monitor_point_strategy: 'auto_low_sigma_high_rate',
|
||||
monitor_point_strategy: 'auto_representative_points',
|
||||
});
|
||||
const runId = data?.run?.run_id;
|
||||
const runData = await listSbasInsarRuns();
|
||||
@@ -303,7 +638,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
}
|
||||
}, [readOnly, selectedStackId, stackCandidates]);
|
||||
}, [readOnly, selectedStackId, stackCandidates, stackDiscoveryPayload]);
|
||||
|
||||
const workflowPayload = useMemo(() => ({
|
||||
force: false,
|
||||
@@ -586,6 +921,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
const iptaTimeseriesPlan = runManifest.ipta_timeseries || null;
|
||||
const publishProductsPlan = runManifest.publish_products || null;
|
||||
const monitorProductsPlan = runManifest.monitor_point_products || null;
|
||||
const runGeographicCoverage = runDetail?.geographic_coverage || null;
|
||||
const runPrimaryPreview = (
|
||||
runArtifacts.find(item => item.key === 'los_rate_toward_m_per_year_hls_geo_preview_png')
|
||||
|| runArtifacts.find(item => item.key === 'los_rate_toward_mm_per_year_geo_preview_png')
|
||||
@@ -662,12 +998,32 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
<section style={sectionStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 15, color: '#0f172a' }}>候选 SBAS 序列发现</h3>
|
||||
<h3 style={{ margin: 0, fontSize: 15, color: '#0f172a' }}>SBAS 生产区域</h3>
|
||||
<div style={{ ...mutedStyle, marginTop: 5 }}>
|
||||
直接扫描本地 LT1 数据池,按平台、相对轨道、升降轨、模式、极化、接收站和中心桶硬分组,并检查精轨 TXT。
|
||||
按生产行政区查找覆盖同一目标区域的 LT1 时序候选,并检查日期密度、精轨和公共重叠范围。
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
<input
|
||||
value={stackAdminRegionQuery}
|
||||
onChange={event => {
|
||||
setStackAdminRegionQuery(event.target.value);
|
||||
setStackCandidates([]);
|
||||
setSelectedStackId('');
|
||||
setStackAudit(null);
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') handleDiscoverStacks();
|
||||
}}
|
||||
placeholder="输入行政区,例如 牡丹江 / 洛阳"
|
||||
style={{
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: 8,
|
||||
padding: '8px 10px',
|
||||
fontSize: 12,
|
||||
minWidth: 160,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDiscoverStacks}
|
||||
@@ -683,7 +1039,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{discovering ? '发现中' : '发现序列'}
|
||||
{discovering ? '查找中' : '查找候选'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -742,7 +1098,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
||||
<strong style={{ color: '#0f172a', fontSize: 13 }}>
|
||||
{item.satellite} / relOrbit {item.relative_orbit} / {item.center_bucket}
|
||||
{item.satellite || 'LT1'} / {item.orbit_direction || '-'} / relOrbit {item.relative_orbit || '-'}
|
||||
</strong>
|
||||
<StatusBadge value={item.status} />
|
||||
</div>
|
||||
@@ -750,18 +1106,37 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
{item.date_start} 至 {item.date_end},可用 {item.usable_scene_count}/{item.scene_count} 景,
|
||||
缺精轨 {item.missing_orbit_count},最大间隔 {item.max_temporal_gap_days} 天
|
||||
</div>
|
||||
<div style={{ ...mutedStyle, marginTop: 4 }}>
|
||||
行政区:{formatAdminRegion(item.admin_region)};公共重叠 {formatPercent(item.common_overlap_ratio)}
|
||||
</div>
|
||||
<div style={{ ...mutedStyle, marginTop: 4 }}>
|
||||
覆盖 {formatPercent(item.aoi_overlap_ratio_mean)};中心点 {formatCenter(item.center)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{selectedStack && (
|
||||
<>
|
||||
<div style={metricGridStyle}>
|
||||
<Metric label="平台/模式" value={`${selectedStack.satellite || '-'} / ${selectedStack.imaging_mode || '-'}`} />
|
||||
<Metric label="轨道方向" value={selectedStack.orbit_direction || '-'} />
|
||||
<Metric label="极化/接收站" value={`${selectedStack.polarization || '-'} / ${selectedStack.receiving_station || '-'}`} />
|
||||
<Metric label="建议参考日期" value={selectedStack.reference_date || '-'} />
|
||||
<Metric label="公共重叠" value={formatPercent(selectedStack.common_overlap_ratio)} />
|
||||
<Metric label="AOI 覆盖" value={formatPercent(selectedStack.aoi_overlap_ratio_mean)} />
|
||||
</div>
|
||||
<LocationSummaryPanel
|
||||
coverage={{
|
||||
bbox: selectedStack.bbox || selectedStack.bbox_intersection,
|
||||
bbox_intersection: selectedStack.bbox_intersection,
|
||||
center: selectedStack.center || bboxCenter(selectedStack.bbox || selectedStack.bbox_intersection),
|
||||
admin_region: selectedStack.admin_region,
|
||||
scene_bbox_count: selectedStack.usable_scene_count || selectedStack.scene_count || 0,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{stackAudit && (
|
||||
<div style={{ border: '1px solid #dbeafe', borderRadius: 8, padding: 10, background: '#eff6ff' }}>
|
||||
@@ -826,7 +1201,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
||||
<strong style={{ color: '#0f172a', fontSize: 13 }}>
|
||||
{item.platform || 'LT1'} / relOrbit {item.relative_orbit || '-'} / {item.center_bucket || '-'}
|
||||
{formatAdminRegion(item.admin_region)} / {item.platform || 'LT1'} / relOrbit {item.relative_orbit || '-'}
|
||||
</strong>
|
||||
<StatusBadge value={item.status} />
|
||||
</div>
|
||||
@@ -836,6 +1211,11 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{runs.length > 0 && (
|
||||
<div style={{ ...mutedStyle, padding: '2px 0 6px' }}>
|
||||
当前 Run 列表已补充中心点行政区;筛选入口优先放在候选序列发现阶段。
|
||||
</div>
|
||||
)}
|
||||
{!loading && runs.length === 0 && (
|
||||
<div style={{ ...mutedStyle, padding: '10px 0' }}>
|
||||
暂无计划 Run。先发现序列,再创建计划 Run。
|
||||
@@ -854,6 +1234,8 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
<Metric label="下一阶段" value={run.next_stage || '-'} />
|
||||
</div>
|
||||
|
||||
<LocationSummaryPanel coverage={runGeographicCoverage} />
|
||||
|
||||
{!readOnly && (
|
||||
<div style={{ border: '1px solid #bbf7d0', borderRadius: 8, padding: 10, background: '#f0fdf4' }}>
|
||||
<div style={valueStyle}>Gamma SBAS Workflow</div>
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
getSbasInsarCatalogStatus,
|
||||
getSbasInsarProductAssetUrl,
|
||||
getSbasInsarProductDetail,
|
||||
getSbasInsarProductPreviewUrl,
|
||||
listSbasInsarProducts,
|
||||
queueSbasInsarCatalogRebuild,
|
||||
} from './api/sbasInsarProducts';
|
||||
|
||||
const statusColors = {
|
||||
READY: '#15803d',
|
||||
WARN: '#b45309',
|
||||
REBUILDING: '#2563eb',
|
||||
INCOMPLETE: '#b45309',
|
||||
ERROR: '#dc2626',
|
||||
};
|
||||
|
||||
const panelStyle = { display: 'grid', gap: 12 };
|
||||
const sectionStyle = {
|
||||
background: '#ffffff',
|
||||
border: '1px solid #d8dee8',
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
};
|
||||
const mutedStyle = { color: '#64748b', fontSize: 12, lineHeight: 1.55 };
|
||||
const buttonStyle = {
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: 6,
|
||||
background: '#ffffff',
|
||||
color: '#0f172a',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
fontWeight: 650,
|
||||
padding: '7px 11px',
|
||||
};
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '-';
|
||||
try {
|
||||
return new Date(value).toLocaleString();
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumber(value, digits = 4) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return '-';
|
||||
return numeric.toFixed(digits);
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const size = Number(value || 0);
|
||||
if (!Number.isFinite(size) || size <= 0) return '-';
|
||||
if (size < 1024) return `${size} B`;
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let current = size / 1024;
|
||||
let index = 0;
|
||||
while (current >= 1024 && index < units.length - 1) {
|
||||
current /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${current.toFixed(current >= 100 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function normalizeBbox(bbox) {
|
||||
if (!bbox || typeof bbox !== 'object') return null;
|
||||
const minLon = Number(bbox.min_lon);
|
||||
const minLat = Number(bbox.min_lat);
|
||||
const maxLon = Number(bbox.max_lon);
|
||||
const maxLat = Number(bbox.max_lat);
|
||||
if (![minLon, minLat, maxLon, maxLat].every(Number.isFinite)) return null;
|
||||
if (minLon >= maxLon || minLat >= maxLat) return null;
|
||||
return { min_lon: minLon, min_lat: minLat, max_lon: maxLon, max_lat: maxLat };
|
||||
}
|
||||
|
||||
function bboxCenter(bbox) {
|
||||
const normalized = normalizeBbox(bbox);
|
||||
if (!normalized) return null;
|
||||
return {
|
||||
lon: (normalized.min_lon + normalized.max_lon) / 2,
|
||||
lat: (normalized.min_lat + normalized.max_lat) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function formatBbox(bbox) {
|
||||
const normalized = normalizeBbox(bbox);
|
||||
if (!normalized) return '-';
|
||||
return [
|
||||
formatNumber(normalized.min_lon, 5),
|
||||
formatNumber(normalized.min_lat, 5),
|
||||
formatNumber(normalized.max_lon, 5),
|
||||
formatNumber(normalized.max_lat, 5),
|
||||
].join(', ');
|
||||
}
|
||||
|
||||
function formatCenter(center) {
|
||||
if (!center) return '-';
|
||||
const lon = Number(center.lon);
|
||||
const lat = Number(center.lat);
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return '-';
|
||||
return `${lon.toFixed(5)}, ${lat.toFixed(5)}`;
|
||||
}
|
||||
|
||||
function formatAdminRegion(region) {
|
||||
if (!region || typeof region !== 'object') return '-';
|
||||
return region.display_name || region.name || region.tree_id || '-';
|
||||
}
|
||||
|
||||
function StatusBadge({ value }) {
|
||||
const color = statusColors[value] || '#64748b';
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '2px 9px',
|
||||
borderRadius: 999,
|
||||
background: `${color}16`,
|
||||
color,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 7, height: 7, borderRadius: 999, background: color }} />
|
||||
{value || 'UNKNOWN'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value, accent }) {
|
||||
return (
|
||||
<div style={{ border: '1px solid #e2e8f0', borderRadius: 8, padding: '9px 10px', background: '#f8fafc' }}>
|
||||
<div style={{ color: '#64748b', fontSize: 12 }}>{label}</div>
|
||||
<div style={{ color: accent || '#0f172a', fontSize: 15, fontWeight: 750, marginTop: 4 }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function findFirstAsset(assets, roles) {
|
||||
const roleSet = new Set(roles);
|
||||
return assets.find(asset => roleSet.has(asset.asset_role) && asset.exists_flag);
|
||||
}
|
||||
|
||||
function findAssets(assets, roles) {
|
||||
const roleSet = new Set(roles);
|
||||
return assets.filter(asset => roleSet.has(asset.asset_role) && asset.exists_flag);
|
||||
}
|
||||
|
||||
function ProductPreview({ title, asset, productId }) {
|
||||
if (!asset) {
|
||||
return (
|
||||
<div style={{ border: '1px solid #e2e8f0', borderRadius: 8, padding: 10, background: '#f8fafc' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: '#0f172a' }}>{title}</div>
|
||||
<div style={{ ...mutedStyle, marginTop: 6 }}>暂无预览。</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ border: '1px solid #e2e8f0', borderRadius: 8, overflow: 'hidden', background: '#ffffff' }}>
|
||||
<div style={{ padding: '8px 10px', fontSize: 12, fontWeight: 700, color: '#0f172a', background: '#f8fafc' }}>
|
||||
{title}
|
||||
</div>
|
||||
<img
|
||||
src={getSbasInsarProductAssetUrl(productId, asset.id)}
|
||||
alt={title}
|
||||
style={{ display: 'block', width: '100%', maxHeight: 300, objectFit: 'contain', background: '#0f172a' }}
|
||||
/>
|
||||
<div style={{ ...mutedStyle, padding: '7px 10px', wordBreak: 'break-all' }}>{asset.relative_path}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PointVectorDownload({ asset, summary, productId }) {
|
||||
if (!asset && !summary) return null;
|
||||
const fields = Array.isArray(summary?.fields) ? summary.fields : [];
|
||||
return (
|
||||
<div style={{ border: '1px solid #e2e8f0', borderRadius: 8, padding: 12, background: '#f8fafc' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 800, color: '#0f172a' }}>全量有效点 GeoJSON.gz</div>
|
||||
<div style={{ ...mutedStyle, marginTop: 4 }}>
|
||||
仅提供下载,不在前端渲染;用于 QGIS、ArcGIS、Python 或精细制图。
|
||||
</div>
|
||||
</div>
|
||||
{asset ? (
|
||||
<a href={getSbasInsarProductAssetUrl(productId, asset.id)} target="_blank" rel="noreferrer" style={{ ...buttonStyle, textDecoration: 'none' }}>
|
||||
下载
|
||||
</a>
|
||||
) : (
|
||||
<span style={{ color: '#dc2626', fontSize: 12 }}>缺失</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))', gap: 8, marginTop: 10 }}>
|
||||
<Metric label="点数" value={summary?.feature_count ?? '-'} />
|
||||
<Metric label="文件大小" value={formatBytes(asset?.file_size || summary?.output_size_bytes)} />
|
||||
<Metric label="坐标系" value={summary?.crs || 'EPSG:4326'} />
|
||||
<Metric label="策略" value="download only" />
|
||||
</div>
|
||||
{fields.length > 0 && (
|
||||
<div style={{ ...mutedStyle, marginTop: 9, wordBreak: 'break-word' }}>
|
||||
字段:{fields.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SbasInsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
const [catalogStatus, setCatalogStatus] = useState(null);
|
||||
const [products, setProducts] = useState([]);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [adminRegionQuery, setAdminRegionQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const loadCatalog = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = { limit: 100, offset: 0 };
|
||||
if (query.trim()) params.query = query.trim();
|
||||
if (adminRegionQuery.trim()) params.admin_region = adminRegionQuery.trim();
|
||||
const [statusData, productData] = await Promise.all([
|
||||
getSbasInsarCatalogStatus(),
|
||||
listSbasInsarProducts(params),
|
||||
]);
|
||||
const nextProducts = Array.isArray(productData?.items) ? productData.items : [];
|
||||
setCatalogStatus(statusData);
|
||||
setProducts(nextProducts);
|
||||
setSelectedId(current => (current && nextProducts.some(item => item.id === current) ? current : nextProducts[0]?.id ?? null));
|
||||
} catch (error) {
|
||||
setCatalogStatus(null);
|
||||
setProducts([]);
|
||||
setSelectedId(null);
|
||||
setMessage(`SBAS 结果目录加载失败:${error?.response?.data?.detail || error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [adminRegionQuery, query]);
|
||||
|
||||
const loadDetail = useCallback(async productId => {
|
||||
if (!productId) {
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
setDetail(await getSbasInsarProductDetail(productId));
|
||||
} catch (error) {
|
||||
setDetail({ error: error?.response?.data?.detail || error.message });
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadCatalog();
|
||||
}, [loadCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDetail(selectedId);
|
||||
}, [loadDetail, selectedId]);
|
||||
|
||||
const handleRebuild = async () => {
|
||||
if (readOnly) return;
|
||||
setActionLoading(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const result = await queueSbasInsarCatalogRebuild({ full_rebuild: true });
|
||||
setMessage(`SBAS 结果目录重建任务已提交:${result.task_id}`);
|
||||
onJobQueued?.(result.task_id);
|
||||
await loadCatalog();
|
||||
} catch (error) {
|
||||
setMessage(`SBAS 结果目录重建失败:${error?.response?.data?.detail || error.message}`);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedAssets = Array.isArray(detail?.assets) ? detail.assets : [];
|
||||
const selectedIssues = Array.isArray(detail?.issues) ? detail.issues : [];
|
||||
const velocityPreview = useMemo(() => findFirstAsset(selectedAssets, ['primary_geocoded_preview']), [selectedAssets]);
|
||||
const sigmaPreview = useMemo(() => findFirstAsset(selectedAssets, ['quality_geocoded_preview']), [selectedAssets]);
|
||||
const monitorPreviews = useMemo(() => findAssets(selectedAssets, ['monitor_point_curve']), [selectedAssets]);
|
||||
const pointVectorAsset = useMemo(() => findFirstAsset(selectedAssets, ['point_vector_geojson_gz']), [selectedAssets]);
|
||||
const pointVectorSummary = detail?.point_vector || {};
|
||||
const monitorPoints = detail?.monitor_points?.monitor_points || detail?.geographic_coverage?.monitor_points || [];
|
||||
const coverage = detail?.geographic_coverage || {};
|
||||
const center = detail?.center || coverage.center || bboxCenter(coverage.bbox);
|
||||
const adminRegion = detail?.admin_region || coverage.admin_region;
|
||||
const quality = detail?.quality || {};
|
||||
const rateStats = quality.los_rate_toward_mm_per_year_rdc || quality.los_rate_toward_m_per_year_rdc || {};
|
||||
const sigmaStats = quality.los_sigma_mm_per_year_rdc || quality.los_sigma_m_per_year_rdc || {};
|
||||
const catalogColor = statusColors[catalogStatus?.status] || '#64748b';
|
||||
|
||||
return (
|
||||
<div style={panelStyle}>
|
||||
<section style={sectionStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: '#0f172a', fontSize: 18 }}>SBAS-InSAR 结果管理</h3>
|
||||
<div style={{ ...mutedStyle, marginTop: 5 }}>
|
||||
管理 Gamma SBAS 生产结果、重要预览图、GeoTIFF、监测点曲线和发布资产。
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" onClick={loadCatalog} disabled={loading || actionLoading} style={buttonStyle}>
|
||||
{loading ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
<button type="button" onClick={handleRebuild} disabled={readOnly || actionLoading} style={{ ...buttonStyle, opacity: readOnly ? 0.55 : 1 }}>
|
||||
{actionLoading ? '提交中...' : '重建目录'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 10, marginTop: 12 }}>
|
||||
<Metric label="目录状态" value={<StatusBadge value={catalogStatus?.status || 'UNKNOWN'} />} accent={catalogColor} />
|
||||
<Metric label="需要重建" value={catalogStatus?.needs_rebuild ? '是' : '否'} accent={catalogStatus?.needs_rebuild ? '#dc2626' : '#15803d'} />
|
||||
<Metric label="Run / DB" value={`${catalogStatus?.run_count ?? catalogStatus?.manifest_count ?? 0} / ${catalogStatus?.db_count ?? 0}`} />
|
||||
<Metric label="问题数" value={catalogStatus?.issue_count ?? 0} accent={(catalogStatus?.issue_count ?? 0) > 0 ? '#b45309' : '#15803d'} />
|
||||
</div>
|
||||
|
||||
<div style={{ ...mutedStyle, marginTop: 10, wordBreak: 'break-all' }}>
|
||||
<div><strong>根目录:</strong>{catalogStatus?.storage_root || '-'}</div>
|
||||
<div><strong>最近消息:</strong>{catalogStatus?.last_message || '-'}</div>
|
||||
<div><strong>最近重建:</strong>{formatDateTime(catalogStatus?.last_full_rebuild_at)}</div>
|
||||
</div>
|
||||
{message && (
|
||||
<div style={{ marginTop: 10, fontSize: 12, color: message.includes('失败') ? '#dc2626' : '#166534' }}>{message}</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={{ display: 'grid', gridTemplateColumns: 'minmax(280px, 380px) minmax(0, 1fr)', gap: 12, alignItems: 'start' }}>
|
||||
<div style={sectionStyle}>
|
||||
<div style={{ display: 'grid', gap: 8, marginBottom: 10 }}>
|
||||
<input
|
||||
value={query}
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') loadCatalog();
|
||||
}}
|
||||
placeholder="搜索 run、stack、产品编号"
|
||||
style={{ border: '1px solid #cbd5e1', borderRadius: 6, padding: '7px 9px', fontSize: 12 }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
value={adminRegionQuery}
|
||||
onChange={event => setAdminRegionQuery(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') loadCatalog();
|
||||
}}
|
||||
placeholder="按行政区检索,如 洛阳 / 河南"
|
||||
style={{ flex: 1, border: '1px solid #cbd5e1', borderRadius: 6, padding: '7px 9px', fontSize: 12 }}
|
||||
/>
|
||||
<button type="button" onClick={loadCatalog} style={buttonStyle}>查询</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, fontWeight: 750, color: '#0f172a', marginBottom: 8 }}>结果列表 ({products.length})</div>
|
||||
{products.length === 0 ? (
|
||||
<div style={{ ...mutedStyle, padding: '14px 0' }}>{loading ? '正在加载结果...' : '暂无已登记 SBAS 结果。'}</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 8, maxHeight: 720, overflowY: 'auto', paddingRight: 4 }}>
|
||||
{products.map(product => {
|
||||
const active = product.id === selectedId;
|
||||
const productCenter = product.center || bboxCenter(product);
|
||||
return (
|
||||
<button
|
||||
key={product.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(product.id)}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
border: `1px solid ${active ? '#93c5fd' : '#e2e8f0'}`,
|
||||
borderRadius: 8,
|
||||
background: active ? '#eff6ff' : '#ffffff',
|
||||
padding: '10px 11px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginBottom: 4 }}>
|
||||
<strong style={{ fontSize: 12, color: '#0f172a', wordBreak: 'break-all' }}>
|
||||
{product.display_name || product.product_id}
|
||||
</strong>
|
||||
<StatusBadge value={product.status} />
|
||||
</div>
|
||||
<div style={mutedStyle}>
|
||||
{product.date_start || '-'} 至 {product.date_end || '-'} / {product.stack_size || product.scene_count || 0} 景 / {product.pair_count || 0} 对
|
||||
</div>
|
||||
<div style={{ ...mutedStyle, marginTop: 3 }}>行政区:{formatAdminRegion(product.admin_region)}</div>
|
||||
<div style={{ ...mutedStyle, marginTop: 3 }}>中心点:{formatCenter(productCenter)}</div>
|
||||
<div style={{ ...mutedStyle, wordBreak: 'break-all', marginTop: 3 }}>{product.run_key || '-'}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{!selectedId ? (
|
||||
<section style={sectionStyle}>
|
||||
<div style={mutedStyle}>请选择一个 SBAS 结果。</div>
|
||||
</section>
|
||||
) : detailLoading ? (
|
||||
<section style={sectionStyle}>
|
||||
<div style={mutedStyle}>正在加载结果详情...</div>
|
||||
</section>
|
||||
) : detail?.error ? (
|
||||
<section style={sectionStyle}>
|
||||
<div style={{ color: '#dc2626', fontSize: 13 }}>{detail.error}</div>
|
||||
</section>
|
||||
) : detail ? (
|
||||
<>
|
||||
<section style={sectionStyle}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(180px, 260px) minmax(0, 1fr)', gap: 14, alignItems: 'start' }}>
|
||||
<div style={{ border: '1px solid #e2e8f0', borderRadius: 8, overflow: 'hidden', background: '#0f172a' }}>
|
||||
<img
|
||||
src={getSbasInsarProductPreviewUrl(detail.id)}
|
||||
alt={detail.display_name}
|
||||
style={{ display: 'block', width: '100%', minHeight: 150, objectFit: 'contain' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 18, color: '#0f172a' }}>{detail.display_name || detail.product_id}</h3>
|
||||
<div style={{ ...mutedStyle, marginTop: 4, wordBreak: 'break-all' }}>{detail.product_id}</div>
|
||||
</div>
|
||||
<StatusBadge value={detail.status} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 8, marginTop: 12 }}>
|
||||
<Metric label="参考日期" value={detail.reference_date || '-'} />
|
||||
<Metric label="时间范围" value={`${detail.date_start || '-'} 至 ${detail.date_end || '-'}`} />
|
||||
<Metric label="景数 / 干涉对" value={`${detail.scene_count || detail.stack_size || 0} / ${detail.pair_count || 0}`} />
|
||||
<Metric label="监测点" value={monitorPoints.length || 0} />
|
||||
</div>
|
||||
<div style={{ ...mutedStyle, marginTop: 10 }}>
|
||||
<div><strong>LOS 约定:</strong>{detail.los_sign_convention || 'toward radar positive; away from radar negative'}</div>
|
||||
<div><strong>Run:</strong>{detail.run_id || detail.run_key || '-'}</div>
|
||||
<div><strong>Stack:</strong>{detail.stack_key || '-'}</div>
|
||||
<div><strong>Manifest:</strong>{detail.manifest_path || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<h4 style={{ margin: '0 0 10px', fontSize: 15, color: '#0f172a' }}>位置摘要</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 8 }}>
|
||||
<Metric label="中心点 lon, lat" value={formatCenter(center)} />
|
||||
<Metric label="中心点行政区" value={formatAdminRegion(adminRegion)} />
|
||||
<Metric label="影像 BBox" value={formatBbox(coverage.bbox)} />
|
||||
<Metric label="交集 BBox" value={formatBbox(coverage.bbox_intersection)} />
|
||||
<Metric label="单景范围数" value={(coverage.scene_footprints_geojson?.features || []).length || coverage.scene_bbox_count || 0} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<h4 style={{ margin: '0 0 10px', fontSize: 15, color: '#0f172a' }}>重要产物预览</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 12 }}>
|
||||
<ProductPreview title="LOS 速率图" asset={velocityPreview} productId={detail.id} />
|
||||
<ProductPreview title="LOS Sigma 图" asset={sigmaPreview} productId={detail.id} />
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<PointVectorDownload asset={pointVectorAsset} summary={pointVectorSummary} productId={detail.id} />
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 800, color: '#0f172a', marginBottom: 8 }}>监测点形变曲线</div>
|
||||
{monitorPreviews.length === 0 ? (
|
||||
<div style={{ ...mutedStyle, border: '1px solid #e2e8f0', borderRadius: 8, padding: 10, background: '#f8fafc' }}>
|
||||
暂无监测点曲线。
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 12 }}>
|
||||
{monitorPreviews.map(asset => (
|
||||
<ProductPreview key={asset.id} title={asset.asset_name || '监测点曲线'} asset={asset} productId={detail.id} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<h4 style={{ margin: '0 0 10px', fontSize: 15, color: '#0f172a' }}>统计摘要</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 8 }}>
|
||||
<Metric label="速率中位数" value={`${formatNumber(rateStats.median, 2)} mm/yr`} />
|
||||
<Metric label="速率 P05 / P95" value={`${formatNumber(rateStats.p05, 2)} / ${formatNumber(rateStats.p95, 2)}`} />
|
||||
<Metric label="Sigma 中位数" value={`${formatNumber(sigmaStats.median, 2)} mm/yr`} />
|
||||
<Metric label="有效像元" value={rateStats.valid_count ?? '-'} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<h4 style={{ margin: '0 0 10px', fontSize: 15, color: '#0f172a' }}>资产下载</h4>
|
||||
<div style={{ display: 'grid', gap: 7 }}>
|
||||
{selectedAssets.map(asset => (
|
||||
<div
|
||||
key={asset.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(160px, 220px) minmax(0, 1fr) auto',
|
||||
gap: 10,
|
||||
alignItems: 'center',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 8,
|
||||
padding: '8px 10px',
|
||||
background: asset.exists_flag ? '#ffffff' : '#fef2f2',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 750, color: '#0f172a' }}>{asset.asset_role}</div>
|
||||
<div style={mutedStyle}>{formatBytes(asset.file_size)} / {asset.format || '-'}</div>
|
||||
</div>
|
||||
<div style={{ ...mutedStyle, wordBreak: 'break-all' }}>{asset.relative_path}</div>
|
||||
{asset.exists_flag ? (
|
||||
<a href={getSbasInsarProductAssetUrl(detail.id, asset.id)} target="_blank" rel="noreferrer" style={{ color: '#1d4ed8', fontSize: 12, fontWeight: 750 }}>
|
||||
打开
|
||||
</a>
|
||||
) : (
|
||||
<span style={{ color: '#dc2626', fontSize: 12 }}>缺失</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<h4 style={{ margin: '0 0 10px', fontSize: 15, color: '#0f172a' }}>问题</h4>
|
||||
{selectedIssues.length === 0 ? (
|
||||
<div style={{ color: '#15803d', fontSize: 12 }}>当前目录索引未发现问题。</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 7 }}>
|
||||
{selectedIssues.map(issue => (
|
||||
<div key={issue.id} style={{ border: '1px solid #e2e8f0', borderRadius: 8, padding: '8px 10px', fontSize: 12 }}>
|
||||
<strong style={{ color: issue.severity === 'ERROR' ? '#dc2626' : '#b45309' }}>{issue.severity} / {issue.issue_code}</strong>
|
||||
<div style={{ color: '#334155', marginTop: 3 }}>{issue.message}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export const getSbasInsarCatalogStatus = () =>
|
||||
apiClient.get('/sbas-insar-products/catalog-status').then(r => r.data);
|
||||
|
||||
export const queueSbasInsarCatalogRebuild = payload =>
|
||||
apiClient.post('/sbas-insar-products/rebuild', payload).then(r => r.data);
|
||||
|
||||
export const listSbasInsarProducts = (params = {}) =>
|
||||
apiClient.get('/sbas-insar-products', { params }).then(r => r.data);
|
||||
|
||||
export const getSbasInsarProductDetail = productId =>
|
||||
apiClient.get(`/sbas-insar-products/${encodeURIComponent(productId)}`).then(r => r.data);
|
||||
|
||||
export const getSbasInsarProductPreviewUrl = productId =>
|
||||
`${apiClient.defaults.baseURL || '/api'}/sbas-insar-products/${encodeURIComponent(productId)}/preview`;
|
||||
|
||||
export const getSbasInsarProductAssetUrl = (productId, assetId) =>
|
||||
`${apiClient.defaults.baseURL || '/api'}/sbas-insar-products/${encodeURIComponent(productId)}/assets/${encodeURIComponent(assetId)}`;
|
||||
@@ -70,6 +70,11 @@ export const PRODUCTION_WORKSPACE_VIEWS = [
|
||||
label: 'SBAS-InSAR Production',
|
||||
description: 'Gamma IPTA SBAS stack production, velocity maps, quality metrics, and monitor-point curves',
|
||||
},
|
||||
{
|
||||
key: 'sbas_insar_products',
|
||||
label: 'SBAS-InSAR 结果',
|
||||
description: 'Gamma SBAS LOS velocity, uncertainty, coverage and monitoring-point product catalog',
|
||||
},
|
||||
{
|
||||
key: 'dinsar_products',
|
||||
label: 'D-InSAR 产物',
|
||||
@@ -82,7 +87,7 @@ export const PRODUCTION_WORKSPACE_ENTRY_TO_VIEW = Object.freeze({
|
||||
dinsar_production: 'dinsar_runs',
|
||||
dinsar_products: 'dinsar_products',
|
||||
ps_production: 'sbas_insar_production',
|
||||
ps_products: 'sbas_insar_production',
|
||||
ps_products: 'sbas_insar_products',
|
||||
});
|
||||
|
||||
export const PRODUCTION_WORKSPACE_ROUTE_TABS = new Set([
|
||||
|
||||
Reference in New Issue
Block a user