From 0b7a875ba83ac9c47972d6ce5ed9dc68ede850e5 Mon Sep 17 00:00:00 2001 From: Harmon Date: Thu, 28 May 2026 22:06:08 +0800 Subject: [PATCH] feat: add SBAS AOI discovery and result management --- backend/app/main.py | 28 +- backend/app/routers/__init__.py | 2 + backend/app/routers/sbas_insar_production.py | 45 +- backend/app/routers/sbas_insar_products.py | 139 +++ .../services/admin_region_lookup_service.py | 392 ++++++++ backend/app/services/health_service.py | 16 + backend/app/services/job_handlers.py | 27 + .../services/sbas_insar_catalog_service.py | 912 ++++++++++++++++++ .../services/sbas_insar_production_service.py | 632 +++++++++++- .../wsl/runners/gamma_sbas_product_tools.py | 344 ++++++- ...OJSON_RESULT_MANAGEMENT_DESIGN_20260527.md | 146 ++- ...ECTOR_AND_MONITOR_CURVE_DESIGN_20260528.md | 137 +++ ...BAS_STACK_DISCOVERY_AOI_DESIGN_20260528.md | 311 ++++++ frontend/src/ProductionWorkspace.jsx | 11 + frontend/src/SbasInsarProductionPanel.jsx | 438 ++++++++- frontend/src/SbasInsarProductsPanel.jsx | 556 +++++++++++ frontend/src/api/sbasInsarProducts.js | 19 + frontend/src/config/appConstants.js | 7 +- 18 files changed, 4100 insertions(+), 62 deletions(-) create mode 100644 backend/app/routers/sbas_insar_products.py create mode 100644 backend/app/services/admin_region_lookup_service.py create mode 100644 backend/app/services/sbas_insar_catalog_service.py create mode 100644 docs/SBAS_INSAR_POINT_VECTOR_AND_MONITOR_CURVE_DESIGN_20260528.md create mode 100644 docs/SBAS_STACK_DISCOVERY_AOI_DESIGN_20260528.md create mode 100644 frontend/src/SbasInsarProductsPanel.jsx create mode 100644 frontend/src/api/sbasInsarProducts.js diff --git a/backend/app/main.py b/backend/app/main.py index 2289f05..841954a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -19,6 +19,7 @@ from .services.pairing_state_service import pairing_state_service from .services.psinsar_catalog_service import psinsar_catalog_service from .services.result_catalog_service import result_catalog_service from .services.root_registry_service import root_registry_service +from .services.sbas_insar_catalog_service import sbas_insar_catalog_service @asynccontextmanager @@ -83,6 +84,18 @@ async def lifespan(app: FastAPI): "queued": False, "error": str(exc), } + try: + sbas_catalog_bootstrap = await sbas_insar_catalog_service.bootstrap_catalog_on_startup_clean() + except Exception as exc: + sbas_catalog_bootstrap = { + "storage_root": settings.GAMMA_SBAS_WORK_ROOT, + "manifest_count": 0, + "db_count": 0, + "needs_rebuild": False, + "rebuilt": False, + "queued": False, + "error": str(exc), + } try: pairing_bootstrap = await pairing_state_service.bootstrap_pairing_cache_state() except Exception as exc: @@ -178,6 +191,17 @@ async def lifespan(app: FastAPI): ) if ps_catalog_bootstrap.get("error"): print(f">>> [Timeseries Catalog] Startup bootstrap failed: {ps_catalog_bootstrap['error']}") + print( + ">>> [Gamma SBAS Catalog] root={0} runs={1} db={2} rebuild={3} rebuilt={4}".format( + sbas_catalog_bootstrap.get("storage_root") or "?", + sbas_catalog_bootstrap.get("manifest_count", 0), + sbas_catalog_bootstrap.get("db_count", 0), + "YES" if sbas_catalog_bootstrap.get("needs_rebuild") else "NO", + "YES" if sbas_catalog_bootstrap.get("rebuilt") else "NO", + ) + ) + if sbas_catalog_bootstrap.get("error"): + print(f">>> [Gamma SBAS Catalog] Startup bootstrap failed: {sbas_catalog_bootstrap['error']}") print( ">>> [Pairing] status={0} scenes={1} pairs={2} dirty={3} metric={4} rebuild={5}".format( pairing_bootstrap.get("status") or "?", @@ -214,17 +238,19 @@ async def lifespan(app: FastAPI): health.get("timeseries_result_catalog", {}) or health.get("psinsar_result_catalog", {}) ).get("ok") + sbas_catalog_ok = health.get("sbas_insar_result_catalog", {}).get("ok") pairing_ok = health.get("pairing_system", {}).get("ok") idl_ok = health.get("idl", {}).get("ok") product_packages_ok = health.get("product_packages", {}).get("ok") wsl_runtime_ok = health.get("wsl_runtime", {}).get("ok") print( - ">>> [Health] DB:{0} Schema:{1} Worker:{2} DInSAR-Catalog:{3} Timeseries-Catalog:{4} Packages:{5} WSL:{6} Pairing:{7} IDL:{8}".format( + ">>> [Health] DB:{0} Schema:{1} Worker:{2} DInSAR-Catalog:{3} Timeseries-Catalog:{4} SBAS-Catalog:{5} Packages:{6} WSL:{7} Pairing:{8} IDL:{9}".format( "OK" if db_ok else "FAIL", "OK" if schema_ok else "FAIL", "OK" if worker_ok else "FAIL", "OK" if dinsar_catalog_ok else "FAIL", "OK" if psinsar_catalog_ok else "FAIL", + "OK" if sbas_catalog_ok else "FAIL", "OK" if product_packages_ok else "FAIL", "OK" if wsl_runtime_ok else "FAIL", "OK" if pairing_ok else "FAIL", diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py index a94db0f..11ce453 100644 --- a/backend/app/routers/__init__.py +++ b/backend/app/routers/__init__.py @@ -20,6 +20,7 @@ from . import ( orbit, pairing, ps_products, + sbas_insar_products, radar, root_registry, sbas_insar_production, @@ -55,6 +56,7 @@ def include_all_routers(router: APIRouter) -> None: router.include_router(dinsar_products.router) router.include_router(dinsar_production.router) router.include_router(sbas_insar_production.router) + router.include_router(sbas_insar_products.router) router.include_router(timeseries_production.router) router.include_router(ps_products.router) router.include_router(ai.router) diff --git a/backend/app/routers/sbas_insar_production.py b/backend/app/routers/sbas_insar_production.py index 86f14e0..43f74c5 100644 --- a/backend/app/routers/sbas_insar_production.py +++ b/backend/app/routers/sbas_insar_production.py @@ -6,7 +6,7 @@ import subprocess from fastapi import APIRouter, HTTPException from fastapi.responses import FileResponse -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator from ..services.job_queue_service import job_queue_service from ..services.sbas_insar_production_service import sbas_insar_production_service @@ -16,6 +16,19 @@ from ..services.task_service import task_service router = APIRouter(prefix="/sbas-insar-production", tags=["sbas-insar-production"]) +class SbasAoiBbox(BaseModel): + min_lon: float = Field(ge=-180, le=180) + min_lat: float = Field(ge=-90, le=90) + max_lon: float = Field(ge=-180, le=180) + max_lat: float = Field(ge=-90, le=90) + + @model_validator(mode="after") + def _validate_order(self): + if self.min_lon >= self.max_lon or self.min_lat >= self.max_lat: + raise ValueError("aoi_bbox min values must be smaller than max values") + return self + + class SbasStackDiscoverRequest(BaseModel): source_roots: list[str] | None = None orbit_roots: list[str] | None = None @@ -26,6 +39,11 @@ class SbasStackDiscoverRequest(BaseModel): platform: str | None = Field(default=None, max_length=16) relative_orbit: str | None = Field(default=None, max_length=32) orbit_direction: str | None = Field(default=None, max_length=32) + admin_region: str | None = Field(default=None, max_length=120) + discovery_mode: str = Field(default="strict", pattern="^(strict|aoi)$") + aoi_bbox: SbasAoiBbox | None = None + min_aoi_coverage_ratio: float = Field(default=0.01, ge=0, le=1) + min_common_overlap_ratio: float = Field(default=0.0, ge=0, le=1) @field_validator("source_roots", "orbit_roots", mode="before") @classmethod @@ -39,7 +57,7 @@ class SbasStackDiscoverRequest(BaseModel): cleaned = [str(item or "").strip() for item in items if str(item or "").strip()] return cleaned or None - @field_validator("platform", "relative_orbit", "orbit_direction", mode="before") + @field_validator("platform", "relative_orbit", "orbit_direction", "admin_region", mode="before") @classmethod def _normalize_optional_text(cls, value): if value is None: @@ -47,6 +65,12 @@ class SbasStackDiscoverRequest(BaseModel): text = str(value).strip() return text or None + @field_validator("discovery_mode", mode="before") + @classmethod + def _normalize_discovery_mode(cls, value): + text = str(value or "strict").strip().lower() + return text or "strict" + class SbasMonitorPoint(BaseModel): point_id: str | None = Field(default=None, max_length=64) @@ -58,7 +82,7 @@ class SbasMonitorPoint(BaseModel): class SbasRunSubmitRequest(SbasStackDiscoverRequest): run_label: str | None = Field(default=None, max_length=120) dry_run: bool = True - monitor_point_strategy: str = Field(default="auto_low_sigma_high_rate", max_length=64) + monitor_point_strategy: str = Field(default="auto_representative_points", max_length=64) monitor_points: list[SbasMonitorPoint] | None = None @@ -160,6 +184,11 @@ async def discover_sbas_insar_stacks(request: SbasStackDiscoverRequest): platform=request.platform, relative_orbit=request.relative_orbit, orbit_direction=request.orbit_direction, + admin_region=request.admin_region, + discovery_mode=request.discovery_mode, + aoi_bbox=request.aoi_bbox.model_dump() if request.aoi_bbox else None, + min_aoi_coverage_ratio=request.min_aoi_coverage_ratio, + min_common_overlap_ratio=request.min_common_overlap_ratio, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -175,6 +204,11 @@ async def audit_sbas_insar_stack(stack_id: str, request: SbasStackDiscoverReques orbit_roots=request.orbit_roots, min_scenes=request.min_scenes, require_orbits=request.require_orbits, + discovery_mode=request.discovery_mode, + admin_region=request.admin_region, + aoi_bbox=request.aoi_bbox.model_dump() if request.aoi_bbox else None, + min_aoi_coverage_ratio=request.min_aoi_coverage_ratio, + min_common_overlap_ratio=request.min_common_overlap_ratio, ) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @@ -193,6 +227,11 @@ async def submit_sbas_insar_run(stack_id: str, request: SbasRunSubmitRequest): orbit_roots=request.orbit_roots, min_scenes=request.min_scenes, require_orbits=request.require_orbits, + discovery_mode=request.discovery_mode, + admin_region=request.admin_region, + aoi_bbox=request.aoi_bbox.model_dump() if request.aoi_bbox else None, + min_aoi_coverage_ratio=request.min_aoi_coverage_ratio, + min_common_overlap_ratio=request.min_common_overlap_ratio, monitor_points=[ point.model_dump(exclude_none=True) for point in (request.monitor_points or []) diff --git a/backend/app/routers/sbas_insar_products.py b/backend/app/routers/sbas_insar_products.py new file mode 100644 index 0000000..e28ee65 --- /dev/null +++ b/backend/app/routers/sbas_insar_products.py @@ -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), + ) diff --git a/backend/app/services/admin_region_lookup_service.py b/backend/app/services/admin_region_lookup_service.py new file mode 100644 index 0000000..eba1f5f --- /dev/null +++ b/backend/app/services/admin_region_lookup_service.py @@ -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() diff --git a/backend/app/services/health_service.py b/backend/app/services/health_service.py index 6e793ca..c8455ec 100644 --- a/backend/app/services/health_service.py +++ b/backend/app/services/health_service.py @@ -298,6 +298,14 @@ async def _check_timeseries_result_catalog() -> Dict[str, Any]: ) +async def _check_sbas_insar_result_catalog() -> Dict[str, Any]: + return await _check_catalog( + catalog_name="sbas_insar", + storage_root=os.path.join(settings.GAMMA_SBAS_WORK_ROOT, "runs"), + enabled=bool(settings.GAMMA_SBAS_ENABLED), + ) + + async def _check_psinsar_result_catalog() -> Dict[str, Any]: return await _check_timeseries_result_catalog() @@ -472,6 +480,7 @@ def _sanitize_health_status(payload: Dict[str, Any]) -> Dict[str, Any]: payload.get("timeseries_result_catalog", {}) or payload.get("psinsar_result_catalog", {}) or {} ) psinsar_result_catalog = timeseries_result_catalog + sbas_insar_result_catalog = payload.get("sbas_insar_result_catalog", {}) or {} dinsar_bridge = payload.get("dinsar_bridge", {}) or {} source_roots = payload.get("source_roots", {}) or {} sar_analysis_ready = payload.get("sar_analysis_ready", {}) or {} @@ -486,6 +495,7 @@ def _sanitize_health_status(payload: Dict[str, Any]) -> Dict[str, Any]: sanitized_dinsar_catalog = _sanitize_catalog_status(dinsar_result_catalog) sanitized_timeseries_catalog = _sanitize_catalog_status(timeseries_result_catalog) sanitized_psinsar_catalog = sanitized_timeseries_catalog + sanitized_sbas_insar_catalog = _sanitize_catalog_status(sbas_insar_result_catalog) sanitized_dinsar_bridge = _sanitize_bridge_status(dinsar_bridge) sanitized_source_roots = _sanitize_source_roots_status(source_roots) sanitized_sar_analysis_ready = _sanitize_sar_analysis_ready_status(sar_analysis_ready) @@ -511,10 +521,12 @@ def _sanitize_health_status(payload: Dict[str, Any]) -> Dict[str, Any]: "dinsar_result_catalog": sanitized_dinsar_catalog, "timeseries_result_catalog": sanitized_timeseries_catalog, "psinsar_result_catalog": sanitized_psinsar_catalog, + "sbas_insar_result_catalog": sanitized_sbas_insar_catalog, "catalogs": { "dinsar": sanitized_dinsar_catalog, "timeseries": sanitized_timeseries_catalog, "psinsar": sanitized_psinsar_catalog, + "sbas_insar": sanitized_sbas_insar_catalog, }, "dinsar_bridge": sanitized_dinsar_bridge, "source_roots": sanitized_source_roots, @@ -1340,6 +1352,7 @@ async def get_health_status( result_catalog_status = await _check_result_catalog() timeseries_result_catalog_status = await _check_timeseries_result_catalog() psinsar_result_catalog_status = timeseries_result_catalog_status + sbas_insar_result_catalog_status = await _check_sbas_insar_result_catalog() dinsar_bridge_status = await _check_dinsar_bridge() source_roots_status = await _check_source_roots() sar_analysis_ready_status = await _check_sar_analysis_ready() @@ -1366,6 +1379,7 @@ async def get_health_status( wsl_runtime_status.get("ok"), pairing_system_status.get("ok"), (not settings.TIMESERIES_ENABLED) or timeseries_result_catalog_status.get("ok"), + (not settings.GAMMA_SBAS_ENABLED) or sbas_insar_result_catalog_status.get("ok"), ] ) @@ -1378,10 +1392,12 @@ async def get_health_status( "dinsar_result_catalog": result_catalog_status, "timeseries_result_catalog": timeseries_result_catalog_status, "psinsar_result_catalog": psinsar_result_catalog_status, + "sbas_insar_result_catalog": sbas_insar_result_catalog_status, "catalogs": { "dinsar": result_catalog_status, "timeseries": timeseries_result_catalog_status, "psinsar": psinsar_result_catalog_status, + "sbas_insar": sbas_insar_result_catalog_status, }, "dinsar_bridge": dinsar_bridge_status, "source_roots": source_roots_status, diff --git a/backend/app/services/job_handlers.py b/backend/app/services/job_handlers.py index 697454c..6678c0b 100644 --- a/backend/app/services/job_handlers.py +++ b/backend/app/services/job_handlers.py @@ -37,6 +37,7 @@ from .engine_lock_service import engine_lock_service from .envi_service import build_envi_runner_command, get_envi_runner_cwd, get_envi_runner_env from .psinsar_catalog_service import psinsar_catalog_service from .result_catalog_service import result_catalog_service +from .sbas_insar_catalog_service import sbas_insar_catalog_service from .task_service import task_service from .timeseries_service import ( JOB_TYPE_TIMESERIES_MATERIALIZE, @@ -90,6 +91,7 @@ JOB_TYPE_PYINT_RUN = "PYINT_RUN" JOB_TYPE_PUBLISH_DINSAR_PRODUCTS = "PUBLISH_DINSAR_PRODUCTS" JOB_TYPE_REBUILD_DINSAR_CATALOG = "REBUILD_DINSAR_CATALOG" JOB_TYPE_REBUILD_PSINSAR_CATALOG = "REBUILD_PSINSAR_CATALOG" +JOB_TYPE_REBUILD_SBAS_INSAR_CATALOG = "REBUILD_SBAS_INSAR_CATALOG" JOB_TYPE_SCAN_ASSET_INVENTORY = "SCAN_ASSET_INVENTORY" JOB_TYPE_SBAS_COREGISTRATION = "SBAS_COREGISTRATION" JOB_TYPE_SBAS_RDC_DEM = "SBAS_RDC_DEM" @@ -4188,6 +4190,30 @@ async def _handle_rebuild_psinsar_catalog(job: SystemJobORM) -> None: ) +async def _handle_rebuild_sbas_insar_catalog(job: SystemJobORM) -> None: + if not job.task_id: + raise ValueError("REBUILD_SBAS_INSAR_CATALOG requires task_id for progress tracking.") + payload = job.payload or {} + full_rebuild = bool(payload.get("full_rebuild", True)) + + await task_service.start_task(job.task_id, message="Rebuilding SBAS-InSAR result catalog...") + async with AsyncSessionLocal() as db: + result = await sbas_insar_catalog_service.rebuild_catalog( + db, + full_rebuild=full_rebuild, + ) + await task_service.update_task( + job.task_id, + status="COMPLETED", + progress=100, + message=( + f"SBAS-InSAR result catalog rebuilt: runs={result.get('run_count', 0)}, " + f"registered={result.get('registered', 0)}, failed={result.get('failed', 0)}, " + f"issues={result.get('issue_count', 0)}" + ), + ) + + async def _handle_sbas_coregistration(job: SystemJobORM) -> None: if not job.task_id: raise ValueError("SBAS_COREGISTRATION requires task_id for progress tracking.") @@ -4623,6 +4649,7 @@ _HANDLERS = { JOB_TYPE_TIMESERIES_EXPORT_PUBLISH: _handle_timeseries_export_publish, JOB_TYPE_TIMESERIES_REGISTER_PRODUCT: _handle_timeseries_register_product, JOB_TYPE_REBUILD_PSINSAR_CATALOG: _handle_rebuild_psinsar_catalog, + JOB_TYPE_REBUILD_SBAS_INSAR_CATALOG: _handle_rebuild_sbas_insar_catalog, JOB_TYPE_COPY_DATA: _handle_copy_data, JOB_TYPE_UNPACK: _handle_unpack_archives, JOB_TYPE_UNPACK_SENTINEL1: _handle_unpack_sentinel1, diff --git a/backend/app/services/sbas_insar_catalog_service.py b/backend/app/services/sbas_insar_catalog_service.py new file mode 100644 index 0000000..b667f85 --- /dev/null +++ b/backend/app/services/sbas_insar_catalog_service.py @@ -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() diff --git a/backend/app/services/sbas_insar_production_service.py b/backend/app/services/sbas_insar_production_service.py index 63b9617..6b1913d 100644 --- a/backend/app/services/sbas_insar_production_service.py +++ b/backend/app/services/sbas_insar_production_service.py @@ -13,7 +13,14 @@ from pathlib import Path from typing import Any from xml.etree import ElementTree as ET +from shapely.geometry import box as shapely_box + from ..config import settings +from .admin_region_lookup_service import ( + admin_region_matches, + lookup_admin_region_for_point, + lookup_admin_region_geometry, +) PRODUCT_DEFINITIONS = ( @@ -745,7 +752,7 @@ class SbasInsarProductionService: "default_strategy": "gamma_geocode_back_data2geotiff_los_sign_conversion", "geocoded_preview_source": "EPSG:4326 GeoTIFF", }, - "monitor_point_modes": ["auto_low_sigma_high_rate", "manual_lonlat"], + "monitor_point_modes": ["auto_representative_points", "auto_low_sigma_high_rate", "manual_lonlat"], "default_los_convention": { "key": "los_rate_toward_mm_per_year", "description": "toward radar positive; away from radar negative", @@ -778,10 +785,20 @@ class SbasInsarProductionService: platform: str | None = None, relative_orbit: str | None = None, orbit_direction: str | None = None, + admin_region: str | None = None, + discovery_mode: str = "strict", + aoi_bbox: dict[str, Any] | None = None, + min_aoi_coverage_ratio: float = 0.01, + min_common_overlap_ratio: float = 0.0, force_refresh: bool = False, ) -> dict[str, Any]: source_paths = self._resolve_source_roots(source_roots) orbit_paths = self._resolve_orbit_roots(orbit_roots) + normalized_mode = self._normalize_discovery_mode(discovery_mode) + min_aoi_coverage_ratio = max(0.0, min(1.0, float(min_aoi_coverage_ratio or 0.0))) + min_common_overlap_ratio = max(0.0, min(1.0, float(min_common_overlap_ratio or 0.0))) + discovery_aoi = self._build_discovery_aoi(admin_region=admin_region, aoi_bbox=aoi_bbox) + effective_mode = "aoi" if normalized_mode == "aoi" and discovery_aoi.get("geometry") is not None else "strict" cache_key = self._discovery_cache_key( source_paths=source_paths, orbit_paths=orbit_paths, @@ -792,6 +809,11 @@ class SbasInsarProductionService: platform=platform, relative_orbit=relative_orbit, orbit_direction=orbit_direction, + admin_region=admin_region, + discovery_mode=effective_mode, + aoi_bbox=aoi_bbox, + min_aoi_coverage_ratio=min_aoi_coverage_ratio, + min_common_overlap_ratio=min_common_overlap_ratio, ) if not force_refresh: cached = self._read_discovery_cache(cache_key) @@ -804,6 +826,7 @@ class SbasInsarProductionService: platform_filter = str(platform or "").strip().upper() rel_filter = str(relative_orbit or "").strip() direction_filter = str(orbit_direction or "").strip().upper() + aoi_geometry = discovery_aoi.get("geometry") if effective_mode == "aoi" else None for root in source_paths: try: @@ -819,18 +842,54 @@ class SbasInsarProductionService: continue if direction_filter and str(scene.get("orbit_direction") or "").upper() != direction_filter: continue + if aoi_geometry is not None: + scene = self._scene_with_aoi_metrics(scene, aoi_geometry) + if not scene.get("aoi_intersects"): + continue + if float(scene.get("aoi_overlap_ratio") or 0.0) < min_aoi_coverage_ratio: + continue scenes.append(scene) except Exception as exc: errors.append({"source_root": str(root), "error": str(exc)}) - grouped: dict[str, list[dict[str, Any]]] = {} + grouped_initial: dict[str, list[dict[str, Any]]] = {} for scene in scenes: - grouped.setdefault(self._stack_group_key(scene), []).append(scene) + group_key = self._aoi_stack_group_key(scene) if effective_mode == "aoi" else self._stack_group_key(scene) + grouped_initial.setdefault(group_key, []).append(scene) + + if effective_mode == "aoi": + grouped: dict[str, list[dict[str, Any]]] = {} + for observation_key, group_scenes in grouped_initial.items(): + for cluster in self._cluster_aoi_scenes(group_scenes): + cluster_key = self._aoi_cluster_key(observation_key, cluster) + clustered_scenes = [ + { + **scene, + "aoi_cluster_key": cluster_key, + "aoi_cluster_source": "footprint_common_overlap", + } + for scene in cluster + ] + grouped[cluster_key] = clustered_scenes + else: + grouped = grouped_initial candidates = [ - self._build_stack_candidate(group_scenes, min_scenes=min_scenes, require_orbits=require_orbits) + self._build_stack_candidate( + group_scenes, + min_scenes=min_scenes, + require_orbits=require_orbits, + discovery_mode=effective_mode, + aoi_summary=discovery_aoi.get("summary"), + min_common_overlap_ratio=min_common_overlap_ratio, + ) for group_scenes in grouped.values() ] + if admin_region and effective_mode != "aoi": + candidates = [ + candidate for candidate in candidates + if admin_region_matches(candidate.get("admin_region"), admin_region) + ] candidates.sort( key=lambda item: ( int(item.get("status") != "READY"), @@ -852,6 +911,11 @@ class SbasInsarProductionService: "orbit_roots": [str(path) for path in orbit_paths], "min_scenes": min_scenes, "require_orbits": require_orbits, + "discovery_mode": effective_mode, + "requested_discovery_mode": normalized_mode, + "aoi": discovery_aoi.get("summary"), + "min_aoi_coverage_ratio": min_aoi_coverage_ratio, + "min_common_overlap_ratio": min_common_overlap_ratio, "scene_count": len(scenes), "candidate_count": len(candidates), "errors": errors[:50], @@ -874,6 +938,11 @@ class SbasInsarProductionService: orbit_roots: list[str] | None = None, min_scenes: int = 3, require_orbits: bool = True, + discovery_mode: str = "strict", + admin_region: str | None = None, + aoi_bbox: dict[str, Any] | None = None, + min_aoi_coverage_ratio: float = 0.01, + min_common_overlap_ratio: float = 0.0, ) -> dict[str, Any]: discovery = self.discover_stacks( source_roots=source_roots, @@ -882,6 +951,11 @@ class SbasInsarProductionService: require_orbits=require_orbits, include_scenes=True, limit=0, + discovery_mode=discovery_mode, + admin_region=admin_region, + aoi_bbox=aoi_bbox, + min_aoi_coverage_ratio=min_aoi_coverage_ratio, + min_common_overlap_ratio=min_common_overlap_ratio, ) candidate = next( (item for item in discovery.get("items", []) if item.get("stack_id") == stack_id), @@ -927,6 +1001,9 @@ class SbasInsarProductionService: "status": "READY_FOR_GAMMA_BASELINE_AUDIT" if not blockers else "BLOCKED", "require_orbits": require_orbits, "min_scenes": min_scenes, + "discovery_mode": candidate.get("discovery_mode") or discovery.get("discovery_mode") or "strict", + "aoi": candidate.get("aoi") or discovery.get("aoi"), + "common_overlap_ratio": candidate.get("common_overlap_ratio"), "stack": { key: candidate.get(key) for key in [ @@ -941,6 +1018,7 @@ class SbasInsarProductionService: "reference_date", ] }, + "geographic_coverage": self._build_stack_geographic_coverage({"scenes": usable_scenes}), "scenes": usable_scenes, "excluded_scenes": [ scene for scene in candidate.get("scenes", []) @@ -983,7 +1061,12 @@ class SbasInsarProductionService: min_scenes: int = 3, require_orbits: bool = True, monitor_points: list[dict[str, Any]] | None = None, - monitor_point_strategy: str = "auto_low_sigma_high_rate", + monitor_point_strategy: str = "auto_representative_points", + discovery_mode: str = "strict", + admin_region: str | None = None, + aoi_bbox: dict[str, Any] | None = None, + min_aoi_coverage_ratio: float = 0.01, + min_common_overlap_ratio: float = 0.0, dry_run: bool = True, ) -> dict[str, Any]: audit = self.audit_stack( @@ -992,6 +1075,11 @@ class SbasInsarProductionService: orbit_roots=orbit_roots, min_scenes=min_scenes, require_orbits=require_orbits, + discovery_mode=discovery_mode, + admin_region=admin_region, + aoi_bbox=aoi_bbox, + min_aoi_coverage_ratio=min_aoi_coverage_ratio, + min_common_overlap_ratio=min_common_overlap_ratio, ) manifest = audit["manifest"] if manifest.get("status") != "READY_FOR_GAMMA_BASELINE_AUDIT": @@ -1023,6 +1111,9 @@ class SbasInsarProductionService: "status": "WORKFLOW_READY", "created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z", "stack_id": stack_id, + "discovery_mode": manifest.get("discovery_mode"), + "aoi": manifest.get("aoi"), + "common_overlap_ratio": manifest.get("common_overlap_ratio"), "stack_manifest_path": audit["manifest_path"], "pair_network_path": audit["pair_network_path"], "workflow_manifest_path": str(run_dir / "manifest.json"), @@ -1106,6 +1197,7 @@ class SbasInsarProductionService: } workflow_state = self._read_optional_json(run_dir / "state" / "step_status.json") monitor_points = self._read_optional_json(run_dir / "monitor_points.json") + geographic_coverage = self._build_run_geographic_coverage(run_dir, manifest) return { "run": self._build_run_card(run_dir, manifest), "manifest": manifest, @@ -1113,6 +1205,7 @@ class SbasInsarProductionService: "workflow_manifest": workflow_manifest, "workflow_state": workflow_state, "monitor_points": monitor_points, + "geographic_coverage": geographic_coverage, "artifacts": self._build_run_artifacts(run_dir), } @@ -2483,6 +2576,9 @@ class SbasInsarProductionService: }, "outputs": { "export_dir": str(export_dir), + "vector_dir": str(run_dir / "publish" / "vectors"), + "point_vector_geojson_gz": str(run_dir / "publish" / "vectors" / "los_rate_points.geojson.gz"), + "point_vector_summary": str(run_dir / "publish" / "vectors" / "los_rate_points_summary.json"), "product_summary": str(run_dir / "product_summary.json"), "quality_summary": str(run_dir / "quality_summary.json"), }, @@ -3614,6 +3710,11 @@ class SbasInsarProductionService: platform: str | None, relative_orbit: str | None, orbit_direction: str | None, + admin_region: str | None, + discovery_mode: str, + aoi_bbox: dict[str, Any] | None, + min_aoi_coverage_ratio: float, + min_common_overlap_ratio: float, ) -> str: payload = { "source_paths": [os.path.normcase(str(path.resolve())) for path in source_paths], @@ -3633,6 +3734,12 @@ class SbasInsarProductionService: "platform": str(platform or "").strip().upper(), "relative_orbit": str(relative_orbit or "").strip(), "orbit_direction": str(orbit_direction or "").strip().upper(), + "admin_region": str(admin_region or "").strip(), + "discovery_mode": str(discovery_mode or "strict").strip().lower(), + "aoi_bbox": SbasInsarProductionService._normalize_bbox(aoi_bbox), + "min_aoi_coverage_ratio": float(min_aoi_coverage_ratio), + "min_common_overlap_ratio": float(min_common_overlap_ratio), + "response_shape": "aoi_discovery_v1", } return hashlib.sha1(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()[:16] @@ -4433,6 +4540,249 @@ class SbasInsarProductionService: except (KeyError, TypeError, ValueError): return None + @staticmethod + def _normalize_bbox(value: Any) -> dict[str, float] | None: + if not isinstance(value, dict): + return None + try: + min_lon = float(value["min_lon"]) + min_lat = float(value["min_lat"]) + max_lon = float(value["max_lon"]) + max_lat = float(value["max_lat"]) + except (KeyError, TypeError, ValueError): + return None + if min_lon >= max_lon or min_lat >= max_lat: + return None + return { + "min_lon": min_lon, + "min_lat": min_lat, + "max_lon": max_lon, + "max_lat": max_lat, + } + + @classmethod + def _bbox_to_geojson_feature(cls, bbox: dict[str, Any] | None, *, properties: dict[str, Any] | None = None) -> dict[str, Any] | None: + normalized = cls._normalize_bbox(bbox) + if not normalized: + return None + min_lon = normalized["min_lon"] + min_lat = normalized["min_lat"] + max_lon = normalized["max_lon"] + max_lat = normalized["max_lat"] + return { + "type": "Feature", + "properties": properties or {}, + "geometry": { + "type": "Polygon", + "coordinates": [[ + [min_lon, min_lat], + [max_lon, min_lat], + [max_lon, max_lat], + [min_lon, max_lat], + [min_lon, min_lat], + ]], + }, + } + + @staticmethod + def _point_to_geojson_feature(point: dict[str, Any] | None, *, properties: dict[str, Any] | None = None) -> dict[str, Any] | None: + if not isinstance(point, dict): + return None + try: + lon = float(point["lon"]) + lat = float(point["lat"]) + except (KeyError, TypeError, ValueError): + return None + return { + "type": "Feature", + "properties": properties or {}, + "geometry": { + "type": "Point", + "coordinates": [lon, lat], + }, + } + + def _build_stack_geographic_coverage(self, stack_manifest: dict[str, Any]) -> dict[str, Any]: + scenes = stack_manifest.get("scenes") or [] + usable_scenes = [ + scene for scene in scenes + if isinstance(scene, dict) and isinstance(scene.get("bbox"), dict) + ] + bbox_union = self._stack_bbox_union(stack_manifest) + bbox_intersection = self._bbox_intersection([scene.get("bbox") for scene in usable_scenes]) + center = self._stack_center(stack_manifest) + union_feature = self._bbox_to_geojson_feature( + bbox_union, + properties={ + "role": "stack_bbox_union", + "source": "lt1_scene_metadata", + "scene_count": len(usable_scenes), + }, + ) + intersection_feature = self._bbox_to_geojson_feature( + bbox_intersection, + properties={ + "role": "stack_bbox_intersection", + "source": "lt1_scene_metadata", + "scene_count": len(usable_scenes), + }, + ) + center_feature = self._point_to_geojson_feature( + center, + properties={"role": "stack_center", "source": "scene_centers_or_bbox"}, + ) + scene_features: list[dict[str, Any]] = [] + for scene in usable_scenes: + feature = self._bbox_to_geojson_feature( + scene.get("bbox"), + properties={ + "role": "scene_bbox", + "scene_name": scene.get("scene_name"), + "date": scene.get("date"), + "satellite": scene.get("satellite"), + "relative_orbit": scene.get("relative_orbit"), + }, + ) + if feature: + scene_features.append(feature) + overview_features = [ + item for item in [union_feature, intersection_feature, center_feature] + if item + ] + return { + "schema": "insar.sbas-geographic-coverage/v1", + "crs": "EPSG:4326", + "source": "lt1_scene_metadata", + "bbox": bbox_union, + "bbox_intersection": bbox_intersection, + "center": center, + "admin_region": lookup_admin_region_for_point( + (center or {}).get("lon"), + (center or {}).get("lat"), + ), + "scene_bbox_count": len(scene_features), + "geojson": { + "type": "FeatureCollection", + "features": overview_features, + }, + "scene_footprints_geojson": { + "type": "FeatureCollection", + "features": scene_features, + }, + } + + def _build_run_geographic_coverage(self, run_dir: Path, run_manifest: dict[str, Any]) -> dict[str, Any]: + stack_manifest = self._read_optional_json(run_dir / "stack_manifest.json") + if not stack_manifest: + stack_manifest_path = Path(str(run_manifest.get("stack_manifest_path") or "")) + if stack_manifest_path.is_file(): + stack_manifest = self._read_optional_json(stack_manifest_path) + stack_manifest = stack_manifest or {} + coverage = self._build_stack_geographic_coverage(stack_manifest) + rdc_dem = run_manifest.get("rdc_dem") or {} + rdc_dem_summary = ( + (rdc_dem.get("summary") if isinstance(rdc_dem, dict) else None) + or self._read_optional_json(run_dir / "rdc_dem_summary.json") + or {} + ) + dem_source = rdc_dem_summary.get("dem_source") or (rdc_dem.get("dem_source") if isinstance(rdc_dem, dict) else None) or {} + dem_coverage = self._normalize_bbox(dem_source.get("coverage")) if isinstance(dem_source, dict) else None + monitor_summary = ( + (run_manifest.get("monitor_point_products") or {}).get("summary") + or self._read_optional_json(run_dir / "monitor_points_summary.json") + or {} + ) + monitor_points: list[dict[str, Any]] = [] + for item in monitor_summary.get("monitor_outputs") or []: + if not isinstance(item, dict): + continue + metadata = item.get("metadata") or {} + lonlat = metadata.get("approx_lonlat") or {} + try: + lon = float(lonlat["lon"]) + lat = float(lonlat["lat"]) + except (KeyError, TypeError, ValueError): + continue + monitor_points.append( + { + "point_id": item.get("point_id") or metadata.get("point_id"), + "lon": lon, + "lat": lat, + "selection": metadata.get("selection"), + "los_rate_toward_mm_per_year": metadata.get("los_rate_toward_mm_per_year"), + "los_sigma_mm_per_year": metadata.get("los_sigma_mm_per_year"), + "source": "monitor_points_summary", + } + ) + if not monitor_points: + for item in monitor_summary.get("monitor_points") or []: + if not isinstance(item, dict): + continue + try: + lon = float(item["lon"]) + lat = float(item["lat"]) + except (KeyError, TypeError, ValueError): + continue + monitor_points.append( + { + "point_id": item.get("point_id"), + "lon": lon, + "lat": lat, + "selection": item.get("selection"), + "los_rate_toward_mm_per_year": item.get("los_rate_toward_mm_per_year"), + "los_sigma_mm_per_year": item.get("los_sigma_mm_per_year"), + "source": "monitor_points_summary", + } + ) + monitor_features = [ + feature for feature in ( + self._point_to_geojson_feature( + {"lon": point["lon"], "lat": point["lat"]}, + properties={ + "role": "monitor_point", + "point_id": point.get("point_id"), + "selection": point.get("selection"), + "los_rate_toward_mm_per_year": point.get("los_rate_toward_mm_per_year"), + "los_sigma_mm_per_year": point.get("los_sigma_mm_per_year"), + }, + ) + for point in monitor_points + ) + if feature + ] + dem_feature = self._bbox_to_geojson_feature( + dem_coverage, + properties={ + "role": "dem_coverage", + "source": "rdc_dem_summary", + "covers_stack_bbox": dem_source.get("covers_stack_bbox"), + "covers_stack_center": dem_source.get("covers_stack_center"), + }, + ) + features = list((coverage.get("geojson") or {}).get("features") or []) + if dem_feature: + features.append(dem_feature) + features.extend(monitor_features) + coverage.update( + { + "source": "run_stack_manifest", + "run_id": run_manifest.get("run_id") or run_dir.name, + "stack_id": run_manifest.get("stack_id") or stack_manifest.get("stack_id"), + "stack": stack_manifest.get("stack") or run_manifest.get("stack") or {}, + "date_start": min(self._stack_dates(stack_manifest), default=None), + "date_end": max(self._stack_dates(stack_manifest), default=None), + "dem_coverage": dem_coverage, + "dem_covers_stack_bbox": dem_source.get("covers_stack_bbox"), + "dem_covers_stack_center": dem_source.get("covers_stack_center"), + "monitor_points": monitor_points, + "geojson": { + "type": "FeatureCollection", + "features": features, + }, + } + ) + return coverage + @staticmethod def _file_record(path: Path) -> dict[str, Any]: exists = path.is_file() @@ -4478,12 +4828,157 @@ class SbasInsarProductionService: ] return "|".join(str(part or "") for part in parts) + @staticmethod + def _aoi_stack_group_key(scene: dict[str, Any]) -> str: + parts = [ + scene.get("satellite"), + scene.get("satellite_mode"), + scene.get("relative_orbit"), + scene.get("orbit_direction"), + scene.get("imaging_mode"), + scene.get("polarization"), + ] + return "|".join(str(part or "") for part in parts) + + @staticmethod + def _normalize_discovery_mode(value: str | None) -> str: + text = str(value or "").strip().lower() + return "aoi" if text == "aoi" else "strict" + + def _build_discovery_aoi( + self, + *, + admin_region: str | None, + aoi_bbox: dict[str, Any] | None, + ) -> dict[str, Any]: + bbox = self._normalize_bbox(aoi_bbox) + if bbox: + geometry = shapely_box( + bbox["min_lon"], + bbox["min_lat"], + bbox["max_lon"], + bbox["max_lat"], + ) + return { + "geometry": geometry, + "summary": { + "match_status": "matched", + "source": "bbox", + "bbox": bbox, + "display_name": "Custom AOI bbox", + }, + } + + region = lookup_admin_region_geometry(admin_region) + if not region: + return {"geometry": None, "summary": None} + geometry = region.get("geometry") + summary = {key: value for key, value in region.items() if key != "geometry"} + if geometry is None or getattr(geometry, "is_empty", False): + return {"geometry": None, "summary": summary} + return {"geometry": geometry, "summary": summary} + + def _scene_with_aoi_metrics(self, scene: dict[str, Any], aoi_geometry: Any) -> dict[str, Any]: + bbox = self._normalize_bbox(scene.get("bbox")) + if not bbox: + return {**scene, "aoi_intersects": False, "aoi_overlap_ratio": 0.0} + scene_geometry = shapely_box( + bbox["min_lon"], + bbox["min_lat"], + bbox["max_lon"], + bbox["max_lat"], + ) + try: + intersects = bool(scene_geometry.intersects(aoi_geometry)) + except Exception: + return {**scene, "aoi_intersects": False, "aoi_overlap_ratio": 0.0} + if not intersects: + return {**scene, "aoi_intersects": False, "aoi_overlap_ratio": 0.0} + try: + intersection_area = float(scene_geometry.intersection(aoi_geometry).area or 0.0) + scene_area = float(scene_geometry.area or 0.0) + aoi_area = float(getattr(aoi_geometry, "area", 0.0) or 0.0) + except Exception: + intersection_area = 0.0 + scene_area = 0.0 + aoi_area = 0.0 + return { + **scene, + "aoi_intersects": True, + "aoi_overlap_ratio": intersection_area / scene_area if scene_area > 0 else 0.0, + "aoi_covered_ratio": intersection_area / aoi_area if aoi_area > 0 else None, + } + + @staticmethod + def _bbox_area(value: dict[str, Any] | None) -> float: + if not value: + return 0.0 + try: + width = float(value["max_lon"]) - float(value["min_lon"]) + height = float(value["max_lat"]) - float(value["min_lat"]) + except (KeyError, TypeError, ValueError): + return 0.0 + return width * height if width > 0 and height > 0 else 0.0 + + def _cluster_aoi_scenes(self, scenes: list[dict[str, Any]]) -> list[list[dict[str, Any]]]: + sorted_scenes = sorted( + scenes, + key=lambda item: ( + str(item.get("date") or ""), + float(item.get("center_lon") or 0.0), + float(item.get("center_lat") or 0.0), + ), + ) + clusters: list[dict[str, Any]] = [] + for scene in sorted_scenes: + scene_bbox = self._normalize_bbox(scene.get("bbox")) + if not scene_bbox: + continue + best_index: int | None = None + best_score = -1.0 + for index, cluster in enumerate(clusters): + candidate_intersection = self._bbox_intersection( + [cluster.get("bbox_intersection"), scene_bbox] + ) + if not candidate_intersection: + continue + score = self._bbox_area(candidate_intersection) + if score > best_score: + best_index = index + best_score = score + if best_index is None: + clusters.append({"bbox_intersection": scene_bbox, "scenes": [scene]}) + continue + cluster = clusters[best_index] + cluster["bbox_intersection"] = self._bbox_intersection( + [cluster.get("bbox_intersection"), scene_bbox] + ) + cluster["scenes"].append(scene) + + return [cluster["scenes"] for cluster in clusters if cluster.get("scenes")] + + def _aoi_cluster_key(self, observation_key: str, scenes: list[dict[str, Any]]) -> str: + bbox = self._bbox_intersection([scene.get("bbox") for scene in scenes]) + if bbox: + lon = (bbox["min_lon"] + bbox["max_lon"]) / 2 + lat = (bbox["min_lat"] + bbox["max_lat"]) / 2 + spatial_key = f"overlap_E{lon:.2f}_N{lat:.2f}" + else: + center = self._stack_center({"scenes": scenes}) or {} + lon = self._as_float(center.get("lon")) + lat = self._as_float(center.get("lat")) + spatial_key = f"center_{self._center_bucket(lon, lat)}" + return f"{observation_key}|{spatial_key}" + def _build_stack_candidate( self, scenes: list[dict[str, Any]], *, min_scenes: int, require_orbits: bool, + discovery_mode: str = "strict", + aoi_summary: dict[str, Any] | None = None, + min_common_overlap_ratio: float = 0.0, ) -> dict[str, Any]: scenes = sorted(scenes, key=lambda item: str(item.get("date") or "")) first = scenes[0] @@ -4491,7 +4986,11 @@ class SbasInsarProductionService: usable = orbit_ready if require_orbits else scenes dates = [scene.get("date") for scene in scenes if scene.get("date")] usable_dates = [scene.get("date") for scene in usable if scene.get("date")] - group_key = self._stack_group_key(first) + mode = self._normalize_discovery_mode(discovery_mode) + group_key = ( + str(first.get("aoi_cluster_key") or "") + or (self._aoi_stack_group_key(first) if mode == "aoi" else self._stack_group_key(first)) + ) stack_id = self._stable_id(group_key) temporal_gaps = self._temporal_gaps(usable_dates) blockers: list[str] = [] @@ -4499,11 +4998,55 @@ class SbasInsarProductionService: blockers.append(f"usable_scene_count {len(usable)} < min_scenes {min_scenes}") if require_orbits and len(orbit_ready) < len(scenes): blockers.append("missing precise orbit for one or more scenes") + usable_stack = {"scenes": usable} + bbox_intersection = self._bbox_intersection([scene.get("bbox") for scene in usable]) + bbox_union = self._stack_bbox_union(usable_stack) + common_overlap_ratio = ( + self._bbox_area(bbox_intersection) / self._bbox_area(bbox_union) + if bbox_intersection and bbox_union and self._bbox_area(bbox_union) > 0 + else 0.0 + ) + if mode == "aoi" and usable and not bbox_intersection: + blockers.append("no common overlap across usable scenes") + if mode == "aoi" and min_common_overlap_ratio > 0 and common_overlap_ratio < min_common_overlap_ratio: + blockers.append( + f"common_overlap_ratio {common_overlap_ratio:.3f} < min_common_overlap_ratio {min_common_overlap_ratio:.3f}" + ) + center = self._stack_center(usable_stack) + admin_region = lookup_admin_region_for_point( + (center or {}).get("lon"), + (center or {}).get("lat"), + ) + aoi_overlap_values = [ + float(scene.get("aoi_overlap_ratio") or 0.0) + for scene in usable + if scene.get("aoi_overlap_ratio") is not None + ] return { "stack_id": stack_id, "status": "READY" if not blockers else "BLOCKED", "blockers": blockers, + "discovery_mode": mode, + "aoi": aoi_summary, "group_key": group_key, + "hard_group_fields": [ + "satellite", + "satellite_mode", + "relative_orbit", + "orbit_direction", + "imaging_mode", + "polarization", + ] if mode == "aoi" else [ + "satellite", + "satellite_mode", + "receiving_station", + "relative_orbit", + "orbit_direction", + "imaging_mode", + "polarization", + "center_bucket", + ], + "soft_group_fields": ["receiving_station", "center_bucket"] if mode == "aoi" else [], "satellite": first.get("satellite"), "satellite_mode": first.get("satellite_mode"), "receiving_station": first.get("receiving_station"), @@ -4523,7 +5066,17 @@ class SbasInsarProductionService: "reference_date": usable_dates[len(usable_dates) // 2] if usable_dates else None, "temporal_gaps_days": temporal_gaps, "max_temporal_gap_days": max(temporal_gaps) if temporal_gaps else 0, - "bbox_intersection": self._bbox_intersection([scene.get("bbox") for scene in usable]), + "bbox": bbox_union, + "bbox_intersection": bbox_intersection, + "common_overlap_ratio": common_overlap_ratio, + "aoi_overlap_ratio_min": min(aoi_overlap_values) if aoi_overlap_values else None, + "aoi_overlap_ratio_max": max(aoi_overlap_values) if aoi_overlap_values else None, + "aoi_overlap_ratio_mean": ( + sum(aoi_overlap_values) / len(aoi_overlap_values) + if aoi_overlap_values else None + ), + "center": center, + "admin_region": admin_region, "scenes": scenes, } @@ -5072,19 +5625,22 @@ class SbasInsarProductionService: mode = "manual_lonlat" note = "Manual monitoring points are stored for extraction after geocoded products are available." else: - mode = strategy or "auto_low_sigma_high_rate" + mode = strategy or "auto_representative_points" + if mode == "auto_low_sigma_high_rate": + mode = "auto_representative_points" note = ( - "Automatic point is only a production placeholder until users provide a point layer " - "or approve a quality-filtered sampler." + "Automatic representative points are report-preview candidates until users provide " + "a point layer or approve final monitoring locations." ) return { "schema": "insar.sbas-monitor-points/v1", "mode": mode, "points": normalized_points, + "auto_count": 5, "default_auto_strategy": { - "key": "auto_low_sigma_high_rate", - "selection": "low LOS sigma, high absolute LOS velocity, non-edge valid pixel", - "usage": "debug/sample only; not a business monitoring network", + "key": "auto_representative_points", + "selection": "away/toward/high-absolute-rate/stable/center valid pixels with low sigma and non-edge constraints", + "usage": "preview candidates only; not a business monitoring network", }, "reference_date": (stack_manifest.get("stack") or {}).get("reference_date"), "coordinate_system": "EPSG:4326 for manual lon/lat points; radar coordinates are derived during publishing", @@ -6062,6 +6618,14 @@ class SbasInsarProductionService: python_bin = settings.GAMMA_SBAS_PYTHON or settings.WSL_SHARED_PYTHON or settings.PYINT_WSL_PYTHON or "/home/administrator/miniconda3/envs/insar_wsl_v1/bin/python" tool_script = Path(settings.PROJECT_ROOT) / "deploy" / "wsl" / "runners" / "gamma_sbas_product_tools.py" phase_to_los = wavelength / (4.0 * math.pi) + stack_manifest = self._read_optional_json(run_dir / "stack_manifest.json") + stack_dates = self._stack_dates(stack_manifest) + date_start = min(stack_dates, default="") + date_end = max(stack_dates, default="") + coverage = self._build_stack_geographic_coverage(stack_manifest) + admin_region = coverage.get("admin_region") or {} + admin_province = str(admin_region.get("province") or "").strip() + admin_city = str(admin_region.get("city") or "").strip() lines = [ "#!/usr/bin/env bash", "set -euo pipefail", @@ -6081,9 +6645,14 @@ class SbasInsarProductionService: f'RLKS="{rlks}"', f'WAVELENGTH="{wavelength:.12g}"', f'PHASE_TO_LOS="{phase_to_los:.12g}"', + f'DATE_START="{date_start}"', + f'DATE_END="{date_end}"', + f'ADMIN_PROVINCE="{admin_province}"', + f'ADMIN_CITY="{admin_city}"', "", f'source "{env_script}" >/dev/null 2>&1', - 'mkdir -p "${EXPORT_DIR}" "${LOG_DIR}"', + 'VECTOR_DIR="${RUN_ROOT}/publish/vectors"', + 'mkdir -p "${EXPORT_DIR}" "${VECTOR_DIR}" "${LOG_DIR}"', "", 'RDC_WIDTH="$(awk \'$1 == "range_samples:" {print $2; exit}\' "${MLI_PAR}")"', 'GEO_WIDTH="$(awk \'$1 == "width:" {print $2; exit}\' "${DEM_PAR}")"', @@ -6218,7 +6787,21 @@ class SbasInsarProductionService: "", ' make_preview "${EXPORT_DIR}/los_rate_toward_mm_per_year.tif" "${RATE_CMAP}" "${EXPORT_DIR}/los_rate_toward_mm_per_year.geo_preview.png"', ' make_preview "${EXPORT_DIR}/los_sigma_mm_per_year.tif" "${SIGMA_CMAP}" "${EXPORT_DIR}/los_sigma_mm_per_year.geo_preview.png"', + "", + ' "${PYTHON_BIN}" "${TOOL_SCRIPT}" export-points-geojson \\', + ' --toward-tif "${EXPORT_DIR}/los_rate_toward_mm_per_year.tif" \\', + ' --away-tif "${EXPORT_DIR}/los_rate_away_mm_per_year.tif" \\', + ' --sigma-tif "${EXPORT_DIR}/los_sigma_mm_per_year.tif" \\', + ' --output "${VECTOR_DIR}/los_rate_points.geojson.gz" \\', + ' --summary-path "${VECTOR_DIR}/los_rate_points_summary.json" \\', + ' --run-id "${RUN_ROOT##*/}" \\', + ' --date-start "${DATE_START}" \\', + ' --date-end "${DATE_END}" \\', + ' --reference-date "${REF_DATE}" \\', + ' --admin-province "${ADMIN_PROVINCE}" \\', + ' --admin-city "${ADMIN_CITY}"', ' ls -lh "${EXPORT_DIR}"', + ' ls -lh "${VECTOR_DIR}"', '} >"${LOG_DIR}/publish_products.log" 2>&1', "", 'echo "Published Gamma SBAS products: ${EXPORT_DIR}"', @@ -6853,6 +7436,12 @@ class SbasInsarProductionService: "los_rate_m_per_year_tif": export_dir / "los_rate_m_per_year.tif", "los_rate_away_m_per_year_hls_bmp": export_dir / "los_rate_away_m_per_year.hls.bmp", } + vector_dir = run_dir / "publish" / "vectors" + vector_outputs = { + "point_vector_geojson_gz": vector_dir / "los_rate_points.geojson.gz", + "point_vector_summary": vector_dir / "los_rate_points_summary.json", + } + point_vector_summary = self._read_optional_json(vector_outputs["point_vector_summary"]) or {} missing_outputs = [ name for name, path in required_outputs.items() if not path.is_file() or path.stat().st_size <= 0 @@ -6961,8 +7550,10 @@ class SbasInsarProductionService: }, "outputs": { "export_dir": str(export_dir), - **{name: self._file_record(path) for name, path in {**required_outputs, **optional_outputs}.items()}, + "vector_dir": str(vector_dir), + **{name: self._file_record(path) for name, path in {**required_outputs, **optional_outputs, **vector_outputs}.items()}, }, + "point_vector_summary": point_vector_summary, "rdc_size_checks": rdc_size_checks, "quality_summary": quality_stats, "product_summary": product_summary, @@ -7489,6 +8080,10 @@ class SbasInsarProductionService: def _build_run_card(self, run_dir: Path, manifest: dict[str, Any]) -> dict[str, Any]: stack = manifest.get("stack") or {} + try: + coverage = self._build_run_geographic_coverage(run_dir, manifest) + except Exception: + coverage = {} return { "run_id": manifest.get("run_id") or run_dir.name, "run_label": manifest.get("run_label"), @@ -7501,12 +8096,19 @@ class SbasInsarProductionService: "scene_count": manifest.get("scene_count"), "pair_count": manifest.get("pair_count"), "next_stage": manifest.get("next_stage"), + "discovery_mode": manifest.get("discovery_mode"), + "aoi": manifest.get("aoi"), + "common_overlap_ratio": manifest.get("common_overlap_ratio"), "platform": stack.get("satellite"), "relative_orbit": stack.get("relative_orbit"), "direction": stack.get("orbit_direction"), "polarization": stack.get("polarization"), "center_bucket": stack.get("center_bucket"), "reference_date": stack.get("reference_date"), + "date_start": coverage.get("date_start"), + "date_end": coverage.get("date_end"), + "center": coverage.get("center"), + "admin_region": coverage.get("admin_region"), "run_dir": str(run_dir), } diff --git a/deploy/wsl/runners/gamma_sbas_product_tools.py b/deploy/wsl/runners/gamma_sbas_product_tools.py index e41ae20..5771465 100644 --- a/deploy/wsl/runners/gamma_sbas_product_tools.py +++ b/deploy/wsl/runners/gamma_sbas_product_tools.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse import csv +import gzip import json import math import re @@ -44,7 +45,7 @@ def write_scaled_float32(input_path: Path, output_path: Path, scale: float) -> N (data * float(scale)).astype(">f4", copy=False).tofile(output_path) -def pick_auto_point(rate: np.ndarray, sigma: np.ndarray) -> tuple[int, int]: +def monitor_valid_mask(rate: np.ndarray, sigma: np.ndarray) -> np.ndarray: lines, width = rate.shape yy, xx = np.indices(rate.shape) edge_mask = ( @@ -57,6 +58,42 @@ def pick_auto_point(rate: np.ndarray, sigma: np.ndarray) -> tuple[int, int]: valid = finite & edge_mask & (rate != 0.0) & (sigma > 0.0) if not valid.any(): raise RuntimeError("No valid pixels available for monitor point selection") + return valid + + +def remove_near_selected(candidate: np.ndarray, selected: list[tuple[int, int]], min_distance: int) -> np.ndarray: + if not selected: + return candidate + yy, xx = np.indices(candidate.shape) + filtered = candidate.copy() + min_distance_sq = float(min_distance * min_distance) + for x, y in selected: + filtered &= ((xx - float(x)) ** 2 + (yy - float(y)) ** 2) >= min_distance_sq + return filtered + + +def pick_scored_point( + score: np.ndarray, + candidate: np.ndarray, + selected: list[tuple[int, int]], + *, + min_distance: int, +) -> tuple[int, int] | None: + filtered = remove_near_selected(candidate, selected, min_distance) + if not filtered.any(): + filtered = candidate + if not filtered.any(): + return None + safe_score = np.full(score.shape, -np.inf, dtype=np.float64) + safe_score[filtered] = score[filtered] + y, x = np.unravel_index(int(np.nanargmax(safe_score)), score.shape) + if not np.isfinite(safe_score[y, x]): + return None + return int(x), int(y) + + +def pick_auto_point(rate: np.ndarray, sigma: np.ndarray) -> tuple[int, int]: + valid = monitor_valid_mask(rate, sigma) abs_rate = np.abs(rate[valid]) sig = sigma[valid] @@ -73,6 +110,97 @@ def pick_auto_point(rate: np.ndarray, sigma: np.ndarray) -> tuple[int, int]: return int(x), int(y) +def pick_auto_points(rate: np.ndarray, sigma: np.ndarray, *, count: int = 5) -> list[dict[str, Any]]: + valid = monitor_valid_mask(rate, sigma) + lines, width = rate.shape + yy, xx = np.indices(rate.shape) + min_distance = max(24, int(min(width, lines) * 0.08)) + sigma_max = float(np.percentile(sigma[valid], 40)) + low_sigma = valid & (sigma <= sigma_max) + if not low_sigma.any(): + low_sigma = valid + + abs_rate = np.abs(rate) + abs_valid = abs_rate[valid] + high_abs_min = float(np.percentile(abs_valid, 85)) + high_abs_max = float(np.percentile(abs_valid, 99)) + low_abs_max = float(np.percentile(abs_valid, 25)) + cx = (width - 1) / 2.0 + cy = (lines - 1) / 2.0 + + definitions = [ + { + "point_id": "auto_away_high_rate_low_sigma", + "selection": "automatic_away_from_radar_high_rate_low_sigma_non_edge", + "candidate": low_sigma & (rate < 0.0) & (abs_rate >= high_abs_min) & (abs_rate <= high_abs_max), + "score": (-rate) / (sigma + 1.0e-6), + }, + { + "point_id": "auto_toward_high_rate_low_sigma", + "selection": "automatic_toward_radar_high_rate_low_sigma_non_edge", + "candidate": low_sigma & (rate > 0.0) & (abs_rate >= high_abs_min) & (abs_rate <= high_abs_max), + "score": rate / (sigma + 1.0e-6), + }, + { + "point_id": "auto_low_sigma_high_rate", + "selection": "automatic_low_sigma_high_abs_rate_non_edge", + "candidate": low_sigma & (abs_rate >= high_abs_min) & (abs_rate <= high_abs_max), + "score": abs_rate / (sigma + 1.0e-6), + }, + { + "point_id": "auto_stable_low_sigma", + "selection": "automatic_near_zero_rate_low_sigma_non_edge", + "candidate": low_sigma & (abs_rate <= low_abs_max), + "score": 1.0 / ((abs_rate + 1.0) * (sigma + 1.0e-6)), + }, + { + "point_id": "auto_center_valid", + "selection": "automatic_valid_pixel_nearest_stack_center", + "candidate": valid, + "score": -((xx - cx) ** 2 + (yy - cy) ** 2), + }, + ] + + selected_xy: list[tuple[int, int]] = [] + selected_points: list[dict[str, Any]] = [] + for definition in definitions: + if len(selected_points) >= count: + break + candidate = definition["candidate"] + if not candidate.any(): + candidate = low_sigma if low_sigma.any() else valid + picked = pick_scored_point( + np.asarray(definition["score"], dtype=np.float64), + candidate, + selected_xy, + min_distance=min_distance, + ) + if picked is None: + continue + x, y = picked + selected_xy.append((x, y)) + selected_points.append( + { + "point_id": definition["point_id"], + "selection": definition["selection"], + "range_pixel": x, + "azimuth_line": y, + } + ) + + if not selected_points: + x, y = pick_auto_point(rate, sigma) + selected_points.append( + { + "point_id": "auto_low_sigma_high_rate", + "selection": "automatic_low_sigma_high_rate_non_edge", + "range_pixel": x, + "azimuth_line": y, + } + ) + return selected_points[:count] + + def dem_grid(dem_par: Path) -> dict[str, float | int]: return { "width": int(read_gamma_value(dem_par, "width")), @@ -218,6 +346,190 @@ def write_point_outputs( return {"png": str(png_path), "csv": str(csv_path), "metadata": str(json_path)} +def read_geotiff_float32(path: Path) -> dict[str, Any]: + try: + import rasterio + + with rasterio.open(path) as src: + transform = src.transform + return { + "array": src.read(1).astype(np.float32, copy=False), + "width": src.width, + "height": src.height, + "nodata": src.nodata, + "crs": src.crs.to_string() if src.crs else None, + "transform": (transform.a, transform.b, transform.c, transform.d, transform.e, transform.f), + } + except Exception as rasterio_exc: + try: + from osgeo import gdal + except Exception as gdal_exc: + raise RuntimeError("rasterio or osgeo.gdal is required to read GeoTIFF files") from gdal_exc + dataset = gdal.Open(str(path), gdal.GA_ReadOnly) + if dataset is None: + raise RuntimeError(f"Unable to open GeoTIFF: {path}") from rasterio_exc + band = dataset.GetRasterBand(1) + array = band.ReadAsArray().astype(np.float32, copy=False) + geotransform = dataset.GetGeoTransform() + return { + "array": array, + "width": int(dataset.RasterXSize), + "height": int(dataset.RasterYSize), + "nodata": band.GetNoDataValue(), + "crs": dataset.GetProjection() or None, + "transform": ( + float(geotransform[1]), + float(geotransform[2]), + float(geotransform[0]), + float(geotransform[4]), + float(geotransform[5]), + float(geotransform[3]), + ), + } + + +def normalize_crs_label(value: Any) -> str | None: + text = str(value or "").strip() + if not text: + return None + upper = text.upper() + if "EPSG" in upper and "4326" in upper: + return "EPSG:4326" + if "WGS 84" in upper or "WGS_1984" in upper: + return "EPSG:4326" + return text[:240] + + +def pixel_center(transform: tuple[float, float, float, float, float, float], row: int, col: int) -> tuple[float, float]: + a, b, c, d, e, f = transform + x = c + (col + 0.5) * a + (row + 0.5) * b + y = f + (col + 0.5) * d + (row + 0.5) * e + return float(x), float(y) + + +def run_export_points_geojson(args: argparse.Namespace) -> int: + + toward_path = Path(args.toward_tif) + away_path = Path(args.away_tif) + sigma_path = Path(args.sigma_tif) + output_path = Path(args.output) + summary_path = Path(args.summary_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + summary_path.parent.mkdir(parents=True, exist_ok=True) + + run_id = str(args.run_id or "").strip() + date_start = str(args.date_start or "").strip() + date_end = str(args.date_end or "").strip() + reference_date = str(args.reference_date or "").strip() + admin_province = str(args.admin_province or "").strip() + admin_city = str(args.admin_city or "").strip() + + fields = [ + "run_id", + "row", + "col", + "lon", + "lat", + "los_rate_toward_mm_per_year", + "los_rate_away_mm_per_year", + "los_sigma_mm_per_year", + "date_start", + "date_end", + "reference_date", + "admin_province", + "admin_city", + ] + + toward_meta = read_geotiff_float32(toward_path) + away_meta = read_geotiff_float32(away_path) + sigma_meta = read_geotiff_float32(sigma_path) + width = int(toward_meta["width"]) + height = int(toward_meta["height"]) + if (width, height) != (int(away_meta["width"]), int(away_meta["height"])): + raise RuntimeError("toward and away GeoTIFF dimensions do not match") + if (width, height) != (int(sigma_meta["width"]), int(sigma_meta["height"])): + raise RuntimeError("toward and sigma GeoTIFF dimensions do not match") + + toward = np.asarray(toward_meta["array"], dtype=np.float32) + away = np.asarray(away_meta["array"], dtype=np.float32) + sigma = np.asarray(sigma_meta["array"], dtype=np.float32) + valid = np.isfinite(toward) & np.isfinite(away) & np.isfinite(sigma) & (sigma > 0.0) + if toward_meta.get("nodata") is not None: + valid &= toward != float(toward_meta["nodata"]) + if away_meta.get("nodata") is not None: + valid &= away != float(away_meta["nodata"]) + if sigma_meta.get("nodata") is not None: + valid &= sigma != float(sigma_meta["nodata"]) + + transform = tuple(float(value) for value in toward_meta["transform"]) + feature_count = 0 + with gzip.open(output_path, "wt", encoding="utf-8", compresslevel=6) as handle: + handle.write('{"type":"FeatureCollection","features":[\n') + first = True + for row in range(height): + cols = np.where(valid[row])[0] + for col in cols.tolist(): + lon, lat = pixel_center(transform, row, int(col)) + properties = { + "run_id": run_id, + "row": int(row), + "col": int(col), + "lon": lon, + "lat": lat, + "los_rate_toward_mm_per_year": float(toward[row, col]), + "los_rate_away_mm_per_year": float(away[row, col]), + "los_sigma_mm_per_year": float(sigma[row, col]), + "date_start": date_start, + "date_end": date_end, + "reference_date": reference_date, + "admin_province": admin_province, + "admin_city": admin_city, + } + feature = { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [lon, lat]}, + "properties": properties, + } + if not first: + handle.write(",\n") + handle.write(json.dumps(feature, ensure_ascii=False, separators=(",", ":"))) + first = False + feature_count += 1 + handle.write("\n]}\n") + + crs = normalize_crs_label(toward_meta.get("crs")) + + summary = { + "schema": "insar.gamma-sbas-point-vector-summary/v1", + "generated_at": datetime.utcnow().isoformat(timespec="seconds") + "Z", + "ready": output_path.is_file() and output_path.stat().st_size > 0, + "feature_count": feature_count, + "output_geojson_gz": str(output_path), + "output_size_bytes": output_path.stat().st_size if output_path.is_file() else 0, + "fields": fields, + "source_geotiffs": { + "los_rate_toward_mm_per_year": str(toward_path), + "los_rate_away_mm_per_year": str(away_path), + "los_sigma_mm_per_year": str(sigma_path), + }, + "width": width, + "height": height, + "crs": crs, + "date_start": date_start, + "date_end": date_end, + "reference_date": reference_date, + "admin_region": { + "province": admin_province or None, + "city": admin_city or None, + }, + "los_convention": "toward radar positive; away from radar negative", + "frontend_policy": "download_only; do not render full point GeoJSON in browser", + } + summary_path.write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8") + print(json.dumps(summary, indent=2, ensure_ascii=False)) + return 0 + + def run_phase_to_los(args: argparse.Namespace) -> int: write_scaled_float32(Path(args.input), Path(args.output), float(args.scale)) return 0 @@ -273,18 +585,10 @@ def run_monitor_points(args: argparse.Namespace) -> int: } ) else: - x, y = pick_auto_point(rate_toward, sigma) - lon, lat = radar_to_lonlat(x, y, dem_par, lookup) - selected_points.append( - { - "point_id": "auto_low_sigma_high_rate", - "selection": "automatic_low_sigma_high_rate_non_edge", - "range_pixel": x, - "azimuth_line": y, - "lon": lon, - "lat": lat, - } - ) + auto_count = int(config.get("auto_count") or 5) + for point in pick_auto_points(rate_toward, sigma, count=max(1, min(auto_count, 12))): + lon, lat = radar_to_lonlat(int(point["range_pixel"]), int(point["azimuth_line"]), dem_par, lookup) + selected_points.append({**point, "lon": lon, "lat": lat}) outputs: list[dict[str, Any]] = [] for point in selected_points: @@ -343,6 +647,20 @@ def build_parser() -> argparse.ArgumentParser: phase.add_argument("scale", type=float) phase.set_defaults(func=run_phase_to_los) + vector = subparsers.add_parser("export-points-geojson") + vector.add_argument("--toward-tif", required=True) + vector.add_argument("--away-tif", required=True) + vector.add_argument("--sigma-tif", required=True) + vector.add_argument("--output", required=True) + vector.add_argument("--summary-path", required=True) + vector.add_argument("--run-id", default="") + vector.add_argument("--date-start", default="") + vector.add_argument("--date-end", default="") + vector.add_argument("--reference-date", default="") + vector.add_argument("--admin-province", default="") + vector.add_argument("--admin-city", default="") + vector.set_defaults(func=run_export_points_geojson) + monitor = subparsers.add_parser("monitor-points") monitor.add_argument("--monitor-config", required=True) monitor.add_argument("--timeseries-dir", required=True) diff --git a/docs/SBAS_INSAR_GEOJSON_RESULT_MANAGEMENT_DESIGN_20260527.md b/docs/SBAS_INSAR_GEOJSON_RESULT_MANAGEMENT_DESIGN_20260527.md index c5aec8f..aae2f4b 100644 --- a/docs/SBAS_INSAR_GEOJSON_RESULT_MANAGEMENT_DESIGN_20260527.md +++ b/docs/SBAS_INSAR_GEOJSON_RESULT_MANAGEMENT_DESIGN_20260527.md @@ -497,6 +497,120 @@ Recommended order: The first user-visible win is step 1-2: the operator can immediately see whether a Run covers the intended location. +## 10.1 Implementation Note 2026-05-27 + +Implemented the first slice after commit `9f0ba32`: + +```text +backend/app/services/sbas_insar_production_service.py +frontend/src/SbasInsarProductionPanel.jsx +``` + +Backend now returns `geographic_coverage` from `GET /api/sbas-insar-production/runs/{run_id}`. The field is derived from `stack_manifest.json`, `rdc_dem_summary.json`, and `monitor_points_summary.json` without changing the Gamma expert workflow outputs. + +The returned structure includes: + +```text +bbox +bbox_intersection +center +scene_bbox_count +scene_footprints_geojson +dem_coverage +dem_covers_stack_bbox +dem_covers_stack_center +monitor_points +geojson FeatureCollection +``` + +Frontend now displays the coverage block in two places: + +```text +candidate stack discovery detail +selected production Run detail +``` + +The mini-map uses the existing Leaflet/offline tile configuration and draws: + +```text +stack bbox rectangle +DEM coverage rectangle when available +monitor point markers when available +``` + +Validation against `sbas_7537cc71c998`: + +```text +geographic_coverage.bbox = 128.7690438245,43.7486321624,129.6293024728,44.3582486206 +geojson feature count = 5 +monitor point = auto_low_sigma_high_rate, 129.10207098755,44.15041727515 +backend AST syntax check passed with configured Python +frontend npm run build passed +``` + +Remaining result-management work starts at catalog registration and a separate SBAS products page. + +## 10.2 Implementation Note 2026-05-27 Result Catalog + +Implemented the SBAS result-management slice: + +```text +backend/app/services/sbas_insar_catalog_service.py +backend/app/routers/sbas_insar_products.py +frontend/src/api/sbasInsarProducts.js +frontend/src/SbasInsarProductsPanel.jsx +``` + +Backend behavior: + +```text +catalog_name = sbas_insar +storage root = GAMMA_SBAS_WORK_ROOT/runs +source of truth = completed Gamma SBAS run folders +startup bootstrap = scan publish-ready runs and rebuild index when stale +manual rebuild = POST /api/sbas-insar-products/rebuild through job queue +list/detail = GET /api/sbas-insar-products and /{id} +asset serving = /api/sbas-insar-products/{id}/assets/{asset_id} +``` + +The catalog registers only database metadata and file pointers. It does not copy the large Gamma outputs. + +Important registered assets: + +```text +LOS velocity geocoded preview +LOS sigma geocoded preview +LOS velocity GeoTIFF, toward radar positive +LOS velocity GeoTIFF, away from radar positive +LOS sigma GeoTIFF +Gamma ts_rate and sigma_rate GeoTIFFs +monitor-point PNG/CSV/metadata +run, stack, workflow, product, quality, and monitor summaries +``` + +Frontend behavior: + +```text +Production Management -> SBAS-InSAR 结果 +catalog health cards +searchable result list +result detail +coverage map with stack bbox, DEM bbox, and monitor points +velocity/sigma/monitor preview panels +quality statistics +asset download/open links +issue list +``` + +Known next slices: + +```text +AOI / administrative-region filter for list and discovery +GeoTIFF raster overlay or server-side tile generation +multi-monitor-point comparison view +explicit orbit-trend/detrend quality diagnostics +``` + ## 11. Validation Use `sbas_7537cc71c998` as the first validation run. @@ -516,7 +630,37 @@ AOI filter returns this product when AOI intersects bbox AOI filter excludes this product when AOI is far away ``` -## 12. Open Questions +## 12. 2026-05-27 Center-Region UI Closeout + +The gray footprint maps are no longer the primary SBAS UI contract. Production planning and result management now show a location summary instead: + +```text +center lon/lat +center administrative region +stack bbox and common-overlap bbox as text +scene footprint count +monitor point count +``` + +Administrative lookup uses the existing `backend/geojson` AOI region data. The lookup starts with center-point containment, repairs invalid administrative geometries where possible, and falls back to a clear unavailable/not-matched state instead of blocking SBAS production. + +The SBAS result catalog now extracts dates from `stack_manifest.scenes[*].date`. This fixes the old list symptom: + +```text +before: - 至 - / 0景 / 6对 +after : 20240422 至 20250908 / 7景 / 6对 +``` + +The current validation run `sbas_7537cc71c998` rebuilt successfully into the result catalog and matched: + +```text +center = 129.1949239855143, 44.053833584014285 +admin region = 黑龙江省 / 牡丹江市 +date range = 20240422 至 20250908 +scene/pair count = 7 / 6 +``` + +## 13. Open Questions 1. Administrative-region naming should start with center-point lookup or intersection lookup? Recommendation: center-point lookup first, intersection later. diff --git a/docs/SBAS_INSAR_POINT_VECTOR_AND_MONITOR_CURVE_DESIGN_20260528.md b/docs/SBAS_INSAR_POINT_VECTOR_AND_MONITOR_CURVE_DESIGN_20260528.md new file mode 100644 index 0000000..105cad9 --- /dev/null +++ b/docs/SBAS_INSAR_POINT_VECTOR_AND_MONITOR_CURVE_DESIGN_20260528.md @@ -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 导出。 +- 自动点只适合作为快速检查和报告初稿候选点,正式报告应支持用户指定点、导入点位或专家确认点位。 diff --git a/docs/SBAS_STACK_DISCOVERY_AOI_DESIGN_20260528.md b/docs/SBAS_STACK_DISCOVERY_AOI_DESIGN_20260528.md new file mode 100644 index 0000000..40130fa --- /dev/null +++ b/docs/SBAS_STACK_DISCOVERY_AOI_DESIGN_20260528.md @@ -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//` 数据,并在运行前手动修改日期、阈值、宽高和种子点等参数。 + +文档中的 `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 可作为下一步高级入口接入,但不应在普通页面展示开发诊断信息。 diff --git a/frontend/src/ProductionWorkspace.jsx b/frontend/src/ProductionWorkspace.jsx index 0738775..5823792 100644 --- a/frontend/src/ProductionWorkspace.jsx +++ b/frontend/src/ProductionWorkspace.jsx @@ -9,6 +9,7 @@ import { PanelLoadingBody } from './components/app/AppLoadingFallbacks'; const LazyDinsarProductionPanel = lazy(() => import('./DinsarProductionPanel')); const LazySbasInsarProductionPanel = lazy(() => import('./SbasInsarProductionPanel')); +const LazySbasInsarProductsPanel = lazy(() => import('./SbasInsarProductsPanel')); const LazyDinsarProductsPanel = lazy(() => import('./DinsarProductsPanel')); const shellStyle = { @@ -67,6 +68,10 @@ export default function ProductionWorkspace({ onTaskStart?.(taskId, 'D-InSAR 产物任务已入队,等待处理...'); }; + const handleSbasProductQueued = taskId => { + onTaskStart?.(taskId, 'SBAS-InSAR result catalog task queued.'); + }; + return (
@@ -178,6 +183,12 @@ export default function ProductionWorkspace({ readOnly={readOnly} /> )} + {activeView === 'sbas_insar_products' && ( + + )} {activeView === 'dinsar_products' && ( = 100 ? 0 : 1)} ${units[index]}`; } +function normalizeBbox(bbox) { + if (!bbox || typeof bbox !== 'object') return null; + const minLon = Number(bbox.min_lon); + const minLat = Number(bbox.min_lat); + const maxLon = Number(bbox.max_lon); + const maxLat = Number(bbox.max_lat); + if (![minLon, minLat, maxLon, maxLat].every(Number.isFinite)) return null; + if (minLon >= maxLon || minLat >= maxLat) return null; + return { min_lon: minLon, min_lat: minLat, max_lon: maxLon, max_lat: maxLat }; +} + +function formatCoord(value, digits = 5) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return '-'; + return numeric.toFixed(digits); +} + +function formatBbox(bbox) { + const normalized = normalizeBbox(bbox); + if (!normalized) return '-'; + return [ + formatCoord(normalized.min_lon), + formatCoord(normalized.min_lat), + formatCoord(normalized.max_lon), + formatCoord(normalized.max_lat), + ].join(', '); +} + +function bboxCenter(bbox) { + const normalized = normalizeBbox(bbox); + if (!normalized) return null; + return { + lon: (normalized.min_lon + normalized.max_lon) / 2, + lat: (normalized.min_lat + normalized.max_lat) / 2, + }; +} + +function formatCenter(center) { + if (!center) return '-'; + const lon = Number(center.lon); + const lat = Number(center.lat); + if (!Number.isFinite(lon) || !Number.isFinite(lat)) return '-'; + return `${formatCoord(lon)}, ${formatCoord(lat)}`; +} + +function formatAdminRegion(region) { + if (!region || typeof region !== 'object') return '-'; + return region.display_name || region.name || region.tree_id || '-'; +} + +function formatPercent(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return '-'; + return `${(numeric * 100).toFixed(numeric >= 0.1 ? 0 : 1)}%`; +} + function StatusBadge({ value }) { const okValues = new Set([ 'READY', @@ -156,6 +212,276 @@ function RunArtifactLink({ runId, artifact }) { ); } +function LocationSummaryPanel({ coverage }) { + const bbox = normalizeBbox(coverage?.bbox); + const intersection = normalizeBbox(coverage?.bbox_intersection); + const center = coverage?.center || bboxCenter(bbox); + const adminRegion = coverage?.admin_region; + const monitorPoints = Array.isArray(coverage?.monitor_points) ? coverage.monitor_points : []; + return ( +
+
+
位置摘要
+ center / admin region +
+
+ + + + + + +
+ {monitorPoints.length > 0 && ( +
+ 监测点:{monitorPoints.map(point => `${point.point_id || 'point'} (${formatCenter(point)})`).join(';')} +
+ )} +
+ ); +} + +/* +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 ( +
+ 暂无可展示的地理范围 +
+ ); + } + + return ( +
+
+
+ Blue scene footprints ({sceneFeatureCount}) + Green dashed coverage GeoJSON ({coverageFeatureCount}) + Gray dashed outer bbox + Purple monitor points +
+
+ ); +} + +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 ( +
+
地理范围
+
+ 当前 Run 尚未找到 LT1 元数据 bbox。后续按行政区/AOI 生产时会在这里显示范围。 +
+
+ ); + } + + return ( +
+
+
地理范围
+ EPSG:4326 / GeoJSON +
+
+ +
+
+ + + + + 0 ? 'scene GeoJSON' : 'stack bbox'} /> + +
+ {monitorPoints.length > 0 && ( +
+ 监测点:{monitorPoints.map(point => `${point.point_id || 'point'} (${formatCoord(point.lon)}, ${formatCoord(point.lat)})`).join(';')} +
+ )} + {geojsonText && ( +
+ 查看 GeoJSON +
+                {geojsonText}
+              
+
+ )} +
+
+
+ ); +} +*/ + export default function SbasInsarProductionPanel({ readOnly = false }) { const [capabilities, setCapabilities] = useState(null); const [runs, setRuns] = useState([]); @@ -170,6 +496,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) { const [auditLoading, setAuditLoading] = useState(false); const [stackAudit, setStackAudit] = useState(null); const [submitLoading, setSubmitLoading] = useState(false); + const [stackAdminRegionQuery, setStackAdminRegionQuery] = useState(''); const [baselineAuditLoading, setBaselineAuditLoading] = useState(false); const [itabDecisionLoading, setItabDecisionLoading] = useState(false); const [coregistrationLoading, setCoregistrationLoading] = useState(false); @@ -188,6 +515,20 @@ export default function SbasInsarProductionPanel({ readOnly = false }) { const [workflowJobLoading, setWorkflowJobLoading] = useState(false); const [workflowJob, setWorkflowJob] = useState(null); + const stackDiscoveryPayload = useMemo(() => { + const adminRegion = stackAdminRegionQuery.trim(); + return { + min_scenes: 3, + require_orbits: true, + include_scenes: false, + limit: 30, + discovery_mode: adminRegion ? 'aoi' : 'strict', + admin_region: adminRegion || undefined, + min_aoi_coverage_ratio: 0.01, + min_common_overlap_ratio: 0, + }; + }, [stackAdminRegionQuery]); + const loadProductionRuns = useCallback(async () => { setLoading(true); setError(''); @@ -240,22 +581,17 @@ export default function SbasInsarProductionPanel({ readOnly = false }) { setError(''); setStackAudit(null); try { - const data = await discoverSbasInsarStacks({ - min_scenes: 3, - require_orbits: true, - include_scenes: false, - limit: 30, - }); + const data = await discoverSbasInsarStacks(stackDiscoveryPayload); const items = Array.isArray(data?.items) ? data.items : []; setStackCandidates(items); - setSelectedStackId(current => current || items[0]?.stack_id || ''); + setSelectedStackId(items[0]?.stack_id || ''); } catch (exc) { setError(exc?.response?.data?.detail || exc.message || 'SBAS-InSAR 栈发现失败'); setStackCandidates([]); } finally { setDiscovering(false); } - }, []); + }, [stackDiscoveryPayload]); const handleAuditStack = useCallback(async stackId => { if (!stackId) return; @@ -263,8 +599,8 @@ export default function SbasInsarProductionPanel({ readOnly = false }) { setError(''); try { const data = await auditSbasInsarStack(stackId, { - min_scenes: 3, - require_orbits: true, + ...stackDiscoveryPayload, + include_scenes: true, }); setStackAudit(data); } catch (exc) { @@ -273,7 +609,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) { } finally { setAuditLoading(false); } - }, []); + }, [stackDiscoveryPayload]); const handleSubmitRun = useCallback(async () => { if (!selectedStackId || readOnly) return; @@ -282,13 +618,12 @@ export default function SbasInsarProductionPanel({ readOnly = false }) { try { const candidate = stackCandidates.find(item => item.stack_id === selectedStackId); const data = await submitSbasInsarRun(selectedStackId, { + ...stackDiscoveryPayload, run_label: candidate - ? `${candidate.satellite || 'LT1'} ${candidate.relative_orbit || ''} ${candidate.center_bucket || ''}`.trim() + ? `${candidate.satellite || 'LT1'} ${formatAdminRegion(candidate.admin_region)} relOrbit ${candidate.relative_orbit || ''}`.trim() : undefined, - min_scenes: 3, - require_orbits: true, dry_run: false, - monitor_point_strategy: 'auto_low_sigma_high_rate', + monitor_point_strategy: 'auto_representative_points', }); const runId = data?.run?.run_id; const runData = await listSbasInsarRuns(); @@ -303,7 +638,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) { } finally { setSubmitLoading(false); } - }, [readOnly, selectedStackId, stackCandidates]); + }, [readOnly, selectedStackId, stackCandidates, stackDiscoveryPayload]); const workflowPayload = useMemo(() => ({ force: false, @@ -586,6 +921,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) { const iptaTimeseriesPlan = runManifest.ipta_timeseries || null; const publishProductsPlan = runManifest.publish_products || null; const monitorProductsPlan = runManifest.monitor_point_products || null; + const runGeographicCoverage = runDetail?.geographic_coverage || null; const runPrimaryPreview = ( runArtifacts.find(item => item.key === 'los_rate_toward_m_per_year_hls_geo_preview_png') || runArtifacts.find(item => item.key === 'los_rate_toward_mm_per_year_geo_preview_png') @@ -662,12 +998,32 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
-

候选 SBAS 序列发现

+

SBAS 生产区域

- 直接扫描本地 LT1 数据池,按平台、相对轨道、升降轨、模式、极化、接收站和中心桶硬分组,并检查精轨 TXT。 + 按生产行政区查找覆盖同一目标区域的 LT1 时序候选,并检查日期密度、精轨和公共重叠范围。
-
+
+ { + 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, + }} + />
+
+ 行政区:{formatAdminRegion(item.admin_region)};公共重叠 {formatPercent(item.common_overlap_ratio)} +
+
+ 覆盖 {formatPercent(item.aoi_overlap_ratio_mean)};中心点 {formatCenter(item.center)} +
); })}
{selectedStack && ( -
- - - - -
+ <> +
+ + + + + + +
+ + )} {stackAudit && (
@@ -826,7 +1201,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) { >
- {item.platform || 'LT1'} / relOrbit {item.relative_orbit || '-'} / {item.center_bucket || '-'} + {formatAdminRegion(item.admin_region)} / {item.platform || 'LT1'} / relOrbit {item.relative_orbit || '-'}
@@ -836,6 +1211,11 @@ export default function SbasInsarProductionPanel({ readOnly = false }) { ); })} + {runs.length > 0 && ( +
+ 当前 Run 列表已补充中心点行政区;筛选入口优先放在候选序列发现阶段。 +
+ )} {!loading && runs.length === 0 && (
暂无计划 Run。先发现序列,再创建计划 Run。 @@ -854,6 +1234,8 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
+ + {!readOnly && (
Gamma SBAS Workflow
diff --git a/frontend/src/SbasInsarProductsPanel.jsx b/frontend/src/SbasInsarProductsPanel.jsx new file mode 100644 index 0000000..4894ed8 --- /dev/null +++ b/frontend/src/SbasInsarProductsPanel.jsx @@ -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 ( + + + {value || 'UNKNOWN'} + + ); +} + +function Metric({ label, value, accent }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +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 ( +
+
{title}
+
暂无预览。
+
+ ); + } + return ( +
+
+ {title} +
+ {title} +
{asset.relative_path}
+
+ ); +} + +function PointVectorDownload({ asset, summary, productId }) { + if (!asset && !summary) return null; + const fields = Array.isArray(summary?.fields) ? summary.fields : []; + return ( +
+
+
+
全量有效点 GeoJSON.gz
+
+ 仅提供下载,不在前端渲染;用于 QGIS、ArcGIS、Python 或精细制图。 +
+
+ {asset ? ( + + 下载 + + ) : ( + 缺失 + )} +
+
+ + + + +
+ {fields.length > 0 && ( +
+ 字段:{fields.join(', ')} +
+ )} +
+ ); +} + +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 ( +
+
+
+
+

SBAS-InSAR 结果管理

+
+ 管理 Gamma SBAS 生产结果、重要预览图、GeoTIFF、监测点曲线和发布资产。 +
+
+
+ + +
+
+ +
+ } accent={catalogColor} /> + + + 0 ? '#b45309' : '#15803d'} /> +
+ +
+
根目录:{catalogStatus?.storage_root || '-'}
+
最近消息:{catalogStatus?.last_message || '-'}
+
最近重建:{formatDateTime(catalogStatus?.last_full_rebuild_at)}
+
+ {message && ( +
{message}
+ )} +
+ +
+
+
+ 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 }} + /> +
+ 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 }} + /> + +
+
+ +
结果列表 ({products.length})
+ {products.length === 0 ? ( +
{loading ? '正在加载结果...' : '暂无已登记 SBAS 结果。'}
+ ) : ( +
+ {products.map(product => { + const active = product.id === selectedId; + const productCenter = product.center || bboxCenter(product); + return ( + + ); + })} +
+ )} +
+ +
+ {!selectedId ? ( +
+
请选择一个 SBAS 结果。
+
+ ) : detailLoading ? ( +
+
正在加载结果详情...
+
+ ) : detail?.error ? ( +
+
{detail.error}
+
+ ) : detail ? ( + <> +
+
+
+ {detail.display_name} +
+
+
+
+

{detail.display_name || detail.product_id}

+
{detail.product_id}
+
+ +
+
+ + + + +
+
+
LOS 约定:{detail.los_sign_convention || 'toward radar positive; away from radar negative'}
+
Run:{detail.run_id || detail.run_key || '-'}
+
Stack:{detail.stack_key || '-'}
+
Manifest:{detail.manifest_path || '-'}
+
+
+
+
+ +
+

位置摘要

+
+ + + + + +
+
+ +
+

重要产物预览

+
+ + +
+
+ +
+
+
监测点形变曲线
+ {monitorPreviews.length === 0 ? ( +
+ 暂无监测点曲线。 +
+ ) : ( +
+ {monitorPreviews.map(asset => ( + + ))} +
+ )} +
+
+ +
+

统计摘要

+
+ + + + +
+
+ +
+

资产下载

+
+ {selectedAssets.map(asset => ( +
+
+
{asset.asset_role}
+
{formatBytes(asset.file_size)} / {asset.format || '-'}
+
+
{asset.relative_path}
+ {asset.exists_flag ? ( + + 打开 + + ) : ( + 缺失 + )} +
+ ))} +
+
+ +
+

问题

+ {selectedIssues.length === 0 ? ( +
当前目录索引未发现问题。
+ ) : ( +
+ {selectedIssues.map(issue => ( +
+ {issue.severity} / {issue.issue_code} +
{issue.message}
+
+ ))} +
+ )} +
+ + ) : null} +
+
+
+ ); +} diff --git a/frontend/src/api/sbasInsarProducts.js b/frontend/src/api/sbasInsarProducts.js new file mode 100644 index 0000000..0b2c441 --- /dev/null +++ b/frontend/src/api/sbasInsarProducts.js @@ -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)}`; diff --git a/frontend/src/config/appConstants.js b/frontend/src/config/appConstants.js index c16ba4c..6a12e87 100644 --- a/frontend/src/config/appConstants.js +++ b/frontend/src/config/appConstants.js @@ -70,6 +70,11 @@ export const PRODUCTION_WORKSPACE_VIEWS = [ label: 'SBAS-InSAR Production', description: 'Gamma IPTA SBAS stack production, velocity maps, quality metrics, and monitor-point curves', }, + { + key: 'sbas_insar_products', + label: 'SBAS-InSAR 结果', + description: 'Gamma SBAS LOS velocity, uncertainty, coverage and monitoring-point product catalog', + }, { key: 'dinsar_products', label: 'D-InSAR 产物', @@ -82,7 +87,7 @@ export const PRODUCTION_WORKSPACE_ENTRY_TO_VIEW = Object.freeze({ dinsar_production: 'dinsar_runs', dinsar_products: 'dinsar_products', ps_production: 'sbas_insar_production', - ps_products: 'sbas_insar_production', + ps_products: 'sbas_insar_products', }); export const PRODUCTION_WORKSPACE_ROUTE_TABS = new Set([