Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/core.rst
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,11 @@ Domains (:mod:`hmf.core.domain`)
Every model declares a *valid* domain (outside which it always raises a
:class:`~hmf.core.domain.DomainError`) and a *calibration* domain (outside which
a user-chosen :data:`~hmf.core.domain.DomainPolicy` applies: ``"ignore"``,
``"warn"``, ``"mask"`` or ``"raise"``), or ``None`` for no calibration domain. A
``"warn"``, ``"mask"`` or ``"raise"``), or ``None`` for no calibration domain.
Ask a model instance for its calibration domain, ``model.calibration``
(:class:`~hmf.core.domain.Calibrated`): it can depend on the model's parameters
(e.g. :class:`~hmf.core.concentration.Duffy08`'s redshift range), and defaults to
the class's ``calibration_domain``, that of its default parameters. A
:class:`~hmf.core.domain.Domain` bounds each variable by an interval whose ends
are closed (``>=``, ``<=``) or open (``>``, ``<``): ``(0, None, "(]")`` is
``> 0``. :meth:`~hmf.core.domain.Domain.describe` writes it as inequalities, and
Expand Down
2 changes: 1 addition & 1 deletion docs/templates/core-model-class.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
{#- A model's parameters are listed in the "Parameters" section generated from its
fields. They get no pages of their own: names that differ only in case (A_200,
a_200) would clash. Only the model's metadata is listed here. #}
{% set metadata = ["requires", "valid_domain", "calibration_domain",
{% set metadata = ["requires", "valid_domain", "calibration_domain", "calibration",
"measured_mass_definition", "measured_mass_definition_note",
"simulations", "references",
"parameter_source",
Expand Down
10 changes: 4 additions & 6 deletions src/hmf/core/_cosmology_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
:class:`_CosmologyModel` holds the class-level scaffolding that such a kind
(:class:`~hmf.core.transfer_models.TransferModel`,
:class:`~hmf.core.growth_models.GrowthModel`) declares: the Boltzmann ``backend``,
the ``valid_domain`` and ``calibration_domain``, and the ``check_cosmology`` hook.
the ``valid_domain``, the calibration domain (from
:class:`~hmf.core.domain.Calibrated`), and the ``check_cosmology`` hook.
:class:`_BoltzmannBacked` is the cosmology check of the models computed by a
Boltzmann code (CAMB or CLASS), for transfer and growth alike.

Expand All @@ -18,10 +19,10 @@
from astropy.cosmology import FLRW

from ._boltzmann import check_boltzmann_cosmology
from .domain import Domain
from .domain import Calibrated, Domain


class _CosmologyModel:
class _CosmologyModel(Calibrated):
"""The class-level scaffolding of a kind of models computed from a cosmology.

The kind sets :attr:`valid_domain`; the other class variables default to
Expand All @@ -37,9 +38,6 @@ class _CosmologyModel:
#: model's class's domain, so a model can narrow it.
valid_domain: ClassVar[Domain]

#: Where the model was calibrated, if that is stated by its source.
calibration_domain: ClassVar[Domain | None] = None

def check_cosmology(self, cosmology: FLRW) -> None:
"""Raise if the model does not apply to ``cosmology`` (by default it does).

Expand Down
109 changes: 13 additions & 96 deletions src/hmf/core/_kernels/mass_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@
point, each elementwise, so the result does not depend on the batch size, and
:func:`profile_convert` gives the masses and the analytic Jacobian. For NFW
(:func:`nfw_ln_mu_and_slope`) the starting point is the fit of Hu & Kravtsov (2003,
eq. C11; :func:`hu_kravtsov_inverse`), and :func:`nfw_concentration_ratio_solve` and
:func:`nfw_convert` are the two with it.
eq. C11; :func:`nfw_start`). :class:`~hmf.core.profiles.NFW` calls the two with
them, through :meth:`DensityProfile.convert_concentration_kernel
<hmf.core.profiles.DensityProfile.convert_concentration_kernel>`.

The functions follow the rules of :mod:`hmf.core._kernels` and do not check their
inputs: the kernel-level entry points of :mod:`hmf.core.profiles` do.
Expand All @@ -45,12 +46,8 @@
"LnMuAndSlope",
"concentration_ratio_solve",
"hu_kravtsov_inverse",
"nfw_concentration_ratio_solve",
"nfw_convert",
"nfw_ln_mean_density",
"nfw_ln_mu",
"nfw_ln_mu_and_slope",
"nfw_ln_mu_slope",
"nfw_start",
"power_law_start",
"profile_convert",
Expand All @@ -61,14 +58,15 @@
#: A profile's :math:`x \mapsto (\ln\mu(x), \psi(x))`, on plain arrays.
LnMuAndSlope = Callable[[FloatArray], tuple[FloatArray, FloatArray]]

#: The number of Newton steps that polish the Hu & Kravtsov (2003) starting point.
#: The number of Newton steps that polish the Hu & Kravtsov (2003) starting point
#: (:attr:`hmf.core.profiles.NFW.newton_steps`).
#: Newton converges quadratically from its error (below 1% for 0 < c < 400): for
#: 1e-3 < c < 1e4 and 1e-4 < q < 1e4 two steps leave 1e-12 and three round-off
#: (~1e-13 in c); the fourth is a margin.
NEWTON_STEPS = 4

#: Below this x, :func:`nfw_ln_mu` uses the series of mu(x), whose closed form loses
#: digits to cancellation (relative error ~ 1e-16 / x^2).
#: Below this x, :func:`nfw_ln_mu_and_slope` uses the series of mu(x), whose closed
#: form loses digits to cancellation (relative error ~ 1e-16 / x^2).
_SERIES_X = 0.01

#: The parameters (a1, a2, a3, a4) of Hu & Kravtsov (2003), eq. C11.
Expand All @@ -78,7 +76,11 @@
def nfw_ln_mu_and_slope(x: npt.ArrayLike) -> tuple[FloatArray, FloatArray]:
r"""The NFW :math:`\ln\mu(x)` and :math:`\psi(x)`, from one evaluation of :math:`\mu`.

See :func:`nfw_ln_mu` and :func:`nfw_ln_mu_slope`.
:math:`\mu(x) = \ln(1+x) - x/(1+x)`; below x = 0.01 it uses the series
:math:`\mu(x) = \sum_{n\ge2} (-1)^n (n-1)x^n/n` (to :math:`x^{9}`), which avoids
the cancellation in the closed form. The slope
:math:`\psi(x) = d\ln\mu/d\ln x = x^2/((1+x)^2\mu(x))` falls from 2 (as
:math:`x \to 0`) to 0 (as :math:`x \to \infty`).

Parameters
----------
Expand Down Expand Up @@ -106,44 +108,6 @@ def nfw_ln_mu_and_slope(x: npt.ArrayLike) -> tuple[FloatArray, FloatArray]:
return ln_mu, psi


def nfw_ln_mu(x: npt.ArrayLike) -> FloatArray:
r"""The log of the NFW mass function :math:`\mu(x) = \ln(1+x) - x/(1+x)`.

Below x = 0.01 it uses the series
:math:`\mu(x) = \sum_{n\ge2} (-1)^n (n-1)x^n/n` (to :math:`x^{9}`), which avoids
the cancellation in the closed form.

Parameters
----------
x
:math:`r/r_s` (> 0), dimensionless.

Returns
-------
numpy.ndarray
:math:`\ln\mu(x)`.
"""
return nfw_ln_mu_and_slope(np.asarray(x, dtype=np.float64))[0]


def nfw_ln_mu_slope(x: npt.ArrayLike) -> FloatArray:
r"""The logarithmic slope :math:`\psi(x) = d\ln\mu/d\ln x = x^2/((1+x)^2\mu(x))`.

It falls from 2 (as :math:`x \to 0`) to 0 (as :math:`x \to \infty`).

Parameters
----------
x
:math:`r/r_s` (> 0), dimensionless.

Returns
-------
numpy.ndarray
:math:`\psi(x)`, dimensionless.
"""
return nfw_ln_mu_and_slope(np.asarray(x, dtype=np.float64))[1]


def nfw_ln_mean_density(x: npt.ArrayLike) -> FloatArray:
r""":math:`\ln F(x) = \ln[\mu(x)/x^3]`: the mean enclosed density over :math:`3\rho_s`.

Expand All @@ -161,7 +125,7 @@ def nfw_ln_mean_density(x: npt.ArrayLike) -> FloatArray:
:math:`\ln F(x)`.
"""
x = np.asarray(x, dtype=np.float64)
out: FloatArray = nfw_ln_mu(x) - 3.0 * np.log(x)
out: FloatArray = nfw_ln_mu_and_slope(x)[0] - 3.0 * np.log(x)
return out


Expand Down Expand Up @@ -328,50 +292,3 @@ def nfw_start(c: FloatArray, ln_q: FloatArray) -> FloatArray:
r"""The NFW starting point: Hu & Kravtsov (2003) at :math:`\ln F(c) + \ln q`."""
c = np.asarray(c, dtype=np.float64)
return hu_kravtsov_inverse(nfw_ln_mean_density(c) + ln_q)


def nfw_concentration_ratio_solve(c: npt.ArrayLike, ln_q: npt.ArrayLike) -> FloatArray:
r"""The NFW concentration :math:`c'` with :math:`F(c') = q F(c)`, elementwise.

:func:`concentration_ratio_solve` for NFW, from :func:`hu_kravtsov_inverse`, with
:data:`NEWTON_STEPS` steps, so the error is at round-off.

Parameters
----------
c
The concentrations (> 0), dimensionless.
ln_q
:math:`\ln q`, broadcast with ``c``.

Returns
-------
numpy.ndarray
:math:`c'`, with the broadcast shape.
"""
c = np.asarray(c, dtype=np.float64)
ln_q = np.asarray(ln_q, dtype=np.float64)
return concentration_ratio_solve(nfw_ln_mu_and_slope, c, ln_q, nfw_start(c, ln_q), NEWTON_STEPS)


def nfw_convert(
c: npt.ArrayLike, slope: npt.ArrayLike, ln_q: npt.ArrayLike
) -> tuple[FloatArray, FloatArray, FloatArray, FloatArray]:
r""":func:`profile_convert` for NFW haloes (see :func:`nfw_concentration_ratio_solve`).

Parameters
----------
c
The concentrations in the old definition (> 0).
slope
:math:`s = d\ln c/d\ln M`, in the old definition.
ln_q
:math:`\ln q = \ln(\rho_{\rm new}/\rho_{\rm old})`.

Returns
-------
ln_mass_ratio, c_new, dln_m_new_dln_m, dln_c_new_dln_m : numpy.ndarray
As for :func:`profile_convert`.
"""
c = np.asarray(c, dtype=np.float64)
ln_q = np.asarray(ln_q, dtype=np.float64)
return profile_convert(nfw_ln_mu_and_slope, c, slope, ln_q, nfw_start(c, ln_q), NEWTON_STEPS)
29 changes: 11 additions & 18 deletions src/hmf/core/concentration.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
Every relation is a :class:`ConcentrationModel`, a model kind with its own registry
(``ConcentrationModel.get("Duffy08")``), so other packages (e.g. halomod) add theirs
by subclassing it. Each declares a :attr:`~ConcentrationModel.valid_domain` (outside
which it raises) and a :attr:`~ConcentrationModel.calibration_domain`, in its own
mass definition.
which it raises) and a calibration domain, in its own mass definition: an instance's
is :attr:`~ConcentrationModel.calibration` (it can depend on the instance's
parameters; see :class:`~hmf.core.domain.Calibrated`).

Implemented:

Expand All @@ -34,7 +35,7 @@
from . import _references as refs
from ._fields import field
from ._validators import one_of
from .domain import Domain, check_extent
from .domain import Calibrated, Domain, check_extent
from .mass_definitions import MassDefinition, SOCritical, SOMean, SOVirial
from .model import Model
from .units import Msun_h, unit_boundary
Expand All @@ -46,7 +47,7 @@


@attrs.frozen(kw_only=True)
class ConcentrationModel(Model, kind=True):
class ConcentrationModel(Calibrated, Model, kind=True):
r"""The kind of concentration-mass relations: :math:`c(M, z)`.

Subclasses implement :meth:`_ln_concentration_and_slope` (on checked plain arrays)
Expand All @@ -60,27 +61,19 @@ class ConcentrationModel(Model, kind=True):
{"m": (0 * Msun_h, None, "(]"), "z": (0, None)}, source="m > 0, z >= 0."
)

#: Where the relation was calibrated, with its source (``None`` if it was not).
calibration_domain: ClassVar[Domain | None] = None

@property
@abc.abstractmethod
def mass_definition(self) -> MassDefinition:
"""The mass definition the relation's masses and concentrations are in."""

@property
def calibration(self) -> Domain | None:
"""The calibration domain of this instance (the class's, unless it depends on it)."""
return type(self).calibration_domain

@abc.abstractmethod
def _ln_concentration_and_slope(
self, m: FloatArray, z: FloatArray
) -> tuple[FloatArray, FloatArray]:
"""Ln c and dln c / dln m, on checked plain arrays (m in Msun/h)."""

def ln_concentration_and_slope_kernel(
self, m: npt.ArrayLike, *, z: npt.ArrayLike
self, *, m: npt.ArrayLike, z: npt.ArrayLike
) -> tuple[FloatArray, FloatArray]:
r"""Ln c(M, z) and its slope :math:`d\ln c/d\ln M`, at kernel level.

Expand Down Expand Up @@ -112,7 +105,7 @@ def ln_concentration_and_slope_kernel(
np.array(np.broadcast_to(slope, shape)),
)

def in_calibration_domain_kernel(self, m: npt.ArrayLike, *, z: npt.ArrayLike) -> BoolArray:
def in_calibration_domain_kernel(self, *, m: npt.ArrayLike, z: npt.ArrayLike) -> BoolArray:
"""Whether (m, z) is inside :attr:`calibration`, at kernel level.

Parameters
Expand Down Expand Up @@ -145,7 +138,7 @@ def in_calibration_domain_kernel(self, m: npt.ArrayLike, *, z: npt.ArrayLike) ->
)

@unit_boundary(m=Msun_h)
def concentration(self, m: Any, *, z: npt.ArrayLike) -> FloatArray:
def concentration(self, *, m: Any, z: npt.ArrayLike) -> FloatArray:
"""The median concentration c(M, z), in :attr:`mass_definition`.

Parameters
Expand All @@ -165,7 +158,7 @@ def concentration(self, m: Any, *, z: npt.ArrayLike) -> FloatArray:
DomainError
Outside :attr:`valid_domain`.
"""
return np.exp(self.ln_concentration_and_slope_kernel(m, z=z)[0])
return np.exp(self.ln_concentration_and_slope_kernel(m=m, z=z)[0])


# ---------------------------------------------------------------------------------
Expand Down Expand Up @@ -259,8 +252,8 @@ class Duffy08(ConcentrationModel, alias="Duffy08"):
doc="The redshift range the set was fitted to: '0-2', or '0' (with C = 0).",
)

#: The calibration domain of the default set (z = 0-2); :attr:`calibration` is that
#: of the instance's set.
#: The calibration domain of the default set (z = 0-2): ask an instance for
#: :attr:`calibration`, that of its set.
calibration_domain: ClassVar[Domain | None] = _DUFFY08_DOMAINS["0-2"]

#: The pivot mass, in Msun/h.
Expand Down
48 changes: 40 additions & 8 deletions src/hmf/core/domain.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
"""Model domains, the domain policy, and the errors of :mod:`hmf.core`.

Each model carries two domains, as class variables:
Each model carries two domains:

``valid_domain``
Where the model's formula is mathematically defined or sensible (e.g. sigma > 0).
Outside it, evaluation **always raises** a :class:`DomainError`.
``calibration_domain``
Where the model was calibrated against simulations, or ``None`` if it has none.
Outside it, results are extrapolations, handled according to the user's
:data:`DomainPolicy`.
Where the model's formula is mathematically defined or sensible (e.g. sigma > 0),
a class variable. Outside it, evaluation **always raises** a :class:`DomainError`.
``calibration``
Where the model was calibrated against simulations, or ``None`` if it has none:
an instance property (:attr:`Calibrated.calibration`), since it can depend on the
model's parameters (e.g. :class:`~hmf.core.concentration.Duffy08`'s redshift
range). Outside it, results are extrapolations, handled according to the user's
:data:`DomainPolicy`. By default it is the class variable ``calibration_domain``,
the calibration of the model's default parameters.

Every model kind with a calibration domain (fits, concentration-mass relations,
density profiles, filters, transfer and growth models) is a :class:`Calibrated`:
ask an instance for ``model.calibration``.

This module provides the :class:`Domain` type describing either (with
:meth:`Domain.describe` and :meth:`Domain.check`), :func:`apply_domain_policy`,
Expand Down Expand Up @@ -52,7 +59,7 @@
from collections.abc import Mapping
from functools import cached_property
from types import MappingProxyType
from typing import Any, Literal, get_args
from typing import Any, ClassVar, Literal, get_args

import astropy.units as u
import attrs
Expand All @@ -64,6 +71,7 @@

__all__ = [
"DOMAIN_POLICIES",
"Calibrated",
"Domain",
"DomainError",
"DomainPolicy",
Expand Down Expand Up @@ -402,6 +410,30 @@ def check(self, values: Mapping[str, Any], *, where: str) -> None:
)


class Calibrated:
"""The calibration domain of a model kind (a mixin, listed before the kind's base).

A kind sets :attr:`calibration_domain` (``None`` by default); a model whose
calibration depends on its parameters overrides :attr:`calibration`.
"""

__slots__ = ()

#: The calibration domain of the class, with its source (``None`` if it was not
#: calibrated): that of its default parameters. Ask an instance for
#: :attr:`calibration`.
calibration_domain: ClassVar[Domain | None] = None

@property
def calibration(self) -> Domain | None:
"""Where this model was calibrated, with its source (``None`` if it was not).

The class's :attr:`calibration_domain`, unless it depends on the instance's
parameters.
"""
return type(self).calibration_domain


def check_extent(
name: str,
x: npt.ArrayLike,
Expand Down
Loading
Loading