Files
insar-management-system-v2/backend/app/dinsar_engines/landsar_engine.py
T

1964 lines
76 KiB
Python

"""LandSAR D-InSAR engine integration.
This engine wraps LandSAR's console workflow:
InSAR_Console.exe <Output_Data/200014.txt>
It intentionally does not import the bundled PyQt GUI helper. Only the
documented proID 200014 parameter-file format is reproduced here so the backend
can run in the service process without desktop dependencies.
"""
from __future__ import annotations
import json
import os
import queue
import re
import shutil
import socket
import subprocess
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from ..config import get_env_text, read_bool_env, settings
from ..services.dinsar_naming import (
PAIR_META_FILENAME,
build_fallback_pair_key,
write_pair_metadata,
write_run_metadata,
)
from ..services.dinsar_result_layout_service import normalize_isce2_run_layout
from ..services.isce2_result_validator import validate_isce2_result_files
from ..utils import normalize_satellite_family
from .base import DinsarEngine, EngineAvailability, EngineProfile, RunRequest, RunResult
DINSAR_PROID = "200014"
IMPORT_PROID = "100016"
RERUN_MODE_UNFINISHED_ONLY = "unfinished_only"
SUPPORTED_PROFILES = {"lt1_dinsar", "standard"}
_PROJECT_ROOT = Path(__file__).resolve().parents[3]
_DATE_RE = re.compile(r"(?:^|[_-])((?:19|20)\d{6})(?:[_-]|$)")
_SAFE_NAME_RE = re.compile(r"[^0-9A-Za-z._-]+")
_SUCCESS_RE = re.compile(r"(success|成功)", re.IGNORECASE)
_DEFAULT_PARAM_VALUES: Dict[str, Any] = {
"dem_file_type": 0,
"dem_product_type": 0,
"gcp_file": "",
"gacos_file": "",
"do_registration": 1,
"reg_method": 0,
"reg_grid_points": 64,
"reg_window": 128,
"reg_snr": 7,
"do_resample": 1,
"crop_invalid": 1,
"crop_gcps": 0,
"do_interferogram": 1,
"az_looks": 3,
"rg_looks": 3,
"gen_intensity": 1,
"gcp_geometric": 0,
"gcp_multilook": 0,
"gen_8bit": 0,
"do_deflatten": 1,
"deflat_method": 0,
"deflat_window": 128,
"deflat_oversample": 3,
"do_filter": 1,
"filter_alpha": 0.6,
"filter_iterations": 1,
"do_coherence_mask": 1,
"coh_mask_threshold": 0.3,
"do_unwrap": 1,
"unwrap_method": 0,
"unwrap_az_blocks": 1,
"unwrap_rg_blocks": 1,
"unwrap_coh_threshold": 0.3,
"do_gcp_extract": 1,
"gcp_grid_points": 25,
"gcp_flat_area": 10,
"gcp_height_diff": 10,
"gcp_coh_diff": 0.1,
"do_baseline_refine": 1,
"baseline_method": 0,
"phase_correction": 0,
"baseline_coh_threshold": 0.9,
"optimize_gcps": 0,
"gcp_error_threshold": 10,
"do_diff_fitting": 1,
"diff_coh_threshold": 0.91,
"diff_az_samples": 64,
"diff_rg_samples": 64,
"do_los_displacement": 1,
"displacement_format": 0,
"do_vertical_displacement": 0,
"do_atmosphere": 0,
"do_displacement_correction": 0,
"do_geocoding": 1,
"geo_wrapped": 1,
"geo_unwrapped": 1,
"geo_coherence": 1,
"geo_los": 1,
"geo_vertical": 1,
}
_INT_PARAM_KEYS = {
"dem_file_type",
"dem_product_type",
"do_registration",
"reg_method",
"reg_grid_points",
"reg_window",
"reg_snr",
"do_resample",
"crop_invalid",
"crop_gcps",
"do_interferogram",
"az_looks",
"rg_looks",
"gen_intensity",
"gcp_geometric",
"gcp_multilook",
"gen_8bit",
"do_deflatten",
"deflat_method",
"deflat_window",
"deflat_oversample",
"do_filter",
"filter_iterations",
"do_coherence_mask",
"do_unwrap",
"unwrap_method",
"unwrap_az_blocks",
"unwrap_rg_blocks",
"do_gcp_extract",
"gcp_grid_points",
"gcp_flat_area",
"gcp_height_diff",
"do_baseline_refine",
"baseline_method",
"phase_correction",
"optimize_gcps",
"gcp_error_threshold",
"do_diff_fitting",
"diff_az_samples",
"diff_rg_samples",
"do_los_displacement",
"displacement_format",
"do_vertical_displacement",
"do_atmosphere",
"do_displacement_correction",
"do_geocoding",
"geo_wrapped",
"geo_unwrapped",
"geo_coherence",
"geo_los",
"geo_vertical",
}
_FLOAT_PARAM_KEYS = {
"filter_alpha",
"coh_mask_threshold",
"unwrap_coh_threshold",
"gcp_coh_diff",
"baseline_coh_threshold",
"diff_coh_threshold",
}
_PATH_PARAM_KEYS = {"dem_path", "gcp_file", "gacos_file"}
_LANDSAR_REQUIRED_DLLS = [
"Qt5Core.dll",
"Qt5Network.dll",
"Qt5Widgets.dll",
"libgcc_s_seh-1.dll",
"libwinpthread-1.dll",
"libstdc++-6.dll",
"SAR_ImagePrinterModel.dll",
"SAR_InSAR_DInSARModel.dll",
"SAR_InSAR_GeneralModel.dll",
"SAR_InSAR_GeoInterferometricModel.dll",
"SAR_InSAR_GeometricModel.dll",
"SAR_InSAR_InSARModel.dll",
"SAR_InSAR_IOModel.dll",
"SAR_InSAR_Model.dll",
"SAR_InSAR_MTInSARModel.dll",
"SAR_InSAR_Sequential.dll",
"SAR_SwapIO.dll",
]
_SYSTEM_EXTRA_KEYS = {
"__managed_run_dir",
"__managed_native_output_dir",
"__managed_work_dir",
"__managed_export_dir",
"__managed_orbit_output_dir",
"__managed_run_key",
"__source_root_override",
"__rerun_mode",
"__validated_task_count",
"__validated_mode",
"__discovered_task_count",
"__skipped_completed_count",
}
def _read_env(name: str, default: str = "") -> str:
value = os.getenv(name)
if value is None:
value = get_env_text(name, default)
return str(value or default).strip().strip('"').strip("'")
def _norm_path(path: Any) -> str:
text = str(path or "").strip().strip('"').strip("'")
if not text:
return ""
return os.path.normpath(os.path.abspath(text))
def _utc_text(value: Optional[datetime] = None) -> str:
return (value or datetime.utcnow()).isoformat(timespec="seconds") + "Z"
def _coerce_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
text = str(value or "").strip().lower()
if text in {"1", "true", "yes", "on"}:
return True
if text in {"0", "false", "no", "off", ""}:
return False
return bool(value)
def _read_int_env(name: str, default: int) -> int:
try:
return int(_read_env(name, str(default)) or default)
except (TypeError, ValueError):
return int(default)
def _tcp_connect(host: str, port: int, timeout: float = 1.0) -> bool:
if not host or int(port or 0) <= 0:
return False
try:
with socket.create_connection((host, int(port)), timeout=timeout):
return True
except OSError:
return False
def _decode_line(raw: bytes) -> str:
for encoding in ("utf-8", "gbk", "mbcs"):
try:
return raw.decode(encoding, errors="strict").rstrip("\r\n")
except Exception:
continue
return raw.decode("utf-8", errors="replace").rstrip("\r\n")
def _extract_date(name: str) -> str:
match = _DATE_RE.search(name or "")
if match:
return match.group(1)
fallback = re.search(r"((?:19|20)\d{6})", name or "")
return fallback.group(1) if fallback else ""
def _collect_tail(text: str, max_chars: int = 4000) -> str:
content = str(text or "")
if len(content) <= max_chars:
return content
return content[-max_chars:]
def _summarize_landsar_failure(stdout_text: str, stage: str, return_code: int) -> str:
content = str(stdout_text or "")
lowered = content.lower()
prefix = f"{stage} returned {return_code}"
if "connect server failed" in lowered or "load_server_memory_2_dongle failed" in lowered:
return f"{prefix}: LandSAR network license server is unreachable or rejected the request."
license_failure_markers = [
"hasp_login failed",
"dongle_read failed",
"read_memory(from server) failed",
"buff_parsing failed",
]
if any(marker in lowered for marker in license_failure_markers):
match = re.search(r"HASP_STATUS\s*==\s*([0-9]+)", content, re.IGNORECASE)
status_text = f" (HASP_STATUS == {match.group(1)})" if match else ""
return f"{prefix}: LandSAR license dongle login failed{status_text}."
if "cann't find 'config.csv'" in lowered or "can't find 'config.csv'" in lowered:
return f"{prefix}: LandSAR config.csv is missing. Run versionControl.exe once from LANDSAR_HOME."
lines = [line.strip() for line in content.splitlines() if line.strip()]
error_lines = [
line
for line in lines
if (
re.search(r"\berror\b", line, re.IGNORECASE)
or re.search(r"\bfailed\b", line, re.IGNORECASE)
or "CurProModule:" in line
)
and "dongle.info:" not in line
and line.lower() != "console failed."
]
if error_lines:
return f"{prefix}: {' | '.join(error_lines[-5:])}"
if lines:
return f"{prefix}: {' | '.join(lines[-4:])}"
return f"{prefix}."
def _find_first_existing(*paths: Path) -> str:
for path in paths:
try:
if path.is_file():
return str(path)
except OSError:
continue
return ""
def _split_path_env(value: str) -> List[str]:
return [part.strip() for part in str(value or "").split(os.pathsep) if part.strip()]
def _path_search_dirs(*leading_dirs: str) -> List[str]:
seen = set()
configured_dirs = [
*_split_path_env(_read_env("LANDSAR_RUNTIME_PATHS", "")),
*_split_path_env(_read_env("LANDSAR_DLL_DIRS", "")),
]
result: List[str] = []
for directory in list(leading_dirs) + configured_dirs + [
os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32"),
os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "SysWOW64"),
os.environ.get("SystemRoot", r"C:\Windows"),
*os.environ.get("PATH", "").split(os.pathsep),
]:
normalized = _norm_path(directory)
if not normalized or normalized.lower() in seen:
continue
seen.add(normalized.lower())
result.append(normalized)
return result
def _find_dll(name: str, search_dirs: List[str]) -> str:
for directory in search_dirs:
candidate = os.path.join(directory, name)
try:
if os.path.isfile(candidate):
return _norm_path(candidate)
except OSError:
continue
return ""
def _check_required_dlls(console_path: str, home: str) -> Dict[str, Any]:
console_dir = os.path.dirname(_norm_path(console_path))
search_dirs = _path_search_dirs(console_dir, home)
missing: List[str] = []
found: Dict[str, str] = {}
for name in _LANDSAR_REQUIRED_DLLS:
resolved = _find_dll(name, search_dirs)
if resolved:
found[name] = resolved
else:
missing.append(name)
return {
"ok": not missing,
"missing": missing,
"found": found,
"search_dirs": search_dirs,
}
def _landsar_process_env(console_path: str, home: str) -> Dict[str, str]:
env = dict(os.environ)
leading_dirs = _path_search_dirs(os.path.dirname(_norm_path(console_path)), home)
existing_path = env.get("PATH", "")
env["PATH"] = os.pathsep.join(leading_dirs + ([existing_path] if existing_path else []))
return env
def _copy_file(src: str, dst: str) -> None:
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(src, dst)
def _has_lt1_source_data(directory: str) -> bool:
source_dir = _norm_path(directory)
if not os.path.isdir(source_dir):
return False
for entry in os.scandir(source_dir):
if not entry.is_file():
continue
lower_name = entry.name.lower()
if lower_name.startswith("lt1") and lower_name.endswith((".xml", ".tif", ".tiff")):
return True
return False
def _looks_like_raw_task_dir(task_dir: str) -> bool:
normalized = _norm_path(task_dir)
master_dir = os.path.join(normalized, "master")
slave_dir = os.path.join(normalized, "slave")
return (
os.path.isdir(master_dir)
and os.path.isdir(slave_dir)
and _has_lt1_source_data(master_dir)
and _has_lt1_source_data(slave_dir)
)
def parse_lt1_slc_pair(input_data_dir: str) -> Optional[Dict[str, Any]]:
"""Return the first chronological LT-1 SLC pair from Input_Data."""
input_dir = _norm_path(input_data_dir)
if not os.path.isdir(input_dir):
return None
by_date: Dict[str, Dict[str, str]] = {}
for entry in os.scandir(input_dir):
if not entry.is_file():
continue
lower_name = entry.name.lower()
if not lower_name.endswith((".xml", ".tif", ".tiff")):
continue
date_text = _extract_date(entry.name)
if not date_text:
continue
payload = by_date.setdefault(date_text, {})
if lower_name.endswith(".xml") and "xml" not in payload:
payload["xml"] = entry.path
elif lower_name.endswith((".tif", ".tiff")) and "tif" not in payload:
payload["tif"] = entry.path
scenes = [
{"date": date_text, **paths}
for date_text, paths in sorted(by_date.items())
if paths.get("xml") and paths.get("tif")
]
if len(scenes) < 2:
return None
master = scenes[0]
slave = scenes[1]
return {
"master_date": master["date"],
"slave_date": slave["date"],
"master_xml": _norm_path(master["xml"]),
"master_tif": _norm_path(master["tif"]),
"slave_xml": _norm_path(slave["xml"]),
"slave_tif": _norm_path(slave["tif"]),
}
def _generate_import_param_file(
filepath: str,
*,
master_dir: str,
slave_dir: str,
export_dir: str,
import_method: str = "dir",
sat_mode: str = "BIST",
read_xml: bool = True,
read_slc: bool = True,
export_to_new: bool = True,
master_xml: str = "",
master_slc: str = "",
master_rpb: str = "",
slave_xml: str = "",
slave_slc: str = "",
slave_rpb: str = "",
) -> str:
is_dir_import = import_method == "dir"
lines = [
"卫星数据导入LT-1",
f"处理 {IMPORT_PROID}",
f"设置数据导入形式_0文件夹导入_1数据导入 {'文件夹导入' if is_dir_import else '数据导入'}",
f"读取成像参数文件_0否_1是 {'1' if read_xml else '0'}",
f"读取SLC数据文件_0否_1是 {'1' if read_slc else '0'}",
f"文件夹导入标识 {'TRUE' if is_dir_import else 'FALSE'}",
"文件夹导入个数 2",
f"文件夹1路径 <{master_dir}>",
f"文件夹2路径 <{slave_dir}>",
f"数据导入 {'FALSE' if is_dir_import else 'TRUE'}",
f"输入卫星数据格式 {sat_mode}",
f"输入主影像成像参数文件路径 <{master_xml}>",
f"输入主影像SLC数据文件路径 <{master_slc}>",
f"输入主影像RPB数据文件路径 <{master_rpb}>",
f"输入辅影像成像参数文件路径 <{slave_xml}>",
f"输入辅影像SLC数据文件路径 <{slave_slc}>",
f"输入辅影像RPB数据文件路径 <{slave_rpb}>",
f"设置数据导出目标路径_0原目录_1新目录 {'1' if export_to_new else '0'}",
f"设置输出文件目录 <{export_dir}>",
]
target = _norm_path(filepath)
os.makedirs(os.path.dirname(target), exist_ok=True)
with open(target, "w", encoding="utf-8") as fp:
fp.write("\n".join(lines) + "\n")
return target
def _generate_dinsar_param_file(
filepath: str,
*,
master_xml: str,
master_tif: str,
slave_xml: str,
slave_tif: str,
dem_path: str,
output_dir: str,
params: Dict[str, Any],
) -> str:
def p(key: str, default: Any) -> Any:
return params.get(key, default)
lines = [
"DInSARProcess流程化处理",
f"ID\t {DINSAR_PROID}",
"",
"输入输出数据设置",
f"输入主影像XML文件路径\t <{master_xml}>",
f"输入主影像SLC数据路径\t <{master_tif}>",
f"输入辅影像XML文件路径\t <{slave_xml}>",
f"输入辅影像SLC数据路径\t <{slave_tif}>",
f"输入外部参考DEM文件类型_0文件_1目录\t {p('dem_file_type', 0)}",
f"输入外部参考DEM产品类型_0STRM_1TanDEM\t {p('dem_product_type', 0)}",
f"输入外部参考DEM路径或目录\t <{dem_path}>",
f"输入控制点文件 <{p('gcp_file', '')}>",
f"输入外部GACOS大气相位数据 <{p('gacos_file', '')}>",
f"输出差分干涉处理结果目录 <{output_dir}>",
"",
f"是否处理干涉对配准模块 {p('do_registration', 1)}",
f"配准方法\t {p('reg_method', 0)}",
f"规则格网点数\t {p('reg_grid_points', 64)}",
f"配准窗口\t {p('reg_window', 128)}",
f"信噪比\t {p('reg_snr', 7)}",
"",
f"是否处理辅影像重采样模块 {p('do_resample', 1)}",
f"裁剪无效区域标识\t {p('crop_invalid', 1)}",
f"裁剪主影像对应的GCPs文件\t {p('crop_gcps', 0)}",
"",
f"是否处理干涉条纹图模块 {p('do_interferogram', 1)}",
f"方位向多视\t {p('az_looks', 3)}",
f"距离向多视\t {p('rg_looks', 3)}",
f"是否生成多视强度影像(0表示不生成_1表示生成) {p('gen_intensity', 1)}",
f"是否利用控制点进行几何纠正(0表示不纠正_1表示纠正) {p('gcp_geometric', 0)}",
f"是否对控制点数据进行多视(0表示不多视_1表示多视) {p('gcp_multilook', 0)}",
f"是否生成生成8bit灰度影像(0表示不生成_1表示生成) {p('gen_8bit', 0)}",
"",
f"是否处理去除平地地形相位模块 {p('do_deflatten', 1)}",
f"去除相位方法标识 {p('deflat_method', 0)}",
f"精配准窗口大小 {p('deflat_window', 128)}",
f"采样倍数 {p('deflat_oversample', 3)}",
"",
f"是否处理滤波干涉条纹图模块 {p('do_filter', 1)}",
f"滤波因子 {p('filter_alpha', 0.6)}",
f"滤波次数 {p('filter_iterations', 1)}",
"",
f"是否处理相干性掩膜模块 {p('do_coherence_mask', 1)}",
f"相干性掩膜阈值 {p('coh_mask_threshold', 0.3)}",
"",
f"是否处理相位解缠模块 {p('do_unwrap', 1)}",
f"解缠方法 {p('unwrap_method', 0)}",
f"方位向分块 {p('unwrap_az_blocks', 1)}",
f"距离向分块 {p('unwrap_rg_blocks', 1)}",
f"相干性阈值 {p('unwrap_coh_threshold', 0.3)}",
"",
f"是否处理GCP提取模块 {p('do_gcp_extract', 1)}",
f"规则格网点数 {p('gcp_grid_points', 25)}",
f"在窗口内筛选相对平坦区域 {p('gcp_flat_area', 10)}",
f"窗口内高差设置 {p('gcp_height_diff', 10)}",
f"窗口内相干性差异设置 {p('gcp_coh_diff', 0.1)}",
"",
f"是否处理基线精估计模块 {p('do_baseline_refine', 1)}",
f"基线精估计方法 {p('baseline_method', 0)}",
f"相位校正标识 {p('phase_correction', 0)}",
f"基线精估计相干性阈值 {p('baseline_coh_threshold', 0.9)}",
f"优选控制点标识 {p('optimize_gcps', 0)}",
f"优选控制点误差阈值 {p('gcp_error_threshold', 10)}",
"",
f"是否处理差分干涉相位拟合模块 {p('do_diff_fitting', 1)}",
f"相干阈值 \t\t\t\t{p('diff_coh_threshold', 0.91)}",
f"方位向采样点数 {p('diff_az_samples', 64)}",
f"距离向采样点数\t {p('diff_rg_samples', 64)}",
"",
f"是否处理相位转LOS向形变模块 {p('do_los_displacement', 1)}",
f"形变图产品形式 {p('displacement_format', 0)}",
"",
f"是否处理LOS向形变转垂直向形变模块 {p('do_vertical_displacement', 0)}",
"",
f"是否处理大气相位改正模块 {p('do_atmosphere', 0)}",
"",
f"是否处理形变结果校正模块 {p('do_displacement_correction', 0)}",
"",
f"是否处理地理编码模块 {p('do_geocoding', 1)}",
f"是否编码滤波后缠绕数据(差分干涉相位拟合) {p('geo_wrapped', 1)}",
f"是否编码滤波后解缠数据(差分干涉相位拟合) {p('geo_unwrapped', 1)}",
f"是否编码滤波后相干系数数据 {p('geo_coherence', 1)}",
f"是否编码视线向形变场数据 {p('geo_los', 1)}",
f"是否编码垂直向形变场数据 {p('geo_vertical', 1)}",
]
target = _norm_path(filepath)
os.makedirs(os.path.dirname(target), exist_ok=True)
with open(target, "w", encoding="utf-8") as fp:
fp.write("\n".join(lines) + "\n")
return target
class LandsarEngine(DinsarEngine):
@property
def engine_code(self) -> str:
return "landsar"
@property
def engine_label(self) -> str:
return "LandSAR"
@property
def default_timeout_seconds(self) -> int:
return max(60, int(getattr(settings, "LANDSAR_DINSAR_TIMEOUT_SECONDS", 0) or _read_env("LANDSAR_DINSAR_TIMEOUT_SECONDS", "43200") or 43200))
@property
def _enabled(self) -> bool:
return read_bool_env("LANDSAR_ENABLED", True)
@property
def _default_home(self) -> str:
return str(_PROJECT_ROOT / "third_party" / "LandSAR" / "dist" / "LandSAR_Portable")
@property
def _home(self) -> str:
return _norm_path(_read_env("LANDSAR_HOME", self._default_home))
@property
def _console_exe(self) -> str:
explicit = _read_env("LANDSAR_CONSOLE_EXE", "")
if explicit:
return _norm_path(explicit)
home = self._home
candidates = [
Path(home) / "InSAR_Console.exe",
_PROJECT_ROOT / "third_party" / "LandSAR" / "dist" / "LandSAR_Portable" / "InSAR_Console.exe",
_PROJECT_ROOT / "third_party" / "LandSAR" / "dist" / "InSAR_Console.exe",
]
found = _find_first_existing(*candidates)
return _norm_path(found or str(candidates[0]))
@property
def _config_csv(self) -> str:
return _norm_path(os.path.join(self._home, "config", "config.csv"))
@property
def _version_control_exe(self) -> str:
return _norm_path(os.path.join(self._home, "versionControl.exe"))
@property
def _license_mode(self) -> str:
return _read_env("LANDSAR_LICENSE_MODE", str(getattr(settings, "LANDSAR_LICENSE_MODE", "netVersion") or "netVersion"))
@property
def _license_host(self) -> str:
return _read_env("LANDSAR_LICENSE_HOST", str(getattr(settings, "LANDSAR_LICENSE_HOST", "127.0.0.1") or "127.0.0.1"))
@property
def _license_port(self) -> int:
return _read_int_env("LANDSAR_LICENSE_PORT", int(getattr(settings, "LANDSAR_LICENSE_PORT", 6666) or 6666))
@property
def _config_auto_write(self) -> bool:
return read_bool_env("LANDSAR_CONFIG_AUTO_WRITE", bool(getattr(settings, "LANDSAR_CONFIG_AUTO_WRITE", True)))
@property
def _auth_server_exe(self) -> str:
return _norm_path(_read_env("LANDSAR_AUTH_SERVER_EXE", str(getattr(settings, "LANDSAR_AUTH_SERVER_EXE", "") or "")))
@property
def _auth_server_auto_start(self) -> bool:
return read_bool_env("LANDSAR_AUTH_SERVER_AUTO_START", bool(getattr(settings, "LANDSAR_AUTH_SERVER_AUTO_START", True)))
@property
def _auth_server_host(self) -> str:
return _read_env("LANDSAR_AUTH_SERVER_HOST", str(getattr(settings, "LANDSAR_AUTH_SERVER_HOST", "127.0.0.1") or "127.0.0.1"))
@property
def _auth_server_port(self) -> int:
return _read_int_env("LANDSAR_AUTH_SERVER_PORT", int(getattr(settings, "LANDSAR_AUTH_SERVER_PORT", 6666) or 6666))
@property
def _expected_config_row(self) -> str:
explicit = _read_env("LANDSAR_CONFIG_ROW", str(getattr(settings, "LANDSAR_CONFIG_ROW", "") or ""))
if explicit:
return explicit
mode = self._license_mode or "netVersion"
host = self._license_host or "127.0.0.1"
port = self._license_port
return f"{mode},zh,{host},{port}"
def _expected_config_parts(self) -> List[str]:
return [part.strip() for part in self._expected_config_row.split(",")]
def _expected_config_mode(self) -> str:
parts = self._expected_config_parts()
return parts[0] if parts else self._license_mode
def _expected_config_host(self) -> str:
parts = self._expected_config_parts()
if len(parts) >= 3 and parts[2]:
return parts[2]
return self._license_host or "127.0.0.1"
def _expected_config_port(self) -> int:
parts = self._expected_config_parts()
if len(parts) >= 4:
try:
return int(parts[3])
except (TypeError, ValueError):
pass
return int(self._license_port or 6666)
@property
def _default_dem_path(self) -> str:
landsar_dem = _read_env("LANDSAR_DEM_PATH", "")
if landsar_dem:
return _norm_path(landsar_dem)
pyint_dem = _read_env("PYINT_PREPARED_DEM_PATH", "")
if pyint_dem:
return _norm_path(pyint_dem)
isce2_dem = _read_env("ISCE2_DEM_PATH", "")
if isce2_dem:
return _norm_path(isce2_dem)
return _norm_path(getattr(settings, "IDL_DINSAR_DEM_BASE_FILE", "") or "")
def _read_config_rows(self) -> List[str]:
path = self._config_csv
try:
with open(path, "r", encoding="utf-8-sig", errors="ignore") as fp:
return [line.strip() for line in fp.read().splitlines() if line.strip()]
except OSError:
return []
def _config_matches_expected(self) -> bool:
rows = self._read_config_rows()
if len(rows) < 2:
return False
return rows[1].strip().lower() == self._expected_config_row.lower()
def _ensure_config_csv(self) -> tuple[bool, str]:
path = self._config_csv
if self._config_matches_expected():
return True, f"ok: {path}"
if not self._config_auto_write:
if os.path.isfile(path):
return False, f"LandSAR config.csv is not set to expected license row: {path}"
return False, f"LandSAR config.csv is missing: {path}"
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8", newline="\n") as fp:
fp.write("version,language,Address,port\n")
fp.write(self._expected_config_row + "\n")
return True, f"wrote: {path}"
except OSError as exc:
return False, f"failed to write {path}: {exc}"
def _is_network_license_mode(self) -> bool:
return self._expected_config_mode().strip().lower() == "netversion"
def _start_auth_server_if_needed(self) -> tuple[bool, str]:
if not self._is_network_license_mode():
return True, "not required for standalone license mode"
client_host = self._expected_config_host()
client_port = int(self._expected_config_port())
bind_host = self._auth_server_host or client_host
bind_port = int(self._auth_server_port or client_port)
if _tcp_connect(client_host, client_port):
return True, f"listening on {client_host}:{client_port}"
exe = self._auth_server_exe
if not self._auth_server_auto_start:
return False, f"not listening on {client_host}:{client_port}; auto-start disabled"
if not exe or not os.path.isfile(exe):
return False, f"auth server executable missing: {exe or '<empty>'}"
server_dir = os.path.dirname(exe)
memory_bin = os.path.join(server_dir, "dongle_0xa0.bin")
if not os.path.isfile(memory_bin):
fallback_bin = str(_PROJECT_ROOT / "third_party" / "LandSAR" / "tools" / "dongle_0xa0.bin")
if os.path.isfile(fallback_bin):
try:
shutil.copy2(fallback_bin, memory_bin)
except OSError as exc:
return False, f"failed to copy LandSAR authorization block to auth server dir: {exc}"
else:
return False, f"auth server memory image missing: {memory_bin}"
command = [exe, "--host", bind_host, "--port", str(bind_port)]
try:
subprocess.Popen(
command,
cwd=server_dir,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except OSError as exc:
return False, f"failed to start auth server: {exc}"
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if _tcp_connect(client_host, client_port):
return True, f"started: {exe} on {bind_host}:{bind_port}; client={client_host}:{client_port}"
time.sleep(0.25)
return False, f"started auth server but client port is not reachable: {client_host}:{client_port}"
def get_profiles(self) -> List[EngineProfile]:
params_schema = {
"dem_path": {
"label": "DEM 文件",
"type": "string",
"default": self._default_dem_path,
"section": "输入数据",
"description": "传给 LandSAR 200014 D-InSAR 模块的外部参考 DEM,建议使用已验证可用的 GeoTIFF。",
},
"az_looks": {
"label": "方位向多视数",
"type": "number",
"default": _DEFAULT_PARAM_VALUES["az_looks"],
"step": 1,
"min": 1,
"section": "核心参数",
},
"rg_looks": {
"label": "距离向多视数",
"type": "number",
"default": _DEFAULT_PARAM_VALUES["rg_looks"],
"step": 1,
"min": 1,
"section": "核心参数",
},
"coh_mask_threshold": {
"label": "相干掩膜阈值",
"type": "number",
"default": _DEFAULT_PARAM_VALUES["coh_mask_threshold"],
"step": 0.01,
"min": 0,
"max": 1,
"section": "核心参数",
},
"unwrap_coh_threshold": {
"label": "解缠相干阈值",
"type": "number",
"default": _DEFAULT_PARAM_VALUES["unwrap_coh_threshold"],
"step": 0.01,
"min": 0,
"max": 1,
"section": "核心参数",
},
"filter_alpha": {
"label": "Goldstein 滤波因子",
"type": "number",
"default": _DEFAULT_PARAM_VALUES["filter_alpha"],
"step": 0.05,
"min": 0,
"max": 1,
"section": "核心参数",
},
"do_vertical_displacement": {
"label": "生成垂直向形变",
"type": "boolean",
"default": False,
"section": "输出与改正",
"description": "可选模块,将 LOS 向形变换算为垂直向形变;LOS 形变仍会保留。",
},
"do_atmosphere": {
"label": "GACOS 大气相位改正",
"type": "boolean",
"default": False,
"section": "输出与改正",
"readonly": True,
"readonly_label": "暂未开放",
"include_in_payload": False,
"description": "暂未开放。该模块需要外部 GACOS 大气延迟文件,LandSAR 不会自动生成该文件。",
},
"do_geocoding": {
"label": "地理编码输出",
"type": "boolean",
"default": True,
"section": "输出与改正",
"readonly": True,
"readonly_label": "固定开启",
"include_in_payload": False,
"description": "系统入库和地图展示依赖地理编码 GeoTIFF,因此固定开启。",
},
}
return [
EngineProfile(
code="lt1_dinsar",
label="LT-1 LandSAR D-InSAR",
description="Run LandSAR import 100016 when needed, then proID 200014 D-InSAR.",
params_schema=params_schema,
),
]
def normalize_extra(self, extra: Optional[Dict[str, Any]]) -> Dict[str, Any]:
normalized: Dict[str, Any] = {}
for key, value in dict(extra or {}).items():
if key in _SYSTEM_EXTRA_KEYS:
normalized[key] = value
continue
if value is None:
continue
if isinstance(value, str) and value.strip() == "":
continue
normalized[key] = value
for key in set(_DEFAULT_PARAM_VALUES) | {"dem_path"}:
if key not in normalized:
continue
value = normalized[key]
if key in _PATH_PARAM_KEYS:
path = _norm_path(value)
if key in {"gcp_file", "gacos_file"} and not path:
normalized.pop(key, None)
continue
normalized[key] = path
continue
if key in _INT_PARAM_KEYS:
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{key} must be an integer.") from exc
normalized[key] = parsed
continue
if key in _FLOAT_PARAM_KEYS:
try:
parsed_f = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{key} must be a number.") from exc
if key.endswith("threshold") and (parsed_f < 0 or parsed_f > 1):
raise ValueError(f"{key} must be between 0 and 1.")
normalized[key] = parsed_f
for bool_key in ("do_vertical_displacement", "do_atmosphere", "do_geocoding"):
if bool_key in normalized:
normalized[bool_key] = 1 if _coerce_bool(normalized[bool_key]) else 0
return normalized
def _iter_candidate_task_dirs(self, root_dir: str) -> Iterable[str]:
root = _norm_path(root_dir)
if os.path.isdir(os.path.join(root, "Input_Data")) or _looks_like_raw_task_dir(root):
yield root
return
if not os.path.isdir(root):
return
for entry in sorted(os.scandir(root), key=lambda item: item.name.lower()):
if entry.is_dir() and entry.name.lower().startswith("task_"):
yield _norm_path(entry.path)
def _has_completed_task_result(self, task_dir: str) -> bool:
output_dir = os.path.join(_norm_path(task_dir), "Output_Data")
return self._is_completed_output(output_dir)
def _is_completed_output(self, output_dir: str) -> bool:
if not output_dir or not os.path.isdir(output_dir):
return False
has_geo = bool(self._select_primary_file(output_dir))
if not has_geo:
return False
log_candidates = [
os.path.join(output_dir, f"{DINSAR_PROID}.log"),
os.path.join(output_dir, f"{DINSAR_PROID}_console.log"),
]
log_candidates.extend(str(path) for path in Path(output_dir).glob(f"*{DINSAR_PROID}*.log"))
for log_path in log_candidates:
if not os.path.isfile(log_path):
continue
try:
with open(log_path, "r", encoding="utf-8", errors="ignore") as fp:
content = fp.read()
if "console success" in content.lower():
return True
if _SUCCESS_RE.search(content) and ("DInSAR" in content or "差分" in content or "module" in content.lower()):
return True
except OSError:
continue
return False
def validate_root_dir(
self,
root_dir: str,
num_to_process: int = 0,
rerun_mode: str = "rerun_all",
) -> Dict[str, Any]:
normalized_root = _norm_path(root_dir)
if not normalized_root or not os.path.isdir(normalized_root):
raise ValueError(f"LandSAR root_dir does not exist or is not a directory: {root_dir}")
candidates = list(self._iter_candidate_task_dirs(normalized_root))
invalid_candidates: List[Dict[str, Any]] = []
valid_task_dirs: List[str] = []
for task_dir in candidates:
input_dir = os.path.join(task_dir, "Input_Data")
raw_ready = _looks_like_raw_task_dir(task_dir)
if not os.path.isdir(input_dir):
if raw_ready:
valid_task_dirs.append(task_dir)
continue
invalid_candidates.append(
{
"name": os.path.basename(task_dir),
"path": task_dir,
"reason": "missing Input_Data and no valid master/slave raw LT-1 data",
}
)
continue
pair = parse_lt1_slc_pair(input_dir)
if pair is None:
if raw_ready:
valid_task_dirs.append(task_dir)
continue
invalid_candidates.append(
{
"name": os.path.basename(task_dir),
"path": task_dir,
"reason": "less than two valid Input_Data SLC xml/tif pairs and no valid master/slave raw LT-1 data",
}
)
continue
valid_task_dirs.append(task_dir)
if not valid_task_dirs:
detail = ""
if invalid_candidates:
formatted = ", ".join(f"{item['name']} {item['reason']}" for item in invalid_candidates[:5])
detail = f" Invalid candidates: {formatted}."
raise ValueError(
"LandSAR root_dir must be either one Task_* directory containing Input_Data, "
"one Task_* directory containing master/slave raw LT-1 folders, "
"or a parent directory containing either layout."
f"{detail}"
)
discovered_task_count = len(valid_task_dirs)
selected_dirs: List[str] = []
skipped_completed_count = 0
if str(rerun_mode or "").strip().lower() == RERUN_MODE_UNFINISHED_ONLY:
for task_dir in valid_task_dirs:
if self._has_completed_task_result(task_dir):
skipped_completed_count += 1
continue
selected_dirs.append(task_dir)
else:
selected_dirs = list(valid_task_dirs)
limit = int(num_to_process or 0)
if limit > 0:
selected_dirs = selected_dirs[:limit]
mode = (
"single_task_dir"
if os.path.isdir(os.path.join(normalized_root, "Input_Data")) or _looks_like_raw_task_dir(normalized_root)
else "task_root_dir"
)
return {
"root_dir": normalized_root,
"mode": mode,
"task_dirs": selected_dirs,
"task_count": len(selected_dirs),
"selected_task_count": len(selected_dirs),
"discovered_task_count": discovered_task_count,
"skipped_completed_count": skipped_completed_count,
"invalid_candidates": invalid_candidates,
}
def check_available(self) -> EngineAvailability:
console_path = self._console_exe
home = self._home
config_ok, config_detail = self._ensure_config_csv() if os.path.isdir(home) else (False, f"LANDSAR_HOME missing: {home}")
auth_ok, auth_detail = self._start_auth_server_if_needed() if config_ok else (False, "skipped until config.csv is available")
version_control_exe = self._version_control_exe
enabled = self._enabled
dll_check = _check_required_dlls(console_path, home) if os.path.isfile(console_path) else {
"ok": False,
"missing": list(_LANDSAR_REQUIRED_DLLS),
"found": {},
"search_dirs": _path_search_dirs(home),
}
checks = [
{"name": "LANDSAR_ENABLED", "ok": enabled, "detail": str(enabled).lower()},
{"name": "LANDSAR_HOME", "ok": os.path.isdir(home), "detail": home},
{"name": "InSAR_Console.exe", "ok": os.path.isfile(console_path), "detail": console_path},
{
"name": "LandSAR runtime DLLs",
"ok": bool(dll_check["ok"]),
"detail": "ok" if dll_check["ok"] else f"missing: {', '.join(dll_check['missing'])}",
},
{
"name": "LandSAR config.csv",
"ok": config_ok,
"detail": config_detail,
},
{
"name": "LandSAR license mode",
"ok": True,
"detail": self._expected_config_row,
"optional": True,
},
{
"name": "LandSAR auth server",
"ok": auth_ok,
"detail": auth_detail,
"optional": not self._is_network_license_mode(),
},
{
"name": "versionControl.exe",
"ok": os.path.isfile(version_control_exe),
"detail": version_control_exe,
"optional": True,
},
{
"name": "LANDSAR_DEM_PATH",
"ok": True,
"detail": self._default_dem_path or "set per run",
"optional": True,
},
]
available = enabled and os.path.isfile(console_path) and bool(dll_check["ok"]) and config_ok and auth_ok
status = "ok" if available else "unavailable"
if available and not os.path.isdir(home):
status = "degraded"
if available:
message = "LandSAR console is available."
elif os.path.isfile(console_path) and not dll_check["ok"]:
message = f"LandSAR console dependencies are missing: {', '.join(dll_check['missing'])}"
elif not config_ok:
message = config_detail
elif not auth_ok:
message = auth_detail
else:
message = "LandSAR console is not configured."
return EngineAvailability(
engine_code=self.engine_code,
status=status,
available=available,
checks=checks,
message=message,
)
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="LandSAR is disabled.",
)
if request.profile not in SUPPORTED_PROFILES:
return RunResult(
success=False,
engine_code=self.engine_code,
profile=request.profile,
job_id=request.job_id,
error=f"Unknown LandSAR profile: {request.profile}",
)
extra = self.normalize_extra(request.extra)
dem_path = _norm_path(extra.get("dem_path") or self._default_dem_path)
if not dem_path or not os.path.isfile(dem_path):
return RunResult(
success=False,
engine_code=self.engine_code,
profile=request.profile,
job_id=request.job_id,
error=f"LandSAR DEM file is missing: {dem_path or '<empty>'}",
)
if _coerce_bool(extra.get("do_atmosphere")):
gacos_path = _norm_path(extra.get("gacos_file"))
if not gacos_path or not os.path.isfile(gacos_path):
return RunResult(
success=False,
engine_code=self.engine_code,
profile=request.profile,
job_id=request.job_id,
error="LandSAR GACOS atmospheric correction requires a valid external GACOS file.",
)
console_path = self._console_exe
if not os.path.isfile(console_path):
return RunResult(
success=False,
engine_code=self.engine_code,
profile=request.profile,
job_id=request.job_id,
error=f"InSAR_Console.exe not found: {console_path}",
)
config_ok, config_detail = self._ensure_config_csv()
if not config_ok:
return RunResult(
success=False,
engine_code=self.engine_code,
profile=request.profile,
job_id=request.job_id,
error=f"LandSAR config.csv is not ready: {config_detail}",
)
auth_ok, auth_detail = self._start_auth_server_if_needed()
if not auth_ok:
return RunResult(
success=False,
engine_code=self.engine_code,
profile=request.profile,
job_id=request.job_id,
error=f"LandSAR network license server is not ready: {auth_detail}",
)
validation = self.validate_root_dir(
request.root_dir,
request.num_to_process,
str(extra.get("__rerun_mode") or "rerun_all"),
)
task_dirs: List[str] = list(validation["task_dirs"])
run_started_at = datetime.utcnow()
run_started_at_text = _utc_text(run_started_at)
run_key_override = str(extra.get("__managed_run_key") or "").strip()
run_key = run_key_override or f"run_{run_started_at.strftime('%Y%m%dT%H%M%SZ')}_{self.engine_code}_{request.profile}"
timeout = max(60, int(request.timeout_seconds or self.default_timeout_seconds))
output_dirs: List[str] = []
task_results: List[Dict[str, Any]] = []
pairs_processed = 0
pairs_failed = 0
progress_callback = request.progress_callback
def emit_progress(event_type: str, **payload: Any) -> None:
if not callable(progress_callback):
return
try:
progress_callback({"event": event_type, **payload})
except Exception:
return
for pair_index, task_dir in enumerate(task_dirs, start=1):
task_name = os.path.basename(task_dir)
initial_input_data_dir = os.path.join(task_dir, "Input_Data")
parsed_pair = parse_lt1_slc_pair(initial_input_data_dir)
task_alias, pair_key, pair_meta = self._resolve_task_identity(task_dir, task_name, parsed_pair)
managed_run_dir = _norm_path(extra.get("__managed_run_dir"))
if managed_run_dir:
run_dir = managed_run_dir
else:
pair_root = os.path.join(settings.DINSAR_PRODUCT_DIR, pair_key, "runs")
run_dir = os.path.join(pair_root, run_key)
native_output_dir = _norm_path(extra.get("__managed_native_output_dir")) or os.path.join(run_dir, "native")
landsar_input_dir = os.path.join(native_output_dir, "landsar_input")
landsar_output_dir = os.path.join(native_output_dir, "landsar_output")
os.makedirs(landsar_output_dir, exist_ok=True)
command = [console_path, os.path.join(landsar_output_dir, f"{DINSAR_PROID}.txt")]
emit_progress(
"pair_started",
pair_index=pair_index,
pair_total=len(task_dirs),
task_name=task_name,
task_alias=task_alias,
pair_key=pair_key,
)
if not parsed_pair:
import_result = self._ensure_imported_input_data(
task_dir=task_dir,
task_name=task_name,
task_alias=task_alias,
pair_key=pair_key,
export_dir=landsar_input_dir,
console_path=console_path,
timeout=timeout,
pair_index=pair_index,
pair_total=len(task_dirs),
emit_progress=emit_progress,
)
if not import_result.get("success"):
pairs_failed += 1
error_text = str(import_result.get("error") or "LandSAR import failed.")
emit_progress("pair_finished", pair_index=pair_index, pair_total=len(task_dirs), success=False, error=error_text)
task_results.append(
self._build_task_result(
task_name=task_name,
task_alias=task_alias,
pair_key=pair_key,
run_key=run_key,
task_dir=task_dir,
run_dir=run_dir,
native_output_dir=native_output_dir,
landsar_output_dir=landsar_output_dir,
command=import_result.get("command") or command,
success=False,
returncode=int(import_result.get("returncode") or -2),
error=error_text,
stdout_tail=str(import_result.get("stdout_tail") or ""),
param_file=str(import_result.get("param_file") or ""),
)
)
continue
parsed_pair = parse_lt1_slc_pair(str(import_result.get("input_data_dir") or landsar_input_dir))
if not parsed_pair:
pairs_failed += 1
error_text = "LandSAR import completed but no valid Input_Data xml/tif pair was produced."
emit_progress("pair_finished", pair_index=pair_index, pair_total=len(task_dirs), success=False, error=error_text)
task_results.append(
self._build_task_result(
task_name=task_name,
task_alias=task_alias,
pair_key=pair_key,
run_key=run_key,
task_dir=task_dir,
run_dir=run_dir,
native_output_dir=native_output_dir,
landsar_output_dir=landsar_output_dir,
command=import_result.get("command") or command,
success=False,
returncode=-2,
error=error_text,
stdout_tail=str(import_result.get("stdout_tail") or ""),
param_file=str(import_result.get("param_file") or ""),
)
)
continue
task_alias, pair_key, pair_meta = self._resolve_task_identity(task_dir, task_name, parsed_pair)
param_values = {**_DEFAULT_PARAM_VALUES}
param_values.update({key: value for key, value in extra.items() if key in _DEFAULT_PARAM_VALUES})
param_file = _generate_dinsar_param_file(
command[1],
master_xml=parsed_pair["master_xml"],
master_tif=parsed_pair["master_tif"],
slave_xml=parsed_pair["slave_xml"],
slave_tif=parsed_pair["slave_tif"],
dem_path=dem_path,
output_dir=landsar_output_dir,
params=param_values,
)
command = [console_path, param_file]
emit_progress(
"log",
pair_index=pair_index,
pair_total=len(task_dirs),
task_name=task_name,
task_alias=task_alias,
pair_key=pair_key,
level="INFO",
source="input",
message=f"master={os.path.basename(parsed_pair['master_xml'])}, slave={os.path.basename(parsed_pair['slave_xml'])}",
)
emit_progress(
"log",
pair_index=pair_index,
pair_total=len(task_dirs),
task_name=task_name,
task_alias=task_alias,
pair_key=pair_key,
level="INFO",
source="dem",
message=dem_path,
)
rc, stdout_text, timed_out = self._run_console(
command,
cwd=self._home if os.path.isdir(self._home) else os.path.dirname(console_path),
log_path=os.path.join(landsar_output_dir, f"{DINSAR_PROID}_console.log"),
timeout=timeout,
emit_log=lambda level, message: emit_progress(
"log",
pair_index=pair_index,
pair_total=len(task_dirs),
task_name=task_name,
task_alias=task_alias,
pair_key=pair_key,
level=level,
source="console",
message=message,
),
)
success_marker = self._is_completed_output(landsar_output_dir)
primary_raw = self._select_primary_file(landsar_output_dir)
coherence_raw = self._select_coherence_file(landsar_output_dir)
success = rc == 0 and success_marker and bool(primary_raw)
error_text = ""
primary_file = ""
source_files: List[str] = []
validation_result: Dict[str, Any] = {}
layout_result: Dict[str, Any] = {}
if timed_out:
error_text = f"LandSAR console timed out after {timeout}s."
success = False
elif rc != 0:
error_text = _summarize_landsar_failure(stdout_text, "LandSAR console", rc)
elif not success_marker:
error_text = "LandSAR success marker or geocoded output is missing."
elif not primary_raw:
error_text = "LandSAR primary displacement GeoTIFF is missing."
if success:
try:
raw_sources = [primary_raw]
if coherence_raw:
raw_sources.append(coherence_raw)
validation_result = validate_isce2_result_files(primary_raw, raw_sources)
if not bool(validation_result.get("accepted")):
issues = validation_result.get("issues") or []
raise RuntimeError("; ".join(str(item) for item in issues[:3]) or "GeoTIFF validation failed.")
os.makedirs(run_dir, exist_ok=True)
standard_disp = os.path.join(run_dir, "assets", "disp", "disp.tif")
standard_coh = os.path.join(run_dir, "assets", "coh", "coh.tif")
_copy_file(primary_raw, standard_disp)
if coherence_raw:
_copy_file(coherence_raw, standard_coh)
source_files = [standard_disp]
if os.path.isfile(standard_coh):
source_files.append(standard_coh)
layout_result = normalize_isce2_run_layout(
run_dir,
primary_file=standard_disp,
source_files=source_files,
rewrite_metadata=False,
)
primary_file = layout_result["primary_file"]
source_files = list(layout_result["source_files"])
pair_meta_payload = self._build_pair_meta_payload(
pair_key=pair_key,
task_alias=task_alias,
pair_meta=pair_meta,
parsed_pair=parsed_pair,
)
write_pair_metadata(task_dir, pair_meta_payload)
self._write_run_metadata(
run_dir=run_dir,
native_output_dir=native_output_dir,
task_dir=task_dir,
task_name=task_name,
task_alias=task_alias,
pair_key=pair_key,
run_key=run_key,
request=request,
pair_meta=pair_meta_payload,
params=param_values,
dem_path=dem_path,
landsar_output_dir=landsar_output_dir,
primary_file=primary_file,
source_files=source_files,
returncode=rc,
started_at=run_started_at_text,
)
output_dirs.append(run_dir)
pairs_processed += 1
except Exception as exc:
success = False
error_text = f"LandSAR output packaging failed: {exc}"
if not success:
pairs_failed += 1
emit_progress(
"pair_finished",
pair_index=pair_index,
pair_total=len(task_dirs),
task_name=task_name,
task_alias=task_alias,
pair_key=pair_key,
success=success,
returncode=rc,
error=error_text,
)
task_results.append(
self._build_task_result(
task_name=task_name,
task_alias=task_alias,
pair_key=pair_key,
run_key=run_key,
task_dir=task_dir,
run_dir=run_dir,
native_output_dir=native_output_dir,
landsar_output_dir=landsar_output_dir,
command=command,
success=success,
returncode=rc,
error=error_text,
stdout_tail=_collect_tail(stdout_text, 3000),
primary_file=primary_file,
source_files=source_files,
validation=validation_result,
layout=layout_result,
param_file=param_file,
raw_primary_file=primary_raw,
raw_coherence_file=coherence_raw,
)
)
invalid_candidates = validation.get("invalid_candidates", [])
pairs_failed += len(invalid_candidates)
overall_success = pairs_processed > 0 and pairs_failed == 0
if pairs_processed > 0 and pairs_failed > 0:
overall_success = False
error = None
if not overall_success:
failed_names = [item.get("task_alias") or item.get("task_name") for item in task_results if not item.get("success")]
error = f"LandSAR run failed: {', '.join(failed_names[:10])}" if failed_names else "LandSAR run failed."
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,
"timeout_seconds": timeout,
"dem_path": dem_path,
"console_path": console_path,
},
)
def _run_console(
self,
command: Any,
*,
cwd: str,
log_path: str,
timeout: int,
emit_log,
) -> tuple[int, str, bool]:
os.makedirs(os.path.dirname(log_path), exist_ok=True)
with open(log_path, "a", encoding="utf-8", errors="replace") as log_fp:
log_fp.write(f"\n[{_utc_text()}] command: {' '.join(command)}\n")
process = subprocess.Popen(
command,
cwd=cwd,
env=_landsar_process_env(command[0], self._home),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
output_lines: List[str] = []
line_queue: queue.Queue[Any] = queue.Queue()
sentinel = object()
def _reader() -> None:
try:
if process.stdout is None:
return
for raw_line in iter(process.stdout.readline, b""):
line_queue.put(raw_line)
finally:
line_queue.put(sentinel)
reader = threading.Thread(target=_reader, name="landsar-console-reader", daemon=True)
reader.start()
started = time.monotonic()
timed_out = False
stdout_closed = False
while True:
try:
raw_item = line_queue.get(timeout=1)
except queue.Empty:
raw_item = None
if raw_item is sentinel:
stdout_closed = True
elif raw_item:
line = _decode_line(raw_item)
output_lines.append(line)
log_fp.write(line + "\n")
log_fp.flush()
if line.strip():
emit_log("INFO", line.strip())
if process.poll() is not None and stdout_closed:
break
if time.monotonic() - started > timeout:
timed_out = True
process.kill()
break
if timed_out:
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
reader.join(timeout=5)
while True:
try:
raw_item = line_queue.get_nowait()
except queue.Empty:
break
if raw_item is sentinel or not raw_item:
continue
line = _decode_line(raw_item)
output_lines.append(line)
log_fp.write(line + "\n")
if line.strip():
emit_log("INFO", line.strip())
if process.poll() is None:
timed_out = True
process.kill()
return_code = int(process.poll() if process.poll() is not None else -9)
if timed_out:
log_fp.write(f"[{_utc_text()}] timeout after {timeout}s\n")
log_fp.write(f"[{_utc_text()}] returncode={return_code}\n")
return return_code, "\n".join(output_lines), timed_out
def _ensure_imported_input_data(
self,
*,
task_dir: str,
task_name: str,
task_alias: str,
pair_key: str,
export_dir: str,
console_path: str,
timeout: int,
pair_index: int,
pair_total: int,
emit_progress,
) -> Dict[str, Any]:
master_dir = os.path.join(_norm_path(task_dir), "master")
slave_dir = os.path.join(_norm_path(task_dir), "slave")
export_dir = _norm_path(export_dir)
os.makedirs(export_dir, exist_ok=True)
existing_pair = parse_lt1_slc_pair(export_dir)
if existing_pair:
return {
"success": True,
"input_data_dir": export_dir,
"skipped": True,
"returncode": 0,
"command": "",
"param_file": "",
"stdout_tail": "",
}
if not _looks_like_raw_task_dir(task_dir):
return {
"success": False,
"input_data_dir": export_dir,
"returncode": -2,
"error": "Task directory has neither valid Input_Data nor valid master/slave raw LT-1 folders.",
}
param_file = _generate_import_param_file(
os.path.join(export_dir, f"{IMPORT_PROID}.txt"),
master_dir=master_dir,
slave_dir=slave_dir,
export_dir=export_dir,
import_method="dir",
sat_mode="BIST",
read_xml=True,
read_slc=True,
export_to_new=True,
)
command = [console_path, param_file]
emit_progress(
"log",
pair_index=pair_index,
pair_total=pair_total,
task_name=task_name,
task_alias=task_alias,
pair_key=pair_key,
level="INFO",
source="import",
message=f"LandSAR import 100016 -> {export_dir}",
)
rc, stdout_text, timed_out = self._run_console(
command,
cwd=self._home if os.path.isdir(self._home) else os.path.dirname(console_path),
log_path=os.path.join(export_dir, f"{IMPORT_PROID}_console.log"),
timeout=timeout,
emit_log=lambda level, message: emit_progress(
"log",
pair_index=pair_index,
pair_total=pair_total,
task_name=task_name,
task_alias=task_alias,
pair_key=pair_key,
level=level,
source="import",
message=message,
),
)
success = rc == 0 and self._is_completed_import(export_dir) and bool(parse_lt1_slc_pair(export_dir))
error = ""
if timed_out:
error = f"LandSAR import timed out after {timeout}s."
success = False
elif rc != 0:
error = _summarize_landsar_failure(stdout_text, "LandSAR import", rc)
elif not self._is_completed_import(export_dir):
error = "LandSAR import success marker is missing."
elif not parse_lt1_slc_pair(export_dir):
error = "LandSAR import did not produce at least two LT1*_SLC xml/tif pairs."
return {
"success": success,
"input_data_dir": export_dir,
"returncode": rc,
"error": error,
"command": " ".join(command),
"param_file": param_file,
"stdout_tail": _collect_tail(stdout_text, 3000),
}
def _is_completed_import(self, output_dir: str) -> bool:
if not output_dir or not os.path.isdir(output_dir):
return False
log_candidates = [
os.path.join(output_dir, f"{IMPORT_PROID}.log"),
os.path.join(output_dir, f"{IMPORT_PROID}_console.log"),
]
log_candidates.extend(str(path) for path in Path(output_dir).glob(f"*{IMPORT_PROID}*.log"))
for log_path in log_candidates:
if not os.path.isfile(log_path):
continue
try:
with open(log_path, "r", encoding="utf-8", errors="ignore") as fp:
content = fp.read()
lowered = content.lower()
if "console success" in lowered:
return True
if "module [LT-1数据导入] success" in content:
return True
if "lt-1" in lowered and "success" in lowered:
return True
except OSError:
continue
return False
def _resolve_task_identity(
self,
task_dir: str,
task_name: str,
parsed_pair: Optional[Dict[str, Any]],
) -> tuple[str, str, Dict[str, Any]]:
sidecar_path = os.path.join(task_dir, PAIR_META_FILENAME)
pair_meta: Dict[str, Any] = {}
if os.path.isfile(sidecar_path):
try:
with open(sidecar_path, "r", encoding="utf-8") as fp:
payload = json.load(fp)
if isinstance(payload, dict):
pair_meta = payload
except Exception:
pair_meta = {}
task_alias = str(pair_meta.get("task_alias") or task_name).strip() or task_name
satellite_family = normalize_satellite_family(
pair_meta.get("master_satellite") or pair_meta.get("slave_satellite") or "lt1"
)
pair_key = str(pair_meta.get("pair_key") or "").strip()
if not pair_key and parsed_pair:
pair_key = build_fallback_pair_key(
task_alias,
"||".join([parsed_pair["master_xml"], parsed_pair["slave_xml"]]),
satellite_family=satellite_family,
)
if not pair_key:
pair_key = build_fallback_pair_key(task_alias, task_dir, satellite_family=satellite_family)
return task_alias, pair_key, pair_meta
def _build_pair_meta_payload(
self,
*,
pair_key: str,
task_alias: str,
pair_meta: Dict[str, Any],
parsed_pair: Dict[str, Any],
) -> Dict[str, Any]:
return {
**dict(pair_meta or {}),
"pair_key": pair_key,
"task_alias": task_alias,
"master_path": pair_meta.get("master_path") or parsed_pair["master_tif"],
"slave_path": pair_meta.get("slave_path") or parsed_pair["slave_tif"],
"master_satellite": pair_meta.get("master_satellite") or "LT-1",
"slave_satellite": pair_meta.get("slave_satellite") or "LT-1",
"master_imaging_date": pair_meta.get("master_imaging_date") or parsed_pair.get("master_date"),
"slave_imaging_date": pair_meta.get("slave_imaging_date") or parsed_pair.get("slave_date"),
}
def _write_run_metadata(
self,
*,
run_dir: str,
native_output_dir: str,
task_dir: str,
task_name: str,
task_alias: str,
pair_key: str,
run_key: str,
request: RunRequest,
pair_meta: Dict[str, Any],
params: Dict[str, Any],
dem_path: str,
landsar_output_dir: str,
primary_file: str,
source_files: List[str],
returncode: int,
started_at: str,
) -> None:
payload = {
"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": _norm_path(request.extra.get("__source_root_override") or request.root_dir),
"task_dir": _norm_path(task_dir),
"work_dir": landsar_output_dir,
"output_dir": _norm_path(run_dir),
"native_output_dir": _norm_path(native_output_dir),
"started_at": started_at,
"finished_at": _utc_text(),
"params": {
**dict(params or {}),
"dem_path": dem_path,
},
"metrics": {
"returncode": returncode,
"primary_file": primary_file,
"source_files": source_files,
},
"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_center_distance_meters": pair_meta.get("scene_center_distance_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"),
}
write_run_metadata(run_dir, payload)
write_run_metadata(native_output_dir, payload)
def _build_task_result(
self,
*,
task_name: str,
task_alias: str,
pair_key: str,
run_key: str,
task_dir: str,
run_dir: str,
native_output_dir: str,
landsar_output_dir: str,
command: List[str],
success: bool,
returncode: int,
error: str = "",
stdout_tail: str = "",
primary_file: str = "",
source_files: Optional[List[str]] = None,
validation: Optional[Dict[str, Any]] = None,
layout: Optional[Dict[str, Any]] = None,
param_file: str = "",
raw_primary_file: str = "",
raw_coherence_file: str = "",
) -> Dict[str, Any]:
command_text = command if isinstance(command, str) else " ".join(str(part) for part in command)
return {
"task_name": task_name,
"task_alias": task_alias,
"pair_key": pair_key,
"run_key": run_key,
"task_dir": _norm_path(task_dir),
"run_dir": _norm_path(run_dir),
"native_output_dir": _norm_path(native_output_dir),
"output_dir": _norm_path(run_dir),
"landsar_output_dir": _norm_path(landsar_output_dir),
"primary_file": _norm_path(primary_file) if primary_file else "",
"source_files": [_norm_path(path) for path in (source_files or [])],
"raw_primary_file": _norm_path(raw_primary_file) if raw_primary_file else "",
"raw_coherence_file": _norm_path(raw_coherence_file) if raw_coherence_file else "",
"param_file": _norm_path(param_file) if param_file else "",
"validation": validation or {},
"layout": layout or {},
"command": command_text,
"success": success,
"returncode": returncode,
"stdout_tail": stdout_tail,
"stderr_tail": "",
"error": error,
}
def _select_primary_file(self, output_dir: str) -> str:
patterns = [
"*displ.geo.tif",
"*displ.geo.tiff",
"*dispv.geo.tif",
"*dispv.geo.tiff",
"*diff.unw.geo.tif",
"*diff.unw.geo.tiff",
"*unw.geo.tif",
"*unw.geo.tiff",
"*.geo.tif",
"*.geo.tiff",
]
return self._first_matching_file(output_dir, patterns, exclude=("coh", "filcc", "wrap"))
def _select_coherence_file(self, output_dir: str) -> str:
return self._first_matching_file(output_dir, ["*coh.geo.tif", "*coh.geo.tiff", "*filcc.geo.tif", "*filcc.geo.tiff"])
def _first_matching_file(self, output_dir: str, patterns: List[str], exclude: tuple[str, ...] = ()) -> str:
root = Path(output_dir)
if not root.is_dir():
return ""
for pattern in patterns:
matches = sorted(root.rglob(pattern), key=lambda path: str(path).lower())
for match in matches:
if not match.is_file():
continue
lower_name = match.name.lower()
if any(token in lower_name for token in exclude):
continue
return _norm_path(match)
return ""