rule_engine.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. """Rule engine for AFM motor simulation plan generation.
  2. Provides:
  3. - Scan parameter registry (Motor-CAD variable names, units, physical ranges)
  4. - Boundary-condition-based range recommendation
  5. - Initial plan generation from project boundary conditions
  6. All source is ASCII. Chinese labels use Unicode escapes.
  7. """
  8. from __future__ import annotations
  9. import math
  10. from dataclasses import dataclass, field
  11. from typing import Any
  12. from .bc_fields import normalize_bc
  13. # ---------------------------------------------------------------------------
  14. # Scan parameter registry
  15. # ---------------------------------------------------------------------------
  16. @dataclass
  17. class ScanParameter:
  18. """Definition of a scannable Motor-CAD parameter."""
  19. name: str # Motor-CAD variable name (exact)
  20. display_name: str # Human-readable label
  21. unit: str # Unit string
  22. category: str # Geometry / Electrical / Thermal / Mesh
  23. default_start: float # Recommended range start
  24. default_stop: float # Recommended range stop
  25. default_step: float # Recommended step
  26. min_allowed: float # Hard physical minimum
  27. max_allowed: float # Hard physical maximum
  28. description: str = ""
  29. topology_supported: list[str] = field(default_factory=lambda: ["SSSR", "DRSS", "SDSR"])
  30. # Registry of scannable parameters for AFM motors.
  31. # Ranges based on MARS-12S10P reference model and AFM design literature.
  32. SCAN_PARAMETERS: dict[str, ScanParameter] = {
  33. # --- Geometry ---
  34. "Airgap": ScanParameter(
  35. name="Airgap",
  36. display_name="Airgap Length",
  37. unit="mm",
  38. category="Geometry",
  39. default_start=0.6, default_stop=1.5, default_step=0.3,
  40. min_allowed=0.2, max_allowed=5.0,
  41. description="Mechanical airgap between stator and rotor.",
  42. ),
  43. "Magnet_Length": ScanParameter(
  44. name="Magnet_Length",
  45. display_name="Magnet Axial Thickness",
  46. unit="mm",
  47. category="Geometry",
  48. default_start=2.0, default_stop=5.0, default_step=1.0,
  49. min_allowed=0.5, max_allowed=15.0,
  50. description="Magnet axial thickness (NOT radial depth).",
  51. ),
  52. "Magnet_Thickness": ScanParameter(
  53. name="Magnet_Thickness",
  54. display_name="Magnet Radial Depth",
  55. unit="mm",
  56. category="Geometry",
  57. default_start=8.0, default_stop=16.0, default_step=2.0,
  58. min_allowed=2.0, max_allowed=40.0,
  59. description="Magnet ring radial depth = (D_out - D_in) / 2.",
  60. ),
  61. "Magnet_Arc_[ED]": ScanParameter(
  62. name="Magnet_Arc_[ED]",
  63. display_name="Magnet Pole Arc",
  64. unit="deg",
  65. category="Geometry",
  66. default_start=100.0, default_stop=140.0, default_step=10.0,
  67. min_allowed=30.0, max_allowed=180.0,
  68. description="Magnet pole arc in electrical degrees.",
  69. ),
  70. # --- Electrical ---
  71. "RMSCurrent": ScanParameter(
  72. name="RMSCurrent",
  73. display_name="RMS Phase Current",
  74. unit="A",
  75. category="Electrical",
  76. default_start=10.0, default_stop=30.0, default_step=5.0,
  77. min_allowed=0.5, max_allowed=200.0,
  78. description="RMS phase current (CurrentDefinition=1).",
  79. ),
  80. "Shaft_Speed": ScanParameter(
  81. name="Shaft_Speed",
  82. display_name="Shaft Speed",
  83. unit="rpm",
  84. category="Electrical",
  85. default_start=3000.0, default_stop=8000.0, default_step=1000.0,
  86. min_allowed=100.0, max_allowed=50000.0,
  87. description="Rotational shaft speed.",
  88. ),
  89. # --- Thermal ---
  90. "Magnet_Temperature": ScanParameter(
  91. name="Magnet_Temperature",
  92. display_name="Magnet Temperature",
  93. unit="C",
  94. category="Thermal",
  95. default_start=60.0, default_stop=120.0, default_step=20.0,
  96. min_allowed=-40.0, max_allowed=200.0,
  97. description="Magnet operating temperature (default 100C hot).",
  98. ),
  99. # --- Mesh ---
  100. "TorquePointsPerCycle": ScanParameter(
  101. name="TorquePointsPerCycle",
  102. display_name="Torque Points / Cycle",
  103. unit="pts",
  104. category="Mesh",
  105. default_start=60.0, default_stop=180.0, default_step=60.0,
  106. min_allowed=12.0, max_allowed=720.0,
  107. description="Torque sampling points per electrical cycle.",
  108. ),
  109. # --- Geometry / winding (frequently recommended by the AI but previously
  110. # treated as "non-standard" and trimmed). Ranges anchored on the MARS
  111. # 12S10P baseline (values in fixed_params_template) with engineering
  112. # variation. default range ~ baseline +/-20-50%, physical limits wider.
  113. "Stator_Outer_Diameter": ScanParameter(
  114. name="Stator_Outer_Diameter",
  115. display_name="Stator Outer Diameter",
  116. unit="mm",
  117. category="Geometry",
  118. default_start=160.0, default_stop=240.0, default_step=10.0,
  119. min_allowed=50.0, max_allowed=500.0,
  120. description="Stator lamination outer diameter (MARS baseline 198mm).",
  121. ),
  122. "Stator_Inner_Diameter": ScanParameter(
  123. name="Stator_Inner_Diameter",
  124. display_name="Stator Inner Diameter",
  125. unit="mm",
  126. category="Geometry",
  127. default_start=80.0, default_stop=160.0, default_step=10.0,
  128. min_allowed=20.0, max_allowed=400.0,
  129. description="Stator lamination inner diameter (MARS baseline 122mm).",
  130. ),
  131. "Slot_Depth": ScanParameter(
  132. name="Slot_Depth",
  133. display_name="Slot Depth",
  134. unit="mm",
  135. category="Geometry",
  136. default_start=4.0, default_stop=12.0, default_step=1.0,
  137. min_allowed=1.0, max_allowed=30.0,
  138. description="Stator slot depth (MARS baseline 7mm).",
  139. ),
  140. # --- Winding ---
  141. "Turns_per_Coil": ScanParameter(
  142. name="Turns_per_Coil",
  143. display_name="Turns per Coil",
  144. unit="",
  145. category="Winding",
  146. default_start=8.0, default_stop=40.0, default_step=4.0,
  147. min_allowed=1.0, max_allowed=100.0,
  148. description="Number of turns per coil (MARS baseline 20; ConductorsPerSlot).",
  149. ),
  150. "Wire_Diameter": ScanParameter(
  151. name="Wire_Diameter",
  152. display_name="Wire Diameter",
  153. unit="mm",
  154. category="Winding",
  155. default_start=0.8, default_stop=2.5, default_step=0.2,
  156. min_allowed=0.1, max_allowed=5.0,
  157. description="Magnet wire diameter (MARS baseline 1.63mm).",
  158. ),
  159. }
  160. def get_parameter_registry() -> list[dict[str, Any]]:
  161. """Return the scan parameter registry as a list of dicts (for API)."""
  162. return [
  163. {
  164. "name": p.name,
  165. "display_name": p.display_name,
  166. "unit": p.unit,
  167. "category": p.category,
  168. "default_start": p.default_start,
  169. "default_stop": p.default_stop,
  170. "default_step": p.default_step,
  171. "min_allowed": p.min_allowed,
  172. "max_allowed": p.max_allowed,
  173. "description": p.description,
  174. "topology_supported": p.topology_supported,
  175. }
  176. for p in SCAN_PARAMETERS.values()
  177. ]
  178. def get_parameter(name: str) -> ScanParameter | None:
  179. """Get a parameter definition by name."""
  180. return SCAN_PARAMETERS.get(name)
  181. # ---------------------------------------------------------------------------
  182. # Boundary condition based range recommendation
  183. # ---------------------------------------------------------------------------
  184. @dataclass
  185. class BoundaryConditions:
  186. """Parsed project boundary conditions."""
  187. topology: str = "SSSR"
  188. outer_diameter_mm: float | None = None
  189. inner_diameter_mm: float | None = None
  190. speed_rpm: float | None = None
  191. current_a: float | None = None
  192. magnet_temp_c: float | None = None
  193. target_torque_nm: float | None = None
  194. target_efficiency_pct: float | None = None
  195. max_losses_w: float | None = None
  196. raw: dict[str, Any] = field(default_factory=dict)
  197. @classmethod
  198. def from_dict(cls, data: dict[str, Any]) -> "BoundaryConditions":
  199. """Parse boundary conditions from a project's JSON dict."""
  200. if not data:
  201. return cls()
  202. # Normalize legacy/alias BC keys (rated_*, slot_count, ...) to the
  203. # canonical keys via the single-source catalog, so older projects using
  204. # rated_ names are parsed correctly and BC-key handling stays in one
  205. # place (P1-1).
  206. data = normalize_bc(data)
  207. return cls(
  208. topology=data.get("topology", "SSSR"),
  209. outer_diameter_mm=_to_float(data.get("outer_diameter_mm")),
  210. inner_diameter_mm=_to_float(data.get("inner_diameter_mm")),
  211. speed_rpm=_to_float(data.get("speed_rpm")),
  212. current_a=_to_float(data.get("current_a")),
  213. magnet_temp_c=_to_float(data.get("magnet_temp_c")),
  214. target_torque_nm=_to_float(data.get("target_torque_nm")),
  215. target_efficiency_pct=_to_float(data.get("target_efficiency_pct")),
  216. max_losses_w=_to_float(data.get("max_losses_w")),
  217. raw=data,
  218. )
  219. def _to_float(v: Any) -> float | None:
  220. """Safely convert a value to float."""
  221. if v is None:
  222. return None
  223. try:
  224. return float(v)
  225. except (ValueError, TypeError):
  226. return None
  227. def recommend_range(
  228. param_name: str,
  229. bc: BoundaryConditions,
  230. ) -> dict[str, float]:
  231. """Recommend a scan range for a parameter based on boundary conditions.
  232. Returns dict with start, stop, step, and a confidence note.
  233. Adjustments are heuristic and based on AFM design scaling rules.
  234. """
  235. p = get_parameter(param_name)
  236. if p is None:
  237. return {"start": 0.0, "stop": 1.0, "step": 0.5}
  238. start = p.default_start
  239. stop = p.default_stop
  240. step = p.default_step
  241. notes: list[str] = []
  242. # --- Airgap: scale with outer diameter ---
  243. if param_name == "Airgap":
  244. if bc.outer_diameter_mm:
  245. # Smaller machines tend to use smaller airgaps.
  246. # Airgap ~ 0.5-2% of outer diameter for AFM.
  247. d = bc.outer_diameter_mm
  248. start = max(p.min_allowed, round(d * 0.008, 2)) # ~0.8% D
  249. stop = min(p.max_allowed, round(d * 0.02, 2)) # ~2% D
  250. step = round((stop - start) / 3, 2)
  251. notes.append(f"scaled from D={d}mm")
  252. if bc.target_torque_nm and bc.target_torque_nm > 5:
  253. # Higher torque targets benefit from smaller airgap exploration.
  254. start = max(p.min_allowed, start * 0.8)
  255. notes.append("torque target > 5Nm: shifted lower")
  256. # --- Magnet axial thickness: scale with diameter and speed ---
  257. elif param_name == "Magnet_Length":
  258. if bc.outer_diameter_mm:
  259. d = bc.outer_diameter_mm
  260. start = max(p.min_allowed, round(d * 0.03, 1)) # ~3% D
  261. stop = min(p.max_allowed, round(d * 0.07, 1)) # ~7% D
  262. step = round((stop - start) / 3, 1)
  263. notes.append(f"scaled from D={d}mm")
  264. if bc.speed_rpm and bc.speed_rpm > 10000:
  265. # High speed: thinner magnets reduce eddy current loss.
  266. stop = min(stop, p.default_stop)
  267. notes.append("high speed: capped upper range")
  268. # --- Magnet radial depth: scale with diameter ratio ---
  269. elif param_name == "Magnet_Thickness":
  270. if bc.outer_diameter_mm and bc.inner_diameter_mm:
  271. radial_depth = (bc.outer_diameter_mm - bc.inner_diameter_mm) / 2
  272. start = max(p.min_allowed, round(radial_depth * 0.6, 1))
  273. stop = min(p.max_allowed, round(radial_depth * 1.0, 1))
  274. step = round((stop - start) / 3, 1)
  275. notes.append(f"scaled from radial depth={radial_depth}mm")
  276. elif bc.outer_diameter_mm:
  277. d = bc.outer_diameter_mm
  278. start = max(p.min_allowed, round(d * 0.1, 1))
  279. stop = min(p.max_allowed, round(d * 0.25, 1))
  280. step = round((stop - start) / 3, 1)
  281. notes.append(f"scaled from D={d}mm")
  282. # --- Pole arc: standard range, adjust for torque/ripple targets ---
  283. elif param_name == "Magnet_Arc_[ED]":
  284. if bc.target_torque_nm:
  285. # Wider arc -> more torque but more ripple.
  286. start = 110.0
  287. stop = 150.0
  288. notes.append("torque target: widened upper range")
  289. else:
  290. start = 100.0
  291. stop = 140.0
  292. # --- RMS current: center around specified current ---
  293. elif param_name == "RMSCurrent":
  294. if bc.current_a:
  295. i = bc.current_a
  296. start = max(p.min_allowed, round(i * 0.5, 1))
  297. stop = min(p.max_allowed, round(i * 1.5, 1))
  298. step = round((stop - start) / 4, 1)
  299. notes.append(f"centered on I={i}A")
  300. if bc.max_losses_w:
  301. # Loss-limited: don't go too high on current.
  302. stop = min(stop, p.default_stop * 1.2)
  303. notes.append("loss constraint: capped upper")
  304. # --- Shaft speed: center around specified speed ---
  305. elif param_name == "Shaft_Speed":
  306. if bc.speed_rpm:
  307. s = bc.speed_rpm
  308. start = max(p.min_allowed, round(s * 0.6, -2))
  309. stop = min(p.max_allowed, round(s * 1.4, -2))
  310. step = round((stop - start) / 4, -2)
  311. notes.append(f"centered on N={s}rpm")
  312. # --- Magnet temperature: hot/cold sweep ---
  313. elif param_name == "Magnet_Temperature":
  314. if bc.magnet_temp_c:
  315. t = bc.magnet_temp_c
  316. start = max(p.min_allowed, t - 40)
  317. stop = min(p.max_allowed, t + 40)
  318. step = 20.0
  319. notes.append(f"centered on T={t}C")
  320. else:
  321. start = 20.0 # cold
  322. stop = 120.0 # hot
  323. step = 20.0
  324. notes.append("default cold-to-hot sweep")
  325. # --- Torque points: based on speed/accuracy needs ---
  326. elif param_name == "TorquePointsPerCycle":
  327. start = 60.0
  328. stop = 180.0
  329. step = 60.0
  330. if bc.target_efficiency_pct and bc.target_efficiency_pct > 90:
  331. stop = 240.0
  332. notes.append("high efficiency target: extended upper range")
  333. # Clamp to physical limits
  334. start = max(p.min_allowed, min(p.max_allowed, start))
  335. stop = max(p.min_allowed, min(p.max_allowed, stop))
  336. if start >= stop:
  337. start = p.default_start
  338. stop = p.default_stop
  339. if step <= 0:
  340. step = p.default_step
  341. return {
  342. "start": round(start, 4),
  343. "stop": round(stop, 4),
  344. "step": round(step, 4),
  345. "notes": "; ".join(notes) if notes else "default range",
  346. }
  347. # ---------------------------------------------------------------------------
  348. # Plan generation
  349. # ---------------------------------------------------------------------------
  350. def generate_values(start: float, stop: float, step: float) -> list[float]:
  351. """Generate evenly spaced values from start to stop inclusive.
  352. Uses round to avoid floating point artifacts.
  353. """
  354. if step <= 0:
  355. return [start]
  356. count = int(math.floor((stop - start) / step + 1e-9)) + 1
  357. values = [round(start + i * step, 6) for i in range(count)]
  358. # Ensure stop is included if rounding pushed it slightly off
  359. if values and abs(values[-1] - stop) > 1e-9:
  360. values.append(round(stop, 6))
  361. return values
  362. def estimate_point_count(variables: list[dict[str, Any]]) -> int:
  363. """Estimate total scan points (Cartesian product)."""
  364. total = 1
  365. for v in variables:
  366. if v.get("values"):
  367. total *= len(v["values"])
  368. elif v.get("start") is not None and v.get("stop") is not None and v.get("step"):
  369. total *= len(generate_values(v["start"], v["stop"], v["step"]))
  370. else:
  371. total *= 1
  372. return total
  373. def generate_plan(
  374. bc: BoundaryConditions,
  375. param_names: list[str] | None = None,
  376. model_path: str = "",
  377. ) -> dict[str, Any]:
  378. """Generate a recommended simulation plan from boundary conditions.
  379. Args:
  380. bc: Parsed boundary conditions.
  381. param_names: List of parameter names to include. If None, uses a
  382. default set (Airgap, Magnet_Length, RMSCurrent).
  383. model_path: Path to the baseline .mot model.
  384. Returns:
  385. Plan dict compatible with src/plan_schema.py and the plans API.
  386. """
  387. if param_names is None:
  388. # Default scan set: geometry + electrical
  389. param_names = ["Airgap", "Magnet_Length", "RMSCurrent"]
  390. variables = []
  391. for name in param_names:
  392. p = get_parameter(name)
  393. if p is None:
  394. continue
  395. if bc.topology not in p.topology_supported:
  396. continue
  397. rng = recommend_range(name, bc)
  398. values = generate_values(rng["start"], rng["stop"], rng["step"])
  399. variables.append({
  400. "name": name,
  401. "display_name": p.display_name,
  402. "unit": p.unit,
  403. "start": rng["start"],
  404. "stop": rng["stop"],
  405. "step": rng["step"],
  406. "values": values,
  407. "recommendation_notes": rng["notes"],
  408. })
  409. total_points = estimate_point_count(variables)
  410. plan_name = _generate_plan_name(bc, param_names)
  411. return {
  412. "name": plan_name,
  413. "model_path": model_path,
  414. "topology": bc.topology,
  415. "variables": variables,
  416. "cases": [],
  417. "estimated_points": total_points,
  418. "estimated_time_min": total_points * 3, # ~3 min per point
  419. "generation_summary": {
  420. "boundary_conditions_used": {
  421. k: v for k, v in bc.raw.items() if v is not None
  422. },
  423. "parameters_scanned": [v["name"] for v in variables],
  424. "total_points": total_points,
  425. "rule_engine_version": "1.0",
  426. },
  427. }
  428. def _generate_plan_name(bc: BoundaryConditions, param_names: list[str]) -> str:
  429. """Generate a human-readable plan name."""
  430. short = {
  431. "Airgap": "Ag",
  432. "Magnet_Length": "Lm",
  433. "Magnet_Thickness": "Tm",
  434. "Magnet_Arc_[ED]": "Arc",
  435. "RMSCurrent": "I",
  436. "Shaft_Speed": "N",
  437. "Magnet_Temperature": "Tmag",
  438. "TorquePointsPerCycle": "Pts",
  439. }
  440. tags = [short.get(n, n) for n in param_names]
  441. parts = ["Auto"]
  442. if bc.topology:
  443. parts.append(bc.topology)
  444. parts.append("-".join(tags))
  445. if bc.outer_diameter_mm:
  446. parts.append(f"D{bc.outer_diameter_mm:g}")
  447. return "_".join(parts)