This site is version 0.0.1. Most of the code on this site is drafted by an agent and needs review. I apologize for the AI-speak, we are working on rewriting it!
viv-lang.org

Gallery

Landau damping from the root of the dispersion relation

1+1+ζZ(ζ)k2λD2=0,ζ=ω2 kvth1 + \frac{1 + \zeta Z(\zeta)}{k^2\lambda_D^2} = 0, \qquad \zeta = \frac{\omega}{\sqrt 2\, k v_{th}}

A Langmuir wave in a collisionless plasma still decays: electrons moving near its phase velocity take energy from it. Newton's method on the kinetic dispersion relation, with the plasma dispersion function from lib/zfunc, puts the least-damped root at at (in units of ), matching a 30-digit mpmath root to . The usual small- damping formula is 1.3% off there and 59% off at .

The problem

A Langmuir wave is an oscillation of the electrons in a plasma against the ions, at about the plasma frequency . In a plasma with no collisions at all it still decays. Electrons moving a little slower than the wave are pushed forward by it and take energy; electrons moving a little faster are held back and give energy. In a Maxwellian distribution the slower ones outnumber the faster ones at any speed above the mean, so the wave loses. This is Landau damping (Landau, 1946).

The rate comes out of the kinetic dispersion relation. For a Maxwellian with thermal speed and Debye length , a wave exists when

where is the plasma dispersion function. Its roots are complex, , and is the damping rate. comes from lib/zfunc, which computes it by Weideman's rational expansion and is tested there against scipy.special.wofz. Units: , so means . posts/refs/landau_root.py finds the same roots with scipy and with mpmath at 30 digits.

Run

What the run shows

log10|ϵ(ω)| at kλD=0.5
Imω/ωpe
Reω/ωpe
48 × 64 grid
Damping rate of the least-damped Langmuir wave
exact rootsmall-k formula
10⁻⁴10⁻³10⁻²10⁻¹0.20.30.40.50.6kλDγ/ωpe
Frequency of the least-damped Langmuir wave
exact rootBohm-Gross, 1+3k2
1.21.40.20.30.40.50.6kλDωr/ωpe
The field of a Langmuir wave at k lambda_D = 0.5, from the linearized Vlasov equation
Re E1Im E1decay at the root's rate
−1012010203040t omega_peE1

How it is checked

The reference is a 30-digit mpmath root. 9 of 9 tests pass.

The program

posts/2026-09-25-landau-damping-from-the-dispersion-relation.viv, 318 lines. The imports it names are the site's library modules; Run splices them in and runs the whole thing.

from zfunc import zf_z, zf_zprime

RT2 = sqrt(2.0)

#| The dielectric function ε(ω) = 1 + (1 + ζ·Z(ζ))/k², with ζ = ω/(√2·k).
def dielectric(w: Complex, k) {
    zeta = w / (RT2 * k)
    return 1.0 + (1.0 + zeta * zf_z(zeta)) / (k * k)
}

#| dε/dω = (Z + ζ·Z')/(√2·k³), from d(ζ·Z)/dζ = Z + ζ·Z'.
def dielectric_slope(w: Complex, k) {
    zeta = w / (RT2 * k)
    return (zf_z(zeta) + zeta * zf_zprime(zeta)) / (RT2 * k * k * k)
}

NEWTON_STEPS = 30
#| Newton's method for ε(ω) = 0 in the complex plane, from the guess w0.
def landau_root(k, w0: Complex) {
    w = w0
    for i to NEWTON_STEPS {
        w = w - dielectric(w, k) / dielectric_slope(w, k)
    }
    return w
}

###
## The root at $k\lambda_D = 0.5$

A good starting guess is the Bohm-Gross frequency $\omega = \sqrt{1 + 3k^2}$, the
fluid-theory answer, on the real axis. From there Newton's method moves the guess
down into the lower half plane. The cell runs 30 steps; the step size is at the
level of rounding after six.
###

K0 = 0.5
w_bg = sqrt(1.0 + 3.0 * K0 * K0)
root = landau_root(K0, complex(w_bg, 0.0))
show re(root)
show im(root)
v_phase = re(root) / K0
show v_phase

test "at k lambda_D = 0.5 the root is 1.4156618886045364 - 0.15335946690960483 i (mpmath, 30 digits)" {
    assert_near(re(root), 1.4156618886045364, 1.0e-12)
    assert_near(im(root), -0.15335946690960483, 1.0e-12)
}
test "scipy.optimize.newton with scipy.special.wofz finds the same root to 1e-12" {
    assert_near(re(root), 1.4156618886045371, 1.0e-12)
    assert_near(im(root), -0.15335946690960464, 1.0e-12)
}
test "the wave's phase velocity is 2.83 thermal speeds, out in the tail of the Maxwellian" {
    assert_near(v_phase, 2.8313237772090742, 1.0e-11)
}

###
## Where the roots are

The dispersion relation has more than one root. The heatmap shows
$\log_{10}|\varepsilon(\omega)|$ over part of the complex $\omega$ plane at $k\lambda_D = 0.5$;
roots are the dark points. The one found above is the highest, the least damped,
and it is the one a wave settles into. The others lie deeper below the axis and
die out faster: Newton's method started at the next dark point, near $1.8 - 1.1i$,
converges to $\omega = 1.789571 - 1.144143i$, which decays 7.5 times faster than the
first. Re $\omega$ is across and Im $\omega$ up.
###

# a 64 x 48 grid over 0.2 <= Re w <= 3.4, -1.6 <= Im w <= 0.2
NRE = 64
NIM = 48
RE_LO = 0.2
RE_HI = 3.4
IM_LO = -1.6
IM_HI = 0.2
logeps = zeros(NIM, NRE)
for r to NIM {
    for c to NRE {
        wr = RE_LO + (c + 0.5) * (RE_HI - RE_LO) / NRE
        wi = IM_LO + (r + 0.5) * (IM_HI - IM_LO) / NIM
        # three decimals are plenty for a picture and keep the figure data small
        logeps[r, c] = round(1000.0 * log(abs(dielectric(complex(wr, wi), K0))) / log(10.0)) / 1000.0
    }
}
figure("$\\log_{10} |\\epsilon(\\omega)|$ at $k\\lambda_D = 0.5$", "$\\mathrm{Re}\\,\\omega/\\omega_{pe}$", "$\\mathrm{Im}\\,\\omega/\\omega_{pe}$", RE_LO, RE_HI, IM_LO, IM_HI)
origin("lower")
plot2d(logeps, "$\\log_{10} |\\epsilon|$")

# the next root down, from the dark point near 1.8 - 1.1i in the picture
root2 = landau_root(K0, complex(1.8, -1.1))
show re(root2)
show im(root2)
test "the next root, 1.7895705949216291 - 1.1441430104277659 i (mpmath), is damped 7.5 times faster" {
    assert_near(re(root2), 1.7895705949216291, 1.0e-12)
    assert_near(im(root2), -1.1441430104277659, 1.0e-12)
    assert_near(im(root2) / im(root), 7.46, 0.01)
}

###
## Against the small-$k$ formula

For long waves, $k\lambda_D \ll 1$, the phase velocity is far out in the tail and the
damping can be expanded. The usual result is

$$\gamma \approx \sqrt{\frac{\pi}{8}}\,\frac{\omega_{pe}}{(k\lambda_D)^3}\exp\left(-\frac{1}{2k^2\lambda_D^2} - \frac32\right).$$

The cell follows the root from $k\lambda_D = 0.2$ to $0.6$, each Newton solve starting
from the previous root. At $0.5$ the formula gives $0.1514$ against the exact $0.1534$,
1.3% low. That agreement is an accident of where the error changes sign: at $0.3$
the formula gives $0.0200$ against $0.0126$, 59% too high, and at $0.2$ it is still 18%
high. The real part is above Bohm-Gross by 0.5% at $0.2$ and by 7% at $0.5$.
###

N_K = 41
ks = linspace(0.2, 0.6, N_K)
w_re = zeros(N_K)
damp = zeros(N_K)
bg = zeros(N_K)
weak = zeros(N_K)
guess = complex(sqrt(1.0 + 3.0 * ks[0] * ks[0]), 0.0)
for i to N_K {
    kk = ks[i]
    guess = landau_root(kk, guess)
    w_re[i] = re(guess)
    damp[i] = -im(guess)
    bg[i] = sqrt(1.0 + 3.0 * kk * kk)
    weak[i] = sqrt(3.141592653589793 / 8.0) / (kk * kk * kk) * exp(-1.0 / (2.0 * kk * kk) - 1.5)
}
# ks[10] = 0.3 and ks[30] = 0.5 on this grid of step 0.01
I_03 = 10
I_05 = 30
show damp[0]
show damp[I_03]
show weak[I_03] / damp[I_03]
show weak[I_05] / damp[I_05]
# the formula's error relative to the root: negative is low, positive high
weak_err_05 = weak[I_05] / damp[I_05] - 1.0
weak_err_03 = weak[I_03] / damp[I_03] - 1.0
weak_err_02 = weak[0] / damp[0] - 1.0
show weak_err_05
show weak_err_03
show weak_err_02

test "along the curve the roots at k = 0.2, 0.3, 0.5 are the mpmath roots to 1e-11" {
    assert_near(w_re[0], 1.0639843406877026, 1.0e-11)
    assert_near(damp[0], 5.510741476862848e-5, 1.0e-11)
    assert_near(w_re[I_03], 1.1598464805919138, 1.0e-11)
    assert_near(damp[I_03], 0.012620368421117154, 1.0e-11)
    assert_near(damp[I_05], 0.15335946690960483, 1.0e-11)
}
test "the small-k formula is 1.3% low at k = 0.5, 59% high at 0.3 and 18% high at 0.2" {
    assert_near(weak[I_05] / damp[I_05], 0.9871397786075161, 1.0e-9)
    assert_near(weak[I_03] / damp[I_03], 1.5863729682065133, 1.0e-9)
    assert_near(weak[0] / damp[0], 1.1819719537886595, 1.0e-6)
    assert_near(ks[I_03], 0.3, 1.0e-15)
    assert_near(weak_err_05, 0.9871397786075161 - 1.0, 1.0e-9)
    assert_near(weak_err_03, 1.5863729682065133 - 1.0, 1.0e-9)
    assert_near(weak_err_02, 1.1819719537886595 - 1.0, 1.0e-6)
}
test "the small-k formula gives 0.1514 at k = 0.5 and 0.0200 at k = 0.3" {
    # the formula evaluated in double precision by Python's math module
    assert_near(weak[I_05], 0.151387230212514, 1.0e-12)
    assert_near(weak[I_03], 0.020020611312067026, 1.0e-12)
}
test "Re omega exceeds Bohm-Gross by 0.54% at k = 0.2 and 7.0% at k = 0.5" {
    assert_near(w_re[0] / bg[0] - 1.0, 1.0639843406877026 / 1.0583005244258363 - 1.0, 1.0e-10)
    assert_near(w_re[I_05] / bg[I_05] - 1.0, 1.4156618886045364 / 1.3228756555322954 - 1.0, 1.0e-10)
}

# the engines' complex arithmetic differs in the last few bits; the curves are drawn
# from values rounded to 1e-10, well below what a plot can show
damp_plot = zeros(N_K)
w_re_plot = zeros(N_K)
for i to N_K {
    damp_plot[i] = round(1.0e10 * damp[i]) / 1.0e10
    w_re_plot[i] = round(1.0e10 * w_re[i]) / 1.0e10
}

figure("Damping rate of the least-damped Langmuir wave", "$k\\lambda_D$", "$\\gamma/\\omega_{pe}$")
yscale("log")
plot(ks, damp_plot, "exact root")
plot(ks, weak, "small-k formula")
style("small-k formula", "dash")

figure("Frequency of the least-damped Langmuir wave", "$k\\lambda_D$", "$\\omega_r/\\omega_{pe}$")
plot(ks, w_re_plot, "exact root")
plot(ks, bg, "Bohm-Gross, $\\sqrt{1 + 3k^2}$")
style("Bohm-Gross, $\\sqrt{1 + 3k^2}$", "dash")


###
## The wave itself

The root says how fast a wave decays; the last cell watches one decay. It
solves the linearized Vlasov equation for one Fourier mode, $f = f_0(v) +
f_1(v, t)\,e^{ikx}$ at $k\lambda_D = 0.5$, on 512 velocities from $-8$ to $8$:

$$\partial_t f_1 = -ikv\,f_1 + E_1 f_0'(v), \qquad E_1 = \frac{i}{k}\int f_1\,dv,$$

from a density bump $f_1(v, 0) = f_0(v)$, by fourth-order Runge-Kutta with
$\Delta t = 0.05$. Nothing in it knows about $Z$ or the root. The field
oscillates and its peaks fall on a straight line in $\log|E_1|$; the slope of
that line is the measured damping rate, and the spacing of the peaks, half a
period, gives the frequency. Both are compared with the root above.
###

VL_NV = 512
VL_VMAX = 8.0
VL_DT = 0.05
VL_STEPS = 800
# peaks before this time still carry the faster roots
VL_T_SKIP = 8.0
# every this many steps the field is recorded for the figure
VL_EVERY = 2
VL_MAX_PEAKS = 64

vl_v = linspace(-VL_VMAX, VL_VMAX, VL_NV)
vl_dv = vl_v[1] - vl_v[0]
vl_f0 = exp(-0.5 * vl_v * vl_v) / sqrt(2.0 * 3.141592653589793)
vl_df0 = -vl_v * vl_f0
vl_kv = K0 * vl_v

#| The field of the perturbation (fr + i fi): E1 = (i / k) * integral of f1 dv.
#@ 0a19710e
def vlasov_field(fr: Arr, fi: Arr) {
    sr = sum(fr) * vl_dv
    si = sum(fi) * vl_dv
    return -si / K0, sr / K0
}

#| The time derivative of f1 in the linearized Vlasov equation, as its real and imaginary parts.
#@ 7a194a57
def vlasov_rate(fr: Arr, fi: Arr) {
    er, ei = vlasov_field(fr, fi)
    return vl_kv * fi + er * vl_df0, -vl_kv * fr + ei * vl_df0
}

vl_fr = vl_f0 + 0.0
vl_fi = zeros(VL_NV)
vl_nrec = VL_STEPS / VL_EVERY + 1
vl_t = zeros(vl_nrec)
vl_er = zeros(vl_nrec)
vl_ei = zeros(vl_nrec)
vl_abs = zeros(VL_STEPS + 1)
e0r, e0i = vlasov_field(vl_fr, vl_fi)
vl_abs[0] = sqrt(e0r * e0r + e0i * e0i)
vl_er[0] = e0r
vl_ei[0] = e0i
for n to VL_STEPS {
    a1r, a1i = vlasov_rate(vl_fr, vl_fi)
    a2r, a2i = vlasov_rate(vl_fr + 0.5 * VL_DT * a1r, vl_fi + 0.5 * VL_DT * a1i)
    a3r, a3i = vlasov_rate(vl_fr + 0.5 * VL_DT * a2r, vl_fi + 0.5 * VL_DT * a2i)
    a4r, a4i = vlasov_rate(vl_fr + VL_DT * a3r, vl_fi + VL_DT * a3i)
    vl_fr = vl_fr + (VL_DT / 6.0) * (a1r + 2.0 * a2r + 2.0 * a3r + a4r)
    vl_fi = vl_fi + (VL_DT / 6.0) * (a1i + 2.0 * a2i + 2.0 * a3i + a4i)
    enr, eni = vlasov_field(vl_fr, vl_fi)
    vl_abs[n + 1] = sqrt(enr * enr + eni * eni)
    if mod(n + 1, VL_EVERY) == 0 {
        rec = (n + 1) / VL_EVERY
        vl_t[rec] = (n + 1) * VL_DT
        vl_er[rec] = enr
        vl_ei[rec] = eni
    }
}

# the peaks of |E1|, each placed by a parabola through log|E1| at three steps
peak_t = zeros(VL_MAX_PEAKS)
peak_log = zeros(VL_MAX_PEAKS)
n_peaks = 0
for n from 1 to VL_STEPS {
    if n * VL_DT > VL_T_SKIP and vl_abs[n] > vl_abs[n - 1] and vl_abs[n] >= vl_abs[n + 1] and n_peaks < VL_MAX_PEAKS {
        ym = log(vl_abs[n - 1])
        y0 = log(vl_abs[n])
        yp = log(vl_abs[n + 1])
        shift = 0.5 * (ym - yp) / (ym - 2.0 * y0 + yp)
        peak_t[n_peaks] = (n + shift) * VL_DT
        peak_log[n_peaks] = y0 - 0.25 * (ym - yp) * shift
        n_peaks = n_peaks + 1
    }
}
# least-squares lines: log peak against time, and peak time against its index
pt = peak_t[0:n_peaks]
pl = peak_log[0:n_peaks]
pi_idx = linspace(0.0, n_peaks - 1.0, n_peaks)
mt = sum(pt) / n_peaks
ml = sum(pl) / n_peaks
mi = sum(pi_idx) / n_peaks
gamma_measured = -sum((pt - mt) * (pl - ml)) / sum((pt - mt) * (pt - mt))
half_period = sum((pi_idx - mi) * (pt - mt)) / sum((pi_idx - mi) * (pi_idx - mi))
omega_measured = 3.141592653589793 / half_period
gamma_root = -im(root)
show n_peaks
show gamma_measured
show gamma_root
show omega_measured
gamma_rel_gap = abs(gamma_measured / gamma_root - 1.0)
omega_rel_gap = abs(omega_measured / re(root) - 1.0)
show gamma_rel_gap
show omega_rel_gap

# the line through the peaks at the root's rate, from the measured fit's height at t = 0
fit_log0 = ml + gamma_measured * mt
vl_line = zeros(vl_nrec)
for i to vl_nrec {
    vl_line[i] = round(1.0e10 * exp(fit_log0 - gamma_root * vl_t[i])) / 1.0e10
    vl_er[i] = round(1.0e10 * vl_er[i]) / 1.0e10
    vl_ei[i] = round(1.0e10 * vl_ei[i]) / 1.0e10
}
figure("The field of a Langmuir wave at k lambda_D = 0.5, from the linearized Vlasov equation", "t omega_pe", "E1")
plot(vl_t, vl_er, "Re E1")
plot(vl_t, vl_ei, "Im E1")
plot(vl_t, vl_line, "decay at the root's rate")
style("decay at the root's rate", "dash")

test "the measured damping rate and frequency are the root's within 1e-4, relative" {
    assert_lt(gamma_rel_gap, 1.0e-4)
    assert_lt(omega_rel_gap, 1.0e-4)
    assert_lt(10.0, n_peaks)
}