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