chore: initialize insar management system v2
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
"""D-InSAR 多引擎包。
|
||||
|
||||
使用方式:
|
||||
from app.dinsar_engines import registry
|
||||
engine = registry.get_engine("sarscape")
|
||||
avail = engine.check_available()
|
||||
"""
|
||||
from . import registry
|
||||
from .base import DinsarEngine, EngineAvailability, EngineProfile, RunRequest, RunResult
|
||||
|
||||
__all__ = [
|
||||
"registry",
|
||||
"DinsarEngine",
|
||||
"EngineAvailability",
|
||||
"EngineProfile",
|
||||
"RunRequest",
|
||||
"RunResult",
|
||||
]
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Abstract base types for D-InSAR engines."""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class EngineProfile:
|
||||
"""Describes a runnable engine profile."""
|
||||
|
||||
code: str
|
||||
label: str
|
||||
description: str = ""
|
||||
params_schema: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EngineAvailability:
|
||||
"""Represents an engine availability check result."""
|
||||
|
||||
engine_code: str
|
||||
status: str
|
||||
available: bool
|
||||
checks: List[Dict[str, Any]] = field(default_factory=list)
|
||||
message: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunRequest:
|
||||
"""Normalized D-InSAR run request passed to an engine."""
|
||||
|
||||
engine_code: str
|
||||
profile: str
|
||||
root_dir: str
|
||||
job_id: str
|
||||
num_to_process: int = 0
|
||||
timeout_seconds: Optional[int] = None
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunResult:
|
||||
"""Normalized D-InSAR run result returned by an engine."""
|
||||
|
||||
success: bool
|
||||
engine_code: str
|
||||
profile: str
|
||||
job_id: str
|
||||
pairs_processed: int = 0
|
||||
pairs_failed: int = 0
|
||||
output_dirs: List[str] = field(default_factory=list)
|
||||
error: Optional[str] = None
|
||||
detail: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class DinsarEngine(ABC):
|
||||
"""Common interface implemented by all D-InSAR engines."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def engine_code(self) -> str:
|
||||
"""Returns the stable engine identifier."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def engine_label(self) -> str:
|
||||
"""Returns the display label shown in the UI."""
|
||||
|
||||
@abstractmethod
|
||||
def check_available(self) -> EngineAvailability:
|
||||
"""Runs engine-specific availability checks."""
|
||||
|
||||
@abstractmethod
|
||||
def get_profiles(self) -> List[EngineProfile]:
|
||||
"""Returns supported production profiles."""
|
||||
|
||||
@abstractmethod
|
||||
def run(self, request: RunRequest) -> RunResult:
|
||||
"""Executes a production run synchronously."""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serializes the engine definition for API responses."""
|
||||
|
||||
return {
|
||||
"engine_code": self.engine_code,
|
||||
"engine_label": self.engine_label,
|
||||
"profiles": [
|
||||
{
|
||||
"code": profile.code,
|
||||
"label": profile.label,
|
||||
"description": profile.description,
|
||||
"params_schema": profile.params_schema,
|
||||
}
|
||||
for profile in self.get_profiles()
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
"""ISCE2 D-InSAR engine backed by a WSL pipeline script."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ..config import get_env_text, read_bool_env
|
||||
from .base import DinsarEngine, EngineAvailability, EngineProfile, RunRequest, RunResult
|
||||
from ..services.dinsar_naming import (
|
||||
PAIR_META_FILENAME,
|
||||
build_fallback_pair_key,
|
||||
build_run_key,
|
||||
find_json_sidecar,
|
||||
write_run_metadata,
|
||||
)
|
||||
|
||||
|
||||
LT1_FIXED_WAVELENGTH = 0.23793052222222222
|
||||
DEFAULT_TARGET_GRID_SIZE_M = 10
|
||||
DEFAULT_BBOX_MARGIN = 0.05
|
||||
DEFAULT_COH_THRESHOLD = 0.05
|
||||
ORBIT_MARGIN_MIN_SEC = 60.0
|
||||
ORBIT_MARGIN_MAX_SEC = 120.0
|
||||
TARGET_GRID_SIZE_MIN_M = 5
|
||||
TARGET_GRID_SIZE_MAX_M = 100
|
||||
|
||||
|
||||
def _read_env(name: str, default: str = "") -> str:
|
||||
return get_env_text(name, default) or default
|
||||
|
||||
|
||||
def _read_bool_env(name: str, default: bool = False) -> bool:
|
||||
return read_bool_env(name, default)
|
||||
|
||||
|
||||
def _windows_path_to_wsl_mount(path: str) -> str:
|
||||
text = str(path or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
drive, tail = os.path.splitdrive(os.path.normpath(text))
|
||||
if not drive:
|
||||
return text.replace("\\", "/")
|
||||
drive_letter = drive.rstrip(":").lower()
|
||||
normalized_tail = tail.replace("\\", "/")
|
||||
return f"/mnt/{drive_letter}/{normalized_tail}"
|
||||
|
||||
|
||||
class Isce2Engine(DinsarEngine):
|
||||
@property
|
||||
def engine_code(self) -> str:
|
||||
return "isce2"
|
||||
|
||||
@property
|
||||
def engine_label(self) -> str:
|
||||
return "ISCE2(WSL)"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Config helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def _enabled(self) -> bool:
|
||||
return _read_bool_env("ISCE2_ENABLED", False)
|
||||
|
||||
@property
|
||||
def _distro(self) -> str:
|
||||
return _read_env("ISCE2_WSL_DISTRO", "Ubuntu-24.04")
|
||||
|
||||
@property
|
||||
def _python(self) -> str:
|
||||
return _read_env(
|
||||
"ISCE2_PYTHON",
|
||||
"/home/administrator/miniconda3/envs/isce2/bin/python",
|
||||
)
|
||||
|
||||
@property
|
||||
def _dem_path(self) -> str:
|
||||
explicit = _read_env("ISCE2_DEM_PATH", "")
|
||||
if explicit:
|
||||
return explicit
|
||||
base = _read_env("IDL_DINSAR_DEM_BASE_FILE", "")
|
||||
return f"{base}.wgs84" if base else ""
|
||||
|
||||
@property
|
||||
def _orbit_pool_isce2(self) -> str:
|
||||
return _read_env("ORBIT_POOL_ISCE2", "") or _read_env("ISCE2_ORBIT_DIR", "")
|
||||
|
||||
@property
|
||||
def _work_root(self) -> str:
|
||||
return _read_env("ISCE2_WORK_ROOT", "")
|
||||
|
||||
@property
|
||||
def _output_root(self) -> str:
|
||||
return _read_env("ISCE2_OUTPUT_ROOT", "")
|
||||
|
||||
@property
|
||||
def _smoke_test(self) -> bool:
|
||||
return _read_bool_env("ISCE2_SMOKE_TEST_ENABLED", False)
|
||||
|
||||
@property
|
||||
def _stripmap_app(self) -> str:
|
||||
return _read_env(
|
||||
"ISCE2_STRIPMAP_APP",
|
||||
"/home/administrator/miniconda3/envs/isce2/lib/python3.11"
|
||||
"/site-packages/isce/applications/stripmapApp.py",
|
||||
)
|
||||
|
||||
@property
|
||||
def _pipeline_script(self) -> str:
|
||||
explicit = _read_env("ISCE2_PIPELINE_SCRIPT", "")
|
||||
if explicit:
|
||||
return explicit
|
||||
local_script = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "isce2_pipeline"
|
||||
/ "run_lt1_dinsar_pipeline.py"
|
||||
)
|
||||
return _windows_path_to_wsl_mount(str(local_script))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Profiles
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_profiles(self) -> List[EngineProfile]:
|
||||
return [
|
||||
EngineProfile(
|
||||
code="lt1_stripmap",
|
||||
label="LT-1 条带模式",
|
||||
description="通过 WSL 环境运行 LT-1 条带模式 D-InSAR 处理流程。",
|
||||
params_schema={
|
||||
"force": {
|
||||
"label": "强制重建工作目录",
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "如果工作目录已经存在,先删除旧目录再重新处理。",
|
||||
"recommendation": "仅在确认旧结果可以丢弃时启用。",
|
||||
},
|
||||
"target_grid_size_m": {
|
||||
"label": "目标网格尺寸(米)",
|
||||
"type": "number",
|
||||
"default": DEFAULT_TARGET_GRID_SIZE_M,
|
||||
"step": 1,
|
||||
"min": TARGET_GRID_SIZE_MIN_M,
|
||||
"max": TARGET_GRID_SIZE_MAX_M,
|
||||
"description": "控制多视尺度和地理编码输出粒度,系统会自动换算内部处理参数。",
|
||||
"recommendation": "建议优先使用 10;希望保留更多细节可尝试 5,若更看重稳定性和效率可提高到 15 或 20。",
|
||||
},
|
||||
"bbox": {
|
||||
"label": "地理编码范围",
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"placeholder": "南,北,西,东",
|
||||
"description": "手工指定地理编码范围,格式为南、北、西、东。",
|
||||
"recommendation": "通常留空即可,让系统自动估算;只有在你明确知道目标范围时再手工填写。",
|
||||
},
|
||||
"coh_threshold": {
|
||||
"label": "相干性阈值",
|
||||
"type": "number",
|
||||
"default": DEFAULT_COH_THRESHOLD,
|
||||
"step": 0.01,
|
||||
"min": 0,
|
||||
"max": 1,
|
||||
"description": "导出位移结果时,低于该阈值的像元会被掩膜。",
|
||||
"recommendation": "默认 0.05 便于先看全量结果;正式成果更建议从 0.10 起用。",
|
||||
},
|
||||
"bbox_margin": {
|
||||
"label": "范围外扩量(度)",
|
||||
"type": "number",
|
||||
"default": DEFAULT_BBOX_MARGIN,
|
||||
"step": 0.01,
|
||||
"min": 0,
|
||||
"description": "自动估算地理编码范围后,向四周额外扩展的角度。",
|
||||
"recommendation": "推荐 0.05;如果边缘仍有裁切,可逐步提高到 0.08 或 0.10。",
|
||||
},
|
||||
"wavelength": {
|
||||
"label": "雷达波长(米)",
|
||||
"type": "number",
|
||||
"default": LT1_FIXED_WAVELENGTH,
|
||||
"step": 0.000001,
|
||||
"readonly": True,
|
||||
"include_in_payload": False,
|
||||
"description": "LT-1 固定参数,用于位移量换算。",
|
||||
"recommendation": "系统已锁定,不允许修改。",
|
||||
},
|
||||
"orbit_margin_sec": {
|
||||
"label": "精轨裁剪时间余量(秒)",
|
||||
"type": "number",
|
||||
"default": ORBIT_MARGIN_MIN_SEC,
|
||||
"step": 1,
|
||||
"min": ORBIT_MARGIN_MIN_SEC,
|
||||
"max": ORBIT_MARGIN_MAX_SEC,
|
||||
"description": "生成精轨 XML 时,在场景开始和结束时刻前后额外保留的时间。",
|
||||
"recommendation": "建议优先使用 60;如果元数据时间标签偏紧或边界异常,可提高到 90 或 120。",
|
||||
},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
def normalize_extra(self, extra: Dict[str, Any] | None) -> Dict[str, Any]:
|
||||
normalized: Dict[str, Any] = dict(extra or {})
|
||||
normalized.pop("wavelength", None)
|
||||
|
||||
if "force" in normalized:
|
||||
normalized["force"] = bool(normalized["force"])
|
||||
|
||||
if "bbox" in normalized and normalized["bbox"] is not None:
|
||||
normalized["bbox"] = str(normalized["bbox"]).strip()
|
||||
|
||||
if "target_grid_size_m" in normalized and normalized["target_grid_size_m"] is not None:
|
||||
try:
|
||||
grid_size = float(normalized["target_grid_size_m"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("目标网格尺寸必须为数字。") from exc
|
||||
if int(grid_size) != grid_size:
|
||||
raise ValueError("目标网格尺寸必须使用整数米。")
|
||||
grid_size = int(grid_size)
|
||||
if grid_size < TARGET_GRID_SIZE_MIN_M or grid_size > TARGET_GRID_SIZE_MAX_M:
|
||||
raise ValueError(
|
||||
f"目标网格尺寸必须在 {TARGET_GRID_SIZE_MIN_M} 到 {TARGET_GRID_SIZE_MAX_M} 米之间。"
|
||||
)
|
||||
normalized["target_grid_size_m"] = grid_size
|
||||
|
||||
if "coh_threshold" in normalized and normalized["coh_threshold"] is not None:
|
||||
try:
|
||||
coh_threshold = float(normalized["coh_threshold"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("相干性阈值必须为数字。") from exc
|
||||
if coh_threshold < 0 or coh_threshold > 1:
|
||||
raise ValueError("相干性阈值必须在 0 到 1 之间。")
|
||||
normalized["coh_threshold"] = coh_threshold
|
||||
|
||||
if "bbox_margin" in normalized and normalized["bbox_margin"] is not None:
|
||||
try:
|
||||
bbox_margin = float(normalized["bbox_margin"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("范围外扩量必须为数字。") from exc
|
||||
if bbox_margin < 0:
|
||||
raise ValueError("范围外扩量不能小于 0。")
|
||||
normalized["bbox_margin"] = bbox_margin
|
||||
|
||||
if "orbit_margin_sec" in normalized and normalized["orbit_margin_sec"] is not None:
|
||||
try:
|
||||
orbit_margin = float(normalized["orbit_margin_sec"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("精轨裁剪时间余量必须为数字。") from exc
|
||||
if orbit_margin < ORBIT_MARGIN_MIN_SEC or orbit_margin > ORBIT_MARGIN_MAX_SEC:
|
||||
raise ValueError(
|
||||
f"精轨裁剪时间余量必须在 {int(ORBIT_MARGIN_MIN_SEC)} 到 {int(ORBIT_MARGIN_MAX_SEC)} 秒之间。"
|
||||
)
|
||||
normalized["orbit_margin_sec"] = orbit_margin
|
||||
|
||||
return normalized
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Availability
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def check_available(self) -> EngineAvailability:
|
||||
if not self._enabled:
|
||||
return EngineAvailability(
|
||||
engine_code=self.engine_code,
|
||||
status="unavailable",
|
||||
available=False,
|
||||
checks=[
|
||||
{
|
||||
"name": "ISCE2_ENABLED",
|
||||
"ok": False,
|
||||
"detail": "ISCE2_ENABLED=false",
|
||||
}
|
||||
],
|
||||
message="ISCE2 is disabled. Set ISCE2_ENABLED=true to enable it.",
|
||||
)
|
||||
|
||||
from ..services.wsl_service import check_wsl_environment
|
||||
|
||||
report = check_wsl_environment(
|
||||
distro=self._distro,
|
||||
python_cmd=self._python,
|
||||
stripmap_app_path=self._stripmap_app,
|
||||
pipeline_script_path=self._pipeline_script,
|
||||
dem_path_win=self._dem_path,
|
||||
orbit_dir_win=self._orbit_pool_isce2,
|
||||
output_dir_win=self._output_root,
|
||||
smoke_test=self._smoke_test,
|
||||
)
|
||||
|
||||
checks_list = [
|
||||
{"name": check.name, "ok": check.ok, "detail": check.detail, "skipped": check.skipped}
|
||||
for check in report.checks
|
||||
]
|
||||
|
||||
if report.overall_ok:
|
||||
status = "ok"
|
||||
available = True
|
||||
else:
|
||||
critical_failed = [check for check in report.checks if not check.ok and not check.skipped]
|
||||
status = "degraded" if critical_failed else "unavailable"
|
||||
available = False
|
||||
|
||||
return EngineAvailability(
|
||||
engine_code=self.engine_code,
|
||||
status=status,
|
||||
available=available,
|
||||
checks=checks_list,
|
||||
message=report.message,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Task discovery
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def validate_root_dir(self, root_dir: str, num_to_process: int = 0) -> Dict[str, Any]:
|
||||
normalized_root = os.path.normpath(os.path.abspath(str(root_dir or "").strip()))
|
||||
if not root_dir or not os.path.isdir(normalized_root):
|
||||
raise ValueError(f"ISCE2 root_dir does not exist or is not a directory: {root_dir}")
|
||||
|
||||
if self._looks_like_task_dir(normalized_root):
|
||||
task_dirs = [normalized_root]
|
||||
invalid_candidates: List[Dict[str, Any]] = []
|
||||
mode = "single_task_dir"
|
||||
else:
|
||||
task_dirs = []
|
||||
invalid_candidates = []
|
||||
for entry in self._iter_child_dirs(normalized_root):
|
||||
if not entry.name.lower().startswith("task_"):
|
||||
continue
|
||||
missing = self._missing_task_subdirs(entry.path)
|
||||
if missing:
|
||||
invalid_candidates.append(
|
||||
{"name": entry.name, "path": entry.path, "missing_subdirs": missing}
|
||||
)
|
||||
continue
|
||||
task_dirs.append(os.path.normpath(entry.path))
|
||||
mode = "task_root_dir"
|
||||
|
||||
if not task_dirs:
|
||||
detail = ""
|
||||
if invalid_candidates:
|
||||
formatted = ", ".join(
|
||||
f"{item['name']} missing {','.join(item['missing_subdirs'])}"
|
||||
for item in invalid_candidates[:5]
|
||||
)
|
||||
detail = f" Invalid candidates: {formatted}."
|
||||
raise ValueError(
|
||||
"ISCE2 root_dir must be either a single task directory containing "
|
||||
"'master' and 'slave', or a parent directory containing valid Task_* subdirectories."
|
||||
f"{detail}"
|
||||
)
|
||||
|
||||
selected_count = int(num_to_process or 0)
|
||||
if selected_count > 0:
|
||||
task_dirs = task_dirs[:selected_count]
|
||||
|
||||
return {
|
||||
"root_dir": normalized_root,
|
||||
"mode": mode,
|
||||
"task_dirs": task_dirs,
|
||||
"task_count": len(task_dirs),
|
||||
"invalid_candidates": invalid_candidates,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _iter_child_dirs(root_dir: str):
|
||||
with os.scandir(root_dir) as entries:
|
||||
child_dirs = [entry for entry in entries if entry.is_dir()]
|
||||
child_dirs.sort(key=lambda entry: entry.name.lower())
|
||||
return child_dirs
|
||||
|
||||
@staticmethod
|
||||
def _missing_task_subdirs(task_dir: str) -> List[str]:
|
||||
missing: List[str] = []
|
||||
for subdir in ("master", "slave"):
|
||||
if not os.path.isdir(os.path.join(task_dir, subdir)):
|
||||
missing.append(subdir)
|
||||
return missing
|
||||
|
||||
def _looks_like_task_dir(self, task_dir: str) -> bool:
|
||||
return not self._missing_task_subdirs(task_dir)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Run
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run(self, request: RunRequest) -> RunResult:
|
||||
if not self._enabled:
|
||||
return RunResult(
|
||||
success=False,
|
||||
engine_code=self.engine_code,
|
||||
profile=request.profile,
|
||||
job_id=request.job_id,
|
||||
error="ISCE2 is disabled.",
|
||||
)
|
||||
|
||||
if request.profile != "lt1_stripmap":
|
||||
return RunResult(
|
||||
success=False,
|
||||
engine_code=self.engine_code,
|
||||
profile=request.profile,
|
||||
job_id=request.job_id,
|
||||
error=f"Unknown profile: {request.profile}",
|
||||
)
|
||||
|
||||
return self._run_lt1_stripmap(request)
|
||||
|
||||
def _run_lt1_stripmap(self, request: RunRequest) -> RunResult:
|
||||
from ..services.wsl_service import run_wsl_command, windows_path_to_wsl
|
||||
|
||||
extra = self.normalize_extra(request.extra)
|
||||
validation = self.validate_root_dir(request.root_dir, request.num_to_process)
|
||||
task_dirs: List[str] = validation["task_dirs"]
|
||||
run_started_at = datetime.utcnow()
|
||||
run_started_at_text = run_started_at.isoformat(timespec="seconds") + "Z"
|
||||
run_key = build_run_key(self.engine_code, request.profile, started_at=run_started_at)
|
||||
|
||||
timeout = request.timeout_seconds or 21600
|
||||
force = bool(extra.get("force"))
|
||||
target_grid_size_m = int(extra.get("target_grid_size_m", DEFAULT_TARGET_GRID_SIZE_M))
|
||||
bbox = extra.get("bbox", "")
|
||||
coh_threshold = extra.get("coh_threshold")
|
||||
bbox_margin = extra.get("bbox_margin")
|
||||
wavelength = LT1_FIXED_WAVELENGTH
|
||||
orbit_margin_sec = extra.get("orbit_margin_sec")
|
||||
|
||||
wsl_isce2_pool = ""
|
||||
if self._orbit_pool_isce2:
|
||||
wsl_isce2_pool = windows_path_to_wsl(self._orbit_pool_isce2, distro=self._distro)
|
||||
|
||||
wsl_dem = ""
|
||||
if self._dem_path:
|
||||
wsl_dem = windows_path_to_wsl(self._dem_path, distro=self._distro)
|
||||
|
||||
task_results: List[Dict[str, Any]] = []
|
||||
output_dirs: List[str] = []
|
||||
pairs_processed = 0
|
||||
pairs_failed = 0
|
||||
|
||||
for task_dir in task_dirs:
|
||||
task_name = os.path.basename(os.path.normpath(task_dir))
|
||||
pair_meta = find_json_sidecar(task_dir, PAIR_META_FILENAME, max_levels=0) or {}
|
||||
task_alias = str(pair_meta.get("task_alias") or task_name).strip() or task_name
|
||||
pair_key = str(pair_meta.get("pair_key") or "").strip() or build_fallback_pair_key(task_alias, task_dir)
|
||||
work_root = self._work_root or os.path.join(task_dir, "isce2_work")
|
||||
output_root = self._output_root or os.path.join(task_dir, "isce2_output")
|
||||
work_dir = os.path.normpath(os.path.join(work_root, pair_key, run_key))
|
||||
output_dir = os.path.normpath(os.path.join(output_root, pair_key, run_key, "native"))
|
||||
orbit_output_dir = os.path.join(work_dir, "orbits")
|
||||
wsl_task_dir = windows_path_to_wsl(task_dir, distro=self._distro)
|
||||
wsl_work_dir = windows_path_to_wsl(work_dir, distro=self._distro)
|
||||
wsl_output_dir = windows_path_to_wsl(output_dir, distro=self._distro)
|
||||
wsl_orbit_output_dir = windows_path_to_wsl(orbit_output_dir, distro=self._distro)
|
||||
if not wsl_task_dir:
|
||||
pairs_failed += 1
|
||||
task_results.append(
|
||||
{
|
||||
"task_name": task_name,
|
||||
"task_alias": task_alias,
|
||||
"pair_key": pair_key,
|
||||
"run_key": run_key,
|
||||
"task_dir": task_dir,
|
||||
"work_dir": work_dir,
|
||||
"output_dir": output_dir,
|
||||
"success": False,
|
||||
"returncode": -2,
|
||||
"error": f"Unable to convert task dir to WSL path: {task_dir}",
|
||||
"stdout_tail": "",
|
||||
"stderr_tail": "",
|
||||
"command": "",
|
||||
"wsl_task_dir": "",
|
||||
"wsl_work_dir": "",
|
||||
"wsl_output_dir": "",
|
||||
}
|
||||
)
|
||||
continue
|
||||
if not wsl_work_dir or not wsl_output_dir or not wsl_orbit_output_dir:
|
||||
pairs_failed += 1
|
||||
task_results.append(
|
||||
{
|
||||
"task_name": task_name,
|
||||
"task_alias": task_alias,
|
||||
"pair_key": pair_key,
|
||||
"run_key": run_key,
|
||||
"task_dir": task_dir,
|
||||
"work_dir": work_dir,
|
||||
"output_dir": output_dir,
|
||||
"success": False,
|
||||
"returncode": -2,
|
||||
"error": "Unable to convert ISCE2 work/output paths to WSL paths.",
|
||||
"stdout_tail": "",
|
||||
"stderr_tail": "",
|
||||
"command": "",
|
||||
"wsl_task_dir": wsl_task_dir,
|
||||
"wsl_work_dir": wsl_work_dir,
|
||||
"wsl_output_dir": wsl_output_dir,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
cmd_parts = [
|
||||
"export PROJ_DATA=/home/administrator/miniconda3/envs/isce2/share/proj",
|
||||
f"&& {self._python} '{self._pipeline_script}' '{wsl_task_dir}'",
|
||||
f"--task-name '{task_alias}'",
|
||||
f"--output-prefix '{task_alias}'",
|
||||
f"--work-dir '{wsl_work_dir}'",
|
||||
f"--output-dir '{wsl_output_dir}'",
|
||||
f"--orbit-output-dir '{wsl_orbit_output_dir}'",
|
||||
]
|
||||
if wsl_isce2_pool:
|
||||
cmd_parts.append(f"--orbit-root '{wsl_isce2_pool}'")
|
||||
if wsl_dem:
|
||||
cmd_parts.append(f"--dem '{wsl_dem}'")
|
||||
if force:
|
||||
cmd_parts.append("--force")
|
||||
if target_grid_size_m:
|
||||
cmd_parts.append(f"--target-grid-size-m {target_grid_size_m}")
|
||||
if bbox:
|
||||
cmd_parts.append(f"--bbox '{bbox}'")
|
||||
if coh_threshold is not None:
|
||||
cmd_parts.append(f"--coh-threshold {coh_threshold}")
|
||||
if bbox_margin is not None:
|
||||
cmd_parts.append(f"--bbox-margin {bbox_margin}")
|
||||
if wavelength is not None:
|
||||
cmd_parts.append(f"--wavelength {wavelength}")
|
||||
if orbit_margin_sec is not None:
|
||||
cmd_parts.append(f"--orbit-margin-sec {orbit_margin_sec}")
|
||||
|
||||
cmd = " ".join(cmd_parts)
|
||||
rc, stdout, stderr = run_wsl_command(
|
||||
cmd,
|
||||
distro=self._distro,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
success = rc == 0
|
||||
if success:
|
||||
pairs_processed += 1
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
write_run_metadata(
|
||||
output_dir,
|
||||
{
|
||||
"run_key": run_key,
|
||||
"pair_key": pair_key,
|
||||
"task_name": task_name,
|
||||
"task_alias": task_alias,
|
||||
"engine_code": self.engine_code,
|
||||
"profile_code": request.profile,
|
||||
"source_root": os.path.normpath(request.root_dir),
|
||||
"task_dir": os.path.normpath(task_dir),
|
||||
"work_dir": work_dir,
|
||||
"output_dir": output_dir,
|
||||
"orbit_output_dir": orbit_output_dir,
|
||||
"started_at": run_started_at_text,
|
||||
"finished_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
|
||||
"params": {
|
||||
"force": force,
|
||||
"target_grid_size_m": target_grid_size_m,
|
||||
"bbox": bbox,
|
||||
"coh_threshold": coh_threshold,
|
||||
"bbox_margin": bbox_margin,
|
||||
"wavelength": wavelength,
|
||||
"orbit_margin_sec": orbit_margin_sec,
|
||||
},
|
||||
"master_path": pair_meta.get("master_path"),
|
||||
"slave_path": pair_meta.get("slave_path"),
|
||||
"master_satellite": pair_meta.get("master_satellite"),
|
||||
"slave_satellite": pair_meta.get("slave_satellite"),
|
||||
"master_imaging_date": pair_meta.get("master_imaging_date"),
|
||||
"slave_imaging_date": pair_meta.get("slave_imaging_date"),
|
||||
"master_imaging_mode": pair_meta.get("master_imaging_mode"),
|
||||
"slave_imaging_mode": pair_meta.get("slave_imaging_mode"),
|
||||
"master_polarization": pair_meta.get("master_polarization"),
|
||||
"slave_polarization": pair_meta.get("slave_polarization"),
|
||||
"time_baseline_days": pair_meta.get("time_baseline_days"),
|
||||
"spatial_baseline_meters": pair_meta.get("spatial_baseline_meters"),
|
||||
"scene_pair_uid": pair_meta.get("scene_pair_uid") or pair_meta.get("pair_uid"),
|
||||
"pair_uid": pair_meta.get("pair_uid") or pair_meta.get("scene_pair_uid"),
|
||||
"network_run_id": pair_meta.get("network_run_id"),
|
||||
"network_edge_id": pair_meta.get("network_edge_id"),
|
||||
"policy_version": pair_meta.get("policy_version"),
|
||||
"selection_strategy": pair_meta.get("selection_strategy"),
|
||||
},
|
||||
)
|
||||
output_dirs.append(output_dir)
|
||||
else:
|
||||
pairs_failed += 1
|
||||
|
||||
task_results.append(
|
||||
{
|
||||
"task_name": task_name,
|
||||
"task_alias": task_alias,
|
||||
"pair_key": pair_key,
|
||||
"run_key": run_key,
|
||||
"task_dir": task_dir,
|
||||
"work_dir": work_dir,
|
||||
"output_dir": output_dir,
|
||||
"wsl_task_dir": wsl_task_dir,
|
||||
"wsl_work_dir": wsl_work_dir,
|
||||
"wsl_output_dir": wsl_output_dir,
|
||||
"command": cmd,
|
||||
"success": success,
|
||||
"returncode": rc,
|
||||
"stdout_tail": stdout[-3000:] if stdout else "",
|
||||
"stderr_tail": stderr[-3000:] if stderr else "",
|
||||
"error": stderr.strip() if stderr else "",
|
||||
}
|
||||
)
|
||||
|
||||
invalid_candidates = validation.get("invalid_candidates", [])
|
||||
pairs_failed += len(invalid_candidates)
|
||||
|
||||
overall_success = pairs_processed > 0 or (pairs_processed == 0 and pairs_failed == 0)
|
||||
failed_task_names = [
|
||||
item["task_name"]
|
||||
for item in task_results
|
||||
if not item.get("success")
|
||||
] + [item["name"] for item in invalid_candidates]
|
||||
|
||||
error = None
|
||||
if not overall_success:
|
||||
if failed_task_names:
|
||||
error = f"All ISCE2 tasks failed: {', '.join(failed_task_names[:10])}"
|
||||
else:
|
||||
error = "ISCE2 run failed."
|
||||
|
||||
last_task_result = task_results[-1] if task_results else {}
|
||||
return RunResult(
|
||||
success=overall_success,
|
||||
engine_code=self.engine_code,
|
||||
profile=request.profile,
|
||||
job_id=request.job_id,
|
||||
pairs_processed=pairs_processed,
|
||||
pairs_failed=pairs_failed,
|
||||
output_dirs=output_dirs,
|
||||
error=error,
|
||||
detail={
|
||||
"mode": validation["mode"],
|
||||
"task_count": len(task_dirs),
|
||||
"selected_tasks": [item.get("task_alias") or item.get("task_name") for item in task_results],
|
||||
"invalid_candidates": invalid_candidates,
|
||||
"task_results": task_results,
|
||||
"run_key": run_key,
|
||||
"started_at": run_started_at_text,
|
||||
"force": force,
|
||||
"timeout_seconds": timeout,
|
||||
"target_grid_size_m": target_grid_size_m,
|
||||
"wavelength": wavelength,
|
||||
"orbit_margin_sec": orbit_margin_sec,
|
||||
"command": last_task_result.get("command", ""),
|
||||
"stdout_tail": last_task_result.get("stdout_tail", ""),
|
||||
"stderr_tail": last_task_result.get("stderr_tail", ""),
|
||||
"wsl_task_dir": last_task_result.get("wsl_task_dir", ""),
|
||||
"wsl_work_dir": last_task_result.get("wsl_work_dir", ""),
|
||||
"wsl_output_dir": last_task_result.get("wsl_output_dir", ""),
|
||||
"wsl_orbit_pool": wsl_isce2_pool,
|
||||
"wsl_dem": wsl_dem,
|
||||
"wsl_work_root": windows_path_to_wsl(self._work_root, distro=self._distro) if self._work_root else "",
|
||||
"wsl_output_root": windows_path_to_wsl(self._output_root, distro=self._distro) if self._output_root else "",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""LANDSAR 引擎占位实现。
|
||||
|
||||
本轮不实现算法,仅保留接口和前端占位。
|
||||
check_available() 永远返回 not_implemented。
|
||||
run() 直接抛出 NotImplementedError。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from .base import DinsarEngine, EngineAvailability, EngineProfile, RunRequest, RunResult
|
||||
|
||||
|
||||
class LandsarEngine(DinsarEngine):
|
||||
|
||||
@property
|
||||
def engine_code(self) -> str:
|
||||
return "landsar"
|
||||
|
||||
@property
|
||||
def engine_label(self) -> str:
|
||||
return "LANDSAR(预留)"
|
||||
|
||||
def get_profiles(self) -> List[EngineProfile]:
|
||||
return [
|
||||
EngineProfile(
|
||||
code="standard",
|
||||
label="标准链路(未实现)",
|
||||
description="LANDSAR 处理链路,本版本暂未实现",
|
||||
),
|
||||
]
|
||||
|
||||
def check_available(self) -> EngineAvailability:
|
||||
return EngineAvailability(
|
||||
engine_code=self.engine_code,
|
||||
status="not_implemented",
|
||||
available=False,
|
||||
checks=[],
|
||||
message="LANDSAR 引擎尚未实现,仅作接口预留",
|
||||
)
|
||||
|
||||
def run(self, request: RunRequest) -> RunResult:
|
||||
return RunResult(
|
||||
success=False,
|
||||
engine_code=self.engine_code,
|
||||
profile=request.profile,
|
||||
job_id=request.job_id,
|
||||
error="LANDSAR 引擎尚未实现",
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Registry for D-InSAR engine instances."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .base import DinsarEngine
|
||||
|
||||
_registry: Dict[str, DinsarEngine] = {}
|
||||
|
||||
|
||||
def register(engine: DinsarEngine) -> None:
|
||||
"""Registers or replaces an engine instance."""
|
||||
|
||||
_registry[engine.engine_code] = engine
|
||||
|
||||
|
||||
def get_engine(engine_code: str) -> Optional[DinsarEngine]:
|
||||
"""Returns a registered engine instance by code."""
|
||||
|
||||
return _registry.get(engine_code)
|
||||
|
||||
|
||||
def list_engines() -> List[DinsarEngine]:
|
||||
"""Returns all registered engines in registration order."""
|
||||
|
||||
return list(_registry.values())
|
||||
|
||||
|
||||
def _bootstrap() -> None:
|
||||
"""Imports and registers all built-in engines."""
|
||||
|
||||
from .isce2_engine import Isce2Engine
|
||||
from .landsar_engine import LandsarEngine
|
||||
from .sarscape_engine import SarscapeEngine
|
||||
|
||||
for engine in (SarscapeEngine(), Isce2Engine(), LandsarEngine()):
|
||||
register(engine)
|
||||
|
||||
|
||||
_bootstrap()
|
||||
@@ -0,0 +1,144 @@
|
||||
"""SARscape 引擎 — 封装现有 envi_service 逻辑。
|
||||
|
||||
profiles:
|
||||
- metatask : 调用 run_dinsar_workflow()(SARscape 自动链路)
|
||||
- custom6 : 调用 run_dinsar_custom_workflow()(6 步手动链路)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .base import DinsarEngine, EngineAvailability, EngineProfile, RunRequest, RunResult
|
||||
|
||||
|
||||
class SarscapeEngine(DinsarEngine):
|
||||
|
||||
@property
|
||||
def engine_code(self) -> str:
|
||||
return "sarscape"
|
||||
|
||||
@property
|
||||
def engine_label(self) -> str:
|
||||
return "ENVI / SARscape"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Profiles
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_profiles(self) -> List[EngineProfile]:
|
||||
return [
|
||||
EngineProfile(
|
||||
code="metatask",
|
||||
label="SARscape 自动链路",
|
||||
description="调用 SARscape MetaTask,全自动完成 D-InSAR 处理",
|
||||
),
|
||||
EngineProfile(
|
||||
code="custom6",
|
||||
label="6 步手动链路",
|
||||
description="逐步执行:干涉图生成 → 滤波相干 → 轨道去趋势 → 相位解缠 → GCP → 形变地理编码",
|
||||
),
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Availability check
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def check_available(self) -> EngineAvailability:
|
||||
from ..services import envi_service
|
||||
|
||||
status_raw = envi_service.get_status()
|
||||
idl_ok: bool = status_raw.get("idl_installed", False)
|
||||
dem_ok: bool = status_raw.get("dem_exists", False)
|
||||
|
||||
checks = [
|
||||
{
|
||||
"name": "IDL/ENVI 可执行文件",
|
||||
"ok": idl_ok,
|
||||
"detail": status_raw.get("idl_executable", ""),
|
||||
},
|
||||
{
|
||||
"name": "DEM 文件",
|
||||
"ok": dem_ok,
|
||||
"detail": status_raw.get("dem_base_file", ""),
|
||||
},
|
||||
]
|
||||
|
||||
if idl_ok and dem_ok:
|
||||
status = "ok"
|
||||
available = True
|
||||
message = "ENVI/SARscape 可用"
|
||||
elif idl_ok:
|
||||
status = "degraded"
|
||||
available = True
|
||||
message = "IDL 可用但 DEM 未配置,部分功能受限"
|
||||
else:
|
||||
status = "unavailable"
|
||||
available = False
|
||||
message = "IDL/ENVI 未安装或路径错误"
|
||||
|
||||
return EngineAvailability(
|
||||
engine_code=self.engine_code,
|
||||
status=status,
|
||||
available=available,
|
||||
checks=checks,
|
||||
message=message,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Run
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run(self, request: RunRequest) -> RunResult:
|
||||
from ..services import envi_service
|
||||
|
||||
profile = request.profile
|
||||
root_dir = request.root_dir
|
||||
num = request.num_to_process
|
||||
timeout = request.timeout_seconds
|
||||
job_id = request.job_id
|
||||
|
||||
try:
|
||||
if profile == "metatask":
|
||||
result = envi_service.run_dinsar_workflow(
|
||||
root_dir=root_dir,
|
||||
num_to_process=num,
|
||||
timeout_seconds=timeout,
|
||||
job_id=job_id,
|
||||
)
|
||||
elif profile == "custom6":
|
||||
result = envi_service.run_dinsar_custom_workflow(
|
||||
root_dir=root_dir,
|
||||
num_to_process=num,
|
||||
timeout_seconds=timeout,
|
||||
job_id=job_id,
|
||||
)
|
||||
else:
|
||||
return RunResult(
|
||||
success=False,
|
||||
engine_code=self.engine_code,
|
||||
profile=profile,
|
||||
job_id=job_id,
|
||||
error=f"未知 profile: {profile}",
|
||||
)
|
||||
|
||||
pairs_ok = result.get("processed", 0)
|
||||
pairs_fail = result.get("failed", 0)
|
||||
return RunResult(
|
||||
success=pairs_fail == 0 or pairs_ok > 0,
|
||||
engine_code=self.engine_code,
|
||||
profile=profile,
|
||||
job_id=job_id,
|
||||
pairs_processed=pairs_ok,
|
||||
pairs_failed=pairs_fail,
|
||||
detail=result,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
return RunResult(
|
||||
success=False,
|
||||
engine_code=self.engine_code,
|
||||
profile=profile,
|
||||
job_id=job_id,
|
||||
error=str(exc),
|
||||
)
|
||||
Reference in New Issue
Block a user