When is the cosmic microwave background actually emitted, and how thick is the "surface" it comes
from? I built this code to learn recombination physics from first principles by following the
free-electron fraction
(a) Free-electron fraction from CAMB/RECFAST, my Peebles model and the Peebles model with the
RECFAST fudge factor
Planck 2018 background, no reionisation. CAMB 2.0.5 is run with the same parameters (massless
neutrinos, same
| Peebles (this work) | Peebles, |
CAMB 2.0.5 (RECFAST) | |
|---|---|---|---|
|
|
1089.44 | 1090.28 | 1089.89 |
| Peak of |
1078.80 | 1079.05 | 1078.37 |
| Peak of visibility per unit conformal time | 1089.11 | 1089.32 | 1088.78 |
| Width of last scattering (FWHM of |
203.33 | 202.73 | 204.00 |
|
|
903.18 | 904.44 | 903.55 |
| 3.849 × 10⁻⁴ | 3.404 × 10⁻⁴ | 3.375 × 10⁻⁴ | |
| 2.665 × 10⁻⁴ | 2.351 × 10⁻⁴ | 1.738 × 10⁻⁴ |
How far
| Peebles (this work) | Peebles, |
|
|---|---|---|
| (a) Largest |
1.08% (at |
1.04% (at |
| (b) Most negative residual | −3.58% (at |
−3.58% (at |
| (c) Most positive residual | +14.05% (at |
+1.46% (at |
| Range over the hydrogen-only era, |
−0.33% to +14.05% | −1.46% to +1.46% |
(a) Last scattering. This is the part that matters for the CMB. Across the window that holds half
the visibility, my RECFAST_Hswitch option, fitted to multi-level calculations). I think these are the likely origin of
the bump, but I have not isolated it.
(b) Helium. Above
(c) Late times. Below the last-scattering window the plain Peebles residual grows steadily,
reaching +14.05% at
The comparison is not fully independent, because CAMB's RECFAST is itself a fudged three-level model.
The fudge also moves
Two more checks.
- I fed CAMB's own
$x_e(z)$ into my optical-depth integrator. It gives$z_\ast = 1089.8901$ , against CAMB's internal 1089.8923, so the integrator is not hiding anything. - The familiar "
$z \approx 1090$ " is$z_\ast$ or the peak per unit conformal time. The peak of$g(z)$ per unit redshift is about 10 lower, at 1078.80.docs/theory.mdexplains why.
Every number above is printed by python scripts/validate.py (written to results/validation.md) or
python scripts/make_figures.py (written to results/key_results.md).
The full derivations, with each approximation stated where it is made, are in
docs/theory.md. These are the equations the code solves.
Background. Flat ΛCDM with
If ionisation and recombination are in balance, the ionised fraction
where
Here
with
- A direct capture to the ground state emits a photon that immediately ionises a neighbouring atom.
- Lyman-α photons are trapped by resonant scattering.
Below
-
$\alpha_B(T)$ is the case-B recombination coefficient: recombinations to$n \ge 2$ only (Péquignot, Petitjean & Boisson 1991 fit, multiplied by a fudge factor$F$ , which is 1 by default). -
$\beta_B = \alpha_B\,(m_ek_BT/2\pi\hbar^2)^{3/2}\,e^{-E_{\rm ion}/4k_BT}$ is the photo-ionisation rate from$n = 2$ , fixed by detailed balance. -
$E_{{\rm Ly}\alpha} = \tfrac34 E_{\rm ion}$ is the Lyman-α energy. -
$\Lambda_{2s1s} = 8.22458\ {\rm s^{-1}}$ is the two-photon 2s → 1s decay rate. -
$\Lambda_\alpha = 8\pi H/(\lambda_\alpha^3\, n_{1s})$ is the rate at which Lyman-α photons redshift out of the line, with$\lambda_\alpha$ the Lyman-α wavelength and$n_{1s} = (1 - x_e)\,n_{\rm H}$ the ground-state density.
Photons scatter off free electrons at the rate
I define the last-scattering redshift
Its full width at half maximum is the thickness of the last-scattering surface.
Saha equilibrium (orange, pink) recombines far too early. The Peebles model sits almost on top of
CAMB through last scattering. More figures: figures/visibility_function.png
(also shows figures/rates.png (
| Approximation | What it does to the result | What would fix it |
|---|---|---|
| Three-level atom with case-B |
|
multi-level atom (HyRec, CosmoRec) |
| Gas temperature equals radiation temperature |
|
evolve the matter temperature with Compton heating |
| Helium in Saha equilibrium; neutral below |
|
helium rate equations (as in RECFAST) |
| No Lyman-series feedback, Raman scattering or two-photon corrections; none of RECFAST's extra rate corrections | ~1% wiggles in |
HyRec / CosmoRec |
| No reionisation |
|
add a reionisation model |
| Massless neutrinos | none in the comparison (CAMB uses the same) | massive-neutrino background |
CAMB's RECFAST includes helium rate equations, matter-temperature evolution,
You'll need Python 3.10 or newer. With pip:
git clone https://github.com/sethuastro/CMB_Decoupling.git
cd CMB_Decoupling
python -m venv .venv
source .venv/bin/activate # on Windows: .venv\Scripts\activate
pip install -e ".[dev]" # or, for the exact versions I used: pip install -r requirements.txt && pip install -e . --no-depsWith conda:
conda create -n cmb python=3.13 && conda activate cmb
pip install -e ".[dev]"Reproduce the validation figure and tables (well under a minute):
python scripts/validate.py # figures/validation.{png,pdf}, results/validation.{md,json}
python scripts/make_figures.py # the other figures, results/key_results.{md,json}Or, from Python:
from cmb_decoupling import PLANCK2018, solve_peebles, last_scattering
history = solve_peebles(PLANCK2018) # Peebles x_e(z) on the Planck 2018 background
ls = last_scattering(history.x_e, PLANCK2018) # tau(z) and g(z)
print(f"{ls.z_tau_one():.2f} {ls.fwhm()[0]:.2f}") # 1089.44 203.33How it's organised
src/cmb_decoupling/ the package
cosmology.py Planck 2018 parameters (defined once), H(z), n_H(z)
constants.py physical and atomic constants, with sources
saha.py Saha equilibrium for hydrogen and helium
peebles.py Peebles three-level atom (stiff ODE)
visibility.py Thomson rate, tau(z), g(z), peak and width
results.py summary numbers and residuals against a reference
reference.py the CAMB comparison run
scripts/ validate.py (comparison with CAMB), make_figures.py (all other figures)
tests/ pytest suite
notebooks/ a walk-through from Saha to last scattering
docs/theory.md the derivations, step by step, with references
archive/ my original scripts, kept as they were
pytestThere are 41 tests. Some check things that physics says must be true:
-
$x_e \to 1$ at high redshift in Saha; - the Peebles rate vanishes exactly at the Saha solution (detailed balance);
- charge is conserved in the hydrogen–helium Saha solution;
-
$\int g\,dz = 1 - e^{-\tau}$ exactly, and$\int g\,dz = 1$ to$10^{-6}$ since$\tau(3000) \gg 1$ .
Others check that the answers don't change when I tighten the ODE tolerances, refine the redshift grid or move the Saha-to-Peebles switch.
The comparison tests against CAMB use tolerances that describe the agreement I measured, not targets I tuned the model to meet:
-
$z_\ast$ , the peak and$z_{\rm dec}$ within 2; - the FWHM within 3;
-
$\lvert\Delta x_e/x_e\rvert < 2\%$ in the last-scattering window.
GitHub Actions runs on every push (badge at the top):
- lint (ruff);
- tests on Linux (Python 3.11 and 3.13) and on Windows;
- both scripts, rebuilt from scratch;
- a check that the committed results tables match the code;
- the notebook, run top to bottom.
These are planned, not done:
-
Sound horizon
$r_s(z_\ast)$ : the comoving distance a sound wave in the photon–baryon fluid travels before last scattering. This sets the angular scale of the acoustic peaks. -
Angular power spectrum
$C_\ell$ : starting from the visibility function computed here.
If you use this code, please cite it with the metadata in CITATION.cff; GitHub's
"Cite this repository" button generates BibTeX from it. The code is released under the
MIT licence.
References:
- Peebles 1968, ApJ 153, 1
- Zel'dovich, Kurt & Sunyaev 1968, Zh. Eksp. Teor. Fiz. 55, 278
- Seager, Sasselov & Scott 1999, ApJ 523, L1
- Seager, Sasselov & Scott 2000, ApJS 128, 407
- Péquignot, Petitjean & Boisson 1991, A&A 251, 680
- Planck Collaboration 2020, A&A 641, A6
- Lewis, Challinor & Lasenby 2000, ApJ 538, 473 (CAMB)
- Dodelson & Schmidt 2020, Modern Cosmology, 2nd ed.
The full list is in docs/theory.md.
About me. I'm Sethulekshmi Beena, MSc Astrophysics (Distinction), University of Hertfordshire. ORCID 0009-0004-4767-2733 · homepage

