test_metrics_extension.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. """P5-M6: unit tests for metrics extension (thermal + structural) and
  2. domain-grouped report generation.
  3. Covers:
  4. - New thermal/structural metrics present in METRIC_DEFINITIONS with domain
  5. - parse_export + extract_all_metrics picks up new metrics automatically
  6. - check_required_metrics unaffected (new metrics required=False)
  7. - report_generator._group_metrics_by_domain correct grouping
  8. - report_generator JSON fallback includes metrics_by_domain
  9. - Boundary: empty metrics, unknown keys, mixed domains
  10. - robust_motorcad enable_thermal parameter exists (signature check)
  11. All source is ASCII only. Run: python scripts/test_metrics_extension.py
  12. exit 0 = PASS.
  13. """
  14. import os
  15. import sys
  16. import tempfile
  17. import unittest
  18. _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  19. _SRC = os.path.join(_ROOT, "src")
  20. if _SRC not in sys.path:
  21. sys.path.insert(0, _SRC)
  22. from afmcore.metrics import ( # noqa: E402
  23. METRIC_DEFINITIONS,
  24. METRIC_KEYS,
  25. REQUIRED_METRICS,
  26. check_required_metrics,
  27. extract_all_metrics,
  28. parse_export,
  29. )
  30. # report_generator lives under web/backend; add its dir to path
  31. _REPORT_DIR = os.path.join(_ROOT, "web", "backend", "app", "services")
  32. if _REPORT_DIR not in sys.path:
  33. sys.path.insert(0, _REPORT_DIR)
  34. from report_generator import ( # noqa: E402
  35. ReportGenerator,
  36. _DOMAIN_ORDER,
  37. _group_metrics_by_domain,
  38. _metric_display,
  39. )
  40. THERMAL_KEYS = [
  41. "winding_hotspot_temp_c", "magnet_temp_c", "stator_temp_c",
  42. "bearing_temp_c", "temp_rise_c", "thermal_resistance_k_w",
  43. ]
  44. STRUCTURAL_KEYS = [
  45. "axial_force_n", "radial_force_n", "max_stress_mpa", "deformation_mm",
  46. ]
  47. class TestMetricDefinitions(unittest.TestCase):
  48. def test_thermal_metrics_present(self):
  49. keys = {m["key"] for m in METRIC_DEFINITIONS}
  50. for k in THERMAL_KEYS:
  51. self.assertIn(k, keys, "missing thermal metric: %s" % k)
  52. def test_structural_metrics_present(self):
  53. keys = {m["key"] for m in METRIC_DEFINITIONS}
  54. for k in STRUCTURAL_KEYS:
  55. self.assertIn(k, keys, "missing structural metric: %s" % k)
  56. def test_thermal_metrics_have_domain(self):
  57. for m in METRIC_DEFINITIONS:
  58. if m["key"] in THERMAL_KEYS:
  59. self.assertEqual(m.get("domain"), "thermal",
  60. "%s should have domain=thermal" % m["key"])
  61. def test_structural_metrics_have_domain(self):
  62. for m in METRIC_DEFINITIONS:
  63. if m["key"] in STRUCTURAL_KEYS:
  64. self.assertEqual(m.get("domain"), "structural",
  65. "%s should have domain=structural" % m["key"])
  66. def test_new_metrics_not_required(self):
  67. for k in THERMAL_KEYS + STRUCTURAL_KEYS:
  68. self.assertNotIn(k, REQUIRED_METRICS,
  69. "%s should be required=False" % k)
  70. def test_total_metric_count(self):
  71. # original 25 + 6 thermal + 4 structural = 35
  72. self.assertEqual(len(METRIC_DEFINITIONS), 35)
  73. class TestMetricExtraction(unittest.TestCase):
  74. """Construct a mock Motor-CAD export CSV with thermal/structural fields
  75. and verify extract_all_metrics picks them up automatically."""
  76. def _make_export(self, fields):
  77. """Write a mock semicolon-CSV export and return its path."""
  78. lines = ["E-Magnetics"]
  79. for field, value in fields:
  80. lines.append("%s;%s" % (field, value))
  81. fd, path = tempfile.mkstemp(suffix=".csv")
  82. with os.fdopen(fd, "w", encoding="utf-8") as f:
  83. f.write("\n".join(lines))
  84. self.addCleanup(os.unlink, path)
  85. return path
  86. def test_extract_thermal_metrics(self):
  87. path = self._make_export([
  88. ("Average torque (virtual work)", "1.5"),
  89. ("Magnet Temperature", "62.5"),
  90. ("Winding Hotspot Temperature", "88.3"),
  91. ("Stator Temperature", "70.1"),
  92. ("Bearing Temperature", "55.0"),
  93. ("Temperature Rise", "48.2"),
  94. ("Thermal Resistance", "0.85"),
  95. ])
  96. parsed = parse_export(path)
  97. metrics = extract_all_metrics(parsed)
  98. self.assertAlmostEqual(metrics["magnet_temp_c"], 62.5, places=2)
  99. self.assertAlmostEqual(metrics["winding_hotspot_temp_c"], 88.3, places=2)
  100. self.assertAlmostEqual(metrics["stator_temp_c"], 70.1, places=2)
  101. self.assertAlmostEqual(metrics["bearing_temp_c"], 55.0, places=2)
  102. self.assertAlmostEqual(metrics["temp_rise_c"], 48.2, places=2)
  103. self.assertAlmostEqual(metrics["thermal_resistance_k_w"], 0.85, places=2)
  104. def test_extract_structural_metrics(self):
  105. path = self._make_export([
  106. ("Axial Force", "125.5"),
  107. ("Radial Force", "45.2"),
  108. ("Maximum Stress", "180.3"),
  109. ("Max Deformation", "0.12"),
  110. ])
  111. parsed = parse_export(path)
  112. metrics = extract_all_metrics(parsed)
  113. self.assertAlmostEqual(metrics["axial_force_n"], 125.5, places=2)
  114. self.assertAlmostEqual(metrics["radial_force_n"], 45.2, places=2)
  115. self.assertAlmostEqual(metrics["max_stress_mpa"], 180.3, places=2)
  116. self.assertAlmostEqual(metrics["deformation_mm"], 0.12, places=2)
  117. def test_chinese_alias_thermal(self):
  118. path = self._make_export([
  119. ("\u6c38\u78c1\u4f53\u6e29\u5ea6", "70.0"), # magnet temp
  120. ("\u8f74\u5411\u529b", "200.0"), # axial force
  121. ])
  122. parsed = parse_export(path)
  123. metrics = extract_all_metrics(parsed)
  124. self.assertAlmostEqual(metrics["magnet_temp_c"], 70.0, places=2)
  125. self.assertAlmostEqual(metrics["axial_force_n"], 200.0, places=2)
  126. def test_required_check_unaffected(self):
  127. # Only tavg/ripple/efficiency/total_losses are required;
  128. # missing thermal/structural metrics must not fail the check.
  129. ok, missing = check_required_metrics({"tavg_nm": 1.0, "ripple_pct": 2.0,
  130. "efficiency_pct": 90.0, "total_losses_w": 10.0})
  131. self.assertTrue(ok)
  132. self.assertEqual(missing, [])
  133. def test_required_check_fails_on_missing_core(self):
  134. ok, missing = check_required_metrics({"tavg_nm": 1.0})
  135. self.assertFalse(ok)
  136. self.assertIn("ripple_pct", missing)
  137. class TestDomainGrouping(unittest.TestCase):
  138. def test_group_by_domain(self):
  139. metrics = {
  140. "tavg_nm": 1.5, "ripple_pct": 5.0, # electromagnetic (default)
  141. "magnet_temp_c": 60.0, "temp_rise_c": 40.0, # thermal
  142. "axial_force_n": 100.0, "max_stress_mpa": 150.0, # structural
  143. }
  144. grouped = _group_metrics_by_domain(metrics)
  145. self.assertIn("electromagnetic", grouped)
  146. self.assertIn("thermal", grouped)
  147. self.assertIn("structural", grouped)
  148. self.assertEqual(set(grouped["thermal"].keys()), {"magnet_temp_c", "temp_rise_c"})
  149. self.assertEqual(set(grouped["structural"].keys()), {"axial_force_n", "max_stress_mpa"})
  150. def test_empty_metrics(self):
  151. self.assertEqual(_group_metrics_by_domain({}), {})
  152. def test_unknown_key_defaults_electromagnetic(self):
  153. grouped = _group_metrics_by_domain({"unknown_metric_xyz": 42.0})
  154. self.assertIn("electromagnetic", grouped)
  155. self.assertEqual(grouped["electromagnetic"]["unknown_metric_xyz"], 42.0)
  156. def test_domain_order(self):
  157. self.assertEqual(_DOMAIN_ORDER, ["electromagnetic", "thermal", "structural"])
  158. def test_metric_display(self):
  159. label, value = _metric_display("tavg_nm", 1.5)
  160. self.assertIn("Average Torque", label)
  161. self.assertEqual(value, "1.5")
  162. def test_metric_display_float_format(self):
  163. _, value = _metric_display("efficiency_pct", 92.3456789)
  164. self.assertEqual(value, "92.35") # %.4g
  165. class TestReportJsonFallback(unittest.TestCase):
  166. def test_json_report_includes_metrics_by_domain(self):
  167. rg = ReportGenerator(output_dir=tempfile.mkdtemp())
  168. task_data = {
  169. "task_id": "test-001",
  170. "task_name": "Test Task",
  171. "status": "completed",
  172. "result_metrics": {
  173. "tavg_nm": 1.5,
  174. "magnet_temp_c": 60.0,
  175. "axial_force_n": 100.0,
  176. },
  177. }
  178. path = rg._generate_json_report(task_data, None, None)
  179. self.addCleanup(os.unlink, path)
  180. import json
  181. with open(path, "r", encoding="utf-8") as f:
  182. report = json.load(f)
  183. self.assertIn("metrics_by_domain", report)
  184. self.assertIn("thermal", report["metrics_by_domain"])
  185. self.assertIn("structural", report["metrics_by_domain"])
  186. self.assertEqual(report["metrics_by_domain"]["thermal"]["magnet_temp_c"], 60.0)
  187. class TestRobustMotorcadThermalParam(unittest.TestCase):
  188. """Verify enable_thermal parameter exists in RobustMotorCADSolver."""
  189. def test_init_has_enable_thermal(self):
  190. sys.path.insert(0, _ROOT)
  191. from scripts.robust_motorcad import RobustMotorCADSolver
  192. import inspect
  193. sig = inspect.signature(RobustMotorCADSolver.__init__)
  194. self.assertIn("enable_thermal", sig.parameters)
  195. self.assertFalse(sig.parameters["enable_thermal"].default)
  196. def test_run_single_point_has_enable_thermal(self):
  197. sys.path.insert(0, _ROOT)
  198. from scripts.robust_motorcad import RobustMotorCADSolver
  199. import inspect
  200. sig = inspect.signature(RobustMotorCADSolver.run_single_point)
  201. self.assertIn("enable_thermal", sig.parameters)
  202. self.assertIsNone(sig.parameters["enable_thermal"].default)
  203. if __name__ == "__main__":
  204. unittest.main(verbosity=2)