482 lines
18 KiB
Python
482 lines
18 KiB
Python
"""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] <job-directory>", 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())
|