History / release 1 · saved , the current version · released as https://doi.org/10.83371/sh.2610.0wmh7
Cite this release
@software{tokamak_orbits_2026, author = {ScienceHub}, title = {Tokamak orbits}, version = {1}, year = {2026}, publisher = {ScienceHub}, doi = {10.83371/sh.2610.0wmh7} }
ScienceHub (2026). Tokamak orbits (Version 1) [Computer software]. ScienceHub. https://doi.org/10.83371/sh.2610.0wmh7cff-version: 1.2.0 message: "If you use this version, cite it as below." title: "Tokamak orbits" type: software authors: - name: "ScienceHub" version: "1" date-released: "2026-10-13" doi: "10.83371/sh.2610.0wmh7" url: "https://doi.org/10.83371/sh.2610.0wmh7"
The DOI names exactly this release. The project page cites all releases.
tokamak-orbits.py
# /// script
# requires-python = ">=3.12"
# dependencies = ["marimo>=0.25.1", "numpy", "matplotlib", "contourpy"]
# ///
import marimo
__generated_with = "0.25.1"
app = marimo.App(width="medium")
@app.cell(hide_code=True)
def _(mo):
mo.md(
r"""
# Will a fusion alpha stay in the plasma?
Deuterium-tritium fusion releases a 3.5 MeV helium nucleus (an alpha
particle) with every reaction. A burning plasma heats itself only if
those alphas stay confined long enough to hand their energy to the fuel.
In a tokamak a particle follows a magnetic field line around the torus
while its guiding centre drifts. Three quantities are conserved on that
path: the energy $E$, the magnetic moment $\mu = m v_\perp^2 / 2B$, and
the canonical toroidal momentum
$$P_\varphi = m R\, v_\parallel - Z e\, \psi(r),$$
where $\psi$ is the poloidal magnetic flux. Every orbit is a curve on
which all three stay fixed, so it can be drawn without integrating the
motion. The machine below has the size and field of ITER.
"""
)
return
@app.cell
def _():
import marimo as mo
import numpy as np
import matplotlib.pyplot as plt
import contourpy
return contourpy, mo, np, plt
@app.cell
def _(np):
E_CHARGE = 1.602e-19
AMU = 1.6605e-27
R0, A, B0 = 6.2, 2.0, 5.3 # major radius (m), minor radius (m), field on axis (T)
Q0, QA = 1.0, 3.0 # safety factor on axis and at the edge
PARTICLES = {
"Alpha (helium-4)": (4.0015 * AMU, 2),
"Deuteron": (2.0136 * AMU, 1),
"Triton": (3.0155 * AMU, 1),
}
def safety_factor(r):
return Q0 + (QA - Q0) * (r / A) ** 2
def poloidal_flux(r):
"""psi(r) = integral of B0 r / q(r) dr for the parabolic q profile."""
k = (QA - Q0) / A**2
return B0 / (2 * k) * np.log(1 + k * r**2 / Q0)
def field(R):
return B0 * R0 / R
return A, B0, E_CHARGE, PARTICLES, R0, field, poloidal_flux, safety_factor
@app.cell
def _(A, PARTICLES, mo):
particle = mo.ui.dropdown(list(PARTICLES), value="Alpha (helium-4)", label="Particle")
energy = mo.ui.slider(0.05, 3.5, step=0.05, value=3.5, label="Energy (MeV)")
start = mo.ui.slider(0.1, 0.9, step=0.01, value=0.5, label="Start radius r / a")
pitch = mo.ui.slider(-1.0, 1.0, step=0.01, value=0.3, label="Pitch v∥ / v at start")
mo.vstack([mo.hstack([particle, energy], justify="start"), start, pitch])
return energy, particle, pitch, start
@app.cell
def _(A, E_CHARGE, R0, contourpy, field, np, poloidal_flux):
GRID_R = np.linspace(R0 - A, R0 + A, 320)
GRID_Z = np.linspace(-A, A, 320)
MESH_R, MESH_Z = np.meshgrid(GRID_R, GRID_Z)
MESH_r = np.hypot(MESH_R - R0, MESH_Z)
def orbit(mass, charge, energy_mev, r_start, lam):
"""Guiding-centre orbit of a particle launched on the outboard midplane.
Returns the orbit as an (n, 2) array of (R, Z) and its class:
"passing", "trapped", or "lost" when it reaches the wall.
"""
v = np.sqrt(2 * energy_mev * 1e6 * E_CHARGE / mass)
R_s = R0 + r_start
lam = np.clip(lam, -0.999, 0.999)
if abs(lam) < 1e-3:
lam = 1e-3
mu = mass * v**2 * (1 - lam**2) / (2 * field(R_s))
ze = charge * E_CHARGE
p_phi = mass * R_s * lam * v - ze * poloidal_flux(r_start)
# v_par from P_phi must agree with v_par from E and mu: the zero set of H.
v_par = (p_phi + ze * poloidal_flux(MESH_r)) / (mass * MESH_R)
H = (v_par**2 + 2 * mu * field(MESH_R) / mass - v**2) / v**2
lines = contourpy.contour_generator(GRID_R, GRID_Z, H).lines(0.0)
if not lines:
return np.array([[R_s, 0.0]]), "lost"
path = min(lines, key=lambda seg: np.min(np.hypot(seg[:, 0] - R_s, seg[:, 1])))
r_path = np.hypot(path[:, 0] - R0, path[:, 1])
closed = np.allclose(path[0], path[-1], atol=1e-6)
if r_path.max() > 0.985 * A or not closed:
return path, "lost"
theta = np.unwrap(np.arctan2(path[:, 1], path[:, 0] - R0))
return path, "passing" if np.ptp(theta) > 1.9 * np.pi else "trapped"
return (orbit,)
@app.cell
def _(A, PARTICLES, R0, energy, np, orbit, particle, pitch, plt, start):
mass, charge = PARTICLES[particle.value]
path, kind = orbit(mass, charge, energy.value, start.value * A, pitch.value)
width = np.ptp(np.hypot(path[:, 0] - R0, path[:, 1]))
colour = {"passing": "#0969da", "trapped": "#9a6700", "lost": "#cf222e"}[kind]
fig, ax = plt.subplots(figsize=(5.4, 6))
circle = np.linspace(0, 2 * np.pi, 200)
for rho in (0.25, 0.5, 0.75):
ax.plot(R0 + rho * A * np.cos(circle), rho * A * np.sin(circle), color="#d0d7de", lw=0.8)
ax.plot(R0 + A * np.cos(circle), A * np.sin(circle), color="#57606a", lw=1.5)
ax.plot(*path.T, color=colour, lw=2)
ax.plot(R0 + start.value * A, 0, "o", color=colour)
ax.plot(R0, 0, "+", color="#57606a")
ax.set(aspect="equal", xlabel="R (m)", ylabel="Z (m)")
ax.set_title(f"{kind.capitalize()} orbit, radial width {width * 100:.0f} cm")
fig.tight_layout()
fig
return charge, kind, mass
@app.cell(hide_code=True)
def _(mo):
mo.md(
r"""
The grey circles are magnetic surfaces at a quarter, half, and three
quarters of the minor radius; the dark circle is the plasma edge.
The magnetic field is stronger on the inside of the torus ($B \propto
1/R$). A particle moving mostly across the field is reflected by that
stronger field, the way a mirror reflects it, and its drift traces the
**banana** shape. A particle moving mostly along the field passes
around the whole torus. A 3.5 MeV alpha has a wide banana. Lower its
energy, or switch to a deuteron of the same energy, and the orbit
shrinks towards its magnetic surface.
"""
)
return
@app.cell(hide_code=True)
def _(mo):
mo.md(
r"""
## Which pitches are trapped
In the limit of thin orbits a particle starting on the outboard midplane
at inverse aspect ratio $\varepsilon = r/R_0$ is trapped when
$|v_\parallel / v| < \sqrt{2\varepsilon / (1 + \varepsilon)}$.
The scan below launches 81 particles from the start radius you chose and
compares each orbit with that estimate.
"""
)
return
@app.cell
def _(A, R0, charge, energy, mass, np, orbit, plt, start):
pitches = np.linspace(-0.99, 0.99, 81)
scan = [orbit(mass, charge, energy.value, start.value * A, lam) for lam in pitches]
widths = np.array([np.ptp(np.hypot(_p[:, 0] - R0, _p[:, 1])) for _p, _ in scan])
kinds = np.array([_k for _, _k in scan])
eps = start.value * A / R0
lam_c = np.sqrt(2 * eps / (1 + eps))
fig2, ax2 = plt.subplots(figsize=(7, 3.4))
for _kind, _colour in (("passing", "#0969da"), ("trapped", "#9a6700"), ("lost", "#cf222e")):
_chosen = kinds == _kind
ax2.scatter(pitches[_chosen], widths[_chosen] * 100, s=14, color=_colour, label=_kind)
for edge in (-lam_c, lam_c):
ax2.axvline(edge, color="#57606a", ls="--", lw=0.8)
ax2.set(xlabel="pitch v∥ / v at start", ylabel="orbit width (cm)")
ax2.set_ylim(0, widths.max() * 125)
ax2.legend(frameon=False, ncol=3, loc="upper center")
fig2.tight_layout()
fig2
return
@app.cell(hide_code=True)
def _(mo):
mo.md(
r"""
---
**Model and limits.** Circular, concentric magnetic surfaces; toroidal
field $B_0 R_0 / R$ with $R_0 = 6.2$ m, $a = 2.0$ m, $B_0 = 5.3$ T;
safety factor $q(r) = 1 + 2(r/a)^2$; guiding-centre approximation with
$|B| \approx B_\varphi$. No collisions, no electric field, no ripple
from the discrete coils, so this shows orbit topology, not loss rates.
An orbit is counted as lost when it reaches the plasma edge.
**References.** J. Wesson, *Tokamaks*, 4th ed. (2011), ch. 3;
ITER Physics Basis, Nucl. Fusion 39 (1999) 2137, ch. 5.
Fork it to add a realistic plasma shape or a ripple field.
"""
)
return
if __name__ == "__main__":
app.run()