Comparison to CLASS
This page compares results from SymBoltz and the fantastic Einstein-Boltzmann solver CLASS for the ΛCDM model.
Setup
using SymBoltz
using CLASS
using DelimitedFiles
using DataInterpolations
using Unitful, UnitfulAstro
using CairoMakie
lmax = 6
reionization = true
Hswitch = 1
Heswitch = 6
M = w0waCDM(; lmax, reionization, Hswitch, Heswitch)
pars = merge(parameters_Planck18(M), Dict(
M.X.w0 => -0.9,
M.X.wa => 0.1,
M.X.cₛ² => 0.9
))
h = pars[M.g.h] # needed later
prob = CosmologyProblem(M, pars)
function solve_class(pars, k = nothing)
prob = CLASSProblem(
"write_background" => "yes",
"write_thermodynamics" => "yes",
"background_verbose" => 2,
"output" => "mPk, tCl, pCl", # need one to evolve perturbations
"k_output_values" => isnothing(k) ? "" : NoUnits(k / u"1/Mpc"),
"ic" => "ad",
"modes" => "s",
"gauge" => "newtonian",
# metric
"h" => pars[M.g.h],
# photons
"T_cmb" => pars[M.γ.T₀],
"l_max_g" => lmax,
"l_max_pol_g" => lmax,
# baryons
"Omega_b" => pars[M.b.Ω₀],
"YHe" => pars[M.b.YHe],
"recombination" => "recfast", # TODO: HyREC
"recfast_Hswitch" => Hswitch,
"recfast_Heswitch" => Heswitch,
"reio_parametrization" => reionization ? "reio_camb" : "reio_none",
# cold dark matter
"Omega_cdm" => pars[M.c.Ω₀],
# neutrinos
"N_ur" => SymBoltz.have(M, :ν) ? pars[M.ν.Neff] : 0.0,
"N_ncdm" => SymBoltz.have(M, :h) ? 1 : 0,
"m_ncdm" => SymBoltz.have(M, :h) ? pars[M.h.m_eV] : 0.0, # in eV/c^2
"T_ncdm" => SymBoltz.have(M, :h) ? (4/11)^(1/3) : 0.0,
"l_max_ur" => lmax,
"l_max_ncdm" => lmax,
# primordial power spectrum
"ln_A_s_1e10" => pars[M.I.ln_As1e10],
"n_s" => pars[M.I.ns],
# w0wa dark energy
"Omega_Lambda" => 0.0, # unspecified
"w0_fld" => SymBoltz.have(M, :X) ? pars[M.X.w0] : -1.0,
"wa_fld" => SymBoltz.have(M, :X) ? pars[M.X.wa] : 0.0,
"cs2_fld" => SymBoltz.have(M, :X) ? pars[M.X.cₛ²] : 1.0,
"use_ppf" => SymBoltz.have(M, :X) ? "no" : "yes", # use full equations (must be yes with CC to prevent crash)
# other stuff
"Omega_k" => SymBoltz.have(M, :K) ? pars[M.K.Ω₀] : 0.0, # curvature
"Omega_scf" => 0.0,
"Omega_dcdmdr" => 0.0,
# approximations (see include/precisions.h)
"tight_coupling_trigger_tau_c_over_tau_h" => 1e-2, # cannot turn off?
"tight_coupling_trigger_tau_c_over_tau_k" => 1e-3, # cannot turn off
"radiation_streaming_approximation" => 3, # turns off RSA
"ur_fluid_approximation" => 3, # turns off UFA
"ncdm_fluid_approximation" => 3, # turns off NCDM fluid approximation
#"l_max_scalars" => 1500,
#"temperature_contributions" => "pol",
)
return solve(prob)
end
k = 1e1 / u"Mpc" # 1/Mpc
sol1 = solve_class(pars, k)
sol2 = solve(prob, k; ptopts = (alg = SymBoltz.Rodas4P(),)) # looks like lower-precision KenCarp4 and Kvaerno5 "emulate" radiation streaming, while higher-precision Rodas5P continues in an exact way
function plot_compare(x1s, x2s, y1s, y2s, xlabel, ylabels; lgx=false, lgy=false, common=false, errtype=:auto, errlim=NaN, tol = nothing, kwargs...)
if !(ylabels isa AbstractArray)
y1s = [y1s]
y2s = [y2s]
ylabels = [ylabels]
end
xplot(x) = lgx ? log10.(abs.(x)) : x
yplot(y) = lgy ? log10.(abs.(y)) : y
xlab(x) = lgx ? "lg(|$x|)" : x
ylab(y) = lgy ? "lg(|$y|)" : y
fig = Figure(width = 800, height = 600)
ax1 = Axis(fig[2:3, 1]; xticklabelsvisible = false)
ax2 = Axis(fig[4, 1]; xlabel = xlab(xlabel), yminorticks = IntervalsBetween(10), yminorgridvisible = true)
maxerr = 0.0
xlims = (NaN, NaN)
linewidth = 2
lines!(ax1, [NaN]; color = :black, linewidth, linestyle = :solid, label = "CLASS") # dummy for legend entry
lines!(ax1, [NaN]; color = :black, linewidth, linestyle = :dash, label = "SymBoltz") # dummy for legend entry
for (i, (y1, y2, ylabel)) in enumerate(zip(y1s, y2s, ylabels))
x1, x2 = x1s, x2s
x1min, x1max = extrema(x1)
x2min, x2max = extrema(x2)
plotx1 = xplot(x1) # https://discourse.julialang.org/t/makie-axis-limits-with-inf/85784
plotx2 = xplot(x2)
ploty1 = replace!(yplot(y1), -Inf => NaN, Inf => NaN)
ploty2 = replace!(yplot(y2), -Inf => NaN, Inf => NaN)
color = Makie.wong_colors()[i]
lines!(ax1, plotx1, ploty1; color, alpha = 0.6, linewidth, linestyle = :solid)
lines!(ax1, plotx2, ploty2; color, alpha = 0.6, linewidth, linestyle = :dash)
lines!(ax1, [NaN]; color, linewidth, label = ylab(ylabel)) # dummy for legend entry
if i == 1
xlims = (x1min, x1max) # initialize so not NaN
end
xlims = if common
(max(xlims[1], x1min, x2min), min(xlims[2], x1max, x2max))
else
(min(xlims[1], x1min, x2min), max(xlims[2], x1max, x2max))
end
xmin = max(x1min, x2min) # need common times to compare error
xmax = min(x1max, x2max) # need common times to compare error
i1s = xmin .≤ x1 .≤ xmax # select x values in common range and exclude points too close in time (probably due to CLASS switching between approximation schemes)
i2s = xmin .≤ x2 .≤ xmax # select x values in common range
x1 = x1[i1s]
x2 = x2[i2s]
y1 = y1[i1s]
y2 = y2[i2s]
x = x2 # compare ratios at SymBoltz' times
y1 = LinearInterpolation(y1, x1; extrapolation = ExtrapolationType.Linear).(x) # TODO: use built-in CosmoloySolution interpolation
y2 = LinearInterpolation(y2, x2; extrapolation = ExtrapolationType.Linear).(x)
!isnothing(tol) && @assert all(isapprox.(y1, y2; atol = tol)) "$ylabel does not match within absolute tolerance $tol. Maximum difference was $(maximum(abs.(y1.-y2)))."
# Compare absolute error if quantity crosses zero, otherwise relative error (unless overridden)
abserr = (errtype == :abs) || (errtype == :auto && (any(y1 .<= 0) || any(y2 .<= 0)))
if abserr
err = y2 .- y1
ylabel = "SymBoltz - CLASS"
else
err = y2 ./ y1 .- 1
ylabel = "SymBoltz / CLASS - 1"
end
ax2.ylabel = ylabel
maxerr = max(maxerr, maximum(abs.(err)))
lines!(ax2, xplot(x), err; color, alpha = 1.0, linewidth)
end
# write maximum error like a * 10^b (for integer a and b)
if isnan(errlim)
b = floor(log10(maxerr))
a = ceil(maxerr / 10^b)
errlim = a * 10^b
end
Makie.ylims!(ax2, (-errlim, +errlim))
xlims = xplot.(xlims) # transform to log?
Makie.xlims!(ax1, xlims)
Makie.xlims!(ax2, xlims)
#axislegend(ax1)
fig[1, 1] = Legend(fig, ax1; padding = (0, 0, 0, 0), margin = (0, 0, 0, 0), framevisible = false, orientation = :horizontal)
return fig
end
Running CLASS version v3.3.1
Computing background
-> non-cold dark matter species with i=1 has m_i = 6.000000e-02 eV (so m_i / omega_i =9.405928e+01 eV)
-> ncdm species i=1 sampled with 11 (resp. 11) points for purpose of background (resp. perturbation) integration. In the relativistic limit it gives Delta N_eff = 0.999997
Chose ndf15 as generic_evolver
-> age = 13.524806 Gyr
-> conformal age = 13964.401666 Mpc
-> N_eff = 3.99 (summed over all species that are non-relativistic at early times)
-> radiation/matter equality at z = 3020.944008
corresponding to conformal time = 119.706325 Mpc
---------------------------- Budget equation -----------------------
---> Nonrelativistic Species
-> Bayrons Omega = 0.0493678 , omega = 0.0224
-> Cold Dark Matter Omega = 0.26447 , omega = 0.12
---> Non-Cold Dark Matter Species (incl. massive neutrinos)
-> Non-Cold Species Nr. 1 Omega = 0.00140587 , omega = 0.000637896
---> Relativistic Species
-> Photons Omega = 5.45025e-05 , omega = 2.47298e-05
-> Ultra-relativistic relics Omega = 3.701e-05 , omega = 1.67928e-05
---> Other Content
-> Dark Energy Fluid Omega = 0.684664 , omega = 0.310658
---> Total budgets
Radiation Omega = 9.15124e-05 , omega = 4.15226e-05
Non-relativistic Omega = 0.315244 , omega = 0.143038
- Non-Free-Streaming Matter Omega = 0.313838 , omega = 0.1424
- Non-Cold Dark Matter Omega = 0.00140587 , omega = 0.000637896
Other Content Omega = 0.684664 , omega = 0.310658
TOTAL Omega = 1 , omega = 0.453737
--------------------------------------------------------------------
┌ Warning: Multi-threading over perturbation modes was requested, but BLAS is running with 2 threads.
│ It is recommended to restrict BLAS to one thread with `using LinearAlgebra: BLAS; BLAS.set_num_threads(1)`.
│ For more information, see https://docs.julialang.org/en/v1/manual/performance-tips/#man-multithreading-linear-algebra.
└ @ SymBoltz ~/work/SymBoltz.jl/SymBoltz.jl/src/solve.jl:414
Background
Conformal time
a1 = (1 ./ (sol1["background"][:,"z"] .+ 1))
a2 = sol2[M.g.a]
χ1 = sol1["background"][:,"conf. time [Mpc]"][end].-sol1["background"][:,"conf. time [Mpc]"]
χ2 = sol2[M.χ] / (h*SymBoltz.k0)
plot_compare(a1, a2, χ1, χ2, "a", "χ"; tol = 2e-3)

Hubble function
E1 = sol1["background"][:,"H [1/Mpc]"]./sol1["background"][end,"H [1/Mpc]"]
E2 = sol2[M.g.E]
plot_compare(a1, a2, E1, E2, "a", "E"; lgx=true, lgy=true, tol = 2e8)

Energy densities
ρ1 = map(s -> sol1["background"][:,"(.)rho_$s"], ["g", "ur", "cdm", "b", "fld", "ncdm[0]"])
ρ2 = map(s -> sol2[s.ρ] * 8π/3*(h*SymBoltz.k0)^2, [M.γ, M.ν, M.c, M.b, M.X, M.h])
plot_compare(a1, a2, ρ1, ρ2, "a", ["ργ", "ρb", "ρc", "ρX", "ρν", "ρh"]; lgx=true, lgy=true, tol = 2e15)

Equations of state
wh1 = sol1["background"][:,"(.)p_ncdm[0]"] ./ sol1["background"][:,"(.)rho_ncdm[0]"]
wh2 = sol2[M.h.w]
wX1 = sol1["background"][:,"(.)w_fld"]
wX2 = sol2[M.X.w]
plot_compare(a1, a2, [wh1, wX1], [wh2, wX2], "a", ["wh", "wX"]; lgx=true, tol = 2e-4)

Photon-baryon sound horizon
rs1 = sol1["background"][:,"comov.snd.hrz."]
rs2 = sound_horizon(sol2) ./ (h*SymBoltz.k0)
plot_compare(a1, a2, rs1, rs2, "a", "rₛ"; lgx = true, tol = 2e-2)

Luminosity distance
dL1 = sol1["background"][:,"lum. dist."]
dL2 = SymBoltz.distance_luminosity(sol2) / SymBoltz.Mpc
plot_compare(a1, a2, dL1, dL2, "a", "dL"; lgx=true, lgy=true, tol = 1e6)

Thermodynamics
Optical depth derivative
a1 = reverse(sol1["thermodynamics"][:,"scale factor a"])
a2 = sol2[M.g.a]
dκ1 = reverse(sol1["thermodynamics"][:,"kappa' [Mpc^-1]"])
dκ2 = -sol2[M.b.κ̇] * (h*SymBoltz.k0)
plot_compare(a1, a2, dκ1, dκ2, "a", "κ̇"; lgx=true, lgy=true, tol = 2e3)

Optical depth exponential
expmκ1 = reverse(sol1["thermodynamics"][:,"exp(-kappa)"])
expmκ2 = sol2[M.b.I]
plot_compare(a1, a2, expmκ1, expmκ2, "a", "exp(-κ)"; lgx=true, tol = 4e-5)

Visibility function
v1 = reverse(sol1["thermodynamics"][:,"g [Mpc^-1]"])
v2 = sol2[M.b.v] * (h*SymBoltz.k0)
plot_compare(a1, a2, v1, v2, "a", "v"; lgx=true, lgy=false, tol = 2e-6)

Free electron fraction
Xe1 = reverse(sol1["thermodynamics"][:,"x_e"])
Xe2 = sol2[M.b.Xe]
plot_compare(a1, a2, Xe1, Xe2, "a", "Xe"; lgx=true, lgy=false, tol = 5e-4)

Baryon temperature
Tb1 = reverse(sol1["thermodynamics"][:,"Tb [K]"])
Tb2 = sol2[M.b.T]
dTb1 = reverse(sol1["thermodynamics"][:,"dTb [K]"])
dTb2 = sol2[M.b.DT] ./ -sol2[M.g.E] # convert my dT/dt̂ to CLASS' dT/dz = -1/H * dT/dt
plot_compare(a1, a2, [Tb1, dTb1], [Tb2, dTb2], "a", ["Tb", "dTb"]; lgx=true, lgy=true, tol = 6e0)

Baryon equation of state
# baryon equation of state parameter (e.g. https://arxiv.org/pdf/1906.06831 eq. (B10))
wb1 = reverse(sol1["thermodynamics"][:,"w_b"])
wb2 = sol2[SymBoltz.kB*M.b.T/M.b.μc²]
plot_compare(a1, a2, wb1, wb2, "a", "wb"; lgx=true, lgy=true, tol = 3e-10)

Baryon sound speed
csb²1 = reverse(sol1["thermodynamics"][:,"c_b^2"])
csb²2 = sol2[M.b.cₛ²]
plot_compare(a1, a2, csb²1, csb²2, "a", "csb²"; lgx=true, lgy=true, tol = 4e-10)

Perturbations
Metric potentials
a1 = sol1["perturbations_k0_s"][:,"a"]
a2 = sol2[1, M.g.a]
Φ1, Ψ1 = sol1["perturbations_k0_s"][:,"phi"], sol1["perturbations_k0_s"][:,"psi"]
Φ2, Ψ2 = sol2[1, M.g.Φ], sol2[1, M.g.Ψ]
plot_compare(a1, a2, [Φ1, Ψ1], [Φ2, Ψ2], "a", ["Ψ", "Φ"]; lgx=true, tol = 2e-4)

Energy overdensities
δ1 = map(s -> sol1["perturbations_k0_s"][:,"delta_$s"], ["b", "cdm", "g", "ur", "ncdm[0]"])
δ2 = map(s -> sol2[1, s.δ], [M.b, M.c, M.γ, M.ν, M.h])
plot_compare(a1, a2, δ1, δ2, "a", ["δb", "δc", "δγ", "δν", "δh"]; lgx=true, lgy=true)

Momenta
θ1 = map(s -> sol1["perturbations_k0_s"][:,"theta_$s"], ["b", "cdm", "g", "ur", "ncdm[0]"])
θ2 = map(s -> sol2[1, s.θ] * (h*SymBoltz.k0), [M.b, M.c, M.γ, M.ν, M.h])
plot_compare(a1, a2, θ1, θ2, "a", ["θb", "θc", "θγ", "θν", "θh"]; lgx=true, lgy=true, tol = 2e-2)

Dark energy overdensity
δρX1 = sol1["perturbations_k0_s"][:,"delta_rho_fld"]
δρX2 = sol2[1, M.X.δ*M.X.ρ] * 8π/3*(h*SymBoltz.k0)^2
plot_compare(a1, a2, δρX1, δρX2, "a", "δρX"; lgx=true, lgy=true, tol = 2e-7)

Dark energy momentum
pX1 = sol1["perturbations_k0_s"][:,"rho_plus_p_theta_fld"]
pX2 = sol2[1, (M.X.ρ+M.X.P)*M.X.θ * 8π/3*(h*SymBoltz.k0)^3]
plot_compare(a1, a2, pX1, pX2, "a", "pX"; lgx=true, lgy=true, tol = 7e-8)

Shear stresses
σ1 = [sol1["perturbations_k0_s"][:,"shear_g"], sol1["perturbations_k0_s"][:,"shear_ur"]]
σ2 = [sol2[1, M.γ.σ], sol2[1, M.ν.F[2]/2]]
plot_compare(a1, a2, σ1, σ2, "a", ["σγ", "σν"]; lgx=true, tol = 6e-4)

Polarization
P1 = map(n -> sol1["perturbations_k0_s"][:,"pol$(n)_g"], 0:2)
P2 = [sol2[1, var] for var in [M.γ.G0, M.γ.G[1], M.γ.G[2]]]
plot_compare(a1, a2, P1, P2, "a", ["P0", "P1", "P2"]; lgx=true, tol = 4e-5)

Matter power spectrum
function P_class(pars)
sol = solve_class(pars)
h = pars[M.g.h]
k = sol["pk"][:,"k (h/Mpc)"] * h
P = sol["pk"][:,"P (Mpc/h)^3"] / h^3
return k, P
end
function P_class(k, pars)
k′, P′ = P_class(pars)
P = exp.(LinearInterpolation(log.(P′), log.(k′)).(log.(k)))
return P
end
function P_symboltz(k, pars)
prob′ = parameter_updater(prob, collect(keys(pars)))(collect(values(pars))) # TODO: move outside; common for Pk and Cl
P = spectrum_matter(prob′, k / u"Mpc") / u"Mpc^3"
return P
end
k, P1 = P_class(pars)
P2 = P_symboltz(k, pars)
plot_compare(k, k, P1, P2, "k/Mpc⁻¹", "P/Mpc³"; lgx = true, lgy = true, tol = 4e1)

using ForwardDiff, FiniteDiff
k = 10 .^ range(-3, 0, length=100) # 1/Mpc
function plot_compare_P_diff(par, val; relstep = 1e-2, kwargs...)
out = zeros(length(k))
f = function(out, logval)
val = exp(logval)
out .= log.(P_class(k, merge(pars, Dict(par => val))))
return out
end
Δt1 = @elapsed ∂logP1_∂logθ = FiniteDiff.finite_difference_gradient!(out, f, log(val), Val{:central}; relstep)
println("Computed CLASS derivatives in $Δt1 seconds")
Δt2 = @elapsed ∂logP2_∂logθ = ForwardDiff.derivative(logval -> log.(P_symboltz(k, Dict(par => exp(logval)))), log(val))
println("Computed SymBoltz derivatives in $Δt2 seconds")
return plot_compare(k, k, ∂logP1_∂logθ, ∂logP2_∂logθ, "k", "∂(log(P))/∂(log($(replace(string(par), "₊" => "."))))"; lgx = true, kwargs...)
end
plot_compare_P_diff(M.c.Ω₀, pars[M.c.Ω₀]; relstep = 1e-3, tol = 1e-1) # smaller relstep is noisier

plot_compare_P_diff(M.b.Ω₀, pars[M.b.Ω₀]; relstep = 1e-3, tol = 1e-1) # smaller relstep is noisier

plot_compare_P_diff(M.g.h, pars[M.g.h]; relstep = 1e-3, tol = 1e-1) # smaller relstep is noisier

CMB power spectrum
function Dl_class(modes, l, pars)
sol = solve_class(pars)
lout = sol["cl"][:,"l"]
Dl = [sol["cl"][:,string(mode)] for mode in modes]
i = findall(l′ -> l′ in l, lout)
Dl = [Dl[j][i] for j in eachindex(modes)]
return stack(Dl)
end
function Dl_symboltz(modes, l, pars; kwargs...)
prob′ = parameter_updater(prob, collect(keys(pars)))(collect(values(pars)))
return spectrum_cmb(modes, prob′, l; normalization = :Dl, kwargs...)
end
l = 20:20:2000 # CLASS default is lmax = 2500
Dl1 = Dl_class([:TT, :TE, :EE], l, pars)
Dl2 = Dl_symboltz([:TT, :TE, :EE], l, pars)
plot_compare(l, l, Dl1[:, 1], Dl2[:, 1], "l", "Dₗ(TT)"; tol = 8e-13)

plot_compare(l, l, Dl1[:, 2], Dl2[:, 2], "l", "Dₗ(TE)"; tol = 7e-14)

plot_compare(l, l, Dl1[:, 3], Dl2[:, 3], "l", "Dₗ(EE)"; tol = 2e-14)

diffpars = [M.c.Ω₀, M.b.Ω₀, M.g.h]
θ = [pars[par] for par in diffpars]
modes = [:TT, :TE, :EE]
Δt1 = @elapsed ∂Dl1_∂θ = FiniteDiff.finite_difference_jacobian(θ -> Dl_class(modes, l, merge(pars, Dict(diffpars .=> θ))), θ, Val{:central}; relstep = 1e-3)
println("Computed CLASS derivatives in $Δt1 seconds")
Δt2 = @elapsed ∂Dl2_∂θ = ForwardDiff.jacobian(θ -> Dl_symboltz(modes, l, Dict(diffpars .=> θ)), θ)
println("Computed SymBoltz derivatives in $Δt2 seconds")
# returned matrices have size (length(l)*length(modes), length(diffpars)); reshape to (length(l), length(modes), length(diffpars))
∂Dl1_∂θ_3d = reshape(∂Dl1_∂θ, (length(l), length(modes), length(diffpars)))
∂Dl2_∂θ_3d = reshape(∂Dl2_∂θ, (length(l), length(modes), length(diffpars)))
plot_compare(l, l, eachcol(∂Dl1_∂θ_3d[:,1,:]), eachcol(∂Dl2_∂θ_3d[:,1,:]), "l", ["∂(Dₗ)/∂($(replace(string(par), "₊" => "."))) (TT)" for par in diffpars]; tol = 1e-10)

plot_compare(l, l, eachcol(∂Dl1_∂θ_3d[:,2,:]), eachcol(∂Dl2_∂θ_3d[:,2,:]), "l", ["∂(Dₗ)/∂($(replace(string(par), "₊" => "."))) (TE)" for par in diffpars]; tol = 1e-11)

plot_compare(l, l, eachcol(∂Dl1_∂θ_3d[:,3,:]), eachcol(∂Dl2_∂θ_3d[:,3,:]), "l", ["∂(Dₗ)/∂($(replace(string(par), "₊" => "."))) (EE)" for par in diffpars]; tol = 1e-12)
