zcbot/windows-node/adapters/ansys.mechanical.static_str.../acceptance.py

245 lines
8.6 KiB
Python

"""Run the guarded ANSYS 2024 R2 v2 acceptance suite on a dedicated Windows node."""
from __future__ import annotations
import argparse
import csv
import hashlib
import json
import re
import shutil
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
WORKER = Path(__file__).with_name("worker.py")
BUILTIN_GEOMETRY = Path(__file__).with_name("acceptance-coupon.step")
PROCESS_NAMES = {"ansys.exe", "ansyswbu.exe", "mechanical.exe", "mapdl.exe"}
def _request(geometry_id: str) -> dict:
return {
"schema_version": 2,
"inputs": [{"key": "geometry", "artifact_id": geometry_id}],
"operation": {
"analysis": {
"type": "static_structural",
"title": "PyMechanical 2024 R2 acceptance coupon",
"unit_system": "mm_kg_s",
"geometry": {"input": "geometry"},
"material": "structural_steel",
"mesh": {"global_size": 2.0},
"boundary_conditions": [
{
"type": "fixed_support",
"name": "fixed_face",
"scope": {
"type": "planar_faces",
"normal": [0, 0, 1],
"offset": -1.5,
"tolerance": 0.001,
"expected_count": 1,
},
}
],
"loads": [
{
"type": "force",
"name": "load_face",
"scope": {
"type": "planar_faces",
"normal": [0, 0, 1],
"offset": 0.425,
"tolerance": 0.001,
"expected_count": 1,
},
"components": [0, 0, -1000],
}
],
"results": [
"total_deformation",
"equivalent_stress",
"reaction_force",
],
}
},
"outputs": [
{"key": "project", "format": "mechdat"},
{"key": "stress_image", "format": "png"},
{"key": "deformation_image", "format": "png"},
{"key": "solver_log", "format": "txt"},
],
}
def _stage(root: Path, geometry: Path, label: str) -> Path:
job_dir = root / label
request_dir = job_dir / "request"
input_dir = job_dir / "input" / "geometry"
request_dir.mkdir(parents=True)
input_dir.mkdir(parents=True)
shutil.copyfile(geometry, input_dir / geometry.name)
request = _request(str(uuid4()))
normalized = json.dumps(request, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
record = {
"job_id": str(uuid4()),
"lease_id": str(uuid4()),
"request_digest": hashlib.sha256(normalized.encode("utf-8")).hexdigest(),
"request": request,
}
(request_dir / "request.json").write_text(
json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8"
)
return job_dir
def _mechanical_processes() -> dict[int, str]:
completed = subprocess.run(
["tasklist.exe", "/fo", "csv", "/nh"],
check=True,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
processes: dict[int, str] = {}
for row in csv.reader(completed.stdout.splitlines()):
name = row[0].casefold() if row else ""
if len(row) >= 2 and (name in PROCESS_NAMES or re.fullmatch(r"ansys\d{3}\.exe", name)):
processes[int(row[1])] = row[0]
return processes
def _wait_for_release(baseline: dict[int, str], timeout_seconds: int) -> dict[int, str]:
deadline = time.monotonic() + timeout_seconds
while True:
remaining = {
pid: name for pid, name in _mechanical_processes().items() if pid not in baseline
}
if not remaining:
return {}
if time.monotonic() >= deadline:
return remaining
time.sleep(2)
def _run_success(job_dir: Path, baseline: dict[int, str], release_wait: int) -> dict:
started = time.monotonic()
completed = subprocess.run(
[sys.executable, str(WORKER), "--acceptance", str(job_dir)],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
elapsed = time.monotonic() - started
terminal = json.loads((job_dir / "terminal.json").read_text(encoding="utf-8"))
remaining = _wait_for_release(baseline, release_wait)
if completed.returncode != 0 or terminal["status"] != "succeeded":
raise RuntimeError(f"BENCHMARK_FAILED:{completed.stderr[-500:]}")
if remaining:
raise RuntimeError(f"MECHANICAL_PROCESS_REMAINS:{remaining}")
expected = {
"project",
"summary",
"result_table",
"selection_preview",
"stress_image",
"deformation_image",
"solver_log",
"analysis_spec",
"provenance",
}
actual = {item["artifact_id"] for item in terminal["artifact_manifest"]}
if actual != expected:
raise RuntimeError(f"ARTIFACT_MANIFEST_MISMATCH:{sorted(actual)}")
return {"elapsed_seconds": elapsed, "terminal": terminal}
def _run_cancellation(
job_dir: Path, baseline: dict[int, str], cancel_after: int, release_wait: int
) -> dict:
process = subprocess.Popen(
[sys.executable, str(WORKER), "--acceptance", str(job_dir)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
)
time.sleep(cancel_after)
if process.poll() is not None:
raise RuntimeError("CANCELLATION_JOB_FINISHED_BEFORE_CANCEL")
killed = subprocess.run(
["taskkill.exe", "/pid", str(process.pid), "/t", "/f"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
process.wait(timeout=30)
remaining = _wait_for_release(baseline, release_wait)
if killed.returncode != 0:
raise RuntimeError(f"PROCESS_TREE_CANCEL_FAILED:{killed.stderr[-500:]}")
if remaining:
raise RuntimeError(f"CANCELLED_PROCESS_REMAINS:{remaining}")
return {"worker_pid": process.pid, "exit_code": process.returncode}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("geometry", type=Path, nargs="?", default=BUILTIN_GEOMETRY)
parser.add_argument("--work-root", type=Path, required=True)
parser.add_argument("--repeat", type=int, default=20)
parser.add_argument("--cancel-after", type=int, default=10)
parser.add_argument("--release-wait", type=int, default=60)
args = parser.parse_args()
if sys.platform != "win32":
raise RuntimeError("Acceptance requires Windows")
geometry = args.geometry.resolve(strict=True)
if geometry.suffix.casefold() not in {".step", ".stp", ".x_t", ".x_b", ".iges", ".igs"}:
raise ValueError("Unsupported benchmark geometry")
if args.repeat < 1:
raise ValueError("repeat must be positive")
root = args.work_root.resolve()
root.mkdir(parents=True, exist_ok=False)
baseline = _mechanical_processes()
report = {
"started_at": datetime.now(timezone.utc).isoformat(),
"geometry": str(geometry),
"baseline_processes": baseline,
"runs": [],
}
report_path = root / "acceptance-report.json"
try:
cancel_job = _stage(root, geometry, "cancel")
report["cancellation"] = _run_cancellation(
cancel_job, baseline, args.cancel_after, args.release_wait
)
print("[OK] Process-tree cancellation completed and released.")
for index in range(1, args.repeat + 1):
job_dir = _stage(root, geometry, f"run-{index:02d}")
report["runs"].append(_run_success(job_dir, baseline, args.release_wait))
print(f"[OK] Acceptance solve {index}/{args.repeat} completed and released.")
report["passed"] = True
except Exception as exc:
report["passed"] = False
report["failure"] = {"type": type(exc).__name__, "detail": str(exc)[:1000]}
raise
finally:
report["completed_at"] = datetime.now(timezone.utc).isoformat()
report_path.write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(f"[OK] Acceptance passed. Report: {report_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())