# 桌面仿真工具开发与协作工作流指南

> 来源：轴向磁拉力仿真 GUI 项目（Motor-CAD + PyQt5 + PyInstaller）
> 适用：任何需要"科学计算内核 + 桌面 GUI + 打包 exe + Git 协作"的工程项目
> 日期：2026-08-27

---

## 一、项目纪律（先立规矩再干活）

| 纪律 | 具体做法 | 为什么 |
|---|---|---|
| 运行前 commit | 每次启动仿真/打包前 `git add -A && git commit` | 仿真可能改坏模型/脚本，可随时回退 |
| 生成物不入库 | `output/`, `build/`, `dist/`, `*.log`, `*.spec` 写入 `.gitignore` | 二进制/临时文件膨胀仓库，diff 无意义 |
| 版本化不覆盖 | 报告/结果文件名带版本号 V1/V2，旧版保留 | 出新版可回溯对比，避免误删 |
| 时间戳记录 | 结果文件名含 `MMDD_HHMMSS`，对话/决策记入 `CONVERSATION_LOG.md` | 跨机器复现、追溯"什么时候改了什么" |
| 原始文件只读 | 原始模型/数据文件不修改，操作在时间戳副本上进行 | 保护输入数据完整性 |

### `.gitignore` 模板

```
# 生成物
output/
build/
dist/
*.log
*.spec
__pycache__/
*.pyc
```

---

## 二、如何跑仿真（Motor-CAD + PyMotorCAD）

### 2.1 环境要求

| 项 | 要求 |
|---|---|
| 系统 | Windows（Motor-CAD 仅 Windows） |
| Motor-CAD | 2026R1 (v261)，安装路径如 `D:\Program Files\ANSYS Inc\v261\motorcad\` |
| Python | ≥ 3.10，`pip install ansys-motorcad-core` |
| 许可证 | FlexNet `ANSYSLMD_LICENSE_FILE=1055@localhost`，lmgrd + ansyslmd 都必须在跑 |

### 2.2 环境变量陷阱（AI 工具 shell 常踩）

非登录 shell（Kimi/Cursor/豆包等）可能**不继承机器级环境变量**，导致：
- `MOTORCAD_ACTIVEX` 为空 → pymotorcad 找不到 Motor-CAD
- `ANSYSLMD_LICENSE_FILE` 为空 → Motor-CAD 启动后 ~30s 静默退出，报 `psutil.NoSuchProcess`

**解决**：运行前 inline 设置，或脚本内回退：
```python
import os
if not os.environ.get("MOTORCAD_ACTIVEX"):
    from ansys.motorcad.core import set_motorcad_exe
    set_motorcad_exe(r"D:\Program Files\ANSYS Inc\v261\motorcad\MotorCAD.exe")
```

### 2.3 核心方法：AFM 轴向力的唯一数据口

Motor-CAD 对轴向磁通电机 (AFM) 的轴向力**没有输出变量、没有 2D 图、帮助文档无记录**。唯一入口：

```python
mc.get_magnetic_3d_graph_point(graph_name, section, node, timestep)
# graph_name ∈ {Fr|Ft}_{Rotor|Stator}_{OL|OC}_Lumped
```

- **Fr（法向力）= 轴向力**（AFM 2.5D 展开模型沿用径向机命名）
- Ft = 切向力（可由 Σ(Ft×r) 交叉核对转矩）
- OL=负载, OC=空载 —— **一次求解两者全出**
- 求解前必须开开关（默认关）：
  ```python
  mc.set_variable("ElectromagneticForcesCalc_Load", True)
  mc.set_variable("ElectromagneticForcesCalc_OC", True)
  ```
- 节点首尾 0°/360° 重复，求和须去重：`if x[-1]-x[0]==360: 丢末点`
- 净力 = 对全部去重节点求和 + 对全部径向切片求和

### 2.4 .mot 参数语义陷阱（AFM 模板易错）

| 参数 | 正确语义 | 常见误读 |
|---|---|---|
| `Magnet_Length` | 磁钢**轴向厚度** | 以为是长度 |
| `Magnet_Thickness` | 磁钢环**径向深度** = (D_out−D_in)/2 | 以为是厚度 |
| `CurrentDefinition=1` | RMS 口径 → 改电流设 `RMSCurrent` | 改 `PeakCurrent` 无效且不报错 |
| `Magnet_Temperature` | 默认 **100°C 热态** | 以为是常温 20°C |
| `Pole_Arc` | 某些转子类型不生效 | 以为是极弧 |
| 极弧实际参数 | `Magnet_Arc_[ED]`（电角度） | — |

### 2.5 前台运行 + 窗口可见性

```python
mc = MotorCAD()          # 前台启动（非 /SCRIPTING 隐藏模式）
mc.set_visible(True)     # 关键：/SCRIPTING 模式默认主窗口隐藏，任务栏有图标点不开
```

> 实测：`/SCRIPTING` 模式下 Motor-CAD 主窗口 `IsWindowVisible=False`，任务栏图标是坐标 (−32000,−32000) 的代理窗口。`set_visible(True)` 后正常显示。这是**双机通用的默认行为**，不是个别机器问题。

### 2.6 三判据验证（全过才采信结果）

1. **作用-反作用**：定/转子合力反号，偏差 < 5%
2. **转矩交叉**：Σ(Ft×r) vs 转矩图，偏差 < 10%
3. **解析量级**：F ≈ A·mean(B²)/(2μ₀) 与 FEA 同量级（解析偏高 ~1.4× 属正常）

### 2.7 仿真脚本标准结构

```python
# 1. 启动 Motor-CAD（set_visible 兜底）
# 2. load_from_file(原始.mot) → save_to_file(时间戳副本.mot)
# 3. set_variable 设置工况（RMSCurrent/ShaftSpeed/Airgap/Magnet_Temperature）
# 4. 开力计算开关
# 5. do_magnetic_calculation()  (~90-150s)
# 6. 读 3D 力图 → 节点去重 → 切片求和 → 净力时间序列
# 7. 三判据计算
# 8. 输出 JSON + CSV（output/ 目录，不入库）
# 9. finally: keep_open? 保持打开 : mc.quit()
```

---

## 三、如何做 exe 应用（PyQt5 + PyInstaller）

### 3.1 技术选型

| 层 | 选择 | 理由 |
|---|---|---|
| GUI 框架 | **PyQt5** | 成熟稳定、控件丰富、QSS 样式表强大、工业软件风 |
| 打包 | **PyInstaller `--onefile --windowed`** | 单 exe 双击即用、无控制台黑窗 |
| 计算核心 | 独立 `solver.py` | 与 GUI 解耦，可单独测试/复用 |
| GUI 主程序 | `main.py` | 仅负责界面 + 线程调度 + 信号槽 |

### 3.2 代码结构（关键：计算与界面分离）

```
project/
├── solver.py          # 计算核心：纯函数/类，无 GUI 依赖，可单元测试
├── main.py            # PyQt5 GUI：参数面板 + 日志 + 结果展示 + 线程
├── requirements.txt   # PyQt5, ansys-motorcad-core, pyinstaller
├── build.bat          # 一键打包脚本
└── dist/
    └── AppName.exe    # 生成物（不入库）
```

### 3.3 后台线程模式（避免 GUI 卡死）

仿真耗时 ~2 分钟，必须在子线程运行，否则 GUI 冻结：

```python
class SolverWorker(QObject):
    log = pyqtSignal(str)           # 实时日志
    result = pyqtSignal(dict)       # 结果数据
    finished = pyqtSignal()
    error = pyqtSignal(str)

    def run(self):
        try:
            from solver import Solver
            s = Solver(..., log_cb=lambda m: self.log.emit(m))
            r = s.run_single()
            self.result.emit(r)
        except Exception as e:
            self.error.emit(traceback.format_exc())
        finally:
            self.finished.emit()

# GUI 中启动
self.thread = QThread()
self.worker = SolverWorker(params)
self.worker.moveToThread(self.thread)
self.thread.started.connect(self.worker.run)
self.worker.log.connect(self._append_log)
self.worker.result.connect(self._on_result)
self.worker.finished.connect(self.thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
self.thread.finished.connect(self.thread.deleteLater)
self.thread.start()
```

### 3.4 打包命令

```bat
pyinstaller --onefile --windowed --name AppName ^
  --hidden-import solver ^
  --collect-submodules ansys ^
  main.py
```

- `--onefile`：单 exe（启动时解压到临时目录，首次 ~3-5s）
- `--windowed`：无控制台窗口
- `--hidden-import solver`：solver.py 是动态导入，需显式声明
- `--collect-submodules ansys`：pymotorcad 子模块多，确保全部收集

### 3.5 闪退防护（5 层，缺一不可）

打包成 `--windowed` 后，**任何未捕获异常都会导致静默闪退**（无控制台可看）。必须层层防护：

| 层 | 防护 | 代码 |
|---|---|---|
| 1 | 全局异常钩子 | `sys.excepthook = custom_handler`（写日志文件 + 弹窗） |
| 2 | 禁止末窗退出 | `app.setQuitOnLastWindowClosed(False)` |
| 3 | 数据安全访问 | 所有 dict 访问用 `(d.get(k) or {}).get(k2)`，禁止链式 `.get().get()` |
| 4 | 槽函数 try-except | 每个 `_on_xxx` 信号槽包裹 try-except，异常写日志不抛出 |
| 5 | 计算对象 keepalive | 全局变量持有 MotorCAD 等长生命周期对象，防止 GC 时连带关闭子进程 |

**全局异常钩子模板**：
```python
def _excepthook(exc_type, exc_value, exc_tb):
    import traceback, time
    msg = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
    with open(os.path.expanduser("~/app_error.log"), "a", encoding="utf-8") as f:
        f.write("\n=== %s ===\n%s" % (time.strftime("%Y-%m-%d %H:%M:%S"), msg))
    QMessageBox.critical(None, "程序异常", str(exc_value))
sys.excepthook = _excepthook
```

**线程清理防竞态**：
```python
def _on_finished(self):
    # 不要立即 self.worker = None / self.thread = None
    # 用 QTimer 延迟清理，避免与 deleteLater 竞态
    QTimer.singleShot(100, lambda: setattr(self, 'worker', None))
    QTimer.singleShot(100, lambda: setattr(self, 'thread', None))
```

### 3.6 打包后验证清单

1. 双击 exe，8 秒内不崩溃 → 基本初始化 OK
2. 跑一次完整仿真 → 确认不闪退、结果正常显示
3. 测试边界情况（空结果、部分字段缺失）→ 确认优雅降级
4. 检查 `~/app_error.log` 是否有未捕获异常

---

## 四、GUI 外观风格要求（现代工业软件风）

### 4.1 设计原则

参考 ANSYS、JetBrains IDE、VS Code 的工业工具风格：
- **简洁**：无多余装饰，信息层级清晰
- **比例得当**：字体大小、间距、卡片尺寸协调
- **功能优先**：结果数据醒目，操作路径短
- **无动画**：工程工具不需要花哨动效

### 4.2 配色方案

| 角色 | 色值 | 用途 |
|---|---|---|
| 背景 | `#eef1f5` | 主窗口浅灰蓝 |
| 卡片/面板 | `#ffffff` | 分组容器 |
| 边框 | `#e2e8f0` | 卡片/输入框边框 |
| 强调色 | `#2563eb` | 按钮、选中态、链接 |
| 主文字 | `#1e293b` | 标题、重要数值 |
| 次文字 | `#64748b` | 标签、说明 |
| 弱文字 | `#94a3b8` | 占位符、辅助信息 |
| 成功 | `#16a34a` | 通过判据、完成状态 |
| 警告 | `#d97706` | 运行中、警告 |
| 错误 | `#dc2626` | 失败、错误 |
| 信息 | `#7c3aed` | 文件路径、解析值 |

### 4.3 字体比例

| 元素 | 字号 | 字重 |
|---|---|---|
| 全局基础 | 10pt | Regular |
| 分组标题 | 11pt | 600 |
| 输入框/按钮 | 10pt | 600（按钮） |
| 结果大数字 | **22pt** | 700 |
| 结果单位 | 11pt | Regular |
| 结果标签 | 9pt | 500，字间距 1px |
| 日志 | 9.5pt | Consolas 等宽 |
| 状态栏 | 9.5pt | Regular |

> 关键：结果数字用大字号（20-24pt），让用户一眼看到核心数据；标签用小字号弱化为辅助信息。

### 4.4 布局规范

- 卡片圆角 **6-8px**，边框 1px `#e2e8f0`
- 卡片内边距 **14-16px**
- 控件间距 **8-10px**
- 输入框最小高度 **22px**，圆角 6px
- 按钮最小高度 **38-42px**（主操作按钮），圆角 6px
- 左右分栏：左侧参数面板固定宽 **380-400px**，右侧结果+日志自适应
- 结果区在上（优先展示），日志区在下（辅助信息）

### 4.5 结果卡片设计

```
┌─────────────────────┐
│ 空载净轴向力          │  ← 9pt 弱色标签
│ 342.9  N             │  ← 22pt 粗体数字 + 11pt 单位
└─────────────────────┘
```

- 白色卡片，圆角 10px，边框 1px
- 数字与单位水平排列，单位弱色
- 成功/不同工况用不同颜色区分数字

### 4.6 日志区美化（拒绝大黑框）

- **浅色终端风**：背景 `#f8fafc`，文字 `#334155`
- 等宽字体 Consolas/Cascadia Code，行高 1.5
- **彩色分级**（HTML 格式 QTextEdit）：
  - 结论/成功 → 绿色 `#16a34a`
  - 错误 → 红色 `#dc2626`
  - 警告 → 橙色 `#d97706`
  - 提示 → 蓝色 `#2563eb`
  - 阶段标题 → 深灰加粗 `#475569`
  - 文件路径 → 紫色 `#7c3aed`
- 圆角边框，内边距 10px

### 4.7 状态栏

- 底部状态栏，深色背景 `#1e293b`，浅色文字
- 左侧：运行状态（就绪 / ● 运行中 / ✓ 完成 / ✗ 出错），颜色区分
- 右侧：总耗时等永久信息

### 4.8 QSS 样式表要点

```python
app.setStyle("Fusion")   # 基础风格，再叠加 QSS
app.setStyleSheet("""
    QGroupBox { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px;
                margin-top: 16px; padding-top: 14px; font-weight: 600; font-size: 11pt; }
    QGroupBox::title { subcontrol-origin: margin; left: 14px; padding: 0 8px; background: #fff; }
    QPushButton { padding: 8px 20px; border-radius: 6px; background: #2563eb; color: #fff;
                  border: 1px solid #2563eb; font-weight: 600; }
    QPushButton:hover { background: #1d4ed8; }
    QPushButton:disabled { background: #94a3b8; border-color: #94a3b8; }
    QLineEdit, QDoubleSpinBox, QSpinBox { padding: 6px 10px; border: 1px solid #cbd5e1;
                                          border-radius: 6px; background: #f8fafc; }
    QLineEdit:focus { border-color: #2563eb; background: #fff; }
    QTextEdit#logView { background: #f8fafc; color: #334155; border: 1px solid #e2e8f0;
                        border-radius: 8px; padding: 10px; }
    QStatusBar { background: #1e293b; color: #e2e8f0; }
    QScrollBar:vertical { width: 10px; background: #f1f5f9; }
    QScrollBar::handle:vertical { background: #cbd5e1; border-radius: 5px; }
""")
```

---

## 五、Git 协作流程

### 5.1 Pull 最新程序

```bash
# 1. 先看本地状态，确保无未提交改动
git status

# 2. 拉取
git pull origin master

# 3. 如果有冲突 → 手动解决后 git add + git commit
# 4. 确认同步
git status -sb   # 应显示 ## master...origin/master（无 ahead/behind）
```

**Pull 前检查清单**：
- 工作区是否干净？有未提交改动先 commit 或 stash
- 当前在哪个分支？`git branch`
- 远程地址对不对？`git remote -v`

### 5.2 推送更新

```bash
# 1. 确保本地已 commit
git add -A
git commit -m "描述: 做了什么, 为什么"

# 2. 先 pull 避免冲突（别人可能也推了）
git pull origin master

# 3. 再 push
git push origin master

# 4. 确认
git log --oneline -3
git status -sb
```

### 5.3 Commit 信息规范

```
<类型>: <简明描述>

类型可选:
  新增  - 新功能/新文件
  修复  - bug 修复
  文档  - 文档/说明更新
  重构  - 代码重构，功能不变
  结果  - 仿真结果/数据
  打包  - exe 打包相关
  合并  - merge

示例:
  修复: 仿真完成后GUI闪退 - None安全访问+全局异常钩子
  新增: PyQt5桌面GUI - 参数面板+结果卡片+彩色日志
  文档: README补充exe不入库原因
```

### 5.4 常见问题

| 现象 | 原因 | 解决 |
|---|---|---|
| `push rejected` | 远程有新提交，本地落后 | 先 `git pull`，解决冲突后再 push |
| `merge conflict` | 同一文件同一行被两边修改 | 手动编辑冲突文件 → `git add` → `git commit` |
| PowerShell 中 git push 报 exit code 1 | git 把进度信息写 stderr，PS 误判为错误 | 看 stdout 是否有 `branch -> branch`，有就是成功了 |
| 大文件 push 慢/失败 | exe/二进制入库了 | 加入 `.gitignore`，`git rm --cached` 移除追踪 |

### 5.5 二进制交付物处理

- **exe 不入库**（40MB+ 二进制，git diff 无意义，仓库膨胀快）
- 源码 + `build.bat` 入库，同事可自行打包
- 交付 exe 时：直接传文件 / 放网盘 / Release 页面
- 在 README 中明确说明"为什么仓库没有 exe"以及"如何自行打包"

---

## 六、排查速查表

| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| Motor-CAD 启动后 30s 退出 | 许可证 ansyslmd 未运行 | 检查 ANSYS License Management Center |
| pymotorcad 报 NoSuchProcess | 同上，或环境变量未继承 | inline export 两个环境变量 |
| 力结果全为 0/空 | 力计算开关未开 | 确认 `ElectromagneticForcesCalc_Load/OC=True` |
| 改电流无效 | CurrentDefinition=1 时改了 PeakCurrent | 改 `RMSCurrent` |
| 力值比预期小很多 | 磁钢温度 100°C vs 报告 20°C | F∝Br²，按温度系数折算 |
| GUI 点运行后卡死 | 仿真在主线程跑了 | 必须用 QThread 子线程 |
| exe 双击闪退 | 未捕获异常 + windowed 模式 | 加全局 excepthook，查 `~/app_error.log` |
| exe 运行正常但结果不显示 | 结果处理函数中 None 访问异常 | 所有 dict 访问加 `or {}` 安全兜底 |
| Motor-CAD 窗口看不见 | /SCRIPTING 模式默认隐藏 | `mc.set_visible(True)` |
| 打包后缺模块 | 动态导入未被 PyInstaller 发现 | 加 `--hidden-import` / `--collect-submodules` |

---

## 七、新项目快速启动清单

1. [ ] 初始化 git 仓库，写 `.gitignore`
2. [ ] 写 `AGENTS.md` / `README.md`（项目说明 + 纪律 + 快速开始）
3. [ ] 确认计算环境（软件版本、许可证、Python 包）
4. [ ] 先写**计算核心脚本**（无 GUI），跑通并验证数值
5. [ ] 建立结果验证判据（三判据或等效方法）
6. [ ] 提取计算逻辑为 `solver.py`（可独立调用）
7. [ ] 写 PyQt5 GUI（参数面板 + 日志 + 结果卡片 + 子线程）
8. [ ] 加 5 层闪退防护
9. [ ] 开发模式测试 → 边界测试 → 打包 exe 测试
10. [ ] 写 `build.bat` + `requirements.txt` + 使用说明
11. [ ] commit → 同事 review → push
12. [ ] 对话/决策记录入 `CONVERSATION_LOG.md`
