From 9c80b9538546148a5b36629aedc9f15c1e52b89c Mon Sep 17 00:00:00 2001 From: Harmon Date: Tue, 30 Jun 2026 15:25:29 +0800 Subject: [PATCH] Checkpoint production workflow updates --- .env.example | 30 +- backend/app/config.py | 50 +- backend/app/copier.py | 14 + .../app/isce2_pipeline/lt1_input_resolver.py | 8 +- backend/app/models/schemas.py | 4 + .../run_gamma_scene_preprocess.py | 471 ++++++- backend/app/routers/__init__.py | 2 + backend/app/routers/idl.py | 102 +- backend/app/routers/landsar_lt1_production.py | 485 ++++++++ backend/app/routers/pairing.py | 58 +- backend/app/routers/radar.py | 98 +- backend/app/routers/tasks_runtime.py | 265 +++- .../app/services/asset_inventory_service.py | 88 +- backend/app/services/data_service.py | 16 +- .../app/services/dinsar_production_service.py | 1 + backend/app/services/job_handlers.py | 534 +++++++- backend/app/services/job_queue_service.py | 7 +- backend/app/services/job_worker.py | 30 +- .../landsar_lt1_production_service.py | 1080 +++++++++++++++++ .../app/services/lt_gamma_scene_service.py | 23 +- backend/app/services/pairing_cache_service.py | 81 +- .../services/sar_analysis_ready_service.py | 9 +- .../009_raw_source_pairing_fields.sql | 19 +- .../test_landsar_lt1_production_service.py | 307 +++++ ...DEM_PRODUCTION_SOURCE_CONTRACT_20260628.md | 52 + docs/DEPLOYMENT.md | 4 +- ...SCAPE_NATIVE_TO_GEOTIFF_DESIGN_20260530.md | 2 +- docs/INDEX.md | 8 + ..._SCENE_STACK_PRODUCTION_DESIGN_20260627.md | 642 ++++++++++ ...T1_GEOCODED_GEOTIFF_PRODUCTION_20260628.md | 68 ++ ...ODUCTION_NODE_SUBSYSTEM_DESIGN_20260627.md | 462 +++++++ ...XTRACTION_ACCESS_CONTROL_AUDIT_20260630.md | 219 ++++ ...CE_ORBIT_ASSET_SCAN_OPERATIONS_20260626.md | 9 + frontend/index.html | 6 +- frontend/public/app-icon.png | Bin 0 -> 95494 bytes frontend/src/App.css | 126 ++ frontend/src/App.jsx | 7 +- frontend/src/AssetInventoryPanel.jsx | 4 +- frontend/src/DinsarProductionPanel.jsx | 199 ++- frontend/src/DinsarProductsPanel.jsx | 11 +- frontend/src/IDLAutomationPanel.jsx | 42 +- frontend/src/LandsarLt1ProductionPanel.jsx | 816 +++++++++++++ frontend/src/ProductionWorkspace.jsx | 41 +- frontend/src/api/index.js | 1 + frontend/src/api/landsarLt1Production.js | 22 + frontend/src/api/tasks.js | 1 + frontend/src/components/GlobalTaskCenter.jsx | 62 +- frontend/src/components/app/AppOverlays.jsx | 6 +- .../src/components/app/AppStatusHeader.jsx | 42 +- .../src/components/panels/RadarDataRow.jsx | 6 + frontend/src/config/appConstants.js | 21 +- frontend/src/hooks/useGlobalTaskControl.js | 42 +- frontend/src/hooks/usePairingLogic.js | 42 +- frontend/src/i18n/translations.js | 2 +- frontend/src/panels/PairPlanningPanel.jsx | 18 + frontend/src/panels/PairsListPanel.jsx | 38 +- frontend/src/store/taskStore.js | 2 + nginx/nginx.conf | 13 + scripts/start_app.ps1 | 5 + tools/lt1_data_sync/LT1DataSync.spec | 38 + tools/lt1_data_sync/README.md | 189 +++ tools/lt1_data_sync/build_exe.ps1 | 37 + tools/lt1_data_sync/lt1_data_sync_cli.py | 88 ++ tools/lt1_data_sync/lt1_data_sync_gui.py | 327 +++++ tools/lt1_data_sync/sync_core.py | 316 +++++ tools/lt1_data_sync/test_sync_core.py | 88 ++ 66 files changed, 7639 insertions(+), 267 deletions(-) create mode 100644 backend/app/routers/landsar_lt1_production.py create mode 100644 backend/app/services/landsar_lt1_production_service.py create mode 100644 backend/tests/test_landsar_lt1_production_service.py create mode 100644 docs/DEM_PRODUCTION_SOURCE_CONTRACT_20260628.md create mode 100644 docs/LANDSAR_LT1_SCENE_STACK_PRODUCTION_DESIGN_20260627.md create mode 100644 docs/LT1_GEOCODED_GEOTIFF_PRODUCTION_20260628.md create mode 100644 docs/PRODUCTION_NODE_SUBSYSTEM_DESIGN_20260627.md create mode 100644 docs/RESULT_EXTRACTION_ACCESS_CONTROL_AUDIT_20260630.md create mode 100644 frontend/public/app-icon.png create mode 100644 frontend/src/LandsarLt1ProductionPanel.jsx create mode 100644 frontend/src/api/landsarLt1Production.js create mode 100644 tools/lt1_data_sync/LT1DataSync.spec create mode 100644 tools/lt1_data_sync/README.md create mode 100644 tools/lt1_data_sync/build_exe.ps1 create mode 100644 tools/lt1_data_sync/lt1_data_sync_cli.py create mode 100644 tools/lt1_data_sync/lt1_data_sync_gui.py create mode 100644 tools/lt1_data_sync/sync_core.py create mode 100644 tools/lt1_data_sync/test_sync_core.py diff --git a/.env.example b/.env.example index d4c79f4..73c5477 100644 --- a/.env.example +++ b/.env.example @@ -88,7 +88,7 @@ GF3_STORAGE_DIRS=D:\GaoFen3_Pool\catalog GF3_SARSCAPE_RUNTIME_DIR=D:\GaoFen3_Pool\task_pool\sarscape_runtime GF3_SARSCAPE_WRAPPER_EXE=D:\Code\Insar_management_system_v2\third_party\GF3_L1A_To_L2_pipeline\dist\windows\gf3wrapper.exe GF3_SARSCAPE_IDLRT_PATH=C:\Program Files\Harris\ENVI56\IDL88\bin\bin.x86_64\idlrt.exe -GF3_SARSCAPE_DEM_PATH=D:\DEM\COPDEM_GLO30_China_4326_DEM +GF3_SARSCAPE_DEM_PATH=D:\DEM\SRTMDEM_RSP_SARscape GF3_SARSCAPE_POLARIZATIONS=HH,HV GF3_SARSCAPE_KEEP_EXTRACTED=true GF3_SARSCAPE_AUTO_STANDARDIZE=false @@ -126,9 +126,9 @@ ORBIT_QUARANTINE_DIR= # DEM / 其他数据 # ----------------------------------------------------------------------------- # Raw D-InSAR DEM source for SARscape/ENVI. Keep the base path without the .wgs84 suffix here. -IDL_DINSAR_DEM_BASE_FILE=D:\SRTM30m\SRTMDEM_RSP_SARscape -SRTM_DEM_DIR=D:\SRTM30m -GF3_GEO_DEM_PATH=D:\DEM\gf3_dem.jp2 +IDL_DINSAR_DEM_BASE_FILE=D:\DEM\SRTMDEM_RSP_SARscape +SRTM_DEM_DIR=D:\DEM +GF3_GEO_DEM_PATH=D:\DEM\SRTMDEM_RSP_SARscape_global_int16.tif WATER_RESULTS_DIR=D:\WaterResult GF3_WATER_DEM_PATH= GF3_WATER_DEFAULT_CARTOGRAPHIC=true @@ -137,6 +137,14 @@ SAR_ANALYSIS_READY_ROOT=D:\production_results\sar_analysis_ready SAR_ANALYSIS_WORK_ROOT=D:\production_runtime\sar_analysis_work SAR_ANALYSIS_NODATA_VALUE=-9999 SAR_ANALYSIS_OUTPUT_COG=true +SAR_ANALYSIS_DEM_PATH=D:\DEM\SRTMDEM_RSP_SARscape.wgs84 +SAR_ANALYSIS_TARGET_GRID_SIZE_M=30.0 +SAR_ANALYSIS_DEM_RESOLUTION_M=30.0 +SAR_ANALYSIS_RANGE_LOOKS=6 +SAR_ANALYSIS_AZIMUTH_LOOKS=5 +SAR_ANALYSIS_SPECKLE_FILTER_ENABLED=true +SAR_ANALYSIS_SPECKLE_FILTER_METHOD=lee +SAR_ANALYSIS_SPECKLE_FILTER_SIZE=5 # ----------------------------------------------------------------------------- @@ -188,7 +196,7 @@ ISCE2_PROFILE=lt1_stripmap ISCE2_STRIPMAP_APP=/home/administrator/miniconda3/envs/insar_wsl_v1/lib/python3.11/site-packages/isce/applications/stripmapApp.py ISCE2_PIPELINE_SCRIPT= # ISCE2 should point to the prepared WGS84 DEM after the one-time conversion. -ISCE2_DEM_PATH=D:\SRTM30m\SRTMDEM_RSP_SARscape.wgs84 +ISCE2_DEM_PATH=D:\DEM\SRTMDEM_RSP_SARscape.wgs84 ISCE2_WORK_ROOT=D:\production_runtime\isce2_work ISCE2_OUTPUT_ROOT=D:\production_results\dinsar ISCE2_PER_TASK_TIMEOUT_SECONDS=43200 @@ -245,11 +253,11 @@ PYINT_DEM_ROOT=D:\production_runtime\pyint_dem PYINT_DEM_MODE=local_fabdem PYINT_FABDEM_ROOT= # When PYINT_DEM_MODE=prepared_file, point this to the same prepared WGS84 DEM. -PYINT_PREPARED_DEM_PATH= +PYINT_PREPARED_DEM_PATH=D:\DEM\SRTMDEM_RSP_SARscape.wgs84 # 0 means do not derive looks from a target output grid. PYINT_DEFAULT_TARGET_GRID_SIZE_M=0 # Source DEM resolution recorded in PyINT/Gamma run metadata. -PYINT_DEM_RESOLUTION_M=30.0 +PYINT_DEM_RESOLUTION_M=90.0 PYINT_OPENTOPO_DEM_TYPE=SRTMGL1 PYINT_OPENTOPO_API_KEY= PYINT_DEM_STRICT=true @@ -293,7 +301,7 @@ GAMMA_SBAS_TRIAL_ROOT=D:\production_runtime\gamma_ipta_trials GAMMA_SBAS_SCRIPT_TEMPLATE_ROOT=D:\Code\Insar_management_system_v2\backend\templates\gamma_sbas GAMMA_SBAS_SOURCE_ROOTS=D:\Task_Pool\source_materialized\lutan1 GAMMA_SBAS_ORBIT_ROOTS=D:\orbit_pools\envi -GAMMA_SBAS_DEM_PATH=D:\DEM\HeiLongJiang10M_DEM.tif +GAMMA_SBAS_DEM_PATH=D:\DEM\SRTMDEM_RSP_SARscape.wgs84 GAMMA_SBAS_DEFAULT_RLKS=8 GAMMA_SBAS_DEFAULT_AZLKS=8 GAMMA_SBAS_DEFAULT_MB_MODE=0 @@ -312,7 +320,7 @@ TIMESERIES_WSL_DISTRO=Ubuntu-24.04 TIMESERIES_ENV_NAME=insar_wsl_v1 TIMESERIES_PYTHON=/home/administrator/miniconda3/envs/insar_wsl_v1/bin/python TIMESERIES_WORK_ROOT=D:\production_runtime\timeseries_work -TIMESERIES_DEM_PATH=D:\SRTM30m\SRTMDEM_RSP_SARscape.wgs84 +TIMESERIES_DEM_PATH=D:\DEM\SRTMDEM_RSP_SARscape.wgs84 TIMESERIES_ORBIT_POOL_ISCE2= TIMESERIES_EXPERIMENT_ROOT= TIMESERIES_STACK_PREP_SCRIPT= @@ -350,6 +358,8 @@ CLUSTER_TRANSFER_TIMEOUT_SECONDS=3600 CLUSTER_MAIN_SERVER_URL= # Empty means the worker can claim all job types. Remote LandSAR nodes should set: # JOB_WORKER_ALLOWED_TYPES=LANDSAR_CLUSTER_ITEM +JOB_WORKER_POLL_INTERVAL=1.0 +JOB_WORKER_CONCURRENCY=2 JOB_WORKER_ALLOWED_TYPES= @@ -413,7 +423,7 @@ DEFAULT_VLM_MODEL=qwen3-vl:30b VITE_TILE_SERVER_URL= VITE_TILE_SERVER_TOKEN=change_me VITE_APP_ORG_NAME=黑龙江省自然资源卫星应用技术中心 -VITE_APP_SYSTEM_NAME=InSAR 自动化管理系统 +VITE_APP_SYSTEM_NAME=雷达数据生产管理系统 VITE_APP_SYSTEM_TAGLINE=科研工程生产平台 # Optional override for deployed builds. Empty uses frontend/src/logo.jpg. VITE_APP_LOGO_URL= diff --git a/backend/app/config.py b/backend/app/config.py index a69df4f..cd87ea0 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -252,6 +252,14 @@ class Settings(BaseSettings): SAR_ANALYSIS_WORK_ROOT: str = "" SAR_ANALYSIS_NODATA_VALUE: float = -9999.0 SAR_ANALYSIS_OUTPUT_COG: bool = True + SAR_ANALYSIS_DEM_PATH: str = "" + SAR_ANALYSIS_TARGET_GRID_SIZE_M: float = 30.0 + SAR_ANALYSIS_DEM_RESOLUTION_M: float = 30.0 + SAR_ANALYSIS_RANGE_LOOKS: int = 6 + SAR_ANALYSIS_AZIMUTH_LOOKS: int = 5 + SAR_ANALYSIS_SPECKLE_FILTER_ENABLED: bool = True + SAR_ANALYSIS_SPECKLE_FILTER_METHOD: str = "lee" + SAR_ANALYSIS_SPECKLE_FILTER_SIZE: int = 5 SRTM_DEM_DIR: str = "" GF3_GEO_DEM_PATH: str = "" @@ -318,7 +326,7 @@ class Settings(BaseSettings): ISCE2_WSL_DISTRO: str = "Ubuntu-24.04" ISCE2_PYTHON: str = "/home/administrator/miniconda3/envs/isce2/bin/python" ISCE2_PROFILE: str = "lt1_stripmap" - ISCE2_DEM_PATH: str = "D:\\SRTM30m\\SRTMDEM_RSP_SARscape.wgs84" + ISCE2_DEM_PATH: str = "D:\\DEM\\SRTMDEM_RSP_SARscape.wgs84" ISCE2_WORK_ROOT: str = "" ISCE2_OUTPUT_ROOT: str = "" ISCE2_PER_TASK_TIMEOUT_SECONDS: int = 43200 @@ -355,7 +363,7 @@ class Settings(BaseSettings): PYINT_DEM_MODE: str = "local_fabdem" PYINT_FABDEM_ROOT: str = "" PYINT_PREPARED_DEM_PATH: str = "" - PYINT_DEM_RESOLUTION_M: float = 30.0 + PYINT_DEM_RESOLUTION_M: float = 90.0 PYINT_OPENTOPO_DEM_TYPE: str = "SRTMGL1" PYINT_OPENTOPO_API_KEY: str = "" PYINT_DEM_STRICT: bool = True @@ -423,6 +431,7 @@ class Settings(BaseSettings): JOB_WORKER_STALE_RECOVER_INTERVAL: float = 15.0 JOB_WORKER_STALE_RUNNING_SECONDS: int = 300 JOB_WORKER_HEARTBEAT_INTERVAL: float = 5.0 + JOB_WORKER_CONCURRENCY: int = 1 JOB_WORKER_ALLOWED_TYPES: str = "" TIMESERIES_ENABLED: bool = False @@ -500,6 +509,39 @@ class Settings(BaseSettings): "SAR_ANALYSIS_NODATA_VALUE", float(self.SAR_ANALYSIS_NODATA_VALUE if self.SAR_ANALYSIS_NODATA_VALUE is not None else -9999.0), ) + if not self.SAR_ANALYSIS_DEM_PATH: + object.__setattr__( + self, + "SAR_ANALYSIS_DEM_PATH", + self.GAMMA_SBAS_DEM_PATH + or self.PYINT_PREPARED_DEM_PATH + or self.ISCE2_DEM_PATH + or self.IDL_DINSAR_DEM_BASE_FILE, + ) + object.__setattr__( + self, + "SAR_ANALYSIS_TARGET_GRID_SIZE_M", + max(1.0, float(self.SAR_ANALYSIS_TARGET_GRID_SIZE_M or 30.0)), + ) + object.__setattr__( + self, + "SAR_ANALYSIS_DEM_RESOLUTION_M", + max(1.0, float(self.SAR_ANALYSIS_DEM_RESOLUTION_M or self.SAR_ANALYSIS_TARGET_GRID_SIZE_M or 30.0)), + ) + object.__setattr__(self, "SAR_ANALYSIS_RANGE_LOOKS", max(1, int(self.SAR_ANALYSIS_RANGE_LOOKS or 6))) + object.__setattr__(self, "SAR_ANALYSIS_AZIMUTH_LOOKS", max(1, int(self.SAR_ANALYSIS_AZIMUTH_LOOKS or 5))) + filter_method = str(self.SAR_ANALYSIS_SPECKLE_FILTER_METHOD or "lee").strip().lower() + if filter_method in {"", "0", "false", "none", "off", "disabled", "no"}: + filter_method = "none" + elif filter_method not in {"lee"}: + filter_method = "lee" + if not self.SAR_ANALYSIS_SPECKLE_FILTER_ENABLED: + filter_method = "none" + filter_size = max(3, min(99, int(self.SAR_ANALYSIS_SPECKLE_FILTER_SIZE or 5))) + if filter_size % 2 == 0: + filter_size += 1 + object.__setattr__(self, "SAR_ANALYSIS_SPECKLE_FILTER_METHOD", filter_method) + object.__setattr__(self, "SAR_ANALYSIS_SPECKLE_FILTER_SIZE", filter_size) if not self.SRTM_DEM_DIR: object.__setattr__(self, "SRTM_DEM_DIR", os.path.join(backend_dir, "dem_data")) object.__setattr__(self, "ASSET_SCAN_PARSE_WORKERS", max(1, int(self.ASSET_SCAN_PARSE_WORKERS or 1))) @@ -514,6 +556,7 @@ class Settings(BaseSettings): max(60, int(self.ASSET_SCAN_PARSE_TIMEOUT_SECONDS or 600)), ) object.__setattr__(self, "ASSET_SCAN_DB_BATCH_SIZE", max(1, int(self.ASSET_SCAN_DB_BATCH_SIZE or 1))) + object.__setattr__(self, "JOB_WORKER_CONCURRENCY", max(1, int(self.JOB_WORKER_CONCURRENCY or 1))) if not self.GF3_ARCHIVE_SOURCE_DIRS: object.__setattr__( self, @@ -725,7 +768,7 @@ class Settings(BaseSettings): if pyint_dem_mode not in {"local_fabdem", "opentopo", "prepared_file"}: pyint_dem_mode = "local_fabdem" object.__setattr__(self, "PYINT_DEM_MODE", pyint_dem_mode) - object.__setattr__(self, "PYINT_DEM_RESOLUTION_M", max(0.1, float(self.PYINT_DEM_RESOLUTION_M or 30.0))) + object.__setattr__(self, "PYINT_DEM_RESOLUTION_M", max(0.1, float(self.PYINT_DEM_RESOLUTION_M or 90.0))) object.__setattr__( self, "PYINT_UNWRAP_COH_THRESHOLD", @@ -1291,6 +1334,7 @@ def validate_runtime_config() -> dict[str, Any]: _check_path(label="WATER_RESULTS_DIR", value=settings.WATER_RESULTS_DIR, errors=errors, warnings=warnings, expect_file=False) _check_path(label="SAR_ANALYSIS_READY_ROOT", value=settings.SAR_ANALYSIS_READY_ROOT, errors=errors, warnings=warnings, expect_file=False) _check_path(label="SAR_ANALYSIS_WORK_ROOT", value=settings.SAR_ANALYSIS_WORK_ROOT, errors=errors, warnings=warnings, expect_file=False) + _check_path(label="SAR_ANALYSIS_DEM_PATH", value=settings.SAR_ANALYSIS_DEM_PATH, errors=errors, warnings=warnings, expect_file=True) _check_path(label="MONITOR_ORBIT_DIR", value=settings.MONITOR_ORBIT_DIR, errors=errors, warnings=warnings, expect_file=False) for label, value in ( ("SOURCE_PRODUCT_DIRS", settings.SOURCE_PRODUCT_DIRS), diff --git a/backend/app/copier.py b/backend/app/copier.py index 920d7f2..a782d6e 100644 --- a/backend/app/copier.py +++ b/backend/app/copier.py @@ -305,6 +305,10 @@ def _normalize_source_bundle_archive_path(source_path: str) -> str: def _safe_archive_member_name(member_name: str, archive_path: str) -> str: name = str(member_name or "").replace("\\", "/").strip("/") + while name.startswith("./"): + name = name[2:] + if name in {"", "."}: + return "" if not name or name.startswith("../") or "/../" in f"/{name}/": raise ValueError(f"Unsafe archive member path in {archive_path}: {member_name}") if os.path.isabs(name) or os.path.splitdrive(name)[0]: @@ -318,6 +322,11 @@ def _extract_archive_to_dir(archive_path: str, dest_dir: str) -> int: with zipfile.ZipFile(archive_path) as zip_obj: for info in zip_obj.infolist(): rel_name = _safe_archive_member_name(info.filename, archive_path) + if not rel_name: + if info.is_dir(): + os.makedirs(dest_dir, exist_ok=True) + continue + raise ValueError(f"Unsafe ZIP member path: {info.filename}") dest_path = os.path.abspath(os.path.join(dest_dir, rel_name)) if not dest_path.startswith(os.path.abspath(dest_dir) + os.sep): raise ValueError(f"Unsafe ZIP member path: {info.filename}") @@ -335,6 +344,11 @@ def _extract_archive_to_dir(archive_path: str, dest_dir: str) -> int: with tarfile.open(archive_path, "r:*") as tar_obj: for member in tar_obj: rel_name = _safe_archive_member_name(member.name, archive_path) + if not rel_name: + if member.isdir(): + os.makedirs(dest_dir, exist_ok=True) + continue + raise ValueError(f"Unsafe TAR member path: {member.name}") dest_path = os.path.abspath(os.path.join(dest_dir, rel_name)) if not dest_path.startswith(os.path.abspath(dest_dir) + os.sep): raise ValueError(f"Unsafe TAR member path: {member.name}") diff --git a/backend/app/isce2_pipeline/lt1_input_resolver.py b/backend/app/isce2_pipeline/lt1_input_resolver.py index 33f047e..fe71204 100644 --- a/backend/app/isce2_pipeline/lt1_input_resolver.py +++ b/backend/app/isce2_pipeline/lt1_input_resolver.py @@ -31,12 +31,12 @@ PathTransform = Callable[[str | Path], Path] DEFAULT_WINDOWS_DEM_CANDIDATES = ( - r"D:\SRTM30m\SRTMDEM_RSP_SARscape.wgs84", - r"D:\SRTM30m\SRTMDEM_RSP_SARscape", + r"D:\DEM\SRTMDEM_RSP_SARscape.wgs84", + r"D:\DEM\SRTMDEM_RSP_SARscape", ) DEFAULT_WSL_DEM_CANDIDATES = ( - "/mnt/d/SRTM30m/SRTMDEM_RSP_SARscape.wgs84", - "/mnt/d/SRTM30m/SRTMDEM_RSP_SARscape", + "/mnt/d/DEM/SRTMDEM_RSP_SARscape.wgs84", + "/mnt/d/DEM/SRTMDEM_RSP_SARscape", ) DEFAULT_WINDOWS_ORBIT_POOL_CANDIDATES = (r"D:\orbit_pools\isce2",) DEM_SIDECAR_PROPERTY_NAMES = ("file_name", "metadata_location", "extra_file_name") diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index eb8b0c7..e628218 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -231,6 +231,10 @@ class RadarData(BaseModel): stack_selection_mode: Optional[str] = None stack_network_edge_count: Optional[int] = None stack_network_warnings: Optional[List[str]] = None + lt1_image_produced: bool = False + lt1_image_product: Optional[Dict[str, Any]] = None + lt1_landsar_produced: bool = False + lt1_landsar_product: Optional[Dict[str, Any]] = None model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/pyint_pipeline/run_gamma_scene_preprocess.py b/backend/app/pyint_pipeline/run_gamma_scene_preprocess.py index d51e682..d0ed7c8 100644 --- a/backend/app/pyint_pipeline/run_gamma_scene_preprocess.py +++ b/backend/app/pyint_pipeline/run_gamma_scene_preprocess.py @@ -2,7 +2,7 @@ """Single-scene Gamma preprocessing to analysis-ready GeoTIFF. The script is intentionally narrower than the full PyINT DInSAR pipeline: -LT source product -> Gamma SLC -> multilook amplitude -> geocode -> GeoTIFF. +LT source product -> Gamma SLC -> multilook amplitude -> geocode -> speckle-filtered dB GeoTIFF. It is executed inside WSL by backend.app.services.lt_gamma_scene_service. """ from __future__ import annotations @@ -27,6 +27,10 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--pyint-home", required=True) parser.add_argument("--dem-root", required=True) parser.add_argument("--prepared-dem-path", default="") + parser.add_argument("--dem-resolution-m", type=float, default=30.0) + parser.add_argument("--target-grid-size-m", type=float, default=30.0) + parser.add_argument("--dem-lat-ovr", type=float, default=0.0) + parser.add_argument("--dem-lon-ovr", type=float, default=0.0) parser.add_argument("--project-name", required=True) parser.add_argument("--date", required=True) parser.add_argument("--satellite-family", default="LT1") @@ -35,9 +39,175 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--geo-interp", default="1") parser.add_argument("--nodata-value", type=float, default=-9999.0) parser.add_argument("--to-db", action="store_true") + parser.add_argument("--speckle-filter-method", default="lee") + parser.add_argument("--speckle-filter-size", type=int, default=5) + parser.add_argument("--speckle-filter-enl", type=float, default=0.0) return parser.parse_args() +def clamp_float(value: float, minimum: float, maximum: float) -> float: + if not math.isfinite(value): + return minimum + return min(maximum, max(minimum, float(value))) + + +def format_gamma_number(value: float) -> str: + text = f"{float(value):.6f}".rstrip("0").rstrip(".") + return text or "0" + + +def calculate_dem_oversampling( + *, + dem_resolution_m: float, + target_grid_size_m: float, + dem_lat_ovr: float, + dem_lon_ovr: float, +) -> dict[str, Any]: + dem_resolution = float(dem_resolution_m or 30.0) + target_grid = float(target_grid_size_m or 30.0) + if dem_resolution <= 0: + dem_resolution = 30.0 + if target_grid <= 0: + target_grid = dem_resolution + + derived = dem_resolution / target_grid + lat_factor = clamp_float(float(dem_lat_ovr or derived), 0.25, 16.0) + lon_factor = clamp_float(float(dem_lon_ovr or derived), 0.25, 16.0) + actual_grid = dem_resolution / ((lat_factor + lon_factor) / 2.0) + return { + "dem_resolution_m": dem_resolution, + "target_grid_size_m": target_grid, + "derived_oversampling": derived, + "dem_lat_ovr": lat_factor, + "dem_lon_ovr": lon_factor, + "actual_grid_size_m": actual_grid, + } + + +def meters_per_degree_lon(latitude_deg: float) -> float: + latitude_rad = math.radians(float(latitude_deg)) + return max(1.0, 111_320.0 * math.cos(latitude_rad)) + + +def inspect_prepared_dem_path(path_text: str) -> dict[str, str]: + text = str(path_text or "").strip() + if not text: + return {"kind": "", "direct_dem_path": "", "source_dem_path": ""} + + path = Path(text) + try: + resolved = path.resolve() + except Exception: + resolved = path + + if resolved.is_file() and Path(str(resolved) + ".par").is_file(): + return {"kind": "gamma_ready", "direct_dem_path": str(resolved), "source_dem_path": ""} + if resolved.is_file(): + return {"kind": "source_dem", "direct_dem_path": "", "source_dem_path": str(resolved)} + return {"kind": "", "direct_dem_path": "", "source_dem_path": str(resolved)} + + +def read_slc_bbox( + pyint_home: Path, + slc_par: Path, + env: dict[str, str], + *, + margin_deg: float = 0.1, +) -> tuple[float, float, float, float]: + result = subprocess.run( + ["SLC_corners", str(slc_par)], + cwd=str(pyint_home), + env=env, + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + detail = (result.stderr or result.stdout or "").strip() + raise RuntimeError(f"SLC_corners failed rc={result.returncode}: {detail}") + lines = result.stdout.splitlines() + if len(lines) < 10: + raise RuntimeError(f"Unexpected SLC_corners output for {slc_par}") + lat_line = lines[8].rstrip() + lon_line = lines[9].rstrip() + min_lat = float(lat_line.split(":")[1].split(" max. ")[0]) + max_lat = float(lat_line.split(":")[2]) + min_lon = float(lon_line.split(":")[1].split(" max. ")[0]) + max_lon = float(lon_line.split(":")[2]) + margin = max(0.0, float(margin_deg or 0.0)) + return min_lon - margin, min_lat - margin, max_lon + margin, max_lat + margin + + +def build_gamma_dem_from_source( + *, + source_dem: Path, + target_base: Path, + slc_par: Path, + pyint_home: Path, + log_dir: Path, + env: dict[str, str], +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + west, south, east, north = read_slc_bbox(pyint_home, slc_par, env) + log_dir.mkdir(parents=True, exist_ok=True) + source_open = Path(str(source_dem) + ".vrt") if Path(str(source_dem) + ".vrt").is_file() else source_dem + clipped_tif = target_base.with_suffix(".prepared_source_clip.tif") + clipped_aux = Path(str(clipped_tif) + ".aux.xml") + commands: list[dict[str, Any]] = [] + commands.append(run_logged( + [ + "gdal_translate", + "-projwin", + str(west), + str(north), + str(east), + str(south), + "-of", + "GTiff", + str(source_open), + str(clipped_tif), + ], + cwd=target_base.parent, + env=env, + log_dir=log_dir, + stage="clip_prepared_dem", + )) + commands.append(run_logged( + [ + "makedem.py", + "-d", + str(clipped_tif), + "-p", + "gamma", + "-o", + str(target_base), + ], + cwd=target_base.parent, + env=env, + log_dir=log_dir, + stage="convert_prepared_dem", + )) + for path in (clipped_tif, clipped_aux): + try: + if path.exists(): + path.unlink() + except OSError: + pass + dem_path = Path(str(target_base) + ".dem") + dem_par_path = Path(str(target_base) + ".dem.par") + if not dem_path.is_file() or not dem_par_path.is_file(): + raise RuntimeError(f"Prepared source DEM conversion did not create Gamma DEM: {dem_path}") + return ( + { + "kind": "source_dem_converted", + "source_dem_path": str(source_dem), + "source_open_path": str(source_open), + "gamma_dem_path": str(dem_path), + "bbox": {"west": west, "south": south, "east": east, "north": north}, + }, + commands, + ) + + def run_logged(command: list[str], *, cwd: Path, env: dict[str, str], log_dir: Path, stage: str) -> dict[str, Any]: log_dir.mkdir(parents=True, exist_ok=True) stdout_path = log_dir / f"{stage}.stdout.log" @@ -74,6 +244,48 @@ def read_gamma_par(path: Path, key: str) -> str: raise KeyError(f"Cannot read {key} from {path}") +def calculate_dem_oversampling_from_gamma_dem( + *, + dem_par_path: Path, + target_grid_size_m: float, + explicit_dem_lat_ovr: float, + explicit_dem_lon_ovr: float, +) -> dict[str, Any]: + target_grid = max(1.0, float(target_grid_size_m or 30.0)) + post_lat_deg = abs(float(read_gamma_par(dem_par_path, "post_lat"))) + post_lon_deg = abs(float(read_gamma_par(dem_par_path, "post_lon"))) + corner_lat = float(read_gamma_par(dem_par_path, "corner_lat")) + nlines = int(float(read_gamma_par(dem_par_path, "nlines"))) + center_lat = corner_lat - (post_lat_deg * max(0, nlines - 1) / 2.0) + + lat_spacing_m = post_lat_deg * 111_320.0 + lon_spacing_m = post_lon_deg * meters_per_degree_lon(center_lat) + derived_lat = lat_spacing_m / target_grid + derived_lon = lon_spacing_m / target_grid + lat_factor = clamp_float(float(explicit_dem_lat_ovr or derived_lat), 0.25, 16.0) + lon_factor = clamp_float(float(explicit_dem_lon_ovr or derived_lon), 0.25, 16.0) + actual_lat_m = lat_spacing_m / lat_factor + actual_lon_m = lon_spacing_m / lon_factor + return { + "dem_resolution_m": (lat_spacing_m + lon_spacing_m) / 2.0, + "target_grid_size_m": target_grid, + "derived_oversampling": (derived_lat + derived_lon) / 2.0, + "derived_dem_lat_ovr": derived_lat, + "derived_dem_lon_ovr": derived_lon, + "dem_lat_ovr": lat_factor, + "dem_lon_ovr": lon_factor, + "actual_grid_size_m": (actual_lat_m + actual_lon_m) / 2.0, + "actual_lat_grid_size_m": actual_lat_m, + "actual_lon_grid_size_m": actual_lon_m, + "source_dem_post_lat_deg": post_lat_deg, + "source_dem_post_lon_deg": post_lon_deg, + "source_dem_lat_spacing_m": lat_spacing_m, + "source_dem_lon_spacing_m": lon_spacing_m, + "source_dem_center_lat": center_lat, + "source_dem_par_path": str(dem_par_path), + } + + def discover_lt_inputs(source_path: Path, date: str) -> list[Path]: patterns = [f"LT1*{date}*.tar.gz", f"LT1*{date}*.tiff", f"LT1*{date}*.tif"] if source_path.is_file(): @@ -119,15 +331,18 @@ def write_template( range_looks: int, azimuth_looks: int, geo_interp: str, - prepared_dem_path: str, + dem_path: str, + prepared_dem_source: str, + dem_oversampling: dict[str, Any], ) -> None: lines = [ "satelite = LT", f"masterDate = {date}", f"range_looks = {range_looks}", f"azimuth_looks = {azimuth_looks}", - "dem_lat_ovr = 0.5", - "dem_lon_ovr = 0.5", + f"target_grid_size_m = {format_gamma_number(float(dem_oversampling.get('target_grid_size_m') or 0.0))}", + f"dem_lat_ovr = {format_gamma_number(float(dem_oversampling.get('dem_lat_ovr') or 1.0))}", + f"dem_lon_ovr = {format_gamma_number(float(dem_oversampling.get('dem_lon_ovr') or 1.0))}", "Simphase_rpos = -", "Simphase_azpos = -", "Simphase_rwin = 256", @@ -135,24 +350,153 @@ def write_template( "Simphase_thresh = -", f"geo_interp = {geo_interp}", ] - dem = str(prepared_dem_path or "").strip() + dem = str(dem_path or "").strip() if dem and Path(dem).is_file() and Path(dem + ".par").is_file(): lines.append(f"DEM = {dem}") + source = str(prepared_dem_source or "").strip() + if source: + lines.append(f"prepared_dem_source = {source}") template_path.parent.mkdir(parents=True, exist_ok=True) template_path.write_text("\n".join(lines) + "\n", encoding="utf-8") -def convert_to_db_geotiff(source_tif: Path, target_tif: Path, nodata_value: float) -> dict[str, Any]: +def normalize_speckle_filter_method(method: str) -> str: + text = str(method or "").strip().lower() + if text in {"", "0", "false", "none", "off", "disabled", "no"}: + return "none" + if text in {"lee", "lee_filter"}: + return "lee" + raise ValueError(f"Unsupported speckle filter method: {method}") + + +def normalize_speckle_filter_size(size: int | float | str) -> int: + try: + value = int(float(size or 5)) + except Exception: + value = 5 + value = max(3, min(99, value)) + if value % 2 == 0: + value += 1 + return value + + +def moving_sum_axis(values: Any, size: int, axis: int) -> Any: + import numpy as np + + radius = size // 2 + pad_width = [(0, 0)] * values.ndim + pad_width[axis] = (radius, size - 1 - radius) + padded = np.pad(values, pad_width, mode="edge") + cumulative = np.cumsum(padded, axis=axis, dtype="float64") + zero_shape = list(cumulative.shape) + zero_shape[axis] = 1 + cumulative = np.concatenate([np.zeros(zero_shape, dtype="float64"), cumulative], axis=axis) + length = values.shape[axis] + start = np.arange(0, length) + end = np.arange(size, size + length) + return np.take(cumulative, end, axis=axis) - np.take(cumulative, start, axis=axis) + + +def box_sum(values: Any, size: int) -> Any: + return moving_sum_axis(moving_sum_axis(values, size, axis=0), size, axis=1) + + +def local_power_stats(data: Any, valid: Any, window_size: int) -> tuple[Any, Any, Any]: + import numpy as np + + values = np.where(valid, data, 0.0).astype("float64", copy=False) + weights = valid.astype("float64", copy=False) + count = box_sum(weights, window_size) + power_sum = box_sum(values, window_size) + power_sq_sum = box_sum(values * values, window_size) + mean = np.divide(power_sum, count, out=np.zeros_like(power_sum), where=count > 0) + mean_sq = np.divide(power_sq_sum, count, out=np.zeros_like(power_sq_sum), where=count > 0) + variance = np.maximum(mean_sq - mean * mean, 0.0) + valid_fraction = count / float(window_size * window_size) + return mean, variance, valid_fraction + + +def apply_speckle_filter_power( + data: Any, + invalid: Any, + *, + method: str, + window_size: int, + equivalent_number_of_looks: float = 0.0, +) -> tuple[Any, dict[str, Any]]: + import numpy as np + + normalized_method = normalize_speckle_filter_method(method) + normalized_size = normalize_speckle_filter_size(window_size) + record: dict[str, Any] = { + "enabled": normalized_method != "none", + "method": normalized_method, + "window_size": normalized_size, + "equivalent_number_of_looks": float(equivalent_number_of_looks or 0.0), + "domain": "linear_power", + } + if normalized_method == "none": + return data, record + + valid = ~invalid + valid_count = int(np.count_nonzero(valid)) + record["valid_pixels"] = valid_count + if valid_count == 0: + record["enabled"] = False + record["warning"] = "no valid positive pixels to filter" + return data, record + + local_mean, local_variance, valid_fraction = local_power_stats(data, valid, normalized_size) + stats_mask = valid & np.isfinite(local_variance) & np.isfinite(local_mean) & (valid_fraction > 0.0) + enl = float(equivalent_number_of_looks or 0.0) + if math.isfinite(enl) and enl > 0: + noise_variance = np.maximum((local_mean * local_mean) / enl, 0.0) + weight = np.divide( + np.maximum(local_variance - noise_variance, 0.0), + local_variance, + out=np.zeros_like(local_variance), + where=local_variance > 0, + ) + record["noise_variance_model"] = "local_mean_squared_over_enl" + else: + noise_samples = local_variance[stats_mask] + global_noise_variance = float(np.nanmedian(noise_samples)) if noise_samples.size else 0.0 + if not math.isfinite(global_noise_variance) or global_noise_variance <= 0: + record["enabled"] = False + record["warning"] = "local variance estimate is zero; kept unfiltered power values" + return data, record + noise_variance = global_noise_variance + weight = np.divide( + local_variance, + local_variance + noise_variance, + out=np.zeros_like(local_variance), + where=(local_variance + noise_variance) > 0, + ) + record["noise_variance"] = global_noise_variance + record["noise_variance_model"] = "global_median_local_variance" + filtered = local_mean + weight * (data.astype("float64", copy=False) - local_mean) + filtered = np.where(np.isfinite(filtered) & (filtered > 0), filtered, data) + output = data.astype("float32", copy=True) + output[stats_mask] = filtered[stats_mask].astype("float32") + return output, record + + +def convert_to_db_geotiff( + source_tif: Path, + target_tif: Path, + nodata_value: float, + *, + speckle_filter_method: str = "none", + speckle_filter_size: int = 5, + speckle_filter_enl: float = 0.0, +) -> dict[str, Any]: + filter_method = normalize_speckle_filter_method(speckle_filter_method) + filter_size = normalize_speckle_filter_size(speckle_filter_size) try: import numpy as np import rasterio except Exception as exc: - shutil.copy2(source_tif, target_tif) - return { - "target": str(target_tif), - "backscatter_unit": "gamma_mli_power", - "warning": f"rasterio/numpy unavailable; kept power values: {exc}", - } + raise RuntimeError(f"rasterio/numpy unavailable; cannot create filtered dB GeoTIFF: {exc}") from exc with rasterio.open(source_tif) as src: data = src.read(1).astype("float32") @@ -163,14 +507,23 @@ def convert_to_db_geotiff(source_tif: Path, target_tif: Path, nodata_value: floa if src_nodata is not None: invalid |= data == src_nodata invalid |= data <= 0 + filtered_data, speckle_filter = apply_speckle_filter_power( + data, + invalid, + method=filter_method, + window_size=filter_size, + equivalent_number_of_looks=float(speckle_filter_enl or 0.0), + ) + invalid |= ~np.isfinite(filtered_data) + invalid |= filtered_data <= 0 db_data = np.full(data.shape, nodata_value, dtype="float32") - db_data[~invalid] = (10.0 * np.log10(data[~invalid])).astype("float32") + db_data[~invalid] = (10.0 * np.log10(filtered_data[~invalid])).astype("float32") profile.update(dtype="float32", count=1, nodata=nodata_value, compress="deflate") target_tif.parent.mkdir(parents=True, exist_ok=True) with rasterio.open(target_tif, "w", **profile) as dst: dst.write(db_data, 1) - return {"target": str(target_tif), "backscatter_unit": "gamma_mli_db"} + return {"target": str(target_tif), "backscatter_unit": "gamma_mli_db", "speckle_filter": speckle_filter} def main() -> int: @@ -201,6 +554,18 @@ def main() -> int: env["PATH"] = f"{pyint_home / 'pyint'}:{env.get('PATH', '')}" staged_inputs = stage_lt_inputs(source_path, download_dir, date) + dem_oversampling = calculate_dem_oversampling( + dem_resolution_m=float(args.dem_resolution_m or 30.0), + target_grid_size_m=float(args.target_grid_size_m or 30.0), + dem_lat_ovr=float(args.dem_lat_ovr or 0.0), + dem_lon_ovr=float(args.dem_lon_ovr or 0.0), + ) + prepared_dem = inspect_prepared_dem_path(args.prepared_dem_path) + if not prepared_dem.get("kind"): + raise RuntimeError(f"A prepared DEM is required for LT analysis GeoTIFF production: {args.prepared_dem_path}") + + dem_path = prepared_dem.get("direct_dem_path") or "" + prepared_dem_conversion: dict[str, Any] | None = None template_path = template_dir / f"{project_name}.template" write_template( template_path=template_path, @@ -208,7 +573,9 @@ def main() -> int: range_looks=max(1, int(args.range_looks)), azimuth_looks=max(1, int(args.azimuth_looks)), geo_interp=str(args.geo_interp or "1"), - prepared_dem_path=args.prepared_dem_path, + dem_path=dem_path, + prepared_dem_source=str(prepared_dem.get("source_dem_path") or ""), + dem_oversampling=dem_oversampling, ) commands: list[dict[str, Any]] = [] @@ -221,6 +588,44 @@ def main() -> int: stage="down2slc_lt1", ) ) + + if prepared_dem.get("kind") == "source_dem": + slc_par = project_dir / "SLC" / date / f"{date}.slc.par" + if not slc_par.is_file(): + raise FileNotFoundError(f"Gamma SLC parameter file missing before DEM conversion: {slc_par}") + dem_target_base = dem_root / project_name / project_name + dem_target_base.parent.mkdir(parents=True, exist_ok=True) + prepared_dem_conversion, dem_commands = build_gamma_dem_from_source( + source_dem=Path(str(prepared_dem.get("source_dem_path"))), + target_base=dem_target_base, + slc_par=slc_par, + pyint_home=pyint_home, + log_dir=log_dir, + env=env, + ) + commands.extend(dem_commands) + dem_path = str(Path(str(dem_target_base) + ".dem")) + + dem_par_path = Path(str(dem_path) + ".par") if dem_path else Path() + if dem_path and dem_par_path.is_file(): + dem_oversampling = calculate_dem_oversampling_from_gamma_dem( + dem_par_path=dem_par_path, + target_grid_size_m=float(args.target_grid_size_m or 30.0), + explicit_dem_lat_ovr=float(args.dem_lat_ovr or 0.0), + explicit_dem_lon_ovr=float(args.dem_lon_ovr or 0.0), + ) + + write_template( + template_path=template_path, + date=date, + range_looks=max(1, int(args.range_looks)), + azimuth_looks=max(1, int(args.azimuth_looks)), + geo_interp=str(args.geo_interp or "1"), + dem_path=dem_path, + prepared_dem_source=str(prepared_dem.get("source_dem_path") or ""), + dem_oversampling=dem_oversampling, + ) + commands.append( run_logged( [sys.executable, str(pyint_home / "pyint" / "generate_rdc_dem.py"), project_name], @@ -286,9 +691,25 @@ def main() -> int: raise RuntimeError(f"data2geotiff did not create output: {power_tif}") final_tif = output_dir / "analysis_ready.tif" - conversion = convert_to_db_geotiff(power_tif, final_tif, float(args.nodata_value)) if args.to_db else { + speckle_filter_config = { + "method": normalize_speckle_filter_method(args.speckle_filter_method), + "window_size": normalize_speckle_filter_size(args.speckle_filter_size), + } + conversion = convert_to_db_geotiff( + power_tif, + final_tif, + float(args.nodata_value), + speckle_filter_method=args.speckle_filter_method, + speckle_filter_size=args.speckle_filter_size, + speckle_filter_enl=float(args.speckle_filter_enl or (range_looks * max(1, int(args.azimuth_looks)))), + ) if args.to_db else { "target": str(final_tif), "backscatter_unit": "gamma_mli_power", + "speckle_filter": { + "enabled": False, + **speckle_filter_config, + "warning": "not applied because --to-db was disabled", + }, } if not args.to_db: shutil.copy2(power_tif, final_tif) @@ -313,6 +734,24 @@ def main() -> int: "geo_amp": str(geo_amp), }, "looks": {"range": range_looks, "azimuth": max(1, int(args.azimuth_looks))}, + "speckle_filter": conversion.get("speckle_filter"), + "processing_steps": { + "multilook": { + "enabled": True, + "range_looks": range_looks, + "azimuth_looks": max(1, int(args.azimuth_looks)), + }, + "geocode": {"enabled": True, "interpolation": str(args.geo_interp or "1")}, + "speckle_filter": conversion.get("speckle_filter"), + "db_conversion": {"enabled": bool(args.to_db), "unit": conversion.get("backscatter_unit")}, + }, + "dem": { + "prepared_dem_path": str(args.prepared_dem_path or "").strip(), + "prepared_dem_kind": prepared_dem.get("kind"), + "gamma_dem_path": dem_path, + "conversion": prepared_dem_conversion, + "oversampling": dem_oversampling, + }, "commands": commands, "conversion": conversion, } diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py index 11ce453..eae33af 100644 --- a/backend/app/routers/__init__.py +++ b/backend/app/routers/__init__.py @@ -14,6 +14,7 @@ from . import ( hazard, health, idl, + landsar_lt1_production, license, logs, monitor, @@ -55,6 +56,7 @@ def include_all_routers(router: APIRouter) -> None: router.include_router(dinsar.router) router.include_router(dinsar_products.router) router.include_router(dinsar_production.router) + router.include_router(landsar_lt1_production.router) router.include_router(sbas_insar_production.router) router.include_router(sbas_insar_products.router) router.include_router(timeseries_production.router) diff --git a/backend/app/routers/idl.py b/backend/app/routers/idl.py index e5d2f2f..4e991bb 100644 --- a/backend/app/routers/idl.py +++ b/backend/app/routers/idl.py @@ -16,7 +16,6 @@ from .dependencies import _require_admin, _get_current_user, _validate_export_pa from ..models import AuthUserORM from ..services import envi_service from ..services.job_queue_service import job_queue_service -from ..services.result_catalog_service import result_catalog_service from ..services.task_service import task_service router = APIRouter() @@ -61,38 +60,6 @@ class SarscapeSbasInspectRequest(BaseModel): timeout_seconds: Optional[int] = Field(default=120, ge=10, le=600) -def _normalize_existing_dir(path: Optional[str]) -> Optional[str]: - text = str(path or "").strip() - if not text: - return None - normalized = os.path.normpath(os.path.abspath(text)) - if not os.path.isdir(normalized): - return None - return normalized - - -def _dedupe_publish_roots(*paths: Optional[str]) -> list[str]: - ordered: list[str] = [] - for raw_path in paths: - normalized = _normalize_existing_dir(raw_path) - if not normalized: - continue - - if any( - normalized == existing or normalized.startswith(existing + os.sep) - for existing in ordered - ): - continue - - ordered = [ - existing - for existing in ordered - if not existing.startswith(normalized + os.sep) - ] - ordered.append(normalized) - return ordered - - # --------------------------------------------------------------------------- # Job queue helper # --------------------------------------------------------------------------- @@ -292,7 +259,7 @@ async def get_task_overview_endpoint( return result -@router.post("/idl/extract-disp") +@router.post("/idl/extract-disp", status_code=202) async def extract_disp_endpoint( request: ExtractDispRequest, admin_user: AuthUserORM = Depends(_require_admin), @@ -303,52 +270,29 @@ async def extract_disp_endpoint( if request.dest_dir: _validate_export_path(request.dest_dir, "dest_dir") try: - result = await asyncio.to_thread( - envi_service.extract_disp_results, request.root_dir, request.dest_dir + payload = { + "root_dir": request.root_dir, + "dest_dir": request.dest_dir, + } + task_id = await task_service.create_task( + "EXTRACT_DINSAR_PRODUCTS", + "D-InSAR 结果提取与登记", + params=payload, + db=db, ) + job_id = await job_queue_service.create_job( + "EXTRACT_DINSAR_PRODUCTS", + payload=payload, + task_id=task_id, + db=db, + ) + await db.commit() except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc + raise HTTPException(status_code=409, detail=str(exc)) from exc - publish_roots = _dedupe_publish_roots(result.get("target_dir")) - catalog_status: Dict[str, Any] = { - "attempted": False, - "status": "skipped", - "source_directories": publish_roots, - "message": "catalog publish skipped", + return { + "queued": True, + "task_id": task_id, + "job_id": job_id, + "message": "D-InSAR 结果提取与登记任务已入队", } - if publish_roots: - try: - catalog_status["attempted"] = True - publish_result = await result_catalog_service.publish_from_sources( - db, - publish_roots, - ) - rebuild_result = None - if int(publish_result.get("processed", 0) or 0) > 0: - rebuild_result = await result_catalog_service.rebuild_catalog( - db, - full_rebuild=True, - ) - catalog_status = { - "attempted": True, - "status": "ok", - "source_directories": publish_roots, - "publish": publish_result, - "rebuild": rebuild_result, - "message": ( - "catalog published and rebuilt" - if rebuild_result is not None - else "catalog publish finished with no rebuild needed" - ), - } - except Exception as exc: - await db.rollback() - catalog_status = { - "attempted": True, - "status": "error", - "source_directories": publish_roots, - "message": str(exc), - } - - result["catalog"] = catalog_status - return result diff --git a/backend/app/routers/landsar_lt1_production.py b/backend/app/routers/landsar_lt1_production.py new file mode 100644 index 0000000..fb0d321 --- /dev/null +++ b/backend/app/routers/landsar_lt1_production.py @@ -0,0 +1,485 @@ +from __future__ import annotations + +import os +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field, field_validator +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..database import get_db +from ..models import AuthUserORM, RadarDataORM, SARSceneGeoORM +from ..services.job_handlers import JOB_TYPE_SAR_SCENE_PREPROCESS +from ..services.job_queue_service import job_queue_service +from ..services.landsar_lt1_production_service import landsar_lt1_production_service +from ..services.task_service import task_service +from ..utils import normalize_satellite_family +from .dependencies import _add_operation_audit_log, _get_current_user, _require_admin + + +router = APIRouter() +STATIC_ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"} + + +class LandsarLt1ImageProductionRequest(BaseModel): + source_asset_ids: List[int] = Field(default_factory=list) + radar_data_ids: List[int] = Field(default_factory=list) + mode: str = "scene" + task_name: Optional[str] = None + + @field_validator("mode") + @classmethod + def _validate_mode(cls, value): + mode = str(value or "scene").strip().lower() + if mode == "stack": + mode = "batch" + if mode not in {"scene", "batch"}: + raise ValueError("mode must be scene or batch") + return mode + + +def _dedupe_positive_ids(values: List[int]) -> List[int]: + result: List[int] = [] + for value in values or []: + try: + parsed = int(value) + except (TypeError, ValueError): + continue + if parsed > 0 and parsed not in result: + result.append(parsed) + return result + + +def _scene_product_marker(scene: SARSceneGeoORM) -> Dict[str, Any]: + return { + "scene_id": scene.id, + "radar_data_id": scene.radar_data_id, + "product_id": f"sar_scene_geo:{scene.id}", + "product_family": "lt1_analysis_ready_geotiff", + "engine_code": scene.analysis_engine, + "profile_code": scene.analysis_profile, + "analysis_tif_path": scene.analysis_tif_path, + "analysis_dir": scene.analysis_dir, + "analysis_preview_path": scene.analysis_preview_path, + "status": scene.status, + "published_at": scene.updated_at.isoformat() if scene.updated_at else None, + } + + +def _scene_asset_items(scene: SARSceneGeoORM) -> List[Dict[str, Any]]: + candidates = [ + (1, "analysis_tif", "analysis_ready.tif", scene.analysis_tif_path, "image/tiff", True), + (2, "preview", "preview.png", scene.analysis_preview_path, "image/png", False), + ] + metadata = scene.analysis_metadata_json if isinstance(scene.analysis_metadata_json, dict) else {} + manifest_path = str(metadata.get("manifest_path") or "").strip() + if manifest_path: + candidates.append((3, "manifest", "manifest.json", manifest_path, "application/json", False)) + if scene.analysis_dir: + quality_path = os.path.join(scene.analysis_dir, "quality.json") + candidates.append((4, "quality", "quality.json", quality_path, "application/json", False)) + assets: List[Dict[str, Any]] = [] + for asset_id, role, name, path, media_type, primary in candidates: + if not path: + continue + assets.append( + { + "id": asset_id, + "role": role, + "name": name, + "relative_path": os.path.basename(path), + "absolute_path": path, + "format": os.path.splitext(path)[1].lower().lstrip(".") or None, + "media_type": media_type, + "is_required": primary, + "is_primary": primary, + "exists": os.path.isfile(path), + "file_size": os.path.getsize(path) if os.path.isfile(path) else None, + } + ) + return assets + + +async def _resolve_lt1_radars_for_request( + db: AsyncSession, + request: LandsarLt1ImageProductionRequest, +) -> List[RadarDataORM]: + source_asset_ids = _dedupe_positive_ids(request.source_asset_ids) + radar_data_ids = _dedupe_positive_ids(request.radar_data_ids) + filters = [] + if radar_data_ids: + filters.append(RadarDataORM.id.in_(radar_data_ids)) + if source_asset_ids: + filters.append(RadarDataORM.source_product_ref_id.in_(source_asset_ids)) + if not filters: + return [] + result = await db.execute(select(RadarDataORM).where(*([filters[0]] if len(filters) == 1 else [filters[0] | filters[1]]))) + radars = list(result.scalars().all()) + unique: Dict[int, RadarDataORM] = {} + for radar in radars: + if not radar.id: + continue + family = normalize_satellite_family(radar.satellite_family or radar.satellite) + if str(family or "").upper() != "LT1": + continue + unique[int(radar.id)] = radar + return [unique[key] for key in sorted(unique.keys())] + + +async def _produced_radars_for_request( + db: AsyncSession, + request: LandsarLt1ImageProductionRequest, +) -> Dict[int, dict]: + radars = await _resolve_lt1_radars_for_request(db, request) + radar_ids = [int(item.id) for item in radars if item.id] + if not radar_ids: + return {} + result = await db.execute( + select(SARSceneGeoORM).where( + SARSceneGeoORM.radar_data_id.in_(radar_ids), + SARSceneGeoORM.status == "DONE", + SARSceneGeoORM.analysis_tif_path.isnot(None), + SARSceneGeoORM.analysis_engine == "lt_gamma", + SARSceneGeoORM.analysis_profile == "lt1_gamma_geocoded_mli", + ) + ) + return {int(scene.radar_data_id): _scene_product_marker(scene) for scene in result.scalars().all()} + + +async def _active_radars_for_request( + db: AsyncSession, + request: LandsarLt1ImageProductionRequest, +) -> Dict[int, dict]: + radars = await _resolve_lt1_radars_for_request(db, request) + radar_ids = [int(item.id) for item in radars if item.id] + if not radar_ids: + return {} + result = await db.execute( + select(SARSceneGeoORM).where( + SARSceneGeoORM.radar_data_id.in_(radar_ids), + SARSceneGeoORM.status.in_(["PENDING", "RUNNING"]), + ) + ) + return {int(scene.radar_data_id): _scene_product_marker(scene) for scene in result.scalars().all()} + + +def _already_produced_blocker(produced: Dict[int, dict]) -> str: + first_id = sorted(produced.keys())[0] + marker = produced[first_id] or {} + product_id = marker.get("product_id") or "unknown" + return f"Radar data {first_id} already has an analysis-ready GeoTIFF: {product_id}" + + +def _active_blocker(active: Dict[int, dict]) -> str: + first_id = sorted(active.keys())[0] + marker = active[first_id] or {} + return f"Radar data {first_id} already has an active GeoTIFF production task (scene_id={marker.get('scene_id')})." + + +@router.get("/landsar-lt1-production/capabilities") +async def get_landsar_lt1_capabilities( + current_user: AuthUserORM = Depends(_get_current_user), +): + _ = current_user + legacy = landsar_lt1_production_service.check_capabilities() + return { + "catalog_name": "sar_scene_geo", + "supported_profiles": ["lt1_gamma_geocoded_mli"], + "engine": "lt_gamma", + "available": True, + "status": "configured", + "message": "LT-1 image production uses the existing Gamma single-scene pipeline: multilook, geocode, and analysis-ready GeoTIFF registration.", + "legacy_landsar_import": legacy, + } + + +@router.post("/landsar-lt1-production/preview") +async def preview_landsar_lt1_production( + request: LandsarLt1ImageProductionRequest, + current_user: AuthUserORM = Depends(_get_current_user), + db: AsyncSession = Depends(get_db), +): + _ = current_user + blockers: List[str] = [] + warnings: List[str] = [] + radars = await _resolve_lt1_radars_for_request(db, request) + if not radars: + blockers.append("No LT-1 radar records were resolved from the selected source assets.") + if request.mode == "scene" and len(radars) != 1: + blockers.append("Scene mode requires exactly one LT-1 source asset.") + if request.mode == "batch" and len(radars) < 1: + blockers.append("Batch mode requires at least one LT-1 source asset.") + produced = await _produced_radars_for_request(db, request) + if produced: + blockers.append(_already_produced_blocker(produced)) + active = await _active_radars_for_request(db, request) + if active: + blockers.append(_active_blocker(active)) + if request.mode == "batch": + warnings.append("Batch mode submits one independent geocoded GeoTIFF task per scene; it does not build a D-InSAR stack.") + preview = { + "allow_submit": not blockers, + "blockers": blockers, + "warnings": warnings, + "mode": request.mode, + "profile_code": "lt1_gamma_geocoded_mli", + "engine": "lt_gamma", + "scene_count": len(radars), + "source_asset_count": len(_dedupe_positive_ids(request.source_asset_ids)), + "radar_data_count": len(radars), + "produced_radars": produced, + "active_radars": active, + "scenes": [ + { + "radar_data_id": radar.id, + "source_asset_id": radar.source_product_ref_id, + "satellite": radar.satellite, + "imaging_date": radar.imaging_date, + "imaging_mode": radar.imaging_mode, + "polarization": radar.polarization, + "file_path": radar.file_path, + } + for radar in radars + ], + } + return preview + + +@router.post("/landsar-lt1-production/run", status_code=202) +async def queue_landsar_lt1_production( + request: LandsarLt1ImageProductionRequest, + http_request: Request, + db: AsyncSession = Depends(get_db), + admin_user: AuthUserORM = Depends(_require_admin), +): + _ = admin_user + preview = await preview_landsar_lt1_production(request, current_user=admin_user, db=db) + if preview.get("blockers"): + raise HTTPException(status_code=400, detail={"blockers": preview.get("blockers")}) + + queued: List[Dict[str, Any]] = [] + radars = await _resolve_lt1_radars_for_request(db, request) + for radar in radars: + result = await db.execute( + select(SARSceneGeoORM) + .where(SARSceneGeoORM.radar_data_id == int(radar.id)) + .with_for_update(skip_locked=True) + ) + scene = result.scalar_one_or_none() + if scene and scene.status in ("PENDING", "RUNNING"): + raise HTTPException(status_code=409, detail=f"Radar data {radar.id} already has an active GeoTIFF production task.") + if scene and scene.status == "DONE" and scene.analysis_tif_path: + raise HTTPException(status_code=409, detail=f"Radar data {radar.id} already has an analysis-ready GeoTIFF.") + if not scene: + scene = SARSceneGeoORM(radar_data_id=int(radar.id), status="PENDING") + db.add(scene) + await db.flush() + else: + scene.status = "PENDING" + scene.error_msg = None + await db.flush() + scene_id = int(scene.id) + await db.commit() + payload = { + "scene_id": scene_id, + "radar_data_id": int(radar.id), + "engine": "lt_gamma", + "source_asset_id": radar.source_product_ref_id, + "requested_from": "landsar_lt1_production", + } + task_label = request.task_name or radar.product_unique_id or radar.unique_id or f"radar_id={radar.id}" + task_type = f"LT1_SCENE_GEOTIFF_{scene_id}" + try: + task_id = await task_service.create_task( + task_type, + f"LT-1 geocoded GeoTIFF: {task_label}", + params=payload, + ) + job_id = await job_queue_service.create_job( + JOB_TYPE_SAR_SCENE_PREPROCESS, + payload=payload, + task_id=task_id, + max_attempts=3, + ) + except Exception as exc: + failed_scene = await db.get(SARSceneGeoORM, scene_id) + if failed_scene and failed_scene.status == "PENDING": + failed_scene.status = "FAILED" + failed_scene.error_msg = "Job queue failed" + await db.commit() + raise HTTPException(status_code=409 if "conflict" in str(exc).lower() else 400, detail=str(exc)) from exc + queued.append( + { + "task_id": task_id, + "job_id": job_id, + "scene_id": scene_id, + "radar_data_id": int(radar.id), + "source_asset_id": radar.source_product_ref_id, + } + ) + await _add_operation_audit_log( + db, + request=http_request, + action="lt1_geotiff_production_queued", + resource="landsar-lt1-production/run", + detail={ + "queued": queued, + "mode": request.mode, + "scene_count": len(queued), + }, + ) + await db.commit() + return { + "message": "LT-1 geocoded GeoTIFF production job queued.", + "task_id": queued[0]["task_id"] if len(queued) == 1 else None, + "job_id": queued[0]["job_id"] if len(queued) == 1 else None, + "queued": queued, + "preview": preview, + } + + +@router.get("/landsar-lt1-production/products") +async def list_landsar_lt1_products( + limit: int = 100, + offset: int = 0, + status: Optional[str] = None, + query: Optional[str] = None, + current_user: AuthUserORM = Depends(_get_current_user), + db: AsyncSession = Depends(get_db), +): + _ = current_user + safe_limit = max(1, min(500, int(limit or 100))) + safe_offset = max(0, int(offset or 0)) + filters = [ + SARSceneGeoORM.analysis_engine == "lt_gamma", + SARSceneGeoORM.analysis_profile == "lt1_gamma_geocoded_mli", + ] + if status: + filters.append(SARSceneGeoORM.status == str(status).strip().upper()) + if query: + like = f"%{str(query).strip()}%" + filters.append(RadarDataORM.product_unique_id.ilike(like) | RadarDataORM.unique_id.ilike(like) | RadarDataORM.file_path.ilike(like)) + total_result = await db.execute( + select(func.count(SARSceneGeoORM.id)) + .join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id) + .where(*filters) + ) + total = int(total_result.scalar_one() or 0) + result = await db.execute( + select(SARSceneGeoORM, RadarDataORM) + .join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id) + .where(*filters) + .order_by(SARSceneGeoORM.updated_at.desc().nullslast(), SARSceneGeoORM.id.desc()) + .limit(safe_limit) + .offset(safe_offset) + ) + items = [] + for scene, radar in result.all(): + marker = _scene_product_marker(scene) + items.append( + { + "id": scene.id, + "product_id": marker["product_id"], + "catalog_name": "sar_scene_geo", + "product_family": "lt1_analysis_ready_geotiff", + "product_type": "analysis_ready_geotiff", + "display_name": radar.product_unique_id or radar.unique_id or f"radar_id={radar.id}", + "task_name": "", + "profile_code": scene.analysis_profile, + "engine_code": scene.analysis_engine, + "status": scene.status, + "health_status": "OK" if scene.status == "DONE" and scene.analysis_tif_path else "PENDING", + "publish_dir": scene.analysis_dir, + "manifest_path": (scene.analysis_metadata_json or {}).get("manifest_path") if isinstance(scene.analysis_metadata_json, dict) else None, + "native_output_dir": scene.analysis_dir, + "primary_asset_path": scene.analysis_tif_path, + "summary": { + "scene_count": 1, + "radar_data_id": radar.id, + "source_asset_ids": [radar.source_product_ref_id] if radar.source_product_ref_id else [], + "imaging_date": radar.imaging_date, + "polarization": radar.polarization, + "pixel_size_m": scene.pixel_size_m, + "backscatter_unit": scene.analysis_backscatter_unit, + }, + "tags": {"engine": scene.analysis_engine, "profile": scene.analysis_profile}, + "produced_at": scene.updated_at.isoformat() if scene.updated_at else None, + "published_at": scene.updated_at.isoformat() if scene.updated_at else None, + "registered_at": scene.created_at.isoformat() if scene.created_at else None, + } + ) + return {"total": total, "limit": safe_limit, "offset": safe_offset, "items": items} + + +@router.get("/landsar-lt1-production/products/{product_db_id}") +async def get_landsar_lt1_product_detail( + product_db_id: int, + current_user: AuthUserORM = Depends(_get_current_user), + db: AsyncSession = Depends(get_db), +): + _ = current_user + result = await db.execute( + select(SARSceneGeoORM, RadarDataORM) + .join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id) + .where( + SARSceneGeoORM.id == product_db_id, + SARSceneGeoORM.analysis_engine == "lt_gamma", + SARSceneGeoORM.analysis_profile == "lt1_gamma_geocoded_mli", + ) + ) + row = result.first() + if row is None: + raise HTTPException(status_code=404, detail="LT-1 geocoded GeoTIFF product not found") + scene, radar = row + marker = _scene_product_marker(scene) + detail = { + "id": scene.id, + "product_id": marker["product_id"], + "catalog_name": "sar_scene_geo", + "product_family": "lt1_analysis_ready_geotiff", + "product_type": "analysis_ready_geotiff", + "display_name": radar.product_unique_id or radar.unique_id or f"radar_id={radar.id}", + "profile_code": scene.analysis_profile, + "engine_code": scene.analysis_engine, + "status": scene.status, + "publish_dir": scene.analysis_dir, + "primary_asset_path": scene.analysis_tif_path, + "summary": { + "scene_count": 1, + "radar_data_id": radar.id, + "source_asset_ids": [radar.source_product_ref_id] if radar.source_product_ref_id else [], + "imaging_date": radar.imaging_date, + "polarization": radar.polarization, + "pixel_size_m": scene.pixel_size_m, + "backscatter_unit": scene.analysis_backscatter_unit, + }, + "assets": _scene_asset_items(scene), + } + return detail + + +@router.get("/landsar-lt1-production/products/{product_db_id}/assets/{asset_id}") +async def get_landsar_lt1_product_asset( + product_db_id: int, + asset_id: int, + current_user: AuthUserORM = Depends(_get_current_user), + db: AsyncSession = Depends(get_db), +): + _ = current_user + scene = await db.get(SARSceneGeoORM, product_db_id) + if scene is None or scene.analysis_engine != "lt_gamma" or scene.analysis_profile != "lt1_gamma_geocoded_mli": + raise HTTPException(status_code=404, detail="LT-1 geocoded GeoTIFF product not found") + asset = next((item for item in _scene_asset_items(scene) if int(item["id"]) == int(asset_id)), None) + if asset is None: + raise HTTPException(status_code=404, detail="LT-1 geocoded GeoTIFF asset not found") + absolute_path = str(asset.get("absolute_path") or "") + if not absolute_path or not os.path.isfile(absolute_path): + raise HTTPException(status_code=404, detail="Asset file not found") + return FileResponse( + absolute_path, + media_type=str(asset.get("media_type") or "application/octet-stream"), + filename=str(asset.get("name") or os.path.basename(absolute_path)), + headers=STATIC_ASSET_CACHE_HEADERS, + ) diff --git a/backend/app/routers/pairing.py b/backend/app/routers/pairing.py index b9d04d6..bbf1739 100644 --- a/backend/app/routers/pairing.py +++ b/backend/app/routers/pairing.py @@ -26,7 +26,10 @@ from ..models import ( TimeseriesStackPlanORM, ) from ..services.pairing_cache_service import pairing_cache_service +from ..services.job_handlers import JOB_TYPE_PAIRING_CACHE_REBUILD +from ..services.job_queue_service import job_queue_service from ..services.spatial_service import spatial_service +from ..services.task_service import task_service from .dependencies import ( _parse_aoi_from_files, _parse_aoi_geojson_form_value, @@ -38,6 +41,49 @@ logger = logging.getLogger(__name__) router = APIRouter() +async def _queue_pairing_cache_job( + *, + db: AsyncSession, + mode: str, + force_full: bool = False, +) -> Dict[str, object]: + full_rebuild = mode in {"full", "full_rebuild"} or force_full + task_name = ( + "D-InSAR pairing cache full rebuild" + if full_rebuild + else "D-InSAR pairing cache dirty reconcile" + ) + payload = { + "mode": "full_rebuild" if full_rebuild else "auto_reconcile", + "force_full": bool(full_rebuild), + } + try: + task_id = await task_service.create_task( + JOB_TYPE_PAIRING_CACHE_REBUILD, + task_name, + params=payload, + db=db, + ) + job_id = await job_queue_service.create_job( + JOB_TYPE_PAIRING_CACHE_REBUILD, + payload=payload, + max_attempts=1, + task_id=task_id, + db=db, + ) + await db.commit() + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return { + "ok": True, + "queued": True, + "mode": payload["mode"], + "task_id": task_id, + "job_id": job_id, + "message": f"{task_name} queued", + } + + def get_pairing_request_from_form( time_baseline_min: int = Form(1), time_baseline_max: int = Form(30), @@ -130,26 +176,26 @@ async def get_pairing_health_endpoint( return await pairing_cache_service.get_admin_summary(db) -@router.post("/pairing/rebuild-cache") +@router.post("/pairing/rebuild-cache", status_code=202) async def rebuild_pairing_cache_endpoint( db: AsyncSession = Depends(get_db), current_user: AuthUserORM = Depends(_require_admin), ): _ = current_user - return await pairing_cache_service.rebuild_metric_cache(db, commit=True) + return await _queue_pairing_cache_job(db=db, mode="full_rebuild", force_full=True) -@router.post("/pairing/reconcile-dirty") +@router.post("/pairing/reconcile-dirty", status_code=202) async def reconcile_dirty_pairing_endpoint( force_full: bool = Query(False), db: AsyncSession = Depends(get_db), current_user: AuthUserORM = Depends(_require_admin), ): _ = current_user - return await pairing_cache_service.reconcile_dirty_scenes( - db, + return await _queue_pairing_cache_job( + db=db, + mode="full_rebuild" if force_full else "auto_reconcile", force_full=force_full, - commit=True, ) diff --git a/backend/app/routers/radar.py b/backend/app/routers/radar.py index 9e4b801..4d35ef0 100644 --- a/backend/app/routers/radar.py +++ b/backend/app/routers/radar.py @@ -23,6 +23,7 @@ from ..models import ( RadarDataORM, RadarDataPage, RadarPreviewStatusInfo, + SARSceneGeoORM, ScanRequest, ) from ..services.data_service import data_service @@ -154,6 +155,51 @@ def _normalize_list_pagination(limit: int, offset: int) -> Tuple[int, int]: return safe_limit, safe_offset +def _lt1_image_marker(scene: SARSceneGeoORM) -> Dict[str, Any]: + return { + "scene_id": scene.id, + "radar_data_id": scene.radar_data_id, + "product_id": f"sar_scene_geo:{scene.id}", + "product_family": "lt1_analysis_ready_geotiff", + "engine_code": scene.analysis_engine, + "profile_code": scene.analysis_profile, + "analysis_tif_path": scene.analysis_tif_path, + "analysis_dir": scene.analysis_dir, + "analysis_preview_path": scene.analysis_preview_path, + "status": scene.status, + "published_at": scene.updated_at.isoformat() if scene.updated_at else None, + } + + +async def _decorate_lt1_landsar_status(db: AsyncSession, items: List[RadarDataORM]) -> List[RadarData]: + payloads = [RadarData.model_validate(item) for item in items] + radar_ids = [ + int(item.id) + for item in items + if item.id and str(item.satellite_family or item.satellite or "").upper().replace("-", "") in {"LT1", "LT"} + ] + if not radar_ids: + return payloads + result = await db.execute( + select(SARSceneGeoORM).where( + SARSceneGeoORM.radar_data_id.in_(radar_ids), + SARSceneGeoORM.status == "DONE", + SARSceneGeoORM.analysis_tif_path.isnot(None), + SARSceneGeoORM.analysis_engine == "lt_gamma", + SARSceneGeoORM.analysis_profile == "lt1_gamma_geocoded_mli", + ) + ) + produced = {int(scene.radar_data_id): _lt1_image_marker(scene) for scene in result.scalars().all()} + for payload in payloads: + marker = produced.get(int(payload.id or 0)) + if marker: + payload.lt1_image_produced = True + payload.lt1_image_product = marker + payload.lt1_landsar_produced = True + payload.lt1_landsar_product = marker + return payloads + + def _normalize_optional_text(value: Optional[str]) -> Optional[str]: if value is None: return None @@ -256,6 +302,24 @@ def _build_radar_preview_status( ) +def _build_cached_radar_preview_status(record: RadarDataORM) -> RadarPreviewStatusInfo: + raw_cache_path, geo_cache_path = _radar_preview_paths(record) + preview_cache_path = str(record.preview_cache_path or "") + has_geo_cache = ( + os.path.exists(geo_cache_path) + or bool(preview_cache_path and os.path.exists(preview_cache_path)) + ) + has_raw_cache = os.path.exists(raw_cache_path) + cached_status = str(record.preview_cache_status or "NONE").upper() + source_found = cached_status in {"READY", "FAILED"} or has_geo_cache or has_raw_cache + return _build_radar_preview_status( + record=record, + source_found=source_found, + has_geo_cache=has_geo_cache, + has_raw_cache=has_raw_cache, + ) + + async def _build_radar_preview_cache( record: RadarDataORM, db: AsyncSession, @@ -510,10 +574,13 @@ async def search_radar_data_endpoint( limit: int = Form(500), offset: int = Form(0), satellite: Optional[str] = Form(None), + satellite_family: Optional[str] = Form(None), + source_format: Optional[str] = Form(None), satellite_mode: Optional[str] = Form(None), receiving_station: Optional[str] = Form(None), imaging_mode: Optional[str] = Form(None), orbit_circle: Optional[str] = Form(None), + relative_orbit: Optional[str] = Form(None), acquisition_time_utc: Optional[str] = Form(None), product_type: Optional[str] = Form(None), polarization: Optional[str] = Form(None), @@ -536,10 +603,13 @@ async def search_radar_data_endpoint( n_satellite_list: Optional[List[str]] = None if n_satellite_raw and "," in n_satellite_raw: n_satellite_list = [s.strip() for s in n_satellite_raw.split(",") if s.strip()] + n_satellite_family = _normalize_optional_text(satellite_family) + n_source_format = _normalize_optional_text(source_format) n_satellite_mode = _normalize_optional_text(satellite_mode) n_receiving_station = _normalize_optional_text(receiving_station) n_imaging_mode = _normalize_optional_text(imaging_mode) n_orbit_circle = _normalize_optional_text(orbit_circle) + n_relative_orbit = _normalize_optional_text(relative_orbit) n_acquisition_time = _normalize_optional_text(acquisition_time_utc) n_product_type = _normalize_optional_text(product_type) n_polarization = _normalize_optional_text(polarization) @@ -584,6 +654,10 @@ async def search_radar_data_endpoint( filters.append(RadarDataORM.satellite.in_(n_satellite_list)) elif n_satellite_raw: filters.append(RadarDataORM.satellite.ilike(f"%{n_satellite_raw}%")) + if n_satellite_family: + filters.append(func.upper(RadarDataORM.satellite_family) == n_satellite_family.upper()) + if n_source_format: + filters.append(func.upper(RadarDataORM.source_format) == n_source_format.upper()) if n_satellite_mode: filters.append(RadarDataORM.satellite_mode.ilike(f"%{n_satellite_mode}%")) if n_receiving_station: @@ -592,6 +666,8 @@ async def search_radar_data_endpoint( filters.append(RadarDataORM.imaging_mode.ilike(f"%{n_imaging_mode}%")) if n_orbit_circle: filters.append(RadarDataORM.orbit_circle.ilike(f"%{n_orbit_circle}%")) + if n_relative_orbit: + filters.append(RadarDataORM.relative_orbit.ilike(f"%{n_relative_orbit}%")) if n_acquisition_time: filters.append(RadarDataORM.acquisition_time_utc.ilike(f"%{n_acquisition_time}%")) if n_product_type: @@ -606,10 +682,11 @@ async def search_radar_data_endpoint( filters.append(RadarDataORM.orbit_direction.ilike(f"%{n_orbit_direction}%")) if has_orbit_data is not None: filters.append(RadarDataORM.has_orbit_data == has_orbit_data) + normalized_imaging_date = func.replace(RadarDataORM.imaging_date, "-", "") if n_date_from: - filters.append(RadarDataORM.imaging_date >= n_date_from) + filters.append(normalized_imaging_date >= n_date_from.replace("-", "")) if n_date_to: - filters.append(RadarDataORM.imaging_date <= n_date_to) + filters.append(normalized_imaging_date <= n_date_to.replace("-", "")) if resolved_aoi_wkt: aoi_geom = func.ST_GeomFromText(resolved_aoi_wkt, 4326) filters.append(ST_Intersects(RadarDataORM.geom, aoi_geom)) @@ -630,8 +707,9 @@ async def search_radar_data_endpoint( result = await db.execute(data_stmt) items = result.scalars().all() + decorated_items = await _decorate_lt1_landsar_status(db, items) return RadarDataSearchPageResponse( - items=items, + items=decorated_items, total=total, limit=limit, offset=offset, @@ -663,8 +741,9 @@ async def get_all_data_endpoint( .limit(limit) ) items = result.scalars().all() + decorated_items = await _decorate_lt1_landsar_status(db, items) return RadarDataPage( - items=items, + items=decorated_items, total=total, limit=limit, offset=offset, @@ -724,16 +803,7 @@ async def get_radar_preview_status_endpoint(data_id: int, db: AsyncSession = Dep if _is_gf3_native_preview_record(record): return _build_gf3_native_preview_status(record) - raw_cache_path, geo_cache_path = _radar_preview_paths(record) - has_geo_cache = os.path.exists(geo_cache_path) - has_raw_cache = os.path.exists(raw_cache_path) - source_found = bool(await asyncio.to_thread(data_service.find_radar_preview_source, record.file_path)) - return _build_radar_preview_status( - record=record, - source_found=source_found, - has_geo_cache=has_geo_cache, - has_raw_cache=has_raw_cache, - ) + return _build_cached_radar_preview_status(record) @router.post("/radar-data/{data_id}/rebuild-preview-cache", response_model=RadarPreviewStatusInfo) diff --git a/backend/app/routers/tasks_runtime.py b/backend/app/routers/tasks_runtime.py index b0551a7..f29b243 100644 --- a/backend/app/routers/tasks_runtime.py +++ b/backend/app/routers/tasks_runtime.py @@ -2,16 +2,19 @@ from __future__ import annotations import asyncio import json +from datetime import datetime, timedelta from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel +from sqlalchemy import case, func, select from .. import database from ..auth_service import SESSION_COOKIE_NAME, get_user_by_session_token from ..auth_utils import verify_password -from ..models import AuthUserORM, TaskInfo +from ..config import settings +from ..models import AuthUserORM, SystemJobORM, SystemTaskORM, SystemWorkerHeartbeatORM, TaskInfo from ..services.dinsar_production_service import dinsar_production_service from ..services.task_service import ( TASK_ACTIVE_DEFAULT_LIMIT, @@ -47,6 +50,73 @@ def _split_csv_param(raw: Optional[str]) -> List[str]: return values +def _dt(value): + return value.isoformat() if value else None + + +def _task_payload(task: SystemTaskORM) -> dict: + return TaskInfo.model_validate(task).model_dump(mode="json") + + +def _worker_note(worker: SystemWorkerHeartbeatORM) -> dict: + try: + parsed = json.loads(str(worker.note or "") or "{}") + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + + +def _worker_concurrency(worker: SystemWorkerHeartbeatORM) -> int: + note = _worker_note(worker) + try: + return max(1, int(note.get("concurrency") or 1)) + except (TypeError, ValueError): + return 1 + + +def _job_payload(job: SystemJobORM, task_by_id: dict[str, SystemTaskORM]) -> dict: + task = task_by_id.get(str(job.task_id or "")) + return { + "job_id": job.job_id, + "job_type": job.job_type, + "status": job.status, + "priority": int(job.priority or 0), + "attempts": int(job.attempts or 0), + "max_attempts": int(job.max_attempts or 0), + "task_id": job.task_id, + "task_type": task.task_type if task else None, + "task_name": task.task_name if task else None, + "task_status": task.status if task else None, + "task_progress": int(task.progress or 0) if task else None, + "task_message": task.message if task else None, + "workflow_run_id": job.workflow_run_id, + "workflow_step_id": job.workflow_step_id, + "locked_by": job.locked_by, + "locked_at": _dt(job.locked_at), + "heartbeat_at": _dt(job.heartbeat_at), + "next_run_at": _dt(job.next_run_at), + "created_at": _dt(job.created_at), + "started_at": _dt(job.started_at), + "finished_at": _dt(job.finished_at), + "last_error": job.last_error, + } + + +def _worker_payload(worker: SystemWorkerHeartbeatORM, active_job_count: int, concurrency: int) -> dict: + note = _worker_note(worker) + return { + "worker_id": worker.worker_id, + "hostname": worker.hostname, + "pid": worker.pid, + "note": worker.note, + "concurrency": concurrency, + "allowed_job_types": note.get("allowed_job_types") if isinstance(note.get("allowed_job_types"), list) else [], + "started_at": _dt(worker.started_at), + "last_seen": _dt(worker.last_seen), + "active_job_count": active_job_count, + } + + @router.get("/tasks/active", response_model=List[TaskInfo]) async def get_active_tasks(limit: int = TASK_ACTIVE_DEFAULT_LIMIT, offset: int = 0): safe_limit = min(TASK_ACTIVE_MAX_LIMIT, max(1, int(limit or TASK_ACTIVE_DEFAULT_LIMIT))) @@ -73,6 +143,199 @@ async def get_recent_tasks( return [TaskInfo.model_validate(task) for task in orm_tasks] +@router.get("/tasks/runtime-summary") +async def get_task_runtime_summary(limit: int = TASK_ACTIVE_DEFAULT_LIMIT, offset: int = 0): + safe_limit = min(TASK_ACTIVE_MAX_LIMIT, max(1, int(limit or TASK_ACTIVE_DEFAULT_LIMIT))) + safe_offset = min(TASK_QUERY_MAX_OFFSET, max(0, int(offset or 0))) + active_job_statuses = ["READY", "RETRY", "RUNNING"] + scan_job_types = { + "SCAN_DATA", + "SCAN_DINSAR", + "SCAN_ASSET_INVENTORY", + "AUDIT_SOURCE_ARCHIVE_INTEGRITY", + "GF3_SARSCAPE_SYNC", + "GF3_QUICKLOOK_WEBP", + } + + worker_timeout = max(5, int(getattr(settings, "JOB_WORKER_HEALTH_TIMEOUT", 60) or 60)) + worker_threshold = datetime.utcnow() - timedelta(seconds=worker_timeout) + configured_concurrency = max(1, int(getattr(settings, "JOB_WORKER_CONCURRENCY", 1) or 1)) + + async with _new_session() as db: + active_tasks = await task_service.get_active_tasks(limit=safe_limit, offset=safe_offset, db=db) + + workers_result = await db.execute( + select(SystemWorkerHeartbeatORM) + .where(SystemWorkerHeartbeatORM.last_seen >= worker_threshold) + .order_by(SystemWorkerHeartbeatORM.last_seen.desc()) + ) + active_workers = workers_result.scalars().all() + active_worker_ids = {str(worker.worker_id) for worker in active_workers} + + running_by_worker_result = await db.execute( + select(SystemJobORM.locked_by, func.count(SystemJobORM.id)) + .where(SystemJobORM.status == "RUNNING") + .group_by(SystemJobORM.locked_by) + ) + running_by_worker = { + str(worker_id or ""): int(count or 0) + for worker_id, count in running_by_worker_result.all() + } + + status_counts_result = await db.execute( + select(SystemJobORM.status, func.count(SystemJobORM.id)) + .where(SystemJobORM.status.in_(active_job_statuses)) + .group_by(SystemJobORM.status) + ) + job_status_counts = { + "READY": 0, + "RETRY": 0, + "RUNNING": 0, + } + for status, count in status_counts_result.all(): + job_status_counts[str(status or "").upper()] = int(count or 0) + + status_rank = case( + (SystemJobORM.status == "RUNNING", 0), + (SystemJobORM.status == "RETRY", 1), + else_=2, + ) + jobs_result = await db.execute( + select(SystemJobORM) + .where(SystemJobORM.status.in_(active_job_statuses)) + .order_by(status_rank, SystemJobORM.priority.desc(), SystemJobORM.id.asc()) + .offset(safe_offset) + .limit(safe_limit) + ) + active_jobs = jobs_result.scalars().all() + + task_ids = { + str(task.task_id) + for task in active_tasks + if task.task_id + } + task_ids.update( + str(job.task_id) + for job in active_jobs + if job.task_id + ) + task_by_id: dict[str, SystemTaskORM] = {} + if task_ids: + task_result = await db.execute( + select(SystemTaskORM).where(SystemTaskORM.task_id.in_(sorted(task_ids))) + ) + task_by_id = { + str(task.task_id): task + for task in task_result.scalars().all() + if task.task_id + } + + worker_concurrency_by_id = { + str(worker.worker_id): _worker_concurrency(worker) + for worker in active_workers + } + total_slots = sum(worker_concurrency_by_id.values()) + busy_slots = sum( + count + for worker_id, count in running_by_worker.items() + if worker_id in active_worker_ids + ) + running_count = int(job_status_counts.get("RUNNING") or 0) + stale_running_count = max(0, running_count - busy_slots) + queued_count = int(job_status_counts.get("READY") or 0) + int(job_status_counts.get("RETRY") or 0) + + task_items = [_task_payload(task) for task in active_tasks] + job_items = [_job_payload(job, task_by_id) for job in active_jobs] + scan_jobs = [ + item for item in job_items + if str(item.get("job_type") or "").upper() in scan_job_types + ] + scan_tasks = [ + item for item in task_items + if str(item.get("task_type") or "").upper() in scan_job_types + ] + + return { + "timestamp": datetime.utcnow().isoformat() + "Z", + "worker": { + "ok": len(active_workers) > 0, + "worker_count": len(active_workers), + "configured_concurrency": configured_concurrency, + "total_slots": total_slots, + "busy_slots": busy_slots, + "idle_slots": max(0, total_slots - busy_slots), + "timeout_seconds": worker_timeout, + "stale_running_job_count": stale_running_count, + "workers": [ + _worker_payload( + worker, + running_by_worker.get(str(worker.worker_id), 0), + worker_concurrency_by_id.get(str(worker.worker_id), 1), + ) + for worker in active_workers + ], + }, + "jobs": { + "active_count": running_count + queued_count, + "running_count": running_count, + "queued_count": queued_count, + "ready_count": int(job_status_counts.get("READY") or 0), + "retry_count": int(job_status_counts.get("RETRY") or 0), + "items": job_items, + }, + "tasks": { + "active_count": len(task_items), + "running_count": sum(1 for item in task_items if item.get("status") == "RUNNING"), + "pending_count": sum(1 for item in task_items if item.get("status") == "PENDING"), + "items": task_items, + }, + "scan": { + "active_task_count": len(scan_tasks), + "active_job_count": len(scan_jobs), + "running_job_count": sum(1 for item in scan_jobs if item.get("status") == "RUNNING"), + "queued_job_count": sum(1 for item in scan_jobs if item.get("status") in {"READY", "RETRY"}), + "tasks": scan_tasks, + "jobs": scan_jobs, + }, + } + + +@router.get("/tasks/runtime-summary/stream") +async def stream_task_runtime_summary(request: Request): + token = request.cookies.get(SESSION_COOKIE_NAME) + if not token: + raise HTTPException(status_code=401, detail="Authentication required.") + + async with _new_session() as db: + user = await get_user_by_session_token(db, token) + if not user: + raise HTTPException(status_code=401, detail="Authentication required.") + + async def event_generator(): + while True: + if await request.is_disconnected(): + break + try: + summary = await get_task_runtime_summary( + limit=TASK_ACTIVE_MAX_LIMIT, + offset=0, + ) + yield f"data: {json.dumps(summary)}\n\n" + except Exception: + yield "data: {}\n\n" + await asyncio.sleep(3) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + "Connection": "keep-alive", + }, + ) + + @router.get("/tasks/active/stream") async def stream_active_tasks(request: Request): token = request.cookies.get(SESSION_COOKIE_NAME) diff --git a/backend/app/services/asset_inventory_service.py b/backend/app/services/asset_inventory_service.py index 7cf2682..fb59e6e 100644 --- a/backend/app/services/asset_inventory_service.py +++ b/backend/app/services/asset_inventory_service.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import gzip import hashlib +import logging import math import multiprocessing as mp import os @@ -48,6 +49,8 @@ from ..utils import ( ) from .pairing_state_service import pairing_state_service from .data_service import DataService + +logger = logging.getLogger(__name__) from .image_service import image_service from .orbit_converter import sync_orbit_pools from .task_service import task_service @@ -2599,6 +2602,53 @@ def _image_data_format_for_source(row: Dict[str, Any]) -> str: return "DIRECTORY" +PAIRING_RELEVANT_RADAR_FIELDS = { + "satellite", + "satellite_family", + "imaging_date", + "imaging_mode", + "orbit_direction", + "polarization", + "look_direction", + "relative_orbit", + "insar_source_ready", + "file_path", + "coverage_polygon", + "min_lon", + "min_lat", + "max_lon", + "max_lat", + "scene_center_lon", + "scene_center_lat", +} + + +def _normalize_pairing_compare_value(value: Any) -> Any: + if isinstance(value, str): + text = value.strip() + return text or None + if isinstance(value, bool): + return bool(value) + if isinstance(value, (int, float)): + return round(float(value), 9) + if isinstance(value, list): + return [_normalize_pairing_compare_value(item) for item in value] + if isinstance(value, tuple): + return [_normalize_pairing_compare_value(item) for item in value] + return value + + +def _radar_pairing_fields_changed(existing: RadarDataORM, new_values: Dict[str, Any]) -> bool: + for field in PAIRING_RELEVANT_RADAR_FIELDS: + if field not in new_values: + continue + before = _normalize_pairing_compare_value(getattr(existing, field, None)) + after = _normalize_pairing_compare_value(new_values.get(field)) + if before != after: + return True + return False + + class AssetInventoryService: async def _progress(self, task_id: Optional[str], message: str, progress: int) -> None: if not task_id: @@ -2784,6 +2834,13 @@ class AssetInventoryService: raw_cache_path = DataService.get_radar_raw_cache_path(unique_id, record.file_path) geo_cache_path = DataService.get_radar_geo_cache_path(unique_id, record.file_path) product_name = os.path.basename(str(record.file_path or "")) + if task_id: + progress = progress_start + int(index / max(1, total) * max(1, progress_end - progress_start)) + await task_service.update_task( + task_id, + message=f"Building archive preview cache ({index}/{total}): {product_name}", + progress=min(progress_end, progress), + ) preview_source = await asyncio.to_thread(DataService.find_radar_preview_source, record.file_path) if not preview_source: @@ -4675,6 +4732,7 @@ class AssetInventoryService: profile_inputs.append((row, asset_id, int(scene.id))) else: before_orbit_id = existing.selected_orbit_asset_id + pairing_fields_changed = _radar_pairing_fields_changed(existing, radar_values) for key, value in radar_values.items(): setattr(existing, key, value) if not existing.orbit_binding_status: @@ -4682,16 +4740,15 @@ class AssetInventoryService: db.add(existing) if existing.id is not None: profile_inputs.append((row, asset_id, int(existing.id))) - if existing.id is not None and before_orbit_id != existing.selected_orbit_asset_id: + if existing.id is not None and ( + pairing_fields_changed or before_orbit_id != existing.selected_orbit_asset_id + ): dirty_scene_ids.append(int(existing.id)) await db.flush() if profile_inputs: await self._upsert_geometry_profiles(db, profile_inputs) await self._attach_radar_ids_to_metadata_documents(db, profile_inputs) - for _, _, radar_id in profile_inputs: - if radar_id not in dirty_scene_ids: - dirty_scene_ids.append(radar_id) if dirty_scene_ids: await pairing_state_service.mark_scenes_dirty(db, scene_ids=dirty_scene_ids, reason="asset_inventory_source_update", commit=False) @@ -4988,7 +5045,6 @@ class AssetInventoryService: matched = 0 missing = 0 candidate_count = 0 - dirty_scene_ids: List[int] = [] for scene in scenes: candidates = await self._find_orbit_candidates(db, scene) if not candidates: @@ -5018,8 +5074,6 @@ class AssetInventoryService: }, ) ) - if scene.id is not None: - dirty_scene_ids.append(int(scene.id)) continue candidate_count += len(candidates) @@ -5049,8 +5103,6 @@ class AssetInventoryService: scene.orbit_binding_reason = selected[2] db.add(scene) matched += 1 - if scene.id is not None: - dirty_scene_ids.append(int(scene.id)) if len(candidates) > 1 and abs(float(candidates[0][1]) - float(candidates[1][1])) < 0.001: db.add( @@ -5068,13 +5120,8 @@ class AssetInventoryService: ) ) - if dirty_scene_ids: - await pairing_state_service.mark_scenes_dirty( - db, - scene_ids=sorted(set(dirty_scene_ids)), - reason="asset_inventory_orbit_binding", - commit=False, - ) + # Pairing queries join radar_data and filter has_orbit_data live; orbit rebinding + # does not change the cached geometric/time pairing metrics. return { "scene_count": len(scenes), "matched_count": matched, @@ -5279,8 +5326,15 @@ class AssetInventoryService: .limit(safe_limit) ) ).scalars().all() + items = [self._source_asset_payload(row) for row in rows] + try: + from .landsar_lt1_production_service import landsar_lt1_production_service + + await landsar_lt1_production_service.decorate_source_asset_payloads(db, items) + except Exception: + logger.debug("Failed to decorate LT-1 LandSAR production status", exc_info=True) return { - "items": [self._source_asset_payload(row) for row in rows], + "items": items, "total": total, "limit": safe_limit, "offset": safe_offset, diff --git a/backend/app/services/data_service.py b/backend/app/services/data_service.py index 37eb01a..8b988a4 100644 --- a/backend/app/services/data_service.py +++ b/backend/app/services/data_service.py @@ -288,6 +288,8 @@ def _radar_archive_expected_preview_rank(archive_path: str, member_name: str) -> return None member = str(member_name or "").replace("\\", "/").strip("/") + while member.startswith("./"): + member = member[2:].strip("/") member_lower = member.lower() product_lower = product_stem.lower() expected_names = [ @@ -315,7 +317,10 @@ def _radar_archive_expected_preview_rank(archive_path: str, member_name: str) -> def _radar_archive_preview_score(member_name: str, size_bytes: int = 0) -> Optional[Tuple[int, int, int, int, str]]: - lower_name = str(member_name or "").replace("\\", "/").lower() + normalized_name = str(member_name or "").replace("\\", "/").strip("/") + while normalized_name.startswith("./"): + normalized_name = normalized_name[2:].strip("/") + lower_name = normalized_name.lower() base_name = os.path.basename(lower_name) if not base_name.endswith(_RADAR_PREVIEW_EXTENSIONS): return None @@ -1024,7 +1029,6 @@ class DataService: # 3. 更新缺失精轨的现有记录 update_progress("正在关联精轨数据...", 85) updated_orbits = 0 - updated_orbit_scene_ids = set() if orbit_files_map: stmt_select = select(RadarDataORM).where(RadarDataORM.has_orbit_data == False) result = await db.execute(stmt_select) @@ -1037,8 +1041,6 @@ class DataService: record.orbit_file_path = orbit_files_map[key] db.add(record) updated_orbits += 1 - if record.id is not None: - updated_orbit_scene_ids.add(int(record.id)) for data_type, root_path, mtime in scan_state_updates: await DataService._upsert_scan_state(db, data_type, root_path, mtime) @@ -1046,7 +1048,9 @@ class DataService: await db.commit() pairing_dirty_summary: Dict[str, Any] = {} - dirty_scene_ids = set(updated_orbit_scene_ids) + # Orbit availability is filtered live by pairing queries; it is not part of + # pairing_metric_cache, so orbit-only updates should not dirty the cache. + dirty_scene_ids = set() processed_unique_ids = [key for key in radar_cache_candidates.keys() if key] for chunk in _chunked(processed_unique_ids, 500): id_result = await db.execute( @@ -1061,7 +1065,7 @@ class DataService: reason="radar_scan", commit=True, ) - elif processed_scenes > 0 or updated_orbits > 0: + elif processed_scenes > 0: pairing_dirty_summary = await pairing_state_service.mark_global_dirty( db, reason="radar_scan", diff --git a/backend/app/services/dinsar_production_service.py b/backend/app/services/dinsar_production_service.py index e9759bd..feb05f8 100644 --- a/backend/app/services/dinsar_production_service.py +++ b/backend/app/services/dinsar_production_service.py @@ -1007,6 +1007,7 @@ class DinsarProductionService: "root_dir": run.source_root, "publish_root_dir": run.publish_root_dir, "message": run.latest_message, + "summary_json": run.summary_json if isinstance(run.summary_json, dict) else {}, "total_items": run.total_items, "completed_items": run.completed_items, "failed_items": run.failed_items, diff --git a/backend/app/services/job_handlers.py b/backend/app/services/job_handlers.py index 621aa0c..aacc902 100644 --- a/backend/app/services/job_handlers.py +++ b/backend/app/services/job_handlers.py @@ -41,7 +41,7 @@ from .dinsar_result_layout_service import ( ) from .dinsar_scan_service import dinsar_scan_service from .engine_lock_service import engine_lock_service -from .envi_service import build_envi_runner_command, get_envi_runner_cwd, get_envi_runner_env +from .envi_service import build_envi_runner_command, extract_disp_results, 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 @@ -101,10 +101,13 @@ JOB_TYPE_ISCE2_RUN = "ISCE2_RUN" JOB_TYPE_PYINT_RUN = "PYINT_RUN" JOB_TYPE_LANDSAR_RUN = "LANDSAR_RUN" JOB_TYPE_LANDSAR_CLUSTER_ITEM = "LANDSAR_CLUSTER_ITEM" +JOB_TYPE_LANDSAR_LT1_IMPORT = "LANDSAR_LT1_IMPORT" +JOB_TYPE_EXTRACT_DINSAR_PRODUCTS = "EXTRACT_DINSAR_PRODUCTS" 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_PAIRING_CACHE_REBUILD = "PAIRING_CACHE_REBUILD" JOB_TYPE_SCAN_ASSET_INVENTORY = "SCAN_ASSET_INVENTORY" JOB_TYPE_AUDIT_SOURCE_ARCHIVE_INTEGRITY = "AUDIT_SOURCE_ARCHIVE_INTEGRITY" JOB_TYPE_SBAS_COREGISTRATION = "SBAS_COREGISTRATION" @@ -206,6 +209,142 @@ def _dedupe_existing_dirs(paths: Any) -> List[str]: return ordered +def _compact_failure_text(value: Any, *, max_length: int = 700) -> str: + text = re.sub(r"\s+", " ", str(value or "")).strip() + if not text: + return "Unknown error" + if len(text) <= max_length: + return text + return text[: max(0, max_length - 3)].rstrip() + "..." + + +def _classify_dinsar_failure(error_message: Any) -> str: + text = str(error_message or "") + lowered = text.lower() + if "3221225477" in text or "status_access_violation" in lowered: + return "LandSAR access violation during coherence mask/phase unwrapping" + if "not enough gcps" in lowered or "space insar calibration failed" in lowered: + return "Insufficient GCPs for baseline/calibration" + if ( + "no enough points" in lowered + or "not enough points" in lowered + or "geo_extract_gcp" in lowered + or "无满足snr" in lowered + or "离散采样点" in text + ): + return "Insufficient tie/GCP points for DEM/geocoding" + if "dem/sub-terrain" in lowered or "subterrain" in lowered or "sub-terrain" in lowered: + return "DEM/sub-terrain processing failed" + if "相干性掩膜" in text and "相位解缠" in text: + return "Coherence mask/phase unwrapping failed" + if "timeout" in lowered or "timed out" in lowered or "超时" in text: + return "Processing timeout" + if "publish" in lowered: + return "Result catalog publish failed" + compact = _compact_failure_text(text, max_length=120) + return compact if compact != "Unknown error" else "Unclassified D-InSAR failure" + + +async def _build_dinsar_failure_summary(db, run) -> Dict[str, Any]: + result = await db.execute( + select(DinsarProductionRunItemORM) + .where( + DinsarProductionRunItemORM.run_id == run.run_id, + DinsarProductionRunItemORM.status == "FAILED", + ) + .order_by( + DinsarProductionRunItemORM.order_index.asc().nullslast(), + DinsarProductionRunItemORM.id.asc(), + ) + ) + failed_items = result.scalars().all() + details: List[Dict[str, Any]] = [] + grouped: Dict[str, Dict[str, Any]] = {} + + for item in failed_items: + label = str(item.task_alias or item.task_name or f"item-{item.id}").strip() + reason = _classify_dinsar_failure(item.last_error) + compact_error = _compact_failure_text(item.last_error) + detail = { + "id": item.id, + "order_index": item.order_index, + "task_name": item.task_name, + "task_alias": item.task_alias, + "reason": reason, + "error": compact_error, + "source_task_dir": item.source_task_dir, + "latest_output_dir": item.latest_output_dir, + "latest_log_path": item.latest_log_path, + } + details.append(detail) + + group = grouped.setdefault(reason, {"reason": reason, "count": 0, "items": []}) + group["count"] += 1 + if len(group["items"]) < 25: + group["items"].append(label) + + groups = sorted(grouped.values(), key=lambda item: (-int(item["count"]), str(item["reason"]))) + return { + "failed_count": len(details), + "groups": groups, + "items": details, + } + + +def _chunk_log_lines(lines: List[str], *, max_chars: int = 3500) -> List[str]: + chunks: List[str] = [] + current: List[str] = [] + current_len = 0 + for line in lines: + line_len = len(line) + 1 + if current and current_len + line_len > max_chars: + chunks.append("\n".join(current)) + current = [] + current_len = 0 + current.append(line) + current_len += line_len + if current: + chunks.append("\n".join(current)) + return chunks + + +async def _log_dinsar_failure_summary( + *, + task_id: str, + run_id: str, + engine_title: str, + run, + failure_summary: Dict[str, Any], + run_log, +) -> None: + if not failure_summary or int(failure_summary.get("failed_count") or 0) <= 0: + return + + lines = [ + ( + f"{engine_title} D-InSAR failure summary: " + f"completed={run.completed_items} failed={run.failed_items} total={run.total_items}" + ), + "Failure groups:", + ] + for group in failure_summary.get("groups") or []: + items = ", ".join(str(item) for item in (group.get("items") or [])) + omitted = int(group.get("count") or 0) - len(group.get("items") or []) + suffix = f" (+{omitted} more)" if omitted > 0 else "" + lines.append(f"- {group.get('reason')}: {group.get('count')} item(s): {items}{suffix}") + + lines.append("Failed items:") + for item in failure_summary.get("items") or []: + order_index = item.get("order_index") + order_text = f"{order_index}/{run.total_items}" if order_index else f"id={item.get('id')}" + label = item.get("task_alias") or item.get("task_name") or f"item-{item.get('id')}" + lines.append(f"- [{order_text}] {label}: {item.get('reason')} | {item.get('error')}") + + for chunk in _chunk_log_lines(lines): + await task_service.add_log(task_id, "WARNING", chunk) + run_log(run_id, f"[failure-summary]\n{chunk}") + + async def _run_scan_data_custom(task_id: str, payload: Dict[str, Any]) -> None: from ..database import AsyncSessionLocal @@ -2086,6 +2225,7 @@ async def _run_dinsar_production_controller(job: SystemJobORM) -> None: f"failed={run.failed_items} total={run.total_items}" ) + failure_summary = await _build_dinsar_failure_summary(db, run) summary_payload = { "workflow": workflow, "engine_code": run.engine_code, @@ -2098,7 +2238,17 @@ async def _run_dinsar_production_controller(job: SystemJobORM) -> None: "publish": publish_result, "publish_error": publish_error, "published_output_dirs": publish_dirs, + "failure_summary": failure_summary, } + if failure_summary.get("failed_count"): + await _log_dinsar_failure_summary( + task_id=job.task_id, + run_id=run.run_id, + engine_title="ENVI", + run=run, + failure_summary=failure_summary, + run_log=run_log, + ) await dinsar_production_service.finalize_run( run, db=db, @@ -3061,6 +3211,7 @@ async def _run_wsl_dinsar_production_controller( f"failed={run.failed_items} total={run.total_items}" ) + failure_summary = await _build_dinsar_failure_summary(db, run) summary_payload = { "workflow": f"dinsar_{engine_code}", "engine_code": run.engine_code, @@ -3074,7 +3225,17 @@ async def _run_wsl_dinsar_production_controller( "rebuild": rebuild_result, "publish_error": publish_error, "published_output_dirs": publish_dirs, + "failure_summary": failure_summary, } + if failure_summary.get("failed_count"): + await _log_dinsar_failure_summary( + task_id=job.task_id, + run_id=run.run_id, + engine_title=engine_title, + run=run, + failure_summary=failure_summary, + run_log=run_log, + ) await dinsar_production_service.finalize_run( run, db=db, @@ -3266,6 +3427,211 @@ async def _handle_landsar_run(job: SystemJobORM) -> None: ) +async def _handle_landsar_lt1_import(job: SystemJobORM) -> None: + from .landsar_lt1_production_service import landsar_lt1_production_service + from .asset_inventory_service import asset_inventory_service + + payload = dict(job.payload or {}) + task_id = job.task_id + if not task_id: + raise ValueError("LANDSAR_LT1_IMPORT job missing task_id") + + loop = asyncio.get_running_loop() + + def _progress(event: Dict[str, Any]) -> None: + message = str(event.get("message") or event.get("event") or "").strip() + if not message: + return + progress = event.get("progress") + + async def _write() -> None: + try: + await task_service.add_log(task_id, "INFO", message) + if progress is not None: + await task_service.update_task(task_id, progress=int(progress), message=message) + except Exception: + logger.debug("Failed to write LandSAR LT-1 progress", exc_info=True) + + asyncio.run_coroutine_threadsafe(_write(), loop) + + await task_service.start_task(task_id, message="LandSAR LT-1 import started") + await task_service.update_task(task_id, progress=5, message="Checking LandSAR LT-1 runtime") + try: + source_asset_ids = _dedupe_positive_ints(payload.get("source_asset_ids")) + radar_data_ids = _dedupe_positive_ints(payload.get("radar_data_ids")) + if source_asset_ids or radar_data_ids: + await task_service.update_task(task_id, progress=8, message="Preparing LT-1 source assets") + prepared = await _prepare_landsar_lt1_source_assets( + task_id, + payload, + source_asset_ids=source_asset_ids, + radar_data_ids=radar_data_ids, + landsar_lt1_production_service=landsar_lt1_production_service, + asset_inventory_service=asset_inventory_service, + ) + payload = { + **payload, + "source_asset_ids": prepared["source_asset_ids"], + "radar_data_ids": prepared["radar_data_ids"], + "__prepared_scene_dirs": prepared["scene_dirs"], + "__materialized": prepared["materialized"], + "__materialize_task_root": prepared["task_root"], + } + + async with _local_engine_lock("landsar"): + result = await asyncio.to_thread( + landsar_lt1_production_service.run_import, + payload, + progress_callback=_progress, + ) + async with AsyncSessionLocal() as db: + catalog_result = await landsar_lt1_production_service.register_manifest( + db, + result["manifest_path"], + ) + await task_service.add_log( + task_id, + "INFO", + ( + "LandSAR LT-1 product registered: " + f"product_id={catalog_result.get('product_id')}, " + f"assets={catalog_result.get('asset_count')}" + ), + ) + await task_service.update_task( + task_id, + status="COMPLETED", + progress=100, + message=( + "LandSAR LT-1 import completed: " + f"product_id={catalog_result.get('product_id')}, " + f"Input_Data={result.get('input_data_dir')}" + ), + ) + except Exception as exc: + await task_service.add_log(task_id, "ERROR", f"LandSAR LT-1 import failed: {exc}") + await task_service.update_task( + task_id, + status="FAILED", + progress=100, + message=f"LandSAR LT-1 import failed: {exc}", + ) + raise + + +def _dedupe_positive_ints(values: Any) -> List[int]: + result: List[int] = [] + if not isinstance(values, list): + return result + for value in values: + try: + parsed = int(value) + except (TypeError, ValueError): + continue + if parsed > 0 and parsed not in result: + result.append(parsed) + return result + + +async def _prepare_landsar_lt1_source_assets( + task_id: str, + payload: Dict[str, Any], + *, + source_asset_ids: List[int], + radar_data_ids: List[int], + landsar_lt1_production_service: Any, + asset_inventory_service: Any, +) -> Dict[str, Any]: + from ..models import SourceProductAssetORM + + scene_dirs = _dedupe_existing_dirs(payload.get("scene_dirs") or []) + radar_ids: List[int] = [] + async with AsyncSessionLocal() as db: + if radar_data_ids: + rows = ( + await db.execute(select(RadarDataORM).where(RadarDataORM.id.in_(radar_data_ids))) + ).scalars().all() + radar_by_id = {int(row.id): row for row in rows} + for radar_id in radar_data_ids: + radar = radar_by_id.get(int(radar_id)) + if radar is None: + raise ValueError(f"Radar data not found: {radar_id}") + radar_ids.append(int(radar.id)) + if radar.source_product_ref_id and int(radar.source_product_ref_id) not in source_asset_ids: + source_asset_ids.append(int(radar.source_product_ref_id)) + elif radar.file_path and os.path.isdir(radar.file_path): + scene_dirs.append(os.path.normpath(os.path.abspath(radar.file_path))) + else: + raise ValueError(f"Radar data {radar_id} has no source asset or scene directory.") + + produced = await landsar_lt1_production_service.find_produced_source_asset_map(db, source_asset_ids) + if produced: + first_id = sorted(produced.keys())[0] + product_id = (produced[first_id] or {}).get("product_id") + raise ValueError(f"Source asset {first_id} already has a LandSAR LT-1 product: {product_id}") + + assets = [] + if source_asset_ids: + assets = ( + await db.execute(select(SourceProductAssetORM).where(SourceProductAssetORM.id.in_(source_asset_ids))) + ).scalars().all() + asset_by_id = {int(asset.id): asset for asset in assets} + + task_root = os.path.join( + os.path.normpath(os.path.abspath(settings.LANDSAR_WORK_ROOT)), + "lt1_import_tasks", + task_id, + "scenes", + ) + os.makedirs(task_root, exist_ok=True) + materialized: List[Dict[str, Any]] = [] + overwrite = bool(payload.get("materialize_overwrite", False)) + for asset_id in source_asset_ids: + asset = asset_by_id.get(int(asset_id)) + if asset is None: + raise ValueError(f"Source asset not found: {asset_id}") + if str(asset.satellite_family or "").upper() != "LT1": + raise ValueError(f"Source asset {asset_id} is not LT-1.") + result = await asyncio.to_thread( + asset_inventory_service.materialize_source_asset, + asset, + target_root=task_root, + overwrite=overwrite, + ) + target_dir = os.path.normpath(os.path.abspath(str(result.get("safe_dir") or result.get("target_dir") or ""))) + if not target_dir or not os.path.isdir(target_dir): + raise ValueError(f"Source asset {asset_id} materialize did not produce a directory.") + if target_dir not in scene_dirs: + scene_dirs.append(target_dir) + materialized.append( + { + "source_asset_id": int(asset.id), + "asset_uid": asset.asset_uid, + "logical_product_uid": asset.logical_product_uid, + "source_path": asset.file_path, + "scene_dir": target_dir, + "status": result.get("status"), + "member_count": result.get("member_count"), + } + ) + await task_service.add_log( + task_id, + "INFO", + f"Prepared LT-1 source asset {asset.id}: {target_dir} ({result.get('status')})", + ) + + scene_dirs = list(dict.fromkeys(scene_dirs)) + if not scene_dirs: + raise ValueError("No LT-1 scene directories were prepared.") + return { + "scene_dirs": scene_dirs, + "source_asset_ids": list(dict.fromkeys(source_asset_ids)), + "radar_data_ids": radar_ids, + "materialized": materialized, + "task_root": task_root, + } + + async def _handle_landsar_cluster_item(job: SystemJobORM) -> None: payload = job.payload or {} production_run_id = str(payload.get("production_run_id") or "").strip() @@ -4636,6 +5002,85 @@ async def _handle_gf3_sarscape_clean(job: SystemJobORM) -> None: await task_service.update_task(job.task_id, status=status, progress=100, message=message) +async def _handle_extract_dinsar_products(job: SystemJobORM) -> None: + if not job.task_id: + raise ValueError("EXTRACT_DINSAR_PRODUCTS requires task_id for progress tracking.") + + payload = job.payload or {} + root_dir = str(payload.get("root_dir") or "").strip() + dest_dir = payload.get("dest_dir") or None + if not root_dir: + raise ValueError("EXTRACT_DINSAR_PRODUCTS requires root_dir payload.") + + await task_service.start_task(job.task_id, message="正在提取 D-InSAR 位移结果...") + result = await asyncio.to_thread( + extract_disp_results, + root_dir, + dest_dir, + ) + await task_service.update_task( + job.task_id, + progress=45, + message=( + "D-InSAR 位移结果提取完成: " + f"processed={result.get('processed', 0)}, " + f"copied={result.get('copied', 0)}, " + f"overwritten={result.get('overwritten', 0)}, " + f"failed={result.get('failed', 0)}" + ), + ) + + target_dir = result.get("target_dir") + publish_roots = [target_dir] if target_dir and os.path.isdir(str(target_dir)) else [] + publish_result = None + rebuild_result = None + if publish_roots: + async with AsyncSessionLocal() as db: + await task_service.update_task( + job.task_id, + progress=60, + message="正在发布 D-InSAR 标准结果包...", + ) + publish_result = await result_catalog_service.publish_from_sources( + db, + publish_roots, + ) + if int(publish_result.get("processed", 0) or 0) > 0: + await task_service.update_task( + job.task_id, + progress=80, + message="正在重建 D-InSAR 结果目录索引...", + ) + rebuild_result = await result_catalog_service.rebuild_catalog( + db, + full_rebuild=True, + ) + + failed = int(result.get("failed", 0) or 0) + publish_failed = int((publish_result or {}).get("failed", 0) or 0) + rebuild_failed = int((rebuild_result or {}).get("failed", 0) or 0) + status = "FAILED" if (failed or publish_failed or rebuild_failed) else "COMPLETED" + message = ( + "D-InSAR 结果提取与登记完成: " + f"提取 {int(result.get('processed', 0) or 0)} 项, " + f"复制 {int(result.get('copied', 0) or 0)} 个, " + f"覆盖 {int(result.get('overwritten', 0) or 0)} 个" + ) + if publish_result is not None: + message += f", 发布 {int(publish_result.get('processed', 0) or 0)} 项" + if rebuild_result is not None: + message += f", 入库 {int(rebuild_result.get('registered', 0) or 0)} 项" + if status == "FAILED": + message += f", 失败 {failed + publish_failed + rebuild_failed} 项" + + await task_service.update_task( + job.task_id, + status=status, + progress=100, + message=message, + ) + + async def _handle_publish_dinsar_products_clean(job: SystemJobORM) -> None: if not job.task_id: raise ValueError("PUBLISH_DINSAR_PRODUCTS requires task_id for progress tracking.") @@ -5669,13 +6114,99 @@ async def _handle_sbas_landsar_workflow(job: SystemJobORM) -> None: ) +async def _handle_pairing_cache_rebuild(job: SystemJobORM) -> None: + if not job.task_id: + raise ValueError("PAIRING_CACHE_REBUILD requires task_id for progress tracking.") + + from .pairing_cache_service import pairing_cache_service + + payload = job.payload or {} + mode = str(payload.get("mode") or "auto_reconcile").strip().lower() + force_full = bool(payload.get("force_full", False)) + full_rebuild = mode in {"full", "full_rebuild"} or force_full + action_label = "full rebuild" if full_rebuild else "dirty reconcile" + + await task_service.start_task( + job.task_id, + message=f"D-InSAR pairing cache {action_label} started", + ) + await task_service.update_task( + job.task_id, + progress=5, + message=f"D-InSAR pairing cache {action_label} is running", + ) + progress_state: Dict[str, Any] = { + "progress": 5, + "message": f"D-InSAR pairing cache {action_label} is running", + } + + async def _report_progress(message: str, progress: int) -> None: + safe_progress = max(5, min(95, int(progress))) + progress_state["progress"] = safe_progress + progress_state["message"] = message + await task_service.update_task( + job.task_id, + progress=safe_progress, + message=message, + ) + + async def _keepalive() -> None: + while True: + await asyncio.sleep(60) + message = str(progress_state.get("message") or f"D-InSAR pairing cache {action_label} is still running") + progress = int(progress_state.get("progress") or 5) + await task_service.update_task( + job.task_id, + progress=progress, + message=f"{message} (still running)", + ) + + keepalive_task = asyncio.create_task(_keepalive()) + try: + async with AsyncSessionLocal() as db: + if full_rebuild: + result = await pairing_cache_service.rebuild_metric_cache( + db, + commit=True, + progress_callback=_report_progress, + ) + else: + result = await pairing_cache_service.reconcile_dirty_scenes( + db, + force_full=False, + commit=True, + progress_callback=_report_progress, + ) + finally: + keepalive_task.cancel() + try: + await keepalive_task + except asyncio.CancelledError: + pass + + await task_service.update_task( + job.task_id, + status="COMPLETED", + progress=100, + message=( + "D-InSAR pairing cache completed: " + f"mode={result.get('mode')}, " + f"scenes={result.get('scene_count', 0)}, " + f"pairs={result.get('pair_count', 0)}, " + f"dirty={result.get('dirty_scene_count', 0)}" + ), + ) + + _HANDLERS = { JOB_TYPE_SCAN_DATA: _handle_scan_data, JOB_TYPE_SCAN_ASSET_INVENTORY: _handle_scan_asset_inventory, JOB_TYPE_AUDIT_SOURCE_ARCHIVE_INTEGRITY: _handle_archive_integrity_audit, JOB_TYPE_SCAN_DINSAR: _handle_scan_dinsar, + JOB_TYPE_EXTRACT_DINSAR_PRODUCTS: _handle_extract_dinsar_products, JOB_TYPE_PUBLISH_DINSAR_PRODUCTS: _handle_publish_dinsar_products_clean, JOB_TYPE_REBUILD_DINSAR_CATALOG: _handle_rebuild_dinsar_catalog_clean, + JOB_TYPE_PAIRING_CACHE_REBUILD: _handle_pairing_cache_rebuild, JOB_TYPE_TIMESERIES_PREPARE: _handle_timeseries_prepare, JOB_TYPE_TIMESERIES_STACK_PREP: _handle_timeseries_stack_prep, JOB_TYPE_TIMESERIES_MATERIALIZE: _handle_timeseries_materialize, @@ -5701,6 +6232,7 @@ _HANDLERS = { JOB_TYPE_ISCE2_RUN: _handle_isce2_run, JOB_TYPE_PYINT_RUN: _handle_pyint_run, JOB_TYPE_LANDSAR_RUN: _handle_landsar_run, + JOB_TYPE_LANDSAR_LT1_IMPORT: _handle_landsar_lt1_import, JOB_TYPE_LANDSAR_CLUSTER_ITEM: _handle_landsar_cluster_item, JOB_TYPE_WATER_GEOCODE: _handle_water_geocode, JOB_TYPE_SAR_SCENE_PREPROCESS: _handle_sar_scene_preprocess, diff --git a/backend/app/services/job_queue_service.py b/backend/app/services/job_queue_service.py index 86352d8..4c93754 100644 --- a/backend/app/services/job_queue_service.py +++ b/backend/app/services/job_queue_service.py @@ -299,10 +299,11 @@ class JobQueueService: ) stale_jobs = result.scalars().all() if not stale_jobs: - return {"recovered": 0, "failed": 0} + return {"recovered": 0, "failed": 0, "failed_task_ids": []} recovered = 0 failed = 0 + failed_task_ids = [] for job in stale_jobs: attempts = int(job.attempts or 0) + 1 if attempts < int(job.max_attempts or 1): @@ -315,6 +316,8 @@ class JobQueueService: next_run_at = None failed += 1 finished_at = now + if job.task_id: + failed_task_ids.append(str(job.task_id)) await db.execute( update(SystemJobORM) @@ -331,7 +334,7 @@ class JobQueueService: ) ) await db.commit() - return {"recovered": recovered, "failed": failed} + return {"recovered": recovered, "failed": failed, "failed_task_ids": failed_task_ids} finally: if gen_db: await db.close() diff --git a/backend/app/services/job_worker.py b/backend/app/services/job_worker.py index 57ac57e..a0b5eb8 100644 --- a/backend/app/services/job_worker.py +++ b/backend/app/services/job_worker.py @@ -1,4 +1,5 @@ import asyncio +import json import os import socket import uuid @@ -50,20 +51,35 @@ def _new_session(): return database.AsyncSessionLocal() -async def _touch_worker(worker_id: str) -> None: +async def _touch_worker( + worker_id: str, + *, + concurrency: int, + allowed_job_types: Optional[Set[str]], +) -> None: host = socket.gethostname() pid = os.getpid() + note = json.dumps( + { + "concurrency": max(1, int(concurrency or 1)), + "allowed_job_types": sorted(allowed_job_types or []), + }, + ensure_ascii=False, + separators=(",", ":"), + ) async with _new_session() as db: stmt = pg_insert(SystemWorkerHeartbeatORM).values( worker_id=worker_id, hostname=host, pid=pid, + note=note, ) stmt = stmt.on_conflict_do_update( index_elements=["worker_id"], set_={ "hostname": host, "pid": pid, + "note": note, "last_seen": func.now(), }, ) @@ -172,7 +188,11 @@ async def run_worker_loop( now = time.monotonic() if now - last_heartbeat >= heartbeat_interval: try: - await _touch_worker(worker_id) + await _touch_worker( + worker_id, + concurrency=concurrency, + allowed_job_types=allowed_job_types, + ) except Exception as exc: print(f"[WARN] worker cleanup: {exc}") last_heartbeat = now @@ -184,6 +204,12 @@ async def run_worker_loop( f"[*] Recovered stale jobs: retry={recovered.get('recovered', 0)} " f"failed={recovered.get('failed', 0)}" ) + for task_id in recovered.get("failed_task_ids", []) or []: + await task_service.update_task( + task_id, + status="FAILED", + message="后台任务心跳超时,任务已被标记为失败", + ) except Exception as exc: print(f"[WARN] recover_stale: {exc}") last_recover = now diff --git a/backend/app/services/landsar_lt1_production_service.py b/backend/app/services/landsar_lt1_production_service.py new file mode 100644 index 0000000..0c1f8ad --- /dev/null +++ b/backend/app/services/landsar_lt1_production_service.py @@ -0,0 +1,1080 @@ +from __future__ import annotations + +import hashlib +import json +import mimetypes +import os +import re +import shutil +import subprocess +from dataclasses import dataclass, replace +from datetime import datetime, timezone +from typing import Any, Callable, Dict, Iterable, List, Optional + +from sqlalchemy import delete, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..config import settings, split_env_paths +from ..dinsar_engines.landsar_engine import LandsarEngine +from ..models import RadarDataORM, ResultAssetORM, ResultCatalogStateORM, ResultProductORM, SARSceneGeoORM + + +LANDSAR_LT1_CATALOG = "lt1_landsar" +IMPORT_PROID = "100016" +ORBIT_PROID = "100206" +PROFILE_SCENE_IMPORT = "landsar.import.lt1.scene.v1" +PROFILE_STACK_IMPORT = "landsar.import.lt1.stack.v1" +PRODUCT_FAMILY_SCENE = "lt1_scene_import" +PRODUCT_FAMILY_STACK = "lt1_stack_import" + + +ProgressCallback = Callable[[Dict[str, Any]], None] + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _utc_text(value: Optional[datetime] = None) -> str: + return (value or _utc_now()).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _norm_path(path: Any) -> str: + text = str(path or "").strip().strip('"') + if not text: + return "" + return os.path.normpath(os.path.abspath(os.path.expandvars(os.path.expanduser(text)))) + + +def _safe_key(value: Any, *, fallback: str = "lt1") -> str: + text = str(value or "").strip() + if not text: + text = fallback + text = re.sub(r"[^A-Za-z0-9_.-]+", "_", text).strip("._-") + return text[:96] or fallback + + +def _short_hash(values: Iterable[Any], *, length: int = 10) -> str: + digest = hashlib.sha256() + for value in values: + digest.update(str(value or "").encode("utf-8", errors="ignore")) + digest.update(b"\0") + return digest.hexdigest()[:length] + + +def _write_json(path: str, payload: Dict[str, Any]) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8", newline="\n") as fp: + json.dump(payload, fp, ensure_ascii=False, indent=2) + fp.write("\n") + + +def _read_text_tail(path: str, *, max_chars: int = 12000) -> str: + if not path or not os.path.isfile(path): + return "" + try: + with open(path, "rb") as fp: + fp.seek(0, os.SEEK_END) + size = fp.tell() + fp.seek(max(0, size - max_chars)) + return fp.read().decode("utf-8", errors="replace") + except OSError: + return "" + + +def _file_size(path: str) -> Optional[int]: + try: + return os.path.getsize(path) + except OSError: + return None + + +def _media_type(path: str) -> tuple[Optional[str], Optional[str]]: + ext = os.path.splitext(path)[1].lower().lstrip(".") + media, _ = mimetypes.guess_type(path) + if ext == "xml": + media = "application/xml" + elif ext == "json": + media = "application/json" + elif ext in {"tif", "tiff"}: + media = "image/tiff" + elif ext == "txt": + media = "text/plain" + return ext or None, media + + +def _success_in_logs(log_path: str, keywords: Iterable[str], exit_code: int) -> bool: + content = _read_text_tail(log_path, max_chars=80000).lower() + if any(str(keyword).lower() in content for keyword in keywords): + return True + return int(exit_code) == 0 and ("console success" in content or "success" in content) + + +def _run_console( + console_path: str, + param_file: str, + log_path: str, + *, + cwd: str, + timeout_seconds: int, +) -> Dict[str, Any]: + os.makedirs(os.path.dirname(log_path), exist_ok=True) + started_at = _utc_now() + env = dict(os.environ) + configured_runtime_dirs: List[str] = [] + for env_name in ("LANDSAR_RUNTIME_PATHS", "LANDSAR_DLL_DIRS"): + configured_runtime_dirs.extend( + _norm_path(item) + for item in str(os.environ.get(env_name, "") or "").split(os.pathsep) + if _norm_path(item) + ) + system_root = os.environ.get("SystemRoot", r"C:\Windows") + prepend_dirs = [ + os.path.dirname(_norm_path(console_path)), + _norm_path(getattr(settings, "LANDSAR_HOME", "")), + *configured_runtime_dirs, + os.path.join(system_root, "System32"), + os.path.join(system_root, "SysWOW64"), + system_root, + ] + current_path = env.get("PATH", "") + env["PATH"] = os.pathsep.join([path for path in prepend_dirs if path] + ([current_path] if current_path else [])) + command = [console_path, param_file] + with open(log_path, "a", encoding="utf-8", errors="replace") as log_fp: + log_fp.write(f"\n[{_utc_text(started_at)}] command: {' '.join(command)}\n") + log_fp.flush() + try: + proc = subprocess.run( + command, + cwd=cwd, + env=env, + stdout=log_fp, + stderr=subprocess.STDOUT, + text=True, + timeout=max(60, int(timeout_seconds or 3600)), + check=False, + ) + returncode = int(proc.returncode) + except subprocess.TimeoutExpired: + returncode = -9 + log_fp.write(f"\n[{_utc_text()}] timeout after {int(timeout_seconds or 3600)} seconds\n") + log_fp.write(f"\n[{_utc_text()}] returncode: {returncode}\n") + return { + "command": command, + "returncode": returncode, + "started_at": _utc_text(started_at), + "finished_at": _utc_text(), + "log_path": log_path, + "stdout_tail": _read_text_tail(log_path), + } + + +def generate_lt1_import_param_file( + filepath: str, + scene_dirs: List[str], + export_dir: str, + *, + sat_mode: str = "MONO", +) -> str: + if not scene_dirs: + raise ValueError("scene_dirs cannot be empty.") + mode = str(sat_mode or "MONO").strip().upper() + lines = [ + "卫星数据导入LT-1", + f"处理编号 {IMPORT_PROID}", + "设置数据导入形式_0文件夹导入_1数据导入 文件夹导入", + "读取成像参数文件_0否_1是 1", + "读取SLC数据文件_0否_1是 1", + "文件夹导入标识 TRUE", + f"文件夹导入个数 {len(scene_dirs)}", + ] + for idx, scene_dir in enumerate(scene_dirs, 1): + lines.append(f"文件夹{idx}路径 <{scene_dir}>") + lines.extend( + [ + "数据导入 FALSE", + f"输入卫星数据格式 {mode}", + "输入主影像成像参数文件路径 <>", + "输入主影像SLC数据文件路径 <>", + "输入主影像RPB数据文件路径 <>", + "输入辅影像成像参数文件路径 <>", + "输入辅影像SLC数据文件路径 <>", + "输入辅影像RPB数据文件路径 <>", + "设置数据导出目标路径_0原目录_1新目录 1", + f"设置输出文件目录 <{export_dir}>", + ] + ) + os.makedirs(os.path.dirname(filepath), exist_ok=True) + with open(filepath, "w", encoding="utf-8", newline="\n") as fp: + fp.write("\n".join(lines) + "\n") + return filepath + + +def generate_lt1_orbit_param_file( + filepath: str, + xml_paths: List[str], + orbit_dir: str, + output_dir: str, + *, + xml_save_mode: int = 0, + export_to_new: bool = False, +) -> str: + if not xml_paths: + raise ValueError("xml_paths cannot be empty.") + lines = [ + "LT-1精密轨道数据导入", + f"处理编号 {ORBIT_PROID}", + f"输入数据个数 {len(xml_paths)}", + ] + for idx, xml_path in enumerate(xml_paths, 1): + lines.append(f"输入数据{idx}的xml <{xml_path}>") + lines.extend( + [ + f"输入精密轨道数据文件夹 <{orbit_dir}>", + f"选择XML文件保存方式 {int(xml_save_mode)}", + f"设置数据导出目录形式0原目录1新目录 {1 if export_to_new else 0}", + f"输出更新处理后数据目录 <{output_dir}>", + ] + ) + os.makedirs(os.path.dirname(filepath), exist_ok=True) + with open(filepath, "w", encoding="utf-8", newline="\n") as fp: + fp.write("\n".join(lines) + "\n") + return filepath + + +@dataclass(frozen=True) +class Lt1ImportRequest: + scene_dirs: List[str] + source_asset_ids: List[int] + radar_data_ids: List[int] + materialize_assets: bool = True + materialize_overwrite: bool = False + mode: str = "scene" + task_name: Optional[str] = None + sat_mode: str = "MONO" + import_orbit: bool = False + orbit_dir: Optional[str] = None + timeout_seconds: int = 7200 + + +class LandsarLt1ProductionService: + def __init__(self) -> None: + self._engine = LandsarEngine() + + @property + def publish_root(self) -> str: + return _norm_path(os.path.join(settings.RESULT_PUBLISH_ROOT, LANDSAR_LT1_CATALOG)) + + def _console_path(self) -> str: + return _norm_path(getattr(settings, "LANDSAR_CONSOLE_EXE", "")) + + def _landsar_home(self) -> str: + return _norm_path(getattr(settings, "LANDSAR_HOME", "")) or os.path.dirname(self._console_path()) + + def check_capabilities(self) -> Dict[str, Any]: + availability = self._engine.check_available() + orbit_roots = [ + _norm_path(path) + for path in split_env_paths(getattr(settings, "ORBIT_SOURCE_DIRS", "")) + if _norm_path(path) + ] + return { + "catalog_name": LANDSAR_LT1_CATALOG, + "supported_profiles": [PROFILE_SCENE_IMPORT, PROFILE_STACK_IMPORT], + "proids": { + "import": IMPORT_PROID, + "orbit": ORBIT_PROID, + }, + "available": bool(getattr(availability, "available", False)), + "status": getattr(availability, "status", "unknown"), + "message": getattr(availability, "message", ""), + "checks": getattr(availability, "checks", []), + "console_path": self._console_path(), + "landsar_home": self._landsar_home(), + "publish_root": self.publish_root, + "orbit_roots": orbit_roots, + } + + def _normalize_request(self, payload: Dict[str, Any], *, allow_empty_scene_dirs: bool = False) -> Lt1ImportRequest: + raw_dirs = payload.get("scene_dirs") or payload.get("source_dirs") or [] + if not isinstance(raw_dirs, list): + raise ValueError("scene_dirs must be a list.") + scene_dirs: List[str] = [] + for raw in raw_dirs: + path = _norm_path(raw) + if not path: + continue + if path not in scene_dirs: + scene_dirs.append(path) + if not scene_dirs and not allow_empty_scene_dirs: + raise ValueError("scene_dirs cannot be empty.") + for path in scene_dirs: + if not os.path.isdir(path): + raise FileNotFoundError(f"LT-1 scene directory not found: {path}") + + source_asset_ids = self._normalize_id_list(payload.get("source_asset_ids")) + radar_data_ids = self._normalize_id_list(payload.get("radar_data_ids")) + planned_count = len(scene_dirs) + len(source_asset_ids) + len(radar_data_ids) + + mode = str(payload.get("mode") or ("stack" if planned_count > 1 else "scene")).strip().lower() + if mode not in {"scene", "stack"}: + raise ValueError("mode must be scene or stack.") + if mode == "scene" and planned_count != 1: + raise ValueError("scene mode requires exactly one scene directory or source asset.") + + orbit_dir = _norm_path(payload.get("orbit_dir")) + return Lt1ImportRequest( + scene_dirs=scene_dirs, + source_asset_ids=source_asset_ids, + radar_data_ids=radar_data_ids, + materialize_assets=bool(payload.get("materialize_assets", True)), + materialize_overwrite=bool(payload.get("materialize_overwrite", False)), + mode=mode, + task_name=str(payload.get("task_name") or "").strip() or None, + sat_mode=str(payload.get("sat_mode") or "MONO").strip().upper(), + import_orbit=bool(payload.get("import_orbit")), + orbit_dir=orbit_dir or None, + timeout_seconds=max(60, int(payload.get("timeout_seconds") or 7200)), + ) + + def _normalize_id_list(self, value: Any) -> List[int]: + if value in (None, ""): + return [] + if not isinstance(value, list): + raise ValueError("asset id fields must be lists.") + normalized: List[int] = [] + for raw in value: + try: + parsed = int(raw) + except (TypeError, ValueError): + continue + if parsed > 0 and parsed not in normalized: + normalized.append(parsed) + return normalized + + def preview_import(self, payload: Dict[str, Any]) -> Dict[str, Any]: + request = self._normalize_request(payload, allow_empty_scene_dirs=True) + scene_info = [self._inspect_scene_dir(path) for path in request.scene_dirs] + orbit_dir = request.orbit_dir or self._default_orbit_dir() + blockers: List[str] = [] + warnings: List[str] = [] + planned_scene_count = len(request.scene_dirs) + len(request.source_asset_ids) + len(request.radar_data_ids) + if not planned_scene_count: + blockers.append("No LT-1 scene directory or source asset was selected.") + for item in scene_info: + if not item["xml_count"]: + blockers.append(f"Missing LT-1 XML in {item['path']}") + if not item["slc_count"]: + blockers.append(f"Missing LT-1 SLC TIFF in {item['path']}") + if request.import_orbit and not orbit_dir: + blockers.append("import_orbit is enabled but no orbit directory is configured.") + elif request.import_orbit and orbit_dir and not os.path.isdir(orbit_dir): + blockers.append(f"Orbit directory not found: {orbit_dir}") + if request.mode == "stack" and planned_scene_count < 2: + warnings.append("Stack mode normally expects at least two scenes.") + return { + "allow_submit": not blockers, + "blockers": blockers, + "warnings": warnings, + "mode": request.mode, + "profile_code": PROFILE_STACK_IMPORT if request.mode == "stack" else PROFILE_SCENE_IMPORT, + "scene_count": planned_scene_count, + "directory_scene_count": len(request.scene_dirs), + "source_asset_count": len(request.source_asset_ids), + "radar_data_count": len(request.radar_data_ids), + "scenes": scene_info, + "sat_mode": request.sat_mode, + "import_orbit": request.import_orbit, + "orbit_dir": orbit_dir, + "publish_root": self.publish_root, + } + + def _inspect_scene_dir(self, path: str) -> Dict[str, Any]: + xmls: List[str] = [] + slcs: List[str] = [] + try: + with os.scandir(path) as entries: + for entry in entries: + if not entry.is_file(): + continue + lower = entry.name.lower() + if lower.startswith("lt1") and lower.endswith(".xml"): + xmls.append(entry.path) + elif lower.startswith("lt1") and lower.endswith((".tif", ".tiff")): + slcs.append(entry.path) + except OSError: + pass + return { + "path": path, + "name": os.path.basename(path), + "xml_count": len(xmls), + "slc_count": len(slcs), + "sample_xml": xmls[0] if xmls else "", + "sample_slc": slcs[0] if slcs else "", + } + + def _default_orbit_dir(self) -> str: + for raw in split_env_paths(getattr(settings, "ORBIT_SOURCE_DIRS", "")): + path = _norm_path(raw) + if path and os.path.isdir(path): + return path + return "" + + def _product_keys(self, request: Lt1ImportRequest) -> tuple[str, str, str]: + profile = PROFILE_STACK_IMPORT if request.mode == "stack" else PROFILE_SCENE_IMPORT + family = PRODUCT_FAMILY_STACK if request.mode == "stack" else PRODUCT_FAMILY_SCENE + if request.mode == "scene": + identity_key = _safe_key(os.path.basename(request.scene_dirs[0])) + else: + names = [_safe_key(os.path.basename(path)) for path in request.scene_dirs] + identity_key = _safe_key(f"stack_{len(names)}_{names[0]}_{names[-1]}_{_short_hash(request.scene_dirs)}") + return profile, family, identity_key + + def _ensure_runtime_ready(self) -> None: + availability = self._engine.check_available() + if not bool(getattr(availability, "available", False)): + raise RuntimeError(getattr(availability, "message", "") or "LandSAR console is unavailable.") + + def _find_imported_xmls(self, input_data_dir: str) -> List[str]: + matches: List[str] = [] + for root, _, files in os.walk(input_data_dir): + for name in files: + lower = name.lower() + if lower.startswith("lt1") and lower.endswith("_slc.xml"): + matches.append(os.path.join(root, name)) + return sorted(matches) + + def _copy_scene_summary(self, request: Lt1ImportRequest, run_dir: str) -> str: + target = os.path.join(run_dir, "source_scenes.json") + payload = { + "scene_count": len(request.scene_dirs), + "scene_dirs": request.scene_dirs, + "source_asset_ids": request.source_asset_ids, + "radar_data_ids": request.radar_data_ids, + "mode": request.mode, + "sat_mode": request.sat_mode, + "generated_at": _utc_text(), + } + _write_json(target, payload) + return target + + async def find_produced_source_asset_map( + self, + db: AsyncSession, + source_asset_ids: Iterable[int], + ) -> Dict[int, Dict[str, Any]]: + ids: List[int] = [] + for item in source_asset_ids: + try: + parsed = int(item or 0) + except (TypeError, ValueError): + continue + if parsed > 0 and parsed not in ids: + ids.append(parsed) + if not ids: + return {} + result = await db.execute( + select(RadarDataORM.source_product_ref_id, SARSceneGeoORM) + .join(SARSceneGeoORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id) + .where( + RadarDataORM.source_product_ref_id.in_(ids), + RadarDataORM.satellite_family == "LT1", + SARSceneGeoORM.status == "DONE", + SARSceneGeoORM.analysis_tif_path.isnot(None), + SARSceneGeoORM.analysis_engine == "lt_gamma", + SARSceneGeoORM.analysis_profile == "lt1_gamma_geocoded_mli", + ) + .order_by(SARSceneGeoORM.updated_at.desc().nullslast(), SARSceneGeoORM.id.desc()) + ) + wanted = set(ids) + produced: Dict[int, Dict[str, Any]] = {} + for source_id, scene in result.all(): + try: + parsed_source_id = int(source_id or 0) + except (TypeError, ValueError): + continue + if parsed_source_id not in wanted or parsed_source_id in produced: + continue + produced[parsed_source_id] = { + "product_db_id": scene.id, + "scene_id": scene.id, + "radar_data_id": scene.radar_data_id, + "product_id": f"sar_scene_geo:{scene.id}", + "product_family": "lt1_analysis_ready_geotiff", + "engine_code": scene.analysis_engine, + "profile_code": scene.analysis_profile, + "analysis_tif_path": scene.analysis_tif_path, + "analysis_dir": scene.analysis_dir, + "analysis_preview_path": scene.analysis_preview_path, + "published_at": scene.updated_at.isoformat() if scene.updated_at else None, + "native_output_dir": scene.analysis_dir, + } + return produced + + async def decorate_source_asset_payloads(self, db: AsyncSession, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + produced = await self.find_produced_source_asset_map( + db, + [int(item.get("id") or 0) for item in items], + ) + for item in items: + marker = produced.get(int(item.get("id") or 0)) + item["lt1_image_produced"] = bool(marker) + item["lt1_image_product"] = marker + item["lt1_landsar_produced"] = bool(marker) + item["lt1_landsar_product"] = marker + return items + + def run_import( + self, + payload: Dict[str, Any], + *, + progress_callback: Optional[ProgressCallback] = None, + ) -> Dict[str, Any]: + original_source_asset_ids = self._normalize_id_list(payload.get("source_asset_ids")) + original_radar_data_ids = self._normalize_id_list(payload.get("radar_data_ids")) + prepared_scene_dirs = [ + _norm_path(path) + for path in (payload.get("__prepared_scene_dirs") or []) + if _norm_path(path) + ] + if prepared_scene_dirs: + payload = { + **payload, + "scene_dirs": [*payload.get("scene_dirs", []), *prepared_scene_dirs], + "source_asset_ids": [], + "radar_data_ids": [], + } + request = self._normalize_request(payload, allow_empty_scene_dirs=True) + if prepared_scene_dirs: + request = replace( + request, + source_asset_ids=original_source_asset_ids, + radar_data_ids=original_radar_data_ids, + ) + if (request.source_asset_ids or request.radar_data_ids) and not request.scene_dirs: + raise ValueError("Source assets must be materialized before LandSAR LT-1 import starts.") + self._ensure_runtime_ready() + + profile, product_family, identity_key = self._product_keys(request) + started_at = _utc_now() + run_hash = _short_hash([started_at.isoformat(), *request.scene_dirs]) + run_key = f"run_{started_at.strftime('%Y%m%dT%H%M%SZ')}_{run_hash}" + product_prefix = "lt1_stack" if request.mode == "stack" else "lt1_scene" + readable_len = max(8, 64 - len(product_prefix) - len(run_hash) - 2) + product_id = _safe_key( + f"{product_prefix}_{identity_key[:readable_len]}_{run_hash}", + fallback="lt1_product", + )[:64] + + publish_dir = os.path.join(self.publish_root, product_family, identity_key, "runs", run_key) + native_dir = os.path.join(publish_dir, "native") + input_data_dir = os.path.join(native_dir, "Input_Data") + param_dir = os.path.join(publish_dir, "params") + log_dir = os.path.join(publish_dir, "logs") + os.makedirs(input_data_dir, exist_ok=True) + os.makedirs(param_dir, exist_ok=True) + os.makedirs(log_dir, exist_ok=True) + self._copy_scene_summary(request, publish_dir) + + console_path = self._console_path() + landsar_home = self._landsar_home() + timeout = int(request.timeout_seconds or 7200) + import_param = os.path.join(param_dir, f"{IMPORT_PROID}.txt") + import_log = os.path.join(log_dir, f"{IMPORT_PROID}_console.log") + generate_lt1_import_param_file( + import_param, + request.scene_dirs, + input_data_dir, + sat_mode=request.sat_mode, + ) + self._emit(progress_callback, "import_started", progress=10, message="LandSAR LT-1 import started") + import_run = _run_console( + console_path, + import_param, + import_log, + cwd=landsar_home, + timeout_seconds=timeout, + ) + import_ok = _success_in_logs( + import_log, + ["module [lt-1数据导入] success", "lt-1数据导入] success", "console success"], + int(import_run["returncode"]), + ) + imported_xmls = self._find_imported_xmls(input_data_dir) + if not import_ok or not imported_xmls: + raise RuntimeError( + "LandSAR LT-1 import failed or produced no Input_Data XML. " + f"returncode={import_run['returncode']}; log={import_log}" + ) + + orbit_result: Optional[Dict[str, Any]] = None + orbit_dir = request.orbit_dir or self._default_orbit_dir() + if request.import_orbit: + if not orbit_dir or not os.path.isdir(orbit_dir): + raise FileNotFoundError(f"Orbit directory not found: {orbit_dir or ''}") + orbit_param = os.path.join(param_dir, f"{ORBIT_PROID}.txt") + orbit_log = os.path.join(log_dir, f"{ORBIT_PROID}_console.log") + generate_lt1_orbit_param_file( + orbit_param, + imported_xmls, + orbit_dir, + input_data_dir, + xml_save_mode=0, + export_to_new=False, + ) + self._emit(progress_callback, "orbit_started", progress=60, message="LandSAR LT-1 orbit import started") + orbit_run = _run_console( + console_path, + orbit_param, + orbit_log, + cwd=landsar_home, + timeout_seconds=timeout, + ) + orbit_ok = _success_in_logs( + orbit_log, + ["module [lt-1精密轨道数据导入] success", "精密轨道数据导入] success", "console success"], + int(orbit_run["returncode"]), + ) + if not orbit_ok: + raise RuntimeError( + "LandSAR LT-1 precise orbit import failed. " + f"returncode={orbit_run['returncode']}; log={orbit_log}" + ) + orbit_result = { + **orbit_run, + "param_file": orbit_param, + "orbit_dir": orbit_dir, + "xml_count": len(imported_xmls), + } + + self._emit(progress_callback, "packaging_started", progress=85, message="Registering LT-1 product") + manifest_path = self._build_manifest( + request=request, + product_id=product_id, + profile=profile, + product_family=product_family, + identity_key=identity_key, + run_key=run_key, + publish_dir=publish_dir, + native_dir=native_dir, + input_data_dir=input_data_dir, + started_at=started_at, + import_result={**import_run, "param_file": import_param}, + orbit_result=orbit_result, + imported_xmls=imported_xmls, + materialized=payload.get("__materialized") or [], + task_root=payload.get("__materialize_task_root"), + ) + self._emit(progress_callback, "completed", progress=95, message="LT-1 LandSAR import completed") + return { + "product_id": product_id, + "manifest_path": manifest_path, + "publish_dir": publish_dir, + "native_output_dir": native_dir, + "input_data_dir": input_data_dir, + "run_key": run_key, + "profile_code": profile, + "product_family": product_family, + "imported_xml_count": len(imported_xmls), + "import_orbit": bool(orbit_result), + } + + def _emit(self, callback: Optional[ProgressCallback], event: str, **payload: Any) -> None: + if not callable(callback): + return + try: + callback({"event": event, **payload}) + except Exception: + return + + def _collect_assets(self, publish_dir: str, native_dir: str, manifest_path: str) -> List[Dict[str, Any]]: + assets: List[Dict[str, Any]] = [] + + def add(path: str, role: str, *, primary: bool = False, required: bool = False) -> None: + absolute = _norm_path(path) + if not absolute: + return + try: + relative = os.path.relpath(absolute, publish_dir) + except ValueError: + relative = absolute + fmt, media = _media_type(absolute) + assets.append( + { + "role": role, + "name": os.path.basename(absolute), + "relative_path": relative, + "absolute_path": absolute, + "format": fmt, + "media_type": media, + "is_required": required, + "is_primary": primary, + "exists": os.path.isfile(absolute), + "file_size": _file_size(absolute), + } + ) + + add(manifest_path, "manifest", required=True) + source_summary = os.path.join(publish_dir, "source_scenes.json") + add(source_summary, "source_summary", required=True) + for folder in (os.path.join(publish_dir, "params"), os.path.join(publish_dir, "logs")): + if not os.path.isdir(folder): + continue + for name in sorted(os.listdir(folder)): + path = os.path.join(folder, name) + if os.path.isfile(path): + add(path, "param" if name.lower().endswith(".txt") else "log", required=True) + + input_data_dir = os.path.join(native_dir, "Input_Data") + primary_set = False + for root, _, files in os.walk(input_data_dir): + for name in sorted(files): + lower = name.lower() + path = os.path.join(root, name) + if lower.endswith(".xml"): + add(path, "input_xml", primary=not primary_set, required=True) + primary_set = True + elif lower.endswith((".tif", ".tiff")): + add(path, "input_tif", primary=not primary_set) + primary_set = True + elif lower.endswith((".jpg", ".jpeg", ".png", ".webp")): + add(path, "preview") + return assets + + def _build_manifest( + self, + *, + request: Lt1ImportRequest, + product_id: str, + profile: str, + product_family: str, + identity_key: str, + run_key: str, + publish_dir: str, + native_dir: str, + input_data_dir: str, + started_at: datetime, + import_result: Dict[str, Any], + orbit_result: Optional[Dict[str, Any]], + imported_xmls: List[str], + materialized: List[Dict[str, Any]], + task_root: Optional[str], + ) -> str: + manifest_path = os.path.join(publish_dir, "manifest.json") + task_name = request.task_name or ( + os.path.basename(request.scene_dirs[0]) if request.mode == "scene" else f"LT-1 stack {len(request.scene_dirs)} scenes" + ) + summary = { + "scene_count": len(request.scene_dirs), + "imported_xml_count": len(imported_xmls), + "source_asset_ids": request.source_asset_ids, + "radar_data_ids": request.radar_data_ids, + "materialized_dirs": [item.get("scene_dir") for item in materialized if item.get("scene_dir")], + "materialize_task_root": task_root, + "input_data_dir": input_data_dir, + "sat_mode": request.sat_mode, + "import_orbit": bool(orbit_result), + "orbit_dir": (orbit_result or {}).get("orbit_dir"), + "import_returncode": import_result.get("returncode"), + "orbit_returncode": (orbit_result or {}).get("returncode"), + } + payload: Dict[str, Any] = { + "schema_version": "lt1_landsar.import.v1", + "catalog_name": LANDSAR_LT1_CATALOG, + "product_family": product_family, + "product_type": "landsar_input_data", + "product_id": product_id, + "display_name": task_name, + "task_name": task_name, + "identity": { + "mode": request.mode, + "scene_key": identity_key if request.mode == "scene" else None, + "stack_key": identity_key if request.mode == "stack" else None, + "run_key": run_key, + }, + "engine": { + "code": "landsar", + "console_path": self._console_path(), + "home": self._landsar_home(), + }, + "processor": { + "code": "landsar.lt1.import", + "profile_code": profile, + "import_proid": IMPORT_PROID, + "orbit_proid": ORBIT_PROID if orbit_result else None, + }, + "run": { + "started_at": _utc_text(started_at), + "finished_at": _utc_text(), + "status": "COMPLETED", + }, + "source": { + "scene_dirs": request.scene_dirs, + "source_asset_ids": request.source_asset_ids, + "radar_data_ids": request.radar_data_ids, + "materialized": materialized, + "native_output_dir": native_dir, + "publish_dir": publish_dir, + }, + "summary": summary, + "execution": { + "import": import_result, + "orbit": orbit_result, + }, + "assets": [], + } + _write_json(manifest_path, payload) + payload["assets"] = self._collect_assets(publish_dir, native_dir, manifest_path) + _write_json(manifest_path, payload) + execution_manifest_path = os.path.join(publish_dir, "execution_manifest.json") + shutil.copy2(manifest_path, execution_manifest_path) + return manifest_path + + async def register_manifest(self, db: AsyncSession, manifest_path: str) -> Dict[str, Any]: + normalized_manifest = _norm_path(manifest_path) + with open(normalized_manifest, "r", encoding="utf-8") as fp: + manifest = json.load(fp) + + product_id = str(manifest.get("product_id") or "").strip() + if not product_id: + raise ValueError("manifest.product_id is required.") + if str(manifest.get("catalog_name") or "") != LANDSAR_LT1_CATALOG: + raise ValueError("manifest is not a LandSAR LT-1 product manifest.") + + await db.execute(delete(ResultProductORM).where(ResultProductORM.product_id == product_id)) + + identity = manifest.get("identity") or {} + processor = manifest.get("processor") or {} + engine = manifest.get("engine") or {} + source = manifest.get("source") or {} + summary = manifest.get("summary") or {} + run = manifest.get("run") or {} + publish_dir = _norm_path(source.get("publish_dir")) or os.path.dirname(normalized_manifest) + native_dir = _norm_path(source.get("native_output_dir")) + assets = list(manifest.get("assets") or []) + primary_asset_path = "" + for asset in assets: + if asset.get("is_primary"): + primary_asset_path = _norm_path(asset.get("absolute_path")) + break + + product = ResultProductORM( + product_id=product_id, + catalog_name=LANDSAR_LT1_CATALOG, + product_family=str(manifest.get("product_family") or PRODUCT_FAMILY_SCENE), + product_type=str(manifest.get("product_type") or "landsar_input_data"), + display_name=str(manifest.get("display_name") or product_id), + task_name=str(manifest.get("task_name") or ""), + pair_key=None, + stack_key=identity.get("stack_key"), + run_key=identity.get("run_key"), + profile_code=processor.get("profile_code"), + engine_code=str(engine.get("code") or "landsar"), + processor_code=str(processor.get("code") or "landsar.lt1.import"), + package_schema=str(manifest.get("schema_version") or "lt1_landsar.import.v1"), + package_layout="lt1_landsar_import", + status="READY", + health_status="OK", + publish_dir=publish_dir, + manifest_path=normalized_manifest, + native_output_dir=native_dir, + primary_asset_path=primary_asset_path or None, + summary_json=summary, + tags_json={ + "catalog_name": LANDSAR_LT1_CATALOG, + "mode": identity.get("mode"), + "import_orbit": bool(summary.get("import_orbit")), + }, + produced_at=self._parse_time(run.get("finished_at")) or _utc_now(), + published_at=_utc_now(), + ) + db.add(product) + await db.flush() + + for asset in assets: + absolute = _norm_path(asset.get("absolute_path")) + fmt = asset.get("format") + media = asset.get("media_type") + if not fmt or not media: + fmt, media = _media_type(absolute) + db.add( + ResultAssetORM( + product_ref_id=product.id, + asset_role=str(asset.get("role") or "asset")[:32], + asset_name=str(asset.get("name") or os.path.basename(absolute) or "asset"), + relative_path=str(asset.get("relative_path") or ""), + absolute_path=absolute, + format=fmt, + media_type=media, + is_required=bool(asset.get("is_required")), + is_primary=bool(asset.get("is_primary")), + exists_flag=os.path.isfile(absolute), + file_size=_file_size(absolute), + ) + ) + + await self._update_catalog_state(db) + await db.commit() + return { + "product_db_id": product.id, + "product_id": product_id, + "asset_count": len(assets), + "manifest_path": normalized_manifest, + } + + def _parse_time(self, value: Any) -> Optional[datetime]: + text = str(value or "").strip() + if not text: + return None + try: + return datetime.strptime(text, "%Y-%m-%dT%H:%M:%SZ") + except ValueError: + return None + + async def _update_catalog_state(self, db: AsyncSession) -> None: + result = await db.execute( + select(ResultCatalogStateORM).where(ResultCatalogStateORM.catalog_name == LANDSAR_LT1_CATALOG) + ) + state = result.scalar_one_or_none() + count_result = await db.execute( + select(func.count(ResultProductORM.id)).where(ResultProductORM.catalog_name == LANDSAR_LT1_CATALOG) + ) + db_count = int(count_result.scalar_one() or 0) + if state is None: + state = ResultCatalogStateORM( + catalog_name=LANDSAR_LT1_CATALOG, + product_family="lt1", + storage_root=self.publish_root, + ) + db.add(state) + state.status = "READY" + state.needs_rebuild = False + state.storage_root = self.publish_root + state.db_count = db_count + state.manifest_count = db_count + state.last_message = f"LandSAR LT-1 catalog ready: {db_count} products" + state.last_incremental_scan_at = _utc_now() + + async def list_products( + self, + db: AsyncSession, + *, + limit: int = 100, + offset: int = 0, + status: Optional[str] = None, + query: Optional[str] = None, + ) -> Dict[str, Any]: + safe_limit = max(1, min(500, int(limit or 100))) + safe_offset = max(0, int(offset or 0)) + filters = [ResultProductORM.catalog_name == LANDSAR_LT1_CATALOG] + if status: + filters.append(ResultProductORM.status == str(status).strip().upper()) + if query: + like = f"%{str(query).strip()}%" + filters.append( + or_( + ResultProductORM.product_id.ilike(like), + ResultProductORM.display_name.ilike(like), + ResultProductORM.task_name.ilike(like), + ResultProductORM.stack_key.ilike(like), + ResultProductORM.run_key.ilike(like), + ) + ) + total_result = await db.execute(select(func.count(ResultProductORM.id)).where(*filters)) + total = int(total_result.scalar_one() or 0) + rows_result = await db.execute( + select(ResultProductORM) + .where(*filters) + .order_by(ResultProductORM.published_at.desc(), ResultProductORM.id.desc()) + .limit(safe_limit) + .offset(safe_offset) + ) + products = rows_result.scalars().all() + return { + "total": total, + "limit": safe_limit, + "offset": safe_offset, + "items": [self._serialize_product(product) for product in products], + } + + async def get_product_detail(self, db: AsyncSession, *, product_id: int) -> Optional[Dict[str, Any]]: + result = await db.execute( + select(ResultProductORM) + .where( + ResultProductORM.id == product_id, + ResultProductORM.catalog_name == LANDSAR_LT1_CATALOG, + ) + ) + product = result.scalar_one_or_none() + if product is None: + 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()) + ) + detail = self._serialize_product(product) + detail["assets"] = [self._serialize_asset(asset) for asset in assets_result.scalars().all()] + return detail + + async def get_asset(self, db: AsyncSession, *, product_id: int, asset_id: int) -> Optional[ResultAssetORM]: + product_result = await db.execute( + select(ResultProductORM.id).where( + ResultProductORM.id == product_id, + ResultProductORM.catalog_name == LANDSAR_LT1_CATALOG, + ) + ) + product_db_id = product_result.scalar_one_or_none() + if product_db_id is None: + return None + asset_result = await db.execute( + select(ResultAssetORM).where( + ResultAssetORM.id == asset_id, + ResultAssetORM.product_ref_id == product_db_id, + ) + ) + return asset_result.scalar_one_or_none() + + def _serialize_product(self, product: ResultProductORM) -> Dict[str, Any]: + return { + "id": product.id, + "product_id": product.product_id, + "catalog_name": product.catalog_name, + "product_family": product.product_family, + "product_type": product.product_type, + "display_name": product.display_name, + "task_name": product.task_name, + "stack_key": product.stack_key, + "run_key": product.run_key, + "profile_code": product.profile_code, + "engine_code": product.engine_code, + "status": product.status, + "health_status": product.health_status, + "publish_dir": product.publish_dir, + "manifest_path": product.manifest_path, + "native_output_dir": product.native_output_dir, + "primary_asset_path": product.primary_asset_path, + "summary": product.summary_json or {}, + "tags": product.tags_json or {}, + "produced_at": product.produced_at.isoformat() if product.produced_at else None, + "published_at": product.published_at.isoformat() if product.published_at else None, + "registered_at": product.registered_at.isoformat() if product.registered_at else None, + } + + def _serialize_asset(self, asset: ResultAssetORM) -> Dict[str, Any]: + return { + "id": asset.id, + "role": asset.asset_role, + "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": asset.exists_flag, + "file_size": asset.file_size, + } + + +landsar_lt1_production_service = LandsarLt1ProductionService() diff --git a/backend/app/services/lt_gamma_scene_service.py b/backend/app/services/lt_gamma_scene_service.py index dca94b1..4dae628 100644 --- a/backend/app/services/lt_gamma_scene_service.py +++ b/backend/app/services/lt_gamma_scene_service.py @@ -88,6 +88,15 @@ def run_lt_gamma_scene_preprocess( if not runner.is_file(): raise FileNotFoundError(f"Gamma scene runner not found: {runner}") + analysis_dem_path = ( + settings.SAR_ANALYSIS_DEM_PATH + or settings.GAMMA_SBAS_DEM_PATH + or settings.PYINT_PREPARED_DEM_PATH + or _prepared_dem_path() + ) + if not analysis_dem_path: + raise RuntimeError("SAR_ANALYSIS_DEM_PATH is not configured for LT analysis GeoTIFF production") + args = [ pyint_python, to_wsl_path(str(runner)), @@ -102,7 +111,11 @@ def run_lt_gamma_scene_preprocess( "--dem-root", to_wsl_path(str(settings.PYINT_DEM_ROOT)), "--prepared-dem-path", - to_wsl_path(_prepared_dem_path()), + to_wsl_path(str(analysis_dem_path)), + "--dem-resolution-m", + str(float(settings.SAR_ANALYSIS_DEM_RESOLUTION_M or 30.0)), + "--target-grid-size-m", + str(float(settings.SAR_ANALYSIS_TARGET_GRID_SIZE_M or 30.0)), "--project-name", run_name, "--date", @@ -110,9 +123,13 @@ def run_lt_gamma_scene_preprocess( "--satellite-family", "LT1", "--range-looks", - str(DEFAULT_RANGE_LOOKS), + str(int(settings.SAR_ANALYSIS_RANGE_LOOKS or DEFAULT_RANGE_LOOKS)), "--azimuth-looks", - str(DEFAULT_AZIMUTH_LOOKS), + str(int(settings.SAR_ANALYSIS_AZIMUTH_LOOKS or DEFAULT_AZIMUTH_LOOKS)), + "--speckle-filter-method", + str(settings.SAR_ANALYSIS_SPECKLE_FILTER_METHOD or "none"), + "--speckle-filter-size", + str(int(settings.SAR_ANALYSIS_SPECKLE_FILTER_SIZE or 5)), "--geo-interp", str(settings.PYINT_GEO_INTERP or "1"), "--nodata-value", diff --git a/backend/app/services/pairing_cache_service.py b/backend/app/services/pairing_cache_service.py index 3eeaaf7..6f34fb1 100644 --- a/backend/app/services/pairing_cache_service.py +++ b/backend/app/services/pairing_cache_service.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import datetime -from typing import Any, Dict, List, Optional, Sequence +from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence from sqlalchemy import delete, func, or_, select, text, update from sqlalchemy.ext.asyncio import AsyncSession @@ -380,6 +380,15 @@ def _incremental_insert_sql() -> str: class PairingCacheService: + async def _notify_progress( + self, + progress_callback: Optional[Callable[[str, int], Awaitable[None]]], + message: str, + progress: int, + ) -> None: + if progress_callback is not None: + await progress_callback(message, progress) + async def _get_state_row(self, db: AsyncSession) -> PairingCacheStateORM: payload = await pairing_state_service.ensure_pairing_cache_state(db, commit=False) result = await db.execute( @@ -501,11 +510,24 @@ class PairingCacheService: db: AsyncSession, *, commit: bool = True, + progress_callback: Optional[Callable[[str, int], Awaitable[None]]] = None, ) -> Dict[str, Any]: await pairing_state_service.ensure_pairing_cache_state(db, commit=False) await self._set_state_rebuilding(db) + if commit: + await db.commit() try: + await self._notify_progress( + progress_callback, + "Pairing cache full rebuild: clearing old metric rows", + 10, + ) delete_result = await db.execute(delete(PairingMetricCacheORM)) + await self._notify_progress( + progress_callback, + "Pairing cache full rebuild: computing spatial/temporal metrics", + 20, + ) await db.execute( text(_full_rebuild_insert_sql()), { @@ -513,7 +535,17 @@ class PairingCacheService: "orientation_rule_version": pairing_state_service.orientation_rule_version, }, ) + await self._notify_progress( + progress_callback, + "Pairing cache full rebuild: resolving dirty scene markers", + 80, + ) resolved_dirty = await self._resolve_dirty_rows(db) + await self._notify_progress( + progress_callback, + "Pairing cache full rebuild: finalizing state", + 90, + ) summary = await self._finalize_state_success(db, full_rebuild=True) if commit: await db.commit() @@ -539,6 +571,7 @@ class PairingCacheService: *, force_full: bool = False, commit: bool = True, + progress_callback: Optional[Callable[[str, int], Awaitable[None]]] = None, ) -> Dict[str, Any]: await pairing_state_service.ensure_pairing_cache_state(db, commit=False) dirty_result = await db.execute( @@ -557,12 +590,21 @@ class PairingCacheService: pair_count=pair_count, force_full=force_full, ): - result = await self.rebuild_metric_cache(db, commit=commit) + result = await self.rebuild_metric_cache( + db, + commit=commit, + progress_callback=progress_callback, + ) result["trigger_dirty_scene_count"] = dirty_scene_count result["forced"] = force_full return result if dirty_scene_count == 0: + await self._notify_progress( + progress_callback, + "Pairing cache reconcile: no dirty scenes, refreshing state", + 80, + ) summary = await self._finalize_state_success(db, full_rebuild=False) if commit: await db.commit() @@ -582,13 +624,24 @@ class PairingCacheService: pair_count=pair_count, force_full=force_full, ): - result = await self.rebuild_metric_cache(db, commit=commit) + result = await self.rebuild_metric_cache( + db, + commit=commit, + progress_callback=progress_callback, + ) result["trigger_dirty_scene_count"] = dirty_scene_count result["forced"] = force_full return result await self._set_state_rebuilding(db) + if commit: + await db.commit() try: + await self._notify_progress( + progress_callback, + f"Pairing cache incremental reconcile: deleting stale rows for {dirty_scene_count} dirty scenes", + 20, + ) delete_result = await db.execute( delete(PairingMetricCacheORM).where( or_( @@ -600,7 +653,7 @@ class PairingCacheService: insert_attempts = 0 insert_sql = text(_incremental_insert_sql()) - for dirty_scene_id in dirty_scene_ids: + for index, dirty_scene_id in enumerate(dirty_scene_ids, start=1): insert_result = await db.execute( insert_sql, { @@ -610,8 +663,28 @@ class PairingCacheService: }, ) insert_attempts += int(insert_result.rowcount or 0) + if index == 1 or index == dirty_scene_count or index % 25 == 0: + progress = 20 + int(index / max(1, dirty_scene_count) * 55) + await self._notify_progress( + progress_callback, + ( + "Pairing cache incremental reconcile: " + f"processed {index}/{dirty_scene_count} dirty scenes" + ), + progress, + ) + await self._notify_progress( + progress_callback, + "Pairing cache incremental reconcile: resolving dirty scene markers", + 80, + ) resolved_dirty = await self._resolve_dirty_rows(db, scene_ids=dirty_scene_ids) + await self._notify_progress( + progress_callback, + "Pairing cache incremental reconcile: finalizing state", + 90, + ) summary = await self._finalize_state_success(db, full_rebuild=False) if commit: await db.commit() diff --git a/backend/app/services/sar_analysis_ready_service.py b/backend/app/services/sar_analysis_ready_service.py index aff70dd..4d98567 100644 --- a/backend/app/services/sar_analysis_ready_service.py +++ b/backend/app/services/sar_analysis_ready_service.py @@ -207,15 +207,20 @@ def _raster_quality(path: Path) -> dict[str, Any]: return quality +def _is_geographic_crs(crs_text: str) -> bool: + text = str(crs_text or "").upper() + return "4326" in text or "GEOGCS" in text or 'UNIT["DEGREE"' in text or "UNIT['DEGREE'" in text + + def _pixel_size_m_from_quality(quality: dict[str, Any]) -> float | None: try: transform = quality.get("transform") or [] xres = abs(float(transform[0])) yres = abs(float(transform[4])) - crs = str(quality.get("crs") or "").upper() + crs = str(quality.get("crs") or "") if not xres or not yres: return None - if crs and "4326" not in crs: + if crs and not _is_geographic_crs(crs): return round((xres + yres) / 2.0, 3) bounds = quality.get("bounds") or {} lat = (float(bounds.get("bottom", 0.0)) + float(bounds.get("top", 0.0))) / 2.0 diff --git a/backend/migrations/009_raw_source_pairing_fields.sql b/backend/migrations/009_raw_source_pairing_fields.sql index f0183e6..d956098 100644 --- a/backend/migrations/009_raw_source_pairing_fields.sql +++ b/backend/migrations/009_raw_source_pairing_fields.sql @@ -174,7 +174,24 @@ SET ) FROM radar_data m, radar_data s WHERE pmc.master_scene_ref_id = m.id - AND pmc.slave_scene_ref_id = s.id; + AND pmc.slave_scene_ref_id = s.id + AND ( + (pmc.scene_center_distance_meters IS NULL AND pmc.spatial_baseline_meters IS NOT NULL) + OR (pmc.master_satellite_family IS NULL AND m.satellite_family IS NOT NULL) + OR (pmc.slave_satellite_family IS NULL AND s.satellite_family IS NOT NULL) + OR (pmc.master_look_direction IS NULL AND m.look_direction IS NOT NULL) + OR (pmc.slave_look_direction IS NULL AND s.look_direction IS NOT NULL) + OR pmc.same_satellite_family IS DISTINCT FROM ( + NULLIF(COALESCE(m.satellite_family, m.satellite), '') IS NOT NULL + AND NULLIF(COALESCE(s.satellite_family, s.satellite), '') IS NOT NULL + AND COALESCE(m.satellite_family, m.satellite) = COALESCE(s.satellite_family, s.satellite) + ) + OR pmc.same_look_direction IS DISTINCT FROM ( + NULLIF(m.look_direction, '') IS NULL + OR NULLIF(s.look_direction, '') IS NULL + OR m.look_direction = s.look_direction + ) + ); UPDATE pairing_cache_state SET diff --git a/backend/tests/test_landsar_lt1_production_service.py b/backend/tests/test_landsar_lt1_production_service.py new file mode 100644 index 0000000..e1ae714 --- /dev/null +++ b/backend/tests/test_landsar_lt1_production_service.py @@ -0,0 +1,307 @@ +from pathlib import Path +import json +import re +import tempfile +import unittest +from datetime import datetime +from types import SimpleNamespace + +import backend.app.services.landsar_lt1_production_service as landsar_lt1_module +from backend.app.services.landsar_lt1_production_service import ( + IMPORT_PROID, + ORBIT_PROID, + generate_lt1_import_param_file, + generate_lt1_orbit_param_file, + landsar_lt1_production_service, +) + + +CN_PROCESS_ID = "\u5904\u7406\u7f16\u53f7" +CN_FOLDER_COUNT = "\u6587\u4ef6\u5939\u5bfc\u5165\u4e2a\u6570" +CN_FOLDER_PATH = "\u6587\u4ef6\u5939{index}\u8def\u5f84" +CN_OUTPUT_DIR = "\u8bbe\u7f6e\u8f93\u51fa\u6587\u4ef6\u76ee\u5f55" +CN_SAT_MODE = "\u8f93\u5165\u536b\u661f\u6570\u636e\u683c\u5f0f" +CN_DATA_COUNT = "\u8f93\u5165\u6570\u636e\u4e2a\u6570" +CN_DATA_XML = "\u8f93\u5165\u6570\u636e{index}\u7684xml" +CN_ORBIT_DIR = "\u8f93\u5165\u7cbe\u5bc6\u8f68\u9053\u6570\u636e\u6587\u4ef6\u5939" + + +def test_generate_lt1_import_param_file_single_scene(tmp_path: Path) -> None: + scene_dir = tmp_path / "LT1A_SCENE" + scene_dir.mkdir() + export_dir = tmp_path / "Input_Data" + param_file = tmp_path / "params" / "100016.txt" + + generate_lt1_import_param_file( + str(param_file), + [str(scene_dir)], + str(export_dir), + sat_mode="MONO", + ) + + content = param_file.read_text(encoding="utf-8") + assert f"{CN_PROCESS_ID} {IMPORT_PROID}" in content + assert f"{CN_FOLDER_COUNT} 1" in content + assert f"{CN_FOLDER_PATH.format(index=1)} <{scene_dir}>" in content + assert f"{CN_OUTPUT_DIR} <{export_dir}>" in content + assert f"{CN_SAT_MODE} MONO" in content + + +def test_generate_lt1_import_param_file_stack(tmp_path: Path) -> None: + scene_dirs = [] + for index in range(3): + scene_dir = tmp_path / f"LT1A_SCENE_{index}" + scene_dir.mkdir() + scene_dirs.append(str(scene_dir)) + param_file = tmp_path / "params" / "100016_stack.txt" + + generate_lt1_import_param_file( + str(param_file), + scene_dirs, + str(tmp_path / "Input_Data"), + sat_mode="BIST", + ) + + content = param_file.read_text(encoding="utf-8") + assert f"{CN_FOLDER_COUNT} 3" in content + assert f"{CN_SAT_MODE} BIST" in content + for index, scene_dir in enumerate(scene_dirs, 1): + assert f"{CN_FOLDER_PATH.format(index=index)} <{scene_dir}>" in content + + +def test_generate_lt1_orbit_param_file(tmp_path: Path) -> None: + xml_paths = [] + for index in range(2): + xml_path = tmp_path / f"LT1A_{index}_SLC.xml" + xml_path.write_text("", encoding="utf-8") + xml_paths.append(str(xml_path)) + orbit_dir = tmp_path / "orbit" + orbit_dir.mkdir() + param_file = tmp_path / "params" / "100206.txt" + + generate_lt1_orbit_param_file( + str(param_file), + xml_paths, + str(orbit_dir), + str(tmp_path / "Input_Data"), + ) + + content = param_file.read_text(encoding="utf-8") + assert f"{CN_PROCESS_ID} {ORBIT_PROID}" in content + assert f"{CN_DATA_COUNT} 2" in content + assert re.search(rf"{CN_ORBIT_DIR}\s+<{re.escape(str(orbit_dir))}>", content) + for index, xml_path in enumerate(xml_paths, 1): + assert f"{CN_DATA_XML.format(index=index)} <{xml_path}>" in content + + +def test_preview_blocks_scene_without_lt1_files(tmp_path: Path) -> None: + scene_dir = tmp_path / "EMPTY_SCENE" + scene_dir.mkdir() + + preview = landsar_lt1_production_service.preview_import( + { + "scene_dirs": [str(scene_dir)], + "mode": "scene", + "sat_mode": "MONO", + "import_orbit": False, + } + ) + + assert preview["allow_submit"] is False + assert any("Missing LT-1 XML" in item for item in preview["blockers"]) + assert any("Missing LT-1 SLC TIFF" in item for item in preview["blockers"]) + + +def test_preview_accepts_source_asset_only_scene() -> None: + preview = landsar_lt1_production_service.preview_import( + { + "source_asset_ids": [101], + "mode": "scene", + "sat_mode": "MONO", + "import_orbit": False, + } + ) + + assert preview["allow_submit"] is True + assert preview["scene_count"] == 1 + assert preview["directory_scene_count"] == 0 + assert preview["source_asset_count"] == 1 + + +def test_stack_preview_counts_source_assets() -> None: + preview = landsar_lt1_production_service.preview_import( + { + "source_asset_ids": [101, 102], + "mode": "stack", + "sat_mode": "MONO", + "import_orbit": False, + } + ) + + assert preview["allow_submit"] is True + assert preview["scene_count"] == 2 + assert not preview["warnings"] + + +def test_scene_mode_rejects_multiple_directories(tmp_path: Path) -> None: + left = tmp_path / "LEFT" + right = tmp_path / "RIGHT" + left.mkdir() + right.mkdir() + + try: + landsar_lt1_production_service.preview_import( + { + "scene_dirs": [str(left), str(right)], + "mode": "scene", + } + ) + except ValueError as exc: + assert "exactly one" in str(exc) + else: + raise AssertionError("scene mode accepted multiple directories") + + +class _ScalarRows: + def __init__(self, rows): + self._rows = rows + + def all(self): + return self._rows + + +class _ExecuteResult: + def __init__(self, rows): + self._rows = rows + + def scalars(self): + return _ScalarRows(self._rows) + + def all(self): + return self._rows + + +class _FakeAsyncDb: + def __init__(self, rows): + self.rows = rows + + async def execute(self, _stmt): + return _ExecuteResult(self.rows) + + +def test_find_produced_source_asset_map_matches_summary_source_ids() -> None: + scene = SimpleNamespace( + id=7, + radar_data_id=77, + analysis_engine="lt_gamma", + analysis_profile="lt1_gamma_geocoded_mli", + analysis_tif_path="D:/ready/analysis_ready.tif", + analysis_dir="D:/ready", + analysis_preview_path="D:/ready/preview.png", + updated_at=datetime(2026, 6, 27, 1, 2, 3), + ) + + async def _run(): + return await landsar_lt1_production_service.find_produced_source_asset_map( + _FakeAsyncDb([(12, scene)]), + [12, 13], + ) + + import asyncio + + produced = asyncio.run(_run()) + assert 12 in produced + assert produced[12]["product_id"] == "sar_scene_geo:7" + assert produced[12]["analysis_tif_path"] == "D:/ready/analysis_ready.tif" + assert 13 not in produced + + +def test_run_import_materialized_asset_does_not_double_count_scene(tmp_path: Path) -> None: + scene_dir = tmp_path / "LT1A_SCENE" + scene_dir.mkdir() + + service = landsar_lt1_production_service + original_ensure = service._ensure_runtime_ready + original_console = service._console_path + original_home = service._landsar_home + + original_publish_root = landsar_lt1_module.settings.RESULT_PUBLISH_ROOT + original_run_console = landsar_lt1_module._run_console + original_find_imported_xmls = service._find_imported_xmls + + def fake_run_console(_console_path, param_file, log_path, *, cwd, timeout_seconds): + Path(log_path).parent.mkdir(parents=True, exist_ok=True) + Path(log_path).write_text("console success\n", encoding="utf-8") + return { + "command": [_console_path, param_file], + "returncode": 0, + "started_at": "2026-06-27T00:00:00Z", + "finished_at": "2026-06-27T00:00:01Z", + "log_path": log_path, + "stdout_tail": "console success", + } + + try: + landsar_lt1_module.settings.RESULT_PUBLISH_ROOT = str(tmp_path / "publish") + service._ensure_runtime_ready = lambda: None + service._console_path = lambda: "LandSARConsole.exe" + service._landsar_home = lambda: str(tmp_path) + service._find_imported_xmls = lambda input_data_dir: [str(Path(input_data_dir) / "LT1A_SCENE_SLC.xml")] + landsar_lt1_module._run_console = fake_run_console + + result = service.run_import( + { + "source_asset_ids": [101], + "__prepared_scene_dirs": [str(scene_dir)], + "__materialized": [{"source_asset_id": 101, "scene_dir": str(scene_dir)}], + "__materialize_task_root": str(tmp_path / "tasks"), + "mode": "scene", + "sat_mode": "MONO", + "import_orbit": False, + } + ) + + manifest = json.loads(Path(result["manifest_path"]).read_text(encoding="utf-8")) + assert manifest["summary"]["scene_count"] == 1 + assert manifest["summary"]["source_asset_ids"] == [101] + assert manifest["source"]["scene_dirs"] == [str(scene_dir)] + finally: + landsar_lt1_module.settings.RESULT_PUBLISH_ROOT = original_publish_root + service._ensure_runtime_ready = original_ensure + service._console_path = original_console + service._landsar_home = original_home + service._find_imported_xmls = original_find_imported_xmls + landsar_lt1_module._run_console = original_run_console + + +class LandsarLt1ProductionServiceTests(unittest.TestCase): + def _with_tmp_path(self, fn) -> None: + with tempfile.TemporaryDirectory() as root: + fn(Path(root)) + + def test_generate_lt1_import_param_file_single_scene_unittest(self) -> None: + self._with_tmp_path(test_generate_lt1_import_param_file_single_scene) + + def test_generate_lt1_import_param_file_stack_unittest(self) -> None: + self._with_tmp_path(test_generate_lt1_import_param_file_stack) + + def test_generate_lt1_orbit_param_file_unittest(self) -> None: + self._with_tmp_path(test_generate_lt1_orbit_param_file) + + def test_preview_blocks_scene_without_lt1_files_unittest(self) -> None: + self._with_tmp_path(test_preview_blocks_scene_without_lt1_files) + + def test_preview_accepts_source_asset_only_scene_unittest(self) -> None: + test_preview_accepts_source_asset_only_scene() + + def test_stack_preview_counts_source_assets_unittest(self) -> None: + test_stack_preview_counts_source_assets() + + def test_scene_mode_rejects_multiple_directories_unittest(self) -> None: + self._with_tmp_path(test_scene_mode_rejects_multiple_directories) + + def test_find_produced_source_asset_map_matches_summary_source_ids_unittest(self) -> None: + test_find_produced_source_asset_map_matches_summary_source_ids() + + def test_run_import_materialized_asset_does_not_double_count_scene_unittest(self) -> None: + self._with_tmp_path(test_run_import_materialized_asset_does_not_double_count_scene) diff --git a/docs/DEM_PRODUCTION_SOURCE_CONTRACT_20260628.md b/docs/DEM_PRODUCTION_SOURCE_CONTRACT_20260628.md new file mode 100644 index 0000000..53f087e --- /dev/null +++ b/docs/DEM_PRODUCTION_SOURCE_CONTRACT_20260628.md @@ -0,0 +1,52 @@ +# DEM Production Source Contract (2026-06-28) + +## Decision + +New production should use `D:\DEM\SRTMDEM_RSP_SARscape` as the common DEM source family unless a task explicitly declares another DEM in its manifest. The system should not mix SRTM, COPDEM, GMTED, and the interpolated Heilongjiang 10 m DEM silently. + +This does not mean every engine reads the same physical file. The same SRTM source is maintained in several engine-compatible forms: + +| Role | Path | Format | Intended consumers | +| --- | --- | --- | --- | +| SARscape/ENVI source | `D:\DEM\SRTMDEM_RSP_SARscape` | ENVI/SARscape float32 binary with `.hdr` | `IDL_DINSAR_DEM_BASE_FILE`, `GF3_SARSCAPE_DEM_PATH` | +| WGS84 prepared source | `D:\DEM\SRTMDEM_RSP_SARscape.wgs84` | ENVI float32 binary/VRT-readable raster | `ISCE2_DEM_PATH`, `PYINT_PREPARED_DEM_PATH`, `SAR_ANALYSIS_DEM_PATH`, `GAMMA_SBAS_DEM_PATH`, `TIMESERIES_DEM_PATH` | +| Int16 GeoTIFF source | `D:\DEM\SRTMDEM_RSP_SARscape_global_int16.tif` | GeoTIFF int16 | `LANDSAR_DEM_PATH`, `LANDSAR_SBAS_DEM_PATH`, GDAL/RPC-style GeoTIFF consumers such as `GF3_GEO_DEM_PATH` | + +The LT-1 single-scene production profile uses a 30 m analysis grid. Product manifests must still record the selected SRTM-derived source file, the cropped/converted DEM path, the actual Gamma DEM spacing, the derived `dem_lat_ovr`/`dem_lon_ovr`, and the configured target grid so reviewers can distinguish the source DEM family from the output grid. + +The LT-1 single-scene profile also performs multilook and speckle filtering before registering the final analysis GeoTIFF. The production manifest must record `SAR_ANALYSIS_RANGE_LOOKS`, `SAR_ANALYSIS_AZIMUTH_LOOKS`, and the speckle filter method/window so reviewers can reproduce the output pixel statistics. + +## Current Configuration + +The current server should use: + +```env +IDL_DINSAR_DEM_BASE_FILE=D:\DEM\SRTMDEM_RSP_SARscape +GF3_SARSCAPE_DEM_PATH=D:\DEM\SRTMDEM_RSP_SARscape + +ISCE2_DEM_PATH=D:\DEM\SRTMDEM_RSP_SARscape.wgs84 +PYINT_PREPARED_DEM_PATH=D:\DEM\SRTMDEM_RSP_SARscape.wgs84 +SAR_ANALYSIS_DEM_PATH=D:\DEM\SRTMDEM_RSP_SARscape.wgs84 +SAR_ANALYSIS_DEM_RESOLUTION_M=30.0 +SAR_ANALYSIS_TARGET_GRID_SIZE_M=30.0 +SAR_ANALYSIS_RANGE_LOOKS=6 +SAR_ANALYSIS_AZIMUTH_LOOKS=5 +SAR_ANALYSIS_SPECKLE_FILTER_ENABLED=true +SAR_ANALYSIS_SPECKLE_FILTER_METHOD=lee +SAR_ANALYSIS_SPECKLE_FILTER_SIZE=5 +GAMMA_SBAS_DEM_PATH=D:\DEM\SRTMDEM_RSP_SARscape.wgs84 +TIMESERIES_DEM_PATH=D:\DEM\SRTMDEM_RSP_SARscape.wgs84 + +LANDSAR_DEM_PATH=D:\DEM\SRTMDEM_RSP_SARscape_global_int16.tif +LANDSAR_SBAS_DEM_PATH=D:\DEM\SRTMDEM_RSP_SARscape_global_int16.tif +GF3_GEO_DEM_PATH=D:\DEM\SRTMDEM_RSP_SARscape_global_int16.tif +``` + +## Non-Default DEMs + +- `D:\DEM\HeiLongJiang10M_DEM.tif` is a regional interpolated DEM. It is not the default production DEM. +- `D:\DEM\landsar_prepared\HeiLongJiang10M_DEM_full_4326_int16.tif` is a LandSAR-compatible regional derivative of the interpolated DEM. It should only be used by an explicitly named regional/high-resolution experiment. +- `D:\DEM\COPDEM_GLO30_China_4326_DEM` remains a possible China-coverage fallback, but it is not the default after this contract. +- `D:\DEM\GMTED2010.jp2` is too coarse for production geocoding and should not be used as a production DEM default. + +Any task that intentionally uses a non-default DEM must record the selected source path, derived/cropped path, DEM resolution, target output grid, and coverage decision in its manifest. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 5bad103..30a3f71 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -112,7 +112,7 @@ ORBIT_POOL_LANDSAR= ```env IDL_EXECUTABLE=C:\Program Files\Harris\ENVI56\IDL88\bin\bin.x86_64\idl.exe IDL_WORKBENCH_PATH=C:\Program Files\Harris\ENVI56\IDL88\bin\bin.x86_64\idlde.exe -IDL_DINSAR_DEM_BASE_FILE=D:\SRTM30m\SRTMDEM_RSP_SARscape +IDL_DINSAR_DEM_BASE_FILE=D:\DEM\SRTMDEM_RSP_SARscape IDL_WORKER_RUNTIME_DIR=D:\production_runtime\idl_worker ENVI_TASK_TIMEOUT_SECONDS=21600 ``` @@ -131,7 +131,7 @@ GF3_STORAGE_DIRS=D:\GaoFen3_Pool\catalog GF3_SARSCAPE_RUNTIME_DIR=D:\GaoFen3_Pool\task_pool\sarscape_runtime GF3_SARSCAPE_WRAPPER_EXE=D:\Code\Insar_management_system_v2\third_party\GF3_L1A_To_L2_pipeline\dist\windows\gf3wrapper.exe GF3_SARSCAPE_IDLRT_PATH=C:\Program Files\Harris\ENVI56\IDL88\bin\bin.x86_64\idlrt.exe -GF3_SARSCAPE_DEM_PATH=D:\DEM\COPDEM_GLO30_China_4326_DEM +GF3_SARSCAPE_DEM_PATH=D:\DEM\SRTMDEM_RSP_SARscape GF3_SARSCAPE_POLARIZATIONS=HH,HV GF3_SARSCAPE_AUTO_STANDARDIZE=false GF3_SARSCAPE_CLEAN_AFTER_SUCCESS=true diff --git a/docs/GF3_SARSCAPE_NATIVE_TO_GEOTIFF_DESIGN_20260530.md b/docs/GF3_SARSCAPE_NATIVE_TO_GEOTIFF_DESIGN_20260530.md index 89a430a..615b0a2 100644 --- a/docs/GF3_SARSCAPE_NATIVE_TO_GEOTIFF_DESIGN_20260530.md +++ b/docs/GF3_SARSCAPE_NATIVE_TO_GEOTIFF_DESIGN_20260530.md @@ -454,7 +454,7 @@ GF3_ARCHIVE_SOURCE_DIRS ```env GF3_SARSCAPE_WRAPPER_EXE=D:\Code\Insar_management_system_v2\third_party\GF3_L1A_To_L2_pipeline\dist\windows\gf3wrapper.exe GF3_SARSCAPE_IDLRT_PATH=C:\Program Files\Harris\ENVI56\IDL88\bin\bin.x86_64\idlrt.exe -GF3_SARSCAPE_DEM_PATH=D:\DEM\GMTED2010.jp2 +GF3_SARSCAPE_DEM_PATH=D:\DEM\SRTMDEM_RSP_SARscape GF3_SARSCAPE_POLARIZATIONS=HH,HV GF3_SARSCAPE_KEEP_EXTRACTED=true GF3_SARSCAPE_AUTO_STANDARDIZE=false diff --git a/docs/INDEX.md b/docs/INDEX.md index cf0d193..e214c05 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -31,14 +31,22 @@ 褰撳墠闄嗘帰涓€鍙枫€丼entinel-1銆侀珮鍒嗕笁鏈満鐢熶骇銆佹寜闇€瑙e寘銆丟F3 澶栭儴鐢熶骇鐧昏銆佺粨鏋滅鐞嗗拰 UNC 閫€鍑虹害瀹氥€? - [PRODUCTION_RESULTS_MULTI_ENGINE_DESIGN_20260423.md](PRODUCTION_RESULTS_MULTI_ENGINE_DESIGN_20260423.md) 缁熶竴缁撴灉鐩綍銆佹爣鍑嗕骇鍝佸寘銆乧atalog 涓庡寮曟搸缁撴灉鍏卞瓨绾﹀畾銆? +- [RESULT_EXTRACTION_ACCESS_CONTROL_AUDIT_20260630.md](RESULT_EXTRACTION_ACCESS_CONTROL_AUDIT_20260630.md) + Result extraction and access-control audit: current D-InSAR export/registration boundaries, placeholder channels, admin/viewer limitations, and recommended exporter/operator/admin permission model. - [DINSAR_TASK_POOL_THREE_ENGINE_REFACTOR_20260614.md](DINSAR_TASK_POOL_THREE_ENGINE_REFACTOR_20260614.md) D-InSAR 淇濈暀 ENVI/SARscape銆丩andSAR銆丟amma/PyINT 涓夊紩鎿庯紝閫€鍑?ISCE2锛岀粺涓€ Task_Pool銆佺粨鏋滆仛鍚堝拰涓棿鏂囦欢娓呯悊鐨勫綋鍓嶈璁°€? - [LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md](LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md) LandSAR D-InSAR/SBAS 鐨勫叏鐞?DEM 涓€娆℃€?Int16 鏍囧噯鍖栥€佸尯鍩熻鍓?tif銆佺敓浜ч厤缃拰 guardrail 绾﹀畾銆? +- [DEM_PRODUCTION_SOURCE_CONTRACT_20260628.md](DEM_PRODUCTION_SOURCE_CONTRACT_20260628.md) + Production DEM source contract: SRTM-derived common source family, engine-specific derived formats, and non-default DEM guardrails. - [LANDSAR_CLUSTER_WORKER_DEPLOYMENT_20260624.md](LANDSAR_CLUSTER_WORKER_DEPLOYMENT_20260624.md) LandSAR D-InSAR 集群 worker 的队列分片设计、主服务器 IP 白名单、远端 Windows 节点 192.168.1.6 部署和运行约束。 - [LANDSAR_CLUSTER_DATA_TRANSPORT_DESIGN_20260625.md](LANDSAR_CLUSTER_DATA_TRANSPORT_DESIGN_20260625.md) LandSAR 集群数据搬运(HTTP Task_Pool 下载 + 结果回传)、Windows 集群运维(Task Scheduler 开机自启 + 心跳监控)。 +- [PRODUCTION_NODE_SUBSYSTEM_DESIGN_20260627.md](PRODUCTION_NODE_SUBSYSTEM_DESIGN_20260627.md) + D-InSAR 与 LT-1/Sentinel-1 单景影像生产的统一生产节点子系统设计,明确 LandSAR 集群 MVP、worker-only 部署、安全边界和后续本机/集群双模式路线。 +- [LANDSAR_LT1_SCENE_STACK_PRODUCTION_DESIGN_20260627.md](LANDSAR_LT1_SCENE_STACK_PRODUCTION_DESIGN_20260627.md) + LandSAR 陆探一号单景/多景生产 proID 与参数链探索,设计 `100016/100206` 导入产品化、结果 catalog、本机/集群双模式和后续影像产品验证路线。 - [UNC_SOURCE_ARCHIVE_AND_MATERIALIZE_DESIGN_20260615.md](UNC_SOURCE_ARCHIVE_AND_MATERIALIZE_DESIGN_20260615.md) LT-1/Sentinel-1 鏈湴婧愬帇缂╁寘绠$悊銆佸寘鍐?XML/manifest 璧勪骇鍖栥€佹湰鍦?Task_Pool materialize锛屼互鍙?UNC 閫€鍑哄悗鐨勬湰鏈洪儴缃茶竟鐣屻€? - [SOURCE_ARCHIVE_INTEGRITY_AUDIT_20260620.md](SOURCE_ARCHIVE_INTEGRITY_AUDIT_20260620.md) diff --git a/docs/LANDSAR_LT1_SCENE_STACK_PRODUCTION_DESIGN_20260627.md b/docs/LANDSAR_LT1_SCENE_STACK_PRODUCTION_DESIGN_20260627.md new file mode 100644 index 0000000..6934d8f --- /dev/null +++ b/docs/LANDSAR_LT1_SCENE_STACK_PRODUCTION_DESIGN_20260627.md @@ -0,0 +1,642 @@ +# LandSAR 陆探一号单景/多景生产链路探索与系统设计(2026-06-27) + +## 1. 结论 + +LandSAR 不只承担 LT-1 D-InSAR。仓库内 LandSAR 工具和文档显示,LandSAR 已有一条明确的 LT-1 数据生产基础链: + +```text +LT-1 源数据 + -> 100016 LT-1 数据导入 + -> 100206 LT-1 精密轨道导入 + -> Task_*/Input_Data +``` + +这条链可以支撑两类非 D-InSAR 生产能力: + +1. **单景生产基础产品**:把一景 LT-1 源数据导入为 LandSAR 统一 `Input_Data` 格式,形成后续处理可复用的标准输入。 +2. **多景生产基础产品**:把同轨道、同模式、同极化的一组 LT-1 场景导入并注入精密轨道,形成 LandSAR 时序栈 `Task_TS_*/Input_Data`。 + +当前系统的问题不是 LandSAR 没能力,而是这条能力没有被后端产品化: + +- 现有后端只在 `LandsarEngine.run()` 的 D-InSAR 前置阶段调用 `100016`。 +- `100206` 精轨导入和多景导入逻辑主要沉淀在 `third_party/LandSAR/lt1_import_gui.py`,还没有进入后端生产服务。 +- 单景/多景导入结果还没有独立 catalog、运行记录和本机/集群双模式。 +- 地理编码、正射、强度图这类“业务影像产品”还需要进一步验证 LandSAR 的 `180016`、`180044`、`200016`、`200046` 等 proID 参数链,不能直接把 `100016` 导入结果命名为正射影像。 + +因此第一阶段建议先产品化: + +```text +landsar.import.lt1.scene.v1 +landsar.import.lt1.stack.v1 +landsar.orbit.lt1.v1 +``` + +第二阶段再在真实样例参数和运行验证基础上开放: + +```text +landsar.image.lt1.geocode.v1 +landsar.image.lt1.ortho.v1 +``` + +## 1.1 当前实现状态 + +截至 2026-06-27,系统已落地第一阶段的本机生产入口: + +- 后端新增 `LANDSAR_LT1_IMPORT` 任务类型。 +- 后端新增 `/api/landsar-lt1-production/*` API。 +- 已按 LandSAR GUI 中的真实参数格式生成 `100016.txt` 与可选 `100206.txt`。 +- 运行结果以 `catalog_name=lt1_landsar` 写入 `result_products/result_assets`。 +- 前端“生产管理 -> 陆探一工作台 -> 陆探一影像生产”已可提交单景或多景 LT-1 源资产/目录导入任务。 +- 选择 `source_product_assets` 中的 LT-1 源资产时,任务会先 materialize 到 `LANDSAR_WORK_ROOT/lt1_import_tasks//scenes/`,再把解包后的 scene 目录交给 LandSAR。 +- `manifest.summary.source_asset_ids` 会记录源资产 id;资产台账和检索列表根据 `result_products.summary_json.source_asset_ids` 下发 `lt1_landsar_produced` 标识。 +- 已经登记为 READY 的 LT-1 LandSAR 产品会在生产面板中禁止再次选择;后端任务执行前也会再次拒绝已生产源资产。 + +当前实现边界: + +- 输入支持资产台账中的 LT-1 源资产,或人工指定的已 materialize/解包 LT-1 scene 目录;人工目录无法可靠反查源资产去重状态。 +- 输出产品语义是 LandSAR `Input_Data` 标准化生产结果,不是正射影像、地理编码强度图或 D-InSAR 结果。 +- 集群执行尚未接入这一任务类型;当前先采用主服务器本机 LandSAR 执行,并通过同一 manifest/catalog 结构为后续生产节点适配保留边界。 + +## 2. 已确认的 LandSAR proID 与参数链 + +### 2.1 已有可复用参数生成器 + +当前仓库中 `third_party/LandSAR/lt1_import_gui.py` 已有以下参数生成器: + +| 能力 | proID | 现有函数 | 状态 | +| --- | --- | --- | --- | +| LT-1 数据导入 | `100016` | `generate_param_file()` | 已有,偏 master/slave 两目录 | +| LT-1 多景导入 | `100016` | `generate_lt1_multiscene_import_param_file()` | 已有,支持 `文件夹导入个数=N` | +| LT-1 精密轨道导入 | `100206` | `generate_orbit_param_file()` | 已有 | +| D-InSAR | `200014` | `generate_dinsar_param_file()` | 已接入后端 | +| SBAS 一体化 | `280039` | `generate_sbas_param_file()` | 已有参数生成器,运行授权/能力需另行验证 | +| PS-InSAR 分步 | `280000~280032` | `generate_psinsar_step_param_file()` | 已有参数生成器,运行链路需另行验证 | +| Stacking | `300001` | `generate_stacking_param_file()` | 已有参数生成器 | + +这说明 `100016 + 100206` 不是猜测能力,已经有明确参数格式、GUI 调用方式和成功判定逻辑。 + +### 2.2 已确认的第一阶段链路 + +#### `100016`: LT-1 数据导入 + +用途: + +- 从 LT-1 源 scene 目录读取 XML/TIFF。 +- 输出 LandSAR 统一格式 `Input_Data`。 +- 可用于单景,也可用于多景。 + +关键参数形态: + +```text +卫星数据导入LT-1 +处理编号 100016 +设置数据导入形式_0文件夹导入_1数据导入 文件夹导入 +读取成像参数文件_0否_1是 1 +读取SLC数据文件_0否_1是 1 +文件夹导入标识 TRUE +文件夹导入个数 N +文件夹1路径 +... +设置数据导出目标路径_0原目录_1新目录 1 +设置输出文件目录 +``` + +成功判定: + +- 日志包含 `module [LT-1数据导入] success`。 +- 或 `console success`。 +- 输出目录存在 `LT1*_SLC.xml` 和对应 `LT1*_SLC.tif`。 + +注意: + +- 当前后端 `backend/app/dinsar_engines/landsar_engine.py` 的 `_generate_import_param_file()` 写死 `文件夹导入个数 2`,适合 D-InSAR pair 前置导入。 +- 单景/多景产品化应改用 N 景参数生成逻辑,而不是复用 pair-shaped 参数。 + +#### `100206`: LT-1 精密轨道导入 + +用途: + +- 对 `Input_Data` 中的 LT-1 XML 注入或关联精密轨道。 +- 为后续 D-InSAR、SBAS、PS、Stacking 或影像处理提供已校正输入。 + +关键参数形态: + +```text +LT-1精密轨道数据导入 +处理编号 100206 +输入数据个数 N +输入数据1的xml +... +输入精密轨道数据文件夹 +选择XML文件保存方式 0 +设置数据导出目录形式0原目录1新目录 0 +输出更新处理后数据目录 +``` + +成功判定: + +- 日志包含 `module [LT-1精密轨道数据导入] success`。 +- 或日志同时包含 `精密轨道` 和 `success`。 +- 或 `console success`。 + +### 2.3 候选但未验证的影像产品链 + +LandSAR 文档列出了以下与单景影像产品相关的 proID: + +| proID | 功能 | 当前判断 | +| --- | --- | --- | +| `180044` | 多视处理 | 可能用于单景强度/幅度产品前置,但参数格式未在后端沉淀 | +| `180016` | 地理编码 | 可能用于单景地理编码产品,但缺少样例参数 | +| `180070` | SLC 处理 | 可能用于单景 SLC 派生处理,但语义需验证 | +| `200016` | 地理编码(流程) | 可能是一体化地理编码流程,但缺少样例参数 | +| `200046` | SLC 处理(流程) | 可能是一体化 SLC 处理流程,但缺少样例参数 | +| `280032` | SLC 数据多视 | PS/MTInSAR 链路中的多视步骤,不应直接等同于通用单景影像生产 | + +这些 proID 不能直接进入正式 UI。需要先从 LandSAR GUI 生成真实参数文件,拿一景样本跑通,再决定产品定义。 + +## 3. 产品能力分层 + +### 3.1 第一层:导入型生产产品 + +这是近期可落地层。 + +#### `landsar.import.lt1.scene.v1` + +```text +输入: + LT-1 单景源压缩包或 materialized scene 目录 + +处理: + 100016 LT-1 数据导入 + 可选 100206 LT-1 精密轨道导入 + +输出: + Input_Data/ + LT1*_SLC.xml + LT1*_SLC.tif + *.thumb.jpg + params/ + 100016.txt + 100206.txt + logs/ + 100016_console.log + 100206_console.log + import_manifest.json + +catalog: + catalog_name = lt1_landsar + product_family = lt1_scene_import + product_type = landsar_input_data +``` + +产品语义: + +- 这是 LandSAR 输入标准化产品。 +- 不是正射影像。 +- 不是地理编码强度图。 +- 可作为 D-InSAR、SBAS、PS、后续影像产品的上游缓存。 + +#### `landsar.import.lt1.stack.v1` + +```text +输入: + 同一轨道/模式/极化/方向的一组 LT-1 scene + +处理: + 100016 LT-1 多景导入 + 100206 LT-1 精密轨道导入 + +输出: + Task_TS______/ + Input_Data/ + Output_Data/ + stack_import_manifest.json + 时序数据构建报告.txt + +catalog: + catalog_name = lt1_landsar + product_family = lt1_stack_import + product_type = landsar_timeseries_input_data +``` + +产品语义: + +- 这是 LandSAR 多景时序输入产品。 +- 可作为 PS/SBAS/MT-InSAR 的上游准备结果。 +- 不直接表示形变结果。 + +### 3.2 第二层:影像型生产产品 + +这层需要先验证 LandSAR 影像处理 proID。 + +候选 profile: + +```text +landsar.image.lt1.multilook.v1 +landsar.image.lt1.geocode.v1 +landsar.image.lt1.ortho.v1 +``` + +可能链路: + +```text +landsar.import.lt1.scene.v1 + -> 180044 多视处理 + -> 180016 地理编码 +``` + +或: + +```text +landsar.import.lt1.scene.v1 + -> 200046 SLC 处理(流程) + -> 200016 地理编码(流程) +``` + +当前必须标记为待验证: + +- 缺少真实参数文件。 +- 缺少真实输出样例。 +- 缺少对输出单位、坐标系、辐射定标、nodata、分辨率的确认。 +- 缺少成功判定和错误摘要规则。 + +## 4. 系统架构设计 + +### 4.1 后端模块 + +建议新增独立模块,不放进 `dinsar_engines`: + +```text +backend/app/landsar_lt1/ + contracts.py + param_files.py + runtime.py + discovery.py + scene_import_adapter.py + stack_import_adapter.py + result_package.py +``` + +职责: + +- `contracts.py`:定义 scene/stack 输入 manifest、结果 manifest、能力描述。 +- `param_files.py`:沉淀 `100016`、`100206` 参数文件生成器,不再依赖 GUI 代码。 +- `runtime.py`:统一调用 `InSAR_Console.exe`、日志捕获、超时、成功判定、错误摘要。 +- `discovery.py`:从资产表或 materialized 目录解析 LT-1 scene,按轨道/极化/日期分组。 +- `scene_import_adapter.py`:执行 `landsar.import.lt1.scene.v1`。 +- `stack_import_adapter.py`:执行 `landsar.import.lt1.stack.v1`。 +- `result_package.py`:生成标准发布包、manifest、current 指针。 + +LandSAR D-InSAR 当前已有的 runtime 检查、授权服务启动、DLL 校验逻辑可以抽取共用,但不要把单景/多景生产继续塞进 `LandsarEngine.run()`。 + +### 4.2 调度模型 + +建议走统一生产节点协议: + +```text +主服务器创建 production run + -> 每个 scene 或 stack group 生成 production item + -> 本机 worker 或远端 production node 领取 + -> adapter 执行 LandSAR + -> 上传/登记标准产品包 +``` + +执行模式: + +| 模式 | 说明 | +| --- | --- | +| `local` | 主服务器本机执行 | +| `cluster` | 指定远端生产节点执行 | +| `auto` | 按节点能力、负载、数据缓存选择 | + +节点能力: + +```json +{ + "capabilities": [ + "landsar.import.lt1.scene.v1", + "landsar.import.lt1.stack.v1", + "landsar.orbit.lt1.v1" + ] +} +``` + +### 4.3 运行记录模型 + +现有 `dinsar_production_runs` 虽然有 `product_family` 字段,但表名、字段和 item 语义都偏 pair。为了避免技术债,不建议把单景/多景陆探生产继续塞进 D-InSAR run 表。 + +推荐新增通用生产表: + +```text +production_runs + run_id + product_family + processor_code + profile_code + execution_mode + source_scope + status + total_items + completed_items + failed_items + params_json + +production_run_items + item_id + run_id + item_key + item_type # scene / stack_group + source_asset_ids_json + source_paths_json + target_key # scene_uid / stack_key + status + latest_run_key + latest_manifest_path + metrics_json + +production_executions + execution_id + run_id + item_id + node_id + run_key + status + output_dir + manifest_path + log_path + error_message +``` + +也可以短期复用 `system_jobs` 做执行队列,但正式 UI、重试、批次统计、集群调度和结果追溯需要上述通用运行表。 + +### 4.4 结果 catalog + +现有 `result_products` / `result_assets` 表可以承载单景/多景产品,因为它们已经有: + +- `catalog_name` +- `product_family` +- `product_type` +- `engine_code` +- `processor_code` +- `profile_code` +- `stack_key` +- `summary_json` +- `assets` + +但现有 `result_catalog_service._load_manifest()` 明确拒绝非 `dinsar` 的 manifest。需要拆出通用结果登记服务: + +```text +result_package_registry + register_manifest(manifest_path) + validate_manifest(product_family) + upsert_result_product() + upsert_result_assets() +``` + +LandSAR LT-1 生产建议使用: + +```text +catalog_name = lt1_landsar +product_family = lt1_scene_import / lt1_stack_import +product_type = landsar_input_data / landsar_timeseries_input_data +engine_code = landsar +processor_code = landsar.import.lt1.scene / landsar.import.lt1.stack +profile_code = landsar.import.lt1.scene.v1 / landsar.import.lt1.stack.v1 +``` + +## 5. 输入与分组设计 + +### 5.1 单景输入 + +来源: + +- `source_product_assets` 中的 `LT1_ARCHIVE`。 +- 已 materialized 的 LT-1 scene 目录。 +- 人工指定的受控服务器目录。 + +流程: + +```text +source asset + -> materialize scene + -> run 100016 + -> optional run 100206 + -> publish result package +``` + +### 5.2 多景输入 + +分组键建议: + +```text +satellite_family = LT1 +track / orbit number +imaging_mode +polarization +orbit_direction +product_type = SLC +admin/aoi 或用户选择范围 +date range +``` + +多景生产不应简单把用户勾选的所有 LT-1 都丢给 LandSAR。需要先做分组预检: + +- 是否同一轨道或可构成同一时序栈。 +- 是否同一极化。 +- 是否同一成像模式。 +- 日期是否可解析。 +- 是否都有源包。 +- 精轨是否匹配。 +- scene footprint 是否满足业务区域覆盖要求。 + +输出 `stack_key` 示例: + +```text +lt1_stack_______ +``` + +## 6. 发布目录设计 + +```text +D:\production_results\lt1_landsar\ + scene_import\ + \ + current\ + landsar.import.lt1.scene.v1.json + runs\ + \ + manifest.json + execution_manifest.json + native\ + Input_Data\ + params\ + logs\ + assets\ + input_data\ + thumb\ + metadata\ + + stack_import\ + \ + current\ + landsar.import.lt1.stack.v1.json + runs\ + \ + manifest.json + execution_manifest.json + native\ + Task_TS_...\Input_Data\ + Task_TS_...\Output_Data\ + params\ + logs\ + assets\ + input_data_manifest.json + stack_report.txt + scene_index.json +``` + +`native/` 保留 LandSAR 原始结构,`assets/` 放系统标准化索引文件和必要缩略图。不要把完整 `Input_Data` 再复制两份;标准资产层可以用 manifest 指向 native 内的相对路径。 + +## 7. API 与前端入口 + +### 7.1 API + +建议新增: + +```text +GET /api/landsar-lt1-production/capabilities +POST /api/landsar-lt1-production/preview +POST /api/landsar-lt1-production/run +GET /api/landsar-lt1-production/products +GET /api/landsar-lt1-production/products/{product_db_id} +GET /api/landsar-lt1-production/products/{product_db_id}/assets/{asset_id} +``` + +`preview` 必须先返回可执行性: + +- 选中 scene 数。 +- 分组结果。 +- 缺失源包。 +- 缺失精轨。 +- 预计输出目录。 +- 是否可本机执行。 +- 是否有集群节点支持。 +- 源资产是否已经存在 READY 状态的 LT-1 LandSAR 产品。 + +### 7.2 前端 + +生产管理里将 `陆探一生产占位` 改成实际工作台: + +```text +陆探一生产 + ├─ 单景导入 + ├─ 多景时序输入构建 + ├─ 运行记录 + └─ 产品结果 +``` + +第一版按钮只开放: + +- 预检。 +- 提交单景导入。 +- 提交多景导入。 +- 查看日志。 +- 打开结果目录。 +- 查看 catalog 资产。 + +不要第一版就开放: + +- 正射产品。 +- 地理编码强度图。 +- PS/SBAS 自动执行。 +- Stacking 自动执行。 + +这些应建立在第一层 `Input_Data` 产品稳定之后。 + +## 8. 集群化设计 + +单景/多景导入非常适合纳入生产节点: + +- 输入大但结构明确。 +- 输出可通过 manifest 回传。 +- 不需要主服务器承担长时间 LandSAR 进程。 +- `Input_Data` 可作为远端缓存,后续 D-InSAR/SBAS 复用。 + +远端节点能力: + +```text +landsar.import.lt1.scene.v1 +landsar.import.lt1.stack.v1 +landsar.orbit.lt1.v1 +``` + +第一版并发建议: + +- LandSAR 进程并发:每节点 1。 +- 多 scene 导入内部由 LandSAR 控制,不在外层并发拆太碎。 +- 多个单景任务可以排队,但不要同节点同时启动多个 `InSAR_Console.exe`,除非实测证明安全。 + +数据搬运: + +- 主服务器给 input manifest。 +- worker 下载源包或 materialized scene。 +- worker 执行 `100016/100206`。 +- worker 上传 manifest 和必要产物。 +- 大体量 `Input_Data` 是否全量回传可配置:第一版建议回传完整受管产品;后续可做远端缓存引用。 + +## 9. 实施路线 + +### 阶段 1:参数链固化 + +- 从 `third_party/LandSAR/lt1_import_gui.py` 抽取 `100016`、`100206` 参数生成逻辑。 +- 增加参数文件 golden tests。 +- 不接 UI,不跑真实任务。 + +### 阶段 2:本机单景导入 + +- 实现 `landsar.import.lt1.scene.v1` adapter。 +- 输入一个 LT-1 scene。 +- 执行 `100016`。 +- 可选执行 `100206`。 +- 生成标准 manifest。 +- 登记到 `result_products`,catalog 为 `lt1_landsar`。 + +### 阶段 3:本机多景导入 + +- 实现分组预检。 +- 实现 `landsar.import.lt1.stack.v1` adapter。 +- 复用 `generate_lt1_multiscene_import_param_file()` 语义。 +- 执行 `100016 -> 100206`。 +- 发布 `Task_TS_*/Input_Data` 产品。 + +### 阶段 4:生产节点接入 + +- 把 scene/stack import adapter 接入生产节点协议。 +- 远端节点上报能力。 +- 实现输入下载、结果回传、日志上报。 + +### 阶段 5:影像产品验证 + +- 用 LandSAR GUI 对单景生成多视、地理编码或正射产品。 +- 收集真实参数文件。 +- 确认 proID、输出命名、坐标系、单位和成功判定。 +- 再实现 `landsar.image.lt1.*`。 + +## 10. 需要运行验证的问题 + +1. `100016` 单景导入时 `文件夹导入个数=1` 是否被当前 LandSAR runtime 接受。 +2. `100016` 多景导入最大稳定 scene 数是多少。 +3. `100206` 对已导入 XML 是原地改写还是复制输出。 +4. `100206` 精轨注入后 XML 中可验证字段是什么。 +5. `180044 -> 180016` 是否能独立从单景 `Input_Data` 生成地理编码影像。 +6. `200046/200016` 是否比单步 `180xxx` 更适合作为正式影像产品链。 +7. LandSAR 同一台机器是否允许多个导入任务并发。 +8. 导入输出是否可跨机器复用,还是强依赖本机路径。 + +## 11. 近期不建议做的事 + +- 不建议把 `100016` 单景导入继续藏在 D-InSAR 前置步骤里。 +- 不建议把 `100016` 输出命名为正射或地理编码产品。 +- 不建议把单景/多景生产塞进 `dinsar_production_runs`。 +- 不建议在未验证 `180016/200016` 参数前开放“LandSAR 正射生产”按钮。 +- 不建议先做复杂 UI。应先做 adapter、manifest、catalog、真实样例验证。 diff --git a/docs/LT1_GEOCODED_GEOTIFF_PRODUCTION_20260628.md b/docs/LT1_GEOCODED_GEOTIFF_PRODUCTION_20260628.md new file mode 100644 index 0000000..7423f47 --- /dev/null +++ b/docs/LT1_GEOCODED_GEOTIFF_PRODUCTION_20260628.md @@ -0,0 +1,68 @@ +# LT-1 Geocoded GeoTIFF Production Update (2026-06-28) + +## Decision + +The LT-1 production button must produce a usable geocoded GeoTIFF, not only prepare LandSAR `Input_Data` for later D-InSAR work. + +The current implemented production path is: + +```text +LT-1 source asset + -> radar_data record + -> SAR_SCENE_PREPROCESS job + -> lt_gamma single-scene pipeline + -> SARSceneGeoORM.analysis_tif_path = analysis_ready.tif +``` + +This is the platform's real LT-1 single-scene backscatter image product for now. It performs the Gamma single-scene preprocessing chain: LT source product to Gamma SLC, multilook amplitude, geocode, speckle filtering in the linear-power domain, dB conversion, and analysis-ready GeoTIFF registration. + +The chain must not silently fall back to unrelated DEM sources. The default production contract is SRTM-derived on the current server: + +- `SAR_ANALYSIS_DEM_PATH=D:\DEM\SRTMDEM_RSP_SARscape.wgs84` +- `SAR_ANALYSIS_TARGET_GRID_SIZE_M=30.0` +- `SAR_ANALYSIS_DEM_RESOLUTION_M=30.0` +- `SAR_ANALYSIS_RANGE_LOOKS=6` +- `SAR_ANALYSIS_AZIMUTH_LOOKS=5` +- `SAR_ANALYSIS_SPECKLE_FILTER_ENABLED=true` +- `SAR_ANALYSIS_SPECKLE_FILTER_METHOD=lee` +- `SAR_ANALYSIS_SPECKLE_FILTER_SIZE=5` +- `PYINT_GEO_INTERP=1` + +The runtime clips the configured SRTM-derived DEM to the scene footprint with a small margin, converts that clip to Gamma DEM format, runs `generate_rdc_dem.py`, geocodes the multilooked amplitude with `geocode_back`, exports a GeoTIFF with `data2geotiff`, applies a Lee speckle filter to the linear power raster, then converts power to dB. The output `pixel_size_m` stored in `sar_scene_geo` is derived from the GeoTIFF transform; WGS84 degree grids are converted to approximate meters before registration. + +Multilook is already part of the Gamma preprocessing path through `generate_rdc_dem.py`; it is controlled by `SAR_ANALYSIS_RANGE_LOOKS` and `SAR_ANALYSIS_AZIMUTH_LOOKS`. Speckle filtering is a separate post-geocode raster operation before dB conversion. The filter manifest records the method, window size, domain, and equivalent number of looks used for the Lee weighting. + +For SRTM-derived DEMs, the configured 30 m analysis grid must be translated into Gamma `dem_lat_ovr` and `dem_lon_ovr` from the actual converted `.dem.par` spacing. Do not assume that `SAR_ANALYSIS_DEM_RESOLUTION_M=30.0` alone changes the output GeoTIFF grid. A 3 arc-second source DEM with `dem_lat_ovr=1` and `dem_lon_ovr=1` will still produce about 90 m latitude spacing. The runner now reads `post_lat`, `post_lon`, and scene latitude from the converted Gamma DEM parameter file, then writes the derived oversampling into the template before `generate_rdc_dem.py`. + +The previous `D:\DEM\HeiLongJiang10M_DEM.tif` default is not the production default. It is an interpolated regional DEM and must not be mixed silently with SRTM-derived products. If a future production profile intentionally uses it, the selected DEM and output grid must be recorded in the product manifest. + +## What Changed + +- `/api/landsar-lt1-production/run` queues `SAR_SCENE_PREPROCESS` jobs with `engine=lt_gamma`. +- Each selected LT-1 source asset resolves to a `radar_data` scene and produces one independent `SARSceneGeoORM` record. +- Batch mode means batch single-scene GeoTIFF production. It does not create a D-InSAR pair/stack product. +- The product list under `/api/landsar-lt1-production/products` reads from `sar_scene_geo`, not from `result_products.catalog_name=lt1_landsar`. +- Asset inventory and radar search mark LT-1 assets as produced only when `sar_scene_geo.status = DONE` and `analysis_tif_path` exists for the LT-1 `lt_gamma` profile. + +## UI And Query Contract + +- The LT-1 GeoTIFF production UI does not accept arbitrary LT-1 scene directories. Operators must select scanned source assets so each output can be linked back to `radar_data` and `sar_scene_geo`. +- The UI does not expose a satellite-mode/BIST selector. Acquisition mode should come from scanned source metadata, and the current Gamma single-scene pipeline does not require the operator to choose LandSAR import mode manually. +- The production candidate list reuses `/radar-data/search` with `satellite_family=LT1` and `source_format=LT1_ARCHIVE`, so operators can plan production by acquisition date, administrative AOI, orbit, polarization, and product name. +- `/assets/sources` remains the asset-led inventory view. It is not the main LT-1 production planning surface because it lacks the full image search/AOI workflow. +- Items that already have a completed LT-1 GeoTIFF product are shown as produced and are not selectable for a new production task. + +## What Is Not A Finished Image Product + +LandSAR `100016` and `100206` are still useful, but they only prepare/import LT-1 data: + +```text +100016 -> LandSAR Input_Data +100206 -> precise-orbit injection for imported XML +``` + +Those outputs must not be displayed as "image production complete" and must not block reprocessing as if they were geocoded images. + +## Open LandSAR Work + +LandSAR single-scene geocoded image production may still be possible through `180044`, `180016`, `200046`, or `200016`, but the repository does not yet contain a verified parameter chain for those modules. Do not expose those proIDs in the formal UI until a real parameter file and sample run are verified. diff --git a/docs/PRODUCTION_NODE_SUBSYSTEM_DESIGN_20260627.md b/docs/PRODUCTION_NODE_SUBSYSTEM_DESIGN_20260627.md new file mode 100644 index 0000000..47312c0 --- /dev/null +++ b/docs/PRODUCTION_NODE_SUBSYSTEM_DESIGN_20260627.md @@ -0,0 +1,462 @@ +# 生产节点子系统设计:D-InSAR 与单景影像生产(2026-06-27) + +## 1. 结论 + +当前 `LANDSAR_CLUSTER_ITEM` 已经证明:LandSAR D-InSAR 可以从主服务器拆分 pair,并在远端 Windows 节点完成输入搬运、LandSAR 执行和结果回传。 + +但这仍是 LandSAR D-InSAR 的集群 MVP,不应直接扩展成长期架构。后续陆探一号和 Sentinel-1 的“只生产影像、不做 D-InSAR”能力也会进入生产管理域。陆探一号这条线应优先承认 LandSAR 已有的 `100016` LT-1 数据导入/统一格式转换能力,再决定是否继续扩展为地理编码或正射影像产品。因此远端节点不能只理解 `LANDSAR_CLUSTER_ITEM`,而应该抽象成一个受控的“生产节点子系统”。 + +建议把后续设计目标调整为: + +1. 主服务器继续负责资产索引、任务编排、调度策略、结果 catalog 和权限边界。 +2. 子服务器只部署生产节点运行包,不部署完整项目仓库、前端、管理后台和无关源码。 +3. 所有生产任务按“产品类型 + 处理器能力”分发,同一任务可以选择本机执行或集群执行。 +4. LandSAR、Gamma/PyINT、未来 Sentinel-1 影像生产处理器都通过 adapter 接入生产节点协议。 +5. 结果以标准产品 manifest 回传,由主服务器统一入库,而不是让子服务器直接写主库或扫描任意目录。 + +## 2. 当前事实 + +### 2.1 已有设计边界 + +- [THREE_SENSOR_LOCAL_PRODUCTION_CONTRACT_20260616.md](THREE_SENSOR_LOCAL_PRODUCTION_CONTRACT_20260616.md) 明确 LT-1、Sentinel-1 当前管理对象是本机压缩包源池,生产时才按任务 materialize 到 `Task_Pool`。 +- [DINSAR_TASK_POOL_THREE_ENGINE_REFACTOR_20260614.md](DINSAR_TASK_POOL_THREE_ENGINE_REFACTOR_20260614.md) 明确 D-InSAR 保留 `sarscape`、`landsar`、`pyint` 三条主线,其中 LandSAR 只处理 LT-1,Gamma/PyINT 同时支持 LT-1 和 Sentinel-1。 +- [LANDSAR_CLUSTER_DATA_TRANSPORT_DESIGN_20260625.md](LANDSAR_CLUSTER_DATA_TRANSPORT_DESIGN_20260625.md) 已经为 LandSAR D-InSAR 定义了输入下载和结果上传链路。 +- 当前 192.168.1.6 节点已能执行 LandSAR D-InSAR 集群 item,并调用与本机一致的 `LandsarEngine.run()`。 +- `third_party/LandSAR/LT-1_数据导入功能说明.md` 明确 `100016` 是 LT-1 数据导入算法 ID;当前 `backend/app/dinsar_engines/landsar_engine.py` 已经能生成 `100016.txt` 并调用 `InSAR_Console.exe`,但这段能力目前只作为 D-InSAR 前置导入阶段存在。 + +### 2.2 仍未完成的能力 + +- 生产管理里的陆探一号已接入第一阶段本机生产链:`100016` LT-1 导入生成 LandSAR `Input_Data`,并支持可选 `100206` 精轨注入。它仍不是正射/地理编码影像产品。 +- 生产管理里的 Sentinel-1“只生产影像”仍是占位,不是已实现链路。 +- 陆探一号单景生产至少应拆成两层:`landsar.import.lt1` 表示 LandSAR `100016` 导入/统一格式转换,`landsar.image.lt1` 表示后续地理编码、正射或业务可用影像产品。前者已在本机任务/API/前端/catalog 中产品化;后者仍需要确认 LandSAR 调用链和输出规格。 +- Sentinel-1 影像生产的处理器尚未最终确认,不能把 Sentinel-1 影像生产硬编码到 LandSAR 集群链路。 +- 当前集群 worker 更接近“把后端生产代码部署到远端执行”,还不是一个最小权限、最小源码暴露的生产节点运行包。 + +## 3. 需要解决的问题 + +### 3.1 不要把集群等同于 LandSAR D-InSAR + +如果继续按 `LANDSAR_CLUSTER_ITEM` 的方式增长,后续很容易出现: + +- `LANDSAR_IMAGE_CLUSTER_ITEM` +- `S1_IMAGE_CLUSTER_ITEM` +- `PYINT_CLUSTER_ITEM` +- `SBAS_CLUSTER_ITEM` + +每新增一种生产能力都复制一套领取、搬运、执行、上传、入库逻辑,技术债会快速扩大。 + +更稳妥的边界是: + +```text +生产任务协议 + ├─ 输入 manifest + ├─ 处理器 adapter + ├─ 执行状态上报 + ├─ 结果 manifest + └─ 结果上传与 catalog + +具体处理器 + ├─ landsar.dinsar.lt1 + ├─ landsar.import.lt1 + ├─ landsar.image.lt1 + ├─ pyint.dinsar.lt1 + ├─ pyint.dinsar.s1 + └─ s1.image.<待定处理器> +``` + +### 3.2 单景影像生产也需要本机/集群双模式 + +LT-1 和 Sentinel-1 的影像生产虽然不是 D-InSAR,但仍可能是重计算、重 IO、长耗时任务。它们不应该只作为本机按钮实现。 + +推荐统一执行模式: + +| 模式 | 含义 | 适用场景 | +| --- | --- | --- | +| `local` | 主服务器本机执行 adapter | 调试、小批量、没有可用节点 | +| `cluster` | 远端生产节点领取执行 | 大批量、长耗时、需要释放主服务器 | +| `auto` | 主服务器按能力、负载、数据位置选择 | 正式生产默认模式 | + +前端可以先只暴露“本机执行 / 集群执行”,内部仍按统一任务协议创建任务。 + +### 3.3 子服务器不应长期部署完整代码仓库 + +当前 MVP 为了快速跑通,子服务器需要较完整的项目运行环境。这对验证是可接受的,但长期有三个问题: + +1. 源码暴露面过大:子服务器不需要前端、后台管理、资产扫描、用户接口等源码。 +2. 配置权限过宽:子服务器不应持有主库高权限连接信息。 +3. 升级不可控:完整仓库部署容易出现主服务器和子服务器代码版本漂移。 + +长期应改为生产节点运行包: + +```text +production-node/ + worker_service.py + config.py + client.py + adapters/ + landsar_dinsar.py + landsar_lt1_image.py + pyint_dinsar.py + s1_image.py + contracts/ + job_manifest.py + product_manifest.py + status_event.py + scripts/ + install_windows_service.ps1 + requirements.lock +``` + +这个运行包只包含: + +- 任务领取和心跳客户端。 +- 输入下载和结果上传客户端。 +- 必要的生产 adapter。 +- 与主服务器共享的 manifest schema。 +- Windows 服务安装脚本。 + +不包含: + +- 前端源码。 +- 管理后台路由。 +- 数据扫描入口。 +- 用户认证管理。 +- 数据库迁移脚本。 +- 与该节点能力无关的处理器源码。 + +## 4. 目标架构 + +```mermaid +flowchart LR + UI["生产管理前端"] --> API["主服务器 API"] + API --> Scheduler["调度器"] + Scheduler --> Queue["生产任务队列"] + API --> Catalog["结果 catalog"] + API --> Assets["源数据/精轨资产库"] + + NodeA["本机生产节点"] --> Queue + NodeB["远端生产节点 1.6"] --> Queue + NodeC["远端生产节点 N"] --> Queue + + NodeB --> Adapter1["landsar.dinsar.lt1"] + NodeB --> Adapter2["landsar.image.lt1"] + NodeC --> Adapter3["pyint.dinsar.s1"] + NodeC --> Adapter4["s1.image.<待定>"] + + Queue --> Manifest["输入 manifest"] + Manifest --> NodeB + NodeB --> Upload["结果上传 API"] + Upload --> Catalog +``` + +### 4.1 主服务器职责 + +- 维护源数据、精轨、DEM、Task_Pool、结果 catalog。 +- 根据资产状态生成生产任务。 +- 决定任务执行模式:本机、指定节点、自动调度。 +- 为 worker 生成输入 manifest,包含文件清单、hash、大小、产品类型、处理器 profile。 +- 接收 worker 状态、日志摘要、进度事件和结果包。 +- 校验结果 manifest 后入库。 +- 维护节点注册、能力、版本、心跳和并发上限。 + +### 4.2 生产节点职责 + +- 启动后向主服务器注册或发送心跳。 +- 上报能力,例如 `landsar.dinsar.lt1`、`landsar.import.lt1`、`landsar.image.lt1`、`pyint.dinsar.s1`。 +- 按能力领取任务。 +- 下载输入文件或复用本地缓存。 +- 调用本机已安装的生产软件或 adapter。 +- 将运行日志、状态、结果 manifest 和产品文件上传回主服务器。 +- 清理本地临时目录,保留可配置缓存。 + +### 4.3 Adapter 职责 + +Adapter 是生产节点中唯一知道具体软件细节的层: + +| Adapter | 输入 | 输出 | 备注 | +| --- | --- | --- | --- | +| `landsar.dinsar.lt1` | LT-1 pair Task_Pool | D-InSAR 标准产品包 | 当前集群 MVP 已覆盖核心执行 | +| `landsar.import.lt1` | LT-1 单景源包、解包 scene,或多景导入目录 | LandSAR `Input_Data` 统一格式、缩略图、导入 manifest | 基于 `100016`,当前代码已有 pair-shaped 前置调用,需要拆成一等单景 adapter | +| `landsar.image.lt1` | `landsar.import.lt1` 输出 + 可选精轨/DEM | LT-1 地理编码、正射或业务影像产品 | 需要确认 LandSAR 后续 proID/参数和产品规格 | +| `pyint.dinsar.lt1` | LT-1 pair Task_Pool | D-InSAR 标准产品包 | 可后续接入 | +| `pyint.dinsar.s1` | S1 pair Task_Pool + EOF | D-InSAR 标准产品包 | 当前只应按 Gamma/PyINT 能力开放 | +| `s1.image.<待定>` | S1 ZIP/SAFE + EOF | S1 单景影像产品 | 处理器未确定前保持占位 | + +主服务器不应该把某个 adapter 的内部目录结构暴露给前端;前端只看到任务类型、执行位置、状态和结果。 + +## 5. 统一任务类型 + +### 5.1 产品族 + +建议把生产任务按产品族建模,而不是按按钮建模: + +| 产品族 | 数据粒度 | 当前状态 | 目标执行模式 | +| --- | --- | --- | --- | +| `dinsar_pair` | 两景 pair | LandSAR LT-1 集群 MVP 已跑通 | 本机 + 集群 | +| `single_scene_import` | 单景或多景导入 | LT-1 LandSAR `100016/100206` 已作为本机 `LANDSAR_LT1_IMPORT` 任务、API、前端入口和 `lt1_landsar` catalog 产品发布;集群执行尚未接入 | 本机 + 集群 | +| `single_scene_image` | 单景 | LT-1 后续影像产品和 S1 均为占位 | 本机 + 集群 | +| `sbas_stack` | 多景 stack | 当前不纳入本轮集群化 | 后续再设计 | +| `gf3_native_register` | 外部结果登记 | 本机登记 `_geo` | 不建议进入生产节点 | + +### 5.2 推荐任务字段 + +```json +{ + "job_id": 123, + "product_family": "single_scene_image", + "sensor": "LT1", + "processor": "landsar", + "profile": "landsar.image.lt1", + "execution_mode": "cluster", + "input_manifest_url": "/api/production-node/jobs/123/input-manifest", + "result_contract": "standard_product_manifest.v1", + "priority": 50, + "retry_policy": { + "max_retries": 2, + "timeout_seconds": 7200 + } +} +``` + +### 5.3 结果 manifest + +D-InSAR 和单景影像生产都应该回传标准 manifest,差异放在 `product_family` 和 `product_type` 中: + +```json +{ + "manifest_version": 1, + "product_family": "single_scene_image", + "sensor": "LT1", + "processor": "landsar", + "profile": "landsar.image.lt1", + "scene_id": "LT1A_MONO_KSC_STRIP1_...", + "run_key": "run_20260627T010203Z_landsar_image_lt1_456", + "products": [ + { + "role": "main_image", + "path": "products/main.tif", + "format": "GeoTIFF", + "crs": "EPSG:4326" + }, + { + "role": "preview", + "path": "preview/main.webp", + "format": "WEBP" + }, + { + "role": "metadata", + "path": "metadata/product.json", + "format": "JSON" + } + ] +} +``` + +## 6. 陆探一号单景生产设计方向 + +陆探一号单景生产如果由 LandSAR 承担,应先把“导入/统一格式转换”和“正式影像产品”分开。 + +### 6.1 `landsar.import.lt1` + +这是当前最清楚、风险最低的第一版能力。 + +LandSAR `100016` 的语义是 LT-1 数据导入:把 LT-1A/LT-1B SLC XML/TIFF 转成 LandSAR 统一内部格式,形成 `Task_*/Input_Data` 可消费的 XML/TIF 组织,并生成缩略图等辅助文件。当前代码已经在 `_ensure_imported_input_data()` 中调用这条链路,但有两个限制: + +- 它被包在 D-InSAR 执行内部,只在缺少 `Input_Data` 时作为前置阶段触发。 +- 当前参数生成器按 `master/slave` 两文件夹导入写死,不是正式的单景产品 adapter。 + +第一版应把它产品化为: + +```text +landsar.import.lt1 + 输入:LT-1 单景源包、解包 scene,或显式 scene 目录 + 执行:InSAR_Console.exe + 100016.txt + 输出:LandSAR Input_Data 统一格式 + import_manifest.json + 缩略图/日志 + 入库:单景预处理/影像生产 catalog + 执行模式:local / cluster / auto +``` + +这个产品不应伪装成正射影像或地理编码强度图。它的价值是把陆探源数据转成 LandSAR 后续 D-InSAR、SBAS、影像处理可复用的标准输入。 + +### 6.2 `landsar.image.lt1` + +如果“只生产影像”指的是业务可用影像,例如地理编码强度图、幅度图、正射 GeoTIFF、洪涝分析输入图,那么还需要确认 LandSAR 是否有对应单景 proID 或可复用处理链。不能把 `100016` 的导入输出直接命名为正射产品。 + +需要确认的产品规格: + +- 输入是源压缩包、解包目录,还是现有 Task_Pool scene 目录。 +- 输出是 SLC/SSC 的标准化影像、地理编码强度图、幅度图、还是系统用于浏览和洪水分析的 GeoTIFF。 +- 是否需要精轨。 +- 是否需要 DEM。 +- 是否需要生成 WebP 预览。 +- 是否进入 `radar_data`、`source_product_assets`、D-InSAR catalog,还是新的影像产品 catalog。 + +如果后续确认 LandSAR 能从 `Input_Data` 继续生成地理编码/正射产品,再实现第二层: + +```text +LT-1 single scene image product + 输入:landsar.import.lt1 输出 + 可选精轨 + 可选 DEM + 执行器:LandSAR + 输出:标准产品目录 + product_manifest.json + preview.webp + 入库:影像产品 catalog + 执行模式:local / cluster / auto +``` + +不要在第一版同时承诺“原始归档标准化、地理编码、洪水分析输入、全部极化派生物、可视化浏览缓存”这些目标。先把一个产品闭环做对,再扩展产品角色。 + +## 7. Sentinel-1 影像生产设计方向 + +Sentinel-1 单景影像生产目前不能直接套用 LandSAR。需要先确定处理器: + +- 如果走 Gamma/PyINT,需要定义单景预处理 profile。 +- 如果走 GDAL/SNAP/其他工具,需要单独 adapter。 +- 如果只是生成浏览预览,应该归入资产扫描/预览缓存,不应叫正式生产任务。 + +建议在处理器未确认前只保留协议占位: + +```text +s1.image. + 状态:设计占位 + 不进入正式调度 + 不在 UI 上展示为可执行生产能力 +``` + +这样可以避免前端提前出现“哨兵影像集群生产”按钮,但后端没有可信处理链。 + +## 8. 调度与效率 + +### 8.1 节点能力上报 + +生产节点心跳应包含: + +```json +{ + "node_id": "production-node-192-168-1-6", + "version": "2026.06.27", + "capabilities": [ + "landsar.dinsar.lt1", + "landsar.import.lt1", + "landsar.image.lt1" + ], + "max_concurrency": 1, + "active_jobs": 0, + "free_disk_gb": 512, + "runtime": { + "os": "windows", + "landsar_available": true, + "python_version": "3.12" + } +} +``` + +LandSAR 类任务的并发不能只看 CPU 核心数。需要同时考虑: + +- LandSAR 是否支持多实例并发。 +- 许可证或硬件锁是否允许并行。 +- 工作目录是否互相隔离。 +- 磁盘 IO 是否成为瓶颈。 +- 单任务内部是否已经使用多线程。 + +因此第一版远端 LandSAR 节点建议 `max_concurrency=1`。等确认 LandSAR 多实例隔离和资源占用后,再按节点开放 2 个或更多并发。 + +### 8.2 缓存策略 + +单景影像生产和 D-InSAR 可以共享部分输入缓存: + +- LT-1 源包下载缓存。 +- LT-1 解包缓存。 +- LandSAR 导入后的中间目录。 +- DEM 裁剪缓存。 +- Sentinel-1 ZIP/SAFE 和 EOF 缓存。 + +缓存键应基于源文件 hash、mtime、size、processor profile 和关键参数,不应只基于文件名。否则源包被替换后容易复用错误缓存。 + +### 8.3 数据搬运策略 + +优先级建议: + +1. 第一阶段:沿用 HTTP manifest + file download + result upload,路径最清楚。 +2. 第二阶段:增加断点续传和文件级 hash 校验。 +3. 第三阶段:支持节点本地缓存命中,避免重复下载同一源包。 +4. 第四阶段:在受控环境下可选共享只读源池,但不作为默认安全模型。 + +不要让 worker 任意访问主服务器磁盘路径。worker 应只根据主服务器签发的 manifest 下载白名单文件。 + +## 9. 安全边界 + +长期目标: + +- worker 不持有主数据库账号。 +- worker 只持有节点 token。 +- token 按节点、能力和有效期管理。 +- 所有输入下载和结果上传都走主服务器 API。 +- 主服务器校验每个上传文件的相对路径,拒绝目录逃逸。 +- 主服务器校验 result manifest,只有白名单产品角色进入 catalog。 +- worker 运行包只包含生产节点必要代码。 +- worker 版本和 adapter 版本必须上报,主服务器可以拒绝过旧节点领取任务。 + +当前 LandSAR 集群 MVP 可以作为过渡,但文档上应明确:完整仓库部署、DB 直接领取队列、共享 token 都不是长期安全边界。 + +## 10. 实施路线 + +### 阶段 0:保持现状可用 + +- 保留当前 LandSAR D-InSAR 集群能力。 +- 不在未设计清楚前扩展新的集群 job type。 +- 继续记录 1.6 节点运行结果、失败原因、传输耗时和 LandSAR 执行耗时。 + +### 阶段 1:抽取生产节点协议 + +- 定义 `job_manifest`、`input_manifest`、`product_manifest`、`status_event`。 +- 把 LandSAR D-InSAR 当前输入下载、执行、上传流程映射到协议。 +- 主服务器保留当前 API,同时新增通用 `/api/production-node/*` 命名空间。 + +### 阶段 2:拆出 worker-only 运行包 + +- 从完整仓库部署改成生产节点运行包部署。 +- Windows 节点用服务方式启动。 +- 节点只配置主服务器 URL、节点 token、工作根、结果根、缓存根和能力列表。 +- 先支持 `landsar.dinsar.lt1`。 + +### 阶段 3:陆探一号单景生产 + +- 先实现 `landsar.import.lt1` adapter,把 LandSAR `100016` 从 D-InSAR 前置阶段拆成一等生产能力。 +- 当前 `_generate_import_param_file()` 按 master/slave 两文件夹导入写死,单景 adapter 需要支持单 scene 输入、单文件夹导入或显式 file import。 +- 本机模式和集群模式同时接入同一任务协议。 +- 结果进入单景预处理/影像产品 catalog,而不是混入 D-InSAR 结果 catalog。 +- 确认 LandSAR 后续单景地理编码或正射处理链后,再实现 `landsar.image.lt1`。 + +### 阶段 4:Sentinel-1 单景影像生产 + +- 先确认处理器和产品规格。 +- 再实现 `s1.image.` adapter。 +- 未确认前不开放 UI 执行入口。 + +### 阶段 5:节点运维和调度完善 + +- 节点版本管理。 +- 能力矩阵管理。 +- 节点禁用/启用。 +- 任务重分配。 +- 节点磁盘清理。 +- 节点运行日志集中查看。 + +## 11. 近期不建议做的事 + +- 不建议继续复制 `LANDSAR_CLUSTER_ITEM` 形成多个专用 cluster item。 +- 不建议在子服务器长期部署完整项目仓库。 +- 不建议让子服务器直接扫描主服务器源数据目录。 +- 不建议让子服务器直接写主数据库结果表。 +- 不建议在 Sentinel-1 处理器未确认前实现“哨兵影像生产”按钮。 +- 不建议把资产扫描阶段的 WebP 预览生成混同为正式影像生产。 + +## 12. 待确认问题 + +1. 陆探一号“只生产影像”的正式产品定义是什么:只做 LandSAR `100016` 导入/统一格式转换,还是继续生成地理编码强度图、幅度图、正射 GeoTIFF? +2. 陆探一号单景影像生产是否必须使用精轨和 DEM? +3. Sentinel-1 单景影像生产准备用哪个处理器承担? +4. 单景影像产品是否需要新建 catalog,还是复用现有 `radar_data` 资产表加产品 manifest? +5. 远端节点是否允许访问只读共享源池,还是严格走 HTTP 下载? +6. LandSAR 在同一台 Windows 节点上是否允许多个实例并发? + +这些问题确认前,可以继续完善 LandSAR D-InSAR 集群,但不宜把新的影像生产能力直接硬接到当前 MVP worker 上。 diff --git a/docs/RESULT_EXTRACTION_ACCESS_CONTROL_AUDIT_20260630.md b/docs/RESULT_EXTRACTION_ACCESS_CONTROL_AUDIT_20260630.md new file mode 100644 index 0000000..c0efcc5 --- /dev/null +++ b/docs/RESULT_EXTRACTION_ACCESS_CONTROL_AUDIT_20260630.md @@ -0,0 +1,219 @@ +# 结果提取与用户权限审计(2026-06-30) + +## 1. 审计范围 + +本次审计聚焦“结果提取”相关入口和用户权限边界,覆盖: + +- 前端结果提取工作台:`frontend/src/ResultExtractionPanel.jsx` +- D-InSAR 结果管理页:`frontend/src/DinsarProductsPanel.jsx` +- D-InSAR 结果导出接口:`POST /api/dinsar-results/export` +- D-InSAR 生产结果提取与登记接口:`POST /api/idl/extract-disp` +- 用户与权限模型:`auth_users.role`、全局认证守卫、用户管理页 + +本次文档只记录审计结论和后续设计约束,不包含代码修复。 + +## 2. 当前实现事实 + +### 2.1 两条“提取”链路 + +当前系统里“结果提取”实际包含两种不同语义: + +1. **生产结果入库** + - 前端入口:`DinsarProductsPanel.jsx` + - 后端入口:`POST /api/idl/extract-disp` + - 后台任务:`EXTRACT_DINSAR_PRODUCTS` + - 作用:从生产目录提取 D-InSAR 位移结果,发布成标准结果包,并重建结果 catalog。 + +2. **成果交付导出** + - 前端入口:`ResultExtractionPanel.jsx` + - 后端入口:`POST /api/dinsar-results/export` + - 作用:从已经登记的 D-InSAR catalog 中选择成果,复制到服务器指定交付目录。 + +这两条链路目前在产品文案上都叫“提取”,容易让用户混淆“入库”和“交付”。 + +### 2.2 当前接入状态 + +`ResultExtractionPanel.jsx` 中的通道状态: + +| 通道 | 当前状态 | 说明 | +| --- | --- | --- | +| D-InSAR 结果 | 已接入 | 支持查询已登记结果并导出到服务器目录 | +| SBAS-InSAR 结果 | 半接入 | 可读取目录样例,但统一提取接口未实现 | +| LT-1 正射结果 | 占位 | 单景/正射结果 catalog 与导出链路未完成 | +| Sentinel-1 正射结果 | 占位 | 生产和导出链路未完成 | +| GF3 SARscape `_geo` | 占位 | 登记/标准化思路存在,统一导出接口未完成 | + +## 3. 当前权限模型 + +系统当前只有两类角色: + +- `admin` +- `viewer` + +定义位置:`backend/app/auth_service.py` + +全局认证守卫位于 `backend/app/routers/dependencies.py`: + +- `GET / HEAD / OPTIONS` 默认视为只读操作,登录用户可访问。 +- 除少数显式安全 POST 外,非只读请求要求 `admin`。 +- 非管理员执行写操作会被拒绝,返回 `403 Read-only account cannot perform this operation.` + +前端在 `App.jsx` 中把非管理员账号映射为 `readOnly`: + +- viewer 可以浏览结果、查看任务、查看 catalog。 +- viewer 不能提交生产、扫描、提取、导出、删除、修改。 +- admin 拥有所有写权限,包括生产、扫描、结果入库、结果导出、用户管理和运维配置。 + +后端没有依赖前端按钮禁用来保护写操作。`/api/idl/extract-disp` 和 `/api/dinsar-results/export` 都显式要求 `admin`,这一点是正确的。 + +## 4. 审计发现 + +### P1:成果交付权限与系统管理员权限耦合过重 + +当前只有 `admin` 能执行成果导出,但 `admin` 同时拥有用户管理、系统配置、生产扫描、删除记录等高权限。 + +从业务职责看,成果交付导出不应天然等同于系统管理员权限。后续应拆出更细的权限,例如: + +- `operator`:可提交生产任务、结果入库、目录重建。 +- `exporter`:可导出已登记成果到受控交付目录。 +- `viewer`:只读浏览、预览、查询。 +- `admin`:用户管理、系统配置、根目录维护、许可证和高风险运维。 + +### P1:结果导出是同步请求,存在 504 风险 + +`POST /api/dinsar-results/export` 在请求线程内执行文件复制,最多允许 500 个结果 ID。成果文件较大或目标目录较慢时,容易再次触发前端或 Nginx 超时。 + +后续应改成后台任务: + +- 接口只创建任务并返回 `task_id`。 +- 文件复制由 worker 执行。 +- 前端通过任务中心/结果提取工作台展示进度、成功数、失败数和目标目录。 + +### P1:生产结果入库缺少显式操作审计 + +`/api/dinsar-results/export` 已写入 `dinsar_results_exported` 审计日志。 + +`/api/idl/extract-disp` 当前会创建系统任务,但缺少独立的操作审计记录。它会改变结果 catalog,应记录: + +- 操作用户 +- 源生产目录 +- 目标发布目录 +- 创建的 `task_id` / `job_id` +- 完成后的 processed/copied/failed/published/registered 数量 + +### P2:页面命名和工作流边界不清 + +当前“D-InSAR 结果提取与登记”和“结果提取工作台”容易混淆。 + +建议命名: + +- “生产结果入库”:从生产目录提取并登记为系统 catalog。 +- “成果交付导出”:从已登记 catalog 选择成果并复制到交付目录。 + +这两个动作应该放在同一结果管理域下,但用不同分区和不同权限提示。 + +### P2:占位通道需要降低可执行暗示 + +SBAS、LT-1 正射、Sentinel-1 正射、GF3 `_geo` 目前不应被呈现成可执行导出能力。 + +建议 UI 明确显示: + +- `已接入` +- `目录可查,导出未接入` +- `规划中` +- `不可执行` + +并隐藏或禁用导出按钮,避免用户误以为功能已经上线。 + +### P2:导出目录策略需要产品化 + +当前后端已有 `_validate_export_path()` 和 `ALLOWED_EXPORT_DIRS` 约束能力,但前端仍允许用户输入服务器绝对路径。 + +后续建议: + +- 普通业务用户不输入任意服务器路径。 +- 管理员在系统配置中维护“交付目录白名单”。 +- 结果导出页只让用户选择白名单目录和子任务名。 +- 审计记录保存最终解析后的服务器路径。 + +### P3:部分前端文案存在历史编码损坏 + +`DinsarProductsPanel.jsx`、`ResultExtractionPanel.jsx`、`UserAdminPanel.jsx` 等文件存在局部中文乱码。功能不一定受影响,但会降低维护性和产品可信度。 + +建议后续单独做一次 UTF-8 文案修复,不与权限重构混在同一次提交中。 + +## 5. 建议目标模型 + +### 5.1 功能分区 + +结果管理应拆成三个清晰分区: + +1. **产品目录** + - 查看已登记成果。 + - 预览、筛选、查看详情。 + - viewer 可访问。 + +2. **生产结果入库** + - 从生产结果根目录扫描、提取、发布、登记。 + - operator/admin 可执行。 + +3. **成果交付导出** + - 从 catalog 选择成果,导出到受控交付目录。 + - exporter/operator/admin 可执行。 + +### 5.2 权限矩阵建议 + +| 操作 | viewer | exporter | operator | admin | +| --- | --- | --- | --- | --- | +| 查看结果 catalog | yes | yes | yes | yes | +| 查看预览与详情 | yes | yes | yes | yes | +| 生产结果入库 | no | no | yes | yes | +| 目录重建/发布 | no | no | yes | yes | +| 成果交付导出 | no | yes | yes | yes | +| 生产任务提交 | no | no | yes | yes | +| 用户管理 | no | no | no | yes | +| 根目录/许可证/运维配置 | no | no | no | yes | + +实现上可以先保留 `role` 字段,扩展角色枚举;长期可引入权限位表,避免角色继续膨胀。 + +## 6. 推荐实施顺序 + +### 阶段 1:修正产品语义和审计 + +- 页面文案区分“生产结果入库”和“成果交付导出”。 +- `/api/idl/extract-disp` 增加操作审计。 +- 结果提取工作台明确标注未接入通道。 +- 修复相关页面乱码文案。 + +### 阶段 2:导出任务化 + +- 新增 `EXPORT_DINSAR_RESULTS` 后台任务类型。 +- `/api/dinsar-results/export` 改为返回 `task_id`。 +- 前端展示导出任务进度和失败明细。 +- 导出结果保留 task log 和 audit log。 + +### 阶段 3:权限细分 + +- 扩展角色:`viewer/exporter/operator/admin`。 +- 用户管理页支持新角色说明。 +- 后端增加能力级依赖,例如 `require_capability("result.export")`。 +- 所有高风险写操作按 capability 而不是只按 admin 判断。 + +### 阶段 4:交付目录白名单产品化 + +- 将 `ALLOWED_EXPORT_DIRS` 从环境变量能力升级为系统配置/受控根目录。 +- 前端从白名单选择交付根目录。 +- 用户只输入子目录名或交付批次名。 + +## 7. 验收标准 + +完成上述改造后,应满足: + +1. viewer 能看结果,不能导出、不能入库。 +2. exporter 能导出已登记成果,但不能提交生产、不能用户管理。 +3. operator 能生产、入库、导出,但不能用户管理和系统配置。 +4. admin 保留全部权限。 +5. 所有入库和导出动作都有 task log 和 audit log。 +6. 大批量导出不再产生 HTTP 504。 +7. 未实现通道在 UI 上不会被误认为可执行功能。 + diff --git a/docs/SOURCE_ORBIT_ASSET_SCAN_OPERATIONS_20260626.md b/docs/SOURCE_ORBIT_ASSET_SCAN_OPERATIONS_20260626.md index 4d9861b..a9d9d2e 100644 --- a/docs/SOURCE_ORBIT_ASSET_SCAN_OPERATIONS_20260626.md +++ b/docs/SOURCE_ORBIT_ASSET_SCAN_OPERATIONS_20260626.md @@ -61,6 +61,15 @@ The binding step is currently a full re-evaluation of active LT-1/Sentinel-1 sou If a scan must be stopped before retrying with new concurrency settings, stop the running worker processes and mark the active `system_jobs`, `system_tasks`, and `asset_inventory_states` rows as terminal failed states. This prevents the job queue from recovering and re-claiming the old job. +The main application worker is expected to support more than one queued job at a time so long-running preview generation does not block independent production jobs. Current main-server baseline: + +```env +JOB_WORKER_CONCURRENCY=2 +JOB_WORKER_ALLOWED_TYPES= +``` + +Do not start ad-hoc one-off workers during formal testing. Change the configured worker concurrency, stop stale processes, and let the operator restart the application normally. + Recommended health checks during a source scan: - Task log should show `workers=16` and `pending=64` / `active_or_queued=64` after the parser pool fills. diff --git a/frontend/index.html b/frontend/index.html index c0843f2..7d23e62 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,11 +1,11 @@ - + - + - frontend + 雷达数据生产管理系统
diff --git a/frontend/public/app-icon.png b/frontend/public/app-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6ef1d6c746f5d5c89bfebf70a385199a39398f1f GIT binary patch literal 95494 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 z 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 FV1o1aeaZj; literal 0 HcmV?d00001 diff --git a/frontend/src/App.css b/frontend/src/App.css index ea1327a..023b75f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2589,6 +2589,96 @@ input[type="checkbox"] { text-align: left; } +.task-runtime-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 12px; +} + +.task-runtime-summary > div { + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; + padding: 8px; + min-width: 0; +} + +.task-runtime-summary span { + display: block; + color: #64748b; + font-size: 11px; + margin-bottom: 3px; +} + +.task-runtime-summary strong { + color: #0f172a; + font-size: 15px; + font-family: var(--font-mono); +} + +.active-jobs-container { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 12px; +} + +.job-runtime-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) minmax(92px, 0.8fr); + align-items: center; + gap: 8px; + padding: 7px 8px; + border: 1px solid #e2e8f0; + border-radius: 7px; + background: #ffffff; + font-size: 12px; +} + +.job-status-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 58px; + padding: 2px 6px; + border-radius: 999px; + border: 1px solid #cbd5e1; + color: #475569; + background: #f8fafc; + font-family: var(--font-mono); + font-size: 11px; +} + +.job-status-chip.running { + border-color: rgba(37, 99, 235, 0.22); + color: #1d4ed8; + background: rgba(37, 99, 235, 0.08); +} + +.job-status-chip.retry { + border-color: rgba(217, 119, 6, 0.24); + color: #b45309; + background: rgba(245, 158, 11, 0.1); +} + +.job-runtime-title, +.job-runtime-worker { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.job-runtime-title { + color: #334155; + font-weight: 600; +} + +.job-runtime-worker { + color: #64748b; + text-align: right; +} + .task-progress-item { background: #f8fafc; padding: 10px; @@ -2596,6 +2686,10 @@ input[type="checkbox"] { border: 1px solid #e2e8f0; } +.task-progress-item--muted { + background: #ffffff; +} + .task-info-row { display: flex; justify-content: space-between; @@ -3502,6 +3596,7 @@ input[type="checkbox"] { color: var(--color-text-secondary); padding: 4px 8px; border-radius: 6px; + min-width: 132px; } .status-task.has-active-tasks { @@ -3509,6 +3604,13 @@ input[type="checkbox"] { color: #1890ff; } +.status-task small { + color: var(--color-text-muted); + font-size: 0.86em; + line-height: 1.25; + white-space: nowrap; +} + .status-license { font-size: 0.75em; color: var(--color-text-muted); @@ -5615,6 +5717,17 @@ input[type="checkbox"] { box-sizing: border-box; } +.production-workspace-shell .dinsar-production-shell, +.production-workspace-shell .dinsar-products-page { + max-width: none; + margin-left: 0; + margin-right: 0; +} + +.production-workspace-shell .dinsar-production-shell { + padding: 0; +} + .dinsar-production-header { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(420px, 0.95fr); @@ -6485,6 +6598,18 @@ input[type="checkbox"] { .dinsar-products-catalog-section { display: grid; gap: 10px; + width: 100%; + min-width: 0; +} + +.dinsar-products-catalog-section .dinsar-catalog-shell { + width: 100%; + min-width: 0; + box-sizing: border-box; +} + +.dinsar-products-catalog-section .dinsar-catalog-workspace { + grid-template-columns: minmax(360px, 420px) minmax(0, 1fr); } .sbas-products-page { @@ -7170,6 +7295,7 @@ input[type="checkbox"] { .dinsar-filter-layout, .dinsar-catalog-summary, .dinsar-catalog-workspace, + .dinsar-products-catalog-section .dinsar-catalog-workspace, .dinsar-catalog-manage, .dinsar-catalog-filter-bar, .dinsar-catalog-hero, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a7e0636..8f269f0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -208,12 +208,14 @@ function App() { setHealthError: state.setHealthError, }))); const { - activeTasks, setActiveTasks, + activeTasks, setActiveTasks, runtimeSummary, setRuntimeSummary, isCheckingTasks, setIsCheckingTasks, pendingTaskIds, setPendingTaskIds, } = useTaskStore(useShallow((state) => ({ activeTasks: state.activeTasks, setActiveTasks: state.setActiveTasks, + runtimeSummary: state.runtimeSummary, + setRuntimeSummary: state.setRuntimeSummary, isCheckingTasks: state.isCheckingTasks, setIsCheckingTasks: state.setIsCheckingTasks, pendingTaskIds: state.pendingTaskIds, @@ -918,6 +920,7 @@ function App() { licenseOk: !!licenseStatus?.ok, activeTasks, setActiveTasks, + setRuntimeSummary, pendingTaskIds, setPendingTaskIds, setIsCheckingTasks, @@ -2114,6 +2117,7 @@ function App() { isReadOnlyUser={isReadOnlyUser} activeTasks={activeTasks} avgTaskProgress={avgTaskProgress} + runtimeSummary={runtimeSummary} licenseStatus={licenseStatus} healthStatus={healthStatus} healthLoading={healthLoading} @@ -2197,6 +2201,7 @@ function App() { licenseFileName={licenseFileName} licenseUploadStatus={licenseUploadStatus} activeTasks={activeTasks} + runtimeSummary={runtimeSummary} showCancelTask={showCancelTask} cancelTaskPwd={cancelTaskPwd} onShowCancelTask={() => setShowCancelTask(true)} diff --git a/frontend/src/AssetInventoryPanel.jsx b/frontend/src/AssetInventoryPanel.jsx index f414b24..aec2851 100644 --- a/frontend/src/AssetInventoryPanel.jsx +++ b/frontend/src/AssetInventoryPanel.jsx @@ -28,7 +28,7 @@ const fmtBytes = (value) => { const StatusBadge = ({ value }) => { const text = String(value || '-'); const status = text.toUpperCase(); - const tone = status === 'OK' || status === 'MATCHED' || status === 'SELECTED' + const tone = status === 'OK' || status === 'MATCHED' || status === 'SELECTED' || text.startsWith('已生产') ? 'ok' : status === 'WARNING' || status === 'OPEN' || status === 'MISSING' ? 'warn' @@ -202,6 +202,7 @@ export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) { 产品 轨道 状态 + 生产 完整性 动作 文件 @@ -216,6 +217,7 @@ export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) { {item.source_format}{item.imaging_mode} / {item.polarization} {item.relative_orbit || '-'}abs {item.absolute_orbit || '-'} + {item.archive_integrity_member_count != null ? `${item.archive_integrity_member_count} files` : item.archive_integrity_method || '-'} diff --git a/frontend/src/DinsarProductionPanel.jsx b/frontend/src/DinsarProductionPanel.jsx index 2592869..ea22d08 100644 --- a/frontend/src/DinsarProductionPanel.jsx +++ b/frontend/src/DinsarProductionPanel.jsx @@ -63,6 +63,17 @@ const STATUS_LABEL = { pending: '等待中', }; +const FAILURE_REASON_LABEL = { + 'LandSAR access violation during coherence mask/phase unwrapping': 'LandSAR 访问冲突(相干性掩膜/相位解缠)', + 'Insufficient tie/GCP points for DEM/geocoding': 'DEM / 地理编码控制点不足', + 'DEM/sub-terrain processing failed': 'DEM / 去地形阶段失败', + 'Insufficient GCPs for baseline/calibration': '基线精估计控制点不足', + 'Coherence mask/phase unwrapping failed': '相干性掩膜 / 相位解缠失败', + 'Processing timeout': '处理超时', + 'Result catalog publish failed': '结果目录发布失败', + 'Unclassified D-InSAR failure': '未分类失败', +}; + const PYINT_DEM_MODE_LABEL = { local_fabdem: '本地 FABDEM', opentopo: 'OpenTopography', @@ -267,6 +278,123 @@ function formatTaskRootUpdatedAt(value) { } } +function safeCount(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function getRunCounts(run) { + const completed = safeCount(run?.completed_items); + const failed = safeCount(run?.failed_items); + const skipped = safeCount(run?.skipped_items); + const total = safeCount(run?.total_items); + return { completed, failed, skipped, total }; +} + +function hasRunCounts(run) { + const { completed, failed, skipped, total } = getRunCounts(run); + return [completed, failed, skipped, total].some(value => value != null); +} + +function formatRunCounts(run) { + const { completed, failed, skipped, total } = getRunCounts(run); + const parts = []; + if (completed != null) parts.push(`成功 ${completed}`); + if (failed != null) parts.push(`失败 ${failed}`); + if (skipped != null && skipped > 0) parts.push(`跳过 ${skipped}`); + if (total != null) parts.push(`总数 ${total}`); + return parts.join(' / '); +} + +function statusToneClass(status) { + const normalized = String(status || '').toLowerCase(); + if (normalized === 'success' || normalized === 'completed') return 'tone-ready'; + if (normalized === 'failed') return 'tone-error'; + if (normalized === 'running') return 'tone-info'; + if (normalized === 'cancelled' || normalized === 'canceled') return 'tone-neutral'; + return 'tone-warn'; +} + +function formatFailureReason(reason) { + return FAILURE_REASON_LABEL[reason] || reason || '未分类失败'; +} + +function compactText(value, maxLength = 180) { + const text = String(value || '').replace(/\s+/g, ' ').trim(); + if (!text) return ''; + return text.length > maxLength ? `${text.slice(0, maxLength - 1).trim()}…` : text; +} + +function classifyFailureReason(errorMessage) { + const text = String(errorMessage || ''); + const lower = text.toLowerCase(); + if (text.includes('3221225477') || lower.includes('status_access_violation')) { + return 'LandSAR access violation during coherence mask/phase unwrapping'; + } + if (lower.includes('not enough gcps') || lower.includes('space insar calibration failed')) { + return 'Insufficient GCPs for baseline/calibration'; + } + if ( + lower.includes('no enough points') + || lower.includes('not enough points') + || lower.includes('geo_extract_gcp') + || lower.includes('无满足snr') + || text.includes('离散采样点') + ) { + return 'Insufficient tie/GCP points for DEM/geocoding'; + } + if (lower.includes('dem/sub-terrain') || lower.includes('subterrain') || lower.includes('sub-terrain')) { + return 'DEM/sub-terrain processing failed'; + } + if (text.includes('相干性掩膜') && text.includes('相位解缠')) { + return 'Coherence mask/phase unwrapping failed'; + } + if (lower.includes('timeout') || lower.includes('timed out') || text.includes('超时')) { + return 'Processing timeout'; + } + if (lower.includes('publish')) { + return 'Result catalog publish failed'; + } + return 'Unclassified D-InSAR failure'; +} + +function buildFailureSummaryFromItems(run) { + const items = Array.isArray(run?.items) ? run.items : []; + const failedItems = items.filter(item => String(item?.status || '').toUpperCase() === 'FAILED' || item?.last_error); + if (failedItems.length === 0) return null; + + const groupsByReason = new Map(); + const detailItems = failedItems.map(item => { + const reason = classifyFailureReason(item?.last_error); + const label = item?.task_alias || item?.task_name || item?.pair_key || '未命名任务'; + const group = groupsByReason.get(reason) || { reason, count: 0, items: [] }; + group.count += 1; + group.items.push(label); + groupsByReason.set(reason, group); + return { + task_alias: item?.task_alias, + task_name: item?.task_name, + reason, + error: compactText(item?.last_error, 260), + }; + }); + + return { + failed_count: failedItems.length, + partial: true, + groups: Array.from(groupsByReason.values()), + items: detailItems, + }; +} + +function getRunFailureSummary(run) { + const summary = run?.summary_json?.failure_summary; + if (summary && Number(summary.failed_count || 0) > 0) { + return summary; + } + return buildFailureSummaryFromItems(run); +} + function RunPathBlock({ run }) { const items = Array.isArray(run?.items) ? run.items : []; const item = items.find(entry => entry?.status === 'RUNNING') || items[0] || null; @@ -299,6 +427,67 @@ function RunPathBlock({ run }) { ); } +function RunStatusBlock({ run }) { + return ( +
+ + {formatStatus(run?.status)} + + {hasRunCounts(run) && ( + + {formatRunCounts(run)} + + )} +
+ ); +} + +function RunSituationBlock({ run }) { + const failureSummary = getRunFailureSummary(run); + const countsText = hasRunCounts(run) ? formatRunCounts(run) : ''; + const message = compactText(run?.message, 220); + if (!countsText && !failureSummary && !message) return null; + + return ( +
+
+ 运行情况 + {countsText && {countsText}} +
+ {failureSummary ? ( +
+ {(failureSummary.groups || []).slice(0, 4).map((group, index) => ( +
+ {formatFailureReason(group.reason)} + :{Number(group.count || 0)} 项 + {Array.isArray(group.items) && group.items.length > 0 && ( + ({group.items.slice(0, 3).join('、')}{group.items.length > 3 ? ' 等' : ''}) + )} +
+ ))} + {failureSummary.partial && Number(run?.failed_items || 0) > Number(failureSummary.failed_count || 0) && ( +
+ 当前接口仅返回部分失败明细,请查看日志获取完整失败项。 +
+ )} +
+ ) : message ? ( +
{message}
+ ) : null} +
+ ); +} + function PreviewIssueList({ title, items, tone = 'warning' }) { if (!Array.isArray(items) || items.length === 0) { return null; @@ -1957,19 +2146,15 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued }) {run.run_id} {formatEngineLabel(run.engine)} {formatSatelliteFamilyLabel(inferSatelliteFamilyFromResultLike(run))} - - {formatStatus(run.status)} + + {run.started_at ? new Date(run.started_at * 1000).toLocaleString() : '-'} + + + + + {message &&
{message}
} + + +
+
+
+

生产候选检索

+
+ 调用影像检索能力,按时间、行政区、轨道、极化等条件规划要生产的 LT-1 场景。 +
+
+
已选 {selectedRadarIdList.length} 景
+
+ +
+ + + + + + +
+ +
+ + {regionMode === 'region' && ( + <> + + + + )} + +
+ +
+
+ {searchLoading ? '正在检索影像...' : `第 ${sceneStart}-${sceneEnd} 景 / 共 ${scenePage.total} 景`} +
+
+ + + + + +
+
+ +
+ + + + + + + + + + + + + + + {scenes.map(scene => { + const produced = sceneProduced(scene); + const selectable = !produced && Boolean(scene.source_product_ref_id); + const selected = selectedRadarIds.has(scene.id); + return ( + + + + + + + + + + + ); + })} + {scenes.length === 0 && ( + + + + )} + +
选择产品日期模式轨道极化状态路径
+ toggleScene(scene)} + /> + + {getSceneTitle(scene)} + {formatYmd(scene.imaging_date)}{scene.imaging_mode || '-'}{scene.relative_orbit || scene.orbit_circle || '-'}{scene.polarization || '-'} + {produced ? '已生产 GeoTIFF' : (scene.source_product_ref_id ? '可生产' : '未关联源资产')} + + {scene.file_path} +
+ 暂无符合条件的 LT-1 影像 +
+
+
+ + {preview && ( +
+

预览结果

+
+
场景数: {preview.scene_count}
+
engine: {preview.engine}
+
profile: {preview.profile_code}
+
+ {Array.isArray(preview.blockers) && preview.blockers.length > 0 && ( +
+ {preview.blockers.map(item => ( +
{item}
+ ))} +
+ )} + {Array.isArray(preview.warnings) && preview.warnings.length > 0 && ( +
+ {preview.warnings.map(item => ( +
{item}
+ ))} +
+ )} +
+ )} + +
+
+

最近 LT-1 GeoTIFF 产品

+
{products.length} 项
+
+
+ + + + + + + + + + + + + {products.map(product => ( + + + + + + + + + ))} + {products.length === 0 && ( + + + + )} + +
产品状态日期单位时间GeoTIFF
+ {product.display_name || product.product_id} + {product.status}{formatYmd(product.summary?.imaging_date)}{product.summary?.backscatter_unit || '-'}{formatTime(product.published_at)} + {product.primary_asset_path || product.publish_dir || '-'} +
+ 暂无产品 +
+
+
+ + ); +} diff --git a/frontend/src/ProductionWorkspace.jsx b/frontend/src/ProductionWorkspace.jsx index a6f6906..68f1186 100644 --- a/frontend/src/ProductionWorkspace.jsx +++ b/frontend/src/ProductionWorkspace.jsx @@ -11,6 +11,7 @@ const LazyDinsarProductionPanel = lazy(() => import('./DinsarProductionPanel')); const LazySbasInsarProductionPanel = lazy(() => import('./SbasInsarProductionPanel')); const LazySbasInsarProductsPanel = lazy(() => import('./SbasInsarProductsPanel')); const LazyDinsarProductsPanel = lazy(() => import('./DinsarProductsPanel')); +const LazyLandsarLt1ProductionPanel = lazy(() => import('./LandsarLt1ProductionPanel')); const LazyPairPlanningPanel = lazy(() => import('./panels/PairPlanningPanel')); const LazyPairsListPanel = lazy(() => import('./panels/PairsListPanel')); const LazyBatchPanel = lazy(() => import('./panels/BatchPanel')); @@ -25,17 +26,6 @@ const WORKFLOW_STEPS = [ ]; const SENSOR_PRODUCTION_PLACEHOLDERS = { - lt1_production: { - title: '陆探一生产占位', - note: '当前保留 LT-1 源压缩包本机登记与按需 materialize 入口。', - rows: [ - ['数据来源', '本机源压缩包 archive'], - ['精轨策略', '按生产任务关联 orbit 资产'], - ['准备方式', '按需 materialize 到 Task_Pool'], - ['生产边界', 'D-InSAR/SBAS 不走 UNC'], - ['结果管理', '进入统一产品 catalog'], - ], - }, sentinel1_production: { title: 'Sentinel-1 生产占位', note: '当前主要沉淀数据与精轨管理约束,SBAS 仅保留规划能力。', @@ -206,6 +196,13 @@ export default function ProductionWorkspace({ }); }; + const handleLt1ImageQueued = taskId => { + onTaskStart?.(taskId, 'LT-1 地理编码 GeoTIFF 生产任务已入队。', { + taskType: 'SAR_SCENE_PREPROCESS', + nonBlocking: true, + }); + }; + const renderContent = () => { if (activeView === 'dinsar_pairing') { return ( @@ -215,11 +212,11 @@ export default function ProductionWorkspace({ isLoading={isLoading} isReadOnlyUser={readOnly} hasEnoughRadarScenesForPlanning={hasEnoughRadarScenesForPlanning} - onOpenPairingModal={pairingPanel?.openModal} + onOpenPairingModal={pairingPanel?.onOpenPairingModal} hasRadarSearched={hasRadarSearched} - onRefreshRadarSearch={radarPanel?.refresh} - onSearchAll={radarPanel?.searchAll} - onRefreshDinsar={pairsPanel?.refreshDinsar} + onRefreshRadarSearch={pairingPanel?.onRefreshRadarSearch} + onSearchAll={radarPanel?.onSearchAll} + onRefreshDinsar={pairingPanel?.onRefreshDinsar} language={language} /> ); @@ -228,8 +225,12 @@ export default function ProductionWorkspace({ if (activeView === 'dinsar_pairs') { return (
- - + +
); } @@ -272,11 +273,15 @@ export default function ProductionWorkspace({ return ; } + if (activeView === 'lt1_production') { + return ; + } + return ; }; return ( -
+
diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 86b70af..fe52c78 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -17,3 +17,4 @@ export * as unpackApi from './unpack'; export * as statsApi from './stats'; export * as timeseriesProductionApi from './timeseriesProduction'; export * as psinsarProductsApi from './psinsarProducts'; +export * as landsarLt1ProductionApi from './landsarLt1Production'; diff --git a/frontend/src/api/landsarLt1Production.js b/frontend/src/api/landsarLt1Production.js new file mode 100644 index 0000000..75b7020 --- /dev/null +++ b/frontend/src/api/landsarLt1Production.js @@ -0,0 +1,22 @@ +import apiClient from './client'; + +export const getLandsarLt1Capabilities = () => + apiClient.get('/landsar-lt1-production/capabilities').then(r => r.data); + +export const previewLandsarLt1Production = payload => + apiClient.post('/landsar-lt1-production/preview', payload).then(r => r.data); + +export const submitLandsarLt1Production = payload => + apiClient.post('/landsar-lt1-production/run', payload).then(r => r.data); + +export const previewLandsarLt1Import = previewLandsarLt1Production; +export const submitLandsarLt1Import = submitLandsarLt1Production; + +export const listLandsarLt1Products = (params = {}) => + apiClient.get('/landsar-lt1-production/products', { params }).then(r => r.data); + +export const getLandsarLt1Product = productId => + apiClient.get(`/landsar-lt1-production/products/${encodeURIComponent(productId)}`).then(r => r.data); + +export const getLandsarLt1AssetUrl = (productId, assetId) => + `/api/landsar-lt1-production/products/${encodeURIComponent(productId)}/assets/${encodeURIComponent(assetId)}`; diff --git a/frontend/src/api/tasks.js b/frontend/src/api/tasks.js index bc8c9fe..80a090d 100644 --- a/frontend/src/api/tasks.js +++ b/frontend/src/api/tasks.js @@ -1,6 +1,7 @@ import apiClient from './client'; export const getActiveTasks = () => apiClient.get('/tasks/active').then(r => r.data); +export const getTaskRuntimeSummary = () => apiClient.get('/tasks/runtime-summary').then(r => r.data); export const getRecentTasks = (taskTypes = [], statuses = [], limit = 20, offset = 0) => apiClient.get('/tasks/recent', { params: { diff --git a/frontend/src/components/GlobalTaskCenter.jsx b/frontend/src/components/GlobalTaskCenter.jsx index 77a2d94..16d353d 100644 --- a/frontend/src/components/GlobalTaskCenter.jsx +++ b/frontend/src/components/GlobalTaskCenter.jsx @@ -4,6 +4,7 @@ import { getTaskTypeLabel } from '../config/taskUiPolicies'; export default function GlobalTaskCenter({ isVisible, activeTasks, + runtimeSummary, t, isAdmin, showCancelTask, @@ -14,14 +15,27 @@ export default function GlobalTaskCenter({ onCloseCancelTask, }) { const [expanded, setExpanded] = useState(false); - if (!isVisible || activeTasks.length === 0) { + const jobs = runtimeSummary?.jobs || {}; + const worker = runtimeSummary?.worker || {}; + const scan = runtimeSummary?.scan || {}; + const activeJobs = Array.isArray(jobs.items) ? jobs.items : []; + const activeCount = Math.max( + activeTasks.length, + Number(jobs.active_count) || 0, + ); + const workerCount = Number(worker.worker_count) || 0; + const queuedJobs = Number(jobs.queued_count) || 0; + const runningJobs = Number(jobs.running_count) || 0; + const scanJobCount = Number(scan.active_job_count) || 0; + + if (!isVisible || activeCount === 0) { return null; } - const activeCount = activeTasks.length; const avgProgress = Math.round( activeTasks.reduce((sum, task) => sum + (Number(task.progress) || 0), 0) / Math.max(1, activeCount) ); + const visibleJobs = activeJobs.slice(0, 8); return (
@@ -29,7 +43,7 @@ export default function GlobalTaskCenter({ )} {expanded && ( @@ -37,12 +51,45 @@ export default function GlobalTaskCenter({

后台任务

-

任务正在执行,你可以继续使用其他功能;同类重复提交由系统限制。

+

展示 Worker、执行中 Job、排队 Job 和任务进度;同类重复提交由后端冲突检查处理。

+
+
+ Worker + {workerCount} +
+
+ 执行中 + {runningJobs} +
+
+ 排队 + {queuedJobs} +
+
+ 扫描 + {scanJobCount} +
+
+ {visibleJobs.length > 0 && ( +
+ {visibleJobs.map((job) => ( +
+ {job.status || '-'} + + {getTaskTypeLabel(job.task_type || job.job_type)} + + + {job.locked_by ? `Worker ${job.locked_by}` : (job.status === 'RETRY' ? '等待重试' : '等待领取')} + +
+ ))} +
+ )}
{(() => { const waterTasks = activeTasks.filter(task => @@ -73,11 +120,16 @@ export default function GlobalTaskCenter({
)} + {otherTasks.length === 0 && waterTasks.length === 0 && visibleJobs.length > 0 && ( +
+

当前只有 Job 运行态,任务进度尚未写入 system_tasks。

+
+ )} ); })()}
-

任务中心只展示状态,不再锁定整个界面。需要互斥的操作由功能页按钮和后端任务冲突检查处理。

+

取消按钮只作用于可跟踪的 Task;纯 Job 取消需要在对应功能页或运维接口处理。

{isAdmin && (
{!showCancelTask ? ( diff --git a/frontend/src/components/app/AppOverlays.jsx b/frontend/src/components/app/AppOverlays.jsx index be43216..6141296 100644 --- a/frontend/src/components/app/AppOverlays.jsx +++ b/frontend/src/components/app/AppOverlays.jsx @@ -26,6 +26,7 @@ export default function AppOverlays({ licenseFileName, licenseUploadStatus, activeTasks, + runtimeSummary, showCancelTask, cancelTaskPwd, onShowCancelTask, @@ -97,11 +98,12 @@ export default function AppOverlays({ )} - {activeTasks.length > 0 && ( + {(activeTasks.length > 0 || Number(runtimeSummary?.jobs?.active_count || 0) > 0) && ( }> 0} + isVisible={activeTasks.length > 0 || Number(runtimeSummary?.jobs?.active_count || 0) > 0} activeTasks={activeTasks} + runtimeSummary={runtimeSummary} t={t} isAdmin={isAdmin} showCancelTask={showCancelTask} diff --git a/frontend/src/components/app/AppStatusHeader.jsx b/frontend/src/components/app/AppStatusHeader.jsx index 4e6be4e..51f5158 100644 --- a/frontend/src/components/app/AppStatusHeader.jsx +++ b/frontend/src/components/app/AppStatusHeader.jsx @@ -3,7 +3,7 @@ import defaultLogoUrl from '../../logo.jpg'; import { formatUtc } from '../../utils/appUiHelpers'; const ORGANIZATION_NAME = import.meta.env.VITE_APP_ORG_NAME || '黑龙江省自然资源卫星应用技术中心'; -const SYSTEM_NAME = import.meta.env.VITE_APP_SYSTEM_NAME || 'InSAR 自动化管理系统'; +const SYSTEM_NAME = import.meta.env.VITE_APP_SYSTEM_NAME || '雷达数据生产管理系统'; const SYSTEM_TAGLINE = import.meta.env.VITE_APP_SYSTEM_TAGLINE || '科研工程生产平台'; const LOGO_URL = import.meta.env.VITE_APP_LOGO_URL || defaultLogoUrl; @@ -14,11 +14,40 @@ function AppStatusHeader({ isReadOnlyUser, activeTasks, avgTaskProgress, + runtimeSummary, licenseStatus, onLogout, }) { const licenseOk = !!licenseStatus?.ok; - const hasActiveTasks = activeTasks.length > 0; + const worker = runtimeSummary?.worker || {}; + const jobs = runtimeSummary?.jobs || {}; + const scan = runtimeSummary?.scan || {}; + const workerCount = Number(worker.worker_count) || 0; + const runningJobs = Number(jobs.running_count) || 0; + const queuedJobs = Number(jobs.queued_count) || 0; + const scanJobs = Number(scan.active_job_count) || 0; + const scanRunningJobs = Number(scan.running_job_count) || 0; + const staleJobs = Number(worker.stale_running_job_count) || 0; + const hasRuntimeActivity = activeTasks.length > 0 || runningJobs > 0 || queuedJobs > 0; + const taskProgress = hasRuntimeActivity ? avgTaskProgress : 0; + let runtimeLabel = 'Worker 未连接'; + if (!runtimeSummary && activeTasks.length > 0) { + runtimeLabel = `运行中 ${activeTasks.length}`; + } else if (runningJobs > 0 && staleJobs > 0) { + runtimeLabel = `运行态待恢复 ${staleJobs}`; + } else if (runningJobs > 0) { + runtimeLabel = `执行中 ${runningJobs}`; + } else if (queuedJobs > 0) { + runtimeLabel = `排队 ${queuedJobs}`; + } else if (workerCount > 0) { + runtimeLabel = `Worker ${workerCount} 空闲`; + } + const runtimeDetail = !runtimeSummary && activeTasks.length > 0 + ? '任务状态来自兼容接口' + : workerCount > 0 + ? `Worker ${workerCount}` + : '无在线 worker'; + const staleDetail = staleJobs > 0 ? ` · 待恢复 ${staleJobs}` : ''; return ( <> @@ -50,11 +79,12 @@ function AppStatusHeader({
-
- {hasActiveTasks ? `运行中 ${activeTasks.length}` : '任务空闲'} - {hasActiveTasks && ( +
+ {runtimeLabel} + {runtimeDetail}{staleDetail}{scanJobs > 0 ? ` · 扫描 ${scanRunningJobs}/${scanJobs}` : ''} + {hasRuntimeActivity && (