from __future__ import annotations
import logging
from dataclasses import asdict, dataclass, field
from typing import TYPE_CHECKING, Any, get_args
import numpy as np
import yaml
from deprecated import deprecated
from yaw.config import OPTIONS
from yaw.config import default as DEFAULT
from yaw.config import utils
from yaw.config.abc import BaseConfig
from yaw.config.backend import BackendConfig
from yaw.config.binning import BinningConfig
from yaw.config.scales import ScalesConfig
from yaw.core.cosmology import TypeCosmology, get_default_cosmology, r_kpc_to_angle
from yaw.core.docs import Parameter
if TYPE_CHECKING: # pragma: no cover
from matplotlib.figure import Figure
from numpy.typing import ArrayLike, NDArray
from yaw.catalogs import BaseCatalog
from yaw.core.utils import TypePathStr
__all__ = ["Configuration"]
logger = logging.getLogger(__name__)
[docs]
@dataclass(frozen=True)
class Configuration(BaseConfig):
"""The central configration for correlation measurements.
Bundles the configuration of measurement scales, redshift binning, and
backend parameters in a single, hierarchical configuration class.
Additionally holds the cosmological model used for distance calculations.
.. Note::
The structure and meaning of the parameters is described in more detail
in the specialised configuration objects :obj:`ScalesConfig`,
:obj:`BinningConfig`, :obj:`BackendConfig`, which are stored as class
attributes :obj:`scales`, :obj:`binning`, and :obj:`backend`.
To access e.g. the lower measurement scale limit, use
>>> Configuration.scales.rmin
...
which accesses the :obj:`ScalesConfig.rmin` attribute.
A new instance should be constructed with the :meth:`create` method or
as a modified variant with the :meth:`modify` method.
Args:
scales (:obj:`~yaw.config.ScalesConfig`):
The configuration of the measurement scales.
binning (:obj:`~yaw.config.BinningConfig`):
The redshift binning configuration.
backend (:obj:`~yaw.config.BackendConfig`):
The backend-specific configuration.
cosmology (:obj:`astropy.cosmology.FLRW`, :obj:`~yaw.core.cosmology.CustomCosmology`, :obj:`str`, :obj:`None`, optional)
The cosmological model for distance calculations.
"""
scales: ScalesConfig
"""The configuration of the measurement scales."""
binning: BinningConfig
"""The redshift binning configuration."""
backend: BackendConfig = field(default_factory=BackendConfig)
"""The backend-specific configuration."""
cosmology: TypeCosmology | str | None = field(
default=DEFAULT.Configuration.cosmology,
metadata=Parameter(
type=str,
choices=OPTIONS.cosmology,
help="cosmological model used for distance calculations",
default_text="(see astropy.cosmology, default: %(default)s)",
),
)
"""The cosmological model for distance calculations."""
def __post_init__(self) -> None:
# parse cosmology
if self.cosmology is None:
cosmology = get_default_cosmology()
elif isinstance(self.cosmology, str):
cosmology = utils.yaml_to_cosmology(self.cosmology)
elif not isinstance(self.cosmology, get_args(TypeCosmology)):
which = ", ".join(str(c) for c in get_args(TypeCosmology))
raise utils.ConfigError(f"'cosmology' must be instance of: {which}")
else:
cosmology = self.cosmology
cosmology = utils.parse_cosmology(self.cosmology)
object.__setattr__(self, "cosmology", cosmology)
[docs]
@classmethod
def create(
cls,
*,
cosmology: TypeCosmology | str | None = DEFAULT.Configuration.cosmology,
# ScalesConfig
rmin: ArrayLike,
rmax: ArrayLike,
rweight: float | None = DEFAULT.Configuration.scales.rweight,
rbin_num: int = DEFAULT.Configuration.scales.rbin_num,
# BinningConfig
zmin: ArrayLike = None,
zmax: ArrayLike = None,
zbin_num: int | None = DEFAULT.Configuration.binning.zbin_num,
method: str = DEFAULT.Configuration.binning.method,
zbins: NDArray[np.float64] | None = None,
# BackendConfig
thread_num: int | None = DEFAULT.Configuration.backend.thread_num,
crosspatch: bool = DEFAULT.Configuration.backend.crosspatch,
rbin_slop: float = DEFAULT.Configuration.backend.rbin_slop,
) -> Configuration:
"""Create a new configuration object.
Except for the ``cosmology`` parameter, all other parameters are passed
to the constructors of the respective :obj:`ScalesConfig`,
:obj:`BinningConfig`, and :obj:`BackendConfig` classes.
.. Note::
If custom bin edges are provided through the ``zbins`` parameter,
``zmin``, ``zmax``, ``zbin_num`` (optional), and ``method``
(optional) are ignored. Otherwise, at least ``zmin``, ``zmax`` are
required and a binning will be generated automatically.
Otherwise, only ``rmin`` and ``rmax`` are required arguments, e.g.:
>>> yaw.Configuration.create(rmin=100, rmax=1000, zmin=0.1, zmax=1.0)
Keyword Args:
cosmology (:obj:`astropy.cosmology.FLRW`, :obj:`~yaw.core.cosmology.CustomCosmology`, :obj:`str`, :obj:`None`, optional):
Named astropy cosmology used to compute distances. For options
see :obj:`~yaw.config.options.Options.cosmology`.
rmin (:obj:`ArrayLike`):
(List of) lower scale limit in kpc (pyhsical).
rmax (:obj:`ArrayLike`):
(List of) upper scale limit in kpc (pyhsical).
rweight (:obj:`float`, optional):
Weight galaxy pairs by their separation to power 'rweight'.
rbin_num (:obj:`int`, optional):
Number of bins in log r used (i.e. resolution) to compute
distance weights.
zmin (:obj:`float`):
Lower redshift limit.
zmax (:obj:`float`):
Upper redshift limit.
zbin_num (:obj:`int`, optional):
Number of redshift bins
method (:obj:`str`, optional):
Method used to generate the redshift binning. For options see
:obj:`~yaw.config.options.Options.binning`.
zbins (:obj:`NDArray`, optional):
Manually define redshift bin edges.
thread_num (:obj:`int`, optional):
Default number of threads to use.
crosspatch (:obj:`bool`, optional):
whether to count pairs across patch boundaries (scipy backend
only)
rbin_slop (:obj:`float`, optional):
TreeCorr 'rbin_slop' parameter
Returns:
:obj:`Configuration`
"""
cosmology = utils.parse_cosmology(cosmology)
scales = ScalesConfig.create(
rmin=rmin, rmax=rmax, rweight=rweight, rbin_num=rbin_num
)
binning = BinningConfig.create(
cosmology=cosmology,
zmin=zmin,
zmax=zmax,
zbin_num=zbin_num,
method=method,
zbins=zbins,
)
backend = BackendConfig.create(
thread_num=thread_num, crosspatch=crosspatch, rbin_slop=rbin_slop
)
return cls(scales=scales, binning=binning, backend=backend, cosmology=cosmology)
[docs]
def modify(
self,
*,
cosmology: TypeCosmology | str | None = DEFAULT.NotSet,
# ScalesConfig
rmin: ArrayLike | None = DEFAULT.NotSet,
rmax: ArrayLike | None = DEFAULT.NotSet,
rweight: float | None = DEFAULT.NotSet,
rbin_num: int | None = DEFAULT.NotSet,
# BinningConfig
zmin: float | None = DEFAULT.NotSet,
zmax: float | None = DEFAULT.NotSet,
zbin_num: int | None = DEFAULT.NotSet,
method: str | None = DEFAULT.NotSet,
zbins: NDArray[np.float64] | None = DEFAULT.NotSet,
# BackendConfig
thread_num: int | None = DEFAULT.NotSet,
crosspatch: bool | None = DEFAULT.NotSet,
rbin_slop: float | None = DEFAULT.NotSet,
) -> Configuration:
if cosmology is DEFAULT.NotSet:
cosmology = self.cosmology
elif isinstance(cosmology, str):
cosmology = utils.yaml_to_cosmology(cosmology)
scales = self.scales.modify(
rmin=rmin, rmax=rmax, rweight=rweight, rbin_num=rbin_num
)
binning = self.binning.modify(
zmin=zmin,
zmax=zmax,
method=method,
zbin_num=zbin_num,
zbins=zbins,
cosmology=cosmology,
)
backend = self.backend.modify(
thread_num=thread_num, crosspatch=crosspatch, rbin_slop=rbin_slop
)
return self.__class__(
cosmology=cosmology, scales=scales, binning=binning, backend=backend
)
[docs]
@deprecated(reason="no longer maintained", version="2.5.3")
def plot_scales(
self, catalog: BaseCatalog, log: bool = True, legend: bool = True
) -> Figure: # pragma: no cover
"""Plot the configured correlation scales at different redshifts in
comparison to the size of patches in a data catalogue.
.. deprecated:: 2.5.3
No longer maintained.
"""
import matplotlib.pyplot as plt
fig, ax_scale = plt.subplots(1, 1)
# plot scale of annulus
for r_min, r_max in self.scales.as_array():
ang_min, ang_max = np.transpose(
[
r_kpc_to_angle([r_min, r_max], z, self.cosmology)
for z in self.binning.zbins
]
)
ax_scale.fill_between(
self.binning.zbins,
ang_min,
ang_max,
step="post",
alpha=0.3,
label=rf"${r_min:.0f} < r \leq {r_max:.0f}$ kpc",
)
if legend:
ax_scale.legend(loc="lower right")
# plot patch sizes
ax_patch = ax_scale.twiny()
bins = np.histogram_bin_edges(catalog.radii)
if log:
ax_patch.set_yscale("log")
bins = np.logspace(
np.log10(bins[0]), np.log10(bins[-1]), len(bins), base=10.0
)
ax_patch.hist(
catalog.radii, bins, orientation="horizontal", color="k", alpha=0.5
)
# decorate
ax_scale.set_xlim(self.binning.zmin, self.binning.zmax)
ax_scale.set_ylabel("Radius / rad")
ax_scale.set_xlabel("Redshift")
ax_patch.set_xlabel("Patch count")
return fig
[docs]
@classmethod
def from_dict(cls, the_dict: dict[str, Any], **kwargs) -> Configuration:
config = {k: v for k, v in the_dict.items()}
cosmology = utils.parse_cosmology(
config.pop("cosmology", DEFAULT.Configuration.cosmology)
)
# parse the required subgroups
try:
scales_dict = config.pop("scales")
scales = ScalesConfig.from_dict(scales_dict)
except (TypeError, KeyError) as e:
utils.parse_section_error(e, "scales")
try:
binning_dict = config.pop("binning")
binning = BinningConfig.from_dict(binning_dict, cosmology=cosmology)
except (TypeError, KeyError) as e:
utils.parse_section_error(e, "binning")
# parse the optional subgroups
try:
backend_dict = config.pop("backend")
backend = BackendConfig.from_dict(backend_dict)
except KeyError:
backend = BackendConfig()
except TypeError as e:
utils.parse_section_error(e, "backend")
# check that there are no entries left
if len(config) > 0:
key = next(iter(config.keys()))
raise utils.ConfigError(f"encountered unknown section '{key}'")
return cls(scales=scales, binning=binning, backend=backend, cosmology=cosmology)
[docs]
def to_dict(self) -> dict[str, Any]:
values = dict()
for attr in asdict(self):
value = getattr(self, attr) # avoid asdict() recursion
if attr == "cosmology":
values[attr] = utils.cosmology_to_yaml(value)
else:
values[attr] = value.to_dict()
return values
[docs]
@classmethod
def from_yaml(cls, path: TypePathStr) -> Configuration:
"""Create a new instance by loading the configuration from a YAML file.
Args:
path (:obj:`pathlib.Path`, :obj:`str`):
Path to the YAML file containing the configuration.
Returns:
:obj:`Configuration`
"""
logger.info("reading configuration file '%s'", path)
with open(str(path)) as f:
config = yaml.safe_load(f.read())
return cls.from_dict(config)
[docs]
def to_yaml(self, path: TypePathStr) -> None:
"""Store the configuration as YAML file.
Args:
path (:obj:`pathlib.Path`, :obj:`str`):
Path to which the YAML file is written.
"""
logger.info("writing configuration file '%s'", path)
string = yaml.dump(self.to_dict())
with open(str(path), "w") as f:
f.write(string)