Harmonic (frequency-response) analysis#
harmonic_solve() and the underlying
femorph_solver.solvers.harmonic.solve_harmonic_modal() evaluate the
steady-state frequency-response function
at every excitation frequency in a user-supplied list. The solve is
modal-superposition: the load and the response are projected onto
the lowest n_modes mass-orthonormal eigenmodes of the undamped
system, and a per-mode complex receptance is back-substituted.
Algorithm#
The undamped eigenproblem \(K \varphi_{n} = \omega_{n}^{2} M \varphi_{n}\) is solved once (via Modal analysis) and the eigenvectors are normalised so \(\Phi^{T} M \Phi = I\). The modal response coordinate at each excitation frequency \(\omega\) is
and the physical response is the modal sum \(u(\omega) = \Phi\,\eta(\omega)\). Every excitation frequency re-uses the same eigenpairs — the cost of a 200-point sweep is dominated by the one-time modal solve, not the per-frequency back-substitution.
API#
The shortest path lives on Model. Build the
model, fix the BCs, stage the load, then call
harmonic_solve():
import numpy as np
import femorph_solver as fs
from femorph_solver import ELEMENTS
m = fs.Model.from_grid(grid)
m.assign(ELEMENTS.HEX8(integration="enhanced_strain"),
{"EX": 2.0e11, "PRXY": 0.30, "DENS": 7850.0})
m.fix(where=clamped_face, dof=["UX", "UY", "UZ"])
m.apply_force(tip_node, fz=-1.0)
res = m.harmonic_solve(
frequencies=np.linspace(0.0, 1000.0, 200), # Hz
n_modes=20,
modal_damping_ratio=0.02,
)
res.frequency # (n_freq,) Hz
res.displacement # (n_freq, N) complex
res.frf_at(dof_idx) # (n_freq,) complex receptance at one DOF
res.modal # the underlying ModalResult that spanned the basis
For low-level work directly on the K / M matrices, the same solver is
exposed as femorph_solver.solvers.harmonic.solve_harmonic_modal() and
takes the prescribed-DOF dictionary explicitly.
Damping#
Two damping models are accepted; both can be combined and their contributions add.
Modal damping ratio#
A scalar (uniform across modes) or an array of length n_modes:
res = m.harmonic_solve(frequencies=freq, n_modes=20,
modal_damping_ratio=0.02)
res = m.harmonic_solve(frequencies=freq, n_modes=4,
modal_damping_ratio=[0.01, 0.02, 0.03, 0.05])
Rayleigh damping#
Pass an (α, β) pair so the per-mode ratio becomes
\(\zeta_{n} = \alpha/(2\,\omega_{n}) + \beta\,\omega_{n}/2\):
res = m.harmonic_solve(frequencies=freq, n_modes=20,
rayleigh=(0.05, 1e-4))
Pure rigid-body modes (\(\omega_{n} = 0\)) drop the
\(\alpha/(2\omega_{n})\) term — singular by construction — and
keep whatever modal_damping_ratio was supplied at that index.
Convergence and basis size#
The truncation error at frequency \(\omega\) is governed by every mode whose \(\omega_{n}\) exceeds \(\omega\). In practice:
Retain at least the first mode whose \(f_{n}\) exceeds the largest excitation frequency in your sweep.
Add another factor-of-two cushion when the response near a higher pole matters (truncation manifests as a soft underestimate of the off-resonance amplitude, not a hard miss).
Static-correction terms are not yet implemented; below the first retained pole the truncation residual is bounded but not zero.
Reusing a precomputed modal basis#
Parametric forcing sweeps are cheap when the eigenpairs don’t change.
Run the modal solve once, then thread the same ModalResult
into successive harmonic calls:
modal = m.modal_solve(n_modes=20)
for case in load_cases:
m.set_load(case.F)
res = m.harmonic_solve(
frequencies=case.frequencies,
modal_result=modal,
modal_damping_ratio=case.zeta,
)
case.dump(res)
Persistence#
HarmonicResult.save() writes the complex per-frequency response
to a single .pv file alongside the underlying frequency grid and
applied damping ratios. The on-disk format splits each per-frequency
complex array into separate displacement_re_NNNN and
displacement_im_NNNN point-data entries since pyvista’s field
arrays don’t carry complex dtype natively; reading the file back via
femorph_solver.result.read() returns a
HarmonicResultDisk with a
displacement() accessor
that recombines the real and imaginary halves.
See also#
Modal analysis — the underlying eigenproblem.
Transient analysis — Newmark-β time-domain integration.
Solvers — which sparse backend factors the inner shift-invert when the modal solve runs at large size.