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 ("\n Computed 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 ("\n Generating 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." )
0 commit comments