396 lines
15 KiB
Python
396 lines
15 KiB
Python
"""ANSYS Mechanical 2024 R2 declarative static-structural adapter v2."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import json
|
|
import math
|
|
import os
|
|
import shutil
|
|
import sys
|
|
from importlib.metadata import version
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from ansys_geometry import ( # noqa: E402
|
|
ANSYS_REVISION,
|
|
EXECUTION_GATE,
|
|
artifact_manifest,
|
|
atomic_json,
|
|
export_geometry_image,
|
|
file_sha256,
|
|
geometry_inventory,
|
|
import_geometry,
|
|
input_file,
|
|
probe,
|
|
resolve_scope,
|
|
selected_face_records,
|
|
terminal,
|
|
validate_artifact,
|
|
)
|
|
|
|
ADAPTER_VERSION = "0.3.0"
|
|
_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"),
|
|
"selection-preview.png": ("selection_preview", "image/png"),
|
|
"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 _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")
|
|
|
|
conditions = analysis["boundary_conditions"] + analysis["loads"]
|
|
names = [item["name"] for item in conditions]
|
|
if len({item.casefold() for item in names}) != len(names):
|
|
raise ValueError("CONDITION_NAMES_MUST_BE_UNIQUE")
|
|
|
|
for item in conditions:
|
|
scope = item["scope"]
|
|
if scope["type"] == "planar_faces":
|
|
normal = scope["normal"]
|
|
if not any(value != 0 for value in normal):
|
|
raise ValueError("PLANE_NORMAL_MUST_BE_NONZERO")
|
|
|
|
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 _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 _export_result_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) -> 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"]}
|
|
|
|
from ansys.mechanical.core import App
|
|
|
|
result_rows: list[dict[str, Any]] = []
|
|
result_summary: dict[str, list[dict[str, Any]]] = {}
|
|
resolved_summary: list[dict[str, Any]] = []
|
|
software_version: int | None = None
|
|
solution_status = "unknown"
|
|
solver_log_path = output / "solver-output.txt"
|
|
selection_preview_path = output / "selection-preview.png"
|
|
geometry_summary: dict[str, Any] = {}
|
|
with App(version=ANSYS_REVISION, private_appdata=True) as app:
|
|
software_version = app.version
|
|
api: dict[str, Any] = {}
|
|
app.update_globals(api)
|
|
unit_enum, length_unit = _UNIT_SYSTEMS[analysis_spec["unit_system"]]
|
|
app.ExtAPI.Application.ActiveUnitSystem = getattr(
|
|
api["MechanicalUnitSystem"], unit_enum
|
|
)
|
|
model = import_geometry(app, api, geometry)
|
|
inventory, face_entities = geometry_inventory(app)
|
|
geometry_summary = {
|
|
"geometry_unit": inventory["geometry_unit"],
|
|
"counts": inventory["counts"],
|
|
"bounding_box": inventory["bounding_box"],
|
|
}
|
|
|
|
resolved: dict[str, dict[str, Any]] = {}
|
|
all_entity_ids: list[int] = []
|
|
conditions = analysis_spec["boundary_conditions"] + analysis_spec["loads"]
|
|
for item in conditions:
|
|
value = resolve_scope(
|
|
app,
|
|
api,
|
|
inventory,
|
|
face_entities,
|
|
item["scope"],
|
|
length_unit,
|
|
item["name"],
|
|
)
|
|
resolved[item["name"]] = value
|
|
all_entity_ids.extend(value["entity_ids"])
|
|
resolved_summary.append(
|
|
{
|
|
"condition": item["name"],
|
|
"source": value["source"],
|
|
"mechanical_name": value["name"],
|
|
"scope": item["scope"],
|
|
"entity_ids": value["entity_ids"],
|
|
"faces": selected_face_records(inventory, value["entity_ids"]),
|
|
}
|
|
)
|
|
|
|
preview_selection = app.ExtAPI.SelectionManager.CreateSelectionInfo(
|
|
api["SelectionTypeEnum"].GeometryEntities
|
|
)
|
|
preview_selection.Ids = sorted(set(all_entity_ids))
|
|
export_geometry_image(app, api, selection_preview_path, preview_selection)
|
|
|
|
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.Name = item["name"]
|
|
support.Location = resolved[item["name"]]["location"]
|
|
fixed_supports.append((item["name"], support))
|
|
|
|
for item in analysis_spec["loads"]:
|
|
if item["type"] == "force":
|
|
load = analysis.AddForce()
|
|
load.Name = item["name"]
|
|
load.Location = resolved[item["name"]]["location"]
|
|
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.Name = item["name"]
|
|
load.Location = resolved[item["name"]]["location"]
|
|
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 condition_name, support in fixed_supports:
|
|
reaction = solution.AddForceReaction()
|
|
reaction.BoundaryConditionSelection = support
|
|
reactions.append((condition_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_result_image(
|
|
app,
|
|
api,
|
|
results["equivalent_stress"][0][1],
|
|
output / "equivalent-stress.png",
|
|
)
|
|
if "deformation_image" in requested_outputs:
|
|
_export_result_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_path = output / "summary.json"
|
|
atomic_json(
|
|
summary_path,
|
|
{
|
|
"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"],
|
|
"geometry": geometry_summary,
|
|
"resolved_scopes": resolved_summary,
|
|
"results": result_summary,
|
|
"license_received_message": (
|
|
"requested license was received" in solver_text.casefold()
|
|
),
|
|
},
|
|
)
|
|
analysis_spec_path = output / "analysis-spec.json"
|
|
provenance_path = output / "provenance.json"
|
|
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),
|
|
},
|
|
"resolved_scopes": resolved_summary,
|
|
"outputs": request["outputs"],
|
|
},
|
|
)
|
|
|
|
paths = [
|
|
summary_path,
|
|
result_table_path,
|
|
selection_preview_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 [artifact_manifest(path, _ARTIFACTS) for path in paths]
|
|
|
|
|
|
def main() -> int:
|
|
if sys.argv[1:] == ["--probe"]:
|
|
return probe(ADAPTER_VERSION)
|
|
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)
|
|
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())
|