From 487d139d3954f0d9e258b71669039892587b2d6d Mon Sep 17 00:00:00 2001 From: caoqianming Date: Wed, 19 Aug 2026 09:32:34 +0800 Subject: [PATCH] feat(node): implement guarded Mechanical static solving --- DESIGN.md | 2 +- PROGRESS.md | 6 +- RUN.md | 25 +- ...ansys.mechanical.static_structural.v1.json | 2 +- tests/test_ansys_adapter.py | 93 ++++- tests/test_windows_node_source.py | 26 +- windows-node/README.md | 2 +- .../Zcbot.WindowsNode/AdapterProcessRunner.cs | 121 +++++- .../Zcbot.WindowsNode/ConfigurationForm.cs | 276 ++++++++++++- .../Zcbot.WindowsNode/NodeAdapters.cs | 24 ++ .../acceptance-coupon.step | 173 ++++++++ .../acceptance.py | 226 ++++++++++ .../adapter.json | 2 +- .../worker.py | 385 +++++++++++++++++- 14 files changed, 1330 insertions(+), 33 deletions(-) create mode 100644 windows-node/adapters/ansys.mechanical.static_structural@v1/acceptance-coupon.step create mode 100644 windows-node/adapters/ansys.mechanical.static_structural@v1/acceptance.py diff --git a/DESIGN.md b/DESIGN.md index 272de4f..c3bad76 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -486,7 +486,7 @@ Recipe 是专业软件的声明式目标状态,不是任意脚本或逐次鼠 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 使用独立 `runtimes/ansys` Python 3.12 环境与 240 分钟 Worker 超时,不与 Origin 依赖混用。2024 R2 没有新版 PyMechanical 的进程内许可证管理,周期 probe 只做 v242 安装和绑定探测,真实许可证通过首次求解验收;adapter 会被节点自动发现和上报,但在专用节点完成连续算例、取消、残留进程和许可证释放检查前,由机器级验收门保持 `health=unavailable`,不会进入实际调度。 +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`,不会进入实际调度。 后续仍需实现 Token 轮换;不得以任意命令或脚本接口临时代替。当前 Job 中心采用轮询而非用户事件推送,单活与 offer 选择仍只覆盖单 Web 进程;生产启用多实例前必须增加 Redis/PG fencing 或固定路由到单一控制面实例。 diff --git a/PROGRESS.md b/PROGRESS.md index b423f32..3f4e705 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-08-18(ANSYS Mechanical 2024 R2 Node 能力第一阶段,未发版) +最后更新:2026-08-19(ANSYS Mechanical 2024 R2 真实求解待真机验收,未发版) --- @@ -24,7 +24,9 @@ - **08-18 / Unreleased / Windows Node 多 runtime 安装修复**:修复统一 BAT 安装器在连续处理 Origin 与 ANSYS 时依赖跨子程序 `PYTHON_EXE/PYTHON_ARGS` 状态、第二个 venv 可能执行成空命令的问题;每个缺失 runtime 现在都直接用已验证的显式 Python 3.12 命令创建,不再共享可变命令状态。 -- **08-18 / Unreleased / ANSYS Mechanical 2024 R2 Node 第一阶段**:新增非默认注册的 `ansys.mechanical.static_structural@v1`,以单几何、显式单位、Structural Steel、Named Selection 支撑/力/压力和受控结果建立声明式静力契约,固定 Worker 已完成 v242/PyMechanical probe、二次语义校验及真机验收门,尚未开放真实求解。Windows Node 打包改为自动收集多个 adapter 和共享契约,manifest 可声明每软件 1–1440 分钟超时且兼容旧版 30 分钟默认值,统一安装器为 Origin/ANSYS 创建隔离 Python 3.12 runtime;68 项专项 unittest、Ruff 致命规则与 .NET build 通过,全量 659 项仅 3 个既有数据库集成模块因测试库缺少 `users` 表未通过(另跳过 4 项),未连接 ANSYS 或数据库。 +- **08-19 / Unreleased / ANSYS Mechanical 2024 R2 真实求解联调**:`ansys.mechanical.static_structural@v1` adapter 0.2.0 已接入 PyMechanical 0.11.3 嵌入式 v242 静力流程,导入 CAD Named Selection、应用 Structural Steel/网格/支撑/力或压力,求解并导出 MECHDAT、汇总、CSV 结果表、应力/变形图、solver log 与溯源;Node UI 新增无 Token 的诊断复制、内置 NIST STEP 固定参数真机验收和报告校验后的机器执行门入口,用户无需准备几何,可连续 20 次求解并检查进程释放、执行一次完整进程树取消。首次目标机联调确认 0.11.3 的 globals 不含嵌套 `GeometryImportPreference`,已改用 v242 完整枚举路径;该次在几何导入前失败,未视为许可证验收。真实许可证和释放验收仍须重跑,`ZCBOT_ANSYS_242_VALIDATED` 尚未开启。 + +- **08-18 / Unreleased / ANSYS Mechanical 2024 R2 Node 第一阶段**:新增非默认注册的 `ansys.mechanical.static_structural@v1`,以单几何、显式单位、Structural Steel、Named Selection 支撑/力/压力和受控结果建立声明式静力契约,固定 Worker 已完成 v242/PyMechanical probe、二次语义校验及真机验收门。Windows Node 打包改为自动收集多个 adapter 和共享契约,manifest 可声明每软件 1–1440 分钟超时且兼容旧版 30 分钟默认值,统一安装器为 Origin/ANSYS 创建隔离 Python 3.12 runtime;68 项专项 unittest、Ruff 致命规则与 .NET build 通过,全量 659 项仅 3 个既有数据库集成模块因测试库缺少 `users` 表未通过(另跳过 4 项),未连接 ANSYS 或数据库。 - **08-17 / Unreleased / Software Job 单一完成通知与 Origin PNG/3D 修复**:提交/修订工具把成功排队定义为当前 Agent run 的终点,并在返回值中声明后续由系统自动发布,避免 Agent `sleep`、查询、发布后再收到固定完成消息;Origin adapter 0.9.4 不再使用会绘制文字基线的原生 PNG 路径,改为清理 PDF 基线后按请求 DPI 栅格化,同时在添加图形对象前应用最终画布并把模板图层换算为页相对尺寸,修复 3D 曲面图缩小画布后的越界裁切。专项测试、Python 编译与 diff 检查通过,生产数据库仅做只读任务核查。 diff --git a/RUN.md b/RUN.md index 474398f..75ed172 100644 --- a/RUN.md +++ b/RUN.md @@ -1100,7 +1100,30 @@ install-windows-node.bat 若 Python 未加入 PATH,可把绝对路径作为第一个参数,例如 `install-windows-node.bat "C:\Python312\python.exe"`。默认解释器分别为 `%ProgramData%\Zcbot\WindowsNode\runtimes\origin\Scripts\python.exe` 与 `%ProgramData%\Zcbot\WindowsNode\runtimes\ansys\Scripts\python.exe`。如需使用其他受管解释器,使用机器级 `ZCBOT_ADAPTER_ORIGIN_PYTHON` 或 `ZCBOT_ADAPTER_ANSYS_PYTHON`;旧名 `ZCBOT_ORIGIN_PYTHON` 暂时兼容。`node.json`、可恢复任务和 runtime 集中保存在 `%ProgramData%\Zcbot\WindowsNode\`,不会因替换程序目录而丢失。运行时固定依赖见各 adapter 的 `requirements.txt`;任务请求无权选择解释器、脚本或路径。Origin Worker 支持 CSV/XLSX/JSON 输入及 OPJU/PNG/SVG/PDF 输出,成功产物发布到 `origin//`,plot spec 与 provenance 位于其 `.meta/`;上传中断会在重连时幂等续传。 -ANSYS 第一阶段能力为 `ansys.mechanical.static_structural@v1`,只面向 Mechanical 2024 R2(revision 242)。Node 会从 `adapters/` 自动发现并在每次心跳上报全部能力,服务端管理页只读展示双方已有共享契约的能力,不需要管理员勾选,也不需要现有节点清身份或重新注册;更新 Node 后重启一次即可同步。当前交付先完成契约、安装探测、请求语义校验和执行安全门,默认 probe 返回不可用;在目标机器完成真实许可证与基准算例验收前,不得设置 `ZCBOT_ANSYS_242_VALIDATED=1`。验收至少包括:一个带 `fixed_face/load_face` Named Selection 的 STEP/Parasolid 几何能够完成线性静力求解并导出 MECHDAT、汇总、结果表和应力/变形图;取消能终止完整进程树;连续 20 次执行无残留 Mechanical/solver 进程且许可证均释放。验收通过后才以机器级环境变量开启执行门并重启托盘 Node。当前 Worker 的真实求解实现仍待该专用节点联调,开启安全门不会绕过 `ANSYS_242_SOLVER_IMPLEMENTATION_PENDING`。 +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`。 + +验收使用安装包自带的 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,可不受限制地使用,文件头保留来源说明。 + +节点卡片的“复制诊断信息”可把 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" ` + --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 次正常退出及取消后席位均已归还。任一步失败都不得开门。 + +只有 `acceptance-report.json` 的 `passed` 为 `true` 且许可证管理端确认释放后,才以管理员 PowerShell 设置机器级执行门并重启托盘 Node: + +```powershell +[Environment]::SetEnvironmentVariable("ZCBOT_ANSYS_242_VALIDATED", "1", "Machine") +``` + +重启后 probe 应报告 `health=ready`,再从服务端提交一个同规格小算例验证调度、上传与发布闭环。取消验收若提示 `CANCELLATION_JOB_FINISHED_BEFORE_CANCEL`,说明基准在设定秒数前已经完成,应减小 `--cancel-after` 后使用新的 `--work-root` 重跑,而不是把该次视为通过。 Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动任务或完成后的自动分析约 4 秒刷新一次,空闲时降为约 30 秒;卡片优先显示状态、所属对话、输入输出、完成策略和耗时,Job ID、输出目录、执行节点及软件版本收在“任务详情”中。成功任务可查看结果目录、复制完整 Job ID、深入分析或重新分析。停止已派发任务是协作取消,状态先显示“正在停止”,Node 在线时立即接收,断线后在下次连接或心跳时重放。Agent 可调用 `software_capability_list`、`register_artifact`、`software_job_submit`、`software_job_status`、`software_job_revise` 和 `software_job_cancel`。Origin 输入必须是 artifact:已有 UUID 可直接提交,普通 task 文件先逐个用相对路径登记;登记不会发布聊天交付卡片。提交工具接收 `inputs`、`operation`、`outputs` 和可选的 `completion_action=report|analyze`:只要求生成或保存时用默认 `report`,任务成功后平台向原对话写入固定产物报告;明确要求解释、总结或结论时用 `analyze`,平台在原 task 空闲后自动续跑 Agent。两者均不阻塞提交轮次。请求支持 1–16 个输入、跨输入系列和多个显式输出;`plot.canvas` 可指定毫米画布,轴可指定范围、步长、尺度、刻度角度/字号、标题字号和网格,`legend` 可控制显隐、位置和字号,`series[].style` 可控制颜色、线宽/线型、点型/点大小和透明度。所有排版字段可选,旧请求保持默认样式。成功状态提供 `output_dir`,正式输出的 artifact 带 `software_job_id`,供结果卡和产物详情展示来源。Node 输出上传的逐任务诊断日志位于 `%ProgramData%\Zcbot\WindowsNode\jobs\\logs\node-output-upload.log`;日志包含上传阶段、产物文件名、重试次数和 Windows `HRESULT`,单文件达到 1 MiB 后轮转一份 `.1`,不记录 Node Token 或认证请求头。 diff --git a/software-contracts/ansys.mechanical.static_structural.v1.json b/software-contracts/ansys.mechanical.static_structural.v1.json index 0b9bc25..9e8f1f5 100644 --- a/software-contracts/ansys.mechanical.static_structural.v1.json +++ b/software-contracts/ansys.mechanical.static_structural.v1.json @@ -69,7 +69,7 @@ }, "feature_path": ["operation", "analysis", "type"], "features": { - "static_structural": "0.1.0" + "static_structural": "0.2.0" }, "summary": { "title_path": ["operation", "analysis", "title"] diff --git a/tests/test_ansys_adapter.py b/tests/test_ansys_adapter.py index d42314a..2436284 100644 --- a/tests/test_ansys_adapter.py +++ b/tests/test_ansys_adapter.py @@ -2,15 +2,17 @@ from __future__ import annotations import importlib.util import json +import os import subprocess import sys +import tempfile import unittest from pathlib import Path +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" @@ -65,7 +67,7 @@ class AnsysContractTests(unittest.TestCase): normalized, digest = contract.normalize_request(_request()) self.assertFalse(contract.default_enrollment) self.assertEqual(contract.feature(normalized), "static_structural") - self.assertEqual(contract.required_adapter_version(normalized), "0.1.0") + self.assertEqual(contract.required_adapter_version(normalized), "0.2.0") self.assertEqual(len(digest), 64) self.assertEqual( set(contract.expected_outputs(normalized)), @@ -120,7 +122,7 @@ class AnsysWorkerTests(unittest.TestCase): text=True, ) value = json.loads(completed.stdout) - self.assertEqual(value["adapter_version"], "0.1.0") + self.assertEqual(value["adapter_version"], "0.2.0") self.assertEqual(value["software"], "ANSYS Mechanical") if sys.platform != "win32": self.assertEqual(value["health"], "unavailable") @@ -132,6 +134,91 @@ class AnsysWorkerTests(unittest.TestCase): 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: + def __init__(self, value: float, unit: str) -> None: + 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_acceptance_entry_bypasses_gate_without_enabling_scheduled_execution(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + job_dir = Path(temporary) + (job_dir / "request").mkdir() + record = { + "job_id": str(uuid4()), + "lease_id": str(uuid4()), + "request_digest": "a" * 64, + "request": _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)]), + ): + 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) + + 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_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") + 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) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_windows_node_source.py b/tests/test_windows_node_source.py index 378fa52..99d6283 100644 --- a/tests/test_windows_node_source.py +++ b/tests/test_windows_node_source.py @@ -67,10 +67,34 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertIn("CreateTextBox(usePassword: true)", form) self.assertIn("UseSystemPasswordChar = usePassword", form) self.assertNotIn("NodeToken", form) - self.assertNotIn("Clipboard", form) + self.assertIn("Clipboard.SetText(BuildDiagnosticText(currentConfig))", form) + diagnostics = form.split("private string BuildDiagnosticText", 1)[1].split( + "private async Task RunAnsysAcceptanceAsync", 1 + )[0] + self.assertNotIn("NodeToken", diagnostics) + self.assertIn("内容不包含 Node Token", form) self.assertIn("清除本机身份并重新注册", form) self.assertIn("管理后台删除或禁用云端旧节点", form) + def test_ansys_acceptance_ui_runs_only_fixed_bundled_suite_before_gate(self) -> None: + form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8") + adapters = (PROJECT / "NodeAdapters.cs").read_text(encoding="utf-8") + runner = (PROJECT / "AdapterProcessRunner.cs").read_text(encoding="utf-8") + self.assertIn("ANSYS Mechanical 真机验收", form) + self.assertIn("运行内置基准验收", form) + self.assertNotIn("new OpenFileDialog", form) + self.assertIn("AcceptanceReportPassed", form) + self.assertIn('"ZCBOT_ANSYS_242_VALIDATED", "1"', form) + self.assertIn("EnvironmentVariableTarget.Machine", form) + self.assertIn("SupportsLocalAcceptance", adapters) + self.assertIn('Path.Combine(descriptor.DirectoryPath, "acceptance.py")', runner) + self.assertIn( + 'Path.Combine(descriptor.DirectoryPath, "acceptance-coupon.step")', runner + ) + self.assertIn('["--work-root", root, "--repeat", "20"', runner) + self.assertIn("Adapter acceptance cannot run while a dispatched job is active", runner) + self.assertNotIn("Process.Start", form) + def test_configuration_window_is_resizable_and_dpi_safe(self) -> None: form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8") self.assertIn("ClientSize = new Size(840, 680)", form) diff --git a/windows-node/README.md b/windows-node/README.md index eb1a234..dc54308 100644 --- a/windows-node/README.md +++ b/windows-node/README.md @@ -22,7 +22,7 @@ install-windows-node.bat 安装器是纯 BAT,不依赖 PowerShell:它分别检测 Origin/OriginPro 和 ANSYS Mechanical 2024 R2,依次尝试 `py -3.12` 和 PATH 中的 `python.exe`,并安装隔离的 `origin`/`ansys` runtime;未检测到的软件会明确跳过。最后通过 `schtasks.exe` 为当前专用账号注册登录后启动任务,成功或失败后都会保留窗口。Python 未加入 PATH 时,可在命令提示符执行 `install-windows-node.bat "C:\Python312\python.exe"`。runtime 位于 `%ProgramData%\Zcbot\WindowsNode\runtimes\\Scripts\python.exe`。 -ANSYS adapter 0.1.0 当前只完成 Mechanical 2024 R2 安装/绑定 probe、声明式静力分析契约、二次语义校验、独立 240 分钟超时和真机验收门。契约只接收已登记的 STEP/Parasolid/IGES 几何、Named Selection、固定材料/网格/支撑/力或压力及受控结果,不接收 Python、APDL、Journal、工程文件或本机路径。完成专用节点的许可证、基准算例、取消、残留进程和连续 20 次执行验收前保持不可调度;真实 Mechanical 求解器接线属于下一阶段。 +ANSYS adapter 0.2.0 已实现 Mechanical 2024 R2 / PyMechanical 0.11.3 的真实线性静力流程,可从 STEP/Parasolid/IGES 导入 Named Selection,应用固定材料/网格/支撑/力或压力,并导出 MECHDAT、汇总、结果表、结果图、solver log 和溯源。契约不接收 Python、APDL、Journal、工程文件或本机路径。Node 主窗口提供“复制诊断信息”和“ANSYS Mechanical 真机验收”卡片;验收使用内置 NIST FTC-11 STEP 并自动建立两个端面 Named Selection,用户只需选择报告目录。该入口不受执行门影响,但只有固定的取消与连续 20 次求解全部通过后,界面才允许写入机器级执行门。完成专用节点的许可证、取消、残留进程和许可证释放验收前,能力仍保持不可调度,具体步骤见根目录 `RUN.md`。 注册和运行必须使用同一专用 Windows 账号。MVP 通过该账号的登录后计划任务自动启动,不以 Windows Service 在 Session 0 运行。 diff --git a/windows-node/Zcbot.WindowsNode/AdapterProcessRunner.cs b/windows-node/Zcbot.WindowsNode/AdapterProcessRunner.cs index a60d528..5b137f9 100644 --- a/windows-node/Zcbot.WindowsNode/AdapterProcessRunner.cs +++ b/windows-node/Zcbot.WindowsNode/AdapterProcessRunner.cs @@ -12,6 +12,7 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo private readonly ConcurrentDictionary cancellations = new(); internal bool HasActiveJobs => !active.IsEmpty; + internal string RuntimePath => ResolveCommand(descriptor.EntrypointPath).Filename; internal Task RunAsync(RecoverableJob job) => active.GetOrAdd(job.JobId, _ => RunOnceAsync(job, CancellationFor(job.JobId).Token)); @@ -22,7 +23,7 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo { try { - var command = ResolveCommand(); + var command = ResolveCommand(descriptor.EntrypointPath); using var process = Start(command, descriptor.DirectoryPath, ["--probe"]); var stdout = process.StandardOutput.ReadToEndAsync(); var stderr = process.StandardError.ReadToEndAsync(); @@ -81,6 +82,83 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo private CancellationTokenSource CancellationFor(Guid jobId) => cancellations.GetOrAdd(jobId, _ => new CancellationTokenSource()); + internal async Task RunAcceptanceAsync( + string workRoot, + IProgress progress, + CancellationToken cancellationToken) + { + if (HasActiveJobs) + { + throw new InvalidOperationException( + "Adapter acceptance cannot run while a dispatched job is active."); + } + var root = Path.GetFullPath(workRoot); + var parent = Path.GetDirectoryName(root); + if (Directory.Exists(root) + || string.IsNullOrWhiteSpace(parent) + || !Directory.Exists(parent)) + { + throw new InvalidDataException( + "Acceptance work root must be a new directory under an existing parent."); + } + var acceptancePath = Path.GetFullPath( + Path.Combine(descriptor.DirectoryPath, "acceptance.py")); + var fixturePath = Path.GetFullPath( + Path.Combine(descriptor.DirectoryPath, "acceptance-coupon.step")); + if (!acceptancePath.StartsWith( + descriptor.DirectoryPath + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase) + || !fixturePath.StartsWith( + descriptor.DirectoryPath + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase) + || !File.Exists(acceptancePath) + || !File.Exists(fixturePath)) + { + throw new InvalidDataException("Bundled adapter acceptance fixture is unavailable."); + } + + var command = ResolveCommand(acceptancePath); + using var process = Start( + command, + descriptor.DirectoryPath, + ["--work-root", root, "--repeat", "20", + "--cancel-after", "10", "--release-wait", "60"]); + var stdout = CaptureOutputAsync(process.StandardOutput, progress); + var stderr = CaptureOutputAsync(process.StandardError, null); + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + await TerminateProcessTreeAsync(process); + throw; + } + var output = await stdout; + var error = await stderr; + var reportPath = Path.Combine(root, "acceptance-report.json"); + if (!File.Exists(reportPath)) + { + return new AdapterAcceptanceResult( + false, + reportPath, + string.IsNullOrWhiteSpace(error) + ? $"Acceptance exited with code {process.ExitCode}." + : error[^Math.Min(error.Length, 1000)..]); + } + using var report = JsonDocument.Parse(File.ReadAllBytes(reportPath)); + var passed = report.RootElement.TryGetProperty("passed", out var passedValue) + && passedValue.ValueKind == JsonValueKind.True; + var detail = passed + ? "Cancellation and 20 consecutive solves passed." + : report.RootElement.TryGetProperty("failure", out var failure) + ? failure.ToString() + : string.IsNullOrWhiteSpace(error) + ? output[^Math.Min(output.Length, 1000)..] + : error[^Math.Min(error.Length, 1000)..]; + return new AdapterAcceptanceResult(passed, reportPath, detail); + } + private async Task RunOnceAsync(RecoverableJob job, CancellationToken cancellationToken) { try @@ -101,7 +179,7 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo return; } - var command = ResolveCommand(); + var command = ResolveCommand(descriptor.EntrypointPath); WriteMarker(markerPath, command); using var process = Start(command, jobDirectory, [jobDirectory]); var stdout = process.StandardOutput.ReadToEndAsync(); @@ -114,7 +192,7 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo } catch (OperationCanceledException) { - process.Kill(entireProcessTree: true); + await TerminateProcessTreeAsync(process); if (cancellationToken.IsCancellationRequested) { inbox.WriteTerminal(job, "cancelled", "USER_CANCELLED", "Cancelled by user."); @@ -164,11 +242,11 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo ? suffix == "NODE_RESTARTED_DURING_JOB" ? suffix : $"ORIGIN_{suffix}" : suffix == "NODE_RESTARTED_DURING_JOB" ? suffix : $"ADAPTER_{suffix}"; - private ProcessCommand ResolveCommand() + private ProcessCommand ResolveCommand(string entrypointPath) { if (descriptor.Manifest.Runtime == "executable") { - return new ProcessCommand(descriptor.EntrypointPath, []); + return new ProcessCommand(entrypointPath, []); } var runtimeId = descriptor.Manifest.RuntimeId!; var environmentName = "ZCBOT_ADAPTER_" @@ -192,7 +270,7 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo { throw new InvalidOperationException("Managed Python interpreter is unavailable."); } - return new ProcessCommand(interpreter, [descriptor.EntrypointPath]); + return new ProcessCommand(interpreter, [entrypointPath]); } private Process Start( @@ -254,5 +332,36 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo File.WriteAllText(Path.Combine(logs, "worker-process.json"), value, Encoding.UTF8); } + private static async Task TerminateProcessTreeAsync(Process process) + { + if (process.HasExited) return; + process.Kill(entireProcessTree: true); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException exception) + { + throw new InvalidOperationException( + "Adapter worker process tree did not terminate within 30 seconds.", exception); + } + } + + private static async Task CaptureOutputAsync( + StreamReader reader, IProgress? progress) + { + var output = new StringBuilder(); + while (await reader.ReadLineAsync() is { } line) + { + if (output.Length < 16 * 1024) + { + output.AppendLine(line); + } + progress?.Report(line); + } + return output.ToString(); + } + private sealed record ProcessCommand(string Filename, IReadOnlyList PrefixArguments); } diff --git a/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs b/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs index fdd051d..37fc1a7 100644 --- a/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs +++ b/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs @@ -1,3 +1,8 @@ +using System.Runtime.InteropServices; +using System.Security; +using System.Text; +using System.Text.Json; + namespace Zcbot.WindowsNode; internal sealed class ConfigurationForm : Form @@ -10,6 +15,9 @@ internal sealed class ConfigurationForm : Form private readonly Button register = CreateButton("注册并连接", 128, primary: true); private readonly Button reconnect = CreateButton("立即重连", 112, primary: true); private readonly Button resetIdentity = CreateButton("清除本机身份并重新注册", 220); + private readonly Button copyDiagnostics = CreateButton("复制诊断信息", 132); + private readonly Button runAnsysAcceptance = CreateButton("运行内置基准验收", 180, primary: true); + private readonly Button enableAnsysGate = CreateButton("开启 ANSYS 执行门", 164); private readonly CheckBox startAtLogin = new() { Text = "登录 Windows 后自动启动节点", @@ -25,6 +33,16 @@ internal sealed class ConfigurationForm : Form private readonly Label detail = CreateBodyLabel(); private readonly Label identity = CreateBodyLabel(); private readonly Label capabilitySummary = CreateBodyLabel(); + private readonly Label ansysAcceptanceStatus = CreateBodyLabel(); + private readonly ProgressBar ansysAcceptanceProgress = new() + { + Style = ProgressBarStyle.Marquee, + MarqueeAnimationSpeed = 30, + Height = 8, + Dock = DockStyle.Top, + Visible = false, + Margin = new Padding(0, 8, 0, 5), + }; private readonly Label jobSummary = CreateBodyLabel(); private readonly Label jobDetail = CreateBodyLabel(); private readonly DataGridView jobGrid = CreateJobGrid(); @@ -32,7 +50,11 @@ internal sealed class ConfigurationForm : Form private readonly NodeAdapterRegistry adapters; private readonly System.Windows.Forms.Timer jobRefreshTimer = new() { Interval = 1000 }; private readonly TableLayoutPanel registrationCard; + private readonly TableLayoutPanel ansysAcceptanceCard; private bool changingStartup; + private NodeConfig? currentConfig; + private string? passedAcceptanceReport; + private CancellationTokenSource? acceptanceStop; internal event Func? RegisterRequested; internal event Action? ReconnectRequested; @@ -70,7 +92,7 @@ internal sealed class ConfigurationForm : Form AutoSizeMode = AutoSizeMode.GrowAndShrink, Dock = DockStyle.Top, ColumnCount = 1, - RowCount = 6, + RowCount = 7, BackColor = BackColor, }; page.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); @@ -107,9 +129,22 @@ internal sealed class ConfigurationForm : Form var resetActions = CreateActions(); resetActions.Controls.Add(reconnect); resetActions.Controls.Add(resetIdentity); + resetActions.Controls.Add(copyDiagnostics); statusCard.Controls.Add(resetActions); page.Controls.Add(statusCard); + ansysAcceptanceCard = CreateCard(); + ansysAcceptanceCard.Controls.Add(CreateSectionTitle("ANSYS Mechanical 真机验收")); + ansysAcceptanceCard.Controls.Add(CreateHint( + "执行门关闭时也可本地验收。程序使用自带的标准试件,自动建立 fixed_face 和 load_face;先验证完整进程树取消,再连续求解 20 次并检查结果与进程释放。")); + ansysAcceptanceCard.Controls.Add(ansysAcceptanceStatus); + ansysAcceptanceCard.Controls.Add(ansysAcceptanceProgress); + var acceptanceActions = CreateActions(); + acceptanceActions.Controls.Add(runAnsysAcceptance); + acceptanceActions.Controls.Add(enableAnsysGate); + ansysAcceptanceCard.Controls.Add(acceptanceActions); + page.Controls.Add(ansysAcceptanceCard); + var jobsCard = CreateCard(); jobsCard.Controls.Add(CreateSectionTitle("本机任务")); jobsCard.Controls.Add(CreateHint( @@ -146,6 +181,9 @@ internal sealed class ConfigurationForm : Form register.Click += async (_, _) => await RegisterAsync(); reconnect.Click += (_, _) => ReconnectRequested?.Invoke(); resetIdentity.Click += (_, _) => ResetIdentity(); + copyDiagnostics.Click += (_, _) => CopyDiagnostics(); + runAnsysAcceptance.Click += async (_, _) => await RunAnsysAcceptanceAsync(); + enableAnsysGate.Click += (_, _) => EnableAnsysGate(); startAtLogin.CheckedChanged += (_, _) => ToggleStartup(); FormClosing += (_, eventArgs) => { @@ -159,13 +197,23 @@ internal sealed class ConfigurationForm : Form jobGrid.SelectionChanged += (_, _) => ShowSelectedJob(); jobRefreshTimer.Tick += (_, _) => RefreshJobs(); jobRefreshTimer.Start(); - Disposed += (_, _) => jobRefreshTimer.Dispose(); + Disposed += (_, _) => + { + acceptanceStop?.Cancel(); + acceptanceStop?.Dispose(); + jobRefreshTimer.Dispose(); + }; + var ansysAdapter = adapters.Find("ansys.mechanical.static_structural@v1"); + ansysAcceptanceCard.Visible = ansysAdapter?.SupportsLocalAcceptance == true; + enableAnsysGate.Enabled = false; + UpdateAnsysAcceptanceStatus(); RefreshJobs(); ApplyStatus(NodeStatus.Create(NodeState.NotRegistered, "尚未注册"), null); } internal void ApplyStatus(NodeStatus status, NodeConfig? config) { + currentConfig = config; state.Text = status.State switch { NodeState.Online => "● 在线", @@ -189,6 +237,8 @@ internal sealed class ConfigurationForm : Form capabilitySummary.Text = config is null ? "注册后启用" : string.Join("\n", adapters.All.Select(FormatAdapterStatus)); + copyDiagnostics.Enabled = config is not null; + UpdateAnsysAcceptanceStatus(); var registered = config is not null; registrationCard.Visible = !registered; @@ -215,6 +265,228 @@ internal sealed class ConfigurationForm : Form + runtime.Detail; } + private void CopyDiagnostics() + { + if (currentConfig is null) + { + return; + } + try + { + Clipboard.SetText(BuildDiagnosticText(currentConfig)); + MessageBox.Show( + "诊断信息已复制,可直接粘贴到 Codex 对话。内容不包含 Node Token。", + "已复制", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (ExternalException exception) + { + MessageBox.Show( + $"无法写入剪贴板:{exception.Message}", + "复制失败", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private string BuildDiagnosticText(NodeConfig config) + { + var paths = NodePaths.ForCurrentMachine(); + var builder = new StringBuilder() + .AppendLine("zcbot Windows Node diagnostics") + .AppendLine($"Node: {config.NodeName}") + .AppendLine($"Node ID: {config.NodeId}") + .AppendLine($"Server: {config.ServerUrl}") + .AppendLine($"Node version: {Application.ProductVersion}") + .AppendLine($"OS: {Environment.OSVersion.VersionString}") + .AppendLine($"Data root: {paths.RootDirectory}") + .AppendLine($"ANSYS gate: {AnsysGateState()}"); + foreach (var adapter in adapters.All.OrderBy(item => item.Capability, StringComparer.Ordinal)) + { + try + { + var runtime = adapter.DetectRuntime(); + builder.AppendLine() + .AppendLine($"Capability: {adapter.Capability}") + .AppendLine($"Adapter: {adapter.AdapterVersion}") + .AppendLine($"Software: {runtime.Software} {runtime.SoftwareVersion ?? "unknown"}") + .AppendLine($"Health: {runtime.Health}") + .AppendLine($"Detail: {runtime.Detail}") + .AppendLine($"Runtime: {adapter.RuntimePath}"); + } + catch (Exception exception) when ( + exception is IOException + or InvalidOperationException + or UnauthorizedAccessException) + { + builder.AppendLine() + .AppendLine($"Capability: {adapter.Capability}") + .AppendLine($"Adapter: {adapter.AdapterVersion}") + .AppendLine($"Diagnostic error: {exception.Message}"); + } + } + return builder.ToString(); + } + + private async Task RunAnsysAcceptanceAsync() + { + var adapter = adapters.Find("ansys.mechanical.static_structural@v1"); + if (adapter?.SupportsLocalAcceptance != true) + { + MessageBox.Show( + "当前安装包没有 ANSYS 验收工具。请先安装新版完整 Node 包。", + "无法验收", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + using var outputDialog = new FolderBrowserDialog + { + Description = "选择保存 ANSYS 验收报告的父目录", + UseDescriptionForTitle = true, + ShowNewFolderButton = true, + }; + if (outputDialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + var answer = MessageBox.Show( + "程序会使用内置标准试件执行一次进程树取消和连续 20 次真实静力求解,期间会获取并释放 ANSYS 许可证,可能耗时较长。确认开始?", + "开始 ANSYS 真机验收", MessageBoxButtons.YesNo, MessageBoxIcon.Warning, + MessageBoxDefaultButton.Button2); + if (answer != DialogResult.Yes) + { + return; + } + + var workRoot = Path.Combine( + outputDialog.SelectedPath, + $"zcbot-ansys-acceptance-{DateTime.Now:yyyyMMdd-HHmmss}"); + acceptanceStop?.Dispose(); + acceptanceStop = new CancellationTokenSource(); + runAnsysAcceptance.Enabled = false; + enableAnsysGate.Enabled = false; + ansysAcceptanceProgress.Visible = true; + ansysAcceptanceStatus.ForeColor = Color.DarkOrange; + ansysAcceptanceStatus.Text = "正在启动真机验收…"; + var progress = new Progress(line => + { + ansysAcceptanceStatus.Text = line; + }); + try + { + var result = await adapter.RunLocalAcceptanceAsync( + workRoot, + progress, + acceptanceStop.Token); + passedAcceptanceReport = result.Passed ? result.ReportPath : null; + enableAnsysGate.Enabled = result.Passed && AnsysGateState() != "enabled"; + ansysAcceptanceStatus.ForeColor = result.Passed ? Color.ForestGreen : Color.Firebrick; + ansysAcceptanceStatus.Text = result.Passed + ? $"验收通过。报告:{result.ReportPath}\n请确认许可证管理端席位已释放,再开启执行门。" + : $"验收未通过:{result.Detail}\n报告:{result.ReportPath}"; + } + catch (OperationCanceledException) + { + passedAcceptanceReport = null; + ansysAcceptanceStatus.ForeColor = Color.DimGray; + ansysAcceptanceStatus.Text = "验收已停止,执行门保持关闭。"; + } + catch (Exception exception) when ( + exception is IOException + or InvalidDataException + or InvalidOperationException + or JsonException + or UnauthorizedAccessException) + { + passedAcceptanceReport = null; + ansysAcceptanceStatus.ForeColor = Color.Firebrick; + ansysAcceptanceStatus.Text = $"验收启动或执行失败:{exception.Message}"; + } + finally + { + ansysAcceptanceProgress.Visible = false; + runAnsysAcceptance.Enabled = true; + } + } + + private void EnableAnsysGate() + { + if (passedAcceptanceReport is null || !AcceptanceReportPassed(passedAcceptanceReport)) + { + MessageBox.Show( + "没有可验证的本次验收通过报告,执行门不会开启。", + "无法开启", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + var answer = MessageBox.Show( + "请先在许可证管理端确认正常求解和取消后的席位均已释放。开启后需要完全退出并重新启动 Node 才会进入实际调度。确认开启机器级执行门?", + "开启 ANSYS 执行门", MessageBoxButtons.YesNo, MessageBoxIcon.Warning, + MessageBoxDefaultButton.Button2); + if (answer != DialogResult.Yes) + { + return; + } + try + { + Environment.SetEnvironmentVariable( + "ZCBOT_ANSYS_242_VALIDATED", "1", EnvironmentVariableTarget.Machine); + enableAnsysGate.Enabled = false; + ansysAcceptanceStatus.ForeColor = Color.ForestGreen; + ansysAcceptanceStatus.Text = + "机器级执行门已写入。请从托盘退出 Node 后重新启动;重启后 ANSYS 应显示可用。"; + } + catch (Exception exception) when ( + exception is SecurityException or UnauthorizedAccessException) + { + MessageBox.Show( + $"写入机器级环境变量需要管理员权限:{exception.Message}\n请退出 Node,以管理员身份启动后再次点击。", + "权限不足", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private static bool AcceptanceReportPassed(string path) + { + try + { + using var document = JsonDocument.Parse(File.ReadAllBytes(path)); + return document.RootElement.TryGetProperty("passed", out var passed) + && passed.ValueKind == JsonValueKind.True; + } + catch (Exception exception) when ( + exception is IOException or JsonException or UnauthorizedAccessException) + { + return false; + } + } + + private void UpdateAnsysAcceptanceStatus() + { + if (!ansysAcceptanceCard.Visible || ansysAcceptanceProgress.Visible) + { + return; + } + var gate = AnsysGateState(); + if (passedAcceptanceReport is not null + && AcceptanceReportPassed(passedAcceptanceReport)) + { + ansysAcceptanceStatus.ForeColor = Color.ForestGreen; + ansysAcceptanceStatus.Text = gate == "enabled" + ? "当前执行门:已开启。请完全退出并重新启动 Node,使新环境生效。" + : $"验收通过。报告:{passedAcceptanceReport}\n请确认许可证管理端席位已释放,再开启执行门。"; + enableAnsysGate.Enabled = gate != "enabled"; + return; + } + ansysAcceptanceStatus.ForeColor = gate == "enabled" + ? Color.ForestGreen + : Color.FromArgb(71, 85, 105); + ansysAcceptanceStatus.Text = gate == "enabled" + ? "当前执行门:已开启。Node 重启并且 probe 正常后可接收 ANSYS 任务。" + : "当前执行门:关闭。安装成功不代表验收通过,请先运行本页验收。"; + enableAnsysGate.Enabled = false; + } + + private static string AnsysGateState() => + Environment.GetEnvironmentVariable( + "ZCBOT_ANSYS_242_VALIDATED", EnvironmentVariableTarget.Machine) == "1" + ? "enabled" + : "disabled"; + private void RefreshJobs() { Guid? selectedId = jobGrid.SelectedRows.Count > 0 diff --git a/windows-node/Zcbot.WindowsNode/NodeAdapters.cs b/windows-node/Zcbot.WindowsNode/NodeAdapters.cs index cbcbe55..076e06c 100644 --- a/windows-node/Zcbot.WindowsNode/NodeAdapters.cs +++ b/windows-node/Zcbot.WindowsNode/NodeAdapters.cs @@ -12,7 +12,13 @@ internal interface INodeAdapter IReadOnlyList Features { get; } bool HasActiveJobs { get; } string RunningDetail { get; } + bool SupportsLocalAcceptance { get; } + string RuntimePath { get; } AdapterRuntimeStatus DetectRuntime(); + Task RunLocalAcceptanceAsync( + string workRoot, + IProgress progress, + CancellationToken cancellationToken); bool ValidateRequest(JsonElement request); Task RunAsync(RecoverableJob job); void Cancel(Guid jobId); @@ -28,6 +34,11 @@ internal sealed record AdapterManifest( int WorkerTimeoutMinutes, string RunningDetail); +internal sealed record AdapterAcceptanceResult( + bool Passed, + string ReportPath, + string Detail); + internal sealed record NodeAdapterContract( string Capability, string DisplayName, @@ -225,6 +236,11 @@ internal sealed class ProcessNodeAdapter : INodeAdapter public IReadOnlyList Features => Descriptor.Contract.Features; public bool HasActiveJobs => runner.HasActiveJobs; public string RunningDetail => Descriptor.Manifest.RunningDetail; + public bool SupportsLocalAcceptance => + Capability.Equals( + "ansys.mechanical.static_structural@v1", StringComparison.Ordinal) + && File.Exists(Path.Combine(Descriptor.DirectoryPath, "acceptance.py")); + public string RuntimePath => runner.RuntimePath; public AdapterRuntimeStatus DetectRuntime() { @@ -240,6 +256,14 @@ internal sealed class ProcessNodeAdapter : INodeAdapter public bool ValidateRequest(JsonElement request) => Descriptor.Contract.RequestSchema.Evaluate(request, SchemaOptions).IsValid; + public Task RunLocalAcceptanceAsync( + string workRoot, + IProgress progress, + CancellationToken cancellationToken) => SupportsLocalAcceptance + ? runner.RunAcceptanceAsync(workRoot, progress, cancellationToken) + : throw new InvalidOperationException( + "This adapter does not provide a local acceptance suite."); + public Task RunAsync(RecoverableJob job) => runner.RunAsync(job); public void Cancel(Guid jobId) => runner.Cancel(jobId); } diff --git a/windows-node/adapters/ansys.mechanical.static_structural@v1/acceptance-coupon.step b/windows-node/adapters/ansys.mechanical.static_structural@v1/acceptance-coupon.step new file mode 100644 index 0000000..69d83fd --- /dev/null +++ b/windows-node/adapters/ansys.mechanical.static_structural@v1/acceptance-coupon.step @@ -0,0 +1,173 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('FTC-11 geometry only from the NIST MBE PMI Validation and Conformance Testing Project'),'2;1'); +FILE_NAME('nist_ftc_11_asme1.stp','2015-05-27T17:43:57',(''),(''),'','',''); +FILE_SCHEMA(('CONFIG_CONTROL_DESIGN')); +ENDSEC; +DATA; +#5=APPLICATION_CONTEXT('configuration controlled 3D designs of mechanical parts and assemblies'); +#6=APPLICATION_PROTOCOL_DEFINITION('International Standard','ap203_configuration_controlled_3d_design_of_mechanical_parts_and_assemblies_mim_lf',2004,#5); +#7=PRODUCT_CONTEXT('',#5,'mechanical'); +#8=PRODUCT('nist_ftc_11_asme1','nist_ftc_11_asme1',$,(#7)); +#9=PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#8)); +#10=PRODUCT_DEFINITION_FORMATION('',$,#8); +#11=PRODUCT_DEFINITION_CONTEXT('part definition',#5,'design'); +#12=PRODUCT_DEFINITION('',$,#10,#11); +#18=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.)); +#19=DIMENSIONAL_EXPONENTS(0.0,0.0,0.0,0.0,0.0,0.0); +#20=PLANE_ANGLE_MEASURE_WITH_UNIT(PLANE_ANGLE_MEASURE(0.0174532925),#18); +#24=(CONVERSION_BASED_UNIT('DEGREE',#20)NAMED_UNIT(#19)PLANE_ANGLE_UNIT()); +#28=(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT()); +#32=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.)); +#34=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(0.01),#32,'DISTANCE_ACCURACY_VALUE',''); +#36=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#34))GLOBAL_UNIT_ASSIGNED_CONTEXT((#24,#28,#32))REPRESENTATION_CONTEXT('None','None')); +#37=AXIS2_PLACEMENT_3D('',#38,#39,#40); +#38=CARTESIAN_POINT('',(0.0,0.0,0.0)); +#39=DIRECTION('',(0.0,0.0,1.0)); +#40=DIRECTION('',(1.0,0.0,0.0)); +#41=SHAPE_REPRESENTATION('',(#37),#36); +#42=PRODUCT_DEFINITION_SHAPE('','',#12); +#43=SHAPE_DEFINITION_REPRESENTATION(#42,#41); +#49=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.)); +#50=DIMENSIONAL_EXPONENTS(0.0,0.0,0.0,0.0,0.0,0.0); +#51=PLANE_ANGLE_MEASURE_WITH_UNIT(PLANE_ANGLE_MEASURE(0.0174532925),#49); +#55=(CONVERSION_BASED_UNIT('DEGREE',#51)NAMED_UNIT(#50)PLANE_ANGLE_UNIT()); +#59=(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT()); +#63=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.)); +#65=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(0.000001),#63,'DISTANCE_ACCURACY_VALUE',''); +#67=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#65))GLOBAL_UNIT_ASSIGNED_CONTEXT((#55,#59,#63))REPRESENTATION_CONTEXT('','3D')); +#68=CARTESIAN_POINT('',(0.0,0.0,-0.75)); +#69=DIRECTION('',(0.0,0.0,1.0)); +#70=DIRECTION('',(0.0,-1.0,0.0)); +#71=AXIS2_PLACEMENT_3D('',#68,#69,#70); +#72=CYLINDRICAL_SURFACE('',#71,16.0); +#73=CARTESIAN_POINT('',(0.0,-16.0,-1.499999999999999)); +#74=VERTEX_POINT('',#73); +#75=CARTESIAN_POINT('',(0.0,0.0,-1.499999999999999)); +#76=DIRECTION('',(0.0,0.0,1.0)); +#77=DIRECTION('',(0.0,-1.0,0.0)); +#78=AXIS2_PLACEMENT_3D('',#75,#76,#77); +#79=CIRCLE('',#78,16.0); +#80=EDGE_CURVE('',#74,#74,#79,.T.); +#81=ORIENTED_EDGE('',*,*,#80,.F.); +#82=EDGE_LOOP('',(#81)); +#83=FACE_OUTER_BOUND('',#82,.T.); +#84=CARTESIAN_POINT('',(0.0,-16.0,0.0)); +#85=VERTEX_POINT('',#84); +#86=CARTESIAN_POINT('',(0.0,0.0,0.0)); +#87=DIRECTION('',(0.0,0.0,1.0)); +#88=DIRECTION('',(0.0,-1.0,0.0)); +#89=AXIS2_PLACEMENT_3D('',#86,#87,#88); +#90=CIRCLE('',#89,16.0); +#91=EDGE_CURVE('',#85,#85,#90,.T.); +#92=ORIENTED_EDGE('',*,*,#91,.T.); +#93=EDGE_LOOP('',(#92)); +#94=FACE_BOUND('',#93,.T.); +#95=ADVANCED_FACE('',(#83,#94),#72,.F.); +#96=CARTESIAN_POINT('',(0.0,-23.75,-1.5)); +#97=DIRECTION('',(0.0,0.0,-1.0)); +#98=DIRECTION('',(-1.0,0.0,0.0)); +#99=AXIS2_PLACEMENT_3D('',#96,#97,#98); +#100=PLANE('',#99); +#101=CARTESIAN_POINT('',(0.0,-31.5,-1.500000000000001)); +#102=VERTEX_POINT('',#101); +#103=CARTESIAN_POINT('',(0.0,0.0,-1.500000000000001)); +#104=DIRECTION('',(0.0,0.0,1.0)); +#105=DIRECTION('',(0.0,-1.0,0.0)); +#106=AXIS2_PLACEMENT_3D('',#103,#104,#105); +#107=CIRCLE('',#106,31.5); +#108=EDGE_CURVE('',#102,#102,#107,.T.); +#109=ORIENTED_EDGE('',*,*,#108,.F.); +#110=EDGE_LOOP('',(#109)); +#111=FACE_OUTER_BOUND('',#110,.T.); +#112=ORIENTED_EDGE('',*,*,#80,.T.); +#113=EDGE_LOOP('',(#112)); +#114=FACE_BOUND('',#113,.T.); +#115=ADVANCED_FACE('',(#111,#114),#100,.T.); +#116=CARTESIAN_POINT('',(0.0,0.0,-0.75)); +#117=DIRECTION('',(0.0,0.0,1.0)); +#118=DIRECTION('',(0.0,-1.0,0.0)); +#119=AXIS2_PLACEMENT_3D('',#116,#117,#118); +#120=CYLINDRICAL_SURFACE('',#119,31.5); +#121=CARTESIAN_POINT('',(0.0,-31.5,0.0)); +#122=VERTEX_POINT('',#121); +#123=CARTESIAN_POINT('',(0.0,0.0,0.0)); +#124=DIRECTION('',(0.0,0.0,1.0)); +#125=DIRECTION('',(0.0,-1.0,0.0)); +#126=AXIS2_PLACEMENT_3D('',#123,#124,#125); +#127=CIRCLE('',#126,31.5); +#128=EDGE_CURVE('',#122,#122,#127,.T.); +#129=ORIENTED_EDGE('',*,*,#128,.F.); +#130=EDGE_LOOP('',(#129)); +#131=FACE_OUTER_BOUND('',#130,.T.); +#132=ORIENTED_EDGE('',*,*,#108,.T.); +#133=EDGE_LOOP('',(#132)); +#134=FACE_BOUND('',#133,.T.); +#135=ADVANCED_FACE('',(#131,#134),#120,.T.); +#136=CARTESIAN_POINT('',(-1.293027E-015,4.934325E-016,0.425)); +#137=DIRECTION('',(0.0,0.0,1.0)); +#138=DIRECTION('',(1.0,0.0,0.0)); +#139=AXIS2_PLACEMENT_3D('',#136,#137,#138); +#140=PLANE('',#139); +#141=CARTESIAN_POINT('',(-28.561467761918419,3.497771E-015,0.425)); +#142=VERTEX_POINT('',#141); +#143=CARTESIAN_POINT('',(0.0,0.0,0.425)); +#144=DIRECTION('',(0.0,0.0,-1.0)); +#145=DIRECTION('',(1.0,0.0,0.0)); +#146=AXIS2_PLACEMENT_3D('',#143,#144,#145); +#147=CIRCLE('',#146,28.561467761918419); +#148=EDGE_CURVE('',#142,#142,#147,.T.); +#149=ORIENTED_EDGE('',*,*,#148,.F.); +#150=EDGE_LOOP('',(#149)); +#151=FACE_OUTER_BOUND('',#150,.T.); +#152=CARTESIAN_POINT('',(-18.938532238081581,-2.319301E-015,0.425)); +#153=VERTEX_POINT('',#152); +#154=CARTESIAN_POINT('',(0.0,0.0,0.425)); +#155=DIRECTION('',(0.0,0.0,1.0)); +#156=DIRECTION('',(1.0,0.0,0.0)); +#157=AXIS2_PLACEMENT_3D('',#154,#155,#156); +#158=CIRCLE('',#157,18.938532238081581); +#159=EDGE_CURVE('',#153,#153,#158,.T.); +#160=ORIENTED_EDGE('',*,*,#159,.F.); +#161=EDGE_LOOP('',(#160)); +#162=FACE_BOUND('',#161,.T.); +#163=ADVANCED_FACE('',(#151,#162),#140,.T.); +#164=CARTESIAN_POINT('',(0.0,0.0,0.0)); +#165=DIRECTION('',(0.0,0.0,1.0)); +#166=DIRECTION('',(1.0,0.0,0.0)); +#167=AXIS2_PLACEMENT_3D('',#164,#165,#166); +#168=TOROIDAL_SURFACE('',#167,30.0,1.5); +#169=ORIENTED_EDGE('',*,*,#128,.T.); +#170=EDGE_LOOP('',(#169)); +#171=FACE_OUTER_BOUND('',#170,.T.); +#172=ORIENTED_EDGE('',*,*,#148,.T.); +#173=EDGE_LOOP('',(#172)); +#174=FACE_BOUND('',#173,.T.); +#175=ADVANCED_FACE('',(#171,#174),#168,.T.); +#176=CARTESIAN_POINT('',(0.0,0.0,0.0)); +#177=DIRECTION('',(0.0,0.0,1.0)); +#178=DIRECTION('',(1.0,0.0,0.0)); +#179=AXIS2_PLACEMENT_3D('',#176,#177,#178); +#180=TOROIDAL_SURFACE('',#179,17.5,1.5); +#181=ORIENTED_EDGE('',*,*,#159,.T.); +#182=EDGE_LOOP('',(#181)); +#183=FACE_OUTER_BOUND('',#182,.T.); +#184=ORIENTED_EDGE('',*,*,#91,.F.); +#185=EDGE_LOOP('',(#184)); +#186=FACE_BOUND('',#185,.T.); +#187=ADVANCED_FACE('',(#183,#186),#180,.T.); +#188=CLOSED_SHELL('',(#95,#115,#135,#163,#175,#187)); +#189=MANIFOLD_SOLID_BREP('Solid1',#188); +#190=COLOUR_RGB('Default(1)',0.749019622802734,0.749019622802734,0.584313750267029); +#191=FILL_AREA_STYLE_COLOUR('Default(1)',#190); +#192=FILL_AREA_STYLE('Default(1)',(#191)); +#193=SURFACE_STYLE_FILL_AREA(#192); +#194=SURFACE_SIDE_STYLE('Default(1)',(#193)); +#195=SURFACE_STYLE_USAGE(.BOTH.,#194); +#196=PRESENTATION_STYLE_ASSIGNMENT((#195)); +#197=STYLED_ITEM('',(#196),#189); +#198=MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION('',(#197),#36); +#199=ADVANCED_BREP_SHAPE_REPRESENTATION('ABSR',(#189),#36); +#200=SHAPE_REPRESENTATION_RELATIONSHIP('SRR','None',#199,#41); +ENDSEC; +END-ISO-10303-21; diff --git a/windows-node/adapters/ansys.mechanical.static_structural@v1/acceptance.py b/windows-node/adapters/ansys.mechanical.static_structural@v1/acceptance.py new file mode 100644 index 0000000..008eb70 --- /dev/null +++ b/windows-node/adapters/ansys.mechanical.static_structural@v1/acceptance.py @@ -0,0 +1,226 @@ +"""Run the guarded ANSYS 2024 R2 acceptance suite on a dedicated Windows node.""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import re +import shutil +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path +from uuid import uuid4 + +WORKER = Path(__file__).with_name("worker.py") +BUILTIN_GEOMETRY = Path(__file__).with_name("acceptance-coupon.step") +PROCESS_NAMES = {"ansys.exe", "ansyswbu.exe", "mechanical.exe", "mapdl.exe"} + + +def _request(geometry_id: str) -> dict: + return { + "schema_version": 1, + "inputs": [{"key": "geometry", "artifact_id": geometry_id}], + "operation": { + "analysis": { + "type": "static_structural", + "title": "PyMechanical 2024 R2 acceptance coupon", + "unit_system": "mm_kg_s", + "geometry": {"input": "geometry"}, + "material": "structural_steel", + "mesh": {"global_size": 2.0}, + "boundary_conditions": [ + {"type": "fixed_support", "named_selection": "fixed_face"} + ], + "loads": [ + { + "type": "force", + "named_selection": "load_face", + "components": [0, 0, -1000], + } + ], + "results": [ + "total_deformation", + "equivalent_stress", + "reaction_force", + ], + } + }, + "outputs": [ + {"key": "project", "format": "mechdat"}, + {"key": "stress_image", "format": "png"}, + {"key": "deformation_image", "format": "png"}, + {"key": "solver_log", "format": "txt"}, + ], + } + + +def _stage(root: Path, geometry: Path, label: str) -> Path: + job_dir = root / label + request_dir = job_dir / "request" + input_dir = job_dir / "input" / "geometry" + request_dir.mkdir(parents=True) + input_dir.mkdir(parents=True) + shutil.copyfile(geometry, input_dir / geometry.name) + request = _request(str(uuid4())) + normalized = json.dumps(request, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + record = { + "job_id": str(uuid4()), + "lease_id": str(uuid4()), + "request_digest": hashlib.sha256(normalized.encode("utf-8")).hexdigest(), + "request": request, + } + (request_dir / "request.json").write_text( + json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8" + ) + return job_dir + + +def _mechanical_processes() -> dict[int, str]: + completed = subprocess.run( + ["tasklist.exe", "/fo", "csv", "/nh"], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + processes: dict[int, str] = {} + for row in csv.reader(completed.stdout.splitlines()): + name = row[0].casefold() if row else "" + if len(row) >= 2 and (name in PROCESS_NAMES or re.fullmatch(r"ansys\d{3}\.exe", name)): + processes[int(row[1])] = row[0] + return processes + + +def _wait_for_release(baseline: dict[int, str], timeout_seconds: int) -> dict[int, str]: + deadline = time.monotonic() + timeout_seconds + while True: + remaining = { + pid: name for pid, name in _mechanical_processes().items() if pid not in baseline + } + if not remaining: + return {} + if time.monotonic() >= deadline: + return remaining + time.sleep(2) + + +def _run_success(job_dir: Path, baseline: dict[int, str], release_wait: int) -> dict: + started = time.monotonic() + completed = subprocess.run( + [sys.executable, str(WORKER), "--acceptance", str(job_dir)], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + elapsed = time.monotonic() - started + terminal = json.loads((job_dir / "terminal.json").read_text(encoding="utf-8")) + remaining = _wait_for_release(baseline, release_wait) + if completed.returncode != 0 or terminal["status"] != "succeeded": + raise RuntimeError(f"BENCHMARK_FAILED:{completed.stderr[-500:]}") + if remaining: + raise RuntimeError(f"MECHANICAL_PROCESS_REMAINS:{remaining}") + expected = { + "project", + "summary", + "result_table", + "stress_image", + "deformation_image", + "solver_log", + "analysis_spec", + "provenance", + } + actual = {item["artifact_id"] for item in terminal["artifact_manifest"]} + if actual != expected: + raise RuntimeError(f"ARTIFACT_MANIFEST_MISMATCH:{sorted(actual)}") + return {"elapsed_seconds": elapsed, "terminal": terminal} + + +def _run_cancellation( + job_dir: Path, baseline: dict[int, str], cancel_after: int, release_wait: int +) -> dict: + process = subprocess.Popen( + [sys.executable, str(WORKER), "--acceptance", str(job_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + ) + time.sleep(cancel_after) + if process.poll() is not None: + raise RuntimeError("CANCELLATION_JOB_FINISHED_BEFORE_CANCEL") + killed = subprocess.run( + ["taskkill.exe", "/pid", str(process.pid), "/t", "/f"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + process.wait(timeout=30) + remaining = _wait_for_release(baseline, release_wait) + if killed.returncode != 0: + raise RuntimeError(f"PROCESS_TREE_CANCEL_FAILED:{killed.stderr[-500:]}") + if remaining: + raise RuntimeError(f"CANCELLED_PROCESS_REMAINS:{remaining}") + return {"worker_pid": process.pid, "exit_code": process.returncode} + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("geometry", type=Path, nargs="?", default=BUILTIN_GEOMETRY) + parser.add_argument("--work-root", type=Path, required=True) + parser.add_argument("--repeat", type=int, default=20) + parser.add_argument("--cancel-after", type=int, default=10) + parser.add_argument("--release-wait", type=int, default=60) + args = parser.parse_args() + if sys.platform != "win32": + raise RuntimeError("Acceptance requires Windows") + geometry = args.geometry.resolve(strict=True) + if geometry.suffix.casefold() not in {".step", ".stp", ".x_t", ".x_b", ".iges", ".igs"}: + raise ValueError("Unsupported benchmark geometry") + if args.repeat < 1: + raise ValueError("repeat must be positive") + root = args.work_root.resolve() + root.mkdir(parents=True, exist_ok=False) + baseline = _mechanical_processes() + report = { + "started_at": datetime.now(timezone.utc).isoformat(), + "geometry": str(geometry), + "baseline_processes": baseline, + "runs": [], + } + report_path = root / "acceptance-report.json" + try: + cancel_job = _stage(root, geometry, "cancel") + report["cancellation"] = _run_cancellation( + cancel_job, baseline, args.cancel_after, args.release_wait + ) + print("[OK] Process-tree cancellation completed and released.") + for index in range(1, args.repeat + 1): + job_dir = _stage(root, geometry, f"run-{index:02d}") + report["runs"].append(_run_success(job_dir, baseline, args.release_wait)) + print(f"[OK] Acceptance solve {index}/{args.repeat} completed and released.") + report["passed"] = True + except Exception as exc: + report["passed"] = False + report["failure"] = {"type": type(exc).__name__, "detail": str(exc)[:1000]} + raise + finally: + report["completed_at"] = datetime.now(timezone.utc).isoformat() + report_path.write_text( + json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8" + ) + print(f"[OK] Acceptance passed. Report: {report_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/windows-node/adapters/ansys.mechanical.static_structural@v1/adapter.json b/windows-node/adapters/ansys.mechanical.static_structural@v1/adapter.json index ee5eeae..ab56427 100644 --- a/windows-node/adapters/ansys.mechanical.static_structural@v1/adapter.json +++ b/windows-node/adapters/ansys.mechanical.static_structural@v1/adapter.json @@ -1,6 +1,6 @@ { "capability": "ansys.mechanical.static_structural@v1", - "adapter_version": "0.1.0", + "adapter_version": "0.2.0", "runtime": "python", "runtime_id": "ansys", "entrypoint": "worker.py", diff --git a/windows-node/adapters/ansys.mechanical.static_structural@v1/worker.py b/windows-node/adapters/ansys.mechanical.static_structural@v1/worker.py index baf0f48..3630b40 100644 --- a/windows-node/adapters/ansys.mechanical.static_structural@v1/worker.py +++ b/windows-node/adapters/ansys.mechanical.static_structural@v1/worker.py @@ -1,27 +1,48 @@ -"""Guarded ANSYS Mechanical 2024 R2 adapter bootstrap. +"""ANSYS Mechanical 2024 R2 static-structural adapter. -The first implementation phase validates the fixed declarative job protocol and -probes the managed v242 runtime. Execution remains mechanically disabled until a -dedicated Windows node passes the real-license acceptance suite. The request can -never provide Python, APDL, journal text, executables, URLs, or local paths. +The worker consumes only the Node-created declarative job directory. It never +accepts Mechanical scripts, APDL, journals, URLs, executables, or local paths +from the request. Scheduled execution is guarded by a machine-level acceptance +gate; ``--acceptance`` is the explicit local-only entry point used before that +gate is enabled on a dedicated Windows node. """ from __future__ import annotations +import csv +import hashlib import json import math import os +import shutil import sys +from datetime import datetime, timezone from importlib.metadata import PackageNotFoundError, version from pathlib import Path from typing import Any -ADAPTER_VERSION = "0.1.0" +ADAPTER_VERSION = "0.2.0" ANSYS_REVISION = 242 ANSYS_RELEASE = "2024 R2" EXECUTION_GATE = "ZCBOT_ANSYS_242_VALIDATED" +ACCEPTANCE_FIXTURE = "acceptance-coupon.step" INSTALL_ROOT = Path(os.environ.get("AWP_ROOT242", r"C:\Program Files\ANSYS Inc\v242")) +_UNIT_SYSTEMS = { + "m_kg_s": ("StandardMKS", "m"), + "mm_kg_s": ("StandardNMM", "mm"), +} +_ARTIFACTS = { + "project.mechdat": ("project", "application/octet-stream"), + "summary.json": ("summary", "application/json"), + "result-table.csv": ("result_table", "text/csv"), + "equivalent-stress.png": ("stress_image", "image/png"), + "total-deformation.png": ("deformation_image", "image/png"), + "solver-output.txt": ("solver_log", "text/plain"), + "analysis-spec.json": ("analysis_spec", "application/json"), + "provenance.json": ("provenance", "application/json"), +} + def _probe() -> int: health = "unavailable" @@ -98,25 +119,361 @@ def _load_request(job_dir: Path) -> dict[str, Any]: ) request = record.get("request") if not isinstance(request, dict): - raise ValueError("JOB_REQUEST_MISSING") + raise TypeError("JOB_REQUEST_MISSING") _validate_semantics(request) return record +def _atomic_json(path: Path, value: Any) -> None: + temporary = path.with_name(path.name + ".tmp-" + os.urandom(8).hex()) + try: + with temporary.open("w", encoding="utf-8", newline="\n") as handle: + json.dump(value, handle, ensure_ascii=False, indent=2) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def _file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _input_file(job_dir: Path, key: str) -> Path: + directory = job_dir / "input" / key + files = [ + path for path in directory.iterdir() if path.is_file() and not path.name.startswith(".") + ] + if len(files) != 1: + raise ValueError(f"INPUT_FILE_COUNT_INVALID:{key}") + return files[0] + + +def _manifest(path: Path) -> dict[str, Any]: + artifact_id, media_type = _ARTIFACTS[path.name] + return { + "artifact_id": artifact_id, + "filename": path.name, + "media_type": media_type, + "size_bytes": path.stat().st_size, + "sha256": _file_sha256(path), + } + + +def _validate_artifact(path: Path) -> None: + if not path.is_file() or path.stat().st_size == 0: + raise RuntimeError(f"OUTPUT_EXPORT_EMPTY:{path.name}") + if path.suffix.lower() == ".png" and not path.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n": + raise RuntimeError(f"PNG_EXPORT_INVALID:{path.name}") + if path.suffix.lower() == ".mechdat" and path.stat().st_size < 64: + raise RuntimeError("MECHDAT_EXPORT_INVALID") + + +def _quantity(value: Any) -> dict[str, Any]: + return {"value": float(value.Value), "unit": str(value.Unit)} + + +def _result_rows(result_key: str, scope: str, result: Any) -> list[dict[str, Any]]: + if result_key == "reaction_force": + names = ("x", "y", "z", "total") + values = (result.XAxis, result.YAxis, result.ZAxis, result.Total) + else: + names = ("minimum", "maximum") + values = (result.Minimum, result.Maximum) + return [ + {"result": result_key, "scope": scope, "metric": name, **_quantity(value)} + for name, value in zip(names, values, strict=True) + ] + + +def _write_result_table(path: Path, rows: list[dict[str, Any]]) -> None: + with path.open("w", encoding="utf-8-sig", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=["result", "scope", "metric", "value", "unit"]) + writer.writeheader() + writer.writerows(rows) + + +def _named_selections(model: Any, required: list[str]) -> dict[str, Any]: + available: dict[str, Any] = {} + duplicates: set[str] = set() + for item in model.NamedSelections.Children: + key = str(item.Name).casefold() + if key in available: + duplicates.add(key) + available[key] = item + if duplicates: + raise RuntimeError("IMPORTED_NAMED_SELECTIONS_AMBIGUOUS") + missing = [name for name in required if name.casefold() not in available] + if missing: + raise RuntimeError("NAMED_SELECTION_NOT_FOUND:" + ",".join(missing)) + return {name: available[name.casefold()] for name in required} + + +def _create_acceptance_named_selections(model: Any, api: dict[str, Any]) -> None: + """Scope the two planar faces of the fixed bundled NIST validation coupon.""" + for name, location_z in (("fixed_face", -1.5), ("load_face", 0.425)): + selection = model.AddNamedSelection() + selection.Name = name + selection.ScopingMethod = api["GeometryDefineByType"].Worksheet + criterion = api[ + "Ansys" + ].ACT.Automation.Mechanical.NamedSelectionCriterion() + criterion.Active = True + criterion.Action = api["SelectionActionType"].Add + criterion.EntityType = api["SelectionType"].GeoFace + criterion.Criterion = api["SelectionCriterionType"].LocationZ + criterion.Operator = api["SelectionOperatorType"].Equal + criterion.Value = api["Quantity"](location_z, "mm") + selection.GenerationCriteria.Add(criterion) + selection.Generate() + + +def _export_image(app: Any, api: dict[str, Any], result: Any, path: Path) -> None: + app.Tree.Activate([result]) + app.Graphics.Camera.SetFit() + settings = api["Ansys"].Mechanical.Graphics.GraphicsImageExportSettings() + settings.Resolution = api["GraphicsResolutionType"].EnhancedResolution + settings.Width = 1600 + settings.Height = 1200 + app.Graphics.ExportImage( + str(path), api["GraphicsImageExportFormat"].PNG, settings + ) + _validate_artifact(path) + + +def _copy_solver_log(analysis: Any, target: Path) -> None: + source = Path(str(analysis.WorkingDir)) / "solve.out" + if not source.is_file() or source.stat().st_size == 0: + raise RuntimeError("SOLVER_OUTPUT_NOT_FOUND") + shutil.copyfile(source, target) + + +def run(job_dir: Path, *, acceptance: bool = False) -> list[dict[str, Any]]: + job_dir = job_dir.resolve(strict=True) + record = _load_request(job_dir) + request = record["request"] + analysis_spec = request["operation"]["analysis"] + geometry = _input_file(job_dir, analysis_spec["geometry"]["input"]) + output = job_dir / "output" + output.mkdir(exist_ok=True) + requested_outputs = {item["key"] for item in request["outputs"]} + required_names = [ + item["named_selection"] + for item in analysis_spec["boundary_conditions"] + analysis_spec["loads"] + ] + + from ansys.mechanical.core import App + + result_rows: list[dict[str, Any]] = [] + result_summary: dict[str, list[dict[str, Any]]] = {} + software_version: int | None = None + solution_status = "unknown" + solver_log_path = output / "solver-output.txt" + with App(version=ANSYS_REVISION, private_appdata=True) as app: + software_version = app.version + api: dict[str, Any] = {} + app.update_globals(api) + model = app.Model + unit_enum, length_unit = _UNIT_SYSTEMS[analysis_spec["unit_system"]] + app.ExtAPI.Application.ActiveUnitSystem = getattr( + api["MechanicalUnitSystem"], unit_enum + ) + + geometry_import = model.GeometryImportGroup.AddGeometryImport() + preferences = api["Ansys"].ACT.Mechanical.Utilities.GeometryImportPreferences() + preferences.ProcessNamedSelections = True + preferences.NamedSelectionKey = "" + preferences.ProcessMaterialProperties = False + preferences.ProcessCoordinateSystems = False + geometry_import.Import( + str(geometry), + api["Ansys"].Mechanical.DataModel.Enums.GeometryImportPreference.Format.Automatic, + preferences, + ) + + if acceptance and geometry.name.casefold() == ACCEPTANCE_FIXTURE: + _create_acceptance_named_selections(model, api) + selections = _named_selections(model, required_names) + bodies = list(model.GetChildren(api["DataModelObjectCategory"].Body, True)) + if not bodies: + raise RuntimeError("IMPORTED_GEOMETRY_HAS_NO_BODIES") + for body in bodies: + body.Material = "Structural Steel" + + model.Mesh.ElementSize = api["Quantity"]( + analysis_spec["mesh"]["global_size"], length_unit + ) + analysis = model.AddStaticStructuralAnalysis() + fixed_supports: list[tuple[str, Any]] = [] + for item in analysis_spec["boundary_conditions"]: + support = analysis.AddFixedSupport() + support.Location = selections[item["named_selection"]] + fixed_supports.append((item["named_selection"], support)) + + for item in analysis_spec["loads"]: + if item["type"] == "force": + load = analysis.AddForce() + load.Location = selections[item["named_selection"]] + load.DefineBy = api["LoadDefineBy"].Components + for field_name, value in zip( + ("XComponent", "YComponent", "ZComponent"), + item["components"], + strict=True, + ): + getattr(load, field_name).Output.DiscreteValues = [ + api["Quantity"](value, "N") + ] + else: + load = analysis.AddPressure() + load.Location = selections[item["named_selection"]] + load.Magnitude.Output.DiscreteValues = [ + api["Quantity"](item["magnitude"], "Pa") + ] + + solution = analysis.Solution + requested_results = set(analysis_spec["results"]) + if "deformation_image" in requested_outputs: + requested_results.add("total_deformation") + if "stress_image" in requested_outputs: + requested_results.add("equivalent_stress") + results: dict[str, list[tuple[str, Any]]] = {} + if "total_deformation" in requested_results: + results["total_deformation"] = [("all", solution.AddTotalDeformation())] + if "equivalent_stress" in requested_results: + results["equivalent_stress"] = [("all", solution.AddEquivalentStress())] + if "reaction_force" in requested_results: + reactions = [] + for selection_name, support in fixed_supports: + reaction = solution.AddForceReaction() + reaction.BoundaryConditionSelection = support + reactions.append((selection_name, reaction)) + results["reaction_force"] = reactions + + solution.Solve(True) + solution_status = str(solution.Status) + if "done" not in solution_status.casefold(): + raise RuntimeError(f"MECHANICAL_SOLUTION_NOT_DONE:{solution_status}") + for key, scoped_results in results.items(): + rows = [ + row + for scope, result in scoped_results + for row in _result_rows(key, scope, result) + ] + result_rows.extend(rows) + result_summary[key] = rows + + _copy_solver_log(analysis, solver_log_path) + if "stress_image" in requested_outputs: + _export_image( + app, api, results["equivalent_stress"][0][1], output / "equivalent-stress.png" + ) + if "deformation_image" in requested_outputs: + _export_image( + app, api, results["total_deformation"][0][1], output / "total-deformation.png" + ) + if "project" in requested_outputs: + app.save_as(str(output / "project.mechdat")) + + result_table_path = output / "result-table.csv" + _write_result_table(result_table_path, result_rows) + solver_text = solver_log_path.read_text(encoding="utf-8", errors="replace") + summary = { + "title": analysis_spec["title"], + "analysis": "static_structural", + "software": "ANSYS Mechanical", + "software_version": software_version, + "solution_status": solution_status, + "unit_system": analysis_spec["unit_system"], + "material": "Structural Steel", + "mesh": analysis_spec["mesh"], + "named_selections": required_names, + "results": result_summary, + "license_received_message": "requested license was received" in solver_text.casefold(), + } + summary_path = output / "summary.json" + analysis_spec_path = output / "analysis-spec.json" + provenance_path = output / "provenance.json" + _atomic_json(summary_path, summary) + _atomic_json(analysis_spec_path, request) + _atomic_json( + provenance_path, + { + "adapter_version": ADAPTER_VERSION, + "pymechanical_version": version("ansys-mechanical-core"), + "ansys_revision": ANSYS_REVISION, + "request_digest": record["request_digest"], + "input": { + "key": analysis_spec["geometry"]["input"], + "filename": geometry.name, + "sha256": _file_sha256(geometry), + }, + "outputs": request["outputs"], + }, + ) + + paths = [summary_path, result_table_path, analysis_spec_path, provenance_path] + if "project" in requested_outputs: + paths.append(output / "project.mechdat") + if "stress_image" in requested_outputs: + paths.append(output / "equivalent-stress.png") + if "deformation_image" in requested_outputs: + paths.append(output / "total-deformation.png") + if "solver_log" in requested_outputs: + paths.append(solver_log_path) + else: + solver_log_path.unlink(missing_ok=True) + for path in paths: + _validate_artifact(path) + return [_manifest(path) for path in paths] + + +def _terminal(record: dict[str, Any], status: str, artifacts: list[dict[str, Any]], error: dict[str, str]) -> dict[str, Any]: + return { + "job_id": record.get("job_id", ""), + "lease_id": record.get("lease_id", ""), + "request_digest": record.get("request_digest", ""), + "status": status, + "error": error, + "artifact_manifest": artifacts, + "terminal_at": datetime.now(timezone.utc).isoformat(), + } + + def main() -> int: if sys.argv[1:] == ["--probe"]: return _probe() - if len(sys.argv) != 2: - print("[ERR] Usage: worker.py ", file=sys.stderr) + acceptance = len(sys.argv) == 3 and sys.argv[1] == "--acceptance" + if not acceptance and len(sys.argv) != 2: + print("[ERR] Usage: worker.py [--acceptance] ", file=sys.stderr) return 2 + job_dir = Path(sys.argv[-1]) + record: dict[str, Any] = {} try: - _load_request(Path(sys.argv[1])) - if os.environ.get(EXECUTION_GATE) != "1": + record = _load_request(job_dir) + if not acceptance and os.environ.get(EXECUTION_GATE) != "1": raise RuntimeError("ANSYS_242_REAL_LICENSE_ACCEPTANCE_REQUIRED") - raise RuntimeError("ANSYS_242_SOLVER_IMPLEMENTATION_PENDING") - except (KeyError, OSError, ValueError, json.JSONDecodeError, RuntimeError) as exc: - print(f"[ERR] {exc}", file=sys.stderr) + artifacts = run(job_dir, acceptance=acceptance) + _atomic_json(job_dir / "artifacts.json", artifacts) + _atomic_json(job_dir / "terminal.json", _terminal(record, "succeeded", artifacts, {})) + print("[OK] ANSYS Mechanical job completed.") + return 0 + except Exception as exc: # noqa: BLE001 - terminal.json must capture vendor failures + _atomic_json( + job_dir / "terminal.json", + _terminal( + record, + "failed", + [], + {"code": type(exc).__name__, "detail": str(exc)[:500]}, + ), + ) + print(f"[ERR] {type(exc).__name__}: {exc}", file=sys.stderr) return 1