feat: engineer SBAS timeseries production workflow

This commit is contained in:
2026-04-29 14:43:31 +08:00
parent dace8b20f6
commit 4c0d1f2c2b
54 changed files with 5843 additions and 201 deletions
+5
View File
@@ -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.
# -----------------------------------------------------------------------------
+29 -1
View File
@@ -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 <repo-url>
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 必须常驻,否则生产任务、扫描任务、解包任务不会执行。
+13 -2
View File
@@ -506,9 +506,20 @@ 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")
object.__setattr__(
self,
"TIMESERIES_ENV_NAME",
str(self.WSL_SHARED_CONDA_ENV or "insar_wsl_v1").strip() or "insar_wsl_v1",
)
if not self.TIMESERIES_PYTHON:
env_name = str(self.TIMESERIES_ENV_NAME or "isce2_mintpy_v1").strip() or "isce2_mintpy_v1"
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",
+1
View File
@@ -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",
]
+406 -2
View File
@@ -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", []),
+278 -21
View File
@@ -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)
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
@@ -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()
@@ -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())
@@ -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" <property name=\"do denseoffsets\">{str(config.dense_offsets)}</property>\n"
f" <property name=\"do rubbersheetingRange\">{str(config.rubbersheet_range)}</property>\n"
f" <property name=\"do rubbersheetingAzimuth\">{str(config.rubbersheet_azimuth)}</property>\n"
f" <property name=\"rubber sheet SNR Threshold\">{config.rubber_sheet_snr_threshold}</property>\n"
f" <property name=\"rubber sheet filter size\">{config.rubber_sheet_filter_size}</property>\n"
f" <property name=\"dense window width\">{config.dense_window_width}</property>\n"
f" <property name=\"dense window height\">{config.dense_window_height}</property>\n"
f" <property name=\"dense search width\">{config.dense_search_width}</property>\n"
f" <property name=\"dense search height\">{config.dense_search_height}</property>\n"
f" <property name=\"dense skip width\">{config.dense_skip_width}</property>\n"
f" <property name=\"dense skip height\">{config.dense_skip_height}</property>\n"
)
text = (
"<stripmapApp>\n"
" <component name=\"stripmapApp\">\n"
@@ -514,10 +744,13 @@ def write_stripmap_xml(xml_path: Path, config: PipelineConfig) -> None:
" <property name=\"renderer\">xml</property>\n"
" <property name=\"do unwrap\">True</property>\n"
" <property name=\"unwrapper name\">snaphu</property>\n"
f" <property name=\"do split spectrum\">{str(config.ionosphere_correction)}</property>\n"
f" <property name=\"do dispersive\">{str(config.ionosphere_correction)}</property>\n"
f" <property name=\"posting\">{config.target_grid_size_m}</property>\n"
f" <property name=\"geoPosting\">{config.geo_posting_deg:.12f}</property>\n"
f"{bbox_xml}"
f"{geocode_list_xml}"
f"{enhancement_props}"
f" <property name=\"demFilename\">{config.dem_path.as_posix()}</property>\n"
"\n"
" <component name=\"Reference\">\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,
)
+7 -1
View File
@@ -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",
+71
View File
@@ -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)
+58 -2
View File
@@ -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
+26 -3
View File
@@ -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())
+36
View File
@@ -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),
+176 -2
View File
@@ -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)
@@ -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
@@ -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")),
@@ -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,
+442 -36
View File
@@ -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,
+3
View File
@@ -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:
File diff suppressed because it is too large Load Diff
+91
View File
@@ -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)
+24
View File
@@ -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(
@@ -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);
+45
View File
@@ -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 路径不一致,说明仍然有旧环境残留在配置层或运行时注册层。
@@ -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
+146
View File
@@ -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
+16 -4
View File
@@ -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"
}
}
+88 -1
View File
@@ -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)
+141 -2
View File
@@ -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:
- `<dem>.xml`
- `<dem>.vrt`
- `<dem>.wgs84`
- `<dem>.wgs84.xml`
- `<dem>.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 <repo-url>
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.
+9
View File
@@ -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)
@@ -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.
@@ -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/<run_id>/...`
- publish root:
- `TIMESERIES_PRODUCT_DIR/<stack_key>/runs/<run_id>/...`
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:
- `<publish_dir>/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`
+28 -1
View File
@@ -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
@@ -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
@@ -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),
@@ -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"
+10
View File
@@ -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;
+115 -1
View File
@@ -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(
`<strong>时序候选场景</strong><br>` +
`ID: ${escapeHtml(scene.id)}<br>` +
`日期: ${escapeHtml(scene.imaging_date || '-')}<br>` +
`卫星: ${escapeHtml(scene.satellite || '-')}<br>` +
`模式: ${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,
};
+63 -2
View File
@@ -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 (
<div style={{ minWidth: 180, flex: '0 1 220px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<label style={{ fontSize: 12, color: '#64748b', display: 'block' }}>{label}</label>
{isReadonly && (
<span
style={{
padding: '1px 6px',
borderRadius: 999,
background: '#e2e8f0',
color: '#475569',
fontSize: 11,
}}
>
固定值
</span>
)}
</div>
<select
value={value ?? schema.default ?? schema.enum[0]}
disabled={disabled || isReadonly}
onChange={event => onChange(name, event.target.value)}
style={inputStyle}
>
{schema.enum.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
{description && <div style={{ fontSize: 11, color: '#94a3b8', marginTop: 4 }}>{description}</div>}
{recommendation && <div style={{ fontSize: 11, color: '#2563eb', marginTop: 4 }}>推荐:{recommendation}</div>}
</div>
);
}
return (
<div style={{ minWidth: 180, flex: schema.type === 'string' ? '1 1 280px' : '0 1 180px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
@@ -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,7 +987,13 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
{currentParamHelpText}
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{Object.entries(currentParamSchema).map(([name, schema]) => (
{currentParamSections.map(section => (
<div key={section.title} style={{ width: '100%' }}>
<div style={{ fontSize: 12, color: '#0f172a', fontWeight: 600, marginBottom: 8 }}>
{section.title}
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{section.items.map(([name, schema]) => (
<ParamField
key={name}
name={name}
@@ -947,6 +1005,9 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
))}
</div>
</div>
))}
</div>
</div>
)}
{selectedEngine === 'pyint' && (
+409 -6
View File
@@ -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 (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '2px 10px',
borderRadius: 999,
border: `1px solid ${border}`,
background,
color,
fontSize: 12,
fontWeight: 600,
}}
>
<span
style={{
width: 7,
height: 7,
borderRadius: '50%',
background: color,
display: 'inline-block',
}}
/>
{ok ? okLabel : failLabel}
</span>
);
}
function JsonBlock({ value }) {
return (
<pre
style={{
margin: 0,
padding: '8px 10px',
background: '#f8fafc',
borderRadius: 6,
border: '1px solid #e2e8f0',
fontSize: 11,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{JSON.stringify(value || {}, null, 2)}
</pre>
);
}
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 (
<div style={{ padding: '16px 0', width: '100%' }}>
<div style={card}>
<strong style={{ fontSize: 14, display: 'block', marginBottom: 10 }}>时序InSAR 运行入口</strong>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', marginBottom: 10 }}>
<strong style={{ fontSize: 14, display: 'block' }}>时序InSAR 运行入口</strong>
<button
type="button"
onClick={handleWslCheck}
disabled={readOnly || wslChecking}
style={{
padding: '4px 10px',
borderRadius: 6,
border: '1px solid #cbd5e1',
background: '#fff',
cursor: readOnly ? 'not-allowed' : 'pointer',
fontSize: 12,
}}
>
{wslChecking ? '检查中...' : 'WSL检查'}
</button>
</div>
<div
style={{
fontSize: 12,
@@ -201,6 +387,32 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
register_psinsar_product。提交后系统会依次生成选栈 manifest、物化 LT-1 SLC、执行 ISCE2
stack、运行 MintPy SBAS、导出 publish bundle,并把结果注册进时序InSAR catalog。
</div>
{wslReport && (
<div
style={{
marginTop: 10,
padding: '10px 12px',
borderRadius: 6,
border: `1px solid ${wslReport.overall_ok ? '#bbf7d0' : '#fecaca'}`,
background: wslReport.overall_ok ? '#f0fdf4' : '#fef2f2',
fontSize: 12,
color: wslReport.overall_ok ? '#166534' : '#991b1b',
}}
>
<div style={{ fontWeight: 600, marginBottom: 6 }}>
{wslReport.overall_ok ? 'WSL运行时正常' : 'WSL运行时存在问题'}
</div>
<div style={{ marginBottom: 6 }}>{wslReport.message || '-'}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{(Array.isArray(wslReport.checks) ? wslReport.checks : []).slice(0, 8).map(check => (
<div key={check.name}>
<strong>{check.ok ? 'OK' : 'FAIL'}</strong> {check.name}
{check.detail ? `: ${check.detail}` : ''}
</div>
))}
</div>
</div>
)}
</div>
<div style={card}>
@@ -277,6 +489,8 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
{selectedBatch && (
<div style={{ marginTop: 10, fontSize: 12, color: '#334155', lineHeight: 1.7 }}>
<div><strong>Stack Plan:</strong>{selectedBatch.plan_id || '-'}</div>
<div><strong>Plan Strategy:</strong>{selectedBatch.plan_strategy || '-'}</div>
<div><strong>方向:</strong>{selectedBatch.direction || '-'}</div>
<div><strong>影像数:</strong>{selectedBatch.total_items || 0}</div>
<div><strong>批次状态:</strong>{selectedBatch.status || '-'}</div>
@@ -284,7 +498,22 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
</div>
)}
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 12 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 12, flexWrap: 'wrap' }}>
<button
type="button"
onClick={handlePreflight}
disabled={readOnly || preflightLoading || !selectedBatchId}
style={{
padding: '6px 14px',
borderRadius: 6,
border: '1px solid #cbd5e1',
background: '#fff',
color: '#0f172a',
cursor: readOnly ? 'not-allowed' : 'pointer',
}}
>
{preflightLoading ? '预检中...' : '运行预检'}
</button>
<button
type="button"
onClick={handleSubmit}
@@ -306,6 +535,114 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
</span>
)}
</div>
{preflightReport && (
<div
style={{
marginTop: 12,
padding: '10px 12px',
borderRadius: 6,
border: `1px solid ${preflightReport.overall_ok ? '#bbf7d0' : '#fecaca'}`,
background: preflightReport.overall_ok ? '#f0fdf4' : '#fef2f2',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 8, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<strong style={{ fontSize: 13, color: preflightReport.overall_ok ? '#166534' : '#991b1b' }}>
{preflightReport.overall_ok ? '预检通过' : '预检发现问题'}
</strong>
<QualityBadge ok={!!preflightReport.overall_ok} okLabel="可提交" failLabel="需处理" />
</div>
<div style={{ fontSize: 11, color: '#475569' }}>
错误 {preflightErrors.length} / 告警 {preflightWarnings.length}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 8, marginBottom: 8 }}>
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#fff', border: '1px solid #e2e8f0', fontSize: 12 }}>
<div style={{ color: '#64748b', marginBottom: 4 }}>有效参考日期</div>
<strong>{preflightReport.reference_date_effective || '-'}</strong>
</div>
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#fff', border: '1px solid #e2e8f0', fontSize: 12 }}>
<div style={{ color: '#64748b', marginBottom: 4 }}>场景规模</div>
<strong>{preflightSummary.scene_count || 0} 景</strong>
</div>
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#fff', border: '1px solid #e2e8f0', fontSize: 12 }}>
<div style={{ color: '#64748b', marginBottom: 4 }}>Stack Key</div>
<strong style={{ wordBreak: 'break-all' }}>{preflightSummary.stack_key || '-'}</strong>
</div>
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#fff', border: '1px solid #e2e8f0', fontSize: 12 }}>
<div style={{ color: '#64748b', marginBottom: 4 }}>数据量</div>
<strong>{formatBytes(preflightSummary.total_scene_bytes)}</strong>
</div>
</div>
<div style={{ fontSize: 12, color: '#334155', lineHeight: 1.7 }}>
<div><strong>Stack Plan:</strong>{preflightReport.plan_id || preflightSummary.plan_id || '-'}</div>
<div><strong>Plan Strategy:</strong>{preflightReport.plan_strategy || preflightSummary.plan_strategy || '-'}</div>
<div><strong>批次:</strong>{preflightReport.batch_name || preflightReport.batch_id || '-'}</div>
<div><strong>批次状态:</strong>{preflightReport.batch_status || '-'}</div>
<div><strong>水体掩膜:</strong>{preflightReport.water_mask_mode || '-'}</div>
<div><strong>分组:</strong>{preflightSummary.group_key || '-'}</div>
<div><strong>源目录:</strong>{preflightSummary.source_root_windows || '-'}</div>
<div><strong>日期列表:</strong>{(preflightSummary.stack_dates || []).join(', ') || '-'}</div>
</div>
{preflightErrors.length > 0 && (
<div style={{ marginTop: 8, padding: '8px 10px', borderRadius: 6, background: '#fff', border: '1px solid #fecaca', fontSize: 12, color: '#991b1b' }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>错误</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{preflightErrors.map((item, index) => (
<div key={`preflight-error-${index}`}>{item}</div>
))}
</div>
</div>
)}
{preflightWarnings.length > 0 && (
<div style={{ marginTop: 8, padding: '8px 10px', borderRadius: 6, background: '#fff', border: '1px solid #fde68a', fontSize: 12, color: '#92400e' }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>告警</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{preflightWarnings.map((item, index) => (
<div key={`preflight-warning-${index}`}>{item}</div>
))}
</div>
</div>
)}
{preflightChecks.length > 0 && (
<div style={{ marginTop: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#0f172a', marginBottom: 6 }}>检查项</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{preflightChecks.map(check => {
const accent = check.ok ? '#166534' : check.severity === 'warn' ? '#92400e' : '#991b1b';
return (
<div
key={check.name}
style={{
padding: '8px 10px',
borderRadius: 6,
border: '1px solid #e2e8f0',
background: '#fff',
fontSize: 12,
color: '#334155',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
<strong style={{ color: accent }}>
{check.ok ? 'OK' : check.severity === 'warn' ? 'WARN' : 'FAIL'} / {check.name}
</strong>
{check.skipped ? <span style={{ color: '#64748b' }}>skipped</span> : null}
</div>
<div style={{ marginTop: 2, wordBreak: 'break-word' }}>{check.detail || '-'}</div>
</div>
);
})}
</div>
</div>
)}
</div>
)}
</div>
<div style={card}>
@@ -377,6 +714,8 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
<strong>{runData?.run_name || '-'}</strong>
<StatusPill value={runData?.status} />
</div>
<div><strong>Stack Plan:</strong>{runData?.plan_id || '-'}</div>
<div><strong>Plan Strategy:</strong>{runData?.plan_strategy || '-'}</div>
<div><strong>运行标识:</strong>{runData?.run_id || '-'}</div>
<div><strong>批次标识:</strong>{runData?.batch_id || '-'}</div>
<div><strong>参考日期:</strong>{runData?.reference_date || '-'}</div>
@@ -392,7 +731,54 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
<div><strong>创建时间:</strong>{formatDateTime(runData?.created_at)}</div>
<div><strong>结束时间:</strong>{formatDateTime(runData?.ended_at)}</div>
<div><strong>输入日期:</strong>{(runData?.input_snapshot_json?.stack_dates || []).join(', ') || '-'}</div>
<div><strong>轨道摘要:</strong>{JSON.stringify(runData?.orbit_summary_json || {}, null, 2)}</div>
<div>
<strong>轨道摘要:</strong>
<div style={{ marginTop: 4 }}>
<JsonBlock value={runData?.orbit_summary_json || {}} />
</div>
</div>
{(runPreflightQuality || runPublishValidation) && (
<div style={{ marginTop: 8, paddingTop: 8, borderTop: '1px dashed #cbd5e1' }}>
<div style={{ fontWeight: 600, marginBottom: 6 }}>运行质量</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 8 }}>
{runPreflightQuality && (
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc', border: '1px solid #e2e8f0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center', marginBottom: 6 }}>
<strong>预检记录</strong>
<QualityBadge ok={!!runPreflightQuality.overall_ok} okLabel="通过" failLabel="失败" />
</div>
<div><strong>有效参考日期:</strong>{runPreflightQuality.reference_date_effective || '-'}</div>
<div><strong>错误数:</strong>{(runPreflightQuality.errors || []).length}</div>
<div><strong>告警数:</strong>{(runPreflightQuality.warnings || []).length}</div>
<details style={{ marginTop: 8 }}>
<summary style={{ cursor: 'pointer', color: '#2563eb' }}>查看预检详情</summary>
<div style={{ marginTop: 8 }}>
<JsonBlock value={runPreflightQuality} />
</div>
</details>
</div>
)}
{runPublishValidation && (
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc', border: '1px solid #e2e8f0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center', marginBottom: 6 }}>
<strong>发布校验</strong>
<QualityBadge ok={!!runPublishValidation.ok} okLabel="通过" failLabel="失败" />
</div>
<div><strong>主资产角色:</strong>{runPublishValidation.primary_role || '-'}</div>
<div><strong>预览角色:</strong>{runPublishValidation.preview_role || '-'}</div>
<div><strong>缺失角色:</strong>{(runPublishValidation.missing_roles || []).length}</div>
<div><strong>缺失文件:</strong>{(runPublishValidation.missing_paths || []).length}</div>
<details style={{ marginTop: 8 }}>
<summary style={{ cursor: 'pointer', color: '#2563eb' }}>查看发布校验详情</summary>
<div style={{ marginTop: 8 }}>
<JsonBlock value={runPublishValidation} />
</div>
</details>
</div>
)}
</div>
</div>
)}
{workflowSteps.length > 0 && (
<div style={{ marginTop: 8, paddingTop: 8, borderTop: '1px dashed #cbd5e1' }}>
<div style={{ fontWeight: 600, marginBottom: 6 }}>工作流步骤</div>
@@ -423,8 +809,25 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
</div>
)}
</div>
<div style={{ flexShrink: 0 }}>
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end' }}>
<StatusPill value={step.status} />
{!readOnly && step.status === 'FAILED' && (
<button
type="button"
onClick={() => handleRetryStep(step.step_id)}
disabled={retryingStepId === step.step_id}
style={{
padding: '4px 8px',
borderRadius: 6,
border: '1px solid #cbd5e1',
background: '#fff',
cursor: 'pointer',
fontSize: 11,
}}
>
{retryingStepId === step.step_id ? '重试中...' : '重试该步'}
</button>
)}
</div>
</div>
))}
+9
View File
@@ -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);
+20 -6
View File
@@ -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]) => (
<div className="form-group" key={key}>
<label title={
key === 'initial_overlap_threshold'
? '影像与AOI重叠面积 / AOI面积'
: '影像与公共重叠区面积 / 公共重叠区面积'
}>
{key.replace(/_/g, ' ')}:
<label title={PARAM_METADATA[key]?.title || key}>
{PARAM_METADATA[key]?.label || key.replace(/_/g, ' ')}:
</label>
<input
type="number"
@@ -125,6 +134,11 @@ function PsStackModal({
value={value}
onChange={e => setPsParams({...psParams, [key]: parseFloat(e.target.value)})}
/>
{PARAM_METADATA[key]?.hint && (
<div style={{ marginTop: '4px', fontSize: '12px', color: '#6b7280', lineHeight: 1.4 }}>
{PARAM_METADATA[key].hint}
</div>
)}
</div>
))}
<div className="modal-actions">
@@ -329,9 +329,14 @@ export default function PsinsarCatalogPanel({
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 12, color: '#334155' }}>
<div><strong>Stack Plan:</strong>{selectedProduct.plan_id || '-'}</div>
<div><strong>Plan Strategy:</strong>{selectedProduct.plan_strategy || '-'}</div>
<div><strong>名称:</strong>{selectedProduct.display_name || '-'}</div>
<div><strong>产品编号:</strong>{selectedProduct.product_id || '-'}</div>
<div><strong>运行标识:</strong>{selectedProduct.run_key || '-'}</div>
<div><strong>运行标识:</strong>{selectedProduct.run_id || selectedProduct.run_key || '-'}</div>
<div><strong>来源批次:</strong>{selectedProduct.batch_id || '-'}</div>
<div><strong>任务标识:</strong>{selectedProduct.task_id || '-'}</div>
<div><strong>工作流标识:</strong>{selectedProduct.workflow_run_id || '-'}</div>
<div><strong>参考日期:</strong>{selectedProduct.reference_date || '-'}</div>
<div><strong>影像数:</strong>{selectedProduct.stack_size || 0}</div>
<div><strong>引擎:</strong>{selectedProduct.engine_code || '-'}</div>
@@ -349,6 +354,21 @@ export default function PsinsarCatalogPanel({
<div><strong>主展示产物:</strong>{selectedProduct.primary_asset_path || '-'}</div>
</div>
{selectedProduct.source_summary && (
<div style={{ borderTop: '1px dashed #cbd5e1', paddingTop: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>来源追踪</div>
<div style={{ fontSize: 12, color: '#334155', lineHeight: 1.7 }}>
<div><strong>Stack Plan:</strong>{selectedProduct.source_summary?.plan_id || selectedProduct.plan_id || '-'}</div>
<div><strong>Plan Strategy:</strong>{selectedProduct.source_summary?.plan_strategy || selectedProduct.plan_strategy || '-'}</div>
<div><strong>规划来源:</strong>{selectedProduct.source_summary?.planning_context?.source || '-'}</div>
<div><strong>规划策略:</strong>{selectedProduct.source_summary?.planning_context?.strategy || '-'}</div>
<div><strong>场景数:</strong>{selectedProduct.source_summary?.planning_context?.scene_count || '-'}</div>
<div><strong>工作目录:</strong>{selectedProduct.source_summary?.work_dir || '-'}</div>
<div><strong>原生输出:</strong>{selectedProduct.source_summary?.native_output_dir || '-'}</div>
</div>
</div>
)}
<div style={{ borderTop: '1px dashed #cbd5e1', paddingTop: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>质量摘要</div>
<pre
@@ -482,7 +482,9 @@ export default function AppSidePanel({
<Suspense fallback={<PanelLoadingPanel message="正在加载 PS 候选结果面板..." />}>
<LazyPsResultsPanel
onPreviewPsStack={psPanel.onPreviewPsStack}
onClearPsStackPreview={psPanel.onClearPsStackPreview}
onCreatePsBatch={psPanel.onCreatePsBatch}
onSendToTimeseriesProduction={psPanel.onSendToTimeseriesProduction}
onClearPsResults={psPanel.onClearPsResults}
/>
</Suspense>
@@ -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 (
<div className="ps-stack">
<div className="ps-stack-header">
<div>
<h4>{direction} ({stack.length} scenes)</h4>
<button className="preview-button" onClick={() => onPreviewPsStack(stack)}>预览</button>
{(planMeta.stack_plan_id || planMeta.stack_key) && (
<div style={{ fontSize: 11, color: '#64748b', marginTop: 2 }}>
{planMeta.stack_plan_id ? `plan=${planMeta.stack_plan_id}` : ''}
{planMeta.stack_plan_id && planMeta.stack_key ? ' / ' : ''}
{planMeta.stack_key ? `stack=${planMeta.stack_key}` : ''}
</div>
)}
{(Number.isFinite(consistencyRatio) || Number.isFinite(commonAoiRatio)) && (
<div style={{ fontSize: 11, color: '#64748b', marginTop: 2 }}>
{selectionModeLabel ? `${selectionModeLabel} / ` : ''}
{Number.isFinite(consistencyRatio) ? `一致性 ${(consistencyRatio * 100).toFixed(1)}%` : ''}
{Number.isFinite(consistencyRatio) && Number.isFinite(commonAoiRatio) ? ' / ' : ''}
{Number.isFinite(commonAoiRatio) ? `公共AOI ${(commonAoiRatio * 100).toFixed(1)}%` : ''}
</div>
)}
</div>
<div className="ps-stack-preview-actions">
<button className="preview-button" onClick={() => onPreviewPsStack(stack)}>预览开</button>
<button className="preview-button preview-button-secondary" onClick={onClearPsStackPreview}>预览关</button>
</div>
</div>
<VirtualizedList
items={stack}
@@ -45,6 +77,9 @@ function PsStackSection({
<button onClick={() => onCreatePsBatch(direction, stack)} disabled={isReadOnlyUser}>
保存批次
</button>
<button onClick={() => onSendToTimeseriesProduction(direction, stack)} disabled={isReadOnlyUser}>
送入生产
</button>
</div>
</div>
);
+39 -6
View File
@@ -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) {
+1 -1
View File
@@ -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' },
+1 -1
View File
@@ -115,7 +115,7 @@ export default function PairPlanningPanel({
{en ? 'Pair' : '配对'}
</button>
<button onClick={onOpenPsModal} disabled={isLoading || !hasEnoughRadarScenesForPlanning || isReadOnlyUser} style={{ flex: 1 }}>
{en ? 'PS Prep' : 'PS 准备'}
{en ? 'Timeseries Prep' : '时序准备'}
</button>
</div>
</div>
+1 -1
View File
@@ -28,7 +28,7 @@ export default function PairingPanel({
{en ? 'Pair' : '配对'}
</button>
<button onClick={onOpenPsModal} disabled={isLoading || !hasEnoughRadarScenesForPlanning || isReadOnlyUser} style={{ flex: 1 }}>
{en ? 'PS Prep' : 'PS准备'}
{en ? 'Timeseries Prep' : '时序准备'}
</button>
</div>
</div>
+5 -1
View File
@@ -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 (
<div className="panel-content panel-scroll-shell">
{psStacks.length === 0 ? (
<p className="empty-state">未找到时序InSAR候选栈。</p>
<p className="empty-state">未找到满足 SBAS 至少 3 景要求的时序InSAR候选栈。</p>
) : (
<>
<div className="list-toolbar">
@@ -32,7 +34,9 @@ function PsResultsPanel({
stack={stack}
isReadOnlyUser={isReadOnlyUser}
onPreviewPsStack={onPreviewPsStack}
onClearPsStackPreview={onClearPsStackPreview}
onCreatePsBatch={onCreatePsBatch}
onSendToTimeseriesProduction={onSendToTimeseriesProduction}
/>
))}
</div>
+2
View File
@@ -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'),
+417
View File
@@ -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."
+9
View File
@@ -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"