Files
insar-management-system-v2/backend/app/services/envi_service.py
T

2935 lines
105 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ENVI/SARscape integration service via envipyengine.
Single execution engine: envipyengine → taskengine.exe subprocess.
Provides Import and D-InSAR workflows with smart chaining
(D-InSAR auto-detects missing imports and runs them first).
"""
from __future__ import annotations
import json
import math
import os
import subprocess
import sys
import time
import defusedxml.ElementTree as ET
from contextlib import contextmanager
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
from datetime import datetime
from glob import glob
from typing import Any, Dict, List, Optional
from ..config import get_env_text, settings
from ..process_utils import is_any_process_running
from .dinsar_naming import (
PAIR_META_FILENAME,
build_fallback_pair_key,
build_run_key,
find_json_sidecar,
write_run_metadata,
)
from .dinsar_result_layout_service import get_run_native_output_dir
_BACKEND_DIR = type(settings).BACKEND_DIR
# ---------------------------------------------------------------------------
# Config helpers
# ---------------------------------------------------------------------------
def _read_env(name: str, default: Optional[str] = None) -> Optional[str]:
return get_env_text(name, default)
# ---------------------------------------------------------------------------
# Path helpers
# ---------------------------------------------------------------------------
def _normalize_path(value: Any) -> str:
"""Normalize to forward-slash path (ENVI convention)."""
normalized = str(value or "").strip().strip('"').strip("'")
if not normalized:
return ""
return normalized.replace("\\", "/")
def _to_local_path(value: Any) -> str:
"""Normalize to OS-native path."""
raw = str(value or "").strip().strip('"').strip("'")
if not raw:
return ""
return os.path.normpath(raw.replace("/", os.sep))
def get_envi_runner_python() -> str:
configured = _to_local_path(getattr(settings, "PYTHON_PATH", "") or "")
if configured:
return configured
return os.path.normpath(sys.executable)
def get_envi_taskengine_cwd() -> str:
"""Dedicated cwd for envipyengine/taskengine temp files.
SARscape can create zero-byte env_*.xyz and IDL*.tmp files in the current
working directory. Keep those files under runtime instead of the repo root.
"""
base_dir = _to_local_path(
getattr(settings, "IDL_WORKER_RUNTIME_DIR", "")
or os.path.join(_BACKEND_DIR, "runtime", "idl_worker")
)
cwd = os.path.join(base_dir, "envi_cwd")
os.makedirs(cwd, exist_ok=True)
return os.path.normpath(os.path.abspath(cwd))
def get_envi_custom_code_dir() -> str:
envi_root = _envi_install_root()
if not envi_root:
return ""
candidates = [
os.path.join(envi_root, "user_custom_code"),
os.path.join(envi_root, "custom_code"),
]
for path in candidates:
if os.path.isdir(path):
return os.path.normpath(os.path.abspath(path))
return ""
def get_envi_runner_cwd() -> str:
return get_envi_taskengine_cwd()
def get_envi_runner_env() -> Dict[str, str]:
env = os.environ.copy()
project_root = os.path.normpath(os.path.abspath(type(settings).PROJECT_ROOT))
taskengine_cwd = get_envi_taskengine_cwd()
existing = [part for part in str(env.get("PYTHONPATH") or "").split(os.pathsep) if str(part).strip()]
ordered = [project_root, *existing]
deduped: List[str] = []
seen = set()
for raw_path in ordered:
try:
key = os.path.normcase(os.path.normpath(os.path.abspath(str(raw_path))))
except Exception:
key = str(raw_path)
if key in seen:
continue
seen.add(key)
deduped.append(str(raw_path))
env["PYTHONPATH"] = os.pathsep.join(deduped)
env["TEMP"] = taskengine_cwd
env["TMP"] = taskengine_cwd
env["IDL_TMPDIR"] = taskengine_cwd
custom_code_dir = get_envi_custom_code_dir()
if custom_code_dir:
env["ENVI_CUSTOM_CODE"] = custom_code_dir
return env
@contextmanager
def _envi_taskengine_runtime_context():
"""Run in-process ENVI calls from the dedicated runtime cwd."""
target_cwd = get_envi_taskengine_cwd()
old_cwd = os.getcwd()
old_env = {name: os.environ.get(name) for name in ("TEMP", "TMP", "IDL_TMPDIR")}
os.environ["TEMP"] = target_cwd
os.environ["TMP"] = target_cwd
os.environ["IDL_TMPDIR"] = target_cwd
try:
os.chdir(target_cwd)
yield target_cwd
finally:
os.chdir(old_cwd)
for name, value in old_env.items():
if value is None:
os.environ.pop(name, None)
else:
os.environ[name] = value
def build_envi_runner_command(*args: Any) -> List[str]:
command = [
get_envi_runner_python(),
"-m",
"backend.app.services.envi_runner_cli",
]
command.extend(str(arg) for arg in args if arg is not None)
return command
def _list_taskengine_pids() -> set[int]:
"""Return taskengine.exe PIDs on Windows without importing optional deps."""
if os.name != "nt":
return set()
try:
completed = subprocess.run(
[
"powershell.exe",
"-NoProfile",
"-Command",
"Get-Process taskengine -ErrorAction SilentlyContinue | ForEach-Object { $_.Id }",
],
capture_output=True,
text=True,
timeout=5,
check=False,
)
except Exception:
return set()
pids: set[int] = set()
for line in str(completed.stdout or "").splitlines():
raw = line.strip()
if raw.isdigit():
pids.add(int(raw))
return pids
def _stop_taskengine_pids(pids: set[int]) -> List[int]:
"""Stop specific taskengine.exe PIDs; avoids killing pre-existing sessions."""
stopped: List[int] = []
if os.name != "nt":
return stopped
for pid in sorted(pids):
try:
subprocess.run(
[
"powershell.exe",
"-NoProfile",
"-Command",
f"Stop-Process -Id {int(pid)} -Force -ErrorAction SilentlyContinue",
],
capture_output=True,
text=True,
timeout=10,
check=False,
)
stopped.append(int(pid))
except Exception:
continue
return stopped
def _cleanup_new_taskengine_processes(existing_pids: set[int]) -> Dict[str, Any]:
"""Best-effort cleanup for taskengine.exe children spawned by a timed-out runner."""
existing = set(existing_pids or set())
first_targets = _list_taskengine_pids() - existing
stopped = _stop_taskengine_pids(first_targets)
# taskengine can take a moment to detach from the runner process. Re-check once.
time.sleep(1)
second_targets = _list_taskengine_pids() - existing
stopped.extend(pid for pid in _stop_taskengine_pids(second_targets) if pid not in stopped)
time.sleep(1)
remaining = sorted(_list_taskengine_pids() - existing)
return {
"taskengine_cleanup_attempted": True,
"taskengine_stopped_pids": sorted(set(stopped)),
"taskengine_remaining_new_pids": remaining,
}
def probe_envi_runner() -> Dict[str, Any]:
python_path = get_envi_runner_python()
project_root = get_envi_runner_cwd()
result: Dict[str, Any] = {
"python_path": python_path,
"cwd": project_root,
"ready": False,
"returncode": None,
"message": "",
}
if not python_path:
result["message"] = "PYTHON_PATH is empty."
return result
if not os.path.isfile(python_path):
result["message"] = f"Python executable not found: {python_path}"
return result
if not os.path.isdir(project_root):
result["message"] = f"Project root not found: {project_root}"
return result
try:
completed = subprocess.run(
build_envi_runner_command("--help"),
cwd=project_root,
env=get_envi_runner_env(),
capture_output=True,
text=True,
timeout=20,
check=False,
)
except Exception as exc:
result["message"] = str(exc)
return result
result["returncode"] = int(completed.returncode)
if completed.returncode == 0:
result["ready"] = True
result["message"] = "Runner entrypoint is available."
return result
stderr_text = str(completed.stderr or "").strip()
stdout_text = str(completed.stdout or "").strip()
result["message"] = (stderr_text or stdout_text or f"returncode={completed.returncode}")[:1000]
return result
# ---------------------------------------------------------------------------
# Configuration (read once at import time)
# ---------------------------------------------------------------------------
IDL_EXECUTABLE = _read_env(
"IDL_EXECUTABLE",
r"C:\Program Files\Harris\ENVI56\IDL88\bin\bin.x86_64\idl.exe",
)
IDL_WORKBENCH_PATH = _read_env(
"IDL_WORKBENCH_PATH",
r"C:\Program Files\Harris\ENVI56\IDL88\bin\bin.x86_64\idlde.exe",
)
# ---------------------------------------------------------------------------
# envipyengine config bootstrap
# ---------------------------------------------------------------------------
# envipyengine reads taskengine path from %LOCALAPPDATA%\envipyengine\settings.cfg.
# To avoid per-machine manual setup, we redirect LOCALAPPDATA to the project's
# runtime dir and auto-generate settings.cfg from IDL_EXECUTABLE at first use.
_ENVIPY_CONFIG_BASE = os.path.join(_BACKEND_DIR, "runtime")
_TASKENGINE_EXE = os.path.join(os.path.dirname(IDL_EXECUTABLE), "taskengine.exe") if IDL_EXECUTABLE else ""
def _ensure_envipyengine_config() -> None:
"""Write envipyengine settings.cfg into the project runtime dir and
redirect LOCALAPPDATA so envipyengine picks it up automatically.
Safe to call multiple times (no-op if already configured correctly).
"""
if not _TASKENGINE_EXE:
return
cfg_dir = os.path.join(_ENVIPY_CONFIG_BASE, "envipyengine")
cfg_path = os.path.join(cfg_dir, "settings.cfg")
expected = (
f"[envipyengine]\n"
f"engine = {_TASKENGINE_EXE}\n\n"
f"[engine-environment]\n"
)
try:
with open(cfg_path, "r", encoding="utf-8") as _cfg_f:
if _cfg_f.read() == expected:
os.environ["LOCALAPPDATA"] = _ENVIPY_CONFIG_BASE
return
except OSError as exc:
print(f"[WARN] _ensure_config: {exc}")
os.makedirs(cfg_dir, exist_ok=True)
with open(cfg_path, "w", encoding="utf-8") as f:
f.write(expected)
os.environ["LOCALAPPDATA"] = _ENVIPY_CONFIG_BASE
DEM_BASE_FILE = _to_local_path(
_read_env("IDL_DINSAR_DEM_BASE_FILE", "")
)
RUNTIME_DIR = _read_env(
"IDL_WORKER_RUNTIME_DIR",
os.path.join(_BACKEND_DIR, "runtime", "idl_worker"),
)
DEFAULT_TIMEOUT = int(_read_env("IDL_WORKER_DEFAULT_TIMEOUT_SECONDS", "14400") or 14400)
MAX_TIMEOUT = int(_read_env("IDL_WORKER_MAX_TIMEOUT_SECONDS", "43200") or 43200)
# Custom D-InSAR parameters (step-by-step mode)
CUSTOM_TARGET_RESOLUTION_M = float(
_read_env("IDL_DINSAR_CUSTOM_TARGET_RESOLUTION_M", "10.0") or 10.0
)
CUSTOM_FILTER_METHOD = _read_env("IDL_DINSAR_CUSTOM_FILTER_METHOD", "GOLDSTEIN")
CUSTOM_UNWRAP_COH_THRESHOLD = float(
_read_env("IDL_DINSAR_CUSTOM_UNWRAP_COH_THRESHOLD", "0.05") or 0.05
)
CUSTOM_GCP_COH_THRESHOLD = float(
_read_env("IDL_DINSAR_CUSTOM_GCP_COH_THRESHOLD", "0.7") or 0.7
)
CUSTOM_GCP_NUMBER = int(
_read_env("IDL_DINSAR_CUSTOM_GCP_NUMBER", "100") or 100
)
CUSTOM_GEOCODING_COH_THRESHOLD = float(
_read_env("IDL_DINSAR_CUSTOM_GEOCODING_COH_THRESHOLD", "0.0") or 0.0
)
CUSTOM_GEOCODING_PIXEL_SIZE_M = float(
_read_env("IDL_DINSAR_CUSTOM_GEOCODING_PIXEL_SIZE_M", "10.0") or 10.0
)
# Timeout for individual envipyengine task.execute() calls (seconds).
# If envipyengine hangs after the task completes, this ensures we don't
# block forever. The caller's except block will use fallback file scanning.
_ENVI_TASK_TIMEOUT = int(_read_env("ENVI_TASK_TIMEOUT_SECONDS", "300") or 300)
# Global mutex: taskengine.exe is a singleton process and cannot handle
# concurrent calls. All execute_envi_task() calls must be serialized.
import threading
_ENVI_GLOBAL_LOCK = threading.Lock()
SARSCAPE_SBAS_NATIVE_WORKFLOW_CANDIDATES = [
"wf_sbas",
"wf_esbas",
]
SARSCAPE_SBAS_SUPPORT_TASK_CANDIDATES = [
"SARscape_setting_output_folders",
"SARsLoadPreferences",
"SARsImportSarSelector",
"SARscapeSuggestLooks",
"SARscapeEnviuriToShape",
]
SARSCAPE_SBAS_STACK_TASK_CANDIDATES = [
"SARsInSARStackSBASGenerateConnectionGraph",
"SARsInSARStackSBASInterferogramGeneration",
"SARsInSARStackSBASInversionStep1",
"SARsInSARStackSBASInversionStep2",
"SARsInSARStackSBASGeocode",
"SARsInSARStackSBASVariogram",
"SARsInSARStackESBASInterferogramGeneration",
"SARsInSARStackESBASInversion",
"SARsInSARStackESBASGeocode",
"SARsInSARConnectionGraphESBAS",
]
SARSCAPE_SBAS_TASK_CANDIDATES = [
*SARSCAPE_SBAS_NATIVE_WORKFLOW_CANDIDATES,
*SARSCAPE_SBAS_SUPPORT_TASK_CANDIDATES,
*SARSCAPE_SBAS_STACK_TASK_CANDIDATES,
]
# ---------------------------------------------------------------------------
# Progress file for subprocess ↔ job handler communication
# ---------------------------------------------------------------------------
def _progress_file_path(job_id: str) -> str:
"""Return the path to the progress JSON file for a given job."""
return os.path.join(RUNTIME_DIR, f"job_{job_id}_progress.json")
def _write_progress(
job_id: Optional[str],
step: int,
total_steps: int,
message: str,
output_dir: str = "",
pair_index: int = 0,
total_pairs: int = 0,
pair_name: str = "",
) -> None:
"""Write a progress JSON file (atomic via tmp+rename).
The job handler monitors this file's mtime to determine liveness.
pair_index/total_pairs track which pair is being processed in a batch.
"""
if not job_id:
return
try:
os.makedirs(RUNTIME_DIR, exist_ok=True)
progress_file = _progress_file_path(job_id)
data = {
"step": step,
"total_steps": total_steps,
"message": message,
"output_dir": output_dir,
"pair_index": pair_index,
"total_pairs": total_pairs,
"pair_name": pair_name,
"timestamp": time.time(),
}
tmp = progress_file + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f)
os.replace(tmp, progress_file)
except Exception as exc:
print(f"[WARN] _write_progress: {exc}")
# ---------------------------------------------------------------------------
# Directory scanning
# ---------------------------------------------------------------------------
def _collect_task_folders(root_dir: str) -> List[str]:
"""Return sorted list of Task_* directories under root_dir."""
pattern = os.path.join(root_dir, "Task_*")
return sorted(p for p in glob(pattern) if os.path.isdir(p))
def _find_meta_files(data_dir: str) -> List[str]:
return sorted(glob(os.path.join(data_dir, "*.meta.xml")))
def _has_sml(data_dir: str) -> bool:
return bool(glob(os.path.join(data_dir, "*.sml")))
def _first_sml_base(data_dir: str) -> Optional[str]:
"""Return base path (without .sml) of the first .sml file, or None."""
smls = sorted(glob(os.path.join(data_dir, "*.sml")))
return smls[0][:-4] if smls else None
def _utc_now_text() -> str:
return datetime.utcnow().isoformat(timespec="seconds") + "Z"
def _resolve_dinsar_pair_identity(task_dir: str, task_name: str) -> tuple[str, str, Dict[str, Any]]:
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)
return task_alias, pair_key, pair_meta
def _write_envi_run_sidecar(
output_dir: str,
*,
engine_code: str,
profile_code: str,
root_dir: str,
task_dir: str,
task_name: str,
task_alias: str,
pair_key: str,
run_key: str,
pair_meta: Dict[str, Any],
started_at: str,
params: Dict[str, Any],
metrics: Dict[str, Any],
native_output_dir: Optional[str] = None,
) -> None:
write_run_metadata(
output_dir,
{
"run_key": run_key,
"pair_key": pair_key,
"task_name": task_name,
"task_alias": task_alias,
"engine_code": engine_code,
"profile_code": profile_code,
"source_root": os.path.normpath(root_dir),
"task_dir": os.path.normpath(task_dir),
"output_dir": os.path.normpath(output_dir),
"native_output_dir": os.path.normpath(native_output_dir or output_dir),
"started_at": started_at,
"finished_at": _utc_now_text(),
"params": params,
"metrics": metrics,
"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"),
},
)
def _build_sarscapedata(base_path: str) -> Dict[str, Any]:
"""Build a SARSCAPEDATA hash from a base path (without extension).
envipyengine/taskengine expects SARSCAPEDATA parameters as a Hash:
{"url": "...", "factory": "ENVISARscapedata", "auxiliary_url": [...]}
Passing a plain string causes hydration failure.
"""
normalized = _normalize_path(base_path)
return {
"url": normalized,
"factory": "ENVISARscapedata",
"auxiliary_url": [
normalized + ".sml",
normalized + ".hdr",
],
}
# ---------------------------------------------------------------------------
# Core: execute a single ENVI task via envipyengine
# ---------------------------------------------------------------------------
def execute_envi_task(task_name: str, parameters: Dict[str, Any]) -> Dict[str, Any]:
"""Call envipyengine to execute one ENVI/SARscape task.
Returns the outputParameters dict from the task result so callers
can directly access output SARSCAPEDATA by parameter name.
Raises RuntimeError on timeout (envipyengine hang).
NOTE: taskengine.exe is a singleton — calls are serialized via _ENVI_GLOBAL_LOCK.
"""
try:
_ensure_envipyengine_config()
from envipyengine import Engine
except ImportError as exc:
raise RuntimeError(
"envipyengine is not installed. "
"Install it with: pip install envipyengine"
) from exc
with _ENVI_GLOBAL_LOCK:
with _envi_taskengine_runtime_context():
engine = Engine("ENVI")
task = engine.task(task_name)
existing_taskengine_pids = _list_taskengine_pids()
# Run with timeout to handle envipyengine hangs
with ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(task.execute, parameters)
try:
result = future.result(timeout=_ENVI_TASK_TIMEOUT)
except FuturesTimeoutError:
try:
cleanup = _cleanup_new_taskengine_processes(existing_taskengine_pids)
stopped = cleanup.get("taskengine_stopped_pids") or []
remaining = cleanup.get("taskengine_remaining_new_pids") or []
print(
"[WARN] execute_envi_task: task timed out; "
f"stopped_new_taskengine_pids={stopped}; "
f"remaining_new_taskengine_pids={remaining}"
)
except Exception as _exc:
print(f"[WARN] execute_envi_task: taskengine cleanup failed — {_exc}")
raise RuntimeError(
f"Task {task_name} timed out after {_ENVI_TASK_TIMEOUT}s "
f"(envipyengine hung). Output files may still exist."
)
# taskengine returns {"outputParameters": {...}, ...}
return result.get("outputParameters", result)
def _unwrap_sarscapedata(value: Any) -> Any:
"""Unwrap SARSCAPEDATA output from envipyengine.
taskengine returns output SARSCAPEDATA as a list: [{url, factory, ...}].
Input parameters expect a plain dict: {url, factory, ...}.
"""
if isinstance(value, list) and len(value) == 1 and isinstance(value[0], dict):
return value[0]
return value
def _configured_sarscape_sbas_task_candidates() -> List[str]:
configured = str(_read_env("SARSCAPE_SBAS_TASK_NAMES", "") or "").strip()
if not configured:
return list(SARSCAPE_SBAS_TASK_CANDIDATES)
names: List[str] = []
for raw in configured.replace(";", ",").split(","):
name = raw.strip()
if name and name not in names:
names.append(name)
return names or list(SARSCAPE_SBAS_TASK_CANDIDATES)
def _envi_install_root() -> str:
executable = _to_local_path(IDL_EXECUTABLE)
if not executable:
return ""
return os.path.abspath(os.path.join(os.path.dirname(executable), "..", "..", ".."))
def _static_envi_task_template_path(task_name: str) -> str:
name = str(task_name or "").strip()
if not name:
return ""
envi_root = _envi_install_root()
if not envi_root:
return ""
candidates = [
os.path.join(envi_root, "user_custom_code", f"{name}.task"),
os.path.join(envi_root, "resource", "templates", "tasks", "SARscape", f"{name}.task"),
os.path.join(envi_root, "resource", "templates", "tasks", f"{name}.task"),
]
for path in candidates:
if os.path.isfile(path):
return path
return ""
def _json_safe_parameter(value: Any) -> Any:
if isinstance(value, dict):
return {str(key): _json_safe_parameter(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_json_safe_parameter(item) for item in value]
if value is None or isinstance(value, (str, int, float, bool)):
return value
return str(value)
def _summarize_task_parameters(raw_parameters: Any) -> Dict[str, Any]:
safe_parameters = _json_safe_parameter(raw_parameters)
input_names: List[str] = []
output_names: List[str] = []
required_input_names: List[str] = []
if isinstance(safe_parameters, dict):
iterable = safe_parameters.values()
elif isinstance(safe_parameters, list):
iterable = safe_parameters
else:
iterable = []
for item in iterable:
if not isinstance(item, dict):
continue
name = str(item.get("name") or item.get("NAME") or "").strip()
direction = str(item.get("direction") or item.get("DIRECTION") or "").strip().lower()
required = bool(item.get("required") or item.get("REQUIRED"))
if not name:
continue
if direction == "input":
input_names.append(name)
if required:
required_input_names.append(name)
elif direction == "output":
output_names.append(name)
return {
"parameter_count": (
len(safe_parameters)
if isinstance(safe_parameters, (dict, list))
else 0
),
"input_names": input_names,
"required_input_names": required_input_names,
"output_names": output_names,
"parameters": safe_parameters,
}
def list_envi_tasks() -> Dict[str, Any]:
"""List ENVI task names without instantiating individual task parameters."""
result: Dict[str, Any] = {
"ok": False,
"engine": "envipyengine",
"task_count": 0,
"tasks": [],
"error": None,
}
try:
_ensure_envipyengine_config()
from envipyengine import Engine
except ImportError:
result["error"] = (
"envipyengine is not installed. Install it with: pip install envipyengine"
)
return result
with _ENVI_GLOBAL_LOCK:
with _envi_taskengine_runtime_context():
try:
names = Engine("ENVI").tasks()
except Exception as exc:
result["error"] = str(exc)
return result
result["tasks"] = [str(name) for name in names]
result["task_count"] = len(result["tasks"])
result["ok"] = True
return result
def discover_sarscape_sbas_tasks() -> Dict[str, Any]:
"""Discover installed SARscape SBAS/E-SBAS task names by filtering Engine.tasks()."""
report = list_envi_tasks()
task_names = list(report.get("tasks") or [])
keywords = (
"StackSBAS",
"StackESBAS",
"ConnectionGraphESBAS",
)
explicit_names = set(SARSCAPE_SBAS_NATIVE_WORKFLOW_CANDIDATES) | set(SARSCAPE_SBAS_SUPPORT_TASK_CANDIDATES)
matches = [
name
for name in task_names
if (
str(name) in explicit_names
or (
str(name).startswith("SARsInSAR")
and any(keyword.lower() in str(name).lower() for keyword in keywords)
)
)
]
static_task_files: Dict[str, str] = {}
for name in SARSCAPE_SBAS_TASK_CANDIDATES:
path = _static_envi_task_template_path(name)
if path:
static_task_files[name] = path
if name not in matches:
matches.append(name)
preferred_order = [
*SARSCAPE_SBAS_NATIVE_WORKFLOW_CANDIDATES,
*SARSCAPE_SBAS_SUPPORT_TASK_CANDIDATES,
"SARsInSARStackSBASGenerateConnectionGraph",
"SARsInSARStackSBASInterferogramGeneration",
"SARsInSARStackSBASInversionStep1",
"SARsInSARStackSBASInversionStep2",
"SARsInSARStackSBASGeocode",
"SARsInSARStackSBASVariogram",
"SARsInSARStackESBASInterferogramGeneration",
"SARsInSARStackESBASInversion",
"SARsInSARStackESBASGeocode",
"SARsInSARConnectionGraphESBAS",
]
ordered: List[str] = []
for name in preferred_order:
if name in matches and name not in ordered:
ordered.append(name)
for name in sorted(matches):
if name not in ordered:
ordered.append(name)
return {
"ok": bool(report.get("ok")) and bool(ordered),
"engine": report.get("engine"),
"task_count": int(report.get("task_count") or 0),
"sarscape_sbas_task_count": len(ordered),
"sarscape_sbas_tasks": ordered,
"static_task_files": {
name: static_task_files[name]
for name in ordered
if name in static_task_files
},
"error": report.get("error"),
}
def inspect_envi_tasks(task_names: List[str]) -> Dict[str, Any]:
"""Inspect ENVI/SARscape tasks without executing them."""
started_at = _utc_now_text()
deduped_names: List[str] = []
for raw_name in task_names:
name = str(raw_name or "").strip()
if name and name not in deduped_names:
deduped_names.append(name)
result: Dict[str, Any] = {
"ok": False,
"engine": "envipyengine",
"started_at": started_at,
"finished_at": None,
"task_count": len(deduped_names),
"available_count": 0,
"missing_count": 0,
"tasks": [],
"error": None,
}
if not deduped_names:
result["error"] = "No task names provided."
result["finished_at"] = _utc_now_text()
return result
try:
_ensure_envipyengine_config()
from envipyengine import Engine
except ImportError as exc:
result["error"] = (
"envipyengine is not installed. Install it with: pip install envipyengine"
)
result["finished_at"] = _utc_now_text()
return result
with _ENVI_GLOBAL_LOCK:
with _envi_taskengine_runtime_context():
try:
engine = Engine("ENVI")
except Exception as exc:
result["error"] = f"Failed to initialize ENVI engine: {exc}"
result["finished_at"] = _utc_now_text()
return result
for task_name in deduped_names:
item: Dict[str, Any] = {
"name": task_name,
"available": False,
"error": None,
"parameter_count": 0,
"input_names": [],
"required_input_names": [],
"output_names": [],
"parameters": [],
}
try:
task = engine.task(task_name)
summary = _summarize_task_parameters(getattr(task, "parameters", []))
item.update(summary)
item["available"] = True
except Exception as exc:
item["error"] = str(exc)
result["tasks"].append(item)
result["available_count"] = sum(1 for item in result["tasks"] if item.get("available"))
result["missing_count"] = sum(1 for item in result["tasks"] if not item.get("available"))
result["ok"] = result["available_count"] > 0
result["finished_at"] = _utc_now_text()
return result
def inspect_sarscape_sbas_tasks(
task_names: Optional[List[str]] = None,
*,
include_parameters: bool = False,
) -> Dict[str, Any]:
"""Inspect likely SARscape SBAS/E-SBAS task names for the installed version."""
status = get_status()
discovery = discover_sarscape_sbas_tasks()
names = task_names or list(discovery.get("sarscape_sbas_tasks") or _configured_sarscape_sbas_task_candidates())
if include_parameters:
task_report = inspect_envi_tasks(names)
else:
discovered_set = set(discovery.get("sarscape_sbas_tasks") or [])
task_report = {
"ok": bool(discovery.get("ok")),
"engine": "envipyengine",
"task_count": len(names),
"available_count": sum(1 for name in names if name in discovered_set),
"missing_count": sum(1 for name in names if name not in discovered_set),
"tasks": [
{
"name": name,
"available": name in discovered_set,
"error": None if name in discovered_set else "Task name not listed by Engine.tasks().",
"parameter_count": None,
"input_names": [],
"required_input_names": [],
"output_names": [],
"parameters": [],
}
for name in names
],
"error": discovery.get("error"),
}
task_report["status"] = {
"idl_installed": status.get("idl_installed"),
"idl_executable": status.get("idl_executable"),
"runner_ready": status.get("runner_ready"),
"runner_python": status.get("runner_python"),
"runner_message": status.get("runner_message"),
"dem_base_file": status.get("dem_base_file"),
"dem_exists": status.get("dem_exists"),
}
task_report["candidate_source"] = (
"SARSCAPE_SBAS_TASK_NAMES"
if str(_read_env("SARSCAPE_SBAS_TASK_NAMES", "") or "").strip()
else "engine_task_list"
)
task_report["include_parameters"] = bool(include_parameters)
task_report["discovery"] = discovery
task_report["ready_for_pipeline_design"] = bool(task_report.get("ok"))
return task_report
def inspect_sarscape_sbas_tasks_subprocess(
task_names: Optional[List[str]] = None,
*,
timeout_seconds: int = 120,
include_parameters: bool = False,
) -> Dict[str, Any]:
"""Run SARscape SBAS task inspection through the isolated ENVI runner."""
command = build_envi_runner_command("--inspect-sarscape-sbas")
if include_parameters:
command.append("--include-parameters")
for name in task_names or []:
if str(name or "").strip():
command.extend(["--task-name", str(name).strip()])
existing_taskengine_pids = _list_taskengine_pids()
try:
completed = subprocess.run(
command,
cwd=get_envi_runner_cwd(),
env=get_envi_runner_env(),
capture_output=True,
text=True,
timeout=max(10, int(timeout_seconds or 120)),
check=False,
)
except subprocess.TimeoutExpired as exc:
cleanup = _cleanup_new_taskengine_processes(existing_taskengine_pids)
stdout_text = str(exc.stdout or "").strip()
stderr_text = str(exc.stderr or "").strip()
return {
"ok": False,
"returncode": None,
"timeout": True,
"timeout_seconds": max(10, int(timeout_seconds or 120)),
"stdout": stdout_text[:2000],
"stderr": stderr_text[:2000],
"error": (
"SARscape SBAS task inspection timed out. "
"Use lightweight discovery without include_parameters, or provide a manually verified task template."
),
"runner_command": command,
**cleanup,
}
stdout_text = str(completed.stdout or "").strip()
stderr_text = str(completed.stderr or "").strip()
try:
payload = json.loads(stdout_text) if stdout_text else {}
except Exception:
payload = {}
payload.setdefault("returncode", int(completed.returncode))
payload.setdefault("stdout", stdout_text[:2000])
payload.setdefault("stderr", stderr_text[:2000])
payload["runner_command"] = command
if completed.returncode != 0:
payload["ok"] = False
payload.setdefault("error", stderr_text or stdout_text or f"returncode={completed.returncode}")
return payload
# ---------------------------------------------------------------------------
# Import workflow
# ---------------------------------------------------------------------------
def _import_single_dir(
display_name: str,
data_dir: str,
log_lines: List[str],
) -> tuple:
"""Import all .meta.xml in one directory. Returns (processed, failed, skipped)."""
if _has_sml(data_dir):
log_lines.append(f"[skip] {display_name}: .sml already exists")
return 0, 0, 1
meta_files = _find_meta_files(data_dir)
if not meta_files:
log_lines.append(f"[skip] {display_name}: no *.meta.xml")
return 0, 0, 0
processed = 0
failed = 0
for meta_file in meta_files:
start = time.time()
try:
execute_envi_task(
"SARsImportLuTan1",
{
"INPUT_FILE_LIST": [meta_file],
"ROOT_URI_FOR_OUTPUT": data_dir,
},
)
elapsed = round(time.time() - start, 1)
processed += 1
log_lines.append(
f"[ok] import {display_name}: "
f"{os.path.basename(meta_file)} ({elapsed}s)"
)
except Exception as exc:
elapsed = round(time.time() - start, 1)
failed += 1
log_lines.append(
f"[err] import {display_name}: "
f"{os.path.basename(meta_file)} failed ({elapsed}s): {exc}"
)
return processed, failed, 0
def run_import_workflow(
root_dir: str,
num_to_process: int = 0,
timeout: int = DEFAULT_TIMEOUT,
) -> Dict[str, Any]:
"""Scan root_dir for raw SAR data and batch-import via SARsImportLuTan1.
Supports two layouts:
- Task_* folders with master/slave subdirs
- Flat folders containing .meta.xml directly
"""
root_dir = _to_local_path(root_dir)
if not root_dir or not os.path.isdir(root_dir):
raise ValueError(f"Import root directory does not exist: {root_dir}")
task_folders = _collect_task_folders(root_dir)
scan_mode = "task_folder"
if not task_folders:
task_folders = sorted(
p for p in glob(os.path.join(root_dir, "*")) if os.path.isdir(p)
)
scan_mode = "flat_folder"
log_lines: List[str] = [
f"[envi] import task=SARsImportLuTan1",
f"[envi] root_dir={root_dir}",
f"[envi] scan_mode={scan_mode}",
f"[envi] found folders={len(task_folders)}",
]
if not task_folders:
return {
"summary": {
"scan_mode": scan_mode,
"task_folders": 0,
"processed": 0,
"failed": 0,
"skipped": 0,
},
"log_lines": log_lines,
}
total_processed = 0
total_failed = 0
total_skipped = 0
for folder in task_folders:
if num_to_process > 0 and total_processed >= num_to_process:
log_lines.append(f"[envi] reached limit={num_to_process}")
break
name = os.path.basename(folder)
if scan_mode == "flat_folder":
candidates = [(name, folder)]
else:
candidates = []
for side in ("master", "slave"):
d = os.path.join(folder, side)
if os.path.isdir(d):
candidates.append((f"{name}/{side}", d))
else:
log_lines.append(f"[warn] {name}/{side} missing, skip")
for display, data_dir in candidates:
if num_to_process > 0 and total_processed >= num_to_process:
break
p, f, s = _import_single_dir(display, data_dir, log_lines)
total_processed += p
total_failed += f
total_skipped += s
if total_failed > 0 and total_processed == 0:
detail = "\n".join(log_lines[-20:])
raise RuntimeError(
f"All import tasks failed. failed={total_failed}.\n{detail}"
)
return {
"summary": {
"scan_mode": scan_mode,
"task_folders": len(task_folders),
"processed": total_processed,
"failed": total_failed,
"skipped": total_skipped,
},
"log_lines": log_lines,
}
# ---------------------------------------------------------------------------
# D-InSAR workflow (with smart chaining: auto-import if needed)
# ---------------------------------------------------------------------------
def run_dinsar_workflow(
root_dir: str,
num_to_process: int = 0,
timeout: int = DEFAULT_TIMEOUT,
timeout_seconds: Optional[int] = None,
job_id: Optional[str] = None,
run_key: Optional[str] = None,
profile_code: str = "metatask",
started_at: Optional[datetime] = None,
) -> Dict[str, Any]:
"""Run D-InSAR metatask on Task_* folders.
Smart chaining: for each Task_* folder, if master/slave lack .sml files,
automatically run Import first, then proceed with D-InSAR.
DEM path is read from .env (IDL_DINSAR_DEM_BASE_FILE).
"""
timeout = min(timeout_seconds or timeout or DEFAULT_TIMEOUT, MAX_TIMEOUT)
root_dir = _to_local_path(root_dir)
run_started_at = started_at or datetime.utcnow()
run_started_at_text = run_started_at.isoformat(timespec="seconds") + "Z"
resolved_run_key = run_key or build_run_key("sarscape", profile_code, started_at=run_started_at)
dem_base_file = DEM_BASE_FILE
if not root_dir or not os.path.isdir(root_dir):
raise ValueError(f"D-InSAR root directory does not exist: {root_dir}")
if not dem_base_file:
raise ValueError(
"DEM path not configured. Set IDL_DINSAR_DEM_BASE_FILE in .env"
)
task_folders = _collect_task_folders(root_dir)
log_lines: List[str] = [
f"[envi] dinsar metatask",
f"[envi] root_dir={root_dir}",
f"[envi] dem={dem_base_file}",
f"[envi] Task_* folders={len(task_folders)}",
]
if not task_folders:
return {
"summary": {
"task_folders": 0,
"processed": 0,
"failed": 0,
"skipped": 0,
"auto_imported": 0,
},
"log_lines": log_lines,
"task_results": [],
"output_dirs": [],
"run_key": resolved_run_key,
"profile_code": profile_code,
}
processed = 0
failed = 0
skipped = 0
auto_imported = 0
task_results: List[Dict[str, Any]] = []
output_dirs: List[str] = []
for folder in task_folders:
if num_to_process > 0 and processed >= num_to_process:
log_lines.append(f"[envi] reached limit={num_to_process}")
break
task_name = os.path.basename(folder)
task_alias, pair_key, pair_meta = _resolve_dinsar_pair_identity(folder, task_name)
master_dir = os.path.join(folder, "master")
slave_dir = os.path.join(folder, "slave")
if not os.path.isdir(master_dir) or not os.path.isdir(slave_dir):
skipped += 1
log_lines.append(f"[skip] {task_name}: master/slave dir missing")
task_results.append(
{
"task_name": task_name,
"task_alias": task_alias,
"pair_key": pair_key,
"run_key": resolved_run_key,
"task_dir": folder,
"success": False,
"status": "skipped",
"error": "master/slave dir missing",
}
)
continue
# --- Smart chaining: auto-import if .sml missing ---
for side, side_dir in [("master", master_dir), ("slave", slave_dir)]:
if not _has_sml(side_dir):
meta_files = _find_meta_files(side_dir)
if not meta_files:
log_lines.append(
f"[warn] {task_name}/{side}: no .sml and no .meta.xml"
)
continue
log_lines.append(
f"[auto-import] {task_name}/{side}: "
f"importing {len(meta_files)} file(s)"
)
for mf in meta_files:
start = time.time()
try:
execute_envi_task(
"SARsImportLuTan1",
{
"INPUT_FILE_LIST": [mf],
"ROOT_URI_FOR_OUTPUT": side_dir,
},
)
elapsed = round(time.time() - start, 1)
auto_imported += 1
log_lines.append(
f"[auto-import ok] {task_name}/{side}: "
f"{os.path.basename(mf)} ({elapsed}s)"
)
except Exception as exc:
elapsed = round(time.time() - start, 1)
log_lines.append(
f"[auto-import err] {task_name}/{side}: "
f"{os.path.basename(mf)} ({elapsed}s): {exc}"
)
# After auto-import, check .sml again
master_base = _first_sml_base(master_dir)
slave_base = _first_sml_base(slave_dir)
if not master_base or not slave_base:
skipped += 1
log_lines.append(
f"[skip] {task_name}: still missing .sml after import "
f"(master={'yes' if master_base else 'no'} "
f"slave={'yes' if slave_base else 'no'})"
)
task_results.append(
{
"task_name": task_name,
"task_alias": task_alias,
"pair_key": pair_key,
"run_key": resolved_run_key,
"task_dir": folder,
"success": False,
"status": "skipped",
"error": "missing .sml after import",
}
)
continue
output_dir = os.path.join(folder, "dinsar_results")
os.makedirs(output_dir, exist_ok=True)
start = time.time()
try:
execute_envi_task(
"SARsMetataskInSARDisplacementGeneration",
{
"REFERENCE_SARSCAPEDATA": _build_sarscapedata(master_base),
"SECONDARY_SARSCAPEDATA": _build_sarscapedata(slave_base),
"DEM_SARSCAPEDATA": _build_sarscapedata(dem_base_file),
"OUTPUT_FOLDER": _normalize_path(output_dir),
},
)
elapsed = round(time.time() - start, 1)
processed += 1
log_lines.append(f"[ok] dinsar {task_name} ({elapsed}s)")
_write_envi_run_sidecar(
output_dir,
engine_code="sarscape",
profile_code=profile_code,
root_dir=root_dir,
task_dir=folder,
task_name=task_name,
task_alias=task_alias,
pair_key=pair_key,
run_key=resolved_run_key,
pair_meta=pair_meta,
started_at=run_started_at_text,
params={
"timeout_seconds": timeout,
"workflow": "metatask",
"dem_base_file": dem_base_file,
},
metrics={
"elapsed_seconds": elapsed,
},
)
output_dirs.append(output_dir)
task_results.append(
{
"task_name": task_name,
"task_alias": task_alias,
"pair_key": pair_key,
"run_key": resolved_run_key,
"task_dir": folder,
"output_dir": output_dir,
"success": True,
"status": "ok",
"elapsed_seconds": elapsed,
}
)
except Exception as exc:
elapsed = round(time.time() - start, 1)
failed += 1
log_lines.append(
f"[err] dinsar {task_name} failed ({elapsed}s): {exc}"
)
task_results.append(
{
"task_name": task_name,
"task_alias": task_alias,
"pair_key": pair_key,
"run_key": resolved_run_key,
"task_dir": folder,
"output_dir": output_dir,
"success": False,
"status": "failed",
"elapsed_seconds": elapsed,
"error": str(exc),
}
)
if failed > 0 and processed == 0 and len(task_folders) > 0:
detail = "\n".join(log_lines[-20:])
raise RuntimeError(
f"All D-InSAR tasks failed. failed={failed}, "
f"skipped={skipped}.\n{detail}"
)
return {
"summary": {
"task_folders": len(task_folders),
"processed": processed,
"failed": failed,
"skipped": skipped,
"auto_imported": auto_imported,
},
"log_lines": log_lines,
"task_results": task_results,
"output_dirs": output_dirs,
"run_key": resolved_run_key,
"profile_code": profile_code,
}
# ---------------------------------------------------------------------------
# Custom D-InSAR workflow helpers
# ---------------------------------------------------------------------------
def _read_sml_parameter(sml_file: str, param_name: str) -> Optional[str]:
"""Read a parameter value from a SARscape .sml XML file.
SML files use a namespace (http://www.sarmap.ch/xml/SARscapeHeaderSchema),
so we strip the namespace prefix before comparing tag names.
"""
sml_path = _to_local_path(sml_file)
if not os.path.isfile(sml_path):
return None
try:
tree = ET.parse(sml_path)
root = tree.getroot()
tag_upper = param_name.upper()
for elem in root.iter():
# Strip namespace: {http://...}TagName -> TagName
local_tag = elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag
if local_tag.upper() == tag_upper and elem.text:
return elem.text.strip()
except Exception as exc:
print(f"[WARN] _read_sml: {sml_path}{exc}")
return None
def _calculate_looks(
master_sml: str,
slave_sml: str,
target_resolution: float,
) -> tuple:
"""Calculate range and azimuth looks from SML pixel spacing.
Returns (range_looks, azimuth_looks) as integers >= 1.
Mirrors the IDL logic in batch_dinsarworkflow_all.pro.
"""
m_rg = _read_sml_parameter(master_sml, "PixelSpacingRg")
m_az = _read_sml_parameter(master_sml, "PixelSpacingAz")
m_inc = _read_sml_parameter(master_sml, "IncidenceAngle")
s_rg = _read_sml_parameter(slave_sml, "PixelSpacingRg")
s_az = _read_sml_parameter(slave_sml, "PixelSpacingAz")
s_inc = _read_sml_parameter(slave_sml, "IncidenceAngle")
if not all([m_rg, m_az, m_inc, s_rg, s_az, s_inc]):
raise ValueError(
"Cannot read pixel spacing / incidence angle from SML files. "
f"master={master_sml} slave={slave_sml}"
)
m_rg_f, m_az_f, m_inc_f = float(m_rg), float(m_az), float(m_inc)
s_rg_f, s_az_f, s_inc_f = float(s_rg), float(s_az), float(s_inc)
avg_az = (m_az_f + s_az_f) / 2.0
azimuth_looks = max(1, int(target_resolution / avg_az))
m_ground_rg = m_rg_f / math.sin(math.radians(m_inc_f))
s_ground_rg = s_rg_f / math.sin(math.radians(s_inc_f))
avg_ground_rg = (m_ground_rg + s_ground_rg) / 2.0
range_looks = max(1, int(target_resolution / avg_ground_rg))
return range_looks, azimuth_looks
def _find_latest_sarscapedata(
directory: str,
pattern_fragment: str,
log_lines: Optional[List[str]] = None,
) -> Optional[Dict[str, Any]]:
"""Scan directory for the latest SARscape file matching a pattern.
Used as a fallback when envipyengine reports 'outputs not generated'
but SARscape actually wrote the file. ``pattern_fragment`` is inserted
into a glob like ``*{pattern_fragment}*.sml``.
Returns a SARSCAPEDATA dict or None if not found.
"""
pattern = os.path.join(directory, f"*{pattern_fragment}*.sml")
candidates = sorted(glob(pattern), key=os.path.getmtime, reverse=True)
if log_lines is not None:
log_lines.append(f"[scan] pattern={pattern} found={len(candidates)}")
if not candidates:
return None
sml_path = candidates[0]
base = sml_path[:-4] # strip .sml
if log_lines is not None:
log_lines.append(f"[scan] using: {os.path.basename(base)}")
return _build_sarscapedata(base)
# Stability check configuration
_STABILITY_INTERVAL = int(_read_env("ENVI_STABILITY_CHECK_INTERVAL", "15") or 15)
_STABILITY_ROUNDS = int(_read_env("ENVI_STABILITY_ROUNDS", "3") or 3)
_STABILITY_MAX_WAIT = int(_read_env("ENVI_STABILITY_MAX_WAIT", "3600") or 3600)
def _wait_files_stable(
directory: str,
log_lines: Optional[List[str]] = None,
) -> None:
"""Wait until all files in directory have stable sizes.
Checks every _STABILITY_INTERVAL seconds. Returns when file sizes
are unchanged for _STABILITY_ROUNDS consecutive checks, or after
_STABILITY_MAX_WAIT seconds (safety cap).
"""
if not directory or not os.path.isdir(directory):
return
def _snapshot() -> Dict[str, int]:
sizes: Dict[str, int] = {}
try:
for root, _dirs, files in os.walk(directory):
for f in files:
fp = os.path.join(root, f)
try:
sizes[fp] = os.path.getsize(fp)
except OSError as exc:
print(f"[WARN] _snapshot getsize: {exc}")
except Exception as exc:
print(f"[WARN] _snapshot walk: {exc}")
return sizes
stable_count = 0
prev = _snapshot()
wait_start = time.time()
while stable_count < _STABILITY_ROUNDS:
elapsed = time.time() - wait_start
if elapsed > _STABILITY_MAX_WAIT:
if log_lines is not None:
log_lines.append(
f"[stability] max wait {_STABILITY_MAX_WAIT}s reached, proceeding"
)
break
time.sleep(_STABILITY_INTERVAL)
cur = _snapshot()
if cur == prev:
stable_count += 1
else:
stable_count = 0
prev = cur
total_wait = round(time.time() - wait_start, 1)
if log_lines is not None:
log_lines.append(
f"[stability] files stable after {total_wait}s "
f"({stable_count}/{_STABILITY_ROUNDS} rounds, "
f"{len(prev)} files)"
)
def _wait_for_disp_stable(
directory: str,
log_lines: Optional[List[str]] = None,
) -> bool:
"""Wait for *_rsp_disp file to appear and stabilize.
1. Poll until a file matching *_rsp_disp (no extra extension) appears.
2. Then wait until that file's size is unchanged for consecutive checks.
Returns True if the file was found and stabilized, False on timeout.
"""
if not directory or not os.path.isdir(directory):
return False
wait_start = time.time()
# Phase 1: wait for the file to appear
disp_path = None
while (time.time() - wait_start) < _STABILITY_MAX_WAIT:
for f in os.listdir(directory):
# Match *_rsp_disp exactly (not _rsp_disp_cc_geo etc.)
if f.endswith("_rsp_disp") and not f.endswith(".hdr") and not f.endswith(".sml"):
disp_path = os.path.join(directory, f)
break
if disp_path:
break
if log_lines is not None and int(time.time() - wait_start) % 60 == 0:
log_lines.append(
f"[wait_disp] waiting for _rsp_disp file... "
f"({int(time.time() - wait_start)}s)"
)
time.sleep(_STABILITY_INTERVAL)
if not disp_path:
if log_lines is not None:
log_lines.append(
f"[wait_disp] _rsp_disp file not found after "
f"{int(time.time() - wait_start)}s"
)
return False
if log_lines is not None:
log_lines.append(
f"[wait_disp] found {os.path.basename(disp_path)} "
f"after {int(time.time() - wait_start)}s"
)
# Phase 2: wait for the file size to stabilize
stable_count = 0
prev_size = None # None = not yet read; -1 = read failed
while stable_count < _STABILITY_ROUNDS:
if (time.time() - wait_start) > _STABILITY_MAX_WAIT:
if log_lines is not None:
log_lines.append("[wait_disp] max wait reached, proceeding")
break
time.sleep(_STABILITY_INTERVAL)
try:
cur_size = os.path.getsize(disp_path)
except OSError:
cur_size = -1
# Only count as stable if we got a real size (>= 0) and it matches prev
if cur_size >= 0 and cur_size == prev_size:
stable_count += 1
else:
stable_count = 0
prev_size = cur_size
total_wait = round(time.time() - wait_start, 1)
if log_lines is not None:
log_lines.append(
f"[wait_disp] stable after {total_wait}s "
f"(size={prev_size} bytes)"
)
return True
def _generate_gcps(
coherence_file: str,
output_shp: str,
coh_threshold: float = 0.7,
num_points: int = 100,
log_lines: Optional[List[str]] = None,
) -> bool:
"""Generate GCP shapefile from coherence raster (Python rewrite of IDL logic).
Divides the raster into a grid, picks the highest-coherence pixel per cell
that exceeds coh_threshold, and writes a point shapefile with SARscape fields.
"""
try:
import numpy as np
import rasterio
import geopandas as gpd
from shapely.geometry import Point
except ImportError as exc:
raise RuntimeError(
"rasterio, geopandas, and shapely are required for GCP generation. "
f"Missing: {exc}"
) from exc
coh_path = _to_local_path(coherence_file)
# SARscape coherence files may not have an extension — try common variants
if not os.path.isfile(coh_path):
for ext in [".hdr", ""]:
candidate = coh_path + ext
if os.path.isfile(candidate):
coh_path = candidate
break
if not os.path.isfile(coh_path):
if log_lines is not None:
log_lines.append(f"[gcp] coherence file not found: {coh_path}")
return False
with rasterio.open(coh_path) as src:
data = src.read(1) # first band
ns, nl = src.width, src.height
grid_dim = math.ceil(math.sqrt(num_points))
x_step = nl // grid_dim # rows
y_step = ns // grid_dim # cols
points = []
for j in range(grid_dim):
for i in range(grid_dim):
x_start = i * y_step
y_start = j * x_step
x_end = min((i + 1) * y_step, ns)
y_end = min((j + 1) * x_step, nl)
if x_start >= ns or y_start >= nl:
continue
cell = data[y_start:y_end, x_start:x_end]
max_val = float(np.nanmax(cell)) if cell.size > 0 else 0.0
if max_val >= coh_threshold:
idx = int(np.nanargmax(cell))
cell_h, cell_w = cell.shape
max_row = idx // cell_w
max_col = idx % cell_w
px_col = x_start + max_col
px_row = y_start + max_row
points.append((px_col, px_row))
if not points:
if log_lines is not None:
log_lines.append(
f"[gcp] no points found above threshold {coh_threshold}"
)
return False
# Build GeoDataFrame with SARscape-compatible fields
records = []
for idx, (col, row) in enumerate(points):
records.append({
"SHP_ID": idx,
"GCP_LABEL": f"GCP_{idx + 1}",
"GCP_TYPE": "undefined",
"GCP_COLUMN": float(col),
"GCP_ROW": float(row),
"GCP_OTHER_": "",
"geometry": Point(float(col), float(row)),
})
gdf = gpd.GeoDataFrame(records)
out_path = _to_local_path(output_shp)
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
gdf.to_file(out_path, driver="ESRI Shapefile")
if log_lines is not None:
log_lines.append(f"[gcp] created {len(points)} GCPs -> {out_path}")
return True
def _run_dinsar_custom_single(
master_base: str,
slave_base: str,
dem_base: str,
output_root: str,
log_lines: List[str],
job_id: Optional[str] = None,
pair_index: int = 0,
total_pairs: int = 0,
pair_name: str = "",
) -> bool:
"""Execute the 6-step custom D-InSAR workflow for one pair.
Uses envipyengine individual tasks instead of the metatask.
Each step's output SARSCAPEDATA is chained as input to the next step.
"""
master_sml = master_base + ".sml"
slave_sml = slave_base + ".sml"
# Build SARSCAPEDATA hashes
master_sd = _build_sarscapedata(master_base)
slave_sd = _build_sarscapedata(slave_base)
dem_sd = _build_sarscapedata(dem_base)
out_dir = _normalize_path(os.path.dirname(output_root))
# --- Calculate looks ---
try:
range_looks, azimuth_looks = _calculate_looks(
master_sml, slave_sml, CUSTOM_TARGET_RESOLUTION_M
)
log_lines.append(
f"[custom] looks: range={range_looks} azimuth={azimuth_looks} "
f"(target_res={CUSTOM_TARGET_RESOLUTION_M}m)"
)
except Exception as exc:
log_lines.append(f"[custom] looks calculation failed: {exc}")
return False
# === STEP 1: Interferogram Generation ===
log_lines.append("[custom] step 1/6: Interferogram Generation")
_write_progress(job_id, 1, 6, "Interferogram Generation", out_dir, pair_index, total_pairs, pair_name)
start = time.time()
try:
r1 = execute_envi_task(
"SARsInSARInterferogramGeneration",
{
"REFERENCE_SARSCAPEDATA": master_sd,
"SECONDARY_SARSCAPEDATA": slave_sd,
"DEM_SARSCAPEDATA": dem_sd,
"ROOT_URI_FOR_OUTPUT": out_dir,
"RG_LOOKS_NBR": float(range_looks),
"AZ_LOOKS_NBR": float(azimuth_looks),
"COREGISTRATION_WITH_DEM": True,
},
)
log_lines.append(f"[custom] step 1 ok ({round(time.time() - start, 1)}s)")
except Exception as exc:
log_lines.append(
f"[custom] step 1 failed ({round(time.time() - start, 1)}s): {exc}"
)
return False
# Extract step 1 outputs for chaining
s1_dint = _unwrap_sarscapedata(r1.get("DINT_SARSCAPEDATA"))
s1_ref_pwr = _unwrap_sarscapedata(r1.get("REFERENCE_POWER_SARSCAPEDATA"))
s1_sec_pwr = _unwrap_sarscapedata(r1.get("SECONDARY_POWER_SARSCAPEDATA"))
s1_sint = _unwrap_sarscapedata(r1.get("SINT_SARSCAPEDATA"))
s1_srdem = _unwrap_sarscapedata(r1.get("SRDEM_SARSCAPEDATA"))
log_lines.append(
f"[custom] step 1 outputs: dint={bool(s1_dint)} ref_pwr={bool(s1_ref_pwr)} "
f"sec_pwr={bool(s1_sec_pwr)} sint={bool(s1_sint)} srdem={bool(s1_srdem)}"
)
# === STEP 2: Filtering and Coherence ===
log_lines.append("[custom] step 2/6: Filtering and Coherence")
_write_progress(job_id, 2, 6, "Filtering and Coherence", out_dir, pair_index, total_pairs, pair_name)
start = time.time()
try:
r2 = execute_envi_task(
"SARsInSARFilterAndCoherence",
{
"DINT_SARSCAPEDATA": s1_dint,
"REFERENCE_SARSCAPEDATA": s1_ref_pwr,
"SECONDARY_SARSCAPEDATA": s1_sec_pwr,
"ROOT_URI_FOR_OUTPUT": out_dir,
"FILTERING_METHOD": CUSTOM_FILTER_METHOD,
"COHERENCE": True,
"INTERF_FILT": True,
},
)
log_lines.append(f"[custom] step 2 ok ({round(time.time() - start, 1)}s)")
except Exception as exc:
log_lines.append(
f"[custom] step 2 failed ({round(time.time() - start, 1)}s): {exc}"
)
return False
s2_fint = _unwrap_sarscapedata(r2.get("FINT_SARSCAPEDATA"))
s2_cc = _unwrap_sarscapedata(r2.get("COHERENCE_SARSCAPEDATA"))
log_lines.append(
f"[custom] step 2 outputs: fint={bool(s2_fint)} cc={bool(s2_cc)}"
)
# === STEP 3: Remove Residual Phase Frequency ===
log_lines.append("[custom] step 3/6: Orbital Trend Removal")
_write_progress(job_id, 3, 6, "Orbital Trend Removal", out_dir, pair_index, total_pairs, pair_name)
start = time.time()
s3_rrpf = None
try:
r3 = execute_envi_task(
"SARsInSARRemoveResidualPhaseFrequency",
{
"INTERFEROGRAM_SARSCAPEDATA": s2_fint,
"COHERENCE_FILE_NAME": s2_cc,
"ROOT_URI_FOR_OUTPUT": out_dir,
},
)
s3_rrpf = _unwrap_sarscapedata(r3.get("RRPF_DINT_SARSCAPEDATA"))
log_lines.append(f"[custom] step 3 ok ({round(time.time() - start, 1)}s)")
except Exception as exc:
elapsed = round(time.time() - start, 1)
log_lines.append(f"[custom] step 3 engine error ({elapsed}s): {exc}")
# envipyengine may report "outputs not generated" even though
# SARscape DID write the file. Scan the directory for it.
log_lines.append("[custom] step 3 scanning for generated RRPF file...")
s3_rrpf = _find_latest_sarscapedata(
_to_local_path(out_dir), "ISARRRPF", log_lines
)
if not s3_rrpf:
log_lines.append("[custom] step 3 failed: no RRPF output found")
return False
log_lines.append(f"[custom] step 3 output: {s3_rrpf.get('url', '?')}")
# === STEP 4: Phase Unwrapping ===
log_lines.append("[custom] step 4/6: Phase Unwrapping")
_write_progress(job_id, 4, 6, "Phase Unwrapping", out_dir, pair_index, total_pairs, pair_name)
start = time.time()
s4_upha = None
try:
r4 = execute_envi_task(
"SARsInSARPhaseUnwrapping",
{
"INFILE_NAME": s3_rrpf,
"COHERENCEFILE_NAME": s2_cc,
"ROOT_URI_FOR_OUTPUT": out_dir,
"UPHA_COH_THRESHOLD": CUSTOM_UNWRAP_COH_THRESHOLD,
},
)
s4_upha = _unwrap_sarscapedata(r4.get("OUTFILE_NAME"))
log_lines.append(f"[custom] step 4 ok ({round(time.time() - start, 1)}s)")
except Exception as exc:
elapsed = round(time.time() - start, 1)
log_lines.append(f"[custom] step 4 engine error ({elapsed}s): {exc}")
log_lines.append("[custom] step 4 scanning for generated UPHA file...")
s4_upha = _find_latest_sarscapedata(
_to_local_path(out_dir), "ISARPU", log_lines
)
if not s4_upha:
log_lines.append("[custom] step 4 failed: no UPHA output found")
return False
log_lines.append(f"[custom] step 4 output: {s4_upha.get('url', '?')}")
# === STEP 5: Refinement and Reflattening (with auto GCP) ===
# 5a: Generate GCPs from coherence
log_lines.append("[custom] step 5a/6: GCP Generation")
_write_progress(job_id, 5, 6, "GCP Generation + Refinement", out_dir, pair_index, total_pairs, pair_name)
# Extract coherence base path from SARSCAPEDATA for rasterio
cc_url = s2_cc.get("url", "") if isinstance(s2_cc, dict) else str(s2_cc)
cc_local = _to_local_path(cc_url)
auto_gcp_shp = os.path.join(os.path.dirname(cc_local) or out_dir, "auto_gcp.shp")
gcp_ok = _generate_gcps(
coherence_file=cc_local,
output_shp=auto_gcp_shp,
coh_threshold=CUSTOM_GCP_COH_THRESHOLD,
num_points=CUSTOM_GCP_NUMBER,
log_lines=log_lines,
)
if not gcp_ok:
log_lines.append("[custom] step 5a failed: GCP generation returned no points")
return False
# 5b: Refinement and Reflattening
log_lines.append("[custom] step 5b/6: Refinement and Reflattening")
start = time.time()
s5_upha = None
try:
r5 = execute_envi_task(
"SARsInSARRefinementAndReflattening",
{
"INPUT_UPHA_FILE_NAME": s4_upha,
"REFERENCE_SARSCAPEDATA": s1_ref_pwr,
"SECONDARY_SARSCAPEDATA": s1_sec_pwr,
"SLANT_RANGE_DEM_FILE_NAME": s1_srdem,
"SYNTHETIC_FILE_NAME": s1_sint,
"COHERENCE_FILE_NAME": s2_cc,
"DEM_SARSCAPEDATA": dem_sd,
"ROOT_URI_FOR_OUTPUT": out_dir,
"REFINEMENT_GCP_FILE_NAME": _normalize_path(auto_gcp_shp),
},
)
s5_upha = _unwrap_sarscapedata(r5.get("UPHA_REFLAT_SARSCAPEDATA"))
log_lines.append(f"[custom] step 5b ok ({round(time.time() - start, 1)}s)")
except Exception as exc:
elapsed = round(time.time() - start, 1)
log_lines.append(f"[custom] step 5b engine error ({elapsed}s): {exc}")
log_lines.append("[custom] step 5b scanning for generated REFLAT UPHA file...")
s5_upha = _find_latest_sarscapedata(
_to_local_path(out_dir), "ISARRF", log_lines
)
# Refinement produces multiple outputs; pick the _upha one
if not s5_upha:
s5_upha = _find_latest_sarscapedata(
_to_local_path(out_dir), "_reflat_upha", log_lines
)
if not s5_upha:
log_lines.append("[custom] step 5b failed: no REFLAT UPHA output found")
return False
log_lines.append(f"[custom] step 5b output: {s5_upha.get('url', '?')}")
# === STEP 6: Phase to Displacement and Geocoding ===
log_lines.append("[custom] step 6/6: Phase to Displacement + Geocoding")
_write_progress(job_id, 6, 6, "Phase to Displacement + Geocoding", out_dir, pair_index, total_pairs, pair_name)
start = time.time()
try:
r6 = execute_envi_task(
"SARsInSARPhaseToDisplacement",
{
"INPUT_SARSCAPEDATA": s5_upha,
"COHERNCE_SARSCAPEDATA": s2_cc,
"DEM_SARSCAPEDATA": dem_sd,
"ROOT_URI_FOR_OUTPUT": out_dir,
"COHERENCE_THRESHOLD": CUSTOM_GEOCODING_COH_THRESHOLD,
"GEOCODE_RG_GRID_SIZE": CUSTOM_GEOCODING_PIXEL_SIZE_M,
"GEOCODE_AZ_GRID_SIZE": CUSTOM_GEOCODING_PIXEL_SIZE_M,
},
)
log_lines.append(f"[custom] step 6 ok ({round(time.time() - start, 1)}s)")
except Exception as exc:
elapsed = round(time.time() - start, 1)
log_lines.append(f"[custom] step 6 engine error ({elapsed}s): {exc}")
# envipyengine may return (success or error) before ENVI finishes writing.
# Always wait for the _rsp_disp file to appear and stabilize.
log_lines.append("[custom] step 6: waiting for _rsp_disp file...")
disp_ok = _wait_for_disp_stable(_to_local_path(out_dir), log_lines)
if not disp_ok:
log_lines.append("[custom] step 6 failed: _rsp_disp never appeared")
return False
# Final stability wait: ensure ALL output files are fully written
log_lines.append("[custom] final stability check on output directory...")
_wait_files_stable(_to_local_path(out_dir), log_lines)
_write_progress(job_id, 6, 6, "Completed", out_dir, pair_index, total_pairs, pair_name)
return True
# ---------------------------------------------------------------------------
# Custom D-InSAR batch workflow
# ---------------------------------------------------------------------------
def run_dinsar_custom_workflow(
root_dir: str,
num_to_process: int = 0,
timeout: int = DEFAULT_TIMEOUT,
job_id: Optional[str] = None,
timeout_seconds: Optional[int] = None,
run_key: Optional[str] = None,
profile_code: str = "custom6",
started_at: Optional[datetime] = None,
) -> Dict[str, Any]:
"""Run custom 6-step D-InSAR on Task_* folders.
Same smart-chaining as metatask version (auto-import if needed),
but uses individual SARscape tasks with user-defined parameters.
"""
timeout = min(timeout_seconds or timeout or DEFAULT_TIMEOUT, MAX_TIMEOUT)
root_dir = _to_local_path(root_dir)
run_started_at = started_at or datetime.utcnow()
run_started_at_text = run_started_at.isoformat(timespec="seconds") + "Z"
resolved_run_key = run_key or build_run_key("sarscape", profile_code, started_at=run_started_at)
dem_base_file = DEM_BASE_FILE
if not root_dir or not os.path.isdir(root_dir):
raise ValueError(f"D-InSAR root directory does not exist: {root_dir}")
if not dem_base_file:
raise ValueError(
"DEM path not configured. Set IDL_DINSAR_DEM_BASE_FILE in .env"
)
task_folders = _collect_task_folders(root_dir)
log_lines: List[str] = [
f"[envi] dinsar custom (6-step)",
f"[envi] root_dir={root_dir}",
f"[envi] dem={dem_base_file}",
f"[envi] target_resolution={CUSTOM_TARGET_RESOLUTION_M}m",
f"[envi] filter={CUSTOM_FILTER_METHOD}",
f"[envi] unwrap_coh={CUSTOM_UNWRAP_COH_THRESHOLD}",
f"[envi] gcp_coh={CUSTOM_GCP_COH_THRESHOLD} gcp_n={CUSTOM_GCP_NUMBER}",
f"[envi] geocode_coh={CUSTOM_GEOCODING_COH_THRESHOLD} "
f"geocode_px={CUSTOM_GEOCODING_PIXEL_SIZE_M}m",
f"[envi] Task_* folders={len(task_folders)}",
]
if not task_folders:
return {
"summary": {
"task_folders": 0,
"processed": 0,
"failed": 0,
"skipped": 0,
"auto_imported": 0,
},
"log_lines": log_lines,
"task_results": [],
"output_dirs": [],
"run_key": resolved_run_key,
"profile_code": profile_code,
}
processed = 0
failed = 0
skipped = 0
auto_imported = 0
task_results: List[Dict[str, Any]] = []
output_dirs: List[str] = []
effective_total = len(task_folders) if num_to_process <= 0 else min(num_to_process, len(task_folders))
pair_counter = 0
for folder in task_folders:
if num_to_process > 0 and processed >= num_to_process:
log_lines.append(f"[envi] reached limit={num_to_process}")
break
task_name = os.path.basename(folder)
task_alias, pair_key, pair_meta = _resolve_dinsar_pair_identity(folder, task_name)
master_dir = os.path.join(folder, "master")
slave_dir = os.path.join(folder, "slave")
if not os.path.isdir(master_dir) or not os.path.isdir(slave_dir):
skipped += 1
log_lines.append(f"[skip] {task_name}: master/slave dir missing")
task_results.append(
{
"task_name": task_name,
"task_alias": task_alias,
"pair_key": pair_key,
"run_key": resolved_run_key,
"task_dir": folder,
"success": False,
"status": "skipped",
"error": "master/slave dir missing",
}
)
continue
# --- Smart chaining: auto-import if .sml missing ---
for side, side_dir in [("master", master_dir), ("slave", slave_dir)]:
if not _has_sml(side_dir):
meta_files = _find_meta_files(side_dir)
if not meta_files:
log_lines.append(
f"[warn] {task_name}/{side}: no .sml and no .meta.xml"
)
continue
log_lines.append(
f"[auto-import] {task_name}/{side}: "
f"importing {len(meta_files)} file(s)"
)
for mf in meta_files:
imp_start = time.time()
try:
execute_envi_task(
"SARsImportLuTan1",
{
"INPUT_FILE_LIST": [mf],
"ROOT_URI_FOR_OUTPUT": side_dir,
},
)
elapsed = round(time.time() - imp_start, 1)
auto_imported += 1
log_lines.append(
f"[auto-import ok] {task_name}/{side}: "
f"{os.path.basename(mf)} ({elapsed}s)"
)
except Exception as exc:
elapsed = round(time.time() - imp_start, 1)
log_lines.append(
f"[auto-import err] {task_name}/{side}: "
f"{os.path.basename(mf)} ({elapsed}s): {exc}"
)
# After auto-import, check .sml again
master_base = _first_sml_base(master_dir)
slave_base = _first_sml_base(slave_dir)
if not master_base or not slave_base:
skipped += 1
log_lines.append(
f"[skip] {task_name}: still missing .sml after import "
f"(master={'yes' if master_base else 'no'} "
f"slave={'yes' if slave_base else 'no'})"
)
task_results.append(
{
"task_name": task_name,
"task_alias": task_alias,
"pair_key": pair_key,
"run_key": resolved_run_key,
"task_dir": folder,
"success": False,
"status": "skipped",
"error": "missing .sml after import",
}
)
continue
output_dir = os.path.join(folder, "dinsar_results")
os.makedirs(output_dir, exist_ok=True)
output_root = os.path.join(output_dir, "workflow")
pair_start = time.time()
pair_counter += 1
log_lines.append(f"[custom] === {task_name} start ({pair_counter}/{effective_total}) ===")
try:
success = _run_dinsar_custom_single(
master_base, slave_base, dem_base_file, output_root, log_lines,
job_id=job_id,
pair_index=pair_counter,
total_pairs=effective_total,
pair_name=task_name,
)
except Exception as exc:
success = False
log_lines.append(f"[custom] {task_name} crashed: {exc}")
elapsed = round(time.time() - pair_start, 1)
if success:
processed += 1
log_lines.append(f"[ok] custom dinsar {task_name} ({elapsed}s)")
_write_envi_run_sidecar(
output_dir,
engine_code="sarscape",
profile_code=profile_code,
root_dir=root_dir,
task_dir=folder,
task_name=task_name,
task_alias=task_alias,
pair_key=pair_key,
run_key=resolved_run_key,
pair_meta=pair_meta,
started_at=run_started_at_text,
params={
"timeout_seconds": timeout,
"workflow": "custom6",
"dem_base_file": dem_base_file,
"target_resolution_m": CUSTOM_TARGET_RESOLUTION_M,
"filter_method": CUSTOM_FILTER_METHOD,
"unwrap_coh_threshold": CUSTOM_UNWRAP_COH_THRESHOLD,
"gcp_coh_threshold": CUSTOM_GCP_COH_THRESHOLD,
"gcp_number": CUSTOM_GCP_NUMBER,
"geocoding_coh_threshold": CUSTOM_GEOCODING_COH_THRESHOLD,
"geocoding_pixel_size_m": CUSTOM_GEOCODING_PIXEL_SIZE_M,
},
metrics={
"elapsed_seconds": elapsed,
},
)
output_dirs.append(output_dir)
task_results.append(
{
"task_name": task_name,
"task_alias": task_alias,
"pair_key": pair_key,
"run_key": resolved_run_key,
"task_dir": folder,
"output_dir": output_dir,
"success": True,
"status": "ok",
"elapsed_seconds": elapsed,
}
)
else:
failed += 1
log_lines.append(f"[err] custom dinsar {task_name} failed ({elapsed}s)")
task_results.append(
{
"task_name": task_name,
"task_alias": task_alias,
"pair_key": pair_key,
"run_key": resolved_run_key,
"task_dir": folder,
"output_dir": output_dir,
"success": False,
"status": "failed",
"elapsed_seconds": elapsed,
}
)
# Flush intermediate log so progress is preserved if process crashes
try:
os.makedirs(RUNTIME_DIR, exist_ok=True)
_interim_log = os.path.join(RUNTIME_DIR, "dinsar_custom_progress.log")
with open(_interim_log, "w", encoding="utf-8") as _fp:
_fp.write("\n".join(log_lines))
except Exception as exc:
print(f"[WARN] dinsar_workflow log: {exc}")
if failed > 0 and processed == 0 and len(task_folders) > 0:
detail = "\n".join(log_lines[-20:])
raise RuntimeError(
f"All custom D-InSAR tasks failed. failed={failed}, "
f"skipped={skipped}.\n{detail}"
)
return {
"summary": {
"task_folders": len(task_folders),
"processed": processed,
"failed": failed,
"skipped": skipped,
"auto_imported": auto_imported,
},
"log_lines": log_lines,
"task_results": task_results,
"output_dirs": output_dirs,
"run_key": resolved_run_key,
"profile_code": profile_code,
}
# ---------------------------------------------------------------------------
# Single-task D-InSAR workflow entrypoints
# ---------------------------------------------------------------------------
def run_single_task_workflow(
workflow: str,
task_dir: str,
output_dir: str,
*,
source_root: Optional[str] = None,
timeout: int = DEFAULT_TIMEOUT,
timeout_seconds: Optional[int] = None,
job_id: Optional[str] = None,
run_key: Optional[str] = None,
profile_code: Optional[str] = None,
started_at: Optional[datetime] = None,
) -> Dict[str, Any]:
"""Run one Task_* directory into an isolated output directory."""
normalized_workflow = str(workflow or "").strip().lower()
if normalized_workflow not in {"dinsar", "dinsar_custom"}:
raise ValueError(f"Unsupported single-task workflow: {workflow}")
timeout = min(timeout_seconds or timeout or DEFAULT_TIMEOUT, MAX_TIMEOUT)
task_dir = _to_local_path(task_dir)
output_dir = _to_local_path(output_dir)
source_root = _to_local_path(source_root or os.path.dirname(task_dir))
run_started_at = started_at or datetime.utcnow()
run_started_at_text = run_started_at.isoformat(timespec="seconds") + "Z"
resolved_profile_code = profile_code or ("custom6" if normalized_workflow == "dinsar_custom" else "metatask")
resolved_run_key = run_key or build_run_key("sarscape", resolved_profile_code, started_at=run_started_at)
task_name = os.path.basename(os.path.normpath(task_dir))
task_alias, pair_key, pair_meta = _resolve_dinsar_pair_identity(task_dir, task_name)
if not task_dir or not os.path.isdir(task_dir):
raise ValueError(f"D-InSAR task directory does not exist: {task_dir}")
if not output_dir:
raise ValueError("output_dir must not be empty.")
if not DEM_BASE_FILE:
raise ValueError("DEM path not configured. Set IDL_DINSAR_DEM_BASE_FILE in .env")
native_output_dir = get_run_native_output_dir(output_dir)
master_dir = os.path.join(task_dir, "master")
slave_dir = os.path.join(task_dir, "slave")
if not os.path.isdir(master_dir) or not os.path.isdir(slave_dir):
raise RuntimeError(f"{task_name}: master/slave dir missing")
os.makedirs(output_dir, exist_ok=True)
os.makedirs(native_output_dir, exist_ok=True)
log_lines: List[str] = [
f"[envi] single task workflow={normalized_workflow}",
f"[envi] source_root={source_root}",
f"[envi] task_dir={task_dir}",
f"[envi] output_dir={output_dir}",
f"[envi] native_output_dir={native_output_dir}",
f"[envi] dem={DEM_BASE_FILE}",
]
auto_imported = 0
for side, side_dir in [("master", master_dir), ("slave", slave_dir)]:
if _has_sml(side_dir):
continue
meta_files = _find_meta_files(side_dir)
if not meta_files:
log_lines.append(f"[warn] {task_name}/{side}: no .sml and no .meta.xml")
continue
log_lines.append(f"[auto-import] {task_name}/{side}: importing {len(meta_files)} file(s)")
for meta_file in meta_files:
start = time.time()
try:
execute_envi_task(
"SARsImportLuTan1",
{
"INPUT_FILE_LIST": [meta_file],
"ROOT_URI_FOR_OUTPUT": side_dir,
},
)
elapsed = round(time.time() - start, 1)
auto_imported += 1
log_lines.append(f"[auto-import ok] {task_name}/{side}: {os.path.basename(meta_file)} ({elapsed}s)")
except Exception as exc:
elapsed = round(time.time() - start, 1)
log_lines.append(f"[auto-import err] {task_name}/{side}: {os.path.basename(meta_file)} ({elapsed}s): {exc}")
master_base = _first_sml_base(master_dir)
slave_base = _first_sml_base(slave_dir)
if not master_base or not slave_base:
raise RuntimeError(
f"{task_name}: missing .sml after import "
f"(master={'yes' if master_base else 'no'} slave={'yes' if slave_base else 'no'})"
)
start = time.time()
if normalized_workflow == "dinsar":
_write_progress(job_id, 1, 1, "Metatask D-InSAR", output_dir, 1, 1, task_name)
execute_envi_task(
"SARsMetataskInSARDisplacementGeneration",
{
"REFERENCE_SARSCAPEDATA": _build_sarscapedata(master_base),
"SECONDARY_SARSCAPEDATA": _build_sarscapedata(slave_base),
"DEM_SARSCAPEDATA": _build_sarscapedata(DEM_BASE_FILE),
"OUTPUT_FOLDER": _normalize_path(native_output_dir),
},
)
_write_progress(job_id, 1, 1, "Completed", output_dir, 1, 1, task_name)
else:
success = _run_dinsar_custom_single(
master_base,
slave_base,
DEM_BASE_FILE,
os.path.join(native_output_dir, "workflow"),
log_lines,
job_id=job_id,
pair_index=1,
total_pairs=1,
pair_name=task_name,
)
if not success:
detail = "\n".join(log_lines[-20:])
raise RuntimeError(f"{task_name}: custom D-InSAR workflow failed.\n{detail}")
elapsed = round(time.time() - start, 1)
log_lines.append(f"[ok] {normalized_workflow} {task_name} ({elapsed}s)")
_write_envi_run_sidecar(
output_dir,
engine_code="sarscape",
profile_code=resolved_profile_code,
root_dir=source_root,
task_dir=task_dir,
task_name=task_name,
task_alias=task_alias,
pair_key=pair_key,
run_key=resolved_run_key,
pair_meta=pair_meta,
started_at=run_started_at_text,
params=(
{
"timeout_seconds": timeout,
"workflow": "custom6",
"dem_base_file": DEM_BASE_FILE,
"target_resolution_m": CUSTOM_TARGET_RESOLUTION_M,
"filter_method": CUSTOM_FILTER_METHOD,
"unwrap_coh_threshold": CUSTOM_UNWRAP_COH_THRESHOLD,
"gcp_coh_threshold": CUSTOM_GCP_COH_THRESHOLD,
"gcp_number": CUSTOM_GCP_NUMBER,
"geocoding_coh_threshold": CUSTOM_GEOCODING_COH_THRESHOLD,
"geocoding_pixel_size_m": CUSTOM_GEOCODING_PIXEL_SIZE_M,
}
if normalized_workflow == "dinsar_custom"
else {
"timeout_seconds": timeout,
"workflow": "metatask",
"dem_base_file": DEM_BASE_FILE,
}
),
metrics={
"elapsed_seconds": elapsed,
},
native_output_dir=native_output_dir,
)
return {
"summary": {
"task_folders": 1,
"processed": 1,
"failed": 0,
"skipped": 0,
"auto_imported": auto_imported,
},
"log_lines": log_lines,
"task_results": [
{
"task_name": task_name,
"task_alias": task_alias,
"pair_key": pair_key,
"run_key": resolved_run_key,
"task_dir": task_dir,
"output_dir": output_dir,
"native_output_dir": native_output_dir,
"success": True,
"status": "ok",
"elapsed_seconds": elapsed,
}
],
"output_dirs": [output_dir],
"run_key": resolved_run_key,
"profile_code": resolved_profile_code,
}
# ---------------------------------------------------------------------------
# Inspect (pre-check) functions
# ---------------------------------------------------------------------------
def inspect_import(root_dir: str) -> Dict[str, Any]:
"""Pre-check Import readiness for a root directory."""
root_dir = _to_local_path(root_dir)
result: Dict[str, Any] = {
"workflow": "import",
"root_dir": root_dir,
"exists": bool(root_dir and os.path.isdir(root_dir)),
"ready": False,
"summary": {},
"warnings": [],
}
if not result["exists"]:
result["warnings"].append("root_dir does not exist.")
return result
task_folders = _collect_task_folders(root_dir)
scan_mode = "task_folder"
scan_targets = task_folders
if not task_folders:
scan_mode = "flat_folder"
scan_targets = sorted(
p for p in glob(os.path.join(root_dir, "*")) if os.path.isdir(p)
)
meta_count = 0
sml_count = 0
candidate_count = 0
for folder in scan_targets:
if scan_mode == "flat_folder":
candidates = [folder]
else:
candidates = [
os.path.join(folder, "master"),
os.path.join(folder, "slave"),
]
for candidate in candidates:
if not os.path.isdir(candidate):
continue
metas = glob(os.path.join(candidate, "*.meta.xml"))
smls = glob(os.path.join(candidate, "*.sml"))
meta_count += len(metas)
sml_count += len(smls)
if metas and not smls:
candidate_count += len(metas)
result["summary"] = {
"scan_mode": scan_mode,
"folder_count": len(scan_targets),
"meta_file_count": meta_count,
"existing_sml_count": sml_count,
"import_candidate_count": candidate_count,
}
result["ready"] = candidate_count > 0
if candidate_count == 0:
result["warnings"].append(
"No import candidates (*.meta.xml without *.sml)."
)
return result
def inspect_dinsar(root_dir: str) -> Dict[str, Any]:
"""Pre-check D-InSAR readiness. Includes Import status detection."""
root_dir = _to_local_path(root_dir)
dem_base_file = DEM_BASE_FILE
dem_ok = bool(
dem_base_file
and (os.path.isfile(dem_base_file) or os.path.isdir(dem_base_file))
)
result: Dict[str, Any] = {
"workflow": "dinsar",
"root_dir": root_dir,
"exists": bool(root_dir and os.path.isdir(root_dir)),
"ready": False,
"summary": {},
"warnings": [],
}
if not result["exists"]:
result["warnings"].append("root_dir does not exist.")
return result
task_folders = _collect_task_folders(root_dir)
ready_count = 0
need_import_count = 0
missing_structure = 0
for folder in task_folders:
master_dir = os.path.join(folder, "master")
slave_dir = os.path.join(folder, "slave")
if not os.path.isdir(master_dir) or not os.path.isdir(slave_dir):
missing_structure += 1
continue
master_has_sml = _has_sml(master_dir)
slave_has_sml = _has_sml(slave_dir)
if master_has_sml and slave_has_sml:
ready_count += 1
else:
master_has_meta = bool(_find_meta_files(master_dir))
slave_has_meta = bool(_find_meta_files(slave_dir))
if (master_has_sml or master_has_meta) and (
slave_has_sml or slave_has_meta
):
need_import_count += 1
else:
missing_structure += 1
result["summary"] = {
"task_folder_count": len(task_folders),
"ready_for_dinsar": ready_count,
"need_import_first": need_import_count,
"missing_structure": missing_structure,
"dem_base_file": dem_base_file or "(not configured)",
"dem_exists": dem_ok,
}
result["ready"] = (ready_count + need_import_count) > 0 and dem_ok
if not dem_ok:
result["warnings"].append(
"DEM path not configured or does not exist. "
"Set IDL_DINSAR_DEM_BASE_FILE in .env"
)
if len(task_folders) == 0:
result["warnings"].append("No Task_* folders found.")
if need_import_count > 0:
result["warnings"].append(
f"{need_import_count} folder(s) need Import first "
f"(will be auto-imported during D-InSAR)."
)
return result
# ---------------------------------------------------------------------------
# System status
# ---------------------------------------------------------------------------
def get_status() -> Dict[str, Any]:
"""Return ENVI/IDL system status and DEM configuration."""
idl_installed = bool(IDL_EXECUTABLE and os.path.isfile(IDL_EXECUTABLE))
is_running = is_any_process_running(["idl.exe", "idlde.exe", "taskengine.exe"])
runner_status = probe_envi_runner()
dem_ok = bool(
DEM_BASE_FILE
and (os.path.isfile(DEM_BASE_FILE) or os.path.isdir(DEM_BASE_FILE))
)
return {
"engine": "envipyengine",
"idl_installed": idl_installed,
"idl_executable": IDL_EXECUTABLE,
"idl_running": is_running,
"dem_base_file": DEM_BASE_FILE or "(not configured)",
"dem_exists": dem_ok,
"runner_python": runner_status.get("python_path", ""),
"runner_cwd": runner_status.get("cwd", ""),
"runner_ready": bool(runner_status.get("ready")),
"runner_returncode": runner_status.get("returncode"),
"runner_message": runner_status.get("message", ""),
}
# ---------------------------------------------------------------------------
# Run history (stored as JSON files in runtime dir)
# ---------------------------------------------------------------------------
_RUNS_DIR = os.path.join(RUNTIME_DIR, "runs")
def _save_run_record(record: Dict[str, Any]) -> None:
os.makedirs(_RUNS_DIR, exist_ok=True)
run_id = record.get("run_id", "unknown")
path = os.path.join(_RUNS_DIR, f"{run_id}.json")
with open(path, "w", encoding="utf-8") as fp:
json.dump(record, fp, ensure_ascii=False, indent=2)
def list_recent_runs(limit: int = 20) -> List[Dict[str, Any]]:
"""Return recent run records, newest first."""
if not os.path.isdir(_RUNS_DIR):
return []
files = sorted(
glob(os.path.join(_RUNS_DIR, "*.json")), reverse=True
)
runs: List[Dict[str, Any]] = []
for f in files[:limit]:
try:
with open(f, "r", encoding="utf-8") as fp:
runs.append(json.load(fp))
except Exception as exc:
print(f"[WARN] get_run_history: {exc}")
continue
return runs
# ---------------------------------------------------------------------------
# Top-level workflow runner (called by envi_runner_cli.py)
# ---------------------------------------------------------------------------
def run_workflow(
workflow: str,
root_dir: str,
num_to_process: int = 0,
timeout: int = DEFAULT_TIMEOUT,
job_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Execute a complete workflow and persist the run record."""
timeout = min(timeout or DEFAULT_TIMEOUT, MAX_TIMEOUT)
run_started_at = datetime.utcnow()
run_id = run_started_at.strftime("%Y%m%d_%H%M%S") + f"_{workflow}"
started_at = run_started_at.isoformat(timespec="seconds") + "Z"
started = time.time()
profile_code = None
run_key = None
try:
if workflow == "import":
result = run_import_workflow(root_dir, num_to_process, timeout)
elif workflow == "dinsar":
profile_code = "metatask"
run_key = build_run_key("sarscape", profile_code, started_at=run_started_at)
result = run_dinsar_workflow(
root_dir,
num_to_process,
timeout,
job_id=job_id,
run_key=run_key,
profile_code=profile_code,
started_at=run_started_at,
)
elif workflow == "dinsar_custom":
profile_code = "custom6"
run_key = build_run_key("sarscape", profile_code, started_at=run_started_at)
result = run_dinsar_custom_workflow(
root_dir,
num_to_process,
timeout,
job_id=job_id,
run_key=run_key,
profile_code=profile_code,
started_at=run_started_at,
)
else:
raise ValueError(f"Unknown workflow: {workflow}")
status = "success"
error = None
except Exception as exc:
result = {"summary": {}, "log_lines": [str(exc)]}
status = "failed"
error = str(exc)
elapsed = round(time.time() - started, 1)
finished_at = datetime.utcnow().isoformat(timespec="seconds") + "Z"
# Write log file
os.makedirs(RUNTIME_DIR, exist_ok=True)
log_path = os.path.join(RUNTIME_DIR, f"{run_id}.log")
with open(log_path, "w", encoding="utf-8") as fp:
fp.write("\n".join(result.get("log_lines", [])))
record = {
"run_id": run_id,
"workflow": workflow,
"status": status,
"started_at": started_at,
"finished_at": finished_at,
"duration_seconds": elapsed,
"root_dir": root_dir,
"summary": result.get("summary", {}),
"task_results": result.get("task_results", []),
"output_dirs": result.get("output_dirs", []),
"run_key": result.get("run_key") or run_key,
"profile_code": result.get("profile_code") or profile_code,
"log_path": log_path,
"error": error,
}
_save_run_record(record)
# Clean up progress file
if job_id:
try:
pf = _progress_file_path(job_id)
if os.path.isfile(pf):
os.remove(pf)
except Exception as exc:
print(f"[WARN] run_workflow cleanup: {exc}")
if error:
raise RuntimeError(error)
return record
# ---------------------------------------------------------------------------
# Disp result extraction
# ---------------------------------------------------------------------------
import re as _re
import shutil as _shutil
def _get_dinsar_target_dir() -> str:
raw = _read_env("MONITOR_DINSAR_DIRS", "")
dirs = [p.strip() for p in raw.replace(";", ",").split(",") if p.strip()]
if not dirs:
raise ValueError("MONITOR_DINSAR_DIRS 未在 .env 中配置")
return dirs[0]
def extract_disp_results(root_dir: str, dest_dir: Optional[str] = None) -> Dict[str, Any]:
from .envi_extract import extract_disp_results as _extract_disp_results
return _extract_disp_results(root_dir, dest_dir)
"""将 Task_*/dinsar_results/ 下最新的 disp 文件复制到目标目录并重命名。
Args:
root_dir: 生产根目录(包含 Task_* 文件夹)
dest_dir: 目标目录(可选,默认使用 MONITOR_DINSAR_DIRS[0]
"""
if dest_dir:
target_dir = _to_local_path(dest_dir)
if not os.path.isdir(target_dir):
raise ValueError(f"目标目录不存在: {dest_dir}")
else:
target_dir = _get_dinsar_target_dir()
os.makedirs(target_dir, exist_ok=True)
_pattern = _re.compile(r"out_ISARPTD_(\d{14})_rsp_disp$")
_extensions = ["", ".hdr", ".sml"]
processed = 0
copied = 0
skipped = 0
overwritten = 0
failed = 0
details: List[Dict[str, Any]] = []
task_dirs = sorted(
d for d in os.listdir(root_dir)
if d.startswith("Task_") and os.path.isdir(os.path.join(root_dir, d))
)
for task_name in task_dirs:
dinsar_results_dir = os.path.join(root_dir, task_name, "dinsar_results")
if not os.path.isdir(dinsar_results_dir):
continue
# Find all no-extension files matching the pattern
candidates = []
try:
for fname in os.listdir(dinsar_results_dir):
m = _pattern.match(fname)
if m and os.path.isfile(os.path.join(dinsar_results_dir, fname)):
candidates.append((m.group(1), fname))
except OSError:
continue
if not candidates:
continue
# Pick the one with the largest timestamp
candidates.sort(key=lambda x: x[0], reverse=True)
_, best_fname = candidates[0]
base_src = os.path.join(dinsar_results_dir, best_fname)
target_base = os.path.join(target_dir, f"{task_name}_geo_disp")
task_status = "ok"
task_copied = 0
task_skipped = 0
task_overwritten = 0
task_failed = 0
for ext in _extensions:
src = base_src + ext
dst = target_base + ext
if not os.path.isfile(src):
task_failed += 1
continue
if os.path.isfile(dst):
if os.path.getsize(src) == os.path.getsize(dst):
task_skipped += 1
skipped += 1
continue
else:
try:
_shutil.copy2(src, dst)
task_overwritten += 1
overwritten += 1
except OSError as e:
task_failed += 1
task_status = f"error: {e}"
else:
try:
_shutil.copy2(src, dst)
task_copied += 1
copied += 1
except OSError as e:
task_failed += 1
task_status = f"error: {e}"
failed += task_failed
processed += 1
details.append({
"task": task_name,
"target_name": f"{task_name}_geo_disp",
"status": task_status,
"copied": task_copied,
"skipped": task_skipped,
"overwritten": task_overwritten,
"failed": task_failed,
})
return {
"processed": processed,
"copied": copied,
"skipped": skipped,
"overwritten": overwritten,
"failed": failed,
"target_dir": target_dir,
"details": details,
}
def get_task_overview(root_dir: str) -> Dict[str, Any]:
from .envi_extract import get_task_overview as _get_task_overview
return _get_task_overview(root_dir)
"""扫描 root_dir 下所有 Task_* 文件夹,返回每个 Task 的处理状态。"""
try:
target_dir = _get_dinsar_target_dir()
except ValueError:
target_dir = None
if not os.path.isdir(root_dir):
raise ValueError(f"目录不存在: {root_dir}")
task_dirs = sorted(
d for d in os.listdir(root_dir)
if d.startswith("Task_") and os.path.isdir(os.path.join(root_dir, d))
)
tasks = []
summary = {"total": 0, "imported": 0, "dinsar_done": 0, "extracted": 0}
for task_name in task_dirs:
task_path = os.path.join(root_dir, task_name)
has_master = os.path.isdir(os.path.join(task_path, "master"))
has_slave = os.path.isdir(os.path.join(task_path, "slave"))
has_structure = has_master and has_slave
imported = False
for sub in ("master", "slave"):
sub_path = os.path.join(task_path, sub)
if os.path.isdir(sub_path):
if any(f.endswith(".sml") for f in os.listdir(sub_path)):
imported = True
break
dinsar_results_dir = os.path.join(task_path, "dinsar_results")
dinsar_done = False
if os.path.isdir(dinsar_results_dir):
dinsar_done = any(
_re.search(r"ISARPTD.*_rsp_disp$", f)
for f in os.listdir(dinsar_results_dir)
if os.path.isfile(os.path.join(dinsar_results_dir, f))
)
extracted = False
if target_dir:
extracted = os.path.isfile(os.path.join(target_dir, f"{task_name}_geo_disp"))
try:
mtime = os.path.getmtime(task_path)
last_modified = datetime.utcfromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")
except OSError:
last_modified = "-"
tasks.append({
"task_name": task_name,
"has_structure": has_structure,
"imported": imported,
"dinsar_done": dinsar_done,
"extracted": extracted,
"last_modified": last_modified,
})
summary["total"] += 1
if imported:
summary["imported"] += 1
if dinsar_done:
summary["dinsar_done"] += 1
if extracted:
summary["extracted"] += 1
return {"tasks": tasks, "summary": summary, "target_dir": target_dir or ""}
# ---------------------------------------------------------------------------
# Backward-compatible re-exports from split modules
# These lazy imports avoid circular-import issues at module load time.
# Callers that import run_import_workflow etc. directly from envi_service
# will still work without any changes.
# ---------------------------------------------------------------------------
def __getattr__(name: str): # noqa: N807
_split_map = {
"run_import_workflow": ("envi_import", "run_import_workflow"),
"inspect_import": ("envi_import", "inspect_import"),
"run_dinsar_workflow": ("envi_dinsar", "run_dinsar_workflow"),
"run_dinsar_custom_workflow": ("envi_dinsar", "run_dinsar_custom_workflow"),
"inspect_dinsar": ("envi_dinsar", "inspect_dinsar"),
"extract_disp_results": ("envi_extract", "extract_disp_results"),
"get_task_overview": ("envi_extract", "get_task_overview"),
}
if name in _split_map:
mod_name, attr = _split_map[name]
import importlib
mod = importlib.import_module(f".{mod_name}", package=__name__.rsplit(".", 1)[0])
return getattr(mod, attr)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")