—
Field-driven ionization then relaxation of a 1D plasma channel (n_e video)#
A transient 1D radial run of PlasmaChannel
showing an initially ionized channel that ionizes further under an applied electric
field and then relaxes once the field switches off, rendering a video of the
electron number density n_e(r, t) along the radius.
What this exercises#
Every piece added for this scenario:
an explicit non-uniform radial grid supplied from Python (Cantera-flame style,
grid=->Domain1D::setupGrid), clustered near the axis;an arbitrary field schedule
E(t)passed from Python as a(t, E)table (here a trapezoidal pulse: ramp up, hold, ramp down) – any waveform works;Joule heating with
sigma = n_e e^2 / (m_e nu_m)from the localn_e;the stiff BDF backend (
integrator="bdf", Cantera’s CVODE over the method-of-lines RHS), which integrates the field-driven plasma chemistry that the fixed-step Newton solver cannot;the solver-recorded
n_e(r, t)(electron_density()).
Mechanism note#
This runs on the air mechanism air_plasma_Laux2000 (native two-temperature-
plasma rates), chosen here because air ionizes strongly at the few-thousand-K
temperatures used, giving a clear n_e swing. The CH4 Goutier2025 mechanism also
runs in this 1D channel (its electron-impact rates are finite up to >=3 eV; use
integrator="bdf" with a gentle Te=Tg start and a modest field, mirroring
rizer’s 0D NRP reactor) – but CH4 only ionizes weakly below ~1 eV, so a visible
field-driven n_e rise needs a higher electron temperature than is comfortable for the
solver here. Air makes the ionization-then-relaxation physics easiest to see; the
pipeline is identical for either mechanism.
Import the required libraries.#
import cantera as ct
import numpy as np
from rizer.cantera_ext import PlasmaChannel
from rizer.misc.plt_utils import set_mpl_style
from rizer.misc.utils import get_path_to_data
set_mpl_style()
Scenario setup.#
mechanism = str(get_path_to_data("mechanisms") / "air_plasma_Laux2000.yaml")
phase = "plasma"
# -- Scenario parameters ------------------------------------------------ #
wall_radius = 4.0e-3 # Wall radius [m]
wall_temperature = 2000.0 # Wall/ambient temperature [K]
initial_channel_width = 500.0e-6 # Initial channel width (FWHM) [m]
core_temperature = 7000.0 # Initial gas/electron temperature on axis [K]
# -- Field pulse parameters ---------------------------------------------- #
field_peak = 2.0e5 # Peak applied field [V/m]
t_rise = 30.0e-9 # Field pulse rise time [s]
t_hold = 90.0e-9 # Field pulse hold time [s]
t_fall = 40.0e-9 # Field pulse fall time [s]
t_end = 250.0e-9 # Total simulated time [s]
# -- Collision frequency model -------------------------------------------- #
nu_m = 1.0e12 # e-neutral momentum-transfer frequency [1/s]
# -- Solver parameters ----------------------------------------------------- #
n_points = 31 # Number of radial grid points [-]
dt = 1.0e-9 # Output cadence / initial BDF step [s]
# -- Derived quantities ---------------------------------------------------- #
sigma_gaussian = initial_channel_width / (
2.0 * np.sqrt(2.0 * np.log(2.0))
) # Gaussian std dev of the initial channel [m]
Define the helper functions.#
The first builds a non-uniform radial grid, the second builds the initial hot
channel in local LTE, the third defines the trapezoidal field pulse, and the last
renders the n_e(r, t) video from the solved channel.
def stretched_grid(R, n, beta=3.5):
"""Non-uniform radial grid on [0, R], fine near r=0 (sinh stretching)."""
u = np.linspace(0.0, 1.0, n)
return R * np.sinh(beta * u) / np.sinh(beta)
def build_initial_state(r):
"""Hot Gaussian channel in local LTE (consistent initial ionization)."""
g = np.exp(-(r**2) / (2.0 * sigma_gaussian**2))
Tg = wall_temperature + (core_temperature - wall_temperature) * g
gas = ct.Solution(mechanism, phase)
gas.TPX = wall_temperature, ct.one_atm, "N2:0.79, O2:0.21"
gas.equilibrate("TP")
Y0 = gas.Y.copy() # ambient/wall composition
gas.TPX = (
0.5 * (core_temperature + wall_temperature),
ct.one_atm,
"N2:0.79, O2:0.21",
)
rho = float(gas.density) # one (constant-volume) density
Y_2d = np.zeros((len(r), gas.n_species))
for i in range(len(r)):
gas.TPX = Tg[i], ct.one_atm, "N2:0.79, O2:0.21"
gas.equilibrate("TP")
Y_2d[i] = gas.Y
return rho, Y0, Tg, Y_2d
def field_pulse():
"""Trapezoidal E(t): 0 ->(rise) field_peak ->(hold) field_peak ->(fall) 0 -> 0."""
t1, t2, t3 = t_rise, t_rise + t_hold, t_rise + t_hold + t_fall
return (
np.array([0.0, t1, t2, t3, t_end]),
np.array([0.0, field_peak, field_peak, 0.0, 0.0]),
)
def make_video(ch, path="channel_ne.mp4"):
"""Animate n_e(r, t); save mp4 (ffmpeg) -> gif (pillow) -> skip. Returns path or None."""
import matplotlib.pyplot as plt
from matplotlib.animation import FFMpegWriter, FuncAnimation, PillowWriter
r_mm = ch.r * 1e3
ne = ch.electron_density() * 1e-6 # cm^-3
t = ch.t
t_off = t_rise + t_hold + t_fall
fig, ax = plt.subplots(figsize=(7, 4.5))
(line,) = ax.semilogy([], [], "o-", color="crimson", ms=3)
ax.set_xlim(0, r_mm.max())
ax.set_ylim(ne.min() * 0.7, ne.max() * 1.5)
ax.set_xlabel("Radius [mm]")
ax.set_ylabel(r"$n_e$ [cm$^{-3}$]")
title = ax.set_title("")
def update(f):
line.set_data(r_mm, ne[f])
phase = "field ON" if t[f] <= t_off else "field OFF (relaxing)"
title.set_text(f"Air channel t = {t[f] * 1e9:5.0f} ns ({phase})")
return line, title
anim = FuncAnimation(fig, update, frames=len(t), interval=120, blit=False)
saved = None
for writer, p in (
(FFMpegWriter(fps=8), path),
(PillowWriter(fps=8), path.replace(".mp4", ".gif")),
):
try:
anim.save(p, writer=writer)
saved = p
break
except Exception as exc: # noqa: BLE001
print(f" ({type(writer).__name__} unavailable: {exc})")
plt.close(fig)
return saved
Run the transient 1D channel solve.#
print(
f"Air channel: E = {field_peak / 1e3:.0f} kV/m pulse "
f"({t_rise * 1e9:.0f}/{t_hold * 1e9:.0f}/{t_fall * 1e9:.0f} ns), total {t_end * 1e9:.0f} ns"
)
r = stretched_grid(wall_radius, n_points)
rho, Y0, Tg, Y_2d = build_initial_state(r)
nu_E = (2.0 * 9.1093837015e-31 / (29.0e-3 / ct.avogadro)) * nu_m
n_steps = int(round(t_end / dt))
channel = PlasmaChannel(
mechanism,
phase,
wall_radius,
rho,
Y0,
grid=r,
Tg_profile=(r, Tg),
Te_profile=(r, Tg),
Y0_profile=(r, Y_2d),
T_amb=wall_temperature,
nu_m=nu_m,
nu_E=nu_E,
electric_field=field_pulse(), # arbitrary E(t) table from Python
kappa=2.0,
kappa_e=0.1,
reacting=True,
integrator="bdf", # stiff CVODE backend
dt=dt,
n_steps=n_steps,
record_every=max(n_steps // 40, 1),
)
Print the axis electron density and temperature evolution.#
ne = channel.electron_density()
Tg_result, Te_result = channel.temperature_profiles()
print(f"axis n_e: {ne[0, 0]:.2e} -> peak {ne[:, 0].max():.2e} -> {ne[-1, 0]:.2e} m^-3")
print(
f"axis Te : {Te_result[0, 0]:.0f} -> peak {Te_result[:, 0].max():.0f} -> "
f"{Te_result[-1, 0]:.0f} K"
)
Render the electron density video.#
saved = make_video(channel)
if saved:
print(f"saved n_e animation -> {saved}")
Plot the static summary figure.#
import matplotlib.pyplot as plt
r_mm = channel.r * 1e3
idx = np.unique(np.linspace(0, len(channel.t) - 1, 6).astype(int))
fig, (axn, axt) = plt.subplots(1, 2, figsize=(11, 4.2))
for f in idx:
axn.semilogy(
r_mm, channel.electron_density()[f] * 1e-6, label=f"{channel.t[f] * 1e9:.0f} ns"
)
axn.set_title("Electron density n_e(r) (snapshots)")
axn.set_xlabel("Radius [mm]")
axn.set_ylabel(r"$n_e$ [cm$^{-3}$]")
axn.legend(fontsize="small")
axt.plot(channel.t * 1e9, ne[:, 0] * 1e-6, "o-", color="crimson", label=r"$n_e$ axis")
axt.set_xlabel("Time [ns]")
axt.set_ylabel(r"$n_e$ axis [cm$^{-3}$]")
axt.axvspan(
0,
(t_rise + t_hold + t_fall) * 1e9,
color="gold",
alpha=0.25,
label="field on",
)
axt.set_title("Axis electron density vs time")
axt.legend(fontsize="small")
fig.tight_layout()
plt.show()