Skip to content

Commit 94ebeb8

Browse files
authored
Add reduced DOF linear solver and modes visualization (#8)
1 parent d97725b commit 94ebeb8

File tree

2 files changed

+255
-0
lines changed

2 files changed

+255
-0
lines changed

9_reduced_DOF/linear.py

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
2+
"""
3+
2D Modal Analysis with Combined Animation of Mode Shapes
4+
5+
This program performs a dynamic modal analysis of a 2D vertical cantilever beam
6+
using 4-node quadrilateral finite elements under a plane stress assumption.
7+
The first 6 eigenmodes are computed and visualized side-by-side in a single
8+
animated GIF, showing the harmonic motion of each mode over time.
9+
10+
Author: Žiga Kovačič
11+
Date: 2025-06-22
12+
"""
13+
14+
import numpy as np
15+
from scipy.sparse import lil_matrix
16+
from scipy.sparse.linalg import eigsh
17+
import matplotlib.pyplot as plt
18+
import matplotlib.tri as tri
19+
import matplotlib.animation as animation
20+
import time
21+
22+
# Problem parameters
23+
L = 15.0 # Length (now vertical)
24+
H = 1.0 # Height (now horizontal width)
25+
t = 0.1 # Thickness
26+
27+
# Material properties
28+
E = 5e7 # Young's modulus
29+
nu = 0.3 # Poisson's ratio
30+
rho = 500 # Density
31+
32+
# Mesh discretization
33+
Nel_L = 30 # Elements along the length
34+
Nel_H = 5 # Elements along the height
35+
36+
print(f"Solving with a 2D mesh: {Nel_H}x{Nel_L} elements for a vertical beam.")
37+
38+
def generate_2d_mesh(L_mesh, H_mesh, Nx_mesh, Ny_mesh):
39+
"""Generate structured grid of nodes and quadrilateral elements."""
40+
x_coords = np.linspace(0, L_mesh, Nx_mesh + 1)
41+
y_coords = np.linspace(0, H_mesh, Ny_mesh + 1)
42+
nodes_x, nodes_y = np.meshgrid(x_coords, y_coords)
43+
nodes = np.vstack([nodes_x.ravel(), nodes_y.ravel()]).T
44+
45+
elements = []
46+
for j in range(Ny_mesh):
47+
for i in range(Nx_mesh):
48+
n0, n1 = j * (Nx_mesh + 1) + i, j * (Nx_mesh + 1) + (i + 1)
49+
n2, n3 = (j + 1) * (Nx_mesh + 1) + i, (j + 1) * (Nx_mesh + 1) + (i + 1)
50+
elements.append([n0, n1, n3, n2])
51+
return nodes, np.array(elements)
52+
53+
def get_element_matrices(element_nodes, D, rho, t):
54+
r"""
55+
Compute stiffness (K) and mass (M) matrices for a 4-node quad element.
56+
This function computes the building blocks for the global M and K matrices
57+
required by the PDF's Equation (2): $M \ddot{u} + D \dot{u} + K u = f$.
58+
"""
59+
60+
# The integrals for k_e and m_e are computed numerically using Gaussian Quadrature.
61+
# The method approximates an integral over the standard interval [-1, 1] as:
62+
# $\int_{-1}^{1} g(x) dx \approx \sum_{i=1}^{n} w_i g(x_i)$
63+
# We use the 2-point Gauss-Legendre rule (n=2), which is exact for polynomials up to degree 2n-1=3.
64+
65+
# Gauss point coordinate $\xi_i, \eta_i = \pm 1/\sqrt{3}$ for 2x2 numerical integration.
66+
gp = 1.0 / np.sqrt(3)
67+
68+
gauss_points = np.array([[-gp, -gp], [gp, -gp], [gp, gp], [-gp, gp]])
69+
70+
# Initialize element stiffness $k_e$ and mass $m_e$ matrices
71+
k_e, m_e = np.zeros((8, 8)), np.zeros((8, 8))
72+
73+
# loop over the 4 Gauss points to perform numerical integration.
74+
for i in range(4):
75+
xi, eta = gauss_points[i]
76+
77+
# derivatives of shape functions w.r.t. natural coords $(\xi,\eta)$:
78+
# dN_dxi_eta corresponds to the matrix $[\partial N / \partial \xi, \partial N / \partial \eta]^T$
79+
dN_dxi_eta = 0.25 * np.array([
80+
[-(1 - eta), (1 - eta), (1 + eta), -(1 + eta)],
81+
[-(1 - xi), -(1 + xi), (1 + xi), (1 - xi)]
82+
])
83+
84+
# Jacobian matrix: $J = \frac{\partial(x,y)}{\partial(\xi,\eta)}$
85+
J = dN_dxi_eta @ element_nodes
86+
# $dA = \det(J) d\xi d\eta$
87+
detJ = np.linalg.det(J)
88+
# derivatives of shape functions w.r.t. real coords $(x,y)$: $[\partial N / \partial x, \partial N / \partial y]^T = J^{-1} [\partial N / \partial \xi, \partial N / \partial \eta]^T$
89+
dN_dxy = np.linalg.inv(J) @ dN_dxi_eta
90+
91+
# Form the strain-displacement matrix $B$, which defines strain: $\varepsilon = B d$
92+
B = np.zeros((3, 8))
93+
for j in range(4):
94+
B[0, 2*j], B[1, 2*j + 1] = dN_dxy[0, j], dN_dxy[1, j]
95+
B[2, 2*j], B[2, 2*j + 1] = dN_dxy[1, j], dN_dxy[0, j]
96+
97+
# Approximate the stiffness matrix integral: $k_e = \int_A (B^T D B) t \, dA$
98+
# by calculating one term in the Gauss quadrature sum: $(B^T D B) \cdot (t \cdot \det(J)) \cdot w_i$, where again $w_i=1$.
99+
k_e += B.T @ D @ B * detJ * t
100+
101+
# Shape function matrix $N$, for interpolating displacement: $u(x,y) = N d$
102+
N_shape = 0.25 * (1 + np.array([-1, 1, 1, -1])*xi) * (1 + np.array([-1, -1, 1, 1])*eta)
103+
N_mat = np.zeros((2, 8))
104+
for j in range(4):
105+
N_mat[0, 2*j] = N_mat[1, 2*j+1] = N_shape[j]
106+
107+
# Approximate the mass matrix integral: $m_e = \int_A (\rho N^T N) t \, dA$
108+
# by calculating one term in the sum: $(\rho N^T N) \cdot (t \cdot \det(J)) \cdot w_i$, where again $w_i=1$.
109+
m_e += rho * t * N_mat.T @ N_mat * detJ
110+
111+
return k_e, m_e
112+
113+
start_time = time.time()
114+
115+
# Generate mesh for a vertical beam (width H, length L)
116+
nodes, elements = generate_2d_mesh(H, L, Nel_H, Nel_L)
117+
n_nodes, n_dof = nodes.shape[0], 2 * nodes.shape[0]
118+
119+
# Plane stress constitutive matrix, defines stress-strain relationship
120+
D = (E / (1 - nu**2)) * np.array([[1, nu, 0], [nu, 1, 0], [0, 0, (1 - nu) / 2]])
121+
122+
K_global = lil_matrix((n_dof, n_dof))
123+
M_global = lil_matrix((n_dof, n_dof))
124+
125+
print("Assembling global matrices...")
126+
for el_nodes_indices in elements:
127+
k_e, m_e = get_element_matrices(nodes[el_nodes_indices], D, rho, t)
128+
dof_indices = np.ravel([[2*n, 2*n+1] for n in el_nodes_indices])
129+
K_global[np.ix_(dof_indices, dof_indices)] += k_e
130+
M_global[np.ix_(dof_indices, dof_indices)] += m_e
131+
132+
K_global, M_global = K_global.tocsr(), M_global.tocsr() # Convert to CSR format for efficient operations
133+
134+
# Apply boundary conditions
135+
# Vertical beam fixed at the bottom (y=0)
136+
clamped_nodes = np.where(nodes[:, 1] < 1e-9)[0]
137+
clamped_dofs = np.ravel([[2*n, 2*n+1] for n in clamped_nodes])
138+
free_dofs = np.setdiff1d(np.arange(n_dof), clamped_dofs)
139+
140+
# Compute reduced matrices
141+
K_reduced = K_global[np.ix_(free_dofs, free_dofs)]
142+
M_reduced = M_global[np.ix_(free_dofs, free_dofs)]
143+
144+
# Solve eigenvalue problem
145+
# Equation: K_reduced * psi = lambda * M_reduced * psi
146+
N_eig = 8
147+
print(f"Solving eigenvalue problem for {N_eig} modes...")
148+
149+
eigenvalues, eigenvectors_reduced = eigsh(K_reduced, k=N_eig, M=M_reduced, sigma=0, which='LM')
150+
angular_freq = np.sqrt(np.abs(eigenvalues))
151+
frequencies = angular_freq / (2 * np.pi)
152+
153+
# Reconstruct full eigenvectors, zero out fixed DOFs
154+
eigenvectors = np.zeros((n_dof, N_eig))
155+
eigenvectors[free_dofs, :] = eigenvectors_reduced
156+
157+
assembly_time = time.time() - start_time
158+
print(f"Assembly and solution took {assembly_time:.2f} seconds.")
159+
160+
print("\nComputed Frequencies (Hz):")
161+
for i in range(N_eig):
162+
print(f"Mode {i+1}: {frequencies[i]:.2f} Hz")
163+
164+
165+
# ---------------------
166+
# --- Visualization ---
167+
# ---------------------
168+
print("\nGenerating combined mode animation...")
169+
170+
num_modes_to_animate = 6
171+
animation_duration = 5.0
172+
fps = 30
173+
num_frames = int(animation_duration * fps)
174+
displacement_scale = L * 0.15 # Scale displacements for better visibility
175+
176+
fig, axes = plt.subplots(1, num_modes_to_animate, figsize=(15, 8))
177+
178+
triangles = []
179+
for el in elements:
180+
triangles.append([el[0], el[1], el[3]])
181+
triangles.append([el[1], el[2], el[3]])
182+
triang = tri.Triangulation(nodes[:, 0], nodes[:, 1], triangles)
183+
184+
all_deformed_nodes = []
185+
for i in range(num_modes_to_animate):
186+
if i >= N_eig: break
187+
psi = eigenvectors[:, i]
188+
max_disp_shape = (psi.reshape(-1, 2) / np.max(np.abs(psi))) * displacement_scale
189+
all_deformed_nodes.append(nodes + max_disp_shape)
190+
all_deformed_nodes.append(nodes - max_disp_shape)
191+
192+
all_deformed_nodes = np.vstack(all_deformed_nodes)
193+
x_min, y_min = all_deformed_nodes.min(axis=0)
194+
x_max, y_max = all_deformed_nodes.max(axis=0)
195+
x_range, y_range = x_max - x_min, y_max - y_min
196+
fixed_xlim = [x_min - 0.1 * x_range, x_max + 0.1 * x_range]
197+
fixed_ylim = [y_min - 0.1 * y_range, y_max + 0.1 * y_range]
198+
199+
vmax_values = []
200+
for i in range(num_modes_to_animate):
201+
psi = eigenvectors[:, i]
202+
psi_normalized = psi / np.max(np.abs(psi))
203+
disp_mag_static = np.sqrt(psi_normalized.reshape(-1, 2)[:, 0]**2 + psi_normalized.reshape(-1, 2)[:, 1]**2)
204+
vmax_values.append(np.max(disp_mag_static) * displacement_scale)
205+
206+
def animate(frame):
207+
t_current = frame / fps
208+
for i, ax in enumerate(axes):
209+
if i >= N_eig:
210+
ax.axis('off')
211+
continue
212+
213+
ax.clear()
214+
ax.set_xlim(fixed_xlim)
215+
ax.set_ylim(fixed_ylim)
216+
ax.set_aspect('equal')
217+
218+
ax.triplot(triang, '--', color='gray', linewidth=0.5)
219+
220+
psi = eigenvectors[:, i]
221+
omega = angular_freq[i]
222+
223+
# Normalize eigenvector so max displacement is displacement_scale
224+
psi_normalized = psi / np.max(np.abs(psi))
225+
226+
scale_factor = np.sin(omega * t_current)
227+
current_disp = psi_normalized.reshape(-1, 2) * scale_factor * displacement_scale
228+
deformed_nodes = nodes + current_disp
229+
230+
disp_mag_instant = np.sqrt(current_disp[:, 0]**2 + current_disp[:, 1]**2)
231+
232+
tpc = ax.tripcolor(deformed_nodes[:, 0], deformed_nodes[:, 1], triang.triangles,
233+
facecolors=disp_mag_instant[triang.triangles].mean(axis=1),
234+
edgecolors='k', cmap='viridis', linewidth=0.5,
235+
vmin=0, vmax=vmax_values[i])
236+
237+
ax.set_title(f'Mode {i+1}\n{frequencies[i]:.2f} Hz', fontsize=15)
238+
ax.set_xticks([])
239+
ax.set_yticks([])
240+
241+
if axes[0]:
242+
axes[0].set_ylabel('Y-Position (m)')
243+
fig.supxlabel('Mode Shapes', y=0.05)
244+
245+
return [artist for ax in axes for artist in ax.get_children() if isinstance(artist, plt.Artist)]
246+
247+
ani = animation.FuncAnimation(fig, animate, frames=num_frames, interval=1000/fps, blit=False)
248+
fig.tight_layout(rect=[0, 0.05, 1, 1])
249+
250+
filename = f"modes_1-{num_modes_to_animate}_combined_animation.gif"
251+
print(f"Saving combined animation for Modes 1-{num_modes_to_animate} to '{filename}'...")
252+
ani.save(filename, writer='pillow', fps=fps)
253+
plt.close(fig)
254+
255+
print("All animations generated.")

9_reduced_DOF/modes_1-6.gif

45.2 MB
Loading

0 commit comments

Comments
 (0)