Note
Go to the end to download the full example code.
Cyclic-symmetry modal on a rotor sector#
A rotor with \(N\) identical sectors spanning 360° has a spectrum that factors by harmonic index \(k \in \{0, 1, \dots, N/2\}\). Analysing one base sector plus the constraint
recovers the full-rotor spectrum at a fraction of the DOF count.
This example builds a simple 4-sector rotor from a pyvista annular
sector, wraps it in a CyclicModel, and runs
modal_solve() across all harmonic
indices. CyclicModel auto-detects the low/high cyclic faces from
the rotation axis and angle, so the caller never has to assemble a
manual DOF pair list.
from __future__ import annotations
import numpy as np
import pyvista as pv
import femorph_solver as fs
from femorph_solver import ELEMENTS
Build a 90° annular sector#
N_SECTORS = 4
ALPHA = 2.0 * np.pi / N_SECTORS
N_T, N_R, N_Z = 12, 6, 3
theta = np.linspace(0.0, ALPHA, N_T + 1)
r = np.linspace(0.8, 1.0, N_R + 1)
z = np.linspace(0.0, 0.1, N_Z + 1)
# (r, θ, z) order gives a CCW-from-above hex winding in the (r, θ)
# plane — swapping r and θ would flip the winding inside-out.
R, T, Z = np.meshgrid(r, theta, z, indexing="ij")
X = R * np.cos(T)
Y = R * np.sin(T)
grid = pv.StructuredGrid(X, Y, Z).cast_to_unstructured_grid()
Wrap as a CyclicModel#
CyclicModel.from_grid builds the underlying base-sector
Model, detects the cyclic face pairs from
the rotation axis (default "z") + n_sectors, and validates
that every low-face point has a rotated counterpart on the high
face within pair_tol.
Solve every harmonic index#
modal_solve defaults to all harmonics k = 0 … N/2, returning
one CyclicModalResult per
harmonic. Each result carries frequency and mode_shapes.
results = cm.modal_solve(n_modes=4)
print(f"Cyclic modal on {N_SECTORS}-sector rotor:")
for res in results:
fs_str = ", ".join(f"{f:.2f}" for f in res.frequency[:4])
print(f" k = {res.harmonic_index}: f [Hz] = [{fs_str}]")
Cyclic modal on 4-sector rotor:
k = 0: f [Hz] = [0.00, 0.00, 503.32, 503.32]
k = 1: f [Hz] = [0.00, 0.00, 262.09, 452.92]
k = 2: f [Hz] = [91.75, 91.75, 164.68, 164.68]
Visualise mode 0 of k = 1#
r1 = next(res for res in results if res.harmonic_index == 1)
phi = r1.mode_shapes[:, 0].reshape(-1, 3)
grid.point_data["mode_k1"] = np.abs(phi)
plotter = pv.Plotter(off_screen=True)
plotter.add_mesh(
grid.warp_by_vector("mode_k1", factor=0.05 / (np.max(np.abs(phi)) + 1e-30)),
scalars=np.linalg.norm(phi, axis=1),
cmap="viridis",
scalar_bar_args={"title": f"|mode| (k=1, f={r1.frequency[0]:.1f} Hz)"},
)
plotter.add_axes()
plotter.show()

Total running time of the script: (0 minutes 1.209 seconds)