Take a solid metal plate (or any other rigid material) and sprinkle some particles on it. It can either be sand, salt, or even baking soda. Then, generate some vibration on the plate by using either a violin bow to excite the plate, or place it on a wave generator and set it to specific frequencies. What you will observe are the beautiful patterns generated as shown in the figure above. This experiment sounds relatively simple, but as simple as it sounds, this experiment reveals some extremely complex and intensive underlying mathematical principles. In fact, the problem of understanding these patterns challenged some of the brightest mathematical minds of the time, and the research to understand these patterns spans for hundreds of years, and still continues till the modern day.
How does this work? Well, it turns out these are examples of standing waves in the two-dimensional space. In one dimension, a “node” in a standing wave is a point along the wave where the amplitude is at minimum. Similarly, in two dimensions, there are “nodal lines,” which are static points on the plate where the particles settle.
In the spring semester of 2024, my physics project required selecting an experiment to demonstrate a scientific law. Chladni's law was a compelling choice, and one good idea was to prove the constant wave speed across a plate using the two-dimensional wave equation model
where $c$ here denotes the wave speed of the plate, $u_{tt}$ is the plate's displacement, and $Q$ is the forcing function (the wave generator).
By assuming that the plate is supported by tension, one can derive the two-dimensional wave equation in the Cartersian coordinate to be
The circular plate is similar, with a slightly different Laplacian since we need to convert the wave equation to the polar coordinate system:
In the next grand section, we will discuss the simulation process of this model on the square and circular domain, and to discuss the flaws of this model (lots of flaws, but no spoiler yet). Basically, the emphasized, initial goal of this project is to simulate Chladni patterns, prove that the wave speed is constant, and validate Chladni's law (see the section Chladni's Law below). For a much more detailed explanation, please refer to my project report and checkout the source code on github
The study of wave and vibration theory dates its way back to the time of Pythagoras in the fifth century BC, where the Ionian School of Natural Philosophy first introduced the scientific methods to study the real world and natural phenomena. But just as with many other scientific discoveries before Newton, we lacked the tools to rigorously understand the underlying theory behind them. Fast forward to the Renaissance period. Galileo also noticed that pieces of bristle on the sounding board only move in certain areas. Leonardo da Vinci also observed the strange patterns generated by particles in response to vibration.
The first rigorous investigation of these patterns so called “Chladni patterns” is of course, attributed to German physicist and musician Ernst Chladni (1756 - 1827) (not Euler or Gauss this time). The idea of sprinkling sand on a plate and bow it is not entirely new, but Chladni took our understanding to a new level by coming up with a formula that describe the pattern, and hypothesized a formula that predicts the number of rings on the circular plate. This formula is later proven true by Kirchoff and Rayleigh.
Part of the formula states that the vibrating frequency is proportional to four times the number of rings on the plate squared, that is, $f \sim 4n^2$, where $n$ is the number of rings (see in Fig. 4). The full formula that he derived is \begin{equation} f \sim (m+2n)^2, \end{equation} where $m$ here is the number of diameter lines. This is called Chladni's law. We don't see any $m$'s in the figure below, there is a reason for this, and I'll get to that part later.
Depends on whether you clamp your edge or not, clamp the edge partially, or simply support the plate by placing some other objects below the plate; the boundary condition may be different. For this project, we will emphasize the free boundary condition. That is, only the wave generator supports the plate.
| Type of Boundary | Boundary Condition |
|---|---|
| Clamped (fixed) edge | $u(\pm a, y, t) = u(x, \pm a, t) = 0$ |
| Moving edge | $u(a, y, t_0) = f(y, t)$ |
| Free edge | $\pd{u}{x}(\pm a, y, t) = \pd{u}{y}(x, \pm a, t) = 0$ |
The free boundary condition means that there are no forces acting on the edges of the plate; that is, expressed mathematically,
which gave us four boundary conditions. The uniqueness of the solution requires our equation to have two more initial conditions. Similar to ordinary differential equations, these conditions are the initial displacement and velocity of the plate at $t=0$. That is,
Consider the wave equation on a rectangle $\Omega = \{(x,y)\in\R^2\;|\; -a\leq x,y\leq a\}$,
Here, $a$ is half the length of the plate, and also note that we denote the length of the plate to be $L$. A standard way to solve a forced differential equation is to solve its homogenous case first, i.e. when $Q=0$.There are many ways to solve this initial-boundary value problem, and one common, and easy-to-understand technique is via separation of variables. If we assume that $u$ is separable and let $u(x,y,t) = X(x)Y(y)T(t)$, the equation becomes
Divide both sides by $XYT$, we obtain
Notice that $\frac{X''}{X}+\frac{Y''}{Y}=-\lambda$ is constant if and only if $X''/X=-\mu$ and $Y''/Y = -\nu$, where $\mu$ and $\nu$ are themselves constants and $\lambda = \mu + \nu$. The two constants above yield two eigenvalue problems with Neumann boundary conditions:
and the time equation yields the third eigenvalue problem:
The detail solution of these eigenvalue problems is beyond the scope of this article. For the detail solution, again, please refer to my project report. The general idea is we need to solve the eigenvalue problems (\ref{eq:X1}) and (\ref{eq:Y1}) first, and from there we solve the third eigenvalue problem, since $\lambda = \mu + \nu$. After carrying out all the hard work, the eigenvalues for equations (\ref{eq:X1}) and (\ref{eq:Y1}) are
and the solution to the homogeneous equation is the double Fourier series
where,
The coefficients $a_n$ and $\overline{a_n}$ are defined as $a_n = \frac{1 + (-1)^n}{2},\overline{a_n} = \frac{1 - (-1)^n}{2}$, and,
The forcing term $Q$ doesn't change the overall shape of the patterns, but it does change the displacement of the plate overtime, hence the time function $T_{nm}(t)$ becomes
Here, $q_{nm}$ is a function defined on the disk of radius $r$ — the radius of the tip of the wave generator,
Consider the polar wave equation on a disk $\Omega = \{(r,\theta)\in\R^2\;|\; 0 \leq r\leq a, 0 \leq \theta\leq 2\pi\},$
Let $u(r, \theta, t) = R(r)\Theta(\theta)T(t)$. Skipping all the hard work., we may arrive at the final solution to be
The reason for all those derivations above is to plot our theoretical patterns. Recall that our final solution is derived as
Before we design our algorithm, there are a few things to note:
The final step is to notice the angular frequency $\omega$. Our solution $u(\mathbf{x}, t)$ is derived as the superposition sum of all other possible solutions. However, this is a double infinite series; how are we going to plot this function? This is a lesson I learned throughout my time as an engineering student: Occasionally, we must reinterpret abstract mathematics to make it meaningful in the context of the physical world..
In summary, the algorithm to plot the patterns of the square plate is just an algorithm to search for pairs of sum of squares $(n, m)$.
Algorithm 1: Square Plate Chladni Pattern Generation
Function GenerateSignCombinations(pairs)
Input: List of integer pairs (n, m)
Output: Array of sign combinations [+/-1, +/-1, ...] with first element fixed at +1
Function BasisFunction(x, n)
Input: Spatial coordinate x, mode number n
Output: X_n(x) = a_n cos(n*pi*x/L) + a_bar_n sin(n*pi*x/L)
Return ((n+1) mod 2) * cos(n*pi*x/2) + (n mod 2) * sin(n*pi*x/2)
Function WaveFunction(x, y, pairs, signs)
Input: Coordinates (x, y), mode pairs [(n_1,m_1), (n_2,m_2), ...], sign array
Output: u(x,y) = sum over i of signs_i * X_n_i(x) * Y_m_i(y)
Return sum from i=1 to n of signs_i * BasisFunction(x, n_i) * BasisFunction(y, m_i)
Input: Integer S
Output: Set of pairs {(n, m) : n^2 + m^2 = S}
pairs = empty_set
for n = 0 to floor(sqrt(S)) do
m = sqrt(S - n^2)
if m is an integer then
pairs = pairs union {(n, m), (m, n)}
return pairs
Function IsSumOfSquares(S)
Input: Integer S
Output: Boolean indicating if S = n^2 + m^2 for some integers n, m
Return size(FindSumOfSquaresPairs(S)) > 0
Algorithm PlotSquareChladniPatterns(startIndex, endIndex)
Input: Range [startIndex, endIndex] for pattern indices
Output: Contour plots of nodal lines at u(x,y) = 0
totalPlots = endIndex - startIndex
equations = [ ] // Store wave functions
sumValues = [ ] // Store corresponding S values
plotCount = 0
k = 1
while plotCount < totalPlots do
if IsSumOfSquares(k) then
pairs = FindSumOfSquaresPairs(k)
signCombos = GenerateSignCombinations(pairs)
for each combo in signCombos do
if plotCount >= startIndex and plotCount < endIndex then
u_xy = WaveFunction(x, y, pairs, combo)
equations.append(u_xy)
sumValues.append(k)
plotCount = plotCount + 1
k = k + 1
for i = 0 to totalPlots - 1 do
AssignColorsByModePairs(pairs[i])
PlotContour(equations[i], level=0) // Plot nodal lines at z = 0
The circular plate is a bit easier, since we don't need to design an algorithm to search for sum of squares and perfect squares exceptions. Recall that for a circular plate,
By applying a similar technique as the square plate, we can eliminate $c_{nm}, d_{nm}$, and the forcing term. The circular plate is easier to implement because we don't have to search for any sum of squares, so each Chladni pattern is simply a pair of arbitrary $n$ and $m$. The equations that we need to plot are simply
Function BesselDerivativeZero(n, m)
Input: Order n, zero index m
Output: m-th zero z_nm of J'_n(x)
if n = 0 then
zeros = ComputeBesselPrimeZeros(0, m + 1)
zeros[0] = 0 // Redefine first zero as origin
return zeros[m]
else
zeros = ComputeBesselPrimeZeros(n, m + 1)
return zeros[m]
Function InitialDisplacement(r, theta)
Input: Polar coordinates (r, theta)
Output: Initial condition f(r, theta)
Return r * cos(theta)
Function FourierCoefficient_A(n, m, a)
Input: Mode numbers (n, m), plate radius a
Output: Coefficient a_nm from initial conditions
k = z_nm / a
numerator = integral from 0 to a, integral from 0 to 2*pi of
J_n(kr) * cos(n*theta) * f(r,theta) * r d(theta) dr
denominator = 2*pi * integral from 0 to a of J_n^2(kr) * r dr
Return numerator / denominator
Function FourierCoefficient_B(n, m, a)
Input: Mode numbers (n, m), plate radius a
Output: Coefficient b_nm from initial conditions
k = z_nm / a
numerator = integral from 0 to a, integral from 0 to 2*pi of
J_n(kr) * sin(n*theta) * f(r,theta) * r d(theta) dr
denominator = 2*pi * integral from 0 to a of J_n^2(kr) * r dr
Return numerator / denominator
Function CircularWaveFunction(n, m, r, theta, a)
Input: Mode numbers (n, m), polar coordinates (r, theta), radius a
Output: u_nm(r, theta) = J_n(kr)[a_nm cos(n*theta) + b_nm sin(n*theta)]
z_nm = BesselDerivativeZero(n, m)
k = z_nm / a
a_nm = FourierCoefficient_A(n, m, a)
b_nm = FourierCoefficient_B(n, m, a)
Return J_n(kr) * [a_nm * cos(n*theta) + b_nm * sin(n*theta)]
Algorithm PlotCircularChladniPattern(n, m, a)
Input: Mode numbers (n, m), plate radius a
Output: Contour plot of nodal lines at u(r,theta) = 0
for each point (r, theta) in [0, a] x [0, 2*pi] do
u[r, theta] = CircularWaveFunction(n, m, r, theta, a)
PlotContour(u, level=0) // Plot nodal lines where u = 0
The next part of this article will be dedicated to implementing the code in python and compare the theoretical results to the experimental result. By then, we may know whether the wave speed is constant or not. You can read Part 2 by pressing the directory below.
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from mpl_toolkits.mplot3d import Axes3D
# Define the function f
def f(x, y, t):
return 0.2 * np.cos(t) * (
np.cos(2 * np.pi * x / 2) * np.cos(4 * np.pi * y / 2) +
np.cos(4 * np.pi * x / 2) * np.cos(2 * np.pi * y / 2)
)
# Create grid for x, y
x = np.linspace(-1, 1, 40)
y = np.linspace(-1, 1, 40)
X, Y = np.meshgrid(x, y)
# Create the figure and axis for the 3D animation
fig = plt.figure(figsize=(10, 8))
ax = fig.add_subplot(111, projection='3d')
# Function to update the 3D surface for each frame
def update_surface(t):
ax.clear() # Clear previous plot
# Compute the Z values
Z = f(X, Y, t)
# Plot the dynamic surface
surface = ax.plot_surface(X, Y, Z, cmap='viridis', alpha=0.85, rstride=1, cstride=1, linewidth=0)
# Plot the central plane z = 0 as a grid
ax.plot_wireframe(X, Y, np.zeros_like(X), color='black', alpha=0.5, linewidth=0.5)
# Plot the contour lines where f(x,y,t) = 0 on the plane
ax.contour(X, Y, Z, levels=[0], colors='red', linewidths=2, linestyles='solid', zdir='z', offset=0)
# Set consistent axis limits and ensure z-axis is to scale
ax.set_xlim(-1, 1)
ax.set_ylim(-1, 1)
ax.set_zlim(-0.5, 0.5) # Z-axis scaled properly
# Set axis labels
ax.set_xlabel('X axis')
ax.set_ylabel('Y axis')
ax.set_zlabel('Z axis')
# Set equal scaling for axes
ax.set_box_aspect([1, 1, 0.5]) # Aspect ratio of x:y:z
# Return the surface to update the animation
return surface
# Create the 3D animation for one full cycle (0 to 2π)
t_values = np.linspace(0, 2 * np.pi, 100) # 100 frames for one cycle
ani_3d = animation.FuncAnimation(fig, update_surface, frames=t_values, interval=1, blit=False)
# Show the animation
plt.show()
output_path = 'animation.gif'
writer = animation.PillowWriter(fps=50)
print("Saving...")
ani_3d.save(output_path, writer=writer)
# Print the output path to confirm where it is saved
print(f"Animation saved to: {output_path}")