Branching processes: why one neutron rarely starts a lasting fission chain
Afterwards you can model a fission chain as a branching process, find its survival probability from the generating function, and check it with NumPy.
- Topic
- Stochastic processes
- Field
- Engineering, Physics
- Libraries
matplotlib 3.11.2numpy 2.4.3
py-branching-process.ipynb, executed with the versions above. The download needs a free account
Run it yourself. In a terminal, this installs exactly the versions above:
pip install numpy==2.4.3 matplotlib==3.11.2 jupyterlabThe question
A research reactor is brought to k = 1.001 with no neutron source inside: on average, 1,000 neutrons in one generation make 1,001 in the next. One stray neutron starts a chain. It causes a fission with probability p = 0.4120, and otherwise it is captured or leaks out. A fission yields ν neutrons. Most are released at the moment of fission, 0 to 7 of them, with the measured distribution for uranium-235 split by a slow neutron. With probability 0.0158 a delayed neutron follows, released a fraction of a second to about a minute later by a decaying fission fragment. That makes ν̄ = 2.430 neutrons per fission on average, and k = p ν̄ counts both kinds. Whether the chain lasts is a question about a branching process. Here are 40 such chains and the first lasting one among 10,000:
Show code
import numpy as np
import matplotlib.pyplot as plt
plt.rcParams.update({
"figure.figsize": (7, 3.6), "figure.dpi": 110,
"axes.spines.top": False, "axes.spines.right": False,
"axes.grid": True, "grid.alpha": 0.25,
"font.size": 11, "lines.linewidth": 1.8,
})
INK, ACCENT, SECOND, MUTED = "#1f2a44", "#c8553d", "#2a7f9e", "#8a8f98"
# Neutrons released at the moment of a thermal fission of U-235, nu = 0..7, from N. E. Holden and
# M. S. Zucker, BNL-NCS-35513 (1985), https://www.osti.gov/servlets/purl/6205262.
# The eight numbers sum to 1.0000003, so they are normalized.
P_AT_ONCE = np.array([0.0317223, 0.1717071, 0.3361991, 0.3039695, 0.1269459, 0.0266793, 0.0026322, 0.0001449])
P_AT_ONCE /= P_AT_ONCE.sum()
NU_DELAYED = 0.0158 # delayed neutrons per fission; never more than one counted
P_NU = (1 - NU_DELAYED) * np.append(P_AT_ONCE, 0) + NU_DELAYED * np.append(0, P_AT_ONCE)
NU = np.arange(P_NU.size) # 0..8 neutrons per fission
nu_bar = P_NU @ NU
D = P_NU @ (NU * (NU - 1)) / nu_bar**2 # Diven factor: the spread of nu that matters here
print(f"neutrons per fission: released at once {P_AT_ONCE @ np.arange(8):.3f}, in all {nu_bar:.3f}; D = {D:.3f}")
def offspring_pmf(k, P=P_NU):
"""P(X = j), the children of one neutron: no fission with chance 1 - p, else nu children from P."""
p = k / (P @ np.arange(P.size))
pmf = p * P
pmf[0] += 1 - p
return pmf
# The chains are simulated by the method this tutorial explains, so that the answer
# can be checked at the end. Pretend you have not seen this cell.
rng = np.random.default_rng(20261010)
def simulate(k, n_chains, cap=10_000, record=False):
"""Chains of one neutron each, run until every chain is dead or has reached cap neutrons."""
pmf = offspring_pmf(k)
children = np.arange(pmf.size)
Z = np.ones(n_chains, dtype=np.int64)
death = np.zeros(n_chains, dtype=int) # generation in which the chain died, 0 if it lasted
lasting = np.zeros(n_chains, dtype=bool)
alive = np.arange(n_chains)
log, n = [], 0
while alive.size:
n += 1
# sort the Z neutrons of each chain by their number of children, then add the children up
Z_new = rng.multinomial(Z[alive], pmf) @ children
if record:
log.append((alive, Z_new))
Z[alive] = Z_new
death[alive[Z_new == 0]] = n
lasting[alive[Z_new >= cap]] = True
alive = alive[(Z_new > 0) & (Z_new < cap)]
assert np.all(lasting != (death > 0)) # every chain decided, one way or the other
return death, lasting, log
def path(log, i):
"""Neutron numbers of chain i, generation by generation, from a recorded run."""
Z = [1]
for alive, Z_new in log:
j = np.searchsorted(alive, i)
if j == alive.size or alive[j] != i:
break
Z.append(Z_new[j])
return np.array(Z)
K_REACTOR = 1.001
death, lasting, log = simulate(K_REACTOR, 10_000, record=True)
dead = death > 0
print(f"dead after generation 1: {np.mean(death == 1):.1%}, by generation 10: {np.mean(dead & (death <= 10)):.1%}")
print(f"dead chains: median lifetime {np.median(death[dead]):.0f} generation, longest {death.max():,} generations")
print(f"lasting (reached 10,000 neutrons): {lasting.sum()} of {death.size:,}; among the first 40: {lasting[:40].sum()}")
paths = [path(log, i) for i in range(40)]
first_lasting = path(log, np.flatnonzero(lasting)[0])
print(f"the first lasting chain passes 10,000 neutrons in generation {first_lasting.size - 1:,}, "
f"where the mean is 1.001ⁿ = {K_REACTOR ** (first_lasting.size - 1):.2f}")
fig, ax = plt.subplots(figsize=(8, 3.6))
for Z in paths:
ax.step(np.arange(Z.size), Z, where="post", color=MUTED, lw=1, alpha=0.7)
ax.plot(Z.size - 1, 0, "o", ms=4, color=MUTED, clip_on=False)
ax.step(np.arange(first_lasting.size), first_lasting, where="post", color=ACCENT, lw=1.4)
n = np.arange(first_lasting.size)
ax.plot(n, K_REACTOR**n, color=SECOND, lw=1.2, ls="--")
ax.text(first_lasting.size - 1, 1.6 * K_REACTOR ** n[-1], "mean 1.001ⁿ", color=SECOND, ha="right", va="bottom")
ax.text(300, 250, "a lasting chain", color=ACCENT, ha="center", va="top")
ax.text(0.05, 3000, f"{100 * np.mean(death == 1):.1f} % of 10,000 chains\nare dead after one generation", color=INK, va="center")
ax.set_xscale("symlog", linthresh=1, linscale=0.4) # room for the first generations, zero included
ax.set_yscale("symlog", linthresh=1, linscale=0.4) # zero gets a row of its own
ax.minorticks_off()
ticks = [0, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]
ax.set(xlim=(0, first_lasting.size), ylim=(0, 2e4), xticks=ticks, xticklabels=[f"{t:,}" for t in ticks],
yticks=[0, 1, 10, 100, 1000, 10_000], yticklabels=["0", "1", "10", "100", "1,000", "10,000"],
xlabel="generation n", ylabel="neutrons Zₙ")
plt.show()
neutrons per fission: released at once 2.414, in all 2.430; D = 0.799 dead after generation 1: 60.6%, by generation 10: 91.8% dead chains: median lifetime 1 generation, longest 2,384 generations lasting (reached 10,000 neutrons): 9 of 10,000; among the first 40: 0 the first lasting chain passes 10,000 neutrons in generation 1,378, where the mean is 1.001ⁿ = 3.96
Of the 10,000 chains, 60.6 % are dead after one generation and 91.8 % by generation 10, and the median dead chain lived for a single generation. The mean, 1.001ⁿ, is still below 4 at generation 1,378, where the lasting chain passes 10,000 neutrons. That is where the simulation calls a chain lasting, because from there its chance of still dying is negligible; the number comes at the end. Nine chains of 10,000 got there. The longest-lived chain that did die held on for 2,384 generations.
The mean says the neutron population grows, and still most chains die at once. The question is what fraction of single neutrons starts a chain that never dies, and how that fraction depends on k and on the spread of ν. The answer also explains why a reactor startup insists on a neutron source when in principle one neutron is enough.
The idea: a chain dies only if every branch dies
Follow one neutron. Either it causes no fission, with probability 1 − p, or it causes one and has ν children drawn from P(ν). Every neutron does this independently, with the same odds, whatever happened to its ancestors and siblings. A chain built this way is a Galton-Watson branching process, and the number of children X of one neutron is its offspring distribution:
Show code
pmf_X = offspring_pmf(K_REACTOR)
print(f"P(X = 0) = {pmf_X[0]:.3f}, mean of X = {pmf_X @ NU:.4f}")
print(f"simulated share of chains dead after one generation {np.mean(death == 1):.3f}")
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(7, 4.4), sharex=True)
ax1.bar(NU, P_NU, width=0.7, color=INK)
ax1.axvline(nu_bar, color=MUTED, lw=1, ls="--")
ax1.text(nu_bar + 0.12, 0.37, f"mean ν̄ = {nu_bar:.3f}", color=MUTED, va="center")
ax1.set(ylabel="P(ν)", ylim=(0, 0.4))
ax2.bar(NU, pmf_X, width=0.7, color=ACCENT)
ax2.text(0.42, pmf_X[0], f"{pmf_X[0]:.3f}", color=ACCENT, va="center")
ax2.set(ylabel="P(X)", xlabel="neutrons per fission ν (top), children of one neutron X (bottom)",
xticks=NU, ylim=(0, 0.7))
plt.show()
P(X = 0) = 0.601, mean of X = 1.0010 simulated share of chains dead after one generation 0.606
The chance of no children at all is P(X = 0) = 1 − p + p P(ν = 0) = 0.601, which is the first-generation death share of the question seen from the other side. The mean of X is k = 1.001.
A chain started by one neutron dies if and only if each of the chains started by its children dies. Those subchains are independent, and each is a copy of the whole problem: one neutron, the same odds. So if q is the probability that a chain dies, a neutron with j children has a line that dies with probability \(q^j\), and summing over j,
The right side is a power series in q. Write it with a free variable s and it gets its name, the generating function of X:
with ⟨·⟩ the average over X. For fission, by the same two cases as above, f(s) = 1 − p + p g(s), where \(g(s) = \sum_\nu P(\nu)\, s^\nu\) is the generating function of the fission itself. The extinction probability q is a point where the curve f(s) meets the diagonal. Here it is at three values of k:
Show code
def f(s, pmf):
return np.polynomial.polynomial.polyval(s, pmf)
s = np.linspace(0, 1, 300)
fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(s, s, color=MUTED, lw=1, ls="--")
for k, alpha in [(0.8, 0.35), (1.0, 0.65), (1.3, 1.0)]:
ax.plot(s, f(s, offspring_pmf(k)), color=INK, alpha=alpha)
ax.text(0.02, f(0, offspring_pmf(k)) + 0.025, f"k = {k}", color=INK, alpha=alpha, va="bottom")
q = 0.0
for _ in range(500): # q_n = f(q_(n-1)) from q_0 = 0, as explained below
q = f(q, offspring_pmf(1.3))
ax.plot([q, 1], [q, 1], "o", ms=6, color=ACCENT)
ax.text(q + 0.02, q - 0.04, f"q = {q:.3f}", color=ACCENT, va="top")
ax.set(xlabel="s", ylabel="f(s)", xlim=(0, 1.02), ylim=(0, 1.02))
plt.show()
Every curve ends at f(1) = 1, because the probabilities add up to one, so s = 1 always solves f(s) = s. The slope there is f′(1) = Σ j P(X = j) = k. All coefficients of f are nonnegative, so f bends upward and meets a straight line at most twice. For k = 0.8 and 1.0 the curve stays above the diagonal and meets it only at 1. For k = 1.3 it arrives at 1 steeper than the diagonal, so it must have crossed it once before, at q = 0.728.
Of the two roots, the chain takes the smaller. Let \(q_n\) be the probability that the chain is dead by generation n. It is dead by then exactly when every child's subchain is dead within the n − 1 generations left to it, so \(q_n = f(q_{n-1})\), starting from \(q_0 = 0\), since one neutron is alive at generation 0. Because f increases, a value below q is mapped to a value below q: the sequence climbs, stops at the smaller root, and never reaches 1. A cobweb draws this iteration. From the diagonal go up to the curve, across to the diagonal, and repeat:

Show code
"""Extinction cobweb: the iteration q_n = f(q_(n-1)) at k = 1.3, against simulated chains.
Renders ../../assets/extinction-cobweb.gif. Top: the generating function f(s) of the children
of one neutron, the diagonal, and the cobweb from q_0 = 0, one segment at a time. Bottom: q_n
against generation n, the predicted steps and the share of 10,000 simulated chains dead by
generation n. Run it from any directory:
python scene.py
"""
from pathlib import Path
import matplotlib
import numpy as np
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation, PillowWriter
from PIL import Image
OUT = Path(__file__).resolve().parents[2] / "assets" / "extinction-cobweb.gif"
INK, ACCENT, SECOND, MUTED = "#1f2a44", "#c8553d", "#2a7f9e", "#8a8f98"
plt.rcParams.update({"axes.spines.top": False, "axes.spines.right": False,
"axes.grid": True, "grid.alpha": 0.25, "font.size": 11})
# ---- the model of the tutorial: neutrons per fission of U-235, one delayed neutron with chance 0.0158
P_AT_ONCE = np.array([0.0317223, 0.1717071, 0.3361991, 0.3039695, 0.1269459, 0.0266793, 0.0026322, 0.0001449])
P_AT_ONCE /= P_AT_ONCE.sum()
P_NU = (1 - 0.0158) * np.append(P_AT_ONCE, 0) + 0.0158 * np.append(0, P_AT_ONCE)
NU = np.arange(P_NU.size)
K, N_CHAINS, N_GEN = 1.3, 10_000, 14
p = K / (P_NU @ NU)
pmf = p * P_NU
pmf[0] += 1 - p
def f(s):
return np.polynomial.polynomial.polyval(s, pmf)
q = [0.0]
for n in range(N_GEN):
q.append(f(q[-1]))
q = np.array(q)
q_root = q[-1]
for _ in range(500): # the fixed point itself, for the guide line
q_root = f(q_root)
# ---- the simulated share dead by generation n, with its own generator
rng = np.random.default_rng(20261011)
Z = np.ones(N_CHAINS, dtype=np.int64)
dead = [0.0]
for n in range(N_GEN):
alive = (Z > 0) & (Z < 10_000) # a chain at 10,000 neutrons counts as lasting
Z[alive] = rng.multinomial(Z[alive], pmf) @ NU
dead.append(np.mean(Z == 0))
# ---- frames: per generation, three frames up to the curve, three across to the diagonal
FRAMES_PER_HALF, HOLD = 4, 18
frames = [(n, part, i) for n in range(1, N_GEN + 1) for part in (0, 1) for i in range(1, FRAMES_PER_HALF + 1)]
frames += [frames[-1]] * HOLD
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(7, 6.4), dpi=80, height_ratios=[1.35, 1])
fig.subplots_adjust(left=0.12, right=0.97, top=0.95, bottom=0.09, hspace=0.45)
s = np.linspace(0, 1, 300)
def update(frame):
n_now, part, i = frame
ax1.clear(); ax2.clear()
ax1.plot(s, f(s), color=INK)
ax1.plot(s, s, color=MUTED, lw=1, ls="--")
# the cobweb up to the current segment: up from (q_{n-1}, q_{n-1}) to the curve, across to the diagonal
xs, ys = [0.0], [0.0]
for n in range(1, n_now + 1):
x0, y1 = q[n - 1], q[n]
if n < n_now:
xs += [x0, y1]; ys += [y1, y1]
else:
t = i / FRAMES_PER_HALF
if part == 0:
xs.append(x0); ys.append(x0 + t * (y1 - x0))
else:
xs += [x0, x0 + t * (y1 - x0)]; ys += [y1, y1]
ax1.plot(xs, ys, color=ACCENT, lw=1.6)
ax1.plot(q_root, q_root, "o", ms=6, color=ACCENT, alpha=1.0 if n_now == N_GEN and part == 1 else 0.0)
ax1.set(xlim=(-0.015, 1), ylim=(0, 1), xlabel="s", ylabel="f(s)") # the first step runs up s = 0, off the spine
ax1.set_title(f"cobweb at k = {K}, generation {n_now}", loc="left")
shown = n_now if part == 1 else n_now - 1
gens = np.arange(shown + 1)
ax2.step(gens, q[: shown + 1], where="post", color=ACCENT)
ax2.plot(gens, dead[: shown + 1], "o", ms=5, color=INK)
ax2.axhline(q_root, color=MUTED, lw=1, ls="--")
ax2.text(N_GEN, q_root - 0.03, f"q = {q_root:.3f}", color=MUTED, ha="right", va="top")
ax2.set(xlim=(-0.3, N_GEN + 0.3), ylim=(0, 0.85), xlabel="generation n", ylabel="share dead")
ax2.text(N_GEN, 0.22, "steps: qₙ = f(qₙ₋₁)", color=ACCENT, ha="right")
ax2.text(N_GEN, 0.08, "dots: 10,000 simulated chains", color=INK, ha="right")
OUT.parent.mkdir(exist_ok=True)
FuncAnimation(fig, update, frames=frames).save(OUT, writer=PillowWriter(fps=12))
plt.close(fig)
print(f"q_n: {', '.join(f'{x:.3f}' for x in q)}")
print(f"simulated: {', '.join(f'{x:.3f}' for x in dead)}")
with Image.open(OUT) as im:
print(f"{OUT.name}: {im.width} x {im.height} px, {im.n_frames} frames, {OUT.stat().st_size / 1024:,.0f} kB")
The dots of the simulation sit on every step.
Just above critical: why the chance grows as k − 1
At k = 1.001 the second crossing is a hair below 1, and the shape of f near s = 1 decides where it is. Expand f to second order in 1 − s,
with f′(1) = k and f″(1) = ⟨X(X − 1)⟩, both read off by differentiating the power series at s = 1. Setting f(s) = s and dividing by 1 − s gives the survival probability π = 1 − q:
A slope greater than 1 pushes the crossing away from 1, and the curvature pulls it back. From f(s) = 1 − p + p g(s), f″(1) = p ⟨ν(ν − 1)⟩. With p = k/ν̄ and the Diven factor D = ⟨ν(ν − 1)⟩/ν̄², which measures the spread of ν (1 for a Poisson-distributed ν, 0.799 for the measured one), the curvature is f″(1) = k ν̄ D = 1.001 × 2.430 × 0.799 = 1.94, and π = 1.03 × 10⁻³. One neutron in 971 starts a lasting chain.
Iterating q = f(q) this close to critical takes about 10,000 steps to settle the fourth digit. Faster: divide the polynomial f(s) − s by s − 1 and take the smallest root in [0, 1) of the rest. The division matters: a root finder returns the root at 1 only to rounding, sometimes just below 1, and below critical it would then pass for q:
Show code
from numpy.polynomial import Polynomial
def extinction_q(pmf):
"""Smallest root of f(s) = s in [0, 1], with the root at s = 1 divided out first."""
g, _ = divmod(Polynomial(pmf) - Polynomial([0, 1]), Polynomial([-1, 1]))
roots = g.roots()
real = roots[np.abs(roots.imag) < 1e-12].real
inside = real[(real >= 0) & (real < 1)]
return inside.min() if inside.size else 1.0
q_root = extinction_q(pmf_X)
q = 0.0
for _ in range(10_000):
q = f(q, pmf_X)
f2 = pmf_X @ (NU * (NU - 1))
print(f"k = {K_REACTOR}: pi from the root {1 - q_root:.4e}, after 10,000 steps of q = f(q) {1 - q:.4e}")
print(f"f''(1) = {f2:.3f}, linear law 2(k - 1)/f''(1) = {2 * (K_REACTOR - 1) / f2:.4e}, one in {1 / (1 - q_root):.0f}")
print(f"sibling pairs from X = 3 alone {6 * pmf_X[3]:.2f}; children per neutron that has any {K_REACTOR / (1 - pmf_X[0]):.2f};"
f" Poisson-distributed X with mean k: f''(1) = k² = {K_REACTOR**2:.2f}")
s = np.linspace(0.995, 1, 300)
u = 1 - s
fig, ax = plt.subplots()
ax.axhline(0, color=MUTED, lw=1)
ax.plot(s, 1e6 * (f(s, pmf_X) - s), color=INK)
ax.plot(s, 1e6 * (-(K_REACTOR - 1) * u + f2 / 2 * u**2), color=SECOND, lw=1.2, ls="--")
ax.plot(q_root, 0, "o", ms=6, color=ACCENT)
ax.annotate(f"q = {q_root:.5f}", (q_root, 0), xytext=(q_root - 0.0006, 5),
color=ACCENT, ha="center", arrowprops=dict(arrowstyle="->", color=ACCENT, lw=1))
ax.text(0.9968, 15, "f(s) − s", color=INK)
ax.text(0.9968, 13.2, "second-order expansion (dashed)", color=SECOND)
ax.set(xlabel="s", ylabel="f(s) − s / 10⁻⁶", xlim=(0.995, 1))
plt.show()
k = 1.001: pi from the root 1.0303e-03, after 10,000 steps of q = f(q) 1.0303e-03 f''(1) = 1.942, linear law 2(k - 1)/f''(1) = 1.0298e-03, one in 971 sibling pairs from X = 3 alone 0.75; children per neutron that has any 2.51; Poisson-distributed X with mean k: f''(1) = k² = 1.00
Root and iteration agree in every printed digit, and the linear law is 0.05 % off.
Why is f″(1) as large as 1.94? ⟨X(X − 1)⟩ counts ordered pairs of siblings: a childless neutron adds none, and fissions with three neutrons alone add 0.75. The mean of X is held at k ≈ 1, so when three neutrons in five have no children, the others must make up for them: a neutron that has any children has 2.51 on average. Children come in bursts, and bursts make sibling pairs. A Poisson-distributed X with the same mean has f″(1) = k² = 1.00, and the bursts nearly halve the survival probability.
The spread of ν enters through D: at the same ν̄ and k, a narrower ν gives a smaller D, a flatter f, and a larger π. The measured ν is narrower than a Poisson-distributed ν, so the two comparisons point opposite ways. Compared with a Poisson-distributed X the real chain survives less often, compared with a Poisson-distributed ν more often:
Show code
import math
P_23 = np.zeros(9)
P_23[2], P_23[3] = 3 - nu_bar, nu_bar - 2 # always 2 or 3, same mean
P_POISSON = np.array([math.exp(-nu_bar) * nu_bar**n / math.factorial(n) for n in range(31)])
P_POISSON /= P_POISSON.sum()
for name, P in [("nu always 2 or 3", P_23), ("measured P(nu)", P_NU), ("Poisson-distributed nu", P_POISSON)]:
print(f"{name:24s} pi = {1 - extinction_q(offspring_pmf(K_REACTOR, P)):.3e}")
nu always 2 or 3 pi = 1.306e-03 measured P(nu) pi = 1.030e-03 Poisson-distributed nu pi = 8.228e-04
Two or three neutrons from every fission give 1.31 × 10⁻³, the measured ν 1.03 × 10⁻³, a Poisson-distributed ν 0.82 × 10⁻³.
Formalization
The chain starts with Z₀ = 1 neutron, and each generation is the sum of the children of the last,
where the \(X_{n,i}\) are independent and all have the distribution P(X = j). Its generating function is the f(s) = \(\langle s^X \rangle\) = 1 − p + p g(s) of the idea section, with f(1) = 1, f′(1) = k and f″(1) = k ν̄ D. The generating function of \(Z_n\) is f applied n times, f(f(⋯f(s)⋯)), because each child of the first neutron starts its own chain with n − 1 generations to go. At s = 0 this gives back \(q_n = f(q_{n-1})\). The extinction probability q is the smallest root of f(s) = s in [0, 1], and the survival probability is π = 1 − q. The predictions for the example:
Show code
K_VALUES = [1.001, 1.003, 1.01, 1.03, 1.1, 1.3]
pi_root = {k: 1 - extinction_q(offspring_pmf(k)) for k in K_VALUES}
pi_law = {k: 2 * (k - 1) / (k * nu_bar * D) for k in K_VALUES}
print(" k q pi 2(k - 1)/(k nu D) law / pi")
for k in K_VALUES:
print(f"{k:6.3f} {1 - pi_root[k]:.5f} {pi_root[k]:.3e} {pi_law[k]:.3e} {pi_law[k] / pi_root[k]:.3f}")
print(f"\nmean neutrons in one chain at k = 0.99: 1/(1 - k) = {1 / (1 - 0.99):.0f}")
pi = pi_root[K_REACTOR]
for S in [10, 1e6]:
print(f"source of {S:9,.0f} n/s at k = {K_REACTOR}: mean wait 1/(S pi) = {1 / (S * pi):.3g} s,"
f" 10 % chance of waiting longer than {np.log(10) / (S * pi):.3g} s")
beta = NU_DELAYED / nu_bar
print(f"\ndelayed share of all neutrons {beta:.2%}; children per neutron without them at k = {K_REACTOR}: "
f"{K_REACTOR * (1 - beta):.4f}")
k q pi 2(k - 1)/(k nu D) law / pi 1.001 0.99897 1.030e-03 1.030e-03 0.999 1.003 0.99691 3.088e-03 3.083e-03 0.998 1.010 0.98974 1.026e-02 1.021e-02 0.995 1.030 0.96952 3.048e-02 3.002e-02 0.985 1.100 0.90160 9.840e-02 9.371e-02 0.952 1.300 0.72794 2.721e-01 2.379e-01 0.874 mean neutrons in one chain at k = 0.99: 1/(1 - k) = 100 source of 10 n/s at k = 1.001: mean wait 1/(S pi) = 97.1 s, 10 % chance of waiting longer than 223 s source of 1,000,000 n/s at k = 1.001: mean wait 1/(S pi) = 0.000971 s, 10 % chance of waiting longer than 0.00223 s delayed share of all neutrons 0.65%; children per neutron without them at k = 1.001: 0.9945
Three consequences follow that you need every time.
At or below critical every chain dies. For k ≤ 1 the curve arrives at 1 no steeper than the diagonal, and since it bends upward it stays above the diagonal until then, so q = 1, unless every neutron has exactly one child. A source neutron below critical still leaves 1 + k + k² + ⋯ = 1/(1 − k) neutrons on average in its chain, 100 at k = 0.99. That is subcritical multiplication, the count a startup watches rise as k approaches 1.
The linear law holds well beyond k = 1.001. The formula to keep is π ≈ 2(k − 1)/(k ν̄ D). It is within 5 % of the root at k = 1.1, 0.0937 against 0.0984, and 13 % low at k = 1.3, 0.238 against 0.272. The cubic and higher terms of f, dropped in the expansion, grow with the distance 1 − q.
A source makes lasting chains arrive as a Poisson process in time. Each neutron from a source starts its own independent chain, so with S neutrons per second the lasting chains arrive at the rate Sπ, and the wait for the first is exponential with mean 1/(Sπ), like the wait for the first click of a counter near a radioactive sample. At k = 1.001 that is 97 s for S = 10 neutrons per second and 0.97 ms for 10⁶. The chance of waiting longer than t is \(e^{-S\pi t}\), which drops to 10 % only at t = ln(10)/(Sπ) = 223 s. The random wait, not its mean, is the startup problem. A reactor taken above critical with a weak source can sit there with nothing on the instruments while the operators keep raising k, and the chain that finally lasts starts at a higher k than intended.
The delayed neutrons have been children like the rest since the question, 0.65 % of all neutrons. Extinction depends on how many children a neutron has, not on when they appear, so their delay leaves q unchanged and only slows how fast a lasting chain grows. They must be counted all the same: without them a neutron would have 0.9945 children on average at k = 1.001, and every chain would die.
See it in code
One generation of a chain with Z neutrons is one call, rng.multinomial(Z, pmf). It deals the Z neutrons into nine bins by their number of children, 0 to 8, and returns the count in each. Each neutron in bin j adds j to the next generation, so the counts dotted with 0, 1, …, 8 give \(Z_{n+1}\). Given an array of Z, one per living chain, it returns one row per chain and advances them all (seeded generators and whole-array draws are the subject of Random numbers with numpy.random). The cell runs 10,000 chains at each of the six k, reusing the 10,000 of the question at 1.001, adds 10⁶ chains at 1.001, and compares the share that reached 10,000 neutrons with the root of f(s) = s.
Show code
runs = {K_REACTOR: lasting}
for k in K_VALUES[1:]:
runs[k] = simulate(k, 10_000)[1]
big = simulate(K_REACTOR, 1_000_000)[1]
print(" k lasting simulated pi root linear law")
rows = [(k, runs[k]) for k in K_VALUES] + [(K_REACTOR, big)]
for k, last in rows:
pi_sim = last.mean()
se = np.sqrt(pi_sim * (1 - pi_sim) / last.size)
print(f"{k:6.3f} {last.sum():5d} / {last.size:<9,d} {pi_sim:.3e} ± {se:.1e} {pi_root[k]:.3e} {pi_law[k]:.3e}")
print(f"\nchance that a chain at 10,000 neutrons still dies, k = {K_REACTOR}: q^10000 = {(1 - pi_root[K_REACTOR]) ** 10_000:.3e}, "
f"one in {(1 - pi_root[K_REACTOR]) ** -10_000:,.0f}")
dk = np.geomspace(1e-3, 0.3, 200)
fig, ax = plt.subplots()
ax.plot(dk, [1 - extinction_q(offspring_pmf(1 + x)) for x in dk], color=ACCENT)
ax.plot(dk, 2 * dk / ((1 + dk) * nu_bar * D), color=SECOND, lw=1.2, ls="--")
for k, last in rows:
pi_sim = last.mean()
se = np.sqrt(pi_sim * (1 - pi_sim) / last.size)
big_run = last.size > 10_000
ax.errorbar(k - 1, pi_sim, yerr=se, fmt="o", ms=4, capsize=2, lw=1, color=INK,
mfc="white" if big_run else INK, zorder=3)
ax.text(0.29, 0.035, "line: root of f(s) = s", color=ACCENT, ha="right")
ax.text(0.29, 0.02, "dashed: 2(k − 1)/f″(1)", color=SECOND, ha="right", va="top")
ax.text(1.4e-3, 6.5e-4, "simulated; open circle: 10⁶ chains", color=INK, va="center")
ax.set(xscale="log", yscale="log", xlabel="k − 1", ylabel="survival probability π")
plt.show()
k lasting simulated pi root linear law 1.001 9 / 10,000 9.000e-04 ± 3.0e-04 1.030e-03 1.030e-03 1.003 30 / 10,000 3.000e-03 ± 5.5e-04 3.088e-03 3.083e-03 1.010 115 / 10,000 1.150e-02 ± 1.1e-03 1.026e-02 1.021e-02 1.030 316 / 10,000 3.160e-02 ± 1.7e-03 3.048e-02 3.002e-02 1.100 1005 / 10,000 1.005e-01 ± 3.0e-03 9.840e-02 9.371e-02 1.300 2735 / 10,000 2.735e-01 ± 4.5e-03 2.721e-01 2.379e-01 1.001 1073 / 1,000,000 1.073e-03 ± 3.3e-05 1.030e-03 1.030e-03 chance that a chain at 10,000 neutrons still dies, k = 1.001: q^10000 = 3.336e-05, one in 29,975
Every simulated value lies within two standard errors of the root: 9 lasting chains of 10,000 at k = 1.001 against 10.3 expected, 115 at 1.01 against 103, 1,005 at 1.1 against 984. At k = 1.001 the 10,000 chains are a ±30 % check and prove little. The 10⁶ chains give (1.073 ± 0.033) × 10⁻³ against 1.030 × 10⁻³, 1.3 standard errors high, a ±3 % check (The standard error of the mean explains the ±). The cap of 10,000 neutrons does not bias the count: a chain that reaches it still dies with probability q¹⁰⁰⁰⁰ = 3.336 × 10⁻⁵ at k = 1.001, a bias of 0.003 % against the ±3 % of the largest run.
Where it shows up
- Reactor startup and pulsed reactors. A startup source keeps neutrons coming, and a source-range counter, the neutron detector that watches a reactor while it is far below power, sees its count rate grow with the subcritical multiplication 1/(1 − k) as k approaches 1. In a pulsed reactor brought above critical quickly, the delay between reaching the target k and the start of the pulse is random, with the rate Sπ of the waiting-time result.
- Population genetics. A new beneficial mutation whose carrier leaves a Poisson-distributed number of copies with mean 1.01 survives with probability about 2 %, Haldane's result of 1927; it is the k − 1 law with f″(1) ≈ 1. A neutral copy, as in Genetic drift, wins only with 1/2N in a population of N diploid individuals.
- Epidemics. R₀, the mean number of people one case infects, is the k of an outbreak, and the chance that one introduced case starts a lasting outbreak is π. Lloyd-Smith and colleagues (2005) fitted a negative-binomial number of secondary cases, a Poisson-distributed number whose mean itself varies from case to case, and found that a larger spread at the same R₀ makes most introductions fizzle, the spread table above in another field.
- Cell lineages and colonies. A cell that divides at rate b and dies at rate d leaves a lineage that dies out with probability d/b when b > d, the same fixed point in continuous time. The Gillespie algorithm tutorial simulates such birth and death processes event by event.
- Polymer gelation. In Flory-Stockmayer theory a growing molecule is a branching tree of bonds, and the gel point is where the mean number of further branches per branch reaches 1. The gel fraction plays the role of π: it comes from a fixed-point equation of the same form and grows linearly past the gel point.
In every case one founder's fate turns first on its chance of having no offspring, which the mean alone never shows.
Further reading
numpy.random.Generator.multinomial, which takes an array of counts, andnumpy.polynomial.Polynomial.rootsfor the root of f(s) = s.- Theodore E. Harris, The Theory of Branching Processes (Springer, 1963), the classic, with the generating-function argument in its first chapter.
- George I. Bell, "On the stochastic theory of neutron transport", Nuclear Science and Engineering 21, 390 (1965), for the survival probability of fission chains with space and energy included; G. E. Hansen, "Assembly of fissionable material in the presence of a weak neutron source", Nuclear Science and Engineering 8, 709 (1960), for the weak-source problem.
- N. E. Holden and M. S. Zucker, "A reevaluation of the average prompt neutron emission multiplicity (nubar) values from fission of uranium and transuranium nuclides", report BNL-NCS-35513 (Brookhaven National Laboratory, 1985), the source of the distribution of prompt neutrons used for P(ν) here.
- Related tutorials on this site: Random numbers with numpy.random: ten thousand reproducible random walks; Genetic drift: why a neutral allele is fixed or lost, and how long it takes; The Gillespie algorithm: how noisy is a protein with ten copies per cell?; Neutron moderation: why hydrogen needs 18 collisions and carbon 115, what a neutron does before it causes the next fission; Monte Carlo transport: why a shield lets through more than e⁻³, where the leakage and capture behind 1 − p come from; The standard error of the mean: why four times the data halves the error, for the ± on a simulated fraction; Matplotlib animation with FuncAnimation: a probe sweep as a small GIF, how the animation was built; planned: the same tutorial in Julia.
- Download the notebook. It was executed with the library versions in the header.