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

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())