feat: engineer SBAS timeseries production workflow
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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", []),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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."
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user