"""ANSYS Mechanical 2024 R2 static-structural adapter. The worker consumes only the Node-created declarative job directory. It never accepts Mechanical scripts, APDL, journals, URLs, executables, or local paths from the request. Scheduled execution is guarded by a machine-level acceptance gate; ``--acceptance`` is the explicit local-only entry point used before that gate is enabled on a dedicated Windows node. """ from __future__ import annotations import csv import hashlib import json import math import os import shutil import sys from datetime import datetime, timezone from importlib.metadata import PackageNotFoundError, version from pathlib import Path from typing import Any ADAPTER_VERSION = "0.2.0" ANSYS_REVISION = 242 ANSYS_RELEASE = "2024 R2" EXECUTION_GATE = "ZCBOT_ANSYS_242_VALIDATED" ACCEPTANCE_FIXTURE = "acceptance-coupon.step" INSTALL_ROOT = Path(os.environ.get("AWP_ROOT242", r"C:\Program Files\ANSYS Inc\v242")) _UNIT_SYSTEMS = { "m_kg_s": ("StandardMKS", "m"), "mm_kg_s": ("StandardNMM", "mm"), } _ARTIFACTS = { "project.mechdat": ("project", "application/octet-stream"), "summary.json": ("summary", "application/json"), "result-table.csv": ("result_table", "text/csv"), "equivalent-stress.png": ("stress_image", "image/png"), "total-deformation.png": ("deformation_image", "image/png"), "solver-output.txt": ("solver_log", "text/plain"), "analysis-spec.json": ("analysis_spec", "application/json"), "provenance.json": ("provenance", "application/json"), } def _probe() -> int: health = "unavailable" software_version = None details: list[str] = [] try: if sys.platform != "win32": raise RuntimeError("ANSYS adapter requires Windows") if not INSTALL_ROOT.is_dir(): raise RuntimeError("ANSYS Mechanical 2024 R2 installation was not found") from ansys.mechanical.core import App # noqa: F401 package_version = version("ansys-mechanical-core") software_version = ANSYS_RELEASE details.append(f"PyMechanical {package_version} and v242 installation detected") if os.environ.get(EXECUTION_GATE) != "1": details.append("real-license acceptance is pending") else: health = "ready" details.append("real-license acceptance gate is enabled") except ( FileNotFoundError, ImportError, OSError, PackageNotFoundError, RuntimeError, ) as exc: details = [str(exc)] print( json.dumps( { "adapter_version": ADAPTER_VERSION, "software": "ANSYS Mechanical", "software_version": software_version, "health": health, "detail": "; ".join(details), }, ensure_ascii=False, ) ) return 0 def _validate_semantics(request: dict[str, Any]) -> None: analysis = request["operation"]["analysis"] input_keys = {item["key"] for item in request["inputs"]} if analysis["geometry"]["input"] not in input_keys: raise ValueError("GEOMETRY_INPUT_NOT_BOUND") selections = [ item["named_selection"] for item in analysis["boundary_conditions"] + analysis["loads"] ] if len({item.casefold() for item in selections}) != len(selections): raise ValueError("NAMED_SELECTION_TARGETS_MUST_BE_UNIQUE") for load in analysis["loads"]: values = load.get("components", [load.get("magnitude")]) if not all(isinstance(item, (int, float)) and math.isfinite(item) for item in values): raise ValueError("LOAD_VALUES_MUST_BE_FINITE") if load["type"] == "force" and not any(item != 0 for item in values): raise ValueError("FORCE_VECTOR_MUST_BE_NONZERO") if load["type"] == "pressure" and load["magnitude"] == 0: raise ValueError("PRESSURE_MUST_BE_NONZERO") output_keys = [item["key"] for item in request["outputs"]] if len(set(output_keys)) != len(output_keys): raise ValueError("OUTPUT_KEYS_MUST_BE_UNIQUE") def _load_request(job_dir: Path) -> dict[str, Any]: record = json.loads( (job_dir / "request" / "request.json").read_text(encoding="utf-8") ) request = record.get("request") if not isinstance(request, dict): raise TypeError("JOB_REQUEST_MISSING") _validate_semantics(request) return record def _atomic_json(path: Path, value: Any) -> None: temporary = path.with_name(path.name + ".tmp-" + os.urandom(8).hex()) try: with temporary.open("w", encoding="utf-8", newline="\n") as handle: json.dump(value, handle, ensure_ascii=False, indent=2) handle.flush() os.fsync(handle.fileno()) os.replace(temporary, path) finally: temporary.unlink(missing_ok=True) def _file_sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def _input_file(job_dir: Path, key: str) -> Path: directory = job_dir / "input" / key files = [ path for path in directory.iterdir() if path.is_file() and not path.name.startswith(".") ] if len(files) != 1: raise ValueError(f"INPUT_FILE_COUNT_INVALID:{key}") return files[0] def _manifest(path: Path) -> dict[str, Any]: artifact_id, media_type = _ARTIFACTS[path.name] return { "artifact_id": artifact_id, "filename": path.name, "media_type": media_type, "size_bytes": path.stat().st_size, "sha256": _file_sha256(path), } def _validate_artifact(path: Path) -> None: if not path.is_file() or path.stat().st_size == 0: raise RuntimeError(f"OUTPUT_EXPORT_EMPTY:{path.name}") if path.suffix.lower() == ".png" and not path.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n": raise RuntimeError(f"PNG_EXPORT_INVALID:{path.name}") if path.suffix.lower() == ".mechdat" and path.stat().st_size < 64: raise RuntimeError("MECHDAT_EXPORT_INVALID") def _quantity(value: Any) -> dict[str, Any]: return {"value": float(value.Value), "unit": str(value.Unit)} def _result_rows(result_key: str, scope: str, result: Any) -> list[dict[str, Any]]: if result_key == "reaction_force": names = ("x", "y", "z", "total") values = (result.XAxis, result.YAxis, result.ZAxis, result.Total) else: names = ("minimum", "maximum") values = (result.Minimum, result.Maximum) return [ {"result": result_key, "scope": scope, "metric": name, **_quantity(value)} for name, value in zip(names, values, strict=True) ] def _write_result_table(path: Path, rows: list[dict[str, Any]]) -> None: with path.open("w", encoding="utf-8-sig", newline="") as handle: writer = csv.DictWriter(handle, fieldnames=["result", "scope", "metric", "value", "unit"]) writer.writeheader() writer.writerows(rows) def _named_selections(model: Any, required: list[str]) -> dict[str, Any]: available: dict[str, Any] = {} duplicates: set[str] = set() for item in model.NamedSelections.Children: key = str(item.Name).casefold() if key in available: duplicates.add(key) available[key] = item if duplicates: raise RuntimeError("IMPORTED_NAMED_SELECTIONS_AMBIGUOUS") missing = [name for name in required if name.casefold() not in available] if missing: raise RuntimeError("NAMED_SELECTION_NOT_FOUND:" + ",".join(missing)) return {name: available[name.casefold()] for name in required} def _create_acceptance_named_selections(model: Any, api: dict[str, Any]) -> None: """Scope the two planar faces of the fixed bundled NIST validation coupon.""" for name, location_z in (("fixed_face", -1.5), ("load_face", 0.425)): selection = model.AddNamedSelection() selection.Name = name selection.ScopingMethod = api["GeometryDefineByType"].Worksheet criterion = api[ "Ansys" ].ACT.Automation.Mechanical.NamedSelectionCriterion() criterion.Active = True criterion.Action = api["SelectionActionType"].Add criterion.EntityType = api["SelectionType"].GeoFace criterion.Criterion = api["SelectionCriterionType"].LocationZ criterion.Operator = api["SelectionOperatorType"].Equal criterion.Value = api["Quantity"](location_z, "mm") selection.GenerationCriteria.Add(criterion) selection.Generate() def _export_image(app: Any, api: dict[str, Any], result: Any, path: Path) -> None: app.Tree.Activate([result]) app.Graphics.Camera.SetFit() settings = api["Ansys"].Mechanical.Graphics.GraphicsImageExportSettings() settings.Resolution = api["GraphicsResolutionType"].EnhancedResolution settings.Width = 1600 settings.Height = 1200 app.Graphics.ExportImage( str(path), api["GraphicsImageExportFormat"].PNG, settings ) _validate_artifact(path) def _copy_solver_log(analysis: Any, target: Path) -> None: source = Path(str(analysis.WorkingDir)) / "solve.out" if not source.is_file() or source.stat().st_size == 0: raise RuntimeError("SOLVER_OUTPUT_NOT_FOUND") shutil.copyfile(source, target) def run(job_dir: Path, *, acceptance: bool = False) -> list[dict[str, Any]]: job_dir = job_dir.resolve(strict=True) record = _load_request(job_dir) request = record["request"] analysis_spec = request["operation"]["analysis"] geometry = _input_file(job_dir, analysis_spec["geometry"]["input"]) output = job_dir / "output" output.mkdir(exist_ok=True) requested_outputs = {item["key"] for item in request["outputs"]} required_names = [ item["named_selection"] for item in analysis_spec["boundary_conditions"] + analysis_spec["loads"] ] from ansys.mechanical.core import App result_rows: list[dict[str, Any]] = [] result_summary: dict[str, list[dict[str, Any]]] = {} software_version: int | None = None solution_status = "unknown" solver_log_path = output / "solver-output.txt" with App(version=ANSYS_REVISION, private_appdata=True) as app: software_version = app.version api: dict[str, Any] = {} app.update_globals(api) model = app.Model unit_enum, length_unit = _UNIT_SYSTEMS[analysis_spec["unit_system"]] app.ExtAPI.Application.ActiveUnitSystem = getattr( api["MechanicalUnitSystem"], unit_enum ) geometry_import = model.GeometryImportGroup.AddGeometryImport() preferences = api["Ansys"].ACT.Mechanical.Utilities.GeometryImportPreferences() preferences.ProcessNamedSelections = True preferences.NamedSelectionKey = "" preferences.ProcessMaterialProperties = False preferences.ProcessCoordinateSystems = False geometry_import.Import( str(geometry), api["Ansys"].Mechanical.DataModel.Enums.GeometryImportPreference.Format.Automatic, preferences, ) if acceptance and geometry.name.casefold() == ACCEPTANCE_FIXTURE: _create_acceptance_named_selections(model, api) selections = _named_selections(model, required_names) bodies = list(model.GetChildren(api["DataModelObjectCategory"].Body, True)) if not bodies: raise RuntimeError("IMPORTED_GEOMETRY_HAS_NO_BODIES") for body in bodies: body.Material = "Structural Steel" model.Mesh.ElementSize = api["Quantity"]( analysis_spec["mesh"]["global_size"], length_unit ) analysis = model.AddStaticStructuralAnalysis() fixed_supports: list[tuple[str, Any]] = [] for item in analysis_spec["boundary_conditions"]: support = analysis.AddFixedSupport() support.Location = selections[item["named_selection"]] fixed_supports.append((item["named_selection"], support)) for item in analysis_spec["loads"]: if item["type"] == "force": load = analysis.AddForce() load.Location = selections[item["named_selection"]] load.DefineBy = api["LoadDefineBy"].Components for field_name, value in zip( ("XComponent", "YComponent", "ZComponent"), item["components"], strict=True, ): getattr(load, field_name).Output.DiscreteValues = [ api["Quantity"](value, "N") ] else: load = analysis.AddPressure() load.Location = selections[item["named_selection"]] load.Magnitude.Output.DiscreteValues = [ api["Quantity"](item["magnitude"], "Pa") ] solution = analysis.Solution requested_results = set(analysis_spec["results"]) if "deformation_image" in requested_outputs: requested_results.add("total_deformation") if "stress_image" in requested_outputs: requested_results.add("equivalent_stress") results: dict[str, list[tuple[str, Any]]] = {} if "total_deformation" in requested_results: results["total_deformation"] = [("all", solution.AddTotalDeformation())] if "equivalent_stress" in requested_results: results["equivalent_stress"] = [("all", solution.AddEquivalentStress())] if "reaction_force" in requested_results: reactions = [] for selection_name, support in fixed_supports: reaction = solution.AddForceReaction() reaction.BoundaryConditionSelection = support reactions.append((selection_name, reaction)) results["reaction_force"] = reactions solution.Solve(True) solution_status = str(solution.Status) if "done" not in solution_status.casefold(): raise RuntimeError(f"MECHANICAL_SOLUTION_NOT_DONE:{solution_status}") for key, scoped_results in results.items(): rows = [ row for scope, result in scoped_results for row in _result_rows(key, scope, result) ] result_rows.extend(rows) result_summary[key] = rows _copy_solver_log(analysis, solver_log_path) if "stress_image" in requested_outputs: _export_image( app, api, results["equivalent_stress"][0][1], output / "equivalent-stress.png" ) if "deformation_image" in requested_outputs: _export_image( app, api, results["total_deformation"][0][1], output / "total-deformation.png" ) if "project" in requested_outputs: app.save_as(str(output / "project.mechdat")) result_table_path = output / "result-table.csv" _write_result_table(result_table_path, result_rows) solver_text = solver_log_path.read_text(encoding="utf-8", errors="replace") summary = { "title": analysis_spec["title"], "analysis": "static_structural", "software": "ANSYS Mechanical", "software_version": software_version, "solution_status": solution_status, "unit_system": analysis_spec["unit_system"], "material": "Structural Steel", "mesh": analysis_spec["mesh"], "named_selections": required_names, "results": result_summary, "license_received_message": "requested license was received" in solver_text.casefold(), } summary_path = output / "summary.json" analysis_spec_path = output / "analysis-spec.json" provenance_path = output / "provenance.json" _atomic_json(summary_path, summary) _atomic_json(analysis_spec_path, request) _atomic_json( provenance_path, { "adapter_version": ADAPTER_VERSION, "pymechanical_version": version("ansys-mechanical-core"), "ansys_revision": ANSYS_REVISION, "request_digest": record["request_digest"], "input": { "key": analysis_spec["geometry"]["input"], "filename": geometry.name, "sha256": _file_sha256(geometry), }, "outputs": request["outputs"], }, ) paths = [summary_path, result_table_path, analysis_spec_path, provenance_path] if "project" in requested_outputs: paths.append(output / "project.mechdat") if "stress_image" in requested_outputs: paths.append(output / "equivalent-stress.png") if "deformation_image" in requested_outputs: paths.append(output / "total-deformation.png") if "solver_log" in requested_outputs: paths.append(solver_log_path) else: solver_log_path.unlink(missing_ok=True) for path in paths: _validate_artifact(path) return [_manifest(path) for path in paths] def _terminal(record: dict[str, Any], status: str, artifacts: list[dict[str, Any]], error: dict[str, str]) -> dict[str, Any]: return { "job_id": record.get("job_id", ""), "lease_id": record.get("lease_id", ""), "request_digest": record.get("request_digest", ""), "status": status, "error": error, "artifact_manifest": artifacts, "terminal_at": datetime.now(timezone.utc).isoformat(), } def main() -> int: if sys.argv[1:] == ["--probe"]: return _probe() acceptance = len(sys.argv) == 3 and sys.argv[1] == "--acceptance" if not acceptance and len(sys.argv) != 2: print("[ERR] Usage: worker.py [--acceptance] ", file=sys.stderr) return 2 job_dir = Path(sys.argv[-1]) record: dict[str, Any] = {} try: record = _load_request(job_dir) if not acceptance and os.environ.get(EXECUTION_GATE) != "1": raise RuntimeError("ANSYS_242_REAL_LICENSE_ACCEPTANCE_REQUIRED") artifacts = run(job_dir, acceptance=acceptance) _atomic_json(job_dir / "artifacts.json", artifacts) _atomic_json(job_dir / "terminal.json", _terminal(record, "succeeded", artifacts, {})) print("[OK] ANSYS Mechanical job completed.") return 0 except Exception as exc: # noqa: BLE001 - terminal.json must capture vendor failures _atomic_json( job_dir / "terminal.json", _terminal( record, "failed", [], {"code": type(exc).__name__, "detail": str(exc)[:500]}, ), ) print(f"[ERR] {type(exc).__name__}: {exc}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())