From 4c0d1f2c2baf66797c41c95b2515d64243a91d22 Mon Sep 17 00:00:00 2001 From: Harmon Date: Wed, 29 Apr 2026 14:43:31 +0800 Subject: [PATCH] feat: engineer SBAS timeseries production workflow --- .env.example | 5 + README.md | 30 +- backend/app/config.py | 21 +- backend/app/db_maintenance.py | 1 + backend/app/dinsar_engines/isce2_engine.py | 408 +++++++- .../app/isce2_pipeline/export_isce_geotiff.py | 301 +++++- .../app/isce2_pipeline/lt1_input_resolver.py | 101 +- .../app/isce2_pipeline/repair_dem_sidecars.py | 91 ++ .../isce2_pipeline/run_lt1_dinsar_pipeline.py | 415 +++++++-- backend/app/models/__init__.py | 8 +- backend/app/models/orm.py | 71 ++ backend/app/models/schemas.py | 60 +- backend/app/routers/dependencies.py | 29 +- backend/app/routers/pairing.py | 36 + backend/app/routers/task_batches.py | 178 +++- backend/app/routers/timeseries_production.py | 94 ++ backend/app/services/product_packaging.py | 3 + .../app/services/psinsar_catalog_service.py | 14 + backend/app/services/spatial_service.py | 478 +++++++++- backend/app/services/task_service.py | 3 + backend/app/services/timeseries_service.py | 871 +++++++++++++++++- backend/app/services/workflow_service.py | 91 ++ backend/app/services/wsl_service.py | 24 + .../007_timeseries_stack_plan_trace.sql | 17 + deploy/wsl/conda/README.md | 45 + deploy/wsl/conda/insar_wsl_v1.environment.yml | 2 + deploy/wsl/conda/insar_wsl_v1.explicit.lock | 146 +++ .../wsl/conda/insar_wsl_v1.fingerprint.json | 22 +- deploy/wsl/runners/isce2_runner.py | 89 +- docs/DEPLOYMENT.md | 143 ++- docs/INDEX.md | 9 + ...SCE2_LT1_ENHANCEMENT_ALIGNMENT_20260427.md | 154 ++++ .../ISCE2_SBAS_ENGINEERING_DESIGN_20260428.md | 663 +++++++++++++ docs/ISCE2_SBAS_TIMESERIES_DESIGN.md | 29 +- .../ISCE2_STABILIZATION_UPDATELOG_20260427.md | 101 ++ .../scripts/build_lt1_stack_prep.py | 77 +- .../run_generated_stack_runfile_ubuntu2404.sh | 9 +- frontend/src/App.css | 10 + frontend/src/App.jsx | 116 ++- frontend/src/DinsarProductionPanel.jsx | 81 +- frontend/src/TimeseriesProductionPanel.jsx | 415 ++++++++- frontend/src/api/timeseriesProduction.js | 9 + frontend/src/components/PsStackModal.jsx | 26 +- .../src/components/PsinsarCatalogPanel.jsx | 22 +- frontend/src/components/app/AppSidePanel.jsx | 2 + .../src/components/panels/PsStackSection.jsx | 39 +- frontend/src/hooks/usePairingLogic.js | 45 +- frontend/src/i18n/translations.js | 2 +- frontend/src/panels/PairPlanningPanel.jsx | 2 +- frontend/src/panels/PairingPanel.jsx | 2 +- frontend/src/panels/PsResultsPanel.jsx | 6 +- frontend/src/store/batchStore.js | 2 + scripts/bootstrap_clone.ps1 | 417 +++++++++ scripts/start_app.ps1 | 9 + 54 files changed, 5843 insertions(+), 201 deletions(-) create mode 100644 backend/app/isce2_pipeline/repair_dem_sidecars.py create mode 100644 backend/migrations/007_timeseries_stack_plan_trace.sql create mode 100644 docs/ISCE2_LT1_ENHANCEMENT_ALIGNMENT_20260427.md create mode 100644 docs/ISCE2_SBAS_ENGINEERING_DESIGN_20260428.md create mode 100644 scripts/bootstrap_clone.ps1 diff --git a/.env.example b/.env.example index 7e0bdd5..4e9e677 100644 --- a/.env.example +++ b/.env.example @@ -145,6 +145,11 @@ ISCE2_WORK_ROOT=D:\Code\Insar_management_system_v2\backend\runtime\isce2_work ISCE2_OUTPUT_ROOT=D:\production_results\dinsar ISCE2_PER_TASK_TIMEOUT_SECONDS=43200 ISCE2_SMOKE_TEST_ENABLED=false +# Managed LT-1 stripmap production defaults are defined in code: +# split-spectrum ionosphere correction = on, +# dense offsets = on, rubbersheeting range = on, rubbersheeting azimuth = on. +# These are profile-level workflow semantics, not standalone .env toggles. +# The WSL runtime must include cv2 + scipy for ionosphere correction and astropy for rubbersheeting. # ----------------------------------------------------------------------------- diff --git a/README.md b/README.md index b0a71a5..cc67967 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ - 会自动创建缺失表。 - 会自动补齐缺失列。 -- 会自动执行 `backend/migrations/001` 到 `006` 的 SQL 文件。 +- 会自动执行 `backend/migrations/001` 到 `007` 的 SQL 文件。 - 只有同时设置 `DB_SCHEMA_RESET_ON_MISMATCH=true` 和 `DB_SCHEMA_RESET_CONFIRM=true`,才允许破坏性重建。 不会自动处理的情况: @@ -77,6 +77,34 @@ python run_backend.py python run_worker.py ``` +## Git Clone Deployment + +This repository is intended to stay deployable after a clean `git clone` on a new +Windows host. + +Recommended bootstrap path: + +```powershell +git clone +cd Insar_management_system_v2 +Copy-Item .env.example .env +notepad .env +powershell -ExecutionPolicy Bypass -File .\scripts\bootstrap_clone.ps1 -InitFrontend -BuildFrontend +start_system.bat +``` + +Optional runtime bootstrap commands: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\bootstrap_clone.ps1 -InitWindowsConda +powershell -ExecutionPolicy Bypass -File .\scripts\bootstrap_clone.ps1 -InitWslConda +powershell -ExecutionPolicy Bypass -File .\scripts\bootstrap_clone.ps1 -All +``` + +The clone bootstrap script keeps the existing startup, database self-maintenance, +and health-check chain unchanged. See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) +for the current deployment model and switch details. + 说明: - Worker 必须常驻,否则生产任务、扫描任务、解包任务不会执行。 diff --git a/backend/app/config.py b/backend/app/config.py index 8d3efab..c9d9cc5 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -506,14 +506,25 @@ class Settings(BaseSettings): if not self.TIMESERIES_WSL_DISTRO: object.__setattr__(self, "TIMESERIES_WSL_DISTRO", self.WSL_DISTRO or self.ISCE2_WSL_DISTRO) if not self.TIMESERIES_ENV_NAME: - object.__setattr__(self, "TIMESERIES_ENV_NAME", "isce2_mintpy_v1") - if not self.TIMESERIES_PYTHON: - env_name = str(self.TIMESERIES_ENV_NAME or "isce2_mintpy_v1").strip() or "isce2_mintpy_v1" object.__setattr__( self, - "TIMESERIES_PYTHON", - f"/home/administrator/miniconda3/envs/{env_name}/bin/python", + "TIMESERIES_ENV_NAME", + str(self.WSL_SHARED_CONDA_ENV or "insar_wsl_v1").strip() or "insar_wsl_v1", ) + if not self.TIMESERIES_PYTHON: + shared_python = str(self.WSL_SHARED_PYTHON or "").strip() + if shared_python: + object.__setattr__(self, "TIMESERIES_PYTHON", shared_python) + else: + env_name = ( + str(self.TIMESERIES_ENV_NAME or "insar_wsl_v1").strip() + or "insar_wsl_v1" + ) + object.__setattr__( + self, + "TIMESERIES_PYTHON", + f"/home/administrator/miniconda3/envs/{env_name}/bin/python", + ) if not self.TIMESERIES_WORK_ROOT: object.__setattr__( self, diff --git a/backend/app/db_maintenance.py b/backend/app/db_maintenance.py index 1913fb9..b5ac2fc 100644 --- a/backend/app/db_maintenance.py +++ b/backend/app/db_maintenance.py @@ -33,6 +33,7 @@ MIGRATION_FILES = [ "004_pairing_refactor.sql", "005_pairing_task_trace.sql", "006_result_pairing_trace.sql", + "007_timeseries_stack_plan_trace.sql", ] diff --git a/backend/app/dinsar_engines/isce2_engine.py b/backend/app/dinsar_engines/isce2_engine.py index 4326bde..845d778 100644 --- a/backend/app/dinsar_engines/isce2_engine.py +++ b/backend/app/dinsar_engines/isce2_engine.py @@ -27,8 +27,22 @@ LT1_FIXED_WAVELENGTH = 0.23793052222222222 DEFAULT_TARGET_GRID_SIZE_M = 10 DEFAULT_BBOX_MARGIN = 0.05 DEFAULT_COH_THRESHOLD = 0.05 -DEFAULT_REFERENCE_MODE = "none" +DEFAULT_REFERENCE_MODE = "coh_median" DEFAULT_REFERENCE_COH_THRESHOLD = 0.30 +DEFAULT_DERAMP_MODE = "plane" +DEFAULT_DERAMP_COH_THRESHOLD = 0.30 +DEFAULT_DENSE_OFFSETS = True +DEFAULT_RUBBERSHEET_RANGE = True +DEFAULT_RUBBERSHEET_AZIMUTH = True +DEFAULT_IONOSPHERE_CORRECTION = True +DEFAULT_RUBBER_SHEET_SNR_THRESHOLD = 5.0 +DEFAULT_RUBBER_SHEET_FILTER_SIZE = 9 +DEFAULT_DENSE_WINDOW_WIDTH = 64 +DEFAULT_DENSE_WINDOW_HEIGHT = 64 +DEFAULT_DENSE_SEARCH_WIDTH = 20 +DEFAULT_DENSE_SEARCH_HEIGHT = 20 +DEFAULT_DENSE_SKIP_WIDTH = 32 +DEFAULT_DENSE_SKIP_HEIGHT = 32 ORBIT_MARGIN_MIN_SEC = 60.0 ORBIT_MARGIN_MAX_SEC = 120.0 TARGET_GRID_SIZE_MIN_M = 5 @@ -36,6 +50,7 @@ TARGET_GRID_SIZE_MAX_M = 100 RERUN_MODE_UNFINISHED_ONLY = "unfinished_only" RESUME_STAGE_CHOICES = {"", "unwrap", "geocode", "export"} REFERENCE_MODE_CHOICES = {"none", "coh_median"} +DERAMP_MODE_CHOICES = {"none", "plane"} def _read_env(name: str, default: str = "") -> str: @@ -206,7 +221,7 @@ class Isce2Engine(DinsarEngine): # Profiles # ------------------------------------------------------------------ - def get_profiles(self) -> List[EngineProfile]: + def _legacy_get_profiles(self) -> List[EngineProfile]: return [ EngineProfile( code="lt1_stripmap", @@ -281,6 +296,243 @@ class Isce2Engine(DinsarEngine): ), ] + def get_profiles(self) -> List[EngineProfile]: + return [ + EngineProfile( + code="lt1_stripmap", + label="LT-1 Stripmap", + description=( + "Managed LT-1 stripmap D-InSAR production in WSL with the standard " + "ISCE2 enhancement steps and split-spectrum ionosphere correction enabled by default." + ), + params_schema={ + "force": { + "label": "Rebuild Work Dir", + "type": "boolean", + "default": False, + "section": "Execution", + "description": "Delete the existing work directory before rerunning the task.", + "recommendation": "Use only when the previous work directory can be discarded.", + }, + "target_grid_size_m": { + "label": "Target Grid Size (m)", + "type": "number", + "default": DEFAULT_TARGET_GRID_SIZE_M, + "step": 1, + "min": TARGET_GRID_SIZE_MIN_M, + "max": TARGET_GRID_SIZE_MAX_M, + "section": "Execution", + "description": "Controls multilook scale and geocoded output spacing.", + "recommendation": "Use 10 by default; try 5 for more detail or 15-20 for more stability.", + }, + "bbox": { + "label": "Geocode BBox", + "type": "string", + "default": "", + "placeholder": "south,north,west,east", + "section": "Execution", + "description": "Optional manual geocode bounding box.", + "recommendation": "Leave empty unless you need to constrain the output area.", + }, + "coh_threshold": { + "label": "Coherence Threshold", + "type": "number", + "default": DEFAULT_COH_THRESHOLD, + "step": 0.01, + "min": 0, + "max": 1, + "section": "Delivery", + "description": "Masks displacement pixels below this coherence threshold in the exported product.", + "recommendation": "Use 0.05 for broad inspection and 0.10+ for stricter delivery.", + }, + "reference_mode": { + "label": "Reference Mode", + "type": "string", + "default": DEFAULT_REFERENCE_MODE, + "enum": sorted(REFERENCE_MODE_CHOICES), + "section": "Delivery", + "description": "Normalizes the final displacement field before delivery.", + "recommendation": "Use coh_median for production so the result is centered on stable high-coherence pixels.", + }, + "reference_coh_threshold": { + "label": "Reference Coh Threshold", + "type": "number", + "default": DEFAULT_REFERENCE_COH_THRESHOLD, + "step": 0.01, + "min": 0, + "max": 1, + "section": "Delivery", + "description": "Minimum coherence used when selecting pixels for displacement referencing.", + "recommendation": "Use 0.30 by default; raise it only when you have enough high-quality support pixels.", + }, + "deramp_mode": { + "label": "Deramp Mode", + "type": "string", + "default": DEFAULT_DERAMP_MODE, + "enum": sorted(DERAMP_MODE_CHOICES), + "section": "Delivery", + "description": "Removes long-wavelength ramp residuals after referencing.", + "recommendation": "Use plane for LT-1 production unless you are explicitly debugging raw ISCE2 output.", + }, + "deramp_coh_threshold": { + "label": "Deramp Coh Threshold", + "type": "number", + "default": DEFAULT_DERAMP_COH_THRESHOLD, + "step": 0.01, + "min": 0, + "max": 1, + "section": "Delivery", + "description": "Minimum coherence used when selecting pixels for deramp fitting.", + "recommendation": "Use 0.30 by default so the ramp is fitted on cleaner support pixels.", + }, + "bbox_margin": { + "label": "BBox Margin (deg)", + "type": "number", + "default": DEFAULT_BBOX_MARGIN, + "step": 0.01, + "min": 0, + "section": "Execution", + "description": "Extra degree margin added to the auto-estimated geocode bounding box.", + "recommendation": "Use 0.05 by default; increase only if edges are clipped.", + }, + "ionosphere_correction": { + "label": "Enable Split-Spectrum Ionosphere Correction", + "type": "boolean", + "default": DEFAULT_IONOSPHERE_CORRECTION, + "section": "Enhancement", + "description": "Runs the split-spectrum dispersive correction branch before geocode and export.", + "recommendation": "Keep enabled by default; disable it when the correction itself is suspected to degrade a scene.", + }, + "dense_offsets": { + "label": "Enable Dense Offsets", + "type": "boolean", + "default": DEFAULT_DENSE_OFFSETS, + "section": "Enhancement", + "description": "Run ISCE2 dense offset estimation before fine resampling.", + "recommendation": "Keep enabled for LT-1 stripmap production.", + }, + "rubbersheet_range": { + "label": "Enable Range Rubbersheeting", + "type": "boolean", + "default": DEFAULT_RUBBERSHEET_RANGE, + "section": "Enhancement", + "description": "Update range offsets with dense offsets before fine resampling.", + "recommendation": "Keep enabled for LT-1 stripmap production.", + }, + "rubbersheet_azimuth": { + "label": "Enable Azimuth Rubbersheeting", + "type": "boolean", + "default": DEFAULT_RUBBERSHEET_AZIMUTH, + "section": "Enhancement", + "description": "Update azimuth offsets with dense offsets before fine resampling.", + "recommendation": "Keep enabled for LT-1 stripmap production.", + }, + "rubber_sheet_snr_threshold": { + "label": "Rubbersheet SNR Threshold", + "type": "number", + "default": DEFAULT_RUBBER_SHEET_SNR_THRESHOLD, + "step": 0.5, + "min": 0, + "section": "Enhancement", + "description": "SNR threshold used when masking dense offsets for rubbersheeting.", + "recommendation": "Start with 5.0 unless a scene-specific diagnosis suggests otherwise.", + }, + "rubber_sheet_filter_size": { + "label": "Rubbersheet Filter Size", + "type": "number", + "default": DEFAULT_RUBBER_SHEET_FILTER_SIZE, + "step": 1, + "min": 1, + "section": "Enhancement", + "description": "Median filter size used when smoothing masked dense offsets.", + "recommendation": "Start with 9.", + }, + "dense_window_width": { + "label": "Dense Window Width", + "type": "number", + "default": DEFAULT_DENSE_WINDOW_WIDTH, + "step": 1, + "min": 1, + "section": "Enhancement", + "description": "Dense offset correlation window width.", + "recommendation": "Start with 64.", + }, + "dense_window_height": { + "label": "Dense Window Height", + "type": "number", + "default": DEFAULT_DENSE_WINDOW_HEIGHT, + "step": 1, + "min": 1, + "section": "Enhancement", + "description": "Dense offset correlation window height.", + "recommendation": "Start with 64.", + }, + "dense_search_width": { + "label": "Dense Search Width", + "type": "number", + "default": DEFAULT_DENSE_SEARCH_WIDTH, + "step": 1, + "min": 1, + "section": "Enhancement", + "description": "Dense offset search width.", + "recommendation": "Start with 20.", + }, + "dense_search_height": { + "label": "Dense Search Height", + "type": "number", + "default": DEFAULT_DENSE_SEARCH_HEIGHT, + "step": 1, + "min": 1, + "section": "Enhancement", + "description": "Dense offset search height.", + "recommendation": "Start with 20.", + }, + "dense_skip_width": { + "label": "Dense Skip Width", + "type": "number", + "default": DEFAULT_DENSE_SKIP_WIDTH, + "step": 1, + "min": 1, + "section": "Enhancement", + "description": "Dense offset sampling stride in range direction.", + "recommendation": "Start with 32.", + }, + "dense_skip_height": { + "label": "Dense Skip Height", + "type": "number", + "default": DEFAULT_DENSE_SKIP_HEIGHT, + "step": 1, + "min": 1, + "section": "Enhancement", + "description": "Dense offset sampling stride in azimuth direction.", + "recommendation": "Start with 32.", + }, + "wavelength": { + "label": "Radar Wavelength (m)", + "type": "number", + "default": LT1_FIXED_WAVELENGTH, + "step": 0.000001, + "readonly": True, + "include_in_payload": False, + "section": "Execution", + "description": "Fixed LT-1 radar wavelength used for displacement conversion.", + "recommendation": "This value is locked by the system.", + }, + "orbit_margin_sec": { + "label": "Orbit Margin (sec)", + "type": "number", + "default": ORBIT_MARGIN_MIN_SEC, + "step": 1, + "min": ORBIT_MARGIN_MIN_SEC, + "max": ORBIT_MARGIN_MAX_SEC, + "section": "Execution", + "description": "Extra time margin preserved when clipping the precise orbit XML.", + "recommendation": "Use 60 by default; raise to 90-120 only if scene timing is tight.", + }, + }, + ), + ] + def normalize_extra(self, extra: Dict[str, Any] | None) -> Dict[str, Any]: normalized: Dict[str, Any] = dict(extra or {}) normalized.pop("wavelength", None) @@ -330,6 +582,22 @@ class Isce2Engine(DinsarEngine): raise ValueError("reference_coh_threshold must be between 0 and 1") normalized["reference_coh_threshold"] = reference_coh_threshold + if "deramp_mode" in normalized and normalized["deramp_mode"] is not None: + deramp_mode = str(normalized["deramp_mode"]).strip().lower() + if deramp_mode not in DERAMP_MODE_CHOICES: + supported_modes = ", ".join(sorted(DERAMP_MODE_CHOICES)) + raise ValueError(f"deramp_mode must be one of: {supported_modes}") + normalized["deramp_mode"] = deramp_mode + + if "deramp_coh_threshold" in normalized and normalized["deramp_coh_threshold"] is not None: + try: + deramp_coh_threshold = float(normalized["deramp_coh_threshold"]) + except (TypeError, ValueError) as exc: + raise ValueError("deramp_coh_threshold must be numeric") from exc + if deramp_coh_threshold < 0 or deramp_coh_threshold > 1: + raise ValueError("deramp_coh_threshold must be between 0 and 1") + normalized["deramp_coh_threshold"] = deramp_coh_threshold + if "bbox_margin" in normalized and normalized["bbox_margin"] is not None: try: bbox_margin = float(normalized["bbox_margin"]) @@ -339,6 +607,40 @@ class Isce2Engine(DinsarEngine): raise ValueError("范围外扩量不能小于 0。") normalized["bbox_margin"] = bbox_margin + for bool_key in ("ionosphere_correction", "dense_offsets", "rubbersheet_range", "rubbersheet_azimuth"): + if bool_key in normalized: + normalized[bool_key] = bool(normalized[bool_key]) + + if "rubber_sheet_snr_threshold" in normalized and normalized["rubber_sheet_snr_threshold"] is not None: + try: + snr_threshold = float(normalized["rubber_sheet_snr_threshold"]) + except (TypeError, ValueError) as exc: + raise ValueError("rubber_sheet_snr_threshold must be numeric") from exc + if snr_threshold < 0: + raise ValueError("rubber_sheet_snr_threshold must be non-negative") + normalized["rubber_sheet_snr_threshold"] = snr_threshold + + for int_key in ( + "rubber_sheet_filter_size", + "dense_window_width", + "dense_window_height", + "dense_search_width", + "dense_search_height", + "dense_skip_width", + "dense_skip_height", + ): + if int_key not in normalized or normalized[int_key] is None: + continue + try: + numeric_value = float(normalized[int_key]) + except (TypeError, ValueError) as exc: + raise ValueError(f"{int_key} must be numeric") from exc + if int(numeric_value) != numeric_value: + raise ValueError(f"{int_key} must be an integer") + if int(numeric_value) <= 0: + raise ValueError(f"{int_key} must be greater than 0") + normalized[int_key] = int(numeric_value) + if "orbit_margin_sec" in normalized and normalized["orbit_margin_sec"] is not None: try: orbit_margin = float(normalized["orbit_margin_sec"]) @@ -548,7 +850,21 @@ class Isce2Engine(DinsarEngine): coh_threshold: Any, reference_mode: str, reference_coh_threshold: Any, + deramp_mode: str, + deramp_coh_threshold: Any, bbox_margin: Any, + dense_offsets: bool, + rubbersheet_range: bool, + rubbersheet_azimuth: bool, + ionosphere_correction: bool, + rubber_sheet_snr_threshold: Any, + rubber_sheet_filter_size: Any, + dense_window_width: Any, + dense_window_height: Any, + dense_search_width: Any, + dense_search_height: Any, + dense_skip_width: Any, + dense_skip_height: Any, wavelength: Any, orbit_margin_sec: Any, full_geocode: bool, @@ -586,11 +902,26 @@ class Isce2Engine(DinsarEngine): "coh_threshold": coh_threshold, "reference_mode": str(reference_mode or "").strip(), "reference_coh_threshold": reference_coh_threshold, + "deramp_mode": str(deramp_mode or "").strip(), + "deramp_coh_threshold": deramp_coh_threshold, "bbox_margin": bbox_margin, + "dense_offsets": bool(dense_offsets), + "rubbersheet_range": bool(rubbersheet_range), + "rubbersheet_azimuth": bool(rubbersheet_azimuth), + "ionosphere_correction": bool(ionosphere_correction), + "rubber_sheet_snr_threshold": rubber_sheet_snr_threshold, + "rubber_sheet_filter_size": rubber_sheet_filter_size, + "dense_window_width": dense_window_width, + "dense_window_height": dense_window_height, + "dense_search_width": dense_search_width, + "dense_search_height": dense_search_height, + "dense_skip_width": dense_skip_width, + "dense_skip_height": dense_skip_height, "wavelength": wavelength, "orbit_margin_sec": orbit_margin_sec, "full_geocode": bool(full_geocode), "resume_from": str(resume_from or "").strip(), + "split_spectrum": bool(ionosphere_correction), }, "pair_meta": dict(pair_meta or {}), } @@ -704,7 +1035,34 @@ class Isce2Engine(DinsarEngine): "reference_coh_threshold", DEFAULT_REFERENCE_COH_THRESHOLD, ) + deramp_mode = str( + extra.get("deramp_mode", DEFAULT_DERAMP_MODE) or DEFAULT_DERAMP_MODE + ).strip().lower() + deramp_coh_threshold = extra.get( + "deramp_coh_threshold", + DEFAULT_DERAMP_COH_THRESHOLD, + ) bbox_margin = extra.get("bbox_margin", DEFAULT_BBOX_MARGIN) + ionosphere_correction = bool( + extra.get("ionosphere_correction", DEFAULT_IONOSPHERE_CORRECTION) + ) + dense_offsets = bool(extra.get("dense_offsets", DEFAULT_DENSE_OFFSETS)) + rubbersheet_range = bool(extra.get("rubbersheet_range", DEFAULT_RUBBERSHEET_RANGE)) + rubbersheet_azimuth = bool(extra.get("rubbersheet_azimuth", DEFAULT_RUBBERSHEET_AZIMUTH)) + rubber_sheet_snr_threshold = extra.get( + "rubber_sheet_snr_threshold", + DEFAULT_RUBBER_SHEET_SNR_THRESHOLD, + ) + rubber_sheet_filter_size = extra.get( + "rubber_sheet_filter_size", + DEFAULT_RUBBER_SHEET_FILTER_SIZE, + ) + dense_window_width = extra.get("dense_window_width", DEFAULT_DENSE_WINDOW_WIDTH) + dense_window_height = extra.get("dense_window_height", DEFAULT_DENSE_WINDOW_HEIGHT) + dense_search_width = extra.get("dense_search_width", DEFAULT_DENSE_SEARCH_WIDTH) + dense_search_height = extra.get("dense_search_height", DEFAULT_DENSE_SEARCH_HEIGHT) + dense_skip_width = extra.get("dense_skip_width", DEFAULT_DENSE_SKIP_WIDTH) + dense_skip_height = extra.get("dense_skip_height", DEFAULT_DENSE_SKIP_HEIGHT) wavelength = LT1_FIXED_WAVELENGTH orbit_margin_sec = extra.get("orbit_margin_sec", ORBIT_MARGIN_MIN_SEC) full_geocode = bool(extra.get("full_geocode")) @@ -850,7 +1208,21 @@ class Isce2Engine(DinsarEngine): coh_threshold=coh_threshold, reference_mode=reference_mode, reference_coh_threshold=reference_coh_threshold, + deramp_mode=deramp_mode, + deramp_coh_threshold=deramp_coh_threshold, bbox_margin=bbox_margin, + dense_offsets=dense_offsets, + rubbersheet_range=rubbersheet_range, + rubbersheet_azimuth=rubbersheet_azimuth, + ionosphere_correction=ionosphere_correction, + rubber_sheet_snr_threshold=rubber_sheet_snr_threshold, + rubber_sheet_filter_size=rubber_sheet_filter_size, + dense_window_width=dense_window_width, + dense_window_height=dense_window_height, + dense_search_width=dense_search_width, + dense_search_height=dense_search_height, + dense_skip_width=dense_skip_width, + dense_skip_height=dense_skip_height, wavelength=wavelength, orbit_margin_sec=orbit_margin_sec, full_geocode=full_geocode, @@ -930,9 +1302,25 @@ class Isce2Engine(DinsarEngine): "coh_threshold": coh_threshold, "reference_mode": reference_mode, "reference_coh_threshold": reference_coh_threshold, + "deramp_mode": deramp_mode, + "deramp_coh_threshold": deramp_coh_threshold, "bbox_margin": bbox_margin, + "ionosphere_correction": ionosphere_correction, + "dense_offsets": dense_offsets, + "rubbersheet_range": rubbersheet_range, + "rubbersheet_azimuth": rubbersheet_azimuth, + "rubber_sheet_snr_threshold": rubber_sheet_snr_threshold, + "rubber_sheet_filter_size": rubber_sheet_filter_size, + "dense_window_width": dense_window_width, + "dense_window_height": dense_window_height, + "dense_search_width": dense_search_width, + "dense_search_height": dense_search_height, + "dense_skip_width": dense_skip_width, + "dense_skip_height": dense_skip_height, "wavelength": wavelength, "orbit_margin_sec": orbit_margin_sec, + "split_spectrum": ionosphere_correction, + "ionosphere_correction": ionosphere_correction, }, "master_path": pair_meta.get("master_path"), "slave_path": pair_meta.get("slave_path"), @@ -1053,9 +1441,25 @@ class Isce2Engine(DinsarEngine): "coh_threshold": coh_threshold, "reference_mode": reference_mode, "reference_coh_threshold": reference_coh_threshold, + "deramp_mode": deramp_mode, + "deramp_coh_threshold": deramp_coh_threshold, "bbox_margin": bbox_margin, + "ionosphere_correction": ionosphere_correction, + "dense_offsets": dense_offsets, + "rubbersheet_range": rubbersheet_range, + "rubbersheet_azimuth": rubbersheet_azimuth, + "rubber_sheet_snr_threshold": rubber_sheet_snr_threshold, + "rubber_sheet_filter_size": rubber_sheet_filter_size, + "dense_window_width": dense_window_width, + "dense_window_height": dense_window_height, + "dense_search_width": dense_search_width, + "dense_search_height": dense_search_height, + "dense_skip_width": dense_skip_width, + "dense_skip_height": dense_skip_height, "wavelength": wavelength, "orbit_margin_sec": orbit_margin_sec, + "split_spectrum": ionosphere_correction, + "ionosphere_correction": ionosphere_correction, "runtime_id": runtime.runtime_id, "command": last_task_result.get("command", ""), "runner_argv": last_task_result.get("runner_argv", []), diff --git a/backend/app/isce2_pipeline/export_isce_geotiff.py b/backend/app/isce2_pipeline/export_isce_geotiff.py index 74d656d..e95153c 100644 --- a/backend/app/isce2_pipeline/export_isce_geotiff.py +++ b/backend/app/isce2_pipeline/export_isce_geotiff.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse +import json from pathlib import Path import numpy as np @@ -11,9 +12,12 @@ gdal.UseExceptions() DEFAULT_WAVELENGTH = 0.23793052222222222 DEFAULT_NODATA = -9999.0 -DEFAULT_REFERENCE_MODE = "none" +DEFAULT_REFERENCE_MODE = "coh_median" DEFAULT_REFERENCE_COH_THRESHOLD = 0.30 +DEFAULT_DERAMP_MODE = "plane" +DEFAULT_DERAMP_COH_THRESHOLD = 0.30 REFERENCE_MODE_CHOICES = ("none", "coh_median") +DERAMP_MODE_CHOICES = ("none", "plane") def parse_args() -> argparse.Namespace: @@ -54,7 +58,7 @@ def parse_args() -> argparse.Namespace: type=str, choices=REFERENCE_MODE_CHOICES, default=DEFAULT_REFERENCE_MODE, - help="Optional reference normalization mode for debug exports", + help="Reference normalization mode applied before final displacement export", ) parser.add_argument( "--reference-coh-threshold", @@ -62,10 +66,23 @@ def parse_args() -> argparse.Namespace: default=DEFAULT_REFERENCE_COH_THRESHOLD, help="Minimum coherence used to select reference pixels for normalization", ) + parser.add_argument( + "--deramp-mode", + type=str, + choices=DERAMP_MODE_CHOICES, + default=DEFAULT_DERAMP_MODE, + help="Optional long-wavelength ramp removal applied after reference normalization", + ) + parser.add_argument( + "--deramp-coh-threshold", + type=float, + default=DEFAULT_DERAMP_COH_THRESHOLD, + help="Minimum coherence used when selecting pixels for deramp fitting", + ) parser.add_argument( "--include-disp-full", action="store_true", - help="Also export the coherence-unmasked displacement GeoTIFF for debugging", + help="Also export the coherence-unmasked final displacement GeoTIFF", ) return parser.parse_args() @@ -93,6 +110,51 @@ def write_geotiff(array: np.ndarray, ref_ds: gdal.Dataset, out_path: Path, nodat ds = None +def _resolve_phase_source(work_dir: Path) -> dict[str, str | bool]: + ionosphere_phase = work_dir / "ionosphere" / "nondispersive.bil.unwCor.filt.geo.vrt" + ionosphere_mask = work_dir / "ionosphere" / "mask.bil.geo.vrt" + full_unwrap = work_dir / "interferogram" / "filt_topophase.unw.geo.vrt" + if ionosphere_phase.exists(): + return { + "phase_path": str(ionosphere_phase), + "phase_source": "ionosphere_nondispersive", + "mask_path": str(ionosphere_mask) if ionosphere_mask.exists() else "", + "ionosphere_corrected": True, + } + return { + "phase_path": str(full_unwrap), + "phase_source": "interferogram_unwrapped", + "mask_path": "", + "ionosphere_corrected": False, + } + + +def _select_support_mask( + *, + base_mask: np.ndarray, + amp_valid: np.ndarray, + disp_valid: np.ndarray, + coh: np.ndarray, + selection_threshold: float, +) -> tuple[np.ndarray, dict[str, float | int | str]]: + fallback = "" + support_mask = base_mask & (coh >= selection_threshold) + if not support_mask.any(): + support_mask = base_mask & (coh > 0) + fallback = "coh>0" + if not support_mask.any(): + support_mask = amp_valid & disp_valid + fallback = "amp_only" + stats: dict[str, float | int | str] = { + "selection_threshold": float(selection_threshold), + "fallback": fallback, + "support_ratio": float(base_mask.mean()), + "support_count": int(support_mask.sum()), + "support_mask_ratio": float(support_mask.mean()), + } + return support_mask, stats + + def compute_reference_offset( disp_m_raw: np.ndarray, amp: np.ndarray, @@ -100,7 +162,7 @@ def compute_reference_offset( coh_threshold: float, reference_mode: str, reference_coh_threshold: float, -) -> tuple[float, dict[str, float | int | str]]: +) -> tuple[float, np.ndarray, dict[str, float | int | str]]: amp_valid = np.isfinite(amp) & (amp != 0) coh_finite = np.isfinite(coh) disp_valid = np.isfinite(disp_m_raw) @@ -121,17 +183,16 @@ def compute_reference_offset( "fallback": "", } if normalized_mode == "none": - return 0.0, stats + return 0.0, base_mask, stats selection_threshold = min(1.0, max(0.0, max(float(coh_threshold), float(reference_coh_threshold)))) - reference_mask = base_mask & (coh >= selection_threshold) - fallback = "" - if not reference_mask.any(): - reference_mask = base_mask & (coh > 0) - fallback = "coh>0" - if not reference_mask.any(): - reference_mask = amp_valid & disp_valid - fallback = "amp_only" + reference_mask, mask_stats = _select_support_mask( + base_mask=base_mask, + amp_valid=amp_valid, + disp_valid=disp_valid, + coh=coh, + selection_threshold=selection_threshold, + ) reference_count = int(reference_mask.sum()) if reference_count <= 0: @@ -141,11 +202,101 @@ def compute_reference_offset( { "reference_count": reference_count, "reference_ratio": float(reference_mask.mean()), - "selection_threshold": float(selection_threshold), - "fallback": fallback, + "selection_threshold": float(mask_stats["selection_threshold"]), + "fallback": str(mask_stats["fallback"]), } ) - return float(np.median(disp_m_raw[reference_mask])), stats + return float(np.median(disp_m_raw[reference_mask])), reference_mask, stats + + +def compute_deramp_surface( + disp_m: np.ndarray, + amp: np.ndarray, + coh: np.ndarray, + coh_threshold: float, + deramp_mode: str, + deramp_coh_threshold: float, +) -> tuple[np.ndarray, np.ndarray, dict[str, float | int | str | bool]]: + amp_valid = np.isfinite(amp) & (amp != 0) + coh_finite = np.isfinite(coh) + disp_valid = np.isfinite(disp_m) + base_mask = amp_valid & coh_finite & disp_valid + + normalized_mode = str(deramp_mode or DEFAULT_DERAMP_MODE).strip().lower() + if normalized_mode not in DERAMP_MODE_CHOICES: + raise ValueError(f"Unsupported deramp mode: {deramp_mode}") + + empty_surface = np.zeros_like(disp_m, dtype=np.float32) + stats: dict[str, float | int | str | bool] = { + "mode": normalized_mode, + "applied": False, + "fit_count": 0, + "fit_ratio": 0.0, + "selection_threshold": 0.0, + "fallback": "", + "sample_step": 0, + "sample_count": 0, + } + if normalized_mode == "none": + return empty_surface, base_mask, stats + if not base_mask.any(): + return empty_surface, base_mask, stats + + selection_threshold = min(1.0, max(0.0, max(float(coh_threshold), float(deramp_coh_threshold)))) + fit_mask, mask_stats = _select_support_mask( + base_mask=base_mask, + amp_valid=amp_valid, + disp_valid=disp_valid, + coh=coh, + selection_threshold=selection_threshold, + ) + fit_count = int(fit_mask.sum()) + stats.update( + { + "fit_count": fit_count, + "fit_ratio": float(fit_mask.mean()), + "selection_threshold": float(mask_stats["selection_threshold"]), + "fallback": str(mask_stats["fallback"]), + } + ) + if fit_count < 3: + stats["fallback"] = "insufficient_support" + return empty_surface, fit_mask, stats + + yy, xx = np.indices(disp_m.shape, dtype=np.float64) + xs = xx[fit_mask] + ys = yy[fit_mask] + zs = disp_m[fit_mask].astype(np.float64) + sample_step = max(1, fit_count // 250_000) + if sample_step > 1: + xs = xs[::sample_step] + ys = ys[::sample_step] + zs = zs[::sample_step] + sample_count = int(zs.size) + stats["sample_step"] = int(sample_step) + stats["sample_count"] = sample_count + if sample_count < 3: + stats["fallback"] = "insufficient_sample" + return empty_surface, fit_mask, stats + + design = np.column_stack([xs, ys, np.ones_like(xs)]) + coeffs, _, _, _ = np.linalg.lstsq(design, zs, rcond=None) + plane = ( + coeffs[0] * xx + + coeffs[1] * yy + + coeffs[2] + ).astype(np.float32) + stats.update( + { + "applied": True, + "coef_x_per_pixel": float(coeffs[0]), + "coef_y_per_pixel": float(coeffs[1]), + "intercept_m": float(coeffs[2]), + "left_right_delta_m": float(coeffs[0] * max(disp_m.shape[1] - 1, 0)), + "top_bottom_delta_m": float(coeffs[1] * max(disp_m.shape[0] - 1, 0)), + } + ) + return plane, fit_mask, stats def export_products( @@ -156,32 +307,48 @@ def export_products( coh_threshold: float, reference_mode: str = DEFAULT_REFERENCE_MODE, reference_coh_threshold: float = DEFAULT_REFERENCE_COH_THRESHOLD, + deramp_mode: str = DEFAULT_DERAMP_MODE, + deramp_coh_threshold: float = DEFAULT_DERAMP_COH_THRESHOLD, include_disp_full: bool = False, nodata: float = DEFAULT_NODATA, ) -> dict[str, Path]: unw_path = work_dir / "interferogram" / "filt_topophase.unw.geo.vrt" cor_path = work_dir / "interferogram" / "topophase.cor.geo.vrt" + phase_source = _resolve_phase_source(work_dir) + phase_path = Path(str(phase_source["phase_path"])) + mask_path = Path(str(phase_source["mask_path"])) if str(phase_source["mask_path"]) else None if not unw_path.exists(): raise FileNotFoundError(f"Missing unwrapped product: {unw_path}") if not cor_path.exists(): raise FileNotFoundError(f"Missing coherence product: {cor_path}") + if not phase_path.exists(): + raise FileNotFoundError(f"Missing phase source product: {phase_path}") unw_ds = gdal.Open(str(unw_path)) cor_ds = gdal.Open(str(cor_path)) - if unw_ds is None or cor_ds is None: + phase_ds = gdal.Open(str(phase_path)) + mask_ds = gdal.Open(str(mask_path)) if mask_path is not None else None + if unw_ds is None or cor_ds is None or phase_ds is None: raise RuntimeError("Failed to open ISCE2 geo products with GDAL.") amp = unw_ds.GetRasterBand(1).ReadAsArray().astype(np.float32) - phase = unw_ds.GetRasterBand(2).ReadAsArray().astype(np.float32) + if bool(phase_source["ionosphere_corrected"]): + phase = phase_ds.GetRasterBand(1).ReadAsArray().astype(np.float32) + else: + phase = unw_ds.GetRasterBand(2).ReadAsArray().astype(np.float32) coh_band = 2 if cor_ds.RasterCount >= 2 else 1 coh = cor_ds.GetRasterBand(coh_band).ReadAsArray().astype(np.float32) coh_valid = np.isfinite(coh) & (coh > 0) amp_valid = np.isfinite(amp) & (amp != 0) + ionosphere_mask_valid = None + if mask_ds is not None: + ionosphere_mask = mask_ds.GetRasterBand(1).ReadAsArray().astype(np.float32) + ionosphere_mask_valid = np.isfinite(ionosphere_mask) & (ionosphere_mask > 0) disp_m_raw = phase * wavelength / (4.0 * np.pi) - reference_offset_m, reference_stats = compute_reference_offset( + reference_offset_m, reference_mask, reference_stats = compute_reference_offset( disp_m_raw=disp_m_raw, amp=amp, coh=coh, @@ -189,10 +356,25 @@ def export_products( reference_mode=reference_mode, reference_coh_threshold=reference_coh_threshold, ) - disp_m = disp_m_raw - reference_offset_m + disp_m_ref = disp_m_raw - reference_offset_m + deramp_surface_m, deramp_mask, deramp_stats = compute_deramp_surface( + disp_m=disp_m_ref, + amp=amp, + coh=coh, + coh_threshold=coh_threshold, + deramp_mode=deramp_mode, + deramp_coh_threshold=deramp_coh_threshold, + ) + disp_m = disp_m_ref - deramp_surface_m disp_m_full = disp_m.copy() mask = (~amp_valid) | (~np.isfinite(disp_m)) | (~np.isfinite(coh)) | (coh < coh_threshold) + if ionosphere_mask_valid is not None: + mask |= ~ionosphere_mask_valid + disp_m_raw_masked = disp_m_raw.copy() + disp_m_raw_masked[mask] = nodata + disp_m_ref_masked = disp_m_ref.copy() + disp_m_ref_masked[mask] = nodata disp_m_masked = disp_m.copy() disp_m_masked[mask] = nodata disp_m_full[(~amp_valid) | (~np.isfinite(disp_m_full))] = nodata @@ -202,8 +384,13 @@ def export_products( output_dir.mkdir(parents=True, exist_ok=True) out_disp = output_dir / f"{prefix}_disp.tif" + out_disp_raw = output_dir / f"{prefix}_disp_raw.tif" + out_disp_ref = output_dir / f"{prefix}_disp_ref.tif" out_coh = output_dir / f"{prefix}_coh.tif" + out_meta = output_dir / f"{prefix}_disp_meta.json" + write_geotiff(disp_m_raw_masked, unw_ds, out_disp_raw, nodata) + write_geotiff(disp_m_ref_masked, unw_ds, out_disp_ref, nodata) write_geotiff(disp_m_masked, unw_ds, out_disp, nodata) write_geotiff(coh_out, cor_ds, out_coh, nodata) out_disp_full = None @@ -211,14 +398,50 @@ def export_products( out_disp_full = output_dir / f"{prefix}_disp_full.tif" write_geotiff(disp_m_full, unw_ds, out_disp_full, nodata) + valid_raw_masked = disp_m_raw_masked[disp_m_raw_masked != nodata] + valid_ref_masked = disp_m_ref_masked[disp_m_ref_masked != nodata] valid_disp = disp_m_masked[disp_m_masked != nodata] valid_coh = coh_out[coh_out != nodata] valid_full = disp_m_full[disp_m_full != nodata] if include_disp_full else np.array([], dtype=np.float32) valid_raw = disp_m_raw[amp_valid & np.isfinite(disp_m_raw)] using_reference = str(reference_stats["mode"]) != "none" + using_deramp = bool(deramp_stats["applied"]) + + meta_payload = { + "work_dir": str(work_dir), + "output_dir": str(output_dir), + "prefix": prefix, + "coh_threshold": float(coh_threshold), + "phase_source": { + "kind": str(phase_source["phase_source"]), + "path": str(phase_path), + "ionosphere_corrected": bool(phase_source["ionosphere_corrected"]), + "mask_path": str(mask_path) if mask_path is not None else "", + "mask_applied": bool(ionosphere_mask_valid is not None), + "mask_valid_ratio": float(ionosphere_mask_valid.mean()) if ionosphere_mask_valid is not None else None, + }, + "reference": { + **reference_stats, + "offset_m": float(reference_offset_m), + "support_count": int(reference_mask.sum()), + }, + "deramp": { + **deramp_stats, + "support_count": int(deramp_mask.sum()), + }, + "ranges_m": { + "raw_valid": [float(valid_raw.min()), float(valid_raw.max())] if valid_raw.size else [], + "raw_masked": [float(valid_raw_masked.min()), float(valid_raw_masked.max())] if valid_raw_masked.size else [], + "ref_masked": [float(valid_ref_masked.min()), float(valid_ref_masked.max())] if valid_ref_masked.size else [], + "final_masked": [float(valid_disp.min()), float(valid_disp.max())] if valid_disp.size else [], + "final_full": [float(valid_full.min()), float(valid_full.max())] if valid_full.size else [], + }, + } + out_meta.write_text(json.dumps(meta_payload, indent=2, ensure_ascii=False), encoding="utf-8") print(f"Work dir: {work_dir}") print(f"Output prefix: {prefix}") + print(f"Phase source: {phase_source['phase_source']}") print(f"Coherence threshold: {coh_threshold}") print(f"Reference mode: {reference_stats['mode']}") if using_reference: @@ -233,29 +456,61 @@ def export_products( print(f"Reference offset: {reference_offset_m:.4f} m") if reference_stats["fallback"]: print(f"Reference fallback: {reference_stats['fallback']}") + print(f"Deramp mode: {deramp_stats['mode']}") + if using_deramp: + print( + "Deramp coh floor: " + f"{float(deramp_stats['selection_threshold']):.2f}" + ) + print( + "Deramp pixel ratio: " + f"{float(deramp_stats['fit_ratio'])*100:.2f}%" + ) + print( + "Deramp plane delta: " + f"dx={float(deramp_stats['left_right_delta_m']):.4f} m, " + f"dy={float(deramp_stats['top_bottom_delta_m']):.4f} m" + ) + if deramp_stats["fallback"]: + print(f"Deramp fallback: {deramp_stats['fallback']}") + elif deramp_stats["fallback"]: + print(f"Deramp fallback: {deramp_stats['fallback']}") print(f"Unwrap support ratio: {amp_valid.mean()*100:.2f}%") print(f"Coherence support ratio: {coh_valid.mean()*100:.2f}%") print(f"Masked disp ratio: {(disp_m_masked != nodata).mean()*100:.2f}%") if valid_raw.size: print(f"Raw disp range: [{valid_raw.min():.4f}, {valid_raw.max():.4f}] m") + if valid_raw_masked.size: + print(f"Raw masked range: [{valid_raw_masked.min():.4f}, {valid_raw_masked.max():.4f}] m") + if valid_ref_masked.size: + label = "Ref disp range" if using_reference else "Ref disp range" + print(f"{label + ':':24}[{valid_ref_masked.min():.4f}, {valid_ref_masked.max():.4f}] m") if valid_disp.size: - label = "Norm disp range" if using_reference else "Disp range" + label = "Final disp range" if using_reference or using_deramp else "Disp range" print(f"{label + ':':24}[{valid_disp.min():.4f}, {valid_disp.max():.4f}] m") if include_disp_full and valid_full.size: - label = "Norm full disp range" if using_reference else "Full disp range" + label = "Final full disp range" if using_reference or using_deramp else "Full disp range" print(f"{label + ':':24}[{valid_full.min():.4f}, {valid_full.max():.4f}] m") if valid_coh.size: print(f"Coherence range: [{valid_coh.min():.4f}, {valid_coh.max():.4f}]") + print(f"Wrote: {out_disp_raw}") + print(f"Wrote: {out_disp_ref}") print(f"Wrote: {out_disp}") if out_disp_full is not None: print(f"Wrote: {out_disp_full}") print(f"Wrote: {out_coh}") + print(f"Wrote: {out_meta}") unw_ds = None cor_ds = None + phase_ds = None + mask_ds = None outputs: dict[str, Path] = { + "disp_raw": out_disp_raw, + "disp_ref": out_disp_ref, "disp": out_disp, "coh": out_coh, + "meta": out_meta, } if out_disp_full is not None: outputs["disp_full"] = out_disp_full @@ -276,6 +531,8 @@ def main() -> int: coh_threshold=args.coh_threshold, reference_mode=args.reference_mode, reference_coh_threshold=args.reference_coh_threshold, + deramp_mode=args.deramp_mode, + deramp_coh_threshold=args.deramp_coh_threshold, include_disp_full=args.include_disp_full, ) return 0 diff --git a/backend/app/isce2_pipeline/lt1_input_resolver.py b/backend/app/isce2_pipeline/lt1_input_resolver.py index 39b9df5..33f047e 100644 --- a/backend/app/isce2_pipeline/lt1_input_resolver.py +++ b/backend/app/isce2_pipeline/lt1_input_resolver.py @@ -2,9 +2,11 @@ from __future__ import annotations import sys +import re +import xml.etree.ElementTree as ET from dataclasses import dataclass from pathlib import Path -from typing import Callable, Iterable, Mapping, Optional +from typing import Any, Callable, Iterable, Mapping, Optional try: from .convert_lt1_orbit_to_isce_xml import ( @@ -37,6 +39,7 @@ DEFAULT_WSL_DEM_CANDIDATES = ( "/mnt/d/SRTM30m/SRTMDEM_RSP_SARscape", ) DEFAULT_WINDOWS_ORBIT_POOL_CANDIDATES = (r"D:\orbit_pools\isce2",) +DEM_SIDECAR_PROPERTY_NAMES = ("file_name", "metadata_location", "extra_file_name") @dataclass(frozen=True) @@ -126,6 +129,24 @@ def resolve_prepared_dem_path( return resolve_existing_prepared_file(candidates, path_transform=path_transform) +def repair_related_dem_sidecars( + dem_path: Path, + *, + write_changes: bool = True, +) -> list[dict[str, Any]]: + reports: list[dict[str, Any]] = [] + seen: set[str] = set() + for candidate in _related_dem_sidecar_candidates(dem_path): + key = str(candidate) + if key in seen: + continue + seen.add(key) + report = repair_dem_sidecar_paths(candidate, write_changes=write_changes) + if report.get("exists"): + reports.append(report) + return reports + + def resolve_existing_directory( candidates: Iterable[str | Path], path_transform: PathTransform = identity_path_transform, @@ -162,6 +183,63 @@ def resolve_existing_prepared_file( return None +def repair_dem_sidecar_paths( + dem_path: Path, + *, + write_changes: bool = True, +) -> dict[str, Any]: + normalized_path = Path(str(dem_path)) + xml_path = Path(str(normalized_path) + ".xml") + vrt_path = Path(str(normalized_path) + ".vrt") + report: dict[str, Any] = { + "dem_path": str(normalized_path), + "xml_path": str(xml_path), + "vrt_path": str(vrt_path), + "exists": xml_path.exists(), + "changed": False, + "updated_fields": [], + "expected": {}, + "current": {}, + } + if not xml_path.exists(): + return report + + expected_values = { + "file_name": _to_isce_sidecar_path(normalized_path), + "metadata_location": _to_isce_sidecar_path(xml_path), + "extra_file_name": _to_isce_sidecar_path(vrt_path) if vrt_path.exists() else "", + } + + tree = ET.parse(xml_path) + root = tree.getroot() + updates: list[str] = [] + for prop in root.findall("property"): + name = str(prop.get("name") or "").strip() + if name not in DEM_SIDECAR_PROPERTY_NAMES: + continue + value_node = prop.find("value") + if value_node is None: + value_node = ET.SubElement(prop, "value") + current_value = str(value_node.text or "").strip() + expected_value = expected_values.get(name, "") + report["current"][name] = current_value + report["expected"][name] = expected_value + if not expected_value: + continue + if current_value == expected_value: + continue + value_node.text = expected_value + updates.append(name) + + if updates and write_changes: + ET.indent(tree, space=" ") + tree.write(xml_path, encoding="utf-8") + + report["changed"] = bool(updates) + report["updated_fields"] = updates + return report + + def ensure_lt1_orbit_xml( date_yyyymmdd: str, satellite: str, @@ -251,3 +329,24 @@ def _prepared_dem_variants(value: str | Path) -> tuple[str | Path, ...]: if text.lower().endswith(".wgs84"): return (value,) return (f"{text}.wgs84", value) + + +def _related_dem_sidecar_candidates(dem_path: Path) -> tuple[Path, ...]: + text = str(dem_path).strip() + if not text: + return () + if text.lower().endswith(".wgs84"): + raw_path = Path(text[:-6]) + return (dem_path, raw_path) + prepared_path = Path(text + ".wgs84") + return (dem_path, prepared_path) + + +def _to_isce_sidecar_path(path: Path) -> str: + text = str(path).strip() + match = re.match(r"^([A-Za-z]):[\\/](.*)$", text) + if match: + drive = match.group(1).lower() + rest = match.group(2).replace("\\", "/") + return f"/mnt/{drive}/{rest}" + return Path(text).as_posix() diff --git a/backend/app/isce2_pipeline/repair_dem_sidecars.py b/backend/app/isce2_pipeline/repair_dem_sidecars.py new file mode 100644 index 0000000..24085ea --- /dev/null +++ b/backend/app/isce2_pipeline/repair_dem_sidecars.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +try: + from .lt1_input_resolver import repair_dem_sidecar_paths +except ImportError: + SCRIPT_DIR = Path(__file__).resolve().parent + if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + from lt1_input_resolver import repair_dem_sidecar_paths # type: ignore + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Audit and optionally repair moved ISCE DEM XML sidecars." + ) + parser.add_argument( + "--root", + type=Path, + required=True, + help="Directory containing DEM files and sidecars", + ) + parser.add_argument( + "--repair", + action="store_true", + help="Write repaired file_name / metadata_location / extra_file_name values back to XML", + ) + return parser.parse_args() + + +def iter_dem_sidecars(root: Path) -> list[Path]: + sidecars: list[Path] = [] + for xml_path in sorted(root.rglob("*.xml")): + if xml_path.name.lower().endswith(".aux.xml"): + continue + dem_path = Path(str(xml_path)[:-4]) + if dem_path.exists(): + sidecars.append(dem_path) + return sidecars + + +def main() -> int: + args = parse_args() + root = args.root.resolve() + if not root.exists() or not root.is_dir(): + raise FileNotFoundError(f"DEM root directory not found: {root}") + + sidecars = iter_dem_sidecars(root) + changed_count = 0 + mismatch_count = 0 + + print(f"DEM root: {root}") + print(f"Sidecars: {len(sidecars)}") + for dem_path in sidecars: + report = repair_dem_sidecar_paths(dem_path, write_changes=bool(args.repair)) + updated_fields = list(report.get("updated_fields") or []) + if updated_fields: + changed_count += 1 + mismatch_count += 1 + print( + f"[fixed] {report['xml_path']} -> {', '.join(updated_fields)}" + if args.repair + else f"[mismatch] {report['xml_path']} -> {', '.join(updated_fields)}" + ) + continue + + current = report.get("current") or {} + expected = report.get("expected") or {} + mismatched = [ + key + for key, expected_value in expected.items() + if expected_value and str(current.get(key) or "").strip() != str(expected_value).strip() + ] + if mismatched: + mismatch_count += 1 + print(f"[mismatch] {report['xml_path']} -> {', '.join(mismatched)}") + else: + print(f"[ok] {report['xml_path']}") + + print(f"Mismatched: {mismatch_count}") + if args.repair: + print(f"Repaired: {changed_count}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/app/isce2_pipeline/run_lt1_dinsar_pipeline.py b/backend/app/isce2_pipeline/run_lt1_dinsar_pipeline.py index 2217428..ae87d67 100644 --- a/backend/app/isce2_pipeline/run_lt1_dinsar_pipeline.py +++ b/backend/app/isce2_pipeline/run_lt1_dinsar_pipeline.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse import ast +import importlib.util import os import re import shutil @@ -14,15 +15,19 @@ from dataclasses import dataclass from pathlib import Path from export_isce_geotiff import ( + DEFAULT_DERAMP_COH_THRESHOLD, + DEFAULT_DERAMP_MODE, DEFAULT_REFERENCE_COH_THRESHOLD, DEFAULT_REFERENCE_MODE, DEFAULT_WAVELENGTH, + DERAMP_MODE_CHOICES, REFERENCE_MODE_CHOICES, export_products, ) from lt1_input_resolver import ( DEFAULT_WSL_DEM_CANDIDATES, ensure_lt1_orbit_xml, + repair_related_dem_sidecars, resolve_prepared_dem_path, ) @@ -35,7 +40,22 @@ RESUME_STAGE_CHOICES = PIPELINE_STAGE_ORDER[1:] DEFAULT_EXPORT_GEOCODE_PRODUCTS = [ "interferogram/filt_topophase.unw", "interferogram/topophase.cor", + "ionosphere/dispersive.bil.unwCor.filt", + "ionosphere/nondispersive.bil.unwCor.filt", + "ionosphere/mask.bil", ] +DEFAULT_EXPORT_GEOCODE_PRODUCTS_NO_IONO = [ + "interferogram/filt_topophase.unw", + "interferogram/topophase.cor", +] +DEFAULT_RUBBER_SHEET_SNR_THRESHOLD = 5.0 +DEFAULT_RUBBER_SHEET_FILTER_SIZE = 9 +DEFAULT_DENSE_WINDOW_WIDTH = 64 +DEFAULT_DENSE_WINDOW_HEIGHT = 64 +DEFAULT_DENSE_SEARCH_WIDTH = 20 +DEFAULT_DENSE_SEARCH_HEIGHT = 20 +DEFAULT_DENSE_SKIP_WIDTH = 32 +DEFAULT_DENSE_SKIP_HEIGHT = 32 @dataclass @@ -59,6 +79,18 @@ class PipelineConfig: target_grid_size_m: int geo_posting_deg: float geocode_products: list[str] | None + ionosphere_correction: bool + dense_offsets: bool + rubbersheet_range: bool + rubbersheet_azimuth: bool + rubber_sheet_snr_threshold: float + rubber_sheet_filter_size: int + dense_window_width: int + dense_window_height: int + dense_search_width: int + dense_search_height: int + dense_skip_width: int + dense_skip_height: int def parse_args() -> argparse.Namespace: @@ -66,7 +98,7 @@ def parse_args() -> argparse.Namespace: repo_root = script_dir.parent parser = argparse.ArgumentParser( - description="Run an LT-1 ISCE2 DInSAR production pipeline with SNAPHU." + description="Run an LT-1 ISCE2 DInSAR production pipeline with the standard stripmap workflow." ) parser.add_argument("task_dir", help="Task directory, for example Task_20250112_20250309") parser.add_argument( @@ -156,7 +188,7 @@ def parse_args() -> argparse.Namespace: "--reference-mode", choices=REFERENCE_MODE_CHOICES, default=DEFAULT_REFERENCE_MODE, - help="Optional reference normalization mode used only for debug exports", + help="Reference normalization mode applied during final displacement export", ) parser.add_argument( "--reference-coh-threshold", @@ -164,6 +196,18 @@ def parse_args() -> argparse.Namespace: default=DEFAULT_REFERENCE_COH_THRESHOLD, help="Minimum coherence used when selecting reference pixels for export normalization", ) + parser.add_argument( + "--deramp-mode", + choices=DERAMP_MODE_CHOICES, + default=DEFAULT_DERAMP_MODE, + help="Optional ramp-removal mode applied after reference normalization", + ) + parser.add_argument( + "--deramp-coh-threshold", + type=float, + default=DEFAULT_DERAMP_COH_THRESHOLD, + help="Minimum coherence used when selecting pixels for deramp fitting", + ) parser.add_argument( "--target-grid-size-m", type=int, @@ -180,6 +224,76 @@ def parse_args() -> argparse.Namespace: action="store_true", help="Let ISCE2 geocode its full default product list instead of the reduced export-only list.", ) + parser.add_argument( + "--no-ionosphere-correction", + action="store_false", + dest="ionosphere_correction", + help="Disable split-spectrum dispersive correction and export the standard unwrapped interferogram.", + ) + parser.set_defaults(ionosphere_correction=True) + parser.add_argument( + "--dense-offsets", + action="store_true", + help="Enable ISCE2 dense offset estimation before fine resampling.", + ) + parser.add_argument( + "--rubbersheet-range", + action="store_true", + help="Enable ISCE2 range rubbersheeting using dense offsets.", + ) + parser.add_argument( + "--rubbersheet-azimuth", + action="store_true", + help="Enable ISCE2 azimuth rubbersheeting using dense offsets.", + ) + parser.add_argument( + "--rubber-sheet-snr-threshold", + type=float, + default=DEFAULT_RUBBER_SHEET_SNR_THRESHOLD, + help="SNR threshold used by ISCE2 rubbersheet offset masking.", + ) + parser.add_argument( + "--rubber-sheet-filter-size", + type=int, + default=DEFAULT_RUBBER_SHEET_FILTER_SIZE, + help="Median filter size used by ISCE2 rubbersheet offset masking.", + ) + parser.add_argument( + "--dense-window-width", + type=int, + default=DEFAULT_DENSE_WINDOW_WIDTH, + help="Dense offset correlation window width.", + ) + parser.add_argument( + "--dense-window-height", + type=int, + default=DEFAULT_DENSE_WINDOW_HEIGHT, + help="Dense offset correlation window height.", + ) + parser.add_argument( + "--dense-search-width", + type=int, + default=DEFAULT_DENSE_SEARCH_WIDTH, + help="Dense offset search window width.", + ) + parser.add_argument( + "--dense-search-height", + type=int, + default=DEFAULT_DENSE_SEARCH_HEIGHT, + help="Dense offset search window height.", + ) + parser.add_argument( + "--dense-skip-width", + type=int, + default=DEFAULT_DENSE_SKIP_WIDTH, + help="Dense offset sampling stride in range direction.", + ) + parser.add_argument( + "--dense-skip-height", + type=int, + default=DEFAULT_DENSE_SKIP_HEIGHT, + help="Dense offset sampling stride in azimuth direction.", + ) parser.add_argument( "--resume-from", choices=RESUME_STAGE_CHOICES, @@ -219,11 +333,75 @@ def parse_args() -> argparse.Namespace: raise ValueError("--target-grid-size-m must be greater than 0") if args.reference_coh_threshold < 0 or args.reference_coh_threshold > 1: raise ValueError("--reference-coh-threshold must be between 0 and 1") + if args.deramp_coh_threshold < 0 or args.deramp_coh_threshold > 1: + raise ValueError("--deramp-coh-threshold must be between 0 and 1") + if args.rubber_sheet_snr_threshold < 0: + raise ValueError("--rubber-sheet-snr-threshold must be non-negative") + if args.rubber_sheet_filter_size <= 0: + raise ValueError("--rubber-sheet-filter-size must be greater than 0") + for field_name in ( + "dense_window_width", + "dense_window_height", + "dense_search_width", + "dense_search_height", + "dense_skip_width", + "dense_skip_height", + ): + if int(getattr(args, field_name)) <= 0: + raise ValueError(f"--{field_name.replace('_', '-')} must be greater than 0") if args.force and args.resume_from: raise ValueError("--force cannot be used together with --resume-from") return args +def _find_python_module(module_name: str) -> bool: + try: + return importlib.util.find_spec(module_name) is not None + except ModuleNotFoundError: + return False + + +def validate_runtime_dependencies(args: argparse.Namespace) -> str: + errors: list[str] = [] + env_for_cli = build_process_env() + ionosphere_correction = bool(getattr(args, "ionosphere_correction", True)) + + if ionosphere_correction: + missing_ionosphere_modules: list[str] = [] + if not _find_python_module("cv2"): + missing_ionosphere_modules.append("cv2") + if not _find_python_module("scipy"): + missing_ionosphere_modules.append("scipy") + if missing_ionosphere_modules: + errors.append( + "Missing Python dependencies for the ISCE2 stripmap ionosphere step: " + + ", ".join(missing_ionosphere_modules) + + ". The managed LT-1 workflow enables split-spectrum dispersive correction " + "before geocode. Install the missing packages in the WSL runtime, for example: " + "conda install -n insar_wsl_v1 -c conda-forge opencv scipy." + ) + + if (args.rubbersheet_range or args.rubbersheet_azimuth) and not _find_python_module( + "astropy.convolution" + ): + errors.append( + "Missing Python dependency 'astropy.convolution'. " + "ISCE2 stripmap rubbersheeting imports astropy.convolution in " + "runRubbersheetRange.py. Install astropy in the WSL runtime, for example: " + "conda install -n insar_wsl_v1 -c conda-forge astropy." + ) + + if ionosphere_correction and not shutil.which("imageMath.py", path=str(env_for_cli.get("PATH") or "")): + errors.append( + "Missing CLI dependency 'imageMath.py' on PATH. " + "ISCE2 stripmap shells out to imageMath.py in the ionosphere step, so a missing PATH entry " + "will only surface late in the run. Export the active conda env bin directory into PATH " + "before launching production." + ) + + return "\n".join(errors) + + def locate_stripmap_app() -> Path: import isce @@ -233,6 +411,29 @@ def locate_stripmap_app() -> Path: return app_path +def locate_isce_applications_dir() -> Path | None: + spec = importlib.util.find_spec("isce") + if not spec or not spec.origin: + return None + + app_dir = Path(spec.origin).resolve().parent / "applications" + if app_dir.exists(): + return app_dir + return None + + +def build_process_env(base_env: dict[str, str] | None = None) -> dict[str, str]: + env = dict(base_env or os.environ.copy()) + path_prefixes = [Path(sys.executable).resolve().parent.as_posix()] + app_dir = locate_isce_applications_dir() + if app_dir: + path_prefixes.append(app_dir.as_posix()) + + current_path = str(env.get("PATH") or "") + env["PATH"] = ":".join(path_prefixes + ([current_path] if current_path else [])) + return env + + def normalize_linux_path(value: str | Path) -> Path: text = str(value).strip() if text.startswith("\\\\"): @@ -374,6 +575,14 @@ def resolve_dem(dem_value: str | None) -> Path: path_transform=normalize_linux_path, ) if dem_path is not None: + repair_reports = repair_related_dem_sidecars(dem_path) + for report in repair_reports: + if not report.get("changed"): + continue + print( + "Repaired DEM sidecar paths: " + f"{report['xml_path']} -> {', '.join(report['updated_fields'])}" + ) return dem_path searched = ", ".join(str(path) for path in DEFAULT_WSL_DEM_CANDIDATES) @@ -502,9 +711,30 @@ def meters_to_geoposting_degrees(target_grid_size_m: int) -> float: return float(target_grid_size_m) / METERS_PER_DEGREE +def build_default_geocode_products(*, ionosphere_correction: bool) -> list[str]: + return list( + DEFAULT_EXPORT_GEOCODE_PRODUCTS + if ionosphere_correction + else DEFAULT_EXPORT_GEOCODE_PRODUCTS_NO_IONO + ) + + def write_stripmap_xml(xml_path: Path, config: PipelineConfig) -> None: bbox_xml = render_bbox(config.bbox) geocode_list_xml = render_string_list("geocode list", config.geocode_products) + enhancement_props = ( + f" {str(config.dense_offsets)}\n" + f" {str(config.rubbersheet_range)}\n" + f" {str(config.rubbersheet_azimuth)}\n" + f" {config.rubber_sheet_snr_threshold}\n" + f" {config.rubber_sheet_filter_size}\n" + f" {config.dense_window_width}\n" + f" {config.dense_window_height}\n" + f" {config.dense_search_width}\n" + f" {config.dense_search_height}\n" + f" {config.dense_skip_width}\n" + f" {config.dense_skip_height}\n" + ) text = ( "\n" " \n" @@ -514,10 +744,13 @@ def write_stripmap_xml(xml_path: Path, config: PipelineConfig) -> None: " xml\n" " True\n" " snaphu\n" + f" {str(config.ionosphere_correction)}\n" + f" {str(config.ionosphere_correction)}\n" f" {config.target_grid_size_m}\n" f" {config.geo_posting_deg:.12f}\n" f"{bbox_xml}" f"{geocode_list_xml}" + f"{enhancement_props}" f" {config.dem_path.as_posix()}\n" "\n" " \n" @@ -553,7 +786,7 @@ def run_logged(stage_name: str, cmd: list[str], cwd: Path, log_path: Path) -> No handle.write(f"Log: {log_path}\n") handle.flush() - child_env = os.environ.copy() + child_env = build_process_env() child_env["PYTHONUNBUFFERED"] = "1" proc = subprocess.Popen( cmd, @@ -730,59 +963,57 @@ def should_run_stage(start_stage: str, stage_name: str) -> bool: return stage_index >= start_index -def prepare_snaphu_resume(work_dir: Path, bbox: list[float] | None) -> None: +def has_pickle_state(work_dir: Path, state_name: str) -> bool: pickle_dir = work_dir / "PICKLE" - src = pickle_dir / "filter" - src_xml = pickle_dir / "filter.xml" - dst = pickle_dir / "filter_high_band" - dst_xml = pickle_dir / "filter_high_band.xml" - - if not src.exists() or not src_xml.exists(): - raise FileNotFoundError("filter step output is missing; cannot prepare SNAPHU resume state.") - - shutil.copy2(src, dst) - shutil.copy2(src_xml, dst_xml) - - root = ET.fromstring(dst_xml.read_text(encoding="utf-8")) - props = {prop.attrib.get("name"): prop for prop in root.findall("property")} - - required = { - "referenceslccroppedproduct": "reference_slc.xml", - "secondaryslccroppedproduct": "secondary_slc.xml", - "referenceslcproduct": "reference_slc.xml", - "secondaryslcproduct": "secondary_slc.xml", - "referencegeometrysystem": "Zero Doppler", - "secondarygeometrysystem": "Zero Doppler", - } - if bbox is not None: - required["estimatedboundingbox"] = str(bbox) - - for name, value in required.items(): - if name in props: - node = props[name].find("value") - if node is None: - node = ET.SubElement(props[name], "value") - node.text = value - continue - - prop = ET.SubElement(root, "property", {"name": name}) - ET.SubElement(prop, "value").text = value - - dst_xml.write_text(ET.tostring(root, encoding="unicode"), encoding="utf-8") + return (pickle_dir / state_name).exists() and (pickle_dir / f"{state_name}.xml").exists() -def prepare_geocode_resume(work_dir: Path) -> None: - pickle_dir = work_dir / "PICKLE" - unwrap = pickle_dir / "unwrap" - unwrap_xml = pickle_dir / "unwrap.xml" - ionosphere = pickle_dir / "ionosphere" - ionosphere_xml = pickle_dir / "ionosphere.xml" +def resolve_unwrap_start_step(work_dir: Path, *, ionosphere_correction: bool) -> str: + if ionosphere_correction: + if has_pickle_state(work_dir, "ionosphere"): + return "ionosphere" + if has_pickle_state(work_dir, "unwrap_low_band") and has_pickle_state( + work_dir, "unwrap_high_band" + ): + return "ionosphere" + if has_pickle_state(work_dir, "filter_low_band") and has_pickle_state( + work_dir, "filter_high_band" + ): + return "unwrap" + if has_pickle_state(work_dir, "filter"): + return "filter_low_band" + raise FileNotFoundError( + "Unable to resume the ISCE2 unwrap/ionosphere stage. Missing PICKLE state for " + "filter, filter_low_band/filter_high_band, unwrap_low_band/unwrap_high_band, or ionosphere." + ) - if not unwrap.exists() or not unwrap_xml.exists(): - raise FileNotFoundError("unwrap step output is missing; cannot prepare geocode resume state.") + if has_pickle_state(work_dir, "unwrap"): + return "unwrap" + if has_pickle_state(work_dir, "filter"): + return "unwrap" + raise FileNotFoundError( + "Unable to resume the ISCE2 unwrap stage. Missing PICKLE state for filter or unwrap." + ) - shutil.copy2(unwrap, ionosphere) - shutil.copy2(unwrap_xml, ionosphere_xml) + +def resolve_geocode_start_step(work_dir: Path, *, ionosphere_correction: bool) -> str: + if ionosphere_correction: + if has_pickle_state(work_dir, "ionosphere"): + return "geocode" + if has_pickle_state(work_dir, "unwrap_low_band") and has_pickle_state( + work_dir, "unwrap_high_band" + ): + return "ionosphere" + raise FileNotFoundError( + "Unable to resume the ISCE2 geocode stage. Missing PICKLE state for ionosphere or " + "unwrap_low_band/unwrap_high_band." + ) + + if has_pickle_state(work_dir, "unwrap"): + return "geocode" + raise FileNotFoundError( + "Unable to resume the ISCE2 geocode stage. Missing PICKLE state for unwrap." + ) def print_summary( @@ -804,6 +1035,25 @@ def print_summary( print(f"BBox: {config.bbox if config.bbox is not None else 'auto'}") print(f"Target grid: {config.target_grid_size_m} m") print(f"Geo posting: {config.geo_posting_deg:.12f} deg") + print( + "Enhancement: " + f"split_spectrum={config.ionosphere_correction}, " + f"ionosphere={config.ionosphere_correction}, " + f"dense_offsets={config.dense_offsets}, " + f"rubbersheet_range={config.rubbersheet_range}, " + f"rubbersheet_azimuth={config.rubbersheet_azimuth}" + ) + print( + "Dense params: " + f"window={config.dense_window_width}x{config.dense_window_height}, " + f"search={config.dense_search_width}x{config.dense_search_height}, " + f"skip={config.dense_skip_width}x{config.dense_skip_height}" + ) + print( + "Rubber mask: " + f"snr_threshold={config.rubber_sheet_snr_threshold}, " + f"filter_size={config.rubber_sheet_filter_size}" + ) print( "Geocode list: " + ( @@ -816,6 +1066,11 @@ def print_summary( def main() -> int: args = parse_args() + if not args.dry_run: + dependency_error = validate_runtime_dependencies(args) + if dependency_error: + print(dependency_error, file=sys.stderr) + return 2 resume_from = str(args.resume_from or "").strip().lower() start_stage = resume_from or PIPELINE_STAGE_ORDER[0] task_dir = normalize_linux_path(args.task_dir).resolve() @@ -873,12 +1128,30 @@ def main() -> int: bbox=bbox, target_grid_size_m=args.target_grid_size_m, geo_posting_deg=geo_posting_deg, - geocode_products=None if args.full_geocode else list(DEFAULT_EXPORT_GEOCODE_PRODUCTS), + geocode_products=( + None + if args.full_geocode + else build_default_geocode_products( + ionosphere_correction=bool(args.ionosphere_correction) + ) + ), + ionosphere_correction=bool(args.ionosphere_correction), + dense_offsets=bool(args.dense_offsets), + rubbersheet_range=bool(args.rubbersheet_range), + rubbersheet_azimuth=bool(args.rubbersheet_azimuth), + rubber_sheet_snr_threshold=float(args.rubber_sheet_snr_threshold), + rubber_sheet_filter_size=int(args.rubber_sheet_filter_size), + dense_window_width=int(args.dense_window_width), + dense_window_height=int(args.dense_window_height), + dense_search_width=int(args.dense_search_width), + dense_search_height=int(args.dense_search_height), + dense_skip_width=int(args.dense_skip_width), + dense_skip_height=int(args.dense_skip_height), ) if start_stage == PIPELINE_STAGE_ORDER[0]: guard_large_unprepared_base_dem(config.dem_path) - if resume_from in {"unwrap", "geocode", "export"}: + if resume_from in {"unwrap", "geocode"}: ensure_geocode_bbox(work_dir, config, args.bbox_margin) if should_run_stage(start_stage, "geocode"): prepare_geocode_dem(work_dir, config) @@ -908,20 +1181,42 @@ def main() -> int: write_stripmap_xml(xml_path, config) if should_run_stage(start_stage, "unwrap"): - prepare_snaphu_resume(work_dir, config.bbox) + unwrap_start_step = resolve_unwrap_start_step( + work_dir, + ionosphere_correction=config.ionosphere_correction, + ) + unwrap_end_step = "ionosphere" if config.ionosphere_correction else "unwrap" + unwrap_stage_name = "02_to_ionosphere" if config.ionosphere_correction else "02_to_unwrap" run_logged( - "02_unwrap_snaphu", - [sys.executable, app_py.as_posix(), xml_path.as_posix(), "--steps", "--start=unwrap", "--end=unwrap"], + unwrap_stage_name, + [ + sys.executable, + app_py.as_posix(), + xml_path.as_posix(), + "--steps", + f"--start={unwrap_start_step}", + f"--end={unwrap_end_step}", + ], cwd=work_dir, - log_path=work_dir / "02_unwrap_snaphu.log", + log_path=work_dir / f"{unwrap_stage_name}.log", ) if should_run_stage(start_stage, "geocode"): - prepare_geocode_resume(work_dir) + geocode_start_step = resolve_geocode_start_step( + work_dir, + ionosphere_correction=config.ionosphere_correction, + ) cleanup_geocode_outputs(work_dir, config.geocode_products) run_logged( "03_geocode", - [sys.executable, app_py.as_posix(), xml_path.as_posix(), "--steps", "--start=geocode", "--end=geocode"], + [ + sys.executable, + app_py.as_posix(), + xml_path.as_posix(), + "--steps", + f"--start={geocode_start_step}", + "--end=geocode", + ], cwd=work_dir, log_path=work_dir / "03_geocode.log", ) @@ -936,6 +1231,8 @@ def main() -> int: coh_threshold=args.coh_threshold, reference_mode=args.reference_mode, reference_coh_threshold=args.reference_coh_threshold, + deramp_mode=args.deramp_mode, + deramp_coh_threshold=args.deramp_coh_threshold, include_disp_full=args.include_disp_full, ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 49c0f10..622964e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -15,6 +15,8 @@ from .orm import ( PairingMetricCacheORM, PairingNetworkRunORM, PairingNetworkEdgeORM, + TimeseriesStackPlanORM, + TimeseriesStackPlanItemORM, HazardPointORM, SystemTaskORM, TaskLogORM, @@ -59,6 +61,9 @@ from .schemas import ( RadarPair, PairingResponse, PsRequest, + TimeseriesStackPlan, + TimeseriesStackPlanItem, + TimeseriesStackPlanDetail, TaskInfo, AuthUserInfo, AuthAuditLogInfo, @@ -84,6 +89,7 @@ __all__ = [ "ResultIssueORM", "ResultCatalogStateORM", "PairingCacheStateORM", "PairingDirtySceneORM", "PairingMetricCacheORM", "PairingNetworkRunORM", "PairingNetworkEdgeORM", + "TimeseriesStackPlanORM", "TimeseriesStackPlanItemORM", "SystemTaskORM", "TaskLogORM", "SystemJobORM", "ScanStateORM", "ManagedRootORM", "ScanCursorORM", "PathInventoryORM", "WorkflowDefORM", "WorkflowRunORM", "WorkflowStepORM", "WorkflowArtifactORM", @@ -99,7 +105,7 @@ __all__ = [ "HazardPoint", "DinsarResult", "ScanRequest", "ManagedRootInfo", "ScanCursorInfo", "RadarData", "RadarDataPage", "DinsarResultPage", "PairingRequest", "RadarPair", "PairingResponse", - "PsRequest", "TaskInfo", + "PsRequest", "TimeseriesStackPlan", "TimeseriesStackPlanItem", "TimeseriesStackPlanDetail", "TaskInfo", "AuthUserInfo", "AuthAuditLogInfo", "RadarPreviewStatusInfo", "DinsarTaskBatch", "DinsarTaskItem", "PsTaskBatch", "PsTaskItem", "PsTimeseriesRun", "WaterDetectRequest", "WaterDetectResponse", diff --git a/backend/app/models/orm.py b/backend/app/models/orm.py index 83f4e71..477703a 100644 --- a/backend/app/models/orm.py +++ b/backend/app/models/orm.py @@ -442,6 +442,72 @@ class PairingNetworkEdgeORM(Base): ) +class TimeseriesStackPlanORM(Base): + __tablename__ = "timeseries_stack_plans" + + id = Column(Integer, primary_key=True, autoincrement=True) + plan_id = Column(String(64), unique=True, index=True, nullable=False) + strategy = Column(String(32), index=True, nullable=False, default="sbas_stack") + request_hash = Column(String(64), index=True, nullable=True) + request_params_json = Column(JSON, nullable=True) + aoi_source = Column(String(32), nullable=True) + aoi_hash = Column(String(64), index=True, nullable=True) + aoi_summary_json = Column(JSON, nullable=True) + direction = Column(String(32), index=True, nullable=True) + scene_count = Column(Integer, nullable=False, default=0) + stack_key = Column(String(128), index=True, nullable=True) + group_key = Column(String(128), index=True, nullable=True) + status = Column(String(16), index=True, nullable=False, default="READY") + created_by = Column(String(64), nullable=True) + created_at = Column(DateTime, server_default=func.now(), nullable=False) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + items = relationship( + "TimeseriesStackPlanItemORM", + back_populates="plan", + cascade="all, delete-orphan", + ) + + __table_args__ = ( + Index("idx_timeseries_stack_plans_direction_created", "direction", "created_at"), + ) + + +class TimeseriesStackPlanItemORM(Base): + __tablename__ = "timeseries_stack_plan_items" + + id = Column(Integer, primary_key=True, autoincrement=True) + plan_ref_id = Column( + Integer, + ForeignKey("timeseries_stack_plans.id", ondelete="CASCADE"), + index=True, + nullable=False, + ) + radar_data_ref_id = Column( + Integer, + ForeignKey("radar_data.id", ondelete="SET NULL"), + index=True, + nullable=True, + ) + scene_rank = Column(Integer, nullable=False, default=0) + file_path = Column(String, nullable=False) + satellite = Column(String, nullable=True) + imaging_date = Column(String, nullable=True) + imaging_mode = Column(String, nullable=True) + polarization = Column(String, nullable=True) + has_orbit_data = Column(Boolean, nullable=False, default=False) + selection_meta_json = Column(JSON, nullable=True) + created_at = Column(DateTime, server_default=func.now(), nullable=False) + + plan = relationship("TimeseriesStackPlanORM", back_populates="items") + radar_data = relationship("RadarDataORM") + + __table_args__ = ( + UniqueConstraint("plan_ref_id", "scene_rank", name="uq_timeseries_plan_items_plan_rank"), + Index("idx_timeseries_plan_items_plan_date", "plan_ref_id", "imaging_date"), + ) + + class HazardPointORM(Base): __tablename__ = 'hazard_points' @@ -901,6 +967,8 @@ class PsTaskBatchORM(Base): batch_id = Column(String, unique=True, index=True, nullable=False) name = Column(String, nullable=True) direction = Column(String, nullable=True) + plan_id = Column(String(64), index=True, nullable=True) + plan_strategy = Column(String(32), nullable=True) status = Column(String, index=True, nullable=False, default="PENDING") total_items = Column(Integer, default=0) completed_items = Column(Integer, default=0) @@ -915,6 +983,7 @@ class PsTaskItemORM(Base): id = Column(Integer, primary_key=True, autoincrement=True) batch_id = Column(String, ForeignKey("ps_task_batches.batch_id"), index=True, nullable=False) + plan_item_ref_id = Column(Integer, index=True, nullable=True) file_path = Column(String, nullable=False) satellite = Column(String, nullable=True) @@ -937,6 +1006,8 @@ class PsTimeseriesRunORM(Base): id = Column(Integer, primary_key=True, autoincrement=True) run_id = Column(String(64), unique=True, index=True, nullable=False) batch_id = Column(String, ForeignKey("ps_task_batches.batch_id"), index=True, nullable=False) + plan_id = Column(String(64), index=True, nullable=True) + plan_strategy = Column(String(32), nullable=True) product_family = Column(String(32), index=True, nullable=True) run_name = Column(String(255), nullable=False) diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index ba9a1f0..96c13b2 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -194,6 +194,15 @@ class RadarData(BaseModel): preview_cache_version: Optional[str] = None preview_cache_updated_at: Optional[datetime] = None preview_cache_error: Optional[str] = None + stack_plan_id: Optional[str] = None + stack_plan_item_id: Optional[int] = None + stack_scene_rank: Optional[int] = None + stack_group_key: Optional[str] = None + stack_key: Optional[str] = None + stack_common_aoi_coverage_ratio: Optional[float] = None + stack_coverage_consistency_ratio: Optional[float] = None + stack_threshold_satisfied: Optional[bool] = None + stack_selection_mode: Optional[str] = None model_config = ConfigDict(from_attributes=True) @@ -342,8 +351,50 @@ class PairingResponse(BaseModel): class PsRequest(BaseModel): """PS-InSAR 时序分析数据准备的请求模型。""" - initial_overlap_threshold: float = 0.3 - final_overlap_threshold: float = 0.95 + initial_overlap_threshold: float = Field(default=0.3, ge=0.0, le=1.0) + final_overlap_threshold: float = Field(default=0.95, ge=0.0, le=1.0) + + +class TimeseriesStackPlanItem(BaseModel): + id: int + plan_ref_id: int + radar_data_ref_id: Optional[int] = None + scene_rank: int + file_path: str + satellite: Optional[str] = None + imaging_date: Optional[str] = None + imaging_mode: Optional[str] = None + polarization: Optional[str] = None + has_orbit_data: bool + selection_meta_json: Optional[Dict[str, Any]] = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class TimeseriesStackPlan(BaseModel): + id: int + plan_id: str + strategy: str + request_hash: Optional[str] = None + request_params_json: Optional[Dict[str, Any]] = None + aoi_source: Optional[str] = None + aoi_hash: Optional[str] = None + aoi_summary_json: Optional[Dict[str, Any]] = None + direction: Optional[str] = None + scene_count: int + stack_key: Optional[str] = None + group_key: Optional[str] = None + status: str + created_by: Optional[str] = None + created_at: datetime + updated_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + +class TimeseriesStackPlanDetail(TimeseriesStackPlan): + items: List[TimeseriesStackPlanItem] = Field(default_factory=list) class TaskInfo(BaseModel): @@ -446,6 +497,8 @@ class PsTaskBatch(BaseModel): batch_id: str name: Optional[str] = None direction: Optional[str] = None + plan_id: Optional[str] = None + plan_strategy: Optional[str] = None status: str total_items: int completed_items: int @@ -458,6 +511,7 @@ class PsTaskBatch(BaseModel): class PsTaskItem(BaseModel): id: int batch_id: str + plan_item_ref_id: Optional[int] = None file_path: str satellite: Optional[str] = None imaging_date: Optional[str] = None @@ -474,6 +528,8 @@ class PsTaskItem(BaseModel): class PsTimeseriesRun(BaseModel): run_id: str batch_id: str + plan_id: Optional[str] = None + plan_strategy: Optional[str] = None product_family: Optional[str] = None run_name: str catalog_name: str diff --git a/backend/app/routers/dependencies.py b/backend/app/routers/dependencies.py index 8ec21aa..1c85865 100644 --- a/backend/app/routers/dependencies.py +++ b/backend/app/routers/dependencies.py @@ -158,6 +158,7 @@ AOI_UPLOAD_MAX_TOTAL_BYTES = max( AOI_UPLOAD_MAX_SINGLE_FILE_BYTES, ) AOI_UPLOAD_STREAM_CHUNK_BYTES = 1024 * 1024 +_SHAPEFILE_READ_LOCK = asyncio.Lock() # --------------------------------------------------------------------------- # Region index caches @@ -805,6 +806,20 @@ def _parse_aoi_geojson_form_value(aoi_geojson: Optional[str]) -> Optional[Tuple[ return merged_geometry.wkt, feature_collection +def _read_aoi_shapefile_with_restore_shx(shp_path: str): + import geopandas as gpd + + previous_restore_shx = os.environ.get("SHAPE_RESTORE_SHX") + os.environ["SHAPE_RESTORE_SHX"] = "YES" + try: + return gpd.read_file(shp_path, engine="pyogrio") + finally: + if previous_restore_shx is None: + os.environ.pop("SHAPE_RESTORE_SHX", None) + else: + os.environ["SHAPE_RESTORE_SHX"] = previous_restore_shx + + async def _parse_aoi_from_files(files: Optional[List[UploadFile]]) -> Optional[Tuple[str, Dict[str, Any]]]: if not files: return None @@ -869,9 +884,17 @@ async def _parse_aoi_from_files(files: Optional[List[UploadFile]]) -> Optional[T geojson_payload = json.loads(Path(dest_path).read_text(encoding="gbk")) if shp_path: - import geopandas as gpd - - gdf = await asyncio.to_thread(gpd.read_file, shp_path, engine="pyogrio") + try: + async with _SHAPEFILE_READ_LOCK: + gdf = await asyncio.to_thread(_read_aoi_shapefile_with_restore_shx, shp_path) + except Exception as exc: + raise HTTPException( + status_code=400, + detail=( + "AOI Shapefile 读取失败。系统已尝试自动恢复缺失的 .shx 索引;" + f"请确认已上传 .shp/.dbf/.prj/.shx 或可恢复的标准 Shapefile。原始错误: {exc}" + ), + ) from exc if gdf.crs and gdf.crs.to_epsg() != 4326: gdf = gdf.to_crs(epsg=4326) feature_collection = json.loads(gdf.to_json()) diff --git a/backend/app/routers/pairing.py b/backend/app/routers/pairing.py index 5fd6b47..c77be65 100644 --- a/backend/app/routers/pairing.py +++ b/backend/app/routers/pairing.py @@ -16,6 +16,11 @@ from ..models import ( PairingResponse, PsRequest, RadarData, + TimeseriesStackPlan, + TimeseriesStackPlanDetail, + TimeseriesStackPlanItem, + TimeseriesStackPlanItemORM, + TimeseriesStackPlanORM, ) from ..services.pairing_cache_service import pairing_cache_service from ..services.spatial_service import spatial_service @@ -177,6 +182,37 @@ async def get_pairing_network_run_endpoint( } +@router.get("/timeseries-plans/{plan_id}", response_model=TimeseriesStackPlanDetail) +async def get_timeseries_stack_plan_endpoint( + plan_id: str, + db: AsyncSession = Depends(get_db), + current_user: AuthUserORM = Depends(_require_admin), +): + _ = current_user + normalized_plan_id = str(plan_id or "").strip() + if not normalized_plan_id: + raise HTTPException(status_code=400, detail="plan_id is required.") + + plan_result = await db.execute( + select(TimeseriesStackPlanORM).where(TimeseriesStackPlanORM.plan_id == normalized_plan_id) + ) + plan = plan_result.scalar_one_or_none() + if plan is None: + raise HTTPException(status_code=404, detail="Timeseries stack plan not found.") + + items_result = await db.execute( + select(TimeseriesStackPlanItemORM) + .where(TimeseriesStackPlanItemORM.plan_ref_id == plan.id) + .order_by(TimeseriesStackPlanItemORM.scene_rank.asc(), TimeseriesStackPlanItemORM.id.asc()) + ) + payload = TimeseriesStackPlan.model_validate(plan).model_dump() + payload["items"] = [ + TimeseriesStackPlanItem.model_validate(item) + for item in items_result.scalars().all() + ] + return TimeseriesStackPlanDetail.model_validate(payload) + + @router.post("/find-pairs", response_model=PairingResponse) async def find_pairs_endpoint( params: PairingRequest = Depends(get_pairing_request_from_form), diff --git a/backend/app/routers/task_batches.py b/backend/app/routers/task_batches.py index e0420a8..d7a4227 100644 --- a/backend/app/routers/task_batches.py +++ b/backend/app/routers/task_batches.py @@ -1,8 +1,10 @@ from __future__ import annotations +import json +import os import uuid from datetime import datetime -from typing import List, Optional +from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field, field_validator @@ -23,6 +25,8 @@ from ..models import ( PsTaskItemORM, RadarData, RadarPair, + TimeseriesStackPlanItemORM, + TimeseriesStackPlanORM, ) from .dependencies import ( _add_operation_audit_log, @@ -109,7 +113,9 @@ class DinsarBatchCreateRequest(BaseModel): class PsBatchCreateRequest(BaseModel): name: Optional[str] = Field(default=None, max_length=BATCH_TEXT_MAX_LENGTH) direction: Optional[str] = Field(default=None, max_length=BATCH_TEXT_MAX_LENGTH) + plan_id: Optional[str] = Field(default=None, max_length=64) stack: List[RadarData] + planning_context: Optional[Dict[str, Any]] = None @field_validator("stack") @classmethod @@ -118,6 +124,8 @@ class PsBatchCreateRequest(BaseModel): raise ValueError( f"stack exceeds max item count ({TASK_BATCH_MAX_ITEMS})." ) + if len(value) < 3: + raise ValueError("SBAS timeseries batch requires at least 3 scenes.") return value @@ -126,6 +134,57 @@ class BatchItemUpdateRequest(BaseModel): remark: Optional[str] = Field(default=None, max_length=BATCH_REMARK_MAX_LENGTH) +def _normalize_lookup_key(value: Optional[str]) -> str: + text = str(value or "").strip() + if not text: + return "" + return os.path.normcase(os.path.normpath(text)) + + +def _build_plan_context( + plan: TimeseriesStackPlanORM, + plan_items: List[TimeseriesStackPlanItemORM], +) -> Dict[str, Any]: + request_params = plan.request_params_json if isinstance(plan.request_params_json, dict) else {} + ordered_items = sorted( + plan_items, + key=lambda item: (int(item.scene_rank or 0), int(item.id or 0)), + ) + scenes = [ + { + "plan_item_id": item.id, + "scene_id": item.radar_data_ref_id, + "scene_rank": item.scene_rank, + "scene_file_path": item.file_path, + "scene_imaging_date": item.imaging_date, + "scene_satellite": item.satellite, + "scene_imaging_mode": item.imaging_mode, + "scene_polarization": item.polarization, + "selection_meta": item.selection_meta_json if isinstance(item.selection_meta_json, dict) else None, + } + for item in ordered_items + ] + return { + "source": "timeseries_stack_plan", + "plan_id": plan.plan_id, + "strategy": plan.strategy, + "direction": plan.direction, + "scene_count": int(plan.scene_count or len(scenes)), + "stack_key": plan.stack_key, + "group_key": plan.group_key, + "request_hash": plan.request_hash, + "aoi_summary": plan.aoi_summary_json if isinstance(plan.aoi_summary_json, dict) else None, + "initial_overlap_threshold": request_params.get("initial_overlap_threshold"), + "final_overlap_threshold": request_params.get("final_overlap_threshold"), + "stack_dates": [ + str(item.imaging_date).strip() + for item in ordered_items + if str(item.imaging_date or "").strip() + ], + "scenes": scenes, + } + + @router.post("/task-batches/dinsar", response_model=DinsarTaskBatch) async def create_dinsar_batch_endpoint( request: DinsarBatchCreateRequest, @@ -303,12 +362,85 @@ async def create_ps_batch_endpoint( if not request.stack: raise HTTPException(status_code=400, detail="No PS items provided.") + request_plan_id = ( + request.planning_context.get("plan_id") + if isinstance(request.planning_context, dict) + else None + ) + explicit_plan_id = str(request.plan_id or request_plan_id or "").strip() or None + inferred_plan_ids = sorted( + { + str(item.stack_plan_id or "").strip() + for item in request.stack + if str(item.stack_plan_id or "").strip() + } + ) + if len(inferred_plan_ids) > 1: + raise HTTPException(status_code=400, detail="PS stack items belong to multiple stack plans.") + if explicit_plan_id and inferred_plan_ids and explicit_plan_id != inferred_plan_ids[0]: + raise HTTPException(status_code=400, detail="request.plan_id does not match stack scene plan metadata.") + + effective_plan_id = explicit_plan_id or (inferred_plan_ids[0] if inferred_plan_ids else None) + plan: Optional[TimeseriesStackPlanORM] = None + plan_items: List[TimeseriesStackPlanItemORM] = [] + plan_item_by_id: Dict[int, TimeseriesStackPlanItemORM] = {} + plan_item_by_scene_id: Dict[int, TimeseriesStackPlanItemORM] = {} + plan_item_by_path: Dict[str, TimeseriesStackPlanItemORM] = {} + planning_context = request.planning_context if isinstance(request.planning_context, dict) else None + + if effective_plan_id: + plan_result = await db.execute( + select(TimeseriesStackPlanORM).where(TimeseriesStackPlanORM.plan_id == effective_plan_id) + ) + plan = plan_result.scalar_one_or_none() + if plan is None: + raise HTTPException(status_code=404, detail=f"Timeseries stack plan not found: {effective_plan_id}") + if ( + str(request.direction or "").strip() + and str(plan.direction or "").strip() + and str(request.direction).strip().upper() != str(plan.direction).strip().upper() + ): + raise HTTPException(status_code=400, detail="request.direction does not match the referenced stack plan.") + + items_result = await db.execute( + select(TimeseriesStackPlanItemORM) + .where(TimeseriesStackPlanItemORM.plan_ref_id == plan.id) + .order_by(TimeseriesStackPlanItemORM.scene_rank.asc(), TimeseriesStackPlanItemORM.id.asc()) + ) + plan_items = items_result.scalars().all() + plan_item_by_id = {int(item.id): item for item in plan_items if item.id is not None} + plan_item_by_scene_id = { + int(item.radar_data_ref_id): item + for item in plan_items + if item.radar_data_ref_id is not None + } + plan_item_by_path = { + _normalize_lookup_key(item.file_path): item + for item in plan_items + if _normalize_lookup_key(item.file_path) + } + if not planning_context: + planning_context = _build_plan_context(plan, plan_items) + else: + merged_context = { + **_build_plan_context(plan, plan_items), + **planning_context, + } + if "scenes" not in planning_context: + merged_context["scenes"] = _build_plan_context(plan, plan_items).get("scenes") or [] + planning_context = merged_context + batch_id = str(uuid.uuid4()) batch_name = request.name or f"PS_{(request.direction or 'STACK')}_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}" batch = PsTaskBatchORM( batch_id=batch_id, name=batch_name, direction=request.direction, + plan_id=plan.plan_id if plan is not None else effective_plan_id, + plan_strategy=( + (plan.strategy if plan is not None else None) + or (planning_context or {}).get("strategy") + ), status="PENDING", total_items=len(request.stack), completed_items=0, @@ -316,14 +448,49 @@ async def create_ps_batch_endpoint( db.add(batch) for img in request.stack: + matched_plan_item: Optional[TimeseriesStackPlanItemORM] = None + if img.stack_plan_item_id is not None and int(img.stack_plan_item_id) in plan_item_by_id: + matched_plan_item = plan_item_by_id[int(img.stack_plan_item_id)] + elif img.id is not None and int(img.id) in plan_item_by_scene_id: + matched_plan_item = plan_item_by_scene_id[int(img.id)] + else: + matched_plan_item = plan_item_by_path.get(_normalize_lookup_key(img.file_path)) + if batch.plan_id and matched_plan_item is None: + raise HTTPException( + status_code=400, + detail=f"PS stack scene is not part of referenced stack plan: {img.file_path}", + ) + + remark_payload = None + if planning_context: + planning_summary = { + key: value + for key, value in planning_context.items() + if key != "scenes" + } + remark_payload = { + **planning_summary, + "plan_id": batch.plan_id, + "plan_item_id": int(matched_plan_item.id) if matched_plan_item and matched_plan_item.id is not None else None, + "scene_id": img.id, + "scene_file_path": img.file_path, + "scene_imaging_date": img.imaging_date, + "scene_satellite": img.satellite, + } item = PsTaskItemORM( batch_id=batch_id, + plan_item_ref_id=( + int(matched_plan_item.id) + if matched_plan_item is not None and matched_plan_item.id is not None + else None + ), file_path=img.file_path, satellite=img.satellite, imaging_date=img.imaging_date, polarization=img.polarization, has_orbit_data=bool(img.has_orbit_data), status="PENDING", + remark=json.dumps(remark_payload, ensure_ascii=False) if remark_payload else None, ) db.add(item) @@ -332,7 +499,14 @@ async def create_ps_batch_endpoint( request=http_request, action="batch_created", resource=f"task-batches/ps/{batch_id}", - detail={"batch_name": batch_name, "items": len(request.stack), "direction": request.direction}, + detail={ + "batch_name": batch_name, + "items": len(request.stack), + "direction": request.direction, + "plan_id": batch.plan_id, + "plan_strategy": batch.plan_strategy, + "planning_context": planning_context, + }, ) await db.commit() await db.refresh(batch) diff --git a/backend/app/routers/timeseries_production.py b/backend/app/routers/timeseries_production.py index 86ba676..5ceeae0 100644 --- a/backend/app/routers/timeseries_production.py +++ b/backend/app/routers/timeseries_production.py @@ -31,6 +31,45 @@ class TimeseriesRunCreateRequest(BaseModel): return text +class TimeseriesWslCheckRequest(BaseModel): + distro: Optional[str] = Field(default=None, max_length=128) + smoke_test: bool = Field(default=False) + + @field_validator("distro", mode="before") + @classmethod + def _normalize_distro(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return None + text = str(value).strip() + return text or None + + +class TimeseriesPreflightRequest(BaseModel): + batch_id: str = Field(..., description="PS batch id") + reference_date: Optional[str] = Field(default=None, pattern=r"^\d{8}$|^$") + water_mask_mode: str = Field(default="synthetic_fallback", max_length=64) + + @field_validator("batch_id", mode="before") + @classmethod + def _validate_batch_id(cls, value: str) -> str: + text = str(value or "").strip() + if not text: + raise ValueError("batch_id is required") + return text + + +class TimeseriesRetryStepRequest(BaseModel): + step_id: str = Field(..., max_length=128) + + @field_validator("step_id", mode="before") + @classmethod + def _validate_step_id(cls, value: str) -> str: + text = str(value or "").strip() + if not text: + raise ValueError("step_id is required") + return text + + @router.post("/runs", status_code=202) async def create_timeseries_run( request: TimeseriesRunCreateRequest, @@ -53,6 +92,39 @@ async def create_timeseries_run( raise HTTPException(status_code=status_code, detail=message) from exc +@router.post("/wsl-check") +async def run_timeseries_wsl_check( + request: TimeseriesWslCheckRequest, + current_user: AuthUserORM = Depends(_require_admin), +): + _ = current_user + try: + return await timeseries_service.get_runtime_report( + distro=request.distro, + smoke_test=request.smoke_test, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/preflight") +async def run_timeseries_preflight( + request: TimeseriesPreflightRequest, + current_user: AuthUserORM = Depends(_require_admin), + db: AsyncSession = Depends(get_db), +): + _ = current_user + try: + return await timeseries_service.get_preflight_report( + batch_id=request.batch_id, + reference_date=request.reference_date, + water_mask_mode=request.water_mask_mode, + db=db, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.get("/runs") async def list_timeseries_runs( limit: int = 50, @@ -75,3 +147,25 @@ async def get_timeseries_run_detail( if detail is None: raise HTTPException(status_code=404, detail="Timeseries run not found") return detail + + +@router.post("/runs/{run_id}/retry-step", status_code=202) +async def retry_timeseries_run_step( + run_id: str, + request: TimeseriesRetryStepRequest, + current_user: AuthUserORM = Depends(_require_admin), + db: AsyncSession = Depends(get_db), +): + _ = current_user + try: + return await timeseries_service.retry_step( + run_id, + step_id=request.step_id, + db=db, + ) + except ValueError as exc: + message = str(exc) + status_code = 409 if "cannot be retried" in message or "running steps" in message else 400 + if "not found" in message: + status_code = 404 + raise HTTPException(status_code=status_code, detail=message) from exc diff --git a/backend/app/services/product_packaging.py b/backend/app/services/product_packaging.py index e3c7b8b..6170f7a 100644 --- a/backend/app/services/product_packaging.py +++ b/backend/app/services/product_packaging.py @@ -143,6 +143,8 @@ def upgrade_timeseries_package_manifest( "run_id": run_context.get("run_id"), "run_name": run_context.get("run_name"), "batch_id": run_context.get("batch_id"), + "plan_id": run_context.get("plan_id"), + "plan_strategy": run_context.get("plan_strategy"), "task_id": run_context.get("task_id"), "workflow_run_id": run_context.get("workflow_run_id"), "mode": run_context.get("mode"), @@ -173,6 +175,7 @@ def upgrade_timeseries_package_manifest( **_clean_dict(document.get("identity")), "stack_key": document.get("stack_key") or document.get("group_key"), "run_key": run_context.get("run_id") or _clean_dict(document.get("identity")).get("run_key"), + "plan_id": run_context.get("plan_id") or _clean_dict(document.get("identity")).get("plan_id"), } document["engine"] = { **_clean_dict(document.get("engine")), diff --git a/backend/app/services/psinsar_catalog_service.py b/backend/app/services/psinsar_catalog_service.py index aaef4b5..40301ec 100644 --- a/backend/app/services/psinsar_catalog_service.py +++ b/backend/app/services/psinsar_catalog_service.py @@ -246,6 +246,12 @@ class PsinsarCatalogService: "product_family": "timeseries", "stack_key": stack_key, "group_key": group_key, + "run_id": str(manifest.get("run_id") or "").strip() or None, + "batch_id": str(manifest.get("batch_id") or "").strip() or None, + "plan_id": str(manifest.get("plan_id") or "").strip() or None, + "plan_strategy": str(manifest.get("plan_strategy") or "").strip() or None, + "task_id": str(manifest.get("task_id") or "").strip() or None, + "workflow_run_id": str(manifest.get("workflow_run_id") or "").strip() or None, "reference_date": reference_date, "reference_point": manifest.get("reference_point"), "stack_dates": stack_dates, @@ -256,6 +262,7 @@ class PsinsarCatalogService: "summaries": manifest.get("summaries"), "canonical": canonical_payload, "runtime": runtime_payload, + "source_summary": manifest.get("source_summary"), } published_at = _parse_datetime(temporal.get("published_at") or manifest.get("published_at")) if published_at is None: @@ -679,6 +686,12 @@ class PsinsarCatalogService: "product_type": product.product_type, "display_name": product.display_name, "run_key": product.run_key, + "run_id": summary.get("run_id") or product.run_key, + "batch_id": summary.get("batch_id"), + "plan_id": summary.get("plan_id"), + "plan_strategy": summary.get("plan_strategy"), + "task_id": summary.get("task_id"), + "workflow_run_id": summary.get("workflow_run_id"), "profile_code": product.profile_code, "engine_code": product.engine_code, "package_schema": product.package_schema, @@ -699,6 +712,7 @@ class PsinsarCatalogService: "stack_size": summary.get("stack_size") or len(summary.get("stack_dates") or []), "quality": summary.get("quality"), "summaries": summary.get("summaries"), + "source_summary": summary.get("source_summary"), "coverage_polygon": product.coverage_polygon, "min_lon": product.min_lon, "min_lat": product.min_lat, diff --git a/backend/app/services/spatial_service.py b/backend/app/services/spatial_service.py index b9f064c..83726b6 100644 --- a/backend/app/services/spatial_service.py +++ b/backend/app/services/spatial_service.py @@ -5,9 +5,11 @@ """ import hashlib import json +import logging import math import uuid from collections import defaultdict +from itertools import combinations from typing import Any, Dict, List, Optional, Tuple from sqlalchemy.ext.asyncio import AsyncSession @@ -33,6 +35,8 @@ from ..models import ( RadarDataORM, RadarPair, ResultProductORM, + TimeseriesStackPlanItemORM, + TimeseriesStackPlanORM, ) from .dinsar_naming import build_pair_key, build_task_alias, ensure_unique_task_aliases from .pairing_state_service import pairing_state_service @@ -40,6 +44,7 @@ from .pairing_state_service import pairing_state_service PAIRING_POLICY_VERSION = "2026.04.phase3.v1" PAIRING_WARNING_CANDIDATE_THRESHOLD = 3000 +logger = logging.getLogger(__name__) class SpatialService: @@ -360,6 +365,142 @@ class SpatialService: payload = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) return hashlib.sha1(payload.encode("utf-8", errors="ignore")).hexdigest() + def _build_timeseries_stack_identity( + self, + direction: Optional[str], + scenes: List[RadarDataORM], + ) -> Dict[str, Any]: + sorted_scenes = sorted(scenes, key=lambda item: str(item.imaging_date or "")) + first = sorted_scenes[0] + satellite = self._normalize_timeseries_satellite_family(first) + imaging_mode = str(first.imaging_mode or "").strip() or "UNKNOWN" + polarization = str(first.polarization or "").strip() or "UNKNOWN" + orbit_direction = ( + str(direction or first.orbit_direction or "").strip().upper() or "UNKNOWN" + ) + group_key = "_".join( + part + for part in (satellite, imaging_mode, polarization, orbit_direction) + if str(part).strip() + ) + stack_dates = [ + str(item.imaging_date or "").strip() + for item in sorted_scenes + if str(item.imaging_date or "").strip() + ] + digest = self._stable_sha1( + { + "direction": orbit_direction, + "scene_ids": [int(item.id) for item in sorted_scenes], + "stack_dates": stack_dates, + } + )[:10] + date_start = stack_dates[0] if stack_dates else "NA" + date_end = stack_dates[-1] if stack_dates else "NA" + return { + "direction": orbit_direction, + "group_key": group_key, + "stack_key": f"{group_key}_{date_start}_{date_end}_{digest}", + "stack_dates": stack_dates, + } + + async def _persist_timeseries_stack_plan( + self, + db: AsyncSession, + *, + direction: Optional[str], + params: PsRequest, + aoi_wkt: Optional[str], + scenes: List[RadarDataORM], + common_aoi_coverage_ratio: Optional[float] = None, + coverage_consistency_ratio: Optional[float] = None, + threshold_satisfied: Optional[bool] = None, + selection_mode: Optional[str] = None, + ) -> Dict[str, Any]: + request_payload = params.model_dump(exclude_none=True) + aoi_hash = self._stable_sha1(aoi_wkt) if aoi_wkt else None + identity = self._build_timeseries_stack_identity(direction, scenes) + plan = TimeseriesStackPlanORM( + plan_id=f"tsp_{uuid.uuid4().hex[:24]}", + strategy="sbas_stack", + request_hash=self._stable_sha1( + { + "params": request_payload, + "aoi_hash": aoi_hash, + "direction": identity.get("direction"), + "scene_ids": [int(item.id) for item in scenes], + } + ), + request_params_json=request_payload, + aoi_source="wkt" if aoi_wkt else None, + aoi_hash=aoi_hash, + aoi_summary_json=self._build_aoi_summary(aoi_wkt), + direction=identity.get("direction"), + scene_count=len(scenes), + stack_key=identity.get("stack_key"), + group_key=identity.get("group_key"), + status="READY", + created_by="system:find_ps_timeseries", + ) + db.add(plan) + await db.flush() + + sorted_scenes = sorted(scenes, key=lambda item: str(item.imaging_date or "")) + scene_payloads: List[RadarData] = [] + for rank, item in enumerate(sorted_scenes, start=1): + plan_item = TimeseriesStackPlanItemORM( + plan_ref_id=plan.id, + radar_data_ref_id=int(item.id) if item.id is not None else None, + scene_rank=rank, + file_path=item.file_path, + satellite=item.satellite, + imaging_date=item.imaging_date, + imaging_mode=item.imaging_mode, + polarization=item.polarization, + has_orbit_data=bool(item.has_orbit_data), + selection_meta_json={ + "source": "find_ps_timeseries", + "direction": identity.get("direction"), + "group_key": identity.get("group_key"), + "stack_key": identity.get("stack_key"), + "initial_overlap_threshold": params.initial_overlap_threshold, + "final_overlap_threshold": params.final_overlap_threshold, + "common_aoi_coverage_ratio": common_aoi_coverage_ratio, + "coverage_consistency_ratio": coverage_consistency_ratio, + "threshold_satisfied": threshold_satisfied, + "selection_mode": selection_mode, + "orbit_direction": item.orbit_direction, + "satellite_family": self._normalize_timeseries_satellite_family(item), + "bbox": [item.min_lon, item.min_lat, item.max_lon, item.max_lat], + "scene_unique_id": item.unique_id, + }, + ) + db.add(plan_item) + await db.flush() + scene_payloads.append( + RadarData.model_validate(item).model_copy( + update={ + "orbit_direction": identity.get("direction") or item.orbit_direction, + "stack_plan_id": plan.plan_id, + "stack_plan_item_id": int(plan_item.id), + "stack_scene_rank": rank, + "stack_group_key": identity.get("group_key"), + "stack_key": identity.get("stack_key"), + "stack_common_aoi_coverage_ratio": common_aoi_coverage_ratio, + "stack_coverage_consistency_ratio": coverage_consistency_ratio, + "stack_threshold_satisfied": threshold_satisfied, + "stack_selection_mode": selection_mode, + } + ) + ) + + return { + "plan_id": plan.plan_id, + "group_key": identity.get("group_key"), + "stack_key": identity.get("stack_key"), + "scenes": scene_payloads, + } + def _apply_strategy( self, candidate_pool: List[dict], @@ -833,6 +974,220 @@ class SpatialService: def _generate_task_names(self, pairs: List[RadarPair]) -> List[RadarPair]: return ensure_unique_task_aliases(pairs) + def _normalize_timeseries_direction(self, image: RadarDataORM) -> str: + raw_direction = str(image.orbit_direction or "").strip().upper() + if raw_direction in {"ASC", "ASCENDING"}: + return "ASC" + if raw_direction in {"DSC", "DESC", "DESCENDING"}: + return "DSC" + if "ASC" in raw_direction: + return "ASC" + if "DSC" in raw_direction or "DESC" in raw_direction: + return "DSC" + return raw_direction or "UNKNOWN" + + def _normalize_timeseries_satellite_family(self, image: RadarDataORM) -> str: + raw_satellite = str(image.satellite or "").strip().upper() + compact = raw_satellite.replace("-", "").replace("_", "").replace(" ", "") + if compact in {"LT1", "LT1A", "LT1B", "LUTAN1", "LUTAN1A", "LUTAN1B"}: + return "LT1" + if compact in {"S1", "S1A", "S1B", "SENTINEL1", "SENTINEL1A", "SENTINEL1B"}: + return "S1" + return raw_satellite or "UNKNOWN" + + def _timeseries_compatibility_key(self, image: RadarDataORM) -> Tuple[str, str, str, str]: + return ( + self._normalize_timeseries_direction(image), + self._normalize_timeseries_satellite_family(image), + str(image.imaging_mode or "UNKNOWN").strip().upper() or "UNKNOWN", + str(image.polarization or "UNKNOWN").strip().upper() or "UNKNOWN", + ) + + def _format_timeseries_group_label(self, group_key: Tuple[str, str, str, str]) -> str: + return "_".join(part for part in group_key if part and part != "UNKNOWN") or "STACK" + + async def _calculate_wkt_area(self, db: AsyncSession, geom_wkt: str) -> float: + geom = func.ST_GeomFromText(geom_wkt, 4326) + result = await db.execute(select(ST_Area(cast(geom, Geography)))) + return float(result.scalar() or 0.0) + + async def _select_stable_timeseries_stack( + self, + db: AsyncSession, + images: List[RadarDataORM], + params: PsRequest, + *, + aoi_wkt: str, + aoi_area: float, + ) -> Tuple[List[RadarDataORM], float, float, bool, str]: + original_images = sorted(images, key=lambda item: (str(item.imaging_date or ""), int(item.id or 0))) + remaining = list(original_images) + best_stack: List[RadarDataORM] = [] + best_consistency_ratio = 0.0 + best_common_aoi_ratio = 0.0 + min_stack_size = 3 + target_ratio = float(params.final_overlap_threshold) + scene_aoi_areas: Dict[int, float] = {} + for img in remaining: + if img.id is None: + continue + scene_aoi_areas[int(img.id)] = await self._calculate_overlap_area(db, int(img.id), aoi_wkt) + + def _score_stack(stack: List[RadarDataORM], common_area: float) -> Tuple[float, float]: + scene_areas = [ + float(scene_aoi_areas.get(int(img.id or 0)) or 0.0) + for img in stack + if img.id is not None + ] + min_scene_area = min(scene_areas) if scene_areas else 0.0 + consistency_ratio = common_area / min_scene_area if min_scene_area > 0 else 0.0 + common_aoi_ratio = common_area / aoi_area if aoi_area > 0 else 0.0 + return ( + max(0.0, min(consistency_ratio, 1.0)), + max(0.0, min(common_aoi_ratio, 1.0)), + ) + + while len(remaining) >= min_stack_size: + common_overlap = await self._find_common_overlap( + db, + [int(img.id) for img in remaining if img.id is not None], + clip_wkt=aoi_wkt, + ) + common_area = float((common_overlap or {}).get("area") or 0.0) + consistency_ratio, common_aoi_ratio = _score_stack(remaining, common_area) + + if ( + consistency_ratio > best_consistency_ratio + 1e-9 + or ( + abs(consistency_ratio - best_consistency_ratio) <= 1e-9 + and common_aoi_ratio > best_common_aoi_ratio + 1e-9 + ) + or ( + abs(consistency_ratio - best_consistency_ratio) <= 1e-9 + and abs(common_aoi_ratio - best_common_aoi_ratio) <= 1e-9 + and len(remaining) > len(best_stack) + ) + ): + best_stack = list(remaining) + best_consistency_ratio = consistency_ratio + best_common_aoi_ratio = common_aoi_ratio + + if consistency_ratio >= target_ratio: + return remaining, consistency_ratio, common_aoi_ratio, True, "common_overlap" + + if len(remaining) == min_stack_size: + break + + trial_options: List[Tuple[float, float, int, List[RadarDataORM]]] = [] + for remove_index, _ in enumerate(remaining): + trial = remaining[:remove_index] + remaining[remove_index + 1:] + trial_overlap = await self._find_common_overlap( + db, + [int(img.id) for img in trial if img.id is not None], + clip_wkt=aoi_wkt, + ) + trial_area = float((trial_overlap or {}).get("area") or 0.0) + trial_consistency_ratio, trial_common_aoi_ratio = _score_stack(trial, trial_area) + removed_id = int(remaining[remove_index].id or 0) + trial_options.append((trial_consistency_ratio, trial_common_aoi_ratio, -removed_id, trial)) + + if not trial_options: + break + + _, _, _, remaining = max(trial_options, key=lambda item: (item[0], item[1], item[2])) + + if best_consistency_ratio >= target_ratio and len(best_stack) >= min_stack_size: + return best_stack, best_consistency_ratio, best_common_aoi_ratio, True, "common_overlap" + + network_stack, network_ratio = await self._select_pairwise_sbas_network_stack( + db, + original_images, + scene_aoi_areas, + target_ratio, + aoi_wkt=aoi_wkt, + ) + if len(network_stack) >= min_stack_size: + return network_stack, network_ratio, best_common_aoi_ratio, True, "pairwise_sbas_network" + + return [], best_consistency_ratio, best_common_aoi_ratio, False, "none" + + async def _select_pairwise_sbas_network_stack( + self, + db: AsyncSession, + images: List[RadarDataORM], + scene_aoi_areas: Dict[int, float], + target_ratio: float, + *, + aoi_wkt: str, + ) -> Tuple[List[RadarDataORM], float]: + if len(images) < 3: + return [], 0.0 + + image_by_id = {int(img.id): img for img in images if img.id is not None} + adjacency: Dict[int, List[Tuple[int, float]]] = {scene_id: [] for scene_id in image_by_id} + + aoi_geom = func.ST_GeomFromText(aoi_wkt, 4326) + for left, right in combinations(images, 2): + if left.id is None or right.id is None: + continue + left_id = int(left.id) + right_id = int(right.id) + left_area = float(scene_aoi_areas.get(left_id) or 0.0) + right_area = float(scene_aoi_areas.get(right_id) or 0.0) + min_scene_area = min(left_area, right_area) + if min_scene_area <= 0: + continue + + left_geom = select(RadarDataORM.geom).where(RadarDataORM.id == left_id).scalar_subquery() + right_geom = select(RadarDataORM.geom).where(RadarDataORM.id == right_id).scalar_subquery() + pair_geom = ST_Intersection(ST_Intersection(left_geom, right_geom), aoi_geom) + result = await db.execute(select(ST_Area(cast(pair_geom, Geography)))) + pair_area = float(result.scalar() or 0.0) + pair_ratio = max(0.0, min(pair_area / min_scene_area, 1.0)) + if pair_ratio >= target_ratio: + adjacency[left_id].append((right_id, pair_ratio)) + adjacency[right_id].append((left_id, pair_ratio)) + + visited: set[int] = set() + best_component: List[int] = [] + best_component_ratio = 0.0 + + for scene_id in sorted(adjacency): + if scene_id in visited: + continue + stack = [scene_id] + visited.add(scene_id) + component: List[int] = [] + component_edge_ratios: List[float] = [] + while stack: + current = stack.pop() + component.append(current) + for neighbor, ratio in adjacency.get(current, []): + component_edge_ratios.append(float(ratio)) + if neighbor not in visited: + visited.add(neighbor) + stack.append(neighbor) + + if len(component) < 3: + continue + component_ratio = min(component_edge_ratios) if component_edge_ratios else 0.0 + if ( + len(component) > len(best_component) + or ( + len(component) == len(best_component) + and component_ratio > best_component_ratio + ) + ): + best_component = component + best_component_ratio = component_ratio + + if len(best_component) < 3: + return [], 0.0 + + selected = [image_by_id[scene_id] for scene_id in best_component if scene_id in image_by_id] + selected.sort(key=lambda item: (str(item.imaging_date or ""), int(item.id or 0))) + return selected, best_component_ratio + async def find_ps_timeseries_data( self, db: AsyncSession, @@ -850,9 +1205,14 @@ class SpatialService: Returns: 按轨道方向分组的影像字典 """ - # 1. 初始筛选:找到与 AOI 相交的影像 + # 1. 初始筛选:找到与 AOI 相交且单景覆盖率达标的影像 aoi_geom = func.ST_GeomFromText(aoi_wkt, 4326) aoi_geog = cast(aoi_geom, Geography) + aoi_area = await self._calculate_wkt_area(db, aoi_wkt) + if aoi_area <= 0: + logger.warning("timeseries stack planning skipped: AOI area is empty") + return {} + intersection_geog = cast(ST_Intersection(RadarDataORM.geom, aoi_geom), Geography) stmt = select(RadarDataORM).where( and_( @@ -863,49 +1223,93 @@ class SpatialService: result = await db.execute(stmt) candidates = result.scalars().all() + logger.info( + "timeseries stack planning: candidates_after_aoi_gate=%s initial_threshold=%.3f final_consistency_threshold=%.3f", + len(candidates), + float(params.initial_overlap_threshold), + float(params.final_overlap_threshold), + ) if not candidates: return {} - # 2. 按轨道分组 - images_by_orbit: Dict[str, List[RadarDataORM]] = {} + # 2. 按轨道方向、卫星、成像模式、极化分组,避免混入不兼容场景。 + images_by_group: Dict[Tuple[str, str, str, str], List[RadarDataORM]] = {} for img in candidates: - direction = img.orbit_direction or ("ASC" if "ASC" in img.satellite else "DSC") - images_by_orbit.setdefault(direction, []).append(img) + images_by_group.setdefault(self._timeseries_compatibility_key(img), []).append(img) + logger.info( + "timeseries stack planning: compatible_groups=%s group_sizes=%s", + len(images_by_group), + { + self._format_timeseries_group_label(group_key): len(items) + for group_key, items in images_by_group.items() + }, + ) - # 3. 计算每个轨道的公共重叠区 + # 3. 每个兼容组内寻找满足公共 AOI 覆盖阈值的最大稳定候选栈。 final_results: Dict[str, List[RadarData]] = {} + plans_created = False - for direction, images in images_by_orbit.items(): - if len(images) < 2: + for group_key, images in images_by_group.items(): + if len(images) < 3: + logger.info( + "timeseries stack planning: group=%s skipped because scene_count=%s < 3", + self._format_timeseries_group_label(group_key), + len(images), + ) continue try: - # 查找公共重叠区 - common_overlap = await self._find_common_overlap(db, [img.id for img in images]) - - if not common_overlap or common_overlap["area"] < 1e-6: - continue - - common_geom = common_overlap["geom"] - common_area = common_overlap["area"] - - # 4. 最终筛选:覆盖公共区域一定比例的影像 - final_stack = [] - - for img in images: - img_overlap = await self._calculate_overlap_area(db, img.id, common_geom.wkt) - if img_overlap / common_area >= params.final_overlap_threshold: - final_stack.append(RadarData.model_validate(img)) - - if len(final_stack) >= 2: - final_stack.sort(key=lambda x: x.imaging_date) - final_results[direction] = final_stack + ( + final_stack, + consistency_ratio, + common_aoi_ratio, + threshold_satisfied, + selection_mode, + ) = await self._select_stable_timeseries_stack( + db, + images, + params, + aoi_wkt=aoi_wkt, + aoi_area=aoi_area, + ) + logger.info( + "timeseries stack planning: group=%s input_scenes=%s selected_scenes=%s consistency=%.4f common_aoi=%.4f threshold_satisfied=%s mode=%s", + self._format_timeseries_group_label(group_key), + len(images), + len(final_stack), + consistency_ratio, + common_aoi_ratio, + threshold_satisfied, + selection_mode, + ) + if len(final_stack) >= 3: + final_stack.sort(key=lambda x: str(x.imaging_date or "")) + direction = group_key[0] + persisted_plan = await self._persist_timeseries_stack_plan( + db, + direction=direction, + params=params, + aoi_wkt=aoi_wkt, + scenes=final_stack, + common_aoi_coverage_ratio=common_aoi_ratio, + coverage_consistency_ratio=consistency_ratio, + threshold_satisfied=threshold_satisfied, + selection_mode=selection_mode, + ) + result_key = persisted_plan.get("group_key") or self._format_timeseries_group_label(group_key) + if result_key in final_results: + result_key = f"{result_key}_{len(final_results) + 1}" + final_results[result_key] = persisted_plan["scenes"] + plans_created = True except Exception as e: - print(f"处理轨道 {direction} 时出错: {e}") + print(f"处理时序候选组 {self._format_timeseries_group_label(group_key)} 时出错: {e}") continue + if plans_created: + await db.commit() + return final_results async def find_hazard_points_in_area( @@ -1044,7 +1448,8 @@ class SpatialService: async def _find_common_overlap( self, db: AsyncSession, - image_ids: List[int] + image_ids: List[int], + clip_wkt: Optional[str] = None, ) -> Optional[dict]: """ Compute common overlap geometry and area using DB aggregation. @@ -1052,7 +1457,12 @@ class SpatialService: if not image_ids: return None - intersection_expr = func.st_intersection_agg(RadarDataORM.geom) + geom_expr = RadarDataORM.geom + if clip_wkt: + clip_geom = func.ST_GeomFromText(clip_wkt, 4326) + geom_expr = ST_Intersection(RadarDataORM.geom, clip_geom) + + intersection_expr = func.st_intersection_agg(geom_expr) stmt = select( ST_Area(cast(intersection_expr, Geography)).label("common_area"), intersection_expr.label("common_geom") @@ -1062,11 +1472,7 @@ class SpatialService: if not row or not row.common_geom: return None - try: - return {"geom": to_shape(row.common_geom), "area": float(row.common_area or 0)} - except Exception as exc: - print(f"[WARN] _compute_footprint: {exc}") - return None + return {"geom": row.common_geom, "area": float(row.common_area or 0)} def _optimize_coverage_diversity( self, diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py index 114de4f..6e66da2 100644 --- a/backend/app/services/task_service.py +++ b/backend/app/services/task_service.py @@ -273,6 +273,9 @@ class TaskService: else: # 显式更新心跳时间,防止 SQLAlchemy 因属性未变而跳过 UPDATE task.updated_at = datetime.now() + task.ended_at = None + if status == "RUNNING" and task.started_at is None: + task.started_at = datetime.now() await db.commit() else: diff --git a/backend/app/services/timeseries_service.py b/backend/app/services/timeseries_service.py index b280fac..e7155f5 100644 --- a/backend/app/services/timeseries_service.py +++ b/backend/app/services/timeseries_service.py @@ -10,7 +10,7 @@ import subprocess import sys import uuid from datetime import datetime -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Any, Dict, List, Optional from sqlalchemy import func, select @@ -23,13 +23,16 @@ from ..models import ( PsTimeseriesRunORM, RadarDataORM, ResultProductORM, + TimeseriesStackPlanItemORM, + TimeseriesStackPlanORM, WorkflowStepORM, ) from .psinsar_catalog_service import psinsar_catalog_service from .product_packaging import upgrade_timeseries_package_manifest +from .product_package_schema import normalize_package_manifest from .task_service import task_service from .workflow_service import workflow_service -from .wsl_service import run_wsl_command +from .wsl_service import check_wsl_environment, run_wsl_command CATALOG_NAME_PSINSAR = "psinsar" @@ -74,6 +77,38 @@ STACK_RUN_FILE_SEQUENCE = ( "run_08_igram", ) _SAFE_NAME_RE = re.compile(r"[^0-9A-Za-z._-]+") +TIMESERIES_STEP_STATUS_HINTS = { + "prepare": STATUS_PENDING, + "stack_prep_initial": STATUS_PREPARED, + "materialize": STATUS_STACK_PREPARED, + "stack_prep_refresh": STATUS_MATERIALIZED, + "run_isce2_stack": STATUS_STACK_READY, + "run_mintpy_sbas": STATUS_STACK_COMPLETED, + "export_publish_bundle": STATUS_MINTPY_COMPLETED, + "register_psinsar_product": STATUS_EXPORTED, +} +TIMESERIES_STEP_PROGRESS_HINTS = { + "prepare": 5, + "stack_prep_initial": 30, + "materialize": 65, + "stack_prep_refresh": 82, + "run_isce2_stack": 88, + "run_mintpy_sbas": 93, + "export_publish_bundle": 96, + "register_psinsar_product": 99, +} +REQUIRED_TIMESERIES_ASSET_ROLES = ( + "timeseries_cube", + "velocity_map", + "velocity_geotiff", + "temporal_coherence", + "quality_mask", + "preview_png", +) +REQUIRED_TIMESERIES_EXTRA_FILES = ( + "metadata/smallbaselineApp.cfg", + "manifest.json", +) def _utcnow() -> datetime: @@ -241,6 +276,33 @@ def _write_step_logs(logs_dir: Path, step_name: str, stdout: str, stderr: str) - (logs_dir / f"{step_name}.stderr.log").write_text(stderr or "", encoding="utf-8") +def _probe_local_writable_dir(path: str) -> tuple[bool, str]: + normalized = _normalize_path(path) + probe_dir = Path(normalized) + probe_dir.mkdir(parents=True, exist_ok=True) + probe_file = probe_dir / f".timeseries_probe_{uuid.uuid4().hex}.tmp" + try: + probe_file.write_text("ok", encoding="utf-8") + probe_file.unlink() + return True, normalized + except Exception as exc: + try: + if probe_file.exists(): + probe_file.unlink() + except Exception: + pass + return False, f"{normalized}: {exc}" + + +def _dem_sidecar_candidates(dem_path: str) -> List[str]: + normalized = _normalize_path(dem_path) + candidates = [ + normalized, + normalized + ".xml", + ] + return list(dict.fromkeys(candidates)) + + class TimeseriesService: def _derive_paths(self, run_id: str, *, stack_key: Optional[str] = None) -> Dict[str, str]: work_root_windows = _normalize_path(os.path.join(settings.TIMESERIES_WORK_ROOT, run_id)) @@ -276,9 +338,18 @@ class TimeseriesService: def _scene_payload(self, items: List[PsTaskItemORM]) -> List[Dict[str, Any]]: payload: List[Dict[str, Any]] = [] for item in items: + remark_json: Optional[Dict[str, Any]] = None + if str(item.remark or "").strip(): + try: + parsed = json.loads(str(item.remark)) + if isinstance(parsed, dict): + remark_json = parsed + except Exception: + remark_json = None payload.append( { "item_id": item.id, + "plan_item_ref_id": item.plan_item_ref_id, "file_path": item.file_path, "satellite": item.satellite, "imaging_date": item.imaging_date, @@ -286,10 +357,118 @@ class TimeseriesService: "has_orbit_data": bool(item.has_orbit_data), "status": item.status, "remark": item.remark, + "remark_json": remark_json, } ) return payload + def _extract_planning_context(self, items: List[PsTaskItemORM]) -> Optional[Dict[str, Any]]: + scene_records: List[Dict[str, Any]] = [] + summary_payload: Optional[Dict[str, Any]] = None + for item in items: + remark_text = str(item.remark or "").strip() + if not remark_text: + continue + try: + parsed = json.loads(remark_text) + except Exception: + continue + if not isinstance(parsed, dict): + continue + if summary_payload is None: + summary_payload = { + key: value + for key, value in parsed.items() + if key not in {"scene_id", "scene_file_path", "scene_imaging_date", "scene_satellite"} + } + scene_records.append( + { + "item_id": item.id, + "scene_id": parsed.get("scene_id"), + "file_path": parsed.get("scene_file_path") or item.file_path, + "imaging_date": parsed.get("scene_imaging_date") or item.imaging_date, + "satellite": parsed.get("scene_satellite") or item.satellite, + } + ) + if summary_payload is None: + return None + summary_payload["scenes"] = scene_records + summary_payload["scene_count"] = len(scene_records) or int(summary_payload.get("scene_count") or 0) + summary_payload["stack_dates"] = [ + str(item.get("imaging_date")).strip() + for item in scene_records + if str(item.get("imaging_date") or "").strip() + ] or list(summary_payload.get("stack_dates") or []) + return summary_payload + + def _build_plan_context( + self, + plan: TimeseriesStackPlanORM, + plan_items: List[TimeseriesStackPlanItemORM], + ) -> Dict[str, Any]: + request_params = plan.request_params_json if isinstance(plan.request_params_json, dict) else {} + ordered_items = sorted( + plan_items, + key=lambda item: (int(item.scene_rank or 0), int(item.id or 0)), + ) + return { + "source": "timeseries_stack_plan", + "plan_id": plan.plan_id, + "strategy": plan.strategy, + "direction": plan.direction, + "scene_count": int(plan.scene_count or len(ordered_items)), + "stack_key": plan.stack_key, + "group_key": plan.group_key, + "request_hash": plan.request_hash, + "aoi_summary": plan.aoi_summary_json if isinstance(plan.aoi_summary_json, dict) else None, + "initial_overlap_threshold": request_params.get("initial_overlap_threshold"), + "final_overlap_threshold": request_params.get("final_overlap_threshold"), + "stack_dates": [ + str(item.imaging_date).strip() + for item in ordered_items + if str(item.imaging_date or "").strip() + ], + "scenes": [ + { + "plan_item_id": item.id, + "scene_id": item.radar_data_ref_id, + "scene_rank": item.scene_rank, + "file_path": item.file_path, + "imaging_date": item.imaging_date, + "satellite": item.satellite, + "imaging_mode": item.imaging_mode, + "polarization": item.polarization, + "selection_meta": item.selection_meta_json if isinstance(item.selection_meta_json, dict) else None, + } + for item in ordered_items + ], + } + + async def _load_stack_plan_context( + self, + db: AsyncSession, + plan_id: Optional[str], + ) -> Optional[Dict[str, Any]]: + normalized_plan_id = str(plan_id or "").strip() + if not normalized_plan_id: + return None + plan_result = await db.execute( + select(TimeseriesStackPlanORM).where(TimeseriesStackPlanORM.plan_id == normalized_plan_id) + ) + plan = plan_result.scalar_one_or_none() + if plan is None: + return { + "source": "timeseries_stack_plan", + "plan_id": normalized_plan_id, + "status": "missing", + } + items_result = await db.execute( + select(TimeseriesStackPlanItemORM) + .where(TimeseriesStackPlanItemORM.plan_ref_id == plan.id) + .order_by(TimeseriesStackPlanItemORM.scene_rank.asc(), TimeseriesStackPlanItemORM.id.asc()) + ) + return self._build_plan_context(plan, items_result.scalars().all()) + async def _load_run(self, run_id: str, db: AsyncSession) -> PsTimeseriesRunORM: result = await db.execute( select(PsTimeseriesRunORM).where(PsTimeseriesRunORM.run_id == run_id) @@ -555,6 +734,441 @@ class TimeseriesService: raise ValueError("TIMESERIES_WSL_DISTRO is not configured.") return distro + def _runtime_stack_share_wsl(self, python_wsl: str) -> str: + text = str(python_wsl or "").strip() + if not text: + raise ValueError("TIMESERIES_PYTHON is not configured.") + python_path = PurePosixPath(text) + env_root = python_path.parent.parent + return str(env_root / "share" / "isce2") + + def _retry_status_for_step(self, step_id: str) -> str: + return TIMESERIES_STEP_STATUS_HINTS.get(str(step_id or "").strip(), STATUS_PENDING) + + def _retry_progress_for_step(self, step_id: str) -> int: + return int(TIMESERIES_STEP_PROGRESS_HINTS.get(str(step_id or "").strip(), 1)) + + async def get_preflight_report( + self, + *, + batch_id: str, + reference_date: Optional[str] = None, + water_mask_mode: str = "synthetic_fallback", + db: AsyncSession, + ) -> Dict[str, Any]: + checks: List[Dict[str, Any]] = [] + errors: List[str] = [] + warnings: List[str] = [] + + def add_check( + name: str, + ok: bool, + detail: str, + *, + severity: str = "error", + skipped: bool = False, + ) -> None: + checks.append( + { + "name": name, + "ok": bool(ok), + "detail": detail, + "severity": severity, + "skipped": bool(skipped), + } + ) + if skipped or ok: + return + if severity == "warn": + warnings.append(f"{name}: {detail}") + else: + errors.append(f"{name}: {detail}") + + normalized_batch_id = str(batch_id or "").strip() + normalized_reference_date = _normalize_date(reference_date) + normalized_water_mask_mode = ( + str(water_mask_mode or "synthetic_fallback").strip() or "synthetic_fallback" + ) + + add_check( + "TIMESERIES_ENABLED", + bool(settings.TIMESERIES_ENABLED), + "enabled" if settings.TIMESERIES_ENABLED else "TIMESERIES_ENABLED is false.", + ) + if not normalized_batch_id: + add_check("batch_id", False, "batch_id is required.") + return { + "overall_ok": False, + "batch_id": normalized_batch_id, + "checks": checks, + "errors": errors, + "warnings": warnings, + } + + batch_result = await db.execute( + select(PsTaskBatchORM).where(PsTaskBatchORM.batch_id == normalized_batch_id) + ) + batch = batch_result.scalar_one_or_none() + add_check( + "batch_exists", + batch is not None, + f"batch_id={normalized_batch_id}" if batch is not None else "PS batch not found.", + ) + if batch is None: + return { + "overall_ok": False, + "batch_id": normalized_batch_id, + "checks": checks, + "errors": errors, + "warnings": warnings, + } + + items_result = await db.execute( + select(PsTaskItemORM) + .where(PsTaskItemORM.batch_id == normalized_batch_id) + .order_by(PsTaskItemORM.id.asc()) + ) + items = self._sorted_items(items_result.scalars().all()) + add_check( + "scene_count", + len(items) >= 3, + f"{len(items)} scenes found; SBAS requires at least 3 scenes.", + ) + + valid_dates: List[str] = [] + invalid_date_items: List[str] = [] + scene_dir_errors = 0 + tiff_errors = 0 + meta_errors = 0 + total_bytes = 0 + + for item in items: + normalized_date = _normalize_date(item.imaging_date) + if normalized_date: + valid_dates.append(normalized_date) + else: + invalid_date_items.append(str(item.file_path or item.id)) + + scene_dir = Path(_normalize_path(item.file_path)) + if not scene_dir.exists() or not scene_dir.is_dir(): + scene_dir_errors += 1 + continue + + try: + tiff_path = _choose_scene_tiff(scene_dir) + total_bytes += int(tiff_path.stat().st_size) + except Exception: + tiff_errors += 1 + continue + + try: + _scene_meta_from_tiff(tiff_path) + except Exception: + meta_errors += 1 + + add_check( + "scene_dates", + len(invalid_date_items) == 0 and len(valid_dates) == len(items), + ( + "all scene dates are valid." + if len(invalid_date_items) == 0 and len(valid_dates) == len(items) + else f"invalid scene dates: {len(invalid_date_items)}" + ), + ) + duplicate_dates = sorted({item for item in valid_dates if valid_dates.count(item) > 1}) + add_check( + "unique_dates", + len(duplicate_dates) == 0, + "all scene dates are unique." if len(duplicate_dates) == 0 else f"duplicate dates: {', '.join(duplicate_dates)}", + ) + add_check( + "scene_directories", + scene_dir_errors == 0, + "all scene directories are present." if scene_dir_errors == 0 else f"missing scene directories: {scene_dir_errors}", + ) + add_check( + "scene_tiff_files", + tiff_errors == 0, + "all scene tiff files are present." if tiff_errors == 0 else f"scene tiff errors: {tiff_errors}", + ) + add_check( + "scene_meta_files", + meta_errors == 0, + "all scene meta xml files are present." if meta_errors == 0 else f"scene meta xml errors: {meta_errors}", + ) + + if normalized_reference_date and normalized_reference_date not in valid_dates: + add_check( + "reference_date", + False, + f"requested reference date {normalized_reference_date} is not in the stack; system will fall back to the middle date.", + severity="warn", + ) + else: + add_check( + "reference_date", + True, + normalized_reference_date or "no explicit reference date requested; middle date will be used.", + ) + + effective_reference_date = self._choose_reference_date(valid_dates, normalized_reference_date) + add_check( + "effective_reference_date", + bool(effective_reference_date), + effective_reference_date or "unable to determine a valid reference date.", + ) + + synthetic_allowed = bool(settings.TIMESERIES_ALLOW_SYNTHETIC_WATER_MASK) + water_mask_ok = not ( + normalized_water_mask_mode == "synthetic_fallback" and not synthetic_allowed + ) + add_check( + "water_mask_mode", + water_mask_ok, + ( + f"{normalized_water_mask_mode} accepted." + if water_mask_ok + else "synthetic_fallback is disabled by configuration." + ), + ) + + dem_path = str(settings.TIMESERIES_DEM_PATH or "").strip() + add_check( + "dem_path", + bool(dem_path) and os.path.isfile(_normalize_path(dem_path)), + dem_path or "TIMESERIES_DEM_PATH is not configured.", + ) + orbit_pool = str(settings.TIMESERIES_ORBIT_POOL_ISCE2 or "").strip() + add_check( + "orbit_pool", + bool(orbit_pool) and os.path.isdir(_normalize_path(orbit_pool)), + orbit_pool or "TIMESERIES_ORBIT_POOL_ISCE2 is not configured.", + ) + + stack_preview: Optional[Dict[str, Any]] = None + if not errors: + try: + stack_preview = await self._resolve_stack_scene_records( + items=items, + batch_direction=batch.direction, + run_id="preflight", + work_root_windows=_normalize_path(os.path.join(settings.TIMESERIES_WORK_ROOT, "_preflight")), + db=db, + ) + add_check( + "stack_identity", + True, + ( + f"stack_key={stack_preview.get('stack_key')} " + f"group_key={stack_preview.get('group_key')}" + ), + ) + except Exception as exc: + add_check("stack_identity", False, str(exc)) + + batch_status = str(batch.status or "").strip() + if batch_status and batch_status.upper() != "COMPLETED": + add_check( + "batch_status", + False, + f"batch status is {batch_status}; production can still run but the batch is not marked COMPLETED.", + severity="warn", + ) + else: + add_check("batch_status", True, batch_status or "COMPLETED") + + return { + "overall_ok": len(errors) == 0, + "batch_id": normalized_batch_id, + "batch_name": batch.name, + "batch_status": batch.status, + "plan_id": batch.plan_id, + "plan_strategy": batch.plan_strategy, + "reference_date_requested": normalized_reference_date, + "reference_date_effective": effective_reference_date, + "water_mask_mode": normalized_water_mask_mode, + "checks": checks, + "errors": errors, + "warnings": warnings, + "summary": { + "scene_count": len(items), + "valid_date_count": len(valid_dates), + "invalid_date_count": len(invalid_date_items), + "duplicate_date_count": len(duplicate_dates), + "item_has_orbit_data_count": sum(1 for item in items if item.has_orbit_data), + "total_scene_bytes": total_bytes, + "stack_dates": sorted(valid_dates), + "group_key": (stack_preview or {}).get("group_key"), + "stack_key": (stack_preview or {}).get("stack_key"), + "tile_key": (stack_preview or {}).get("tile_key"), + "source_root_windows": (stack_preview or {}).get("source_root_windows"), + "plan_id": batch.plan_id, + "plan_strategy": batch.plan_strategy, + }, + } + + async def get_runtime_report( + self, + *, + distro: Optional[str] = None, + smoke_test: bool = False, + ) -> Dict[str, Any]: + effective_distro = str(distro or settings.TIMESERIES_WSL_DISTRO or "").strip() + if not effective_distro: + raise ValueError("TIMESERIES_WSL_DISTRO is not configured.") + + env_name = str(settings.TIMESERIES_ENV_NAME or "").strip() + python_wsl = str(settings.TIMESERIES_PYTHON or "").strip() + if not python_wsl and env_name: + python_wsl = f"/home/administrator/miniconda3/envs/{env_name}/bin/python" + if not python_wsl: + raise ValueError("TIMESERIES_PYTHON is not configured.") + + stack_share_wsl = self._runtime_stack_share_wsl(python_wsl) + stack_script_wsl = str(PurePosixPath(stack_share_wsl) / "stripmapStack" / "stackStripMap.py") + prepare_dem_script_wsl = _windows_path_to_wsl_mount(settings.TIMESERIES_PREPARE_DEM_SCRIPT) or "" + report = await asyncio.to_thread( + check_wsl_environment, + distro=effective_distro, + python_cmd=python_wsl, + stripmap_app_path=stack_script_wsl, + pipeline_script_path=prepare_dem_script_wsl, + dem_path_win=settings.TIMESERIES_DEM_PATH, + orbit_dir_win=settings.TIMESERIES_ORBIT_POOL_ISCE2, + output_dir_win=settings.TIMESERIES_PRODUCT_DIR, + smoke_test=smoke_test, + ) + payload = report.to_dict() + checks = list(payload.get("checks") or []) + + script_checks = [ + ("TIMESERIES_STACK_PREP_SCRIPT", settings.TIMESERIES_STACK_PREP_SCRIPT), + ("TIMESERIES_MATERIALIZE_SCRIPT", settings.TIMESERIES_MATERIALIZE_SCRIPT), + ("TIMESERIES_PREPARE_DEM_SCRIPT", settings.TIMESERIES_PREPARE_DEM_SCRIPT), + ("TIMESERIES_STACK_RUNNER_SCRIPT", settings.TIMESERIES_STACK_RUNNER_SCRIPT), + ("TIMESERIES_MINTPY_SBAS_SCRIPT", settings.TIMESERIES_MINTPY_SBAS_SCRIPT), + ("TIMESERIES_EXPORT_PUBLISH_SCRIPT", settings.TIMESERIES_EXPORT_PUBLISH_SCRIPT), + ] + extra_ok = True + for label, script_path in script_checks: + normalized = _normalize_path(script_path) if str(script_path or "").strip() else "" + exists_flag = bool(normalized) and os.path.isfile(normalized) + detail = normalized or "not configured" + checks.append( + { + "name": label, + "ok": exists_flag, + "detail": detail, + "skipped": not bool(normalized), + } + ) + if normalized and not exists_flag: + extra_ok = False + + local_dir_checks = [ + ("TIMESERIES_WORK_ROOT", str(settings.TIMESERIES_WORK_ROOT or "").strip()), + ("TIMESERIES_PRODUCT_DIR", str(settings.TIMESERIES_PRODUCT_DIR or "").strip()), + ] + for label, dir_path in local_dir_checks: + normalized = _normalize_path(dir_path) if dir_path else "" + exists_flag = bool(normalized) and os.path.isdir(normalized) + checks.append( + { + "name": f"{label} exists", + "ok": exists_flag, + "detail": normalized or "not configured", + "skipped": not bool(normalized), + } + ) + if normalized and not exists_flag: + extra_ok = False + continue + if normalized: + writable_ok, writable_detail = _probe_local_writable_dir(normalized) + checks.append( + { + "name": f"{label} writable", + "ok": writable_ok, + "detail": writable_detail, + "skipped": False, + } + ) + if not writable_ok: + extra_ok = False + + work_root_wsl = _windows_path_to_wsl_mount(str(settings.TIMESERIES_WORK_ROOT or "").strip()) or "" + if work_root_wsl: + rc, _, stderr = await asyncio.to_thread( + run_wsl_command, + f"mkdir -p {shlex.quote(work_root_wsl)} && test -w {shlex.quote(work_root_wsl)}", + effective_distro, + 20, + None, + ) + work_root_wsl_ok = rc == 0 + checks.append( + { + "name": "TIMESERIES_WORK_ROOT writable in WSL", + "ok": work_root_wsl_ok, + "detail": work_root_wsl if work_root_wsl_ok else (stderr or work_root_wsl), + "skipped": False, + } + ) + if not work_root_wsl_ok: + extra_ok = False + + dem_path_windows = str(settings.TIMESERIES_DEM_PATH or "").strip() + if dem_path_windows: + sidecar_candidates = _dem_sidecar_candidates(dem_path_windows) + missing_sidecars = [path for path in sidecar_candidates if not os.path.exists(path)] + checks.append( + { + "name": "DEM sidecars", + "ok": len(missing_sidecars) == 0, + "detail": ( + "all expected local DEM sidecars exist." + if len(missing_sidecars) == 0 + else "missing: " + ", ".join(missing_sidecars[:5]) + ), + "skipped": False, + } + ) + if missing_sidecars: + extra_ok = False + + mintpy_command = f"{shlex.quote(python_wsl)} -c \"import mintpy; print('mintpy_ok')\"" + rc, stdout, stderr = await asyncio.to_thread( + run_wsl_command, + mintpy_command, + effective_distro, + 30, + None, + ) + mintpy_ok = rc == 0 and "mintpy_ok" in stdout + checks.append( + { + "name": "mintpy import", + "ok": mintpy_ok, + "detail": stdout or stderr, + "skipped": False, + } + ) + if not mintpy_ok: + extra_ok = False + + payload["checks"] = checks + payload["env_name"] = env_name + payload["python_wsl"] = python_wsl + payload["stack_script_wsl"] = stack_script_wsl + payload["overall_ok"] = bool(payload.get("overall_ok")) and extra_ok + if payload["overall_ok"]: + payload["message"] = payload.get("message") or "Timeseries WSL runtime is ready." + else: + failed = [item.get("name") for item in checks if not item.get("ok") and not item.get("skipped")] + if failed: + payload["message"] = "Timeseries WSL runtime has issues: " + ", ".join(failed) + return payload + async def _run_wsl_step( self, run: PsTimeseriesRunORM, @@ -669,6 +1283,8 @@ class TimeseriesService: payload = _read_json(manifest_path) now_text = _utcnow().replace(microsecond=0).isoformat() + "Z" source_summary = { + "plan_id": run.plan_id, + "plan_strategy": run.plan_strategy, "selected_manifest_path_windows": str(self._selected_manifest_path(run)), "selected_manifest_path_wsl": _windows_path_to_wsl_mount(str(self._selected_manifest_path(run))), "generated_stack_manifest_path_windows": str(self._generated_stack_manifest_path(run)), @@ -679,6 +1295,11 @@ class TimeseriesService: "mintpy_work_dir_wsl": _windows_path_to_wsl_mount(str(mintpy_work_dir)), "publish_dir_windows": str(run.publish_dir_windows or ""), "publish_dir_wsl": _windows_path_to_wsl_mount(str(run.publish_dir_windows or "")), + "planning_context": ( + (run.params_json or {}).get("planning_context") + if isinstance(run.params_json, dict) + else None + ), } payload = upgrade_timeseries_package_manifest( payload, @@ -686,6 +1307,8 @@ class TimeseriesService: "run_id": run.run_id, "run_name": run.run_name, "batch_id": run.batch_id, + "plan_id": run.plan_id, + "plan_strategy": run.plan_strategy, "task_id": run.task_id, "workflow_run_id": run.workflow_run_id, "mode": run.mode, @@ -723,6 +1346,91 @@ class TimeseriesService: _write_json(manifest_path, payload) return payload + def _validate_publish_bundle(self, manifest_path: Path) -> Dict[str, Any]: + if not manifest_path.exists(): + raise FileNotFoundError(f"Publish manifest not found: {manifest_path}") + + normalized = normalize_package_manifest(_read_json(manifest_path)) + package_dir = manifest_path.parent + assets = list(normalized.get("assets") or []) + asset_by_role: Dict[str, List[Dict[str, Any]]] = {} + for asset in assets: + role = str(asset.get("role") or "").strip() + if not role: + continue + asset_by_role.setdefault(role, []).append(asset) + + missing_roles: List[str] = [] + missing_files: List[str] = [] + zero_size_files: List[str] = [] + for role in REQUIRED_TIMESERIES_ASSET_ROLES: + role_assets = asset_by_role.get(role) or [] + if not role_assets: + missing_roles.append(role) + continue + relative_path = str(role_assets[0].get("relative_path") or "").strip() + absolute_path = package_dir / relative_path + if not absolute_path.is_file(): + missing_files.append(relative_path) + continue + try: + if absolute_path.stat().st_size <= 0: + zero_size_files.append(relative_path) + except OSError: + zero_size_files.append(relative_path) + + missing_extra_files: List[str] = [] + for relative_path in REQUIRED_TIMESERIES_EXTRA_FILES: + if not (package_dir / relative_path).is_file(): + missing_extra_files.append(relative_path) + + temporal = normalized.get("temporal") or {} + stack_dates = [ + str(item).strip() + for item in (temporal.get("stack_dates") or normalized.get("stack_dates") or []) + if str(item).strip() + ] + reference_date = str( + temporal.get("reference_date") or normalized.get("reference_date") or "" + ).strip() + canonical = normalized.get("canonical") or {} + primary_role = str(canonical.get("primary_asset_role") or "").strip() + preview_role = str(canonical.get("preview_asset_role") or "").strip() + + issues: List[str] = [] + if not reference_date: + issues.append("reference_date_missing") + if not stack_dates: + issues.append("stack_dates_missing") + if not primary_role: + issues.append("primary_asset_role_missing") + if not preview_role: + issues.append("preview_asset_role_missing") + + ok = not ( + missing_roles + or missing_files + or zero_size_files + or missing_extra_files + or issues + ) + return { + "ok": ok, + "schema_version": normalized.get("schema_version"), + "catalog_name": normalized.get("catalog_name"), + "product_family": normalized.get("product_family"), + "stack_size": len(stack_dates), + "reference_date": reference_date or None, + "primary_asset_role": primary_role or None, + "preview_asset_role": preview_role or None, + "asset_roles": sorted(asset_by_role.keys()), + "missing_roles": missing_roles, + "missing_files": missing_files, + "zero_size_files": zero_size_files, + "missing_extra_files": missing_extra_files, + "issues": issues, + } + def _workflow_steps(self, *, run_id: str, task_id: str) -> List[Dict[str, Any]]: return [ { @@ -794,6 +1502,8 @@ class TimeseriesService: return { "run_id": run.run_id, "batch_id": run.batch_id, + "plan_id": run.plan_id, + "plan_strategy": run.plan_strategy, "product_family": run.product_family, "run_name": run.run_name, "catalog_name": run.catalog_name, @@ -907,7 +1617,55 @@ class TimeseriesService: if batch is None: raise ValueError(f"PS batch not found: {normalized_batch_id}") + preflight = await self.get_preflight_report( + batch_id=normalized_batch_id, + reference_date=reference_date, + water_mask_mode=water_mask_mode, + db=db, + ) + if not preflight.get("overall_ok"): + problem_text = "; ".join(str(item) for item in (preflight.get("errors") or [])[:8]) or "unknown preflight failure" + raise ValueError("Timeseries preflight failed: " + problem_text) + items = await self._load_batch_items(normalized_batch_id, db) + remark_planning_context = self._extract_planning_context(items) + stack_plan_context = await self._load_stack_plan_context(db, batch.plan_id) + planning_context = remark_planning_context + if stack_plan_context: + planning_context = { + **stack_plan_context, + **(remark_planning_context or {}), + } + planning_context["source"] = stack_plan_context.get("source") + planning_context["plan_id"] = stack_plan_context.get("plan_id") + planning_context["strategy"] = ( + stack_plan_context.get("strategy") + or planning_context.get("strategy") + ) + planning_context["scene_count"] = ( + stack_plan_context.get("scene_count") + or planning_context.get("scene_count") + ) + planning_context["stack_key"] = ( + stack_plan_context.get("stack_key") + or planning_context.get("stack_key") + ) + planning_context["group_key"] = ( + stack_plan_context.get("group_key") + or planning_context.get("group_key") + ) + if not planning_context.get("scenes"): + planning_context["scenes"] = stack_plan_context.get("scenes") or [] + resolved_plan_id = str( + batch.plan_id + or ((planning_context or {}).get("plan_id")) + or "" + ).strip() or None + resolved_plan_strategy = str( + batch.plan_strategy + or ((planning_context or {}).get("strategy")) + or "" + ).strip() or None stack_dates = [ normalized for normalized in (_normalize_date(item.imaging_date) for item in items) @@ -954,6 +1712,7 @@ class TimeseriesService: params={ "run_id": run_id, "batch_id": normalized_batch_id, + "plan_id": resolved_plan_id, "reference_date": effective_reference_date, }, db=db, @@ -962,6 +1721,8 @@ class TimeseriesService: run = PsTimeseriesRunORM( run_id=run_id, batch_id=normalized_batch_id, + plan_id=resolved_plan_id, + plan_strategy=resolved_plan_strategy, product_family="timeseries", run_name=run_name_text, catalog_name=CATALOG_NAME_PSINSAR, @@ -990,6 +1751,8 @@ class TimeseriesService: manifest_path_wsl=_windows_path_to_wsl_mount(str(selected_manifest_path)), params_json={ "batch_id": normalized_batch_id, + "plan_id": resolved_plan_id, + "plan_strategy": resolved_plan_strategy, "requested_reference_date": _normalize_date(reference_date), "effective_reference_date": effective_reference_date, "water_mask_mode": normalized_water_mask_mode, @@ -997,24 +1760,42 @@ class TimeseriesService: "stack_workflow": settings.TIMESERIES_STACK_WORKFLOW, "group_key": stack_preview.get("group_key"), "stack_key": stack_key, + "planning_context": planning_context, + "preflight": preflight, }, summary_json={ "phase": "queued", "workflow": settings.TIMESERIES_STACK_WORKFLOW, + "plan_id": resolved_plan_id, + "plan_strategy": resolved_plan_strategy, "group_key": stack_preview.get("group_key"), "stack_key": stack_key, "stack_dates": stack_dates, "task_name": task_name, + "planning_context": { + "source": (planning_context or {}).get("source"), + "plan_id": (planning_context or {}).get("plan_id"), + "strategy": (planning_context or {}).get("strategy"), + "scene_count": (planning_context or {}).get("scene_count"), + } if planning_context else None, + "preflight": { + "overall_ok": bool(preflight.get("overall_ok")), + "warning_count": len(preflight.get("warnings") or []), + "effective_reference_date": preflight.get("reference_date_effective"), + }, }, input_snapshot_json={ "batch_id": normalized_batch_id, "batch_name": batch.name, "direction": batch.direction, + "plan_id": resolved_plan_id, + "plan_strategy": resolved_plan_strategy, "scene_count": len(items), "group_key": stack_preview.get("group_key"), "stack_key": stack_key, "stack_dates": stack_dates, "source_root_windows": stack_preview.get("source_root_windows"), + "planning_context": planning_context, "items": self._scene_payload(items), }, orbit_summary_json={ @@ -1024,9 +1805,13 @@ class TimeseriesService: "orbit_pool_windows": str(settings.TIMESERIES_ORBIT_POOL_ISCE2 or "").strip() or None, }, quality_summary_json={ + "plan_id": resolved_plan_id, + "plan_strategy": resolved_plan_strategy, "water_mask_mode": normalized_water_mask_mode, "synthetic_water_mask_allowed": bool(settings.TIMESERIES_ALLOW_SYNTHETIC_WATER_MASK), "notes": str(notes or "").strip() or None, + "planning_context": planning_context, + "preflight": preflight, }, created_by=created_by, ) @@ -1039,6 +1824,7 @@ class TimeseriesService: params={ "run_id": run_id, "batch_id": normalized_batch_id, + "plan_id": resolved_plan_id, "reference_date": effective_reference_date, "workflow": settings.TIMESERIES_STACK_WORKFLOW, }, @@ -1047,6 +1833,7 @@ class TimeseriesService: "product_family": "timeseries", "processor_code": "isce2_stack_mintpy", "batch_id": normalized_batch_id, + "plan_id": resolved_plan_id, "stack_key": stack_key, }, created_by=created_by, @@ -1061,6 +1848,7 @@ class TimeseriesService: "task_id": run.task_id, "workflow_run_id": run.workflow_run_id, "status": run.status, + "plan_id": run.plan_id, "reference_date": run.reference_date, "stack_size": run.stack_size, } @@ -1727,6 +2515,20 @@ class TimeseriesService: mintpy_cfg_path=mintpy_cfg_path, mintpy_work_dir=mintpy_work_dir, ) + publish_validation = self._validate_publish_bundle(publish_manifest_path) + if not publish_validation.get("ok"): + problems = [] + for key in ( + "missing_roles", + "missing_files", + "zero_size_files", + "missing_extra_files", + "issues", + ): + values = [str(item) for item in (publish_validation.get(key) or []) if str(item)] + if values: + problems.append(f"{key}={','.join(values)}") + raise ValueError("Publish bundle validation failed: " + "; ".join(problems or ["unknown"])) run.status = STATUS_EXPORTED run.summary_json = { @@ -1740,12 +2542,14 @@ class TimeseriesService: "stack_key": publish_manifest.get("stack_key"), "group_key": publish_manifest.get("group_key"), "export_runner": export_result, + "validation": publish_validation, }, } run.quality_summary_json = { **(run.quality_summary_json or {}), "water_mask_mode": run.water_mask_mode, "phase": "publish_bundle_complete", + "publish_validation": publish_validation, } await db.commit() await db.refresh(run) @@ -1772,6 +2576,9 @@ class TimeseriesService: run.error_message = None await db.commit() await db.refresh(run) + publish_validation = self._validate_publish_bundle(publish_manifest_path) + if not publish_validation.get("ok"): + raise ValueError("Managed publish bundle is not valid enough for catalog registration.") registration = await psinsar_catalog_service.register_manifest( db, manifest_path=str(publish_manifest_path), @@ -1789,12 +2596,14 @@ class TimeseriesService: "product_db_id": registration.get("product_db_id"), "product_status": registration.get("status"), "health_status": registration.get("health_status"), + "validation": publish_validation, }, } run.quality_summary_json = { **(run.quality_summary_json or {}), "water_mask_mode": run.water_mask_mode, "phase": "catalog_registered", + "publish_validation": publish_validation, } await db.commit() await db.refresh(run) @@ -1806,6 +2615,62 @@ class TimeseriesService: "product_db_id": registration.get("product_db_id"), } + async def retry_step( + self, + run_id: str, + *, + step_id: str, + db: AsyncSession, + ) -> Dict[str, Any]: + run = await self._load_run(run_id, db) + workflow_run_id = str(run.workflow_run_id or "").strip() + target_step_id = str(step_id or "").strip() + if not workflow_run_id: + raise ValueError(f"Timeseries run has no workflow_run_id: {run_id}") + if not target_step_id: + raise ValueError("step_id is required.") + + retry_result = await workflow_service.retry_step( + workflow_run_id, + target_step_id, + db=db, + ) + + run.status = self._retry_status_for_step(target_step_id) + run.error_message = None + run.ended_at = None + run.summary_json = { + **(run.summary_json or {}), + "phase": "retry_queued", + "retry": { + "step_id": target_step_id, + "queued_at": _utcnow().replace(microsecond=0).isoformat() + "Z", + "reset_steps": retry_result.get("reset_steps") or [], + }, + } + run.quality_summary_json = { + **(run.quality_summary_json or {}), + "phase": "retry_queued", + } + if run.task_id: + await task_service.update_task( + run.task_id, + status="RUNNING", + progress=self._retry_progress_for_step(target_step_id), + message=f"Retry queued from workflow step: {target_step_id}", + db=db, + ) + + await db.commit() + await db.refresh(run) + return { + "run_id": run.run_id, + "workflow_run_id": workflow_run_id, + "step_id": target_step_id, + "status": run.status, + "reset_steps": retry_result.get("reset_steps") or [], + } + async def mark_run_failed(self, run_id: str, error: str, *, db: AsyncSession) -> Dict[str, Any]: run = await self._load_run(run_id, db) run.status = STATUS_FAILED @@ -1895,6 +2760,8 @@ class TimeseriesService: "product_id": product.product_id, "display_name": product.display_name, "run_key": product.run_key, + "plan_id": summary.get("plan_id"), + "plan_strategy": summary.get("plan_strategy"), "package_schema": product.package_schema, "processor_code": product.processor_code, "runtime_id": product.runtime_id, diff --git a/backend/app/services/workflow_service.py b/backend/app/services/workflow_service.py index 14f8e8c..06ba65f 100644 --- a/backend/app/services/workflow_service.py +++ b/backend/app/services/workflow_service.py @@ -23,6 +23,28 @@ class WorkflowService: Lightweight workflow orchestration service backed by DB. """ + @staticmethod + def _collect_downstream_step_ids( + target_step_id: str, + steps: List[WorkflowStepORM], + ) -> set[str]: + reverse_graph: Dict[str, List[str]] = {} + for step in steps: + for dependency in step.depends_on or []: + reverse_graph.setdefault(str(dependency), []).append(step.step_id) + + pending = [target_step_id] + visited: set[str] = set() + while pending: + current = pending.pop() + if current in visited: + continue + visited.add(current) + for child_step_id in reverse_graph.get(current, []): + if child_step_id not in visited: + pending.append(child_step_id) + return visited + async def create_run( self, workflow_name: str, @@ -201,6 +223,75 @@ class WorkflowService: if gen_db: await db.close() + async def retry_step( + self, + run_id: str, + step_id: str, + db: Optional[AsyncSession] = None, + ) -> Dict[str, Any]: + gen_db = db is None + if gen_db: + db = _new_session() + + try: + run_result = await db.execute( + select(WorkflowRunORM).where(WorkflowRunORM.run_id == run_id) + ) + run = run_result.scalar_one_or_none() + if run is None: + raise ValueError(f"Workflow run not found: {run_id}") + + steps_result = await db.execute( + select(WorkflowStepORM) + .where(WorkflowStepORM.run_id == run_id) + .order_by(WorkflowStepORM.id.asc()) + ) + steps = steps_result.scalars().all() + if not steps: + raise ValueError(f"Workflow run has no steps: {run_id}") + + step_map = {step.step_id: step for step in steps} + target = step_map.get(step_id) + if target is None: + raise ValueError(f"Workflow step not found: {step_id}") + + if any(step.status == "RUNNING" for step in steps): + raise ValueError("Workflow still has running steps and cannot be retried.") + + retryable_statuses = {"FAILED", "COMPLETED", "CANCELLED", "SKIPPED"} + if target.status not in retryable_statuses: + raise ValueError( + f"Workflow step '{step_id}' is not retryable from status '{target.status}'." + ) + + reset_step_ids = self._collect_downstream_step_ids(step_id, steps) + for step in steps: + if step.step_id not in reset_step_ids: + continue + step.status = "READY" if step.step_id == step_id else "PENDING" + step.error = None + step.outputs = None + step.started_at = None + step.ended_at = None + + run.status = "RUNNING" + run.ended_at = None + + if gen_db: + await db.commit() + else: + await db.flush() + finally: + if gen_db: + await db.close() + + await self.enqueue_ready_steps(run_id, db=None if gen_db else db) + return { + "run_id": run_id, + "step_id": step_id, + "reset_steps": sorted(reset_step_ids), + } + async def _advance_ready_steps(self, run_id: str, db: AsyncSession) -> None: result = await db.execute( select(WorkflowStepORM).where(WorkflowStepORM.run_id == run_id) diff --git a/backend/app/services/wsl_service.py b/backend/app/services/wsl_service.py index 478e472..9735866 100644 --- a/backend/app/services/wsl_service.py +++ b/backend/app/services/wsl_service.py @@ -193,6 +193,9 @@ def check_wsl_environment( "bash -lc 可执行", "Python 可执行", "ISCE2 可 import", + "astropy.convolution import", + "cv2 import", + "scipy import", "stripmapApp 存在", "生产脚本存在", "DEM 路径可读", @@ -279,6 +282,27 @@ def check_wsl_environment( isce_ok = rc == 0 add("ISCE2 可 import", isce_ok, out or err) + rc, out, err = run_wsl_command( + f'{python_cmd} -c "from astropy.convolution import convolve; print(\'astropy_ok\')"', + distro=distro, timeout=30, + ) + astropy_ok = rc == 0 and "astropy_ok" in out + add("astropy.convolution import", astropy_ok, out or err) + + rc, out, err = run_wsl_command( + f'{python_cmd} -c "import cv2; print(\'cv2_ok\')"', + distro=distro, timeout=30, + ) + cv2_ok = rc == 0 and "cv2_ok" in out + add("cv2 import", cv2_ok, out or err) + + rc, out, err = run_wsl_command( + f'{python_cmd} -c "import scipy; print(\'scipy_ok\')"', + distro=distro, timeout=30, + ) + scipy_ok = rc == 0 and "scipy_ok" in out + add("scipy import", scipy_ok, out or err) + # 8. stripmapApp.py 存在(全路径检查) if stripmap_app_path: rc, out, err = run_wsl_command( diff --git a/backend/migrations/007_timeseries_stack_plan_trace.sql b/backend/migrations/007_timeseries_stack_plan_trace.sql new file mode 100644 index 0000000..d70c1cd --- /dev/null +++ b/backend/migrations/007_timeseries_stack_plan_trace.sql @@ -0,0 +1,17 @@ +-- Additive indexes for the phase-2 timeseries stack plan trace chain. +-- Tables/columns are created by SQLAlchemy metadata and db_maintenance missing-column repair. + +CREATE INDEX IF NOT EXISTS idx_ps_task_batches_plan_id + ON ps_task_batches (plan_id); + +CREATE INDEX IF NOT EXISTS idx_ps_task_items_plan_item_ref_id + ON ps_task_items (plan_item_ref_id); + +CREATE INDEX IF NOT EXISTS idx_ps_timeseries_runs_plan_id + ON ps_timeseries_runs (plan_id); + +CREATE INDEX IF NOT EXISTS idx_timeseries_stack_plans_request_hash + ON timeseries_stack_plans (request_hash); + +CREATE INDEX IF NOT EXISTS idx_timeseries_stack_plan_items_radar_ref + ON timeseries_stack_plan_items (radar_data_ref_id); diff --git a/deploy/wsl/conda/README.md b/deploy/wsl/conda/README.md index 8b3527d..b134447 100644 --- a/deploy/wsl/conda/README.md +++ b/deploy/wsl/conda/README.md @@ -114,4 +114,49 @@ PYINT_WSL_PYTHON=/home/administrator/miniconda3/envs/insar_wsl_v1/bin/python - `shared_conda_env_name = insar_wsl_v1` - `required_runtime_count == healthy_runtime_count` +## ISCE2 Rubbersheeting Dependency + +The managed `ISCE2` `lt1_stripmap` profile enables native range and azimuth +rubbersheeting by default. ISCE2's `runRubbersheetRange.py` imports +`astropy.convolution` during that step, so the shared WSL runtime must include +`astropy`. + +Check the active runtime: + +```bash +/home/administrator/miniconda3/envs/insar_wsl_v1/bin/python -c "from astropy.convolution import convolve; print('astropy_ok')" +``` + +Install or repair the package in an existing deployment: + +```bash +conda install -n insar_wsl_v1 -c conda-forge astropy +``` + +After changing the runtime, regenerate `insar_wsl_v1.explicit.lock` and +`insar_wsl_v1.fingerprint.json` so migrations can reproduce the same capability. + +## ISCE2 Ionosphere Dependency + +The managed `ISCE2` `lt1_stripmap` profile now runs the native stripmap +ionosphere path (`split spectrum`, low/high-band unwrap, and `ionosphere`) +before geocoding. That step requires both `cv2` and `scipy` in the shared WSL +runtime. + +Check the active runtime: + +```bash +/home/administrator/miniconda3/envs/insar_wsl_v1/bin/python -c "import cv2, scipy; print('ionosphere_ok')" +``` + +Install or repair the packages in an existing deployment: + +```bash +conda install -n insar_wsl_v1 -c conda-forge opencv scipy +``` + +If you change the runtime package set, regenerate +`insar_wsl_v1.explicit.lock` and `insar_wsl_v1.fingerprint.json` so deployment +and migration records stay aligned with the actual environment. + 如果健康面板显示 Python 路径不一致,说明仍然有旧环境残留在配置层或运行时注册层。 diff --git a/deploy/wsl/conda/insar_wsl_v1.environment.yml b/deploy/wsl/conda/insar_wsl_v1.environment.yml index a6ac27e..59f6aad 100644 --- a/deploy/wsl/conda/insar_wsl_v1.environment.yml +++ b/deploy/wsl/conda/insar_wsl_v1.environment.yml @@ -8,6 +8,7 @@ dependencies: - aom=3.9.1 - argcomplete=3.6.3 - attr=2.5.2 + - astropy=7.2.0 - attrs=26.1.0 - aws-c-auth=0.9.3 - aws-c-cal=0.9.13 @@ -251,6 +252,7 @@ dependencies: - nss=3.118 - numpy=1.26.4 - openjpeg=2.5.4 + - opencv - openjph=0.26.3 - openldap=2.6.10 - openmotif=2.3.8 diff --git a/deploy/wsl/conda/insar_wsl_v1.explicit.lock b/deploy/wsl/conda/insar_wsl_v1.explicit.lock index 1c7091f..5f51a45 100644 --- a/deploy/wsl/conda/insar_wsl_v1.explicit.lock +++ b/deploy/wsl/conda/insar_wsl_v1.explicit.lock @@ -16,18 +16,23 @@ https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/nlohman https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/poppler-data-0.4.12-hd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/python_abi-3.11-8_cp311.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/wayland-protocols-1.47-hd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_2.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libopengl-1.7.0-ha4b6fd6_2.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libgcc-15.2.0-he0feb66_18.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/alsa-lib-1.2.15.3-hb03c661_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/attr-2.5.2-h39aace5_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/aws-c-common-0.12.6-hb03c661_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/fribidi-1.0.16-hb03c661_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/json-c-0.18-h6688a6e_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libcap-2.77-hd0affe5_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libexpat-2.7.4-hecca717_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda @@ -38,6 +43,10 @@ https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libjpeg https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libntlm-1.8-hb9d3cd8_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libogg-1.3.5-hd0c01bc_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libopus-1.6.1-h280c20c_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libpciaccess-0.18-hb9d3cd8_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libsodium-1.0.21-h280c20c_3.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_18.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libutf8proc-2.11.3-hfe17d71_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda @@ -60,41 +69,61 @@ https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/aws-che https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/capnproto-1.2.0-hfc315d8_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/charls-2.4.3-hecca717_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/dav1d-1.2.1-hd590300_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/double-conversion-3.4.0-hecca717_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/fmt-12.0.0-h2b0788b_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/geos-3.14.1-h480dda7_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/gflags-2.2.2-h5888daf_1005.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/giflib-5.2.2-hd590300_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/graphite2-1.3.14-hecca717_2.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/icu-78.2-h33c6efd_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/imath-3.2.2-hde8ca8f_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/intel-gmmlib-22.10.0-hb700be7_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/jxrlib-1.1-hd590300_3.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/lame-3.100-h166bdaf_1003.tar.bz2 https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/lerc-4.1.0-hdb68285_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/level-zero-1.28.4-hb700be7_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libabseil-20250512.1-cxx17_hba17884_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libacl-2.3.2-h0f662aa_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libaec-1.1.5-h088129d_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libdrm-2.4.125-hb03c661_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libev-4.33-hd590300_2.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libevent-2.1.12-hf998b51_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libflac-1.5.0-he200343_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_18.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libglu-9.0.3-h5888daf_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libhwy-1.3.0-h4c17acf_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libpng-1.6.55-h421ea60_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_18.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libsystemd0-260.1-h6569c3e_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libudev1-260.1-h6569c3e_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libunwind-1.8.3-h65a8314_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/liburing-2.14-hb700be7_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libvorbis-1.3.7-h54a6638_2.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libvpx-1.15.2-hecca717_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libzip-1.11.2-h6991a6a_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/metis-5.1.0-hd0bcaf9_1007.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/mpg123-1.32.9-hc50e24c_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/nspr-4.38-h29cc59b_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/opencl-headers-2025.06.13-hecca717_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/openh264-2.6.0-hc22cd8d_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/pugixml-1.15-h3f63f65_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/readline-8.3-h853b02a_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/s2n-1.6.2-he8a4886_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/spirv-tools-2026.1-hb700be7_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/svt-av1-4.0.1-hecca717_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/wayland-1.25.0-hd6090a7_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/x264-1!164.3095-h166bdaf_2.tar.bz2 https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/zfp-1.0.1-h909a3a2_5.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/zlib-1.3.1-hb9d3cd8_2.conda @@ -108,6 +137,7 @@ https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/brunsli https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/c-blosc2-2.23.1-hc31b594_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/fftw-3.3.10-nompi_h3b011a4_112.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/glog-0.7.1-hbabe93e_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/glslang-16.2.0-h96af755_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/gmp-6.3.0-hac33072_2.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/hdf4-4.2.15-h2a13503_7.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/krb5-1.22.2-ha1258a1_0.conda @@ -121,23 +151,33 @@ https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libopen https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libprotobuf-6.31.1-h49aed37_4.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libre2-11-2025.11.05-h7b12aa8_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/librttopo-1.1.0-h46dd2a8_20.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libsndfile-1.2.2-hc7d488a_2.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libsqlite-3.52.0-hf4e2dac_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libsuitesparseconfig-7.10.1-h92d6892_7100102.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libthrift-0.22.0-h454ac66_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libusb-1.0.29-h73b1eb8_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libxml2-16-2.15.2-hca6bf5a_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libzopfli-1.0.3-h9c3ff4c_0.tar.bz2 https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/minizip-4.0.10-h05a5f5f_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/ocl-icd-2.3.3-hb9d3cd8_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/qhull-2020.2-h434a139_5.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/spdlog-1.16.0-hffee6e0_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/uriparser-0.9.8-hac33072_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/x265-3.5-h924138e_3.tar.bz2 +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xcb-util-0.4.1-h4f16b4b_2.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xcb-util-keysyms-0.4.1-hb711507_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xcb-util-renderutil-0.3.10-hb711507_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xcb-util-wm-0.4.2-hb711507_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xerces-c-3.3.0-hd9031aa_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xorg-libx11-1.8.13-he1eb515_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/aws-c-event-stream-0.5.7-h28f887f_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/aws-c-http-0.10.7-ha8fc4e3_5.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/brotli-1.2.0-hed03a55_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/cyrus-sasl-2.1.28-hac629b4_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/dbus-1.16.2-h24cb091_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/freexl-2.0.0-h9dce30a_2.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/gdk-pixbuf-2.44.5-h2b0a6b4_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/glpk-5.0-h445213a_0.tar.bz2 https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/lcms2-2.18-h0c24ade_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libamd-3.3.3-haaf9dc3_7100102.conda @@ -147,6 +187,7 @@ https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libbtf- https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libcamd-3.3.3-h32481e8_7100102.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libccolamd-3.3.4-h32481e8_7100102.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libcolamd-3.3.4-h32481e8_7100102.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libcups-2.3.3-h7a8fb5f_6.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libcurl-8.18.0-hcf29cc6_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libcxsparse-4.4.1-h32481e8_7100102.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libfreetype-2.14.2-ha770c72_0.conda @@ -162,12 +203,19 @@ https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/openjph https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/orc-2.2.1-hd747db4_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/python-3.11.15-hd63d673_0_cpython.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/re2-2025.11.05-h5301d42_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/shaderc-2025.5-h718be3e_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/sqlite-3.52.0-h04a0ce9_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xcb-util-image-0.4.0-hb711507_2.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xkeyboard-config-2.47-hb03c661_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xorg-libxext-1.3.7-hb03c661_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xorg-libxt-1.3.1-hb9d3cd8_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/zeromq-4.3.5-h41580af_10.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/aiohappyeyeballs-2.6.1-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/argcomplete-3.6.3-pyhd8ed1ab_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/astropy-iers-data-0.2026.4.27.1.3.2-pyhd8ed1ab_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/aws-c-auth-0.9.3-hef928c7_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/aws-c-mqtt-0.13.3-hc63082f_11.conda @@ -181,40 +229,60 @@ https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/cfitsio https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/charset-normalizer-3.4.5-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/click-8.3.2-pyhc90fa1f_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/debugpy-1.8.20-py311hc665b79_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/distlib-0.4.0-pyhd8ed1ab_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/filelock-3.29.0-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/fontconfig-2.17.1-h27c8c51_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/freetype-2.14.2-ha770c72_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/frozenlist-1.7.0-py311h52bc045_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/fsspec-2026.3.0-pyhd8ed1ab_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/gast-0.4.0-pyh9f0ad1d_0.tar.bz2 https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/hdf5-1.14.6-nompi_h19486de_106.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/jmespath-1.1.0-pyhcf101f3_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/jupyterlab_widgets-3.0.16-pyhcf101f3_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/kiwisolver-1.5.0-py311h724c32c_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libarchive-3.8.5-gpl_hc2c16d8_100.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libcblas-3.11.0-5_h0358290_openblas.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_2.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libgrpc-1.73.1-h3288cfb_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libhwloc-2.12.2-default_hafda6a7_1000.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/liblapack-3.11.0-5_h47877c9_openblas.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libllvm22-22.1.1-hf7376ad_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libspex-3.2.3-had10066_7100102.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libxkbcommon-1.13.1-hca5e8e5_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libxml2-devel-2.15.2-he237659_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libxslt-1.1.43-h711ed8c_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/locket-1.0.0-pyhd8ed1ab_0.tar.bz2 https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/lz4-4.4.5-py311h1c460e0_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/markupsafe-3.0.3-py311h3778330_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/mpmath-1.4.1-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/msgpack-python-1.1.2-py311hdf67eae_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/multidict-6.7.1-py311h3778330_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/narwhals-2.20.0-pyhcf101f3_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/nest-asyncio-1.6.0-pyhd8ed1ab_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/networkx-3.6.1-pyhcf101f3_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/openexr-3.4.7-h40f6f1d_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/openldap-2.6.10-hbde042b_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/parso-0.8.6-pyhcf101f3_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/pillow-12.1.1-py311hf88fc01_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/platformdirs-4.9.6-pyhcf101f3_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/proj-9.7.1-he0df7b0_3.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/prometheus-cpp-1.3.0-ha5d0236_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/propcache-0.3.1-py311h2dc5d0c_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/psutil-7.2.2-py311haee01d2_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/pulseaudio-client-17.0-h9a6aba3_3.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda @@ -222,26 +290,43 @@ https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/pyshp-3.0 https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/pytz-2026.1.post1-pyhcf101f3_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/pyyaml-6.0.3-py311h3778330_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/pyzmq-27.1.0-py311h57d2397_2.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/soupsieve-2.8.3-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/tblib-3.2.2-pyhcf101f3_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/toolz-1.1.0-pyhd8ed1ab_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/tornado-6.5.5-py311h49ec1c0_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/tqdm-4.67.3-pyh8f84b5b_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/traitlets-5.14.3-pyhd8ed1ab_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/uncompresspy-0.4.1-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/unicodedata2-17.0.1-py311h49ec1c0_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/utm-0.7.0-pyhd8ed1ab_0.tar.bz2 +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/wcwidth-0.6.0-pyhd8ed1ab_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/widgetsnbextension-4.0.15-pyhd8ed1ab_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/wrapt-2.1.2-py311h49ec1c0_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xcb-util-cursor-0.1.6-hb03c661_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xorg-libxcomposite-0.4.7-hb03c661_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xorg-libxi-1.8.2-hb9d3cd8_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xorg-libxmu-1.3.1-hb03c661_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xorg-libxp-1.0.4-hb03c661_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xorg-libxrandr-1.5.5-hb03c661_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xorg-libxscrnsaver-1.2.4-hb9d3cd8_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xorg-libxxf86vm-1.1.7-hb03c661_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/xyzservices-2026.3.0-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/zict-3.0.0-pyhd8ed1ab_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/zipp-3.23.1-pyhcf101f3_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/aioitertools-0.13.0-pyhd8ed1ab_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/aiosignal-1.4.0-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/aws-c-s3-0.11.3-h06ab39a_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/azure-identity-cpp-1.13.2-h3a5f585_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/azure-storage-common-cpp-12.11.0-h3d7a050_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/cairo-1.18.4-he90730b_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/cffi-2.0.0-py311h03d9500_1.conda @@ -254,38 +339,66 @@ https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/freeglu https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/geotiff-1.7.4-h1000f5c_4.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/gsl-2.7-he838d99_0.tar.bz2 https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/html5lib-1.1-pyhd8ed1ab_2.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/importlib-metadata-8.8.0-pyhcf101f3_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/jedi-0.19.2-pyhd8ed1ab_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/joblib-1.5.3-pyhd8ed1ab_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/jupyter_core-5.9.1-pyhc90fa1f_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/kealib-1.6.2-hb2f3951_2.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/lazy-loader-0.5-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libcholmod-5.3.1-h59ddab4_7100102.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libclang-cpp22.1-22.1.0-default_h99862b1_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libclang13-22.1.0-default_h746c552_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libgoogle-cloud-2.39.0-hdb79228_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/liblapacke-3.11.0-5_h6ae95b6_openblas.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libnetcdf-4.9.3-nompi_hbf2fc22_104.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libopentelemetry-cpp-1.21.0-hb9b0907_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libpq-18.3-h9abb657_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libspatialite-5.1.0-gpl_h2abfd87_119.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libva-2.23.0-he1eb515_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libvulkan-loader-1.4.341.0-h5279c79_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/lxml-6.0.2-py311h8840267_2.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/markdown-it-py-4.0.0-pyhd8ed1ab_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/matplotlib-inline-0.2.1-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/numpy-1.26.4-py311h64a7726_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/partd-1.4.2-pyhd8ed1ab_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/py2vega-0.6.1-pyhd8ed1ab_0.tar.bz2 https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/pyproj-3.7.2-py311h400b93a_3.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/python-discovery-1.2.2-pyhcf101f3_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/tbb-2022.3.0-hb700be7_2.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/traittypes-0.2.3-pyh332efcf_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/wheel-0.46.3-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xorg-libxft-2.3.9-h355ab9f_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/yarl-1.23.0-py311h3778330_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/aiohttp-3.13.5-py311h55b9665_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/aws-crt-cpp-0.35.2-h8824e59_6.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/azure-storage-blobs-cpp-12.15.0-h2a74896_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/bottleneck-1.6.0-np2py311h50facf7_3.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/contourpy-1.3.3-py311h724c32c_4.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/dask-core-2026.3.0-pyhc364b38_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/h5py-3.15.1-nompi_py311h0b2f468_101.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/harfbuzz-13.1.1-h6083320_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/imagecodecs-2026.3.6-py311h9837d23_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/imageio-2.37.0-pyhfb79c49_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/intel-media-driver-25.3.4-hecca717_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/ipython-9.13.0-pyh53cf698_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/jasper-4.2.9-h1588d4d_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/jplephem-2.24-pyha4b2019_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/jupyter_client-8.8.0-pyhcf101f3_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libgdal-core-3.10.3-h1f481a6_27.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libgoogle-cloud-storage-2.39.0-hdbdcf42_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libklu-2.3.5-hf24d653_7100102.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libopenvino-2025.4.1-hb56ce9e_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libspqr-4.3.4-h852d39f_7100102.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libumfpack-6.3.5-heb53515_7100102.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/openmotif-2.3.8-hf55c2fc_5.conda @@ -293,21 +406,28 @@ https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/pandas- https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/pip-26.0.1-pyh8b19718_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/poppler-25.07.0-h13eef12_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/postgresql-18.3-h9d31465_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/pyerfa-2.0.1.5-py310h32771cd_2.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/pykdtree-1.4.3-py311h0372a8f_2.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/pykml-0.2.0-pyhd8ed1ab_2.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/rich-15.0.0-pyhcf101f3_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/scipy-1.17.1-py311hbe70eeb_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/sdl3-3.4.4-hdeec2a5_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/shapely-2.1.2-py311h8a92878_2.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/ukkonen-1.1.0-py311hdf67eae_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/urllib3-2.6.3-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/virtualenv-21.2.4-pyhcf101f3_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/astropy-base-7.2.0-py311h0372a8f_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/aws-sdk-cpp-1.11.606-hf38915e_9.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/azure-storage-files-datalake-cpp-12.13.0-hf38f1be_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/bokeh-3.9.0-pyhd8ed1ab_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/botocore-1.42.91-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/distributed-2026.3.0-pyhc364b38_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/eccodes-2.46.0-h83bc92c_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/gdal-3.10.3-py311h34ccccb_27.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/ipykernel-7.2.0-pyha191276_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/ipywidgets-8.1.8-pyhd8ed1ab_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libass-0.17.4-h96ad9f0_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libgdal-fits-3.10.3-hec9d828_27.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libgdal-grib-3.10.3-hb20eef8_27.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libgdal-hdf4-3.10.3-ha810028_27.conda @@ -317,17 +437,36 @@ https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libgdal https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libgdal-pg-3.10.3-h55c2262_27.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libgdal-postgisraster-3.10.3-h55c2262_27.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libgdal-xls-3.10.3-hdee084c_27.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libopenvino-auto-batch-plugin-2025.4.1-hd85de46_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libopenvino-auto-plugin-2025.4.1-hd85de46_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libopenvino-hetero-plugin-2025.4.1-hd41364c_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libopenvino-intel-cpu-plugin-2025.4.1-hb56ce9e_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libopenvino-intel-gpu-plugin-2025.4.1-hb56ce9e_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libopenvino-intel-npu-plugin-2025.4.1-hb56ce9e_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libopenvino-ir-frontend-2025.4.1-hd41364c_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libopenvino-onnx-frontend-2025.4.1-h1862bb8_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libopenvino-paddle-frontend-2025.4.1-h1862bb8_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libopenvino-pytorch-frontend-2025.4.1-hecca717_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libopenvino-tensorflow-frontend-2025.4.1-h0767aad_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libopenvino-tensorflow-lite-frontend-2025.4.1-hecca717_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libparu-1.0.0-h17147ab_7100102.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libvpl-2.15.0-h54a6638_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/matplotlib-base-3.10.8-py311h0f3be63_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/pango-1.56.4-hadf4263_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/pyresample-1.35.0-py311h1ddb823_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/pysolid-0.3.4-py311h9bb1bfa_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/qt6-main-6.10.2-h17e89b9_5.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/requests-2.32.5-pyhcf101f3_1.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/sdl2-2.32.56-h54a6638_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/tifffile-2026.3.3-pyhd8ed1ab_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/aiobotocore-3.5.0-pyhcf101f3_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/bqplot-0.12.37-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/cartopy-0.25.0-py311hed34c8f_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/dask-jobqueue-0.9.0-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libarrow-22.0.0-h2937f24_4_cuda.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libgdal-kea-3.10.3-h2bf108d_27.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libgdal-netcdf-3.10.3-ha526aae_27.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/librsvg-2.62.1-h4c96295_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/multiurl-0.3.7-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/pygrib-2.1.8-py311he4f3390_0.conda @@ -336,17 +475,24 @@ https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/suitesp https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/tiledb-2.29.2-h8821262_1.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/cvxopt-1.3.3-py311h3d1f434_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/ecmwf-datastores-client-0.5.1-pyhd8ed1ab_0.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/ffmpeg-8.0.1-gpl_h43fde53_912.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/ipydatagrid-1.4.0-pyhcf101f3_2.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libarrow-compute-22.0.0-h58682fd_4_cuda.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libgdal-tiledb-3.10.3-h6c35068_27.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libparquet-22.0.0-h31208bf_4_cuda.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/s3fs-2026.3.0-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/cdsapi-0.7.7-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libarrow-acero-22.0.0-hb826db4_4_cuda.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libgdal-3.10.3-h3b705f5_27.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libopencv-4.13.0-qt6_py311hb0c3220_601.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/pyarrow-core-22.0.0-py311hbabfba9_2_cuda.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/isce2-2.6.4-py311h916084f_2.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libarrow-dataset-22.0.0-hb826db4_4_cuda.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/py-opencv-4.13.0-qt6_py311h44848da_601.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/pyaps3-0.3.7-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/libarrow-substrait-22.0.0-h9d9f3f8_4_cuda.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/opencv-4.13.0-qt6_py311hf44fbca_601.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/linux-64/pyarrow-22.0.0-py311h38be061_2.conda +https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/astropy-7.2.0-pyhd8ed1ab_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/dask-2026.3.0-pyhc364b38_0.conda https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/noarch/mintpy-1.6.3-pyhd8ed1ab_0.conda diff --git a/deploy/wsl/conda/insar_wsl_v1.fingerprint.json b/deploy/wsl/conda/insar_wsl_v1.fingerprint.json index a4152ba..af9e9d3 100644 --- a/deploy/wsl/conda/insar_wsl_v1.fingerprint.json +++ b/deploy/wsl/conda/insar_wsl_v1.fingerprint.json @@ -7,16 +7,28 @@ "osgeo": true, "numpy": true, "scipy": true, - "mintpy": true + "mintpy": true, + "astropy": true, + "cv2": true }, "versions": { + "isce": "2.6.3", "rasterio": "1.4.4", - "gdal": "3100300", + "osgeo": "3.10.3", "numpy": "1.26.4", - "scipy": "1.17.1" + "scipy": "1.17.1", + "mintpy": "1.6.2", + "astropy": "7.2.0", + "cv2": "4.13.0" }, "module_paths": { "isce": "/home/administrator/miniconda3/envs/insar_wsl_v1/lib/python3.11/site-packages/isce/__init__.py", - "mintpy": "/home/administrator/miniconda3/envs/insar_wsl_v1/lib/python3.11/site-packages/mintpy/__init__.py" + "rasterio": "/home/administrator/miniconda3/envs/insar_wsl_v1/lib/python3.11/site-packages/rasterio/__init__.py", + "osgeo": "/home/administrator/miniconda3/envs/insar_wsl_v1/lib/python3.11/site-packages/osgeo/__init__.py", + "numpy": "/home/administrator/miniconda3/envs/insar_wsl_v1/lib/python3.11/site-packages/numpy/__init__.py", + "scipy": "/home/administrator/miniconda3/envs/insar_wsl_v1/lib/python3.11/site-packages/scipy/__init__.py", + "mintpy": "/home/administrator/miniconda3/envs/insar_wsl_v1/lib/python3.11/site-packages/mintpy/__init__.py", + "astropy": "/home/administrator/miniconda3/envs/insar_wsl_v1/lib/python3.11/site-packages/astropy/__init__.py", + "cv2": "/home/administrator/miniconda3/envs/insar_wsl_v1/lib/python3.11/site-packages/cv2/__init__.py" } -} +} \ No newline at end of file diff --git a/deploy/wsl/runners/isce2_runner.py b/deploy/wsl/runners/isce2_runner.py index 08dbba9..9c26434 100644 --- a/deploy/wsl/runners/isce2_runner.py +++ b/deploy/wsl/runners/isce2_runner.py @@ -1,8 +1,10 @@ from __future__ import annotations import argparse +import importlib.util import json import os +import shutil import subprocess import sys from pathlib import Path @@ -67,7 +69,25 @@ def _build_pipeline_argv(payload: Mapping[str, Any], *, dry_run: bool = False) - _append_optional(argv, "--coh-threshold", params.get("coh_threshold")) _append_optional(argv, "--reference-mode", params.get("reference_mode")) _append_optional(argv, "--reference-coh-threshold", params.get("reference_coh_threshold")) + _append_optional(argv, "--deramp-mode", params.get("deramp_mode")) + _append_optional(argv, "--deramp-coh-threshold", params.get("deramp_coh_threshold")) _append_optional(argv, "--bbox-margin", params.get("bbox_margin")) + if not bool(params.get("ionosphere_correction", True)): + argv.append("--no-ionosphere-correction") + if bool(params.get("dense_offsets")): + argv.append("--dense-offsets") + if bool(params.get("rubbersheet_range")): + argv.append("--rubbersheet-range") + if bool(params.get("rubbersheet_azimuth")): + argv.append("--rubbersheet-azimuth") + _append_optional(argv, "--rubber-sheet-snr-threshold", params.get("rubber_sheet_snr_threshold")) + _append_optional(argv, "--rubber-sheet-filter-size", params.get("rubber_sheet_filter_size")) + _append_optional(argv, "--dense-window-width", params.get("dense_window_width")) + _append_optional(argv, "--dense-window-height", params.get("dense_window_height")) + _append_optional(argv, "--dense-search-width", params.get("dense_search_width")) + _append_optional(argv, "--dense-search-height", params.get("dense_search_height")) + _append_optional(argv, "--dense-skip-width", params.get("dense_skip_width")) + _append_optional(argv, "--dense-skip-height", params.get("dense_skip_height")) _append_optional(argv, "--wavelength", params.get("wavelength")) _append_optional(argv, "--orbit-margin-sec", params.get("orbit_margin_sec")) _append_optional(argv, "--resume-from", params.get("resume_from")) @@ -82,6 +102,16 @@ def _build_pipeline_argv(payload: Mapping[str, Any], *, dry_run: bool = False) - def _build_child_env() -> Dict[str, str]: env = os.environ.copy() + env_bin = Path(sys.executable).resolve().parent + path_prefixes = [env_bin.as_posix()] + isce_spec = importlib.util.find_spec("isce") + if isce_spec and isce_spec.origin: + isce_app_dir = Path(isce_spec.origin).resolve().parent / "applications" + if isce_app_dir.exists(): + path_prefixes.append(isce_app_dir.as_posix()) + + current_path = str(env.get("PATH") or "") + env["PATH"] = ":".join(path_prefixes + ([current_path] if current_path else [])) env_root = Path(sys.executable).resolve().parents[1] proj_data = env_root / "share" / "proj" if proj_data.exists(): @@ -90,6 +120,54 @@ def _build_child_env() -> Dict[str, str]: return env +def _find_python_module(module_name: str) -> bool: + try: + return importlib.util.find_spec(module_name) is not None + except ModuleNotFoundError: + return False + + +def _validate_runtime_dependencies(payload: Mapping[str, Any], child_env: Mapping[str, str]) -> str: + params = payload.get("params") or {} + errors: List[str] = [] + ionosphere_correction = bool(params.get("ionosphere_correction", True)) + + if ionosphere_correction: + missing_ionosphere_modules: List[str] = [] + if not _find_python_module("cv2"): + missing_ionosphere_modules.append("cv2") + if not _find_python_module("scipy"): + missing_ionosphere_modules.append("scipy") + if missing_ionosphere_modules: + errors.append( + "Missing WSL Python dependencies for the ISCE2 stripmap ionosphere step: " + + ", ".join(missing_ionosphere_modules) + + ". The managed LT-1 workflow enables split-spectrum dispersive correction before geocode. " + "Install the missing packages in insar_wsl_v1, for example: " + "conda install -n insar_wsl_v1 -c conda-forge opencv scipy." + ) + + needs_rubbersheet = bool(params.get("rubbersheet_range")) or bool( + params.get("rubbersheet_azimuth") + ) + if needs_rubbersheet and not _find_python_module("astropy.convolution"): + errors.append( + "Missing WSL Python dependency 'astropy.convolution'. " + "The managed LT-1 ISCE2 profile enables rubbersheeting, and ISCE2 " + "imports astropy.convolution while running runRubbersheetRange.py. " + "Install astropy in insar_wsl_v1 before running production." + ) + + if ionosphere_correction and not shutil.which("imageMath.py", path=str(child_env.get("PATH") or "")): + errors.append( + "Missing WSL CLI dependency 'imageMath.py' on PATH. " + "The ISCE2 stripmap ionosphere step shells out to imageMath.py late in the run. " + "Make sure the active conda env bin directory is exported into PATH before launching production." + ) + + return "\n".join(errors) + + def main() -> int: parser = argparse.ArgumentParser(description="ISCE2 runtime V1 runner.") parser.add_argument("--manifest", required=True, help="WSL path to the staged job manifest.") @@ -113,11 +191,20 @@ def main() -> int: if not pipeline_path.exists(): raise FileNotFoundError(f"ISCE2 pipeline script not found: {pipeline_path}") + child_env = _build_child_env() + dependency_error = _validate_runtime_dependencies(payload, child_env) + if dependency_error: + print( + json.dumps({"runtime_dependency_error": dependency_error}, ensure_ascii=False), + file=sys.stderr, + ) + return 2 + argv = _build_pipeline_argv(payload, dry_run=bool(args.dry_run)) print(json.dumps({"pipeline_argv": argv}, ensure_ascii=False)) completed = subprocess.run( argv, - env=_build_child_env(), + env=child_env, check=False, ) return int(completed.returncode or 0) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index db202ad..3ac3538 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -205,7 +205,7 @@ python run_worker.py 这套链路要求: - `.env` 中的根目录配置要真实可访问。 -- `backend/migrations/001` 到 `006` 必须保持幂等。 +- `backend/migrations/001` 到 `007` 必须保持幂等。 - 启动自维护默认是保守模式,不会在 schema 不匹配时自动破坏性重建。 ## 6. 数据库自维护边界 @@ -216,7 +216,7 @@ python run_worker.py - 自动创建 `postgis` 扩展 - 自动创建缺失表 - 自动补齐缺失列 - - 自动执行 `001` 到 `006` 号 SQL 文件 + - 自动执行 `001` 到 `007` 号 SQL 文件 - 自动引导管理员账号与灾害点初始化 - 不支持: @@ -304,3 +304,142 @@ VITE_TILE_SERVER_TOKEN=change_me - [DATABASE_SELF_MAINTENANCE_AUDIT_20260425.md](DATABASE_SELF_MAINTENANCE_AUDIT_20260425.md) - [PRODUCTION_RESULTS_MULTI_ENGINE_DESIGN_20260423.md](PRODUCTION_RESULTS_MULTI_ENGINE_DESIGN_20260423.md) - [WSL_RUNTIME_REFACTOR_DESIGN_20260422.md](WSL_RUNTIME_REFACTOR_DESIGN_20260422.md) + +## DEM Sidecar Migration Warning + +If you copy or move a prepared ISCE DEM bundle to a new directory or machine, do not assume +that the sidecar XML files are portable as-is. + +Affected files typically include: + +- `.xml` +- `.vrt` +- `.wgs84` +- `.wgs84.xml` +- `.wgs84.vrt` + +The ISCE XML sidecars may still contain absolute historical paths in: + +- `file_name` +- `metadata_location` +- `extra_file_name` + +Typical failure symptom: + +- `verifyDEM` succeeds, but `topo` fails with `FileNotFoundError` pointing at an old + `/mnt/...` path from the previous machine or previous directory layout. + +Recommended post-migration repair: + +```powershell +C:\ProgramData\anaconda3\envs\InSAR\python.exe ` + backend\app\isce2_pipeline\repair_dem_sidecars.py ` + --root D:\DEM ` + --repair +``` + +The managed ISCE2 pipeline now repairs the selected DEM sidecar paths before running, but +the directory-level repair is still recommended after deployment or storage migration so the +whole DEM bundle remains internally consistent. + +## ISCE2 Rubbersheeting Runtime Dependency + +The managed `ISCE2` `lt1_stripmap` profile enables dense offsets plus range and azimuth +rubbersheeting by default. This is an ISCE2 native workflow step, not an export-time +correction. + +The range rubbersheeting implementation imports `astropy.convolution`, so every deployed +or migrated WSL runtime must include `astropy` in `insar_wsl_v1`. + +Check: + +```bash +/home/administrator/miniconda3/envs/insar_wsl_v1/bin/python -c "from astropy.convolution import convolve; print('astropy_ok')" +``` + +Repair: + +```bash +conda install -n insar_wsl_v1 -c conda-forge astropy +``` + +If this package is missing, production now fails during preflight instead of after the +long dense-offset stage. + +## ISCE2 Ionosphere Runtime Dependency + +The managed `ISCE2` `lt1_stripmap` profile now keeps the standard stripmap +`split-spectrum -> low/high-band unwrap -> ionosphere -> geocode` path enabled. +This is part of the native workflow and replaces the older fake `PICKLE/ionosphere` +resume shortcut. + +Operational consequences: + +- `resume_from=unwrap` now resumes the full stage-2 chain up to `ionosphere` +- `resume_from=geocode` now starts from real `ionosphere` state when available +- the export step prefers geocoded `ionosphere/nondispersive.bil.unwCor.filt` + when that product exists + +The ionosphere implementation in ISCE2 imports `cv2` and `scipy`, so every +deployed or migrated WSL runtime must include both packages. + +Check: + +```bash +/home/administrator/miniconda3/envs/insar_wsl_v1/bin/python -c "import cv2, scipy; print('ionosphere_ok')" +``` + +Repair: + +```bash +conda install -n insar_wsl_v1 -c conda-forge opencv scipy +``` + +If these packages are missing, production now fails during preflight instead of +after the long stripmap filtering / unwrap stage. + +## Git Clone Bootstrap + +Goal: a clean `git clone` on a new Windows host should already contain the +deployment entrypoints required to install dependencies, validate `.env`, and +start the system. + +Recommended path: + +```powershell +git clone +cd Insar_management_system_v2 +Copy-Item .env.example .env +notepad .env +powershell -ExecutionPolicy Bypass -File .\scripts\bootstrap_clone.ps1 -InitFrontend -BuildFrontend +start_system.bat +``` + +Optional runtime bootstrap: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\bootstrap_clone.ps1 -InitWindowsConda +powershell -ExecutionPolicy Bypass -File .\scripts\bootstrap_clone.ps1 -InitWslConda +powershell -ExecutionPolicy Bypass -File .\scripts\bootstrap_clone.ps1 -All +``` + +`scripts/bootstrap_clone.ps1` is intentionally conservative: + +- It copies `.env.example` to `.env` only when `.env` is missing. +- It can create or update the Windows conda env from `environment.yml`. +- It can create or update the shared WSL conda env from + `deploy/wsl/conda/insar_wsl_v1.environment.yml`. +- It can run `npm ci` and `npm run build` in `frontend/`. +- It runs `scripts/check_runtime_config.py` unless `-SkipChecks` is specified. +- `start_system.bat` now fails early when `frontend/dist/index.html` is missing. +- It does not auto-start backend, worker, or Nginx. +- It does not bypass the existing database self-maintenance safeguards. + +Typical deployment sequence: + +1. Edit `.env` to match the new server. +2. Run `bootstrap_clone.ps1` with the switches required by that server. +3. Run `start_system.bat`. +4. Check `GET /api/health`. +5. Trigger a small real production task and confirm the result is registered in + the catalog. diff --git a/docs/INDEX.md b/docs/INDEX.md index a82cdf6..3cdc2ea 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -49,6 +49,8 @@ - [ISCE2_SBAS_TIMESERIES_DESIGN.md](ISCE2_SBAS_TIMESERIES_DESIGN.md) - [ISCE2_SBAS_PRODUCT_SPEC.md](ISCE2_SBAS_PRODUCT_SPEC.md) +- [ISCE2_SBAS_ENGINEERING_DESIGN_20260428.md](ISCE2_SBAS_ENGINEERING_DESIGN_20260428.md) + Current-phase engineering design for the managed ISCE2 + MintPy SBAS route. 说明: @@ -83,6 +85,13 @@ - [SECURITY_AUDIT_2026-03-12.md](SECURITY_AUDIT_2026-03-12.md) +## Clone Bootstrap + +- [../scripts/bootstrap_clone.ps1](../scripts/bootstrap_clone.ps1) + Fresh-server bootstrap entry for clone-based deployment. It keeps `.env` + initialization, frontend dependency install/build, runtime bootstrap, and + deployment validation in one place without changing the main startup chain. + ## 7. 工作笔记 - [../INIT.md](../INIT.md) diff --git a/docs/ISCE2_LT1_ENHANCEMENT_ALIGNMENT_20260427.md b/docs/ISCE2_LT1_ENHANCEMENT_ALIGNMENT_20260427.md new file mode 100644 index 0000000..dd88e4c --- /dev/null +++ b/docs/ISCE2_LT1_ENHANCEMENT_ALIGNMENT_20260427.md @@ -0,0 +1,154 @@ +# ISCE2 LT-1 Enhancement Alignment 2026-04-27 + +## Purpose + +This note records why the managed `ISCE2` `lt1_stripmap` production profile now +enables the built-in stripmap enhancement steps by default, and how that choice +relates to the existing SARscape `custom6` production chain. + +## Background + +The SARscape `custom6` chain already goes beyond a bare minimum D-InSAR run. +Its production semantics include: + +1. Interferogram generation +2. Filtering and coherence +3. Orbital trend / residual phase frequency removal +4. Phase unwrapping +5. GCP-based refinement and reflattening +6. Phase to displacement and geocoding + +This means the current LT-1 production baseline in the system is not a +scientifically "raw" interferometric export. It is an operationally enhanced +delivery chain. + +## ISCE2 Mapping + +ISCE2 stripmap does not expose the exact same SARscape modules, but it does +provide native enhancement steps that address the same operational risk class: +residual misregistration and geometry-driven long-wavelength artifacts. + +The relevant built-in ISCE2 controls are: + +- `doDenseOffsets` +- `doRubbersheetingRange` +- `doRubbersheetingAzimuth` +- `do split spectrum` +- `do dispersive` +- `rubberSheetSNRThreshold` +- `rubberSheetFilterSize` + +When enabled, the stripmap workflow: + +- estimates dense offsets from cross-correlation +- filters / masks those offsets +- updates the geometry offset fields +- performs a fine resampling pass using the corrected offsets +- unwraps low/high-band interferograms and estimates a dispersive ionosphere term +- geocodes the ionosphere-corrected nondispersive phase for delivery + +This is not identical to SARscape's `RemoveResidualPhaseFrequency` plus +`RefinementAndReflattening`, but it is the closest native ISCE2 enhancement +path inside the standard stripmap application. + +## Production Decision + +The managed `ISCE2` `lt1_stripmap` profile now treats these steps as part of the +default LT-1 production workflow: + +- split-spectrum ionosphere correction: enabled +- dense offsets: enabled +- range rubbersheeting: enabled +- azimuth rubbersheeting: enabled + +Default numeric parameters: + +- `rubberSheetSNRThreshold = 5.0` +- `rubberSheetFilterSize = 9` +- `denseWindowWidth = 64` +- `denseWindowHeight = 64` +- `denseSearchWidth = 20` +- `denseSearchHeight = 20` +- `denseSkipWidth = 32` +- `denseSkipHeight = 32` + +These defaults are stored as profile semantics in code, not as loose `.env` +feature toggles. + +## Runtime Dependency + +The stripmap ionosphere implementation imports `cv2` and `scipy`. + +Deployment check: + +```bash +/home/administrator/miniconda3/envs/insar_wsl_v1/bin/python -c "import cv2, scipy; print('ionosphere_ok')" +``` + +Repair command for an existing runtime: + +```bash +conda install -n insar_wsl_v1 -c conda-forge opencv scipy +``` + +The range rubbersheeting implementation in ISCE2 imports +`astropy.convolution` from `runRubbersheetRange.py`. The shared WSL conda +runtime therefore must include `astropy`. + +Deployment check: + +```bash +/home/administrator/miniconda3/envs/insar_wsl_v1/bin/python -c "from astropy.convolution import convolve; print('astropy_ok')" +``` + +Repair command for an existing runtime: + +```bash +conda install -n insar_wsl_v1 -c conda-forge astropy +``` + +## Boundary + +This change does **not** mean that every long-wavelength ramp problem is solved. + +It only means the default managed ISCE2 LT-1 profile now includes the native +registration-enhancement path that was previously omitted. + +If a run still shows a strong residual scene-wide ramp after rubbersheeting, +that should be treated as a separate quality / post-processing issue and should +be diagnosed explicitly rather than silently hidden inside export logic. + +## Operational Implication + +When comparing current SARscape and ISCE2 LT-1 products: + +- SARscape `custom6` remains the more explicitly refined chain +- ISCE2 `lt1_stripmap` is no longer a bare stripmap baseline +- both engines now include standard enhancement intent in default production + +This makes cross-engine behavior more defensible for LT-1 operational delivery. + +## Operator Controls + +As of 2026-04-29, the production UI no longer hides these choices behind code +defaults only. + +The ISCE2 production panel now exposes the managed LT-1 profile parameters in +three groups: + +- `Execution` +- `Delivery` +- `Enhancement` + +The user-visible controls now cover: + +- split-spectrum ionosphere correction on/off +- dense offsets on/off +- range rubbersheeting on/off +- azimuth rubbersheeting on/off +- reference normalization mode (`coh_median` or `none`) +- deramp mode (`plane` or `none`) + +This keeps the default managed behavior unchanged, while allowing operators to +fall back toward a more conservative stripmap delivery path when a specific +scene looks worse after enhancement. diff --git a/docs/ISCE2_SBAS_ENGINEERING_DESIGN_20260428.md b/docs/ISCE2_SBAS_ENGINEERING_DESIGN_20260428.md new file mode 100644 index 0000000..f439f96 --- /dev/null +++ b/docs/ISCE2_SBAS_ENGINEERING_DESIGN_20260428.md @@ -0,0 +1,663 @@ +# ISCE2 + MintPy SBAS Engineering Design + +Updated: 2026-04-28 + +## 1. Purpose + +This document defines the engineering expansion plan for the current stack-based time-series InSAR route: + +- `LT-1 stack batch -> ISCE2 stripmapStack -> MintPy SBAS -> publish bundle -> psinsar catalog` + +The repository already has a working phase-1 skeleton. The goal of this document is not to restart the design from zero, but to align the next implementation round with the code that already exists in: + +- `backend/app/services/timeseries_service.py` +- `backend/app/routers/timeseries_production.py` +- `backend/app/services/psinsar_catalog_service.py` +- `frontend/src/TimeseriesProductionPanel.jsx` +- `frontend/src/components/PsinsarCatalogPanel.jsx` + +This document supersedes the "missing pieces" parts of `docs/ISCE2_SBAS_TIMESERIES_DESIGN.md` for the current implementation phase. + +## 2. Decisions + +### 2.1 Primary processing route + +Keep the current scientific split: + +- ISCE2 is responsible for LT-1 stack preparation, stack geometry, co-registration, baseline generation, interferogram generation, and unwrap inputs. +- MintPy is responsible for SBAS inversion and time-series products. +- The system registers only publish-grade bundles, not raw MintPy work directories. + +This means the production claim for the current phase is: + +- `SBAS time-series production on top of ISCE2 + MintPy` + +It is not: + +- full PS-InSAR +- full StaMPS integration +- full commercial-grade atmospheric/error-correction stack + +### 2.2 Keep the current business model + +Use the current model already implemented in code: + +- planning-layer stack snapshot: + - `PsTaskBatchORM` + - `PsTaskItemORM` +- business-facing production run: + - `PsTimeseriesRunORM` +- step orchestration: + - `WorkflowRunORM` + - `WorkflowStepORM` +- publish/catalog registration: + - `ResultProductORM` + - `ResultAssetORM` + - `ResultIssueORM` + +Do not redesign the run model into a new engine abstraction in this round. + +### 2.3 Keep naming stable for now + +Current naming in the repository is mixed: + +- product family shown to users: `timeseries` +- processing mode: `sbas` +- catalog namespace and package schema legacy: `psinsar` + +For this round: + +- keep `product_family = timeseries` +- keep `mode = sbas` +- keep `catalog_name = psinsar` for compatibility +- keep `psinsar.publish.v1` ingestion support working + +Do not do a DB/API namespace rename and a pipeline hardening round at the same time. + +## 2.4 Current implementation scope (2026-04-28) + +This round is intentionally constrained to avoid impact on other production business: + +- no DB schema migration +- no catalog namespace rename +- no workflow framework refactor +- no change to the system self-maintenance / self-check contract + +The implementation landed in this round focuses on pipeline hardening around the existing `PsTimeseriesRunORM` path: + +- preflight gating before SBAS run creation +- stronger runtime self-check visibility +- publish-bundle validation before catalog registration +- frontend visibility for preflight, runtime checks, and publish validation + +This means the current engineering target is: + +- make the existing ISCE2 + MintPy SBAS route operationally safer + +not: + +- redesign the overall architecture +- replace the existing result registration model +- introduce a second persistence path for timeseries products + +### 2.5 Phase-2 scope (2026-04-28) + +The second implementation round keeps the same production chain but upgrades the planning trace from ad-hoc JSON to first-class additive schema objects. + +Additive schema only: + +- new planning tables: + - `TimeseriesStackPlanORM` + - `TimeseriesStackPlanItemORM` +- nullable trace columns on existing objects: + - `PsTaskBatchORM.plan_id` + - `PsTaskBatchORM.plan_strategy` + - `PsTaskItemORM.plan_item_ref_id` + - `PsTimeseriesRunORM.plan_id` + - `PsTimeseriesRunORM.plan_strategy` + +Operational rules for phase 2: + +- do not introduce a separate migration framework +- rely on the existing database self-maintenance path: + - `Base.metadata.create_all()` + - missing-column auto-add in `backend/app/db_maintenance.py` +- keep phase-1 `planning_context` / `remark` compatibility for old batches + +The engineering target of phase 2 is: + +- formalize `plan -> batch -> run -> publish bundle -> catalog product` traceability +- expose `plan_id` in frontend production and result views +- keep old batches runnable without backfilling or hard migration +- expose `GET /timeseries-plans/{plan_id}` for plan audit/detail lookup + +## 3. Current Baseline In Code + +The current code already implements the core production skeleton. + +### 3.1 Run record and workflow + +`backend/app/models/orm.py` + +- `PsTimeseriesRunORM` already stores: + - run identity + - batch binding + - processor/runtime metadata + - work and publish roots + - input/orbit/quality summaries + - failure state +- `WorkflowRunORM` and `WorkflowStepORM` already support DAG execution and retry. +- `SystemJobORM` already supports queued worker execution per workflow step. + +### 3.2 Current workflow steps + +`backend/app/services/timeseries_service.py` + +Current step chain is already eight steps: + +1. `prepare` +2. `stack_prep_initial` +3. `materialize` +4. `stack_prep_refresh` +5. `run_isce2_stack` +6. `run_mintpy_sbas` +7. `export_publish_bundle` +8. `register_psinsar_product` + +This is already the correct backbone for the managed SBAS route. + +### 3.3 Current scientific execution boundary + +The scientific boundary is still script-based, and that is acceptable for now: + +- `experiments/isce2_sbas_timeseries/scripts/build_lt1_stack_prep.py` +- `experiments/isce2_sbas_timeseries/scripts/materialize_lt1_stack_scenes.py` +- `experiments/isce2_sbas_timeseries/scripts/prepare_lt1_stack_dem.py` +- `experiments/isce2_sbas_timeseries/scripts/run_generated_stack_runfile_ubuntu2404.sh` +- `experiments/isce2_sbas_timeseries/scripts/run_mintpy_sbas_unified_env_smoketest_ubuntu2404.sh` +- `experiments/isce2_sbas_timeseries/scripts/export_mintpy_publish_products_ubuntu2404.sh` + +The current implementation should continue to wrap these scripts instead of rewriting the scientific logic prematurely. + +### 3.4 Current frontend and ops surface + +Already present: + +- run submission and run detail: + - `frontend/src/TimeseriesProductionPanel.jsx` +- product catalog panel: + - `frontend/src/components/PsinsarCatalogPanel.jsx` +- health-check visibility: + - `frontend/src/HealthCheckPanel.jsx` +- catalog rebuild API: + - `backend/app/routers/ps_products.py` + +So the next round is a hardening and extension round, not an empty scaffold round. + +## 4. Main Gaps + +The next engineering work should focus on the following gaps. + +### 4.1 Self-check is present but still shallow + +Current runtime check already validates: + +- WSL distro +- Python path +- stack script path +- configured helper scripts +- MintPy import +- DEM/orbit/output root presence + +What is still missing: + +- write permission checks for work and publish roots +- DEM sidecar consistency checks +- runtime dependency checks for scientific imports used by ISCE2/MintPy +- batch-level readiness checks before a run is queued +- publish-bundle structural validation before catalog registration + +### 4.2 Quality summary exists, but quality gating is weak + +Current code validates: + +- stack prep readiness +- required run files +- required ISCE2 output directories +- required MintPy outputs +- publish manifest existence + +But it still does not promote enough scientific quality indicators into release gates, for example: + +- interferogram count versus expected network count +- non-empty unwrap/correlation outputs +- valid-pixel ratio after `maskAllValid` +- temporal coherence thresholds +- reference point presence and stability summary + +### 4.3 Frontend is functional but still operationally thin + +Current frontend can: + +- submit a run +- run WSL check +- list runs +- show workflow steps +- retry failed workflow steps +- browse catalog entries + +Still missing: + +- structured preflight diagnostics for the selected batch +- clearer phase summaries per run +- direct visibility into quality summaries and key artifacts +- better linkage between run detail and published product detail +- a richer product detail view closer to the D-InSAR catalog panel depth + +### 4.4 Result management needs stricter contract enforcement + +The catalog path is correct, but the following rules should be made explicit and enforced: + +- `manifest.json` is the only registration entrypoint +- every published run must have a stable `publish_dir` +- required assets must exist before registration +- missing assets should generate catalog issues and possibly quarantine status +- every product should carry: + - processor code + - runtime id + - native output trace + - stack identity + +## 5. Target Pipeline + +### 5.1 Input contract + +The run input must remain stack-based, not pair-based. + +Source objects: + +- one `ps_task_batch` +- many `ps_task_items` +- one selected stack manifest: + - `input/selected_stack_manifest.json` +- one generated stack manifest: + - `input/stack_input_manifest.json` + +The selected manifest is the planning snapshot. + +The generated stack manifest is the execution snapshot and must include: + +- stack dates +- reference date +- stack key +- group key +- resolved DEM and orbit dependencies +- readiness flags +- blocking reasons +- generated ISCE2 command arguments + +### 5.2 Runtime directory model + +Keep the current directory split: + +- work root: + - `backend/runtime/timeseries_work//...` +- publish root: + - `TIMESERIES_PRODUCT_DIR//runs//...` + +Recommended internal layout under the work root: + +- `input/` +- `inputs/dem/` +- `stack_work/` +- `logs/` +- `mintpy/` + +Recommended publish layout: + +- `manifest.json` +- `assets/` +- `preview/` +- `metadata/` + +### 5.3 Managed workflow + +The current eight-step chain is the correct managed workflow and should be kept: + +1. `prepare` + - validate batch + - resolve stack identity + - choose reference date + - write `selected_stack_manifest.json` +2. `stack_prep_initial` + - generate execution-layer stack manifest + - resolve DEM/orbits + - tell the system whether materialization is the only blocker +3. `materialize` + - materialize LT-1 scenes into stack input layout + - materialize orbit XML and local dependencies +4. `stack_prep_refresh` + - re-run readiness check after materialization + - must reach ready state +5. `run_isce2_stack` + - prepare local DEM sidecars + - generate run files + - run `run_01` to `run_08` + - validate `geom_reference`, `baselines`, and `Igrams` +6. `run_mintpy_sbas` + - write MintPy config + - run controlled `smallbaselineApp` + - validate core MintPy outputs +7. `export_publish_bundle` + - geocode MintPy outputs + - export GeoTIFF browse layers + - generate preview and manifest + - augment the manifest with canonical metadata +8. `register_psinsar_product` + - register the publish bundle into catalog + - mark the run as published + +### 5.4 Current publish contract + +Keep `docs/ISCE2_SBAS_PRODUCT_SPEC.md` as the publish contract source of truth. + +Required publish assets for the managed SBAS route: + +- `assets/geo_timeseries.h5` +- `assets/geo_velocity.h5` +- `assets/velocity.tif` +- `assets/geo_temporalCoherence.h5` +- `assets/geo_maskTempCoh.h5` +- `preview/velocity_preview.png` +- `metadata/smallbaselineApp.cfg` +- `manifest.json` + +Optional but recommended: + +- `preview/numTriNonzeroIntAmbiguity.png` +- extra quality JSON files + +## 6. Self-Check Design + +Self-check should exist at four levels. + +### 6.1 Runtime preflight + +Primary entry: + +- `POST /timeseries-production/wsl-check` + +Current checks should be kept and extended with: + +- WSL distro reachable +- configured Python reachable +- ISCE2 stack script import/help check +- MintPy import check +- helper script existence checks +- DEM root existence +- orbit pool existence +- publish root existence +- work root existence +- write-test for work root +- write-test for publish root +- DEM sidecar consistency check +- optional import checks for: + - `cv2` + - `scipy` + - `astropy` + +Return structure should remain machine-readable so the frontend can render a diagnostic card instead of a plain message string. + +### 6.2 Batch preflight + +Add a run-specific preflight before or during `create_run`. + +Minimum checks: + +- scene count meets SBAS minimum +- all scene dates are valid +- scene dates are unique +- direction is consistent +- source files exist and are readable +- orbit coverage is complete or explicitly degraded +- `group_key` and `stack_key` are derivable +- publish path does not collide with another active run + +Recommended surface: + +- a new backend helper in `timeseries_service.py` +- frontend summary block in `TimeseriesProductionPanel.jsx` + +### 6.3 In-run gates + +Each workflow step should continue to fail fast when hard requirements are not met. + +Required gates: + +- `stack_prep_refresh` must report `ready_for_stackStripMap_nofocus = true` +- all expected run files must exist before stack execution +- `run_08_igram` output directories must exist +- MintPy required outputs must exist and be non-empty +- export must generate a manifest plus required assets +- registration must succeed against the catalog service + +### 6.4 Post-publish health + +Health is not only "the run finished". + +The catalog and package checks must continue to validate: + +- manifest exists +- publish dir exists +- processor code present +- runtime id present for WSL-native engines +- native output dir present +- canonical package schema valid +- manifest count versus DB count consistency + +## 7. Frontend Design + +### 7.1 Timeseries production panel + +Keep `frontend/src/TimeseriesProductionPanel.jsx` as the main run workspace. + +Planned enhancements: + +- show structured runtime preflight results +- show batch preflight results before submission +- show phase-oriented run summary: + - input prepared + - stack ready + - ISCE2 complete + - MintPy complete + - exported + - published +- show key paths and quality summary blocks without forcing the operator to inspect raw JSON +- keep failed-step retry +- add clearer linkage to the published product once available + +### 7.2 Product catalog panel + +Keep `frontend/src/components/PsinsarCatalogPanel.jsx` as the catalog entry. + +Planned enhancements: + +- retain catalog status and rebuild actions +- enrich product detail with: + - stack identity + - processor/runtime identity + - preview and primary assets + - quality summary + - asset list + - issue list + - coverage summary +- keep the publish bundle as the fact source + +### 7.3 Health panel + +Keep health visibility in `frontend/src/HealthCheckPanel.jsx`. + +The timeseries section should continue to show: + +- catalog status +- rebuild need +- manifest vs DB counts +- issue count + +It should remain aligned with: + +- `timeseries_result_catalog` +- `product_packages` +- `wsl_runtime` + +## 8. Result Management and Registration + +### 8.1 Registration rule + +Register only from: + +- `/manifest.json` + +Do not register from: + +- MintPy work directories +- ISCE2 runtime directories +- ad hoc copied assets + +### 8.2 Catalog model + +Keep: + +- `catalog_name = psinsar` +- `product_family = timeseries` + +Current catalog service already derives: + +- product id +- display name +- stack identity +- runtime and processor metadata +- bbox from asset summaries +- preview and primary asset paths + +The next round should strengthen issue generation for missing assets and invalid package states. + +### 8.3 Quarantine policy + +Do not auto-delete broken publish packages. + +If a rebuild finds broken packages, the preferred behavior is: + +- keep package on disk +- create `ResultIssueORM` records +- downgrade `health_status` +- use quarantine status only when the package is structurally unusable + +This keeps auditability intact. + +## 9. Implementation Strategy + +### 9.1 Do not over-refactor first + +The old design expected many new modules. That is no longer necessary because the codebase already has the main modules. + +For the next round: + +- keep `timeseries_service.py` as the orchestration center +- keep `job_handlers.py` as worker entrypoints +- keep `psinsar_catalog_service.py` as catalog authority +- extract helper modules only when a block becomes independently reusable or too large + +### 9.2 Recommended implementation phases + +#### Phase A: contract and self-check hardening + +Files likely involved: + +- `backend/app/services/timeseries_service.py` +- `backend/app/services/health_service.py` +- `frontend/src/TimeseriesProductionPanel.jsx` + +Target: + +- stronger runtime report +- batch preflight +- clearer failure reasons + +#### Phase B: quality summary and gating + +Files likely involved: + +- `backend/app/services/timeseries_service.py` +- `experiments/isce2_sbas_timeseries/scripts/build_mintpy_publish_bundle.py` +- `backend/app/services/psinsar_catalog_service.py` + +Target: + +- richer `quality_summary_json` +- richer manifest quality block +- stronger publish/register gates + +#### Phase C: frontend run and catalog UX + +Files likely involved: + +- `frontend/src/TimeseriesProductionPanel.jsx` +- `frontend/src/components/PsinsarCatalogPanel.jsx` +- `frontend/src/api/timeseriesProduction.js` +- `frontend/src/api/psinsarProducts.js` + +Target: + +- better preflight display +- better run summary +- richer product detail + +#### Phase D: validation and operator closure + +Target: + +- one small LT-1 AOI end-to-end validation +- one rerun-from-failure validation +- catalog rebuild validation +- deployment/ops notes update + +## 10. Non-Goals For This Round + +Do not include the following in the same implementation round: + +- Gamma/PyINT timeseries integration +- full PS-InSAR or StaMPS +- atmospheric correction productization +- topographic residual correction productization +- large database namespace migration from `psinsar` to `timeseries` + +These are valid future directions, but they should not be mixed into the current SBAS production hardening round. + +## 11. Acceptance Criteria + +The engineering expansion can be treated as complete for this round when all of the following are true: + +1. An operator can run runtime preflight and understand failures before queuing a run. +2. A stored PS stack batch can be submitted as one managed SBAS run. +3. The run can execute through all eight workflow steps in the managed path. +4. Failure at any step produces a clear error and supports controlled retry. +5. The publish bundle is complete and canonical. +6. The product is registered into the `psinsar` catalog from `manifest.json`. +7. The frontend can show: + - run state + - workflow step state + - published product linkage + - product assets and quality summary +8. `GET /api/health` remains healthy for: + - `timeseries_result_catalog` + - `product_packages` + - `wsl_runtime` + +## 12. Related Documents + +- `docs/ISCE2_SBAS_TIMESERIES_DESIGN.md` +- `docs/ISCE2_SBAS_PRODUCT_SPEC.md` +- `docs/DEPLOYMENT.md` +- `docs/CURRENT_STATUS_20260425.md` +- `experiments/isce2_sbas_timeseries/README.md` diff --git a/docs/ISCE2_SBAS_TIMESERIES_DESIGN.md b/docs/ISCE2_SBAS_TIMESERIES_DESIGN.md index 854ffcf..2744170 100644 --- a/docs/ISCE2_SBAS_TIMESERIES_DESIGN.md +++ b/docs/ISCE2_SBAS_TIMESERIES_DESIGN.md @@ -1,6 +1,6 @@ # ISCE2 SBAS Time-Series Production Design -Updated: 2026-04-06 +Updated: 2026-04-29 ## 1. Goal @@ -126,6 +126,33 @@ Current LT-1 stack experiment status already de-risks the processing side of pha - the original pair-oriented D-InSAR public entry was not removed - `backend/app/isce2_pipeline/run_lt1_dinsar_pipeline.py` still owns the existing workflow entry and now delegates shared input preparation to the helper +### 2.5 Stack Planning Thresholds + +The planning entry that historically used the old PS preparation label is now treated as time-series stack preparation. +The internal API path remains `/find-ps-timeseries` for compatibility. + +Current threshold meaning: + +- `initial_overlap_threshold` + - single-scene AOI coverage gate + - formula: `area(scene footprint ∩ AOI) / area(AOI)` + - default: `0.30` + - purpose: remove scenes that barely intersect the study area +- `final_overlap_threshold` + - final stack footprint consistency gate + - formula: `area(common footprint of selected stack ∩ AOI) / min(area(each selected scene footprint ∩ AOI))` + - default: `0.95` + - purpose: ensure the retained stack has a stable common processing area without requiring each strip to cover the whole AOI + +Planning algorithm rule: + +- first group candidates by orbit direction, satellite family, imaging mode, and polarization +- `LT1A` and `LT1B` are treated as the same `LT1` satellite family for stack planning +- then search each compatible group for the largest stack whose footprint consistency satisfies `final_overlap_threshold` +- when one or more outlier scenes break the common area, the planner may drop scenes until a valid stack is found +- if no all-scene common-overlap stack exists, the planner may return a connected pairwise SBAS network when each retained network edge satisfies `final_overlap_threshold` +- at least 3 scenes are required before a stack can be persisted as a `TimeseriesStackPlan` + ## 3. Recommendation ### 3.1 Deliver SBAS first diff --git a/docs/ISCE2_STABILIZATION_UPDATELOG_20260427.md b/docs/ISCE2_STABILIZATION_UPDATELOG_20260427.md index 29b0dec..a92a866 100644 --- a/docs/ISCE2_STABILIZATION_UPDATELOG_20260427.md +++ b/docs/ISCE2_STABILIZATION_UPDATELOG_20260427.md @@ -81,3 +81,104 @@ Delivered adjustments: operators do not treat post-processing heuristics as part of the standard workflow. - Revalidated the modified pipeline, engine, and WSL runner modules with `python3 -m py_compile` inside the target WSL runtime environment. + +## Additional Update: DEM Sidecar Path Repair + +After switching the managed DEM bundle to a copied `SRTMDEM_RSP_SARscape` dataset under +`D:\DEM`, an ISCE2 run failed in `topo` even though the outer pipeline XML pointed at the +new location. The root cause was that the copied DEM sidecar XML files still contained old +absolute `/mnt/...` paths in `file_name`, `metadata_location`, and `extra_file_name`. + +Delivered adjustments: + +- Added `backend/app/isce2_pipeline/repair_dem_sidecars.py` to audit and repair moved DEM + sidecar XML files at directory scope. +- Added sidecar self-repair for the selected ISCE2 DEM during pipeline resolution so the + managed run no longer depends on manually editing copied `.xml` files first. +- Documented the migration risk and the recommended repair command in `docs/DEPLOYMENT.md`. + +## Additional Update: LT-1 Enhancement Alignment + +The managed `ISCE2` `lt1_stripmap` production profile now enables the built-in +stripmap enhancement path by default: + +- dense offsets +- range rubbersheeting +- azimuth rubbersheeting + +This was done to bring the default ISCE2 LT-1 production semantics closer to the +existing SARscape `custom6` chain, which already includes non-trivial refinement +steps rather than shipping a bare minimum interferometric result. + +The implementation now passes these parameters end-to-end through: + +- `backend/app/dinsar_engines/isce2_engine.py` +- `deploy/wsl/runners/isce2_runner.py` +- `backend/app/isce2_pipeline/run_lt1_dinsar_pipeline.py` + +The rationale and the SARscape / ISCE2 mapping are documented in: + +- `docs/ISCE2_LT1_ENHANCEMENT_ALIGNMENT_20260427.md` + +## Additional Update: Rubbersheet Runtime Dependency + +The first enhanced LT-1 run reached ISCE2 `dense_offsets` successfully and then +failed at `rubber_sheet_range` with: + +```text +ModuleNotFoundError: No module named 'astropy' +``` + +Root cause: + +- ISCE2's `runRubbersheetRange.py` imports `astropy.convolution`. +- The shared WSL runtime `insar_wsl_v1` had ISCE2 and SciPy installed, but did + not include `astropy`. + +Delivered adjustments: + +- Added `astropy` to `deploy/wsl/conda/insar_wsl_v1.environment.yml`. +- Added a runtime dependency preflight in the WSL runner and LT-1 pipeline so + rubbersheeting fails immediately with a clear message instead of after the + dense-offset stage has already run. +- Added `astropy.convolution` to the ISCE2 WSL availability check. + +Required deployment action: + +```bash +conda install -n insar_wsl_v1 -c conda-forge astropy +``` + +## Additional Update: Real Ionosphere Stage Integration + +The managed `ISCE2` LT-1 stripmap workflow now runs the native stripmap +dispersive correction path instead of faking `PICKLE/ionosphere` state during +resume. + +Delivered adjustments: + +- Enabled `do split spectrum = True` and `do dispersive = True` in the generated + `stripmapApp` XML. +- Changed stage-2 execution from a narrow `unwrap -> unwrap` run to a real + `filter_low_band/unwrap/ionosphere` resume path. +- Changed stage-3 execution to resume from real `ionosphere` state when present, + or from the low/high-band unwrap state when only stage-2 products exist. +- Extended the reduced geocode export list with: + - `ionosphere/dispersive.bil.unwCor.filt` + - `ionosphere/nondispersive.bil.unwCor.filt` + - `ionosphere/mask.bil` +- Updated the export step to prefer geocoded + `ionosphere/nondispersive.bil.unwCor.filt` when available. + +Operational effect: + +- `resume_from=unwrap` now resumes the complete stage-2 chain up to + `ionosphere` +- `resume_from=geocode` now performs real `ionosphere -> geocode` continuation + instead of relying on copied pickle files + +Deployment note: + +- The native ionosphere implementation imports `cv2` and `scipy` +- The WSL runner, pipeline preflight, and health check now verify those modules + before production starts diff --git a/experiments/isce2_sbas_timeseries/scripts/build_lt1_stack_prep.py b/experiments/isce2_sbas_timeseries/scripts/build_lt1_stack_prep.py index 76b5eb9..d8df430 100644 --- a/experiments/isce2_sbas_timeseries/scripts/build_lt1_stack_prep.py +++ b/experiments/isce2_sbas_timeseries/scripts/build_lt1_stack_prep.py @@ -8,7 +8,7 @@ import re import sys from dataclasses import dataclass from datetime import datetime -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Any, Dict, List, Optional @@ -20,17 +20,10 @@ DEFAULT_MANIFEST_PATH = ( / "configs" / "sample_stack_e123p3_n46p1.json" ) -DEFAULT_STACK_SCRIPT_WSL = ( - "/home/administrator/miniconda3/envs/isce2/share/isce2/stripmapStack/stackStripMap.py" -) -DEFAULT_CONDA_WSL = "/home/administrator/miniconda3/bin/conda" -DEFAULT_ISCE2_SHARE_WSL = "/home/administrator/miniconda3/envs/isce2/share/isce2" -DEFAULT_STRIPMAP_STACK_DIR_WSL = f"{DEFAULT_ISCE2_SHARE_WSL}/stripmapStack" +DEFAULT_SHARED_ENV_NAME = "insar_wsl_v1" +DEFAULT_CONDA_ROOT_WSL = "/home/administrator/miniconda3" +DEFAULT_CONDA_WSL = f"{DEFAULT_CONDA_ROOT_WSL}/bin/conda" SUPPORTED_STACK_WORKFLOWS = ("slc", "interferogram", "ionosphere") -DEFAULT_STACK_TEXT_CMD = ( - f"export PATH={DEFAULT_STRIPMAP_STACK_DIR_WSL}:$PATH; " - f"export PYTHONPATH={DEFAULT_STRIPMAP_STACK_DIR_WSL}:{DEFAULT_ISCE2_SHARE_WSL}${{PYTHONPATH:+:$PYTHONPATH}}; " -) def windows_to_wsl(path: str | Path) -> str: @@ -57,6 +50,47 @@ def load_isce2_input_helper_module(): ISCE2_INPUT_HELPER = load_isce2_input_helper_module() +def _default_python_wsl(env_name: str) -> str: + normalized_env = str(env_name or "").strip() or DEFAULT_SHARED_ENV_NAME + return f"{DEFAULT_CONDA_ROOT_WSL}/envs/{normalized_env}/bin/python" + + +def _resolve_runtime_paths(env_values: Dict[str, Any]) -> Dict[str, str]: + env_name = str( + env_values.get("TIMESERIES_ENV_NAME") + or env_values.get("WSL_SHARED_CONDA_ENV") + or DEFAULT_SHARED_ENV_NAME + ).strip() or DEFAULT_SHARED_ENV_NAME + python_wsl = str( + env_values.get("TIMESERIES_PYTHON") + or env_values.get("WSL_SHARED_PYTHON") + or env_values.get("ISCE2_PYTHON") + or _default_python_wsl(env_name) + ).strip() or _default_python_wsl(env_name) + + python_path = PurePosixPath(python_wsl) + env_root = python_path.parent.parent + conda_root = env_root.parent.parent + conda_bin_wsl = str(conda_root / "bin" / "conda") + isce2_share_wsl = str(env_root / "share" / "isce2") + stripmap_stack_dir_wsl = str(PurePosixPath(isce2_share_wsl) / "stripmapStack") + stack_script_wsl = str(PurePosixPath(stripmap_stack_dir_wsl) / "stackStripMap.py") + stack_text_cmd = ( + f"export PATH={stripmap_stack_dir_wsl}:$PATH; " + f"export PYTHONPATH={stripmap_stack_dir_wsl}:{isce2_share_wsl}${{PYTHONPATH:+:$PYTHONPATH}}; " + ) + + return { + "env_name": env_name, + "python_wsl": python_wsl, + "conda_bin_wsl": conda_bin_wsl, + "isce2_share_wsl": isce2_share_wsl, + "stripmap_stack_dir_wsl": stripmap_stack_dir_wsl, + "stack_script_wsl": stack_script_wsl, + "stack_text_cmd": stack_text_cmd, + } + + def require_file(path: Path, label: str) -> None: if not path.exists(): raise FileNotFoundError(f"Missing {label}: {path}") @@ -204,14 +238,15 @@ def render_stack_command( work_dir_wsl: str, reference_date: str, workflow: str, + runtime: Dict[str, str], ) -> List[str]: return [ - DEFAULT_CONDA_WSL, + runtime["conda_bin_wsl"], "run", "-n", - "isce2", + runtime["env_name"], "python", - DEFAULT_STACK_SCRIPT_WSL, + runtime["stack_script_wsl"], "-s", slc_dir_wsl, "-d", @@ -226,7 +261,7 @@ def render_stack_command( "-u", "snaphu", "-c", - DEFAULT_STACK_TEXT_CMD, + runtime["stack_text_cmd"], ] @@ -341,6 +376,7 @@ def render_run_script(report: Dict[str, Any]) -> str: reference_date = report["reference_date"] dates = " ".join(scene["date"] for scene in report["scenes"]) command = report["stack_command"]["shell"] + runtime = report["runtime"] return f"""#!/usr/bin/env bash set -euo pipefail @@ -349,8 +385,10 @@ SLC_DIR={shell_quote(slc_dir)} WORK_DIR={shell_quote(work_dir)} DEM={shell_quote(dem_path)} REFERENCE_DATE={shell_quote(reference_date)} -ISCE2_SHARE={shell_quote(DEFAULT_ISCE2_SHARE_WSL)} -STRIPMAP_STACK_DIR={shell_quote(DEFAULT_STRIPMAP_STACK_DIR_WSL)} +CONDA_ENV={shell_quote(runtime["env_name"])} +CONDA_BIN={shell_quote(runtime["conda_bin_wsl"])} +ISCE2_SHARE={shell_quote(runtime["isce2_share_wsl"])} +STRIPMAP_STACK_DIR={shell_quote(runtime["stripmap_stack_dir_wsl"])} DATES=({dates}) export PYTHONPATH="$STRIPMAP_STACK_DIR:$ISCE2_SHARE${{PYTHONPATH:+:$PYTHONPATH}}" @@ -361,6 +399,8 @@ echo "SLC root: $SLC_DIR" echo "Work dir: $WORK_DIR" echo "DEM: $DEM" echo "Reference date: $REFERENCE_DATE" +echo "Conda env: $CONDA_ENV" +echo "Conda bin: $CONDA_BIN" echo "PYTHONPATH: $PYTHONPATH" echo "PATH prefix: $STRIPMAP_STACK_DIR" @@ -436,6 +476,7 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() env_values = ISCE2_INPUT_HELPER.load_env_file(REPO_ROOT / ".env") + runtime = _resolve_runtime_paths(env_values) manifest_path = Path(args.manifest_path).resolve() require_file(manifest_path, "stack manifest") @@ -506,6 +547,7 @@ def main() -> int: work_dir_wsl=windows_to_wsl(stack_work_dir), reference_date=manifest["reference_date"], workflow=args.workflow, + runtime=runtime, ) blockers = build_blockers(scene_plans, orbit_pool=orbit_pool, dem_path=dem_path) @@ -530,6 +572,7 @@ def main() -> int: "processing_workflow": args.workflow, "sensor_name": "LUTAN1", "stack_driver": "isce2.stripmapStack.stackStripMap", + "runtime": runtime, "workspace": { "root_windows": str(scratch_root), "root_wsl": windows_to_wsl(scratch_root), diff --git a/experiments/isce2_sbas_timeseries/scripts/run_generated_stack_runfile_ubuntu2404.sh b/experiments/isce2_sbas_timeseries/scripts/run_generated_stack_runfile_ubuntu2404.sh index 3a43675..ab015c5 100644 --- a/experiments/isce2_sbas_timeseries/scripts/run_generated_stack_runfile_ubuntu2404.sh +++ b/experiments/isce2_sbas_timeseries/scripts/run_generated_stack_runfile_ubuntu2404.sh @@ -10,9 +10,10 @@ fi SCRATCH_ROOT="$1" RUN_FILE_NAME="$2" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CONDA_BIN="${CONDA_BIN:-/home/administrator/miniconda3/bin/conda}" -CONDA_ENV="${CONDA_ENV:-isce2}" -ISCE2_SHARE="${ISCE2_SHARE:-/home/administrator/miniconda3/envs/isce2/share/isce2}" +CONDA_ROOT="${CONDA_ROOT:-/home/administrator/miniconda3}" +CONDA_ENV="${CONDA_ENV:-insar_wsl_v1}" +CONDA_BIN="${CONDA_BIN:-$CONDA_ROOT/bin/conda}" +ISCE2_SHARE="${ISCE2_SHARE:-$CONDA_ROOT/envs/$CONDA_ENV/share/isce2}" STRIPMAP_STACK_DIR="${STRIPMAP_STACK_DIR:-$ISCE2_SHARE/stripmapStack}" SYNTHETIC_WATERMASK_SCRIPT="${SYNTHETIC_WATERMASK_SCRIPT:-$SCRIPT_DIR/create_synthetic_watermask.py}" ALLOW_SYNTHETIC_WATERMASK="${ALLOW_SYNTHETIC_WATERMASK:-1}" @@ -84,6 +85,8 @@ echo "Scratch root: $SCRATCH_ROOT" echo "Run file: $RUN_FILE" echo "Log file: $LOG_FILE" echo "Conda env: $CONDA_ENV" +echo "Conda bin: $CONDA_BIN" +echo "ISCE2 share: $ISCE2_SHARE" echo "PYTHONPATH: $PYTHONPATH" echo "PATH prefix: $STRIPMAP_STACK_DIR" diff --git a/frontend/src/App.css b/frontend/src/App.css index 673d1b4..465fed4 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -917,6 +917,16 @@ input[type="checkbox"] { font-size: 0.8em; padding: 3px 8px; } +.ps-stack-preview-actions { + display: flex; + gap: 6px; + flex: 0 0 auto; +} +.preview-button-secondary { + background: var(--color-panel-muted); + color: var(--color-text-primary); + border: 1px solid var(--color-border); +} .ps-stack-list { display: flex; flex-direction: column; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c227b63..b4bf220 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -276,6 +276,8 @@ function App() { const activeLayersRef = useRef({}); const radarPreviewLayersRef = useRef({}); const pairLayersRef = useRef({}); + const psStackPreviewLayerRef = useRef(null); + const psStackPreviewStateRef = useRef({ previousVisibilityById: new Map() }); const hazardLayersGroupRef = useRef(null); const aoeLayerRef = useRef(null); const waterSceneLayersRef = useRef({}); @@ -394,6 +396,11 @@ function App() { aoeLayerRef.current.remove(); aoeLayerRef.current = null; } + if (psStackPreviewLayerRef.current) { + psStackPreviewLayerRef.current.remove(); + psStackPreviewLayerRef.current = null; + } + psStackPreviewStateRef.current = { previousVisibilityById: new Map() }; setAoiLayer(null); Object.values(activeLayersRef.current).forEach(layer => layer.remove()); @@ -938,10 +945,59 @@ function App() { } }, [aoiLayer]); + const restorePsStackPreviewVisibility = () => { + const previousVisibilityById = psStackPreviewStateRef.current?.previousVisibilityById; + if (!(previousVisibilityById instanceof Map) || previousVisibilityById.size === 0) { + psStackPreviewStateRef.current = { previousVisibilityById: new Map() }; + return false; + } + + let changed = false; + const currentData = allDataRef.current; + const restoredData = currentData.map(item => { + if (!previousVisibilityById.has(item.id)) { + return item; + } + const shouldBeVisible = previousVisibilityById.get(item.id); + if (item.isVisible !== shouldBeVisible) { + updateLayerVisibility(item, shouldBeVisible); + changed = true; + return { ...item, isVisible: shouldBeVisible }; + } + return item; + }); + + if (changed) { + allDataRef.current = restoredData; + setAllData(restoredData); + } + psStackPreviewStateRef.current = { previousVisibilityById: new Map() }; + return changed; + }; + + const clearPsStackPreview = ({ silent = false } = {}) => { + const hadLayer = !!psStackPreviewLayerRef.current; + if (psStackPreviewLayerRef.current) { + psStackPreviewLayerRef.current.remove(); + psStackPreviewLayerRef.current = null; + } + const restoredVisibility = restorePsStackPreviewVisibility(); + if (silent) { + return; + } + if (!hadLayer && !restoredVisibility) { + addLog('info', '当前没有打开的时序候选栈预览范围。'); + return; + } + addLog('info', '已关闭时序候选栈预览范围。'); + }; + const previewPsStack = (stack) => { cancelMapBatch(); + clearPsStackPreview({ silent: true }); const stackIds = new Set(stack.map(img => img.id)); const currentData = allDataRef.current; + const previousVisibilityById = new Map(currentData.map(item => [item.id, item.isVisible])); const newAllData = currentData.map(item => { const shouldBeVisible = stackIds.has(item.id); if (item.isVisible !== shouldBeVisible) { @@ -952,7 +1008,63 @@ function App() { }); allDataRef.current = newAllData; setAllData(newAllData); - addLog('info', `正在预览包含 ${stack.length} 个场景的时序InSAR候选栈。`); + psStackPreviewStateRef.current = { previousVisibilityById }; + + if (!mapRef.current) { + clearPsStackPreview({ silent: true }); + addLog('warn', '地图尚未就绪,无法预览时序候选栈范围。'); + return; + } + + const previewGroup = L.layerGroup(); + const allLatLngs = []; + const palette = ['#f59e0b', '#06b6d4', '#a855f7', '#22c55e', '#ef4444', '#3b82f6']; + let validSceneCount = 0; + + stack.forEach((scene, index) => { + const polygon = scene.coverage_polygon; + if (!Array.isArray(polygon) || polygon.length < 3) { + return; + } + const latLngs = polygon + .filter((point) => Array.isArray(point) && point.length >= 2) + .map((point) => [Number(point[1]), Number(point[0])]) + .filter(([lat, lon]) => Number.isFinite(lat) && Number.isFinite(lon)); + if (latLngs.length < 3) { + return; + } + + validSceneCount += 1; + allLatLngs.push(...latLngs); + const color = palette[index % palette.length]; + const scenePolygon = L.polygon(latLngs, { + color, + weight: 3, + opacity: 0.95, + fillColor: color, + fillOpacity: 0.08, + dashArray: scene.stack_selection_mode === 'pairwise_sbas_network' ? '8, 5' : null, + }); + scenePolygon.bindPopup( + `时序候选场景
` + + `ID: ${escapeHtml(scene.id)}
` + + `日期: ${escapeHtml(scene.imaging_date || '-')}
` + + `卫星: ${escapeHtml(scene.satellite || '-')}
` + + `模式: ${escapeHtml(scene.imaging_mode || '-')} / ${escapeHtml(scene.polarization || '-')}` + ); + previewGroup.addLayer(scenePolygon); + }); + + if (validSceneCount === 0 || allLatLngs.length < 3) { + clearPsStackPreview({ silent: true }); + addLog('warn', `时序候选栈包含 ${stack.length} 个场景,但没有可绘制的覆盖范围。`); + return; + } + + previewGroup.addTo(mapRef.current); + psStackPreviewLayerRef.current = previewGroup; + mapRef.current.fitBounds(L.latLngBounds(allLatLngs), { padding: [50, 50], maxZoom: 10 }); + addLog('info', `正在预览包含 ${stack.length} 个场景的时序InSAR候选栈,已绘制 ${validSceneCount} 个覆盖范围。`); }; const updateLayerTooltip = useCallback((layer, result, show) => { @@ -1560,7 +1672,9 @@ function App() { }; const psPanel = { onPreviewPsStack: previewPsStack, + onClearPsStackPreview: clearPsStackPreview, onCreatePsBatch: createPsBatch, + onSendToTimeseriesProduction: (direction, stack) => createPsBatch(direction, stack, { sendToProduction: true }), onClearPsResults: clearPsResults, }; diff --git a/frontend/src/DinsarProductionPanel.jsx b/frontend/src/DinsarProductionPanel.jsx index ac1cc74..ed9e2fb 100644 --- a/frontend/src/DinsarProductionPanel.jsx +++ b/frontend/src/DinsarProductionPanel.jsx @@ -239,6 +239,20 @@ function buildExtraPayload(schema, values) { return payload; } +function buildParamSections(schema) { + const sections = []; + const indexByTitle = new Map(); + Object.entries(schema || {}).forEach(([name, item]) => { + const title = item.section || '处理参数'; + if (!indexByTitle.has(title)) { + indexByTitle.set(title, sections.length); + sections.push({ title, items: [] }); + } + sections[indexByTitle.get(title)].items.push([name, item]); + }); + return sections; +} + function ParamField({ name, schema, value, disabled, onChange }) { const label = schema.label || name; const description = schema.description || ''; @@ -283,6 +297,41 @@ function ParamField({ name, schema, value, disabled, onChange }) { ); } + if (Array.isArray(schema.enum) && schema.enum.length > 0) { + return ( +
+
+ + {isReadonly && ( + + 固定值 + + )} +
+ + {description &&
{description}
} + {recommendation &&
推荐:{recommendation}
} +
+ ); + } + return (
@@ -350,10 +399,13 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued }) const currentProfiles = currentEngineObj?.profiles || EMPTY_ARRAY; const currentProfileObj = currentProfiles.find(profile => profile.code === selectedProfile) || null; const currentParamSchema = currentProfileObj?.params_schema || EMPTY_OBJECT; + const currentParamSections = buildParamSections(currentParamSchema); const currentDefaultTimeoutSec = Number(currentEngineObj?.default_timeout_seconds || 0) || 0; const currentParamHelpText = selectedEngine === 'pyint' ? '这些参数影响 PyINT 的多视、并行度以及是否执行解缠/地理编码。建议先直接使用默认值,优先确认当前任务目录里的 LT-1 原始压缩包是否能被正常识别。' - : '这些参数主要影响目标网格大小、精裁剪范围、地理编码范围和位移结果掩膜。建议先使用默认值,通常优先只调整目标网格大小;只有在边缘被裁切、时间窗异常或噪声较多时,再继续调整其他参数。'; + : selectedEngine === 'isce2' + ? '这些参数现在按执行、交付、增强分组展示。结果异常时,优先尝试关闭增强项,再回看基础几何和配对质量。' + : '这些参数影响当前引擎的生产模板。建议先使用默认值,只有在结果边界、噪声或几何表现异常时再逐项调整。'; const pyintPreviewBlocksSubmit = selectedEngine === 'pyint' && pyintPreview && pyintPreview.allow_submit === false; const latestRunWithTask = runs.find(run => run?.task_id) || null; const monitoredTask = activeTask || ( @@ -935,15 +987,24 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued }) {currentParamHelpText}
- {Object.entries(currentParamSchema).map(([name, schema]) => ( - + {currentParamSections.map(section => ( +
+
+ {section.title} +
+
+ {section.items.map(([name, schema]) => ( + + ))} +
+
))}
diff --git a/frontend/src/TimeseriesProductionPanel.jsx b/frontend/src/TimeseriesProductionPanel.jsx index f7dfb8e..bec50b2 100644 --- a/frontend/src/TimeseriesProductionPanel.jsx +++ b/frontend/src/TimeseriesProductionPanel.jsx @@ -1,10 +1,14 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { getPsBatches } from './api/taskBatches'; +import { useBatchStore } from './store'; import { createTimeseriesRun, getTimeseriesRunDetail, listTimeseriesRuns, + retryTimeseriesStep, + runTimeseriesPreflight, + runTimeseriesWslCheck, } from './api/timeseriesProduction'; const card = { @@ -75,7 +79,75 @@ function StatusPill({ value }) { ); } +function formatBytes(value) { + const size = Number(value || 0); + if (!Number.isFinite(size) || size <= 0) return '-'; + if (size < 1024) return `${size} B`; + const units = ['KB', 'MB', 'GB', 'TB']; + let current = size / 1024; + let unitIndex = 0; + while (current >= 1024 && unitIndex < units.length - 1) { + current /= 1024; + unitIndex += 1; + } + return `${current.toFixed(current >= 100 ? 0 : 1)} ${units[unitIndex]}`; +} + +function QualityBadge({ ok, okLabel = '通过', failLabel = '失败' }) { + const color = ok ? '#166534' : '#991b1b'; + const background = ok ? '#f0fdf4' : '#fef2f2'; + const border = ok ? '#bbf7d0' : '#fecaca'; + return ( + + + {ok ? okLabel : failLabel} + + ); +} + +function JsonBlock({ value }) { + return ( +
+      {JSON.stringify(value || {}, null, 2)}
+    
+ ); +} + export default function TimeseriesProductionPanel({ readOnly = false, onJobQueued }) { + const pendingTimeseriesBatchId = useBatchStore(state => state.pendingTimeseriesBatchId); + const setPendingTimeseriesBatchId = useBatchStore(state => state.setPendingTimeseriesBatchId); const [batches, setBatches] = useState([]); const [runs, setRuns] = useState([]); const [selectedBatchId, setSelectedBatchId] = useState(''); @@ -89,6 +161,11 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue const [referenceDate, setReferenceDate] = useState(''); const [waterMaskMode, setWaterMaskMode] = useState('synthetic_fallback'); const [notes, setNotes] = useState(''); + const [wslChecking, setWslChecking] = useState(false); + const [wslReport, setWslReport] = useState(null); + const [preflightLoading, setPreflightLoading] = useState(false); + const [preflightReport, setPreflightReport] = useState(null); + const [retryingStepId, setRetryingStepId] = useState(''); const selectedBatch = useMemo( () => batches.find(item => item.batch_id === selectedBatchId) || null, @@ -100,11 +177,16 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue const data = await getPsBatches(); const nextItems = Array.isArray(data) ? data : []; setBatches(nextItems); - setSelectedBatchId(current => current || nextItems[0]?.batch_id || ''); + setSelectedBatchId(current => { + if (pendingTimeseriesBatchId && nextItems.some(item => item.batch_id === pendingTimeseriesBatchId)) { + return pendingTimeseriesBatchId; + } + return current || nextItems[0]?.batch_id || ''; + }); } catch { setBatches([]); } - }, []); + }, [pendingTimeseriesBatchId]); const loadRuns = useCallback(async () => { setLoading(true); @@ -138,6 +220,72 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue } }, []); + const handleWslCheck = useCallback(async () => { + setWslChecking(true); + try { + const report = await runTimeseriesWslCheck(); + setWslReport(report); + } catch (error) { + setWslReport({ + overall_ok: false, + message: error?.response?.data?.detail || error.message || 'WSL 检查失败', + checks: [], + }); + } finally { + setWslChecking(false); + } + }, []); + + const handleRetryStep = useCallback(async stepId => { + if (!selectedRunId || !stepId) return; + setRetryingStepId(stepId); + setMessage(''); + try { + await retryTimeseriesStep(selectedRunId, { step_id: stepId }); + setMessage(`已重新入队:${selectedRunId} / ${stepId}`); + await loadRuns(); + await loadRunDetail(selectedRunId); + } catch (error) { + setMessage(error?.response?.data?.detail || error.message || '重试失败'); + } finally { + setRetryingStepId(''); + } + }, [loadRunDetail, loadRuns, selectedRunId]); + + const handlePreflight = useCallback(async () => { + if (!selectedBatchId) { + setPreflightReport({ + overall_ok: false, + errors: ['请先选择一个时序批次。'], + warnings: [], + checks: [], + summary: {}, + }); + return; + } + setPreflightLoading(true); + try { + const report = await runTimeseriesPreflight({ + batch_id: selectedBatchId, + reference_date: referenceDate.trim() || null, + water_mask_mode: waterMaskMode, + }); + setPreflightReport(report); + } catch (error) { + const detail = error?.response?.data?.detail || error.message || '预检失败'; + setPreflightReport({ + overall_ok: false, + batch_id: selectedBatchId, + errors: [detail], + warnings: [], + checks: [], + summary: {}, + }); + } finally { + setPreflightLoading(false); + } + }, [referenceDate, selectedBatchId, waterMaskMode]); + useEffect(() => { loadBatches(); loadRuns(); @@ -152,6 +300,20 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue loadRunDetail(selectedRunId); }, [loadRunDetail, selectedRunId]); + useEffect(() => { + if ( + pendingTimeseriesBatchId && + batches.some(item => item.batch_id === pendingTimeseriesBatchId) && + selectedBatchId === pendingTimeseriesBatchId + ) { + setPendingTimeseriesBatchId(''); + } + }, [batches, pendingTimeseriesBatchId, selectedBatchId, setPendingTimeseriesBatchId]); + + useEffect(() => { + setPreflightReport(null); + }, [selectedBatchId, referenceDate, waterMaskMode]); + const handleSubmit = async () => { if (!selectedBatchId) { setMessage('请先选择一个时序批次。'); @@ -168,6 +330,7 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue notes: notes.trim() || null, }); setMessage(`运行已入队:${result.run_id} / task=${result.task_id}`); + setSelectedRunId(result.run_id); onJobQueued?.(result.task_id); await loadRuns(); } catch (error) { @@ -180,11 +343,34 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue const runData = selectedRunDetail?.run || null; const linkedProduct = selectedRunDetail?.product || null; const workflowSteps = selectedRunDetail?.workflow?.steps || []; + const preflightChecks = Array.isArray(preflightReport?.checks) ? preflightReport.checks : []; + const preflightErrors = Array.isArray(preflightReport?.errors) ? preflightReport.errors : []; + const preflightWarnings = Array.isArray(preflightReport?.warnings) ? preflightReport.warnings : []; + const preflightSummary = preflightReport?.summary || {}; + const runPreflightQuality = runData?.quality_summary_json?.preflight || null; + const runPublishValidation = runData?.quality_summary_json?.publish_validation || null; return (
- 时序InSAR 运行入口 +
+ 时序InSAR 运行入口 + +
+ {wslReport && ( +
+
+ {wslReport.overall_ok ? 'WSL运行时正常' : 'WSL运行时存在问题'} +
+
{wslReport.message || '-'}
+
+ {(Array.isArray(wslReport.checks) ? wslReport.checks : []).slice(0, 8).map(check => ( +
+ {check.ok ? 'OK' : 'FAIL'} {check.name} + {check.detail ? `: ${check.detail}` : ''} +
+ ))} +
+
+ )}
@@ -277,6 +489,8 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue {selectedBatch && (
+
Stack Plan:{selectedBatch.plan_id || '-'}
+
Plan Strategy:{selectedBatch.plan_strategy || '-'}
方向:{selectedBatch.direction || '-'}
影像数:{selectedBatch.total_items || 0}
批次状态:{selectedBatch.status || '-'}
@@ -284,7 +498,22 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
)} -
+
+
+ + {preflightReport && ( +
+
+
+ + {preflightReport.overall_ok ? '预检通过' : '预检发现问题'} + + +
+
+ 错误 {preflightErrors.length} / 告警 {preflightWarnings.length} +
+
+ +
+
+
有效参考日期
+ {preflightReport.reference_date_effective || '-'} +
+
+
场景规模
+ {preflightSummary.scene_count || 0} 景 +
+
+
Stack Key
+ {preflightSummary.stack_key || '-'} +
+
+
数据量
+ {formatBytes(preflightSummary.total_scene_bytes)} +
+
+ +
+
Stack Plan:{preflightReport.plan_id || preflightSummary.plan_id || '-'}
+
Plan Strategy:{preflightReport.plan_strategy || preflightSummary.plan_strategy || '-'}
+
批次:{preflightReport.batch_name || preflightReport.batch_id || '-'}
+
批次状态:{preflightReport.batch_status || '-'}
+
水体掩膜:{preflightReport.water_mask_mode || '-'}
+
分组:{preflightSummary.group_key || '-'}
+
源目录:{preflightSummary.source_root_windows || '-'}
+
日期列表:{(preflightSummary.stack_dates || []).join(', ') || '-'}
+
+ + {preflightErrors.length > 0 && ( +
+
错误
+
+ {preflightErrors.map((item, index) => ( +
{item}
+ ))} +
+
+ )} + + {preflightWarnings.length > 0 && ( +
+
告警
+
+ {preflightWarnings.map((item, index) => ( +
{item}
+ ))} +
+
+ )} + + {preflightChecks.length > 0 && ( +
+
检查项
+
+ {preflightChecks.map(check => { + const accent = check.ok ? '#166534' : check.severity === 'warn' ? '#92400e' : '#991b1b'; + return ( +
+
+ + {check.ok ? 'OK' : check.severity === 'warn' ? 'WARN' : 'FAIL'} / {check.name} + + {check.skipped ? skipped : null} +
+
{check.detail || '-'}
+
+ ); + })} +
+
+ )} +
+ )}
@@ -377,6 +714,8 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue {runData?.run_name || '-'}
+
Stack Plan:{runData?.plan_id || '-'}
+
Plan Strategy:{runData?.plan_strategy || '-'}
运行标识:{runData?.run_id || '-'}
批次标识:{runData?.batch_id || '-'}
参考日期:{runData?.reference_date || '-'}
@@ -392,7 +731,54 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
创建时间:{formatDateTime(runData?.created_at)}
结束时间:{formatDateTime(runData?.ended_at)}
输入日期:{(runData?.input_snapshot_json?.stack_dates || []).join(', ') || '-'}
-
轨道摘要:{JSON.stringify(runData?.orbit_summary_json || {}, null, 2)}
+
+ 轨道摘要: +
+ +
+
+ {(runPreflightQuality || runPublishValidation) && ( +
+
运行质量
+
+ {runPreflightQuality && ( +
+
+ 预检记录 + +
+
有效参考日期:{runPreflightQuality.reference_date_effective || '-'}
+
错误数:{(runPreflightQuality.errors || []).length}
+
告警数:{(runPreflightQuality.warnings || []).length}
+
+ 查看预检详情 +
+ +
+
+
+ )} + {runPublishValidation && ( +
+
+ 发布校验 + +
+
主资产角色:{runPublishValidation.primary_role || '-'}
+
预览角色:{runPublishValidation.preview_role || '-'}
+
缺失角色:{(runPublishValidation.missing_roles || []).length}
+
缺失文件:{(runPublishValidation.missing_paths || []).length}
+
+ 查看发布校验详情 +
+ +
+
+
+ )} +
+
+ )} {workflowSteps.length > 0 && (
工作流步骤
@@ -423,8 +809,25 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
)}
-
+
+ {!readOnly && step.status === 'FAILED' && ( + + )}
))} diff --git a/frontend/src/api/timeseriesProduction.js b/frontend/src/api/timeseriesProduction.js index cac5e16..6f3b4a6 100644 --- a/frontend/src/api/timeseriesProduction.js +++ b/frontend/src/api/timeseriesProduction.js @@ -3,8 +3,17 @@ import apiClient from './client'; export const createTimeseriesRun = payload => apiClient.post('/timeseries-production/runs', payload).then(r => r.data); +export const runTimeseriesWslCheck = (payload = {}) => + apiClient.post('/timeseries-production/wsl-check', payload).then(r => r.data); + +export const runTimeseriesPreflight = payload => + apiClient.post('/timeseries-production/preflight', payload).then(r => r.data); + export const listTimeseriesRuns = (params = {}) => apiClient.get('/timeseries-production/runs', { params }).then(r => r.data); export const getTimeseriesRunDetail = runId => apiClient.get(`/timeseries-production/runs/${encodeURIComponent(runId)}`).then(r => r.data); + +export const retryTimeseriesStep = (runId, payload) => + apiClient.post(`/timeseries-production/runs/${encodeURIComponent(runId)}/retry-step`, payload).then(r => r.data); diff --git a/frontend/src/components/PsStackModal.jsx b/frontend/src/components/PsStackModal.jsx index bb9416e..e3c060a 100644 --- a/frontend/src/components/PsStackModal.jsx +++ b/frontend/src/components/PsStackModal.jsx @@ -1,6 +1,19 @@ import { usePairingStore, useUiStore, useAuthStore } from '../store'; import { getSelectedRegionTreeId } from '../utils/appUiHelpers'; +const PARAM_METADATA = { + initial_overlap_threshold: { + label: '单景 AOI 覆盖率', + title: '单景影像覆盖 AOI 的最低比例。0.30 表示影像至少覆盖 AOI 面积的 30%。', + hint: '先过滤明显不覆盖研究区的影像;AOI 很大时可适当降低。', + }, + final_overlap_threshold: { + label: '栈覆盖一致性', + title: '最终候选栈的公共覆盖区 / 栈内最小单景 AOI 覆盖区。0.95 表示栈内场景覆盖范围基本一致,不要求覆盖整个行政区。', + hint: '控制时序栈内部覆盖稳定性;若同轨同模式影像仍被过滤,可尝试 0.85-0.90。', + }, +}; + function PsStackModal({ onSubmit, onAoiModeChange, @@ -110,12 +123,8 @@ function PsStackModal({ )} {Object.entries(psParams).map(([key, value]) => (
-
))}
diff --git a/frontend/src/components/PsinsarCatalogPanel.jsx b/frontend/src/components/PsinsarCatalogPanel.jsx index 28b3754..97615b8 100644 --- a/frontend/src/components/PsinsarCatalogPanel.jsx +++ b/frontend/src/components/PsinsarCatalogPanel.jsx @@ -329,9 +329,14 @@ export default function PsinsarCatalogPanel({
+
Stack Plan:{selectedProduct.plan_id || '-'}
+
Plan Strategy:{selectedProduct.plan_strategy || '-'}
名称:{selectedProduct.display_name || '-'}
产品编号:{selectedProduct.product_id || '-'}
-
运行标识:{selectedProduct.run_key || '-'}
+
运行标识:{selectedProduct.run_id || selectedProduct.run_key || '-'}
+
来源批次:{selectedProduct.batch_id || '-'}
+
任务标识:{selectedProduct.task_id || '-'}
+
工作流标识:{selectedProduct.workflow_run_id || '-'}
参考日期:{selectedProduct.reference_date || '-'}
影像数:{selectedProduct.stack_size || 0}
引擎:{selectedProduct.engine_code || '-'}
@@ -349,6 +354,21 @@ export default function PsinsarCatalogPanel({
主展示产物:{selectedProduct.primary_asset_path || '-'}
+ {selectedProduct.source_summary && ( +
+
来源追踪
+
+
Stack Plan:{selectedProduct.source_summary?.plan_id || selectedProduct.plan_id || '-'}
+
Plan Strategy:{selectedProduct.source_summary?.plan_strategy || selectedProduct.plan_strategy || '-'}
+
规划来源:{selectedProduct.source_summary?.planning_context?.source || '-'}
+
规划策略:{selectedProduct.source_summary?.planning_context?.strategy || '-'}
+
场景数:{selectedProduct.source_summary?.planning_context?.scene_count || '-'}
+
工作目录:{selectedProduct.source_summary?.work_dir || '-'}
+
原生输出:{selectedProduct.source_summary?.native_output_dir || '-'}
+
+
+ )} +
质量摘要
}>
                     
                 
diff --git a/frontend/src/components/panels/PsStackSection.jsx b/frontend/src/components/panels/PsStackSection.jsx
index 650b68a..5070035 100644
--- a/frontend/src/components/panels/PsStackSection.jsx
+++ b/frontend/src/components/panels/PsStackSection.jsx
@@ -17,8 +17,20 @@ function PsStackSection({
     stack,
     isReadOnlyUser,
     onPreviewPsStack,
+    onClearPsStackPreview,
     onCreatePsBatch,
+    onSendToTimeseriesProduction,
 }) {
+    const planMeta = stack[0] || {};
+    const commonAoiRatio = planMeta.stack_common_aoi_coverage_ratio == null
+        ? NaN
+        : Number(planMeta.stack_common_aoi_coverage_ratio);
+    const consistencyRatio = planMeta.stack_coverage_consistency_ratio == null
+        ? NaN
+        : Number(planMeta.stack_coverage_consistency_ratio);
+    const selectionModeLabel = planMeta.stack_selection_mode === 'pairwise_sbas_network'
+        ? 'SBAS网络'
+        : (planMeta.stack_selection_mode === 'common_overlap' ? '公共栈' : '');
     const viewportHeight = useMemo(
         () => Math.min(Math.max(stack.length * PS_STACK_ROW_HEIGHT, PS_STACK_ROW_HEIGHT), PS_STACK_MAX_HEIGHT),
         [stack.length]
@@ -27,8 +39,28 @@ function PsStackSection({
     return (
         
-

{direction} ({stack.length} scenes)

- +
+

{direction} ({stack.length} scenes)

+ {(planMeta.stack_plan_id || planMeta.stack_key) && ( +
+ {planMeta.stack_plan_id ? `plan=${planMeta.stack_plan_id}` : ''} + {planMeta.stack_plan_id && planMeta.stack_key ? ' / ' : ''} + {planMeta.stack_key ? `stack=${planMeta.stack_key}` : ''} +
+ )} + {(Number.isFinite(consistencyRatio) || Number.isFinite(commonAoiRatio)) && ( +
+ {selectionModeLabel ? `${selectionModeLabel} / ` : ''} + {Number.isFinite(consistencyRatio) ? `一致性 ${(consistencyRatio * 100).toFixed(1)}%` : ''} + {Number.isFinite(consistencyRatio) && Number.isFinite(commonAoiRatio) ? ' / ' : ''} + {Number.isFinite(commonAoiRatio) ? `公共AOI ${(commonAoiRatio * 100).toFixed(1)}%` : ''} +
+ )} +
+
+ + +
onCreatePsBatch(direction, stack)} disabled={isReadOnlyUser}> 保存批次 +
); diff --git a/frontend/src/hooks/usePairingLogic.js b/frontend/src/hooks/usePairingLogic.js index ca752e4..081355d 100644 --- a/frontend/src/hooks/usePairingLogic.js +++ b/frontend/src/hooks/usePairingLogic.js @@ -26,7 +26,7 @@ export default function usePairingLogic({ psParams, setShowPsModal, setPsResults, } = usePairingStore(); const { setAoiLayer } = useMapStore(); - const { setBatchTab, setSelectedBatchId, setBatchItems } = useBatchStore(); + const { setBatchTab, setSelectedBatchId, setBatchItems, setPendingTimeseriesBatchId } = useBatchStore(); const { currentUser } = useAuthStore(); const isAdmin = currentUser?.role === 'admin'; @@ -51,15 +51,48 @@ export default function usePairingLogic({ const createPsBatch = async (direction, stack, options = {}) => { if (!ensureCanOperate()) return; - const { focusAfterCreate = true } = options; + const { focusAfterCreate = true, sendToProduction = false } = options; + if (!Array.isArray(stack) || stack.length < 3) { + addLog('warn', `当前候选栈仅 ${Array.isArray(stack) ? stack.length : 0} 景,SBAS 至少需要 3 景。`); + return; + } + const firstScene = stack[0] || {}; + const planId = firstScene.stack_plan_id || null; + const batchDirection = firstScene.orbit_direction || direction; + const planningContext = { + source: planId ? 'timeseries_stack_plan' : 'find_ps_timeseries', + plan_id: planId, + strategy: 'sbas_stack', + direction: batchDirection, + display_group: direction, + scene_count: stack.length, + group_key: firstScene.stack_group_key || null, + stack_key: firstScene.stack_key || null, + initial_overlap_threshold: psParams?.initial_overlap_threshold ?? null, + final_overlap_threshold: psParams?.final_overlap_threshold ?? null, + stack_dates: stack.map(item => item.imaging_date).filter(Boolean), + }; try { const response = await apiClient.post('/task-batches/ps', { - direction, + direction: batchDirection, + plan_id: planId, stack, - name: `PS_${direction}_${new Date().toISOString().slice(0, 10)}` + name: `TS_${batchDirection}_${new Date().toISOString().slice(0, 10)}`, + planning_context: planningContext, }); const batchId = response.data?.batch_id || ''; - addLog('success', `已创建时序批次: ${batchId || direction}`); + if (planId && batchId) { + addLog('info', `时序批次已关联候选栈计划 ${planId}`); + } + addLog('success', `已创建时序批次: ${batchId || batchDirection}`); + if (batchId && sendToProduction) { + setBatchTab('ps'); + setSelectedBatchId(batchId); + setPendingTimeseriesBatchId(batchId); + setLeftPanelTab('ps_production'); + addLog('info', `已将批次 ${batchId} 送入时序生产入口。`); + return; + } if (focusAfterCreate && batchId) { await focusBatchAfterCreate('ps', batchId); } @@ -264,7 +297,7 @@ export default function usePairingLogic({ await createPsBatch(direction, stack, { focusAfterCreate: false }); } } else { - addLog('info', '在给定的AOI和阈值下,未找到合适的时序影像栈。'); + addLog('info', '在给定的AOI和阈值下,未找到满足 SBAS 至少 3 景要求的时序影像栈。'); setLeftPanelTab('ps_results'); } } catch (error) { diff --git a/frontend/src/i18n/translations.js b/frontend/src/i18n/translations.js index 9dec838..1df99b2 100644 --- a/frontend/src/i18n/translations.js +++ b/frontend/src/i18n/translations.js @@ -140,7 +140,7 @@ // PairingPanel { zh: '基于时间基线、空间基线与重叠率筛选干涉对,可选 AOI 限定范围。', en: 'Filter interferometric pairs by temporal baseline, spatial baseline, and overlap ratio. Optional AOI constraint.' }, { zh: '配对', en: 'Pair' }, - { zh: 'PS准备', en: 'PS Prep' }, + { zh: '时序准备', en: 'Timeseries Prep' }, { zh: '结果与刷新', en: 'Results & Refresh' }, { zh: '已生成配对', en: 'Generated Pairs' }, { zh: '已选中', en: 'Selected' }, diff --git a/frontend/src/panels/PairPlanningPanel.jsx b/frontend/src/panels/PairPlanningPanel.jsx index 7e381a1..12a0896 100644 --- a/frontend/src/panels/PairPlanningPanel.jsx +++ b/frontend/src/panels/PairPlanningPanel.jsx @@ -115,7 +115,7 @@ export default function PairPlanningPanel({ {en ? 'Pair' : '配对'}
diff --git a/frontend/src/panels/PairingPanel.jsx b/frontend/src/panels/PairingPanel.jsx index 93566cc..ba48a0b 100644 --- a/frontend/src/panels/PairingPanel.jsx +++ b/frontend/src/panels/PairingPanel.jsx @@ -28,7 +28,7 @@ export default function PairingPanel({ {en ? 'Pair' : '配对'} diff --git a/frontend/src/panels/PsResultsPanel.jsx b/frontend/src/panels/PsResultsPanel.jsx index 27edfe7..54c323e 100644 --- a/frontend/src/panels/PsResultsPanel.jsx +++ b/frontend/src/panels/PsResultsPanel.jsx @@ -5,7 +5,9 @@ import PsStackSection from '../components/panels/PsStackSection'; function PsResultsPanel({ onPreviewPsStack, + onClearPsStackPreview, onCreatePsBatch, + onSendToTimeseriesProduction, onClearPsResults, }) { const { psResults } = usePairingStore(useShallow((state) => ({ @@ -18,7 +20,7 @@ function PsResultsPanel({ return (
{psStacks.length === 0 ? ( -

未找到时序InSAR候选栈。

+

未找到满足 SBAS 至少 3 景要求的时序InSAR候选栈。

) : ( <>
@@ -32,7 +34,9 @@ function PsResultsPanel({ stack={stack} isReadOnlyUser={isReadOnlyUser} onPreviewPsStack={onPreviewPsStack} + onClearPsStackPreview={onClearPsStackPreview} onCreatePsBatch={onCreatePsBatch} + onSendToTimeseriesProduction={onSendToTimeseriesProduction} /> ))}
diff --git a/frontend/src/store/batchStore.js b/frontend/src/store/batchStore.js index 3c20a38..4b6edfc 100644 --- a/frontend/src/store/batchStore.js +++ b/frontend/src/store/batchStore.js @@ -8,6 +8,7 @@ export const useBatchStore = create((set) => ({ dinsarBatches: [], psBatches: [], selectedBatchId: '', + pendingTimeseriesBatchId: '', batchItems: [], batchLoading: false, batchError: '', @@ -15,6 +16,7 @@ export const useBatchStore = create((set) => ({ setDinsarBatches: s(set, 'dinsarBatches'), setPsBatches: s(set, 'psBatches'), setSelectedBatchId: s(set, 'selectedBatchId'), + setPendingTimeseriesBatchId: s(set, 'pendingTimeseriesBatchId'), setBatchItems: s(set, 'batchItems'), setBatchLoading: s(set, 'batchLoading'), setBatchError: s(set, 'batchError'), diff --git a/scripts/bootstrap_clone.ps1 b/scripts/bootstrap_clone.ps1 new file mode 100644 index 0000000..90f5ae4 --- /dev/null +++ b/scripts/bootstrap_clone.ps1 @@ -0,0 +1,417 @@ +# Clone bootstrap helper for fresh Windows deployments. +[CmdletBinding()] +param( + [switch]$InitWindowsConda, + [switch]$InitFrontend, + [switch]$BuildFrontend, + [switch]$InitWslConda, + [switch]$All, + [switch]$SkipChecks +) + +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$ProjectRoot = Split-Path -Parent $ScriptDir +Set-Location -LiteralPath $ProjectRoot + +if ($All) { + $InitWindowsConda = $true + $InitFrontend = $true + $BuildFrontend = $true + $InitWslConda = $true +} + +$EnvPath = Join-Path $ProjectRoot ".env" +$EnvExamplePath = Join-Path $ProjectRoot ".env.example" +$WindowsCondaSpec = Join-Path $ProjectRoot "environment.yml" +$WslCondaSpec = Join-Path $ProjectRoot "deploy\wsl\conda\insar_wsl_v1.environment.yml" +$FrontendDir = Join-Path $ProjectRoot "frontend" +$CheckRuntimeScript = Join-Path $ProjectRoot "scripts\check_runtime_config.py" + +function Write-Section { + param([string]$Title) + + Write-Host "" + Write-Host "==> $Title" -ForegroundColor Cyan +} + +function Write-Info { + param([string]$Message) + + Write-Host "[INFO] $Message" -ForegroundColor DarkCyan +} + +function Write-WarnMessage { + param([string]$Message) + + Write-Host "[WARN] $Message" -ForegroundColor Yellow +} + +function Get-FirstNonEmptyValue { + param( + [object[]]$Values, + [string]$Fallback = "" + ) + + foreach ($value in $Values) { + $trimmed = "$value".Trim().Trim('"').Trim("'") + if ($trimmed) { + return $trimmed + } + } + + return $Fallback +} + +function Read-EnvFile { + param([string]$Path) + + $map = @{} + if (-not (Test-Path -LiteralPath $Path)) { + return $map + } + + foreach ($line in Get-Content -LiteralPath $Path) { + $trimmed = $line.Trim() + if (-not $trimmed -or $trimmed.StartsWith("#") -or -not $trimmed.Contains("=")) { + continue + } + + $parts = $trimmed.Split("=", 2) + $key = $parts[0].Trim() + $value = $parts[1].Trim().Trim('"').Trim("'") + $map[$key] = $value + } + + return $map +} + +function Resolve-CommandPath { + param([string[]]$Candidates) + + foreach ($candidate in $Candidates) { + $trimmed = "$candidate".Trim().Trim('"').Trim("'") + if (-not $trimmed) { + continue + } + + if (Test-Path -LiteralPath $trimmed) { + $resolved = Resolve-Path -LiteralPath $trimmed -ErrorAction SilentlyContinue + if ($resolved) { + return $resolved.Path + } + return $trimmed + } + + $command = Get-Command -Name $trimmed -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command) { + if ($command.Source) { + return $command.Source + } + if ($command.Path) { + return $command.Path + } + return $trimmed + } + } + + return $null +} + +function Get-CondaEnvNameFromSpec { + param( + [string]$SpecPath, + [string]$FallbackName + ) + + if (-not (Test-Path -LiteralPath $SpecPath)) { + return $FallbackName + } + + $match = Select-String -Path $SpecPath -Pattern '^\s*name:\s*([^\s#]+)\s*$' | Select-Object -First 1 + if ($match -and $match.Matches.Count -gt 0) { + return $match.Matches[0].Groups[1].Value.Trim() + } + + return $FallbackName +} + +function Get-WindowsCondaEnvExists { + param( + [string]$CondaExe, + [string]$EnvName + ) + + try { + $envsJson = & "$CondaExe" env list --json 2>$null + if ($LASTEXITCODE -ne 0 -or -not $envsJson) { + return $false + } + + $envsInfo = $envsJson | ConvertFrom-Json + foreach ($envPath in ($envsInfo.envs | Where-Object { $_ })) { + if ((Split-Path -Leaf "$envPath") -eq $EnvName) { + return $true + } + } + } catch { + return $false + } + + return $false +} + +function Resolve-CondaEnvPythonPath { + param( + [string]$CondaExe, + [string]$EnvName + ) + + if (-not $CondaExe -or -not $EnvName) { + return $null + } + + try { + $envsJson = & "$CondaExe" env list --json 2>$null + if ($LASTEXITCODE -ne 0 -or -not $envsJson) { + return $null + } + + $envsInfo = $envsJson | ConvertFrom-Json + foreach ($envPath in ($envsInfo.envs | Where-Object { $_ })) { + if ((Split-Path -Leaf "$envPath") -ne $EnvName) { + continue + } + + $pythonPath = Join-Path -Path "$envPath" -ChildPath "python.exe" + if (Test-Path -LiteralPath $pythonPath) { + $resolved = Resolve-Path -LiteralPath $pythonPath -ErrorAction SilentlyContinue + if ($resolved) { + return $resolved.Path + } + return $pythonPath + } + } + } catch { + return $null + } + + return $null +} + +function Convert-WindowsPathToWsl { + param([string]$WindowsPath) + + $resolved = Resolve-Path -LiteralPath $WindowsPath -ErrorAction SilentlyContinue + $path = if ($resolved) { $resolved.Path } else { [System.IO.Path]::GetFullPath($WindowsPath) } + + if ($path -notmatch '^[A-Za-z]:\\') { + throw "Path cannot be converted to a WSL mount path: $path" + } + + $drive = $path.Substring(0, 1).ToLowerInvariant() + $rest = $path.Substring(2).Replace('\', '/') + return "/mnt/$drive$rest" +} + +function Get-WslBootstrapPrefix { + return @( + "if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi", + "if ! command -v conda >/dev/null 2>&1 && [ -f ~/miniconda3/etc/profile.d/conda.sh ]; then . ~/miniconda3/etc/profile.d/conda.sh; fi", + "if ! command -v conda >/dev/null 2>&1 && [ -f ~/anaconda3/etc/profile.d/conda.sh ]; then . ~/anaconda3/etc/profile.d/conda.sh; fi", + "command -v conda >/dev/null 2>&1 || { echo 'conda not found inside WSL'; exit 127; }" + ) -join "; " +} + +function Invoke-WslCommand { + param( + [string]$Distro, + [string]$Command, + [switch]$AllowFailure + ) + + & "$WslExe" -d "$Distro" bash -lc "$Command" + $exitCode = $LASTEXITCODE + if (-not $AllowFailure -and $exitCode -ne 0) { + throw "WSL command failed for distro '$Distro' with exit code $exitCode." + } + return $exitCode +} + +Write-Section "Clone bootstrap" +Write-Info "Project root: $ProjectRoot" + +if (-not (Test-Path -LiteralPath $EnvPath)) { + if (-not (Test-Path -LiteralPath $EnvExamplePath)) { + throw "Missing .env.example at $EnvExamplePath" + } + + Copy-Item -LiteralPath $EnvExamplePath -Destination $EnvPath + Write-Info "Created .env from .env.example. Review it before starting the system." +} else { + Write-Info ".env already exists. Keeping the current file." +} + +$EnvMap = Read-EnvFile -Path $EnvPath +$CondaExe = Resolve-CommandPath -Candidates @($EnvMap["CONDA_EXE"], "conda", "conda.exe") +$NpmExe = Resolve-CommandPath -Candidates @("npm.cmd", "npm") +$PythonExe = Resolve-CommandPath -Candidates @($EnvMap["PYTHON_PATH"], "python", "py") +$WslExe = Resolve-CommandPath -Candidates @("wsl.exe", "wsl") + +$WindowsCondaEnvName = Get-FirstNonEmptyValue -Values @( + $EnvMap["CONDA_ENV_NAME"], + (Get-CondaEnvNameFromSpec -SpecPath $WindowsCondaSpec -FallbackName "InSAR") +) + +$WslDistro = Get-FirstNonEmptyValue -Values @( + $EnvMap["WSL_DISTRO"], + $EnvMap["TIMESERIES_WSL_DISTRO"], + $EnvMap["ISCE2_WSL_DISTRO"], + $EnvMap["PYINT_WSL_DISTRO"] +) -Fallback "Ubuntu-24.04" + +$WslCondaEnvName = Get-FirstNonEmptyValue -Values @( + $EnvMap["WSL_SHARED_CONDA_ENV"], + $EnvMap["TIMESERIES_ENV_NAME"], + (Get-CondaEnvNameFromSpec -SpecPath $WslCondaSpec -FallbackName "insar_wsl_v1") +) -Fallback "insar_wsl_v1" + +if (-not $InitWindowsConda -and -not $InitFrontend -and -not $BuildFrontend -and -not $InitWslConda) { + Write-Info "No optional install switches were requested. Use -All or individual switches to bootstrap runtimes." +} + +if ($InitWindowsConda) { + Write-Section "Windows Conda runtime" + + if (-not $CondaExe) { + throw "Conda was not found. Set CONDA_EXE in .env or add conda to PATH." + } + if (-not (Test-Path -LiteralPath $WindowsCondaSpec)) { + throw "Missing Windows conda spec: $WindowsCondaSpec" + } + + if (Get-WindowsCondaEnvExists -CondaExe $CondaExe -EnvName $WindowsCondaEnvName) { + Write-Info "Updating Windows conda env '$WindowsCondaEnvName' from environment.yml" + & "$CondaExe" env update --name "$WindowsCondaEnvName" --file "$WindowsCondaSpec" --prune + } else { + Write-Info "Creating Windows conda env '$WindowsCondaEnvName' from environment.yml" + & "$CondaExe" env create --name "$WindowsCondaEnvName" --file "$WindowsCondaSpec" + } + if ($LASTEXITCODE -ne 0) { + throw "Windows conda bootstrap failed." + } + + $ResolvedWindowsPython = Resolve-CondaEnvPythonPath -CondaExe $CondaExe -EnvName $WindowsCondaEnvName + if ($ResolvedWindowsPython) { + $PythonExe = $ResolvedWindowsPython + Write-Info "Suggested .env values:" + Write-Host " CONDA_EXE=$CondaExe" + Write-Host " CONDA_ENV_NAME=$WindowsCondaEnvName" + Write-Host " PYTHON_PATH=$ResolvedWindowsPython" + } else { + Write-WarnMessage "The conda environment was created or updated, but python.exe could not be resolved automatically." + } +} + +if ($InitFrontend) { + Write-Section "Frontend dependencies" + + if (-not $NpmExe) { + throw "npm was not found. Install Node.js and make sure npm is in PATH." + } + if (-not (Test-Path -LiteralPath $FrontendDir)) { + throw "Missing frontend directory: $FrontendDir" + } + + Push-Location $FrontendDir + try { + Write-Info "Running npm ci in frontend/" + & "$NpmExe" ci + if ($LASTEXITCODE -ne 0) { + throw "npm ci failed." + } + } finally { + Pop-Location + } +} + +if ($BuildFrontend) { + Write-Section "Frontend build" + + if (-not $NpmExe) { + throw "npm was not found. Install Node.js and make sure npm is in PATH." + } + if (-not (Test-Path -LiteralPath $FrontendDir)) { + throw "Missing frontend directory: $FrontendDir" + } + + Push-Location $FrontendDir + try { + Write-Info "Running npm run build in frontend/" + & "$NpmExe" run build + if ($LASTEXITCODE -ne 0) { + throw "npm run build failed." + } + } finally { + Pop-Location + } +} + +if ($InitWslConda) { + Write-Section "WSL shared runtime" + + if (-not $WslExe) { + throw "wsl.exe was not found." + } + if (-not (Test-Path -LiteralPath $WslCondaSpec)) { + throw "Missing WSL conda spec: $WslCondaSpec" + } + + $WslSpecPath = Convert-WindowsPathToWsl -WindowsPath $WslCondaSpec + $Prefix = Get-WslBootstrapPrefix + $ExistsCommand = "$Prefix; conda env list | grep -E ""^$WslCondaEnvName[[:space:]]"" >/dev/null" + $ExistsExitCode = Invoke-WslCommand -Distro $WslDistro -Command $ExistsCommand -AllowFailure + + if ($ExistsExitCode -eq 0) { + Write-Info "Updating WSL conda env '$WslCondaEnvName' in distro '$WslDistro'" + $BootstrapCommand = "$Prefix; conda env update -n ""$WslCondaEnvName"" -f ""$WslSpecPath"" --prune" + } else { + Write-Info "Creating WSL conda env '$WslCondaEnvName' in distro '$WslDistro'" + $BootstrapCommand = "$Prefix; conda env create -n ""$WslCondaEnvName"" -f ""$WslSpecPath""" + } + + [void](Invoke-WslCommand -Distro $WslDistro -Command $BootstrapCommand) + + Write-Info "Suggested .env values:" + Write-Host " WSL_DISTRO=$WslDistro" + Write-Host " WSL_SHARED_CONDA_ENV=$WslCondaEnvName" + Write-Host " TIMESERIES_ENV_NAME=$WslCondaEnvName" + Write-WarnMessage "Verify WSL_SHARED_PYTHON, ISCE2_PYTHON, TIMESERIES_PYTHON, and PYINT_WSL_PYTHON against the real conda install root inside WSL." +} + +if (-not $SkipChecks) { + Write-Section "Deployment validation" + + if (-not $PythonExe -and $CondaExe -and $WindowsCondaEnvName) { + $PythonExe = Resolve-CondaEnvPythonPath -CondaExe $CondaExe -EnvName $WindowsCondaEnvName + } + + if (-not $PythonExe) { + Write-WarnMessage "No Windows Python interpreter was resolved. Skipping scripts/check_runtime_config.py." + } elseif (-not (Test-Path -LiteralPath $CheckRuntimeScript)) { + Write-WarnMessage "Missing validation script: $CheckRuntimeScript" + } else { + Write-Info "Using Python for validation: $PythonExe" + & "$PythonExe" "$CheckRuntimeScript" + if ($LASTEXITCODE -ne 0) { + throw "Deployment configuration validation failed. Fix .env and rerun the bootstrap." + } + } +} + +Write-Section "Next steps" +Write-Host "1. Review .env and align local paths, database settings, and WSL python paths." +Write-Host "2. Run start_system.bat when scripts/check_runtime_config.py passes." +Write-Host "3. Use GET /api/health after startup to confirm database, catalog, product_packages, and wsl_runtime are healthy." diff --git a/scripts/start_app.ps1 b/scripts/start_app.ps1 index 53d83d0..36cae11 100644 --- a/scripts/start_app.ps1 +++ b/scripts/start_app.ps1 @@ -487,6 +487,15 @@ $NginxConfPath = Join-Path -Path $ProjectRoot -ChildPath "nginx\nginx.conf" if (Test-Path -LiteralPath "$NginxConfPath") { Write-Host ">>> Configuring Nginx paths..." -ForegroundColor Yellow + $FrontendDistPath = Join-Path -Path $ProjectRoot -ChildPath "frontend\dist" + $FrontendIndexPath = Join-Path -Path $FrontendDistPath -ChildPath "index.html" + if (-not (Test-Path -LiteralPath "$FrontendIndexPath")) { + Write-Host "`n[ERROR] Frontend build output not found: $FrontendIndexPath" -ForegroundColor Red + Write-Host "Run scripts\bootstrap_clone.ps1 -InitFrontend -BuildFrontend or build frontend/dist manually before using start_system.bat." -ForegroundColor Red + $global:LASTEXITCODE = 1 + return + } + $NginxBase = Split-Path -Parent "$NginxExe" $SrcMime = Join-Path -Path $NginxBase -ChildPath "conf\mime.types" $DestMime = Join-Path -Path $ProjectRoot -ChildPath "nginx\mime.types"