"""L0 Analytic Pre-screening Engine (P3-M2). Per third-party review: L0 analytic + rule-based pre-filtering to exclude obviously infeasible regions before any simulation. Covers: - Geometric constraints (inner/outer diameter, airgap, axial length) - Electrical constraints (current density, voltage, magnetic loading) - Thermal constraints (temperature rise, cooling capacity) - Manufacturing constraints (PCB line width/spacing, copper thickness, tolerances) """ import math from dataclasses import dataclass, field from typing import Dict, List, Optional, Tuple, Any @dataclass class ConstraintResult: """Result of a single constraint check.""" name: str category: str # geometric / electrical / thermal / manufacturing passed: bool value: Optional[float] = None limit: Optional[float] = None margin: Optional[float] = None # percentage margin (positive = safe) message: str = "" @dataclass class FeasibilityReport: """Complete L0 feasibility report for a parameter set.""" feasible: bool total_checks: int passed_checks: int failed_checks: int results: List[ConstraintResult] = field(default_factory=list) risk_items: List[str] = field(default_factory=list) @property def pass_rate(self) -> float: return self.passed_checks / self.total_checks if self.total_checks > 0 else 0.0 def to_dict(self) -> Dict[str, Any]: return { "feasible": self.feasible, "total_checks": self.total_checks, "passed_checks": self.passed_checks, "failed_checks": self.failed_checks, "pass_rate": round(self.pass_rate, 4), "results": [ { "name": r.name, "category": r.category, "passed": r.passed, "value": r.value, "limit": r.limit, "margin_pct": round(r.margin, 2) if r.margin is not None else None, "message": r.message, } for r in self.results ], "risk_items": self.risk_items, } class L0PreScreeningEngine: """L0 analytic pre-screening engine. Uses first-order physics and engineering rules to quickly reject infeasible parameter combinations without simulation. """ # Default engineering limits (can be overridden per project) DEFAULT_LIMITS = { # Geometric "min_outer_diameter_mm": 20.0, "max_outer_diameter_mm": 500.0, "min_inner_diameter_mm": 5.0, "min_diameter_ratio": 0.2, # inner/outer "max_diameter_ratio": 0.8, "min_airgap_mm": 0.3, "max_airgap_mm": 5.0, "min_magnet_thickness_mm": 1.0, "max_magnet_thickness_mm": 20.0, # Electrical "max_current_density_amm2": 15.0, # A/mm^2 (natural convection) "max_current_density_forced_amm2": 25.0, # A/mm^2 (forced cooling) "min_slot_fill_factor": 0.3, "max_slot_fill_factor": 0.75, "max_magnetic_loading_t": 1.8, # Tesla (avoid saturation) # Thermal "max_temperature_rise_c": 80.0, # K (above ambient) "max_winding_temp_c": 150.0, # Class F "max_magnet_temp_c": 120.0, # NdFeB N42SH # Manufacturing (PCB) "min_pcb_line_width_mm": 0.1, "min_pcb_line_spacing_mm": 0.1, "min_pcb_copper_thickness_oz": 0.5, "max_pcb_copper_thickness_oz": 6.0, "min_via_diameter_mm": 0.2, "max_pcb_layers": 20, } def __init__(self, limits: Optional[Dict[str, float]] = None): self.limits = dict(self.DEFAULT_LIMITS) if limits: self.limits.update(limits) def check_geometric(self, params: Dict[str, Any]) -> List[ConstraintResult]: """Check geometric feasibility constraints.""" results = [] L = self.limits outer_d = params.get("outer_diameter_mm") inner_d = params.get("inner_diameter_mm") airgap = params.get("airgap_mm") magnet_thickness = params.get("magnet_thickness_mm") # Outer diameter range if outer_d is not None: passed = L["min_outer_diameter_mm"] <= outer_d <= L["max_outer_diameter_mm"] results.append(ConstraintResult( name="outer_diameter_range", category="geometric", passed=passed, value=outer_d, limit=f"{L['min_outer_diameter_mm']}-{L['max_outer_diameter_mm']}", message=f"Outer diameter {outer_d}mm {'within' if passed else 'outside'} valid range", )) # Inner diameter and ratio if outer_d is not None and inner_d is not None: ratio = inner_d / outer_d if outer_d > 0 else 0 passed = (L["min_inner_diameter_mm"] <= inner_d and L["min_diameter_ratio"] <= ratio <= L["max_diameter_ratio"]) margin = (ratio - L["min_diameter_ratio"]) / L["min_diameter_ratio"] * 100 if ratio >= L["min_diameter_ratio"] else None results.append(ConstraintResult( name="inner_diameter_ratio", category="geometric", passed=passed, value=round(ratio, 3), limit=f"{L['min_diameter_ratio']}-{L['max_diameter_ratio']}", margin=margin, message=f"Inner/outer diameter ratio {ratio:.3f} {'valid' if passed else 'invalid'}", )) # Airgap range if airgap is not None: passed = L["min_airgap_mm"] <= airgap <= L["max_airgap_mm"] results.append(ConstraintResult( name="airgap_range", category="geometric", passed=passed, value=airgap, limit=f"{L['min_airgap_mm']}-{L['max_airgap_mm']}", message=f"Airgap {airgap}mm {'within' if passed else 'outside'} valid range", )) # Magnet thickness if magnet_thickness is not None: passed = L["min_magnet_thickness_mm"] <= magnet_thickness <= L["max_magnet_thickness_mm"] results.append(ConstraintResult( name="magnet_thickness_range", category="geometric", passed=passed, value=magnet_thickness, limit=f"{L['min_magnet_thickness_mm']}-{L['max_magnet_thickness_mm']}", message=f"Magnet thickness {magnet_thickness}mm {'within' if passed else 'outside'} valid range", )) # Airgap vs magnet thickness ratio (engineering rule) if airgap is not None and magnet_thickness is not None: ratio = airgap / magnet_thickness if magnet_thickness > 0 else 0 passed = 0.05 <= ratio <= 0.5 # typical: airgap 5-50% of magnet thickness results.append(ConstraintResult( name="airgap_magnet_ratio", category="geometric", passed=passed, value=round(ratio, 3), limit="0.05-0.5", message=f"Airgap/magnet thickness ratio {ratio:.3f} {'reasonable' if passed else 'unusual'}", )) return results def check_electrical(self, params: Dict[str, Any]) -> List[ConstraintResult]: """Check electrical feasibility constraints.""" results = [] L = self.limits current_density = params.get("current_density_amm2") rms_current = params.get("current_a") or params.get("rms_current_a") conductor_area = params.get("conductor_area_mm2") slot_fill_factor = params.get("slot_fill_factor") magnetic_loading = params.get("magnetic_loading_t") or params.get("airgap_flux_density_t") forced_cooling = params.get("forced_cooling", False) # Current density (derived or direct) if current_density is None and rms_current is not None and conductor_area is not None and conductor_area > 0: current_density = rms_current / conductor_area if current_density is not None: limit = L["max_current_density_forced_amm2"] if forced_cooling else L["max_current_density_amm2"] passed = current_density <= limit margin = (limit - current_density) / limit * 100 if current_density > 0 else None results.append(ConstraintResult( name="current_density", category="electrical", passed=passed, value=round(current_density, 2), limit=limit, margin=margin, 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)", )) # Slot fill factor if slot_fill_factor is not None: passed = L["min_slot_fill_factor"] <= slot_fill_factor <= L["max_slot_fill_factor"] results.append(ConstraintResult( name="slot_fill_factor", category="electrical", passed=passed, value=slot_fill_factor, limit=f"{L['min_slot_fill_factor']}-{L['max_slot_fill_factor']}", message=f"Slot fill factor {slot_fill_factor:.2f} {'within' if passed else 'outside'} valid range", )) # Magnetic loading (saturation check) if magnetic_loading is not None: passed = magnetic_loading <= L["max_magnetic_loading_t"] margin = (L["max_magnetic_loading_t"] - magnetic_loading) / L["max_magnetic_loading_t"] * 100 results.append(ConstraintResult( name="magnetic_loading", category="electrical", passed=passed, value=round(magnetic_loading, 3), limit=L["max_magnetic_loading_t"], margin=margin, message=f"Magnetic loading {magnetic_loading:.2f}T {'below' if passed else 'exceeds'} {L['max_magnetic_loading_t']}T saturation limit", )) return results def check_thermal(self, params: Dict[str, Any]) -> List[ConstraintResult]: """Check thermal feasibility constraints (first-order estimates).""" results = [] L = self.limits ambient_temp = params.get("ambient_temp_c", 25.0) winding_temp = params.get("winding_temp_c") magnet_temp = params.get("magnet_temp_c") total_losses_w = params.get("total_losses_w") cooling_area_mm2 = params.get("cooling_area_mm2") # Winding temperature limit if winding_temp is not None: passed = winding_temp <= L["max_winding_temp_c"] margin = (L["max_winding_temp_c"] - winding_temp) / L["max_winding_temp_c"] * 100 results.append(ConstraintResult( name="winding_temperature", category="thermal", passed=passed, value=winding_temp, limit=L["max_winding_temp_c"], margin=margin, message=f"Winding temperature {winding_temp}C {'below' if passed else 'exceeds'} {L['max_winding_temp_c']}C limit (Class F)", )) # Magnet temperature limit if magnet_temp is not None: passed = magnet_temp <= L["max_magnet_temp_c"] margin = (L["max_magnet_temp_c"] - magnet_temp) / L["max_magnet_temp_c"] * 100 results.append(ConstraintResult( name="magnet_temperature", category="thermal", passed=passed, value=magnet_temp, limit=L["max_magnet_temp_c"], margin=margin, message=f"Magnet temperature {magnet_temp}C {'below' if passed else 'exceeds'} {L['max_magnet_temp_c']}C limit (NdFeB N42SH)", )) # First-order thermal estimate: losses vs cooling capacity if total_losses_w is not None and cooling_area_mm2 is not None and cooling_area_mm2 > 0: # Rough heat transfer coefficient: 10 W/m^2K (natural), 50 W/m^2K (forced) forced = params.get("forced_cooling", False) h = 50.0 if forced else 10.0 # W/m^2K cooling_area_m2 = cooling_area_mm2 / 1e6 temp_rise = total_losses_w / (h * cooling_area_m2) if cooling_area_m2 > 0 else float('inf') passed = temp_rise <= L["max_temperature_rise_c"] results.append(ConstraintResult( name="thermal_estimate", category="thermal", passed=passed, value=round(temp_rise, 1), limit=L["max_temperature_rise_c"], 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)", )) return results def check_manufacturing(self, params: Dict[str, Any]) -> List[ConstraintResult]: """Check PCB manufacturing constraints.""" results = [] L = self.limits pcb_line_width = params.get("pcb_line_width_mm") pcb_line_spacing = params.get("pcb_line_spacing_mm") pcb_copper_thickness_oz = params.get("pcb_copper_thickness_oz") pcb_layers = params.get("pcb_layers") via_diameter = params.get("via_diameter_mm") if pcb_line_width is not None: passed = pcb_line_width >= L["min_pcb_line_width_mm"] results.append(ConstraintResult( name="pcb_line_width", category="manufacturing", passed=passed, value=pcb_line_width, limit=L["min_pcb_line_width_mm"], message=f"PCB line width {pcb_line_width}mm {'meets' if passed else 'below'} {L['min_pcb_line_width_mm']}mm minimum", )) if pcb_line_spacing is not None: passed = pcb_line_spacing >= L["min_pcb_line_spacing_mm"] results.append(ConstraintResult( name="pcb_line_spacing", category="manufacturing", passed=passed, value=pcb_line_spacing, limit=L["min_pcb_line_spacing_mm"], message=f"PCB line spacing {pcb_line_spacing}mm {'meets' if passed else 'below'} {L['min_pcb_line_spacing_mm']}mm minimum", )) if pcb_copper_thickness_oz is not None: passed = L["min_pcb_copper_thickness_oz"] <= pcb_copper_thickness_oz <= L["max_pcb_copper_thickness_oz"] results.append(ConstraintResult( name="pcb_copper_thickness", category="manufacturing", passed=passed, value=pcb_copper_thickness_oz, limit=f"{L['min_pcb_copper_thickness_oz']}-{L['max_pcb_copper_thickness_oz']}", message=f"PCB copper thickness {pcb_copper_thickness_oz}oz {'within' if passed else 'outside'} standard range", )) if pcb_layers is not None: passed = pcb_layers <= L["max_pcb_layers"] results.append(ConstraintResult( name="pcb_layer_count", category="manufacturing", passed=passed, value=pcb_layers, limit=L["max_pcb_layers"], message=f"PCB layer count {pcb_layers} {'within' if passed else 'exceeds'} {L['max_pcb_layers']} layer limit", )) return results def evaluate(self, params: Dict[str, Any]) -> FeasibilityReport: """Run full L0 pre-screening on a parameter set. Args: params: Dictionary of parameter values (diameters, airgap, currents, etc.) Returns: FeasibilityReport with all check results and overall feasibility. """ all_results = [] all_results.extend(self.check_geometric(params)) all_results.extend(self.check_electrical(params)) all_results.extend(self.check_thermal(params)) all_results.extend(self.check_manufacturing(params)) passed = sum(1 for r in all_results if r.passed) failed = len(all_results) - passed # C3 fix: require minimum coverage. If no checks ran (all params None), # the gate must not silently pass as "feasible". MIN_CHECKS = 3 if len(all_results) == 0: feasible = False risk_items = ["[UNKNOWN] No checks were performed - input params may be missing or unrecognized."] elif len(all_results) < MIN_CHECKS: feasible = failed == 0 risk_items = [f"[WARNING] Only {len(all_results)} check(s) ran (minimum {MIN_CHECKS} recommended); result may be unreliable."] else: feasible = failed == 0 risk_items = [] # Collect risk items (failed or low-margin checks) for r in all_results: if not r.passed: risk_items.append(f"[FAIL] {r.name}: {r.message}") elif r.margin is not None and r.margin < 10: risk_items.append(f"[RISK] {r.name}: margin only {r.margin:.1f}%") return FeasibilityReport( feasible=feasible, total_checks=len(all_results), passed_checks=passed, failed_checks=failed, results=all_results, risk_items=risk_items, ) def is_feasible(self, params: Dict[str, Any]) -> bool: """Quick feasibility check (boolean only).""" return self.evaluate(params).feasible def filter_feasible(self, param_sets: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: """Filter a list of parameter sets into feasible and infeasible. Returns: Tuple of (feasible_sets, infeasible_sets) """ feasible = [] infeasible = [] for params in param_sets: if self.is_feasible(params): feasible.append(params) else: infeasible.append(params) return feasible, infeasible