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)
Example block output

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)
Example block output

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)
Example block output

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)
Example block output

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)
Example block output

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)
Example block output

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)
Example block output

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)
Example block output

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)
Example block output

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)
Example block output

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)
Example block output

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)
Example block output

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)
Example block output

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)
Example block output

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)
Example block output

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)
Example block output

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)
Example block output

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)
Example block output

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)
Example block output

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)
Example block output

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)
Example block output
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
Example block output
plot_compare_P_diff(M.b.Ω₀, pars[M.b.Ω₀]; relstep = 1e-3, tol = 1e-1) # smaller relstep is noisier
Example block output
plot_compare_P_diff(M.g.h, pars[M.g.h]; relstep = 1e-3, tol = 1e-1) # smaller relstep is noisier
Example block output

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)
Example block output
plot_compare(l, l, Dl1[:, 2], Dl2[:, 2], "l", "Dₗ(TE)"; tol = 7e-14)
Example block output
plot_compare(l, l, Dl1[:, 3], Dl2[:, 3], "l", "Dₗ(EE)"; tol = 2e-14)
Example block output
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)
Example block output
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)
Example block output
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)
Example block output