Source code for cosmofit.cosmology.models.lscdm

"""
Lambda_s CDM: the sign-switching cosmological constant.
"""

from __future__ import annotations

import numpy as np

from cosmofit.typing import Array, Redshift

from cosmofit.cosmology.numerics.powers import cube

from cosmofit.cosmology.core import Cosmology


[docs] class LsCDM(Cosmology): r""" Lambda_s CDM (Akarsu et al. 2021): LCDM, except that the cosmological constant *changes sign* at a redshift ``z_dagger``. E(z)^2 = Omega_m (1+z)^3 + Omega_k (1+z)^2 + Omega_Ls0 sgn(z_dagger - z) Below ``z_dagger`` this is ordinary LCDM with a positive (de Sitter) Lambda. Above it, Lambda is *negative* -- an anti-de Sitter vacuum -- which suppresses the expansion rate in the pre-transition universe. Why a discontinuous model is taken seriously -------------------------------------------- It looks like a hack, and the mechanism (a rapid AdS-to-dS vacuum transition, as in graduated dark energy or certain string-swampland constructions) is genuinely speculative. What earns it a place is that it addresses the H0 and S8 tensions *and* the BAO sound-horizon problem with a single parameter, by the one route that is otherwise hard to arrange: a lower expansion rate before ``z_dagger ~ 2`` shrinks the sound horizon ``r_d``, and a smaller ``r_d`` raises the ``H0`` inferred from a fixed BAO angular scale. Parametrizations that only alter late-time dark energy cannot do this, because they leave ``r_d`` untouched. The extra parameter is ``z_dagger``, with fits typically preferring ``z_dagger ~ 1.7-2.3``. Numerical note -------------- ``E(z)`` is genuinely discontinuous at ``z_dagger`` -- that is the model, not an artifact. The comoving distance ``chi(z) = int dz/E`` remains continuous (it acquires a kink, not a jump), but the trapezoidal grid in :class:`~cosmology.numerics.integrals.DistanceIntegrator` smears the step across one grid cell. Measured against a piecewise-exact integration that splits the integral at ``z_dagger``, the resulting error in ``chi`` is below 1e-4 relative -- an order of magnitude under the precision of any dataset in this library. It is quantified rather than assumed; see ``tests/test_models.py``. ``dEdz`` returns the derivative of the smooth part only. The distributional delta at the transition is not representable as a float and is not used by anything: ``chi`` integrates ``E`` rather than differentiating it, and the deceleration parameter ``q(z)`` is undefined exactly at a discontinuity anyway. References ---------- Akarsu, Kumar, Ozulker & Vazquez (2021), "Relaxing cosmological tensions with a sign switching cosmological constant", Phys. Rev. D 104, 123512, arXiv:2108.09239. Akarsu, Kumar, Ozulker, Vazquez & Yadav (2023), Phys. Rev. D 108, 023513, arXiv:2211.05742 (updated constraints). """ MODEL_NAME = "LsCDM" MODEL_LABEL = r"$\Lambda_{\rm s}$CDM" EXTRA_PARAMS = { "z_dagger": { "default": 1.8, "bounds": (0.5, 4.0), "label": r"$z_\dagger$", }, } # --------------------------------------------------------- def _sign(self, z): """ ``sgn(z_dagger - z)``: +1 below the transition, -1 above. ``np.sign`` returns 0 exactly at ``z = z_dagger``, which would put a spurious zero-Lambda point in the middle of the expansion history; ``+1`` is used there instead, matching the convention that the transition happens *at* z_dagger rather than in an infinitesimal interval around it. """ z = np.asarray(z, dtype=float) return np.where(z <= self.z_dagger, 1.0, -1.0) # --------------------------------------------------------- def background_jumps(self) -> tuple[float, ...]: """ ``E`` jumps at ``z_dagger``; see the class docstring. """ return (float(self.z_dagger),) # --------------------------------------------------------- def Omega_de(self, z: Redshift) -> Array: return self.Omega_de0 * self._sign(z) # --------------------------------------------------------- def E(self, z: Redshift) -> Array: z = np.asarray(z, dtype=float) e2 = ( self.Omega_m * cube(1.0 + z) + self.Omega_k * (1.0 + z) ** 2 + self.Omega_de0 * self._sign(z) ) # A negative Lambda above z_dagger can in principle # overwhelm the matter term for a low enough Omega_m, which # would mean a universe that recollapses before z_dagger -- # not a numerical problem but a parameter region the model # does not describe. Surface it rather than returning nan # from the sqrt and letting the sampler wander there. if np.any(e2 <= 0.0): raise ValueError( f"LsCDM: E(z)^2 <= 0 for Omega_m=" f"{self.Omega_m:.4f}, Omega_k={self.Omega_k:.4f}, " f"z_dagger={self.z_dagger:.3f}. Above z_dagger the " f"negative (AdS) Lambda exceeds the matter density, " f"so there is no expanding solution there.", ) return np.sqrt(e2) # --------------------------------------------------------- def dEdz(self, z: Redshift) -> Array: z = np.asarray(z, dtype=float) return ( ( 3.0 * self.Omega_m * (1.0 + z) ** 2 + 2.0 * self.Omega_k * (1.0 + z) ) / (2.0 * self.E(z)) )