diff --git a/machine_design/designs/computation.py b/machine_design/designs/computation.py index 179f643..4e57fbe 100644 --- a/machine_design/designs/computation.py +++ b/machine_design/designs/computation.py @@ -2,6 +2,8 @@ from ansys.aedt.core import Maxwell2d +from machine_design.generic.compute_initpos import compute_initpos + from .geometry import GeometryBase from .reserved_variables import check_no_reserved_variable_names @@ -41,6 +43,12 @@ def set_variables(self, m2d: Maxwell2d, *args): ... @abstractmethod def extract_results(self, solutions): ... + def set_initpos(self) -> None: + Q = int(self.geometry.geom_params["SlotNumber"]) + p = self.geometry.PolePairs + self.InitPos = compute_initpos(Q, p, self.phases, belt_offset=self.belt_offset) + self.oper_params["InitPos"] = f"{self.InitPos}deg" + def push_variables(self, m2d: Maxwell2d) -> None: for k, v in self.oper_params.items(): m2d[k] = v diff --git a/machine_design/generic/__init__.py b/machine_design/generic/__init__.py index bd6633f..a63bd58 100644 --- a/machine_design/generic/__init__.py +++ b/machine_design/generic/__init__.py @@ -1 +1,3 @@ +from .compute_initpos import compute_initpos from .transforms import electrical_angle, to_dq, to_phases +from .winding import coil_name, phase_groups, sector_winding diff --git a/machine_design/generic/compute_initpos.py b/machine_design/generic/compute_initpos.py new file mode 100644 index 0000000..c7e4209 --- /dev/null +++ b/machine_design/generic/compute_initpos.py @@ -0,0 +1,24 @@ +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 new file mode 100644 index 0000000..70bb828 --- /dev/null +++ b/machine_design/generic/winding.py @@ -0,0 +1,96 @@ +"""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/motors/synrm_3f_36s.py b/motors/synrm_3f_36s.py index 93af0c0..812a051 100644 --- a/motors/synrm_3f_36s.py +++ b/motors/synrm_3f_36s.py @@ -172,21 +172,22 @@ def _assign_stator_mesh(self, m2d: Maxwell2d, stator_id, id_coils) -> None: class Computation(ComputationBase): def set_oper_params(self): + self.phases = 3 + self.belt_offset = 0 f = 50 # [Hz] RotSpeed = 60 * f / self.geometry.PolePairs # [rpm] w = 2 * np.pi * f - self.InitPos = -30 # deg self.oper_params = { "Id": "0.0A", "Iq": "0.0A", "epsI": "atan2(Iq,Id)", # current angle "Im": "sqrt(Id^2+Iq^2)", - "InitPos": f"{self.InitPos}deg", "w": f"{w}Hz", "RotSpeed": f"{RotSpeed}rpm", "Nper": "1/6", # number of included periods "PointPer": "101", # number of time points per period } + self.set_initpos() def set_solution_expressions(self): self.solution_expressions = [ @@ -211,10 +212,6 @@ def set_post_params(self): "FluxLinkage(PhaseB)", "FluxLinkage(PhaseC)", ): "FluxLinkage", - ("I_d", "I_q"): "Current_dq", - ("Flux_d", "Flux_q"): "FluxLinkage_dq", - ("Ui_d", "Ui_q"): "InducedVoltage_dq", - ("L_d", "L_q"): "Inductance_dq", } def assign_stator_coils(self, m2d: Maxwell2d) -> None: diff --git a/motors/synrm_3f_60s.py b/motors/synrm_3f_60s.py new file mode 100644 index 0000000..65c0189 --- /dev/null +++ b/motors/synrm_3f_60s.py @@ -0,0 +1,61 @@ +"""Anchor `synrm_3f_60s`: 3-phase synchronous reluctance machine, 60 stator slots.""" + +import numpy as np +from ansys.aedt.core import Maxwell2d + +from machine_design.generic.winding import phase_groups + +from .synrm_3f_36s import Computation as BaseComputation +from .synrm_3f_36s import Geometry as BaseGeometry + + +class Geometry(BaseGeometry): + def set_geom_params(self): + super().set_geom_params() + self.geom_params["SlotNumber"] = "60" + + def set_slot_params(self): + super().set_slot_params() + self.slot_params["Bs0"] = "1.5mm" + self.slot_params["Bs1"] = "2.0mm" + self.slot_params["Bs2"] = "2.85mm" + self.slot_params["Rs"] = "1.0mm" + self.slot_params["SetAngle"] = "6deg" + + def set_winds_params(self): + super().set_winds_params() + self.wind_params["CoilPitch"] = "15" # full pitch over one pole (60/4) + + +class Computation(BaseComputation): + def set_solution_expressions(self): + self.solution_expressions = ["Moving1.Torque"] + + def assign_stator_coils(self, m2d: Maxwell2d) -> None: + # Excitations + I_A = "Im * cos(w*Time+epsI)" + I_B = "Im * cos(w*Time-120deg+epsI)" + I_C = "Im * cos(w*Time-240deg+epsI)" + + Q = int(self.geometry.geom_params["SlotNumber"]) + p = self.geometry.PolePairs + # belt_offset=0 reproduces the legacy 3-phase(36-slot) base winding. + groups = phase_groups(Q, p, 3, belt_offset=0) + + 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("ABC", [I_A, I_B, I_C], 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 extract_results(self, solutions): + return {"Moving1.Torque": np.array(solutions.data_real("Moving1.Torque"))} diff --git a/motors/synrm_5f_40s.py b/motors/synrm_5f_40s.py index de271c9..d22601a 100644 --- a/motors/synrm_5f_40s.py +++ b/motors/synrm_5f_40s.py @@ -31,10 +31,11 @@ def set_winds_params(self): class Computation(ComputationBase): def set_oper_params(self): + self.phases = 5 + self.belt_offset = 1 f = 50 # [Hz] RotSpeed = 60 * f / self.geometry.PolePairs # [rpm] w = 2 * np.pi * f - self.InitPos = -45 # deg self.RotSign = 1 self.Rstat = 19.0 self.Lew = 0.0 @@ -48,12 +49,12 @@ def set_oper_params(self): "epsI3": "atan2(Iq3,Id3)", # current angle, 1st harmonic "Im1": "sqrt(Id1^2+Iq1^2)", "Im3": "sqrt(Id3^2+Iq3^2)", - "InitPos": f"{self.InitPos}deg", "w": f"{w}Hz", "RotSpeed": f"{RotSpeed}rpm", "Nper": "1/10", # number of included periods "PointPer": "101", # number of time points per period } + self.set_initpos() def set_solution_expressions(self): self.solution_expressions = [ @@ -92,13 +93,6 @@ def set_post_params(self): "FluxLinkage(PhaseD)", "FluxLinkage(PhaseE)", ): "FluxLinkage", - ("I_d1", "I_q1", "I_d3", "I_q3"): "Current_dq", - ("Flux_d1", "Flux_q1", "Flux_d3", "Flux_q3"): "FluxLinkage_dq", - ("Flux_e_d1", "Flux_e_q1", "Flux_e_d3", "Flux_e_q3"): "FluxLinkage excitation_dq", - ("Vind_d1", "Vind_q1", "Vind_d3", "Vind_q3"): "InducedVoltage_dq", - ("V_d1", "V_q1", "V_d3", "V_q3"): "TerminalVoltage_dq", - ("Ld1", "Lq1", "Ld3", "Lq3"): "Inductance_dq main", - ("Ld1q1", "Ld1d3", "Ld1q3", "Lq1d3", "Lq1q3", "Ld3q3"): "Inductance_dq cross-coupling", } def assign_stator_coils(self, m2d: Maxwell2d) -> None: diff --git a/motors/synrm_5f_60s.py b/motors/synrm_5f_60s.py new file mode 100644 index 0000000..8e6fe46 --- /dev/null +++ b/motors/synrm_5f_60s.py @@ -0,0 +1,67 @@ +"""Anchor `synrm_5f_60s`: 5-phase synchronous reluctance machine, 60 stator slots. + +Derives from `synrm_5f_40s`, overriding only the slotting and winding it needs for five +phases. Excitation is dq1 + dq3. +""" + +import numpy as np +from ansys.aedt.core import Maxwell2d + +from machine_design.generic.winding import phase_groups + +from .synrm_5f_40s import Computation as BaseComputation +from .synrm_5f_40s import Geometry as BaseGeometry + + +class Geometry(BaseGeometry): + def set_geom_params(self): + super().set_geom_params() + self.geom_params["SlotNumber"] = "60" + + def set_slot_params(self): + super().set_slot_params() + self.slot_params["Bs0"] = "1.5mm" + self.slot_params["Bs1"] = "2.0mm" + self.slot_params["Bs2"] = "2.85mm" + self.slot_params["Rs"] = "1.0mm" + self.slot_params["SetAngle"] = "6deg" + + def set_winds_params(self): + super().set_winds_params() + self.wind_params["CoilPitch"] = "15" + + +class Computation(BaseComputation): + def set_solution_expressions(self): + self.solution_expressions = ["Moving1.Torque"] + + def assign_stator_coils(self, m2d: Maxwell2d) -> None: + # Excitations + I_A = "Im1*cos(w*Time+epsI1-pi) + Im3*cos(3*(w*Time)+epsI3-pi)" + I_B = "Im1*cos(w*Time-72deg+epsI1-pi) + Im3*cos(3*(w*Time-72deg)+epsI3-pi)" + I_C = "Im1*cos(w*Time-144deg+epsI1-pi) + Im3*cos(3*(w*Time-144deg)+epsI3-pi)" + I_D = "Im1*cos(w*Time-216deg+epsI1-pi) + Im3*cos(3*(w*Time-216deg)+epsI3-pi)" + I_E = "Im1*cos(w*Time-288deg+epsI1-pi) + Im3*cos(3*(w*Time-288deg)+epsI3-pi)" + + Q = int(self.geometry.geom_params["SlotNumber"]) + p = self.geometry.PolePairs + # belt_offset=1 reproduces the 5-phase(40-slot) base winding. + groups = phase_groups(Q, p, 5, belt_offset=1) + + 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("ABCDE", [I_A, I_B, I_C, I_D, I_E], 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 extract_results(self, solutions): + return {"Moving1.Torque": np.array(solutions.data_real("Moving1.Torque"))} diff --git a/tests/test_ansys_synrm_60s_solve.py b/tests/test_ansys_synrm_60s_solve.py new file mode 100644 index 0000000..7504fa4 --- /dev/null +++ b/tests/test_ansys_synrm_60s_solve.py @@ -0,0 +1,76 @@ +"""Goal: test both synrm_3f_60s.py and synrm_5f_60s.py can accept the same rotor and solve. + +Run explicitly with: + + pytest -m ansys tests/test_ansys_synrm_60s_solve.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 as LiveDesign +from machine_design.optimization.generators import HacklGenerator_OneLambda +from motors.synrm_3f_60s import Computation as Computation3f +from motors.synrm_3f_60s import Geometry as Geometry3f +from motors.synrm_5f_60s import Computation as Computation5f +from motors.synrm_5f_60s import Geometry as Geometry5f + +pytestmark = pytest.mark.ansys + +AEDT_VERSION = load_config()["aedt_version"] +R_STATOR_END = 0.7 +OFFSET = 0.35 +SEEDS = (42, 43) +NUM_CORES = 1 +Machines = [ + ("synrm_3f_60s", Geometry3f, Computation3f, (1.5, 1.5)), + ("synrm_5f_60s", Geometry5f, Computation5f, (7.0711, 7.0711, 0.0, 0.0)), +] + + +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 = Geometry3f() + dummy = LiveDesign(m2d=None, geometry=geometry, computation=Computation3f(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 test_synrm_3f_60s_and_5f_60s_solve_with_same_rotor(tmp_path): + for seed in SEEDS: + barriers = _generate_one_lambda_barriers(seed) + + for name, geometry_cls, computation_cls, current_setpoint in Machines: + geometry = geometry_cls() + computation = computation_cls(geometry) + design = LiveDesign.create( + f"Check60_{name}_{seed}", + "Design01", + str(tmp_path / f"{name}_{seed}.aedt"), + geometry, + computation, + version=AEDT_VERSION, + non_graphical=True, + new_desktop=False, + close_on_exit=False, + ) + try: + design.add_rotor() + for barrier in barriers: + design.add_rotor_barrier(barrier) + torque = design.compute(*current_setpoint, NUM_CORES=NUM_CORES) + assert torque is not None, f"{name} seed {seed}: torque is None" + finally: + design.close_project() diff --git a/tests/test_synrm_3f_36s.py b/tests/test_synrm_3f_36s.py index 3f5fa68..69d9ad5 100644 --- a/tests/test_synrm_3f_36s.py +++ b/tests/test_synrm_3f_36s.py @@ -77,7 +77,7 @@ def test_oper_params(computation): "Iq": "0.0A", "epsI": "atan2(Iq,Id)", "Im": "sqrt(Id^2+Iq^2)", - "InitPos": "-30deg", + "InitPos": "-30.0deg", "w": f"{2 * np.pi * 50}Hz", "RotSpeed": "1500.0rpm", "Nper": "1/6", diff --git a/tests/test_synrm_5f_40s.py b/tests/test_synrm_5f_40s.py index bd5a5dc..ed7953e 100644 --- a/tests/test_synrm_5f_40s.py +++ b/tests/test_synrm_5f_40s.py @@ -53,7 +53,7 @@ def test_oper_params_fully_replaced(computation2): "epsI3": "atan2(Iq3,Id3)", "Im1": "sqrt(Id1^2+Iq1^2)", "Im3": "sqrt(Id3^2+Iq3^2)", - "InitPos": "-45deg", + "InitPos": "-45.0deg", "w": f"{2 * np.pi * 50}Hz", "RotSpeed": "1500.0rpm", "Nper": "1/10", diff --git a/tests/test_synrm_60s_torque_i3_zero.py b/tests/test_synrm_60s_torque_i3_zero.py new file mode 100644 index 0000000..406ff50 --- /dev/null +++ b/tests/test_synrm_60s_torque_i3_zero.py @@ -0,0 +1,90 @@ +"""Goal: test 5f_60s performs the same with 3f_60s when Id3=Iq3=0 and the same rotor. + +Run explicitly with: + + pytest -m ansys tests/test_synrm_60s_torque_i3_zero.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 as LiveDesign +from machine_design.optimization.generators import HacklGenerator_OneLambda +from machine_design.optimization.geometry import analyze_results +from motors.synrm_3f_60s import Computation as Computation3f +from motors.synrm_3f_60s import Geometry as Geometry3f +from motors.synrm_5f_60s import Computation as Computation5f +from motors.synrm_5f_60s import Geometry as Geometry5f + +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_setpoints_3f = (2.5, 2.5) +current_setpoints_5f = (1.5, 1.5, 0.0, 0.0) + +Machines = [ + ("synrm_3f_60s", Geometry3f, Computation3f, current_setpoints_3f), + ("synrm_5f_60s", Geometry5f, Computation5f, current_setpoints_5f), +] + + +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 = Geometry3f() + dummy = LiveDesign(m2d=None, geometry=geometry, computation=Computation3f(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 test_synrm_5f_60s_i3_zero_performs_same_with_3f_60s(tmp_path): + for seed in SEEDS: + barriers = _generate_one_lambda_barriers(seed) + + results = {} + for name, geometry_cls, computation_cls, current_setpoint in Machines: + geometry = geometry_cls() + computation = computation_cls(geometry) + design = LiveDesign.create( + f"CheckI3zero_{name}_{seed}", + "Design01", + str(tmp_path / f"{name}_{seed}.aedt"), + geometry, + computation, + version=AEDT_VERSION, + non_graphical=True, + new_desktop=False, + close_on_exit=False, + ) + try: + design.add_rotor() + for barrier in barriers: + design.add_rotor_barrier(barrier) + + torque = design.compute(*current_setpoint, NUM_CORES=NUM_CORES) + assert torque is not None, f"{name} seed {seed}: torque is None" + TorAvg, _, TorRippleRms = analyze_results(torque["Moving1.Torque"]) + results[name] = TorAvg + finally: + design.close_project() + + torque_3f = results["synrm_3f_60s"] + torque_5f = results["synrm_5f_60s"] + diff_pct = abs(torque_3f - torque_5f) / abs(torque_3f) * 100 + print(f"seed {seed}: 3f TorAvg={torque_3f:.4f} Nm, 5f(i3=0) TorAvg={torque_5f:.4f} Nm, diff={diff_pct:.2f}%") + assert diff_pct < 5, f"seed {seed}: 3f and 5f(i3=0) torque differ by {diff_pct:.2f}%, expected roughly comparable"