| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496 |
- """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
- from .bc_fields import normalize_bc
- # ---------------------------------------------------------------------------
- # 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.",
- ),
- # --- Geometry / winding (frequently recommended by the AI but previously
- # treated as "non-standard" and trimmed). Ranges anchored on the MARS
- # 12S10P baseline (values in fixed_params_template) with engineering
- # variation. default range ~ baseline +/-20-50%, physical limits wider.
- "Stator_Outer_Diameter": ScanParameter(
- name="Stator_Outer_Diameter",
- display_name="Stator Outer Diameter",
- unit="mm",
- category="Geometry",
- default_start=160.0, default_stop=240.0, default_step=10.0,
- min_allowed=50.0, max_allowed=500.0,
- description="Stator lamination outer diameter (MARS baseline 198mm).",
- ),
- "Stator_Inner_Diameter": ScanParameter(
- name="Stator_Inner_Diameter",
- display_name="Stator Inner Diameter",
- unit="mm",
- category="Geometry",
- default_start=80.0, default_stop=160.0, default_step=10.0,
- min_allowed=20.0, max_allowed=400.0,
- description="Stator lamination inner diameter (MARS baseline 122mm).",
- ),
- "Slot_Depth": ScanParameter(
- name="Slot_Depth",
- display_name="Slot Depth",
- unit="mm",
- category="Geometry",
- default_start=4.0, default_stop=12.0, default_step=1.0,
- min_allowed=1.0, max_allowed=30.0,
- description="Stator slot depth (MARS baseline 7mm).",
- ),
- # --- Winding ---
- "Turns_per_Coil": ScanParameter(
- name="Turns_per_Coil",
- display_name="Turns per Coil",
- unit="",
- category="Winding",
- default_start=8.0, default_stop=40.0, default_step=4.0,
- min_allowed=1.0, max_allowed=100.0,
- description="Number of turns per coil (MARS baseline 20; ConductorsPerSlot).",
- ),
- "Wire_Diameter": ScanParameter(
- name="Wire_Diameter",
- display_name="Wire Diameter",
- unit="mm",
- category="Winding",
- default_start=0.8, default_stop=2.5, default_step=0.2,
- min_allowed=0.1, max_allowed=5.0,
- description="Magnet wire diameter (MARS baseline 1.63mm).",
- ),
- }
- 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()
- # Normalize legacy/alias BC keys (rated_*, slot_count, ...) to the
- # canonical keys via the single-source catalog, so older projects using
- # rated_ names are parsed correctly and BC-key handling stays in one
- # place (P1-1).
- data = normalize_bc(data)
- 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)
|