rule_engine.py 16 KB

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