The CMB angular power spectra
We now assemble everything into the observable: the angular power spectrum . The pipeline is a single sentence: evolve the perturbations for every , build source functions, do the LOS integral against , integrate against the primordial power spectrum, normalise. This chapter executes that sentence and validates the result against CAMB.
The master formula
The CMB temperature on our sky is a stochastic field. Its statistical properties are summarised by the angular power spectrum
with the spherical-harmonic coefficients of the relevant field ( for us). Going through the projection from -space to the sky, the result is
with the dimensionless primordial curvature power spectrum and the transfer functions from chapter 4. The factor of comes from the spherical-harmonic normalisation; the measure is natural because primordial perturbations are scale-invariant to a good approximation.
For -mode polarisation the master formula picks up an extra normalisation, , from the spin-2 differential operator – this factor sits inside our compute_spectra function as ctnorm.
We usually report not itself but
which is approximately flat on the Sachs-Wolfe plateau.
Anatomy of the spectrum
Before plotting, it is worth knowing what to expect at each scale.
Sachs-Wolfe plateau ().
At these scales the modes are still super-horizon at last scattering. The dominant contribution is (the classic SW formula), which freezes out at horizon crossing. Result: , with amplitude set by .
Acoustic peaks ().
Modes that completed an integer number of oscillation half-cycles by last scattering produce extrema in . The -th compression peak (odd ) is enhanced by baryon mass and the -th rarefaction peak (even ) is suppressed: the even/odd ratio measures . The angular position of the first peak,
measures the angular scale of the sound horizon and fixes the geometry.
Silk damping tail ().
Photon diffusion smears small-scale anisotropies on a comoving scale , producing an exponential damping envelope with .
Reionisation bump on EE ().
Reionisation scatters CMB photons off late-time free electrons. Because polarisation is generated only by an anisotropic radiation field, the contribution is amplified relative to TT. This produces a low- EE bump that measures .
Choice of grids
For the LOS integral and the integral we need two -grids: a coarse grid for the ODE evolution (the Boltzmann hierarchy is smooth in , - modes suffice) and a fine grid for the LOS integral (the transfer function oscillates rapidly, so we need modes). For the time integral we need a -grid concentrated near recombination (where the visibility function lives) and near reionisation. Both grids are constructed by an equidistribution principle: the node density is proportional to of an estimator function, which minimises trapezoidal-rule error.
The code: make_k_grid
make_k_grid
def make_k_grid(N, mode, bg, thermo, params,
k_min=1e-5, k_max=0.5,
ell_min=2, ell_max=2500, n_ell_samples=30,
n_eval=5000):
"""Optimal non-uniform k-grid.
mode='cl' : optimised for the C_ell integral
mode='ode' : optimised for source-function interpolation.
"""
x = np.linspace(np.log(k_min), np.log(k_max), n_eval)
k = np.exp(x)
primordial = k ** (params['n_s'] + 2)
acoustic_curv = (1.0 / thermo['r_s']) ** 2
if mode == "cl":
damped = primordial * np.exp(-1.0 * (k / thermo['k_D']) ** 2)
sigma_k = 1.0 / thermo['delta_tau_rec']
chi_star = bg['tau0'] - thermo['tau_star']
ells = np.unique(np.geomspace(ell_min, ell_max,
n_ell_samples).astype(int))
raw_weight = np.zeros_like(k)
for ell in ells:
envelope = np.exp(-0.5 * ((k - ell / chi_star) / (3.0 * sigma_k)) ** 2)
curv = np.maximum(acoustic_curv, sigma_k ** 2) * envelope
raw_weight += curv * damped
floor = 1e-6 * np.max(raw_weight)
else:
damped = primordial * np.exp(-1.0 * (k / thermo['k_D']) ** 2)
smooth_curv = (k * thermo['r_s']) ** 2 / bg['tau_eq'] ** 2
raw_weight = np.maximum(acoustic_curv, smooth_curv) * damped
floor = 0.005 * np.max(raw_weight)
density = (raw_weight + floor) ** (1.0 / 3.0)
dx = x[1] - x[0]
cdf = np.cumsum(density) * dx
cdf -= cdf[0]; cdf /= cdf[-1]
grid = np.exp(np.interp(np.linspace(0, 1, N), cdf, x))
grid[0] = k_min; grid[-1] = k_max
return grid
What the weight function does.
The two modes target different functions. mode="ode" samples densely where the source function varies fast: around the acoustic-oscillation scale and beyond, with Silk damping cutting off the high- side. mode="cl" samples densely where the transfer function oscillates – i.e. near the Bessel peak for each we plan to compute. The in the weight is a stand-in for “support over the whole range”.
The code: make_tau_grid
make_tau_grid
def make_tau_grid(N, k_max, bg, thermo,
tau_min=1.0, tau_max=None, n_eval=10000):
"""Optimal non-uniform tau-grid for the line-of-sight integral."""
if tau_max is None:
tau_max = bg['tau0']
tau = np.linspace(tau_min, tau_max, n_eval)
tau_star = thermo['tau_star']
delta_tau_rec = thermo['delta_tau_rec']
g_rec = np.exp(-0.5 * ((tau - tau_star) / delta_tau_rec) ** 2)
g_broad = np.exp(-0.5 * ((tau - tau_star) / thermo['r_s']) ** 2)
weight = g_rec / delta_tau_rec**2 + g_broad * (k_max / np.sqrt(3.0))**2
g_reion = np.exp(-0.5 * ((tau - thermo['tau_reion'])
/ thermo['delta_tau_reion']) ** 2)
weight += 0.3 * g_reion / thermo['delta_tau_reion'] ** 2
density = (weight + 0.005 * np.max(weight)) ** (1.0 / 3.0)
dtau = tau[1] - tau[0]
cdf = np.cumsum(density) * dtau
cdf -= cdf[0]; cdf /= cdf[-1]
grid = np.interp(np.linspace(0, 1, N), cdf, tau)
grid[0] = tau_min; grid[-1] = tau_max
return grid
The Gaussian g_rec concentrates samples around the visibility peak (FWHM ), with g_broad adding a wider envelope to capture the acoustic-source structure either side of recombination. g_reion adds samples around reionisation.
The code: Bessel-function tables
For efficiency we precompute and on a uniform -grid for every we plan to use. The interpolation back to the actual values is then a simple linear lookup. The derivatives and follow from the recurrence Eq. – no second table is needed.
def _build_bessel_tables(ells_compute, x_max, dx):
"""Precompute j_l and j_{l+1} on a uniform x-grid."""
n_x = int(np.ceil(x_max / dx)) + 1
x_lo = 0.0
x_arr = x_lo + dx * np.arange(n_x)
inv_dx = 1.0 / dx
nell = len(ells_compute)
jl_tab = np.zeros((nell, n_x))
jl1_tab = np.zeros((nell, n_x))
for il, ell in enumerate(ells_compute):
jl_tab[il] = special.spherical_jn(ell, x_arr)
jl1_tab[il] = special.spherical_jn(ell + 1, x_arr)
return x_lo, inv_dx, n_x, jl_tab, jl1_tab
@_jit
def _interp_uniform_table(x, x0, inv_dx, n_x, vals):
"""Linear interpolation on a uniform grid."""
out = np.zeros_like(x)
flat = x.ravel()
flat_out = out.ravel()
for i in range(flat.size):
u = (flat[i] - x0) * inv_dx
j = int(u)
if 0 <= j < n_x - 1:
t = u - j
flat_out[i] = (1 - t) * vals[j] + t * vals[j + 1]
return out
The code: compute_spectra
The main pipeline. Evolve all modes (in parallel if possible), interpolate sources to the fine -grid, do the LOS integral for every , integrate against .
compute_spectra
def compute_spectra(bg, thermo, params, N_k_ode=200, N_k_fine=4000, N_tau=1000,
k_arr=None, k_fine=None, tau_out=None):
"""Main CMB pipeline: evolve all k modes, do LOS integration, assemble C_ell."""
pgrid = init_perturbation_grid(bg, thermo)
tau0 = bg['tau0']
tau_star = thermo['tau_star']
if k_arr is None:
k_arr = make_k_grid(N_k_ode, "ode", bg, thermo, params)
nk = len(k_arr)
if k_fine is None:
k_fine = make_k_grid(N_k_fine, "cl", bg, thermo, params,
k_min=k_arr[0], k_max=k_arr[-1])
if tau_out is None:
tau_out = make_tau_grid(N_tau, k_arr[-1], bg, thermo,
tau_min=1.0, tau_max=tau0 - 1)
ntau = len(tau_out)
# Warm up numba JIT (no-op without numba), then evolve all modes sequentially.
# A parallel multiprocessing version is in nanocmb.py if you need it.
boltzmann_derivs(tau_out[0], np.zeros(NVAR), k_arr[0],
pgrid['bg_vec'], pgrid['sp_a_x'], pgrid['sp_a_c'],
pgrid['sp_op_x'], pgrid['sp_op_c'],
pgrid['sp_cs_x'], pgrid['sp_cs_c'])
results = [evolve_k(k, bg, thermo, pgrid, tau_out) for k in k_arr]
sources_j0 = np.array([r[0] for r in results])
sources_j1 = np.array([r[1] for r in results])
sources_j2 = np.array([r[2] for r in results])
sources_E = np.array([r[3] for r in results])
# Akima interpolation onto fine k-grid
nk_fine = len(k_fine)
lnk_ode = np.log(k_arr)
lnk_fine = np.log(k_fine)
src_fine_j0 = np.zeros((nk_fine, ntau))
src_fine_j1 = np.zeros((nk_fine, ntau))
src_fine_j2 = np.zeros((nk_fine, ntau))
src_fine_E = np.zeros((nk_fine, ntau))
for it in range(ntau):
for src_in, src_out in [(sources_j0, src_fine_j0), (sources_j1, src_fine_j1),
(sources_j2, src_fine_j2), (sources_E, src_fine_E)]:
src_out[:, it] = interpolate.Akima1DInterpolator(lnk_ode, src_in[:, it])(lnk_fine)
# ell-grid: dense at low ell, sparser at high ell
ell_max = params['ell_max']
ells_compute = np.unique(np.concatenate([
np.arange(2, 40, 2),
np.arange(40, 200, 5),
np.arange(200, ell_max + 1, 50),
]))
ells_compute = ells_compute[ells_compute <= ell_max]
nell = len(ells_compute)
chi_arr = tau0 - tau_out
chi_star = tau0 - tau_star
chi_max = chi_arr.max()
Delta_T = np.zeros((nell, nk_fine))
Delta_E = np.zeros((nell, nk_fine))
x_2d_full = k_fine[:, None] * chi_arr[None, :]
x0_tab, inv_dx_tab, n_x_tab, jl_tab, jl1_tab = _build_bessel_tables(
ells_compute, float(np.max(x_2d_full)) + 2.0, 0.03)
def _compute_ell_transfer(il, ell):
# Restrict the k range to where j_ell has support
x_lo = max(0.0, ell - 4.0 * ell**(1.0/3.0))
k_lo = x_lo / chi_max if chi_max > 0 else 0
k_hi = (ell + 2500) / chi_star if chi_star > 0 else k_fine[-1]
ik_lo = max(0, np.searchsorted(k_fine, k_lo) - 1)
ik_hi = min(nk_fine, np.searchsorted(k_fine, k_hi) + 1)
x_2d = x_2d_full[ik_lo:ik_hi, :]
jl = _interp_uniform_table(x_2d, x0_tab, inv_dx_tab, n_x_tab, jl_tab[il])
jl_next = _interp_uniform_table(x_2d, x0_tab, inv_dx_tab, n_x_tab, jl1_tab[il])
with np.errstate(divide='ignore', invalid='ignore'):
inv_x = np.where(x_2d > 1e-30, 1.0 / x_2d, 0.0)
jl_d = np.where(x_2d > 1e-30, ell * inv_x * jl - jl_next, 0.0)
jl_dd = np.where(x_2d > 1e-30,
-2.0 * inv_x * jl_d
+ (ell * (ell + 1) * inv_x * inv_x - 1.0) * jl,
0.0)
integrand_T = (src_fine_j0[ik_lo:ik_hi, :] * jl
+ src_fine_j1[ik_lo:ik_hi, :] * jl_d
+ src_fine_j2[ik_lo:ik_hi, :] * jl_dd)
integrand_E = src_fine_E[ik_lo:ik_hi, :] * jl
Delta_T[il, ik_lo:ik_hi] = np.trapz(integrand_T, tau_out, axis=1)
Delta_E[il, ik_lo:ik_hi] = np.trapz(integrand_E, tau_out, axis=1)
for il, ell in enumerate(ells_compute):
_compute_ell_transfer(il, int(ell))
# Primordial power and assembly: C_l = 4 pi int dlnk P(k) Delta_T^2
Pk = params['A_s'] * (k_fine / params['k_pivot'])**(params['n_s'] - 1.0)
Cl_TT, Cl_EE, Cl_TE = [np.trapz(Pk * d, lnk_fine, axis=1)
for d in (Delta_T**2, Delta_E**2, Delta_T * Delta_E)]
# Normalise to D_l = l(l+1) C_l / (2 pi) (E mode has extra spin-2 factor)
ells_f = ells_compute.astype(float)
norm = 4.0 * np.pi * ells_f * (ells_f + 1) / (2.0 * np.pi)
ctnorm = (ells_f**2 - 1.0) * (ells_f + 2) * ells_f
Cl_TT *= norm
Cl_EE *= norm * ctnorm
Cl_TE *= norm * np.sqrt(ctnorm)
# Convert dimensionless (dT/T)^2 to mu K^2
T0_muK2 = (params['T_cmb'] * 1e6) ** 2
Cl_TT *= T0_muK2; Cl_EE *= T0_muK2; Cl_TE *= T0_muK2
# Interpolate to all integer ell
ells_all = np.arange(2, ell_max + 1)
Dl_TT, Dl_EE, Dl_TE = [interpolate.CubicSpline(ells_compute, cl)(ells_all)
for cl in (Cl_TT, Cl_EE, Cl_TE)]
return {
'ells': ells_all, 'Dl_TT': Dl_TT, 'Dl_EE': Dl_EE, 'Dl_TE': Dl_TE,
'k_fine': k_fine, 'ells_compute': ells_compute,
'Delta_T': Delta_T, 'Delta_E': Delta_E,
}
Walking through the pipeline.
Grid setup: build the coarse ODE -grid, the fine LOS -grid, and the -grid.
Evolution: run
evolve_kfor every in the ODE grid, in parallel where available. Each call returns the four source arrays at the output times.Interpolation: source functions are smooth in , so Akima interpolation pushes them to the fine grid.
LOS integration: for every , build , , at from the precomputed tables, multiply by the three source arrays, and integrate over .
Assembly: weight by , integrate over , apply and the unit conversion.
Interpolation in : we compute on a sparse grid (every at low , every at high ) and cubic-spline back to all integer .
Running the code
result = compute_spectra(bg, th, params)
ells = result['ells']
Dl_TT = result['Dl_TT']
Dl_EE = result['Dl_EE']
Dl_TE = result['Dl_TE']
The run takes one to a few minutes depending on machine and whether numba is available. Once done you have your CMB power spectra.
Plot: TT, EE, TE on one panel
fig, ax = plt.subplots(figsize=(8, 5))
ax.plot(ells, Dl_TT, color='C0', label='TT')
ax.plot(ells, Dl_EE, color='C1', label='EE')
ax.plot(ells, np.abs(Dl_TE), color='C2', label='|TE|')
ax.set_xlabel(r'Multipole $\ell$')
ax.set_ylabel(r'$\mathcal{D}_\ell\,[\mu K^2]$')
ax.set_title('Scalar CMB power spectra')
ax.set_xscale('log'); ax.set_yscale('log')
ax.set_xlim(2, ells[-1]); ax.legend(); ax.grid(True, which='both', alpha=0.3)
plt.show()
Three curves: temperature TT (blue) with the Sachs-Wolfe plateau, six acoustic peaks, and the Silk damping tail; -mode polarisation EE (orange) with peaks 90 out of phase with TT and a reionisation bump at ; the temperature-polarisation cross-spectrum TE (green), which oscillates between positive and negative values.
Validation against CAMB
We promised CAMB would only appear twice. This is the first time. Set up an equivalent CAMB run and overplot.
import camb
pars = camb.set_params(H0=100 * params['h'],
ombh2=params['omega_b_h2'],
omch2=params['omega_c_h2'],
As=params['A_s'], ns=params['n_s'],
tau=params['tau_reion'])
pars.set_for_lmax(params['ell_max'], lens_potential_accuracy=2)
camb_powers = camb.get_results(pars).get_cmb_power_spectra(
pars, CMB_unit='muK', raw_cl=False)
unlensed = camb_powers['unlensed_scalar'] # shape (lmax+1, 4); cols = TT,EE,BB,TE
ells_camb = np.arange(unlensed.shape[0])
Dl_TT_camb = unlensed[:, 0]
Dl_EE_camb = unlensed[:, 1]
Dl_TE_camb = unlensed[:, 3]
fig, axes = plt.subplots(2, 1, sharex=True, figsize=(8, 6),
gridspec_kw={'height_ratios': [3, 1]})
# Top: spectra
axes[0].plot(ells, Dl_TT, 'k-', lw=1.4, label='tutorial')
axes[0].plot(ells_camb, Dl_TT_camb, 'C3--', lw=1.0, label='CAMB')
axes[0].plot(ells, Dl_EE, 'k-', lw=1.4)
axes[0].plot(ells_camb, Dl_EE_camb, 'C3--', lw=1.0)
axes[0].set_yscale('log'); axes[0].set_xscale('log')
axes[0].set_ylabel(r'$\mathcal{D}_\ell$ [$\mu K^2$]')
axes[0].set_xlim(2, params['ell_max'])
axes[0].legend(); axes[0].grid(True, which='both', alpha=0.3)
axes[0].set_title('Validation: tutorial vs CAMB')
# Residuals
common = np.intersect1d(ells, ells_camb)
ix_t = np.isin(ells, common)
ix_c = np.isin(ells_camb, common)
resid_TT = 100.0 * (Dl_TT[ix_t] - Dl_TT_camb[ix_c]) / Dl_TT_camb[ix_c]
resid_EE = 100.0 * (Dl_EE[ix_t] - Dl_EE_camb[ix_c]) / Dl_EE_camb[ix_c]
axes[1].plot(common, resid_TT, 'C0-', lw=1.0, label='TT')
axes[1].plot(common, resid_EE, 'C1-', lw=1.0, label='EE')
axes[1].axhline(0, color='gray', lw=0.5)
axes[1].set_xscale('log')
axes[1].set_xlabel(r'Multipole $\ell$')
axes[1].set_ylabel('residual [%]')
axes[1].set_ylim(-5, 5)
axes[1].legend(); axes[1].grid(True, which='both', alpha=0.3)
plt.show()
Agreement at the percent level across the full range, with residuals localised on the acoustic peaks (mostly numerical-projection artefacts).
Looking ahead
These are the unlensed scalar spectra. The real CMB has been lensed by intervening matter, which smooths the acoustic peaks and – more importantly – converts a fraction of the -mode power into -mode polarisation. That is chapter 6. After that, chapter 7 adds the contribution from primordial gravitational waves, which produce a tensor -mode signal.