From dd3c1f65fa1b0fb9a4d61f70d6a5fb862b83030b Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Wed, 14 Oct 2026 15:51:57 +0200 Subject: [PATCH 01/25] add winding_layout_JL.py --- machine_design/generic/winding_layout_JL.py | 289 ++++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 machine_design/generic/winding_layout_JL.py diff --git a/machine_design/generic/winding_layout_JL.py b/machine_design/generic/winding_layout_JL.py new file mode 100644 index 0000000..eb7bb31 --- /dev/null +++ b/machine_design/generic/winding_layout_JL.py @@ -0,0 +1,289 @@ +"""Winding layout of an m-phase AC stator winding for AEDT FEM models. + +Assigns a phase letter and polarity to each slot's coil side, for single- +or double-layer windings with integer or fractional slots per pole per +phase q = n/c (c must be coprime with the number of phases). Odd and even +phase counts are supported; an even-phase winding is treated as several +symmetrical sub-phase sets shifted against each other (e.g. six phases = +two 3-phase sets 30 el. deg apart). + +Besides the slot assignment, the layout provides: + - the electrical angle of each phase, + - the winding factor (pitch x distribution), with a warning below 0.9, + - p2_min_fem, the minimum pole count of the FEM sector (odd values mean + an antiperiodic boundary), + - the electrical axis of phase A in the model, + - the coil pitch, auto-selected when not given. + +assign_slot_currents_to_aedt then creates AEDT coils and current windings +from a layout. +""" + +import warnings +from dataclasses import dataclass +from math import ceil, gcd, pi, sin +from fractions import Fraction +from typing import Dict, List, Optional + + +@dataclass(frozen=True) +class WindingLayout: + coil_pitch: int + phase: List[str] + polarity: List[int] + phase_order: List[str] + winding_factor: float + phase_angles: Dict[str, float] # electrical angle [deg] of each phase, e.g. {"A": 0.0, "B": -30.0, ...} + p2_min_fem: int # minimum pole count of the FEM sector (odd allowed - antiperiodic boundary) + phase_a_axis: float # electrical axis [deg, 0..180) of phase A in the model + phase_back: Optional[List[str]] = None + polarity_back: Optional[List[int]] = None + + +def _phase_axis_tables(phases: int): + """ + Returns (phase_letters, phase_signs, phase_angles): the phase letter and + +-1 marker for each of the `phases` phase-group positions used by the + main assignment loop, fixed by sorting all 2*phases physical+mathematical + phase axes by electrical angle (descending) and keeping the first + `phases`; phase_angles maps each phase letter to its electrical angle. + + An even phase count m is treated as x symmetrical (m/x)-phase sets, x + being the largest power of two dividing m, with neighbouring sets + shifted by 360/(2*m) electrical degrees. E.g. six phases are two + symmetrical 3-phase sets (ACE and BDF) shifted by 30 degrees. + """ + if phases % 2 == 0: # even phases + phase_sets = 1 + reduced_phases = phases + gcd_value = 2 + while gcd_value == 2: + gcd_value = gcd(reduced_phases, 2) + phase_sets *= gcd_value + reduced_phases //= gcd_value + + phase_shift_step = -360.0 / reduced_phases + phase_angles_base = [i * phase_shift_step for i in range(reduced_phases)] + phase_angle_matrix = [] + for set_index in range(phase_sets): + set_shift = -360.0 / (2 * phases) * set_index + phase_angle_matrix.append([v + set_shift for v in phase_angles_base]) + # Flatten column-major (set index varies fastest). + phase_angles = [phase_angle_matrix[row][col] for col in range(reduced_phases) for row in range(phase_sets)] + phase_angles.sort(reverse=True) + else: # even phases + phase_shift_step = -360.0 / phases + phase_angles = [i * phase_shift_step for i in range(phases)] + + # phase_angles[i] is the axis of phase chr(65 + i) (A, B, C, ...) + phase_angle_by_letter = {chr(65 + i): angle + 0.0 for i, angle in enumerate(phase_angles)} + + phase_angles_back = [] + for angle in phase_angles: + angle_back = angle + 180.0 + if angle_back > 0: + angle_back -= 360.0 + phase_angles_back.append(angle_back) + + phase_angles_all = phase_angles + phase_angles_back + phase_signs = [1] * phases + [-1] * phases + phase_letter_codes = [chr(65 + i) for i in range(phases)] * 2 + + order = sorted(range(2 * phases), key=lambda i: phase_angles_all[i], reverse=True) + phase_letters_sorted = [phase_letter_codes[i] for i in order] + phase_signs_sorted = [phase_signs[i] for i in order] + + return phase_letters_sorted[:phases], phase_signs_sorted[:phases], phase_angle_by_letter + + +def compute_winding_layout( + phases: int, + slots: int, + pole_pairs: int, + coil_pitch: Optional[int] = None, + layers: int = 1, +) -> WindingLayout: + """Compute per-slot phase letter and polarity for an m-phase AC winding. + + `coil_pitch` is given in slots; None or 0 auto-selects the pitch closest + to (2m-1)/(2m) of the pole pitch (odd for a single layer). For + layers == 1, each slot holds one coil side; `phase_back`/`polarity_back` + are populated only for layers == 2 and describe the second (inner/bottom) + radial coil side per slot. + """ + if layers not in (1, 2): + raise ValueError("layers must be 1 or 2") + + pole_count = 2 * pole_pairs + if slots % (phases) != 0: + raise ValueError(f"slots ({slots}) must be divisible by number of phases ({phases})") + slots_ppp = Fraction(slots, pole_count * phases) # q, as an exact fraction n/c + slots_ppp_numer = slots_ppp.numerator # n + slots_ppp_denom = slots_ppp.denominator # c + if gcd(slots_ppp_denom, phases) != 1: + raise ValueError(f"c ({slots_ppp_denom}) and the number of phases ({phases}) must be coprime for a symmetric winding") + pole_pitch_slots = slots / pole_count # t_pd + + if not coil_pitch: + optimal_pitch_ratio = (2 * phases - 1) / (2 * phases) + optimal_coil_pitch = optimal_pitch_ratio * pole_pitch_slots + coil_pitch = round(optimal_coil_pitch) + if layers == 1 and coil_pitch % 2 == 0: + coil_pitch = coil_pitch - 1 if coil_pitch > optimal_coil_pitch else coil_pitch + 1 + if layers == 1 and coil_pitch % 2 == 0: + raise ValueError(f"single-layer winding requires an odd coil pitch, got {coil_pitch}") + + # effective_n (coil sides per phase group, used for the distribution + # factor) is derived from n, not q; it and c together fix p2_min - the minimum pole count for which the winding + # pattern closes periodically. The model's actual pole count must be an + # integer multiple of p2_min. + if layers == 2: + effective_n = slots_ppp_numer + p2_min = 2 * slots_ppp_denom if slots_ppp_denom % 2 == 1 else slots_ppp_denom + p2_min_fem = slots_ppp_denom + elif slots_ppp_numer % 2 == 0: + effective_n = slots_ppp_numer // 2 + p2_min = 2 * slots_ppp_denom if slots_ppp_denom % 2 == 1 else slots_ppp_denom + p2_min_fem = slots_ppp_denom + else: + effective_n = slots_ppp_numer + p2_min = 2 * slots_ppp_denom + p2_min_fem = slots_ppp_denom if coil_pitch == pole_pitch_slots else 2 * slots_ppp_denom + if pole_count % p2_min != 0: + raise ValueError(f"pole count ({pole_count}) must be a multiple of the minimum pole count {p2_min} required for this winding (phases={phases}, slots={slots}, layers={layers}) to close periodically") + + phase_letters, phase_signs, phase_angles = _phase_axis_tables(phases) + + single_layer_flag = layers % 2 # 1 = single layer (needs gap-fill), 0 = double layer + slot_step = 1 + single_layer_flag + + phase: List[Optional[str]] = [None] * slots + polarity: List[Optional[int]] = [None] * slots + front_slot_indices: List[int] = [] + + # cell_index walks the technological-scheme grid (step slot_step*c); + # slot_index walks the physical slots (step slot_step) - the two only + # stay in lockstep when c == 1. + cell_step = slot_step * slots_ppp_denom + pole_row = 1 + sign = 1 + cell_index = 1 + slot_index = 0 + while cell_index <= pole_count * phases * slots_ppp_numer: + while cell_index > phases * slots_ppp_numer * pole_row: + pole_row += 1 + sign = -sign + phase_group = ceil((cell_index - (pole_row - 1) * phases * slots_ppp_numer) / slots_ppp_numer) # 1..phases + phase[slot_index] = phase_letters[phase_group - 1] + polarity[slot_index] = sign * phase_signs[phase_group - 1] + front_slot_indices.append(slot_index) + slot_index += slot_step + cell_index += cell_step + + phase_back: Optional[List[str]] = None + polarity_back: Optional[List[int]] = None + + if layers == 1: + # For a single layer the loop above fills every other slot only; + # the remaining slots are the back coil sides, related to the front + # ones by the coil pitch: back[s] = -front[(s - coil_pitch) mod slots]. + for slot in range(slots): + if phase[slot] is None: + source = (slot - coil_pitch) % slots + phase[slot] = phase[source] + polarity[slot] = -polarity[source] + else: + phase_back = [None] * slots + polarity_back = [None] * slots + for slot in range(slots): + source = (slot - coil_pitch) % slots + phase_back[slot] = phase[source] + polarity_back[slot] = -polarity[source] + + pitch_ratio = coil_pitch / pole_pitch_slots + pitch_factor = sin(pitch_ratio * pi / 2) + distribution_factor = sin(pi / (2 * phases)) / (effective_n * sin(pi / (2 * phases * effective_n))) + winding_factor = pitch_factor * distribution_factor + if winding_factor < 0.9: + warnings.warn(f"winding factor is low: {winding_factor:.3g} (< 0.9)") + + phase_order = [chr(65 + i) for i in range(phases)] + + # Phase A axis: mean (mod 180) electrical angle of phase A's front coil + # sides, plus half the coil pitch (front side -> coil axis) and half a + # slot pitch (index 0 sits on a tooth, not a slot center - the base coil + # rectangle is rotated by half a slot pitch before being duplicated + # around the machine). + slot_pitch_el = 360.0 * pole_pairs / slots + a_angles = [(i * slot_pitch_el) % 180.0 for i in front_slot_indices if phase[i] == "A"] + if not a_angles: + raise ValueError("phase 'A' has no front coil side in this winding") + front_a_axis = sum(a_angles) / len(a_angles) + phase_a_axis = (front_a_axis + coil_pitch * slot_pitch_el / 2.0 + slot_pitch_el / 2.0) % 180.0 + + return WindingLayout( + coil_pitch=coil_pitch, + phase=phase, + polarity=polarity, + phase_order=phase_order, + winding_factor=winding_factor, + phase_angles=phase_angles, + p2_min_fem=p2_min_fem, + phase_a_axis=phase_a_axis, + phase_back=phase_back, + polarity_back=polarity_back, + ) + + +def assign_slot_currents_to_aedt( + m2d, + coil_objects: List[str], + layout: WindingLayout, + phase_currents: Dict[str, str], + turns_var: str = "Nc", + parallel_paths_var: str = "ParallelPaths", +) -> Dict[str, str]: + """Assign AEDT coils and windings from a WindingLayout. + + Replaces the hand-written assign_coil/assign_winding/add_winding_coils + blocks. `coil_objects` must be in the same slot order as + `layout.phase`/`layout.polarity` (i.e. modeler.get_objects_w_string(...) + right after duplicate_around_axis). Returns {phase_letter: winding_name} + for the windings that were created. + """ + coil_names_by_phase: Dict[str, List[str]] = {p: [] for p in layout.phase_order} + + for i, obj in enumerate(coil_objects): + letter = layout.phase[i] + polarity = "Positive" if layout.polarity[i] > 0 else "Negative" + cs_name = f"CS{i + 1}" + m2d.assign_coil(assignment=[obj], conductors_number=turns_var, polarity=polarity, name=cs_name) + coil_names_by_phase[letter].append(cs_name) + + winding_names: Dict[str, str] = {} + for letter, cs_list in coil_names_by_phase.items(): + if not cs_list or letter not in phase_currents: + continue + winding_name = f"Phase{letter}" + m2d.assign_winding( + assignment=None, + winding_type="Current", + is_solid=False, + current=phase_currents[letter], + parallel_branches=parallel_paths_var, + name=winding_name, + ) + m2d.add_winding_coils(assignment=winding_name, coils=cs_list) + winding_names[letter] = winding_name + + return winding_names + + +if __name__ == "__main__": + layout_6ph = compute_winding_layout(phases=6, slots=48, pole_pairs=5, coil_pitch=5, layers=1) + layout_6ph_v2 = compute_winding_layout(phases=6, slots=48, pole_pairs=2, coil_pitch=11, layers=1) + + layout_3ph = compute_winding_layout(phases=3, slots=6, pole_pairs=2, coil_pitch=1, layers=2) + layout_5ph = compute_winding_layout(phases=5, slots=40, pole_pairs=2, coil_pitch=9, layers=1) + + print("All self-tests passed.") From 74469e9046a4c14be04471a3c5bc31d08b9766dc Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Wed, 14 Oct 2026 16:31:32 +0200 Subject: [PATCH 02/25] assign_phase_windings_new in computation to use new method --- machine_design/designs/computation.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/machine_design/designs/computation.py b/machine_design/designs/computation.py index 5f8cfc0..114f4bc 100644 --- a/machine_design/designs/computation.py +++ b/machine_design/designs/computation.py @@ -4,7 +4,8 @@ from ansys.aedt.core import Maxwell2d from machine_design.generic.compute_initpos import compute_initpos -from machine_design.generic.winding import phase_groups +from machine_design.generic.winding import coil_name, phase_groups +from machine_design.generic.winding_layout_JL import assign_slot_currents_to_aedt, compute_winding_layout from .geometry import GeometryBase from .reserved_variables import check_no_reserved_variable_names @@ -90,6 +91,15 @@ def assign_phase_windings(self, m2d: Maxwell2d, phase_currents: list) -> None: ) m2d.add_winding_coils(assignment=f"Phase{phase_name}", coils=[f"CS_{coil_name}" for coil_name, _ in group]) + def assign_phase_windings_new(self, m2d: Maxwell2d, phase_currents: list, coil_pitch: int) -> None: + g = self.geometry + Q = int(g.geom_params["SlotNumber"]) + n = Q // (2 * g.PolePairs) + layout = compute_winding_layout(g.n_phases, Q, g.PolePairs, coil_pitch=coil_pitch, layers=1) + coil_objects = [coil_name(k) for k in range(n)] + phase_currents_dict = dict(zip(self.phases, phase_currents)) + assign_slot_currents_to_aedt(m2d, coil_objects, layout, phase_currents_dict) + def extract_phase_results(self, solutions): # SI units flux_phases = np.stack([self.extract_expression(solutions, f"FluxLinkage(Phase{p})") for p in self.phases], axis=-1) From f37f98f17948937b5c428982dbbefdce693714d6 Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Wed, 14 Oct 2026 16:43:47 +0200 Subject: [PATCH 03/25] update coil_name --- machine_design/designs/computation.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/machine_design/designs/computation.py b/machine_design/designs/computation.py index 114f4bc..7a561af 100644 --- a/machine_design/designs/computation.py +++ b/machine_design/designs/computation.py @@ -4,7 +4,7 @@ from ansys.aedt.core import Maxwell2d from machine_design.generic.compute_initpos import compute_initpos -from machine_design.generic.winding import coil_name, phase_groups +from machine_design.generic.winding import phase_groups from machine_design.generic.winding_layout_JL import assign_slot_currents_to_aedt, compute_winding_layout from .geometry import GeometryBase @@ -92,6 +92,8 @@ def assign_phase_windings(self, m2d: Maxwell2d, phase_currents: list) -> None: m2d.add_winding_coils(assignment=f"Phase{phase_name}", coils=[f"CS_{coil_name}" for coil_name, _ in group]) def assign_phase_windings_new(self, m2d: Maxwell2d, phase_currents: list, coil_pitch: int) -> None: + from machine_design.generic.winding import coil_name + g = self.geometry Q = int(g.geom_params["SlotNumber"]) n = Q // (2 * g.PolePairs) From cb6d036f123df1339cd951169de75f490db5588d Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Thu, 15 Oct 2026 10:26:40 +0200 Subject: [PATCH 04/25] modify test file according to original test_ansys_regression.py --- ...sys_new_winding_regression_synrm_5f_36s.py | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 tests/test_ansys_new_winding_regression_synrm_5f_36s.py diff --git a/tests/test_ansys_new_winding_regression_synrm_5f_36s.py b/tests/test_ansys_new_winding_regression_synrm_5f_36s.py new file mode 100644 index 0000000..f826729 --- /dev/null +++ b/tests/test_ansys_new_winding_regression_synrm_5f_36s.py @@ -0,0 +1,118 @@ +"""Compare old winding assignment (winding.py) vs. the new one (winding_layout_JL.py). + +Run explicitly with: + + pytest -m ansys tests/test_new_winding_regression_synrm_3f_36s.py + +Requires a running Ansys Electronics Desktop session and a free license seat. +""" + +import numpy as np +import pytest + +from machine_design.config import load_config +from machine_design.designs.design import Design +from machine_design.optimization.generators import HacklGenerator_OneLambda +from motors.synrm_3f_36s import Computation, Geometry + +pytestmark = pytest.mark.ansys + +AEDT_VERSION = load_config()["aedt_version"] +R_STATOR_END = 0.7 +OFFSET = 0.35 +SEEDS = (42, 43) +NUM_CORES = 1 +current_setpoint = (1.5, 1.5) + + +class ComputationNewWinding(Computation): + def assign_stator_coils(self, m2d): + m=self.geometry.n_phases + phase_currents=[f"Im * cos(w*Time-{360 * k / m}deg+epsI)" for k in range(m)] + self.assign_phase_windings_new(m2d,phase_currents,coil_pitch=9) + + +def _generate_one_lambda_barriers(seed): + # rotor_r_min/rotor_r_max are pure-Python derived params, so a throwaway + # instance (no Ansys) is enough to size the generator. + geometry = Geometry() + dummy = Design(m2d=None, geometry=geometry, computation=Computation(geometry)) + generator = HacklGenerator_OneLambda(dummy, R_STATOR_END, offset=OFFSET) + + np.random.seed(seed) + while True: + params = generator.random_parameters() + generator.set_parameters(params) + barriers = generator.generate_barriers() + barriers = generator.split_barriers(barriers) + if generator.feasible_barriers(barriers): + return barriers + + +def _add_rotor(design, barriers): + design.add_rotor() + for barrier in barriers: + design.add_rotor_barrier(barrier) + + +def _create_design(design_cls, project_name, file_name, extra_args=()): + return design_cls.create( + project_name, + "Design01", + file_name, + *extra_args, + version=AEDT_VERSION, + non_graphical=True, + new_desktop=False, + close_on_exit=False, + ) + + +def _compute_torque(design, barriers, add_rotor, setpoint=current_setpoint): + add_rotor(design, barriers) + torque = design.compute(*setpoint, NUM_CORES=NUM_CORES) + design.delete_rotor() + return torque + + +def test_legacy_and_live_design_match_across_rotor_rebuilds(tmp_path): + barrier_sets = [_generate_one_lambda_barriers(seed) for seed in SEEDS] + LegacyDesign = _load_legacy_design_class() + + legacy_design, live_design = None, None + try: + legacy_design = _create_design(LegacyDesign, "RegressionTest_legacy", str(tmp_path / "legacy.aedt")) + legacy_design.solution_expressions = ["Moving1.Torque", *check_expressions] + + def _legacy_extract_results(solutions): + out = {} + for expr in legacy_design.solution_expressions: + out[expr] = np.array(solutions.data_real(expr, convert_to_SI=True)) # use SI units to compare + return out + + legacy_design.extract_results = _legacy_extract_results + + live_geometry = Geometry() + live_computation = Computation(live_geometry) + live_design = _create_design( + LiveDesign, + "RegressionTest_live", + str(tmp_path / "live.aedt"), + extra_args=(live_geometry, live_computation), + ) + + for seed, barriers in zip(SEEDS, barrier_sets): + torque_legacy = _compute_torque(legacy_design, barriers, _add_rotor) + torque_live = _compute_torque(live_design, barriers, _add_rotor) + + assert torque_legacy is not None, f"legacy torque is None for seed {seed}" + assert torque_live is not None, f"live torque is None for seed {seed}" + np.testing.assert_allclose(torque_live["Moving1.Torque"], torque_legacy["Moving1.Torque"], rtol=1e-3, err_msg=f"mismatch for Moving1.Torque, seed {seed}") + for expr in check_expressions: + np.testing.assert_allclose(torque_live[expr], torque_legacy[expr], rtol=1e-3, err_msg=f"mismatch for {expr}, seed {seed}") + + finally: + if live_design is not None: + live_design.close_project() + elif legacy_design is not None: + legacy_design.close_project() From e3f9886749357f66968808022946a7b7407593aa Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Thu, 15 Oct 2026 11:02:59 +0200 Subject: [PATCH 05/25] correct file name and unpdate testing function --- machine_design/designs/computation.py | 2 +- ...ys_new_winding_regression_synrm_3f_36s.py} | 63 +++++++++++-------- 2 files changed, 38 insertions(+), 27 deletions(-) rename tests/{test_ansys_new_winding_regression_synrm_5f_36s.py => test_ansys_new_winding_regression_synrm_3f_36s.py} (64%) diff --git a/machine_design/designs/computation.py b/machine_design/designs/computation.py index 7a561af..c056140 100644 --- a/machine_design/designs/computation.py +++ b/machine_design/designs/computation.py @@ -93,7 +93,7 @@ def assign_phase_windings(self, m2d: Maxwell2d, phase_currents: list) -> None: def assign_phase_windings_new(self, m2d: Maxwell2d, phase_currents: list, coil_pitch: int) -> None: from machine_design.generic.winding import coil_name - + g = self.geometry Q = int(g.geom_params["SlotNumber"]) n = Q // (2 * g.PolePairs) diff --git a/tests/test_ansys_new_winding_regression_synrm_5f_36s.py b/tests/test_ansys_new_winding_regression_synrm_3f_36s.py similarity index 64% rename from tests/test_ansys_new_winding_regression_synrm_5f_36s.py rename to tests/test_ansys_new_winding_regression_synrm_3f_36s.py index f826729..51ea540 100644 --- a/tests/test_ansys_new_winding_regression_synrm_5f_36s.py +++ b/tests/test_ansys_new_winding_regression_synrm_3f_36s.py @@ -2,7 +2,7 @@ Run explicitly with: - pytest -m ansys tests/test_new_winding_regression_synrm_3f_36s.py + pytest -m ansys tests/test_ansys_new_winding_regression_synrm_3f_36s.py Requires a running Ansys Electronics Desktop session and a free license seat. """ @@ -15,6 +15,8 @@ from machine_design.optimization.generators import HacklGenerator_OneLambda from motors.synrm_3f_36s import Computation, Geometry +from ansys.aedt.core import Maxwell2d + pytestmark = pytest.mark.ansys AEDT_VERSION = load_config()["aedt_version"] @@ -23,13 +25,25 @@ SEEDS = (42, 43) NUM_CORES = 1 current_setpoint = (1.5, 1.5) +check_expressions = [ + "Flux_d", + "Flux_q", + "Ui_d", + "Ui_q", + "I_d", + "I_q", + "L_d", + "L_q", + "Irms", +] +# we should verify all the above 9 vars + Moving1.Torque (a total of 10 vars with different seeds) class ComputationNewWinding(Computation): - def assign_stator_coils(self, m2d): - m=self.geometry.n_phases - phase_currents=[f"Im * cos(w*Time-{360 * k / m}deg+epsI)" for k in range(m)] - self.assign_phase_windings_new(m2d,phase_currents,coil_pitch=9) + def assign_stator_coils(self, m2d: Maxwell2d) -> None: + m = self.geometry.n_phases + phase_currents = [f"Im * cos(w*Time-{360 * k / m}deg+epsI)" for k in range(m)] + self.assign_phase_windings_new(m2d, phase_currents, coil_pitch=9) def _generate_one_lambda_barriers(seed): @@ -75,44 +89,41 @@ def _compute_torque(design, barriers, add_rotor, setpoint=current_setpoint): return torque -def test_legacy_and_live_design_match_across_rotor_rebuilds(tmp_path): +def test_new_winding_and_live_design_match_across_rotor_rebuilds(tmp_path): barrier_sets = [_generate_one_lambda_barriers(seed) for seed in SEEDS] - LegacyDesign = _load_legacy_design_class() - legacy_design, live_design = None, None + live_design, new_design = None, None try: - legacy_design = _create_design(LegacyDesign, "RegressionTest_legacy", str(tmp_path / "legacy.aedt")) - legacy_design.solution_expressions = ["Moving1.Torque", *check_expressions] - - def _legacy_extract_results(solutions): - out = {} - for expr in legacy_design.solution_expressions: - out[expr] = np.array(solutions.data_real(expr, convert_to_SI=True)) # use SI units to compare - return out - - legacy_design.extract_results = _legacy_extract_results - live_geometry = Geometry() live_computation = Computation(live_geometry) live_design = _create_design( - LiveDesign, + Design, "RegressionTest_live", str(tmp_path / "live.aedt"), extra_args=(live_geometry, live_computation), ) + new_geometry = Geometry() + new_computation = ComputationNewWinding(new_geometry) + new_design = _create_design( + Design, + "RegressionTest_new", + str(tmp_path / "new.aedt"), + extra_args=(new_geometry, new_computation), + ) + for seed, barriers in zip(SEEDS, barrier_sets): - torque_legacy = _compute_torque(legacy_design, barriers, _add_rotor) torque_live = _compute_torque(live_design, barriers, _add_rotor) + torque_new = _compute_torque(new_design, barriers, _add_rotor) - assert torque_legacy is not None, f"legacy torque is None for seed {seed}" + assert torque_new is not None, f"new torque is None for seed {seed}" assert torque_live is not None, f"live torque is None for seed {seed}" - np.testing.assert_allclose(torque_live["Moving1.Torque"], torque_legacy["Moving1.Torque"], rtol=1e-3, err_msg=f"mismatch for Moving1.Torque, seed {seed}") + np.testing.assert_allclose(torque_live["Moving1.Torque"], torque_new["Moving1.Torque"], rtol=1e-3, err_msg=f"mismatch for Moving1.Torque, seed {seed}") for expr in check_expressions: - np.testing.assert_allclose(torque_live[expr], torque_legacy[expr], rtol=1e-3, err_msg=f"mismatch for {expr}, seed {seed}") + np.testing.assert_allclose(torque_new[expr], torque_live[expr], rtol=1e-3, err_msg=f"mismatch for {expr}, seed {seed}") finally: if live_design is not None: live_design.close_project() - elif legacy_design is not None: - legacy_design.close_project() + elif new_design is not None: + new_design.close_project() From 9d4aa3c25d2cceeff85da7e2e42d47a3af68e839 Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Thu, 15 Oct 2026 12:24:25 +0200 Subject: [PATCH 06/25] add new_winding test of 5f_40s --- ...sys_new_winding_regression_synrm_5f_40s.py | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 tests/test_ansys_new_winding_regression_synrm_5f_40s.py diff --git a/tests/test_ansys_new_winding_regression_synrm_5f_40s.py b/tests/test_ansys_new_winding_regression_synrm_5f_40s.py new file mode 100644 index 0000000..185a97a --- /dev/null +++ b/tests/test_ansys_new_winding_regression_synrm_5f_40s.py @@ -0,0 +1,129 @@ +"""Golden-master check: tests/legacy/design2.py vs. the live machine_design/designs/design.py. + +Generates two feasible rotor designs (different seeds) with HacklGenerator_OneLambda and +runs each through add_rotor -> compute -> delete_rotor on the SAME Design instance, both +with the frozen reference implementation and the current one - mirroring how an +optimization loop reuses one Design across many candidate rotors. Asserts the resulting +torque waveforms match at each step. Run explicitly with: + + pytest -m ansys tests/test_ansys_new_winding_regression_synrm_5f_40s.py + +Requires a running Ansys Electronics Desktop session and a free license seat. +""" + +import numpy as np +import pytest +from legacy.design2 import Design2 + +from machine_design.config import load_config +from machine_design.designs.design import Design as LiveDesign +from machine_design.optimization.generators import HacklGenerator_OneLambda +from motors.synrm_5f_40s import Computation, Geometry + +pytestmark = pytest.mark.ansys + +AEDT_VERSION = load_config()["aedt_version"] +R_STATOR_END = 0.7 +OFFSET = 0.35 +SEEDS = (42, 43) +NUM_CORES = 1 +current_setpoint = (7.0711, 7.0711, 0.0, 0.0) + + +class ComputationNewWinding(Computation): + def assign_stator_coils(self, m2d) -> None: + m = self.geometry.n_phases + phase_currents = [f"Im1 * cos(w*Time-{360 * k / m}deg+epsI1-pi)+Im3*cos(3*(w*Time-{360 * k / m}deg)+epsI3-pi)" for k in range(m)] + self.assign_phase_windings_new(m2d, phase_currents, coil_pitch=9) + + +def _generate_one_lambda_barriers(seed): + # rotor_r_min/rotor_r_max are pure-Python derived params, so a throwaway + # instance (no Ansys) is enough to size the generator. + geometry = Geometry() + dummy = LiveDesign(m2d=None, geometry=geometry, computation=Computation(geometry)) + generator = HacklGenerator_OneLambda(dummy, R_STATOR_END, offset=OFFSET) + + np.random.seed(seed) + while True: + params = generator.random_parameters() + generator.set_parameters(params) + barriers = generator.generate_barriers() + barriers = generator.split_barriers(barriers) + if generator.feasible_barriers(barriers): + return barriers + + +def _add_rotor(design, barriers): + design.add_rotor() + for barrier in barriers: + design.add_rotor_barrier(barrier) + + +def _create_design(design_cls, project_name, file_name, extra_args=()): + return design_cls.create( + project_name, + "Design01", + file_name, + *extra_args, + version=AEDT_VERSION, + non_graphical=True, + new_desktop=False, + close_on_exit=False, + ) + + +def _compute_torque(design, barriers, add_rotor, setpoint=current_setpoint): + add_rotor(design, barriers) + torque = design.compute(*setpoint, NUM_CORES=NUM_CORES) + design.delete_rotor() + return torque + + +def test_legacy_live_and_new_winding_design_match_across_rotor_rebuilds(tmp_path): + barrier_sets = [_generate_one_lambda_barriers(seed) for seed in SEEDS] + + legacy_design, live_design, new_design = None, None, None + try: + legacy_design = _create_design(Design2, "RegressionTest_legacy", str(tmp_path / "legacy.aedt")) + + live_geometry = Geometry() + live_computation = Computation(live_geometry) + live_design = _create_design( + LiveDesign, + "RegressionTest_live", + str(tmp_path / "live.aedt"), + extra_args=(live_geometry, live_computation), + ) + + new_geometry = Geometry() + new_computation = ComputationNewWinding(new_geometry) + new_design = _create_design( + LiveDesign, + "RegressionTest_new", + str(tmp_path / "new.aedt"), + extra_args=(new_geometry, new_computation), + ) + + for seed, barriers in zip(SEEDS, barrier_sets): + torque_legacy = _compute_torque(legacy_design, barriers, _add_rotor) + torque_live = _compute_torque(live_design, barriers, _add_rotor) + torque_new = _compute_torque(new_design, barriers, _add_rotor) + + assert torque_legacy is not None, f"legacy torque is None for seed {seed}" + assert torque_live is not None, f"live torque is None for seed {seed}" + assert torque_new is not None, f"new torque is None for seed {seed}" + + for expr in legacy_design.solution_expressions: + np.testing.assert_allclose(torque_live[expr], torque_legacy[expr], rtol=1e-3, atol=2e-4, err_msg=f"mismatch for {expr}, seed {seed}") + np.testing.assert_allclose(torque_new[expr], torque_legacy[expr], rtol=1e-3, atol=2e-4, err_msg=f"mismatch for {expr}, seed {seed}") + # rtol is a relative tolerance, but when current_setpoint = (7.0711, 7.0711, 0.0, 0.0) is used, + # I_d3 is 1e-15 and 2.2e-16 for legacy and live, respectively, rtol is not meaningful when comparing such small numbers, + # so atol (absolute tolerance) is used to avoid false failures. + finally: + if live_design is not None: + live_design.close_project() + elif legacy_design is not None: + legacy_design.close_project() + elif new_design is not None: + new_design.close_project() From ab06a593c3858ac942f92f91d044afe4ac4df6aa Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Thu, 15 Oct 2026 13:02:09 +0200 Subject: [PATCH 07/25] compare new_winding with old and legacy --- ...sys_new_winding_regression_synrm_3f_36s.py | 51 ++++++++++++++----- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/tests/test_ansys_new_winding_regression_synrm_3f_36s.py b/tests/test_ansys_new_winding_regression_synrm_3f_36s.py index 51ea540..cff423a 100644 --- a/tests/test_ansys_new_winding_regression_synrm_3f_36s.py +++ b/tests/test_ansys_new_winding_regression_synrm_3f_36s.py @@ -7,16 +7,17 @@ Requires a running Ansys Electronics Desktop session and a free license seat. """ +import importlib.util +from pathlib import Path + import numpy as np import pytest from machine_design.config import load_config -from machine_design.designs.design import Design +from machine_design.designs.design import Design as LiveDesign from machine_design.optimization.generators import HacklGenerator_OneLambda from motors.synrm_3f_36s import Computation, Geometry -from ansys.aedt.core import Maxwell2d - pytestmark = pytest.mark.ansys AEDT_VERSION = load_config()["aedt_version"] @@ -40,17 +41,25 @@ class ComputationNewWinding(Computation): - def assign_stator_coils(self, m2d: Maxwell2d) -> None: + def assign_stator_coils(self, m2d): m = self.geometry.n_phases phase_currents = [f"Im * cos(w*Time-{360 * k / m}deg+epsI)" for k in range(m)] self.assign_phase_windings_new(m2d, phase_currents, coil_pitch=9) +def _load_legacy_design_class(): + legacy_path = Path(__file__).parent / "legacy" / "design.py" + spec = importlib.util.spec_from_file_location("legacy_design", legacy_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.Design + + def _generate_one_lambda_barriers(seed): # rotor_r_min/rotor_r_max are pure-Python derived params, so a throwaway # instance (no Ansys) is enough to size the generator. geometry = Geometry() - dummy = Design(m2d=None, geometry=geometry, computation=Computation(geometry)) + dummy = LiveDesign(m2d=None, geometry=geometry, computation=Computation(geometry)) generator = HacklGenerator_OneLambda(dummy, R_STATOR_END, offset=OFFSET) np.random.seed(seed) @@ -89,15 +98,27 @@ def _compute_torque(design, barriers, add_rotor, setpoint=current_setpoint): return torque -def test_new_winding_and_live_design_match_across_rotor_rebuilds(tmp_path): +def test_legacy_live_and_new_winding_design_match_across_rotor_rebuilds(tmp_path): barrier_sets = [_generate_one_lambda_barriers(seed) for seed in SEEDS] + LegacyDesign = _load_legacy_design_class() - live_design, new_design = None, None + legacy_design, live_design, new_design = None, None, None try: + legacy_design = _create_design(LegacyDesign, "RegressionTest_legacy", str(tmp_path / "legacy.aedt")) + legacy_design.solution_expressions = ["Moving1.Torque", *check_expressions] + + def _legacy_extract_results(solutions): + out = {} + for expr in legacy_design.solution_expressions: + out[expr] = np.array(solutions.data_real(expr, convert_to_SI=True)) # use SI units to compare + return out + + legacy_design.extract_results = _legacy_extract_results + live_geometry = Geometry() live_computation = Computation(live_geometry) live_design = _create_design( - Design, + LiveDesign, "RegressionTest_live", str(tmp_path / "live.aedt"), extra_args=(live_geometry, live_computation), @@ -106,24 +127,30 @@ def test_new_winding_and_live_design_match_across_rotor_rebuilds(tmp_path): new_geometry = Geometry() new_computation = ComputationNewWinding(new_geometry) new_design = _create_design( - Design, + LiveDesign, "RegressionTest_new", str(tmp_path / "new.aedt"), extra_args=(new_geometry, new_computation), ) for seed, barriers in zip(SEEDS, barrier_sets): + torque_legacy = _compute_torque(legacy_design, barriers, _add_rotor) torque_live = _compute_torque(live_design, barriers, _add_rotor) torque_new = _compute_torque(new_design, barriers, _add_rotor) - assert torque_new is not None, f"new torque is None for seed {seed}" + assert torque_legacy is not None, f"legacy torque is None for seed {seed}" assert torque_live is not None, f"live torque is None for seed {seed}" - np.testing.assert_allclose(torque_live["Moving1.Torque"], torque_new["Moving1.Torque"], rtol=1e-3, err_msg=f"mismatch for Moving1.Torque, seed {seed}") + assert torque_new is not None, f"new torque is None for seed {seed}" + np.testing.assert_allclose(torque_live["Moving1.Torque"], torque_legacy["Moving1.Torque"], rtol=1e-3, err_msg=f"mismatch for Moving1.Torque, seed {seed}") + np.testing.assert_allclose(torque_new["Moving1.Torque"], torque_legacy["Moving1.Torque"], rtol=1e-3, err_msg=f"mismatch for Moving1.Torque, seed {seed}") for expr in check_expressions: - np.testing.assert_allclose(torque_new[expr], torque_live[expr], rtol=1e-3, err_msg=f"mismatch for {expr}, seed {seed}") + np.testing.assert_allclose(torque_live[expr], torque_legacy[expr], rtol=1e-3, err_msg=f"mismatch for {expr}, seed {seed}") + np.testing.assert_allclose(torque_new[expr], torque_legacy[expr], rtol=1e-3, err_msg=f"mismatch for {expr}, seed {seed}") finally: if live_design is not None: live_design.close_project() elif new_design is not None: new_design.close_project() + elif legacy_design is not None: + legacy_design.close_project() From 5ccb92b5a736bfa3a59e413c5acfe09c5ccca554 Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Thu, 15 Oct 2026 13:27:09 +0200 Subject: [PATCH 08/25] compare new, live and legacy with each other --- .../test_ansys_new_winding_regression_synrm_3f_36s.py | 10 ++++++---- .../test_ansys_new_winding_regression_synrm_5f_40s.py | 5 +++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/test_ansys_new_winding_regression_synrm_3f_36s.py b/tests/test_ansys_new_winding_regression_synrm_3f_36s.py index cff423a..b591221 100644 --- a/tests/test_ansys_new_winding_regression_synrm_3f_36s.py +++ b/tests/test_ansys_new_winding_regression_synrm_3f_36s.py @@ -141,11 +141,13 @@ def _legacy_extract_results(solutions): assert torque_legacy is not None, f"legacy torque is None for seed {seed}" assert torque_live is not None, f"live torque is None for seed {seed}" assert torque_new is not None, f"new torque is None for seed {seed}" - np.testing.assert_allclose(torque_live["Moving1.Torque"], torque_legacy["Moving1.Torque"], rtol=1e-3, err_msg=f"mismatch for Moving1.Torque, seed {seed}") - np.testing.assert_allclose(torque_new["Moving1.Torque"], torque_legacy["Moving1.Torque"], rtol=1e-3, err_msg=f"mismatch for Moving1.Torque, seed {seed}") + np.testing.assert_allclose(torque_live["Moving1.Torque"], torque_legacy["Moving1.Torque"], rtol=1e-3, err_msg=f"live legacy mismatch for Moving1.Torque, seed {seed}") + np.testing.assert_allclose(torque_new["Moving1.Torque"], torque_legacy["Moving1.Torque"], rtol=1e-3, err_msg=f"new legacy mismatch for Moving1.Torque, seed {seed}") + np.testing.assert_allclose(torque_new["Moving1.Torque"], torque_live["Moving1.Torque"], rtol=1e-3, err_msg=f"new live mismatch for Moving1.Torque, seed {seed}") for expr in check_expressions: - np.testing.assert_allclose(torque_live[expr], torque_legacy[expr], rtol=1e-3, err_msg=f"mismatch for {expr}, seed {seed}") - np.testing.assert_allclose(torque_new[expr], torque_legacy[expr], rtol=1e-3, err_msg=f"mismatch for {expr}, seed {seed}") + np.testing.assert_allclose(torque_live[expr], torque_legacy[expr], rtol=1e-3, err_msg=f"live legacy mismatch for {expr}, seed {seed}") + np.testing.assert_allclose(torque_new[expr], torque_legacy[expr], rtol=1e-3, err_msg=f"new legacy mismatch for {expr}, seed {seed}") + np.testing.assert_allclose(torque_new[expr], torque_live[expr], rtol=1e-3, err_msg=f"new live mismatch for {expr}, seed {seed}") finally: if live_design is not None: diff --git a/tests/test_ansys_new_winding_regression_synrm_5f_40s.py b/tests/test_ansys_new_winding_regression_synrm_5f_40s.py index 185a97a..c87b6e9 100644 --- a/tests/test_ansys_new_winding_regression_synrm_5f_40s.py +++ b/tests/test_ansys_new_winding_regression_synrm_5f_40s.py @@ -115,8 +115,9 @@ def test_legacy_live_and_new_winding_design_match_across_rotor_rebuilds(tmp_path assert torque_new is not None, f"new torque is None for seed {seed}" for expr in legacy_design.solution_expressions: - np.testing.assert_allclose(torque_live[expr], torque_legacy[expr], rtol=1e-3, atol=2e-4, err_msg=f"mismatch for {expr}, seed {seed}") - np.testing.assert_allclose(torque_new[expr], torque_legacy[expr], rtol=1e-3, atol=2e-4, err_msg=f"mismatch for {expr}, seed {seed}") + np.testing.assert_allclose(torque_live[expr], torque_legacy[expr], rtol=1e-3, atol=2e-4, err_msg=f"live legacy mismatch for {expr}, seed {seed}") + np.testing.assert_allclose(torque_new[expr], torque_legacy[expr], rtol=1e-3, atol=2e-4, err_msg=f"new legacy mismatch for {expr}, seed {seed}") + np.testing.assert_allclose(torque_new[expr], torque_live[expr], rtol=1e-3, atol=2e-4, err_msg=f"new live mismatch for {expr}, seed {seed}") # rtol is a relative tolerance, but when current_setpoint = (7.0711, 7.0711, 0.0, 0.0) is used, # I_d3 is 1e-15 and 2.2e-16 for legacy and live, respectively, rtol is not meaningful when comparing such small numbers, # so atol (absolute tolerance) is used to avoid false failures. From 1e5aa90374f2317255364da06618539c4546339b Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Thu, 15 Oct 2026 14:12:07 +0200 Subject: [PATCH 09/25] define coil_name --- machine_design/designs/computation.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/machine_design/designs/computation.py b/machine_design/designs/computation.py index c056140..6c56868 100644 --- a/machine_design/designs/computation.py +++ b/machine_design/designs/computation.py @@ -92,13 +92,11 @@ def assign_phase_windings(self, m2d: Maxwell2d, phase_currents: list) -> None: m2d.add_winding_coils(assignment=f"Phase{phase_name}", coils=[f"CS_{coil_name}" for coil_name, _ in group]) def assign_phase_windings_new(self, m2d: Maxwell2d, phase_currents: list, coil_pitch: int) -> None: - from machine_design.generic.winding import coil_name - g = self.geometry Q = int(g.geom_params["SlotNumber"]) n = Q // (2 * g.PolePairs) layout = compute_winding_layout(g.n_phases, Q, g.PolePairs, coil_pitch=coil_pitch, layers=1) - coil_objects = [coil_name(k) for k in range(n)] + coil_objects = ["Coil" if k == 0 else f"Coil_{k}" for k in range(n)] phase_currents_dict = dict(zip(self.phases, phase_currents)) assign_slot_currents_to_aedt(m2d, coil_objects, layout, phase_currents_dict) From 81adacbf53457ec2691b23fa67c10625f0eb0bba Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Thu, 15 Oct 2026 16:04:42 +0200 Subject: [PATCH 10/25] replace set_initpos --- machine_design/designs/computation.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/machine_design/designs/computation.py b/machine_design/designs/computation.py index 6c56868..bd2e07a 100644 --- a/machine_design/designs/computation.py +++ b/machine_design/designs/computation.py @@ -48,6 +48,13 @@ def set_initpos(self) -> None: self.InitPos = compute_initpos(Q, g.PolePairs, g.n_phases, belt_offset=g.belt_offset) self.oper_params["InitPos"] = f"{self.InitPos}deg" + def set_initpos_new(self, coil_pitch: int) -> None: + g = self.geometry + Q = int(g.geom_params["SlotNumber"]) + layout = compute_winding_layout(g.n_phases, Q, g.PolePairs, coil_pitch=coil_pitch, layers=1) + self.InitPos = (layout.phase_a_axis - 180) / g.PolePairs + self.oper_params["InitPos"] = f"{self.InitPos}deg" + def compute_phase_groups(self): g = self.geometry Q = int(g.geom_params["SlotNumber"]) From 59fe88603760c99aee81981708ac832cb95493d6 Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Thu, 15 Oct 2026 16:11:14 +0200 Subject: [PATCH 11/25] use new method to replace compute_initpos --- tests/test_ansys_new_winding_regression_synrm_3f_36s.py | 8 +++++++- tests/test_ansys_new_winding_regression_synrm_5f_40s.py | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/test_ansys_new_winding_regression_synrm_3f_36s.py b/tests/test_ansys_new_winding_regression_synrm_3f_36s.py index b591221..20c6708 100644 --- a/tests/test_ansys_new_winding_regression_synrm_3f_36s.py +++ b/tests/test_ansys_new_winding_regression_synrm_3f_36s.py @@ -41,10 +41,16 @@ class ComputationNewWinding(Computation): + coil_pitch=9 + + def set_oper_params(self): + super().set_oper_params() + self.set_initpos_new(self.coil_pitch) + def assign_stator_coils(self, m2d): m = self.geometry.n_phases phase_currents = [f"Im * cos(w*Time-{360 * k / m}deg+epsI)" for k in range(m)] - self.assign_phase_windings_new(m2d, phase_currents, coil_pitch=9) + self.assign_phase_windings_new(m2d, phase_currents, coil_pitch=self.coil_pitch) def _load_legacy_design_class(): diff --git a/tests/test_ansys_new_winding_regression_synrm_5f_40s.py b/tests/test_ansys_new_winding_regression_synrm_5f_40s.py index c87b6e9..cac5ccf 100644 --- a/tests/test_ansys_new_winding_regression_synrm_5f_40s.py +++ b/tests/test_ansys_new_winding_regression_synrm_5f_40s.py @@ -31,10 +31,16 @@ class ComputationNewWinding(Computation): + coil_pitch=9 + + def set_oper_params(self): + super().set_oper_params() + self.set_initpos_new(self.coil_pitch) + def assign_stator_coils(self, m2d) -> None: m = self.geometry.n_phases phase_currents = [f"Im1 * cos(w*Time-{360 * k / m}deg+epsI1-pi)+Im3*cos(3*(w*Time-{360 * k / m}deg)+epsI3-pi)" for k in range(m)] - self.assign_phase_windings_new(m2d, phase_currents, coil_pitch=9) + self.assign_phase_windings_new(m2d, phase_currents, coil_pitch=self.coil_pitch) def _generate_one_lambda_barriers(seed): From 432dfe457a8b980400edc6f6f17a2bbe19748924 Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Fri, 16 Oct 2026 08:19:44 +0200 Subject: [PATCH 12/25] merge set_initpos and assign_phase_windings to use new method --- machine_design/designs/computation.py | 34 +++------------------------ 1 file changed, 3 insertions(+), 31 deletions(-) diff --git a/machine_design/designs/computation.py b/machine_design/designs/computation.py index bd2e07a..4706314 100644 --- a/machine_design/designs/computation.py +++ b/machine_design/designs/computation.py @@ -3,8 +3,6 @@ import numpy as np from ansys.aedt.core import Maxwell2d -from machine_design.generic.compute_initpos import compute_initpos -from machine_design.generic.winding import phase_groups from machine_design.generic.winding_layout_JL import assign_slot_currents_to_aedt, compute_winding_layout from .geometry import GeometryBase @@ -45,21 +43,11 @@ def extract_expression(solutions, expr: str) -> np.ndarray: def set_initpos(self) -> None: g = self.geometry Q = int(g.geom_params["SlotNumber"]) - self.InitPos = compute_initpos(Q, g.PolePairs, g.n_phases, belt_offset=g.belt_offset) - self.oper_params["InitPos"] = f"{self.InitPos}deg" - - def set_initpos_new(self, coil_pitch: int) -> None: - g = self.geometry - Q = int(g.geom_params["SlotNumber"]) + coil_pitch=int(g.wind_params["CoilPitch"]) layout = compute_winding_layout(g.n_phases, Q, g.PolePairs, coil_pitch=coil_pitch, layers=1) self.InitPos = (layout.phase_a_axis - 180) / g.PolePairs self.oper_params["InitPos"] = f"{self.InitPos}deg" - def compute_phase_groups(self): - g = self.geometry - Q = int(g.geom_params["SlotNumber"]) - return phase_groups(Q, g.PolePairs, g.n_phases, belt_offset=g.belt_offset) - def set_solution_expressions(self): self.solution_expressions = [ "Moving1.Position", @@ -81,27 +69,11 @@ def set_post_params(self): tuple(f"FluxLinkage(Phase{p})" for p in self.phases): "FluxLinkage", } - def assign_phase_windings(self, m2d: Maxwell2d, phase_currents: list) -> None: - groups = self.compute_phase_groups() - for group in groups: - for coil_name, polarity in group: - m2d.assign_coil(assignment=[coil_name], conductors_number="Nc", polarity=polarity, name=f"CS_{coil_name}") - - for phase_name, current, group in zip(self.phases, phase_currents, groups): - m2d.assign_winding( - assignment=None, - winding_type="Current", - is_solid=False, - current=current, - parallel_branches="ParallelPaths", - name=f"Phase{phase_name}", - ) - m2d.add_winding_coils(assignment=f"Phase{phase_name}", coils=[f"CS_{coil_name}" for coil_name, _ in group]) - - def assign_phase_windings_new(self, m2d: Maxwell2d, phase_currents: list, coil_pitch: int) -> None: + def assign_phase_windings(self, m2d: Maxwell2d, phase_currents: list, coil_pitch: int) -> None: g = self.geometry Q = int(g.geom_params["SlotNumber"]) n = Q // (2 * g.PolePairs) + coil_pitch=int(g.wind_params["CoilPitch"]) layout = compute_winding_layout(g.n_phases, Q, g.PolePairs, coil_pitch=coil_pitch, layers=1) coil_objects = ["Coil" if k == 0 else f"Coil_{k}" for k in range(n)] phase_currents_dict = dict(zip(self.phases, phase_currents)) From cc3f936dac9ecf6d7eae6fa5454d85cc19b7c8a0 Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Fri, 16 Oct 2026 08:24:37 +0200 Subject: [PATCH 13/25] ruff format --- machine_design/designs/computation.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/machine_design/designs/computation.py b/machine_design/designs/computation.py index 4706314..dd766ff 100644 --- a/machine_design/designs/computation.py +++ b/machine_design/designs/computation.py @@ -43,8 +43,7 @@ def extract_expression(solutions, expr: str) -> np.ndarray: def set_initpos(self) -> None: g = self.geometry Q = int(g.geom_params["SlotNumber"]) - coil_pitch=int(g.wind_params["CoilPitch"]) - layout = compute_winding_layout(g.n_phases, Q, g.PolePairs, coil_pitch=coil_pitch, layers=1) + layout = compute_winding_layout(g.n_phases, Q, g.PolePairs, coil_pitch=int(g.wind_params["CoilPitch"]), layers=int(g.wind_params["Layers"])) self.InitPos = (layout.phase_a_axis - 180) / g.PolePairs self.oper_params["InitPos"] = f"{self.InitPos}deg" @@ -73,8 +72,7 @@ def assign_phase_windings(self, m2d: Maxwell2d, phase_currents: list, coil_pitch g = self.geometry Q = int(g.geom_params["SlotNumber"]) n = Q // (2 * g.PolePairs) - coil_pitch=int(g.wind_params["CoilPitch"]) - layout = compute_winding_layout(g.n_phases, Q, g.PolePairs, coil_pitch=coil_pitch, layers=1) + layout = compute_winding_layout(g.n_phases, Q, g.PolePairs, coil_pitch=int(g.wind_params["CoilPitch"]), layers=int(g.wind_params["Layers"])) coil_objects = ["Coil" if k == 0 else f"Coil_{k}" for k in range(n)] phase_currents_dict = dict(zip(self.phases, phase_currents)) assign_slot_currents_to_aedt(m2d, coil_objects, layout, phase_currents_dict) From d29282c2bfd84dec92938734317cd5a30f6bb0d6 Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Fri, 16 Oct 2026 08:39:55 +0200 Subject: [PATCH 14/25] delete belt_offset --- motors/synrm_3f_36s.py | 1 - motors/synrm_5f_40s.py | 1 - 2 files changed, 2 deletions(-) diff --git a/motors/synrm_3f_36s.py b/motors/synrm_3f_36s.py index 7228355..fd1ecff 100644 --- a/motors/synrm_3f_36s.py +++ b/motors/synrm_3f_36s.py @@ -49,7 +49,6 @@ def set_winds_params(self): def set_mod_params(self): self.PolePairs = 2 self.n_phases = 3 - self.belt_offset = 0 self.mod_params = { "Poles": f"2*{self.PolePairs}", "ModelLength": "85mm", diff --git a/motors/synrm_5f_40s.py b/motors/synrm_5f_40s.py index 8776501..b15870a 100644 --- a/motors/synrm_5f_40s.py +++ b/motors/synrm_5f_40s.py @@ -31,7 +31,6 @@ def set_winds_params(self): def set_mod_params(self): super().set_mod_params() self.n_phases = 5 - self.belt_offset = 1 class Computation(ComputationBase): From 0be06ba1f28393db33b63e7e27f904e12739c11a Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Fri, 16 Oct 2026 08:44:05 +0200 Subject: [PATCH 15/25] read layers from machines instead of hardcoding --- machine_design/designs/computation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/machine_design/designs/computation.py b/machine_design/designs/computation.py index dd766ff..c1a85b9 100644 --- a/machine_design/designs/computation.py +++ b/machine_design/designs/computation.py @@ -68,7 +68,7 @@ def set_post_params(self): tuple(f"FluxLinkage(Phase{p})" for p in self.phases): "FluxLinkage", } - def assign_phase_windings(self, m2d: Maxwell2d, phase_currents: list, coil_pitch: int) -> None: + def assign_phase_windings(self, m2d: Maxwell2d, phase_currents: list) -> None: g = self.geometry Q = int(g.geom_params["SlotNumber"]) n = Q // (2 * g.PolePairs) From 8c9740ceaac95cad8b6f6bb52675093c60f0c3f9 Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Fri, 16 Oct 2026 08:44:31 +0200 Subject: [PATCH 16/25] delete old unnecessary files --- machine_design/generic/compute_initpos.py | 24 --- machine_design/generic/winding.py | 96 ---------- ...sys_new_winding_regression_synrm_3f_36s.py | 164 ------------------ ...sys_new_winding_regression_synrm_5f_40s.py | 136 --------------- 4 files changed, 420 deletions(-) delete mode 100644 machine_design/generic/compute_initpos.py delete mode 100644 machine_design/generic/winding.py delete mode 100644 tests/test_ansys_new_winding_regression_synrm_3f_36s.py delete mode 100644 tests/test_ansys_new_winding_regression_synrm_5f_40s.py diff --git a/machine_design/generic/compute_initpos.py b/machine_design/generic/compute_initpos.py deleted file mode 100644 index c7e4209..0000000 --- a/machine_design/generic/compute_initpos.py +++ /dev/null @@ -1,24 +0,0 @@ -import math - -from machine_design.generic.winding import sector_winding - - -def compute_initpos(Q: int, p: int, m: int, belt_offset: int): - """This function aims to compute the value of InitPos for those machines with d-axis - at 45deg. - Override this method if a different rotor generator places the d-axis elsewhere.""" - - out = sector_winding(Q, p, m, belt_offset) - xa = ya = 0.0 - for k, (phase_index, sign) in enumerate(out): - if phase_index != 0: # only need phase A - continue - theta_e_deg = (0.5 + k) * (360.0 * p / Q) - if sign > 0: - eff_deg = theta_e_deg - else: - eff_deg = theta_e_deg + 180.0 - xa += math.cos(math.radians(eff_deg)) - ya += math.sin(math.radians(eff_deg)) - gamma_e_deg = math.degrees(math.atan2(ya, xa)) - return round((gamma_e_deg - 90.0) / p, 6) diff --git a/machine_design/generic/winding.py b/machine_design/generic/winding.py deleted file mode 100644 index 70bb828..0000000 --- a/machine_design/generic/winding.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Parametric stator winding-layout generator (pure algorithm, no AEDT). - -Produces the coil -> (phase, polarity) map for the one-pole, anti-periodic FEA -sector our machine models, for an arbitrary balanced integer-slot distributed -winding given (Q slots, p pole-pairs, m phases). It replaces the hardcoded -per-coil assignment in Design.assign_stator_coils / Design2.assign_stator_coils. - -Convention (reverse-engineered from, and validated to exactly reproduce, the -project's 40-slot/5-phase and 36-slot/3-phase windings): - - the FEA sector spans ONE pole, n = Q/(2p) slots = coils, with anti-periodic BC; - - q = Q/(2*p*m) slots per phase belt (must be a positive integer); - - coils are created in slot order: coil 0 = "Coil", coil k = "Coil_{k}"; - - belt index of coil k: b = (k + 1) // q (raw, half-slot-offset origin) - - phase index: ((m+1)//2 * b) mod m (standard (m+1)/2 progression) - - polarity: +1 if b even else -1 (alternating belts; the wrap - coil flips via anti-periodicity) -""" - - -def coil_name(k: int) -> str: - """AEDT object name for the k-th duplicated coil (0-based).""" - return "Coil" if k == 0 else f"Coil_{k}" - - -def sector_winding(Q: int, p: int, m: int, belt_offset: int = 1): - """Return a list (length n = Q/(2p)) of (phase_index 0..m-1, sign +/-1), - one entry per sector coil in slot/creation order. - - belt_offset shifts the belt origin (a pure spatial rotation of the winding, - immaterial to performance, absorbed by the rotor-alignment / current angle): - - belt_offset=1 -> PhaseA centred on the d-axis with an anti-periodic wrap; - reproduces the project's 5-phase (40-slot) winding. Default / preferred. - - belt_offset=0 -> belts start at slot 0; reproduces the legacy 3-phase - (36-slot) base winding. - New machines should use the default (1) and align the dq reference to it.""" - if Q % (2 * p * m) != 0: - raise ValueError(f"non-integer slots/pole/phase: Q={Q}, 2*p*m={2 * p * m} (q=Q/(2pm) must be a positive integer for this winding)") - n = Q // (2 * p) # coils in the one-pole sector - q = Q // (2 * p * m) # slots per belt - step = (m + 1) // 2 - out = [] - for k in range(n): - b = (k + belt_offset) // q - out.append(((step * b) % m, 1 if b % 2 == 0 else -1)) - return out - - -def phase_groups(Q: int, p: int, m: int, belt_offset: int = 1): - """Return m lists; phase j -> [(coil_name, polarity_str), ...] for driving - assign_coil / add_winding_coils generically.""" - groups = [[] for _ in range(m)] - for k, (ph, sg) in enumerate(sector_winding(Q, p, m, belt_offset)): - groups[ph].append((coil_name(k), "Positive" if sg > 0 else "Negative")) - return groups - - -if __name__ == "__main__": - PHASE = "ABCDEFG" - - # --- validation: must reproduce the existing hardcoded windings exactly --- - def asmap(Q, p, m, off): - g = {} - for k, (ph, sg) in enumerate(sector_winding(Q, p, m, off)): - g.setdefault(PHASE[ph], set()).add((k, sg)) - return g - - EXPECT_5 = { # design2.assign_stator_coils (CS_i -> Coil_{i-1} -> slot i-1) - "A": {(0, 1), (9, -1)}, - "B": {(3, 1), (4, 1)}, - "C": {(7, 1), (8, 1)}, - "D": {(1, -1), (2, -1)}, - "E": {(5, -1), (6, -1)}, - } - EXPECT_3 = { # design.assign_stator_coils (PhaseA=CS1-3, B=CS7-9, C=CS4-6) - "A": {(0, 1), (1, 1), (2, 1)}, - "B": {(6, 1), (7, 1), (8, 1)}, - "C": {(3, -1), (4, -1), (5, -1)}, - } - assert asmap(40, 2, 5, 1) == EXPECT_5, "5-phase (offset 1) mismatch" - assert asmap(36, 2, 3, 0) == EXPECT_3, "3-phase (offset 0) mismatch" - print("OK reproduces BOTH hardcoded windings exactly: 5-phase 40-slot (offset 1), 3-phase 36-slot (offset 0)") - - def show(Q, p, m): - lay = sector_winding(Q, p, m) - bins = {} - for k, (ph, sg) in enumerate(lay): - bins.setdefault(PHASE[ph], []).append(f"{coil_name(k)}{'+' if sg > 0 else '-'}") - q = Q // (2 * p * m) - print(f"\n(Q={Q}, p={p}, m={m}) sector coils={len(lay)} q={q}") - for ph in sorted(bins): - print(f" Phase {ph}: {bins[ph]}") - - show(40, 2, 5) # current 5-phase - show(36, 2, 3) # current 3-phase - show(60, 2, 3) # common-stator target, 3-phase - show(60, 2, 5) # common-stator target, 5-phase diff --git a/tests/test_ansys_new_winding_regression_synrm_3f_36s.py b/tests/test_ansys_new_winding_regression_synrm_3f_36s.py deleted file mode 100644 index 20c6708..0000000 --- a/tests/test_ansys_new_winding_regression_synrm_3f_36s.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Compare old winding assignment (winding.py) vs. the new one (winding_layout_JL.py). - -Run explicitly with: - - pytest -m ansys tests/test_ansys_new_winding_regression_synrm_3f_36s.py - -Requires a running Ansys Electronics Desktop session and a free license seat. -""" - -import importlib.util -from pathlib import Path - -import numpy as np -import pytest - -from machine_design.config import load_config -from machine_design.designs.design import Design as LiveDesign -from machine_design.optimization.generators import HacklGenerator_OneLambda -from motors.synrm_3f_36s import Computation, Geometry - -pytestmark = pytest.mark.ansys - -AEDT_VERSION = load_config()["aedt_version"] -R_STATOR_END = 0.7 -OFFSET = 0.35 -SEEDS = (42, 43) -NUM_CORES = 1 -current_setpoint = (1.5, 1.5) -check_expressions = [ - "Flux_d", - "Flux_q", - "Ui_d", - "Ui_q", - "I_d", - "I_q", - "L_d", - "L_q", - "Irms", -] -# we should verify all the above 9 vars + Moving1.Torque (a total of 10 vars with different seeds) - - -class ComputationNewWinding(Computation): - coil_pitch=9 - - def set_oper_params(self): - super().set_oper_params() - self.set_initpos_new(self.coil_pitch) - - def assign_stator_coils(self, m2d): - m = self.geometry.n_phases - phase_currents = [f"Im * cos(w*Time-{360 * k / m}deg+epsI)" for k in range(m)] - self.assign_phase_windings_new(m2d, phase_currents, coil_pitch=self.coil_pitch) - - -def _load_legacy_design_class(): - legacy_path = Path(__file__).parent / "legacy" / "design.py" - spec = importlib.util.spec_from_file_location("legacy_design", legacy_path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module.Design - - -def _generate_one_lambda_barriers(seed): - # rotor_r_min/rotor_r_max are pure-Python derived params, so a throwaway - # instance (no Ansys) is enough to size the generator. - geometry = Geometry() - dummy = LiveDesign(m2d=None, geometry=geometry, computation=Computation(geometry)) - generator = HacklGenerator_OneLambda(dummy, R_STATOR_END, offset=OFFSET) - - np.random.seed(seed) - while True: - params = generator.random_parameters() - generator.set_parameters(params) - barriers = generator.generate_barriers() - barriers = generator.split_barriers(barriers) - if generator.feasible_barriers(barriers): - return barriers - - -def _add_rotor(design, barriers): - design.add_rotor() - for barrier in barriers: - design.add_rotor_barrier(barrier) - - -def _create_design(design_cls, project_name, file_name, extra_args=()): - return design_cls.create( - project_name, - "Design01", - file_name, - *extra_args, - version=AEDT_VERSION, - non_graphical=True, - new_desktop=False, - close_on_exit=False, - ) - - -def _compute_torque(design, barriers, add_rotor, setpoint=current_setpoint): - add_rotor(design, barriers) - torque = design.compute(*setpoint, NUM_CORES=NUM_CORES) - design.delete_rotor() - return torque - - -def test_legacy_live_and_new_winding_design_match_across_rotor_rebuilds(tmp_path): - barrier_sets = [_generate_one_lambda_barriers(seed) for seed in SEEDS] - LegacyDesign = _load_legacy_design_class() - - legacy_design, live_design, new_design = None, None, None - try: - legacy_design = _create_design(LegacyDesign, "RegressionTest_legacy", str(tmp_path / "legacy.aedt")) - legacy_design.solution_expressions = ["Moving1.Torque", *check_expressions] - - def _legacy_extract_results(solutions): - out = {} - for expr in legacy_design.solution_expressions: - out[expr] = np.array(solutions.data_real(expr, convert_to_SI=True)) # use SI units to compare - return out - - legacy_design.extract_results = _legacy_extract_results - - live_geometry = Geometry() - live_computation = Computation(live_geometry) - live_design = _create_design( - LiveDesign, - "RegressionTest_live", - str(tmp_path / "live.aedt"), - extra_args=(live_geometry, live_computation), - ) - - new_geometry = Geometry() - new_computation = ComputationNewWinding(new_geometry) - new_design = _create_design( - LiveDesign, - "RegressionTest_new", - str(tmp_path / "new.aedt"), - extra_args=(new_geometry, new_computation), - ) - - for seed, barriers in zip(SEEDS, barrier_sets): - torque_legacy = _compute_torque(legacy_design, barriers, _add_rotor) - torque_live = _compute_torque(live_design, barriers, _add_rotor) - torque_new = _compute_torque(new_design, barriers, _add_rotor) - - assert torque_legacy is not None, f"legacy torque is None for seed {seed}" - assert torque_live is not None, f"live torque is None for seed {seed}" - assert torque_new is not None, f"new torque is None for seed {seed}" - np.testing.assert_allclose(torque_live["Moving1.Torque"], torque_legacy["Moving1.Torque"], rtol=1e-3, err_msg=f"live legacy mismatch for Moving1.Torque, seed {seed}") - np.testing.assert_allclose(torque_new["Moving1.Torque"], torque_legacy["Moving1.Torque"], rtol=1e-3, err_msg=f"new legacy mismatch for Moving1.Torque, seed {seed}") - np.testing.assert_allclose(torque_new["Moving1.Torque"], torque_live["Moving1.Torque"], rtol=1e-3, err_msg=f"new live mismatch for Moving1.Torque, seed {seed}") - for expr in check_expressions: - np.testing.assert_allclose(torque_live[expr], torque_legacy[expr], rtol=1e-3, err_msg=f"live legacy mismatch for {expr}, seed {seed}") - np.testing.assert_allclose(torque_new[expr], torque_legacy[expr], rtol=1e-3, err_msg=f"new legacy mismatch for {expr}, seed {seed}") - np.testing.assert_allclose(torque_new[expr], torque_live[expr], rtol=1e-3, err_msg=f"new live mismatch for {expr}, seed {seed}") - - finally: - if live_design is not None: - live_design.close_project() - elif new_design is not None: - new_design.close_project() - elif legacy_design is not None: - legacy_design.close_project() diff --git a/tests/test_ansys_new_winding_regression_synrm_5f_40s.py b/tests/test_ansys_new_winding_regression_synrm_5f_40s.py deleted file mode 100644 index cac5ccf..0000000 --- a/tests/test_ansys_new_winding_regression_synrm_5f_40s.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Golden-master check: tests/legacy/design2.py vs. the live machine_design/designs/design.py. - -Generates two feasible rotor designs (different seeds) with HacklGenerator_OneLambda and -runs each through add_rotor -> compute -> delete_rotor on the SAME Design instance, both -with the frozen reference implementation and the current one - mirroring how an -optimization loop reuses one Design across many candidate rotors. Asserts the resulting -torque waveforms match at each step. Run explicitly with: - - pytest -m ansys tests/test_ansys_new_winding_regression_synrm_5f_40s.py - -Requires a running Ansys Electronics Desktop session and a free license seat. -""" - -import numpy as np -import pytest -from legacy.design2 import Design2 - -from machine_design.config import load_config -from machine_design.designs.design import Design as LiveDesign -from machine_design.optimization.generators import HacklGenerator_OneLambda -from motors.synrm_5f_40s import Computation, Geometry - -pytestmark = pytest.mark.ansys - -AEDT_VERSION = load_config()["aedt_version"] -R_STATOR_END = 0.7 -OFFSET = 0.35 -SEEDS = (42, 43) -NUM_CORES = 1 -current_setpoint = (7.0711, 7.0711, 0.0, 0.0) - - -class ComputationNewWinding(Computation): - coil_pitch=9 - - def set_oper_params(self): - super().set_oper_params() - self.set_initpos_new(self.coil_pitch) - - def assign_stator_coils(self, m2d) -> None: - m = self.geometry.n_phases - phase_currents = [f"Im1 * cos(w*Time-{360 * k / m}deg+epsI1-pi)+Im3*cos(3*(w*Time-{360 * k / m}deg)+epsI3-pi)" for k in range(m)] - self.assign_phase_windings_new(m2d, phase_currents, coil_pitch=self.coil_pitch) - - -def _generate_one_lambda_barriers(seed): - # rotor_r_min/rotor_r_max are pure-Python derived params, so a throwaway - # instance (no Ansys) is enough to size the generator. - geometry = Geometry() - dummy = LiveDesign(m2d=None, geometry=geometry, computation=Computation(geometry)) - generator = HacklGenerator_OneLambda(dummy, R_STATOR_END, offset=OFFSET) - - np.random.seed(seed) - while True: - params = generator.random_parameters() - generator.set_parameters(params) - barriers = generator.generate_barriers() - barriers = generator.split_barriers(barriers) - if generator.feasible_barriers(barriers): - return barriers - - -def _add_rotor(design, barriers): - design.add_rotor() - for barrier in barriers: - design.add_rotor_barrier(barrier) - - -def _create_design(design_cls, project_name, file_name, extra_args=()): - return design_cls.create( - project_name, - "Design01", - file_name, - *extra_args, - version=AEDT_VERSION, - non_graphical=True, - new_desktop=False, - close_on_exit=False, - ) - - -def _compute_torque(design, barriers, add_rotor, setpoint=current_setpoint): - add_rotor(design, barriers) - torque = design.compute(*setpoint, NUM_CORES=NUM_CORES) - design.delete_rotor() - return torque - - -def test_legacy_live_and_new_winding_design_match_across_rotor_rebuilds(tmp_path): - barrier_sets = [_generate_one_lambda_barriers(seed) for seed in SEEDS] - - legacy_design, live_design, new_design = None, None, None - try: - legacy_design = _create_design(Design2, "RegressionTest_legacy", str(tmp_path / "legacy.aedt")) - - live_geometry = Geometry() - live_computation = Computation(live_geometry) - live_design = _create_design( - LiveDesign, - "RegressionTest_live", - str(tmp_path / "live.aedt"), - extra_args=(live_geometry, live_computation), - ) - - new_geometry = Geometry() - new_computation = ComputationNewWinding(new_geometry) - new_design = _create_design( - LiveDesign, - "RegressionTest_new", - str(tmp_path / "new.aedt"), - extra_args=(new_geometry, new_computation), - ) - - for seed, barriers in zip(SEEDS, barrier_sets): - torque_legacy = _compute_torque(legacy_design, barriers, _add_rotor) - torque_live = _compute_torque(live_design, barriers, _add_rotor) - torque_new = _compute_torque(new_design, barriers, _add_rotor) - - assert torque_legacy is not None, f"legacy torque is None for seed {seed}" - assert torque_live is not None, f"live torque is None for seed {seed}" - assert torque_new is not None, f"new torque is None for seed {seed}" - - for expr in legacy_design.solution_expressions: - np.testing.assert_allclose(torque_live[expr], torque_legacy[expr], rtol=1e-3, atol=2e-4, err_msg=f"live legacy mismatch for {expr}, seed {seed}") - np.testing.assert_allclose(torque_new[expr], torque_legacy[expr], rtol=1e-3, atol=2e-4, err_msg=f"new legacy mismatch for {expr}, seed {seed}") - np.testing.assert_allclose(torque_new[expr], torque_live[expr], rtol=1e-3, atol=2e-4, err_msg=f"new live mismatch for {expr}, seed {seed}") - # rtol is a relative tolerance, but when current_setpoint = (7.0711, 7.0711, 0.0, 0.0) is used, - # I_d3 is 1e-15 and 2.2e-16 for legacy and live, respectively, rtol is not meaningful when comparing such small numbers, - # so atol (absolute tolerance) is used to avoid false failures. - finally: - if live_design is not None: - live_design.close_project() - elif legacy_design is not None: - legacy_design.close_project() - elif new_design is not None: - new_design.close_project() From aeba6c43e7566866eb7929e6c4a1909cf75f4400 Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Fri, 16 Oct 2026 08:49:53 +0200 Subject: [PATCH 17/25] update --- machine_design/generic/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/machine_design/generic/__init__.py b/machine_design/generic/__init__.py index a63bd58..9110de5 100644 --- a/machine_design/generic/__init__.py +++ b/machine_design/generic/__init__.py @@ -1,3 +1,2 @@ -from .compute_initpos import compute_initpos from .transforms import electrical_angle, to_dq, to_phases -from .winding import coil_name, phase_groups, sector_winding +from .winding_layout_JL import compute_winding_layout, assign_slot_currents_to_aedt From 0f702fb94cf0341349bb7bd194524d3e66bcf892 Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Fri, 16 Oct 2026 09:19:30 +0200 Subject: [PATCH 18/25] ruff format --- machine_design/generic/__init__.py | 2 +- machine_design/generic/winding_layout_JL.py | 37 ++++++++++----------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/machine_design/generic/__init__.py b/machine_design/generic/__init__.py index 9110de5..706ad4f 100644 --- a/machine_design/generic/__init__.py +++ b/machine_design/generic/__init__.py @@ -1,2 +1,2 @@ from .transforms import electrical_angle, to_dq, to_phases -from .winding_layout_JL import compute_winding_layout, assign_slot_currents_to_aedt +from .winding_layout_JL import assign_slot_currents_to_aedt, compute_winding_layout diff --git a/machine_design/generic/winding_layout_JL.py b/machine_design/generic/winding_layout_JL.py index eb7bb31..c119467 100644 --- a/machine_design/generic/winding_layout_JL.py +++ b/machine_design/generic/winding_layout_JL.py @@ -21,23 +21,22 @@ import warnings from dataclasses import dataclass -from math import ceil, gcd, pi, sin from fractions import Fraction -from typing import Dict, List, Optional +from math import ceil, gcd, pi, sin @dataclass(frozen=True) class WindingLayout: coil_pitch: int - phase: List[str] - polarity: List[int] - phase_order: List[str] + phase: list[str] + polarity: list[int] + phase_order: list[str] winding_factor: float - phase_angles: Dict[str, float] # electrical angle [deg] of each phase, e.g. {"A": 0.0, "B": -30.0, ...} + phase_angles: dict[str, float] # electrical angle [deg] of each phase, e.g. {"A": 0.0, "B": -30.0, ...} p2_min_fem: int # minimum pole count of the FEM sector (odd allowed - antiperiodic boundary) phase_a_axis: float # electrical axis [deg, 0..180) of phase A in the model - phase_back: Optional[List[str]] = None - polarity_back: Optional[List[int]] = None + phase_back: list[str] | None = None + polarity_back: list[int] | None = None def _phase_axis_tables(phases: int): @@ -100,7 +99,7 @@ def compute_winding_layout( phases: int, slots: int, pole_pairs: int, - coil_pitch: Optional[int] = None, + coil_pitch: int | None = None, layers: int = 1, ) -> WindingLayout: """Compute per-slot phase letter and polarity for an m-phase AC winding. @@ -157,9 +156,9 @@ def compute_winding_layout( single_layer_flag = layers % 2 # 1 = single layer (needs gap-fill), 0 = double layer slot_step = 1 + single_layer_flag - phase: List[Optional[str]] = [None] * slots - polarity: List[Optional[int]] = [None] * slots - front_slot_indices: List[int] = [] + phase: list[str | None] = [None] * slots + polarity: list[int | None] = [None] * slots + front_slot_indices: list[int] = [] # cell_index walks the technological-scheme grid (step slot_step*c); # slot_index walks the physical slots (step slot_step) - the two only @@ -180,8 +179,8 @@ def compute_winding_layout( slot_index += slot_step cell_index += cell_step - phase_back: Optional[List[str]] = None - polarity_back: Optional[List[int]] = None + phase_back: list[str] | None = None + polarity_back: list[int] | None = None if layers == 1: # For a single layer the loop above fills every other slot only; @@ -237,12 +236,12 @@ def compute_winding_layout( def assign_slot_currents_to_aedt( m2d, - coil_objects: List[str], + coil_objects: list[str], layout: WindingLayout, - phase_currents: Dict[str, str], + phase_currents: dict[str, str], turns_var: str = "Nc", parallel_paths_var: str = "ParallelPaths", -) -> Dict[str, str]: +) -> dict[str, str]: """Assign AEDT coils and windings from a WindingLayout. Replaces the hand-written assign_coil/assign_winding/add_winding_coils @@ -251,7 +250,7 @@ def assign_slot_currents_to_aedt( right after duplicate_around_axis). Returns {phase_letter: winding_name} for the windings that were created. """ - coil_names_by_phase: Dict[str, List[str]] = {p: [] for p in layout.phase_order} + coil_names_by_phase: dict[str, list[str]] = {p: [] for p in layout.phase_order} for i, obj in enumerate(coil_objects): letter = layout.phase[i] @@ -260,7 +259,7 @@ def assign_slot_currents_to_aedt( m2d.assign_coil(assignment=[obj], conductors_number=turns_var, polarity=polarity, name=cs_name) coil_names_by_phase[letter].append(cs_name) - winding_names: Dict[str, str] = {} + winding_names: dict[str, str] = {} for letter, cs_list in coil_names_by_phase.items(): if not cs_list or letter not in phase_currents: continue From d0dc1a695bba1b437e9b01e3f2ab065a42288a32 Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Fri, 16 Oct 2026 09:52:08 +0200 Subject: [PATCH 19/25] use original name winding.py --- machine_design/designs/computation.py | 2 +- machine_design/generic/__init__.py | 2 +- machine_design/generic/{winding_layout_JL.py => winding.py} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename machine_design/generic/{winding_layout_JL.py => winding.py} (100%) diff --git a/machine_design/designs/computation.py b/machine_design/designs/computation.py index c1a85b9..0a75e93 100644 --- a/machine_design/designs/computation.py +++ b/machine_design/designs/computation.py @@ -3,7 +3,7 @@ import numpy as np from ansys.aedt.core import Maxwell2d -from machine_design.generic.winding_layout_JL import assign_slot_currents_to_aedt, compute_winding_layout +from machine_design.generic.winding import assign_slot_currents_to_aedt, compute_winding_layout from .geometry import GeometryBase from .reserved_variables import check_no_reserved_variable_names diff --git a/machine_design/generic/__init__.py b/machine_design/generic/__init__.py index 706ad4f..ac80697 100644 --- a/machine_design/generic/__init__.py +++ b/machine_design/generic/__init__.py @@ -1,2 +1,2 @@ from .transforms import electrical_angle, to_dq, to_phases -from .winding_layout_JL import assign_slot_currents_to_aedt, compute_winding_layout +from .winding import assign_slot_currents_to_aedt, compute_winding_layout diff --git a/machine_design/generic/winding_layout_JL.py b/machine_design/generic/winding.py similarity index 100% rename from machine_design/generic/winding_layout_JL.py rename to machine_design/generic/winding.py From 5b063cc5bd7900482d2ac1e69f830b9b3de026ce Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Mon, 19 Oct 2026 08:55:14 +0200 Subject: [PATCH 20/25] update assign_phse_windings to support layer2 --- machine_design/designs/computation.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/machine_design/designs/computation.py b/machine_design/designs/computation.py index 0a75e93..d231985 100644 --- a/machine_design/designs/computation.py +++ b/machine_design/designs/computation.py @@ -72,10 +72,13 @@ def assign_phase_windings(self, m2d: Maxwell2d, phase_currents: list) -> None: g = self.geometry Q = int(g.geom_params["SlotNumber"]) n = Q // (2 * g.PolePairs) - layout = compute_winding_layout(g.n_phases, Q, g.PolePairs, coil_pitch=int(g.wind_params["CoilPitch"]), layers=int(g.wind_params["Layers"])) - coil_objects = ["Coil" if k == 0 else f"Coil_{k}" for k in range(n)] + layers=int(g.wind_params["Layers"]) + layout = compute_winding_layout(g.n_phases, Q, g.PolePairs, coil_pitch=int(g.wind_params["CoilPitch"]), layers=layers) + layer_coils=[[f"Coil{i_layer}" if k==0 else f"Coil{i_layer}_{k}" for k in range(n)] for i_layer in range(layers)] + coil_objects = layer_coils[0] + coil_objects_back=layer_coils[1] if layers==2 else None phase_currents_dict = dict(zip(self.phases, phase_currents)) - assign_slot_currents_to_aedt(m2d, coil_objects, layout, phase_currents_dict) + assign_slot_currents_to_aedt(m2d, coil_objects, layout, phase_currents_dict, coil_objects_back=coil_objects_back) def extract_phase_results(self, solutions): # SI units From a6dce77ff25a391790fef6cff0f8200f9d7ef9d3 Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Mon, 19 Oct 2026 08:55:50 +0200 Subject: [PATCH 21/25] update for layer2 --- machine_design/generic/winding.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/machine_design/generic/winding.py b/machine_design/generic/winding.py index c119467..9a8b908 100644 --- a/machine_design/generic/winding.py +++ b/machine_design/generic/winding.py @@ -234,11 +234,12 @@ def compute_winding_layout( ) -def assign_slot_currents_to_aedt( +def assign_slot_currents_to_aedt( # for second layer, use layout.phase_back, andlayout.polarity_back m2d, coil_objects: list[str], layout: WindingLayout, phase_currents: dict[str, str], + coil_objects_back=None, turns_var: str = "Nc", parallel_paths_var: str = "ParallelPaths", ) -> dict[str, str]: @@ -259,6 +260,14 @@ def assign_slot_currents_to_aedt( m2d.assign_coil(assignment=[obj], conductors_number=turns_var, polarity=polarity, name=cs_name) coil_names_by_phase[letter].append(cs_name) + if coil_objects_back is not None: + for i, obj in enumerate(coil_objects_back): + letter = layout.phase_back[i] + polarity = "Positive" if layout.polarity_back[i] > 0 else "Negative" + cs_name = f"CSB{i + 1}" # CSB means CS Back + m2d.assign_coil(assignment=[obj], conductors_number=turns_var, polarity=polarity, name=cs_name) + coil_names_by_phase[letter].append(cs_name) + winding_names: dict[str, str] = {} for letter, cs_list in coil_names_by_phase.items(): if not cs_list or letter not in phase_currents: From 3e321d768e59adc574919d181c2d40d58543f3e5 Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Mon, 19 Oct 2026 09:24:29 +0200 Subject: [PATCH 22/25] support layer2 --- motors/synrm_3f_36s.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/motors/synrm_3f_36s.py b/motors/synrm_3f_36s.py index fd1ecff..9e1f0a0 100644 --- a/motors/synrm_3f_36s.py +++ b/motors/synrm_3f_36s.py @@ -142,15 +142,19 @@ def _build_stator_coils(self, m2d: Maxwell2d): modeler = m2d.modeler assert isinstance(modeler, Modeler2D) - coil_id = modeler.create_rectangle( - origin=["DiaStatorGap/2+Hs0+Hs1+SlotLiner", "-(Bs1/2-SlotLiner)", 0], - sizes=["(Hs2+Rs/2-2*SlotLiner)/Layers-(Layers-1)*SpaceLayers/2", "Bs1-2*SlotLiner", 0], - name="Coil", - material="copper", - ) - modeler.rotate(assignment=coil_id, axis="Z", angle="360deg/SlotNumber/2") - coil_id.duplicate_around_axis(axis="Z", angle="360deg/SlotNumber", clones="SlotNumber/Poles", create_new_objects=True) - id_coils = modeler.get_objects_w_string(string_name="Coil", case_sensitive=True) + id_coils = [] + for i_layer in range(int(self.wind_params["Layers"])): + + coil_id = modeler.create_rectangle( + origin=[f"DiaStatorGap/2+Hs0+Hs1+SlotLiner+{i_layer}*(Hs2+Rs/2-2*SlotLiner)/Layers-(Layers-1)*SpaceLayers/2", "-(Bs1/2-SlotLiner)", 0], + sizes=["(Hs2+Rs/2-2*SlotLiner)/Layers-(Layers-1)*SpaceLayers/2", "Bs1-2*SlotLiner", 0], + name=f"Coil{i_layer}", + material="copper", + ) + + modeler.rotate(assignment=coil_id, axis="Z", angle="360deg/SlotNumber/2") + coil_id.duplicate_around_axis(axis="Z", angle="360deg/SlotNumber", clones="SlotNumber/Poles", create_new_objects=True) + id_coils += modeler.get_objects_w_string(string_name=f"Coil{i_layer}", case_sensitive=True) return id_coils From 327a8853079ca9a16d86acb606bcf44ca964d265 Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Mon, 19 Oct 2026 10:07:52 +0200 Subject: [PATCH 23/25] ruff format --- machine_design/designs/computation.py | 6 +++--- motors/synrm_3f_36s.py | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/machine_design/designs/computation.py b/machine_design/designs/computation.py index d231985..33313ea 100644 --- a/machine_design/designs/computation.py +++ b/machine_design/designs/computation.py @@ -72,11 +72,11 @@ def assign_phase_windings(self, m2d: Maxwell2d, phase_currents: list) -> None: g = self.geometry Q = int(g.geom_params["SlotNumber"]) n = Q // (2 * g.PolePairs) - layers=int(g.wind_params["Layers"]) + layers = int(g.wind_params["Layers"]) layout = compute_winding_layout(g.n_phases, Q, g.PolePairs, coil_pitch=int(g.wind_params["CoilPitch"]), layers=layers) - layer_coils=[[f"Coil{i_layer}" if k==0 else f"Coil{i_layer}_{k}" for k in range(n)] for i_layer in range(layers)] + layer_coils = [[f"Coil{i_layer}" if k == 0 else f"Coil{i_layer}_{k}" for k in range(n)] for i_layer in range(layers)] coil_objects = layer_coils[0] - coil_objects_back=layer_coils[1] if layers==2 else None + coil_objects_back = layer_coils[1] if layers == 2 else None phase_currents_dict = dict(zip(self.phases, phase_currents)) assign_slot_currents_to_aedt(m2d, coil_objects, layout, phase_currents_dict, coil_objects_back=coil_objects_back) diff --git a/motors/synrm_3f_36s.py b/motors/synrm_3f_36s.py index 9e1f0a0..4f91769 100644 --- a/motors/synrm_3f_36s.py +++ b/motors/synrm_3f_36s.py @@ -144,7 +144,6 @@ def _build_stator_coils(self, m2d: Maxwell2d): id_coils = [] for i_layer in range(int(self.wind_params["Layers"])): - coil_id = modeler.create_rectangle( origin=[f"DiaStatorGap/2+Hs0+Hs1+SlotLiner+{i_layer}*(Hs2+Rs/2-2*SlotLiner)/Layers-(Layers-1)*SpaceLayers/2", "-(Bs1/2-SlotLiner)", 0], sizes=["(Hs2+Rs/2-2*SlotLiner)/Layers-(Layers-1)*SpaceLayers/2", "Bs1-2*SlotLiner", 0], From c2658c7a3444bed132ca22d532cbaff8994d7dba Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Tue, 20 Oct 2026 13:35:48 +0200 Subject: [PATCH 24/25] fix Nc and coil edge --- motors/synrm_3f_36s.py | 6 +++--- motors/synrm_5f_40s.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/motors/synrm_3f_36s.py b/motors/synrm_3f_36s.py index 4f91769..7b888ea 100644 --- a/motors/synrm_3f_36s.py +++ b/motors/synrm_3f_36s.py @@ -43,7 +43,7 @@ def set_winds_params(self): "CoilPitch": "9", # coil pitch in slots "SlotLiner": "0.3mm", "SpaceLayers": "0.2mm", - "Nc": "68", # turns per coil + "Nc": "68/Layers", # turns per coil } def set_mod_params(self): @@ -145,8 +145,8 @@ def _build_stator_coils(self, m2d: Maxwell2d): id_coils = [] for i_layer in range(int(self.wind_params["Layers"])): coil_id = modeler.create_rectangle( - origin=[f"DiaStatorGap/2+Hs0+Hs1+SlotLiner+{i_layer}*(Hs2+Rs/2-2*SlotLiner)/Layers-(Layers-1)*SpaceLayers/2", "-(Bs1/2-SlotLiner)", 0], - sizes=["(Hs2+Rs/2-2*SlotLiner)/Layers-(Layers-1)*SpaceLayers/2", "Bs1-2*SlotLiner", 0], + origin=[f"DiaStatorGap/2+Hs0+Hs1+SlotLiner+{i_layer}*((Hs2+Rs/2-2*SlotLiner-(Layers-1)*SpaceLayers)/Layers+SpaceLayers)", "-(Bs1/2-SlotLiner)", 0], + sizes=["(Hs2+Rs/2-2*SlotLiner-(Layers-1)*SpaceLayers)/Layers", "Bs1-2*SlotLiner", 0], name=f"Coil{i_layer}", material="copper", ) diff --git a/motors/synrm_5f_40s.py b/motors/synrm_5f_40s.py index b15870a..596dd76 100644 --- a/motors/synrm_5f_40s.py +++ b/motors/synrm_5f_40s.py @@ -26,7 +26,7 @@ def set_slot_params(self): def set_winds_params(self): super().set_winds_params() - self.wind_params["Nc"] = "113" + self.wind_params["Nc"] = "113/Layers" def set_mod_params(self): super().set_mod_params() From 4925a2494cf296b64a0f0913b8cea0034e930c63 Mon Sep 17 00:00:00 2001 From: haitao-ZCU Date: Tue, 20 Oct 2026 13:40:08 +0200 Subject: [PATCH 25/25] update Nc values in test files --- tests/test_synrm_3f_36s.py | 2 +- tests/test_synrm_5f_40s.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_synrm_3f_36s.py b/tests/test_synrm_3f_36s.py index 95ffa38..98bf9c9 100644 --- a/tests/test_synrm_3f_36s.py +++ b/tests/test_synrm_3f_36s.py @@ -57,7 +57,7 @@ def test_wind_params(geometry): "CoilPitch": "9", "SlotLiner": "0.3mm", "SpaceLayers": "0.2mm", - "Nc": "68", + "Nc": "68/Layers", } diff --git a/tests/test_synrm_5f_40s.py b/tests/test_synrm_5f_40s.py index dbfe673..ad2209d 100644 --- a/tests/test_synrm_5f_40s.py +++ b/tests/test_synrm_5f_40s.py @@ -39,7 +39,7 @@ def test_slot_params_overrides_bs1_bs2_setangle_only(base_geometry, geometry2): def test_wind_params_overrides_nc_only(base_geometry, geometry2): expected = dict(base_geometry.wind_params) - expected["Nc"] = "113" + expected["Nc"] = "113/Layers" assert geometry2.wind_params == expected