Tutorial 3: Halo mass functions and collapse thresholds

The halo mass function \(\mathrm{d}n/\mathrm{d}M\) — the comoving number density of dark matter halos per unit mass — is the backbone of any semi-analytic model. Its ingredients are all objects from Tutorial 2 plus two collapse-threshold classes:

  • criticalOverdensity — the linearly-extrapolated collapse threshold \(\delta_\mathrm{c}(t)\) from spherical-collapse theory (\(\simeq 1.686\) for Einstein-de Sitter);

  • virialDensityContrast — the mean density contrast \(\Delta_\mathrm{vir}(t)\) of a just-virialized halo (\(\simeq 178\) for Einstein-de Sitter, higher in \(\Lambda\)CDM today).

We compare the Sheth & Tormen (1999) and Tinker et al. (2008) mass functions and follow their evolution to high redshift.

[1]:
import os, sys

# Locate the Galacticus library interface. Two supported layouts:
#  * a Galacticus source tree built with
#      make GALACTICUS_BUILD_OPTION=lib libgalacticus.so
#    (galacticus.py at the tree root, the library under galacticus/lib/);
#  * an unpacked binary distribution (the `galacticus/` folder from
#    libgalacticus.tar.bz2, with python/ and lib/ inside it).
# Set GALACTICUS_LIBRARY_PATH to the directory CONTAINING the `galacticus/`
# folder if the auto-detection below does not fit your setup.
root = os.environ.get('GALACTICUS_LIBRARY_PATH',
                      os.path.abspath(os.path.join(os.getcwd(), os.pardir)))
os.chdir(root)   # galacticus.py loads galacticus/lib/libgalacticus.so relative to here
for candidate in (root, os.path.join(root, 'galacticus', 'python')):
    if os.path.exists(os.path.join(candidate, 'galacticus.py')):
        sys.path.insert(0, candidate)
        break
else:
    raise RuntimeError(f"galacticus.py not found under {root} - build the library "
                       "(make GALACTICUS_BUILD_OPTION=lib libgalacticus.so) or set "
                       "GALACTICUS_LIBRARY_PATH")

import numpy as np
import matplotlib.pyplot as plt
import galacticus
print("Galacticus library interface loaded.")
Galacticus library interface loaded.
[2]:
plt.rcParams.update({'figure.figsize': (7.0, 4.5), 'font.size': 11,
                     'axes.grid': True, 'grid.alpha': 0.3})
[3]:
# Assemble the sigma(M) chain (see Tutorial 2 for a walk-through).
cosmologyParameters = galacticus.cosmologyParametersSimple(0.3, 0.045, 0.7, 2.78, 70.0)
cosmologyFunctions  = galacticus.cosmologyFunctionsMatterLambda(cosmologyParameters)
darkMatterParticle  = galacticus.darkMatterParticleCDM()
transferFunction    = galacticus.transferFunctionEisensteinHu1999(
    3.046, 0.0, darkMatterParticle, cosmologyParameters, cosmologyFunctions)
linearGrowth        = galacticus.linearGrowthCollisionlessMatter(cosmologyParameters, cosmologyFunctions)
powerSpectrumPrimordial = galacticus.powerSpectrumPrimordialPowerLaw(0.965, 0.0, 0.0, 1.0, False)
powerSpectrum       = galacticus.powerSpectrumPrimordialTransferredSimple(
    powerSpectrumPrimordial, transferFunction, linearGrowth)
powerSpectrumWindowFunction = galacticus.powerSpectrumWindowFunctionTopHat(cosmologyParameters)
cosmologicalMassVariance = galacticus.cosmologicalMassVarianceFilteredPower(
    sigma8=0.8, tolerance=1.0e-4, toleranceTopHat=1.0e-4, nonMonotonicIsFatal=True,
    integrationFailureIsFatal=True, monotonicInterpolation=False,
    rootVarianceLogarithmicGradientTolerance=1.0e-4, truncateAtParticleHorizon=False,
    storeTabulations=True, cosmologyParameters_=cosmologyParameters,
    cosmologyFunctions_=cosmologyFunctions, linearGrowth_=linearGrowth,
    powerSpectrumPrimordialTransferred_=powerSpectrum,
    powerSpectrumWindowFunction_=powerSpectrumWindowFunction)
ageToday = cosmologyFunctions.cosmicTime(1.0)
def timeOf(z):
    return cosmologyFunctions.cosmicTime(cosmologyFunctions.expansionFactorFromRedshift(z))
print("sigma(M) chain ready.")
sigma(M) chain ready.

Collapse thresholds

\(\delta_\mathrm{c}\) and \(\Delta_\mathrm{vir}\) are computed here from full spherical-collapse solutions (not fits), tabulated on first use.

[4]:
criticalOverdensity = galacticus.criticalOverdensitySphericalCollapseClsnlssMttrCsmlgclCnstnt(
    linearGrowth, cosmologyFunctions, cosmologicalMassVariance, darkMatterParticle, True)
virialDensityContrast = galacticus.virialDensityContrastSphericalCollapseClsnlssMttrCsmlgclCnstnt(
    True, cosmologyFunctions)

zGrid  = np.linspace(0.0, 6.0, 60)
deltaC = np.array([criticalOverdensity.value(time=timeOf(z), mass=1.0e12)   for z in zGrid])
DeltaV = np.array([virialDensityContrast.densityContrast(1.0e12, time=timeOf(z)) for z in zGrid])

fig, axes = plt.subplots(1, 2, figsize=(11, 4))
axes[0].plot(zGrid, deltaC); axes[0].axhline(1.686, color='gray', ls=':', label='EdS value 1.686')
axes[0].set_xlabel('$z$'); axes[0].set_ylabel('$\\delta_\\mathrm{c}(z)$'); axes[0].legend()
axes[1].plot(zGrid, DeltaV); axes[1].axhline(177.65, color='gray', ls=':', label='EdS value 178')
axes[1].set_xlabel('$z$'); axes[1].set_ylabel('$\\Delta_\\mathrm{vir}(z)$'); axes[1].legend()
fig.suptitle('Spherical-collapse thresholds approach Einstein-de Sitter values at high $z$')
fig.tight_layout(); plt.show()
print(f"delta_c(z=0) = {deltaC[0]:.4f},  Delta_vir(z=0) = {DeltaV[0]:.1f}")
../_images/tutorials_03-halo-mass-function_5_0.png
delta_c(z=0) = 1.6756,  Delta_vir(z=0) = 339.8

The mass function today: Sheth-Tormen vs. Tinker et al.

differential(time, mass) returns \(\mathrm{d}n/\mathrm{d}M\) in units of Mpc\(^{-3} M_\odot^{-1}\); we plot \(\mathrm{d}n/\mathrm{d}\ln M = M\, \mathrm{d}n/\mathrm{d}M\). The two calibrations agree to tens of percent over the well-resolved range — differences of exactly the size that matter when you fit galaxy surveys.

[5]:
haloMassFunctionST = galacticus.haloMassFunctionShethTormen(
    cosmologyParameters, cosmologicalMassVariance, criticalOverdensity,
    0.707, 0.3, 0.322183)     # a, p, normalization (Sheth & Tormen 1999)
haloMassFunctionTinker = galacticus.haloMassFunctionTinker2008(
    cosmologyParameters, cosmologicalMassVariance, linearGrowth,
    cosmologyFunctions, virialDensityContrast)

masses = np.logspace(8, 15.5, 60)
fig, (axF, axR) = plt.subplots(2, 1, figsize=(7, 6.5), sharex=True,
                               gridspec_kw={'height_ratios': (3, 1)})
dndlnM = {}
for label, hmf in (('Sheth-Tormen', haloMassFunctionST), ('Tinker+2008', haloMassFunctionTinker)):
    dndlnM[label] = np.array([M*hmf.differential(ageToday, M) for M in masses])
    axF.loglog(masses, dndlnM[label], label=label)
axF.set_ylabel('$\\mathrm{d}n/\\mathrm{d}\\ln M$ [Mpc$^{-3}$]'); axF.legend()
axF.set_title('Halo mass function at $z=0$')
axR.semilogx(masses, dndlnM['Tinker+2008']/dndlnM['Sheth-Tormen'])
axR.axhline(1.0, color='gray', ls=':')
axR.set_xlabel('$M$ [$M_\\odot$]'); axR.set_ylabel('Tinker / ST')
fig.tight_layout(); plt.show()
../_images/tutorials_03-halo-mass-function_7_0.png

Evolution with redshift

The exponential high-mass cut-off tracks \(\delta_\mathrm{c}/\sigma(M,t)\): cluster-mass halos are exponentially rare at \(z=4\), while the dwarf-scale mass function barely moves.

[6]:
for z, color in ((0.0, 'C0'), (1.0, 'C1'), (2.0, 'C2'), (4.0, 'C3')):
    t = timeOf(z)
    plt.loglog(masses, [M*haloMassFunctionST.differential(t, M) for M in masses],
               color=color, label=f'$z={z:.0f}$')
plt.ylim(1.0e-12, 1.0e2)
plt.xlabel('$M$ [$M_\\odot$]'); plt.ylabel('$\\mathrm{d}n/\\mathrm{d}\\ln M$ [Mpc$^{-3}$]')
plt.legend(); plt.title('Sheth-Tormen mass function through cosmic time')
plt.show()
../_images/tutorials_03-halo-mass-function_9_0.png

How much matter lives in halos?

massFraction(time, massLow, massHigh) integrates the mass-weighted mass function — the collapsed fraction. Watching it grow with cosmic time is hierarchical structure formation in a single number.

[7]:
zGrid = np.array([6.0, 4.0, 3.0, 2.0, 1.0, 0.5, 0.0])
fractions = [haloMassFunctionST.massFraction(timeOf(z), 1.0e8, 1.0e16) for z in zGrid]
for z, f in zip(zGrid, fractions):
    print(f"  z = {z:3.1f}:  fraction of matter in halos above 1e8 Msun = {f:.3f}")
plt.plot(zGrid, fractions, 'o-')
plt.gca().invert_xaxis()
plt.xlabel('redshift $z$'); plt.ylabel('collapsed mass fraction ($M > 10^8\\,M_\\odot$)')
plt.title('Hierarchical growth of structure')
plt.show()
  z = 6.0:  fraction of matter in halos above 1e8 Msun = 0.094
  z = 4.0:  fraction of matter in halos above 1e8 Msun = 0.190
  z = 3.0:  fraction of matter in halos above 1e8 Msun = 0.260
  z = 2.0:  fraction of matter in halos above 1e8 Msun = 0.348
  z = 1.0:  fraction of matter in halos above 1e8 Msun = 0.454
  z = 0.5:  fraction of matter in halos above 1e8 Msun = 0.512
  z = 0.0:  fraction of matter in halos above 1e8 Msun = 0.570
../_images/tutorials_03-halo-mass-function_11_1.png

Try it yourself: swap the transfer function for one of the WDM variants from Tutorial 2 and re-run — the low-mass slope of the mass function flattens and turns over below the half-mode mass.