"""Rule engine for AFM motor simulation plan generation. Provides: - Scan parameter registry (Motor-CAD variable names, units, physical ranges) - Boundary-condition-based range recommendation - Initial plan generation from project boundary conditions All source is ASCII. Chinese labels use Unicode escapes. """ from __future__ import annotations import math from dataclasses import dataclass, field from typing import Any # --------------------------------------------------------------------------- # Scan parameter registry # --------------------------------------------------------------------------- @dataclass class ScanParameter: """Definition of a scannable Motor-CAD parameter.""" name: str # Motor-CAD variable name (exact) display_name: str # Human-readable label unit: str # Unit string category: str # Geometry / Electrical / Thermal / Mesh default_start: float # Recommended range start default_stop: float # Recommended range stop default_step: float # Recommended step min_allowed: float # Hard physical minimum max_allowed: float # Hard physical maximum description: str = "" topology_supported: list[str] = field(default_factory=lambda: ["SSSR", "DRSS", "SDSR"]) # Registry of scannable parameters for AFM motors. # Ranges based on MARS-12S10P reference model and AFM design literature. SCAN_PARAMETERS: dict[str, ScanParameter] = { # --- Geometry --- "Airgap": ScanParameter( name="Airgap", display_name="Airgap Length", unit="mm", category="Geometry", default_start=0.6, default_stop=1.5, default_step=0.3, min_allowed=0.2, max_allowed=5.0, description="Mechanical airgap between stator and rotor.", ), "Magnet_Length": ScanParameter( name="Magnet_Length", display_name="Magnet Axial Thickness", unit="mm", category="Geometry", default_start=2.0, default_stop=5.0, default_step=1.0, min_allowed=0.5, max_allowed=15.0, description="Magnet axial thickness (NOT radial depth).", ), "Magnet_Thickness": ScanParameter( name="Magnet_Thickness", display_name="Magnet Radial Depth", unit="mm", category="Geometry", default_start=8.0, default_stop=16.0, default_step=2.0, min_allowed=2.0, max_allowed=40.0, description="Magnet ring radial depth = (D_out - D_in) / 2.", ), "Magnet_Arc_[ED]": ScanParameter( name="Magnet_Arc_[ED]", display_name="Magnet Pole Arc", unit="deg", category="Geometry", default_start=100.0, default_stop=140.0, default_step=10.0, min_allowed=30.0, max_allowed=180.0, description="Magnet pole arc in electrical degrees.", ), # --- Electrical --- "RMSCurrent": ScanParameter( name="RMSCurrent", display_name="RMS Phase Current", unit="A", category="Electrical", default_start=10.0, default_stop=30.0, default_step=5.0, min_allowed=0.5, max_allowed=200.0, description="RMS phase current (CurrentDefinition=1).", ), "Shaft_Speed": ScanParameter( name="Shaft_Speed", display_name="Shaft Speed", unit="rpm", category="Electrical", default_start=3000.0, default_stop=8000.0, default_step=1000.0, min_allowed=100.0, max_allowed=50000.0, description="Rotational shaft speed.", ), # --- Thermal --- "Magnet_Temperature": ScanParameter( name="Magnet_Temperature", display_name="Magnet Temperature", unit="C", category="Thermal", default_start=60.0, default_stop=120.0, default_step=20.0, min_allowed=-40.0, max_allowed=200.0, description="Magnet operating temperature (default 100C hot).", ), # --- Mesh --- "TorquePointsPerCycle": ScanParameter( name="TorquePointsPerCycle", display_name="Torque Points / Cycle", unit="pts", category="Mesh", default_start=60.0, default_stop=180.0, default_step=60.0, min_allowed=12.0, max_allowed=720.0, description="Torque sampling points per electrical cycle.", ), } def get_parameter_registry() -> list[dict[str, Any]]: """Return the scan parameter registry as a list of dicts (for API).""" return [ { "name": p.name, "display_name": p.display_name, "unit": p.unit, "category": p.category, "default_start": p.default_start, "default_stop": p.default_stop, "default_step": p.default_step, "min_allowed": p.min_allowed, "max_allowed": p.max_allowed, "description": p.description, "topology_supported": p.topology_supported, } for p in SCAN_PARAMETERS.values() ] def get_parameter(name: str) -> ScanParameter | None: """Get a parameter definition by name.""" return SCAN_PARAMETERS.get(name) # --------------------------------------------------------------------------- # Boundary condition based range recommendation # --------------------------------------------------------------------------- @dataclass class BoundaryConditions: """Parsed project boundary conditions.""" topology: str = "SSSR" outer_diameter_mm: float | None = None inner_diameter_mm: float | None = None speed_rpm: float | None = None current_a: float | None = None magnet_temp_c: float | None = None target_torque_nm: float | None = None target_efficiency_pct: float | None = None max_losses_w: float | None = None raw: dict[str, Any] = field(default_factory=dict) @classmethod def from_dict(cls, data: dict[str, Any]) -> "BoundaryConditions": """Parse boundary conditions from a project's JSON dict.""" if not data: return cls() return cls( topology=data.get("topology", "SSSR"), outer_diameter_mm=_to_float(data.get("outer_diameter_mm")), inner_diameter_mm=_to_float(data.get("inner_diameter_mm")), speed_rpm=_to_float(data.get("speed_rpm")), current_a=_to_float(data.get("current_a")), magnet_temp_c=_to_float(data.get("magnet_temp_c")), target_torque_nm=_to_float(data.get("target_torque_nm")), target_efficiency_pct=_to_float(data.get("target_efficiency_pct")), max_losses_w=_to_float(data.get("max_losses_w")), raw=data, ) def _to_float(v: Any) -> float | None: """Safely convert a value to float.""" if v is None: return None try: return float(v) except (ValueError, TypeError): return None def recommend_range( param_name: str, bc: BoundaryConditions, ) -> dict[str, float]: """Recommend a scan range for a parameter based on boundary conditions. Returns dict with start, stop, step, and a confidence note. Adjustments are heuristic and based on AFM design scaling rules. """ p = get_parameter(param_name) if p is None: return {"start": 0.0, "stop": 1.0, "step": 0.5} start = p.default_start stop = p.default_stop step = p.default_step notes: list[str] = [] # --- Airgap: scale with outer diameter --- if param_name == "Airgap": if bc.outer_diameter_mm: # Smaller machines tend to use smaller airgaps. # Airgap ~ 0.5-2% of outer diameter for AFM. d = bc.outer_diameter_mm start = max(p.min_allowed, round(d * 0.008, 2)) # ~0.8% D stop = min(p.max_allowed, round(d * 0.02, 2)) # ~2% D step = round((stop - start) / 3, 2) notes.append(f"scaled from D={d}mm") if bc.target_torque_nm and bc.target_torque_nm > 5: # Higher torque targets benefit from smaller airgap exploration. start = max(p.min_allowed, start * 0.8) notes.append("torque target > 5Nm: shifted lower") # --- Magnet axial thickness: scale with diameter and speed --- elif param_name == "Magnet_Length": if bc.outer_diameter_mm: d = bc.outer_diameter_mm start = max(p.min_allowed, round(d * 0.03, 1)) # ~3% D stop = min(p.max_allowed, round(d * 0.07, 1)) # ~7% D step = round((stop - start) / 3, 1) notes.append(f"scaled from D={d}mm") if bc.speed_rpm and bc.speed_rpm > 10000: # High speed: thinner magnets reduce eddy current loss. stop = min(stop, p.default_stop) notes.append("high speed: capped upper range") # --- Magnet radial depth: scale with diameter ratio --- elif param_name == "Magnet_Thickness": if bc.outer_diameter_mm and bc.inner_diameter_mm: radial_depth = (bc.outer_diameter_mm - bc.inner_diameter_mm) / 2 start = max(p.min_allowed, round(radial_depth * 0.6, 1)) stop = min(p.max_allowed, round(radial_depth * 1.0, 1)) step = round((stop - start) / 3, 1) notes.append(f"scaled from radial depth={radial_depth}mm") elif bc.outer_diameter_mm: d = bc.outer_diameter_mm start = max(p.min_allowed, round(d * 0.1, 1)) stop = min(p.max_allowed, round(d * 0.25, 1)) step = round((stop - start) / 3, 1) notes.append(f"scaled from D={d}mm") # --- Pole arc: standard range, adjust for torque/ripple targets --- elif param_name == "Magnet_Arc_[ED]": if bc.target_torque_nm: # Wider arc -> more torque but more ripple. start = 110.0 stop = 150.0 notes.append("torque target: widened upper range") else: start = 100.0 stop = 140.0 # --- RMS current: center around specified current --- elif param_name == "RMSCurrent": if bc.current_a: i = bc.current_a start = max(p.min_allowed, round(i * 0.5, 1)) stop = min(p.max_allowed, round(i * 1.5, 1)) step = round((stop - start) / 4, 1) notes.append(f"centered on I={i}A") if bc.max_losses_w: # Loss-limited: don't go too high on current. stop = min(stop, p.default_stop * 1.2) notes.append("loss constraint: capped upper") # --- Shaft speed: center around specified speed --- elif param_name == "Shaft_Speed": if bc.speed_rpm: s = bc.speed_rpm start = max(p.min_allowed, round(s * 0.6, -2)) stop = min(p.max_allowed, round(s * 1.4, -2)) step = round((stop - start) / 4, -2) notes.append(f"centered on N={s}rpm") # --- Magnet temperature: hot/cold sweep --- elif param_name == "Magnet_Temperature": if bc.magnet_temp_c: t = bc.magnet_temp_c start = max(p.min_allowed, t - 40) stop = min(p.max_allowed, t + 40) step = 20.0 notes.append(f"centered on T={t}C") else: start = 20.0 # cold stop = 120.0 # hot step = 20.0 notes.append("default cold-to-hot sweep") # --- Torque points: based on speed/accuracy needs --- elif param_name == "TorquePointsPerCycle": start = 60.0 stop = 180.0 step = 60.0 if bc.target_efficiency_pct and bc.target_efficiency_pct > 90: stop = 240.0 notes.append("high efficiency target: extended upper range") # Clamp to physical limits start = max(p.min_allowed, min(p.max_allowed, start)) stop = max(p.min_allowed, min(p.max_allowed, stop)) if start >= stop: start = p.default_start stop = p.default_stop if step <= 0: step = p.default_step return { "start": round(start, 4), "stop": round(stop, 4), "step": round(step, 4), "notes": "; ".join(notes) if notes else "default range", } # --------------------------------------------------------------------------- # Plan generation # --------------------------------------------------------------------------- def generate_values(start: float, stop: float, step: float) -> list[float]: """Generate evenly spaced values from start to stop inclusive. Uses round to avoid floating point artifacts. """ if step <= 0: return [start] count = int(math.floor((stop - start) / step + 1e-9)) + 1 values = [round(start + i * step, 6) for i in range(count)] # Ensure stop is included if rounding pushed it slightly off if values and abs(values[-1] - stop) > 1e-9: values.append(round(stop, 6)) return values def estimate_point_count(variables: list[dict[str, Any]]) -> int: """Estimate total scan points (Cartesian product).""" total = 1 for v in variables: if v.get("values"): total *= len(v["values"]) elif v.get("start") is not None and v.get("stop") is not None and v.get("step"): total *= len(generate_values(v["start"], v["stop"], v["step"])) else: total *= 1 return total def generate_plan( bc: BoundaryConditions, param_names: list[str] | None = None, model_path: str = "", ) -> dict[str, Any]: """Generate a recommended simulation plan from boundary conditions. Args: bc: Parsed boundary conditions. param_names: List of parameter names to include. If None, uses a default set (Airgap, Magnet_Length, RMSCurrent). model_path: Path to the baseline .mot model. Returns: Plan dict compatible with src/plan_schema.py and the plans API. """ if param_names is None: # Default scan set: geometry + electrical param_names = ["Airgap", "Magnet_Length", "RMSCurrent"] variables = [] for name in param_names: p = get_parameter(name) if p is None: continue if bc.topology not in p.topology_supported: continue rng = recommend_range(name, bc) values = generate_values(rng["start"], rng["stop"], rng["step"]) variables.append({ "name": name, "display_name": p.display_name, "unit": p.unit, "start": rng["start"], "stop": rng["stop"], "step": rng["step"], "values": values, "recommendation_notes": rng["notes"], }) total_points = estimate_point_count(variables) plan_name = _generate_plan_name(bc, param_names) return { "name": plan_name, "model_path": model_path, "topology": bc.topology, "variables": variables, "cases": [], "estimated_points": total_points, "estimated_time_min": total_points * 3, # ~3 min per point "generation_summary": { "boundary_conditions_used": { k: v for k, v in bc.raw.items() if v is not None }, "parameters_scanned": [v["name"] for v in variables], "total_points": total_points, "rule_engine_version": "1.0", }, } def _generate_plan_name(bc: BoundaryConditions, param_names: list[str]) -> str: """Generate a human-readable plan name.""" short = { "Airgap": "Ag", "Magnet_Length": "Lm", "Magnet_Thickness": "Tm", "Magnet_Arc_[ED]": "Arc", "RMSCurrent": "I", "Shaft_Speed": "N", "Magnet_Temperature": "Tmag", "TorquePointsPerCycle": "Pts", } tags = [short.get(n, n) for n in param_names] parts = ["Auto"] if bc.topology: parts.append(bc.topology) parts.append("-".join(tags)) if bc.outer_diameter_mm: parts.append(f"D{bc.outer_diameter_mm:g}") return "_".join(parts)