from matplotlib import pyplot as plt
Tension modulated string
This notebook contains an implementation of the tension modulated string model. Based on this paper1 and the code included with this paper2.
Step 1:
Start with the non-linear tension modulated string model. The model is defined by the following equations:
\[ \begin{align} \rho A \frac{\partial^2 y}{\partial t^2} + EI \frac{\partial^4 y}{\partial x^4} - T_{NL}(y) \frac{\partial^2 y}{\partial x^2} + d_{1} \frac{\partial y}{\partial t} + d_{3} \frac{\partial^3 y}{\partial t^3} = f(x,t) \end{align} \]
where \(T_{NL}\) is the non-linear tension function truncated at the third order:
\[ \begin{aligned} T_{N L}(y) & =T_0+T_1(y(x, t)) \\ & =T_0+\frac{E A}{2 l_0} \int_0^{l_0} y^2(x, t) d x . \end{aligned} \]
Step 2:
Perform a transformation of the PDE with respect to the spatial variable \(x\). We can do this using the transformation kernel \(K(x, \beta_{\mu})\).
\[ \begin{aligned} K\left(x, \beta_\mu\right) & =\sin \left(\frac{\mu \pi}{l_0} x\right) \\ \beta_\mu^4 & =E I\left(\frac{\mu \pi}{l_0}\right)^4+T_0\left(\frac{\mu \pi}{l_0}\right)^2 \end{aligned} \]
where \(\beta_\mu\) is the frequency variable. We see that the \(K(x, \beta_{\mu})\) is a function that generates the fourier sine series of the spatial variable \(x\).
The spatial tranformation of the non-linear term \(T_{1}\) is given as:
\[ \mathcal{T}\left\{T_1\right\} = \bar{b}(\mu,y,\bar{y}) \]
We now can write the transformed PDE as:
\[ \boxed{ \begin{aligned} \rho A \ddot{\bar{y}}(\mu, t) + \left(d_3 \eta_\mu^2+d_1\right) \dot{\bar{y}}(\mu, t) + \beta_\mu^4 \bar{y}(\mu, t)-\bar{b}(\mu, y, \bar{y}) = 0 \end{aligned} } \]
Where \(\eta_\mu=-\left(\mu \pi / l_0\right)^2\). The initial conditions are given as:
\[ \begin{aligned} \bar{y}_0(\mu) &= \mathcal{T}{\left\{y_0(x)\right\}} \\ \dot{\bar{y}}_0(\mu) &= \mathcal{T}{\left\{\dot{y}_0(x)\right\}} = 0 \end{aligned} \]
Step 3:
Discretize the non-linear term \(\bar{b}(\mu, y, \bar{y})\)
\[ \begin{aligned} \bar{b}^d(\mu) & =\eta_\mu^2 \frac{E A \bar{y}(\mu)}{2 l_0} \int_0^{l_0}\left[\sum_{\nu=1}^M \frac{1}{N_\nu} \bar{y}^d(\nu) K(\xi, \nu)\right]^{\prime 2} d x \\ & =\eta_\mu^2 \frac{E A \pi^2 \bar{y}(\mu)}{l_0^4} \sum_{\nu=1}^M \nu^2\left(\bar{y}^d(\nu)\right)^2 \end{aligned} \]
Step 4
Write as a system of first order ODEs:
\[ \begin{aligned} \dot{\bar{y}}(\mu, t) &= \bar{v}(\mu, t) \\ \dot{\bar{v}}(\mu, t) &= \frac{-\left(d_3 \eta_\mu^2+d_1\right)}{\rho A} \bar{v}(\mu, t) - \frac{\beta_\mu^4}{\rho A} \bar{y}(\mu, t) + \frac{\bar{b}(\mu, y, \bar{y})}{\rho A} \end{aligned} \]
This is a system of first-order ODEs. In matrix form, we can write this as:
\[ \boxed{ \begin{aligned} \mathbf{\dot{\bar{y}}} &= \mathbf{\bar{v}} \\ \mathbf{\dot{\bar{v}}} &= -\mathbf{M_v} \mathbf{\bar{v}} - \mathbf{M_y} \mathbf{\bar{y}} + \mathbf{\bar{b}} \end{aligned} } \]
The Matrices \(\mathbf{M_v}\), \(\mathbf{M_y}\) are diagonal matrices whose order is equal to the number of modes \(M\).
\[ \begin{aligned} \mathbf{M_v} &= \text{diag}\left(\frac{d_3 \eta_\mu^2+d_1}{\rho A}\right) \\ \mathbf{M_y} &= \text{diag}\left(\frac{\beta_\mu^4}{\rho A}\right) \end{aligned} \]
The vector \(\mathbf{b}\) contains the non-linear terms and is given as:
\[ \mathbf{\Eta} = \text{diag}\left(\eta_\mu^2\right) \\ T_{1} = \frac{E A \pi^2}{l_0^4} \mathbf{\bar{y}}(\mu)^T \cdot \Eta \cdot \mathbf{\bar{y}} \\ \mathbf{\bar{b}} = T_{1} \text{diag}(\frac{\eta_\mu^2}{\rho A}) \mathbf{\bar{y}} \]
::: {#cell-7 .cell 0=‘e’ 1=‘x’ 2=‘p’ 3=‘o’ 4=‘r’ 5=‘t’}
import numpy as np
from scipy.integrate import solve_ivp
from physmodjax.utils.ftm import (
forward_STL,
inverse_STL,
eigenvalues_from_pde,
string_eigenvalues_sqrt,
string_eigenfunctions,
StringParameters,
)from numba import jit, njit
from functools import partial
from einops import rearrange
import diffrax as drx
import jax.numpy as jnp
from jax.typing import ArrayLike
:::
The parameters of the model are:
- \(N\): number of points in the string
- \(T\): total time of simulation
- \(f_s\): sampling rate
- \(l\): length of string
- \(A\): string cross-sectional area
- \(E\): Young’s modulus
- \(I\): string moment of inertia
- \(\rho\): density
- \(Ts_0\): initial tension
- \(d_{1}\): string frequency independent damping
- \(d_{3}\): string frequency dependent damping
- \(M\): number of modes to include in the modal expansion
::: {#cell-10 .cell 0=‘e’ 1=‘x’ 2=‘p’ 3=‘o’ 4=‘r’ 5=‘t’}
@njit
def nonlinear_string_func(
# time
t, # state vector
state, # number of modes
n_max_modes, # use nonlinear wave equation
use_nonlinear, # precalculated constant
pre, # H matrix
H, # H_1 matrix
H_1, # M_v matrix
M_v, # M_y matrix
M_y, -> np.ndarray: # state at timestep t and position x (u(x, t))
) # unpack the state vector
= state[:n_max_modes] # displacement in modal coordinates
y = state[n_max_modes:] # velocity in modal coordinates
v
# add axis for calculation
= y.reshape(-1, 1)
y
# calculate the $\bar{b}$ vector
if use_nonlinear:
= pre * y.T @ H @ y
t_1 = t_1 * H_1 @ y
b
# update the state vector
= v
v = -M_v @ v - M_y @ y.reshape(-1)
dv_dt
if use_nonlinear:
-= b.reshape(-1)
dv_dt
# return the state derivatives
return np.concatenate((v, dv_dt))
@njit
def nl_harmonic_oscillator(
# time
t, # state vector (state_vars,)
state, # number of modes
n_max_modes, # use nonlinear wave equation
use_nonlinear, # positive dampings
c, # stiffness
k, # mu integers
mu, # precalculated scalar constant times squared wavenumbers
gamma, -> np.ndarray: # state at timestep t and position x (u(x, t))
) # unpack the state vector
= state[:n_max_modes] # displacement in modal coordinates
y = state[n_max_modes:] # velocity in modal coordinates
v
# update the state vector
= v
dy_dt = -c * v - k * y
dv_dt
if use_nonlinear:
# calculate the $\bar{b}$ vector and subtract it from the acceleration
= np.sum(mu**2 * y**2)
energy = gamma * energy * y
b -= b
dv_dt
# return the state derivatives
return np.concatenate((dy_dt, dv_dt))
def nl_harmonic_oscillator_diffrax(
# time
t, # state vector (state_vars,)
state, # additional arguments
args, # number of modes
n_max_modes, # use nonlinear wave equation
use_nonlinear, # positive dampings
c, # stiffness
k, # mu integers
mu, # precalculated scalar constant times squared wavenumbers
gamma, -> ArrayLike: # state at timestep t and position x (u(x, t))
) # unpack the state vector
= state[:n_max_modes] # displacement in modal coordinates
y = state[n_max_modes:] # velocity in modal coordinates
v
# update the state vector
= v
dy_dt = -c * v - k * y
dv_dt
if use_nonlinear:
# calculate the $\bar{b}$ vector and subtract it from the acceleration
= jnp.sum(mu**2 * y**2)
energy = gamma * energy * y
b -= b
dv_dt
# return the state derivatives
return jnp.concatenate((dy_dt, dv_dt))
:::
class Wave1dSolverTensionModulated:
"""
Tension modulated wave equation solver.
This solver is based on the paper "Sound Synthesis With Tension Modulated Nonlinearities"
and the accompanying code to the paper "Physical Modeling using Recurrent Neural Networks with Fast Convolutional Layers".
"""
# fmt: off
def __init__(
self,
int = 48000, # 1/s Temporal sampling frequency
sampling_rate: float = 0.5, # s Duration of the simulation
final_time: int = 101, # pts/m Spatial sampling grid
n_gridpoints: float = 1.00, # m Length at rest
length: float = 0.19634e-6, # m**2 Cross section area
A: float = 0.02454e-12, # m**4 Moment of intertia
I: float = 7800, # kg/m**3 Density
rho: int = 190e9, # Pa Young's modulus
E: float = 4e-3, # kg/(ms) Frequency independent loss
d1: float = 6e-5, # kg m/s Frequency dependent loss
d3: float = 150, # N Tension
Ts0: int = 50, # Number of modal expansion terms
n_max_modes: bool = True, # Use nonlinear wave equation
use_nonlinear: str = "DOP853", # Integration method
method: float = 1e-12, # Relative tolerance
rtol: float = 1e-14, # Absolute tolerance
atol:
):# fmt: on
# Attributes intrinsic to the Wave Equation PDE
self.pde_num_variables = 1
self.pde_num_spatial_dimensions = 1
self.pde_order_time_derivatives = 2
# Attributes of the simulation
self.sampling_rate = sampling_rate
self.final_time = final_time
self.n_gridpoints = n_gridpoints
self.length = length
self.use_nonlinear = use_nonlinear
self.method = method
self.rtol = rtol
self.atol = atol
self.A = A
self.I = I
self.rho = rho
self.E = E
self.d1 = d1
self.d3 = d3
self.Ts0 = Ts0
# For Dirichlet BC, the number of possible modes is equal the the number of points in the interior of the domain
if n_max_modes > n_gridpoints - 2:
self.n_max_modes = n_gridpoints - 2
print(f"n_max_modes too high, setting to {self.n_max_modes}")
else:
self.n_max_modes = n_max_modes
# spatial grid
self.grid = np.linspace(0, self.length, self.n_gridpoints)
self.dx = self.grid[1] - self.grid[0] # m spatial sampling interval
# temporal grid use arange to make sure that the timestep corresponds exactly to the sampling frequency
self.dt = 1 / self.sampling_rate # s temporal sampling interval
self.timesteps = np.arange(0, self.final_time, self.dt)
self.mu = np.ascontiguousarray(np.arange(1, self.n_max_modes + 1, dtype=np.float64))
self.wavenumbers = self.mu * np.pi / self.length
self.modes = np.sin(np.outer(self.wavenumbers, self.grid))
# precalculate the variables
= self.wavenumbers
eta_mu = self.E * self.I * eta_mu**4 + self.Ts0 * eta_mu**2
beta_mu
# calculate the matrices
self.H = np.ascontiguousarray(np.diag(self.mu**2))
self.H_1 = np.ascontiguousarray(np.diag(eta_mu**2)) / (rho * A)
self.M_v = np.ascontiguousarray(np.diag(d1 + d3 * eta_mu**2)) / (rho * A)
self.M_y = np.ascontiguousarray(np.diag(beta_mu)) / (rho * A)
print(f"dx: {self.dx} in meters")
print(f"dt: {self.dt} in seconds")
print(f"number of points (n_gridpoints): {self.grid.shape}")
print(f"time in samples (nt): {self.timesteps.shape}")
def solve_modal(
self,
bar_u0,
bar_v0,-> tuple[np.ndarray, np.ndarray]:
)
# solve the wave equation in modal coordinates
= solve_ivp(
sol =partial(
fun
nonlinear_string_func,=self.n_max_modes,
n_max_modes=self.use_nonlinear,
use_nonlinear=self.E * self.A * np.pi**2 / self.length**4,
pre=self.H,
H=self.H_1,
H_1=self.M_v,
M_v=self.M_y,
M_y
),=[0, self.final_time],
t_span=np.ascontiguousarray(np.concatenate([bar_u0, bar_v0], axis=0)),
y0=self.timesteps,
t_eval=self.method,
method=self.rtol,
rtol=self.atol,
atol
)
# unpack the solution
= sol.y[: self.n_max_modes]
bar_u = sol.y[self.n_max_modes :]
bar_v return sol.t, bar_u, bar_v
def solve(
self,
= None, # initial displacement (default: None)
u0: np.ndarray = None, # initial velocity (default: None)
v0: np.ndarray -> tuple[np.ndarray, np.ndarray, np.ndarray]:
) """
Solve the wave equation with the given initial conditions.
Parameters:
----------
u0: np.ndarray
Initial displacement (default: None)
v0: np.ndarray
Initial velocity (default: None)
Returns:
----------
t: np.ndarray
Time steps
u: np.ndarray
Displacement at each time step with shape (n_timesteps, n_gridpoints)
v: np.ndarray
Velocity at each time step with shape (n_timesteps, n_gridpoints)
"""
# set the initial conditions if not given
= u0 if u0 is not None else np.zeros_like(self.grid)
u0 = v0 if v0 is not None else np.zeros_like(self.grid)
v0 # Enforce the dirichlet boundary conditions
0] = 0
u0[-1] = 0
u0[0] = 0
v0[-1] = 0
v0[# transform the initial conditions to modal coordinates
# This is the same projection that we do in the modal solver for the ideal string,
# assuming dirichlet boundary conditions (makes it a bit wasteful as we only need the interior)
# but, this operation is only once, then we operate in modal coordinates anyway
= forward_STL(self.modes, u0, self.dx)
bar_u0 = forward_STL(self.modes, v0, self.dx)
bar_v0
# solve the wave equation in modal coordinates
= self.solve_modal(
t, bar_u, bar_v
bar_u0,
bar_v0,
)
# transform back to the physical domain
= inverse_STL(self.modes, bar_u, self.length)
u = inverse_STL(self.modes, bar_v, self.length)
v
return (
t,"g t -> t g"),
rearrange(u, "g t -> t g"),
rearrange(v, )
::: {#cell-12 .cell 0=‘e’ 1=‘x’ 2=‘p’ 3=‘o’ 4=‘r’ 5=‘t’}
class Wave1DSolverNL:
# fmt: off
def __init__(
self,
# Physical parameters of the string
string_parameters: StringParameters, int = 48000, # 1/s Temporal sampling frequency
sampling_rate: float = 0.5, # s Duration of the simulation
final_time: int = 101, # pts/m Spatial sampling grid
n_gridpoints: int = 99, # Number of modal expansion terms
n_max_modes: bool = True, # Use nonlinear wave equation
use_nonlinear: str = "DOP853", # Integration method
method: float = 1e-12, # Relative tolerance
rtol: float = 1e-14, # Absolute tolerance
atol: bool = False, # Use diffrax for integration
use_diffrax:
):# fmt: on
self.sampling_rate = sampling_rate
self.final_time = final_time
self.n_gridpoints = n_gridpoints
self.n_max_modes = n_max_modes
self.use_nonlinear = use_nonlinear
self.method = method
self.rtol = rtol
self.atol = atol
self.string_parameters = string_parameters
self.use_diffrax = use_diffrax
self.wavenumbers = string_eigenvalues_sqrt(
n_max_modes,
string_parameters.length,
)self.grid = np.linspace(0, string_parameters.length, n_gridpoints)
self.dx = self.grid[1] - self.grid[0]
self.K = string_eigenfunctions(
=self.wavenumbers,
wavenumbers=self.grid,
grid
)self.mu = np.arange(1, self.n_max_modes + 1, dtype=np.float64)
self.eigvals = eigenvalues_from_pde(string_parameters, self.wavenumbers)
self.c = -2 * np.real(self.eigvals)
self.k = self.eigvals.imag**2 + self.eigvals.real**2
# temporal grid use arange to make sure that the timestep corresponds exactly to the sampling frequency
self.dt = 1 / self.sampling_rate # s temporal sampling interval
self.timesteps = np.arange(0, self.final_time, self.dt)
def solve_modal(
self,
bar_u0,
bar_v0,-> tuple[np.ndarray, np.ndarray]:
)
if not self.use_diffrax:
# solve the wave equation in modal coordinates
= solve_ivp(
sol =partial(
fun
nl_harmonic_oscillator,=self.n_max_modes,
n_max_modes=self.use_nonlinear,
use_nonlinear=self.mu,
mu=self.wavenumbers**2 \
gamma/ self.string_parameters.area_density \
* self.string_parameters.E \
* self.string_parameters.A \
* np.pi**2 \
/ self.string_parameters.length**4,
=self.c,
c=self.k,
k
),=[0, self.final_time],
t_span=np.concatenate([bar_u0, bar_v0], axis=0),
y0=self.timesteps,
t_eval=self.method,
method=self.rtol,
rtol=self.atol,
atol
)
# unpack the solution
= sol.y[: self.n_max_modes]
bar_u = sol.y[self.n_max_modes :]
bar_v = sol.t
t else:
= drx.ODETerm(
term
partial(
nl_harmonic_oscillator_diffrax,=self.n_max_modes,
n_max_modes=self.use_nonlinear,
use_nonlinear=self.mu,
mu=self.wavenumbers**2 \
gamma/ self.string_parameters.area_density \
* self.string_parameters.E \
* self.string_parameters.A \
* np.pi**2 \
/ self.string_parameters.length**4,
=self.c,
c=self.k,
k
)
)
= drx.Dopri8() # RK45
solver = drx.diffeqsolve(
sol: drx.Solution
term,
solver,=0.0,
t0=self.dt,
dt0=self.final_time,
t1=jnp.concatenate([bar_u0, bar_v0], axis=0),
y0=drx.SaveAt(ts=self.timesteps),
saveat=self.sampling_rate * 2,
max_steps
)
# unpack the solution
= sol.ys[:, : self.n_max_modes].T # (n_modes, n_timesteps)
bar_u = sol.ys[:, self.n_max_modes :].T # (n_modes, n_timesteps)
bar_v = sol.ts
t
return t, bar_u, bar_v
def solve(
self,
= None, # initial displacement (default: None)
u0: np.ndarray = None, # initial velocity (default: None)
v0: np.ndarray -> tuple[np.ndarray, np.ndarray, np.ndarray]:
) # set the initial conditions if not given
= u0 if u0 is not None else np.zeros_like(self.grid)
u0 = v0 if v0 is not None else np.zeros_like(self.grid)
v0
# enforce the dirichlet boundary conditions
# TODO: this should be in the IC itself
0] = 0
u0[-1] = 0
u0[0] = 0
v0[-1] = 0
v0[
# transform the initial conditions to modal coordinates
= forward_STL(self.K, u0, self.dx)
bar_u0 = forward_STL(self.K, v0, self.dx)
bar_v0
= self.solve_modal(
t, bar_u, bar_v
bar_u0,
bar_v0,
)
# transform back to the physical domain
= inverse_STL(self.K, bar_u, self.string_parameters.length)
u = inverse_STL(self.K, bar_v, self.string_parameters.length)
v
return (
t,"g t -> t g"),
rearrange(u, "g t -> t g"),
rearrange(v, )
:::
Test the solver
from physmodjax.solver.generator import (
Gaussian,
generate_initial_condition, )
= StringParameters()
string_parameters = 101
n_gridpoints = 99
n_max_modes = 96000
sr = 0.1
final_time = True
use_nonlinear
# initial conditions
= 0.02
ic_max_amplitude = generate_initial_condition(
u0, v0 =np.random.default_rng(354),
rng=Gaussian(num_points=n_gridpoints),
generator=ic_max_amplitude,
ic_max_amplitude=False,
ic_amplitude_random )
from timeit import default_timer as timer
= timer()
start = Wave1dSolverTensionModulated(
solver **string_parameters.asdict(),
=sr,
sampling_rate=final_time,
final_time=n_gridpoints,
n_gridpoints=n_max_modes,
n_max_modes=use_nonlinear,
use_nonlinear
)
= solver.solve(u0, v0)
t, u, v = timer()
end print(f"Time elapsed: {end - start}")
= timer()
start = Wave1DSolverNL(
solver
string_parameters,=sr,
sampling_rate=final_time,
final_time=n_gridpoints,
n_gridpoints=n_max_modes,
n_max_modes=use_nonlinear,
use_nonlinear
)= solver.solve(u0, v0)
t, u2, v2 = timer()
end print(f"Time elapsed: {end - start}")
= timer()
start = Wave1DSolverNL(
solver
string_parameters,=sr,
sampling_rate=final_time,
final_time=n_gridpoints,
n_gridpoints=n_max_modes,
n_max_modes=use_nonlinear,
use_nonlinear=True,
use_diffrax
)= solver.solve(u0, v0)
t, u2, v2 = timer()
end print(f"Time elapsed: {end - start}")
assert np.allclose(u, u2, atol=1e-5, rtol=1e-5)
assert np.allclose(
=1e-1, rtol=1e-1
v, v2, atol# we need to relax the tolerance for the velocity for diffrax )
100, 27], label="u")
plt.plot(v[:100, 27], label="u2")
plt.plot(v2[:# assert np.allclose(u, u2)
# print(f"u shape: {u.shape}")
= 7000
n_samples_plot = plt.subplots(1, 2, figsize=(5, 6))
fig, ax 0].pcolormesh(solver.grid, t[-n_samples_plot:], u[-n_samples_plot:, :])
ax[0].set_title("u")
ax[0].set_xlabel("x")
ax[0].set_ylabel("t")
ax[1].pcolormesh(solver.grid, t[-n_samples_plot:], v[-n_samples_plot:, :])
ax[1].set_title("v")
ax[1].set_xlabel("x")
ax[1].set_ylabel("t")
ax[
fig.tight_layout()
plt.show()# plot the solution at the endpoints
=(10, 5))
plt.figure(figsize0], label="left")
plt.plot(t, u[:, -1], label="right", alpha=0.5)
plt.plot(t, u[:,
plt.legend()
plt.show()
# Plot FFT of the solution for a point on the string, in db scale
=(10, 5))
plt.figure(figsize= np.fft.rfftfreq(u.shape[-1], d=solver.dt)
freq 20 * np.log10(np.abs(np.fft.rfft(u[17, :]))), label="fft")
plt.plot(freq, # plt.figure(figsize=(10, 5))
# freq = np.fft.rfftfreq(u.shape[0], d=solver.dt)
# plt.plot(freq, np.abs(np.fft.rfft(u[:, 17])), label="fft")
# plt.xlim(3000, 8000)
100, 5000)
plt.xlim(
plt.legend()
plt.show()
# Plot spectrogram of the solution for a point on the string
=(10, 5))
plt.figure(figsize17], Fs=solver.sampling_rate, NFFT=2048, noverlap=1024)
plt.specgram(u[:, 0, 20000)
plt.ylim(
plt.show()
# Plot FFT of the initial condition
=(10, 5))
plt.figure(figsize= np.fft.rfftfreq(u.shape[-1], d=solver.dx)
freq # plt.plot(freq, np.abs(np.fft.rfft(u0)), label="fft")
abs(np.fft.rfft(u0)), label="fft")
plt.plot(np.# plt.xlim(0, 1)
plt.legend()
# plot the initial conditions
=(10, 5))
plt.figure(figsize="u0")
plt.plot(solver.grid, u0, label0, :], label="u0_sol", alpha=0.5)
plt.plot(solver.grid, u[
plt.legend()
plt.show()
# Plot the difference between the initial condition given and the one obtained from the solution
=(10, 5))
plt.figure(figsize- u[0], label="u0 - u0_sol")
plt.plot(solver.grid, u0
plt.legend() plt.show()
from IPython import display as ipd
# Play the solution as audio
27], rate=solver.sampling_rate) ipd.Audio(u[:,