I'm implementing a numerical solver for the 1D diffusion (heat) equation using an iterative method. The initial temperature distribution is a Gaussian profile, which should theoretically smooth out over time as the diffusion process evolves.
However, when the correction or update between iterations becomes large, I observe small oscillations or noise near the bottom of the temperature profile. Instead of relaxing smoothly, the temperature shows local fluctuations that shouldn't be there physically.
Here’s a summary of my setup:
Equation (including one nonlinear term \$T^{2.5}\$)
$$\dfrac{\partial{T}}{\partial{t}}=\dfrac{\partial}{\partial{x}}T^{2.5}\dfrac{\partial{T}}{\partial{x}}$$
Discretization: finite difference method (implicit)
Initial condition: Gaussian temperature distribution
Boundary conditions: zero flux
Observation: oscillations appear at the lowest point of the profile when the correction step is large
My questions are:
What causes these local oscillations or “numerical noise” in a diffusion solver that should theoretically smooth out?
How can I prevent or mitigate this issue (I have already tried adjusting the time step, grid spacing)?
Any insight into the numerical stability or discretization errors that could cause this would be appreciated.
import numpy as np
from scipy.sparse import diags
from scipy.sparse.linalg import spsolve
from scipy.integrate import quad
# ---------------------------
# Spitzer-Härm flux
# ---------------------------
import numpy as np
import matplotlib.pyplot as plt
from math import sqrt, log, pi, exp
# Constants
c0 = 299792458 # the vacuum light velocity in unit of m/s
me = 9.10938356e-31 # the electron mass in kg
epsilon0 = 8.8541878128e-12 # the dielectric constant in vacuum (C^2⋅kg^−1⋅m^−3⋅s^2)
qe = 1.60217662e-19 # the electron charge in unit of coulomb (C)
K = 1.783e-33*c0*c0 # in unit of kg*(m/s)^2/(keV), i.e., no units
kB = 1.380649e-23 # in the unit of J/K
mu00 = 1.25663706127e-6 # vacuum permeability in the unit of (N⋅(C/s)^−2)
# OR
mu0 = 1/(c0**2*epsilon0) # the relation connected by the light velocity
Te_central = 1000 # the temperature in the central wall, in the unit of eV
Te_per = 4000
Ne = 1.5e20 # the central plasma density 1/m^3
omegap = sqrt(Ne*qe**2/(epsilon0*me)) # plasma frequency (1/s)
kp = omegap/c0 # plasma wavenumber
p = Ne*kB*Te_central*11604.51812 # the thermal pressure (J/m^3) in kept as constant, 1eV = 11604.51812 K
TeRatio = 2 # the temperature ratio between hot and cold
Teh = 2*Te_central/(1+TeRatio)*TeRatio # the temperature in the hot wall, in the unit of eV
Tel = 2*Te_central/(1+TeRatio) # the temperature in the cold wall, in the unit of eV
vthMean_SI = sqrt(Te_central/1000*1*K/me) # thermal velocity in the hot wall, in the unit of SI (m/s)
vthh_SI = sqrt(Teh/1000*1*K/me) # thermal velocity in the hot wall, in the unit of SI (m/s)
vthl_SI = sqrt(Tel/1000*1*K/me) # thermal velocity in the cold wall, in the unit of SI (m/s)
Z = 1 # ion charge state
LambdaCoulomb1 = 23 - log((Ne*1.e-6)**(1/2)*Z*(Te_central)**(-3/2)) # Ti*me/mi < Te < 10z^2 eV
LambdaCoulomb2 = 24 - log((Ne*1.e-6)**(1/2)*(Te_central)**(-1)) # Ti*me/mi < 10z^2 eV < Te
if Te_central <= 10*Z**2:
CoulombLogarithm = LambdaCoulomb1
else:
CoulombLogarithm = LambdaCoulomb2
nu_ei0 = Z*Ne*qe**4*CoulombLogarithm/(4*pi*epsilon0**2*me**2)/(vthMean_SI**3)
mfp0 = vthMean_SI/nu_ei0 # centrel mean free path in m
LL = 160*mfp0 # simulation box length
Xmin = -LL/2
Xmax = LL/2
ll = 8.44*mfp0
# Create spatial grid
num_points = 800
central = int(num_points / 2) # Ensures an integer value
x = np.linspace(Xmin, Xmax, num_points)
nx = len(x)
dx = x[1]-x[0]
temperature_profile = Te_central + Te_per*np.exp(-x**2/ll**2) # temperature profile in eV
thermal_velocity_profile = np.sqrt(temperature_profile / 1000 * 1 * K / me) # thermal velocity profile in m/s
#density_profile = Te_central*Ne/temperature_profile # electron density 1/m^3
density_profile = np.full(len(x), Ne) # electron density 1/m^3
Z_factor = (Z + 0.24) / (Z + 4.2)
cont_nu_ei = Z*density_profile*qe**4* CoulombLogarithm/(4*pi*epsilon0**2*me**2)
nu_ei_local = cont_nu_ei/(thermal_velocity_profile**3)
mfp_profile = thermal_velocity_profile/nu_ei_local # mean free path in m (SI units)
mfp_sherlock = mfp_profile*np.sqrt(Z)*np.sqrt(Z_factor) # mean free path in m (SI units)
lambda_lmv = 32 * np.sqrt(Z + 1) * mfp_profile
dT_dx = np.gradient(temperature_profile, x)
const_factor = 128 / (3 * np.pi) * Z_factor * (3 * np.pi) / np.sqrt(2 * np.pi)
kappa = const_factor * 4*pi*epsilon0**2*me**2 / (Z*qe**4*CoulombLogarithm) * np.sqrt(temperature_profile/1000*1*K/me)**5
ka = const_factor * 4*pi*epsilon0**2*me**2 / (Z*qe**4*CoulombLogarithm) * np.sqrt(1/1000*1*K/me)**5
q_sh = -kappa * dT_dx
q_free = density_profile * temperature_profile * thermal_velocity_profile
def SH_solver(Te):
Te_center = (Te[:-1] + Te[1:]) /2
kappa_face = ka * Te_center**2.5 # Cao
Q_nl = -kappa_face * (Te[1:] - Te[:-1]) / dx * 0.01
divQ_nl = np.zeros_like(Te)
divQ_nl[1:-1] = (Q_nl[1:] - Q_nl[:-1]) / dx
divQ_nl[0] = divQ_nl[1]
divQ_nl[-1] = divQ_nl[-2]
return Q_nl, divQ_nl
import numpy as np
from scipy.sparse import diags
from scipy.sparse.linalg import spsolve
# ---------------------------------------------------------------
# Simulation setup
# ---------------------------------------------------------------
dt = 0.1/nu_ei0
total_time = 30/nu_ei0
num_steps = int(total_time / dt)
n_e = density_profile
# initial condition
Te_int = temperature_profile # initial temperature profile
# iteration control
max_iter = 1000
alpha = 0.01
Te_history = np.zeros((num_steps+1, nx))
Qe_history_current = np.zeros((num_steps, nx-1))
Qe_history_previous = np.zeros((num_steps, nx-1))
Te_history[0, :] = Te_int.copy()
for n in range(num_steps):
print(f"\n================ TIME STEP {n+1}/{num_steps} ================")
if n == 0:
Te_n = Te_int.copy()
else:
Te_n = Te.copy()
Te_k = Te_n.copy()
divQ_SH_prev = np.zeros_like(Te_n)
S_nl_corr_prev = np.zeros_like(Te_n)
# -----------------------------------------------------------
# Inner nonlinear iteration loop
# -----------------------------------------------------------
for k in range(1, max_iter + 1):
#print(f" Iteration {k} ...")
# STEP 1: compute Te_k
Te_face = (Te_k[:-1] + Te_k[1:])/2
kappa_i = ka * Te_face**(2.5)
k_right = np.zeros(nx)
k_left = np.zeros(nx)
k_right[:-1] = kappa_i
k_left[1:] = k_right[:-1]
n_interior = nx - 2
rhs = np.zeros(n_interior)
a = np.zeros(nx)
b = np.zeros(nx)
c = np.zeros(nx)
for i in range(1, nx-1):
a[i] = -k_left[i] / dx**2
b[i] = (k_left[i] + k_right[i]) / dx**2 + (1.5 * n_e[i] / dt)
c[i] = -k_right[i] / dx**2
# reflective BC corrections ---
b[1] += a[1]; a[1] = 0.0
b[nx-2] += c[nx-2]; c[nx-2] = 0.0
# diagonals for sparse matrix
lower_diag = np.zeros(n_interior-1)
main_diag = np.zeros(n_interior)
upper_diag = np.zeros(n_interior-1)
for j, i in enumerate(range(1, nx-1)):
if i > 1:
lower_diag[j-1] = a[i]
main_diag[j] = b[i]
if i < nx-2:
upper_diag[j] = c[i]
rhs[j] = (1.5 * n_e[i] * Te_n[i] / dt) + S_nl_corr_prev[i]
# build sparse matrix directly
A_sparse = diags([lower_diag, main_diag, upper_diag], [-1, 0, 1], format="csc")
# print(A_sparse)
Te_new_bulk = spsolve(A_sparse, rhs)
# reflective boundary for temperature ---
Te_new = np.zeros(nx)
Te_new[1:-1] = Te_new_bulk
Te_new[0] = Te_new[1]
Te_new[-1] = Te_new[-2]
#Te_newkk = 2 * Te_new[:-1] * Te_new[1:] / (Te_new[:-1] + Te_new[1:])
Te_new_face = (Te_new[:-1] + Te_new[1:])/2
# STEP 2: compute ∇·Q_SH
kappa_face = ka * Te_new_face**2.5 # Cao
flux_face = - kappa_face * (Te_new[1:] - Te_new[:-1]) / dx
#plt.plot(kappa_face1)
#plt.show()
divQ_SH_now = np.zeros_like(Te_new)
divQ_SH_now[1:-1] = (flux_face[1:] - flux_face[:-1]) / dx
divQ_SH_now[0] = divQ_SH_now[1]
divQ_SH_now[-1] = divQ_SH_now[-2]
# STEP 3: convergence check (skip the k=1 interation)
if k > 1:
flux_diff = np.abs(divQ_SH_now - divQ_SH_prev)
tol_array = alpha * (1.5 * n_e * Te_new / dt)
if np.all(flux_diff <= tol_array):
print(f" ✅ Converged at iteration {k}")
break
# STEP 4 & 5: compute nonlocal heat flux and its divergence
Q_nl, divQ_nl = SH_solver(Te_new)
# STEP 7: update nonlocal correction
S_nl_corr = divQ_SH_now - divQ_nl
# update for next iteration
divQ_SH_prev = divQ_SH_now.copy()
S_nl_corr_prev = S_nl_corr.copy()
#Te_k = 0.5 * Te_k + 0.5 * Te_new
Te_k = Te_new.copy()
# update temperature for next time step
Te_history[n+1, :] = Te_new.copy()
Te = Te_new.copy()
Qe_history_previous[n, :] = Q_nl.copy()
Q_nlt, divQ_nlt = SH_solver(Te)
Qe_history_current[n, :] = Q_nlt.copy()
#plt.plot(Te)
# optional: diagnostic
#total_energy = np.trapz(1.5 * n_e * Te, x)
#print(f" Total energy = {total_energy:.3e} J/m²")
print(f" Max Te = {Te.max():.3f} eV, Min Te = {Te.min():.3f} eV")
print("\nSimulation complete.")