Note
Go to the end to download the full example code.
Principal stresses + principal directions on a thick cylinder#
Where Nodal stress recovery + invariants — Lamé thick-walled cylinder answered how big the stress is, this example answers which way it points. The Cauchy stress tensor \(\sigma_{ij}\) at every node has three orthogonal principal directions along which there is no shear, with the corresponding principal stresses \(\sigma_{1} \ge \sigma_{2} \ge \sigma_{3}\) along those axes. Tracing those eigenvectors over a domain gives the stress-trajectory picture beloved by mechanical designers (Timoshenko & Goodier 1970 §1.7) — the visualisation that shows where the load actually flows.
For a thick cylinder under internal pressure, the answer is known exactly: the principal directions are radial and hoop, and \(\sigma_{1}\) is the hoop component everywhere. So the recovered eigenvectors give a clean accuracy check — they should point along the local \(\hat{\theta}\) to within mesh- discretisation error.
This example uses the public stress-recovery pipeline plus
numpy.linalg.eigh() per node to extract the principal directions
(stress_invariants returns the principal stresses only):
compute_nodal_stress(model, u)— node-averaged Voigt array.stress_invariants(sigma)— \(\sigma_{1,2,3}\) and companion scalars per node.np.linalg.eighon the per-node 3x3 Cauchy tensor — yields sorted eigenvalues and eigenvectors so we can identify which principal axis aligns with which physical direction.
Verification#
At every node of the Lamé annulus, the recovered \(\sigma_{1}\) direction must be tangent to the local hoop circle (perpendicular to the radial unit vector). We compute the angle between the recovered \(\sigma_{1}\) axis and the analytical hoop direction \(\hat{\theta}\) at every node and verify that it stays under a small tolerance — the trace error grows mildly with mesh size but never drifts off the hoop direction.
References#
Timoshenko, S. P. and Goodier, J. N. (1970) Theory of Elasticity, 3rd ed., McGraw-Hill, §1.7 — principal axes, §33 — Lamé thick cylinder.
Cook, R. D., Malkus, D. S., Plesha, M. E., Witt, R. J. (2002) Concepts and Applications of Finite Element Analysis, 4th ed., Wiley, §3.6 — stress invariants and principal stresses.
Sokolnikoff, I. S. (1956) Mathematical Theory of Elasticity, 2nd ed., McGraw-Hill, §22 — principal-direction trajectories.
from __future__ import annotations
import math
import numpy as np
import pyvista as pv
import femorph_solver
from femorph_solver import ELEMENTS
from femorph_solver.recover import compute_nodal_stress, stress_invariants
Build the Lamé quarter-annulus (HEX8 EAS, plane strain)#
a = 0.10 # bore radius [m]
b = 0.20 # outer radius [m]
t_axial = 0.02 # plane-strain slab thickness [m]
p_i = 1.0e7 # internal pressure [Pa]
E = 2.0e11
NU = 0.30
N_THETA, N_RAD = 24, 16
theta = np.linspace(0.0, 0.5 * math.pi, N_THETA + 1)
r = np.linspace(a, b, N_RAD + 1)
pts_list: list[list[float]] = []
for kz in (0.0, t_axial):
for ti in theta:
for rj in r:
pts_list.append([rj * math.cos(ti), rj * math.sin(ti), kz])
pts_arr = np.array(pts_list, dtype=np.float64)
nx_plane = (N_THETA + 1) * (N_RAD + 1)
n_cells = N_THETA * N_RAD
cells = np.empty((n_cells, 9), dtype=np.int64)
cells[:, 0] = 8
c = 0
for i in range(N_THETA):
for j in range(N_RAD):
n00b = i * (N_RAD + 1) + j
n10b = i * (N_RAD + 1) + (j + 1)
n11b = (i + 1) * (N_RAD + 1) + (j + 1)
n01b = (i + 1) * (N_RAD + 1) + j
cells[c, 1:] = (
n00b,
n10b,
n11b,
n01b,
n00b + nx_plane,
n10b + nx_plane,
n11b + nx_plane,
n01b + nx_plane,
)
c += 1
grid = pv.UnstructuredGrid(cells.ravel(), np.full(n_cells, 12, dtype=np.uint8), pts_arr)
m = femorph_solver.Model.from_grid(grid)
m.assign(
ELEMENTS.HEX8(integration="enhanced_strain"),
material={"EX": E, "PRXY": NU, "DENS": 7850.0},
)
for k, p in enumerate(pts_arr):
if p[0] < 1e-9:
m.fix(nodes=[int(k + 1)], dof="UX")
if p[1] < 1e-9:
m.fix(nodes=[int(k + 1)], dof="UY")
m.fix(nodes=[int(k + 1)], dof="UZ")
# Internal pressure — lump as nodal forces on the bore face.
fx_acc: dict[int, float] = {}
fy_acc: dict[int, float] = {}
for kz in (0, 1):
inner = [kz * nx_plane + i * (N_RAD + 1) + 0 for i in range(N_THETA + 1)]
for seg in range(N_THETA):
ai, bi = inner[seg], inner[seg + 1]
ds = float(np.linalg.norm(pts_arr[bi] - pts_arr[ai]))
mid = 0.5 * (pts_arr[ai] + pts_arr[bi])
rxy = np.array([mid[0], mid[1]])
nrm = float(np.linalg.norm(rxy))
outward = rxy / nrm if nrm > 1e-12 else np.zeros(2)
f_seg = p_i * ds * (t_axial / 2.0)
for n_idx in (ai, bi):
fx_acc[n_idx] = fx_acc.get(n_idx, 0.0) + 0.5 * f_seg * outward[0]
fy_acc[n_idx] = fy_acc.get(n_idx, 0.0) + 0.5 * f_seg * outward[1]
for n_idx, fx in fx_acc.items():
fy = fy_acc[n_idx]
m.apply_force(int(n_idx + 1), fx=fx, fy=fy)
print("Lamé thick cylinder — principal stress + principal direction recovery")
print(f" a = {a} m, b = {b} m, p_i = {p_i / 1e6:.1f} MPa, mesh ({N_THETA}, {N_RAD})")
Lamé thick cylinder — principal stress + principal direction recovery
a = 0.1 m, b = 0.2 m, p_i = 10.0 MPa, mesh (24, 16)
Solve and recover the stress field#
res = m.solve_static()
u = np.asarray(res.displacement, dtype=np.float64).ravel()
sigma = compute_nodal_stress(m, u)
inv = stress_invariants(sigma)
Recover the principal directions per node#
stress_invariants returns the eigenvalues only; for principal
directions we re-build the (n_points, 3, 3) Cauchy tensors and call
np.linalg.eigh to get sorted eigenvalues and eigenvectors.
eigh returns ascending eigenvalues, so column 2 is \(\sigma_{1}\).
n_pts = sigma.shape[0]
T = np.empty((n_pts, 3, 3), dtype=np.float64)
T[:, 0, 0] = sigma[:, 0]
T[:, 1, 1] = sigma[:, 1]
T[:, 2, 2] = sigma[:, 2]
T[:, 0, 1] = T[:, 1, 0] = sigma[:, 3]
T[:, 1, 2] = T[:, 2, 1] = sigma[:, 4]
T[:, 0, 2] = T[:, 2, 0] = sigma[:, 5]
eigvals, eigvecs = np.linalg.eigh(T)
# eigvecs[:, :, k] is the k-th eigenvector (ascending); k=2 → σ1.
v1 = eigvecs[:, :, 2]
v3 = eigvecs[:, :, 0]
Verify: σ1 axis aligns with the analytical hoop direction#
At a point \((r\cos\theta, r\sin\theta, z)\) on the annulus, the hoop unit vector is \(\hat{\theta} = (-\sin\theta, \cos\theta, 0)\). The recovered \(\sigma_{1}\) eigenvector should be parallel (or anti-parallel) to it — alignment is the absolute dot product.
r_xy = np.linalg.norm(pts_arr[:, :2], axis=1)
theta_node = np.arctan2(pts_arr[:, 1], pts_arr[:, 0])
hoop_hat = np.column_stack([-np.sin(theta_node), np.cos(theta_node), np.zeros_like(theta_node)])
align = np.abs((v1 * hoop_hat).sum(axis=1))
# Skip the on-axis ridge (r ≈ 0) where the hoop direction is undefined.
mask = r_xy > 1e-6
mean_align = float(align[mask].mean())
worst_align = float(align[mask].min())
print(f"\n σ1 / hoop alignment mean |v1·θ̂| = {mean_align:.6f} min = {worst_align:.6f}")
print(f" worst-case angle off hoop: {np.rad2deg(np.arccos(worst_align)):.3f}°")
assert mean_align > 0.999, f"σ1 axis drifted from hoop direction (mean alignment {mean_align:.6f})"
assert worst_align > 0.95, f"worst-case σ1 / hoop alignment {worst_align:.6f}"
σ1 / hoop alignment mean |v1·θ̂| = 0.999989 min = 0.999866
worst-case angle off hoop: 0.938°
Tabulate principal stresses at the bore + outer radii#
Closed form for the Lamé cylinder at any radius \(r\):
So \(\sigma_1 \approx \sigma_\theta\) (positive) and \(\sigma_3 \approx \sigma_r\) (negative) — at \(\theta = 0\) / \(\theta = \pi/2\) the radial and hoop directions trade with the global \(x / y\) axes.
print()
print(
f" {'r [m]':>7} {'σ1 FE [MPa]':>13} {'σ_θ pub [MPa]':>14} "
f"{'σ3 FE [MPa]':>13} {'σ_r pub [MPa]':>14}"
)
print(" " + "-" * 64)
target_radii = (a, 0.5 * (a + b), b)
for r_target in target_radii:
# First node closest to (r_target, 0, 0).
diff = np.linalg.norm(pts_arr[:, :2] - np.array([r_target, 0.0]), axis=1)
i_star = int(np.argmin(diff))
sigma_theta_pub = p_i * a**2 * (b**2 + r_target**2) / (r_target**2 * (b**2 - a**2))
sigma_r_pub = -p_i * a**2 * (b**2 - r_target**2) / (r_target**2 * (b**2 - a**2))
print(
f" {r_target:>7.4f} "
f"{inv['s1'][i_star] / 1e6:>11.3f} {sigma_theta_pub / 1e6:>13.3f} "
f"{inv['s3'][i_star] / 1e6:>11.3f} {sigma_r_pub / 1e6:>13.3f}"
)
r [m] σ1 FE [MPa] σ_θ pub [MPa] σ3 FE [MPa] σ_r pub [MPa]
----------------------------------------------------------------
0.1000 17.255 16.667 -8.624 -10.000
0.1500 9.251 9.259 -2.611 -2.593
0.2000 6.585 6.667 -0.189 -0.000
Render the σ1 field and principal-direction trajectories#
grid_render = grid.copy()
grid_render.point_data["σ1 [Pa]"] = inv["s1"]
grid_render.point_data["σ3 [Pa]"] = inv["s3"]
grid_render.point_data["σ_VM [Pa]"] = inv["von_mises"]
grid_render.point_data["v1"] = v1
grid_render.point_data["v3"] = v3
# Sample every Mth node to keep the arrow plot readable.
sample_stride = max(1, n_pts // 64)
sample_idx = np.arange(0, n_pts, sample_stride)
arrow_pts = pts_arr[sample_idx]
arrow_v1 = v1[sample_idx]
# Scale arrows uniformly to ~6 % of the bounding box.
arrow_len = 0.06 * (b - a)
arrow_grid = pv.PolyData(arrow_pts)
arrow_grid["v1"] = arrow_v1
arrow_grid.set_active_vectors("v1")
arrows = arrow_grid.glyph(orient="v1", scale=False, factor=arrow_len)
plotter = pv.Plotter(off_screen=True, window_size=(820, 540))
plotter.add_mesh(
grid_render,
scalars="σ1 [Pa]",
cmap="plasma",
show_edges=False,
scalar_bar_args={"title": "σ1 [Pa] (max principal)"},
)
plotter.add_mesh(arrows, color="white", line_width=2)
plotter.view_xy()
plotter.camera.zoom(1.05)
plotter.show()

Take-aways#
print()
print("Take-aways:")
print(
" • stress_invariants(sigma) returns the principal *stresses* (s1 ≥ s2 ≥ s3) "
"along with von-Mises / hydrostatic / max-shear scalars. Use it for any "
"design check that needs σ1 (max tension) or σ3 (max compression)."
)
print(
" • Principal *directions* require the full eigendecomposition: build the "
"per-node 3x3 Cauchy tensor and call np.linalg.eigh — eigvecs[:,:,2] is "
"the σ1 axis."
)
print(
" • Plotting the σ1 eigenvector field on top of a stress contour gives the "
"stress-trajectory picture: where the load actually flows. At bore-edge "
"in a Lamé cylinder the trajectories are perfect circles."
)
print(
" • Always sanity-check principal directions against an analytical reference "
"where one exists (radial / hoop for cylinders, bending fibres for beams) — "
"drift off the expected axis is an early warning of mesh or BC error."
)
Take-aways:
• stress_invariants(sigma) returns the principal *stresses* (s1 ≥ s2 ≥ s3) along with von-Mises / hydrostatic / max-shear scalars. Use it for any design check that needs σ1 (max tension) or σ3 (max compression).
• Principal *directions* require the full eigendecomposition: build the per-node 3x3 Cauchy tensor and call np.linalg.eigh — eigvecs[:,:,2] is the σ1 axis.
• Plotting the σ1 eigenvector field on top of a stress contour gives the stress-trajectory picture: where the load actually flows. At bore-edge in a Lamé cylinder the trajectories are perfect circles.
• Always sanity-check principal directions against an analytical reference where one exists (radial / hoop for cylinders, bending fibres for beams) — drift off the expected axis is an early warning of mesh or BC error.
Total running time of the script: (0 minutes 0.408 seconds)