瀏覽代碼

feat(results): Excel export of plan results with engineer-focused EM/thermal columns

- new src/afmcore/xlsx_report.py: two-row-header xlsx writer adapted from
  torqrippswap write_summary_xlsx (category groups, freeze panes, autofilter)
- backend GET /api/plans/{id}/export-xlsx with RFC 5987 Chinese filenames
- frontend PlanDetail: 30+ metric Chinese labels, thermal columns auto-added
  to default view for thermal tasks, new Export Excel button
- tests: scripts/test_xlsx_export.py 30 checks (unit + TestClient API)
- requirements: openpyxl>=3.1.0
carlin 15 小時之前
父節點
當前提交
1dd045f759

+ 8 - 0
docs/CONVERSATION_LOG.md

@@ -1171,3 +1171,11 @@ ambient_temperature=25.0)` 单点端到端 PASS——7 项热指标落盘,温
 **本次完成**:① 分离式稳态热 @25°C(EM 170.3s + 热 7.5s):绕组 52.59、磁钢 67.61、温升 27.57、热阻 5.657 K/W、效率 86.06%;② 磁热耦合 @25°C(405.3s):绕组 54.86、磁钢 62.74、温升 29.91、热阻 2.058、转矩 0.545 Nm(较分离式 +4.3%)、效率 84.91%(−1.2pp)。数据在 output/thermal_validation_20260904_{123600,124142}/raw/。
 **结论**:分离式低估转矩/高估效率,精细评估用 coupled;两档磁钢温度均远离退磁区。
 **遗留**:无。
+
+## 2026-09-04 — 仿真结果 Web 展示增强 + Excel 导出(TEST-066)
+
+**用户要求**:分析工程师最关心的电磁/温度指标,Web 端仿真结束后显示主要结果,可导出完整 Excel(格式参考 torqrippswap 的 scan_results.xlsx)。
+**本次完成**:① 新建 `src/afmcore/xlsx_report.py`(移植 torqrippswap write_summary_xlsx 双行表头格式,ASCII 源码);② 后端 `GET /api/plans/{id}/export-xlsx`(中文文件名 RFC 5987、变量中文表头、仅导出存在指标列);③ 前端 PlanDetail 结果表 labelMap 扩充 30+ 指标、热任务自动加入 5 热核心列、新增"导出 Excel"按钮;④ requirements.txt 补 openpyxl。
+**验证**:test_xlsx_export.py 30 项全过;真实库 plan 31 导出 56 列(电磁 23 + 热 1)、plan 23(80 FAILED)29 列;vue-tsc 0 错误。
+**坑**:Edit 工具对 ASCII 化文件需按 \u 转义字面量匹配,直接写中文匹配不上(改用 python heredoc 补丁)。
+**遗留**:无。

+ 25 - 0
docs/TEST_RECORDS.md

@@ -2361,3 +2361,28 @@ solver.run_single_point(thermal_mode)(coupled 分支调 do_magnetic_thermal_ca
 ### 结论
 
 耦合档与分离档结果差异在合理量级(转矩差 4.3%),证明分离式会**低估**转矩、**高估**效率约 1.2 个百分点;精细评估建议用 coupled。
+
+---
+
+## TEST-066:仿真结果 Excel 导出(工程师关键指标 + 完整结果)
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-04 |
+| 测试目的 | 用户要求:Web 端显示工程师最关心的电磁+温度指标,并能导出完整仿真结果为 Excel(格式参考 torqrippswap scan_results.xlsx) |
+
+### 实现
+
+1. **新建 `src/afmcore/xlsx_report.py`**:移植 torqrippswap `write_summary_xlsx`(双行表头:行1 合并类目分组 + 行2 列名、冻结窗格、自动筛选),并按 afmcore 规范改为全 ASCII 源码(中文用 \u 转义)。`build_export_columns()` 按"扫描信息/电磁性能/热性能/结构性能/其他指标"分组组装列,仅导出数据中真实存在的指标列(EM-only 任务无热性能列)。
+2. **后端**:`plans.py` 新增 `GET /api/plans/{id}/export-xlsx`,从 SimulationResult 表读结果,方案变量名映射中文表头(name_cn[unit]),RFC 5987 中文文件名,临时文件 + BackgroundTask 清理。requirements.txt 补 openpyxl>=3.1.0。
+3. **前端 PlanDetail.vue**:结果表格 labelMap 扩充 30+ 中英文指标名(热/电/结构全套);thermal_mode 非 off 的结果自动把 5 个热核心列(绕组温度/绕组热点/磁钢温度/定子温度/温升)加入默认列;"导出 Excel" 按钮放在"下载 CSV"旁。
+
+### 验证
+
+- `scripts/test_xlsx_export.py`(新建):30 项全过(18 单元 + 12 API),覆盖列组装、双行表头结构、冻结/筛选、热列条件出现、404/400 错误路径。
+- 真实库验证:plan 31(3 点 OK 结果)导出 56 列,含电磁性能 23 列 + 热性能(磁钢温度);plan 23(80 点全 FAILED)导出 29 列仅扫描信息,失败行错误信息完整。
+- vue-tsc 0 错误。
+
+### 结论
+
+工程师最关心指标(转矩/脉动/效率/损耗分解/电气/温度)在 Web 表格默认可视,完整结果一键导出 xlsx,格式与 torqrippswap 一致。

+ 234 - 0
scripts/test_xlsx_export.py

@@ -0,0 +1,234 @@
+"""Tests for the plan-results Excel export (TEST-066).
+
+Covers:
+1. afmcore.xlsx_report unit level: column assembly (thermal columns appear
+   only when thermal metrics are present) and the two-row-header writer.
+2. API level: GET /api/plans/{id}/export-xlsx on a temp SQLite DB via
+   FastAPI TestClient - verifies xlsx payload, header structure, category
+   groups, error paths (404 / 400).
+
+Run: python scripts/test_xlsx_export.py   (from repo root)
+All source is ASCII only.
+"""
+from __future__ import annotations
+
+import os
+import sys
+import tempfile
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+BACKEND_DIR = REPO_ROOT / "web" / "backend"
+SRC_DIR = REPO_ROOT / "src"
+for p in (str(BACKEND_DIR), str(SRC_DIR)):
+    if p not in sys.path:
+        sys.path.insert(0, p)
+
+# Isolate the DB before any app module is imported.
+_tmp_db = tempfile.NamedTemporaryFile(prefix="afm_xlsx_test_", suffix=".db", delete=False)
+_tmp_db.close()
+os.environ["AFM_DB_PATH"] = _tmp_db.name
+
+FAILURES = []
+
+
+def check(name: str, cond: bool, detail: str = "") -> None:
+    tag = "PASS" if cond else "FAIL"
+    print(f"[{tag}] {name}" + (f" -- {detail}" if detail and not cond else ""))
+    if not cond:
+        FAILURES.append(name)
+
+
+# ---------------------------------------------------------------------------
+# Part 1: unit tests for afmcore.xlsx_report
+# ---------------------------------------------------------------------------
+
+from afmcore.xlsx_report import (  # noqa: E402
+    CAT_EM,
+    CAT_SCAN_INFO,
+    CAT_THERMAL,
+    build_export_columns,
+    write_summary_xlsx,
+)
+
+
+def unit_tests() -> None:
+    params = ["Airgap", "Magnet_Thickness"]
+
+    # Case A: EM-only data (thermal_mode off) - no thermal columns.
+    keys, cats, headers = build_export_columns(
+        params, ["tavg_nm", "ripple_pct", "efficiency_pct", "total_losses_w"]
+    )
+    check("unit: fixed cols first", keys[:4] == ["run_index", "status", "solve_time_s", "error"])
+    check("unit: params in scan-info group",
+          keys[4:6] == params and all(c == CAT_SCAN_INFO for c in cats[4:6]))
+    check("unit: no thermal columns for EM-only data", CAT_THERMAL not in cats)
+    check("unit: EM group present", CAT_EM in cats)
+    check("unit: tavg header", headers[keys.index("tavg_nm")] == "\u5e73\u5747\u8f6c\u77e9[Nm]")
+
+    # Case B: thermal data present - thermal group appears after EM.
+    keys, cats, headers = build_export_columns(
+        params,
+        ["tavg_nm", "efficiency_pct", "winding_temp_c", "magnet_temp_c",
+         "temp_rise_c", "thermal_resistance_k_w"],
+        {"Airgap": "\u6c14\u9699[mm]"},
+    )
+    check("unit: thermal group present", CAT_THERMAL in cats)
+    check("unit: thermal after EM", cats.index(CAT_THERMAL) > cats.index(CAT_EM))
+    check("unit: param label used", headers[keys.index("Airgap")] == "\u6c14\u9699[mm]")
+    check("unit: winding temp header",
+          headers[keys.index("winding_temp_c")] == "\u7ed5\u7ec4\u6e29\u5ea6[C]")
+
+    # Case C: unknown metric key still exported (no data loss).
+    keys, cats, headers = build_export_columns([], ["tavg_nm", "mystery_metric"])
+    check("unit: unknown metric kept", "mystery_metric" in keys)
+
+    # Writer round-trip: write 2 data rows, read back structure.
+    out = Path(tempfile.mkdtemp()) / "roundtrip.xlsx"
+    rows = [
+        {"run_index": 1, "status": "OK", "solve_time_s": 170.3, "error": "",
+         "Airgap": 1.2, "tavg_nm": 0.545, "winding_temp_c": 54.86},
+        {"run_index": 2, "status": "FAILED", "solve_time_s": 0, "error": "boom",
+         "Airgap": 1.4, "tavg_nm": "", "winding_temp_c": ""},
+    ]
+    write_summary_xlsx(out, keys, cats, headers, rows)
+
+    import openpyxl
+    wb = openpyxl.load_workbook(out)
+    ws = wb.active
+    check("unit: sheet name", ws.title == "scan_results")
+    check("unit: row1 is category groups", ws.cell(1, 1).value == CAT_SCAN_INFO)
+    check("unit: row2 is headers", ws.cell(2, 1).value == "\u5e8f\u53f7")
+    check("unit: data starts row 3", ws.cell(3, 1).value == 1)
+    check("unit: failed row error cell", ws.cell(4, 4).value == "boom")
+    check("unit: freeze panes", ws.freeze_panes == "B3")
+    check("unit: autofilter", ws.auto_filter.ref == f"A2:{openpyxl.utils.get_column_letter(len(keys))}4")
+    check("unit: merged category groups", len(ws.merged_cells.ranges) > 0)
+
+
+# ---------------------------------------------------------------------------
+# Part 2: API tests against a temp DB via TestClient
+# ---------------------------------------------------------------------------
+
+def api_tests() -> None:
+    from fastapi.testclient import TestClient
+    from app.main import app
+    from app.database import SessionLocal, init_db
+    from app.models.simulation_plan import SimulationPlan
+    from app.models.simulation_result import SimulationResult
+
+    init_db()
+    client = TestClient(app)
+
+    with SessionLocal() as db:
+        from app.models.project import Project
+
+        project = Project(name="xlsx-export-test-project", topology="SSSR")
+        db.add(project)
+        db.commit()
+        db.refresh(project)
+        project_pk = project.id
+
+        plan = SimulationPlan(
+            plan_id="SP-XLSX-TEST", name="\u70ed\u4eff\u771f\u5bfc\u51fa\u9a8c\u8bc1",
+            status="completed", project_id=project_pk,
+        )
+        plan.set_plan_dict({
+            "variables": [
+                {"name": "Airgap", "name_cn": "\u6c14\u9699", "unit": "mm",
+                 "values": [1.0, 1.2]},
+            ],
+        })
+        db.add(plan)
+        db.commit()
+        db.refresh(plan)
+        plan_pk = plan.id
+
+        rows = [
+            (1, "OK", 178.0, "", {"Airgap": 1.0},
+             {"tavg_nm": 0.522, "ripple_pct": 2.82, "efficiency_pct": 86.06,
+              "total_losses_w": 41.9, "winding_temp_c": 52.59,
+              "magnet_temp_c": 67.61, "temp_rise_c": 27.57,
+              "thermal_resistance_k_w": 5.657}),
+            (2, "OK", 181.2, "", {"Airgap": 1.2},
+             {"tavg_nm": 0.545, "ripple_pct": 2.99, "efficiency_pct": 84.91,
+              "total_losses_w": 47.1, "winding_temp_c": 54.86,
+              "magnet_temp_c": 62.74, "temp_rise_c": 29.91,
+              "thermal_resistance_k_w": 2.058}),
+        ]
+        for idx, status, secs, err, params, metrics in rows:
+            r = SimulationResult(
+                plan_id=plan_pk, run_index=idx, status=status,
+                solve_time_s=secs, error_message=err,
+            )
+            r.set_params(params)
+            r.set_metrics(metrics)
+            db.add(r)
+        # A second plan with EM-only results (thermal_mode off).
+        plan2 = SimulationPlan(plan_id="SP-XLSX-EM", name="\u7eaf\u7535\u626b\u63cf", status="completed", project_id=project_pk)
+        plan2.set_plan_dict({"variables": []})
+        db.add(plan2)
+        db.commit()
+        db.refresh(plan2)
+        r2 = SimulationResult(plan_id=plan2.id, run_index=1, status="OK",
+                              solve_time_s=170.0)
+        r2.set_params({})
+        r2.set_metrics({"tavg_nm": 0.5, "efficiency_pct": 86.0})
+        db.add(r2)
+        db.commit()
+        plan2_pk = plan2.id
+
+    # 1) Happy path: thermal plan export.
+    resp = client.get(f"/api/plans/{plan_pk}/export-xlsx")
+    check("api: export 200", resp.status_code == 200, f"got {resp.status_code}")
+    check("api: xlsx content-type",
+          "spreadsheetml" in resp.headers.get("content-type", ""))
+    disp = resp.headers.get("content-disposition", "")
+    check("api: attachment disposition", "attachment" in disp and "filename*=" in disp)
+
+    out = Path(tempfile.mkdtemp()) / "api_export.xlsx"
+    out.write_bytes(resp.content)
+    import openpyxl
+    wb = openpyxl.load_workbook(out)
+    ws = wb.active
+    headers = [ws.cell(2, c).value for c in range(1, ws.max_column + 1)]
+    cats = [ws.cell(1, c).value for c in range(1, ws.max_column + 1)]
+    check("api: thermal headers present",
+          "\u7ed5\u7ec4\u6e29\u5ea6[C]" in headers and "\u70ed\u963b[K/W]" in headers)
+    check("api: thermal category present", CAT_THERMAL in cats)
+    check("api: param header uses Chinese label", "\u6c14\u9699[mm]" in headers)
+    check("api: two data rows", ws.max_row == 4)
+    check("api: row3 winding temp", abs(ws.cell(3, headers.index("\u7ed5\u7ec4\u6e29\u5ea6[C]") + 1).value - 52.59) < 1e-6)
+
+    # 2) EM-only plan: no thermal columns.
+    resp2 = client.get(f"/api/plans/{plan2_pk}/export-xlsx")
+    check("api: EM-only export 200", resp2.status_code == 200)
+    out2 = Path(tempfile.mkdtemp()) / "api_export_em.xlsx"
+    out2.write_bytes(resp2.content)
+    ws2 = openpyxl.load_workbook(out2).active
+    headers2 = [ws2.cell(2, c).value for c in range(1, ws2.max_column + 1)]
+    check("api: EM-only has no thermal columns",
+          "\u7ed5\u7ec4\u6e29\u5ea6[C]" not in headers2 and CAT_THERMAL not in
+          [ws2.cell(1, c).value for c in range(1, ws2.max_column + 1)])
+
+    # 3) Error paths.
+    check("api: 404 for unknown plan", client.get("/api/plans/99999/export-xlsx").status_code == 404)
+    with SessionLocal() as db:
+        empty = SimulationPlan(plan_id="SP-XLSX-EMPTY", name="\u7a7a\u65b9\u6848", status="draft", project_id=project_pk)
+        empty.set_plan_dict({"variables": []})
+        db.add(empty)
+        db.commit()
+        db.refresh(empty)
+        empty_pk = empty.id
+    check("api: 400 when no results",
+          client.get(f"/api/plans/{empty_pk}/export-xlsx").status_code == 400)
+
+
+if __name__ == "__main__":
+    unit_tests()
+    api_tests()
+    print()
+    if FAILURES:
+        print(f"FAILED: {len(FAILURES)} -> {FAILURES}")
+        sys.exit(1)
+    print("ALL PASS")

+ 249 - 0
src/afmcore/xlsx_report.py

@@ -0,0 +1,249 @@
+"""Excel (.xlsx) scan-result report writer.
+
+Two-row header format (category group row + column header row), freeze panes
+and autofilter. Adapted from torqrippswap src/solver.py write_summary_xlsx so
+exported files match the format engineers already use.
+
+Consumers: web/backend (plan results export endpoint).
+
+All source is ASCII only (project rule: source files ASCII, user-facing
+Chinese text written as unicode escapes).
+"""
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, Dict, List, Sequence, Tuple
+
+# ---------------------------------------------------------------------------
+# Category names (row-1 groups)
+# ---------------------------------------------------------------------------
+
+CAT_SCAN_INFO = "\u626b\u63cf\u4fe1\u606f"
+CAT_EM = "\u7535\u78c1\u6027\u80fd"
+CAT_THERMAL = "\u70ed\u6027\u80fd"
+CAT_STRUCTURAL = "\u7ed3\u6784\u6027\u80fd"
+CAT_OTHER = "\u5176\u4ed6\u6307\u6807"
+
+# ---------------------------------------------------------------------------
+# Column registry: metric key -> (category, Chinese header with unit)
+#
+# Engineer-focused key metrics first (electromagnetic + thermal), in the
+# order engineers scan a result table: torque/ripple/efficiency, losses
+# breakdown, electrical, thermal.
+# ---------------------------------------------------------------------------
+
+FIXED_COLUMNS: List[Tuple[str, str, str]] = [
+    ("run_index", CAT_SCAN_INFO, "\u5e8f\u53f7"),
+    ("status", CAT_SCAN_INFO, "\u72b6\u6001"),
+    ("solve_time_s", CAT_SCAN_INFO, "\u8017\u65f6[s]"),
+    ("error", CAT_SCAN_INFO, "\u9519\u8bef\u4fe1\u606f"),
+]
+
+EM_COLUMNS: List[Tuple[str, str]] = [
+    ("tavg_nm", "\u5e73\u5747\u8f6c\u77e9[Nm]"),
+    ("ripple_pct", "\u8f6c\u77e9\u8109\u52a8[%]"),
+    ("ripple_nm", "\u8f6c\u77e9\u8109\u52a8[Nm]"),
+    ("tmax_nm", "\u6700\u5927\u8f6c\u77e9[Nm]"),
+    ("tmin_nm", "\u6700\u5c0f\u8f6c\u77e9[Nm]"),
+    ("shaft_torque_nm", "\u8f74\u8f6c\u77e9[Nm]"),
+    ("stall_torque_nm", "\u5835\u8f6c\u8f6c\u77e9[Nm]"),
+    ("torque_constant", "\u8f6c\u77e9\u5e38\u6570[Nm/A]"),
+    ("efficiency_pct", "\u6548\u7387[%]"),
+    ("output_power_w", "\u8f93\u51fa\u529f\u7387[W]"),
+    ("em_power_w", "\u7535\u78c1\u529f\u7387[W]"),
+    ("input_power_w", "\u8f93\u5165\u529f\u7387[W]"),
+    ("power_factor", "\u529f\u7387\u56e0\u6570"),
+    ("total_losses_w", "\u603b\u635f\u8017[W]"),
+    ("copper_loss_w", "\u94dc\u8017[W]"),
+    ("iron_loss_w", "\u94c1\u8017[W]"),
+    ("magnet_loss_w", "\u78c1\u94a2\u635f\u8017[W]"),
+    ("back_emf_v", "\u53cd\u7535\u52a8\u52bf\u7ebf\u7535\u538b[V]"),
+    ("back_emf_thd_pct", "\u53cd\u7535\u52a8\u52bf\u8c10\u6ce2[%]"),
+    ("line_current_rms_a", "\u7ebf\u7535\u6d41\u6709\u6548\u503c[A]"),
+    ("phase_current_peak_a", "\u76f8\u7535\u6d41\u5cf0\u503c[A]"),
+    ("shaft_speed_rpm", "\u8f6c\u901f[rpm]"),
+    ("no_load_speed_rpm", "\u7a7a\u8f7d\u8f6c\u901f[rpm]"),
+]
+
+THERMAL_COLUMNS: List[Tuple[str, str]] = [
+    ("winding_temp_c", "\u7ed5\u7ec4\u6e29\u5ea6[C]"),
+    ("winding_hotspot_temp_c", "\u7ed5\u7ec4\u70ed\u70b9\u6e29\u5ea6[C]"),
+    ("magnet_temp_c", "\u78c1\u94a2\u6e29\u5ea6[C]"),
+    ("stator_temp_c", "\u5b9a\u5b50\u6e29\u5ea6[C]"),
+    ("bearing_temp_c", "\u8f74\u627f\u6e29\u5ea6[C]"),
+    ("temp_rise_c", "\u6e29\u5347[C]"),
+    ("thermal_resistance_k_w", "\u70ed\u963b[K/W]"),
+]
+
+STRUCTURAL_COLUMNS: List[Tuple[str, str]] = [
+    ("axial_force_n", "\u8f74\u5411\u529b[N]"),
+    ("radial_force_n", "\u5f84\u5411\u529b[N]"),
+    ("max_stress_mpa", "\u6700\u5927\u5e94\u529b[MPa]"),
+    ("deformation_mm", "\u6700\u5927\u53d8\u5f62[mm]"),
+]
+
+_KNOWN_KEYS = frozenset(
+    k for k, _ in EM_COLUMNS + THERMAL_COLUMNS + STRUCTURAL_COLUMNS
+)
+
+
+# ---------------------------------------------------------------------------
+# Column assembly
+# ---------------------------------------------------------------------------
+
+def build_export_columns(
+    param_names: Sequence[str],
+    present_metric_keys: Sequence[str],
+    param_labels: Dict[str, str] | None = None,
+) -> Tuple[List[str], List[str], List[str]]:
+    """Assemble (col_keys, col_categories, col_headers) for the export.
+
+    Args:
+        param_names: scan/fixed parameter names present in the result rows.
+        present_metric_keys: metric keys actually present in the data (union
+            across rows). Only columns with at least one value are exported.
+        param_labels: optional {param_name: display header} map (Chinese
+            names with units); falls back to the raw parameter name.
+
+    Returns:
+        (col_keys, col_categories, col_headers) for write_summary_xlsx().
+    """
+    present = set(present_metric_keys)
+    labels = param_labels or {}
+
+    col_keys: List[str] = []
+    col_categories: List[str] = []
+    col_headers: List[str] = []
+
+    for key, cat, header in FIXED_COLUMNS:
+        col_keys.append(key)
+        col_categories.append(cat)
+        col_headers.append(header)
+
+    for name in param_names:
+        if name in col_keys:
+            continue
+        col_keys.append(name)
+        col_categories.append(CAT_SCAN_INFO)
+        col_headers.append(labels.get(name, name))
+
+    for key, header in EM_COLUMNS:
+        if key in present:
+            col_keys.append(key)
+            col_categories.append(CAT_EM)
+            col_headers.append(header)
+
+    for key, header in THERMAL_COLUMNS:
+        if key in present:
+            col_keys.append(key)
+            col_categories.append(CAT_THERMAL)
+            col_headers.append(header)
+
+    for key, header in STRUCTURAL_COLUMNS:
+        if key in present:
+            col_keys.append(key)
+            col_categories.append(CAT_STRUCTURAL)
+            col_headers.append(header)
+
+    # Any remaining metric the data carries but the registry does not know:
+    # keep it under CAT_OTHER so the export is complete, not lossy.
+    for key in present:
+        if key in _KNOWN_KEYS or key in col_keys:
+            continue
+        col_keys.append(key)
+        col_categories.append(CAT_OTHER)
+        col_headers.append(key)
+
+    return col_keys, col_categories, col_headers
+
+
+# ---------------------------------------------------------------------------
+# Writer (adapted from torqrippswap src/solver.py write_summary_xlsx)
+# ---------------------------------------------------------------------------
+
+def write_summary_xlsx(
+    xlsx_path: str | Path,
+    col_keys: List[str],
+    col_categories: List[str],
+    col_headers: List[str],
+    data_rows: List[Dict[str, Any]],
+    sheet_title: str = "scan_results",
+) -> None:
+    """Write scan results as .xlsx with a two-row header.
+
+    Row 1 = merged category groups, row 2 = column headers, data from row 3.
+    Panes frozen at row 3; autofilter on the header row.
+
+    Args:
+        xlsx_path: output file path.
+        col_keys: dict keys used to look up values in each data row.
+        col_categories: category (row-1 group) name per column.
+        col_headers: header text (row 2) per column.
+        data_rows: list of dicts keyed by col_keys.
+        sheet_title: worksheet name.
+    """
+    from openpyxl import Workbook
+    from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
+    from openpyxl.utils import get_column_letter
+
+    wb = Workbook()
+    ws = wb.active
+    ws.title = sheet_title
+
+    n_cols = len(col_keys)
+    header_font = Font(bold=True, size=10)
+    header_fill = PatternFill(
+        start_color="F1F5F9", end_color="F1F5F9", fill_type="solid"
+    )
+    cat_fill = PatternFill(
+        start_color="E2E8F0", end_color="E2E8F0", fill_type="solid"
+    )
+    thin = Side(style="thin", color="94A3B8")
+    thin_border = Border(left=thin, right=thin, top=thin, bottom=thin)
+    center = Alignment(horizontal="center", vertical="center", wrap_text=True)
+
+    # Row 2: individual column headers.
+    for ci in range(n_cols):
+        cell = ws.cell(row=2, column=ci + 1, value=col_headers[ci])
+        cell.font = header_font
+        cell.fill = header_fill
+        cell.alignment = center
+        cell.border = thin_border
+
+    # Row 1: category groups (merge consecutive same-category columns).
+    ci = 0
+    while ci < n_cols:
+        cat = col_categories[ci]
+        start = ci
+        while ci < n_cols and col_categories[ci] == cat:
+            ci += 1
+        end = ci - 1
+        if start == end:
+            cell = ws.cell(row=1, column=start + 1, value=cat)
+        else:
+            ws.merge_cells(
+                start_row=1, start_column=start + 1,
+                end_row=1, end_column=end + 1,
+            )
+            cell = ws.cell(row=1, column=start + 1, value=cat)
+        cell.font = Font(bold=True, size=10, color="1E293B")
+        cell.fill = cat_fill
+        cell.alignment = center
+        cell.border = thin_border
+
+    # Data rows (row 3+).
+    for ri, row_data in enumerate(data_rows):
+        for ci in range(n_cols):
+            val = row_data.get(col_keys[ci], "")
+            cell = ws.cell(row=ri + 3, column=ci + 1, value=val)
+            cell.border = thin_border
+            cell.alignment = Alignment(vertical="center")
+
+    # Freeze panes below the two header rows and after the index column.
+    ws.freeze_panes = "B3"
+
+    # Auto-filter on the header row (any column count).
+    last_col = get_column_letter(n_cols)
+    ws.auto_filter.ref = f"A2:{last_col}{len(data_rows) + 2}"
+
+    wb.save(str(xlsx_path))

+ 107 - 0
web/backend/app/routers/plans.py

@@ -360,6 +360,113 @@ def get_plan_results(plan_id: int, db: Session = Depends(get_db)):
     return ResultListResponse(total=len(items), items=items)
 
 
+@router.get("/{plan_id}/export-xlsx")
+def export_plan_results_xlsx(plan_id: int, db: Session = Depends(get_db)):
+    """Export plan simulation results as a formatted .xlsx workbook.
+
+    Format follows the torqrippswap scan_results.xlsx convention: two-row
+    header (merged category groups + column headers), freeze panes,
+    autofilter. Columns cover scan info, scan variables, electromagnetic
+    metrics, and thermal metrics (only columns present in the data are
+    exported, so thermal columns appear only for thermal_mode != off).
+    """
+    import tempfile
+    from urllib.parse import quote
+
+    from fastapi.responses import FileResponse
+    from starlette.background import BackgroundTask
+
+    from ..metrics_constants import METRIC_KEYS as _METRIC_KEYS  # noqa: F401
+    from afmcore.xlsx_report import build_export_columns, write_summary_xlsx
+
+    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 export")
+
+    # Parameter names + Chinese labels from the plan's scan variables.
+    param_labels: dict = {}
+    try:
+        plan_dict = plan.get_plan_dict()
+        for var in plan_dict.get("variables", []) or []:
+            name = var.get("name")
+            if not name:
+                continue
+            cn = var.get("name_cn") or name
+            unit = var.get("unit") or ""
+            param_labels[name] = f"{cn}[{unit}]" if unit else cn
+    except Exception:
+        param_labels = {}
+
+    param_names: list = []
+    present_metrics: set = set()
+    data_rows: list = []
+    for r in results:
+        params = r.get_params()
+        metrics = r.get_metrics()
+        for name in params:
+            if name not in param_names:
+                param_names.append(name)
+        present_metrics.update(metrics.keys())
+        row = {
+            "run_index": r.run_index,
+            "status": r.status,
+            "solve_time_s": r.solve_time_s,
+            "error": r.error_message or "",
+        }
+        row.update(params)
+        row.update(metrics)
+        data_rows.append(row)
+
+    # Keep only metric keys the platform knows about; unknown keys are still
+    # exported via the CAT_OTHER fallback inside build_export_columns.
+    present_metrics = {k for k in present_metrics if isinstance(k, str)}
+
+    col_keys, col_categories, col_headers = build_export_columns(
+        param_names, sorted(present_metrics), param_labels
+    )
+
+    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
+    tmp = tempfile.NamedTemporaryFile(
+        prefix=f"scan_results_{ts}_", suffix=".xlsx", delete=False
+    )
+    tmp.close()
+    write_summary_xlsx(tmp.name, col_keys, col_categories, col_headers, data_rows)
+
+    plan_name = (plan.name or f"plan_{plan_id}").strip() or f"plan_{plan_id}"
+    # RFC 5987: ASCII fallback + UTF-8 filename* for Chinese plan names.
+    ascii_fallback = f"scan_results_{plan_id}_{ts}.xlsx"
+    utf8_name = f"scan_results_{plan_name}_{ts}.xlsx"
+    disposition = (
+        f"attachment; filename=\"{ascii_fallback}\"; "
+        f"filename*=UTF-8''{quote(utf8_name)}"
+    )
+    return FileResponse(
+        tmp.name,
+        media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+        headers={"Content-Disposition": disposition},
+        background=BackgroundTask(os_remove_quiet, tmp.name),
+    )
+
+
+def os_remove_quiet(path: str) -> None:
+    """Best-effort temp-file cleanup after FileResponse streaming."""
+    import os
+
+    try:
+        os.remove(path)
+    except OSError:
+        pass
+
+
 # ---------------------------------------------------------------------------
 # One-click simulation start (P0-4)
 # ---------------------------------------------------------------------------

+ 1 - 0
web/backend/requirements.txt

@@ -3,3 +3,4 @@ uvicorn[standard]>=0.24.0
 sqlalchemy>=2.0.0
 pydantic>=2.0.0
 python-multipart>=0.0.6
+openpyxl>=3.1.0

+ 34 - 0
web/frontend/src/views/PlanDetail.vue

@@ -352,6 +352,9 @@
             <el-button size="small" @click="downloadResults" :disabled="!results.length">
               <el-icon><Download /></el-icon> 下载 CSV
             </el-button>
+            <el-button size="small" type="primary" plain @click="exportExcel" :disabled="!results.length">
+              <el-icon><Download /></el-icon> 导出 Excel
+            </el-button>
           </template>
           <el-table :data="results" size="small" border max-height="600">
             <el-table-column label="#" type="index" width="50" fixed="left" />
@@ -880,6 +883,19 @@ const allResultColumns = computed(() => {
     iron_loss_w: '铁耗(W)', magnet_loss_w: '磁钢耗(W)',
     max_flux_density_t: '最大磁密(T)', status: '状态',
     solve_time_s: '求解时间(s)', error: '错误信息', error_message: '错误信息',
+    output_power_w: '输出功率(W)', input_power_w: '输入功率(W)',
+    em_power_w: '电磁功率(W)', power_factor: '功率因数',
+    back_emf_v: '反电动势(V)', back_emf_thd_pct: '反电动势谐波(%)',
+    line_current_rms_a: '线电流(A)', phase_current_peak_a: '相电流峰值(A)',
+    shaft_speed_rpm: '转速(rpm)', no_load_speed_rpm: '空载转速(rpm)',
+    torque_constant: '转矩常数(Nm/A)', shaft_torque_nm: '轴转矩(Nm)',
+    stall_torque_nm: '堵转转矩(Nm)', tmax_nm: '最大转矩(Nm)', tmin_nm: '最小转矩(Nm)',
+    winding_temp_c: '绕组温度(°C)', winding_hotspot_temp_c: '绕组热点(°C)',
+    magnet_temp_c: '磁钢温度(°C)', stator_temp_c: '定子温度(°C)',
+    bearing_temp_c: '轴承温度(°C)', temp_rise_c: '温升(°C)',
+    thermal_resistance_k_w: '热阻(K/W)',
+    axial_force_n: '轴向力(N)', radial_force_n: '径向力(N)',
+    max_stress_mpa: '最大应力(MPa)', deformation_mm: '最大变形(mm)',
   }
   return keys.map(k => ({
     key: k,
@@ -925,6 +941,18 @@ const defaultColumnKeys = computed(() => {
   scanVariables.value.forEach((v: any) => {
     if (v?.name && !keys.includes(v.name)) keys.push(v.name)
   })
+  // Thermal columns join the default view when the task ran with thermal
+  // analysis (thermal_mode steady/coupled) and the results carry them.
+  const thermalCore = [
+    'winding_temp_c', 'winding_hotspot_temp_c', 'magnet_temp_c',
+    'stator_temp_c', 'temp_rise_c',
+  ]
+  if (results.value.length) {
+    const present = new Set(Object.keys(results.value[0]))
+    thermalCore.forEach(k => {
+      if (present.has(k) && !keys.includes(k)) keys.push(k)
+    })
+  }
   return keys
 })
 
@@ -1258,6 +1286,12 @@ const downloadResults = () => {
   window.open(`/api/plans/${planId.value}/download`, '_blank')
 }
 
+// Export the full result set (scan info + EM + thermal columns, two-row
+// header format matching the torqrippswap scan_results.xlsx convention).
+const exportExcel = () => {
+  window.open(`/api/plans/${planId.value}/export-xlsx`, '_blank')
+}
+
 const runAIAnalysis = async () => {
   analyzing.value = true
   try {