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

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