metrics.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759
  1. """Metrics single source of truth + normalized result parser.
  2. This module is THE authoritative definition of simulation output metrics
  3. for the whole platform. Consumers MUST import from here instead of
  4. maintaining their own copies:
  5. - src/solver_core.py (legacy local solver)
  6. - scripts/robust_motorcad.py (robust solver / executor)
  7. - web/backend/app/metrics_constants.py (Web backend)
  8. Design:
  9. - METRIC_DEFINITIONS: complete list of {key, label, aliases, unit, direction, required}
  10. - Normalized matching: full-width -> half-width brackets, strip ALL whitespace
  11. (incl. full-width space U+3000), lowercase. This fixes the historical bug
  12. where "Average torque (virtual work)" / "\\u5e73\\u5747\\u8f6c\\u77e9 (virtual work)"
  13. could not be matched due to invisible full-width chars.
  14. - parse_export(): section-aware semicolon CSV parser with tolerant numeric scan.
  15. - pick_metric()/extract_all_metrics(): section-priority exact match ->
  16. all-section exact match -> prefix fuzzy match.
  17. All source is ASCII only.
  18. """
  19. from __future__ import annotations
  20. import os
  21. from pathlib import Path
  22. from typing import Dict, List, Optional, Tuple, Any
  23. # ---------------------------------------------------------------------------
  24. # Single source of truth: metric definitions
  25. # ---------------------------------------------------------------------------
  26. # unit: SI display unit. direction: higher/lower/neutral (for optimization).
  27. # required: True means a valid result MUST contain this metric.
  28. METRIC_DEFINITIONS: List[Dict[str, Any]] = [
  29. # --- Torque ---
  30. {
  31. "key": "tavg_nm",
  32. "label": "Average Torque [Nm]",
  33. "unit": "Nm",
  34. "direction": "higher",
  35. "required": True,
  36. "aliases": [
  37. "Average torque (virtual work)",
  38. "Average Torque (Virtual Work)",
  39. "Average torque (DQ)",
  40. "Average torque (loop torque)",
  41. "Torque (average)",
  42. "Average Torque",
  43. "\u5e73\u5747\u8f6c\u77e9 (virtual work)",
  44. "\u5e73\u5747\u8f6c\u77e9(virtual work)",
  45. "\u5e73\u5747\u8f6c\u77e9 (DQ)",
  46. "\u5e73\u5747\u8f6c\u77e9(DQ)",
  47. "\u5e73\u5747\u8f6c\u77e9 (loop torque)",
  48. "\u5e73\u5747\u8f6c\u77e9(loop torque)",
  49. "\u5e73\u5747\u8f6c\u77e9",
  50. ],
  51. },
  52. {
  53. "key": "tmax_nm",
  54. "label": "Max Torque [Nm]",
  55. "unit": "Nm",
  56. "direction": "higher",
  57. "required": False,
  58. "aliases": [
  59. "Maximum torque (virtual work)",
  60. "Max Torque",
  61. "\u6700\u5927\u8f6c\u77e9 (virtual work)",
  62. "\u6700\u5927\u8f6c\u77e9(virtual work)",
  63. "\u6700\u5927\u8f6c\u77e9",
  64. ],
  65. },
  66. {
  67. "key": "tmin_nm",
  68. "label": "Min Torque [Nm]",
  69. "unit": "Nm",
  70. "direction": "higher",
  71. "required": False,
  72. "aliases": [
  73. "Minimum torque (virtual work)",
  74. "Min Torque",
  75. "\u6700\u5c0f\u8f6c\u77e9 (virtual work)",
  76. "\u6700\u5c0f\u8f6c\u77e9(virtual work)",
  77. "\u6700\u5c0f\u8f6c\u77e9",
  78. ],
  79. },
  80. {
  81. "key": "ripple_pct",
  82. "label": "Torque Ripple [%]",
  83. "unit": "%",
  84. "direction": "lower",
  85. "required": True,
  86. "aliases": [
  87. "Torque Ripple (VW) [%]",
  88. "Torque Ripple (VW)[%]",
  89. "Torque Ripple (VW) %",
  90. "Torque Ripple (VW) (%)",
  91. "Torque Ripple [%]",
  92. "Torque Ripple (%)",
  93. "Torque Ripple %",
  94. "\u8f6c\u77e9\u8109\u52a8 (VW) [%]",
  95. "\u8f6c\u77e9\u8109\u52a8(VW)[%]",
  96. "\u8f6c\u77e9\u8109\u52a8 [%]",
  97. "\u8f6c\u77e9\u8109\u52a8[%]",
  98. "\u8f6c\u77e9\u8109\u52a8",
  99. ],
  100. },
  101. {
  102. "key": "ripple_nm",
  103. "label": "Torque Ripple [Nm]",
  104. "unit": "Nm",
  105. "direction": "lower",
  106. "required": False,
  107. "aliases": [
  108. "Torque Ripple (VW)",
  109. "Torque Ripple",
  110. "\u8f6c\u77e9\u8109\u52a8 (VW)",
  111. "\u8f6c\u77e9\u8109\u52a8(VW)",
  112. ],
  113. },
  114. {
  115. "key": "ripple_abs_nm",
  116. "label": "Torque Ripple (abs) [Nm]",
  117. "unit": "Nm",
  118. "direction": "lower",
  119. "required": False,
  120. "aliases": [
  121. "Torque Ripple (VW) (abs)",
  122. "\u8f6c\u77e9\u8109\u52a8\u7edd\u5bf9\u503c",
  123. ],
  124. },
  125. {
  126. "key": "shaft_torque_nm",
  127. "label": "Shaft Torque [Nm]",
  128. "unit": "Nm",
  129. "direction": "higher",
  130. "required": False,
  131. "aliases": [
  132. "Shaft Torque",
  133. "Shaft Torque (Nm)",
  134. "\u8f74\u8f6c\u77e9",
  135. "\u8f74\u8f6c\u77e9(Nm)",
  136. ],
  137. },
  138. {
  139. "key": "stall_torque_nm",
  140. "label": "Stall Torque [Nm]",
  141. "unit": "Nm",
  142. "direction": "higher",
  143. "required": False,
  144. "aliases": [
  145. "Stall Torque",
  146. "Stall Torque (Nm)",
  147. "\u5835\u8f6c\u8f6c\u77e9",
  148. "\u5835\u8f6c\u8f6c\u77e9(Nm)",
  149. ],
  150. },
  151. {
  152. "key": "torque_constant",
  153. "label": "Torque Constant [Nm/A]",
  154. "unit": "Nm/A",
  155. "direction": "higher",
  156. "required": False,
  157. "aliases": [
  158. "Torque Constant (Kt)",
  159. "Torque Constant",
  160. "Torque Constant [Nm/A]",
  161. "\u8f6c\u77e9\u5e38\u6570(Kt)",
  162. "\u8f6c\u77e9\u5e38\u6570\uff08Kt\uff09",
  163. ],
  164. },
  165. # --- Efficiency / power ---
  166. {
  167. "key": "efficiency_pct",
  168. "label": "Efficiency [%]",
  169. "unit": "%",
  170. "direction": "higher",
  171. "required": True,
  172. "aliases": [
  173. "System Efficiency",
  174. "Efficiency",
  175. "\u7cfb\u7edf\u6548\u7387",
  176. "\u6548\u7387",
  177. ],
  178. },
  179. {
  180. "key": "input_power_w",
  181. "label": "Input Power [W]",
  182. "unit": "W",
  183. "direction": "neutral",
  184. "required": False,
  185. "aliases": [
  186. "Input Power",
  187. "\u8f93\u5165\u529f\u7387",
  188. ],
  189. },
  190. {
  191. "key": "output_power_w",
  192. "label": "Output Power [W]",
  193. "unit": "W",
  194. "direction": "higher",
  195. "required": False,
  196. "aliases": [
  197. "Output Power",
  198. "\u8f93\u51fa\u529f\u7387",
  199. "\u8f93\u51fa\u529f\u7387_\u7535\u538b\u9650\u5236\u9644\u8fd1\u5de5\u4f5c\u70b9",
  200. ],
  201. },
  202. {
  203. "key": "em_power_w",
  204. "label": "EM Power [W]",
  205. "unit": "W",
  206. "direction": "higher",
  207. "required": False,
  208. "aliases": [
  209. "Electromagnetic Power",
  210. "EM Power",
  211. "\u7535\u78c1\u529f\u7387",
  212. "\u7535\u78c1\u529f\u7387_\u7535\u538b\u9650\u5236\u9644\u8fd1\u5de5\u4f5c\u70b9",
  213. ],
  214. },
  215. {
  216. "key": "power_factor",
  217. "label": "Power Factor",
  218. "unit": "",
  219. "direction": "higher",
  220. "required": False,
  221. "aliases": [
  222. "Power Factor",
  223. "\u529f\u7387\u56e0\u6570",
  224. ],
  225. },
  226. # --- Losses ---
  227. {
  228. "key": "total_losses_w",
  229. "label": "Total Losses [W]",
  230. "unit": "W",
  231. "direction": "lower",
  232. "required": True,
  233. "aliases": [
  234. "Total Losses (on load)",
  235. "Total Losses",
  236. "\u603b\u635f\u8017(\u989d\u5b9a)",
  237. "\u603b\u635f\u8017 (\u989d\u5b9a)",
  238. "\u603b\u635f\u8017(\u7a7a\u8f7d)",
  239. "\u603b\u635f\u8017",
  240. ],
  241. },
  242. {
  243. "key": "copper_loss_w",
  244. "label": "DC Copper Loss [W]",
  245. "unit": "W",
  246. "direction": "lower",
  247. "required": False,
  248. "aliases": [
  249. "Armature DC Copper Loss (on load)",
  250. "Armature Copper Loss (on load)",
  251. "DC Copper Loss (on load)",
  252. "\u7535\u67a2\u76f4\u6d41\u94dc\u8017(\u5e26\u8f7d)",
  253. "\u7535\u67a2\u76f4\u6d41\u94dc\u8017 (\u5e26\u8f7d)",
  254. "\u7535\u67a2\u76f4\u6d41\u94dc\u8017(\u7a7a\u8f7d)",
  255. "\u7535\u67a2\u76f4\u6d41\u94dc\u8017",
  256. "\u94dc\u8017",
  257. ],
  258. },
  259. {
  260. "key": "iron_loss_w",
  261. "label": "Stator Iron Loss [W]",
  262. "unit": "W",
  263. "direction": "lower",
  264. "required": False,
  265. "aliases": [
  266. "Stator iron Loss [total] (on load)",
  267. "Stator Iron Loss [total] (on load)",
  268. "Stator Iron Loss",
  269. "Iron Loss",
  270. "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017](\u989d\u5b9a)",
  271. "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017] (\u989d\u5b9a)",
  272. "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017](\u7a7a\u8f7d)",
  273. "\u5b9a\u5b50\u94c1\u8017",
  274. "\u94c1\u635f",
  275. "\u94c1\u8017",
  276. ],
  277. },
  278. {
  279. "key": "magnet_loss_w",
  280. "label": "Magnet Loss [W]",
  281. "unit": "W",
  282. "direction": "lower",
  283. "required": False,
  284. "aliases": [
  285. "Magnet Loss (on load)",
  286. "Magnet Loss",
  287. "\u6c38\u78c1\u4f53\u635f\u8017(\u989d\u5b9a)",
  288. "\u6c38\u78c1\u4f53\u635f\u8017 (\u989d\u5b9a)",
  289. "\u6c38\u78c1\u4f53\u635f\u8017(\u7a7a\u8f7d)",
  290. "\u78c1\u94a2\u635f\u8017",
  291. ],
  292. },
  293. # --- Electrical ---
  294. {
  295. "key": "back_emf_v",
  296. "label": "Back EMF LL rms [V]",
  297. "unit": "V",
  298. "direction": "neutral",
  299. "required": False,
  300. "aliases": [
  301. "Back EMF Line-Line Voltage (rms)",
  302. "Back EMF Line-Line Voltage",
  303. "Back EMF Line-Line Voltage (peak)",
  304. "Back EMF (rms)",
  305. "\u7ebf\u95f4\u53cd\u5411\u7535\u52a8\u52bf\u6709\u6548\u503c",
  306. "\u7ebf\u95f4\u53cd\u5411\u7535\u52a8\u52bf\u5e45\u503c",
  307. "\u53cd\u7535\u52a8\u52bf",
  308. ],
  309. },
  310. {
  311. "key": "back_emf_thd_pct",
  312. "label": "Back EMF THD [%]",
  313. "unit": "%",
  314. "direction": "lower",
  315. "required": False,
  316. "aliases": [
  317. "Harmonic Distortion Back EMF Line-Line Voltage",
  318. "Back EMF THD",
  319. "\u7ebf\u53cd\u5411\u7535\u52a8\u52bf\u8c10\u6ce2",
  320. "\u7ebf\u7535\u538b\u8c10\u6ce2",
  321. ],
  322. },
  323. {
  324. "key": "phase_current_peak_a",
  325. "label": "Phase Current (peak) [A]",
  326. "unit": "A",
  327. "direction": "neutral",
  328. "required": False,
  329. "aliases": [
  330. "Peak Phase Current",
  331. "Phase Current (peak)",
  332. "\u76f8\u7535\u6d41\u5cf0\u503c",
  333. ],
  334. },
  335. {
  336. "key": "line_current_rms_a",
  337. "label": "Line Current (rms) [A]",
  338. "unit": "A",
  339. "direction": "neutral",
  340. "required": False,
  341. "aliases": [
  342. "Line Current (rms)",
  343. "Line Current",
  344. "\u7ebf\u7535\u6d41 (\u6709\u6548\u503c)",
  345. "\u7ebf\u7535\u6d41(\u6709\u6548\u503c)",
  346. "\u76f8\u7535\u6d41\u6709\u6548\u503c",
  347. ],
  348. },
  349. # --- Speed ---
  350. {
  351. "key": "shaft_speed_rpm",
  352. "label": "Shaft Speed [rpm]",
  353. "unit": "rpm",
  354. "direction": "neutral",
  355. "required": False,
  356. "aliases": [
  357. "Shaft Speed",
  358. "Speed",
  359. "\u8f6c\u901f[RPM]",
  360. "\u8f6c\u901f [RPM]",
  361. "\u8f6c\u901f{RPM}",
  362. "\u8f6c\u901f",
  363. ],
  364. },
  365. {
  366. "key": "no_load_speed_rpm",
  367. "label": "No-load Speed [rpm]",
  368. "unit": "rpm",
  369. "direction": "neutral",
  370. "required": False,
  371. "aliases": [
  372. "No load speed",
  373. "No-Load Speed",
  374. "\u7a7a\u8f7d\u8f6c\u901f",
  375. ],
  376. },
  377. # --- Thermal ---
  378. {
  379. "key": "winding_temp_c",
  380. "label": "Winding Temp [C]",
  381. "unit": "C",
  382. "direction": "lower",
  383. "required": False,
  384. "aliases": [
  385. "Winding Temperature",
  386. "\u7ed5\u7ec4\u6e29\u5ea6",
  387. "T[\u7ed5\u7ec4\u5e73\u5747]",
  388. "T [Winding (A) Average]",
  389. ],
  390. },
  391. {
  392. "key": "winding_hotspot_temp_c",
  393. "label": "Winding Hotspot Temp [C]",
  394. "unit": "C",
  395. "direction": "lower",
  396. "required": False,
  397. "domain": "thermal",
  398. "aliases": [
  399. "Winding Hotspot Temperature",
  400. "Winding Hotspot Temp",
  401. "Hotspot Temperature",
  402. "\u7ed5\u7ec4\u70ed\u70b9\u6e29\u5ea6",
  403. "\u70ed\u70b9\u6e29\u5ea6",
  404. "T[\u7ed5\u7ec4\u6700\u9ad8]",
  405. "T [Winding (A) Maximum]",
  406. "T [EWdg (Outer) Maximum]",
  407. ],
  408. },
  409. {
  410. "key": "magnet_temp_c",
  411. "label": "Magnet Temp [C]",
  412. "unit": "C",
  413. "direction": "lower",
  414. "required": False,
  415. "domain": "thermal",
  416. "aliases": [
  417. "Magnet Temperature",
  418. "Magnet Temp",
  419. "Permanent Magnet Temperature",
  420. "\u6c38\u78c1\u4f53\u6e29\u5ea6",
  421. "\u78c1\u94a2\u6e29\u5ea6",
  422. "T [Magnet Average]",
  423. "T [Magnet Active]",
  424. "T [Magnet Maximum]",
  425. ],
  426. },
  427. {
  428. "key": "stator_temp_c",
  429. "label": "Stator Temp [C]",
  430. "unit": "C",
  431. "direction": "lower",
  432. "required": False,
  433. "domain": "thermal",
  434. "aliases": [
  435. "Stator Temperature",
  436. "Stator Temp",
  437. "Stator Winding Temperature",
  438. "\u5b9a\u5b50\u6e29\u5ea6",
  439. "T[\u5b9a\u5b50\u5916\u8868\u9762]",
  440. "T[\u5b9a\u5b50\u8f6d]",
  441. ],
  442. },
  443. {
  444. "key": "bearing_temp_c",
  445. "label": "Bearing Temp [C]",
  446. "unit": "C",
  447. "direction": "lower",
  448. "required": False,
  449. "domain": "thermal",
  450. "aliases": [
  451. "Bearing Temperature",
  452. "Bearing Temp",
  453. "\u8f74\u627f\u6e29\u5ea6",
  454. "T[\u540e\u8f74\u627f]",
  455. ],
  456. },
  457. {
  458. "key": "temp_rise_c",
  459. "label": "Temperature Rise [C]",
  460. "unit": "C",
  461. "direction": "lower",
  462. "required": False,
  463. "domain": "thermal",
  464. "aliases": [
  465. "Temperature Rise",
  466. "Temp Rise",
  467. "Winding Temperature Rise",
  468. "\u6e29\u5347",
  469. "\u7ed5\u7ec4\u6e29\u5347",
  470. "dT [Winding (Maximum) - Ambient]",
  471. "dT [Winding (Average) - Ambient]",
  472. ],
  473. },
  474. {
  475. "key": "thermal_resistance_k_w",
  476. "label": "Thermal Resistance [K/W]",
  477. "unit": "K/W",
  478. "direction": "lower",
  479. "required": False,
  480. "domain": "thermal",
  481. "aliases": [
  482. "Thermal Resistance",
  483. "Thermal Resistance (total)",
  484. "\u70ed\u963b",
  485. "\u603b\u70ed\u963b",
  486. "Rt [Winding (Maximum) - Ambient]",
  487. "Rt [Winding (Average) - Ambient]",
  488. ],
  489. },
  490. # --- Structural / mechanical ---
  491. {
  492. "key": "axial_force_n",
  493. "label": "Axial Force [N]",
  494. "unit": "N",
  495. "direction": "lower",
  496. "required": False,
  497. "domain": "structural",
  498. "aliases": [
  499. "Axial Force",
  500. "Axial Magnetic Force",
  501. "Axial Pull Force",
  502. "\u8f74\u5411\u529b",
  503. "\u8f74\u5411\u78c1\u5438\u529b",
  504. ],
  505. },
  506. {
  507. "key": "radial_force_n",
  508. "label": "Radial Force [N]",
  509. "unit": "N",
  510. "direction": "lower",
  511. "required": False,
  512. "domain": "structural",
  513. "aliases": [
  514. "Radial Force",
  515. "Radial Magnetic Force",
  516. "\u5f84\u5411\u529b",
  517. "\u5f84\u5411\u78c1\u5438\u529b",
  518. ],
  519. },
  520. {
  521. "key": "max_stress_mpa",
  522. "label": "Max Stress [MPa]",
  523. "unit": "MPa",
  524. "direction": "lower",
  525. "required": False,
  526. "domain": "structural",
  527. "aliases": [
  528. "Maximum Stress",
  529. "Max Stress",
  530. "Von Mises Stress (max)",
  531. "\u6700\u5927\u5e94\u529b",
  532. "\u5bc6\u5e94\u529b\u6700\u5927\u503c",
  533. ],
  534. },
  535. {
  536. "key": "deformation_mm",
  537. "label": "Max Deformation [mm]",
  538. "unit": "mm",
  539. "direction": "lower",
  540. "required": False,
  541. "domain": "structural",
  542. "aliases": [
  543. "Maximum Deformation",
  544. "Max Deformation",
  545. "Total Deformation (max)",
  546. "\u6700\u5927\u53d8\u5f62",
  547. "\u603b\u53d8\u5f62\u6700\u5927\u503c",
  548. ],
  549. },
  550. ]
  551. # ---------------------------------------------------------------------------
  552. # Derived lookup structures
  553. # ---------------------------------------------------------------------------
  554. METRIC_KEYS: frozenset = frozenset(m["key"] for m in METRIC_DEFINITIONS)
  555. METRIC_LABELS: Dict[str, str] = {m["key"]: m["label"] for m in METRIC_DEFINITIONS}
  556. METRIC_UNITS: Dict[str, str] = {m["key"]: m["unit"] for m in METRIC_DEFINITIONS}
  557. METRIC_DIRECTIONS: Dict[str, str] = {m["key"]: m["direction"] for m in METRIC_DEFINITIONS}
  558. REQUIRED_METRICS: frozenset = frozenset(m["key"] for m in METRIC_DEFINITIONS if m["required"])
  559. # ---------------------------------------------------------------------------
  560. # Name normalization (the historical tavg/ripple parsing fix)
  561. # ---------------------------------------------------------------------------
  562. # Full-width -> half-width char map
  563. _FULL_TO_HALF = {
  564. "\uff08": "(", # full-width (
  565. "\uff09": ")", # full-width )
  566. "\uff0c": ",", # full-width ,
  567. "\uff1b": ";", # full-width ;
  568. "\uff1a": ":", # full-width :
  569. "\uff3b": "[", # full-width [
  570. "\uff3d": "]", # full-width ]
  571. "\u3000": " ", # full-width space (ideographic space)
  572. }
  573. def normalize_name(name: str) -> str:
  574. """Normalize a Motor-CAD field name for matching.
  575. - Full-width brackets/punct -> half-width
  576. - Strip ALL whitespace (incl. full-width space U+3000)
  577. - Lowercase
  578. Example: "\\u5e73\\u5747\\u8f6c\\u77e9 (virtual work)" and
  579. "Average torque (virtual work)" both normalize to the same token space.
  580. """
  581. s = name or ""
  582. for full, half in _FULL_TO_HALF.items():
  583. s = s.replace(full, half)
  584. s = "".join(s.split())
  585. return s.lower()
  586. # Pre-normalized alias map: normalized_alias -> metric_key
  587. _METRIC_ALIAS_MAP: Dict[str, str] = {}
  588. for _m in METRIC_DEFINITIONS:
  589. for _alias in _m["aliases"]:
  590. _METRIC_ALIAS_MAP[normalize_name(_alias)] = _m["key"]
  591. # Section name priority for exact matching (English + Chinese).
  592. SECTION_PRIORITY = [
  593. "E-Magnetics", "\u7535\u78c1",
  594. "Drive", "\u9a71\u52a8",
  595. "Losses", "\u635f\u8017",
  596. "Materials", "\u6750\u6599",
  597. "Miscellaneous", "\u6742\u9879",
  598. ]
  599. # ---------------------------------------------------------------------------
  600. # Parsing
  601. # ---------------------------------------------------------------------------
  602. def parse_export(path: str | Path) -> Dict[str, Dict[str, float]]:
  603. """Parse a Motor-CAD semicolon-delimited export file into
  604. {section_name: {field_name: value}}.
  605. - Section header = a line WITHOUT ';'.
  606. - Data line = "field;value[;...]" -> field is parts[0], value is the
  607. first numeric token in parts[1:] (tolerates thousands separators).
  608. - Multi-encoding fallback: utf-8-sig, utf-8, gbk, cp1252, latin-1.
  609. """
  610. text: Optional[str] = None
  611. for encoding in ("utf-8-sig", "utf-8", "gbk", "cp1252", "latin-1"):
  612. try:
  613. text = Path(path).read_text(encoding=encoding)
  614. break
  615. except (UnicodeDecodeError, OSError):
  616. continue
  617. if text is None:
  618. return {}
  619. result: Dict[str, Dict[str, float]] = {}
  620. section = "(root)"
  621. result.setdefault(section, {})
  622. for raw in text.splitlines():
  623. line = raw.strip()
  624. if not line:
  625. continue
  626. if ";" not in line:
  627. section = line.strip()
  628. result.setdefault(section, {})
  629. continue
  630. parts = line.split(";")
  631. field = parts[0].strip().strip('"')
  632. if not field:
  633. continue
  634. value: Optional[float] = None
  635. for part in parts[1:]:
  636. part = part.strip()
  637. if not part:
  638. continue
  639. try:
  640. value = float(part.replace(",", "."))
  641. break
  642. except ValueError:
  643. continue
  644. if value is None:
  645. continue
  646. result.setdefault(section, {})[field] = value
  647. return result
  648. def _exact_match_in_sections(
  649. parsed: Dict[str, Dict[str, float]],
  650. wanted: set,
  651. section_order: Optional[List[str]] = None,
  652. ) -> Optional[float]:
  653. """Exact normalized match. If section_order given, scan sections in that
  654. order first; then fall back to all sections."""
  655. if section_order:
  656. for section_name in section_order:
  657. section = parsed.get(section_name)
  658. if not section:
  659. continue
  660. for field, value in section.items():
  661. if normalize_name(field) in wanted:
  662. return value
  663. for section in parsed.values():
  664. for field, value in section.items():
  665. if normalize_name(field) in wanted:
  666. return value
  667. return None
  668. def pick_metric(parsed: Dict[str, Dict[str, float]], metric_key: str) -> Optional[float]:
  669. """Extract a single metric from parsed export results.
  670. Strategy (reference: KNOWLEDGE_BASE section 4.3):
  671. 1. Exact normalized match, E-Magnetics section priority.
  672. 2. Exact normalized match across all sections.
  673. 3. Prefix fuzzy match across all sections (len > 3 to avoid noise).
  674. """
  675. wanted = {
  676. norm for alias, key in _METRIC_ALIAS_MAP.items()
  677. if key == metric_key for norm in [alias]
  678. }
  679. if not wanted:
  680. return None
  681. value = _exact_match_in_sections(parsed, wanted, SECTION_PRIORITY)
  682. if value is not None:
  683. return value
  684. # Phase 3: prefix fuzzy match - ONLY when the exported field name is
  685. # LONGER than / starts with a known alias. The reverse direction
  686. # (alias longer than field) is intentionally dropped because it caused
  687. # false positives, e.g. alias "Torque Ripple (VW) (abs)" matched the
  688. # plain "Torque Ripple (VW)" field and polluted ripple_abs_nm.
  689. for alias_norm in wanted:
  690. if len(alias_norm) <= 3:
  691. continue
  692. for section in parsed.values():
  693. for field, value in section.items():
  694. field_norm = normalize_name(field)
  695. if len(field_norm) <= 3:
  696. continue
  697. if not field_norm.startswith(alias_norm):
  698. continue
  699. # Guard: a "%" field (e.g. "Torque Ripple (VW) [%]") must
  700. # never satisfy a non-percent alias (e.g. "Torque Ripple (VW)")
  701. # or it would pollute the Nm-valued metric with a % value.
  702. if "%" in field_norm and "%" not in alias_norm:
  703. continue
  704. return value
  705. return None
  706. def extract_all_metrics(parsed: Dict[str, Dict[str, float]]) -> Dict[str, float]:
  707. """Extract every defined metric from parsed results."""
  708. out: Dict[str, float] = {}
  709. for m in METRIC_DEFINITIONS:
  710. val = pick_metric(parsed, m["key"])
  711. if val is not None:
  712. out[m["key"]] = val
  713. return out
  714. def check_required_metrics(metrics: Dict[str, float]) -> Tuple[bool, List[str]]:
  715. """Check that all required metrics are present."""
  716. missing = [k for k in REQUIRED_METRICS if k not in metrics]
  717. return (len(missing) == 0, missing)
  718. def extract_metrics_from_file(path: str | Path) -> Dict[str, float]:
  719. """One-shot: parse an export file and extract all metrics."""
  720. return extract_all_metrics(parse_export(path))