Add SARscape SBAS prepared stack workflow

This commit is contained in:
2026-04-30 09:42:01 +08:00
parent 4c0d1f2c2b
commit 6696fe90fa
27 changed files with 4915 additions and 95 deletions
@@ -0,0 +1,255 @@
"""Extract SARscape SBAS task template metadata from installed .task files.
This script is intentionally file-based. It does not start ENVI, taskengine, or
envipyengine, so it is safe to use on workstations where live
task.parameters inspection can hang.
Examples:
python scripts/extract_sarscape_sbas_task_templates.py --json
python scripts/extract_sarscape_sbas_task_templates.py --template --output tmp_sarscape_sbas_template.json
"""
from __future__ import annotations
import argparse
import json
import os
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
DEFAULT_ENVI_ROOT = Path(os.environ.get("SARSCAPE_ENVI_ROOT", r"C:\Program Files\Harris\ENVI56"))
NATIVE_WORKFLOW_TASKS = ["wf_sbas", "wf_esbas"]
SUPPORT_TASKS = [
"SARscape_setting_output_folders",
"SARsLoadPreferences",
"SARsImportSarSelector",
"SARscapeSuggestLooks",
"SARscapeEnviuriToShape",
]
STACK_TASKS = [
"SARsInSARStackSBASGenerateConnectionGraph",
"SARsInSARStackSBASInterferogramGeneration",
"SARsInSARStackSBASInversionStep1",
"SARsInSARStackSBASInversionStep2",
"SARsInSARStackSBASGeocode",
"SARsInSARStackSBASVariogram",
]
ESBAS_TASKS = [
"SARsInSARConnectionGraphESBAS",
"SARsInSARStackESBASInterferogramGeneration",
"SARsInSARStackESBASInversion",
"SARsInSARStackESBASGeocode",
]
DEFAULT_TASKS = [
*NATIVE_WORKFLOW_TASKS,
*SUPPORT_TASKS,
*STACK_TASKS,
*ESBAS_TASKS,
]
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Read SARscape SBAS .task files and emit a static parameter report."
)
parser.add_argument("--envi-root", default=str(DEFAULT_ENVI_ROOT), help="ENVI install root.")
parser.add_argument("--task", action="append", default=[], help="Task name to extract. May be repeated.")
parser.add_argument("--json", action="store_true", help="Print the extraction report as JSON.")
parser.add_argument("--template", action="store_true", help="Print a backend template skeleton.")
parser.add_argument("--output", default="", help="Optional output file for JSON/template output.")
return parser.parse_args()
def _candidate_paths(envi_root: Path, task_name: str) -> Iterable[Path]:
if task_name in NATIVE_WORKFLOW_TASKS:
yield envi_root / "user_custom_code" / f"{task_name}.task"
yield envi_root / "resource" / "templates" / "tasks" / "SARscape" / f"{task_name}.task"
yield envi_root / "resource" / "templates" / "tasks" / f"{task_name}.task"
yield envi_root / "user_custom_code" / f"{task_name}.task"
def _read_task(envi_root: Path, task_name: str) -> Dict[str, Any]:
for path in _candidate_paths(envi_root, task_name):
if path.is_file():
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except UnicodeDecodeError:
payload = json.loads(path.read_text(encoding="utf-8-sig"))
return {"ok": True, "path": str(path), "payload": payload}
return {"ok": False, "path": None, "payload": None, "error": "task file not found"}
def _choice_list(value: Any) -> Optional[Dict[str, Any]]:
if isinstance(value, dict):
return {str(key): item for key, item in value.items()}
return None
def _normalize_parameter(item: Dict[str, Any]) -> Dict[str, Any]:
parameter_type = str(item.get("parameterType") or "").strip()
required = bool(item.get("required")) or parameter_type.lower() == "required"
default = item.get("defaultValue", item.get("default", item.get("value")))
normalized: Dict[str, Any] = {
"name": str(item.get("name") or "").strip(),
"keyword": str(item.get("keyword") or item.get("name") or "").strip(),
"display_name": str(item.get("displayName") or item.get("display_name") or "").strip(),
"data_type": str(item.get("dataType") or item.get("type") or "").strip(),
"direction": str(item.get("direction") or "").strip().lower(),
"required": required,
}
if parameter_type:
normalized["parameter_type"] = parameter_type
if default is not None:
normalized["default"] = default
choices = _choice_list(item.get("choiceList") or item.get("choice_list"))
if choices:
normalized["choice_list"] = choices
description = str(item.get("description") or "").strip()
if description:
normalized["description"] = description
return normalized
def _dag_summary(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
parameters = payload.get("parameters") if isinstance(payload.get("parameters"), list) else []
dag_param = next((item for item in parameters if item.get("name") == "DAG"), None)
dag = dag_param.get("default") if isinstance(dag_param, dict) else None
if not isinstance(dag, dict):
return []
summary: List[Dict[str, Any]] = []
for node_id, node in dag.items():
if not isinstance(node, dict):
continue
task_name = node.get("name")
if isinstance(task_name, dict):
task_name = task_name.get("base_class") or "<inline_task>"
summary.append(
{
"node_id": str(node_id),
"task_name": str(task_name or ""),
"external_input": node.get("external_input") or {},
"internal_input": node.get("internal_input") or {},
"static_input": node.get("static_input") or {},
"output": node.get("output") or {},
}
)
return summary
def build_report(envi_root: Path, task_names: List[str]) -> Dict[str, Any]:
tasks: List[Dict[str, Any]] = []
missing: List[str] = []
for task_name in task_names:
raw = _read_task(envi_root, task_name)
if not raw["ok"]:
missing.append(task_name)
tasks.append({"name": task_name, "available": False, "error": raw.get("error")})
continue
payload = raw["payload"] if isinstance(raw["payload"], dict) else {}
parameters = payload.get("parameters") if isinstance(payload.get("parameters"), list) else []
normalized_params = [
_normalize_parameter(item)
for item in parameters
if isinstance(item, dict) and str(item.get("name") or "").strip()
]
tasks.append(
{
"name": str(payload.get("name") or task_name),
"available": True,
"path": raw["path"],
"version": payload.get("version") or payload.get("revision"),
"display_name": payload.get("displayName") or payload.get("display_name"),
"base_class": payload.get("baseClass") or payload.get("base_class"),
"parameter_count": len(normalized_params),
"required_inputs": [
item["name"]
for item in normalized_params
if item.get("required") and item.get("direction") == "input"
],
"outputs": [
item["name"]
for item in normalized_params
if item.get("direction") == "output"
],
"parameters": normalized_params,
"dag": _dag_summary(payload),
}
)
return {
"schema": "insar.sarscape-task-template-extract/v1",
"generated_at_utc": datetime.utcnow().replace(microsecond=0).isoformat() + "Z",
"envi_root": str(envi_root),
"task_count": len(tasks),
"available_count": sum(1 for item in tasks if item.get("available")),
"missing": missing,
"tasks": tasks,
}
def build_template(report: Dict[str, Any]) -> Dict[str, Any]:
by_name = {str(item.get("name") or ""): item for item in report.get("tasks") or []}
wf_sbas = by_name.get("wf_sbas") or {}
stack_tasks = [by_name.get(name) or {"name": name, "available": False} for name in STACK_TASKS]
return {
"schema": "insar.sarscape-sbas-template/v1",
"template_name": "SARscape SBAS native wf_sbas template skeleton",
"sarscape_version_hint": "Extracted from installed ENVI/SARscape .task files",
"validated": False,
"execution_strategy": "native_workflow_metatask",
"source_report_schema": report.get("schema"),
"source_envi_root": report.get("envi_root"),
"native_workflow": {
"phase_id": "native_wf_sbas",
"task_name": "wf_sbas",
"source_task_file": wf_sbas.get("path"),
"parameters": {
"INPUT_FILE_LIST": "${scene_input_uris}",
"SARSCAPE_PREFERENCE": "Use actual preferences",
"DEM_SARSCAPEDATA": "${dem_sarscapedata}",
"OUTPUT_FOLDER": "${output_root}",
"GEOCODE_RG_GRID_SIZE": 10.0,
"ESTIMATE_RESIDUAL_HEIGHT": True,
"DISPLACEMENT_MODEL_TYPE": "linear",
},
"parameter_schema": wf_sbas.get("parameters") or [],
"dag": wf_sbas.get("dag") or [],
},
"tasks": [
{
"phase_id": str(item.get("name") or ""),
"task_name": str(item.get("name") or ""),
"enabled": False,
"source_task_file": item.get("path"),
"required_inputs": item.get("required_inputs") or [],
"outputs": item.get("outputs") or [],
"parameter_schema": item.get("parameters") or [],
"parameters": {},
}
for item in stack_tasks
],
}
def main() -> int:
args = _parse_args()
envi_root = Path(args.envi_root)
task_names = args.task or DEFAULT_TASKS
report = build_report(envi_root, task_names)
payload = build_template(report) if args.template else report
text = json.dumps(payload, ensure_ascii=False, indent=2)
if args.output:
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(text + "\n", encoding="utf-8")
if args.json or args.template or not args.output:
print(text)
return 0 if report.get("available_count") else 1
if __name__ == "__main__":
raise SystemExit(main())
+328
View File
@@ -0,0 +1,328 @@
"""Validate the SARscape SBAS parameter template without executing SBAS.
The validation scope is intentionally limited to the template contract:
- load the checked-in SARscape SBAS template
- inspect the native wf_sbas task parameters through taskengine
- resolve template macros against a selected stack manifest
- verify required inputs, parameter names, basic types, and source paths
It does not call task.execute() and does not run SARscape processing.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List
def _repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Validate SARscape SBAS template parameters without executing SARscape."
)
parser.add_argument(
"--stack-manifest",
required=True,
help="Path to selected_stack_manifest.json.",
)
parser.add_argument(
"--template",
default="",
help="Optional SARscape SBAS template path. Defaults to configured template.",
)
parser.add_argument(
"--output",
default="",
help="Optional output JSON report path.",
)
parser.add_argument(
"--timeout",
type=int,
default=120,
help="Task inspection timeout in seconds.",
)
parser.add_argument(
"--skip-live",
action="store_true",
help="Skip live wf_sbas parameter inspection.",
)
return parser.parse_args()
def _read_json(path: Path) -> Dict[str, Any]:
with path.open("r", encoding="utf-8") as fp:
payload = json.load(fp)
if not isinstance(payload, dict):
raise ValueError(f"JSON root must be an object: {path}")
return payload
def _path_exists(path_text: str) -> bool:
text = str(path_text or "").strip()
return bool(text) and os.path.exists(os.path.normpath(text))
def _dem_exists(dem: Dict[str, Any]) -> Dict[str, Any]:
url = str(dem.get("url") or "").replace("/", os.sep)
aux = [str(item or "").replace("/", os.sep) for item in dem.get("auxiliary_url") or []]
return {
"url": dem.get("url"),
"url_exists": _path_exists(url),
"auxiliary_url": dem.get("auxiliary_url") or [],
"auxiliary_exists": [_path_exists(item) for item in aux],
}
def _scene_path_report(scenes: List[Dict[str, Any]]) -> Dict[str, Any]:
rows = []
for index, scene in enumerate(scenes):
meta_path = str(scene.get("meta_path") or "").strip()
tiff_path = str(scene.get("tiff_path") or "").strip()
folder_path = str(scene.get("folder_path") or "").strip()
rows.append(
{
"index": index,
"imaging_date": scene.get("imaging_date"),
"meta_path": meta_path,
"meta_exists": _path_exists(meta_path),
"tiff_path": tiff_path,
"tiff_exists": _path_exists(tiff_path),
"folder_path": folder_path,
"folder_exists": _path_exists(folder_path),
}
)
return {
"scene_count": len(scenes),
"missing_meta_count": sum(1 for item in rows if not item["meta_exists"]),
"missing_tiff_count": sum(1 for item in rows if not item["tiff_exists"]),
"missing_folder_count": sum(1 for item in rows if not item["folder_exists"]),
"scenes": rows,
}
def _validate_resolved_parameters(
parameters: Dict[str, Any],
live_input_names: List[str],
live_required_inputs: List[str],
live_choice_lists: Dict[str, List[Any]],
) -> List[str]:
issues: List[str] = []
live_input_set = set(live_input_names)
for key in parameters:
if live_input_set and key not in live_input_set:
issues.append(f"Template parameter is not a live wf_sbas input: {key}")
for key in live_required_inputs:
value = parameters.get(key)
if value is None or value == "" or value == []:
issues.append(f"Required live wf_sbas input is missing or empty: {key}")
input_files = parameters.get("INPUT_FILE_LIST")
if not isinstance(input_files, list) or not input_files:
issues.append("INPUT_FILE_LIST must resolve to a non-empty list.")
elif any(not isinstance(item, str) or not item.strip() for item in input_files):
issues.append("INPUT_FILE_LIST contains an empty or non-string item.")
output_folder = parameters.get("OUTPUT_FOLDER")
if output_folder is not None and not isinstance(output_folder, str):
issues.append("OUTPUT_FOLDER must resolve to a string path.")
dem = parameters.get("DEM_SARSCAPEDATA")
if dem is not None:
if not isinstance(dem, dict):
issues.append("DEM_SARSCAPEDATA must resolve to a SARSCAPEDATA object.")
elif dem.get("factory") != "ENVISARscapedata":
issues.append("DEM_SARSCAPEDATA.factory must be ENVISARscapedata.")
if "GEOCODE_RG_GRID_SIZE" in parameters and not isinstance(
parameters.get("GEOCODE_RG_GRID_SIZE"),
(int, float),
):
issues.append("GEOCODE_RG_GRID_SIZE must be numeric.")
if "ESTIMATE_RESIDUAL_HEIGHT" in parameters and not isinstance(
parameters.get("ESTIMATE_RESIDUAL_HEIGHT"),
bool,
):
issues.append("ESTIMATE_RESIDUAL_HEIGHT must be boolean.")
for key, choices in live_choice_lists.items():
if key in parameters and choices and parameters[key] not in choices:
issues.append(f"{key} is not in live choice list: {parameters[key]}")
return issues
def main() -> int:
repo_root = _repo_root()
if str(repo_root) not in sys.path:
sys.path.insert(0, str(repo_root))
from backend.app.config import ensure_project_env_loaded
from backend.app.services import envi_service
from backend.app.services.sarscape_sbas_service import (
NATIVE_WORKFLOW_TASK,
_resolve_template_value,
_scene_input_uris,
default_parameter_template_path,
load_parameter_template,
)
ensure_project_env_loaded()
args = _parse_args()
manifest_path = Path(args.stack_manifest).resolve()
template_path = Path(args.template).resolve() if args.template else Path(default_parameter_template_path()).resolve()
stack_manifest = _read_json(manifest_path)
template_status = load_parameter_template(str(template_path))
template = template_status.get("template") if isinstance(template_status.get("template"), dict) else {}
native_workflow = template.get("native_workflow") if isinstance(template.get("native_workflow"), dict) else {}
task_name = str(native_workflow.get("task_name") or NATIVE_WORKFLOW_TASK).strip()
live_report: Dict[str, Any] = {"skipped": True}
live_task: Dict[str, Any] = {}
if not args.skip_live:
live_report = envi_service.inspect_sarscape_sbas_tasks_subprocess(
[task_name],
include_parameters=True,
timeout_seconds=max(10, int(args.timeout or 120)),
)
live_task = next(
(
item
for item in live_report.get("tasks") or []
if str(item.get("name") or "") == task_name
),
{},
)
scenes = stack_manifest.get("scenes") if isinstance(stack_manifest.get("scenes"), list) else []
work_root = Path(stack_manifest.get("proposed_scratch_windows") or manifest_path.parents[1]).resolve()
output_root = work_root / "sarscape_sbas_template_validation_output"
network_edges_path = output_root / "selected_network_edges.json"
context = {
"${work_root}": str(work_root),
"${output_root}": str(output_root),
"${selected_stack_manifest}": str(manifest_path),
"${selected_network_edges}": str(network_edges_path),
"${scene_meta_paths}": [
str(item.get("meta_path"))
for item in scenes
if str(item.get("meta_path") or "").strip()
],
"${scene_input_uris}": _scene_input_uris(scenes),
"${scene_folder_paths}": [
str(item.get("folder_path"))
for item in scenes
if str(item.get("folder_path") or "").strip()
],
"${selection_params}": stack_manifest.get("selection_params") or {},
"${dem_sarscapedata}": envi_service._build_sarscapedata(envi_service.DEM_BASE_FILE), # noqa: SLF001
}
resolved_parameters = _resolve_template_value(native_workflow.get("parameters") or {}, context)
live_parameters = live_task.get("parameters") if isinstance(live_task.get("parameters"), list) else []
live_choice_lists = {
str(item.get("name")): list(item.get("choice_list") or [])
for item in live_parameters
if isinstance(item, dict) and isinstance(item.get("choice_list"), list)
}
issues: List[str] = []
execution_gate_issues: List[str] = []
for item in template_status.get("errors") or []:
text = str(item)
if text == "Template is not marked validated=true.":
execution_gate_issues.append(text)
else:
issues.append(text)
if not bool(template_status.get("readable")):
issues.append("Template is not readable.")
if not args.skip_live:
if not bool(live_report.get("ok")):
issues.append("Live wf_sbas parameter inspection failed.")
if not bool(live_task.get("available")):
issues.append("Live wf_sbas task is not available to taskengine.")
issues.extend(
_validate_resolved_parameters(
resolved_parameters,
list(live_task.get("input_names") or []),
list(live_task.get("required_input_names") or []),
live_choice_lists,
)
)
scene_report = _scene_path_report(scenes)
if scene_report["missing_meta_count"]:
issues.append("One or more scene meta_path files are missing.")
dem_report = _dem_exists(resolved_parameters.get("DEM_SARSCAPEDATA") or {})
if not dem_report["url_exists"] and not all(dem_report["auxiliary_exists"]):
issues.append("DEM SARSCAPEDATA path or auxiliary files are missing.")
report = {
"schema": "insar.sarscape-sbas-template-validation/v1",
"created_at_utc": datetime.utcnow().replace(microsecond=0).isoformat() + "Z",
"ok": not issues,
"validation_scope": "template_contract_only_no_task_execute",
"issues": issues,
"execution_gate_issues": execution_gate_issues,
"template": {
"path": str(template_path),
"validated_flag": bool(template_status.get("validated")),
"execution_strategy": template_status.get("execution_strategy"),
"native_workflow_task": task_name,
"errors": template_status.get("errors") or [],
},
"live_task": {
"skipped": bool(args.skip_live),
"ok": bool(live_report.get("ok")) if not args.skip_live else None,
"available": bool(live_task.get("available")) if live_task else None,
"parameter_count": live_task.get("parameter_count"),
"input_names": live_task.get("input_names") or [],
"required_input_names": live_task.get("required_input_names") or [],
"output_names": live_task.get("output_names") or [],
"error": live_task.get("error"),
},
"environment": {
"runner_cwd": envi_service.get_envi_runner_cwd(),
"envi_custom_code": envi_service.get_envi_runner_env().get("ENVI_CUSTOM_CODE"),
"dem_base_file": envi_service.DEM_BASE_FILE,
},
"stack_manifest": {
"path": str(manifest_path),
"scene_count": len(scenes),
"network_edge_count": len(stack_manifest.get("network_edges") or []),
"reference_date": stack_manifest.get("reference_date"),
"processor_code": stack_manifest.get("processor_code"),
},
"resolved_parameters": {
"keys": sorted(resolved_parameters.keys()),
"INPUT_FILE_LIST_count": len(resolved_parameters.get("INPUT_FILE_LIST") or []),
"OUTPUT_FOLDER": resolved_parameters.get("OUTPUT_FOLDER"),
"GEOCODE_RG_GRID_SIZE": resolved_parameters.get("GEOCODE_RG_GRID_SIZE"),
"ESTIMATE_RESIDUAL_HEIGHT": resolved_parameters.get("ESTIMATE_RESIDUAL_HEIGHT"),
"DISPLACEMENT_MODEL_TYPE": resolved_parameters.get("DISPLACEMENT_MODEL_TYPE"),
"DEM_SARSCAPEDATA": dem_report,
},
"scene_paths": scene_report,
}
if args.output:
output_path = Path(args.output).resolve()
else:
output_path = repo_root / "backend" / "runtime" / "sarscape_sbas_template_validation_latest.json"
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0 if report["ok"] else 1
if __name__ == "__main__":
raise SystemExit(main())
+100
View File
@@ -0,0 +1,100 @@
"""Inspect likely SARscape SBAS/E-SBAS ENVI task names.
This script does not execute processing tasks. It only asks envipyengine to
instantiate task definitions and read their parameters.
Examples:
python scripts/verify_sarscape_sbas_tasks.py
python scripts/verify_sarscape_sbas_tasks.py --task SARsInSARStackSBASGenerateConnectionGraph --parameters
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
def _repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Inspect SARscape SBAS/E-SBAS ENVI task availability."
)
parser.add_argument(
"--task",
action="append",
default=[],
help="Task name to inspect. May be repeated. Defaults to built-in candidates.",
)
parser.add_argument(
"--json",
action="store_true",
help="Print the full JSON report.",
)
parser.add_argument(
"--parameters",
action="store_true",
help="Also inspect task parameters. This can be slow for some SARscape tasks.",
)
parser.add_argument(
"--timeout",
type=int,
default=120,
help="Timeout in seconds when --parameters is used.",
)
return parser.parse_args()
def main() -> int:
repo_root = _repo_root()
if str(repo_root) not in sys.path:
sys.path.insert(0, str(repo_root))
from backend.app.config import ensure_project_env_loaded
from backend.app.services.envi_service import (
inspect_sarscape_sbas_tasks,
inspect_sarscape_sbas_tasks_subprocess,
)
ensure_project_env_loaded()
args = _parse_args()
if args.parameters:
report = inspect_sarscape_sbas_tasks_subprocess(
args.task or None,
include_parameters=True,
timeout_seconds=max(10, int(args.timeout or 120)),
)
else:
report = inspect_sarscape_sbas_tasks(args.task or None)
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2))
else:
print("SARscape SBAS/E-SBAS task inspection")
print(f"ok: {report.get('ok')}")
print(f"candidate_source: {report.get('candidate_source')}")
print(f"include_parameters: {report.get('include_parameters')}")
print(f"available: {report.get('available_count')} / {report.get('task_count')}")
error = str(report.get("error") or "").strip()
if error:
print(f"error: {error}")
print()
for item in report.get("tasks") or []:
status = "OK" if item.get("available") else "MISS"
print(f"[{status}] {item.get('name')}")
if item.get("available"):
required = item.get("required_input_names") or []
outputs = item.get("output_names") or []
print(f" required inputs: {required}")
print(f" outputs: {outputs}")
else:
print(f" error: {item.get('error')}")
return 0 if report.get("ok") else 1
if __name__ == "__main__":
raise SystemExit(main())