Hi everyone,
I’m implementing an inverse dispersion solver in DOLFINx that computes complex wavenumbers k(ω) for waves in a periodic elastic 3D structure, using the SLEPc PEP-solver and Bloch periodic boundary conditions. The eigenvalues are correct and the dispersion curves look as expected. However, when plotting the mode shapes from the dispersion curve, the modes don’t look correct. Especially in the first branch, the modes don’t look accurate as there is only rigid body motion and no bending waves.
I have a related forward solver that computes ω(k) with the SLEPc EPS-solver for the same geometry, mesh, and material assignment, which yields a similar dispersion curve (with only real-values k-numbers). The mode shapes from that solver look accurate.
I’ve tried to extract the eigenvectors from the PEP-solver in different ways, plotting both the real part of the displacement as well as the magnitudes, but the problem persists.
The code for solving the inverse problem and generating the dataset with frequencies, eigenvalues (k), and eigenvectors is attached below, along with the code for plotting the dispersion curve and clicking on points to obtain the mode shapes. The STEP-file for the geometry is available at: GitHub - NadineTab02/FEniCSx_Project · GitHub
# Minimum Working Example - Solving for complex k (quadratic eigenvalue problem)
#--------------Import packages--------------------------
import numpy as np
import matplotlib.pyplot as plt
from petsc4py import PETSc
from mpi4py import MPI
import dolfinx
import gmsh
import dolfinx.mesh as msh
from dolfinx import mesh, fem, plot
from ufl import dx, inner
from basix.ufl import element
from dolfinx import default_scalar_type
from dolfinx.fem import form, functionspace, assemble_scalar, Function
from dolfinx.fem.petsc import assemble_matrix, LinearProblem
from slepc4py import SLEPc
from dolfinx.io import gmsh as gmshio
import pickle
import os
import dolfinx_mpc.utils
import ufl
import pyvista as pv
from dolfinx_mpc import MultiPointConstraint, assemble_matrix
from dolfinx.fem import form
from slepc4py import SLEPc
from petsc4py import PETSc
import cmath
import os
import pickle
import numpy as np
import matplotlib.pyplot as plt
# Function for gmsh
def import_CAD_to_FEniCSx(input_file = "object_new_broken_cubic_case", output_file = "from_comsol", lc = 0.25):
gmsh.initialize()
comm = MPI.COMM_WORLD
model_rank = 0
model = gmsh.model()
gmsh.model.add(output_file)
gmsh.model.occ.importShapes(f"{input_file}.step")
gmsh.model.occ.synchronize()
volumes = gmsh.model.getEntities(dim=3)
volume_tags = [v[1] for v in volumes]
if len(volume_tags) > 1:
gmsh.model.occ.fragment([(3, tag) for tag in volume_tags], [])
gmsh.model.occ.synchronize()
volumes = gmsh.model.getEntities(dim=3)
gmsh.model.occ.synchronize()
volumes = gmsh.model.getEntities(dim=3)
if len(volumes) == 1:
gmsh.model.addPhysicalGroup(3, [v[1] for v in volumes], tag=1)
else:
listvols1 = [1, 2, 3, 4, 5, 6, 7, 9]
gmsh.model.addPhysicalGroup(3, listvols1, tag=101)
listvols2 = [8, 10]
gmsh.model.addPhysicalGroup(3, listvols2, tag=201)
surfaces = gmsh.model.getEntities(dim=2)
listsurf1 = [4]
listsurf2 = [5]
listsurf3 = [1, 2, 3, 6]
gmsh.model.addPhysicalGroup(2, listsurf1, tag=1001)
gmsh.model.addPhysicalGroup(2, listsurf2, tag=2001)
gmsh.model.addPhysicalGroup(2, listsurf3, tag=3001)
gmsh.option.setNumber("Mesh.ElementOrder", 2)
gmsh.option.setNumber("Mesh.HighOrderOptimize", 2)
gmsh.option.setNumber("Mesh.CharacteristicLengthMax", lc)
gmsh.model.mesh.generate(3)
gmsh.write(f"{output_file}.msh")
mesh_data = gmshio.model_to_mesh(model, comm, model_rank)
final_mesh = mesh_data.mesh
facet_tags = mesh_data.facet_tags
cell_tags = mesh_data.cell_tags
gmsh.option.setNumber("Geometry.Surfaces", 1)
gmsh.option.setNumber("Geometry.SurfaceLabels", 1)
gmsh.fltk.run()
gmsh.finalize()
mesh_info = [final_mesh, cell_tags, facet_tags]
return mesh_info, None
# Function for removing rows and columns to handle rigid body modes
def extract_sub_matrix(A: PETSc.Mat,
V: dolfinx.fem.FunctionSpace,
bcs: list[dolfinx.fem.DirichletBC] = None,
mpc=None) -> PETSc.Mat:
num_dofs_local = V.dofmap.index_map.size_local * V.dofmap.index_map_bs
num_ghosts = V.dofmap.index_map.num_ghosts * V.dofmap.index_map_bs
if mpc is not None:
slave_local = np.array(mpc.is_slave, dtype=np.int32)
mask = np.ones(num_dofs_local + num_ghosts, dtype=np.int32)
mask[slave_local.astype(bool)] = 0
idx_set = np.flatnonzero(mask).astype(np.int32)
idx_set = idx_set[idx_set < num_dofs_local]
elif bcs is not None:
not_dirichlet = np.full(num_dofs_local + num_ghosts, 1, dtype=np.int32)
for bc in bcs:
not_dirichlet[bc.dof_indices()[0]] = 0
idx_set = np.flatnonzero(not_dirichlet).astype(np.int32)
idx_set = idx_set[idx_set < num_dofs_local]
else:
raise ValueError("Either bcs or mpc must be provided.")
# Build PETSc index sets
isx = PETSc.IS(A.getComm()).createGeneral(idx_set)
lgm_row = A.getLGMap()[0].applyIS(isx)
lgm_col = A.getLGMap()[1].applyIS(isx)
# Extract the submatrix
A_sub = A.createSubMatrix(lgm_row, lgm_col)
A_sub.assemble()
return A_sub, (idx_set, idx_set)
# Functions to handle periodicity in the system
def periodic_indicator(x, direction='z', toli=1e-8):
"""Returns True for points on the periodic boundary (max coordinate side)"""
coord_map = {'x': 0, 'y': 1, 'z': 2}
idx = coord_map[direction]
x_max = mesh.geometry.x[:, idx].max()
return np.isclose(x[idx], x_max, atol=toli)
def periodic_relation(x, direction='z', a_basis=None, toli=1e-8):
"""Maps points from max boundary to min boundary"""
out = x.copy()
coord_map = {'x': 0, 'y': 1, 'z': 2}
idx = coord_map[direction]
out[idx] = x[idx] - a_basis[idx]
return out
#--------------Create mesh from the step file--------------------
step_filename, mesh_name = 'Test_periodic_rod_geo_r025', 'mesh_rod'
lc = 10 # characteristic mesh element size
mesh_info, submesh_info = import_CAD_to_FEniCSx(step_filename, mesh_name, lc)
mesh, cell_tags, facet_tags = mesh_info
dim = mesh.geometry.dim
mesh.geometry.x[:] *= 0.001 # Need to downscale the geometry to original size 1 m
ds = ufl.Measure("ds", domain=mesh, subdomain_data=facet_tags)
#-------------------Geometry properties---------------------------
z_coords = mesh.geometry.x[:, 2]
L_f = z_coords.max() - z_coords.min()
# ---------------Define space function and element----------------------------
deg = 2
Ve = element("Lagrange", mesh.basix_cell(), deg, shape=(dim,))
V = functionspace(mesh, Ve)
u = ufl.TrialFunction(V)
v = ufl.TestFunction(V)
#-------------Material parameters-----------------------------------------------------------
material_tags = np.unique(cell_tags.values) if cell_tags is not None else np.array([])
print(f"Material tags found in mesh: {material_tags}")
# Define material properties based on the tags from your import function
material_properties = {}
if len(material_tags) == 1:
# Single material case
print("Single material detected")
# Use the actual tag from the mesh
tag = material_tags[0]
material_properties[tag] = {
"name": "main_structure",
"E": 2.10e14,
"rho": 7850,
"nu": 0.3
}
print("Material configuration:")
for tag, props in material_properties.items():
print(f" Tag {tag} ({props['name']}): E={props['E']:.2e}, rho={props['rho']}, nu={props['nu']}")
Q = functionspace(mesh, ("DG", 0))
E = Function(Q)
rho = Function(Q)
nu = Function(Q)
mu = Function(Q)
lambda_ = Function(Q)
# Get cell tags values
if hasattr(cell_tags, 'values'):
cell_values = cell_tags.values
else:
cell_values = cell_tags
for tag, props in material_properties.items():
cells = np.where(cell_values == tag)[0]
if len(cells) > 0:
E_val = props['E']
rho_val = props['rho']
nu_val = props['nu']
E.x.array[cells] = np.full_like(cells, E_val, dtype=default_scalar_type)
rho.x.array[cells] = np.full_like(cells, rho_val, dtype=default_scalar_type)
nu.x.array[cells] = np.full_like(cells, nu_val, dtype=default_scalar_type)
# Compute mu and lambda for these cells
mu_val = E_val / (2 * (1 + nu_val))
lambda_val = E_val * nu_val / ((1 + nu_val) * (1 - 2 * nu_val))
# Assign to mu and lambda functions
mu.x.array[cells] = np.full_like(cells, mu_val, dtype=default_scalar_type)
lambda_.x.array[cells] = np.full_like(cells, lambda_val, dtype=default_scalar_type)
print(f" Assigned to {len(cells)} cells for tag {tag}")
# ------------Boundary tagging-----------------------------------------------------
dx = ufl.Measure("dx", domain=mesh)
# ------------Define weak form for linear elasticity -----------
# Create coordinate component
direction = 'z'
direction_map = {'x': 0, 'y': 1, 'z': 2}
d_idx = direction_map[direction]
# Unit vector in periodicity direction
e_d = ufl.as_vector([float(i == d_idx) for i in range(3)])
# Define weak forms
def epsilon(u):
return 0.5 * (ufl.nabla_grad(u) + ufl.nabla_grad(u).T)
def kappa(u):
i, j = ufl.indices(2)
Aij = u[i]*e_d[j] + e_d[i]*u[j]
A = ufl.as_tensor(Aij, (i,j))
return 0.5 * A
# Fourth-order elasticity tensor C
Id = ufl.Identity(dim)
indices = ufl.indices(4)
def delta_product(i, j, k, l):
return ufl.as_tensor(Id[i, j] * Id[k, l], indices)
i, j, k, l = indices
C = lambda_ * delta_product(i, j, k, l) + mu * (delta_product(i, k, j, l) + delta_product(i, l, k, j))
# Bilinear forms with explicit conjugation (complex mode)
# K matrix
a_K = C[i, j, k, l] * ufl.conj(epsilon(v)[i, j]) * epsilon(u)[k, l] * dx
# L matrix
a_linear = (-C[i, j, k, l] * ufl.conj(kappa(v)[i, j]) * epsilon(u)[k, l] +
C[i, j, k, l] * ufl.conj(epsilon(v)[i, j]) * kappa(u)[k, l]) * dx
# H matrix
a_quadratic = C[i, j, k, l] * ufl.conj(kappa(v)[i, j]) * kappa(u)[k, l] * dx
# Mass matrix
m = rho * ufl.conj(v[i]) * u[i] * dx
# For the standard stiffness
a = a_K
# Set periodicity parameters
direction = 'z' # Periodicity along z-axis
direction_map = {'x': 0, 'y': 1, 'z': 2}
d_idx = direction_map[direction]
# Set basis vector
a_basis = np.zeros(3)
a_basis[d_idx] = L_f # L_f is the length in periodic direction
# Create indicator and relation functions for MPC
indicator = lambda x: periodic_indicator(x, direction=direction)
relation = lambda x: periodic_relation(x, direction=direction, a_basis=a_basis)
#-----------------MPC, assembling matrices and handling rigid body modes -------------------------------------------------------------------------------------
# Create MPC with phase=1 for periodic boundary conditions (no phase shift for the base matrices)
mpc = dolfinx_mpc.MultiPointConstraint(V)
mpc.create_periodic_constraint_geometrical(V, indicator=indicator, relation=relation, bcs=[], scale=1.0)
mpc.finalize()
print("Assembling matrices with periodic boundary conditions (phase=1)...")
# Assemble ALL matrices ONCE with periodic BCs (phase=1)
K0 = dolfinx_mpc.assemble_matrix(form(a), mpc)
K1 = dolfinx_mpc.assemble_matrix(form(a_linear), mpc)
K2 = dolfinx_mpc.assemble_matrix(form(a_quadratic), mpc)
M = dolfinx_mpc.assemble_matrix(form(m), mpc)
K0.assemble()
K1.assemble()
K2.assemble()
M.assemble()
print("Matrix assembly complete!")
rows_K0, cols_K0= K0.getSize()
rows_K1, cols_K1= K1.getSize()
rows_K2, cols_K2= K2.getSize()
rows_M, cols_M= M.getSize()
K0_sub, (idx_set_row, idx_set_row) = extract_sub_matrix(K0, V, mpc=mpc)
K1_sub, (idx_set_row, idx_set_row) = extract_sub_matrix(K1, V, mpc=mpc)
K2_sub, (idx_set_row, idx_set_row) = extract_sub_matrix(K2, V, mpc=mpc)
M_sub, (idx_set_row, idx_set_row) = extract_sub_matrix(M, V, mpc=mpc)
print('Done with extracting dof')
#-------------------Frequency sweep to solve for k-------------------------------------
num_k = 20 # Minimum number of eigenvalues we want
f_name = 'f'
num_f = 20 # Number of frequencies to sweep over
f_values = np.linspace(0, 10000, num_f)
num_f = len(f_values)
dispersion_k_real = []
dispersion_k_imag = []
all_modes_displacements = [] # Store displacement arrays
for idx, f in enumerate(f_values):
print(f"\n{'='*60}")
print(f"Frequency {idx+1}/{len(f_values)}: f = {f:.1f} Hz")
print(f"{'='*60}")
omega = 2 * np.pi * f
omega_sq = omega**2
# Build A0 = K0 - ω²M
A0 = K0_sub.copy()
A0 += -omega_sq * M_sub
A0.assemble()
A2 = K2_sub.copy()
A2 *= -1.0
pep = SLEPc.PEP().create(mesh.comm)
pep.setOperators([A0, K1_sub, A2])
pep.setDimensions(nev=num_k)
k0 = 1
target = 1j * k0
pep.setTarget(target)
pep.setWhichEigenpairs(SLEPc.PEP.Which.TARGET_MAGNITUDE)
st = pep.getST()
st.setType(SLEPc.ST.Type.SINVERT)
ksp = st.getKSP()
ksp.setType("preonly")
pc = ksp.getPC()
pc.setType("lu")
pc.setFactorSolverType("mumps")
pep.setFromOptions()
pep.solve()
# Get converged eigenvalues
nconv = pep.getConverged()
print(f"Number of converged eigenvalues for f = {f} Hz is {nconv}")
# Storage for this frequency (initialize empty lists)
Lambda_values = []
Lambda_values_real = []
Lambda_values_imag = []
modes_at_f = [] # Store mode indices
for i in range(nconv):
# Create vector to hold eigenvector
vr = A0.createVecRight()
Lambda = pep.getEigenpair(i, vr)
Lambda_values.append(Lambda)
Lambda_values_real.append(Lambda.real)
Lambda_values_imag.append(Lambda.imag)
k_real = Lambda.imag
k_imag = -Lambda.real
print(f"Mode {i}: Lambda = {Lambda.real:.6f} + {Lambda.imag:.6f}i "
f"k = {k_real:.6f} + {k_imag:.6f}i")
# Create full vector in function space
eh = Function(V)
full_vec = eh.x.petsc_vec
full_vec.set(0.0)
full_vec.setValues(idx_set_row, vr.getArray())
full_vec.assemble()
dim = V.dofmap.index_map_bs
mpc.backsubstitution(eh)
u_complex = eh.x.array.copy()
u_complex_reshaped = u_complex.reshape(-1, dim)
# Real and imaginary parts
u_real = np.real(u_complex_reshaped)
u_imag = np.imag(u_complex_reshaped)
u_magnitude = np.abs(u_complex_reshaped)
modes_at_f.append({
'mode_index': i,
'k_real': k_real,
'k_imag': k_imag,
'displacement_real': u_real,
'displacement_imag': u_imag,
'displacement_magnitude': u_magnitude,
'displacement_complex': u_complex_reshaped,
})
# Append to dispersion data
dispersion_k_real.append(Lambda_values_imag)
minus_Lambda_values_real = []
for i in range(len(Lambda_values_real)):
minus_Lambda_values_real.append(-Lambda_values_real[i])
dispersion_k_imag.append(minus_Lambda_values_real)
# Append mode shapes
all_modes_displacements.append(modes_at_f)
# ----------------Save frequencies and k-values---------------------------------
with open(os.path.join('fenicsx_dump_inverse_cyl_testingJune25.pkl'), 'wb') as f:
pickle.dump([
f_name, # 'f'
num_f, # number of frequencies
f_values, # frequency values
dispersion_k_real, # Re(k) for each frequency
dispersion_k_imag, # Im(k) for each frequency,
all_modes_displacements # mode shapes for later plotting
], f)
print("\nData saved successfully!")
# Minimum Working Example - Interactive plotting with point clicking
import numpy as np
import matplotlib.pyplot as plt
import pyvista as pv
import dolfinx
from dolfinx import mesh, fem, plot
from basix.ufl import element
from mpi4py import MPI
import gmsh
import dolfinx.mesh as msh
from dolfinx.io import gmsh as gmshio
import pickle
import os
#--------------Mesh import function (same as solver)--------------------------------
def import_CAD_to_FEniCSx(input_file = "object_new_broken_cubic_case", output_file = "from_comsol", lc = 0.25):
gmsh.initialize()
comm = MPI.COMM_WORLD
model_rank = 0
model = gmsh.model()
gmsh.model.add(output_file)
gmsh.model.occ.importShapes(f"{input_file}.step")
gmsh.model.occ.synchronize()
volumes = gmsh.model.getEntities(dim=3)
volume_tags = [v[1] for v in volumes]
if len(volume_tags) > 1:
gmsh.model.occ.fragment([(3, tag) for tag in volume_tags], [])
gmsh.model.occ.synchronize()
volumes = gmsh.model.getEntities(dim=3)
gmsh.model.occ.synchronize()
volumes = gmsh.model.getEntities(dim=3)
if len(volumes) == 1:
gmsh.model.addPhysicalGroup(3, [v[1] for v in volumes], tag=1)
else:
listvols1 = [1, 2, 3, 4, 5, 6, 7, 9]
gmsh.model.addPhysicalGroup(3, listvols1, tag=101)
listvols2 = [8, 10]
gmsh.model.addPhysicalGroup(3, listvols2, tag=201)
surfaces = gmsh.model.getEntities(dim=2)
listsurf1 = [4]
listsurf2 = [5]
listsurf3 = [1, 2, 3, 6]
gmsh.model.addPhysicalGroup(2, listsurf1, tag=1001)
gmsh.model.addPhysicalGroup(2, listsurf2, tag=2001)
gmsh.model.addPhysicalGroup(2, listsurf3, tag=3001)
gmsh.option.setNumber("Mesh.ElementOrder", 2)
gmsh.option.setNumber("Mesh.HighOrderOptimize", 2)
gmsh.option.setNumber("Mesh.CharacteristicLengthMax", lc)
gmsh.model.mesh.generate(3)
gmsh.write(f"{output_file}.msh")
mesh_data = gmshio.model_to_mesh(model, comm, model_rank)
final_mesh = mesh_data.mesh
facet_tags = mesh_data.facet_tags
cell_tags = mesh_data.cell_tags
gmsh.option.setNumber("Geometry.Surfaces", 1)
gmsh.option.setNumber("Geometry.SurfaceLabels", 1)
gmsh.fltk.run()
gmsh.finalize()
mesh_info = [final_mesh, cell_tags, facet_tags]
return mesh_info, None
#--------------Create mesh--------------------------------
step_filename, mesh_name = 'Test_periodic_rod_geo_r025', 'mesh_rod'
lc = 10
mesh_info, submesh_info = import_CAD_to_FEniCSx(step_filename, mesh_name, lc)
mesh, cell_tags, facet_tags = mesh_info
dim = mesh.geometry.dim
mesh.geometry.x[:] *= 0.001
# Get length parameter L_f for scaling
z_coords = mesh.geometry.x[:, 2]
L_f = z_coords.max() - z_coords.min()
k_scaling = L_f / np.pi
#--------------Load data--------------------------------
print("Loading data from fenicsx_dump_inverse_cyl_testingJune25.pkl...")
with open('fenicsx_dump_inverse_cyl_testingJune25.pkl', 'rb') as f:
obj = pickle.load(f)
f_name = obj[0]
num_f = obj[1]
f_values = obj[2]
dispersion_k_real = obj[3]
dispersion_k_imag = obj[4]
all_modes_displacements = obj[5]
print(f"Loaded {len(f_values)} frequencies")
print(f"Frequency range: {f_values[0]:.1f} - {f_values[-1]:.1f} Hz")
# Scale k values
print("Scaling k values...")
for i in range(len(dispersion_k_real)):
for j in range(len(dispersion_k_real[i])):
dispersion_k_real[i][j] = dispersion_k_real[i][j] * k_scaling
dispersion_k_imag[i][j] = dispersion_k_imag[i][j] * k_scaling
#--------------Interactive plot with point clicking--------------------------------
print("\n" + "="*50)
print("INTERACTIVE MODE SHAPE PICKER")
print("Click on points in the dispersion curve to plot mode shapes")
print("="*50)
# Collect data for plotting
all_k = []
all_f = []
points_data = []
for i, f in enumerate(f_values):
for j, k in enumerate(dispersion_k_real[i]):
if k > 0: # Only positive k
all_k.append(k)
all_f.append(f)
points_data.append({
'k': k,
'f': f,
'f_idx': i,
'mode_j': j,
'mode_index': all_modes_displacements[i][j].get('mode_index', j) if j < len(all_modes_displacements[i]) else j
})
# Create interactive plot
fig, ax = plt.subplots(figsize=(10, 8))
sc = ax.scatter(all_k, all_f, c='blue', s=20, picker=True)
ax.set_xlabel(r'Re$(k)$ [$\pi$/a]')
ax.set_ylabel(r'$f$ [Hz]')
ax.set_xlim(0, 1)
ax.set_ylim(0, 70000)
ax.grid(True)
# Store clicked points
clicked_points = []
def on_pick(event):
ind = event.ind[0]
point = points_data[ind]
print(f"\n{'='*50}")
print(f"CLICKED POINT {len(clicked_points)+1}:")
print(f" f = {point['f']:.2f} Hz")
print(f" k = {point['k']:.6f} (π/a)")
print(f" f_idx = {point['f_idx']}")
print(f" mode_j = {point['mode_j']}")
print(f" mode_index = {point['mode_index']}")
print(f"{'='*50}")
clicked_points.append(point)
fig.canvas.mpl_connect('pick_event', on_pick)
plt.show()
#--------------Plot mode shapes for clicked points--------------------------------
print(f"\n{'='*50}")
print(f"Plotting mode shapes for {len(clicked_points)} clicked points...")
print(f"{'='*50}")
VISUAL_SCALE = 10
for idx, point in enumerate(clicked_points):
print(f"\nPlotting {idx+1}/{len(clicked_points)}: f = {point['f']:.2f} Hz, k = {point['k']:.6f}")
f_idx = point['f_idx']
f_actual = f_values[f_idx]
modes_at_f = all_modes_displacements[f_idx]
# Find mode by mode_index
mode_found = None
for mode in modes_at_f:
if mode.get('mode_index') == point['mode_index']:
mode_found = mode
break
# Try using mode_j as fallback
if mode_found is None:
print(f" Trying mode_j = {point['mode_j']} instead...")
for mode in modes_at_f:
if mode.get('mode_index') == point['mode_j']:
mode_found = mode
print(f" Found mode with mode_index = {point['mode_j']}")
break
if mode_found is None:
print(f" No mode found. Skipping...")
continue
u_plot = mode_found['displacement_real']
k_real_scaled = mode_found['k_real'] * k_scaling
k_imag_scaled = mode_found['k_imag'] * k_scaling
# Create grid and attach displacement
topology, cell_types, geometry = plot.vtk_mesh(mesh)
grid = pv.UnstructuredGrid(topology, cell_types, geometry)
grid["u"] = u_plot
# Calculate displacement magnitude
u_mag = np.linalg.norm(u_plot, axis=1)
max_u = np.max(u_mag)
# Normalize for colorbar
if max_u > 0:
u_mag_normalized = u_mag / max_u
else:
u_mag_normalized = u_mag
grid["Normalized |u|"] = u_mag_normalized
# Warp the mesh
warped = grid.warp_by_vector("u", factor=VISUAL_SCALE)
warped["Normalized |u|"] = u_mag_normalized
# Create plotter
p = pv.Plotter(window_size=(1000, 800))
p.set_background('white')
# Show undeformed mesh as wireframe
p.add_mesh(grid, style="wireframe", color="lightgray", line_width=1)
# Show deformed mesh colored by normalized displacement
p.add_mesh(warped, scalars="Normalized |u|", cmap="plasma", show_scalar_bar=True,
scalar_bar_args={
'title': 'Normalized |u|',
'n_labels': 5,
'fmt': '%.2f'
})
# Add text info
p.add_text(f"Point {idx+1}: f = {f_actual:.1f} Hz\nk = {k_real_scaled:.4f} + {k_imag_scaled:.4f}i\nScale: {VISUAL_SCALE:.3f}x",
font_size=12, position='upper_left', color='black')
p.show_axes()
p.view_isometric()
p.show()
print("\nDone! All mode shapes plotted.")
I would be very grateful for any advice on how to solve this problem!
/Nadine