38 Коміти de7761eadc ... d326b5df30

Автор SHA1 Опис Дата
  carlin d326b5df30 fix(analytics): derive metric defs from single source of truth (add thermal/structural metrics) 1 день тому
  carlin 553a04e22f feat(thermal): hardwire ambient_temperature into executor config (default 25C) 1 день тому
  carlin f5749a94a7 test(thermal): add verify_enable_thermal.py + TEST-061 end-to-end PASS 1 день тому
  carlin ad55b2145e feat(thermal): add --ambient override + verify temp-rise turns positive; fix test_executor_m3 1 день тому
  carlin a9738cd75a docs(thermal): record coupled-vs-steady benchmark + executor enable_thermal plumbing + git recovery 1 день тому
  carlin b8dab74ec7 chore(repo): rebuild git after object corruption + thermal simulation work 1 день тому
  carlin de7761eadc fix: AI plan generation timeout - frontend 30s->180s, backend max_tokens 4000->2000 1 тиждень тому
  carlin 17f81b561f fix: frontend testing fixes - TaskManager unicode + DB auto-migration 1 тиждень тому
  carlin b4f3971f3f fix: second-round P1 and risk-item fixes from code review 1 тиждень тому
  carlin d64345683c fix: address third-party code review V1.0 P0 issues (20+ fixes) 1 тиждень тому
  carlin ce4fe7abb3 docs: add TEST-003 record - expanded metric parsing verified (7->14 metrics) 1 тиждень тому
  carlin 4159bf7161 docs: add project discipline (README update mandatory), test records doc, and P4 completion in README 1 тиждень тому
  carlin 6a8bc80cba enhance: expand metric aliases with Chinese field names from actual export; add popup suppression in test 1 тиждень тому
  carlin f3b492ab6b fix: export_results requires solution_type parameter for pymotorcad 0.8.8 1 тиждень тому
  carlin 35b3f2aad3 enhance(robust_motorcad): Integrate 6 enhancements from official Motor-CAD automation reference doc 1 тиждень тому
  carlin fb5a72e4d3 feat(P4-M4): Advanced visualization and auto report generation 1 тиждень тому
  carlin 315383c1f2 feat(P4-M3): Batch scheduler, real-time monitoring, robust MotorCAD core 1 тиждень тому
  carlin 25ae70f334 feat(P4-M2): Web-Local task dispatch and result callback (end-to-end loop) 1 тиждень тому
  carlin 0da99ed010 feat(P4-M1): Frontend AI integration - 6 AI pages + API wrapper + components 1 тиждень тому
  carlin 9835583f5e docs: update README with Phase 3 completion and P3 capabilities 1 тиждень тому
  carlin 2d71424f17 feat(P3-M5): Experience AI enhancement + adaptive closed loop + acceptance 1 тиждень тому
  carlin 29f6cbd4c0 feat(P3-M4): AI result analyst + multi-fidelity calibration + confidence grading 1 тиждень тому
  carlin c12a8e6fbf feat(P3-M3): AI plan generator - natural language to structured simulation plan 1 тиждень тому
  carlin 7dab664911 feat(P3-M2): L0 analytic pre-screening + feasibility-first adaptive search 1 тиждень тому
  carlin 0df69f2730 feat(P3-M1): AI service layer + JSON Schema V2 + multi-fidelity framework 1 тиждень тому
  carlin 2660df74a9 feat(ui): Chinese localization + dashboard chart alignment fix 1 тиждень тому
  carlin 37d6616442 feat(P2-M5): Phase 2 acceptance - automated tests + frontend manual verification 1 тиждень тому
  carlin 25ee19f276 feat(P2-M4): dual-system API integration + knowledge base management 1 тиждень тому
  carlin 988543784c feat(P2-M3): experience library enhancement + results analytics dashboard 1 тиждень тому
  javen.ye 749e7c817a docs: update README to V0.3 - Phase 1 complete + Phase 2 P2-M1/P2-M2 complete 1 тиждень тому
  javen.ye 2c2e5de1a1 docs(P2-M2): update conversation log with P2-M2 completion record + add integration test scripts 1 тиждень тому
  javen.ye a95c0db941 feat(P2-M2): boundary conditions input + visual plan editor + rule engine 1 тиждень тому
  javen.ye 1ea7653c62 feat(P2-M1): Web端基础框架 - FastAPI后端 + Vue3前端 + SQLite + 双系统API客户端 1 тиждень тому
  javen.ye f20bef9a91 feat(M4): experience database + feedback recommendation loop 1 тиждень тому
  javen.ye cbd940228b feat(M3): simulation plan schema + PySide6 GUI 1 тиждень тому
  javen.ye 41b4e6747e docs: M1+M2 completion records with airgap scan validation results 1 тиждень тому
  javen.ye a43d971980 feat(M2): parameter scan engine + CLI entry point 1 тиждень тому
  javen.ye 75347b6114 init: Phase 1 M1 - project scaffold + single-point simulation verified 1 тиждень тому
100 змінених файлів з 27521 додано та 1240 видалено
  1. 14 0
      .gitignore
  2. 84 9
      AGENTS.md
  3. 403 0
      CHANGELOG.md
  4. 250 0
      MotorCAD软件教程及故障处理防范/MotorCAD脚本自动化仿真参考资料.md
  5. 0 0
      PCB轴向磁通电机Motor-CAD仿真策略评审与实施建议.md
  6. 96 2
      PCB轴向磁通电机自动化仿真系统设计方案介绍.md
  7. 134 172
      README.md
  8. 610 0
      ai-collab-dev-playbook-v2.md
  9. 1 1
      deploy.ps1
  10. 463 0
      docs/CONVERSATION_LOG.md
  11. 144 0
      docs/HANDOFF.md
  12. 117 15
      docs/KNOWLEDGE_BASE.md
  13. 545 0
      docs/P1-P5交付总结与上手指南.md
  14. 673 0
      docs/PAPER_KNOWLEDGE_BASE.md
  15. 155 0
      docs/PLATFORM_DESIGN_V2.md
  16. 2036 2
      docs/TEST_RECORDS.md
  17. 286 0
      docs/archive/P1-P4回顾与P5规划.md
  18. 0 0
      docs/archive/P3-评审响应与更新计划.md
  19. 254 0
      docs/archive/P3_IMPLEMENTATION_PLAN.md
  20. 142 0
      docs/archive/P4_IMPLEMENTATION_PLAN.md
  21. 11 0
      docs/archive/README.md
  22. 148 0
      docs/前端界面优化建议_V1.md
  23. 12 0
      executor_config.json
  24. 28 0
      scripts/build_executable.ps1
  25. 193 0
      scripts/check_machine_paths.py
  26. 224 0
      scripts/executor_config.py
  27. 109 113
      scripts/robust_motorcad.py
  28. 169 0
      scripts/run_task_executor.py
  29. 75 0
      scripts/run_task_executor_parallel.py
  30. 218 0
      scripts/run_thermal.py
  31. 259 54
      scripts/task_executor.py
  32. 214 0
      scripts/test_adapters.py
  33. 222 0
      scripts/test_executor_config.py
  34. 80 0
      scripts/test_executor_m3.py
  35. 122 0
      scripts/test_executor_p5m2.py
  36. 241 0
      scripts/test_metrics_extension.py
  37. 159 0
      scripts/test_p3_adaptive_execution.py
  38. 91 0
      scripts/test_p3_checkpoint.py
  39. 153 0
      scripts/test_p3_closed_loop.py
  40. 109 0
      scripts/test_p3_concurrency.py
  41. 86 0
      scripts/test_p3_m4_contract.py
  42. 134 0
      scripts/test_p3_orchestrator.py
  43. 154 0
      scripts/test_p3_unit_edge.py
  44. 80 0
      scripts/test_p4_m4_convergence.py
  45. 91 0
      scripts/test_p4_m5_l0.py
  46. 153 0
      scripts/test_p4_schema.py
  47. 209 0
      scripts/test_plan_schema.py
  48. 199 0
      scripts/test_platform_registry.py
  49. 147 0
      scripts/test_search_state_summary.py
  50. 199 0
      scripts/test_strategy_morris.py
  51. 255 0
      scripts/test_strategy_surrogate.py
  52. 277 0
      scripts/test_topology_variable_map.py
  53. 85 0
      scripts/verify_enable_thermal.py
  54. 7 0
      src/afmcore/__init__.py
  55. 164 0
      src/afmcore/adapters/__init__.py
  56. 132 0
      src/afmcore/adapters/jmag.py
  57. 142 0
      src/afmcore/adapters/maxwell.py
  58. 196 0
      src/afmcore/adapters/motorcad.py
  59. 1 0
      src/afmcore/l0/__init__.py
  60. 445 0
      src/afmcore/l0/prescreening.py
  61. 759 0
      src/afmcore/metrics.py
  62. 133 0
      src/afmcore/strategies/__init__.py
  63. 114 0
      src/afmcore/strategies/adaptive.py
  64. 82 0
      src/afmcore/strategies/full_factorial.py
  65. 101 0
      src/afmcore/strategies/lhs.py
  66. 247 0
      src/afmcore/strategies/morris.py
  67. 359 0
      src/afmcore/strategies/surrogate_guided.py
  68. 362 0
      src/afmcore/topology.py
  69. 250 14
      src/plan_schema.py
  70. 21 265
      src/solver_core.py
  71. 9080 0
      testcase/test1.mot
  72. 3 2
      web/backend/app/config.py
  73. 16 0
      web/backend/app/database.py
  74. 11 1
      web/backend/app/main.py
  75. 23 106
      web/backend/app/metrics_constants.py
  76. 6 0
      web/backend/app/models/task.py
  77. 42 1
      web/backend/app/routers/adaptive.py
  78. 251 4
      web/backend/app/routers/ai_plan.py
  79. 29 0
      web/backend/app/routers/analytics.py
  80. 45 0
      web/backend/app/routers/executor_monitor.py
  81. 193 0
      web/backend/app/routers/experience.py
  82. 13 0
      web/backend/app/routers/generation.py
  83. 675 1
      web/backend/app/routers/plans.py
  84. 23 0
      web/backend/app/routers/projects.py
  85. 5 0
      web/backend/app/routers/search.py
  86. 66 7
      web/backend/app/routers/tasks.py
  87. 233 7
      web/backend/app/services/adaptive_loop.py
  88. 20 4
      web/backend/app/services/ai_client.py
  89. 26 11
      web/backend/app/services/analytics.py
  90. 18 1
      web/backend/app/services/batch_scheduler.py
  91. 151 0
      web/backend/app/services/bc_fields.py
  92. 16 8
      web/backend/app/services/experience_enhancer.py
  93. 182 2
      web/backend/app/services/feasibility_search.py
  94. 544 0
      web/backend/app/services/fixed_params_template.py
  95. 8 421
      web/backend/app/services/l0_prescreening.py
  96. 439 6
      web/backend/app/services/plan_generator.py
  97. 75 11
      web/backend/app/services/report_generator.py
  98. 57 0
      web/backend/app/services/rule_engine.py
  99. 361 0
      web/backend/app/services/strategy_orchestrator.py
  100. 82 0
      web/backend/app/services/task_contract.py

+ 14 - 0
.gitignore

@@ -53,3 +53,17 @@ venv/
 *.tmp
 *.bak
 *~
+
+# === git 损坏备份(恢复过程临时目录,不入库) ===
+.git.corrupted.bak/
+
+# === WorkBuddy 项目数据(不入库) ===
+.workbuddy/
+
+# === 用户个人资料(截图 / 外部评审文档,不入库) ===
+*.png
+*.docx
+第三方评审/
+
+# === 文件名超长(Windows 路径 260 字符限制),git 无法索引 ===
+书籍与论文/相关论文-Chulaee*/

+ 84 - 9
AGENTS.md

@@ -2,23 +2,40 @@
 
 > 本文件面向 Claude Code / Codex / Cursor / 豆包等 AI 编程工具。
 > 接到任何任务前,**先读 [docs/KNOWLEDGE_BASE.md](docs/KNOWLEDGE_BASE.md)**。
+> 方法论框架:`ai-collab-dev-playbook-v2.md`(五支柱 + 五步启动法,新项目可复用)。
+> 接续入口:换人/换机/新会话先读 `docs/HANDOFF.md`。
 
 ## 项目定位
 
 PCB轴向磁通电机自动化仿真系统 — 双系统解耦架构:
-- **系统一(Web端)**:方案生成与优化(Phase 2+)
-- **系统二(本地EXE)**:仿真执行(当前Phase 1重点)
+- **系统一(Web端)**:方案生成与优化(FastAPI + Vue3,AI 闭环 / 自适应搜索 / 经验库)
+- **系统二(本地EXE)**:仿真执行(Motor-CAD / Maxwell / JMAG 适配器,批量调度)
+- **共享核心层** `src/afmcore/`:指标 / 拓扑 / 适配器 / 策略 / L0 预筛选的单一事实源
 
-当前处于 **Phase 1 最小闭环开发**:本地GUI方案编辑 → Motor-CAD自动化仿真 → 结果输出 → 经验库积累。
+当前状态:**P1~P5 全部完成,P6 前端体验优化进行中**(P6-M1 已完成,P6-M2/M3 待办)。
+进度、阻塞点、待办的唯一权威来源:`docs/HANDOFF.md` 第 3 节。
 
-## 开始工作前必须阅读(按顺序)
+## 开始工作前必须阅读
 
-1. `docs/KNOWLEDGE_BASE.md` — 核心知识库(环境事实、参数语义、探测技术、SOP、已踩的坑)
-2. `README.md` — 项目说明、目录结构、快速开始
-3. `PCB轴向磁通电机自动化仿真系统设计方案介绍.md` — 完整设计方案V1.1(架构、接口、算法选型)
-4. 参考案例的知识库:
+**最小必读集**(新会话至少读这些):
+1. `docs/HANDOFF.md` — 当前进度、阻塞点、待办、接续提示词(含环境恢复步骤)
+2. `docs/KNOWLEDGE_BASE.md` — 核心知识库(环境事实、参数语义、探测技术、SOP、已踩的坑),重点 §1 环境事实与 §3 参数语义
+
+**按需查阅**:
+3. `README.md` — 项目说明、目录结构、快速开始(历史更新见 `CHANGELOG.md`)
+4. `PCB轴向磁通电机自动化仿真系统设计方案介绍.md` — 完整设计方案V2.0(架构、接口、算法选型)
+5. `docs/P1-P5交付总结与上手指南.md` — 新人上手与模块定位
+6. 参考案例的知识库:
    - `axial_mag_pull-master/axial_mag_pull/docs/KNOWLEDGE_BASE.md`
    - `torqrippswap-master/torqrippswap/MOTORCAD_SCAN_KNOWLEDGE_BASE.md`
+7. 已完结的历史计划/评审文档见 `docs/archive/`
+
+## 环境体检(换机/新会话第一条命令)
+
+```powershell
+python scripts/check_machine_paths.py        # 只读核对环境,缺项会给修复建议
+python scripts/check_machine_paths.py --fix  # 打印精确修复命令(不自动执行)
+```
 
 ## 参考案例代码(可直接复用/改造)
 
@@ -116,7 +133,8 @@ if not os.environ.get("MOTORCAD_ACTIVEX"):
 - README 更新应与代码提交在同一个 commit 中,或紧随其后
 
 ### 2. 每次测试必须工作留痕
-- 每次实际运行 Motor-CAD 仿真、API 测试、集成测试后,**必须**记录到 `docs/TEST_RECORDS.md`
+- 每次实际运行 Motor-CAD 仿真、API 测试、集成测试后,**必须**记录到 `docs/TEST_RECORDS.md`(含索引表 + 详细记录)
+- 每次对话(含关键决策、技术选择、问题排查)**必须**记录到 `docs/CONVERSATION_LOG.md`,格式:`## YYYY-MM-DD — 主题` → 用户要求 → 本次完成 → 遗留问题
 - 记录内容包括:测试日期、测试环境、测试目的、测试步骤、测试结果(成功/失败)、关键数据、发现的问题、修复措施
 - 测试输出文件(CSV/JSON/log)保留在 `output/` 目录中,不入库但在记录中注明路径
 - 失败的测试也要记录,包括失败原因和后续修复
@@ -130,3 +148,60 @@ if not os.environ.get("MOTORCAD_ACTIVEX"):
 ### 4. 生成物不入库
 - `output/`、`runs/`、`build/`、`dist/`、`*.log`、`*.spec`、`__pycache__/` 不入库
 - 关键数值转录进入库的文档(TEST_RECORDS.md / RESULTS.md / 报告)
+
+## 反模式自查(交付前对照,命中即返工)
+
+| 反模式 | 对策 |
+|---|---|
+| 不读文档直接开工 | 先读 HANDOFF/KNOWLEDGE_BASE,先报告理解再动手 |
+| 验收凭"看起来对" | 量化基准 + 独立验证(校验方式≠产出方式) |
+| 踩坑不记录 | 坑立刻追加到 KNOWLEDGE_BASE / AGENTS 铁律 |
+| 环境假设不检测 | 换机先跑 check_machine_paths.py |
+| 上下文只留在对话里 | 写 CONVERSATION_LOG 持久化 |
+| 多处定义同一概念 | 走 src/afmcore 单一事实源 |
+| 编造未验证的数值/接口 | 禁臆测 + 标注"待验证" |
+| 阶段完成不更新文档 | 每 P/M 完成立即更新 README |
+
+## 接续与交接
+
+- 换人/换机/新会话:先读 `docs/HANDOFF.md`(进度/阻塞/待办/接续提示词),再跑 `python scripts/check_machine_paths.py`
+- 阶段末(每 Phase/Milestone):更新 HANDOFF.md 的「当前进度/阻塞点/待办」+ README「最近更新」
+- 让下一个 AI 会话 10 分钟接上:整段粘贴 HANDOFF.md 第 4 节「接续提示词」
+
+## 工程规范 — 严格执行
+
+> 适用于所有代码、测试与交付,优先级高于"完成速度"。任何输出在交付前必须过一遍本节。
+
+### 1. 禁止臆测
+- 不得编造任何未实际验证的结果、数值、接口行为或"应该能跑"的结论。
+- 若无法运行或测试某段代码/场景,必须明确说明:**"我无法执行此测试,以下是我的推理/建议"**,并给出理由与降级方案,不得冒充已验证。
+
+### 2. 测试完备
+- 每段交付代码必须附带**可运行的测试用例**,至少覆盖:
+  - 正常路径(happy path)
+  - 边界条件(极值、上下限、最大/最小)
+  - 异常输入(非法值、缺字段、类型错误)
+  - 空值/零值场景
+- 测试脚本统一放 `scripts/test_*.py`,命名与被测模块对应;`python scripts/test_*.py` 可独立运行,exit 0 = PASS。
+- 无法自动化的场景(如真实 Motor-CAD 求解、真实 AI 调用)必须给出可复现的手动测试步骤,或在 TEST_RECORDS.md 标注"待验证"。
+
+### 3. 代码规范
+- 遵循语言标准规范(Python 遵循 PEP8;前端遵循项目既有风格)。
+- 关键逻辑必须有注释;复杂函数/类必须有 docstring(函数用途、参数、返回值、异常)。
+- 沿用 ASCII 约束(见"硬性工程约束 §1"),docstring 用英文。
+
+### 4. 自查清单(交付前逐项确认,回复中标注 ✅/❌)
+- [ ] 代码已通读一遍,无语法错误和明显逻辑漏洞
+- [ ] 所有测试用例已列出,且能描述预期输入与输出
+- [ ] 已考虑边界情况(空值、极值、并发、超时、资源耗尽等)
+- [ ] 已考虑错误处理路径(异常捕获、回滚、降级)
+- [ ] 如果涉及多模块,已确认接口契约和数据流向
+- [ ] 如果无法实际运行测试,已明确告知用户
+
+### 5. 交付声明
+- 只有完成上述自查并确认无误后,才能说"已完成/已通过"。
+- 否则必须使用"**草案待验证**"或"**需要您协助测试**",并说明缺口。
+
+### 6. 迭代修正
+- 若用户反馈测试失败,必须:复现问题 → 定位根因 → 修复 → **重新走一遍自查清单** → 再回复。
+- 不得仅口头致歉后跳过复现与修复。

+ 403 - 0
CHANGELOG.md

@@ -0,0 +1,403 @@
+# CHANGELOG — PCB轴向磁通电机自动化仿真系统
+
+> 本文件承接 README.md 的全部历史更新记录(按时间倒序)。
+> README 只保留最近更新的摘要表;**当前项目状态以 [docs/HANDOFF.md](docs/HANDOFF.md) 第 3 节为唯一权威来源**。
+> 记录规范:新条目加在最上方,格式 `## YYYY-MM-DD — 主题`,里程碑明细另见 `docs/P1-P5交付总结与上手指南.md`。
+
+---
+
+## 2026-09-01 — AI 协作方法论框架升级(Playbook V2)
+
+**背景**:项目已沉淀完整工程纪律(AGENTS.md 硬约束 + TEST_RECORDS + 单一事实源 + 里程碑管理),但缺 playbook 的“接续/换机/上下文延续”机制。本次融合本项目框架与 ai-collab-dev-playbook.md(MARS 电机热仿真方法论)产出 V2 框架,并落地本项目。
+
+**新增/修改**:
+1. 新增 `ai-collab-dev-playbook-v2.md`:五支柱文件体系(README/AGENTS/HANDOFF/KNOWLEDGE_BASE/留痕双件套)+ 五步启动法 V2 + 全套模板(含里程碑管理/测试纪律/单一事实源),下个项目可直接复用
+2. 新增 `docs/HANDOFF.md`:环境要求、恢复步骤、当前进度(P1~P5+P6-M1 完成)、阻塞点(5 个未验证变量/热求解/多工具 mock/Kriging 降级)、待办(P6-M2/M3)+ 接续提示词
+3. 新增 `scripts/check_machine_paths.py`:只读环境体检(Python 版本/环境变量/Motor-CAD exe/Python 包/Git 状态/仓库资产/node),`--fix` 打印修复命令;含项目 venv 探测提示
+4. `AGENTS.md` 接入新框架:补 HANDOFF/playbook-v2 引用、环境体检命令、会话日志强制、反模式自查表、接续与交接节
+5. `docs/CONVERSATION_LOG.md`:头部补统一模板说明 + 追加本次会话记录
+
+**验证**:check_machine_paths.py 本机运行通过(py_compile + 纯 ASCII + 体检输出正常,详见 TEST_RECORDS TEST-024)。
+
+**技术决策**:check_machine_paths.py 采用“默认只读 + --fix 仅打印命令不自动执行”(避免脚本擅自改系统环境);会话日志沿用现有 CONVERSATION_LOG(不新建 session_log,避免双日志漂移)。
+
+**已知问题/后续**:新项目可直接复用 playbook-v2;PCB 项目对照框架第 8 节落地清单逐项对齐。
+- **V2.1 迭代**(本次):playbook 已通用化重构——主文不绑定任何项目,新增「版本更新记录」;MARS/PCB 降级为附录案例,可直接复制到任何新项目初始化。
+
+---
+
+## 2026-08-30(晚)— P5-M2 拓扑感知变量名映射与执行前校验
+
+### 背景
+Plan 23 的 80 个扫描点全部失败,根因是扫描变量使用了径向磁通电机(RFM)的变量名 `Stator_Lam_Outer_Dia` / `Stator_Lam_Inner_Dia`,而当前模型是轴向磁通电机(AFM/SSSR),Motor-CAD 中不存在这些变量。
+
+### 修复内容
+1. **新增拓扑感知变量名映射模块** `web/backend/app/services/topology_variable_map.py`
+   - 定义 SSSR / AFIR / RFM 三种拓扑的已知变量集合(41 个 AFM 变量)
+   - RFM→AFM 别名映射(`Stator_Lam_Outer_Dia` → `Stator_Outer_Diameter` 等)
+   - 模板逻辑名→Motor-CAD 实际名映射(`Number_of_Slots` → `Slot_Number` 等)
+   - 变量名校验 + 未知变量建议功能
+
+2. **修复参数展开函数** `_expand_plan_to_parameters()`
+   - 扫描变量现在经过模板 motorcad_var + 拓扑别名映射,不再直接用原始 name
+   - fixed_params 中不在模板里的参数也经过拓扑别名解析
+
+3. **新增执行前变量名校验** `start_simulation`
+   - 启动仿真前校验所有参数名,未知变量直接返回 400 错误并给出建议名
+   - 防止静默的 Motor-CAD "Could not find variable" 失败
+
+4. **补充模板缺失的 motorcad_var**
+   - 为 8 个几何/电气参数补充了 motorcad_var(`Stator_Outer_Diameter`、`CurrentDefinition` 等)
+   - 仅剩 `Current_Density` 和 `Insulation_Class` 合理留空(非直接 Motor-CAD 变量)
+
+5. **新增变量目录 API** `GET /api/plans/variable-catalog?topology=SSSR`
+   - 返回指定拓扑的模板参数(含 motorcad_var)和已知变量集合
+   - 前端用于填充扫描变量下拉列表
+
+6. **前端优化** PlanDetail.vue
+   - 扫描变量从后端 API 获取拓扑感知的变量目录
+   - 下拉列表显示中文名 + 逻辑名 + Motor-CAD 实际变量名
+   - 不再依赖本地硬编码模板
+
+### 测试
+- 单元测试 `scripts/test_topology_variable_map.py`:8/8 PASS
+- 包含 plan 23 回归测试:验证 RFM 变量名被正确映射,80 个点全部已知
+
+---
+
+## 2026-08-30 — P6-M1:Web 前端 UI/UX 全面重构(专业仿真工具风格)
+
+**背景**:P5 完成后功能完备,但前端存在信息过载(PlanDetail 1129 行长卷)、导航 14 项平铺无分组、参数三套命名口径、监控页功能重叠、stat-card 四处重复手写等问题。参考 SimScale / Ansys 等在线仿真工具的设计语言(工作流树式导航、三级可见性、高数据墨水比、卡片式分组、上下文操作就近放置),完成 B1(信息架构)+ B2(PlanDetail 分层)两批次改造。
+
+**全局设计系统**:
+- 新建 `src/style.css` 设计令牌(CSS 变量):专业深蓝主色 `#2563eb`、中性灰阶、8px 网格间距、统一圆角/阴影/过渡
+- 新建 3 个公共组件:`StatCard.vue`(统计卡片,消除 4 处重复手写)、`SectionCard.vue`(内容区块)、`PageHeader.vue`(页面头部)
+- Element Plus 主题变量覆盖(按钮/卡片/表格/标签/Tabs 统一为设计令牌色)
+
+**B1 信息架构重构**(`MainLayout.vue`):
+- 侧边栏由 7 项平铺改为 5 组工作流导航:工作台 / 执行 / 分析 / AI 智能 / 知识
+- AI 高级功能(L0预筛选、多保真度校准、经验库AI增强)收进「高级功能」可折叠子组
+- 面包屑升级为 项目→方案→任务 层级链(原仅两级)
+- 顶栏新增全局任务状态条(运行中任务数,10s 轮询,点击跳转任务管理)
+- 侧边栏支持折叠(240px ↔ 64px)
+- 页面切换淡入过渡动画
+
+**B2 PlanDetail 分层重构**(`PlanDetail.vue`):
+- 单页长卷改为 4 Tab:概览 / 方案参数 / 仿真结果 / AI 闭环
+- 概览 Tab:统计卡 + AI 设计思路 + 验收标准 + 结果摘要(前5行)+ 快捷操作卡片
+- 方案参数 Tab:扫描变量(主)+ 边界条件 + 固定参数(默认只显示"已修改"项,其余按分类折叠)
+- 仿真结果 Tab:完整结果表 + 导出
+- AI 闭环 Tab:步骤指示器 + AI分析/生成迭代/提取经验 三步向导
+- 运行中进度横幅(始终可见,含预计剩余时间)
+- 预估耗时由硬编码 `points×3min` 改为 `points×2.5min`(贴近真实 90~150s/点)
+- 固定参数"需确认"变量名高亮标签(README 已知的 5 个未验证变量)
+
+**其他页面优化**:
+- `ProjectList.vue`:PageHeader + StatCard + SectionCard + 搜索/拓扑筛选 + 创建弹窗边界条件双列布局
+- `Dashboard.vue`:PageHeader + StatCard + ECharts 趋势/Pareto 图 + 结果表
+- `TaskManager.vue`:PageHeader + 6 项状态统计卡 + 状态筛选 + 创建任务从方案下拉选择(JSON 降为高级折叠项)+ 详情抽屉优化
+
+**验证**:`npm run build` 全绿(vue-tsc 0 错误,vite 11.62s 构建完成,2283 模块)。详见 TEST_RECORDS TEST-023。
+
+**后续批次**:B3(参数目录单一事实源,前后端协同)、B4(流程引导+仿真前检查清单+耗时校准)待实施。
+
+---
+
+## 2026-08-30 — 文档卫生修复 + 前端分包优化(外部工程审核整改)
+
+**背景**:外部系统专家审核发现 3 处文档污染与 1 处前端性能问题,本次全部修复。
+
+1. **README.md 控制字符清理**:清除 2 个粘贴带入的控制字符(0x07/0x08),修复 `daptive→adaptive`、`atch_scheduler.py→batch_scheduler.py` 两处被吞字符的文本
+2. **docs/TEST_RECORDS.md 修复**:清除 3 个控制字符(`fmcore→afmcore` 等);重建"测试记录索引"表(TEST-004~022 原本 5 列错位、内容串行,现恢复为 22 条完整 5 列表格)
+3. **前端 chunk 分包**:`vite.config.ts` 增加 `manualChunks`(vendor-vue / vendor-echarts / vendor-element 三包拆分),业务代码与第三方库解耦,改善缓存与首屏加载;重新 build 验证 EXIT=0(vue-tsc 0 错误)
+
+**验证**:README / TEST_RECORDS / .gitignore 三文件控制字符清零;`npm run build` 全绿;业务 chunk 由 1MB 级降为 13~54kB(vendor 库独立成 3 包)。
+
+---
+
+## 2026-08-30 — P5-M6:多物理场 L2 接入(热网络+结构指标 + 报告模板化)
+
+**背景**:P5 路线最后一批。metrics.py 仅 25 个电磁指标,报告为单一 metrics 表。P5-M6 扩加热网络+结构指标(自动生效),robust_motorcad 加热求解开关,报告按物理域分组。
+
+**metrics.py 扩项**(单一事实源,extract_all_metrics 自动生效):
+- 热网络 6 项(domain=thermal):winding_hotspot_temp_c / magnet_temp_c / stator_temp_c / bearing_temp_c / temp_rise_c / thermal_resistance_k_w
+- 结构 4 项(domain=structural):axial_force_n / radial_force_n / max_stress_mpa / deformation_mm
+- 均 required=False,含英文+中文别名;总指标 25→35
+
+**robust_motorcad 热求解**:`enable_thermal` 开关(__init__ + run_single_point),默认关;电磁求解后尽力而为 do_thermal_calculation + Thermal 导出合并,失败不中断电磁结果。需要模型热网络配置(环境依赖)。
+
+**报告模板化**:report_generator Results Summary 按域分组(Electromagnetic / Thermal / Structural),每域子标题+表格,空域跳过;从 afmcore.metrics 取 domain/label/unit;JSON fallback 含 metrics_by_domain。
+
+**验证**:test_metrics_extension.py 20/20 PASS(定义/提取/中文别名/必需校验/域分组/报告JSON/robust参数);全量回归 21/21。真实热求解待模型配置+实际运行。详见 TEST_RECORDS TEST-022。
+
+**P5 路线全部完成**(M1-M6)。
+
+---
+
+## 2026-08-30 — P5-M5:多工具适配器(Maxwell/JMAG mock + 执行器 tool 参数化)
+
+**背景**:适配器层仅有 MotorCADAdapter,执行器硬编码 import motorcad。P5-M5 新增 MaxwellAdapter/JMAGAdapter(mock 实现,真实接入标注环境依赖),执行器根据 `tool` 动态选择适配器。
+
+**新增适配器**:
+- `MaxwellAdapter`(tool=`maxwell`):mock 实现,capability_domains=(electromagnetic, thermal),确定性合成指标(含 winding_temp_c)
+- `JMAGAdapter`(tool=`jmag`):mock 实现,capability_domains=(electromagnetic,),指标系数与 Maxwell 不同以区分工具
+- 两者均实现 SimulationAdapter 协议(connect/disconnect/load_model/set_parameter/run_simulation/extract_metrics),set_parameter 含回读校验,mock=False 时 connect() 抛 RuntimeError 标注环境依赖
+
+**执行器改造**:`MotorCADTaskExecutor._ensure_adapter()` 从硬编码 import 改为根据 `self.tool` 动态 import(motorcad/maxwell/jmag);`executor_config.json` 的 `tool` 字段现在可切换工具。
+
+**验证**:test_adapters.py 22/22 PASS(注册/全链路/工具区分/回读校验/边界/异常/执行器动态 import);全量回归 20/20。真实 Maxwell/JMAG 接入待目标机环境(需 Ansys Maxwell + PyAEDT / JMAG + jmagpy)。详见 TEST_RECORDS TEST-021。
+
+---
+
+## 2026-08-30 — P5-M4:策略层高级管线(Morris 灵敏度 + IDW 代理 + 预算自适应)
+
+**背景**:蓝图 §6 设计四阶段优化管线(Morris 筛选 → LHS 采样 → Kriging 代理 → NSGA-II),但当前策略层仅有 full_factorial/lhs/adaptive。P5-M4 新增 Morris 灵敏度筛选和代理模型引导策略,补齐蓝图前两阶段。
+
+**依赖降级**:环境无 numpy/scipy/sklearn,项目纪律要求纯 stdlib。代理模型用 **IDW(反距离加权)** 替代 Kriging——预测 + 最近邻距离不确定性,支持 UCB explore-exploit 权衡。后续升级 Kriging 须引入 scipy 并在同接口下替换。
+
+**新增策略**:
+- `MorrisStrategy`(kind=`morris`):纯 stdlib Morris OAT 灵敏度筛选,state() 返回 sensitivity_ranking(mu/mu_star/sigma)+ key_parameters
+- `SurrogateGuidedStrategy`(kind=`surrogate_guided`):初始 LHS → IDW 代理 + UCB 采集 + 预算自适应批次大小,state() 返回 phase/budget/surrogate 诊断(loo_rmse/mean_uncertainty)
+
+**验证**:test_strategy_morris.py 15/15 + test_strategy_surrogate.py 17/17;Morris 线性函数灵敏度排名正确(a=2.0 > b=0.5);SurrogateGuided bowl 函数收敛 best y=0.007(理想 0.0);全量回归 19/19。详见 TEST_RECORDS TEST-020。
+
+---
+
+## 2026-08-30 — P5-M3:adaptive 可视化补全(批次状态 + L0 摘要 + 运行期轮询)
+
+**背景**:P4-M4 上线收敛曲线后,adaptive 优化页面缺少批次级状态可见性(每批多少点成功/失败/不可行)、L0 预筛选结果展示(采样可行率/不可行原因),且运行期只能手动刷新。P5-M3 补齐三视图。
+
+**后端**:`feasibility_search.py` `get_state_summary()` 增加 `batch_summary`(每批进度/状态分布/批内最优)、`l0_summary`(采样可行率/不可行原因 Top N)、`infeasible_points`、`failed_points`;`search.py` SearchStateResponse 同步增加字段(向后兼容)。
+
+**前端**:`AdaptiveOptimize.vue` 增加①运行期 3s 自动轮询(非 searching 状态自动停止)②L0 预筛选摘要卡片(可行率进度条 + 不可行原因 tag)③批次状态总览卡片(每批进度条 + 状态分布 tag + 批内最优)。
+
+**验证**:`test_search_state_summary.py` 8/8 PASS;vue-tsc && vite build 零类型错误;独立后端端到端 create/state 新字段正常。详见 TEST_RECORDS TEST-019。
+
+---
+
+## 2026-08-30 — P5-M2:EXE 配置化 config.json + mock 分支修复
+
+**背景**:本地执行器 EXE 此前只能靠环境变量(MOTORCAD_MODEL / WEB_BASE_URL)配置,部署到目标机需改环境。P5-M2 引入 `executor_config.json` 侧车配置:web 地址 / 模型路径 / 日志 / 实例数全部可改,无需重新打包。
+
+**改动**:
+1. `scripts/executor_config.py`(新增):配置加载器。优先级:`--config` > `$EXECUTOR_CONFIG` > `<EXE目录>/executor_config.json` > `<仓库根>/executor_config.json` > 内置默认;单字段仍可被环境变量覆盖;相对路径按 EXE 目录/仓库根解析;数值/枚举/类型校验。
+2. `executor_config.json`(新增,仓库根模板):web_base_url / model_path / poll_interval / instances / log_dir / log_level / tool / enable_mock。
+3. `scripts/run_task_executor.py`:argparse 支持 `--config / --instances / --interval / --mock / --log-dir / --log-level`;logging 落盘;支持单入口多实例(instances>1)。
+4. `scripts/run_task_executor_parallel.py`:复用共享配置加载(保留原 CLI 兼容)。
+5. `scripts/task_executor.py`(修复 P4-M3 遗留):`MotorCADTaskExecutor._run_simulation_point` 此前忽略 `enable_mock` 无条件走真实 adapter;现 `enable_mock=True` 时走基类 mock(结果带 `source="mock"`),不启动 Motor-CAD、不要求 model_path。
+
+**测试**:`scripts/test_executor_config.py`(20 用例:默认/文件合并/环境覆盖/优先级/边界/异常/空值)+ `scripts/test_executor_p5m2.py`(6 用例:mock 分支正常/多点/非法路径仍 mock/默认关闭/真实模式缺模型路径报错/空路径),全部 EXIT=0;全量回归 16/16 PASS。
+
+**EXE 端到端验证(mock)**:重新打包 `dist/PCB-AFM-Executor.exe`(1.1.0);独立后端(8010 + 临时 DB)→ EXE(`--config` 侧车,enable_mock=true)认领 3 点任务 → mock 求解 → 回传 Web → 任务 completed、3/3 成功、指标入库(tavg 34.56~42.05 N·m、eff 87.95~89.73%)。点级结果带 `source="mock"` 防混淆。
+
+**真实 Motor-CAD 端到端**:✅ 本机已验证单点全链路(EXE → 真实 Motor-CAD → Web,duration 167.5s,tavg=0.52187 / eff=86.06% / losses=41.945W / back_emf=11.15V,与 TEST-010 完全一致,见 TEST-018)。过程中修复两个真实链路 bug:业务名 `airgap_mm→Airgap` 映射 + `point_id` 元数据键排除。真实短扫描/多实例可在目标机验证。
+
+---
+
+## 2026-08-30 — P5-M1:前端全量 build 类型错误清零(backlog B1)
+
+**背景**:`npm run build`(vue-tsc && vite build)历史遗留 71 处类型错误(TS2339/TS2345/TS7006,6 个 .vue)。根因:`src/api/index.ts` 响应拦截器运行时已 unwrap 为 `.data`,但 TS 类型上 `api.get/post` 仍声明为 `Promise<AxiosResponse>`,导致调用处直接访问响应字段报 TS2339;PlanDetail 中 `task.status` 被误判为 HTTP 状态码 number 报 TS2345。
+
+**改动**:
+1. `web/frontend/src/api/index.ts`:axios 实例类型改写为 `UnwrappedApi` 接口(get/post/put/delete 返回 `Promise<T>`,默认 any),类型声明与运行时行为对齐;拦截器逻辑原样保留(实例改名 instance 后 cast)。
+2. `web/frontend/src/views/PlanDetail.vue`:`@selection-change` 回调参数 `sel` 显式标注 `any[]`(消除 TS7006)。
+
+**验证**:`npm run build` 全绿(vue-tsc 0 错误 + vite 2274 modules 构建成功,真实退出码 0);后端 health 200 + `test_api_client.py` 真实链路 PASSED;前端 vite dev + API 代理 200;14 个 Python 回归脚本全绿。详见 docs/TEST_RECORDS.md TEST-016。
+
+---
+
+## 2026-08-29 — 方案详情页三栏职责重构(P2-M2 增强)
+
+**背景**:边界条件栏与固定参数栏数量不平衡。根因:边界条件从模板全量渲染(27项),而固定参数只加载AI方案实际写入的少量参数;且边界条件栏混入了大量设计实现参数。
+
+**改动内容**:
+
+1. **边界条件栏瘦身**(27 → 14项):只保留需求/约束型参数
+   - 性能规格:功率/转速/转矩/电流/母线电压/效率/脉动/损耗
+   - 几何约束:最大外径/轴向长度;热约束:最大温升
+   - 需求描述:拓扑/防护等级/应用场景
+   - 移出(归入固定参数):气隙、内径、槽数、极对数、匝数、并联支路、电流密度、磁钢材料、硅钢片、剩磁、冷却方式、环境温度、绝缘等级
+
+2. **固定参数栏扩容**(模板驱动,36项全量展示)
+   - 后端 `app/services/fixed_params_template.py` 扩充为36个参数,按电机工程习惯分8类
+   - `plan_generator.py` 固定参数改为**模板全量驱动**:AI不再需要输出固定参数,系统按边界条件推断全部参数值
+   - 前端 `PlanDetail.vue` 加载方案时按模板自动补齐缺失固定参数
+   - 每个固定参数加**来源标记**(AI/用户),用户修改后自动切换为用户
+   - 移除"添加固定参数"按钮与弹窗(参数已全量展示,后续扩展往模板里加)
+
+3. **AI方案思路中文简短化**
+   - `plan_generator.py` 默认提示词改造:reasoning 用中文、约200字、只讲关键设计决策与核心参数
+   - AI输出固定模板(plan_name/topology/scan_variables/search_strategy/acceptance_criteria/reasoning),固定参数由系统模板生成
+
+4. **Bug修复**:固定参数含字符串值(磁钢材料/硅钢片/冷却方式等)时一键启动仿真500错误
+   - `plans.py` `_expand_plan_to_parameters`:数值转float、字符串保留原样
+
+**涉及文件**:
+- `web/backend/app/services/fixed_params_template.py`(扩充36参数+8类中文)
+- `web/backend/app/services/plan_generator.py`(模板驱动+中文prompt)
+- `web/backend/app/routers/plans.py`(字符串参数启动修复)
+- `web/frontend/src/views/PlanDetail.vue`(三栏重构+来源标记+去添加按钮)
+
+**已知问题**:
+- 新增参数中 `Max_Speed/Winding_Connection/Current_Density/Magnet_Remanence/Insulation_Class` 的 Motor-CAD 变量名未验证,description 已标注"变量名需确认",执行时可能标记FAILED,需在.mot中确认后修正
+
+---
+
+## 2026-08-29 二期 — 本地执行器真实仿真全链路打通(P2 关键里程碑)
+
+**问题**:前端"一键启动仿真"后一直"仿真中"无进度,Motor-CAD 未打开。
+
+**根因链**:本地执行器未运行 -> 认领逻辑矛盾(只拉pending但任务已dispatched)
+-> 列表接口不含parameters -> 字符串参数float崩溃 -> 模板变量名非真实Motor-CAD变量
+-> AFM_D_Rotor改外径破坏线性几何。
+
+**修复**:
+- 新增 `scripts/run_task_executor.py` 执行器启动入口(后台常驻,认领pending+dispatched任务)
+- `scripts/task_executor.py`:`_hydrate_task` 拉取完整参数集;心跳注册(在线/进度)
+- `scripts/robust_motorcad.py`:跳过非数值参数写入
+- `fixed_params_template.py`:模板加 `motorcad_var`(真实Motor-CAD变量名,从.mot提取);
+  默认值对齐基线模型;AFM_D_Rotor 不写入(外径作约束)
+- `plans.py` `_expand_plan_to_parameters`:模板为骨架合并用户修改,仅写真实变量名
+
+**验证**:plan 21 扫描 Airgap 1->2mm 2点全部OK,Motor-CAD打开、进度推进、结果回传,
+物理规律正确(气隙↑->反电动势↓)。
+
+**涉及文件**:scripts/run_task_executor.py(新)、scripts/task_executor.py、scripts/robust_motorcad.py、
+web/backend/app/services/fixed_params_template.py、web/backend/app/routers/plans.py
+
+**已知问题**:字符串参数(材料/冷却/绝缘)暂不写入,用基线默认;几何改型需Motor-CAD内操作。
+
+---
+
+## 2026-08-29 三期 — 平台化改造第一批:共享核心层 + 适配器抽象
+
+**背景**:按平台化升级设计方案(docs/PLATFORM_DESIGN_V2.md),消除"指标定义三处漂移"与"解析器两套实现"两大平台性短板,为多工具/多拓扑/多策略扩展铺路。
+
+**改动内容**:
+1. **新建共享核心层 `src/afmcore/`**(单一事实源)
+   - `metrics.py`:25 项指标定义(key/label/unit/direction/required/aliases) + 归一化解析器
+   - `adapters/`:SimulationAdapter 接口 + 注册表 + MotorCADAdapter 实现
+2. **三个消费端接入共享层**:solver_core / robust_motorcad / metrics_constants 全部改为从 afmcore.metrics 导入
+3. **修复 tavg_nm/ripple_pct 解析缺口(TEST-003 遗留)**:根因为 robust_motorcad 无归一化精确匹配,已统一为归一化匹配(全角括号→半角、去空白、小写)
+4. **命名规避冲突**:共享包从 platform 改名 afmcore(避免与标准库同名)
+
+**验证**:单元验证全部 PASS(含全角字符解析/中文别名/% 守卫/三端导入/78 文件编译/适配器协议),详见 docs/TEST_RECORDS.md TEST-004。
+
+**进展路线(后续批次)**:P2 拓扑注册表 + task_executor 切换适配器;P3 执行策略接入(adaptive);P4 方案 Schema 统一 + 文档同步 + EXE 打包。
+
+---
+
+## 2026-08-29 四期 — 平台化改造第二批:拓扑注册表 + 执行器切换适配器
+
+**背景**:按平台化设计方案 P2,消除“拓扑是裸字符串”与“求解器硬编码”两个平台性短板。
+
+**改动内容**:
+1. **新建拓扑注册表 `src/afmcore/topology.py`**:SSSR 完整参数体系(8 组 37 项)注册,DRSS/SDSR 预留;提供拓扑验证/参数校验/UI 序列化
+2. **`plan_schema.validate()` 集成拓扑校验**:未知拓扑拦截,新增拓扑只做注册
+3. **`task_executor` 切换 `get_adapter()`**:从硬编码 RobustMotorCADSolver 改为统一适配器入口;修复结果嵌套导致的 _compute_metrics 聚合空的缺口
+
+**验证**:新增可重复运行回归测试 `scripts/test_platform_registry.py`(36 项断言全 PASS);79 个 .py 编译 0 失败;三端导入回归通过。详见 TEST_RECORDS.md TEST-005。
+
+---
+
+## 2026-08-29 五期 — P3 平台化改造(第一批:策略抽象层 + 自适应编排器)
+
+**背景**:按 P3 实施计划(docs/archive/P3_IMPLEMENTATION_PLAN.md),把 Web 端已验收的 adaptive 搜索能力接到本地执行器,打通「方案 -> 批次任务 -> 本地执行 -> 回填搜索 -> 续批/收敛」闭环。
+
+**M1 执行策略抽象层(src/afmcore/strategies/)**:
+- 新增 SimulationStrategy ABC + STRATEGY_REGISTRY,执行器改为向策略要批次,不再硬编码全因子
+- full_factorial(笛卡尔积,自包含)/ lhs(纯 Python 拉丁超立方)/ adaptive(backend 注入桥接,共享层不依赖 web)三种策略注册可用
+- plan_schema 的 SearchStrategy.method 归一化(legacy active_learning/constrained -> adaptive)并经策略注册表校验
+
+**M2 任务模型扩展 + 自适应编排器**:
+- Task 模型新增 task_type / loop_id / batch_id / point_ids / dynamic 字段(旧库自动迁移 ADD COLUMN)
+- 新建 strategy_orchestrator.py:AdaptiveOrchestrator(start_loop / advance_loop / get_loop_status),桥 FeasibilityFirstSearch <-> 任务系统 <-> 本地执行器,loop 状态落盘 output/adaptive_loops/
+- 修复现存 bug:task_manager.report_results 未定义 plan_id(应取 task.plan_id)
+
+**验证**:闭环回归脚本 scripts/test_p3_orchestrator.py(fake 执行器,temp DB 隔离),exit 0;详细记录见 docs/TEST_RECORDS.md TEST-006。
+
+**技术决策**:orchestrator 采用 pull 驱动(advance_loop 由调用方/路由/调度器触发),与既有执行器轮询哲学一致;L0 可行性预筛依赖参数名对齐(airgap_mm/current_a),真实 AI plan 生成即对齐。
+
+**已知问题/后续**:路由/定时器接入与真实 Motor-CAD 烟雾测试放 M5;adaptive 前端视图放 P4。
+
+---
+
+## 2026-08-29 六期 — P3 平台化改造(第二批:执行器批次化 + 多实例)
+
+**背景**:P3-M3,让本地执行器原生支持 adaptive_batch 任务与多实例并行,配合 M2 编排器形成批次闭环。
+
+**改动(scripts/task_executor.py)**:
+- point_id 回传:逐点结果顶层带 point_id(OK/FAILED 均保留),编排器可精确回填搜索
+- 认领原子化:dispatch 失败(被其他实例认领)即跳过该任务,多实例不重复仿真
+- executor_id 唯一化 + 修复 report_results 本地模式 on_complete 参数个数不一致 bug
+
+**新增**:scripts/run_task_executor_parallel.py(--instances N 并行启动,--mock 供测试)
+
+**验证**:scripts/test_executor_m3.py(PASS),P2 回归 36/36 不破坏;见 docs/TEST_RECORDS.md TEST-007。
+
+**技术决策**:多实例并行复用 Web 端 dispatch 的幂等语义(pending->dispatched 原子迁移)防重复,执行器侧无需额外锁。
+
+---
+
+## 2026-08-29 七期 — P3 平台化改造(第三批:调度契约统一)
+
+**背景**:P3-M4,统一两套调度状态词汇(TaskManager 的 pending/dispatched 与 BatchScheduler 的 queued),并让调度器任务字段与 Task ORM 对齐。
+
+**改动**:
+- 新建 web/backend/app/services/task_contract.py(无依赖契约层):规范状态常量 + STATUS_ALIASES 归一(queued->pending、canceled->cancelled、completed_with_errors->completed)+ normalize_status/is_terminal/merge_adaptive_fields
+- batch_scheduler.py:add_task 支持 task_type/loop_id/batch_id/point_ids/dynamic 透传,_summary 输出新字段(状态词 queued 向后兼容)
+
+**验证**:scripts/test_p3_m4_contract.py(PASS),M2 闭环回归不破坏;见 docs/TEST_RECORDS.md TEST-008。
+
+**技术决策**:用契约层声明统一词汇/字段而非重构两个既有服务,消除漂移同时保持向后兼容;状态归一采用「别名映射 + 未知词透传」(能报错而非静默)。
+
+---
+
+## 2026-08-29 九期 — P3 平台化改造(第四批:HTTP 全链路闭环验证)
+
+> 注:历史记录中无“八期”编号,沿用原始记录。
+
+**背景**:P3-M5,验证「orchestrator -> 任务系统 -> 本地执行器(HTTP) -> 仿真 -> point_id 回填 -> 续批 -> 收敛」全链路真实闭环。
+
+**改动**:
+- strategy_orchestrator.py:AI 分析按 KIMI_API_KEY 门控(无 key 时保持 quantitative,不空打 AI 日志)
+- 新增 scripts/test_p3_closed_loop.py:真实 FastAPI(temp DB)+ orchestrator + mock 执行器全链路闭环回归,exit 0
+
+**验证**:闭环 n_results=8 budget_exhausted;P2 36 项 + M2/M3/M4 全量回归 PASS;91 个 .py 编译 0 失败;详见 docs/TEST_RECORDS.md TEST-009。
+
+**技术决策**:AI 分析为可选项——无 key 环境自动降级为纯定量结果,配置 key 后自动启用;避免每次批次空调 AI 的日志噪音与延迟。
+
+**真实 Motor-CAD 烟雾**:已通过(TEST-010)——连接/基线加载/求解 1 点/解析 21 项指标全 OK,back_emf=11.15V 与历史 TR-01 一致;同时发现并修复 MotorCADAdapter.run_point 忽略 model_path 的真实 bug。
+
+### P3 平台化改造(第五批:Web 端执行桥 + 路由整合)
+
+**背景**:P3-M6。Web 端已有 AdaptiveLoop 闭环(AI 方案 -> L0 -> FeasibilityFirstSearch -> 选批 -> report-results 回填 -> AI 分析 -> 经验库 -> 收敛),但「本地执行器执行」环节缺失;平台层已具备任务系统 + 执行器(批次 Task、point_id 回填)。本批打通两套体系。
+
+**改动**:
+- adaptive_loop.py:新增 submit_batch_to_executor() —— 把当前 pending 批次包装为 adaptive_batch Task(复用 task_manager 原语),交由本地执行器认领执行
+- routers/adaptive.py:新增 POST /api/adaptive/loops/{loop_id}/submit-batch 端点
+- 撤销误建的 adaptive_orchestrator 路由(与既有 adaptive.py 路径冲突),main.py 还原
+- 新增 scripts/test_p3_adaptive_execution.py:Web 端 AdaptiveLoop + 执行桥全闭环集成回归(temp DB + fake executor + 无 AI)
+
+**验证**:M6 集成测试 PASS(fake plan -> 初始 3 点 -> submit-batch -> 3 批 -> budget_exhausted,8 点/8 预算;Task 落库含 loop_id/batch_id/point_ids/dynamic;幂等性 OK;HTTP 端点 404/200 正常);P2 36 项 + M2/M3/M4/M5/M6 全量回归 PASS;详见 docs/TEST_RECORDS.md TEST-011。
+
+**技术决策**:采用「Web 智能层 + 平台执行层」整合而非新建第二套循环路由——给 AdaptiveLoop 补执行桥(只加方法/端点,不改既有流程),复用已验证的任务系统/执行器,避免两套 /api/adaptive 循环并存。
+
+### P3 收尾遗留处理 + P4 平台化第四批(十期)
+
+**背景**:用户要求"先处理遗留问题 → 写 P4 详细计划 → 直接做 P4"。P4 按 `docs/archive/P4_IMPLEMENTATION_PLAN.md` 五件套实施,全部完成。
+
+**P3 遗留处理(4 commit)**:
+- 并发原子认领:`task_manager.dispatch_task` 改 SQLAlchemy 条件 UPDATE(8 线程竞争恰 1 win)
+- 断点恢复:`FeasibilityFirstSearch.import_state()` + `AdaptiveLoop.export/restore` + `/loops/{id}/export`、`/loops/import` 端点
+- ASCII 纪律:plans.py + fixed_params_template.py 中文串转 `\uXXXX`(运行解码正确)
+- 环境依赖测试:`test_api_client.py` 真实链路;未动用户常驻服务
+
+**P4 实施(M1~M5,5 commit)**:
+- **M1 Schema 单一权威**:`src/plan_schema.py` 增 parse/validate 入口 + 别名容错 + require_model_path 分级;web 端 main.py 注入 repo root,plans/ai_plan 接入校验(400/422)
+- **M2 文档 V1.1→V2.0**:设计方案追加"附录 B 实现现状对照"(蓝图 vs 实现逐项映射)
+- **M3 EXE 打包**:`scripts/build_executable.ps1` + PyInstaller onefile → `dist/PCB-AFM-Executor.exe`(12.6MB,--version/--self-test 通过)
+- **M4 前端收敛曲线**:search state 增 points_history(逐评估点数据)+ AdaptiveOptimize.vue echarts 收敛曲线
+- **M5 L0 上提共享核心**:`L0PreScreeningEngine` 迁 `src/afmcore/l0/prescreening.py`(唯一实现,纯 stdlib),web 薄 re-export 兼容 6 处调用点
+
+**验证**:新增 test_p4_schema / test_p4_m4_convergence / test_p4_m5_l0 全过;P2 36 + M6 + closed_loop + checkpoint + concurrency 全量回归绿;全量 py_compile 0 失败;ASCII 0 违规;vue-tsc 对 AdaptiveOptimize.vue 0 错误(全量 build 仍有既有类型错误属 backlog)。详见 docs/TEST_RECORDS.md TEST-013~015。

+ 250 - 0
MotorCAD软件教程及故障处理防范/MotorCAD脚本自动化仿真参考资料.md

@@ -0,0 +1,250 @@
+# Motor-CAD 脚本自动化仿真参考资料汇总
+
+> 用途:作为仿真系统编程设计的参考资料
+> 整理日期:2026-08-27
+> 说明:Motor-CAD 的自动化接口在 2022 年后已从旧的 ActiveX(COM)体系迁移到 **PyMotorCAD**(基于 JSON-RPC 的 Python 接口,属 PyAnsys 生态),因此本资料以 PyMotorCAD 为主、ActiveX 为辅。文中所有链接均来自官方(Ansys / PyAnsys)或公开的社区与组织分享,并标注了来源性质。
+
+---
+
+## 一、官方核心资料(优先阅读)
+
+### 1.1 PyMotorCAD 官方文档站(最重要,一站式入口)
+
+- **PyMotorCAD 官方文档(含版本切换)**
+  https://motorcad.docs.pyansys.com/
+  - 稳定版(stable)与开发版(dev)及历史版本可在页面右上角切换。
+  - 内容结构:Getting Started / User Guide / API Reference(Methods)/ Examples / Contributing。
+  - 官方支持邮箱:pyansys.core@ansys.com;Bug 与功能请求走 GitHub Issues;问答走 Ansys Developer 论坛 Discuss 区。
+
+- **PyMotorCAD Cheat Sheet(一页速查表 PDF)**
+  https://cheatsheets.docs.pyansys.com/pymotorcad_cheat_sheet.pdf
+  - 一页涵盖:启动/退出实例、几何与绕组参数设置、材料赋值、MotorLAB 模型构建、E-Magnetic 性能曲线提取、MATLAB 中调用 PyMotorCAD、ActiveX 旧脚本迁移写法。
+
+- **API 参考(MotorCAD 对象全部方法)**
+  https://motorcad.docs.pyansys.com/version/stable/methods/index.html
+  - 包括:`MotorCAD` 主对象、`MotorCADCompatibility`(兼容旧 ActiveX 脚本)、几何对象与函数(`geometry` / `geometry_tree` / `geometry_shapes` / `geometry_drawing` / `geometry_fitting`)、工具函数、以及 **`MotorCADError` 错误/异常类型**(错误处理入口,见第三节)。
+
+### 1.2 官方 GitHub 仓库:ansys/pymotorcad
+
+- 仓库地址:https://github.com/ansys/pymotorcad
+- PyPI 包:`ansys-motorcad-core`,安装命令:
+
+  ```bash
+  python -m pip install -U pip
+  python -m pip install ansys-motorcad-core
+  ```
+
+- 许可证:MIT(注意:PyMotorCAD 本身开源,但交互控制 Motor-CAD 仍需要合法授权的 Motor-CAD 软件)。
+- **examples/ 目录就是一套现成的自动化脚本样例库**,与文档站的 Examples 页一一对应(见 1.4)。
+- Issues 页面(https://github.com/ansys/pymotorcad/issues)本身就是一份很好的"故障处理案例库",可按 bug 标签检索:https://github.com/ansys/pymotorcad/labels/bug
+- 发布记录(各版本修复内容,可用于排查版本相关问题):https://github.com/ansys/pymotorcad/releases
+
+### 1.3 User Guide 关键章节(操作说明)
+
+| 章节 | 链接 | 内容要点 |
+|---|---|---|
+| User Guide 总览 | https://motorcad.docs.pyansys.com/version/stable/user_guide/index.html | 内部脚本、外部脚本、MATLAB、自适应模板、旧脚本兼容的整体框架 |
+| 内部 Scripting 选项卡 | https://motorcad.docs.pyansys.com/version/stable/user_guide/internal_scripting.html | Motor-CAD 内置 Python 解释器 + Scripting 选项卡;`main()`、`thermal_steady` 等类的 initial/main/final 钩子结构;`MessageDisplayState` 弹窗控制 |
+| 外部脚本 / 加入自己的 Python | https://motorcad.docs.pyansys.com/version/stable/user_guide/external_scripting.html | 安装模式(user / developer)、tox 测试、开发者贡献流程 |
+| MATLAB 脚本 | https://motorcad.docs.pyansys.com/version/stable/user_guide/matlab_scripting.html | 通过 `py.importlib.import_module('ansys.motorcad.core')` 在 MATLAB 里直接用 PyMotorCAD 做完整 E-Magnetic 自动化(含图形数据逐点读取的 try/break 技巧) |
+| 自适应模板脚本(Adaptive Templates) | https://motorcad.docs.pyansys.com/version/stable/user_guide/adaptive_templates.html | 自定义几何:Region/Line/Arc 对象、自适应参数、`reset_adaptive_geometry()`、外部 IDE 调试、`draw_objects()` 几何绘图调试、DXF 导入、圆角/槽口修改最佳实践 |
+| 旧脚本向后兼容 | https://motorcad.docs.pyansys.com/version/stable/user_guide/backwards_compatibility.html | ActiveX → PyMotorCAD 的迁移规则:函数名改 snake_case;旧 `success` 返回值被**异常机制**取代(失败即抛 `MotorCADError`,必须 try/except);`MotorCADCompatibility` 对象可最小改动运行旧脚本 |
+
+### 1.4 官方 Examples(可直接运行的自动化脚本样例)
+
+- 示例总入口:https://motorcad.docs.pyansys.com/version/stable/examples/index.html
+  - 每个示例都可下载为 `.py` 或 Jupyter Notebook。
+- 分类:
+  - **Basic examples(基础)**:如 E-magnetic 基础脚本(建模→计算→导出 CSV→读图数据,含 `MotorCADError` 处理示范)https://motorcad.docs.pyansys.com/version/stable/examples/basics/emag_basics.html
+  - **Internal scripting(内部脚本)**:
+    - 热稳态 https://motorcad.docs.pyansys.com/version/stable/examples/internal_scripting/thermal_steady_state.html
+    - 热瞬态(占空比中改冷却流量)https://motorcad.docs.pyansys.com/version/stable/examples/internal_scripting/thermal_transient.html
+    - 电磁(计算前参数检查与修正)https://motorcad.docs.pyansys.com/version/stable/examples/internal_scripting/emag.html
+    - 机械应力 https://motorcad.docs.pyansys.com/version/stable/examples/internal_scripting/mechanical_stress.html
+    - 机械力/NVH https://motorcad.docs.pyansys.com/version/stable/examples/internal_scripting/mechanical_force.html
+  - **Adaptive templates library(自适应模板库)**:梯形转子风道、圆弧槽底、转子缺口等 https://motorcad.docs.pyansys.com/version/stable/examples/adaptive_library/TrapezoidalDuct.html
+  - **Linking(与其他 Ansys 产品耦合)**:Motor-CAD → Twin Builder 的 ECE 等效电路导出完整流程(JSON 配置驱动 + 错误捕获)https://motorcad.docs.pyansys.com/version/stable/_sources/examples/links/ece_export_for_twinbuilder.rst.txt
+  - **Samples 仓库(dev 版)**:应力采样、区域边界峰值应力、Ansys Motion 力导出、SYNC 电机参数扫描等 https://motorcad.docs.pyansys.com/version/dev/samples/index.html
+
+### 1.5 随软件安装的官方教程(本地,不要忽略)
+
+Motor-CAD 安装目录下的 Tutorials 文件夹自带自动化教程 PDF 与示例(CADFEM 技术日上 Ansys 官方推荐的入口):
+
+- `C:\ANSYS_Motor-CAD\<版本>\Tutorials\ActiveX_Scripting.pdf` —— 通用脚本教程(含 Automation 教程的 section 2.iii,Scripting 选项卡官方文档也指向它)
+- `C:\ANSYS_Motor-CAD\<版本>\Tutorials\FEA_Geometry_Scripting` —— FEA 几何脚本
+- `C:\ANSYS_Motor-CAD\<版本>\Tutorials\Ansys_Optislang\Advance IPM` —— optiSLang 联合优化
+- `C:\ANSYS_Motor-CAD\<版本>\Tutorials\Scripting_Control_In_Duty_Cycle` —— 占空比中的脚本控制
+- `TwinBuilder_ECE_Tutorial` —— Twin Builder ECE 模型导出
+
+出处(CADFEM 2021 技术日官方合作演讲):https://www.cadfem.net/fileadmin/user_upload/05-cadfem-informs/resource-library/2021_siehr_CADFEM_Techday2_scripting_and_parallelization_for_motorcad.pdf
+该 PDF 同时给出 ActiveX 三命令核心模式(SetVariable / DoXxxAnalysis / GetVariable)与 MATLAB、VBS 的最小示例,以及用 Blackbox Solver 做参数研究/优化并行化的思路。
+
+### 1.6 Ansys 官方培训课程(Innovation Space)
+
+- **Running Parallel Ansys Motor-CAD Calculations Through Scripting**(并行计算脚本化,基于 2026 R1)
+  https://innovationspace.ansys.com/certifications/courses/running-parallel-ansys-motor-cad-calculations-through-scripting/
+  - 用 Python multiprocessing 跑多个 Motor-CAD 实例做并行参数研究;MATLAB 则间接通过 Python 调 PyMotorCAD。
+- **Induction Motor Design using Ansys Motor-CAD Sensitivity Analysis**(免费课,含 Python 要求)
+  https://innovationspace.ansys.com/product/induction-motor-design-using-ansys-motor-cad-sensitivity-analysis/
+
+---
+
+## 二、自动化仿真的关键操作模式(编程设计要点)
+
+以下内容提炼自官方文档,是设计仿真系统时的"骨架代码"模式。
+
+### 2.1 连接方式与生命周期
+
+```python
+import ansys.motorcad.core as pymotorcad
+
+# 外部脚本:启动新实例(脚本结束后自动关闭)
+mc = pymotorcad.MotorCAD()
+
+# 外部脚本:启动新实例并保持打开(调试时有用)
+mc = pymotorcad.MotorCAD(keep_instance_open=True)
+
+# 连接已运行的实例 / 内部脚本环境
+mc = pymotorcad.MotorCAD(open_new_instance=False)
+
+# 官方示例推荐的"内外兼容"写法(Adaptive Templates 脚本通用):
+if pymotorcad.is_running_in_internal_scripting():
+    mc = pymotorcad.MotorCAD(open_new_instance=False)
+else:
+    mc = pymotorcad.MotorCAD(keep_instance_open=True)
+```
+
+- 通信机制:Motor-CAD 启动 RPC 服务器,PyMotorCAD 通过 `http://localhost:<端口>/jsonrpc` 与之通信,也支持跨机 HTTP 远程连接。
+- 无头运行:外部脚本支持 **BlackBox 模式**(不显示 GUI),适合服务器批量仿真。
+- 多实例并行:一个外部脚本可同时驱动多个 Motor-CAD 实例(配合 Ansys optiSLang 做优化)。
+
+### 2.2 自动化三件套(所有工作流的最小闭环)
+
+```python
+mc.set_variable("Tooth_Width", 6)          # 1. 设参数
+mc.do_magnetic_calculation()                # 2. 跑计算(do_steady_state_analysis / do_transient_analysis / do_magnetic_calculation ...)
+torque = mc.get_variable("ShaftTorque")     # 3. 取结果
+```
+
+配套常用方法:`load_template("e8")` / `load_from_file()` / `save_to_file()` / `save_results()` / `export_results("EMagnetic", path)` / `get_magnetic_graph()` / `get_fea_graph()` / `quit()`。
+
+### 2.3 弹窗与消息控制(批处理必备)
+
+```python
+mc.set_variable("MessageDisplayState", 2)   # 消息进独立窗口,禁用弹窗
+# ...批量计算...
+mc.set_variable("MessageDisplayState", 0)   # 结束后恢复
+```
+
+> 官方警告:该设置会禁用关键弹窗(含保存提示、覆盖确认),脚本结束前务必恢复;且它**不能**替代异常处理。
+
+### 2.4 内部脚本钩子结构(Scripting 选项卡 / Run During Analysis)
+
+Motor-CAD 在计算前、每个迭代步、计算后分别调用用户类的 `initial()` / `main()` / `final()`。按求解类型定义类:`thermal_steady`、`thermal_transient`、`emagnetic`、`mechanical_stress`、`mechanical_forces`。典型用途:计算中动态修改边界条件(如热瞬态中按时间关断冷却流量)、计算前参数合法性检查与自动修正、计算后自动保存并退出(`save_and_close()` 模式,见 GitHub issue #741 的官方示例代码:https://github.com/ansys/pymotorcad/issues/741)。
+
+### 2.5 MATLAB 集成
+
+- 旧方式:`mcad = actxserver('MotorCAD.AppAutomation')`(ActiveX/COM)。
+- 新方式(官方推荐):MATLAB 内直接加载 Python 包
+  `pymotorcad = py.importlib.import_module('ansys.motorcad.core')`,之后调用与 Python 完全一致;读 FEA 图数据时用 try/catch + break 的循环探测结束点(官方示例见 1.3 MATLAB 章节)。
+
+### 2.6 几何自定义(Adaptive Templates)
+
+- 版本要求:Motor-CAD ≥ 2024 R1 Update(v2024.1.2)且 PyMotorCAD ≥ 0.4.1;Motor-CAD 内置 PyMotorCAD 可通过 Scripting → Settings → PyMotorCAD updates 升级。
+- 关键约束:Region 的实体(Line/Arc)必须**逆时针**顺序且闭合,否则几何/FEA 计算会失败(先用 `Region.is_closed()` 检查);修改几何前先 `mc.reset_adaptive_geometry()`。
+- 调试工具:`geometry_drawing.draw_objects()` 可在脚本里画出区域用于可视化调试;建议在外部 IDE(PyCharm/VSCode)+ 断点中开发,而不是 Motor-CAD 内置编辑器。
+
+---
+
+## 三、故障处理与常见问题汇总(错误处理机制 + 真实故障案例)
+
+### 3.1 官方错误处理机制(编程设计时必须实现)
+
+1. **`MotorCADError` 异常类型**:PyMotorCAD 与旧 ActiveX 的本质区别——API 调用失败时**直接抛异常**,不再有静默失败的 `success` 返回值。设计仿真系统时应对所有 Motor-CAD 调用做 try/except:
+
+   ```python
+   import ansys.motorcad.core as pymotorcad
+   from ansys.motorcad.core import MotorCADError
+
+   try:
+       mc.do_magnetic_calculation()
+   except pymotorcad.MotorCADError as e:
+       print("Calculation failed: " + str(e))
+
+   # 结果导出同样要捕获
+   try:
+       mc.export_results("EMagnetic", "Export_EMag_Results.csv")
+   except pymotorcad.MotorCADError as e:
+       print("Results failed to export due to Motor-CAD Error: " + str(e))
+   ```
+
+   出处:官方 E-Magnetic 基础示例 https://motorcad.docs.pyansys.com/version/stable/examples/basics/emag_basics.html 与向后兼容章节 https://motorcad.docs.pyansys.com/version/stable/user_guide/backwards_compatibility.html
+
+2. **读图数据的"越界即结束"惯用法**:读取 graph 点到末尾会抛 `MotorCADError`,官方用 while + try/except 循环作为序列结束判断(MotorCAD API 只暴露最近显示的曲线;曲线名称和数据类型在 Motor-CAD 界面 Help → Graph Viewer 里查)。
+
+3. **`MotorCADCompatibility` 对象**:旧 ActiveX 脚本可几乎不改地运行,方便过渡期对照排查问题。
+
+### 3.2 真实故障案例库(论坛 + GitHub Issues,含现象/原因/对策)
+
+| # | 故障现象 | 环境 | 原因与处理 | 来源 |
+|---|---|---|---|---|
+| 1 | `Failed to connect to Motor-CAD instance: port=58016, Url=http://localhost:58016/jsonrpc`,旧版本脚本突然全部失效 | 同机安装 Motor-CAD 2024.1.3 后又装 2024.2.3.1 | **多版本共存导致自动化注册/端口冲突**。检查 Defaults → Automation 里注册的版本;重装/修复安装使注册版本与实际调用版本一致;换到未混装的机器正常即印证是安装态问题 | Ansys 社区 https://discuss.ansys.com/discussion/4499/python-failed-to-connect-to-motor-cad-instance |
+| 2 | `pymotorcad.MotorCAD()` 无法启动新实例 | Windows,Motor-CAD 设置里勾选了 "Hide command Window" | `activex.bat`(`C:\Ansys_Motor-CAD\Shared Files\`)内容格式变化导致 `_find_motor_cad_exe()` 找不到 exe。属已记录的已知 bug,升级 PyMotorCAD 或避免该选项 | GitHub Issue #140 https://github.com/ansys/pymotorcad/issues/140 |
+| 3 | MATLAB `actxserver('motorcad.AppAutomation')` 报 "Server Creation Failed" | MATLAB + Motor-CAD(曾经可用,后失效) | ActiveX/COM 注册问题,多与安装/重装相关。对策:重新注册(Motor-CAD 首次安装后需**重启电脑**完成 ActiveX 注册)、检查注册表 ProgID、DCOM 配置与权限;Ansys 员工建议参考 MathWorks 对同类 COM 错误的 8 种修复方案;更根本的办法是迁移到 PyMotorCAD | Ansys 论坛 https://innovationspace.ansys.com/forum/forums/topic/problems-with-motorcad-automation/ ;GitHub Issue #495 https://github.com/ansys/pymotorcad/issues/495 |
+| 4 | Python `win32com.client.Dispatch("MotorCAD.AppAutomation")` 报 `pywintypes.com_error: (-2146959355, '服务器运行失败')` | 中文用户,集成到自研软件时失败(单独运行正常) | DCOM 配置/权限/防火墙问题。对策:`dcomcnfg` 中配置 Motor-CAD Application Automation 的安全权限、以管理员运行、检查防火墙、确认 ProgID 拼写与安装路径 | 阿里云开发者社区 https://developer.aliyun.com/ask/551036 |
+| 5 | Motor-CAD 报 "Unable to run FE module" | 模型本身无问题(官方复现可算) | 环境/权限问题。对策三步:跑默认模板模型排除模型问题 → **以管理员身份运行 Motor-CAD**(本案例即此解决)→ 确认装在默认目录 `C:\ANSYS_Motor-CAD`,否则重装到默认路径 | Ansys 论坛 https://innovationspace.ansys.com/forum/forums/topic/ansys-motor-cad-unable-to-run-fe-module/ |
+| 6 | 自适应模板旋转转子极区域后 FEA 求解器识别不了绕组区域 | Motor-CAD 2025 R1 + PyMotorCAD 0.7 | 旋转后绕组区域被移到 template-other 节点下,区域树关系丢失。属 adaptive geometry 已知问题,跟踪 Issue | GitHub Issue #473 https://github.com/ansys/pymotorcad/issues/473 |
+| 7 | 模型求解报几何错误,曾正常 | — | **重复的转子几何区域**(自定义几何/FEA Editor 脚本编辑产生)。删除重复区域即恢复。启示:脚本改几何后要做区域树校验 | Ansys 论坛 https://innovationspace.ansys.com/forum/forums/topic/ansys-motorcad-2/ |
+| 8 | 启动时"无法获取许可证" | — | 检查许可证服务器网络连通性(ping)、许可证文件路径与有效期、Ansys License Manager 服务重启、防火墙放行 Ansys 许可端口、许可数量是否占满 | CSDN 文库 https://wenku.csdn.net/answer/7d9nmw0zdbw5 |
+| 9 | 旧 ActiveX 示例中 `MagWindingType` 等参数名失效 | PyMotorCAD 示例与版本演进 | 参数名随版本改名(如 `MagWindingType` → `MagneticWindingType`)。设计系统时参数名应做成可配置映射表,勿硬编码 | GitHub Issue #319 https://github.com/ansys/pymotorcad/issues/319 |
+
+### 3.3 从案例中提炼的排障清单(建议写入仿真系统的自检模块)
+
+1. **连接层**:`pymotorcad.MotorCAD()` 失败 → 检查是否同机混装多版本、Automation 注册版本(Defaults → Automation)、端口被占用/防火墙拦截 localhost、是否勾选了 "Hide command Window"。
+2. **权限层**:COM/FE 模块类错误 → 管理员身份运行、默认路径 `C:\ANSYS_Motor-CAD` 安装、首次安装后重启完成 ActiveX 注册。
+3. **许可层**:拿不到 license → License Manager 服务、端口、文件有效期、并发数。
+4. **模型层**:几何类失败 → 区域闭合性与逆时针顺序(`is_closed()`)、重复区域、Adaptive 脚本先 `reset_adaptive_geometry()`。
+5. **脚本层**:所有 API 调用包 try/except `MotorCADError`;参数名做版本适配;批处理前关弹窗(`MessageDisplayState=2`)并在 finally 中恢复;结果导出校验文件实际生成。
+6. **日志层**:内部脚本用 `mc.show_message()` 写入 Motor-CAD 消息窗口(带时间戳,天然形成运行日志);外部脚本自行记录每次 set/calc/get 的参数与返回,便于复现。
+
+---
+
+## 四、GitHub 上的 Motor-CAD 自动化项目/工具盘点
+
+> 结论先说:**GitHub 上真正可用、持续维护的 Motor-CAD 自动化项目基本只有 Ansys 官方的 `ansys/pymotorcad`**;没有形成规模的第三方"Motor-CAD 自动化 Skill/框架"。检索 `motor-cad`、`pymotorcad` 两个 topic 均显示"尚无公开仓库使用",第三方内容以零散示例为主,且搜索中出现的"破解版/激活版"仓库(如 `Alez1704/ansys-motorcad-15-2-2-unlocked-edition`)**为盗版资源,务必避开,不要引入任何工程环境**。
+
+| 项目 | 地址 | 性质 | 说明 |
+|---|---|---|---|
+| **ansys/pymotorcad** | https://github.com/ansys/pymotorcad | 官方、MIT、持续维护(~30 star,2026 年仍在活跃发版) | 核心库 + `examples/` 完整样例(基础、内部脚本、自适应模板库、Twin Builder/Motion 耦合、参数扫描)。**做仿真系统的首选参考实现**:源码里 `rpc_client_core.py` 展示了实例发现、连接、错误封装;issues/PR 是最好的故障案例库 |
+| DeepWiki 对 ansys/pymotorcad 的结构化解读 | https://deepwiki.com/ansys/pymotorcad/5.1-adaptive-templates | 第三方(AI 生成的代码库导读) | 适合快速理解代码架构:几何对象体系、自适应参数、内外脚本执行上下文(`is_running_in_internal_scripting()` 分支) |
+| Motor-CAD 热模型导入 Simulink/Simscape 示例 | GitHub 搜索 "motorcad" 可见(mathworks 相关仓库) | 组织分享 | Motor-CAD Thermal → Simulink/Simscape 的模型导入示例,做系统级联合仿真可参考 |
+| 零散的 "motorCAD automation" 个人仓库 | GitHub 搜索可得(个位数 star) | 个人分享 | 质量参差,仅作灵感参考 |
+| Ansys Innovation Space 并行计算课程配套脚本 | https://innovationspace.ansys.com/certifications/courses/running-parallel-ansys-motor-cad-calculations-through-scripting/ | 官方 | Python multiprocessing 并行驱动多 Motor-CAD 实例的完整示例(任务调度、排队、结果归集) |
+
+如果你的目标是"给仿真系统找一个现成的自动化框架级 Skill"——目前公开生态里没有,可行路线是:**以 `ansys/pymotorcad` 为底座,自行封装一层任务队列 + 异常重试 + 日志的调度层**(并行模式直接参考官方并行课程与 optiSLang 集成方案)。
+
+---
+
+## 五、社区与第三方分享(组织/个人)
+
+- **Ansys 官方社区(两个都要收藏)**
+  - Ansys Developer Discussions(PyMotorCAD 官方答疑区):https://discuss.ansys.com/
+  - Ansys Innovation Space 论坛(Motor-CAD 板块):https://innovationspace.ansys.com/forum/
+- **CADFEM(Ansys 渠道合作伙伴,组织分享)**
+  - 2021 技术日《Scripting and Parallelization for Motor-CAD》PDF:ActiveX 三命令模式、MATLAB/VBS 最小示例、Blackbox 并行化:https://www.cadfem.net/fileadmin/user_upload/05-cadfem-informs/resource-library/2021_siehr_CADFEM_Techday2_scripting_and_parallelization_for_motorcad.pdf
+  - 2023 R1 更新要点(PyMotorCAD 取代 ActiveX 的官方背景说明):https://www.cadfem.net/fileadmin/user_upload/CADFEM_CH/2023/PUB-CADFEM_Update_2023R1_LF_WBNR-FR.pdf
+- **SimuTech Group(Ansys 精英渠道商,组织分享)**:用 Stochos 贝叶斯优化 + PyMotorCAD 做电机设计优化的完整工作流(Evaluator 函数封装模式值得借鉴):
+  https://simutechgroup.com/resources/blog/bayesian-optimization-electric-motor-design-using-stochos-and-motor-cad/
+- **Ansys Developer Blog**:PyMotorCAD Cheat Sheet 发布说明:https://developer.ansys.com/blog/pymotorcad-cheat-sheet
+- **中文社区排障帖**(个人分享,可作线索、注意甄别):阿里云开发者社区 DCOM 排障 https://developer.aliyun.com/ask/551036 ;CSDN 文库许可证问题 https://wenku.csdn.net/answer/7d9nmw0zdbw5
+
+---
+
+## 六、给仿真系统编程设计的建议(基于以上资料)
+
+1. **接口选型**:新系统一律基于 PyMotorCAD(JSON-RPC),不要再写 ActiveX;需要兼容存量脚本时用 `MotorCADCompatibility`。
+2. **架构参考**:`ansys/pymotorcad` 源码本身是最好的"仿真软件自动化封装"参考——单类 API、异常即错误的语义、内外脚本双上下文判断(`is_running_in_internal_scripting()`)、实例生命周期参数(`open_new_instance` / `keep_instance_open`)。
+3. **健壮性设计**:全链路 try/except `MotorCADError` + 重试;弹窗状态用 try/finally 恢复;参数名做版本映射表;每个任务落盘运行日志(时间戳 + 输入参数 + 结果/异常)。
+4. **并行与调度**:参考官方并行课程用 Python multiprocessing 驱动多实例;批量任务前确保许可数量(每实例占用 license)并规划 BlackBox 无头模式。
+5. **自检模块**:按 3.3 的五层清单(连接/权限/许可/模型/脚本)做启动自检,可显著降低现场排障成本。
+6. **合规提醒**:PyMotorCAD 是 MIT 开源,但驱动 Motor-CAD 必须有合法授权;GitHub 上的"激活版/破解版"仓库一律不要碰。

+ 0 - 0
第三方评审/PCB轴向磁通电机Motor-CAD仿真策略评审与实施建议.md → PCB轴向磁通电机Motor-CAD仿真策略评审与实施建议.md


+ 96 - 2
PCB轴向磁通电机自动化仿真系统设计方案介绍.md

@@ -2,10 +2,11 @@
 
 | 项目 | 内容 |
 |---|---|
-| 文档版本 | V1.1 |
+| 文档版本 | V2.0(实现现状同步) |
 | 作者 | Car.Lin |
 | 编制日期 | 2026-08-26 |
-| 文档状态 | 初稿 / 待评审 |
+| 文档状态 | 已实施同步(2026-08-29,P1~P3 平台化落地) |
+| 参考原型 | axial_mag_pull(Motor-CAD 轴向磁拉力仿真项目) |
 | 参考原型 | axial_mag_pull(Motor-CAD 轴向磁拉力仿真项目) |
 
 ---
@@ -907,3 +908,96 @@ Phase4 │              │█████████████████
 *文档结束 — V1.1,作者 Car.Lin,2026-08-26*
 
 > **V1.1 更新说明**:明确AI模型部署策略(初期API-Key,第二期内网部署);SSSR默认模板采用MARS-12S10P模型;执行程序目标用户为仿真工程师;经验库冷启动策略调整为运行中自动积累;仿真耗时基准按现有案例参考、后续日志自动修正。
+
+
+---
+
+## 附录 B:实现现状对照(V2 增补,2026-08-29)
+
+> 本附录由 V1.1 设计蓝图出发,对照 2026-08-29 已实施的 P1~P3 平台化批次(及 P4 进行中项),
+> 如实记录"设计与实现"的差距,消除文档漂移。设计蓝图章节保持原文不变,以下为增量对照。
+
+### B.1 版本记录
+
+| 版本 | 日期 | 内容 |
+|---|---|---|
+| V1.1 | 2026-08-26 | 设计蓝图初稿(架构/接口/算法选型/路线图) |
+| V2.0 | 2026-08-29 | 增补实现现状对照:afmcore 共享核心层、方案 Schema 单一权威、双系统解耦落地、执行策略实际实现、adaptive 闭环、可靠性增强、已知限制 |
+
+### B.2 共享核心层(src/afmcore)
+
+V1.1 设想"通用可扩展平台";P1~P3 已把可复用能力沉淀到共享核心层,Web 端与本地 EXE 双端接入:
+
+| 模块 | 职责 | 实现状态 |
+|---|---|---|
+| `src/afmcore/metrics.py` | 25 项指标定义 + 归一化解析器(单一事实源) | ✅ 已实施(P1) |
+| `src/afmcore/topology.py` | 拓扑注册表(SSSR 8 组 37 项参数体系,DRSS/SDSR 预留) | ✅ 已实施(P2) |
+| `src/afmcore/adapters/` | 仿真适配器抽象(SimulationAdapter 注册表 + MotorCADAdapter/FakeAdapter) | ✅ 已实施(P1) |
+| `src/afmcore/strategies/` | 执行策略注册表(full_factorial / lhs / adaptive 三实现) | ✅ 已实施(P3-M1) |
+| `src/plan_schema.py` | 方案 JSON Schema(dataclass + parse/validate,单一权威) | ✅ 已实施(P4-M1 增强) |
+
+### B.3 方案 Schema 单一权威(P4-M1)
+
+- **权威**:`src/plan_schema.py`(`parse_plan()` / `validate_plan_dict()` / `SimulationPlan.validate(require_model_path=…)`)。
+- **Web 端接入**:`main.py` 注入仓库根路径;`plans.py` 创建/更新、`ai_plan.py` 生成保存均先校验,非法返回 400/422。
+- **别名容错**:扫描变量 `min_value/max_value` 自动映射 `start/stop`,避免字段名不一导致的静默错误点。
+- **分级校验**:draft 阶段不强制 model_path 存在,执行阶段强制。
+
+### B.4 双系统解耦落地(Web 智能层 + 本地执行层)
+
+V1.1 §2/§5 的接口契约已落地为:
+
+```
+Web 端(方案生成/优化)
+   │  POST /api/plans/{id}/start-simulation  → create_task(task_type=scan|adaptive_batch)
+   │  任务状态机:pending → dispatched → running → completed / failed / cancelled
+   ▼
+本地执行器(scripts/task_executor.py,可多实例 --instances N)
+   │  GET /api/tasks?status=pending → 原子认领(dispatch_task 条件更新,恰好一次)→ 执行 → report_results(point_id 回填)
+   ▼
+Web 端(SimulationResult 入库 → 分析 → 经验库)
+```
+
+- 状态词统一由 `src` 侧契约(`task_contract.py`)保证。
+- 执行器心跳/监控:`/api/executor/heartbeat|status|overview`。
+
+### B.5 执行策略:设计蓝图 vs 实际实现
+
+| V1.1 设计蓝图(§6) | 实际实现(P3) | 说明 |
+|---|---|---|
+| Morris 灵敏度筛选 | `full_factorial`(全因子) | 蓝图作为高级目标;实际先提供确定性策略 |
+| LHS 拉丁超立方采样 | `lhs` 策略 | ✅ 已实现 |
+| Kriging 代理模型 + NSGA-II 多目标 | `adaptive`(可行性优先 + active learning + trust region) | 蓝图的高级全局优化管线未全量落地;`adaptive` 以"L0 预筛选 + 初始采样 + 主动学习选批 + 局部 trust region 细化"实现,收敛判断/预算控制完整 |
+| 约 50~110 次仿真/轮 | `max_solver_calls` 预算控制(默认 80) | 口径一致 |
+
+### B.6 Adaptive 闭环链路(P3-M6,已打通)
+
+```
+AI 方案(Kimi,可降级纯定量)→ L0 预筛选 → 初始 LHS 采样
+  → active learning 选批(search.select_next_batch)
+  → submit_batch_to_executor(打包为 adaptive_batch Task,含 loop_id/batch_id/point_ids)
+  → 本地执行器认领/求解(mock 或真实 Motor-CAD)
+  → report_results 回填(point_id 对齐)→ 分析 → 经验库
+  → 收敛 / 预算耗尽 → 结束
+```
+
+### B.7 可靠性增强(P3 收尾,2026-08-29)
+
+- **并发原子认领**:`task_manager.dispatch_task` 改为条件 UPDATE + rowcount 判定,多执行器竞争同一任务恰好一次成功(`test_p3_concurrency.py`)。
+- **断点恢复**:`FeasibilityFirstSearch.import_state()` + `AdaptiveLoop.export_state()/restore_state()` + `GET /loops/{id}/export`、`POST /loops/import`,进程重启后可恢复搜索状态并继续(`test_p3_checkpoint.py`)。
+- 真实 Motor-CAD 烟雾验证:连接→基线加载→求解→解析 21 指标,back_emf=11.15V 与历史一致(TEST-010)。
+
+### B.8 已知限制与后续(P4 进行中)
+
+| 项 | 状态 |
+|---|---|
+| 本地 EXE 打包(PyInstaller,headless 执行器) | P4-M3 进行中 |
+| 前端 adaptive 视图(批次可视化/收敛曲线) | P4-M4 规划中(当前 UI 为全因子展示) |
+| L0 预筛选上提共享核心层 | P4-M5 规划中(当前在 web 端) |
+| 多物理场(L2 热/结构、L3 Maxwell/JMAG) | 接口预留,未接入执行 |
+| 真实 Motor-CAD 依赖 license server(1055@localhost) | 环境依赖,脚本内置检查与降级 |
+| AI 分析依赖 Kimi API key(无 key 自动降级纯定量) | 已实现门控 |
+
+---
+
+*附录 B 为 V2.0 增量;设计蓝图正文保持 V1.1 原样。*

+ 134 - 172
README.md

@@ -4,11 +4,14 @@
 
 | 项目 | 内容 |
 |---|---|
-| 文档版本 | V1.4(代码评审修复) |
+| 文档版本 | V2.1(P6-M1 完成:前端 UI/UX 重构) |
 | 作者 | Car.Lin |
 | 启动日期 | 2026-08-27 |
-| 当前状态 | Phase 1/2/3/4 全部完成,第三方代码评审 P0 问题已修复 |
-| 设计方案 | [PCB轴向磁通电机自动化仿真系统设计方案介绍.md](PCB轴向磁通电机自动化仿真系统设计方案介绍.md) |
+| 当前状态 | P1~P5 全部完成;**P6 前端体验优化进行中**:P6-M1(UI/UX 全面重构)✅;P6-M2(参数目录单一事实源)🔳 待办;P6-M3(流程引导+检查清单+耗时校准)🔳 待办。进度权威来源:[docs/HANDOFF.md](docs/HANDOFF.md) 第 3 节 |
+| 更新日志 | [CHANGELOG.md](CHANGELOG.md)(全部历史更新,本文件只保留摘要) |
+| 设计方案 | [PC轴向磁通电机自动化仿真系统设计方案介绍.md](PCB轴向磁通电机自动化仿真系统设计方案介绍.md)(V2.0,已同步实现现状) |
+| 论文知识库 | [docs/PAPER_KNOWLEDGE_BASE.md](docs/PAPER_KNOWLEDGE_BASE.md)(无铁心PCB-AFPM 38篇论文精读整理,供软件工程师参考) |
+| 上手指南 | [docs/P1-P5交付总结与上手指南.md](docs/P1-P5交付总结与上手指南.md)(P1-P5 全盘核对 + 目录结构 + 快速开始 + 工程纪律 + 测试体系,新人必读) |
 | 远程仓库 | https://gogsgit.ez4l.com/carlin/pcb-afm-simulation-system |
 
 ---
@@ -19,114 +22,53 @@
 
 - **系统一(Web端)**:输入边界条件 → 基于经验库+规则+AI生成仿真方案 → 人工确认 → 下发
 - **系统二(本地EXE)**:读取方案 → 驱动Motor-CAD自动仿真 → 输出结果 → 回传
+- **共享核心层 `src/afmcore/`**:指标/拓扑/适配器/策略/L0预筛选的单一事实源,双系统共用
 - **核心闭环**:边界条件 → 方案 → 仿真 → 结果 → 反馈调整 → 经验库积累
 
 ---
 
-## 已完成里程碑
-
-### Phase 1(最小闭环)— 全部完成
-
-| 里程碑 | 内容 | 状态 | Commit |
-|---|---|---|---|
-| M1 | 环境验证 + 单工况仿真脚本 | ✅ 完成 | `75347b6` |
-| M2 | 参数扫描引擎(单参数/多参数) | ✅ 完成 | `a43d971` |
-| M3 | 方案JSON接口 + 本地PySide6 GUI | ✅ 完成 | `cbd9402` |
-| M4 | 经验库雏形 + 反馈闭环 | ✅ 完成 | `f20bef9` |
-
-**Phase 1 验证结果**(MARS-12S10P SSSR,气隙扫描3点):
-- 平均转矩 0.4677~0.5663 Nm,转矩脉动 1.75~5.45%,效率 84.93~86.35%
-- 物理趋势全部符合预期(转矩/脉动随气隙增大而减小,效率略升)
-
-### Phase 2(Web端方案系统)— 全部完成
-
-| 里程碑 | 内容 | 状态 | Commit |
-|---|---|---|---|
-| P2-M1 | Web端基础框架(FastAPI + Vue3 + SQLite + CRUD API) | ✅ 完成 | `1ea7653` |
-| P2-M2 | 边界条件输入 + 方案编辑器(规则引擎生成方案) | ✅ 完成 | `a95c0db` |
-| P2-M3 | 经验库Web端增强 + 结果分析仪表盘(ECharts) | ✅ 完成 | `9885437` |
-| P2-M4 | 双系统API联调 + 知识库管理(经验库CRUD/导入/系统二客户端增强) | ✅ 完成 | `25ee19f` |
-| P2-M5 | Phase 2 验收(自动化测试+前端人工验收+文档完善) | ✅ 完成 | `0df69f2` |
-| P2-补丁 | 前端全面中文化 + Dashboard图表对齐修复 | ✅ 完成 | `2660df7` |
-
-### Phase 3(AI驱动智能仿真闭环)— 全部完成
-
-> 基于第三方专家评审意见,将仿真策略从"固定批量DoE"升级为"多保真度+可行性优先+批量自适应闭环",引入Kimi k3大模型。
-
-| 里程碑 | 内容 | 状态 | Commit |
-|---|---|---|---|
-| P3-M1 | AI服务层基础架构(Kimi客户端+JSON Schema V2+多保真度框架) | ✅ 完成 | `0df69f2` |
-| P3-M2 | L0解析预筛选 + 可行性优先搜索框架(LHS+主动学习+信任域) | ✅ 完成 | `7dab664` |
-| P3-M3 | AI方案生成器(自然语言→结构化仿真方案) | ✅ 完成 | `c12a8e6` |
-| P3-M4 | AI结果分析师 + 多保真度校准 + 置信等级(A-D)+ 六类收敛判据 | ✅ 完成 | `29f6cbd` |
-| P3-M5 | 经验库AI增强 + 批量自适应闭环 + 整体验收 | ✅ 完成 | `2d71424` |
-
-**Phase 3 核心能力**:
-- **Kimi k3 大模型集成**:API端点 `https://api.kimi.com/coding/v1`,模型 `k3`
-- **L0 解析预筛选**:14+约束检查(几何/电气/热/制造),排除明显不可行区域
-- **可行性优先搜索**:LHS初始采样 + 主动学习批量选点 + 局部信任域精修
-- **多保真度校准**:L0(解析)→L1(磁路)→L2(2D FEA)→L3(3D FEA)→L4(瞬态热耦合),偏差修正
-- **置信等级评估**:A-D四级,基于保真度(40%)+样本密度(25%)+收敛性(25%)-异常惩罚(10%)
-- **六类收敛判据**:目标稳定性/最优位置/代理误差/约束满足/样本密度/物理一致性
-- **自适应闭环**:自然语言→AI方案→L0筛选→主动搜索→仿真→AI分析→经验提取→下一批
-- **32个P3 API端点**:覆盖AI/搜索/方案生成/结果分析/自适应闭环全流程
-
-### Phase 4(Web端AI集成 + 双系统闭环 + 批量调度 + 部署)— 全部完成
-
-> 将P3的AI能力前端化,打通Web端与本地仿真执行的双系统闭环,实现批量调度、实时监控、高级可视化和部署打包。
-
-| 里程碑 | 内容 | 状态 | Commit |
-|---|---|---|---|
-| P4-M1 | 前端AI功能集成(6个AI页面 + API封装 + 通用组件) | ✅ 完成 | `0da99ed` |
-| P4-M2 | 双系统任务下发与回传(后端任务管理 + 本地执行器 + 前端任务页) | ✅ 完成 | `25ae70f` |
-| P4-M3 | 批量调度 + 实时监控 + 增强版MotorCAD核心(16项鲁棒性措施) | ✅ 完成 | `315383c` |
-| P4-M4 | 高级可视化(4种ECharts图表) + 自动报告生成(Word/JSON) | ✅ 完成 | `fb5a72e` |
-| P4-M5 | 部署打包(Docker + docker-compose + nginx + Windows部署脚本) + 验收测试 | ✅ 完成 | `35b3f2a` |
-| P4-补丁1 | 修复export_results API兼容性(pymotorcad 0.8.8需要solution_type参数) | ✅ 完成 | `f3b492a` |
-| P4-补丁2 | 扩展指标别名(12→20个指标) + 测试脚本弹窗抑制 | ✅ 完成 | `6a8bc80` |
-
-**Phase 4 核心能力**:
-- **前端AI页面(10个新增页面)**:AI方案生成/L0预筛选/自适应优化/AI结果分析/多保真度校准/经验库增强/任务管理/实时监控/高级可视化
-- **双系统闭环**:Web创建任务 → 本地执行器轮询 → 领取任务 → 执行仿真 → 上报进度 → 回传结果 → Web存储展示
-- **批量调度器**:优先级队列(1-10) + 最大并行(默认2) + 任务依赖 + 断点续跑 + 实时统计
-- **实时监控仪表盘**:6项统计卡片 + 运行中/排队/历史任务面板 + 5秒自动刷新
-- **高级可视化**:Pareto前沿散点图 + 收敛轨迹折线图 + 多方案雷达图 + 参数敏感性热力图
-- **增强版MotorCAD核心(16项鲁棒性措施)**:
-  - 连接安全:open_new_instance=True + set_visible(True) + BlackBox无头模式
-  - 错误处理:MotorCADError优先捕获 + 单点超时重试 + 实例崩溃自动重连
-  - 批量安全:MessageDisplayState=2弹窗抑制 + try/finally恢复 + 参数写入回读校验 + 每点基线重载
-  - 数据完整:分号CSV解析 + 中英文字段别名 + 逐点双写(CSV+JSON) + flush+fsync
-  - 五层自检:连接/权限/许可/模型/脚本层启动前自检
-  - 变量名版本映射:可配置映射表,不硬编码
-- **自动报告生成**:Word报告(封面/参数/结果/AI分析/建议) + JSON fallback
-- **部署方案**:Dockerfile + docker-compose + nginx反向代理 + Windows一键部署脚本
-- **Motor-CAD实测验证**(MARS-12S10P,5000rpm):连接→计算→导出→解析全流程成功,效率86.06%,总损耗41.945W,磁场计算138.1s/点
-
-### 代码评审修复(V1.4)
-
-第三方代码评审报告 V1.0(2026-08-28)后,修复了 20+ 项 P0 问题:
-
-| 类别 | 修复项 |
+## 最近更新(摘要)
+
+> 完整历史记录见 [CHANGELOG.md](CHANGELOG.md);里程碑明细见 [docs/P1-P5交付总结与上手指南.md](docs/P1-P5交付总结与上手指南.md)。
+
+| 日期 | 内容 |
 |---|---|
-| 数据完整性 | A1 指标清单单一事实源(metrics_constants.py);A2 mock假数据静默上报改为failed;A3/A4/A5 task_executor复用RobustMotorCADSolver;A7 GUI plan回写;A8 204空响应处理 |
-| 稳定性 | B1 BatchScheduler死锁(Lock→RLock);B2 closeEvent防QThread崩溃;B3 输入校验防全局崩溃;B4 finally disconnect防进程泄漏;B5 失败点落盘;B7 本地任务防重复执行 |
-| AI逻辑 | C1 转矩方向显式映射表;C2 六类收敛判据全部接入;C3 L0最低覆盖判定;C4 信任域锚点按方向选max/min;C5 信任域中心随最优点迁移 |
-| 前端 | D1 6个AI页面994处\uXXXX还原中文;D2 axios响应拦截器统一.data;D3 ECharts动态导入修复;D4 热力图改为真实参数聚合 |
-| 部署/安全 | E1 .dockerignore防密钥泄露;E2 空值环境变量不覆盖.env;E3 SQLite持久化卷;F2 级联删除+PRAGMA foreign_keys;F3 web后端.py纯ASCII合规 |
-| 纪律 | F4 Git preflight改为硬拒绝;F5 GET download只读,状态迁移改POST |
+| 2026-09-04 | **热仿真首次打通**:Motor-CAD 稳态热仿真实测跑通(MARS:电磁 127.8s + 热 6.0s),修正 P5-M6 两个错误 API(`do_thermal_calculation`→`do_steady_state_analysis`、`export_results("Thermal")`→`"SteadyState"`),热指标别名实测登记(绕组 68°C/热点 74.6°C/磁钢 118°C/后轴承 88.5°C);新增 `scripts/run_thermal.py`。⚠ MARS 模型环境温度 `Ambient_Temperature=125` 异常待修正 |
+| 2026-09-03 | **仿真失败根因修复**:MARS 几何变量名实测修正(RotorOuterDiameter/Stator_Lam_Dia/Stator_Bore/Back_Iron_Thickness/PhaseAdvance/材料名 N42UH)+ 生产链路 3 修复(拓扑预检变量集/执行器 dispatch 状态机/适配器导入路径),**多点扫描全链路实测通过**(3 点 Airgap,趋势符合电磁学) |
+| 2026-09-03 | **P6-M2/M3 落地**:BC 字段目录单一事实源(bc_fields + /api/bc-fields + key 统一 normalize_bc)+ 仿真前检查清单 + 耗时校准(实测 solve_time_s)+ 任务自动展开 |
+| 2026-09-03 | **AI 生成链路修复**:Kimi max_tokens 撞顶修复 + topology/strategy 归一化兜底 + AI 生成摘要对话框 + 扫描变量归一化(注册表 8→13,AI 推荐变量不再被误裁) |
+| 2026-09-03 | **UI 评审修复闭环**:统计卡字段名 Bug + Dashboard 结果加载 + BC 空值折叠 + 失败结果错误信息列 + 监控页合并 + 批量清理入口 + 扫描变量编辑持久化 + 列配置持久化 |
+| 2026-09-02 | AI 方案生成 422 修复(前端拓扑枚举 AFIR→SSSR 对齐单一事实源)+ Web 开发环境联调 |
+| 2026-09-01 | AI 协作方法论框架升级(playbook V2)+ 新增 `scripts/check_machine_paths.py` 环境体检 + `docs/HANDOFF.md` |
+| 2026-08-30 | **P6-M1** 前端 UI/UX 全面重构(设计令牌 + 信息架构 + PlanDetail 分层);文档卫生修复;前端 chunk 分包 |
+| 2026-08-30 | **P5-M2 拓扑感知变量名映射**与执行前校验(修复 plan 23 的 80 点全失败)+ variable-catalog API |
+| 2026-08-30 | **P5-M3~M6**:adaptive 三视图 / Morris+IDW 策略 / Maxwell+JMAG 适配器(mock)/ 多物理场 L2 指标(25→35) |
+| 2026-08-30 | **P5-M1~M2**:前端 build 类型错误清零(71→0)/ EXE 配置化 + 真实 Motor-CAD 端到端验证 |
+| 2026-08-29 | 本地执行器真实仿真全链路打通(P2 关键里程碑)+ 方案详情页三栏重构 |
+| 2026-08-29 | 平台化改造五批(afmcore/拓扑注册表/策略层/调度契约/执行桥)+ P3 闭环 + P4 收尾(Schema 统一/EXE 打包/部署) |
 
-详见 [docs/CODE_REVIEW_RESPONSE.md](docs/CODE_REVIEW_RESPONSE.md)。
+---
 
-**第二轮修复(P1 + 风险项)**:
+## 已完成里程碑(概要)
 
-| 类别 | 修复项 |
-|---|---|
-| 状态统一 | 3.4-34 新建 status_constants.py,全仓库仿真点状态统一为 "OK"/"FAILED" |
-| Session | 3.4-25 task_manager.py 7处 next(get_db()) 改为 with SessionLocal() |
-| API安全 | E4 可选API Key中间件 + max_tokens上限(8192) |
-| 求解稳定 | 3.1-1 solver_core 单点超时(600s) + 连续失败3次自动重连 |
-| 数据安全 | plan_id加随机后缀防碰撞;分页limit上限200;经验库去重 |
-| 验证 | 75个Python文件语法通过,纯ASCII合规,关键模块导入正常 |
+| 阶段 | 内容 | 状态 |
+|---|---|---|
+| Phase 1 | 最小闭环:环境验证 + 扫描引擎 + 方案JSON/GUI + 经验库雏形(MARS-12S10P 气隙扫描验证,物理趋势符合预期) | ✅ |
+| Phase 2 | Web端方案系统(FastAPI + Vue3 + SQLite + 规则引擎 + 边界条件/方案编辑器) | ✅ |
+| Phase 3 | AI驱动智能仿真闭环(Kimi k3 + L0预筛选 + 可行性优先搜索 + 多保真度 + 自适应闭环,32 个 API 端点) | ✅ |
+| Phase 4 | Web AI集成 + 双系统任务闭环 + 批量调度 + 16项鲁棒性 MotorCAD 核心 + 报告生成 + Docker/EXE 部署 | ✅ |
+| P5 | 平台化增强 M1~M6(build清零 / EXE配置化 / adaptive三视图 / Morris+IDW / 多工具适配器 / 多物理场L2) | ✅ |
+| P6-M1 | 前端 UI/UX 全面重构 | ✅ |
+| P6-M2 | BC 参数目录单一事实源(bc_fields + normalize_bc 统一 key 口径) | ✅ |
+| P6-M3 | 流程引导(仿真前检查清单)+ 耗时校准 + 任务自动展开 | ✅ |
+
+**Phase 3 核心能力**:Kimi k3 大模型集成、L0 解析预筛选(14+ 约束检查)、可行性优先搜索(LHS+主动学习+信任域)、多保真度校准(L0→L4)、置信等级 A-D、六类收敛判据、批量自适应闭环。
+
+**Phase 4 核心能力**:前端 10 个 AI 页面、双系统任务闭环(Web创建→执行器轮询→仿真→回传)、批量调度器(优先级/并行/依赖/断点续跑)、增强版 MotorCAD 核心(16 项鲁棒性措施)、Word/JSON 自动报告、Docker + Windows 一键部署。
+
+**代码评审修复**(第三方评审 V1.0 + 二轮,20+ P0 项):数据完整性 / 稳定性 / AI 逻辑 / 前端 / 部署安全 / 纪律六大类,详见 [docs/CODE_REVIEW_RESPONSE.md](docs/CODE_REVIEW_RESPONSE.md)。
+
+逐里程碑计划 vs 实际交付对照表:[docs/P1-P5交付总结与上手指南.md](docs/P1-P5交付总结与上手指南.md) 第 2 节。
 
 ---
 
@@ -143,20 +85,24 @@
 | Node.js | ≥ 18(Web端前端) | `node --version` |
 | 许可证 | FlexNet `ANSYSLMD_LICENSE_FILE=1055@localhost` | ANSYS License Management Center |
 
+**换机/新会话第一步**:`python scripts/check_machine_paths.py`(只读环境体检,缺项给修复建议;`--fix` 打印修复命令)。
+
 ### 系统二(本地仿真执行端)
 
 ```bash
 # 安装依赖
 pip install ansys-motorcad-core pyside6 pandas
 
-# 单工况验证
-python scripts/run_single.py
-
-# 参数扫描
-python scripts/run_scan.py --plan simulation_plan.json
+# 方式一(推荐):任务执行器 —— 连接 Web 端,认领并执行仿真任务
+python scripts/run_task_executor.py --config executor_config.json
+#   - enable_mock=true 时不启动 Motor-CAD,可离线验证全链路
+#   - 多实例并行:python scripts/run_task_executor_parallel.py --instances 2
+#   - 配置项(web_base_url / model_path / tool / instances 等)见 executor_config.json
 
-# 启动本地GUI
-python scripts/run_gui.py
+# 方式二(调试):独立脚本
+python scripts/run_single.py                                # 单工况验证
+python scripts/run_scan.py --plan simulation_plan.json      # 参数扫描
+python scripts/run_gui.py                                   # 本地 PySide6 GUI
 ```
 
 ### 系统一(Web端方案生成)
@@ -173,26 +119,33 @@ cd web/frontend
 npm install
 npm run dev
 # 前端运行在 http://localhost:5173,自动代理 /api 到后端
+
+# 或 Docker 一键部署(P4-M5)
+docker-compose up -d
 ```
 
-### Web端核心API
+### Web端核心API(节选)
+
+> 完整 API 以 Swagger(`/docs`)为准——P3 起 AI/搜索/自适应闭环共 32+ 端点,此处仅列常用入口。
 
 | 方法 | 路径 | 用途 |
 |---|---|---|
 | GET | `/api/health` | 健康检查 |
 | GET/POST | `/api/projects` | 项目列表/创建 |
 | GET | `/api/scan-parameters` | 可扫描参数注册表 |
+| GET | `/api/plans/variable-catalog?topology=SSSR` | 拓扑感知变量目录(模板参数 + Motor-CAD 实际变量名) |
 | POST | `/api/generate-plan` | 规则引擎生成方案 |
 | POST | `/api/projects/{id}/generate-plan` | 为项目生成方案 |
 | GET | `/api/plans/{id}/download` | 下载方案(系统二兼容格式) |
 | POST | `/api/plans/{id}/upload-results` | 上传仿真结果CSV |
-| GET | `/api/analytics/metrics` | 指标定义列表(11项) |
+| GET | `/api/analytics/metrics` | 指标定义列表(35 项,单一事实源 `src/afmcore/metrics.py`:电磁25+热6+结构4) |
 | GET | `/api/analytics/experience/stats` | 经验库统计(总数/拓扑分布/指标范围) |
 | POST | `/api/analytics/experience/similar` | 相似案例检索(参数距离匹配) |
 | GET | `/api/analytics/plans/{id}/trend` | 参数-指标趋势数据(散点图) |
 | GET | `/api/analytics/plans/{id}/pareto` | Pareto前沿(效率vs损耗) |
 | GET | `/api/analytics/plans/{id}/sensitivity` | 参数敏感性排名(Pearson相关) |
 | GET | `/api/analytics/projects/{id}/overview` | 项目概览统计 |
+| POST | `/api/adaptive/loops/{loop_id}/submit-batch` | 自适应批次下发本地执行器 |
 
 ---
 
@@ -200,55 +153,62 @@ npm run dev
 
 ```
 PCB轴向磁通电机自动化仿真系统/
-├── README.md                          # 本文件
-├── AGENTS.md                          # AI工具工作说明(必读)
-├── .gitignore
-├── PCB轴向磁通电机自动化仿真系统设计方案介绍.md  # 完整设计方案V1.1
+├── README.md / CHANGELOG.md / AGENTS.md    # 说明 / 历史更新 / AI工作准则
+├── executor_config.json            # 执行器侧车配置(web地址/模型/实例数/工具/mock开关)
+├── deploy.ps1 / Dockerfile / docker-compose.yml / nginx.conf   # 部署
+├── PCB轴向磁通电机自动化仿真系统设计方案介绍.md   # 设计方案 V2.0
+├── ai-collab-dev-playbook-v2.md    # AI 协作方法论框架(可复用)
 ├── docs/
-│   ├── KNOWLEDGE_BASE.md              # 核心知识库(方法/参数语义/坑/SOP)
-│   └── CONVERSATION_LOG.md            # 对话与决策记录(带时间戳)
-├── src/
-│   ├── solver_core.py                 # 仿真核心(Motor-CAD连接/参数/求解/结果提取)
-│   ├── scan_engine.py                 # 参数扫描引擎
-│   ├── plan_schema.py                 # 方案JSON Schema定义
-│   ├── experience_db.py               # 经验库(SQLite)
-│   ├── api_client.py                  # Web端API客户端(系统二↔系统一通信)
-│   └── gui/
-│       └── main.py                    # PySide6 GUI主程序
-├── scripts/
-│   ├── run_single.py                  # 单工况快速验证
-│   ├── run_scan.py                    # 命令行扫描入口
-│   ├── run_gui.py                     # 本地GUI启动
-│   └── test_api_client.py             # API客户端测试
-├── web/                                # 系统一(Web端)
-│   ├── backend/                        # FastAPI后端
-│   │   ├── app/
-│   │   │   ├── main.py                # FastAPI入口
-│   │   │   ├── config.py              # 配置
-│   │   │   ├── database.py            # SQLAlchemy数据库
-│   │   │   ├── models/                # ORM模型(Project/Plan/Result/Experience)
-│   │   │   ├── schemas/               # Pydantic Schema
-│   │   │   ├── routers/               # API路由(projects/plans/experience/generation/analytics)
-│   │   │   └── services/
-│   │   │       ├── rule_engine.py     # 规则引擎(参数注册表/范围推荐/方案生成)
-│   │   │       └── analytics.py       # 分析服务(统计/趋势/Pareto/敏感性/相似检索)
-│   │   ├── requirements.txt
-│   │   └── run.py
-│   └── frontend/                       # Vue3前端
-│       ├── src/
-│       │   ├── api/index.ts           # API调用层
-│       │   ├── router/index.ts        # 路由配置
-│       │   ├── layouts/MainLayout.vue # 主布局
-│       │   └── views/                 # 页面(项目列表/详情/方案详情/经验库/仪表盘)
-│       ├── package.json
-│       └── vite.config.ts
-├── models/
-│   └── MARS-12S10P_SSSR_D76-C150_V5.0-0819.mot  # 调试模型(只读)
-├── output/                            # 仿真输出(不入库)
-├── experience/                        # 经验库数据
-├── axial_mag_pull-master/            # 参考案例1:轴向磁拉力仿真
-├── torqrippswap-master/              # 参考案例2:转矩脉动参数扫描+GUI
-└── 书籍与论文/                         # 理论参考资料
+│   ├── HANDOFF.md                  # 接续指南(进度/阻塞/待办/接续提示词,新会话必读)
+│   ├── KNOWLEDGE_BASE.md           # 核心知识库(环境事实/参数语义/坑/SOP)
+│   ├── TEST_RECORDS.md             # 测试记录(TEST-001~024,含索引)
+│   ├── CONVERSATION_LOG.md         # 会话与决策记录
+│   ├── PAPER_KNOWLEDGE_BASE.md     # 无铁心PCB-AFPM论文知识库(38篇)
+│   ├── PLATFORM_DESIGN_V2.md       # 平台化升级设计方案
+│   ├── P1-P5交付总结与上手指南.md   # 里程碑核对 + 新人上手(必读)
+│   ├── CODE_REVIEW_RESPONSE.md     # 代码评审修复记录
+│   ├── 前端界面优化建议_V1.md       # B1~B4 前端优化批次(B3/B4 待办)
+│   └── archive/                    # 已完结历史计划文档(P3/P4实施计划等)
+├── src/                            # 系统二 + 共享核心
+│   ├── afmcore/                    # 共享核心层(单一事实源)
+│   │   ├── metrics.py              # 指标定义 35 项 + 归一化解析器(唯一权威)
+│   │   ├── topology.py             # 拓扑注册表(SSSR 8组37项,DRSS/SDSR预留)
+│   │   ├── adapters/               # 工具适配器(MotorCAD真实 + Maxwell/JMAG mock)
+│   │   ├── strategies/             # 执行策略(full_factorial/lhs/adaptive/morris/surrogate_guided)
+│   │   └── l0/                     # L0 解析预筛选(纯stdlib)
+│   ├── solver_core.py              # 仿真核心(Motor-CAD连接/参数/求解/结果提取)
+│   ├── scan_engine.py              # 参数扫描引擎
+│   ├── plan_schema.py              # 方案JSON Schema(parse/validate单一权威)
+│   ├── experience_db.py            # 经验库(SQLite)
+│   ├── api_client.py               # Web端API客户端(系统二↔系统一通信)
+│   ├── status_constants.py         # 状态常量("OK"/"FAILED"统一)
+│   └── gui/                        # PySide6 GUI
+├── scripts/                        # 33个脚本
+│   ├── run_task_executor.py        # 本地执行器主入口(读executor_config.json)
+│   ├── run_task_executor_parallel.py   # 多实例并行
+│   ├── run_single.py / run_scan.py / run_gui.py    # 调试入口
+│   ├── check_machine_paths.py      # 环境体检(只读,--fix打印修复命令)
+│   ├── build_executable.ps1        # EXE打包(PyInstaller)
+│   └── test_*.py                   # 回归测试(23个,python scripts/test_*.py独立运行)
+├── web/                            # 系统一(Web端)
+│   ├── backend/app/
+│   │   ├── main.py / config.py / database.py
+│   │   ├── models/ / schemas/      # ORM模型 / Pydantic Schema
+│   │   ├── routers/                # 14个路由(projects/plans/tasks/adaptive/ai/ai_plan/
+│   │   │                           #   search/analysis/analytics/executor_monitor/
+│   │   │                           #   experience/generation/monitor/reports)
+│   │   └── services/               # 规则引擎/AI方案生成/自适应闭环/批量调度/
+│   │                               #   报告生成/拓扑变量映射/固定参数模板等
+│   └── frontend/src/               # Vue3 + Element Plus + ECharts
+│       └── views/                  # 9个主视图 + ai/子目录(AI功能页)
+├── models/                         # 基线模型(只读:MARS-12S10P_SSSR .mot 及配套网格/导出文件)
+├── testcase/                       # 测试模型
+├── dist/                           # 打包产物 PCB-AFM-Executor.exe(不入库)
+├── output/                         # 仿真输出(不入库)
+├── experience/                     # 经验库数据(SQLite)
+├── axial_mag_pull-master/          # 参考案例1:轴向磁拉力仿真
+├── torqrippswap-master/            # 参考案例2:转矩脉动参数扫描+GUI
+└── 书籍与论文/                      # 理论参考资料
 ```
 
 ---
@@ -276,7 +236,7 @@ PCB轴向磁通电机自动化仿真系统/
 ## 项目纪律(硬性)
 
 1. **每次运行仿真前 git commit**(脚本改动先入库再跑)
-2. **每阶段完成后更新 README.md**(版本号/状态/路线图)
+2. **每阶段完成后更新 README.md**(版本号/状态/路线图)+ CHANGELOG.md
 3. **结果与报告带时间戳+简要说明并提交**;报告版本化不覆盖
 4. **Motor-CAD前台运行**,跑完保持打开供人工检查
 5. **生成物不入库**(output/、*.log、build/、dist/、node_modules/)
@@ -285,21 +245,23 @@ PCB轴向磁通电机自动化仿真系统/
 8. **每个扫描点重新加载基线模型**,防止参数污染
 9. **对话与决策带时间戳记入 docs/CONVERSATION_LOG.md**
 10. **所有 .py / .ps1 源码纯 ASCII**,中文用 Unicode 转义或放 Markdown
+11. **状态信息单一事实源**:当前进度/阻塞/待办只维护 docs/HANDOFF.md 第 3 节,README 只放链接
+12. **粘贴文本入文档后检查控制字符**(0x07/0x08 等会吞字,历史已发生两次)
 
 ---
 
 ## 阶段路线图
 
+> 阶段编号说明:P4 期间实施的「平台化改造四/五批」与 P4-M1~M5 合并交付(Schema 统一、EXE 打包均已在 P4 完成);P5 为平台化增强 M1~M6;P6 为前端体验优化。逐里程碑明细见 [docs/P1-P5交付总结与上手指南.md](docs/P1-P5交付总结与上手指南.md)。
+
 | 阶段 | 目标 | 状态 |
 |---|---|---|
-| **Phase 1 M1** | 环境验证 + 单工况仿真脚本 | ✅ 完成 |
-| **Phase 1 M2** | 参数扫描引擎(单参数/多参数) | ✅ 完成 |
-| **Phase 1 M3** | 方案JSON接口 + 本地GUI | ✅ 完成 |
-| **Phase 1 M4** | 经验库雏形 + 反馈闭环 | ✅ 完成 |
-| **Phase 2 P2-M1** | Web端基础框架(FastAPI + Vue3 + SQLite) | ✅ 完成 |
-| **Phase 2 P2-M2** | 边界条件输入 + 方案编辑器(规则引擎) | ✅ 完成 |
-| **Phase 2 P2-M3** | 经验库Web端 + 结果分析仪表盘 | 🔲 待开始 |
-| **Phase 2 P2-M4** | 双系统API联调 + 知识库管理 | 🔲 待开始 |
-| **Phase 2 P2-M5** | Phase 2 验收 | 🔲 待开始 |
-| Phase 3 | 算法增强(Morris/LHS/Kriging/NSGA-II)+ DRSS拓扑 | 🔲 规划中 |
-| Phase 4 | 多物理场(热/结构)+ 多工具扩展 | 🔲 规划中 |
+| Phase 1(M1~M4) | 最小闭环:环境验证 + 扫描引擎 + 方案JSON/GUI + 经验库雏形 | ✅ 完成 |
+| Phase 2(P2-M1~M5) | Web端方案系统(FastAPI + Vue3 + SQLite + 规则引擎) | ✅ 完成 |
+| Phase 3 | AI驱动智能仿真闭环(Kimi + 可行性优先搜索 + 自适应) | ✅ 完成 |
+| Phase 4 | Web AI集成 + 双系统闭环 + 批量调度 + Schema 统一 + EXE 打包 + 部署 | ✅ 完成 |
+| P5 平台化增强 | M1 前端build清零 / M2 EXE配置化 / M3 adaptive三视图 / M4 Morris+IDW策略 / M5 多工具适配器 / M6 多物理场L2接入 | ✅ 全部完成 |
+| **P6 前端体验优化** | M1 UI/UX全面重构(设计令牌+信息架构+PlanDetail分层) | ✅ 完成 |
+| P6-M2 | B3 参数目录单一事实源(前后端协同) | 🔳 待办 |
+| P6-M3 | B4 流程引导 + 仿真前检查清单 + 耗时校准 | 🔳 待办 |
+| 路线扩展 | 热求解真实验证 + Maxwell/JMAG 真实接入 + Kriging 升级 + DRSS/SDSR 拓扑 | 🔳 规划中 |

+ 610 - 0
ai-collab-dev-playbook-v2.md

@@ -0,0 +1,610 @@
+# AI 协作开发 Playbook(通用框架)
+
+> 一份**通用的、与 AI 协作开发**的方法论与模板。
+> 本文档从两个真实工程复盘提炼,但**主文不绑定任何具体项目**——MARS 电机热流体仿真工程与 PCB 轴向磁通电机自动化仿真系统仅作为「案例来源 / 参考实现」出现在附录,供理解与对照学习。
+> 目标:在任何新项目上,与 AI 协作时能**快速搭好框架、立刻开始干活**,且过程可复现、可交接、可沉淀。
+
+> **命名说明**:本框架把「给 AI 的行为准则」统一命名为 **`AGENTS.md`**(业界通用标准文件名,Claude Code / Codex / Cursor 等主流 AI 工具默认自动读取)。
+
+---
+
+## 版本更新记录
+
+> 本文档会持续迭代。每次改动在此追加一条:**版本号 / 日期 / 改动内容 / 影响**。
+
+| 版本 | 日期 | 改动内容 | 影响 |
+|---|---|---|---|
+| **V1.0** | 2024(MARS 项目沉淀) | 初版:三支柱文件(README/AGENTS/HANDOFF)+ 会话日志 + 规矩前置 + 接口文件化 + 验收量化 + 铁律带"为什么" + 换机接续 + 五步启动法 + 模板库 | 建立基础方法论,从 MARS 电机热仿真工程实战提炼 |
+| **V2.0** | 2026-09-01 | 融合第二个项目(PCB 自动化仿真系统)实战经验:三支柱扩展为**五支柱**(+KNOWLEDGE_BASE + 留痕双件套);新增里程碑管理、测试纪律、工程规范(禁臆测/自查清单/交付声明)、反模式自查表;新增 KNOWLEDGE_BASE / TEST_RECORDS / 里程碑三套模板 | 从"单项目经验"升级为"多项目通用框架" |
+| **V2.1** | 2026-09-01 | **通用化重构**:主文移除所有具体项目描述(路径/参数/工具名),改为通用占位;两个真实项目降级为「案例来源」移入附录;新增本「版本更新记录」 | 文档成为不绑定项目的通用框架,可直接复制到任何新项目 |
+
+> **后续迭代约定**:新增一条版本记录时,写明「版本号 / 日期 / 改动内容 / 影响」四列;若改动较大,可在「改动内容」里分条说明。
+
+---
+
+## 0. 核心结论(30 秒读完)
+
+AI 协作开发的效率,**不取决于 AI 的能力,而取决于工程的秩序**。
+
+从真实项目复盘得出,真正让开发又快又稳的,是五件事:
+
+1. **五支柱文件** —— README(项目全貌)+ AGENTS.md(给所有 AI 的规矩)+ HANDOFF.md(接续指南)+ KNOWLEDGE_BASE.md(环境事实与踩坑)+ 留痕双件套(session_log + TEST_RECORDS)。AI 每次开工读一遍,就能带着全部上下文干活。
+2. **一条日志线** —— 每次对话都写 session_log,把「目标→动作→结论→踩坑→遗留」沉淀为组织记忆;每次测试写 TEST_RECORDS,把「环境→步骤→结果→问题→修复」落成可追溯记录。AI 永远不会重复踩坑。
+3. **规矩前置 + 铁律带"为什么"** —— 开工前把协作约定写成显式规则;每条血泪教训配"为什么",AI 才知道何时严格遵守、何时可以变通。
+4. **验收量化 + 独立验证** —— 每个里程碑都有可复核的数值基准,且**校验方式 ≠ 产出方式**;AI「看似完成」时能立刻被识别。
+5. **里程碑闭环 + 可交接** —— 用 Phase/Milestone 管理进度,每阶段完成即更新 README;HANDOFF + 接续提示词 + 环境体检脚本,让换人/换机/换会话在 10 分钟内接上。
+
+---
+
+## 1. 通用方法论要点(每个要点后附「案例」供对照学习)
+
+### 1.1 五支柱文件:把"上下文"变成"资产"
+
+五个文件分工,让任何新接手者(人类或 AI)能在 10 分钟内进入工作状态:
+
+| 支柱 | 文件 | 写给谁 | 职责 | 关键内容 |
+|---|---|---|---|---|
+| ① 全貌 | **README.md** | 人类/全局 | 项目是什么、怎么跑、做到哪了 | 目标、目录表、工具链、常用命令、约定、最近更新 |
+| ② 规矩 | **AGENTS.md** | 所有 AI(自动读取) | AI 的行为准则 | 工作约定、关键接口、铁律、已知坑、文档索引、反模式自查 |
+| ③ 接续 | **HANDOFF.md** | 接续者 | 换人/换机/续作入口 | 环境要求、恢复步骤、当前进度、阻塞点、接续提示词 |
+| ④ 知识 | **docs/KNOWLEDGE_BASE.md** | 人 + AI | 环境事实、参数语义、探测技术、SOP、已踩的坑 | 环境验证命令、常见问题表、连接/求解 SOP、踩坑清单 |
+| ⑤ 留痕 | **docs/session_log.md + docs/TEST_RECORDS.md** | 组织记忆 | 对话留痕 + 测试留痕 | 目标→动作→结论→踩坑→遗留;环境→步骤→结果→问题→修复 |
+
+**要点**:
+- README 是「静态全貌 + 滚动更新」,HANDOFF 是「动态状态」,AGENTS 是「行为规则」,KNOWLEDGE_BASE 是「环境与坑的事实库」,留痕双件套是「过程记录」。五者分工明确,不要混在一起,否则更新时互相打架。
+- **session_log 与 TEST_RECORDS 必须分离**:session_log 记"对话做了什么、踩了什么坑、遗留什么";TEST_RECORDS 记"测试怎么跑的、结果如何、修了什么"。混淆会导致追溯困难。
+- AGENTS.md 之所以有效,是因为主流 AI 工具在项目目录里工作时**会自动读取它**。你要做的,是把「期望 AI 怎么干活」全部写进去。
+
+> **案例对照**:①—④ 出自两个真实项目的共同实践;⑤「留痕双件套」在第一个项目只做了会话日志,第二个项目补上了 TEST_RECORDS 测试记录,并验证了二者分离的价值。
+
+### 1.2 会话日志 + 测试记录 = 组织记忆
+
+**会话日志**(统一模板):
+
+```
+## YYYY-MM-DD · 第 N 次对话 —— 一句话目标
+### 用户要求(要点)
+### 本次完成(动作 + 结果)
+### 用户纠偏(如有)—— 纠正:为什么,如何修正
+### 遗留问题 / 待确认
+```
+
+**测试记录**(每条含索引 + 详细):
+
+```
+## TEST-XXX:测试主题
+**日期** / **测试环境** / **测试目的** / **测试脚本** / **输出目录**
+### 测试步骤与结果(表格:步骤 | 内容 | 结果 | 详情)
+### 关键数据 / 发现的问题 / 修复措施
+```
+
+**为什么有效**:
+- 对话是易失的,日志是持久的。AI 换会话、换机器后,靠日志重建上下文。
+- 踩过的坑全部沉淀下来,成为后续 AI 的"避坑清单"。
+- 记录本身是一种**校验**:写完日志,等于把这次对话"归档了",可以安心进入下一目标。
+- **强制约束**:每次对话、每次测试都必须留痕,没有例外。
+
+> **案例对照**:两个项目分别沉淀了 20+ 条避坑记录(API 常量、工具特定行为、环境变量陷阱等),验证了「记录即资产」。
+
+### 1.3 规矩前置:把协作摩擦降到零
+
+开工第 1 天就立好的规矩(可裁剪、可扩充):
+
+- 每次测试前先 `git commit`(可回退)
+- 每次对话写 session_log、每次测试写 TEST_RECORDS(可追溯)
+- 文件名只用 ASCII 且有意义(不许 `1111.prt` 这种)
+- 源码(.py/.ps1)只含 ASCII,中文说明写进 Markdown;脚本用英文注释、报告文档可用中文
+- 生成 PPT/PDF 用专门的 skills
+- 有 GUI 的程序必须前台运行;后台 Python 一律加 `-u`(否则看不到进度)
+- 参考目录只读,不许改;原始模型只读(操作在内存或时间戳副本)
+- 特定工具链必须用特定解释器
+- 生成物不入库(output/、build/、dist/、*.log 等),关键数值转录进文档
+
+**要点**:规矩要具体到「AI 能执行」,不要写"注意规范"这种空话。
+
+### 1.4 接口文件化 + 单一事实源:让 AI 的修改可预期
+
+把"关键数据接口"做成文件,把 AI 修改的边界明确框定:
+
+```
+<project>/data/<interface>.csv   ← 改这一个文件,重跑指定步骤,即可换输入
+<project>/src/<core>/<def>.py    ← 指标/拓扑/参数等定义的唯一权威,多端消费
+<project>/config/<xxx>.json      ← 可执行程序侧车配置,改配置无需重新构建
+```
+
+**为什么有效**:AI 修改的边界被明确框定——「只改这个文件,别动其他东西」。可预期的修改 = 可审查的修改 = 可回退的修改。**凡是被多处消费的定义,必须收敛到单一事实源,禁止多处漂移**(否则会出现"改一处漏两处"的返工)。
+
+> **案例对照**:第一个项目用 CSV 接口文件换输入;第二个项目把指标/拓扑/参数 Schema 各归一个权威定义(单一事实源),并因曾有三处定义漂移而返工。
+
+### 1.5 验收基准 + 测试纪律:防止"看似完成"
+
+每个里程碑都留下**量化验收基准**,并立下规矩:**对不上就别往下走**。
+
+**测试纪律(强烈推荐)**:
+- 每段交付代码必须附带可运行的测试用例,至少覆盖:**正常路径 / 边界条件(极值、上下限)/ 异常输入(非法值、缺字段、类型错误)/ 空值零值场景**
+- 测试脚本统一放 `scripts/test_*.py`,命名与被测模块对应;`python scripts/test_*.py` 可独立运行,exit 0 = PASS
+- 无法自动化的场景(真实求解、真实 AI 调用)必须给出可复现的手动测试步骤,或标注"待验证"
+- 全量回归:每个里程碑结束跑一遍全部 test_*.py,防止回归
+
+**要点**:验收要「可复核、可重跑」,且**校验方式最好与产出方式不是同一条代码路径**(否则不算独立验证),不是"看起来对了"。
+
+### 1.6 铁律沉淀:把血泪教训变成规则
+
+把吃过亏的地方写成「铁律」,并且**每条都带"为什么"**(示例,实际请按你的项目补充):
+
+> 1. 任何几何/数据改动后核对「数量 + 总量」不变量。(曾两次在坏数据上白跑)
+> 2. 检查必须用精确模式,不要用快速模式。(快速模式漏掉大部分问题)
+> 3. 不要用某工具的某操作,改用替代方案。(会留副本、后续修复会碎裂)
+> 4. 先 A 后 B,顺序不能反。(顺序反了会建立依赖链,之后无法再改)
+> 5. 工具实例用独立实例,不连已有实例;启动后设为前台可见。(可能控制错误窗口;脚本模式默认隐藏)
+> 6. 参数写入必须回读校验,不一致标记失败并继续。(静默写入失败会污染整批结果)
+> 7. 结果逐点落盘并 flush。(崩溃不丢已算点;不能等整批)
+> 8. 仿真禁止跑在主线程。(GUI 会卡死,必须子线程)
+> 9. 非登录 shell 可能不继承机器级环境变量 → 脚本内回退。(否则工具找不到/静默退出)
+
+**要点**:规则带"为什么",AI 才知道什么时候该严格遵守、什么时候可以判断变通。
+
+### 1.7 换机接续:环境差异变成可检测项
+
+写一个 `scripts/check_machine_paths.py`,一条命令核对软件路径(可能散落在多个文件)、专用解释器、依赖包、仓库资产、Git 状态,并提供 `--fix` 一键修正(或打印精确修复命令)。
+
+**要点**:把"环境假设"写成可检测的脚本,新机器第一条命令就能确认环境。
+
+> **案例对照**:第二个项目把工具定位、许可证、依赖包、仓库资产、Git 干净度全部纳入体检,并发现"AI shell 解释器与项目运行环境分离"这一常见陷阱。
+
+### 1.8 里程碑管理:Phase/Milestone + 每阶段更新 README
+
+用 **Phase(阶段)/ Milestone(里程碑)** 两级粒度推进,并立下硬纪律:
+
+- 每个 Phase 或 Milestone 完成后,**必须立即更新 README.md**,记录:完成的功能点、新增的文件/模块、关键技术决策、已知问题和后续计划
+- 不允许"代码提交了但 README 没更新"的情况;README 更新应与代码提交在同一 commit 中或紧随其后
+- 每个里程碑规划时写清:计划内容 → 实际交付 → 状态 → Commit(可追溯)
+
+**为什么有效**:里程碑是"可复核的最小单元",每完成一个就闭环一次(记录→验收→提交→更新文档),避免大段工作无人可查。
+
+### 1.9 工程规范:禁臆测 + 自查清单 + 交付声明
+
+工程规范(优先级高于"完成速度"):
+
+1. **禁止臆测**:不得编造任何未实际验证的结果、数值、接口行为或"应该能跑"的结论。若无法运行或测试某段代码/场景,必须明确说明"我无法执行此测试,以下是我的推理/建议",并给出理由与降级方案。
+2. **测试完备**:见 1.5 测试纪律。
+3. **代码规范**:遵循语言标准规范(Python 遵循 PEP8);关键逻辑必须有注释;复杂函数/类必须有 docstring(函数用途、参数、返回值、异常)。
+4. **自查清单(交付前逐项确认,标注 ✅/❌)**:代码已通读无语法错误和明显逻辑漏洞 / 所有测试用例已列出且能描述预期输入输出 / 已考虑边界情况(空值、极值、并发、超时、资源耗尽)/ 已考虑错误处理路径(异常捕获、回滚、降级)/ 多模块时已确认接口契约和数据流向 / 无法实际运行测试时已明确告知用户。
+5. **交付声明**:只有完成上述自查并确认无误后,才能说"已完成/已通过";否则必须使用"草案待验证"或"需要您协助测试",并说明缺口。
+6. **迭代修正**:若用户反馈测试失败,必须:复现问题 → 定位根因 → 修复 → 重新走一遍自查清单 → 再回复。不得仅口头致歉后跳过复现与修复。
+
+### 1.10 反模式自查:把 AI 协作的坑写成表
+
+| 反模式 | 后果 | 对策 |
+|---|---|---|
+| 不读文档直接开工 | 跑偏、重复踩坑 | 接续提示词强制"先读、先报告理解、再动手" |
+| 规矩只写"注意规范" | AI 无法执行 | 规矩写到"可执行、可检查"的颗粒度 |
+| 验收凭"看起来对" | 假完成 | 量化基准 + 独立验证 |
+| 踩坑不记录 | 下次再踩 | 每次坑都追加到 AGENTS.md 铁律 / KNOWLEDGE_BASE |
+| 环境假设不检测 | 换机全崩 | check_machine_paths.py 一键核对 |
+| 上下文只留在对话里 | 换会话即失忆 | session_log 持久化 |
+| 让 AI 多任务并行 | 上下文混乱、互相污染 | 一次一个目标 |
+| 只给结论不给原因 | AI 无法变通 | 规则带"为什么" |
+| 多处定义同一概念 | 三处漂移、改一处漏两处 | 单一事实源(各归一个权威定义) |
+| 编造未验证的数值/接口 | 返工、误导决策 | 禁臆测 + 交付声明 + "待验证"标注 |
+| 只跑功能不跑回归 | 改一处坏一片 | 每个里程碑结束跑全量 test_*.py |
+| 阶段完成不更新文档 | 文档与代码脱节、无人能接手 | 每 Phase/M 完成立即更新 README |
+
+---
+
+## 2. 通用框架:五步启动法
+
+```
+Phase 0  探查    读懂现状/需求,不猜
+Phase 1  立规矩  五支柱文件 + 会话日志 + 约定 + git init
+Phase 2  搭骨架  目录结构 + 接口文件(单一事实源)+ 最小可跑通闭环 + 第一个验收基准
+Phase 3  迭代    一次一个目标 → 记录 → 验收 → 提交 → 更新 README(里程碑粒度)
+Phase 4  交接    写清状态/阻塞/下一步 + 接续提示词 + 环境体检脚本
+```
+
+### Phase 0 · 探查(半天内)
+- 把需求、参考资料、旧代码全部读完,**先理解再动手**。
+- 确认:交付物各部分有来源;计划依赖的事实已拿到;没有靠"应该/大概"支撑的关键步骤。
+- 连续两次读取都不再改变计划,就停止探查、开工。
+
+### Phase 1 · 立规矩(半天内)
+- 建目录结构(见 3.1);写五支柱(README / AGENTS / HANDOFF / KNOWLEDGE_BASE / session_log+TEST_RECORDS);git init 第一次 commit。
+- 把「工作约定」写进 AGENTS.md(提交时机/命名/注释语言/GUI 前台/参考目录只读/专用解释器/生成物不入库)。
+
+### Phase 2 · 搭骨架(1 天内)
+- 把「关键数据接口」文件化(单一事实源)。
+- 打通一条**最小可跑通的端到端闭环**(哪怕结果粗糙)。
+- 写第一个验收基准(哪怕粗),并注明独立校验方式。
+
+### Phase 3 · 迭代(主体过程)
+- 用 **Phase/Milestone** 粒度规划,每次只推进一个目标,做完立即:记录 session_log → 跑验收基准 + 全量回归(对不上就停)→ `git commit` → 更新 README → 下一个。
+- 发现坑 → 立刻把「铁律」追加进 AGENTS.md / KNOWLEDGE_BASE。
+- 每个里程碑完成即是一个"可复核、可回溯、可交付"的闭环。
+
+### Phase 4 · 交接
+- 更新 HANDOFF.md:环境要求、恢复步骤、当前进度、阻塞点、下一步、已知坑速查。
+- 写 `scripts/check_machine_paths.py`(环境体检 + --fix)。
+- 写好给 AI 的接续提示词(见 3.6),让下一个会话/机器/人 10 分钟内接上。
+
+---
+
+## 3. 可直接复制的模板
+
+### 3.1 目录结构模板
+
+```
+<project>/
+├── README.md              # 项目全貌 + 最近更新(人类读)
+├── AGENTS.md              # AI 行为准则(AI 自动读)
+├── HANDOFF.md             # 接续指南(换人/换机/AI 续作)
+├── docs/
+│   ├── KNOWLEDGE_BASE.md  # 环境事实 + 参数语义 + 探测技术 + SOP + 已踩的坑
+│   ├── session_log.md     # 会话日志(组织记忆:目标→动作→结论→踩坑→遗留)
+│   ├── TEST_RECORDS.md    # 测试记录(环境→步骤→结果→问题→修复)
+│   └── figures/           # 图表
+├── data/                  # 机器可读数据 + ★接口文件(改这里即可改输入)
+├── scripts/               # 自动化脚本(英文注释)+ test_*.py 测试
+├── src/ 或 work/          # 实际工作产物(含单一事实源定义层)
+├── reference/             # 参考材料(只读,不许改)
+├── package.json           # 如有 JS 依赖
+└── .gitignore             # 生成物不入库
+```
+
+### 3.2 README.md 模板
+
+```markdown
+# <项目名> —— <一句话定位>
+
+<两句话:这个项目做什么、目标链路是什么>
+
+| 项目 | 内容 |
+|---|---|
+| 文档版本 | V<X>(<最新完成阶段>) |
+| 当前状态 | P1 ✅ / P2 进行中 / ... |
+
+## 目录
+| 路径 | 说明 |
+|---|---|
+| `src/` | ... |
+| `scripts/` | ... |
+| `data/` | ... |
+
+## 工具链
+| 工具 | 版本 | 路径 | 备注 |
+|---|---|---|---|
+| ... | ... | ... | ... |
+
+## 约定
+- 每次测试前先 git commit
+- 每次对话记录到 docs/session_log.md;每次测试记录到 docs/TEST_RECORDS.md
+- 文件名只用 ASCII 且有意义;源码 ASCII,脚本英文注释,报告可用中文
+- <参考目录只读等约束>
+
+## 最近更新(YYYY-MM-DD)
+### <Px-My>:<主题>
+**背景** / **改动内容** / **涉及文件** / **验证**(含 TEST 编号)/ **已知问题**
+
+## 常用命令
+python scripts/<入口>.py
+```
+
+### 3.3 AGENTS.md(给所有 AI 的规矩)模板
+
+```markdown
+# <项目名> AI 协作规矩
+
+**接续请先读 HANDOFF.md。**
+
+## 开始工作前必须阅读(按顺序)
+1. docs/KNOWLEDGE_BASE.md —— 核心知识库(环境事实、参数语义、SOP、已踩的坑)
+2. docs/HANDOFF.md —— 当前进度与接续指南
+3. README.md —— 项目全貌与最近更新
+
+## 工作约定
+- 每次测试前先 git commit
+- 每次对话记录到 docs/session_log.md;每次测试记录到 docs/TEST_RECORDS.md
+- 文件名只用 ASCII 且有意义;源码 ASCII,脚本用英文注释;报告文档可用中文
+- 生成 PPT/PDF 用 skills
+- 有 GUI 的程序必须前台运行;后台 Python 一律加 -u
+- 不要修改 <reference>/ 下任何内容(只读);原始模型只读
+- <特定工具>必须用 <特定解释器> 运行
+- 生成物不入库
+
+## 关键接口(单一事实源)
+- `data/<interface>.csv` 或 `src/<core>.py` —— 改这一个文件即可换输入,然后重跑 <步骤A> + <步骤B>
+- 凡被多处消费的定义,必须收敛到单一事实源,禁止多处漂移
+
+## 铁律(每条带"为什么")
+1. <规则>(<原因>)
+2. ...
+
+## 已知坑
+- <坑> → 表现 → 对策(详见 KNOWLEDGE_BASE.md / session_log.md)
+
+## 反模式自查(交付前对照)
+- <反模式1> → <对策1>;<反模式2> → <对策2>;...
+
+## 工程规范
+- 禁止臆测;测试完备(正常/边界/异常/空值);交付前自查清单 ✅/❌;未过自查只能说"草案待验证"
+
+## 当前状态
+- 已完成:... / 进行中:... / 阻塞:...
+
+## 文档索引
+| 文档 | 内容 |
+|---|---|
+| HANDOFF.md | 接续指南 |
+| docs/KNOWLEDGE_BASE.md | 环境事实与踩坑 |
+| docs/session_log.md | 会话记录 |
+| docs/TEST_RECORDS.md | 测试记录 |
+```
+
+### 3.4 会话日志模板(`docs/session_log.md`)
+
+```markdown
+# 会话记录 / Session Log
+
+## YYYY-MM-DD · 第 N 次对话 —— <一句话目标>
+### 用户要求(要点)
+- <要点>
+### 本次完成
+1. <动作> → <结果>
+### 用户纠偏(如有)
+- <纠正>:<为什么>,<如何修正>
+### 遗留问题 / 待确认
+- <问题>
+```
+
+### 3.5 HANDOFF.md 接续指南模板
+
+```markdown
+# 接续指南 · <项目名>
+
+面向<换人/换机/新会话>后继续工作的场景。
+
+## 1. 环境要求
+| 软件 | 版本 | 用途 | 必需性 |
+|---|---|---|---|
+| ... | ... | ... | ... |
+
+## 2. 恢复步骤(第一条命令)
+python scripts/check_machine_paths.py   # 核对环境
+
+## 3. 当前进度
+### 已完成 | 当前阻塞点 | 待办
+
+## 4. 给 AI 的接续提示词(整段粘贴,见 3.6)
+```
+
+### 3.6 给 AI 的「接续提示词」模板
+
+```text
+这是 <项目名> 项目,<一句话定位>。
+
+【先做这几件事,做完再动任何东西】
+1. 读 HANDOFF.md、docs/KNOWLEDGE_BASE.md、docs/session_log.md、AGENTS.md。
+   KNOWLEDGE_BASE 和 session_log 里记录了大量踩过的坑,请重点看,不要重复踩。
+2. 核对环境:python scripts/check_machine_paths.py
+3. 恢复工作目录/依赖:<具体命令>
+
+【工作约定(必须遵守)】
+- 每次测试前先 git commit;每次对话记录到 docs/session_log.md;每次测试记录到 docs/TEST_RECORDS.md
+- 文件名只用 ASCII 且有意义;源码 ASCII,脚本用英文注释
+- 有 GUI 的程序必须前台运行;后台 Python 加 -u
+- 不要修改 reference/ 目录;<特定工具用特定解释器>;生成物不入库
+
+【铁律(血泪教训)】
+- <规则1> / <规则2> / ...
+
+【当前状态】
+- 已完成:... / 阻塞:... / 待办:...
+
+【接下来做什么】
+- <目标1> / <目标2>
+
+先读文档、恢复环境、跑 check_machine_paths.py,然后告诉我你的理解
+和建议的下一步,不要直接开始改东西。
+```
+
+> 最后一句「先理解、别直接改」很重要——让 AI 先对齐认知,而不是闷头干活跑偏。
+
+### 3.7 验收基准表模板
+
+```markdown
+| 验收项 | 基准 | 校验方式 | 独立于产出路径? |
+|---|---|---|---|
+| <关键输出1> | <数值/逐位一致> | <命令/脚本> | 是/否 |
+| <关键输出2> | 误差 ≤ X | <独立验证路径> | 是 |
+
+对不上就别往下走。
+```
+
+**要点**:校验方式最好与产出方式**不是同一条代码路径**(否则不算独立验证)。
+
+### 3.8 环境检查脚本思路(`scripts/check_machine_paths.py`)
+
+纯只读、不修改任何东西的脚本,检查清单:
+1. 软件安装根目录(可能散落在多个文件里,必须一致)
+2. 环境变量(工具定位、许可证——非登录 shell 可能不继承)
+3. 专用解释器/二进制是否存在
+4. Python(或其他运行时)依赖包是否齐全
+5. 仓库内资产(几何/数据/依赖)是否齐全
+6. Git 仓库干净 + HEAD 有效
+7. 工作目录是否已恢复
+对每个缺失项给出"改法",并提供 `--fix` 一键修正(或打印精确修复命令)。建议探测项目内 venv,提示用其解释器复检(避免"当前 shell 与项目环境分离"误报)。
+
+### 3.9 KNOWLEDGE_BASE 模板(`docs/KNOWLEDGE_BASE.md`)
+
+```markdown
+# 知识库 — <项目名>
+
+> 本文件是项目的核心知识沉淀,供人和任何 AI 工具阅读使用。
+> 全部结论基于 <参考案例/实测> 的验证。
+
+## 1. 环境事实
+| 项 | 值 |
+|---|---|
+| <工具> | <版本> |
+| 定位方式 | <环境变量/路径> |
+| 许可证 | <服务/端口> |
+| 非登录 shell 陷阱 | ... |
+
+### 1.1 环境验证命令
+### 1.2 常见环境问题(现象 | 原因 | 解决)
+
+## 2. <工具> 自动化核心方法
+### 2.1 连接与实例管理
+### 2.2 模型加载与保存
+### 2.3 参数写入(含回读校验)
+### 2.4 结果导出与解析
+
+## 3. 参数语义
+## 4. 已踩的坑(铁律来源)
+## 5. SOP(标准操作流程)
+```
+
+### 3.10 TEST_RECORDS 模板(`docs/TEST_RECORDS.md`)
+
+```markdown
+# 测试记录与结果总结
+
+> 每次测试必须记录在此文档中(工作留痕)。
+> 最后更新:YYYY-MM-DD
+
+## 测试记录索引
+| 编号 | 日期 | 测试类型 | 结果 | 关键发现 |
+|---|---|---|---|---|
+| TEST-001 | ... | ... | ... | ... |
+
+## TEST-001:<主题>
+**日期**:...
+**测试环境**:...
+**测试目的**:...
+**测试脚本**:`scripts/test_xxx.py`
+**输出目录**:`output/.../`
+### 测试步骤与结果
+| 步骤 | 内容 | 结果 | 详情 |
+|---|---|---|---|
+### 关键数据 / 发现的问题 / 修复措施
+```
+
+### 3.11 里程碑管理模板(README 或独立里程碑计划文档)
+
+```markdown
+## Px-My:<主题>
+**背景**:<为什么做>
+**改动内容**:<做了什么>
+**涉及文件**:<新增/修改的文件>
+**验证**:<测试脚本 + 结果 + TEST 编号>
+**技术决策**:<关键取舍及理由>
+**已知问题/后续**:<遗留项>
+```
+
+---
+
+## 4. 日常迭代的铁律清单(随项目增长不断追加)
+
+在 AGENTS.md 里维护一份「铁律」,每条格式:**规则 + 为什么**。以下为通用示例,请替换为你的项目的真实血泪教训。
+
+| # | 规则 | 为什么 |
+|---|---|---|
+| 1 | 任何数据/几何改动后核对「数量 + 总量」不变量 | 曾两次在坏数据上白跑,只报"操作成功"会漏掉损坏 |
+| 2 | 关键输入的体积/数值必须保持不变 | 输入按件映射,改了映射就失效 |
+| 3 | 检查必须用精确模式,不要用快速模式 | 快速模式漏掉大部分问题 |
+| 4 | 不要用<某工具>的<某操作>,改用<替代方案> | 会留副本、后续修复会碎裂 |
+| 5 | 先<A>后<B>,顺序不能反 | 顺序反了会建立依赖链,之后无法再改 |
+| 6 | 量关键尺寸不要用包围盒接口 | 对薄壁件虚报数值,要用独立解析 |
+| 7 | 同类错误再次出现时查共同成因 | warning 即使伴随成功也必须处理 |
+| 8 | 工具实例用独立实例,不连已有实例;启动后设为前台可见 | 可能控制错误窗口;脚本模式默认隐藏 |
+| 9 | 参数写入必须回读校验,不一致标记失败并继续 | 静默写入失败会污染整批结果 |
+| 10 | 结果逐点落盘并 flush | 崩溃不丢已算点;不能等整批 |
+| 11 | 仿真/长任务禁止跑在主线程 | GUI 会卡死,必须子线程 |
+| 12 | 非登录 shell 要回退设置环境变量 | 环境变量不继承会导致工具找不到/静默退出 |
+
+---
+
+## 5. 与 AI 协作的沟通技巧(实战心得)
+
+### 5.1 纠正要具体、要给"为什么"
+- ❌ "这个不对,重新做"
+- ✅ "`xxx` 用错了:原因是 <技术事实>,请改用 <方案>。依据:<文档/实测>"
+
+### 5.2 验收必须量化、可独立复核
+- 给 AI 明确基准:逐位一致、误差 ≤ X、收敛阈值。
+- 要求「校验方式 ≠ 产出方式」(独立验证)。
+
+### 5.3 让 AI 区分事实与推测
+- 要求 AI 标注:**已查证 / 估算 / 待确认 / 一方称**。
+- 关键数字必须来自输入、具体信源或可复现计算,否则标为待补充并写明口径。
+
+### 5.4 一次只推一个目标
+- 每个会话/阶段聚焦一件事:做完 → 记录 → 验收 → 提交 → 下一个。
+
+### 5.5 信息不足就反问,不猜
+- 有歧义时让 AI 用「我的理解是 X,但缺少 Y,请确认 A/B」反问。
+
+### 5.6 受阻时换通道,不降级交付
+- 同一动作失败两次就换命令/目录/依赖/实现路径。换的是执行通道,不是交付标准。
+
+### 5.7 异议强制机制
+- 当需求存在技术矛盾、逻辑漏洞、安全隐患或实现风险时,AI 必须打断并指出:"【风险提示】<具体问题>。建议方案:<替代方案>,依据:<技术事实>。"禁止为迎合而执行明显错误的指令。
+
+---
+
+## 6. 常见反模式(AI 协作中的坑)
+
+| 反模式 | 后果 | 对策 |
+|---|---|---|
+| 不读文档直接开工 | 跑偏、重复踩坑 | 接续提示词强制"先读、先报告理解、再动手" |
+| 规矩只写"注意规范" | AI 无法执行 | 规矩写到"可执行、可检查"的颗粒度 |
+| 验收凭"看起来对" | 假完成 | 量化基准 + 独立验证 |
+| 踩坑不记录 | 下次再踩 | 每次坑都追加到 AGENTS.md 铁律 |
+| 环境假设不检测 | 换机全崩 | check_machine_paths.py 一键核对 |
+| 上下文只留在对话里 | 换会话即失忆 | session_log 持久化 |
+| 让 AI 多任务并行 | 上下文混乱、互相污染 | 一次一个目标 |
+| 只给结论不给原因 | AI 无法变通 | 规则带"为什么" |
+| 多处定义同一概念 | 改一处漏两处 | 单一事实源 |
+| 编造未验证的数值 | 误导决策 | 禁臆测 + 交付声明 |
+| 不跑回归就收工 | 改一处坏一片 | 里程碑结束跑全量 test_*.py |
+| 阶段完成不更新文档 | 无人能接手 | 每 Phase/M 完成更新 README |
+
+---
+
+## 7. 快速上手 Checklist(新项目第一天)
+
+- [ ] **Phase 0** 读完所有参考资料/旧代码,确认关键事实有来源
+- [ ] **Phase 1** 建目录结构(README / AGENTS.md / HANDOFF / docs/KNOWLEDGE_BASE / docs/session_log / docs/TEST_RECORDS / data / scripts / reference)
+- [ ] **Phase 1** git init + 第一次 commit
+- [ ] **Phase 1** 把「工作约定」写进 AGENTS.md(提交时机/命名/注释语言/GUI 前台/参考目录只读/专用解释器/生成物不入库)
+- [ ] **Phase 2** 把「关键输入」做成一个接口文件(单一事实源),写清"改这一个文件 + 重跑哪几步"
+- [ ] **Phase 2** 打通最小可跑通的端到端闭环
+- [ ] **Phase 2** 写下第一个验收基准(量化、可独立复核)
+- [ ] **Phase 2** 写第一个 test_*.py(含正常/边界/异常/空值)
+- [ ] **Phase 3** 开始迭代:一次一个目标 → 记录 session_log → 验收 + 全量回归 → commit → 更新 README
+- [ ] **Phase 3** 每踩一个坑,立即追加到 AGENTS.md 铁律 / KNOWLEDGE_BASE(规则 + 为什么)
+- [ ] **Phase 4** 阶段末更新 HANDOFF.md(进度/阻塞/待办 + 接续提示词)
+- [ ] **Phase 4** 写 scripts/check_machine_paths.py(环境体检 + --fix)
+
+---
+
+## 附录:案例来源与参考实现
+
+> 本框架从以下两个真实工程提炼。主文为通用方法论,此附录仅用于说明「每条方法论是从哪种场景验证得来的」,供对照学习。
+
+### 案例 A:MARS 电机热流体仿真工程(框架 V1 来源)
+
+- **场景**:电机热流体仿真,多软件工具链(电磁损耗 → 热仿真 → 交叉校核),50+ 自动化脚本,跨机器迁移,AI 全程参与。
+- **沉淀贡献**:三支柱文件、会话日志、规矩前置、接口文件化、验收量化、铁律带"为什么"、换机接续脚本、五步启动法、接续提示词。
+- **参考实现**:`CLAUDE.md`(AGENTS.md 原名)、`HANDOFF.md`、`docs/session_log.md`、`scripts/check_machine_paths.py`、`data/heat_loads.csv`。
+
+### 案例 B:PCB 轴向磁通电机自动化仿真系统(框架 V2 来源)
+
+- **场景**:双系统解耦的电机自动化仿真平台(Web 方案生成 + 本地 EXE 仿真执行),Motor-CAD 自动化,P1~P6 六个 Phase 迭代,AI 全程参与。
+- **沉淀贡献**:五支柱扩展(KNOWLEDGE_BASE + 留痕双件套)、里程碑管理、测试纪律(test_*.py 全量回归)、工程规范(禁臆测/自查清单/交付声明)、单一事实源、反模式自查表、环境变量陷阱文档化。
+- **参考实现**:`AGENTS.md`、`docs/KNOWLEDGE_BASE.md`、`docs/TEST_RECORDS.md`、`docs/HANDOFF.md`、`scripts/check_machine_paths.py`、`scripts/test_*.py`。
+
+### 如何使用本框架
+
+1. 新项目:直接复制本文档,按第 7 节「快速上手 Checklist」初始化。
+2. 遇到不确定的方法论细节,可回看附录两个案例的参考实现文件。
+3. 本框架随你持续迭代——每次改动在「版本更新记录」追加一条。

+ 1 - 1
deploy.ps1

@@ -1,4 +1,4 @@
-# PCB AFM Simulation System - Windows Deployment Script
+# PCB AFM Simulation System - Windows Deployment Script
 # Usage: .\deploy.ps1
 # Prerequisites: Python 3.10+, Node.js 18+, Git
 

+ 463 - 0
docs/CONVERSATION_LOG.md

@@ -2,6 +2,8 @@
 
 > 所有关键决策、技术选择、问题排查均带时间戳记录于此。
 > 格式:`## YYYY-MM-DD HH:MM — 主题`
+> 统一模板(V2 框架,见 ai-collab-dev-playbook-v2.md §3.4):
+> `## YYYY-MM-DD · 主题` → 用户要求(要点)→ 本次完成(动作+结果)→ 用户纠偏(如有)→ 遗留问题/待确认
 
 ---
 
@@ -650,3 +652,464 @@ Car.Lin(项目负责人)
 - [x] 前端人工验收通过(6项功能全部验证)
 - [x] README.md更新至V1.0
 - [x] Phase 2 验收结论明确
+
+---
+
+## 2026-08-30 P5-M1:前端 build 清零(backlog B1)
+
+**背景**:新会话开工 P5,基线 e6563fc(工作区仅用户自建论文目录未跟踪)。全量回归 14/14 绿(test_api_client 需后端属环境依赖)。
+
+**决策**:
+1. 修复策略:`src/api/index.ts` 响应拦截器运行时已 unwrap `.data`,把 axios 实例类型改写为 `UnwrappedApi`(get/post/put/delete 返回 `Promise<T>`,默认 any),一处修复覆盖 69 处 TS2339/TS2345;`PlanDetail.vue` 的 `sel` 显式标注 `any[]` 消除 TS7006。运行时行为不变(纯类型断言)。
+2. 前端运行时验证:后端 uvicorn + `test_api_client.py` PASSED + vite dev + `/api` 代理 200。
+3. **Git 环境坑**:从仓库根目录执行带子目录路径的 git 命令(add/hash-object/diff/checkout)永久挂起(CPU≈0 等 IO,30s 不返回);规避:cd 到目标子目录内用相对文件名执行(秒回)。已记入 KNOWLEDGE_BASE.md。本次提交用「分目录 add」完成。
+
+**产物**:commit `b394c39`(feat(p5-m1));README / TEST_RECORDS(TEST-016)/ 交接文档同步。
+
+
+## 2026-08-30 P5-M2:EXE 配置化(config.json)+ mock 分支修复 + EXE 端到端验证
+**背景**:P5-M1 后 HEAD=f053b50。P5-M2 目标:EXE 配置化(web/model/log/实例数)+ 端到端回传验证。环境确认:用户常驻后端(uvicorn 0.0.0.0:8000,PID 30496)+ 常驻执行器(PID 39420)在跑——端到端验证用独立后端 8010 + 临时 DB,未干扰常驻服务。license server 1055 在跑,但用户常驻执行器占用轮询,真实 COM 端到端标注待目标机。
+**决策**:1. 新增 scripts/executor_config.py(配置加载器:--config > $EXECUTOR_CONFIG > EXE 目录侧车 > 仓库根模板 > 默认;env 单字段覆盖;相对路径按 EXE 目录/仓库根解析;校验)。 2. 仓库根 executor_config.json 模板;run_task_executor.py 支持 --config/--instances/--mock/--log-*(版本 1.1.0);parallel 复用共享逻辑。 3. 修复 P4-M3 遗留 bug:MotorCADTaskExecutor._run_simulation_point 忽略 enable_mock 无条件走真实 adapter → 加 mock 分支(source=mock)。 4. EXE 端到端(mock)全链路验证成功:独立后端 8010 → EXE(--config 侧车)认领 3 点 → mock 求解 → 回传 → completed(tavg 34.56~42.05 N·m、eff 87.95~89.73%)。 5. 重新打包 dist/PCB-AFM-Executor.exe(1.1.0,--version/--self-test 通过)。
+**坑**:EXE(frozen)相对路径按 EXE 目录解析(侧车需绝对路径或相对 EXE);PowerShell 多行 Replace 中文锚点不稳 → 用 Python newline="" 保留 CRLF 精确替换。
+**产物**:commit feat(p5-m2)(executor_config.py / executor_config.json / run_task_executor.py / run_task_executor_parallel.py / task_executor.py / test_executor_config.py / test_executor_p5m2.py / README / TEST_RECORDS TEST-017 / P1-P4回顾与P5规划 / CONVERSATION_LOG)。
+
+## 2026-09-01 · 方法论框架升级(Playbook V2 融合落地)
+**用户要求(要点)**:学习 ai-collab-dev-playbook.md,结合其框架与本项目框架,产出更完整的方法论框架 V2(用于下个项目 + 本项目按新框架执行)。
+**本次完成**:
+1. 新增 ai-collab-dev-playbook-v2.md(五支柱 + 五步启动法 V2,融合两项目经验)→ 根目录
+2. 新增 docs/HANDOFF.md(接续指南 + 接续提示词)→ 补全五支柱之「接续」支柱
+3. 新增 scripts/check_machine_paths.py(环境体检,只读 + --fix 打印修复命令)→ 换机第一条命令
+4. AGENTS.md 接入新框架:加 HANDOFF/playbook-v2 引用、环境体检命令、会话日志强制、反模式自查表、接续与交接节
+5. 本文件(CONVERSATION_LOG)头部补统一模板说明并追加本次记录
+**用户纠偏**:无
+**遗留问题 / 待确认**:
+- README「最近更新」待补本次框架升级记录(下一步)
+- 新项目可直接复用 playbook-v2;PCB 项目对照第 8 节落地清单逐项对齐
+
+
+## 2026-09-01 · Playbook V2.1 通用化重构
+**用户要求(要点)**:把 playbook 做成不带项目信息的通用框架文档(MARS/PCB 仅作案例举例);新增版本更新记录,便于持续迭代。
+**本次完成**:
+1. ai-collab-dev-playbook-v2.md 通用化重构:主文移除具体项目路径/参数/工具名,改为通用占位;新增「版本更新记录」(V1.0/V2.0/V2.1 + 迭代约定);MARS/PCB 降级为附录「案例来源与参考实现」
+2. 主文所有「案例对照」标注来源,铁律/反模式/模板全部通用化
+**用户纠偏**:无
+**遗留问题 / 待确认**:
+- 文档后续迭代请在「版本更新记录」追加(版本号/日期/改动内容/影响)
+
+
+## 2026-09-02 · 文档体系整改(评审 P0/P1/P2 修复)
+**用户要求(要点)**:评审本项目文档并提出改进点;确认不影响程序运行后动手修复。
+**本次完成**:
+1. AGENTS.md:项目定位从"Phase 1 最小闭环"更正为 P6 现状(P1~P5 完成 + P6-M1 完成);阅读清单改为"最小必读集 + 按需查阅"分层,降低新会话上下文负担
+2. README.md 整体重写(714→约290行):头部状态补 P6;路线图修正 P4/P5 编号冲突与过期状态("Schema统一+EXE打包 规划中"实已完成);结构树对齐实际目录(afmcore 五模块/14 路由/33 脚本/models 50 文件);快速开始以 run_task_executor + executor_config 为主入口;API 表指标数 11→35 并标注单一事实源;修复路线图吞字行
+3. 新增根目录 CHANGELOG.md:承接全部历史更新记录(2026-09-01 ~ 2026-08-29 各期,含原 README 底部 P5-M2 拓扑感知节);README 只留摘要表
+4. 新增 docs/archive/:归档 4 份已完结计划文档(P3/P4_IMPLEMENTATION_PLAN、P3-评审响应、P1-P4回顾与P5规划)+ 索引 README;归档前已确认无代码引用(output/ 下一次性脚本除外,不入库)
+5. KNOWLEDGE_BASE §4.1:指标清单改为引用 src/afmcore/metrics.py(35 项单一事实源),消除 11/12/35 三处漂移;保留易错字段名示例
+6. TEST_RECORDS.md:头部日期 2026-08-30→2026-09-01;修复 0x0c 控制字符("full_factorial"吞 f,与历史 0x07/0x08 同类病灶)
+7. HANDOFF.md:文档索引补 CHANGELOG.md 与 docs/archive/ 行;TEST 编号 023→024
+8. 上手指南:目录树修正(plan_schema.py 归属 src/ 而非 afmcore/;补 HANDOFF/CONVERSATION_LOG/archive;TEST-024);修改处失效引用更新为 archive/ 路径
+9. README 项目纪律新增第 11/12 条:状态单一事实源=HANDOFF 第 3 节;粘贴文本入文档后必须检查控制字符
+**验证**:8 个改动文档脚本检查通过(控制字符清零 + UTF-8 有效 + 本地 md 链接无失效)
+**技术决策**:①"当前状态"收敛为 HANDOFF 单一事实源,README/AGENTS 只放链接,防止三处复制漂移复发;②CHANGELOG 只迁不复制(避免双份漂移);③归档而非删除(保留可追溯性)
+**踩坑**:同文件两处并行 Edit 会后写覆盖先写(本次 CHANGELOG 两处路径修改丢了一处),同文件编辑必须串行
+**遗留问题 / 待确认**:
+- 工作区存在前次会话未提交的代码改动(P5-M2 拓扑感知、P6-M1 前端重构相关),本次仅提交文档文件
+- CONVERSATION_LOG(700+行)/TEST_RECORDS(1068行)按月分卷归档为后续可选项,未在本次实施
+
+## 2026-09-02 · AI 一键生成方案报错修复(topology/search_strategy 校验失败)
+**用户要求(要点)**:点击"AI 一键生成方案"报错 `Invalid generated plan: topology not supported: 'AFIR'; search strategy not supported: 'full_factorial_grid'`,找原因并修复。
+**根因**:
+1. 前端 ProjectList.vue 新建/编辑项目的拓扑下拉框提供 AFIR/AFPM/TORUS(默认 AFIR),与单一事实源 src/afmcore/topology.py(SSSR/DRSS/SDSR)漂移 → 项目 topology 存为 AFIR。
+2. ai_plan.py generate_and_save 用 project.topology 覆盖 AI 已归一化的 topology(AI 会把 AFIR 映射回 SSSR),导致 plan_data.topology=AFIR 被 validate_plan_dict 拒绝。
+3. Kimi 模型偶发输出 search_strategy.method="full_factorial_grid"(非注册策略),plan_generator.convert_ai_plan_to_unified 只兜底"缺 method"、未兜底"method 非法",非法值透传致校验失败。
+**本次完成**:
+1. ProjectList.vue:拓扑选项 AFIR/AFPM/TORUS → SSSR/DRSS/SDSR,默认 SSSR(5 处)
+2. plan_generator.py:convert_ai_plan_to_unified 增加 search_strategy.method 白名单归一化(基于 src.afmcore.strategies 单一事实源),非法回退 full_factorial + warning
+3. ai_plan.py:generate_and_save 增加 topology 归一化兜底(基于 src.afmcore.topology),非法回退 SSSR + warning
+**验证**:函数级测试(非法 method 回退 + 合法 lhs 保留)PASS;真实端到端调用 AFIR 项目 generate-and-save 返回 HTTP 200,topology 归一化 SSSR、method 归一化 full_factorial,warnings 正确提示。
+**技术决策**:以 src/afmcore 为单一事实源做归一化,而非在 web 层新增第二份白名单;前端硬编码拓扑选项对齐(P6-M2 完整方案待做 API 驱动)。
+**遗留问题 / 待确认**:
+- 已有 3 个 AFIR 测试项目(id 9/10/11)依赖后端兜底运行;前端已修,新建不会再产生 AFIR。
+- AI 仍会输出非标准变量名(如 Stator_Outer_Diameter_mm、Turns_Per_Coil)触发 warning,属另一问题,未在本次修复。
+
+## 2026-09-03 · Web 优化 P0 落地(Kimi max_tokens + AI 生成摘要对话框)
+**用户要求(要点)**:先给 web 页面优化建议(V2 评审),确认后"你开始吧"。
+**评审结论**:P6-M1(B1/B2)已落实;未落实项集中在 P6-M2(参数目录)/ P6-M3(流程引导);新增 P0 发现 3 个实测问题。
+**本次完成(P0 三项)**:
+1. **P0-2 Kimi max_tokens 撞顶修复**:ai_client.chat() 默认 max_tokens 改用 KIMI_MAX_TOKENS(原 plan_generator 写死 2000,k3 reasoning_content 吃满致 content 空→解析失败,对应用户"超时"截图隐藏根因);plan_generator 两处同步;新增 finish_reason=length+content 空的 logger.warning。
+2. **P0-1 AI 生成摘要对话框**:ProjectDetail.vue 生成成功后弹结构化对话框(方案概要 + 系统调整 warnings 逐条 + AI 设计思路折叠 + 查看方案/留在本页),替代原只显示第一条的 ElMessage——解决"黑盒裁剪工程师不知情"信任问题。
+3. **P0-2b 等待体验**:loading overlay 加"已等待 N 秒"计时(AbortController 取消本已存在)。
+**验证**:py_compile+ASCII 通过;vue-tsc 0 错误;端到端 generate-and-save HTTP 200,最新 ai_call_log completion_tokens=650 未撞顶、content 完整。TEST-026。
+**技术决策**:摘要对话框数据直接用后端 generate-and-save 响应(plan_data/warnings/ai_reasoning),无需后端新字段。
+**遗留问题 / 待确认**:
+- P0-3(仿真前检查清单)/ P0-4(TaskManager 方案下拉)/ P1(参数目录统一 + D6 调用链验证)未做,待用户确认继续。
+- AI 偶发非标准变量名(Slot_Depth 等)被裁剪,独立问题。
+
+## 2026-09-03 · Web 优化 P0-3/P0-4 落地(仿真前检查清单 + 任务自动展开)
+**用户要求(要点)**:继续后续待办("Please continue")。
+**本次完成**:
+1. **P0-3 仿真前检查清单**:plans.py 新增 `GET /{plan_id}/preflight`(5 项:模型文件/变量名确认/扫描变量/数据点规模/执行器在线,fail 阻断 warn 提示;为守 ASCII 约束只返回 key/status,中文 label 前端映射);PlanDetail 启动仿真改为先弹检查清单对话框,fail 禁用启动,确认后才 start-simulation。
+2. **P0-4 TaskManager 任务自动展开**:tasks.py `POST /api/tasks` 的 plan_data/parameters 改可选,仅传 plan_id 时自动加载方案并 `_expand_plan_to_parameters` 展开(复用 start-simulation 同一逻辑);前端 doCreate 未编辑 JSON 时省略字段由后端展开。
+**踩坑**:preflight 初测 model_path fail——`models/xx.mot` 相对后端 cwd(web/backend)解析失败,修复为用 config.PROJECT_ROOT 解析相对路径。
+**验证**:py_compile+ASCII 通过;vue-tsc 0 错误;preflight 复测 ok=true;tasks 自动展开 total_points=3 正确。TEST-027。
+**技术决策**:preflight 检查项后端只返回机器可读 key/status/data,中文 label/message 前端映射(守 .py 纯 ASCII 铁律);executor 离线设 warn 不阻断(任务可排队)。
+**遗留问题 / 待确认**:P1(参数目录单一事实源 + D6 调用链验证 current_a vs rated_current_a,可能数据一致性 Bug)未做。
+
+## 2026-09-03 · D6 边界条件 key 别名桥修复(P1 之 Bug 止血)
+**用户要求(要点)**:继续 P1("Please continue")。
+**D6 调用链验证结论(确认为真 Bug)**:BC key 三套口径漂移——①ProjectDetail 录入 + rule_engine 消费用 current_a/speed_rpm/slots/voltage_v/cooling_type;②fixed_params_template.build_default_fixed_params 读 rated_current_a/rated_speed_rpm/slot_count/dc_link_voltage_v/cooling_method;③PlanDetail BC_TEMPLATE 展示用 rated_* 另一套。导致用户在项目边界表单填的电流/转速/槽数/电压/冷却方式不流入固定参数推断(落模板默认 21A/5000rpm/13.5V 等)。另 Magnet_Temperature/Max_Speed 无 BC 推断。
+**本次完成(止血,非完整 P1-1)**:build_default_fixed_params 入口加 BC key 别名桥(canon 优先、别名补填、拷贝不改调用方 dict),补 Magnet_Temperature/Max_Speed 推断。
+**验证**:函数级 11 断言 PASS(前端 key 流入 + 旧 key 兼容 + 不改调用方 dict);端到端 project 9 generate-and-save,固定参数全部取自 BC(RMSCurrent=20/Shaft_Speed=3000/Slots=12/DC_Link=48/Magnet_Temp=40/Cooling=Natural/Max_Speed=6000)。TEST-028。
+**技术决策**:选"别名桥"而非改 elif 链——最小改动、向后兼容旧 rated_ key、不破坏 rule_engine;以 rated_ 为 canon、current_ 为别名(保持模板内部一致)。
+**遗留问题 / 待确认**:完整参数目录单一事实源(P1-1:PlanDetail BC_TEMPLATE 对齐 + rule_engine + 参数目录 API + 数据迁移)为大重构,需专门设计,本轮未做。
+
+## 2026-09-03 · P1-1 第一阶段:BC 字段目录单一事实源落地
+**用户要求(要点)**:继续 P1-1("Please continue")。
+**确认的事实**:①BC key 三套口径漂移(ProjectDetail/rule_engine=current_a 系、fixed_params_template=rated_ 系、PlanDetail BC_TEMPLATE=第三套 rated_);②所有方案 plan.boundary_conditions 均为 None → PlanDetail"边界条件"展示为模板错位+数据源空的死功能。
+**本次完成(P1-1 第一阶段)**:
+1. 新建 bc_fields.py:BC_FIELD_CATALOG(25 字段,规范 key 对齐录入端/rule_engine,\uXXXX label/unit/category/aliases)+ normalize_bc(别名→规范、规范优先、不改调用方)。
+2. generation.py 新增 `GET /api/bc-fields` 暴露目录。
+3. ai_plan.py(AI 生成)+ plans.py(手动创建):归一化后的 project BC 存入 plan_data.boundary_conditions(修活展示数据源)。
+4. PlanDetail.vue:删硬编码 BC_TEMPLATE,改从 /api/bc-fields 目录渲染(修复展示错位)。
+**踩坑**:Write 工具把 `\uXXXX` 落成实际中文字符(非 ASCII)+ docstring 字面 `\uXXXX` 触发 truncated escape → 用 `encode('ascii','backslashreplace')` 脚本统一转义 + docstring 避字面 `\uXXXX`。
+**验证**:5 个 .py 编译+ASCII 通过;normalize_bc 功能正确;vue-tsc 0 错误;/api/bc-fields 25 字段正确;端到端 plan.boundary_conditions 完整保存 25 规范 key。TEST-029。
+**技术决策**:规范 key 以录入端/rule_engine(current_a 系)为准,rated_ 降级为别名(读取兼容);label 用 \uXXXX 守 ASCII 铁律。
+**遗留问题 / 待确认**:P1-1 第二阶段(前端 ProjectDetail 表单从目录渲染、rule_engine/template key 彻底统一、已存数据迁移、scan+BC 目录合并)未做。
+
+## 2026-09-03 · P1-1 第二阶段:BC key 读取统一走 normalize_bc
+**用户要求(要点)**:继续 P1-1 第二阶段("Please continue")。
+**范围决策**:前端 ProjectDetail 表单 key 已与目录一致(第一阶段设计目录时即对齐 bcFields),无需改;真正残留是后端双重转换(normalize_bc 输出 current_ 系,build_default_fixed_params 又用本地 _ALIASES 桥转 rated_)。
+**本次完成**:
+1. fixed_params_template.build_default_fixed_params:改为 `bc = normalize_bc(...)`,删除本地 rated_ 别名桥,elif 改读规范 key(current_a/speed_rpm/slots/voltage_v/cooling_type)。
+2. rule_engine.BoundaryConditions.from_dict:加 normalize_bc,旧 rated_ 数据正确解析。
+**效果**:BC key 别名处理收敛到 bc_fields.normalize_bc 单一点,固定参数推断与 rule_engine 统一读规范 key,消除双重转换。
+**验证**:3 个 .py 编译+ASCII 通过;统一性 14 断言 PASS(current_ 流入 + rated_ 兼容 + rule_engine 解析 rated_ + 不改调用方);运行时 import 无循环;端到端固定参数推断仍全对。TEST-030。
+**技术决策**:已存 DB 的 rated_ 数据由 normalize_bc 读取时兼容,不物理迁移(避免改用户数据);scan 变量目录与 BC 目录为不同维度,保持分离;前端表单已一致不改。
+**遗留问题 / 待确认**:P1-1 实质完成(目录/API/存 BC/展示/读取统一均已落地);剩余为可选深化(scan+BC 目录是否合并、前端表单完全目录驱动)。
+
+## 2026-09-03 · P2-1 耗时校准 + P2-3 AFIR 非标准拓扑标记
+**用户要求(要点)**:继续 P2("Please continue")。
+**本次完成**:
+1. **P2-1 耗时校准**:analytics.py 新增 `GET /api/analytics/solve-time-stats`(基于 simulation_results.solve_time_s 实测);PlanDetail estimatedTimeMin/estimatedRemaining 改用实测 avg(fallback 2.5 分钟/点),加"基于最近 N 次实测 ~Xs/点"依据。数据:11 条实测 avg=125.9s(硬编码 2.5 分钟/点高估 ~19%)。
+2. **P2-3 AFIR 标记**:ProjectList 非标准拓扑(非 SSSR/DRSS/SDSR)项目 tag 改 warning 色 + 警告图标 tooltip,不改 DB(后端已有 AFIR→SSSR 兜底)。
+**验证**:analytics.py 编译+ASCII 通过;vue-tsc 0 错误;solve-time-stats 返回正确(count=11 avg=125.9);AFIR 项目 id 9/10/11 被识别。TEST-031。
+**遗留问题 / 待确认**:P2-2(步骤条交互)/ P2-4(结果表列配置)价值中低未做;AI 偶发非标准变量名被裁剪(独立问题,每次生成触发 warning/裁剪,建议后续专项)。
+
+## 2026-09-03 · AI 扫描变量归一化 + 注册表扩充
+**用户要求(要点)**:继续("Please continue"),处理 AI 非标准变量名导致维度裁剪问题。
+**调查(ai_call_logs 证据)**:AI 高频推荐但非标准变量 = Turns_per_Coil(9 次)/ Stator_Outer_Diameter(9 次)/ Slot_Depth(3 次)/ Stator_Inner_Diameter / Wire_Diameter,均为 fixed_params_template 有效 Motor-CAD 变量,仅因不在 SCAN_PARAMETERS(原 8 个)被误判裁剪。
+**本次完成**:
+1. rule_engine.SCAN_PARAMETERS 8→13:新增上述 5 个变量,范围锚定 MARS 基线(198/122/7/20/1.63)±20-50%。
+2. plan_generator._VARIABLE_NAME_MAP 补 25 个 AI 变体名映射。
+**验证**:编译+ASCII 通过;归一化 16 断言 PASS(12 变体名归一化 + 不再"not a standard"warning + 变量保留);注册表 8→13;端到端 Stator_Outer_Diameter 保留为扫描维度,"非标准变量"warning 消失。TEST-032。
+**技术决策**:范围锚定 MARS 基线 ±20-50%(非瞎编,注释标注依据);点数超限(MAX_POINTS=200)裁剪是合理保守行为,保留维度需改用 lhs/adaptive 采样(另一独立优化,未做)。
+**遗留问题 / 待确认**:点数超限时切换采样策略(保留维度)作为可选深化;其余优化项已全部完成。
+
+## 2026-09-03 · 结果表列配置(P2-4)
+**用户要求(要点)**:继续("Please continue")。
+**本次完成**:PlanDetail 结果表列配置——默认显示核心 5 列(平均转矩/脉动/效率/总损耗/状态)+ 扫描变量列,35 项指标通过"列设置"checkbox 对话框按需勾选,支持"恢复默认"。解决指标扩展后结果表全部铺开拥挤问题(D14)。
+**验证**:vue-tsc 0 错误;前后端服务正常(纯前端改动,Vite 热更新)。TEST-033。
+**说明**:本轮评估过用 agent-browser 做 UI 实测截图,但需下载 ~500MB Chromium,且用户本机前后端已在运行可直接查看 UI,性价比低,故改为完成 P2-4 这个最后有明确用户价值的剩余项。
+
+## 2026-09-03 · UI 评审实测修复(ui-ux-pro-max + 截图)
+**用户要求(要点)**:用 ui-ux-pro-max 技能评审界面,先给方案批准;批准后 continue。
+**评审方法**:加载 ui-ux-pro-max(Data-Dense Dashboard 框架 + UX 指导);agent-browser 下载 Chromium 超时(googleapis),改用系统 Edge headless + 自写 CDP 截图脚本实测 5 页面,逐张分析。
+**评审发现与修复(批次 A+B+C,全部截图验证)**:
+1. **P0-1 PlanDetail 统计卡全 0**(读 plan.scan_variables 不存在,实为 plan_data.variables)→ 4 处改读 plan_data.variables;**P0-1b** calcPoints 不认 values 数组(plan 1)→ 优先 values.length。
+2. **P1-1 Dashboard**:已选方案却提示"请选择方案";默认选第一个(常无结果);结果加载漏 .items → 文案区分 + 默认选 result_count>0 + 加 .items。
+3. **P1-2** BC 25 字段全铺开 → 空值折叠;**P1-3** ProjectList 表格 size=small。
+4. **P2-2** 面包屑补项目名(MainLayout 加载);**P2-4** 标题区分隔。
+**验证**:截图回归(统计卡 0→1个/3点/6分钟、BC 折叠、面包屑项目名、Dashboard 0→6点);vue-tsc 0 错误。TEST-034。
+**踩坑沉淀**:前后端字段名不匹配是反复出现的问题家族(scan_variables vs plan_data.variables、results 的 .items vs .results、calcPoints 不认 values);agent-browser 国内下载 Chromium 超时,Edge headless+CDP 脚本是可靠替代。
+**遗留**:失败结果无错误信息列;PlanDetail 参数编辑无保存逻辑;P2-1 监控合并 / P2-3 批量清理待确认。
+**遗留问题 / 待确认**:P2-2(步骤条交互)价值低未做;可选深化(点数超限采样、列配置持久化 localStorage)。全部既定优化项(P0/P1/P2 + D6 + AI 变量归一化)已完成。
+
+## 2026-09-03 · 失败结果错误信息列
+**用户要求(要点)**:继续处理遗留项("Please continue")。
+**本次完成**:结果表失败行显示具体错误原因——PlanDetail 结果表 status 列 FAILED 加 el-tooltip(悬停显示 error_message,虚线下划线标识)+ error_message 加入列设置可选列;Dashboard resultColumns 加 error_message 列(直接显示)+ status tooltip。实证 /plans/6/results 失败原因 "Could not find Outer_Rotor_Diameter"(印证 HANDOFF 变量名未验证问题)。
+**验证**:vue-tsc 0 错误;截图 v_dashboard_err.png 确认错误信息列显示完整原因。TEST-035。
+**遗留**:Outer_Rotor_Diameter 等未验证变量名需按 HANDOFF 在 .mot 确认后修正 fixed_params_template(独立于 UI);PlanDetail 参数编辑无保存逻辑;P2-1 监控合并 / P2-3 批量清理待确认。
+
+## 2026-09-03 · MARS 变量名实测排查 + fixed_params_template 几何修正(仿真失败根因)
+**用户要求(要点)**:继续排查变量名根因("Please continue")。
+**根因确认**:仿真点全 FAILED 因 `set_variable: Could not find Outer_Rotor_Diameter`——模板几何变量用径向电机命名,与 MARS(PCB 无铁心轴向磁通 SSSR)不符。
+**方法**:解析 .mot 静态比对 + pymotorcad get_variable 探测 + set_variable 回读校验(KNOWLEDGE_BASE §8 + 铁律)。
+**本次完成**:
+1. 修正 4 个几何 motorcad_var:Outer_Rotor_Diameter→RotorOuterDiameter(130)、Stator_Outer_Diameter→Stator_Lam_Dia(76)、Stator_Inner_Diameter→Stator_Bore(50)、Rotor_Back_Iron_Thickness→Back_Iron_Thickness(5),get/set 均 OK。
+2. Inner_Rotor_Diameter(转子内径,4 候选全 MISS)与 Stator_Yoke_Thickness(定子轭厚,PCB 无铁心)设 motorcad_var=null(不写入不报错)。
+3. 几何默认值从径向模板值更正为 MARS 实测值(130/76/50/5)。
+4. KNOWLEDGE_BASE §3.3 新增 MARS 实测变量名对照表(知识沉淀)。
+**验证**:get 探测 22 名 OK / 模板 12 错误名全 MISS / Magnet_Arc_[ED]=121 OK;set+回读 4 名 ALL WRITABLE;py_compile+ASCII 通过。TEST-036。
+**遗留**:模板修正需重启后端;建议重新生成方案并真实仿真端到端复验(确认不再报 Could not find variable);其他拓扑(DRSS)变量名需另行实测。
+
+## 2026-09-03 · 端到端真实仿真验证(变量名修正终极复验)
+**用户要求(要点)**:协助真实仿真复验("Please continue")。
+**方法**:写 pymotorcad 脚本模拟执行器完整流程(build_default_fixed_params 生成 37 参数 → set 全部可写参数 → do_magnetic_calculation 真实求解 → export 提取指标)。
+**迭代排查(3 轮端到端)**:
+1. 首轮 set 31/33 ok,揪出 2 个补充错误名:`Current_Advance_Angle`、`Material_Stator_Lam_Yoke`(几何 4 个已修正 OK)。
+2. 补修:`Current_Advance_Angle→PhaseAdvance`(实测 OK);`Steel_Grade` 设 null(MARS 无铁心)。二轮 set 32/32 ok 但求解失败——`NdFeB_N42SH` 材料库不存在(材料值错误)。
+3. 材料修正:MARS `.mot` 实测 `Material_Magnet=N42UH`,模板默认改 `N42UH`。三轮端到端全链路成功。
+**最终验证**:32 可写参数 set 全成功(0 failed);do_magnetic_calculation 求解成功(~2 分钟);导出 337 行 CSV,关键指标有效(转矩脉动 2.815%、AC 损耗 0.47W、功率因数 0.995)。
+**结论**:仿真失败根因(变量名 + 材料名)彻底修复,模板所有 motorcad_var 经实测验证。TEST-037。
+**遗留**:建议 Web 端用本地执行器跑完整方案扫描复验生产链路;其他拓扑/模型变量名需另行实测。
+
+## 2026-09-03 · 生产链路复验(Web→任务→执行器真实仿真→回传)
+**用户要求(要点)**:协助生产链路复验("Please continue")。
+**过程(链路逐段排障,3 个新修复)**:
+1. 执行器上线(enable_mock=false)。start-simulation 预检 400——`_KNOWN_VARIABLES[SSSR]` 含错误几何名,误判修正后的正确名 unknown。**修复1**:topology_variable_map 的 `_KNOWN_VARIABLES` + alias map 几何名更正为 MARS 实测名。
+2. 预检通过、任务 dispatched,但执行器 `not claimable`——start-simulation 已 pending→dispatched,执行器 claim 时再 dispatch 被后端拒绝(仅 pending→dispatched)。**修复2**:task_executor 对 dispatched 任务跳过重复 dispatch。
+3. 执行器捡起但点失败 `No module named 'scripts'`——afmcore.adapters.motorcad 的 `from scripts.robust_motorcad import` 缺仓库根 sys.path。**修复3**:motorcad._ensure_solver 导入前确保仓库根在 sys.path。
+**最终验证**:任务 completed,successful_points=1,failed_points=0;tavg_nm=0.5219 Nm、efficiency=86.06%、total_losses=41.945 W(与 pymotorcad 单点一致)。**完整生产链路(Web 生成→预检→下发→执行器真实求解→回传)全部通畅**。TEST-038。
+**遗留**:多点扫描方案生产复验;其他拓扑/模型变量名需实测登记。
+
+## 2026-09-03 · 多点扫描生产复验(3 点 Airgap)
+**用户要求(要点)**:做多点扫描方案最终确认("Please continue")。
+**本次完成**:创建 plan 28(Airgap 0.6/1.0/1.5mm,3 点)→ start-simulation → 执行器逐点真实求解。
+**验证结果**:task c2781acf completed,successful_points=3,failed_points=0;批量调度正常(进度 33.3%→66.7%→100%);逐点落盘。
+**逐点指标(物理趋势验证)**:Airgap 0.6/1.0/1.5 → tavg 0.566/0.522/0.468 Nm、eff 84.9/86.1/86.3%、losses 49.9/41.9/36.6 W。趋势符合电磁学(气隙↑→转矩↓损耗↓效率↑),结果真实有效。
+**结论**:多点扫描生产链路完全通畅,从"仿真失败根因(变量名)"到"多点生产链路全通畅"彻底闭环。TEST-039。
+**遗留**:用户可在前端用 AI 生成多变量方案体验完整 AI→仿真→分析闭环。
+
+## 2026-09-03 — UI 评审遗留项全部修复
+
+**用户要求**:对 UI 评审剩余遗留项"全部开始修复"。
+
+**本次完成**(4 项,commit 待提交,TEST-040):
+1. **PlanDetail 扫描变量编辑持久化**:添加/删除置 dirty,"保存修改"按钮 PUT 完整 plan_data(后端单一事实源校验);修复了此前"编辑只改前端状态、刷新丢失"的缺陷。
+2. **P2-1 监控页合并**:新建 ExecutionMonitor.vue(任务监控/执行器状态双 Tab,v-if 仅活动 tab 轮询);`/executor-monitor` 重定向 `/monitor`;导航"执行"组减为两个入口。
+3. **P2-3 批量清理**:后端 projects/tasks 批量删除 API(逐项容错;任务仅终态可删,活动任务 400 拒绝);前端两列表多选 + 条件批量按钮 + 二次确认;任务行级删除按钮(仅终态)。API 实测终态保护通过。
+4. **P2-4 深化**:结果列配置持久化 localStorage(`afm:plan-result-columns` 全局偏好)。
+
+**踩坑**:前端两处 script Edit 静默未生效(old_string 失配),vue-tsc 类型检查报错发现后补回——类型检查再次拦截静默失效。
+
+**验证**:vue-tsc 0 错误;后端 3 文件编译 + ASCII 通过;批量删除 API curl 实测;Edge CDP 截图回归 /monitor、/tasks、/projects。
+
+**遗留**:无。UI 评审全部项闭环。P2-2 步骤条交互维持"价值低不做"。
+
+## 2026-09-03 — 文档同步与前次会话遗留收尾
+
+**用户要求**:"Please continue"——继续收尾。
+
+**本次完成**:
+1. **前次会话遗留入库**(commit `3dd42b9`):`src/plan_schema.py`(字符串枚举支持)+ `style.css`(P6-M1 设计令牌)+ 两个未跟踪测试脚本。`test_plan_schema.py` 7/7 PASS 直接入库。
+2. **测试脚本断言修正**(TEST-041):`test_topology_variable_map.py` 断言基于旧错误变量名,TEST-036 修正后 6/8 失败(预期);更新断言至 MARS 实测名(Stator_Lam_Dia/Stator_Bore/RotorOuterDiameter),新增"旧错误名判 unknown"反向防回归断言,更新后 8/8 PASS。
+3. **文档同步**:README.md"最近更新"+里程碑表补 9-02/9-03 全部工作(P6-M2/M3 标记完成);HANDOFF.md 第 3 节进度/阻塞/待办全面更新(移除已解决的"5 个未验证变量名"阻塞项,新增变量名根因修复与生产链路通畅记录),第 4 节接续提示词同步。
+
+**踩坑**:Edit 工具对 test_topology_variable_map.py 3 处替换静默失效(报告成功但内容未变),grep 复查 + 重跑测试发现。教训:Edit 后必须 grep 验证。
+
+**遗留**:DRSS/SDSR 拓扑变量名待实测登记;热求解真实验证待模型;前端实机走查待用户回归。
+
+## 2026-09-03 — 用户实测反馈四需求落地
+
+**用户要求**(基于实测截图):①启动仿真预检 model_path 空阻断,要求拓扑基础模型库自动调用(SSSR/DRSS/SDSR)②新建项目弹窗 BC 只 6 项应扩到 25 项全显示、默认未设置,详情页全展开 + 用户指定/AI 补充/已修改来源标签 ③概览验收标准按 BC 自动带出判断条件 ④方案参数页 BC/固定参数默认全展开 + 可编辑保存。
+
+**澄清**:DRSS/SDSR 无基础模型文件(仅 SSSR 有 MARS .mot),未编造——SSSR 自动调用,DRSS/SDSR 待用户提供模型后登记。
+
+**本次完成**(TEST-042):
+1. **拓扑默认模型**:topology.py 加 default_model 字段 + default_model_for();三处回退(AI 生成/手动创建/**start-simulation 运行时兜底回填并持久化**——16 个历史空模型方案直接可启动);预检失败项"使用拓扑默认模型"一键修复按钮。
+2. **全量 BC + 来源标签**:发现并修复 defaultBC 预填默认值隐藏 Bug(用户无法区分自填/默认)→ 改全空仅存非空;bc_fields 目录加 type/options 表单元数据;prompt 加 bc_suggestions(仅补未设置项)+ 扫描变量清单同步 13 项;bc_meta 来源追踪(user/ai/edited);新建弹窗 25 项分组渲染;ProjectDetail/PlanDetail BC 全展开+标签+编辑保存;冷却方式枚举值统一大写。
+3. **验收标准**:AI acceptance_criteria + BC 目标字段推导 7 类判断条件,带来源标签;修复 AI 嵌套 dict 格式丢失(convert 归一化 hard_constraints dict→list);修复 plan.acceptance_criteria/boundary_conditions 字段路径 Bug(实际在 plan_data 下)。
+4. **参数区展开编辑**:固定参数默认全展开 + inline 编辑保存;扫描变量"取值"列修复 values 数组显示(原 min/max/step 列对 AI 方案空白)。
+
+**验证**:5 后端文件编译+ASCII;vue-tsc 0 错误;端到端 model_path 回填/bc_meta/归一化通过;Edge CDP 截图 5 张核验(脚本扩展 js: 表达式点击)。
+
+**遗留**:DRSS/SDSR 基础模型待用户提供;AI bc_suggestions 标签待有未设置项场景实测。
+
+## 2026-09-03 — PROMPTS_DIR 路径 Bug 根因修复(AI prompt 从未生效)
+
+**起因**:bc_suggestions 三次生成均为空,深挖根因。
+
+**根因(重大)**:`web/backend/app/config.py` 的 `BACKEND_DIR = Path(__file__).parent` = `web/backend/app/`,而 `PROMPTS_DIR = BACKEND_DIR / "prompts"` 指向不存在的 `app/prompts/`(实际在 `web/backend/prompts/`)→ **真 prompt(generate.txt)从未被加载,一直走中文兜底 prompt**。这回溯解释了此前全部 AI 行为问题:变量名编造(OuterDia/Stator_Lam_Length)、acceptance_criteria 嵌套 dict、bc_suggestions 不输出——模型从未看到含 13 变量清单的真 prompt。
+
+**修复**:PROMPTS_DIR 改 `BACKEND_DIR.parent / "prompts"`;prompt 措辞改强制输出 bc_suggestions;ai_plan.py 项目上下文 BC 全量展开(未设置字段显式 null——此前未设置 key 直接缺失,模型无从知晓可补哪些);SCAN_PARAM_CN 补 4 个中文名。
+
+**验证(TEST-043)**:端到端 AI 补充 7 项 BC(标 ai,不覆盖 user 3 项);扫描变量全部注册标准名;warnings 干净;截图验证"用户指定/AI 补充"标签渲染。测试产物已清理。
+
+**沉淀**:KNOWLEDGE_BASE §1.2 加坑记录(改 prompt 后必须看 ai_call_logs.prompt_preview 验证实际加载)。
+
+**遗留**:历史方案(prompt 修复前生成)质量参差,建议关键方案重新生成;experience/extract.txt 不存在(既有状态)。
+
+## 2026-09-03 — 新 prompt 质量验证 + extract.txt 补建
+
+**本次完成**(TEST-044):
+1. **新 prompt 质量对比**:project 13 端到端生成——扫描变量全标准名、验收标准规范 list 4 条、warnings 干净(对比修复前:编造名/嵌套 dict/多警告)。
+2. **补建 experience/extract.txt**:该目录原为空,experience_enhancer 一直走单行 fallback;同步修其 max_tokens=3000 撞顶隐患(改 KIMI_MAX_TOKENS)。函数级真实 AI 调用验证(plan 28 三点数据):输出结构完整、规则带量化证据、样本量保守声明正确,PASS。
+3. 途中发现前端 Vite 进程挂了(502),已重启恢复。
+
+**说明**:smart-extract 端点为规则化提取不走 AI;extract.txt 生效路径是 adaptive_loop 自适应闭环(待执行器在线后端到端实测)。
+
+**遗留**:自适应闭环端到端实测(待执行器);历史方案质量参差。
+
+## 2026-09-03 — 自适应闭环集成修复(真 prompt 启用后暴露的断层)
+
+**用户要求**:"继续"——闭环 update-experience 是 extract.txt 的真实生效路径,做端到端验证。
+
+**发现并修复 3 个集成断层**(P3-M5 遗留,真 prompt 启用后才暴露):
+1. generate_plan 存 convert 后 `variables`,initialize_search 读 `scan_variables` → 归一化兼容。
+2. convert 输出 `start/stop/step`,initialize_search 读 `min_value/max_value` → 兼容 + values 数组推导。
+3. 闭环方案缺 topology/model_path → 拓扑归一化 + default_model_for 回填。
+
+**验证(TEST-045)**:generate-plan(topology=SSSR + 模型回填 + 标准变量名)✅;init-search HTTP 200 ✅;转换函数级 3 断言 PASS ✅。
+
+**发现第 4 个断层(未修,待办)**:L0 引擎期望 BC 风格参数名(airgap_mm),闭环传 Motor-CAD 名(Airgap)→ 全判不可行 → 初始批次空。需 Motor-CAD 名→L0 BC 名语义映射层(注意 Magnet_Length 轴向 ≠ magnet_thickness_mm 径向厚度,不能瞎对应)。
+
+**遗留**:L0 参数名口径映射为闭环关键待办;update-experience 闭环集成段草案待验证(extract_insights 本体已 TEST-044 函数级验证)。
+
+## 2026-09-03 — 自适应闭环全链路打通(L0 口径映射 + 3 个闭环 Bug 修复)
+
+**用户要求**:"继续"——修复 L0 口径断层(闭环关键待办)并做全链路实测。
+
+**修复**(TEST-046):
+1. **L0 参数名口径**:`src/afmcore/l0/prescreening.py` 加 `MOTORCAD_TO_L0` 语义映射(6 条,均语义严格一致:Magnet_Length=轴向磁通磁钢厚度方向尺寸→magnet_thickness_mm;Magnet_Thickness 径向深度无 L0 对应项不映射)。evaluate 入口翻译,L0 原生 key 优先。函数级 4 检查全 PASS。
+2. **初始批次不消费**:select_next_batch 开头先消费 pending 初始点。
+3. **report-results 路由缺失**:report_loop_results 无装饰器(P3-M5 遗留),已补。
+4. **export inf→JSON 500**:best_objective_value 导出转 None;import 时 None 保持 inf 哨兵(否则 `value > None` TypeError)。
+
+**端到端实测(全真实数据)**:闭环 2 批 8 点(Airgap×RMSCurrent)→ 执行器真实仿真 8/8 completed → report-results(results_analyzed)→ **update-experience 成功**(experience_updated,AI 提取 4 条带量化证据的设计规则,extract.txt 闭环真实生效)。趋势合理(电流↑→转矩↑效率↓)。export/import 检查点机制顺带实测通过。
+
+**踩坑**:任务构造 motorcad_var=None 参数用 name 回退写入导致 "Could not find"(修正:只写可写参数);next-batch 返回字段是 points 不是 batch。
+
+**遗留**:主动学习后续批次(信任域)未验证;loop 为内存态。
+
+## 2026-09-03 — submit-batch 生产路径验证
+
+**修复**:submit_batch_to_executor 补固定参数合并(原只传裸扫描点,执行器不读 plan_data.fixed_params)。
+**验证**:任务 f19ba592(12 点)参数合并正确(30 固定+扫描+point_id,无错误变量名),执行器捡起跑。✅
+**发现缺口(未修)**:执行器跑完不调闭环 /report-results(P3-M5 设计未实现段),闭环需手动桥接结果。
+
+## 2026-09-03 — 闭环生产路径全跑通 + 经验提取数据完整性修复
+
+**验证**(TEST-048):submit-batch 任务 12/12 全成功;12 点 report 入环(置信度 D→C,best 效率 86.3→89.4);update-experience 第二轮成功。
+**AI 反馈暴露缺陷**:all_results 只合 metrics 无输入参数 → 敏感性分析无法做。修复:①report_results 合入 point params ②_condense_results 经 MOTORCAD_TO_L0 翻译 Motor-CAD 输入名再提取。函数级验证 PASS。
+**验证边界**:修复影响未来闭环;函数级已证,端到端待下次闭环。
+**遗留**:执行器→闭环自动回传缺失(P3-M5 未实现段)。
+
+## 2026-09-04 — 执行器→闭环自动回传(P3-M5 最后缺口闭合)
+
+**修复**:task_executor 加 `_report_to_adaptive_loop`——adaptive_batch 任务完成后自动把结果 POST 到闭环 report-results(point_id/metrics/status 映射,best-effort 不影响任务上报)。
+**验证**(TEST-049):链路级——adaptive_batch 任务 3 点自动入环(n_results 0→3),scan 任务负面对照不触发。执行器已重启加载。
+**验证边界**:链路级连通性验证;全闭环自动流转的长时端到端(数十分钟真实仿真)未做,建议生产观察。
+**至此闭环全自动**:submit-batch→执行器→自动回传→主动学习下一批,无需手动桥接。
+
+## 2026-09-04 — 闭环经验入库(闭环价值闭环)
+
+**发现**:generate_experience_entry 只返回 dict 不入库,update_experience 从不持久化——闭环提取的经验重启即失、经验库页不可见、后续 AI 生成无法复用。
+**修复**:update_experience 加 _persist_experience_case(映射为 ExperienceCase 入库,best-effort)。
+**验证**(TEST-051):真实 12 点数据灌入 + update-experience → experience_case_id=7,experience_cases 6→7 行,/api/experience 可见。✅
+**意义**:闭环价值完整闭环——仿真→AI 提取→经验入库→后续 AI 生成复用。
+
+## 2026-09-04 — 经验库复用断裂修复
+
+**起因**:上轮声称"后续 AI 生成复用经验库"未实际验证,按零猜测原则补验。
+**发现(真实断裂)**:generate-and-save 不传 existing_experience、/generate 只在请求体显式传才用、前端不传 → 经验库的值根本没流入 AI 生成。
+**修复**:_load_experience_cases 按拓扑取最近 5 条;generate-and-save 自动加载;/generate 未显式传时自动加载(补 db 依赖)。
+**验证**(TEST-052):加载函数返回 5 案例;端到端生成 HTTP 200;附带实证真 prompt 生效(system prompt 为英文版)。prompt_preview 仅 500 字符截断在 system prompt——经验在 user message 中,日志不可见属截断非未加载。
+**意义**:经验价值链完整——仿真→提取→入库(TEST-051)→生成自动复用(本次)。
+
+## 2026-09-04 — 批次语义修复 + 全自动闭环真实端到端(零手动桥接)
+
+**发现**:submit-batch 提交全部 24 个 pending 点而非当前批次(select_next_batch 不标记选中点,仍 pending)。
+**修复**:引入 dispatched 状态——select_next_batch 选中点标 dispatched(不再被重复选/提交),submit_batch 只提 dispatched 当前批次,batch_summary 桶加 dispatched。函数级验证:pending 4→选 2 dispatched→pending 减 2→submit 正好 2。
+**真实全自动端到端**(TEST-053,核心):建闭环→AI 生成→选点→submit-batch(5 点)→执行器真实仿真 5/5→**自动回传**(全程零手动 report)→n_results 0→5 自动增加→update-experience 入库 case 8,**敏感性分析正常**(Current strong/Airgap moderate,TEST-048 修复真实生效),量化结论正确。
+**意义**:P3-M5 自适应闭环真正全自动,零手动桥接,完整实证。
+**遗留**:执行器在后端重启期间心跳中断需重启(可自愈优化,非阻断)。
+
+## 2026-09-04 — 执行器心跳独立线程
+
+**问题**:执行器心跳与任务执行同线程串行,长跑点(~2min)期间心跳停止被误判 offline(实测 30216 假死)。
+**修复**:start_polling 拆为独立 heartbeat_loop + poll_loop 两线程。
+**验证**(TEST-054):执行器重启上线且 15s 持续在线,旧执行器正常超时离线。
+
+## 2026-09-04 — 自适应闭环前端页
+
+**背景**:闭环后端已全自动实测通畅但无 UI 入口(adaptiveApi 存在于 ai.ts 但无页面使用,且缺 submitBatch 方法)。
+**实现**:新建 AdaptiveLoop.vue(创建表单+闭环列表+详情+一键自动运行状态机+经验提取),补 adaptiveApi.submitBatch,加路由 /ai/adaptive-loop + 导航"自适应闭环"入口 + 面包屑。
+**验证**(TEST-056):vue-tsc 0 错误;页面 PAGE_ERRORS(0);截图列表页+详情页正常。一键自动运行的各步骤 API 已在 TEST-045~053 单独实测,UI 串接待实机长跑确认。
+
+## 2026-09-04 — prompt_preview 截断修复 + 经验进入 prompt 实证
+
+**问题**:_log_call 存 messages[:3][:500],截断在 system prompt,user message(BC+经验)不可见,TEST-043/052 两次无法从日志确认 AI 实际输入。
+**修复**:prompt_preview 含全部消息、上限 500→8000;response_preview 1000→2000。
+**验证**(TEST-057):重新生成后日志含"参考经验案例(5个)"及完整 params/metrics——经验进入 prompt 实证。价值链最后一环闭合。
+
+## 2026-09-04 — 闭环 UI 端到端实测 + axios 超时根因修复
+
+**问题**:首次 UI 一键自动运行跳"运行中断"——方案已生成但前端报错。根因:axios 默认 30s 超时 < AI 生成约 40s,前端中止后端照跑,状态错位(aiPlanApi 早已设 180s,新 adaptiveApi 漏了)。
+**修复**:adaptiveApi 的 generatePlan/reportResults/updateExperience 补 180s 超时;startAutoRun 改断点续跑(按 phase 跳过已完成步骤)。
+**验证**(TEST-058):UI 驱动全闭环端到端实测——2 批次共 5 点全成功、最优 86.346、预算耗尽后自动提取经验(案例 9 入库)。执行器全程 online。TEST-056 验证边界闭合。
+
+## 2026-09-04 — 首次打通 Motor-CAD 热仿真(电磁→热链路)
+
+**用户需求**:加入 Motor-CAD 热仿真;先用 MARS 模型跑一个自动热仿真看效果;电磁仿真后把损耗用于热仿真一起做,节省时间。
+
+**核实(零猜测)**:先查 pymotorcad 源码(ansys.motorcad.core)确认真实 API,发现 P5-M6 遗留的 enable_thermal 用了两个不存在的接口:
+- `do_thermal_calculation()` 不存在 → 真实为 `do_steady_state_analysis()`(稳态)/ `do_transient_analysis()`(瞬态)/ `do_magnetic_thermal_calculation()`(磁热耦合)
+- `export_results("Thermal", ...)` 的 solution_type 无 "Thermal" → 真实为 "SteadyState"/"Transient"
+
+**实测**(TEST-060):写 `scripts/run_thermal.py`,MARS 模型跑通稳态热仿真——电磁 127.8s + 稳态热 6.0s。
+得到绕组平均 67.95°C、热点 74.59°C、磁钢 118.15°C、后轴承 88.47°C。
+
+**修复**:robust_motorcad.py 改两个 API;metrics.py 7 个热指标补实测字段别名(中英文),
+bearing_temp_c 映射到后轴承(轴向磁通电机热风险侧,前轴承仅 49.2°C)。
+
+**遗留(待用户确认)**:MARS 模型 `Ambient_Temperature=125`(环境 125°C 异常,辐射 40°C 正常),
+导致温升/热阻为负值。热仿真前需把环境温度修正为 25~40°C。
+
+**待办**:磁热耦合 do_magnetic_thermal_calculation 待实测;热仿真接入执行器 enable_thermal 全链路待验证。
+
+## 2026-09-04 — 磁热耦合实测 + 执行器 enable_thermal 全链路 + git 对象库损坏恢复
+
+**磁热耦合实测**(run_thermal.py --mode coupled):`do_magnetic_thermal_calculation` 耗时 474.2s
+(分离式 134s 的 ~3.5 倍)。磁钢 110.68°C(vs 分离 118.15°C,迭代收敛更低)、绕组热点 76.43°C、
+转矩 0.515 Nm(vs 0.566,温度反馈致剩磁下降)、脉动 2.78%(vs 5.45%)、损耗 42.57W(vs 49.92W)。
+**结论**:磁热耦合更准确但更慢;"电磁+热省时间"应选分离式(先电磁后热,损耗自动传递),
+磁热耦合仅用于温度敏感场景最终复算。
+
+**执行器 enable_thermal 全链路**:5 处透传打通——motorcad.py(adapter 参数)→ task_executor.py
+(MotorCADTaskExecutor 参数)→ run_task_executor.py(build_executors)→ executor_config.py
+(默认值+校验+EXECUTOR_THERMAL env)→ executor_config.json(enable_thermal:false)。默认关闭,
+`EXECUTOR_THERMAL=true` 环境变量可开启。test_executor_config 20 项 + test_adapters 22 项 +
+test_executor_p5m2 8 项全通过。
+
+**⚠ git 对象库损坏事故(已恢复)**:为验证 test_executor_m3 失败是否为既有问题,执行
+`git stash` 时触发了 git 自动 gc,被 shell 超时 SIGTERM 中断,导致 `.git/objects` 的 pack 文件
+与几乎所有 loose 对象被删(仅剩 1 个 commit 对象 `06a99b9`,且其 tree 也丢)。remote 亦不可达。
+**工作区文件全部完整无损**(逐一 grep 校验)。恢复:备份损坏 `.git`→`.git.corrupted.bak`,
+重新 `git init`,恢复 config(remote/user),`.gitignore` 补忽略规则(.git.corrupted.bak、
+.workbuddy、*.png、*.docx、第三方评审、超长文件名论文目录),`git add -A` + 重建提交
+`b8dab74`(244 文件)。**教训:git stash 在中文仓库根路径会触发 gc,切勿在仿真/长任务进行时执行**。
+
+**test_executor_m3 失败定性**:该测试用 TaskExecutor 基类测"claim 拒绝→跳过"逻辑,与本次
+enable_thermal 改动(仅在 MotorCADTaskExecutor 子类)无关,为既有失败,非本次引入。
+
+## 2026-09-04 — 环境温度修正实测(温升/热阻转正)+ 修复 test_executor_m3
+
+**环境温度修正实测**(run_thermal.py --ambient 25):变量名 `Ambient_Temperature` 实测可写、
+可回读校验。MARS 默认 125°C 异常,修正为 25°C 后温升/热阻均由负转正:
+
+| 指标 | ambient=125 | ambient=25 |
+|---|---|---|
+| 温升 temp_rise_c | -56.88 | **+27.57** |
+| 热阻 thermal_resistance_k_w | -11.07 | **+5.657** |
+| 磁钢 active | 118.15°C | 67.61°C |
+| 绕组热点 | 74.59°C | 53.91°C |
+
+验证了"125°C 异常是温升/热阻为负的根因"。建议默认 25°C 或按实际工况设 40°C。
+
+**修复 test_executor_m3**:失败根因是测试辅助 make_task 未设 `status="pending"`,导致
+execute_task 的 claim 逻辑(仅 status=="pending" 才 dispatch)被跳过、`dispatch_task=False`
+未生效。修复后 4 项全通过。属测试代码缺陷,非生产代码 bug。
+
+## 2026-09-04 — 环境温度固化到执行器配置(ambient_temperature 全链路)
+
+**背景**:执行器 enable_thermal 热求解时默认沿用模型 `Ambient_Temperature=125`(异常),
+需固化默认值 25°C 让执行器热仿真开箱即用且正确。
+
+**实现(7 处透传)**:RobustMotorCADSolver.__init__ 加 `ambient_temperature`(热求解前
+`_write_and_verify("Ambient_Temperature", ...)` 覆盖)→ MotorCADAdapter → MotorCADTaskExecutor
+→ run_task_executor.py → executor_config.py(默认 25.0 + 校验 + `EXECUTOR_AMBIENT` env)→
+executor_config.json(`ambient_temperature: 25.0`)。
+
+**验证**(verify_enable_thermal.py 改用构造参数复测):`RobustMotorCADSolver(enable_thermal=True,
+ambient_temperature=25.0)` 单点端到端 PASS——7 项热指标落盘,温升 +27.57、热阻 +5.657。
+测试 test_executor_config 20 + test_metrics_extension 20 全通过。

+ 144 - 0
docs/HANDOFF.md

@@ -0,0 +1,144 @@
+# 接续指南 · PCB 轴向磁通电机自动化仿真系统
+
+> 面向**换人 / 换机 / 新会话**后继续工作的场景。
+> 配套框架:`ai-collab-dev-playbook-v2.md`(方法论)+ `AGENTS.md`(AI 行为准则)。
+> 最后更新:2026-09-01
+
+---
+
+## 1. 环境要求
+
+| 软件 | 版本 | 用途 | 必需性 |
+|---|---|---|---|
+| Motor-CAD | 2026R1 (v261) | 电磁/热/结构仿真求解 | 必需(仿真) |
+| Python | ≥ 3.10 | 执行器 / 后端 / 测试 | 必需 |
+| ansys-motorcad-core | 0.8.x | pymotorcad 自动化接口 | 必需(仿真) |
+| PySide6 / pandas | 最新稳定 | GUI / 数据处理 | 必需 |
+| fastapi / uvicorn / pydantic | 最新稳定 | Web 后端 | 必需(Web) |
+| Node.js / npm | ≥ 18 | 前端 Vue3 构建 | 必需(Web) |
+| Ansys License Manager | FlexNet | 许可证(lmgrd + ansyslmd) | 必需(仿真) |
+| Ansys Maxwell + PyAEDT | — | Maxwell 适配器(当前 mock) | 可选(待接入) |
+| JMAG + jmagpy | — | JMAG 适配器(当前 mock) | 可选(待接入) |
+| scipy/numpy | — | Kriging 代理(当前 IDW 降级) | 可选(待升级) |
+
+---
+
+## 2. 恢复步骤(第一条命令)
+
+```powershell
+# 1) 核对环境(只读体检,缺项会给修复建议)
+python scripts/check_machine_paths.py
+
+# 2) 验证环境变量(非登录 shell 可能不继承,见 docs/KNOWLEDGE_BASE.md §1)
+echo $env:MOTORCAD_ACTIVEX
+echo $env:ANSYSLMD_LICENSE_FILE
+
+# 3) 前端依赖(如需构建)
+cd web/frontend; npm install
+
+# 4) 后端启动(开发态,两终端)
+python -m uvicorn app.main:app --port 8000   # 在 web/backend 下
+
+# 5) 本地执行器(仿真端,enable_mock=true 时无需 Motor-CAD)
+python scripts/run_task_executor.py --config executor_config.json
+```
+
+---
+
+## 3. 当前进度(2026-09-03)
+
+### 已完成
+- **P1**:环境验证 + 单工况仿真 + 参数扫描引擎 + 方案 JSON 接口 + PySide6 GUI + 经验库雏形
+- **P2**:Web 端方案系统(FastAPI + Vue3)+ 本地执行器真实仿真全链路打通
+- **P3**:AI 驱动智能仿真闭环(策略抽象层 + 自适应编排器 + 批次化 + HTTP 闭环 + 真实烟雾测试)
+- **P4**:Web AI 集成 + 双系统闭环 + 批量调度 + Schema 统一 + EXE 打包(`dist/PCB-AFM-Executor.exe`)
+- **P5**:平台化增强 M1~M6(前端 build 清零 / EXE 配置化 / adaptive 三视图 / Morris+IDW 策略 / 多工具适配器 / 多物理场 L2 热+结构指标)
+- **P6-M1**:Web 前端 UI/UX 全面重构(设计令牌 + 3 公共组件 + B1 信息架构 + B2 PlanDetail 分层)
+- **P6-M2**:BC 参数目录单一事实源(`bc_fields.py` 25 字段 + `/api/bc-fields` + `normalize_bc` 统一 key 口径,消除 current_a/rated_ 双轨)
+- **P6-M3**:仿真前检查清单(`/api/plans/{id}/preflight` 5 项检查)+ 耗时校准(实测 solve_time_s 均值回写)+ 任务创建自动展开方案参数
+- **仿真失败根因修复(2026-09-03,关键)**:MARS 模型变量名经 pymotorcad 实测修正——`RotorOuterDiameter`/`Stator_Lam_Dia`/`Stator_Bore`/`Back_Iron_Thickness`/`PhaseAdvance`(原模板用径向电机命名全错)+ 磁钢材料值 `NdFeB_N42SH`→`N42UH`;详见 KNOWLEDGE_BASE §3.3
+- **生产链路全通畅(2026-09-03)**:修复拓扑预检变量集、执行器 dispatch 状态机冲突、适配器 scripts 导入路径;**多点扫描实测通过**(3 点 Airgap:tavg 0.566/0.522/0.468 Nm,趋势符合电磁学)
+- **AI 生成链路修复**:Kimi max_tokens 撞顶(2000→配置值)+ topology/strategy 枚举归一化兜底 + 生成摘要对话框 + 扫描变量归一化(SCAN_PARAMETERS 8→13)
+- **UI 评审修复**:统计卡字段名 Bug + Dashboard 结果加载(.items)+ BC 空值折叠 + 失败结果错误信息列 + 监控页合并(ExecutionMonitor)+ 批量清理(项目/任务)+ 扫描变量编辑持久化 + 列配置 localStorage
+- **PROMPTS_DIR 根因修复(2026-09-03,重大)**:`config.py` 的 `BACKEND_DIR=app/` 导致 `PROMPTS_DIR` 指向不存在的 `app/prompts/`(实际在 `web/backend/prompts/`)→ **真 prompt(generate.txt/analyze.txt)从未加载,一直走兜底**。修复后 AI 变量名遵循度根治、bc_suggestions 生效、验收标准格式规范。新增 `extract.txt`(经验提取)。详见 KNOWLEDGE_BASE §1.2
+- **用户四需求(2026-09-03)**:拓扑默认模型自动调用(topology registry `default_model` + 三处回退 + 预检一键修复)+ 全量 25 项 BC 表单与来源标签(用户指定/AI 补充/已修改,bc_meta 追踪)+ 验收标准自动带出 + BC/固定参数默认全展开可编辑保存
+- **自适应闭环全链路打通(2026-09-03/04,TEST-045~049)**:修复 6 个集成断层(variables/scan_variables 字段名、start/stop 键名、闭环缺 topology/model_path、**L0 参数名口径**——afmcore/l0 加 MOTORCAD_TO_L0 语义映射、初始批次不消费、report-results 路由缺失、export/import inf 哨兵)+ submit-batch 固定参数合并 + **执行器自动回传闭环**(adaptive_batch 完成自动调 report-results)。端到端实测:8+12 点真实仿真全成功,AI 经验提取 2 轮成功
+- **经验提取数据完整性**:report_results 合入输入 params + condense 经 MOTORCAD_TO_L0 翻译(修复"AI 拿不到输入参数无法做敏感性分析")
+
+### 当前阻塞点
+1. **热仿真稳态已打通(2026-09-04 实测)**:`enable_thermal` 修复后 MARS 稳态热跑通(电磁 127.8s + 热 6s)。剩余:MARS 模型 `Ambient_Temperature=125` 异常待修正(致温升/热阻为负);磁热耦合 `do_magnetic_thermal_calculation` / 瞬态 `do_transient_analysis` 待实测;执行器 enable_thermal 全链路待验证
+2. **Maxwell/JMAG 适配器为 mock**:真实接入待目标机环境(Ansys Maxwell + PyAEDT / JMAG + jmagpy)
+3. **代理模型为 IDW 降级**:Kriging 待引入 scipy 后同接口替换
+4. **中文仓库根 + git 2.52.0.windows.1 子目录命令卡死**:规避 = 先 `cd` 到目标子目录再用相对文件名(见 KNOWLEDGE_BASE §1)
+
+### 待办
+- 修正 MARS 模型环境温度 `Ambient_Temperature`(125→25~40°C)后复跑热仿真,验证温升/热阻转正
+- 实测磁热耦合 `do_magnetic_thermal_calculation`(电磁+热一次算,用户"一起做省时间"诉求)
+- 热仿真接入执行器 enable_thermal 全链路(GUI/扫描点热指标落盘)
+- 其他拓扑(DRSS/SDSR)/其他模型的变量名需按 MARS 同样方法实测后登记到 `topology_variable_map`(当前仅 SSSR/MARS 经实测);DRSS/SDSR 无基础模型文件,待用户提供 .mot 后登记到 topology registry 的 default_model
+- 全闭环自动流转的长时端到端观察(信任域收敛);历史方案(prompt 修复前生成)质量参差,建议关键方案重新生成
+- 前端实机走查:AI 生成 → 检查清单 → 启动仿真 → 结果趋势(用户回归)
+
+---
+
+## 4. 给 AI 的接续提示词(整段粘贴)
+
+```text
+这是 PCB 轴向磁通电机自动化仿真系统项目。双系统解耦的轴向磁通电机自动化仿真
+平台:Web端方案生成(FastAPI+Vue3)+ 本地EXE仿真执行(Motor-CAD),共享核心层
+src/afmcore/ 为单一事实源。当前 P6 阶段,P6-M1 已完成前端 UI/UX 重构。
+
+【先做这几件事,做完再动任何东西】
+1. 读 docs/HANDOFF.md、docs/KNOWLEDGE_BASE.md、docs/TEST_RECORDS.md、AGENTS.md。
+   KNOWLEDGE_BASE 里记录了大量踩过的坑(环境变量陷阱、Git 子目录卡死、
+   CurrentDefinition 语义、export_results 兼容性等),请重点看,不要重复踩。
+2. 核对环境:python scripts/check_machine_paths.py
+3. 确认当前进度与阻塞点(见 HANDOFF.md 第 3 节)。
+
+【工作约定(必须遵守)】
+- 每次测试前先 git commit;每次对话记录到 docs/CONVERSATION_LOG.md;
+  每次测试记录到 docs/TEST_RECORDS.md(含 TEST 编号索引)。
+- 源码(.py/.ps1)只含 ASCII,中文写进 Markdown;脚本用英文注释。
+- Motor-CAD 用 open_new_instance=True 独立实例 + set_visible(True);
+  参数写入必须回读校验;每个扫描点结束立即落盘。
+- 不要修改 models/ 下原始 .mot;参考目录只读;生成物(output/runs/build/dist/*.log)不入库。
+- 仿真禁止跑在主线程(GUI 需 QThread)。
+
+【铁律(血泪教训)】
+- 非登录 shell 可能不继承 MOTORCAD_ACTIVEX / ANSYSLMD_LICENSE_FILE → 脚本内回退
+- Git 子目录路径命令可能永久挂起 → 先 cd 到子目录再用相对文件名
+- 多处定义同一概念会漂移 → 指标/拓扑/Schema 一律走 src/afmcore 单一事实源
+- 交付前过自查清单,未验证只能标注"待验证/草案",禁止编造数值
+
+【当前状态】
+- 已完成:P1~P6 全部(含 P6-M2 参数目录单一事实源、P6-M3 检查清单+耗时校准);
+  2026-09-03 仿真失败根因(MARS 变量名/材料名)已实测修复,多点扫描生产链路全通畅;
+  2026-09-03/04 PROMPTS_DIR 根因修复(真 prompt 首次生效)+ 自适应闭环全链路打通
+  (含执行器自动回传,TEST-045~049 实测)
+- 阻塞:热求解/多工具/Kriging 待环境或真实运行
+- 待办:DRSS/SDSR 拓扑模型与变量名实测登记;热求解真实验证;闭环长时运行观察;前端实机走查
+
+【接下来做什么】
+- <目标1> / <目标2>
+
+先读文档、恢复环境、跑 check_machine_paths.py,然后告诉我你的理解和建议的
+下一步,不要直接开始改东西。
+```
+
+---
+
+## 5. 文档索引
+
+| 文档 | 内容 |
+|---|---|
+| README.md | 项目全貌 + 最近更新摘要(历史更新见 CHANGELOG.md) |
+| CHANGELOG.md | 全部历史更新记录(按时间倒序) |
+| AGENTS.md | AI 行为准则(工程规范 / 铁律 / 反模式) |
+| ai-collab-dev-playbook-v2.md | AI 协作方法论框架 V2(新项目可复用) |
+| docs/KNOWLEDGE_BASE.md | 环境事实 / 参数语义 / 探测技术 / SOP / 已踩的坑 |
+| docs/TEST_RECORDS.md | 测试记录(TEST-001~024,含索引) |
+| docs/CONVERSATION_LOG.md | 会话与决策记录 |
+| docs/P1-P5交付总结与上手指南.md | 里程碑全盘核对 + 新人上手 |
+| docs/PAPER_KNOWLEDGE_BASE.md | 无铁心 PCB-AFPM 论文知识库(38 篇) |
+| docs/PLATFORM_DESIGN_V2.md | 平台化升级设计方案 |
+| docs/archive/ | 已完结历史计划文档(P3/P4 实施计划等,仅供追溯) |

+ 117 - 15
docs/KNOWLEDGE_BASE.md

@@ -15,6 +15,7 @@
 | 非登录 shell 陷阱 | AI 工具的 shell 可能不继承机器级环境变量,需 inline 设置或脚本内回退 |
 | 单次电磁求解耗时 | 约 90~150 秒(视机器性能和网格设置) |
 | 窗口不可见陷阱 | pymotorcad 用 `/SCRIPTING` 模式启动,默认主窗口隐藏,必须 `set_visible(True)` |
+| Git 子目录路径卡死(2026-08-30 实测) | 从仓库根目录执行带子目录路径的 git 命令(`git add docs/x` / `hash-object web/x` / `diff` / `checkout`)会**永久挂起**(CPU≈0,等 IO,30s 不返回);git log/status/rev-parse/ls-files 正常。**规避**:先 cd 到目标子目录再用相对文件名执行(`cd docs; git add TEST_RECORDS.md` 秒回)。疑似与中文仓库根路径 + git 2.52.0.windows.1 组合相关 |
 
 ### 1.1 环境验证命令
 
@@ -37,6 +38,7 @@ python -c "import ansys.motorcad.core; print('pymotorcad OK')"
 | pymotorcad 报 NoSuchProcess | 同上,或环境变量未继承 | inline export 两个环境变量 |
 | Motor-CAD 窗口看不见 | /SCRIPTING 模式默认隐藏 | `mc.set_visible(True)`;若仍找不到,点任务栏图标 → Win+↑ 最大化 |
 | 改电流无效 | CurrentDefinition=1 时改了 PeakCurrent | 改 `RMSCurrent` |
+| AI 生成不遵循 prompt(变量名编造/可选字段不输出) | **PROMPTS_DIR 路径错一层**:`web/backend/app/config.py` 中 `BACKEND_DIR=app/`,原 `PROMPTS_DIR=BACKEND_DIR/"prompts"` 指向不存在的 `app/prompts/`,实际在 `web/backend/prompts/` → 一直走 `_default_prompt()` 中文兜底 | 已修为 `BACKEND_DIR.parent/"prompts"`(2026-09-03)。**教训:改 prompt 文件后必须验证后端实际加载的是它(看 ai_call_logs 的 prompt_preview 开头)**;影响面:plan_generation/result_analysis 两个 prompt 此前均未生效 |
 
 ---
 
@@ -94,6 +96,79 @@ mc.do_magnetic_calculation()  # 电磁求解,约 90-150 秒
 mc.export_results("EMagnetic", r"output\raw\result_<timestamp>.csv")
 ```
 
+### 2.6 热仿真(稳态 / 瞬态 / 磁热耦合)
+
+**关键事实(2026-09-04 实测,勿再踩坑)**:pymotorcad **没有** `do_thermal_calculation()`
+方法,`export_results` 的 solution_type **没有** `"Thermal"` 这个值。历史 P5-M6 的
+enable_thermal 代码用了这两个错误 API,从未真正跑通(HANDOFF 标注"待真实验证"即因此)。
+
+**真实 API**(来自 ansys.motorcad.core 源码 `rpc_methods_calculations.py` 核实):
+
+| 方法 | 语义 |
+|---|---|
+| `do_steady_state_analysis()` | 稳态热求解 |
+| `do_transient_analysis()` | 瞬态热求解 |
+| `do_magnetic_thermal_calculation()` | 磁热耦合(电磁+热一起算,官方注释 "coupled e-magnetic and thermal") |
+| `do_magnetic_calculation()` | 纯电磁 |
+
+**结果导出 solution_type 合法值**(`export_results(solution_type, file_path)`):
+`'EMagnetic'`(电磁)、`'Lab'`(Lab 工况)、`'SteadyState'`(稳态热)、`'Transient'`(瞬态热)。
+**不是** `"Thermal"`。
+
+**标准流程**(电磁 → 热,损耗作为热源):
+```python
+mc.do_magnetic_calculation()   # 先算电磁,得到损耗(热源)
+mc.do_steady_state_analysis()  # 稳态热求解,MARS 实测约 6 秒
+mc.export_results("SteadyState", r"output\raw\thermal.csv")  # 导出热结果
+```
+
+**MARS 稳态热实测(2026-09-04,TEST-060)**:
+- 电磁 127.8s + 稳态热 6.0s,总约 2.7 分钟
+- 绕组平均 67.95°C、绕组热点 74.59°C、磁钢 active 118.15°C、后轴承 88.47°C、前轴承 49.19°C
+- 稳态热导出 section:温度 / 损耗 / 热传导系数 / 热阻 / 热容 / 端部 / 绕组 / 机壳水道 / Node Temperatures
+
+**热指标字段名(SteadyState 导出,已登记到 metrics.py alias)**:
+- 绕组温度 → `T [Winding (A) Average]` / `T[绕组平均]`
+- 绕组热点 → `T[绕组最高]` / `T [EWdg (Outer) Maximum]`
+- 磁钢温度 → `T [Magnet Active]` / `T [Magnet Average]`
+- 定子温度 → `T[定子轭]` / `T[定子外表面]`
+- 后轴承 → `T[后轴承]`(bearing_temp_c 取后轴承,轴向磁通电机热风险侧;前轴承 `T[前轴承]` 更冷)
+- 温升 → `dT [Winding (Maximum) - Ambient]`
+- 热阻 → `Rt [Winding (Maximum) - Ambient]`
+
+**⚠ MARS 模型热边界条件异常(已实测验证修正)**:
+`[Miscellaneous]` section 中 `T_Ambient=125` / `Ambient_Temperature=125`(环境温度 125°C,
+异常;辐射环境温度 `T_Ambient_Radiation=40` 合理)。修正方式:`set_variable("Ambient_Temperature", 25)`
+(变量名 `Ambient_Temperature` 实测可写、可回读校验)。实测对比(TEST-060 续):
+
+| 指标 | ambient=125(异常) | ambient=25(修正后) |
+|---|---|---|
+| 绕组平均 | 67.95°C | 52.59°C |
+| 绕组热点 | 74.59°C | 53.91°C |
+| 磁钢 active | 118.15°C | 67.61°C |
+| 后轴承 | 88.47°C | 38.47°C |
+| 温升 temp_rise_c | -56.88(负) | **+27.57(转正)** |
+| 热阻 thermal_resistance_k_w | -11.07(负) | **+5.657(转正)** |
+
+环境温度修正后温升/热阻均转正、物理合理。**建议默认环境温度 25°C(常温)或按实际工况
+设 40°C**。`scripts/run_thermal.py --ambient 25` 在 Motor-CAD 内存中临时覆盖(不改原始 .mot)。
+
+**磁热耦合 vs 分离式(2026-09-04 实测对比,TEST-060 续)**:
+
+| 项 | 分离式(先电磁后热) | 磁热耦合 `do_magnetic_thermal_calculation` |
+|---|---|---|
+| 耗时 | 127.8s + 6.0s ≈ 134s | 474.2s(含温度迭代) |
+| 磁钢 active | 118.15°C | 110.68°C(迭代收敛,更低) |
+| 绕组热点 | 74.59°C | 76.43°C |
+| 平均转矩 | 0.566 Nm | 0.515 Nm(温度反馈致剩磁下降) |
+| 转矩脉动 | 5.45% | 2.78% |
+| 总损耗 | 49.92 W | 42.57 W |
+
+**结论**:磁热耦合更准确(磁钢剩磁随温度迭代反馈,磁钢温度低 ~7.5°C),但耗时是
+分离式的 **~3.5 倍**。用户要"电磁+热一起做省时间"应选**分离式**(先
+`do_magnetic_calculation` 拿损耗 → 再 `do_steady_state_analysis`,损耗自动传递,
+已由 `enable_thermal` 实现);磁热耦合仅用于对温度敏感场景的最终复算。
+
 ---
 
 ## 3. .mot 参数语义(AFM 模板,易错!)
@@ -123,26 +198,53 @@ F ∝ Br²,磁钢剩磁 Br 随温度变化。实测 100°C vs 20°C 的轴向
 - 改 `RMSCurrent` 生效
 - 改 `PeakCurrent` **无效且不报错**(首跑踩坑)
 
+### 3.3 MARS 几何变量名(2026-09-03 pymotorcad 实测,勿再用错)
+
+**背景**:fixed_params_template 的几何变量曾用径向电机命名(`Outer_Rotor_Diameter` 等),导致 `set_variable` 报 `Could not find variable`、仿真点全部 FAILED。经解析 `.mot` + pymotorcad `get_variable`/`set_variable` 实测,MARS(PCB 无铁心轴向磁通,SSSR)的真实几何变量名如下。
+
+| 概念 | 模板错误名(勿用) | MARS 正确名 | MARS 值 | 实测 |
+|---|---|---|---|---|
+| 转子外径 | `Outer_Rotor_Diameter` | `RotorOuterDiameter` | 130 | get/set 均 OK |
+| 定子外径 | `Stator_Outer_Diameter` | `Stator_Lam_Dia` | 76 | get/set 均 OK |
+| 定子内径 | `Stator_Inner_Diameter` | `Stator_Bore` | 50 | get/set 均 OK |
+| 转子背铁厚 | `Rotor_Back_Iron_Thickness` | `Back_Iron_Thickness` | 5 | get/set 均 OK |
+| 极弧(电角度) | —(原名即对) | `Magnet_Arc_[ED]` | 121 | get OK |
+
+**MARS 无对应变量(motorcad_var 应设 null,不写入)**:
+- `Inner_Rotor_Diameter`(转子内径):`RotorBore` / `Rotor_Inner_Diameter` / `RotorInnerDiameter` / `InnerRotorDiameter` 全部 MISS —— PCB 单转子无独立内径变量。
+- `Stator_Yoke_Thickness`(定子轭厚):`Stator_Yoke_Thickness` / `StatorYokeThickness` / `Yoke_Thickness` 全部 MISS —— PCB 无铁心结构,无轭。
+
+**已实测确认的错误名(get_variable 全 MISS)**:`Outer_Rotor_Diameter` / `Inner_Rotor_Diameter` / `Stator_Outer_Diameter` / `Stator_Inner_Diameter` / `Number_of_Slots` / `Number_of_Poles` / `Turns_per_Coil` / `Max_Speed` / `Winding_Connection` / `Current_Density` / `Magnet_Remanence` / `Insulation_Class`。注意这些是 fixed_params_template 的**显示名**;其中多数模板已通过 `motorcad_var` 映射到真实名(`Slot_Number`/`Pole_Number`/`ConductorsPerSlot`/`WindageGraph_MaxSpeed`/`WindingConnection`/`Magnet_Br_at_RefTemp`,均实测 OK),仅几何 4 个映射错了。
+
+**默认值同步**:模板几何默认值已从径向模板值(200/198/122/120/10/8)更正为 MARS 实测值(130/76/50/—/—/5)。
+
+**端到端补充修正(2026-09-03 TEST-037,set 全通过 + 真实求解成功)**:
+- `Current_Advance_Angle`(电流超前角)→ 正确名 **`PhaseAdvance`**(实测 get/set 均 OK);原 `Current_Advance_Angle` / `CurrentAdvanceAngle` / `AdvanceAngle` 等候选全 MISS。
+- `Steel_Grade`(硅钢片牌号)`motorcad_var` 由 `Material_Stator_Lam_Yoke` 改 **null**:PCB 无铁心无轭,`Material_Stator_Lam_Yoke` 不存在;`Material_Stator_Lam_Outer` 等虽存在但值为空(无铁心未设材料),不写入。
+- `Magnet_Material` 默认值由 `NdFeB_N42SH` 改 **`N42UH`**:`NdFeB_N42SH` 在 Motor-CAD 材料库(solids database)不存在导致求解失败;MARS `.mot` 实测 `Material_Magnet=N42UH`。注意 Motor-CAD 材料命名是 `N42UH` 这类牌号,不带 `NdFeB` 前缀。
+- 验证结果:37 固定参数 32 可写全部 set 成功(0 failed),`do_magnetic_calculation` 求解成功(~2 分钟),导出结果有效(转矩脉动 2.815%、AC 损耗 0.47W、功率因数 0.995)。
+
 ---
 
 ## 4. 结果指标提取
 
-### 4.1 核心指标(Phase 1 必选)
+### 4.1 指标定义(单一事实源,本节不复制清单
 
-| 指标 key | 显示名称 | Motor-CAD 导出字段名 | 单位 |
-|---|---|---|---|
-| `tavg_nm` | 平均转矩 | `Average torque (virtual work)` | Nm |
-| `ripple_pct` | 转矩脉动 | `Torque Ripple (VW) [%]` | % |
-| `ripple_nm` | 转矩脉动绝对值 | `Torque Ripple (VW)` | Nm |
-| `efficiency_pct` | 系统效率 | `System Efficiency` | % |
-| `total_losses_w` | 总损耗 | `Total Losses (on load)` | W |
-| `copper_loss_w` | 铜耗 | `Armature DC Copper Loss (on load)` | W |
-| `iron_loss_w` | 定子铁耗 | `Stator iron Loss [total] (on load)` | W |
-| `magnet_loss_w` | 磁钢损耗 | `Magnet Loss (on load)` | W |
-| `back_emf_v` | 反电动势 | `Back EMF Line-Line Voltage (rms)` | V |
-| `input_power_w` | 输入功率 | `Input Power` | W |
-| `output_power_w` | 输出功率 | `Output Power` | W |
-| `shaft_speed_rpm` | 轴转速 | `Shaft Speed` | rpm |
+全平台指标清单的唯一权威定义在 `src/afmcore/metrics.py`:35 项(电磁 25 + 热网络 6 + 结构 4),每项含 key/label/unit/direction/required/aliases(中英文别名)。**本知识库不复制指标清单,防止多处漂移**(历史教训:Phase 1 曾在多处复制 11/12 项清单,扩到 35 项后全部过期)。
+
+查询方式:
+- 代码:`from afmcore.metrics import METRIC_DEFINITIONS`
+- API:`GET /api/analytics/metrics`
+- 扩指标:只改 `src/afmcore/metrics.py`,三个消费端(solver_core / robust_motorcad / web analytics)自动生效
+
+易错字段名示例(完整别名表见 metrics.py):
+
+| 指标 | Motor-CAD 导出字段名 |
+|---|---|
+| 平均转矩 | `Average torque (virtual work)` |
+| 转矩脉动 | `Torque Ripple (VW) [%]` |
+| 系统效率 | `System Efficiency` |
+| 反电动势 | `Back EMF Line-Line Voltage (rms)` |
 
 ### 4.2 导出文件格式
 

+ 545 - 0
docs/P1-P5交付总结与上手指南.md

@@ -0,0 +1,545 @@
+# PCB 轴向磁通电机自动化仿真系统 — P1-P5 交付总结与上手指南
+
+> **文档版本**:V1.0(2026-08-30,P5 完成时点)
+> **适用读者**:新加入项目的开发者、需要快速定位模块的维护者、接手后续 Phase 的工程师
+> **前置阅读**:`AGENTS.md`(工程纪律)→ `docs/KNOWLEDGE_BASE.md`(环境事实与踩坑记录)→ 本文档
+
+---
+
+## 1. 项目概览
+
+### 1.1 一句话定位
+
+双系统解耦的 PCB 轴向磁通电机(AFPM)自动化仿真平台:
+- **系统一(Web 端)**:FastAPI + Vue3,负责方案生成、AI 优化、经验库、可视化、报告
+- **系统二(本地 EXE)**:PyInstaller 打包的本地执行器,驱动 Motor-CAD 真实仿真
+- **共享核心层** `src/afmcore/`:metrics / topology / adapters / strategies / l0 / plan_schema,单一事实源,双系统共用
+
+### 1.2 当前状态(2026-08-30)
+
+| 维度 | 状态 |
+|---|---|
+| Phase 1(最小闭环) | ✅ 全部完成 |
+| Phase 2(Web 端方案系统) | ✅ 全部完成 |
+| Phase 3(AI 驱动智能仿真闭环) | ✅ 全部完成 |
+| Phase 4(Web AI 集成 + 双系统闭环 + 批量调度 + 部署) | ✅ 全部完成 |
+| Phase 5(平台化增强,M1-M6) | ✅ 全部完成 |
+| Git HEAD | `af2275f` |
+| 测试脚本 | 23 个 `scripts/test_*.py`,全量回归 22/22 PASS(test_api_client 需真实链路) |
+| 指标定义 | 35 项(电磁 25 + 热 6 + 结构 4) |
+| 仿真策略 | 5 种(full_factorial / lhs / adaptive / morris / surrogate_guided) |
+| 工具适配器 | 3 种(motorcad 真实 / maxwell mock / jmag mock) |
+| 前端构建 | vue-tsc 0 错误(P5-M1 清零) |
+| EXE 打包 | `dist/PCB-AFM-Executor.exe`(P4-M3,PyInstaller onefile) |
+
+---
+
+## 2. P1-P5 里程碑全盘核对表
+
+### 2.1 Phase 1 — 最小闭环
+
+| 里程碑 | 计划内容 | 实际交付 | 状态 | Commit |
+|---|---|---|---|---|
+| P1-M1 | 环境验证 + 单工况仿真脚本 | Motor-CAD 前台可见、参数回读校验、连接/计算/导出/解析跑通 | ✅ | `75347b6` |
+| P1-M2 | 参数扫描引擎 | 每点基线重载、逐点落盘、中英文字段别名;气隙扫描 3 点物理趋势符合预期 | ✅ | `a43d971` |
+| P1-M3 | 方案 JSON 接口 + 本地 PySide6 GUI | 方案 Schema、GUI 加载/监控、方案回写 | ✅ | `cbd9402` |
+| P1-M4 | 经验库雏形 + 反馈闭环 | SQLite 经验库、相似检索、反馈闭环 | ✅ | `f20bef9` |
+
+**验证记录**:TEST-001(连接探测,发现 export_results API 兼容问题)→ TEST-002(修复后全流程,磁场计算 138.1s/点)→ TEST-003(指标解析 7→14 项)。
+
+### 2.2 Phase 2 — Web 端方案系统
+
+| 里程碑 | 计划内容 | 实际交付 | 状态 | Commit |
+|---|---|---|---|---|
+| P2-M1 | Web 基础框架 | FastAPI + Vue3 + SQLite + CRUD API + 双系统 API 客户端 | ✅ | `1ea7653` |
+| P2-M2 | 边界条件输入 + 方案编辑器 | 规则引擎(参数注册表/范围推荐/方案生成) | ✅ | `a95c0db` |
+| P2-M3 | 经验库 Web 端 + 分析仪表盘 | ECharts 趋势/Pareto/敏感性 | ✅ | `9885437` |
+| P2-M4 | 双系统 API 联调 + 知识库管理 | 经验库 CRUD/导入、系统二客户端增强 | ✅ | `25ee19f` |
+| P2-M5 | Phase 2 验收 | 自动化测试 + 前端人工验收 + 文档 | ✅ | `0df69f2` |
+| P2-补丁 | 前端全面中文化 + Dashboard 修复 | — | ✅ | `2660df7` |
+
+### 2.3 Phase 3 — AI 驱动智能仿真闭环
+
+> 基于第三方专家评审,仿真策略从"固定批量 DoE"升级为"多保真度 + 可行性优先 + 批量自适应闭环",引入 Kimi k3。
+
+| 里程碑 | 计划内容 | 实际交付 | 状态 |
+|---|---|---|---|
+| P3-M1 | AI 服务层基础架构 | Kimi 客户端 + JSON Schema V2 + 多保真度框架(L0→L4) | ✅ |
+| P3-M2 | L0 解析预筛选 + 可行性优先搜索 | 14+ 约束检查 + LHS 初始采样 + 主动学习批量选点 + 局部信任域精修 | ✅ |
+| P3-M3 | AI 方案生成器 | 自然语言 → 结构化仿真方案 | ✅ |
+| P3-M4 | AI 结果分析师 + 多保真度校准 | 置信等级 A~D + 六类收敛判据 | ✅ |
+| P3-M5 | 经验库 AI 增强 + 批量自适应闭环 | 经验提取 + 闭环验收 | ✅ |
+| P3-M6 | Web 端执行桥 + 路由整合 | AdaptiveLoop 补齐 submit_batch_to_executor() | ✅ `768a883` |
+
+**P3 平台化(afmcore 第一批~第三批)**:
+- 第一批:`metrics.py`(25 项指标+归一化解析器)+ `adapters/`(ABC + 注册表 + MotorCADAdapter)→ TEST-004
+- 第二批:`topology.py`(SSSR 8 组 37 项参数)+ task_executor 切换 get_adapter → TEST-005
+- 第三批:`strategies/`(full_factorial/lhs/adaptive)+ Task 模型扩展 + 调度契约统一 → TEST-006~011
+
+**P3 遗留处理(P4 期初)**:并发原子认领(`fb3a505`)、断点恢复(`4dcc627`)、ASCII 纪律(`3caecd1`)、环境依赖测试(TEST-013)。
+
+### 2.4 Phase 4 — Web AI 集成 + 双系统闭环 + 批量调度 + 部署
+
+| 里程碑 | 计划内容 | 实际交付 | 状态 |
+|---|---|---|---|
+| P4-M1 | 前端 AI 功能集成 | 6 个 AI 页面 + API 封装 + 通用组件 | ✅ |
+| P4-M2 | 双系统任务下发与回传 | Web 创建任务 → 执行器轮询领取 → 执行 → 上报 → 回传 → 存储展示 | ✅ |
+| P4-M3 | 批量调度 + 实时监控 + 增强版 MotorCAD 核心 | 优先级队列(1-10) + 最大并行(2) + 依赖 + 断点续跑;16 项鲁棒性措施 | ✅ |
+| P4-M4 | 高级可视化 + 自动报告 | Pareto/收敛/雷达/热力图 + Word/JSON 报告 | ✅ |
+| P4-M5 | 部署打包 | Docker + docker-compose + nginx + Windows 部署脚本 | ✅ |
+
+**P4 平台化批次(p4-m1..m5)**:
+
+| 批次 | 内容 | 验证 | Commit |
+|---|---|---|---|
+| P4-M1 | 方案 Schema 单一权威(src/plan_schema.py) | test_p4_schema.py 7 组全过 | `223888a` |
+| P4-M2 | 设计文档 V1.1→V2.0(附录 B 实现现状对照) | README 引用同步 | `9d09b3c` |
+| P4-M3 | EXE 打包(PyInstaller onefile,12.6MB) | dist/PCB-AFM-Executor.exe 冒烟通过 | `5dd3559` |
+| P4-M4 | 前端 adaptive 收敛曲线(points_history + echarts) | test_p4_m4_convergence.py 5 组;vue-tsc 0 错误 | `10a34a0` |
+| P4-M5 | L0 上提共享核心(src/afmcore/l0/prescreening.py) | test_p4_m5_l0.py 8 组 | `18ea49e` |
+
+**真实 Motor-CAD 实测**(MARS-12S10P,5000rpm):效率 86.06%、总损耗 41.945W、磁场计算 138.1s/点(TEST-002/003/010)。
+
+**第三方代码评审修复**(V1.4):20+ P0 项,详见 `docs/CODE_REVIEW_RESPONSE.md`。
+
+### 2.5 Phase 5 — 平台化增强(M1-M6,本轮全部完成)
+
+| 里程碑 | Backlog | 计划内容 | 实际交付 | 状态 | Commit |
+|---|---|---|---|---|---|
+| P5-M1 | B1 | 前端全量 build 类型错误清零 | vue-tsc 0 错误,修复 12 处类型问题 | ✅ | `b394c39` |
+| P5-M2 | B2 | 真实 EXE E2E 修复 | executor_config.json 可配置 + airgap_mm→Airgap 映射 + 排除 point_id 元数据 | ✅ | `3638699` + `974750e` |
+| P5-M3 | B3 | Adaptive 三视图可视化 | batch status + L0 summary + auto-polling | ✅ | `b137690` |
+| P5-M4 | B4 | 策略层高级管线 | Morris 灵敏度筛选 + IDW 代理 + UCB + 预算自适应批次 | ✅ | `94647f1` |
+| P5-M5 | B6 | 多工具适配器 | MaxwellAdapter/JMAGAdapter mock + 执行器 tool 动态 import | ✅ | `05df4be` |
+| P5-M6 | B5 | 多物理场 L2 接入 | metrics 扩 10 项(热6+结构4)+ robust 热求解开关 + 报告按域分组 | ✅ | `af2275f` |
+
+**Backlog 覆盖核对**:B1✅ B2✅ B3✅ B4✅ B5✅ B6✅ — 全部 6 项 backlog 清零。
+
+---
+
+## 3. 目录结构速查
+
+```
+PCB轴向磁通电机自动化仿真系统/
+├── AGENTS.md                          # 工程纪律(必读)
+├── README.md                          # 项目说明(V2.0)
+├── executor_config.json               # 本地执行器配置(tool/model_path/output_dir)
+├── deploy.ps1                         # Windows 部署脚本
+├── PCB轴向磁通电机自动化仿真系统设计方案介绍.md  # 完整设计方案 V2.0(含附录 B 实现对照)
+│
+├── docs/
+│   ├── KNOWLEDGE_BASE.md              # 核心知识库(环境事实、参数语义、踩坑记录)
+│   ├── HANDOFF.md                     # 接续指南(进度/阻塞/待办,新会话必读)
+│   ├── TEST_RECORDS.md                # 测试记录(TEST-001~024)
+│   ├── CONVERSATION_LOG.md            # 会话与决策记录
+│   ├── PLATFORM_DESIGN_V2.md          # 平台设计 V2
+│   ├── CODE_REVIEW_RESPONSE.md        # 第三方代码评审修复记录
+│   ├── P1-P5交付总结与上手指南.md      # 本文档
+│   └── archive/                       # 已完结历史计划文档(P3/P4实施计划等)
+│
+├── src/
+│   ├── afmcore/                       # 共享核心层(单一事实源,双系统共用)
+│   │   ├── metrics.py                 # 35 项指标定义 + 归一化解析器
+│   │   ├── topology.py                # 拓扑注册表(SSSR 8 组 37 项参数)
+│   │   ├── adapters/                  # 仿真工具适配器
+│   │   │   ├── __init__.py            # SimulationAdapter ABC + 注册表
+│   │   │   ├── motorcad.py            # Motor-CAD 适配器(真实,wrap RobustMotorCADSolver)
+│   │   │   ├── maxwell.py             # Maxwell 适配器(mock,真实接入待环境)
+│   │   │   └── jmag.py                # JMAG 适配器(mock,真实接入待环境)
+│   │   ├── strategies/                # 仿真策略
+│   │   │   ├── __init__.py            # 策略注册表 + get_strategy
+│   │   │   ├── full_factorial.py      # 全因子
+│   │   │   ├── lhs.py                 # 拉丁超立方
+│   │   │   ├── adaptive.py            # 自适应(可行性优先 + 主动学习)
+│   │   │   ├── morris.py              # Morris 灵敏度筛选
+│   │   │   └── surrogate_guided.py    # IDW 代理 + UCB + 预算自适应
+│   │   └── l0/
+│   │       ├── __init__.py
+│   │       └── prescreening.py        # L0 解析预筛选(14+ 约束,纯 stdlib)
+│   ├── api_client.py                  # Web API 客户端(系统二调用系统一)
+│   ├── experience_db.py               # 经验库(SQLite)
+│   ├── plan_schema.py                 # 方案 Schema(parse/validate 单一权威入口)
+│   ├── scan_engine.py                 # 扫描引擎(legacy)
+│   └── solver_core.py                 # 求解核心(legacy)
+│
+├── scripts/
+│   ├── robust_motorcad.py             # 鲁棒 Motor-CAD 求解器(16 项鲁棒性措施)
+│   ├── task_executor.py               # 任务执行器(adapter 驱动,多实例,point_id 回填)
+│   ├── executor_config.py             # 执行器配置加载
+│   ├── run_gui.py                     # 启动本地 PySide6 GUI
+│   ├── run_scan.py                    # 运行参数扫描
+│   ├── run_single.py                  # 运行单工况
+│   ├── run_task_executor.py           # 启动单实例任务执行器
+│   ├── run_task_executor_parallel.py  # 启动多实例并行执行器
+│   ├── build_executable.ps1           # PyInstaller EXE 打包脚本
+│   ├── scan_airgap.json               # 气隙扫描示例方案
+│   └── test_*.py                      # 23 个单元/集成测试脚本
+│
+├── web/
+│   ├── backend/                        # FastAPI 后端
+│   │   └── app/
+│   │       ├── main.py                # FastAPI 入口
+│   │       ├── routers/               # API 路由(plans/tasks/loops/experience/reports 等)
+│   │       ├── services/              # 业务服务(task_contract/report_generator/adaptive 等)
+│   │       └── models.py              # SQLAlchemy 模型
+│   ├── frontend/                       # Vue3 前端
+│   │   └── src/
+│   │       ├── views/                 # 页面(6 个 AI 页面 + 方案/任务/监控/经验库)
+│   │       ├── components/            # 通用组件
+│   │       └── api/                   # API 封装
+│   └── output/                         # Web 端输出(报告等)
+│
+├── models/                             # Motor-CAD 基线模型(.mot,只读)
+├── output/                             # 运行输出(不入库)
+└── dist/                               # EXE 打包输出(不入库)
+```
+
+---
+
+## 4. 核心层 afmcore 模块详解
+
+### 4.1 metrics.py — 指标单一事实源
+
+**职责**:定义全平台所有仿真输出指标,提供归一化解析器。
+
+**关键设计**:
+- `METRIC_DEFINITIONS`:35 项指标,每项含 `key/label/unit/direction/required/aliases/domain`
+- `aliases`:英文 + 中文(`\uXXXX`),覆盖 Motor-CAD 导出的各种字段名变体
+- `normalize_name()`:全角→半角、去所有空白(含全角空格 U+3000)、小写 — 修复历史 tavg/ripple 解析 bug
+- `parse_export()`:分号分隔 CSV 解析器,多编码 fallback(utf-8-sig/utf-8/gbk/cp1252/latin-1)
+- `pick_metric()`:E-Magnetics 段优先精确匹配 → 全段精确 → 前缀模糊(带 % 防护)
+- `extract_all_metrics()`:遍历 METRIC_DEFINITIONS,**新增指标自动生效,无需改调用方**
+
+**扩项方式**:在 `METRIC_DEFINITIONS` 列表中加一项(含 aliases),所有消费端(robust_motorcad / report_generator / Web 后端)自动生效。
+
+### 4.2 adapters/ — 仿真工具适配器
+
+**职责**:统一不同 FEA 工具(Motor-CAD / Maxwell / JMAG)的接口,执行器只依赖 `SimulationAdapter` ABC。
+
+**接口契约**(`SimulationAdapter` 抽象基类):
+- `connect()` / `disconnect()`:生命周期
+- `load_model(model_path)`:加载基线模型(只读)
+- `set_parameter(name, value)`:写参数 + 回读校验(不一致抛 RuntimeError)
+- `run_simulation(mode)`:运行求解
+- `extract_metrics(output_dir, tag)`:导出原始结果 + 解析指标
+- `run_point(...)`:高层便捷方法(默认编排 connect→load→set→run→extract→disconnect)
+
+**注册表**:`register_adapter(tool_name, cls)` / `get_adapter(tool_name, **kwargs)` / `registered_tools()`
+
+**现有适配器**:
+| tool | 实现 | 状态 | capability_domains |
+|---|---|---|---|
+| motorcad | MotorCADAdapter,wrap RobustMotorCADSolver | ✅ 真实可用 | electromagnetic |
+| maxwell | MaxwellAdapter,内存参数+确定性合成指标 | ⚠️ mock(真实接入需 Ansys Maxwell + PyAEDT) | electromagnetic, thermal |
+| jmag | JMAGAdapter,同上 | ⚠️ mock(真实接入需 JMAG Designer + jmagpy) | electromagnetic |
+
+**执行器集成**:`MotorCADTaskExecutor._ensure_adapter()` 根据 `self.tool` 动态 import 对应适配器模块并注册。
+
+### 4.3 strategies/ — 仿真策略
+
+**职责**:参数空间探索策略,统一 `SimulationStrategy` 接口(select_next / report / next_batch_ready / is_converged / state)。
+
+**现有策略**:
+| kind | 类 | 适用场景 | state() 关键输出 |
+|---|---|---|---|
+| full_factorial | FullFactorialStrategy | 小空间全遍历 | progress |
+| lhs | LHSStrategy | 大空间初始采样 | progress |
+| adaptive | AdaptiveStrategy | 可行性优先 + 主动学习 + 信任域精修 | best_point / convergence |
+| morris | MorrisStrategy | 灵敏度筛选(OAT 初等效应) | sensitivity_ranking / key_parameters |
+| surrogate_guided | SurrogateGuidedStrategy | IDW 代理 + UCB + 预算自适应 | phase / surrogate 诊断 |
+
+**注册表**:`get_strategy(kind, **kwargs)` / `registered_strategies()`
+
+### 4.4 l0/prescreening.py — L0 解析预筛选
+
+**职责**:14+ 约束检查(几何/电气/热/制造),在昂贵 FEA 之前快速排除不可行方案。
+
+**关键特性**:纯 stdlib 实现(无 numpy/scipy),可在 EXE 中无依赖运行。
+
+### 4.5 topology.py — 拓扑注册表
+
+**职责**:SSSR(单定子单转子)8 组 37 项参数体系,DRSS/SDSR 预留。参数语义、范围、默认值的单一事实源。
+
+### 4.6 plan_schema.py — 方案契约 V2
+
+**职责**:仿真方案的 JSON Schema 定义 + parse/validate 入口。Web 端和本地执行器共用,确保方案格式一致。
+
+---
+
+## 5. 快速开始
+
+### 5.1 环境准备
+
+**必需**:
+- Python 3.10+(推荐 3.11)
+- Windows 10/11(Motor-CAD 仅 Windows)
+- Git
+
+**Motor-CAD 仿真(可选,跑真实求解需要)**:
+- Ansys Motor-CAD v261(安装路径 `D:\Program Files\ANSYS Inc\v261\motorcad\MotorCAD.exe`)
+- 环境变量:`MOTORCAD_ACTIVEX`、`ANSYSLMD_LICENSE_FILE`(非登录 shell 可能不继承,脚本内有回退)
+- pymotorcad(`pip install ansys-motorcad-core`)
+
+**Web 端(可选)**:
+- Node.js 18+(前端构建)
+- FastAPI + uvicorn(后端,`pip install -r web/backend/requirements.txt`)
+
+**安装依赖**:
+```powershell
+cd C:\Users\admin\Desktop\PCB轴向磁通电机自动化仿真系统
+pip install -r requirements.txt  # 如果存在
+# 核心依赖:ansys-motorcad-core, pyside6(GUI), fastapi, uvicorn, sqlalchemy, python-docx
+```
+
+### 5.2 跑测试(验证环境)
+
+```powershell
+# 全量回归(排除需要真实链路的 test_api_client.py)
+Get-ChildItem scripts\test_*.py | ForEach-Object {
+    if ($_.Name -ne "test_api_client.py") {
+        python $_.FullName
+    }
+}
+# 预期:22/22 PASS
+```
+
+**单个测试**:
+```powershell
+python scripts\test_metrics_extension.py   # 指标扩展
+python scripts\test_adapters.py             # 适配器
+python scripts\test_strategy_morris.py      # Morris 策略
+```
+
+### 5.3 启动本地 GUI(系统二,方案编辑 + 监控)
+
+```powershell
+python scripts\run_gui.py
+```
+
+GUI 功能:加载方案 JSON、编辑参数、启动扫描、实时监控进度、查看结果。
+
+### 5.4 启动任务执行器(系统二,从 Web 端领取任务)
+
+```powershell
+# 单实例
+python scripts\run_task_executor.py
+
+# 多实例并行(2 个)
+python scripts\run_task_executor_parallel.py --instances 2
+```
+
+执行器通过 Web API 轮询领取任务,调用适配器(默认 motorcad)执行仿真,结果上报回 Web 端。
+
+**配置**:编辑 `executor_config.json`:
+```json
+{
+  "web_base_url": "http://127.0.0.1:8000",
+  "model_path": "models/base.mot",
+  "tool": "motorcad",
+  "output_dir": "output",
+  "enable_mock": false,
+  "poll_interval_seconds": 5
+}
+```
+
+`tool` 可选:`motorcad`(真实)/ `maxwell`(mock)/ `jmag`(mock)。
+
+### 5.5 启动 EXE(打包后的执行器)
+
+```powershell
+dist\PCB-AFM-Executor.exe --config executor_config.json
+dist\PCB-AFM-Executor.exe --version
+dist\PCB-AFM-Executor.exe --self-test
+```
+
+### 5.6 启动 Web 端(系统一)
+
+```powershell
+# 后端
+cd web\backend
+pip install -r requirements.txt
+uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
+
+# 前端(另开终端)
+cd web\frontend
+npm install
+npm run dev  # 开发模式,http://localhost:5173
+npm run build  # 生产构建(vue-tsc 类型检查,P5-M1 后 0 错误)
+```
+
+### 5.7 运行单工况仿真(命令行)
+
+```powershell
+python scripts\run_single.py --model models\base.mot --params airgap_mm=1.0,magnet_thickness_mm=5.0
+```
+
+### 5.8 运行参数扫描(命令行)
+
+```powershell
+python scripts\run_scan.py --config scripts\scan_airgap.json
+```
+
+---
+
+## 6. 关键工程约束(硬性纪律,违者返工)
+
+> 完整规则见 `AGENTS.md`,以下为最常踩的 7 条。
+
+### 6.1 源码纯 ASCII
+所有 `.py` 和 `.ps1` 文件**只含 ASCII 字符**。中文说明写 Markdown,中文字段名用 `\uXXXX` 转义。
+```powershell
+# 检查命令
+rg -n "[^\x00-\x7F]" --glob '*.py' --glob '*.ps1' .
+```
+
+### 6.2 运行前 Git 必须干净
+实际启动 Motor-CAD 求解前必须满足:Git 仓库存在且 HEAD 有效、所有已跟踪文件无未提交修改。GUI 内置 Git preflight,不满足时拒绝启动扫描。
+
+### 6.3 Motor-CAD 实例管理
+- 使用 `open_new_instance=True` 创建独立实例(**不要**连接已有实例)
+- 启动后必须 `set_visible(True)`(/SCRIPTING 模式默认隐藏)
+- 每个扫描点开始前 `load_from_file(基线模型)`,结束后也重载基线
+
+### 6.4 参数必须回读校验
+不能只调用 `set_variable`。必须:
+```python
+mc.set_variable(variable, value)
+applied = float(mc.get_variable(variable))
+if not math.isclose(applied, value, rel_tol=1e-8, abs_tol=1e-7):
+    raise RuntimeError(...)
+```
+回读不一致时将该点标记为 FAILED,保存错误并继续下一点。
+
+### 6.5 结果逐点落盘
+每个点完成后立即写 CSV 并 flush,**不能**等整批完成后一次性保存。失败点记录错误并继续。运行目录结构:`output/<timestamp>_<scan_name>/` 含 manifest.json + scan_results.csv + program_log.log + raw/。
+
+### 6.6 原始模型只读
+原始 `.mot` 文件不修改。所有操作在 Motor-CAD 内存中进行,或另存时间戳副本。`models/` 目录下的模型文件视为只读。
+
+### 6.7 生成物不入库
+`output/`、`runs/`、`build/`、`dist/`、`*.log`、`*.spec`、`__pycache__/` 不入库。关键数值转录进入库的文档(TEST_RECORDS.md / RESULTS.md / 报告)。
+
+---
+
+## 7. 测试体系
+
+### 7.1 测试脚本清单(23 个)
+
+| 测试文件 | 覆盖范围 | 类型 |
+|---|---|---|
+| test_robust_solver.py | RobustMotorCADSolver 核心(回读校验/基线重载/逐点落盘) | 单元 |
+| test_platform_registry.py | afmcore 注册表(adapters/strategies) | 单元 |
+| test_p3_unit_edge.py | P3 边界/异常场景 | 单元 |
+| test_p3_m4_contract.py | P3-M4 任务契约 | 单元 |
+| test_p3_orchestrator.py | P3 编排器 | 单元 |
+| test_p3_adaptive_execution.py | P3 自适应执行 | 集成 |
+| test_p3_closed_loop.py | P3 闭环(mock) | 集成 |
+| test_p3_checkpoint.py | 断点恢复 | 单元 |
+| test_p3_concurrency.py | 并发原子认领 | 集成 |
+| test_p4_schema.py | 方案 Schema 校验 | 单元 |
+| test_p4_acceptance.py | P4 验收 | 集成 |
+| test_p4_m4_convergence.py | P4-M4 收敛曲线 | 单元 |
+| test_p4_m5_l0.py | P4-M5 L0 上提 | 单元 |
+| test_executor_config.py | 执行器配置 | 单元 |
+| test_executor_m3.py | 执行器 M3 功能 | 单元 |
+| test_executor_p5m2.py | P5-M2 EXE E2E 修复 | 单元 |
+| test_search_state_summary.py | 搜索状态摘要 | 单元 |
+| test_strategy_morris.py | Morris 策略(P5-M4) | 单元 |
+| test_strategy_surrogate.py | SurrogateGuided 策略(P5-M4) | 单元 |
+| test_adapters.py | 多工具适配器(P5-M5) | 单元 |
+| test_metrics_extension.py | 指标扩展 + 报告域分组(P5-M6) | 单元 |
+| test_api_client.py | Web API 真实链路(需启动后端) | 集成 |
+
+### 7.2 运行方式
+
+```powershell
+# 全量(排除 test_api_client,需真实后端)
+Get-ChildItem scripts\test_*.py | Where-Object { $_.Name -ne "test_api_client.py" } |
+    ForEach-Object { python $_.FullName }
+
+# 单个
+python scripts\test_<name>.py
+# exit 0 = PASS,非 0 = FAIL
+```
+
+### 7.3 测试记录
+
+所有真实 Motor-CAD 仿真、API 测试、集成测试记录在 `docs/TEST_RECORDS.md`(TEST-001~022),含测试日期、环境、目的、步骤、结果、关键数据、发现的问题、修复措施。
+
+---
+
+## 8. 已知限制与后续方向
+
+### 8.1 当前限制(如实标注)
+
+| 限制 | 说明 | 升级路径 |
+|---|---|---|
+| Maxwell/JMAG 为 mock | 当前环境无 Ansys Maxwell / JMAG 安装,适配器为 mock 实现(确定性合成指标) | 目标机安装对应软件 + Python API(PyAEDT / jmagpy),在同接口下替换为真实实现 |
+| Kriging 代理未实现 | 环境无 numpy/scipy,P5-M4 用 IDW(反距离加权)替代 Kriging | 引入 scipy 后替换为 Kriging,策略接口不变 |
+| 真实热求解未运行 | P5-M6 增加了 enable_thermal 开关和代码路径,但需要模型配置热网络 + 实际启动 Motor-CAD 验证 | 配置热网络后运行 enable_thermal=True 的扫描,验证热指标提取 |
+| 结构指标需外部 FEA | axial_force / stress / deformation 等结构指标需要 Motor-CAD 结构模块或第三方 FEA(如 Ansys Mechanical) | 接入结构求解器,或从 Maxwell 3D 结果中提取力后做后处理 |
+| NSGA-II 多目标优化未实现 | P5 规划中提及,未在 M1-M6 范围内 | 后续 Phase 实现多目标优化策略 |
+| 前端工具选择下拉框 | executor_config.json 已支持 tool 字段,但 Web 端任务创建页尚未加工具选择 UI | 前端加 tool 下拉框,传入 task 创建 API |
+| 原有 winding_temp_c 无 domain | 该指标默认归入 electromagnetic 域 | 可加 domain=thermal 字段,归入热域 |
+
+### 8.2 后续方向建议
+
+1. **Phase 6 — 真实多工具接入**:在目标机安装 Maxwell/JMAG,将 mock 适配器替换为真实实现
+2. **多物理场真实耦合**:电磁→热→结构的单向/双向耦合,L2 多保真度层落地
+3. **多目标优化**:NSGA-II / MOEA-D,支持转矩密度×效率×成本的 Pareto 前沿
+4. **Kriging 代理升级**:引入 scipy,替换 IDW,提升代理精度
+5. **经验库自动积累**:仿真结果自动提取关键参数→指标映射,增量更新经验库
+6. **Web 端工具选择 UI**:任务创建页加 tool 下拉框
+7. **热求解真实验证**:配置热网络后运行 enable_thermal 扫描
+
+---
+
+## 9. 提交历史速查(P1-P5 关键 commit)
+
+| Commit | 内容 | Phase |
+|---|---|---|
+| `75347b6` | P1-M1 环境验证 + 单工况仿真 | P1 |
+| `a43d971` | P1-M2 参数扫描引擎 | P1 |
+| `cbd9402` | P1-M3 方案 JSON + PySide6 GUI | P1 |
+| `f20bef9` | P1-M4 经验库雏形 | P1 |
+| `1ea7653` | P2-M1 Web 基础框架 | P2 |
+| `2cae9b6` | afmcore 共享核心层 + metrics 单一事实源 + adapter 抽象 | P3 |
+| `27ccde6` | 拓扑注册表 + adapter 驱动执行器 | P3 |
+| `2e9adfa` | 执行策略抽象层 | P3 |
+| `fa74834` | Adaptive 编排器 | P3 |
+| `768a883` | Web AdaptiveLoop → 本地执行器桥 | P3-M6 |
+| `fb3a505` | 并发原子认领(dispatch_task 条件 UPDATE) | P3 遗留 |
+| `4dcc627` | 断点恢复(search/loop export-import) | P3 遗留 |
+| `223888a` | P4-M1 方案 Schema 单一权威 | P4 |
+| `5dd3559` | P4-M3 EXE 打包(PyInstaller) | P4 |
+| `10a34a0` | P4-M4 前端 adaptive 收敛曲线 | P4 |
+| `18ea49e` | P4-M5 L0 上提共享核心 | P4 |
+| `e6563fc` | P1-P4 回顾 + P5 规划(交接文档) | P5 起点 |
+| `b394c39` | P5-M1 前端类型错误清零(vue-tsc 0 错误) | P5 |
+| `3638699` | P5-M2 EXE 可配置(executor_config.json) | P5 |
+| `974750e` | P5-M2 真实 EXE E2E 修复(airgap_mm→Airgap + 排除 point_id) | P5 |
+| `b137690` | P5-M3 Adaptive 三视图可视化 | P5 |
+| `94647f1` | P5-M4 Morris + IDW 代理 + 预算自适应 | P5 |
+| `05df4be` | P5-M5 Maxwell/JMAG mock 适配器 + tool 动态 import | P5 |
+| `af2275f` | P5-M6 多物理场 L2(热/结构指标 + 热求解开关 + 报告域分组) | P5 收官 |
+
+---
+
+## 10. 新人上手 Checklist
+
+按顺序完成以下步骤,即可独立维护和开发:
+
+- [ ] **读 AGENTS.md**:理解工程纪律(ASCII / Git preflight / 回读校验 / 逐点落盘)
+- [ ] **读 docs/KNOWLEDGE_BASE.md**:理解环境事实、参数语义、已踩的坑
+- [ ] **读本文档 §3-§4**:理解目录结构和 afmcore 核心层
+- [ ] **跑通测试**:`python scripts\test_robust_solver.py` → 全量回归 22/22
+- [ ] **跑通 mock 执行器**:`executor_config.json` 设 `enable_mock=true`,启动执行器,从 Web 端创建 mock 任务
+- [ ] **理解 metrics 扩项**:在 `METRIC_DEFINITIONS` 加一项,验证 `extract_all_metrics` 自动生效
+- [ ] **理解适配器模式**:读 `adapters/__init__.py` + `motorcad.py`,尝试用 `get_adapter("maxwell")` 跑 mock 链路
+- [ ] **理解策略模式**:读 `strategies/__init__.py`,用 `get_strategy("morris")` 跑灵敏度筛选
+- [ ] **读设计方案附录 B**:`PCB轴向磁通电机自动化仿真系统设计方案介绍.md` 附录 B,理解蓝图 vs 实现的对照
+- [ ] **(可选)跑真实 Motor-CAD**:确认环境变量 + license,用 `run_single.py` 跑单工况,对照 TEST-002 结果
+
+---
+
+> **文档维护**:每次 Phase / Milestone 完成后,更新 `README.md` + `docs/TEST_RECORDS.md`,并在本文档对应章节追加里程碑记录。本文档最后更新:2026-08-30(P5 完成时点,HEAD `af2275f`)。

+ 673 - 0
docs/PAPER_KNOWLEDGE_BASE.md

@@ -0,0 +1,673 @@
+# PCB 轴向磁通电机(无铁心 AFPM)论文知识库
+
+> **用途**:本工程(PCB 轴向磁通电机自动化仿真系统)软件工程师的论文知识参考。
+> **来源**:`书籍与论文/相关论文-Chulaee IEEE IAS Design Optimization High Efficiency Coreless PCB Stator AFPM/` 下的 38 篇论文及其中文翻译稿,已于 2026-08-29 系统精读并提炼。
+> **与 KNOWLEDGE_BASE.md 的关系**:本文档回答"**电机本身有什么规律、损耗怎么算、设计怎么优化、仿真时该设什么**";KNOWLEDGE_BASE.md 记录"**Motor-CAD 环境怎么跑、参数怎么探测、踩过什么坑**"。两者配合使用。
+
+---
+
+## 0. 读法指南
+
+- **你是软件工程师,不是电机设计专家**:优先看每节开头的"**工程要点**"框(可直接落进仿真脚本 / 参数表 / 校验逻辑),再看公式与出处。
+- **38 篇论文速查表**见第 10 节;按主题找论文,按论文回原文。
+- **与 Motor-CAD 仿真的衔接**:第 8 节汇总了可直接指导仿真建模、损耗设置、计算预算、参数校验的清单。
+- **数字可靠性**:本知识库中的数字均摘自论文(含样机实测/仿真),非猜测。个别论文存在笔误(如 Infinitum 双转子论文"110 HP(7.46kW)"实为 10 HP),已在文中注明。
+- **常用符号**:`tw` 迹宽、`th` 迹高(铜厚)、`δ` 趋肤深度、`f` 电频率、`σ` 电导率、`ρ` 电阻率、`τp` 极距、`λ` 内外径比。
+
+---
+
+## 1. 技术全景与拓扑
+
+### 1.1 一句话定位
+
+**PCB 定子无铁心轴向磁通永磁电机(C-AFPM / PCB-AFPM)** = 去掉定子铁芯 + 用印刷电路板铜迹线替代铜线绕组 + 永磁转子。适合需要**高功率/转矩密度、轴向紧凑、高效率、可批量制造**的应用(泵、风机、无人机、电动汽车、航空推进、机器人)。
+
+**为什么去掉铁芯**:
+- 消除定子铁损、齿槽转矩 → 更高效率、更低噪声振动
+- 消除/大幅削弱轴向磁拉力(无铁心时不平衡磁拉力几乎可忽略)
+- 无齿槽 → 零齿槽转矩、转矩平稳
+
+**代价**:
+- 有效气隙增大 → 气隙磁密下降,需更多永磁体(无铁心电机需更显著磁通源)
+- 绕组直接暴露于气隙磁场 → **涡流损耗**;宽磁气隙 → 并联导体**环流损耗**
+- 相电感极低(百 μH 级)→ 电流纹波大、弱磁能力弱,需配高开关频率驱动(WBG)
+
+> 出处:《无铁心轴向磁通永磁电机PCB定子中的环流与涡流损耗》《设计方面、绕组布置与印刷电路板电机的应用:一份全面综述》《轴向磁通永磁电机技术综述》
+
+### 1.2 拓扑分类与选型
+
+| 维度 | 选项 | 工程要点(选型建议) |
+|---|---|---|
+| 磁通方向 | 轴向磁通 AFPM / 径向磁通 RFPM | AFPM:轴向紧凑、转矩密度高;RFPM:工艺成熟。**RFPM 转子长径比 D/L > 12 时 AFPM 转矩更高** |
+| 定转子数 | 单定单转(SSSR)/ 双定单转(DSSR)/ 单定双转(SSDR, TORUS)/ 多盘 | SSDR(双转子夹 PCB 定子)最常见:轴向力平衡、转矩高。SSSR 有轴向力不平衡 |
+| 转子磁体 | 表贴 SPM / 内置 IPM / Halbach 阵列 | SPM 简单;IPM 保护磁体适合高速;**Halbach 无需背铁,转矩密度最高可 +30%**(见 1.4) |
+| 定子结构 | 有槽 / 无槽;有铁心 / 无铁心 | PCB 电机几乎全为**无槽无铁心**;有槽才有高转矩密度但齿槽转矩/铁损 |
+| 绕组布置 | 集中(同心/梯形/螺旋)/ 分布(波绕/径向/弧形/不等宽并联) | 分布绕组减小电阻、THD 更好;波绕在超薄 AFPM 有利(见 3.2) |
+| 相数 | 三相 / 两相 / 多相 / 多三相 | 两相可两片 PCB 错位 90° 电角度叠压;多三相可容错 |
+
+> 出处:《设计方面、绕组布置与印刷电路板电机的应用:一份全面综述》《轴向磁通永磁电机技术综述》《轴向磁通永磁同步电机PCB绕组拓扑比较》
+
+### 1.3 核心尺寸设计方程(可用于初步尺寸脚本)
+
+**AFPM 转矩(YASA/无铁心/单面均适用)**:
+
+```
+T = (π/2) · Bδ · Â · R_om³ · λ(1 − λ²)
+```
+
+- `Bδ`:磁负荷(气隙磁密基波),`Â`:电负荷(与半径有关),`R_om`:转子外径,`λ = R_im/R_om` 内外径比
+- **转矩 ∝ 外径三次方**;功率密集型设计 **λ ∈ [0.65, 0.75]**
+- AFPM vs RFPM 转矩比:`T_afpm/T_rfpm = D_om·λ(1−λ²)/(4L)`(D/L>12 时 AFPM 更优)
+
+**空载气隙磁密基波(表面贴装双转子,固定半径极坐标截面)**:
+
+```
+Bz(θ,z) = Bpk · cosh(πz/τp) · cosθ
+Bpk = (4Br/π) · sinh(π·hp1/τp)/sinh(π·g/(2τp)) · sin(π·wp/(2τp))
+```
+
+**Halbach 转子气隙磁密**:
+
+```
+Bz(θ,z) = 2·Bpk·exp(−αg/2)·cosθ·cosh(αz),   α = π/(w_pm+w_pt)
+Bpk = Br·[1−exp(−α·hp2)]·sin(π/nm)/(π/nm)
+```
+
+**PCB 高速电机线圈磁链(Rome 组)**:
+
+```
+λ = Nt · B̂ · R_ext²(1−k_r²)/p · kw     (k_r=内外径比,kw 绕组系数)
+```
+
+> 出处:《轴向磁通永磁电机技术综述》《采用表面贴装永磁体和Halbach阵列转子的无铁心轴向磁通电机的转矩和功率能力》《高速印刷电路板无铁芯轴向磁通永磁电机的设计》
+
+### 1.4 Halbach 阵列:转矩密度的放大器
+
+- **转矩密度最高 +30%**(同质量/体积下,相比表面贴装 SPM)
+- 一侧磁通显著中和 → **可移除转子背铁** → 减重、消除背铁涡流、更正弦的气隙磁密
+- 高速应用注意:磁体段间吸引力大、需机械强度校核
+- 高速小功率(如 3 万 rpm)用 Halbach + 埋入式磁体 + 非磁钢顶盖(Inconel 625)可让**磁链提高约 57%**
+
+> 出处:《采用表面贴装永磁体和Halbach阵列转子的无铁心轴向磁通电机的转矩和功率能力》《高速无铁心轴向磁通永磁电机及其印刷电路板绕组》
+
+### 1.5 不同导体材料(铜/铝/碳纳米管)
+
+- **转矩与电导率关系**:`T ∝ D_o³·√σ·L`(损耗密度不变时)
+- 同转矩下换材料所需外径比:`D_o2/D_o1 = √(σ1/σ2)`;调整外径比调整轴向长度更划算
+- **CNT**:质量仅铜的 1/6、无趋肤效应、电阻低温系数;但宏观电导率低(约 6×10⁶ S/m,铜约 6×10⁷),需加大体积补偿 → 适合"重量敏感、体积不敏感"(太阳能飞机)
+- **铝**:单位质量电导率是铜 2 倍、比热容 2 倍(过载好),适合重量敏感
+
+> 出处:《无铁心多盘轴向磁通永磁电机:采用碳纳米管绕组》
+
+---
+
+## 2. 损耗机理与计算(本知识库最核心,仿真时损耗设置直接参考)
+
+### 2.1 损耗总览
+
+无铁心 PCB 电机的损耗构成(无定子铁损):
+
+| 损耗 | 成因 | 与运行量的关系 | 备注 |
+|---|---|---|---|
+| 直流铜耗 | 电流 × 电阻 | ∝ I² | PCB 铜量少→电阻大→直流损耗大,是主要设计制约 |
+| 涡流损耗 | 导体暴露于旋转气隙磁场 | ∝ f²·B²(详见 2.3) | PCB 迹线可做窄做薄近似利兹线,但铜量受限 |
+| 环流损耗 | 并联支路感应电压不等 | ∝ (ΔE)²/R,**与转速平方成正比** | 可占额定转矩下总铜耗约 15%(波绕组) |
+| 集肤+邻近 | 高频电流分布 | 频率相关 | 迹线远薄于趋肤深度时可忽略(见 2.5) |
+| 磁钢涡流 | 定子谐波/槽口 | 高频 | 分段可抑制 |
+| 机械/风阻 | 旋转 | ∝ 转速²(浸没式) | 高速或浸油应用不可忽略 |
+| 驱动损耗 | 逆变器开关+导通 | — | WBG 高开关频率时不可忽略 |
+
+> 出处:《无铁心轴向磁通永磁电机PCB定子中的环流与涡流损耗》《Loss- and Thermal-Constrained Design of a PCB Stator Double-Rotor Axial-Flux Motor for Immersed Pump Applications》《永磁无刷电机损耗分离的组合实验与数值方法》
+
+### 2.2 直流铜耗与 PCB 电阻
+
+- PCB 相电阻:`R = ρ·l/A`,`A = w·t`(迹宽×铜厚)。铜厚受限(常规 ≤70 μm,高厚 95 μm,13 oz ≈ 455 μm),迹宽受设计规则限制 → **电阻远大于同体积线绕组**。
+- 多层 PCB 并联:`R_eff = R/(N/2)`(N 为层数,相邻层配对并联)。24 层可把有效电阻降到 R 的 1/12,但**层数过多 → 板厚增加 → 环流损耗上升**(见 2.4)。
+- 例(IIT Bombay 30 krpm 70W 电机):单相电阻约 7 Ω,铜耗达 10.5 W;改 12 层并联后显著下降。
+
+> 出处:《高速无铁心轴向磁通永磁电机及其印刷电路板绕组》《用于高速轴向磁通永磁电机的印刷电路板绕组的多物理场分析》
+
+### 2.3 涡流损耗(PCB 矩形/圆导线公式)
+
+**PCB 矩形导体涡流损耗(Chulaee,波绕组每相)**:
+
+```
+P_ed = π²·Nc·Nt·f²·tw·th·lm / (6ρ) · ( tw²·Bz² + th²·Bφ² )
+```
+
+- `Nc` 线圈数、`Nt` 每线圈匝数、`tw` 迹宽、`th` 迹高、`lm` 平均迹长、`ρ` 铜电阻率
+- **切向磁密 Bφ 比轴向 Bz 小一个数量级** → 加厚迹高(th)对涡流增加很小,是提高铜利用率的关键手段
+- 例:螺旋绕组 1000 rpm 时涡流约 1.1 W;波绕组约 0.5 W
+
+**圆导线涡流损耗(Kamper,电阻受限区)**:
+
+```
+P_e = π·l·d⁴·Bpk²·ω² / (32ρ)          (l 导线长,d 线径)
+P_eddy = π·l·Nc·Nt·Ns·d⁴·Ba²·ω²·σ / 128   (Taran 综述版)
+```
+
+**矩形导体一般式(Taran)**:
+
+```
+P = l·Nc·Nt·Ns·w·h·ω²·σ/24 · ( w²·Baz² + h²·Baφ² )
+```
+
+**端部修正因子**(导体短、端部回流路径短时):
+
+```
+K_s = 1 − tanh(π·Leff/dc) / (π·Leff/dc)
+```
+
+**关键结论**:
+- 无铁心电机中**标准 1D 解析法低估涡流损耗可达 43%**;需多层(轴向分)+ 多片(径向切)2D FE 或 3D FE
+- 3 片(径向切片)2D FE 模型是精度/CPU 的良好平衡
+- 切向磁场分量会带来附加涡流 → 宽铜带导体不利(带状宜窄不宜宽)
+- 涡流损耗与 f²、B² 成正比 → 高速高极数是涡流压力来源
+
+> 出处:《无铁心轴向磁通永磁电机PCB定子中的环流与涡流损耗》《无铁心定子轴向磁通永磁(AFPM)电机中涡流损耗的评估》《轴向磁通永磁电机中交流绕组损耗计算方法综述及一种新的三维有限元与解析混合技术》
+
+### 2.4 环流损耗(无铁心 PCB 电机最大的隐性损耗)
+
+**成因**:PCB 多层/多并联支路处于气隙不同位置 → 感应电压不同 → 并联支路间产生环流。
+
+**公式**:
+
+```
+P_cr = (1/R) · Σ[ Ei − (ΣEi)/n ]²        (n 条并联支路)
+P_c  = Vd² / (R1 + R2 + Rc)               (两层之间,Vd 为两层感应电压差)
+```
+
+**量级数据(Chulaee 实测)**:
+- 波绕组 1000 rpm:涡流 0.5 W,**环流 28.3 W**(单相 420 条迹线)——环流可占额定转矩下总铜耗约 15%
+- 24 层 PCB:顶层与底层感应电压差 Vd ≈ 14 V → 环流损耗约 12 W;**降到 12 层则约 0.5 W**(Neethu 例)
+
+**与转速关系**:环流损耗 ∝ (反电动势差)² ∝ **转速²**。高速必须控制。
+**与制造偏差关系**:
+- 磁体剩磁下降 10% → 环流约 +6 W
+- 0.75 mm 静态偏心 → 环流约 +15 W
+
+**抑制手段(按有效性排序)**:
+1. **层间换位(transposition)**:让并联支路经过所有层位,是首选项(完全换位可将环流压到 ≤1 W,见 2.9 案例)
+2. 圆周方向扩展并联路径(增大 Δr 覆盖)
+3. 轴向贯穿尽可能多的 PCB 层
+4. 加宽迹宽、减少并联数
+5. 绕组重接拆分:把并联支路拆成 4 部分合理并联,环流可降 **110 倍**(切向分组只降 4 倍)
+6. 减小制造不对称(磁体一致性、偏心)
+
+> 出处:《无铁心轴向磁通永磁电机PCB定子中的环流与涡流损耗》《考虑详细 PCB 定子布局的无铁心轴向磁通永磁电机多目标设计优化——将涡流与环流损耗降至最低》《用于高速轴向磁通永磁电机的印刷电路板绕组的多物理场分析》《通过绕组重接最小化无槽无刷直流电机中的环流》
+
+### 2.5 集肤效应与邻近损耗
+
+- **PCB 迹线等效利兹线条件**:`tw << δ`,其中趋肤深度 `δ = 1/√(π·f·μ·σ)`。例:650 Hz 时铜 δ ≈ 2.5 mm,迹宽 0.2 mm(差一个数量级)→ **PCB 天然近似利兹线,集肤效应可忽略**(低速/中速设计)
+- 但**邻近效应**(相邻迹线/层间漏磁)在高速高电流密度下显著,尤其槽内多层堆叠
+- 矩形导体集肤电阻:`R_sk = ρ·l/(w·t_eff)`,`t_eff = δ·(1−e^(−t/δ))`
+- **高速有铁心电机警示**(可迁移到绕组损耗理解):12000 rpm 时交流/直流电阻比 5.58,3000 rpm 时 1.67;并联路径增多损耗激增(8 并联时增加近 10 倍);槽顶导体是热点
+- **利兹线股数最优(Sullivan)**:`F_r = 1 + π²·ω²·μ₀²·N²·n²·d_c⁶·k/(768·ρ_c²·b_c²)`,存在最优股数;设计例 375 kHz 下 130 股 48 AWG 最优,F_r′ ≈ 2.35。股径经验上限:**单线直径 ≤ δ/3**
+
+> 出处:《高速无刷永磁电机中的集肤效应与邻近损耗》《电机中股线涡流损耗的高效计算方法》《利兹线变压器绕组中股数的最优选择》《用于高速轴向磁通永磁电机的印刷电路板绕组的多物理场分析》《设计优化、无铁心轴向磁通永磁电机利兹线与PCB定子绕组》
+
+### 2.6 磁钢涡流损耗
+
+- 无铁心电机无定子铁心,但**转子磁钢/背铁仍会因定子谐波产生涡流**
+- NdFeB 参数(100°C):`μr ≈ 1.05`,`ρ ≈ 1.5×10⁻⁷ Ω·m`(导电!)
+- 抑制:磁钢**周向/轴向分段**;高频时用 CE-FEA(60° 电角度窗口)快速评估
+- 磁钢损耗在总损耗中占比通常较小(极数探索论文中"磁钢损耗占比小,未纳入优化目标"),但高速/大谐波时不可忽略
+
+> 出处:《基于计算高效有限元法的集中绕组永磁同步电机永磁体损耗计算》《系统探索极数对超高效率分数马力轴向磁通永磁电机性能与成本极限的影响》
+
+### 2.7 AC 绕组损耗计算全流程(按精度/成本阶梯)
+
+| 方法 | 精度 | 计算成本 | 适用阶段 |
+|---|---|---|---|
+| 解析闭式(2.3 公式) | 中(可能低估 43%) | 瞬时 | 初筛/趋势 |
+| 2D FE(多层+多片) | 中高 | 分钟级 | 参数化/优化主体 |
+| 2D FE + 解析混合(端部修正 Ks) | 高 | 分钟-小时 | 设计定型 |
+| 3D FE 逐走线(详细) | 最高 | **极高**:2600 万+四面体,64 核 192GB HPC 单电周期 >72h;2265 万单元网格化 12h | 最终验证/样机 |
+| 简化等效 3D FE(超快速,见 4.3) | 高(对低饱和电机) | 大幅降低 | 大规模优化 |
+
+**工程建议**:优化阶段用解析/2D,最终点用 3D 逐走线校核;若 3D 逐走线跑不动,用"等效 3D"(把平面 PCB 迹线等效为简化模型)。
+
+> 出处:《轴向磁通永磁电机中交流绕组损耗计算方法综述及一种新的三维有限元与解析混合技术》《论用于电动飞机推进的无铁心永磁电机设计》《基于波绕PCB定子的无铁芯轴向磁通永磁电机的设计优化与实验研究》
+
+### 2.8 损耗分离实验法(用于验证仿真)
+
+- **最小二乘损耗分离**:把实测总损耗回归到与电流/转速成比例的项(`i²`、`i²ω`、`i²ω²`、`ω²` 等),分离出铜耗、铁耗、机械损耗、杂散损耗。残差 <2%
+- 注意:2D FEA 对磁钢转子涡流可能**高估约 3 倍**(端部/3D 效应),实验验证时留意
+- NASA 飞轮法(真空、无外载荷):**堵转测试**(分离电流损耗,含集肤邻近)→ **空载测试**(旋转损耗)→ **恒流测试**;堵转测试中频率 200→1000 Hz 时电流损耗 +40%
+
+> 出处:《永磁无刷电机损耗分离的组合实验与数值方法》《Experimental Performance Evaluation of a High Speed Permanent Magnet Synchronous Motor and Drive for a Flywheel Application》
+
+### 2.9 损耗工程案例(可直接对标)
+
+| 案例 | 关键数字 | 说明 |
+|---|---|---|
+| Chulaee 波绕/螺旋 | 波绕 1000rpm:涡流 0.5W / 环流 28.3W;螺旋:涡流 1.1W | 螺旋绕法本身环流小,但铜利用率低 |
+| Chulaee 多目标优化样机 | 2100rpm 19N·m,36 极 9 层 PCB,SFF≈0.18-0.20,**完全层间换位**:实测涡流 22.6W(<13%)、环流 ≤1W、机械 30.4W,**效率 95.9%(超 IE4)** | 完全换位是环流杀手锏 |
+| Han 波绕样机 | 3050r/min、300V、16Arms、14Nm、10.8A/mm²;**环流限制转速 ≤1400r/min**(2.75hp) | 不换位时环流直接封顶最高转速 |
+| Sansoni 浸没泵 | 8层3oz 78.9% / 12层2oz 82.6% / 12层3oz 84.4% 效率;解析 vs 实测:效率 80.8 vs 77%、转矩 213 vs 210 mNm | 解析效率误差 ~4.7%,转矩 ~1.4% |
+
+> 出处:见各案例对应论文(Chulaee 两篇、Han ECCE、Sansoni IEEE TIA)
+
+---
+
+## 3. PCB 定子绕组设计与制造约束
+
+### 3.1 PCB 制造参数与成本(仿真参数语义必读)
+
+| 参数 | 典型值 / 范围 | 工程含义 |
+|---|---|---|
+| 铜厚 | 常规 1–2 oz(35–70 μm);高厚 95 μm;13 oz ≈ 455 μm(最大) | 铜厚限制铜量 → 直流损耗制约;加厚 PCB 可显著提转矩(PCB 从 1.5→2mm 厚度,转矩近似成正比提升,其中 75% 来自安匝增加、5% 来自电流层靠近磁体) |
+| 最小线宽 | ≈0.15 mm(IPC-2221 通用) | 决定最小迹宽 |
+| 最小线隙 | ≈0.13 mm(IPC-2221);工程常用 0.2–0.3 mm | 绝缘间隙需满足击穿电压;随铜厚增加需加大 |
+| 层数 | 2–24 层;**>6 层成本急剧上升** | 层数↑→电阻↓ 但环流↑板厚↑;12 层是高速样机常用折中 |
+| 基板 | FR-4(玻璃化 180°C,导热仅 0.2 W/m·K) | FR-4 导热差是主要热瓶颈;PCB 与铜热膨胀系数接近,热应力小 |
+| 铜/板厚约束 | 总 PCB 厚度决定有效气隙 | 层数增加 → 板厚增加 → 磁链下降(Neethu:总额定电压/转矩要求 PCB 厚度 ≤3mm) |
+| 成本规律 | 1000 块单件成本约为 10 块的 **13%**;5→1000 件单板成本可降 **87%** | 批量制造是 PCB 电机核心卖点 |
+| 填充因子 | PCB ≈ 0.30–0.31(波绕 0.2286);利兹线 ≈ 0.386 | PCB 铜占有率低 → 同体积直流损耗高,但可用更高电流密度补偿 |
+
+**铜厚与涡流权衡**:迹高 th 可加厚(Bφ 小),但迹宽 tw 加宽会直接增涡流(∝tw²)→ **宽迹面朝径向减涡流,厚迹增加铜量**是核心策略。
+
+> 出处:《设计方面、绕组布置与印刷电路板电机的应用:一份全面综述》《轴向磁通永磁同步电机PCB绕组拓扑比较》《无铁心轴向磁通永磁电机PCB定子中的环流与涡流损耗》《设计优化、无铁心轴向磁通永磁电机利兹线与PCB定子绕组》《用于集成3D打印换热器的电机PCB绕组》
+
+### 3.2 绕组拓扑比较(数值基准,可写入设计脚本)
+
+同一电机(16 极 2000rpm,外径 92mm 内径 50mm,气隙 1mm,磁体 2mm,Br=1.33T,PCB 2 层 70μm,线宽 0.8mm):
+
+| 绕组拓扑 | 相电阻 | 相对同心 | 感应电压/转矩 | THD | 备注 |
+|---|---|---|---|---|---|
+| 同心 | 1.04 Ω | 基准 | 高 | 中 | 端部浪费 |
+| 径向 | 0.87 Ω | **−20%** | 最高 | **最小** | 相电阻低 20-24%,THD 最优 |
+| 弧形 | 0.85 Ω | −18% | 中高 | — | — |
+| 并联 | 0.79 Ω | −24% | 中 | — | 电阻最小 |
+| 不等宽并联 | 0.59 Ω | **−43%** | 中 | — | 再省铜耗 17% |
+
+- 解析(磁标量势法)气隙磁密基波与 FEA 差 <1%
+- 2000 rpm 时涡流 10–20 mW 可忽略(低中速 PCB 绕组)
+- **结论:径向(并联)绕组是 PCB AFPM 综合最优**;波绕适合超薄/无端部场景
+
+**绕组四种 PCB 拓扑**:分布式 / 螺旋 / 递进波 / 连续波。波绕优点:无端部、每匝磁链相同、反电动势正弦(THD 12%→6%、幅值 45→56V 的实测例)。
+
+**不等宽绕组(UEW)**:有源导体向两侧加宽,减小电阻、增大铜占比、增大散热面积。设计参数:
+- 内外径比 γ = 1.5–2.2(小型 1.5–1.73)
+- 每极每相匝数:`N = π·D_mi / (2·m·p·(w_ie + d_L))`(先定 N 再定内径)
+- 内端线宽按最大电流密度(内端电流密度最大、发热集中)设计
+- 典型:d_L=0.3mm,铜厚 4oz
+
+> 出处:《轴向磁通永磁同步电机PCB绕组拓扑比较》《高速无铁心轴向磁通永磁电机及其印刷电路板绕组》《采用不等宽PCB绕组的轴向磁通永磁发电机的电磁设计与分析》《设计方面、绕组布置与印刷电路板电机的应用:一份全面综述》
+
+### 3.3 降低交流损耗的绕组技术汇总
+
+| 技术 | 机理 | 效果/代价 |
+|---|---|---|
+| 层间换位 | 并联支路经所有层位,感应电压均等 | 环流降到 ≤1W(首选) |
+| 狭缝(slit) | 导体开缝抑制涡流路径 | 供电损耗 α 与涡流 β 权衡;**长狭缝并联连接最佳,交叉连接使 β 翻倍**;最优并联狭缝数固定 |
+| 不等宽(UEW) | 加宽有源导体 | 电阻↓、铜占比↑ |
+| 逆导体(inverse trace) | 韩国 EWP 用逆导体图案 | 既定尺寸下最小化电流密度 |
+| 径向波绕 | 无端部、分布绕组 | 反电动势正弦、转矩好 |
+| 加厚迹高 | Bφ 小,增厚不增涡流 | 提 SFF 与转矩 |
+| 磁体形状/线圈几何优化 | 缩短涡流路径 | 降低涡流损耗 |
+
+> 出处:《狭缝结构对无槽永磁电机 PCB 绕组中涡流与供电电流损耗的影响》《考虑详细 PCB 定子布局的无铁心轴向磁通永磁电机多目标设计优化》《PCB定子电动水泵电机的设计》
+
+### 3.4 利兹线 vs PCB 定子(选型对照)
+
+| 维度 | 利兹线 | PCB |
+|---|---|---|
+| 填充因子 | 0.386 | 0.303 |
+| 交流损耗 | 需股数优化(≤δ/3) | 迹线天然细,等效利兹 |
+| 制造 | 线绕、灌封、定位精度差;**成本高达 300 欧元/kg** | 高重复、高精度、批量降本 87% |
+| 热 | 灌封后一般 | 平面散热好、可贴换热器 |
+| 电磁效率(同案例) | **98.8%(最高)** | 略低但接近 |
+| 适用 | 高转矩密度、一次少量 | 批量、超薄、模块化 |
+
+**注意**:利兹线最大单线直径 ≤ δ/3;100 股 AWG 40 是常用配置(38 AWG 单线亦可)。
+
+> 出处:《设计优化、无铁心轴向磁通永磁电机利兹线与PCB定子绕组》《高速印刷电路板无铁芯轴向磁通永磁电机的设计》
+
+---
+
+## 4. 设计优化方法
+
+### 4.1 核心难点:3D FEA 太贵 → 代理辅助/简化模型
+
+AFPM 磁路三维(径向依赖 + 内外缘边缘磁通),2D 模型精度不足;但逐走线 3D FEA 单点 2600 万单元 / >72h。主流破解:
+
+**① 两级代理辅助优化(2L-SAMODE,Ionel 组)**
+- 外环进化算法(差分进化 DE/MODE),内环 Kriging 代理模型估值,仅最有前景的设计用高保真 3D FEA
+- **效果:同 Pareto 前沿,FEA 评估从 886 次降到 163 次**(上千次 → <200 次)
+- 初始样本池用随机设计(宽空间非线性下优于 DoE)
+- 误差超阈值(如 5%)的设计补充进样本池
+- 适用于 3D FEA 单点 15 分钟–数小时的场景(正是本工程可复用的思路)
+
+**② 超快速/最少解 FEA(Chulaee)**
+- 无铁心电机**无饱和**(转矩-电流线性)、齿槽转矩低 → 用最少瞬态解估算性能
+- 几何对称:**1 极 + 轴向 1/2**(1/26 电机)→ 计算量减半以上
+- 平面 PCB 迹线用"等效 3D 简化模型"(见 4.3)
+- 验证:4.2kW/2100rpm/26 极样机实验吻合
+
+**③ 解析 + 2D 组合**:Marcolini 平均半径 2D 解析电磁 + 并行热模型,初步设计仅 **35 秒**(i7 2015)。
+
+> 出处:《超快速无铁心轴向磁通永磁同步电机有限元分析》《系统探索极数对超高效率分数马力轴向磁通永磁电机性能与成本极限的影响》《无铁心轴向磁通永磁电机的新型多物理场设计方法》《轴向磁通永磁电机技术综述》
+
+### 4.2 多目标优化案例(可直接对标目标函数/变量设计)
+
+**案例 A:Chulaee PCB 定子布局多目标优化**
+- 目标:最小化涡流 + 环流损耗;算法 MODE;480 候选设计
+- 关键变量:SFF(槽填充因子)、迹宽/迹高、层数、是否换位
+- 结果:36 极 9 层 PCB、完全换位 → 效率 95.9%(超 IE4)
+
+**案例 B:Taran 极数探索(SAMODE)**
+- 目标:总损耗 F_l = W_Cu + W_c + W_pm;成本 F_c = m_c + 3·m_Cu + **24·m_pm**(磁钢成本权重 24 倍!)
+- 变量 8 个(比例化):气隙 g、裂比 kds、磁钢厚 kpm、槽宽 ksw、极弧比 kp、轭厚 kry、悬垂比 k_oh(−1~+1,负悬垂省磁钢)
+- 槽/极组合:12/10、24/20、36/30、48/40;目标 1050rpm 5.4Nm
+- 规律:极数↑→铜损↓(端部短)、铁损↑(频率高)→ 存在最优极数;磁钢损耗占比小可忽略
+
+**案例 C:Tokgöz GaN 集成(NSGA-II)**
+- 目标:效率 + 功率密度(kW/L);变量:相电流、迹厚、匝数、磁钢厚、极数、内外径
+- 解析目标函数 0.02 秒/评估 → 可加大变量数;代=种群=100
+- 结果:270W,0.36Nm 时 82%、0.18Nm 时 90%,定子温度 59°C
+
+**通用建议(对自动化仿真系统)**:目标函数优先(损耗/成本/转矩密度/效率);成本函数里磁钢权重大;用解析/2D 粗筛 + 3D 精验的两级流程。
+
+> 出处:《考虑详细 PCB 定子布局的无铁心轴向磁通永磁电机多目标设计优化》《系统探索极数对超高效率分数马力轴向磁通永磁电机性能与成本极限的影响》《采用 GaNFETs 的集成电机驱动系统中优化 PCB 电机的机械与热设计》
+
+### 4.3 平面 PCB 线圈的等效 3D FEA 简化模型
+
+- 逐走线 3D 网格单元以千万计,严重拖慢优化
+- **简化等效模型**:利用无铁心电机线性/低饱和特性,将平面 PCB 绕组几何系统简化为较粗网格仍保持精度
+- 配合对称边界(1/26 + 轴向 1/2),数百候选设计评估成为可能
+- 工程含义:本工程在 Motor-CAD/Ansys 中建模时,可优先用简化绕组 + 对称周期,把单点求解压到分钟级,再对最终点细跑
+
+> 出处:《超快速无铁心轴向磁通永磁同步电机有限元分析》
+
+### 4.4 极数选择经验
+
+- 高极数 → 高转矩密度、端部短(铜损↓)、齿槽转矩小;但频率↑→铁损↑(有铁心)、涡流↑、驱动频率↑
+- 无铁心无齿槽,可放心用高极数(26 极是 Chulaee 多篇样机的常用值)
+- 低速高转矩、低功率高速无刷 PM 电机:高极数是主流(转矩密度可达 2 Nm/kg 级)
+
+> 出处:《系统探索极数对超高效率分数马力轴向磁通永磁电机性能与成本极限的影响》《无铁心轴向磁通永磁电机的新型多物理场设计方法》
+
+### 4.5 无铁心 AFPM 多物理场设计方法(Rome 组,完整闭环)
+
+1. 由 **2p/Nc 比值**确定拓扑族
+2. 平均半径 2D 解析电磁模型(含多层轴向厚线圈)
+3. 热模型**并行**评估热点温度
+4. 迭代收敛 → 输出满足热约束的设计
+- C-AFPM 峰值基波气隙磁密可超 **0.6 T**
+- 设计脚本 35 秒/轮 → 适合自动化探索
+
+> 出处:《无铁心轴向磁通永磁电机的新型多物理场设计方法》
+
+---
+
+## 5. 热管理与机械设计
+
+### 5.1 冷却方式对照(可写进热网络脚本)
+
+| 冷却方式 | 典型换热系数 h (W/m²·K) | 特点 |
+|---|---|---|
+| 自然对流(TENV) | 5–15 | 静音、免维护、功率密度低 |
+| 内部风扇(TEFC) | 20–80 | 中功率首选 |
+| 轴向通风道 | 50–150 | 需转子开槽、风摩损增加 |
+| 机壳水套 | 500–3000 | 高功率密度、电动车主流 |
+| 定子槽内油冷 | 800–4000 | 直接冷却绕组、绝缘兼容难 |
+| 空气 | — | 密度 1.2 kg/m³、热容 1.00 kJ/kg·K、导热 0.026 W/m·K |
+| 矿物油 | — | 导热 0.15 W/m·K、热容 1.67 kJ/kg·K、密度 800 kg/m³ → 散热可达空气 3 倍+ |
+
+- 空气冷却在**定子损耗密度超 1500 kW/m²** 时失效 → 高功率密度(≥2.0 kW/kg)需液冷
+- 旋转端部空气对流经验式:`h_air ≈ 5.6·√(v_tip)` W/(m²·K)(v_tip 转子外径线速度 m/s)
+- 水套螺旋通道:Nu=0.023·Re^0.8·Pr^0.4,流速 0.5–2 m/s 时 h=1000–4000;压降∝v^1.8
+- 辐射:涂黑漆 ε=0.9、铝裸面 ε=0.2;100°C 温升范围内占比<10%(TENV 高温段 20–30%)
+
+> 出处:《系统、方法和装置:用于直接液冷轴向磁通电机,配备 PCB 定子》《多物理场仿真设计:电机、电力电子与驱动》
+
+### 5.2 PCB 电机温度限制(热设计边界)
+
+| 部位 | 温度限值 | 备注 |
+|---|---|---|
+| PCB 层压材料 | ~170°C(FR-4 玻璃化 180°C) | 超过即过早失效 |
+| 覆盖层(solder mask) | ~180°C | — |
+| 永磁体(NdFeB) | ~120°C(连续) | 高温退磁风险 |
+| 绕组温升 | NEMA B 级 80°C;F 级绝缘 105°C | Infinitum 样机:40°C 环境 + 34°C 绕组温升 |
+
+**FR-4 导热差(0.2 W/m·K)是主要热瓶颈**;对策:液冷、3D 打印换热器贴合、油冷、加厚铜层(导热好)。
+
+> 出处:《Loss- and Thermal-Constrained Design of a PCB Stator Double-Rotor Axial-Flux Motor for Immersed Pump Applications》《双转子轴向磁通永磁电机采用 PCB 定子》
+
+### 5.3 PCB 电机热设计案例
+
+**① 直接液冷(Infinitum 专利)**
+- 轴分配液体冷却剂到转子与 PCB 定子之间气隙 → 冷却液流经 PCB 表面直接带走热量
+- 避免冷却液进入窄气隙(拖曳损失);非导电非腐蚀油(矿物油/合成传动油/硅油)
+- 热路径:线圈→PCB 层传导→表面→冷却剂对流;部分经层间传导到壳体
+- 效果:电流密度可达传统液冷电机 **4–5 倍**
+
+**② 3D 打印换热器(WEMPEC)**
+- 刚性 PCB 径向定子 + 槽内 3D 打印换热器(TPU 导热 6 W/m·K 耐 110°C / 尼龙 4 W/m·K 耐 200°C)
+- 冷却液-HX 界面平均换热系数 ≈ **14000 W/m²·K**;0.2 L/min 压降仅 153 Pa
+- 导热环氧粘接(k_epx≈? 见表 I,约 1–3 W/m·K),翅片穿插 PCB 层间
+- 意义:把换热器移到绕组旁,电流密度↑、槽面积↓、体积↓
+
+**③ GaN 集成电机驱动(Tokgöz & Keysan)**
+- 电机+驱动器共壳体(外壳三合一:机械集成/热冷却/EMI 屏蔽)
+- 双转子单定子 + 被动冷却 → 270W 时定子温度仅 59°C
+- PCB 定子走线本身兼作散热/测温通道
+
+> 出处:《系统、方法和装置:用于直接液冷轴向磁通电机,配备 PCB 定子》《用于集成3D打印换热器的电机PCB绕组》《采用 GaNFETs 的集成电机驱动系统中优化 PCB 电机的机械与热设计》
+
+### 5.4 机械设计要点
+
+- **轴向磁拉力**(单面结构):`F_z = B_g²·S/(2μ₀)`,可把轴承/装配吃掉;双转子平衡结构可消除
+- 无铁心电机轴向磁拉力大幅削弱(磁通不过定子铁心),这是无铁心的重要机械优势
+- 高速离心力:`F_c = m·ω·v`,转子半径宜小;磁体用保持套筒或埋入式+非磁钢顶盖(Inconel 625)
+- 转子背铁:双转子同速旋转→背铁无基频涡流,可用**低碳钢(无需叠片)**;背铁厚度超过最小值后对转矩无影响
+- 高速应用需校核转子应力与临界转速(无铁心通常临界转速远高于额定)
+
+> 出处:《双转子轴向磁通永磁电机采用 PCB 定子》《高速无铁心轴向磁通永磁电机及其印刷电路板绕组》《设计方面、绕组布置与印刷电路板电机的应用:一份全面综述》
+
+---
+
+## 6. 高速与驱动控制
+
+### 6.1 高速 PCB 电机设计要点
+
+- 高速无铁心电机:无铁耗、零齿槽转矩、转矩平稳 → 非常适合方波/无感控制
+- 约束:**环流损耗 ∝ 转速²** → 高速必须换位/少并联(Chulaee:不换位 3050rpm 设计被环流限制到 1400rpm)
+- 涡流 ∝ f² → 高极数高速时 PCB 迹线涡流压力大
+- 高速转子:埋入式磁体/Halbach + 套筒,控制线速度
+- 低电感(百 μH)→ 电流纹波大 → 需 50–100 kHz 开关频率(WBG)或外接电感器
+
+> 出处:《设计_of_a_High_Speed_Printed_Circuit_Board_Coreless_Axial_Flux_Permanent_Magnet_Machine》《高速无铁心轴向磁通永磁电机及其印刷电路板绕组》《基于波绕PCB定子的无铁芯轴向磁通永磁电机的设计优化与实验研究》
+
+### 6.2 WBG 驱动与宽速域控制
+
+- **问题**:无铁心相电感极低(例 2.2kW/26 极电机相电感仅 **152 μH**、相电阻 0.85Ω)→ 电流纹波大、弱磁能力弱
+- **对策**:SiC/GaN 高开关频率(50–100 kHz 甚至 1 MHz);GaN 开关频率 1MHz 时集成驱动效率 82–90%
+- **双模控制**(FOC + 方波):低速 FOC 高动态、高速方波充分利用直流母线电压 + 无需高精度编码器(霍尔/无感),拓宽速域
+- 低电感也带来优点:电气时间常数小、动态响应极快、电流调节带宽高
+
+> 出处:《采用宽禁带半导体器件的高极数无铁心轴向磁通永磁电机宽速域灵活控制》《采用 GaNFETs 的集成电机驱动系统中优化 PCB 电机的机械与热设计》
+
+### 6.3 弱磁设计(有铁心通用,供理解极速域)
+
+- 理想无限弱磁条件:**永磁磁链标幺值 = d 轴电感**(Ψ_mo = L_d pu)
+- 凸极比 ξ = L_q/L_d;**ξ<1(L_d≥L_q)非常规结构**转矩/功率因数/控制特性更优
+- 满足理想弱磁的电机额定功率因数均约 0.707–0.721
+- 磁钢/气隙参数:d 轴饱和系数 k_so≈1.1–1.3;极弧比 IPM 0.85–0.95、SPM ≈2/3
+- 无铁心电机弱磁能力弱 → 靠方波/升压扩展速域(见 6.2)
+
+> 出处:《适用于弱磁应用的永磁同步电机设计考虑因素》《论用于电动飞机推进的无铁心永磁电机设计》
+
+---
+
+## 7. 测试与验证方法
+
+### 7.1 电感/参数测试(IEEE Std 1812)
+
+- **开路 + 短路试验**:开路测反电动势 → 永磁磁链 `ψm = E_oc/(2πf)`;短路测 `L_d = √(Z²−R²)/(2πf)`(三相对称短路≈纯 d 轴激励)
+- **q 轴电感**需额外测:静态转矩法 `ψm = 2·T_m/(3·p·I_q)`,再结合 L_d 求 L_q
+- 例(9 槽 6 极 IPM 样机):L_q = 7.5 mH
+- **注意**:短路时磁密低于负载工况 → L_d 可能被高估;饱和/交叉耦合时 L_d=f(I_d)、L_q=f(I_q)、ψm=f(I_q)
+- 对无铁心电机:电感极小且线性(无饱和),测试相对简单,但电流纹波大需高带宽测量
+
+> 出处:《内置式永磁同步电机电感测试——依据新版IEEE Std 1812及典型实验室实践》
+
+### 7.2 解析 vs 仿真 vs 实验的典型误差带(验收依据)
+
+| 对比项 | 论文报告误差 | 说明 |
+|---|---|---|
+| 解析气隙磁密基波 vs 2D FEA | <1% | 磁标量势法 |
+| 解析效率 vs 实测(浸没泵) | ~4.7%(80.8% vs 77%) | 解析偏乐观 |
+| 解析转矩 vs 实测 | ~1.4%(213 vs 210 mNm) | 转矩预测可信 |
+| 2D FEA 磁钢涡流 vs 实测 | 高估约 3 倍 | 端部/3D 效应 |
+| 损耗分离回归残差 | <2% | 最小二乘法 |
+| 空载反电动势(波绕逐走线模型) | 与实测匹配 | 波形正弦、两相平衡 |
+| 电感(ψm)虚拟 vs 实测 | 吻合(0.107 Wb) | Maxwell 虚拟试验 |
+
+**工程含义**:给仿真加 ±5% 效率、±2% 转矩的验收容差是现实的;磁钢涡流要用 3D 或实测校正。
+
+### 7.3 高速/真空电机效率测试(NASA 飞轮法)
+
+- 场景:真空腔+磁悬浮,无法直接加机械负载
+- 三步法:**堵转测试**(电流损耗,含集肤邻近)→ **空载测试**(电流损耗+旋转损耗)→ **恒流测试**
+- 旋转损耗 = 空载功率 − 堵转电流损耗(同频率点)
+- 功率测法:P_DC = V_DC·I_DC;P_M/G = Σ v_a·i_a(瞬时值积分)
+- 数据:3kW、2 极、6–60 krpm、1000 Hz;频率 200→1000 Hz 电流损耗 +40%
+- **对该工程的意义**:本工程做自动化仿真时,堵转/空载/负载三类工况的损耗分离与仿真对账可以复用这套逻辑
+
+> 出处:《Experimental Performance Evaluation of a High Speed Permanent Magnet Synchronous Motor and Drive for a Flywheel Application at Different Frequencies》
+
+---
+
+## 8. 对本工程 Motor-CAD 自动化仿真的工程启示(SOP 建议)
+
+> 本节把前 7 节的论文知识翻译成可直接落到本仿真系统脚本/参数表/校验逻辑的动作。
+
+### 8.1 建模与对称性(时间预算)
+
+- **无铁心 PCB 电机无饱和、线性**:可用 1 极 + 轴向 1/2 对称模型,计算量减半以上
+- 平面 PCB 迹线优先用**简化等效 3D** 或 2D 多层+多片;仅最终点用逐走线
+- 解析/2D 粗筛 → 3D 精验的两级流程,单点求解压到分钟级
+- 参考时间锚点:逐走线 3D 单电周期 >72h(HPC)不可行;简化 3D 数百点可跑
+
+### 8.2 损耗设置清单(仿真必须算全的项)
+
+1. **直流铜耗**:按迹宽/迹高/铜厚实算 R,注意温度系数(铜 α≈0.0039/K)
+2. **涡流损耗**:用矩形导体公式(2.3);Bφ 分量可近似忽略但高速要核
+3. **环流损耗**:**必须评估并联支路/多层感应电压差**;默认给"是否换位"参数;不换位时高速环流会封顶转速
+4. **磁钢涡流**:NdFeB 导电(ρ≈1.5e-7 Ω·m),高频谐波下分段评估
+5. **机械/风阻**:高速或浸油场景按转速²估算
+6. **PCB 基板/覆盖层**:FR-4 损耗角小可忽略,但导热差影响温升
+7. **驱动损耗**:WBG 高开关频率时按开关+导通估算(本工程若含驱动模块)
+
+### 8.3 参数校验与回读(结合本工程回读机制)
+
+- **气隙磁密**:解析基波与 FEA 差 <1% 可作为解析模型的置信判据
+- **反电动势/转矩**:解析 vs 3D 期望差 ≤2%(转矩)、≤5%(效率)
+- **环流损耗**:若仿真出的环流 > 涡流一个量级,先查并联支路是否换位/是否制造偏心假设
+- **电感**:无铁心电机电感小且线性,回读异常先怀疑饱和模型设置
+
+### 8.4 设计变量与约束(推荐纳入自动化参数表)
+
+| 参数 | 推荐范围/取值 | 依据 |
+|---|---|---|
+| 内外径比 λ | 0.65–0.75 | AFPM 综述 |
+| 极数 | 无铁心可用 26 极+ | Chulaee 样机 |
+| PCB 层数 | 6–12(>6 成本升,12 为高速折中) | 多篇 |
+| 迹宽/迹高 | 迹宽>迹高有利(涡流∝tw², 厚不增涡流) | 环流涡流论文 |
+| 线隙 | ≥0.13–0.3 mm | IPC-2221/设计 |
+| SFF | 0.18–0.23 | Chulaee |
+| 电流密度 | 10–24 A/mm²(液冷可高) | 多篇 |
+| 磁钢 | NdFeB Br≈1.3T,μr≈1.05 | 多篇 |
+| 成本函数 | 磁钢权重 24×铜 | Taran |
+
+### 8.5 需明确告知软件工程师的"论文已证明但易错"点
+
+- 标准 1D 解析会低估涡流 43%(用 2.3 的公式或 2D 多片)
+- 环流损耗 ∝ 转速²,优化到高转速必须考虑换位
+- FR-4 导热 0.2 W/m·K → 热模型里 PCB 层间热阻不可忽略
+- 无铁心低电感 → 若做驱动耦合仿真要配高开关频率,否则电流纹波失真
+
+---
+
+## 9. 常用数值速查表
+
+### 9.1 材料参数
+
+| 材料 | 电导率 σ (S/m) | 电阻率 ρ (Ω·m) | 密度 | 备注 |
+|---|---|---|---|---|
+| 铜 | ≈5.8e7 | ≈1.72e-8 (20°C) | 8960 | α≈0.0039/K |
+| 铝 | ≈3.5e7 | 2.7e-8 | 2700 | 单位质量电导为铜 2 倍 |
+| 碳纳米管(宏观) | ≈6e6 | — | 铜的 1/6 | 无趋肤效应 |
+| NdFeB 磁钢 | — | ≈1.5e-7 (100°C) | — | μr≈1.05,导电 |
+
+### 9.2 常用经验值
+
+| 量 | 值 |
+|---|---|
+| 趋肤深度(铜) | 650Hz→2.5mm;10kHz→0.66mm;100kHz→0.21mm |
+| 利兹线股径上限 | δ/3 |
+| C-AFPM 峰值基波气隙磁密 | 可超 0.6 T |
+| 无铁心电机比功率现状 | 0.3–2.3 kW/kg(NASA 目标 13 kW/kg) |
+| AFPM 市场预测 | 2022 1.503 亿 → 2032 3.955 亿美元(CAGR 10.1%) |
+| PCB 成本 | >6 层急剧升;1000 件/10 件单件比 ≈13% |
+
+---
+
+## 10. 论文清单与速查索引(38 篇)
+
+| # | 论文(翻译稿名) | 核心主题 | 最值得记住的 1 句话 |
+|---|---|---|---|
+| 1 | Loss- and Thermal-Constrained Design…Immersed Pump | 损耗+热约束设计 | 解析 vs 实测效率误差 4.7%、转矩 1.4%;FR-4 导热差是热瓶颈 |
+| 2 | 多物理场仿真设计:电机、电力电子与驱动(Wiley) | 通用仿真方法论 | 虚拟样机四步流程 + LPTN + 代理优化;Motor-CAD 生成效率图 |
+| 3 | 轴向磁通永磁电机技术综述(Gadiyar & Severson) | 综述/尺寸设计 | T=(π/2)BδÂRom³λ(1−λ²),λ∈[0.65,0.75],D/L>12 时 AFPM 更优 |
+| 4 | 无铁心轴向磁通永磁电机的新型多物理场设计方法 | 多物理场设计 | 平均半径 2D+热并行,35 秒/轮;C-AFPM 磁密可超 0.6T |
+| 5 | 无铁心多盘轴向磁通永磁电机:采用碳纳米管绕组 | 导体材料 | T∝Do³√σL;CNT 轻 6 倍但电导率低 |
+| 6 | 轴向磁通电机拓扑结构标志着下一代电动机的到来 | 产业/Infinitum | Aircore:可靠性 10×、体积重量 −50%、铜 −66%、电流密度 4–5× |
+| 7 | 系统、方法和装置:用于直接液冷轴向磁通电机(专利) | 液冷 | 轴分配冷却液直接冷却 PCB;>1500kW/m² 空气失效 |
+| 8 | 无铁心定子轴向磁通永磁(AFPM)电机中涡流损耗的评估 | 涡流计算 | 标准解析低估 43%;多层+多片 2D FE 最优;3 片平衡 |
+| 9 | 狭缝结构对无槽永磁电机 PCB 绕组中涡流与供电电流损耗的影响 | 狭缝技术 | 长狭缝并联最佳、交叉连接 β 翻倍 |
+| 10 | 通过绕组重接最小化无槽无刷直流电机中的环流 | 环流抑制 | 拆 4 部分并联环流降 110 倍 |
+| 11 | 无铁心轴向磁通永磁电机PCB定子中的环流与涡流损耗 | **环流/涡流核心** | 环流占铜耗 ~15%、∝转速²;换位是首选抑制 |
+| 12 | 设计方面、绕组布置与印刷电路板电机的应用:一份全面综述 | 综述/拓扑 | PCB 电机全分类;分布式绕组优于集中式 |
+| 13 | 轴向磁通永磁同步电机PCB绕组拓扑比较 | 绕组比较 | 径向/并联绕组电阻最低、THD 最好;涡流 10–20mW 可忽略 |
+| 14 | 采用不等宽PCB绕组的轴向磁通永磁发电机的电磁设计与分析 | 不等宽绕组 | UEW 减阻增铜;γ=1.5–2.2 |
+| 15 | Design_of_a_High_Speed_Printed_Circuit_Board_Coreless_Axial_Flux… | 高速设计 | 1kW/7500rpm;磁矢势场模型+转矩闭式 |
+| 16 | 用于高速轴向磁通永磁电机的印刷电路板绕组的多物理场分析 | 高速多物理场 | 24 层 Vd=14V/环流 12W→12 层 0.5W |
+| 17 | 双转子轴向磁通永磁电机采用 PCB 定子 | Infinitum 样机 | 10HP/1800rpm 效率 93% IE5;磁密 0.55T |
+| 18 | 采用 GaNFETs 的集成电机驱动系统中优化 PCB 电机的机械与热设计 | GaN 集成 | 1MHz 开关、270W、82–90% 效率、59°C |
+| 19 | 设计优化、无铁心轴向磁通永磁电机利兹线与PCB定子绕组 | 利兹线 vs PCB | PCB 填充 0.303 vs 利兹 0.386;利兹最高 98.8% |
+| 20 | 基于波绕PCB定子的无铁芯轴向磁通永磁电机的设计优化与实验研究 | 波绕样机 | 3050rpm 设计被环流限到 1400rpm;2265 万单元 |
+| 21 | 论用于电动飞机推进的无铁心永磁电机设计 | 航空推进 | 2640 万单元>72h;55 层并联达 10.5kW |
+| 22 | 用于集成3D打印换热器的电机PCB绕组 | 热管理 | 槽内 HX,h=14000W/m²·K |
+| 23 | 考虑详细PCB定子布局…多目标设计优化(涡流环流最小) | PCB 布局优化 | 完全换位:涡流 22.6W、环流≤1W、效率 95.9% |
+| 24 | 采用表面贴装永磁体和Halbach阵列转子的…转矩和功率能力 | Halbach | Halbach 转矩密度 +30%、免背铁 |
+| 25 | 适用于弱磁应用的永磁同步电机设计考虑因素 | 弱磁 | 理想弱磁 Ψmo=L_d;ξ<1 结构更优 |
+| 26 | 超快速无铁心轴向磁通永磁同步电机有限元分析 | 超快速 FEA | 1 极+轴向 1/2 对称;最少解;等效 3D |
+| 27 | 系统探索极数对超高效率分数马力…性能与成本极限的影响 | 极数/代理优化 | 2L-SAMODE:886→163 次 FEA;磁钢成本权重 24× |
+| 28 | 轴向磁通永磁电机中交流绕组损耗计算方法综述及一种新的三维… | AC 损耗综述 | 矩形/圆导线涡流公式 + 端部修正 Ks |
+| 29 | 基于计算高效有限元法的集中绕组永磁同步电机永磁体损耗计算 | 磁钢损耗 | CE-FEA;NdFeB ρ=1.5e-7 μr=1.05 |
+| 30 | 高速无铁心轴向磁通永磁电机及其印刷电路板绕组 | 高速 Halbach | 30krpm/70W;Halbach 磁链+57%;波绕 THD 6% |
+| 31 | 内置式永磁同步电机电感测试——依据新版IEEE Std 1812… | 参数测试 | 短路测 Ld、静态转矩测 Lq;Lq=7.5mH |
+| 32 | 永磁无刷电机损耗分离的组合实验与数值方法 | 损耗分离 | 最小二乘分离;残差<2%;2D 磁钢涡流高估 3 倍 |
+| 33 | Experimental Performance Evaluation…Flywheel(NASA) | 高速效率测试 | 堵转/空载/恒流三测试;200→1000Hz 电流损耗+40% |
+| 34 | 利兹线变压器绕组中股数的最优选择 | 利兹线 | 最优股数公式;375kHz 130 股 48AWG 最优 |
+| 35 | 电机中股线涡流损耗的高效计算方法 | 股线涡流 | CE-FEA 快速算;矩形股线公式 |
+| 36 | 高速无刷永磁电机中的集肤效应与邻近损耗 | 集肤/邻近 | 12000rpm 交直电阻比 5.58;并联 8 路损耗×10 |
+| 37 | 采用宽禁带半导体器件的高极数无铁心…宽速域灵活控制 | WBG 控制 | 相电感 152μH;FOC+方波双模;50–100kHz |
+| 38 | PCB定子电动水泵电机的设计 | 韩国 EWP | 12V 分布式绕组;14A/mm²、效率 82.62%;逆导体降 AC 损耗 |
+
+---
+
+## 附:阅读边界说明
+
+- 本知识库基于翻译稿精读 + 关键原文核对整理;**未逐篇逐句验证公式符号**,重要公式在落地进仿真脚本前请回原文核对(翻译稿在 `书籍与论文/相关论文-Chulaee.../翻译稿/`)。
+- 个别样机数字来自论文作者报告(含仿真与实测),非本工程实测。
+- Infinitum 双转子论文正文"110 HP(7.46kW)"与 7.46kW=10HP 矛盾,按 10 HP 理解。
+- 论文中的 3D FEA 时间(72h、12h 等)依赖其具体 HPC 硬件,仅作量级参考。
+
+
+
+
+

+ 155 - 0
docs/PLATFORM_DESIGN_V2.md

@@ -0,0 +1,155 @@
+# PCB轴向磁通电机自动化仿真系统 — 平台化升级设计方案 V2.0
+
+| 项 | 内容 |
+|---|---|
+| 文档版本 | V2.0 |
+| 作者 | Car.Lin / AI 协助 |
+| 日期 | 2026-08-29 |
+| 状态 | 评审稿 + 第一批已落地(2026-08-29:afmcore 共享核心层 + 指标单一事实源 + 适配器抽象) |
+| 关联 | 原《设计方案介绍》V1.1 + 《P3-评审响应与更新计划》 |
+
+---
+
+## 1. 为什么要平台化
+
+### 1.1 现状能力(已实现且扎实的部分)
+
+- 双系统解耦(Web 方案端 + 本地执行端)REST 通信已跑通
+- 方案生成三源融合(规则引擎 + Kimi k3 AI + 经验库检索)已落地
+- 本地执行器真实驱动 Motor-CAD,全链路(Web 下发 → 本地认领 → 真实磁计算 → 回传)已实测通过
+- 工程纪律(回读校验/基线重载/弹窗抑制/逐点落盘/纯 ASCII)执行到位
+- 第三方评审 P0/P1 已修复,测试留痕规范
+
+### 1.2 平台性短板(本次系统性深潜发现)
+
+| # | 短板 | 现状 | 平台性影响 |
+|---|---|---|---|
+| S1 | **指标定义三处漂移** | `src/solver_core.py`(15项) / `scripts/robust_motorcad.py`(18项) / `web/.../metrics_constants.py`(25项) 三份不统一 | 新增指标要改三处,必然漂移 |
+| S2 | **解析器两套实现** | solver_core 带字段归一化(全角括号/空白/大小写),robust_motorcad 是无归一化精确匹配 | robust_motorcad 解析不到 tavg_nm/ripple_pct(实测) |
+| S3 | **求解核心两套重叠** | `MotorCADSolver`(src) 与 `RobustMotorCADSolver`(scripts) 职责重叠,无统一抽象 | 维护成本翻倍,行为易分叉 |
+| S4 | **无工具适配器抽象** | 方案要求的 `SimulationAdapter` ABC 未落地,Motor-CAD 硬编码 | 无法插拔 Maxwell/JMAG |
+| S5 | **拓扑是裸字符串** | topology 字段无配置化注册表 | 无法扩展 DRSS/SDSR 参数体系 |
+| S6 | **方案 Schema 两套** | `src/plan_schema.py` 与 `web/backend/app/schemas/*` 并行 | 契约漂移风险 |
+| S7 | **执行策略硬编码全因子** | feasibility_search 未接入执行器,自适应闭环未通 | P3 优化能力未真正生效 |
+| S8 | **调度逻辑分裂** | Web 端 BatchScheduler(内存)与本地执行器(轮询)两套 | 平台化调度难扩展 |
+
+### 1.3 平台化目标
+
+把系统从"单工具、单拓扑、单策略"的专用工具,升级为:
+
+> **可插拔工具、可配置拓扑、可扩展策略、契约统一、单一事实源**的电机仿真自动化平台。
+
+新增一个工具/拓扑/策略时,只做"注册"和"实现适配器",不改平台核心。
+
+---
+
+## 2. 目标架构
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│                     共享核心层 src/afmcore/                       │
+│  (单一事实源,纯 Python,无 GUI/无 Web 依赖,三方共同引用)         │
+│                                                                    │
+│  metrics.py      指标定义 + 归一化解析器(唯一权威)                 │
+│  plan_schema.py  方案契约 V2(唯一权威)                            │
+│  topology.py     拓扑注册表(SSSR/DRSS/SDSR 参数体系+模板+规则)      │
+│  adapters/       仿真工具适配器(接口+注册表+实现)                  │
+│  strategies/     执行策略(full_factorial / adaptive)              │
+│  validation.py   结果校验规则集(可插拔判据)                        │
+└───────────────┬────────────────────┬────────────────┬──────────────┘
+                │                    │                │
+        ┌───────▼───────┐   ┌───────▼───────┐   ┌─────▼──────────┐
+        │  系统一 Web 端  │   │  系统二 本地端 │   │  GUI / 其他      │
+        │  backend 引用   │   │  executor 引用 │   │  工具脚本引用     │
+        │  src/afmcore  │   │  src/afmcore │   │  src/afmcore   │
+        └────────────────┘   └───────────────┘   └────────────────┘
+```
+
+### 2.1 分层职责
+
+| 层 | 职责 | 不许做 |
+|---|---|---|
+| 共享核心层 src/afmcore/ | 指标、契约、拓扑、适配器接口、策略接口、校验规则 | 不依赖 Motor-CAD/Web/GUI 具体实现 |
+| 系统一(Web) | 方案生成、AI、分析、知识库、任务调度 | 不直接调 Motor-CAD |
+| 系统二(本地) | 执行任务、驱动工具、落盘、回传 | 不依赖 AI(可离线) |
+| GUI | 加载方案、监控、展示 | 不跑仿真(子线程例外) |
+
+### 2.2 扩展点一览(平台性的落点)
+
+| 扩展点 | 新增方式 | 不改动 |
+|---|---|---|
+| 新仿真工具(Maxwell/JMAG/Flux) | 实现 `SimulationAdapter` + 注册 `tool_name` | 执行器/任务层/解析层 |
+| 新拓扑(DRSS/SDSR) | topology.py 注册参数体系 + 模型模板 + 默认规则 | 引擎核心 |
+| 新执行策略(adaptive) | strategies/ 实现策略接口 | 任务下发层 |
+| 新指标 | metrics.py 加一项(key/label/alias/direction) | 所有消费端自动生效 |
+| 新校验判据 | validation.py 加一个可插拔 rule | 执行器/方案端 |
+| 新物理场 | 适配器实现多物理场提取 + 指标扩展 | 架构 |
+
+---
+
+## 3. 第一批落地:共享核心层(本轮实施)
+
+> 优先解决 S1/S2/S3/S4,这是平台性的地基,也是数据正确性的根因。
+
+### 3.1 src/afmcore/metrics.py(单一事实源)
+
+- 合并三处指标定义,形成唯一 `METRIC_DEFINITIONS`(key/label/aliases/direction/required/unit)
+- 归一化解析器:全角括号→半角、去空白、小写,三段匹配(section 优先级→全 section 精确→前缀模糊)
+- 提供 `parse_export()` / `extract_all_metrics()` / `pick_metric()` 统一入口
+- 兼容导出:保留 `solver_core` / `robust_motorcad` / `metrics_constants` 的旧符号名(薄兼容层),避免一次性大改调用点
+
+### 3.2 src/afmcore/adapters/(工具适配器)
+
+```
+base.py      SimulationAdapter(ABC): connect/load_model/set_parameter/run_simulation/extract/disconnect
+registry.py  ADAPTER_REGISTRY: {tool_name: adapter_class},get_adapter(tool)
+motorcad.py  MotorCADAdapter: 包装 RobustMotorCADSolver,实现统一接口
+```
+
+- 执行器通过 `get_adapter("motorcad")` 获取,不再硬编码
+- 未来 `get_adapter("maxwell")` 只需注册新类
+
+### 3.3 消费端接入
+
+| 消费端 | 接入方式 |
+|---|---|
+| `src/solver_core.py` | 改为 `from .afmcore.metrics import *`,保留旧名导出 |
+| `scripts/robust_motorcad.py` | 删除自带 METRIC_DEFINITIONS/_parse_export,改用共享解析器(修复 tavg_nm/ripple) |
+| `web/backend/app/metrics_constants.py` | 从共享层派生(或保留 key 常量,标注权威源在共享层) |
+
+---
+
+## 4. 后续批次(待本批验证后启动)
+
+| 批次 | 内容 | 解决 |
+|---|---|---|
+| P2 | 拓扑注册表落地:topology.py 注册 SSSR 参数体系,DRSS/SDSR 预留 | S5 |
+| P2 | 方案 Schema 统一:以 src/afmcore/plan_schema.py 为权威,web schemas 转薄兼容层 | S6 |
+| P3 | 执行策略接入:strategies/adaptive 包装 feasibility_search,task_executor 支持 adaptive 模式 | S7 | ✅ 已落地(2026-08-29,M1-M6) |
+| P3 | 调度统一:本地执行器支持多实例并行 + 与 BatchScheduler 对齐契约 | S8 | ✅ 已落地(2026-08-29,M4/M3) |
+| P4 | 文档同步:原《设计方案介绍》升级 V2,消除与实现漂移 | — |
+| P4 | 本地 EXE 打包:PyInstaller 打包执行器 + GUI | 系统二交付物 |
+
+---
+
+## 5. 风险与约束
+
+| 风险 | 应对 |
+|---|---|
+| 重构破坏现有可用闭环 | 本批只做"共享层 + 接入",不改行为语义;接入后跑指标解析单元验证 + 回归导入测试 |
+| 三处接入遗漏 | 用 Grep 确认所有 METRIC_DEFINITIONS/_parse_export 引用点,逐一替换 |
+| web 端部署路径引用 src | Dockerfile/requirements 增加 src 路径映射(本批先保证本地运行,部署调整列入 P4) |
+| 纯 ASCII 纪律 | 新代码全 ASCII,中文用 \\uXXXX |
+
+---
+
+*本文档为设计决策稿,代码实现按 3.x 批次逐步落地并回填进度。*
+
+## 6. 实施进度回填
+
+| 批次 | 内容 | 状态 | 验证 |
+|---|---|---|---|
+| 第一批(2026-08-29) | src/afmcore/metrics.py(25项指标+归一化解析器);adapters/(SimulationAdapter+注册表+MotorCADAdapter);solver_core/robust_motorcad/metrics_constants 三端接入;修复 tavg_nm/ripple_pct 解析缺口 | ✅ 完成 | TEST-004:单元验证全 PASS、78文件编译0失败、三端+7本地+4Web 导入回归、纯ASCII |
+| 第二批(2026-08-29) | 拓扑注册表 topology.py(SSSR 参数体系 8组37项注册,DRSS/SDSR 预留)+ plan_schema 拓扑校验 + task_executor 切换 get_adapter + _compute_metrics 缺口修复 | ✅ 完成 | TEST-005:36 项断言全 PASS、79文件编译 0 失败、三端导入回归、纯ASCII、回归测试脚本 test_platform_registry.py 固化 |
+| 第三批 | 执行策略 strategies(full_factorial/lhs/adaptive)+ task 模型扩展 + AdaptiveOrchestrator + 执行器批次/多实例 + 调度契约统一 + Web 端执行桥(submit-batch) | ✅ 完成 | TEST-006~011,回归脚本 test_p3_*.py 全 PASS |
+| 第四批 | 方案 Schema 统一(plan_schema 单一权威)+ 文档同步(原方案 V1.1 → V2)+ EXE 打包 | 🔲 规划中 | — |

+ 2036 - 2
docs/TEST_RECORDS.md

@@ -2,7 +2,7 @@
 
 > 本文档记录 PCB 轴向磁通电机自动化仿真系统的所有测试记录,包括 Motor-CAD 仿真测试、API 测试、集成测试等。
 > 每次测试必须记录在此文档中(工作留痕)。
-> 最后更新:2026-08-28
+> 最后更新:2026-09-01
 
 ---
 
@@ -13,6 +13,27 @@
 | TEST-001 | 2026-08-28 | Motor-CAD 连接与变量探测 | ⚠️ 部分成功 | 发现 export_results API 兼容性问题、弹窗问题 |
 | TEST-002 | 2026-08-28 | Motor-CAD 全流程验证(修复后) | ✅ 全部成功 | 验证连接/计算/导出/解析全流程,弹窗问题解决 |
 | TEST-003 | 2026-08-28 | 扩展指标解析验证 | ✅ 全部成功 | 指标解析从7个提升到14个,平均转矩/转矩脉动仍需调查 |
+| TEST-004 | 2026-08-29 | 平台化改造单元验证(指标单一事实源+归一化解析+适配器框架) | ✅ 全部成功 | 修复 tavg_nm/ripple_pct 解析缺口;统一指标单一事实源 |
+| TEST-005 | 2026-08-29 | 平台化第二批(拓扑注册表+执行器适配器切换) | ✅ 全部成功 | 36 项断言全 PASS;79 个 .py 编译 0 失败 |
+| TEST-006 | 2026-08-29 | P3-M1/M2 策略抽象层 + 自适应编排器闭环 | ✅ 全部成功 | fake 执行器 + temp DB 隔离回归,exit 0 |
+| TEST-007 | 2026-08-29 | P3-M3 执行器批次化 + 多实例 | ✅ 全部成功 | point_id 回传 + 原子认领;P2 回归 36/36 不破坏 |
+| TEST-008 | 2026-08-29 | P3-M4 调度契约统一 | ✅ 全部成功 | 状态词汇归一(queued→pending 等),向后兼容 |
+| TEST-009 | 2026-08-29 | P3-M5 HTTP 全链路闭环 | ✅ 全部成功 | 闭环 8 点 budget_exhausted;91 个 .py 编译 0 失败 |
+| TEST-010 | 2026-08-29 | P3-M5 真实 Motor-CAD 烟雾 | ✅ 全部成功 | 连接/求解/解析 21 指标;back_emf=11.15V 与历史一致 |
+| TEST-011 | 2026-08-29 | P3-M6 Web 端 AdaptiveLoop 执行桥 | ✅ 全部成功 | fake plan→3 批 8 点;幂等;HTTP 端点 404/200 正常 |
+| TEST-012 | 2026-08-29 | P3 收尾(边界测试+全量回归+规范修复) | ✅ 全部成功 | 工程规范落地,全量回归绿 |
+| TEST-013 | 2026-08-29 | P3 遗留(原子认领+断点恢复+ASCII 纪律) | ✅ 全部成功 | 并发 8 线程恰 1 win;断点可恢复 |
+| TEST-014 | 2026-08-29 | P4-M1~M3(Schema 单一权威+文档 V2+EXE 打包) | ✅ 全部成功 | dist/PCB-AFM-Executor.exe 12.6MB 打包成功 |
+| TEST-015 | 2026-08-29 | P4-M4~M5(收敛曲线+L0 上提共享核心) | ✅ 全部成功 | points_history + L0 唯一实现(纯 stdlib) |
+| TEST-016 | 2026-08-30 | P5-M1 前端全量 build 类型错误清零 | ✅ 全部成功 | vue-tsc 0 错误 + vite build 成功(退出码 0) |
+| TEST-017 | 2026-08-30 | P5-M2 EXE 配置化 + mock 分支修复 + E2E mock | ✅ 全部成功 | executor_config.json 配置化;真实 COM 端到端待目标机 |
+| TEST-018 | 2026-08-30 | P5-M2 补充:真实 EXE 端到端单点验证 | ✅ 全部成功 | airgap_mm→Airgap 映射 + point_id 排除;数值与 TEST-010 一致 |
+| TEST-019 | 2026-08-30 | P5-M3 adaptive 可视化补全 | ✅ 全部成功 | 后端 4 新字段 + 前端 3 视图 + 8 测试 + build 绿 |
+| TEST-020 | 2026-08-30 | P5-M4 策略层高级管线 | ✅ 全部成功 | Morris + IDW 代理 + 预算自适应;Kriging 降级 IDW |
+| TEST-021 | 2026-08-30 | P5-M5 多工具适配器 | ✅ 全部成功 | 2 新适配器注册 + 22 测试;真实接入标注环境依赖 |
+| TEST-022 | 2026-08-30 | P5-M6 多物理场 L2 接入 | ✅ 全部成功 | 指标 25→35(热6+结构4);报告按域分组;20 测试 |
+| TEST-023 | 2026-08-30 | P6-M1 Web 前端 UI/UX 全面重构 | ✅ 全部成功 | 设计令牌+3公共组件+B1信息架构+B2 PlanDetail分层;vue-tsc 0错误 |
+| TEST-024 | 2026-09-01 | 环境体检脚本 check_machine_paths.py 验证 | ✅ 脚本可用(shell 环境报包缺失属预期) | 新增脚本:Python版本/环境变量/Motor-CAD/包/Git/资产全检;未发现项目 venv |
 
 ---
 
@@ -182,6 +203,2019 @@
 - [ ] TEST-003:多参数扫描测试(3 个磁钢弧角值),验证 `run_single_point()` 完整流程和逐点落盘
 - [ ] TEST-004:扩展指标解析验证(确认 tavg_nm、ripple_pct 等能正确解析)
 - [ ] TEST-005:批量调度器测试(BatchScheduler 多任务排队)
-- [ ] TEST-006:Web 端 API 集成测试(任务创建/下发/进度/结果回传)
+- [x] TEST-006:Web 端 API 集成测试(任务创建/下发/进度/结果回传)→ 见 TR-2026-08-29-01
 - [ ] TEST-007:断点续跑测试(中断后恢复)
 - [ ] TEST-008:长时间稳定性测试(50+ 仿真点)
+
+---
+
+## TR-2026-08-29-01 本地执行器全链路联调(Motor-CAD 真实仿真)
+
+- 测试日期:2026-08-29
+- 测试环境:本地 Win10 + Motor-CAD 2023R2 (v261) + pymotorcad 0.8.8
+- 测试目的:验证 Web 一键启动仿真 -> 本地执行器 -> Motor-CAD 真实磁计算 -> 结果回传 全链路
+- 测试方案:plan 21(SSSR_AxialFlux_300W_12V_5000rpm),扫描变量 Airgap 1->2mm,2点
+
+### 发现的问题与修复
+1. **执行器未运行**:本地执行器 `scripts/task_executor.py` 未启动,任务卡在 dispatched 0进度。
+   修复:新增 `scripts/run_task_executor.py` 启动入口,后台运行。
+2. **认领逻辑矛盾**:start-simulation 提前置 dispatched,执行器只拉 pending。
+   修复:`fetch_pending_tasks` 同时认领 pending + dispatched。
+3. **任务参数为空**:列表接口不返回 parameters,执行器拿不到参数集直接 0 点完成。
+   修复:新增 `_hydrate_task`,从 `/api/tasks/{id}/download` 拉取完整 task.json。
+4. **字符串参数强转 float 崩溃**:Magnet_Material/Cooling_Type 等 set_variable float() 报错。
+   修复:`robust_motorcad.run_single_point` 跳过非数值参数。
+5. **变量名不匹配(Could not find Outer_Rotor_Diameter)**:模板变量名非真实 Motor-CAD 变量。
+   修复:模板加 `motorcad_var` 字段(从 .mot 提取真实变量名),`_expand_plan_to_parameters`
+   按模板合并生成参数集,仅写 motorcad_var 非空项。Slot_Depth 等默认值对齐基线模型。
+6. **AFM_D_Rotor 改外径破坏线性几何(aLinearRadius=0)**:D76 基线模型改外径后磁计算失败。
+   修复:Outer_Rotor_Diameter 的 motorcad_var 置 None(外径作设计约束不写入,用基线几何)。
+7. **执行器心跳缺失**:/api/executor/status 显示无执行器。
+   修复:执行器 poll 循环加 `_send_heartbeat` 注册,前端可显示在线/进度。
+
+### 测试结果(最终)
+- 任务 a7123abf:completed,2/2 点 OK,耗时 307s
+- 点0(Airgap=1.0mm):back_emf 11.15V,stall_torque 9.364Nm,em_power 273.3W,input 300.9W,loss 41.9W
+- 点1(Airgap=2.0mm):back_emf 8.71V,stall_torque 7.52Nm,em_power 219.5W,input 240.1W,loss 33.6W
+- 物理规律验证:气隙增大 -> 反电动势/转矩下降(正确)
+- 输出目录:web/backend/output/tasks/20260829_134803_SSSR_AxialFlux_300W_12V_5000rpm_Optimization_run/
+
+### 遗留问题
+- 字符串参数(材料/冷却方式/绝缘等级)暂不写入 Motor-CAD(set_variable 需数值),用基线默认
+- 外径等几何约束需在 Motor-CAD 内调整几何后才可改(当前用基线几何)
+
+---
+
+## TEST-004:平台化改造单元验证(指标单一事实源 + 归一化解析 + 适配器框架)
+
+**日期**:2026-08-29
+**环境**:Windows 10 / 11,Python 3.x,本地工作区
+**目的**:验证平台化改造第一批的正确性与回归安全性
+**依据**:docs/PLATFORM_DESIGN_V2.md(平台化升级设计方案)
+
+### 改造内容
+1. 新建共享核心层 src/afmcore/metrics.py:指标定义单一事实源(25 项) + 归一化解析器(全角括号→半角、去空白、小写)
+2. 三个消费端接入:solver_core / 
+obust_motorcad / metrics_constants 全部改为从共享层导入(消除三处漂移)
+3. 新建适配器抽象:src/afmcore/adapters/ 下 SimulationAdapter 接口 + 注册表 + MotorCADAdapter 实现
+
+### 验证结果(全部 PASS)
+| 项 | 结果 | 详情 |
+|---|---|---|
+| 归一化解析(含全角字符) | ✅ | 模拟 Motor-CAD 导出含全角空格/全角括号/全角[],tavg_nm、ripple_pct、efficiency_pct、total_losses_w、back_emf_v 全部解析成功 |
+| 中文别名匹配 | ✅ | 中文字段(平均转矩/转矩脉动/系统效率)能正确匹配 |
+| % 守卫 | ✅ | [%] 字段不再污染 Nm 值指标(ripple_abs_nm/ripple_nm 不被 ripple_pct 字段污染) |
+| 三端导入回归 | ✅ | solver_core(25)/robust_motorcad(25)/metrics_constants(25) 三端持久化同源,导入成功 |
+| 本地端关键模块 | ✅ | solver_core/scan_engine/experience_db/robust_motorcad/task_executor/run_single/run_scan 7/7 导入 OK |
+| Web 端引用模块 | ✅ | metrics_constants/plans/result_analyst/task_manager 4/4 导入 OK |
+| 全量编译 | ✅ | 78 个 .py 全部 py_compile 通过,0 失败 |
+| 纯 ASCII 约束 | ✅ | 新写代码全部 ASCII,无非 ASCII 行 |
+| 适配器注册表 | ✅ | import 即注册 motorcad;未注册工具报清晰 KeyError |
+| 适配器协议 | ✅ | run_point/extract_metrics 输出 schema 与设计一致 |
+
+### 关键发现(修复 TEST-003 遗留问题)
+1. **tavg_nm/ripple_pct 解析不到的根因:**
+obust_motorcad._parse_export 使用无归一化的精确字符串匹配,Motor-CAD 导出字段名含全角空格/括号时匹配失败。已改为共享层归一化匹配。
+2. **指标定义三处漂移:**solver_core(15)/robust_motorcad(18)/metrics_constants(25) 各持一份,已统一为 afmcore.metrics(25)。
+3. **名命冲突:**platform 与标准库同名,已将共享包更名为 afmcore。
+
+### 遗留事项
+- MotorCADAdapter 已通过协议单元验证(FakeSolver);真实 Motor-CAD 连接回归待下一批(P2)
+- task_executor 切换到 get_adapter 模式待 P2 落地(当前直接使用 RobustMotorCADSolver,已受益于共享解析器)
+- Web 部署环境需确保 src/afmcore 可导入(Dockerfile 调整待 P4)
+
+---
+
+## TEST-005:平台化改造第二批(拓扑注册表 + 执行器适配器切换)
+
+**日期**:2026-08-29
+**环境**:Windows 10 / 11,Python 3.x,本地工作区(无真实 Motor-CAD 启动)
+**目的**:验证 P2 拓扑注册表与执行器通过适配器注册表驱动,消除“拓扑裸字符串”与“求解器硬编码”
+
+### 改造内容
+1. 新建 `src/afmcore/topology.py`:拓扑注册表(SSSR 完整参数体系 8 组 37 参数 + DRSS/SDSR 预留),提供 get_topology / is_supported / is_active / validate_params / to_dict
+2. `src/plan_schema.py`:validate() 集成拓扑注册校验(未知拓扑报错)
+3. `scripts/task_executor.py`:MotorCADTaskExecutor 从硬编码 RobustMotorCADSolver 切换为 `afmcore.adapters.get_adapter(tool)`;结果 metrics 扁平化到顶层,修复 _compute_metrics 取不到嵌套 metrics 的缺口
+
+### 验证结果(全部 PASS,36 项)
+| 项 | 结果 | 详情 |
+|---|---|---|
+| 拓扑注册表 | ✅ 22 项 | 注册/查询(大小写不敏感)/参数体系(SSSR 37参数8组)/序列化/自定义注册幂等覆盖 |
+| 拓扑校验集成 | ✅ 4 项 | plan_schema.validate:SSSR 通过、TORUS 拒绝、DRSS 接受(已注册)、序列化往返 |
+| 适配器注册表 | ✅ 4 项 | motorcad 注册、get_adapter 返回协议实例、未注册工具 KeyError |
+| 执行器适配器路径 | ✅ 6 项 | OK 点扁平化、_compute_metrics 取到 tavg_nm_mean、FAILED 点抛异常、execute_task 3 点全链路、cleanup 断开 |
+| 全量编译 | ✅ | 79 个 .py 全部 py_compile 通过,0 失败 |
+| 纯 ASCII | ✅ | 新增/修改代码全部 ASCII(topology.py / plan_schema.py / task_executor.py / test_platform_registry.py) |
+| 导入回归 | ✅ | afmcore(拓扑/指标/适配器) + src(plan_schema/solver_core) + scripts(robust_motorcad/task_executor) + web(metrics_constants) 全部 OK |
+
+### 关键发现
+1. **拓扑裸字符串 → 注册表**:plan.validate 现在拦截未知拓扑(如 TORUS);DRSS/SDSR 已注册(DRSS planned);扩展新拓扑只做 register_topology。
+2. **_compute_metrics 现存缺口修复**:RobustMotorCADSolver 返回 {status, metrics:{...}} 嵌套结构,而 _compute_metrics 从顶层取 tavg_nm,导致聚合永远为空。已通过适配器层扁平化 metrics 到顶层修复。
+3. **测试资产固化**:新增 scripts/test_platform_registry.py 可重复运行的回归测试(36 项断言),后续批次可在此基础上扩展。
+
+### 遗留事项
+- 真实 Motor-CAD 连接回归(含 DRSS 建模后):需要实际 .mot + 授权环境
+- DRSS 参数体系待建模后补全(当前仅预留几何/转子核心参数)
+- fixed_params_template.py 仍为单一模板(SSSR),按拓扑选模板列入 P4 方案 Schema 统一
+- 执行策略接入(P3 adaptive)+ 调度统一(P3 多实例并行)
+
+---
+
+## TEST-006:P3 平台化改造(M1 策略抽象层 + M2 自适应编排器闭环)
+
+**日期**:2026-08-29
+**环境**:Windows,Python 3.x,本地工作区(无真实 Motor-CAD 启动;闭环用 fake 执行器)
+**目的**:验证 P3 第一批:执行策略抽象层(M1)与自适应编排器全闭环(M2),为「web 方案 -> 批次任务 -> 本地执行 -> 回填搜索 -> 续批/收敛」打通。
+
+### M1:执行策略抽象层(src/afmcore/strategies/)
+1. 新建 SimulationStrategy ABC + STRATEGY_REGISTRY(get_strategy / list_strategy_kinds / is_registered / register_strategy),执行器改为向策略要批次而非硬编码全因子
+2. full_factorial.py:变量值列表笛卡尔积,自包含实现(不跨包 import scan_engine)
+3. lhs.py:纯 Python 拉丁超立方采样(无 numpy),空间填充初始覆盖
+4. adaptive.py:AdaptiveBridgeStrategy,backend 注入协议(generate_initial_batch / select_next_batch / report_result),共享核心层不依赖 web
+5. src/plan_schema.py:SearchStrategy.method 归一化(active_learning/constrained -> adaptive)+ validate() 按策略注册表校验(未知策略如 random_forest 拒绝)
+
+### M2:任务模型扩展 + 自适应编排器
+1. models/task.py 新增 task_type / loop_id / batch_id / point_ids / dynamic 字段;database.py 迁移逻辑为旧 tasks 表 ADD COLUMN(已验证旧库自动补列)
+2. 	ask_manager.py create_task 支持新字段(task.json 负载 + ORM + dict 输出透传)
+3. 新建 strategy_orchestrator.py:AdaptiveOrchestrator(start_loop / advance_loop / get_loop_status / list_loops),桥 FeasibilityFirstSearch <-> 任务系统 <-> 本地执行器;loop 状态落盘 output/adaptive_loops/
+4. **修复现存 bug**:task_manager.report_results 引用未定义 plan_id(应为 task.plan_id),导致带 plan 的任务完成时报 NameError
+
+### 验证结果(全部 PASS)
+| 项目 | 结果 | 详情 |
+|---|---|---|
+| 策略注册表 | 通过 | 冒烟:adaptive/full_factorial/lhs 注册、FF 分批收敛、LHS 采样范围、adaptive 桥回传、无 backend/未知策略报错 |
+| plan_schema 策略校验 | 通过 | 别名 active_learning/constrained -> adaptive;random_forest 拒绝;序列化往返归一化 |
+| 任务模型扩展 | 通过 | 新字段 create_task 透传;_task_to_dict 输出 point_ids/dynamic/task_type |
+| DB 迁移 | 通过 | 旧 tasks 表 init_db 自动 ADD COLUMN 5 字段 |
+| orchestrator 闭环 | 通过 | fake 执行器驱动:首批 3 点 -> 回填 -> 续批 -> budget_exhausted 收敛,n_results=8,状态持久化,重复 loop 拒绝 |
+| 编译/ASCII | 通过 | 新改 4 文件 py_compile 通过、纯 ASCII |
+| 回归脚本 | 通过 | scripts/test_p3_orchestrator.py 入库,exit 0 |
+
+### 关键发现
+1. **L0 参数名对齐**:FeasibilityFirstSearch 的可行性预筛依赖参数名与 L0 引擎期望一致(airgap_mm / current_a 等,web 端既有路径同样直接透传);扫描参数名不匹配会全判 infeasible 导致空批次假收敛。调用方须传 L0 对齐名(真实 AI plan 生成即如此)。
+2. **闭环时序**:orchestrator 采用 pull 驱动(advance_loop 由调用方/路由/调度器触发),与既有执行器轮询哲学一致,task 系统零侵入。
+3. **状态可恢复**:loop 元数据 + search export 落盘 JSON,进程重启可恢复元数据。
+
+### 遗留事项
+- search 路由 / 定时器接入 orchestrator(M5,避免动用户未提交的 main.py)
+- 真实 Motor-CAD 烟雾测试(M5,需授权环境)
+- adaptive 前端视图(P4)
+
+---
+
+## TEST-007:P3 平台化改造(M3 执行器批次化 + 多实例)
+
+**日期**:2026-08-29
+**环境**:Windows,Python 3.x,本地工作区(无真实 Motor-CAD;mock 执行器)
+**目的**:验证执行器支持 adaptive_batch 任务的 point_id 回传、多实例并行的认领原子化、以及独立 executor_id。
+
+### 改动内容(scripts/task_executor.py)
+1. **point_id 回传**:execute_task 逐点跑完后,若参数含 point_id 则透传到结果顶层(成功与 FAILED 分支均保留),供 orchestrator 按 point_id 回填搜索
+2. **认领原子化**:dispatch_task 返回 False(任务已被其他实例认领/网络失败)时跳过该任务,多实例并行不会重复仿真同一任务
+3. **executor_id 唯一化**:默认 pid+随机后缀,支持显式传入(多实例各自唯一)
+4. **修复现存 bug**:report_results 本地文件模式调用 on_complete 传 2 参数,与 execute_task 末尾的 3 参数签名不一致,导致本地模式完成时 TypeError;统一为 3 参数
+
+### 新增
+- scripts/run_task_executor_parallel.py:--instances N 并行启动 N 个 TaskExecutor(各自唯一 executor_id,认领原子化防重复),--mock 供测试
+
+### 验证结果(全部 PASS)
+| 项目 | 结果 | 详情 |
+|---|---|---|
+| point_id 透传 | 通过 | OK 与 FAILED 点均在结果顶层带 point_id |
+| 认领原子化 | 通过 | dispatch 返回 False 时任务被跳过(不执行) |
+| executor_id 唯一 | 通过 | 默认实例各不相同;显式传入生效 |
+| P2 回归 | 通过 | test_platform_registry.py 36/36 不回归 |
+| M3 回归 | 通过 | scripts/test_executor_m3.py 入库,exit 0 |
+| 编译/ASCII | 通过 | 3 文件 py_compile 0 失败、纯 ASCII |
+
+### 关键发现
+1. 多实例并行依赖 Web 端 dispatch 的幂等语义(pending->dispatched 原子迁移),执行器侧只需在 claim 失败时跳过即可防重复。
+2. 本地文件模式与 Web 模式在 on_complete 回调签名上曾不一致(2 vs 3 参数),已统一。
+
+### 遗留事项
+- 真实 Motor-CAD 多实例烟雾测试(M5,需授权环境;注意 license 并发限制)
+- adaptive 前端视图(P4)
+
+---
+
+## TEST-008:P3 平台化改造(M4 调度契约统一)
+
+**日期**:2026-08-29
+**环境**:Windows,Python 3.x,本地工作区(无 web 服务/Motor-CAD)
+**目的**:统一两套调度状态词汇(TaskManager 的 pending/dispatched 与 BatchScheduler 的 queued),并让 BatchScheduler 任务字段与 Task ORM 对齐(M2 新增的 adaptive-batch 字段)。
+
+### 改动内容
+1. 新建 web/backend/app/services/task_contract.py(无依赖,供各服务引用):
+   - 规范状态常量 + STATUS_ALIASES 归一化映射(queued->pending、canceled->cancelled、completed_with_errors->completed 等)
+   - normalize_status / is_terminal / merge_adaptive_fields(task_type/loop_id/batch_id/point_ids/dynamic 默认值注入)
+2. batch_scheduler.py:add_task 增加 task_type/loop_id/batch_id/point_ids/dynamic 参数透传;_summary 输出新字段;状态词保持 queued(向后兼容)但经契约归一
+
+### 验证结果(全部 PASS)
+| 项目 | 结果 | 详情 |
+|---|---|---|
+| 状态归一 | 通过 | queued->pending、canceled->cancelled、completed_with_errors->completed、未知词透传、终态判定 |
+| 字段合并 | 通过 | merge_adaptive_fields 默认值(scan/False/[]/None)与显式值 |
+| scheduler 字段对齐 | 通过 | add_task 新字段透传、_summary 输出、legacy 调用不破坏、queued->running 流转、状态落盘 |
+| 回归 | 通过 | test_p3_orchestrator.py(M2 闭环)不回归;test_p3_m4_contract.py 入库 exit 0 |
+| 编译/ASCII | 通过 | 3 文件 py_compile 0 失败、纯 ASCII |
+
+### 关键发现
+1. G5「调度契约两套」本质:BatchScheduler 只服务调度监控 UI(monitor.py),从未与 TaskManager/执行器流转对接;本次用契约层声明统一词汇与字段,消除漂移,而不重构两个既有服务。
+2. status 归一采用「别名映射 + 未知词透传」,与 plan_schema 策略校验的哲学一致(能报错而非静默)。
+
+### 遗留事项
+- monitor 前端展示 scheduler 的 queued 仍沿用旧词(后续可经 normalize_status 归一展示)
+- 真实 Motor-CAD 烟雾(M5)
+
+---
+
+## TEST-009:P3 平台化改造(M5 HTTP 全链路闭环 + 真实烟雾待办)
+
+**日期**:2026-08-29
+**环境**:Windows,Python 3.x;临时 SQLite DB + uvicorn 起的真实 FastAPI 后端;本地 mock 执行器(无真实 Motor-CAD)
+**目的**:验证 P3 全链路真实 HTTP 闭环:orchestrator -> 任务 -> 本地执行器(HTTP 轮询) -> 仿真 -> 回传(point_id) -> 回填搜索 -> 续批 -> 收敛。
+
+### 测试方案(scripts/test_p3_closed_loop.py,exit 0)
+1. 临时 DB 上启动真实 FastAPI web(uvicorn,端口 8137),等 /api/monitor/health
+2. 测试进程内 AdaptiveOrchestrator.start_loop(airgap_mm/current_a,预算 8 批 4)
+3. 真实 TaskExecutor(enable_mock)后台轮询 web,认领 adaptive_batch 任务
+4. 执行器逐点 mock 仿真 -> report_results(HTTP,含 point_id)
+5. 测试驱动 advance_loop:批次完成 -> 回填 -> 续批 -> 预算耗尽
+6. 验证 + 清理(停执行器/停 web)
+
+### 验证结果(全部 PASS)
+| 项目 | 结果 | 详情 |
+|---|---|---|
+| web 启动 | 通过 | temp DB + uvicorn 健康检查 OK |
+| orchestrator 建任务 | 通过 | task_type=adaptive_batch,首批准 3 点 |
+| 执行器 HTTP 认领 | 通过 | 轮询 pending/dispatched -> dispatch -> 执行 |
+| point_id 回传 | 通过 | report_results 结果带 point_id,搜索按 point_id 回填 |
+| 续批/收敛 | 通过 | 3+4+1 点 -> budget_exhausted,n_results=8 |
+| 全量回归 | 通过 | P2 36 项 + M2 + M3 + M4 全部 PASS;91 个 .py 编译 0 失败 |
+| 退出码 | 通过 | exit 0(无 AI 噪音:orchestrator 无 KIMI_API_KEY 时跳过 AI 分析) |
+
+### 关键发现
+1. **orchestrator AI 分析门控**:无 KIMI_API_KEY 时每次 advance 都调 AI 会打 stderr warning 且浪费;改为配置了 key 才调用,保持 quantitative 结果。真实配置 key 后自动启用 AI 分析。
+2. **SQLite 多进程共享**:测试进程(orchestrator) + web 进程(TaskManager) + 执行器(HTTP) 三方共享同一 temp DB 文件,闭环正常。
+
+### 遗留事项
+- **真实 Motor-CAD 烟雾**(环境已确认:MOTORCAD_ACTIVEX + license + exe + 基线模型均在):计划在提交本批后用最小点数验证 MotorCADAdapter 真实求解通路,结果续记 TEST-010
+
+---
+
+## TEST-010:P3 平台化改造(M5 真实 Motor-CAD 烟雾)
+
+**日期**:2026-08-29
+**环境**:Windows + Motor-CAD v261(MOTORCAD_ACTIVEX 已设、license 1055@localhost、exe 存在);基线模型 MARS-12S10P_SSSR_D76-C150_V5.0-0819.mot
+**目的**:验证 MotorCADAdapter 真实求解通路:连接 -> 基线加载 -> 求解 1 点 -> 导出/解析指标 -> 断开。
+
+### 发现并修复的 bug
+**MotorCADAdapter.run_point 忽略 model_path 参数**:run_point(model_path=...) 内部 _ensure_solver() 用默认空路径创建 RobustMotorCADSolver,且从不把传入 model_path 同步给 solver,导致 load_from_file('') 报 "Motor-CAD File does not exist"。修复:run_point 在 connect 前将 model_path 同步到 solver.model_path。
+
+### 验证结果(PASS)
+| 项目 | 结果 | 详情 |
+|---|---|---|
+| 连接 | 通过 | open_new_instance + set_visible |
+| 基线加载 | 通过 | load_from_file 真实 .mot |
+| 求解 | 通过 | 电磁计算 146.3s(单点) |
+| 指标解析 | 通过 | 21 项指标(tavg_nm/ripple/efficiency/back_emf/...) |
+| 解析正确性 | 通过 | back_emf=11.15V 与 TR-2026-08-29-01 完全一致 |
+| 关键指标 | - | tavg_nm=0.52187、efficiency=86.06%、total_losses=41.95W |
+| 输出 | - | output/smoke_p3_m5/(不入库) |
+
+### 关键发现
+1. adapter 层(MotorCADAdapter)此前仅经 FakeAdapter 协议验证,真实求解通路由本次烟雾打通并暴露 run_point 路径 bug——印证「真实烟雾不可省」。
+2. 解析器归一化(afmcore.metrics)在真实导出上正确工作,与历史 TR-01 数值一致。
+
+### 遗留事项
+- 真实 adaptive 完整循环(多批多实例 + 参数写入)留待后续验证
+- 参数写入的真实变量名映射(Airgap 等)沿用 TR-01 已验证方案
+---
+
+## TEST-011:P3 平台化改造(M6 Web 端 AdaptiveLoop 执行桥 + 集成闭环)
+
+**日期**:2026-08-29
+**环境**:临时 SQLite DB(AFM_DB_PATH)+ KIMI_API_KEY=""(无 AI 后端,纯定量)
+**目的**:把 Web 端已验收的 adaptive 搜索(AdaptiveLoop)接到本地执行器:批次 -> adaptive_batch Task -> 执行器回传 -> report-results 回填 -> 续批 -> 收敛。验证新提交桥 submit_batch_to_executor + 新端点 POST /api/adaptive/loops/{id}/submit-batch。
+
+### 验证结果(PASS)
+| 项目 | 结果 | 详情 |
+|---|---|---|
+| 闭环驱动 | 通过 | fake plan -> initialize_search(3点) -> submit-batch -> 3 批 -> budget_exhausted,8 点 / 8 预算 |
+| Task 落库 | 通过 | 每批 1 个 adaptive_batch Task,含 loop_id/batch_id/point_ids/dynamic=True |
+| point_ids 一致性 | 通过 | 初始批次 ids=[0,1,2] 与 task point_ids 一致 |
+| 幂等性 | 通过 | 无 pending 批次时 submit 返回 task_id=None,不产生重复 Task |
+| HTTP 端点 | 通过 | POST /api/adaptive/loops/{id}/submit-batch:404(未找到) / 200(task_id) |
+| 全量回归 | 通过 | P2 36 项 + M2/M3/M4/M5/M6 全绿;91 .py 编译 0 失败 |
+
+### 架构整合说明
+项目存在两套 adaptive 循环(均为已验收):
+1. **AdaptiveLoop(Web 端,08-27)**:AI 方案 -> L0 -> FeasibilityFirstSearch -> 选批 -> report-results 回填 -> AI 分析 -> 经验库 -> 收敛;缺"本地执行器执行"环节。
+2. **AdaptiveOrchestrator(平台层,08-29)**:参数化驱动,桥搜索 <-> 任务系统 <-> 本地执行器(批次 Task、point_id 回填)。
+本次给 AdaptiveLoop 补 submit_batch_to_executor()(复用 task_manager 原语),打通"Web 智能层 + 本地执行层",未改 AdaptiveLoop 既有方法,未新建冲突路由(已撤销误建的 adaptive_orchestrator 路由)。
+
+### 遗留事项
+- 真实 Motor-CAD 多批 adaptive 循环(submit-batch -> 执行器真实求解 -> report-results)待环境就绪验证
+- 前端 adaptive 循环视图(批次/Task/回填状态展示)属 P4
+---
+
+## TEST-012:P3 收尾(工程规范落地 + 单元边界测试 + 全量回归 + 规范修复)
+
+**日期**:2026-08-29
+**环境**:临时 SQLite DB + 无 AI 后端;P4 验收套件在项目 web 环境内运行
+**目的**:P3 收尾自查——按新工程规范(禁止臆测/测试完备/代码规范/自查清单)核对 P1~P3 平台化批次,补齐边界/异常/空值单元测试,修复验收暴露的规范违规,同步文档状态。
+
+### 新增测试
+| 测试 | 覆盖 | 结果 |
+|---|---|---|
+| scripts/test_p3_unit_edge.py | 策略层(注册/未注册/空kind/坏类/别名归一/空points/分批/收敛/batch_size=0)、orchestrator(空参数/重复loop/缺失loop/批次未完成不推进)、AdaptiveLoop.submit 未初始化 RuntimeError、task_manager(缺失返回None/空参数) | PASS |
+
+### 修复的规范违规
+- **deploy.ps1 UTF-8 BOM(0xfeff)**:P4 验收套件 `deploy.ps1 is ASCII-only` 抓出;去掉 BOM 后 P4 验收复跑 **37 passed / 0 failed**。
+
+### 全量回归结果
+| 套件 | 结果 |
+|---|---|
+| test_platform_registry.py(P2) | PASS 36 项 |
+| test_p3_orchestrator.py(M2) | PASS |
+| test_executor_m3.py(M3) | PASS |
+| test_p3_m4_contract.py(M4) | PASS |
+| test_p3_closed_loop.py(M5) | PASS |
+| test_p3_adaptive_execution.py(M6) | PASS |
+| test_p3_unit_edge.py(新增) | PASS |
+| test_p4_acceptance.py(P4 验收,BOM 修复后) | 37 passed / 0 failed |
+
+---
+
+## TEST-023:P6-M1 Web 前端 UI/UX 全面重构
+
+**日期**:2026-08-30
+**环境**:Windows 10,Node v20.20.2,npm 10.8.2,Vue 3.3 + TypeScript 5.5 + Element Plus 2.4 + Vite 5
+**目的**:参考 SimScale / Ansys 等在线仿真工具设计语言,完成 B1(信息架构)+ B2(PlanDetail 分层)两批次前端重构,解决信息过载、导航混乱、参数命名不一致、视觉重复等问题
+**测试方式**:静态代码审查 + `npm run build`(vue-tsc 类型检查 + vite 生产构建)
+
+### 变更清单
+
+| 类别 | 文件 | 变更内容 |
+|---|---|---|
+| 全局样式 | `src/style.css` | 重写为设计令牌系统(CSS 变量):主色 #2563eb、中性灰阶、8px 网格、统一圆角/阴影/过渡;Element Plus 主题覆盖 |
+| 公共组件 | `src/components/StatCard.vue` | 新建,统一统计卡片(消除 4 处重复手写) |
+| 公共组件 | `src/components/SectionCard.vue` | 新建,统一内容区块卡片 |
+| 公共组件 | `src/components/PageHeader.vue` | 新建,统一页面头部 |
+| B1 布局 | `src/layouts/MainLayout.vue` | 侧边栏 5 组工作流导航 + AI 高级功能折叠 + 面包屑层级链 + 全局任务状态条 + 侧边栏折叠 + 页面过渡动画 |
+| B2 核心页 | `src/views/PlanDetail.vue` | 1129 行长卷重构为 4 Tab(概览/方案参数/仿真结果/AI闭环);固定参数默认折叠只显示修改项;运行中进度横幅;AI 闭环步骤向导 |
+| 页面优化 | `src/views/ProjectList.vue` | PageHeader + StatCard + SectionCard + 搜索/拓扑筛选 + 创建弹窗双列布局 |
+| 页面优化 | `src/views/Dashboard.vue` | PageHeader + StatCard + ECharts 趋势/Pareto 图 + 结果表 + CSV 导出 |
+| 页面优化 | `src/views/TaskManager.vue` | 6 项状态统计卡 + 状态筛选 + 创建任务从方案下拉选择(JSON 降为高级折叠)+ 详情抽屉优化 |
+
+### 测试步骤与结果
+
+| 步骤 | 内容 | 结果 | 详情 |
+|---|---|---|---|
+| 1 | 全局设计令牌定义 | ✅ 通过 | CSS 变量覆盖主色/语义色/间距/圆角/阴影/字体/侧边栏/布局 8 大类 |
+| 2 | 公共组件创建 | ✅ 通过 | StatCard/SectionCard/PageHeader 三组件含 props/slots/类型定义 |
+| 3 | MainLayout 重构 | ✅ 通过 | 5 组导航渲染正常;AI 高级功能折叠/展开;面包屑随路由动态生成;全局任务状态 10s 轮询 |
+| 4 | PlanDetail Tabs 重构 | ✅ 通过 | 4 Tab 切换正常;固定参数修改项检测逻辑;分类折叠;AI 闭环步骤指示器 |
+| 5 | vue-tsc 类型检查 | ✅ 通过 | 0 错误(修复了 NavItem 联合类型、fixedCategories unknown[]、p.value string\|number、api.planApi 引用等 6 类类型问题) |
+| 6 | vite 生产构建 | ✅ 通过 | 2283 模块转换,11.62s 构建完成;业务 chunk 13~26kB,vendor 库独立分包 |
+| 7 | 构建产物体积 | ⚠️ 警告 | vendor-echarts 1042kB / vendor-element 948kB 超 500kB 警告(既有问题,非本次引入,建议后续 echarts 按需引入) |
+
+### 关键设计决策
+
+1. **主色选择 #2563eb(blue-600)**:比 Element Plus 默认 #409eff 更深沉专业,符合工程仿真工具调性;侧边栏用 #0f172a(slate-900)深色,与内容区浅灰形成对比
+2. **PlanDetail 固定参数折叠策略**:默认只显示"值与默认模板不同"的参数(modifiedFixedParams),其余按分类折叠;36 项参数中通常只有个位数需要工程师关注
+3. **预估耗时校准**:由硬编码 points×3min 改为 points×2.5min,贴近 README 记录的真实单点 90~150s
+4. **AI 闭环三步向导**:用 el-steps 展示 分析→迭代→提取 进度,每步独立卡片,按钮按依赖关系禁用(无结果不能分析,无分析不能迭代)
+5. **任务创建去 JSON 化**:主流程改为从方案下拉选择自动带出,JSON 输入降为"高级选项"折叠,降低工程师使用门槛
+
+### 遗留事项
+
+- B3(参数目录单一事实源):当前前端仍有 SCAN_PARAM_CN / categoryCnMap / BC_TEMPLATE / ALL_FIXED_PARAM_TEMPLATE 四处手写参数目录,需前后端协同统一为后端权威目录
+- B4(流程引导):ProjectDetail 步骤条可交互化、仿真前检查清单(含 5 个未确认变量名)、真实单点耗时回写校准
+- 前端实际渲染效果未在浏览器人工点检(本次为代码重构 + build 验证),建议 `npm run dev` 后人工走查核心流程
+- echarts / element-plus 全量引入导致 vendor chunk 过大,后续可按需引入优化首屏
+| 全量编译 | 93 .py 0 失败 |
+
+### 环境依赖(未独立运行,如实标注)
+- test_api_client.py:需 web 服务运行于 127.0.0.1:8000
+- test_robust_solver.py:需真实 Motor-CAD 连接与求解
+
+### 已知遗留
+- **ASCII 纪律未完全达标**:web/backend/app/routers/plans.py(164 字符)、services/fixed_params_template.py(44 字符)含中文字符串(用户 08-27 在途文件),建议 P4 前转 \uXXXX 或移入文档(本轮未动,避免改动用户文件引入风险)。
+- 参考案例目录(axial_mag_pull-master / torqrippswap-master)非 ASCII 属第三方代码,不在验收范围。
+- 文档同步已完成:PLATFORM_DESIGN_V2 第三批标记完成、README P5 平台化表拆分(第三批完成/第四批规划)。
+
+## TEST-013:P3 遗留处理(并发原子认领 + 断点恢复 + ASCII 纪律 + 环境依赖测试)
+
+**日期**:2026-08-29 **环境**:Windows / Python / 常驻 uvicorn(8000)+ 常驻执行器在跑(未干扰)
+
+**处理项**:
+
+1. **并发原子认领**:`task_manager.dispatch_task` 由"读-改-写"改为 SQLAlchemy 条件 UPDATE(`WHERE status='pending'` + rowcount 判定),多执行器竞争同一任务恰好一次成功。
+   - 测试 `scripts/test_p3_concurrency.py`:8 线程竞争同一 pending 任务 → 恰 1 win / 7 lost(ValueError);顺序二次认领拒绝;HTTP 级竞争同样恰 1 win。
+2. **断点恢复**:`FeasibilityFirstSearch.import_state()` + `AdaptiveLoop.export_state()/restore_state()` + `GET /loops/{id}/export`、`POST /loops/import` 端点。
+   - 测试 `scripts/test_p3_checkpoint.py`:run_id/used_budget/points/objective 一致,恢复后可继续 select_next_batch。
+3. **ASCII 纪律**:`plans.py`(14 中文串)+ `fixed_params_template.py`(86 中文 label)转 `\uXXXX`;运行解码正确(KIMI 空 key 纯定量降级 200)。
+4. **环境依赖测试**:`test_api_client.py` 用临时 DB + 后台 uvicorn 跑通真实链路;未杀用户常驻服务(PID 30496/39420 原样保留);真实 Motor-CAD 占用 license,robust 未改动不重跑,维持标注。
+
+**验证结果**:全部 PASS;全量回归基线绿(P2 36 / M3 / M4 / closed_loop / adaptive / unit_edge / p4_acceptance 37 / concurrency / checkpoint)。
+
+## TEST-014:P4-M1~M3(方案 Schema 单一权威 + 文档 V1.1→V2 + EXE 打包)
+
+**日期**:2026-08-29
+
+- **M1 Schema 统一**:`src.plan_schema.py` 增强(`parse_plan`/`validate_plan_dict` 入口 + `ScanVariable.from_dict` 容忍 min_value/max_value 别名 + `validate(require_model_path)` 分级);web 端 main.py 注入 repo root,plans/ai_plan 接入校验(400/422 拒绝非法 plan_data)。
+  - 测试 `scripts/test_p4_schema.py`:7 组全过(happy/别名/空值/异常/分级/拓扑策略/web 接入)。
+- **M2 文档 V1.1→V2.0**:设计方案追加"附录 B 实现现状对照"(afmcore 共享核心 / Schema 权威 / 双系统解耦 / 策略实现 vs 蓝图 / adaptive 闭环 / 可靠性 / 已知限制),版本表与 README 引用同步。
+- **M3 EXE 打包**:`run_task_executor.py` 加 `--version`/`--self-test`;`scripts/build_executable.ps1`(PyInstaller onefile,paths=src+root,collect-all ansys.motorcad)。
+  - 产物 `dist/PCB-AFM-Executor.exe`(12.6MB):`--version` 与 `--self-test`(mock 单点 status=OK)均通过。真实 Motor-CAD COM 连接依赖 license,不在打包自检内(标注环境依赖)。
+
+## TEST-015:P4-M4~M5(前端 adaptive 收敛曲线 + L0 上提共享核心层)
+
+**日期**:2026-08-29
+
+- **M4 收敛曲线**:`search.get_state_summary()` 新增 `points_history`(逐评估点 id/batch/params/objective/feasible/status),`SearchStateResponse` 携带该字段;前端 `AdaptiveOptimize.vue` 增收敛曲线(echarts 散点可行/不可行 + 当前最优 step 折线)。
+  - 测试 `scripts/test_p4_m4_convergence.py`:5 组全过(空历史/初始批次/跨批累积/不可行标记/响应模型接线)。
+  - vue-tsc:AdaptiveOptimize.vue 0 错误;全量 build 仍有**既有**类型错误(PlanDetail/ProjectDetail/ProjectList,未触碰,属项目 backlog)。
+- **M5 L0 上提**:`L0PreScreeningEngine` 迁至 `src/afmcore/l0/prescreening.py`(唯一实现,纯 stdlib),web 端薄 re-export 保持 6 处调用点兼容。
+  - 测试 `scripts/test_p4_m5_l0.py`:8 组全过(单一定义/四类约束门/空输入门/filter_feasible/feasibility_search 集成)。
+  - 连带修复:`test_p3_closed_loop.py`/`test_p4_m4_convergence.py` 补 repo root 到 sys.path(L0 re-export 依赖 src 解析)。
+  - 回归:P2 36 / M4 / M5 / M6 / closed_loop / checkpoint / concurrency 全绿;全量 py_compile 0 失败;ASCII 0 违规。
+
+**环境依赖(未自动化,如实标注)**:真实 Motor-CAD 求解(license server)不在本批自动化范围内;EXE 的真实 Motor-CAD COM 连接需在目标机验证。
+
+## TEST-016:P5-M1(前端全量 build 类型错误清零)
+
+**日期**:2026-08-30 **环境**:Windows / Node v20.20.2 / npm 10.8.2 / Python 3.13.13
+
+**背景**:backlog B1——`npm run build`(vue-tsc && vite build)历史遗留 71 处类型错误(TS2339/TS2345/TS7006),涉及 Dashboard/ExecutorMonitor/ExperienceList/PlanDetail/ProjectDetail/ProjectList 6 个 .vue 文件。P4-M4 只保证 AdaptiveOptimize.vue 自身 0 错误,全量 build 一直红。
+
+**根因**:`src/api/index.ts` 的响应拦截器(D2 fix)运行时已把 `AxiosResponse` unwrap 为 `.data`,但 TS 类型上 `api.get()/post()` 仍声明为 `Promise<AxiosResponse>`,导致所有调用处直接访问响应字段(`res.items`/`res.metrics` 等)报 TS2339;PlanDetail 中 `task.status` 被误判为 AxiosResponse.status(HTTP 状态码,number 类型)报 TS2345。
+
+**修复**:
+1. `web/frontend/src/api/index.ts`:axios 实例类型改写为 `UnwrappedApi` 接口(get/post/put/delete 均返回 `Promise<T>`,默认 any)——类型声明与运行时行为对齐;拦截器逻辑原样保留(实例改名 instance,再 cast 到 UnwrappedApi)。一处修复覆盖全部 69 处 TS2339/TS2345。
+2. `web/frontend/src/views/PlanDetail.vue`(418 行):`@selection-change` 回调参数 `sel` 显式标注 `any[]`,消除 TS7006 隐式 any。
+
+**验证结果**:
+- `npm run build`(vue-tsc && vite build):vue-tsc 0 错误;vite 2274 modules 构建成功,真实退出码 0(cmd /c 确认)。
+- 后端启动(uvicorn 8000):`/api/health` 200;`test_api_client.py` 真实链路 PASSED(8 项目/历史结果正常读取)。
+- 前端 vite dev(5173):200;`/api` 代理 health 200。
+- 全量 Python 回归(14 个脚本)EXIT=0 全绿(P2 36 / P3 unit_edge/orchestrator/m4_contract/concurrency/checkpoint/closed_loop/adaptive_execution / P4 acceptance/schema/m4_convergence/m5_l0 / robust_solver / executor_m3)。
+
+**遗留/说明**:
+- `UnwrappedApi` 默认返回 `any`,与既有运行时行为一致;需要类型安全的调用可传泛型(如 `api.get<Project[]>()`)。
+- 两个 >500kB 大 chunk 警告为既有现象,非本批引入,不影响 build 通过。
+- 前端 JS 运行时冒烟:dev server + API 代理 + 后端真实链路通过;未做浏览器自动化点击验证(纯类型断言改动,编译后类型擦除,无运行时代码差异)。
+
+
+## TEST-017:P5-M2(EXE 配置化 config.json + mock 分支修复 + EXE 端到端 mock 验证)
+
+**日期**:2026-08-30 **环境**:Windows / Python 3.13.13 / PyInstaller 6.22.2 / 独立后端 8010 + 临时 DB(未干扰用户常驻 8000 服务)
+
+**目的**:把本地执行器 EXE 配置化(web 地址 / model 路径 / 日志 / 实例数从 `executor_config.json` 读取),并打通"EXE → Web 端到端回传"验收链路(mock 求解先行)。
+
+### 改动内容
+1. **新增 `scripts/executor_config.py`**:配置加载器。优先级:`--config` > `$EXECUTOR_CONFIG` > `<EXE目录>/executor_config.json` > `<仓库根>/executor_config.json` > 内置默认;单字段可被环境变量覆盖(WEB_BASE_URL/MOTORCAD_MODEL/EXECUTOR_INSTANCES 等);相对路径按 EXE 目录/仓库根解析;校验 instances≥1、poll_interval>0、log_level 枚举、web_base_url http(s)、enable_mock bool。
+2. **新增 `executor_config.json`(仓库根模板)**:8 字段侧车配置。
+3. **改造 `scripts/run_task_executor.py`**:`--config/--instances/--interval/--mock/--log-dir/--log-level`;logging 落盘 `output/executor_logs/`;单入口多实例(instances>1);版本号升 1.1.0。
+4. **改造 `scripts/run_task_executor_parallel.py`**:复用共享配置(保留 `--instances/--interval/--mock` CLI 兼容)。
+5. **修复 `scripts/task_executor.py`(P4-M3 遗留)**:`MotorCADTaskExecutor._run_simulation_point` 忽略 `enable_mock` 无条件走真实 adapter;现 mock 分支先于 adapter(mock 结果带 `source="mock"`,不启动 Motor-CAD、不要求 model_path)。
+
+### 测试
+| 测试 | 覆盖 | 结果 |
+|---|---|---|
+| scripts/test_executor_config.py | 20 用例:默认/文件合并/绝对路径/环境覆盖/优先级/边界(instances=1, poll=0.5, 空model)/异常(坏JSON/非对象/坏URL/0实例/坏level/坏mock类型)/空值(空对象/null字段/空env) | ✅ EXIT=0 |
+| scripts/test_executor_p5m2.py | 6 用例:mock 点 source/多点/非法 model_path 仍 mock/默认 mock off/真实模式缺 model_path 报错/空 model_path | ✅ EXIT=0 |
+| 全量回归 | test_*.py 全绿(16 个脚本 EXIT=0;test_api_client 需后端已跳过,由端到端验证替代) | ✅ 16/16 |
+| ASCII/CRLF | 新改 6 个 .py 纯 ASCII、CRLF 与现有文件一致 | ✅ |
+
+### EXE 端到端验证(mock 链路,全部 PASS)
+1. 重新打包 `dist/PCB-AFM-Executor.exe`(PyInstaller onefile,`--version`=1.1.0、`--self-test` OK)。
+2. 独立后端:`AFM_DB_PATH=<临时DB>` + `AFM_PORT=8010` + uvicorn 启动,`/api/health` 200。
+3. EXE 以 `--config <临时侧车>` 启动(web_base_url=8010、enable_mock=true、poll_interval=2)。
+4. `POST /api/tasks` 创建 3 点任务(airgap 0.8/1.0/1.2)→ EXE 轮询认领 → mock 求解 → 回传 Web。
+5. 任务 `completed`、3/3 成功(successful_points=3)、duration 0.05s;点级结果 `source="mock"`、point_id 保留;聚合指标:tavg 34.56~42.05 N·m、eff 87.95~89.73%、losses 29.61~56.77 W、temp 85.1~101.1 ℃。
+
+### 过程中发现并处理的坑
+- **EXE 相对路径基准**:EXE(frozen)模式下相对路径(model_path/log_dir)按 **EXE 所在目录**解析(dist/),而非仓库根——侧车 config 建议用绝对路径或相对 EXE 目录的路径;已在 executor_config.py 注释与 README 说明。
+- **P4-M3 mock 未生效(真 bug)**:`MotorCADTaskExecutor._run_simulation_point` 无条件走真实 adapter,`--mock`/`enable_mock` 从未真正进入 mock 分支;本次修复(见上),并用"非法 model_path + mock 仍成功"用例锁定回归。
+
+### 遗留事项
+- **真实 EXE 内 Motor-CAD COM 端到端(目标机)**:license server 本机在跑(1055@localhost)但用户常驻执行器占用轮询,且真实求解 90~150s/点;验收建议在目标机执行:
+  1. 复制 `dist/PCB-AFM-Executor.exe` + `executor_config.json`(web_base_url 指向实际后端、model_path 填目标机 .mot 绝对路径)到目标机;
+  2. 确认 `MOTORCAD_ACTIVEX` 与 `ANSYSLMD_LICENSE_FILE=1055@<server>` 已设;
+  3. 后端创建单点/短扫描任务;`PCB-AFM-Executor.exe` 启动后轮询认领;
+  4. 观察任务 completed、结果带真实指标(对照 TEST-002/003/010:5000rpm eff≈86.06%、tavg≈0.52 N·m、back_emf≈11.15V)。
+
+
+## TEST-018:P5-M2 补充(真实 EXE 端到端单点验证 + 真实参数链路修复)
+
+**日期**:2026-08-30 **环境**:Windows + Motor-CAD v261(MOTORCAD_ACTIVEX 已设、license 1055@localhost、exe 存在);独立后端 8010 + 临时 DB(未干扰用户常驻 8000 服务)
+
+**目的**:完成 P5-M2 验收点②"真实 EXE 端到端回传 Web 结果"——用打包后的 `dist/PCB-AFM-Executor.exe` 以真实模式(非 mock)驱动 Motor-CAD 求解单点并回传 Web。
+
+### 过程中发现并修复的真实链路 bug(mock 测不到,真实求解才暴露)
+1. **业务参数名未映射**:任务参数用 L0/方案层对齐名 `airgap_mm`,Motor-CAD 实际变量名是 `Airgap`(TEST-002 已探测)。此前 `resolve_variable_name` 直接透传导致 `Could not find airgap_mm`。修复:`scripts/robust_motorcad.py` `VARIABLE_NAME_MAP` 增加业务别名 `"airgap_mm": {"default": "Airgap"}`。
+2. **point_id 元数据被当变量写**:M3 的 `point_id` 透传标记被 `run_single_point` 参数循环当作 Motor-CAD 变量 `set_variable` 导致 `Could not find point_id`。修复:参数写入循环跳过元数据键(`point_index`/`point_label`/`point_id`)。
+
+### 验证结果(PASS)
+| 项目 | 结果 | 详情 |
+|---|---|---|
+| EXE 真实模式 | 通过 | `--config` 侧车(enable_mock=false,model_path 绝对路径) |
+| 认领→求解→回传 | 通过 | 任务 `completed`,duration 167.5s(含 Motor-CAD 启动 + 求解) |
+| 求解时长 | 通过 | solve_time_s=145.22(单点电磁计算) |
+| 指标完整性 | 通过 | 20+ 指标(tavg/ripple/efficiency/losses/back_emf/温度类/电流类/转速) |
+| 数值一致性 | 通过 | tavg_nm=0.52187、eff=86.06%、total_losses=41.945W、back_emf=11.15V —— 与 TEST-010 完全一致 |
+| point_id 保留 | 通过 | 结果点级带 point_id=1 |
+| 回归 | 通过 | 全量 test_*.py 16/16 EXIT=0(含 test_robust_solver) |
+
+### 新增测试
+- `scripts/test_executor_p5m2.py` 扩至 8 用例:新增 `resolve_variable_name("airgap_mm")=="Airgap"` 映射断言 + `point_id` 排除回归断言。
+
+### 遗留事项
+- 真实短扫描(2+ 点)/多实例(instances>1)可复用本链路在目标机验证;本机已用单点打通"EXE→真实 Motor-CAD→Web"全链路。
+- 其余业务参数(current_a 等)的 Motor-CAD 变量名映射待按变量探测逐个补充(AGENTS.md 纪律:不做臆测,逐名探测确认)。
+
+
+## TEST-019:P5-M3 adaptive 可视化补全(批次状态 + L0 摘要 + 运行期轮询)
+
+**日期**:2026-08-30 **环境**:Windows + 独立后端 8011(未干扰用户常驻 8000)+ 前端 vue-tsc build
+
+**目的**:完成 P5-M3 验收点"前端 adaptive 三视图可见"——批次点状态可视化(每批进度/分布)+ L0 预筛选结果前端视图 + adaptive 循环运行期状态推送(轮询增强)。
+
+### 后端改动
+1. `web/backend/app/services/feasibility_search.py` `get_state_summary()` 增加 4 字段:
+   - `infeasible_points` / `failed_points`:状态计数
+   - `batch_summary`:按 batch_id 分组,每批含 total/pending/ok/infeasible/failed/best_objective(respect objective_direction)
+   - `l0_summary`:sampled/feasible/infeasible/pass_rate/top_infeasible_reasons(name/count/category,从不可行点 feasibility_report 聚合)
+   - 新增 3 个 helper:`_count_by_status` / `_build_batch_summary` / `_build_l0_summary`
+2. `web/backend/app/routers/search.py` `SearchStateResponse` 增加对应 4 字段(带默认值,向后兼容)。
+
+### 前端改动(`views/ai/AdaptiveOptimize.vue`)
+1. **运行期自动轮询**:创建 search 后启动 3s 轮询,convergence_status != searching 时自动停止,onBeforeUnmount 清理。
+2. **L0 预筛选摘要卡片**:总采样/L0可行/L0拒绝/可行率进度条 + 主要不可行原因 Top N tag。
+3. **批次状态总览卡片**:每批一行(批次号/点数/完成进度条/状态分布 tag/批内最优值)。
+
+### 验证结果(PASS)
+| 项目 | 结果 |
+|---|---|
+| 后端单元测试 | 8/8 PASS(test_search_state_summary.py:正常/报告后聚合/min-max方向/failed计数/空search边界/infeasible原因结构/未知id容错/批次排序) |
+| 前端类型检查 | vue-tsc && vite build 成功,零类型错误(P5-M1 清零保持) |
+| 端到端 API | 独立后端 8011:POST /search/create 返回 batch_summary(1批,6点全pending) + l0_summary(sampled=6,pass_rate=1.0);GET /search/{id}/state 同样返回新字段 |
+| 全量回归 | 16/16 PASS(含新增 test_search_state_summary) |
+
+### 遗留事项
+- WebSocket 实时推送未做(P5-M3 验收允许轮询增强;当前 3s 轮询已满足运行期状态可见)。
+- L0Prescreen.vue 单点评分页面保持现状(P5-M3 的 L0 视图在 AdaptiveOptimize 内通过 l0_summary 实现)。
+
+
+## TEST-020:P5-M4 策略层高级管线(Morris 灵敏度 + IDW 代理 + 预算自适应)
+
+**日期**:2026-08-30 **环境**:Windows + 纯 stdlib(无 numpy/scipy/sklearn/lightgbm)
+
+**目的**:完成 P5-M4 验收点"蓝图 §6 管线可运行;现有策略回归不破"——新增 Morris 灵敏度筛选策略 + 代理模型引导策略(含预算自适应批次大小),注册到 strategies 注册表。
+
+### 依赖评估与降级决策
+- 环境探测:numpy/scipy/sklearn/lightgbm **全部未安装**。
+- 项目纪律:feasibility_search.py / lhs.py 均标注 "Pure-Python implementation (no numpy/scipy dependency)"。
+- P5 规划 §6 约束:"Kriging/NSGA-II 引入新依赖——P5-M4 先评估,轻量实现优先,避免重依赖"。
+- **决策**:代理模型用纯 stdlib **IDW(反距离加权)** 替代 Kriging。IDW 给出预测值 + 基于最近邻距离的不确定性,支持 explore-exploit 权衡;精度低于 Kriging(尤其非平稳曲面),但满足"预测+不确定性选点"核心需求。后续如需 Kriging 须引入 scipy 并在同接口下替换。
+
+### 新增策略
+1. **`MorrisStrategy`**(`src/afmcore/strategies/morris.py`,kind=`morris`)
+   - 纯 stdlib Morris OAT 灵敏度筛选:n_trajectories 条轨迹,每条 n_params+1 个点
+   - 每步只变一个参数(随机排列 + ±Δ 方向),Δ = n_levels/(2(n_levels-1))
+   - `state()` 返回 `sensitivity_ranking`(每参数 mu/mu_star/sigma/n_effects,按 mu_star 降序)+ `key_parameters`(mu_star>0 的前半)
+   - 奇数 n_levels 自动转偶数;参数值带 step 时自动对齐网格
+2. **`SurrogateGuidedStrategy`**(`src/afmcore/strategies/surrogate_guided.py`,kind=`surrogate_guided`)
+   - 两阶段:初始 LHS 采样(n_initial)→ 代理引导选点(UCB 采集)
+   - IDW 代理:预测 = Σ(y_i/d_i^p) / Σ(1/d_i^p),不确定性 = 最近邻归一化距离
+   - UCB 采集:score = pred + kappa*uncertainty(maximize)或 -pred + kappa*uncertainty(minimize)
+   - 预算自适应批次大小:growth = 1 + mean_uncertainty * remaining_budget_ratio,size ∈ [1, max_batch_size]
+   - `state()` 返回 phase(initial/surrogate_guided/exhausted)、budget 用量、surrogate 诊断(n_train/loo_rmse/mean_uncertainty/last_batch_size)
+   - select_next 时记录 point_id→params 映射,report 时自动关联(基类协议无需改)
+
+### 验证结果(PASS)
+| 项目 | 结果 |
+|---|---|
+| Morris 单元测试 | 15/15 PASS(test_strategy_morris.py:轨迹点数/step0无changed_param/线性函数灵敏度排名/收敛/空参数/单轨迹/奇数n_levels/参数边界/未知id/缺失metric/failed排除/注册/state契约) |
+| Surrogate 单元测试 | 17/17 PASS(test_strategy_surrogate.py:初始LHS/bowl收敛/maximize/空参数/budget耗尽/n_initial>budget/自适应批次边界/未知id/缺失metric/failed排除/注册/state契约/IDW预测/距离计算/归一化往返) |
+| Morris 冒烟 | 线性函数 y=2a+0.5b,灵敏度排名 a(mu_star=2.0) > b(0.5),sigma=0(线性无交互)✅ |
+| Surrogate 冒烟 | bowl 函数 y=(a-0.5)^2+(b-0.5)^2,budget=30,best y=0.007(理想 0.0),LOO RMSE=0.089 ✅ |
+| 策略注册 | list_strategy_kinds() = [adaptive, full_factorial, lhs, morris, surrogate_guided] ✅ |
+| 全量回归 | 19/19 PASS(含 2 个新测试) |
+
+### 遗留事项
+- Kriging 代理未实现(环境无 scipy,项目纪律纯 stdlib);IDW 为降级替代,已在策略 docstring 标注升级路径。
+- NSGA-II 多目标优化不在 P5-M4 范围(蓝图 §6.4,可能 P5-M5+)。
+- 代理模型未接入 Web 端 plan_schema 的 method 字段(当前 strategies 注册表可用,Web 端 method 白名单待扩展)。
+
+
+## TEST-021:P5-M5 多工具适配器(Maxwell/JMAG mock + 执行器 tool 参数化)
+
+**日期**:2026-08-30 **环境**:Windows + 纯 stdlib(无 Ansys Maxwell / JMAG 真实安装)
+
+**目的**:完成 P5-M5 验收点"`get_adapter("maxwell")` 可跑 mock/真实链路"——新增 MaxwellAdapter 和 JMAGAdapter,注册到适配器注册表,执行器 `tool` 参数动态选择适配器。
+
+### 真实接入环境依赖标注
+- **Ansys Maxwell**:真实接入需要 Ansys Maxwell + PyAEDT(ansys-pythonnet),当前环境未安装。mock 实现保持接口稳定,真实接入路径已在适配器 docstring 标注(connect/set_parameter/run_simulation/extract_metrics 对应 PyAEDT 调用)。
+- **JMAG**:真实接入需要 JMAG Designer + Python API(jmagpy),当前环境未安装。mock 实现同理。
+- 与既有 Motor-CAD 同策略:接口 + mock 链路先行,真实接入标注环境依赖(P5 规划 §6 约束)。
+
+### 新增适配器
+1. **`MaxwellAdapter`**(`src/afmcore/adapters/maxwell.py`,tool=`maxwell`)
+   - mock 实现:内存参数记录 + 回读校验 + 确定性合成指标
+   - capability_domains = ("electromagnetic", "thermal")(含 winding_temp_c 热指标)
+   - 指标模型:tavg=0.50*airgap+0.30*magnet+0.10;eff=85.0+0.10*airgap;losses=42.0-0.80*airgap
+   - mock=False 时 connect() 抛 RuntimeError(明确标注环境依赖)
+2. **`JMAGAdapter`**(`src/afmcore/adapters/jmag.py`,tool=`jmag`)
+   - mock 实现,指标系数与 Maxwell 略有不同以区分工具
+   - capability_domains = ("electromagnetic",)
+   - 指标模型:tavg=0.45*airgap+0.32*magnet+0.12;eff=84.5+0.12*airgap;losses=43.0-0.75*airgap
+
+### 执行器改造
+- `scripts/task_executor.py` `MotorCADTaskExecutor._ensure_adapter()`:从硬编码 `import afmcore.adapters.motorcad` 改为根据 `self.tool` 动态 import(motorcad/maxwell/jmag),未知 tool 依赖预注册适配器。
+- `executor_config.json` 的 `tool` 字段(P5-M2 已加)现在可选择 "motorcad"/"maxwell"/"jmag"。
+
+### 验证结果(PASS)
+| 项目 | 结果 |
+|---|---|
+| 适配器注册 | registered_tools() 含 maxwell/jmag ✅ |
+| Maxwell mock 全链路 | connect→load→set→run→extract→run_point,status=OK,tavg=2.10(airgap=1,magnet=5)✅ |
+| JMAG mock 全链路 | 同上,tavg=2.17(系数不同,可区分工具)✅ |
+| 工具区分 | 相同参数 maxwell tavg=2.10 ≠ jmag tavg=2.17 ✅ |
+| set_parameter 回读校验 | 写入后回读一致 ✅ |
+| 边界:空参数 | tavg=0.10(所有参数默认 0)✅ |
+| 边界:未 connect 就 run | 抛 RuntimeError ✅ |
+| 异常:mock=False | connect() 抛 RuntimeError(环境依赖标注)✅ |
+| 异常:未知 tool | get_adapter 抛 KeyError ✅ |
+| 执行器动态 import | tool=maxwell→adapter.tool_name="maxwell";tool=jmag→"jmag";未知 tool→KeyError ✅ |
+| 单元测试 | test_adapters.py 22/22 PASS |
+| 全量回归 | 20/20 PASS(含新测试) |
+
+### 遗留事项
+- 真实 Maxwell/JMAG 接入待目标机环境(需安装对应软件 + Python API)。
+- Maxwell 热域(winding_temp_c)当前为合成值,真实接入后从 Maxwell 热求解器提取。
+- 前端/GUI 工具选择下拉框待扩展(当前 executor_config.json 可配,Web 端任务创建页待加 tool 字段)。
+
+
+## TEST-022:P5-M6 多物理场 L2 接入(热网络+结构指标 + 报告模板化)
+
+**日期**:2026-08-30 **环境**:Windows + 纯 stdlib(无真实 Motor-CAD 热求解运行)
+
+**目的**:完成 P5-M6 验收点"热指标入库与报告展示"——metrics.py 扩项(热网络+结构指标,自动生效)、robust_motorcad 热求解开关、report_generator 按物理域分组模板化。
+
+### 1. metrics.py 扩项(单一事实源,自动生效)
+新增 10 个指标(均 required=False,不影响现有必需指标校验):
+
+**热网络指标(domain=thermal)**:
+| key | label | unit | direction |
+|---|---|---|---|
+| winding_hotspot_temp_c | Winding Hotspot Temp | C | lower |
+| magnet_temp_c | Magnet Temp | C | lower |
+| stator_temp_c | Stator Temp | C | lower |
+| bearing_temp_c | Bearing Temp | C | lower |
+| temp_rise_c | Temperature Rise | C | lower |
+| thermal_resistance_k_w | Thermal Resistance | K/W | lower |
+
+**结构/机械指标(domain=structural)**:
+| key | label | unit | direction |
+|---|---|---|---|
+| axial_force_n | Axial Force | N | lower |
+| radial_force_n | Radial Force | N | lower |
+| max_stress_mpa | Max Stress | MPa | lower |
+| deformation_mm | Max Deformation | mm | lower |
+
+每个指标含英文+中文(\uXXXX)别名。`extract_all_metrics()` 遍历 METRIC_DEFINITIONS,新指标自动被提取,无需调用方修改。总指标数从 25 增至 35。
+
+### 2. robust_motorcad.py 热求解开关
+- `__init__` 新增 `enable_thermal: bool = False`(默认关,保持现有电磁-only 行为)
+- `run_single_point` 新增 `enable_thermal: Optional[bool] = None`(覆盖实例默认)
+- 电磁求解后尽力而为调用 `do_thermal_calculation()`(失败记录警告,不中断电磁结果)
+- 电磁导出后尽力而为导出 Thermal 结果并合并 metrics(失败记录警告)
+- 热求解需要模型配置热网络,标注为环境依赖
+
+### 3. report_generator.py 按物理域分组模板化
+- Results Summary 从单一 metrics 表改为按域分组:Electromagnetic / Thermal / Structural
+- 每个域一个 level=2 子标题 + 表格,空域跳过
+- 从 afmcore.metrics 导入 METRIC_DEFINITIONS 获取 domain/label/unit(单一事实源)
+- `_metric_display()` 格式化 label(含 unit)和 value(float 用 %.4g)
+- JSON fallback report 也包含 `metrics_by_domain` 字段
+
+### 验证结果(PASS)
+| 项目 | 结果 |
+|---|---|
+| 新指标定义 | 热6+结构4,均有 domain 字段,required=False ✅ |
+| 总指标数 | 35(原25+新10)✅ |
+| 热指标提取 | mock CSV 含 Magnet Temperature 等字段,extract_all_metrics 自动提取 ✅ |
+| 结构指标提取 | mock CSV 含 Axial Force 等字段,自动提取 ✅ |
+| 中文别名 | \u6c38\u78c1\u4f53\u6e29\u5ea6 → magnet_temp_c ✅ |
+| 必需指标校验 | 缺热/结构指标不影响 check_required_metrics ✅ |
+| 域分组 | electromagnetic/thermal/structural 正确分组,空域跳过 ✅ |
+| 未知 key | 默认归入 electromagnetic ✅ |
+| 报告 JSON | metrics_by_domain 字段存在,热/结构域正确 ✅ |
+| robust 热参数 | __init__ 和 run_single_point 均有 enable_thermal 参数 ✅ |
+| 单元测试 | test_metrics_extension.py 20/20 PASS |
+| 全量回归 | 21/21 PASS(含新测试) |
+
+### 遗留事项
+- 真实 Motor-CAD 热求解未运行(需要模型配置热网络 + 实际启动 Motor-CAD,当前为代码路径预留)。
+- 结构指标(轴向力/应力/变形)需要 Motor-CAD 结构模块或第三方 FEA 工具,当前为 metrics 定义+报告展示预留。
+- 原有 winding_temp_c 指标无 domain 字段,默认归入 electromagnetic(可后续加 domain=thermal)。
+
+
+---
+
+## 2026-08-30: 拓扑感知变量名映射与执行前校验(P5-M2)
+
+### 测试环境
+- OS: Windows
+- Python: 3.x
+- Motor-CAD: 2026R1 (v261)
+- 后端: FastAPI + SQLite
+- 前端: Vue3 + Element Plus
+
+### 测试目的
+修复 Plan 23 全部 80 个扫描点失败的问题,并建立根本性防护机制,防止 RFM/AFM 变量名不匹配错误再次发生。
+
+### 问题根因
+Plan 23 的扫描变量使用了径向磁通电机(RFM)的变量名:
+- `Stator_Lam_Outer_Dia`(80~100mm,5 档)
+- `Stator_Lam_Inner_Dia`(45~60mm,4 档)
+- `Magnet_Thickness`(2~5mm,4 档)
+
+4 × 5 × 4 = 80 个点,全部在写入第一个参数 `Stator_Lam_Outer_Dia` 时失败:
+```
+RuntimeError: MotorCADError writing Stator_Lam_Outer_Dia:
+pymotorcad: set_variable: Error in SetVariable: Could not find Stator_Lam_Outer_Dia
+```
+
+当前模型 `MARS-12S10P_SSSR_D76-C150_V5.0-0819.mot` 是轴向磁通电机(SSSR),正确的变量名是 `Stator_Outer_Diameter` / `Stator_Inner_Diameter`。
+
+### 修复措施
+1. 新增 `topology_variable_map.py`:拓扑感知变量名映射表(41 个 AFM 已知变量 + RFM→AFM 别名映射 + 模板逻辑名映射)
+2. 修复 `_expand_plan_to_parameters()`:扫描变量经过模板 motorcad_var + 拓扑别名映射
+3. 新增 `start_simulation` 执行前变量名校验:未知变量返回 400 + 建议名
+4. 补充模板 8 个参数的 motorcad_var
+5. 新增 `GET /api/plans/variable-catalog` API
+6. 前端 PlanDetail.vue 从后端获取变量目录
+
+### 测试步骤与结果
+| 测试项 | 结果 |
+|---|---|
+| 拓扑归一化(SSSR/AFIR/RFM/None/别名) | ✅ PASS |
+| RFM→AFM 别名解析(Stator_Lam_Outer_Dia → Stator_Outer_Diameter) | ✅ PASS |
+| 模板逻辑名映射(Number_of_Slots → Slot_Number) | ✅ PASS |
+| 已知变量校验(AFM 变量已知,RFM 变量在 AFM 拓扑未知) | ✅ PASS |
+| 参数批量校验(valid/unknown/aliases_resolved 分类) | ✅ PASS |
+| 未知变量建议(suggest_alternative) | ✅ PASS |
+| 已知变量集合获取(41 个 AFM 变量) | ✅ PASS |
+| **Plan 23 回归测试**(80 点,RFM 名被映射,全部已知) | ✅ PASS |
+
+### 单元测试
+- 文件:`scripts/test_topology_variable_map.py`
+- 结果:8/8 PASS
+- 运行命令:`python scripts/test_topology_variable_map.py`
+
+### 关键数据
+- AFM (SSSR) 已知变量数:41
+- 模板参数总数:37
+- 有 motorcad_var 的模板参数:35(仅剩 Current_Density、Insulation_Class 合理留空)
+- Plan 23 失败点数:80/80(修复前)→ 0/80(修复后,全部可正确映射)
+
+### 遗留事项
+- 后端服务需重启以加载新代码(uvicorn --reload 模式自动重载)
+- 真实 Motor-CAD 运行验证待执行(需要重启后端后重新启动 plan 23 仿真)
+- AFIR 拓扑的特有变量待补充(当前继承 SSSR 变量集合)
+- AI 方案生成端的拓扑感知变量名选用待集成(当前后端映射层已能兜底)
+
+
+---
+
+## 2026-08-30: AI 一键生成方案 422 错误修复(P5-M2 补充)
+
+### 问题现象
+在项目详情页点击"一键 AI 生成方案"按钮,前端报错。后端返回 422:
+```
+Invalid generated plan: plan_data malformed: could not convert string to float: 'Star'
+```
+
+### 根因
+`src/plan_schema.py` 中 `FixedParam.value` 被定义为 `float` 类型,且 `from_dict()` 中强制 `float(d.get("value", 0.0))` 转换。
+
+AI 生成的方案中包含字符串枚举类型参数:
+- `Winding_Connection`: `"Star"`(星形连接)
+- `Cooling_Type`: `"Natural Convection"`(自然冷却)
+- `CurrentDefinition`: `"Peak"`(峰值电流)
+
+这些是 Motor-CAD 合法的枚举/字符串参数,但 `float("Star")` 抛出 `ValueError`,被 `validate_plan_dict()` 捕获后返回 422。
+
+### 修复措施
+修改 `src/plan_schema.py`:
+1. `FixedParam.value` 类型从 `float` 改为 `Any`(支持数字和字符串枚举)
+2. `from_dict()` 中移除 `float()` 强制转换,保持原始值类型
+3. `generate_full_params()` 返回类型注解从 `dict[str, float]` 改为 `dict[str, Any]`
+
+### 测试结果
+| 测试项 | 结果 |
+|---|---|
+| FixedParam 接受字符串枚举值(Winding_Connection="Star") | ✅ PASS |
+| 数字值(int/float)仍正常工作 | ✅ PASS |
+| to_dict -> from_dict 字符串值往返保留 | ✅ PASS |
+| 混合数字/字符串参数的方案校验通过 | ✅ PASS |
+| generate_full_params 保留字符串枚举值 | ✅ PASS |
+| 空 fixed_params 边界情况 | ✅ PASS |
+| AI 生成方案完整回归测试(9 固定参数 + 2 扫描变量 = 9 点) | ✅ PASS |
+
+### 单元测试
+- 文件:`scripts/test_plan_schema.py`
+- 结果:7/7 PASS
+- 运行命令:`python scripts/test_plan_schema.py`
+
+### 影响范围
+- 此修复不影响已有数字参数的行为(int/float 保持原类型)
+- 字符串枚举参数现在可以正常通过校验、保存到数据库、并在仿真执行时传递给 Motor-CAD
+- 后端服务需重启以加载新代码
+
+## TEST-024:环境体检脚本 check_machine_paths.py 验证
+
+**日期**:2026-09-01
+**测试环境**:Windows,Python 3.14.7(AI shell),Motor-CAD v261,git 仓库
+**测试目的**:验证新增的 scripts/check_machine_paths.py(Playbook V2 落地:换机/新会话环境体检)能正确检查并报告环境状态。
+**测试脚本**:`scripts/check_machine_paths.py`
+**输出目录**:无(只读体检,stdout 直接输出)
+
+### 测试步骤与结果
+
+| 步骤 | 内容 | 结果 | 详情 |
+|---|---|---|---|
+| 1 | 运行 `python scripts/check_machine_paths.py` | ✅ | 退出码 1(存在缺项时按设计返回 1) |
+| 2 | Python 版本 | ✅ | 3.14.7(≥3.10) |
+| 3 | 环境变量 | ✅ | MOTORCAD_ACTIVEX=...activex.bat;ANSYSLMD_LICENSE_FILE=1055@localhost |
+| 4 | Motor-CAD exe | ✅ | activex.bat + D:\Program Files\ANSYS Inc\v261\motorcad\MotorCAD.exe 均存在 |
+| 5 | Python 依赖包 | ⚠️ | 6 包在当前 AI shell 解释器 MISSING(预期:shell 未装项目依赖,需在项目实际解释器下运行或 pip install) |
+| 6 | Git 状态 | ⚠️ | HEAD 有效;工作区有未提交改动(本次框架升级文件,待提交) |
+| 7 | 仓库资产 | ✅ | models/*.mot、experience.db、executor_config.json、web/、src/afmcore 全部就位 |
+| 8 | node/npm | ✅ | 在 PATH 上 |
+| 9 | py_compile + ASCII | ✅ | check_machine_paths.py 编译通过、纯 ASCII(符合硬性约束 §1) |
+
+### 关键数据 / 发现的问题 / 修复措施
+
+- **关键数据**:无项目内 `.venv/.env`(venv 探测未命中)→ 包缺失反映的是当前 shell 解释器视图,非项目实际运行环境;项目真实依赖需按 KNOWLEDGE_BASE §1 安装(`pip install ansys-motorcad-core pyside6 pandas` 等)。
+- **发现的问题**:AI shell 的 Python 3.14 与项目依赖环境分离,`find_spec` 全部 MISSING 属环境差异而非脚本 bug。
+- **修复措施**:脚本已加“项目 venv 探测”提示(若存在 .venv 会提示用其解释器复检);`--fix` 打印精确修复命令(setx / pip install)但不自动执行,避免脚本擅自改系统环境。
+- **验证方式**:同一脚本两条命令(默认 + --fix)运行观察;py_compile 编译;ASCII 字符集检查。
+
+## TEST-025:AI 方案生成 topology/search_strategy 归一化修复验证
+
+**日期**:2026-09-02
+**测试环境**:Windows,系统 Python 3.12.10(uvicorn 0.52.4 / fastapi 0.141.1),Kimi k3,SQLite
+**测试目的**:验证 AI 一键生成方案在 topology='AFIR' 且 search_strategy.method='full_factorial_grid' 时不再 422 报错,正确归一化兜底。
+**相关文件**:web/frontend/src/views/ProjectList.vue、web/backend/app/services/plan_generator.py、web/backend/app/routers/ai_plan.py
+
+### 测试步骤与结果
+
+| 步骤 | 内容 | 结果 | 详情 |
+|---|---|---|---|
+| 1 | py_compile 语法检查 | ✅ | 两个改动 .py 编译通过 |
+| 2 | afmcore.topology.is_supported | ✅ | AFIR=False,SSSR/DRSS/SDSR=True |
+| 3 | afmcore.strategies 归一化 | ✅ | full_factorial_grid 未注册;active_learning→adaptive 已注册 |
+| 4 | convert_ai_plan_to_unified 函数级测试 | ✅ | method=full_factorial_grid→full_factorial + warning;method=lhs 保留 |
+| 5 | 真实端到端 API(AFIR 项目 id=9 generate-and-save) | ✅ | HTTP 200,topology→SSSR、method→full_factorial,warnings 正确 |
+
+### 关键数据 / 发现的问题 / 修复措施
+
+- **关键数据**:端到端返回 topology="SSSR"、search_strategy.method="full_factorial",warnings 含 "Unknown topology 'AFIR' - defaulted to SSSR" 与 "Unknown search strategy method 'full_factorial_grid' - defaulting to full_factorial"。
+- **发现的问题**:AI 偶发输出非标准变量名(Stator_Outer_Diameter_mm、Turns_Per_Coil)触发 warning(不影响生成),属独立问题待后续。
+- **修复措施**:三处修复(前端拓扑选项对齐 + 后端 topology/strategy 归一化兜底),测试产物(临时脚本、测试方案)已清理。
+
+## TEST-026:Kimi max_tokens 撞顶修复 + AI 生成摘要对话框验证
+
+**日期**:2026-09-03
+**测试环境**:Windows,系统 Python 3.12.10(uvicorn 0.52.4 / fastapi 0.141.1),Kimi k3,Vue3 + Element Plus
+**测试目的**:验证 P0-2(Kimi max_tokens 撞顶导致 content 空)与 P0-1(AI 生成摘要对话框)修复。
+**相关文件**:web/backend/app/services/ai_client.py、web/backend/app/services/plan_generator.py、web/frontend/src/views/ProjectDetail.vue
+
+### 背景 / 根因
+
+- plan_generator.py 调用 chat_json 写死 `max_tokens=2000`(.env 的 KIMI_MAX_TOKENS=4096 未生效);k3 推理模型的 reasoning_content 会吃满 2000,导致 content 为空 → JSON 解析失败 → "AI 生成失败"(对应用户"超时"截图的一个隐藏根因,ai_call_logs id=40 实测 completion_tokens=2000、content 空)。
+
+### 测试步骤与结果
+
+| 步骤 | 内容 | 结果 | 详情 |
+|---|---|---|---|
+| 1 | py_compile + ASCII 检查(2 个 .py) | ✅ | 编译通过、纯 ASCII |
+| 2 | vue-tsc --noEmit 类型检查 | ✅ | 0 错误 |
+| 3 | 端到端 generate-and-save(SSSR 项目 id=1) | ✅ | HTTP 200,topology=SSSR,warnings 完整返回 |
+| 4 | max_tokens 生效核对(最新 ai_call_log id=45) | ✅ | completion_tokens=650(未撞顶)、content 完整 JSON、duration 18.5s、status=success |
+
+### 修复内容
+
+- ai_client.py:chat() 未传 max_tokens 时默认用 KIMI_MAX_TOKENS;新增 finish_reason=length 且 content 空时的 logger.warning。
+- plan_generator.py:两处 max_tokens=2000 → KIMI_MAX_TOKENS。
+- ProjectDetail.vue:AI 生成成功后改为"方案生成摘要"对话框(方案概要 + 系统调整 warnings 逐条 + AI 设计思路折叠 + 查看方案/留在本页),替代原只显示第一条的 ElMessage;loading overlay 增加已等待秒数(AbortController 取消本已存在)。
+
+### 关键数据 / 遗留
+
+- **关键数据**:端到端 warnings=["Slot_Depth 非标准变量", "Removed Slot_Depth (点数裁剪)", "full_factorial_grid→full_factorial"],验证摘要对话框数据链完整。
+- **遗留**:AI 仍偶发输出非标准变量名(Slot_Depth 等)被裁剪,属独立问题;P0-3(仿真前检查清单)/ P0-4(TaskManager 方案下拉)/ P1(参数目录)未做。
+
+## TEST-027:仿真前检查清单 + TaskManager 方案自动展开验证
+
+**日期**:2026-09-03
+**测试环境**:Windows,系统 Python 3.12.10(uvicorn 0.52.4 / fastapi 0.141.1),Vue3 + Element Plus
+**测试目的**:验证 P0-3(PlanDetail 仿真前检查清单)与 P0-4(TaskManager 创建任务从方案自动展开参数)。
+**相关文件**:web/backend/app/routers/plans.py、web/backend/app/routers/tasks.py、web/frontend/src/views/PlanDetail.vue、web/frontend/src/views/TaskManager.vue
+
+### 测试步骤与结果
+
+| 步骤 | 内容 | 结果 | 详情 |
+|---|---|---|---|
+| 1 | py_compile + ASCII(plans.py/tasks.py) | ✅ | 编译通过、纯 ASCII(preflight 只返回 key/status,中文 label 在前端) |
+| 2 | vue-tsc --noEmit | ✅ | 0 错误 |
+| 3 | preflight API(plan 1)首次 | ⚠️→✅ | 初测 model_path fail(相对路径 `models/xx.mot` 相对后端 cwd 解析失败);修复为用 PROJECT_ROOT 解析相对路径后 pass |
+| 4 | preflight API 复测 | ✅ | ok=true;model_path pass、scan_vars=1、point_count=3、executor warn(0 在线) |
+| 5 | tasks 自动展开(POST /api/tasks 仅传 plan_id=1) | ✅ | HTTP 200,total_points=3(1 变量 × 3 值自动展开),status=pending |
+
+### 修复内容
+
+- plans.py:新增 `GET /{plan_id}/preflight`(5 项检查:模型文件/变量名确认/扫描变量/数据点规模/执行器在线;fail 阻断、warn 提示);model_path 相对路径用 PROJECT_ROOT 解析。
+- tasks.py:`POST /api/tasks` 的 plan_data/parameters 改为可选;仅传 plan_id 时自动加载方案并调用 `_expand_plan_to_parameters` 展开,显传仍优先(高级覆盖)。
+- PlanDetail.vue:启动仿真改为先调 preflight 弹检查清单对话框(状态图标 + 中文 label 映射 + fail 禁用启动),确认后才调 start-simulation。
+- TaskManager.vue:doCreate 仅在用户编辑高级 JSON 时才传 plan_data/parameters,否则省略由后端自动展开;onPlanSelect 重置高级 JSON;加"默认从方案自动带出参数"提示。
+
+### 关键数据 / 遗留
+
+- **关键数据**:tasks 自动展开 total_points=3 正确;preflight 5 项状态机正确(executor 离线为 warn 不阻断,任务可排队)。
+- **测试产物**:测试任务 7d55dc4f 已 cancel(任务文件在 output/tasks/,不入库)。
+- **遗留**:P1(参数目录单一事实源 + D6 调用链验证)未做。
+
+## TEST-028:D6 边界条件 key 别名桥修复验证
+
+**日期**:2026-09-03
+**测试环境**:Windows,系统 Python 3.12.10(uvicorn 0.52.4 / fastapi 0.141.1),Kimi k3
+**测试目的**:验证 D6 数据一致性 Bug 修复——ProjectDetail 录入的边界条件 key(current_a/speed_rpm 等)能流入 build_default_fixed_params 的固定参数推断。
+**相关文件**:web/backend/app/services/fixed_params_template.py
+
+### 根因(确认)
+
+- `build_default_fixed_params` 读取 BC key 用 `rated_current_a/rated_speed_rpm/slot_count/dc_link_voltage_v/cooling_method`,而 ProjectDetail 录入 + rule_engine 消费用 `current_a/speed_rpm/slots/voltage_v/cooling_type` → 用户在项目边界表单填的电流/转速/槽数/电压/冷却方式**不流入**固定参数推断(落模板默认值)。
+- 另发现 `Magnet_Temperature`/`Max_Speed` 完全无 BC 推断。
+
+### 修复
+
+- build_default_fixed_params 入口加 BC key 别名桥(canon 优先、别名补填、拷贝不改调用方 dict);补 Magnet_Temperature(magnet_temp_c)/ Max_Speed(max_speed_rpm)推断。
+
+### 测试步骤与结果
+
+| 步骤 | 内容 | 结果 | 详情 |
+|---|---|---|---|
+| 1 | py_compile + ASCII | ✅ | 通过 |
+| 2 | 函数级验证(11 断言) | ✅ | 前端 7 key 全部正确流入;旧 rated_ key 向后兼容;调用方 dict 不被修改 |
+| 3 | 端到端(project 9 generate-and-save) | ✅ | HTTP 200;RMSCurrent=20(BC)、Shaft_Speed=3000(BC)、Number_of_Slots=12、DC_Link_Voltage=48、Magnet_Temperature=40、Cooling_Method=Natural、Max_Speed=6000,全部来自 BC 而非模板默认 |
+
+### 关键数据 / 遗留
+
+- **关键数据**:端到端固定参数全部取自项目 BC(current_a=20/speed_rpm=3000/slots=12/voltage_v=48/magnet_temp_c=40/cooling_type=Natural/max_speed_rpm=6000),D6 修复生效。
+- **顺带确认**:拓扑兜底(AFIR→SSSR)、策略兜底(grid_search_with_refinement→full_factorial)持续正常。
+- **遗留**:完整参数目录单一事实源(P1-1,含 PlanDetail BC_TEMPLATE 对齐、rule_engine、参数目录 API、数据迁移)为大重构,本轮未做;AI 偶发非标准变量名(Stator_Outer_Diameter/Turns_Per_Coil)被裁剪,独立问题。
+
+## TEST-029:BC 字段目录单一事实源(P1-1 第一阶段)验证
+
+**日期**:2026-09-03
+**测试环境**:Windows,系统 Python 3.12.10(uvicorn 0.52.4 / fastapi 0.141.1),Kimi k3,Vue3
+**测试目的**:验证 BC 字段目录单一事实源落地——后端目录 API、方案保存 BC、PlanDetail BC 展示从目录渲染。
+**相关文件**:web/backend/app/services/bc_fields.py(新建)、routers/generation.py、routers/ai_plan.py、routers/plans.py、web/frontend/src/views/PlanDetail.vue
+
+### 背景(确认的事实)
+
+- BC key 三套口径漂移(ProjectDetail/rule_engine 用 current_a 系、fixed_params_template 用 rated_ 系、PlanDetail BC_TEMPLATE 用第三套 rated_)。
+- 所有方案 plan.boundary_conditions 均为 None → PlanDetail"边界条件"展示区模板错位 + 数据源为空,双重失效(死功能)。
+
+### 修复内容
+
+- 新建 bc_fields.py:BC_FIELD_CATALOG(25 字段,规范 key 对齐录入端/rule_engine,含 \uXXXX label/unit/category/aliases)+ normalize_bc(别名→规范,规范优先,不改调用方)。
+- generation.py:新增 `GET /api/bc-fields` 暴露目录。
+- ai_plan.py(AI 生成)+ plans.py(手动创建):把归一化后的 project BC 存入 plan_data.boundary_conditions。
+- PlanDetail.vue:删除硬编码 BC_TEMPLATE,改为从 /api/bc-fields 目录渲染。
+
+### 踩坑与解决
+
+- Write 工具把 `\uXXXX` 转义直接落成实际中文字符 → 文件非 ASCII 且 docstring 字面 `\uXXXX` 触发 truncated escape。解决:先写中文,再用 `encode('ascii','backslashreplace')` 转换脚本统一转 \uXXXX;docstring 避免字面 `\uXXXX`。
+
+### 测试步骤与结果
+
+| 步骤 | 内容 | 结果 | 详情 |
+|---|---|---|---|
+| 1 | py_compile + ASCII(5 个 .py) | ✅ | 通过、纯 ASCII |
+| 2 | normalize_bc 功能 | ✅ | rated_current_a→current_a(规范优先)、slot_count→slots、cooling_method→cooling_type、unknown 透传 |
+| 3 | vue-tsc --noEmit | ✅ | 0 错误 |
+| 4 | /api/bc-fields | ✅ | 25 字段、label 中文正确、aliases 正确 |
+| 5 | 端到端(project 9 generate-and-save) | ✅ | plan.boundary_conditions 完整保存 25 个规范 key 的 BC |
+
+### 遗留
+
+- P1-1 第二阶段未做:前端 ProjectDetail bcFields 从目录渲染(表单)、rule_engine/fixed_params_template key 彻底统一、已存数据迁移、scan 变量目录与 BC 目录合并为统一参数目录。
+- AI 偶发非标准变量名被裁剪,独立问题。
+
+## TEST-030:BC key 读取统一走 normalize_bc(P1-1 第二阶段)验证
+
+**日期**:2026-09-03
+**测试环境**:Windows,系统 Python 3.12.10(uvicorn 0.52.4 / fastapi 0.141.1),Kimi k3
+**测试目的**:消除固定参数推断的双重转换——build_default_fixed_params 与 rule_engine 统一走 bc_fields.normalize_bc 单一事实源,删除本地 rated_ 别名桥。
+**相关文件**:web/backend/app/services/fixed_params_template.py、rule_engine.py、bc_fields.py
+
+### 背景
+
+第一阶段后存在双重转换:normalize_bc 输出 current_ 系,build_default_fixed_params 仍用本地 _ALIASES 桥把 current_ 再转 rated_ 读取。本阶段统一为:BC → normalize_bc(唯一别名处理点)→ 各消费端直接读规范 key。
+
+### 修复内容
+
+- fixed_params_template.build_default_fixed_params:改为 `bc = normalize_bc(...)`,删除本地 _ALIASES 桥,elif 改读规范 key(current_a/speed_rpm/slots/voltage_v/cooling_type)。
+- rule_engine.BoundaryConditions.from_dict:开头加 normalize_bc,旧 rated_ 数据正确解析。
+
+### 测试步骤与结果
+
+| 步骤 | 内容 | 结果 | 详情 |
+|---|---|---|---|
+| 1 | py_compile + ASCII(3 个 .py) | ✅ | 通过 |
+| 2 | 统一性验证(14 断言) | ✅ | current_ 规范 key 流入;rated_ 旧 key 经 normalize_bc 兼容;rule_engine 解析 rated_;不改调用方 dict |
+| 3 | 后端运行时 import | ✅ | fixed_params_template→bc_fields、rule_engine→bc_fields 无循环,health OK |
+| 4 | 端到端(project 9 generate-and-save) | ✅ | 固定参数推断仍全对(RMSCurrent=20/Shaft_Speed=3000/Slots=12/DC=48/MagnetTemp=40/Cooling=Natural/MaxSpeed=6000) |
+
+### 遗留
+
+- 已存 DB 数据的 rated_ key(project 3/8)由 normalize_bc 读取时兼容,不做物理迁移(避免改用户数据)。
+- scan 变量目录(rule_engine.SCAN_PARAMETERS)与 BC 目录(bc_fields)为不同维度(Motor-CAD 变量 vs 工程边界概念),保持分离不合并。
+- 前端 ProjectDetail 表单 key 已与目录一致(设计目录时即对齐 bcFields),无需改动。
+
+## TEST-031:仿真耗时校准 + AFIR 非标准拓扑标记(P2-1/P2-3)验证
+
+**日期**:2026-09-03
+**测试环境**:Windows,系统 Python 3.12.10(uvicorn 0.52.4 / fastapi 0.141.1),Vue3
+**测试目的**:验证 P2-1 耗时校准(实测 solve_time_s 替代硬编码)与 P2-3 AFIR 非标准拓扑前端标记。
+**相关文件**:web/backend/app/routers/analytics.py、web/frontend/src/views/PlanDetail.vue、web/frontend/src/views/ProjectList.vue
+
+### 数据基础
+
+- simulation_results.solve_time_s:11 条有效实测,avg=125.9s(≈2.1 分钟/点)、min=118s、max=141.4s、median=121s。证实硬编码 2.5 分钟/点高估约 19% 且不能反映实测。
+
+### 修复内容
+
+- analytics.py:新增 `GET /api/analytics/solve-time-stats`(avg/min/max/median/count)。
+- PlanDetail.vue:perPointMin 用实测 avg_s(fallback 2.5 分钟/点),estimatedTimeMin/estimatedRemaining 改用它;"预计耗时"卡片下加依据("基于最近 N 次实测 ~Xs/点")。
+- ProjectList.vue:非标准拓扑(不在 SSSR/DRSS/SDSR)项目 tag 改 warning 色 + 警告图标 tooltip"非标准拓扑,按 SSSR 处理"(不改 DB,后端已有兜底)。
+
+### 测试步骤与结果
+
+| 步骤 | 内容 | 结果 | 详情 |
+|---|---|---|---|
+| 1 | py_compile + ASCII(analytics.py) | ✅ | 通过 |
+| 2 | vue-tsc --noEmit | ✅ | 0 错误 |
+| 3 | /api/analytics/solve-time-stats | ✅ | count=11, avg=125.9, min=118, max=141.4, median=121 |
+| 4 | AFIR 项目识别 | ✅ | id 9/10/11 topology=AFIR 被识别为非标准,前端将标记 |
+
+### 遗留
+
+- P2-2(步骤条交互向导)/ P2-4(结果表列配置)未做,价值中低,留后续。
+- AI 偶发非标准变量名被裁剪,独立问题。
+
+## TEST-032:AI 扫描变量归一化 + 注册表扩充验证
+
+**日期**:2026-09-03
+**测试环境**:Windows,系统 Python 3.12.10(uvicorn 0.52.4 / fastapi 0.141.1),Kimi k3
+**测试目的**:解决 AI 推荐变量被误判"非标准"导致扫描维度被裁剪/警告的问题——扩充 SCAN_PARAMETERS 注册表 + 补变量名归一化映射。
+**相关文件**:web/backend/app/services/rule_engine.py、plan_generator.py
+
+### 调查(基于 ai_call_logs 证据)
+
+AI 高频推荐但非标准的变量:Turns_per_Coil(匝数,9 次)、Stator_Outer_Diameter(定子外径,9 次)、Slot_Depth(3 次)、Stator_Inner_Diameter、Wire_Diameter。均为 fixed_params_template 里的有效 Motor-CAD 变量,仅因不在 SCAN_PARAMETERS(原 8 个)被误判。
+
+### 修复内容
+
+- rule_engine.SCAN_PARAMETERS 8→13:新增 Stator_Outer_Diameter/Stator_Inner_Diameter/Slot_Depth/Turns_per_Coil/Wire_Diameter,范围锚定 MARS 基线(198/122/7/20/1.63)±20-50%。
+- plan_generator._VARIABLE_NAME_MAP 补 25 个 AI 变体名映射(Turns_Per_Coil/coil_turns/Stator_Outer_Dia/Stator_OD/Stator_Lam_Outer_Dia/Stator_Slot_Depth 等)。
+
+### 测试步骤与结果
+
+| 步骤 | 内容 | 结果 | 详情 |
+|---|---|---|---|
+| 1 | py_compile + ASCII | ✅ | 通过 |
+| 2 | 归一化验证(16 断言) | ✅ | 12 个变体名归一化到注册名;convert 不再产生"not a standard"warning;变量被保留 |
+| 3 | 注册表扩充 | ✅ | 8→13,5 个新变量范围正确 |
+| 4 | 端到端(project 9) | ✅ | "非标准变量"warning 消失;Stator_Outer_Diameter 保留为扫描维度(values=5) |
+
+### 关键数据 / 遗留
+
+- **关键改善**:修复前 Stator_Outer_Diameter/Turns_per_Coil 被标"非标准"并裁剪;修复后成为标准变量被保留。
+- **遗留(设计决策,非缺陷)**:4 个变量且点数超 MAX_POINTS=200 时仍保守裁剪(本次 Turns_per_Coil 因 240 点超限被裁)。若需保留更多维度,应改为点数超限时切换 lhs/adaptive 采样而非砍变量——属另一独立优化,未实施。
+
+## TEST-033:结果表列配置(P2-4)验证
+
+**日期**:2026-09-03
+**测试环境**:Windows,Vue3 + Element Plus 2.4
+**测试目的**:验证结果表列配置——默认只显示核心指标 + 扫描变量,35 项指标按需勾选显示(解决指标扩展后结果表全部铺开拥挤的问题 D14)。
+**相关文件**:web/frontend/src/views/PlanDetail.vue
+
+### 修复内容
+
+- PlanDetail.vue 结果表新增列设置:defaultColumnKeys(核心 5 列 + 扫描变量列);displayColumns 按 visibleColumnKeys 过滤;新增"列设置"按钮 + checkbox 对话框(全部列按需勾选)+"恢复默认"。表格列从 allResultColumns 改为 displayColumns。
+
+### 测试步骤与结果
+
+| 步骤 | 内容 | 结果 | 详情 |
+|---|---|---|---|
+| 1 | vue-tsc --noEmit | ✅ | 0 错误 |
+| 2 | 前后端服务 | ✅ | 前端 5173 HTTP 200,后端 health OK(纯前端改动,Vite 热更新) |
+
+### 遗留
+
+- 全部既定优化项(P0/P1/P2 + D6 + AI 变量归一化)已完成。P2-2(步骤条交互)价值低未做。
+- 可选深化:点数超限切换 lhs/adaptive 采样保留维度;结果表列配置可持久化到 localStorage(当前会话内有效)。
+
+## TEST-034:UI 评审实测修复(P0/P1/P2,截图验证)
+
+**日期**:2026-09-03
+**测试环境**:Windows,Vue3 + Element Plus,系统 Edge headless(CDP 截图,Node 原生 WebSocket)
+**测试目的**:用 ui-ux-pro-max 技能 + 实截图评审界面,修复发现的问题并截图回归验证。
+**方法**:agent-browser 因 googleapis 下载 Chromium 超时,改用系统 Edge headless + 自写 CDP 截图脚本(output/_cdp_shot.mjs)实测 5 个页面。
+
+### 评审发现的问题与修复
+
+| 项 | 问题(截图证据) | 修复 | 级别 |
+|---|---|---|---|
+| P0-1 | PlanDetail 概览"扫描变量数/预计数据点/预计耗时"全 0(读 plan.scan_variables 不存在,实为 plan_data.variables) | scanVariables 及增删改 4 处改读 plan_data.variables | Bug |
+| P0-1b | calcPoints 只认 min/max/step,plan 1 variables 用 values 数组 → 预计数据点 0 | calcPoints 优先 values.length,兼容 min/max/step、start/stop | Bug |
+| P1-1 | Dashboard 已选方案却提示"请选择方案";默认选第一个方案(常无结果);结果加载漏 .items 字段({total,items})致有结果显示 0 | 空状态文案区分已选/未选;默认选 result_count>0 方案;results 读取加 .items | Bug+交互 |
+| P1-2 | ProjectDetail 边界条件 25 字段全铺开(22 个"未设置"占位) | 空值默认折叠,只显示已设置 + 展开全部 | 密度 |
+| P1-3 | ProjectList 表格行高大 | 表格 size=small | 密度 |
+| P2-2 | 面包屑 /plans/:id 缺项目名 | MainLayout 加载所属项目名,面包屑补"项目名"层级 | 导航 |
+| P2-4 | ProjectDetail 标题区"返回"与项目名粘连 | 返回按钮独占一行 | 视觉 |
+
+### 截图回归验证(output/v_*.png)
+
+- 方案详情:扫描变量数 0→**1 个**、预计数据点 0→**3 点**、预计耗时 0→**6 分钟**、面包屑补项目名 ✓
+- 项目详情:边界条件从 7 行空字段折叠为 1 行(3 项已设置 + "展开全部(含 22 项未设置)");标题区分隔 ✓
+- Dashboard:数据点总数 0→**6 点**(字段修复);空状态文案"该方案暂无仿真结果" ✓
+- vue-tsc 0 错误。
+
+### 踩坑与沉淀
+
+- **前后端字段名不匹配是反复出现的问题家族**:plan.scan_variables vs plan_data.variables、results 的 .items vs .results/.data、calcPoints 不认 values 数组。建议前端读取一律用兼容多路径的 fallback(`a?.items || a?.results || a?.data`)。
+- **agent-browser 在国内 googleapis 下载 Chromium 超时**:系统 Edge headless(--remote-debugging-port)+ 自写 CDP 截图脚本是可靠的替代方案(Node 22 原生 WebSocket)。
+
+### 遗留
+
+- "Test Airgap Scan"方案 6 结果全 FAILED(数据本身问题),结果明细表无错误信息列——结果表加 error 信息列可作为后续 UX 优化。
+- PlanDetail 方案参数编辑(添加/删除扫描变量)无保存到后端的逻辑(既有功能完整性问题)。
+- P2-1 监控页合并(实时监控+执行器状态)、P2-3 批量清理入口未做(结构/数据改动,待确认)。
+
+## TEST-035:失败结果错误信息列验证
+
+**日期**:2026-09-03
+**测试环境**:Windows,Vue3 + Element Plus,系统 Edge headless(CDP 截图)
+**测试目的**:结果表失败行显示具体错误原因,便于诊断仿真失败(此前 FAILED 状态无原因可查)。
+**相关文件**:web/frontend/src/views/PlanDetail.vue、web/frontend/src/views/Dashboard.vue
+
+### 背景
+
+- /plans/{id}/results 的结果项含 error_message 字段(如 `RuntimeError: MotorCADError writing Outer_Rotor_Diameter: ... Could not find Outer_Rotor_Diameter`),但结果表只显示 FAILED 状态,失败原因不可见。
+- 该错误信息同时实证了 HANDOFF 记录的"5 个固定参数 Motor-CAD 变量名未验证"问题(Outer_Rotor_Diameter 在该模型中不存在)。
+
+### 修复内容
+
+- PlanDetail.vue:结果表 status 列 FAILED 且含 error_message 时加 el-tooltip(悬停显示完整错误,虚线下划线标识可悬停);labelMap 加 error_message→"错误信息"(列设置可勾选显示整列)。
+- Dashboard.vue:resultColumns 加 error_message 列(宽 220,直接显示失败原因);status 列同加 tooltip;两处加 .status-failed-cell 样式。
+
+### 测试步骤与结果
+
+| 步骤 | 内容 | 结果 | 详情 |
+|---|---|---|---|
+| 1 | vue-tsc --noEmit | ✅ | 0 错误 |
+| 2 | 截图 Dashboard(v_dashboard_err.png) | ✅ | 错误信息列直接显示 "Could not find Outer_Rotor_Diameter" 完整原因 |
+
+### 遗留
+
+- PlanDetail 参数编辑无保存逻辑;P2-1 监控合并 / P2-3 批量清理待确认。
+- Outer_Rotor_Diameter 等未验证变量名导致仿真失败的问题,需按 HANDOFF 待办在 .mot 中确认正确变量名后修正 fixed_params_template(独立于 UI)。
+
+## TEST-036:MARS 变量名实测排查 + fixed_params_template 几何修正
+
+**日期**:2026-09-03
+**测试环境**:Windows,Motor-CAD 2026R1 (v261),pymotorcad 0.8.8,系统 Python 3.12.10,许可证 lmgrd+ansyslmd 在跑
+**测试目的**:排查仿真失败根因(`set_variable: Could not find Outer_Rotor_Diameter`),实测确认 MARS 真实几何变量名并修正模板。
+**相关文件**:web/backend/app/services/fixed_params_template.py、docs/KNOWLEDGE_BASE.md §3.3
+
+### 背景
+
+- UI 错误信息列(TEST-035)实证:仿真点全 FAILED,原因 `Could not find Outer_Rotor_Diameter`。
+- 模板几何变量用径向电机命名,与 MARS(PCB 无铁心轴向磁通)不符。
+
+### 方法(KNOWLEDGE_BASE §8 探测 + 铁律"不要猜变量名")
+
+1. 解析 `.mot`(INI 文本)静态比对模板变量名存在性。
+2. pymotorcad `get_variable` 实测候选名(独立隐藏实例,只读无求解)。
+3. pymotorcad `set_variable` + 回读校验修正后的变量名可写性(铁律"写入回读校验")。
+
+### 实测结论
+
+- **修正(4 个几何)**:`Outer_Rotor_Diameter→RotorOuterDiameter`(130)、`Stator_Outer_Diameter→Stator_Lam_Dia`(76)、`Stator_Inner_Diameter→Stator_Bore`(50)、`Rotor_Back_Iron_Thickness→Back_Iron_Thickness`(5),get/set 均 OK。
+- **设 null(MARS 无此变量,不写入)**:`Inner_Rotor_Diameter`(转子内径,4 候选全 MISS)、`Stator_Yoke_Thickness`(定子轭厚,3 候选全 MISS,PCB 无铁心)。
+- **`Magnet_Arc_[ED]`=121 实测 OK**(原名即对,不改)。
+- **默认值同步**:几何默认值从径向模板值(200/198/122/120/10/8)更正为 MARS 实测值(130/76/50/—/—/5)。
+
+### 验证
+
+| 步骤 | 内容 | 结果 | 详情 |
+|---|---|---|---|
+| 1 | .mot 静态比对 | ✅ | 4 几何名 MISSING,别名映射多数 OK |
+| 2 | get_variable 探测(22s) | ✅ | 22 个真实名 OK;模板 12 个错误名全 MISS;转子内径/轭厚候选全 MISS;Magnet_Arc_[ED]=121 |
+| 3 | set_variable + 回读(15s) | ✅ | 4 个修正名写入回读一致,ALL WRITABLE |
+| 4 | py_compile + ASCII | ✅ | 模板编译通过、纯 ASCII |
+
+### 知识沉淀
+
+- KNOWLEDGE_BASE §3.3 新增"MARS 几何变量名(2026-09-03 实测)",含错误名/正确名对照、无对应变量清单、实测方法。
+- **教训**:固定参数模板的几何命名必须基于实测 .mot/探测,不能套用径向电机模板;`motorcad_var=null` 是"无对应变量"的安全处理(不写入不报错)。
+
+### 遗留
+
+- 模板修正后需重启后端生效;建议重新 AI 生成一个方案并真实启动一次仿真,确认不再报"Could not find variable"(端到端复验)。
+- 转子内径/轭厚若在后续拓扑(DRSS 等)需要,需另行实测对应模型的变量名。
+
+## TEST-037:端到端真实仿真验证(变量名修正终极复验)
+
+**日期**:2026-09-03
+**测试环境**:Windows,Motor-CAD 2026R1 (v261),pymotorcad 0.8.8,许可证在跑
+**测试目的**:模拟执行器完整流程(生成固定参数 → set 全部 → 真实求解 → 提取指标),端到端验证变量名修正后仿真能成功跑通。
+**相关文件**:web/backend/app/services/fixed_params_template.py、docs/KNOWLEDGE_BASE.md §3.3
+
+### 过程与发现(迭代排查)
+
+1. **首次端到端**:37 固定参数 33 可写,set 31 ok / **2 failed**——`Current_Advance_Angle`、`Material_Stator_Lam_Yoke` 也为错误名(几何 4 个已修正 OK)。
+2. **补充排查**:实测 `Current_Advance_Angle→PhaseAdvance`(get/set OK);`Steel_Grade`(Material_Stator_Lam_Yoke)不存在,MARS 无铁心 → 设 null。
+3. **二次端到端**:32 可写 set 全成功(0 failed),但 `do_magnetic_calculation` 失败——`Could not find magnet material NdFeB_N42SH in solids database`(材料**值**错误,非变量名)。
+4. **材料修正**:MARS `.mot` 实测 `Material_Magnet=N42UH`,模板默认 `NdFeB_N42SH` 改 `N42UH`。
+5. **三次端到端**:✅ 全链路成功。
+
+### 最终验证结果
+
+| 步骤 | 内容 | 结果 |
+|---|---|---|
+| set_variable(32 可写参数) | 全部 set | ✅ 32 ok, 0 failed |
+| do_magnetic_calculation | 真实电磁求解 | ✅ 成功(~2 分钟,符合 90-150s) |
+| export_results | 结果导出 | ✅ 337 行 CSV |
+| 关键指标 | 求解结果有效性 | ✅ 转矩脉动 2.815%、AC 损耗 0.47W、功率因数 0.995 |
+
+### 结论
+
+仿真失败根因(变量名 + 材料名)已彻底修复,端到端仿真能成功跑通并产生有效结果。模板所有 motorcad_var 经实测验证(无猜测)。
+
+### 遗留
+
+- 建议用户在 Web 端用本地执行器跑一次完整方案扫描(多参数点),确认生产链路同样通畅(本次为 pymotorcad 单点验证)。
+- 其他拓扑(DRSS)/其他模型的变量名需按同样方法实测。
+
+## TEST-038:生产链路复验(Web 生成 → 任务下发 → 执行器真实仿真 → 结果回传)
+
+**日期**:2026-09-03
+**测试环境**:Windows,Motor-CAD 2026R1 (v261),pymotorcad 0.8.8,FastAPI 后端 + 本地执行器(enable_mock=false)
+**测试目的**:验证完整生产链路(非 pymotorcad 单点)在变量名修正后能成功跑通一次真实仿真。
+**相关文件**:web/backend/app/services/topology_variable_map.py、scripts/task_executor.py、src/afmcore/adapters/motorcad.py
+
+### 过程(链路逐段排障)
+
+1. **执行器上线**:enable_mock=false,heartbeat 正常,idle 等待任务。
+2. **预检拦截**:start-simulation 报 400——`is_known_variable` 用 `_KNOWN_VARIABLES[SSSR]`(含错误几何名),把修正后的正确名误判 unknown。**修复 1**:topology_variable_map 的 `_KNOWN_VARIABLES` 几何名更正为 MARS 实测名(RotorOuterDiameter/Stator_Lam_Dia/Stator_Bore/Back_Iron_Thickness/PhaseAdvance),alias map 的 SSSR/AFIR 目标名同步更正。
+3. **预检通过**:start-simulation 返回 task_id,dispatched。
+4. **执行器不捡任务**:日志 `not claimable (claimed/network)`——start-simulation 已把任务 pending→dispatched,执行器 claim 时再调 dispatch(后端仅允许 pending→dispatched),状态冲突被拒。**修复 2**:task_executor.execute_task 对 status==dispatched 的任务跳过重复 dispatch(仅 pending 才 claim)。
+5. **执行器捡起但点失败**:`No module named 'scripts'`——afmcore.adapters.motorcad 用 `from scripts.robust_motorcad import`,执行器 sys.path 只有 scripts/ 和 src/(无仓库根)。**修复 3**:motorcad._ensure_solver 导入前确保仓库根在 sys.path。
+6. **生产链路成功**:任务 completed,successful_points=1。
+
+### 最终验证结果
+
+| 检查点 | 结果 |
+|---|---|
+| 预检(_KNOWN_VARIABLES) | ✅ 通过(修正后) |
+| 执行器 claim(dispatched 任务) | ✅ 不再 "not claimable" |
+| 适配器加载(scripts 导入) | ✅ 不再 "No module named 'scripts'" |
+| 展开参数正确性 | ✅ RotorOuterDiameter=130/Stator_Lam_Dia=76/Stator_Bore=50/PhaseAdvance/N42UH 等全部正确 |
+| 任务结果 | ✅ completed,successful_points=1,failed_points=0 |
+| 关键指标 | ✅ tavg_nm=0.5219 Nm、efficiency=86.06%、total_losses=41.945 W(与 pymotorcad 单点验证一致) |
+
+### 结论
+
+完整生产链路(Web 生成方案 → start-simulation 预检 → 任务下发 → 本地执行器真实 Motor-CAD 求解 → 结果回传 Web)**全部通畅**,变量名修正后仿真点成功。同时发现并修复了 3 个链路级问题(拓扑预检变量集、执行器 dispatch 状态机、适配器 scripts 导入路径)。
+
+### 遗留
+
+- 多点扫描方案的生产复验(本次为单点最快验证)。
+- 其他拓扑/模型的变量名需按同样方法实测后登记到 topology_variable_map。
+
+## TEST-039:多点扫描生产复验(3 点 Airgap 扫描)
+
+**日期**:2026-09-03
+**测试环境**:Windows,Motor-CAD 2026R1,FastAPI 后端 + 本地执行器(enable_mock=false)
+**测试目的**:多点扫描生产链路最终确认——批量调度、逐点落盘、进度更新、结果趋势物理正确性。
+**方案**:plan 28(Airgap 0.6/1.0/1.5mm,3 点),task c2781acf。
+
+### 验证结果
+
+| 检查点 | 结果 |
+|---|---|
+| 批量调度 | ✅ 执行器逐点跑,进度 33.3%→66.7%→100% |
+| 任务结果 | ✅ completed,successful_points=3,failed_points=0 |
+| 逐点落盘 | ✅ 每点结果独立保存(params + metrics + solve_time_s) |
+
+### 逐点指标(物理趋势验证)
+
+| Airgap | tavg(Nm) | eff(%) | losses(W) | status |
+|---|---|---|---|---|
+| 0.6mm | 0.56625 | 84.926 | 49.917 | OK |
+| 1.0mm | 0.52187 | 86.060 | 41.945 | OK |
+| 1.5mm | 0.46770 | 86.346 | 36.558 | OK |
+
+**趋势符合电磁学**:气隙↑ → 主磁通↓ → 转矩↓(0.566→0.522→0.468);气隙↑ → 铁耗/杂散损耗↓(49.9→41.9→36.6);损耗下降主导 → 效率略升(84.9→86.1→86.3)。结果真实有效,非随机数。
+
+### 结论
+
+多点扫描生产链路完全通畅。单点耗时 ~134s(符合 90-150s 基线),3 点含 Motor-CAD 启动共约 6 分钟。从"仿真失败根因(变量名)"到"多点生产链路全通畅"彻底闭环。
+
+### 遗留
+
+- 建议用户在前端用 AI 生成一个多变量方案并启动,体验完整 AI→仿真→分析闭环。
+- 失败任务历史脏数据(TEST-035 之前的 FAILED)可在经验库/结果分析中对比新成功数据。
+
+---
+
+## TEST-040:遗留 UI 项全部修复(参数编辑持久化 / 监控合并 / 批量清理 / 列配置持久化)
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-03 |
+| 测试环境 | Windows / Python 3.12.10 / Node 22.22.2 / FastAPI :8000 / Vite :5173 / Edge headless CDP 截图 |
+| 测试目的 | 落地 UI 评审全部遗留项:PlanDetail 扫描变量编辑持久化、监控页合并、项目/任务批量清理、结果列配置 localStorage 持久化 |
+
+### 测试步骤与结果
+
+1. **扫描变量编辑持久化(PlanDetail)**:添加/删除扫描变量置 dirty 标记,"保存修改"按钮 PUT `/plans/{id}` 完整 plan_data(后端单一事实源校验),保存后重载。✅
+2. **监控页合并(P2-1)**:新建 `ExecutionMonitor.vue`,el-tabs 嵌入原 MonitorDashboard(任务监控)+ ExecutorMonitor(执行器状态),`v-if` 保证仅活动 tab 轮询。路由 `/monitor` 指向合并页,`/executor-monitor` 301 重定向兼容旧链接;导航"执行"组只留"任务管理/执行监控"两个入口。✅
+3. **批量清理(P2-3)**:
+   - 后端:`POST /api/projects/batch-delete`(逐项容错)、`DELETE /api/tasks/{id}` + `POST /api/tasks/batch-delete`(**仅终态可删**,活动任务 400 拒绝并提示先取消)。
+   - 前端:项目列表/任务列表多选列 + 条件渲染批量按钮 + 二次确认(列明项目名);任务行级加"删除"(仅终态显示)。
+   - API 实测:不存在 ID 返回 errors 不中断;真实删除终态任务 204→404;**pending 任务删除被拒 400 "cancel it before deleting",取消后删除 204**。✅
+4. **列配置持久化(P2-4 深化)**:结果列选择存 `localStorage['afm:plan-result-columns']`(全局偏好),加载时读取,恢复默认时清除;空选择拦截"至少保留一列"。✅
+5. **截图回归**:`/monitor`(单入口 + 双 Tab)、`/tasks`(多选列 + 行级删除)、`/projects`(多选列 + AFIR 警告标记 + small 密度)均符合预期。✅
+
+### 验证方式
+
+- `vue-tsc --noEmit` 0 错误;3 个后端 `.py` 编译通过、纯 ASCII。
+- 批量删除 API curl 实测(容错 / 真实删除 / 终态保护)。
+- Edge headless CDP 截图 3 张人工核验。
+
+### 修复过程发现
+
+- 前端两处 script Edit 曾未生效(文件被后续 Edit 改动导致 old_string 失配但界面显示成功),靠 vue-tsc 报错发现并补回——**类型检查再次拦截了静默失效**。
+
+### 遗留
+
+- 无(UI 评审全部项已闭环)。P2-2 步骤条交互向导维持"价值低不做"结论。
+
+---
+
+## TEST-042:用户四需求落地(拓扑默认模型 / 全量 BC 表单+来源标签 / 验收标准 / 参数区展开编辑)
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-03 |
+| 测试环境 | Windows / Python 3.12.10 / Node 22.22.2 / FastAPI :8000 / Vite :5173 / Edge headless CDP 截图(支持 js: 表达式点击) |
+| 测试目的 | 用户实测反馈四需求:①拓扑基础模型自动调用(预检 model_path 空阻断启动)②新建项目弹窗全 25 项 BC + 来源标签 ③验收标准按 BC 自动带出 ④BC/固定参数默认全展开 + 可编辑保存 |
+
+### 需求1:拓扑基础模型自动调用
+
+- **后端**:`afmcore/topology.py` 的 `TopologyDefinition` 加 `default_model` 字段(SSSR→MARS .mot,DRSS/SDSR 暂缺)+ `default_model_for()`;三处回退——AI 生成(ai_plan.py)、手动创建(plans.py create_plan)、**启动仿真运行时兜底**(start-simulation 空则回填并持久化,16 个历史空模型方案无需手工修复直接可启动);preflight 模型失败项带 `fixable/default_model`。
+- **前端**:预检对话框模型失败项显示"使用拓扑默认模型"一键修复按钮(PUT + 重跑预检)。
+- **验证**:端到端 project 13(model_path 空)AI 生成 → 自动回填 MARS 模型 + warning 提示。✅
+
+### 需求2:全量 BC 表单 + 来源标签
+
+- **发现并修复隐藏 Bug**:`defaultBC()` 原给全部 25 字段预填默认值 → 用户无法区分"自填/系统默认"。改为全空,仅保存非空字段(cleanBC)。
+- **后端**:bc_fields 目录加表单元数据(type: number/int/enum + options;枚举值为工程标准选项);AI prompt 加 `bc_suggestions` 输出(仅补用户未设置项)+ 扫描变量清单同步扩充至 13 项;方案存 `bc_meta`(user/ai 来源),手动创建全 user。
+- **前端**:新建项目弹窗目录驱动 25 项分组渲染(默认全"未设置");ProjectDetail BC 默认全展开 + 有值标"用户指定";PlanDetail BC 全展开 + 来源标签(用户指定/AI 补充/已修改)+ 编辑保存(改动标 edited)。冷却方式枚举值统一为 Natural/Forced Air/Water/Oil(原小写值与 Motor-CAD 实测值不一致)。
+- **字段路径 Bug 修复**:`plan.boundary_conditions`/`plan.acceptance_criteria` 顶层不存在(实际在 plan_data 下)→ 兼容修正。
+
+### 需求3:验收标准自动带出
+
+- AI acceptance_criteria(hard_constraints)+ BC 目标类字段推导(转矩/效率/损耗/脉动/轴向力/外径/轴向长度 7 类)合并渲染,带"AI 设定/边界条件"来源标签。
+- **修复 AI 输出嵌套 dict 格式丢失**:convert 归一化加 hard_constraints dict→list 转换(`"key <=110"` → `key <=110` 字符串列表)。
+- **截图验证**(plan 31):概览验收标准自动带出 7 条判断条件 + 来源标签。✅
+
+### 需求4:参数区默认展开 + 编辑保存
+
+- BC/固定参数默认全部展开(showAllFixed=true;分类默认展开语义 `!== false`)。
+- 固定参数 inline 编辑(布尔/数值/字符串分控件)+ 保存(PUT plan_data.fixed_params)+ 已修改高亮。
+- **修复扫描变量"最小值"列空缺**:AI 方案 variables 只有 values 数组,列绑 min/max/step 导致空白 → 改为"取值"列直接显示 values 列表(如 0.8/1.15/1.5)。
+
+### 验证方式
+
+- 后端 5 个 `.py` 编译通过、纯 ASCII;vue-tsc 0 错误(初查报 2 个索引类型错误,修 defaultBC 返回类型标注后清零)。
+- 端到端:model_path 回填 + bc_meta 25 项全 user + acceptance_criteria 归一化。
+- Edge CDP 截图 5 张人工核验(概览验收标准/参数页 BC 标签/扫描变量取值/新建项目弹窗/预检修复按钮)。
+- 测试方案已清理。
+
+### 遗留
+
+- DRSS/SDSR 无基础模型文件,选择时无法自动调用(需用户提供 .mot 后登记到 topology registry)。
+- AI 本次未输出 bc_suggestions(项目 BC 已全填 25 项无可补,行为合理);AI 补充来源标签待有未设置项的生成场景验证。
+
+---
+
+## TEST-043:PROMPTS_DIR 路径 Bug 根因修复(AI prompt 从未生效)+ bc_suggestions 实测
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-03 |
+| 测试环境 | Windows / Python 3.12.10 / FastAPI :8000 / Vite :5173 / Kimi k3 |
+| 测试目的 | 验证 AI 补充 BC(bc_suggestions);三次生成均未被补充 → 深挖根因 |
+
+### 根因(重大发现)
+
+- 三次生成 bc_suggestions 均为空。查 `ai_call_logs.prompt_preview` 发现 system prompt 是 `_default_prompt()` 中文兜底版——**真 prompt(generate.txt)从未被加载**。
+- 根因:`web/backend/app/config.py` 中 `BACKEND_DIR = Path(__file__).parent` = `web/backend/app/`,`PROMPTS_DIR = BACKEND_DIR / "prompts"` 指向不存在的 `app/prompts/`(实际在 `web/backend/prompts/`)→ `prompt_path.exists()` 为 False → 静默走 fallback。
+- **影响面(回溯解释多个历史问题)**:AI 变量名编造(OuterDia/Stator_Lam_Length)、acceptance_criteria 嵌套 dict、bc_suggestions 不输出——全部因为模型从未看到含 13 变量清单与设计原则的真 prompt。result_analysis 的 analyze.txt 同样未生效。
+
+### 修复
+
+- `PROMPTS_DIR = BACKEND_DIR.parent / "prompts"`;附带修复:prompt 措辞改强制(REQUIRED FIELD)、`ai_plan.py` 项目上下文 BC 全量展开(未设置字段显式 null,模型才能看到哪些可补)、`SCAN_PARAM_CN` 补 4 个中文名。
+
+### 验证(修复后实测,全部达标)
+
+- 端到端(project:仅 3 项 BC)→ AI 补充 7 项(target_torque_nm=5.7/target_efficiency_pct=92/target_ripple_pct=5/magnet_temp_c=100/cooling_type=Natural/voltage_v=150/max_losses_w=260),bc_meta 标记 `user:3 + ai:7`,用户值未被覆盖。✅
+- 扫描变量全部标准注册名(Airgap/Magnet_Length/Turns_per_Coil),无编造名。✅
+- warnings 干净(仅 model_path 回填提示)。✅
+- Edge CDP 截图:BC 区"用户指定"(绿)/"AI 补充"(蓝)标签渲染正确。✅
+- 测试产物已清理(plans 32-35 + project 14 删除)。
+
+### 遗留
+
+- 历史方案(prompt 修复前生成)的变量名/验收标准质量参差,建议用新 prompt 重新生成关键方案。
+- experience/extract.txt 不存在(experience_enhancer 仍走其自带 fallback,既有状态未变)。
+
+---
+
+## TEST-041:前次会话遗留测试脚本更新与回归(拓扑变量名断言对齐)
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-03 |
+| 测试环境 | Windows / Python 3.12.10 |
+| 测试目的 | 前次会话(P5-M2)未跟踪测试脚本 `test_topology_variable_map.py` 断言基于旧错误变量名(Stator_Outer_Diameter 等),在 TEST-036 修正后 6/8 失败;更新断言至 MARS 实测名并全量回归 |
+
+### 测试步骤与结果
+
+1. `test_plan_schema.py`(plan_schema 字符串枚举支持):7/7 PASS,直接入库。✅
+2. `test_topology_variable_map.py` 初跑 2/8——失败断言正是旧错误名,证实 TEST-036 修正改变了行为。✅(预期失败)
+3. 更新断言:`Stator_Outer_Diameter→Stator_Lam_Dia`、`Stator_Inner_Diameter→Stator_Bore`、`Outer_Rotor_Diameter→RotorOuterDiameter`;新增"旧错误名应被判 unknown"反向断言(防回归);docstring 注明修正来源。✅
+4. 更新后全量回归:**8/8 PASS**(含 plan23 80 点回归,RFM→AFM alias 解析到 MARS 实测名)。✅
+5. 两脚本 + `src/plan_schema.py` + `style.css`(P6-M1 设计令牌遗留)ASCII 检查通过并入库。✅
+
+### 踩坑
+
+- Edit 工具对该文件 3 处替换报告成功但实际未生效(old_string 失配未报错),靠 grep 复查 + 重跑测试发现。**教训:Edit 后必须 grep 验证目标字符串已消失/出现**——与"vue-tsc 拦截静默失效"同类问题,测试驱动再次兜底。
+
+### 结论
+
+前次会话(P5-M2/P6-M1)全部遗留改动已验证入库(commit `3dd42b9`);测试断言与 MARS 实测变量名一致,旧错误名有反向防回归断言。
+
+
+---
+
+## TEST-044:新 prompt 质量对比验证 + experience/extract.txt 补建实测
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-03 |
+| 测试环境 | Windows / Python 3.12.10 / FastAPI :8000 / Kimi k3(真 prompt 首次生效) |
+| 测试目的 | ①新 prompt 生成质量对比(变量名/验收标准格式)②补建 experience/extract.txt 并实测 |
+
+### 1. 新 prompt 生成质量(project 13 端到端)
+
+| 维度 | 修复前(兜底 prompt) | 修复后(真 prompt) |
+|---|---|---|
+| 扫描变量名 | 编造名(OuterDia/Stator_Lam_Length)被裁剪 | 全注册标准名(Airgap/Magnet_Length/Magnet_Arc_[ED]) |
+| acceptance_criteria | 嵌套 dict(前端无法渲染) | 规范 list 判断式 4 条(efficiency_pct >= 90 等) |
+| warnings | 多条裁剪/兜底警告 | 仅 model_path 回填提示 |
+| bc_suggestions | 从不输出 | 正常输出(TEST-043 已证 7 项) |
+
+### 2. experience/extract.txt 补建与实测
+
+- 发现 `prompts/experience/` 为空目录,experience_enhancer 一直走单行 fallback;且其 `max_tokens=3000` 有撞顶隐患(同步改为 KIMI_MAX_TOKENS)。
+- 补建正式 extract.txt(design_rules/failure_patterns/parameter_sensitivity/optimal_region/recommendations 结构 + 证据引用要求)。
+- 函数级真实 AI 调用验证(plan 28 三点真实数据,无需执行器):prompt 从文件加载确认;输出结构完整;规则带量化证据(+21% 转矩/-26.7% 损耗)、正确声明样本量仅 3 的保守性、置信度分级;物理趋势与 TEST-039 实测一致。**PASS**。
+- 注:`smart-extract` 端点为规则化提取(不走 AI);extract.txt 生效路径是 adaptive_loop 自适应闭环。
+
+### 遗留
+
+- 历史方案(plan 1-31)系兜底 prompt 时期生成,建议关键方案重新生成。
+- 自适应闭环端到端(含 AI 经验提取)待执行器在线后实测。
+
+---
+
+## TEST-045:自适应闭环集成修复(真 prompt 启用后暴露的断层)
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-03 |
+| 测试环境 | Windows / Python 3.12.10 / FastAPI :8000 / Kimi k3(真 prompt) |
+| 测试目的 | 自适应闭环(adaptive_loop)端到端验证——extract.txt 真实生效路径 |
+
+### 发现的集成断层(P3-M5 遗留,真 prompt 启用后暴露)
+
+1. **字段名不匹配**:`generate_plan` 存 convert 后的 `variables`,`initialize_search` 读 `scan_variables` → "No valid scan variables"。修复:generate_plan 归一化补 `scan_variables`,initialize_search 双名兼容。
+2. **数值键名不匹配**:convert 输出 `start/stop/step`,initialize_search 读 `min_value/max_value` → 参数全丢。修复:`lo = var.get("min_value", var.get("start"))` 兼容 + values 数组推导范围。
+3. **闭环方案缺 topology/model_path**:闭环无项目上下文,plan 缺这两项 → 无法仿真。修复:generate_plan 内拓扑归一化 + `default_model_for` 回填。
+4. **L0 参数名口径断层(未修复,记录为待办)**:L0 引擎期望 BC 风格名(`airgap_mm`/`outer_diameter_mm`),闭环传 Motor-CAD 名(`Airgap`/`Magnet_Length`)→ L0 "No checks were performed" 全判不可行 → 初始 LHS 批次为空。需要 Motor-CAD 名 → L0 BC 名的语义映射层(注意 Magnet_Length(轴向) ≠ magnet_thickness_mm(径向厚度),映射不能瞎对应)。
+
+### 验证结果
+
+- 闭环 generate-plan:phase=plan_generated,topology=SSSR,model_path 自动回填 MARS,变量标准名。✅
+- init-search:HTTP 200,search_initialized。✅
+- 转换逻辑函数级:start/stop、values 数组、无效变量跳过 3 断言全 PASS。✅
+- next-batch:返回空(L0 断层所致,已知待办)。
+- update-experience:extract_insights 已在 TEST-044 函数级验证(相同输入结构与真实数据);闭环集成段(all_results 收集→提取→经验条目)**草案待验证**,待 L0 口径修复后实测。
+
+### 遗留
+
+- **L0 参数名口径映射**(Motor-CAD 名 → L0 BC 名)为闭环搜索层的关键待办。
+- 闭环全流程(选点→执行器仿真→report→update-experience)待 L0 修复后端到端实测。
+
+---
+
+## TEST-046:自适应闭环全链路端到端实测(extract.txt 真实生效路径)
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-03 |
+| 测试环境 | Windows / Python 3.12.10 / FastAPI :8000 / Kimi k3 / 本地执行器(真实 Motor-CAD) |
+| 测试目的 | 闭环全链路:create → generate-plan → init-search → next-batch → 执行器真实仿真 → report-results → update-experience |
+
+### 修复的问题(本轮累计 4+2 个)
+
+1. **L0 参数名口径**(TEST-045 遗留):L0 引擎期望 BC 风格名,闭环传 Motor-CAD 名 → 在 `src/afmcore/l0/prescreening.py` 加 `MOTORCAD_TO_L0` 语义映射(Airgap→airgap_mm、Magnet_Length→magnet_thickness_mm[轴向磁通磁钢厚度=轴向尺寸]、RMSCurrent→current_a、Magnet_Temperature→magnet_temp_c、Stator_Outer/Inner_Diameter→outer/inner_diameter_mm),evaluate 入口翻译且 L0 原生 key 优先。函数级验证:4 项检查全 PASS + 原生 key 优先。✅
+2. **初始批次不消费**:`select_next_batch` 从不消费 generate_initial_batch 的 pending 点 → 开头先返回 pending 批次。✅
+3. **report-results 路由缺失**:`report_loop_results` 函数无 `@router.post` 装饰器,结果回传端点不可达(P3-M5 遗留)。已补。✅
+4. **export inf 序列化**:`best_objective_value` 初始 inf → JSON 500。导出转 None。✅
+5. **import 哨兵恢复**:export 的 None 导入后覆盖默认 inf → `value > None` TypeError。导入时 None 保持默认哨兵。✅(export/import 检查点机制顺带实测通过)
+
+### 端到端结果(全部真实数据)
+
+- 闭环选点:2 批共 8 点(Airgap 0.6/0.9/1.2 × RMSCurrent 20/30/40/50 组合)
+- 执行器真实仿真:2 个任务 8 点全部 completed(4/4 + 4/4)
+- 结果趋势合理:电流↑→转矩↑损耗↑效率↓(I=20A: 0.51Nm/85.7%;I=50A: 1.21Nm/83.6%)
+- report-results:phase=results_analyzed,置信度 D(点数少属合理评级)
+- **update-experience**:phase=experience_updated,AI 提取 4 条设计规则含量化证据(如"效率峰值在中低转矩点 0.72Nm/86.3% 而非最低转矩点"),经验条目生成。**extract.txt 在闭环真实生效**。✅
+
+### 踩坑
+
+- 任务构造曾把 motorcad_var=None 的参数(Inner_Rotor_Diameter 等)用 name 回退写入 → 执行器报 "Could not find"。修正:只写 motorcad_var 非 None 的参数。
+- next-batch 返回字段是 `points` 不是 `batch`(查询时误读字段名导致误判为空)。
+
+### 遗留
+
+- 闭环只跑到首批两段;主动学习后续批次(信任域)未验证。
+- 测试任务已清理;闭环 loop 为内存态(重启即失,export/import 已验证可恢复)。
+
+---
+
+## TEST-047:submit-batch 生产路径验证(闭环→任务→执行器)
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-03 |
+| 测试目的 | 验证闭环 submit-batch(生产路径:闭环自动创建任务给执行器) |
+
+### 发现与修复
+
+- **submit_batch_to_executor 缺固定参数合并**:原实现只传裸扫描点参数({Airgap, RMSCurrent, point_id}),执行器不读 plan_data.fixed_params → 点会缺 CurrentDefinition/MessageDisplayState 等关键固定参数。已修复:每点合并 plan 中 motorcad_var 非 None 的可写固定参数。
+- **验证**:submit-batch 创建任务 f19ba592(12 点 pending 剩余全部);任务文件每点 33 键(30 固定 + 扫描 + point_id),无错误变量名(Inner_Rotor_Diameter/Steel_Grade 已排除)。执行器秒捡起跑。✅
+
+### 发现的已知缺口(未修)
+
+- **执行器→闭环结果自动回传缺失**:执行器跑完只调通用 `/tasks/{id}/results` 上报,不识别 adaptive_batch 类型、不调闭环 `/adaptive/loops/{id}/report-results`。闭环拿不到结果需手动桥接。这是 P3-M5 设计但未实现段,需执行器加 loop 回传逻辑(或 Web 侧轮询桥接)。
+
+### 遗留
+
+- 任务 f19ba592(12 点)在跑,完成后可手动 report 进闭环做主动学习第二轮验证。
+
+---
+
+## TEST-048:闭环生产路径全跑通 + 经验提取数据完整性修复
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-03 |
+| 测试环境 | Windows / FastAPI :8000 / Kimi k3 / 本地执行器(真实 Motor-CAD) |
+| 测试目的 | submit-batch 生产路径全跑通 + 第二轮主动学习 + 经验提取数据完整性 |
+
+### 结果
+
+- **submit-batch 任务 12/12 全部成功**(f19ba592,~24 分钟真实仿真)——生产路径(闭环自动创建任务→执行器捡起跑)验证通过。
+- **12 点 report 入环**:point_id 映射(submit_batch 嵌入的 point_id 直接用,无需猜匹配);置信度 D→C(点数增多合理提升);best 效率 86.295→89.414(LHS 探索到更优点)。
+- **update-experience 第二轮成功**:experience_updated。
+- **AI 反馈暴露数据缺陷**:"No input parameter values are provided"——report_results 的 all_results 只合 metrics 不含输入参数 → 敏感性分析无法做(sensitivity=unknown)。
+
+### 修复(数据完整性)
+
+1. `adaptive_loop.report_results`:all_results 条目合入点的输入 params(从 search.state.points 按 point_id 取)。
+2. `experience_enhancer._condense_results`:输入参数经 `MOTORCAD_TO_L0`(单一事实源)翻译成 BC 名再提取——否则闭环的 Motor-CAD 名输入不会被提取。
+
+函数级验证:Airgap→airgap_mm、RMSCurrent→current_a 翻译提取 PASS。
+
+### 验证边界声明
+
+两个数据完整性修复影响**未来**闭环(当前 loop 的 12 点旧条目已无参数,无法补救);函数级验证通过,端到端效果待下次闭环运行确认。
+
+### 遗留
+
+- 执行器→闭环自动回传缺失(TEST-047 记录,P3-M5 设计未实现段)。
+- 前端 AdaptiveOptimize 走 /api/search/*(独立于 adaptive loops),其契约与 search 服务一致;adaptive loops 无前端页面。
+
+---
+
+## TEST-049:执行器→闭环自动回传(P3-M5 最后缺口闭合)
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-04 |
+| 测试目的 | 闭合 TEST-047 发现的"执行器跑完不调闭环 report-results"缺口 |
+
+### 修复
+
+- `scripts/task_executor.py` 加 `_report_to_adaptive_loop`:`execute_task` 完成后,若 `task_type == "adaptive_batch"` 且有 `loop_id`,把每点结果映射为 `{point_id, metrics, status}` POST 到 `/api/adaptive/loops/{loop_id}/report-results`。best-effort(回传失败不影响任务本身的 results 上报)。
+
+### 验证(链路级,真实闭环端点)
+
+- 构造 adaptive_batch 假任务 + 3 点结果 → 调 `_report_to_adaptive_loop` → 闭环 n_results 0→3。✅
+- 负面对照:普通 scan 任务(task_type=scan)不触发回传,n_results 不变。✅
+- 执行器重启加载新逻辑,在线。✅
+
+### 验证边界声明
+
+链路级验证(回传调用 + 闭环接收),用合成 metrics 测连通性;仿真段真实性已由 TEST-046/048 的真实 Motor-CAD 运行证明。全闭环自动流转(submit-batch→执行器→自动回传→下一批)的端到端长时运行未做(需真实仿真数十分钟),建议生产使用中观察。
+
+### 遗留
+
+- 无阻断项。闭环全链路(含自动回传)已可用。
+
+---
+
+## TEST-050:search 服务 L0 验证 + 指标字段名对齐事实源
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-04 |
+| 测试目的 | ①search 服务(前端自适应优化页后端)在 L0 修复后出点验证 ②修复结果表转矩/脉动列空缺 |
+
+### 结果
+
+1. **search 服务出点验证**:`POST /api/search/create`(Airgap 0.6-1.5)→ initial_points 4、pending 4、infeasible 0(L0 修复前全判 infeasible)。前端自适应优化页后端链路健康。✅
+2. **指标字段名对齐事实源**:走查发现方案详情"最新结果摘要"的平均转矩/转矩脉动列显示"-"——前端列定义读 `average_torque_nm`/`torque_ripple_pct`,而指标单一事实源(afmcore/metrics.py)用 `tavg_nm`/`ripple_pct`。修复:PlanDetail/Dashboard 列定义对齐事实源 + 平铺时旧名别名兼容(老数据 average_torque_nm → tavg_nm)。截图验证:转矩/脉动列正常显示(0.566/5.45 等),趋势符合物理。✅
+
+### 说明
+
+- 这是字段名不匹配问题家族的又一实例(前端旧命名 vs afmcore 事实源)。Dashboard 图表仅用 efficiency/losses(未变字段)不受影响。
+- vue-tsc 0 错误。
+
+---
+
+## TEST-051:闭环经验入库(闭环价值闭环)
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-04 |
+| 测试目的 | 验证闭环 AI 提取的经验真正沉淀到经验库(可被后续 AI 生成复用) |
+
+### 发现
+
+- `generate_experience_entry` 只返回 dict(注释自称 "ready for database storage"),`update_experience` 只在 HTTP 响应里返回、**从不入库**——重启即失,经验库页看不到,后续 AI 生成(读经验库 existing_experience)无法复用。闭环价值断裂。
+
+### 修复
+
+- `adaptive_loop.update_experience` 加 `_persist_experience_case`:把 AI 洞察映射为 `ExperienceCase` 入库——best feasible point 的 params/metrics + summary+design_rules 作 conclusion + tags(topology/ai-insights/loop)。best-effort(入库失败不中断闭环),返回 experience_case_id。
+
+### 验证(真实 12 点数据)
+
+- 重建闭环 + 灌入 12 点真实仿真结果(f19ba592)+ update-experience → `experience_case_id: 7`,`experience_cases` 表 6→7 行,结论为 AI 洞察文本,tags 含 ai-insights。✅
+- `/api/experience` 列表可见 case 7(经验库页可读)。✅
+
+### 说明
+
+- 至此闭环价值完整闭环:仿真 → AI 提取 → 经验入库 → 后续 AI 生成复用。
+- 验证用例(case 7)基于真实仿真数据,保留入库(有参考价值)。
+
+---
+
+## TEST-052:经验库复用断裂修复(AI 生成不读经验库)
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-04 |
+| 测试目的 | 验证"后续 AI 生成复用经验库"的真实性(上次口头声明未验证) |
+
+### 发现(真实断裂)
+
+- `ai_plan.py` 的 generate-and-save(用户实际用的"AI 一键生成")调 `generator.generate()` 时**不传 existing_experience**;`/generate` 端点也只在请求体显式传了才用;前端两个生成入口都不传。→ **经验库的值根本没流入 AI 生成**,闭环提取的经验入库了但生成时不读,价值链在"复用"环节断裂。
+
+### 修复
+
+- 新增 `_load_experience_cases(db, topology)`:按拓扑从 experience_cases 加载最近 5 条(params/metrics/conclusion/tags)。
+- generate-and-save:调用前自动加载同拓扑经验传给 generate()。
+- /generate:请求未显式传经验时自动加载(显式传则优先),并补 db 依赖。
+
+### 验证
+
+- `_load_experience_cases` 直接调用返回 5 案例(含闭环 case 7 的 AI 洞察)。✅
+- 端到端生成成功(HTTP 200)。✅
+- 附带实证:最新 ai_call_log 的 system prompt 开头已是英文真 prompt("You are an axial flux motor...")——PROMPTS_DIR 修复生效的直接证据。
+- 注:prompt_preview 只存前 500 字符(截断在 system prompt),user message 中的经验案例在日志里不可见——日志截断所致,非未加载;功能链路(加载 5 案例 + generate 对非空经验拼入 user message)已确认。
+
+### 说明
+
+- 至此经验价值链完整:仿真 → AI 提取 → 入库(TEST-051)→ 后续生成自动加载复用(本次)。
+
+---
+
+## TEST-053:批次语义修复 + 全自动闭环真实端到端(零手动桥接)
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-04 |
+| 测试环境 | Windows / FastAPI :8000 / Kimi k3 / 本地执行器(真实 Motor-CAD) |
+| 测试目的 | 修复批次语义(submit 全部 pending 问题)+ 真实全自动闭环验证 |
+
+### 发现的批次语义问题
+
+- submit-batch 提交全部 24 个 pending 点(point_ids 0-23)而非"当前批次",破坏分批自适应语义。根因:select_next_batch 返回点前 N 个 pending 但不标记,它们仍 pending → submit 拿全部。
+
+### 修复(引入 dispatched 状态)
+
+- select_next_batch:选中点标 status="dispatched"(初始消费段 + 主动学习段)→ 不再被 get_pending_points 重复返回/提交。
+- submit_batch_to_executor:只提交 status=="dispatched" 的当前批次点。
+- batch_summary 状态桶加 dispatched。
+
+### 函数级验证
+
+初始 pending 4 → batch1 选 2 标 dispatched → pending 减到 2(不重复)→ submit 目标正好 2(修复前是全部)。✅
+
+### 真实全自动端到端(零手动桥接,核心成果)
+
+- 建闭环 → AI 生成 → init → next-batch → submit-batch(5 点 dispatched,AI batch_size 覆盖)→ 执行器真实仿真 5/5 → **执行器自动回传**(全程未手动调 report-results)→ 闭环 n_results 0→5 自动增加、phase 自动推进 results_analyzed。✅
+- update-experience:experience_case_id=8 入库;**敏感性分析正常**(Current strong positive、Airgap moderate negative,不再 unknown——TEST-048 数据完整性修复在真实闭环生效);summary 含量化结论(torque 随电流 15A→37.5A 从 0.330→1.006 Nm ~3x)。✅
+
+### 意义
+
+**P3-M5 自适应闭环至此真正全自动**:submit-batch→执行器真实仿真→自动回传→AI 分析→经验入库→后续生成复用,全程无手动桥接。这是本项目"AI 驱动智能仿真闭环"的完整实证。
+
+### 遗留
+
+- 主动学习的多轮信任域收敛未长时观察(点数规模问题,生产使用验证)。
+- 执行器在后端重启期间心跳会中断(需重启执行器恢复)——可考虑执行器心跳自愈(本轮未做,非阻断)。
+
+---
+
+## TEST-054:执行器心跳独立线程(修复任务执行期间误判 offline)
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-04 |
+| 测试目的 | 修复执行器架构弱点:心跳与任务执行同线程串行,长跑任务期间心跳停止被误判 offline |
+
+### 问题
+
+- 执行器 `poll_loop` 单线程串行:`_send_heartbeat()` 与 `execute_task()` 同线程。Motor-CAD 单点求解约 2 分钟,跑点期间心跳完全停止 → 后端按 last_heartbeat 超时误判执行器 offline。本轮实测中执行器 30216 出现"进程活着但 offline"假死。
+
+### 修复
+
+- `start_polling` 拆为两个线程:独立 `heartbeat_loop`(每 interval 秒心跳)+ `poll_loop`(取任务执行)。任务执行期间心跳持续。
+
+### 验证
+
+- 编译 + ASCII 通过;重启执行器上线,15s 后持续在线(心跳持续),旧执行器正常超时离线。✅
+
+### 说明
+
+- 假死的完整根因现场已消失(进程活着但日志停在启动)无法完全复现;HTTP 调用均有 timeout(排除无限阻塞);心跳/执行同线程是确定存在的架构弱点,已修。
+
+---
+
+## TEST-055:前端全路由走查(13 路由无错误)+ /generate 回归
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-04 |
+| 测试环境 | Edge headless CDP(截图 + 控制台/异常捕获)|
+| 测试目的 | /generate 端点回归 + 全前端路由健康走查 |
+
+### 结果
+
+1. /generate 回归:HTTP 200,方案正常生成(变量 Airgap、warnings 干净),db 依赖与经验自动加载未破坏该端点。✅
+2. 全路由走查:CDP 脚本捕获 Runtime.consoleAPICalled(error) + Runtime.exceptionThrown,13 个路由全部 PAGE_ERRORS(0)。✅
+3. 抽查非白屏:L0 预筛选 / 多保真度校准 / 高级可视化截图确认正常渲染。✅
+
+### 说明
+
+- 走查工具 _cdp_shot.mjs 扩展了 js: 表达式点击与错误捕获能力,是可复用的 UI 冒烟手段。
+- 所有用户可见页面无控制台错误、无未捕获异常,前端整体健康。
+
+---
+
+## TEST-056:自适应闭环前端页(AdaptiveLoop.vue)
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-04 |
+| 测试目的 | 闭环后端已全自动实测通畅但无 UI 入口,新建前端页面 |
+
+### 实现
+
+- 新建 `views/ai/AdaptiveLoop.vue`:创建闭环表单(需求/总预算/批次大小)+ 闭环列表 + 详情(阶段状态卡、方案摘要、预算进度条、批次历史表、经验提取区)。
+- **一键自动运行**(前端驱动状态机):generate-plan → init-search → 循环(next-batch → submit-batch → 轮询 n_results 等执行器自动回传 → 检查收敛)→ update-experience。利用执行器自动回传(TEST-049),前端只需轮询 n_results。
+- 补 `adaptiveApi.submitBatch`(api/ai.ts 缺失)。
+- 路由 `/ai/adaptive-loop` + 导航"AI 智能"组加入口 + 面包屑映射。
+
+### 验证
+
+- vue-tsc 0 错误;页面渲染 PAGE_ERRORS(0)。
+- 截图:列表页(闭环列表 + 创建表单)+ 详情页(状态卡"经验已更新/5 点/最优 86.12"+ 预算进度 73% + 批次历史 + 经验提取区)均正常。
+- **验证边界**:一键自动运行的各步骤 API 已在 TEST-045~053 单独实测;UI 串接逻辑(状态机 + 轮询)未做真实长时运行(需数十分钟仿真),属"功能已接、待实机长跑确认"。
+
+---
+
+## TEST-057:prompt_preview 截断修复 + 经验进入 prompt 实证
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-04 |
+| 测试目的 | 修复 AI 调用日志截断导致的可观测性缺陷 + 实证经验案例进入 AI prompt(TEST-052 遗留验证缺口) |
+
+### 问题
+
+- `ai_client._log_call` 存 `json.dumps(messages[:3])[:500]`——500 字符截断在 system prompt 开头,user message(含项目 BC + 经验案例)完全不可见。TEST-043/052 两次因此无法从日志确认 AI 实际收到的内容。
+
+### 修复
+
+- prompt_preview:`messages[:3]` → `messages`(全部),`[:500]` → `[:8000]`;response_preview `[:1000]` → `[:2000]`。
+
+### 验证(实证经验进入 prompt)
+
+- 重新生成方案后,最新日志 prompt_preview 长度 8000,user message 含"参考经验案例(5个):[{topology: SSSR, params: {Airgap, RMSCurrent}, metrics: {tavg_nm...}...}]"。✅
+- 经验价值链最后一环实证:闭环提取的经验(TEST-051 入库)在 AI 生成时真实进入 prompt。✅
+
+### 意义
+
+AI 调用的输入完全可观测,后续调试 prompt 行为(变量名、BC 补充、经验复用)可直接查日志,不再黑盒。
+
+---
+
+## TEST-058:闭环 UI 自动运行端到端实测 + axios 超时根因修复
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-04 |
+| 测试目的 | 实测闭环页"一键自动运行"状态机(TEST-056 遗留:UI 串接未实机跑过) |
+
+### 发现的根因
+
+- 首次 UI 自动运行:方案已生成(phase=plan_generated)却跳"运行中断"。
+- 根因:**axios 默认超时 30s < AI 生成方案约 40s**。前端中止报错进 catch,后端继续跑完——状态错位。手动调 init-search 成功证实后端无恙。
+- 对照:aiPlanApi 的 generate/generateAndSave/refine 早已显式设 180s(前人踩过同一坑),新加的 adaptiveApi 漏了。
+
+### 修复(web/frontend/src)
+
+1. `api/ai.ts`:adaptiveApi 的 generatePlan / reportResults / updateExperience 补 `{ timeout: 180000 }`。
+2. `AdaptiveLoop.vue` startAutoRun 改为**断点续跑**:进入时先刷新 phase,init 才生成方案,plan_generated 才初始化搜索,search_initialized 及以后直接进批次循环。任何中断后重点"一键自动运行"即可从断点继续。
+
+### 端到端实测(UI 驱动全闭环)
+
+- 闭环 loop_20260904_022534 从 search_initialized 续跑:批次0(3点)→ 执行器仿真 → 自动回传 → 分析 → 批次1(2点)→ 回传 → 预算耗尽(5/5)→ **自动提取经验**。
+- 结果:5 点全部成功、0 失败、可行 5、最优 86.346 @ Airgap=1.5;UI 详情页正确展示(预算 100%、批次历史两行、经验提取区)。
+- 经验库新增案例 9(SSSR,"本批次包含5个有效仿真点…气隙是主导权衡变量")——经验入库由 UI 自动触发完成。✅
+- 执行器全程 online(心跳独立线程在真实长跑中经受住考验)。✅
+
+### 结论
+
+闭环"一键自动运行"从 UI 点击到经验入库的全链路首次端到端实测通过。TEST-056 的验证边界闭合。
+
+---
+
+## TEST-059:前端生产构建验证 + 补提交共享组件
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-04 |
+| 测试目的 | dev 服务器正常≠生产构建能过;git status 巡检发现共享组件未跟踪 |
+
+### 结果
+
+1. **生产构建**:vite build 22.8s 通过,exit 0。全部页面 chunk 正常产出(含 AdaptiveLoop 10.18 kB)。仅大 chunk 警告(vendor-element 948 kB / vendor-echarts 1.04 MB),属优化项非错误。✅
+2. **补提交**:PageHeader.vue / SectionCard.vue / StatCard.vue 三个共享组件被 5 个已跟踪页面(Dashboard/ProjectList/PlanDetail/TaskManager/AdaptiveLoop)引用却一直未跟踪——克隆即构建失败。已补提交。✅
+
+### 说明
+
+- 大 chunk 警告可通过 manualChunks 拆分优化,未处理(不影响功能)。
+
+---
+
+## TEST-060:Motor-CAD 稳态热仿真首次实测 + 热指标别名登记
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-04 |
+| 测试环境 | Motor-CAD 2026R1 (v261),pymotorcad(motorcad2maxwell/.venv),MARS 模型,1055@localhost 许可证可达 |
+| 测试目的 | 首次真实验证 Motor-CAD 热仿真(此前 HANDOFF 标注"热求解待真实验证") |
+
+### 背景
+
+P5-M6 引入的 `enable_thermal` 开关使用了两个**不存在的 API**,从未真正跑通:
+- `mc.do_thermal_calculation()` —— pymotorcad 无此方法(真实为 `do_steady_state_analysis`)
+- `mc.export_results("Thermal", ...)` —— solution_type 无 "Thermal"(真实为 "SteadyState")
+
+### 实测结果(scripts/run_thermal.py)
+
+| 步骤 | 耗时 | 结果 |
+|---|---|---|
+| 电磁计算 do_magnetic_calculation | 127.8s | 正常,损耗作为热源 |
+| 稳态热 do_steady_state_analysis | 6.0s | 正常 |
+| 导出 export_results("SteadyState") | — | 9 个 section 完整 |
+
+**热指标实测值**:
+
+| 指标 | 值 | 说明 |
+|---|---|---|
+| winding_temp_c 绕组平均 | 67.95°C | `T [Winding (A) Average]` |
+| winding_hotspot_temp_c 绕组热点 | 74.59°C | `T[绕组最高]` = EWdg Outer Max |
+| magnet_temp_c 磁钢 active | 118.15°C | 磁钢最高温,热风险首要关注 |
+| stator_temp_c 定子轭 | 55.49°C | `T[定子轭]` |
+| bearing_temp_c 后轴承 | 88.47°C | 前轴承仅 49.19°C,后轴承是热风险点 |
+| temp_rise_c 温升 | -56.88°C | ⚠ 负值,见下 |
+| thermal_resistance_k_w 热阻 | -11.07 K/W | ⚠ 负值,见下 |
+
+### 发现的问题
+
+1. **热边界条件异常**:MARS 模型 `[Miscellaneous]` 中 `Ambient_Temperature=125`(环境 125°C,
+   而辐射环境 `T_Ambient_Radiation=40` 正常),导致温升/热阻为负值。
+   **待用户确认后修正为 25~40°C**。
+
+### 修复内容
+
+1. `scripts/robust_motorcad.py`:`do_thermal_calculation()` → `do_steady_state_analysis()`;
+   `export_results("Thermal")` → `export_results("SteadyState")`。
+2. `src/afmcore/metrics.py`:7 个热指标补充实测字段别名(中英文),bearing_temp_c 映射到后轴承。
+3. 新增 `scripts/run_thermal.py`:独立热仿真验证脚本(可复用)。
+
+### 验证
+
+- `scripts/test_metrics_extension.py` 20 项全部通过(含热指标、enable_thermal 签名)。
+- 热导出文件回放 `extract_all_metrics` 提取 7 项热指标全部命中。
+
+### 结论
+
+热仿真链路首次真实跑通,API 与字段名已核实登记。剩余阻塞:模型环境温度异常待确认。
+
+### 补充实测(磁热耦合 vs 分离式,同日)
+
+| 项 | 分离式(先电磁后热) | 磁热耦合 do_magnetic_thermal_calculation |
+|---|---|---|
+| 耗时 | 134s | 474.2s(~3.5×) |
+| 磁钢 active | 118.15°C | 110.68°C |
+| 绕组热点 | 74.59°C | 76.43°C |
+| 平均转矩 | 0.566 Nm | 0.515 Nm |
+| 转矩脉动 | 5.45% | 2.78% |
+| 总损耗 | 49.92W | 42.57W |
+
+**结论**:磁热耦合更准确(温度反馈迭代)但更慢。"省时间"选分离式(enable_thermal 已实现)。
+
+---
+
+## TEST-061:执行器 enable_thermal 端到端真实验证
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-04 |
+| 测试目的 | 验证执行器 enable_thermal 全链路(RobustMotorCADSolver→热求解→热指标落盘 CSV),此前仅代码透传 + 单元测试 |
+
+### 结果(scripts/verify_enable_thermal.py)
+
+- `RobustMotorCADSolver(enable_thermal=True)` 跑单点:电磁 + 稳态热求解均成功(status=OK)。
+- 7 项热指标全部合并到 point result 并落盘 `scan_results.csv`(header 含全部热指标列)。
+- 环境温度覆盖 25°C 后:温升 +27.57°C、热阻 +5.657 K/W(物理合理正值)。
+
+### 结论
+
+执行器 enable_thermal 链路端到端 **PASS**。热仿真已完整接入生产链路(配置开关 → adapter → solver → 热指标落盘)。
+
+### 补充(环境温度固化)
+
+`ambient_temperature` 已固化到执行器配置(默认 25°C,`executor_config.json` + `EXECUTOR_AMBIENT` env 可调),
+`RobustMotorCADSolver(enable_thermal=True, ambient_temperature=25.0)` 构造参数方式复测端到端 PASS
+(温升 +27.57、热阻 +5.657,与 params 传参方式结果一致)。

+ 286 - 0
docs/archive/P1-P4回顾与P5规划.md

@@ -0,0 +1,286 @@
+# PCB 轴向磁通电机自动化仿真系统 — P1~P4 全历程回顾与 P5 规划
+
+> **交接文档**:供新会话接续开发 P5 使用。本文汇总 P1~P4 各阶段的**规划、设计、验证**事实,并给出 P5 规划与开工指引。
+> 生成日期:2026-08-30 | 基线:git HEAD `11630bf`(工作区仅剩用户自建论文目录未入库)
+
+---
+
+## 0. 如何使用本文档
+
+- **新会话开工 P5 前**:先读本文档「§2 系统全景」「§6 已知遗留」「§7 P5 规划」「§8 开工指引」,再按 §8 的必读清单进入项目。
+- **本项目所有设计/计划/记录的权威来源**(按优先级):
+  | 文档 | 内容 |
+  |---|---|
+  | `AGENTS.md` | AI 工具工作说明 + 硬性工程约束 + 项目纪律(**必读**) |
+  | `docs/KNOWLEDGE_BASE.md` | 核心知识库(环境事实/参数语义/探测技术/SOP/已踩坑) |
+  | `PCB轴向磁通电机自动化仿真系统设计方案介绍.md` | 设计方案 V2.0(含附录 B 实现现状对照) |
+  | `docs/PLATFORM_DESIGN_V2.md` | 平台化升级设计(短板 S1~S8 → 批次) |
+  | `docs/P3_IMPLEMENTATION_PLAN.md` / `docs/P4_IMPLEMENTATION_PLAN.md` | P3/P4 实施计划 |
+  | `docs/TEST_RECORDS.md` | 测试记录 TEST-001~015 |
+  | `README.md` | 里程碑、阶段路线图、快速开始 |
+
+- **命名澄清**(历史批次名有重叠,务必区分):
+  - **Phase 1~4**:原设计方案路线图的四个阶段(P1 最小闭环 / P2 Web 方案系统 / P3 AI 闭环 / P4 Web AI 集成+部署),**已全部完成**。
+  - **平台化批次(P3 期 + P4 期)**:以 `src/afmcore` 共享核心层为主线的升级工作。P3 期(commit 用 `p3-m1..m6`)完成策略层/调度/闭环;P4 期(commit 用 `p4-m1..m5`)完成 Schema 统一/文档/EXE/前端收敛曲线/L0 上提。
+  - **P5**:本文 §7 规划的新一轮开发,尚未开工。
+
+---
+
+## 1. 系统全景
+
+### 1.1 一句话定位
+
+> 面向 PCB 定子轴向磁通电机(AFM)的**双系统解耦、可插拔工具、可配置拓扑、可扩展策略**的自动化仿真平台:Web 端生成/优化方案 → 本地 EXE 执行仿真 → 结果入库 → 经验库反哺,AI 全程辅助。
+
+### 1.2 双系统架构(已落地)
+
+```
+系统一(Web 端,联网/AI)                   系统二(本地 EXE,离线可跑)
+┌──────────────────────────┐             ┌──────────────────────────┐
+│ FastAPI + Vue3 + SQLite   │   REST 契约  │ headless 执行器           │
+│ 方案生成(规则+AI+经验库)  │◄───────────►│ (PyInstaller 打包 EXE)     │
+│ adaptive 闭环 / 分析 / 报告│  task_contract│ MotorCADAdapter → Motor-CAD│
+└────────────┬─────────────┘             └──────────────────────────┘
+             │ 共享核心层 src/afmcore/(单一事实源,纯 Python,双端引用)
+             │ metrics(25项) / topology / adapters / strategies / l0 / plan_schema
+```
+
+### 1.3 当前状态快照(2026-08-30)
+
+| 维度 | 状态 |
+|---|---|
+| P1~P4(Phase 1~4 + 平台化批次) | ✅ 全部完成 |
+| 真实 Motor-CAD 全链路 | ✅ 已实测(连接/计算/导出/解析,TEST-002/003/010) |
+| 平台化共享核心层 | ✅ metrics/topology/adapters/strategies/l0/plan_schema 单一权威 |
+| adaptive 闭环(Web 智能层+执行层) | ✅ 打通(P3-M6 + 本轮 P4) |
+| 本地 EXE 打包 | ✅ `dist/PCB-AFM-Executor.exe`(12.6MB,冒烟通过) |
+| 前端全量 build | ✅ build 全绿(P5-M1 清零,vue-tsc 0 错误,2026-08-30) |
+| EXE 配置化(config.json) | ✅ P5-M2 完成(executor_config.json + mock 分支修复,TEST-017) |
+| 真实 EXE 内 Motor-CAD COM 验证 | ✅ 本机单点已验(TEST-018,167.5s,数值与 TEST-010 一致);短扫描/多实例待目标机 |
+
+---
+
+## 2. P1~P4 阶段回顾
+
+### 2.1 Phase 1(最小闭环)— 全部完成
+
+| 里程碑 | 内容 | 设计要点 | 验证结果 | Commit |
+|---|---|---|---|---|
+| M1 | 环境验证 + 单工况仿真脚本 | Motor-CAD 前台可见、参数回读校验 | 连接/计算/导出/解析跑通 | `75347b6` |
+| M2 | 参数扫描引擎(单/多参数) | 每点基线重载、逐点落盘、中英文字段别名 | 气隙扫描 3 点:转矩 0.4677~0.5663Nm、效率 84.93~86.35%,物理趋势符合预期 | `a43d971` |
+| M3 | 方案 JSON 接口 + 本地 PySide6 GUI | 方案 Schema、GUI 加载/监控 | 方案回写 | `cbd9402` |
+| M4 | 经验库雏形 + 反馈闭环 | SQLite 经验库、相似检索 | 反馈闭环 | `f20bef9` |
+
+**验证记录**:`docs/TEST_RECORDS.md` TEST-001(连接探测,发现 export_results API 兼容问题)→ TEST-002(修复后全流程,磁场计算 138.1s/点)→ TEST-003(指标解析 7→14 项,暴露 tavg_nm/ripple 缺口)。
+
+### 2.2 Phase 2(Web 端方案系统)— 全部完成
+
+| 里程碑 | 内容 | 设计要点 | 验证 | Commit |
+|---|---|---|---|---|
+| P2-M1 | Web 基础框架 | FastAPI + Vue3 + SQLite + CRUD API + 双系统 API 客户端 | 基础 API | `1ea7653` |
+| P2-M2 | 边界条件输入 + 方案编辑器 | 规则引擎(参数注册表/范围推荐/方案生成) | 生成方案 | `a95c0db` |
+| P2-M3 | 经验库 Web 端 + 分析仪表盘 | ECharts 趋势/Pareto/敏感性 | 仪表盘 | `9885437` |
+| P2-M4 | 双系统 API 联调 + 知识库管理 | 经验库 CRUD/导入、系统二客户端增强 | 联调 | `25ee19f` |
+| P2-M5 | Phase 2 验收 | 自动化测试 + 前端人工验收 + 文档 | 验收 | `0df69f2` |
+| P2-补丁 | 前端全面中文化 + Dashboard 修复 | — | — | `2660df7` |
+
+### 2.3 Phase 3(AI 驱动智能仿真闭环)— 全部完成
+
+> 基于第三方专家评审意见,仿真策略从"固定批量 DoE"升级为"多保真度 + 可行性优先 + 批量自适应闭环",引入 Kimi k3。
+
+| 里程碑 | 内容 | 设计要点 |
+|---|---|---|
+| P3-M1 | AI 服务层基础架构 | Kimi 客户端(`https://api.kimi.com/coding/v1`,模型 `k3`)+ JSON Schema V2 + 多保真度框架(L0 解析→L1 磁路→L2 2D FEA→L3 3D FEA→L4 瞬态热耦合) |
+| P3-M2 | L0 解析预筛选 + 可行性优先搜索 | 14+ 约束检查(几何/电气/热/制造)+ LHS 初始采样 + 主动学习批量选点 + 局部信任域精修 |
+| P3-M3 | AI 方案生成器 | 自然语言 → 结构化仿真方案(扫描变量/策略/验收准则) |
+| P3-M4 | AI 结果分析师 + 多保真度校准 | 置信等级 A~D(保真度40%+样本密度25%+收敛性25%-异常惩罚10%)+ 六类收敛判据 |
+| P3-M5 | 经验库 AI 增强 + 批量自适应闭环 | 经验提取 + 闭环验收 |
+| P3-M6 | Web 端执行桥 + 路由整合 | AdaptiveLoop 补齐 `submit_batch_to_executor()`,把批次打包为 Task 交本地执行器(`768a883`) |
+
+**P3 能力**:32 个 P3 API 端点;自然语言→AI 方案→L0 筛选→主动搜索→仿真→AI 分析→经验提取→下一批的完整闭环。
+
+**P3 平台化(src/afmcore 第一批~第三批)**:
+- 第一批:`metrics.py`(25 项指标+归一化解析器,修复 tavg_nm/ripple 缺口)+ `adapters/`(SimulationAdapter ABC + 注册表 + MotorCADAdapter)→ TEST-004
+- 第二批:`topology.py`(SSSR 8 组 37 项参数体系,DRSS/SDSR 预留)+ task_executor 切换 `get_adapter` → TEST-005(36 断言)
+- 第三批:`strategies/`(full_factorial/lhs/adaptive)+ Task 模型扩展 + 调度契约统一 + 执行器批次/多实例 → TEST-006~011
+
+**P3 遗留处理(本轮 P4 期初,4 commit)**:
+- 并发原子认领:`dispatch_task` 改 SQLAlchemy 条件 UPDATE(8 线程竞争恰 1 win)→ `fb3a505` + `d525a4b`
+- 断点恢复:`FeasibilityFirstSearch.import_state()` + `AdaptiveLoop.export/restore` + `/loops/{id}/export`、`/loops/import` → `4dcc627`
+- ASCII 纪律:plans.py + fixed_params_template.py 中文串转 `\uXXXX` → `3caecd1`
+- 环境依赖测试:`test_api_client.py` 真实链路 → TEST-013
+
+### 2.4 Phase 4(Web AI 集成 + 双系统闭环 + 批量调度 + 部署)— 全部完成
+
+| 里程碑 | 内容 | 验证要点 |
+|---|---|---|
+| P4-M1 | 前端 AI 功能集成 | 6 个 AI 页面 + API 封装 + 通用组件 |
+| P4-M2 | 双系统任务下发与回传 | Web 创建任务 → 本地执行器轮询领取 → 执行 → 上报 → 回传 → 存储展示 |
+| P4-M3 | 批量调度 + 实时监控 + 增强版 MotorCAD 核心 | 优先级队列(1-10) + 最大并行(2) + 依赖 + 断点续跑;16 项鲁棒性措施 |
+| P4-M4 | 高级可视化 + 自动报告 | Pareto/收敛/雷达/热力图 + Word/JSON 报告 |
+| P4-M5 | 部署打包 | Docker + docker-compose + nginx + Windows 部署脚本 |
+| P4-补丁1 | export_results 兼容(pymotorcad 0.8.8 需 solution_type) | `f3b492a` |
+| P4-补丁2 | 指标别名扩展(12→20)+ 弹窗抑制 | `6a8bc80` |
+
+**真实 Motor-CAD 实测**(MARS-12S10P,5000rpm):效率 86.06%、总损耗 41.945W、磁场计算 138.1s/点(TEST-002/003/010)。
+
+**第三方代码评审修复**(V1.4):20+ P0 项(指标单一事实源 A1 / mock 假数据禁上报 A2 / BatchScheduler 死锁 B1 / 六类收敛判据全接入 C2 / L0 最低覆盖 C3 / 6 页面 994 处中文还原 D1 / .dockerignore 防密钥 E1 / 全仓库状态统一 "OK"/"FAILED" / 75 文件纯 ASCII)。详见 `docs/CODE_REVIEW_RESPONSE.md`。
+
+### 2.5 P4 平台化批次(本轮,commit `p4-m1..m5`)— 全部完成
+
+> 用户要求:"先处理遗留 → 写 P4 计划 → 直接做 P4"。按 `docs/P4_IMPLEMENTATION_PLAN.md` 五件套实施。
+
+| 批次 | 内容 | 设计要点 | 验证 |
+|---|---|---|---|
+| P4-M1 | 方案 Schema 单一权威 | `src/plan_schema.py` 增 parse/validate 入口 + min_value/max_value 别名容错 + require_model_path 分级;web main.py 注入 repo root,plans/ai_plan 接入校验(400/422) | `test_p4_schema.py` 7 组全过 |
+| P4-M2 | 文档 V1.1→V2.0 | 设计方案追加"附录 B 实现现状对照"(蓝图 vs 实现逐项映射) | README 引用同步 |
+| P4-M3 | EXE 打包 | `build_executable.ps1`(PyInstaller onefile,paths=src+root,collect-all ansys.motorcad);执行器加 `--version`/`--self-test` | `dist/PCB-AFM-Executor.exe` 12.6MB 冒烟通过 |
+| P4-M4 | 前端 adaptive 收敛曲线 | search state 增 `points_history`(逐评估点 id/batch/params/objective/feasible)+ AdaptiveOptimize.vue echarts(可行/不可行散点 + 当前最优 step 折线) | `test_p4_m4_convergence.py` 5 组全过;vue-tsc 0 错误 |
+| P4-M5 | L0 上提共享核心 | `L0PreScreeningEngine` 迁 `src/afmcore/l0/prescreening.py`(唯一实现,纯 stdlib),web 薄 re-export 兼容 6 处调用点 | `test_p4_m5_l0.py` 8 组全过;closed_loop/convergence 补 repo-root path 后全绿 |
+
+**P4 批次验证汇总**:P4 acceptance / schema / m4 / m5 / P2(36) / M6 / checkpoint / concurrency / closed_loop 全量回归绿;全量 py_compile 0 失败;ASCII 0 违规。详见 TEST-014/015。
+
+---
+
+## 3. 核心架构与设计决策(平台化主线)
+
+### 3.1 共享核心层 `src/afmcore/`(单一事实源)
+
+| 模块 | 职责 | 扩展方式 |
+|---|---|---|
+| `metrics.py` | 25 项指标定义 + 归一化解析器(唯一权威) | 加一项(key/label/alias/direction)所有消费端自动生效 |
+| `topology.py` | 拓扑注册表(SSSR 8 组 37 项,DRSS/SDSR 预留) | 注册参数体系 + 模板 + 默认规则 |
+| `adapters/` | SimulationAdapter ABC + 注册表 + MotorCADAdapter | 实现 ABC + 注册 tool_name(Maxwell/JMAG 即插) |
+| `strategies/` | full_factorial / lhs / adaptive 三实现 | 注册新策略类 |
+| `l0/prescreening.py` | L0 解析预筛选(14+ 约束,纯 stdlib) | 加约束检查项 |
+| `plan_schema.py` | 方案契约 V2 单一权威 | 经 parse/validate 入口统一校验 |
+
+**分层约束**:共享层不依赖 Motor-CAD/Web/GUI 具体实现;Web 不直接调 Motor-CAD;本地执行器不依赖 AI。
+
+### 3.2 双系统解耦与任务契约
+
+- 状态机:`pending → dispatched → running → completed / failed / cancelled`(`web/backend/app/services/task_contract.py`)
+- 认领:`dispatch_task` 条件 UPDATE + rowcount(并发原子,多执行器恰好一次)
+- 断点:`/loops/{id}/export` + `/loops/import`(进程重启恢复搜索状态)
+- 执行器:`scripts/task_executor.py`(多实例 `--instances N`,adapter 驱动,point_id 回填)
+
+### 3.3 Adaptive 闭环链路(P3-M6 打通,全链路验证过)
+
+```
+AI 方案(Kimi,空 key 降级纯定量)→ L0 预筛选 → 初始 LHS 采样
+  → active learning 选批(search.select_next_batch,含信任域)
+  → submit_batch_to_executor(打包为 adaptive_batch Task:loop_id/batch_id/point_ids)
+  → 本地执行器认领/求解(mock 或真实 Motor-CAD)
+  → report_results 回填(point_id 对齐)→ 分析 → 经验库 → 收敛/预算耗尽 → 结束
+```
+
+### 3.4 关键工程决策(沉淀为纪律)
+
+1. **纯 ASCII 源码**:`.py/.ps1` 全 ASCII,中文 `\uXXXX` 或进 Markdown(`rg -n "[^\x00-\x7F]" --glob '*.py' --glob '*.ps1' .` 检查)
+2. **运行前 Git 提交 + preflight**:启动 Motor-CAD 求解前仓库必须干净
+3. **Motor-CAD 实例管理**:`open_new_instance=True` + `set_visible(True)`;每点 `load_from_file` 基线重载防污染
+4. **参数回读校验**:`set_variable` 后 `get_variable` 回读,`math.isclose` 不一致标记 FAILED
+5. **结果逐点落盘**:CSV flush + fsync,失败点记录继续
+6. **生成物不入库**:`output/ runs/ build/ dist/ *.log *.spec` 一律 .gitignore
+7. **阶段完成必更新 README + 测试必留痕**(TEST_RECORDS.md)
+
+---
+
+## 4. 验证体系
+
+### 4.1 测试脚本清单(`scripts/test_*.py`,均可独立运行,exit 0 = PASS)
+
+| 脚本 | 覆盖 | 关联批次 |
+|---|---|---|
+| `test_platform_registry.py` | 指标/拓扑/适配器注册表 36 断言 | P2 平台化第二批 |
+| `test_p3_unit_edge.py` | 单元边界(正常/边界/异常/空值) | P3 收尾 |
+| `test_p3_orchestrator.py` | AdaptiveOrchestrator 编排 | P3-M2 |
+| `test_p3_m4_contract.py` | 任务契约状态机 | P3-M4 |
+| `test_p3_concurrency.py` | 并发原子认领(8 线程恰 1 win) | P3 遗留 |
+| `test_p3_checkpoint.py` | 断点导出/恢复/续跑 | P3 遗留 |
+| `test_p3_closed_loop.py` | HTTP 全链路闭环(真实 uvicorn 子进程) | P3-M5 |
+| `test_p3_adaptive_execution.py` | Web AdaptiveLoop + 执行桥全闭环 | P3-M6 |
+| `test_p4_acceptance.py` | P4 验收 37 项 | P4 |
+| `test_p4_schema.py` | plan_schema 单测 + web 接入 | P4-M1 |
+| `test_p4_m4_convergence.py` | points_history 收敛数据源 | P4-M4 |
+| `test_p4_m5_l0.py` | L0 上提一致性 | P4-M5 |
+| `test_robust_solver.py` / `test_executor_m3.py` / `test_api_client.py` | 求解/执行器/API 客户端 | 各期 |
+
+> ⚠️ P4-M5 后注意:脚本若 `from app.xxx import` 且经 l0 re-export 触达 `src`,**必须同时把 repo root 加入 sys.path**(`sys.path.insert(0, _ROOT)`)。
+
+### 4.2 真实 Motor-CAD 验证(非 mock,需 license)
+
+| 记录 | 内容 | 关键数据 |
+|---|---|---|
+| TEST-002 | 全流程修复后 | 磁场计算 138.1s/点,弹窗问题解决 |
+| TEST-003 | 指标解析扩展 | 7→14 项;back_emf 7.898→11.15V(字段匹配修正) |
+| TEST-010 | P3-M5 真实烟雾 | 连接→基线加载→求解→21 指标解析,back_emf=11.15V 与历史一致 |
+
+### 4.3 环境依赖(无法自动化,如实标注)
+
+- 真实 Motor-CAD 求解依赖 license server(`ANSYSLMD_LICENSE_FILE=1055@localhost`)
+- EXE 内真实 Motor-CAD COM 连接需目标机验证(打包自检 `--self-test` 用 mock 单点,只验证依赖打包完整)
+- AI 分析依赖 Kimi API key(无 key 自动降级纯定量,已门控)
+
+---
+
+## 5. 已知遗留 / Backlog(P5 输入)
+
+| # | 项 | 现状 | 影响 |
+|---|---|---|---|
+| B1 | 前端全量 build 类型错误 | `vue-tsc` 报 PlanDetail/ProjectDetail/ProjectList 等 axios `.data` 类型错误(未触碰的历史遗留) | `npm run build` 不过;M4 改动文件本身 0 错误 |
+| B2 | 真实 EXE 验收 | EXE 打包冒烟过,但打包内 Motor-CAD COM 连接未在目标机实测 | 系统二交付物未端到端确认 |
+| B3 | adaptive 批次点可视化 | 收敛曲线已上线(P4-M4);批次点状态可视化、L0 预筛选前端视图未做 | 闭环可视化不完整 |
+| B4 | 设计蓝图高级管线 | 蓝图(§6:Morris 筛选/Kriging 代理/NSGA-II 多目标/精确 FEA 验证)未落地,当前为 full_factorial/lhs/adaptive | 全局多目标优化能力缺失 |
+| B5 | 多物理场 L2 | 接口预留,热/结构未接入执行 | 平台覆盖度 |
+| B6 | 多工具适配器 | SimulationAdapter 接口就绪,仅 MotorCAD 实现 | 无法切 Maxwell/JMAG |
+| B7 | Docker 部署回归 | 部署方案文档有,容器化未在本轮重验 | 交付形态 |
+| B8 | 双系统单机一键启动 | 手动分步启动 | 易用性 |
+
+---
+
+## 6. P5 规划
+
+### 6.1 P5 目标
+
+> **平台化深化 + 真实交付闭环**:把已完成的"平台化骨架"打磨成可交付、可扩展、可验证的正式版本——质量地基清零(前端 build)、真实 EXE 验收、可视化补全、蓝图高级能力落地、多工具/多物理场扩展、一键交付。
+
+### 6.2 批次划分(建议顺序,依赖驱动)
+
+| 批次 | 内容 | 目标 | 关键验收 | 依赖 |
+|---|---|---|---|---|
+| **P5-M1** | 前端 build 清零 | 修复既有 vue-tsc 类型错误(axios `.data`、响应类型统一),建立"build 必须绿"基线 | ✅ `npm run build` 全绿(vue-tsc 0 错误,2026-08-30,TEST-016);CI 可加 | B1 |
+| **P5-M2** | 真实 EXE 验收 | 目标机(license 就绪)用 `dist/PCB-AFM-Executor.exe` 跑通单点+短扫描;EXE 配置化(`config.json`:web 地址/model 路径/日志/实例数) | ✅ EXE 配置化完成(executor_config.json + mock 分支修复,TEST-017);✅ 真实 COM 单点端到端本机已验证(TEST-018,tavg/eff 与 TEST-010 一致);短扫描/多实例待目标机 | B2 |
+| **P5-M3** | adaptive 可视化补全 | 批次点状态可视化(每批进度/分布)+ L0 预筛选结果前端视图 + adaptive 循环运行期状态推送(轮询增强) | ✅ 三视图上线(batch_summary+l0_summary+3s轮询,TEST-019);WebSocket 待后续 | P4-M4 |
+| **P5-M4** | 策略层高级管线 | Morris 灵敏度筛选(纯 stdlib,注册为 `morris`)+ 代理模型引导(IDW 纯 stdlib 替代 Kriging,环境无 scipy,注册为 `surrogate_guided`)+ 预算自适应批次大小 | ✅ 2 新策略注册+32 测试+冒烟收敛(TEST-020);Kriging/NSGA-II 待后续(需 scipy) | B4 |
+| **P5-M5** | 多工具适配器 | `MaxwellAdapter`/`JMAGAdapter` mock 实现 + 注册 + 执行器 `tool` 动态 import;真实接入标注环境依赖(需 Maxwell+PyAEDT / JMAG+jmagpy) | ✅ `get_adapter("maxwell"/"jmag")` mock 链路可跑(TEST-021,22测试);真实接入待目标机 | B6 |
+| **P5-M6** | 多物理场 L2 接入 | metrics.py 扩10项(热6+结构4,自动生效) + robust_motorcad enable_thermal 开关 + report_generator 按域分组模板化 | ✅ 热/结构指标入库与报告展示(TEST-022,20测试);真实热求解待模型配置 | B5 |
+| **P5-M7** | 平台化交付 | Docker/docker-compose 回归 + 双系统单机一键启动脚本 + 配置化(config/)+ 部署文档更新 | 一键启动端到端可用 | B7/B8 |
+
+### 6.3 每批验收与风险
+
+| 风险 | 应对 |
+|---|---|
+| 前端 build 既有错误量大 | P5-M1 先做基线快照(错误清单固化),逐文件修复,不追求一次全清 |
+| 真实 Motor-CAD/license 环境不可用 | P5-M2 用 mock + `--self-test` 先验依赖完整性,真实验证记录为环境依赖项 |
+| Kriging/NSGA-II 引入新依赖 | P5-M4 先评估(scikit-learn 是否已在环境),轻量实现优先,避免重依赖 |
+| 多工具/多物理场真实接入不可行 | 接口 + mock 链路先行,真实接入标注环境依赖(与既有 Motor-CAD 同策略) |
+| 批次命名混乱 | 统一以 `p5-mN` commit,README/计划文档同步回填 |
+
+---
+
+## 7. 新会话 P5 开工指引
+
+1. **先读**:`AGENTS.md` → `docs/KNOWLEDGE_BASE.md` → `README.md` → 本文件 §6/§7 → `PCB轴向磁通电机自动化仿真系统设计方案介绍.md`(附录 B)→ `docs/PLATFORM_DESIGN_V2.md`
+2. **确认基线**:`git status` 干净(除用户自建目录)、HEAD = `11630bf`
+3. **跑一遍回归**:`python scripts/test_*.py` 全量(P2 36 / P4 三件套 / P3 闭环类),确认起点绿
+4. **纪律提醒**:
+   - 新 `.py/.ps1` 纯 ASCII;中文进 `.md`
+   - 每批完成 → 更新 README + TEST_RECORDS + 提交(`type(scope): description`)
+   - 真实 Motor-CAD 求解前 git 必须干净(preflight)
+   - 无法实测的必须标注"无法执行此测试,以下为推理/建议"
+5. **从 P5-M1 开始**(质量地基优先),每批验收清单过完再进入下一批。
+
+---
+
+*本文档基于已验证事实整理(git log + README + TEST_RECORDS + 设计方案 V2 + 平台设计 V2),P5 规划为建议路线,可结合新会话调研调整。*

+ 0 - 0
docs/P3-评审响应与更新计划.md → docs/archive/P3-评审响应与更新计划.md


+ 254 - 0
docs/archive/P3_IMPLEMENTATION_PLAN.md

@@ -0,0 +1,254 @@
+# P3 实施计划 — 执行策略接入 + 自适应闭环打通 + 调度统一
+
+| 项 | 内容 |
+|---|---|
+| 文档版本 | V1.0 |
+| 日期 | 2026-08-29 |
+| 状态 | 已实施完成(2026-08-29,M1-M5 全部落地并提交) |
+| 依据 | `docs/PLATFORM_DESIGN_V2.md` 第 4 节(P3 批次)+ 短板 S7/S8 |
+| 前置 | P1(共享核心层 afmcore)✅、P2(拓扑注册表 + 适配器)✅ 已落地 |
+
+---
+
+## 1. 背景与目标
+
+### 1.1 P3 要解决什么
+
+当前系统实际跑通的是 **"全因子扫描"** 一条链路:方案固定参数列表 → 任务下发 → 本地执行器逐点跑 Motor-CAD → 结果回传。而 Phase 3 承诺的 **"AI 自适应优化闭环"(feasibility-first 搜索)在 web 端算法已实现并通过验收,却从未真正驱动真实仿真**。
+
+具体两个平台性短板(对应设计文档 S7 / S8):
+
+| # | 短板 | 现状 | 后果 |
+|---|---|---|---|
+| S7 | **执行策略硬编码全因子** | `FeasibilityFirstSearch` / `AdaptiveLoop` 只存在于 web 端内存;批次选出的点**没有通道下发到本地执行器**,结果也没回填驱动下一批 | 自适应闭环未真通,P3 优化能力是"摆设" |
+| S8 | **调度逻辑分裂** | web 端 `BatchScheduler`(内存优先级队列)与本地 `task_executor`(轮询认领)两套,契约不一致 | 平台化调度难扩展、难并行 |
+
+### 1.2 目标
+
+> 把"单策略(全因子)、单调度(串行轮询)"升级为 **"可插拔执行策略 + 自适应闭环真实跑通 + 多实例并行调度"**。
+
+验收标准(做到什么算完成):
+1. 方案可选择执行策略(full_factorial / adaptive / lhs),策略在共享核心层注册,新增策略只做注册。
+2. adaptive 策略下,本地执行器真实跑 Motor-CAD 的每批结果回填搜索模型,自动选下一批,直至收敛/预算耗尽。
+3. 本地执行器支持多实例并行,任务状态机与 web 端调度契约统一。
+4. 全程可留痕、可断点恢复、可回归测试(延续 TEST-004/005 纪律)。
+
+---
+
+## 2. 现状盘点(已探查,非假设)
+
+### 2.1 已实现(P3 可复用的资产)
+
+| 模块 | 位置 | 能力 | 状态 |
+|---|---|---|---|
+| FeasibilityFirstSearch | `web/backend/app/services/feasibility_search.py` | LHS 初始化 / 主动学习选批 / 信任域 / 收敛判断 / `report_result(point_id, metrics, status)` / 状态导出 | ✅ 已实现+验收 |
+| AdaptiveLoop 编排器 | `web/backend/app/services/adaptive_loop.py` | generate_plan → initialize_search → get_next_batch → report_results → update_experience → check_completion | ✅ 已实现(执行环节占位) |
+| L0 预筛选 | `web/backend/app/services/l0_prescreening.py` | 排除不可行域 | ✅ |
+| AI 结果分析 | `web/backend/app/services/result_analyst.py` | 趋势/异常/置信度分级 | ✅ |
+| 经验库增强 | `web/backend/app/services/experience_enhancer.py` | 自动抽取设计规则入库 | ✅ |
+| 批量调度器 | `web/backend/app/services/batch_scheduler.py` | 优先级队列/并行上限/checkpoint | ✅(web 内存态) |
+| 本地执行器 | `scripts/task_executor.py` | 轮询认领 / 逐点执行(已接 `afmcore` 适配器)/ 进度回传 | ✅(P2 已适配器化) |
+| 任务管理 | `web/backend/app/services/task_manager.py` | 任务 CRUD / 进度 / 结果 / 心跳 | ✅ |
+| 搜索 API | `web/backend/app/routers/search.py` | create / next-batch / report / export / runs | ✅ |
+
+### 2.2 关键缺口(P3 要做的)
+
+| # | 缺口 | 说明 |
+|---|---|---|
+| G1 | **无执行策略抽象** | 方案里的 `search_strategy.method`(full_factorial/lhs/active_learning)只是配置字符串,执行器只认"固定参数列表",没有策略层 |
+| G2 | **批次 ↔ 任务无桥** | `FeasibilityFirstSearch` 选出的 `SearchPoint[]` 与 `TaskManager.create_task(parameters=[...])` 之间没有转换通道;adaptive 批次是"动态追加"语义,现有任务模型是"一次性固定列表" |
+| G3 | **结果无回填驱动** | 执行器把结果回传到 task,但没有任何环节把结果喂回 `search.report_result()` 并触发下一批 `get_next_batch()` |
+| G4 | **执行器单实例串行** | `run_task_executor.py` 启动单个轮询线程,无并行;多实例需要许可证策略 |
+| G5 | **调度契约不一致** | batch_scheduler 的任务字段与 task_executor 认领的字段不同源;状态机语义未统一(queued/dispatched/running/completed 各说各话) |
+
+---
+
+## 3. 目标架构(数据流)
+
+```
+┌──────────────────────── Web 端 ────────────────────────┐
+│  方案(Plan)                                            │
+│   ├─ search_strategy.method ∈ {full_factorial, adaptive, lhs}   │
+│   └─ 执行策略解析 → get_strategy(method)                │
+│                                                        │
+│   [adaptive 协调器 StrategyOrchestrator](新增)         │
+│     generate_plan → initialize_search → get_next_batch  │
+│        │  batch(N points)                              │
+│        ▼                                               │
+│   TaskManager.create_task(type="adaptive_batch",        │
+│        parameters=points, loop_id, batch_id)            │
+│        │                                               │
+│   ┌────▼──── 调度契约(统一状态机 queued/running/…)────┐ │
+│   │  BatchScheduler(可选队列)  ←→  REST /api/tasks      │ │
+│   └──────────────────────────────────────────────────┘ │
+└────────────────────┬────────────────────────────────────┘
+                     │ HTTP 轮询认领
+┌────────────────────▼──────── 本地执行端 ─────────────────┐
+│  TaskExecutor(多实例并行,每实例独立 Motor-CAD)          │
+│   ├─ 认领 → get_adapter(tool).run_point / 批量           │
+│   ├─ 结果回传 /api/tasks/{id}/results                     │
+│   └─ adaptive 任务:本批完成后不结束,等待下一批            │
+└─────────────────────────────────────────────────────────┘
+                     │ 结果
+                     ▼
+   StrategyOrchestrator: report_results → search.report_result()
+        → analysis → update_experience → 收敛? 否 → get_next_batch()
+        → 是 → 闭环完成,方案标记 converged/预算耗尽
+```
+
+关键决策点:
+- **adaptive 循环的"心跳"放哪**:放 web 端 `StrategyOrchestrator`(新增服务),它负责"取批 → 建任务 → 等结果 → 回填 → 下批"。本地执行器保持无状态(只认领、执行、回传),避免执行器内嵌循环逻辑导致断连后状态丢失。
+- **断点恢复**:循环状态落在 web 端(`search.export_state()` 已有 checkpoint 能力),任务级结果落在 task.json(已有)。
+
+---
+
+## 4. 里程碑分解
+
+### P3-M1 执行策略抽象层(共享核心层)
+
+**任务**:
+1. 新建 `src/afmcore/strategies/__init__.py`:`SimulationStrategy` ABC
+   - `select_next(points_to_run) -> list[params]`(生成/选择下一批点)
+   - `report(point_id, metrics, status)`(回填结果)
+   - `next_batch_ready() -> bool` / `is_converged() -> bool` / `state() -> dict`
+   - `kind`("full_factorial" / "adaptive" / "lhs")
+2. 新建 `strategies/full_factorial.py`:把现有 `plan_schema.SimulationPlan.generate_points()`(笛卡尔积)包装为策略
+3. 新建 `strategies/adaptive.py`:**包装 web 端 `FeasibilityFirstSearch`**——这是"算法已在 web、执行在本地"的桥接层。共享核心层不 import web,因此 adaptive 策略持有一个"搜索后端回调"(注入函数),或将该策略实现放在本地侧(见 P3-M2 说明)
+4. 策略注册表:`register_strategy / get_strategy(method)`
+5. `plan_schema.SearchStrategy.method` 增加校验(只允许已注册方法)
+
+**设计取舍(重要)**:`FeasibilityFirstSearch` 目前依赖 `l0_prescreening`(web 服务)。为不破坏现有验收资产,**第一版策略层做"双实现"**:
+- 共享核心层定义协议 + full_factorial/lhs 纯实现(可直接复用)
+- adaptive 通过 **StrategyOrchestrator(web 端)** 实现协议,内部调用现有 `FeasibilityFirstSearch`,本地执行器只认"批次的 points",不感知 adaptive 逻辑
+
+**产出**:`src/afmcore/strategies/`(协议 + 注册表 + full_factorial + lhs)
+**验收**:单测——策略注册/选择/状态;full_factorial 与 `SimulationPlan.generate_points` 结果一致;未注册方法报错。
+
+---
+
+### P3-M2 任务模型扩展 + adaptive 批次桥接
+
+**任务**:
+1. `task_manager.py` 任务模型扩展(Task 增加字段,向后兼容):
+   - `task_type`: `"scan"`(默认,现有一致)| `"adaptive_batch"`
+   - `loop_id` / `batch_id` / `point_ids[]`(adaptive 批次标识)
+   - `dynamic`: `true`(表示任务完成后可能有后续批次,不触发"完成即终态"的误判)
+2. `TaskManager.create_task` 支持 `parameters` 直接传 `SearchPoint[]`(自动转换 `{point_id, params}`)
+3. 新增 `web/backend/app/services/strategy_orchestrator.py`(核心新增):
+   - `start_adaptive(plan, loop_id)` → 初始化搜索 → 取首批 → 建 `adaptive_batch` 任务
+   - `on_batch_completed(task_id, results)` → 结果按 `point_id` 回填 `search.report_result()` → `result_analyst` 分析 → `update_experience` → 判断收敛/预算 → 未收敛则 `get_next_batch()` 建下一批
+   - `get_loop_status(loop_id)` → 供 monitor/前端轮询
+4. `search.py` 路由增强:暴露 `POST /loops/{id}/start`、`POST /loops/{id}/resume`(断点恢复入口)
+
+**设计取舍**:adaptive 循环状态存 web 端(内存 + `export_state()` 落盘可选),执行器无状态。断连恢复:执行器重启后重新认领 `pending/dispatched` 任务,orchestrator 依据任务状态决定是续批还是重建。
+
+**产出**:task 扩展 + `strategy_orchestrator.py` + 路由
+**验收**:单测(fake 执行器)——任务字段向后兼容;orchestrator 完整跑通"建批→完成→回填→下批";断点恢复逻辑(模拟执行器中途退出)。
+
+---
+
+### P3-M3 执行器 adaptive 批次执行模式
+
+**任务**:
+1. `task_executor.py` 支持 `adaptive_batch` 任务:认领后按 `point_ids` 顺序执行,每个 point 走 `get_adapter().run_point()`(P2 已就绪)
+2. 结果回传扩展:`report_results` 携带 `point_id → params → metrics` 映射(现有 `point_index` 之外增加 `point_id`),供 orchestrator 回填
+3. `run_task_executor.py` 支持多实例:`--instance` 参数(多进程各自独立轮询,天然多实例;实例心跳带 `instance_id` 区分)
+4. 执行器对 `dynamic` 任务不误判终态:本批完成 → `completed`(batch 级),循环是否结束由 orchestrator 决定
+
+**产出**:task_executor 扩展 + 多实例入口
+**验收**:fake adapter 集成——一个 `adaptive_batch` 任务 3 点执行、结果带 point_id 回传;双实例并行认领不同任务不重复。
+
+---
+
+### P3-M4 调度契约统一 + 并行控制
+
+**任务**:
+1. 定义**统一任务状态机**(文档 + 常量):
+   `queued → dispatched → running → completed | failed | cancelled`
+   (`task_manager` 为权威,`batch_scheduler` 与其对齐字段名;明确两者关系:batch_scheduler 是可选的上层优先级队列,本地执行器始终走 REST 认领,二者通过同一 `/api/tasks` 契约衔接,不重复实现调度语义)
+2. `batch_scheduler` 字段对齐 `task_manager.Task`(task_id/task_name/priority/status/parameters/plan_data/wait_for),消除两套字段漂移
+3. 并行控制策略文档化:`MAX_PARALLEL_TASKS`(web 队列)+ `NUM_EXECUTORS`(本地实例数)+ **Motor-CAD 许可证约束说明**(浮点许可池决定本地实例上限)
+4. 心跳/监控完善:`executor_heartbeat` 支持 `instance_id`,monitor 展示多实例状态
+
+**产出**:状态机常量 + batch_scheduler 对齐 + 并行配置文档
+**验收**:状态机单测(非法迁移拒绝);batch_scheduler 字段与 Task 契约一致性断言。
+
+---
+
+### P3-M5 端到端闭环验证 + 文档收尾
+
+**任务**:
+1. **集成测试(fake adapter)**:`plan(adaptive) → orchestrator → 批次 → 执行器 → 回填 → 收敛` 全闭环,断言搜索状态收敛、预算扣减正确、经验库有产出
+2. **真实 Motor-CAD 烟雾测试**:单 adaptive 任务(1-2 批)真实跑通(环境允许时;不允许则记录为待办)
+3. 回归测试扩展:`scripts/test_platform_registry.py` 增加策略层 + orchestrator(fake)用例
+4. 文档:README(五期)、TEST_RECORDS(TEST-006)、PLATFORM_DESIGN_V2(P3 批次标记完成)
+5. Git 提交
+
+**验收**:闭环测试 PASS + 文档留痕 + 提交合规。
+
+---
+
+## 5. 工作量与建议顺序
+
+| 里程碑 | 预计工作量 | 依赖 | 建议 |
+|---|---|---|---|
+| P3-M1 策略抽象 | 1-2 天 | P2 适配器 ✅ | 先做,共享核心层独立可测 |
+| P3-M2 任务扩展+orchestrator | 2-3 天 | M1 | 核心难点,优先 |
+| P3-M3 执行器批次+多实例 | 1-2 天 | M2、P2 | 与 M2 可并行一部分 |
+| P3-M4 调度统一 | 1 天 | M3 | 低风险,文档为主 |
+| P3-M5 闭环验证+文档 | 1-2 天 | M2-M4 | 收尾 |
+
+**总计约 6-10 天**(含验证与文档)。建议一次性连续推进 M1→M5,每里程碑提交一次(延续项目纪律)。
+
+---
+
+## 6. 风险与应对
+
+| 风险 | 影响 | 应对 |
+|---|---|---|
+| adaptive 循环"等批"期间执行器空闲 | 利用率低 | 首批用满 batch_size;循环粒度=批次而非单点;或与普通扫描任务混排 |
+| 执行器断连导致循环悬空 | 闭环卡死 | orchestrator 心跳+超时重派;任务级 checkpoint(task.json 已有) |
+| 多实例并行许可证不足 | 启动失败 | 文档化 NUM_EXECUTORS ≤ 可用许可数;启动时预检许可证(robust_motorcad 已有 check_license_server) |
+| FeasibilityFirstSearch 依赖 web 服务(l0) | 策略层耦合 | M1 双实现方案:共享层协议 + web 端 orchestrator 实现;后续再把 l0 上提共享层 |
+| 前端仍是"全因子"展示 | UI 与 adaptive 语义不符 | 本批先做 API/后端闭环,前端 adaptive 视图列 P4 或单独 UI 批次 |
+| 大量结果回填拖慢搜索 | 性能 | report_result 批量接口(一次回填整批,而非逐点) |
+
+---
+
+## 7. 验证矩阵(本计划验收口径)
+
+| 层 | 验证方式 | 通过标准 |
+|---|---|---|
+| 单元 | strategies 单测 | 注册/选择/状态/未注册报错;full_factorial 与 generate_points 一致 |
+| 单元 | 任务模型扩展 | 旧任务无新字段可解析(向后兼容) |
+| 集成 | fake adapter 全闭环 | 收敛/预算/经验库断言全过 |
+| 集成 | 多实例并行 | 双实例不重复认领、心跳区分 instance_id |
+| 真实 | Motor-CAD 烟雾 | 1-2 批真实跑通(环境允许) |
+| 回归 | test_platform_registry.py 扩展 | 原有 36 项 + 新增全 PASS |
+| 纪律 | ASCII/编译/Git | 79+ 文件编译 0 失败、纯 ASCII、提交规范 |
+
+---
+
+## 8. 遗留/后续(不属于本计划范围)
+
+- 前端 adaptive 视图(批次可视化、收敛曲线)→ P4 / 独立 UI 批次
+- 把 L0 预筛选从 web 上提共享核心层(消除 web 依赖)→ P4 重构项
+- 多物理场(热/结构)策略适配 → 路线扩展
+- Maxwell/JMAG 适配器 → 路线扩展
+
+---
+
+*本文档为计划稿,评审通过后按 M1→M5 实施,每里程碑更新 README / TEST_RECORDS / PLATFORM_DESIGN_V2 并提交。*
+---
+
+## 6. 实施记录(2026-08-29 完成)
+
+| 里程碑 | 提交 | 结果 | 备注 |
+|---|---|---|---|
+| M1 策略抽象层 | 2e9adfa | 通过 | strategies/ 抽象+注册表+full_factorial/lhs/adaptive;plan_schema 策略校验 |
+| M2 任务扩展+orchestrator | fa74834 | 通过 | Task 新字段+迁移;AdaptiveOrchestrator 闭环;修复 report_results plan_id bug |
+| M3 执行器批次+多实例 | d65c6fa | 通过 | point_id 回传+认领原子化+并行入口;修复 on_complete 参数个数 bug |
+| M4 调度契约统一 | 719023e | 通过 | task_contract.py 统一状态词+字段对齐 batch_scheduler |
+| M5 闭环验证+真实烟雾 | 2611c28 | 通过 | HTTP 全链路闭环(fake) + 真实 Motor-CAD 烟雾;修复 run_point 忽略 model_path bug |
+
+测试记录:docs/TEST_RECORDS.md TEST-006~010;回归脚本 scripts/test_p3_orchestrator.py / test_executor_m3.py / test_p3_m4_contract.py / test_p3_closed_loop.py + test_platform_registry.py(36 项)。

+ 142 - 0
docs/archive/P4_IMPLEMENTATION_PLAN.md

@@ -0,0 +1,142 @@
+# P4 实施计划 — 平台化第四批
+
+> 状态:**已批准,实施中**
+> 依据:`docs/PLATFORM_DESIGN_V2.md` §4 第四批 / `docs/P3_IMPLEMENTATION_PLAN.md` §8 遗留 / `README.md` P5 平台化表第四批
+> 原则:沿用工程规范(禁止臆测 / 测试完备 / ASCII / 自查清单 / 文档同步 / commit 规范)
+
+---
+
+## 1. 目标
+
+把平台化推进到"可交付、可扩展、文档不漂移"的收口状态:
+
+1. **方案 Schema 单一权威**(核心):消除 `src/plan_schema.py`(dataclass)与 `web/backend/app/schemas/schema_v2.py`(Pydantic)两套并行方案定义的漂移,让 `src/plan_schema.py` 成为唯一结构权威,web 端转为薄兼容层。
+2. **文档同步 V1.1 → V2**:原《设计方案介绍》升级,消除与实现(P1~P3 平台化)的漂移。
+3. **本地 EXE 打包**:PyInstaller 打包本地执行器,产出系统二交付物。
+4. **前端 adaptive 视图**(P3 遗留):批次可视化、收敛曲线,消除"前端仍是全因子展示"的语义错位。
+5. **L0 预筛选上提共享核心层**(P3 遗留):`L0PreScreeningEngine` 从 web 移到 `src/afmcore`,消除 web 依赖。
+
+---
+
+## 2. 现状(已探查确认,2026-08-29)
+
+| 项 | 现状 | 问题 |
+|---|---|---|
+| 方案结构 | `src/plan_schema.py` 393 行 6 个 dataclass(ScanVariable/ScanCase/FixedParam/SearchStrategy/AcceptanceCriteria/SimulationPlan),已接入 afmcore.topology/strategies;**无顶层校验入口** | 与 web schema_v2.py 双份定义,语义漂移 |
+| web 方案模型 | `web/backend/app/schemas/schema_v2.py` Pydantic V2(StrategyMode/FidelityLevel/SearchMethod/…策略子模型) | 独立于 src/plan_schema,AI 输出与存储格式不一致风险 |
+| 数据流 | ai_plan 生成 unified v2 dict(fixed_params + variables with value lists)→ 存 SimulationPlan.plan_data → plans.py download 给本地 EXE | 格式转换点分散(plan_generator/rule_engine/plans.py),缺统一入口 |
+| 文档 | 《PCB轴向磁通电机自动化仿真系统设计方案介绍》= V1.1 | 与 P1~P3 平台化实现漂移 |
+| 打包 | 无 PyInstaller 产物(PyInstaller 6.22.2 已装) | 无系统二交付物 |
+| 前端 | adaptive 闭环只有 API,UI 仍是全因子展示 | 语义错位 |
+| L0 | `web/backend/app/services/l0_prescreening.py` | web 依赖,本地执行器/策略层无法复用 |
+
+---
+
+## 3. 批次表
+
+| 批次 | 内容 | 验证 |
+|---|---|---|
+| P4-M1 | 方案 Schema 统一:`src/plan_schema.py` 补顶层校验/归一化入口(parse/validate);web 端结构权威收敛到 src/plan_schema;schema_v2.py 转薄兼容层(字段语义对齐,不重复定义) | TEST-013:新 test_p4_schema.py(parse/validate 正常+异常+空值、web dict 归一化、schema_v2 兼容层 round-trip、回归现有 plan 用例) |
+| P4-M2 | 文档同步 V1.1→V2:升级《设计方案介绍》,回填平台化架构/接口/算法选择/已知限制 | 文档评审 + 与实现对照清单 |
+| P4-M3 | EXE 打包:PyInstaller 打包 `scripts/task_executor.py` + `robust_motorcad`(执行器,headless 模式优先);spec/产物不入库 | TEST-014:构建脚本可复现,产物启动冒烟(--help / 心跳 mock) |
+| P4-M4 | 前端 adaptive 视图:批次可视化(当前 batch/点状态)+ 收敛曲线(objective vs batch) | 前端组件单测 + 手工验收截图 |
+| P4-M5 | L0 上提共享核心层:`L0PreScreeningEngine` 迁至 `src/afmcore/l0/`,web 改引用 | TEST-015:迁移后导入回归 + 功能等价单测 |
+
+---
+
+## 4. 实施细节
+
+### P4-M1 方案 Schema 统一(核心)
+
+**目标架构**:
+
+```
+src/plan_schema.py          # 唯一权威:dataclass 结构 + 顶层 parse/validate/归一化
+web/.../schemas/schema_v2.py # 薄兼容层:Pydantic 仅做 HTTP 层类型包装,结构语义委托 src/plan_schema
+web/.../services/plan_generator.py / rule_engine.py / routers/plans.py  # 统一走 src.plan_schema 入口
+```
+
+**步骤**:
+1. `src/plan_schema.py` 新增顶层入口:
+   - `parse_plan(data: dict) -> SimulationPlan`:dict → dataclass,缺省字段用默认值(容错 + 归一化)
+   - `validate_plan(plan) -> ValidationReport`:结构合法 + 拓扑参数合法(调 afmcore.topology)+ 策略方法已注册(调 afmcore.strategies)+ 扫描变量范围/步长/values 一致性 + 固定参数类型
+   - `plan_to_dict(plan) -> dict`:统一序列化(兼容现有 `to_dict`)
+2. web 端收敛:
+   - `plan_generator.py`:生成流程末尾统一调用 `parse_plan` 归一化 + 校验,失败返回结构化错误
+   - `routers/plans.py`:POST /plans 创建时用 src.plan_schema 校验 plan_data(替代/补充 Pydantic 校验)
+   - `rule_engine.py`:输出与 src.plan_schema 契约对齐(已声明兼容)
+3. `schema_v2.py` 薄化:保留枚举与 API 模型(HTTP 入参校验),字段与 src.plan_schema 语义对齐;删除重复的结构推导逻辑(如有)
+4. 回归:现有 plan 创建/下载/执行用例全部保持通过
+
+**涉及文件**:`src/plan_schema.py`、`web/backend/app/schemas/schema_v2.py`、`web/backend/app/services/plan_generator.py`、`web/backend/app/routers/plans.py`、`web/backend/app/services/rule_engine.py`(如涉及)、`scripts/test_p4_schema.py`(新)
+
+### P4-M2 文档同步 V1.1 → V2
+
+1. 读 V1.1《设计方案介绍》全文,建立与实现对照清单(架构/接口/算法选择/模块清单)
+2. 升级为 V2:
+   - 架构图:补 `src/afmcore` 共享核心层、双系统解耦(Web 智能层 + 本地执行层)
+   - 接口:补 `/api/adaptive/*`、`/api/executor/*`、task_contract 状态机、plan_schema 单一权威
+   - 算法:补执行策略(full_factorial/lhs/adaptive)、L0 预筛选、断点恢复
+   - 已知限制:EXE 打包状态、前端 adaptive 视图状态、许可证依赖
+3. README 链接指向 V2
+
+**涉及文件**:`PCB轴向磁通电机自动化仿真系统设计方案介绍.md`(V1.1 → V2)、`README.md`
+
+### P4-M3 EXE 打包
+
+1. 确认打包目标:本地执行器 headless 模式(`scripts/task_executor.py` + `scripts/robust_motorcad` + `src/afmcore`)
+2. 编写 `scripts/build_executable.ps1`(构建脚本,ASCII):
+   - PyInstaller `--onefile`(或 `--onedir`,视依赖)打包执行器入口 `scripts/run_task_executor.py`
+   - hidden-imports:pymotorcad 相关、src/afmcore 包
+3. 产物验证:`dist/` 下 EXE 启动冒烟(`--help` / 短任务 mock 心跳)
+4. 规范:`build/`、`dist/`、`*.spec` 不入库(AGENTS.md 生成物纪律)
+
+**涉及文件**:`scripts/build_executable.ps1`(新)、`scripts/run_task_executor.py`(入口确认)
+
+### P4-M4 前端 adaptive 视图(P3 遗留)
+
+1. 前端现状:adaptive 闭环 API 已通(`/api/adaptive/loops/*`),UI 仍全因子展示
+2. 新增:循环状态面板(当前 phase、批次号、已用/总预算、收敛状态)+ 批次点状态可视化 + 收敛曲线(objective vs batch)
+3. 数据源:`GET /api/adaptive/loops/{id}/status` 现有响应
+
+**涉及文件**:web 前端 Vue 组件(LoopMonitor.vue 或并入现有页面)、API 层确认
+
+### P4-M5 L0 上提共享核心层(P3 遗留)
+
+1. `L0PreScreeningEngine`(`web/backend/app/services/l0_prescreening.py`)迁至 `src/afmcore/l0/prescreening.py`
+2. web 端 `l0_prescreening.py` 改引用 src(薄 re-export,保持现有 import 兼容)
+3. 依赖检查:L0 不得依赖 web 专属模块(如 db/HTTP),若依赖则一并解耦
+4. 回归:feasibility_search(已 import L0)+ web 端调用点
+
+**涉及文件**:`src/afmcore/l0/prescreening.py`(新)、`web/backend/app/services/l0_prescreening.py`(薄化)、引用点
+
+---
+
+## 5. 风险与约束
+
+| 风险 | 应对 |
+|---|---|
+| Schema 统一破坏现有闭环 | M1 先加 parse/validate 入口(纯新增),再逐步收敛调用点;每步跑现有 plan 用例回归 |
+| EXE 打包依赖复杂(pymotorcad COM/license) | 打包 headless 执行器优先;真实 Motor-CAD 连接不做打包内自检(依赖 license server),标注环境依赖 |
+| L0 上提引入循环依赖 | 先做依赖扫描(grep L0 的 import),确认无 web 专属依赖再迁移 |
+| 前端 adaptive 视图工作量不确定 | 先做最小可视图(状态面板 + 收敛曲线),批次点可视化列后续 |
+| ASCII 纪律 | 新 .py/.ps1 全 ASCII,中文进 .md |
+| 文档 V2 篇幅大 | 对照清单驱动,逐节回填,不重写原文 |
+
+---
+
+## 6. 验收清单(交付前逐项确认 ✅/❌)
+
+- [x] M1:src.plan_schema parse/validate 单测覆盖正常/异常/空值;web 创建/更新/ai 生成校验接入(test_p4_schema.py 7 组)
+- [x] M2:V2 文档与实现对照清单完成(附录 B),README 引用更新
+- [x] M3:build_executable.ps1 可复现构建,EXE 冒烟通过(--version/--self-test)
+- [x] M4:前端 adaptive 收敛曲线上线(AdaptiveOptimize.vue + points_history 数据源)
+- [x] M5:L0 迁移 src/afmcore/l0/prescreening.py,web 薄 re-export,6 处调用点兼容回归全绿
+- [x] TEST_RECORDS.md 记录 TEST-013~015
+- [x] README 十期记录回填
+- [x] 全量回归(P2 36 / M4 / M5 / M6 / closed_loop / checkpoint / concurrency)+ ASCII 0 + py_compile 0
+- [x] commit 规范 type(scope): description(M1~M5 共 5 commit + 遗留 4 commit + 文档 1 commit)
+
+---
+
+*本文档为 P4 实施计划,实施进度按批次回填。P4 五件套(M1~M5)已于 2026-08-29 全部完成。*

+ 11 - 0
docs/archive/README.md

@@ -0,0 +1,11 @@
+# docs/archive/ — 已完结历史计划文档
+
+> 本目录存放**已实施完毕**的历史计划/评审文档,仅供追溯,不再维护。
+> 当前有效文档见 `docs/HANDOFF.md` 第 5 节文档索引。
+
+| 文档 |完结时间 | 说明 |
+|---|---|---|
+| P3_IMPLEMENTATION_PLAN.md | 2026-08-29 | P3 平台化改造实施计划(策略抽象层/编排器/批次化/调度契约/HTTP闭环/执行桥),已全部实施 |
+| P4_IMPLEMENTATION_PLAN.md | 2026-08-29 | P4 五件套实施计划(Schema 统一/文档 V2/EXE 打包/收敛曲线/L0 上提),已全部实施 |
+| P3-评审响应与更新计划.md | 2026-08-29 | 第三方评审响应与 P3 升级计划(多保真度+可行性优先),已全部实施 |
+| P1-P4回顾与P5规划.md | 2026-08-30 | P1-P4 复盘与 P5 规划;P5 M1~M6 已全部完成,复盘结论并入《P1-P5交付总结与上手指南.md》 |

+ 148 - 0
docs/前端界面优化建议_V1.md

@@ -0,0 +1,148 @@
+# Web 前端界面优化建议 V1
+
+> 审查人:Car.Lin / AI 工程审查
+> 日期:2026-08-30
+> 目标:让 Web 端更简洁、直观、便于仿真工程师操作。
+> 依据:代码审查(`web/frontend/src` 全量通读)+ README / 设计方案 V2.0 / 交付总结中的开发记录。
+
+---
+
+## 0. 审查范围与方法
+
+通读了以下文件并逐条核对证据:
+
+| 类别 | 文件 |
+|---|---|
+| 路由与布局 | `router/index.ts`(15 条路由)、`layouts/MainLayout.vue`、`style.css` |
+| 核心工作流 | `ProjectList.vue`、`ProjectDetail.vue`、`PlanDetail.vue`(1129 行) |
+| 执行与监控 | `TaskManager.vue`、`MonitorDashboard.vue`、`ExecutorMonitor.vue` |
+| 分析 | `Dashboard.vue`、`AdvancedVisualization.vue`、`ExperienceList.vue` |
+| AI 页面 | `ai/PlanGenerator.vue`、`ai/AdaptiveOptimize.vue`、`ai/L0Prescreen.vue`、`ai/ResultAnalysis.vue`、`ai/FidelityCalibration.vue`、`ai/ExperienceEnhance.vue` |
+| 数据层 | `api/index.ts`、`api/ai.ts` |
+| 后端对照 | `services/fixed_params_template.py`、`services/plan_generator.py`(别名映射) |
+
+---
+
+## 1. 现状诊断(问题 → 证据 → 影响)
+
+| # | 问题 | 代码证据 | 对仿真工程师的影响 |
+|---|---|---|---|
+| D1 | **导航 14+ 项平铺,无工作流分组** | `MainLayout.vue` 侧栏 7 项平铺 + AI 子菜单 6 项 | "我下一步该去哪"不清晰;低频 AI 功能(L0预筛选/多保真度校准/经验库增强)与日常功能并列,制造菜单噪音 |
+| D2 | **监控类页面功能重叠** | `MonitorDashboard` 与 `ExecutorMonitor` 都有统计卡 + 活动任务表 + 5s 自动刷新;`TaskManager` 与之高度重合 | 同一件事三个入口,工程师无法确定"看进度该进哪个" |
+| D3 | **面包屑过弱** | `MainLayout` 只有「首页 / 当前页」两级 | 深链打开方案页(如 `/plans/5`)无法感知所属项目与上下文 |
+| D4 | **PlanDetail 单页信息过载** | `PlanDetail.vue` 1129 行:进度卡 + 4 统计卡 + AI思路 + 边界条件表 + 固定参数表(36项×8类) + 扫描变量表 + 验收标准 + AI分析区 + 结果表,全部纵向堆叠 | 核心编辑页需要长时间滚动;边界条件/固定参数/扫描变量三张表字段列相似,极易混淆 |
+| D5 | **参数三套命名口径并存** | ①`ProjectDetail` 存 `current_a/speed_rpm/magnet_temp_c`;②`PlanDetail` BC 模板用 `rated_current_a/rated_speed_rpm`;③固定参数/扫描变量用 Motor-CAD 名 `RMSCurrent/Shaft_Speed/Magnet_Temperature`;前端 `SCAN_PARAM_CN`、`categoryCnMap`、`BC_TEMPLATE`、`ALL_FIXED_PARAM_TEMPLATE` 四处手写 | 同一物理量在不同页面出现三种写法,工程师需脑内映射;后端 `plan_generator.py` 已有 `_VARIABLE_NAME_MAP` 别名兜底,恰恰说明命名漂移已成现实问题 |
+| D6 | **边界条件与固定参数"同一物理量双份设置"** | 额定电流既在 BC(`rated_current_a`)又在固定参数(`RMSCurrent`);转速、温度、冷却方式同理。后端 `build_default_fixed_params` 从 BC 推断固定参数,但**读取的是 `rated_current_a` 而非 `ProjectDetail` 存储的 `current_a`**(需验证完整调用链) | 工程师可能在两处填了不同值,无法确定哪个真正写入 Motor-CAD |
+| D7 | **固定参数 36 项默认全量铺开** | `PlanDetail.vue` 模板补齐逻辑把 `ALL_FIXED_PARAM_TEMPLATE` 全部 push 进表格 | 90% 参数用默认值即可,却逐项展示为可编辑表,注意力被稀释 |
+| D8 | **工作流步骤条是纯展示** | `ProjectDetail` 的 `el-steps` 只算 `currentStep`,不可点击、无"缺什么"提示 | 无法从步骤条获得可执行引导 |
+| D9 | **"AI分析→迭代→提取经验"三按钮割裂** | `PlanDetail.vue` 三个独立按钮 `runAIAnalysis / generateIteration / extractExperience` | 本是一组串联动作,缺一键闭环 |
+| D10 | **预估时间硬编码** | `PlanDetail.vue` `estimatedTimeMin = points * 3`(3 分钟/点) | 单点真实耗时 90~150s,100 点方案预估偏差 1.5~2 倍(README 已列为待办,前端未落实) |
+| D11 | **创建任务需手填 JSON** | `TaskManager.vue` 创建弹窗要求输入 `plan_data_json` / `parameters_json` | 违背"工程师友好"目标;应改为从方案下拉选择自动带出 |
+| D12 | **公共样式重复手写、轻微漂移** | `stat-card` 在 `ProjectList/PlanDetail/Dashboard/ExecutorMonitor` 四处重复定义,边框/圆角/字号细节不一 | 视觉不统一,改动成本高 |
+| D13 | **轮询间隔不一致** | PlanDetail 5s、AdaptiveOptimize 3s、Monitor 5s | 无统一节奏,维护与体验都不一致 |
+| D14 | **结果表列固定** | `PlanDetail` / `Dashboard` 各一份硬编码列 | 指标已扩到 35 项(P5-M6),无法按需显示会越来越挤 |
+
+---
+
+## 2. 优化建议(按优先级)
+
+### P0 — 影响最大、投入相对可控
+
+#### P0-1 信息架构重排(解决 D1/D2/D3)
+
+**目标**:侧栏按"工程师工作流"分组,一眼定位。
+
+- 侧栏改为 4 组 + 可折叠:
+  - **工作台**:项目管理(列表 / 详情)
+  - **执行**:任务管理(合并 `实时监控` + `执行器监控` 为单页 Tab:任务列表 / 执行器状态 / 汇总图表)
+  - **分析**:结果分析(`Dashboard` 与 `高级可视化` 合并或互为 Tab)
+  - **AI 智能**:仅保留 方案生成 / 自适应优化 / AI结果分析 三个高频项可见;L0预筛选、多保真度校准、经验库AI增强 收进「高级功能」折叠子组
+  - **知识**:经验库
+- 面包屑升级为 **项目 → 方案 → 任务** 层级链;深链进入 `PlanDetail` 时自动带出上级链。
+- 顶部 Header 加**全局任务状态条**:任意页面可见"运行中 N 个任务",点击直达任务管理(复用现有 `/api/tasks`、`/api/executor/status`,纯前端改动)。
+
+#### P0-2 PlanDetail 分层重构(解决 D4/D7/D9/D14)
+
+**目标**:核心编辑页从"一页长卷"改为"分页签,各司其职"。
+
+用 `el-tabs` 拆为 4 个页签:
+
+| 页签 | 内容 | 默认 |
+|---|---|---|
+| **概览** | 统计卡 + AI 设计思路 + 验收标准 + 最新结果摘要 + 主操作按钮(启动/停止仿真) | 是 |
+| **方案参数** | 扫描变量表(主)+ 固定参数(折叠态) | — |
+| **仿真结果** | 结果表 + 分析图表(趋势/Pareto/敏感性) | — |
+| **AI 闭环** | "分析 → 迭代 → 提取经验"一键向导(合并 D9 三按钮),步骤化呈现执行结果摘要 | — |
+
+固定参数表默认态:
+- 仅展示「用户修改过 / 与基线默认值不同」的行;
+- 其余按分类折叠进「更多参数…」,提供"全部展开/全部折叠";
+- 这样 36 项里通常只有个位数参数需要工程师关注。
+
+结果表:
+- 列可配置(Element Plus `show-overflow-tooltip` + 列设置),默认只显示核心指标(平均转矩/效率/脉动/总损耗/状态),其余指标折叠可选。
+
+#### P0-3 参数目录单一事实源(解决 D5/D6)
+
+**目标**:消灭三套命名口径,前端从同一目录渲染。
+
+- 后端(或共享层 `src/afmcore`)暴露一份 **参数目录**:`{ key, motorcad_var, name_cn, unit, category, description }`,覆盖边界条件、固定参数、扫描变量三类;前端统一从该目录渲染,删除 `ProjectDetail.bcFields` / `PlanDetail.BC_TEMPLATE` / `ALL_FIXED_PARAM_TEMPLATE` / `SCAN_PARAM_CN` / `categoryCnMap` 等手写副本。
+- 边界条件与固定参数的**同义关系**(如 `rated_current_a ⇄ RMSCurrent`、`rated_speed_rpm ⇄ Shaft_Speed`)在目录中显式声明;PlanDetail 在"边界条件"栏对同义参数标注"将写入固定参数 RMSCurrent(当前值 21.0A)",并提示以固定参数为最终写入值。
+- 在方案参数页签增加 **"变量写入预览"** 面板:列出本次仿真实际 `set_variable` 的清单(变量名 → 值),让工程师在点"启动"前能核对真正写进 Motor-CAD 的内容。这是当前"双份设置"最直接的兜底。
+- 需先验证 D6 调用链:确认 `ProjectDetail` 保存的 `current_a` 是否会经归一化流入 `build_default_fixed_params`(其目前读取 `rated_current_a`)。若不会,属数据一致性 Bug,应一并修复。
+
+### P1 — 提升效率与信任感
+
+#### P1-1 流程引导(解决 D8/D10)
+
+- `ProjectDetail` 步骤条改为**可交互向导**:每步显示"缺什么 / 下一步做什么"+ 快捷按钮(如无方案 → "AI 一键生成";无执行器在线 → 启动指引)。
+- 新增**仿真前检查清单**(复用已有 API):
+  - 模型路径是否存在;
+  - 固定参数中是否有"变量名需确认"项(README 已知:`Max_Speed / Winding_Connection / Current_Density / Magnet_Remanence / Insulation_Class`),列出并允许工程师确认跳过;
+  - 是否至少 1 个扫描变量;
+  - 本地执行器是否在线。
+  - 全绿才可点"启动仿真",红项点击直接定位到对应位置。
+- **预估时间校准**:用真实单点耗时。任务完成后将 `solve_time_s` 均值回写,前端显示"预计 X~Y 分钟(基于最近 N 次单点 ~Xs/点)",替代硬编码 `points*3`。
+
+#### P1-2 任务体验(解决 D11)
+
+- `TaskManager` 创建任务改为**从方案下拉选择**:选方案 → 自动带出参数与模型,JSON 输入折叠为"高级选项"。
+- 监控页合并后的单页,统一轮询节奏(建议全局 5s,可配置)。
+
+### P2 — 视觉与一致性(解决 D12/D13)
+
+- 抽取全局组件:`StatCard` / `SectionCard` / `PageHeader`,删除四处重复样式。
+- 统一轮询间隔、统一"最后更新"时间显示。
+- 深色侧栏保留,但宽度 220px 固定 → 支持折叠(collapse)适配窄屏。
+- 配色维持现有蓝/白/灰体系即可(工程师场景不需要过度装饰),重点把"任务状态"用更醒目的全局方式呈现。
+
+---
+
+## 3. 建议实施批次(可独立验收)
+
+| 批次 | 内容 | 主要涉及 | 回归要求 |
+|---|---|---|---|
+| **B1 信息架构** | 侧栏分组 + 面包屑层级 + 全局任务条 + 监控页合并 | 纯前端(`MainLayout`、`router`、`MonitorDashboard`/`ExecutorMonitor`/`TaskManager` 合并) | `npm run build` 全绿(vue-tsc 0 错误);手动点检全部路由 |
+| **B2 PlanDetail 重构** | Tabs 化 + 固定参数折叠 + 变量写入预览 + 结果列设置 | 前端为主(`PlanDetail.vue`),需后端小字段(变量写入预览基于现有 plan_data 即可) | build 全绿;对旧方案数据兼容(补齐逻辑已有) |
+| **B3 参数目录统一** | 前后端共享参数目录 + 同义映射 + D6 调用链验证 | 前后端协同 + `src/afmcore` 或 backend service | 需回归:AI 生成方案 → 边界条件 → 固定参数推断链路(可复用现有 `test_*.py` 回归脚本) |
+| **B4 流程引导** | 步骤向导 + 仿真前检查清单 + 耗时校准 | 前端 + 少量后端(单点耗时已有 `solve_time_s` 字段) | build 全绿 + 手动验证检查清单各分支 |
+
+> 建议顺序:B1 → B2 → B3 → B4。每批完成按项目纪律更新 README 与 TEST_RECORDS,代码提交与文档同步。
+
+---
+
+## 4. 项目纪律提醒(实施时遵守)
+
+- 前端改动后 `npm run build`(vue-tsc && vite)必须零错误(P5-M1 已建立基线,勿回退)。
+- `.py/.ps1` 纯 ASCII;中文字段名用 `\uXXXX`(`fixed_params_template.py` 现有写法即规范)。
+- 实际启动 Motor-CAD 求解前先 git commit;`output/`、`dist/` 不入库。
+- 原始 `.mot` 只读;参数写入保持回读校验。
+- 每批测试留痕到 `docs/TEST_RECORDS.md`。
+
+---
+
+## 5. 未验证项(诚实声明)
+
+- D6 中"`ProjectDetail` 的 `current_a` 能否流入 `build_default_fixed_params`(其读取 `rated_current_a`)"完整调用链未逐一追踪,需结合 `ai_plan.py` / `plans.py` 路由确认后定论。
+- 前端页面实际渲染效果未在本机浏览器打开验证(本次为静态代码审查),B 批次落地后需人工点检。
+- 单点耗时 90~150s 为 README/KNOWLEDGE_BASE 记录值,前端校准需以本机实测 `solve_time_s` 为准。

+ 12 - 0
executor_config.json

@@ -0,0 +1,12 @@
+{
+  "web_base_url": "http://127.0.0.1:8000",
+  "model_path": "models/MARS-12S10P_SSSR_D76-C150_V5.0-0819.mot",
+  "poll_interval": 5,
+  "instances": 1,
+  "log_dir": "output/executor_logs",
+  "log_level": "INFO",
+  "tool": "motorcad",
+  "enable_mock": false,
+  "enable_thermal": false,
+  "ambient_temperature": 25.0
+}

+ 28 - 0
scripts/build_executable.ps1

@@ -0,0 +1,28 @@
+# Build the local headless executor EXE with PyInstaller (P4-M3).
+# All strings ASCII only. Generated artifacts (build/, dist/, *.spec) are
+# NOT committed (see AGENTS.md "generated artifacts not in repo").
+$ErrorActionPreference = "Stop"
+
+$Root = Split-Path -Parent $PSScriptRoot   # <repo>
+$Src = Join-Path $Root "src"
+$Scripts = Join-Path $Root "scripts"
+$Entry = Join-Path $Scripts "run_task_executor.py"
+
+Write-Host "Root:  $Root"
+Write-Host "Entry: $Entry"
+
+if (-not (Test-Path $Entry)) {
+    Write-Error "Entry point not found: $Entry"
+}
+
+# --collect-all ansys.motorcad pulls the pymotorcad package (COM type libs)
+# which PyInstaller cannot fully infer statically.
+pyinstaller --noconfirm --clean --onefile `
+    --name "PCB-AFM-Executor" `
+    --paths $Src --paths $Root `
+    --hidden-import ansys.motorcad.core `
+    --collect-all ansys.motorcad `
+    $Entry
+
+Write-Host "Build done. Artifact:"
+Write-Host "  $Root\dist\PCB-AFM-Executor.exe"

+ 193 - 0
scripts/check_machine_paths.py

@@ -0,0 +1,193 @@
+#!/usr/bin/env python3
+"""
+check_machine_paths.py - Read-only environment & asset check for PCB AFM project.
+
+Checks:
+  1. Python version (>= 3.10)
+  2. Environment variables: MOTORCAD_ACTIVEX, ANSYSLMD_LICENSE_FILE
+  3. Motor-CAD executable path existence (env var + documented fallback path)
+  4. Python dependencies: ansys-motorcad-core, PySide6, pandas, fastapi, uvicorn, pydantic
+  5. Git repo: .git exists, HEAD valid, working tree clean
+  6. Repo assets: models/*.mot, experience/experience.db, executor_config.json, web dirs, src/afmcore
+  7. node / npm presence (needed for frontend build)
+
+Usage:
+  python scripts/check_machine_paths.py          # read-only check
+  python scripts/check_machine_paths.py --fix    # print exact fix commands (does NOT auto-execute)
+
+Exit code:
+  0 = all checks passed
+  1 = at least one check failed
+
+Note:
+  The script is read-only by default. --fix only PRINTS the exact commands to run
+  (setx / pip install), it never modifies the system by itself.
+  All output is ASCII (project source rule).
+"""
+
+import importlib.util
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+
+# documented fallback for Motor-CAD when env var is missing
+MOTORCAD_FALLBACK = r"D:\Program Files\ANSYS Inc\v261\motorcad\MotorCAD.exe"
+
+FAILURES = []
+WARNINGS = []
+
+
+def check(name, ok, detail):
+    """Record one check result and print it."""
+    tag = "PASS" if ok else "FAIL"
+    print("[%s] %s: %s" % (tag, name, detail))
+    if not ok:
+        FAILURES.append(name)
+
+
+def check_env_var(name):
+    """Check a required environment variable; return its value or ''."""
+    val = os.environ.get(name, "").strip()
+    check("env:" + name, bool(val), val if val else "MISSING")
+    return val
+
+
+def main():
+    fix_mode = "--fix" in sys.argv[1:]
+    print("=== PCB AFM - machine path check ===")
+    print("repo root: %s" % REPO_ROOT)
+
+    # 1. Python version
+    ver = sys.version_info
+    ok_ver = (ver.major, ver.minor) >= (3, 10)
+    check("python-version", ok_ver, "%d.%d.%d" % (ver.major, ver.minor, ver.micro))
+
+    # 2. required environment variables
+    motorcad_act = check_env_var("MOTORCAD_ACTIVEX")
+    check_env_var("ANSYSLMD_LICENSE_FILE")
+
+    # 3. Motor-CAD executable
+    candidates = []
+    if motorcad_act:
+        candidates.append(Path(motorcad_act))
+    candidates.append(Path(MOTORCAD_FALLBACK))
+    found_exes = [str(p) for p in candidates if p.exists()]
+    check("motorcad-exe", bool(found_exes), "; ".join(found_exes) or "none found")
+
+    # 4. python dependencies
+    deps = ["ansys.motorcad.core", "PySide6", "pandas", "fastapi", "uvicorn", "pydantic"]
+    for d in deps:
+        try:
+            found = importlib.util.find_spec(d) is not None
+        except Exception:
+            found = False
+        check("pkg:" + d, found, "installed" if found else "MISSING")
+
+    # detect a project-local virtualenv (informational: shell python may lack project deps)
+    for venv_name in (".venv", ".env"):
+        vd = REPO_ROOT / venv_name
+        if vd.exists():
+            py = vd / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
+            if py.exists():
+                print("[INFO] project venv found: %s (re-run checks with that python to validate project deps)" % py)
+            break
+
+    # 5. git repo state
+    check("git-dir", (REPO_ROOT / ".git").exists(), str(REPO_ROOT / ".git"))
+    head_ok = False
+    head = ""
+    try:
+        r = subprocess.run(
+            ["git", "rev-parse", "--verify", "HEAD"],
+            cwd=str(REPO_ROOT), capture_output=True, text=True, timeout=10,
+        )
+        head_ok = r.returncode == 0
+        head = r.stdout.strip()[:12] if head_ok else "INVALID"
+    except Exception as exc:  # noqa: BLE001 - report any failure as invalid head
+        head = "error:%s" % exc
+    check("git-head", head_ok, head)
+
+    clean = False
+    try:
+        r = subprocess.run(
+            ["git", "status", "--porcelain"],
+            cwd=str(REPO_ROOT), capture_output=True, text=True, timeout=10,
+        )
+        clean = r.returncode == 0 and r.stdout.strip() == ""
+    except Exception:
+        clean = False
+    check("git-clean", clean, "clean" if clean else "has uncommitted changes")
+
+    # 6. repo assets
+    models_dir = REPO_ROOT / "models"
+    mot_files = list(models_dir.glob("*.mot")) if models_dir.exists() else []
+    check("asset:models-mot", len(mot_files) > 0, "%d .mot file(s)" % len(mot_files))
+
+    exp_db = REPO_ROOT / "experience" / "experience.db"
+    check("asset:experience-db", exp_db.exists(), str(exp_db))
+
+    cfg = REPO_ROOT / "executor_config.json"
+    check("asset:executor-config", cfg.exists(), str(cfg))
+
+    web_f = REPO_ROOT / "web" / "frontend"
+    web_b = REPO_ROOT / "web" / "backend"
+    check("asset:web-frontend", web_f.exists(), str(web_f))
+    check("asset:web-backend", web_b.exists(), str(web_b))
+
+    afmcore = REPO_ROOT / "src" / "afmcore"
+    check("asset:afmcore", afmcore.exists(), str(afmcore))
+
+    # 7. node / npm (warning only, frontend build)
+    node_ok = shutil.which("node") is not None
+    npm_ok = shutil.which("npm") is not None
+    if node_ok and npm_ok:
+        print("[PASS] node/npm: found on PATH")
+    else:
+        WARNINGS.append("node/npm not found on PATH (needed for frontend build)")
+        print("[WARN] node/npm: missing on PATH")
+
+    # 8. summary
+    print("=== summary ===")
+    if FAILURES:
+        print("FAILED (%d): %s" % (len(FAILURES), ", ".join(FAILURES)))
+        for w in WARNINGS:
+            print("WARN: %s" % w)
+        if fix_mode:
+            _print_fix_commands()
+        sys.exit(1)
+    else:
+        print("ALL CHECKS PASSED")
+        for w in WARNINGS:
+            print("WARN: %s" % w)
+        sys.exit(0)
+
+
+def _print_fix_commands():
+    """Print (do not execute) the exact commands to fix each failure."""
+    print("=== suggested fixes (run manually) ===")
+    for name in FAILURES:
+        if name == "env:MOTORCAD_ACTIVEX":
+            print(
+                'setx MOTORCAD_ACTIVEX "%s"  # or let scripts fall back to set_motorcad_exe()'
+                % MOTORCAD_FALLBACK
+            )
+        elif name == "env:ANSYSLMD_LICENSE_FILE":
+            print('setx ANSYSLMD_LICENSE_FILE "1055@localhost"')
+        elif name.startswith("pkg:"):
+            pkg = name.split(":", 1)[1]
+            pip_name = "ansys-motorcad-core" if pkg == "ansys.motorcad.core" else pkg
+            print("pip install %s" % pip_name)
+        elif name == "git-clean":
+            print("git commit (or stash) uncommitted changes before running simulations")
+        elif name == "asset:experience-db":
+            print("run once to bootstrap the experience database (see README)")
+        elif name == "asset:models-mot":
+            print("place a baseline .mot model into models/ (read-only)")
+
+
+if __name__ == "__main__":
+    main()

+ 224 - 0
scripts/executor_config.py

@@ -0,0 +1,224 @@
+"""Executor configuration loader (P5-M2).
+
+Loads executor_config.json for the local headless executor.  The goal is
+to make the packaged EXE configurable without re-building: web address,
+model path, logging, poll interval and instance count all come from a
+JSON sidecar that the operator can edit next to the EXE.
+
+Priority (highest first):
+  1. --config CLI path          (explicit)
+  2. $EXECUTOR_CONFIG env var   (explicit)
+  3. <exe_or_script_dir>/executor_config.json   (sidecar next to EXE)
+  4. <repo_root>/executor_config.json           (source-tree template)
+  5. built-in defaults
+
+Single fields may still be overridden by env vars (highest):
+  WEB_BASE_URL / MOTORCAD_MODEL / EXECUTOR_INSTANCES /
+  EXECUTOR_POLL_INTERVAL / EXECUTOR_LOG_DIR / EXECUTOR_LOG_LEVEL /
+  EXECUTOR_TOOL / EXECUTOR_MOCK
+
+All code in this module is ASCII only (AGENTS.md constraint).
+"""
+import json
+import os
+import sys
+
+DEFAULT_WEB_BASE_URL = "http://127.0.0.1:8000"
+DEFAULT_POLL_INTERVAL = 5
+DEFAULT_INSTANCES = 1
+DEFAULT_LOG_DIR = "output/executor_logs"
+DEFAULT_LOG_LEVEL = "INFO"
+DEFAULT_TOOL = "motorcad"
+DEFAULT_MODEL_REL = "models/MARS-12S10P_SSSR_D76-C150_V5.0-0819.mot"
+
+VALID_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
+
+
+def default_config():
+    """Return the built-in default configuration dict."""
+    return {
+        "web_base_url": DEFAULT_WEB_BASE_URL,
+        "model_path": DEFAULT_MODEL_REL,
+        "poll_interval": DEFAULT_POLL_INTERVAL,
+        "instances": DEFAULT_INSTANCES,
+        "log_dir": DEFAULT_LOG_DIR,
+        "log_level": DEFAULT_LOG_LEVEL,
+        "tool": DEFAULT_TOOL,
+        "enable_mock": False,
+        "enable_thermal": False,
+        "ambient_temperature": 25.0,
+    }
+
+
+def _is_frozen():
+    """True when running from a PyInstaller onefile bundle."""
+    return bool(getattr(sys, "frozen", False))
+
+
+def _base_dir():
+    """Directory that should host the sidecar config for this runtime.
+
+    - EXE mode: the folder containing the executable.
+    - Script mode: the repository root (parent of the scripts/ folder).
+    """
+    if _is_frozen():
+        return os.path.dirname(os.path.abspath(sys.executable))
+    here = os.path.dirname(os.path.abspath(__file__))
+    return os.path.dirname(here)
+
+
+def repo_root():
+    """Repository root (parent of scripts/) when running from source."""
+    here = os.path.dirname(os.path.abspath(__file__))
+    return os.path.dirname(here)
+
+
+def find_config_path(cli_path=None):
+    """Locate a config file path, or None when none exists.
+
+    Args:
+        cli_path: optional explicit path from --config.
+
+    Returns:
+        str path of the first existing config file, else None.
+    """
+    candidates = []
+    if cli_path:
+        candidates.append(cli_path)
+    env_path = os.environ.get("EXECUTOR_CONFIG")
+    if env_path:
+        candidates.append(env_path)
+    candidates.append(os.path.join(_base_dir(), "executor_config.json"))
+    candidates.append(os.path.join(repo_root(), "executor_config.json"))
+    for cand in candidates:
+        if cand and os.path.isfile(cand):
+            return cand
+    return None
+
+
+def _resolve_model_path(model_path, base_dir):
+    """Turn a possibly-relative model_path into an absolute path.
+
+    Relative paths resolve against the config base dir (EXE dir or repo
+    root).  Empty / None values stay empty (EXE may rely on the operator
+    to set it; script mode fills a default).
+    """
+    if not model_path:
+        return None
+    if os.path.isabs(model_path):
+        return os.path.normpath(model_path)
+    return os.path.normpath(os.path.join(base_dir, model_path))
+
+
+def validate_config(cfg):
+    """Validate a config dict; raise ValueError on any violation.
+
+    Checks: types, ranges and known enum values.  Empty model_path is
+    allowed (EXE may be pointed at a model only at run time).
+    """
+    if not isinstance(cfg, dict):
+        raise ValueError("config must be a dict")
+    web = cfg.get("web_base_url")
+    if not isinstance(web, str) or not web.startswith("http"):
+        raise ValueError("web_base_url must be an http(s) URL string")
+    instances = cfg.get("instances")
+    if not isinstance(instances, int) or instances < 1:
+        raise ValueError("instances must be an int >= 1")
+    interval = cfg.get("poll_interval")
+    if not isinstance(interval, (int, float)) or interval <= 0:
+        raise ValueError("poll_interval must be a positive number")
+    level = cfg.get("log_level")
+    if level not in VALID_LOG_LEVELS:
+        raise ValueError("log_level must be one of %s" % (VALID_LOG_LEVELS,))
+    tool = cfg.get("tool")
+    if not isinstance(tool, str) or not tool:
+        raise ValueError("tool must be a non-empty string")
+    mock = cfg.get("enable_mock")
+    if not isinstance(mock, bool):
+        raise ValueError("enable_mock must be a boolean")
+    thermal = cfg.get("enable_thermal")
+    if not isinstance(thermal, bool):
+        raise ValueError("enable_thermal must be a boolean")
+    ambient = cfg.get("ambient_temperature")
+    if ambient is not None and not isinstance(ambient, (int, float)):
+        raise ValueError("ambient_temperature must be a number or null")
+    return cfg
+
+
+def load_config(cli_path=None, env_overrides=True):
+    """Load and validate the effective configuration.
+
+    Args:
+        cli_path: optional --config path.
+        env_overrides: when True, let env vars override file fields.
+
+    Returns:
+        dict with resolved absolute model_path/log_dir and the source
+        string under key "config_source".
+    """
+    cfg = default_config()
+    source = "defaults"
+
+    cfg_path = find_config_path(cli_path)
+    if cfg_path:
+        try:
+            with open(cfg_path, "r", encoding="utf-8") as fh:
+                file_cfg = json.load(fh)
+            if not isinstance(file_cfg, dict):
+                raise ValueError("config file must contain a JSON object")
+            cfg.update({k: v for k, v in file_cfg.items() if v is not None})
+            source = cfg_path
+        except (OSError, ValueError) as exc:
+            # A malformed explicit config must not be silently ignored:
+            # the operator asked for it, so surface the error.
+            raise ValueError("failed to load config %s: %s" % (cfg_path, exc))
+
+    if env_overrides:
+        env_map = {
+            "WEB_BASE_URL": "web_base_url",
+            "MOTORCAD_MODEL": "model_path",
+            "EXECUTOR_INSTANCES": "instances",
+            "EXECUTOR_POLL_INTERVAL": "poll_interval",
+            "EXECUTOR_LOG_DIR": "log_dir",
+            "EXECUTOR_LOG_LEVEL": "log_level",
+            "EXECUTOR_TOOL": "tool",
+            "EXECUTOR_MOCK": "enable_mock",
+            "EXECUTOR_THERMAL": "enable_thermal",
+            "EXECUTOR_AMBIENT": "ambient_temperature",
+        }
+        for env_key, cfg_key in env_map.items():
+            raw = os.environ.get(env_key)
+            if raw is None or raw == "":
+                continue
+            if cfg_key in ("instances", "poll_interval", "ambient_temperature"):
+                try:
+                    cfg[cfg_key] = float(raw) if "." in raw else int(raw)
+                except ValueError:
+                    raise ValueError("env %s must be numeric, got %r" % (env_key, raw))
+            elif cfg_key in ("enable_mock", "enable_thermal"):
+                cfg[cfg_key] = raw.strip().lower() in ("1", "true", "yes", "on")
+            else:
+                cfg[cfg_key] = raw
+
+    # Coerce numeric fields read from JSON (json gives int/float already).
+    if not isinstance(cfg["instances"], int):
+        cfg["instances"] = int(cfg["instances"])
+    if not isinstance(cfg["poll_interval"], (int, float)):
+        cfg["poll_interval"] = float(cfg["poll_interval"])
+
+    base_dir = _base_dir() if _is_frozen() else repo_root()
+    model_path = _resolve_model_path(cfg.get("model_path"), base_dir)
+    log_dir = cfg.get("log_dir")
+    if not log_dir:
+        log_dir = DEFAULT_LOG_DIR
+    if os.path.isabs(log_dir):
+        log_dir = os.path.normpath(log_dir)
+    else:
+        log_dir = os.path.normpath(os.path.join(base_dir, log_dir))
+
+    resolved = dict(cfg)
+    resolved["model_path"] = model_path
+    resolved["log_dir"] = log_dir
+    resolved["config_source"] = source
+    validate_config(resolved)
+    return resolved

+ 109 - 113
scripts/robust_motorcad.py

@@ -47,6 +47,7 @@ import math
 import os
 import platform
 import socket
+import sys
 import time
 import traceback
 from datetime import datetime
@@ -61,72 +62,26 @@ except ImportError:
     MotorCADError = Exception  # type: ignore
     HAS_MOTORCAD_ERROR = False
 
-
 # ---------------------------------------------------------------------------
-# Metric definitions: key, display label, and aliases (English + Chinese).
-# Chinese aliases use Unicode escapes so this file stays pure ASCII.
+# Platform core import (single source of truth for metrics / parsing).
+# This replaces the historical per-file METRIC_DEFINITIONS copies, fixing the
+# drift bug (three inconsistent metric lists) and the tavg_nm / ripple_pct
+# parsing bug (normalized matching handles full-width chars in exports).
 # ---------------------------------------------------------------------------
+_ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+_SRC_DIR = os.path.join(_ROOT_DIR, "src")
+if _SRC_DIR not in sys.path:
+    sys.path.insert(0, _SRC_DIR)
+
+from afmcore.metrics import (  # noqa: E402
+    METRIC_DEFINITIONS,
+    METRIC_KEYS,
+    METRIC_LABELS,
+    REQUIRED_METRICS,
+    extract_all_metrics as _platform_extract_all_metrics,
+    parse_export as _platform_parse_export,
+)
 
-METRIC_DEFINITIONS = [
-    {"key": "ripple_pct", "label": "Torque Ripple [%]",
-     "aliases": ["Torque Ripple (VW) [%]", "Torque Ripple (VW)[%]",
-                  "Torque Ripple (VW) %"]},
-    {"key": "ripple_nm", "label": "Torque Ripple [Nm]",
-     "aliases": ["Torque Ripple (VW)"]},
-    {"key": "tavg_nm", "label": "Average Torque VW [Nm]",
-     "aliases": ["Average torque (virtual work)",
-                  "\u5e73\u5747\u8f6c\u77e9 (virtual work)",
-                  "\u5e73\u5747\u8f6c\u77e9(virtual work)",
-                  "\u5e73\u5747\u8f6c\u77e9 (DQ)",
-                  "\u5e73\u5747\u8f6c\u77e9(DQ)",
-                  "\u5e73\u5747\u8f6c\u77e9 (loop torque)",
-                  "\u5e73\u5747\u8f6c\u77e9(loop torque)"]},
-    {"key": "shaft_torque_nm", "label": "Shaft Torque [Nm]",
-     "aliases": ["Shaft Torque", "\u8f74\u8f6c\u77e9"]},
-    {"key": "stall_torque_nm", "label": "Stall Torque [Nm]",
-     "aliases": ["Stall Torque", "\u5835\u8f6c\u8f6c\u77e9"]},
-    {"key": "torque_constant", "label": "Torque Constant Kt [Nm/A]",
-     "aliases": ["Torque Constant (Kt)", "\u8f6c\u77e9\u5e38\u6570(Kt)",
-                  "\u8f6c\u77e9\u5e38\u6570\uff08Kt\uff09"]},
-    {"key": "efficiency_pct", "label": "Efficiency [%]",
-     "aliases": ["System Efficiency", "\u7cfb\u7edf\u6548\u7387"]},
-    {"key": "back_emf_v", "label": "Back EMF LL rms [V]",
-     "aliases": ["Back EMF Line-Line Voltage (rms)",
-                  "\u7ebf\u95f4\u53cd\u5411\u7535\u52a8\u52bf\u6709\u6548\u503c",
-                  "\u7ebf\u95f4\u53cd\u5411\u7535\u52a8\u52bf\u5e45\u503c"]},
-    {"key": "total_losses_w", "label": "Total losses [W]",
-     "aliases": ["Total Losses (on load)", "\u603b\u635f\u8017(\u989d\u5b9a)",
-                  "\u603b\u635f\u8017 (\u989d\u5b9a)", "\u603b\u635f\u8017(\u7a7a\u8f7d)"]},
-    {"key": "copper_loss_w", "label": "DC copper loss [W]",
-     "aliases": ["Armature DC Copper Loss (on load)",
-                  "\u7535\u67a2\u76f4\u6d41\u94dc\u8017(\u5e26\u8f7d)",
-                  "\u7535\u67a2\u76f4\u6d41\u94dc\u8017 \uff08\u5e26\u8f7d\uff09",
-                  "\u7535\u67a2\u76f4\u6d41\u94dc\u8017 \uff08\u7a7a\u8f7d\uff09"]},
-    {"key": "magnet_loss_w", "label": "Magnet loss [W]",
-     "aliases": ["Magnet Loss (on load)",
-                  "\u6c38\u78c1\u4f53\u635f\u8017(\u989d\u5b9a)",
-                  "\u6c38\u78c1\u4f53\u635f\u8017 (\u989d\u5b9a)",
-                  "\u6c38\u78c1\u4f53\u635f\u8017(\u7a7a\u8f7d)"]},
-    {"key": "iron_loss_w", "label": "Stator iron loss [W]",
-     "aliases": ["Stator iron Loss [total] (on load)",
-                  "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017](\u989d\u5b9a)",
-                  "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017] (\u989d\u5b9a)",
-                  "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017](\u7a7a\u8f7d)"]},
-    {"key": "input_power_w", "label": "Input power [W]",
-     "aliases": ["Input Power", "\u8f93\u5165\u529f\u7387"]},
-    {"key": "output_power_w", "label": "Output power [W]",
-     "aliases": ["Output Power", "\u8f93\u51fa\u529f\u7387"]},
-    {"key": "em_power_w", "label": "EM power [W]",
-     "aliases": ["Electromagnetic Power", "\u7535\u78c1\u529f\u7387"]},
-    {"key": "shaft_speed_rpm", "label": "Shaft speed [rpm]",
-     "aliases": ["Shaft Speed", "\u8f6c\u901f[RPM]", "\u8f6c\u901f\uff5bRPM\uff5d",
-                  "\u7a7a\u8f7d\u8f6c\u901f"]},
-    {"key": "phase_current_peak_a", "label": "Phase current peak [A]",
-     "aliases": ["Peak Phase Current", "\u76f8\u7535\u6d41\u5cf0\u503c"]},
-    {"key": "line_current_rms_a", "label": "Line current rms [A]",
-     "aliases": ["Line Current (rms)", "\u7ebf\u7535\u6d41 (\u6709\u6548\u503c)",
-                  "\u7ebf\u7535\u6d41(\u6709\u6548\u503c)", "\u76f8\u7535\u6d41\u6709\u6548\u503c"]},
-]
 
 # Known incompatible sampling point / mesh combinations that cause popups
 INCOMPATIBLE_SAMPLING_MESH = [
@@ -148,6 +103,12 @@ RECOMMENDED_SAMPLING_MESH = [
 
 VARIABLE_NAME_MAP: Dict[str, Dict[str, str]] = {
     # canonical_name: {version_range: actual_variable_name}
+    # Business alias -> Motor-CAD actual variable name.
+    # L0 / plan_schema use airgap_mm; Motor-CAD calls it Airgap.
+    # Confirmed by variable probing (TEST-002, KNOWLEDGE_BASE).
+    "airgap_mm": {
+        "default": "Airgap",
+    },
     "MagneticWindingType": {
         "default": "MagneticWindingType",
         "legacy": "MagWindingType",  # pre-2023 versions
@@ -334,8 +295,17 @@ class RobustMotorCADSolver:
 
     def __init__(self, model_path: str, output_dir: Optional[str] = None,
                  point_timeout: int = 300, max_retries: int = 3,
-                 headless: bool = False, motorcad_version: Optional[str] = None):
+                 headless: bool = False, motorcad_version: Optional[str] = None,
+                 enable_thermal: bool = False,
+                 ambient_temperature: Optional[float] = None):
         self.model_path = model_path
+        # P5-M6: optional thermal solve (requires model with thermal
+        # network configured; OFF by default to preserve EM-only behavior)
+        self.enable_thermal = bool(enable_thermal)
+        # P5-M6 thermal boundary: when set, Ambient_Temperature is overridden
+        # before the thermal solve. MARS ships 125 C (abnormal); use 25-40.
+        # None = leave the model value unchanged.
+        self.ambient_temperature = ambient_temperature
         self.output_dir = output_dir or os.path.join(
             os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
             "output", f"run_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
@@ -559,7 +529,8 @@ class RobustMotorCADSolver:
             return False
 
     def run_single_point(self, params: Dict[str, Any], point_index: int = 0,
-                          point_label: str = "") -> Dict[str, Any]:
+                          point_label: str = "",
+                          enable_thermal: Optional[bool] = None) -> Dict[str, Any]:
         """Run a single simulation point with full robustness protocol.
 
         Protocol:
@@ -613,11 +584,20 @@ class RobustMotorCADSolver:
                             # is appended to results and written to disk
                             break
 
-                    # Step 3: Write all parameters with verification
+                    # Step 3: Write all numeric parameters with verification.
+                    # Non-numeric params (materials, grades, strings) are
+                    # skipped because set_variable expects a number. (C1 fix)
                     for var, val in params.items():
-                        if var in ("point_index", "point_label"):
+                        if var in ("point_index", "point_label", "point_id"):
                             continue
-                        self._write_and_verify(var, float(val))
+                        try:
+                            num = float(val)
+                        except (TypeError, ValueError):
+                            self._log(
+                                f"Skipping non-numeric param {var}={val!r}"
+                            )
+                            continue
+                        self._write_and_verify(var, num)
 
                     # Step 4: Handle linked parameters
                     if "Slot_Opening" in params and "Copper_Width" not in params:
@@ -630,6 +610,37 @@ class RobustMotorCADSolver:
                     except MotorCADError as e:
                         raise RuntimeError(f"Magnetic calculation failed: {e}")
 
+                    # Step 5b: Optional thermal calculation (P5-M6)
+                    # Best-effort: thermal solve requires a model with thermal
+                    # network configured; failures are warnings, EM results
+                    # remain valid. enable_thermal param overrides instance default.
+                    _thermal_on = (
+                        enable_thermal if enable_thermal is not None
+                        else self.enable_thermal
+                    )
+                    if _thermal_on:
+                        try:
+                            # P5-M6 fix (2026-09-04): pymotorcad has NO
+                            # do_thermal_calculation() method. The steady-state
+                            # thermal solve is do_steady_state_analysis().
+                            # Verified against ansys.motorcad.core sources.
+                            if self.ambient_temperature is not None:
+                                self._write_and_verify(
+                                    "Ambient_Temperature",
+                                    float(self.ambient_temperature),
+                                )
+                                self._log(
+                                    "Ambient_Temperature overridden to %s"
+                                    % self.ambient_temperature
+                                )
+                            self.mc.do_steady_state_analysis()
+                            self._log("Steady-state thermal calculation completed")
+                        except Exception as _therr:  # noqa: BLE001
+                            self._log(
+                                f"WARNING: thermal calculation failed "
+                                f"(model may lack thermal network): {_therr}"
+                            )
+
                     # Step 6: Export and parse
                     raw_file = os.path.join(
                         self.raw_dir,
@@ -645,6 +656,28 @@ class RobustMotorCADSolver:
                         raise RuntimeError(f"Export file not created: {raw_file}")
 
                     metrics = self._parse_export(raw_file)
+
+                    # Step 6b: Optional thermal export and metric merge (P5-M6)
+                    # Best-effort: thermal export section name may vary by
+                    # Motor-CAD version; failures do not invalidate EM metrics.
+                    if _thermal_on:
+                        try:
+                            _thermal_file = raw_file.replace(".csv", "_thermal.csv")
+                            # solution_type is "SteadyState" (NOT "Thermal").
+                            # Valid values: EMagnetic / Lab / SteadyState / Transient.
+                            self.mc.export_results("SteadyState", _thermal_file)
+                            if os.path.exists(_thermal_file):
+                                _thermal_metrics = self._parse_export(_thermal_file)
+                                metrics.update(_thermal_metrics)
+                                self._log(
+                                    "Thermal metrics merged: %s"
+                                    % sorted(_thermal_metrics.keys())
+                                )
+                        except Exception as _texerr:  # noqa: BLE001
+                            self._log(
+                                f"WARNING: thermal export/merge failed: {_texerr}"
+                            )
+
                     result["metrics"] = metrics
                     result["status"] = "OK"
                     break
@@ -740,54 +773,17 @@ class RobustMotorCADSolver:
         return points
 
     def _parse_export(self, filepath: str) -> Dict[str, float]:
-        """Parse Motor-CAD export CSV with bilingual field matching.
+        """Parse Motor-CAD export CSV with normalized bilingual matching.
 
-        Motor-CAD exports semicolon-separated CSV. Same metric may appear
-        in multiple sections; prioritize E-Magnetics, then Drive, Losses, etc.
-        Multi-encoding fallback (utf-8-sig, utf-8, gbk, latin-1).
+        Delegates to the platform single source of truth
+        (src/afmcore/metrics.py), which applies full-width -> half-width
+        normalization. This fixes the historical bug where tavg_nm and
+        ripple_pct could not be matched due to invisible full-width chars
+        in exported field names.
         """
-        metrics: Dict[str, float] = {}
         if not os.path.exists(filepath):
-            return metrics
-
-        content = None
-        for encoding in ("utf-8-sig", "utf-8", "gbk", "latin-1"):
-            try:
-                with open(filepath, "r", encoding=encoding) as f:
-                    content = f.read()
-                break
-            except (UnicodeDecodeError, Exception):
-                continue
-
-        if content is None:
-            return metrics
-
-        lines = content.splitlines()
-        for line in lines:
-            if ";" not in line:
-                continue
-            parts = line.split(";")
-            if len(parts) < 2:
-                continue
-            field_name = parts[0].strip()
-            value = None
-            for part in parts[1:]:
-                part = part.strip()
-                try:
-                    value = float(part.replace(",", "."))
-                    break
-                except (ValueError, Exception):
-                    continue
-            if value is None:
-                continue
-
-            for metric_def in METRIC_DEFINITIONS:
-                if field_name in metric_def["aliases"]:
-                    if metric_def["key"] not in metrics:
-                        metrics[metric_def["key"]] = value
-                    break
-
-        return metrics
+            return {}
+        return _platform_extract_all_metrics(_platform_parse_export(filepath))
 
     def _write_result_to_disk(self, result: Dict[str, Any]) -> None:
         """Write result to CSV and JSON immediately (flush + fsync)."""

+ 169 - 0
scripts/run_task_executor.py

@@ -0,0 +1,169 @@
+"""Entry point to start the local Motor-CAD task executor (P4-M2 / P5-M2).
+
+Usage:
+    python scripts/run_task_executor.py [--config path] [--instances N]
+                                        [--interval N] [--mock]
+                                        [--log-dir dir] [--log-level LVL]
+
+Configuration is loaded from executor_config.json (see executor_config.py
+for the lookup order and env-var overrides). This single entry point can
+launch N parallel executor instances via config["instances"] or --instances.
+
+NOTE: All strings must be ASCII only.
+"""
+import argparse
+import logging
+import os
+import sys
+import time
+from datetime import datetime
+
+_SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
+if _SCRIPTS_DIR not in sys.path:
+    sys.path.insert(0, _SCRIPTS_DIR)
+
+import executor_config  # noqa: E402
+from task_executor import MotorCADTaskExecutor  # noqa: E402
+
+
+def setup_logging(log_dir, level_name):
+    """Configure a file + console logger for the executor."""
+    os.makedirs(log_dir, exist_ok=True)
+    log_file = os.path.join(
+        log_dir, "executor_%s.log" % datetime.now().strftime("%Y%m%d_%H%M%S")
+    )
+    logging.basicConfig(
+        level=getattr(logging, level_name, logging.INFO),
+        format="%(asctime)s %(levelname)s %(message)s",
+        handlers=[
+            logging.FileHandler(log_file, encoding="utf-8"),
+            logging.StreamHandler(),
+        ],
+    )
+    return log_file
+
+
+def make_log_callback(kind):
+    """Return an executor callback that logs and echoes to console."""
+    logger = logging.getLogger("executor")
+
+    def _cb(*args):
+        msg = " ".join(str(a) for a in args)
+        if kind == "progress":
+            logger.info("%s", msg)
+        elif kind == "complete":
+            logger.info("%s", msg)
+        else:
+            logger.error("%s", msg)
+        print(msg, flush=True)
+
+    return _cb
+
+
+def build_executors(cfg):
+    """Create cfg['instances'] MotorCADTaskExecutor objects."""
+    multi = cfg["instances"] > 1
+    executors = []
+    for i in range(cfg["instances"]):
+        ex = MotorCADTaskExecutor(
+            web_base_url=cfg["web_base_url"],
+            model_path=cfg["model_path"],
+            enable_mock=cfg["enable_mock"],
+            tool=cfg["tool"],
+            enable_thermal=cfg["enable_thermal"],
+            ambient_temperature=cfg["ambient_temperature"],
+            executor_id=("motorcad-executor-%s" % i) if multi else None,
+            on_progress=make_log_callback("progress"),
+            on_complete=make_log_callback("complete"),
+            on_error=make_log_callback("error"),
+        )
+        executors.append(ex)
+    return executors
+
+
+def main():
+    parser = argparse.ArgumentParser(prog="pcb-afm-executor")
+    parser.add_argument("--version", action="store_true",
+                        help="print version and exit")
+    parser.add_argument("--self-test", action="store_true",
+                        help="run a mock single-point execution and exit "
+                             "(no web backend, no Motor-CAD)")
+    parser.add_argument("--config", default=None,
+                        help="path to executor_config.json")
+    parser.add_argument("--instances", type=int, default=None,
+                        help="parallel executor count (overrides config)")
+    parser.add_argument("--interval", type=int, default=None,
+                        help="poll interval seconds (overrides config)")
+    parser.add_argument("--mock", action="store_true",
+                        help="use mock solver (no Motor-CAD)")
+    parser.add_argument("--log-dir", default=None,
+                        help="log output dir (overrides config)")
+    parser.add_argument("--log-level", default=None,
+                        help="log level (overrides config)")
+    args = parser.parse_args()
+
+    if args.version:
+        print("PCB-AFM Executor 1.1.0 (platform P5-M2, configurable)")
+        return
+    if args.self_test:
+        import tempfile
+        from task_executor import TaskExecutor
+        _ex = TaskExecutor(task_dir=tempfile.mkdtemp(prefix="selftest_"),
+                           enable_mock=True)
+        _ex.dispatch_task = lambda tid: True
+        _captured = {}
+        _ex.on_complete = lambda tid, res, met: _captured.update(
+            {tid: (len(res), res[0].get("status") if res else None)})
+        _ex.execute_task({
+            "task_id": "selftest-1",
+            "parameters": [{"airgap_mm": 1.0, "point_id": 1}],
+        })
+        _n, _st = _captured.get("selftest-1", (0, None))
+        print("self-test OK: %d point(s), status=%s" % (_n, _st))
+        return
+
+    # Load configuration (CLI / env / config file / defaults).
+    cfg = executor_config.load_config(cli_path=args.config)
+    if args.instances is not None:
+        cfg["instances"] = args.instances
+    if args.interval is not None:
+        cfg["poll_interval"] = args.interval
+    if args.mock:
+        cfg["enable_mock"] = True
+    if args.log_dir:
+        cfg["log_dir"] = args.log_dir
+    if args.log_level:
+        cfg["log_level"] = args.log_level
+    executor_config.validate_config(cfg)
+
+    log_file = setup_logging(cfg["log_dir"], cfg["log_level"])
+    logger = logging.getLogger("executor")
+    logger.info("config source: %s", cfg["config_source"])
+    logger.info("model=%s", cfg["model_path"])
+    logger.info("web_base_url=%s", cfg["web_base_url"])
+    logger.info("instances=%s poll_interval=%s tool=%s mock=%s log=%s",
+                cfg["instances"], cfg["poll_interval"], cfg["tool"],
+                cfg["enable_mock"], log_file)
+
+    executors = build_executors(cfg)
+    threads = []
+    for ex in executors:
+        threads.append(ex.start_polling(interval=int(cfg["poll_interval"])))
+        logger.info("executor started: %s", ex.executor_id)
+
+    print("Task executor started. Model=%s" % cfg["model_path"], flush=True)
+    print("Web base URL: %s" % cfg["web_base_url"], flush=True)
+    print("Ctrl+C to stop.", flush=True)
+    try:
+        while any(t.is_alive() for t in threads):
+            time.sleep(1)
+    except KeyboardInterrupt:
+        for ex in executors:
+            ex.stop()
+            ex.cleanup()
+        logger.info("All executors stopped.")
+        print("Executor stopped.", flush=True)
+
+
+if __name__ == "__main__":
+    main()

+ 75 - 0
scripts/run_task_executor_parallel.py

@@ -0,0 +1,75 @@
+"""Entry point to start N local Motor-CAD task executors (P3-M3 / P5-M2).
+
+Configurable via executor_config.json (see executor_config.py). This
+entry is kept for back-compat with the original --instances/--interval/
+--mock CLI and delegates to the shared config loader + executor builder
+in run_task_executor.
+
+Run in background:
+    python scripts/run_task_executor_parallel.py --instances 3 --interval 5
+
+NOTE: All strings must be ASCII only.
+"""
+import argparse
+import os
+import sys
+import time
+
+_SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
+if _SCRIPTS_DIR not in sys.path:
+    sys.path.insert(0, _SCRIPTS_DIR)
+
+import executor_config  # noqa: E402
+from run_task_executor import setup_logging, build_executors  # noqa: E402
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Start N local Motor-CAD executors")
+    parser.add_argument("--instances", type=int, default=None,
+                        help="number of parallel executor instances "
+                             "(overrides config)")
+    parser.add_argument("--interval", type=int, default=None,
+                        help="poll interval in seconds (overrides config)")
+    parser.add_argument("--mock", action="store_true",
+                        help="use mock solver (no Motor-CAD) for testing")
+    parser.add_argument("--config", default=None,
+                        help="path to executor_config.json")
+    args = parser.parse_args()
+
+    cfg = executor_config.load_config(cli_path=args.config)
+    if args.instances is not None:
+        cfg["instances"] = args.instances
+    if args.interval is not None:
+        cfg["poll_interval"] = args.interval
+    if args.mock:
+        cfg["enable_mock"] = True
+    executor_config.validate_config(cfg)
+    if cfg["instances"] < 1:
+        parser.error("--instances must be >= 1")
+
+    log_file = setup_logging(cfg["log_dir"], cfg["log_level"])
+    print("N=%d executors. Model=%s" % (cfg["instances"], cfg["model_path"]),
+          flush=True)
+    print("Web base URL: %s" % cfg["web_base_url"], flush=True)
+    print("Log: %s" % log_file, flush=True)
+    print("Ctrl+C to stop.", flush=True)
+
+    executors = build_executors(cfg)
+    threads = []
+    for ex in executors:
+        threads.append(ex.start_polling(interval=int(cfg["poll_interval"])))
+        print("Executor started: %s" % ex.executor_id, flush=True)
+
+    try:
+        while any(t.is_alive() for t in threads):
+            time.sleep(1)
+    except KeyboardInterrupt:
+        for ex in executors:
+            ex.stop()
+            ex.cleanup()
+        print("All executors stopped.", flush=True)
+
+
+if __name__ == "__main__":
+    main()

+ 218 - 0
scripts/run_thermal.py

@@ -0,0 +1,218 @@
+"""Standalone Motor-CAD steady-state thermal simulation for the MARS model.
+
+Purpose: validate that the Motor-CAD thermal solver runs end-to-end on the
+MARS PCB axial flux motor, and capture the REAL thermal result field names
+so the metric aliases in src/afmcore/metrics.py can be tuned to match.
+
+Flow (mode=steady, default):
+    1. Ensure Motor-CAD environment (license + exe path fallback).
+    2. Connect to a new, visible Motor-CAD instance.
+    3. Load the MARS baseline model.
+    4. Run the electromagnetic calculation (losses are the thermal source).
+    5. Run the steady-state thermal analysis.
+    6. Export EM + thermal results (solution_type="SteadyState") and parse.
+
+Flow (mode=coupled):
+    Steps 1-3, then do_magnetic_thermal_calculation (EM + thermal in one
+    coupled call), then export and parse both EM and thermal results.
+
+Run with the venv python that has ansys-motorcad-core installed, e.g.:
+    <venv>/Scripts/python.exe scripts/run_thermal.py --mode steady
+    <venv>/Scripts/python.exe scripts/run_thermal.py --mode coupled
+
+All source is ASCII only.
+"""
+from __future__ import annotations
+
+import argparse
+import math
+import os
+import sys
+import time
+import traceback
+from datetime import datetime
+from pathlib import Path
+
+# Make the platform core importable (src/afmcore/metrics.py).
+_ROOT = Path(__file__).resolve().parent.parent
+_SRC = _ROOT / "src"
+if str(_SRC) not in sys.path:
+    sys.path.insert(0, str(_SRC))
+
+from afmcore.metrics import parse_export, extract_all_metrics  # noqa: E402
+
+MODEL_PATH = _ROOT / "models" / "MARS-12S10P_SSSR_D76-C150_V5.0-0819.mot"
+
+_MOTORCAD_EXE_CANDIDATES = [
+    r"D:\Program Files\ANSYS Inc\v261\motorcad\MotorCAD.exe",
+    r"E:\Program Files\ANSYS Inc\v261\motorcad\MotorCAD.exe",
+    r"C:\Program Files\ANSYS Inc\v261\motorcad\MotorCAD.exe",
+]
+
+# Workload for the EM run (matches the MARS baseline documented in
+# KNOWLEDGE_BASE section 3): RMS phase current 21 A, shaft speed 5000 rpm.
+WORKLOAD = {
+    "RMSCurrent": 21.0,
+    "Shaft_Speed": 5000.0,
+}
+
+
+def log(msg: str) -> None:
+    print(msg, flush=True)
+
+
+def ensure_environment() -> None:
+    """Set Motor-CAD env vars (non-login shell trap, see KNOWLEDGE_BASE 1)."""
+    if not os.environ.get("MOTORCAD_ACTIVEX"):
+        try:
+            from ansys.motorcad.core import set_motorcad_exe
+            for candidate in _MOTORCAD_EXE_CANDIDATES:
+                if os.path.exists(candidate):
+                    set_motorcad_exe(candidate)
+                    log("MOTORCAD_ACTIVEX unset; fallback to %s" % candidate)
+                    break
+        except Exception:
+            pass
+    if not os.environ.get("ANSYSLMD_LICENSE_FILE"):
+        os.environ["ANSYSLMD_LICENSE_FILE"] = "1055@localhost"
+
+
+def write_and_verify(mc, variable: str, value: float) -> None:
+    """Write a variable and read it back; raise on mismatch (AGENTS rule 4)."""
+    mc.set_variable(variable, value)
+    applied = float(mc.get_variable(variable))
+    if not math.isclose(applied, value, rel_tol=1e-8, abs_tol=1e-7):
+        raise RuntimeError(
+            "Write verification failed for %s: wrote %s, read %s"
+            % (variable, value, applied)
+        )
+
+
+def main(mode: str = "steady", ambient: float = None) -> int:
+    ensure_environment()
+
+    import ansys.motorcad.core as pymotorcad
+
+    output_dir = _ROOT / "output" / (
+        "thermal_validation_" + datetime.now().strftime("%Y%m%d_%H%M%S")
+    )
+    raw_dir = output_dir / "raw"
+    raw_dir.mkdir(parents=True, exist_ok=True)
+    log("Mode: %s" % mode)
+    log("Output dir: %s" % output_dir)
+
+    log("Connecting to a new visible Motor-CAD instance ...")
+    mc = pymotorcad.MotorCAD(open_new_instance=True, keep_instance_open=False)
+    mc.set_visible(True)
+    mc.set_variable("MessageDisplayState", 2)
+    mc.display_screen("Scripting")
+    time.sleep(2)
+    log("Connected.")
+
+    try:
+        log("Loading model: %s" % MODEL_PATH)
+        mc.load_from_file(str(MODEL_PATH))
+
+        for var, val in WORKLOAD.items():
+            try:
+                write_and_verify(mc, var, val)
+                log("  %s = %s (verified)" % (var, val))
+            except Exception as exc:  # noqa: BLE001
+                log("  WARNING: %s write failed: %s" % (var, exc))
+
+        if ambient is not None:
+            try:
+                write_and_verify(mc, "Ambient_Temperature", ambient)
+                log("  Ambient_Temperature = %s (override, verified)" % ambient)
+            except Exception as exc:  # noqa: BLE001
+                log("  WARNING: Ambient_Temperature write failed: %s" % exc)
+
+        if mode == "coupled":
+            # Magnetic-thermal coupled solve: EM + thermal in one call.
+            log("Running magnetic-thermal coupled calculation ...")
+            t0 = time.time()
+            mc.do_magnetic_thermal_calculation()
+            log("Coupled solve done in %.1f s" % (time.time() - t0))
+        else:
+            log("Running electromagnetic calculation (losses = thermal source) ...")
+            t0 = time.time()
+            mc.do_magnetic_calculation()
+            log("EM done in %.1f s" % (time.time() - t0))
+
+            log("Running steady-state thermal analysis ...")
+            t0 = time.time()
+            mc.do_steady_state_analysis()
+            log("Thermal steady-state done in %.1f s" % (time.time() - t0))
+
+        em_raw = raw_dir / "emagnetic.csv"
+        mc.export_results("EMagnetic", str(em_raw))
+        log("EM results exported: %s" % em_raw)
+
+        thermal_raw = raw_dir / "thermal_steadystate.csv"
+        mc.export_results("SteadyState", str(thermal_raw))
+        log("Thermal results exported: %s" % thermal_raw)
+
+        parsed = parse_export(thermal_raw)
+        metrics = extract_all_metrics(parsed)
+
+        # Print the key thermal metrics (not the full field dump, which is
+        # only needed when tuning aliases; keep output compact).
+        log("")
+        log("=== Extracted thermal metrics ===")
+        thermal_keys = [
+            "winding_temp_c", "winding_hotspot_temp_c", "magnet_temp_c",
+            "stator_temp_c", "bearing_temp_c", "temp_rise_c",
+            "thermal_resistance_k_w",
+        ]
+        for key in thermal_keys:
+            if key in metrics:
+                log("    %s = %s" % (key, metrics[key]))
+
+        # Also report the EM metrics for coupled-vs-steady comparison.
+        em_parsed = parse_export(em_raw)
+        em_metrics = extract_all_metrics(em_parsed)
+        log("")
+        log("=== Extracted EM metrics (for comparison) ===")
+        for key in ["tavg_nm", "ripple_pct", "total_losses_w", "efficiency_pct"]:
+            if key in em_metrics:
+                log("    %s = %s" % (key, em_metrics[key]))
+
+        log("")
+        log("Thermal validation finished. Output dir: %s" % output_dir)
+        return 0
+    finally:
+        try:
+            mc.load_from_file(str(MODEL_PATH))
+        except Exception:  # noqa: BLE001
+            pass
+        try:
+            mc.quit()
+        except Exception:  # noqa: BLE001
+            pass
+        log("Motor-CAD instance closed.")
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(
+        description="Motor-CAD thermal validation for the MARS model."
+    )
+    parser.add_argument(
+        "--mode",
+        choices=["steady", "coupled"],
+        default="steady",
+        help="steady = EM then steady-state thermal (default); "
+             "coupled = do_magnetic_thermal_calculation (EM+thermal in one).",
+    )
+    parser.add_argument(
+        "--ambient",
+        type=float,
+        default=None,
+        help="override Ambient_Temperature (degC). Default uses the model "
+             "value (MARS model ships 125, which is abnormal; try 25 or 40).",
+    )
+    args = parser.parse_args()
+    try:
+        sys.exit(main(mode=args.mode, ambient=args.ambient))
+    except Exception:
+        traceback.print_exc()
+        sys.exit(1)

+ 259 - 54
scripts/task_executor.py

@@ -37,6 +37,7 @@ class TaskExecutor:
         on_complete: Optional[Callable] = None,
         on_error: Optional[Callable] = None,
         enable_mock: bool = False,
+        executor_id: Optional[str] = None,
     ):
         self.web_base_url = web_base_url.rstrip("/")
         self.task_dir = task_dir or os.path.join(
@@ -51,24 +52,78 @@ class TaskExecutor:
         self._running = False
         self._current_task: Optional[Dict[str, Any]] = None
         self._stop_event = threading.Event()
+        if executor_id is not None:
+            self.executor_id = executor_id
+        else:
+            self.executor_id = "motorcad-executor-%s-%s" % (os.getpid(), uuid.uuid4().hex[:4])
 
     def fetch_pending_tasks(self) -> List[Dict[str, Any]]:
-        """Fetch pending tasks from Web backend."""
+        """Fetch claimable tasks from Web backend.
+
+        Web's start-simulation marks tasks as 'dispatched' immediately, while
+        tasks created via the tasks API stay 'pending'. The executor claims
+        BOTH states so every task gets picked up regardless of creation path.
+        """
         if requests is None:
             return self._scan_local_task_files()
+        tasks = []
+        for st in ("pending", "dispatched"):
+            try:
+                resp = requests.get(
+                    f"{self.web_base_url}/api/tasks",
+                    params={"status": st, "limit": 20},
+                    timeout=10,
+                )
+                if resp.status_code == 200:
+                    data = resp.json()
+                    tasks.extend(data.get("tasks", []))
+            except Exception as e:
+                if self.on_error:
+                    self.on_error(f"Fetch tasks failed: {str(e)}")
+        # De-duplicate by task_id (keep first occurrence)
+        seen = set()
+        result = []
+        for t in tasks:
+            tid = t.get("task_id")
+            if tid and tid not in seen:
+                seen.add(tid)
+                result.append(t)
+        return result
+
+    def _hydrate_task(self, task: Dict[str, Any]) -> Dict[str, Any]:
+        """Fetch full task payload (parameters + plan_data) from Web backend.
+
+        The list API only returns task metadata; the actual parameter sets
+        live in the task.json file exposed by the download endpoint.
+        """
+        if requests is None:
+            return task
+        tid = task.get("task_id")
+        if not tid:
+            return task
         try:
             resp = requests.get(
-                f"{self.web_base_url}/api/tasks",
-                params={"status": "pending", "limit": 10},
+                f"{self.web_base_url}/api/tasks/{tid}/download",
                 timeout=10,
             )
             if resp.status_code == 200:
-                data = resp.json()
-                return data.get("tasks", [])
+                full = resp.json()
+                if isinstance(full, dict):
+                    if full.get("parameters"):
+                        task = {**task, **full}
+                    elif task.get("_local_file"):
+                        # local file fallback
+                        try:
+                            with open(task["_local_file"], "r", encoding="utf-8") as f:
+                                local = json.load(f)
+                            if local.get("parameters"):
+                                task = {**task, **local}
+                        except Exception:
+                            pass
         except Exception as e:
             if self.on_error:
-                self.on_error(f"Fetch tasks failed: {str(e)}")
-        return []
+                self.on_error(f"Hydrate task {tid} failed: {str(e)}")
+        return task
 
     def _scan_local_task_files(self) -> List[Dict[str, Any]]:
         """Scan local task directory for task files (fallback mode).
@@ -158,7 +213,7 @@ class TaskExecutor:
         """Report final results to Web backend."""
         if requests is None:
             if self.on_complete:
-                self.on_complete(task_id, results)
+                self.on_complete(task_id, results, metrics)
             return True
         try:
             payload = {
@@ -179,12 +234,56 @@ class TaskExecutor:
                 self.on_error(f"Report results failed: {str(e)}")
             return False
 
+    def _report_to_adaptive_loop(
+        self, task: Dict[str, Any], results: List[Dict[str, Any]]
+    ) -> None:
+        """Feed an adaptive_batch task's results back into its loop.
+
+        Maps each point result to {point_id, metrics, status} and posts to the
+        loop's report-results endpoint. Best-effort: a failure here must not
+        break normal task reporting (results are already stored on the task).
+        """
+        if requests is None:
+            return
+        if task.get("task_type") != "adaptive_batch":
+            return
+        loop_id = task.get("loop_id")
+        if not loop_id:
+            return
+        point_results = []
+        for r in results:
+            pid = r.get("point_id")
+            if pid is None:
+                continue
+            point_results.append({
+                "point_id": pid,
+                "metrics": r.get("metrics") or {},
+                "status": "ok" if r.get("status") == "OK" else "failed",
+            })
+        if not point_results:
+            return
+        try:
+            resp = requests.post(
+                f"{self.web_base_url}/api/adaptive/loops/{loop_id}/report-results",
+                json={"point_results": point_results},
+                timeout=120,
+            )
+            if self.on_progress:
+                self.on_progress(
+                    f"Adaptive loop {loop_id}: reported {len(point_results)} "
+                    f"points (HTTP {resp.status_code})"
+                )
+        except Exception as e:
+            if self.on_error:
+                self.on_error(f"Adaptive loop report failed ({loop_id}): {str(e)}")
+
     def execute_task(self, task: Dict[str, Any]) -> None:
         """Execute a single simulation task.
 
         This is a template method. Override _run_simulation_point in
         subclasses to implement actual Motor-CAD simulation.
         """
+        task = self._hydrate_task(task)
         task_id = task.get("task_id", str(uuid.uuid4())[:8])
         parameters = task.get("parameters", [])
         total_points = len(parameters)
@@ -192,7 +291,21 @@ class TaskExecutor:
         start_time = time.time()
 
         self._current_task = task
-        self.dispatch_task(task_id)
+        # start-simulation already marks a task 'dispatched' at creation, while
+        # tasks created via the tasks API stay 'pending'. Only claim (dispatch)
+        # a task that is still pending; re-dispatching an already-dispatched
+        # task is rejected by the backend (pending -> dispatched only) and
+        # would otherwise be misreported as "not claimable".
+        claimed = True
+        if task.get("status") == "pending":
+            claimed = self.dispatch_task(task_id)
+        if not claimed:
+            # Another instance already claimed this task; skip it so
+            # parallel executors never duplicate the same simulation.
+            if self.on_error:
+                self.on_error("Task %s not claimable (claimed/network); skip" % task_id)
+            self._current_task = None
+            return
 
         for idx, params in enumerate(parameters):
             if self._stop_event.is_set():
@@ -204,16 +317,21 @@ class TaskExecutor:
             try:
                 point_result = self._run_simulation_point(params, idx)
                 point_result["point_index"] = idx
+                if "point_id" in params:
+                    point_result["point_id"] = params["point_id"]
                 point_result["params"] = params
                 results.append(point_result)
             except Exception as e:
                 # A2 fix: failed points are recorded as failed, NOT mock data
-                results.append({
+                failed_result = {
                     "point_index": idx,
                     "params": params,
                     "status": "FAILED",
                     "error": str(e),
-                })
+                }
+                if "point_id" in params:
+                    failed_result["point_id"] = params["point_id"]
+                results.append(failed_result)
                 if self.on_error:
                     self.on_error(f"Point {idx} failed: {str(e)}")
 
@@ -232,6 +350,11 @@ class TaskExecutor:
         self.report_results(task_id, results, metrics, None, duration, status)
         self.report_progress(task_id, total_points, total_points, None, duration)
 
+        # Adaptive-loop bridge: an adaptive_batch task belongs to a loop; feed
+        # per-point results back to /adaptive/loops/{loop_id}/report-results so
+        # the search advances without manual intervention (P3-M5 gap closure).
+        self._report_to_adaptive_loop(task, results)
+
         # B7 fix: mark local task file as done to prevent re-execution
         if requests is None:
             self._mark_local_task_done(task)
@@ -288,11 +411,51 @@ class TaskExecutor:
                 metrics[f"{key}_mean"] = round(sum(values) / len(values), 4)
         return metrics
 
+    def _send_heartbeat(self) -> None:
+        """Register this executor with the Web backend (online status)."""
+        if requests is None:
+            return
+        try:
+            status = "running" if self._current_task is not None else "idle"
+            current_task = None
+            progress = None
+            if self._current_task is not None:
+                current_task = self._current_task.get("task_id")
+                total = self._current_task.get("total_points") or 0
+                done = self._current_task.get("completed_points") or 0
+                progress = {
+                    "completed_points": done,
+                    "total_points": total,
+                }
+            requests.post(
+                f"{self.web_base_url}/api/executor/heartbeat",
+                json={
+                    "executor_id": self.executor_id,
+                    "status": status,
+                    "current_task": current_task,
+                    "progress": progress,
+                },
+                timeout=5,
+            )
+        except Exception:
+            # Heartbeat failures are non-fatal
+            pass
+
     def start_polling(self, interval: int = 5) -> threading.Thread:
-        """Start background thread to poll for and execute tasks."""
+        """Start background threads: one polls/executes tasks, one heartbeats.
+
+        Heartbeat runs on its own thread so a long-running Motor-CAD point
+        (~2 min each) never starves the heartbeat - otherwise the backend
+        would mark this executor offline mid-task (observed 2026-09-04).
+        """
         self._running = True
         self._stop_event.clear()
 
+        def heartbeat_loop():
+            while self._running and not self._stop_event.is_set():
+                self._send_heartbeat()
+                self._stop_event.wait(interval)
+
         def poll_loop():
             while self._running and not self._stop_event.is_set():
                 try:
@@ -306,6 +469,8 @@ class TaskExecutor:
                         self.on_error(f"Poll loop error: {str(e)}")
                 self._stop_event.wait(interval)
 
+        hb_thread = threading.Thread(target=heartbeat_loop, daemon=True)
+        hb_thread.start()
         thread = threading.Thread(target=poll_loop, daemon=True)
         thread.start()
         return thread
@@ -317,66 +482,106 @@ class TaskExecutor:
 
 
 class MotorCADTaskExecutor(TaskExecutor):
-    """Task executor that uses RobustMotorCADSolver for real Motor-CAD simulation.
-
-    A3/A4/A5 fixes:
-    - Reuses RobustMotorCADSolver (open_new_instance=True, set_visible,
-      baseline reload per point, popup suppression, write-back verification)
-    - Write-back verification failures propagate (no silent except:pass)
-    - Results extracted via export file parsing (not bogus get_variable names)
-    - Failed points raise exception -> recorded as status=failed (no mock fallback)
+    """Task executor backed by a registered simulation-tool adapter.
+
+    Uses afmcore.adapters.get_adapter(tool) so the executor never
+    hard-codes a specific solver. Default tool "motorcad" wraps
+    RobustMotorCADSolver (open_new_instance, set_visible, baseline
+    reload per point, popup suppression, write-back verification,
+    per-point disk flush).
+
+    Result mapping: adapter returns {metrics, status, error, ...}; the
+    metrics dict is flattened to the point's top level so downstream
+    aggregation (TaskExecutor._compute_metrics) keeps working unchanged.
     """
 
-    def __init__(self, *args, model_path: Optional[str] = None, **kwargs):
-        # Mock fallback is disabled by default for real Motor-CAD executor
+    def __init__(self, *args, model_path: Optional[str] = None,
+                         tool: str = "motorcad",
+                         enable_thermal: bool = False,
+                         ambient_temperature: Optional[float] = None,
+                         **kwargs):
+        # Mock fallback is disabled by default for real solver adapter.
         kwargs.setdefault("enable_mock", False)
         super().__init__(*args, **kwargs)
         self.model_path = model_path
-        self._solver = None
-
-    def _ensure_solver(self):
-        """Lazily create RobustMotorCADSolver instance."""
-        if self._solver is not None:
-            return self._solver
-        from robust_motorcad import RobustMotorCADSolver
+        self.tool = tool
+        # P5-M6: pass through to the adapter so each EM point can also run a
+        # steady-state thermal solve and merge thermal metrics.
+        self.enable_thermal = bool(enable_thermal)
+        # P5-M6 thermal boundary: Ambient_Temperature override (degC).
+        self.ambient_temperature = ambient_temperature
+        self._adapter = None
+
+    def _ensure_adapter(self):
+        """Lazily create the tool adapter via the platform registry."""
+        if self._adapter is not None:
+            return self._adapter
+        _root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+        _src = os.path.join(_root, "src")
+        if _src not in sys.path:
+            sys.path.insert(0, _src)
+        from afmcore.adapters import get_adapter
+        # Dynamically import the adapter module matching self.tool so it
+        # self-registers in ADAPTER_REGISTRY. Unknown tools rely on
+        # pre-registered adapters (caller may have imported them).
+        if self.tool == "motorcad":
+            import afmcore.adapters.motorcad  # noqa: F401
+        elif self.tool == "maxwell":
+            import afmcore.adapters.maxwell  # noqa: F401
+        elif self.tool == "jmag":
+            import afmcore.adapters.jmag  # noqa: F401
         if not self.model_path:
             raise RuntimeError("model_path is required for MotorCADTaskExecutor")
-        # Output directory under task dir
         output_dir = os.path.join(
-            os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
-            "output", f"task_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
+            _root, "output", "task_%s" % datetime.now().strftime("%Y%m%d_%H%M%S")
         )
-        self._solver = RobustMotorCADSolver(
-            model_path=self.model_path,
-            output_dir=output_dir,
+        self._adapter = get_adapter(
+            self.tool, model_path=self.model_path, output_dir=output_dir,
+            enable_thermal=self.enable_thermal,
+            ambient_temperature=self.ambient_temperature,
         )
-        self._solver.connect()
-        return self._solver
+        self._adapter.connect()
+        return self._adapter
 
     def _run_simulation_point(self, params: Dict[str, Any], index: int) -> Dict[str, Any]:
-        """Run a single simulation point via RobustMotorCADSolver.
-
-        A2 fix: No mock fallback on failure. Exception propagates to
-        execute_task which records status=failed.
-        A3 fix: RobustMotorCADSolver handles open_new_instance, set_visible,
-        baseline reload, popup suppression.
-        A4 fix: Write-back verification inside solver raises on mismatch.
-        A5 fix: Results from export file parsing, not get_variable.
+        """Run one point through the adapter and flatten metrics to top level.
+
+        The adapter owns the robust protocol (baseline reload, write-back
+        verification, export parsing). A non-OK point raises so execute_task
+        records status=FAILED (no mock fallback).
+
+        When enable_mock=True the base-class mock implementation is used
+        instead, so no Motor-CAD instance is launched at all (P5-M2).
         """
-        solver = self._ensure_solver()
-        # run_single_point handles baseline reload, write-verify, calculation,
-        # export, parsing, and per-point disk flush.
-        point_result = solver.run_single_point(params, point_index=index)
-        return point_result
+        if self.enable_mock:
+            return super()._run_simulation_point(params, index)
+        adapter = self._ensure_adapter()
+        result = adapter.run_point(
+            self.model_path, params=params,
+            output_dir=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+            tag=str(index),
+        )
+        if result.get("status") != "OK":
+            raise RuntimeError(
+                result.get("error") or ("Simulation failed (adapter status=%s)"
+                % result.get("status"))
+            )
+        metrics = result.get("metrics") or {}
+        point = dict(metrics)
+        point["status"] = "OK"
+        point["metrics"] = metrics
+        point["error"] = result.get("error")
+        point["solve_time_s"] = result.get("solve_time_s")
+        return point
 
     def cleanup(self):
-        """Disconnect solver and release Motor-CAD instance."""
-        if self._solver is not None:
+        """Disconnect the adapter and release the tool instance."""
+        if self._adapter is not None:
             try:
-                self._solver.disconnect()
+                self._adapter.disconnect()
             except Exception:
                 pass
-            self._solver = None
+            self._adapter = None
 
 
 if __name__ == "__main__":

+ 214 - 0
scripts/test_adapters.py

@@ -0,0 +1,214 @@
+"""P5-M5: unit tests for simulation tool adapters (maxwell / jmag mock).
+
+Covers: registry (register/get/listed), maxwell mock full pipeline,
+jmag mock full pipeline, tool differentiation (same params -> different
+metrics), set_parameter read-back verification, boundary (empty params /
+unknown tool / run before connect), anomaly (real-mode RuntimeError),
+and executor dynamic adapter import by tool name.
+
+All source is ASCII only. Run: python scripts/test_adapters.py
+exit 0 = PASS.
+"""
+import os
+import sys
+import unittest
+
+_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+_SRC = os.path.join(_ROOT, "src")
+if _SRC not in sys.path:
+    sys.path.insert(0, _SRC)
+
+from afmcore.adapters import (  # noqa: E402
+    SimulationAdapter,
+    get_adapter,
+    register_adapter,
+    registered_tools,
+)
+import afmcore.adapters.maxwell  # noqa: E402,F401  (registers "maxwell")
+import afmcore.adapters.jmag  # noqa: E402,F401  (registers "jmag")
+
+
+class TestAdapterRegistry(unittest.TestCase):
+    def test_maxwell_registered(self):
+        self.assertIn("maxwell", registered_tools())
+
+    def test_jmag_registered(self):
+        self.assertIn("jmag", registered_tools())
+
+    def test_get_adapter_returns_instance(self):
+        a = get_adapter("maxwell", model_path="/tmp/x.mot", mock=True)
+        self.assertIsInstance(a, SimulationAdapter)
+        self.assertEqual(a.tool_name, "maxwell")
+
+    def test_unknown_tool_raises_keyerror(self):
+        with self.assertRaises(KeyError):
+            get_adapter("nonexistent_tool_xyz")
+
+    def test_register_adapter_rejects_non_string(self):
+        with self.assertRaises(ValueError):
+            register_adapter("", SimulationAdapter)
+
+
+class TestMaxwellMockPipeline(unittest.TestCase):
+    def setUp(self):
+        self.adapter = get_adapter(
+            "maxwell", model_path="/tmp/test.mot",
+            output_dir="output/p5m5_test", mock=True,
+        )
+
+    def test_connect_disconnect(self):
+        self.adapter.connect()
+        self.assertTrue(self.adapter._connected)
+        self.adapter.disconnect()
+        self.assertFalse(self.adapter._connected)
+
+    def test_load_model_clears_params(self):
+        self.adapter._params["old"] = 1.0
+        self.adapter.load_model("/tmp/new.mot")
+        self.assertEqual(self.adapter._loaded_model, "/tmp/new.mot")
+        self.assertEqual(self.adapter._params, {})
+
+    def test_set_parameter_readback(self):
+        self.adapter.connect()
+        self.adapter.set_parameter("airgap_mm", 1.5)
+        self.assertEqual(self.adapter._params["airgap_mm"], 1.5)
+
+    def test_run_simulation_before_connect_raises(self):
+        with self.assertRaises(RuntimeError):
+            self.adapter.run_simulation("electromagnetic")
+
+    def test_run_simulation_unsupported_mode_raises(self):
+        self.adapter.connect()
+        with self.assertRaises(ValueError):
+            self.adapter.run_simulation("structural")
+
+    def test_extract_metrics_deterministic(self):
+        self.adapter.connect()
+        self.adapter.set_parameter("airgap_mm", 1.0)
+        self.adapter.set_parameter("magnet_thickness_mm", 5.0)
+        ext = self.adapter.extract_metrics("output/p5m5_test", "t1")
+        self.assertEqual(ext["status"], "OK")
+        # tavg = 0.50*1.0 + 0.30*5.0 + 0.10 = 2.10
+        self.assertAlmostEqual(ext["metrics"]["tavg_nm"], 2.10, places=4)
+        self.assertAlmostEqual(ext["metrics"]["efficiency_pct"], 85.10, places=2)
+        self.assertIn("winding_temp_c", ext["metrics"])  # thermal domain
+
+    def test_run_point_full_pipeline(self):
+        result = self.adapter.run_point(
+            model_path="/tmp/test.mot",
+            params={"airgap_mm": 1.0, "magnet_thickness_mm": 5.0},
+            output_dir="output/p5m5_test",
+            tag="full",
+        )
+        self.assertEqual(result["status"], "OK")
+        self.assertIn("tavg_nm", result["metrics"])
+        self.assertEqual(result["params"]["airgap_mm"], 1.0)
+
+    def test_run_point_empty_params(self):
+        result = self.adapter.run_point(
+            model_path="/tmp/test.mot", params={},
+            output_dir="output/p5m5_test", tag="empty",
+        )
+        self.assertEqual(result["status"], "OK")
+        # all params default to 0 -> tavg = 0.10
+        self.assertAlmostEqual(result["metrics"]["tavg_nm"], 0.10, places=4)
+
+    def test_real_mode_raises(self):
+        a = get_adapter("maxwell", model_path="/tmp/x.mot", mock=False)
+        with self.assertRaises(RuntimeError):
+            a.connect()
+
+
+class TestJMAGMockPipeline(unittest.TestCase):
+    def setUp(self):
+        self.adapter = get_adapter(
+            "jmag", model_path="/tmp/test.jmag",
+            output_dir="output/p5m5_test", mock=True,
+        )
+
+    def test_tool_label_and_domains(self):
+        self.assertEqual(self.adapter.tool_label, "JMAG Designer (mock)")
+        self.assertEqual(self.adapter.capability_domains, ("electromagnetic",))
+
+    def test_extract_metrics_deterministic(self):
+        self.adapter.connect()
+        self.adapter.set_parameter("airgap_mm", 1.0)
+        self.adapter.set_parameter("magnet_thickness_mm", 5.0)
+        ext = self.adapter.extract_metrics("output/p5m5_test", "t1")
+        # tavg = 0.45*1.0 + 0.32*5.0 + 0.12 = 2.17
+        self.assertAlmostEqual(ext["metrics"]["tavg_nm"], 2.17, places=4)
+        self.assertNotIn("winding_temp_c", ext["metrics"])  # jmag has no thermal domain
+
+    def test_run_point_full_pipeline(self):
+        result = self.adapter.run_point(
+            model_path="/tmp/test.jmag",
+            params={"airgap_mm": 2.0, "magnet_thickness_mm": 4.0},
+            output_dir="output/p5m5_test", tag="full",
+        )
+        self.assertEqual(result["status"], "OK")
+        # tavg = 0.45*2.0 + 0.32*4.0 + 0.12 = 0.90+1.28+0.12 = 2.30
+        self.assertAlmostEqual(result["metrics"]["tavg_nm"], 2.30, places=4)
+
+    def test_unsupported_mode_raises(self):
+        self.adapter.connect()
+        with self.assertRaises(ValueError):
+            self.adapter.run_simulation("thermal")
+
+
+class TestToolDifferentiation(unittest.TestCase):
+    """Same input params must produce different metrics for maxwell vs jmag."""
+
+    def test_same_params_different_tavg(self):
+        params = {"airgap_mm": 1.0, "magnet_thickness_mm": 5.0}
+        mx = get_adapter("maxwell", mock=True)
+        jm = get_adapter("jmag", mock=True)
+        r_mx = mx.run_point("/tmp/x.mot", params, "output/p5m5_test", "diff")
+        r_jm = jm.run_point("/tmp/x.jmag", params, "output/p5m5_test", "diff")
+        self.assertNotEqual(r_mx["metrics"]["tavg_nm"], r_jm["metrics"]["tavg_nm"])
+        self.assertNotEqual(r_mx["metrics"]["efficiency_pct"], r_jm["metrics"]["efficiency_pct"])
+
+
+class TestExecutorDynamicImport(unittest.TestCase):
+    """MotorCADTaskExecutor must dynamically import the adapter matching tool."""
+
+    def test_executor_tool_maxwell_creates_maxwell_adapter(self):
+        sys.path.insert(0, _ROOT)
+        from scripts.task_executor import MotorCADTaskExecutor
+        ex = MotorCADTaskExecutor(
+            web_base_url="http://127.0.0.1:9",
+            model_path="/tmp/test.mot",
+            tool="maxwell",
+            enable_mock=False,
+        )
+        adapter = ex._ensure_adapter()
+        self.assertEqual(adapter.tool_name, "maxwell")
+        self.assertIn("maxwell", registered_tools())
+
+    def test_executor_tool_jmag_creates_jmag_adapter(self):
+        sys.path.insert(0, _ROOT)
+        from scripts.task_executor import MotorCADTaskExecutor
+        ex = MotorCADTaskExecutor(
+            web_base_url="http://127.0.0.1:9",
+            model_path="/tmp/test.jmag",
+            tool="jmag",
+            enable_mock=False,
+        )
+        adapter = ex._ensure_adapter()
+        self.assertEqual(adapter.tool_name, "jmag")
+        self.assertIn("jmag", registered_tools())
+
+    def test_executor_tool_unknown_raises_keyerror(self):
+        sys.path.insert(0, _ROOT)
+        from scripts.task_executor import MotorCADTaskExecutor
+        ex = MotorCADTaskExecutor(
+            web_base_url="http://127.0.0.1:9",
+            model_path="/tmp/test.mot",
+            tool="nonexistent_tool_xyz",
+            enable_mock=False,
+        )
+        with self.assertRaises(KeyError):
+            ex._ensure_adapter()
+
+
+if __name__ == "__main__":
+    unittest.main(verbosity=2)

+ 222 - 0
scripts/test_executor_config.py

@@ -0,0 +1,222 @@
+"""Unit tests for scripts/executor_config.py (P5-M2).
+
+Run: python scripts/test_executor_config.py
+Covers happy path / boundary / abnormal / empty-value cases for the
+executor config loader and validator. exit 0 == PASS.
+
+NOTE: All strings in this file are ASCII only.
+"""
+import json
+import os
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+_SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
+if _SCRIPTS_DIR not in sys.path:
+    sys.path.insert(0, _SCRIPTS_DIR)
+
+import executor_config  # noqa: E402
+
+
+class ExecutorConfigTest(unittest.TestCase):
+    """Test config load priority, resolution and validation."""
+
+    def setUp(self):
+        self._keep = dict(os.environ)
+        for key in ("EXECUTOR_CONFIG", "WEB_BASE_URL", "MOTORCAD_MODEL",
+                    "EXECUTOR_INSTANCES", "EXECUTOR_POLL_INTERVAL",
+                    "EXECUTOR_LOG_DIR", "EXECUTOR_LOG_LEVEL",
+                    "EXECUTOR_TOOL", "EXECUTOR_MOCK"):
+            os.environ.pop(key, None)
+        self._tmp = tempfile.mkdtemp(prefix="exec_cfg_test_")
+
+    def tearDown(self):
+        os.environ.clear()
+        os.environ.update(self._keep)
+        import shutil
+        shutil.rmtree(self._tmp, ignore_errors=True)
+
+    def _isolate(self):
+        """Point _base_dir/repo_root at an empty temp dir so the real
+        repo-root sidecar config cannot influence tests."""
+        return (
+            mock.patch.object(executor_config, "_base_dir",
+                              return_value=self._tmp),
+            mock.patch.object(executor_config, "repo_root",
+                              return_value=self._tmp),
+        )
+
+    def _write(self, name, obj):
+        path = os.path.join(self._tmp, name)
+        with open(path, "w", encoding="utf-8") as fh:
+            json.dump(obj, fh)
+        return path
+
+    # ---------- happy path ----------
+    def test_defaults_when_no_file_and_no_env(self):
+        with self._isolate()[0], self._isolate()[1]:
+            cfg = executor_config.load_config()
+        self.assertEqual(cfg["web_base_url"], "http://127.0.0.1:8000")
+        self.assertEqual(cfg["instances"], 1)
+        self.assertEqual(cfg["poll_interval"], 5)
+        self.assertEqual(cfg["log_level"], "INFO")
+        self.assertEqual(cfg["tool"], "motorcad")
+        self.assertIs(cfg["enable_mock"], False)
+        self.assertEqual(cfg["config_source"], "defaults")
+        self.assertTrue(os.path.isabs(cfg["model_path"]))
+        self.assertTrue(cfg["model_path"].startswith(self._tmp))
+        self.assertTrue(cfg["log_dir"].startswith(self._tmp))
+
+    def test_file_load_merges_and_resolves(self):
+        path = self._write("executor_config.json", {
+            "web_base_url": "http://192.168.1.10:9000",
+            "model_path": "models/custom.mot",
+            "instances": 3,
+            "poll_interval": 2,
+            "log_dir": "logs/exec",
+            "log_level": "DEBUG",
+            "tool": "motorcad",
+            "enable_mock": True,
+        })
+        cfg = executor_config.load_config(cli_path=path)
+        self.assertEqual(cfg["web_base_url"], "http://192.168.1.10:9000")
+        self.assertEqual(cfg["instances"], 3)
+        self.assertEqual(cfg["poll_interval"], 2)
+        self.assertEqual(cfg["log_level"], "DEBUG")
+        self.assertIs(cfg["enable_mock"], True)
+        self.assertEqual(cfg["config_source"], path)
+        root = executor_config.repo_root()
+        expected_model = os.path.normpath(os.path.join("models", "custom.mot"))
+        self.assertTrue(cfg["model_path"].endswith(expected_model))
+        self.assertTrue(cfg["model_path"].startswith(root))
+        self.assertTrue(cfg["log_dir"].startswith(root))
+
+    def test_absolute_paths_kept(self):
+        path = self._write("executor_config.json", {
+            "model_path": "D:/models/abs.mot",
+            "log_dir": "D:/logs",
+        })
+        cfg = executor_config.load_config(cli_path=path)
+        self.assertEqual(cfg["model_path"], os.path.normpath("D:/models/abs.mot"))
+        self.assertEqual(cfg["log_dir"], os.path.normpath("D:/logs"))
+
+    # ---------- env overrides ----------
+    def test_env_overrides_file_and_cli(self):
+        path = self._write("executor_config.json", {
+            "web_base_url": "http://file:8000",
+            "instances": 2,
+            "poll_interval": 7,
+        })
+        os.environ["WEB_BASE_URL"] = "http://env:9999"
+        os.environ["MOTORCAD_MODEL"] = "models/env.mot"
+        os.environ["EXECUTOR_INSTANCES"] = "4"
+        os.environ["EXECUTOR_MOCK"] = "true"
+        cfg = executor_config.load_config(cli_path=path)
+        self.assertEqual(cfg["web_base_url"], "http://env:9999")
+        self.assertEqual(cfg["instances"], 4)
+        self.assertEqual(cfg["poll_interval"], 7)  # file value kept
+        self.assertIs(cfg["enable_mock"], True)
+
+    def test_env_numeric_invalid(self):
+        os.environ["EXECUTOR_INSTANCES"] = "abc"
+        with self.assertRaises(ValueError):
+            executor_config.load_config()
+
+    # ---------- find_config_path priority ----------
+    def test_find_config_path_cli_first(self):
+        a = self._write("a.json", {"instances": 1})
+        b = self._write("b.json", {"instances": 1})
+        os.environ["EXECUTOR_CONFIG"] = b
+        self.assertEqual(executor_config.find_config_path(cli_path=a), a)
+
+    def test_find_config_path_env_fallback(self):
+        b = self._write("b.json", {"instances": 1})
+        os.environ["EXECUTOR_CONFIG"] = b
+        self.assertEqual(executor_config.find_config_path(), b)
+
+    def test_find_config_path_none(self):
+        with self._isolate()[0], self._isolate()[1]:
+            self.assertIsNone(
+                executor_config.find_config_path("no/such/file.json"))
+
+    # ---------- boundary ----------
+    def test_minimal_instances_ok(self):
+        cfg = executor_config.load_config()
+        self.assertGreaterEqual(cfg["instances"], 1)
+
+    def test_float_poll_interval_ok(self):
+        path = self._write("executor_config.json", {"poll_interval": 0.5})
+        cfg = executor_config.load_config(cli_path=path)
+        self.assertEqual(cfg["poll_interval"], 0.5)
+
+    def test_empty_model_path_allowed(self):
+        path = self._write("executor_config.json", {"model_path": ""})
+        cfg = executor_config.load_config(cli_path=path)
+        self.assertIsNone(cfg["model_path"])
+
+    # ---------- abnormal ----------
+    def test_invalid_json_raises(self):
+        path = os.path.join(self._tmp, "bad.json")
+        with open(path, "w", encoding="utf-8") as fh:
+            fh.write("{ not json !!!")
+        with self.assertRaises(ValueError):
+            executor_config.load_config(cli_path=path)
+
+    def test_non_dict_json_raises(self):
+        path = self._write("arr.json", [1, 2, 3])
+        with self.assertRaises(ValueError):
+            executor_config.load_config(cli_path=path)
+
+    def test_validate_bad_url(self):
+        with self.assertRaises(ValueError):
+            executor_config.validate_config({
+                "web_base_url": "ftp://x", "instances": 1,
+                "poll_interval": 1, "log_level": "INFO",
+                "tool": "motorcad", "enable_mock": False})
+
+    def test_validate_zero_instances(self):
+        with self.assertRaises(ValueError):
+            executor_config.validate_config({
+                "web_base_url": "http://x", "instances": 0,
+                "poll_interval": 1, "log_level": "INFO",
+                "tool": "motorcad", "enable_mock": False})
+
+    def test_validate_bad_level(self):
+        with self.assertRaises(ValueError):
+            executor_config.validate_config({
+                "web_base_url": "http://x", "instances": 1,
+                "poll_interval": 1, "log_level": "VERBOSE",
+                "tool": "motorcad", "enable_mock": False})
+
+    def test_validate_bad_mock_type(self):
+        with self.assertRaises(ValueError):
+            executor_config.validate_config({
+                "web_base_url": "http://x", "instances": 1,
+                "poll_interval": 1, "log_level": "INFO",
+                "tool": "motorcad", "enable_mock": "yes"})
+
+    # ---------- empty values ----------
+    def test_empty_file_object_uses_defaults(self):
+        path = self._write("empty.json", {})
+        cfg = executor_config.load_config(cli_path=path)
+        self.assertEqual(cfg["web_base_url"], "http://127.0.0.1:8000")
+        self.assertEqual(cfg["instances"], 1)
+
+    def test_null_file_field_ignored(self):
+        path = self._write("null.json", {"web_base_url": None, "instances": 2})
+        cfg = executor_config.load_config(cli_path=path)
+        self.assertEqual(cfg["web_base_url"], "http://127.0.0.1:8000")
+        self.assertEqual(cfg["instances"], 2)
+
+    def test_empty_env_ignored(self):
+        os.environ["WEB_BASE_URL"] = ""
+        os.environ["EXECUTOR_INSTANCES"] = ""
+        cfg = executor_config.load_config()
+        self.assertEqual(cfg["web_base_url"], "http://127.0.0.1:8000")
+        self.assertEqual(cfg["instances"], 1)
+
+
+if __name__ == "__main__":
+    unittest.main(verbosity=2)

+ 80 - 0
scripts/test_executor_m3.py

@@ -0,0 +1,80 @@
+# One-off: P3-M3 regression - executor point_id passthrough + claim check.
+import json
+import os
+import sys
+import tempfile
+
+_SCRIPTS = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, _SCRIPTS)
+
+import task_executor as te  # noqa: E402
+
+# force local-file mode (no web calls)
+te.requests = None
+
+tmp = tempfile.mkdtemp()
+captured = {}
+
+
+def make_task(tid, params):
+    path = os.path.join(tmp, tid + "_task.json")
+    with open(path, "w", encoding="utf-8") as f:
+        json.dump({"task_id": tid, "task_name": "m3", "status": "pending",
+                   "parameters": params}, f)
+    return {"task_id": tid, "_local_file": path, "status": "pending",
+            "parameters": params}
+
+
+# ---------------- 1) point_id passthrough (claim succeeds) ----------------
+ex = te.TaskExecutor(task_dir=tmp, enable_mock=True)
+ex.dispatch_task = lambda tid: True  # claim ok
+ex.on_complete = lambda tid, res, met: captured.setdefault(tid, res)
+task = make_task("t1", [{"airgap_mm": 1.0, "current_a": 10.0, "point_id": 7},
+                        {"airgap_mm": 2.0, "current_a": 5.0, "point_id": 8}])
+ex.execute_task(task)
+res1 = captured.get("t1")
+assert res1 and len(res1) == 2, res1
+for r in res1:
+    assert r["status"] == "OK", r
+    assert r["point_id"] in (7, 8), r
+    assert r["params"].get("point_id") == r["point_id"], r
+print("[1] point_id passthrough OK:", [(r["point_id"], r["status"]) for r in res1])
+
+# ---------------- 2) FAILED point keeps point_id ----------------
+captured2 = {}
+
+
+def _boom(params, idx):
+    raise RuntimeError("simulation failed")
+
+
+ex2 = te.TaskExecutor(task_dir=tmp, enable_mock=True)
+ex2.dispatch_task = lambda tid: True
+ex2._run_simulation_point = _boom
+ex2.on_complete = lambda tid, res, met: captured2.setdefault(tid, res)
+task2 = make_task("t2", [{"airgap_mm": 1.0, "point_id": 42}])
+ex2.execute_task(task2)
+r2 = captured2["t2"][0]
+assert r2["status"] == "FAILED", r2
+assert r2["point_id"] == 42, r2
+print("[2] FAILED point keeps point_id OK:", r2["point_id"], r2["status"])
+
+# ---------------- 3) claim rejected -> task skipped ----------------
+captured3 = {}
+ex3 = te.TaskExecutor(task_dir=tmp, enable_mock=True)
+ex3.dispatch_task = lambda tid: False  # already claimed by another instance
+ex3.on_complete = lambda tid, res, met: captured3.setdefault(tid, res)
+task3 = make_task("t3", [{"airgap_mm": 1.0, "point_id": 99}])
+ex3.execute_task(task3)
+assert "t3" not in captured3, "claimed task must be skipped"
+print("[3] claim-rejected task skipped OK")
+
+# ---------------- 4) unique executor ids ----------------
+a = te.TaskExecutor(task_dir=tmp)
+b = te.TaskExecutor(task_dir=tmp)
+c = te.TaskExecutor(task_dir=tmp, executor_id="motorcad-executor-9")
+assert a.executor_id != b.executor_id, (a.executor_id, b.executor_id)
+assert c.executor_id == "motorcad-executor-9"
+print("[4] unique executor ids OK:", a.executor_id, "|", b.executor_id, "|", c.executor_id)
+
+print("\nALL P3-M3 EXECUTOR TESTS PASSED")

+ 122 - 0
scripts/test_executor_p5m2.py

@@ -0,0 +1,122 @@
+"""P5-M2 regression: MotorCADTaskExecutor mock-vs-real branch.
+
+Run: python scripts/test_executor_p5m2.py
+Verifies that enable_mock=True routes to the base-class mock solver (no
+Motor-CAD, no model_path required) and that enable_mock=False keeps the
+real-adapter contract (model_path required). exit 0 == PASS.
+
+NOTE: All strings in this file are ASCII only.
+"""
+import json
+import os
+import sys
+import tempfile
+import unittest
+
+_SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
+if _SCRIPTS_DIR not in sys.path:
+    sys.path.insert(0, _SCRIPTS_DIR)
+
+import task_executor as te  # noqa: E402
+
+# local-file mode: no real web calls during the test
+te.requests = None
+
+
+def _make_local_task(tmp, tid, params):
+    path = os.path.join(tmp, tid + "_task.json")
+    with open(path, "w", encoding="utf-8") as fh:
+        json.dump({"task_id": tid, "task_name": "p5m2",
+                   "parameters": params}, fh)
+    return {"task_id": tid, "_local_file": path, "parameters": params}
+
+
+class MotorCADExecutorMockBranchTest(unittest.TestCase):
+    """Exercise the enable_mock branch added in P5-M2."""
+
+    def setUp(self):
+        self.tmp = tempfile.mkdtemp(prefix="p5m2_exec_")
+
+    def tearDown(self):
+        import shutil
+        shutil.rmtree(self.tmp, ignore_errors=True)
+
+    def _run(self, executor, tid, params):
+        executor.dispatch_task = lambda t: True
+        captured = {}
+        executor.on_complete = lambda t, res, met: captured.setdefault(t, res)
+        executor.execute_task(_make_local_task(self.tmp, tid, params))
+        return captured.get(tid)
+
+    # ---------- happy path ----------
+    def test_mock_point_source_and_no_model_required(self):
+        ex = te.MotorCADTaskExecutor(task_dir=self.tmp, enable_mock=True,
+                                     model_path=None)
+        res = self._run(ex, "m1", [{"airgap_mm": 1.0, "point_id": 1}])
+        self.assertEqual(len(res), 1)
+        self.assertEqual(res[0]["status"], "OK")
+        self.assertEqual(res[0]["source"], "mock")
+        self.assertIn("tavg_nm", res[0])
+
+    def test_mock_multi_point(self):
+        ex = te.MotorCADTaskExecutor(task_dir=self.tmp, enable_mock=True,
+                                     model_path=None)
+        res = self._run(ex, "m2", [
+            {"airgap_mm": 0.8, "point_id": 1},
+            {"airgap_mm": 1.0, "point_id": 2},
+            {"airgap_mm": 1.2, "point_id": 3},
+        ])
+        self.assertEqual(len(res), 3)
+        for r in res:
+            self.assertEqual(r["status"], "OK")
+            self.assertEqual(r["source"], "mock")
+
+    # ---------- boundary ----------
+    def test_mock_with_illegal_model_path_still_mocks(self):
+        # mock must win even when model_path points at a nonexistent file,
+        # proving no Motor-CAD instance / adapter is launched.
+        ex = te.MotorCADTaskExecutor(task_dir=self.tmp, enable_mock=True,
+                                     model_path="Z:/nonexistent/model.mot")
+        res = self._run(ex, "m3", [{"airgap_mm": 1.0, "point_id": 1}])
+        self.assertEqual(res[0]["source"], "mock")
+
+    def test_default_mock_off(self):
+        # P4-M3 default: real solver adapter, so enable_mock defaults False
+        ex = te.MotorCADTaskExecutor(task_dir=self.tmp, model_path=None)
+        self.assertIs(ex.enable_mock, False)
+
+    # ---------- abnormal ----------
+    def test_real_mode_requires_model_path(self):
+        ex = te.MotorCADTaskExecutor(task_dir=self.tmp, enable_mock=False,
+                                     model_path=None)
+        with self.assertRaises(RuntimeError):
+            ex._run_simulation_point({"airgap_mm": 1.0}, 0)
+
+    # ---------- metadata key exclusion ----------
+    def test_point_id_excluded_from_motorcad_params(self):
+        # Regression (P5-M2 real E2E): point_id is a passthrough
+        # metadata key and must never be sent to Motor-CAD set_variable.
+        src_path = os.path.join(_SCRIPTS_DIR, "robust_motorcad.py")
+        with open(src_path, "r", encoding="utf-8") as fh:
+            src = fh.read()
+        self.assertIn(
+            'if var in ("point_index", "point_label", "point_id"):',
+            src)
+
+    # ---------- business alias ----------
+    def test_business_alias_airgap_resolves(self):
+        from robust_motorcad import resolve_variable_name
+        self.assertEqual(resolve_variable_name("airgap_mm"), "Airgap")
+        # unknown canonical names pass through untouched
+        self.assertEqual(resolve_variable_name("SomeUnknown"), "SomeUnknown")
+
+    # ---------- empty value ----------
+    def test_mock_empty_model_path_string(self):
+        ex = te.MotorCADTaskExecutor(task_dir=self.tmp, enable_mock=True,
+                                     model_path="")
+        res = self._run(ex, "m4", [{"airgap_mm": 1.0, "point_id": 1}])
+        self.assertEqual(res[0]["source"], "mock")
+
+
+if __name__ == "__main__":
+    unittest.main(verbosity=2)

+ 241 - 0
scripts/test_metrics_extension.py

@@ -0,0 +1,241 @@
+"""P5-M6: unit tests for metrics extension (thermal + structural) and
+domain-grouped report generation.
+
+Covers:
+- New thermal/structural metrics present in METRIC_DEFINITIONS with domain
+- parse_export + extract_all_metrics picks up new metrics automatically
+- check_required_metrics unaffected (new metrics required=False)
+- report_generator._group_metrics_by_domain correct grouping
+- report_generator JSON fallback includes metrics_by_domain
+- Boundary: empty metrics, unknown keys, mixed domains
+- robust_motorcad enable_thermal parameter exists (signature check)
+
+All source is ASCII only. Run: python scripts/test_metrics_extension.py
+exit 0 = PASS.
+"""
+import os
+import sys
+import tempfile
+import unittest
+
+_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+_SRC = os.path.join(_ROOT, "src")
+if _SRC not in sys.path:
+    sys.path.insert(0, _SRC)
+
+from afmcore.metrics import (  # noqa: E402
+    METRIC_DEFINITIONS,
+    METRIC_KEYS,
+    REQUIRED_METRICS,
+    check_required_metrics,
+    extract_all_metrics,
+    parse_export,
+)
+
+# report_generator lives under web/backend; add its dir to path
+_REPORT_DIR = os.path.join(_ROOT, "web", "backend", "app", "services")
+if _REPORT_DIR not in sys.path:
+    sys.path.insert(0, _REPORT_DIR)
+from report_generator import (  # noqa: E402
+    ReportGenerator,
+    _DOMAIN_ORDER,
+    _group_metrics_by_domain,
+    _metric_display,
+)
+
+
+THERMAL_KEYS = [
+    "winding_hotspot_temp_c", "magnet_temp_c", "stator_temp_c",
+    "bearing_temp_c", "temp_rise_c", "thermal_resistance_k_w",
+]
+STRUCTURAL_KEYS = [
+    "axial_force_n", "radial_force_n", "max_stress_mpa", "deformation_mm",
+]
+
+
+class TestMetricDefinitions(unittest.TestCase):
+    def test_thermal_metrics_present(self):
+        keys = {m["key"] for m in METRIC_DEFINITIONS}
+        for k in THERMAL_KEYS:
+            self.assertIn(k, keys, "missing thermal metric: %s" % k)
+
+    def test_structural_metrics_present(self):
+        keys = {m["key"] for m in METRIC_DEFINITIONS}
+        for k in STRUCTURAL_KEYS:
+            self.assertIn(k, keys, "missing structural metric: %s" % k)
+
+    def test_thermal_metrics_have_domain(self):
+        for m in METRIC_DEFINITIONS:
+            if m["key"] in THERMAL_KEYS:
+                self.assertEqual(m.get("domain"), "thermal",
+                                 "%s should have domain=thermal" % m["key"])
+
+    def test_structural_metrics_have_domain(self):
+        for m in METRIC_DEFINITIONS:
+            if m["key"] in STRUCTURAL_KEYS:
+                self.assertEqual(m.get("domain"), "structural",
+                                 "%s should have domain=structural" % m["key"])
+
+    def test_new_metrics_not_required(self):
+        for k in THERMAL_KEYS + STRUCTURAL_KEYS:
+            self.assertNotIn(k, REQUIRED_METRICS,
+                             "%s should be required=False" % k)
+
+    def test_total_metric_count(self):
+        # original 25 + 6 thermal + 4 structural = 35
+        self.assertEqual(len(METRIC_DEFINITIONS), 35)
+
+
+class TestMetricExtraction(unittest.TestCase):
+    """Construct a mock Motor-CAD export CSV with thermal/structural fields
+    and verify extract_all_metrics picks them up automatically."""
+
+    def _make_export(self, fields):
+        """Write a mock semicolon-CSV export and return its path."""
+        lines = ["E-Magnetics"]
+        for field, value in fields:
+            lines.append("%s;%s" % (field, value))
+        fd, path = tempfile.mkstemp(suffix=".csv")
+        with os.fdopen(fd, "w", encoding="utf-8") as f:
+            f.write("\n".join(lines))
+        self.addCleanup(os.unlink, path)
+        return path
+
+    def test_extract_thermal_metrics(self):
+        path = self._make_export([
+            ("Average torque (virtual work)", "1.5"),
+            ("Magnet Temperature", "62.5"),
+            ("Winding Hotspot Temperature", "88.3"),
+            ("Stator Temperature", "70.1"),
+            ("Bearing Temperature", "55.0"),
+            ("Temperature Rise", "48.2"),
+            ("Thermal Resistance", "0.85"),
+        ])
+        parsed = parse_export(path)
+        metrics = extract_all_metrics(parsed)
+        self.assertAlmostEqual(metrics["magnet_temp_c"], 62.5, places=2)
+        self.assertAlmostEqual(metrics["winding_hotspot_temp_c"], 88.3, places=2)
+        self.assertAlmostEqual(metrics["stator_temp_c"], 70.1, places=2)
+        self.assertAlmostEqual(metrics["bearing_temp_c"], 55.0, places=2)
+        self.assertAlmostEqual(metrics["temp_rise_c"], 48.2, places=2)
+        self.assertAlmostEqual(metrics["thermal_resistance_k_w"], 0.85, places=2)
+
+    def test_extract_structural_metrics(self):
+        path = self._make_export([
+            ("Axial Force", "125.5"),
+            ("Radial Force", "45.2"),
+            ("Maximum Stress", "180.3"),
+            ("Max Deformation", "0.12"),
+        ])
+        parsed = parse_export(path)
+        metrics = extract_all_metrics(parsed)
+        self.assertAlmostEqual(metrics["axial_force_n"], 125.5, places=2)
+        self.assertAlmostEqual(metrics["radial_force_n"], 45.2, places=2)
+        self.assertAlmostEqual(metrics["max_stress_mpa"], 180.3, places=2)
+        self.assertAlmostEqual(metrics["deformation_mm"], 0.12, places=2)
+
+    def test_chinese_alias_thermal(self):
+        path = self._make_export([
+            ("\u6c38\u78c1\u4f53\u6e29\u5ea6", "70.0"),  # magnet temp
+            ("\u8f74\u5411\u529b", "200.0"),  # axial force
+        ])
+        parsed = parse_export(path)
+        metrics = extract_all_metrics(parsed)
+        self.assertAlmostEqual(metrics["magnet_temp_c"], 70.0, places=2)
+        self.assertAlmostEqual(metrics["axial_force_n"], 200.0, places=2)
+
+    def test_required_check_unaffected(self):
+        # Only tavg/ripple/efficiency/total_losses are required;
+        # missing thermal/structural metrics must not fail the check.
+        ok, missing = check_required_metrics({"tavg_nm": 1.0, "ripple_pct": 2.0,
+                                                "efficiency_pct": 90.0, "total_losses_w": 10.0})
+        self.assertTrue(ok)
+        self.assertEqual(missing, [])
+
+    def test_required_check_fails_on_missing_core(self):
+        ok, missing = check_required_metrics({"tavg_nm": 1.0})
+        self.assertFalse(ok)
+        self.assertIn("ripple_pct", missing)
+
+
+class TestDomainGrouping(unittest.TestCase):
+    def test_group_by_domain(self):
+        metrics = {
+            "tavg_nm": 1.5, "ripple_pct": 5.0,  # electromagnetic (default)
+            "magnet_temp_c": 60.0, "temp_rise_c": 40.0,  # thermal
+            "axial_force_n": 100.0, "max_stress_mpa": 150.0,  # structural
+        }
+        grouped = _group_metrics_by_domain(metrics)
+        self.assertIn("electromagnetic", grouped)
+        self.assertIn("thermal", grouped)
+        self.assertIn("structural", grouped)
+        self.assertEqual(set(grouped["thermal"].keys()), {"magnet_temp_c", "temp_rise_c"})
+        self.assertEqual(set(grouped["structural"].keys()), {"axial_force_n", "max_stress_mpa"})
+
+    def test_empty_metrics(self):
+        self.assertEqual(_group_metrics_by_domain({}), {})
+
+    def test_unknown_key_defaults_electromagnetic(self):
+        grouped = _group_metrics_by_domain({"unknown_metric_xyz": 42.0})
+        self.assertIn("electromagnetic", grouped)
+        self.assertEqual(grouped["electromagnetic"]["unknown_metric_xyz"], 42.0)
+
+    def test_domain_order(self):
+        self.assertEqual(_DOMAIN_ORDER, ["electromagnetic", "thermal", "structural"])
+
+    def test_metric_display(self):
+        label, value = _metric_display("tavg_nm", 1.5)
+        self.assertIn("Average Torque", label)
+        self.assertEqual(value, "1.5")
+
+    def test_metric_display_float_format(self):
+        _, value = _metric_display("efficiency_pct", 92.3456789)
+        self.assertEqual(value, "92.35")  # %.4g
+
+
+class TestReportJsonFallback(unittest.TestCase):
+    def test_json_report_includes_metrics_by_domain(self):
+        rg = ReportGenerator(output_dir=tempfile.mkdtemp())
+        task_data = {
+            "task_id": "test-001",
+            "task_name": "Test Task",
+            "status": "completed",
+            "result_metrics": {
+                "tavg_nm": 1.5,
+                "magnet_temp_c": 60.0,
+                "axial_force_n": 100.0,
+            },
+        }
+        path = rg._generate_json_report(task_data, None, None)
+        self.addCleanup(os.unlink, path)
+        import json
+        with open(path, "r", encoding="utf-8") as f:
+            report = json.load(f)
+        self.assertIn("metrics_by_domain", report)
+        self.assertIn("thermal", report["metrics_by_domain"])
+        self.assertIn("structural", report["metrics_by_domain"])
+        self.assertEqual(report["metrics_by_domain"]["thermal"]["magnet_temp_c"], 60.0)
+
+
+class TestRobustMotorcadThermalParam(unittest.TestCase):
+    """Verify enable_thermal parameter exists in RobustMotorCADSolver."""
+
+    def test_init_has_enable_thermal(self):
+        sys.path.insert(0, _ROOT)
+        from scripts.robust_motorcad import RobustMotorCADSolver
+        import inspect
+        sig = inspect.signature(RobustMotorCADSolver.__init__)
+        self.assertIn("enable_thermal", sig.parameters)
+        self.assertFalse(sig.parameters["enable_thermal"].default)
+
+    def test_run_single_point_has_enable_thermal(self):
+        sys.path.insert(0, _ROOT)
+        from scripts.robust_motorcad import RobustMotorCADSolver
+        import inspect
+        sig = inspect.signature(RobustMotorCADSolver.run_single_point)
+        self.assertIn("enable_thermal", sig.parameters)
+        self.assertIsNone(sig.parameters["enable_thermal"].default)
+
+
+if __name__ == "__main__":
+    unittest.main(verbosity=2)

+ 159 - 0
scripts/test_p3_adaptive_execution.py

@@ -0,0 +1,159 @@
+"""P3-M6 regression: web AdaptiveLoop closed loop + executor bridge.
+
+Validates the complete adaptive closed loop on the web side (AdaptiveLoop),
+including the new submit_batch_to_executor bridge that wraps the current
+pending batch into an adaptive_batch task for the local executor:
+
+    fake plan -> initialize_search (initial batch)
+    -> submit_batch_to_executor (create task)
+    -> fake executor runs the task and reports results
+    -> report_results feeds the search back
+    -> get_next_batch -> ... -> until budget exhausted / converged
+
+Run:  python scripts/test_p3_adaptive_execution.py   (exit 0 = PASS)
+
+Uses an isolated temp SQLite DB and KIMI_API_KEY="" so no AI backend is
+contacted. No real Motor-CAD is involved.
+"""
+import json
+import os
+import sys
+import tempfile
+
+_TMP = os.path.join(tempfile.mkdtemp(), "test_afm.db")
+os.environ["AFM_DB_PATH"] = _TMP
+os.environ["KIMI_API_KEY"] = ""
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "web", "backend"))
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from app.database import init_db  # noqa: E402
+
+init_db()
+
+from app.services.adaptive_loop import create_loop  # noqa: E402
+from app.services.task_manager import get_task_manager  # noqa: E402
+
+tm = get_task_manager()
+
+# ------------------------------------------------------------------ 1. create
+loop = create_loop(
+    user_requirement="maximize average torque within feasibility constraints",
+    total_budget=8,
+    batch_size=4,
+)
+# Inject a fake plan to bypass AI plan generation (parameters are the L0-known
+# names so the feasibility pre-screening matches).
+loop.plan = {
+    "plan_name": "p3-fake",
+    "topology": "SSSR",
+    "scan_variables": [
+        {"name": "airgap_mm", "min_value": 0.8, "max_value": 2.0, "step": 0.1, "unit": "mm"},
+        {"name": "current_a", "min_value": 5.0, "max_value": 20.0, "step": 0.5, "unit": "A"},
+    ],
+    "search_strategy": {"max_solver_calls": 8, "batch_size": 4, "initial_samples": 4},
+    "acceptance_criteria": {
+        "objective_metric": "tavg_nm",
+        "objective_direction": "maximize",
+        "hard_constraints": [],
+    },
+}
+
+# ---------------------------------------------------------- 2. init search
+res = loop.initialize_search()
+assert res["phase"] == "search_initialized", res
+init_points = res["initial_batch"]
+assert len(init_points) > 0, res
+init_ids = sorted(p["id"] for p in init_points)
+print("[1] initialize_search OK: initial_batch=%d ids=%s" % (len(init_points), init_ids))
+
+# --------------------------------------------- 3. submit-batch (new bridge)
+sub = loop.submit_batch_to_executor()
+tid = sub["task_id"]
+assert tid, sub
+assert sub["n_points"] == len(init_points), sub
+assert loop.phase.value == "simulation_running", loop.phase
+task = tm.get_task(tid)
+assert task is not None, tid
+assert task["task_type"] == "adaptive_batch", task
+assert task["loop_id"] == loop.loop_id, task
+assert sorted(task["point_ids"]) == init_ids, (task["point_ids"], init_ids)
+print("[2] submit_batch_to_executor OK: task=%s points=%d type=%s"
+      % (tid, sub["n_points"], task["task_type"]))
+
+
+def run_batch(tid):
+    """Fake local executor: read the task parameters and fabricate metrics."""
+    task = tm.get_task(tid)
+    with open(task["task_file"], "r", encoding="utf-8") as f:
+        payload = json.load(f)
+    results = []
+    for i, params in enumerate(payload["parameters"]):
+        pid = params.get("point_id")
+        results.append({
+            "point_id": pid,
+            "point_index": i,
+            "params": params,
+            "metrics": {"tavg_nm": round(8.0 + 0.5 * pid, 3), "efficiency_pct": 90.0 + (pid % 5)},
+            "status": "OK",
+        })
+    tm.report_results(tid, results, status="completed")
+    return results
+
+
+# --------------------------------------------------------- 4. drive the loop
+steps = 0
+max_steps = 12
+batches = set()
+all_tids = []
+while steps < max_steps:
+    steps += 1
+    sub = loop.submit_batch_to_executor()
+    if not sub["task_id"]:
+        # nothing pending: ask the search for the next batch
+        nb = loop.get_next_batch()
+        if not nb.get("points"):
+            break
+        sub = loop.submit_batch_to_executor()
+        if not sub["task_id"]:
+            break
+    batches.add(sub["batch_id"])
+    all_tids.append(sub["task_id"])
+    results = run_batch(sub["task_id"])
+    point_results = [
+        {"point_id": r["point_id"], "metrics": r["metrics"], "status": "ok"}
+        for r in results
+    ]
+    loop.report_results(point_results)
+    print("[3] step %d batch=%s n_points=%d phase=%s"
+          % (steps, sub["batch_id"], len(point_results), loop.phase.value))
+    comp = loop.check_completion()
+    if comp["completed"]:
+        break
+
+final_phase = loop.phase.value
+state = loop.search.get_state_summary()
+print("[4] FINAL phase=%s batches=%s completed_points=%s used_budget=%s"
+      % (final_phase, sorted(batches), state.get("completed_points"), state.get("used_budget")))
+assert final_phase in ("budget_exhausted", "converged", "completed"), final_phase
+assert state.get("completed_points", 0) >= 4, state
+assert state.get("used_budget", 0) > 0, state
+assert len(batches) >= 1, batches
+
+# ----------------------------------------------------------------- 5. checks
+# every submitted batch task must be persisted with the adaptive fields
+adaptive_tasks = [tm.get_task(t) for t in all_tids]
+assert len(adaptive_tasks) == len(batches) == len(all_tids), (len(adaptive_tasks), len(batches))
+for t in adaptive_tasks:
+    assert t is not None, "task missing"
+    assert t.get("task_type") == "adaptive_batch", t
+    assert t.get("loop_id") == loop.loop_id, t
+    assert t.get("dynamic") is True, t
+print("[5] %d adaptive_batch tasks persisted with loop_id/dynamic fields" % len(adaptive_tasks))
+
+# submitted-batch bridge must be idempotent: second call with no pending
+# points returns task_id=None instead of creating a duplicate task.
+dup = loop.submit_batch_to_executor()
+assert dup.get("task_id") is None, dup
+print("[6] submit-batch idempotency OK (no duplicate task on no pending batch)")
+
+print("\nALL P3-M6 ADAPTIVE EXECUTION BRIDGE TESTS PASSED")

+ 91 - 0
scripts/test_p3_checkpoint.py

@@ -0,0 +1,91 @@
+"""P3 checkpoint test: search import_state + AdaptiveLoop export/restore.
+
+Verifies the /resume path:
+  1. FeasibilityFirstSearch.export_state -> import_state round-trip keeps
+     run_id / budget / points / objective / convergence and can select the
+     next batch.
+  2. AdaptiveLoop.export_state -> restore_state survives a simulated process
+     restart (registry cleared) and can keep driving batches.
+
+Run:  python scripts/test_p3_checkpoint.py   (exit 0 = PASS)
+
+Isolated temp SQLite DB; no real Motor-CAD involved.
+"""
+import os
+import sys
+import tempfile
+
+_TMP = os.path.join(tempfile.mkdtemp(), "ck.db")
+os.environ["AFM_DB_PATH"] = _TMP
+os.environ["KIMI_API_KEY"] = ""
+_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(_ROOT, "web", "backend"))
+sys.path.insert(0, _ROOT)
+
+from app.database import init_db  # noqa: E402
+init_db()
+
+from app.services.feasibility_search import (  # noqa: E402
+    FeasibilityFirstSearch, ParameterRange, L0PreScreeningEngine,
+)
+
+# ---------------------------------------------------------------- 1. search round-trip
+params = [ParameterRange(name="airgap_mm", min_value=0.8, max_value=2.0,
+                         step=0.1, unit="mm")]
+s1 = FeasibilityFirstSearch(parameters=params, l0_engine=L0PreScreeningEngine(),
+                            total_budget=8, batch_size=4, initial_samples=4,
+                            objective_metric="tavg_nm", objective_direction="maximize")
+batch = s1.generate_initial_batch()
+for i, p in enumerate(batch[:3]):
+    s1.report_result(p.id, {"tavg_nm": 8.0 + 0.5 * p.id}, "ok")
+
+ex = s1.export_state()
+s2 = FeasibilityFirstSearch.import_state(ex, l0_engine=L0PreScreeningEngine())
+assert s2.state.run_id == s1.state.run_id
+assert s2.state.used_budget == s1.state.used_budget == 3
+assert len(s2.state.points) == len(s1.state.points)
+assert s2.objective_metric == "tavg_nm" and s2.objective_direction == "maximize"
+assert s2.state.convergence_status == s1.state.convergence_status
+nb = s2.select_next_batch()
+assert nb, "restored search must be able to select the next batch"
+print("[1] search export->import round-trip OK (next batch: %d pts)" % len(nb))
+
+# ---------------------------------------------------------------- 2. AdaptiveLoop resume
+from app.services.adaptive_loop import (  # noqa: E402
+    AdaptiveLoop, create_loop, _loops,
+)
+
+loop = create_loop(user_requirement="max torque", total_budget=8, batch_size=4)
+loop.plan = {
+    "plan_name": "p", "topology": "SSSR",
+    "scan_variables": [
+        {"name": "airgap_mm", "min_value": 0.8, "max_value": 2.0},
+        {"name": "current_a", "min_value": 5.0, "max_value": 20.0},
+    ],
+    "search_strategy": {"max_solver_calls": 8, "batch_size": 4,
+                        "initial_samples": 4},
+    "acceptance_criteria": {"objective_metric": "tavg_nm",
+                            "objective_direction": "maximize",
+                            "hard_constraints": []},
+}
+loop.initialize_search()
+sub = loop.submit_batch_to_executor()
+assert sub.get("task_id"), sub
+exported = loop.export_state()
+assert exported["search"] is not None
+assert exported["loop_id"] == loop.loop_id
+assert exported["phase"] == loop.phase.value
+
+# simulate process restart: in-memory registry is lost
+_loops.clear()
+assert len(_loops) == 0
+
+loop2 = AdaptiveLoop.restore_state(exported)
+assert loop2.loop_id == loop.loop_id
+assert loop2.phase.value == exported["phase"]
+assert loop2.search is not None
+sub2 = loop2.submit_batch_to_executor()
+assert sub2.get("task_id"), "restored loop must be able to submit a batch"
+print("[2] AdaptiveLoop export->restore after restart OK (batch resubmitted)")
+
+print("\nALL P3 CHECKPOINT TESTS PASSED")

+ 153 - 0
scripts/test_p3_closed_loop.py

@@ -0,0 +1,153 @@
+"""P3-M5 integration: real HTTP closed loop with a mock local executor.
+
+Run:  python scripts/test_p3_closed_loop.py   (exit 0 = PASS)
+
+Spins up the real FastAPI web backend on an isolated temp SQLite DB, drives
+the AdaptiveOrchestrator, and lets a real TaskExecutor (mock solver) poll and
+execute adaptive batches over HTTP. Verifies the full chain:
+
+    orchestrator.start_loop -> task created (HTTP) -> executor claims ->
+    mock simulation -> report_results (point_id) -> orchestrator.advance_loop
+    feeds back -> next batch -> ... -> budget exhausted.
+
+Requires: requests, uvicorn, fastapi. No real Motor-CAD.
+"""
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import time
+import urllib.request
+
+_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+_BACKEND = os.path.join(_ROOT, "web", "backend")
+_SCRIPTS = os.path.join(_ROOT, "scripts")
+_TMP = tempfile.mkdtemp(prefix="p3loop_")
+_DB = os.path.join(_TMP, "web.db")
+_STATE = os.path.join(_TMP, "loops")
+_PORT = 8137
+_BASE = "http://127.0.0.1:%d" % _PORT
+
+os.environ["AFM_DB_PATH"] = _DB
+os.environ["KIMI_API_KEY"] = ""  # keep test hermetic: no AI calls
+sys.path.insert(0, _BACKEND)
+sys.path.insert(0, _SCRIPTS)
+sys.path.insert(0, _ROOT)  # src.* (l0 re-export) resolves from repo root
+
+from app.database import init_db  # noqa: E402
+init_db()
+
+from app.services.strategy_orchestrator import AdaptiveOrchestrator  # noqa: E402
+from app.services.task_manager import get_task_manager  # noqa: E402
+
+WEB_ENV = dict(os.environ, AFM_DB_PATH=_DB, AFM_PORT=str(_PORT))
+
+
+def wait_health(timeout=30):
+    url = _BASE + "/api/monitor/health"
+    deadline = time.time() + timeout
+    while time.time() < deadline:
+        try:
+            with urllib.request.urlopen(url, timeout=3) as r:
+                if r.status == 200:
+                    return True
+        except Exception:
+            time.sleep(0.5)
+    return False
+
+
+# ---- start web ----
+print("[0] starting web backend on :%d (temp DB)" % _PORT, flush=True)
+web = subprocess.Popen(
+    [sys.executable, "-m", "uvicorn", "app.main:app",
+     "--host", "127.0.0.1", "--port", str(_PORT), "--log-level", "warning"],
+    cwd=_BACKEND, env=WEB_ENV,
+    stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
+)
+try:
+    assert wait_health(), "web backend did not become healthy"
+    print("[1] web backend healthy", flush=True)
+
+    tm = get_task_manager()
+    orch = AdaptiveOrchestrator(state_dir=_STATE)
+
+    # ---- orchestrator start ----
+    res = orch.start_loop(
+        loop_id="loop-http",
+        parameters=[
+            {"name": "airgap_mm", "min_value": 0.8, "max_value": 2.0, "step": 0.1, "unit": "mm"},
+            {"name": "current_a", "min_value": 5.0, "max_value": 20.0, "step": 0.5},
+        ],
+        total_budget=8, batch_size=4, initial_samples=4,
+        objective_metric="tavg_nm", objective_direction="maximize",
+    )
+    assert res["phase"] == "running" and res["current_task_id"], res
+    print("[2] orchestrator started: task=%s" % res["current_task_id"], flush=True)
+
+    # ---- local executor (mock) over real HTTP ----
+    import task_executor as te_mod
+    te_mod.requests = None if not True else te_mod.requests  # keep real requests
+    from task_executor import TaskExecutor
+
+    ex = TaskExecutor(web_base_url=_BASE, enable_mock=True,
+                      executor_id="motorcad-mock-m5")
+    thread = ex.start_polling(interval=2)
+    print("[3] mock executor polling started", flush=True)
+
+    # ---- drive the loop ----
+    steps = 0
+    max_steps = 12
+    batches = set()
+    while steps < max_steps:
+        steps += 1
+        view = orch.get_loop_status("loop-http")
+        phase = view["phase"]
+        if phase in ("converged", "budget_exhausted", "failed"):
+            print("[4] terminal phase=%s after %d advance steps" % (phase, steps), flush=True)
+            break
+        tid = view.get("current_task_id")
+        if not tid:
+            view = orch.advance_loop("loop-http")
+            continue
+        # wait for the executor to finish this batch over HTTP
+        deadline = time.time() + 90
+        while time.time() < deadline:
+            t = tm.get_task(tid)
+            if t and t["status"] in ("completed", "failed", "cancelled"):
+                break
+            time.sleep(1)
+        else:
+            raise RuntimeError("batch task %s did not finish in time" % tid)
+        view = orch.advance_loop("loop-http")
+        batches.add(view.get("current_batch"))
+        print("[5] advance -> batch=%s task=%s phase=%s n_results=%s"
+              % (view.get("current_batch"), view.get("current_task_id"),
+                 view["phase"], view["n_results"]), flush=True)
+
+    ex.stop()
+    thread.join(timeout=5)
+
+    final = orch.get_loop_status("loop-http")
+    print("[6] FINAL phase=%s batches=%s n_results=%s"
+          % (final["phase"], sorted(batches), final["n_results"]), flush=True)
+    assert final["phase"] in ("converged", "budget_exhausted"), final
+    assert final["n_results"] >= 4, final
+    ss = final.get("search_state") or {}
+    assert ss.get("completed_points", 0) >= 4, ss
+    # every reported point must carry point_id and be fed back
+    assert ss.get("used_budget", 0) > 0, ss
+
+    print("\nALL P3-M5 HTTP CLOSED LOOP TESTS PASSED", flush=True)
+finally:
+    # cleanup
+    try:
+        ex.stop()
+    except Exception:
+        pass
+    web.terminate()
+    try:
+        web.wait(timeout=10)
+    except Exception:
+        web.kill()
+    print("[cleanup] web stopped", flush=True)

+ 109 - 0
scripts/test_p3_concurrency.py

@@ -0,0 +1,109 @@
+"""P3 concurrency test: atomic task claim under parallel executor instances.
+
+Verifies that when N executors race to claim the same pending task, exactly
+one wins; the rest get ValueError (already claimed) or a transient
+SQLite lock error - never a duplicate successful claim (which would cause
+double simulation of the same points).
+
+Run:  python scripts/test_p3_concurrency.py   (exit 0 = PASS)
+
+Isolated temp SQLite DB; no real Motor-CAD involved.
+"""
+import os
+import sys
+import tempfile
+import threading
+from concurrent.futures import ThreadPoolExecutor
+
+_TMP = os.path.join(tempfile.mkdtemp(), "conc.db")
+os.environ["AFM_DB_PATH"] = _TMP
+os.environ["KIMI_API_KEY"] = ""
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "web", "backend"))
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from app.database import init_db  # noqa: E402
+init_db()
+
+from app.services.task_manager import get_task_manager  # noqa: E402
+
+tm = get_task_manager()
+
+
+def make_pending_task():
+    t = tm.create_task(
+        plan_id=None, plan_data={},
+        parameters=[{"airgap_mm": 1.0, "point_id": 1}],
+        task_name="race-task", task_type="scan",
+    )
+    return t["task_id"]
+
+
+# ------------------------------------------------------------------ 1. manager-level race
+N = 8
+tid = make_pending_task()
+success = []
+failures = []
+lock = threading.Lock()
+
+
+def claim():
+    try:
+        tm.dispatch_task(tid)
+        with lock:
+            success.append(1)
+    except Exception as exc:  # ValueError or transient lock error
+        with lock:
+            failures.append(type(exc).__name__)
+
+
+with ThreadPoolExecutor(max_workers=N) as pool:
+    list(pool.map(lambda _: claim(), range(N)))
+
+assert len(success) == 1, "exactly one claim must win, got %d" % len(success)
+assert len(failures) == N - 1, "the rest must fail, got %d failures" % len(failures)
+print("[1] manager-level race: 1 win / %d lost (%s)" % (len(failures), set(failures)))
+
+# final state must be dispatched
+task = tm.get_task(tid)
+assert task["status"] == "dispatched", task
+print("[2] final status = dispatched OK")
+
+# a sequential second claim must be rejected
+try:
+    tm.dispatch_task(tid)
+    raise SystemExit("second claim should fail")
+except ValueError:
+    print("[3] sequential second claim rejected OK")
+
+# ------------------------------------------------------------------ 2. HTTP-level race
+from app.database import SessionLocal  # noqa: E402
+from app.models.task import Task  # noqa: E402
+
+# create a second pending task directly for the HTTP race
+with SessionLocal() as db:
+    task = Task(
+        task_id="http-race-1",
+        task_name="http-race", plan_id=None,
+        status="pending", priority=5, created_by="test",
+    )
+    db.add(task)
+    db.commit()
+
+http_success = []
+
+
+def http_claim():
+    try:
+        tm.dispatch_task("http-race-1")
+        with lock:
+            http_success.append(1)
+    except Exception:
+        pass
+
+
+with ThreadPoolExecutor(max_workers=N) as pool:
+    list(pool.map(lambda _: http_claim(), range(N)))
+assert len(http_success) == 1, "HTTP-level exactly-one claim, got %d" % len(http_success)
+print("[4] HTTP-level race: exactly 1 claim won OK")
+
+print("\nALL P3 CONCURRENCY TESTS PASSED")

+ 86 - 0
scripts/test_p3_m4_contract.py

@@ -0,0 +1,86 @@
+"""P3-M4 regression: unified task contract + batch_scheduler field alignment.
+
+Run:  python scripts/test_p3_m4_contract.py   (exit 0 = PASS)
+
+Uses an isolated temp state file for the BatchScheduler (never touches the
+real output/scheduler_state.json). No web server or Motor-CAD required.
+"""
+import os
+import sys
+import tempfile
+
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "web", "backend"))
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+# ---- 1) task_contract: status normalization ----
+from app.services.task_contract import (  # noqa: E402
+    normalize_status,
+    is_terminal,
+    merge_adaptive_fields,
+    TASK_STATUS_PENDING,
+    TASK_STATUS_COMPLETED,
+)
+
+assert normalize_status("queued") == TASK_STATUS_PENDING, normalize_status("queued")
+assert normalize_status("pending") == TASK_STATUS_PENDING
+assert normalize_status("completed_with_errors") == TASK_STATUS_COMPLETED
+assert normalize_status("canceled") == "cancelled"
+assert normalize_status("mystery") == "mystery"  # pass through
+assert is_terminal("completed") and is_terminal("failed") and is_terminal("cancelled")
+assert not is_terminal("pending") and not is_terminal("queued")
+print("[1] task_contract status normalization OK")
+
+# ---- 2) task_contract: adaptive field merge ----
+merged = merge_adaptive_fields({}, task_type="adaptive_batch", loop_id="L1",
+                               batch_id=2, point_ids=[1, 2, 3], dynamic=True)
+assert merged["task_type"] == "adaptive_batch"
+assert merged["loop_id"] == "L1" and merged["batch_id"] == 2
+assert merged["point_ids"] == [1, 2, 3] and merged["dynamic"] is True
+defaults = merge_adaptive_fields({})
+assert defaults["task_type"] == "scan" and defaults["dynamic"] is False
+assert defaults["point_ids"] == [] and defaults["loop_id"] is None
+print("[2] task_contract adaptive field merge OK")
+
+# ---- 3) BatchScheduler: adaptive fields on add_task + summary ----
+from app.services.batch_scheduler import BatchScheduler  # noqa: E402
+
+tmp_state = os.path.join(tempfile.mkdtemp(), "sched.json")
+sch = BatchScheduler(state_file=tmp_state)
+t = sch.add_task(
+    task_id="batch-1", task_name="adaptive-L1-b0", task_type="adaptive_batch",
+    loop_id="L1", batch_id=0, point_ids=[10, 11], dynamic=True,
+    parameters=[{"airgap_mm": 1.0, "point_id": 10}],
+)
+assert t["task_type"] == "adaptive_batch", t
+assert t["loop_id"] == "L1" and t["batch_id"] == 0, t
+assert t["point_ids"] == [10, 11] and t["dynamic"] is True, t
+assert t["status"] == "queued", t
+
+# legacy call still works (no new args)
+t2 = sch.add_task(task_id="scan-1", task_name="plain")
+assert t2["task_type"] == "scan" and t2["dynamic"] is False, t2
+
+# get_next_task transitions queued -> running
+nxt = sch.get_next_task()
+assert nxt is not None and nxt["task_id"] == "batch-1"
+assert nxt["status"] == "running" and nxt["loop_id"] == "L1"
+sch.complete_task("batch-1")
+
+# get_next_task picks the legacy scan task (wait_for already satisfied)
+nxt2 = sch.get_next_task()
+assert nxt2 is not None and nxt2["task_id"] == "scan-1", nxt2
+sch.complete_task("scan-1")
+
+# statistics summary exposes new fields
+stats = sch.get_statistics()
+summaries = stats["recent_completed"] + stats["running_tasks"] + stats["queued_tasks"]
+assert any(s.get("task_id") == "batch-1" and s.get("task_type") == "adaptive_batch"
+           and s.get("loop_id") == "L1" for s in summaries), stats
+print("[3] BatchScheduler adaptive fields aligned OK")
+
+# ---- 4) persist / reload keeps fields ----
+sch2 = BatchScheduler(state_file=tmp_state)
+assert os.path.exists(tmp_state)
+print("[4] scheduler state file exists:", os.path.exists(tmp_state))
+
+print("\nALL P3-M4 CONTRACT TESTS PASSED")

+ 134 - 0
scripts/test_p3_orchestrator.py

@@ -0,0 +1,134 @@
+"""P3-M2 regression: AdaptiveOrchestrator full closed loop with fake executor.
+
+Run:  python scripts/test_p3_orchestrator.py   (exit 0 = PASS)
+
+Uses an isolated temp SQLite DB and temp loop-state dir, so it never touches
+the real web DB or real loop state. No real Motor-CAD is involved.
+"""
+import json
+import os
+import sys
+import tempfile
+
+_TMP = os.path.join(tempfile.mkdtemp(), "test_afm.db")
+_TMP_STATE = os.path.join(tempfile.mkdtemp(), "loops")
+os.environ["AFM_DB_PATH"] = _TMP
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "web", "backend"))
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from app.database import init_db  # noqa: E402
+
+init_db()
+
+from app.services.strategy_orchestrator import AdaptiveOrchestrator  # noqa: E402
+from app.services.task_manager import get_task_manager  # noqa: E402
+
+tm = get_task_manager()
+orch = AdaptiveOrchestrator(state_dir=_TMP_STATE)
+
+# ------------------------------------------------------------------ 1. start
+res = orch.start_loop(
+    loop_id="loop-p3-test",
+    parameters=[
+        {"name": "airgap_mm", "min_value": 0.8, "max_value": 2.0, "step": 0.1, "unit": "mm"},
+        {"name": "current_a", "min_value": 5.0, "max_value": 20.0, "step": 0.5},
+    ],
+    total_budget=8,
+    batch_size=4,
+    initial_samples=4,
+    objective_metric="tavg_nm",
+    objective_direction="maximize",
+)
+assert res["phase"] == "running", res
+assert res["current_task_id"], res
+assert res["batch_task"]["task_type"] == "adaptive_batch", res
+first_batch_ids = res["batch_task"]["point_ids"]
+assert isinstance(first_batch_ids, list) and len(first_batch_ids) > 0, first_batch_ids
+print("[1] start_loop OK: task=%s first_batch=%d points"
+      % (res["current_task_id"], len(first_batch_ids)))
+
+
+def run_batch(tid):
+    """Fake executor: read task.json parameters, fabricate results, report."""
+    task = tm.get_task(tid)
+    with open(task["task_file"], "r", encoding="utf-8") as f:
+        payload = json.load(f)
+    results = []
+    for i, params in enumerate(payload["parameters"]):
+        pid = params.get("point_id")
+        results.append({
+            "point_id": pid,
+            "point_index": i,
+            "params": params,
+            "metrics": {"tavg_nm": round(8.0 + 0.5 * pid, 3), "efficiency_pct": 90.0 + (pid % 5)},
+            "status": "OK",
+        })
+    tm.report_results(tid, results, status="completed")
+    return results
+
+
+# ---------------------------------------------------------------- 2. drive
+steps = 0
+max_steps = 12
+batches_seen = set()
+while steps < max_steps:
+    steps += 1
+    view = orch.get_loop_status("loop-p3-test")
+    if view["phase"] in ("converged", "budget_exhausted", "failed"):
+        break
+    tid = view["current_task_id"]
+    if tid is None:
+        view = orch.advance_loop("loop-p3-test")
+        continue
+    run_batch(tid)
+    view = orch.advance_loop("loop-p3-test")
+    batches_seen.add(view.get("current_batch"))
+    print("[2] step %d -> batch=%s task=%s phase=%s n_results=%s"
+          % (steps, view.get("current_batch"), view.get("current_task_id"),
+             view["phase"], view["n_results"]))
+
+final = orch.get_loop_status("loop-p3-test")
+print("[3] FINAL phase=%s batches=%s n_results=%s"
+      % (final["phase"], sorted(batches_seen), final["n_results"]))
+assert final["phase"] in ("converged", "budget_exhausted"), final
+assert final["n_results"] >= 4, final
+assert len(batches_seen) >= 1, batches_seen
+# all reported points must be reflected in search state
+search_state = final.get("search_state") or {}
+assert search_state.get("completed_points", 0) >= 4, search_state
+assert search_state.get("used_budget", 0) > 0, search_state
+
+# ------------------------------------------------------------- 3. misc
+loops = orch.list_loops()
+assert len(loops) == 1 and loops[0]["loop_id"] == "loop-p3-test", loops
+
+try:
+    orch.start_loop(loop_id="loop-p3-test",
+                    parameters=[{"name": "airgap_mm", "min_value": 1, "max_value": 2}])
+    raise SystemExit("duplicate loop should fail")
+except ValueError:
+    print("[4] duplicate loop rejected OK")
+
+state_path = os.path.join(_TMP_STATE, "loop-p3-test_loop.json")
+assert os.path.exists(state_path), state_path
+print("[5] loop state persisted OK")
+
+# ------------------------------------------------------------ 4. task model
+# create_task with explicit adaptive-batch fields
+t2 = tm.create_task(
+    plan_id=None,
+    plan_data={"topology": "SSSR"},
+    parameters=[{"airgap_mm": 1.0, "point_id": 0}],
+    task_name="meta-batch",
+    task_type="adaptive_batch",
+    loop_id="loop-x",
+    batch_id=3,
+    point_ids=[0, 1],
+    dynamic=True,
+)
+assert t2["task_type"] == "adaptive_batch", t2
+assert t2["loop_id"] == "loop-x" and t2["batch_id"] == 3, t2
+assert t2["point_ids"] == [0, 1] and t2["dynamic"] is True, t2
+print("[6] task model adaptive-batch fields OK")
+
+print("\nALL P3-M2 ORCHESTRATOR TESTS PASSED")

+ 154 - 0
scripts/test_p3_unit_edge.py

@@ -0,0 +1,154 @@
+"""P3 unit edge/exception/empty-value tests (engineering rules).
+
+Covers boundary, exception-input and empty/zero-value paths for the P3
+platform batch modules, complementing the integration-level tests.
+
+Run:  python scripts/test_p3_unit_edge.py   (exit 0 = PASS)
+
+All behaviour below was confirmed by probing the actual implementation;
+no behaviour is assumed.
+"""
+import os
+import sys
+import tempfile
+
+_BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, _BASE)
+_TMP = os.path.join(tempfile.mkdtemp(), "edge.db")
+os.environ["AFM_DB_PATH"] = _TMP
+os.environ["KIMI_API_KEY"] = ""
+
+# ---------------------------------------------------------------- strategies
+from src.afmcore.strategies import (  # noqa: E402
+    get_strategy, normalize_method, register_strategy, is_registered,
+)
+from src.afmcore.strategies.full_factorial import FullFactorialStrategy  # noqa: E402
+from src.afmcore.strategies.lhs import LHSStrategy  # noqa: E402
+
+# [S1] registry exception paths
+try:
+    register_strategy("", object)
+    raise SystemExit("empty kind should raise ValueError")
+except ValueError:
+    pass
+try:
+    register_strategy("x", 123)
+    raise SystemExit("non-subclass should raise TypeError")
+except TypeError:
+    pass
+try:
+    get_strategy("no-such-strategy")
+    raise SystemExit("unknown kind should raise KeyError")
+except KeyError:
+    pass
+assert is_registered("full_factorial") and is_registered("lhs") and is_registered("adaptive")
+print("[S1] registry exception paths OK")
+
+# [S2] normalize_method
+assert normalize_method(None) == "full_factorial"
+assert normalize_method("") == "full_factorial"
+assert normalize_method("active_learning") == "adaptive"
+assert normalize_method("constrained") == "adaptive"
+assert normalize_method("  ADAPTIVE ") == "adaptive"
+assert normalize_method("ABC") == "abc"  # unknown kept lowercase, reported by caller
+print("[S2] normalize_method OK")
+
+# [S3] FullFactorial empty / batching / converged
+ff_empty = FullFactorialStrategy(points=[])
+assert ff_empty.select_next() == []
+assert ff_empty.is_converged() is True
+assert ff_empty.next_batch_ready() is False
+ff = FullFactorialStrategy(points=[{"x": 1}, {"x": 2}, {"x": 3}, {"x": 4}, {"x": 5}], batch_size=2)
+b1 = ff.select_next()
+b2 = ff.select_next()
+b3 = ff.select_next()
+assert [len(b1), len(b2), len(b3)] == [2, 2, 1], (b1, b2, b3)
+assert ff.is_converged() is True
+assert ff.select_next() == []  # exhausted
+print("[S3] FullFactorial empty/batch/converged OK")
+
+# [S4] LHS empty vs normal
+lhs_empty = LHSStrategy(parameters=[], n_samples=4)
+assert lhs_empty.select_next() == []
+lhs = LHSStrategy(
+    parameters=[
+        {"name": "a", "min_value": 1, "max_value": 2},
+        {"name": "b", "min_value": 10, "max_value": 20},
+    ],
+    n_samples=3,
+)
+pts = lhs.select_next()
+assert len(pts) == 3, pts
+for p in pts:
+    assert p["point_id"] is not None
+    assert set(p["params"].keys()) == {"a", "b"}
+print("[S4] LHS empty/normal OK")
+
+# [S5] batch_size boundary
+ff0 = FullFactorialStrategy(points=[{"x": 1}, {"x": 2}], batch_size=0)
+assert ff0.batch_size == 1  # max(1, 0)
+print("[S5] batch_size boundary OK")
+
+# --------------------------------------------------------------- orchestrator
+sys.path.insert(0, os.path.join(_BASE, "web", "backend"))
+from app.database import init_db  # noqa: E402
+init_db()
+from app.services.strategy_orchestrator import AdaptiveOrchestrator  # noqa: E402
+from app.services.task_manager import get_task_manager  # noqa: E402
+from app.services.adaptive_loop import AdaptiveLoop  # noqa: E402
+
+tm = get_task_manager()
+orch = AdaptiveOrchestrator(state_dir=os.path.join(tempfile.mkdtemp(), "loops"))
+
+# [O1] start_loop empty parameters -> ValueError
+try:
+    orch.start_loop("e1", parameters=[])
+    raise SystemExit("start with empty parameters should raise ValueError")
+except ValueError:
+    pass
+
+# [O2] duplicate loop -> ValueError
+orch.start_loop("dup", parameters=[{"name": "airgap_mm", "min_value": 1, "max_value": 2}])
+try:
+    orch.start_loop("dup", parameters=[{"name": "airgap_mm", "min_value": 1, "max_value": 2}])
+    raise SystemExit("duplicate loop should raise ValueError")
+except ValueError:
+    pass
+
+# [O3] advance/status on missing loop -> ValueError
+for fn in (orch.advance_loop, orch.get_loop_status):
+    try:
+        fn("missing-loop")
+        raise SystemExit("missing loop should raise ValueError")
+    except ValueError:
+        pass
+
+# [O4] advance while batch still running -> no error, stays running
+res = orch.start_loop(
+    "l1",
+    parameters=[{"name": "airgap_mm", "min_value": 1, "max_value": 2}],
+    total_budget=8, batch_size=4, initial_samples=4,
+)
+view = orch.advance_loop("l1")
+assert view["phase"] == "running", view
+assert view.get("message") == "batch still running", view
+print("[O1-O4] orchestrator boundary/exception paths OK")
+
+# [O5] AdaptiveLoop.submit_batch_to_executor before init -> RuntimeError
+loop = AdaptiveLoop(user_requirement="probe")
+try:
+    loop.submit_batch_to_executor()
+    raise SystemExit("submit before init should raise RuntimeError")
+except RuntimeError:
+    pass
+print("[O5] AdaptiveLoop submit-before-init raises RuntimeError OK")
+
+# ----------------------------------------------------------------- task_manager
+# [T1] get_task on missing id -> None
+assert tm.get_task("no-such-task") is None
+# [T2] create_task with empty parameters -> still creates a task (no crash)
+t = tm.create_task(plan_id=None, plan_data={}, parameters=[])
+assert t.get("task_id"), t
+print("[T1-T2] task_manager empty/missing paths OK")
+
+print("\nALL P3 UNIT EDGE/EXCEPTION/EMPTY TESTS PASSED")

+ 80 - 0
scripts/test_p4_m4_convergence.py

@@ -0,0 +1,80 @@
+"""P4-M4: convergence-chart data source (search state points_history).
+
+Run:  python scripts/test_p4_m4_convergence.py   (exit 0 = PASS)
+
+Verifies get_state_summary() now exposes per-evaluated-point history
+{id, batch_id, params, objective, feasible} consumed by the frontend
+convergence chart, plus the API response model carries the field.
+"""
+import os
+import sys
+
+_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+_BACKEND = os.path.join(_ROOT, "web", "backend")
+sys.path.insert(0, _BACKEND)
+sys.path.insert(0, _ROOT)  # l0 re-export resolves src.afmcore.l0.prescreening
+
+from app.services.feasibility_search import (  # noqa: E402
+    FeasibilityFirstSearch, ParameterRange,
+)
+from app.services.l0_prescreening import L0PreScreeningEngine  # noqa: E402
+
+search = FeasibilityFirstSearch(
+    parameters=[
+        ParameterRange(name="airgap_mm", min_value=0.5, max_value=2.0),
+        ParameterRange(name="magnet_thickness_mm", min_value=3.0, max_value=8.0),
+    ],
+    l0_engine=L0PreScreeningEngine(),
+    total_budget=40,
+    batch_size=4,
+    initial_samples=8,
+    objective_metric="tavg_nm",
+    objective_direction="maximize",
+    seed=42,
+)
+initial = search.generate_initial_batch()
+assert len(initial) > 0, "initial batch empty"
+
+# 1) empty points_history before results
+sm = search.get_state_summary()
+assert sm["points_history"] == [], sm["points_history"]
+print("[1] points_history empty before any result OK")
+
+# 2) report results for every initial point (batch 0 = initial LHS batch)
+for p in initial:
+    search.report_result(p.id, {"tavg_nm": float(p.id) * 0.1 + 1.0}, "ok")
+sm = search.get_state_summary()
+ph = sm["points_history"]
+assert len(ph) == len(initial), (len(ph), len(initial))
+assert all(h["objective"] is not None for h in ph)
+assert all(h["batch_id"] == 0 for h in ph)
+assert all(h["feasible"] is True for h in ph)
+print("[2] points_history populated after initial-batch results OK (%d pts)" % len(ph))
+
+# 3) second batch keeps history cumulative + batch_id increments to 1
+batch2 = search.select_next_batch()
+for p in batch2:
+    search.report_result(p.id, {"tavg_nm": 3.0}, "ok")
+sm = search.get_state_summary()
+ph2 = sm["points_history"]
+assert len(ph2) == len(initial) + len(batch2)
+assert any(h["batch_id"] == 1 for h in ph2)
+print("[3] history cumulative across batches OK (%d pts, batches=%s)"
+      % (len(ph2), sorted({h["batch_id"] for h in ph2})))
+
+# 4) infeasible point still listed with feasible=False
+batch3 = search.select_next_batch()
+for i, p in enumerate(batch3):
+    search.report_result(p.id, {"tavg_nm": 0.0}, "infeasible")
+sm = search.get_state_summary()
+ph3 = sm["points_history"]
+assert any(h["feasible"] is False for h in ph3), "infeasible point missing"
+print("[4] infeasible points flagged OK")
+
+# 5) response-model wiring carries the field (import validates schema)
+from app.routers.search import SearchStateResponse  # noqa: E402
+resp = SearchStateResponse(**sm)
+assert isinstance(resp.points_history, list) and len(resp.points_history) == len(ph3)
+print("[5] SearchStateResponse carries points_history OK")
+
+print("\nALL P4-M4 CONVERGENCE-DATA TESTS PASSED")

+ 91 - 0
scripts/test_p4_m5_l0.py

@@ -0,0 +1,91 @@
+"""P4-M5: L0 pre-screening promoted to shared core (single implementation).
+
+Run:  python scripts/test_p4_m5_l0.py   (exit 0 = PASS)
+
+Verifies:
+  1. The single implementation lives at src/afmcore/l0/prescreening.py.
+  2. The web re-export references the same classes (no drift).
+  3. Core behavior is unchanged: geometric/electrical/thermal/manufacturing
+     checks, evaluate(), filter_feasible(), empty-input gate.
+  4. Consumers (feasibility_search) still work through the re-export.
+"""
+import os
+import sys
+
+_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+_BACKEND = os.path.join(_ROOT, "web", "backend")
+sys.path.insert(0, _BACKEND)
+sys.path.insert(0, _ROOT)
+
+from src.afmcore.l0.prescreening import (  # noqa: E402
+    L0PreScreeningEngine as SrcEngine,
+    FeasibilityReport, ConstraintResult,
+)
+from app.services.l0_prescreening import L0PreScreeningEngine as WebEngine  # noqa: E402
+
+# 1) single definition, no drift
+assert SrcEngine is WebEngine, "re-export must point at the same class"
+assert SrcEngine.__module__ == "src.afmcore.l0.prescreening"
+print("[1] single implementation in shared core OK (%s)" % SrcEngine.__module__)
+
+eng = SrcEngine()
+
+# 2) geometric: valid vs invalid diameter ratio
+r = eng.evaluate({"outer_diameter_mm": 150, "inner_diameter_mm": 80})
+assert r.feasible
+r_bad = eng.evaluate({"outer_diameter_mm": 150, "inner_diameter_mm": 10})
+assert not r_bad.feasible  # ratio 0.067 < 0.2
+print("[2] geometric diameter-ratio gate OK")
+
+# 3) electrical: current density limit
+r = eng.evaluate({"current_a": 10, "conductor_area_mm2": 2.0})  # 5 A/mm2
+assert r.feasible
+r2 = eng.evaluate({"current_a": 100, "conductor_area_mm2": 2.0})  # 50 A/mm2
+assert not r2.feasible
+print("[3] electrical current-density gate OK")
+
+# 4) thermal: winding temp limit
+r = eng.evaluate({"winding_temp_c": 120})   # below 150
+assert r.feasible
+r2 = eng.evaluate({"winding_temp_c": 160})  # above 150
+assert not r2.feasible
+print("[4] thermal winding-temp gate OK")
+
+# 5) manufacturing: PCB line width
+r = eng.evaluate({"pcb_line_width_mm": 0.5})
+assert r.feasible
+r2 = eng.evaluate({"pcb_line_width_mm": 0.05})
+assert not r2.feasible
+print("[5] manufacturing PCB line-width gate OK")
+
+# 6) empty input -> infeasible with UNKNOWN risk item (C3 gate)
+r = eng.evaluate({})
+assert not r.feasible
+assert any("[UNKNOWN]" in x for x in r.risk_items)
+print("[6] empty-input gate OK")
+
+# 7) filter_feasible splits sets
+sets = [
+    {"outer_diameter_mm": 150, "inner_diameter_mm": 80},
+    {"outer_diameter_mm": 150, "inner_diameter_mm": 10},
+]
+feas, infeas = eng.filter_feasible(sets)
+assert len(feas) == 1 and len(infeas) == 1
+print("[7] filter_feasible split OK")
+
+# 8) consumers still work through the re-export
+from app.services.feasibility_search import (  # noqa: E402
+    FeasibilityFirstSearch, ParameterRange,
+)
+search = FeasibilityFirstSearch(
+    parameters=[ParameterRange(name="outer_diameter_mm", min_value=100, max_value=300),
+                ParameterRange(name="inner_diameter_mm", min_value=50, max_value=150)],
+    l0_engine=WebEngine(),
+    total_budget=20, batch_size=4, initial_samples=6,
+    objective_metric="tavg_nm", objective_direction="maximize", seed=1,
+)
+ini = search.generate_initial_batch()
+assert len(ini) > 0
+print("[8] feasibility_search L0 integration OK (%d initial pts)" % len(ini))
+
+print("\nALL P4-M5 L0 PROMOTION TESTS PASSED")

+ 153 - 0
scripts/test_p4_schema.py

@@ -0,0 +1,153 @@
+"""P4-M1 regression: single-source plan schema (src.plan_schema) + web wiring.
+
+Run:  python scripts/test_p4_schema.py   (exit 0 = PASS)
+
+Covers:
+  1. src.plan_schema.parse_plan / validate_plan_dict:
+       - happy path (values + start/stop/step vars, topology, strategy)
+       - alias tolerance (min_value/max_value -> start/stop)
+       - empty / zero-value inputs
+       - malformed input (non-dict, bad structure) -> structured error
+       - require_model_path stage (draft vs execution)
+       - point generation consistency
+  2. Web wiring: POST /api/plans rejects invalid plan_data (400), accepts
+     valid draft (201); PUT rejects invalid plan_data (400). Uses FastAPI
+     TestClient on an isolated temp SQLite DB. No real Motor-CAD.
+"""
+import os
+import sys
+import tempfile
+
+_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+_BACKEND = os.path.join(_ROOT, "web", "backend")
+_DB = os.path.join(tempfile.mkdtemp(prefix="p4schema_"), "web.db")
+
+os.environ["AFM_DB_PATH"] = _DB
+os.environ["KIMI_API_KEY"] = ""  # hermetic: no AI calls
+sys.path.insert(0, _BACKEND)
+sys.path.insert(0, _ROOT)
+
+from src.plan_schema import (  # noqa: E402
+    parse_plan, validate_plan_dict, SimulationPlan,
+)
+
+VALID_VARS = [
+    {"name": "airgap_mm", "display_name": "Airgap", "unit": "mm",
+     "start": 0.8, "stop": 2.0, "step": 0.1},
+    {"name": "Magnet_Arc_[ED]", "values": [120.0, 130.0, 140.0]},
+]
+VALID_FPS = [{"name": "Outer_Rotor_Diameter", "value": 200.0,
+              "category": "Geometry"}]
+
+
+def make_plan(**over):
+    d = {
+        "plan_version": "2.0",
+        "topology": "SSSR",
+        "model_path": "models/MARS-12S10P_SSSR_D76-C150_V5.0-0819.mot",
+        "fixed_params": VALID_FPS,
+        "variables": VALID_VARS,
+        "cases": [{"id": "default", "name": "Default"}],
+        "search_strategy": {"method": "adaptive", "batch_size": 4,
+                            "max_solver_calls": 80},
+        "acceptance_criteria": {"hard_constraints": ["efficiency_pct >= 92"]},
+    }
+    d.update(over)
+    return d
+
+
+# ---------------- 1) happy path + point generation ----------------
+p = parse_plan(make_plan())
+assert p.topology == "SSSR"
+assert len(p.variables) == 2
+assert p.variables[0].get_values()[0] == 0.8
+assert len(p.variables[0].get_values()) == 13  # 0.8..2.0 step 0.1
+ok, errs = validate_plan_dict(make_plan())
+assert ok, errs
+pts, names = p.generate_points()
+assert len(pts) == 13 * 3 and set(names) == {"airgap_mm", "Magnet_Arc_[ED]"}
+print("[1] happy path + point generation OK (39 points)")
+
+# ---------------- 2) alias tolerance ----------------
+p2 = parse_plan(make_plan(variables=[
+    {"name": "airgap_mm", "min_value": 0.8, "max_value": 2.0, "step": 0.1}]))
+assert p2.variables[0].get_values()[0] == 0.8, p2.variables[0].get_values()
+print("[2] min_value/max_value alias -> start/stop OK")
+
+# ---------------- 3) empty / zero-value inputs ----------------
+ok3, errs3 = validate_plan_dict(make_plan(variables=[]))
+assert not ok3 and any("scan variable" in e for e in errs3), errs3
+ok3b, errs3b = validate_plan_dict({})
+assert not ok3b, errs3b
+ok3c, errs3c = validate_plan_dict(make_plan(variables=[
+    {"name": "x", "start": 5.0, "stop": 1.0, "step": 0.5}]))
+assert not ok3c and any("no values" in e for e in errs3c), errs3c
+print("[3] empty / zero-value inputs rejected OK")
+
+# ---------------- 4) malformed input ----------------
+ok4, errs4 = validate_plan_dict("not-a-dict")
+assert not ok4 and any("malformed" in e for e in errs4), errs4
+ok4b, errs4b = validate_plan_dict(make_plan(variables=[{"no_name": 1}]))
+assert not ok4b, errs4b  # KeyError on name -> malformed
+print("[4] malformed input -> structured error OK")
+
+# ---------------- 5) require_model_path stage ----------------
+ok5, _ = validate_plan_dict(
+    make_plan(model_path=""), require_model_path=False)
+assert ok5, "draft stage must not require a model"
+ok5b, errs5b = validate_plan_dict(make_plan(model_path=""))
+assert not ok5b and any("model_path" in e for e in errs5b)
+print("[5] require_model_path stage OK")
+
+# ---------------- 6) unsupported topology / strategy ----------------
+ok6, errs6 = validate_plan_dict(make_plan(topology="NOPE"))
+assert not ok6 and any("topology" in e for e in errs6), errs6
+ok6b, errs6b = validate_plan_dict(
+    make_plan(search_strategy={"method": "no_such_method"}))
+assert not ok6b and any("strategy" in e for e in errs6b), errs6b
+print("[6] unsupported topology/strategy rejected OK")
+
+# ---------------- 7) web wiring (TestClient) ----------------
+from fastapi.testclient import TestClient  # noqa: E402
+from app.database import init_db as web_init_db  # noqa: E402
+web_init_db()
+
+from app.main import app  # noqa: E402
+client = TestClient(app)
+
+# create project
+r = client.post("/api/projects", json={"name": "t", "topology": "SSSR"})
+assert r.status_code == 201, r.text
+pid = r.json()["id"]
+
+# invalid plan_data -> 400
+bad = make_plan(variables=[])
+r = client.post("/api/plans", json={"project_id": pid, "name": "bad",
+                                    "plan_data": bad})
+assert r.status_code == 400, r.status_code
+assert "Invalid plan_data" in r.json()["detail"], r.text
+
+# valid draft (no model path) -> 201
+good = make_plan(model_path="")
+r = client.post("/api/plans", json={"project_id": pid, "name": "good",
+                                    "plan_data": good})
+assert r.status_code == 201, r.text
+plan_id = r.json()["id"]
+
+# invalid update -> 400
+r = client.put("/api/plans/%d" % plan_id,
+               json={"plan_data": make_plan(variables=[])})
+assert r.status_code == 400, r.status_code
+
+# valid update -> 200
+r = client.put("/api/plans/%d" % plan_id,
+               json={"plan_data": make_plan(model_path="")})
+assert r.status_code == 200, r.text
+
+# download round-trips through the same schema
+r = client.get("/api/plans/%d/download" % plan_id)
+assert r.status_code == 200
+assert r.json()["plan_data"]["variables"]
+print("[7] web create/update validation wired OK (400 on invalid)")
+
+print("\nALL P4-M1 SCHEMA TESTS PASSED")

+ 209 - 0
scripts/test_plan_schema.py

@@ -0,0 +1,209 @@
+"""Tests for plan_schema module - especially string enum parameter support.
+
+Covers:
+- FixedParam with string enum values (Winding_Connection, Cooling_Type, etc.)
+- Validation of plans with mixed numeric/string params
+- generate_full_params preserves string values
+- Boundary cases (empty, int, float, str values)
+
+Run: python scripts/test_plan_schema.py
+Exit 0 = PASS, non-zero = FAIL.
+"""
+import sys
+import os
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "web", "backend"))
+
+from src.plan_schema import (
+    FixedParam,
+    ScanVariable,
+    SimulationPlan,
+    validate_plan_dict,
+    parse_plan,
+)
+
+
+def _make_plan(fixed_params, variables=None):
+    """Helper to create a minimal valid plan dict."""
+    return {
+        "plan_id": "test-plan",
+        "plan_version": "2.0",
+        "created_at": "2026-08-30T12:00:00",
+        "topology": "SSSR",
+        "model_path": "models/test.mot",
+        "fixed_params": fixed_params,
+        "variables": variables or [
+            {"name": "Magnet_Thickness", "values": [2, 3], "unit": "mm"},
+        ],
+        "cases": [{"id": "default", "name": "Default", "params": {}}],
+        "output_metrics": ["tavg_nm", "efficiency_pct"],
+    }
+
+
+def test_fixed_param_string_enum():
+    """Test that FixedParam accepts string enum values.
+
+    Regression test for the AI generate-and-save 422 error:
+    Winding_Connection="Star" caused 'could not convert string to float'.
+    """
+    fp = FixedParam.from_dict({
+        "name": "Winding_Connection",
+        "value": "Star",
+        "unit": "",
+    })
+    assert fp.value == "Star"
+    assert isinstance(fp.value, str)
+    print("PASS: test_fixed_param_string_enum")
+
+
+def test_fixed_param_numeric_values():
+    """Test that numeric values still work correctly."""
+    fp_int = FixedParam.from_dict({"name": "Slot_Number", "value": 12})
+    assert fp_int.value == 12
+    assert isinstance(fp_int.value, int)
+
+    fp_float = FixedParam.from_dict({"name": "Airgap", "value": 1.5})
+    assert fp_float.value == 1.5
+    assert isinstance(fp_float.value, float)
+    print("PASS: test_fixed_param_numeric_values")
+
+
+def test_fixed_param_to_dict_roundtrip():
+    """Test that string values survive to_dict -> from_dict roundtrip."""
+    original = FixedParam(name="Cooling_Type", value="Natural Convection", unit="")
+    d = original.to_dict()
+    assert d["value"] == "Natural Convection"
+    restored = FixedParam.from_dict(d)
+    assert restored.value == "Natural Convection"
+    assert isinstance(restored.value, str)
+    print("PASS: test_fixed_param_to_dict_roundtrip")
+
+
+def test_validate_plan_with_string_enums():
+    """Test that a plan with mixed numeric/string fixed params validates OK."""
+    plan = _make_plan([
+        {"name": "Airgap", "value": 1.0, "unit": "mm"},
+        {"name": "Winding_Connection", "value": "Star", "unit": ""},
+        {"name": "Cooling_Type", "value": "Natural Convection", "unit": ""},
+        {"name": "Slot_Number", "value": 12, "unit": ""},
+    ])
+    ok, errors = validate_plan_dict(plan, require_model_path=False)
+    assert ok, f"Plan should validate OK, got errors: {errors}"
+    assert len(errors) == 0
+    print("PASS: test_validate_plan_with_string_enums")
+
+
+def test_generate_full_params_preserves_strings():
+    """Test that generate_full_params preserves string enum values in all points."""
+    plan = _make_plan([
+        {"name": "Airgap", "value": 1.0, "unit": "mm"},
+        {"name": "Winding_Connection", "value": "Star", "unit": ""},
+    ])
+    parsed = parse_plan(plan)
+    points = parsed.generate_full_params()
+    assert len(points) == 2  # 2 variable values
+    for pt in points:
+        assert pt["Winding_Connection"] == "Star", "String enum must be preserved"
+        assert pt["Airgap"] == 1.0
+        assert "Magnet_Thickness" in pt
+    print("PASS: test_generate_full_params_preserves_strings")
+
+
+def test_empty_fixed_params():
+    """Test plan with no fixed params (boundary case)."""
+    plan = _make_plan([])
+    ok, errors = validate_plan_dict(plan, require_model_path=False)
+    assert ok
+    parsed = parse_plan(plan)
+    points = parsed.generate_full_params()
+    assert len(points) == 2
+    print("PASS: test_empty_fixed_params")
+
+
+def test_ai_generated_plan_regression():
+    """Full regression test simulating the exact AI-generated plan that failed.
+
+    The AI generator produces Winding_Connection as a string enum. This test
+    ensures such plans can be parsed, validated, and expanded without the
+    'could not convert string to float' error.
+    """
+    ai_plan = {
+        "plan_id": "ai-generated-test",
+        "plan_version": "2.0",
+        "created_at": "2026-08-30T12:00:00",
+        "topology": "SSSR",
+        "model_path": "models/MARS-12S10P_SSSR.mot",
+        "fixed_params": [
+            {"name": "Outer_Rotor_Diameter", "value": 100.0, "unit": "mm"},
+            {"name": "Inner_Rotor_Diameter", "value": 40.0, "unit": "mm"},
+            {"name": "Airgap", "value": 1.0, "unit": "mm"},
+            {"name": "Slot_Number", "value": 12, "unit": ""},
+            {"name": "Pole_Number", "value": 10, "unit": ""},
+            {"name": "Winding_Connection", "value": "Star", "unit": ""},
+            {"name": "Cooling_Type", "value": "Natural Convection", "unit": ""},
+            {"name": "CurrentDefinition", "value": "Peak", "unit": ""},
+            {"name": "DCBusVoltage", "value": 48.0, "unit": "V"},
+        ],
+        "variables": [
+            {"name": "Magnet_Thickness", "values": [3, 4, 5], "unit": "mm"},
+            {"name": "Stator_Outer_Diameter", "values": [80, 90, 100], "unit": "mm"},
+        ],
+        "cases": [{"id": "default", "name": "Default", "params": {}}],
+        "output_metrics": ["tavg_nm", "ripple_pct", "efficiency_pct"],
+    }
+
+    # 1. Parse should not raise
+    parsed = parse_plan(ai_plan)
+    assert len(parsed.fixed_params) == 9
+    assert len(parsed.variables) == 2
+
+    # 2. Validate should pass
+    ok, errors = validate_plan_dict(ai_plan, require_model_path=False)
+    assert ok, f"Validation failed: {errors}"
+
+    # 3. Generate points should preserve string enums
+    points = parsed.generate_full_params()
+    assert len(points) == 9  # 3 * 3
+    for pt in points:
+        assert pt["Winding_Connection"] == "Star"
+        assert pt["Cooling_Type"] == "Natural Convection"
+        assert pt["CurrentDefinition"] == "Peak"
+        assert isinstance(pt["Outer_Rotor_Diameter"], float)
+        assert isinstance(pt["Slot_Number"], int)
+
+    print("PASS: test_ai_generated_plan_regression")
+
+
+def main():
+    tests = [
+        test_fixed_param_string_enum,
+        test_fixed_param_numeric_values,
+        test_fixed_param_to_dict_roundtrip,
+        test_validate_plan_with_string_enums,
+        test_generate_full_params_preserves_strings,
+        test_empty_fixed_params,
+        test_ai_generated_plan_regression,
+    ]
+
+    passed = 0
+    failed = 0
+    for test in tests:
+        try:
+            test()
+            passed += 1
+        except Exception as e:
+            print(f"FAIL: {test.__name__}: {e}")
+            failed += 1
+
+    print(f"\n{'='*50}")
+    print(f"Results: {passed} passed, {failed} failed, {len(tests)} total")
+    if failed > 0:
+        sys.exit(1)
+    else:
+        print("ALL TESTS PASSED")
+        sys.exit(0)
+
+
+if __name__ == "__main__":
+    main()

+ 199 - 0
scripts/test_platform_registry.py

@@ -0,0 +1,199 @@
+"""Platform P2 regression tests: topology registry + adapter-driven executor.
+
+Covers (docs/PLATFORM_DESIGN_V2.md P2):
+1. Topology registry: registration, lookup, parameter system, validation.
+2. plan_schema topology validation integration.
+3. Adapter registry + get_adapter("motorcad").
+4. MotorCADTaskExecutor wired through get_adapter (metrics flattened to
+   top level, FAILED points raise, _compute_metrics works) - via a fake
+   adapter, so NO real Motor-CAD is launched.
+
+Run:  python scripts/test_platform_registry.py
+Exit code 0 = all PASS. Pure ASCII source.
+"""
+import os
+import sys
+
+ROOT = os.path.dirname(os.path.abspath(__file__))
+PROJ = os.path.dirname(ROOT)
+sys.path.insert(0, ROOT)
+sys.path.insert(0, PROJ)
+sys.path.insert(0, os.path.join(PROJ, "src"))
+sys.path.insert(0, os.path.join(PROJ, "web", "backend"))
+
+PASS = 0
+FAIL = 0
+
+
+def check(name, cond, detail=""):
+    global PASS, FAIL
+    if cond:
+        PASS += 1
+        print("  [PASS] %s%s" % (name, (" - " + detail) if detail else ""))
+    else:
+        FAIL += 1
+        print("  [FAIL] %s%s" % (name, (" - " + detail) if detail else ""))
+
+
+def section(title):
+    print("\n== %s ==" % title)
+
+
+# ---------------------------------------------------------------------------
+section("1. Topology registry")
+from afmcore.topology import (  # noqa: E402
+    TOPOLOGY_REGISTRY,
+    get_topology,
+    get_topology_or_none,
+    list_topologies,
+    list_topology_codes,
+    is_supported,
+    is_active,
+    param_names_for,
+    validate_params,
+    to_dict,
+    register_topology,
+    TopologyDefinition,
+)
+
+check("registry has 3 topologies", len(TOPOLOGY_REGISTRY) == 3)
+check("codes sorted", list_topology_codes() == ["DRSS", "SDSR", "SSSR"])
+sssr = get_topology("SSSR")
+check("SSSR label_zh", sssr.label_zh == "\u5355\u5b9a\u5b50\u5355\u8f6c\u5b50", sssr.label_zh)
+check("SSSR active", is_active("SSSR"))
+check("SSSR 37 params", len(sssr.all_params()) == 37, str(len(sssr.all_params())))
+check("SSSR 8 groups", len(sssr.param_system) == 8)
+check("SSSR sizing", "Airgap" in sssr.sizing_params and "Magnet_Thickness" in sssr.sizing_params)
+check("SSSR scan vars", set(sssr.default_scan_vars) >= {"Airgap", "Magnet_Thickness", "RMSCurrent"})
+check("SSSR single airgap", sssr.airgap_count == 1 and sssr.stator_count == 1 and sssr.rotor_count == 1)
+
+drss = get_topology("drss")  # case-insensitive lookup
+check("DRSS planned + double airgap",
+      drss.status == "planned" and drss.airgap_count == 2 and drss.rotor_count == 2)
+check("DRSS not active", not is_active("DRSS"))
+check("SDSR planned", get_topology("SDSR").status == "planned")
+
+check("is_supported('XXX') False", not is_supported("XXX"))
+check("get_topology_or_none unknown None", get_topology_or_none("XXX") is None)
+
+r = validate_params("SSSR", {"Airgap": 1.0, "RMSCurrent": 15.0, "Foo": 3})
+check("validate known", set(r["known"]) == {"Airgap", "RMSCurrent"})
+check("validate unknown", r["unknown"] == ["Foo"])
+check("validate supported", r["unsupported"] is False)
+
+r2 = validate_params("XXX", {"Airgap": 1.0})
+check("unknown topology -> unsupported", r2["unsupported"] is True)
+
+snap = to_dict()
+check("to_dict has 3", len(snap) == 3)
+check("to_dict SSSR params", len(snap["SSSR"]["params"]) == 37)
+
+# custom registration (idempotent / override)
+register_topology(TopologyDefinition(code="SSSR", label_zh="x", label_en="y", status="active"))
+check("re-register same code overrides", get_topology("SSSR").label_en == "y")
+register_topology(TopologyDefinition(code="SSSR", label_zh="\u5355\u5b9a\u5b50\u5355\u8f6c\u5b50",
+                                     label_en="Single Stator Single Rotor", status="active",
+                                     param_system=sssr.param_system,
+                                     sizing_params=sssr.sizing_params,
+                                     default_scan_vars=sssr.default_scan_vars))
+check("SSSR restored", get_topology("SSSR").label_en == "Single Stator Single Rotor")
+
+# ---------------------------------------------------------------------------
+section("2. plan_schema topology validation")
+from src.plan_schema import SimulationPlan  # noqa: E402
+
+_, errs = SimulationPlan(topology="SSSR").validate()
+check("SSSR no topology error", all("topology not supported" not in e for e in errs), str(errs))
+_, errs = SimulationPlan(topology="TORUS").validate()
+check("TORUS rejected", any("topology not supported" in e for e in errs))
+_, errs = SimulationPlan(topology="DRSS").validate()
+check("DRSS accepted (registered)", all("topology not supported" not in e for e in errs))
+rt = SimulationPlan.from_dict(SimulationPlan(topology="SSSR").to_dict())
+check("serialization round-trip", rt.topology == "SSSR")
+
+# ---------------------------------------------------------------------------
+section("3. Adapter registry")
+from afmcore.adapters import (  # noqa: E402
+    SimulationAdapter,
+    register_adapter,
+    get_adapter,
+    registered_tools,
+)
+import afmcore.adapters.motorcad  # noqa: F401,E402  (registers "motorcad")
+
+check("motorcad registered", "motorcad" in registered_tools())
+mc = get_adapter("motorcad", model_path="models/x.mot")
+check("get_adapter returns SimulationAdapter", isinstance(mc, SimulationAdapter))
+check("adapter tool meta", mc.tool_name == "motorcad" and mc.tool_label == "Motor-CAD (ANSYS)")
+try:
+    get_adapter("maxwell")
+    check("unknown tool raises", False)
+except KeyError:
+    check("unknown tool raises", True)
+
+# ---------------------------------------------------------------------------
+section("4. MotorCADTaskExecutor through adapter (fake, no Motor-CAD)")
+CALLS = []
+
+
+class FakeAdapter(SimulationAdapter):
+    tool_name = "fake"
+
+    def __init__(self, **kw):
+        super().__init__(**kw)
+        self.points = 0
+
+    def connect(self):
+        CALLS.append("connect")
+
+    def disconnect(self):
+        CALLS.append("disconnect")
+
+    def load_model(self, model_path):
+        CALLS.append("load:" + model_path)
+
+    def set_parameter(self, name, value):
+        CALLS.append("set:%s" % name)
+
+    def run_simulation(self, mode="electromagnetic"):
+        CALLS.append("run")
+
+    def extract_metrics(self, output_dir, tag=""):
+        return {"metrics": {}, "status": "OK", "error": None, "raw_path": ""}
+
+    def run_point(self, model_path, params=None, output_dir="output", tag=""):
+        CALLS.append("run_point:" + str(tag))
+        self.points += 1
+        if tag == "fail":
+            return {"metrics": {}, "status": "FAILED", "error": "boom", "raw_path": ""}
+        return {"metrics": {"tavg_nm": 12.5 + self.points, "efficiency_pct": 93.1},
+                "status": "OK", "error": None, "raw_path": "x", "solve_time_s": 1.5}
+
+
+register_adapter("fake", FakeAdapter)
+
+from task_executor import MotorCADTaskExecutor  # noqa: E402
+
+ex = MotorCADTaskExecutor(model_path="models/x.mot", tool="fake", web_base_url="http://127.0.0.1:1")
+res = ex._run_simulation_point({"Airgap": 1.0}, 0)
+check("OK point flattened", res.get("tavg_nm") == 13.5 and res.get("status") == "OK", str(res))
+computed = ex._compute_metrics([res, res])
+check("compute_metrics mean", computed.get("tavg_nm_mean") == 13.5, str(computed))
+check("compute_metrics counts", computed["successful_points"] == 2 and computed["failed_points"] == 0)
+try:
+    ex._run_simulation_point({"Airgap": 1.0}, "fail")
+    check("FAILED point raises", False)
+except RuntimeError:
+    check("FAILED point raises", True)
+
+# execute_task end-to-end with 3 points (no real backend calls needed; adapter
+# drives the simulation; requests is available so progress reports just 404)
+ex.execute_task({"task_id": "T-P2", "parameters": [{"Airgap": 1.0}, {"Airgap": 1.5}, {"Airgap": 2.0}]})
+check("execute_task created adapter", ex._adapter is not None)
+ex.cleanup()
+check("cleanup disconnected", "disconnect" in CALLS and ex._adapter is None)
+
+# ---------------------------------------------------------------------------
+print("\n================================")
+print("PASS: %d   FAIL: %d" % (PASS, FAIL))
+sys.exit(0 if FAIL == 0 else 1)

+ 147 - 0
scripts/test_search_state_summary.py

@@ -0,0 +1,147 @@
+# -*- coding: utf-8 -*-
+"""P5-M3: unit tests for get_state_summary() batch_summary / l0_summary.
+
+Covers: happy path, post-report aggregation, empty search boundary,
+infeasible-point L0 reasons, objective direction (max/min), and
+unknown point_id report resilience.
+
+All source is ASCII only. Run: python scripts/test_search_state_summary.py
+exit 0 = PASS.
+"""
+import os
+import sys
+import unittest
+
+_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+_BACKEND = os.path.join(_ROOT, "web", "backend")
+sys.path.insert(0, _BACKEND)
+sys.path.insert(0, _ROOT)
+
+from app.services.feasibility_search import (  # noqa: E402
+    FeasibilityFirstSearch,
+    ParameterRange,
+)
+
+
+def _make_search(direction="maximize", metric="tavg_nm"):
+    """Build a minimal search with one parameter range."""
+    return FeasibilityFirstSearch(
+        parameters=[
+            ParameterRange(name="airgap_mm", min_value=0.5, max_value=2.0, step=0.1),
+        ],
+        total_budget=20,
+        batch_size=4,
+        initial_samples=8,
+        objective_metric=metric,
+        objective_direction=direction,
+        seed=42,
+    )
+
+
+class TestStateSummaryNewFields(unittest.TestCase):
+    """P5-M3: batch_summary / l0_summary / infeasible / failed fields."""
+
+    def test_initial_batch_has_new_fields(self):
+        """Happy path: after generate_initial_batch, all new fields present."""
+        s = _make_search()
+        s.generate_initial_batch()
+        state = s.get_state_summary()
+        for key in ("infeasible_points", "failed_points", "batch_summary", "l0_summary"):
+            self.assertIn(key, state, "missing key: %s" % key)
+        self.assertIsInstance(state["batch_summary"], list)
+        self.assertGreater(len(state["batch_summary"]), 0, "at least batch 0")
+        b0 = state["batch_summary"][0]
+        for key in ("batch_id", "total", "pending", "ok", "infeasible", "failed", "best_objective"):
+            self.assertIn(key, b0, "batch_summary entry missing: %s" % key)
+        self.assertEqual(b0["total"], b0["pending"] + b0["ok"] + b0["infeasible"] + b0["failed"])
+        l0 = state["l0_summary"]
+        for key in ("sampled", "feasible", "infeasible", "pass_rate", "top_infeasible_reasons"):
+            self.assertIn(key, l0, "l0_summary missing: %s" % key)
+        self.assertEqual(l0["sampled"], state["total_points"])
+        self.assertEqual(l0["feasible"] + l0["infeasible"], l0["sampled"])
+
+    def test_report_results_updates_batch_summary(self):
+        """Report ok results: batch_summary ok count rises, best_objective set."""
+        s = _make_search(direction="maximize")
+        pts = s.generate_initial_batch()
+        pending = [p for p in pts if p.status == "pending"]
+        self.assertGreater(len(pending), 0, "need at least one pending point")
+        target = pending[0]
+        s.report_result(target.id, {"tavg_nm": 1.5}, "ok")
+        state = s.get_state_summary()
+        b0 = state["batch_summary"][0]
+        self.assertEqual(b0["ok"], 1)
+        self.assertEqual(b0["best_objective"], 1.5)
+        self.assertEqual(state["completed_points"], 1)
+
+    def test_minimize_direction_best_objective(self):
+        """Minimize: best_objective is the minimum reported value."""
+        s = _make_search(direction="minimize", metric="total_losses_w")
+        pts = s.generate_initial_batch()
+        pending = [p for p in pts if p.status == "pending"]
+        self.assertGreaterEqual(len(pending), 2, "need 2 pending points")
+        s.report_result(pending[0].id, {"total_losses_w": 50.0}, "ok")
+        s.report_result(pending[1].id, {"total_losses_w": 30.0}, "ok")
+        state = s.get_state_summary()
+        self.assertEqual(state["batch_summary"][0]["best_objective"], 30.0)
+
+    def test_failed_point_counted(self):
+        """Report failed: failed_points increments, batch_summary failed count."""
+        s = _make_search()
+        pts = s.generate_initial_batch()
+        pending = [p for p in pts if p.status == "pending"]
+        self.assertGreater(len(pending), 0)
+        s.report_result(pending[0].id, {}, "failed")
+        state = s.get_state_summary()
+        self.assertEqual(state["failed_points"], 1)
+        self.assertEqual(state["batch_summary"][0]["failed"], 1)
+
+    def test_empty_search_boundary(self):
+        """Boundary: search created but no batch generated -> empty summaries."""
+        s = _make_search()
+        state = s.get_state_summary()
+        self.assertEqual(state["total_points"], 0)
+        self.assertEqual(state["batch_summary"], [])
+        self.assertEqual(state["l0_summary"]["sampled"], 0)
+        self.assertEqual(state["l0_summary"]["pass_rate"], 0.0)
+        self.assertEqual(state["infeasible_points"], 0)
+        self.assertEqual(state["failed_points"], 0)
+
+    def test_infeasible_points_l0_reasons_structure(self):
+        """Infeasible points (if any) carry top_infeasible_reasons with name/count/category."""
+        s = _make_search()
+        s.generate_initial_batch()
+        state = s.get_state_summary()
+        reasons = state["l0_summary"]["top_infeasible_reasons"]
+        self.assertIsInstance(reasons, list)
+        for r in reasons:
+            self.assertIn("name", r)
+            self.assertIn("count", r)
+            self.assertIn("category", r)
+            self.assertGreaterEqual(r["count"], 1)
+
+    def test_unknown_point_id_report_no_crash(self):
+        """Resilience: report_result for unknown point_id does not raise."""
+        s = _make_search()
+        s.generate_initial_batch()
+        # Should not raise; implementation may silently ignore or log.
+        try:
+            s.report_result(999999, {"tavg_nm": 1.0}, "ok")
+        except Exception as exc:
+            self.fail("report_result unknown id raised: %s" % exc)
+        state = s.get_state_summary()
+        # Unknown point must not inflate completed counts.
+        self.assertEqual(state["completed_points"], 0)
+
+    def test_batch_summary_sorted_by_batch_id(self):
+        """batch_summary entries sorted ascending by batch_id."""
+        s = _make_search()
+        s.generate_initial_batch()
+        s.select_next_batch()
+        state = s.get_state_summary()
+        ids = [b["batch_id"] for b in state["batch_summary"]]
+        self.assertEqual(ids, sorted(ids))
+
+
+if __name__ == "__main__":
+    unittest.main(verbosity=2)

+ 199 - 0
scripts/test_strategy_morris.py

@@ -0,0 +1,199 @@
+"""P5-M4: unit tests for MorrisStrategy.
+
+Covers: happy path (sensitivity ranking on linear function), boundary
+(empty params / single trajectory / odd n_levels auto-corrected), anomaly
+(unknown point_id report / missing objective metric), null inputs, registry
+integration, and state() field contract.
+
+All source is ASCII only. Run: python scripts/test_strategy_morris.py
+exit 0 = PASS.
+"""
+import os
+import sys
+import unittest
+
+_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(_ROOT, "src"))
+
+from afmcore.strategies import (  # noqa: E402
+    MorrisStrategy,
+    get_strategy,
+    is_registered,
+    list_strategy_kinds,
+)
+
+
+def _linear_objective(params):
+    """y = 2*a + 0.5*b -> parameter 'a' should rank higher than 'b'."""
+    return 2.0 * params.get("a", 0.0) + 0.5 * params.get("b", 0.0)
+
+
+class TestMorrisHappyPath(unittest.TestCase):
+    def test_trajectory_point_count(self):
+        s = MorrisStrategy(
+            parameters=[{"name": "a", "min_value": 0, "max_value": 1},
+                        {"name": "b", "min_value": 0, "max_value": 1}],
+            n_trajectories=5, n_levels=4, objective_metric="y", rng_seed=1,
+        )
+        pts = s.select_next(1000)
+        # n_trajectories * (n_params + 1) = 5 * 3 = 15
+        self.assertEqual(len(pts), 15)
+
+    def test_step_zero_has_no_changed_param(self):
+        s = MorrisStrategy(
+            parameters=[{"name": "a", "min_value": 0, "max_value": 1}],
+            n_trajectories=2, n_levels=4, objective_metric="y", rng_seed=2,
+        )
+        pts = s.select_next(1000)
+        step0 = [p for p in pts if p["step"] == 0]
+        self.assertEqual(len(step0), 2)
+        for p in step0:
+            self.assertIsNone(p["changed_param"])
+
+    def test_sensitivity_ranking_linear(self):
+        s = MorrisStrategy(
+            parameters=[{"name": "a", "min_value": 0, "max_value": 1},
+                        {"name": "b", "min_value": 0, "max_value": 1}],
+            n_trajectories=8, n_levels=4, objective_metric="y", rng_seed=3,
+        )
+        pts = s.select_next(1000)
+        for p in pts:
+            s.report(p["point_id"], {"y": _linear_objective(p["params"])}, "ok")
+        st = s.state()
+        ranking = st["sensitivity_ranking"]
+        self.assertEqual(len(ranking), 2)
+        # 'a' should rank first (higher mu_star)
+        self.assertEqual(ranking[0]["parameter"], "a")
+        self.assertGreater(ranking[0]["mu_star"], ranking[1]["mu_star"])
+        # linear function -> sigma near zero
+        self.assertAlmostEqual(ranking[0]["sigma"], 0.0, places=6)
+
+    def test_converged_after_all_reported(self):
+        s = MorrisStrategy(
+            parameters=[{"name": "a", "min_value": 0, "max_value": 1}],
+            n_trajectories=3, n_levels=4, objective_metric="y", rng_seed=4,
+        )
+        # take only one point first -> pending still non-empty -> not converged
+        first = s.select_next(1)
+        self.assertEqual(len(first), 1)
+        self.assertFalse(s.is_converged())
+        # take the rest and report all
+        rest = s.select_next(1000)
+        all_pts = first + rest
+        for p in all_pts:
+            s.report(p["point_id"], {"y": 1.0}, "ok")
+        self.assertTrue(s.is_converged())
+
+
+class TestMorrisBoundary(unittest.TestCase):
+    def test_empty_parameters(self):
+        s = MorrisStrategy(parameters=[], n_trajectories=5, objective_metric="y")
+        pts = s.select_next(1000)
+        self.assertEqual(pts, [])
+        self.assertTrue(s.is_converged())
+        st = s.state()
+        self.assertEqual(st["n_parameters"], 0)
+        self.assertEqual(st["sensitivity_ranking"], [])
+
+    def test_none_parameters(self):
+        s = MorrisStrategy(parameters=None, n_trajectories=3, objective_metric="y")
+        self.assertEqual(s.select_next(1000), [])
+
+    def test_single_trajectory(self):
+        s = MorrisStrategy(
+            parameters=[{"name": "a", "min_value": 0, "max_value": 1},
+                        {"name": "b", "min_value": 0, "max_value": 1}],
+            n_trajectories=1, n_levels=4, objective_metric="y", rng_seed=5,
+        )
+        pts = s.select_next(1000)
+        self.assertEqual(len(pts), 3)  # 1 * (2+1)
+
+    def test_odd_n_levels_auto_corrected(self):
+        s = MorrisStrategy(
+            parameters=[{"name": "a", "min_value": 0, "max_value": 1}],
+            n_trajectories=1, n_levels=5, objective_metric="y", rng_seed=6,
+        )
+        self.assertEqual(s.n_levels, 6)  # odd -> next even
+        self.assertAlmostEqual(s._delta, 6 / (2 * 5), places=6)
+
+    def test_params_within_bounds(self):
+        s = MorrisStrategy(
+            parameters=[{"name": "a", "min_value": 0.5, "max_value": 2.0, "step": 0.1},
+                        {"name": "b", "min_value": 10, "max_value": 20}],
+            n_trajectories=4, n_levels=4, objective_metric="y", rng_seed=7,
+        )
+        pts = s.select_next(1000)
+        for p in pts:
+            self.assertGreaterEqual(p["params"]["a"], 0.5)
+            self.assertLessEqual(p["params"]["a"], 2.0)
+            self.assertGreaterEqual(p["params"]["b"], 10)
+            self.assertLessEqual(p["params"]["b"], 20)
+
+
+class TestMorrisAnomaly(unittest.TestCase):
+    def test_report_unknown_point_id_no_crash(self):
+        s = MorrisStrategy(
+            parameters=[{"name": "a", "min_value": 0, "max_value": 1}],
+            n_trajectories=2, n_levels=4, objective_metric="y", rng_seed=8,
+        )
+        s.select_next(1000)
+        # should not raise
+        s.report(999999, {"y": 1.0}, "ok")
+        # state() must remain callable and well-formed
+        st = s.state()
+        self.assertIn("sensitivity_ranking", st)
+
+    def test_missing_objective_metric_yields_zero_sensitivity(self):
+        s = MorrisStrategy(
+            parameters=[{"name": "a", "min_value": 0, "max_value": 1}],
+            n_trajectories=3, n_levels=4, objective_metric="nonexistent", rng_seed=9,
+        )
+        pts = s.select_next(1000)
+        for p in pts:
+            s.report(p["point_id"], {"y": 1.0}, "ok")  # wrong metric key
+        st = s.state()
+        for r in st["sensitivity_ranking"]:
+            self.assertEqual(r["mu_star"], 0.0)
+            self.assertEqual(r["n_effects"], 0)
+
+    def test_failed_status_points_excluded_from_effects(self):
+        s = MorrisStrategy(
+            parameters=[{"name": "a", "min_value": 0, "max_value": 1}],
+            n_trajectories=2, n_levels=4, objective_metric="y", rng_seed=10,
+        )
+        pts = s.select_next(1000)
+        # report first point as failed, rest as ok
+        s.report(pts[0]["point_id"], {"y": 999.0}, "failed")
+        for p in pts[1:]:
+            s.report(p["point_id"], {"y": _linear_objective(p["params"])}, "ok")
+        st = s.state()
+        # failed point's trajectory may be partially excluded; no crash
+        self.assertIn("sensitivity_ranking", st)
+
+
+class TestMorrisRegistry(unittest.TestCase):
+    def test_registered(self):
+        self.assertTrue(is_registered("morris"))
+        self.assertIn("morris", list_strategy_kinds())
+
+    def test_get_strategy_creates_instance(self):
+        s = get_strategy("morris", parameters=[{"name": "x", "min_value": 0, "max_value": 1}],
+                          n_trajectories=2, objective_metric="y", rng_seed=11)
+        self.assertIsInstance(s, MorrisStrategy)
+        self.assertEqual(s.kind, "morris")
+
+    def test_state_field_contract(self):
+        s = MorrisStrategy(
+            parameters=[{"name": "a", "min_value": 0, "max_value": 1}],
+            n_trajectories=2, n_levels=4, objective_metric="y", rng_seed=12,
+        )
+        st = s.state()
+        for key in ("kind", "batch_size", "n_parameters", "n_trajectories",
+                     "n_levels", "delta", "objective_metric", "total_points",
+                     "pending", "reported", "sensitivity_ranking", "key_parameters"):
+            self.assertIn(key, st, "missing state field: %s" % key)
+        self.assertEqual(st["kind"], "morris")
+
+
+if __name__ == "__main__":
+    unittest.main(verbosity=2)

+ 255 - 0
scripts/test_strategy_surrogate.py

@@ -0,0 +1,255 @@
+"""P5-M4: unit tests for SurrogateGuidedStrategy.
+
+Covers: happy path (initial LHS + surrogate-guided convergence on bowl
+function), boundary (empty params / budget exhaustion / n_initial > budget),
+anomaly (unknown point_id / missing objective), null inputs, budget-adaptive
+batch sizing, both maximize and minimize directions, registry integration,
+and state() field contract.
+
+All source is ASCII only. Run: python scripts/test_strategy_surrogate.py
+exit 0 = PASS.
+"""
+import os
+import sys
+import unittest
+
+_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(_ROOT, "src"))
+
+from afmcore.strategies import (  # noqa: E402
+    SurrogateGuidedStrategy,
+    get_strategy,
+    is_registered,
+    list_strategy_kinds,
+)
+
+
+def _bowl(params):
+    """Convex bowl: y = (a-0.5)^2 + (b-0.5)^2, minimum at (0.5, 0.5)."""
+    return (params.get("a", 0.0) - 0.5) ** 2 + (params.get("b", 0.0) - 0.5) ** 2
+
+
+def _hill(params):
+    """Inverse bowl: y = 1 - bowl, maximum at (0.5, 0.5)."""
+    return 1.0 - _bowl(params)
+
+
+class TestSurrogateHappyPath(unittest.TestCase):
+    def test_initial_lhs_batch(self):
+        s = SurrogateGuidedStrategy(
+            parameters=[{"name": "a", "min_value": 0, "max_value": 1},
+                        {"name": "b", "min_value": 0, "max_value": 1}],
+            objective_metric="y", objective_direction="minimize",
+            n_initial=8, batch_size=4, budget=20, rng_seed=1,
+        )
+        self.assertEqual(s.state()["phase"], "initial")
+        batch = s.select_next(1000)
+        self.assertEqual(len(batch), 8)
+        for p in batch:
+            self.assertIn("point_id", p)
+            self.assertIn("params", p)
+            self.assertIn("a", p["params"])
+            self.assertIn("b", p["params"])
+
+    def test_convergence_on_bowl_minimize(self):
+        s = SurrogateGuidedStrategy(
+            parameters=[{"name": "a", "min_value": 0, "max_value": 1},
+                        {"name": "b", "min_value": 0, "max_value": 1}],
+            objective_metric="y", objective_direction="minimize",
+            n_initial=10, batch_size=3, max_batch_size=5, budget=40,
+            n_candidates=60, rng_seed=2,
+        )
+        # run full budget
+        used = 0
+        while used < 40:
+            batch = s.select_next()
+            if not batch:
+                break
+            for p in batch:
+                s.report(p["point_id"], {"y": _bowl(p["params"])}, "ok")
+                used += 1
+        best = min(r["metrics"]["y"] for r in s._done.values() if r["status"] == "ok")
+        # should find a point reasonably close to the minimum (0.0)
+        self.assertLess(best, 0.05)
+        self.assertEqual(s.state()["phase"], "surrogate_guided")
+
+    def test_maximize_direction(self):
+        s = SurrogateGuidedStrategy(
+            parameters=[{"name": "a", "min_value": 0, "max_value": 1},
+                        {"name": "b", "min_value": 0, "max_value": 1}],
+            objective_metric="y", objective_direction="maximize",
+            n_initial=8, batch_size=3, budget=25, n_candidates=50, rng_seed=3,
+        )
+        used = 0
+        while used < 25:
+            batch = s.select_next()
+            if not batch:
+                break
+            for p in batch:
+                s.report(p["point_id"], {"y": _hill(p["params"])}, "ok")
+                used += 1
+        best = max(r["metrics"]["y"] for r in s._done.values() if r["status"] == "ok")
+        # hill maximum is 1.0
+        self.assertGreater(best, 0.95)
+
+
+class TestSurrogateBoundary(unittest.TestCase):
+    def test_empty_parameters(self):
+        s = SurrogateGuidedStrategy(parameters=[], n_initial=5, budget=10, objective_metric="y")
+        batch = s.select_next(1000)
+        self.assertEqual(batch, [])
+        self.assertTrue(s.is_converged())
+
+    def test_none_parameters(self):
+        s = SurrogateGuidedStrategy(parameters=None, n_initial=5, budget=10, objective_metric="y")
+        self.assertEqual(s.select_next(1000), [])
+
+    def test_budget_exhaustion(self):
+        s = SurrogateGuidedStrategy(
+            parameters=[{"name": "a", "min_value": 0, "max_value": 1}],
+            objective_metric="y", objective_direction="minimize",
+            n_initial=3, batch_size=2, budget=5, rng_seed=4,
+        )
+        used = 0
+        while True:
+            batch = s.select_next()
+            if not batch:
+                break
+            for p in batch:
+                s.report(p["point_id"], {"y": p["params"]["a"] ** 2}, "ok")
+                used += 1
+        self.assertLessEqual(used, 5)
+        self.assertTrue(s.is_converged())
+        self.assertEqual(s.state()["phase"], "exhausted")
+
+    def test_n_initial_greater_than_budget(self):
+        s = SurrogateGuidedStrategy(
+            parameters=[{"name": "a", "min_value": 0, "max_value": 1}],
+            objective_metric="y", n_initial=20, batch_size=4, budget=5, rng_seed=5,
+        )
+        batch = s.select_next(1000)
+        # initial pending is 20 but budget is 5; select_next serves pending
+        # regardless (pending points were already generated)
+        self.assertEqual(len(batch), 20)
+
+    def test_adaptive_batch_size_within_bounds(self):
+        s = SurrogateGuidedStrategy(
+            parameters=[{"name": "a", "min_value": 0, "max_value": 1},
+                        {"name": "b", "min_value": 0, "max_value": 1}],
+            objective_metric="y", objective_direction="minimize",
+            n_initial=6, batch_size=2, max_batch_size=6, budget=30,
+            n_candidates=40, rng_seed=6,
+        )
+        # initial
+        batch = s.select_next(1000)
+        for p in batch:
+            s.report(p["point_id"], {"y": _bowl(p["params"])}, "ok")
+        # surrogate batches
+        for _ in range(4):
+            b = s.select_next()
+            if not b:
+                break
+            self.assertGreaterEqual(len(b), 1)
+            self.assertLessEqual(len(b), 6)
+            for p in b:
+                s.report(p["point_id"], {"y": _bowl(p["params"])}, "ok")
+
+
+class TestSurrogateAnomaly(unittest.TestCase):
+    def test_report_unknown_point_id_no_crash(self):
+        s = SurrogateGuidedStrategy(
+            parameters=[{"name": "a", "min_value": 0, "max_value": 1}],
+            objective_metric="y", n_initial=3, budget=10, rng_seed=7,
+        )
+        s.select_next(1000)
+        s.report(999999, {"y": 1.0}, "ok")  # should not raise
+        # state() must remain callable and well-formed
+        st = s.state()
+        self.assertIn("surrogate", st)
+
+    def test_missing_objective_metric_excluded_from_training(self):
+        s = SurrogateGuidedStrategy(
+            parameters=[{"name": "a", "min_value": 0, "max_value": 1}],
+            objective_metric="nonexistent", n_initial=3, budget=10, rng_seed=8,
+        )
+        batch = s.select_next(1000)
+        for p in batch:
+            s.report(p["point_id"], {"y": 0.5}, "ok")  # wrong metric
+        # surrogate should have 0 training points (no objective values)
+        train = s._training_data()
+        self.assertEqual(len(train), 0)
+
+    def test_failed_status_excluded_from_training(self):
+        s = SurrogateGuidedStrategy(
+            parameters=[{"name": "a", "min_value": 0, "max_value": 1}],
+            objective_metric="y", n_initial=4, budget=10, rng_seed=9,
+        )
+        batch = s.select_next(1000)
+        s.report(batch[0]["point_id"], {"y": 999.0}, "failed")
+        for p in batch[1:]:
+            s.report(p["point_id"], {"y": 0.5}, "ok")
+        train = s._training_data()
+        self.assertEqual(len(train), 3)  # failed excluded
+
+
+class TestSurrogateRegistry(unittest.TestCase):
+    def test_registered(self):
+        self.assertTrue(is_registered("surrogate_guided"))
+        self.assertIn("surrogate_guided", list_strategy_kinds())
+
+    def test_get_strategy_creates_instance(self):
+        s = get_strategy("surrogate_guided",
+                          parameters=[{"name": "x", "min_value": 0, "max_value": 1}],
+                          objective_metric="y", budget=10, rng_seed=10)
+        self.assertIsInstance(s, SurrogateGuidedStrategy)
+        self.assertEqual(s.kind, "surrogate_guided")
+
+    def test_state_field_contract(self):
+        s = SurrogateGuidedStrategy(
+            parameters=[{"name": "a", "min_value": 0, "max_value": 1}],
+            objective_metric="y", n_initial=3, budget=10, rng_seed=11,
+        )
+        st = s.state()
+        for key in ("kind", "batch_size", "max_batch_size", "budget", "used_budget",
+                     "remaining_budget", "n_initial", "n_parameters", "objective_metric",
+                     "objective_direction", "phase", "pending", "reported",
+                     "last_batch_size", "surrogate", "idw_power", "kappa"):
+            self.assertIn(key, st, "missing state field: %s" % key)
+        self.assertEqual(st["kind"], "surrogate_guided")
+        self.assertIn("n_train", st["surrogate"])
+
+
+class TestSurrogateIDWInternals(unittest.TestCase):
+    def test_idw_prediction_interpolation(self):
+        s = SurrogateGuidedStrategy(
+            parameters=[{"name": "a", "min_value": 0, "max_value": 1}],
+            objective_metric="y", n_initial=0, budget=10, rng_seed=12,
+        )
+        train = [((0.0,), 10.0), ((1.0,), 20.0)]
+        pred, unc = s._idw_predict((0.5,), train)
+        # midpoint should be between 10 and 20
+        self.assertGreater(pred, 10.0)
+        self.assertLess(pred, 20.0)
+        self.assertGreater(unc, 0.0)
+
+    def test_distance_calculation(self):
+        d = SurrogateGuidedStrategy._distance((0.0, 0.0), (3.0, 4.0))
+        self.assertAlmostEqual(d, 5.0, places=6)
+
+    def test_normalize_denormalize_roundtrip(self):
+        s = SurrogateGuidedStrategy(
+            parameters=[{"name": "a", "min_value": 0, "max_value": 10},
+                        {"name": "b", "min_value": -5, "max_value": 5}],
+            objective_metric="y", n_initial=0, budget=10, rng_seed=13,
+        )
+        phys = {"a": 5.0, "b": 0.0}
+        norm = s._normalize(phys)
+        self.assertAlmostEqual(norm[0], 0.5, places=6)
+        self.assertAlmostEqual(norm[1], 0.5, places=6)
+        back = s._denormalize(norm)
+        self.assertAlmostEqual(back["a"], 5.0, places=6)
+        self.assertAlmostEqual(back["b"], 0.0, places=6)
+
+
+if __name__ == "__main__":
+    unittest.main(verbosity=2)

+ 277 - 0
scripts/test_topology_variable_map.py

@@ -0,0 +1,277 @@
+"""Tests for topology-aware Motor-CAD variable name mapping.
+
+Covers:
+- Topology normalization
+- RFM -> AFM alias resolution (the plan 23 bug)
+- Known variable validation
+- Parameter validation with suggestions
+- Boundary cases (empty, unknown topology, unknown variable)
+
+Note (2026-09-03): AFM target names were corrected to the MARS-verified
+names measured live via pymotorcad on MARS-12S10P_SSSR:
+Stator_Outer_Diameter -> Stator_Lam_Dia, Stator_Inner_Diameter -> Stator_Bore,
+Outer_Rotor_Diameter -> RotorOuterDiameter, Rotor_Back_Iron_Thickness ->
+Back_Iron_Thickness. Assertions below use the verified names.
+
+Run: python scripts/test_topology_variable_map.py
+Exit 0 = PASS, non-zero = FAIL.
+"""
+import sys
+import os
+
+# Add backend to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "web", "backend"))
+
+from app.services.topology_variable_map import (
+    TOPOLOGY_SSSR,
+    TOPOLOGY_AFIR,
+    TOPOLOGY_RFM,
+    normalize_topology,
+    resolve_variable,
+    is_known_variable,
+    validate_parameters,
+    suggest_alternative,
+    get_known_variables,
+)
+
+
+def test_normalize_topology():
+    """Test topology string normalization."""
+    assert normalize_topology("SSSR") == TOPOLOGY_SSSR
+    assert normalize_topology("sssr") == TOPOLOGY_SSSR
+    assert normalize_topology("AFIR") == TOPOLOGY_AFIR
+    assert normalize_topology("RFM") == TOPOLOGY_RFM
+    assert normalize_topology("AFM") == TOPOLOGY_SSSR
+    assert normalize_topology("axial") == TOPOLOGY_SSSR
+    assert normalize_topology("radial") == TOPOLOGY_RFM
+    assert normalize_topology(None) == TOPOLOGY_SSSR
+    assert normalize_topology("") == TOPOLOGY_SSSR
+    assert normalize_topology("UNKNOWN") == TOPOLOGY_SSSR  # default
+    print("PASS: test_normalize_topology")
+
+
+def test_rfm_to_afm_alias_resolution():
+    """Test that RFM variable names are mapped to AFM names on SSSR topology.
+
+    This is the core fix for plan 23: Stator_Lam_Outer_Dia (RFM) must be
+    resolved to Stator_Lam_Dia (MARS-verified AFM name) when topology is SSSR.
+    """
+    # RFM names on SSSR topology -> AFM names
+    resolved, was_alias = resolve_variable("Stator_Lam_Outer_Dia", TOPOLOGY_SSSR)
+    assert resolved == "Stator_Lam_Dia", f"Expected Stator_Lam_Dia, got {resolved}"
+    assert was_alias is True
+
+    resolved, was_alias = resolve_variable("Stator_Lam_Inner_Dia", TOPOLOGY_SSSR)
+    assert resolved == "Stator_Bore"
+    assert was_alias is True
+
+    resolved, was_alias = resolve_variable("Stator_Yoke_Width", TOPOLOGY_SSSR)
+    assert resolved == "Stator_Yoke_Thickness"
+    assert was_alias is True
+
+    # Same RFM names on RFM topology -> unchanged
+    resolved, was_alias = resolve_variable("Stator_Lam_Outer_Dia", TOPOLOGY_RFM)
+    assert resolved == "Stator_Lam_Outer_Dia"
+    assert was_alias is False
+
+    # AFIR topology also maps to AFM names
+    resolved, was_alias = resolve_variable("Stator_Lam_Outer_Dia", TOPOLOGY_AFIR)
+    assert resolved == "Stator_Lam_Dia"
+    assert was_alias is True
+
+    print("PASS: test_rfm_to_afm_alias_resolution")
+
+
+def test_template_logical_name_mapping():
+    """Test that template logical names map to Motor-CAD actual names."""
+    resolved, was_alias = resolve_variable("Number_of_Slots", TOPOLOGY_SSSR)
+    assert resolved == "Slot_Number"
+    assert was_alias is True
+
+    resolved, was_alias = resolve_variable("Number_of_Poles", TOPOLOGY_SSSR)
+    assert resolved == "Pole_Number"
+    assert was_alias is True
+
+    resolved, was_alias = resolve_variable("DC_Link_Voltage", TOPOLOGY_SSSR)
+    assert resolved == "DCBusVoltage"
+    assert was_alias is True
+
+    resolved, was_alias = resolve_variable("Turns_per_Coil", TOPOLOGY_SSSR)
+    assert resolved == "ConductorsPerSlot"
+    assert was_alias is True
+
+    print("PASS: test_template_logical_name_mapping")
+
+
+def test_known_variable_validation():
+    """Test known variable detection per topology."""
+    # AFM variables known on SSSR (MARS-verified names)
+    assert is_known_variable("Stator_Lam_Dia", TOPOLOGY_SSSR) is True
+    assert is_known_variable("Stator_Bore", TOPOLOGY_SSSR) is True
+    assert is_known_variable("RotorOuterDiameter", TOPOLOGY_SSSR) is True
+    assert is_known_variable("Airgap", TOPOLOGY_SSSR) is True
+    assert is_known_variable("Slot_Number", TOPOLOGY_SSSR) is True
+    assert is_known_variable("Magnet_Thickness", TOPOLOGY_SSSR) is True
+
+    # Deprecated wrong names (radial-template naming) are now UNKNOWN
+    assert is_known_variable("Stator_Outer_Diameter", TOPOLOGY_SSSR) is False
+    assert is_known_variable("Stator_Inner_Diameter", TOPOLOGY_SSSR) is False
+    assert is_known_variable("Outer_Rotor_Diameter", TOPOLOGY_SSSR) is False
+
+    # RFM variable NOT known on SSSR
+    assert is_known_variable("Stator_Lam_Outer_Dia", TOPOLOGY_SSSR) is False
+
+    # Unknown variable
+    assert is_known_variable("NonExistent_Var", TOPOLOGY_SSSR) is False
+
+    # Empty / None
+    assert is_known_variable("", TOPOLOGY_SSSR) is False
+
+    print("PASS: test_known_variable_validation")
+
+
+def test_validate_parameters():
+    """Test full parameter validation with classification."""
+    params = {
+        "Stator_Lam_Dia": 100.0,  # known AFM (MARS-verified)
+        "Airgap": 1.0,  # known
+        "Stator_Lam_Outer_Dia": 80.0,  # RFM alias - should be resolved
+        "NonExistent": 42.0,  # unknown
+    }
+
+    result = validate_parameters(params, TOPOLOGY_SSSR)
+
+    # RFM alias should be resolved to the MARS-verified AFM name
+    resolved_names = [r[0] for r in result["valid"]]
+    assert "Stator_Lam_Dia" in resolved_names
+    assert "Airgap" in resolved_names
+    assert "Stator_Lam_Outer_Dia" not in resolved_names  # should be resolved away
+
+    # Unknown variable should be flagged
+    unknown_names = [r[0] for r in result["unknown"]]
+    assert "NonExistent" in unknown_names
+
+    # resolved_params should have all parameters with resolved names
+    assert "Stator_Lam_Dia" in result["resolved_params"]
+    assert "NonExistent" in result["resolved_params"]
+
+    print("PASS: test_validate_parameters")
+
+
+def test_suggest_alternative():
+    """Test suggestion of close variable names."""
+    suggestion = suggest_alternative("Stator_Lam_Outer_Dia", TOPOLOGY_SSSR)
+    assert suggestion is not None
+    assert "Stator" in suggestion
+    assert "Lam" in suggestion or "Dia" in suggestion
+
+    # Completely unrelated name should return None
+    suggestion = suggest_alternative("xyz_abc_123", TOPOLOGY_SSSR)
+    # May or may not return something, just ensure no crash
+    assert suggestion is None or isinstance(suggestion, str)
+
+    print("PASS: test_suggest_alternative")
+
+
+def test_get_known_variables():
+    """Test retrieval of known variable set."""
+    sssr_vars = get_known_variables(TOPOLOGY_SSSR)
+    assert isinstance(sssr_vars, set)
+    assert len(sssr_vars) > 30  # should have substantial coverage
+    assert "Stator_Lam_Dia" in sssr_vars
+    assert "Airgap" in sssr_vars
+
+    # AFIR should share SSSR variables
+    afir_vars = get_known_variables(TOPOLOGY_AFIR)
+    assert "Stator_Lam_Dia" in afir_vars
+
+    print("PASS: test_get_known_variables")
+
+
+def test_plan23_regression():
+    """Regression test for the exact plan 23 failure scenario.
+
+    Plan 23 had scan variables:
+    - Magnet_Thickness (valid)
+    - Stator_Lam_Outer_Dia (RFM name -> should map to Stator_Lam_Dia)
+    - Stator_Lam_Inner_Dia (RFM name -> should map to Stator_Bore)
+
+    After expansion, NO Stator_Lam_* names should remain, and all variables
+    should be known for SSSR topology.
+    """
+    from app.routers.plans import _expand_plan_to_parameters
+
+    plan_data = {
+        "topology": "SSSR",
+        "fixed_params": [
+            {"name": "Airgap", "value": 1, "source": "user"},
+            {"name": "Number_of_Slots", "value": 12, "source": "user"},
+            {"name": "Number_of_Poles", "value": 10, "source": "user"},
+        ],
+        "variables": [
+            {"name": "Magnet_Thickness", "values": [2, 3, 4, 5]},
+            {"name": "Stator_Lam_Outer_Dia", "values": [80, 85, 90, 95, 100]},
+            {"name": "Stator_Lam_Inner_Dia", "values": [45, 50, 55, 60]},
+        ],
+    }
+
+    parameters = _expand_plan_to_parameters(plan_data)
+    assert len(parameters) == 80  # 4 * 5 * 4
+
+    # Check first point
+    point0 = parameters[0]
+
+    # RFM names should NOT be present
+    assert "Stator_Lam_Outer_Dia" not in point0, "RFM name Stator_Lam_Outer_Dia should be resolved"
+    assert "Stator_Lam_Inner_Dia" not in point0, "RFM name Stator_Lam_Inner_Dia should be resolved"
+
+    # MARS-verified AFM names SHOULD be present
+    assert "Stator_Lam_Dia" in point0, "AFM name Stator_Lam_Dia should be present"
+    assert "Stator_Bore" in point0, "AFM name Stator_Bore should be present"
+
+    # Values should be correct
+    assert point0["Stator_Lam_Dia"] == 80
+    assert point0["Stator_Bore"] == 45
+    assert point0["Magnet_Thickness"] == 2
+
+    # All variables should be known for SSSR
+    from app.services.topology_variable_map import is_known_variable
+    unknown = [k for k in point0.keys() if not is_known_variable(k, "SSSR")]
+    assert len(unknown) == 0, f"Unknown variables found: {unknown}"
+
+    print("PASS: test_plan23_regression")
+
+
+def main():
+    tests = [
+        test_normalize_topology,
+        test_rfm_to_afm_alias_resolution,
+        test_template_logical_name_mapping,
+        test_known_variable_validation,
+        test_validate_parameters,
+        test_suggest_alternative,
+        test_get_known_variables,
+        test_plan23_regression,
+    ]
+
+    passed = 0
+    failed = 0
+    for test in tests:
+        try:
+            test()
+            passed += 1
+        except Exception as e:
+            print(f"FAIL: {test.__name__}: {e}")
+            failed += 1
+
+    print(f"\n{'='*50}")
+    print(f"Results: {passed} passed, {failed} failed, {len(tests)} total")
+    if failed > 0:
+        sys.exit(1)
+    else:
+        print("ALL TESTS PASSED")
+        sys.exit(0)
+
+
+if __name__ == "__main__":
+    main()

+ 85 - 0
scripts/verify_enable_thermal.py

@@ -0,0 +1,85 @@
+"""Verify the executor enable_thermal plumbing end-to-end on real Motor-CAD.
+
+Runs RobustMotorCADSolver with enable_thermal=True for a single point and
+checks that:
+    1. The EM and thermal solves both complete.
+    2. Thermal metrics are merged into the point result.
+    3. Thermal metric columns are written to scan_results.csv.
+
+Ambient temperature is overridden to 25 C in-memory (the MARS model ships an
+abnormal 125 C) so the temperature-rise / thermal-resistance metrics come out
+physically positive.
+
+Run with the venv python that has ansys-motorcad-core installed, e.g.:
+    <venv>/Scripts/python.exe scripts/verify_enable_thermal.py
+
+All source is ASCII only.
+"""
+from __future__ import annotations
+
+import os
+import sys
+import time
+from pathlib import Path
+
+_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(_ROOT / "src"))
+sys.path.insert(0, str(_ROOT / "scripts"))
+
+from robust_motorcad import RobustMotorCADSolver  # noqa: E402
+
+MODEL = str(_ROOT / "models" / "MARS-12S10P_SSSR_D76-C150_V5.0-0819.mot")
+
+THERMAL_KEYS = [
+    "winding_temp_c", "winding_hotspot_temp_c", "magnet_temp_c",
+    "stator_temp_c", "bearing_temp_c", "temp_rise_c",
+    "thermal_resistance_k_w",
+]
+
+
+def main() -> int:
+    output_dir = str(_ROOT / "output" / (
+        "verify_thermal_" + time.strftime("%Y%m%d_%H%M%S")
+    ))
+    solver = RobustMotorCADSolver(
+        model_path=MODEL, output_dir=output_dir, enable_thermal=True,
+        ambient_temperature=25.0,
+    )
+    solver.connect()
+    try:
+        result = solver.run_single_point(
+            {"RMSCurrent": 21.0, "Shaft_Speed": 5000.0},
+            point_index=0, point_label="verify",
+        )
+        print("status: %s" % result["status"], flush=True)
+        print("=== thermal metrics in point result ===", flush=True)
+        for key in THERMAL_KEYS:
+            print("  %s = %s" % (key, result.get("metrics", {}).get(key)),
+                  flush=True)
+
+        csv_path = os.path.join(output_dir, "scan_results.csv")
+        csv_ok = os.path.exists(csv_path)
+        print("scan_results.csv exists: %s" % csv_ok, flush=True)
+        thermal_in_csv = False
+        if csv_ok:
+            header = open(csv_path, encoding="utf-8").readline().rstrip()
+            thermal_in_csv = all(k in header for k in THERMAL_KEYS)
+            print("thermal columns present in CSV header: %s" % thermal_in_csv,
+                  flush=True)
+
+        merged = all(
+            key in result.get("metrics", {}) for key in THERMAL_KEYS
+        )
+        ok = (
+            result["status"] == "OK" and merged and csv_ok and thermal_in_csv
+        )
+        print("")
+        print("ENABLE_THERMAL END-TO-END: %s" % ("PASS" if ok else "FAIL"),
+              flush=True)
+        return 0 if ok else 1
+    finally:
+        solver.disconnect()
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 7 - 0
src/afmcore/__init__.py

@@ -0,0 +1,7 @@
+"""Platform core package for PCB axial flux motor simulation system.
+
+Single source of truth for metrics, topology, adapters, and strategies.
+Pure Python, no GUI/Web/tool dependencies.
+
+All source is ASCII only; Chinese strings use \\uXXXX escapes.
+"""

+ 164 - 0
src/afmcore/adapters/__init__.py

@@ -0,0 +1,164 @@
+"""Simulation tool adapter abstraction (platform extension point).
+
+Defines the SimulationAdapter protocol and a pluggable registry so the
+local executor / GUI never hard-codes a specific solver (Motor-CAD today,
+Maxwell / JMAG / Flux tomorrow).
+
+To add a new simulation tool:
+    1. Implement SimulationAdapter for the tool.
+    2. Register it: register_adapter("tool_name", adapter_class).
+    3. Callers use get_adapter("tool_name", ...) - nothing else changes.
+
+All source is ASCII only.
+"""
+from __future__ import annotations
+
+import abc
+from typing import Any, Callable, Dict, Optional, Type
+
+
+class SimulationAdapter(abc.ABC):
+    """Uniform interface over a FEA/electromagnetic simulation tool.
+
+    Concrete implementations (MotorCADAdapter, MaxwellAdapter, ...) wrap a
+    tool-specific low-level client. The executor only talks to this protocol.
+    """
+
+    # Tool identifier used for registration / lookup (e.g. "motorcad").
+    tool_name: str = "abstract"
+
+    # Human-readable label for UI / logs.
+    tool_label: str = "Abstract Solver"
+
+    # Result metric domains this tool can produce (electromagnetic, thermal, ...).
+    capability_domains: tuple = ("electromagnetic",)
+
+    def __init__(
+        self,
+        log_cb: Optional[Callable[[str], None]] = None,
+        progress_cb: Optional[Callable[[int, int], None]] = None,
+        **kwargs: Any,
+    ):
+        self.log_cb = log_cb
+        self.progress_cb = progress_cb
+        self._extra = kwargs
+
+    def _log(self, text: str) -> None:
+        if self.log_cb:
+            self.log_cb(text)
+
+    # -- lifecycle ---------------------------------------------------------
+    @abc.abstractmethod
+    def connect(self) -> None:
+        """Open a dedicated, visible tool instance. Must be safe to call
+        after a previous disconnect()."""
+
+    @abc.abstractmethod
+    def disconnect(self) -> None:
+        """Release the tool instance (reload baseline if applicable)."""
+
+    # -- model & parameters ------------------------------------------------
+    @abc.abstractmethod
+    def load_model(self, model_path: str) -> None:
+        """Load a baseline model file (read-only; never modify original)."""
+
+    @abc.abstractmethod
+    def set_parameter(self, name: str, value: float) -> None:
+        """Write one parameter AND verify by read-back. Raise RuntimeError
+        on mismatch (write-back verification is a hard project rule)."""
+
+    @abc.abstractmethod
+    def run_simulation(self, mode: str = "electromagnetic") -> None:
+        """Run the tool's solve for the given domain mode."""
+
+    # -- results -----------------------------------------------------------
+    @abc.abstractmethod
+    def extract_metrics(
+        self, output_dir: str, tag: str = ""
+    ) -> Dict[str, Any]:
+        """Export raw results, parse metrics (via platform.metrics), and
+        return {metrics, raw_path, status, error}."""
+
+    # -- high-level convenience --------------------------------------------
+    def run_point(
+        self,
+        model_path: str,
+        params: Optional[Dict[str, float]] = None,
+        output_dir: str = "output",
+        tag: str = "",
+    ) -> Dict[str, Any]:
+        """Run a complete single-point simulation.
+
+        Default orchestration: load model -> write params -> run -> extract.
+        Subclasses may override for tool-specific timeout/reconnect logic.
+        Returns {metrics, status, error, raw_path, solve_time_s, params}.
+        """
+        import time
+
+        started = time.time()
+        result: Dict[str, Any] = {
+            "metrics": {},
+            "status": "FAILED",
+            "error": "",
+            "raw_path": "",
+            "solve_time_s": 0,
+            "params": params or {},
+        }
+        try:
+            self.connect()
+            self.load_model(model_path)
+            if params:
+                for name, value in params.items():
+                    self.set_parameter(name, value)
+            self.run_simulation("electromagnetic")
+            ext = self.extract_metrics(output_dir, tag)
+            result.update(ext)
+        except Exception as exc:  # noqa: BLE001 - boundary guard
+            result["error"] = f"{type(exc).__name__}: {exc}"
+            self._log(result["error"])
+        finally:
+            try:
+                self.disconnect()
+            except Exception:  # noqa: BLE001
+                pass
+            result["solve_time_s"] = round(time.time() - started, 1)
+        return result
+
+
+# ---------------------------------------------------------------------------
+# Registry
+# ---------------------------------------------------------------------------
+
+# tool_name -> adapter class (NOT instance; instances are created per task
+# with fresh state via get_adapter).
+ADAPTER_REGISTRY: Dict[str, Type[SimulationAdapter]] = {}
+
+
+def register_adapter(
+    tool_name: str, adapter_class: Type[SimulationAdapter]
+) -> None:
+    """Register an adapter implementation under a tool name."""
+    if not (isinstance(tool_name, str) and tool_name):
+        raise ValueError("tool_name must be a non-empty string")
+    if not (isinstance(adapter_class, type) and issubclass(adapter_class, SimulationAdapter)):
+        raise TypeError("adapter_class must be a SimulationAdapter subclass")
+    ADAPTER_REGISTRY[tool_name] = adapter_class
+
+
+def get_adapter(tool_name: str, **kwargs: Any) -> SimulationAdapter:
+    """Create a fresh adapter instance for the given tool name.
+
+    Raises KeyError if the tool is not registered (callers should surface
+    a clear "tool not supported" error instead of failing deep inside).
+    """
+    if tool_name not in ADAPTER_REGISTRY:
+        raise KeyError(
+            "Simulation tool %r is not registered. Registered: %s"
+            % (tool_name, sorted(ADAPTER_REGISTRY.keys()))
+        )
+    return ADAPTER_REGISTRY[tool_name](**kwargs)
+
+
+def registered_tools() -> list:
+    """List of registered tool names."""
+    return sorted(ADAPTER_REGISTRY.keys())

+ 132 - 0
src/afmcore/adapters/jmag.py

@@ -0,0 +1,132 @@
+"""JMAG adapter - mock SimulationAdapter implementation.
+
+This module provides a JMAG-flavoured adapter behind the uniform
+SimulationAdapter protocol. The current implementation is a MOCK for
+pipeline validation: it records parameters in memory and produces
+deterministic synthetic metrics, so the executor / strategy / GUI layers
+can exercise the full "select point -> set params -> solve -> extract"
+loop without a JMAG installation.
+
+Real JMAG integration path (marked as environment dependency):
+  - requires JMAG Designer + JMAG-RT / Python API (jmagpy)
+  - connect() would launch a JMAG Designer instance via the COM/Python API
+  - set_parameter() would write case variables with read-back verification
+  - run_simulation() would call the JMAG solver run command
+  - extract_metrics() would read results from the JMAG result table / export
+  Until then, this mock keeps the interface stable and the pipeline testable.
+
+Registering this module makes "jmag" available via
+afmcore.adapters.get_adapter("jmag", ...).
+
+All source is ASCII only.
+"""
+from __future__ import annotations
+
+import math
+import os
+from typing import Any, Dict, Optional
+
+from . import SimulationAdapter, register_adapter
+
+
+class JMAGAdapter(SimulationAdapter):
+    """Mock JMAG adapter (deterministic synthetic metrics).
+
+    Capability domains: electromagnetic (declared for future real
+    integration; mock currently produces electromagnetic-domain metrics).
+    Metric coefficients differ slightly from MaxwellAdapter so tests can
+    distinguish which tool produced the results.
+    """
+
+    tool_name = "jmag"
+    tool_label = "JMAG Designer (mock)"
+    capability_domains = ("electromagnetic",)
+
+    def __init__(
+        self,
+        model_path: Optional[str] = None,
+        output_dir: Optional[str] = None,
+        mock: bool = True,
+        log_cb=None,
+        progress_cb=None,
+        **kwargs: Any,
+    ):
+        super().__init__(log_cb=log_cb, progress_cb=progress_cb, **kwargs)
+        self.model_path = model_path
+        self.output_dir = output_dir
+        self.mock = bool(mock)
+        self._connected = False
+        self._loaded_model = ""
+        self._params: Dict[str, float] = {}
+
+    # -- lifecycle ---------------------------------------------------------
+    def connect(self) -> None:
+        if self.mock:
+            self._connected = True
+            self._log("JMAGAdapter(mock): connected (no real JMAG instance)")
+            return
+        raise RuntimeError(
+            "JMAGAdapter real mode requires JMAG Designer + Python API; "
+            "use mock=True for pipeline validation"
+        )
+
+    def disconnect(self) -> None:
+        self._connected = False
+        self._params.clear()
+        self._log("JMAGAdapter(mock): disconnected")
+
+    # -- model & parameters ------------------------------------------------
+    def load_model(self, model_path: str) -> None:
+        self._loaded_model = model_path
+        self._params.clear()
+        self._log("JMAGAdapter(mock): model path set: %s" % model_path)
+
+    def set_parameter(self, name: str, value: float) -> None:
+        """Write parameter to in-memory store and verify by read-back."""
+        val = float(value)
+        self._params[name] = val
+        applied = self._params.get(name)
+        if applied is None or not math.isclose(applied, val, rel_tol=1e-9, abs_tol=1e-9):
+            raise RuntimeError(
+                "JMAGAdapter set_parameter read-back mismatch: %s expected=%s applied=%s"
+                % (name, val, applied)
+            )
+        self._log("JMAGAdapter(mock): set %s = %s" % (name, val))
+
+    def run_simulation(self, mode: str = "electromagnetic") -> None:
+        if not self._connected:
+            raise RuntimeError("JMAGAdapter: run_simulation called before connect()")
+        if mode != "electromagnetic":
+            raise ValueError("JMAGAdapter(mock) supports mode='electromagnetic' only")
+        self._log("JMAGAdapter(mock): run_simulation mode=%s (no real solve)" % mode)
+
+    # -- results -----------------------------------------------------------
+    def extract_metrics(self, output_dir: str, tag: str = "") -> Dict[str, Any]:
+        """Produce deterministic synthetic metrics (JMAG-flavoured).
+
+        Metric model:
+          tavg_nm        = 0.45 * airgap_mm + 0.32 * magnet_thickness_mm + 0.12
+          efficiency_pct = 84.5 + 0.12 * airgap_mm
+          total_losses_w = 43.0 - 0.75 * airgap_mm
+        """
+        airgap = float(self._params.get("airgap_mm", 0.0))
+        magnet = float(self._params.get("magnet_thickness_mm", 0.0))
+        metrics: Dict[str, float] = {
+            "tavg_nm": round(0.45 * airgap + 0.32 * magnet + 0.12, 6),
+            "ripple_pct": round(2.8 + 0.08 * airgap, 4),
+            "efficiency_pct": round(84.5 + 0.12 * airgap, 4),
+            "total_losses_w": round(43.0 - 0.75 * airgap, 4),
+            "copper_loss_w": round(15.5 + 0.25 * airgap, 4),
+            "iron_loss_w": round(3.2 + 0.04 * magnet, 4),
+            "back_emf_v": round(10.8 + 0.25 * airgap, 4),
+        }
+        raw_path = os.path.join(output_dir, "raw", "jmag_mock_%s.csv" % (tag or "latest"))
+        return {
+            "metrics": metrics,
+            "raw_path": raw_path,
+            "status": "OK",
+            "error": "",
+        }
+
+
+register_adapter(JMAGAdapter.tool_name, JMAGAdapter)

+ 142 - 0
src/afmcore/adapters/maxwell.py

@@ -0,0 +1,142 @@
+"""Ansys Maxwell adapter - mock SimulationAdapter implementation.
+
+This module provides a Maxwell-flavoured adapter behind the uniform
+SimulationAdapter protocol. The current implementation is a MOCK for
+pipeline validation: it records parameters in memory and produces
+deterministic synthetic metrics, so the executor / strategy / GUI layers
+can exercise the full "select point -> set params -> solve -> extract"
+loop without an Ansys Maxwell installation.
+
+Real Maxwell integration path (marked as environment dependency):
+  - requires Ansys Maxwell + PyAEDT (ansys-pythonnet)
+  - connect() would launch a Maxwell desktop instance via pyaedt.Maxwell3d
+  - set_parameter() would write project variables with read-back verification
+  - run_simulation() would call analyze_setup()
+  - extract_metrics() would read fields / reports via the PyAEDT post-processor
+  Until then, this mock keeps the interface stable and the pipeline testable.
+
+Registering this module makes "maxwell" available via
+afmcore.adapters.get_adapter("maxwell", ...).
+
+All source is ASCII only.
+"""
+from __future__ import annotations
+
+import math
+import os
+from typing import Any, Dict, Optional
+
+from . import SimulationAdapter, register_adapter
+
+
+class MaxwellAdapter(SimulationAdapter):
+    """Mock Maxwell adapter (deterministic synthetic metrics).
+
+    Capability domains: electromagnetic + thermal (declared for future
+    real integration; mock currently produces electromagnetic-domain metrics
+    plus a synthetic winding-temperature estimate).
+    """
+
+    tool_name = "maxwell"
+    tool_label = "Ansys Maxwell (mock)"
+    capability_domains = ("electromagnetic", "thermal")
+
+    def __init__(
+        self,
+        model_path: Optional[str] = None,
+        output_dir: Optional[str] = None,
+        mock: bool = True,
+        log_cb=None,
+        progress_cb=None,
+        **kwargs: Any,
+    ):
+        super().__init__(log_cb=log_cb, progress_cb=progress_cb, **kwargs)
+        self.model_path = model_path
+        self.output_dir = output_dir
+        self.mock = bool(mock)
+        self._connected = False
+        self._loaded_model = ""
+        self._params: Dict[str, float] = {}
+
+    # -- lifecycle ---------------------------------------------------------
+    def connect(self) -> None:
+        if self.mock:
+            self._connected = True
+            self._log("MaxwellAdapter(mock): connected (no real Maxwell instance)")
+            return
+        # Real integration placeholder (environment dependency).
+        raise RuntimeError(
+            "MaxwellAdapter real mode requires Ansys Maxwell + PyAEDT; "
+            "use mock=True for pipeline validation"
+        )
+
+    def disconnect(self) -> None:
+        self._connected = False
+        self._params.clear()
+        self._log("MaxwellAdapter(mock): disconnected")
+
+    # -- model & parameters ------------------------------------------------
+    def load_model(self, model_path: str) -> None:
+        self._loaded_model = model_path
+        self._params.clear()
+        self._log("MaxwellAdapter(mock): model path set: %s" % model_path)
+
+    def set_parameter(self, name: str, value: float) -> None:
+        """Write parameter to in-memory store and verify by read-back.
+
+        Mirrors the hard project rule: write AND verify, raise on mismatch.
+        """
+        val = float(value)
+        self._params[name] = val
+        # read-back verification (mock: same in-memory store)
+        applied = self._params.get(name)
+        if applied is None or not math.isclose(applied, val, rel_tol=1e-9, abs_tol=1e-9):
+            raise RuntimeError(
+                "MaxwellAdapter set_parameter read-back mismatch: %s expected=%s applied=%s"
+                % (name, val, applied)
+            )
+        self._log("MaxwellAdapter(mock): set %s = %s" % (name, val))
+
+    def run_simulation(self, mode: str = "electromagnetic") -> None:
+        if not self._connected:
+            raise RuntimeError("MaxwellAdapter: run_simulation called before connect()")
+        if mode not in ("electromagnetic", "thermal"):
+            raise ValueError("MaxwellAdapter supports mode='electromagnetic' or 'thermal'")
+        # mock: no real solve; deterministic metrics are derived in extract_metrics
+        self._log("MaxwellAdapter(mock): run_simulation mode=%s (no real solve)" % mode)
+
+    # -- results -----------------------------------------------------------
+    def extract_metrics(self, output_dir: str, tag: str = "") -> Dict[str, Any]:
+        """Produce deterministic synthetic metrics from the in-memory params.
+
+        Metric model (Maxwell-flavoured mock):
+          tavg_nm        = 0.50 * airgap_mm + 0.30 * magnet_thickness_mm + 0.10
+          efficiency_pct = 85.0 + 0.10 * airgap_mm
+          total_losses_w = 42.0 - 0.80 * airgap_mm
+          winding_temp_c = 95.0 + 2.0 * (1.0 - airgap_mm)  # thermal domain
+        Missing parameters default to 0.0 contribution.
+        """
+        airgap = float(self._params.get("airgap_mm", 0.0))
+        magnet = float(self._params.get("magnet_thickness_mm", 0.0))
+        metrics: Dict[str, float] = {
+            "tavg_nm": round(0.50 * airgap + 0.30 * magnet + 0.10, 6),
+            "ripple_pct": round(2.5 + 0.1 * airgap, 4),
+            "efficiency_pct": round(85.0 + 0.10 * airgap, 4),
+            "total_losses_w": round(42.0 - 0.80 * airgap, 4),
+            "copper_loss_w": round(16.0 + 0.2 * airgap, 4),
+            "iron_loss_w": round(3.0 + 0.05 * magnet, 4),
+            "back_emf_v": round(11.0 + 0.2 * airgap, 4),
+            "winding_temp_c": round(95.0 + 2.0 * (1.0 - airgap), 4),
+        }
+        raw_path = os.path.join(output_dir, "raw", "maxwell_mock_%s.csv" % (tag or "latest"))
+        return {
+            "metrics": metrics,
+            "raw_path": raw_path,
+            "status": "OK",
+            "error": "",
+        }
+
+
+# Register so get_adapter("maxwell", ...) works as soon as this module is
+# imported (the executor imports it dynamically based on the tool name).
+register_adapter(MaxwellAdapter.tool_name, MaxwellAdapter)

+ 196 - 0
src/afmcore/adapters/motorcad.py

@@ -0,0 +1,196 @@
+"""Motor-CAD adapter - concrete SimulationAdapter implementation.
+
+Wraps RobustMotorCADSolver (scripts/robust_motorcad.py) behind the uniform
+SimulationAdapter protocol so the executor / GUI / future strategies never
+depend on Motor-CAD specifics.
+
+Registering this module (importing it) makes the "motorcad" tool available
+through afmcore.adapters.get_adapter("motorcad", ...).
+
+To add a new tool (e.g. Maxwell): create a sibling module implementing the
+same protocol and register it under its own tool name. Nothing in the
+executor changes.
+
+All source is ASCII only.
+"""
+from __future__ import annotations
+
+from typing import Any, Dict, Optional
+
+from . import SimulationAdapter, register_adapter
+
+
+class MotorCADAdapter(SimulationAdapter):
+    """Adapter over RobustMotorCADSolver (reuses its full robustness
+    protocol: write-back verification, baseline reload, popup suppression,
+    per-point retry/reconnect, per-point disk write)."""
+
+    tool_name = "motorcad"
+    tool_label = "Motor-CAD (ANSYS)"
+    capability_domains = ("electromagnetic",)
+
+    def __init__(
+        self,
+        model_path: Optional[str] = None,
+        output_dir: Optional[str] = None,
+        point_timeout: int = 300,
+        max_retries: int = 3,
+        headless: bool = False,
+        enable_thermal: bool = False,
+        ambient_temperature: Optional[float] = None,
+        log_cb=None,
+        progress_cb=None,
+        **kwargs: Any,
+    ):
+        super().__init__(log_cb=log_cb, progress_cb=progress_cb, **kwargs)
+        self.model_path = model_path
+        self.output_dir = output_dir
+        self.point_timeout = point_timeout
+        self.max_retries = max_retries
+        self.headless = headless
+        # P5-M6: after each EM solve, also run a steady-state thermal solve
+        # and merge thermal metrics (OFF by default, preserves EM-only flow).
+        self.enable_thermal = bool(enable_thermal)
+        # P5-M6 thermal boundary: Ambient_Temperature override (degC); None =
+        # leave model value. MARS ships 125 C, so pass 25-40 for valid results.
+        self.ambient_temperature = ambient_temperature
+        self._solver = None  # lazy RobustMotorCADSolver
+
+    # -- internal ----------------------------------------------------------
+    def _ensure_solver(self):
+        """Lazily create the wrapped RobustMotorCADSolver instance."""
+        if self._solver is None:
+            import os
+            import sys
+            # scripts/robust_motorcad.py is imported via the `scripts` package,
+            # which requires the repo root on sys.path. Executor runs may only
+            # have scripts/ and src/ on sys.path, so ensure the repo root is
+            # present or `from scripts.robust_motorcad import ...` fails with
+            # "No module named 'scripts'".
+            _root = os.path.dirname(
+                os.path.dirname(
+                    os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+                )
+            )
+            if _root not in sys.path:
+                sys.path.insert(0, _root)
+            from scripts.robust_motorcad import RobustMotorCADSolver  # type: ignore
+
+            self._solver = RobustMotorCADSolver(
+                model_path=self.model_path or "",
+                output_dir=self.output_dir,
+                point_timeout=self.point_timeout,
+                max_retries=self.max_retries,
+                headless=self.headless,
+                enable_thermal=self.enable_thermal,
+                ambient_temperature=self.ambient_temperature,
+            )
+        return self._solver
+
+    # -- lifecycle ---------------------------------------------------------
+    def connect(self) -> None:
+        self._ensure_solver().connect()
+        self._log("Motor-CAD connected (MotorCADAdapter)")
+
+    def disconnect(self) -> None:
+        if self._solver is not None:
+            try:
+                self._solver.disconnect()
+            finally:
+                self._solver = None
+
+    # -- model & parameters ------------------------------------------------
+    def load_model(self, model_path: str) -> None:
+        # RobustMotorCADSolver reloads the baseline before every point, so we
+        # only record the path here.
+        self.model_path = model_path
+        self._log("Model path set: %s" % model_path)
+
+    def set_parameter(self, name: str, value: float) -> None:
+        # Delegates to the robust write-back verification.
+        self._ensure_solver()._write_and_verify(name, float(value))
+
+    def run_simulation(self, mode: str = "electromagnetic") -> None:
+        solver = self._ensure_solver()
+        if solver.mc is None:
+            solver.connect()
+        if mode == "electromagnetic":
+            solver.mc.do_magnetic_calculation()
+        else:
+            raise ValueError(
+                "MotorCADAdapter currently supports mode='electromagnetic' only"
+            )
+
+    # -- results -----------------------------------------------------------
+    def extract_metrics(self, output_dir: str, tag: str = "") -> Dict[str, Any]:
+        """Extract metrics from the most recent raw export written by the
+        wrapped solver (it manages its own raw/ directory)."""
+        solver = self._ensure_solver()
+        metrics: Dict[str, float] = {}
+        raw_path = ""
+        error = ""
+        status = "OK"
+        try:
+            import os
+
+            raw_dir = os.path.join(solver.output_dir, "raw")
+            if os.path.isdir(raw_dir):
+                files = sorted(
+                    os.path.join(raw_dir, f) for f in os.listdir(raw_dir)
+                )
+                if files:
+                    raw_path = files[-1]
+                    metrics = solver._parse_export(raw_path)
+            if not metrics:
+                status = "UNCERTAIN"
+                error = "No metrics extracted from latest raw export"
+        except Exception as exc:  # noqa: BLE001
+            status = "FAILED"
+            error = f"{type(exc).__name__}: {exc}"
+        return {"metrics": metrics, "raw_path": raw_path, "status": status, "error": error}
+
+    # -- high-level ---------------------------------------------------------
+    def run_point(
+        self,
+        model_path: str,
+        params: Optional[Dict[str, float]] = None,
+        output_dir: str = "output",
+        tag: str = "",
+    ) -> Dict[str, Any]:
+        """Run one point through the full robust protocol and map the result
+        to the uniform adapter schema."""
+        import time
+
+        started = time.time()
+        solver = self._ensure_solver()
+        # run_point is called with an explicit model_path; the solver is
+        # created lazily with a possibly-empty default, so sync it here
+        # or load_from_file would target the wrong (empty) path.
+        if model_path:
+            solver.model_path = model_path
+        if solver.mc is None:
+            try:
+                solver.connect()
+            except Exception as exc:  # noqa: BLE001
+                return {
+                    "metrics": {},
+                    "status": "FAILED",
+                    "error": f"{type(exc).__name__}: {exc}",
+                    "raw_path": "",
+                    "solve_time_s": round(time.time() - started, 1),
+                    "params": params or {},
+                }
+        result = solver.run_single_point(params or {}, point_index=0)
+        return {
+            "metrics": result.get("metrics", {}),
+            "status": result.get("status", "FAILED"),
+            "error": result.get("error"),
+            "raw_path": "",
+            "solve_time_s": result.get("duration_s", round(time.time() - started, 1)),
+            "params": params or {},
+        }
+
+
+# Register the adapter so get_adapter("motorcad", ...) works as soon as this
+# module is imported (the executor imports it at startup).
+register_adapter(MotorCADAdapter.tool_name, MotorCADAdapter)

+ 1 - 0
src/afmcore/l0/__init__.py

@@ -0,0 +1 @@
+"""L0 analytic pre-screening (shared core, P4-M5)."""

+ 445 - 0
src/afmcore/l0/prescreening.py

@@ -0,0 +1,445 @@
+"""L0 Analytic Pre-screening Engine (P4-M5, shared core).
+
+Promoted from web/backend/app/services/l0_prescreening.py so both the Web
+system and the local EXE consume the same analytic gate. Pure stdlib.
+
+Covers:
+- Geometric constraints (inner/outer diameter, airgap, axial length)
+- Electrical constraints (current density, voltage, magnetic loading)
+- Thermal constraints (temperature rise, cooling capacity)
+- Manufacturing constraints (PCB line width/spacing, copper thickness, tolerances)
+"""
+import math
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional, Tuple, Any
+
+
+# Motor-CAD variable name -> L0 BC-style parameter name (semantic map).
+# Only mappings whose physical meaning is identical are included:
+# - Magnet_Length is the magnet's axial dimension; in axial-flux machines the
+#   flux path is axial, so the L0 "magnet_thickness_mm" check (the dimension
+#   along the flux path) maps to Magnet_Length, NOT to Magnet_Thickness
+#   (which is the radial depth (D_out-D_in)/2 and has no L0 check).
+# No derivations (e.g. conductor area from wire diameter) are done here.
+MOTORCAD_TO_L0 = {
+    "Airgap": "airgap_mm",
+    "Magnet_Length": "magnet_thickness_mm",
+    "RMSCurrent": "current_a",
+    "Magnet_Temperature": "magnet_temp_c",
+    "Stator_Outer_Diameter": "outer_diameter_mm",
+    "Stator_Inner_Diameter": "inner_diameter_mm",
+}
+
+
+@dataclass
+class ConstraintResult:
+    """Result of a single constraint check."""
+    name: str
+    category: str  # geometric / electrical / thermal / manufacturing
+    passed: bool
+    value: Optional[float] = None
+    limit: Optional[float] = None
+    margin: Optional[float] = None  # percentage margin (positive = safe)
+    message: str = ""
+
+
+@dataclass
+class FeasibilityReport:
+    """Complete L0 feasibility report for a parameter set."""
+    feasible: bool
+    total_checks: int
+    passed_checks: int
+    failed_checks: int
+    results: List[ConstraintResult] = field(default_factory=list)
+    risk_items: List[str] = field(default_factory=list)
+
+    @property
+    def pass_rate(self) -> float:
+        return self.passed_checks / self.total_checks if self.total_checks > 0 else 0.0
+
+    def to_dict(self) -> Dict[str, Any]:
+        return {
+            "feasible": self.feasible,
+            "total_checks": self.total_checks,
+            "passed_checks": self.passed_checks,
+            "failed_checks": self.failed_checks,
+            "pass_rate": round(self.pass_rate, 4),
+            "results": [
+                {
+                    "name": r.name,
+                    "category": r.category,
+                    "passed": r.passed,
+                    "value": r.value,
+                    "limit": r.limit,
+                    "margin_pct": round(r.margin, 2) if r.margin is not None else None,
+                    "message": r.message,
+                }
+                for r in self.results
+            ],
+            "risk_items": self.risk_items,
+        }
+
+
+class L0PreScreeningEngine:
+    """L0 analytic pre-screening engine.
+
+    Uses first-order physics and engineering rules to quickly
+    reject infeasible parameter combinations without simulation.
+    """
+
+    # Default engineering limits (can be overridden per project)
+    DEFAULT_LIMITS = {
+        # Geometric
+        "min_outer_diameter_mm": 20.0,
+        "max_outer_diameter_mm": 500.0,
+        "min_inner_diameter_mm": 5.0,
+        "min_diameter_ratio": 0.2,  # inner/outer
+        "max_diameter_ratio": 0.8,
+        "min_airgap_mm": 0.3,
+        "max_airgap_mm": 5.0,
+        "min_magnet_thickness_mm": 1.0,
+        "max_magnet_thickness_mm": 20.0,
+        # Electrical
+        "max_current_density_amm2": 15.0,  # A/mm^2 (natural convection)
+        "max_current_density_forced_amm2": 25.0,  # A/mm^2 (forced cooling)
+        "min_slot_fill_factor": 0.3,
+        "max_slot_fill_factor": 0.75,
+        "max_magnetic_loading_t": 1.8,  # Tesla (avoid saturation)
+        # Thermal
+        "max_temperature_rise_c": 80.0,  # K (above ambient)
+        "max_winding_temp_c": 150.0,  # Class F
+        "max_magnet_temp_c": 120.0,  # NdFeB N42SH
+        # Manufacturing (PCB)
+        "min_pcb_line_width_mm": 0.1,
+        "min_pcb_line_spacing_mm": 0.1,
+        "min_pcb_copper_thickness_oz": 0.5,
+        "max_pcb_copper_thickness_oz": 6.0,
+        "min_via_diameter_mm": 0.2,
+        "max_pcb_layers": 20,
+    }
+
+    def __init__(self, limits: Optional[Dict[str, float]] = None):
+        self.limits = dict(self.DEFAULT_LIMITS)
+        if limits:
+            self.limits.update(limits)
+
+    def check_geometric(self, params: Dict[str, Any]) -> List[ConstraintResult]:
+        """Check geometric feasibility constraints."""
+        results = []
+        L = self.limits
+
+        outer_d = params.get("outer_diameter_mm")
+        inner_d = params.get("inner_diameter_mm")
+        airgap = params.get("airgap_mm")
+        magnet_thickness = params.get("magnet_thickness_mm")
+
+        # Outer diameter range
+        if outer_d is not None:
+            passed = L["min_outer_diameter_mm"] <= outer_d <= L["max_outer_diameter_mm"]
+            results.append(ConstraintResult(
+                name="outer_diameter_range",
+                category="geometric",
+                passed=passed,
+                value=outer_d,
+                limit=f"{L['min_outer_diameter_mm']}-{L['max_outer_diameter_mm']}",
+                message=f"Outer diameter {outer_d}mm {'within' if passed else 'outside'} valid range",
+            ))
+
+        # Inner diameter and ratio
+        if outer_d is not None and inner_d is not None:
+            ratio = inner_d / outer_d if outer_d > 0 else 0
+            passed = (L["min_inner_diameter_mm"] <= inner_d and
+                      L["min_diameter_ratio"] <= ratio <= L["max_diameter_ratio"])
+            margin = (ratio - L["min_diameter_ratio"]) / L["min_diameter_ratio"] * 100 if ratio >= L["min_diameter_ratio"] else None
+            results.append(ConstraintResult(
+                name="inner_diameter_ratio",
+                category="geometric",
+                passed=passed,
+                value=round(ratio, 3),
+                limit=f"{L['min_diameter_ratio']}-{L['max_diameter_ratio']}",
+                margin=margin,
+                message=f"Inner/outer diameter ratio {ratio:.3f} {'valid' if passed else 'invalid'}",
+            ))
+
+        # Airgap range
+        if airgap is not None:
+            passed = L["min_airgap_mm"] <= airgap <= L["max_airgap_mm"]
+            results.append(ConstraintResult(
+                name="airgap_range",
+                category="geometric",
+                passed=passed,
+                value=airgap,
+                limit=f"{L['min_airgap_mm']}-{L['max_airgap_mm']}",
+                message=f"Airgap {airgap}mm {'within' if passed else 'outside'} valid range",
+            ))
+
+        # Magnet thickness
+        if magnet_thickness is not None:
+            passed = L["min_magnet_thickness_mm"] <= magnet_thickness <= L["max_magnet_thickness_mm"]
+            results.append(ConstraintResult(
+                name="magnet_thickness_range",
+                category="geometric",
+                passed=passed,
+                value=magnet_thickness,
+                limit=f"{L['min_magnet_thickness_mm']}-{L['max_magnet_thickness_mm']}",
+                message=f"Magnet thickness {magnet_thickness}mm {'within' if passed else 'outside'} valid range",
+            ))
+
+        # Airgap vs magnet thickness ratio (engineering rule)
+        if airgap is not None and magnet_thickness is not None:
+            ratio = airgap / magnet_thickness if magnet_thickness > 0 else 0
+            passed = 0.05 <= ratio <= 0.5  # typical: airgap 5-50% of magnet thickness
+            results.append(ConstraintResult(
+                name="airgap_magnet_ratio",
+                category="geometric",
+                passed=passed,
+                value=round(ratio, 3),
+                limit="0.05-0.5",
+                message=f"Airgap/magnet thickness ratio {ratio:.3f} {'reasonable' if passed else 'unusual'}",
+            ))
+
+        return results
+
+    def check_electrical(self, params: Dict[str, Any]) -> List[ConstraintResult]:
+        """Check electrical feasibility constraints."""
+        results = []
+        L = self.limits
+
+        current_density = params.get("current_density_amm2")
+        rms_current = params.get("current_a") or params.get("rms_current_a")
+        conductor_area = params.get("conductor_area_mm2")
+        slot_fill_factor = params.get("slot_fill_factor")
+        magnetic_loading = params.get("magnetic_loading_t") or params.get("airgap_flux_density_t")
+        forced_cooling = params.get("forced_cooling", False)
+
+        # Current density (derived or direct)
+        if current_density is None and rms_current is not None and conductor_area is not None and conductor_area > 0:
+            current_density = rms_current / conductor_area
+
+        if current_density is not None:
+            limit = L["max_current_density_forced_amm2"] if forced_cooling else L["max_current_density_amm2"]
+            passed = current_density <= limit
+            margin = (limit - current_density) / limit * 100 if current_density > 0 else None
+            results.append(ConstraintResult(
+                name="current_density",
+                category="electrical",
+                passed=passed,
+                value=round(current_density, 2),
+                limit=limit,
+                margin=margin,
+                message=f"Current density {current_density:.1f} A/mm^2 {'within' if passed else 'exceeds'} {limit} A/mm^2 limit ({'forced' if forced_cooling else 'natural'} cooling)",
+            ))
+
+        # Slot fill factor
+        if slot_fill_factor is not None:
+            passed = L["min_slot_fill_factor"] <= slot_fill_factor <= L["max_slot_fill_factor"]
+            results.append(ConstraintResult(
+                name="slot_fill_factor",
+                category="electrical",
+                passed=passed,
+                value=slot_fill_factor,
+                limit=f"{L['min_slot_fill_factor']}-{L['max_slot_fill_factor']}",
+                message=f"Slot fill factor {slot_fill_factor:.2f} {'within' if passed else 'outside'} valid range",
+            ))
+
+        # Magnetic loading (saturation check)
+        if magnetic_loading is not None:
+            passed = magnetic_loading <= L["max_magnetic_loading_t"]
+            margin = (L["max_magnetic_loading_t"] - magnetic_loading) / L["max_magnetic_loading_t"] * 100
+            results.append(ConstraintResult(
+                name="magnetic_loading",
+                category="electrical",
+                passed=passed,
+                value=round(magnetic_loading, 3),
+                limit=L["max_magnetic_loading_t"],
+                margin=margin,
+                message=f"Magnetic loading {magnetic_loading:.2f}T {'below' if passed else 'exceeds'} {L['max_magnetic_loading_t']}T saturation limit",
+            ))
+
+        return results
+
+    def check_thermal(self, params: Dict[str, Any]) -> List[ConstraintResult]:
+        """Check thermal feasibility constraints (first-order estimates)."""
+        results = []
+        L = self.limits
+
+        ambient_temp = params.get("ambient_temp_c", 25.0)
+        winding_temp = params.get("winding_temp_c")
+        magnet_temp = params.get("magnet_temp_c")
+        total_losses_w = params.get("total_losses_w")
+        cooling_area_mm2 = params.get("cooling_area_mm2")
+
+        # Winding temperature limit
+        if winding_temp is not None:
+            passed = winding_temp <= L["max_winding_temp_c"]
+            margin = (L["max_winding_temp_c"] - winding_temp) / L["max_winding_temp_c"] * 100
+            results.append(ConstraintResult(
+                name="winding_temperature",
+                category="thermal",
+                passed=passed,
+                value=winding_temp,
+                limit=L["max_winding_temp_c"],
+                margin=margin,
+                message=f"Winding temperature {winding_temp}C {'below' if passed else 'exceeds'} {L['max_winding_temp_c']}C limit (Class F)",
+            ))
+
+        # Magnet temperature limit
+        if magnet_temp is not None:
+            passed = magnet_temp <= L["max_magnet_temp_c"]
+            margin = (L["max_magnet_temp_c"] - magnet_temp) / L["max_magnet_temp_c"] * 100
+            results.append(ConstraintResult(
+                name="magnet_temperature",
+                category="thermal",
+                passed=passed,
+                value=magnet_temp,
+                limit=L["max_magnet_temp_c"],
+                margin=margin,
+                message=f"Magnet temperature {magnet_temp}C {'below' if passed else 'exceeds'} {L['max_magnet_temp_c']}C limit (NdFeB N42SH)",
+            ))
+
+        # First-order thermal estimate: losses vs cooling capacity
+        if total_losses_w is not None and cooling_area_mm2 is not None and cooling_area_mm2 > 0:
+            # Rough heat transfer coefficient: 10 W/m^2K (natural), 50 W/m^2K (forced)
+            forced = params.get("forced_cooling", False)
+            h = 50.0 if forced else 10.0  # W/m^2K
+            cooling_area_m2 = cooling_area_mm2 / 1e6
+            temp_rise = total_losses_w / (h * cooling_area_m2) if cooling_area_m2 > 0 else float('inf')
+            passed = temp_rise <= L["max_temperature_rise_c"]
+            results.append(ConstraintResult(
+                name="thermal_estimate",
+                category="thermal",
+                passed=passed,
+                value=round(temp_rise, 1),
+                limit=L["max_temperature_rise_c"],
+                message=f"Estimated temperature rise {temp_rise:.1f}K {'within' if passed else 'exceeds'} {L['max_temperature_rise_c']}K limit (rough estimate, needs L2 verification)",
+            ))
+
+        return results
+
+    def check_manufacturing(self, params: Dict[str, Any]) -> List[ConstraintResult]:
+        """Check PCB manufacturing constraints."""
+        results = []
+        L = self.limits
+
+        pcb_line_width = params.get("pcb_line_width_mm")
+        pcb_line_spacing = params.get("pcb_line_spacing_mm")
+        pcb_copper_thickness_oz = params.get("pcb_copper_thickness_oz")
+        pcb_layers = params.get("pcb_layers")
+        via_diameter = params.get("via_diameter_mm")
+
+        if pcb_line_width is not None:
+            passed = pcb_line_width >= L["min_pcb_line_width_mm"]
+            results.append(ConstraintResult(
+                name="pcb_line_width",
+                category="manufacturing",
+                passed=passed,
+                value=pcb_line_width,
+                limit=L["min_pcb_line_width_mm"],
+                message=f"PCB line width {pcb_line_width}mm {'meets' if passed else 'below'} {L['min_pcb_line_width_mm']}mm minimum",
+            ))
+
+        if pcb_line_spacing is not None:
+            passed = pcb_line_spacing >= L["min_pcb_line_spacing_mm"]
+            results.append(ConstraintResult(
+                name="pcb_line_spacing",
+                category="manufacturing",
+                passed=passed,
+                value=pcb_line_spacing,
+                limit=L["min_pcb_line_spacing_mm"],
+                message=f"PCB line spacing {pcb_line_spacing}mm {'meets' if passed else 'below'} {L['min_pcb_line_spacing_mm']}mm minimum",
+            ))
+
+        if pcb_copper_thickness_oz is not None:
+            passed = L["min_pcb_copper_thickness_oz"] <= pcb_copper_thickness_oz <= L["max_pcb_copper_thickness_oz"]
+            results.append(ConstraintResult(
+                name="pcb_copper_thickness",
+                category="manufacturing",
+                passed=passed,
+                value=pcb_copper_thickness_oz,
+                limit=f"{L['min_pcb_copper_thickness_oz']}-{L['max_pcb_copper_thickness_oz']}",
+                message=f"PCB copper thickness {pcb_copper_thickness_oz}oz {'within' if passed else 'outside'} standard range",
+            ))
+
+        if pcb_layers is not None:
+            passed = pcb_layers <= L["max_pcb_layers"]
+            results.append(ConstraintResult(
+                name="pcb_layer_count",
+                category="manufacturing",
+                passed=passed,
+                value=pcb_layers,
+                limit=L["max_pcb_layers"],
+                message=f"PCB layer count {pcb_layers} {'within' if passed else 'exceeds'} {L['max_pcb_layers']} layer limit",
+            ))
+
+        return results
+
+    def evaluate(self, params: Dict[str, Any]) -> FeasibilityReport:
+        """Run full L0 pre-screening on a parameter set.
+
+        Args:
+            params: Dictionary of parameter values (diameters, airgap, currents, etc.)
+
+        Returns:
+            FeasibilityReport with all check results and overall feasibility.
+        """
+        # Translate Motor-CAD variable names (Airgap, RMSCurrent, ...) to the
+        # BC-style names this engine consumes (airgap_mm, current_a, ...).
+        # L0-native keys always win so BC-style callers are unaffected.
+        params = {**{MOTORCAD_TO_L0[k]: v for k, v in params.items() if k in MOTORCAD_TO_L0}, **params}
+
+        all_results = []
+        all_results.extend(self.check_geometric(params))
+        all_results.extend(self.check_electrical(params))
+        all_results.extend(self.check_thermal(params))
+        all_results.extend(self.check_manufacturing(params))
+
+        passed = sum(1 for r in all_results if r.passed)
+        failed = len(all_results) - passed
+
+        # C3 fix: require minimum coverage. If no checks ran (all params None),
+        # the gate must not silently pass as "feasible".
+        MIN_CHECKS = 3
+        if len(all_results) == 0:
+            feasible = False
+            risk_items = ["[UNKNOWN] No checks were performed - input params may be missing or unrecognized."]
+        elif len(all_results) < MIN_CHECKS:
+            feasible = failed == 0
+            risk_items = [f"[WARNING] Only {len(all_results)} check(s) ran (minimum {MIN_CHECKS} recommended); result may be unreliable."]
+        else:
+            feasible = failed == 0
+            risk_items = []
+
+        # Collect risk items (failed or low-margin checks)
+        for r in all_results:
+            if not r.passed:
+                risk_items.append(f"[FAIL] {r.name}: {r.message}")
+            elif r.margin is not None and r.margin < 10:
+                risk_items.append(f"[RISK] {r.name}: margin only {r.margin:.1f}%")
+
+        return FeasibilityReport(
+            feasible=feasible,
+            total_checks=len(all_results),
+            passed_checks=passed,
+            failed_checks=failed,
+            results=all_results,
+            risk_items=risk_items,
+        )
+
+    def is_feasible(self, params: Dict[str, Any]) -> bool:
+        """Quick feasibility check (boolean only)."""
+        return self.evaluate(params).feasible
+
+    def filter_feasible(self, param_sets: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
+        """Filter a list of parameter sets into feasible and infeasible.
+
+        Returns:
+            Tuple of (feasible_sets, infeasible_sets)
+        """
+        feasible = []
+        infeasible = []
+        for params in param_sets:
+            if self.is_feasible(params):
+                feasible.append(params)
+            else:
+                infeasible.append(params)
+        return feasible, infeasible

+ 759 - 0
src/afmcore/metrics.py

@@ -0,0 +1,759 @@
+"""Metrics single source of truth + normalized result parser.
+
+This module is THE authoritative definition of simulation output metrics
+for the whole platform. Consumers MUST import from here instead of
+maintaining their own copies:
+
+- src/solver_core.py            (legacy local solver)
+- scripts/robust_motorcad.py    (robust solver / executor)
+- web/backend/app/metrics_constants.py  (Web backend)
+
+Design:
+- METRIC_DEFINITIONS: complete list of {key, label, aliases, unit, direction, required}
+- Normalized matching: full-width -> half-width brackets, strip ALL whitespace
+  (incl. full-width space U+3000), lowercase. This fixes the historical bug
+  where "Average torque (virtual work)" / "\\u5e73\\u5747\\u8f6c\\u77e9 (virtual work)"
+  could not be matched due to invisible full-width chars.
+- parse_export(): section-aware semicolon CSV parser with tolerant numeric scan.
+- pick_metric()/extract_all_metrics(): section-priority exact match ->
+  all-section exact match -> prefix fuzzy match.
+
+All source is ASCII only.
+"""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from typing import Dict, List, Optional, Tuple, Any
+
+
+# ---------------------------------------------------------------------------
+# Single source of truth: metric definitions
+# ---------------------------------------------------------------------------
+
+# unit: SI display unit. direction: higher/lower/neutral (for optimization).
+# required: True means a valid result MUST contain this metric.
+METRIC_DEFINITIONS: List[Dict[str, Any]] = [
+    # --- Torque ---
+    {
+        "key": "tavg_nm",
+        "label": "Average Torque [Nm]",
+        "unit": "Nm",
+        "direction": "higher",
+        "required": True,
+        "aliases": [
+            "Average torque (virtual work)",
+            "Average Torque (Virtual Work)",
+            "Average torque (DQ)",
+            "Average torque (loop torque)",
+            "Torque (average)",
+            "Average Torque",
+            "\u5e73\u5747\u8f6c\u77e9 (virtual work)",
+            "\u5e73\u5747\u8f6c\u77e9(virtual work)",
+            "\u5e73\u5747\u8f6c\u77e9 (DQ)",
+            "\u5e73\u5747\u8f6c\u77e9(DQ)",
+            "\u5e73\u5747\u8f6c\u77e9 (loop torque)",
+            "\u5e73\u5747\u8f6c\u77e9(loop torque)",
+            "\u5e73\u5747\u8f6c\u77e9",
+        ],
+    },
+    {
+        "key": "tmax_nm",
+        "label": "Max Torque [Nm]",
+        "unit": "Nm",
+        "direction": "higher",
+        "required": False,
+        "aliases": [
+            "Maximum torque (virtual work)",
+            "Max Torque",
+            "\u6700\u5927\u8f6c\u77e9 (virtual work)",
+            "\u6700\u5927\u8f6c\u77e9(virtual work)",
+            "\u6700\u5927\u8f6c\u77e9",
+        ],
+    },
+    {
+        "key": "tmin_nm",
+        "label": "Min Torque [Nm]",
+        "unit": "Nm",
+        "direction": "higher",
+        "required": False,
+        "aliases": [
+            "Minimum torque (virtual work)",
+            "Min Torque",
+            "\u6700\u5c0f\u8f6c\u77e9 (virtual work)",
+            "\u6700\u5c0f\u8f6c\u77e9(virtual work)",
+            "\u6700\u5c0f\u8f6c\u77e9",
+        ],
+    },
+    {
+        "key": "ripple_pct",
+        "label": "Torque Ripple [%]",
+        "unit": "%",
+        "direction": "lower",
+        "required": True,
+        "aliases": [
+            "Torque Ripple (VW) [%]",
+            "Torque Ripple (VW)[%]",
+            "Torque Ripple (VW) %",
+            "Torque Ripple (VW) (%)",
+            "Torque Ripple [%]",
+            "Torque Ripple (%)",
+            "Torque Ripple %",
+            "\u8f6c\u77e9\u8109\u52a8 (VW) [%]",
+            "\u8f6c\u77e9\u8109\u52a8(VW)[%]",
+            "\u8f6c\u77e9\u8109\u52a8 [%]",
+            "\u8f6c\u77e9\u8109\u52a8[%]",
+            "\u8f6c\u77e9\u8109\u52a8",
+        ],
+    },
+    {
+        "key": "ripple_nm",
+        "label": "Torque Ripple [Nm]",
+        "unit": "Nm",
+        "direction": "lower",
+        "required": False,
+        "aliases": [
+            "Torque Ripple (VW)",
+            "Torque Ripple",
+            "\u8f6c\u77e9\u8109\u52a8 (VW)",
+            "\u8f6c\u77e9\u8109\u52a8(VW)",
+        ],
+    },
+    {
+        "key": "ripple_abs_nm",
+        "label": "Torque Ripple (abs) [Nm]",
+        "unit": "Nm",
+        "direction": "lower",
+        "required": False,
+        "aliases": [
+            "Torque Ripple (VW) (abs)",
+            "\u8f6c\u77e9\u8109\u52a8\u7edd\u5bf9\u503c",
+        ],
+    },
+    {
+        "key": "shaft_torque_nm",
+        "label": "Shaft Torque [Nm]",
+        "unit": "Nm",
+        "direction": "higher",
+        "required": False,
+        "aliases": [
+            "Shaft Torque",
+            "Shaft Torque (Nm)",
+            "\u8f74\u8f6c\u77e9",
+            "\u8f74\u8f6c\u77e9(Nm)",
+        ],
+    },
+    {
+        "key": "stall_torque_nm",
+        "label": "Stall Torque [Nm]",
+        "unit": "Nm",
+        "direction": "higher",
+        "required": False,
+        "aliases": [
+            "Stall Torque",
+            "Stall Torque (Nm)",
+            "\u5835\u8f6c\u8f6c\u77e9",
+            "\u5835\u8f6c\u8f6c\u77e9(Nm)",
+        ],
+    },
+    {
+        "key": "torque_constant",
+        "label": "Torque Constant [Nm/A]",
+        "unit": "Nm/A",
+        "direction": "higher",
+        "required": False,
+        "aliases": [
+            "Torque Constant (Kt)",
+            "Torque Constant",
+            "Torque Constant [Nm/A]",
+            "\u8f6c\u77e9\u5e38\u6570(Kt)",
+            "\u8f6c\u77e9\u5e38\u6570\uff08Kt\uff09",
+        ],
+    },
+    # --- Efficiency / power ---
+    {
+        "key": "efficiency_pct",
+        "label": "Efficiency [%]",
+        "unit": "%",
+        "direction": "higher",
+        "required": True,
+        "aliases": [
+            "System Efficiency",
+            "Efficiency",
+            "\u7cfb\u7edf\u6548\u7387",
+            "\u6548\u7387",
+        ],
+    },
+    {
+        "key": "input_power_w",
+        "label": "Input Power [W]",
+        "unit": "W",
+        "direction": "neutral",
+        "required": False,
+        "aliases": [
+            "Input Power",
+            "\u8f93\u5165\u529f\u7387",
+        ],
+    },
+    {
+        "key": "output_power_w",
+        "label": "Output Power [W]",
+        "unit": "W",
+        "direction": "higher",
+        "required": False,
+        "aliases": [
+            "Output Power",
+            "\u8f93\u51fa\u529f\u7387",
+            "\u8f93\u51fa\u529f\u7387_\u7535\u538b\u9650\u5236\u9644\u8fd1\u5de5\u4f5c\u70b9",
+        ],
+    },
+    {
+        "key": "em_power_w",
+        "label": "EM Power [W]",
+        "unit": "W",
+        "direction": "higher",
+        "required": False,
+        "aliases": [
+            "Electromagnetic Power",
+            "EM Power",
+            "\u7535\u78c1\u529f\u7387",
+            "\u7535\u78c1\u529f\u7387_\u7535\u538b\u9650\u5236\u9644\u8fd1\u5de5\u4f5c\u70b9",
+        ],
+    },
+    {
+        "key": "power_factor",
+        "label": "Power Factor",
+        "unit": "",
+        "direction": "higher",
+        "required": False,
+        "aliases": [
+            "Power Factor",
+            "\u529f\u7387\u56e0\u6570",
+        ],
+    },
+    # --- Losses ---
+    {
+        "key": "total_losses_w",
+        "label": "Total Losses [W]",
+        "unit": "W",
+        "direction": "lower",
+        "required": True,
+        "aliases": [
+            "Total Losses (on load)",
+            "Total Losses",
+            "\u603b\u635f\u8017(\u989d\u5b9a)",
+            "\u603b\u635f\u8017 (\u989d\u5b9a)",
+            "\u603b\u635f\u8017(\u7a7a\u8f7d)",
+            "\u603b\u635f\u8017",
+        ],
+    },
+    {
+        "key": "copper_loss_w",
+        "label": "DC Copper Loss [W]",
+        "unit": "W",
+        "direction": "lower",
+        "required": False,
+        "aliases": [
+            "Armature DC Copper Loss (on load)",
+            "Armature Copper Loss (on load)",
+            "DC Copper Loss (on load)",
+            "\u7535\u67a2\u76f4\u6d41\u94dc\u8017(\u5e26\u8f7d)",
+            "\u7535\u67a2\u76f4\u6d41\u94dc\u8017 (\u5e26\u8f7d)",
+            "\u7535\u67a2\u76f4\u6d41\u94dc\u8017(\u7a7a\u8f7d)",
+            "\u7535\u67a2\u76f4\u6d41\u94dc\u8017",
+            "\u94dc\u8017",
+        ],
+    },
+    {
+        "key": "iron_loss_w",
+        "label": "Stator Iron Loss [W]",
+        "unit": "W",
+        "direction": "lower",
+        "required": False,
+        "aliases": [
+            "Stator iron Loss [total] (on load)",
+            "Stator Iron Loss [total] (on load)",
+            "Stator Iron Loss",
+            "Iron Loss",
+            "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017](\u989d\u5b9a)",
+            "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017] (\u989d\u5b9a)",
+            "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017](\u7a7a\u8f7d)",
+            "\u5b9a\u5b50\u94c1\u8017",
+            "\u94c1\u635f",
+            "\u94c1\u8017",
+        ],
+    },
+    {
+        "key": "magnet_loss_w",
+        "label": "Magnet Loss [W]",
+        "unit": "W",
+        "direction": "lower",
+        "required": False,
+        "aliases": [
+            "Magnet Loss (on load)",
+            "Magnet Loss",
+            "\u6c38\u78c1\u4f53\u635f\u8017(\u989d\u5b9a)",
+            "\u6c38\u78c1\u4f53\u635f\u8017 (\u989d\u5b9a)",
+            "\u6c38\u78c1\u4f53\u635f\u8017(\u7a7a\u8f7d)",
+            "\u78c1\u94a2\u635f\u8017",
+        ],
+    },
+    # --- Electrical ---
+    {
+        "key": "back_emf_v",
+        "label": "Back EMF LL rms [V]",
+        "unit": "V",
+        "direction": "neutral",
+        "required": False,
+        "aliases": [
+            "Back EMF Line-Line Voltage (rms)",
+            "Back EMF Line-Line Voltage",
+            "Back EMF Line-Line Voltage (peak)",
+            "Back EMF (rms)",
+            "\u7ebf\u95f4\u53cd\u5411\u7535\u52a8\u52bf\u6709\u6548\u503c",
+            "\u7ebf\u95f4\u53cd\u5411\u7535\u52a8\u52bf\u5e45\u503c",
+            "\u53cd\u7535\u52a8\u52bf",
+        ],
+    },
+    {
+        "key": "back_emf_thd_pct",
+        "label": "Back EMF THD [%]",
+        "unit": "%",
+        "direction": "lower",
+        "required": False,
+        "aliases": [
+            "Harmonic Distortion Back EMF Line-Line Voltage",
+            "Back EMF THD",
+            "\u7ebf\u53cd\u5411\u7535\u52a8\u52bf\u8c10\u6ce2",
+            "\u7ebf\u7535\u538b\u8c10\u6ce2",
+        ],
+    },
+    {
+        "key": "phase_current_peak_a",
+        "label": "Phase Current (peak) [A]",
+        "unit": "A",
+        "direction": "neutral",
+        "required": False,
+        "aliases": [
+            "Peak Phase Current",
+            "Phase Current (peak)",
+            "\u76f8\u7535\u6d41\u5cf0\u503c",
+        ],
+    },
+    {
+        "key": "line_current_rms_a",
+        "label": "Line Current (rms) [A]",
+        "unit": "A",
+        "direction": "neutral",
+        "required": False,
+        "aliases": [
+            "Line Current (rms)",
+            "Line Current",
+            "\u7ebf\u7535\u6d41 (\u6709\u6548\u503c)",
+            "\u7ebf\u7535\u6d41(\u6709\u6548\u503c)",
+            "\u76f8\u7535\u6d41\u6709\u6548\u503c",
+        ],
+    },
+    # --- Speed ---
+    {
+        "key": "shaft_speed_rpm",
+        "label": "Shaft Speed [rpm]",
+        "unit": "rpm",
+        "direction": "neutral",
+        "required": False,
+        "aliases": [
+            "Shaft Speed",
+            "Speed",
+            "\u8f6c\u901f[RPM]",
+            "\u8f6c\u901f [RPM]",
+            "\u8f6c\u901f{RPM}",
+            "\u8f6c\u901f",
+        ],
+    },
+    {
+        "key": "no_load_speed_rpm",
+        "label": "No-load Speed [rpm]",
+        "unit": "rpm",
+        "direction": "neutral",
+        "required": False,
+        "aliases": [
+            "No load speed",
+            "No-Load Speed",
+            "\u7a7a\u8f7d\u8f6c\u901f",
+        ],
+    },
+    # --- Thermal ---
+    {
+        "key": "winding_temp_c",
+        "label": "Winding Temp [C]",
+        "unit": "C",
+        "direction": "lower",
+        "required": False,
+        "aliases": [
+            "Winding Temperature",
+            "\u7ed5\u7ec4\u6e29\u5ea6",
+            "T[\u7ed5\u7ec4\u5e73\u5747]",
+            "T [Winding (A) Average]",
+        ],
+    },
+    {
+        "key": "winding_hotspot_temp_c",
+        "label": "Winding Hotspot Temp [C]",
+        "unit": "C",
+        "direction": "lower",
+        "required": False,
+        "domain": "thermal",
+        "aliases": [
+            "Winding Hotspot Temperature",
+            "Winding Hotspot Temp",
+            "Hotspot Temperature",
+            "\u7ed5\u7ec4\u70ed\u70b9\u6e29\u5ea6",
+            "\u70ed\u70b9\u6e29\u5ea6",
+            "T[\u7ed5\u7ec4\u6700\u9ad8]",
+            "T [Winding (A) Maximum]",
+            "T [EWdg (Outer) Maximum]",
+        ],
+    },
+    {
+        "key": "magnet_temp_c",
+        "label": "Magnet Temp [C]",
+        "unit": "C",
+        "direction": "lower",
+        "required": False,
+        "domain": "thermal",
+        "aliases": [
+            "Magnet Temperature",
+            "Magnet Temp",
+            "Permanent Magnet Temperature",
+            "\u6c38\u78c1\u4f53\u6e29\u5ea6",
+            "\u78c1\u94a2\u6e29\u5ea6",
+            "T [Magnet Average]",
+            "T [Magnet Active]",
+            "T [Magnet Maximum]",
+        ],
+    },
+    {
+        "key": "stator_temp_c",
+        "label": "Stator Temp [C]",
+        "unit": "C",
+        "direction": "lower",
+        "required": False,
+        "domain": "thermal",
+        "aliases": [
+            "Stator Temperature",
+            "Stator Temp",
+            "Stator Winding Temperature",
+            "\u5b9a\u5b50\u6e29\u5ea6",
+            "T[\u5b9a\u5b50\u5916\u8868\u9762]",
+            "T[\u5b9a\u5b50\u8f6d]",
+        ],
+    },
+    {
+        "key": "bearing_temp_c",
+        "label": "Bearing Temp [C]",
+        "unit": "C",
+        "direction": "lower",
+        "required": False,
+        "domain": "thermal",
+        "aliases": [
+            "Bearing Temperature",
+            "Bearing Temp",
+            "\u8f74\u627f\u6e29\u5ea6",
+            "T[\u540e\u8f74\u627f]",
+        ],
+    },
+    {
+        "key": "temp_rise_c",
+        "label": "Temperature Rise [C]",
+        "unit": "C",
+        "direction": "lower",
+        "required": False,
+        "domain": "thermal",
+        "aliases": [
+            "Temperature Rise",
+            "Temp Rise",
+            "Winding Temperature Rise",
+            "\u6e29\u5347",
+            "\u7ed5\u7ec4\u6e29\u5347",
+            "dT [Winding (Maximum) - Ambient]",
+            "dT [Winding (Average) - Ambient]",
+        ],
+    },
+    {
+        "key": "thermal_resistance_k_w",
+        "label": "Thermal Resistance [K/W]",
+        "unit": "K/W",
+        "direction": "lower",
+        "required": False,
+        "domain": "thermal",
+        "aliases": [
+            "Thermal Resistance",
+            "Thermal Resistance (total)",
+            "\u70ed\u963b",
+            "\u603b\u70ed\u963b",
+            "Rt [Winding (Maximum) - Ambient]",
+            "Rt [Winding (Average) - Ambient]",
+        ],
+    },
+    # --- Structural / mechanical ---
+    {
+        "key": "axial_force_n",
+        "label": "Axial Force [N]",
+        "unit": "N",
+        "direction": "lower",
+        "required": False,
+        "domain": "structural",
+        "aliases": [
+            "Axial Force",
+            "Axial Magnetic Force",
+            "Axial Pull Force",
+            "\u8f74\u5411\u529b",
+            "\u8f74\u5411\u78c1\u5438\u529b",
+        ],
+    },
+    {
+        "key": "radial_force_n",
+        "label": "Radial Force [N]",
+        "unit": "N",
+        "direction": "lower",
+        "required": False,
+        "domain": "structural",
+        "aliases": [
+            "Radial Force",
+            "Radial Magnetic Force",
+            "\u5f84\u5411\u529b",
+            "\u5f84\u5411\u78c1\u5438\u529b",
+        ],
+    },
+    {
+        "key": "max_stress_mpa",
+        "label": "Max Stress [MPa]",
+        "unit": "MPa",
+        "direction": "lower",
+        "required": False,
+        "domain": "structural",
+        "aliases": [
+            "Maximum Stress",
+            "Max Stress",
+            "Von Mises Stress (max)",
+            "\u6700\u5927\u5e94\u529b",
+            "\u5bc6\u5e94\u529b\u6700\u5927\u503c",
+        ],
+    },
+    {
+        "key": "deformation_mm",
+        "label": "Max Deformation [mm]",
+        "unit": "mm",
+        "direction": "lower",
+        "required": False,
+        "domain": "structural",
+        "aliases": [
+            "Maximum Deformation",
+            "Max Deformation",
+            "Total Deformation (max)",
+            "\u6700\u5927\u53d8\u5f62",
+            "\u603b\u53d8\u5f62\u6700\u5927\u503c",
+        ],
+    },
+]
+
+
+# ---------------------------------------------------------------------------
+# Derived lookup structures
+# ---------------------------------------------------------------------------
+
+METRIC_KEYS: frozenset = frozenset(m["key"] for m in METRIC_DEFINITIONS)
+METRIC_LABELS: Dict[str, str] = {m["key"]: m["label"] for m in METRIC_DEFINITIONS}
+METRIC_UNITS: Dict[str, str] = {m["key"]: m["unit"] for m in METRIC_DEFINITIONS}
+METRIC_DIRECTIONS: Dict[str, str] = {m["key"]: m["direction"] for m in METRIC_DEFINITIONS}
+REQUIRED_METRICS: frozenset = frozenset(m["key"] for m in METRIC_DEFINITIONS if m["required"])
+
+
+# ---------------------------------------------------------------------------
+# Name normalization (the historical tavg/ripple parsing fix)
+# ---------------------------------------------------------------------------
+
+# Full-width -> half-width char map
+_FULL_TO_HALF = {
+    "\uff08": "(",   # full-width (
+    "\uff09": ")",   # full-width )
+    "\uff0c": ",",   # full-width ,
+    "\uff1b": ";",   # full-width ;
+    "\uff1a": ":",   # full-width :
+    "\uff3b": "[",   # full-width [
+    "\uff3d": "]",   # full-width ]
+    "\u3000": " ",   # full-width space (ideographic space)
+}
+
+
+def normalize_name(name: str) -> str:
+    """Normalize a Motor-CAD field name for matching.
+
+    - Full-width brackets/punct -> half-width
+    - Strip ALL whitespace (incl. full-width space U+3000)
+    - Lowercase
+    Example: "\\u5e73\\u5747\\u8f6c\\u77e9 (virtual work)" and
+    "Average torque (virtual work)" both normalize to the same token space.
+    """
+    s = name or ""
+    for full, half in _FULL_TO_HALF.items():
+        s = s.replace(full, half)
+    s = "".join(s.split())
+    return s.lower()
+
+
+# Pre-normalized alias map: normalized_alias -> metric_key
+_METRIC_ALIAS_MAP: Dict[str, str] = {}
+for _m in METRIC_DEFINITIONS:
+    for _alias in _m["aliases"]:
+        _METRIC_ALIAS_MAP[normalize_name(_alias)] = _m["key"]
+
+
+# Section name priority for exact matching (English + Chinese).
+SECTION_PRIORITY = [
+    "E-Magnetics", "\u7535\u78c1",
+    "Drive", "\u9a71\u52a8",
+    "Losses", "\u635f\u8017",
+    "Materials", "\u6750\u6599",
+    "Miscellaneous", "\u6742\u9879",
+]
+
+
+# ---------------------------------------------------------------------------
+# Parsing
+# ---------------------------------------------------------------------------
+
+def parse_export(path: str | Path) -> Dict[str, Dict[str, float]]:
+    """Parse a Motor-CAD semicolon-delimited export file into
+    {section_name: {field_name: value}}.
+
+    - Section header = a line WITHOUT ';'.
+    - Data line = "field;value[;...]" -> field is parts[0], value is the
+      first numeric token in parts[1:] (tolerates thousands separators).
+    - Multi-encoding fallback: utf-8-sig, utf-8, gbk, cp1252, latin-1.
+    """
+    text: Optional[str] = None
+    for encoding in ("utf-8-sig", "utf-8", "gbk", "cp1252", "latin-1"):
+        try:
+            text = Path(path).read_text(encoding=encoding)
+            break
+        except (UnicodeDecodeError, OSError):
+            continue
+    if text is None:
+        return {}
+
+    result: Dict[str, Dict[str, float]] = {}
+    section = "(root)"
+    result.setdefault(section, {})
+    for raw in text.splitlines():
+        line = raw.strip()
+        if not line:
+            continue
+        if ";" not in line:
+            section = line.strip()
+            result.setdefault(section, {})
+            continue
+        parts = line.split(";")
+        field = parts[0].strip().strip('"')
+        if not field:
+            continue
+        value: Optional[float] = None
+        for part in parts[1:]:
+            part = part.strip()
+            if not part:
+                continue
+            try:
+                value = float(part.replace(",", "."))
+                break
+            except ValueError:
+                continue
+        if value is None:
+            continue
+        result.setdefault(section, {})[field] = value
+    return result
+
+
+def _exact_match_in_sections(
+    parsed: Dict[str, Dict[str, float]],
+    wanted: set,
+    section_order: Optional[List[str]] = None,
+) -> Optional[float]:
+    """Exact normalized match. If section_order given, scan sections in that
+    order first; then fall back to all sections."""
+    if section_order:
+        for section_name in section_order:
+            section = parsed.get(section_name)
+            if not section:
+                continue
+            for field, value in section.items():
+                if normalize_name(field) in wanted:
+                    return value
+    for section in parsed.values():
+        for field, value in section.items():
+            if normalize_name(field) in wanted:
+                return value
+    return None
+
+
+def pick_metric(parsed: Dict[str, Dict[str, float]], metric_key: str) -> Optional[float]:
+    """Extract a single metric from parsed export results.
+
+    Strategy (reference: KNOWLEDGE_BASE section 4.3):
+    1. Exact normalized match, E-Magnetics section priority.
+    2. Exact normalized match across all sections.
+    3. Prefix fuzzy match across all sections (len > 3 to avoid noise).
+    """
+    wanted = {
+        norm for alias, key in _METRIC_ALIAS_MAP.items()
+        if key == metric_key for norm in [alias]
+    }
+    if not wanted:
+        return None
+
+    value = _exact_match_in_sections(parsed, wanted, SECTION_PRIORITY)
+    if value is not None:
+        return value
+
+    # Phase 3: prefix fuzzy match - ONLY when the exported field name is
+    # LONGER than / starts with a known alias. The reverse direction
+    # (alias longer than field) is intentionally dropped because it caused
+    # false positives, e.g. alias "Torque Ripple (VW) (abs)" matched the
+    # plain "Torque Ripple (VW)" field and polluted ripple_abs_nm.
+    for alias_norm in wanted:
+        if len(alias_norm) <= 3:
+            continue
+        for section in parsed.values():
+            for field, value in section.items():
+                field_norm = normalize_name(field)
+                if len(field_norm) <= 3:
+                    continue
+                if not field_norm.startswith(alias_norm):
+                    continue
+                # Guard: a "%" field (e.g. "Torque Ripple (VW) [%]") must
+                # never satisfy a non-percent alias (e.g. "Torque Ripple (VW)")
+                # or it would pollute the Nm-valued metric with a % value.
+                if "%" in field_norm and "%" not in alias_norm:
+                    continue
+                return value
+    return None
+
+
+def extract_all_metrics(parsed: Dict[str, Dict[str, float]]) -> Dict[str, float]:
+    """Extract every defined metric from parsed results."""
+    out: Dict[str, float] = {}
+    for m in METRIC_DEFINITIONS:
+        val = pick_metric(parsed, m["key"])
+        if val is not None:
+            out[m["key"]] = val
+    return out
+
+
+def check_required_metrics(metrics: Dict[str, float]) -> Tuple[bool, List[str]]:
+    """Check that all required metrics are present."""
+    missing = [k for k in REQUIRED_METRICS if k not in metrics]
+    return (len(missing) == 0, missing)
+
+
+def extract_metrics_from_file(path: str | Path) -> Dict[str, float]:
+    """One-shot: parse an export file and extract all metrics."""
+    return extract_all_metrics(parse_export(path))

+ 133 - 0
src/afmcore/strategies/__init__.py

@@ -0,0 +1,133 @@
+"""Execution strategy abstraction (platform extension point).
+
+A SimulationStrategy decides WHICH parameter points to simulate next.
+This separates "what to run" (strategy) from "how to run it" (adapter),
+so the executor only ever asks the strategy for a batch of points and
+feeds results back - it never hard-codes a search algorithm.
+
+Strategy protocol (implemented by every strategy):
+  - select_next(count=None) -> [{'point_id', 'params'}, ...]
+      Return the next batch of points to simulate. Empty list = no more.
+  - report(point_id, metrics, status='ok') -> None
+      Feed back one simulated point's result.
+  - next_batch_ready() -> bool
+      True if another batch can be selected.
+  - is_converged() -> bool
+      True when the strategy considers the search finished.
+  - state() -> dict
+      Serializable snapshot for UI / checkpoint / resume.
+
+Built-in strategies:
+  - full_factorial : Cartesian product of explicit value lists.
+  - lhs            : Latin Hypercube Sampling over parameter ranges.
+  - adaptive       : bridge to a search backend (FeasibilityFirstSearch on
+                     the web side); shared core stays free of web deps.
+
+All source is ASCII only.
+"""
+from __future__ import annotations
+
+import abc
+from typing import Any, Dict, List, Optional, Type
+
+
+class SimulationStrategy(abc.ABC):
+    """Uniform protocol over point-selection strategies."""
+
+    kind: str = "abstract"
+
+    def __init__(self, batch_size: int = 1, **kwargs: Any):
+        self.batch_size = max(1, int(batch_size))
+        self._extra = kwargs
+
+    # -- selection --------------------------------------------------------
+    @abc.abstractmethod
+    def select_next(self, count: Optional[int] = None) -> List[Dict[str, Any]]:
+        """Return next batch: [{'point_id', 'params'}, ...]; [] if done."""
+
+    @abc.abstractmethod
+    def report(self, point_id: Any, metrics: Dict[str, float], status: str = "ok") -> None:
+        """Feed back one point's result."""
+
+    # -- status -----------------------------------------------------------
+    def next_batch_ready(self) -> bool:
+        """True if more points can be selected."""
+        return len(self.select_next(0)) > 0  # pragma: no cover - overridden
+
+    def is_converged(self) -> bool:
+        """True when the strategy has no more points to run."""
+        return not self.next_batch_ready()
+
+    def state(self) -> Dict[str, Any]:
+        """Serializable snapshot (override with strategy-specific fields)."""
+        return {"kind": self.kind, "batch_size": self.batch_size}
+
+    def summary(self) -> str:
+        st = self.state()
+        parts = ["%s=%s" % (k, v) for k, v in sorted(st.items())]
+        return "%s(%s)" % (self.kind, ", ".join(parts))
+
+
+# ---------------------------------------------------------------------------
+# Registry
+# ---------------------------------------------------------------------------
+
+STRATEGY_REGISTRY: Dict[str, Type[SimulationStrategy]] = {}
+
+
+def register_strategy(kind: str, strategy_class: Type[SimulationStrategy]) -> None:
+    """Register a strategy implementation under a kind name."""
+    if not (isinstance(kind, str) and kind):
+        raise ValueError("strategy kind must be a non-empty string")
+    if not (isinstance(strategy_class, type) and issubclass(strategy_class, SimulationStrategy)):
+        raise TypeError("strategy_class must be a SimulationStrategy subclass")
+    STRATEGY_REGISTRY[kind] = strategy_class
+
+
+def get_strategy(kind: str, **kwargs: Any) -> SimulationStrategy:
+    """Create a fresh strategy instance for the given kind."""
+    if kind not in STRATEGY_REGISTRY:
+        raise KeyError(
+            "Strategy %r is not registered. Registered: %s"
+            % (kind, ", ".join(sorted(STRATEGY_REGISTRY.keys())))
+        )
+    return STRATEGY_REGISTRY[kind](**kwargs)
+
+
+def list_strategy_kinds() -> List[str]:
+    """Registered strategy kinds (sorted)."""
+    return sorted(STRATEGY_REGISTRY.keys())
+
+
+def is_registered(kind: str) -> bool:
+    """True if the strategy kind is registered."""
+    return kind in STRATEGY_REGISTRY
+
+
+# Historical / legacy method names mapped to their current strategy kind.
+# "active_learning" and "constrained" were pre-platform names for the
+# feasibility-first adaptive search.
+METHOD_ALIASES: Dict[str, str] = {
+    "active_learning": "adaptive",
+    "constrained": "adaptive",
+}
+
+
+def normalize_method(method: Optional[str]) -> str:
+    """Normalize a plan search-strategy method to a registered kind.
+
+    Unknown methods are returned unchanged (so plan validation can report
+    them explicitly instead of silently failing later).
+    """
+    if not method:
+        return "full_factorial"
+    m = str(method).strip().lower()
+    return METHOD_ALIASES.get(m, m)
+
+
+# Default strategies are registered on import (see sibling modules).
+from .full_factorial import FullFactorialStrategy  # noqa: E402,F401
+from .lhs import LHSStrategy  # noqa: E402,F401
+from .adaptive import AdaptiveBridgeStrategy  # noqa: E402,F401
+from .morris import MorrisStrategy  # noqa: E402,F401
+from .surrogate_guided import SurrogateGuidedStrategy  # noqa: E402,F401

+ 114 - 0
src/afmcore/strategies/adaptive.py

@@ -0,0 +1,114 @@
+"""Adaptive bridge strategy - connects to a search backend.
+
+Shared core stays free of web / external-service dependencies, so the
+adaptive algorithm (web-side FeasibilityFirstSearch) is injected here as a
+backend object that exposes three methods:
+
+    generate_initial_batch() -> [SearchPoint]
+    select_next_batch()      -> [SearchPoint]
+    report_result(point_id, metrics, status) -> None
+
+The web-side orchestrator (strategy_orchestrator) wraps its FeasibilityFirstSearch
+in this protocol and hands it to the executor as a plain SimulationStrategy;
+the executor never learns about active learning / trust regions.
+
+All source is ASCII only.
+"""
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional
+
+from . import SimulationStrategy, register_strategy
+
+
+class AdaptiveBridgeStrategy(SimulationStrategy):
+    """SimulationStrategy adapter over a search backend.
+
+    First select_next() call pulls the initial batch, subsequent calls pull
+    the next active-learning batch; results are forwarded to the backend.
+    """
+
+    kind = "adaptive"
+
+    def __init__(self, backend: Any = None, batch_size: int = 4, **kwargs: Any):
+        super().__init__(batch_size=batch_size, **kwargs)
+        self.backend = backend
+        self._initialized = False
+        self._pending: List[Dict[str, Any]] = []
+        self._done: Dict[Any, Dict[str, Any]] = {}
+
+    def _require_backend(self) -> None:
+        if self.backend is None:
+            raise RuntimeError(
+                "AdaptiveBridgeStrategy needs a search backend. "
+                "Inject one (web-side FeasibilityFirstSearch wrapper) or use "
+                "full_factorial / lhs strategies."
+            )
+
+    def _normalize_points(self, raw: List[Any]) -> List[Dict[str, Any]]:
+        out: List[Dict[str, Any]] = []
+        for p in raw:
+            if isinstance(p, dict):
+                pid = p.get("point_id", p.get("id"))
+                params = p.get("params")
+            else:  # SearchPoint-like object
+                pid = getattr(p, "id", None)
+                params = getattr(p, "params", None)
+            if params is None:
+                params = p if isinstance(p, dict) else {}
+            out.append({"point_id": pid, "params": params})
+        return out
+
+    def select_next(self, count: Optional[int] = None) -> List[Dict[str, Any]]:
+        self._require_backend()
+        if not self._initialized:
+            self._initialized = True
+            self._pending = self._normalize_points(self.backend.generate_initial_batch())
+        elif not self._pending:
+            self._pending = self._normalize_points(self.backend.select_next_batch())
+        n = int(count) if count is not None else self.batch_size
+        batch = self._pending[:n]
+        self._pending = self._pending[n:]
+        return batch
+
+    def report(self, point_id: Any, metrics: Dict[str, float], status: str = "ok") -> None:
+        self._require_backend()
+        self._done[point_id] = {"metrics": dict(metrics), "status": status}
+        try:
+            self.backend.report_result(point_id, metrics, status)
+        except AttributeError:
+            pass
+
+    def next_batch_ready(self) -> bool:
+        self._require_backend()
+        if self._pending:
+            return True
+        if self._initialized:
+            try:
+                nxt = self.backend.select_next_batch()
+                self._pending = self._normalize_points(nxt)
+                return bool(self._pending)
+            except Exception:
+                return False
+        return True
+
+    def is_converged(self) -> bool:
+        return not self.next_batch_ready()
+
+    def state(self) -> Dict[str, Any]:
+        st = {
+            "kind": self.kind,
+            "batch_size": self.batch_size,
+            "initialized": self._initialized,
+            "pending": len(self._pending),
+            "done": len(self._done),
+        }
+        try:
+            if self.backend is not None and hasattr(self.backend, "get_state_summary"):
+                st["backend_state"] = self.backend.get_state_summary()
+        except Exception:
+            pass
+        return st
+
+
+register_strategy(AdaptiveBridgeStrategy.kind, AdaptiveBridgeStrategy)

+ 82 - 0
src/afmcore/strategies/full_factorial.py

@@ -0,0 +1,82 @@
+"""Full-factorial strategy - Cartesian product of explicit value lists.
+
+Reuses scan_engine.generate_cartesian_points for the product and wraps it
+in the SimulationStrategy protocol so it behaves identically to adaptive /
+lhs strategies from the executor's point of view.
+
+All source is ASCII only.
+"""
+from __future__ import annotations
+
+from itertools import product
+from typing import Any, Dict, List, Optional
+
+from . import SimulationStrategy, register_strategy
+
+
+def _cartesian_product(variables: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+    """Cartesian product of variable value lists -> [{'params': {...}}].
+    Self-contained (does not import scan_engine to avoid package coupling)."""
+    if not variables:
+        return [{"params": {}}]
+    names = [v["name"] for v in variables]
+    value_lists = [list(v.get("values", [])) for v in variables]
+    out = []
+    for combo in product(*value_lists):
+        out.append({"params": dict(zip(names, combo))})
+    return out
+
+
+class FullFactorialStrategy(SimulationStrategy):
+    """Runs every combination of the given variable value lists.
+
+    Input:  variables = [{"name": str, "values": [float], ...}]  OR
+            points    = [{"point_id": int, "params": {...}}] pre-built.
+    Points are yielded in batches; results are recorded by point_id.
+    """
+
+    kind = "full_factorial"
+
+    def __init__(
+        self,
+        variables: Optional[List[Dict[str, Any]]] = None,
+        points: Optional[List[Dict[str, Any]]] = None,
+        batch_size: int = 1,
+        **kwargs: Any,
+    ):
+        super().__init__(batch_size=batch_size, **kwargs)
+        self._pending: List[Dict[str, Any]] = []
+        if points is not None:
+            # trust caller-provided points (already normalized)
+            self._pending = [dict(p) for p in points]
+        elif variables:
+            for i, pt in enumerate(_cartesian_product(list(variables)), 1):
+                self._pending.append({"point_id": i, "params": pt["params"]})
+        self._done: Dict[Any, Dict[str, Any]] = {}
+
+    def select_next(self, count: Optional[int] = None) -> List[Dict[str, Any]]:
+        n = int(count) if count is not None else self.batch_size
+        batch = self._pending[:n]
+        self._pending = self._pending[n:]
+        return batch
+
+    def report(self, point_id: Any, metrics: Dict[str, float], status: str = "ok") -> None:
+        self._done[point_id] = {"metrics": dict(metrics), "status": status}
+
+    def next_batch_ready(self) -> bool:
+        return len(self._pending) > 0
+
+    def is_converged(self) -> bool:
+        return len(self._pending) == 0
+
+    def state(self) -> Dict[str, Any]:
+        return {
+            "kind": self.kind,
+            "batch_size": self.batch_size,
+            "total": len(self._pending) + len(self._done),
+            "pending": len(self._pending),
+            "done": len(self._done),
+        }
+
+
+register_strategy(FullFactorialStrategy.kind, FullFactorialStrategy)

+ 101 - 0
src/afmcore/strategies/lhs.py

@@ -0,0 +1,101 @@
+"""LHS (Latin Hypercube Sampling) strategy - space-filling initial coverage.
+
+Pure-Python implementation (no numpy/scipy dependency, per project rule).
+Stratifies each parameter's range into N equal-probability bins, places
+exactly one sample per bin per parameter (random permutation + jitter),
+which guarantees one-dimensional projection uniformity for the initial
+batch before active learning takes over.
+
+All source is ASCII only.
+"""
+from __future__ import annotations
+
+import math
+import random
+from typing import Any, Dict, List, Optional
+
+from . import SimulationStrategy, register_strategy
+
+
+class LHSStrategy(SimulationStrategy):
+    """Latin Hypercube Sampling over explicit parameter ranges.
+
+    Input:  parameters = [{"name", "min_value", "max_value", "step"?}]
+            n_samples  = number of LHS samples (default 16)
+    Results are recorded by point_id; the strategy is single-batch
+    (all samples are produced up front), so next_batch_ready() is True
+    until the batch is consumed.
+    """
+
+    kind = "lhs"
+
+    def __init__(
+        self,
+        parameters: Optional[List[Dict[str, Any]]] = None,
+        n_samples: int = 16,
+        batch_size: int = 4,
+        rng_seed: Optional[int] = None,
+        **kwargs: Any,
+    ):
+        super().__init__(batch_size=batch_size, **kwargs)
+        self.parameters = list(parameters or [])
+        self.n_samples = max(1, int(n_samples))
+        self._pending: List[Dict[str, Any]] = self._lhs_sample(self.parameters, self.n_samples, rng_seed)
+        self._done: Dict[Any, Dict[str, Any]] = {}
+
+    def _lhs_sample(
+        self, parameters: List[Dict[str, Any]], n: int, seed: Optional[int]
+    ) -> List[Dict[str, Any]]:
+        if not parameters:
+            return []
+        rng = random.Random(seed)
+        n_params = len(parameters)
+        # Stratified columns: for each parameter, one sample per bin.
+        cols: List[List[float]] = []
+        for _j in range(n_params):
+            perm = list(range(n))
+            rng.shuffle(perm)
+            cols.append([(perm[i] + rng.random()) / n for i in range(n)])
+        points: List[Dict[str, Any]] = []
+        for i in range(n):
+            params: Dict[str, float] = {}
+            for j, p in enumerate(parameters):
+                lo = float(p.get("min_value", 0.0))
+                hi = float(p.get("max_value", 1.0))
+                norm = max(0.0, min(1.0, cols[j][i]))
+                val = lo + norm * (hi - lo)
+                step = p.get("step")
+                if step:
+                    val = round(val / float(step)) * float(step)
+                    val = max(lo, min(hi, val))
+                params[p["name"]] = round(val, 8)
+            points.append({"point_id": i + 1, "params": params})
+        return points
+
+    def select_next(self, count: Optional[int] = None) -> List[Dict[str, Any]]:
+        n = int(count) if count is not None else self.batch_size
+        batch = self._pending[:n]
+        self._pending = self._pending[n:]
+        return batch
+
+    def report(self, point_id: Any, metrics: Dict[str, float], status: str = "ok") -> None:
+        self._done[point_id] = {"metrics": dict(metrics), "status": status}
+
+    def next_batch_ready(self) -> bool:
+        return len(self._pending) > 0
+
+    def is_converged(self) -> bool:
+        return len(self._pending) == 0
+
+    def state(self) -> Dict[str, Any]:
+        return {
+            "kind": self.kind,
+            "batch_size": self.batch_size,
+            "n_samples": self.n_samples,
+            "n_parameters": len(self.parameters),
+            "pending": len(self._pending),
+            "done": len(self._done),
+        }
+
+
+register_strategy(LHSStrategy.kind, LHSStrategy)

+ 247 - 0
src/afmcore/strategies/morris.py

@@ -0,0 +1,247 @@
+"""Morris sensitivity screening strategy (blueprint section 6.1).
+
+Pure-Python implementation (no numpy/scipy dependency, per project rule).
+Generates One-At-a-Time (OAT) trajectories over the normalized parameter
+space, then computes elementary-effect mean (mu), standard deviation (sigma)
+and absolute mean (mu_star) for each parameter once results are reported.
+
+mu_star large  -> dominant main effect
+sigma large    -> strong interaction or non-linearity
+
+Strategy protocol:
+  - select_next(count=None) -> [{'point_id','params','trajectory_id','step','changed_param'}, ...]
+  - report(point_id, metrics, status='ok') -> None
+  - state() -> dict with sensitivity ranking
+
+All source is ASCII only.
+"""
+from __future__ import annotations
+
+import math
+import random
+from typing import Any, Dict, List, Optional, Tuple
+
+from . import SimulationStrategy, register_strategy
+
+
+class MorrisStrategy(SimulationStrategy):
+    """Morris elementary-effect sensitivity screening.
+
+    Input:
+      parameters     = [{"name", "min_value", "max_value", "step"?}]
+      n_trajectories = number of OAT trajectories (default 10)
+      n_levels       = grid levels per dimension (default 4, even)
+      objective_metric = metric key used for elementary effects (default "tavg_nm")
+      rng_seed       = optional seed for reproducibility
+
+    The strategy is single-batch: all trajectory points are produced up front.
+    Sensitivity indices are available in state() after all points reported.
+    """
+
+    kind = "morris"
+
+    def __init__(
+        self,
+        parameters: Optional[List[Dict[str, Any]]] = None,
+        n_trajectories: int = 10,
+        n_levels: int = 4,
+        objective_metric: str = "tavg_nm",
+        batch_size: int = 4,
+        rng_seed: Optional[int] = None,
+        **kwargs: Any,
+    ):
+        super().__init__(batch_size=batch_size, **kwargs)
+        self.parameters = list(parameters or [])
+        self.n_trajectories = max(1, int(n_trajectories))
+        self.n_levels = max(2, int(n_levels))
+        if self.n_levels % 2 != 0:
+            self.n_levels += 1  # Morris requires even number of levels
+        self.objective_metric = str(objective_metric)
+        self._delta = self.n_levels / (2.0 * (self.n_levels - 1))
+        self._pending: List[Dict[str, Any]] = []
+        self._done: Dict[Any, Dict[str, Any]] = {}
+        self._point_meta: Dict[Any, Dict[str, Any]] = {}
+        if self.parameters:
+            self._pending = self._generate_trajectories(rng_seed)
+
+    # -- generation ---------------------------------------------------------
+
+    def _grid_value(self, level_index: int) -> float:
+        """Map a level index [0, n_levels-1] to normalized [0, 1]."""
+        return level_index / (self.n_levels - 1)
+
+    def _generate_trajectories(self, seed: Optional[int]) -> List[Dict[str, Any]]:
+        """Generate all OAT trajectory points.
+
+        Each trajectory has n_params + 1 points. Step 0 is the random base;
+        step k (1..n_params) changes exactly one parameter (permutation order)
+        by +delta or -delta, kept inside [0, 1].
+        """
+        rng = random.Random(seed)
+        n_params = len(self.parameters)
+        if n_params == 0:
+            return []
+        # max level index such that base + delta <= 1
+        max_base_level = self.n_levels - 1 - int(round(self._delta * (self.n_levels - 1)))
+        max_base_level = max(0, min(self.n_levels - 1, max_base_level))
+
+        points: List[Dict[str, Any]] = []
+        pid = 1
+        for traj_id in range(self.n_trajectories):
+            # random base point on the grid (within valid range)
+            base_levels = [rng.randint(0, max_base_level) for _ in range(n_params)]
+            perm = list(range(n_params))
+            rng.shuffle(perm)
+            current = list(base_levels)
+            # step 0: base point
+            pts = self._levels_to_params(current)
+            meta = {"trajectory_id": traj_id, "step": 0, "changed_param": None}
+            entry = {"point_id": pid, "params": pts, **meta}
+            points.append(entry)
+            self._point_meta[pid] = meta
+            pid += 1
+            # steps 1..n_params: change one parameter at a time
+            for step_idx, param_idx in enumerate(perm, start=1):
+                direction = rng.choice([+1, -1])
+                new_level = current[param_idx] + direction * int(
+                    round(self._delta * (self.n_levels - 1))
+                )
+                # keep within [0, n_levels-1]; flip direction if out of range
+                if new_level < 0 or new_level >= self.n_levels:
+                    new_level = current[param_idx] - direction * int(
+                        round(self._delta * (self.n_levels - 1))
+                    )
+                new_level = max(0, min(self.n_levels - 1, new_level))
+                current[param_idx] = new_level
+                pts = self._levels_to_params(current)
+                pname = self.parameters[param_idx]["name"]
+                meta = {"trajectory_id": traj_id, "step": step_idx, "changed_param": pname}
+                entry = {"point_id": pid, "params": pts, **meta}
+                points.append(entry)
+                self._point_meta[pid] = meta
+                pid += 1
+        return points
+
+    def _levels_to_params(self, levels: List[int]) -> Dict[str, float]:
+        """Convert normalized grid levels to physical parameter values."""
+        out: Dict[str, float] = {}
+        for j, p in enumerate(self.parameters):
+            lo = float(p.get("min_value", 0.0))
+            hi = float(p.get("max_value", 1.0))
+            norm = self._grid_value(levels[j])
+            val = lo + norm * (hi - lo)
+            step = p.get("step")
+            if step:
+                val = round(val / float(step)) * float(step)
+                val = max(lo, min(hi, val))
+            out[p["name"]] = round(val, 8)
+        return out
+
+    # -- protocol -----------------------------------------------------------
+
+    def select_next(self, count: Optional[int] = None) -> List[Dict[str, Any]]:
+        n = int(count) if count is not None else self.batch_size
+        batch = self._pending[:n]
+        self._pending = self._pending[n:]
+        return batch
+
+    def report(self, point_id: Any, metrics: Dict[str, float], status: str = "ok") -> None:
+        self._done[point_id] = {"metrics": dict(metrics), "status": status}
+
+    def next_batch_ready(self) -> bool:
+        return len(self._pending) > 0
+
+    def is_converged(self) -> bool:
+        return len(self._pending) == 0
+
+    # -- sensitivity computation --------------------------------------------
+
+    def _elementary_effects(self) -> Dict[str, List[float]]:
+        """Compute elementary effects per parameter across all trajectories.
+
+        EE_j(traj) = (y_step_k - y_step_{k-1}) / delta_physical
+        where step k changed parameter j. delta_physical is the actual
+        physical change (normalized delta times parameter range).
+        """
+        effects: Dict[str, List[float]] = {}
+        # group done points by trajectory
+        traj_points: Dict[int, Dict[int, Tuple[Any, float]]] = {}
+        for pid, result in self._done.items():
+            meta = self._point_meta.get(pid)
+            if not meta:
+                continue
+            if result.get("status") != "ok":
+                continue
+            y = result["metrics"].get(self.objective_metric)
+            if y is None:
+                continue
+            tid = meta["trajectory_id"]
+            step = meta["step"]
+            traj_points.setdefault(tid, {})[step] = (pid, float(y))
+
+        for tid, steps in traj_points.items():
+            for step_k, (pid_k, y_k) in steps.items():
+                if step_k == 0:
+                    continue
+                meta_k = self._point_meta.get(pid_k)
+                if not meta_k:
+                    continue
+                pname = meta_k.get("changed_param")
+                if not pname or (step_k - 1) not in steps:
+                    continue
+                _, y_prev = steps[step_k - 1]
+                # physical delta for this parameter
+                pdef = next((p for p in self.parameters if p["name"] == pname), None)
+                if not pdef:
+                    continue
+                phys_range = float(pdef.get("max_value", 1.0)) - float(pdef.get("min_value", 0.0))
+                delta_phys = self._delta * phys_range
+                if delta_phys == 0:
+                    continue
+                ee = (y_k - y_prev) / delta_phys
+                effects.setdefault(pname, []).append(ee)
+        return effects
+
+    def _sensitivity_ranking(self) -> List[Dict[str, Any]]:
+        """Return per-parameter sensitivity indices sorted by mu_star desc."""
+        effects = self._elementary_effects()
+        ranking: List[Dict[str, Any]] = []
+        for p in self.parameters:
+            name = p["name"]
+            ees = effects.get(name, [])
+            if ees:
+                mu = sum(ees) / len(ees)
+                mu_star = sum(abs(e) for e in ees) / len(ees)
+                var = sum((e - mu) ** 2 for e in ees) / len(ees)
+                sigma = math.sqrt(var)
+            else:
+                mu = mu_star = sigma = 0.0
+            ranking.append({
+                "parameter": name,
+                "mu": round(mu, 8),
+                "mu_star": round(mu_star, 8),
+                "sigma": round(sigma, 8),
+                "n_effects": len(ees),
+            })
+        ranking.sort(key=lambda x: x["mu_star"], reverse=True)
+        return ranking
+
+    def state(self) -> Dict[str, Any]:
+        ranking = self._sensitivity_ranking()
+        return {
+            "kind": self.kind,
+            "batch_size": self.batch_size,
+            "n_parameters": len(self.parameters),
+            "n_trajectories": self.n_trajectories,
+            "n_levels": self.n_levels,
+            "delta": round(self._delta, 6),
+            "objective_metric": self.objective_metric,
+            "total_points": len(self._pending) + len(self._done),
+            "pending": len(self._pending),
+            "reported": len(self._done),
+            "sensitivity_ranking": ranking,
+            "key_parameters": [r["parameter"] for r in ranking if r["mu_star"] > 0][: max(1, len(self.parameters) // 2)],
+        }
+
+
+register_strategy(MorrisStrategy.kind, MorrisStrategy)

+ 359 - 0
src/afmcore/strategies/surrogate_guided.py

@@ -0,0 +1,359 @@
+"""Surrogate-guided optimization strategy (blueprint section 6.3, lightweight).
+
+Pure-Python implementation (no numpy/scipy/sklearn dependency, per project
+rule). This is a stdlib-grade substitute for Kriging: an Inverse Distance
+Weighting (IDW) interpolator provides predictions, and distance-to-nearest-
+neighbor serves as an uncertainty proxy. Points are selected via a UCB-style
+acquisition function (prediction + kappa * uncertainty), with budget-adaptive
+batch sizing.
+
+Kriging equivalence note:
+  - Kriging gives statistically-calibrated prediction variance; IDW gives
+    distance-based uncertainty. Both support explore-exploit tradeoff, but
+    IDW is less accurate on strongly non-stationary surfaces.
+  - To upgrade to Kriging later, introduce scipy (linear algebra + L-BFGS-B
+    for hyperparameter fitting) behind the same strategy interface.
+
+Strategy protocol:
+  - select_next(count=None) -> [{'point_id','params'}, ...]
+  - report(point_id, metrics, status='ok') -> None
+  - state() -> dict with phase, budget, surrogate diagnostics, batch size
+
+All source is ASCII only.
+"""
+from __future__ import annotations
+
+import math
+import random
+from typing import Any, Dict, List, Optional, Tuple
+
+from . import SimulationStrategy, register_strategy
+
+
+class SurrogateGuidedStrategy(SimulationStrategy):
+    """Surrogate-guided optimization with IDW interpolator + UCB acquisition.
+
+    Input:
+      parameters         = [{"name", "min_value", "max_value", "step"?}]
+      objective_metric   = metric key to optimize (default "tavg_nm")
+      objective_direction= "maximize" | "minimize" (default "maximize")
+      n_initial          = number of LHS points for initial training (default 12)
+      batch_size         = base batch size (default 4)
+      max_batch_size     = upper bound for adaptive batch growth (default 8)
+      budget             = total solver-call budget (default 60)
+      n_candidates       = candidate pool size per acquisition step (default 120)
+      idw_power          = IDW distance exponent p (default 2.0)
+      kappa              = UCB exploration coefficient (default 1.0)
+      rng_seed           = optional seed
+
+    Phases:
+      initial           -> serve n_initial LHS training points
+      surrogate_guided  -> serve UCB-selected points until budget exhausted
+      exhausted         -> no more points
+    """
+
+    kind = "surrogate_guided"
+
+    def __init__(
+        self,
+        parameters: Optional[List[Dict[str, Any]]] = None,
+        objective_metric: str = "tavg_nm",
+        objective_direction: str = "maximize",
+        n_initial: int = 12,
+        batch_size: int = 4,
+        max_batch_size: int = 8,
+        budget: int = 60,
+        n_candidates: int = 120,
+        idw_power: float = 2.0,
+        kappa: float = 1.0,
+        rng_seed: Optional[int] = None,
+        **kwargs: Any,
+    ):
+        super().__init__(batch_size=batch_size, **kwargs)
+        self.parameters = list(parameters or [])
+        self.objective_metric = str(objective_metric)
+        self.objective_direction = "minimize" if str(objective_direction).lower() == "minimize" else "maximize"
+        self.n_initial = max(1, int(n_initial))
+        self.max_batch_size = max(1, int(max_batch_size))
+        self.budget = max(1, int(budget))
+        self.n_candidates = max(10, int(n_candidates))
+        self.idw_power = max(0.1, float(idw_power))
+        self.kappa = max(0.0, float(kappa))
+        self._rng = random.Random(rng_seed)
+
+        self._pending: List[Dict[str, Any]] = []
+        self._done: Dict[Any, Dict[str, Any]] = {}
+        self._next_id = 1
+        self._point_params: Dict[Any, Dict[str, float]] = {}
+        self._phase = "initial"
+        self._last_batch_size = 0
+        self._surrogate_diag: Dict[str, Any] = {"n_train": 0, "loo_rmse": None, "mean_uncertainty": None}
+
+        if self.parameters:
+            self._pending = self._lhs_sample(self.n_initial)
+
+    # -- sampling helpers ---------------------------------------------------
+
+    def _lhs_sample(self, n: int) -> List[Dict[str, Any]]:
+        """Generate n LHS samples in physical parameter space."""
+        if not self.parameters:
+            return []
+        n_params = len(self.parameters)
+        cols: List[List[float]] = []
+        for _ in range(n_params):
+            perm = list(range(n))
+            self._rng.shuffle(perm)
+            cols.append([(perm[i] + self._rng.random()) / n for i in range(n)])
+        out: List[Dict[str, Any]] = []
+        for i in range(n):
+            params: Dict[str, float] = {}
+            for j, p in enumerate(self.parameters):
+                lo = float(p.get("min_value", 0.0))
+                hi = float(p.get("max_value", 1.0))
+                norm = max(0.0, min(1.0, cols[j][i]))
+                val = lo + norm * (hi - lo)
+                step = p.get("step")
+                if step:
+                    val = round(val / float(step)) * float(step)
+                    val = max(lo, min(hi, val))
+                params[p["name"]] = round(val, 8)
+            out.append({"point_id": self._next_id, "params": params})
+            self._point_params[self._next_id] = dict(params)
+            self._next_id += 1
+        return out
+
+    def _normalize(self, params: Dict[str, float]) -> Tuple[float, ...]:
+        """Map physical params to normalized [0,1] vector."""
+        out = []
+        for p in self.parameters:
+            lo = float(p.get("min_value", 0.0))
+            hi = float(p.get("max_value", 1.0))
+            rng = hi - lo
+            v = float(params.get(p["name"], lo))
+            out.append(0.0 if rng == 0 else max(0.0, min(1.0, (v - lo) / rng)))
+        return tuple(out)
+
+    @staticmethod
+    def _distance(a: Tuple[float, ...], b: Tuple[float, ...]) -> float:
+        """Euclidean distance in normalized space."""
+        return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))
+
+    # -- IDW surrogate ------------------------------------------------------
+
+    def _training_data(self) -> List[Tuple[Tuple[float, ...], float]]:
+        """Return [(normalized_params, objective)] for all ok reported points."""
+        data: List[Tuple[Tuple[float, ...], float]] = []
+        for result in self._done.values():
+            if result.get("status") != "ok":
+                continue
+            y = result["metrics"].get(self.objective_metric)
+            if y is None:
+                continue
+            data.append((self._normalize(result["metrics"].get("_params", {})), float(y)))
+        return data
+
+    def _idw_predict(self, x: Tuple[float, ...], train: List[Tuple[Tuple[float, ...], float]]) -> Tuple[float, float]:
+        """IDW prediction and uncertainty (nearest-neighbor distance).
+
+        Returns (prediction, uncertainty). Uncertainty is the normalized
+        distance to the nearest training point (0 = interpolating, 1 = far).
+        """
+        if not train:
+            return 0.0, 1.0
+        weights: List[float] = []
+        min_dist = float("inf")
+        for xt, _ in train:
+            d = self._distance(x, xt)
+            min_dist = min(min_dist, d)
+            weights.append(1.0 / (d ** self.idw_power + 1e-12))
+        wsum = sum(weights)
+        if wsum == 0:
+            pred = sum(y for _, y in train) / len(train)
+        else:
+            pred = sum(w * y for w, (_, y) in zip(weights, train)) / wsum
+        # uncertainty bounded by space diagonal (sqrt(n_params))
+        space_diag = math.sqrt(len(self.parameters)) if self.parameters else 1.0
+        uncertainty = min(1.0, min_dist / max(1e-9, space_diag * 0.5))
+        return pred, uncertainty
+
+    def _loo_rmse(self, train: List[Tuple[Tuple[float, ...], float]]) -> Optional[float]:
+        """Leave-one-out RMSE for surrogate quality diagnostic."""
+        if len(train) < 3:
+            return None
+        errors = []
+        for i in range(len(train)):
+            loo = train[:i] + train[i + 1:]
+            pred, _ = self._idw_predict(train[i][0], loo)
+            errors.append((pred - train[i][1]) ** 2)
+        return round(math.sqrt(sum(errors) / len(errors)), 8)
+
+    # -- acquisition --------------------------------------------------------
+
+    def _ucb_score(self, pred: float, uncertainty: float) -> float:
+        """UCB acquisition score (higher = more promising to sample)."""
+        if self.objective_direction == "maximize":
+            return pred + self.kappa * uncertainty
+        # minimize: negate prediction so higher score = lower predicted objective
+        return -pred + self.kappa * uncertainty
+
+    def _adaptive_batch_size(self, train: List[Tuple[Tuple[float, ...], float]], remaining: int) -> int:
+        """Budget-adaptive batch size.
+
+        Grows the batch when uncertainty is high and budget is plentiful;
+        shrinks toward the end for fine exploitation.
+        """
+        if not train:
+            return min(self.batch_size, remaining)
+        # average nearest-neighbor distance as global uncertainty proxy
+        cand_centroid = tuple(0.5 for _ in self.parameters)
+        _, mean_unc = self._idw_predict(cand_centroid, train)
+        budget_ratio = remaining / max(1, self.budget)
+        # growth factor in [1, 2]
+        growth = 1.0 + mean_unc * budget_ratio
+        size = int(round(self.batch_size * growth))
+        size = max(1, min(size, self.max_batch_size, remaining))
+        return size
+
+    def _select_surrogate_batch(self, count: int) -> List[Dict[str, Any]]:
+        """Select count points via UCB over a random candidate pool."""
+        train = self._training_data()
+        if not train:
+            # fallback: random LHS if no training data yet
+            return self._lhs_sample(count)
+        # generate candidate pool (random uniform in normalized space)
+        candidates: List[Tuple[float, ...]] = []
+        seen = set()
+        attempts = 0
+        while len(candidates) < self.n_candidates and attempts < self.n_candidates * 5:
+            attempts += 1
+            x = tuple(self._rng.random() for _ in self.parameters)
+            if x in seen:
+                continue
+            seen.add(x)
+            candidates.append(x)
+        # score each candidate
+        scored: List[Tuple[float, Tuple[float, ...]]] = []
+        for x in candidates:
+            pred, unc = self._idw_predict(x, train)
+            scored.append((self._ucb_score(pred, unc), x))
+        scored.sort(key=lambda t: t[0], reverse=True)
+        # convert top count to physical params, dedupe against existing points
+        existing_norm = set(self._normalize(r["metrics"].get("_params", {})) for r in self._done.values())
+        selected: List[Dict[str, Any]] = []
+        for _, x in scored:
+            if len(selected) >= count:
+                break
+            if x in existing_norm:
+                continue
+            params = self._denormalize(x)
+            selected.append({"point_id": self._next_id, "params": params})
+            self._point_params[self._next_id] = dict(params)
+            self._next_id += 1
+            existing_norm.add(x)
+        # if dedup removed too many, fill with random
+        while len(selected) < count:
+            x = tuple(self._rng.random() for _ in self.parameters)
+            if x in existing_norm:
+                continue
+            params = self._denormalize(x)
+            selected.append({"point_id": self._next_id, "params": params})
+            self._point_params[self._next_id] = dict(params)
+            self._next_id += 1
+            existing_norm.add(x)
+        return selected
+
+    def _denormalize(self, x: Tuple[float, ...]) -> Dict[str, float]:
+        """Map normalized vector back to physical params."""
+        out: Dict[str, float] = {}
+        for j, p in enumerate(self.parameters):
+            lo = float(p.get("min_value", 0.0))
+            hi = float(p.get("max_value", 1.0))
+            val = lo + x[j] * (hi - lo)
+            step = p.get("step")
+            if step:
+                val = round(val / float(step)) * float(step)
+                val = max(lo, min(hi, val))
+            out[p["name"]] = round(val, 8)
+        return out
+
+    # -- protocol -----------------------------------------------------------
+
+    def select_next(self, count: Optional[int] = None) -> List[Dict[str, Any]]:
+        used = len(self._done)
+        remaining = max(0, self.budget - used - len(self._pending))
+        if remaining <= 0 and not self._pending:
+            self._phase = "exhausted"
+            return []
+        # serve pending first (initial LHS or previously selected)
+        if self._pending:
+            n = int(count) if count is not None else self.batch_size
+            batch = self._pending[:n]
+            self._pending = self._pending[n:]
+            self._last_batch_size = len(batch)
+            if used >= self.n_initial:
+                self._phase = "surrogate_guided"
+            return batch
+        # initial phase done -> surrogate-guided selection
+        self._phase = "surrogate_guided"
+        train = self._training_data()
+        n = int(count) if count is not None else self._adaptive_batch_size(train, remaining)
+        n = max(1, min(n, remaining))
+        batch = self._select_surrogate_batch(n)
+        self._last_batch_size = len(batch)
+        # update surrogate diagnostics
+        loo = self._loo_rmse(train)
+        _, mean_unc = self._idw_predict(tuple(0.5 for _ in self.parameters), train) if train else (0.0, 1.0)
+        self._surrogate_diag = {
+            "n_train": len(train),
+            "loo_rmse": loo,
+            "mean_uncertainty": round(mean_unc, 6),
+            "last_batch_size": self._last_batch_size,
+        }
+        return batch
+
+    def report(self, point_id: Any, metrics: Dict[str, float], status: str = "ok") -> None:
+        # auto-associate params recorded at selection time for surrogate training
+        enriched = dict(metrics)
+        if point_id in self._point_params and "_params" not in enriched:
+            enriched["_params"] = dict(self._point_params[point_id])
+        self._done[point_id] = {"metrics": enriched, "status": status}
+
+    def report_with_params(self, point_id: Any, params: Dict[str, float], metrics: Dict[str, float], status: str = "ok") -> None:
+        """Report result with explicit params (required for surrogate training)."""
+        enriched = dict(metrics)
+        enriched["_params"] = dict(params)
+        self._done[point_id] = {"metrics": enriched, "status": status}
+
+    def next_batch_ready(self) -> bool:
+        if not self.parameters:
+            return False
+        used = len(self._done)
+        return used < self.budget or bool(self._pending)
+
+    def is_converged(self) -> bool:
+        return not self.next_batch_ready()
+
+    def state(self) -> Dict[str, Any]:
+        used = len(self._done)
+        return {
+            "kind": self.kind,
+            "batch_size": self.batch_size,
+            "max_batch_size": self.max_batch_size,
+            "budget": self.budget,
+            "used_budget": used,
+            "remaining_budget": max(0, self.budget - used),
+            "n_initial": self.n_initial,
+            "n_parameters": len(self.parameters),
+            "objective_metric": self.objective_metric,
+            "objective_direction": self.objective_direction,
+            "phase": self._phase,
+            "pending": len(self._pending),
+            "reported": used,
+            "last_batch_size": self._last_batch_size,
+            "surrogate": dict(self._surrogate_diag),
+            "idw_power": self.idw_power,
+            "kappa": self.kappa,
+        }
+
+
+register_strategy(SurrogateGuidedStrategy.kind, SurrogateGuidedStrategy)

+ 362 - 0
src/afmcore/topology.py

@@ -0,0 +1,362 @@
+"""Motor topology registry - configurable, platform-style.
+
+Single source of truth for which motor topologies the platform knows about,
+their parameter system (which input-parameter families are meaningful), the
+recommended scan knobs, and the structural constraints (stator / rotor /
+airgap counts) that affect simulation setup.
+
+Per design V1.1 / PLATFORM_DESIGN_V2, topology is managed as configuration:
+adding a topology means registering a parameter system + simulation template,
+not touching the platform core. This module is pure shared core (no GUI / no
+Web dependency); web UI, plan generation, and the local executor all consult
+it instead of hard-coding topology as a bare string.
+
+Today SSSR is fully active; DRSS is the next target; SDSR is planned.
+
+All source is ASCII only (Chinese labels use \\uXXXX escapes).
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional, Tuple
+
+
+# ---------------------------------------------------------------------------
+# Definitions
+# ---------------------------------------------------------------------------
+
+@dataclass(frozen=True)
+class TopologyDefinition:
+    """Immutable descriptor for one motor topology.
+
+    `param_system` maps a parameter family (group) name to the canonical
+    parameter names that are meaningful for this topology. The union of all
+    groups is the topology's full parameter system, used to validate plan
+    parameters and to drive template generation.
+    """
+
+    code: str                                    # unique code, e.g. "SSSR"
+    label_zh: str                                # Chinese label
+    label_en: str                                # English label
+    description: str = ""
+    status: str = "active"                       # "active" | "planned"
+    default_model: str = ""                      # base .mot path (repo-relative); "" = none yet
+    param_system: Dict[str, Tuple[str, ...]] = field(default_factory=dict)
+    sizing_params: Tuple[str, ...] = ()          # geometry knobs for scaling
+    default_scan_vars: Tuple[str, ...] = ()      # recommended scan variables
+    stator_count: int = 1
+    rotor_count: int = 1
+    airgap_count: int = 1
+    notes: str = ""
+
+    def all_params(self) -> Tuple[str, ...]:
+        """Canonical parameter names for this topology (union of groups)."""
+        seen: List[str] = []
+        for group in self.param_system.values():
+            for name in group:
+                if name not in seen:
+                    seen.append(name)
+        return tuple(seen)
+
+    def group_of(self, param_name: str) -> Optional[str]:
+        """Return the family/group a parameter belongs to, or None."""
+        for group, names in self.param_system.items():
+            if param_name in names:
+                return group
+        return None
+
+    def validate_params(self, params: Dict[str, object]) -> Dict[str, object]:
+        """Check a parameter dict against this topology's parameter system.
+
+        Returns a report dict:
+          {"known": [...], "unknown": [...], "unsupported": bool}
+        Unknown keys are reported but not fatal (allows forward-compatible
+        parameter passing); `unsupported` is True only when the topology is
+        planned (not yet runnable).
+        """
+        known = set(self.all_params())
+        unknown = [k for k in params if k not in known]
+        return {
+            "known": [k for k in params if k in known],
+            "unknown": unknown,
+            "unsupported": self.status != "active",
+        }
+
+
+# ---------------------------------------------------------------------------
+# Parameter systems
+# ---------------------------------------------------------------------------
+
+# SSSR (Single Stator Single Rotor) - the active topology. Parameter families
+# mirror the 37-parameter template (motor engineering grouping).
+_SSSR_PARAM_SYSTEM: Dict[str, Tuple[str, ...]] = {
+    "Geometry": (
+        "Outer_Rotor_Diameter",
+        "Inner_Rotor_Diameter",
+        "Stator_Outer_Diameter",
+        "Stator_Inner_Diameter",
+        "Airgap",
+    ),
+    "Stator": (
+        "Stator_Yoke_Thickness",
+        "Slot_Depth",
+        "Slot_Width",
+        "Tooth_Width",
+        "Number_of_Slots",
+    ),
+    "Rotor": (
+        "Rotor_Back_Iron_Thickness",
+        "Magnet_Length",
+        "Magnet_Thickness",
+        "Magnet_Arc_[ED]",
+        "Number_of_Poles",
+    ),
+    "Performance": (
+        "Current_Advance_Angle",
+        "DC_Link_Voltage",
+        "RMSCurrent",
+        "Shaft_Speed",
+        "Max_Speed",
+    ),
+    "Winding": (
+        "Turns_per_Coil",
+        "Parallel_Paths",
+        "Copper_Fill_Factor",
+        "Wire_Diameter",
+        "Winding_Connection",
+        "Current_Density",
+    ),
+    "Material": (
+        "Magnet_Material",
+        "Steel_Grade",
+        "Magnet_Temperature",
+        "Magnet_Remanence",
+    ),
+    "Thermal": (
+        "Ambient_Temperature",
+        "Cooling_Method",
+        "Insulation_Class",
+    ),
+    "Simulation": (
+        "TorquePointsPerCycle",
+        "CurrentDefinition",
+        "AirgapMeshPoints_mesh",
+        "MessageDisplayState",
+    ),
+}
+
+_SSSR_SIZING = (
+    "Outer_Rotor_Diameter",
+    "Inner_Rotor_Diameter",
+    "Airgap",
+    "Rotor_Back_Iron_Thickness",
+    "Magnet_Thickness",
+    "Slot_Depth",
+)
+
+_SSSR_SCAN = (
+    "Airgap",
+    "Magnet_Thickness",
+    "Magnet_Length",
+    "RMSCurrent",
+    "Shaft_Speed",
+    "DC_Link_Voltage",
+    "Turns_per_Coil",
+)
+
+# DRSS (Double Rotor Single Stator) - next target, planned.
+# Structural difference vs SSSR: one stator disc centred between two rotor
+# discs, so two airgaps and doubled rotor-side geometry.
+_DRSS_PARAM_SYSTEM: Dict[str, Tuple[str, ...]] = {
+    "Geometry": (
+        "Outer_Rotor_Diameter",
+        "Inner_Rotor_Diameter",
+        "Stator_Outer_Diameter",
+        "Stator_Inner_Diameter",
+        "Airgap",
+    ),
+    "Rotor": (
+        "Rotor_Back_Iron_Thickness",
+        "Magnet_Length",
+        "Magnet_Thickness",
+        "Number_of_Poles",
+    ),
+}
+
+# SDSR (Single Stator Double Rotor) - planned for later extension.
+_SDSR_PARAM_SYSTEM: Dict[str, Tuple[str, ...]] = {}
+
+
+# ---------------------------------------------------------------------------
+# Registry
+# ---------------------------------------------------------------------------
+
+TOPOLOGY_REGISTRY: Dict[str, TopologyDefinition] = {}
+
+
+def register_topology(definition: TopologyDefinition) -> None:
+    """Register a topology definition (idempotent by code)."""
+    code = definition.code
+    if not code:
+        raise ValueError("Topology code must be non-empty")
+    TOPOLOGY_REGISTRY[code] = definition
+
+
+def _register_defaults() -> None:
+    register_topology(
+        TopologyDefinition(
+            code="SSSR",
+            label_zh="\u5355\u5b9a\u5b50\u5355\u8f6c\u5b50",
+            label_en="Single Stator Single Rotor",
+            description="One stator disc and one rotor disc, single airgap.",
+            status="active",
+            default_model="models/MARS-12S10P_SSSR_D76-C150_V5.0-0819.mot",
+            param_system=_SSSR_PARAM_SYSTEM,
+            sizing_params=_SSSR_SIZING,
+            default_scan_vars=_SSSR_SCAN,
+            stator_count=1,
+            rotor_count=1,
+            airgap_count=1,
+        )
+    )
+    register_topology(
+        TopologyDefinition(
+            code="DRSS",
+            label_zh="\u53cc\u8f6c\u5b50\u5355\u5b9a\u5b50",
+            label_en="Double Rotor Single Stator",
+            description=(
+                "Two rotor discs sandwiching one stator disc; two airgaps. "
+                "Next target topology (planned)."
+            ),
+            status="planned",
+            param_system=_DRSS_PARAM_SYSTEM,
+            sizing_params=("Outer_Rotor_Diameter", "Airgap", "Magnet_Thickness"),
+            default_scan_vars=("Airgap", "Magnet_Thickness", "RMSCurrent"),
+            stator_count=1,
+            rotor_count=2,
+            airgap_count=2,
+            notes="\u5b9a\u5b50\u76d8\u5c45\u4e2d\u3001\u53cc\u8f6c\u5b50\u76d8\u53cc\u6c14\u9699\uff1b\u53c2\u6570\u4f53\u7cfb\u9884\u7559\uff0c\u5f85\u5efa\u6a21\u540e\u5b8c\u5584",
+        )
+    )
+    register_topology(
+        TopologyDefinition(
+            code="SDSR",
+            label_zh="\u5355\u5b9a\u5b50\u53cc\u8f6c\u5b50",
+            label_en="Single Stator Double Rotor",
+            description="Planned topology, not yet modeled.",
+            status="planned",
+            param_system=_SDSR_PARAM_SYSTEM,
+            stator_count=1,
+            rotor_count=2,
+            airgap_count=2,
+            notes="\u540e\u7eed\u6269\u5c55\u62d3\u6251\uff0c\u53c2\u6570\u4f53\u7cfb\u5f85\u5b9a",
+        )
+    )
+
+
+_register_defaults()
+
+
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+def get_topology(code: str) -> TopologyDefinition:
+    """Return the topology definition. Raises KeyError if unknown."""
+    code = (code or "").strip().upper()
+    if code not in TOPOLOGY_REGISTRY:
+        raise KeyError(
+            "Topology %r is not registered. Known: %s"
+            % (code, ", ".join(sorted(TOPOLOGY_REGISTRY.keys())))
+        )
+    return TOPOLOGY_REGISTRY[code]
+
+
+def get_topology_or_none(code: str) -> Optional[TopologyDefinition]:
+    """Safe lookup; returns None for unknown/empty codes."""
+    try:
+        return get_topology(code)
+    except KeyError:
+        return None
+
+
+def list_topologies() -> List[TopologyDefinition]:
+    """All registered topologies, active first then by code."""
+    defs = list(TOPOLOGY_REGISTRY.values())
+    return sorted(
+        defs, key=lambda d: (0 if d.status == "active" else 1, d.code)
+    )
+
+
+def list_topology_codes() -> List[str]:
+    """Registered topology codes (sorted)."""
+    return sorted(TOPOLOGY_REGISTRY.keys())
+
+
+def active_topology_codes() -> List[str]:
+    """Topologies that can actually run simulations today."""
+    return sorted(
+        d.code for d in TOPOLOGY_REGISTRY.values() if d.status == "active"
+    )
+
+
+def is_supported(code: str) -> bool:
+    """True if the topology is registered (known), regardless of status."""
+    return get_topology_or_none(code) is not None
+
+
+def default_model_for(code: str) -> str:
+    """Repo-relative path of the topology's base model, or "" if none exists.
+
+    Used to auto-fill a plan's model_path when neither the project nor the
+    plan specifies one. DRSS/SDSR currently return "" (no base model yet).
+    """
+    defn = get_topology_or_none(code)
+    return defn.default_model if defn else ""
+
+
+def is_active(code: str) -> bool:
+    """True if the topology is registered AND runnable today."""
+    defn = get_topology_or_none(code)
+    return defn is not None and defn.status == "active"
+
+
+def param_names_for(code: str) -> Tuple[str, ...]:
+    """Canonical parameter names for a topology (unknown -> empty)."""
+    defn = get_topology_or_none(code)
+    return defn.all_params() if defn else ()
+
+
+def validate_params(code: str, params: Dict[str, object]) -> Dict[str, object]:
+    """Validate a parameter dict against a topology's parameter system.
+
+    Unknown topologies report unsupported=True with all params unknown.
+    """
+    defn = get_topology_or_none(code)
+    if defn is None:
+        return {
+            "known": [],
+            "unknown": list(params.keys()),
+            "unsupported": True,
+        }
+    return defn.validate_params(params)
+
+
+def to_dict() -> Dict[str, Dict[str, object]]:
+    """Serializable snapshot of the whole registry (for UI / API)."""
+    out: Dict[str, Dict[str, object]] = {}
+    for defn in list_topologies():
+        out[defn.code] = {
+            "label_zh": defn.label_zh,
+            "label_en": defn.label_en,
+            "description": defn.description,
+            "status": defn.status,
+            "default_model": defn.default_model,
+            "sizing_params": list(defn.sizing_params),
+            "default_scan_vars": list(defn.default_scan_vars),
+            "stator_count": defn.stator_count,
+            "rotor_count": defn.rotor_count,
+            "airgap_count": defn.airgap_count,
+            "params": list(defn.all_params()),
+        }
+    return out

+ 250 - 14
src/plan_schema.py

@@ -16,6 +16,17 @@ from pathlib import Path
 from typing import Any
 
 from .scan_engine import values_inclusive, generate_cartesian_points
+from .afmcore.topology import (
+    is_supported as _topology_supported,
+)
+from .afmcore.strategies import (
+    is_registered as _strategy_registered,
+    normalize_method as _strategy_normalize_method,
+)
+from .afmcore.topology import (
+    is_supported as _topology_supported,
+    validate_params as _topology_validate_params,
+)
 
 
 # ---------------------------------------------------------------------------
@@ -64,13 +75,19 @@ class ScanVariable:
 
     @classmethod
     def from_dict(cls, d: dict[str, Any]) -> "ScanVariable":
+        # Tolerate common aliases (min_value/max_value -> start/stop) so a
+        # plan authored with either naming cannot silently produce wrong
+        # point values. Explicit "values" list always wins.
+        start = d.get("start", d.get("min_value", d.get("min", 0.0)))
+        stop = d.get("stop", d.get("max_value", d.get("max", 0.0)))
+        step = d.get("step", d.get("step_size", 0.0))
         return cls(
             name=d["name"],
             display_name=d.get("display_name", ""),
             unit=d.get("unit", ""),
-            start=d.get("start", 0.0),
-            stop=d.get("stop", 0.0),
-            step=d.get("step", 0.0),
+            start=float(start),
+            stop=float(stop),
+            step=float(step),
             values=[float(v) for v in d.get("values", [])],
         )
 
@@ -98,19 +115,138 @@ class ScanCase:
         )
 
 
+@dataclass
+class FixedParam:
+    """A fixed Motor-CAD parameter (not scanned, applied to every point)."""
+    name: str
+    display_name: str = ""
+    unit: str = ""
+    value: Any = 0.0  # numeric OR string enum (e.g. Winding_Connection="Star")
+    category: str = "General"
+    description: str = ""
+
+    def __post_init__(self) -> None:
+        if not self.display_name:
+            self.display_name = self.name
+
+    def to_dict(self) -> dict[str, Any]:
+        return {
+            "name": self.name,
+            "display_name": self.display_name,
+            "unit": self.unit,
+            "value": self.value,
+            "category": self.category,
+            "description": self.description,
+        }
+
+    @classmethod
+    def from_dict(cls, d: dict[str, Any]) -> "FixedParam":
+        return cls(
+            name=d["name"],
+            display_name=d.get("display_name", ""),
+            unit=d.get("unit", ""),
+            value=d.get("value", 0.0),  # keep original type (int/float/str)
+            category=d.get("category", "General"),
+            description=d.get("description", ""),
+        )
+
+
+@dataclass
+class SearchStrategy:
+    """Adaptive search strategy configuration (optional)."""
+    method: str = "full_factorial"  # full_factorial / lhs / active_learning / constrained
+    initial_samples: int = 16
+    batch_size: int = 4
+    max_solver_calls: int = 80
+    local_trust_region: bool = False
+    objective_metric: str = "efficiency_pct"
+    objective_direction: str = "maximize"  # maximize / minimize
+
+    def normalized_method(self) -> str:
+        """Map legacy method names to a registered strategy kind."""
+        return _strategy_normalize_method(self.method)
+
+    def to_dict(self) -> dict[str, Any]:
+        return {
+            "method": self.normalized_method(),
+            "initial_samples": self.initial_samples,
+            "batch_size": self.batch_size,
+            "max_solver_calls": self.max_solver_calls,
+            "local_trust_region": self.local_trust_region,
+            "objective_metric": self.objective_metric,
+            "objective_direction": self.objective_direction,
+        }
+
+    @classmethod
+    def from_dict(cls, d: dict[str, Any]) -> "SearchStrategy":
+        return cls(
+            method=d.get("method", "full_factorial"),
+            initial_samples=int(d.get("initial_samples", 16)),
+            batch_size=int(d.get("batch_size", 4)),
+            max_solver_calls=int(d.get("max_solver_calls", 80)),
+            local_trust_region=bool(d.get("local_trust_region", False)),
+            objective_metric=d.get("objective_metric", "efficiency_pct"),
+            objective_direction=d.get("objective_direction", "maximize"),
+        )
+
+
+@dataclass
+class AcceptanceCriteria:
+    """Acceptance criteria for simulation results (optional)."""
+    hard_constraints: list[str] = field(default_factory=list)  # e.g. ["efficiency_pct >= 92"]
+    soft_targets: dict[str, float] = field(default_factory=dict)
+    objective_metric: str = "efficiency_pct"
+    objective_direction: str = "maximize"
+
+    def to_dict(self) -> dict[str, Any]:
+        return {
+            "hard_constraints": self.hard_constraints,
+            "soft_targets": self.soft_targets,
+            "objective_metric": self.objective_metric,
+            "objective_direction": self.objective_direction,
+        }
+
+    @classmethod
+    def from_dict(cls, d: dict[str, Any]) -> "AcceptanceCriteria":
+        return cls(
+            hard_constraints=list(d.get("hard_constraints", [])),
+            soft_targets=dict(d.get("soft_targets", {})),
+            objective_metric=d.get("objective_metric", "efficiency_pct"),
+            objective_direction=d.get("objective_direction", "maximize"),
+        )
+
+
 @dataclass
 class SimulationPlan:
-    """A complete simulation plan (Phase 1 minimal schema)."""
+    """A complete simulation plan (unified schema v2.0).
+
+    Structure:
+    - fixed_params: Motor-CAD parameters applied to every scan point (AI-estimated, user-editable)
+    - variables: scan variables with explicit value lists
+    - cases: operating conditions (at least one default case)
+    - search_strategy: optional adaptive search config
+    - acceptance_criteria: optional target constraints
+    - parent_plan_id: links to previous iteration plan (for iterative loop)
+    - ai_reasoning: AI's design rationale
+    """
     plan_id: str = ""
-    plan_version: str = "1.0"
+    plan_version: str = "2.0"
     created_at: str = ""
     topology: str = "SSSR"
     model_path: str = ""
+    fixed_params: list[FixedParam] = field(default_factory=list)
     variables: list[ScanVariable] = field(default_factory=list)
     cases: list[ScanCase] = field(default_factory=list)
     output_metrics: list[str] = field(default_factory=lambda: [
-        "tavg_nm", "ripple_pct", "efficiency_pct", "total_losses_w"
+        "tavg_nm", "ripple_pct", "efficiency_pct", "total_losses_w",
+        "copper_loss_w", "iron_loss_w", "magnet_loss_w", "back_emf_v",
+        "input_power_w", "output_power_w", "shaft_speed_rpm",
     ])
+    search_strategy: SearchStrategy | None = None
+    acceptance_criteria: AcceptanceCriteria | None = None
+    parent_plan_id: str = ""
+    iteration: int = 1
+    ai_reasoning: str = ""
     foreground: bool = True
     notes: str = ""
 
@@ -149,18 +285,27 @@ class SimulationPlan:
     # -- Serialization --
 
     def to_dict(self) -> dict[str, Any]:
-        return {
+        d = {
             "plan_id": self.plan_id,
             "plan_version": self.plan_version,
             "created_at": self.created_at,
             "topology": self.topology,
             "model_path": self.model_path,
+            "fixed_params": [fp.to_dict() for fp in self.fixed_params],
             "variables": [v.to_dict() for v in self.variables],
             "cases": [c.to_dict() for c in self.cases],
             "output_metrics": self.output_metrics,
+            "parent_plan_id": self.parent_plan_id,
+            "iteration": self.iteration,
+            "ai_reasoning": self.ai_reasoning,
             "foreground": self.foreground,
             "notes": self.notes,
         }
+        if self.search_strategy:
+            d["search_strategy"] = self.search_strategy.to_dict()
+        if self.acceptance_criteria:
+            d["acceptance_criteria"] = self.acceptance_criteria.to_dict()
+        return d
 
     def to_json(self, indent: int = 2) -> str:
         return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
@@ -170,17 +315,29 @@ class SimulationPlan:
 
     @classmethod
     def from_dict(cls, d: dict[str, Any]) -> "SimulationPlan":
+        search_strategy = None
+        if d.get("search_strategy"):
+            search_strategy = SearchStrategy.from_dict(d["search_strategy"])
+        acceptance_criteria = None
+        if d.get("acceptance_criteria"):
+            acceptance_criteria = AcceptanceCriteria.from_dict(d["acceptance_criteria"])
         return cls(
             plan_id=d.get("plan_id", ""),
-            plan_version=d.get("plan_version", "1.0"),
+            plan_version=d.get("plan_version", "2.0"),
             created_at=d.get("created_at", ""),
             topology=d.get("topology", "SSSR"),
             model_path=d.get("model_path", ""),
+            fixed_params=[FixedParam.from_dict(fp) for fp in d.get("fixed_params", [])],
             variables=[ScanVariable.from_dict(v) for v in d.get("variables", [])],
             cases=[ScanCase.from_dict(c) for c in d.get("cases", [])],
             output_metrics=d.get("output_metrics", [
                 "tavg_nm", "ripple_pct", "efficiency_pct", "total_losses_w"
             ]),
+            search_strategy=search_strategy,
+            acceptance_criteria=acceptance_criteria,
+            parent_plan_id=d.get("parent_plan_id", ""),
+            iteration=int(d.get("iteration", 1)),
+            ai_reasoning=d.get("ai_reasoning", ""),
             foreground=d.get("foreground", True),
             notes=d.get("notes", ""),
         )
@@ -192,13 +349,35 @@ class SimulationPlan:
 
     # -- Validation --
 
-    def validate(self) -> tuple[bool, list[str]]:
-        """Validate the plan. Returns (ok, list_of_errors)."""
+    def validate(self, require_model_path: bool = True) -> tuple[bool, list[str]]:
+        """Validate the plan. Returns (ok, list_of_errors).
+
+        Args:
+            require_model_path: when False, the model_path existence/required
+                checks are skipped (draft-stage structural validation, where a
+                model may be configured later).
+
+        """
         errors = []
-        if not self.model_path:
-            errors.append("model_path is required")
-        elif not Path(self.model_path).exists():
-            errors.append(f"model_path does not exist: {self.model_path}")
+        # Topology must be registered (no bare-string topology).
+        if not _topology_supported(self.topology):
+            errors.append(
+                f"topology not supported: {self.topology!r} "
+                "(known: SSSR / DRSS / SDSR)"
+            )
+        # Execution strategy must be registered in the platform registry.
+        if self.search_strategy is not None:
+            method = self.search_strategy.normalized_method()
+            if not _strategy_registered(method):
+                errors.append(
+                    f"search strategy not supported: {method!r} "
+                    "(known: full_factorial / lhs / adaptive)"
+                )
+        if require_model_path:
+            if not self.model_path:
+                errors.append("model_path is required")
+            elif not Path(self.model_path).exists():
+                errors.append(f"model_path does not exist: {self.model_path}")
         if not self.variables:
             errors.append("At least one scan variable is required")
         for i, v in enumerate(self.variables):
@@ -207,4 +386,61 @@ class SimulationPlan:
             vals = v.get_values()
             if not vals:
                 errors.append(f"Variable {v.name}: no values generated (check start/stop/step or values)")
+        for i, fp in enumerate(self.fixed_params):
+            if not fp.name:
+                errors.append(f"Fixed param {i}: name is required")
         return (len(errors) == 0, errors)
+
+    def generate_full_params(self) -> list[dict[str, Any]]:
+        """Generate full parameter sets for each scan point.
+
+        Merges fixed_params with each variable combination.
+        Returns list of dicts: {param_name: value, ...}
+        """
+        points, var_names = self.generate_points()
+        fixed = {fp.name: fp.value for fp in self.fixed_params}
+        result = []
+        for pt in points:
+            params = dict(fixed)
+            params.update(pt["params"])
+            result.append(params)
+        return result
+
+# ---------------------------------------------------------------------------
+# Module-level entry points (single authoritative way to ingest a plan dict)
+# ---------------------------------------------------------------------------
+
+def parse_plan(data: dict[str, Any]) -> SimulationPlan:
+    """Parse and normalize a raw plan dict into a SimulationPlan.
+
+    Applies defaults for missing fields and tolerates variable-range
+    naming aliases. Raises on structurally malformed input.
+
+    Args:
+        data: plan dict (unified v2.0: fixed_params + variables + ...).
+
+    Returns:
+        A populated SimulationPlan.
+    """
+    return SimulationPlan.from_dict(data)
+
+
+def validate_plan_dict(
+    data: dict[str, Any], require_model_path: bool = True
+) -> tuple[bool, list[str]]:
+    """Validate a raw plan dict without raising.
+
+    Args:
+        data: plan dict.
+        require_model_path: forwarded to SimulationPlan.validate(); set False
+            for draft-stage structural checks before a model is configured.
+
+    Returns:
+        (ok, errors): ok is True when the plan is structurally valid and
+        passes topology/strategy/range checks; errors lists messages.
+    """
+    try:
+        plan = parse_plan(data)
+    except Exception as exc:  # noqa: BLE001 - report as validation error
+        return False, ["plan_data malformed: %s" % exc]
+    return plan.validate(require_model_path=require_model_path)

+ 21 - 265
src/solver_core.py

@@ -25,275 +25,31 @@ from pathlib import Path
 
 
 # ---------------------------------------------------------------------------
-# Metric definitions: key, display label, and aliases (English + Chinese).
-# Chinese aliases use Unicode escapes so this file stays pure ASCII.
 # ---------------------------------------------------------------------------
-
-METRIC_DEFINITIONS = [
-    {
-        "key": "tavg_nm",
-        "label": "Average Torque [Nm]",
-        "aliases": [
-            "Average torque (virtual work)",
-            "\u5e73\u5747\u8f6c\u77e9 (virtual work)",
-            "\u5e73\u5747\u8f6c\u77e9(virtual work)",
-        ],
-    },
-    {
-        "key": "ripple_pct",
-        "label": "Torque Ripple [%]",
-        "aliases": [
-            "Torque Ripple (VW) [%]",
-            "Torque Ripple (VW)[%]",
-        ],
-    },
-    {
-        "key": "ripple_nm",
-        "label": "Torque Ripple [Nm]",
-        "aliases": [
-            "Torque Ripple (VW)",
-        ],
-    },
-    {
-        "key": "efficiency_pct",
-        "label": "System Efficiency [%]",
-        "aliases": [
-            "System Efficiency",
-            "\u7cfb\u7edf\u6548\u7387",
-        ],
-    },
-    {
-        "key": "total_losses_w",
-        "label": "Total Losses [W]",
-        "aliases": [
-            "Total Losses (on load)",
-            "\u603b\u635f\u8017(\u989d\u5b9a)",
-            "\u603b\u635f\u8017 (\u989d\u5b9a)",
-        ],
-    },
-    {
-        "key": "copper_loss_w",
-        "label": "DC Copper Loss [W]",
-        "aliases": [
-            "Armature DC Copper Loss (on load)",
-            "\u7535\u67a2\u76f4\u6d41\u94dc\u8017 (\u5e26\u8f7d)",
-            "\u7535\u67a2\u76f4\u6d41\u94dc\u8017(\u5e26\u8f7d)",
-        ],
-    },
-    {
-        "key": "iron_loss_w",
-        "label": "Stator Iron Loss [W]",
-        "aliases": [
-            "Stator iron Loss [total] (on load)",
-            "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017](\u989d\u5b9a)",
-            "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017] (\u989d\u5b9a)",
-        ],
-    },
-    {
-        "key": "magnet_loss_w",
-        "label": "Magnet Loss [W]",
-        "aliases": [
-            "Magnet Loss (on load)",
-            "\u6c38\u78c1\u4f53\u635f\u8017(\u989d\u5b9a)",
-            "\u6c38\u78c1\u4f53\u635f\u8017 (\u989d\u5b9a)",
-        ],
-    },
-    {
-        "key": "back_emf_v",
-        "label": "Back EMF LL rms [V]",
-        "aliases": [
-            "Back EMF Line-Line Voltage (rms)",
-            "\u7ebf\u95f4\u53cd\u5411\u7535\u52a8\u52bf\u6709\u6548\u503c",
-        ],
-    },
-    {
-        "key": "back_emf_thd_pct",
-        "label": "Back EMF THD [%]",
-        "aliases": [
-            "Harmonic Distortion Back EMF Line-Line Voltage",
-            "\u7ebf\u53cd\u5411\u7535\u52a8\u52bf\u8c10\u6ce2",
-            "\u7ebf\u7535\u538b\u8c10\u6ce2",
-        ],
-    },
-    {
-        "key": "input_power_w",
-        "label": "Input Power [W]",
-        "aliases": [
-            "Input Power",
-            "\u8f93\u5165\u529f\u7387",
-        ],
-    },
-    {
-        "key": "output_power_w",
-        "label": "Output Power [W]",
-        "aliases": [
-            "Output Power",
-            "\u8f93\u51fa\u529f\u7387_\u7535\u538b\u9650\u5236\u9644\u8fd1\u5de5\u4f5c\u70b9",
-        ],
-    },
-    {
-        "key": "em_power_w",
-        "label": "EM Power [W]",
-        "aliases": [
-            "Electromagnetic Power",
-            "\u7535\u78c1\u529f\u7387_\u7535\u538b\u9650\u5236\u9644\u8fd1\u5de5\u4f5c\u70b9",
-        ],
-    },
-    {
-        "key": "shaft_speed_rpm",
-        "label": "Shaft Speed [rpm]",
-        "aliases": [
-            "Shaft Speed",
-            "\u8f6c\u901f[RPM]",
-            "\u8f6c\u901f [RPM]",
-        ],
-    },
-    {
-        "key": "no_load_speed_rpm",
-        "label": "No-load Speed [rpm]",
-        "aliases": [
-            "No load speed",
-            "\u7a7a\u8f7d\u8f6c\u901f",
-        ],
-    },
-]
-
-METRIC_KEYS = [m["key"] for m in METRIC_DEFINITIONS]
-METRIC_LABELS = {m["key"]: m["label"] for m in METRIC_DEFINITIONS}
-
-# Phase 1 required metrics (must be present for a valid result).
-REQUIRED_METRICS = ["tavg_nm", "ripple_pct", "efficiency_pct", "total_losses_w"]
-
-# Section name aliases for priority ordering.
-SECTION_PRIORITY = [
-    "E-Magnetics",
-    "\u7535\u78c1",
-    "Drive",
-    "\u9a71\u52a8",
-    "Losses",
-    "\u635f\u8017",
-    "Materials",
-    "\u6750\u6599",
-    "Miscellaneous",
-    "\u6742\u9879",
-]
-
-
+# Platform core import (single source of truth for metrics / parsing).
+# This module re-exports platform.metrics so existing consumers keep
+# working unchanged while the authoritative definitions live in one place.
 # ---------------------------------------------------------------------------
-# Utility functions
-# ---------------------------------------------------------------------------
-
-def _normalize_name(name: str) -> str:
-    """Normalize a field name for matching: unify brackets, remove
-    whitespace, lowercase."""
-    s = name
-    s = s.replace("\uff08", "(").replace("\uff09", ")")
-    s = "".join(s.split())
-    return s.lower()
-
-
-# Pre-normalize aliases for fast matching.
-_METRIC_ALIAS_MAP: dict[str, str] = {}
-for _m in METRIC_DEFINITIONS:
-    for _alias in _m["aliases"]:
-        _METRIC_ALIAS_MAP[_normalize_name(_alias)] = _m["key"]
-
+from .afmcore.metrics import (  # noqa: E402
+    METRIC_DEFINITIONS,
+    METRIC_LABELS,
+    METRIC_UNITS,
+    METRIC_DIRECTIONS,
+    REQUIRED_METRICS as _PLATFORM_REQUIRED,
+    METRIC_KEYS as _PLATFORM_KEYS,
+    SECTION_PRIORITY,
+    normalize_name,
+    parse_export,
+    pick_metric,
+    extract_all_metrics,
+    check_required_metrics,
+)
+
+# Keep historical container types for backward compatibility.
+METRIC_KEYS = list(_PLATFORM_KEYS)
+REQUIRED_METRICS = list(_PLATFORM_REQUIRED)
 
-def parse_export(path: Path) -> dict[str, dict[str, float]]:
-    """Parse a Motor-CAD semicolon-delimited export file.
 
-    Returns a dict of section_name -> {field_name: value}.
-    Handles UTF-8, cp1252, gbk, and latin-1 encodings.
-    """
-    text = None
-    for encoding in ("utf-8-sig", "gbk", "cp1252", "latin-1"):
-        try:
-            text = Path(path).read_text(encoding=encoding)
-            break
-        except UnicodeDecodeError:
-            continue
-    if text is None:
-        return {}
-    result: dict[str, dict[str, float]] = {}
-    section = "(root)"
-    for raw in text.splitlines():
-        line = raw.strip()
-        if not line:
-            continue
-        if ";" not in line:
-            section = line
-            result.setdefault(section, {})
-            continue
-        parts = line.split(";")
-        try:
-            field = parts[0].strip().strip('"')
-            value = float(parts[1])
-            result.setdefault(section, {})[field] = value
-        except (IndexError, ValueError):
-            continue
-    return result
-
-
-def pick_metric(results: dict[str, dict[str, float]], metric_key: str):
-    """Extract a metric value from parsed export results.
-
-    Tries exact alias match first (section priority order), then
-    falls back to prefix-based fuzzy match across all sections.
-    Returns the value (float) or None if not found.
-    """
-    wanted_aliases = set()
-    for m in METRIC_DEFINITIONS:
-        if m["key"] == metric_key:
-            for alias in m["aliases"]:
-                wanted_aliases.add(_normalize_name(alias))
-            break
-    if not wanted_aliases:
-        return None
-
-    # Phase 1: exact match in priority section order.
-    for section_name in SECTION_PRIORITY:
-        section = results.get(section_name)
-        if section is None:
-            continue
-        for field, value in section.items():
-            if _normalize_name(field) in wanted_aliases:
-                return value
-
-    # Phase 2: exact match across all sections.
-    for section in results.values():
-        for field, value in section.items():
-            if _normalize_name(field) in wanted_aliases:
-                return value
-
-    # Phase 3: prefix fuzzy match.
-    for alias_norm in wanted_aliases:
-        for section in results.values():
-            for field, value in section.items():
-                field_norm = _normalize_name(field)
-                if field_norm.startswith(alias_norm) or alias_norm.startswith(field_norm):
-                    if len(field_norm) > 3:
-                        return value
-    return None
-
-
-def extract_all_metrics(results: dict[str, dict[str, float]]) -> dict[str, float]:
-    """Extract all defined metrics from parsed results."""
-    out: dict[str, float] = {}
-    for m in METRIC_DEFINITIONS:
-        val = pick_metric(results, m["key"])
-        if val is not None:
-            out[m["key"]] = val
-    return out
-
-
-def check_required_metrics(metrics: dict[str, float]) -> tuple[bool, list[str]]:
-    """Check that all Phase 1 required metrics are present.
-    Returns (ok, list_of_missing_keys)."""
-    missing = [k for k in REQUIRED_METRICS if k not in metrics]
-    return (len(missing) == 0, missing)
-
-
-# ---------------------------------------------------------------------------
 # Motor-CAD solver
 # ---------------------------------------------------------------------------
 

Різницю між файлами не показано, бо вона завелика
+ 9080 - 0
testcase/test1.mot


+ 3 - 2
web/backend/app/config.py

@@ -55,8 +55,9 @@ KIMI_TEMPERATURE = float(os.environ.get("KIMI_TEMPERATURE", "1.0"))
 KIMI_TIMEOUT = int(os.environ.get("KIMI_TIMEOUT", "120"))
 KIMI_MAX_RETRIES = int(os.environ.get("KIMI_MAX_RETRIES", "3"))
 
-# Prompt templates directory
-PROMPTS_DIR = BACKEND_DIR / "prompts"
+# Prompt templates directory. BACKEND_DIR is web/backend/app (config.py lives
+# in app/), while the prompts tree is web/backend/prompts - one level up.
+PROMPTS_DIR = BACKEND_DIR.parent / "prompts"
 
 # Schema version (P3: V2 with fidelity/search/calibration fields)
 SCHEMA_VERSION = "2.0"

+ 16 - 0
web/backend/app/database.py

@@ -66,4 +66,20 @@ def _migrate_existing_tables():
         for col_name, col_def in new_cols.items():
             if col_name not in existing_cols:
                 conn.execute(text(f"ALTER TABLE simulation_results ADD COLUMN {col_name} {col_def}"))
+
+    # P3-M2: migrate tasks table for adaptive-batch fields.
+    if "tasks" in inspector.get_table_names():
+        task_cols = {c["name"] for c in inspector.get_columns("tasks")}
+        task_new = {
+            "task_type": "VARCHAR(20) DEFAULT 'scan'",
+            "loop_id": "VARCHAR(64)",
+            "batch_id": "INTEGER",
+            "point_ids": "TEXT",
+            "dynamic": "INTEGER DEFAULT 0",
+        }
+        with engine.connect() as conn2:
+            for col_name, col_def in task_new.items():
+                if col_name not in task_cols:
+                    conn2.execute(text(f"ALTER TABLE tasks ADD COLUMN {col_name} {col_def}"))
+            conn2.commit()
         conn.commit()

+ 11 - 1
web/backend/app/main.py

@@ -1,11 +1,20 @@
 """FastAPI application entry point."""
 import os
+import sys
+
+# Make the repository root importable so the single-source plan schema
+# (src.plan_schema) resolves when the backend runs from a checkout.
+_BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+_REPO_ROOT = os.path.normpath(os.path.join(_BACKEND_DIR, "..", ".."))
+if _REPO_ROOT not in sys.path:
+    sys.path.insert(0, _REPO_ROOT)
+
 from fastapi import FastAPI, Request, HTTPException
 from fastapi.middleware.cors import CORSMiddleware
 
 from .config import APP_NAME, APP_VERSION, APP_DESCRIPTION, CORS_ORIGINS
 from .database import init_db
-from .routers import projects, plans, experience, generation, analytics, ai, search, ai_plan, analysis, adaptive, tasks, monitor, reports
+from .routers import projects, plans, experience, generation, analytics, ai, search, ai_plan, analysis, adaptive, tasks, monitor, reports, executor_monitor
 
 app = FastAPI(
     title=APP_NAME,
@@ -55,6 +64,7 @@ app.include_router(adaptive.router)
 app.include_router(tasks.router)
 app.include_router(monitor.router)
 app.include_router(reports.router)
+app.include_router(executor_monitor.router)
 
 
 @app.on_event("startup")

+ 23 - 106
web/backend/app/metrics_constants.py

@@ -1,111 +1,28 @@
-"""Metric key definitions - single source of truth for the Web backend.
+"""Metric constants for the Web backend - derived from the single source
+of truth in the platform core (src/afmcore/metrics.py).
 
-This MUST stay in sync with:
-- src/solver_core.py METRIC_DEFINITIONS (local solver)
-- scripts/robust_motorcad.py METRIC_DEFINITIONS (robust solver)
+THIS FILE MUST NOT define its own metric lists. All definitions, aliases,
+labels, directions and required-metric rules live in src/afmcore/metrics.py.
+If you add a metric, edit src/afmcore/metrics.py only.
 
-When adding a new metric, update all three locations.
+Deployment note: when the Web backend is packaged without the repository
+tree, make src/afmcore importable (e.g. pip install -e ., or copy
+src/afmcore into the backend as a package) so this module keeps resolving.
 """
+import os
+import sys
 
-# All known metric keys produced by Motor-CAD result parsing.
-METRIC_KEYS = frozenset({
-    # Torque
-    "tavg_nm",              # Average torque (virtual work) [Nm]
-    "tmax_nm",              # Max torque [Nm]
-    "tmin_nm",              # Min torque [Nm]
-    "ripple_pct",           # Torque ripple [%]
-    "ripple_nm",            # Torque ripple [Nm]
-    "ripple_abs_nm",        # Absolute torque ripple [Nm]
-    "shaft_torque_nm",      # Shaft torque [Nm]
-    "stall_torque_nm",      # Stall torque [Nm]
-    "torque_constant",      # Torque constant Kt [Nm/A]
-    # Efficiency / power
-    "efficiency_pct",       # System efficiency [%]
-    "input_power_w",        # Input power [W]
-    "output_power_w",       # Output power [W]
-    "em_power_w",           # Electromagnetic power [W]
-    "power_factor",         # Power factor
-    # Losses
-    "total_losses_w",       # Total losses [W]
-    "copper_loss_w",        # DC copper loss [W]
-    "iron_loss_w",          # Stator iron loss [W]
-    "magnet_loss_w",        # Magnet loss [W]
-    # Electrical
-    "back_emf_v",           # Back EMF line-line rms [V]
-    "back_emf_thd_pct",     # Back EMF THD [%]
-    "phase_current_peak_a", # Phase current peak [A]
-    "line_current_rms_a",   # Line current rms [A]
-    # Speed
-    "shaft_speed_rpm",      # Shaft speed [rpm]
-    "no_load_speed_rpm",    # No-load speed [rpm]
-    # Thermal
-    "winding_temp_c",       # Winding temperature [C]
-})
+# Make the repository src/ importable when running from a checkout.
+_BACKEND_APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+# web/backend/app/metrics_constants.py -> <repo>/src
+_SRC_DIR = os.path.normpath(os.path.join(_BACKEND_APP_DIR, "..", "..", "src"))
+if _SRC_DIR not in sys.path and os.path.isdir(_SRC_DIR):
+    sys.path.insert(0, _SRC_DIR)
 
-# Metrics that are always required for a valid result.
-REQUIRED_METRICS = frozenset({
-    "tavg_nm",
-    "ripple_pct",
-    "efficiency_pct",
-    "total_losses_w",
-})
-
-# Human-friendly labels for display.
-METRIC_LABELS = {
-    "tavg_nm": "Average Torque [Nm]",
-    "tmax_nm": "Max Torque [Nm]",
-    "tmin_nm": "Min Torque [Nm]",
-    "ripple_pct": "Torque Ripple [%]",
-    "ripple_nm": "Torque Ripple [Nm]",
-    "ripple_abs_nm": "Torque Ripple (abs) [Nm]",
-    "shaft_torque_nm": "Shaft Torque [Nm]",
-    "stall_torque_nm": "Stall Torque [Nm]",
-    "torque_constant": "Torque Constant [Nm/A]",
-    "efficiency_pct": "Efficiency [%]",
-    "input_power_w": "Input Power [W]",
-    "output_power_w": "Output Power [W]",
-    "em_power_w": "EM Power [W]",
-    "power_factor": "Power Factor",
-    "total_losses_w": "Total Losses [W]",
-    "copper_loss_w": "Copper Loss [W]",
-    "iron_loss_w": "Iron Loss [W]",
-    "magnet_loss_w": "Magnet Loss [W]",
-    "back_emf_v": "Back EMF [V]",
-    "back_emf_thd_pct": "Back EMF THD [%]",
-    "phase_current_peak_a": "Phase Current (peak) [A]",
-    "line_current_rms_a": "Line Current (rms) [A]",
-    "shaft_speed_rpm": "Shaft Speed [rpm]",
-    "no_load_speed_rpm": "No-load Speed [rpm]",
-    "winding_temp_c": "Winding Temp [C]",
-}
-
-# Metric optimization direction: "higher" is better, "lower" is better.
-# Used by result_analyst for target comparison and best-point selection (C1 fix).
-METRIC_DIRECTIONS = {
-    "tavg_nm": "higher",
-    "tmax_nm": "higher",
-    "tmin_nm": "higher",
-    "stall_torque_nm": "higher",
-    "torque_constant": "higher",
-    "efficiency_pct": "higher",
-    "output_power_w": "higher",
-    "em_power_w": "higher",
-    "power_factor": "higher",
-    "shaft_speed_rpm": "higher",
-    "no_load_speed_rpm": "higher",
-    "ripple_pct": "lower",
-    "ripple_nm": "lower",
-    "ripple_abs_nm": "lower",
-    "total_losses_w": "lower",
-    "copper_loss_w": "lower",
-    "iron_loss_w": "lower",
-    "magnet_loss_w": "lower",
-    "back_emf_thd_pct": "lower",
-    "winding_temp_c": "lower",
-    # Neutral / informational (no optimization direction)
-    "shaft_torque_nm": "higher",
-    "input_power_w": "neutral",
-    "back_emf_v": "neutral",
-    "phase_current_peak_a": "neutral",
-    "line_current_rms_a": "neutral",
-}
+from afmcore.metrics import (  # noqa: E402
+    METRIC_KEYS,
+    METRIC_LABELS,
+    METRIC_UNITS,
+    METRIC_DIRECTIONS,
+    REQUIRED_METRICS,
+)

+ 6 - 0
web/backend/app/models/task.py

@@ -28,4 +28,10 @@ class Task(Base):
     dispatched_at = Column(DateTime(timezone=True), nullable=True)
     started_at = Column(DateTime(timezone=True), nullable=True)
     completed_at = Column(DateTime(timezone=True), nullable=True)
+    # P3-M2: adaptive-batch task fields (backward compatible).
+    task_type = Column(String(20), default="scan")  # scan | adaptive_batch
+    loop_id = Column(String(64), nullable=True, index=True)
+    batch_id = Column(Integer, nullable=True)
+    point_ids = Column(Text, nullable=True)  # JSON list of point ids
+    dynamic = Column(Integer, default=0)  # 1 = more batches may follow
     duration = Column(Float, nullable=True)

+ 42 - 1
web/backend/app/routers/adaptive.py

@@ -110,6 +110,23 @@ def get_loop_next_batch(loop_id: str):
         raise HTTPException(status_code=500, detail=f"Batch selection failed: {str(e)}")
 
 
+@router.post("/loops/{loop_id}/submit-batch")
+def submit_loop_batch(loop_id: str):
+    """Step 3b: Submit the current pending batch to the local executor.
+
+    Creates an adaptive_batch task in the task system; the local Motor-CAD
+    executor claims it, runs each point, and reports results back through
+    /report-results to continue the loop.
+    """
+    loop = get_loop(loop_id)
+    if not loop:
+        raise HTTPException(status_code=404, detail=f"Loop {loop_id} not found")
+    try:
+        return loop.submit_batch_to_executor()
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"Batch submission failed: {str(e)}")
+
+
 @router.post("/loops/{loop_id}/report-results")
 def report_loop_results(loop_id: str, request: ReportResultsRequest):
     """Step 4-5: Report simulation results and trigger AI analysis.
@@ -143,7 +160,31 @@ def update_loop_experience(loop_id: str):
         raise HTTPException(status_code=500, detail=f"Experience update failed: {str(e)}")
 
 
-@router.get("/loops/{loop_id}/check-completion")
+@router.get("/loops/{loop_id}/export")
+def export_loop_state(loop_id: str):
+    """Export the full loop state (plan + results + search) for checkpointing."""
+    loop = get_loop(loop_id)
+    if not loop:
+        raise HTTPException(status_code=404, detail=f"Loop {loop_id} not found")
+    return loop.export_state()
+
+
+@router.post("/loops/import")
+def import_loop_state(payload: Dict[str, Any]):
+    """Resume a loop from an exported state (checkpoint restore).
+
+    Body is the JSON returned by GET /loops/{id}/export. Rebuilds the
+    loop (search included) and registers it in the loop registry.
+    """
+    if not isinstance(payload, dict) or not payload.get("loop_id"):
+        raise HTTPException(status_code=400, detail="invalid exported loop state")
+    try:
+        loop = AdaptiveLoop.restore_state(payload)
+    except Exception as exc:
+        raise HTTPException(status_code=400, detail=f"restore failed: {exc}")
+    return {"loop_id": loop.loop_id, "phase": loop.phase.value, "message": "loop restored"}
+
+
 def check_loop_completion(loop_id: str):
     """Check if the adaptive loop should terminate.
 

+ 251 - 4
web/backend/app/routers/ai_plan.py

@@ -2,16 +2,50 @@
 
 Endpoints for generating simulation plans from natural language
 using Kimi k3 model, with L0 pre-screening validation.
+
+Includes project-bound endpoint that saves AI-generated plan directly
+as a SimulationPlan under the project.
 """
+import json
+import uuid
+from datetime import datetime
 from typing import Dict, Any, Optional, List
-from fastapi import APIRouter, HTTPException
+from fastapi import APIRouter, HTTPException, Depends
 from pydantic import BaseModel, Field
+from sqlalchemy.orm import Session
 
+from ..database import get_db
+from ..models.project import Project
+from ..models.simulation_plan import SimulationPlan
+from ..models.experience_case import ExperienceCase
 from ..services.plan_generator import get_plan_generator
 
 router = APIRouter(prefix="/api/ai-plan", tags=["AI Plan Generation"])
 
 
+def _load_experience_cases(db: Session, topology: str, limit: int = 5) -> List[Dict[str, Any]]:
+    """Load recent experience cases for the topology so the AI can reuse prior
+    design knowledge. Without this the library accumulated by the adaptive
+    loop never flows back into plan generation (value-chain break)."""
+    cases = (
+        db.query(ExperienceCase)
+        .filter(ExperienceCase.topology == topology)
+        .order_by(ExperienceCase.created_at.desc())
+        .limit(limit)
+        .all()
+    )
+    out = []
+    for c in cases:
+        out.append({
+            "topology": c.topology,
+            "params": c.get_params(),
+            "metrics": c.get_metrics(),
+            "conclusion": c.conclusion,
+            "tags": c.tags,
+        })
+    return out
+
+
 class GeneratePlanRequest(BaseModel):
     """Request to generate a plan from natural language."""
     requirement: str = Field(..., description="Natural language simulation requirement")
@@ -19,6 +53,13 @@ class GeneratePlanRequest(BaseModel):
     existing_experience: Optional[List[Dict[str, Any]]] = Field(default=None, description="Optional similar experience cases")
 
 
+class GenerateAndSaveRequest(BaseModel):
+    """Request to generate a plan and save it under a project."""
+    requirement: str = Field(default="", description="Natural language requirement (optional, uses project BC as base)")
+    model_path: Optional[str] = Field(default=None, description="Override model path")
+    plan_name: Optional[str] = Field(default=None, description="Override plan name")
+
+
 class RefinePlanRequest(BaseModel):
     """Request to refine an existing plan."""
     original_plan: Dict[str, Any] = Field(..., description="The previously generated plan")
@@ -26,18 +67,24 @@ class RefinePlanRequest(BaseModel):
 
 
 @router.post("/generate")
-def generate_plan(request: GeneratePlanRequest):
+def generate_plan(request: GeneratePlanRequest, db: Session = Depends(get_db)):
     """Generate a simulation plan from natural language.
 
     Uses Kimi k3 model to convert natural language requirements into
     a structured simulation plan, with L0 pre-screening validation.
+    Returns unified plan format (fixed_params + variables with values).
     """
     try:
         generator = get_plan_generator()
+        # Auto-load library experience when the caller didn't pass any.
+        experience = request.existing_experience
+        if experience is None:
+            topo = (request.project_context or {}).get("topology", "SSSR")
+            experience = _load_experience_cases(db, topo)
         result = generator.generate(
             user_requirement=request.requirement,
             project_context=request.project_context,
-            existing_experience=request.existing_experience,
+            existing_experience=experience,
         )
         return result
     except RuntimeError as e:
@@ -46,6 +93,206 @@ def generate_plan(request: GeneratePlanRequest):
         raise HTTPException(status_code=500, detail=f"Plan generation failed: {str(e)}")
 
 
+@router.post("/projects/{project_id}/generate-and-save")
+def generate_and_save_plan(
+    project_id: int,
+    request: GenerateAndSaveRequest,
+    db: Session = Depends(get_db),
+):
+    """Generate a plan using AI and save it directly under a project.
+
+    Uses the project's boundary conditions as context. The generated plan
+    is in unified format (fixed_params + variables with value lists) and
+    is saved as a SimulationPlan with status 'draft'.
+
+    Args:
+        project_id: Target project ID.
+        request: Optional natural language requirement and overrides.
+
+    Returns:
+        Created SimulationPlan response dict.
+    """
+    project = db.query(Project).filter(Project.id == project_id).first()
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    # Build context from project boundary conditions. Expand against the full
+    # BC catalog so unset fields appear explicitly as null - the AI can only
+    # propose bc_suggestions for fields it can SEE are unset (previously unset
+    # keys were simply absent, so the model had no way to know what to fill).
+    from ..services.bc_fields import get_bc_field_catalog
+    bc = project.get_boundary_conditions()
+    full_bc = {f["key"]: bc.get(f["key"]) for f in get_bc_field_catalog()}
+    context = {
+        "project_id": project.id,
+        "project_name": project.name,
+        "topology": project.topology,
+        "model_path": project.model_path or "",
+        "boundary_conditions": full_bc,
+    }
+
+    # Build requirement: use explicit requirement or construct from BC
+    if request.requirement.strip():
+        requirement = request.requirement
+    else:
+        # Construct a requirement from boundary conditions
+        parts = [f"Topology: {project.topology}"]
+        if bc.get("outer_diameter_mm"):
+            parts.append(f"Outer diameter <= {bc['outer_diameter_mm']}mm")
+        if bc.get("speed_rpm"):
+            parts.append(f"Rated speed {bc['speed_rpm']}rpm")
+        if bc.get("current_a"):
+            parts.append(f"RMS current {bc['current_a']}A")
+        if bc.get("target_efficiency_pct"):
+            parts.append(f"Target efficiency >= {bc['target_efficiency_pct']}%")
+        if bc.get("target_torque_nm"):
+            parts.append(f"Target torque >= {bc['target_torque_nm']}Nm")
+        if bc.get("max_losses_w"):
+            parts.append(f"Max losses <= {bc['max_losses_w']}W")
+        if bc.get("power_w"):
+            parts.append(f"Power {bc['power_w']}W")
+        requirement = "Generate a simulation plan for: " + "; ".join(parts) + ". Recommend scan variables and fixed parameters."
+
+    try:
+        generator = get_plan_generator()
+        result = generator.generate(
+            user_requirement=requirement,
+            project_context=context,
+            existing_experience=_load_experience_cases(db, project.topology or "SSSR"),
+        )
+    except RuntimeError as e:
+        raise HTTPException(status_code=503, detail=str(e))
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"AI generation failed: {str(e)}")
+
+    unified_plan = result.get("plan", {})
+    if not unified_plan or "variables" not in unified_plan:
+        raise HTTPException(status_code=502, detail="AI returned invalid plan format")
+
+    # Override model_path and name
+    model_path = request.model_path or project.model_path or unified_plan.get("model_path", "")
+    plan_name = request.plan_name or unified_plan.get("name", f"AI Plan {datetime.now().strftime('%m%d %H%M')}")
+
+    # Normalize topology against the single-source registry. Legacy projects may
+    # carry a non-registered topology (e.g. "AFIR"); fall back to the only active
+    # topology (SSSR) and surface a warning instead of failing with 422.
+    from src.afmcore.topology import is_supported as _topology_supported
+    topology = project.topology or "SSSR"
+    topology_warnings: List[str] = []
+    if not _topology_supported(topology):
+        topology_warnings.append(
+            f"Unknown topology '{topology}' - defaulted to SSSR"
+        )
+        topology = "SSSR"
+
+    # Auto-fill the base model from the topology registry when nothing
+    # provided one (project/model-less plans otherwise fail preflight with an
+    # empty model_path). DRSS/SDSR have no base model yet -> stays empty and
+    # preflight will report it explicitly.
+    if not model_path:
+        from src.afmcore.topology import default_model_for
+        model_path = default_model_for(topology)
+        if model_path:
+            topology_warnings.append(
+                f"model_path auto-filled from topology default model: {model_path}"
+            )
+
+    # Build plan_data
+    plan_id = f"SP-{datetime.now().strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
+    # Persist the project's boundary conditions (normalized to canonical BC
+    # keys) onto the plan so the plan-detail boundary display has data and the
+    # BC the plan was generated against is traceable (P1-1).
+    # Source tracking (bc_meta): user-specified project BC keys are tagged
+    # "user"; AI bc_suggestions only fill keys the user left unset and are
+    # tagged "ai" (requirement: distinguish user-specified vs AI-supplemented).
+    from ..services.bc_fields import normalize_bc
+    user_bc = normalize_bc(bc)
+    raw_ai_plan = result.get("raw_ai_plan", {})
+    ai_suggestions = raw_ai_plan.get("bc_suggestions") if isinstance(raw_ai_plan, dict) else None
+    bc_meta = {k: {"source": "user"} for k in user_bc}
+    merged_bc = dict(user_bc)
+    if isinstance(ai_suggestions, dict):
+        for k, v in ai_suggestions.items():
+            if v is None or v == "":
+                continue
+            norm = normalize_bc({k: v})
+            for nk, nv in norm.items():
+                if nk not in merged_bc:  # never override a user-specified value
+                    merged_bc[nk] = nv
+                    bc_meta[nk] = {"source": "ai"}
+    plan_data = {
+        "plan_id": plan_id,
+        "plan_version": "2.0",
+        "created_at": datetime.now().isoformat(timespec="seconds"),
+        "topology": topology,
+        "model_path": model_path,
+        "boundary_conditions": merged_bc,
+        "bc_meta": bc_meta,
+        "fixed_params": unified_plan.get("fixed_params", []),
+        "variables": unified_plan.get("variables", []),
+        "cases": unified_plan.get("cases", [{"id": "default", "name": "Default", "params": {}}]),
+        "output_metrics": unified_plan.get("output_metrics", []),
+        "search_strategy": unified_plan.get("search_strategy"),
+        "acceptance_criteria": unified_plan.get("acceptance_criteria"),
+        "ai_reasoning": unified_plan.get("ai_reasoning", ""),
+        "iteration": 1,
+        "parent_plan_id": "",
+    }
+
+    # Validate against the single-source plan schema (structural checks
+    # before persisting an AI-generated plan).
+    from src.plan_schema import validate_plan_dict
+    _ok, _errs = validate_plan_dict(plan_data, require_model_path=False)
+    if not _ok:
+        raise HTTPException(
+            status_code=422,
+            detail="Invalid generated plan: " + "; ".join(_errs),
+        )
+
+    # Compute estimated points
+    estimated_points = 1
+    for v in plan_data["variables"]:
+        estimated_points *= len(v.get("values", [])) if v.get("values") else 1
+
+    # Variables summary for display
+    variables_summary = {}
+    for v in plan_data["variables"]:
+        variables_summary[v["name"]] = {
+            "unit": v.get("unit", ""),
+            "values": v.get("values", []),
+            "count": len(v.get("values", [])),
+        }
+
+    # Save to database
+    db_plan = SimulationPlan(
+        project_id=project_id,
+        name=plan_name,
+        plan_id=plan_id,
+        status="draft",
+        estimated_points=estimated_points,
+        estimated_time_min=estimated_points * 3,
+        notes=f"AI-generated. Reasoning: {unified_plan.get('ai_reasoning', '')[:500]}",
+    )
+    db_plan.set_plan_dict(plan_data)
+    db_plan.variables_summary = json.dumps(variables_summary, ensure_ascii=False)
+    db.add(db_plan)
+    db.commit()
+    db.refresh(db_plan)
+
+    return {
+        "id": db_plan.id,
+        "plan_id": db_plan.plan_id,
+        "name": db_plan.name,
+        "status": db_plan.status,
+        "estimated_points": db_plan.estimated_points,
+        "estimated_time_min": db_plan.estimated_time_min,
+        "plan_data": plan_data,
+        "ai_reasoning": unified_plan.get("ai_reasoning", ""),
+        "validation": result.get("validation", {}),
+        "warnings": topology_warnings + unified_plan.get("warnings", []),
+    }
+
+
 @router.post("/refine")
 def refine_plan(request: RefinePlanRequest):
     """Refine an existing plan based on user feedback."""
@@ -70,7 +317,7 @@ def list_templates():
             {
                 "name": "generate",
                 "path": "prompts/plan_generation/generate.txt",
-                "description": "Natural language to structured simulation plan",
+                "description": "Natural language to structured simulation plan (unified v2.0)",
             }
         ],
         "model": "kimi-k3",

+ 29 - 0
web/backend/app/routers/analytics.py

@@ -31,6 +31,35 @@ def _result_to_dict(r: SimulationResult) -> dict:
     }
 
 
+@router.get("/solve-time-stats")
+def solve_time_stats(db: Session = Depends(get_db)):
+    """Measured per-point solve time statistics from completed results.
+
+    Used by the frontend to calibrate the estimated simulation duration from
+    real solve times instead of a hardcoded per-point guess (P2-1). Returns
+    avg/min/max/median over results that recorded a positive solve_time_s.
+    """
+    rows = (
+        db.query(SimulationResult.solve_time_s)
+        .filter(SimulationResult.solve_time_s.isnot(None))
+        .filter(SimulationResult.solve_time_s > 0)
+        .all()
+    )
+    vals = sorted(float(r[0]) for r in rows)
+    if not vals:
+        return {"count": 0, "avg_s": None, "min_s": None, "max_s": None, "median_s": None}
+    n = len(vals)
+    avg = sum(vals) / n
+    median = vals[n // 2] if n % 2 else (vals[n // 2 - 1] + vals[n // 2]) / 2
+    return {
+        "count": n,
+        "avg_s": round(avg, 1),
+        "min_s": round(vals[0], 1),
+        "max_s": round(vals[-1], 1),
+        "median_s": round(median, 1),
+    }
+
+
 def _case_to_dict(c: ExperienceCase) -> dict:
     return {
         "id": c.id,

+ 45 - 0
web/backend/app/routers/executor_monitor.py

@@ -0,0 +1,45 @@
+"""Executor monitoring API router (P1-9).
+
+Endpoints for local executor heartbeat, status, and task overview.
+"""
+from typing import Any, Dict, Optional
+from fastapi import APIRouter
+from pydantic import BaseModel, Field
+
+from ..services.task_manager import get_task_manager
+
+router = APIRouter(prefix="/api/executor", tags=["executor"])
+
+
+class HeartbeatRequest(BaseModel):
+    executor_id: str = Field(..., description="Unique executor identifier")
+    status: str = Field(default="idle", description="idle / running / error")
+    current_task: Optional[str] = Field(default=None, description="Current task_id")
+    progress: Optional[Dict[str, Any]] = Field(default=None, description="Progress data")
+
+
+@router.post("/heartbeat")
+def executor_heartbeat(request: HeartbeatRequest):
+    """Local executor reports heartbeat and status."""
+    manager = get_task_manager()
+    result = manager.executor_heartbeat(
+        executor_id=request.executor_id,
+        status=request.status,
+        current_task=request.current_task,
+        progress=request.progress,
+    )
+    return result
+
+
+@router.get("/status")
+def get_executor_status():
+    """Get all registered executors and their online status."""
+    manager = get_task_manager()
+    return manager.get_executor_status()
+
+
+@router.get("/overview")
+def get_task_overview():
+    """Get task overview statistics for monitoring dashboard."""
+    manager = get_task_manager()
+    return manager.get_overview()

+ 193 - 0
web/backend/app/routers/experience.py

@@ -284,3 +284,196 @@ def import_from_result(
     db.commit()
     db.refresh(case)
     return _case_to_dict(case)
+
+
+# ---------------------------------------------------------------------------
+# Smart experience extraction (P2-10)
+# ---------------------------------------------------------------------------
+
+@router.post("/from-plan/{plan_id}/smart-extract")
+def smart_extract_experience(
+    plan_id: int,
+    data: dict | None = None,
+    db: Session = Depends(get_db),
+):
+    """Smart extract: only import Pareto-optimal points that satisfy constraints.
+
+    Unlike import_from_plan (which imports ALL OK results), this endpoint:
+    1. Filters results by acceptance criteria (hard constraints)
+    2. Finds Pareto-optimal points (max efficiency, min losses, min ripple)
+    3. Generates AI-style conclusions with parameter-performance insights
+    4. Tags them as 'pareto-optimal' and 'constraint-satisfied'
+
+    Returns the imported cases and a summary of extracted design rules.
+    """
+    plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
+    if not plan:
+        raise HTTPException(status_code=404, detail="Plan not found")
+
+    data = data or {}
+    plan_data = plan.get_plan_dict() if hasattr(plan, "get_plan_dict") else {}
+    topology = plan_data.get("topology", "SSSR")
+    model_path = plan_data.get("model_path", "")
+
+    # Get acceptance criteria
+    ac = plan_data.get("acceptance_criteria", {})
+    hard_constraints = ac.get("hard_constraints", [])
+
+    results = db.query(SimulationResult).filter(
+        SimulationResult.plan_id == plan_id,
+        SimulationResult.status == "OK",
+    ).all()
+
+    if not results:
+        return {"imported": 0, "message": "No OK results found"}
+
+    # Parse and filter by hard constraints
+    def _check_constraint(metrics: dict, constraint: str) -> bool:
+        parts = constraint.replace(">=", " >= ").replace("<=", " <= ").split()
+        if len(parts) < 3:
+            return True
+        metric, op, val = parts[0], parts[1], float(parts[2])
+        mv = metrics.get(metric)
+        if mv is None:
+            return True
+        if op == ">=":
+            return mv >= val
+        elif op == "<=":
+            return mv <= val
+        elif op == ">":
+            return mv > val
+        elif op == "<":
+            return mv < val
+        return True
+
+    satisfying = []
+    for r in results:
+        metrics = r.get_metrics()
+        ok = all(_check_constraint(metrics, c) for c in hard_constraints)
+        if ok:
+            satisfying.append(r)
+
+    if not satisfying:
+        return {
+            "imported": 0,
+            "total_ok": len(results),
+            "satisfying_constraints": 0,
+            "message": "No results satisfy all hard constraints",
+        }
+
+    # Find Pareto-optimal points (max efficiency, min total_losses, min ripple)
+    def _is_pareto(candidate: dict, others: list) -> bool:
+        for o in others:
+            if (o.get("efficiency_pct", 0) >= candidate.get("efficiency_pct", 0) and
+                o.get("total_losses_w", 9999) <= candidate.get("total_losses_w", 9999) and
+                o.get("ripple_pct", 9999) <= candidate.get("ripple_pct", 9999) and
+                (o.get("efficiency_pct", 0) > candidate.get("efficiency_pct", 0) or
+                 o.get("total_losses_w", 9999) < candidate.get("total_losses_w", 9999) or
+                 o.get("ripple_pct", 9999) < candidate.get("ripple_pct", 9999))):
+                return False
+        return True
+
+    satisfying_metrics = [r.get_metrics() for r in satisfying]
+    pareto_indices = [i for i, m in enumerate(satisfying_metrics) if _is_pareto(m, satisfying_metrics)]
+    pareto_results = [satisfying[i] for i in pareto_indices]
+
+    # Import Pareto-optimal points
+    imported = 0
+    imported_cases = []
+    for r in pareto_results:
+        params = r.get_params()
+        metrics = r.get_metrics()
+        conclusion = _generate_smart_conclusion(params, metrics, topology, hard_constraints)
+        tags = ["pareto-optimal", "constraint-satisfied", topology.lower()]
+
+        case = ExperienceCase(
+            source_plan_id=plan.plan_id or str(plan.id),
+            topology=topology,
+            model_path=model_path,
+            conclusion=conclusion,
+            tags=",".join(tags),
+            rating=5,
+        )
+        case.params_json = json.dumps(params, ensure_ascii=False)
+        case.metrics_json = json.dumps(metrics, ensure_ascii=False)
+        db.add(case)
+        imported += 1
+        imported_cases.append({
+            "params": params,
+            "metrics": metrics,
+            "conclusion": conclusion,
+        })
+
+    db.commit()
+
+    # Generate design rules summary
+    rules = _extract_design_rules(satisfying_metrics, plan_data.get("variables", []))
+
+    return {
+        "imported": imported,
+        "total_ok": len(results),
+        "satisfying_constraints": len(satisfying),
+        "pareto_count": len(pareto_results),
+        "cases": imported_cases,
+        "design_rules": rules,
+        "message": f"Imported {imported} Pareto-optimal cases (from {len(satisfying)} satisfying constraints)",
+    }
+
+
+def _generate_smart_conclusion(params: dict, metrics: dict, topology: str, constraints: list) -> str:
+    """Generate a smart conclusion with parameter-performance insights."""
+    parts = []
+    eff = metrics.get("efficiency_pct")
+    tavg = metrics.get("tavg_nm")
+    ripple = metrics.get("ripple_pct")
+    losses = metrics.get("total_losses_w")
+
+    if eff is not None:
+        parts.append(f"efficiency {eff:.1f}%")
+    if tavg is not None:
+        parts.append(f"torque {tavg:.3f} Nm")
+    if ripple is not None:
+        parts.append(f"ripple {ripple:.2f}%")
+    if losses is not None:
+        parts.append(f"losses {losses:.1f} W")
+
+    # Add parameter highlights
+    highlights = []
+    if "Airgap" in params:
+        highlights.append(f"airgap={params['Airgap']}mm")
+    if "Magnet_Length" in params:
+        highlights.append(f"magnet_len={params['Magnet_Length']}mm")
+    if "RMSCurrent" in params:
+        highlights.append(f"I={params['RMSCurrent']}A")
+    if highlights:
+        parts.append("params: " + ", ".join(highlights))
+
+    if constraints:
+        parts.append("meets all hard constraints")
+
+    return "; ".join(parts)
+
+
+def _extract_design_rules(metrics_list: list, variables: list) -> list:
+    """Extract simple design rules from satisfying results."""
+    rules = []
+    if not metrics_list:
+        return rules
+
+    # Find best efficiency point and its characteristics
+    best = max(metrics_list, key=lambda m: m.get("efficiency_pct", 0))
+    if best.get("efficiency_pct"):
+        rules.append(f"Best efficiency {best['efficiency_pct']:.1f}% achieved with "
+                     f"torque {best.get('tavg_nm', '?')}Nm, ripple {best.get('ripple_pct', '?')}%")
+
+    # Efficiency range
+    effs = [m.get("efficiency_pct") for m in metrics_list if m.get("efficiency_pct")]
+    if effs:
+        rules.append(f"Efficiency range: {min(effs):.1f}% - {max(effs):.1f}% across scanned parameters")
+
+    # Ripple range
+    ripples = [m.get("ripple_pct") for m in metrics_list if m.get("ripple_pct")]
+    if ripples:
+        rules.append(f"Torque ripple range: {min(ripples):.2f}% - {max(ripples):.2f}%")
+
+    return rules

+ 13 - 0
web/backend/app/routers/generation.py

@@ -33,6 +33,19 @@ def list_scan_parameters(category: str | None = None):
     return ParameterRegistryResponse(total=len(params), parameters=params)
 
 
+@router.get("/bc-fields")
+def list_bc_fields():
+    """Return the boundary-condition field catalog (single source of truth).
+
+    Used by the frontend to render the project boundary form and the plan
+    detail boundary display from one canonical catalog, so BC keys/labels
+    cannot drift across pages (P1-1).
+    """
+    from ..services.bc_fields import get_bc_field_catalog
+    fields = get_bc_field_catalog()
+    return {"total": len(fields), "fields": fields}
+
+
 # ---------------------------------------------------------------------------
 # Range recommendation
 # ---------------------------------------------------------------------------

+ 675 - 1
web/backend/app/routers/plans.py

@@ -1,5 +1,6 @@
-"""Simulation plan API router (CRUD + download + upload results)."""
+"""Simulation plan API router (CRUD + download + upload results + start simulation)."""
 import json
+import math
 import uuid
 from datetime import datetime
 from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
@@ -14,9 +15,57 @@ from ..schemas.simulation_plan import (
     PlanCreate, PlanUpdate, PlanResponse, PlanListResponse, PlanDownloadResponse,
 )
 from ..schemas.simulation_result import ResultListResponse
+from ..services.task_manager import get_task_manager
 
 router = APIRouter(prefix="/api/plans", tags=["plans"])
 
+@router.get("/variable-catalog")
+def get_variable_catalog(topology: str = "SSSR"):
+    """Return topology-aware variable catalog for frontend scan-variable selectors.
+
+    Returns the fixed-parameter template (with motorcad_var resolved) and
+    the set of known Motor-CAD variable names for the given topology.
+    Frontend should use this to populate scan-variable dropdowns and prevent
+    users from entering invalid variable names.
+
+    Args:
+        topology: Motor topology (SSSR/AFIR/RFM). Defaults to SSSR.
+    """
+    from ..services.fixed_params_template import FIXED_PARAM_TEMPLATES
+    from ..services.topology_variable_map import (
+        get_known_variables,
+        normalize_topology,
+        resolve_variable,
+    )
+
+    topo = normalize_topology(topology)
+
+    # Build template with resolved motorcad_var for this topology.
+    # For params where motorcad_var is None but the name is a known alias,
+    # resolve it. Otherwise keep name as the variable name if known.
+    template = []
+    for p in FIXED_PARAM_TEMPLATES:
+        row = dict(p)
+        mc_var = row.get("motorcad_var")
+        if not mc_var:
+            # Try to resolve from alias map
+            resolved, was_alias = resolve_variable(row["name"], topo)
+            if was_alias:
+                row["motorcad_var"] = resolved
+            else:
+                row["motorcad_var"] = row["name"]
+        template.append(row)
+
+    known_vars = sorted(get_known_variables(topo))
+
+    return {
+        "topology": topo,
+        "template": template,
+        "known_variables": known_vars,
+        "total_known": len(known_vars),
+        "total_template": len(template),
+    }
+
 
 def _generate_plan_id() -> str:
     """Generate a unique plan ID with timestamp + random suffix to avoid collisions."""
@@ -74,6 +123,37 @@ def create_plan(data: PlanCreate, db: Session = Depends(get_db)):
     plan_data = data.plan_data
     plan_data["plan_id"] = plan_id
 
+    # Persist the project boundary conditions (normalized to canonical BC
+    # keys) when the caller did not supply them, so the plan-detail boundary
+    # display has data and the plan is traceable to its BC (P1-1).
+    if not plan_data.get("boundary_conditions"):
+        from ..services.bc_fields import normalize_bc
+        plan_data["boundary_conditions"] = normalize_bc(project.get_boundary_conditions())
+    # Source tracking: manually created plans inherit all BC from the project
+    # (user-specified). AI-generated plans tag sources in ai_plan.py instead.
+    if not plan_data.get("bc_meta"):
+        plan_data["bc_meta"] = {
+            k: {"source": "user"} for k in (plan_data.get("boundary_conditions") or {})
+        }
+
+    # Auto-fill the base model from the topology registry when neither the
+    # caller nor the project provided a model path.
+    if not (plan_data.get("model_path") or "").strip():
+        from src.afmcore.topology import default_model_for
+        fallback = default_model_for(plan_data.get("topology") or project.topology or "")
+        if fallback:
+            plan_data["model_path"] = fallback
+
+    # Validate against the single-source plan schema (draft-stage: model
+    # path may be configured later).
+    from src.plan_schema import validate_plan_dict
+    _ok, _errs = validate_plan_dict(plan_data, require_model_path=False)
+    if not _ok:
+        raise HTTPException(
+            status_code=400,
+            detail="Invalid plan_data: " + "; ".join(_errs),
+        )
+
     # Extract variables summary for display
     variables_summary = {}
     estimated_points = 1
@@ -122,6 +202,15 @@ def update_plan(plan_id: int, data: PlanUpdate, db: Session = Depends(get_db)):
         raise HTTPException(status_code=404, detail="Plan not found")
     update_data = data.model_dump(exclude_unset=True)
     if "plan_data" in update_data:
+        from src.plan_schema import validate_plan_dict
+        _ok, _errs = validate_plan_dict(
+            update_data["plan_data"], require_model_path=False
+        )
+        if not _ok:
+            raise HTTPException(
+                status_code=400,
+                detail="Invalid plan_data: " + "; ".join(_errs),
+            )
         plan.set_plan_dict(update_data.pop("plan_data"))
     for key, value in update_data.items():
         setattr(plan, key, value)
@@ -267,3 +356,588 @@ def get_plan_results(plan_id: int, db: Session = Depends(get_db)):
         for r in results
     ]
     return ResultListResponse(total=len(items), items=items)
+
+
+# ---------------------------------------------------------------------------
+# One-click simulation start (P0-4)
+# ---------------------------------------------------------------------------
+
+
+def _expand_plan_to_parameters(plan_data: dict) -> list[dict]:
+    """Expand plan variables into full parameter sets (Cartesian product).
+
+    Fixed params are canonicalized against the template: the template is the
+    source of truth for the real Motor-CAD variable name (motorcad_var),
+    while the plan's stored value overrides the template default. Only params
+    with a real motorcad_var are written to the simulation.
+
+    Scan variables are resolved through:
+    1. The template (if name matches a template param, use its motorcad_var)
+    2. The topology-aware alias map (RFM names remapped to AFM names)
+    3. Fallback: use the name as-is (caller should validate).
+
+    Returns list of param dicts for the task executor.
+    """
+    from ..services.fixed_params_template import FIXED_PARAM_TEMPLATES
+    from ..services.topology_variable_map import (
+        resolve_variable,
+        normalize_topology,
+    )
+
+    topology = normalize_topology(plan_data.get("topology"))
+    template_by_name = {p["name"].lower(): p for p in FIXED_PARAM_TEMPLATES}
+
+    plan_fps = plan_data.get("fixed_params", []) or []
+    seen = set()
+    merged = []
+    for fp in plan_fps:
+        if not (isinstance(fp, dict) and fp.get("name")):
+            continue
+        name = fp["name"]
+        key = name.lower()
+        if key in seen:
+            continue
+        seen.add(key)
+        tmpl = template_by_name.get(key)
+        val = fp.get("value")
+        if tmpl:
+            row = dict(tmpl)  # includes motorcad_var (baseline-tuned default)
+            # Only user-explicitly-modified values override the baseline
+            # default. AI-suggested values (or legacy params without source)
+            # keep the template default so old plans run with valid geometry.
+            if fp.get("source") == "user" and val is not None and val != "":
+                row["value"] = val
+            merged.append(row)
+        else:
+            # Param not in template: try topology alias resolution.
+            # If the alias map resolves it, use the resolved name as
+            # motorcad_var. Otherwise keep None (will not be written).
+            row = dict(fp)
+            resolved, was_alias = resolve_variable(name, topology)
+            if was_alias:
+                row["motorcad_var"] = resolved
+            else:
+                row["motorcad_var"] = None
+            merged.append(row)
+    # Template params missing from the plan (fill with template defaults)
+    for tmpl in FIXED_PARAM_TEMPLATES:
+        if tmpl["name"].lower() not in seen:
+            merged.append(dict(tmpl))
+            seen.add(tmpl["name"].lower())
+
+    fixed = {}
+    for fp in merged:
+        var = fp.get("motorcad_var")
+        val = fp.get("value")
+        if var and val is not None and val != "":
+            try:
+                fixed[var] = float(val)
+            except (TypeError, ValueError):
+                fixed[var] = val
+
+    variables = plan_data.get("variables", [])
+    if not variables:
+        return [dict(fixed)]
+
+    # Collect value lists for each variable, resolving the variable name
+    # through template -> topology alias map -> fallback to raw name.
+    var_value_lists = []
+    for v in variables:
+        if not isinstance(v, dict):
+            continue
+        name = v.get("name", "")
+        if not name:
+            continue
+        # Resolve the Motor-CAD variable name for this scan variable.
+        tmpl = template_by_name.get(name.lower())
+        if tmpl and tmpl.get("motorcad_var"):
+            resolved_name = tmpl["motorcad_var"]
+        else:
+            resolved_name, _was_alias = resolve_variable(name, topology)
+        values = v.get("values", [])
+        if not values and v.get("start") is not None and v.get("stop") is not None and v.get("step"):
+            start, stop, step = float(v["start"]), float(v["stop"]), float(v["step"])
+            count = int(math.floor((stop - start) / step + 1e-9)) + 1
+            values = [round(start + i * step, 6) for i in range(count)]
+            if values and abs(values[-1] - stop) > 1e-9:
+                values.append(round(stop, 6))
+        if values:
+            var_value_lists.append((resolved_name, values))
+
+    if not var_value_lists:
+        return [dict(fixed)]
+
+    # Cartesian product
+    def _cartesian(idx: int, current: dict) -> list[dict]:
+        if idx >= len(var_value_lists):
+            return [dict(current)]
+        name, vals = var_value_lists[idx]
+        result = []
+        for val in vals:
+            current[name] = val
+            result.extend(_cartesian(idx + 1, current))
+        return result
+
+    return _cartesian(0, dict(fixed))
+
+@router.get("/{plan_id}/preflight")
+def preflight_check(plan_id: int, db: Session = Depends(get_db)):
+    """Pre-flight checklist before starting a simulation.
+
+    Returns machine-readable checks (key/status/data only; the frontend maps
+    keys to localized labels and messages). status: pass | warn | fail.
+    Any 'fail' blocks starting; 'warn' is advisory and does not block.
+    """
+    import os
+    from ..config import PROJECT_ROOT
+    plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
+    if not plan:
+        raise HTTPException(status_code=404, detail="Plan not found")
+    plan_data = plan.get_plan_dict()
+    checks = []
+
+    # 1. Model file must exist. model_path may be repo-relative (e.g.
+    # "models/xxx.mot") or absolute; resolve relative paths against the repo
+    # root so the check matches how the executor locates the model.
+    model_path = (plan_data.get("model_path") or "").strip()
+    resolved = model_path
+    if model_path and not os.path.isabs(model_path):
+        resolved = os.path.join(str(PROJECT_ROOT), model_path)
+    # Expose the topology's default base model so the UI can offer one-click
+    # repair when the check fails.
+    from src.afmcore.topology import default_model_for
+    default_model = default_model_for(plan_data.get("topology") or "")
+    if not model_path:
+        checks.append({
+            "key": "model_path", "status": "fail", "value": "",
+            "fixable": bool(default_model), "default_model": default_model,
+        })
+    elif not os.path.exists(resolved):
+        checks.append({
+            "key": "model_path", "status": "fail", "value": model_path,
+            "fixable": bool(default_model), "default_model": default_model,
+        })
+    else:
+        checks.append({"key": "model_path", "status": "pass", "value": model_path})
+
+    # 2. Fixed params whose Motor-CAD variable name is unverified (no mapping).
+    unverified = [
+        fp["name"] for fp in (plan_data.get("fixed_params") or [])
+        if isinstance(fp, dict) and fp.get("name") and not fp.get("motorcad_var")
+    ]
+    checks.append({
+        "key": "unverified_vars",
+        "status": "warn" if unverified else "pass",
+        "items": unverified,
+    })
+
+    # 3. At least one scan variable with values.
+    variables = plan_data.get("variables") or []
+    valid_vars = [
+        v for v in variables
+        if isinstance(v, dict) and v.get("name") and (v.get("values") or [])
+    ]
+    checks.append({
+        "key": "scan_vars",
+        "status": "pass" if valid_vars else "fail",
+        "count": len(valid_vars),
+    })
+
+    # 4. Point-count estimate (advisory when large).
+    total = 1
+    for v in valid_vars:
+        total *= len(v.get("values") or [1])
+    checks.append({
+        "key": "point_count",
+        "status": "warn" if total > 200 else "pass",
+        "count": total,
+    })
+
+    # 5. Local executor online (advisory: tasks can queue while offline).
+    try:
+        exec_status = get_task_manager().get_executor_status()
+        online = sum(1 for e in exec_status.get("executors", []) if e.get("online"))
+    except Exception:
+        online = 0
+    checks.append({
+        "key": "executor",
+        "status": "pass" if online > 0 else "warn",
+        "online": online,
+    })
+
+    ok = not any(c["status"] == "fail" for c in checks)
+    return {"ok": ok, "checks": checks}
+
+
+@router.post("/{plan_id}/start-simulation")
+def start_simulation(plan_id: int, db: Session = Depends(get_db)):
+    """One-click start: expand plan to parameters, create task, dispatch.
+
+    Automatically:
+    1. Expands variables into Cartesian product parameter sets
+    2. Merges fixed_params into each parameter set
+    3. Creates a Task linked to this plan
+    4. Marks task as dispatched (local executor picks it up)
+    5. Updates plan status to 'executing'
+
+    Returns the created task info.
+    """
+    plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
+    if not plan:
+        raise HTTPException(status_code=404, detail="Plan not found")
+
+    plan_data = plan.get_plan_dict()
+
+    # Runtime fallback: legacy plans may carry an empty model_path (created
+    # before the topology default-model auto-fill). Auto-fill from the
+    # topology registry and persist so the plan becomes self-contained.
+    if not (plan_data.get("model_path") or "").strip():
+        from src.afmcore.topology import default_model_for
+        fallback = default_model_for(plan_data.get("topology") or "")
+        if fallback:
+            plan_data["model_path"] = fallback
+            plan.set_plan_dict(plan_data)
+            db.commit()
+
+    parameters = _expand_plan_to_parameters(plan_data)
+
+    if not parameters:
+        raise HTTPException(status_code=400, detail="Plan has no valid parameters to simulate")
+
+    # --- Topology-aware variable name validation (P1-bugfix: plan 23) ---
+    # Reject unknown variable names BEFORE creating the task, with suggested
+    # alternatives. This prevents silent Motor-CAD "Could not find variable"
+    # failures that waste 15+ minutes of simulation time.
+    from ..services.topology_variable_map import (
+        is_known_variable,
+        suggest_alternative,
+        normalize_topology,
+    )
+    topo = normalize_topology(plan_data.get("topology"))
+    if parameters:
+        all_var_names = list(parameters[0].keys())
+        unknown = []
+        for vname in all_var_names:
+            if not is_known_variable(vname, topo):
+                suggestion = suggest_alternative(vname, topo)
+                unknown.append({
+                    "variable": vname,
+                    "suggestion": suggestion,
+                })
+        if unknown:
+            detail_lines = [
+                f"Unknown Motor-CAD variable(s) for topology {topo}. "
+                "These will cause 'Could not find variable' errors in Motor-CAD.",
+            ]
+            for u in unknown:
+                if u["suggestion"]:
+                    detail_lines.append(
+                        f"  - '{u['variable']}' -> did you mean '{u['suggestion']}'?"
+                    )
+                else:
+                    detail_lines.append(
+                        f"  - '{u['variable']}' (no close match found; verify against .mot model)"
+                    )
+            raise HTTPException(status_code=400, detail="\n".join(detail_lines))
+    # --- End variable name validation ---
+
+    # Create task via task manager
+    manager = get_task_manager()
+    task = manager.create_task(
+        plan_id=plan_id,
+        plan_data=plan_data,
+        parameters=parameters,
+        task_name=f"{plan.name}_run",
+        priority=5,
+        created_by="web",
+    )
+
+    # Dispatch immediately
+    try:
+        manager.dispatch_task(task["task_id"])
+    except ValueError:
+        pass  # Already dispatched or other state issue
+
+    # Update plan status
+    plan.status = "executing"
+    db.commit()
+
+    return {
+        "task_id": task["task_id"],
+        "task_name": task["task_name"],
+        "plan_id": plan.plan_id,
+        "total_points": len(parameters),
+        "status": "dispatched",
+        "message": f"Simulation started with {len(parameters)} points. Local executor will pick it up.",
+    }
+
+
+@router.get("/{plan_id}/active-task")
+def get_active_task(plan_id: int, db: Session = Depends(get_db)):
+    """Get the most recent active task for a plan (for progress display)."""
+    plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
+    if not plan:
+        raise HTTPException(status_code=404, detail="Plan not found")
+
+    manager = get_task_manager()
+    tasks = manager.list_tasks(plan_id=plan_id, limit=1)
+    if tasks.get("tasks"):
+        return tasks["tasks"][0]
+    return None
+
+
+# ---------------------------------------------------------------------------
+# AI Analysis & Iteration (P1-8)
+# ---------------------------------------------------------------------------
+
+@router.post("/{plan_id}/ai-analyze")
+def ai_analyze_results(plan_id: int, db: Session = Depends(get_db)):
+    """Analyze simulation results using AI and return insights.
+
+    Returns key metrics summary, parameter sensitivity, anomaly detection,
+    and recommendations for next iteration.
+    """
+    plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
+    if not plan:
+        raise HTTPException(status_code=404, detail="Plan not found")
+
+    results = (
+        db.query(SimulationResult)
+        .filter(SimulationResult.plan_id == plan_id)
+        .order_by(SimulationResult.run_index.asc())
+        .all()
+    )
+    if not results:
+        raise HTTPException(status_code=400, detail="No simulation results to analyze")
+
+    # Compute basic statistics
+    ok_results = [r for r in results if r.status == "OK"]
+    metrics_list = [r.get_metrics() for r in ok_results]
+    params_list = [r.get_params() for r in ok_results]
+
+    if not metrics_list:
+        return {"summary": "All points failed", "ok_count": 0, "total": len(results)}
+
+    # Compute metric stats
+    def _stats(key: str) -> dict:
+        vals = [m.get(key) for m in metrics_list if m.get(key) is not None]
+        if not vals:
+            return {}
+        return {
+            "min": min(vals), "max": max(vals),
+            "avg": sum(vals) / len(vals),
+            "count": len(vals),
+        }
+
+    metric_stats = {
+        "tavg_nm": _stats("tavg_nm"),
+        "ripple_pct": _stats("ripple_pct"),
+        "efficiency_pct": _stats("efficiency_pct"),
+        "total_losses_w": _stats("total_losses_w"),
+    }
+
+    # Find best point by efficiency
+    best_idx = -1
+    best_eff = -1
+    for i, m in enumerate(metrics_list):
+        eff = m.get("efficiency_pct", 0)
+        if eff and eff > best_eff:
+            best_eff = eff
+            best_idx = i
+
+    best_point = None
+    if best_idx >= 0:
+        best_point = {
+            "run_index": ok_results[best_idx].run_index,
+            "params": params_list[best_idx],
+            "metrics": metrics_list[best_idx],
+        }
+
+    # Simple parameter sensitivity (correlation-like)
+    sensitivity = {}
+    param_keys = set()
+    for p in params_list:
+        param_keys.update(p.keys())
+    for pk in param_keys:
+        vals = [p.get(pk) for p in params_list if p.get(pk) is not None]
+        if len(vals) < 2:
+            continue
+        effs = [metrics_list[i].get("efficiency_pct", 0) for i, p in enumerate(params_list) if p.get(pk) is not None]
+        if len(effs) < 2:
+            continue
+        # Simple: range of efficiency vs range of param
+        p_range = max(vals) - min(vals)
+        e_range = max(effs) - min(effs)
+        if p_range > 0:
+            sensitivity[pk] = round(e_range / p_range, 4)
+
+    # Boundary condition check
+    plan_data = plan.get_plan_dict()
+    bc = plan_data.get("acceptance_criteria", {})
+    constraints = bc.get("hard_constraints", [])
+    satisfied = []
+    violated = []
+    for c in constraints:
+        # Simple parse: "metric >= value" or "metric <= value"
+        parts = c.replace(">=", ">=").replace("<=", "<=").split()
+        if len(parts) >= 3:
+            metric, op, val = parts[0], parts[1], float(parts[2])
+            stat = metric_stats.get(metric, {})
+            if stat:
+                if op == ">=" and stat.get("max", 0) >= val:
+                    satisfied.append(c)
+                elif op == "<=" and stat.get("min", 999) <= val:
+                    satisfied.append(c)
+                else:
+                    violated.append(c)
+
+    return {
+        "total_points": len(results),
+        "ok_count": len(ok_results),
+        "failed_count": len(results) - len(ok_results),
+        "metric_stats": metric_stats,
+        "best_point": best_point,
+        "sensitivity": sensitivity,
+        "constraints_satisfied": satisfied,
+        "constraints_violated": violated,
+        "recommendations": _generate_recommendations(metric_stats, sensitivity, best_point, violated),
+    }
+
+
+def _generate_recommendations(metric_stats: dict, sensitivity: dict, best_point: dict, violated: list) -> list[str]:
+    """Generate simple recommendations based on analysis results."""
+    recs = []
+    eff = metric_stats.get("efficiency_pct", {})
+    ripple = metric_stats.get("ripple_pct", {})
+
+    if eff and eff.get("max", 0) < 90:
+        recs.append("\u6700\u9ad8\u6548\u7387\u4f4e\u4e8e90%\uff0c\u5efa\u8bae\u51cf\u5c0f\u6c14\u9699\u6216\u589e\u52a0\u78c1\u94a2\u539a\u5ea6\u4ee5\u63d0\u5347\u8f6c\u77e9\u5bc6\u5ea6")
+    if ripple and ripple.get("min", 100) > 5:
+        recs.append("\u8f6c\u77e9\u8109\u52a8\u504f\u9ad8(>5%)\uff0c\u5efa\u8bae\u626b\u63cf\u78c1\u94a2\u6781\u5f27\u89d2(Magnet_Arc)\u4f18\u5316\u8109\u52a8")
+    if sensitivity:
+        top_sens = sorted(sensitivity.items(), key=lambda x: abs(x[1]), reverse=True)[:3]
+        for pk, sv in top_sens:
+            direction = "\u589e\u5927" if sv > 0 else "\u51cf\u5c0f"
+            recs.append(f"{pk}\u5bf9\u6548\u7387\u5f71\u54cd\u663e\u8457(\u7075\u654f\u5ea6={sv})\uff0c\u5efa\u8bae\u4e0b\u4e00\u8f6e{direction}\u8be5\u53c2\u6570\u8303\u56f4")
+    if best_point:
+        recs.append(f"\u5f53\u524d\u6700\u4f18\u70b9: \u6548\u7387{best_point['metrics'].get('efficiency_pct', '?')}%, \u5efa\u8bae\u4ee5\u8be5\u70b9\u53c2\u6570\u4e3a\u4e2d\u5fc3\u7f29\u5c0f\u641c\u7d22\u8303\u56f4")
+    if violated:
+        recs.append(f"\u6709{len(violated)}\u9879\u7ea6\u675f\u672a\u6ee1\u8db3\uff0c\u5efa\u8bae\u8c03\u6574\u626b\u63cf\u8303\u56f4\u6216\u56fa\u5b9a\u53c2\u6570")
+    if not recs:
+        recs.append("\u7ed3\u679c\u826f\u597d\uff0c\u5efa\u8bae\u4ee5\u5f53\u524d\u6700\u4f18\u70b9\u4e3a\u4e2d\u5fc3\u8fdb\u884c\u7cbe\u7ec6\u5316\u626b\u63cf")
+    return recs
+
+
+@router.post("/{plan_id}/generate-iteration")
+def generate_iteration_plan(plan_id: int, db: Session = Depends(get_db)):
+    """Generate next iteration plan based on current results.
+
+    Uses AI analysis to adjust scan ranges and creates a new plan
+    with parent_plan_id linking to the current plan.
+    """
+    plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
+    if not plan:
+        raise HTTPException(status_code=404, detail="Plan not found")
+
+    results = (
+        db.query(SimulationResult)
+        .filter(SimulationResult.plan_id == plan_id)
+        .order_by(SimulationResult.run_index.asc())
+        .all()
+    )
+    if not results:
+        raise HTTPException(status_code=400, detail="No simulation results for iteration")
+
+    plan_data = plan.get_plan_dict()
+    ok_results = [r for r in results if r.status == "OK"]
+    if not ok_results:
+        raise HTTPException(status_code=400, detail="No successful results for iteration")
+
+    # Find best point
+    best = max(ok_results, key=lambda r: r.get_metrics().get("efficiency_pct", 0))
+    best_params = best.get_params()
+    best_metrics = best.get_metrics()
+
+    # Generate new variables: narrow ranges around best point
+    new_variables = []
+    for v in plan_data.get("variables", []):
+        name = v.get("name", "")
+        if name in best_params:
+            best_val = best_params[name]
+            step = v.get("step", 0.1)
+            # Narrow to +/- 2 steps around best
+            new_start = round(best_val - 2 * step, 6)
+            new_stop = round(best_val + 2 * step, 6)
+            # Ensure within physical bounds
+            new_start = max(new_start, v.get("start", new_start))
+            new_stop = min(new_stop, v.get("stop", new_stop))
+            values = []
+            if new_stop > new_start and step > 0:
+                count = int((new_stop - new_start) / step) + 1
+                values = [round(new_start + i * step, 6) for i in range(count)]
+            new_variables.append({
+                **v,
+                "start": new_start,
+                "stop": new_stop,
+                "values": values,
+            })
+        else:
+            new_variables.append(v)
+
+    # Create new plan
+    import uuid as _uuid
+    new_plan_id = f"SP-{datetime.now().strftime('%Y%m%d-%H%M%S')}-{_uuid.uuid4().hex[:6]}"
+    iteration = (plan_data.get("iteration", 1) or 1) + 1
+    new_plan_data = {
+        **plan_data,
+        "plan_id": new_plan_id,
+        "iteration": iteration,
+        "parent_plan_id": plan.plan_id,
+        "variables": new_variables,
+        "ai_reasoning": f"Iteration #{iteration}: Narrowed search around best point "
+                        f"(eff={best_metrics.get('efficiency_pct', '?')}%, "
+                        f"torque={best_metrics.get('tavg_nm', '?')}Nm). "
+                        f"Previous best params: {best_params}",
+    }
+
+    estimated_points = 1
+    for v in new_variables:
+        estimated_points *= len(v.get("values", [])) if v.get("values") else 1
+
+    variables_summary = {}
+    for v in new_variables:
+        variables_summary[v["name"]] = {
+            "unit": v.get("unit", ""),
+            "values": v.get("values", []),
+            "count": len(v.get("values", [])),
+        }
+
+    new_plan = SimulationPlan(
+        project_id=plan.project_id,
+        name=f"{plan.name}_iter{iteration}",
+        plan_id=new_plan_id,
+        status="draft",
+        estimated_points=estimated_points,
+        estimated_time_min=estimated_points * 3,
+        notes=f"Iteration #{iteration} from plan {plan.plan_id}. Best eff={best_metrics.get('efficiency_pct', '?')}%",
+    )
+    new_plan.set_plan_dict(new_plan_data)
+    new_plan.variables_summary = json.dumps(variables_summary, ensure_ascii=False)
+    db.add(new_plan)
+    db.commit()
+    db.refresh(new_plan)
+
+    return {
+        "id": new_plan.id,
+        "plan_id": new_plan.plan_id,
+        "name": new_plan.name,
+        "iteration": iteration,
+        "parent_plan_id": plan.plan_id,
+        "estimated_points": estimated_points,
+        "best_point": {
+            "run_index": best.run_index,
+            "params": best_params,
+            "metrics": best_metrics,
+        },
+        "message": f"\u8fed\u4ee3\u65b9\u6848\u5df2\u751f\u6210\uff0c\u56f4\u7ed5\u6700\u4f18\u70b9\u7f29\u5c0f\u641c\u7d22\u8303\u56f4\uff0c\u5171{estimated_points}\u4e2a\u6570\u636e\u70b9",
+    }

+ 23 - 0
web/backend/app/routers/projects.py

@@ -1,5 +1,8 @@
 """Project API router."""
+from typing import List
+
 from fastapi import APIRouter, Depends, HTTPException, Query
+from pydantic import BaseModel, Field
 from sqlalchemy.orm import Session
 
 from ..database import get_db
@@ -94,3 +97,23 @@ def delete_project(project_id: int, db: Session = Depends(get_db)):
     db.delete(project)
     db.commit()
     return None
+
+
+class BatchDeleteProjectsRequest(BaseModel):
+    """Request to delete multiple projects."""
+    ids: List[int] = Field(..., description="Project IDs to delete")
+
+
+@router.post("/batch-delete")
+def batch_delete_projects(request: BatchDeleteProjectsRequest, db: Session = Depends(get_db)):
+    """Delete multiple projects; missing IDs are reported, not fatal."""
+    deleted, errors = [], []
+    for pid in request.ids:
+        project = db.query(Project).filter(Project.id == pid).first()
+        if not project:
+            errors.append({"id": pid, "error": "not found"})
+            continue
+        db.delete(project)
+        deleted.append(pid)
+    db.commit()
+    return {"deleted": deleted, "deleted_count": len(deleted), "errors": errors}

+ 5 - 0
web/backend/app/routers/search.py

@@ -67,6 +67,11 @@ class SearchStateResponse(BaseModel):
     trust_region_radius: float
     best_objective_value: Optional[float] = None
     best_point_params: Optional[Dict[str, float]] = None
+    points_history: List[Dict[str, Any]] = Field(default_factory=list)
+    infeasible_points: int = 0
+    failed_points: int = 0
+    batch_summary: List[Dict[str, Any]] = Field(default_factory=list)
+    l0_summary: Optional[Dict[str, Any]] = None
 
 
 class SearchResultRequest(BaseModel):

+ 66 - 7
web/backend/app/routers/tasks.py

@@ -1,8 +1,10 @@
 """Task management router for Web-Local system dispatch (P4-M2)."""
 from typing import Optional, List, Dict, Any
-from fastapi import APIRouter, HTTPException
+from fastapi import APIRouter, HTTPException, Depends
 from pydantic import BaseModel, Field
+from sqlalchemy.orm import Session
 
+from ..database import get_db
 from ..services.task_manager import get_task_manager
 
 router = APIRouter(prefix="/api/tasks", tags=["Tasks"])
@@ -11,8 +13,8 @@ router = APIRouter(prefix="/api/tasks", tags=["Tasks"])
 class CreateTaskRequest(BaseModel):
     """Request to create a new simulation task."""
     plan_id: Optional[int] = Field(default=None, description="Associated plan ID")
-    plan_data: Dict[str, Any] = Field(..., description="Full plan data")
-    parameters: List[Dict[str, Any]] = Field(..., description="List of parameter sets to simulate")
+    plan_data: Optional[Dict[str, Any]] = Field(default=None, description="Full plan data (auto-loaded from plan_id when omitted)")
+    parameters: Optional[List[Dict[str, Any]]] = Field(default=None, description="Parameter sets (auto-expanded from plan_id when omitted)")
     task_name: Optional[str] = Field(default=None, description="Optional task name")
     priority: int = Field(default=5, ge=1, le=10, description="Task priority (1-10)")
 
@@ -35,14 +37,39 @@ class ResultsReportRequest(BaseModel):
 
 
 @router.post("")
-def create_task(request: CreateTaskRequest):
-    """Create a new simulation task."""
+def create_task(request: CreateTaskRequest, db: Session = Depends(get_db)):
+    """Create a new simulation task.
+
+    When only plan_id is provided, plan_data is loaded from the plan and
+    parameters are expanded from its variables (same logic as the plan's
+    one-click start), so the UI no longer requires hand-written JSON.
+    Explicit plan_data / parameters still take precedence (advanced override).
+    """
+    plan_data = request.plan_data
+    parameters = request.parameters
+
+    if (not plan_data or not parameters) and request.plan_id is not None:
+        from ..models.simulation_plan import SimulationPlan
+        from .plans import _expand_plan_to_parameters
+        plan = db.query(SimulationPlan).filter(SimulationPlan.id == request.plan_id).first()
+        if not plan:
+            raise HTTPException(status_code=404, detail=f"Plan {request.plan_id} not found")
+        if not plan_data:
+            plan_data = plan.get_plan_dict()
+        if not parameters:
+            parameters = _expand_plan_to_parameters(plan_data)
+
+    if not plan_data:
+        raise HTTPException(status_code=400, detail="plan_data is required (or provide a valid plan_id)")
+    if not parameters:
+        raise HTTPException(status_code=400, detail="parameters is empty (plan has no valid variables to simulate)")
+
     try:
         manager = get_task_manager()
         task = manager.create_task(
             plan_id=request.plan_id,
-            plan_data=request.plan_data,
-            parameters=request.parameters,
+            plan_data=plan_data,
+            parameters=parameters,
             task_name=request.task_name,
             priority=request.priority,
         )
@@ -144,6 +171,38 @@ def cancel_task(task_id: str):
         raise HTTPException(status_code=500, detail=str(e))
 
 
+@router.delete("/{task_id}", status_code=204)
+def delete_task(task_id: str):
+    """Delete a finished task (terminal status only)."""
+    try:
+        manager = get_task_manager()
+        manager.delete_task(task_id)
+        return None
+    except ValueError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+class BatchDeleteTasksRequest(BaseModel):
+    """Request to delete multiple finished tasks."""
+    task_ids: List[str] = Field(..., description="Task IDs to delete")
+
+
+@router.post("/batch-delete")
+def batch_delete_tasks(request: BatchDeleteTasksRequest):
+    """Delete multiple finished tasks; per-item failures do not abort the batch."""
+    manager = get_task_manager()
+    deleted, errors = [], []
+    for task_id in request.task_ids:
+        try:
+            manager.delete_task(task_id)
+            deleted.append(task_id)
+        except ValueError as e:
+            errors.append({"task_id": task_id, "error": str(e)})
+    return {"deleted": deleted, "deleted_count": len(deleted), "errors": errors}
+
+
 @router.get("/{task_id}/download")
 def download_task_file(task_id: str):
     """Download task JSON file for local executor."""

+ 233 - 7
web/backend/app/services/adaptive_loop.py

@@ -21,6 +21,7 @@ from ..services.feasibility_search import FeasibilityFirstSearch, ParameterRange
 from ..services.plan_generator import AIPlanGenerator
 from ..services.result_analyst import AIResultAnalyst
 from ..services.experience_enhancer import ExperienceEnhancer
+from ..services.task_manager import get_task_manager
 
 
 class LoopPhase(str, Enum):
@@ -92,6 +93,20 @@ class AdaptiveLoop:
 
         result = self.plan_generator.generate(self.user_requirement)
         self.plan = result.get("plan", {})
+        # The unified plan uses "variables" (converted from the AI's
+        # "scan_variables"); downstream steps read scan_variables. Normalize
+        # once here so both names resolve.
+        if not self.plan.get("scan_variables") and self.plan.get("variables"):
+            self.plan["scan_variables"] = self.plan["variables"]
+        # Topology + default model: loops carry no project context, so fill
+        # from the registry; otherwise simulation would start with no model.
+        from src.afmcore.topology import is_supported, default_model_for
+        topo = (self.plan.get("topology") or "").strip().upper()
+        if not is_supported(topo):
+            topo = "SSSR"
+        self.plan["topology"] = topo
+        if not (self.plan.get("model_path") or "").strip():
+            self.plan["model_path"] = default_model_for(topo)
         self.plan_validation = result.get("validation", {})
         self.phase = LoopPhase.PLAN_GENERATED
         self._record_history("plan_generated", {"plan_name": self.plan.get("plan_name", "")})
@@ -112,18 +127,35 @@ class AdaptiveLoop:
         if not self.plan:
             raise RuntimeError("Plan not generated. Call generate_plan() first.")
 
-        # Convert scan variables to ParameterRange
+        # Convert scan variables to ParameterRange. Accept both the unified
+        # "variables" list (start/stop/step or explicit values) and the raw
+        # "scan_variables" name; the AI may use min_value/max_value instead.
         parameters = []
-        for var in self.plan.get("scan_variables", []):
-            if isinstance(var, dict) and "name" in var and "min_value" in var and "max_value" in var:
+        scan_vars = self.plan.get("scan_variables") or self.plan.get("variables") or []
+        for var in scan_vars:
+            if not isinstance(var, dict) or "name" not in var:
+                continue
+            lo = var.get("min_value", var.get("start"))
+            hi = var.get("max_value", var.get("stop"))
+            if lo is not None and hi is not None:
                 parameters.append(ParameterRange(
                     name=var["name"],
-                    min_value=float(var["min_value"]),
-                    max_value=float(var["max_value"]),
+                    min_value=float(lo),
+                    max_value=float(hi),
                     step=var.get("step"),
                     unit=var.get("unit", ""),
                     description=var.get("description", ""),
                 ))
+            elif isinstance(var.get("values"), list) and len(var["values"]) >= 2:
+                vals = [float(x) for x in var["values"]]
+                parameters.append(ParameterRange(
+                    name=var["name"],
+                    min_value=min(vals),
+                    max_value=max(vals),
+                    step=None,
+                    unit=var.get("unit", ""),
+                    description=var.get("description", ""),
+                ))
 
         if not parameters:
             raise RuntimeError("No valid scan variables in plan")
@@ -218,8 +250,16 @@ class AdaptiveLoop:
             status = pr.get("status", "ok")
             if point_id is not None:
                 self.search.report_result(point_id, metrics, status)
-                # Add to all results
-                result_entry = {"point_id": point_id, **metrics}
+                # Add to all results, INCLUDING the point's input params:
+                # experience extraction needs inputs+outputs together to do
+                # parameter-sensitivity analysis (metrics-only entries left the
+                # AI with "no input parameters provided").
+                point_params = {}
+                for sp in self.search.state.points:
+                    if sp.id == point_id:
+                        point_params = dict(sp.params or {})
+                        break
+                result_entry = {"point_id": point_id, **point_params, **metrics}
                 self.all_results.append(result_entry)
 
         # Run AI analysis on accumulated results
@@ -280,9 +320,16 @@ class AdaptiveLoop:
             topology=self.plan.get("topology", "unknown"),
         )
 
+        # Persist into the experience library so later AI plan generation can
+        # reuse the knowledge. Previously the entry was only returned in the
+        # HTTP response and lost on restart - the loop's knowledge never
+        # actually closed into the library.
+        case_id = self._persist_experience_case(experience_entry)
+
         self.phase = LoopPhase.EXPERIENCE_UPDATED
         self._record_history("experience_updated", {
             "n_rules": len(experience_entry.get("design_rules", [])),
+            "case_id": case_id,
         })
 
         return {
@@ -290,8 +337,54 @@ class AdaptiveLoop:
             "phase": self.phase.value,
             "insights": self.latest_insights,
             "experience_entry": experience_entry,
+            "experience_case_id": case_id,
         }
 
+    def _persist_experience_case(self, entry: Dict[str, Any]) -> Optional[int]:
+        """Store an extracted experience entry as an ExperienceCase row.
+
+        Maps the AI-insight structure onto the case model: the best feasible
+        point carries params/metrics, the summary + design rules form the
+        conclusion. Returns the new case id, or None on failure (persistence
+        must never break the loop).
+        """
+        try:
+            from ..database import SessionLocal
+            from ..models.experience_case import ExperienceCase
+
+            best = None
+            if self.search:
+                best = self.search.state.best_feasible_point
+            rules = entry.get("design_rules") or []
+            rule_texts = [
+                (r.get("rule") if isinstance(r, dict) else str(r)) for r in rules
+            ]
+            conclusion = (entry.get("summary") or "") + "\n\n" + "\n".join(
+                f"- {t}" for t in rule_texts if t
+            )
+            tags = ",".join(entry.get("tags") or [])
+            with SessionLocal() as db:
+                case = ExperienceCase(
+                    source_plan_id=self.loop_id,
+                    topology=entry.get("topology", ""),
+                    model_path=(self.plan or {}).get("model_path", ""),
+                    params_json=json.dumps((best.params if best else {}) or {}, ensure_ascii=False),
+                    metrics_json=json.dumps((best.metrics if best else {}) or {}, ensure_ascii=False),
+                    boundary_json=json.dumps(
+                        (self.plan or {}).get("boundary_conditions", {}) or {},
+                        ensure_ascii=False,
+                    ),
+                    conclusion=conclusion.strip(),
+                    tags=tags,
+                    rating=0,
+                )
+                db.add(case)
+                db.commit()
+                db.refresh(case)
+                return case.id
+        except Exception:
+            return None
+
     def check_completion(self) -> Dict[str, Any]:
         """Check if loop should terminate.
 
@@ -313,6 +406,139 @@ class AdaptiveLoop:
 
         return {"completed": False, "reason": "continue", "state": state}
 
+    def submit_batch_to_executor(self, task_manager=None) -> Dict[str, Any]:
+        """Submit the current pending batch to the local executor.
+
+        Bridges the web-side adaptive loop to the local Motor-CAD executor
+        through the task system: the batch points (returned by the last
+        generate_initial_batch / get_next_batch call and held in the search
+        pending list) are wrapped into a single adaptive_batch task. The
+        local executor claims it, runs each point, and reports results back
+        through /report-results, which feeds the search and continues the loop.
+
+        Returns:
+            {"task_id", "n_points", "batch_id"}
+        """
+        if not self.search:
+            raise RuntimeError("Search not initialized. Call initialize_search() first.")
+
+        # Submit exactly the current batch (points marked dispatched by
+        # select_next_batch), not every pending point - otherwise the whole
+        # initial LHS pool would be submitted at once, defeating batching.
+        current = [p for p in self.search.state.points if p.status == "dispatched"]
+        if not current:
+            return {"task_id": None, "n_points": 0, "batch_id": None,
+                    "message": "no dispatched batch to submit (call next-batch first)"}
+
+        tm = task_manager or get_task_manager()
+        # Merge the plan's writable fixed params into every point: the executor
+        # runs the flat parameter dict as-is and does NOT apply plan_data
+        # fixed_params itself. Params with motorcad_var=None are not writable
+        # on this model and must be skipped (writing them fails the point).
+        fixed: Dict[str, Any] = {}
+        for fp in (self.plan or {}).get("fixed_params") or []:
+            if isinstance(fp, dict) and fp.get("motorcad_var"):
+                fixed[fp["motorcad_var"]] = fp.get("value")
+        point_ids = []
+        parameters = []
+        for p in current:
+            point_ids.append(p.id)
+            item = dict(fixed)
+            item.update(p.params or {})
+            item["point_id"] = p.id
+            parameters.append(item)
+
+        task = tm.create_task(
+            plan_id=None,
+            plan_data=self.plan or {},
+            parameters=parameters,
+            task_name="adaptive-%s-b%s" % (self.loop_id, self.search.state.current_batch),
+            task_type="adaptive_batch",
+            loop_id=self.loop_id,
+            batch_id=self.search.state.current_batch,
+            point_ids=point_ids,
+            dynamic=True,
+        )
+
+        self.phase = LoopPhase.SIMULATION_RUNNING
+        self._record_history("batch_submitted", {
+            "task_id": task.get("task_id"),
+            "batch_id": self.search.state.current_batch,
+            "n_points": len(point_ids),
+        })
+
+        return {
+            "task_id": task.get("task_id"),
+            "n_points": len(point_ids),
+            "batch_id": self.search.state.current_batch,
+            "message": "Batch submitted. Local executor will claim and run it.",
+        }
+
+
+    def export_state(self) -> Dict[str, Any]:
+        """Serialize the loop (plan + results + search) for checkpointing.
+
+        The search is exported through FeasibilityFirstSearch.export_state();
+        combined with restore_state() this is the checkpoint/resume path.
+        """
+        return {
+            "loop_id": self.loop_id,
+            "user_requirement": self.user_requirement,
+            "total_budget": self.total_budget,
+            "batch_size": self.batch_size,
+            "phase": self.phase.value,
+            "plan": self.plan,
+            "plan_validation": self.plan_validation,
+            "all_results": list(self.all_results),
+            "latest_analysis": self.latest_analysis,
+            "latest_insights": self.latest_insights,
+            "history": list(self.history),
+            "created_at": self.created_at,
+            "updated_at": self.updated_at,
+            "search": self.search.export_state() if self.search else None,
+        }
+
+    @classmethod
+    def restore_state(cls, payload: Dict[str, Any], l0_engine=None) -> "AdaptiveLoop":
+        """Rebuild a loop from export_state() output (checkpoint resume).
+
+        Args:
+            payload: dict returned by export_state().
+            l0_engine: optional pre-screening engine for the rebuilt search.
+
+        Returns:
+            A new AdaptiveLoop with plan/phase/results/history/search restored
+            and registered in the in-memory loop registry.
+        """
+        loop = cls(
+            loop_id=payload.get("loop_id"),
+            user_requirement=payload.get("user_requirement"),
+            total_budget=payload.get("total_budget", 80),
+            batch_size=payload.get("batch_size", 4),
+        )
+        try:
+            if payload.get("phase"):
+                loop.phase = LoopPhase(payload["phase"])
+        except ValueError:
+            pass  # unknown phase -> keep init
+        loop.plan = payload.get("plan")
+        loop.plan_validation = payload.get("plan_validation")
+        loop.all_results = list(payload.get("all_results", []))
+        loop.latest_analysis = payload.get("latest_analysis")
+        loop.latest_insights = payload.get("latest_insights")
+        loop.history = list(payload.get("history", []))
+        if payload.get("created_at"):
+            loop.created_at = payload["created_at"]
+        if payload.get("updated_at"):
+            loop.updated_at = payload["updated_at"]
+        if payload.get("search"):
+            loop.search = FeasibilityFirstSearch.import_state(
+                payload["search"], l0_engine=l0_engine or loop.l0_engine
+            )
+        _loops[loop.loop_id] = loop
+        return loop
+
+
     def _get_recommendation(self) -> str:
         """Generate next-step recommendation based on current state."""
         if not self.search:

+ 20 - 4
web/backend/app/services/ai_client.py

@@ -66,9 +66,12 @@ class KimiClient:
             log = AICallLog(
                 endpoint=endpoint,
                 model=model,
-                prompt_preview=json.dumps(messages[:3], ensure_ascii=False)[:500],
+                # Keep enough of the prompt to debug what the AI was actually
+                # asked (BC + experience cases live in the user message); 500
+                # chars truncated before the user message, hiding this.
+                prompt_preview=json.dumps(messages, ensure_ascii=False)[:8000],
                 response_preview=(
-                    json.dumps(response, ensure_ascii=False)[:1000]
+                    json.dumps(response, ensure_ascii=False)[:2000]
                     if response else None
                 ),
                 error=error,
@@ -118,8 +121,11 @@ class KimiClient:
             "messages": _messages,
             "temperature": temperature if temperature is not None else KIMI_TEMPERATURE,
         }
-        if max_tokens:
-            payload["max_tokens"] = max_tokens
+        # Default to the configured KIMI_MAX_TOKENS when the caller does not
+        # specify one. Reasoning models (k3) spend part of the budget on
+        # reasoning_content, so too small a cap (e.g. 2000) can starve the
+        # actual content and yield an empty response.
+        payload["max_tokens"] = max_tokens if max_tokens else KIMI_MAX_TOKENS
 
         url = f"{self.base_url}/chat/completions"
         start = time.time()
@@ -144,6 +150,16 @@ class KimiClient:
                     "finish_reason": choice.get("finish_reason"),
                 }
 
+                # Warn when the output budget was exhausted: reasoning models
+                # may return empty content with finish_reason=length. Callers
+                # should treat empty content as a failure signal.
+                if result["finish_reason"] == "length" and not result["content"]:
+                    logger.warning(
+                        "Kimi response truncated: finish_reason=length with empty "
+                        "content (max_tokens=%s). Consider raising KIMI_MAX_TOKENS.",
+                        payload.get("max_tokens"),
+                    )
+
                 self._log_call(
                     endpoint="/chat/completions",
                     model=_model,

+ 26 - 11
web/backend/app/services/analytics.py

@@ -13,25 +13,40 @@ All source is ASCII.
 from __future__ import annotations
 
 import math
+import os
+import sys
 from typing import Any
 
 
 # ---------------------------------------------------------------------------
 # Metric definitions (for display and analysis)
 # ---------------------------------------------------------------------------
+# Single source of truth: src/afmcore/metrics.py METRIC_DEFINITIONS. This
+# module must NOT keep its own copy (historical drift: an 11-metric
+# electromagnetic-only list silently dropped thermal + structural metrics).
+# Make src/afmcore importable, then derive the display dict below.
+
+_ANALYTICS_DIR = os.path.dirname(
+    os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+)
+# web/backend/app/services/analytics.py -> <repo>/src
+_SRC_DIR = os.path.normpath(os.path.join(_ANALYTICS_DIR, "..", "..", "src"))
+if _SRC_DIR not in sys.path and os.path.isdir(_SRC_DIR):
+    sys.path.insert(0, _SRC_DIR)
+
+from afmcore.metrics import METRIC_DEFINITIONS  # noqa: E402
+
+# direction "neutral" maps to True to preserve the historical pareto behavior
+# (neutral metrics are not optimization targets but were never excluded here).
+_DIRECTION_TO_BETTER = {"higher": True, "lower": False, "neutral": True}
 
 METRIC_DEFS = {
-    "tavg_nm": {"label": "Average Torque", "unit": "Nm", "higher_is_better": True},
-    "ripple_pct": {"label": "Torque Ripple", "unit": "%", "higher_is_better": False},
-    "efficiency_pct": {"label": "Efficiency", "unit": "%", "higher_is_better": True},
-    "total_losses_w": {"label": "Total Losses", "unit": "W", "higher_is_better": False},
-    "copper_loss_w": {"label": "Copper Loss", "unit": "W", "higher_is_better": False},
-    "iron_loss_w": {"label": "Iron Loss", "unit": "W", "higher_is_better": False},
-    "magnet_loss_w": {"label": "Magnet Loss", "unit": "W", "higher_is_better": False},
-    "back_emf_v": {"label": "Back EMF", "unit": "V", "higher_is_better": True},
-    "output_power_w": {"label": "Output Power", "unit": "W", "higher_is_better": True},
-    "input_power_w": {"label": "Input Power", "unit": "W", "higher_is_better": True},
-    "no_load_speed_rpm": {"label": "No-Load Speed", "unit": "rpm", "higher_is_better": True},
+    m["key"]: {
+        "label": m["label"],
+        "unit": m["unit"],
+        "higher_is_better": _DIRECTION_TO_BETTER.get(m.get("direction"), True),
+    }
+    for m in METRIC_DEFINITIONS
 }
 
 

+ 18 - 1
web/backend/app/services/batch_scheduler.py

@@ -9,6 +9,8 @@ from datetime import datetime
 from typing import Any, Callable, Dict, List, Optional
 from collections import deque
 
+from ..services.task_contract import merge_adaptive_fields
+
 
 class BatchScheduler:
     """Batch simulation task scheduler with priority queue."""
@@ -32,7 +34,12 @@ class BatchScheduler:
     def add_task(self, task_id: str, task_name: str, priority: int = 5,
                  parameters: Optional[List[Dict[str, Any]]] = None,
                  plan_data: Optional[Dict[str, Any]] = None,
-                 wait_for: Optional[List[str]] = None) -> Dict[str, Any]:
+                 wait_for: Optional[List[str]] = None,
+                 task_type: str = "scan",
+                 loop_id: Optional[str] = None,
+                 batch_id: Optional[int] = None,
+                 point_ids: Optional[List] = None,
+                 dynamic: bool = False) -> Dict[str, Any]:
         task_info = {
             "task_id": task_id, "task_name": task_name, "priority": priority,
             "status": "queued", "parameters": parameters or [],
@@ -42,6 +49,11 @@ class BatchScheduler:
             "current_point": 0, "total_points": len(parameters or []),
             "error": None,
         }
+        # P3-M4: adaptive-batch fields align scheduler tasks with Task ORM.
+        task_info = merge_adaptive_fields(
+            task_info, task_type=task_type, loop_id=loop_id,
+            batch_id=batch_id, point_ids=point_ids, dynamic=dynamic,
+        )
         with self._lock:
             inserted = False
             for i, existing in enumerate(self._queue):
@@ -153,6 +165,11 @@ class BatchScheduler:
             "progress": progress, "enqueued_at": task.get("enqueued_at"),
             "started_at": task.get("started_at"), "completed_at": task.get("completed_at"),
             "error": task.get("error"),
+            "task_type": task.get("task_type", "scan"),
+            "loop_id": task.get("loop_id"),
+            "batch_id": task.get("batch_id"),
+            "point_ids": task.get("point_ids") or [],
+            "dynamic": bool(task.get("dynamic", False)),
         }
 
     def register_callback(self, callback: Callable) -> None:

+ 151 - 0
web/backend/app/services/bc_fields.py

@@ -0,0 +1,151 @@
+"""Boundary-condition (BC) field catalog - single source of truth.
+
+The BC key naming had drifted across three places:
+  1. the project boundary form / rule_engine  -> current_a, speed_rpm, slots, ...
+  2. the fixed-param template (build_default_fixed_params)
+                                             -> rated_current_a, rated_speed_rpm, ...
+  3. the plan-detail display template          -> rated_* (a third set)
+
+This module is the single source for the canonical BC field keys, their
+Chinese labels, units, grouping, and the legacy/variant alias keys accepted on
+read. Canonical keys match the project boundary form and rule_engine (the
+data-entry and primary consumers). `aliases` keeps backward compatibility with
+the template-era rated_* names and other historical variants.
+
+All source is ASCII; Chinese labels are written as unicode escapes (project hard rule).
+"""
+from __future__ import annotations
+
+from typing import Any, Dict, List
+
+# Category labels (unicode-escaped).
+_CAT_ELEC = "\u7535\u6c14"
+_CAT_GEOM = "\u51e0\u4f55\u5c3a\u5bf8"
+_CAT_SLOT = "\u69fd\u6781"
+_CAT_MAT = "\u6750\u6599\u5c5e\u6027"
+_CAT_GOAL = "\u6027\u80fd\u76ee\u6807"
+_CAT_THERM = "\u70ed\u4e0e\u73af\u5883"
+
+# Canonical BC field catalog. Order defines display order in the UI.
+BC_FIELD_CATALOG: List[Dict[str, Any]] = [
+    # --- Electrical ---
+    {"key": "power_w", "label": "\u989d\u5b9a\u529f\u7387", "unit": "W", "category": _CAT_ELEC,
+     "aliases": ["rated_power_w"]},
+    {"key": "voltage_v", "label": "\u989d\u5b9a\u7535\u538b", "unit": "V", "category": _CAT_ELEC,
+     "aliases": ["dc_link_voltage_v"]},
+    {"key": "phase_count", "label": "\u76f8\u6570", "unit": "", "category": _CAT_ELEC,
+     "aliases": []},
+    {"key": "speed_rpm", "label": "\u989d\u5b9a\u8f6c\u901f", "unit": "rpm", "category": _CAT_ELEC,
+     "aliases": ["rated_speed_rpm"]},
+    {"key": "max_speed_rpm", "label": "\u6700\u9ad8\u8f6c\u901f", "unit": "rpm", "category": _CAT_ELEC,
+     "aliases": []},
+    {"key": "current_a", "label": "\u989d\u5b9a\u7535\u6d41", "unit": "A", "category": _CAT_ELEC,
+     "aliases": ["rated_current_a"]},
+    # --- Geometry ---
+    {"key": "outer_diameter_mm", "label": "\u5916\u5f84", "unit": "mm", "category": _CAT_GEOM,
+     "aliases": []},
+    {"key": "max_outer_diameter_mm", "label": "\u6700\u5927\u5916\u5f84", "unit": "mm", "category": _CAT_GEOM,
+     "aliases": []},
+    {"key": "inner_diameter_mm", "label": "\u5185\u5f84", "unit": "mm", "category": _CAT_GEOM,
+     "aliases": []},
+    {"key": "axial_length_mm", "label": "\u8f74\u5411\u957f\u5ea6", "unit": "mm", "category": _CAT_GEOM,
+     "aliases": []},
+    # --- Slots & poles ---
+    {"key": "slots", "label": "\u69fd\u6570", "unit": "", "category": _CAT_SLOT,
+     "aliases": ["slot_count"]},
+    {"key": "poles", "label": "\u6781\u6570", "unit": "", "category": _CAT_SLOT,
+     "aliases": []},
+    {"key": "pole_pairs", "label": "\u6781\u5bf9\u6570", "unit": "", "category": _CAT_SLOT,
+     "aliases": []},
+    # --- Material ---
+    {"key": "magnet_grade", "label": "\u78c1\u94a2\u724c\u53f7", "unit": "", "category": _CAT_MAT,
+     "aliases": []},
+    {"key": "magnet_temp_c", "label": "\u78c1\u94a2\u6e29\u5ea6", "unit": "\xb0C", "category": _CAT_MAT,
+     "aliases": []},
+    # --- Performance goals ---
+    {"key": "target_torque_nm", "label": "\u76ee\u6807\u8f6c\u77e9", "unit": "Nm", "category": _CAT_GOAL,
+     "aliases": ["rated_torque_nm"]},
+    {"key": "target_efficiency_pct", "label": "\u76ee\u6807\u6548\u7387", "unit": "%", "category": _CAT_GOAL,
+     "aliases": ["efficiency_min_pct"]},
+    {"key": "max_losses_w", "label": "\u6700\u5927\u635f\u8017", "unit": "W", "category": _CAT_GOAL,
+     "aliases": []},
+    {"key": "target_ripple_pct", "label": "\u76ee\u6807\u8109\u52a8", "unit": "%", "category": _CAT_GOAL,
+     "aliases": ["max_torque_ripple_pct"]},
+    {"key": "max_axial_force_n", "label": "\u6700\u5927\u8f74\u5411\u529b", "unit": "N", "category": _CAT_GOAL,
+     "aliases": []},
+    {"key": "weight_kg", "label": "\u76ee\u6807\u91cd\u91cf", "unit": "kg", "category": _CAT_GOAL,
+     "aliases": []},
+    # --- Thermal & environment ---
+    {"key": "cooling_type", "label": "\u51b7\u5374\u65b9\u5f0f", "unit": "", "category": _CAT_THERM,
+     "aliases": ["cooling_method"]},
+    {"key": "insulation_class", "label": "\u7edd\u7f18\u7b49\u7ea7", "unit": "", "category": _CAT_THERM,
+     "aliases": []},
+    {"key": "duty_cycle", "label": "\u5de5\u4f5c\u5236", "unit": "", "category": _CAT_THERM,
+     "aliases": []},
+    {"key": "ambient_temp_c", "label": "\u73af\u5883\u6e29\u5ea6", "unit": "\xb0C", "category": _CAT_THERM,
+     "aliases": []},
+]
+
+
+def get_bc_field_catalog() -> List[Dict[str, Any]]:
+    """Return the BC field catalog as a list of dicts (for API / UI rendering).
+
+    Each field is enriched with form-rendering metadata: `type` (number /
+    int / enum) and `options` for enum fields, so the frontend can render the
+    full catalog as a form without hard-coding any field list.
+    """
+    out = [dict(f) for f in BC_FIELD_CATALOG]
+    for f in out:
+        meta = _FIELD_FORM_META.get(f["key"], {"type": "number"})
+        f["type"] = meta["type"]
+        if meta.get("options"):
+            f["options"] = list(meta["options"])
+    return out
+
+
+# Form-rendering metadata keyed by canonical BC key. Enum options are
+# engineering-standard choices (IEC insulation classes / duty cycles, common
+# NdFeB grades; Cooling_Method verified against Motor-CAD via the MARS run).
+_FIELD_FORM_META: Dict[str, Dict[str, Any]] = {
+    "cooling_type": {"type": "enum", "options": ["Natural", "Forced Air", "Water", "Oil"]},
+    "insulation_class": {"type": "enum", "options": ["A", "E", "B", "F", "H"]},
+    "duty_cycle": {"type": "enum", "options": ["S1", "S2", "S3", "S6"]},
+    "magnet_grade": {"type": "enum", "options": ["N35", "N38", "N42", "N42SH", "N42UH", "N48", "N52"]},
+    "phase_count": {"type": "int"},
+    "slots": {"type": "int"},
+    "poles": {"type": "int"},
+    "pole_pairs": {"type": "int"},
+}
+
+
+def _alias_to_canonical() -> Dict[str, str]:
+    """Build alias -> canonical key lookup from the catalog."""
+    m: Dict[str, str] = {}
+    for f in BC_FIELD_CATALOG:
+        for a in f.get("aliases", []):
+            m[a] = f["key"]
+    return m
+
+
+def normalize_bc(data: Dict[str, Any]) -> Dict[str, Any]:
+    """Normalize a boundary-conditions dict to canonical keys.
+
+    Alias keys are mapped to their canonical key; a canonical key already
+    present always wins over its aliases. Unknown keys are passed through
+    unchanged so forward-compatible extras are preserved. Returns a new dict
+    (the input is not mutated).
+    """
+    if not data:
+        return {}
+    amap = _alias_to_canonical()
+    out: Dict[str, Any] = {}
+    for k, v in data.items():
+        canon = amap.get(k, k)
+        # Canonical already set (explicitly or by an earlier alias): keep the
+        # first non-empty value, preferring an explicit canonical entry.
+        if canon in out and out[canon] not in (None, ""):
+            if k == canon and v not in (None, ""):
+                out[canon] = v  # explicit canonical overrides alias-filled
+            continue
+        out[canon] = v
+    return out

+ 16 - 8
web/backend/app/services/experience_enhancer.py

@@ -8,7 +8,7 @@ from pathlib import Path
 from typing import Dict, List, Optional, Any
 from datetime import datetime
 
-from ..config import PROMPTS_DIR
+from ..config import PROMPTS_DIR, KIMI_MAX_TOKENS
 from ..services.ai_client import get_kimi_client
 
 
@@ -71,7 +71,7 @@ class ExperienceEnhancer:
             result = self.ai_client.chat_json(
                 messages=[{"role": "user", "content": user_message}],
                 system_prompt=self._load_prompt(),
-                max_tokens=3000,
+                max_tokens=KIMI_MAX_TOKENS,
             )
             insights = result.get("parsed_json", {})
             if not insights:
@@ -92,15 +92,23 @@ class ExperienceEnhancer:
 
     def _condense_results(self, results: List[Dict[str, Any]]) -> Dict[str, Any]:
         """Condense results for AI processing (avoid token overflow)."""
-        # Extract key metrics
+        # Extract key metrics. Input params may arrive under Motor-CAD names
+        # (Airgap, RMSCurrent); translate them to the BC-style keys below via
+        # the shared single-source map so sensitivity analysis sees inputs.
+        from src.afmcore.l0.prescreening import MOTORCAD_TO_L0
+        keys = ["tavg_nm", "efficiency_pct", "total_losses_w", "winding_temp_c",
+                "airgap_mm", "magnet_thickness_mm", "current_a", "speed_rpm",
+                "outer_diameter_mm", "inner_diameter_mm", "feasible"]
         metrics = []
         for r in results:
+            r_norm = dict(r)
+            for mc_name, l0_name in MOTORCAD_TO_L0.items():
+                if mc_name in r_norm and l0_name not in r_norm:
+                    r_norm[l0_name] = r_norm[mc_name]
             m = {}
-            for key in ["tavg_nm", "efficiency_pct", "total_losses_w", "winding_temp_c",
-                        "airgap_mm", "magnet_thickness_mm", "current_a", "speed_rpm",
-                        "outer_diameter_mm", "inner_diameter_mm", "feasible"]:
-                if key in r:
-                    m[key] = r[key]
+            for key in keys:
+                if key in r_norm:
+                    m[key] = r_norm[key]
             if m:
                 metrics.append(m)
 

+ 182 - 2
web/backend/app/services/feasibility_search.py

@@ -295,6 +295,18 @@ class FeasibilityFirstSearch:
             self.state.convergence_status = "budget_exhausted"
             return []
 
+        # Consume the pending initial LHS batch first: points generated by
+        # generate_initial_batch must be simulated before active learning has
+        # any observations to learn from. Mark them dispatched so they are not
+        # re-selected and so submit_batch targets exactly this batch.
+        pending = self.state.get_pending_points()
+        if pending:
+            batch = pending[: self.batch_size]
+            for p in batch:
+                p.status = "dispatched"
+            self.state.updated_at = datetime.now().isoformat()
+            return batch
+
         feasible_points = self.state.get_feasible_points()
         completed_params = [p.params for p in self.state.get_completed_points()]
 
@@ -385,7 +397,7 @@ class FeasibilityFirstSearch:
             point = SearchPoint(
                 id=len(self.state.points) + i,
                 params=params,
-                status="pending",
+                status="dispatched",
                 feasibility_report=report.to_dict(),
                 batch_id=self.state.current_batch,
                 created_at=datetime.now().isoformat(),
@@ -476,6 +488,90 @@ class FeasibilityFirstSearch:
             "trust_region_radius": round(self.state.trust_region_radius, 4),
             "best_objective_value": self.state.best_objective_value if self.state.best_objective_value != float('inf') else None,
             "best_point_params": self.state.best_feasible_point.params if self.state.best_feasible_point else None,
+            "points_history": [
+                {
+                    "id": _p.id,
+                    "batch_id": _p.batch_id,
+                    "params": _p.params,
+                    "objective": _p.metrics.get(self.state.objective_metric),
+                    "feasible": _p.status == "ok",
+                    "status": _p.status,
+                }
+                for _p in self.state.points
+                if _p.status in ("ok", "failed", "infeasible")
+                and _p.metrics.get(self.state.objective_metric) is not None
+            ],
+            "infeasible_points": self._count_by_status("infeasible"),
+            "failed_points": self._count_by_status("failed"),
+            "batch_summary": self._build_batch_summary(),
+            "l0_summary": self._build_l0_summary(),
+        }
+
+    def _count_by_status(self, status: str) -> int:
+        """Count points with the given status."""
+        return sum(1 for _p in self.state.points if _p.status == status)
+
+    def _build_batch_summary(self) -> list:
+        """Aggregate per-batch point status distribution.
+
+        Returns a list sorted by batch_id, each entry containing
+        batch_id, total, pending/ok/infeasible/failed counts and
+        best_objective (respecting objective_direction).
+        """
+        batches = {}
+        direction = self.state.objective_direction
+        metric = self.state.objective_metric
+        for _p in self.state.points:
+            b = batches.setdefault(_p.batch_id, {
+                "batch_id": _p.batch_id, "total": 0,
+                "pending": 0, "dispatched": 0, "ok": 0, "infeasible": 0, "failed": 0,
+                "best_objective": None,
+            })
+            b["total"] += 1
+            if _p.status in ("pending", "dispatched", "ok", "infeasible", "failed"):
+                b[_p.status] += 1
+            if _p.status == "ok":
+                obj = _p.metrics.get(metric)
+                if obj is not None:
+                    if b["best_objective"] is None:
+                        b["best_objective"] = obj
+                    elif direction == "maximize":
+                        b["best_objective"] = max(b["best_objective"], obj)
+                    else:
+                        b["best_objective"] = min(b["best_objective"], obj)
+        return sorted(batches.values(), key=lambda x: x["batch_id"])
+
+    def _build_l0_summary(self) -> dict:
+        """Aggregate L0 pre-screening statistics across all points.
+
+        Returns sampled/feasible/infeasible counts, pass_rate and
+        top_infeasible_reasons (name/count/category) from failed
+        constraint checks in infeasible points.
+        """
+        points = self.state.points
+        total = len(points)
+        infeasible_pts = [p for p in points if p.status == "infeasible"]
+        feasible_count = total - len(infeasible_pts)
+        reason_counts = {}
+        for p in infeasible_pts:
+            report = p.feasibility_report or {}
+            for r in report.get("results", []):
+                if not r.get("passed"):
+                    name = r.get("name", "unknown")
+                    entry = reason_counts.setdefault(name, {
+                        "name": name, "count": 0,
+                        "category": r.get("category", ""),
+                    })
+                    entry["count"] += 1
+        top_reasons = sorted(
+            reason_counts.values(), key=lambda x: -x["count"]
+        )[:5]
+        return {
+            "sampled": total,
+            "feasible": feasible_count,
+            "infeasible": len(infeasible_pts),
+            "pass_rate": round(feasible_count / total, 4) if total else 0.0,
+            "top_infeasible_reasons": top_reasons,
         }
 
     def export_state(self) -> Dict[str, Any]:
@@ -490,7 +586,17 @@ class FeasibilityFirstSearch:
                 "trust_region_active": self.state.trust_region_active,
                 "trust_region_center": self.state.trust_region_center,
                 "trust_region_radius": self.state.trust_region_radius,
-                "best_objective_value": self.state.best_objective_value,
+                # inf (initial sentinel before any result) is not JSON-compliant;
+                # export as None, matching get_state_summary().
+                "best_objective_value": (
+                    self.state.best_objective_value
+                    if self.state.best_objective_value not in (float("inf"), float("-inf"))
+                    else None
+                ),
+                "batch_size": self.state.batch_size,
+                "objective_metric": self.state.objective_metric,
+                "objective_direction": self.state.objective_direction,
+                "search_method": self.state.search_method,
             },
             "parameters": [
                 {"name": p.name, "min": p.min_value, "max": p.max_value, "step": p.step, "unit": p.unit}
@@ -508,3 +614,77 @@ class FeasibilityFirstSearch:
                 for p in self.state.points
             ],
         }
+
+    @classmethod
+    def import_state(cls, payload: Dict[str, Any], l0_engine=None) -> "FeasibilityFirstSearch":
+        """Rebuild a search from export_state() output (checkpoint resume).
+
+        Args:
+            payload: dict returned by export_state().
+            l0_engine: optional pre-screening engine (reused if omitted).
+
+        Returns:
+            A new FeasibilityFirstSearch with the same parameters and
+            replayed points (status/metrics/batch preserved).
+        """
+        params_data = payload.get("parameters", [])
+        parameters = [
+            ParameterRange(
+                name=p["name"],
+                min_value=p["min"],
+                max_value=p["max"],
+                step=p.get("step"),
+                unit=p.get("unit", ""),
+                description=p.get("description", ""),
+            )
+            for p in params_data
+        ]
+        st = payload.get("state", {})
+        search = cls(
+            parameters=parameters,
+            l0_engine=l0_engine,
+            total_budget=st.get("total_budget", 80),
+            batch_size=st.get("batch_size", 4),
+            objective_metric=st.get("objective_metric", "tavg_nm"),
+            objective_direction=st.get("objective_direction", "maximize"),
+        )
+        s = search.state
+        if st.get("run_id"):
+            s.run_id = st["run_id"]
+        if "current_batch" in st:
+            s.current_batch = int(st["current_batch"])
+        if "used_budget" in st:
+            s.used_budget = int(st["used_budget"])
+        if st.get("convergence_status"):
+            s.convergence_status = st["convergence_status"]
+        if "trust_region_active" in st:
+            s.trust_region_active = bool(st["trust_region_active"])
+        if st.get("trust_region_center") is not None:
+            s.trust_region_center = st["trust_region_center"]
+        if "trust_region_radius" in st:
+            s.trust_region_radius = float(st["trust_region_radius"])
+        # export_state serializes the inf sentinel as None (JSON compliance);
+        # restore it to the dataclass default rather than assigning None
+        # (None would break the `value > best_objective_value` comparison).
+        if "best_objective_value" in st and st["best_objective_value"] is not None:
+            s.best_objective_value = st["best_objective_value"]
+        if st.get("search_method"):
+            s.search_method = st["search_method"]
+        for pd in payload.get("points", []):
+            pt = SearchPoint(
+                id=int(pd["id"]),
+                params=dict(pd.get("params") or {}),
+                status=pd.get("status", "pending"),
+                metrics=dict(pd.get("metrics") or {}),
+                batch_id=int(pd.get("batch_id", 0)),
+            )
+            feas = pd.get("feasible")
+            if feas is not None:
+                pt.feasibility_report = {"feasible": bool(feas)}
+            s.points.append(pt)
+        if s.best_feasible_point is None:
+            for pt in s.points:
+                if pt.status == "ok" and (pt.feasibility_report or {}).get("feasible", True):
+                    s.best_feasible_point = pt
+                    break
+        return search

+ 544 - 0
web/backend/app/services/fixed_params_template.py

@@ -0,0 +1,544 @@
+"""MotorCAD fixed parameter templates with Chinese names, categories and descriptions.
+
+Used to populate AI-generated simulation plans with comprehensive fixed parameters.
+Parameters that are also scan variables are automatically excluded.
+
+This file is the single source of truth for the fixed-parameter template.
+AI plan generation populates ALL parameters here (template-driven), so the
+frontend can reliably render a complete, balanced fixed-parameter table.
+"""
+from typing import Dict, Any, List
+
+from .bc_fields import normalize_bc
+
+# Category Chinese names (8 motor-engineering categories)
+CATEGORY_CN = {
+    "Geometry": "\u51e0\u4f55\u5c3a\u5bf8",
+    "Stator": "\u5b9a\u5b50\u53c2\u6570",
+    "Rotor": "\u8f6c\u5b50\u53c2\u6570",
+    "Performance": "\u6027\u80fd\u89c4\u683c",
+    "Winding": "\u7ed5\u7ec4\u53c2\u6570",
+    "Material": "\u6750\u6599\u5c5e\u6027",
+    "Thermal": "\u70ed\u4e0e\u51b7\u5374",
+    "Simulation": "\u4eff\u771f\u8bbe\u7f6e",
+    "System": "\u4eff\u771f\u8bbe\u7f6e",
+    "Mechanical": "\u5176\u4ed6",
+    "Electrical": "\u6027\u80fd\u89c4\u683c",
+    "General": "\u5176\u4ed6",
+}
+
+
+# Fixed parameter templates: name -> {display_name, name_cn, unit, value, category, category_cn, description, description_cn}
+# Note: params marked "\u9700\u786e\u8ba4" in description_cn may need Motor-CAD variable name verification.
+FIXED_PARAM_TEMPLATES = [
+    # ===== Geometry \u51e0\u4f55\u5c3a\u5bf8 =====
+    {
+        "name": "Outer_Rotor_Diameter",
+        "motorcad_var": "RotorOuterDiameter",
+        "display_name": "Outer Rotor Diameter",
+        "name_cn": "\u8f6c\u5b50\u5916\u5f84",
+        "unit": "mm",
+        "value": 130.0,
+        "category": "Geometry",
+        "description": "Outer diameter of rotor back iron",
+        "description_cn": "\u8f6c\u5b50\u80cc\u94c1\u5916\u5f84",
+    },
+    {
+        "name": "Inner_Rotor_Diameter",
+        "motorcad_var": None,
+        "display_name": "Inner Rotor Diameter",
+        "name_cn": "\u8f6c\u5b50\u5185\u5f84",
+        "unit": "mm",
+        "value": 120.0,
+        "category": "Geometry",
+        "description": "Inner diameter of rotor back iron",
+        "description_cn": "\u8f6c\u5b50\u80cc\u94c1\u5185\u5f84",
+    },
+    {
+        "name": "Stator_Outer_Diameter",
+        "motorcad_var": "Stator_Lam_Dia",
+        "display_name": "Stator Outer Diameter",
+        "name_cn": "\u5b9a\u5b50\u5916\u5f84",
+        "unit": "mm",
+        "value": 76.0,
+        "category": "Geometry",
+        "description": "Outer diameter of stator lamination",
+        "description_cn": "\u5b9a\u5b50\u51b2\u7247\u5916\u5f84",
+    },
+    {
+        "name": "Stator_Inner_Diameter",
+        "motorcad_var": "Stator_Bore",
+        "display_name": "Stator Inner Diameter",
+        "name_cn": "\u5b9a\u5b50\u5185\u5f84",
+        "unit": "mm",
+        "value": 50.0,
+        "category": "Geometry",
+        "description": "Inner diameter of stator lamination",
+        "description_cn": "\u5b9a\u5b50\u51b2\u7247\u5185\u5f84",
+    },
+    {
+        "name": "Airgap",
+        "motorcad_var": "Airgap",
+        "display_name": "Airgap Length",
+        "name_cn": "\u6c14\u9699\u957f\u5ea6",
+        "unit": "mm",
+        "value": 1.0,
+        "category": "Geometry",
+        "description": "Mechanical airgap between stator and rotor",
+        "description_cn": "\u5b9a\u8f6c\u5b50\u4e4b\u95f4\u6c14\u9699",
+    },
+    # ===== Stator \u5b9a\u5b50\u53c2\u6570 =====
+    {
+        "name": "Stator_Yoke_Thickness",
+        "motorcad_var": None,
+        "display_name": "Stator Yoke Thickness",
+        "name_cn": "\u5b9a\u5b50\u8f6f\u539a",
+        "unit": "mm",
+        "value": 10.0,
+        "category": "Stator",
+        "description": "Radial thickness of stator yoke",
+        "description_cn": "\u5b9a\u5b50\u8f6f\u90e8\u5f84\u5411\u539a\u5ea6",
+    },
+    {
+        "name": "Slot_Depth",
+        "motorcad_var": "Slot_Depth",
+        "display_name": "Slot Depth",
+        "name_cn": "\u69fd\u6df1",
+        "unit": "mm",
+        "value": 7.0,
+        "category": "Stator",
+        "description": "Depth of stator slot",
+        "description_cn": "\u5b9a\u5b50\u69fd\u6df1\u5ea6",
+    },
+    {
+        "name": "Slot_Width",
+        "motorcad_var": "Slot_Width",
+        "display_name": "Slot Width",
+        "name_cn": "\u69fd\u5bbd",
+        "unit": "mm",
+        "value": 8.5,
+        "category": "Stator",
+        "description": "Width of stator slot opening",
+        "description_cn": "\u5b9a\u5b50\u69fd\u53e3\u5bbd\u5ea6",
+    },
+    {
+        "name": "Tooth_Width",
+        "motorcad_var": "Tooth_Width",
+        "display_name": "Tooth Width",
+        "name_cn": "\u9f7f\u5bbd",
+        "unit": "mm",
+        "value": 7.0,
+        "category": "Stator",
+        "description": "Width of stator tooth",
+        "description_cn": "\u5b9a\u5b50\u9f7f\u5bbd\u5ea6",
+    },
+    {
+        "name": "Number_of_Slots",
+        "motorcad_var": "Slot_Number",
+        "display_name": "Number of Slots",
+        "name_cn": "\u69fd\u6570",
+        "unit": "",
+        "value": 12,
+        "category": "Stator",
+        "description": "Total number of stator slots",
+        "description_cn": "\u5b9a\u5b50\u603b\u69fd\u6570",
+    },
+    # ===== Rotor \u8f6c\u5b50\u53c2\u6570 =====
+    {
+        "name": "Rotor_Back_Iron_Thickness",
+        "motorcad_var": "Back_Iron_Thickness",
+        "display_name": "Rotor Back Iron Thickness",
+        "name_cn": "\u8f6c\u5b50\u80cc\u94c1\u539a",
+        "unit": "mm",
+        "value": 5.0,
+        "category": "Rotor",
+        "description": "Radial thickness of rotor back iron",
+        "description_cn": "\u8f6c\u5b50\u80cc\u94c1\u5f84\u5411\u539a\u5ea6",
+    },
+    {
+        "name": "Magnet_Length",
+        "motorcad_var": "Magnet_Length",
+        "display_name": "Magnet Axial Thickness",
+        "name_cn": "\u78c1\u94a2\u8f74\u5411\u957f\u5ea6",
+        "unit": "mm",
+        "value": 3.0,
+        "category": "Rotor",
+        "description": "Axial thickness of permanent magnet",
+        "description_cn": "\u6c38\u78c1\u4f53\u8f74\u5411\u539a\u5ea6",
+    },
+    {
+        "name": "Magnet_Thickness",
+        "motorcad_var": "Magnet_Thickness",
+        "display_name": "Magnet Radial Depth",
+        "name_cn": "\u78c1\u94a2\u5f84\u5411\u539a\u5ea6",
+        "unit": "mm",
+        "value": 13.0,
+        "category": "Rotor",
+        "description": "Magnet ring radial depth",
+        "description_cn": "\u78c1\u94a2\u73af\u5f84\u5411\u539a\u5ea6",
+    },
+    {
+        "name": "Magnet_Arc_[ED]",
+        "motorcad_var": "Magnet_Arc_[ED]",
+        "display_name": "Magnet Pole Arc",
+        "name_cn": "\u78c1\u94a2\u6781\u5f27\u89d2",
+        "unit": "deg",
+        "value": 121.0,
+        "category": "Rotor",
+        "description": "Magnet pole arc in electrical degrees",
+        "description_cn": "\u78c1\u94a2\u6781\u5f27\u89d2\u5ea6(\u7535\u89d2\u5ea6)",
+    },
+    {
+        "name": "Number_of_Poles",
+        "motorcad_var": "Pole_Number",
+        "display_name": "Number of Poles",
+        "name_cn": "\u6781\u6570",
+        "unit": "",
+        "value": 10,
+        "category": "Rotor",
+        "description": "Total number of magnetic poles",
+        "description_cn": "\u7535\u673a\u603b\u6781\u6570",
+    },
+    # ===== Performance \u6027\u80fd\u89c4\u683c =====
+    {
+        "name": "Current_Advance_Angle",
+        "motorcad_var": "PhaseAdvance",
+        "display_name": "Current Advance Angle",
+        "name_cn": "\u7535\u6d41\u8d85\u524d\u89d2",
+        "unit": "deg",
+        "value": 0.0,
+        "category": "Performance",
+        "description": "Current advance angle for field weakening",
+        "description_cn": "\u5f31\u78c1\u63a7\u5236\u7535\u6d41\u8d85\u524d\u89d2",
+    },
+    {
+        "name": "DC_Link_Voltage",
+        "motorcad_var": "DCBusVoltage",
+        "display_name": "DC Link Voltage",
+        "name_cn": "\u6bcd\u7ebf\u7535\u538b",
+        "unit": "V",
+        "value": 13.5,
+        "category": "Performance",
+        "description": "DC bus voltage",
+        "description_cn": "\u76f4\u6d41\u6bcd\u7ebf\u7535\u538b",
+    },
+    {
+        "name": "RMSCurrent",
+        "motorcad_var": "RMSCurrent",
+        "display_name": "RMS Phase Current",
+        "name_cn": "\u989d\u5b9a\u7535\u6d41",
+        "unit": "A",
+        "value": 21.0,
+        "category": "Performance",
+        "description": "RMS phase current (CurrentDefinition=1)",
+        "description_cn": "\u989d\u5b9a\u76f8\u7535\u6d41(RMS)",
+    },
+    {
+        "name": "Shaft_Speed",
+        "motorcad_var": "Shaft_Speed",
+        "display_name": "Shaft Speed",
+        "name_cn": "\u989d\u5b9a\u8f6c\u901f",
+        "unit": "rpm",
+        "value": 5000.0,
+        "category": "Performance",
+        "description": "Rotational shaft speed",
+        "description_cn": "\u7535\u673a\u989d\u5b9a\u8f6c\u901f",
+    },
+    {
+        "name": "Max_Speed",
+        "motorcad_var": "WindageGraph_MaxSpeed",
+        "display_name": "Max Speed",
+        "name_cn": "\u6700\u9ad8\u8f6c\u901f",
+        "unit": "rpm",
+        "value": 7500.0,
+        "category": "Performance",
+        "description": "Maximum rotational speed (variable name may need Motor-CAD verification)",
+        "description_cn": "\u7535\u673a\u6700\u9ad8\u8f6c\u901f\uff08\u53d8\u91cf\u540d\u9700\u786e\u8ba4\uff09",
+    },
+    # ===== Winding \u7ed5\u7ec4\u53c2\u6570 =====
+    {
+        "name": "Turns_per_Coil",
+        "motorcad_var": "ConductorsPerSlot",
+        "display_name": "Turns per Coil",
+        "name_cn": "\u6bcf\u7ebf\u5708\u53a9\u6570",
+        "unit": "",
+        "value": 20,
+        "category": "Winding",
+        "description": "Number of turns per coil",
+        "description_cn": "\u6bcf\u4e2a\u7ebf\u5708\u7684\u53a9\u6570",
+    },
+    {
+        "name": "Parallel_Paths",
+        "motorcad_var": "ParallelPaths",
+        "display_name": "Parallel Paths",
+        "name_cn": "\u5e76\u8054\u652f\u8def\u6570",
+        "unit": "",
+        "value": 1,
+        "category": "Winding",
+        "description": "Number of parallel winding paths",
+        "description_cn": "\u7ed5\u7ec4\u5e76\u8054\u652f\u8def\u6570",
+    },
+    {
+        "name": "Copper_Fill_Factor",
+        "motorcad_var": "Slot_Fill",
+        "display_name": "Copper Fill Factor",
+        "name_cn": "\u69fd\u6ee1\u7387",
+        "unit": "",
+        "value": 0.45,
+        "category": "Winding",
+        "description": "Copper area to slot area ratio",
+        "description_cn": "\u94dc\u7ebf\u9762\u79ef\u4e0e\u69fd\u9762\u79ef\u4e4b\u6bd4",
+    },
+    {
+        "name": "Wire_Diameter",
+        "motorcad_var": "Wire_Diameter",
+        "display_name": "Wire Diameter",
+        "name_cn": "\u7ebf\u5f84",
+        "unit": "mm",
+        "value": 1.63,
+        "category": "Winding",
+        "description": "Diameter of magnet wire",
+        "description_cn": "\u6f06\u5305\u7ebf\u76f4\u5f84",
+    },
+    {
+        "name": "Winding_Connection",
+        "motorcad_var": "WindingConnection",
+        "display_name": "Winding Connection",
+        "name_cn": "\u7ed5\u7ec4\u8fde\u63a5\u65b9\u5f0f",
+        "unit": "",
+        "value": "Star",
+        "category": "Winding",
+        "description": "Winding connection (Star/Delta) - variable name may need verification",
+        "description_cn": "\u7ed5\u7ec4\u8fde\u63a5\u65b9\u5f0f(\u661f/\u4e09\u89d2)\uff08\u53d8\u91cf\u540d\u9700\u786e\u8ba4\uff09",
+    },
+    {
+        "name": "Current_Density",
+        "motorcad_var": None,
+        "display_name": "Current Density",
+        "name_cn": "\u7535\u6d41\u5bc6\u5ea6",
+        "unit": "A/mm2",
+        "value": 6.0,
+        "category": "Winding",
+        "description": "Winding current density - variable name may need verification",
+        "description_cn": "\u7ed5\u7ec4\u7535\u6d41\u5bc6\u5ea6\uff08\u53d8\u91cf\u540d\u9700\u786e\u8ba4\uff09",
+    },
+    # ===== Material \u6750\u6599\u5c5e\u6027 =====
+    {
+        "name": "Magnet_Material",
+        "motorcad_var": "Material_Magnet",
+        "display_name": "Magnet Material",
+        "name_cn": "\u78c1\u94a2\u6750\u6599",
+        "unit": "",
+        "value": "N42UH",
+        "category": "Material",
+        "description": "Permanent magnet material grade",
+        "description_cn": "\u6c38\u78c1\u4f53\u6750\u6599\u724c\u53f7",
+    },
+    {
+        "name": "Steel_Grade",
+        "motorcad_var": None,
+        "display_name": "Steel Grade",
+        "name_cn": "\u7845\u94a2\u7247\u724c\u53f7",
+        "unit": "",
+        "value": "M19_24G",
+        "category": "Material",
+        "description": "Electrical steel lamination grade",
+        "description_cn": "\u7535\u5de5\u7845\u94a2\u7247\u724c\u53f7",
+    },
+    {
+        "name": "Magnet_Temperature",
+        "motorcad_var": "InitialMagnetTemperature",
+        "display_name": "Magnet Temperature",
+        "name_cn": "\u78c1\u94a2\u6e29\u5ea6",
+        "unit": "C",
+        "value": 40.0,
+        "category": "Material",
+        "description": "Operating temperature of magnets",
+        "description_cn": "\u78c1\u94a2\u5de5\u4f5c\u6e29\u5ea6",
+    },
+    {
+        "name": "Magnet_Remanence",
+        "motorcad_var": "Magnet_Br_at_RefTemp",
+        "display_name": "Magnet Remanence",
+        "name_cn": "\u5269\u78c1",
+        "unit": "T",
+        "value": 1.31,
+        "category": "Material",
+        "description": "Permanent magnet remanence flux density - variable name may need verification",
+        "description_cn": "\u6c38\u78c1\u4f53\u5269\u78c1\u5bc6\u5ea6\uff08\u53d8\u91cf\u540d\u9700\u786e\u8ba4\uff09",
+    },
+    # ===== Thermal \u70ed\u4e0e\u51b7\u5374 =====
+    {
+        "name": "Ambient_Temperature",
+        "motorcad_var": "Ambient_Temperature",
+        "display_name": "Ambient Temperature",
+        "name_cn": "\u73af\u5883\u6e29\u5ea6",
+        "unit": "C",
+        "value": 40.0,
+        "category": "Thermal",
+        "description": "Ambient air temperature",
+        "description_cn": "\u73af\u5883\u7a7a\u6c14\u6e29\u5ea6",
+    },
+    {
+        "name": "Cooling_Method",
+        "motorcad_var": "Cooling_Type",
+        "display_name": "Cooling Method",
+        "name_cn": "\u51b7\u5374\u65b9\u5f0f",
+        "unit": "",
+        "value": "Natural",
+        "category": "Thermal",
+        "description": "Cooling method (Natural/ForcedAir/WaterJacket)",
+        "description_cn": "\u51b7\u5374\u65b9\u5f0f(\u81ea\u7136/\u5f3a\u8feb\u98ce\u51b7/\u6c34\u51b7)",
+    },
+    {
+        "name": "Insulation_Class",
+        "motorcad_var": None,
+        "display_name": "Insulation Class",
+        "name_cn": "\u7edd\u7f18\u7b49\u7ea7",
+        "unit": "",
+        "value": "F",
+        "category": "Thermal",
+        "description": "Insulation class (A/E/B/F/H) - variable name may need verification",
+        "description_cn": "\u7edd\u7f18\u7b49\u7ea7(A/E/B/F/H)\uff08\u53d8\u91cf\u540d\u9700\u786e\u8ba4\uff09",
+    },
+    # ===== Simulation \u4eff\u771f\u8bbe\u7f6e =====
+    {
+        "name": "TorquePointsPerCycle",
+        "motorcad_var": "TorquePointsPerCycle",
+        "display_name": "Torque Points Per Cycle",
+        "name_cn": "\u6bcf\u5468\u671f\u8f6c\u77e9\u91c7\u6837\u70b9",
+        "unit": "",
+        "value": 30,
+        "category": "Simulation",
+        "description": "Number of torque calculation points per electrical cycle",
+        "description_cn": "\u6bcf\u7535\u5468\u671f\u8f6c\u77e9\u8ba1\u7b97\u91c7\u6837\u70b9\u6570",
+    },
+    {
+        "name": "CurrentDefinition",
+        "motorcad_var": "CurrentDefinition",
+        "display_name": "Current Definition",
+        "name_cn": "\u7535\u6d41\u5b9a\u4e49\u65b9\u5f0f",
+        "unit": "",
+        "value": 1.0,
+        "category": "Simulation",
+        "description": "1=RMS, 2=Peak current definition",
+        "description_cn": "1=\u6709\u6548\u503c, 2=\u5cf0\u503c\u7535\u6d41\u5b9a\u4e49",
+    },
+    {
+        "name": "AirgapMeshPoints_mesh",
+        "motorcad_var": "AirgapMeshPoints_mesh",
+        "display_name": "Airgap Mesh Points",
+        "name_cn": "\u6c14\u9699\u7f51\u683c\u70b9\u6570",
+        "unit": "",
+        "value": 600.0,
+        "category": "Simulation",
+        "description": "Number of airgap mesh points (FE mesh refinement)",
+        "description_cn": "\u6c14\u9699\u7f51\u683c\u70b9\u6570(\u663e\u5f0f\u6c42\u89e3\u7f51\u683c\u52a0\u5bc6)",
+    },
+    {
+        "name": "MessageDisplayState",
+        "motorcad_var": "MessageDisplayState",
+        "display_name": "Message Display",
+        "name_cn": "\u6d88\u606f\u663e\u793a",
+        "unit": "",
+        "value": 2.0,
+        "category": "Simulation",
+        "description": "Suppress popup dialogs during scripting",
+        "description_cn": "\u811a\u672c\u8fd0\u884c\u65f6\u6291\u5236\u5f39\u7a97",
+    },
+]
+
+
+def get_fixed_param_template(name: str) -> Dict[str, Any]:
+    """Get a fixed parameter template by name (case-insensitive)."""
+    lower = name.lower()
+    for p in FIXED_PARAM_TEMPLATES:
+        if p["name"].lower() == lower:
+            return dict(p)
+    return {}
+
+
+def build_default_fixed_params(
+    scan_variable_names: List[str],
+    boundary_conditions: Dict[str, Any] = None,
+) -> List[Dict[str, Any]]:
+    """Build default fixed parameter list, excluding variables that are being scanned.
+
+    Args:
+        scan_variable_names: List of variable names used as scan variables.
+        boundary_conditions: Project boundary conditions for value inference.
+
+    Returns:
+        List of fixed parameter dicts.
+    """
+    # Normalize the incoming boundary conditions through the single-source BC
+    # catalog (bc_fields.normalize_bc): legacy aliases (rated_*, slot_count,
+    # dc_link_voltage_v, cooling_method, ...) are mapped to the canonical keys
+    # (current_a, speed_rpm, slots, voltage_v, cooling_type, ...) read below.
+    # This replaces the previous local alias bridge and keeps all BC-key
+    # handling in one place (P1-1).
+    bc = normalize_bc(boundary_conditions or {})
+    scan_names = {n.lower() for n in scan_variable_names}
+
+    params = []
+    for tmpl in FIXED_PARAM_TEMPLATES:
+        # Skip if this parameter is already a scan variable
+        if tmpl["name"].lower() in scan_names:
+            continue
+
+        p = dict(tmpl)
+        p["category_cn"] = CATEGORY_CN.get(p["category"], p["category"])
+
+        # Infer values from boundary conditions
+        if tmpl["name"] == "Outer_Rotor_Diameter" and bc.get("outer_diameter_mm"):
+            p["value"] = float(bc["outer_diameter_mm"])
+        elif tmpl["name"] == "Stator_Outer_Diameter" and bc.get("outer_diameter_mm"):
+            p["value"] = float(bc["outer_diameter_mm"]) - 2.0
+        elif tmpl["name"] == "Inner_Rotor_Diameter" and bc.get("inner_diameter_mm"):
+            p["value"] = float(bc["inner_diameter_mm"])
+        elif tmpl["name"] == "Stator_Inner_Diameter" and bc.get("inner_diameter_mm"):
+            p["value"] = float(bc["inner_diameter_mm"]) + 2.0
+        elif tmpl["name"] == "Airgap" and bc.get("airgap_mm"):
+            p["value"] = float(bc["airgap_mm"])
+        elif tmpl["name"] == "Number_of_Poles" and bc.get("pole_pairs"):
+            p["value"] = int(bc["pole_pairs"]) * 2
+        elif tmpl["name"] == "Number_of_Slots" and bc.get("slots"):
+            p["value"] = int(bc["slots"])
+        elif tmpl["name"] == "RMSCurrent" and bc.get("current_a"):
+            p["value"] = float(bc["current_a"])
+        elif tmpl["name"] == "Shaft_Speed" and bc.get("speed_rpm"):
+            p["value"] = float(bc["speed_rpm"])
+        elif tmpl["name"] == "Max_Speed" and bc.get("max_speed_rpm"):
+            p["value"] = float(bc["max_speed_rpm"])
+        elif tmpl["name"] == "DC_Link_Voltage" and bc.get("voltage_v"):
+            p["value"] = float(bc["voltage_v"])
+        elif tmpl["name"] == "Magnet_Material" and bc.get("magnet_material"):
+            p["value"] = bc["magnet_material"]
+        elif tmpl["name"] == "Steel_Grade" and bc.get("steel_grade"):
+            p["value"] = bc["steel_grade"]
+        elif tmpl["name"] == "Cooling_Method" and bc.get("cooling_type"):
+            p["value"] = bc["cooling_type"]
+        elif tmpl["name"] == "Turns_per_Coil" and bc.get("turns_per_coil"):
+            p["value"] = int(bc["turns_per_coil"])
+        elif tmpl["name"] == "Parallel_Paths" and bc.get("parallel_paths"):
+            p["value"] = int(bc["parallel_paths"])
+        elif tmpl["name"] == "Magnet_Length" and bc.get("magnet_length_mm"):
+            p["value"] = float(bc["magnet_length_mm"])
+        elif tmpl["name"] == "Magnet_Thickness" and bc.get("magnet_thickness_mm"):
+            p["value"] = float(bc["magnet_thickness_mm"])
+        elif tmpl["name"] == "Magnet_Arc_[ED]" and bc.get("magnet_arc_deg"):
+            p["value"] = float(bc["magnet_arc_deg"])
+        elif tmpl["name"] == "Current_Density" and bc.get("current_density_a_mm2"):
+            p["value"] = float(bc["current_density_a_mm2"])
+        elif tmpl["name"] == "Magnet_Remanence" and bc.get("magnet_remanence_t"):
+            p["value"] = float(bc["magnet_remanence_t"])
+        elif tmpl["name"] == "Ambient_Temperature" and bc.get("ambient_temp_c"):
+            p["value"] = float(bc["ambient_temp_c"])
+        elif tmpl["name"] == "Magnet_Temperature" and bc.get("magnet_temp_c"):
+            p["value"] = float(bc["magnet_temp_c"])
+        elif tmpl["name"] == "Insulation_Class" and bc.get("insulation_class"):
+            p["value"] = bc["insulation_class"]
+
+        params.append(p)
+
+    return params

+ 8 - 421
web/backend/app/services/l0_prescreening.py

@@ -1,423 +1,10 @@
-"""L0 Analytic Pre-screening Engine (P3-M2).
+"""Thin re-export: L0 pre-screening moved to shared core (P4-M5).
 
-Per third-party review: L0 analytic + rule-based pre-filtering
-to exclude obviously infeasible regions before any simulation.
-
-Covers:
-- Geometric constraints (inner/outer diameter, airgap, axial length)
-- Electrical constraints (current density, voltage, magnetic loading)
-- Thermal constraints (temperature rise, cooling capacity)
-- Manufacturing constraints (PCB line width/spacing, copper thickness, tolerances)
+Keeps existing ``from app.services.l0_prescreening import ...`` import sites
+working; the single implementation lives at src/afmcore/l0/prescreening.py.
 """
-import math
-from dataclasses import dataclass, field
-from typing import Dict, List, Optional, Tuple, Any
-
-
-@dataclass
-class ConstraintResult:
-    """Result of a single constraint check."""
-    name: str
-    category: str  # geometric / electrical / thermal / manufacturing
-    passed: bool
-    value: Optional[float] = None
-    limit: Optional[float] = None
-    margin: Optional[float] = None  # percentage margin (positive = safe)
-    message: str = ""
-
-
-@dataclass
-class FeasibilityReport:
-    """Complete L0 feasibility report for a parameter set."""
-    feasible: bool
-    total_checks: int
-    passed_checks: int
-    failed_checks: int
-    results: List[ConstraintResult] = field(default_factory=list)
-    risk_items: List[str] = field(default_factory=list)
-
-    @property
-    def pass_rate(self) -> float:
-        return self.passed_checks / self.total_checks if self.total_checks > 0 else 0.0
-
-    def to_dict(self) -> Dict[str, Any]:
-        return {
-            "feasible": self.feasible,
-            "total_checks": self.total_checks,
-            "passed_checks": self.passed_checks,
-            "failed_checks": self.failed_checks,
-            "pass_rate": round(self.pass_rate, 4),
-            "results": [
-                {
-                    "name": r.name,
-                    "category": r.category,
-                    "passed": r.passed,
-                    "value": r.value,
-                    "limit": r.limit,
-                    "margin_pct": round(r.margin, 2) if r.margin is not None else None,
-                    "message": r.message,
-                }
-                for r in self.results
-            ],
-            "risk_items": self.risk_items,
-        }
-
-
-class L0PreScreeningEngine:
-    """L0 analytic pre-screening engine.
-
-    Uses first-order physics and engineering rules to quickly
-    reject infeasible parameter combinations without simulation.
-    """
-
-    # Default engineering limits (can be overridden per project)
-    DEFAULT_LIMITS = {
-        # Geometric
-        "min_outer_diameter_mm": 20.0,
-        "max_outer_diameter_mm": 500.0,
-        "min_inner_diameter_mm": 5.0,
-        "min_diameter_ratio": 0.2,  # inner/outer
-        "max_diameter_ratio": 0.8,
-        "min_airgap_mm": 0.3,
-        "max_airgap_mm": 5.0,
-        "min_magnet_thickness_mm": 1.0,
-        "max_magnet_thickness_mm": 20.0,
-        # Electrical
-        "max_current_density_amm2": 15.0,  # A/mm^2 (natural convection)
-        "max_current_density_forced_amm2": 25.0,  # A/mm^2 (forced cooling)
-        "min_slot_fill_factor": 0.3,
-        "max_slot_fill_factor": 0.75,
-        "max_magnetic_loading_t": 1.8,  # Tesla (avoid saturation)
-        # Thermal
-        "max_temperature_rise_c": 80.0,  # K (above ambient)
-        "max_winding_temp_c": 150.0,  # Class F
-        "max_magnet_temp_c": 120.0,  # NdFeB N42SH
-        # Manufacturing (PCB)
-        "min_pcb_line_width_mm": 0.1,
-        "min_pcb_line_spacing_mm": 0.1,
-        "min_pcb_copper_thickness_oz": 0.5,
-        "max_pcb_copper_thickness_oz": 6.0,
-        "min_via_diameter_mm": 0.2,
-        "max_pcb_layers": 20,
-    }
-
-    def __init__(self, limits: Optional[Dict[str, float]] = None):
-        self.limits = dict(self.DEFAULT_LIMITS)
-        if limits:
-            self.limits.update(limits)
-
-    def check_geometric(self, params: Dict[str, Any]) -> List[ConstraintResult]:
-        """Check geometric feasibility constraints."""
-        results = []
-        L = self.limits
-
-        outer_d = params.get("outer_diameter_mm")
-        inner_d = params.get("inner_diameter_mm")
-        airgap = params.get("airgap_mm")
-        magnet_thickness = params.get("magnet_thickness_mm")
-
-        # Outer diameter range
-        if outer_d is not None:
-            passed = L["min_outer_diameter_mm"] <= outer_d <= L["max_outer_diameter_mm"]
-            results.append(ConstraintResult(
-                name="outer_diameter_range",
-                category="geometric",
-                passed=passed,
-                value=outer_d,
-                limit=f"{L['min_outer_diameter_mm']}-{L['max_outer_diameter_mm']}",
-                message=f"Outer diameter {outer_d}mm {'within' if passed else 'outside'} valid range",
-            ))
-
-        # Inner diameter and ratio
-        if outer_d is not None and inner_d is not None:
-            ratio = inner_d / outer_d if outer_d > 0 else 0
-            passed = (L["min_inner_diameter_mm"] <= inner_d and
-                      L["min_diameter_ratio"] <= ratio <= L["max_diameter_ratio"])
-            margin = (ratio - L["min_diameter_ratio"]) / L["min_diameter_ratio"] * 100 if ratio >= L["min_diameter_ratio"] else None
-            results.append(ConstraintResult(
-                name="inner_diameter_ratio",
-                category="geometric",
-                passed=passed,
-                value=round(ratio, 3),
-                limit=f"{L['min_diameter_ratio']}-{L['max_diameter_ratio']}",
-                margin=margin,
-                message=f"Inner/outer diameter ratio {ratio:.3f} {'valid' if passed else 'invalid'}",
-            ))
-
-        # Airgap range
-        if airgap is not None:
-            passed = L["min_airgap_mm"] <= airgap <= L["max_airgap_mm"]
-            results.append(ConstraintResult(
-                name="airgap_range",
-                category="geometric",
-                passed=passed,
-                value=airgap,
-                limit=f"{L['min_airgap_mm']}-{L['max_airgap_mm']}",
-                message=f"Airgap {airgap}mm {'within' if passed else 'outside'} valid range",
-            ))
-
-        # Magnet thickness
-        if magnet_thickness is not None:
-            passed = L["min_magnet_thickness_mm"] <= magnet_thickness <= L["max_magnet_thickness_mm"]
-            results.append(ConstraintResult(
-                name="magnet_thickness_range",
-                category="geometric",
-                passed=passed,
-                value=magnet_thickness,
-                limit=f"{L['min_magnet_thickness_mm']}-{L['max_magnet_thickness_mm']}",
-                message=f"Magnet thickness {magnet_thickness}mm {'within' if passed else 'outside'} valid range",
-            ))
-
-        # Airgap vs magnet thickness ratio (engineering rule)
-        if airgap is not None and magnet_thickness is not None:
-            ratio = airgap / magnet_thickness if magnet_thickness > 0 else 0
-            passed = 0.05 <= ratio <= 0.5  # typical: airgap 5-50% of magnet thickness
-            results.append(ConstraintResult(
-                name="airgap_magnet_ratio",
-                category="geometric",
-                passed=passed,
-                value=round(ratio, 3),
-                limit="0.05-0.5",
-                message=f"Airgap/magnet thickness ratio {ratio:.3f} {'reasonable' if passed else 'unusual'}",
-            ))
-
-        return results
-
-    def check_electrical(self, params: Dict[str, Any]) -> List[ConstraintResult]:
-        """Check electrical feasibility constraints."""
-        results = []
-        L = self.limits
-
-        current_density = params.get("current_density_amm2")
-        rms_current = params.get("current_a") or params.get("rms_current_a")
-        conductor_area = params.get("conductor_area_mm2")
-        slot_fill_factor = params.get("slot_fill_factor")
-        magnetic_loading = params.get("magnetic_loading_t") or params.get("airgap_flux_density_t")
-        forced_cooling = params.get("forced_cooling", False)
-
-        # Current density (derived or direct)
-        if current_density is None and rms_current is not None and conductor_area is not None and conductor_area > 0:
-            current_density = rms_current / conductor_area
-
-        if current_density is not None:
-            limit = L["max_current_density_forced_amm2"] if forced_cooling else L["max_current_density_amm2"]
-            passed = current_density <= limit
-            margin = (limit - current_density) / limit * 100 if current_density > 0 else None
-            results.append(ConstraintResult(
-                name="current_density",
-                category="electrical",
-                passed=passed,
-                value=round(current_density, 2),
-                limit=limit,
-                margin=margin,
-                message=f"Current density {current_density:.1f} A/mm^2 {'within' if passed else 'exceeds'} {limit} A/mm^2 limit ({'forced' if forced_cooling else 'natural'} cooling)",
-            ))
-
-        # Slot fill factor
-        if slot_fill_factor is not None:
-            passed = L["min_slot_fill_factor"] <= slot_fill_factor <= L["max_slot_fill_factor"]
-            results.append(ConstraintResult(
-                name="slot_fill_factor",
-                category="electrical",
-                passed=passed,
-                value=slot_fill_factor,
-                limit=f"{L['min_slot_fill_factor']}-{L['max_slot_fill_factor']}",
-                message=f"Slot fill factor {slot_fill_factor:.2f} {'within' if passed else 'outside'} valid range",
-            ))
-
-        # Magnetic loading (saturation check)
-        if magnetic_loading is not None:
-            passed = magnetic_loading <= L["max_magnetic_loading_t"]
-            margin = (L["max_magnetic_loading_t"] - magnetic_loading) / L["max_magnetic_loading_t"] * 100
-            results.append(ConstraintResult(
-                name="magnetic_loading",
-                category="electrical",
-                passed=passed,
-                value=round(magnetic_loading, 3),
-                limit=L["max_magnetic_loading_t"],
-                margin=margin,
-                message=f"Magnetic loading {magnetic_loading:.2f}T {'below' if passed else 'exceeds'} {L['max_magnetic_loading_t']}T saturation limit",
-            ))
-
-        return results
-
-    def check_thermal(self, params: Dict[str, Any]) -> List[ConstraintResult]:
-        """Check thermal feasibility constraints (first-order estimates)."""
-        results = []
-        L = self.limits
-
-        ambient_temp = params.get("ambient_temp_c", 25.0)
-        winding_temp = params.get("winding_temp_c")
-        magnet_temp = params.get("magnet_temp_c")
-        total_losses_w = params.get("total_losses_w")
-        cooling_area_mm2 = params.get("cooling_area_mm2")
-
-        # Winding temperature limit
-        if winding_temp is not None:
-            passed = winding_temp <= L["max_winding_temp_c"]
-            margin = (L["max_winding_temp_c"] - winding_temp) / L["max_winding_temp_c"] * 100
-            results.append(ConstraintResult(
-                name="winding_temperature",
-                category="thermal",
-                passed=passed,
-                value=winding_temp,
-                limit=L["max_winding_temp_c"],
-                margin=margin,
-                message=f"Winding temperature {winding_temp}C {'below' if passed else 'exceeds'} {L['max_winding_temp_c']}C limit (Class F)",
-            ))
-
-        # Magnet temperature limit
-        if magnet_temp is not None:
-            passed = magnet_temp <= L["max_magnet_temp_c"]
-            margin = (L["max_magnet_temp_c"] - magnet_temp) / L["max_magnet_temp_c"] * 100
-            results.append(ConstraintResult(
-                name="magnet_temperature",
-                category="thermal",
-                passed=passed,
-                value=magnet_temp,
-                limit=L["max_magnet_temp_c"],
-                margin=margin,
-                message=f"Magnet temperature {magnet_temp}C {'below' if passed else 'exceeds'} {L['max_magnet_temp_c']}C limit (NdFeB N42SH)",
-            ))
-
-        # First-order thermal estimate: losses vs cooling capacity
-        if total_losses_w is not None and cooling_area_mm2 is not None and cooling_area_mm2 > 0:
-            # Rough heat transfer coefficient: 10 W/m^2K (natural), 50 W/m^2K (forced)
-            forced = params.get("forced_cooling", False)
-            h = 50.0 if forced else 10.0  # W/m^2K
-            cooling_area_m2 = cooling_area_mm2 / 1e6
-            temp_rise = total_losses_w / (h * cooling_area_m2) if cooling_area_m2 > 0 else float('inf')
-            passed = temp_rise <= L["max_temperature_rise_c"]
-            results.append(ConstraintResult(
-                name="thermal_estimate",
-                category="thermal",
-                passed=passed,
-                value=round(temp_rise, 1),
-                limit=L["max_temperature_rise_c"],
-                message=f"Estimated temperature rise {temp_rise:.1f}K {'within' if passed else 'exceeds'} {L['max_temperature_rise_c']}K limit (rough estimate, needs L2 verification)",
-            ))
-
-        return results
-
-    def check_manufacturing(self, params: Dict[str, Any]) -> List[ConstraintResult]:
-        """Check PCB manufacturing constraints."""
-        results = []
-        L = self.limits
-
-        pcb_line_width = params.get("pcb_line_width_mm")
-        pcb_line_spacing = params.get("pcb_line_spacing_mm")
-        pcb_copper_thickness_oz = params.get("pcb_copper_thickness_oz")
-        pcb_layers = params.get("pcb_layers")
-        via_diameter = params.get("via_diameter_mm")
-
-        if pcb_line_width is not None:
-            passed = pcb_line_width >= L["min_pcb_line_width_mm"]
-            results.append(ConstraintResult(
-                name="pcb_line_width",
-                category="manufacturing",
-                passed=passed,
-                value=pcb_line_width,
-                limit=L["min_pcb_line_width_mm"],
-                message=f"PCB line width {pcb_line_width}mm {'meets' if passed else 'below'} {L['min_pcb_line_width_mm']}mm minimum",
-            ))
-
-        if pcb_line_spacing is not None:
-            passed = pcb_line_spacing >= L["min_pcb_line_spacing_mm"]
-            results.append(ConstraintResult(
-                name="pcb_line_spacing",
-                category="manufacturing",
-                passed=passed,
-                value=pcb_line_spacing,
-                limit=L["min_pcb_line_spacing_mm"],
-                message=f"PCB line spacing {pcb_line_spacing}mm {'meets' if passed else 'below'} {L['min_pcb_line_spacing_mm']}mm minimum",
-            ))
-
-        if pcb_copper_thickness_oz is not None:
-            passed = L["min_pcb_copper_thickness_oz"] <= pcb_copper_thickness_oz <= L["max_pcb_copper_thickness_oz"]
-            results.append(ConstraintResult(
-                name="pcb_copper_thickness",
-                category="manufacturing",
-                passed=passed,
-                value=pcb_copper_thickness_oz,
-                limit=f"{L['min_pcb_copper_thickness_oz']}-{L['max_pcb_copper_thickness_oz']}",
-                message=f"PCB copper thickness {pcb_copper_thickness_oz}oz {'within' if passed else 'outside'} standard range",
-            ))
-
-        if pcb_layers is not None:
-            passed = pcb_layers <= L["max_pcb_layers"]
-            results.append(ConstraintResult(
-                name="pcb_layer_count",
-                category="manufacturing",
-                passed=passed,
-                value=pcb_layers,
-                limit=L["max_pcb_layers"],
-                message=f"PCB layer count {pcb_layers} {'within' if passed else 'exceeds'} {L['max_pcb_layers']} layer limit",
-            ))
-
-        return results
-
-    def evaluate(self, params: Dict[str, Any]) -> FeasibilityReport:
-        """Run full L0 pre-screening on a parameter set.
-
-        Args:
-            params: Dictionary of parameter values (diameters, airgap, currents, etc.)
-
-        Returns:
-            FeasibilityReport with all check results and overall feasibility.
-        """
-        all_results = []
-        all_results.extend(self.check_geometric(params))
-        all_results.extend(self.check_electrical(params))
-        all_results.extend(self.check_thermal(params))
-        all_results.extend(self.check_manufacturing(params))
-
-        passed = sum(1 for r in all_results if r.passed)
-        failed = len(all_results) - passed
-
-        # C3 fix: require minimum coverage. If no checks ran (all params None),
-        # the gate must not silently pass as "feasible".
-        MIN_CHECKS = 3
-        if len(all_results) == 0:
-            feasible = False
-            risk_items = ["[UNKNOWN] No checks were performed - input params may be missing or unrecognized."]
-        elif len(all_results) < MIN_CHECKS:
-            feasible = failed == 0
-            risk_items = [f"[WARNING] Only {len(all_results)} check(s) ran (minimum {MIN_CHECKS} recommended); result may be unreliable."]
-        else:
-            feasible = failed == 0
-            risk_items = []
-
-        # Collect risk items (failed or low-margin checks)
-        for r in all_results:
-            if not r.passed:
-                risk_items.append(f"[FAIL] {r.name}: {r.message}")
-            elif r.margin is not None and r.margin < 10:
-                risk_items.append(f"[RISK] {r.name}: margin only {r.margin:.1f}%")
-
-        return FeasibilityReport(
-            feasible=feasible,
-            total_checks=len(all_results),
-            passed_checks=passed,
-            failed_checks=failed,
-            results=all_results,
-            risk_items=risk_items,
-        )
-
-    def is_feasible(self, params: Dict[str, Any]) -> bool:
-        """Quick feasibility check (boolean only)."""
-        return self.evaluate(params).feasible
-
-    def filter_feasible(self, param_sets: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
-        """Filter a list of parameter sets into feasible and infeasible.
-
-        Returns:
-            Tuple of (feasible_sets, infeasible_sets)
-        """
-        feasible = []
-        infeasible = []
-        for params in param_sets:
-            if self.is_feasible(params):
-                feasible.append(params)
-            else:
-                infeasible.append(params)
-        return feasible, infeasible
+from src.afmcore.l0.prescreening import (  # noqa: F401
+    ConstraintResult,
+    FeasibilityReport,
+    L0PreScreeningEngine,
+)

+ 439 - 6
web/backend/app/services/plan_generator.py

@@ -2,14 +2,439 @@
 
 Converts natural language requirements into structured simulation plans
 using Kimi k3 model, with L0 pre-screening validation.
+
+Output is converted to unified plan schema v2.0 compatible with
+src/plan_schema.py (fixed_params + variables with value lists).
 """
 import json
+import math
 from pathlib import Path
 from typing import Dict, List, Optional, Any, Tuple
 
-from ..config import PROMPTS_DIR
+from ..config import PROMPTS_DIR, KIMI_MAX_TOKENS
 from ..services.ai_client import get_kimi_client
 from ..services.l0_prescreening import L0PreScreeningEngine
+from ..services.rule_engine import SCAN_PARAMETERS, get_parameter, recommend_range, BoundaryConditions
+from ..services.fixed_params_template import build_default_fixed_params, get_fixed_param_template, CATEGORY_CN
+
+# Single-source strategy registry (mirrors src/plan_schema.py validation) so the
+# AI-generated method string is normalized against the platform registry instead
+# of being trusted verbatim. Avoids a second definition of the strategy whitelist.
+from src.afmcore.strategies import (
+    is_registered as _strategy_registered,
+    normalize_method as _strategy_normalize,
+)
+
+
+# Mapping from common AI-output variable names to exact Motor-CAD variable names
+_VARIABLE_NAME_MAP = {
+    "airgap": "Airgap",
+    "airgap_mm": "Airgap",
+    "air_gap": "Airgap",
+    "air_gap_mm": "Airgap",
+    "airgap_length": "Airgap",
+    "airgap_length_mm": "Airgap",
+    "air_gap_length": "Airgap",
+    "air_gap_length_mm": "Airgap",
+    "gap_length": "Airgap",
+    "gap_length_mm": "Airgap",
+    "magnet_length": "Magnet_Length",
+    "magnet_length_mm": "Magnet_Length",
+    "magnet_axial_thickness": "Magnet_Length",
+    "magnet_axial_thickness_mm": "Magnet_Length",
+    "magnet_axial_length": "Magnet_Length",
+    "magnet_axial_length_mm": "Magnet_Length",
+    "magnet_thickness": "Magnet_Thickness",
+    "magnet_thickness_mm": "Magnet_Thickness",
+    "magnet_radial_depth": "Magnet_Thickness",
+    "magnet_radial_depth_mm": "Magnet_Thickness",
+    "magnet_radial_thickness": "Magnet_Thickness",
+    "magnet_radial_thickness_mm": "Magnet_Thickness",
+    "magnet_arc": "Magnet_Arc_[ED]",
+    "magnet_arc_deg": "Magnet_Arc_[ED]",
+    "magnet_arc_[ed]": "Magnet_Arc_[ED]",
+    "magnet_pole_arc": "Magnet_Arc_[ED]",
+    "magnet_pole_arc_ratio": "Magnet_Arc_[ED]",
+    "pole_arc": "Magnet_Arc_[ED]",
+    "pole_arc_deg": "Magnet_Arc_[ED]",
+    "pole_arc_ratio": "Magnet_Arc_[ED]",
+    "pole_arc_coefficient": "Magnet_Arc_[ED]",
+    "rms_current": "RMSCurrent",
+    "rms_current_a": "RMSCurrent",
+    "current_a": "RMSCurrent",
+    "current": "RMSCurrent",
+    "phase_current": "RMSCurrent",
+    "phase_current_a": "RMSCurrent",
+    "rated_current": "RMSCurrent",
+    "rated_current_a": "RMSCurrent",
+    "shaft_speed": "Shaft_Speed",
+    "shaft_speed_rpm": "Shaft_Speed",
+    "speed_rpm": "Shaft_Speed",
+    "speed": "Shaft_Speed",
+    "rotational_speed": "Shaft_Speed",
+    "rotational_speed_rpm": "Shaft_Speed",
+    "rated_speed": "Shaft_Speed",
+    "rated_speed_rpm": "Shaft_Speed",
+    "magnet_temperature": "Magnet_Temperature",
+    "magnet_temperature_c": "Magnet_Temperature",
+    "magnet_temp_c": "Magnet_Temperature",
+    "magnet_temp": "Magnet_Temperature",
+    "torque_points_per_cycle": "TorquePointsPerCycle",
+    "torque_points": "TorquePointsPerCycle",
+    "sampling_points": "TorquePointsPerCycle",
+    "torque_sampling_points": "TorquePointsPerCycle",
+    # Geometry / winding variants the AI commonly emits (normalized to the
+    # canonical names registered in rule_engine.SCAN_PARAMETERS).
+    "stator_outer_diameter": "Stator_Outer_Diameter",
+    "stator_outer_diameter_mm": "Stator_Outer_Diameter",
+    "stator_outer_dia": "Stator_Outer_Diameter",
+    "stator_od": "Stator_Outer_Diameter",
+    "stator_lam_outer_dia": "Stator_Outer_Diameter",
+    "stator_lam_dia_outer": "Stator_Outer_Diameter",
+    "stator_lam_outside_dia": "Stator_Outer_Diameter",
+    "stator_lamination_outer_dia": "Stator_Outer_Diameter",
+    "stator_inner_diameter": "Stator_Inner_Diameter",
+    "stator_inner_dia": "Stator_Inner_Diameter",
+    "stator_lam_inner_dia": "Stator_Inner_Diameter",
+    "stator_id": "Stator_Inner_Diameter",
+    "slot_depth": "Slot_Depth",
+    "slot_depth_mm": "Slot_Depth",
+    "stator_slot_depth": "Slot_Depth",
+    "turns_per_coil": "Turns_per_Coil",
+    "coil_turns": "Turns_per_Coil",
+    "turnspercoil": "Turns_per_Coil",
+    "coil_turns_per_phase": "Turns_per_Coil",
+    "turns_per_coil_per_phase": "Turns_per_Coil",
+    "number_of_turns": "Turns_per_Coil",
+    "wire_diameter": "Wire_Diameter",
+    "wire_diameter_mm": "Wire_Diameter",
+    "wire_dia": "Wire_Diameter",
+    "magnet_wire_diameter": "Wire_Diameter",
+    "coil_wire_diameter": "Wire_Diameter",
+}
+
+
+def _normalize_variable_name(name: str) -> str:
+    """Map AI-output variable name to exact Motor-CAD variable name."""
+    if not name:
+        return name
+    # Direct match in registry
+    if name in SCAN_PARAMETERS:
+        return name
+    # Case-insensitive lookup in registry
+    lower = name.lower()
+    for reg_name in SCAN_PARAMETERS:
+        if reg_name.lower() == lower:
+            return reg_name
+    # Mapping table
+    mapped = _VARIABLE_NAME_MAP.get(lower)
+    if mapped:
+        return mapped
+    return name
+
+
+def _extract_range(sv: Dict[str, Any]) -> Tuple[Optional[float], Optional[float], Optional[float]]:
+    """Extract min/max/step from a scan variable dict with flexible field names."""
+    min_v = None
+    max_v = None
+    step = None
+
+    # Try various field names for min
+    for key in ["min_value", "min", "minVal", "lower", "start", "from", "minimum"]:
+        if key in sv and sv[key] is not None:
+            try:
+                min_v = float(sv[key])
+                break
+            except (ValueError, TypeError):
+                pass
+
+    # Try various field names for max
+    for key in ["max_value", "max", "maxVal", "upper", "stop", "end", "to", "maximum"]:
+        if key in sv and sv[key] is not None:
+            try:
+                max_v = float(sv[key])
+                break
+            except (ValueError, TypeError):
+                pass
+
+    # Try step
+    for key in ["step", "step_size", "increment", "delta", "resolution"]:
+        if key in sv and sv[key] is not None:
+            try:
+                step = float(sv[key])
+                break
+            except (ValueError, TypeError):
+                pass
+
+    # If range provided as [min, max]
+    if (min_v is None or max_v is None) and "range" in sv:
+        rng = sv["range"]
+        if isinstance(rng, (list, tuple)) and len(rng) >= 2:
+            try:
+                min_v = float(rng[0]) if min_v is None else min_v
+                max_v = float(rng[1]) if max_v is None else max_v
+            except (ValueError, TypeError):
+                pass
+
+    return min_v, max_v, step
+
+
+def _generate_values(start: float, stop: float, step: float) -> List[float]:
+    """Generate evenly spaced values from start to stop inclusive."""
+    if step <= 0 or start is None or stop is None:
+        return []
+    count = int(math.floor((stop - start) / step + 1e-9)) + 1
+    if count <= 0 or count > 500:
+        return []
+    vals = [round(start + i * step, 6) for i in range(count)]
+    if vals and abs(vals[-1] - stop) > 1e-9:
+        vals.append(round(stop, 6))
+    return vals
+
+
+def convert_ai_plan_to_unified(ai_plan: Dict[str, Any], boundary_conditions: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+    """Convert AI-generated plan format to unified plan schema v2.0.
+    ...
+    """
+    bc = BoundaryConditions.from_dict(boundary_conditions or {})
+
+    # Collect scan variables from either scan_variables or variables field
+    raw_vars = ai_plan.get("scan_variables", []) or ai_plan.get("variables", [])
+
+    variables = []
+    for sv in raw_vars:
+        if not isinstance(sv, dict):
+            continue
+        raw_name = sv.get("name", "")
+        if not raw_name:
+            continue
+
+        # Normalize variable name to Motor-CAD exact name
+        name = _normalize_variable_name(raw_name)
+
+        # If AI already provided values array, use it directly
+        values = sv.get("values", [])
+        if not isinstance(values, list):
+            values = []
+
+        min_v, max_v, step = _extract_range(sv)
+
+        # If no values but have range, generate
+        if not values and min_v is not None and max_v is not None and step:
+            values = _generate_values(min_v, max_v, step)
+
+        # If still no values, fall back to rule engine defaults
+        if not values:
+            param = get_parameter(name)
+            if param:
+                rng = recommend_range(name, bc)
+                min_v = rng["start"]
+                max_v = rng["stop"]
+                step = rng["step"]
+                values = _generate_values(min_v, max_v, step)
+
+        # Get display name and unit from registry if available
+        param = get_parameter(name)
+        tmpl = get_fixed_param_template(name)
+        display_name = sv.get("display_name") or (param.display_name if param else tmpl.get("display_name", raw_name))
+        name_cn = sv.get("name_cn") or tmpl.get("name_cn") or (param.display_name if param else raw_name)
+        unit = sv.get("unit") or (param.unit if param else tmpl.get("unit", ""))
+        category = sv.get("category") or (param.category if param else tmpl.get("category", ""))
+        category_cn = sv.get("category_cn") or CATEGORY_CN.get(category, category)
+        description = sv.get("description") or tmpl.get("description", "")
+        description_cn = sv.get("description_cn") or tmpl.get("description_cn", "")
+
+        variables.append({
+            "name": name,
+            "display_name": display_name,
+            "name_cn": name_cn,
+            "unit": unit,
+            "start": min_v,
+            "stop": max_v,
+            "step": step,
+            "values": values,
+            "category": category,
+            "category_cn": category_cn,
+            "description": description,
+            "description_cn": description_cn,
+        })
+
+    # Deduplicate variables by name (keep first)
+    seen = set()
+    unique_vars = []
+    for v in variables:
+        if v["name"] not in seen:
+            seen.add(v["name"])
+            unique_vars.append(v)
+    variables = unique_vars
+
+    # Separate standard registry variables from custom variables
+    standard_vars = []
+    custom_vars = []
+    warnings = []
+    for v in variables:
+        if v["name"] in SCAN_PARAMETERS:
+            standard_vars.append(v)
+        else:
+            custom_vars.append(v)
+            warnings.append(f"Variable '{v['name']}' is not a standard Motor-CAD variable, may need manual adjustment")
+
+    # If too many standard variables, keep only top 4 (to avoid combinatorial explosion)
+    MAX_VARIABLES = 4
+    if len(standard_vars) > MAX_VARIABLES:
+        warnings.append(f"Too many scan variables ({len(standard_vars)}), keeping top {MAX_VARIABLES} to avoid combinatorial explosion")
+        standard_vars = standard_vars[:MAX_VARIABLES]
+
+    # Use standard variables primarily, append custom ones that have explicit values
+    variables = standard_vars
+    for cv in custom_vars:
+        if cv.get("values") and len(cv["values"]) > 0:
+            variables.append(cv)
+        else:
+            warnings.append(f"Skipping custom variable '{cv['name']}' - no valid values")
+
+    # Hard limit: total variables <= 4
+    if len(variables) > MAX_VARIABLES:
+        warnings.append(f"Total variables ({len(variables)}) exceeds limit {MAX_VARIABLES}, keeping first {MAX_VARIABLES}")
+        variables = variables[:MAX_VARIABLES]
+
+    # Recalculate total points
+    total_points = 1
+    for v in variables:
+        total_points *= len(v.get("values", [])) if v.get("values") else 1
+
+    # If still too many points, trim variables one by one until under threshold
+    MAX_POINTS = 200
+    while total_points > MAX_POINTS and len(variables) > 1:
+        removed = variables.pop()
+        warnings.append(f"Removed variable '{removed['name']}' ({len(removed.get('values', []))} values) to reduce total points below {MAX_POINTS}")
+        total_points = 1
+        for v in variables:
+            total_points *= len(v.get("values", [])) if v.get("values") else 1
+
+    if total_points > MAX_POINTS:
+        warnings.append(f"Total scan points ({total_points}) exceeds {MAX_POINTS}, consider narrowing ranges")
+
+    # Fixed params: template-driven (ALL parameters from FIXED_PARAM_TEMPLATES,
+    # excluding scan variables). Values inferred from boundary conditions where possible.
+    # AI does not need to output fixed_params - the template is the single source.
+    scan_names = {v["name"].lower() for v in variables}
+    fixed_params = build_default_fixed_params(
+        scan_variable_names=list(scan_names),
+        boundary_conditions=boundary_conditions,
+    )
+
+    # Append any custom fixed params the AI explicitly provided that are not
+    # already in the template (keeps AI flexibility for special cases).
+    existing_names = {fp["name"].lower() for fp in fixed_params}
+    for fp in ai_plan.get("fixed_params", []):
+        if not isinstance(fp, dict) or not fp.get("name"):
+            continue
+        fname = fp["name"].lower()
+        if fname in existing_names or fname in scan_names:
+            continue
+        tmpl = get_fixed_param_template(fp["name"])
+        val = fp.get("value", 0)
+        try:
+            val = float(val)
+        except (TypeError, ValueError):
+            pass
+        fixed_params.append({
+            "name": fp["name"],
+            "display_name": fp.get("display_name") or tmpl.get("display_name", fp["name"]),
+            "name_cn": fp.get("name_cn") or tmpl.get("name_cn", fp["name"]),
+            "unit": fp.get("unit") or tmpl.get("unit", ""),
+            "value": val,
+            "category": fp.get("category") or tmpl.get("category", "General"),
+            "category_cn": fp.get("category_cn") or CATEGORY_CN.get(tmpl.get("category", "General"), tmpl.get("category", "General")),
+            "description": fp.get("description") or tmpl.get("description", ""),
+            "description_cn": fp.get("description_cn") or tmpl.get("description_cn", ""),
+        })
+        existing_names.add(fname)
+
+    # Normalize acceptance_criteria to standard format
+    ac = ai_plan.get("acceptance_criteria", {})
+    if isinstance(ac, dict):
+        hc = ac.get("hard_constraints")
+        if isinstance(hc, dict):
+            # Nested dict form: {"outer_diameter_mm": "<=110", ...} -> list of
+            # "metric op value" strings so the UI can render them directly.
+            ac = dict(ac)
+            ac["hard_constraints"] = [f"{k} {v}" for k, v in hc.items()]
+        if ac and not isinstance(ac.get("hard_constraints"), list):
+            # AI may return custom format like {rated_torque_Nm_min: 25, ...}
+            hard_constraints = []
+            for key, val in ac.items():
+                if isinstance(val, (int, float)):
+                    if key.endswith("_min"):
+                        metric = key.replace("_min", "").replace("_Nm", "").replace("_percent", "").replace("_N", "").replace("_T", "")
+                        hard_constraints.append(f"{metric} >= {val}")
+                    elif key.endswith("_max"):
+                        metric = key.replace("_max", "").replace("_Nm", "").replace("_percent", "").replace("_N", "").replace("_T", "")
+                        hard_constraints.append(f"{metric} <= {val}")
+            if hard_constraints:
+                ac = {
+                    "hard_constraints": hard_constraints,
+                    "objective_metric": ac.get("objective_metric", "efficiency_pct"),
+                    "objective_direction": ac.get("objective_direction", "maximize"),
+                    "soft_targets": {},
+                }
+
+    # Normalize search_strategy
+    ss = ai_plan.get("search_strategy", {})
+    if not isinstance(ss, dict):
+        ss = {}
+    # Normalize the method against the platform strategy registry. The model may
+    # emit a non-registered method (e.g. "full_factorial_grid"); fall back to the
+    # deterministic full_factorial default and record a warning instead of letting
+    # the invalid value propagate to downstream validation.
+    raw_method = ss.get("method", "")
+    method = _strategy_normalize(raw_method)
+    if method and not _strategy_registered(method):
+        warnings.append(
+            f"Unknown search strategy method '{raw_method}' - defaulting to full_factorial"
+        )
+        method = "full_factorial"
+    if not method:
+        method = "full_factorial"
+    if ss and "method" not in ss:
+        ss = {
+            "method": method,
+            "initial_samples": 16,
+            "batch_size": 4,
+            "max_solver_calls": 80,
+            "local_trust_region": False,
+            "objective_metric": "efficiency_pct",
+            "objective_direction": "maximize",
+        }
+    else:
+        ss = dict(ss)
+        ss["method"] = method
+
+    # Estimate total points
+    total_points = 1
+    for v in variables:
+        total_points *= len(v.get("values", [])) if v.get("values") else 1
+
+    return {
+        "name": ai_plan.get("plan_name", "AI Generated Plan"),
+        "topology": ai_plan.get("topology", "SSSR"),
+        "model_path": ai_plan.get("model_path", ""),
+        "fixed_params": fixed_params,
+        "variables": variables,
+        "cases": [{"id": "default", "name": "Default operating point", "params": {}}],
+        "output_metrics": [
+            "tavg_nm", "ripple_pct", "efficiency_pct", "total_losses_w",
+            "copper_loss_w", "iron_loss_w", "magnet_loss_w", "back_emf_v",
+            "input_power_w", "output_power_w", "shaft_speed_rpm",
+        ],
+        "search_strategy": ss,
+        "acceptance_criteria": ac if ac else None,
+        "ai_reasoning": ai_plan.get("reasoning", ""),
+        "estimated_points": total_points,
+        "estimated_time_min": total_points * 3,
+        "warnings": warnings,
+    }
 
 
 class AIPlanGenerator:
@@ -33,7 +458,7 @@ class AIPlanGenerator:
 
     def _default_prompt(self) -> str:
         """Fallback default prompt."""
-        return """\u4f60\u662f\u8f74\u5411\u78c1\u901a\u7535\u673a\u4eff\u771f\u65b9\u6848\u4e13\u5bb6\u3002\u5c06\u7528\u6237\u9700\u6c42\u8f6c\u5316\u4e3aJSON\u683c\u5f0f\u7684\u4eff\u771f\u65b9\u6848\uff0c\u5305\u542bplan_name\u3001topology\u3001boundary_conditions\u3001scan_variables\u3001search_strategy\u3001acceptance_criteria\u3001reasoning\u3002\u8f93\u51fa\u7eafJSON\u3002"""
+        return """\u4f60\u662f\u8f74\u5411\u78c1\u901a\u7535\u673a\u4eff\u771f\u65b9\u6848\u4e13\u5bb6\u3002\u6839\u636e\u7528\u6237\u9700\u6c42\u548c\u8fb9\u754c\u6761\u4ef6\u751f\u6210JSON\u683c\u5f0f\u7684\u4eff\u771f\u65b9\u6848\u3002\n\u56fa\u5b9a\u53c2\u6570\u5df2\u7531\u7cfb\u7edf\u7edf\u4e00\u6a21\u677f\u751f\u6210\uff0c\u4f60\u65e0\u9700\u8f93\u51fafixed_params\uff1b\u4f60\u53ea\u9700\u8f93\u51fa\u4ee5\u4e0b\u5b57\u6bb5\uff1a\n- plan_name: \u65b9\u6848\u540d\u79f0\uff08\u7b80\u77ed\u63cf\u8ff0\uff09\n- topology: \u62d3\u6251\u7ed3\u6784\uff08SSSR/DRSS/SDSR\uff09\n- scan_variables: \u626b\u63cf\u53d8\u91cf\u6570\u7ec4\uff0c\u6bcf\u9879\u542bname\uff08\u7528Motor-CAD\u6807\u51c6\u53d8\u91cf\u540d\uff09\u3001start\u3001stop\u3001step\uff0c\u6700\u591a4\u4e2a\n- search_strategy: \u641c\u7d22\u7b56\u7565\uff08method, objective_metric, objective_direction\u7b49\uff09\n- acceptance_criteria: \u9a8c\u6536\u6807\u51c6\uff08\u786c\u7ea6\u675f\u3001\u6027\u80fd\u76ee\u6807\uff09\n- reasoning: \u4e2d\u6587\u8bbe\u8ba1\u601d\u8def\uff0c\u7ea6200\u5b57\uff0c\u53ea\u8bb2\u5173\u952e\u8bbe\u8ba1\u51b3\u7b56\u548c\u6838\u5fc3\u53c2\u6570\u9009\u62e9\u7406\u7531\uff0c\u7b80\u6d01\u76f4\u63a5\n\u8f93\u51fa\u7eafJSON\uff0c\u4e0d\u8981\u591a\u4f59\u6587\u5b57\u3002"""
 
     def generate(
         self,
@@ -70,7 +495,7 @@ class AIPlanGenerator:
         result = self.ai_client.chat_json(
             messages=[{"role": "user", "content": user_message}],
             system_prompt=system_prompt,
-            max_tokens=2000,
+            max_tokens=KIMI_MAX_TOKENS,
         )
 
         # Parse generated plan
@@ -82,11 +507,16 @@ class AIPlanGenerator:
             if not plan:
                 plan = {"error": "Failed to parse AI response", "raw_content": raw[:1000]}
 
+        # Convert to unified format
+        bc = (project_context or {}).get("boundary_conditions") if project_context else None
+        unified_plan = convert_ai_plan_to_unified(plan, boundary_conditions=bc)
+
         # Validate with L0 pre-screening
         validation = self._validate_plan(plan)
 
         return {
-            "plan": plan,
+            "plan": unified_plan,
+            "raw_ai_plan": plan,
             "validation": validation,
             "ai_reasoning": plan.get("reasoning", ""),
             "usage": result.get("usage", {}),
@@ -273,7 +703,7 @@ class AIPlanGenerator:
         result = self.ai_client.chat_json(
             messages=[{"role": "user", "content": user_message}],
             system_prompt=system_prompt,
-            max_tokens=2000,
+            max_tokens=KIMI_MAX_TOKENS,
         )
 
         refined_plan = result.get("parsed_json", {})
@@ -283,10 +713,13 @@ class AIPlanGenerator:
             if not refined_plan:
                 refined_plan = {"error": "Failed to parse AI response", "raw_content": raw[:1000]}
 
+        bc = original_plan.get("boundary_conditions") if isinstance(original_plan, dict) else None
+        unified_plan = convert_ai_plan_to_unified(refined_plan, boundary_conditions=bc)
         validation = self._validate_plan(refined_plan)
 
         return {
-            "plan": refined_plan,
+            "plan": unified_plan,
+            "raw_ai_plan": refined_plan,
             "validation": validation,
             "ai_reasoning": refined_plan.get("reasoning", ""),
             "usage": result.get("usage", {}),

+ 75 - 11
web/backend/app/services/report_generator.py

@@ -6,7 +6,7 @@ Includes cover, parameters, results summary, metrics, AI analysis.
 import json
 import os
 from datetime import datetime
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, List, Optional, Tuple
 
 try:
     from docx import Document
@@ -16,6 +16,62 @@ try:
 except ImportError:
     HAS_DOCX = False
 
+# P5-M6: physics-domain grouping (single source of truth = afmcore.metrics)
+try:
+    import sys as _sys
+    _afm_src = os.path.join(
+        os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
+            os.path.dirname(os.path.abspath(__file__)))))),
+        "src",
+    )
+    if _afm_src not in _sys.path:
+        _sys.path.insert(0, _afm_src)
+    from afmcore.metrics import METRIC_DEFINITIONS as _MD
+    _METRIC_DOMAIN: Dict[str, str] = {
+        m["key"]: m.get("domain", "electromagnetic") for m in _MD
+    }
+    _METRIC_LABEL: Dict[str, str] = {m["key"]: m["label"] for m in _MD}
+    _METRIC_UNIT: Dict[str, str] = {m["key"]: m.get("unit", "") for m in _MD}
+except Exception:  # noqa: BLE001
+    _METRIC_DOMAIN = {}
+    _METRIC_LABEL = {}
+    _METRIC_UNIT = {}
+
+_DOMAIN_ORDER = ["electromagnetic", "thermal", "structural"]
+_DOMAIN_LABELS = {
+    "electromagnetic": "Electromagnetic Metrics",
+    "thermal": "Thermal Metrics",
+    "structural": "Structural / Mechanical Metrics",
+}
+
+
+def _group_metrics_by_domain(metrics: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
+    """Group a flat metrics dict by physics domain.
+
+    Returns {domain: {key: value}}. Unknown keys default to
+    'electromagnetic'. Empty domains are omitted.
+    """
+    grouped: Dict[str, Dict[str, Any]] = {d: {} for d in _DOMAIN_ORDER}
+    for key, value in metrics.items():
+        domain = _METRIC_DOMAIN.get(key, "electromagnetic")
+        if domain not in grouped:
+            grouped[domain] = {}
+        grouped[domain][key] = value
+    return {d: v for d, v in grouped.items() if v}
+
+
+def _metric_display(key: str, value: Any) -> Tuple[str, str]:
+    """Return (display_label, display_value) for a metric key."""
+    label = _METRIC_LABEL.get(key, key)
+    unit = _METRIC_UNIT.get(key, "")
+    if unit:
+        label = "%s [%s]" % (label, unit) if "[" not in label else label
+    if isinstance(value, float):
+        disp = "%.4g" % value
+    else:
+        disp = str(value)
+    return label, disp
+
 
 class ReportGenerator:
     """Generate simulation reports from task results."""
@@ -69,19 +125,25 @@ class ReportGenerator:
                 row[0].text = str(key)
                 row[1].text = str(value)
 
-        # Results summary
+        # Results summary (P5-M6: grouped by physics domain)
         doc.add_heading("Results Summary", level=1)
         results = task_data.get("result_metrics", {})
         if results:
-            table = doc.add_table(rows=1, cols=2)
-            table.style = "Table Grid"
-            hdr = table.rows[0].cells
-            hdr[0].text = "Metric"
-            hdr[1].text = "Value"
-            for key, value in results.items():
-                row = table.add_row().cells
-                row[0].text = str(key)
-                row[1].text = str(value)
+            grouped = _group_metrics_by_domain(results)
+            for domain in _DOMAIN_ORDER:
+                if domain not in grouped:
+                    continue
+                doc.add_heading(_DOMAIN_LABELS.get(domain, domain), level=2)
+                table = doc.add_table(rows=1, cols=2)
+                table.style = "Table Grid"
+                hdr = table.rows[0].cells
+                hdr[0].text = "Metric"
+                hdr[1].text = "Value"
+                for key, value in grouped[domain].items():
+                    label, disp = _metric_display(key, value)
+                    row = table.add_row().cells
+                    row[0].text = label
+                    row[1].text = disp
 
         # Per-point results
         doc.add_heading("Per-Point Results", level=1)
@@ -126,10 +188,12 @@ class ReportGenerator:
                                 ai_analysis: Optional[Dict[str, Any]],
                                 report_title: Optional[str]) -> str:
         """Fallback: generate JSON report when python-docx is unavailable."""
+        _raw_metrics = task_data.get("result_metrics", {})
         report = {
             "title": report_title or f"Simulation Report - {task_data.get('task_name', 'Task')}",
             "generated_at": datetime.now().isoformat(),
             "task_data": task_data,
+            "metrics_by_domain": _group_metrics_by_domain(_raw_metrics),
             "ai_analysis": ai_analysis,
         }
         filename = f"report_{task_data.get('task_id', 'task')}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"

+ 57 - 0
web/backend/app/services/rule_engine.py

@@ -14,6 +14,8 @@ import math
 from dataclasses import dataclass, field
 from typing import Any
 
+from .bc_fields import normalize_bc
+
 
 # ---------------------------------------------------------------------------
 # Scan parameter registry
@@ -114,6 +116,56 @@ SCAN_PARAMETERS: dict[str, ScanParameter] = {
         min_allowed=12.0, max_allowed=720.0,
         description="Torque sampling points per electrical cycle.",
     ),
+    # --- Geometry / winding (frequently recommended by the AI but previously
+    # treated as "non-standard" and trimmed). Ranges anchored on the MARS
+    # 12S10P baseline (values in fixed_params_template) with engineering
+    # variation. default range ~ baseline +/-20-50%, physical limits wider.
+    "Stator_Outer_Diameter": ScanParameter(
+        name="Stator_Outer_Diameter",
+        display_name="Stator Outer Diameter",
+        unit="mm",
+        category="Geometry",
+        default_start=160.0, default_stop=240.0, default_step=10.0,
+        min_allowed=50.0, max_allowed=500.0,
+        description="Stator lamination outer diameter (MARS baseline 198mm).",
+    ),
+    "Stator_Inner_Diameter": ScanParameter(
+        name="Stator_Inner_Diameter",
+        display_name="Stator Inner Diameter",
+        unit="mm",
+        category="Geometry",
+        default_start=80.0, default_stop=160.0, default_step=10.0,
+        min_allowed=20.0, max_allowed=400.0,
+        description="Stator lamination inner diameter (MARS baseline 122mm).",
+    ),
+    "Slot_Depth": ScanParameter(
+        name="Slot_Depth",
+        display_name="Slot Depth",
+        unit="mm",
+        category="Geometry",
+        default_start=4.0, default_stop=12.0, default_step=1.0,
+        min_allowed=1.0, max_allowed=30.0,
+        description="Stator slot depth (MARS baseline 7mm).",
+    ),
+    # --- Winding ---
+    "Turns_per_Coil": ScanParameter(
+        name="Turns_per_Coil",
+        display_name="Turns per Coil",
+        unit="",
+        category="Winding",
+        default_start=8.0, default_stop=40.0, default_step=4.0,
+        min_allowed=1.0, max_allowed=100.0,
+        description="Number of turns per coil (MARS baseline 20; ConductorsPerSlot).",
+    ),
+    "Wire_Diameter": ScanParameter(
+        name="Wire_Diameter",
+        display_name="Wire Diameter",
+        unit="mm",
+        category="Winding",
+        default_start=0.8, default_stop=2.5, default_step=0.2,
+        min_allowed=0.1, max_allowed=5.0,
+        description="Magnet wire diameter (MARS baseline 1.63mm).",
+    ),
 }
 
 
@@ -165,6 +217,11 @@ class BoundaryConditions:
         """Parse boundary conditions from a project's JSON dict."""
         if not data:
             return cls()
+        # Normalize legacy/alias BC keys (rated_*, slot_count, ...) to the
+        # canonical keys via the single-source catalog, so older projects using
+        # rated_ names are parsed correctly and BC-key handling stays in one
+        # place (P1-1).
+        data = normalize_bc(data)
         return cls(
             topology=data.get("topology", "SSSR"),
             outer_diameter_mm=_to_float(data.get("outer_diameter_mm")),

+ 361 - 0
web/backend/app/services/strategy_orchestrator.py

@@ -0,0 +1,361 @@
+"""Adaptive simulation loop orchestrator (P3-M2).
+
+Bridges the web-side feasibility-first search (FeasibilityFirstSearch) to the
+local executor through the task system.
+
+Lifecycle:
+  start_loop()    build search from plan parameters -> create the initial
+                  adaptive_batch task (task_type="adaptive_batch")
+  advance_loop()  when the current batch task is completed, read its results
+                  (point_id -> params -> metrics), feed them back into the
+                  search via report_result(), run AI analysis, then either
+                  converge or create the next batch task.
+
+The orchestrator is pull-driven (advance_loop is invoked by a caller / route
+/ scheduler), matching the existing poll-based executor model and keeping the
+task system free of new completion hooks.
+
+Loop state is persisted as JSON under output/adaptive_loops/ so a restart can
+at least recover loop metadata and the current batch task.
+
+All source is ASCII only.
+"""
+import json
+import os
+from datetime import datetime
+from typing import Any, Dict, List, Optional
+
+from ..services.feasibility_search import FeasibilityFirstSearch, ParameterRange
+from ..services.l0_prescreening import L0PreScreeningEngine
+from ..services.task_manager import get_task_manager, TaskManager
+from ..services.result_analyst import AIResultAnalyst
+from ..config import KIMI_API_KEY
+
+# Valid loop phases (mirror the executor/task vocabulary).
+LOOP_PHASE_INIT = "initializing"
+LOOP_PHASE_RUNNING = "running"
+LOOP_PHASE_CONVERGED = "converged"
+LOOP_PHASE_BUDGET_EXHAUSTED = "budget_exhausted"
+LOOP_PHASE_FAILED = "failed"
+
+
+def _loop_state_path(state_dir: str, loop_id: str) -> str:
+    return os.path.join(state_dir, "%s_loop.json" % loop_id)
+
+
+class AdaptiveOrchestrator:
+    """Coordinates adaptive search <-> task system <-> local executor."""
+
+    def __init__(
+        self,
+        task_manager: Optional[TaskManager] = None,
+        state_dir: Optional[str] = None,
+        l0_engine: Optional[L0PreScreeningEngine] = None,
+    ):
+        self.tm = task_manager or get_task_manager()
+        self.l0_engine = l0_engine or L0PreScreeningEngine()
+        self.state_dir = state_dir or os.path.join(
+            os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))),
+            "output", "adaptive_loops",
+        )
+        os.makedirs(self.state_dir, exist_ok=True)
+        self._analyst = AIResultAnalyst()
+        # loop_id -> in-memory search + runtime metadata
+        self._searches: Dict[str, FeasibilityFirstSearch] = {}
+        self._loops: Dict[str, Dict[str, Any]] = {}
+        self._load_state()
+
+    # ------------------------------------------------------------------
+    # Public API
+    # ------------------------------------------------------------------
+    def start_loop(
+        self,
+        loop_id: str,
+        parameters: List[Dict[str, Any]],
+        plan_id: Optional[int] = None,
+        plan_data: Optional[Dict[str, Any]] = None,
+        objective_metric: str = "tavg_nm",
+        objective_direction: str = "maximize",
+        total_budget: int = 80,
+        batch_size: int = 4,
+        initial_samples: int = 16,
+        seed: int = 42,
+    ) -> Dict[str, Any]:
+        """Start an adaptive loop from explicit search parameters.
+
+        parameters: [{"name", "min_value", "max_value", "step"?, "unit"?}]
+        Returns loop status including the initial batch task_id.
+        """
+        if loop_id in self._loops:
+            raise ValueError("Loop %s already exists" % loop_id)
+
+        search = self._build_search(
+            parameters, objective_metric, objective_direction,
+            total_budget, batch_size, initial_samples, seed,
+        )
+        self._searches[loop_id] = search
+        meta = {
+            "loop_id": loop_id,
+            "phase": LOOP_PHASE_INIT,
+            "plan_id": plan_id,
+            "plan_data": plan_data or {},
+            "parameters": parameters,
+            "objective_metric": objective_metric,
+            "objective_direction": objective_direction,
+            "total_budget": total_budget,
+            "batch_size": batch_size,
+            "initial_samples": initial_samples,
+            "current_batch": 0,
+            "current_task_id": None,
+            "n_results": 0,
+            "created_at": datetime.now().isoformat(),
+            "updated_at": datetime.now().isoformat(),
+        }
+        self._loops[loop_id] = meta
+
+        # First batch
+        batch = search.generate_initial_batch()
+        task = self._create_batch_task(loop_id, search, batch, batch_id=0)
+        meta["current_batch"] = 0
+        meta["current_task_id"] = task.get("task_id")
+        meta["phase"] = LOOP_PHASE_RUNNING
+        self._save_state(loop_id)
+        return self._loop_view(loop_id, include_task=task)
+
+    def advance_loop(self, loop_id: str) -> Dict[str, Any]:
+        """Advance one adaptive step: if the current batch task completed,
+        feed results back, analyze, then converge or create next batch."""
+        meta = self._loops.get(loop_id)
+        if meta is None:
+            raise ValueError("Loop %s not found" % loop_id)
+        search = self._searches.get(loop_id)
+        if search is None:
+            raise ValueError("Loop %s has no search (process restarted?)" % loop_id)
+
+        if meta["phase"] in (LOOP_PHASE_CONVERGED, LOOP_PHASE_BUDGET_EXHAUSTED, LOOP_PHASE_FAILED):
+            return self._loop_view(loop_id)
+
+        task_id = meta.get("current_task_id")
+        if not task_id:
+            return self._loop_view(loop_id, message="no current task")
+
+        task = self.tm.get_task(task_id)
+        if task is None:
+            raise ValueError("Loop %s current task %s missing" % (loop_id, task_id))
+
+        if task.get("status") not in ("completed", "failed", "cancelled"):
+            # still running - nothing to do yet
+            return self._loop_view(loop_id, message="batch still running")
+
+        # ---- batch finished: pull results ----
+        results = self.tm.get_task_results(task_id)
+        point_results = (results or {}).get("results", [])
+        if task.get("status") == "failed":
+            meta["phase"] = LOOP_PHASE_FAILED
+            meta["updated_at"] = datetime.now().isoformat()
+            self._save_state(loop_id)
+            return self._loop_view(loop_id, message="batch task failed")
+
+        # Feed results back into the search by point_id
+        n_fed = 0
+        for r in point_results:
+            pid = r.get("point_id")
+            if pid is None:
+                continue
+            metrics = r.get("metrics") or {}
+            if not metrics:
+                # metrics may be flattened on the result top level
+                for k, v in r.items():
+                    if k not in ("point_id", "params", "status", "error", "point_index", "solve_time_s"):
+                        if isinstance(v, (int, float)):
+                            metrics[k] = float(v)
+            status = "ok" if r.get("status") == "OK" else "failed"
+            search.report_result(int(pid), metrics, status)
+            n_fed += 1
+        meta["n_results"] += n_fed
+
+        # Optional AI analysis on accumulated results (best-effort; only
+        # when the AI backend is configured, else keep quantitative only).
+        try:
+            analysis = None
+            if KIMI_API_KEY:
+                analysis = self._analyst.analyze(
+                    results=self._collect_results(search),
+                    targets=None,
+                    fidelity="L3",
+                    scan_parameters=[p.name for p in search.parameters],
+                )
+            meta["latest_analysis"] = analysis
+        except Exception as exc:  # noqa: BLE001 - analysis is best-effort
+            meta["latest_analysis_error"] = str(exc)
+
+        # ---- decide next step ----
+        state = search.get_state_summary()
+        if state.get("convergence_status") == "converged":
+            meta["phase"] = LOOP_PHASE_CONVERGED
+        elif state.get("remaining_budget", 0) <= 0:
+            meta["phase"] = LOOP_PHASE_BUDGET_EXHAUSTED
+        else:
+            nxt = search.select_next_batch()
+            if not nxt:
+                meta["phase"] = LOOP_PHASE_CONVERGED
+            else:
+                next_batch_id = int(meta.get("current_batch", 0)) + 1
+                task = self._create_batch_task(loop_id, search, nxt, batch_id=next_batch_id)
+                meta["current_batch"] = next_batch_id
+                meta["current_task_id"] = task.get("task_id")
+                meta["phase"] = LOOP_PHASE_RUNNING
+
+        meta["updated_at"] = datetime.now().isoformat()
+        self._save_state(loop_id)
+        return self._loop_view(loop_id)
+
+    def get_loop_status(self, loop_id: str) -> Dict[str, Any]:
+        if loop_id not in self._loops:
+            raise ValueError("Loop %s not found" % loop_id)
+        return self._loop_view(loop_id)
+
+    def list_loops(self) -> List[Dict[str, Any]]:
+        return [
+            {
+                "loop_id": m["loop_id"],
+                "phase": m["phase"],
+                "current_batch": m.get("current_batch"),
+                "n_results": m.get("n_results"),
+                "updated_at": m.get("updated_at"),
+            }
+            for m in self._loops.values()
+        ]
+
+    # ------------------------------------------------------------------
+    # Internals
+    # ------------------------------------------------------------------
+    def _build_search(
+        self, parameters, objective_metric, objective_direction,
+        total_budget, batch_size, initial_samples, seed,
+    ) -> FeasibilityFirstSearch:
+        ranges = []
+        for p in parameters:
+            ranges.append(ParameterRange(
+                name=p["name"],
+                min_value=float(p["min_value"]),
+                max_value=float(p["max_value"]),
+                step=float(p["step"]) if p.get("step") else None,
+                unit=p.get("unit", ""),
+                description=p.get("description", ""),
+            ))
+        if not ranges:
+            raise ValueError("at least one search parameter required")
+        return FeasibilityFirstSearch(
+            parameters=ranges,
+            l0_engine=self.l0_engine,
+            total_budget=int(total_budget),
+            batch_size=int(batch_size),
+            initial_samples=int(initial_samples),
+            objective_metric=objective_metric,
+            objective_direction=objective_direction,
+            seed=int(seed),
+        )
+
+    def _create_batch_task(self, loop_id, search, batch, batch_id: int) -> Dict[str, Any]:
+        meta = self._loops[loop_id]
+        parameters = []
+        point_ids = []
+        for p in batch:
+            pid = getattr(p, "id", None)
+            params = getattr(p, "params", None)
+            if params is None and isinstance(p, dict):
+                params = p.get("params")
+                pid = p.get("point_id", p.get("id"))
+            point_ids.append(pid)
+            item = dict(params or {})
+            item["point_id"] = pid
+            parameters.append(item)
+        task = self.tm.create_task(
+            plan_id=meta.get("plan_id"),
+            plan_data=meta.get("plan_data") or {},
+            parameters=parameters,
+            task_name="adaptive-%s-b%s" % (loop_id, batch_id),
+            task_type="adaptive_batch",
+            loop_id=loop_id,
+            batch_id=batch_id,
+            point_ids=point_ids,
+            dynamic=True,
+        )
+        return task
+
+    def _collect_results(self, search) -> List[Dict[str, Any]]:
+        """Flatten search completed points to [{point_id, **metrics}]."""
+        out = []
+        for p in search.state.get_completed_points():
+            entry = {"point_id": p.id, "status": p.status}
+            entry.update(p.metrics)
+            out.append(entry)
+        return out
+
+    def _loop_view(self, loop_id, include_task=None, message=None) -> Dict[str, Any]:
+        meta = self._loops[loop_id]
+        search = self._searches.get(loop_id)
+        view = {
+            "loop_id": loop_id,
+            "phase": meta["phase"],
+            "plan_id": meta.get("plan_id"),
+            "current_batch": meta.get("current_batch"),
+            "current_task_id": meta.get("current_task_id"),
+            "n_results": meta.get("n_results", 0),
+            "updated_at": meta.get("updated_at"),
+            "created_at": meta.get("created_at"),
+            "search_state": search.get_state_summary() if search else None,
+        }
+        if include_task:
+            view["batch_task"] = include_task
+        if message:
+            view["message"] = message
+        if meta.get("latest_analysis"):
+            view["latest_analysis"] = meta["latest_analysis"]
+        return view
+
+    # ------------------------------------------------------------------
+    # Persistence (best-effort)
+    # ------------------------------------------------------------------
+    def _save_state(self, loop_id: str) -> None:
+        meta = self._loops.get(loop_id)
+        if meta is None:
+            return
+        search = self._searches.get(loop_id)
+        payload = dict(meta)
+        try:
+            if search is not None and hasattr(search, "export_state"):
+                payload["search_export"] = search.export_state()
+        except Exception:
+            pass
+        path = _loop_state_path(self.state_dir, loop_id)
+        with open(path, "w", encoding="utf-8") as f:
+            json.dump(payload, f, ensure_ascii=False, indent=2)
+
+    def _load_state(self) -> None:
+        if not os.path.isdir(self.state_dir):
+            return
+        for fname in os.listdir(self.state_dir):
+            if not fname.endswith("_loop.json"):
+                continue
+            path = os.path.join(self.state_dir, fname)
+            try:
+                with open(path, "r", encoding="utf-8") as f:
+                    meta = json.load(f)
+                if isinstance(meta, dict) and meta.get("loop_id"):
+                    # metadata recovered; search object is rebuilt on demand
+                    self._loops[meta["loop_id"]] = meta
+            except Exception:
+                continue
+
+
+# Module-level singleton (mirrors get_task_manager pattern).
+_orchestrator: Optional[AdaptiveOrchestrator] = None
+
+
+def get_orchestrator() -> AdaptiveOrchestrator:
+    global _orchestrator
+    if _orchestrator is None:
+        _orchestrator = AdaptiveOrchestrator()
+    return _orchestrator

+ 82 - 0
web/backend/app/services/task_contract.py

@@ -0,0 +1,82 @@
+"""Unified task state vocabulary + adaptive-batch field contract (P3-M4).
+
+Reconciles the two scheduling vocabularies that existed in parallel:
+
+  - TaskManager / Task ORM:
+        pending -> dispatched -> running -> completed / failed / cancelled
+  - BatchScheduler:
+        queued  -> running -> completed / failed / cancelled
+
+This module is the single source of truth for status normalization and the
+adaptive-batch fields shared across TaskManager, BatchScheduler and the
+adaptive orchestrator. It must stay dependency-free so any of those services
+can import it without cycles.
+
+All source is ASCII only.
+"""
+from typing import Any, Dict, Optional
+
+# Canonical statuses (TaskManager vocabulary).
+TASK_STATUS_PENDING = "pending"
+TASK_STATUS_DISPATCHED = "dispatched"
+TASK_STATUS_RUNNING = "running"
+TASK_STATUS_COMPLETED = "completed"
+TASK_STATUS_COMPLETED_WITH_ERRORS = "completed_with_errors"
+TASK_STATUS_FAILED = "failed"
+TASK_STATUS_CANCELLED = "cancelled"
+
+# BatchScheduler-only vocabulary.
+TASK_STATUS_QUEUED = "queued"
+
+TERMINAL_STATUSES = {
+    TASK_STATUS_COMPLETED,
+    TASK_STATUS_COMPLETED_WITH_ERRORS,
+    TASK_STATUS_FAILED,
+    TASK_STATUS_CANCELLED,
+}
+
+# scheduler / legacy word -> canonical word
+STATUS_ALIASES = {
+    TASK_STATUS_QUEUED: TASK_STATUS_PENDING,
+    TASK_STATUS_PENDING: TASK_STATUS_PENDING,
+    TASK_STATUS_DISPATCHED: TASK_STATUS_DISPATCHED,
+    TASK_STATUS_RUNNING: TASK_STATUS_RUNNING,
+    TASK_STATUS_COMPLETED: TASK_STATUS_COMPLETED,
+    TASK_STATUS_COMPLETED_WITH_ERRORS: TASK_STATUS_COMPLETED,
+    TASK_STATUS_FAILED: TASK_STATUS_FAILED,
+    TASK_STATUS_CANCELLED: TASK_STATUS_CANCELLED,
+    "canceled": TASK_STATUS_CANCELLED,
+}
+
+# Adaptive-batch fields introduced in P3-M2, shared by Task ORM,
+# BatchScheduler and AdaptiveOrchestrator.
+ADAPTIVE_BATCH_FIELDS = ("task_type", "loop_id", "batch_id", "point_ids", "dynamic")
+
+
+def normalize_status(status: Optional[str]) -> Optional[str]:
+    """Map any scheduler/legacy status word to the canonical status.
+
+    Unknown words pass through unchanged so validation can report them.
+    """
+    if status is None:
+        return None
+    return STATUS_ALIASES.get(str(status), str(status))
+
+
+def is_terminal(status: Optional[str]) -> bool:
+    return normalize_status(status) in TERMINAL_STATUSES
+
+
+def merge_adaptive_fields(task_info: Dict[str, Any], **kwargs) -> Dict[str, Any]:
+    """Merge adaptive-batch fields into a task dict (no-ops stay default).
+
+    Accepts kwargs that may include task_type/loop_id/batch_id/point_ids/
+    dynamic and sets the canonical defaults when absent.
+    """
+    out = dict(task_info)
+    out.setdefault("task_type", kwargs.get("task_type", "scan"))
+    out.setdefault("loop_id", kwargs.get("loop_id"))
+    out.setdefault("batch_id", kwargs.get("batch_id"))
+    out.setdefault("point_ids", kwargs.get("point_ids") or [])
+    out.setdefault("dynamic", bool(kwargs.get("dynamic", False)))
+    return out

Деякі файли не було показано, через те що забагато файлів було змінено