feat(ansys): add geometry inspection and scoped static analysis

This commit is contained in:
caoqianming 2026-08-20 09:36:03 +08:00
parent 172e0dbdaf
commit ede573054b
23 changed files with 1303 additions and 391 deletions

View File

@ -8,6 +8,8 @@
## Unreleased
- ANSYS 新增几何检查能力,可在求解前返回实体、面、尺寸、法向、面积、已有选择集和预览;静力分析现在可按坐标轴端面或指定平面自动建立固定与加载区域,并在数量不符时停止,结果中同时保留实际选区及预览。
- Origin 专业软件任务完成后会把轻量预览图回传到当前任务文件夹,并在图片下方显示可预览、下载的文件入口;预览图仍不会自动登记为正式产物,只有明确导出时才获得稳定产物身份。
- Windows Node 的每个专业软件 adapter 现在自带唯一运行时合同,不再受安装目录遗留共享合同影响;诊断信息会显示实际合同路径、摘要和 Workspace 协议状态,服务端也会阻止协议不匹配的任务反复派发。
@ -16,7 +18,7 @@
- 专业软件任务失败或取消后会在原对话留下持久通知,页面离线、刷新或对话暂时忙碌时也不会错过结果。
- 查询专业软件是否可用时不再误提交真实任务ANSYS 静力分析会要求使用几何中已有的 Named Selection并在输入缺少命名选择时返回明确错误
- 查询专业软件是否可用时不再误提交真实任务ANSYS 静力分析的旧版命名选择入口已由几何检查和声明式选区入口替换
- Origin 专业软件任务现在会在原 Windows Node 上保留可继续加工的工程;后续调整直接打开上一版工程,过程只提供轻量预览,工程和其他大文件仅在明确要求下载或交付时才传回并登记为正式产物。

View File

@ -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 R2revision 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 建模、同步求解、读取结果对象并在上下文退出时释放 MechanicalNode 以 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 R2revision 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 导入、选择、建模、同步求解、读取结果并在上下文退出时释放 MechanicalNode 以 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 或固定路由到单一控制面实例。

View File

@ -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
View File

@ -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 R2revision 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 R2revision 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

View File

@ -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
}
}
}
}

View File

@ -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": 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 input geometry. Never invent or infer this name; ask the user when the geometry does not provide a known name."
"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}
}
},

View File

@ -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,66 +120,227 @@ 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:
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_PATH), "--probe"],
[sys.executable, str(worker), "--probe"],
check=True,
capture_output=True,
text=True,
)
value = json.loads(completed.stdout)
self.assertEqual(value["adapter_version"], "0.2.0")
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)
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)
@ -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__":

View File

@ -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"}

View File

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

View File

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

View File

@ -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 脚本或独立 EXEmanifest 独立声明每种软件的任务超时及可选 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 脚本或独立 EXEmanifest 独立声明每种软件的任务超时及可选 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

View File

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

View File

@ -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;

View File

@ -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>

View File

@ -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 正在检查几何"
}

View File

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

View File

@ -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",

View File

@ -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 正在进行线性静力分析"
}

View File

@ -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"]),
}
)
if acceptance and geometry.name.casefold() == ACCEPTANCE_FIXTURE:
_create_acceptance_named_selections(model, api)
selections = _named_selections(model, required_names)
preview_selection = app.ExtAPI.SelectionManager.CreateSelectionInfo(
api["SelectionTypeEnum"].GeometryEntities
)
preview_selection.Ids = sorted(set(all_entity_ids))
export_geometry_image(app, api, selection_preview_path, preview_selection)
bodies = list(model.GetChildren(api["DataModelObjectCategory"].Body, True))
if not bodies:
raise RuntimeError("IMPORTED_GEOMETRY_HAS_NO_BODIES")
@ -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,7 +297,10 @@ 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 = {
summary_path = output / "summary.json"
atomic_json(
summary_path,
{
"title": analysis_spec["title"],
"analysis": "static_structural",
"software": "ANSYS Mechanical",
@ -393,16 +309,18 @@ def run(job_dir: Path, *, acceptance: bool = False) -> list[dict[str, Any]]:
"unit_system": analysis_spec["unit_system"],
"material": "Structural Steel",
"mesh": analysis_spec["mesh"],
"named_selections": required_names,
"geometry": geometry_summary,
"resolved_scopes": resolved_summary,
"results": result_summary,
"license_received_message": "requested license was received" in solver_text.casefold(),
}
summary_path = output / "summary.json"
"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",
[],

View File

@ -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]

View File

@ -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"