feat(ansys): add geometry inspection and scoped static analysis
This commit is contained in:
parent
172e0dbdaf
commit
ede573054b
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- ANSYS 新增几何检查能力,可在求解前返回实体、面、尺寸、法向、面积、已有选择集和预览;静力分析现在可按坐标轴端面或指定平面自动建立固定与加载区域,并在数量不符时停止,结果中同时保留实际选区及预览。
|
||||
|
||||
- Origin 专业软件任务完成后会把轻量预览图回传到当前任务文件夹,并在图片下方显示可预览、下载的文件入口;预览图仍不会自动登记为正式产物,只有明确导出时才获得稳定产物身份。
|
||||
|
||||
- Windows Node 的每个专业软件 adapter 现在自带唯一运行时合同,不再受安装目录遗留共享合同影响;诊断信息会显示实际合同路径、摘要和 Workspace 协议状态,服务端也会阻止协议不匹配的任务反复派发。
|
||||
|
|
@ -16,7 +18,7 @@
|
|||
|
||||
- 专业软件任务失败或取消后会在原对话留下持久通知,页面离线、刷新或对话暂时忙碌时也不会错过结果。
|
||||
|
||||
- 查询专业软件是否可用时不再误提交真实任务;ANSYS 静力分析会要求使用几何中已有的 Named Selection,并在输入缺少命名选择时返回明确错误。
|
||||
- 查询专业软件是否可用时不再误提交真实任务;ANSYS 静力分析的旧版命名选择入口已由几何检查和声明式选区入口替换。
|
||||
|
||||
- Origin 专业软件任务现在会在原 Windows Node 上保留可继续加工的工程;后续调整直接打开上一版工程,过程只提供轻量预览,工程和其他大文件仅在明确要求下载或交付时才传回并登记为正式产物。
|
||||
|
||||
|
|
|
|||
|
|
@ -490,7 +490,9 @@ Workspace capability 的默认完成协议不发布正式产物。Node 只自动
|
|||
|
||||
Job 完成后的对话闭环复用 `software_jobs` 账本,不增加通用事件表。提交时用 `completion_action=report|analyze` 区分“只报告产物”和“报告并分析”,未明确分析时默认 `report`;成功终态将 `followup_status` 置为 `pending`。分发器只在原 task 空闲时领取:`report` 直接写入带 artifact refs 的固定 assistant 消息,不产生模型费用;`analyze` 写入前端隐藏的内部完成事件,并复用 task 单活锁、持久消息和 SSE run 自动续跑 Agent。`pending/running/completed/failed` 状态使服务重启、对话繁忙和重复完成回执均不会造成重复回复;Job 中心的手动深入分析通过同一入口重新排入分析,而不是模拟点击或只预填输入框。
|
||||
|
||||
ANSYS 第二能力新增 `ansys.mechanical.static_structural@v1`,目标固定为 Windows 上的 Mechanical 2024 R2(revision 242)。契约只接受一份已登记 STEP/Parasolid/IGES 几何、显式单位、内置 Structural Steel、全局网格尺寸、Named Selection 固定支撑、力/压力和三类结果,不接受 Mechanical 工程、Python、APDL、Journal、URL 或本机路径;输出命名固定为 MECHDAT、汇总/结果表、应力/变形图、solver log 和 `.meta` 溯源。adapter 0.2.0 使用独立 `runtimes/ansys` Python 3.12 环境和 PyMechanical 0.11.3 的嵌入式 `App(version=242)`,由固定 Worker 建模、同步求解、读取结果对象并在上下文退出时释放 Mechanical;Node 以 240 分钟超时包围 Worker,取消时终止并等待完整进程树退出。2024 R2 没有新版 PyMechanical 的进程内许可证管理,周期 probe 只做 v242 安装和绑定探测;门外的固定验收入口只供专用节点本地验收,不会被 Node 调度调用。Node UI 可复制不含 Token 的诊断,并使用安装包内固定 NIST STEP,在导入后按已知端面位置自动生成 `fixed_face/load_face`、运行取消与连续 20 次求解;只有本次验收报告通过且操作员确认许可证管理端释放,才允许写入机器级执行门。adapter 会被节点自动发现和上报,但在专用节点完成连续算例、取消、产物、残留进程和许可证释放检查前,由机器级验收门保持 `health=unavailable`,不会进入实际调度。
|
||||
ANSYS 能力固定面向 Windows 上的 Mechanical 2024 R2(revision 242),并以共享 GeoData 层拆成检查和求解两个声明式入口。`ansys.geometry.inspect@v1` 只导入一份已登记 STEP/Parasolid/IGES,枚举 assembly/part/body/face、几何单位、包围盒、面质心/法向/面积、已有 Named Selection,并输出 JSON manifest、整体预览和溯源;它不划分网格或求解。`ansys.mechanical.static_structural@v2` 接受显式单位、内置 Structural Steel、全局网格、固定支撑、力/压力和三类结果,边界区域可引用导入的 Named Selection,也可用全局轴极值平面或带法向/偏置/容差的平面查询;查询只在 Mechanical 导入后的真实 GeoData 上解析,按实体 ID 创建项目内 Named Selection,可用 `expected_count` 硬断言选择数量,并把最终面、来源和选区预览写入结果。两者都不接受 Mechanical 工程、Python、APDL、Journal、URL 或本机路径;静力输出固定为 MECHDAT、汇总/结果表、选区及应力/变形图、solver log 和 `.meta` 溯源。
|
||||
|
||||
本轮按用户明确授权以 `static_structural@v2` 直接替换 v1,不保留旧 capability 调度双轨;历史 Job 账本仍保留,但旧请求不能在新节点重新派发。adapter 0.3.0 继续使用独立 `runtimes/ansys` Python 3.12 环境和 PyMechanical 0.11.3 的嵌入式 `App(version=242)`,由固定 Worker 导入、选择、建模、同步求解、读取结果并在上下文退出时释放 Mechanical;Node 以 240 分钟超时包围求解 Worker,取消时终止并等待完整进程树退出。2024 R2 没有新版 PyMechanical 的进程内许可证管理,周期 probe 只做 v242 安装和绑定探测;门外的固定验收入口只供专用节点本地验收,不会被 Node 调度调用。Node UI 使用安装包内固定 NIST STEP,以与生产相同的 v2 平面查询建立 `fixed_face/load_face`,运行取消与连续 20 次求解;只有验收报告通过且操作员确认许可证释放,才允许写入机器级执行门。检查与求解 adapter 都受同一门控制,在专用节点完成连续算例、取消、产物、残留进程和许可证释放检查前保持 `health=unavailable`。
|
||||
|
||||
后续仍需实现 Token 轮换;不得以任意命令或脚本接口临时代替。当前 Job 中心采用轮询而非用户事件推送,单活与 offer 选择仍只覆盖单 Web 进程;生产启用多实例前必须增加 Redis/PG fencing 或固定路由到单一控制面实例。
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@
|
|||
---
|
||||
## 已完成关键能力
|
||||
|
||||
- **08-20 / Unreleased / ANSYS 几何检查 + 静力选区 v2**:新增 `ansys.geometry.inspect@v1`,由 Mechanical 真实导入后输出 assembly/body/face、几何单位、包围盒、面质心/法向/面积、已有 Named Selection、预览和溯源;`ansys.mechanical.static_structural@v2` 以已有命名选择、全局轴极值面或平面法向/偏置/容差解析边界区域,支持 `expected_count` 硬断言,按实体 ID 创建项目内选择集,并把最终面和选区预览纳入结果。按明确授权直接移除 v1 调度能力;90 项 ANSYS/合同/Job/Node 专项 unittest、.NET build 与发布目录检查通过;全量 684 项仍仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过(另跳过 4 项),未连接 ANSYS 或生产数据库,v2 真实许可证、选区和连续释放验收仍须在目标节点重跑。
|
||||
|
||||
- **08-19 / Unreleased / Windows Node adapter 合同单入口**:仓库根合同继续作为源码事实源,完整 Node 包与独立 adapter 包均在发布时把合同放入对应 adapter 目录;Node 移除共享 `software-contracts` fallback,心跳与诊断新增实际合同摘要和 Workspace 协议状态,服务端只向明确支持协议的节点派发 Workspace Job,避免遗留合同覆盖与确定性拒绝循环。
|
||||
|
||||
- **08-19 / Unreleased / Windows Node 持久 Workspace 与按需导出**:新增独立 `software_workspaces` 注册表,首个 Job 建立 Workspace、后续 Job 只从当前 head 在同一 home node 串行续作;Node 以 `workspaces/<workspace_id>/current + rollback` 保存两代状态,Origin adapter 1.0.0 可打开上一版 OPJU 再加工,失败自动回滚且不会按 Job 永久复制工程。Workspace 完成时只上传契约声明的预览缓存,预览不注册 Artifact;用户明确要求下载或交付时才由 `software_job_export` 触发原节点上传并登记正式产物。专项 101 项 unittest 与 .NET build 通过;全量 672 项仍仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过(另跳过 4 项),未连接或迁移生产数据库。
|
||||
|
|
|
|||
10
RUN.md
10
RUN.md
|
|
@ -1102,22 +1102,24 @@ install-windows-node.bat
|
|||
|
||||
Workspace 目录固定为 `%ProgramData%\Zcbot\WindowsNode\workspaces\<workspace-id>\`,不使用 user ID 上层目录。`current` 是当前可续作工程,`rollback` 只保留上一版用于失败恢复;每次成功续作替换 `current`,不会按 Job 永久堆积工程副本。不要手工移动 workspace 到其他节点:服务端会把后续 Job 固定调度到其 home node,节点离线时任务保持等待。备份或迁移节点时必须把数据库中的 workspace 归属和该目录作为一个整体处理。
|
||||
|
||||
ANSYS 能力为 `ansys.mechanical.static_structural@v1`,只面向 Mechanical 2024 R2(revision 242)。adapter 0.2.0 已实现 PyMechanical 0.11.3 真实静力流程;Node 会从 `adapters/` 自动发现并在每次心跳上报全部能力,服务端管理页只读展示双方已有共享契约的能力,不需要管理员勾选,也不需要现有节点清身份或重新注册。默认 probe 仍返回不可用;在目标机器完成真实许可证与基准算例验收前,不得设置 `ZCBOT_ANSYS_242_VALIDATED=1`。
|
||||
ANSYS 能力为 `ansys.geometry.inspect@v1` 和 `ansys.mechanical.static_structural@v2`,只面向 Mechanical 2024 R2(revision 242)。adapter 0.3.0 使用 PyMechanical 0.11.3:前者导入几何并输出面清单、单位、包围盒、Named Selection 和预览,后者可按已有 Named Selection、全局轴极值面或平面法向/偏置/容差建立支撑与载荷选区,再执行真实静力求解。v2 直接替换旧 v1,旧请求不会在新版节点调度。Node 会从 `adapters/` 自动发现并在每次心跳上报全部能力,现有节点不需要清身份或重新注册,但必须整体替换发布目录并重启。默认 probe 仍返回不可用;在目标机器完成真实许可证与基准算例验收前,不得设置 `ZCBOT_ANSYS_242_VALIDATED=1`。
|
||||
|
||||
验收使用安装包自带的 NIST FTC-11 AP203 几何,adapter 在 Mechanical 中按固定端面位置自动建立 `fixed_face`、`load_face` 两个 Named Selection;用户不需要准备或选择 CAD。先退出托盘 Node、整体替换新版发布目录、重新运行统一安装器并启动 Node,确认托盘仍显示 ANSYS 待验收。打开 Node 主窗口,在“ANSYS Mechanical 真机验收”卡片点击“运行内置基准验收”,只需选择报告父目录;UI 会使用固定参数执行一次完整进程树取消和连续 20 次求解,不能通过界面改变脚本、次数、试件或执行门条件。验收成功后先在许可证管理端确认席位均已归还,再点击“开启 ANSYS 执行门”;该按钮只会在本次 `acceptance-report.json` 明确通过后启用,写入机器级环境变量需要管理员权限。随后从托盘完全退出并重新启动 Node。内置 STEP 来源于 NIST MBE PMI Validation and Conformance Testing Project,可不受限制地使用,文件头保留来源说明。
|
||||
普通几何先提交 `ansys.geometry.inspect@v1`,`outputs` 传空数组;成功后以 `geometry-manifest.json` 的单位、包围盒、面质心/法向/面积和 `geometry-preview.png` 判断边界区域。规则板件可在静力 v2 中使用 `{"type":"extreme_face","axis":"x","side":"min","expected_count":1}`;已知平面可使用 `{"type":"planar_faces","normal":[1,0,0],"offset":200,"tolerance":0.01,"expected_count":1}`,其中偏置和容差沿用 `analysis.unit_system` 的长度单位;已有 CAD Named Selection 则使用 `{"type":"named_selection","name":"fixed_mount"}`。每个 boundary/load 还必须提供唯一的 ASCII `name`,用于 Mechanical 树、反力结果和溯源。`expected_count` 可省略,但自动化生产请求应尽量提供;数量不符、没有平面、查询为空或名称冲突都会在网格和求解前失败。复杂装配或歧义区域不得只凭预览猜测,应回到 CAD 建立 Named Selection 后再提交。
|
||||
|
||||
验收使用安装包自带的 NIST FTC-11 AP203 几何,adapter 通过与生产请求相同的 `planar_faces` 法向、偏置、容差及 `expected_count=1` 建立 `fixed_face`、`load_face`;不再走验收专用选面旁路。先退出托盘 Node、整体替换新版发布目录、重新运行统一安装器并启动 Node,确认托盘仍显示 ANSYS 待验收。打开 Node 主窗口,在“ANSYS Mechanical 真机验收”卡片点击“运行内置基准验收”,只需选择报告父目录;UI 会使用固定参数执行一次完整进程树取消和连续 20 次求解,不能通过界面改变脚本、次数、试件或执行门条件。验收成功后先在许可证管理端确认席位均已归还,再点击“开启 ANSYS 执行门”;该按钮只会在本次 `acceptance-report.json` 明确通过后启用,写入机器级环境变量需要管理员权限。随后从托盘完全退出并重新启动 Node。内置 STEP 来源于 NIST MBE PMI Validation and Conformance Testing Project,可不受限制地使用,文件头保留来源说明。
|
||||
|
||||
节点卡片的“复制诊断信息”可把 Node 版本、系统、数据目录、执行门状态和每个 adapter 的软件版本、health、detail、受管 runtime 路径复制到对话,不包含 Node Token。若 UI 无法运行验收,可先退出托盘 Node,再用同一专用 Windows 账号在 PowerShell 执行以下等价命令(`--work-root` 必须是尚不存在的新目录):
|
||||
|
||||
```powershell
|
||||
& "$env:ProgramData\Zcbot\WindowsNode\runtimes\ansys\Scripts\python.exe" `
|
||||
"D:\ZcbotNode\adapters\ansys.mechanical.static_structural@v1\acceptance.py" `
|
||||
"D:\ZcbotNode\adapters\ansys.mechanical.static_structural@v2\acceptance.py" `
|
||||
--work-root "D:\ZcbotAcceptance\run-20260818" `
|
||||
--repeat 20 `
|
||||
--cancel-after 10 `
|
||||
--release-wait 60
|
||||
```
|
||||
|
||||
工具逐次验证八类产物及其 manifest,等待 Mechanical/MAPDL 相关进程回到运行前基线,最后启动一项求解并以与 Node 相同的 Windows 完整进程树语义取消;成功时生成 `acceptance-report.json`。还应人工抽查首个 `summary.json`、`result-table.csv`、两张 PNG、`project.mechdat` 和 `solver-output.txt`,确认结果量级合理、solver log 显示许可证成功取得,并在许可证管理端确认 20 次正常退出及取消后席位均已归还。任一步失败都不得开门。
|
||||
工具逐次验证九类产物及其 manifest,包含求解前选区预览,等待 Mechanical/MAPDL 相关进程回到运行前基线,最后启动一项求解并以与 Node 相同的 Windows 完整进程树语义取消;成功时生成 `acceptance-report.json`。还应人工抽查首个 `summary.json`、`result-table.csv`、`selection-preview.png`、两张结果 PNG、`project.mechdat` 和 `solver-output.txt`,确认选区、结果量级合理,solver log 显示许可证成功取得,并在许可证管理端确认 20 次正常退出及取消后席位均已归还。任一步失败都不得开门。
|
||||
|
||||
只有 `acceptance-report.json` 的 `passed` 为 `true` 且许可证管理端确认释放后,才以管理员 PowerShell 设置机器级执行门并重启托盘 Node:
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
{
|
||||
"capability": "ansys.geometry.inspect@v1",
|
||||
"display_name": "ANSYS Mechanical 几何检查",
|
||||
"default_enrollment": false,
|
||||
"output_namespace": "ansys",
|
||||
"input_policy": {
|
||||
"suffixes": [".step", ".stp", ".x_t", ".x_b", ".iges", ".igs"],
|
||||
"max_count": 1,
|
||||
"max_bytes": 536870912,
|
||||
"max_total_bytes": 536870912
|
||||
},
|
||||
"outputs": {
|
||||
"geometry_manifest": {
|
||||
"filename": "geometry-manifest.json",
|
||||
"media_type": "application/json",
|
||||
"relative_path": "geometry-manifest.json",
|
||||
"publish": true,
|
||||
"required": true
|
||||
},
|
||||
"geometry_preview": {
|
||||
"filename": "geometry-preview.png",
|
||||
"media_type": "image/png",
|
||||
"relative_path": "geometry-preview.png",
|
||||
"publish": true,
|
||||
"required": true
|
||||
},
|
||||
"provenance": {
|
||||
"filename": "provenance.json",
|
||||
"media_type": "application/json",
|
||||
"relative_path": ".meta/provenance.json",
|
||||
"publish": false,
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"feature_path": ["operation", "inspect", "type"],
|
||||
"features": {
|
||||
"geometry": "0.3.0"
|
||||
},
|
||||
"summary": {
|
||||
"title_path": ["operation", "inspect", "title"]
|
||||
},
|
||||
"legacy_runtime": null,
|
||||
"workspace": null,
|
||||
"request_schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"x-maxBytes": 32768,
|
||||
"required": ["schema_version", "inputs", "operation", "outputs"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schema_version": {"const": 1},
|
||||
"inputs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["key", "artifact_id"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"key": {"const": "geometry"},
|
||||
"artifact_id": {"type": "string", "format": "uuid"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"operation": {
|
||||
"type": "object",
|
||||
"required": ["inspect"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"inspect": {
|
||||
"type": "object",
|
||||
"required": ["type", "title", "geometry"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "geometry"},
|
||||
"title": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"geometry": {
|
||||
"type": "object",
|
||||
"required": ["input"],
|
||||
"additionalProperties": false,
|
||||
"properties": {"input": {"const": "geometry"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"type": "array",
|
||||
"maxItems": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"capability": "ansys.mechanical.static_structural@v1",
|
||||
"capability": "ansys.mechanical.static_structural@v2",
|
||||
"display_name": "ANSYS Mechanical 线性静力分析",
|
||||
"default_enrollment": false,
|
||||
"output_namespace": "ansys",
|
||||
|
|
@ -31,6 +31,13 @@
|
|||
"publish": true,
|
||||
"required": true
|
||||
},
|
||||
"selection_preview": {
|
||||
"filename": "selection-preview.png",
|
||||
"media_type": "image/png",
|
||||
"relative_path": "selection-preview.png",
|
||||
"publish": true,
|
||||
"required": true
|
||||
},
|
||||
"stress_image": {
|
||||
"filename": "equivalent-stress.png",
|
||||
"media_type": "image/png",
|
||||
|
|
@ -69,7 +76,7 @@
|
|||
},
|
||||
"feature_path": ["operation", "analysis", "type"],
|
||||
"features": {
|
||||
"static_structural": "0.2.0"
|
||||
"static_structural": "0.3.0"
|
||||
},
|
||||
"summary": {
|
||||
"title_path": ["operation", "analysis", "title"]
|
||||
|
|
@ -83,7 +90,7 @@
|
|||
"required": ["schema_version", "inputs", "operation", "outputs"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schema_version": {"const": 1},
|
||||
"schema_version": {"const": 2},
|
||||
"inputs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
|
|
@ -162,7 +169,7 @@
|
|||
"outputs": {
|
||||
"type": "array",
|
||||
"minItems": 0,
|
||||
"maxItems": 5,
|
||||
"maxItems": 4,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"oneOf": [
|
||||
|
|
@ -175,12 +182,73 @@
|
|||
}
|
||||
},
|
||||
"$defs": {
|
||||
"named_selection": {
|
||||
"condition_name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^/\\\\:*?\"<>|]+$",
|
||||
"description": "Name of an existing Named Selection imported from the input geometry. Never invent or infer this name; ask the user when the geometry does not provide a known name."
|
||||
"maxLength": 64,
|
||||
"pattern": "^[A-Za-z][A-Za-z0-9_-]*$",
|
||||
"description": "Stable name used in the Mechanical tree and result provenance."
|
||||
},
|
||||
"expected_count": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 128,
|
||||
"description": "Optional assertion for the exact number of selected faces."
|
||||
},
|
||||
"named_selection_scope": {
|
||||
"type": "object",
|
||||
"required": ["type", "name"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "named_selection"},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^/\\\\:*?\"<>|]+$",
|
||||
"description": "Name of an existing Named Selection imported from the geometry."
|
||||
},
|
||||
"expected_count": {"$ref": "#/$defs/expected_count"}
|
||||
}
|
||||
},
|
||||
"extreme_face_scope": {
|
||||
"type": "object",
|
||||
"required": ["type", "axis", "side"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "extreme_face"},
|
||||
"axis": {"enum": ["x", "y", "z"]},
|
||||
"side": {"enum": ["min", "max"]},
|
||||
"tolerance": {"type": "number", "minimum": 0, "maximum": 1000000},
|
||||
"angle_tolerance_degrees": {
|
||||
"type": "number", "minimum": 0, "maximum": 15, "default": 5
|
||||
},
|
||||
"expected_count": {"$ref": "#/$defs/expected_count"}
|
||||
},
|
||||
"description": "Select planar faces at the minimum or maximum extent along a global axis."
|
||||
},
|
||||
"planar_faces_scope": {
|
||||
"type": "object",
|
||||
"required": ["type", "normal", "offset", "tolerance"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "planar_faces"},
|
||||
"normal": {"$ref": "#/$defs/vector3"},
|
||||
"offset": {"type": "number", "minimum": -1000000, "maximum": 1000000},
|
||||
"tolerance": {"type": "number", "exclusiveMinimum": 0, "maximum": 1000000},
|
||||
"angle_tolerance_degrees": {
|
||||
"type": "number", "minimum": 0, "maximum": 15, "default": 5
|
||||
},
|
||||
"expected_count": {"$ref": "#/$defs/expected_count"}
|
||||
},
|
||||
"description": "Select planar faces whose centroid lies on n dot x = offset. Length values use analysis.unit_system."
|
||||
},
|
||||
"scope": {
|
||||
"oneOf": [
|
||||
{"$ref": "#/$defs/named_selection_scope"},
|
||||
{"$ref": "#/$defs/extreme_face_scope"},
|
||||
{"$ref": "#/$defs/planar_faces_scope"}
|
||||
]
|
||||
},
|
||||
"vector3": {
|
||||
"type": "array",
|
||||
|
|
@ -190,30 +258,33 @@
|
|||
},
|
||||
"fixed_support": {
|
||||
"type": "object",
|
||||
"required": ["type", "named_selection"],
|
||||
"required": ["type", "name", "scope"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "fixed_support"},
|
||||
"named_selection": {"$ref": "#/$defs/named_selection"}
|
||||
"name": {"$ref": "#/$defs/condition_name"},
|
||||
"scope": {"$ref": "#/$defs/scope"}
|
||||
}
|
||||
},
|
||||
"force": {
|
||||
"type": "object",
|
||||
"required": ["type", "named_selection", "components"],
|
||||
"required": ["type", "name", "scope", "components"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "force"},
|
||||
"named_selection": {"$ref": "#/$defs/named_selection"},
|
||||
"name": {"$ref": "#/$defs/condition_name"},
|
||||
"scope": {"$ref": "#/$defs/scope"},
|
||||
"components": {"$ref": "#/$defs/vector3"}
|
||||
}
|
||||
},
|
||||
"pressure": {
|
||||
"type": "object",
|
||||
"required": ["type", "named_selection", "magnitude"],
|
||||
"required": ["type", "name", "scope", "magnitude"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "pressure"},
|
||||
"named_selection": {"$ref": "#/$defs/named_selection"},
|
||||
"name": {"$ref": "#/$defs/condition_name"},
|
||||
"scope": {"$ref": "#/$defs/scope"},
|
||||
"magnitude": {"type": "number", "minimum": -1000000000000, "maximum": 1000000000000}
|
||||
}
|
||||
},
|
||||
|
|
@ -8,20 +8,22 @@ import sys
|
|||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
from core.software_contracts import SoftwareContractError, get_contract
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ADAPTER = (
|
||||
ROOT / "windows-node" / "adapters" / "ansys.mechanical.static_structural@v1"
|
||||
)
|
||||
WORKER_PATH = ADAPTER / "worker.py"
|
||||
ADAPTER_ROOT = ROOT / "windows-node" / "adapters"
|
||||
INSPECT_WORKER = ADAPTER_ROOT / "ansys.geometry.inspect@v1" / "worker.py"
|
||||
STATIC_WORKER = ADAPTER_ROOT / "ansys.mechanical.static_structural@v2" / "worker.py"
|
||||
sys.path.insert(0, str(ADAPTER_ROOT))
|
||||
import ansys_geometry # noqa: E402
|
||||
|
||||
|
||||
def _load_worker():
|
||||
spec = importlib.util.spec_from_file_location("zcbot_ansys_worker", WORKER_PATH)
|
||||
def _load_worker(path: Path, name: str):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("could not load ANSYS worker")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
|
|
@ -29,10 +31,25 @@ def _load_worker():
|
|||
return module
|
||||
|
||||
|
||||
def _request() -> dict:
|
||||
def _inspect_request() -> dict:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"inputs": [{"key": "geometry", "artifact_id": str(uuid4())}],
|
||||
"operation": {
|
||||
"inspect": {
|
||||
"type": "geometry",
|
||||
"title": "coupon geometry inspection",
|
||||
"geometry": {"input": "geometry"},
|
||||
}
|
||||
},
|
||||
"outputs": [],
|
||||
}
|
||||
|
||||
|
||||
def _static_request() -> dict:
|
||||
return {
|
||||
"schema_version": 2,
|
||||
"inputs": [{"key": "geometry", "artifact_id": str(uuid4())}],
|
||||
"operation": {
|
||||
"analysis": {
|
||||
"type": "static_structural",
|
||||
|
|
@ -42,12 +59,28 @@ def _request() -> dict:
|
|||
"material": "structural_steel",
|
||||
"mesh": {"global_size": 2.0},
|
||||
"boundary_conditions": [
|
||||
{"type": "fixed_support", "named_selection": "fixed_face"}
|
||||
{
|
||||
"type": "fixed_support",
|
||||
"name": "fixed_end",
|
||||
"scope": {
|
||||
"type": "extreme_face",
|
||||
"axis": "x",
|
||||
"side": "min",
|
||||
"expected_count": 1,
|
||||
},
|
||||
}
|
||||
],
|
||||
"loads": [
|
||||
{
|
||||
"type": "force",
|
||||
"named_selection": "load_face",
|
||||
"name": "tip_load",
|
||||
"scope": {
|
||||
"type": "planar_faces",
|
||||
"normal": [1, 0, 0],
|
||||
"offset": 200,
|
||||
"tolerance": 0.01,
|
||||
"expected_count": 1,
|
||||
},
|
||||
"components": [0, 0, -1000],
|
||||
}
|
||||
],
|
||||
|
|
@ -62,12 +95,23 @@ def _request() -> dict:
|
|||
|
||||
|
||||
class AnsysContractTests(unittest.TestCase):
|
||||
def test_contract_is_not_enrolled_by_default_and_normalizes_static_job(self) -> None:
|
||||
contract = get_contract("ansys.mechanical.static_structural@v1")
|
||||
normalized, digest = contract.normalize_request(_request())
|
||||
def test_inspect_contract_normalizes_and_always_emits_manifest_preview(self) -> None:
|
||||
contract = get_contract("ansys.geometry.inspect@v1")
|
||||
normalized, digest = contract.normalize_request(_inspect_request())
|
||||
self.assertFalse(contract.default_enrollment)
|
||||
self.assertEqual(contract.feature(normalized), "geometry")
|
||||
self.assertEqual(contract.required_adapter_version(normalized), "0.3.0")
|
||||
self.assertEqual(len(digest), 64)
|
||||
self.assertEqual(
|
||||
set(contract.expected_outputs(normalized)),
|
||||
{"geometry_manifest", "geometry_preview", "provenance"},
|
||||
)
|
||||
|
||||
def test_static_v2_contract_accepts_declarative_scopes_and_required_preview(self) -> None:
|
||||
contract = get_contract("ansys.mechanical.static_structural@v2")
|
||||
normalized, digest = contract.normalize_request(_static_request())
|
||||
self.assertEqual(contract.feature(normalized), "static_structural")
|
||||
self.assertEqual(contract.required_adapter_version(normalized), "0.2.0")
|
||||
self.assertEqual(contract.required_adapter_version(normalized), "0.3.0")
|
||||
self.assertEqual(len(digest), 64)
|
||||
self.assertEqual(
|
||||
set(contract.expected_outputs(normalized)),
|
||||
|
|
@ -76,69 +120,230 @@ class AnsysContractTests(unittest.TestCase):
|
|||
"stress_image",
|
||||
"summary",
|
||||
"result_table",
|
||||
"selection_preview",
|
||||
"analysis_spec",
|
||||
"provenance",
|
||||
},
|
||||
)
|
||||
|
||||
def test_contract_rejects_code_paths_and_unsupported_analysis(self) -> None:
|
||||
contract = get_contract("ansys.mechanical.static_structural@v1")
|
||||
for key, value in (
|
||||
("script", "print('unsafe')"),
|
||||
("apdl", "/SOLU"),
|
||||
("path", r"C:\\private\\model.step"),
|
||||
):
|
||||
request = _request()
|
||||
request[key] = value
|
||||
with self.assertRaises(SoftwareContractError):
|
||||
contract.normalize_request(request)
|
||||
request = _request()
|
||||
request["operation"]["analysis"]["type"] = "modal"
|
||||
def test_static_v2_rejects_v1_named_selection_shape(self) -> None:
|
||||
contract = get_contract("ansys.mechanical.static_structural@v2")
|
||||
request = _static_request()
|
||||
request["operation"]["analysis"]["boundary_conditions"][0] = {
|
||||
"type": "fixed_support",
|
||||
"named_selection": "fixed_face",
|
||||
}
|
||||
with self.assertRaises(SoftwareContractError):
|
||||
contract.normalize_request(request)
|
||||
|
||||
def test_contract_requires_existing_named_selection_names(self) -> None:
|
||||
contract = get_contract("ansys.mechanical.static_structural@v1")
|
||||
schema = contract.request_schema["$defs"]["named_selection"]
|
||||
self.assertIn("existing Named Selection", schema["description"])
|
||||
self.assertIn("Never invent", schema["description"])
|
||||
def test_static_v2_rejects_code_paths_and_zero_plane_normal(self) -> None:
|
||||
contract = get_contract("ansys.mechanical.static_structural@v2")
|
||||
request = _static_request()
|
||||
request["script"] = "print('unsafe')"
|
||||
with self.assertRaises(SoftwareContractError):
|
||||
contract.normalize_request(request)
|
||||
|
||||
request = _static_request()
|
||||
request["operation"]["analysis"]["loads"][0]["scope"]["normal"] = [0, 0, 0]
|
||||
normalized, _ = contract.normalize_request(request)
|
||||
worker = _load_worker(STATIC_WORKER, "zcbot_ansys_static_v2_semantics")
|
||||
with self.assertRaisesRegex(ValueError, "PLANE_NORMAL_MUST_BE_NONZERO"):
|
||||
worker._validate_semantics(normalized)
|
||||
|
||||
|
||||
class Face:
|
||||
def __init__(
|
||||
self,
|
||||
face_id: int,
|
||||
centroid: tuple[float, float, float],
|
||||
normal: tuple[float, float, float],
|
||||
surface_type: str = "GeoSurfacePlane",
|
||||
) -> None:
|
||||
self.Id = face_id
|
||||
self.Centroid = centroid
|
||||
self.Area = 20.0
|
||||
self.SurfaceType = surface_type
|
||||
self._normal = normal
|
||||
|
||||
def ParamAtPoint(self, point):
|
||||
return (0.5, 0.5)
|
||||
|
||||
def NormalAtParam(self, u, v):
|
||||
return self._normal
|
||||
|
||||
|
||||
class AnsysGeometryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.inventory = {
|
||||
"geometry_unit": "mm",
|
||||
"bounding_box": {"min": [0, 0, 0], "max": [200, 100, 20]},
|
||||
"faces": [
|
||||
{
|
||||
"id": 1,
|
||||
"surface_type": "plane",
|
||||
"centroid": [0, 50, 10],
|
||||
"normal": [-1, 0, 0],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"surface_type": "plane",
|
||||
"centroid": [200, 50, 10],
|
||||
"normal": [1, 0, 0],
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"surface_type": "plane",
|
||||
"centroid": [100, 50, 0],
|
||||
"normal": [0, 0, -1],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def test_extreme_face_only_uses_axis_aligned_planar_faces(self) -> None:
|
||||
ids = ansys_geometry._scope_face_ids(
|
||||
{},
|
||||
self.inventory,
|
||||
{
|
||||
"type": "extreme_face",
|
||||
"axis": "x",
|
||||
"side": "min",
|
||||
"expected_count": 1,
|
||||
},
|
||||
"mm",
|
||||
)
|
||||
self.assertEqual(ids, [1])
|
||||
|
||||
def test_planar_faces_uses_plane_equation_and_count_assertion(self) -> None:
|
||||
ids = ansys_geometry._scope_face_ids(
|
||||
{},
|
||||
self.inventory,
|
||||
{
|
||||
"type": "planar_faces",
|
||||
"normal": [1, 0, 0],
|
||||
"offset": 200,
|
||||
"tolerance": 0.01,
|
||||
"expected_count": 1,
|
||||
},
|
||||
"mm",
|
||||
)
|
||||
self.assertEqual(ids, [2])
|
||||
with self.assertRaisesRegex(RuntimeError, "GEOMETRY_SCOPE_COUNT_MISMATCH"):
|
||||
ansys_geometry._scope_face_ids(
|
||||
{},
|
||||
self.inventory,
|
||||
{
|
||||
"type": "planar_faces",
|
||||
"normal": [1, 0, 0],
|
||||
"offset": 200,
|
||||
"tolerance": 0.01,
|
||||
"expected_count": 2,
|
||||
},
|
||||
"mm",
|
||||
)
|
||||
|
||||
def test_resolve_scope_creates_named_selection_from_geometry_ids(self) -> None:
|
||||
selection_info = SimpleNamespace(Ids=[])
|
||||
created = SimpleNamespace(Name="", Location=None)
|
||||
model = SimpleNamespace(
|
||||
NamedSelections=SimpleNamespace(Children=[]),
|
||||
AddNamedSelection=mock.Mock(return_value=created),
|
||||
)
|
||||
app = SimpleNamespace(
|
||||
Model=model,
|
||||
ExtAPI=SimpleNamespace(
|
||||
SelectionManager=SimpleNamespace(
|
||||
CreateSelectionInfo=mock.Mock(return_value=selection_info)
|
||||
)
|
||||
),
|
||||
)
|
||||
api = {"SelectionTypeEnum": SimpleNamespace(GeometryEntities="geometry")}
|
||||
resolved = ansys_geometry.resolve_scope(
|
||||
app,
|
||||
api,
|
||||
self.inventory,
|
||||
{1: object(), 2: object(), 3: object()},
|
||||
{"type": "extreme_face", "axis": "x", "side": "max"},
|
||||
"mm",
|
||||
"tip_load",
|
||||
)
|
||||
self.assertEqual(selection_info.Ids, [2])
|
||||
self.assertEqual(created.Name, "tip_load")
|
||||
self.assertIs(created.Location, selection_info)
|
||||
self.assertEqual(resolved["source"], "geometry_query")
|
||||
|
||||
def test_geometry_inventory_reports_faces_units_bounds_and_named_selections(self) -> None:
|
||||
faces = [
|
||||
Face(11, (0, 5, 2), (-1, 0, 0)),
|
||||
Face(12, (10, 5, 2), (1, 0, 0)),
|
||||
]
|
||||
body = SimpleNamespace(
|
||||
Id=7,
|
||||
Name="plate",
|
||||
Volume=400.0,
|
||||
Vertices=[],
|
||||
Faces=faces,
|
||||
)
|
||||
part = SimpleNamespace(Name="part", Bodies=[body])
|
||||
assembly = SimpleNamespace(Name="assembly", Parts=[part])
|
||||
imported = SimpleNamespace(
|
||||
Name="fixture",
|
||||
Location=SimpleNamespace(Ids=[11]),
|
||||
)
|
||||
app = SimpleNamespace(
|
||||
ExtAPI=SimpleNamespace(
|
||||
DataModel=SimpleNamespace(
|
||||
GeoData=SimpleNamespace(Unit="mm", Assemblies=[assembly])
|
||||
)
|
||||
),
|
||||
Model=SimpleNamespace(
|
||||
NamedSelections=SimpleNamespace(Children=[imported])
|
||||
),
|
||||
)
|
||||
inventory, entities = ansys_geometry.geometry_inventory(app)
|
||||
self.assertEqual(inventory["geometry_unit"], "mm")
|
||||
self.assertEqual(inventory["counts"]["faces"], 2)
|
||||
self.assertEqual(inventory["bounding_box"], {"min": [0, 5, 2], "max": [10, 5, 2]})
|
||||
self.assertEqual(inventory["named_selections"][0]["entity_ids"], [11])
|
||||
self.assertEqual(set(entities), {11, 12})
|
||||
|
||||
|
||||
class AnsysWorkerTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.worker = _load_worker()
|
||||
cls.inspect = _load_worker(INSPECT_WORKER, "zcbot_ansys_inspect_worker")
|
||||
cls.static = _load_worker(STATIC_WORKER, "zcbot_ansys_static_v2_worker")
|
||||
|
||||
def test_worker_semantics_reject_zero_force_and_reused_target(self) -> None:
|
||||
request = _request()
|
||||
def test_static_worker_rejects_zero_force_and_duplicate_names(self) -> None:
|
||||
request = _static_request()
|
||||
request["operation"]["analysis"]["loads"][0]["components"] = [0, 0, 0]
|
||||
with self.assertRaisesRegex(ValueError, "FORCE_VECTOR_MUST_BE_NONZERO"):
|
||||
self.worker._validate_semantics(request)
|
||||
self.static._validate_semantics(request)
|
||||
|
||||
request = _request()
|
||||
request["operation"]["analysis"]["loads"][0]["named_selection"] = "FIXED_FACE"
|
||||
with self.assertRaisesRegex(ValueError, "NAMED_SELECTION_TARGETS_MUST_BE_UNIQUE"):
|
||||
self.worker._validate_semantics(request)
|
||||
request = _static_request()
|
||||
request["operation"]["analysis"]["loads"][0]["name"] = "FIXED_END"
|
||||
with self.assertRaisesRegex(ValueError, "CONDITION_NAMES_MUST_BE_UNIQUE"):
|
||||
self.static._validate_semantics(request)
|
||||
|
||||
def test_probe_is_json_and_does_not_claim_ready_off_windows(self) -> None:
|
||||
completed = subprocess.run(
|
||||
[sys.executable, str(WORKER_PATH), "--probe"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
value = json.loads(completed.stdout)
|
||||
self.assertEqual(value["adapter_version"], "0.2.0")
|
||||
self.assertEqual(value["software"], "ANSYS Mechanical")
|
||||
if sys.platform != "win32":
|
||||
self.assertEqual(value["health"], "unavailable")
|
||||
def test_probes_are_json_and_do_not_claim_ready_off_windows(self) -> None:
|
||||
for worker in (INSPECT_WORKER, STATIC_WORKER):
|
||||
completed = subprocess.run(
|
||||
[sys.executable, str(worker), "--probe"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
value = json.loads(completed.stdout)
|
||||
self.assertEqual(value["adapter_version"], "0.3.0")
|
||||
self.assertEqual(value["software"], "ANSYS Mechanical")
|
||||
if sys.platform != "win32":
|
||||
self.assertEqual(value["health"], "unavailable")
|
||||
|
||||
def test_execution_is_guarded_until_real_license_acceptance(self) -> None:
|
||||
source = WORKER_PATH.read_text(encoding="utf-8")
|
||||
self.assertIn('EXECUTION_GATE = "ZCBOT_ANSYS_242_VALIDATED"', source)
|
||||
self.assertIn("ANSYS_242_REAL_LICENSE_ACCEPTANCE_REQUIRED", source)
|
||||
for forbidden in ("subprocess", "eval(", "exec(", "os.system"):
|
||||
self.assertNotIn(forbidden, source)
|
||||
for worker in (INSPECT_WORKER, STATIC_WORKER):
|
||||
source = worker.read_text(encoding="utf-8")
|
||||
self.assertIn("ANSYS_242_REAL_LICENSE_ACCEPTANCE_REQUIRED", source)
|
||||
for forbidden in ("subprocess", "eval(", "exec(", "os.system"):
|
||||
self.assertNotIn(forbidden, source)
|
||||
|
||||
def test_result_rows_preserve_values_units_and_reaction_components(self) -> None:
|
||||
class Quantity:
|
||||
|
|
@ -146,45 +351,21 @@ class AnsysWorkerTests(unittest.TestCase):
|
|||
self.Value = value
|
||||
self.Unit = unit
|
||||
|
||||
class Deformation:
|
||||
Minimum = Quantity(0.0, "mm")
|
||||
Maximum = Quantity(0.125, "mm")
|
||||
|
||||
class Reaction:
|
||||
XAxis = Quantity(1.0, "N")
|
||||
YAxis = Quantity(2.0, "N")
|
||||
ZAxis = Quantity(3.0, "N")
|
||||
Total = Quantity(3.741657, "N")
|
||||
|
||||
deformation = self.worker._result_rows("total_deformation", "all", Deformation())
|
||||
reaction = self.worker._result_rows("reaction_force", "fixed_face", Reaction())
|
||||
self.assertEqual([item["metric"] for item in deformation], ["minimum", "maximum"])
|
||||
self.assertEqual(deformation[1]["value"], 0.125)
|
||||
self.assertEqual(deformation[1]["unit"], "mm")
|
||||
self.assertEqual([item["metric"] for item in reaction], ["x", "y", "z", "total"])
|
||||
self.assertTrue(all(item["scope"] == "fixed_face" for item in reaction))
|
||||
|
||||
def test_named_selection_resolution_is_case_insensitive_and_rejects_missing(self) -> None:
|
||||
class Selection:
|
||||
def __init__(self, name: str) -> None:
|
||||
self.Name = name
|
||||
|
||||
model = mock.Mock()
|
||||
model.NamedSelections.Children = [Selection("fixed_face"), Selection("LOAD_FACE")]
|
||||
resolved = self.worker._named_selections(model, ["FIXED_FACE", "load_face"])
|
||||
self.assertEqual(resolved["FIXED_FACE"].Name, "fixed_face")
|
||||
self.assertEqual(resolved["load_face"].Name, "LOAD_FACE")
|
||||
with self.assertRaisesRegex(RuntimeError, "NAMED_SELECTION_NOT_FOUND:missing"):
|
||||
self.worker._named_selections(model, ["missing"])
|
||||
|
||||
def test_named_selection_resolution_reports_empty_mechanical_container(self) -> None:
|
||||
model = mock.Mock()
|
||||
model.NamedSelections = None
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
"NAMED_SELECTION_NOT_FOUND:fixed_face,load_face",
|
||||
):
|
||||
self.worker._named_selections(model, ["fixed_face", "load_face"])
|
||||
deformation = SimpleNamespace(
|
||||
Minimum=Quantity(0.0, "mm"), Maximum=Quantity(0.125, "mm")
|
||||
)
|
||||
reaction = SimpleNamespace(
|
||||
XAxis=Quantity(1.0, "N"),
|
||||
YAxis=Quantity(2.0, "N"),
|
||||
ZAxis=Quantity(3.0, "N"),
|
||||
Total=Quantity(3.741657, "N"),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.static._result_rows("total_deformation", "all", deformation)[1]["value"],
|
||||
0.125,
|
||||
)
|
||||
rows = self.static._result_rows("reaction_force", "fixed_end", reaction)
|
||||
self.assertEqual([item["metric"] for item in rows], ["x", "y", "z", "total"])
|
||||
|
||||
def test_acceptance_entry_bypasses_gate_without_enabling_scheduled_execution(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
|
|
@ -194,45 +375,47 @@ class AnsysWorkerTests(unittest.TestCase):
|
|||
"job_id": str(uuid4()),
|
||||
"lease_id": str(uuid4()),
|
||||
"request_digest": "a" * 64,
|
||||
"request": _request(),
|
||||
"request": _static_request(),
|
||||
}
|
||||
(job_dir / "request" / "request.json").write_text(
|
||||
json.dumps(record), encoding="utf-8"
|
||||
)
|
||||
with (
|
||||
mock.patch.dict(os.environ, {}, clear=False),
|
||||
mock.patch.object(self.worker, "run", return_value=[]),
|
||||
mock.patch.object(sys, "argv", [str(WORKER_PATH), "--acceptance", str(job_dir)]),
|
||||
mock.patch.object(self.static, "run", return_value=[]),
|
||||
mock.patch.object(
|
||||
sys, "argv", [str(STATIC_WORKER), "--acceptance", str(job_dir)]
|
||||
),
|
||||
):
|
||||
os.environ.pop(self.worker.EXECUTION_GATE, None)
|
||||
self.assertEqual(self.worker.main(), 0)
|
||||
terminal = json.loads((job_dir / "terminal.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(terminal["status"], "succeeded")
|
||||
self.assertNotIn(self.worker.EXECUTION_GATE, os.environ)
|
||||
os.environ.pop(ansys_geometry.EXECUTION_GATE, None)
|
||||
self.assertEqual(self.static.main(), 0)
|
||||
result = json.loads((job_dir / "terminal.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertNotIn(ansys_geometry.EXECUTION_GATE, os.environ)
|
||||
|
||||
def test_worker_uses_v242_embedded_app_and_deterministic_disposal(self) -> None:
|
||||
source = WORKER_PATH.read_text(encoding="utf-8")
|
||||
self.assertIn("with App(version=ANSYS_REVISION, private_appdata=True) as app:", source)
|
||||
self.assertIn("preferences.ProcessNamedSelections = True", source)
|
||||
self.assertIn(
|
||||
'api["Ansys"].Mechanical.DataModel.Enums.GeometryImportPreference.Format.Automatic',
|
||||
source,
|
||||
)
|
||||
self.assertNotIn('api["GeometryImportPreference"]', source)
|
||||
self.assertIn("solution.Solve(True)", source)
|
||||
self.assertIn('Path(str(analysis.WorkingDir)) / "solve.out"', source)
|
||||
def test_worker_uses_v242_geodata_and_geometry_entity_selection(self) -> None:
|
||||
common = (ADAPTER_ROOT / "ansys_geometry.py").read_text(encoding="utf-8")
|
||||
static = STATIC_WORKER.read_text(encoding="utf-8")
|
||||
self.assertIn("GeoData", common)
|
||||
self.assertIn("face.ParamAtPoint(centroid)", common)
|
||||
self.assertIn("face.NormalAtParam", common)
|
||||
self.assertIn("SelectionTypeEnum", common)
|
||||
self.assertIn("location.Ids = face_ids", common)
|
||||
self.assertIn("resolved_scopes", static)
|
||||
self.assertIn("selection-preview.png", static)
|
||||
self.assertIn("solution.Solve(True)", static)
|
||||
|
||||
def test_acceptance_uses_bundled_nist_geometry_and_creates_named_selections(self) -> None:
|
||||
fixture = WORKER_PATH.with_name("acceptance-coupon.step")
|
||||
acceptance = WORKER_PATH.with_name("acceptance.py").read_text(encoding="utf-8")
|
||||
source = WORKER_PATH.read_text(encoding="utf-8")
|
||||
def test_acceptance_uses_bundled_geometry_and_v2_planar_scopes(self) -> None:
|
||||
adapter = STATIC_WORKER.parent
|
||||
fixture = adapter / "acceptance-coupon.step"
|
||||
acceptance = (adapter / "acceptance.py").read_text(encoding="utf-8")
|
||||
self.assertTrue(fixture.is_file())
|
||||
self.assertIn("NIST MBE PMI Validation", fixture.read_text(encoding="utf-8"))
|
||||
self.assertIn('BUILTIN_GEOMETRY = Path(__file__).with_name("acceptance-coupon.step")', acceptance)
|
||||
self.assertIn("nargs=\"?\", default=BUILTIN_GEOMETRY", acceptance)
|
||||
self.assertIn("_create_acceptance_named_selections", source)
|
||||
self.assertIn('("fixed_face", -1.5)', source)
|
||||
self.assertIn('("load_face", 0.425)', source)
|
||||
self.assertIn('"schema_version": 2', acceptance)
|
||||
self.assertIn('"type": "planar_faces"', acceptance)
|
||||
self.assertIn('"offset": -1.5', acceptance)
|
||||
self.assertIn('"offset": 0.425', acceptance)
|
||||
self.assertIn('"selection_preview"', acceptance)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -44,9 +44,14 @@ class SoftwareJobToolTests(unittest.TestCase):
|
|||
self.assertEqual(result["capabilities"][0]["available_nodes"], 1)
|
||||
self.assertEqual(
|
||||
result["capabilities"][1]["capability"],
|
||||
"ansys.mechanical.static_structural@v1",
|
||||
"ansys.geometry.inspect@v1",
|
||||
)
|
||||
self.assertEqual(result["capabilities"][1]["available_nodes"], 0)
|
||||
self.assertEqual(
|
||||
result["capabilities"][2]["capability"],
|
||||
"ansys.mechanical.static_structural@v2",
|
||||
)
|
||||
self.assertEqual(result["capabilities"][2]["available_nodes"], 0)
|
||||
|
||||
def test_submit_injects_current_user_and_task(self):
|
||||
created = {"job_id": str(uuid4()), "status": "queued"}
|
||||
|
|
|
|||
|
|
@ -81,7 +81,11 @@ class SoftwareNodeSecurityTests(unittest.TestCase):
|
|||
enrollment = session.add.call_args.args[0]
|
||||
self.assertEqual(
|
||||
set(enrollment.allowed_capabilities),
|
||||
{"origin.plot@v2", "ansys.mechanical.static_structural@v1"},
|
||||
{
|
||||
"origin.plot@v2",
|
||||
"ansys.geometry.inspect@v1",
|
||||
"ansys.mechanical.static_structural@v2",
|
||||
},
|
||||
)
|
||||
self.assertEqual(set(result["capabilities"]), set(enrollment.allowed_capabilities))
|
||||
|
||||
|
|
@ -95,7 +99,8 @@ class SoftwareNodeSecurityTests(unittest.TestCase):
|
|||
runtime = {
|
||||
"installed_capabilities": [
|
||||
"origin.plot@v2",
|
||||
"ansys.mechanical.static_structural@v1",
|
||||
"ansys.geometry.inspect@v1",
|
||||
"ansys.mechanical.static_structural@v2",
|
||||
"future.unsupported@v1",
|
||||
"origin.plot@v2",
|
||||
]
|
||||
|
|
@ -107,7 +112,11 @@ class SoftwareNodeSecurityTests(unittest.TestCase):
|
|||
self.assertIs(node.runtime, runtime)
|
||||
self.assertEqual(
|
||||
node.capabilities,
|
||||
["origin.plot@v2", "ansys.mechanical.static_structural@v1"],
|
||||
[
|
||||
"origin.plot@v2",
|
||||
"ansys.geometry.inspect@v1",
|
||||
"ansys.mechanical.static_structural@v2",
|
||||
],
|
||||
)
|
||||
self.assertIsNotNone(node.last_seen_at)
|
||||
|
||||
|
|
|
|||
|
|
@ -206,12 +206,20 @@ class WindowsNodeSourceTests(unittest.TestCase):
|
|||
self.assertIn("..\\..\\software-contracts\\origin.plot.v2.json", project)
|
||||
self.assertIn("adapters\\origin.plot@v2\\origin.plot.v2.json", project)
|
||||
self.assertIn(
|
||||
"..\\..\\software-contracts\\ansys.mechanical.static_structural.v1.json",
|
||||
"..\\..\\software-contracts\\ansys.geometry.inspect.v1.json",
|
||||
project,
|
||||
)
|
||||
self.assertIn(
|
||||
"adapters\\ansys.mechanical.static_structural@v1\\"
|
||||
"ansys.mechanical.static_structural.v1.json",
|
||||
"adapters\\ansys.geometry.inspect@v1\\ansys.geometry.inspect.v1.json",
|
||||
project,
|
||||
)
|
||||
self.assertIn(
|
||||
"..\\..\\software-contracts\\ansys.mechanical.static_structural.v2.json",
|
||||
project,
|
||||
)
|
||||
self.assertIn(
|
||||
"adapters\\ansys.mechanical.static_structural@v2\\"
|
||||
"ansys.mechanical.static_structural.v2.json",
|
||||
project,
|
||||
)
|
||||
self.assertNotIn("software-contracts\\%(Filename)%(Extension)", project)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
内网 MVP 的 Windows 执行节点,目标运行环境为 Windows 11 Enterprise + .NET 10 SDK 10.0.303。仓库根目录 `global.json` 固定 SDK patch;客户端只使用 .NET Windows Desktop Framework,不依赖第三方 NuGet 包。
|
||||
|
||||
当前实现托盘状态角标、配置与本机任务窗口、注册、DPAPI/ACL 配置保存、WebSocket `hello`/心跳和退避重连。Node Host 以语言无关的 job 目录协议负责持久化、下载、恢复、取消和上传,并从 EXE 同级 `adapters/*/adapter.json` 发现能力;每个 adapter 目录必须自带 manifest 指向的合同,Node 不读取共享合同目录。Host 只按该合同的 JSON Schema 通用校验请求,软件探测、语义校验和执行都由 Worker 完成。Worker 可为受管 Python 脚本或独立 EXE,manifest 独立声明每种软件的任务超时及可选 Workspace 状态。当前安装包带 `origin.plot@v2` 和受真机验收门保护的 `ansys.mechanical.static_structural@v1` Python Worker,但 Python 不是 Node 通用协议的一部分。本机任务列表只读取已派发到该 Node 的持久化目录;Origin 工程保存在 `workspaces/<workspace-id>/current`,只保留一份 `rollback`,完成时自动上传轻量预览,用户明确请求导出时才上传并发布所选正式产物。
|
||||
当前实现托盘状态角标、配置与本机任务窗口、注册、DPAPI/ACL 配置保存、WebSocket `hello`/心跳和退避重连。Node Host 以语言无关的 job 目录协议负责持久化、下载、恢复、取消和上传,并从 EXE 同级 `adapters/*/adapter.json` 发现能力;每个 adapter 目录必须自带 manifest 指向的合同,Node 不读取共享合同目录。Host 只按该合同的 JSON Schema 通用校验请求,软件探测、语义校验和执行都由 Worker 完成。Worker 可为受管 Python 脚本或独立 EXE,manifest 独立声明每种软件的任务超时及可选 Workspace 状态。当前安装包带 `origin.plot@v2`、`ansys.geometry.inspect@v1` 和受真机验收门保护的 `ansys.mechanical.static_structural@v2` Python Worker,但 Python 不是 Node 通用协议的一部分。本机任务列表只读取已派发到该 Node 的持久化目录;Origin 工程保存在 `workspaces/<workspace-id>/current`,只保留一份 `rollback`,完成时自动上传轻量预览,用户明确请求导出时才上传并发布所选正式产物。
|
||||
|
||||
在仓库根目录执行一条命令生成可分发 ZIP:
|
||||
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ internal sealed class ConfigurationForm : Form
|
|||
acceptanceStop?.Dispose();
|
||||
jobRefreshTimer.Dispose();
|
||||
};
|
||||
var ansysAdapter = adapters.Find("ansys.mechanical.static_structural@v1");
|
||||
var ansysAdapter = adapters.Find("ansys.mechanical.static_structural@v2");
|
||||
ansysAcceptanceCard.Visible = ansysAdapter?.SupportsLocalAcceptance == true;
|
||||
enableAnsysGate.Enabled = false;
|
||||
UpdateAnsysAcceptanceStatus();
|
||||
|
|
@ -333,7 +333,7 @@ internal sealed class ConfigurationForm : Form
|
|||
|
||||
private async Task RunAnsysAcceptanceAsync()
|
||||
{
|
||||
var adapter = adapters.Find("ansys.mechanical.static_structural@v1");
|
||||
var adapter = adapters.Find("ansys.mechanical.static_structural@v2");
|
||||
if (adapter?.SupportsLocalAcceptance != true)
|
||||
{
|
||||
MessageBox.Show(
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ internal sealed class ProcessNodeAdapter : INodeAdapter
|
|||
public string RunningDetail => Descriptor.Manifest.RunningDetail;
|
||||
public bool SupportsLocalAcceptance =>
|
||||
Capability.Equals(
|
||||
"ansys.mechanical.static_structural@v1", StringComparison.Ordinal)
|
||||
"ansys.mechanical.static_structural@v2", StringComparison.Ordinal)
|
||||
&& File.Exists(Path.Combine(Descriptor.DirectoryPath, "acceptance.py"));
|
||||
public string RuntimePath => runner.RuntimePath;
|
||||
public string ContractPath => Descriptor.ContractPath;
|
||||
|
|
|
|||
|
|
@ -25,8 +25,13 @@
|
|||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</Content>
|
||||
<Content Include="..\..\software-contracts\ansys.mechanical.static_structural.v1.json">
|
||||
<Link>adapters\ansys.mechanical.static_structural@v1\ansys.mechanical.static_structural.v1.json</Link>
|
||||
<Content Include="..\..\software-contracts\ansys.geometry.inspect.v1.json">
|
||||
<Link>adapters\ansys.geometry.inspect@v1\ansys.geometry.inspect.v1.json</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</Content>
|
||||
<Content Include="..\..\software-contracts\ansys.mechanical.static_structural.v2.json">
|
||||
<Link>adapters\ansys.mechanical.static_structural@v2\ansys.mechanical.static_structural.v2.json</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</Content>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"capability": "ansys.geometry.inspect@v1",
|
||||
"adapter_version": "0.3.0",
|
||||
"runtime": "python",
|
||||
"runtime_id": "ansys",
|
||||
"entrypoint": "worker.py",
|
||||
"contract": "ansys.geometry.inspect.v1.json",
|
||||
"worker_timeout_minutes": 30,
|
||||
"running_detail": "ANSYS Mechanical 2024 R2 正在检查几何"
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
"""ANSYS Mechanical 2024 R2 declarative geometry-inspection adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
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,
|
||||
terminal,
|
||||
validate_artifact,
|
||||
)
|
||||
|
||||
ADAPTER_VERSION = "0.3.0"
|
||||
_ARTIFACTS = {
|
||||
"geometry-manifest.json": ("geometry_manifest", "application/json"),
|
||||
"geometry-preview.png": ("geometry_preview", "image/png"),
|
||||
"provenance.json": ("provenance", "application/json"),
|
||||
}
|
||||
|
||||
|
||||
def _validate_semantics(request: dict[str, Any]) -> None:
|
||||
inspect = request["operation"]["inspect"]
|
||||
input_keys = {item["key"] for item in request["inputs"]}
|
||||
if inspect["geometry"]["input"] not in input_keys:
|
||||
raise ValueError("GEOMETRY_INPUT_NOT_BOUND")
|
||||
if request["outputs"]:
|
||||
raise ValueError("INSPECT_OUTPUTS_MUST_BE_EMPTY")
|
||||
|
||||
|
||||
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 run(job_dir: Path) -> list[dict[str, Any]]:
|
||||
job_dir = job_dir.resolve(strict=True)
|
||||
record = _load_request(job_dir)
|
||||
request = record["request"]
|
||||
inspect = request["operation"]["inspect"]
|
||||
geometry = input_file(job_dir, inspect["geometry"]["input"])
|
||||
output = job_dir / "output"
|
||||
output.mkdir(exist_ok=True)
|
||||
|
||||
from ansys.mechanical.core import App
|
||||
|
||||
software_version: int | None = None
|
||||
manifest_path = output / "geometry-manifest.json"
|
||||
preview_path = output / "geometry-preview.png"
|
||||
with App(version=ANSYS_REVISION, private_appdata=True) as app:
|
||||
software_version = app.version
|
||||
api: dict[str, Any] = {}
|
||||
app.update_globals(api)
|
||||
import_geometry(app, api, geometry)
|
||||
inventory, _ = geometry_inventory(app)
|
||||
inventory.update(
|
||||
{
|
||||
"title": inspect["title"],
|
||||
"software": "ANSYS Mechanical",
|
||||
"software_version": software_version,
|
||||
}
|
||||
)
|
||||
atomic_json(manifest_path, inventory)
|
||||
export_geometry_image(app, api, preview_path)
|
||||
|
||||
provenance_path = output / "provenance.json"
|
||||
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": inspect["geometry"]["input"],
|
||||
"filename": geometry.name,
|
||||
"sha256": file_sha256(geometry),
|
||||
},
|
||||
},
|
||||
)
|
||||
paths = [manifest_path, preview_path, provenance_path]
|
||||
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)
|
||||
if len(sys.argv) != 2:
|
||||
print("[ERR] Usage: worker.py <job-directory>", file=sys.stderr)
|
||||
return 2
|
||||
job_dir = Path(sys.argv[1])
|
||||
record: dict[str, Any] = {}
|
||||
try:
|
||||
record = _load_request(job_dir)
|
||||
if 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 geometry inspection 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())
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
"""Run the guarded ANSYS 2024 R2 acceptance suite on a dedicated Windows node."""
|
||||
"""Run the guarded ANSYS 2024 R2 v2 acceptance suite on a dedicated Windows node."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ PROCESS_NAMES = {"ansys.exe", "ansyswbu.exe", "mechanical.exe", "mapdl.exe"}
|
|||
|
||||
def _request(geometry_id: str) -> dict:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"inputs": [{"key": "geometry", "artifact_id": geometry_id}],
|
||||
"operation": {
|
||||
"analysis": {
|
||||
|
|
@ -33,12 +33,29 @@ def _request(geometry_id: str) -> dict:
|
|||
"material": "structural_steel",
|
||||
"mesh": {"global_size": 2.0},
|
||||
"boundary_conditions": [
|
||||
{"type": "fixed_support", "named_selection": "fixed_face"}
|
||||
{
|
||||
"type": "fixed_support",
|
||||
"name": "fixed_face",
|
||||
"scope": {
|
||||
"type": "planar_faces",
|
||||
"normal": [0, 0, 1],
|
||||
"offset": -1.5,
|
||||
"tolerance": 0.001,
|
||||
"expected_count": 1,
|
||||
},
|
||||
}
|
||||
],
|
||||
"loads": [
|
||||
{
|
||||
"type": "force",
|
||||
"named_selection": "load_face",
|
||||
"name": "load_face",
|
||||
"scope": {
|
||||
"type": "planar_faces",
|
||||
"normal": [0, 0, 1],
|
||||
"offset": 0.425,
|
||||
"tolerance": 0.001,
|
||||
"expected_count": 1,
|
||||
},
|
||||
"components": [0, 0, -1000],
|
||||
}
|
||||
],
|
||||
|
|
@ -130,6 +147,7 @@ def _run_success(job_dir: Path, baseline: dict[int, str], release_wait: int) ->
|
|||
"project",
|
||||
"summary",
|
||||
"result_table",
|
||||
"selection_preview",
|
||||
"stress_image",
|
||||
"deformation_image",
|
||||
"solver_log",
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"capability": "ansys.mechanical.static_structural@v1",
|
||||
"adapter_version": "0.2.0",
|
||||
"capability": "ansys.mechanical.static_structural@v2",
|
||||
"adapter_version": "0.3.0",
|
||||
"runtime": "python",
|
||||
"runtime_id": "ansys",
|
||||
"entrypoint": "worker.py",
|
||||
"contract": "ansys.mechanical.static_structural.v1.json",
|
||||
"contract": "ansys.mechanical.static_structural.v2.json",
|
||||
"worker_timeout_minutes": 240,
|
||||
"running_detail": "ANSYS Mechanical 2024 R2 正在进行线性静力分析"
|
||||
}
|
||||
|
|
@ -1,33 +1,37 @@
|
|||
"""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.
|
||||
"""
|
||||
"""ANSYS Mechanical 2024 R2 declarative static-structural adapter v2."""
|
||||
|
||||
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 importlib.metadata import 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"))
|
||||
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"),
|
||||
|
|
@ -36,6 +40,7 @@ _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"),
|
||||
|
|
@ -44,60 +49,23 @@ _ARTIFACTS = {
|
|||
}
|
||||
|
||||
|
||||
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")
|
||||
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")])
|
||||
|
|
@ -124,56 +92,6 @@ def _load_request(job_dir: Path) -> dict[str, Any]:
|
|||
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)}
|
||||
|
||||
|
|
@ -193,59 +111,23 @@ def _result_rows(result_key: str, scope: str, result: Any) -> list[dict[str, Any
|
|||
|
||||
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 = 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()
|
||||
container = model.NamedSelections
|
||||
children = () if container is None else (container.Children or ())
|
||||
for item in 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:
|
||||
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)
|
||||
app.Graphics.ExportImage(str(path), api["GraphicsImageExportFormat"].PNG, settings)
|
||||
validate_artifact(path)
|
||||
|
||||
|
||||
def _copy_solver_log(analysis: Any, target: Path) -> None:
|
||||
|
|
@ -255,52 +137,74 @@ def _copy_solver_log(analysis: Any, target: Path) -> None:
|
|||
shutil.copyfile(source, target)
|
||||
|
||||
|
||||
def run(job_dir: Path, *, acceptance: bool = False) -> list[dict[str, Any]]:
|
||||
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"])
|
||||
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]]] = {}
|
||||
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)
|
||||
model = app.Model
|
||||
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"],
|
||||
}
|
||||
|
||||
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,
|
||||
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)
|
||||
|
||||
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")
|
||||
|
|
@ -314,13 +218,15 @@ def run(job_dir: Path, *, acceptance: bool = False) -> list[dict[str, Any]]:
|
|||
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))
|
||||
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.Location = selections[item["named_selection"]]
|
||||
load.Name = item["name"]
|
||||
load.Location = resolved[item["name"]]["location"]
|
||||
load.DefineBy = api["LoadDefineBy"].Components
|
||||
for field_name, value in zip(
|
||||
("XComponent", "YComponent", "ZComponent"),
|
||||
|
|
@ -332,7 +238,8 @@ def run(job_dir: Path, *, acceptance: bool = False) -> list[dict[str, Any]]:
|
|||
]
|
||||
else:
|
||||
load = analysis.AddPressure()
|
||||
load.Location = selections[item["named_selection"]]
|
||||
load.Name = item["name"]
|
||||
load.Location = resolved[item["name"]]["location"]
|
||||
load.Magnitude.Output.DiscreteValues = [
|
||||
api["Quantity"](item["magnitude"], "Pa")
|
||||
]
|
||||
|
|
@ -350,10 +257,10 @@ def run(job_dir: Path, *, acceptance: bool = False) -> list[dict[str, Any]]:
|
|||
results["equivalent_stress"] = [("all", solution.AddEquivalentStress())]
|
||||
if "reaction_force" in requested_results:
|
||||
reactions = []
|
||||
for selection_name, support in fixed_supports:
|
||||
for condition_name, support in fixed_supports:
|
||||
reaction = solution.AddForceReaction()
|
||||
reaction.BoundaryConditionSelection = support
|
||||
reactions.append((selection_name, reaction))
|
||||
reactions.append((condition_name, reaction))
|
||||
results["reaction_force"] = reactions
|
||||
|
||||
solution.Solve(True)
|
||||
|
|
@ -371,12 +278,18 @@ def run(job_dir: Path, *, acceptance: bool = False) -> list[dict[str, Any]]:
|
|||
|
||||
_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"
|
||||
_export_result_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"
|
||||
_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"))
|
||||
|
|
@ -384,25 +297,30 @@ def run(job_dir: Path, *, acceptance: bool = False) -> list[dict[str, Any]]:
|
|||
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"
|
||||
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(summary_path, summary)
|
||||
_atomic_json(analysis_spec_path, request)
|
||||
_atomic_json(
|
||||
atomic_json(analysis_spec_path, request)
|
||||
atomic_json(
|
||||
provenance_path,
|
||||
{
|
||||
"adapter_version": ADAPTER_VERSION,
|
||||
|
|
@ -412,13 +330,20 @@ def run(job_dir: Path, *, acceptance: bool = False) -> list[dict[str, Any]]:
|
|||
"input": {
|
||||
"key": analysis_spec["geometry"]["input"],
|
||||
"filename": geometry.name,
|
||||
"sha256": _file_sha256(geometry),
|
||||
"sha256": file_sha256(geometry),
|
||||
},
|
||||
"resolved_scopes": resolved_summary,
|
||||
"outputs": request["outputs"],
|
||||
},
|
||||
)
|
||||
|
||||
paths = [summary_path, result_table_path, analysis_spec_path, provenance_path]
|
||||
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:
|
||||
|
|
@ -430,45 +355,32 @@ def run(job_dir: Path, *, acceptance: bool = False) -> list[dict[str, Any]]:
|
|||
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(),
|
||||
}
|
||||
validate_artifact(path)
|
||||
return [artifact_manifest(path, _ARTIFACTS) for path in paths]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if sys.argv[1:] == ["--probe"]:
|
||||
return _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, acceptance=acceptance)
|
||||
_atomic_json(job_dir / "artifacts.json", artifacts)
|
||||
_atomic_json(job_dir / "terminal.json", _terminal(record, "succeeded", artifacts, {}))
|
||||
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(
|
||||
atomic_json(
|
||||
job_dir / "terminal.json",
|
||||
_terminal(
|
||||
terminal(
|
||||
record,
|
||||
"failed",
|
||||
[],
|
||||
|
|
@ -0,0 +1,449 @@
|
|||
"""Shared, declarative geometry helpers for the ANSYS Mechanical adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ANSYS_REVISION = 242
|
||||
ANSYS_RELEASE = "2024 R2"
|
||||
EXECUTION_GATE = "ZCBOT_ANSYS_242_VALIDATED"
|
||||
INSTALL_ROOT = Path(os.environ.get("AWP_ROOT242", r"C:\Program Files\ANSYS Inc\v242"))
|
||||
|
||||
|
||||
def probe(adapter_version: str) -> 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 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 artifact_manifest(path: Path, artifacts: dict[str, tuple[str, str]]) -> 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 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 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 import_geometry(app: Any, api: dict[str, Any], geometry: Path) -> Any:
|
||||
model = app.Model
|
||||
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,
|
||||
)
|
||||
return model
|
||||
|
||||
|
||||
def _xyz(value: Any) -> list[float]:
|
||||
try:
|
||||
result = [float(value[index]) for index in range(3)]
|
||||
except (IndexError, KeyError, TypeError):
|
||||
names = ("X", "Y", "Z") if hasattr(value, "X") else ("x", "y", "z")
|
||||
result = [float(getattr(value, name)) for name in names]
|
||||
if not all(math.isfinite(item) for item in result):
|
||||
raise ValueError("GEOMETRY_COORDINATE_NOT_FINITE")
|
||||
return result
|
||||
|
||||
|
||||
def _normal(face: Any, centroid: list[float]) -> list[float] | None:
|
||||
try:
|
||||
uv = face.ParamAtPoint(centroid)
|
||||
raw = face.NormalAtParam(float(uv[0]), float(uv[1]))
|
||||
normal = _xyz(raw)
|
||||
magnitude = math.sqrt(sum(item * item for item in normal))
|
||||
if magnitude <= 1e-12:
|
||||
return None
|
||||
return [item / magnitude for item in normal]
|
||||
except Exception: # noqa: BLE001 - vendor faces can reject a centroid parameterization
|
||||
return None
|
||||
|
||||
|
||||
def _surface_type(value: Any) -> str:
|
||||
text = str(value or "unknown").split(".")[-1]
|
||||
if text.casefold().startswith("geosurface"):
|
||||
text = text[len("GeoSurface"):]
|
||||
return text.casefold() or "unknown"
|
||||
|
||||
|
||||
def _entity_id(value: Any) -> int:
|
||||
return int(value.Id)
|
||||
|
||||
|
||||
def _optional_number(value: Any, name: str) -> float | None:
|
||||
try:
|
||||
number = float(getattr(value, name))
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return None
|
||||
return number if math.isfinite(number) else None
|
||||
|
||||
|
||||
def geometry_inventory(app: Any) -> tuple[dict[str, Any], dict[int, Any]]:
|
||||
geo_data = app.ExtAPI.DataModel.GeoData
|
||||
unit = str(geo_data.Unit)
|
||||
assemblies_out: list[dict[str, Any]] = []
|
||||
bodies_out: list[dict[str, Any]] = []
|
||||
faces_out: list[dict[str, Any]] = []
|
||||
face_entities: dict[int, Any] = {}
|
||||
points: list[list[float]] = []
|
||||
|
||||
for assembly_index, assembly in enumerate(geo_data.Assemblies):
|
||||
assembly_name = str(getattr(assembly, "Name", f"assembly-{assembly_index + 1}"))
|
||||
parts = getattr(assembly, "Parts", None) or getattr(assembly, "AllParts", ())
|
||||
part_count = 0
|
||||
for part_index, part in enumerate(parts):
|
||||
part_count += 1
|
||||
part_name = str(getattr(part, "Name", f"part-{part_index + 1}"))
|
||||
for body_index, body in enumerate(part.Bodies):
|
||||
body_id = _entity_id(body)
|
||||
body_name = str(getattr(body, "Name", f"body-{body_index + 1}"))
|
||||
body_face_ids: list[int] = []
|
||||
for vertex in getattr(body, "Vertices", ()):
|
||||
recorded = False
|
||||
for attribute in ("Point", "Coordinates", "XYZ"):
|
||||
if hasattr(vertex, attribute):
|
||||
try:
|
||||
points.append(_xyz(getattr(vertex, attribute)))
|
||||
recorded = True
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
pass
|
||||
break
|
||||
if not recorded:
|
||||
try:
|
||||
points.append(_xyz(vertex))
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
pass
|
||||
for face in body.Faces:
|
||||
face_id = _entity_id(face)
|
||||
centroid = _xyz(face.Centroid)
|
||||
points.append(centroid)
|
||||
body_face_ids.append(face_id)
|
||||
face_entities[face_id] = face
|
||||
faces_out.append(
|
||||
{
|
||||
"id": face_id,
|
||||
"assembly": assembly_name,
|
||||
"part": part_name,
|
||||
"body_id": body_id,
|
||||
"body": body_name,
|
||||
"surface_type": _surface_type(getattr(face, "SurfaceType", None)),
|
||||
"centroid": centroid,
|
||||
"normal": _normal(face, centroid),
|
||||
"area": _optional_number(face, "Area"),
|
||||
}
|
||||
)
|
||||
bodies_out.append(
|
||||
{
|
||||
"id": body_id,
|
||||
"assembly": assembly_name,
|
||||
"part": part_name,
|
||||
"name": body_name,
|
||||
"volume": _optional_number(body, "Volume"),
|
||||
"face_ids": body_face_ids,
|
||||
}
|
||||
)
|
||||
assemblies_out.append({"name": assembly_name, "part_count": part_count})
|
||||
|
||||
if not bodies_out:
|
||||
raise RuntimeError("IMPORTED_GEOMETRY_HAS_NO_BODIES")
|
||||
if not faces_out:
|
||||
raise RuntimeError("IMPORTED_GEOMETRY_HAS_NO_FACES")
|
||||
|
||||
named_selections: list[dict[str, Any]] = []
|
||||
container = app.Model.NamedSelections
|
||||
for item in () if container is None else (container.Children or ()):
|
||||
location = getattr(item, "Location", None)
|
||||
ids = [] if location is None else [int(value) for value in (location.Ids or ())]
|
||||
named_selections.append({"name": str(item.Name), "entity_ids": ids})
|
||||
|
||||
bounds = {
|
||||
"min": [min(point[index] for point in points) for index in range(3)],
|
||||
"max": [max(point[index] for point in points) for index in range(3)],
|
||||
}
|
||||
return (
|
||||
{
|
||||
"geometry_unit": unit,
|
||||
"counts": {
|
||||
"assemblies": len(assemblies_out),
|
||||
"bodies": len(bodies_out),
|
||||
"faces": len(faces_out),
|
||||
"named_selections": len(named_selections),
|
||||
},
|
||||
"bounding_box": bounds,
|
||||
"assemblies": assemblies_out,
|
||||
"bodies": bodies_out,
|
||||
"faces": faces_out,
|
||||
"named_selections": named_selections,
|
||||
},
|
||||
face_entities,
|
||||
)
|
||||
|
||||
|
||||
def export_geometry_image(
|
||||
app: Any,
|
||||
api: dict[str, Any],
|
||||
path: Path,
|
||||
selection: Any | None = None,
|
||||
) -> None:
|
||||
if selection is not None:
|
||||
app.ExtAPI.SelectionManager.NewSelection(selection)
|
||||
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)
|
||||
if selection is not None:
|
||||
app.ExtAPI.SelectionManager.ClearSelection()
|
||||
|
||||
|
||||
def _convert_length(api: dict[str, Any], value: float, source_unit: str, target_unit: str) -> float:
|
||||
if source_unit.casefold() == target_unit.casefold():
|
||||
return float(value)
|
||||
quantity = api["Quantity"](float(value), source_unit)
|
||||
converted = quantity.ConvertUnit(target_unit)
|
||||
return float((converted if converted is not None else quantity).Value)
|
||||
|
||||
|
||||
def _expected_count(scope: dict[str, Any], face_ids: list[int]) -> None:
|
||||
expected = scope.get("expected_count")
|
||||
if expected is not None and len(face_ids) != expected:
|
||||
raise RuntimeError(
|
||||
f"GEOMETRY_SCOPE_COUNT_MISMATCH:expected={expected},actual={len(face_ids)}"
|
||||
)
|
||||
|
||||
|
||||
def _axis_index(axis: str) -> int:
|
||||
return {"x": 0, "y": 1, "z": 2}[axis]
|
||||
|
||||
|
||||
def _planar_faces(inventory: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
face for face in inventory["faces"]
|
||||
if "plane" in face["surface_type"] and face["normal"] is not None
|
||||
]
|
||||
|
||||
|
||||
def _scope_face_ids(
|
||||
api: dict[str, Any],
|
||||
inventory: dict[str, Any],
|
||||
scope: dict[str, Any],
|
||||
request_length_unit: str,
|
||||
) -> list[int]:
|
||||
planar = _planar_faces(inventory)
|
||||
if not planar:
|
||||
raise RuntimeError("GEOMETRY_SCOPE_NO_PLANAR_FACES")
|
||||
geo_unit = inventory["geometry_unit"]
|
||||
angle = math.radians(float(scope.get("angle_tolerance_degrees", 5.0)))
|
||||
cosine = math.cos(angle)
|
||||
|
||||
if scope["type"] == "extreme_face":
|
||||
axis = _axis_index(scope["axis"])
|
||||
aligned = [face for face in planar if abs(face["normal"][axis]) >= cosine]
|
||||
if not aligned:
|
||||
raise RuntimeError("GEOMETRY_SCOPE_NO_AXIS_ALIGNED_FACES")
|
||||
coordinates = [face["centroid"][axis] for face in aligned]
|
||||
extreme = min(coordinates) if scope["side"] == "min" else max(coordinates)
|
||||
span = inventory["bounding_box"]["max"][axis] - inventory["bounding_box"]["min"][axis]
|
||||
tolerance = scope.get("tolerance")
|
||||
tolerance = (
|
||||
max(abs(span) * 1e-7, 1e-12)
|
||||
if tolerance is None
|
||||
else _convert_length(api, tolerance, request_length_unit, geo_unit)
|
||||
)
|
||||
ids = [
|
||||
face["id"] for face in aligned
|
||||
if abs(face["centroid"][axis] - extreme) <= tolerance
|
||||
]
|
||||
elif scope["type"] == "planar_faces":
|
||||
raw_normal = [float(item) for item in scope["normal"]]
|
||||
magnitude = math.sqrt(sum(item * item for item in raw_normal))
|
||||
if magnitude <= 1e-12:
|
||||
raise ValueError("PLANE_NORMAL_MUST_BE_NONZERO")
|
||||
normal = [item / magnitude for item in raw_normal]
|
||||
offset = _convert_length(api, scope["offset"], request_length_unit, geo_unit)
|
||||
tolerance = _convert_length(api, scope["tolerance"], request_length_unit, geo_unit)
|
||||
ids = []
|
||||
for face in planar:
|
||||
alignment = abs(sum(a * b for a, b in zip(normal, face["normal"], strict=True)))
|
||||
position = sum(a * b for a, b in zip(normal, face["centroid"], strict=True))
|
||||
if alignment >= cosine and abs(position - offset) <= tolerance:
|
||||
ids.append(face["id"])
|
||||
else:
|
||||
raise ValueError("GEOMETRY_SCOPE_TYPE_UNSUPPORTED")
|
||||
|
||||
ids = sorted(set(ids))
|
||||
if not ids:
|
||||
raise RuntimeError(f"GEOMETRY_SCOPE_EMPTY:{scope['type']}")
|
||||
_expected_count(scope, ids)
|
||||
return ids
|
||||
|
||||
|
||||
def resolve_scope(
|
||||
app: Any,
|
||||
api: dict[str, Any],
|
||||
inventory: dict[str, Any],
|
||||
face_entities: dict[int, Any],
|
||||
scope: dict[str, Any],
|
||||
request_length_unit: str,
|
||||
generated_name: str,
|
||||
) -> dict[str, Any]:
|
||||
if scope["type"] == "named_selection":
|
||||
matches = [
|
||||
item for item in (() if app.Model.NamedSelections is None else app.Model.NamedSelections.Children)
|
||||
if str(item.Name).casefold() == scope["name"].casefold()
|
||||
]
|
||||
if len(matches) != 1:
|
||||
code = "NOT_FOUND" if not matches else "AMBIGUOUS"
|
||||
raise RuntimeError(f"NAMED_SELECTION_{code}:{scope['name']}")
|
||||
location = matches[0].Location
|
||||
ids = [int(value) for value in (location.Ids or ())]
|
||||
if not ids:
|
||||
raise RuntimeError(f"NAMED_SELECTION_EMPTY:{scope['name']}")
|
||||
_expected_count(scope, ids)
|
||||
return {
|
||||
"name": str(matches[0].Name),
|
||||
"source": "imported_named_selection",
|
||||
"scope": scope,
|
||||
"entity_ids": ids,
|
||||
"location": matches[0],
|
||||
}
|
||||
|
||||
face_ids = _scope_face_ids(api, inventory, scope, request_length_unit)
|
||||
if any(face_id not in face_entities for face_id in face_ids):
|
||||
raise RuntimeError("GEOMETRY_SCOPE_ENTITY_NOT_FOUND")
|
||||
conflicts = [
|
||||
item for item in (() if app.Model.NamedSelections is None else app.Model.NamedSelections.Children)
|
||||
if str(item.Name).casefold() == generated_name.casefold()
|
||||
]
|
||||
if conflicts:
|
||||
raise RuntimeError(f"GENERATED_SELECTION_NAME_CONFLICT:{generated_name}")
|
||||
location = app.ExtAPI.SelectionManager.CreateSelectionInfo(
|
||||
api["SelectionTypeEnum"].GeometryEntities
|
||||
)
|
||||
location.Ids = face_ids
|
||||
named_selection = app.Model.AddNamedSelection()
|
||||
named_selection.Name = generated_name
|
||||
named_selection.Location = location
|
||||
return {
|
||||
"name": generated_name,
|
||||
"source": "geometry_query",
|
||||
"scope": scope,
|
||||
"entity_ids": face_ids,
|
||||
"location": named_selection,
|
||||
}
|
||||
|
||||
|
||||
def selected_face_records(inventory: dict[str, Any], ids: list[int]) -> list[dict[str, Any]]:
|
||||
selected = set(ids)
|
||||
return [face for face in inventory["faces"] if face["id"] in selected]
|
||||
|
|
@ -6,7 +6,7 @@ set "NODE_EXE=%~dp0Zcbot.WindowsNode.exe"
|
|||
set "ORIGIN_REQUIREMENTS=%~dp0adapters\origin.plot@v2\requirements.txt"
|
||||
set "ORIGIN_RUNTIME_DIR=%ProgramData%\Zcbot\WindowsNode\runtimes\origin"
|
||||
set "ORIGIN_RUNTIME_PYTHON=%ORIGIN_RUNTIME_DIR%\Scripts\python.exe"
|
||||
set "ANSYS_REQUIREMENTS=%~dp0adapters\ansys.mechanical.static_structural@v1\requirements.txt"
|
||||
set "ANSYS_REQUIREMENTS=%~dp0adapters\ansys.mechanical.static_structural@v2\requirements.txt"
|
||||
set "ANSYS_RUNTIME_DIR=%ProgramData%\Zcbot\WindowsNode\runtimes\ansys"
|
||||
set "ANSYS_RUNTIME_PYTHON=%ANSYS_RUNTIME_DIR%\Scripts\python.exe"
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue