Skip to content

axfluxmdo.viz

2D plotting is core (matplotlib); the 3D functions require the [viz3d] extra (PyVista).

axfluxmdo.viz.geometry_plot

2D geometry visualization: front view and axial cross-section.

plot_geometry

plot_geometry(motor: AxialFluxMotor, view: str = 'both', show: bool = False) -> Figure

Plot the motor geometry.

view: "front" (magnet disk seen along the axis), "section" (r-z axial cross-section), or "both".

Source code in src/axfluxmdo/viz/geometry_plot.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def plot_geometry(motor: AxialFluxMotor, view: str = "both", show: bool = False) -> Figure:
    """Plot the motor geometry.

    view: "front" (magnet disk seen along the axis), "section" (r-z axial
    cross-section), or "both".
    """
    if view not in ("front", "section", "both"):
        raise ValueError("view must be 'front', 'section', or 'both'")
    if view == "both":
        fig, (ax_front, ax_section) = plt.subplots(1, 2, figsize=(12, 5.5))
        _draw_front(motor, ax_front)
        _draw_section(motor, ax_section)
    else:
        fig, ax = plt.subplots(figsize=(6.5, 5.5))
        if view == "front":
            _draw_front(motor, ax)
        else:
            _draw_section(motor, ax)
    fig.tight_layout()
    if show:
        plt.show()
    return fig

axfluxmdo.viz.fields

Field/profile visualization: radial profiles and efficiency maps.

plot_radial_profiles

plot_radial_profiles(result: AnnularResult, show: bool = False) -> Figure

2x2 grid of per-slice quantities vs radius from an AnnularResult.

Source code in src/axfluxmdo/viz/fields.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
def plot_radial_profiles(result: AnnularResult, show: bool = False) -> Figure:
    """2x2 grid of per-slice quantities vs radius from an AnnularResult."""
    fig, axes = plt.subplots(2, 2, figsize=(10, 6.4))
    r_mm = result.slice_radii_m * 1e3

    ax = axes[0, 0]
    ax.plot(r_mm, result.slice_airgap_b_t, "o-", label=r"$\langle B_g \rangle$")
    ax.plot(r_mm, result.slice_b1_t, "s-", label="$B_1$ (fundamental)")
    ax.set_ylabel("flux density (T)")
    ax.legend(fontsize=8)

    ax = axes[0, 1]
    dr = np.gradient(result.slice_radii_m)
    ax.plot(r_mm, result.slice_torque_nm / dr, "o-")
    ax.set_ylabel("torque density dT/dr (N·m/m)")

    ax = axes[1, 0]
    ax.plot(r_mm, result.slice_yoke_b_t, "o-")
    ax.axhline(
        max(c.limit for c in result.constraints if c.name == "core_flux_density_t"),
        color="r",
        ls="--",
        lw=1,
        label="saturation limit",
    )
    ax.set_ylabel("yoke flux density (T)")
    ax.legend(fontsize=8)

    ax = axes[1, 1]
    ax.plot(r_mm, result.slice_current_loading_a_m / 1e3, "o-")
    ax.set_ylabel("current loading (kA/m)")

    for ax in axes.flat:
        ax.set_xlabel("radius (mm)")
        ax.grid(True, alpha=0.3)
    fig.tight_layout()
    if show:
        plt.show()
    return fig

plot_efficiency_map

plot_efficiency_map(emap: EfficiencyMap, show: bool = False) -> Figure

Efficiency contours over the speed-torque plane; infeasible region greyed.

Source code in src/axfluxmdo/viz/fields.py
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
def plot_efficiency_map(emap: EfficiencyMap, show: bool = False) -> Figure:
    """Efficiency contours over the speed-torque plane; infeasible region greyed."""
    fig, ax = plt.subplots(figsize=(8, 5.5))

    infeasible = np.where(emap.feasible, np.nan, 1.0)
    ax.contourf(
        emap.speeds_rpm,
        emap.torques_nm,
        infeasible,
        levels=[0.5, 1.5],
        colors=["0.85"],
    )

    levels = np.linspace(np.nanmin(emap.efficiency), np.nanmax(emap.efficiency), 20)
    cf = ax.contourf(
        emap.speeds_rpm, emap.torques_nm, emap.efficiency, levels=levels, cmap="viridis"
    )
    fig.colorbar(cf, ax=ax, label="efficiency")
    line_levels = [lv for lv in (0.80, 0.90, 0.95, 0.97) if lv < np.nanmax(emap.efficiency)]
    if line_levels:
        cs = ax.contour(
            emap.speeds_rpm,
            emap.torques_nm,
            emap.efficiency,
            levels=line_levels,
            colors="w",
            linewidths=0.8,
        )
        ax.clabel(cs, fmt="%.2f", fontsize=8)

    ax.set_xlabel("speed (rpm)")
    ax.set_ylabel("torque (N·m)")
    ax.set_title("Efficiency map (grey = infeasible)")
    fig.tight_layout()
    if show:
        plt.show()
    return fig

plot_gap_field

plot_gap_field(solution: GapFieldSolution, comparison: OpenCircuitComparison | None = None, show: bool = False) -> Figure

FEA air-gap field By(x) along the midline, with analytical overlays.

Source code in src/axfluxmdo/viz/fields.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def plot_gap_field(
    solution: GapFieldSolution,
    comparison: OpenCircuitComparison | None = None,
    show: bool = False,
) -> Figure:
    """FEA air-gap field By(x) along the midline, with analytical overlays."""
    import math

    fig, ax = plt.subplots(figsize=(8.5, 5))
    tau = solution.pole_pitch_m
    x_norm = solution.x_m / tau
    ax.plot(x_norm, solution.by_t, lw=1.5, label="FEA $B_y$ (gap midline)")

    # Shade the magnet-covered spans
    half_arc = 0.5 * solution.magnet_arc_ratio
    for k in (0, 1):
        ax.axvspan(
            k + 0.5 - half_arc,
            k + 0.5 + half_arc,
            color="0.9",
            zorder=0,
            label="magnet arc" if k == 0 else None,
        )

    if comparison is not None:
        b_g, b1 = comparison.analytical_b_g_t, comparison.analytical_b1_t
        for k, sign in ((0, +1), (1, -1)):
            ax.hlines(
                sign * b_g,
                k + 0.5 - half_arc,
                k + 0.5 + half_arc,
                colors="r",
                linestyles="--",
                lw=1.2,
                label="analytical $\\pm B_g$" if k == 0 else None,
            )
        xs = np.linspace(0, 2, 400)
        ax.plot(
            xs,
            b1 * np.sin(math.pi * xs),
            "g:",
            lw=1.2,
            label="analytical $B_1 \\sin(\\pi x/\\tau_p)$",
        )
        ax.set_title(
            f"Gap field — residuals: $B_g$ {comparison.residual_b_g_rel:+.1%}, "
            f"$B_1$ {comparison.residual_b1_rel:+.1%}"
        )
    else:
        ax.set_title("Gap field along the air-gap midline")

    ax.set_xlabel(r"$x / \tau_p$ (pole pitches)")
    ax.set_ylabel("$B_y$ (T)")
    ax.grid(True, alpha=0.3)
    ax.legend(fontsize=8, loc="upper right")
    fig.tight_layout()
    if show:
        plt.show()
    return fig

axfluxmdo.viz.pareto

Pareto-front visualization.

plot_pareto

plot_pareto(study: ParetoStudy, *, x: str = 'torque_density', y: str = 'efficiency', color: str | None = None, annotate_best: bool = False, show: bool = False) -> Figure

Scatter the Pareto front; axes accept aliases, to_dict keys, or design variables.

A three-objective study renders as x/y position plus the color channel.

Source code in src/axfluxmdo/viz/pareto.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
def plot_pareto(
    study: ParetoStudy,
    *,
    x: str = "torque_density",
    y: str = "efficiency",
    color: str | None = None,
    annotate_best: bool = False,
    show: bool = False,
) -> Figure:
    """Scatter the Pareto front; axes accept aliases, to_dict keys, or design variables.

    A three-objective study renders as x/y position plus the ``color`` channel.
    """
    from axfluxmdo.optimize.problem import resolve_key

    records = study.to_records()
    available = records[0].keys()

    def column(name: str) -> tuple[str, np.ndarray]:
        key = name if name in available else resolve_key(name, available)
        return key, np.array([rec[key] for rec in records])

    x_key, xs = column(x)
    y_key, ys = column(y)

    fig, ax = plt.subplots(figsize=(7.5, 5.5))
    if color is not None:
        c_key, cs = column(color)
        sc = ax.scatter(xs, ys, c=cs, cmap="viridis", s=45, edgecolors="k", linewidths=0.4)
        fig.colorbar(sc, ax=ax, label=c_key)
    else:
        ax.scatter(xs, ys, s=45, edgecolors="k", linewidths=0.4)

    if annotate_best:
        for name, marker in ((x, "^"), (y, "s")):
            idx = study.best(name)
            ax.scatter(
                xs[idx],
                ys[idx],
                marker=marker,
                s=160,
                facecolors="none",
                edgecolors="r",
                linewidths=1.5,
                label=f"best {name}",
            )
        ax.legend(fontsize=8)

    ax.set_xlabel(x_key)
    ax.set_ylabel(y_key)
    ax.set_title(f"Pareto front ({len(study)} designs)")
    ax.grid(True, alpha=0.3)
    fig.tight_layout()
    if show:
        plt.show()
    return fig

axfluxmdo.viz.sensitivity

Tornado chart for one-at-a-time design sensitivities.

plot_tornado

plot_tornado(sens: SensitivityResult, *, show: bool = False) -> Figure

Horizontal diverging bars around the baseline output, largest swing on top.

Source code in src/axfluxmdo/viz/sensitivity.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def plot_tornado(sens: SensitivityResult, *, show: bool = False) -> Figure:
    """Horizontal diverging bars around the baseline output, largest swing on top."""
    entries = sens.entries
    fig, ax = plt.subplots(figsize=(8, 0.6 * max(4, len(entries)) + 1.2))

    y_positions = range(len(entries) - 1, -1, -1)  # largest swing at the top
    for y_pos, entry in zip(y_positions, entries, strict=True):
        low_delta = entry.low_output - sens.baseline
        high_delta = entry.high_output - sens.baseline
        ax.barh(y_pos, low_delta, left=sens.baseline, height=0.6, color="#1f77b4", alpha=0.85)
        ax.barh(y_pos, high_delta, left=sens.baseline, height=0.6, color="#d62728", alpha=0.85)
        ax.annotate(
            f"{entry.low_input:.4g}",
            xy=(sens.baseline + low_delta, y_pos),
            xytext=(-4, 0),
            textcoords="offset points",
            ha="right",
            va="center",
            fontsize=8,
        )
        ax.annotate(
            f"{entry.high_input:.4g}",
            xy=(sens.baseline + high_delta, y_pos),
            xytext=(4, 0),
            textcoords="offset points",
            ha="left",
            va="center",
            fontsize=8,
        )

    ax.axvline(sens.baseline, color="k", lw=1)
    ax.set_yticks(list(y_positions))
    ax.set_yticklabels([e.variable for e in entries])
    ax.set_xlabel(sens.output)
    ax.set_title(f"Sensitivity of {sens.output} (baseline {sens.baseline:.4g})")
    ax.grid(True, axis="x", alpha=0.3)
    fig.tight_layout()
    if show:
        plt.show()
    return fig

axfluxmdo.viz.bayesopt

Bayesian-optimization visualization (matplotlib-only; sklearn objects arrive inside the study argument, so this module never imports sklearn).

plot_convergence

plot_convergence(study: BOStudy, *, show: bool = False) -> Figure

Best-feasible-so-far trace vs evaluation index.

Source code in src/axfluxmdo/viz/bayesopt.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
def plot_convergence(study: BOStudy, *, show: bool = False) -> Figure:
    """Best-feasible-so-far trace vs evaluation index."""
    fig, ax = plt.subplots(figsize=(8, 4.8))
    idx = np.arange(len(study.y))
    feas = study.feasible
    ax.scatter(idx[feas], study.y[feas], s=28, label="feasible evaluation", zorder=3)
    if (~feas).any():
        ax.scatter(
            idx[~feas],
            study.y[~feas],
            s=36,
            marker="x",
            color="r",
            label="infeasible",
            zorder=3,
        )
    ax.plot(idx, study.history, drawstyle="steps-post", lw=1.8, label="best so far")
    ax.axvspan(-0.5, study.n_initial - 0.5, color="0.92", zorder=0, label="initial design")
    ax.set_xlabel("evaluation")
    ax.set_ylabel(study.objective.label)
    ax.set_title(f"BO convergence — best {study.best_value:.4g} after {len(study.y)} evaluations")
    ax.grid(True, alpha=0.3)
    ax.legend(fontsize=8)
    fig.tight_layout()
    if show:
        plt.show()
    return fig

plot_surrogate_slice

plot_surrogate_slice(study: BOStudy, x_var: str, *, n: int = 100, show: bool = False) -> Figure

GP mean ± 2σ along one variable through the best design (the uncertainty view).

Other variables are frozen at best_x; evaluated points are projected onto the slice axis at their true objective values, so points far from the slice can sit away from the band — the band is the surrogate's uncertainty ALONG THIS SLICE only.

Source code in src/axfluxmdo/viz/bayesopt.py
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
def plot_surrogate_slice(study: BOStudy, x_var: str, *, n: int = 100, show: bool = False) -> Figure:
    """GP mean ± 2σ along one variable through the best design (the uncertainty view).

    Other variables are frozen at ``best_x``; evaluated points are projected
    onto the slice axis at their true objective values, so points far from
    the slice can sit away from the band — the band is the surrogate's
    uncertainty ALONG THIS SLICE only.
    """
    problem = study.problem
    tick_labels = None
    if x_var in problem.continuous:
        lo, hi = problem.continuous[x_var]
        sweep_values: list = list(np.linspace(lo, hi, n))
        sweep_axis = np.asarray(sweep_values, dtype=float)
    elif x_var in problem.integer:
        lo, hi = problem.integer[x_var]
        sweep_values = [int(v) for v in range(lo, hi + 1)]
        sweep_axis = np.asarray(sweep_values, dtype=float)
    elif x_var in problem.choices:
        options = problem.choices[x_var]
        sweep_values = list(options)
        if all(isinstance(o, (int, float)) for o in options):
            sweep_axis = np.asarray(options, dtype=float)
        else:  # non-numeric choices: plot over option index, label the ticks
            sweep_axis = np.arange(len(options), dtype=float)
            tick_labels = [str(o) for o in options]
    else:
        raise ValueError(f"unknown design variable {x_var!r}")

    sense = study.objective.sense
    means, stds = [], []
    for value in sweep_values:
        x = dict(study.best_x)
        if x_var in problem.continuous:
            x[x_var] = float(value)
        elif x_var in problem.integer:
            x[x_var] = int(value)
        else:
            x[x_var] = value  # choice option as-is; dataset.encode handles mapping
        row = study.dataset.encode(x).reshape(1, -1)
        mean, std = study.surrogate.predict(row)
        means.append(sense * -float(mean[0]))  # minimize-space -> human
        stds.append(float(std[0]))
    means = np.array(means)
    stds = np.array(stds)
    sweep = sweep_axis

    def to_axis(value) -> float:
        """Map a design value onto the slice axis (index for non-numeric choices)."""
        if tick_labels is not None:
            return float(problem.choices[x_var].index(value))
        return float(value)

    fig, ax = plt.subplots(figsize=(8, 4.8))
    ax.plot(sweep, means, lw=1.8, label="GP mean (slice)")
    ax.fill_between(sweep, means - 2 * stds, means + 2 * stds, alpha=0.25, label="±2σ")
    evaluated = np.array([to_axis(x[x_var]) for x in study.X])
    ax.scatter(
        evaluated[study.feasible],
        study.y[study.feasible],
        s=26,
        color="k",
        label="evaluated (projected)",
        zorder=3,
    )
    best_v = to_axis(study.best_x[x_var])
    ax.scatter([best_v], [study.best_value], marker="*", s=240, color="r", label="best", zorder=4)
    if tick_labels is not None:
        ax.set_xticks(sweep)
        ax.set_xticklabels(tick_labels)
    ax.set_xlabel(x_var)
    ax.set_ylabel(study.objective.label)
    ax.set_title(f"Surrogate slice through the best design — {x_var}")
    ax.grid(True, alpha=0.3)
    ax.legend(fontsize=8)
    fig.tight_layout()
    if show:
        plt.show()
    return fig

axfluxmdo.viz.pyvista_3d

PyVista 3D assembly, static views, and animations (SPEC viz/pyvista_3d.py).

Every component is built from one primitive: a closed hexahedral annular sector as a pv.StructuredGrid over a numpy (r, theta, z) vertex grid. Full rings are 360-degree sectors; magnets, teeth, slots, and the cutaway are partial sectors. Vertices lie exactly on the bounding circles (bounds are exact) and the solids have meaningful .volume (tested against the motor's analytic volume properties).

Conventions: - Axial stack matches solvers/gmsh_export.py / Linear2DLayout with the air-gap midline at z = 0. - The optional cutaway wedge applies to STATOR-side parts only — cutting the rotor would slice a moving part mid-animation. - Animations are GIF-only (GitHub renders GIFs in READMEs; MP4 would pull in the imageio-ffmpeg binary wheel for no documentation benefit).

This module imports pyvista at module level but is only reachable lazily from axfluxmdo.viz (PEP 562) — the base package never imports VTK.

build_motor_assembly

build_motor_assembly(motor: AxialFluxMotor, *, theta_cutaway_deg: float | None = 90.0, rotor_angle_rad: float = 0.0, theta_resolution_deg: float = 3.0) -> dict

Full-360° motor assembly as PyVista solids.

Returns a dict with stable keys: rotor_iron (StructuredGrid), magnets (MultiBlock of 2p sectors), stator_teeth, stator_coils, stator_yoke. The cutaway wedge (over [0, theta_cutaway_deg]) removes STATOR-side material only.

Source code in src/axfluxmdo/viz/pyvista_3d.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
def build_motor_assembly(
    motor: AxialFluxMotor,
    *,
    theta_cutaway_deg: float | None = 90.0,
    rotor_angle_rad: float = 0.0,
    theta_resolution_deg: float = 3.0,
) -> dict:
    """Full-360° motor assembly as PyVista solids.

    Returns a dict with stable keys: ``rotor_iron`` (StructuredGrid),
    ``magnets`` (MultiBlock of 2p sectors), ``stator_teeth``,
    ``stator_coils``, ``stator_yoke``. The cutaway wedge (over
    ``[0, theta_cutaway_deg]``) removes STATOR-side material only.
    """
    g = motor.air_gap
    t_m = motor.magnet_thickness
    z_rotor = (-g / 2.0 - t_m - motor.back_iron_thickness, -g / 2.0 - t_m)
    z_magnet = (-g / 2.0 - t_m, -g / 2.0)
    z_slot = (g / 2.0, g / 2.0 + motor.slot_depth)
    z_yoke = (z_slot[1], z_slot[1] + motor.stator_core_thickness)
    r_i, r_o = motor.inner_radius, motor.outer_radius
    res = theta_resolution_deg

    stator_start = math.radians(theta_cutaway_deg) if theta_cutaway_deg else 0.0

    rotor_iron = _annular_sector(r_i, r_o, *z_rotor, 0.0, 2.0 * math.pi, theta_resolution_deg=res)

    pole_angle = math.pi / motor.pole_pairs
    magnets = pv.MultiBlock()
    for k in range(2 * motor.pole_pairs):
        center = (k + 0.5) * pole_angle
        if motor.magnet_shape == "rectangular":
            width = motor.magnet_arc_ratio * motor.pole_pitch
            block = _rectangular_magnet(r_i, r_o, *z_magnet, center, width)
        else:
            half_arc = 0.5 * motor.magnet_arc_ratio * pole_angle
            block = _annular_sector(
                r_i,
                r_o,
                *z_magnet,
                center - half_arc,
                center + half_arc,
                theta_resolution_deg=res,
            )
        magnets.append(block, name=f"magnet_{k}")

    if rotor_angle_rad != 0.0:
        rotor_iron.rotate_z(math.degrees(rotor_angle_rad), inplace=True)
        for block in magnets:
            block.rotate_z(math.degrees(rotor_angle_rad), inplace=True)

    # Slotted stator band: alternating copper slots and iron teeth.
    n_slots = 2 * motor.phases * motor.pole_pairs
    slot_pitch = 2.0 * math.pi / n_slots
    opening = motor.slot_width_fraction * slot_pitch
    teeth_parts: list[pv.StructuredGrid] = []
    coil_parts: list[pv.StructuredGrid] = []

    def clipped(theta0: float, theta1: float) -> tuple[float, float] | None:
        """Clip a sector to the non-cutaway span [stator_start, 2*pi]."""
        lo, hi = max(theta0, stator_start), min(theta1, 2.0 * math.pi)
        return (lo, hi) if hi - lo > 1e-9 else None

    for k in range(n_slots):
        s0 = k * slot_pitch + 0.5 * (slot_pitch - opening)
        s1 = s0 + opening
        for span, bucket in (
            ((k * slot_pitch, s0), teeth_parts),
            ((s0, s1), coil_parts),
            ((s1, (k + 1) * slot_pitch), teeth_parts),
        ):
            kept = clipped(*span)
            if kept is not None:
                bucket.append(_annular_sector(r_i, r_o, *z_slot, *kept, theta_resolution_deg=res))

    stator_teeth = pv.merge(teeth_parts)
    stator_coils = pv.merge(coil_parts)
    stator_yoke = _annular_sector(
        r_i, r_o, *z_yoke, stator_start, 2.0 * math.pi, theta_resolution_deg=res
    )

    return {
        "rotor_iron": rotor_iron,
        "magnets": magnets,
        "stator_teeth": stator_teeth,
        "stator_coils": stator_coils,
        "stator_yoke": stator_yoke,
    }

can_render cached

can_render() -> bool

Probe whether an off-screen VTK render is possible.

Run in a SUBPROCESS: without a usable GL context VTK can segfault rather than raise, and an in-process probe would take the caller down with it. On Linux without a display, try pyvista's xvfb helper first.

Source code in src/axfluxmdo/viz/pyvista_3d.py
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
@functools.lru_cache(maxsize=1)
def can_render() -> bool:
    """Probe whether an off-screen VTK render is possible.

    Run in a SUBPROCESS: without a usable GL context VTK can segfault rather
    than raise, and an in-process probe would take the caller down with it.
    On Linux without a display, try pyvista's xvfb helper first.
    """
    if sys.platform.startswith("linux") and not os.environ.get("DISPLAY"):
        # pyvista.start_xvfb() was removed in pyvista 0.48; without it (or
        # without a display at all) rendering is unavailable here — run the
        # example under `xvfb-run` instead, as CI does.
        start_xvfb = getattr(pv, "start_xvfb", None)
        if start_xvfb is None:
            return False
        try:
            start_xvfb()
        except OSError:
            return False
    code = (
        "import pyvista as pv; p = pv.Plotter(off_screen=True, window_size=(2, 2)); "
        "p.add_mesh(pv.Sphere()); p.screenshot(None); p.close()"
    )
    try:
        proc = subprocess.run([sys.executable, "-c", code], capture_output=True, timeout=120)
    except (OSError, subprocess.TimeoutExpired):  # pragma: no cover
        return False
    return proc.returncode == 0

plot_motor_3d

plot_motor_3d(motor: AxialFluxMotor, *, theta_cutaway_deg: float | None = 90.0, show: bool = False, screenshot: str | Path | None = None, window_size: tuple[int, int] = (960, 720)) -> pv.Plotter

Static 3D view; returns the Plotter (the Figure analogue of this layer).

Source code in src/axfluxmdo/viz/pyvista_3d.py
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
def plot_motor_3d(
    motor: AxialFluxMotor,
    *,
    theta_cutaway_deg: float | None = 90.0,
    show: bool = False,
    screenshot: str | Path | None = None,
    window_size: tuple[int, int] = (960, 720),
) -> pv.Plotter:
    """Static 3D view; returns the Plotter (the Figure analogue of this layer)."""
    assembly = build_motor_assembly(motor, theta_cutaway_deg=theta_cutaway_deg)
    plotter = pv.Plotter(off_screen=not show, window_size=list(window_size))
    _add_assembly(plotter, assembly)
    _set_camera(plotter, motor)
    if screenshot is not None:
        plotter.screenshot(str(Path(screenshot).resolve()))
    if show:  # pragma: no cover - interactive path
        plotter.show()
    return plotter

animate_rotation

animate_rotation(motor: AxialFluxMotor, path: str | Path, *, n_frames: int = 72, fps: int = 15, theta_cutaway_deg: float | None = 90.0, window_size: tuple[int, int] = (640, 480)) -> Path

One full mechanical revolution of the rotor over the (cutaway) stator.

Source code in src/axfluxmdo/viz/pyvista_3d.py
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
def animate_rotation(
    motor: AxialFluxMotor,
    path: str | Path,
    *,
    n_frames: int = 72,
    fps: int = 15,
    theta_cutaway_deg: float | None = 90.0,
    window_size: tuple[int, int] = (640, 480),
) -> Path:
    """One full mechanical revolution of the rotor over the (cutaway) stator."""
    path = _require_gif(path)
    assembly = build_motor_assembly(motor, theta_cutaway_deg=theta_cutaway_deg)
    plotter = pv.Plotter(off_screen=True, window_size=list(window_size))
    _add_assembly(plotter, assembly)
    _set_camera(plotter, motor)
    plotter.open_gif(str(path), fps=fps)
    step_deg = 360.0 / n_frames
    rotor_parts = [assembly["rotor_iron"], *assembly["magnets"]]
    for _ in range(n_frames):
        for mesh in rotor_parts:
            mesh.rotate_z(step_deg, inplace=True)
        plotter.write_frame()
    plotter.close()
    return path

animate_exploded

animate_exploded(motor: AxialFluxMotor, path: str | Path, *, n_frames: int = 60, fps: int = 15, travel: float | None = None, window_size: tuple[int, int] = (640, 480)) -> Path

Components separate axially, hold, and reassemble (ease-in-out).

Source code in src/axfluxmdo/viz/pyvista_3d.py
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
def animate_exploded(
    motor: AxialFluxMotor,
    path: str | Path,
    *,
    n_frames: int = 60,
    fps: int = 15,
    travel: float | None = None,
    window_size: tuple[int, int] = (640, 480),
) -> Path:
    """Components separate axially, hold, and reassemble (ease-in-out)."""
    path = _require_gif(path)
    assembly = build_motor_assembly(motor, theta_cutaway_deg=None)
    stack_height = (
        motor.back_iron_thickness
        + motor.magnet_thickness
        + motor.air_gap
        + motor.slot_depth
        + motor.stator_core_thickness
    )
    travel = travel if travel is not None else 3.0 * stack_height

    multipliers = {
        "rotor_iron": -1.0,
        "magnets": -0.5,
        "stator_coils": 0.5,
        "stator_teeth": 0.5,
        "stator_yoke": 1.0,
    }
    parts: list[tuple[pv.DataSet, float, np.ndarray]] = []
    for name, mult in multipliers.items():
        meshes = assembly[name] if name == "magnets" else [assembly[name]]
        for mesh in meshes:
            parts.append((mesh, mult, mesh.points.copy()))

    plotter = pv.Plotter(off_screen=True, window_size=list(window_size))
    _add_assembly(plotter, assembly)
    _set_camera(plotter, motor)
    plotter.open_gif(str(path), fps=fps)

    n_out = max(1, int(0.4 * n_frames))
    n_hold = max(1, int(0.2 * n_frames))
    n_in = n_frames - n_out - n_hold
    profile = (
        [0.5 * (1 - math.cos(math.pi * t / n_out)) for t in range(n_out)]
        + [1.0] * n_hold
        + [0.5 * (1 + math.cos(math.pi * t / max(1, n_in - 1))) for t in range(n_in)]
    )
    for s in profile:
        for mesh, mult, base in parts:
            # absolute positioning from cached base points: no incremental drift
            mesh.points = base + np.array([0.0, 0.0, mult * travel * s])
        plotter.write_frame()
    plotter.close()
    return path