Note
Go to the end to download the full example code.
Tutorial 2 — Modal survey of a cantilever bracket#
Picking up from Tutorial 1 — Cantilever beam under combined loading, the static analysis confirmed the bracket carries its design load with a comfortable safety margin. But the equipment console it supports has variable-speed motors that excite the bracket at multiple harmonics across the operating band. The next question is dynamic: do any of the bracket’s natural frequencies fall inside the excitation band, and if so which modes need to be included in a response-spectrum analysis to capture 90 % of the modal mass?
This tutorial walks the modal-survey workflow end-to-end:
Step 1 — build the same cantilever geometry from Tutorial 1, this time as a 3-D solid that carries its own mass (no separate tip mass, just the bracket’s structural mass distributed by
ρ × V).Step 2 — run a modal solve and inspect the spectrum.
Step 3 — identify each mode’s character (transverse bending, axial, torsional) by inspecting the recovered eigenvectors.
Step 4 — compute modal participation factors and effective modal mass per excitation direction (X, Y, Z).
Step 5 — read the cumulative-effective-mass curve to pick \(n_\text{modes}\) for a response-spectrum analysis meeting the ASCE 7 / Eurocode 8 90 % threshold.
Step 6 — render the lowest few mode shapes for the design-review packet.
The post-processing recipes for participation factors and mode-shape animation live in Modal participation factors + effective modal mass and Mode-shape animation — cantilever bending modes — they exercise the same APIs in narrower scope.
Theoretical reference#
A free-vibration modal analysis solves the generalised symmetric-definite eigenvalue problem (Modal eigenvalue problem and shift-invert Lanczos)
with \(\mathbf{K}\) and \(\mathbf{M}\) the assembled stiffness and mass matrices. Each eigenpair returns one mode shape \(\boldsymbol{\phi}_i\) and one natural frequency \(f_i = \omega_i / (2\pi)\).
For base-excitation analysis (the bracket bolted to a vibrating floor), the modal participation factor for mode \(i\) in direction \(d\) is
where \(\mathbf{r}^{(d)}\) is the influence vector — ones on every translational DOF along direction \(d\), zeros elsewhere. The corresponding effective modal mass is
and the partition-of-unity property \(\sum_i M_\text{eff,i}^{(d)} = M_\text{total}\) lets the cumulative effective-mass curve answer the design-code question “how many modes do I need?”.
References: Bathe 2014 §9.3.4, Chopra 2017 §13.1, Cook et al. 2002 §11.6.
from __future__ import annotations
import numpy as np
import pyvista as pv
import femorph_solver
from femorph_solver import ELEMENTS
Step 1 — build the bracket#
Same prismatic-cantilever geometry and material as Tutorial 1, this time at slightly higher slenderness to push the first transverse-bending frequency lower. We keep HEX8-EAS so the bending modes don’t shear-lock.
E = 2.0e11 # Pa, steel
NU = 0.30
RHO = 7850.0 # kg/m³
L = 1.0 # m
WIDTH = 0.05
HEIGHT = 0.05
NX, NY, NZ = 60, 3, 3
xs = np.linspace(0.0, L, NX + 1)
ys = np.linspace(0.0, WIDTH, NY + 1)
zs = np.linspace(0.0, HEIGHT, NZ + 1)
grid = pv.StructuredGrid(*np.meshgrid(xs, ys, zs, indexing="ij")).cast_to_unstructured_grid()
m = femorph_solver.Model.from_grid(grid)
m.assign(
ELEMENTS.HEX8(integration="enhanced_strain"),
material={"EX": E, "PRXY": NU, "DENS": RHO},
)
pts = np.asarray(m.grid.points)
node_nums = np.asarray(m.grid.point_data["ansys_node_num"], dtype=np.int64)
clamped = np.where(pts[:, 0] < 1e-9)[0]
m.fix(nodes=node_nums[clamped].tolist(), dof="ALL")
total_mass = RHO * L * WIDTH * HEIGHT
print("Cantilever bracket — modal survey")
print(f" L = {L} m, cross = {WIDTH} × {HEIGHT} m, ρ = {RHO} kg/m³")
print(f" Mesh: {NX} × {NY} × {NZ} HEX8-EAS = {m.grid.n_cells} cells, {m.grid.n_points} nodes")
print(f" Total structural mass M_total = ρ·L·A = {total_mass:.3f} kg")
Cantilever bracket — modal survey
L = 1.0 m, cross = 0.05 × 0.05 m, ρ = 7850.0 kg/m³
Mesh: 60 × 3 × 3 HEX8-EAS = 540 cells, 976 nodes
Total structural mass M_total = ρ·L·A = 19.625 kg
Step 2 — modal solve and spectrum inspection#
Twelve modes is enough for a slender cantilever to clear the 90 % cumulative-mass threshold in the bending directions — more than we need but it gives a clean spectrum to study.
Model.solve_modal
dispatches to scipy.sparse.linalg.eigsh() via shift-
invert Lanczos at \(\sigma = 0\)
(Modal eigenvalue problem and shift-invert Lanczos). n_modes controls
how many lowest eigenpairs come back.
N_MODES = 12
res = m.solve_modal(n_modes=N_MODES)
freqs = np.asarray(res.frequency, dtype=np.float64)
shapes = np.asarray(res.mode_shapes, dtype=np.float64)
# ``mode_shapes`` is shaped ``(n_dof, n_modes)``. Each column is a
# mass-orthonormalised eigenvector.
print()
print(f" {'mode':>4} {'f [Hz]':>10}")
print(" " + "-" * 18)
for i in range(N_MODES):
print(f" {i + 1:>4} {freqs[i]:>10.3f}")
mode f [Hz]
------------------
1 40.831
2 40.831
3 253.268
4 253.268
5 698.142
6 698.142
7 750.821
8 1264.431
9 1338.909
10 1338.909
11 2156.450
12 2156.450
Step 3 — mode-shape character via translation-energy split#
Each mode mixes translational components. A transverse- bending mode has nearly all its kinetic energy in \(u_y\) (or \(u_z\)); an axial mode has it in \(u_x\); a torsional mode has the warping pattern characteristic of Saint-Venant torsion.
We classify each mode by the per-direction translation-energy fraction. The square cross-section makes \(y\) / \(z\) bending pairs degenerate, so transverse modes appear in pairs at the same frequency.
dof_map = m.dof_map()
classifications = []
for i in range(N_MODES):
phi = shapes[:, i]
energy_total = (phi**2).sum()
energy_per_dof = np.zeros(3)
for row, (_node, dof_idx) in enumerate(dof_map.tolist()):
if dof_idx < 3:
energy_per_dof[int(dof_idx)] += phi[row] ** 2
fractions = energy_per_dof / (energy_total + 1e-30)
# Pick the dominant direction.
label = ("axial (x)", "transverse-y", "transverse-z")[int(np.argmax(fractions))]
classifications.append((label, fractions))
print()
print(f" {'mode':>4} {'f [Hz]':>9} {'character':>14} {'%U_x':>6} {'%U_y':>6} {'%U_z':>6}")
print(" " + "-" * 53)
for i, (label, frac) in enumerate(classifications):
print(
f" {i + 1:>4} {freqs[i]:>9.3f} {label:>14} "
f"{100 * frac[0]:>5.1f}% {100 * frac[1]:>5.1f}% {100 * frac[2]:>5.1f}%"
)
mode f [Hz] character %U_x %U_y %U_z
-----------------------------------------------------
1 40.831 transverse-z 0.2% 0.8% 99.0%
2 40.831 transverse-y 0.2% 99.0% 0.8%
3 253.268 transverse-y 1.1% 75.9% 23.0%
4 253.268 transverse-z 1.1% 23.0% 75.9%
5 698.142 transverse-z 2.4% 38.2% 59.4%
6 698.142 transverse-y 2.4% 59.4% 38.2%
7 750.821 transverse-z 0.0% 50.0% 50.0%
8 1264.431 axial (x) 100.0% 0.0% 0.0%
9 1338.909 transverse-z 4.2% 23.7% 72.1%
10 1338.909 transverse-y 4.2% 72.1% 23.7%
11 2156.450 transverse-z 6.1% 35.0% 58.9%
12 2156.450 transverse-y 6.1% 58.9% 35.0%
Step 4 — modal participation factors and effective mass#
Build the X / Y / Z influence vectors once, then compute Γ and M_eff for every mode × every direction in one vectorised pass. The recipe matches Modal participation factors + effective modal mass.
M = m.mass_matrix()
n_dof = M.shape[0]
r_x = np.zeros(n_dof)
r_y = np.zeros(n_dof)
r_z = np.zeros(n_dof)
for row, (_node, dof_idx) in enumerate(dof_map.tolist()):
if dof_idx == 0:
r_x[row] = 1.0
elif dof_idx == 1:
r_y[row] = 1.0
elif dof_idx == 2:
r_z[row] = 1.0
directions = (("X", r_x), ("Y", r_y), ("Z", r_z))
gamma = np.zeros((N_MODES, 3))
m_eff = np.zeros((N_MODES, 3))
for i in range(N_MODES):
phi = shapes[:, i]
M_phi = M @ phi
modal_mass_i = float(phi @ M_phi) # ≈ 1 for mass-orthonormal
for d_idx, (_, r) in enumerate(directions):
L_i = float(phi @ M @ r)
gamma_i = L_i / modal_mass_i
gamma[i, d_idx] = gamma_i
m_eff[i, d_idx] = gamma_i**2 * modal_mass_i
cum_eff = np.cumsum(m_eff, axis=0)
cum_pct = cum_eff / total_mass * 100.0
print()
print(" Cumulative effective mass [%] per direction:")
print(f" {'mode':>4} {'f [Hz]':>9} {'cum_X':>7} {'cum_Y':>7} {'cum_Z':>7}")
print(" " + "-" * 48)
for i in range(N_MODES):
print(
f" {i + 1:>4} {freqs[i]:>9.3f} "
f"{cum_pct[i, 0]:>6.2f}% {cum_pct[i, 1]:>6.2f}% {cum_pct[i, 2]:>6.2f}%"
)
Cumulative effective mass [%] per direction:
mode f [Hz] cum_X cum_Y cum_Z
------------------------------------------------
1 40.831 0.00% 0.49% 60.73%
2 40.831 0.00% 61.21% 61.21%
3 253.268 0.00% 75.72% 65.62%
4 253.268 0.00% 80.13% 80.13%
5 698.142 0.00% 82.69% 84.11%
6 698.142 0.00% 86.68% 86.68%
7 750.821 0.00% 86.68% 86.68%
8 1264.431 80.89% 86.68% 86.68%
9 1338.909 80.89% 87.52% 89.23%
10 1338.909 80.89% 90.06% 90.06%
11 2156.450 80.89% 90.84% 91.37%
12 2156.450 80.89% 92.14% 92.14%
Step 5 — design-code mode-count threshold#
ASCE 7 §12.9 and Eurocode 8 §4.3.3.3 both require at least 90 % cumulative effective mass in every direction the response-spectrum input acts. Read the thresholds off the cumulative table.
target_pct = 90.0
for d_idx, dname in enumerate(("X", "Y", "Z")):
crossings = np.where(cum_pct[:, d_idx] >= target_pct)[0]
if len(crossings) > 0:
n_required = int(crossings[0]) + 1
print(
f" {dname}: needs ≥ {n_required} modes for {target_pct:.0f} % cumulative effective mass "
f"({cum_pct[n_required - 1, d_idx]:.2f} % at mode {n_required})"
)
else:
print(
f" {dname}: {N_MODES} modes only reach {cum_pct[-1, d_idx]:.2f} % — "
f"needs more modes to clear {target_pct:.0f} % threshold"
)
# X direction has no bending modes in the first 12 (all bending
# is transverse y / z), so X stays at 0 % cumulative mass — that's
# the correct physics for a slender cantilever where axial modes
# only appear far above the bending fundamentals. Asserts only
# Y / Z which are the design-relevant directions.
y_n = (
int(np.where(cum_pct[:, 1] >= target_pct)[0][0]) + 1
if (cum_pct[:, 1] >= target_pct).any()
else N_MODES
)
z_n = (
int(np.where(cum_pct[:, 2] >= target_pct)[0][0]) + 1
if (cum_pct[:, 2] >= target_pct).any()
else N_MODES
)
n_for_response_spectrum = max(y_n, z_n)
print()
print(
f" → Use n_modes = {n_for_response_spectrum} for a 3-direction "
f"response-spectrum analysis (max over Y / Z; X is axial-only and "
f"out of the bending band the equipment excites)."
)
assert n_for_response_spectrum <= N_MODES, "default 12 modes was too few — increase the solve"
X: 12 modes only reach 80.89 % — needs more modes to clear 90 % threshold
Y: needs ≥ 10 modes for 90 % cumulative effective mass (90.06 % at mode 10)
Z: needs ≥ 10 modes for 90 % cumulative effective mass (90.06 % at mode 10)
→ Use n_modes = 10 for a 3-direction response-spectrum analysis (max over Y / Z; X is axial-only and out of the bending band the equipment excites).
Step 6 — render the lowest four mode shapes#
mode_pairs = []
seen_freqs: list[float] = []
for i in range(N_MODES):
f = freqs[i]
if any(abs(f - sf) < 1e-3 for sf in seen_freqs):
continue
seen_freqs.append(float(f))
mode_pairs.append(i)
if len(mode_pairs) >= 4:
break
plotter = pv.Plotter(shape=(1, 4), off_screen=True, window_size=(1400, 320), border=False)
for col, idx in enumerate(mode_pairs):
plotter.subplot(0, col)
grid_warped = m.grid.copy()
n_pts = grid_warped.n_points
disp = np.zeros((n_pts, 3))
for row, (node, dof_idx) in enumerate(dof_map.tolist()):
if dof_idx < 3:
pt_idx = np.where(node_nums == int(node))[0]
if len(pt_idx):
disp[pt_idx[0], int(dof_idx)] += shapes[row, idx]
peak = float(np.linalg.norm(disp, axis=1).max())
scale = 0.10 * L / peak if peak > 0 else 1.0
grid_warped.points = np.asarray(m.grid.points) + scale * disp
grid_warped["|disp|"] = np.linalg.norm(disp, axis=1)
plotter.add_mesh(m.grid, style="wireframe", color="grey", opacity=0.3)
plotter.add_mesh(
grid_warped,
scalars="|disp|",
cmap="viridis",
show_edges=False,
show_scalar_bar=False,
)
plotter.add_text(
f"mode {idx + 1}\nf = {freqs[idx]:.2f} Hz\n{classifications[idx][0]}",
position="upper_left",
font_size=9,
)
plotter.view_isometric()
plotter.camera.zoom(1.05)
plotter.link_views()
plotter.show()

Take-aways#
The modal solve is a single
Model.solve_modal(n_modes=N)call; the eigensolver picks shift-invert Lanczos automatically (Modal analysis).Mode classification by translation-energy fraction is a robust filter against degenerate transverse pairs and stray torsional modes — works on any geometry.
Modal participation + effective mass is computed from first principles via
Model.mass_matrix()plus the per-direction influence vector built fromModel.dof_map().Mode-count selection for response-spectrum analysis reads directly off the cumulative-effective-mass table: ASCE 7 / Eurocode 8 require ≥ 90 % per direction.
Mode-shape rendering uses the same scatter pattern as Plotting mode shapes — display amplitude is scaled to a fraction of the bounding box (mode shapes carry no absolute magnitude).
Next steps:
Tutorial 3 (planned) — pressure-vessel design-by-analysis on a Lamé thick cylinder.
For a worked response-spectrum computation that builds on this modal survey, see the planned Tutorial 4 — it convolves a synthetic ASCE 7 design spectrum with the participation-factor table generated above.
Total running time of the script: (0 minutes 1.636 seconds)