Parcourir la source

feat(results): lossless full-field export (all Motor-CAD EM+thermal fields)

- afmcore.metrics: new parse_export_full() keeps every exported field with
  section/name/value/unit; skips Node-to-Node matrix sections; INF/NaN kept
  as strings (JSON-safe). parse_export() now also skips non-finite values
  (fixes latent report crash on Motor-CAD 'INF' fields)
- executor chain: solver captures raw_flat (EM + Thermal- prefixed thermal
  full exports) -> adapter -> task_executor -> report to backend
- backend: SimulationResult.raw_json column (+ SQLite ALTER migration),
  _sync_results_to_plan persists raw_flat, export endpoints append all raw
  columns (Chinese section categories, name[unit] headers, dedup)
- tests: 50 checks all pass; live e2e task fcc12b6c: 897 fields captured,
  885-column xlsx verified
carlin il y a 14 heures
Parent
commit
ccc3dd10f5

+ 8 - 0
docs/CONVERSATION_LOG.md

@@ -1192,3 +1192,11 @@ ambient_temperature=25.0)` 单点端到端 PASS——7 项热指标落盘,温
 
 旧后端日志发现 /api/plans/33/export-xlsx 两次 404。查实:用户(carlin)于 13:26 自行提交 1dd045f 实现该端点,14:04 换启新后端后功能上线。实测 200 + 有效 xlsx(7665B,正确 content-disposition)。无需我方干预。
 另:旧后端 31124(服务 11h43m)于 14:04 退出属预期换启;当前后端 33964 正常。
+
+## 2026-09-04 — 全量数据导出(TEST-067)
+
+**用户要求**:导出完整包含 Motor-CAD 电磁+热仿真所有导出字段,中文表头带单位。
+**本次完成**:① metrics.py 新增 parse_export_full(全量带单位、跳过 Node-to-Node 矩阵段、INF 保留为字符串);parse_export 同步跳过非有限值;② 执行器链采集 raw_flat(EM+热全量)上报;③ SimulationResult 加 raw_json 列 + 迁移;④ 导出端点追加 raw 全量列(类目分组、中文名[单位]、重名消歧)。
+**故障实录**:首跑任务因 EM 导出含 "INF"(恒转矩转速限值字段)JSON 上报被拒,任务卡 running——修复后重跑通过。旧任务卡单已取消。
+**验证**:测试 50 项全过;真实任务 fcc12b6c 端到端 897 字段入库、导出 885 列 xlsx。
+**遗留**:旧任务无 raw 存档(向后兼容,仅归一化列);Web 结果表格仍只显示归一化指标(设计如此)。

+ 26 - 0
docs/TEST_RECORDS.md

@@ -2391,6 +2391,32 @@ solver.run_single_point(thermal_mode)(coupled 分支调 do_magnetic_thermal_ca
 - **验证**:补 7 项 CSV 测试(共 37 项全过);真实链路 plan 33 经 vite 代理(5173)导出 xlsx 7664B(5 行 55 列,三类目分组)+ csv 2504B(BOM 正确、中文表头正常)。
 - **注意**:plan 33 第 1 行 status=OK 但带历史错误信息(Baseline reload failed),为脏数据原样导出,非本次引入。
 
+---
+
+## TEST-067:全量数据导出(Motor-CAD 电磁+热全部字段)
+
+| 项目 | 内容 |
+|---|---|
+| 测试日期 | 2026-09-04 |
+| 测试目的 | 用户要求导出的 Excel/CSV 完整包含 Motor-CAD 电磁和热仿真的所有导出字段,中文表头带单位 |
+
+### 实现
+
+1. **afmcore/metrics.py**:新增 `parse_export_full()`——全量解析(保留每个字段的 section/name/value/unit,含非数值);跳过 "Node to Node" 节点矩阵段;INF/NaN 保留为字符串(JSON 安全)。`parse_export()` 同步跳过非有限值(修潜在 INF 上报崩溃)。
+2. **执行器链**:robust_motorcad.run_single_point 采集 `raw_flat`(EM 全量 + 热全量,热段前缀 "Thermal-")→ adapter.run_point 透传 → task_executor 挂到结果行 → report_results 上报。
+3. **后端**:SimulationResult 加 `raw_json` 列(含 SQLite ALTER 迁移);_sync_results_to_plan 存 raw_flat;`_collect_export_rows` 追加 raw 全量列(类目=Motor-CAD 导出段名,"Thermal-"→"热仿真-";表头=中文名[单位],重名加类目前缀消歧)。
+4. **前端**:结果表格不变(仍显示归一化关键指标),全量数据仅进导出文件——Web 表格不被 ~900 列淹没。
+
+### 验证
+
+- test_xlsx_export.py 扩到 **50 项全过**(新增 raw 列、INF 安全、矩阵段跳过、真实导出文件解析)。
+- **真实端到端**:任务 fcc12b6c(plan 33,1 点 steady)→ 执行器采集 897 字段(EM 319 + 热 578)→ 上报入库 raw_json → 导出 xlsx **885 列**(22 个类目分组),抽查 平均转矩[Nm]/绕组温度[C]/直流母线电压[Volts]/T [Winding (A) Maximum][°C] 全部命中。
+- **故障实录**:首跑任务 e53ff277 因 EM 导出含 "INF"(恒转矩转速限值)导致 JSON 上报被拒("Out of range float values are not JSON compliant"),修复后 v2 任务一次通过。
+
+### 结论
+
+导出现在是无损全量:归一化关键指标列在前(供快速阅读),Motor-CAD 原生全字段在后(供深度分析),电磁+热仿真数据同表。旧任务无 raw_flat 存档则导出仅含归一化列(向后兼容)。
+
 ### 结论
 
 工程师最关心指标(转矩/脉动/效率/损耗分解/电气/温度)在 Web 表格默认可视,完整结果一键导出 xlsx,格式与 torqrippswap 一致。

+ 17 - 0
scripts/robust_motorcad.py

@@ -705,6 +705,23 @@ class RobustMotorCADSolver:
                             )
 
                     result["metrics"] = metrics
+
+                    # Full-fidelity capture: every exported field (EM +
+                    # thermal) with units, for the complete-data Excel/CSV
+                    # export. Normalized metrics above stay the web table
+                    # source; raw_flat is the lossless archive.
+                    try:
+                        from afmcore.metrics import parse_export_full
+                        raw_flat = parse_export_full(raw_file)
+                        _tf = raw_file.replace(".csv", "_thermal.csv")
+                        if _mode in ("steady", "coupled") and os.path.exists(_tf):
+                            for row in parse_export_full(_tf):
+                                row["section"] = "Thermal-" + row["section"]
+                                raw_flat.append(row)
+                        result["raw_flat"] = raw_flat
+                    except Exception as _raerr:  # noqa: BLE001
+                        self._log(f"WARNING: raw_flat capture failed: {_raerr}")
+
                     result["status"] = "OK"
                     break
 

+ 3 - 0
scripts/task_executor.py

@@ -638,6 +638,9 @@ class MotorCADTaskExecutor(TaskExecutor):
         point["metrics"] = metrics
         point["error"] = result.get("error")
         point["solve_time_s"] = result.get("solve_time_s")
+        # Lossless full export (all Motor-CAD fields with units) rides along
+        # for the complete-data Excel/CSV export; stored in raw_json column.
+        point["raw_flat"] = result.get("raw_flat", [])
         return point
 
     def cleanup(self):

+ 96 - 0
scripts/test_xlsx_export.py

@@ -244,6 +244,102 @@ def api_tests() -> None:
     check("csv: 400 when no results",
           client.get(f"/api/plans/{empty_pk}/export-csv").status_code == 400)
 
+    # 5) Raw full-fidelity columns: attach a raw archive to row 1 of the
+    # thermal plan and verify all fields (with units) appear in the export.
+    with SessionLocal() as db:
+        r1 = (db.query(SimulationResult)
+              .filter(SimulationResult.plan_id == plan_pk,
+                      SimulationResult.run_index == 1).first())
+        r1.set_raw([
+            {"section": "\u9a71\u52a8", "name": "\u76f4\u6d41\u6bcd\u7ebf\u7535\u538b",
+             "value": 48.0, "unit": "Volts"},
+            {"section": "\u7535\u78c1", "name": "\u8f6c\u77e9\u5bc6\u5ea6",
+             "value": 23.333, "unit": "kNm/m3"},
+            {"section": "Thermal-\u6e29\u5ea6", "name": "T[\u7ed5\u7ec4\u5e73\u5747]",
+             "value": 52.591, "unit": "\u00b0C"},
+            {"section": "Thermal-\u70ed\u963b", "name": "Rt[\u6c14\u9699]",
+             "value": 5.076, "unit": "\u00b0C/W"},
+        ])
+        db.commit()
+
+    resp4 = client.get(f"/api/plans/{plan_pk}/export-xlsx")
+    check("raw: export 200", resp4.status_code == 200)
+    out4 = Path(tempfile.mkdtemp()) / "raw_export.xlsx"
+    out4.write_bytes(resp4.content)
+    import openpyxl as _oxl
+    ws4 = _oxl.load_workbook(out4).active
+    h4 = [ws4.cell(2, c).value for c in range(1, ws4.max_column + 1)]
+    c4 = []
+    prev = None
+    for c in range(1, ws4.max_column + 1):
+        v = ws4.cell(1, c).value
+        if v and v != prev:
+            c4.append(v); prev = v
+    check("raw: EM raw header with unit",
+          "\u76f4\u6d41\u6bcd\u7ebf\u7535\u538b[Volts]" in h4)
+    check("raw: thermal raw header with unit",
+          "T[\u7ed5\u7ec4\u5e73\u5747][\u00b0C]" in h4)
+    check("raw: thermal category translated",
+          "Thermal-\u6e29\u5ea6".replace("Thermal-", "\u70ed\u4eff\u771f-") in c4
+          or "\u70ed\u4eff\u771f-\u6e29\u5ea6" in c4)
+    r3v = [ws4.cell(3, c).value for c in range(1, ws4.max_column + 1)]
+    check("raw: raw value in data row",
+          52.591 in r3v and 48.0 in r3v)
+
+    # parse_export_full unit test on the real coupled-run export files.
+    from afmcore.metrics import parse_export_full
+    em_csv = REPO_ROOT / "output" / "thermal_validation_20260904_124142" / "raw" / "emagnetic.csv"
+    th_csv = REPO_ROOT / "output" / "thermal_validation_20260904_124142" / "raw" / "thermal_steadystate.csv"
+    if em_csv.exists() and th_csv.exists():
+        em_rows = parse_export_full(em_csv)
+        th_rows = parse_export_full(th_csv)
+        check("full: EM export parsed", len(em_rows) > 200,
+              f"got {len(em_rows)}")
+        check("full: thermal export parsed", len(th_rows) > 500,
+              f"got {len(th_rows)}")
+        check("full: units captured",
+              any(r["unit"] == "\u00b0C" for r in th_rows))
+        check("full: sections captured",
+              any(r["section"] == "\u6e29\u5ea6" for r in th_rows))
+    else:
+        check("full: real export files present", False,
+              "thermal_validation_20260904_124142 raw files missing")
+
+    # INF / NaN safety: non-finite floats must not enter raw rows as floats
+    # (they break JSON reporting: "Out of range float values are not JSON
+    # compliant" - observed live on 2026-09-04 task e53ff277).
+    import math as _math
+    tmp_csv = Path(tempfile.mkdtemp()) / "inf_test.csv"
+    tmp_csv.write_text(
+        "\u7535\u78c1\n"
+        "\u6052\u8f6c\u77e9\u8f6c\u901f\u9650\u5236;INF;rpm\n"
+        "\u5e73\u5747\u8f6c\u77e9;0.5;Nm\n"
+        "Node to Node Thermal Resistances\n"
+        "0;Ambient;2\n"
+        "\u6e29\u5ea6\n"
+        "T[\u7ed5\u7ec4];52.5;\u00b0C\n",
+        encoding="utf-8",
+    )
+    full_rows = parse_export_full(tmp_csv)
+    inf_row = [r for r in full_rows if r["name"] == "\u6052\u8f6c\u77e9\u8f6c\u901f\u9650\u5236"]
+    check("full: INF kept as string",
+          len(inf_row) == 1 and inf_row[0]["value"] == "INF")
+    check("full: no non-finite floats",
+          all(not (isinstance(r["value"], float) and not _math.isfinite(r["value"]))
+              for r in full_rows))
+    check("full: node-to-node matrix skipped",
+          all(r["section"] != "Node to Node Thermal Resistances" for r in full_rows))
+    import json as _json
+    _json.dumps(full_rows)  # must not raise
+    check("full: raw rows JSON-serializable", True)
+
+    # Normalized parser must also skip INF (not extract it as a metric).
+    from afmcore.metrics import parse_export as _pe
+    parsed = _pe(tmp_csv)
+    flat_vals = [v for sec in parsed.values() for v in sec.values()]
+    check("full: parse_export skips INF",
+          all(_math.isfinite(v) for v in flat_vals))
+
 
 if __name__ == "__main__":
     unit_tests()

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

@@ -193,6 +193,7 @@ class MotorCADAdapter(SimulationAdapter):
             "status": result.get("status", "FAILED"),
             "error": result.get("error"),
             "raw_path": "",
+            "raw_flat": result.get("raw_flat", []),
             "solve_time_s": result.get("duration_s", round(time.time() - started, 1)),
             "params": params or {},
         }

+ 67 - 1
src/afmcore/metrics.py

@@ -22,6 +22,7 @@ All source is ASCII only.
 """
 from __future__ import annotations
 
+import math
 import os
 from pathlib import Path
 from typing import Dict, List, Optional, Tuple, Any
@@ -663,7 +664,12 @@ def parse_export(path: str | Path) -> Dict[str, Dict[str, float]]:
             if not part:
                 continue
             try:
-                value = float(part.replace(",", "."))
+                candidate = float(part.replace(",", "."))
+                # Skip non-finite values (Motor-CAD exports "INF" for
+                # unbounded fields) - they break JSON reporting downstream.
+                if not math.isfinite(candidate):
+                    continue
+                value = candidate
                 break
             except ValueError:
                 continue
@@ -757,3 +763,63 @@ def check_required_metrics(metrics: Dict[str, float]) -> Tuple[bool, List[str]]:
 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))
+
+
+# ---------------------------------------------------------------------------
+# Full export parse (every field, with units) for complete-data export
+# ---------------------------------------------------------------------------
+
+def parse_export_full(path: str | Path) -> List[Dict[str, Any]]:
+    """Parse a Motor-CAD semicolon export into an ordered flat row list.
+
+    Unlike parse_export() (which keeps only numeric values and collapses
+    duplicate names), this keeps EVERY field in file order, including its
+    unit (3rd semicolon column) and non-numeric values, so export reports
+    can reproduce the complete Motor-CAD result set.
+
+    Returns:
+        [{"section": str, "name": str, "value": float|str, "unit": str}, ...]
+    """
+    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 []
+
+    rows: List[Dict[str, Any]] = []
+    section = "(root)"
+    for raw in text.splitlines():
+        line = raw.strip()
+        if not line:
+            continue
+        if ";" not in line:
+            section = line.strip()
+            continue
+        # Node-to-node matrix tables (thermal network connectivity) are NOT
+        # "field;value;unit" scalar rows; skip them or they produce hundreds
+        # of meaningless duplicate columns in the export.
+        if section.startswith("Node to Node"):
+            continue
+        parts = [p.strip().strip('"') for p in line.split(";")]
+        field = parts[0]
+        if not field:
+            continue
+        value_str = parts[1] if len(parts) > 1 else ""
+        unit = parts[2] if len(parts) > 2 else ""
+        value: Any = value_str
+        if value_str:
+            try:
+                num = float(value_str.replace(",", "."))
+                # JSON has no inf/nan (e.g. Motor-CAD exports "INF" for an
+                # unbounded value) - keep the original string instead.
+                value = num if math.isfinite(num) else value_str
+            except ValueError:
+                value = value_str
+        rows.append({
+            "section": section, "name": field, "value": value, "unit": unit,
+        })
+    return rows

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

@@ -60,6 +60,7 @@ def _migrate_existing_tables():
         "surrogate_prediction_json": "TEXT DEFAULT '{}'",
         "cross_validation_json": "TEXT DEFAULT '{}'",
         "convergence_status_json": "TEXT DEFAULT '{}'",
+        "raw_json": "TEXT DEFAULT '[]'",
     }
 
     with engine.connect() as conn:

+ 14 - 0
web/backend/app/models/simulation_result.py

@@ -22,6 +22,9 @@ class SimulationResult(Base):
     solve_time_s = Column(Float, default=0.0)
     params_json = Column(Text, default="{}")  # JSON: parameter values
     metrics_json = Column(Text, default="{}")  # JSON: metric values
+    # Lossless Motor-CAD export archive: JSON list of
+    # {section, name, value, unit} covering every exported EM+thermal field.
+    raw_json = Column(Text, default="[]")
     error_message = Column(Text, default="")
     created_at = Column(DateTime, default=datetime.utcnow)
 
@@ -53,6 +56,17 @@ class SimulationResult(Base):
     def set_metrics(self, data: dict) -> None:
         self.metrics_json = json.dumps(data, ensure_ascii=False)
 
+    def get_raw(self) -> list:
+        """Full-fidelity export rows: [{section, name, value, unit}, ...]."""
+        try:
+            data = json.loads(self.raw_json) if self.raw_json else []
+            return data if isinstance(data, list) else []
+        except (json.JSONDecodeError, TypeError):
+            return []
+
+    def set_raw(self, data: list) -> None:
+        self.raw_json = json.dumps(data or [], ensure_ascii=False)
+
     # P3: Helper methods for extended fields
     def get_constraint_margins(self) -> dict:
         try:

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

@@ -433,6 +433,10 @@ def _collect_export_rows(plan, results):
     """Shared row/column assembly for xlsx and csv exports.
 
     Returns (col_keys, col_categories, col_headers, data_rows).
+
+    Columns: scan info (fixed) -> scan params -> normalized EM/thermal key
+    metrics -> the lossless raw export (every Motor-CAD field, grouped by
+    its export section, header = Chinese name + [unit]).
     """
     from afmcore.xlsx_report import build_export_columns
 
@@ -452,6 +456,7 @@ def _collect_export_rows(plan, results):
     param_names: list = []
     present_metrics: set = set()
     data_rows: list = []
+    raw_units: dict = {}  # "section|name" -> unit (first-seen)
     for r in results:
         params = r.get_params()
         metrics = r.get_metrics()
@@ -467,12 +472,44 @@ def _collect_export_rows(plan, results):
         }
         row.update(params)
         row.update(metrics)
+        # Lossless raw archive -> flat row values keyed by section|name.
+        for entry in r.get_raw():
+            sec = entry.get("section", "")
+            nm = entry.get("name", "")
+            if not nm:
+                continue
+            row[f"__raw__{sec}|{nm}"] = entry.get("value", "")
+            uk = f"{sec}|{nm}"
+            if uk not in raw_units:
+                raw_units[uk] = entry.get("unit") or ""
         data_rows.append(row)
 
     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
     )
+
+    # Raw columns: union over all rows, first-seen order, grouped by the
+    # Motor-CAD export section (Chinese category names). Thermal sections
+    # were prefixed "Thermal-" by the solver; display as "热仿真-".
+    seen_raw: set = set()
+    for row in data_rows:
+        for key in row:
+            if not key.startswith("__raw__") or key in seen_raw:
+                continue
+            seen_raw.add(key)
+            sec, _, nm = key[len("__raw__"):].partition("|")
+            entry_unit = raw_units.get(f"{sec}|{nm}", "")
+            category = sec.replace("Thermal-", "热仿真-")
+            header = f"{nm}[{entry_unit}]" if entry_unit else nm
+            # Disambiguate duplicate headers (same field name exported in
+            # two sections, e.g. 系统效率 in 驱动 and 电磁).
+            if header in col_headers:
+                header = f"{category}-{header}"
+            col_keys.append(key)
+            col_categories.append(category)
+            col_headers.append(header)
+
     return col_keys, col_categories, col_headers, data_rows
 
 

+ 5 - 0
web/backend/app/services/task_manager.py

@@ -426,6 +426,11 @@ class TaskManager:
                     )
                     result.set_params({k: float(v) for k, v in params.items() if isinstance(v, (int, float))})
                     result.set_metrics({k: float(v) for k, v in metrics.items() if isinstance(v, (int, float))})
+                    # Lossless full export archive (all EM+thermal fields
+                    # with units) for the complete-data Excel/CSV export.
+                    raw_flat = r.get("raw_flat")
+                    if isinstance(raw_flat, list) and raw_flat:
+                        result.set_raw(raw_flat)
                     db.add(result)
 
                 # Update plan status based on results