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, our 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
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.
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 modest, 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 we'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)$.
Sketch of the Algorithm:# ALGORITHM 1: PLOT SQUARE CHLADNI PATTERNS # generate different sign combinations for X using an array of 1's and -1's. # always keep the first element positive (1) function generate_sign_combinations(pairs) # define the basis function # we don't use Y(y), or m here because it's basically the exact same function as X function X(x, n) return mod(n + 1, 2) * cos(n * pi * x/ 2.0) + mod(n, 2) * sin(n * pi * x/ 2.0) # plot the full wave function # input: x, y, pair (n, m) and sign array, [1, -1, -1] for instance function u(x, y, pairs, signs) return u # search for sum-of-squares pairs # find all pairs of (n, m) such that n^2 + m^2 = number # return value: a list of tuples (n, m) that satisfies the requirement function find_pairs(number) # Check if a given number can be represented as sums of squares pairs # return true if it is a sum of square function exist_sos(number) # search for sum of squares # plot the sum of squares function plot_sum_of_squares(start_index, end_index) # compute total_plots as the difference between end_index and start_index # initialize empty lists: equation, sums_of_squares, and description (to store the title of each plot) # iterate over numbers and check for sum of squares: # for each integer k, check if it can be written as a sum of squares by exist_sos(k) # if true, find all pairs of k using find_pairs(k) # for each pair (n, m) generate all possible sign combinations # for each sign combination: # if current plot is in between start_index and end_index, compute u(x, y, pairs, signs) # store u in the list equation, and k in sums_of_squares # stop when the total_plots is reached. # for i in range of total_plots: # assign colors for each unique pairs (n. m). # plot the contour plot 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
# ALGORITHM 2: PLOT CIRCULAR CHLADNI PATTERNS # Function to get the m-th zero of the derivative of the Bessel function of order n function bessel_derivative_zero(n, m): # redefine the first zero of J_0 prime to be 0 # default is not zero for some reason if n == 0 then zeros = list(jnp_zeros(n, m + 1)) zeros.insert(0, 0) return zeros[m] else # Get the m-th zero, treating first zero as the 0th zero return jnp_zeros(n, m + 1)[-1] # Initial function f(r, theta) function f(r, theta): return r * np.cos(theta) # Two coefficients # use built-in integration tools to obtain these # input: pair (n, m), radius a function a_nm(n, m, a) function b_nm(n, m, a) # Define the function J_n(z_nm * r / a) * (a_nm * cos(n*theta) + b_nm * sin(n*theta)) def u(n, m, r, theta, a)
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}")