Files
insar-management-system-v2/scripts/validate_sarscape_sbas_template.py
T

329 lines
13 KiB
Python

"""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())