l0_prescreening.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. """L0 Analytic Pre-screening Engine (P3-M2).
  2. Per third-party review: L0 analytic + rule-based pre-filtering
  3. to exclude obviously infeasible regions before any simulation.
  4. Covers:
  5. - Geometric constraints (inner/outer diameter, airgap, axial length)
  6. - Electrical constraints (current density, voltage, magnetic loading)
  7. - Thermal constraints (temperature rise, cooling capacity)
  8. - Manufacturing constraints (PCB line width/spacing, copper thickness, tolerances)
  9. """
  10. import math
  11. from dataclasses import dataclass, field
  12. from typing import Dict, List, Optional, Tuple, Any
  13. @dataclass
  14. class ConstraintResult:
  15. """Result of a single constraint check."""
  16. name: str
  17. category: str # geometric / electrical / thermal / manufacturing
  18. passed: bool
  19. value: Optional[float] = None
  20. limit: Optional[float] = None
  21. margin: Optional[float] = None # percentage margin (positive = safe)
  22. message: str = ""
  23. @dataclass
  24. class FeasibilityReport:
  25. """Complete L0 feasibility report for a parameter set."""
  26. feasible: bool
  27. total_checks: int
  28. passed_checks: int
  29. failed_checks: int
  30. results: List[ConstraintResult] = field(default_factory=list)
  31. risk_items: List[str] = field(default_factory=list)
  32. @property
  33. def pass_rate(self) -> float:
  34. return self.passed_checks / self.total_checks if self.total_checks > 0 else 0.0
  35. def to_dict(self) -> Dict[str, Any]:
  36. return {
  37. "feasible": self.feasible,
  38. "total_checks": self.total_checks,
  39. "passed_checks": self.passed_checks,
  40. "failed_checks": self.failed_checks,
  41. "pass_rate": round(self.pass_rate, 4),
  42. "results": [
  43. {
  44. "name": r.name,
  45. "category": r.category,
  46. "passed": r.passed,
  47. "value": r.value,
  48. "limit": r.limit,
  49. "margin_pct": round(r.margin, 2) if r.margin is not None else None,
  50. "message": r.message,
  51. }
  52. for r in self.results
  53. ],
  54. "risk_items": self.risk_items,
  55. }
  56. class L0PreScreeningEngine:
  57. """L0 analytic pre-screening engine.
  58. Uses first-order physics and engineering rules to quickly
  59. reject infeasible parameter combinations without simulation.
  60. """
  61. # Default engineering limits (can be overridden per project)
  62. DEFAULT_LIMITS = {
  63. # Geometric
  64. "min_outer_diameter_mm": 20.0,
  65. "max_outer_diameter_mm": 500.0,
  66. "min_inner_diameter_mm": 5.0,
  67. "min_diameter_ratio": 0.2, # inner/outer
  68. "max_diameter_ratio": 0.8,
  69. "min_airgap_mm": 0.3,
  70. "max_airgap_mm": 5.0,
  71. "min_magnet_thickness_mm": 1.0,
  72. "max_magnet_thickness_mm": 20.0,
  73. # Electrical
  74. "max_current_density_amm2": 15.0, # A/mm^2 (natural convection)
  75. "max_current_density_forced_amm2": 25.0, # A/mm^2 (forced cooling)
  76. "min_slot_fill_factor": 0.3,
  77. "max_slot_fill_factor": 0.75,
  78. "max_magnetic_loading_t": 1.8, # Tesla (avoid saturation)
  79. # Thermal
  80. "max_temperature_rise_c": 80.0, # K (above ambient)
  81. "max_winding_temp_c": 150.0, # Class F
  82. "max_magnet_temp_c": 120.0, # NdFeB N42SH
  83. # Manufacturing (PCB)
  84. "min_pcb_line_width_mm": 0.1,
  85. "min_pcb_line_spacing_mm": 0.1,
  86. "min_pcb_copper_thickness_oz": 0.5,
  87. "max_pcb_copper_thickness_oz": 6.0,
  88. "min_via_diameter_mm": 0.2,
  89. "max_pcb_layers": 20,
  90. }
  91. def __init__(self, limits: Optional[Dict[str, float]] = None):
  92. self.limits = dict(self.DEFAULT_LIMITS)
  93. if limits:
  94. self.limits.update(limits)
  95. def check_geometric(self, params: Dict[str, Any]) -> List[ConstraintResult]:
  96. """Check geometric feasibility constraints."""
  97. results = []
  98. L = self.limits
  99. outer_d = params.get("outer_diameter_mm")
  100. inner_d = params.get("inner_diameter_mm")
  101. airgap = params.get("airgap_mm")
  102. magnet_thickness = params.get("magnet_thickness_mm")
  103. # Outer diameter range
  104. if outer_d is not None:
  105. passed = L["min_outer_diameter_mm"] <= outer_d <= L["max_outer_diameter_mm"]
  106. results.append(ConstraintResult(
  107. name="outer_diameter_range",
  108. category="geometric",
  109. passed=passed,
  110. value=outer_d,
  111. limit=f"{L['min_outer_diameter_mm']}-{L['max_outer_diameter_mm']}",
  112. message=f"Outer diameter {outer_d}mm {'within' if passed else 'outside'} valid range",
  113. ))
  114. # Inner diameter and ratio
  115. if outer_d is not None and inner_d is not None:
  116. ratio = inner_d / outer_d if outer_d > 0 else 0
  117. passed = (L["min_inner_diameter_mm"] <= inner_d and
  118. L["min_diameter_ratio"] <= ratio <= L["max_diameter_ratio"])
  119. margin = (ratio - L["min_diameter_ratio"]) / L["min_diameter_ratio"] * 100 if ratio >= L["min_diameter_ratio"] else None
  120. results.append(ConstraintResult(
  121. name="inner_diameter_ratio",
  122. category="geometric",
  123. passed=passed,
  124. value=round(ratio, 3),
  125. limit=f"{L['min_diameter_ratio']}-{L['max_diameter_ratio']}",
  126. margin=margin,
  127. message=f"Inner/outer diameter ratio {ratio:.3f} {'valid' if passed else 'invalid'}",
  128. ))
  129. # Airgap range
  130. if airgap is not None:
  131. passed = L["min_airgap_mm"] <= airgap <= L["max_airgap_mm"]
  132. results.append(ConstraintResult(
  133. name="airgap_range",
  134. category="geometric",
  135. passed=passed,
  136. value=airgap,
  137. limit=f"{L['min_airgap_mm']}-{L['max_airgap_mm']}",
  138. message=f"Airgap {airgap}mm {'within' if passed else 'outside'} valid range",
  139. ))
  140. # Magnet thickness
  141. if magnet_thickness is not None:
  142. passed = L["min_magnet_thickness_mm"] <= magnet_thickness <= L["max_magnet_thickness_mm"]
  143. results.append(ConstraintResult(
  144. name="magnet_thickness_range",
  145. category="geometric",
  146. passed=passed,
  147. value=magnet_thickness,
  148. limit=f"{L['min_magnet_thickness_mm']}-{L['max_magnet_thickness_mm']}",
  149. message=f"Magnet thickness {magnet_thickness}mm {'within' if passed else 'outside'} valid range",
  150. ))
  151. # Airgap vs magnet thickness ratio (engineering rule)
  152. if airgap is not None and magnet_thickness is not None:
  153. ratio = airgap / magnet_thickness if magnet_thickness > 0 else 0
  154. passed = 0.05 <= ratio <= 0.5 # typical: airgap 5-50% of magnet thickness
  155. results.append(ConstraintResult(
  156. name="airgap_magnet_ratio",
  157. category="geometric",
  158. passed=passed,
  159. value=round(ratio, 3),
  160. limit="0.05-0.5",
  161. message=f"Airgap/magnet thickness ratio {ratio:.3f} {'reasonable' if passed else 'unusual'}",
  162. ))
  163. return results
  164. def check_electrical(self, params: Dict[str, Any]) -> List[ConstraintResult]:
  165. """Check electrical feasibility constraints."""
  166. results = []
  167. L = self.limits
  168. current_density = params.get("current_density_amm2")
  169. rms_current = params.get("current_a") or params.get("rms_current_a")
  170. conductor_area = params.get("conductor_area_mm2")
  171. slot_fill_factor = params.get("slot_fill_factor")
  172. magnetic_loading = params.get("magnetic_loading_t") or params.get("airgap_flux_density_t")
  173. forced_cooling = params.get("forced_cooling", False)
  174. # Current density (derived or direct)
  175. if current_density is None and rms_current is not None and conductor_area is not None and conductor_area > 0:
  176. current_density = rms_current / conductor_area
  177. if current_density is not None:
  178. limit = L["max_current_density_forced_amm2"] if forced_cooling else L["max_current_density_amm2"]
  179. passed = current_density <= limit
  180. margin = (limit - current_density) / limit * 100 if current_density > 0 else None
  181. results.append(ConstraintResult(
  182. name="current_density",
  183. category="electrical",
  184. passed=passed,
  185. value=round(current_density, 2),
  186. limit=limit,
  187. margin=margin,
  188. message=f"Current density {current_density:.1f} A/mm^2 {'within' if passed else 'exceeds'} {limit} A/mm^2 limit ({'forced' if forced_cooling else 'natural'} cooling)",
  189. ))
  190. # Slot fill factor
  191. if slot_fill_factor is not None:
  192. passed = L["min_slot_fill_factor"] <= slot_fill_factor <= L["max_slot_fill_factor"]
  193. results.append(ConstraintResult(
  194. name="slot_fill_factor",
  195. category="electrical",
  196. passed=passed,
  197. value=slot_fill_factor,
  198. limit=f"{L['min_slot_fill_factor']}-{L['max_slot_fill_factor']}",
  199. message=f"Slot fill factor {slot_fill_factor:.2f} {'within' if passed else 'outside'} valid range",
  200. ))
  201. # Magnetic loading (saturation check)
  202. if magnetic_loading is not None:
  203. passed = magnetic_loading <= L["max_magnetic_loading_t"]
  204. margin = (L["max_magnetic_loading_t"] - magnetic_loading) / L["max_magnetic_loading_t"] * 100
  205. results.append(ConstraintResult(
  206. name="magnetic_loading",
  207. category="electrical",
  208. passed=passed,
  209. value=round(magnetic_loading, 3),
  210. limit=L["max_magnetic_loading_t"],
  211. margin=margin,
  212. message=f"Magnetic loading {magnetic_loading:.2f}T {'below' if passed else 'exceeds'} {L['max_magnetic_loading_t']}T saturation limit",
  213. ))
  214. return results
  215. def check_thermal(self, params: Dict[str, Any]) -> List[ConstraintResult]:
  216. """Check thermal feasibility constraints (first-order estimates)."""
  217. results = []
  218. L = self.limits
  219. ambient_temp = params.get("ambient_temp_c", 25.0)
  220. winding_temp = params.get("winding_temp_c")
  221. magnet_temp = params.get("magnet_temp_c")
  222. total_losses_w = params.get("total_losses_w")
  223. cooling_area_mm2 = params.get("cooling_area_mm2")
  224. # Winding temperature limit
  225. if winding_temp is not None:
  226. passed = winding_temp <= L["max_winding_temp_c"]
  227. margin = (L["max_winding_temp_c"] - winding_temp) / L["max_winding_temp_c"] * 100
  228. results.append(ConstraintResult(
  229. name="winding_temperature",
  230. category="thermal",
  231. passed=passed,
  232. value=winding_temp,
  233. limit=L["max_winding_temp_c"],
  234. margin=margin,
  235. message=f"Winding temperature {winding_temp}C {'below' if passed else 'exceeds'} {L['max_winding_temp_c']}C limit (Class F)",
  236. ))
  237. # Magnet temperature limit
  238. if magnet_temp is not None:
  239. passed = magnet_temp <= L["max_magnet_temp_c"]
  240. margin = (L["max_magnet_temp_c"] - magnet_temp) / L["max_magnet_temp_c"] * 100
  241. results.append(ConstraintResult(
  242. name="magnet_temperature",
  243. category="thermal",
  244. passed=passed,
  245. value=magnet_temp,
  246. limit=L["max_magnet_temp_c"],
  247. margin=margin,
  248. message=f"Magnet temperature {magnet_temp}C {'below' if passed else 'exceeds'} {L['max_magnet_temp_c']}C limit (NdFeB N42SH)",
  249. ))
  250. # First-order thermal estimate: losses vs cooling capacity
  251. if total_losses_w is not None and cooling_area_mm2 is not None and cooling_area_mm2 > 0:
  252. # Rough heat transfer coefficient: 10 W/m^2K (natural), 50 W/m^2K (forced)
  253. forced = params.get("forced_cooling", False)
  254. h = 50.0 if forced else 10.0 # W/m^2K
  255. cooling_area_m2 = cooling_area_mm2 / 1e6
  256. temp_rise = total_losses_w / (h * cooling_area_m2) if cooling_area_m2 > 0 else float('inf')
  257. passed = temp_rise <= L["max_temperature_rise_c"]
  258. results.append(ConstraintResult(
  259. name="thermal_estimate",
  260. category="thermal",
  261. passed=passed,
  262. value=round(temp_rise, 1),
  263. limit=L["max_temperature_rise_c"],
  264. message=f"Estimated temperature rise {temp_rise:.1f}K {'within' if passed else 'exceeds'} {L['max_temperature_rise_c']}K limit (rough estimate, needs L2 verification)",
  265. ))
  266. return results
  267. def check_manufacturing(self, params: Dict[str, Any]) -> List[ConstraintResult]:
  268. """Check PCB manufacturing constraints."""
  269. results = []
  270. L = self.limits
  271. pcb_line_width = params.get("pcb_line_width_mm")
  272. pcb_line_spacing = params.get("pcb_line_spacing_mm")
  273. pcb_copper_thickness_oz = params.get("pcb_copper_thickness_oz")
  274. pcb_layers = params.get("pcb_layers")
  275. via_diameter = params.get("via_diameter_mm")
  276. if pcb_line_width is not None:
  277. passed = pcb_line_width >= L["min_pcb_line_width_mm"]
  278. results.append(ConstraintResult(
  279. name="pcb_line_width",
  280. category="manufacturing",
  281. passed=passed,
  282. value=pcb_line_width,
  283. limit=L["min_pcb_line_width_mm"],
  284. message=f"PCB line width {pcb_line_width}mm {'meets' if passed else 'below'} {L['min_pcb_line_width_mm']}mm minimum",
  285. ))
  286. if pcb_line_spacing is not None:
  287. passed = pcb_line_spacing >= L["min_pcb_line_spacing_mm"]
  288. results.append(ConstraintResult(
  289. name="pcb_line_spacing",
  290. category="manufacturing",
  291. passed=passed,
  292. value=pcb_line_spacing,
  293. limit=L["min_pcb_line_spacing_mm"],
  294. message=f"PCB line spacing {pcb_line_spacing}mm {'meets' if passed else 'below'} {L['min_pcb_line_spacing_mm']}mm minimum",
  295. ))
  296. if pcb_copper_thickness_oz is not None:
  297. passed = L["min_pcb_copper_thickness_oz"] <= pcb_copper_thickness_oz <= L["max_pcb_copper_thickness_oz"]
  298. results.append(ConstraintResult(
  299. name="pcb_copper_thickness",
  300. category="manufacturing",
  301. passed=passed,
  302. value=pcb_copper_thickness_oz,
  303. limit=f"{L['min_pcb_copper_thickness_oz']}-{L['max_pcb_copper_thickness_oz']}",
  304. message=f"PCB copper thickness {pcb_copper_thickness_oz}oz {'within' if passed else 'outside'} standard range",
  305. ))
  306. if pcb_layers is not None:
  307. passed = pcb_layers <= L["max_pcb_layers"]
  308. results.append(ConstraintResult(
  309. name="pcb_layer_count",
  310. category="manufacturing",
  311. passed=passed,
  312. value=pcb_layers,
  313. limit=L["max_pcb_layers"],
  314. message=f"PCB layer count {pcb_layers} {'within' if passed else 'exceeds'} {L['max_pcb_layers']} layer limit",
  315. ))
  316. return results
  317. def evaluate(self, params: Dict[str, Any]) -> FeasibilityReport:
  318. """Run full L0 pre-screening on a parameter set.
  319. Args:
  320. params: Dictionary of parameter values (diameters, airgap, currents, etc.)
  321. Returns:
  322. FeasibilityReport with all check results and overall feasibility.
  323. """
  324. all_results = []
  325. all_results.extend(self.check_geometric(params))
  326. all_results.extend(self.check_electrical(params))
  327. all_results.extend(self.check_thermal(params))
  328. all_results.extend(self.check_manufacturing(params))
  329. passed = sum(1 for r in all_results if r.passed)
  330. failed = len(all_results) - passed
  331. # C3 fix: require minimum coverage. If no checks ran (all params None),
  332. # the gate must not silently pass as "feasible".
  333. MIN_CHECKS = 3
  334. if len(all_results) == 0:
  335. feasible = False
  336. risk_items = ["[UNKNOWN] No checks were performed - input params may be missing or unrecognized."]
  337. elif len(all_results) < MIN_CHECKS:
  338. feasible = failed == 0
  339. risk_items = [f"[WARNING] Only {len(all_results)} check(s) ran (minimum {MIN_CHECKS} recommended); result may be unreliable."]
  340. else:
  341. feasible = failed == 0
  342. risk_items = []
  343. # Collect risk items (failed or low-margin checks)
  344. for r in all_results:
  345. if not r.passed:
  346. risk_items.append(f"[FAIL] {r.name}: {r.message}")
  347. elif r.margin is not None and r.margin < 10:
  348. risk_items.append(f"[RISK] {r.name}: margin only {r.margin:.1f}%")
  349. return FeasibilityReport(
  350. feasible=feasible,
  351. total_checks=len(all_results),
  352. passed_checks=passed,
  353. failed_checks=failed,
  354. results=all_results,
  355. risk_items=risk_items,
  356. )
  357. def is_feasible(self, params: Dict[str, Any]) -> bool:
  358. """Quick feasibility check (boolean only)."""
  359. return self.evaluate(params).feasible
  360. def filter_feasible(self, param_sets: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
  361. """Filter a list of parameter sets into feasible and infeasible.
  362. Returns:
  363. Tuple of (feasible_sets, infeasible_sets)
  364. """
  365. feasible = []
  366. infeasible = []
  367. for params in param_sets:
  368. if self.is_feasible(params):
  369. feasible.append(params)
  370. else:
  371. infeasible.append(params)
  372. return feasible, infeasible