| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423 |
- """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
|