feat: add SBAS AOI discovery and result management

This commit is contained in:
2026-05-28 22:06:08 +08:00
parent 9f0ba325f9
commit 0b7a875ba8
18 changed files with 4100 additions and 62 deletions
+27 -1
View File
@@ -19,6 +19,7 @@ from .services.pairing_state_service import pairing_state_service
from .services.psinsar_catalog_service import psinsar_catalog_service from .services.psinsar_catalog_service import psinsar_catalog_service
from .services.result_catalog_service import result_catalog_service from .services.result_catalog_service import result_catalog_service
from .services.root_registry_service import root_registry_service from .services.root_registry_service import root_registry_service
from .services.sbas_insar_catalog_service import sbas_insar_catalog_service
@asynccontextmanager @asynccontextmanager
@@ -83,6 +84,18 @@ async def lifespan(app: FastAPI):
"queued": False, "queued": False,
"error": str(exc), "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: try:
pairing_bootstrap = await pairing_state_service.bootstrap_pairing_cache_state() pairing_bootstrap = await pairing_state_service.bootstrap_pairing_cache_state()
except Exception as exc: except Exception as exc:
@@ -178,6 +191,17 @@ async def lifespan(app: FastAPI):
) )
if ps_catalog_bootstrap.get("error"): if ps_catalog_bootstrap.get("error"):
print(f">>> [Timeseries Catalog] Startup bootstrap failed: {ps_catalog_bootstrap['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( print(
">>> [Pairing] status={0} scenes={1} pairs={2} dirty={3} metric={4} rebuild={5}".format( ">>> [Pairing] status={0} scenes={1} pairs={2} dirty={3} metric={4} rebuild={5}".format(
pairing_bootstrap.get("status") or "?", pairing_bootstrap.get("status") or "?",
@@ -214,17 +238,19 @@ async def lifespan(app: FastAPI):
health.get("timeseries_result_catalog", {}) health.get("timeseries_result_catalog", {})
or health.get("psinsar_result_catalog", {}) or health.get("psinsar_result_catalog", {})
).get("ok") ).get("ok")
sbas_catalog_ok = health.get("sbas_insar_result_catalog", {}).get("ok")
pairing_ok = health.get("pairing_system", {}).get("ok") pairing_ok = health.get("pairing_system", {}).get("ok")
idl_ok = health.get("idl", {}).get("ok") idl_ok = health.get("idl", {}).get("ok")
product_packages_ok = health.get("product_packages", {}).get("ok") product_packages_ok = health.get("product_packages", {}).get("ok")
wsl_runtime_ok = health.get("wsl_runtime", {}).get("ok") wsl_runtime_ok = health.get("wsl_runtime", {}).get("ok")
print( 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 db_ok else "FAIL",
"OK" if schema_ok else "FAIL", "OK" if schema_ok else "FAIL",
"OK" if worker_ok else "FAIL", "OK" if worker_ok else "FAIL",
"OK" if dinsar_catalog_ok else "FAIL", "OK" if dinsar_catalog_ok else "FAIL",
"OK" if psinsar_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 product_packages_ok else "FAIL",
"OK" if wsl_runtime_ok else "FAIL", "OK" if wsl_runtime_ok else "FAIL",
"OK" if pairing_ok else "FAIL", "OK" if pairing_ok else "FAIL",
+2
View File
@@ -20,6 +20,7 @@ from . import (
orbit, orbit,
pairing, pairing,
ps_products, ps_products,
sbas_insar_products,
radar, radar,
root_registry, root_registry,
sbas_insar_production, sbas_insar_production,
@@ -55,6 +56,7 @@ def include_all_routers(router: APIRouter) -> None:
router.include_router(dinsar_products.router) router.include_router(dinsar_products.router)
router.include_router(dinsar_production.router) router.include_router(dinsar_production.router)
router.include_router(sbas_insar_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(timeseries_production.router)
router.include_router(ps_products.router) router.include_router(ps_products.router)
router.include_router(ai.router) router.include_router(ai.router)
+42 -3
View File
@@ -6,7 +6,7 @@ import subprocess
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse 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.job_queue_service import job_queue_service
from ..services.sbas_insar_production_service import sbas_insar_production_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"]) 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): class SbasStackDiscoverRequest(BaseModel):
source_roots: list[str] | None = None source_roots: list[str] | None = None
orbit_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) platform: str | None = Field(default=None, max_length=16)
relative_orbit: str | None = Field(default=None, max_length=32) relative_orbit: str | None = Field(default=None, max_length=32)
orbit_direction: 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") @field_validator("source_roots", "orbit_roots", mode="before")
@classmethod @classmethod
@@ -39,7 +57,7 @@ class SbasStackDiscoverRequest(BaseModel):
cleaned = [str(item or "").strip() for item in items if str(item or "").strip()] cleaned = [str(item or "").strip() for item in items if str(item or "").strip()]
return cleaned or None 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 @classmethod
def _normalize_optional_text(cls, value): def _normalize_optional_text(cls, value):
if value is None: if value is None:
@@ -47,6 +65,12 @@ class SbasStackDiscoverRequest(BaseModel):
text = str(value).strip() text = str(value).strip()
return text or None 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): class SbasMonitorPoint(BaseModel):
point_id: str | None = Field(default=None, max_length=64) point_id: str | None = Field(default=None, max_length=64)
@@ -58,7 +82,7 @@ class SbasMonitorPoint(BaseModel):
class SbasRunSubmitRequest(SbasStackDiscoverRequest): class SbasRunSubmitRequest(SbasStackDiscoverRequest):
run_label: str | None = Field(default=None, max_length=120) run_label: str | None = Field(default=None, max_length=120)
dry_run: bool = True 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 monitor_points: list[SbasMonitorPoint] | None = None
@@ -160,6 +184,11 @@ async def discover_sbas_insar_stacks(request: SbasStackDiscoverRequest):
platform=request.platform, platform=request.platform,
relative_orbit=request.relative_orbit, relative_orbit=request.relative_orbit,
orbit_direction=request.orbit_direction, 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: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from 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, orbit_roots=request.orbit_roots,
min_scenes=request.min_scenes, min_scenes=request.min_scenes,
require_orbits=request.require_orbits, 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: except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from 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, orbit_roots=request.orbit_roots,
min_scenes=request.min_scenes, min_scenes=request.min_scenes,
require_orbits=request.require_orbits, 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=[ monitor_points=[
point.model_dump(exclude_none=True) point.model_dump(exclude_none=True)
for point in (request.monitor_points or []) for point in (request.monitor_points or [])
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
import os
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db
from ..models import AuthUserORM
from ..services.job_queue_service import job_queue_service
from ..services.sbas_insar_catalog_service import (
JOB_TYPE_REBUILD_SBAS_INSAR_CATALOG,
TASK_TYPE_REBUILD_SBAS_INSAR_CATALOG,
sbas_insar_catalog_service,
)
from ..services.task_service import task_service
from .dependencies import _add_operation_audit_log, _get_current_user, _require_admin
router = APIRouter()
class SbasInsarCatalogRebuildRequest(BaseModel):
full_rebuild: bool = True
@router.get("/sbas-insar-products/catalog-status")
async def get_sbas_insar_catalog_status(
current_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
return await sbas_insar_catalog_service.get_catalog_status(db)
@router.post("/sbas-insar-products/rebuild", status_code=202)
async def queue_sbas_insar_catalog_rebuild(
request: SbasInsarCatalogRebuildRequest,
http_request: Request,
db: AsyncSession = Depends(get_db),
admin_user: AuthUserORM = Depends(_require_admin),
):
_ = admin_user
task_id = await task_service.create_task(
TASK_TYPE_REBUILD_SBAS_INSAR_CATALOG,
"SBAS-InSAR result catalog rebuild",
params={"full_rebuild": request.full_rebuild},
db=db,
)
await job_queue_service.create_job(
JOB_TYPE_REBUILD_SBAS_INSAR_CATALOG,
payload={"full_rebuild": request.full_rebuild},
task_id=task_id,
db=db,
)
await _add_operation_audit_log(
db,
request=http_request,
action="sbas_insar_catalog_rebuild_queued",
resource="sbas-insar-products/rebuild",
detail={"task_id": task_id, "full_rebuild": request.full_rebuild},
)
await db.commit()
return {
"message": "SBAS-InSAR result catalog rebuild has been queued.",
"task_id": task_id,
}
@router.get("/sbas-insar-products")
async def list_sbas_insar_products(
limit: int = 100,
offset: int = 0,
status: str | None = None,
query: str | None = None,
admin_region: str | None = None,
current_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
return await sbas_insar_catalog_service.list_products(
db,
limit=limit,
offset=offset,
status=status,
query=query,
admin_region=admin_region,
)
@router.get("/sbas-insar-products/{product_db_id}")
async def get_sbas_insar_product_detail(
product_db_id: int,
current_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
detail = await sbas_insar_catalog_service.get_product_detail(db, product_db_id=product_db_id)
if detail is None:
raise HTTPException(status_code=404, detail="SBAS-InSAR product not found")
return detail
@router.get("/sbas-insar-products/{product_db_id}/preview")
async def get_sbas_insar_product_preview(
product_db_id: int,
current_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
detail = await sbas_insar_catalog_service.get_product_detail(db, product_db_id=product_db_id)
if detail is None:
raise HTTPException(status_code=404, detail="SBAS-InSAR product not found")
preview_path = str(detail.get("preview_path") or "").strip()
if not preview_path or not os.path.isfile(preview_path):
raise HTTPException(status_code=404, detail="Preview not found")
return FileResponse(preview_path, media_type="image/png")
@router.get("/sbas-insar-products/{product_db_id}/assets/{asset_id}")
async def get_sbas_insar_product_asset(
product_db_id: int,
asset_id: int,
current_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
asset = await sbas_insar_catalog_service.get_asset(db, product_db_id=product_db_id, asset_id=asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="SBAS-InSAR product asset not found")
if not asset.absolute_path or not os.path.isfile(asset.absolute_path):
raise HTTPException(status_code=404, detail="Asset file not found")
return FileResponse(
asset.absolute_path,
media_type=asset.media_type or "application/octet-stream",
filename=asset.asset_name or os.path.basename(asset.absolute_path),
)
@@ -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()
+16
View File
@@ -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]: async def _check_psinsar_result_catalog() -> Dict[str, Any]:
return await _check_timeseries_result_catalog() 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 {} payload.get("timeseries_result_catalog", {}) or payload.get("psinsar_result_catalog", {}) or {}
) )
psinsar_result_catalog = timeseries_result_catalog psinsar_result_catalog = timeseries_result_catalog
sbas_insar_result_catalog = payload.get("sbas_insar_result_catalog", {}) or {}
dinsar_bridge = payload.get("dinsar_bridge", {}) or {} dinsar_bridge = payload.get("dinsar_bridge", {}) or {}
source_roots = payload.get("source_roots", {}) or {} source_roots = payload.get("source_roots", {}) or {}
sar_analysis_ready = payload.get("sar_analysis_ready", {}) 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_dinsar_catalog = _sanitize_catalog_status(dinsar_result_catalog)
sanitized_timeseries_catalog = _sanitize_catalog_status(timeseries_result_catalog) sanitized_timeseries_catalog = _sanitize_catalog_status(timeseries_result_catalog)
sanitized_psinsar_catalog = sanitized_timeseries_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_dinsar_bridge = _sanitize_bridge_status(dinsar_bridge)
sanitized_source_roots = _sanitize_source_roots_status(source_roots) sanitized_source_roots = _sanitize_source_roots_status(source_roots)
sanitized_sar_analysis_ready = _sanitize_sar_analysis_ready_status(sar_analysis_ready) 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, "dinsar_result_catalog": sanitized_dinsar_catalog,
"timeseries_result_catalog": sanitized_timeseries_catalog, "timeseries_result_catalog": sanitized_timeseries_catalog,
"psinsar_result_catalog": sanitized_psinsar_catalog, "psinsar_result_catalog": sanitized_psinsar_catalog,
"sbas_insar_result_catalog": sanitized_sbas_insar_catalog,
"catalogs": { "catalogs": {
"dinsar": sanitized_dinsar_catalog, "dinsar": sanitized_dinsar_catalog,
"timeseries": sanitized_timeseries_catalog, "timeseries": sanitized_timeseries_catalog,
"psinsar": sanitized_psinsar_catalog, "psinsar": sanitized_psinsar_catalog,
"sbas_insar": sanitized_sbas_insar_catalog,
}, },
"dinsar_bridge": sanitized_dinsar_bridge, "dinsar_bridge": sanitized_dinsar_bridge,
"source_roots": sanitized_source_roots, "source_roots": sanitized_source_roots,
@@ -1340,6 +1352,7 @@ async def get_health_status(
result_catalog_status = await _check_result_catalog() result_catalog_status = await _check_result_catalog()
timeseries_result_catalog_status = await _check_timeseries_result_catalog() timeseries_result_catalog_status = await _check_timeseries_result_catalog()
psinsar_result_catalog_status = timeseries_result_catalog_status 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() dinsar_bridge_status = await _check_dinsar_bridge()
source_roots_status = await _check_source_roots() source_roots_status = await _check_source_roots()
sar_analysis_ready_status = await _check_sar_analysis_ready() sar_analysis_ready_status = await _check_sar_analysis_ready()
@@ -1366,6 +1379,7 @@ async def get_health_status(
wsl_runtime_status.get("ok"), wsl_runtime_status.get("ok"),
pairing_system_status.get("ok"), pairing_system_status.get("ok"),
(not settings.TIMESERIES_ENABLED) or timeseries_result_catalog_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, "dinsar_result_catalog": result_catalog_status,
"timeseries_result_catalog": timeseries_result_catalog_status, "timeseries_result_catalog": timeseries_result_catalog_status,
"psinsar_result_catalog": psinsar_result_catalog_status, "psinsar_result_catalog": psinsar_result_catalog_status,
"sbas_insar_result_catalog": sbas_insar_result_catalog_status,
"catalogs": { "catalogs": {
"dinsar": result_catalog_status, "dinsar": result_catalog_status,
"timeseries": timeseries_result_catalog_status, "timeseries": timeseries_result_catalog_status,
"psinsar": psinsar_result_catalog_status, "psinsar": psinsar_result_catalog_status,
"sbas_insar": sbas_insar_result_catalog_status,
}, },
"dinsar_bridge": dinsar_bridge_status, "dinsar_bridge": dinsar_bridge_status,
"source_roots": source_roots_status, "source_roots": source_roots_status,
+27
View File
@@ -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 .envi_service import build_envi_runner_command, get_envi_runner_cwd, get_envi_runner_env
from .psinsar_catalog_service import psinsar_catalog_service from .psinsar_catalog_service import psinsar_catalog_service
from .result_catalog_service import result_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 .task_service import task_service
from .timeseries_service import ( from .timeseries_service import (
JOB_TYPE_TIMESERIES_MATERIALIZE, JOB_TYPE_TIMESERIES_MATERIALIZE,
@@ -90,6 +91,7 @@ JOB_TYPE_PYINT_RUN = "PYINT_RUN"
JOB_TYPE_PUBLISH_DINSAR_PRODUCTS = "PUBLISH_DINSAR_PRODUCTS" JOB_TYPE_PUBLISH_DINSAR_PRODUCTS = "PUBLISH_DINSAR_PRODUCTS"
JOB_TYPE_REBUILD_DINSAR_CATALOG = "REBUILD_DINSAR_CATALOG" JOB_TYPE_REBUILD_DINSAR_CATALOG = "REBUILD_DINSAR_CATALOG"
JOB_TYPE_REBUILD_PSINSAR_CATALOG = "REBUILD_PSINSAR_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_SCAN_ASSET_INVENTORY = "SCAN_ASSET_INVENTORY"
JOB_TYPE_SBAS_COREGISTRATION = "SBAS_COREGISTRATION" JOB_TYPE_SBAS_COREGISTRATION = "SBAS_COREGISTRATION"
JOB_TYPE_SBAS_RDC_DEM = "SBAS_RDC_DEM" 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: async def _handle_sbas_coregistration(job: SystemJobORM) -> None:
if not job.task_id: if not job.task_id:
raise ValueError("SBAS_COREGISTRATION requires task_id for progress tracking.") 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_EXPORT_PUBLISH: _handle_timeseries_export_publish,
JOB_TYPE_TIMESERIES_REGISTER_PRODUCT: _handle_timeseries_register_product, JOB_TYPE_TIMESERIES_REGISTER_PRODUCT: _handle_timeseries_register_product,
JOB_TYPE_REBUILD_PSINSAR_CATALOG: _handle_rebuild_psinsar_catalog, 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_COPY_DATA: _handle_copy_data,
JOB_TYPE_UNPACK: _handle_unpack_archives, JOB_TYPE_UNPACK: _handle_unpack_archives,
JOB_TYPE_UNPACK_SENTINEL1: _handle_unpack_sentinel1, 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 typing import Any
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
from shapely.geometry import box as shapely_box
from ..config import settings from ..config import settings
from .admin_region_lookup_service import (
admin_region_matches,
lookup_admin_region_for_point,
lookup_admin_region_geometry,
)
PRODUCT_DEFINITIONS = ( PRODUCT_DEFINITIONS = (
@@ -745,7 +752,7 @@ class SbasInsarProductionService:
"default_strategy": "gamma_geocode_back_data2geotiff_los_sign_conversion", "default_strategy": "gamma_geocode_back_data2geotiff_los_sign_conversion",
"geocoded_preview_source": "EPSG:4326 GeoTIFF", "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": { "default_los_convention": {
"key": "los_rate_toward_mm_per_year", "key": "los_rate_toward_mm_per_year",
"description": "toward radar positive; away from radar negative", "description": "toward radar positive; away from radar negative",
@@ -778,10 +785,20 @@ class SbasInsarProductionService:
platform: str | None = None, platform: str | None = None,
relative_orbit: str | None = None, relative_orbit: str | None = None,
orbit_direction: 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, force_refresh: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
source_paths = self._resolve_source_roots(source_roots) source_paths = self._resolve_source_roots(source_roots)
orbit_paths = self._resolve_orbit_roots(orbit_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( cache_key = self._discovery_cache_key(
source_paths=source_paths, source_paths=source_paths,
orbit_paths=orbit_paths, orbit_paths=orbit_paths,
@@ -792,6 +809,11 @@ class SbasInsarProductionService:
platform=platform, platform=platform,
relative_orbit=relative_orbit, relative_orbit=relative_orbit,
orbit_direction=orbit_direction, 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: if not force_refresh:
cached = self._read_discovery_cache(cache_key) cached = self._read_discovery_cache(cache_key)
@@ -804,6 +826,7 @@ class SbasInsarProductionService:
platform_filter = str(platform or "").strip().upper() platform_filter = str(platform or "").strip().upper()
rel_filter = str(relative_orbit or "").strip() rel_filter = str(relative_orbit or "").strip()
direction_filter = str(orbit_direction or "").strip().upper() 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: for root in source_paths:
try: try:
@@ -819,18 +842,54 @@ class SbasInsarProductionService:
continue continue
if direction_filter and str(scene.get("orbit_direction") or "").upper() != direction_filter: if direction_filter and str(scene.get("orbit_direction") or "").upper() != direction_filter:
continue 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) scenes.append(scene)
except Exception as exc: except Exception as exc:
errors.append({"source_root": str(root), "error": str(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: 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 = [ 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() 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( candidates.sort(
key=lambda item: ( key=lambda item: (
int(item.get("status") != "READY"), int(item.get("status") != "READY"),
@@ -852,6 +911,11 @@ class SbasInsarProductionService:
"orbit_roots": [str(path) for path in orbit_paths], "orbit_roots": [str(path) for path in orbit_paths],
"min_scenes": min_scenes, "min_scenes": min_scenes,
"require_orbits": require_orbits, "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), "scene_count": len(scenes),
"candidate_count": len(candidates), "candidate_count": len(candidates),
"errors": errors[:50], "errors": errors[:50],
@@ -874,6 +938,11 @@ class SbasInsarProductionService:
orbit_roots: list[str] | None = None, orbit_roots: list[str] | None = None,
min_scenes: int = 3, min_scenes: int = 3,
require_orbits: bool = True, 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]: ) -> dict[str, Any]:
discovery = self.discover_stacks( discovery = self.discover_stacks(
source_roots=source_roots, source_roots=source_roots,
@@ -882,6 +951,11 @@ class SbasInsarProductionService:
require_orbits=require_orbits, require_orbits=require_orbits,
include_scenes=True, include_scenes=True,
limit=0, 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( candidate = next(
(item for item in discovery.get("items", []) if item.get("stack_id") == stack_id), (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", "status": "READY_FOR_GAMMA_BASELINE_AUDIT" if not blockers else "BLOCKED",
"require_orbits": require_orbits, "require_orbits": require_orbits,
"min_scenes": min_scenes, "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": { "stack": {
key: candidate.get(key) key: candidate.get(key)
for key in [ for key in [
@@ -941,6 +1018,7 @@ class SbasInsarProductionService:
"reference_date", "reference_date",
] ]
}, },
"geographic_coverage": self._build_stack_geographic_coverage({"scenes": usable_scenes}),
"scenes": usable_scenes, "scenes": usable_scenes,
"excluded_scenes": [ "excluded_scenes": [
scene for scene in candidate.get("scenes", []) scene for scene in candidate.get("scenes", [])
@@ -983,7 +1061,12 @@ class SbasInsarProductionService:
min_scenes: int = 3, min_scenes: int = 3,
require_orbits: bool = True, require_orbits: bool = True,
monitor_points: list[dict[str, Any]] | None = None, 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, dry_run: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
audit = self.audit_stack( audit = self.audit_stack(
@@ -992,6 +1075,11 @@ class SbasInsarProductionService:
orbit_roots=orbit_roots, orbit_roots=orbit_roots,
min_scenes=min_scenes, min_scenes=min_scenes,
require_orbits=require_orbits, 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"] manifest = audit["manifest"]
if manifest.get("status") != "READY_FOR_GAMMA_BASELINE_AUDIT": if manifest.get("status") != "READY_FOR_GAMMA_BASELINE_AUDIT":
@@ -1023,6 +1111,9 @@ class SbasInsarProductionService:
"status": "WORKFLOW_READY", "status": "WORKFLOW_READY",
"created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z", "created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
"stack_id": stack_id, "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"], "stack_manifest_path": audit["manifest_path"],
"pair_network_path": audit["pair_network_path"], "pair_network_path": audit["pair_network_path"],
"workflow_manifest_path": str(run_dir / "manifest.json"), "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") workflow_state = self._read_optional_json(run_dir / "state" / "step_status.json")
monitor_points = self._read_optional_json(run_dir / "monitor_points.json") monitor_points = self._read_optional_json(run_dir / "monitor_points.json")
geographic_coverage = self._build_run_geographic_coverage(run_dir, manifest)
return { return {
"run": self._build_run_card(run_dir, manifest), "run": self._build_run_card(run_dir, manifest),
"manifest": manifest, "manifest": manifest,
@@ -1113,6 +1205,7 @@ class SbasInsarProductionService:
"workflow_manifest": workflow_manifest, "workflow_manifest": workflow_manifest,
"workflow_state": workflow_state, "workflow_state": workflow_state,
"monitor_points": monitor_points, "monitor_points": monitor_points,
"geographic_coverage": geographic_coverage,
"artifacts": self._build_run_artifacts(run_dir), "artifacts": self._build_run_artifacts(run_dir),
} }
@@ -2483,6 +2576,9 @@ class SbasInsarProductionService:
}, },
"outputs": { "outputs": {
"export_dir": str(export_dir), "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"), "product_summary": str(run_dir / "product_summary.json"),
"quality_summary": str(run_dir / "quality_summary.json"), "quality_summary": str(run_dir / "quality_summary.json"),
}, },
@@ -3614,6 +3710,11 @@ class SbasInsarProductionService:
platform: str | None, platform: str | None,
relative_orbit: str | None, relative_orbit: str | None,
orbit_direction: 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: ) -> str:
payload = { payload = {
"source_paths": [os.path.normcase(str(path.resolve())) for path in source_paths], "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(), "platform": str(platform or "").strip().upper(),
"relative_orbit": str(relative_orbit or "").strip(), "relative_orbit": str(relative_orbit or "").strip(),
"orbit_direction": str(orbit_direction or "").strip().upper(), "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] return hashlib.sha1(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()[:16]
@@ -4433,6 +4540,249 @@ class SbasInsarProductionService:
except (KeyError, TypeError, ValueError): except (KeyError, TypeError, ValueError):
return None 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 @staticmethod
def _file_record(path: Path) -> dict[str, Any]: def _file_record(path: Path) -> dict[str, Any]:
exists = path.is_file() exists = path.is_file()
@@ -4478,12 +4828,157 @@ class SbasInsarProductionService:
] ]
return "|".join(str(part or "") for part in parts) 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( def _build_stack_candidate(
self, self,
scenes: list[dict[str, Any]], scenes: list[dict[str, Any]],
*, *,
min_scenes: int, min_scenes: int,
require_orbits: bool, require_orbits: bool,
discovery_mode: str = "strict",
aoi_summary: dict[str, Any] | None = None,
min_common_overlap_ratio: float = 0.0,
) -> dict[str, Any]: ) -> dict[str, Any]:
scenes = sorted(scenes, key=lambda item: str(item.get("date") or "")) scenes = sorted(scenes, key=lambda item: str(item.get("date") or ""))
first = scenes[0] first = scenes[0]
@@ -4491,7 +4986,11 @@ class SbasInsarProductionService:
usable = orbit_ready if require_orbits else scenes usable = orbit_ready if require_orbits else scenes
dates = [scene.get("date") for scene in scenes if scene.get("date")] 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")] 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) stack_id = self._stable_id(group_key)
temporal_gaps = self._temporal_gaps(usable_dates) temporal_gaps = self._temporal_gaps(usable_dates)
blockers: list[str] = [] blockers: list[str] = []
@@ -4499,11 +4998,55 @@ class SbasInsarProductionService:
blockers.append(f"usable_scene_count {len(usable)} < min_scenes {min_scenes}") blockers.append(f"usable_scene_count {len(usable)} < min_scenes {min_scenes}")
if require_orbits and len(orbit_ready) < len(scenes): if require_orbits and len(orbit_ready) < len(scenes):
blockers.append("missing precise orbit for one or more 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 { return {
"stack_id": stack_id, "stack_id": stack_id,
"status": "READY" if not blockers else "BLOCKED", "status": "READY" if not blockers else "BLOCKED",
"blockers": blockers, "blockers": blockers,
"discovery_mode": mode,
"aoi": aoi_summary,
"group_key": group_key, "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": first.get("satellite"),
"satellite_mode": first.get("satellite_mode"), "satellite_mode": first.get("satellite_mode"),
"receiving_station": first.get("receiving_station"), "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, "reference_date": usable_dates[len(usable_dates) // 2] if usable_dates else None,
"temporal_gaps_days": temporal_gaps, "temporal_gaps_days": temporal_gaps,
"max_temporal_gap_days": max(temporal_gaps) if temporal_gaps else 0, "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, "scenes": scenes,
} }
@@ -5072,19 +5625,22 @@ class SbasInsarProductionService:
mode = "manual_lonlat" mode = "manual_lonlat"
note = "Manual monitoring points are stored for extraction after geocoded products are available." note = "Manual monitoring points are stored for extraction after geocoded products are available."
else: 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 = ( note = (
"Automatic point is only a production placeholder until users provide a point layer " "Automatic representative points are report-preview candidates until users provide "
"or approve a quality-filtered sampler." "a point layer or approve final monitoring locations."
) )
return { return {
"schema": "insar.sbas-monitor-points/v1", "schema": "insar.sbas-monitor-points/v1",
"mode": mode, "mode": mode,
"points": normalized_points, "points": normalized_points,
"auto_count": 5,
"default_auto_strategy": { "default_auto_strategy": {
"key": "auto_low_sigma_high_rate", "key": "auto_representative_points",
"selection": "low LOS sigma, high absolute LOS velocity, non-edge valid pixel", "selection": "away/toward/high-absolute-rate/stable/center valid pixels with low sigma and non-edge constraints",
"usage": "debug/sample only; not a business monitoring network", "usage": "preview candidates only; not a business monitoring network",
}, },
"reference_date": (stack_manifest.get("stack") or {}).get("reference_date"), "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", "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" 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" tool_script = Path(settings.PROJECT_ROOT) / "deploy" / "wsl" / "runners" / "gamma_sbas_product_tools.py"
phase_to_los = wavelength / (4.0 * math.pi) 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 = [ lines = [
"#!/usr/bin/env bash", "#!/usr/bin/env bash",
"set -euo pipefail", "set -euo pipefail",
@@ -6081,9 +6645,14 @@ class SbasInsarProductionService:
f'RLKS="{rlks}"', f'RLKS="{rlks}"',
f'WAVELENGTH="{wavelength:.12g}"', f'WAVELENGTH="{wavelength:.12g}"',
f'PHASE_TO_LOS="{phase_to_los:.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', 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}")"', 'RDC_WIDTH="$(awk \'$1 == "range_samples:" {print $2; exit}\' "${MLI_PAR}")"',
'GEO_WIDTH="$(awk \'$1 == "width:" {print $2; exit}\' "${DEM_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_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"', ' 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 "${EXPORT_DIR}"',
' ls -lh "${VECTOR_DIR}"',
'} >"${LOG_DIR}/publish_products.log" 2>&1', '} >"${LOG_DIR}/publish_products.log" 2>&1',
"", "",
'echo "Published Gamma SBAS products: ${EXPORT_DIR}"', '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_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", "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 = [ missing_outputs = [
name for name, path in required_outputs.items() name for name, path in required_outputs.items()
if not path.is_file() or path.stat().st_size <= 0 if not path.is_file() or path.stat().st_size <= 0
@@ -6961,8 +7550,10 @@ class SbasInsarProductionService:
}, },
"outputs": { "outputs": {
"export_dir": str(export_dir), "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, "rdc_size_checks": rdc_size_checks,
"quality_summary": quality_stats, "quality_summary": quality_stats,
"product_summary": product_summary, "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]: def _build_run_card(self, run_dir: Path, manifest: dict[str, Any]) -> dict[str, Any]:
stack = manifest.get("stack") or {} stack = manifest.get("stack") or {}
try:
coverage = self._build_run_geographic_coverage(run_dir, manifest)
except Exception:
coverage = {}
return { return {
"run_id": manifest.get("run_id") or run_dir.name, "run_id": manifest.get("run_id") or run_dir.name,
"run_label": manifest.get("run_label"), "run_label": manifest.get("run_label"),
@@ -7501,12 +8096,19 @@ class SbasInsarProductionService:
"scene_count": manifest.get("scene_count"), "scene_count": manifest.get("scene_count"),
"pair_count": manifest.get("pair_count"), "pair_count": manifest.get("pair_count"),
"next_stage": manifest.get("next_stage"), "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"), "platform": stack.get("satellite"),
"relative_orbit": stack.get("relative_orbit"), "relative_orbit": stack.get("relative_orbit"),
"direction": stack.get("orbit_direction"), "direction": stack.get("orbit_direction"),
"polarization": stack.get("polarization"), "polarization": stack.get("polarization"),
"center_bucket": stack.get("center_bucket"), "center_bucket": stack.get("center_bucket"),
"reference_date": stack.get("reference_date"), "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), "run_dir": str(run_dir),
} }
+331 -13
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import argparse import argparse
import csv import csv
import gzip
import json import json
import math import math
import re 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) (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 lines, width = rate.shape
yy, xx = np.indices(rate.shape) yy, xx = np.indices(rate.shape)
edge_mask = ( 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) valid = finite & edge_mask & (rate != 0.0) & (sigma > 0.0)
if not valid.any(): if not valid.any():
raise RuntimeError("No valid pixels available for monitor point selection") 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]) abs_rate = np.abs(rate[valid])
sig = sigma[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) 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]: def dem_grid(dem_par: Path) -> dict[str, float | int]:
return { return {
"width": int(read_gamma_value(dem_par, "width")), "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)} 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: def run_phase_to_los(args: argparse.Namespace) -> int:
write_scaled_float32(Path(args.input), Path(args.output), float(args.scale)) write_scaled_float32(Path(args.input), Path(args.output), float(args.scale))
return 0 return 0
@@ -273,18 +585,10 @@ def run_monitor_points(args: argparse.Namespace) -> int:
} }
) )
else: else:
x, y = pick_auto_point(rate_toward, sigma) auto_count = int(config.get("auto_count") or 5)
lon, lat = radar_to_lonlat(x, y, dem_par, lookup) for point in pick_auto_points(rate_toward, sigma, count=max(1, min(auto_count, 12))):
selected_points.append( lon, lat = radar_to_lonlat(int(point["range_pixel"]), int(point["azimuth_line"]), dem_par, lookup)
{ selected_points.append({**point, "lon": lon, "lat": lat})
"point_id": "auto_low_sigma_high_rate",
"selection": "automatic_low_sigma_high_rate_non_edge",
"range_pixel": x,
"azimuth_line": y,
"lon": lon,
"lat": lat,
}
)
outputs: list[dict[str, Any]] = [] outputs: list[dict[str, Any]] = []
for point in selected_points: for point in selected_points:
@@ -343,6 +647,20 @@ def build_parser() -> argparse.ArgumentParser:
phase.add_argument("scale", type=float) phase.add_argument("scale", type=float)
phase.set_defaults(func=run_phase_to_los) 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 = subparsers.add_parser("monitor-points")
monitor.add_argument("--monitor-config", required=True) monitor.add_argument("--monitor-config", required=True)
monitor.add_argument("--timeseries-dir", 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. 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 ## 11. Validation
Use `sbas_7537cc71c998` as the first validation run. 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 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? 1. Administrative-region naming should start with center-point lookup or intersection lookup?
Recommendation: center-point lookup first, intersection later. 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 可作为下一步高级入口接入,但不应在普通页面展示开发诊断信息。
+11
View File
@@ -9,6 +9,7 @@ import { PanelLoadingBody } from './components/app/AppLoadingFallbacks';
const LazyDinsarProductionPanel = lazy(() => import('./DinsarProductionPanel')); const LazyDinsarProductionPanel = lazy(() => import('./DinsarProductionPanel'));
const LazySbasInsarProductionPanel = lazy(() => import('./SbasInsarProductionPanel')); const LazySbasInsarProductionPanel = lazy(() => import('./SbasInsarProductionPanel'));
const LazySbasInsarProductsPanel = lazy(() => import('./SbasInsarProductsPanel'));
const LazyDinsarProductsPanel = lazy(() => import('./DinsarProductsPanel')); const LazyDinsarProductsPanel = lazy(() => import('./DinsarProductsPanel'));
const shellStyle = { const shellStyle = {
@@ -67,6 +68,10 @@ export default function ProductionWorkspace({
onTaskStart?.(taskId, 'D-InSAR 产物任务已入队,等待处理...'); onTaskStart?.(taskId, 'D-InSAR 产物任务已入队,等待处理...');
}; };
const handleSbasProductQueued = taskId => {
onTaskStart?.(taskId, 'SBAS-InSAR result catalog task queued.');
};
return ( return (
<div style={shellStyle}> <div style={shellStyle}>
<div style={heroStyle}> <div style={heroStyle}>
@@ -178,6 +183,12 @@ export default function ProductionWorkspace({
readOnly={readOnly} readOnly={readOnly}
/> />
)} )}
{activeView === 'sbas_insar_products' && (
<LazySbasInsarProductsPanel
readOnly={readOnly}
onJobQueued={handleSbasProductQueued}
/>
)}
{activeView === 'dinsar_products' && ( {activeView === 'dinsar_products' && (
<LazyDinsarProductsPanel <LazyDinsarProductsPanel
readOnly={readOnly} readOnly={readOnly}
+404 -22
View File
@@ -94,6 +94,62 @@ function formatBytes(value) {
return `${current.toFixed(current >= 100 ? 0 : 1)} ${units[index]}`; 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 }) { function StatusBadge({ value }) {
const okValues = new Set([ const okValues = new Set([
'READY', '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 }) { export default function SbasInsarProductionPanel({ readOnly = false }) {
const [capabilities, setCapabilities] = useState(null); const [capabilities, setCapabilities] = useState(null);
const [runs, setRuns] = useState([]); const [runs, setRuns] = useState([]);
@@ -170,6 +496,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
const [auditLoading, setAuditLoading] = useState(false); const [auditLoading, setAuditLoading] = useState(false);
const [stackAudit, setStackAudit] = useState(null); const [stackAudit, setStackAudit] = useState(null);
const [submitLoading, setSubmitLoading] = useState(false); const [submitLoading, setSubmitLoading] = useState(false);
const [stackAdminRegionQuery, setStackAdminRegionQuery] = useState('');
const [baselineAuditLoading, setBaselineAuditLoading] = useState(false); const [baselineAuditLoading, setBaselineAuditLoading] = useState(false);
const [itabDecisionLoading, setItabDecisionLoading] = useState(false); const [itabDecisionLoading, setItabDecisionLoading] = useState(false);
const [coregistrationLoading, setCoregistrationLoading] = useState(false); const [coregistrationLoading, setCoregistrationLoading] = useState(false);
@@ -188,6 +515,20 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
const [workflowJobLoading, setWorkflowJobLoading] = useState(false); const [workflowJobLoading, setWorkflowJobLoading] = useState(false);
const [workflowJob, setWorkflowJob] = useState(null); 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 () => { const loadProductionRuns = useCallback(async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
@@ -240,22 +581,17 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
setError(''); setError('');
setStackAudit(null); setStackAudit(null);
try { try {
const data = await discoverSbasInsarStacks({ const data = await discoverSbasInsarStacks(stackDiscoveryPayload);
min_scenes: 3,
require_orbits: true,
include_scenes: false,
limit: 30,
});
const items = Array.isArray(data?.items) ? data.items : []; const items = Array.isArray(data?.items) ? data.items : [];
setStackCandidates(items); setStackCandidates(items);
setSelectedStackId(current => current || items[0]?.stack_id || ''); setSelectedStackId(items[0]?.stack_id || '');
} catch (exc) { } catch (exc) {
setError(exc?.response?.data?.detail || exc.message || 'SBAS-InSAR 栈发现失败'); setError(exc?.response?.data?.detail || exc.message || 'SBAS-InSAR 栈发现失败');
setStackCandidates([]); setStackCandidates([]);
} finally { } finally {
setDiscovering(false); setDiscovering(false);
} }
}, []); }, [stackDiscoveryPayload]);
const handleAuditStack = useCallback(async stackId => { const handleAuditStack = useCallback(async stackId => {
if (!stackId) return; if (!stackId) return;
@@ -263,8 +599,8 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
setError(''); setError('');
try { try {
const data = await auditSbasInsarStack(stackId, { const data = await auditSbasInsarStack(stackId, {
min_scenes: 3, ...stackDiscoveryPayload,
require_orbits: true, include_scenes: true,
}); });
setStackAudit(data); setStackAudit(data);
} catch (exc) { } catch (exc) {
@@ -273,7 +609,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
} finally { } finally {
setAuditLoading(false); setAuditLoading(false);
} }
}, []); }, [stackDiscoveryPayload]);
const handleSubmitRun = useCallback(async () => { const handleSubmitRun = useCallback(async () => {
if (!selectedStackId || readOnly) return; if (!selectedStackId || readOnly) return;
@@ -282,13 +618,12 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
try { try {
const candidate = stackCandidates.find(item => item.stack_id === selectedStackId); const candidate = stackCandidates.find(item => item.stack_id === selectedStackId);
const data = await submitSbasInsarRun(selectedStackId, { const data = await submitSbasInsarRun(selectedStackId, {
...stackDiscoveryPayload,
run_label: candidate 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, : undefined,
min_scenes: 3,
require_orbits: true,
dry_run: false, dry_run: false,
monitor_point_strategy: 'auto_low_sigma_high_rate', monitor_point_strategy: 'auto_representative_points',
}); });
const runId = data?.run?.run_id; const runId = data?.run?.run_id;
const runData = await listSbasInsarRuns(); const runData = await listSbasInsarRuns();
@@ -303,7 +638,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
} finally { } finally {
setSubmitLoading(false); setSubmitLoading(false);
} }
}, [readOnly, selectedStackId, stackCandidates]); }, [readOnly, selectedStackId, stackCandidates, stackDiscoveryPayload]);
const workflowPayload = useMemo(() => ({ const workflowPayload = useMemo(() => ({
force: false, force: false,
@@ -586,6 +921,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
const iptaTimeseriesPlan = runManifest.ipta_timeseries || null; const iptaTimeseriesPlan = runManifest.ipta_timeseries || null;
const publishProductsPlan = runManifest.publish_products || null; const publishProductsPlan = runManifest.publish_products || null;
const monitorProductsPlan = runManifest.monitor_point_products || null; const monitorProductsPlan = runManifest.monitor_point_products || null;
const runGeographicCoverage = runDetail?.geographic_coverage || null;
const runPrimaryPreview = ( 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_m_per_year_hls_geo_preview_png')
|| runArtifacts.find(item => item.key === 'los_rate_toward_mm_per_year_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}> <section style={sectionStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start' }}> <div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start' }}>
<div> <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 }}> <div style={{ ...mutedStyle, marginTop: 5 }}>
直接扫描本地 LT1 数据池按平台相对轨道升降轨模式极化接收站和中心桶硬分组并检查精轨 TXT 按生产行政区查找覆盖同一目标区域的 LT1 时序候选并检查日期密度精轨和公共重叠范围
</div> </div>
</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 <button
type="button" type="button"
onClick={handleDiscoverStacks} onClick={handleDiscoverStacks}
@@ -683,7 +1039,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
> >
{discovering ? '发现中' : '发现序列'} {discovering ? '查找中' : '查找候选'}
</button> </button>
<button <button
type="button" type="button"
@@ -742,7 +1098,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
> >
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}> <div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
<strong style={{ color: '#0f172a', fontSize: 13 }}> <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> </strong>
<StatusBadge value={item.status} /> <StatusBadge value={item.status} />
</div> </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.date_start} {item.date_end}可用 {item.usable_scene_count}/{item.scene_count}
缺精轨 {item.missing_orbit_count}最大间隔 {item.max_temporal_gap_days} 缺精轨 {item.missing_orbit_count}最大间隔 {item.max_temporal_gap_days}
</div> </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> </button>
); );
})} })}
</div> </div>
<div style={{ display: 'grid', gap: 10 }}> <div style={{ display: 'grid', gap: 10 }}>
{selectedStack && ( {selectedStack && (
<>
<div style={metricGridStyle}> <div style={metricGridStyle}>
<Metric label="平台/模式" value={`${selectedStack.satellite || '-'} / ${selectedStack.imaging_mode || '-'}`} /> <Metric label="平台/模式" value={`${selectedStack.satellite || '-'} / ${selectedStack.imaging_mode || '-'}`} />
<Metric label="轨道方向" value={selectedStack.orbit_direction || '-'} /> <Metric label="轨道方向" value={selectedStack.orbit_direction || '-'} />
<Metric label="极化/接收站" value={`${selectedStack.polarization || '-'} / ${selectedStack.receiving_station || '-'}`} /> <Metric label="极化/接收站" value={`${selectedStack.polarization || '-'} / ${selectedStack.receiving_station || '-'}`} />
<Metric label="建议参考日期" value={selectedStack.reference_date || '-'} /> <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> </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 && ( {stackAudit && (
<div style={{ border: '1px solid #dbeafe', borderRadius: 8, padding: 10, background: '#eff6ff' }}> <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 }}> <div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
<strong style={{ color: '#0f172a', fontSize: 13 }}> <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> </strong>
<StatusBadge value={item.status} /> <StatusBadge value={item.status} />
</div> </div>
@@ -836,6 +1211,11 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
</button> </button>
); );
})} })}
{runs.length > 0 && (
<div style={{ ...mutedStyle, padding: '2px 0 6px' }}>
当前 Run 列表已补充中心点行政区筛选入口优先放在候选序列发现阶段
</div>
)}
{!loading && runs.length === 0 && ( {!loading && runs.length === 0 && (
<div style={{ ...mutedStyle, padding: '10px 0' }}> <div style={{ ...mutedStyle, padding: '10px 0' }}>
暂无计划 Run先发现序列再创建计划 Run 暂无计划 Run先发现序列再创建计划 Run
@@ -854,6 +1234,8 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
<Metric label="下一阶段" value={run.next_stage || '-'} /> <Metric label="下一阶段" value={run.next_stage || '-'} />
</div> </div>
<LocationSummaryPanel coverage={runGeographicCoverage} />
{!readOnly && ( {!readOnly && (
<div style={{ border: '1px solid #bbf7d0', borderRadius: 8, padding: 10, background: '#f0fdf4' }}> <div style={{ border: '1px solid #bbf7d0', borderRadius: 8, padding: 10, background: '#f0fdf4' }}>
<div style={valueStyle}>Gamma SBAS Workflow</div> <div style={valueStyle}>Gamma SBAS Workflow</div>
+556
View File
@@ -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 }}>
仅提供下载不在前端渲染用于 QGISArcGISPython 或精细制图
</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>
);
}
+19
View File
@@ -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)}`;
+6 -1
View File
@@ -70,6 +70,11 @@ export const PRODUCTION_WORKSPACE_VIEWS = [
label: 'SBAS-InSAR Production', label: 'SBAS-InSAR Production',
description: 'Gamma IPTA SBAS stack production, velocity maps, quality metrics, and monitor-point curves', 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', key: 'dinsar_products',
label: 'D-InSAR 产物', label: 'D-InSAR 产物',
@@ -82,7 +87,7 @@ export const PRODUCTION_WORKSPACE_ENTRY_TO_VIEW = Object.freeze({
dinsar_production: 'dinsar_runs', dinsar_production: 'dinsar_runs',
dinsar_products: 'dinsar_products', dinsar_products: 'dinsar_products',
ps_production: 'sbas_insar_production', ps_production: 'sbas_insar_production',
ps_products: 'sbas_insar_production', ps_products: 'sbas_insar_products',
}); });
export const PRODUCTION_WORKSPACE_ROUTE_TABS = new Set([ export const PRODUCTION_WORKSPACE_ROUTE_TABS = new Set([