test_xlsx_export.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. """Tests for the plan-results Excel export (TEST-066).
  2. Covers:
  3. 1. afmcore.xlsx_report unit level: column assembly (thermal columns appear
  4. only when thermal metrics are present) and the two-row-header writer.
  5. 2. API level: GET /api/plans/{id}/export-xlsx on a temp SQLite DB via
  6. FastAPI TestClient - verifies xlsx payload, header structure, category
  7. groups, error paths (404 / 400).
  8. Run: python scripts/test_xlsx_export.py (from repo root)
  9. All source is ASCII only.
  10. """
  11. from __future__ import annotations
  12. import os
  13. import sys
  14. import tempfile
  15. from pathlib import Path
  16. REPO_ROOT = Path(__file__).resolve().parents[1]
  17. BACKEND_DIR = REPO_ROOT / "web" / "backend"
  18. SRC_DIR = REPO_ROOT / "src"
  19. for p in (str(BACKEND_DIR), str(SRC_DIR)):
  20. if p not in sys.path:
  21. sys.path.insert(0, p)
  22. # Isolate the DB before any app module is imported.
  23. _tmp_db = tempfile.NamedTemporaryFile(prefix="afm_xlsx_test_", suffix=".db", delete=False)
  24. _tmp_db.close()
  25. os.environ["AFM_DB_PATH"] = _tmp_db.name
  26. FAILURES = []
  27. def check(name: str, cond: bool, detail: str = "") -> None:
  28. tag = "PASS" if cond else "FAIL"
  29. print(f"[{tag}] {name}" + (f" -- {detail}" if detail and not cond else ""))
  30. if not cond:
  31. FAILURES.append(name)
  32. # ---------------------------------------------------------------------------
  33. # Part 1: unit tests for afmcore.xlsx_report
  34. # ---------------------------------------------------------------------------
  35. from afmcore.xlsx_report import ( # noqa: E402
  36. CAT_EM,
  37. CAT_SCAN_INFO,
  38. CAT_THERMAL,
  39. build_export_columns,
  40. write_summary_xlsx,
  41. )
  42. def unit_tests() -> None:
  43. params = ["Airgap", "Magnet_Thickness"]
  44. # Case A: EM-only data (thermal_mode off) - no thermal columns.
  45. keys, cats, headers = build_export_columns(
  46. params, ["tavg_nm", "ripple_pct", "efficiency_pct", "total_losses_w"]
  47. )
  48. check("unit: fixed cols first", keys[:4] == ["run_index", "status", "solve_time_s", "error"])
  49. check("unit: params in scan-info group",
  50. keys[4:6] == params and all(c == CAT_SCAN_INFO for c in cats[4:6]))
  51. check("unit: no thermal columns for EM-only data", CAT_THERMAL not in cats)
  52. check("unit: EM group present", CAT_EM in cats)
  53. check("unit: tavg header", headers[keys.index("tavg_nm")] == "\u5e73\u5747\u8f6c\u77e9[Nm]")
  54. # Case B: thermal data present - thermal group appears after EM.
  55. keys, cats, headers = build_export_columns(
  56. params,
  57. ["tavg_nm", "efficiency_pct", "winding_temp_c", "magnet_temp_c",
  58. "temp_rise_c", "thermal_resistance_k_w"],
  59. {"Airgap": "\u6c14\u9699[mm]"},
  60. )
  61. check("unit: thermal group present", CAT_THERMAL in cats)
  62. check("unit: thermal after EM", cats.index(CAT_THERMAL) > cats.index(CAT_EM))
  63. check("unit: param label used", headers[keys.index("Airgap")] == "\u6c14\u9699[mm]")
  64. check("unit: winding temp header",
  65. headers[keys.index("winding_temp_c")] == "\u7ed5\u7ec4\u6e29\u5ea6[C]")
  66. # Case C: unknown metric key still exported (no data loss).
  67. keys, cats, headers = build_export_columns([], ["tavg_nm", "mystery_metric"])
  68. check("unit: unknown metric kept", "mystery_metric" in keys)
  69. # Writer round-trip: write 2 data rows, read back structure.
  70. out = Path(tempfile.mkdtemp()) / "roundtrip.xlsx"
  71. rows = [
  72. {"run_index": 1, "status": "OK", "solve_time_s": 170.3, "error": "",
  73. "Airgap": 1.2, "tavg_nm": 0.545, "winding_temp_c": 54.86},
  74. {"run_index": 2, "status": "FAILED", "solve_time_s": 0, "error": "boom",
  75. "Airgap": 1.4, "tavg_nm": "", "winding_temp_c": ""},
  76. ]
  77. write_summary_xlsx(out, keys, cats, headers, rows)
  78. import openpyxl
  79. wb = openpyxl.load_workbook(out)
  80. ws = wb.active
  81. check("unit: sheet name", ws.title == "scan_results")
  82. check("unit: row1 is category groups", ws.cell(1, 1).value == CAT_SCAN_INFO)
  83. check("unit: row2 is headers", ws.cell(2, 1).value == "\u5e8f\u53f7")
  84. check("unit: data starts row 3", ws.cell(3, 1).value == 1)
  85. check("unit: failed row error cell", ws.cell(4, 4).value == "boom")
  86. check("unit: freeze panes", ws.freeze_panes == "B3")
  87. check("unit: autofilter", ws.auto_filter.ref == f"A2:{openpyxl.utils.get_column_letter(len(keys))}4")
  88. check("unit: merged category groups", len(ws.merged_cells.ranges) > 0)
  89. # ---------------------------------------------------------------------------
  90. # Part 2: API tests against a temp DB via TestClient
  91. # ---------------------------------------------------------------------------
  92. def api_tests() -> None:
  93. from fastapi.testclient import TestClient
  94. from app.main import app
  95. from app.database import SessionLocal, init_db
  96. from app.models.simulation_plan import SimulationPlan
  97. from app.models.simulation_result import SimulationResult
  98. init_db()
  99. client = TestClient(app)
  100. with SessionLocal() as db:
  101. from app.models.project import Project
  102. project = Project(name="xlsx-export-test-project", topology="SSSR")
  103. db.add(project)
  104. db.commit()
  105. db.refresh(project)
  106. project_pk = project.id
  107. plan = SimulationPlan(
  108. plan_id="SP-XLSX-TEST", name="\u70ed\u4eff\u771f\u5bfc\u51fa\u9a8c\u8bc1",
  109. status="completed", project_id=project_pk,
  110. )
  111. plan.set_plan_dict({
  112. "variables": [
  113. {"name": "Airgap", "name_cn": "\u6c14\u9699", "unit": "mm",
  114. "values": [1.0, 1.2]},
  115. ],
  116. })
  117. db.add(plan)
  118. db.commit()
  119. db.refresh(plan)
  120. plan_pk = plan.id
  121. rows = [
  122. (1, "OK", 178.0, "", {"Airgap": 1.0},
  123. {"tavg_nm": 0.522, "ripple_pct": 2.82, "efficiency_pct": 86.06,
  124. "total_losses_w": 41.9, "winding_temp_c": 52.59,
  125. "magnet_temp_c": 67.61, "temp_rise_c": 27.57,
  126. "thermal_resistance_k_w": 5.657}),
  127. (2, "OK", 181.2, "", {"Airgap": 1.2},
  128. {"tavg_nm": 0.545, "ripple_pct": 2.99, "efficiency_pct": 84.91,
  129. "total_losses_w": 47.1, "winding_temp_c": 54.86,
  130. "magnet_temp_c": 62.74, "temp_rise_c": 29.91,
  131. "thermal_resistance_k_w": 2.058}),
  132. ]
  133. for idx, status, secs, err, params, metrics in rows:
  134. r = SimulationResult(
  135. plan_id=plan_pk, run_index=idx, status=status,
  136. solve_time_s=secs, error_message=err,
  137. )
  138. r.set_params(params)
  139. r.set_metrics(metrics)
  140. db.add(r)
  141. # A second plan with EM-only results (thermal_mode off).
  142. plan2 = SimulationPlan(plan_id="SP-XLSX-EM", name="\u7eaf\u7535\u626b\u63cf", status="completed", project_id=project_pk)
  143. plan2.set_plan_dict({"variables": []})
  144. db.add(plan2)
  145. db.commit()
  146. db.refresh(plan2)
  147. r2 = SimulationResult(plan_id=plan2.id, run_index=1, status="OK",
  148. solve_time_s=170.0)
  149. r2.set_params({})
  150. r2.set_metrics({"tavg_nm": 0.5, "efficiency_pct": 86.0})
  151. db.add(r2)
  152. db.commit()
  153. plan2_pk = plan2.id
  154. # 1) Happy path: thermal plan export.
  155. resp = client.get(f"/api/plans/{plan_pk}/export-xlsx")
  156. check("api: export 200", resp.status_code == 200, f"got {resp.status_code}")
  157. check("api: xlsx content-type",
  158. "spreadsheetml" in resp.headers.get("content-type", ""))
  159. disp = resp.headers.get("content-disposition", "")
  160. check("api: attachment disposition", "attachment" in disp and "filename*=" in disp)
  161. out = Path(tempfile.mkdtemp()) / "api_export.xlsx"
  162. out.write_bytes(resp.content)
  163. import openpyxl
  164. wb = openpyxl.load_workbook(out)
  165. ws = wb.active
  166. headers = [ws.cell(2, c).value for c in range(1, ws.max_column + 1)]
  167. cats = [ws.cell(1, c).value for c in range(1, ws.max_column + 1)]
  168. check("api: thermal headers present",
  169. "\u7ed5\u7ec4\u6e29\u5ea6[C]" in headers and "\u70ed\u963b[K/W]" in headers)
  170. check("api: thermal category present", CAT_THERMAL in cats)
  171. check("api: param header uses Chinese label", "\u6c14\u9699[mm]" in headers)
  172. check("api: two data rows", ws.max_row == 4)
  173. check("api: row3 winding temp", abs(ws.cell(3, headers.index("\u7ed5\u7ec4\u6e29\u5ea6[C]") + 1).value - 52.59) < 1e-6)
  174. # 2) EM-only plan: no thermal columns.
  175. resp2 = client.get(f"/api/plans/{plan2_pk}/export-xlsx")
  176. check("api: EM-only export 200", resp2.status_code == 200)
  177. out2 = Path(tempfile.mkdtemp()) / "api_export_em.xlsx"
  178. out2.write_bytes(resp2.content)
  179. ws2 = openpyxl.load_workbook(out2).active
  180. headers2 = [ws2.cell(2, c).value for c in range(1, ws2.max_column + 1)]
  181. check("api: EM-only has no thermal columns",
  182. "\u7ed5\u7ec4\u6e29\u5ea6[C]" not in headers2 and CAT_THERMAL not in
  183. [ws2.cell(1, c).value for c in range(1, ws2.max_column + 1)])
  184. # 3) Error paths.
  185. check("api: 404 for unknown plan", client.get("/api/plans/99999/export-xlsx").status_code == 404)
  186. with SessionLocal() as db:
  187. empty = SimulationPlan(plan_id="SP-XLSX-EMPTY", name="\u7a7a\u65b9\u6848", status="draft", project_id=project_pk)
  188. empty.set_plan_dict({"variables": []})
  189. db.add(empty)
  190. db.commit()
  191. db.refresh(empty)
  192. empty_pk = empty.id
  193. check("api: 400 when no results",
  194. client.get(f"/api/plans/{empty_pk}/export-xlsx").status_code == 400)
  195. # 4) CSV export (same columns as xlsx, single header row, utf-8-sig).
  196. resp3 = client.get(f"/api/plans/{plan_pk}/export-csv")
  197. check("csv: export 200", resp3.status_code == 200)
  198. check("csv: content-type", "text/csv" in resp3.headers.get("content-type", ""))
  199. disp3 = resp3.headers.get("content-disposition", "")
  200. check("csv: attachment disposition", "attachment" in disp3 and "filename*=" in disp3)
  201. text = resp3.content.decode("utf-8-sig")
  202. import csv as _csv
  203. import io as _io
  204. rows3 = list(_csv.reader(_io.StringIO(text)))
  205. check("csv: header + 2 data rows", len(rows3) == 3)
  206. check("csv: header has seq label", rows3[0][0] == "\u5e8f\u53f7")
  207. check("csv: header has winding temp",
  208. "\u7ed5\u7ec4\u6e29\u5ea6[C]" in rows3[0])
  209. check("csv: row1 winding temp value",
  210. abs(float(rows3[1][rows3[0].index("\u7ed5\u7ec4\u6e29\u5ea6[C]")]) - 52.59) < 1e-6)
  211. check("csv: 404 for unknown plan",
  212. client.get("/api/plans/99999/export-csv").status_code == 404)
  213. check("csv: 400 when no results",
  214. client.get(f"/api/plans/{empty_pk}/export-csv").status_code == 400)
  215. # 5) Raw full-fidelity columns: attach a raw archive to row 1 of the
  216. # thermal plan and verify all fields (with units) appear in the export.
  217. with SessionLocal() as db:
  218. r1 = (db.query(SimulationResult)
  219. .filter(SimulationResult.plan_id == plan_pk,
  220. SimulationResult.run_index == 1).first())
  221. r1.set_raw([
  222. {"section": "\u9a71\u52a8", "name": "\u76f4\u6d41\u6bcd\u7ebf\u7535\u538b",
  223. "value": 48.0, "unit": "Volts"},
  224. {"section": "\u7535\u78c1", "name": "\u8f6c\u77e9\u5bc6\u5ea6",
  225. "value": 23.333, "unit": "kNm/m3"},
  226. {"section": "Thermal-\u6e29\u5ea6", "name": "T[\u7ed5\u7ec4\u5e73\u5747]",
  227. "value": 52.591, "unit": "\u00b0C"},
  228. {"section": "Thermal-\u70ed\u963b", "name": "Rt[\u6c14\u9699]",
  229. "value": 5.076, "unit": "\u00b0C/W"},
  230. ])
  231. db.commit()
  232. resp4 = client.get(f"/api/plans/{plan_pk}/export-xlsx")
  233. check("raw: export 200", resp4.status_code == 200)
  234. out4 = Path(tempfile.mkdtemp()) / "raw_export.xlsx"
  235. out4.write_bytes(resp4.content)
  236. import openpyxl as _oxl
  237. ws4 = _oxl.load_workbook(out4).active
  238. h4 = [ws4.cell(2, c).value for c in range(1, ws4.max_column + 1)]
  239. c4 = []
  240. prev = None
  241. for c in range(1, ws4.max_column + 1):
  242. v = ws4.cell(1, c).value
  243. if v and v != prev:
  244. c4.append(v); prev = v
  245. check("raw: EM raw header with unit",
  246. "\u76f4\u6d41\u6bcd\u7ebf\u7535\u538b[Volts]" in h4)
  247. check("raw: thermal raw header with unit",
  248. "T[\u7ed5\u7ec4\u5e73\u5747][\u00b0C]" in h4)
  249. check("raw: thermal category translated",
  250. "Thermal-\u6e29\u5ea6".replace("Thermal-", "\u70ed\u4eff\u771f-") in c4
  251. or "\u70ed\u4eff\u771f-\u6e29\u5ea6" in c4)
  252. r3v = [ws4.cell(3, c).value for c in range(1, ws4.max_column + 1)]
  253. check("raw: raw value in data row",
  254. 52.591 in r3v and 48.0 in r3v)
  255. # parse_export_full unit test on the real coupled-run export files.
  256. from afmcore.metrics import parse_export_full
  257. em_csv = REPO_ROOT / "output" / "thermal_validation_20260904_124142" / "raw" / "emagnetic.csv"
  258. th_csv = REPO_ROOT / "output" / "thermal_validation_20260904_124142" / "raw" / "thermal_steadystate.csv"
  259. if em_csv.exists() and th_csv.exists():
  260. em_rows = parse_export_full(em_csv)
  261. th_rows = parse_export_full(th_csv)
  262. check("full: EM export parsed", len(em_rows) > 200,
  263. f"got {len(em_rows)}")
  264. check("full: thermal export parsed", len(th_rows) > 500,
  265. f"got {len(th_rows)}")
  266. check("full: units captured",
  267. any(r["unit"] == "\u00b0C" for r in th_rows))
  268. check("full: sections captured",
  269. any(r["section"] == "\u6e29\u5ea6" for r in th_rows))
  270. else:
  271. check("full: real export files present", False,
  272. "thermal_validation_20260904_124142 raw files missing")
  273. # INF / NaN safety: non-finite floats must not enter raw rows as floats
  274. # (they break JSON reporting: "Out of range float values are not JSON
  275. # compliant" - observed live on 2026-09-04 task e53ff277).
  276. import math as _math
  277. tmp_csv = Path(tempfile.mkdtemp()) / "inf_test.csv"
  278. tmp_csv.write_text(
  279. "\u7535\u78c1\n"
  280. "\u6052\u8f6c\u77e9\u8f6c\u901f\u9650\u5236;INF;rpm\n"
  281. "\u5e73\u5747\u8f6c\u77e9;0.5;Nm\n"
  282. "Node to Node Thermal Resistances\n"
  283. "0;Ambient;2\n"
  284. "\u6e29\u5ea6\n"
  285. "T[\u7ed5\u7ec4];52.5;\u00b0C\n",
  286. encoding="utf-8",
  287. )
  288. full_rows = parse_export_full(tmp_csv)
  289. inf_row = [r for r in full_rows if r["name"] == "\u6052\u8f6c\u77e9\u8f6c\u901f\u9650\u5236"]
  290. check("full: INF kept as string",
  291. len(inf_row) == 1 and inf_row[0]["value"] == "INF")
  292. check("full: no non-finite floats",
  293. all(not (isinstance(r["value"], float) and not _math.isfinite(r["value"]))
  294. for r in full_rows))
  295. check("full: node-to-node matrix skipped",
  296. all(r["section"] != "Node to Node Thermal Resistances" for r in full_rows))
  297. import json as _json
  298. _json.dumps(full_rows) # must not raise
  299. check("full: raw rows JSON-serializable", True)
  300. # Normalized parser must also skip INF (not extract it as a metric).
  301. from afmcore.metrics import parse_export as _pe
  302. parsed = _pe(tmp_csv)
  303. flat_vals = [v for sec in parsed.values() for v in sec.values()]
  304. check("full: parse_export skips INF",
  305. all(_math.isfinite(v) for v in flat_vals))
  306. # Node Temperatures 4-column layout: "node_id;name;value;unit" must be
  307. # remapped to (name, value, unit) - regression for 2026-09-04 user report
  308. # where headers became "0[25]" and data cells held part names.
  309. nt_csv = Path(tempfile.mkdtemp()) / "node_temps.csv"
  310. nt_csv.write_text(
  311. "Node Temperatures\n"
  312. "0;\"Ambient\";25;\u00b0C\n"
  313. "1;\"Housing\";39.99;\u00b0C\n",
  314. encoding="utf-8",
  315. )
  316. nt_rows = parse_export_full(nt_csv)
  317. check("full: node temps remapped",
  318. nt_rows[0]["name"] == "Ambient"
  319. and nt_rows[0]["value"] == 25.0
  320. and nt_rows[0]["unit"] == "\u00b0C")
  321. check("full: node temps no id leak",
  322. all(r["name"] != "0" for r in nt_rows))
  323. if __name__ == "__main__":
  324. unit_tests()
  325. api_tests()
  326. print()
  327. if FAILURES:
  328. print(f"FAILED: {len(FAILURES)} -> {FAILURES}")
  329. sys.exit(1)
  330. print("ALL PASS")